@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.
- package/README.md +305 -65
- package/build/ADAPTERS.md +121 -0
- package/build/actions.mjs +4 -0
- package/build/app-config.mjs +2 -0
- package/build/app.mjs +23 -5
- package/build/build-lock.mjs +71 -0
- package/build/bundle-runtime.mjs +82 -0
- package/build/compiler.mjs +3 -1
- package/build/config.mjs +9 -2
- package/build/font-cache.mjs +50 -0
- package/build/font-files.mjs +5 -3
- package/build/fonts.mjs +1 -1
- package/build/index.mjs +12 -1
- package/build/inspect.mjs +20 -2
- package/build/inspection-summary.mjs +37 -0
- package/build/metadata-conflicts.mjs +13 -0
- package/build/metadata-images.mjs +3 -3
- package/build/metadata-routes.mjs +3 -3
- package/build/native-packages.mjs +5 -1
- package/build/npm-packages.mjs +2 -1
- package/build/og.mjs +3 -9
- package/build/optimize-imports.mjs +112 -0
- package/build/output-contract.mjs +41 -0
- package/build/precompress.mjs +13 -0
- package/build/proxy-body-config.mjs +10 -0
- package/build/scan.mjs +10 -8
- package/build/static-adapter.mjs +100 -0
- package/cli.mjs +64 -11
- package/compat/after.cjs +51 -0
- package/compat/diagnostics.cjs +4 -0
- package/compat/index.d.ts +32 -1
- package/compat/server.cjs +2 -1
- package/compat/server.d.cts +2 -0
- package/index.mjs +2 -0
- package/native/launch.mjs +19 -0
- package/package.json +8 -9
- package/release/verify.mjs +2 -0
- package/runtime/api.mjs +20 -7
- package/runtime/app-render.mjs +78 -33
- package/runtime/app-route-plan.mjs +45 -0
- package/runtime/app-routing-state.mjs +101 -21
- package/runtime/app-routing.mjs +37 -18
- package/runtime/app-segments.mjs +25 -11
- package/runtime/app-static.mjs +1 -1
- package/runtime/app-stream-html.mjs +2 -1
- package/runtime/cache-request.mjs +10 -2
- package/runtime/cloud/archive.mjs +317 -0
- package/runtime/cloud/client.mjs +641 -0
- package/runtime/cloud/environment.mjs +60 -0
- package/runtime/cloud/state.mjs +156 -0
- package/runtime/cloud/upload.mjs +48 -0
- package/runtime/html-envelope.mjs +70 -7
- package/runtime/http.mjs +7 -4
- package/runtime/middleware.mjs +3 -3
- package/runtime/og/Geist-Regular.ttf +0 -0
- package/runtime/og/LICENSE +373 -0
- package/runtime/og/LICENSE.Geist +93 -0
- package/runtime/og/LICENSE.fflate +21 -0
- package/runtime/og/LICENSE.harfbuzzjs +22 -0
- package/runtime/og/LICENSE.satori +373 -0
- package/runtime/og/NOTICE.txt +4 -0
- package/runtime/og/hb.wasm +0 -0
- package/runtime/og/index.node.mjs +31260 -0
- package/runtime/og/resvg.wasm +0 -0
- package/runtime/og-backend.mjs +8 -0
- package/runtime/og-loader.mjs +2 -15
- package/runtime/request-transport.mjs +1 -1
- package/runtime/route-static.mjs +1 -1
- package/runtime/rsc-worker.mjs +27 -4
- package/runtime/stream-utils.mjs +22 -3
- package/runtime/transport.mjs +16 -0
- package/runtime/worker-metadata.mjs +23 -0
- 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
|
-
|
|
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) {
|
package/build/npm-packages.mjs
CHANGED
|
@@ -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
|
|
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
|
|
2
|
-
import {
|
|
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
|
|
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
|
+
}
|
package/build/precompress.mjs
CHANGED
|
@@ -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('
|
|
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
|
-
|
|
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)
|
|
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])
|
|
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]
|
|
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
|
|
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
|
|
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('--
|
|
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
|
|
176
|
-
|
|
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
|
-
|
|
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') } });
|
package/compat/after.cjs
ADDED
|
@@ -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/diagnostics.cjs
CHANGED
|
@@ -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';
|