@thomas.f/prnext 0.1.12-alpha → 0.1.15-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +305 -65
  2. package/build/ADAPTERS.md +121 -0
  3. package/build/actions.mjs +4 -0
  4. package/build/app-config.mjs +2 -0
  5. package/build/app.mjs +23 -5
  6. package/build/build-lock.mjs +71 -0
  7. package/build/bundle-runtime.mjs +82 -0
  8. package/build/compiler.mjs +3 -1
  9. package/build/config.mjs +9 -2
  10. package/build/font-cache.mjs +50 -0
  11. package/build/font-files.mjs +5 -3
  12. package/build/fonts.mjs +1 -1
  13. package/build/index.mjs +12 -1
  14. package/build/inspect.mjs +20 -2
  15. package/build/inspection-summary.mjs +37 -0
  16. package/build/metadata-conflicts.mjs +13 -0
  17. package/build/metadata-images.mjs +3 -3
  18. package/build/metadata-routes.mjs +3 -3
  19. package/build/native-packages.mjs +5 -1
  20. package/build/npm-packages.mjs +2 -1
  21. package/build/og.mjs +3 -9
  22. package/build/optimize-imports.mjs +112 -0
  23. package/build/output-contract.mjs +41 -0
  24. package/build/precompress.mjs +13 -0
  25. package/build/proxy-body-config.mjs +10 -0
  26. package/build/scan.mjs +10 -8
  27. package/build/static-adapter.mjs +100 -0
  28. package/cli.mjs +64 -11
  29. package/compat/after.cjs +51 -0
  30. package/compat/diagnostics.cjs +4 -0
  31. package/compat/index.d.ts +32 -1
  32. package/compat/server.cjs +2 -1
  33. package/compat/server.d.cts +2 -0
  34. package/index.mjs +2 -0
  35. package/native/launch.mjs +19 -0
  36. package/package.json +8 -9
  37. package/release/verify.mjs +2 -0
  38. package/runtime/api.mjs +20 -7
  39. package/runtime/app-render.mjs +78 -33
  40. package/runtime/app-route-plan.mjs +45 -0
  41. package/runtime/app-routing-state.mjs +101 -21
  42. package/runtime/app-routing.mjs +37 -18
  43. package/runtime/app-segments.mjs +25 -11
  44. package/runtime/app-static.mjs +1 -1
  45. package/runtime/app-stream-html.mjs +2 -1
  46. package/runtime/cache-request.mjs +10 -2
  47. package/runtime/cloud/archive.mjs +317 -0
  48. package/runtime/cloud/client.mjs +641 -0
  49. package/runtime/cloud/environment.mjs +60 -0
  50. package/runtime/cloud/state.mjs +156 -0
  51. package/runtime/cloud/upload.mjs +48 -0
  52. package/runtime/html-envelope.mjs +70 -7
  53. package/runtime/http.mjs +7 -4
  54. package/runtime/middleware.mjs +3 -3
  55. package/runtime/og/Geist-Regular.ttf +0 -0
  56. package/runtime/og/LICENSE +373 -0
  57. package/runtime/og/LICENSE.Geist +93 -0
  58. package/runtime/og/LICENSE.fflate +21 -0
  59. package/runtime/og/LICENSE.harfbuzzjs +22 -0
  60. package/runtime/og/LICENSE.satori +373 -0
  61. package/runtime/og/NOTICE.txt +4 -0
  62. package/runtime/og/hb.wasm +0 -0
  63. package/runtime/og/index.node.mjs +31260 -0
  64. package/runtime/og/resvg.wasm +0 -0
  65. package/runtime/og-backend.mjs +8 -0
  66. package/runtime/og-loader.mjs +2 -15
  67. package/runtime/request-transport.mjs +1 -1
  68. package/runtime/route-static.mjs +1 -1
  69. package/runtime/rsc-worker.mjs +27 -4
  70. package/runtime/stream-utils.mjs +22 -3
  71. package/runtime/transport.mjs +16 -0
  72. package/runtime/worker-metadata.mjs +23 -0
  73. package/runtime/worker.mjs +36 -9
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:{${files.join(',')}},children:[${value.children.map(serialize).join(',')}],slots:{${Object.entries(value.slots).map(([name, child]) => `${JSON.stringify(name)}:${serialize(child)}`).join(',')}}}`;
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
+ }
@@ -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
+ }
@@ -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 css = (await responseBytes(normalized.url, 1024 * 1024, 'text/css')).toString('utf8');
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 responseBytes(url.href, MAX_FONT, 'font/woff2,font/woff,*/*');
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(projectRoot, { ...options, envMode })); }
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, access } from 'node:fs/promises';
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
- try { await access(path.join(root, 'public', pattern.slice(1))); throw new Error(`Metadata route ${pattern} conflicts with a file in public/.`); }
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, access } from 'node:fs/promises';
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
- try { await access(path.join(root, 'public', pattern.slice(1))); throw new Error(`Metadata route ${pattern} conflicts with a file in public/.`); }
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) {