@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
package/build/app.mjs
CHANGED
|
@@ -118,7 +118,7 @@ function boundaryEntry(boundary) {
|
|
|
118
118
|
return `import * as client from ${JSON.stringify(boundary.file)};\n` + boundary.exports.map((name, index) => `const ref${index}=client[${JSON.stringify(name)}];export {ref${index} as ${JSON.stringify(name)}};`).join('\n');
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
function routeEntry(route, globalError) {
|
|
121
|
+
function routeEntry(route, globalError, dev) {
|
|
122
122
|
if (route.kind === 'api') return `export * from ${JSON.stringify(route.file)};`;
|
|
123
123
|
const imports = [`import * as page from ${JSON.stringify(route.file)};`, 'export {page};', `export const pageConfig=${JSON.stringify(route.pageConfig || {})};`];
|
|
124
124
|
const rootLayout = route.segments.find(segment => segment.layout)?.layout;
|
|
@@ -144,6 +144,9 @@ function routeEntry(route, globalError) {
|
|
|
144
144
|
if (route.routing || route.segments.some(segment => segment.error)) imports.push(`export {default as ErrorBoundary} from ${JSON.stringify(path.join(packageRoot, 'compat/app-error-boundary.cjs'))};`);
|
|
145
145
|
if (route.routing) {
|
|
146
146
|
let index = 0;
|
|
147
|
+
// Only scaffold objects owned by this compiler are immutable. Imported
|
|
148
|
+
// modules and config values retain their original live bindings/behavior.
|
|
149
|
+
const structural = source => dev ? source : `Object.freeze(${source})`;
|
|
147
150
|
function serialize(value) {
|
|
148
151
|
const files = [];
|
|
149
152
|
for (const [name, file] of Object.entries(value.files)) {
|
|
@@ -151,7 +154,7 @@ function routeEntry(route, globalError) {
|
|
|
151
154
|
imports.push(`import * as ${binding} from ${JSON.stringify(file)};`);
|
|
152
155
|
files.push(`${JSON.stringify(name)}:${binding}`);
|
|
153
156
|
}
|
|
154
|
-
return `{path:${JSON.stringify(value.path)},segment:${JSON.stringify(value.segment)},pattern:${JSON.stringify(value.pattern)},layoutId:${JSON.stringify(value.layoutId)},config:${JSON.stringify(value.config || {})},interception:${JSON.stringify(value.interception || null)},files
|
|
157
|
+
return structural(`{path:${JSON.stringify(value.path)},segment:${JSON.stringify(value.segment)},pattern:${JSON.stringify(value.pattern)},layoutId:${JSON.stringify(value.layoutId)},config:${JSON.stringify(value.config || {})},interception:${JSON.stringify(value.interception || null)},files:${structural(`{${files.join(',')}}`)},children:${structural(`[${value.children.map(serialize).join(',')}]`)},slots:${structural(`{${Object.entries(value.slots).map(([name, child]) => `${JSON.stringify(name)}:${serialize(child)}`).join(',')}}`)}}`);
|
|
155
158
|
}
|
|
156
159
|
const routing = serialize(route.routing);
|
|
157
160
|
imports.push(`export const routing=${routing};export const interceptionOnly=${!!route.interceptionOnly};`);
|
|
@@ -263,8 +266,17 @@ export async function compileApp({ project, stage, manifest, cssModules, fonts,
|
|
|
263
266
|
return { path: args.path, namespace: 'prnext-app-empty' };
|
|
264
267
|
});
|
|
265
268
|
esbuild.onLoad({ filter: /.*/, namespace: 'prnext-app-empty' }, () => ({ contents: '', loader: 'js' }));
|
|
269
|
+
if (rsc) esbuild.onLoad({ filter: /^react$/, namespace: 'prnext-rsc-react' }, () => ({
|
|
270
|
+
// Keep the real singleton while preserving bundler-style CJS access.
|
|
271
|
+
// Mixed npm modules may export both server-safe components and unused
|
|
272
|
+
// hook-based components. Native ESM named imports would reject the
|
|
273
|
+
// entire module because React's server build has no client hooks.
|
|
274
|
+
contents: `import React from 'react';export default React;export const {${Object.keys(resolveFromProject('react')).filter(name => t.isValidIdentifier(name) && name !== 'default').join(',')}}=React;`,
|
|
275
|
+
loader: 'js',
|
|
276
|
+
}));
|
|
266
277
|
esbuild.onResolve({ filter: /^(?:react|react-dom|react-server-dom-webpack)(?:\/|$)/ }, args => {
|
|
267
278
|
if (args.pluginData?.prnextExportScan) return;
|
|
279
|
+
if (rsc && args.path === 'react' && args.namespace !== 'prnext-rsc-react') return { path: 'react', namespace: 'prnext-rsc-react' };
|
|
268
280
|
return browser ? { path: resolveFromProject.resolve(args.path) } : { path: args.path, external: true };
|
|
269
281
|
});
|
|
270
282
|
if (!browser) {
|
|
@@ -379,6 +391,9 @@ export async function compileApp({ project, stage, manifest, cssModules, fonts,
|
|
|
379
391
|
define:{...common.define,'process.env.NEXT_RUNTIME':'"nodejs"'},
|
|
380
392
|
chunkNames: 'chunk-[hash]', banner: { js: "import {createRequire as __prnextCreateRequire} from 'node:module';const require=__prnextCreateRequire(import.meta.url);" },
|
|
381
393
|
target: 'node22', conditions: mode === 'rsc' ? ['react-server', 'node'] : ['node'], sourcemap: dev,
|
|
394
|
+
// Dual-format packages can retain use-client boundaries only in their
|
|
395
|
+
// ESM distribution (a bundled CommonJS entry can erase those directives).
|
|
396
|
+
mainFields: mode === 'rsc' ? ['module', 'main'] : ['main', 'module'],
|
|
382
397
|
plugins: [moduleResolutionPlugin(moduleResolution, project.root), images.plugin(), fonts.plugin(), graphPlugin(mode, { styleTarget }), npmPackagesPlugin({ projectRoot: project.root, serverExternalPackages }), nativePackages.plugin(mode), cssModules.plugin(false)],
|
|
383
398
|
});
|
|
384
399
|
if (nativePackages.version !== nativeVersion) {
|
|
@@ -446,14 +461,14 @@ export async function compileApp({ project, stage, manifest, cssModules, fonts,
|
|
|
446
461
|
}
|
|
447
462
|
|
|
448
463
|
await mkdir(path.join(stage, 'assets'), { recursive: true });
|
|
449
|
-
const entries = {};
|
|
464
|
+
const entries = {}, handlerEntries = {};
|
|
450
465
|
for (const route of routes) {
|
|
451
466
|
const entry = path.join(stage, '.entries', route.id + '.server.mjs');
|
|
452
467
|
if ((route.handlerConfig?.runtime === 'edge' || route.cacheConfig?.runtime === 'edge') && manifest.config?.cacheComponents) throw new Error(`Cache Components cannot run in the Edge Runtime (${route.file}).`);
|
|
453
|
-
await writeFile(entry, routeEntry(route, globalError));
|
|
468
|
+
await writeFile(entry, routeEntry(route, globalError, dev));
|
|
454
469
|
if (route.cacheConfig?.runtime === 'edge' && route.kind === 'page') await edgeAppBuild(entry, path.join(stage, 'server', route.id + '.mjs'), 'rsc');
|
|
455
470
|
else if (route.handlerConfig?.runtime === 'edge') await compileEdge({ file: route.file, outfile: path.join(stage, 'server', route.id + '.mjs'), projectRoot: project.root, dev, defineEnvironment });
|
|
456
|
-
else entries[route.id] = entry;
|
|
471
|
+
else (route.kind === 'api' ? handlerEntries : entries)[route.id] = entry;
|
|
457
472
|
const instantConfigs = [route.pageConfig, ...(route.segments || []).map(segment=>segment.staticConfig)];
|
|
458
473
|
const collectInstant = node => {if(!node)return;instantConfigs.push(...Object.values(node.config || {}));for(const child of [...(node.children || []),...Object.values(node.slots || {})])collectInstant(child);};
|
|
459
474
|
collectInstant(route.routing);
|
|
@@ -466,6 +481,9 @@ export async function compileApp({ project, stage, manifest, cssModules, fonts,
|
|
|
466
481
|
}
|
|
467
482
|
manifest.app = { clientModules: {}, actions: {}, actionKey: actions.actionKey };
|
|
468
483
|
const actionRevision = () => [...actions.actions.values()].map(action=>action.id+':'+[...(action.runtimes || [])].sort().join(',')).join(';');
|
|
484
|
+
// Route Handlers have a Node React environment and must not share RSC chunks
|
|
485
|
+
// that import registerServerReference or client-reference registration.
|
|
486
|
+
await serverBuild(handlerEntries, 'server', 'route');
|
|
469
487
|
for (;;) {
|
|
470
488
|
const revision = `${actionRevision()}:${boundaries.size}:${cssFiles.size}:${globalErrorCssFiles.size}`;
|
|
471
489
|
for (const action of actions.actions.values()) {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { mkdir, writeFile, rename, rm, realpath, lstat, open } from 'node:fs/promises';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { hostname } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const LOCK = '.prnext-build.lock';
|
|
7
|
+
const busy = message => Object.assign(new Error(message), { code: 'PRNEXT_BUILD_LOCKED' });
|
|
8
|
+
function alive(pid) {
|
|
9
|
+
try { process.kill(pid, 0); return true; }
|
|
10
|
+
catch (error) { return error.code !== 'ESRCH'; }
|
|
11
|
+
}
|
|
12
|
+
async function owner(directory) {
|
|
13
|
+
const info = await lstat(directory);
|
|
14
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw busy('The build lock is not a real directory. Check .prnext-build.lock before retrying.');
|
|
15
|
+
const file = await open(path.join(directory, 'owner.json'), 'r');
|
|
16
|
+
try {
|
|
17
|
+
if ((await file.stat()).size > 4096) throw new Error('oversized owner');
|
|
18
|
+
const value = JSON.parse(await file.readFile('utf8'));
|
|
19
|
+
if (!Number.isSafeInteger(value.pid) || value.pid < 1 || typeof value.host !== 'string' || !/^[a-f0-9-]{36}$/.test(value.token)) throw new Error('invalid owner');
|
|
20
|
+
return value;
|
|
21
|
+
} finally { await file.close(); }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** One publisher per canonical project, covering React and direct static imports. */
|
|
25
|
+
export async function withBuildLock(directory, action) {
|
|
26
|
+
const root = await realpath(directory), lock = path.join(root, LOCK);
|
|
27
|
+
const identity = { pid: process.pid, host: hostname(), token: randomUUID(), createdAt: new Date().toISOString() };
|
|
28
|
+
const prepared = path.join(root, `.prnext-lock-${identity.token}`);
|
|
29
|
+
await mkdir(prepared, { mode: 0o700 });
|
|
30
|
+
try {
|
|
31
|
+
await writeFile(path.join(prepared, 'owner.json'), JSON.stringify(identity), { flag: 'wx', mode: 0o600 });
|
|
32
|
+
for (let attempt = 0; ; attempt++) {
|
|
33
|
+
try { await rename(prepared, lock); break; }
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (!['EEXIST', 'ENOTEMPTY', 'EPERM', 'EACCES'].includes(error.code)) throw error;
|
|
36
|
+
let previous;
|
|
37
|
+
try { previous = await owner(lock); }
|
|
38
|
+
catch (cause) {
|
|
39
|
+
if (cause.code === 'ENOENT' && attempt < 8) continue;
|
|
40
|
+
throw busy('Cannot verify the build lock. Check .prnext-build.lock before retrying.');
|
|
41
|
+
}
|
|
42
|
+
if (previous.host !== identity.host || alive(previous.pid)) throw busy(`A build is already running for this project (PID ${previous.pid}). Wait for it to finish.`);
|
|
43
|
+
// Exactly one contender can recover this generation. Never delete a lock
|
|
44
|
+
// merely because it is old: long builds and suspended processes are valid.
|
|
45
|
+
let claim;
|
|
46
|
+
try { claim = await open(path.join(lock, 'recovery'), 'wx', 0o600); }
|
|
47
|
+
catch (cause) {
|
|
48
|
+
if (cause.code === 'ENOENT' && attempt < 8) continue;
|
|
49
|
+
throw busy('Build lock recovery is in progress. Retry shortly; if recovery was interrupted, check .prnext-build.lock manually.');
|
|
50
|
+
}
|
|
51
|
+
const retired = path.join(root, `.prnext-lock-retired-${identity.token}`);
|
|
52
|
+
try {
|
|
53
|
+
await claim.writeFile(JSON.stringify(identity)); await claim.close(); claim = null;
|
|
54
|
+
const current = await owner(lock);
|
|
55
|
+
if (current.token !== previous.token) {
|
|
56
|
+
// Do not unlink through this path: the new owner can release and
|
|
57
|
+
// another generation can appear between our read and the unlink.
|
|
58
|
+
throw busy('The build lock changed during recovery. Retry the build.');
|
|
59
|
+
}
|
|
60
|
+
await rename(lock, retired);
|
|
61
|
+
await rm(retired, { recursive: true });
|
|
62
|
+
} finally { await claim?.close(); }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
try { return await action(root); }
|
|
66
|
+
finally {
|
|
67
|
+
// Ownership must still match before releasing a lock.
|
|
68
|
+
if ((await owner(lock)).token === identity.token) await rm(lock, { recursive: true });
|
|
69
|
+
}
|
|
70
|
+
} finally { await rm(prepared, { recursive: true, force: true }); }
|
|
71
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { build } from 'esbuild';
|
|
2
|
+
import { lstat, realpath, readdir, writeFile, unlink } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
// Paths used by the native worker, generated application modules, prerender,
|
|
6
|
+
// standalone tracing, compatibility modules and the runtime lifecycle contract.
|
|
7
|
+
// Other relative imports are resolved into shared chunks by this single build.
|
|
8
|
+
export const runtimeEntryNames = Object.freeze([
|
|
9
|
+
'worker', 'rsc-worker', 'app-render', 'app-stream-html', 'transport', 'request-work',
|
|
10
|
+
'http', 'app-static', 'route-static', 'render', 'api', 'middleware', 'incremental-cache', 'app-instant',
|
|
11
|
+
'action-crypto', 'action-client', 'action-ssr', 'edge', 'metadata-route', 'og-loader',
|
|
12
|
+
].sort());
|
|
13
|
+
|
|
14
|
+
/** Run once, after compiling application/Edge graphs and before any prerender. */
|
|
15
|
+
export async function bundleRuntime(stage) {
|
|
16
|
+
const resolvedStage = path.resolve(stage);
|
|
17
|
+
if (!path.basename(resolvedStage).startsWith('.prnext-build-')) {
|
|
18
|
+
throw new Error('Runtime bundling requires an unpublished PRNext build stage.');
|
|
19
|
+
}
|
|
20
|
+
const runtime = path.join(resolvedStage, 'runtime');
|
|
21
|
+
const [stageInfo, runtimeInfo] = await Promise.all([lstat(resolvedStage), lstat(runtime)]);
|
|
22
|
+
if (!stageInfo.isDirectory() || stageInfo.isSymbolicLink() || !runtimeInfo.isDirectory() || runtimeInfo.isSymbolicLink()) {
|
|
23
|
+
throw new Error('Runtime bundling requires ordinary stage and runtime directories.');
|
|
24
|
+
}
|
|
25
|
+
const [canonicalStage, canonicalRuntime] = await Promise.all([realpath(resolvedStage), realpath(runtime)]);
|
|
26
|
+
if (path.relative(canonicalStage, canonicalRuntime) !== 'runtime') {
|
|
27
|
+
throw new Error('Runtime bundling escaped the build stage.');
|
|
28
|
+
}
|
|
29
|
+
const entries = await readdir(runtime, { withFileTypes: true });
|
|
30
|
+
const originals = entries.filter(entry => entry.name.endsWith('.mjs'));
|
|
31
|
+
if (originals.some(entry => !entry.isFile() || entry.isSymbolicLink())) {
|
|
32
|
+
throw new Error('Runtime source modules must be ordinary files.');
|
|
33
|
+
}
|
|
34
|
+
for (const name of runtimeEntryNames) {
|
|
35
|
+
if (!originals.some(entry => entry.name === `${name}.mjs`)) throw new Error(`Missing runtime entry ${name}.mjs`);
|
|
36
|
+
}
|
|
37
|
+
// write:false makes compilation atomic with respect to the source runtime:
|
|
38
|
+
// no files change if parsing or resolution fails. The virtual output is a
|
|
39
|
+
// sibling so all relative paths have the same depth as the final runtime.
|
|
40
|
+
const virtualOutput = path.join(resolvedStage, '.runtime-bundle');
|
|
41
|
+
const result = await build({
|
|
42
|
+
absWorkingDir: runtime,
|
|
43
|
+
entryPoints: runtimeEntryNames.map(name => path.join(runtime, `${name}.mjs`)),
|
|
44
|
+
outdir: virtualOutput,
|
|
45
|
+
outbase: runtime,
|
|
46
|
+
entryNames: '[name]',
|
|
47
|
+
chunkNames: '_shared-[hash]',
|
|
48
|
+
outExtension: { '.js': '.mjs' },
|
|
49
|
+
bundle: true,
|
|
50
|
+
splitting: true,
|
|
51
|
+
format: 'esm',
|
|
52
|
+
platform: 'node',
|
|
53
|
+
target: 'node22',
|
|
54
|
+
packages: 'external',
|
|
55
|
+
// Edge's prepared VM is already a self-contained CJS artifact. Keeping it
|
|
56
|
+
// external preserves its delayed import and original execution boundary.
|
|
57
|
+
external: ['../compat/*', './profiles.json', './*.cjs'],
|
|
58
|
+
minify: false,
|
|
59
|
+
sourcemap: false,
|
|
60
|
+
metafile: true,
|
|
61
|
+
write: false,
|
|
62
|
+
logLevel: 'silent',
|
|
63
|
+
});
|
|
64
|
+
const emitted = new Map();
|
|
65
|
+
for (const file of result.outputFiles) {
|
|
66
|
+
if (path.dirname(file.path) !== virtualOutput || !file.path.endsWith('.mjs')) {
|
|
67
|
+
throw new Error('Runtime bundler emitted a file outside its flat output directory.');
|
|
68
|
+
}
|
|
69
|
+
emitted.set(path.basename(file.path), file.contents);
|
|
70
|
+
}
|
|
71
|
+
if (runtimeEntryNames.some(name => !emitted.has(`${name}.mjs`))) throw new Error('Runtime bundle omitted a required entry.');
|
|
72
|
+
// This is an unpublished, disposable stage. On any write failure buildProject
|
|
73
|
+
// discards the stage and preserves the previous published build unchanged.
|
|
74
|
+
for (const [name, contents] of emitted) await writeFile(path.join(runtime, name), contents);
|
|
75
|
+
for (const entry of originals) {
|
|
76
|
+
if (!emitted.has(entry.name)) await unlink(path.join(runtime, entry.name));
|
|
77
|
+
}
|
|
78
|
+
// JSON, CJS, license files and the OG assets directory are never traversed or
|
|
79
|
+
// removed. The caller writes the normal runtime contract after prerender.
|
|
80
|
+
return { entryCount: runtimeEntryNames.length, moduleCount: emitted.size,
|
|
81
|
+
bytes: [...emitted.values()].reduce((sum, contents) => sum + contents.length, 0) };
|
|
82
|
+
}
|
package/build/compiler.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { incrementalBuild } from './incremental.mjs';
|
|
|
12
12
|
import { webpackGraph } from './webpack-graph.mjs';
|
|
13
13
|
import { turbopackRules, sourceReader } from './turbopack-conditions.mjs';
|
|
14
14
|
import {withSourceMaps,configureSourceMaps,inlineSourceMap} from './source-maps.mjs';
|
|
15
|
+
import { optimizeImportsPlugins } from './optimize-imports.mjs';
|
|
15
16
|
|
|
16
17
|
let validateOptions;
|
|
17
18
|
const compilation = new AsyncLocalStorage();
|
|
@@ -20,7 +21,7 @@ export function configureCompiler(options) { Object.assign(compilation.getStore(
|
|
|
20
21
|
export async function compilerSource(file, fallback) {
|
|
21
22
|
return compilation.getStore()?.loaded.get(file)?.source ?? fallback ?? await readFile(file, 'utf8');
|
|
22
23
|
}
|
|
23
|
-
const framework = /^(?:prnext-pages-compatibility|prnext-app-(?:rsc|ssr|browser)|prnext-edge-app|prnext-edge|prnext-middleware-compatibility)$/;
|
|
24
|
+
const framework = /^(?:prnext-pages-compatibility|prnext-app-(?:rsc|ssr|browser|route)|prnext-edge-app|prnext-edge|prnext-middleware-compatibility)$/;
|
|
24
25
|
function condition(value, input) {
|
|
25
26
|
if (value === undefined) return true;
|
|
26
27
|
if (value instanceof RegExp) { value.lastIndex = 0; return value.test(input); }
|
|
@@ -120,6 +121,7 @@ export async function validateCompilerConfig(config, root, {dev=false,edge=false
|
|
|
120
121
|
}
|
|
121
122
|
export async function build(options) {
|
|
122
123
|
const state = compilation.getStore();
|
|
124
|
+
if ((options.plugins || []).some(plugin => framework.test(plugin.name))) options = { ...options, plugins: optimizeImportsPlugins(options.plugins, state?.config?.experimental?.optimizePackageImports) };
|
|
123
125
|
// esbuild's shared service dispatches callbacks from its own async context.
|
|
124
126
|
// Re-enter this build so transforms never inherit another project's map mode,
|
|
125
127
|
// action registry or configuration from the first service invocation.
|
package/build/config.mjs
CHANGED
|
@@ -17,6 +17,8 @@ import { validateI18n } from './i18n.mjs';
|
|
|
17
17
|
import { frameworkPackages } from './framework-imports.mjs';
|
|
18
18
|
import { validateTypeScript } from './typescript.mjs';
|
|
19
19
|
import { validateWorkerRetirement } from './worker-retirement.mjs';
|
|
20
|
+
import { validateOptimizePackageImports } from './optimize-imports.mjs';
|
|
21
|
+
import { validateProxyBodySize } from './proxy-body-config.mjs';
|
|
20
22
|
|
|
21
23
|
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
24
|
const configName = /^(?:next|prnext)\.config\.(?:js|mjs|cjs|ts)$/;
|
|
@@ -29,6 +31,7 @@ supported.add('turbopack');
|
|
|
29
31
|
supported.add('i18n');
|
|
30
32
|
supported.add('webpack');
|
|
31
33
|
supported.add('onDemandEntries');
|
|
34
|
+
supported.add('devIndicators');
|
|
32
35
|
for (const key of ['reactCompiler', 'typedRoutes', 'typescript']) supported.add(key);
|
|
33
36
|
for (const key of ['output', 'outputFileTracingRoot', 'outputFileTracingIncludes', 'outputFileTracingExcludes']) supported.add(key);
|
|
34
37
|
|
|
@@ -89,6 +92,7 @@ export function validateProjectConfig(input, filename = 'project config') {
|
|
|
89
92
|
}
|
|
90
93
|
config.typescript = validateTypeScript(config.typescript);
|
|
91
94
|
config.workerRetirement = validateWorkerRetirement(config.workerRetirement);
|
|
95
|
+
if (config.devIndicators !== undefined && config.devIndicators !== false) throw new TypeError('devIndicators currently supports only false. PRNext provides development errors and Fast Refresh, but no Next.js development indicator menu.');
|
|
92
96
|
if (config.onDemandEntries !== undefined) {
|
|
93
97
|
const entries = config.onDemandEntries;
|
|
94
98
|
if (!entries || typeof entries !== 'object' || Array.isArray(entries)) throw new TypeError('onDemandEntries must be an object');
|
|
@@ -117,14 +121,17 @@ export function validateProjectConfig(input, filename = 'project config') {
|
|
|
117
121
|
config[key] = [...new Set(names)];
|
|
118
122
|
}
|
|
119
123
|
for (const name of config.transpilePackages) if (config.serverExternalPackages.includes(name)) throw new Error(`${filename}: ${name} cannot be in both transpilePackages and serverExternalPackages.`);
|
|
124
|
+
// Like Next, retain pg's Node module boundary: its optional pg-native getter
|
|
125
|
+
// must resolve lazily, not be traversed by the RSC/server bundler at build.
|
|
126
|
+
if (!config.transpilePackages.includes('pg') && !config.serverExternalPackages.includes('pg')) config.serverExternalPackages.push('pg');
|
|
120
127
|
if (config.sassOptions !== undefined && (!config.sassOptions || typeof config.sassOptions !== 'object' || Array.isArray(config.sassOptions))) throw new Error(`${filename}: sassOptions must be an object.`);
|
|
121
128
|
if (!config.experimental || typeof config.experimental !== 'object' || Array.isArray(config.experimental)) throw new Error(`${filename}: experimental must be an object.`);
|
|
122
129
|
for (const key of Object.keys(config.experimental)) {
|
|
123
|
-
if (!['nextScriptWorkers', 'serverActions'].includes(key)) throw new Error(`${filename}: experimental.${key} is not supported by PRNext. It cannot be silently ignored.`);
|
|
130
|
+
if (!['nextScriptWorkers', 'serverActions', 'optimizePackageImports', 'proxyClientMaxBodySize'].includes(key)) throw new Error(`${filename}: experimental.${key} is not supported by PRNext. It cannot be silently ignored.`);
|
|
124
131
|
}
|
|
125
132
|
const nextScriptWorkers = config.experimental.nextScriptWorkers === undefined ? false : config.experimental.nextScriptWorkers;
|
|
126
133
|
if (typeof nextScriptWorkers !== 'boolean') throw new Error(`${filename}: experimental.nextScriptWorkers must be a boolean.`);
|
|
127
|
-
config.experimental = { nextScriptWorkers, serverActions: validateServerActions(config.experimental.serverActions) };
|
|
134
|
+
config.experimental = { nextScriptWorkers, serverActions: validateServerActions(config.experimental.serverActions), optimizePackageImports: validateOptimizePackageImports(config.experimental.optimizePackageImports), proxyClientMaxBodySize: validateProxyBodySize(config.experimental.proxyClientMaxBodySize) };
|
|
128
135
|
for (const key of ['basePath', 'assetPrefix']) {
|
|
129
136
|
if (typeof config[key] !== 'string' || Buffer.byteLength(config[key]) > 4096) throw new Error(`${filename}: ${key} must be a string of at most 4096 bytes.`);
|
|
130
137
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, writeFile, rename, rm, readdir, lstat } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
6
|
+
const MAX_TOTAL = 64 * 1024 * 1024;
|
|
7
|
+
let writes = Promise.resolve();
|
|
8
|
+
|
|
9
|
+
/** Project-local build inputs, never responses/user data. Clearing this directory
|
|
10
|
+
* refreshes Google assets. A warm cache also permits an offline rebuild.
|
|
11
|
+
*/
|
|
12
|
+
export async function cachedFontResource(root, url, maximum, accept, download) {
|
|
13
|
+
if (!root) return download();
|
|
14
|
+
const directory = path.join(root, '.prnext-cache', 'fonts-v1');
|
|
15
|
+
const file = path.join(directory, hash(JSON.stringify([url, accept])) + '.bin');
|
|
16
|
+
try {
|
|
17
|
+
const metadata = await lstat(file);
|
|
18
|
+
if (metadata.isFile() && metadata.size > 65 && metadata.size <= maximum + 65) {
|
|
19
|
+
const stored = await readFile(file);
|
|
20
|
+
const bytes = stored.subarray(65);
|
|
21
|
+
if (stored[64] === 10 && hash(bytes) === stored.toString('ascii', 0, 64)) return bytes;
|
|
22
|
+
}
|
|
23
|
+
} catch (error) { if (!['ENOENT', 'ENOTDIR', 'EACCES'].includes(error.code)) throw error; }
|
|
24
|
+
const bytes = await download();
|
|
25
|
+
if (!bytes.length || bytes.length > maximum) throw new Error('Font resource exceeds its cache input limit');
|
|
26
|
+
const task = writes.catch(() => {}).then(async () => {
|
|
27
|
+
await mkdir(directory, { recursive: true });
|
|
28
|
+
const temporary = file + '.' + randomUUID() + '.tmp';
|
|
29
|
+
try {
|
|
30
|
+
await writeFile(temporary, Buffer.concat([Buffer.from(hash(bytes) + '\n'), bytes]), { flag: 'wx' });
|
|
31
|
+
await rename(temporary, file);
|
|
32
|
+
} finally { await rm(temporary, { force: true }); }
|
|
33
|
+
const entries = [];
|
|
34
|
+
for (const item of await readdir(directory, { withFileTypes: true })) {
|
|
35
|
+
if (!item.isFile() || !/^[a-f0-9]{64}\.bin$/.test(item.name)) continue;
|
|
36
|
+
try { entries.push({ file: path.join(directory, item.name), ...await lstat(path.join(directory, item.name)) }); }
|
|
37
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
38
|
+
}
|
|
39
|
+
entries.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
40
|
+
let total = 0;
|
|
41
|
+
for (let index = 0; index < entries.length; index++) {
|
|
42
|
+
total += entries[index].size;
|
|
43
|
+
if (index >= 256 || total > MAX_TOTAL) await rm(entries[index].file, { force: true });
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
writes = task;
|
|
47
|
+
try { await task; }
|
|
48
|
+
catch (error) { if (!['EACCES', 'EPERM', 'EROFS', 'ENOSPC', 'ENOENT'].includes(error.code)) throw error; }
|
|
49
|
+
return bytes;
|
|
50
|
+
}
|
package/build/font-files.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
4
|
import googleFonts from './font-data.json' with { type: 'json' };
|
|
5
|
+
import { cachedFontResource } from './font-cache.mjs';
|
|
5
6
|
|
|
6
7
|
const formats = { woff: 'woff', woff2: 'woff2', ttf: 'truetype', otf: 'opentype', eot: 'embedded-opentype' };
|
|
7
8
|
const types = { woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf', eot: 'application/vnd.ms-fontobject' };
|
|
@@ -36,7 +37,7 @@ function validate(kind, options) {
|
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
async function responseBytes(url, maximum, accept) {
|
|
39
|
-
const response = await fetch(url, { signal: AbortSignal.timeout(15_000), headers: { accept,
|
|
40
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(15_000), redirect: 'error', headers: { accept,
|
|
40
41
|
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36' } });
|
|
41
42
|
if (!response.ok) { await response.body?.cancel(); fail(`Unable to download ${url}: HTTP ${response.status}.`); }
|
|
42
43
|
if (Number(response.headers.get('content-length')) > maximum) { await response.body?.cancel(); fail(`Font resource exceeds ${maximum} bytes.`); }
|
|
@@ -145,7 +146,8 @@ function googleOptions(name, options) {
|
|
|
145
146
|
|
|
146
147
|
async function google(name, options, context) {
|
|
147
148
|
const normalized = googleOptions(name, options);
|
|
148
|
-
const
|
|
149
|
+
const resourceBytes = (url, maximum, accept) => cachedFontResource(context.projectRoot, url, maximum, accept, () => responseBytes(url, maximum, accept).catch(error => { throw new Error(`next/font: download failed for ${url}: ${error.message}. An initial build needs access to Google Fonts; subsequent builds can reuse .prnext-cache/fonts-v1.`, { cause: error }); }));
|
|
150
|
+
const css = (await resourceBytes(normalized.url, 1024 * 1024, 'text/css')).toString('utf8');
|
|
149
151
|
const faces = [], downloaded = new Map();
|
|
150
152
|
let total = 0;
|
|
151
153
|
const facePattern = /(?:\/\*\s*([^*]+?)\s*\*\/\s*)?@font-face\s*\{([^}]+)\}/g;
|
|
@@ -161,7 +163,7 @@ async function google(name, options, context) {
|
|
|
161
163
|
if (url.protocol !== 'https:' || url.hostname !== 'fonts.gstatic.com' || url.username || url.password) fail('Google font CSS referenced an unexpected font origin.');
|
|
162
164
|
if (!downloaded.has(url.href)) {
|
|
163
165
|
if (downloaded.size >= 64) fail('Google font call exceeds 64 files.');
|
|
164
|
-
const bytes = await
|
|
166
|
+
const bytes = await resourceBytes(url.href, MAX_FONT, 'font/woff2,font/woff,*/*');
|
|
165
167
|
total += bytes.length;
|
|
166
168
|
if (total > 64 * 1024 * 1024) fail('Google font call exceeds 64 MiB.');
|
|
167
169
|
const ext = path.extname(url.pathname).slice(1).toLowerCase();
|
package/build/fonts.mjs
CHANGED
|
@@ -37,7 +37,7 @@ export function createFonts({ projectRoot, stage, assetBase }) {
|
|
|
37
37
|
async function prepare(kind, name, options, file, position) {
|
|
38
38
|
const id = hash(JSON.stringify([path.relative(projectRoot, file), position, kind, name, options]));
|
|
39
39
|
if (!compilations.has(id)) compilations.set(id, (async () => {
|
|
40
|
-
const compiled = await compileFont({ kind, name, options, file, id, stage, assetBase });
|
|
40
|
+
const compiled = await compileFont({ kind, name, options, file, id, stage, assetBase, projectRoot });
|
|
41
41
|
const moduleFile = path.join(stage, '.entries', `font-${id}.mjs`);
|
|
42
42
|
const cssFile = path.join(stage, '.entries', `font-${id}.css`);
|
|
43
43
|
await mkdir(path.dirname(moduleFile), { recursive: true });
|
package/build/index.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { rename } from '../runtime/fs.mjs';
|
|
2
2
|
import { writeRuntimeContract } from './runtime-contract.mjs';
|
|
3
|
+
import { writeBuildOutput } from './output-contract.mjs';
|
|
4
|
+
import { bundleRuntime } from './bundle-runtime.mjs';
|
|
3
5
|
import {frameworkImportName, frameworkRuntimeImportPattern} from './framework-imports.mjs';
|
|
4
6
|
import { expandLocales, localizedStaticPaths } from './i18n.mjs';
|
|
5
7
|
import { build as bundle, withCompilation, configureCompiler, compilerSource } from './compiler.mjs';
|
|
@@ -19,6 +21,7 @@ import { createImages } from './images.mjs';
|
|
|
19
21
|
import { compileApp } from './app.mjs';
|
|
20
22
|
import { prerenderApp } from './app-static.mjs';
|
|
21
23
|
import { precompressBuild } from './precompress.mjs';
|
|
24
|
+
import { withBuildLock } from './build-lock.mjs';
|
|
22
25
|
import { loadProjectConfig, defineEnvironment, generateBuildId, publicAssetBase } from './config.mjs';
|
|
23
26
|
import { compileCustomRoutes } from './custom-routes.mjs';
|
|
24
27
|
import { compileMiddleware } from './middleware.mjs';
|
|
@@ -185,10 +188,13 @@ let buildQueue = Promise.resolve();
|
|
|
185
188
|
/** Isolate process.env between programmatic builds, including simultaneous calls. */
|
|
186
189
|
export function build(projectRoot, options = {}) {
|
|
187
190
|
const task = buildQueue.then(async () => {
|
|
191
|
+
if (options.adapter === 'static') return (await import('./static-adapter.mjs')).buildStatic(projectRoot, options);
|
|
192
|
+
if (options.adapter !== undefined && options.adapter !== 'react') throw new Error(`Unknown PRNext adapter: ${options.adapter}`);
|
|
193
|
+
if (options.from !== undefined || options.spa !== undefined) throw new Error('from and spa are only supported by the static adapter.');
|
|
188
194
|
const restore = snapshotEnvConfig();
|
|
189
195
|
const envMode = options.envMode || (process.env.NODE_ENV === 'test' ? 'test' : options.dev ? 'development' : 'production');
|
|
190
196
|
process.env.NODE_ENV = options.dev ? 'development' : 'production';
|
|
191
|
-
try { return await withCompilation(() => buildProject(
|
|
197
|
+
try { return await withBuildLock(projectRoot, root => withCompilation(() => buildProject(root, { ...options, envMode }))); }
|
|
192
198
|
finally { restore(); }
|
|
193
199
|
});
|
|
194
200
|
buildQueue = task.catch(() => {});
|
|
@@ -294,11 +300,15 @@ async function buildProject(projectRoot, { dev = false, envMode, incremental = f
|
|
|
294
300
|
}
|
|
295
301
|
}
|
|
296
302
|
await compileMiddleware({ project, stage, manifest, dev, moduleResolution: config.turbopack, defineEnvironment: environment });
|
|
303
|
+
if (manifest.middleware) manifest.middleware.bodySizeLimit = config.experimental.proxyClientMaxBodySize;
|
|
297
304
|
await prepareMetadataRoutes(project, stage);
|
|
298
305
|
await compileApp({ project, stage, manifest, moduleResolution: config.turbopack, cssModules, fonts, images, dev, defineEnvironment: environment, productionBrowserSourceMaps: config.productionBrowserSourceMaps, serverExternalPackages: config.serverExternalPackages });
|
|
299
306
|
await checkTypeScript(project.root, config, { dev, tsconfig });
|
|
300
307
|
fonts.attach(manifest);
|
|
301
308
|
expandLocales(manifest);
|
|
309
|
+
// All runtime preparation must finish before its shared modules are built;
|
|
310
|
+
// no staged renderer has been imported before this production-only point.
|
|
311
|
+
if (!dev) await bundleRuntime(stage);
|
|
302
312
|
await prerender(stage, manifest, dev);
|
|
303
313
|
await prerenderApp(stage, manifest, dev);
|
|
304
314
|
const pages = manifest.routes.filter(route => route.kind === 'page' && route.router !== 'app' && !route.internal);
|
|
@@ -330,6 +340,7 @@ async function buildProject(projectRoot, { dev = false, envMode, incremental = f
|
|
|
330
340
|
}
|
|
331
341
|
await writeRuntimeContract(stage);
|
|
332
342
|
await writeFile(path.join(stage, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
343
|
+
await writeBuildOutput(stage, manifest);
|
|
333
344
|
if (config.compress) await precompressBuild(stage, manifest, {cacheDirectory:path.join(project.root,'.prnext-cache/precompress-v1')});
|
|
334
345
|
if (config.output === 'standalone' && !dev) await (await import('./standalone-runner.mjs')).prepareStandalone({ projectRoot: project.root, stage, manifest, config });
|
|
335
346
|
const exporting = config.output === 'export' && !dev ? await import('./static-export.mjs') : null;
|
package/build/inspect.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
1
|
+
import { readFile, readdir, stat, open } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { readBuildDirectory } from '../runtime/build-directory.mjs';
|
|
4
|
+
import { summarizeDiagnostics, inspectionMetrics } from './inspection-summary.mjs';
|
|
5
|
+
export { inspectionMetrics };
|
|
4
6
|
|
|
5
7
|
export function explainRoute(route, manifest) {
|
|
6
8
|
const seeds = manifest.prerendered.filter(seed => seed.routeId === route.id || (!seed.routeId && seed.path === route.pattern));
|
|
@@ -27,6 +29,18 @@ export async function inspectProject(directory) {
|
|
|
27
29
|
try { manifest = JSON.parse(await readFile(path.join(root, output, 'manifest.json'), 'utf8')); }
|
|
28
30
|
catch (error) { throw new Error(`Cannot read a build in ${root}. Run prn build first.`, { cause: error }); }
|
|
29
31
|
const folder = path.join(root, '.prnext-cache/inspect');
|
|
32
|
+
const runtime = [];
|
|
33
|
+
for (const name of (await readdir(folder).catch(error => {if(error.code==='ENOENT')return [];throw error;})).filter(name => /^metrics-\d+\.jsonl$/.test(name)).slice(-8)) {
|
|
34
|
+
let file;
|
|
35
|
+
try {
|
|
36
|
+
file = await open(path.join(folder,name),'r');
|
|
37
|
+
const info = await file.stat(), bytes = Buffer.alloc(Math.min(info.size,65536));
|
|
38
|
+
await file.read(bytes,0,bytes.length,Math.max(0,info.size-bytes.length));
|
|
39
|
+
for (const line of bytes.toString().split('\n').reverse()) {
|
|
40
|
+
try { const value=JSON.parse(line);if(Number.isFinite(value.unixMs)&&value.workers){runtime.push(value);break;} } catch {}
|
|
41
|
+
}
|
|
42
|
+
} catch {} finally {await file?.close();}
|
|
43
|
+
}
|
|
30
44
|
let names = [];
|
|
31
45
|
try { names = (await readdir(folder)).filter(name => /^(?:worker-\d+-\d+|native-\d+)\.jsonl(?:\.1)?$/.test(name)); }
|
|
32
46
|
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
@@ -50,7 +64,7 @@ export async function inspectProject(directory) {
|
|
|
50
64
|
durations.sort((a, b) => a - b);
|
|
51
65
|
return { ...group, ...(durations.length ? { meanMs: Math.round(durations.reduce((a, b) => a + b, 0) / durations.length * 100) / 100, p95Ms: durations[Math.ceil(durations.length * .95) - 1], maxMs: durations.at(-1) } : {}) };
|
|
52
66
|
}).sort((a, b) => (b.p95Ms || 0) - (a.p95Ms || 0));
|
|
53
|
-
return { version: 1, root, buildId: manifest.buildId, routes: manifest.routes.filter(route => !route.internal).map(route => explainRoute(route, manifest)),
|
|
67
|
+
return { version: 1, root, buildId: manifest.buildId, ...summarizeDiagnostics(recent,runtime), routes: manifest.routes.filter(route => !route.internal).map(route => explainRoute(route, manifest)),
|
|
54
68
|
measurements, cacheEntries: [...entries.values()].slice(-100), invalidations: recent.filter(event => event.type === 'invalidation').slice(-50), dynamicUsage: recent.filter(event => event.type === 'dynamic').slice(-50),
|
|
55
69
|
sample: { events: recent.length, from: recent[0]?.time, to: recent.at(-1)?.time },
|
|
56
70
|
notes: ['Enable measurements with prn start --inspect (or prn dev --inspect), visit routes, then run prn inspect again.',
|
|
@@ -59,6 +73,10 @@ export async function inspectProject(directory) {
|
|
|
59
73
|
}
|
|
60
74
|
export function printInspection(report) {
|
|
61
75
|
console.log(`PRNext inspector: ${report.root}`);
|
|
76
|
+
console.log(`Recorded sample: ${report.summary.requests} HTTP requests; p50 ${report.summary.p50Ms ?? 'n/a'} ms; p95 ${report.summary.p95Ms ?? 'n/a'} ms (response headers); ${report.summary.errors} server errors.`);
|
|
77
|
+
if(report.summary.wakeP95Ms !== null) console.log(`Worker startup p95: ${report.summary.wakeP95Ms} ms.`);
|
|
78
|
+
if(report.summary.workerRssBytes !== null) console.log(`Latest recorded worker RSS: ${(report.summary.workerRssBytes/1048576).toFixed(1)} MiB. Native server memory is not included.`);
|
|
79
|
+
for(const finding of report.findings)console.log(`${finding.level.toUpperCase()}: ${finding.message} ${finding.action}`);
|
|
62
80
|
for (const route of report.routes) console.log(`${route.mode.padEnd(13)} ${route.pattern}\n ${route.reasons.join('; ') || `${route.prerenderedPaths} prerendered path(s); revalidate: ${route.revalidate.join(', ') || 'on demand'}`}`);
|
|
63
81
|
for (const item of report.measurements) console.log(`${item.type.toUpperCase()} ${item.route}${item.destination ? ' -> ' + item.destination : ''}: ${item.count} sample(s)${item.state ? ', ' + item.state : ''}${item.reason ? ' (' + item.reason + ')' : ''}${item.p95Ms !== undefined ? ', p95 ' + item.p95Ms.toFixed(2) + ' ms' : ''}, ${item.errors} error(s)`);
|
|
64
82
|
for (const event of report.invalidations) console.log(`INVALIDATION ${event.route}: ${event.mode}; tags ${(event.tags || []).join(', ') || 'none'}; paths ${(event.paths || []).join(', ') || 'none'}`);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const quantile = (values, q) => values.length ? Math.round([...values].sort((a,b)=>a-b)[Math.ceil(values.length*q)-1]*100)/100 : null;
|
|
2
|
+
const durations = events => events.map(e=>e.durationMs).filter(v=>Number.isFinite(v)&&v>=0);
|
|
3
|
+
export function summarizeDiagnostics(events, snapshots = []) {
|
|
4
|
+
const http=events.filter(e=>e.type==='http'), times=durations(http);
|
|
5
|
+
const wake=durations(events.filter(e=>e.type==='wake'&&e.phase==='node-ready'));
|
|
6
|
+
const processes=new Map();
|
|
7
|
+
for(const event of events)if(event.type==='process'&&Number.isSafeInteger(event.pid)&&Number.isFinite(event.rssBytes)&&event.rssBytes>=0)processes.set(event.pid,event);
|
|
8
|
+
const last=snapshots.sort((a,b)=>b.unixMs-a.unixMs)[0];
|
|
9
|
+
const current=last&&Date.now()-last.unixMs<30000&&Date.now()>=last.unixMs;
|
|
10
|
+
const rss=[...processes.values()].filter(e=>Date.now()-e.time<30000&&Date.now()>=e.time);
|
|
11
|
+
const summary={requests:http.length,errors:http.filter(e=>e.status>=500).length,p50Ms:quantile(times,.5),p95Ms:quantile(times,.95),wakeP95Ms:quantile(wake,.95),workerRssBytes:rss.length?rss.reduce((n,e)=>n+e.rssBytes,0):null,
|
|
12
|
+
queuedJobs:current?last.workers.queuedJobs??null:null,activeJobs:current?last.workers.activeJobs??null:null, runtimeSampleAt:last?.unixMs??null};
|
|
13
|
+
const findings=[];
|
|
14
|
+
if(!http.length)findings.push({level:'info',code:'NO_SAMPLE',message:'No HTTP sample recorded.',action:'Start with --inspect, visit the site and run inspect again.'});
|
|
15
|
+
if(summary.queuedJobs>0)findings.push({level:'warning',code:'WORKER_QUEUE',message:`${summary.queuedJobs} jobs are waiting for a worker.`,action:'Check slow routes and available CPU/RAM before increasing concurrency.'});
|
|
16
|
+
if(summary.p95Ms>=1000)findings.push({level:'warning',code:'SLOW_HEADERS',message:'The slowest 5% of recorded responses take at least one second to prepare headers.',action:'Inspect route and fetch timings below; this includes queueing and startup.'});
|
|
17
|
+
if(summary.errors)findings.push({level:'warning',code:'SERVER_ERRORS',message:`${summary.errors} recorded requests returned a server error.`,action:'Check application logs for the same sample period.'});
|
|
18
|
+
if(summary.wakeP95Ms>=1000)findings.push({level:'info',code:'SLOW_WAKE',message:'Recorded worker startup p95 exceeds one second.',action:'Review startup imports or use keep-warm if the extra memory is acceptable.'});
|
|
19
|
+
return {summary,findings,metricSample:http.filter(e=>Number.isFinite(e.durationMs)&&e.durationMs>=0&&e.durationMs<=86400000&&Number.isSafeInteger(e.time)&&e.time>=0&&e.time<=18446744073709).map(e=>({route:typeof e.route==='string'?e.route.slice(0,256):'<unmatched>',method:/^(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS|CONNECT|TRACE)$/.test(e.method)?e.method:'_OTHER',durationMs:e.durationMs,time:e.time}))};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** OTLP/JSON Metrics export of a bounded local sample, never a complete traffic counter. */
|
|
23
|
+
export function inspectionMetrics(report) {
|
|
24
|
+
const bounds=[.001,.005,.01,.025,.05,.1,.25,.5,1,2.5,5,10], groups=new Map();
|
|
25
|
+
for(const event of report.metricSample||[]) {
|
|
26
|
+
const key=JSON.stringify([event.route,event.method]);
|
|
27
|
+
if(!groups.has(key))groups.set(key,{route:event.route,method:event.method,values:[],start:event.time,end:event.time});
|
|
28
|
+
const group=groups.get(key);group.values.push(event.durationMs/1000);group.start=Math.min(group.start,event.time);group.end=Math.max(group.end,event.time);
|
|
29
|
+
}
|
|
30
|
+
const dataPoints=[...groups.values()].map(group=>{
|
|
31
|
+
const buckets=Array(bounds.length+1).fill(0);
|
|
32
|
+
for(const value of group.values){const index=bounds.findIndex(bound=>value<=bound);buckets[index<0?bounds.length:index]++;}
|
|
33
|
+
return {attributes:[{key:'http.route',value:{stringValue:group.route}},{key:'http.request.method',value:{stringValue:group.method}}],
|
|
34
|
+
startTimeUnixNano:String(BigInt(Math.trunc(group.start))*1000000n),timeUnixNano:String(BigInt(Math.trunc(group.end))*1000000n),count:String(group.values.length),sum:group.values.reduce((a,b)=>a+b,0),min:Math.min(...group.values),max:Math.max(...group.values),explicitBounds:bounds,bucketCounts:buckets.map(String)};
|
|
35
|
+
});
|
|
36
|
+
return {resourceMetrics:[{resource:{attributes:[{key:'service.name',value:{stringValue:'prnext'}},{key:'prnext.inspect.sampled',value:{boolValue:true}}]},scopeMetrics:[{scope:{name:'prnext.inspect',version:'1'},metrics:[{name:'prnext.http.response.headers.duration',description:'Bounded diagnostic sample of time until response headers; excludes response body streaming.',unit:'s',histogram:{aggregationTemporality:1,dataPoints}}]}]}]};
|
|
37
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export async function validatePublicMetadata(root, pattern, file, dynamic) {
|
|
5
|
+
const publicFile = path.join(root, 'public', pattern.slice(1));
|
|
6
|
+
try {
|
|
7
|
+
const info = await stat(publicFile);
|
|
8
|
+
// Next production serves public files before App metadata handlers. An
|
|
9
|
+
// identical static duplicate is therefore unambiguous and safe to retain.
|
|
10
|
+
if (!dynamic && info.isFile() && info.size <= 16 * 1024 * 1024 && info.size === (await stat(file)).size && (await readFile(publicFile)).equals(await readFile(file))) return;
|
|
11
|
+
throw new Error(`Metadata route ${pattern} conflicts with a different file in public/. Keep a single source for this URL.`);
|
|
12
|
+
} catch (error) { if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error; }
|
|
13
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFile
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
4
|
import { execFile } from 'node:child_process';
|
|
@@ -7,6 +7,7 @@ import { resolveNativeBinary } from '../native/resolve.mjs';
|
|
|
7
7
|
import { validateAppConfig, mergeAppConfig } from './app-config.mjs';
|
|
8
8
|
import { metadataExportNames } from './metadata-routes.mjs';
|
|
9
9
|
import { pageExtensionPattern, validatePageSource } from './page-extensions.mjs';
|
|
10
|
+
import { validatePublicMetadata } from './metadata-conflicts.mjs';
|
|
10
11
|
const execute = promisify(execFile);
|
|
11
12
|
const contentTypes = { ico: 'image/x-icon', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', svg: 'image/svg+xml', gif: 'image/gif' };
|
|
12
13
|
const imageTypes = { favicon: ['ico'], icon: ['ico', 'jpg', 'jpeg', 'png', 'svg'], 'apple-icon': ['jpg', 'jpeg', 'png'], 'opengraph-image': ['jpg', 'jpeg', 'png', 'gif'], 'twitter-image': ['jpg', 'jpeg', 'png', 'gif'] };
|
|
@@ -57,8 +58,7 @@ export async function scanMetadataImages({ root, appRoot, files, routes, pattern
|
|
|
57
58
|
const pattern = validatePattern([...urlParts, filename, ...(multiple ? ['[__metadata_id__]'] : [])], relative);
|
|
58
59
|
const signature = pattern.replace(/\[[^\]]+\]/g, '[]');
|
|
59
60
|
if (patterns.has(signature)) throw new Error(`Conflicting metadata route ${relative} and ${patterns.get(signature)} at ${pattern}.`);
|
|
60
|
-
|
|
61
|
-
catch (error) { if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error; }
|
|
61
|
+
await validatePublicMetadata(root, pattern, file, dynamic);
|
|
62
62
|
patterns.set(signature, relative);
|
|
63
63
|
const handlerConfig = dynamic ? await validateAppConfig(file) : {};
|
|
64
64
|
handlerConfig.revalidate ??= false;
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { readFile, writeFile
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
4
|
import { parse } from '@babel/parser';
|
|
5
5
|
import { validateAppConfig, mergeAppConfig } from './app-config.mjs';
|
|
6
6
|
import { scanMetadataImages } from './metadata-images.mjs';
|
|
7
7
|
import { pageExtensionPattern, validatePageSource } from './page-extensions.mjs';
|
|
8
|
+
import { validatePublicMetadata } from './metadata-conflicts.mjs';
|
|
8
9
|
|
|
9
10
|
const contentTypes = { robots: 'text/plain', sitemap: 'application/xml', manifest: 'application/manifest+json' };
|
|
10
11
|
|
|
@@ -31,8 +32,7 @@ export async function scanMetadataRoutes({ root, appRoot, files, routes, pattern
|
|
|
31
32
|
const signature = pattern.replace(/\[[^\]]+\]/g, '[]');
|
|
32
33
|
if (patterns.has(signature)) throw new Error(`Conflicting metadata route ${relative} and ${patterns.get(signature)} at ${pattern}.`);
|
|
33
34
|
if (kind === 'manifest' && manifest) throw new Error('Multiple manifest metadata files found in the App Router root.');
|
|
34
|
-
|
|
35
|
-
catch (error) { if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error; }
|
|
35
|
+
await validatePublicMetadata(root, pattern, file, dynamic);
|
|
36
36
|
patterns.set(signature, relative);
|
|
37
37
|
const handlerConfig = dynamic ? await validateAppConfig(file) : {};
|
|
38
38
|
if (dynamic) {
|