@thomas.f/prnext 0.1.20-alpha → 0.1.21-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 (47) hide show
  1. package/build/app.mjs +4 -4
  2. package/build/build-timings.mjs +38 -0
  3. package/build/bundle-runtime.mjs +8 -8
  4. package/build/compiler.mjs +2 -1
  5. package/build/flight-summary.mjs +64 -0
  6. package/build/index.mjs +32 -28
  7. package/build/inspect.mjs +3 -0
  8. package/build/inspection-summary.mjs +7 -2
  9. package/build/native-discovery.mjs +18 -6
  10. package/build/npm-packages.mjs +2 -2
  11. package/build/source-analysis.mjs +54 -1
  12. package/build/standalone-copy.mjs +48 -0
  13. package/build/standalone.mjs +2 -22
  14. package/build/transform-cache.mjs +17 -3
  15. package/cli.mjs +13 -0
  16. package/compat/diagnostics.cjs +5 -4
  17. package/compat/runtime-sample.cjs +56 -0
  18. package/compat/use-cache.cjs +5 -2
  19. package/docs/ai/compatibility.md +3 -3
  20. package/docs/ai/deployment.md +8 -5
  21. package/docs/ai/troubleshooting.md +22 -0
  22. package/package.json +20 -20
  23. package/runtime/app-client.mjs +106 -38
  24. package/runtime/app-render.mjs +31 -6
  25. package/runtime/app-stream-channel.mjs +17 -4
  26. package/runtime/app-stream-html.mjs +30 -11
  27. package/runtime/cache-components-rsc.mjs +26 -6
  28. package/runtime/cache-result.mjs +58 -0
  29. package/runtime/dev-client.mjs +26 -5
  30. package/runtime/dev-compiler-broker.mjs +20 -23
  31. package/runtime/dev-gateway.mjs +22 -6
  32. package/runtime/development-flight.mjs +59 -0
  33. package/runtime/development-queue.mjs +33 -0
  34. package/runtime/development.mjs +11 -9
  35. package/runtime/flight-diagnostics.mjs +48 -0
  36. package/runtime/flight-tap.mjs +10 -5
  37. package/runtime/html-envelope.mjs +34 -0
  38. package/runtime/loopback-rpc.mjs +92 -0
  39. package/runtime/native-ppr.mjs +57 -4
  40. package/runtime/persistent/client.mjs +1 -1
  41. package/runtime/persistent/common.mjs +7 -18
  42. package/runtime/persistent/daemon.mjs +1 -1
  43. package/runtime/persistent/native-proxy.mjs +1 -1
  44. package/runtime/persistent/watchdog.mjs +12 -27
  45. package/runtime/prewarm.mjs +35 -0
  46. package/runtime/rsc-worker.mjs +58 -8
  47. package/runtime/worker.mjs +29 -14
package/build/app.mjs CHANGED
@@ -7,7 +7,7 @@ import { createHash } from 'node:crypto';
7
7
  import { createRequire, builtinModules } from 'node:module';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import * as t from '@babel/types';
10
- import {analyzeSource} from './source-analysis.mjs';
10
+ import {analyzeSourceSummary} from './source-analysis.mjs';
11
11
  import { rewriteServerChunkOutputFiles } from './transform.mjs';
12
12
  import { transformDynamicImports } from './dynamic.mjs';
13
13
  import { createServerActions } from './actions.mjs';
@@ -47,13 +47,13 @@ export async function validateReactVersions(resolveFromProject) {
47
47
 
48
48
  function clientDirective(source, filename) {
49
49
  if (!source.includes('use client')) return false;
50
- return analyzeSource(source,filename).directives.includes('use client');
50
+ return analyzeSourceSummary(source,filename).directives.includes('use client');
51
51
  }
52
52
 
53
53
  function frameworkDirective(source, filename) {
54
54
  if (clientDirective(source, filename)) return true;
55
55
  if (!source.includes('use server')) return false;
56
- return analyzeSource(source,filename).hasServerDirective;
56
+ return analyzeSourceSummary(source,filename).hasServerDirective;
57
57
  }
58
58
 
59
59
  /** Enumerate runtime ESM exports without evaluating client code in the server build. */
@@ -61,7 +61,7 @@ export async function exportedNames(file, esbuild, seen = new Set()) {
61
61
  if (seen.has(file)) return new Set();
62
62
  seen.add(file);
63
63
  const source = await compilerSource(file);
64
- const analysis=analyzeSource(source,file),names=new Set(analysis.runtimeExports);
64
+ const analysis=analyzeSourceSummary(source,file),names=new Set(analysis.runtimeExports);
65
65
  for (const specifier of analysis.exportStars) {
66
66
  const resolved = await esbuild.resolve(specifier, { resolveDir: path.dirname(file), kind: 'import-statement', pluginData: { prnextExportScan: true } });
67
67
  if (resolved.errors.length || !resolved.path || resolved.external) throw new Error(`Cannot enumerate client boundary exports from ${specifier} in ${file}. Use explicit named re-exports.`);
@@ -0,0 +1,38 @@
1
+ import {AsyncLocalStorage} from 'node:async_hooks';
2
+
3
+ const storage = new AsyncLocalStorage();
4
+ const names = new Set(['configuration', 'discovery', 'runtime-inputs', 'pages-server', 'pages-browser', 'pages-output', 'middleware', 'metadata', 'app', 'instrumentation', 'types', 'runtime-bundle', 'prerender', 'native-ppr', 'retention', 'precompression', 'analysis', 'standalone', 'compiler-node', 'compiler-browser', 'compiler-edge']);
5
+
6
+ /** Wall times include awaited native/compiler work; nested phases are not additive. */
7
+ export async function withBuildTimings(run, clock = () => performance.now()) {
8
+ const state = {clock, started: clock(), phases: new Map()};
9
+ return storage.run(state, async () => {
10
+ try { return {result: await run(), timings: report(state)}; }
11
+ catch (error) {
12
+ // Diagnostics must never replace the original application/build failure.
13
+ try { if (error && typeof error === 'object') Object.defineProperty(error, 'buildTimings', {value: report(state), configurable: true}); } catch {}
14
+ throw error;
15
+ }
16
+ });
17
+ }
18
+
19
+ export async function measureBuildPhase(name, run) {
20
+ if (!names.has(name)) throw new TypeError('Unknown build timing phase');
21
+ const state = storage.getStore();
22
+ if (!state) return run();
23
+ const started = state.clock();
24
+ let failed = false;
25
+ try { return await run(); }
26
+ catch (error) { failed = true; throw error; }
27
+ finally {
28
+ const row = state.phases.get(name) || {calls: 0, failedCalls: 0, milliseconds: 0};
29
+ row.calls++; row.failedCalls += Number(failed); row.milliseconds += Math.max(0, state.clock() - started);
30
+ state.phases.set(name, row);
31
+ }
32
+ }
33
+
34
+ function report(state) {
35
+ return {version: 1, scope: 'build-wall-time-excluding-module-startup', nestedPhasesAreAdditive: false,
36
+ totalMilliseconds: Math.max(0, state.clock() - state.started),
37
+ phases: Object.fromEntries([...state.phases].map(([name, value]) => [name, {...value}]))};
38
+ }
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import {createHash} from 'node:crypto';
5
5
  import {version as esbuildVersion} from 'esbuild';
6
6
  import {privateCacheDirectory,readCacheRecord,writeCacheRecord} from './persistent-cache.mjs';
7
+ import {mapConcurrent} from './concurrent.mjs';
7
8
 
8
9
  // Paths used by the native worker, generated application modules, prerender,
9
10
  // standalone tracing, compatibility modules and the runtime lifecycle contract.
@@ -39,16 +40,15 @@ export async function bundleRuntime(stage,{projectRoot,sourceDirectory,imageResp
39
40
  for (const name of selectedEntries) {
40
41
  if (!originals.some(entry => entry.name === `${name}.mjs`)) throw new Error(`Missing runtime entry ${name}.mjs`);
41
42
  }
42
- const sources=new Map();
43
43
  // Production compiles the installed sources directly. Only generated stage
44
44
  // overrides (currently the bundled env loader) replace an installed module.
45
45
  // Hash and compile the same captured bytes; do not trust mtimes or skip app
46
46
  // graph callbacks. This avoids copying hundreds of modules into every stage.
47
- for(const entry of originals){
47
+ const sources=new Map(await mapConcurrent(originals,async entry=>{
48
48
  let file=path.join(input,entry.name);
49
49
  if(sourceDirectory){const override=path.join(runtime,entry.name);const info=await lstat(override).catch(e=>{if(e.code!=='ENOENT')throw e;});if(info){if(!info.isFile()||info.isSymbolicLink())throw Error('Runtime overrides must be ordinary files');file=override;}}
50
- sources.set(path.join(input,entry.name),await readFile(file));
51
- }
50
+ return [path.join(input,entry.name),await readFile(file)];
51
+ },8));
52
52
  let directory,key,cached;
53
53
  if(projectRoot){
54
54
  directory=await privateCacheDirectory(projectRoot,'runtime-bundle-v2');
@@ -99,11 +99,11 @@ export async function bundleRuntime(stage,{projectRoot,sourceDirectory,imageResp
99
99
  if(directory&&!validCache)await writeCacheRecord(directory,key,{files:[...emitted].map(([name,bytes])=>[name,Buffer.from(bytes).toString()])});
100
100
  // This is an unpublished, disposable stage. On any write failure buildProject
101
101
  // discards the stage and preserves the previous published build unchanged.
102
- for (const [name, contents] of emitted) await writeFile(path.join(runtime, name), contents);
103
- for (const entry of await readdir(runtime,{withFileTypes:true})) {
104
- if(!entry.name.endsWith('.mjs')||!entry.isFile())continue;
102
+ await mapConcurrent(emitted,async([name,contents])=>writeFile(path.join(runtime,name),contents),8);
103
+ await mapConcurrent(await readdir(runtime,{withFileTypes:true}),async entry=>{
104
+ if(!entry.name.endsWith('.mjs')||!entry.isFile())return;
105
105
  if (!emitted.has(entry.name)) await unlink(path.join(runtime, entry.name));
106
- }
106
+ },8);
107
107
  // JSON, CJS, license files and the OG assets directory are never traversed or
108
108
  // removed. The caller writes the normal runtime contract after prerender.
109
109
  return { entryCount: selectedEntries.length, moduleCount: emitted.size,cached:!!validCache,
@@ -19,6 +19,7 @@ import {recordDevDependencies,developmentDependencies,developmentStylePlans,deve
19
19
  import {normalizeReactCompilerConfig,reactCompilerVersion} from './react-compiler.mjs';
20
20
  import {withReactCompilerReporting,configureReactCompilerReporting,withReactCompilerGraph,recordReactCompilerGraph} from './react-compiler-report.mjs';
21
21
  import {withCompilationGraphCacheReporting,cachedGraphBuild} from './compilation-cache.mjs';
22
+ import {measureBuildPhase} from './build-timings.mjs';
22
23
  export {reactCompilerReport} from './react-compiler-report.mjs';
23
24
  export {compilationGraphCacheReport} from './compilation-cache.mjs';
24
25
 
@@ -150,7 +151,7 @@ export async function build(options, { role = 'main' } = {}) {
150
151
  const state=compilation.getStore();
151
152
  const target=options.define?.['process.env.NEXT_RUNTIME']==='"edge"'?'edge':options.platform==='browser'?'browser':'node';
152
153
  return withReactCompilerGraph(target,async()=>{
153
- const result=await compileGraph(options,role);
154
+ const result=await measureBuildPhase('compiler-'+target,()=>compileGraph(options,role));
154
155
  recordReactCompilerGraph(result);
155
156
  recordBuildAnalysis(state,options,result);
156
157
  recordDevDependencies(state,options,result);
@@ -0,0 +1,64 @@
1
+ const phases = ['rsc', 'ssr'];
2
+ const outcomes = ['completed', 'failed', 'cancelled', 'timedOut'];
3
+ const bytes = value => Number.isSafeInteger(value) && value >= 0 && value <= 32 * 1024 * 1024;
4
+ const duration = value => Number.isFinite(value) && value >= 0 && value <= 86400000;
5
+ const quantile = (values, q) => values.length ? [...values].sort((a,b)=>a-b)[Math.ceil(values.length*q)-1] : null;
6
+
7
+ // A route pattern is supplied by the framework, never inferred from a URL.
8
+ // Copy only this schema from disk; old/corrupted/foreign diagnostic events cannot
9
+ // contribute arbitrary properties, unbounded groups or non-finite metrics.
10
+ export function flightSamples(events) {
11
+ return events.filter(event => event?.type === 'flight' && phases.includes(event.phase) && outcomes.includes(event.outcome) &&
12
+ typeof event.route === 'string' && event.route.length <= 256 && !/[\0\r\n]/.test(event.route) &&
13
+ Number.isSafeInteger(event.time) && event.time >= 0 && event.time <= 18446744073709 &&
14
+ bytes(event.flightBytes) && bytes(event.flightChunks) && bytes(event.responseBytes) && bytes(event.peakQueuedBytes) && duration(event.durationMs))
15
+ .slice(-10000).map(event => ({type:'flight',route:event.route,phase:event.phase,outcome:event.outcome,time:event.time,
16
+ method:/^(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS)$/.test(event.method)?event.method:'_OTHER',
17
+ durationMs:event.durationMs,flightBytes:event.flightBytes,flightChunks:event.flightChunks,responseBytes:event.responseBytes,peakQueuedBytes:event.peakQueuedBytes,
18
+ ...Object.fromEntries(['firstChunkMs','modelReadyMs','shellReadyMs'].filter(key=>duration(event[key])).map(key=>[key,event[key]])),
19
+ ...(['direct','normalized'].includes(event.ppr)?{ppr:event.ppr}:{})}));
20
+ }
21
+
22
+ export function summarizeFlight(events) {
23
+ const groups = new Map(), sample = flightSamples(events);
24
+ for (const event of sample) {
25
+ const key = JSON.stringify([event.route,event.phase]);
26
+ if (!groups.has(key)) {
27
+ if (groups.size >= 256) continue;
28
+ groups.set(key,{route:event.route,phase:event.phase,events:[]});
29
+ }
30
+ groups.get(key).events.push(event);
31
+ }
32
+ const routes = [...groups.values()].map(({events,...group})=>{
33
+ const completed=events.filter(event=>event.outcome==='completed');
34
+ const p95=key=>quantile(completed.map(event=>event[key]).filter(value=>Number.isFinite(value)),.95);
35
+ return {...group,samples:events.length,...Object.fromEntries(outcomes.map(outcome=>[outcome,events.filter(event=>event.outcome===outcome).length])),
36
+ flightP50Bytes:quantile(completed.map(event=>event.flightBytes),.5),flightP95Bytes:p95('flightBytes'),
37
+ responseP95Bytes:p95('responseBytes'),durationP95Ms:p95('durationMs'),firstChunkP95Ms:p95('firstChunkMs'),
38
+ modelReadyP95Ms:p95('modelReadyMs'),shellReadyP95Ms:p95('shellReadyMs'),
39
+ peakQueuedBytes:events.reduce((max,event)=>Math.max(max,event.peakQueuedBytes),0),
40
+ directPprSamples:events.filter(event=>event.ppr==='direct').length,normalizedPprSamples:events.filter(event=>event.ppr==='normalized').length};
41
+ }).sort((a,b)=>(b.flightP95Bytes??-1)-(a.flightP95Bytes??-1)||a.route.localeCompare(b.route)||a.phase.localeCompare(b.phase));
42
+ return {flight:routes,flightSample:sample};
43
+ }
44
+
45
+ export function flightMetrics(events) {
46
+ const bounds=[1024,4096,16384,65536,131072,262144,1048576,4194304,16777216],groups=new Map();
47
+ for (const event of flightSamples(events).filter(value=>value.outcome==='completed')) {
48
+ const key=JSON.stringify([event.route,event.phase]);
49
+ if(!groups.has(key)){
50
+ if(groups.size>=256)continue;
51
+ groups.set(key,{route:event.route,phase:event.phase,start:event.time,end:event.time,values:[]});
52
+ }
53
+ const group=groups.get(key);group.values.push(event.flightBytes);group.start=Math.min(group.start,event.time);group.end=Math.max(group.end,event.time);
54
+ }
55
+ const dataPoints=[...groups.values()].map(group=>{
56
+ const buckets=Array(bounds.length+1).fill(0);
57
+ for(const value of group.values){const index=bounds.findIndex(bound=>value<=bound);buckets[index<0?bounds.length:index]++;}
58
+ return {attributes:[{key:'http.route',value:{stringValue:group.route}},{key:'prnext.render.phase',value:{stringValue:group.phase}}],
59
+ startTimeUnixNano:String(BigInt(group.start)*1000000n),timeUnixNano:String(BigInt(group.end)*1000000n),
60
+ count:String(group.values.length),sum:group.values.reduce((a,b)=>a+b,0),min:Math.min(...group.values),max:Math.max(...group.values),
61
+ explicitBounds:bounds,bucketCounts:buckets.map(String)};
62
+ });
63
+ return {name:'prnext.flight.response.size',description:'Bounded diagnostic sample of uncompressed Flight bytes read to completion. RSC and SSR are separate observations of the same payload; never add their counts. Cancelled/failed samples are excluded.',unit:'By',histogram:{aggregationTemporality:1,dataPoints}};
64
+ }
package/build/index.mjs CHANGED
@@ -5,6 +5,8 @@ import { rename } from '../runtime/fs.mjs';
5
5
  import { writeRuntimeContract } from './runtime-contract.mjs';
6
6
  import { writeBuildOutput } from './output-contract.mjs';
7
7
  import { bundleRuntime } from './bundle-runtime.mjs';
8
+ import {withBuildTimings, measureBuildPhase} from './build-timings.mjs';
9
+ import {mapConcurrent} from './concurrent.mjs';
8
10
  import {withPersistentTransforms} from './transform-cache.mjs';
9
11
  import {withCompilationStorage,configureCompilationStorage} from './compilation-storage.mjs';
10
12
  import {reclaimBuildStages,trackBuildStage} from './build-retention.mjs';
@@ -26,7 +28,7 @@ import { randomUUID, randomBytes, createHash } from 'node:crypto';
26
28
  import { prepareBuildDirectory, OUTPUT_POINTER } from '../runtime/build-directory.mjs';
27
29
  import { moduleResolutionPlugin } from './module-resolution.mjs';
28
30
  import { scanProject } from './scan.mjs';
29
- import { stripServerCode, assertSupportedSource, rewriteServerChunks } from './transform.mjs';
31
+ import { stripServerCode, assertSupportedSource, rewriteServerChunkOutputFiles } from './transform.mjs';
30
32
  import { transformDynamicImports } from './dynamic.mjs';
31
33
  import { createCssModules } from './css-modules.mjs';
32
34
  import { createFonts } from './fonts.mjs';
@@ -189,8 +191,8 @@ export function build(projectRoot, options = {}) {
189
191
  try { return await withBuildLock(projectRoot, async root => {
190
192
  await reclaimBuildStages(root);
191
193
  const started=performance.now();
192
- const result=await withCompilationStorage(root,()=>withPersistentTransforms(root,{dev:!!options.dev},()=>withCompilation(() => buildProject(root, { ...options, envMode }))));
193
- return {...result,durationMs:Math.round(performance.now()-started)};
194
+ const measured=await withBuildTimings(()=>withCompilationStorage(root,()=>withPersistentTransforms(root,{dev:!!options.dev},()=>withCompilation(() => buildProject(root, { ...options, envMode })))));
195
+ return {...measured.result,durationMs:Math.round(performance.now()-started),buildTimings:measured.timings};
194
196
  }); }
195
197
  finally { restore(); }
196
198
  });
@@ -206,7 +208,7 @@ async function buildProject(projectRoot, { dev = false, envMode, incremental = f
206
208
  devInputs,devSession,devActionSecret,incremental});
207
209
  if (deferred) return deferred;
208
210
  }
209
- const config = await loadProjectConfig(projectRoot, { dev, envMode, devInputs });
211
+ const config = await measureBuildPhase('configuration',()=>loadProjectConfig(projectRoot, { dev, envMode, devInputs }));
210
212
  configureCompilationStorage(config.compilationCache);
211
213
  const tsconfig = config.typescript ? await projectTsconfig(projectRoot, config) : undefined;
212
214
  const environment = defineEnvironment(config);
@@ -216,12 +218,12 @@ async function buildProject(projectRoot, { dev = false, envMode, incremental = f
216
218
  const scanOptions={basePath:config.basePath,pageExtensions:config.pageExtensions,i18n:config.i18n};
217
219
  let project,deferredRoutes=[];
218
220
  if(dev && Array.isArray(devRoutes)) {
219
- const discovery=await scanProject(projectRoot,{...scanOptions,devAppFiles:new Set()});
220
- project=await scanProject(projectRoot,{...scanOptions,devAppFiles:routeFiles(discovery,devRoutes)});
221
+ const discovery=await measureBuildPhase('discovery',()=>scanProject(projectRoot,{...scanOptions,devAppFiles:new Set()}));
222
+ project=await measureBuildPhase('discovery',()=>scanProject(projectRoot,{...scanOptions,devAppFiles:routeFiles(discovery,devRoutes)}));
221
223
  await writeTypedRoutes(projectRoot,discovery,config);
222
224
  deferredRoutes=discovery.routes.filter(route=>!devRoutes.includes(route.pattern)&&!route.internal&&!route.errorStatus);
223
225
  project.routes=project.routes.filter(route=>devRoutes.includes(route.pattern)||route.internal||route.errorStatus);
224
- } else project=await scanProject(projectRoot,scanOptions);
226
+ } else project=await measureBuildPhase('discovery',()=>scanProject(projectRoot,scanOptions));
225
227
  if(dev&&config.experimental.lazyDynamicImports){
226
228
  if(project.routes.some(route=>route.router!=='app'&&!route.internal))throw Error('experimental.lazyDynamicImports currently requires an App Router-only application.');
227
229
  if(config.webpack||Object.keys(config.turbopack?.rules||{}).length)throw Error('experimental.lazyDynamicImports currently requires the native compiler without webpack hooks or custom loaders.');
@@ -258,7 +260,7 @@ async function buildProject(projectRoot, { dev = false, envMode, incremental = f
258
260
  const cssModules = createCssModules({ projectRoot: project.root, stage, assetBase: publicPaths.assetBase, sassOptions: config.sassOptions, cssEngine:config.cssEngine,dev,config });
259
261
  configureCompiler({config,root:project.root,stage,buildId,dev,incremental,devInputs,tsconfig,assetBase:publicPaths.assetBase});
260
262
  try {
261
- await copyRuntimeFiles(stage,{development:dev});
263
+ await measureBuildPhase('runtime-inputs',()=>copyRuntimeFiles(stage,{development:dev}));
262
264
  // Runtime copies must work even when dotenv is nested beneath the PRNext
263
265
  // package instead of hoisted beside the application's dependencies.
264
266
  await bundle({ entryPoints: [path.join(packageRoot, 'runtime/env.mjs')], outfile: path.join(stage, 'runtime/env.mjs'), bundle: true, platform: 'node', format: 'esm', target: 'node22', logLevel: 'silent', banner: { js: "import {createRequire as __prnextCreateRequire} from 'node:module';const require=__prnextCreateRequire(import.meta.url);" } });
@@ -291,14 +293,16 @@ async function buildProject(projectRoot, { dev = false, envMode, incremental = f
291
293
  }
292
294
  const common = { absWorkingDir: project.root, bundle: true, logLevel: 'silent', jsx: 'automatic', loader: assetLoaders, assetNames: '[name]-[hash]', publicPath: publicPaths.assetBase, metafile: true, define: { 'process.env.NODE_ENV': JSON.stringify(dev ? 'development' : 'production'), ...environment } };
293
295
  if (Object.keys(serverEntries).length) {
294
- const server = await bundle({ ...common, entryPoints: serverEntries, outdir: path.join(stage, 'server'), outExtension: { '.js': '.mjs' }, platform: 'node', format: 'esm', splitting: true, chunkNames: 'chunk-[hash]', banner: { js: "import {createRequire as __prnextCreateRequire} from 'node:module';const require=__prnextCreateRequire(import.meta.url);" }, target: 'node22', sourcemap: dev, plugins: [moduleResolutionPlugin(config.turbopack, project.root), images.plugin(), fonts.plugin(), compatibilityPlugin({ browser: false, pageFiles, projectRoot: project.root, documentFile: project.document, serverOnlyFiles, fonts, locations, reactCompiler }), npmPackagesPlugin({ pages: true, projectRoot: project.root, transpilePackages: config.transpilePackages, serverExternalPackages: config.serverExternalPackages }), cssModules.plugin(false)] });
296
+ const server = await measureBuildPhase('pages-server',()=>bundle({ ...common, write: false, entryPoints: serverEntries, outdir: path.join(stage, 'server'), outExtension: { '.js': '.mjs' }, platform: 'node', format: 'esm', splitting: true, chunkNames: 'chunk-[hash]', banner: { js: "import {createRequire as __prnextCreateRequire} from 'node:module';const require=__prnextCreateRequire(import.meta.url);" }, target: 'node22', sourcemap: dev, plugins: [moduleResolutionPlugin(config.turbopack, project.root), images.plugin(), fonts.plugin(), compatibilityPlugin({ browser: false, pageFiles, projectRoot: project.root, documentFile: project.document, serverOnlyFiles, fonts, locations, reactCompiler }), npmPackagesPlugin({ pages: true, projectRoot: project.root, transpilePackages: config.transpilePackages, serverExternalPackages: config.serverExternalPackages }), cssModules.plugin(false)] }));
295
297
  fonts.record(server.metafile);
296
298
  const serverChunks = new Set(Object.keys(server.metafile.outputs).filter(file => file.endsWith('.mjs')).map(file => path.basename(file)));
297
- for (const [output, metadata] of Object.entries(server.metafile.outputs)) {
298
- if (!output.endsWith('.mjs') || !metadata.imports.some(item => !item.external)) continue;
299
- const file = path.resolve(project.root, output);
300
- await rewriteServerChunks(file, serverChunks, publicPaths.assetBase);
301
- }
299
+ const importers = new Set(Object.entries(server.metafile.outputs).filter(([output, metadata])=>output.endsWith('.mjs')&&metadata.imports.some(item=>!item.external)).map(([output])=>path.resolve(project.root,output)));
300
+ // All buffers are transformed before their first write. Settle every owned
301
+ // writer before stage cleanup on failure, including external sourcemaps.
302
+ await measureBuildPhase('pages-output',async()=>{
303
+ const outputs = rewriteServerChunkOutputFiles(server.outputFiles,serverChunks,publicPaths.assetBase,importers);
304
+ await mapConcurrent(outputs,async file=>{await mkdir(path.dirname(file.path),{recursive:true});await writeFile(file.path,file.contents);},8);
305
+ });
302
306
  // Image/font imports must resolve to the same public URLs on both render paths.
303
307
  await mkdir(path.join(stage, 'assets'), { recursive: true });
304
308
  for (const [output, metadata] of Object.entries(server.metafile.outputs)) {
@@ -306,7 +310,7 @@ async function buildProject(projectRoot, { dev = false, envMode, incremental = f
306
310
  }
307
311
  }
308
312
  if (Object.keys(clientEntries).length) {
309
- const browser = await bundle({ ...common, entryPoints: clientEntries, outdir: path.join(stage, 'assets'), entryNames: '[name]-[hash]', chunkNames: 'chunk-[hash]', platform: 'browser', format: 'esm', target: ['es2022'], splitting: true, minify: !dev, sourcemap: dev || config.productionBrowserSourceMaps, define: { 'process.env': '{}', ...common.define }, plugins: [moduleResolutionPlugin(config.turbopack, project.root), ...(dev ? [devSingletonsPlugin()] : []), images.plugin(), fonts.plugin(), compatibilityPlugin({ browser: true, pageFiles, projectRoot: project.root, documentFile: project.document, serverOnlyFiles, fonts, refresh, locations, reactCompiler, nativeCompiler: config.reactCompiler?.backend === 'oxc' }), cssModules.plugin(true)] });
313
+ const browser = await measureBuildPhase('pages-browser',()=>bundle({ ...common, entryPoints: clientEntries, outdir: path.join(stage, 'assets'), entryNames: '[name]-[hash]', chunkNames: 'chunk-[hash]', platform: 'browser', format: 'esm', target: ['es2022'], splitting: true, minify: !dev, sourcemap: dev || config.productionBrowserSourceMaps, define: { 'process.env': '{}', ...common.define }, plugins: [moduleResolutionPlugin(config.turbopack, project.root), ...(dev ? [devSingletonsPlugin()] : []), images.plugin(), fonts.plugin(), compatibilityPlugin({ browser: true, pageFiles, projectRoot: project.root, documentFile: project.document, serverOnlyFiles, fonts, refresh, locations, reactCompiler, nativeCompiler: config.reactCompiler?.backend === 'oxc' }), cssModules.plugin(true)] }));
310
314
  fonts.record(browser.metafile);
311
315
  const byEntry = new Map(Object.entries(clientEntries).map(([id, file]) => [path.resolve(file), id]));
312
316
  for (const [output, metadata] of Object.entries(browser.metafile.outputs)) {
@@ -320,10 +324,10 @@ async function buildProject(projectRoot, { dev = false, envMode, incremental = f
320
324
  if (metadata.cssBundle) route.css.push(publicPaths.assetBase + '/' + path.basename(metadata.cssBundle));
321
325
  }
322
326
  }
323
- await compileMiddleware({ project, stage, manifest, dev, moduleResolution: config.turbopack, defineEnvironment: environment });
327
+ await measureBuildPhase('middleware',()=>compileMiddleware({ project, stage, manifest, dev, moduleResolution: config.turbopack, defineEnvironment: environment }));
324
328
  if (manifest.middleware) manifest.middleware.bodySizeLimit = config.experimental.proxyClientMaxBodySize;
325
- await prepareMetadataRoutes(project, stage);
326
- await compileApp({ project, stage, manifest, reactCompiler: config.reactCompiler, moduleResolution: config.turbopack, cssModules, fonts, images, dev, devActionSecret:dev?devActionSecret:undefined, defineEnvironment: environment, productionBrowserSourceMaps: config.productionBrowserSourceMaps, serverExternalPackages: config.serverExternalPackages, lazyDynamicImports:dev&&config.experimental.lazyDynamicImports,devLazyRequest,devLazyActivated, parallelBuilds: !dev && config.experimental.parallelBuilds && await cssModules.parallelSafe() ? config.experimental.parallelBuilds : false });
329
+ await measureBuildPhase('metadata',()=>prepareMetadataRoutes(project, stage));
330
+ await measureBuildPhase('app',async()=>compileApp({ project, stage, manifest, reactCompiler: config.reactCompiler, moduleResolution: config.turbopack, cssModules, fonts, images, dev, devActionSecret:dev?devActionSecret:undefined, defineEnvironment: environment, productionBrowserSourceMaps: config.productionBrowserSourceMaps, serverExternalPackages: config.serverExternalPackages, lazyDynamicImports:dev&&config.experimental.lazyDynamicImports,devLazyRequest,devLazyActivated, parallelBuilds: !dev && config.experimental.parallelBuilds && await cssModules.parallelSafe() ? config.experimental.parallelBuilds : false }));
327
331
  if(manifest.devLazy){
328
332
  const css=Object.values(manifest.devLazy.outputs).flatMap(module=>module.css);
329
333
  for(const route of manifest.routes)if(route.router==='app'&&route.kind==='page'){
@@ -339,14 +343,14 @@ async function buildProject(projectRoot, { dev = false, envMode, incremental = f
339
343
  const selected=manifest.routes.find(route=>!route.internal&&route.pattern===pattern);
340
344
  if(!selected||selected.cacheConfig?.runtime==='edge'||selected.handlerConfig?.runtime==='edge')throw new Error(`workerPreload route ${pattern} must be an existing Node route.`);
341
345
  }
342
- await compileInstrumentation({root:project.root,stage,manifest,config,dev,environment});
346
+ await measureBuildPhase('instrumentation',()=>compileInstrumentation({root:project.root,stage,manifest,config,dev,environment}));
343
347
  if(deferredRoutes.length){
344
348
  for(const route of deferredRoutes){
345
349
  const module=`server/dev-deferred-${route.id}.mjs`;await writeFile(path.join(stage,module),'export {};\n');
346
350
  manifest.routes.push({id:route.id,pattern:route.pattern,kind:route.kind,router:route.router,module,devDeferred:true,css:[],cacheConfig:{maxDuration:120}});
347
351
  }
348
352
  }
349
- await checkTypeScript(project.root, config, { dev, tsconfig });
353
+ await measureBuildPhase('types',()=>checkTypeScript(project.root, config, { dev, tsconfig }));
350
354
  fonts.attach(manifest);
351
355
  expandLocales(manifest);
352
356
  const imageResponse = await imageResponseRequirement(stage, {dev, manifest});
@@ -354,13 +358,13 @@ async function buildProject(projectRoot, { dev = false, envMode, incremental = f
354
358
  else await rm(path.join(stage, 'compat/og.cjs'), {force: true});
355
359
  // All runtime preparation must finish before its shared modules are built;
356
360
  // no staged renderer has been imported before this production-only point.
357
- if (!dev) await bundleRuntime(stage,{projectRoot:project.root,sourceDirectory:path.join(packageRoot,'runtime'),imageResponse:imageResponse.included});
361
+ if (!dev) await measureBuildPhase('runtime-bundle',()=>bundleRuntime(stage,{projectRoot:project.root,sourceDirectory:path.join(packageRoot,'runtime'),imageResponse:imageResponse.included}));
358
362
  // Node caches CJS React by filename, not NODE_ENV. Once development metadata
359
363
  // imports load it, subsequent production renders need a clean process too.
360
364
  if(dev) developmentModulesLoaded = true;
361
- if(!dev) Object.assign(manifest,await (incremental || developmentModulesLoaded ? isolatedPrerender : prerenderProduction)(project.root,stage,manifest,{prerender:config.prerender}));
362
- else {await prerender(stage, manifest, dev);await prerenderApp(stage, manifest, dev);}
363
- if (!dev) await prepareNativePprShells(stage, manifest);
365
+ if(!dev) Object.assign(manifest,await measureBuildPhase('prerender',()=>(incremental || developmentModulesLoaded ? isolatedPrerender : prerenderProduction)(project.root,stage,manifest,{prerender:config.prerender})));
366
+ else await measureBuildPhase('prerender',async()=>{await prerender(stage, manifest, dev);await prerenderApp(stage, manifest, dev);});
367
+ if (!dev) await measureBuildPhase('native-ppr',()=>prepareNativePprShells(stage, manifest));
364
368
  const pages = manifest.routes.filter(route => route.kind === 'page' && route.router !== 'app' && !route.internal && !route.devDeferred);
365
369
  const publicPage = route => ({ ...(route.locale ? {locale:route.locale,originalPattern:route.originalPattern} : {}), id: route.id, pattern: route.pattern, client: route.client, css: route.css, ...(route.fonts ? { fonts: route.fonts } : {}), ssg: !!route.ssg, ssp: !!route.ssp, gip: !!route.gip, appGip: !!route.appGip });
366
370
  if (pages.length || manifest.pagesErrors) {
@@ -402,7 +406,7 @@ async function buildProject(projectRoot, { dev = false, envMode, incremental = f
402
406
  }
403
407
  // A report belongs to this successful staging generation, never to the
404
408
  // runtime manifest or public assets. Publishing the stage is atomic below.
405
- if(!dev)await retainDeploymentAssets({stage,destination,manifest,policy:config.deploymentRetention});
409
+ if(!dev)await measureBuildPhase('retention',()=>retainDeploymentAssets({stage,destination,manifest,policy:config.deploymentRetention}));
406
410
  const compilationCache=compilationGraphCacheReport();
407
411
  if(!dev)await writeFile(path.join(stage,'compilation-cache-report.json'),JSON.stringify(compilationCache,null,2)+'\n');
408
412
  const compilerReport=await writeReactCompilerReport(stage,reactCompilerReport());
@@ -411,9 +415,9 @@ async function buildProject(projectRoot, { dev = false, envMode, incremental = f
411
415
  await writeBuildOutput(stage, manifest);
412
416
  // Offline compression is a deployment optimization. Recompressing source
413
417
  // maps and every entry after a save stalls Fast Refresh for no local benefit.
414
- if (!dev && config.compress) await precompressBuild(stage, manifest, {cacheDirectory:path.join(project.root,'.prnext-cache/precompress-v1')});
415
- if(!dev)await writeBuildAnalysis({root:project.root,stage,destination,manifest,records:buildAnalysisRecords(),routeInputs:buildAnalysisRoutes(),precompressed:config.compress});
416
- if (config.output === 'standalone' && !dev) await (await import('./standalone-runner.mjs')).prepareStandalone({ projectRoot: project.root, stage, manifest, config });
418
+ if (!dev && config.compress) await measureBuildPhase('precompression',()=>precompressBuild(stage, manifest, {cacheDirectory:path.join(project.root,'.prnext-cache/precompress-v1')}));
419
+ if(!dev)await measureBuildPhase('analysis',()=>writeBuildAnalysis({root:project.root,stage,destination,manifest,records:buildAnalysisRecords(),routeInputs:buildAnalysisRoutes(),precompressed:config.compress}));
420
+ if (config.output === 'standalone' && !dev) await measureBuildPhase('standalone',async()=>(await import('./standalone-runner.mjs')).prepareStandalone({ projectRoot: project.root, stage, manifest, config }));
417
421
  const exporting = config.output === 'export' && !dev ? await import('./static-export.mjs') : null;
418
422
  const exportSource = exporting && await exporting.prepareStaticExport({ project, stage, manifest, config });
419
423
  let hadDestination = false;
package/build/inspect.mjs CHANGED
@@ -70,6 +70,8 @@ export async function inspectProject(directory) {
70
70
  notes: ['Enable measurements with prn start --inspect (or prn dev --inspect), visit routes, then run prn inspect again.',
71
71
  'HTTP and fetch durations end when response headers are ready. Samples are bounded and can span server sessions; they are not full-response benchmarks.',
72
72
  'Request-stage samples additionally separate admission, worker first byte, response headers and complete body consumption, with completion/cancellation/deadline outcomes. Stages overlap; do not add their durations. Body completion means the HTTP layer consumed the application body, not remote delivery.',
73
+ 'Flight sizes are uncompressed application bytes observed while reading the stream. RSC and SSR observe the same payload separately: do not add their sizes or counts. Size quantiles include completed samples only; failed/cancelled samples may be partial.',
74
+ 'Flight model-ready, shell-ready and duration timings include application work, queueing and asynchronous waits; they are not isolated serialization CPU. Browser execution, hydration, compressed transfer and remote delivery are not measured.',
73
75
  'Fetch destinations omit queries and credentials. Runtime cache tags/paths are hashed; matching identifiers link reads to invalidations. No diagnostic HTTP endpoint is exposed.'] };
74
76
  }
75
77
  export function printInspection(report) {
@@ -77,6 +79,7 @@ export function printInspection(report) {
77
79
  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.`);
78
80
  if(report.summary.wakeP95Ms !== null) console.log(`Worker startup p95: ${report.summary.wakeP95Ms} ms.`);
79
81
  if(report.summary.workerRssBytes !== null) console.log(`Latest recorded worker RSS: ${(report.summary.workerRssBytes/1048576).toFixed(1)} MiB. Native server memory is not included.`);
82
+ for(const sample of report.flight||[])console.log(`FLIGHT ${sample.route} (${sample.phase}): ${sample.completed} completed sample(s), p95 ${sample.flightP95Bytes===null?'n/a':(sample.flightP95Bytes/1000).toFixed(1)+' kB'}; ${sample.failed} failed, ${sample.cancelled} cancelled, ${sample.timedOut} timed out; model ready p95 ${sample.modelReadyP95Ms??'n/a'} ms, shell ready p95 ${sample.shellReadyP95Ms??'n/a'} ms (includes waiting).`);
80
83
  for(const stage of report.requestStages||[]) console.log(`STAGE ${stage.stage}: ${stage.samples} sample(s), p95 ${stage.p95Ms ?? 'n/a'} ms; ${stage.completed} completed, ${stage.failed} failed, ${stage.cancelled} cancelled, ${stage.timedOut} timed out.`);
81
84
  for(const budget of report.budgets||[])console.log(`BUDGET ${budget.budget}: ${budget.used ?? 'n/a'}/${budget.limit ?? 'n/a'} ${budget.unit}, ${budget.waiting ?? 'n/a'} waiting; ${budget.rejected ?? 'n/a'} rejected, ${budget.timedOut ?? 'n/a'} timed out, ${budget.cancelledWaits ?? 'n/a'} cancelled waits.`);
82
85
  for(const finding of report.findings)console.log(`${finding.level.toUpperCase()}: ${finding.message} ${finding.action}`);
@@ -1,3 +1,4 @@
1
+ import {summarizeFlight,flightMetrics} from './flight-summary.mjs';
1
2
  const quantile = (values, q) => values.length ? Math.round([...values].sort((a,b)=>a-b)[Math.ceil(values.length*q)-1]*100)/100 : null;
2
3
  const durations = events => events.map(e=>e.durationMs).filter(v=>Number.isFinite(v)&&v>=0);
3
4
  const stages=['admission','renderStart','bodyAdmission','bodyRead','workerQueue','workerFirstByte','workerHeaders','httpHeaders','responseBody','requestTotal'];
@@ -21,6 +22,7 @@ function currentBudgets(snapshot) {
21
22
  });
22
23
  }
23
24
  export function summarizeDiagnostics(events, snapshots = []) {
25
+ const flightReport=summarizeFlight(events);
24
26
  const http=events.filter(e=>e.type==='http'), times=durations(http);
25
27
  const wake=durations(events.filter(e=>e.type==='wake'&&e.phase==='node-ready'));
26
28
  const processes=new Map();
@@ -34,13 +36,16 @@ export function summarizeDiagnostics(events, snapshots = []) {
34
36
  queuedJobs:current?last.workers?.queuedJobs??null:null,activeJobs:current?last.workers?.activeJobs??null:null, runtimeSampleAt:last?.unixMs??null,
35
37
  completedResponseP95Ms:quantile(durations(stageSample.filter(e=>e.stage==='requestTotal'&&e.outcome==='completed')),.95),cancelledResponses:totals?.cancelled??null,timedOutResponses:totals?.timedOut??null};
36
38
  const findings=[];
39
+ for(const route of flightReport.flight)if(route.completed>=3&&route.flightP95Bytes>=131072)findings.push({level:'info',code:'LARGE_FLIGHT',
40
+ message:`${route.route} (${route.phase}) has a sampled Flight p95 of ${(route.flightP95Bytes/1000).toFixed(1)} kB.`,
41
+ action:'Inspect the rendered model and repeated presentation markup. Compare explicit public-prop client boundaries with their browser JavaScript and hydration costs; private server code must stay on the server.'});
37
42
  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.'});
38
43
  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.'});
39
44
  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.'});
40
45
  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.'});
41
46
  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.'});
42
47
  if(summary.timedOutResponses>0)findings.push({level:'warning',code:'RESPONSE_DEADLINES',message:`${summary.timedOutResponses} recorded HTTP responses reached their request deadline.`,action:'Compare admission, worker and body timings before increasing the configured timeout.'});
43
- return {summary,findings,requestStages,budgets:current?currentBudgets(last):null,stageSample,metricSample:http.filter(e=>Number.isFinite(e.durationMs)&&e.durationMs>=0&&e.durationMs<=86400000&&validTime(e.time)).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}))};
48
+ return {summary,findings,requestStages,...flightReport,budgets:current?currentBudgets(last):null,stageSample,metricSample:http.filter(e=>Number.isFinite(e.durationMs)&&e.durationMs>=0&&e.durationMs<=86400000&&validTime(e.time)).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}))};
44
49
  }
45
50
 
46
51
  /** OTLP/JSON Metrics export of a bounded local sample, never a complete traffic counter. */
@@ -66,5 +71,5 @@ export function inspectionMetrics(report) {
66
71
  group.count++;group.sum+=value;group.min=Math.min(group.min,value);group.max=Math.max(group.max,value);group.start=Math.min(group.start,event.time);group.end=Math.max(group.end,event.time);group.buckets[index<0?bounds.length:index]++;
67
72
  }
68
73
  const stagePoints=[...stageGroups.values()].map(g=>({attributes:[{key:'prnext.request.stage',value:{stringValue:g.stage}},{key:'prnext.request.outcome',value:{stringValue:g.outcome}}],startTimeUnixNano:String(BigInt(g.start)*1000000n),timeUnixNano:String(BigInt(g.end)*1000000n),count:String(g.count),sum:g.sum,min:g.min,max:g.max,explicitBounds:bounds,bucketCounts:g.buckets.map(String)}));
69
- 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}},{name:'prnext.request.stage.duration',description:'Bounded diagnostic sample of request stages and terminal outcomes; stages overlap and must not be added together.',unit:'s',histogram:{aggregationTemporality:1,dataPoints:stagePoints}}]}]}]};
74
+ 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}},{name:'prnext.request.stage.duration',description:'Bounded diagnostic sample of request stages and terminal outcomes; stages overlap and must not be added together.',unit:'s',histogram:{aggregationTemporality:1,dataPoints:stagePoints}},flightMetrics(report.flightSample||[])]}]}]};
70
75
  }
@@ -1,7 +1,7 @@
1
1
  import { readdir, readFile, realpath } from 'node:fs/promises';
2
2
  import { builtinModules, createRequire } from 'node:module';
3
3
  import path from 'node:path';
4
- import { analyzeSource } from './source-analysis.mjs';
4
+ import { analyzeSourceSummary } from './source-analysis.mjs';
5
5
 
6
6
  const builtins = new Set([...builtinModules, ...builtinModules.map(name => 'node:' + name)]);
7
7
 
@@ -15,6 +15,7 @@ export function createNativeDiscovery({ projectRoot, isClientSource }) {
15
15
  const sourceImports = new Map(), entryNative = new Map(), declaredNative = new Map();
16
16
  const resolvers = new Map();
17
17
  let sourceBytes = 0;
18
+ let declaredVisits=0,declaredProofHits=0;
18
19
  const isApplication = directory => {
19
20
  const relative = path.relative(directory, projectRoot);
20
21
  return relative === '' || (!relative.startsWith('..' + path.sep) && relative !== '..' && !path.isAbsolute(relative));
@@ -27,7 +28,7 @@ export function createNativeDiscovery({ projectRoot, isClientSource }) {
27
28
  const size = Buffer.byteLength(source);
28
29
  if (size > 8 * 1024 ** 2 || (sourceBytes += size) > 64 * 1024 ** 2) throw new Error('Native npm import analysis exceeds its 8 MiB file / 64 MiB source budget. Separate the native loader into a server-only package.');
29
30
  // The compiler accepts JSX in JavaScript npm entries, including .js.
30
- const { imports, computedRequire } = analyzeSource(source, /\.[cm]?js$/.test(file) ? file + '.jsx' : file);
31
+ const { imports, computedRequire } = analyzeSourceSummary(source,file);
31
32
  // Keep edges, not the AST, so the shared parser can bound its retained cache.
32
33
  return { imports, computedRequire };
33
34
  })());
@@ -111,20 +112,30 @@ export function createNativeDiscovery({ projectRoot, isClientSource }) {
111
112
  return dependencyRoots.get(key);
112
113
  }
113
114
 
114
- async function nativeReachable(owner, visited = new Set()) {
115
+ const nativePresent=new Set(),nativeAbsent=new Set();
116
+ async function nativeReachable(owner, visited) {
117
+ if(nativePresent.has(owner.directory)){declaredProofHits++;return true;}
118
+ if(nativeAbsent.has(owner.directory)){declaredProofHits++;return false;}
115
119
  if (visited.has(owner.directory)) return false;
116
120
  visited.add(owner.directory);
117
- if ((await filesFor(owner.directory)).native) return true;
121
+ declaredVisits++;
122
+ if ((await filesFor(owner.directory)).native) {nativePresent.add(owner.directory);return true;}
118
123
  const names = new Set([...Object.keys(owner.metadata.dependencies || {}), ...Object.keys(owner.metadata.optionalDependencies || {})]);
119
124
  for (const name of names) {
120
125
  const dependency = await resolveDependency(owner.directory, name);
121
- if (dependency && await nativeReachable(dependency, visited)) return true;
126
+ if (dependency && await nativeReachable(dependency, visited)) {nativePresent.add(owner.directory);return true;}
122
127
  }
123
128
  return false;
124
129
  }
125
130
 
126
131
  function declaredNativeFor(owner) {
127
- if (!declaredNative.has(owner.directory)) declaredNative.set(owner.directory, nativeReachable(owner));
132
+ if (!declaredNative.has(owner.directory)) declaredNative.set(owner.directory, (async()=>{
133
+ const visited=new Set(),result=await nativeReachable(owner,visited);
134
+ // A recursive false inside a cycle proves nothing by itself. Only a
135
+ // completely native-free root closure can certify all its visited nodes.
136
+ if(!result)for(const directory of visited)nativeAbsent.add(directory);
137
+ return result;
138
+ })());
128
139
  return declaredNative.get(owner.directory);
129
140
  }
130
141
 
@@ -172,6 +183,7 @@ export function createNativeDiscovery({ projectRoot, isClientSource }) {
172
183
  }
173
184
 
174
185
  return {
186
+ stats(){return {declaredVisits,declaredProofHits,nativePresent:nativePresent.size,nativeAbsent:nativeAbsent.size};},
175
187
  setResolver(mode, resolve) { resolvers.set(mode, resolve); },
176
188
  canExternalizeDirectory,
177
189
  async externalOwner(file, mode) {
@@ -1,7 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { builtinModules, createRequire } from 'node:module';
4
- import {analyzeSource} from './source-analysis.mjs';
4
+ import {analyzeSourceSummary} from './source-analysis.mjs';
5
5
  import { createNativeDiscovery } from './native-discovery.mjs';
6
6
  import {frameworkImportPattern} from './framework-imports.mjs';
7
7
 
@@ -25,7 +25,7 @@ export function npmPackagesPlugin({ pages = false, projectRoot, transpilePackage
25
25
  sourceBytes += Buffer.byteLength(source);
26
26
  if (sourceBytes > 64 * 1024 * 1024) throw new Error('npm compilation analysis exceeded 64 MiB of source. Separate large runtime dependencies using serverExternalPackages.');
27
27
  if (Buffer.byteLength(source) > 8 * 1024 * 1024) throw new Error(`npm source ${file} exceeds the 8 MiB analysis limit. Configure serverExternalPackages for packages that do not use Next APIs.`);
28
- const {imports,computedRequire,frameworkBoundary}=analyzeSource(source,file);
28
+ const {imports,computedRequire,frameworkBoundary}=analyzeSourceSummary(source,file);
29
29
  return {imports,computedRequire,frameworkBoundary};
30
30
  })());
31
31
  return sources.get(file);
@@ -1,11 +1,64 @@
1
1
  import {parseSync} from 'oxc-parser';
2
2
  import {createHash} from 'node:crypto';
3
+ import parserPackage from 'oxc-parser/package' with {type:'json'};
4
+ import {cachedValidatedTransform} from './transform-cache.mjs';
3
5
 
4
6
  // Read-only analyses share one Rust parse. Transformations still use Babel's
5
7
  // binding-aware AST: ESTree nodes must never be passed to Babel transforms.
6
8
  const cache=new Map();
7
9
  let bytes=0,hits=0,misses=0;
8
- export const sourceAnalysisStats=()=>({entries:cache.size,bytes,hits,misses});
10
+ const summaries=new Map();
11
+ let summaryBytes=0,summaryHits=0,summaryMisses=0,summaryReplays=0;
12
+ const SUMMARY_BYTES=8*1024*1024,SUMMARY_ENTRIES=4096,SUMMARY_ITEM=128*1024;
13
+ export const sourceAnalysisStats=()=>({entries:cache.size,bytes,hits,misses,
14
+ summaries:{entries:summaries.size,bytes:summaryBytes,hits:summaryHits,misses:summaryMisses,replays:summaryReplays}});
15
+ const summaryFields=['directives','imports','computedRequire','hasServerDirective','runtimeExports','exportStars','frameworkBoundary'];
16
+ const text=value=>typeof value==='string'&&value.length<=32768;
17
+ const texts=value=>Array.isArray(value)&&value.length<=4096&&value.every(text);
18
+ function validSummary(value){
19
+ return value&&Object.getPrototypeOf(value)===Object.prototype&&Object.keys(value).length===summaryFields.length
20
+ &&summaryFields.every(name=>Object.hasOwn(value,name))&&texts(value.directives)&&texts(value.runtimeExports)&&texts(value.exportStars)
21
+ &&['computedRequire','hasServerDirective','frameworkBoundary'].every(name=>typeof value[name]==='boolean')
22
+ &&Array.isArray(value.imports)&&value.imports.length<=4096&&value.imports.every(item=>item&&Object.getPrototypeOf(item)===Object.prototype
23
+ &&Object.keys(item).length===2&&text(item.path)&&['import-statement','dynamic-import','require-call'].includes(item.kind));
24
+ }
25
+ function immutableSummary(value){
26
+ return Object.freeze({directives:Object.freeze([...value.directives]),imports:Object.freeze(value.imports.map(item=>Object.freeze({path:item.path,kind:item.kind}))),
27
+ computedRequire:value.computedRequire,hasServerDirective:value.hasServerDirective,runtimeExports:Object.freeze([...value.runtimeExports]),
28
+ exportStars:Object.freeze([...value.exportStars]),frameworkBoundary:value.frameworkBoundary});
29
+ }
30
+
31
+ /** Pure, versioned facts only: no AST, source, resolver, registry or generation. */
32
+ export function analyzeSourceSummary(source,file='source.js'){
33
+ const digest=createHash('sha256').update(source).digest('hex'),key=file+'\0'+digest;
34
+ const found=summaries.get(key);
35
+ if(found){summaries.delete(key);summaries.set(key,found);summaryHits++;return found.value;}
36
+ summaryMisses++;
37
+ let produced=false,producedAnalysis;
38
+ const validate=raw=>{
39
+ if(typeof raw!=='string'||Buffer.byteLength(raw)>SUMMARY_ITEM)return false;
40
+ const record=JSON.parse(raw);
41
+ return record&&Object.keys(record).length===5&&record.version===1&&record.parser===parserPackage.version
42
+ &&record.file===file&&record.digest===digest&&validSummary(record.analysis);
43
+ };
44
+ const produce=()=>{
45
+ produced=true;
46
+ const analysis=producedAnalysis=analyzeSource(source,file);
47
+ return JSON.stringify({version:1,parser:parserPackage.version,file,digest,analysis:Object.fromEntries(summaryFields.map(name=>[name,analysis[name]]))});
48
+ };
49
+ // Oversized summaries remain ordinary uncached parses, preserving the old
50
+ // accepted input surface without weakening bounded disk/memory retention.
51
+ let raw,value;
52
+ try{raw=cachedValidatedTransform('source-summary',source,[file,parserPackage.version,'v1'],validate,produce);value=JSON.parse(raw).analysis;}
53
+ catch(error){if(error?.message!=='Invalid typed compiler cache result')throw error;value=Object.fromEntries(summaryFields.map(name=>[name,producedAnalysis[name]]));}
54
+ if(!produced)summaryReplays++;
55
+ const immutable=immutableSummary(value),size=Buffer.byteLength(JSON.stringify(value))*2+Buffer.byteLength(key)*2;
56
+ if(size<=SUMMARY_ITEM){
57
+ while(summaries.size&&(summaries.size>=SUMMARY_ENTRIES||summaryBytes+size>SUMMARY_BYTES)){const[old,entry]=summaries.entries().next().value;summaries.delete(old);summaryBytes-=entry.size;}
58
+ summaries.set(key,{value:immutable,size});summaryBytes+=size;
59
+ }
60
+ return immutable;
61
+ }
9
62
  const identifier=(node,name)=>node?.type==='Identifier'&&(name===undefined||node.name===name);
10
63
  const property=node=>node?.computed?node.property?.value:node?.property?.name;
11
64
  const moduleExports=node=>node?.type==='MemberExpression'&&identifier(node.object,'module')&&property(node)==='exports';