nstantpage-agent 0.5.7 → 0.5.9

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,91 @@ 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: API server + file fetch + tunnel connect (all 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
+ const tunnel = new TunnelClient({
592
+ gatewayUrl: opts.gatewayUrl,
593
+ token: opts.token,
594
+ projectId,
595
+ apiPort: allocated.apiPort,
596
+ devPort: allocated.devPort,
597
+ });
598
+ // Launch API server, tunnel, and file fetch all in parallel
599
+ const apiServerPromise = localServer.start().then(() => {
600
+ timings['api-server'] = Date.now() - t0;
601
+ console.log(chalk.green(` āœ“ API server on port ${allocated.apiPort} (${timings['api-server']}ms)`));
602
+ });
603
+ const tunnelPromise = (async () => {
604
+ try {
605
+ await tunnel.connect();
606
+ timings['tunnel'] = Date.now() - t0;
607
+ console.log(chalk.green(` āœ“ Tunnel connected for project ${projectId} (${timings['tunnel']}ms)`));
608
+ }
609
+ catch (err) {
610
+ console.log(chalk.yellow(` ⚠ Tunnel: ${err.message}`));
611
+ tunnel.startBackgroundReconnect();
612
+ }
613
+ })();
614
+ const fileStart = Date.now();
569
615
  try {
570
616
  await fetchProjectFiles(opts.backendUrl, projectId, projectDir, opts.token);
571
- progress('fetching-files', 'Project files downloaded');
617
+ timings['fetch-files'] = Date.now() - fileStart;
618
+ progress('fetching-files', `Files downloaded (${timings['fetch-files']}ms)`);
572
619
  }
573
620
  catch (err) {
574
621
  console.log(chalk.yellow(` ⚠ Could not fetch files: ${err.message}`));
575
622
  progress('fetching-files', `Warning: ${err.message}`);
576
623
  }
577
- // Install dependencies (must complete before dev server can start)
624
+ // Wait for API server before proceeding (tunnel can keep connecting in background)
625
+ await apiServerPromise;
626
+ progress('starting-server', `API server ready`);
627
+ // ── Phase 2: Install deps (if needed) ────────────────────────────
578
628
  const installer = new PackageInstaller({ projectDir });
579
- if (!installer.areDependenciesInstalled() && fs.existsSync(path.join(projectDir, 'package.json'))) {
629
+ const needsInstall = !installer.areDependenciesInstalled() && fs.existsSync(path.join(projectDir, 'package.json'));
630
+ if (needsInstall) {
631
+ const installStart = Date.now();
580
632
  console.log(chalk.gray(` Installing dependencies...`));
581
633
  progress('installing-deps', 'Installing npm dependencies...');
582
634
  try {
583
635
  await installer.ensureDependencies();
584
- console.log(chalk.green(` āœ“ Dependencies installed`));
585
- progress('installing-deps', 'Dependencies installed');
636
+ timings['install'] = Date.now() - installStart;
637
+ console.log(chalk.green(` āœ“ Dependencies installed (${timings['install']}ms)`));
638
+ progress('installing-deps', `Dependencies installed (${timings['install']}ms)`);
586
639
  }
587
640
  catch (err) {
641
+ timings['install'] = Date.now() - installStart;
588
642
  console.log(chalk.red(` āœ— Install failed: ${err.message}`));
589
643
  console.log(chalk.yellow(` ⚠ Dev server may fail — retrying install on first request`));
590
644
  progress('installing-deps', `Install failed: ${err.message}`);
591
645
  }
592
646
  }
593
647
  else {
594
- progress('installing-deps', 'Dependencies already installed');
648
+ progress('installing-deps', 'Dependencies already installed (cached)');
595
649
  }
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,
601
- });
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)
650
+ // ── Phase 3: Start dev server ────────────────────────────────────
606
651
  if (!opts.noDev) {
607
652
  if (installer.areDependenciesInstalled()) {
608
653
  progress('starting-dev', 'Starting dev server...');
609
654
  try {
655
+ const devStart = Date.now();
610
656
  await localServer.getDevServer().start();
611
- console.log(chalk.green(` āœ“ Dev server on port ${allocated.devPort}`));
657
+ timings['dev-server'] = Date.now() - devStart;
658
+ console.log(chalk.green(` āœ“ Dev server on port ${allocated.devPort} (${timings['dev-server']}ms)`));
612
659
  progress('starting-dev', `Dev server on port ${allocated.devPort}`);
613
660
  }
614
661
  catch (err) {
@@ -622,40 +669,22 @@ async function startAdditionalProject(projectId, opts) {
622
669
  progress('starting-dev', 'Skipped — dependencies not installed');
623
670
  }
624
671
  }
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!');
672
+ // Ensure tunnel is connected before declaring ready
673
+ await tunnelPromise;
674
+ // Register project with backend (non-blocking)
675
+ fetch(`${opts.backendUrl}/api/agent/register`, {
676
+ method: 'POST',
677
+ headers: { 'Authorization': `Bearer ${opts.token}`, 'Content-Type': 'application/json' },
678
+ body: JSON.stringify({
679
+ deviceId: opts.deviceId, name: os.hostname(), hostname: os.hostname(),
680
+ platform: `${os.platform()} ${os.arch()}`, agentVersion: VERSION,
681
+ projectId, capabilities: ['file-sync', 'type-check', 'install', 'terminal', 'dev-server'],
682
+ }),
683
+ }).catch(() => { });
684
+ const totalTime = Date.now() - t0;
685
+ console.log(chalk.green(` āœ“ Project ${projectId} is live! (total: ${totalTime}ms)\n`));
686
+ console.log(chalk.gray(` Timings: ${JSON.stringify(timings)}`));
687
+ progress('ready', `Project is live! (${totalTime}ms)`);
659
688
  return { localServer, tunnel };
660
689
  }
661
690
  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.9",
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": {