@thomas.f/prnext 0.1.11-alpha → 0.1.13-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 (60) hide show
  1. package/README.md +395 -29
  2. package/build/actions.mjs +4 -0
  3. package/build/app-config.mjs +2 -0
  4. package/build/app.mjs +20 -5
  5. package/build/bundle-runtime.mjs +82 -0
  6. package/build/cache-handlers.mjs +4 -1
  7. package/build/compiler.mjs +4 -1
  8. package/build/config.mjs +20 -2
  9. package/build/font-cache.mjs +50 -0
  10. package/build/font-files.mjs +5 -3
  11. package/build/fonts.mjs +1 -1
  12. package/build/index.mjs +12 -1
  13. package/build/metadata-conflicts.mjs +13 -0
  14. package/build/metadata-images.mjs +3 -3
  15. package/build/metadata-routes.mjs +3 -3
  16. package/build/native-packages.mjs +5 -1
  17. package/build/npm-packages.mjs +14 -2
  18. package/build/optimize-imports.mjs +112 -0
  19. package/build/proxy-body-config.mjs +10 -0
  20. package/build/runtime-contract.mjs +16 -0
  21. package/build/scan.mjs +9 -7
  22. package/build/typescript-check.mjs +20 -0
  23. package/build/typescript.mjs +49 -0
  24. package/build/worker-retirement.mjs +14 -0
  25. package/cli.mjs +17 -0
  26. package/compat/after.cjs +51 -0
  27. package/compat/index.d.ts +13 -1
  28. package/compat/server.cjs +2 -1
  29. package/compat/server.d.cts +2 -0
  30. package/native/launch.mjs +19 -0
  31. package/package.json +25 -9
  32. package/release/verify.mjs +9 -0
  33. package/runtime/api.mjs +15 -5
  34. package/runtime/app-render.mjs +164 -53
  35. package/runtime/app-route-plan.mjs +45 -0
  36. package/runtime/app-routing-state.mjs +101 -21
  37. package/runtime/app-routing.mjs +37 -18
  38. package/runtime/app-segments.mjs +25 -11
  39. package/runtime/app-static.mjs +1 -1
  40. package/runtime/app-stream-html.mjs +3 -53
  41. package/runtime/cache-request.mjs +10 -2
  42. package/runtime/cloud/archive.mjs +316 -0
  43. package/runtime/cloud/client.mjs +658 -0
  44. package/runtime/cloud/environment.mjs +60 -0
  45. package/runtime/cloud/state.mjs +141 -0
  46. package/runtime/cloud/upload.mjs +48 -0
  47. package/runtime/html-envelope.mjs +136 -0
  48. package/runtime/http.mjs +9 -6
  49. package/runtime/middleware.mjs +3 -3
  50. package/runtime/profiles.json +13 -8
  51. package/runtime/request-transport.mjs +1 -1
  52. package/runtime/request-work.mjs +2 -2
  53. package/runtime/route-static.mjs +1 -1
  54. package/runtime/rsc-lifecycle.mjs +119 -0
  55. package/runtime/rsc-work-watchdog.mjs +75 -0
  56. package/runtime/rsc-worker.mjs +36 -9
  57. package/runtime/stream-utils.mjs +22 -3
  58. package/runtime/transport.mjs +89 -53
  59. package/runtime/worker-metadata.mjs +23 -0
  60. package/runtime/worker.mjs +119 -27
@@ -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,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
+ }
@@ -0,0 +1,16 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile, readdir, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ // Protocol compatibility does not imply safe retirement. These modules jointly
6
+ // implement activity tracking, cancellation and post-response draining.
7
+ export const lifecycleModules = ['worker.mjs', 'transport.mjs', 'request-work.mjs', 'app-render.mjs', 'app-stream-html.mjs', 'rsc-worker.mjs'];
8
+ export async function writeRuntimeContract(stage) {
9
+ const files = (await Promise.all(['runtime', 'compat'].map(async directory =>
10
+ (await readdir(path.join(stage,directory))).filter(name => /\.(?:mjs|cjs|json)$/.test(name)).map(name => `${directory}/${name}`)))).flat().sort();
11
+ const modules = Object.fromEntries(await Promise.all(files.map(async name =>
12
+ [name, createHash('sha256').update(await readFile(path.join(stage, name))).digest('hex')])));
13
+ const contract = { schema: 1, protocol: 2, lifecycle: 1,
14
+ capabilities: ['activity', 'tracked-finalization', 'graceful-drain', 'lane-cancellation'], modules };
15
+ await writeFile(path.join(stage, 'runtime-contract.json'), JSON.stringify(contract, null, 2) + '\n');
16
+ }
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
 
@@ -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,20 @@
1
+ import { createRequire } from 'node:module';
2
+
3
+ const [, , compiler, filename] = process.argv;
4
+ const ts = createRequire(import.meta.url)(compiler);
5
+ const host = {
6
+ ...ts.sys,
7
+ onUnRecoverableConfigFileDiagnostic(diagnostic) {
8
+ process.stderr.write(ts.formatDiagnostics([diagnostic], formatHost));
9
+ process.exit(1);
10
+ },
11
+ };
12
+ const formatHost = { getCanonicalFileName: name => name, getCurrentDirectory: ts.sys.getCurrentDirectory, getNewLine: () => ts.sys.newLine };
13
+ const parsed = ts.getParsedCommandLineOfConfigFile(filename, { noEmit: true }, host);
14
+ const diagnostics = parsed.errors.length ? parsed.errors : ts.getPreEmitDiagnostics(ts.createProgram({
15
+ rootNames: parsed.fileNames, options: parsed.options, projectReferences: parsed.projectReferences,
16
+ }));
17
+ if (diagnostics.length) {
18
+ process.stderr.write(ts.formatDiagnostics(diagnostics, formatHost));
19
+ process.exitCode = 1;
20
+ }
@@ -0,0 +1,49 @@
1
+ import { stat } from 'node:fs/promises';
2
+ import { createRequire } from 'node:module';
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ import { fileURLToPath } from 'node:url';
6
+ import path from 'node:path';
7
+
8
+ const execute = promisify(execFile);
9
+
10
+ export function validateTypeScript(input) {
11
+ if (input === undefined) return undefined;
12
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw new TypeError('typescript must be an object');
13
+ for (const key of Object.keys(input)) if (!['ignoreBuildErrors', 'tsconfigPath'].includes(key)) throw new TypeError(`typescript.${key} is not supported`);
14
+ const { ignoreBuildErrors = false, tsconfigPath = 'tsconfig.json' } = input;
15
+ if (typeof ignoreBuildErrors !== 'boolean') throw new TypeError('typescript.ignoreBuildErrors must be a boolean');
16
+ if (typeof tsconfigPath !== 'string' || !tsconfigPath || tsconfigPath.includes('\0') || Buffer.byteLength(tsconfigPath) > 4096) throw new TypeError('typescript.tsconfigPath must be a nonempty filesystem path of at most 4096 bytes');
17
+ return { ignoreBuildErrors, tsconfigPath };
18
+ }
19
+
20
+ export async function projectTsconfig(root, config) {
21
+ const name = config.typescript?.tsconfigPath ?? 'tsconfig.json';
22
+ const filename = path.resolve(root, name);
23
+ try {
24
+ if (!(await stat(filename)).isFile()) throw new Error(`TypeScript configuration is not a file: ${filename}`);
25
+ return filename;
26
+ } catch (error) {
27
+ if (error.code !== 'ENOENT') throw error;
28
+ if (name !== 'tsconfig.json') throw new Error(`typescript.tsconfigPath does not exist: ${filename}`, { cause: error });
29
+ return undefined;
30
+ }
31
+ }
32
+
33
+ /** Run the checker outside the build/dev process so its graph is released on exit.
34
+ * Never emit files, rewrite the user's config, or install packages implicitly. */
35
+ export async function checkTypeScript(root, config, { dev = false, tsconfig } = {}) {
36
+ if (dev || !config.typescript || config.typescript.ignoreBuildErrors) return;
37
+ tsconfig ??= await projectTsconfig(root, config);
38
+ if (!tsconfig) throw new Error('TypeScript checking was explicitly enabled, but tsconfig.json does not exist. Create a TypeScript configuration, set typescript.tsconfigPath to an existing configuration, or explicitly set typescript.ignoreBuildErrors: true if types are checked separately.');
39
+ let compiler;
40
+ try { compiler = createRequire(path.join(root, 'package.json')).resolve('typescript'); }
41
+ catch (error) { throw new Error('TypeScript checking requires typescript in the project. Install it as a dev dependency, or explicitly set typescript.ignoreBuildErrors: true if a separate process checks types.', { cause: error }); }
42
+ try {
43
+ await execute(process.execPath, [fileURLToPath(new URL('./typescript-check.mjs', import.meta.url)), compiler, tsconfig], {
44
+ cwd: root, windowsHide: true, maxBuffer: 4 * 1024 * 1024,
45
+ });
46
+ } catch (error) {
47
+ throw new Error(`TypeScript check failed (${path.relative(root, tsconfig)}):\n${error.stdout || error.stderr || error.message}`, { cause: error });
48
+ }
49
+ }
@@ -0,0 +1,14 @@
1
+ export function validateWorkerRetirement(value) {
2
+ if (value === undefined) return undefined;
3
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('workerRetirement must be an object');
4
+ for (const [key, setting] of Object.entries(value)) {
5
+ if (!['idleSeconds', 'laneIdleSeconds', 'keepWarm'].includes(key)) throw new TypeError(`workerRetirement.${key} is not supported`);
6
+ if (setting === undefined) continue;
7
+ if (key === 'keepWarm') {
8
+ if (typeof setting !== 'boolean') throw new TypeError('workerRetirement.keepWarm must be a boolean');
9
+ } else if (!Number.isSafeInteger(setting) || setting < 0 || setting > 604800) {
10
+ throw new TypeError(`workerRetirement.${key} must be an integer between 0 and 604800 seconds (0 disables retirement)`);
11
+ }
12
+ }
13
+ return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined));
14
+ }
package/cli.mjs CHANGED
@@ -5,6 +5,7 @@ import { readFile, writeFile, rm } 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
 
@@ -31,6 +32,11 @@ Use prnext for portable scripts; prn is a shortcut on compatible shells.
31
32
  prnext host [config] --check Validate hosting configuration and builds
32
33
  prnext host [config] --status [--json] Show memory, process and idle status
33
34
  prnext migrate [directory] [options] Migrate a Next project's scripts and dependencies
35
+ prn login [--server URL] Connect PRNext Cloud through your browser
36
+ prn deploy [directory] Upload sources, deploy and link the project
37
+ prn update [directory] Deploy changes to the linked Cloud project
38
+ prn whoami / prn logout Show or disconnect your terminal account
39
+ prn deploy --help Cloud options and local development
34
40
 
35
41
  Migration options: --dry-run (preview), --no-install (prepare only), --json
36
42
  prnext migrate --check Audit source compatibility without migrating
@@ -41,6 +47,7 @@ Simple next dev/build/start scripts become prnext commands; custom shell scripts
41
47
 
42
48
  Server options: --port 3000 --hostname 127.0.0.1 --workers 1 --inspect
43
49
  Production profiles: --profile balanced|speed|memory|classic
50
+ Worker retirement: --worker-idle-seconds 300 --lane-idle-seconds 30 --keep-warm [true|false]
44
51
  Memory favors lower RAM under concurrency, accepting more waiting and CPU per response.
45
52
  Without an option: PRNEXT_PROFILE, legacy PRNEXT_MEMORY_PROFILE=compact, then balanced.
46
53
  Classic preserves the former standard settings; standard remains an alias. Legacy compact remains accepted.
@@ -68,6 +75,11 @@ function stop(child) {
68
75
  async function main() {
69
76
  if (['help', '--help', '-h'].includes(command)) { console.log(help); return; }
70
77
  if (['--version', '-v'].includes(command)) { console.log(packageManifest.version); return; }
78
+ if (['login', 'logout', 'whoami', 'deploy', 'update'].includes(command)) {
79
+ const { cloudCommand } = await import('./runtime/cloud/client.mjs');
80
+ await cloudCommand(command, argv);
81
+ return;
82
+ }
71
83
  if (['pstart', 'prestart', 'pstop', 'pstatus', 'plist', 'plogs', 'pdelete', 'pstartup', 'pdown'].includes(command)) {
72
84
  const { persistentCommand } = await import('./runtime/persistent/client.mjs');
73
85
  await persistentCommand(command, argv);
@@ -183,6 +195,11 @@ async function main() {
183
195
  }
184
196
  const binary = await resolveNativeBinary();
185
197
  let outputDirectory = await readBuildDirectory(root);
198
+ const serverEnvironment = { ...process.env, NODE_ENV: process.env.NODE_ENV || (command === 'dev' ? 'development' : 'production') };
199
+ if (command === 'start' && serverEnvironment.NODE_ENV !== 'development') {
200
+ if (replaceWithNative(binary, ['start', root, '--worker', path.join(root, outputDirectory, 'runtime/worker.mjs'),
201
+ '--node', process.execPath, ...args], serverEnvironment)) return;
202
+ }
186
203
  const launch = () => spawn(binary, [command === 'routes' ? 'routes' : 'start', root,
187
204
  ...(command === 'routes' ? [] : ['--worker', path.join(root, outputDirectory, 'runtime/worker.mjs'), '--node', process.execPath,
188
205
  '--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 };
package/compat/index.d.ts CHANGED
@@ -112,14 +112,26 @@ export interface Rewrite extends CustomRoute { destination: string }
112
112
  export type ConfigRedirect = CustomRoute & Redirect;
113
113
  /** Implemented project options. Unsupported behavior-changing options fail the build. */
114
114
  export interface NextConfig {
115
+ /** PRNext only. CLI > environment > build configuration > profile.
116
+ * 0 disables the corresponding retirement; keepWarm retains one lane per used host.
117
+ */
118
+ workerRetirement?: { idleSeconds?: number; laneIdleSeconds?: number; keepWarm?: boolean };
115
119
  /** Next dev page-retention hints; PRNext builds all routes without that eviction queue. */
116
120
  onDemandEntries?: { maxInactiveAge?: number; pagesBufferLength?: number };
121
+ /** Disable the Next development indicator. Error overlays and Fast Refresh remain enabled. */
122
+ devIndicators?: false;
117
123
  i18n?: { locales: string[]; defaultLocale: string; localeDetection?: boolean; domains?: Array<{domain: string; defaultLocale: string; locales?: string[]; http?: boolean}> };
118
124
  webpack?: (config: import('webpack').Configuration, context: { buildId: string; dev: boolean; isServer: boolean; nextRuntime?: 'nodejs' | 'edge'; webpack: typeof import('webpack'); defaultLoaders: {babel: {loader: string}} }) => import('webpack').Configuration | Promise<import('webpack').Configuration>;
119
125
  turbopack?: { root?: string; rules?: Record<string, TurbopackRule | TurbopackRule[]>; resolveAlias?: Record<string, string | { browser: string }>; resolveExtensions?: string[] };
120
126
  images?: import('./image.cjs').ImageConfig;
121
127
  pageExtensions?: string[];
122
128
  reactStrictMode?: boolean;
129
+ /** Enabled React Compiler transforms are not implemented. */
130
+ reactCompiler?: false;
131
+ /** Typed route generation and validation are not implemented. */
132
+ typedRoutes?: false;
133
+ /** Production checks use the project's installed TypeScript without emitting files. */
134
+ typescript?: { ignoreBuildErrors?: boolean; tsconfigPath?: string };
123
135
  cacheComponents?: boolean;
124
136
  cacheLife?: Record<string, import('./cache.cjs').CacheLife>;
125
137
  transpilePackages?: string[];
@@ -131,7 +143,7 @@ export interface NextConfig {
131
143
  implementation?: 'sass' | 'sass-embedded';
132
144
  [option: string]: unknown;
133
145
  };
134
- experimental?: { nextScriptWorkers?: boolean; serverActions?: true | { allowedOrigins?: string[]; bodySizeLimit?: number | string } };
146
+ experimental?: { nextScriptWorkers?: boolean; optimizePackageImports?: string[]; proxyClientMaxBodySize?: number | string; serverActions?: true | { allowedOrigins?: string[]; bodySizeLimit?: number | string } };
135
147
  trailingSlash?: boolean;
136
148
  skipTrailingSlashRedirect?: boolean;
137
149
  skipMiddlewareUrlNormalize?: boolean;
package/compat/server.cjs CHANGED
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
  const { NextRequest, NextResponse, NextURL } = require('./server-primitives.cjs');
3
+ const { after } = require('./after.cjs');
3
4
 
4
5
  async function connection() {
5
6
  const context = require('./headers.cjs').currentRequest();
@@ -10,4 +11,4 @@ async function connection() {
10
11
  dynamicUsage('connection()', context);
11
12
  }
12
13
 
13
- module.exports = { NextRequest, NextResponse, NextURL, connection };
14
+ module.exports = { NextRequest, NextResponse, NextURL, connection, after };
@@ -35,3 +35,5 @@ export interface MiddlewareConfig {
35
35
  export interface ProxyConfig { matcher?: MiddlewareConfig['matcher'] }
36
36
  /** Exclude the following render from prerendering until a request is available. */
37
37
  export function connection(): Promise<void>;
38
+ /** Post-response work in Node Route Handlers. Other after() lifecycles fail explicitly. */
39
+ export function after(task: Promise<unknown> | (() => unknown)): void;
@@ -0,0 +1,19 @@
1
+ // A production CLI has nothing to supervise once the native server starts.
2
+ // On supported Linux Node releases, replace it instead of retaining a Node heap.
3
+ // Keep the portable parent/pipe lifecycle for Windows, older Node, IPC callers
4
+ // and instrumentation which may own descriptors or need JS exit handlers.
5
+ export function replaceWithNative(binary, args, env, runtime = process) {
6
+ const options = [...(runtime.execArgv || []), env.NODE_OPTIONS || ''].join(' ');
7
+ // Only heap sizing is known not to require the Node launcher's lifetime.
8
+ // Be conservative for all other Node flags, including future diagnostics,
9
+ // loaders, preloads, permissions and signal-triggered reports/snapshots.
10
+ const customRuntime = options.replace(/(?:^|\s)--max[-_](?:semi|old)[-_]space[-_]size(?:=|\s+)\d+(?=\s|$)/g, '').trim();
11
+ if (runtime.platform !== 'linux' || typeof runtime.execve !== 'function'
12
+ || typeof runtime.send === 'function' || env.NODE_V8_COVERAGE || customRuntime) return false;
13
+ // execve keeps stdin/out/err and the PID. No parent pipe is needed: signals
14
+ // reach Rust directly, and its worker pipes still supervise every Node child.
15
+ // The marker also makes an old native binary fail clearly instead of using
16
+ // this lifecycle without the Linux parent-death guard it requires.
17
+ runtime.execve(binary, [binary, ...args, '--cli-exec'], env);
18
+ return true; // Only reached by injected test runtimes; real execve never returns.
19
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thomas.f/prnext",
3
- "version": "0.1.11-alpha",
3
+ "version": "0.1.13-alpha",
4
4
  "license": "MIT",
5
5
  "description": "Experimental Rust-powered runtime for Next.js apps, React and npm",
6
6
  "homepage": "https://prnext.dev",
@@ -17,14 +17,30 @@
17
17
  "name": "Thomas",
18
18
  "url": "https://github.com/Lorkydey"
19
19
  },
20
- "keywords": ["prnext", "nextjs", "react", "rust", "framework", "ssr", "server-components"],
20
+ "keywords": [
21
+ "prnext",
22
+ "nextjs",
23
+ "react",
24
+ "rust",
25
+ "framework",
26
+ "ssr",
27
+ "server-components"
28
+ ],
21
29
  "type": "module",
22
30
  "publishConfig": {
23
31
  "access": "public",
24
32
  "tag": "alpha",
25
33
  "registry": "https://registry.npmjs.org/"
26
34
  },
27
- "files": ["cli.mjs", "index.mjs", "build/", "runtime/", "compat/", "native/", "release/"],
35
+ "files": [
36
+ "cli.mjs",
37
+ "index.mjs",
38
+ "build/",
39
+ "runtime/",
40
+ "compat/",
41
+ "native/",
42
+ "release/"
43
+ ],
28
44
  "scripts": {
29
45
  "prepublishOnly": "node release/verify.mjs"
30
46
  },
@@ -117,12 +133,12 @@
117
133
  "react-server-dom-webpack": "19.3.0"
118
134
  },
119
135
  "optionalDependencies": {
120
- "prnext-darwin-arm64": "0.1.11-alpha",
121
- "prnext-darwin-x64": "0.1.11-alpha",
122
- "prnext-linux-arm64-gnu": "0.1.11-alpha",
123
- "prnext-linux-x64-gnu": "0.1.11-alpha",
124
- "@thomas.f/prnext-win32-arm64-msvc": "0.1.11-alpha",
125
- "@thomas.f/prnext-win32-x64-msvc": "0.1.11-alpha"
136
+ "prnext-darwin-arm64": "0.1.13-alpha",
137
+ "prnext-darwin-x64": "0.1.13-alpha",
138
+ "prnext-linux-arm64-gnu": "0.1.13-alpha",
139
+ "prnext-linux-x64-gnu": "0.1.13-alpha",
140
+ "@thomas.f/prnext-win32-x64-msvc": "0.1.13-alpha",
141
+ "@thomas.f/prnext-win32-arm64-msvc": "0.1.13-alpha"
126
142
  },
127
143
  "engines": {
128
144
  "node": ">=22"
@@ -26,6 +26,15 @@ export async function verifyPackage() {
26
26
  if (source) {
27
27
  const cargo = await readFile(path.join(source, 'crates/prnext/Cargo.toml'), 'utf8');
28
28
  if (!cargo.includes(`version = "${pkg.version}"`)) throw new Error('Cargo and npm versions differ. Update crates/prnext/Cargo.toml and Cargo.lock before building release binaries.');
29
+ const top = JSON.parse(await readFile(path.join(source, 'package.json'), 'utf8'));
30
+ const lock = JSON.parse(await readFile(path.join(source, 'package-lock.json'), 'utf8'));
31
+ const cargoLock = (await readFile(path.join(source, 'Cargo.lock'), 'utf8')).replaceAll('\r\n', '\n');
32
+ if ([top.version, lock.version, lock.packages[''].version, lock.packages['packages/prnext'].version].some(version => version !== pkg.version) ||
33
+ !cargoLock.includes(`name = "prnext"\nversion = "${pkg.version}"`)) throw new Error('Workspace and lockfile versions differ; run scripts/release-version.mjs before building.');
34
+ for (const target of platforms) {
35
+ const native = lock.packages[`node_modules/${target.package}`];
36
+ if (native?.version !== pkg.version || native.optional !== true || lock.packages['packages/prnext'].optionalDependencies[target.package] !== pkg.version) throw new Error(`Missing or stale native lock entry: ${target.package}`);
37
+ }
29
38
  }
30
39
  }
31
40
 
package/runtime/api.mjs CHANGED
@@ -178,6 +178,7 @@ function parseBody(bytes, contentType = '') {
178
178
  }
179
179
 
180
180
  export function runApi(options) {
181
+ if (options.timeoutMs === undefined && options.route?.cacheConfig?.maxDuration) options = { ...options, timeoutMs: options.route.cacheConfig.maxDuration * 1000 };
181
182
  return withCacheRequest(options, options.route?.router === 'app' ? 'route' : 'pages', context => runApiInner(options, context));
182
183
  }
183
184
 
@@ -189,11 +190,19 @@ async function runApiInner({ modulePath, route = {}, manifest = {}, production =
189
190
  const mod = (pages && loadedPagesModule(modulePath)) || await withSignal(loadModule(modulePath, pages), stream ? signal : undefined);
190
191
  const requestOriginal = originalUrl ? new URL(originalUrl, url) : undefined;
191
192
  if (requestOriginal) requestOriginal.pathname = removeBasePath(requestOriginal.pathname, manifest.config?.basePath || '');
192
- const { request, bytes } = createRequest({ url, originalUrl: requestOriginal?.href, params, method, headers, body });
193
- request.draftMode = cacheContext.draftMode;
194
- request.previewData = cacheContext.previewData;
195
- request.preview = cacheContext.previewData !== false;
196
- if (typeof mod.default !== 'function') {
193
+ const appHandler = typeof mod.default !== 'function';
194
+ // App Route Handlers receive a NextRequest below. Constructing the Pages
195
+ // IncomingMessage here duplicates URL, header and cookie work and allocates
196
+ // an unused Duplex. Keep the existing Pages request path unchanged.
197
+ const { request, bytes } = appHandler
198
+ ? { bytes: Buffer.isBuffer(body) ? body : Buffer.from(body || '', 'base64') }
199
+ : createRequest({ url, originalUrl: requestOriginal?.href, params, method, headers, body });
200
+ if (!appHandler) {
201
+ request.draftMode = cacheContext.draftMode;
202
+ request.previewData = cacheContext.previewData;
203
+ request.preview = cacheContext.previewData !== false;
204
+ }
205
+ if (appHandler) {
197
206
  const {NextRequest, proxyRouteRequest} = await appRequestPrimitives();
198
207
  if (route.router === 'app' && manifest.dev) {
199
208
  const listing = await collectStaticParams({ page: mod, pageConfig: route.handlerConfig, segments: [] }, route.pattern);
@@ -221,6 +230,7 @@ async function runApiInner({ modulePath, route = {}, manifest = {}, production =
221
230
  const timeout = lifecycle && setTimeout(() => lifecycle.controller.abort(apiTimeoutError('response headers', timeoutMs)), timeoutMs);
222
231
  timeout?.unref();
223
232
  const pendingResponse = runRequestContext({ url, method, headers, params, production, cacheComponents: manifest.config?.cacheComponents, cacheLife: manifest.config?.cacheLife, cacheHandlers: manifest.config?.cacheHandlers, cacheHandler: manifest.config?.cacheHandler, cacheMaxMemorySize: manifest.config?.cacheMaxMemorySize, distDir: path.dirname(path.dirname(modulePath)), signal: lifecycle?.controller.signal || signal, previewModeId: manifest.previewModeId, basePath: manifest.config?.basePath || '', routePattern: route.pattern, cacheConfig: route.cacheConfig, phase: 'route', cacheState: cacheContext.cacheState, staticState: cacheContext.staticState, mutableCookies: true }, async () => {
233
+ currentRequest().afterScope = cacheContext.afterScope;
224
234
  let result;
225
235
  try { result = await handler(proxyRouteRequest(new NextRequest(requestOriginal || url, init), currentRequest()), { params: Promise.resolve(params) }); }
226
236
  catch (error) {