@thomas.f/prnext 0.1.12-alpha → 0.1.15-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.
Files changed (73) hide show
  1. package/README.md +305 -65
  2. package/build/ADAPTERS.md +121 -0
  3. package/build/actions.mjs +4 -0
  4. package/build/app-config.mjs +2 -0
  5. package/build/app.mjs +23 -5
  6. package/build/build-lock.mjs +71 -0
  7. package/build/bundle-runtime.mjs +82 -0
  8. package/build/compiler.mjs +3 -1
  9. package/build/config.mjs +9 -2
  10. package/build/font-cache.mjs +50 -0
  11. package/build/font-files.mjs +5 -3
  12. package/build/fonts.mjs +1 -1
  13. package/build/index.mjs +12 -1
  14. package/build/inspect.mjs +20 -2
  15. package/build/inspection-summary.mjs +37 -0
  16. package/build/metadata-conflicts.mjs +13 -0
  17. package/build/metadata-images.mjs +3 -3
  18. package/build/metadata-routes.mjs +3 -3
  19. package/build/native-packages.mjs +5 -1
  20. package/build/npm-packages.mjs +2 -1
  21. package/build/og.mjs +3 -9
  22. package/build/optimize-imports.mjs +112 -0
  23. package/build/output-contract.mjs +41 -0
  24. package/build/precompress.mjs +13 -0
  25. package/build/proxy-body-config.mjs +10 -0
  26. package/build/scan.mjs +10 -8
  27. package/build/static-adapter.mjs +100 -0
  28. package/cli.mjs +64 -11
  29. package/compat/after.cjs +51 -0
  30. package/compat/diagnostics.cjs +4 -0
  31. package/compat/index.d.ts +32 -1
  32. package/compat/server.cjs +2 -1
  33. package/compat/server.d.cts +2 -0
  34. package/index.mjs +2 -0
  35. package/native/launch.mjs +19 -0
  36. package/package.json +8 -9
  37. package/release/verify.mjs +2 -0
  38. package/runtime/api.mjs +20 -7
  39. package/runtime/app-render.mjs +78 -33
  40. package/runtime/app-route-plan.mjs +45 -0
  41. package/runtime/app-routing-state.mjs +101 -21
  42. package/runtime/app-routing.mjs +37 -18
  43. package/runtime/app-segments.mjs +25 -11
  44. package/runtime/app-static.mjs +1 -1
  45. package/runtime/app-stream-html.mjs +2 -1
  46. package/runtime/cache-request.mjs +10 -2
  47. package/runtime/cloud/archive.mjs +317 -0
  48. package/runtime/cloud/client.mjs +641 -0
  49. package/runtime/cloud/environment.mjs +60 -0
  50. package/runtime/cloud/state.mjs +156 -0
  51. package/runtime/cloud/upload.mjs +48 -0
  52. package/runtime/html-envelope.mjs +70 -7
  53. package/runtime/http.mjs +7 -4
  54. package/runtime/middleware.mjs +3 -3
  55. package/runtime/og/Geist-Regular.ttf +0 -0
  56. package/runtime/og/LICENSE +373 -0
  57. package/runtime/og/LICENSE.Geist +93 -0
  58. package/runtime/og/LICENSE.fflate +21 -0
  59. package/runtime/og/LICENSE.harfbuzzjs +22 -0
  60. package/runtime/og/LICENSE.satori +373 -0
  61. package/runtime/og/NOTICE.txt +4 -0
  62. package/runtime/og/hb.wasm +0 -0
  63. package/runtime/og/index.node.mjs +31260 -0
  64. package/runtime/og/resvg.wasm +0 -0
  65. package/runtime/og-backend.mjs +8 -0
  66. package/runtime/og-loader.mjs +2 -15
  67. package/runtime/request-transport.mjs +1 -1
  68. package/runtime/route-static.mjs +1 -1
  69. package/runtime/rsc-worker.mjs +27 -4
  70. package/runtime/stream-utils.mjs +22 -3
  71. package/runtime/transport.mjs +16 -0
  72. package/runtime/worker-metadata.mjs +23 -0
  73. package/runtime/worker.mjs +36 -9
@@ -89,7 +89,11 @@ export function createNativePackages(projectRoot, { isClientSource }) {
89
89
  }
90
90
  if (args.path.startsWith('.') || args.path.startsWith('#')) return;
91
91
  const resolved = await esbuild.resolve(args.path, { importer: args.importer, resolveDir: args.resolveDir, kind: args.kind, pluginData: { prnextNativeResolving: true } });
92
- if (resolved.errors.length || resolved.external || resolved.namespace !== 'file') return resolved;
92
+ // esbuild knows whether the original require() is inside try/catch.
93
+ // A plugin error would erase that optional-dependency semantics.
94
+ // Let normal resolution report genuine missing imports itself.
95
+ if (resolved.errors.length) return;
96
+ if (resolved.external || resolved.namespace !== 'file') return resolved;
93
97
  references.set(resolved.path, { specifier: args.path, importer: args.importer });
94
98
  const owner = ownerFor(resolved.path) || await discoverOwner(resolved.path, mode);
95
99
  if (owner) {
@@ -91,7 +91,8 @@ export function npmPackagesPlugin({ pages = false, projectRoot, transpilePackage
91
91
  // same validation as Pages to avoid bypassing compatibility transforms.
92
92
  if (!pages && !external.has(name)) return;
93
93
  const resolved = await resolve(args.path, args.importer || path.join(projectRoot, 'package.json'), args.kind);
94
- if (resolved.errors.length || resolved.external || resolved.namespace !== 'file') return resolved;
94
+ if (resolved.errors.length) return;
95
+ if (resolved.external || resolved.namespace !== 'file') return resolved;
95
96
  // A tsconfig paths/baseUrl import can look like an npm specifier while
96
97
  // resolving to application source. Node cannot resolve that alias from
97
98
  // the emitted server bundle; keep the resolved source in the graph.
package/build/og.mjs CHANGED
@@ -1,14 +1,8 @@
1
- import { cp, mkdir, readFile, writeFile } from 'node:fs/promises';
2
- import { createRequire } from 'node:module';
1
+ import { cp, mkdir } from 'node:fs/promises';
2
+ import { fileURLToPath } from 'node:url';
3
3
  import path from 'node:path';
4
4
  export async function prepareImageResponse(stage) {
5
- const directory = path.dirname(createRequire(import.meta.url).resolve('@vercel/og'));
6
5
  const target = path.join(stage, 'runtime/og');
7
6
  await mkdir(target, { recursive: true });
8
- await writeFile(path.join(target, 'index.node.mjs'), `import {createRequire as __prnextRequire} from 'node:module';import {fileURLToPath as __prnextFile} from 'node:url';import {dirname as __prnextDir} from 'node:path';const require=__prnextRequire(import.meta.url),__filename=__prnextFile(import.meta.url),__dirname=__prnextDir(__filename);\n` + await readFile(path.join(directory, 'index.node.js'), 'utf8'));
9
- for (const name of ['Geist-Regular.ttf', 'resvg.wasm']) await cp(path.join(directory, name), path.join(target, name));
10
- await cp(path.join(directory, '../LICENSE'), path.join(target, 'LICENSE'));
11
- await writeFile(path.join(target, 'NOTICE.txt'), 'Image renderer: @vercel/og 1.0.3 (https://github.com/vercel/og). PRNext adds Node CommonJS globals to its bundled ESM entry and includes the adjacent HarfBuzz WASM needed by that entry. Upstream bundled notices are preserved in index.node.mjs.\n');
12
- const satori = createRequire(path.join(directory, 'index.node.js')).resolve('satori');
13
- await cp(createRequire(satori).resolve('harfbuzzjs/hb.wasm'), path.join(target, 'hb.wasm'));
7
+ await cp(fileURLToPath(new URL('../runtime/og/', import.meta.url)), target, { recursive: true });
14
8
  }
@@ -0,0 +1,112 @@
1
+ import { readFile, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { parse } from '@babel/parser';
4
+ import generateModule from '@babel/generator';
5
+ import * as t from '@babel/types';
6
+ import { generateMapped } from './source-maps.mjs';
7
+
8
+ const generate = generateModule.default || generateModule;
9
+ const name = node => node?.name ?? node?.value;
10
+
11
+ export function validateOptimizePackageImports(value = []) {
12
+ if (!Array.isArray(value) || value.length > 256 || value.some(item => typeof item !== 'string' || !/^(?:@[a-zA-Z0-9_.-]+\/)?[a-zA-Z0-9_-][a-zA-Z0-9_.-]*$/.test(item))) {
13
+ throw new TypeError('experimental.optimizePackageImports must be an array of at most 256 npm package names');
14
+ }
15
+ return [...new Set(value)];
16
+ }
17
+
18
+ /** Only bypass pure ESM barrels in packages declaring sideEffects:false.
19
+ * Uncertain graphs retain their original import and normal bundler semantics.
20
+ * Never execute package code or infer private export paths from symbol names.
21
+ */
22
+ export function createImportOptimizer(packages) {
23
+ const selected = new Set(packages), barrels = new Map(), dependencies = new Set();
24
+ async function inspect(file) {
25
+ if (!barrels.has(file)) barrels.set(file, (async () => {
26
+ if (!/\.[cm]?jsx?$/.test(file) || (await stat(file)).size > 2 * 1024 * 1024) return;
27
+ let directory = path.dirname(file), owner;
28
+ for (;;) {
29
+ try { owner = JSON.parse(await readFile(path.join(directory, 'package.json'), 'utf8')); break; }
30
+ catch (error) { if (error.code !== 'ENOENT') return; }
31
+ const parent = path.dirname(directory);
32
+ if (parent === directory) return;
33
+ directory = parent;
34
+ }
35
+ if (owner.sideEffects !== false || !selected.has(owner.name)) return;
36
+ dependencies.add(file);
37
+ dependencies.add(path.join(directory, 'package.json'));
38
+ const ast = parse(await readFile(file, 'utf8'), { sourceType: 'module', plugins: ['jsx'] });
39
+ // Crossing either directive here would change the RSC boundary identity.
40
+ if (ast.program.directives.some(d => d.value.value !== 'use strict')) return;
41
+ const imports = new Map(), exports = new Map();
42
+ for (const node of ast.program.body) {
43
+ if (t.isImportDeclaration(node)) {
44
+ if (!node.specifiers.length || node.attributes?.length) return;
45
+ for (const item of node.specifiers) {
46
+ if (t.isImportNamespaceSpecifier(item)) return;
47
+ imports.set(item.local.name, { source: node.source.value, imported: t.isImportDefaultSpecifier(item) ? 'default' : name(item.imported) });
48
+ }
49
+ } else if (!t.isExportNamedDeclaration(node) || node.declaration || node.attributes?.length) return;
50
+ }
51
+ for (const node of ast.program.body) if (t.isExportNamedDeclaration(node)) {
52
+ for (const item of node.specifiers) {
53
+ if (!t.isExportSpecifier(item)) return;
54
+ const target = node.source ? { source: node.source.value, imported: name(item.local) } : imports.get(name(item.local));
55
+ if (!target || !target.source.startsWith('./') && !target.source.startsWith('../')) return;
56
+ exports.set(name(item.exported), target);
57
+ }
58
+ }
59
+ return { exports, root: directory };
60
+ })());
61
+ return barrels.get(file);
62
+ }
63
+ async function optimize(source, file, resolve) {
64
+ if (![...selected].some(value => source.includes(value))) return source;
65
+ const ast = parse(source, { sourceType: 'unambiguous', sourceFilename: file, plugins: ['jsx', ...(/\.[cm]?tsx?$/.test(file) ? ['typescript'] : [])] });
66
+ let changed = false;
67
+ for (const node of ast.program.body) {
68
+ if (!t.isImportDeclaration(node) || !selected.has(node.source.value) || node.importKind === 'type' || !node.specifiers.length || node.attributes?.length) continue;
69
+ const resolved = await resolve(node.source.value, file);
70
+ if (!resolved) continue;
71
+ const barrel = await inspect(resolved);
72
+ if (!barrel) continue;
73
+ const replacements = [];
74
+ for (const item of node.specifiers) {
75
+ if (!t.isImportSpecifier(item) || item.importKind === 'type') continue;
76
+ const target = barrel.exports.get(name(item.imported));
77
+ if (!target) continue;
78
+ const leaf = await resolve(target.source, resolved);
79
+ if (!leaf) continue;
80
+ const relative = path.relative(barrel.root, leaf);
81
+ if (relative.startsWith('..') || path.isAbsolute(relative) || relative.split(path.sep).includes('node_modules')) continue;
82
+ replacements.push({ item, declaration: t.importDeclaration([target.imported === 'default' ? t.importDefaultSpecifier(item.local) : t.importSpecifier(item.local, t.isValidIdentifier(target.imported) ? t.identifier(target.imported) : t.stringLiteral(target.imported))], t.stringLiteral(leaf)) });
83
+ }
84
+ if (!replacements.length) continue;
85
+ const at = ast.program.body.indexOf(node);
86
+ node.specifiers = node.specifiers.filter(item => !replacements.some(value => value.item === item));
87
+ ast.program.body.splice(at, node.specifiers.length ? 0 : 1, ...replacements.map(value => value.declaration));
88
+ changed = true;
89
+ }
90
+ return changed ? generateMapped(generate, ast, { retainLines: true }, source, file) : source;
91
+ }
92
+ optimize.dependencies = dependencies;
93
+ return optimize;
94
+ }
95
+
96
+ export function optimizeImportsPlugins(plugins, packages) {
97
+ if (!packages?.length) return plugins;
98
+ const optimize = createImportOptimizer(packages);
99
+ return plugins.map(plugin => ({ ...plugin, setup(builder) {
100
+ return plugin.setup({ ...builder, onLoad(settings, callback) {
101
+ builder.onLoad(settings, async args => {
102
+ const result = await callback(args);
103
+ if (!result || typeof result.contents !== 'string' || args.namespace !== 'file' || !/\.[cm]?[jt]sx?$/.test(args.path) || result.errors?.length) return result;
104
+ const contents = await optimize(result.contents, args.path, async (specifier, importer) => {
105
+ const resolved = await builder.resolve(specifier, { importer, resolveDir: path.dirname(importer), kind: 'import-statement', pluginData: { prnextNpmResolving: true } });
106
+ return !resolved.errors.length && !resolved.external && resolved.namespace === 'file' && !resolved.suffix ? resolved.path : undefined;
107
+ });
108
+ return { ...result, contents, watchFiles: [...(result.watchFiles || []), ...optimize.dependencies] };
109
+ });
110
+ } });
111
+ } }));
112
+ }
@@ -0,0 +1,41 @@
1
+ import { open, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ export const OUTPUT_CONTRACT = 'build-output.json';
5
+ const fields = ['version', 'adapter', 'buildId', 'manifest', 'assets', 'server', 'fallback'];
6
+
7
+ /** The public adapter contract is separate from internal rendering metadata. */
8
+ export function validateBuildOutput(value) {
9
+ const invalid = reason => { throw new Error(`Invalid PRNext build output: ${reason}`); };
10
+ if (!value || typeof value !== 'object' || Array.isArray(value)) invalid('expected an object');
11
+ if (value.version !== 1) invalid(`unsupported version ${value.version}; expected 1`);
12
+ if (Object.keys(value).some(key => !fields.includes(key)) || fields.some(key => !(key in value))) invalid('unexpected or missing fields');
13
+ if (!['react', 'static'].includes(value.adapter)) invalid('unsupported adapter');
14
+ if (typeof value.buildId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(value.buildId)) invalid('invalid buildId');
15
+ if (value.manifest !== 'manifest.json') invalid('manifest must be manifest.json');
16
+ if (value.adapter === 'static') {
17
+ if (value.assets !== 'static' || value.server !== null || ![null, 'index.html'].includes(value.fallback)) invalid('invalid static entrypoints');
18
+ } else if (value.assets !== 'assets' || value.fallback !== null || !value.server ||
19
+ Object.keys(value.server).sort().join(',') !== 'entry,runtimeContract' ||
20
+ value.server.entry !== 'runtime/worker.mjs' || value.server.runtimeContract !== 'runtime-contract.json') invalid('invalid React entrypoints');
21
+ return value;
22
+ }
23
+
24
+ export async function writeBuildOutput(directory, manifest, { adapter = 'react', spa = false } = {}) {
25
+ const value = validateBuildOutput({ version: 1, adapter, buildId: manifest.buildId,
26
+ manifest: 'manifest.json', assets: adapter === 'static' ? 'static' : 'assets',
27
+ server: adapter === 'static' ? null : { entry: 'runtime/worker.mjs', runtimeContract: 'runtime-contract.json' },
28
+ fallback: spa ? 'index.html' : null });
29
+ await writeFile(path.join(directory, OUTPUT_CONTRACT), JSON.stringify(value, null, 2) + '\n');
30
+ return value;
31
+ }
32
+
33
+ export async function readBuildOutput(directory) {
34
+ const file = await open(path.join(directory, OUTPUT_CONTRACT), 'r');
35
+ try {
36
+ const bytes = Buffer.alloc(65537);
37
+ const { bytesRead } = await file.read(bytes, 0, bytes.length, 0);
38
+ if (bytesRead > 65536) throw new Error('Invalid PRNext build output: exceeds 64 KiB');
39
+ return validateBuildOutput(JSON.parse(bytes.subarray(0, bytesRead).toString('utf8')));
40
+ } finally { await file.close(); }
41
+ }
@@ -49,6 +49,19 @@ async function compress(filename, cacheDirectory) {
49
49
  const original = await stat(filename);
50
50
  if (!original.size) return 'skipped';
51
51
  const sidecar = filename + '.gz';
52
+ // Static builders may already supply gzip files. Validate them rather than
53
+ // overwriting public input or publishing a mismatched encoded response.
54
+ const existing = await stat(sidecar).catch(error => { if (error.code !== 'ENOENT') throw error; });
55
+ if (existing) {
56
+ const input = createReadStream(sidecar), unzip = createGunzip();
57
+ const verified = digest(unzip, original.size);
58
+ const pumping = pipeline(input, unzip);
59
+ const [actual, expected] = await Promise.all([verified, digest(createReadStream(filename)), pumping]);
60
+ if (actual !== expected) throw new Error(`Precompressed asset does not match its source: ${path.basename(sidecar)}`);
61
+ await utimes(filename, original.atimeMs / 1000, original.mtimeMs / 1000);
62
+ await utimes(sidecar, original.atimeMs / 1000, original.mtimeMs / 1000);
63
+ return 'reused';
64
+ }
52
65
  const key = cacheDirectory && original.size <= 64*1024*1024 ? await digest(createReadStream(filename)) : null;
53
66
  const cached = key && path.join(cacheDirectory,key+'.gz');
54
67
  let reused = false;
@@ -0,0 +1,10 @@
1
+ export function validateProxyBodySize(value = 10 * 1024 * 1024) {
2
+ let bytes = value;
3
+ if (typeof value === 'string') {
4
+ const match = /^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/i.exec(value.trim());
5
+ if (!match) throw new TypeError('experimental.proxyClientMaxBodySize expects bytes or a size such as "25mb"');
6
+ bytes = Math.floor(Number(match[1]) * 1024 ** Math.max(0, ['b', 'kb', 'mb', 'gb'].indexOf((match[2] || 'b').toLowerCase())));
7
+ }
8
+ if (!Number.isSafeInteger(bytes) || bytes < 1 || bytes > 32 * 1024 * 1024) throw new TypeError('experimental.proxyClientMaxBodySize must be between 1 byte and the PRNext 32 MiB buffered transport limit');
9
+ return bytes;
10
+ }
package/build/scan.mjs CHANGED
@@ -6,7 +6,7 @@ import { validateAppConfig, mergeAppConfig } from './app-config.mjs';
6
6
  import { scanMiddleware } from './middleware.mjs';
7
7
  import { scanMetadataRoutes } from './metadata-routes.mjs';
8
8
  import { scanAdvancedAppRoutes } from './app-routing.mjs';
9
- import { pageExtensionPattern, validatePageSource } from './page-extensions.mjs';
9
+ import { pageExtensionPattern, validatePageSource, defaultPageExtensions } from './page-extensions.mjs';
10
10
 
11
11
  const sourceExtension = /\.(?:[cm]?js|jsx|tsx?)$/;
12
12
 
@@ -61,7 +61,7 @@ export async function scanProject(projectRoot, { basePath = '', pageExtensions,
61
61
  const appRoots = [];
62
62
  for (const name of ['app', 'src/app']) if (await exists(path.join(root, name))) appRoots.push(path.join(root, name));
63
63
  if (appRoots.length > 1) throw new Error('Both app/ and src/app/ exist. Keep exactly one App Router directory.');
64
- if (!roots.length && !appRoots.length) throw new Error('No pages/, src/pages/, app/, or src/app/ directory found. Create app/page.tsx or pages/index.tsx to start.');
64
+ if (!roots.length && !appRoots.length) throw new Error('The React adapter needs a pages/, src/pages/, app/, or src/app/ directory. Create app/page.tsx or pages/index.tsx to start. For a Vite or other static site, run its own build, then prnext build --adapter static --from dist.');
65
65
  const pageRoot = roots[0];
66
66
  const files = (pageRoot ? await walk(pageRoot) : []).filter(file => sourceExtension.test(file) && !file.endsWith('.d.ts'));
67
67
  const routes = [];
@@ -96,7 +96,7 @@ export async function scanProject(projectRoot, { basePath = '', pageExtensions,
96
96
  });
97
97
  }
98
98
  const appRoot = appRoots[0];
99
- const { appNotFound, appGlobalError } = appRoot ? await scanAppRoutes({ root, appRoot, routes, patterns, sourceExtension }) : {};
99
+ const { appNotFound, appGlobalError } = appRoot ? await scanAppRoutes({ root, appRoot, routes, patterns, sourceExtension, pageExtensions }) : {};
100
100
  const metadataManifest = appRoot ? await scanMetadataRoutes({ root, appRoot, files: await walk(appRoot), routes, patterns, validatePattern, basePath, pageExtensions, appNotFound }) : undefined;
101
101
  if (metadataManifest) for (const route of routes) if (route.router === 'app' && route.kind === 'page') route.staticMetadata = { manifest: metadataManifest };
102
102
  if (!routes.length && !appNotFound) throw new Error('No page or API route found in the Pages Router or App Router directories.');
@@ -108,11 +108,13 @@ const appConventions = new Map([
108
108
  ['loading', 'loading'], ['error', 'error'], ['not-found', 'notFound'], ['default', 'default'],
109
109
  ]);
110
110
 
111
- async function scanAppRoutes({ root, appRoot, routes, patterns, sourceExtension }) {
111
+ async function scanAppRoutes({ root, appRoot, routes, patterns, sourceExtension, pageExtensions = defaultPageExtensions }) {
112
112
  const directories = new Map();
113
113
  const configs = new Map();
114
114
  let appGlobalError;
115
- for (const file of await walk(appRoot)) {
115
+ const rank = file => pageExtensions.findIndex(extension => file.endsWith('.' + extension));
116
+ const files = (await walk(appRoot)).sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
117
+ for (const file of files) {
116
118
  if (!sourceExtension.test(file) || file.endsWith('.d.ts')) continue;
117
119
  const relative = path.relative(appRoot, file).replaceAll(path.sep, '/');
118
120
  const parts = relative.split('/');
@@ -122,18 +124,18 @@ async function scanAppRoutes({ root, appRoot, routes, patterns, sourceExtension
122
124
  // This convention replaces the complete root document. A similarly named
123
125
  // file inside a route group or segment is not a nested error boundary.
124
126
  if (parts.length === 0) {
125
- if (appGlobalError) throw new Error('Multiple global-error files in the App Router root. Keep only one.');
127
+ if (appGlobalError) { console.warn(`Duplicate global-error file ${relative}; using ${path.relative(appRoot, appGlobalError)} according to pageExtensions.`); continue; }
126
128
  appGlobalError = file;
127
129
  }
128
130
  continue;
129
131
  }
130
132
  if (!appConventions.has(filename)) continue;
131
133
  validatePageSource(file);
132
- if (['page', 'layout', 'route'].includes(filename)) configs.set(file, await validateAppConfig(file));
133
134
  const directory = parts.join('/');
134
135
  const descriptor = directories.get(directory) || { directory, files: {} };
135
136
  const name = appConventions.get(filename);
136
- if (descriptor.files[name]) throw new Error(`Multiple ${filename} files in App Router directory ${directory || '/'}. Keep only one file for each convention.`);
137
+ if (descriptor.files[name]) { console.warn(`Duplicate ${filename} file ${relative}; using ${path.relative(appRoot, descriptor.files[name])} according to pageExtensions.`); continue; }
138
+ if (['page', 'layout', 'route'].includes(filename)) configs.set(file, await validateAppConfig(file));
137
139
  descriptor.files[name] = file;
138
140
  directories.set(directory, descriptor);
139
141
  }
@@ -0,0 +1,100 @@
1
+ import { constants } from 'node:fs';
2
+ import { copyFile, lstat, mkdir, readdir, realpath, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { randomUUID } from 'node:crypto';
4
+ import path from 'node:path';
5
+ import { rename } from '../runtime/fs.mjs';
6
+ import { OUTPUT_POINTER, prepareBuildDirectory, readBuildDirectory, validateDistDir } from '../runtime/build-directory.mjs';
7
+ import { writeBuildOutput } from './output-contract.mjs';
8
+ import { precompressBuild } from './precompress.mjs';
9
+ import { withBuildLock } from './build-lock.mjs';
10
+
11
+ const MAX_FILES = 20000, MAX_BYTES = 512 * 1024 * 1024;
12
+ const inside = (root, file) => file.startsWith(root + path.sep);
13
+ const urlFor = file => '/' + file.split('/').map(encodeURIComponent).join('/');
14
+
15
+ /** Import an already-built public directory. Never evaluate its config or code. */
16
+ export async function buildStatic(directory, { from = 'dist', spa = false, distDir, dev = false } = {}) {
17
+ return withBuildLock(directory, root => importStatic(root, { from, spa, distDir, dev }));
18
+ }
19
+
20
+ async function importStatic(directory, { from, spa, distDir, dev }) {
21
+ if (dev) throw new Error('The static adapter imports production files; use the source framework for development.');
22
+ if (typeof spa !== 'boolean') throw new TypeError('spa must be a boolean');
23
+ const root = await realpath(directory);
24
+ // Reuse the output-directory restrictions to keep imports within this project
25
+ // and away from source/dependency/repository trees.
26
+ const relative = validateDistDir(from);
27
+ const output = distDir === undefined ? await readBuildDirectory(root) : validateDistDir(distDir);
28
+ const source = path.join(root, relative), destination = await prepareBuildDirectory(root, output);
29
+ if (source === destination || inside(source, destination) || inside(destination, source)) throw new Error('Static input and PRNext output directories must not overlap.');
30
+ let ancestor = root;
31
+ for (const part of relative.split('/')) {
32
+ ancestor = path.join(ancestor, part);
33
+ const entry = await lstat(ancestor);
34
+ if (entry.isSymbolicLink() || !entry.isDirectory()) throw new Error('Static input must use real directories, not symbolic links.');
35
+ }
36
+ if (!inside(root, await realpath(source))) throw new Error('Static input must stay inside the project.');
37
+ const index = await lstat(path.join(source, 'index.html')).catch(() => null);
38
+ if (!index?.isFile() || index.isSymbolicLink()) throw new Error('Static input needs index.html. Run your framework build first and select its public output with --from.');
39
+
40
+ const id = randomUUID(), stage = path.join(root, `.prnext-build-${id}`);
41
+ const backup = path.join(root, `.prnext-backup-${id}`), pointer = path.join(root, `.prnext-output-${id}.tmp`);
42
+ const manifest = { version: 1, buildId: id, config: { staticOnly: true, staticFallback: spa, compress: true, poweredByHeader: true }, routes: [], prerendered: [] };
43
+ const paths = new Set();
44
+ let count = 0, bytes = 0, previous = false, published = false;
45
+ const add = (url, file) => {
46
+ if (paths.has(url)) throw new Error(`Ambiguous static URL ${url}; remove the conflicting file or HTML alias.`);
47
+ paths.add(url);
48
+ manifest.prerendered.push({ path: url, file: `static/${file}`, revalidate: false });
49
+ };
50
+ async function copy(folder = '', depth = 0) {
51
+ if (depth > 64) throw new Error('Static input exceeds 64 directory levels.');
52
+ for (const name of (await readdir(path.join(source, folder))).sort()) {
53
+ if (name.includes('\\') || /[\x00-\x1f\x7f:]/.test(name) ||
54
+ name.startsWith('.') && !(folder === '' && name === '.well-known') ||
55
+ ['node_modules', '_prnext', '_next'].includes(name) || folder === '' && name === 'server') {
56
+ throw new Error(`Static input contains a private, reserved or server path: ${folder ? folder + '/' : ''}${name}. Select only browser-ready files.`);
57
+ }
58
+ const file = folder ? `${folder}/${name}` : name;
59
+ const entry = await lstat(path.join(source, file));
60
+ if (entry.isSymbolicLink()) throw new Error(`Static input cannot contain symbolic links: ${file}`);
61
+ if (entry.isDirectory()) { await copy(file, depth + 1); continue; }
62
+ if (!entry.isFile()) throw new Error(`Static input must contain regular files: ${file}`);
63
+ if (++count > MAX_FILES || (bytes += entry.size) > MAX_BYTES) throw new Error('Static input exceeds 20,000 files or 512 MiB.');
64
+ const target = path.join(stage, 'static', file);
65
+ await mkdir(path.dirname(target), { recursive: true });
66
+ await copyFile(path.join(source, file), target, constants.COPYFILE_EXCL);
67
+ const after = await lstat(path.join(source, file));
68
+ if (!after.isFile() || after.size !== entry.size || after.mtimeMs !== entry.mtimeMs || (await stat(target)).size !== entry.size) throw new Error(`Static input changed while being imported: ${file}. Retry after its build finishes.`);
69
+ add(urlFor(file), file);
70
+ if (file === 'index.html') add('/', file);
71
+ else if (file.endsWith('/index.html')) add(urlFor(file.slice(0, -11)), file);
72
+ else if (file.endsWith('.html')) add(urlFor(file.slice(0, -5)), file);
73
+ }
74
+ }
75
+ try {
76
+ await mkdir(stage);
77
+ await copy();
78
+ await writeFile(path.join(stage, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
79
+ await writeBuildOutput(stage, manifest, { adapter: 'static', spa });
80
+ await precompressBuild(stage, manifest, { cacheDirectory: path.join(root, '.prnext-cache/precompress-v1') });
81
+ // A failed import leaves the previously published build and pointer intact.
82
+ previous = await lstat(destination).then(() => true, error => { if (error.code === 'ENOENT') return false; throw error; });
83
+ await writeFile(pointer, JSON.stringify({ distDir: output }) + '\n', { flag: 'wx' });
84
+ if (previous) await rename(destination, backup);
85
+ try {
86
+ await rename(stage, destination);
87
+ published = true;
88
+ await rename(pointer, path.join(root, OUTPUT_POINTER));
89
+ } catch (error) {
90
+ if (published) await rename(destination, stage);
91
+ if (previous) await rename(backup, destination);
92
+ throw error;
93
+ }
94
+ if (previous) await rm(backup, { recursive: true, force: true });
95
+ return { ...manifest, outputDirectory: destination, files: count, bytes };
96
+ } finally {
97
+ await rm(pointer, { force: true });
98
+ await rm(stage, { recursive: true, force: true });
99
+ }
100
+ }
package/cli.mjs CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { rename } from './runtime/fs.mjs';
3
3
  import { spawn } from 'node:child_process';
4
- import { readFile, writeFile, rm } from 'node:fs/promises';
4
+ import { readFile, writeFile, rm, mkdir, readdir } from 'node:fs/promises';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import path from 'node:path';
7
7
  import { resolveNativeBinary, packageManifest } from './native/resolve.mjs';
8
+ import { replaceWithNative } from './native/launch.mjs';
8
9
  import { readBuildDirectory } from './runtime/build-directory.mjs';
9
10
  import { shouldWatchProjectFile } from './runtime/env.mjs';
10
11
 
@@ -15,6 +16,9 @@ const help = `PRNext ${packageManifest.version}
15
16
  Use prnext for portable scripts; prn is a shortcut on compatible shells.
16
17
 
17
18
  prnext build [directory] Build React/TypeScript and static HTML
19
+ prnext build [directory] --adapter static [--from dist] [--spa]
20
+ Import an existing public build directory
21
+ prnext output [directory] [--json] Read the versioned build output contract
18
22
  prnext start [directory] [options] Start native Rust production server
19
23
  prnext pstart [directory] [options] Start production persistently in the background
20
24
  prnext prestart [name] Replace a persistent app after a health check
@@ -26,11 +30,16 @@ Use prnext for portable scripts; prn is a shortcut on compatible shells.
26
30
  prnext dev [directory] [options] Rebuild and restart on source changes
27
31
  prnext routes [directory] Display compiled routes
28
32
  prnext check [directory] [--json] Check an existing Next project before building
29
- prnext inspect [directory] [--json] Explain routes, cache decisions and timings
33
+ prnext inspect [directory] [--json|--otlp] Explain routes, health and sampled timings
30
34
  prnext host [prnext.host.json] Host built apps by hostname; wake on demand
31
35
  prnext host [config] --check Validate hosting configuration and builds
32
36
  prnext host [config] --status [--json] Show memory, process and idle status
33
37
  prnext migrate [directory] [options] Migrate a Next project's scripts and dependencies
38
+ prn login [--server URL] Connect PRNext Cloud through your browser
39
+ prn deploy [directory] Upload sources, deploy and link the project
40
+ prn update [directory] Deploy changes to the linked Cloud project
41
+ prn whoami / prn logout Show or disconnect your terminal account
42
+ prn deploy --help Cloud options and local development
34
43
 
35
44
  Migration options: --dry-run (preview), --no-install (prepare only), --json
36
45
  prnext migrate --check Audit source compatibility without migrating
@@ -46,7 +55,7 @@ Memory favors lower RAM under concurrency, accepting more waiting and CPU per re
46
55
  Without an option: PRNEXT_PROFILE, legacy PRNEXT_MEMORY_PROFILE=compact, then balanced.
47
56
  Classic preserves the former standard settings; standard remains an alias. Legacy compact remains accepted.
48
57
  Development includes React Fast Refresh, stylesheet updates and an error overlay.
49
- Requires Node >=22. Published packages include the native server for supported platforms.
58
+ Requires Node 22.23.4+, 24.22.1+ or 26.12.1+. Published packages include the native server for supported platforms.
50
59
  Rust is only needed when building the framework from source.
51
60
  `;
52
61
 
@@ -69,6 +78,11 @@ function stop(child) {
69
78
  async function main() {
70
79
  if (['help', '--help', '-h'].includes(command)) { console.log(help); return; }
71
80
  if (['--version', '-v'].includes(command)) { console.log(packageManifest.version); return; }
81
+ if (['login', 'logout', 'whoami', 'deploy', 'update'].includes(command)) {
82
+ const { cloudCommand } = await import('./runtime/cloud/client.mjs');
83
+ await cloudCommand(command, argv);
84
+ return;
85
+ }
72
86
  if (['pstart', 'prestart', 'pstop', 'pstatus', 'plist', 'plogs', 'pdelete', 'pstartup', 'pdown'].includes(command)) {
73
87
  const { persistentCommand } = await import('./runtime/persistent/client.mjs');
74
88
  await persistentCommand(command, argv);
@@ -136,19 +150,36 @@ async function main() {
136
150
  if (!report.ok) process.exitCode = 1;
137
151
  return;
138
152
  }
139
- if (!['build', 'start', 'dev', 'routes', 'check', 'inspect'].includes(command)) throw new Error(`Unknown command: ${command}\n${help}`);
153
+ if (!['build', 'start', 'dev', 'routes', 'check', 'inspect', 'output'].includes(command)) throw new Error(`Unknown command: ${command}\n${help}`);
140
154
  const args = [...argv];
141
155
  const root = path.resolve(args[0] && !args[0].startsWith('-') ? args.shift() : '.');
156
+ if (command === 'output') {
157
+ if (args.some(arg => arg !== '--json')) throw new Error('output accepts only [directory] and --json');
158
+ const { readBuildOutput } = await import('./build/output-contract.mjs');
159
+ const output = await readBuildOutput(path.join(root, await readBuildDirectory(root)));
160
+ if (args.includes('--json')) console.log(JSON.stringify(output, null, 2));
161
+ else console.log(`PRNext output v${output.version}: ${output.adapter}\nBuild: ${output.buildId}\nManifest: ${output.manifest}\nAssets: ${output.assets}\nServer: ${output.server?.entry || 'none'}\nSPA fallback: ${output.fallback || 'disabled'}`);
162
+ return;
163
+ }
142
164
  if (command === 'inspect') {
143
- if (args.some(arg => arg !== '--json')) throw new Error('inspect accepts only [directory] and --json');
144
- const { inspectProject, printInspection } = await import('./build/inspect.mjs');
165
+ if (args.some(arg => !['--json', '--otlp'].includes(arg)) || args.includes('--json') && args.includes('--otlp')) throw new Error('inspect accepts [directory] and either --json or --otlp');
166
+ const { inspectProject, printInspection, inspectionMetrics } = await import('./build/inspect.mjs');
145
167
  const report = await inspectProject(root);
146
- if (args.includes('--json')) console.log(JSON.stringify(report, null, 2)); else printInspection(report);
168
+ if (args.includes('--otlp')) console.log(JSON.stringify(inspectionMetrics(report), null, 2));
169
+ else if (args.includes('--json')) console.log(JSON.stringify(report, null, 2)); else printInspection(report);
147
170
  return;
148
171
  }
149
172
  if (['start', 'dev'].includes(command) && args.includes('--inspect')) {
150
173
  args.splice(args.indexOf('--inspect'), 1);
151
174
  process.env.PRNEXT_INSPECT_DIR = path.join(root, '.prnext-cache/inspect');
175
+ await mkdir(process.env.PRNEXT_INSPECT_DIR, {recursive:true, mode:0o700});
176
+ process.env.PRNEXT_METRICS_FILE ||= path.join(process.env.PRNEXT_INSPECT_DIR, `metrics-${process.pid}.jsonl`);
177
+ const old = (await readdir(process.env.PRNEXT_INSPECT_DIR)).filter(name => /^metrics-\d+\.jsonl$/.test(name));
178
+ for (const name of old.slice(0, Math.max(0, old.length - 4))) {
179
+ const pid = Number(name.slice(8,-6));
180
+ try { process.kill(pid, 0); }
181
+ catch (error) { if (error.code === 'ESRCH') await rm(path.join(process.env.PRNEXT_INSPECT_DIR,name),{force:true}); }
182
+ }
152
183
  }
153
184
  if (command === 'build' || command === 'dev') {
154
185
  // Build plugins such as Contentlayer read cwd/INIT_CWD rather than webpack's
@@ -172,18 +203,40 @@ async function main() {
172
203
  return;
173
204
  }
174
205
  if (command === 'build') {
175
- const dev = args.includes('--dev');
176
- if (args.some(arg => arg !== '--dev')) throw new Error('build accepts only [directory] and --dev');
206
+ const options = {};
207
+ for (let i = 0; i < args.length; i++) {
208
+ const flag = args[i];
209
+ if (flag === '--dev') options.dev = true;
210
+ else if (flag === '--spa') options.spa = true;
211
+ else if (flag === '--adapter' || flag === '--from') {
212
+ const value = args[++i];
213
+ if (!value || value.startsWith('-')) throw new Error(`${flag} requires a value`);
214
+ const key = flag.slice(2);
215
+ if (options[key] !== undefined) throw new Error(`Duplicate build option: ${flag}`);
216
+ options[key] = value;
217
+ } else throw new Error(`Unknown build option: ${flag}`);
218
+ }
219
+ const dev = !!options.dev;
177
220
  const envMode = process.env.NODE_ENV === 'test' ? 'test' : undefined;
178
221
  process.env.NODE_ENV = dev ? 'development' : 'production';
179
- const { build } = await import('./build/index.mjs');
180
222
  const started = performance.now();
181
- await build(root, { dev, envMode });
223
+ if (options.adapter === 'static') {
224
+ const { buildStatic } = await import('./build/static-adapter.mjs');
225
+ await buildStatic(root, options);
226
+ } else {
227
+ const { build } = await import('./build/index.mjs');
228
+ await build(root, { ...options, dev, envMode });
229
+ }
182
230
  console.log(`PRNext built ${root} in ${((performance.now() - started) / 1000).toFixed(2)}s`);
183
231
  return;
184
232
  }
185
233
  const binary = await resolveNativeBinary();
186
234
  let outputDirectory = await readBuildDirectory(root);
235
+ const serverEnvironment = { ...process.env, NODE_ENV: process.env.NODE_ENV || (command === 'dev' ? 'development' : 'production') };
236
+ if (command === 'start' && serverEnvironment.NODE_ENV !== 'development') {
237
+ if (replaceWithNative(binary, ['start', root, '--worker', path.join(root, outputDirectory, 'runtime/worker.mjs'),
238
+ '--node', process.execPath, ...args], serverEnvironment)) return;
239
+ }
187
240
  const launch = () => spawn(binary, [command === 'routes' ? 'routes' : 'start', root,
188
241
  ...(command === 'routes' ? [] : ['--worker', path.join(root, outputDirectory, 'runtime/worker.mjs'), '--node', process.execPath,
189
242
  '--shutdown-on-stdin-eof']), ...args], { stdio: ['pipe', 'inherit', 'inherit'], windowsHide: true, env: { ...process.env, NODE_ENV: process.env.NODE_ENV || (command === 'dev' ? 'development' : 'production') } });
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+ const { AsyncLocalStorage } = require('node:async_hooks');
3
+
4
+ function after(task) {
5
+ const context = require('./headers.cjs').currentRequest();
6
+ if (require('./data-cache.cjs').inCacheScope()) throw new Error('after() cannot be registered inside a cache scope');
7
+ if (!context.afterScope || !['route', 'action', 'after'].includes(context.phase)) throw new Error('after() currently requires a Node Route Handler or Server Action in PRNext. Server Component and Edge after() lifecycles are not implemented.');
8
+ context.afterScope.add(task, context, AsyncLocalStorage.snapshot());
9
+ }
10
+
11
+ function createAfterScope({ signal, timeoutMs = 25000, onTimeout = () => {}, onComplete, report = error => console.error('[prnext] after() failed:', error?.stack || error) } = {}) {
12
+ const queue = []; let count = 0, closed = false, finishing;
13
+ const deadline = Date.now() + timeoutMs;
14
+ const track = promise => signal?.[Symbol.for('prnext.request-work')]?.(promise, { timeoutMs: Math.max(1, deadline - Date.now()) });
15
+ const observe = promise => Promise.resolve(promise).then(value => ({ value }), error => ({ error }));
16
+ const scope = {
17
+ add(task, context, run) {
18
+ if (closed) throw new Error('after() request has already completed');
19
+ if (typeof task !== 'function' && (!task || typeof task.then !== 'function')) throw new TypeError('after() expects a callback or a Promise');
20
+ if (++count > 128) throw new Error('after() exceeds 128 tasks per request');
21
+ const pending = typeof task === 'function' ? null : observe(task);
22
+ if (pending) track(pending);
23
+ queue.push({ task: pending ? null : task, pending, context, run });
24
+ },
25
+ finish() {
26
+ return finishing ||= (async () => {
27
+ try {
28
+ while (queue.length) {
29
+ const item = queue.shift(), remaining = deadline - Date.now();
30
+ if (remaining <= 0) { const error = new Error('after() exceeded the request maxDuration'); report(error); onTimeout(error); break; }
31
+ const abort = new AbortController();
32
+ item.context.cookies._mutable = false;
33
+ item.context.phase = 'after';
34
+ item.context.signal = abort.signal;
35
+ item.context.cacheState.closed = false;
36
+ const work = item.pending || observe(Promise.resolve().then(() => item.run(item.task)));
37
+ track(work);
38
+ let timer;
39
+ try {
40
+ const outcome = await Promise.race([work, new Promise(resolve => { timer = setTimeout(() => { const error = new Error('after() exceeded the request maxDuration'); abort.abort(error); resolve({ error }); }, remaining); })]);
41
+ if ('error' in outcome) report(outcome.error);
42
+ if (abort.signal.aborted) { onTimeout(outcome.error); break; }
43
+ } finally { clearTimeout(timer); item.context.cacheState.closed = true; await onComplete?.(item.context); }
44
+ }
45
+ } finally { closed = true; queue.length = 0; }
46
+ })();
47
+ },
48
+ };
49
+ return scope;
50
+ }
51
+ module.exports = { after, createAfterScope };
@@ -4,6 +4,10 @@ const directory = process.env.PRNEXT_INSPECT_DIR;
4
4
  const { threadId } = require('node:worker_threads');
5
5
  let queue = [], scheduled = false, writing = false, size;
6
6
  const limit = 1024 * 1024;
7
+ if (directory) setInterval(() => {
8
+ const {rss, heapUsed} = process.memoryUsage();
9
+ record('process', {rssBytes:rss, heapUsedBytes:heapUsed});
10
+ }, 5000).unref();
7
11
  function record(type, fields = {}) {
8
12
  if (!directory || queue.length >= 256) return;
9
13
  const line = JSON.stringify({ time: Date.now(), pid: process.pid, type, ...fields }) + '\n';