@thomas.f/prnext 0.1.0-alpha.1 → 0.1.1-alpha

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.
@@ -1,7 +1,7 @@
1
1
  import { nodeFileTrace } from '@vercel/nft';
2
2
  import resolver from '@vercel/nft/out/resolve-dependency.js';
3
3
  import picomatch from 'picomatch';
4
- import { mkdir, readFile, writeFile, readdir, realpath, readlink, stat, lstat, copyFile, chmod, symlink } from 'node:fs/promises';
4
+ import { mkdir, readFile, writeFile, readdir, realpath, readlink, stat, lstat, copyFile, chmod, symlink, cp } from 'node:fs/promises';
5
5
  import path from 'node:path';
6
6
  import { resolveNativeBinary } from '../native/resolve.mjs';
7
7
  import { createHash } from 'node:crypto';
@@ -13,6 +13,15 @@ const missing = error => error.code === 'ENOENT' || error.code === 'ENOTDIR';
13
13
  const maxFiles = 100_000;
14
14
  const maxBytes = 2 * 1024 ** 3;
15
15
 
16
+ export async function resolveTraceDependency(...args) {
17
+ const result = await resolver.default(...args);
18
+ // NFT can return mixed separators for scoped packages on Windows. Its
19
+ // realpath traversal only normalizes paths on the tracing base's drive;
20
+ // other drives would then get the wrong package.json boundary.
21
+ const normalize = file => file.startsWith('node:') ? file : path.normalize(file);
22
+ return Array.isArray(result) ? result.map(normalize) : normalize(result);
23
+ }
24
+
16
25
  function patternsFor(rules, route) {
17
26
  return Object.entries(rules || {}).flatMap(([key, values]) => picomatch(key, { dot: true })(route) ? values : []);
18
27
  }
@@ -66,9 +75,20 @@ export async function createStandalone({ projectRoot, stage, manifest, config })
66
75
  const warnings = new Set();
67
76
  let omittedWarnings = 0;
68
77
  const cache = {};
78
+ const traceLocations = new Map();
69
79
  let traceReads = 0;
70
80
  let traceBytes = 0;
71
81
 
82
+ function traceLocation(file) {
83
+ if (!traceLocations.has(file)) traceLocations.set(file, (async () => {
84
+ // Expand Windows 8.3 names and filesystem casing, preserving the final
85
+ // symlink itself so workspace links can still be relocated portably.
86
+ if ((await lstat(file)).isSymbolicLink()) return path.join(await realpath(path.dirname(file)), path.basename(file));
87
+ return realpath(file);
88
+ })());
89
+ return traceLocations.get(file);
90
+ }
91
+
72
92
  function relocated(file) {
73
93
  if (within(stage, file)) return path.join(appOutput, config.distDir, path.relative(stage, file));
74
94
  if (within(tracingRoot, file)) return path.join(output, 'app', path.relative(tracingRoot, file));
@@ -109,19 +129,19 @@ export async function createStandalone({ projectRoot, stage, manifest, config })
109
129
  return readFile(file);
110
130
  },
111
131
  async resolve(specifier, parent, job, cjs) {
112
- const resolved = await resolver.default(specifier, parent, job, cjs);
132
+ const resolved = await resolveTraceDependency(specifier, parent, job, cjs);
113
133
  if (!specifier.startsWith('.') && !specifier.startsWith('#') && !path.isAbsolute(specifier) && !specifier.startsWith('node:')) {
114
134
  const name = specifier.split('/').slice(0, specifier.startsWith('@') ? 2 : 1).join('/');
115
135
  for (const file of Array.isArray(resolved) ? resolved : [resolved]) {
116
136
  if (file.startsWith('node:')) continue;
117
137
  const owner = await packageRoot(file, name);
118
- if (owner) bridges.set(path.join(path.dirname(parent), 'node_modules', name), owner);
138
+ if (owner) bridges.set(path.join(path.dirname(await realpath(parent)), 'node_modules', name), await realpath(owner));
119
139
  }
120
140
  }
121
141
  return resolved;
122
142
  },
123
143
  });
124
- for (const file of result.fileList) traceFiles.add(path.resolve(path.parse(projectRoot).root, file));
144
+ for (const file of result.fileList) traceFiles.add(await traceLocation(path.resolve(path.parse(projectRoot).root, file)));
125
145
  for (const warning of result.warnings) {
126
146
  const message = warning.message.replaceAll(projectRoot, '<project>').slice(0, 2048);
127
147
  if (warnings.has(message)) continue;
@@ -185,8 +205,14 @@ export async function createStandalone({ projectRoot, stage, manifest, config })
185
205
  let copiedFiles = 0;
186
206
  const pendingLinks = [];
187
207
  for (const source of traceFiles) {
188
- const target = relocated(source);
189
208
  const info = await lstat(source);
209
+ // NFT also emits ancestor aliases used by absolute workspace links, such as
210
+ // macOS /var -> /private/var. Their canonical children are already traced;
211
+ // these host directory aliases are not dependencies of the artifact.
212
+ // Real files and links to unrelated external workspaces still go through
213
+ // relocated(), which enforces the configured tracing boundary.
214
+ if (info.isSymbolicLink() && !within(tracingRoot, source) && within(await realpath(source), tracingRoot)) continue;
215
+ const target = relocated(source);
190
216
  if (++copiedFiles > maxFiles || (copiedBytes += info.size) > maxBytes) throw new Error('Standalone output exceeds 100000 files / 2 GiB. Narrow tracing includes.');
191
217
  if (info.isSymbolicLink()) {
192
218
  const destination = await realpath(source);
@@ -203,9 +229,35 @@ export async function createStandalone({ projectRoot, stage, manifest, config })
203
229
  for (const [source, owner] of bridges) {
204
230
  // An excluded optional dependency must not be brought back by a bridge.
205
231
  if (!includedOwners.has(owner)) continue;
206
- pendingLinks.push([relocated(source), relocated(owner)]);
232
+ const marker = `${path.sep}node_modules${path.sep}`;
233
+ const offset = source.lastIndexOf(marker);
234
+ pendingLinks.push([relocated(source), relocated(owner), source.slice(offset + marker.length), relocated(source.slice(0, offset))]);
207
235
  }
208
- for (const [link, target] of pendingLinks) {
236
+ const materialized = new Map();
237
+ const dependencyLinks = pendingLinks.filter(([, , packageName]) => packageName);
238
+ function original(file) {
239
+ for (const [copy, source] of materialized) {
240
+ if (within(copy, file)) return original(path.join(source, path.relative(copy, file)));
241
+ }
242
+ return file;
243
+ }
244
+ for (let [link, target, packageName, importer] of pendingLinks) {
245
+ if (process.platform === 'win32' && packageName) {
246
+ // Reuse Node's ancestor lookup before copying a bridge. Install shared
247
+ // hoisted dependencies once, keeping React and package singletons intact.
248
+ const search = createRequire(path.join(importer, '__prnext_trace.cjs')).resolve.paths(packageName)
249
+ .map(directory => path.join(directory, packageName)).filter(file => within(output, file));
250
+ const vacant = [];
251
+ let found;
252
+ for (const candidate of search) {
253
+ try { await lstat(candidate); found = candidate; break; }
254
+ catch (error) { if (!missing(error)) throw error; vacant.push(candidate); }
255
+ }
256
+ if (found && original(found) === original(target)) continue;
257
+ // If a nearer version shadows this owner, add the bridge before it.
258
+ link = found ? vacant[0] : vacant.at(-1);
259
+ if (!link) throw new Error(`Conflicting standalone dependency ${packageName} at ${importer}.`);
260
+ }
209
261
  if (link === target) continue;
210
262
  await mkdir(path.dirname(link), { recursive: true });
211
263
  try {
@@ -214,7 +266,26 @@ export async function createStandalone({ projectRoot, stage, manifest, config })
214
266
  if (current.isSymbolicLink() && path.resolve(path.dirname(link), await readlink(link)) === target) continue;
215
267
  throw new Error(`Conflicting standalone dependency at ${path.relative(output, link)}.`);
216
268
  } catch (error) { if (!missing(error)) throw error; }
217
- await symlink(path.relative(path.dirname(link), target), link, (await stat(target)).isDirectory() ? 'dir' : 'file');
269
+ if (process.platform === 'win32') {
270
+ // Junctions contain absolute targets and break when an artifact moves;
271
+ // symlinks require privileges. Copy only the already traced files.
272
+ await cp(target, link, { recursive: true, dereference: true, async filter(source) {
273
+ const info = await stat(source);
274
+ if (info.isFile() && (++copiedFiles > maxFiles || (copiedBytes += info.size) > maxBytes)) throw new Error('Standalone output exceeds 100000 files / 2 GiB. Narrow tracing includes.');
275
+ return true;
276
+ } });
277
+ materialized.set(link, target);
278
+ // A copied workspace package no longer resolves through its real path.
279
+ // Give imports inside that copy the same dependency owners as the source.
280
+ for (const [dependency, owner, name, parent] of dependencyLinks) {
281
+ if (within(target, parent)) pendingLinks.push([
282
+ path.join(link, path.relative(target, dependency)), owner, name,
283
+ path.join(link, path.relative(target, parent)),
284
+ ]);
285
+ }
286
+ } else {
287
+ await symlink(path.relative(path.dirname(link), target), link, (await stat(target)).isDirectory() ? 'dir' : 'file');
288
+ }
218
289
  }
219
290
  const binaryName = process.platform === 'win32' ? 'prnext.exe' : 'prnext';
220
291
  const binary = await resolveNativeBinary();
@@ -230,7 +301,7 @@ export async function createStandalone({ projectRoot, stage, manifest, config })
230
301
  await writeFile(path.join(appOutput, 'package.json'), packageJson);
231
302
  const relativeApp = slash(path.relative(output, appOutput));
232
303
  await writeFile(path.join(output, 'package.json'), '{"private":true,"type":"commonjs"}\n');
233
- await writeFile(path.join(output, 'server.js'), `'use strict';\nconst path=require('node:path');const {spawn}=require('node:child_process');\nconst root=path.join(__dirname,${JSON.stringify(relativeApp)});\nconst child=spawn(path.join(__dirname,'bin',${JSON.stringify(binaryName)}),['start',root,'--hostname',process.env.HOSTNAME||'0.0.0.0','--port',process.env.PORT||'3000','--workers',process.env.PRNEXT_WORKERS||'1','--node',process.execPath],{cwd:root,stdio:'inherit',env:{...process.env,NODE_ENV:'production'}});\nfor(const signal of ['SIGINT','SIGTERM'])process.on(signal,()=>child.kill(signal));\nchild.on('error',error=>{console.error(error.message);process.exitCode=1;});child.on('exit',(code,signal)=>{process.exitCode=code??(signal==='SIGINT'?130:signal==='SIGTERM'?143:1);});\n`);
304
+ await writeFile(path.join(output, 'server.js'), `'use strict';\nconst path=require('node:path');const {spawn}=require('node:child_process');\nconst root=path.join(__dirname,${JSON.stringify(relativeApp)});\nconst child=spawn(path.join(__dirname,'bin',${JSON.stringify(binaryName)}),['start',root,'--hostname',process.env.HOSTNAME||'0.0.0.0','--port',process.env.PORT||'3000','--workers',process.env.PRNEXT_WORKERS||'1','--node',process.execPath,...(process.platform==='win32'?['--shutdown-on-stdin-eof']:[])],{cwd:root,stdio:process.platform==='win32'?['pipe','inherit','inherit']:'inherit',windowsHide:true,env:{...process.env,NODE_ENV:'production'}});\nfor(const signal of ['SIGINT','SIGTERM'])process.on(signal,()=>{if(process.platform==='win32')child.stdin.end();else child.kill(signal)});\nchild.on('error',error=>{console.error(error.message);process.exitCode=1;});child.on('exit',(code,signal)=>{process.exitCode=code??(signal==='SIGINT'?130:signal==='SIGTERM'?143:1);});\n`);
234
305
  if (process.platform !== 'win32') {
235
306
  const quotedApp = "'" + relativeApp.replaceAll("'", "'\\''") + "'";
236
307
  await writeFile(path.join(output, 'start'), `#!/bin/sh\nset -eu\nSELF=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)\ncd "$SELF/"${quotedApp}\nexec "$SELF/bin/prnext" start . --hostname "\${HOSTNAME:-0.0.0.0}" --port "\${PORT:-3000}" --workers "\${PRNEXT_WORKERS:-1}" --node "\${PRNEXT_NODE:-node}" "$@"\n`);
@@ -1,4 +1,5 @@
1
- import { mkdir, readFile, writeFile, cp, readdir, lstat, rename, rm } from 'node:fs/promises';
1
+ import { rename } from '../runtime/fs.mjs';
2
+ import { mkdir, readFile, writeFile, cp, readdir, lstat, rm } from 'node:fs/promises';
2
3
  import path from 'node:path';
3
4
  import { randomUUID } from 'node:crypto';
4
5
 
package/cli.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ import { rename } from './runtime/fs.mjs';
2
3
  import { spawn } from 'node:child_process';
3
- import { readFile, writeFile, rename, rm } from 'node:fs/promises';
4
+ import { readFile, writeFile, rm } from 'node:fs/promises';
4
5
  import { fileURLToPath } from 'node:url';
5
6
  import path from 'node:path';
6
7
  import { resolveNativeBinary, packageManifest } from './native/resolve.mjs';
@@ -11,20 +12,34 @@ const self = fileURLToPath(import.meta.url);
11
12
  const [command = 'help', ...argv] = process.argv.slice(2);
12
13
  const help = `PRNext ${packageManifest.version}
13
14
 
14
- Use prnext or prn: both names run the same CLI.
15
+ Use prnext for portable scripts; prn is a shortcut on compatible shells.
15
16
 
16
- prnext build [directory] Build React/TypeScript and static HTML
17
- prnext start [directory] [options] Start native Rust production server
18
- prnext dev [directory] [options] Rebuild and restart on source changes
19
- prnext routes [directory] Display compiled routes
20
- prnext check [directory] [--json] Check an existing Next project before building
21
- prn migrate [directory] [options] Migrate a Next project's scripts and dependencies
17
+ prnext build [directory] Build React/TypeScript and static HTML
18
+ prnext start [directory] [options] Start native Rust production server
19
+ prnext pstart [directory] [options] Start production persistently in the background
20
+ prnext prestart [name] Replace a persistent app after a health check
21
+ prnext pstop [name] [--all] Stop persistent apps
22
+ prnext pstatus [--json] Show persistent app status (alias: plist)
23
+ prnext plogs [name] [--follow] Read persistent app logs
24
+ prnext pstartup [--remove] Configure restoration at user login
25
+ prnext pstart --help All persistent commands and options
26
+ prnext dev [directory] [options] Rebuild and restart on source changes
27
+ prnext routes [directory] Display compiled routes
28
+ prnext check [directory] [--json] Check an existing Next project before building
29
+ prnext inspect [directory] [--json] Explain routes, cache decisions and timings
30
+ prnext host [prnext.host.json] Host built apps by hostname; wake on demand
31
+ prnext host [config] --check Validate hosting configuration and builds
32
+ prnext host [config] --status [--json] Show memory, process and idle status
33
+ prnext migrate [directory] [options] Migrate a Next project's scripts and dependencies
22
34
 
23
35
  Migration options: --dry-run (preview), --no-install (prepare only), --json
36
+ prnext migrate --check Audit source compatibility without migrating
37
+ prnext migrate --check --against URL --candidate URL [--routes paths.json]
38
+ Compare GET responses before switching
24
39
  Migration keeps Next.js and backs up package.json and existing lockfiles.
25
- Simple next dev/build/start scripts become prn commands; custom shell scripts need a manual edit.
40
+ Simple next dev/build/start scripts become prnext commands; custom shell scripts need a manual edit.
26
41
 
27
- Server options: --port 3000 --hostname 127.0.0.1 --workers 1
42
+ Server options: --port 3000 --hostname 127.0.0.1 --workers 1 --inspect
28
43
  Production profiles: --profile balanced|speed|memory|classic
29
44
  Memory favors lower RAM under concurrency, accepting more waiting and CPU per response.
30
45
  Without an option: PRNEXT_PROFILE, legacy PRNEXT_MEMORY_PROFILE=compact, then balanced.
@@ -45,17 +60,68 @@ function stop(child) {
45
60
  return new Promise(resolve => {
46
61
  const timer = setTimeout(() => child.kill('SIGKILL'), 5000);
47
62
  child.once('exit', () => { clearTimeout(timer); resolve(); });
48
- child.kill('SIGTERM');
63
+ if (child.stdin) child.stdin.end();
64
+ else child.kill('SIGTERM');
49
65
  });
50
66
  }
51
67
 
52
68
  async function main() {
53
69
  if (['help', '--help', '-h'].includes(command)) { console.log(help); return; }
54
70
  if (['--version', '-v'].includes(command)) { console.log(packageManifest.version); return; }
71
+ if (['pstart', 'prestart', 'pstop', 'pstatus', 'plist', 'plogs', 'pdelete', 'pstartup', 'pdown'].includes(command)) {
72
+ const { persistentCommand } = await import('./runtime/persistent/client.mjs');
73
+ await persistentCommand(command, argv);
74
+ return;
75
+ }
76
+ if (command === 'host') {
77
+ if (argv.includes('--help') || argv.includes('-h')) { console.log(help); return; }
78
+ const files = argv.filter(arg => !arg.startsWith('-'));
79
+ if (files.length > 1 || argv.some(arg => arg.startsWith('-') && !['--status', '--json', '--check'].includes(arg))) throw new Error('host accepts [config], --check, or --status [--json]');
80
+ const config = path.resolve(files[0] || 'prnext.host.json');
81
+ if (argv.includes('--status') && argv.includes('--check')) throw new Error('Choose --check or --status for the host command.');
82
+ if (argv.includes('--status')) {
83
+ const info = path.parse(config);
84
+ const report = JSON.parse(await readFile(path.join(info.dir, info.name + '.status.json'), 'utf8'));
85
+ report.snapshotAgeMs = Date.now() - report.time;
86
+ if (argv.includes('--json')) console.log(JSON.stringify(report, null, 2));
87
+ else {
88
+ console.log(`PRNext host: ${report.reservedMb}/${report.memoryMb} MiB reserved; snapshot ${Math.round(report.snapshotAgeMs / 1000)}s ago${report.stopping ? ' (stopped)' : ''}`);
89
+ for (const app of report.apps) console.log(`${app.name}: ${app.state}, ${(app.rssBytes / 1024 / 1024).toFixed(1)}/${app.memoryMb} MiB RSS, ${app.activeRequests} active request(s), ${app.starts} start(s) — ${app.reason}`);
90
+ }
91
+ return;
92
+ }
93
+ if (argv.includes('--json')) throw new Error('--json requires --status');
94
+ const binary = await resolveNativeBinary();
95
+ const child = spawn(binary, ['host', config, '--node', process.execPath, '--shutdown-on-stdin-eof', ...(argv.includes('--check') ? ['--check'] : [])], { stdio: ['pipe', 'inherit', 'inherit'], windowsHide: true });
96
+ const shutdown = () => child.stdin.end();
97
+ process.once('SIGINT', shutdown); process.once('SIGTERM', shutdown);
98
+ await done(child);
99
+ return;
100
+ }
55
101
  if (command === 'migrate') {
56
102
  if (argv.includes('--help') || argv.includes('-h')) { console.log(help); return; }
57
103
  const { parseMigrationArgs, migrateProject } = await import('./build/migrate.mjs');
58
104
  const { directory, options } = parseMigrationArgs(argv);
105
+ if (options.check) {
106
+ const { auditMigration, compareServers } = await import('./build/migration-audit.mjs');
107
+ const report = await auditMigration(directory);
108
+ if (options.against) {
109
+ const routes = options.routes ? JSON.parse(await readFile(path.resolve(options.routes), 'utf8'))
110
+ : report.routes.filter(route => route.kind === 'page' && !route.pattern.includes('[')).map(route => route.pattern);
111
+ report.comparison = await compareServers({ ...options, routes });
112
+ report.ok &&= report.comparison.ok;
113
+ }
114
+ if (options.json) console.log(JSON.stringify(report, null, 2));
115
+ else {
116
+ console.log(`PRNext migration check ${report.ok ? 'passed' : 'failed'}: ${directory}`);
117
+ console.log(`${report.files} source files; ${report.routes.length} routes.`);
118
+ for (const item of report.findings) console.log(`${item.severity.toUpperCase()} ${item.file || 'project'}${item.line ? ':' + item.line : ''}: ${item.message}\n ${item.action}`);
119
+ for (const result of report.comparison?.results || []) console.log(`${result.equal ? 'MATCH' : 'DIFFERENT'} ${result.route}: ${result.error || result.differences.join(', ') || 'responses match'}`);
120
+ for (const note of [...report.notes, ...(report.comparison ? [report.comparison.scope] : [])]) console.log(note);
121
+ }
122
+ if (!report.ok) process.exitCode = 1;
123
+ return;
124
+ }
59
125
  const report = await migrateProject(directory, options);
60
126
  if (options.json) console.log(JSON.stringify(report, null, 2));
61
127
  else {
@@ -64,14 +130,25 @@ async function main() {
64
130
  for (const backup of report.backups) console.log(`Backup: ${backup}`);
65
131
  for (const note of report.notes) console.log(note);
66
132
  if (report.error) console.error(report.error);
67
- if (report.ok && !options.dryRun) console.log(options.install ? 'Next: npm run build, then npm start (or npm run dev).' : 'Next: install dependencies with your package manager, then prn check and prn build.');
133
+ if (report.ok && !options.dryRun) console.log(options.install ? 'Next: npm run build, then npm start (or npm run dev).' : 'Next: install dependencies with your package manager, then prnext check and prnext build.');
68
134
  }
69
135
  if (!report.ok) process.exitCode = 1;
70
136
  return;
71
137
  }
72
- if (!['build', 'start', 'dev', 'routes', 'check'].includes(command)) throw new Error(`Unknown command: ${command}\n${help}`);
138
+ if (!['build', 'start', 'dev', 'routes', 'check', 'inspect'].includes(command)) throw new Error(`Unknown command: ${command}\n${help}`);
73
139
  const args = [...argv];
74
140
  const root = path.resolve(args[0] && !args[0].startsWith('-') ? args.shift() : '.');
141
+ if (command === 'inspect') {
142
+ if (args.some(arg => arg !== '--json')) throw new Error('inspect accepts only [directory] and --json');
143
+ const { inspectProject, printInspection } = await import('./build/inspect.mjs');
144
+ const report = await inspectProject(root);
145
+ if (args.includes('--json')) console.log(JSON.stringify(report, null, 2)); else printInspection(report);
146
+ return;
147
+ }
148
+ if (['start', 'dev'].includes(command) && args.includes('--inspect')) {
149
+ args.splice(args.indexOf('--inspect'), 1);
150
+ process.env.PRNEXT_INSPECT_DIR = path.join(root, '.prnext-cache/inspect');
151
+ }
75
152
  if (command === 'build' || command === 'dev') {
76
153
  // Build plugins such as Contentlayer read cwd/INIT_CWD rather than webpack's
77
154
  // context. Explicit application directories must behave like `cd app`.
@@ -107,7 +184,8 @@ async function main() {
107
184
  const binary = await resolveNativeBinary();
108
185
  let outputDirectory = await readBuildDirectory(root);
109
186
  const launch = () => spawn(binary, [command === 'routes' ? 'routes' : 'start', root,
110
- ...(command === 'routes' ? [] : ['--worker', path.join(root, outputDirectory, 'runtime/worker.mjs')]), ...args], { stdio: 'inherit', env: { ...process.env, NODE_ENV: process.env.NODE_ENV || (command === 'dev' ? 'development' : 'production') } });
187
+ ...(command === 'routes' ? [] : ['--worker', path.join(root, outputDirectory, 'runtime/worker.mjs'), '--node', process.execPath,
188
+ '--shutdown-on-stdin-eof']), ...args], { stdio: ['pipe', 'inherit', 'inherit'], windowsHide: true, env: { ...process.env, NODE_ENV: process.env.NODE_ENV || (command === 'dev' ? 'development' : 'production') } });
111
189
  let server;
112
190
  let stopping = false;
113
191
  let watcher;
@@ -149,11 +227,13 @@ async function main() {
149
227
  let buildOutput = '';
150
228
  try {
151
229
  const actualChanges = await filterChanges(pending);
230
+ if (stopping) return;
152
231
  if (attempted && !actualChanges.length) return;
153
232
  attempted = true;
154
233
  const changed = actualChanges.slice(0, 32).map(file => file.slice(0, 256));
155
234
  await notifyDev({ state: 'building' });
156
235
  if (actualChanges.some(file => /(?:^|\/)(?:(?:next|prnext|contentlayer)\.config\.|package(?:-lock)?\.json$|(?:pnpm-lock\.yaml|yarn\.lock)$)/.test(file))) { await stop(rebuildChild); rebuildChild = undefined; }
236
+ if (stopping) return;
157
237
  if (!rebuildChild || rebuildChild.exitCode !== null || rebuildChild.signalCode) {
158
238
  rebuildChild = spawn(process.execPath, [path.join(path.dirname(self), 'build/dev-worker.mjs')], { stdio: ['inherit', 'pipe', 'pipe', 'ipc'], env: { ...process.env, NODE_ENV: process.env.NODE_ENV || 'development' } });
159
239
  }
@@ -179,6 +259,9 @@ async function main() {
179
259
  await stop(server);
180
260
  outputDirectory = await readBuildDirectory(root);
181
261
  await watcher.refresh();
262
+ // Shutdown can arrive while the previous server is draining. Never
263
+ // leave a replacement running after shutdown already stopped its peers.
264
+ if (stopping) return;
182
265
  for (const file of changedFiles) {
183
266
  if (file === outputDirectory || file.startsWith(outputDirectory + '/') || outputDirectory.startsWith(file + '/')) changedFiles.delete(file);
184
267
  }
@@ -186,6 +269,7 @@ async function main() {
186
269
  server = launch();
187
270
  server.on('error', error => console.error(`PRNext server: ${error.message}`));
188
271
  const manifest = JSON.parse(await readFile(path.join(root, outputDirectory, 'manifest.json'), 'utf8'));
272
+ if (stopping) return;
189
273
  await notifyDev({ state: 'ready', buildId: manifest.buildId, clientManifest: manifest.devClient, changed });
190
274
  console.log('Source compiled. React Fast Refresh applied in connected browsers.');
191
275
  }
@@ -197,6 +281,7 @@ async function main() {
197
281
  ignore: relative => relative === outputDirectory || relative.startsWith(outputDirectory + '/'),
198
282
  onError: error => { console.error(`PRNext development watcher: ${error.message}`); process.exitCode = 1; void shutdown(); },
199
283
  onChange: filename => {
284
+ if (stopping) return;
200
285
  const relative = String(filename || '').replaceAll(path.sep, '/');
201
286
  if (relative === outputDirectory || relative.startsWith(outputDirectory + '/') || !shouldWatchProjectFile(filename)) return;
202
287
  changedFiles.add(String(filename).replaceAll(path.sep, '/'));
@@ -204,6 +289,7 @@ async function main() {
204
289
  timer = setTimeout(() => { if (changedFiles.size) void rebuild(); }, 150);
205
290
  },
206
291
  });
292
+ if (stopping) { watcher.close(); return; }
207
293
  await rebuild();
208
294
  }
209
295
 
@@ -134,7 +134,9 @@ async function customCachedValue(key, kind, producer, options) {
134
134
  let work = joined?.work;
135
135
  if (!work) {
136
136
  const revision = revisions.get(handler) || 0;
137
- const timestamp = performance.timeOrigin + performance.now();
137
+ // Handler invalidations use wall-clock timestamps. On Windows the
138
+ // performance clock can run ahead of Date.now(), hiding a later update.
139
+ const timestamp = Date.now();
138
140
  const record = { revision, timestamp, work: undefined, metadata: undefined };
139
141
  work = (async () => {
140
142
  const result = await producer();
@@ -3,6 +3,7 @@ const { AsyncLocalStorage, AsyncResource } = require('node:async_hooks');
3
3
  const http = require('node:http');
4
4
  const { setTimeout: delay } = require('node:timers/promises');
5
5
  const { trackStaticDependency } = require('./static-generation.cjs');
6
+ const diagnostics = require('./diagnostics.cjs');
6
7
 
7
8
  // Capture before the runtime installs its application-facing fetch wrapper.
8
9
  const nativeFetchSymbol = Symbol.for('prnext.nativeFetch');
@@ -309,12 +310,16 @@ async function cachedValue(key, producer, options = {}) {
309
310
  if (state) await state.invalidations;
310
311
  throwIfAborted(signal);
311
312
  if (options.incremental && (context?.cacheHandler || context?.manifest?.config?.cacheHandler)) {
313
+ if (diagnostics.enabled) diagnostics.record('cache', { route: diagnostics.route(context), state: 'custom-handler', reason: 'delegated to incremental cache handler' });
312
314
  return require('./incremental-cache.cjs').cachedIncrementalValue(key, producer, { ...options, context, tags, paths, revalidate });
313
315
  }
314
316
  const target = endpoint();
315
317
  // Builds and standalone runtime use have no native cache server. Compute
316
318
  // without claiming persistence; production workers always receive its URL.
317
- if (!target) return uncachedValue(producer, state, signal);
319
+ if (!target) {
320
+ if (diagnostics.enabled) diagnostics.record('cache', { route: diagnostics.route(context), state: 'bypass', reason: 'native cache endpoint unavailable' });
321
+ return uncachedValue(producer, state, signal);
322
+ }
318
323
  const deadline = Date.now() + WAIT_TIMEOUT_MS;
319
324
  while (true) {
320
325
  throwIfAborted(signal);
@@ -328,6 +333,8 @@ async function cachedValue(key, producer, options = {}) {
328
333
  }
329
334
  if (options.versioned && (typeof entry.key !== 'string' || !/^[a-f\d]{64}$/.test(entry.key) || !Number.isSafeInteger(entry.generation) || entry.generation < 0)) throw new Error('Invalid PRNext versioned cache response');
330
335
  const entryKey = options.versioned ? entry.key : key;
336
+ if (diagnostics.enabled && entry.state !== 'pending') diagnostics.record('cache', { route: diagnostics.route(context), state: entry.state,
337
+ key: key.slice(0, 12), tags: tags.map(diagnostics.label), paths: paths.map(diagnostics.label) });
331
338
  const produce = options.versioned ? info => producer({ ...info, generation: entry.generation }) : producer;
332
339
  if (entry.state === 'fresh') return decodeValue(entry.value);
333
340
  if (entry.state === 'stale') {
@@ -364,6 +371,8 @@ function queueInvalidation(context, operation) {
364
371
  if (target) await rpc(target, { op: 'invalidate', ...operation });
365
372
  if (custom) await require('./cache-handlers.cjs').invalidateHandlers(context, operation);
366
373
  if (incremental) await require('./incremental-cache.cjs').invalidate(context, operation);
374
+ if (diagnostics.enabled) diagnostics.record('invalidation', { route: diagnostics.route(context), mode: operation.mode,
375
+ tags: (operation.tags || []).map(diagnostics.label), paths: (operation.paths || []).map(diagnostics.label) });
367
376
  });
368
377
  // Public invalidation functions return void. Retain errors for the next read
369
378
  // or runtime flush without producing an unhandled rejection in the meantime.
@@ -0,0 +1,40 @@
1
+ 'use strict';
2
+ // Opt-in, bounded diagnostics. Never retain request headers, cookies or bodies.
3
+ const directory = process.env.PRNEXT_INSPECT_DIR;
4
+ const { threadId } = require('node:worker_threads');
5
+ let queue = [], scheduled = false, writing = false, size;
6
+ const limit = 1024 * 1024;
7
+ function record(type, fields = {}) {
8
+ if (!directory || queue.length >= 256) return;
9
+ const line = JSON.stringify({ time: Date.now(), pid: process.pid, type, ...fields }) + '\n';
10
+ if (Buffer.byteLength(line) > 4096) return;
11
+ queue.push(line);
12
+ if (!scheduled) { scheduled = true; setTimeout(flush, 100).unref(); }
13
+ }
14
+ async function flush() {
15
+ scheduled = false;
16
+ if (writing || !queue.length) return;
17
+ writing = true;
18
+ const fs = require('node:fs/promises'), path = require('node:path');
19
+ const file = path.join(directory, `worker-${process.pid}-${threadId}.jsonl`);
20
+ const batch = queue.join(''); queue = [];
21
+ try {
22
+ await fs.mkdir(directory, { recursive: true });
23
+ if (size === undefined) {
24
+ size = await fs.stat(file).then(value => value.size, () => 0);
25
+ // Retain the most recent worker sessions. Each has at most two 1 MiB files.
26
+ const files = await Promise.all((await fs.readdir(directory)).filter(name => /^worker-\d+-\d+\.jsonl(?:\.1)?$/.test(name)).map(async name => ({ name, modified: await fs.stat(path.join(directory, name)).then(value => value.mtimeMs, () => 0) })));
27
+ for (const old of files.sort((a, b) => b.modified - a.modified).slice(62)) await fs.rm(path.join(directory, old.name), { force: true }).catch(() => {});
28
+ }
29
+ if (size + Buffer.byteLength(batch) > limit) { await fs.rm(file + '.1', { force: true }); await fs.rename(file, file + '.1').catch(error => { if (error.code !== 'ENOENT') throw error; }); size = 0; }
30
+ await fs.appendFile(file, batch, { mode: 0o600 }); size += Buffer.byteLength(batch);
31
+ } catch { /* Diagnostics must never fail an application request. */ }
32
+ finally { writing = false; if (queue.length && !scheduled) { scheduled = true; setTimeout(flush, 100).unref(); } }
33
+ }
34
+ function route(context) { return String(context?.routePattern || '<unattributed>').slice(0, 256); }
35
+ function destination(input) {
36
+ try { const url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url); return (url.origin + url.pathname).slice(0, 256); }
37
+ catch { return '<unknown>'; }
38
+ }
39
+ function label(value) { return require('node:crypto').createHash('sha256').update(String(value)).digest('hex').slice(0, 12); }
40
+ module.exports = { enabled: Boolean(directory), record, route, destination, label, flush };
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
  const {sampleObject} = require('./instant-samples.cjs');
3
+ const diagnostics = require('./diagnostics.cjs');
3
4
  let requestReader;
4
5
 
5
6
  function context() {
@@ -24,6 +25,7 @@ function dynamicUsage(reason, request = context()) {
24
25
  }
25
26
 
26
27
  function staticBailout(reason, request = context()) {
28
+ if (diagnostics.enabled) diagnostics.record('dynamic', { route: diagnostics.route(request), reason });
27
29
  const state = request?.staticState;
28
30
  if (!state) return true;
29
31
  if (state.partial && state.mode === 'auto') {
package/native/npm.mjs ADDED
@@ -0,0 +1,29 @@
1
+ import { existsSync, realpathSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+
6
+ const execute = promisify(execFile);
7
+
8
+ // Execute npm's JS entry point: .cmd shims cannot be execFile'd on Windows.
9
+ // No shell interpolation, including when the project path contains spaces.
10
+ export function npmCommand(env = process.env) {
11
+ const candidates = [env.npm_execpath];
12
+ const directories = [path.dirname(process.execPath), ...(env.PATH || env.Path || '').split(path.delimiter)];
13
+ for (const directory of directories.filter(Boolean)) {
14
+ candidates.push(path.join(directory, 'node_modules/npm/bin/npm-cli.js'));
15
+ try {
16
+ const npm = realpathSync(path.join(directory, process.platform === 'win32' ? 'npm.cmd' : 'npm'));
17
+ candidates.push(npm.endsWith('.js') ? npm : path.join(path.dirname(npm), 'node_modules/npm/bin/npm-cli.js'));
18
+ } catch {}
19
+ }
20
+ const cli = candidates.find(file => file && /(?:^|[/\\])npm-cli\.js$/.test(file) && existsSync(file));
21
+ if (cli) return { command: process.execPath, args: [cli] };
22
+ if (process.platform !== 'win32') return { command: 'npm', args: [] };
23
+ throw new Error('Cannot locate npm-cli.js. Install Node.js with npm, or run this command through npm run.');
24
+ }
25
+
26
+ export function executeNpm(args, options = {}) {
27
+ const npm = npmCommand();
28
+ return execute(npm.command, [...npm.args, ...args], { windowsHide: true, ...options });
29
+ }
@@ -2,5 +2,7 @@
2
2
  { "id": "darwin-arm64", "package": "prnext-darwin-arm64", "os": "darwin", "cpu": "arm64", "target": "aarch64-apple-darwin" },
3
3
  { "id": "darwin-x64", "package": "prnext-darwin-x64", "os": "darwin", "cpu": "x64", "target": "x86_64-apple-darwin" },
4
4
  { "id": "linux-arm64-gnu", "package": "prnext-linux-arm64-gnu", "os": "linux", "cpu": "arm64", "libc": "glibc", "target": "aarch64-unknown-linux-gnu" },
5
- { "id": "linux-x64-gnu", "package": "prnext-linux-x64-gnu", "os": "linux", "cpu": "x64", "libc": "glibc", "target": "x86_64-unknown-linux-gnu" }
5
+ { "id": "linux-x64-gnu", "package": "prnext-linux-x64-gnu", "os": "linux", "cpu": "x64", "libc": "glibc", "target": "x86_64-unknown-linux-gnu" },
6
+ { "id": "win32-x64-msvc", "package": "@thomas.f/prnext-win32-x64-msvc", "os": "win32", "cpu": "x64", "target": "x86_64-pc-windows-msvc" },
7
+ { "id": "win32-arm64-msvc", "package": "@thomas.f/prnext-win32-arm64-msvc", "os": "win32", "cpu": "arm64", "target": "aarch64-pc-windows-msvc" }
6
8
  ]
@@ -8,6 +8,7 @@ import path from 'node:path';
8
8
  export const packageRoot = fileURLToPath(new URL('../', import.meta.url));
9
9
  export const packageManifest = JSON.parse(readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
10
10
  export const platforms = JSON.parse(readFileSync(new URL('./platforms.json', import.meta.url), 'utf8'));
11
+ export const nativeBinaryName = (platform = process.platform) => platform === 'win32' ? 'prnext.exe' : 'prnext';
11
12
 
12
13
  // A dependency installed in node_modules must never mistake its consumer for
13
14
  // the framework checkout, even if that consumer also has a Cargo workspace.
@@ -27,7 +28,7 @@ export function nativePlatform({ platform = process.platform, arch = process.arc
27
28
  libc = process.report?.getReport().header.glibcVersionRuntime ? 'glibc' : 'musl';
28
29
  }
29
30
  const result = platforms.find(item => item.os === platform && item.cpu === arch && (!item.libc || item.libc === libc));
30
- if (!result) throw new Error(`PRNext has no prebuilt server for ${platform}/${arch}${libc ? `/${libc}` : ''}. This alpha provides macOS arm64/x64 and Linux glibc arm64/x64. Use a supported system (WSL on Windows), or set PRNEXT_BINARY to your own compatible build.`);
31
+ if (!result) throw new Error(`PRNext has no prebuilt server for ${platform}/${arch}${libc ? `/${libc}` : ''}. Supported targets are Windows (MSVC), macOS and Linux glibc on arm64/x64. Use a supported system, or set PRNEXT_BINARY to your own compatible build.`);
31
32
  return result;
32
33
  }
33
34
 
@@ -47,7 +48,7 @@ export async function resolveNativeBinary({ env = process.env, root = packageRoo
47
48
  }
48
49
  const checkout = sourceCheckout(root);
49
50
  if (checkout) {
50
- const binary = path.join(checkout, 'target/release', process.platform === 'win32' ? 'prnext.exe' : 'prnext');
51
+ const binary = path.join(checkout, 'target/release', nativeBinaryName());
51
52
  try { return await executableFile(binary); }
52
53
  catch (error) { if (error.code !== 'ENOENT') throw error; }
53
54
  if (!allowBuild) throw new Error('Native server missing from this checkout. Run npm run build:native.');
@@ -75,7 +76,7 @@ export async function resolveNativeBinary({ env = process.env, root = packageRoo
75
76
  if (native.name !== target.package || native.version !== manifest.version) {
76
77
  throw new Error(`PRNext native version mismatch: expected ${target.package}@${manifest.version}, found ${native.name}@${native.version}. Reinstall matching versions.`);
77
78
  }
78
- const binary = path.join(path.dirname(nativeManifestFile), 'bin', 'prnext');
79
+ const binary = path.join(path.dirname(nativeManifestFile), 'bin', nativeBinaryName(target.os));
79
80
  try { return await executableFile(binary); }
80
81
  catch (cause) { throw new Error(`PRNext native executable is missing or not executable in ${target.package}. Reinstall the package.`, { cause }); }
81
82
  }
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@thomas.f/prnext",
3
- "version": "0.1.0-alpha.1",
3
+ "version": "0.1.1-alpha",
4
+ "license": "MIT",
4
5
  "description": "Experimental Rust-powered runtime for Next.js apps, React and npm",
5
6
  "homepage": "https://prnext.dev",
6
7
  "repository": {
@@ -116,10 +117,12 @@
116
117
  "react-server-dom-webpack": "19.3.0"
117
118
  },
118
119
  "optionalDependencies": {
119
- "prnext-darwin-arm64": "0.1.0-alpha.1",
120
- "prnext-darwin-x64": "0.1.0-alpha.1",
121
- "prnext-linux-arm64-gnu": "0.1.0-alpha.1",
122
- "prnext-linux-x64-gnu": "0.1.0-alpha.1"
120
+ "prnext-darwin-arm64": "0.1.1-alpha",
121
+ "prnext-darwin-x64": "0.1.1-alpha",
122
+ "prnext-linux-arm64-gnu": "0.1.1-alpha",
123
+ "prnext-linux-x64-gnu": "0.1.1-alpha",
124
+ "@thomas.f/prnext-win32-arm64-msvc": "0.1.1-alpha",
125
+ "@thomas.f/prnext-win32-x64-msvc": "0.1.1-alpha"
123
126
  },
124
127
  "engines": {
125
128
  "node": ">=22"
@@ -6,7 +6,7 @@ import { packageRoot, packageManifest, platforms, sourceCheckout } from '../nati
6
6
  export async function verifyPackage() {
7
7
  const pkg = packageManifest;
8
8
  if (pkg.private === true) throw new Error(`${pkg.name} is marked private.`);
9
- if (!/^\d+\.\d+\.\d+-alpha\.\d+$/.test(pkg.version)) throw new Error('This release workflow requires an explicit x.y.z-alpha.N version.');
9
+ if (!/^\d+\.\d+\.\d+-alpha(?:\.\d+)?$/.test(pkg.version)) throw new Error('This release workflow requires an explicit x.y.z-alpha or x.y.z-alpha.N version.');
10
10
  if (pkg.publishConfig?.tag !== 'alpha' || pkg.publishConfig?.access !== 'public') throw new Error('Alpha releases require publishConfig.tag=alpha and access=public.');
11
11
  if (pkg.bin?.prn !== './cli.mjs' || pkg.bin?.prnext !== './cli.mjs') throw new Error('Both prn and prnext must point to cli.mjs.');
12
12
  const expected = Object.fromEntries(platforms.map(item => [item.package, pkg.version]));
@@ -35,7 +35,7 @@ export async function verifyPublishedNatives({ registry = 'https://registry.npmj
35
35
  const problems = [];
36
36
  for (const target of platforms) {
37
37
  try {
38
- const url = new URL(`${target.package}/${packageManifest.version}`, registry);
38
+ const url = new URL(`${encodeURIComponent(target.package)}/${encodeURIComponent(packageManifest.version)}`, registry);
39
39
  const response = await fetcher(url, { signal: AbortSignal.timeout(15_000) });
40
40
  if (!response.ok) throw new Error(`registry returned HTTP ${response.status}`);
41
41
  const pkg = await response.json();