nstantpage-agent 0.5.7 → 0.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -25,7 +25,7 @@ const program = new Command();
25
25
  program
26
26
  .name('nstantpage')
27
27
  .description('Local development agent for nstantpage.com — run projects on your machine, preview in the cloud')
28
- .version('0.5.7');
28
+ .version('0.5.8');
29
29
  program
30
30
  .command('login')
31
31
  .description('Authenticate with nstantpage.com')
@@ -24,7 +24,7 @@ import { getConfig, getProjectConfig, setProjectConfig, clearProjectConfig, getD
24
24
  import { TunnelClient } from '../tunnel.js';
25
25
  import { LocalServer } from '../localServer.js';
26
26
  import { PackageInstaller } from '../packageInstaller.js';
27
- const VERSION = '0.5.7';
27
+ const VERSION = '0.5.8';
28
28
  /**
29
29
  * Resolve the backend API base URL.
30
30
  * - If --backend is passed, use it
@@ -82,16 +82,29 @@ async function fetchProjectFiles(backendUrl, projectId, projectDir, token) {
82
82
  console.log(chalk.gray(` Files up-to-date (version ${data.version})`));
83
83
  return { fileCount: data.files.length, isNew: false };
84
84
  }
85
- // Write all files to disk
86
- let written = 0;
85
+ // Write all files to disk in parallel (batch I/O for speed)
86
+ // First, collect unique directories to create
87
+ const dirsToCreate = new Set();
87
88
  for (const file of data.files) {
88
89
  const filePath = path.join(projectDir, file.path);
89
- const dir = path.dirname(filePath);
90
+ dirsToCreate.add(path.dirname(filePath));
91
+ }
92
+ // Create all directories first (sync is fine, there are few unique dirs)
93
+ for (const dir of dirsToCreate) {
90
94
  if (!fs.existsSync(dir)) {
91
95
  fs.mkdirSync(dir, { recursive: true });
92
96
  }
93
- fs.writeFileSync(filePath, file.content, 'utf-8');
94
- written++;
97
+ }
98
+ // Write files in parallel batches of 50
99
+ const BATCH_SIZE = 50;
100
+ let written = 0;
101
+ for (let i = 0; i < data.files.length; i += BATCH_SIZE) {
102
+ const batch = data.files.slice(i, i + BATCH_SIZE);
103
+ await Promise.all(batch.map(async (file) => {
104
+ const filePath = path.join(projectDir, file.path);
105
+ await fs.promises.writeFile(filePath, file.content, 'utf-8');
106
+ }));
107
+ written += batch.length;
95
108
  }
96
109
  // Write version marker
97
110
  fs.writeFileSync(versionFile, String(data.versionId), 'utf-8');
@@ -215,8 +228,8 @@ export async function startCommand(directory, options) {
215
228
  conf.set('projectId', projectId);
216
229
  // Kill any leftover agent for THIS PROJECT only (not other projects)
217
230
  cleanupPreviousAgent(projectId, apiPort, devPort);
218
- // Small delay to let ports release
219
- await new Promise(r => setTimeout(r, 300));
231
+ // Brief pause for OS to release ports (50ms is sufficient on macOS/Linux)
232
+ await new Promise(r => setTimeout(r, 50));
220
233
  console.log(chalk.blue(`\nšŸš€ nstantpage agent v${VERSION}\n`));
221
234
  console.log(chalk.gray(` Project ID: ${projectId}`));
222
235
  console.log(chalk.gray(` Device ID: ${deviceId.slice(0, 12)}...`));
@@ -558,57 +571,97 @@ async function startStandbyMode(token, options, backendUrl, deviceId) {
558
571
  */
559
572
  async function startAdditionalProject(projectId, opts) {
560
573
  const progress = opts.onProgress || (() => { });
574
+ const timings = {};
575
+ const t0 = Date.now();
561
576
  console.log(chalk.blue(`\n šŸ“¦ Starting project ${projectId}...`));
562
- progress('fetching-files', 'Fetching project files...');
563
577
  try {
564
578
  const allocated = allocatePortsForProject(projectId);
565
579
  const projectDir = resolveProjectDir('.', projectId);
566
580
  if (!fs.existsSync(projectDir))
567
581
  fs.mkdirSync(projectDir, { recursive: true });
568
- // Fetch project files
582
+ // Ensure ports are clear for this project before starting
583
+ cleanupPreviousAgent(projectId, allocated.apiPort, allocated.devPort);
584
+ await new Promise(r => setTimeout(r, 50));
585
+ // ── Phase 1: Fetch files + start API server in parallel ──────────
586
+ progress('fetching-files', 'Fetching project files...');
587
+ const localServer = new LocalServer({
588
+ projectDir, projectId,
589
+ apiPort: allocated.apiPort, devPort: allocated.devPort,
590
+ });
591
+ // Start API server immediately (doesn't need project files)
592
+ const apiServerPromise = localServer.start().then(() => {
593
+ timings['api-server'] = Date.now() - t0;
594
+ console.log(chalk.green(` āœ“ API server on port ${allocated.apiPort} (${timings['api-server']}ms)`));
595
+ });
596
+ // Fetch project files concurrently with API server start
597
+ const fileStart = Date.now();
569
598
  try {
570
599
  await fetchProjectFiles(opts.backendUrl, projectId, projectDir, opts.token);
571
- progress('fetching-files', 'Project files downloaded');
600
+ timings['fetch-files'] = Date.now() - fileStart;
601
+ progress('fetching-files', `Files downloaded (${timings['fetch-files']}ms)`);
572
602
  }
573
603
  catch (err) {
574
604
  console.log(chalk.yellow(` ⚠ Could not fetch files: ${err.message}`));
575
605
  progress('fetching-files', `Warning: ${err.message}`);
576
606
  }
577
- // Install dependencies (must complete before dev server can start)
607
+ // Wait for API server before proceeding
608
+ await apiServerPromise;
609
+ progress('starting-server', `API server ready`);
610
+ // ── Phase 2: Install deps (if needed) ────────────────────────────
578
611
  const installer = new PackageInstaller({ projectDir });
579
- if (!installer.areDependenciesInstalled() && fs.existsSync(path.join(projectDir, 'package.json'))) {
612
+ const needsInstall = !installer.areDependenciesInstalled() && fs.existsSync(path.join(projectDir, 'package.json'));
613
+ if (needsInstall) {
614
+ const installStart = Date.now();
580
615
  console.log(chalk.gray(` Installing dependencies...`));
581
616
  progress('installing-deps', 'Installing npm dependencies...');
582
617
  try {
583
618
  await installer.ensureDependencies();
584
- console.log(chalk.green(` āœ“ Dependencies installed`));
585
- progress('installing-deps', 'Dependencies installed');
619
+ timings['install'] = Date.now() - installStart;
620
+ console.log(chalk.green(` āœ“ Dependencies installed (${timings['install']}ms)`));
621
+ progress('installing-deps', `Dependencies installed (${timings['install']}ms)`);
586
622
  }
587
623
  catch (err) {
624
+ timings['install'] = Date.now() - installStart;
588
625
  console.log(chalk.red(` āœ— Install failed: ${err.message}`));
589
626
  console.log(chalk.yellow(` ⚠ Dev server may fail — retrying install on first request`));
590
627
  progress('installing-deps', `Install failed: ${err.message}`);
591
628
  }
592
629
  }
593
630
  else {
594
- progress('installing-deps', 'Dependencies already installed');
631
+ progress('installing-deps', 'Dependencies already installed (cached)');
595
632
  }
596
- // Start local server
597
- progress('starting-server', 'Starting API server...');
598
- const localServer = new LocalServer({
599
- projectDir, projectId,
600
- apiPort: allocated.apiPort, devPort: allocated.devPort,
633
+ // ── Phase 3: Start dev server + connect tunnel in parallel ───────
634
+ const tunnel = new TunnelClient({
635
+ gatewayUrl: opts.gatewayUrl,
636
+ token: opts.token,
637
+ projectId,
638
+ apiPort: allocated.apiPort,
639
+ devPort: allocated.devPort,
601
640
  });
602
- await localServer.start();
603
- console.log(chalk.green(` āœ“ API server on port ${allocated.apiPort}`));
604
- progress('starting-server', `API server on port ${allocated.apiPort}`);
605
- // Start dev server (only if dependencies are installed)
641
+ // Connect tunnel immediately (don't wait for dev server — tunnel is needed for status updates)
642
+ const tunnelPromise = (async () => {
643
+ progress('connecting', 'Connecting tunnel to gateway...');
644
+ try {
645
+ await tunnel.connect();
646
+ timings['tunnel'] = Date.now() - t0;
647
+ console.log(chalk.green(` āœ“ Tunnel connected for project ${projectId} (${timings['tunnel']}ms)`));
648
+ progress('connecting', 'Tunnel connected');
649
+ }
650
+ catch (err) {
651
+ console.log(chalk.yellow(` ⚠ Tunnel: ${err.message}`));
652
+ tunnel.startBackgroundReconnect();
653
+ progress('connecting', `Warning: ${err.message} — reconnecting`);
654
+ }
655
+ })();
656
+ // Start dev server in parallel with tunnel connection
606
657
  if (!opts.noDev) {
607
658
  if (installer.areDependenciesInstalled()) {
608
659
  progress('starting-dev', 'Starting dev server...');
609
660
  try {
661
+ const devStart = Date.now();
610
662
  await localServer.getDevServer().start();
611
- console.log(chalk.green(` āœ“ Dev server on port ${allocated.devPort}`));
663
+ timings['dev-server'] = Date.now() - devStart;
664
+ console.log(chalk.green(` āœ“ Dev server on port ${allocated.devPort} (${timings['dev-server']}ms)`));
612
665
  progress('starting-dev', `Dev server on port ${allocated.devPort}`);
613
666
  }
614
667
  catch (err) {
@@ -622,40 +675,22 @@ async function startAdditionalProject(projectId, opts) {
622
675
  progress('starting-dev', 'Skipped — dependencies not installed');
623
676
  }
624
677
  }
625
- // Connect project tunnel
626
- progress('connecting', 'Connecting tunnel to gateway...');
627
- const tunnel = new TunnelClient({
628
- gatewayUrl: opts.gatewayUrl,
629
- token: opts.token,
630
- projectId,
631
- apiPort: allocated.apiPort,
632
- devPort: allocated.devPort,
633
- });
634
- try {
635
- await tunnel.connect();
636
- console.log(chalk.green(` āœ“ Tunnel connected for project ${projectId}`));
637
- progress('connecting', 'Tunnel connected');
638
- }
639
- catch (err) {
640
- console.log(chalk.yellow(` ⚠ Tunnel: ${err.message}`));
641
- tunnel.startBackgroundReconnect();
642
- progress('connecting', `Warning: ${err.message} — reconnecting`);
643
- }
644
- // Register project with backend
645
- try {
646
- await fetch(`${opts.backendUrl}/api/agent/register`, {
647
- method: 'POST',
648
- headers: { 'Authorization': `Bearer ${opts.token}`, 'Content-Type': 'application/json' },
649
- body: JSON.stringify({
650
- deviceId: opts.deviceId, name: os.hostname(), hostname: os.hostname(),
651
- platform: `${os.platform()} ${os.arch()}`, agentVersion: VERSION,
652
- projectId, capabilities: ['file-sync', 'type-check', 'install', 'terminal', 'dev-server'],
653
- }),
654
- });
655
- }
656
- catch { }
657
- console.log(chalk.green(` āœ“ Project ${projectId} is live!\n`));
658
- progress('ready', 'Project is live!');
678
+ // Wait for tunnel to be connected
679
+ await tunnelPromise;
680
+ // Register project with backend (non-blocking)
681
+ fetch(`${opts.backendUrl}/api/agent/register`, {
682
+ method: 'POST',
683
+ headers: { 'Authorization': `Bearer ${opts.token}`, 'Content-Type': 'application/json' },
684
+ body: JSON.stringify({
685
+ deviceId: opts.deviceId, name: os.hostname(), hostname: os.hostname(),
686
+ platform: `${os.platform()} ${os.arch()}`, agentVersion: VERSION,
687
+ projectId, capabilities: ['file-sync', 'type-check', 'install', 'terminal', 'dev-server'],
688
+ }),
689
+ }).catch(() => { });
690
+ const totalTime = Date.now() - t0;
691
+ console.log(chalk.green(` āœ“ Project ${projectId} is live! (total: ${totalTime}ms)\n`));
692
+ console.log(chalk.gray(` Timings: ${JSON.stringify(timings)}`));
693
+ progress('ready', `Project is live! (${totalTime}ms)`);
659
694
  return { localServer, tunnel };
660
695
  }
661
696
  catch (err) {
package/dist/devServer.js CHANGED
@@ -294,14 +294,15 @@ export class DevServer {
294
294
  resolve();
295
295
  });
296
296
  req.on('error', () => {
297
- setTimeout(check, 500);
297
+ setTimeout(check, 300);
298
298
  });
299
299
  req.setTimeout(2000, () => {
300
300
  req.destroy();
301
- setTimeout(check, 500);
301
+ setTimeout(check, 300);
302
302
  });
303
303
  };
304
- setTimeout(check, 1000);
304
+ // Start checking after a short delay (Vite can be ready in <500ms)
305
+ setTimeout(check, 300);
305
306
  });
306
307
  }
307
308
  }
@@ -203,7 +203,8 @@ export class LocalServer {
203
203
  '/live/normalize': this.handleNormalize,
204
204
  '/live/normalize-batch': this.handleNormalizeBatch,
205
205
  '/live/invalidate': this.handleInvalidate,
206
- '/live/refetch': this.handleRefetch,
206
+ '/live/refetch': this.handleRefetch, // Legacy — no-op in agent mode
207
+ '/live/reset': this.handleRefetch, // New push-based reset — also no-op in agent mode
207
208
  '/live/grace-period': this.handleGracePeriod,
208
209
  '/live/stats': this.handleStats,
209
210
  '/live/usage': this.handleUsage,
@@ -591,7 +592,7 @@ export class LocalServer {
591
592
  connected: true,
592
593
  projectId: this.options.projectId,
593
594
  agent: {
594
- version: '0.5.4',
595
+ version: '0.5.8',
595
596
  hostname: os.hostname(),
596
597
  platform: `${os.platform()} ${os.arch()}`,
597
598
  },
@@ -21,8 +21,23 @@ export declare class PackageInstaller {
21
21
  /**
22
22
  * Ensure all project dependencies are installed.
23
23
  * Uses a lock to prevent concurrent installs.
24
+ * Skips install entirely if package.json + lockfile haven't changed (hash cache).
24
25
  */
25
26
  ensureDependencies(): Promise<void>;
27
+ /**
28
+ * Build the fastest possible install command:
29
+ * - npm ci (clean install from lockfile) if package-lock.json exists
30
+ * - npm install --prefer-offline otherwise
31
+ * - pnpm install --frozen-lockfile if pnpm
32
+ */
33
+ private buildFastInstallArgs;
34
+ /**
35
+ * Compute a hash of package.json + lockfile contents.
36
+ * Used to skip npm install when nothing has changed.
37
+ */
38
+ private computeDepsHash;
39
+ private isDepsHashMatch;
40
+ private writeDepsHash;
26
41
  /**
27
42
  * Check if dependencies are actually installed (not just that node_modules/ exists).
28
43
  * Verifies that at least one key dependency from package.json is present.
@@ -5,6 +5,7 @@
5
5
  import { spawn } from 'child_process';
6
6
  import path from 'path';
7
7
  import fs, { existsSync } from 'fs';
8
+ import crypto from 'crypto';
8
9
  export class PackageInstaller {
9
10
  projectDir;
10
11
  /** Serializes concurrent install calls */
@@ -39,26 +40,100 @@ export class PackageInstaller {
39
40
  /**
40
41
  * Ensure all project dependencies are installed.
41
42
  * Uses a lock to prevent concurrent installs.
43
+ * Skips install entirely if package.json + lockfile haven't changed (hash cache).
42
44
  */
43
45
  async ensureDependencies() {
44
46
  // Serialize with any in-flight install
45
47
  await this.installLock;
46
- if (this.areDependenciesInstalled())
48
+ // Fast path: check if deps are cached by hash
49
+ if (this.isDepsHashMatch()) {
50
+ console.log(` [Installer] Dependencies up-to-date (hash match) — skipping install`);
47
51
  return;
52
+ }
53
+ if (this.areDependenciesInstalled()) {
54
+ // node_modules exists and looks valid — write hash and skip
55
+ this.writeDepsHash();
56
+ return;
57
+ }
48
58
  // Acquire lock for this install
49
59
  let releaseLock;
50
60
  this.installLock = new Promise(resolve => { releaseLock = resolve; });
51
61
  try {
52
62
  console.log(` [Installer] Installing project dependencies...`);
53
63
  const pm = this.detectPackageManager();
54
- const args = pm === 'pnpm' ? ['install'] : ['install'];
64
+ const args = this.buildFastInstallArgs(pm);
55
65
  await this.runCommand(pm, args, 300_000);
66
+ this.writeDepsHash();
56
67
  console.log(` [Installer] Dependencies installed`);
57
68
  }
58
69
  finally {
59
70
  releaseLock();
60
71
  }
61
72
  }
73
+ /**
74
+ * Build the fastest possible install command:
75
+ * - npm ci (clean install from lockfile) if package-lock.json exists
76
+ * - npm install --prefer-offline otherwise
77
+ * - pnpm install --frozen-lockfile if pnpm
78
+ */
79
+ buildFastInstallArgs(pm) {
80
+ switch (pm) {
81
+ case 'pnpm': {
82
+ const hasLock = existsSync(path.join(this.projectDir, 'pnpm-lock.yaml'));
83
+ return hasLock ? ['install', '--frozen-lockfile', '--prefer-offline'] : ['install'];
84
+ }
85
+ case 'yarn': {
86
+ const hasLock = existsSync(path.join(this.projectDir, 'yarn.lock'));
87
+ return hasLock ? ['install', '--frozen-lockfile', '--prefer-offline'] : ['install'];
88
+ }
89
+ default: {
90
+ // npm: prefer ci (much faster) if lockfile exists
91
+ const hasLock = existsSync(path.join(this.projectDir, 'package-lock.json'));
92
+ return hasLock ? ['ci', '--prefer-offline'] : ['install', '--prefer-offline'];
93
+ }
94
+ }
95
+ }
96
+ /**
97
+ * Compute a hash of package.json + lockfile contents.
98
+ * Used to skip npm install when nothing has changed.
99
+ */
100
+ computeDepsHash() {
101
+ const hash = crypto.createHash('sha256');
102
+ const pkgPath = path.join(this.projectDir, 'package.json');
103
+ if (existsSync(pkgPath))
104
+ hash.update(fs.readFileSync(pkgPath));
105
+ // Include lockfile if present
106
+ for (const lockFile of ['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock']) {
107
+ const lockPath = path.join(this.projectDir, lockFile);
108
+ if (existsSync(lockPath)) {
109
+ hash.update(fs.readFileSync(lockPath));
110
+ break;
111
+ }
112
+ }
113
+ return hash.digest('hex').slice(0, 16);
114
+ }
115
+ isDepsHashMatch() {
116
+ const hashFile = path.join(this.projectDir, 'node_modules', '.nstantpage-deps-hash');
117
+ if (!existsSync(hashFile))
118
+ return false;
119
+ try {
120
+ const stored = fs.readFileSync(hashFile, 'utf-8').trim();
121
+ return stored === this.computeDepsHash();
122
+ }
123
+ catch {
124
+ return false;
125
+ }
126
+ }
127
+ writeDepsHash() {
128
+ const hashFile = path.join(this.projectDir, 'node_modules', '.nstantpage-deps-hash');
129
+ try {
130
+ const dir = path.dirname(hashFile);
131
+ if (!existsSync(dir))
132
+ fs.mkdirSync(dir, { recursive: true });
133
+ fs.writeFileSync(hashFile, this.computeDepsHash(), 'utf-8');
134
+ }
135
+ catch { }
136
+ }
62
137
  /**
63
138
  * Check if dependencies are actually installed (not just that node_modules/ exists).
64
139
  * Verifies that at least one key dependency from package.json is present.
package/dist/tunnel.js CHANGED
@@ -63,7 +63,7 @@ export class TunnelClient {
63
63
  // Send enhanced agent info with capabilities and deviceId
64
64
  this.send({
65
65
  type: 'agent-info',
66
- version: '0.5.4',
66
+ version: '0.5.8',
67
67
  hostname: os.hostname(),
68
68
  platform: `${os.platform()} ${os.arch()}`,
69
69
  deviceId: getDeviceId(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nstantpage-agent",
3
- "version": "0.5.7",
3
+ "version": "0.5.8",
4
4
  "description": "Local development agent for nstantpage.com — run your projects locally, preview in the cloud. Replaces cloud containers for faster builds.",
5
5
  "type": "module",
6
6
  "bin": {