@thomas.f/prnext 0.1.17-alpha → 0.1.18-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 (137) hide show
  1. package/README.md +26 -869
  2. package/build/actions.mjs +1 -1
  3. package/build/analyze.mjs +180 -24
  4. package/build/app-config.mjs +13 -0
  5. package/build/app-static.mjs +34 -12
  6. package/build/app-styles.mjs +79 -0
  7. package/build/app.mjs +81 -26
  8. package/build/bundle-runtime.mjs +22 -9
  9. package/build/cache-components.mjs +1 -1
  10. package/build/cache-handlers.mjs +1 -1
  11. package/build/compact-css.mjs +53 -0
  12. package/build/compilation-cache.mjs +269 -0
  13. package/build/compilation-store.mjs +142 -0
  14. package/build/compiler.mjs +31 -8
  15. package/build/config.mjs +30 -1
  16. package/build/create.mjs +239 -0
  17. package/build/css-modules.mjs +56 -12
  18. package/build/deployment-assets.mjs +89 -0
  19. package/build/deployment-config.mjs +18 -0
  20. package/build/dev-css-update.mjs +4 -2
  21. package/build/dev-dependencies.mjs +1 -1
  22. package/build/dev-discovery.mjs +3 -3
  23. package/build/dev.mjs +15 -8
  24. package/build/dynamic.mjs +1 -1
  25. package/build/edge.mjs +3 -3
  26. package/build/fonts.mjs +1 -1
  27. package/build/framework-imports.mjs +15 -5
  28. package/build/html-only-config.mjs +53 -0
  29. package/build/html-only-graph.mjs +67 -0
  30. package/build/index.mjs +59 -26
  31. package/build/instrumentation.mjs +30 -0
  32. package/build/metadata-routes.mjs +1 -1
  33. package/build/migration-audit.mjs +1 -1
  34. package/build/native-type-config.mjs +37 -0
  35. package/build/persistent-cache.mjs +18 -3
  36. package/build/precompress.mjs +1 -1
  37. package/build/prerender-cache.mjs +70 -0
  38. package/build/prerender-config.mjs +29 -0
  39. package/build/prerender-pages.mjs +19 -9
  40. package/build/prerender-pool.mjs +165 -0
  41. package/build/prerender-process-worker.mjs +58 -0
  42. package/build/prerender-worker.mjs +5 -5
  43. package/build/production-builder.mjs +58 -9
  44. package/build/react-compiler-report.mjs +225 -0
  45. package/build/react-compiler.mjs +139 -0
  46. package/build/runtime-contract.mjs +2 -2
  47. package/build/static-export.mjs +4 -4
  48. package/build/styles.mjs +6 -4
  49. package/build/transform.mjs +1 -1
  50. package/build/typed-routes.mjs +4 -0
  51. package/build/typescript-dev.mjs +35 -8
  52. package/build/typescript-native-worker.mjs +55 -0
  53. package/build/typescript-native.mjs +18 -0
  54. package/build/typescript-toolchain.mjs +19 -0
  55. package/build/typescript.mjs +6 -4
  56. package/build/webpack-graph.mjs +6 -3
  57. package/build/worker-memory.mjs +9 -0
  58. package/cli.mjs +60 -4
  59. package/compat/activity-cache.cjs +26 -0
  60. package/compat/after.cjs +6 -4
  61. package/compat/app-layout-context.cjs +52 -2
  62. package/compat/base64.cjs +10 -0
  63. package/compat/cache-handlers.cjs +12 -2
  64. package/compat/cache-wire.cjs +28 -0
  65. package/compat/cache.cjs +2 -1
  66. package/compat/cache.d.cts +4 -0
  67. package/compat/cookies.cjs +21 -5
  68. package/compat/data-cache.cjs +12 -8
  69. package/compat/flight-delta.cjs +150 -0
  70. package/compat/headers.cjs +4 -2
  71. package/compat/incremental-cache.cjs +20 -7
  72. package/compat/index.d.ts +68 -3
  73. package/compat/link.cjs +12 -9
  74. package/compat/navigation-authorization.cjs +9 -0
  75. package/compat/navigation-stages.cjs +19 -0
  76. package/compat/offline-store.cjs +11 -0
  77. package/compat/offline.cjs +5 -0
  78. package/compat/offline.d.cts +2 -0
  79. package/compat/redis-cache.cjs +201 -0
  80. package/compat/redis-cache.d.cts +37 -0
  81. package/compat/server-primitives.cjs +31 -11
  82. package/compat/server.d.cts +15 -2
  83. package/compat/static-generation.cjs +7 -0
  84. package/compat/use-cache.cjs +3 -0
  85. package/docs/ai/compatibility.md +171 -2
  86. package/docs/ai/data.md +165 -5
  87. package/docs/ai/deployment.md +158 -0
  88. package/docs/ai/quickstart.md +29 -4
  89. package/docs/ai/routing.md +82 -0
  90. package/docs/ai/troubleshooting.md +37 -2
  91. package/package.json +26 -7
  92. package/runtime/action-client.mjs +10 -4
  93. package/runtime/action-server.mjs +2 -0
  94. package/runtime/app-client.mjs +212 -65
  95. package/runtime/app-content.mjs +4 -3
  96. package/runtime/app-partial-client.mjs +3 -0
  97. package/runtime/app-partial.mjs +1 -1
  98. package/runtime/app-prefetch-constants.mjs +4 -0
  99. package/runtime/app-prefetch.mjs +2 -3
  100. package/runtime/app-render-key.mjs +5 -0
  101. package/runtime/app-render.mjs +88 -28
  102. package/runtime/app-routing.mjs +22 -9
  103. package/runtime/app-static.mjs +18 -8
  104. package/runtime/app-stream-html.mjs +10 -53
  105. package/runtime/app-style-selection.mjs +34 -0
  106. package/runtime/app-styles.mjs +154 -0
  107. package/runtime/cache-components-rsc.mjs +4 -1
  108. package/runtime/cache-request.mjs +2 -1
  109. package/runtime/deployment-client.mjs +19 -0
  110. package/runtime/dev-client.mjs +7 -2
  111. package/runtime/dev-compiler-broker.mjs +42 -0
  112. package/runtime/dev-indicator.mjs +8 -3
  113. package/runtime/dev-lazy-tool.mjs +16 -0
  114. package/runtime/development.mjs +29 -12
  115. package/runtime/document.mjs +1 -1
  116. package/runtime/edge.mjs +6 -5
  117. package/runtime/fetch-cache.mjs +10 -8
  118. package/runtime/flight-delta.mjs +50 -0
  119. package/runtime/flight-tap.mjs +28 -0
  120. package/runtime/html-only.mjs +27 -0
  121. package/runtime/http.mjs +2 -0
  122. package/runtime/incremental-cache.mjs +25 -4
  123. package/runtime/instrumentation.mjs +49 -0
  124. package/runtime/navigation-recovery.mjs +36 -0
  125. package/runtime/navigation.mjs +7 -0
  126. package/runtime/node-html-stream.mjs +47 -0
  127. package/runtime/pages-client.mjs +14 -7
  128. package/runtime/pages-navigation.mjs +48 -63
  129. package/runtime/prefetch-scheduler.mjs +110 -0
  130. package/runtime/profile.mjs +14 -0
  131. package/runtime/profiles.json +2 -2
  132. package/runtime/render.mjs +3 -0
  133. package/runtime/route-pattern.mjs +44 -0
  134. package/runtime/rsc-worker.mjs +34 -12
  135. package/runtime/trace.mjs +78 -0
  136. package/runtime/watch-project.mjs +19 -15
  137. package/runtime/worker.mjs +70 -26
package/build/actions.mjs CHANGED
@@ -44,7 +44,7 @@ export function createServerActions({ projectRoot }) {
44
44
  const id=createHmac('sha256',templateSalt).update(name).digest('hex');
45
45
  registrations.push({name,binding,id});return id;
46
46
  }
47
- const ast=parse(source,{sourceType:'unambiguous',sourceFilename:file,plugins:['jsx',...(/\.tsx?$/.test(file)?['typescript']:[])]});
47
+ const ast=parse(source,{sourceType:'unambiguous',sourceFilename:file,plugins:['jsx',...(/\.[cm]?tsx?$/.test(file)?['typescript']:[])]});
48
48
  const moduleServer=hasDirective(ast.program,'use server');
49
49
  const moduleClient=hasDirective(ast.program,'use client');
50
50
  if(moduleServer&&moduleClient) throw new Error(`A module cannot use both 'use client' and 'use server': ${file}`);
package/build/analyze.mjs CHANGED
@@ -5,12 +5,23 @@ import {gzip,brotliCompress,constants} from 'node:zlib';
5
5
  import {promisify} from 'node:util';
6
6
  import {mapConcurrent} from './concurrent.mjs';
7
7
  import {readBuildDirectory} from '../runtime/build-directory.mjs';
8
+ import {fileURLToPath} from 'node:url';
9
+
10
+ // These imports are awaited before App hydration. esbuild correctly describes
11
+ // them as dynamic imports, but they are not deferred by user interaction.
12
+ const appClient=fileURLToPath(new URL('../runtime/app-client.mjs',import.meta.url));
13
+ const hydrationImports=new Map([
14
+ ['react-server-dom-webpack/client.browser','always'],
15
+ ['./app-partial-client.mjs','cache-components'],
16
+ ]);
8
17
 
9
18
  export function recordBuildAnalysis(state,options,result){
10
19
  if(!state?.stage||!result.metafile)return;
11
20
  state.analysis ||= new Map();
12
21
  state.analysisRoutes ||= new Map();
13
22
  const outputs=new Map(Object.entries(result.metafile.outputs).map(([file,info])=>[path.resolve(options.absWorkingDir||state.root,file),info]));
23
+ const working=options.absWorkingDir||state.root;
24
+ const entries=new Map([...outputs].filter(([,info])=>info.entryPoint).map(([file,info])=>[path.resolve(working,info.entryPoint),file]));
14
25
  for(const [absolute,info] of outputs){
15
26
  const relative=path.relative(state.stage,absolute).replaceAll(path.sep,'/');
16
27
  if(!info.entryPoint||!relative.startsWith('server/'))continue;
@@ -27,15 +38,78 @@ export function recordBuildAnalysis(state,options,result){
27
38
  const absolute=path.resolve(options.absWorkingDir||state.root,file);
28
39
  const relative=path.relative(path.join(state.stage,'assets'),absolute).replaceAll(path.sep,'/');
29
40
  if(relative.startsWith('../')||path.isAbsolute(relative)||!relative.endsWith('.js'))continue;
30
- state.analysis.set(relative,{file:relative,bytes:info.bytes,imports:(info.imports||[]).filter(item=>!item.external).map(item=>({file:path.relative(path.join(state.stage,'assets'),path.resolve(options.absWorkingDir||state.root,item.path)).replaceAll(path.sep,'/'),kind:item.kind})),
41
+ const imports=(info.imports||[]).filter(item=>!item.external).map(item=>({file:path.relative(path.join(state.stage,'assets'),path.resolve(working,item.path)).replaceAll(path.sep,'/'),kind:item.kind}));
42
+ for(const name of Object.keys(info.inputs||{})){
43
+ const source=path.resolve(working,name);
44
+ for(const input of result.metafile.inputs[name]?.imports||[]){
45
+ if(input.kind!=='dynamic-import'||input.external)continue;
46
+ const target=entries.get(path.resolve(working,input.path));if(!target)continue;
47
+ const asset=relativeAsset(state.stage,target);
48
+ const edge=imports.find(item=>item.file===asset&&item.kind==='dynamic-import');
49
+ if(!edge)continue;
50
+ const id='dynamic-'+createHash('sha256').update(relativePath(state.root,source)+'\0'+input.original).digest('hex').slice(0,20);
51
+ (edge.dynamicIds||=[]).push(id);
52
+ if(source===appClient&&hydrationImports.has(input.original))edge.initial=hydrationImports.get(input.original);
53
+ else if(source===path.join(state.stage,'.entries','app-runtime.mjs'))edge.initial='flight-reference';
54
+ }
55
+ }
56
+ for(const edge of imports)if(edge.dynamicIds)edge.dynamicIds=[...new Set(edge.dynamicIds)].sort();
57
+ state.analysis.set(relative,{file:relative,bytes:info.bytes,imports,...(result.metafile.initialGraphComplete===false?{initialIncomplete:true}:{}),
31
58
  modules:Object.entries(info.inputs||{}).map(([name,record])=>({file:path.relative(state.root,path.resolve(options.absWorkingDir||state.root,name)).replaceAll(path.sep,'/').replace(/\.prnext-build-[^/]+\//g,'<generated>/'),bytes:record.bytesInOutput})).sort((a,b)=>b.bytes-a.bytes).slice(0,100)});
32
59
  }
33
60
  }
34
- function initialFiles(entry,files){
61
+ function relativePath(root,file){return path.relative(root,file).replaceAll(path.sep,'/');}
62
+ function relativeAsset(stage,file){return relativePath(path.join(stage,'assets'),file);}
63
+ function initialFiles(entry,files,cacheComponents=false){
35
64
  const seen=new Set(),pending=[entry];
36
- while(pending.length){const name=pending.pop();if(seen.has(name)||!files.has(name))continue;seen.add(name);for(const item of files.get(name).imports)if(item.kind!=='dynamic-import')pending.push(item.file);}
65
+ while(pending.length){const name=pending.pop();if(seen.has(name)||!files.has(name))continue;seen.add(name);for(const item of files.get(name).imports)if(item.kind!=='dynamic-import'||item.initial==='always'||item.initial==='cache-components'&&cacheComponents)pending.push(item.file);}
37
66
  return [...seen].sort();
38
67
  }
68
+
69
+ async function initialSample(stage,seed,route,manifest){
70
+ // These documents were validated before publication and intentionally have
71
+ // no Flight sidecar. Its absence is exact zero JS, not missing coverage.
72
+ if(route.htmlOnly)return {entries:[],ids:[]};
73
+ if(route.router==='app'){
74
+ if(!seed.dataFile)return null;
75
+ const data=await readBoundedText(path.join(stage,seed.dataFile));
76
+ if(data===null)return null;
77
+ const entries=[],rows=new Map(),imports=[];
78
+ // Read only official import rows; do not execute, decode or rewrite Flight.
79
+ for(const line of data.split('\n')){
80
+ // Flight also carries length-framed raw/binary rows. Their bytes may
81
+ // contain newlines that resemble import rows; do not parse such a stream
82
+ // as newline-delimited JSON or claim complete hydration coverage for it.
83
+ if(/^[\da-f]+:[TAOobUSsLlGgMmV]/.test(line))return null;
84
+ const row=/^([\da-f]+):(I?)(.*)$/.exec(line);if(!row)continue;
85
+ let value;try{value=JSON.parse(row[3]);}catch{if(row[2])return null;continue;}
86
+ if(row[2])imports.push(value);else if(typeof value==='string')rows.set(row[1],value);
87
+ }
88
+ for(const value of imports){
89
+ const reference=value?.[0];
90
+ const id=typeof reference==='string'&&/^\$[\da-f]+$/.test(reference)?rows.get(reference.slice(1)):reference;
91
+ const module=manifest.app?.clientModules?.[id]?.browserModule;
92
+ if(!module)return null;
93
+ entries.push(assetName(module));
94
+ }
95
+ return {entries,ids:[]};
96
+ }
97
+ const html=await readBoundedText(path.join(stage,seed.file));
98
+ if(html===null)return null;
99
+ const match=/window\.__PRNEXT_DATA__=JSON\.parse\(("(?:\\.|[^"\\])*")\);/.exec(html);
100
+ if(!match)return null;
101
+ try{const value=JSON.parse(JSON.parse(match[1]));return Array.isArray(value.dynamicIds)?{entries:[],ids:value.dynamicIds}:null;}catch{return null;}
102
+ }
103
+ async function readBoundedText(file){
104
+ const handle=await open(file,'r');try{
105
+ const info=await handle.stat(),size=info.size;if(!info.isFile()||size>16*1024*1024)return null;
106
+ const data=Buffer.alloc(size+1);let length=0;
107
+ while(length<data.length){const result=await handle.read(data,length,data.length-length,null);if(!result.bytesRead)break;length+=result.bytesRead;}
108
+ if(length>size)return null;
109
+ return data.subarray(0,length).toString('utf8');
110
+ }finally{await handle.close();}
111
+ }
112
+ function assetName(url){return decodeURIComponent(new URL(url,'http://local').pathname).split('/').pop();}
39
113
  const gzipAsync=promisify(gzip),brotliAsync=promisify(brotliCompress);
40
114
  export async function writeBuildAnalysis({root,stage,destination,manifest,records,routeInputs,precompressed=false}){
41
115
  const files=new Map(records||[]);
@@ -51,19 +125,53 @@ export async function writeBuildAnalysis({root,stage,destination,manifest,record
51
125
  info.gzipBytes=await size('.gz',()=>gzipAsync(data));
52
126
  info.brotliBytes=await size('.br',()=>brotliAsync(data,{params:{[constants.BROTLI_PARAM_QUALITY]:5}}));
53
127
  });
54
- const routes=manifest.routes.filter(route=>!route.internal&&route.kind==='page').map(route=>{
55
- const client=route.client?decodeURIComponent(new URL(route.client,'http://local').pathname).split('/').pop():null;
56
- const entries=client?[client]:[];
128
+ const dynamicEntries=new Map();
129
+ for(const info of files.values())for(const edge of info.imports)for(const id of edge.dynamicIds||[]){
130
+ if(!dynamicEntries.has(id))dynamicEntries.set(id,new Set());dynamicEntries.get(id).add(edge.file);
131
+ }
132
+ const seedsByRoute=new Map();
133
+ for(const seed of manifest.prerendered||[])if(!seed.status||seed.status<300){
134
+ if(!seedsByRoute.has(seed.routeId))seedsByRoute.set(seed.routeId,[]);seedsByRoute.get(seed.routeId).push(seed);
135
+ }
136
+ const routes=await mapConcurrent(manifest.routes.filter(route=>!route.internal&&route.kind==='page'),async route=>{
137
+ const client=route.client?assetName(route.client):null;
138
+ const baseEntries=client?[client]:[];
139
+ const initialAssets=entries=>[...new Set(entries.flatMap(entry=>initialFiles(entry,files,manifest.config?.cacheComponents)))].sort();
140
+ const costs=assets=>({jsBytes:assets.reduce((total,file)=>total+files.get(file).bytes,0),gzipBytes:assets.reduce((total,file)=>total+files.get(file).gzipBytes,0),brotliBytes:assets.reduce((total,file)=>total+files.get(file).brotliBytes,0)});
141
+ const potential=[];
57
142
  if(route.router==='app')for(const file of routeInputs?.get(route.module)||[]){
58
143
  const id='client-'+createHash('sha256').update(file).digest('hex').slice(0,16);
59
144
  const entry=manifest.app?.clientModules?.[id]?.browserModule;
60
- if(entry)entries.push(new URL(entry,'http://local').pathname.split('/').pop());
145
+ if(entry)potential.push(assetName(entry));
146
+ }
147
+ const seeds=seedsByRoute.get(route.id)||[];
148
+ const samples=[];let failedSample=false;
149
+ for(const seed of seeds){
150
+ const sample=await initialSample(stage,seed,route,manifest);
151
+ if(!sample){failedSample=true;continue;}
152
+ const entries=[...baseEntries,...sample.entries];
153
+ for(const id of sample.ids)entries.push(...dynamicEntries.get(id)||[]);
154
+ const assets=initialAssets(entries);
155
+ samples.push({path:seed.path,files:assets,...costs(assets)});
61
156
  }
62
- const assets=[...new Set(entries.flatMap(entry=>initialFiles(entry,files)))].sort();
63
- return {route:route.pattern,files:assets,jsBytes:assets.reduce((total,file)=>total+files.get(file).bytes,0),gzipBytes:assets.reduce((total,file)=>total+files.get(file).gzipBytes,0),brotliBytes:assets.reduce((total,file)=>total+files.get(file).brotliBytes,0)};
157
+ // A route graph cannot reveal which conditional components request data
158
+ // selects. Never turn its lower bound into a successful initial-JS budget.
159
+ const runtime=route.ssp||route.gip||route.appGip||route.ppr||route.pprGeneric||
160
+ route.pattern.includes('[')&&route.fallback!==false||seeds.some(seed=>typeof seed.revalidate==='number');
161
+ const baseFiles=[...new Set([...initialAssets(baseEntries),...samples.flatMap(sample=>sample.files)])].sort();
162
+ const hasDynamic=(samples.length?samples:[{files:baseFiles}]).some(sample=>{
163
+ const known=new Set(sample.files);
164
+ return sample.files.some(file=>files.get(file).imports.some(edge=>edge.kind==='dynamic-import'&&!edge.initial&&!known.has(edge.file)));
165
+ });
166
+ const unknown=!!runtime||failedSample||(!samples.length&&potential.length>0)||hasDynamic||baseFiles.some(file=>files.get(file).initialIncomplete);
167
+ const values=samples.length?samples:[costs(baseFiles)];
168
+ // Route files are a union for inspection. Each budget metric is the actual
169
+ // largest sampled path, never the sum of mutually exclusive path chunks.
170
+ return {route:route.pattern,files:baseFiles,initial:{status:unknown?'request-dependent':samples.length?'prerendered':'static-graph',samplePaths:samples.map(sample=>sample.path),samples},
171
+ ...Object.fromEntries(['jsBytes','gzipBytes','brotliBytes'].map(key=>[key,Math.max(...values.map(value=>value[key]))]))};
64
172
  });
65
- const report={version:1,buildId:manifest.buildId,routes,files:[...files.values()],totalJsBytes:[...files.values()].reduce((n,item)=>n+item.bytes,0)};
66
- try{const previous=await readReport(path.join(destination,'build-analysis.json'));report.previous={buildId:previous.buildId,totalJsBytes:previous.totalJsBytes,routes:previous.routes.map(({route,jsBytes,gzipBytes,brotliBytes})=>({route,jsBytes,gzipBytes,brotliBytes}))};}catch(error){if(error.code!=='ENOENT')console.warn('Previous bundle analysis could not be read; comparison unavailable.');}
173
+ const report={version:2,buildId:manifest.buildId,routes,files:[...files.values()],totalJsBytes:[...files.values()].reduce((n,item)=>n+item.bytes,0)};
174
+ try{const previous=await readReport(path.join(destination,'build-analysis.json'));if(previous.version===2)report.previous={version:2,buildId:previous.buildId,totalJsBytes:previous.totalJsBytes,routes:previous.routes.map(({route,initial,jsBytes,gzipBytes,brotliBytes})=>({route,initial,jsBytes,gzipBytes,brotliBytes}))};else report.comparisonUnavailable='Initial asset accounting changed; rebuild the baseline with this PRNext version.';}catch(error){if(error.code!=='ENOENT')console.warn('Previous bundle analysis could not be read; comparison unavailable.');}
67
175
  await writeFile(path.join(stage,'build-analysis.json'),JSON.stringify(report));
68
176
  return report;
69
177
  }
@@ -90,26 +198,67 @@ function validateRoutes(routes,{complete=true}={}){
90
198
  if(!object(route)||!text(route.route)||!route.route.startsWith('/')||seen.has(route.route)||!size(route.jsBytes))throw new Error('Invalid or duplicate route in bundle analysis.');
91
199
  seen.add(route.route);
92
200
  for(const key of ['gzipBytes','brotliBytes'])if((complete||route[key]!==undefined)&&!size(route[key]))throw new Error(`Invalid ${key} in bundle analysis.`);
201
+ if(route.initial!==undefined){
202
+ const initial=route.initial;
203
+ if(!object(initial)||!['prerendered','static-graph','request-dependent'].includes(initial.status)||!Array.isArray(initial.samplePaths)||initial.samplePaths.length>10000||initial.samplePaths.some(value=>!text(value))||!Array.isArray(initial.samples)||initial.samples.length>10000)throw new Error('Invalid initial asset coverage in bundle analysis.');
204
+ for(const sample of initial.samples)if(!object(sample)||!text(sample.path)||!Array.isArray(sample.files)||sample.files.length>100000||sample.files.some(file=>!text(file))||['jsBytes','gzipBytes','brotliBytes'].some(key=>!size(sample[key])))throw new Error('Invalid initial asset sample in bundle analysis.');
205
+ }
206
+ }
207
+ }
208
+ function validateInitialTotals(value){
209
+ const files=new Map();
210
+ for(const file of value.files){if(files.has(file.file))throw new Error('Duplicate asset in bundle analysis.');files.set(file.file,file);}
211
+ const fields={jsBytes:'bytes',gzipBytes:'gzipBytes',brotliBytes:'brotliBytes'};
212
+ const sum=(names,key)=>names.reduce((total,name)=>{
213
+ const file=files.get(name);if(!file)throw new Error('Unknown asset in initial bundle analysis.');
214
+ const result=total+file[fields[key]];if(!size(result))throw new Error('Initial asset totals exceed the supported range.');return result;
215
+ },0);
216
+ const unique=names=>new Set(names).size===names.length;
217
+ for(const route of value.routes){
218
+ if(!unique(route.files))throw new Error('Duplicate route asset in bundle analysis.');
219
+ const routeFiles=new Set(route.files);
220
+ const {initial}=route,samples=initial.samples;
221
+ if(!unique(initial.samplePaths)||initial.samplePaths.length!==samples.length||samples.some((sample,index)=>sample.path!==initial.samplePaths[index]))throw new Error('Initial sample paths do not match their measurements.');
222
+ if(initial.status==='prerendered'&&!samples.length||initial.status==='static-graph'&&samples.length)throw new Error('Invalid initial sample coverage status.');
223
+ for(const name of route.files)if(!files.has(name))throw new Error('Unknown route asset in bundle analysis.');
224
+ for(const sample of samples){
225
+ if(!unique(sample.files)||sample.files.some(file=>!routeFiles.has(file)))throw new Error('Invalid or duplicate sampled asset in bundle analysis.');
226
+ for(const key of Object.keys(fields))if(sum(sample.files,key)!==sample[key])throw new Error('Initial sample totals do not match their assets.');
227
+ }
228
+ const union=new Set(samples.flatMap(sample=>sample.files));
229
+ if(samples.length&&(union.size!==route.files.length||route.files.some(file=>!union.has(file))))throw new Error('Route asset union does not match its initial samples.');
230
+ for(const key of Object.keys(fields)){
231
+ const expected=samples.length?Math.max(...samples.map(sample=>sample[key])):sum(route.files,key);
232
+ if(expected!==route[key])throw new Error('Initial route totals do not match their samples.');
233
+ }
234
+ if(initial.status!=='request-dependent'&&route.files.some(file=>files.get(file).initialIncomplete))throw new Error('Incomplete metadata cannot have exact initial asset coverage.');
93
235
  }
94
236
  }
95
237
  async function readReport(file){
96
238
  const value=await boundedJson(file,'Bundle analysis');
97
- if(!object(value)||value.version!==1||!text(value.buildId)||!size(value.totalJsBytes)||!Array.isArray(value.files)||value.files.length>100000)throw new Error('Invalid bundle analysis; rebuild with this PRNext version.');
239
+ if(!object(value)||![1,2].includes(value.version)||!text(value.buildId)||!size(value.totalJsBytes)||!Array.isArray(value.files)||value.files.length>100000)throw new Error('Invalid bundle analysis; rebuild with this PRNext version.');
98
240
  validateRoutes(value.routes);
99
241
  for(const route of value.routes)if(!Array.isArray(route.files)||route.files.length>100000||route.files.some(file=>!text(file)))throw new Error('Invalid route asset list in bundle analysis.');
100
242
  for(const file of value.files){
101
- if(!object(file)||!text(file.file)||!size(file.bytes)||!size(file.gzipBytes)||!size(file.brotliBytes)||!Array.isArray(file.modules)||file.modules.length>100||file.modules.some(module=>!object(module)||!text(module.file)||!size(module.bytes))||!Array.isArray(file.imports)||file.imports.length>100000||file.imports.some(item=>!object(item)||!text(item.file)||!text(item.kind)))throw new Error('Invalid file, module or import in bundle analysis.');
243
+ if(!object(file)||!text(file.file)||!size(file.bytes)||!size(file.gzipBytes)||!size(file.brotliBytes)||file.initialIncomplete!==undefined&&typeof file.initialIncomplete!=='boolean'||!Array.isArray(file.modules)||file.modules.length>100||file.modules.some(module=>!object(module)||!text(module.file)||!size(module.bytes))||!Array.isArray(file.imports)||file.imports.length>100000||file.imports.some(item=>!object(item)||!text(item.file)||!text(item.kind)||item.initial!==undefined&&!['always','cache-components','flight-reference'].includes(item.initial)||item.dynamicIds!==undefined&&(!Array.isArray(item.dynamicIds)||item.dynamicIds.length>100000||item.dynamicIds.some(id=>!/^dynamic-[a-f\d]{20}$/.test(id)))))throw new Error('Invalid file, module or import in bundle analysis.');
102
244
  }
103
245
  if(value.previous!==undefined){
104
- if(!object(value.previous)||!text(value.previous.buildId)||value.previous.totalJsBytes!==undefined&&!size(value.previous.totalJsBytes))throw new Error('Invalid previous bundle analysis.');
246
+ if(!object(value.previous)||!text(value.previous.buildId)||value.previous.totalJsBytes!==undefined&&!size(value.previous.totalJsBytes)||value.version===2&&value.previous.version!==2)throw new Error('Invalid previous bundle analysis.');
105
247
  validateRoutes(value.previous.routes,{complete:false});
106
248
  }
249
+ if(value.version===2){
250
+ if(value.routes.some(route=>!route.initial)||value.previous?.routes.some(route=>!route.initial))throw new Error('Missing initial asset coverage in bundle analysis.');
251
+ validateInitialTotals(value);
252
+ }
107
253
  // Retain only the versioned schema, rather than echoing arbitrary nested
108
254
  // properties from a supplied baseline into a machine-readable report.
109
- const cleanRoute=({route,files,jsBytes,gzipBytes,brotliBytes})=>({route,files,jsBytes,gzipBytes,brotliBytes});
110
- return {version:1,buildId:value.buildId,totalJsBytes:value.totalJsBytes,routes:value.routes.map(cleanRoute),
111
- files:value.files.map(({file,bytes,gzipBytes,brotliBytes,imports,modules})=>({file,bytes,gzipBytes,brotliBytes,imports:imports.map(({file,kind})=>({file,kind})),modules:modules.map(({file,bytes})=>({file,bytes}))})),
112
- ...(value.previous?{previous:{buildId:value.previous.buildId,totalJsBytes:value.previous.totalJsBytes,routes:value.previous.routes.map(({route,jsBytes,gzipBytes,brotliBytes})=>({route,jsBytes,gzipBytes,brotliBytes}))}}:{})};
255
+ const cleanInitial=initial=>initial?{status:initial.status,samplePaths:[...initial.samplePaths],samples:initial.samples.map(({path,files,jsBytes,gzipBytes,brotliBytes})=>({path,files:[...files],jsBytes,gzipBytes,brotliBytes}))}:undefined;
256
+ const cleanRoute=({route,files,initial,jsBytes,gzipBytes,brotliBytes})=>({route,files,...(initial?{initial:cleanInitial(initial)}:{}),jsBytes,gzipBytes,brotliBytes});
257
+ if(value.version===2&&value.routes.some(route=>!route.initial)||value.previous&&value.version===2&&value.previous.routes.some(route=>!route.initial))throw new Error('Missing initial asset coverage in bundle analysis.');
258
+ return {version:value.version,buildId:value.buildId,totalJsBytes:value.totalJsBytes,routes:value.routes.map(cleanRoute),
259
+ files:value.files.map(({file,bytes,gzipBytes,brotliBytes,imports,modules,initialIncomplete})=>({file,bytes,gzipBytes,brotliBytes,...(initialIncomplete?{initialIncomplete:true}:{}),imports:imports.map(({file,kind,initial,dynamicIds})=>({file,kind,...(initial?{initial}:{}),...(dynamicIds?{dynamicIds:[...dynamicIds]}:{})})),modules:modules.map(({file,bytes})=>({file,bytes}))})),
260
+ ...(text(value.comparisonUnavailable)?{comparisonUnavailable:value.comparisonUnavailable}:{}),
261
+ ...(value.previous?{previous:{version:value.previous.version||1,buildId:value.previous.buildId,totalJsBytes:value.previous.totalJsBytes,routes:value.previous.routes.map(cleanRoute)}}:{})};
113
262
  }
114
263
  function nonnegative(value,name,{integer=false}={}){
115
264
  if(typeof value!=='number'||!Number.isFinite(value)||value<0||value>Number.MAX_SAFE_INTEGER||(integer&&!Number.isSafeInteger(value)))throw new Error(`${name} requires a finite nonnegative ${integer?'integer':'number'}.`);
@@ -153,22 +302,25 @@ export async function analyzeBuild(root,{maxJs,baseline,metric='raw',maxIncrease
153
302
  const routeNames=new Set(report.routes.map(route=>route.route));
154
303
  for(const route of Object.keys(limits))if(!routeNames.has(route))throw new Error(`Route budget does not match a current route: ${route}`);
155
304
  const reference=baseline?await readReport(path.resolve(root,baseline)):report.previous;
305
+ if(reference&&report.version!==(reference.version||1))throw new Error('Initial asset accounting changed; rebuild the baseline with this PRNext version before comparing.');
156
306
  const needsBaseline=maxIncreaseBytes!==undefined||maxIncreasePercent!==undefined||Object.values(limits).some(value=>value.maxIncreaseBytes!==undefined||value.maxIncreasePercent!==undefined);
157
307
  if(needsBaseline&&!reference)throw new Error('Regression budgets require --baseline or a previous build analysis.');
158
- const field=metrics[metric],previous=new Map((reference?.routes||[]).map(route=>[route.route,route[field]]));
308
+ const field=metrics[metric],previous=new Map((reference?.routes||[]).filter(route=>route.initial?.status!=='request-dependent').map(route=>[route.route,route[field]]));
159
309
  report.metric=metric;
160
310
  report.baseline=reference?{buildId:reference.buildId,source:baseline?'file':'previous-build'}:null;
161
311
  report.routes=report.routes.map(route=>{
162
312
  const budget={maxJs,maxIncreaseBytes,maxIncreasePercent,...(limits[route.route]||{})};
163
- const before=previous.get(route.route),current=route[field],hasBaseline=size(before),delta=hasBaseline?current-before:null;
313
+ const uncertain=route.initial?.status==='request-dependent';
314
+ const before=previous.get(route.route),current=route[field],hasBaseline=!uncertain&&size(before),delta=hasBaseline?current-before:null;
164
315
  const regressionBudget=budget.maxIncreaseBytes!==undefined||budget.maxIncreasePercent!==undefined;
165
316
  const exceeded=[];
317
+ if(uncertain&&Object.values(budget).some(value=>value!==undefined))exceeded.push('initial-unknown');
166
318
  if(budget.maxJs!==undefined&&current>budget.maxJs*1000)exceeded.push('maxJs');
167
319
  if(regressionBudget&&!hasBaseline)exceeded.push('baseline-missing');
168
320
  if(hasBaseline&&budget.maxIncreaseBytes!==undefined&&delta>budget.maxIncreaseBytes)exceeded.push('maxIncreaseBytes');
169
321
  if(hasBaseline&&budget.maxIncreasePercent!==undefined&&(before===0?delta>0:delta/before*100>budget.maxIncreasePercent))exceeded.push('maxIncreasePercent');
170
322
  return {...route,selectedBytes:current,baselineBytes:hasBaseline?before:null,deltaBytes:delta,deltaPercent:hasBaseline&&before>0?delta/before*100:hasBaseline&&current===0?0:null,
171
- comparison:hasBaseline?'compared':reference?'new-or-unavailable':'no-baseline',budget:{...budget,exceeded}};
323
+ comparison:uncertain?'initial-unknown':hasBaseline?'compared':reference?'new-or-unavailable':'no-baseline',budget:{...budget,exceeded}};
172
324
  }).sort((a,b)=>b.selectedBytes-a.selectedBytes||a.route.localeCompare(b.route));
173
325
  report.budget={metric,maxJsBytes:maxJs===undefined?null:maxJs*1000,maxIncreaseBytes:maxIncreaseBytes??null,maxIncreasePercent:maxIncreasePercent??null,exceeded:report.routes.filter(route=>route.budget.exceeded.length).map(route=>route.route)};
174
326
  report.ok=report.budget.exceeded.length===0;
@@ -176,9 +328,13 @@ export async function analyzeBuild(root,{maxJs,baseline,metric='raw',maxIncrease
176
328
  }
177
329
  export function printAnalysis(report){
178
330
  const kb=n=>(n/1000).toFixed(1)+' kB';
179
- console.log(`PRNext bundle analysis (${report.buildId})\nInitial browser JavaScript per route; shared files counted once per route. Dynamic imports are excluded. Budget metric: ${report.metric||'raw'}.`);
180
- for(const route of report.routes)console.log(`${route.route}: ${kb(route.jsBytes)} JS / ${kb(route.gzipBytes)} gzip / ${kb(route.brotliBytes)} br${route.deltaBytes===null?'':` (${route.deltaBytes>=0?'+':''}${kb(route.deltaBytes)} ${report.metric||'raw'} vs baseline${route.deltaPercent===null?'':`, ${route.deltaPercent.toFixed(1)}%`})`}${route.budget?.exceeded.length?` [${route.budget.exceeded.join(', ')}]`:''}`);
331
+ console.log(`PRNext bundle analysis (${report.buildId})\n${report.version===2?'Hydration JavaScript per route, including required runtime imports and prerendered dynamic modules. Each metric is the largest sampled path; route file lists are unions. Shared files counted once; deferred imports excluded. Request-dependent route maxima are lower bounds.':'Legacy static-import-only accounting; rebuild to include hydration dependencies.'} Budget metric: ${report.metric||'raw'}.`);
332
+ for(const route of report.routes)console.log(`${route.route}: ${route.initial?.status==='request-dependent'?'>= ':''}${kb(route.jsBytes)} JS / ${kb(route.gzipBytes)} gzip / ${kb(route.brotliBytes)} br${route.initial?` [${route.initial.status}]`:''}${route.deltaBytes===null?'':` (${route.deltaBytes>=0?'+':''}${kb(route.deltaBytes)} ${report.metric||'raw'} vs baseline${route.deltaPercent===null?'':`, ${route.deltaPercent.toFixed(1)}%`})`}${route.budget?.exceeded.length?` [${route.budget.exceeded.join(', ')}]`:''}`);
333
+ if(report.comparisonUnavailable)console.log(report.comparisonUnavailable);
181
334
  console.log('\nLargest browser modules (per output chunk):');
182
335
  for(const item of report.files.flatMap(chunk=>chunk.modules.map(module=>({...module,chunk:chunk.file}))).sort((a,b)=>b.bytes-a.bytes).slice(0,10))console.log(` ${kb(item.bytes)} ${item.file} [${item.chunk}]`);
183
- if(report.budget.exceeded.length)console.error(`JavaScript budget exceeded: ${report.budget.exceeded.join(', ')}`);
336
+ const unverified=report.routes.filter(route=>route.budget?.exceeded.some(reason=>reason==='initial-unknown'||reason==='baseline-missing')).map(route=>route.route);
337
+ const exceeded=report.routes.filter(route=>route.budget?.exceeded.some(reason=>!['initial-unknown','baseline-missing'].includes(reason))).map(route=>route.route);
338
+ if(exceeded.length)console.error(`JavaScript budget exceeded: ${exceeded.join(', ')}`);
339
+ if(unverified.length)console.error(`JavaScript budget not verified (initial coverage or baseline unavailable): ${unverified.join(', ')}`);
184
340
  }
@@ -14,6 +14,7 @@ const supported = new Map([
14
14
  ['revalidate', [false]],
15
15
  ['dynamicParams', [true, false]],
16
16
  ['instant', [true, false]],
17
+ ['ensureStatic', ['auto', 'shell', 'prefetch', 'navigation', false]],
17
18
  ['fetchCache', ['auto', 'default-cache', 'only-cache', 'force-cache', 'force-no-store', 'default-no-store', 'only-no-store']],
18
19
  ['preferredRegion', ['auto']],
19
20
  ['maxDuration', []],
@@ -69,6 +70,7 @@ export async function validateAppConfig(file) {
69
70
  if (!supported.has(item.name)) continue;
70
71
  const value = locals.get(item.local);
71
72
  const allowed = supported.get(item.name);
73
+ if (item.name === 'ensureStatic' && analysis.directives.includes('use client')) throw new Error(`ensureStatic cannot be exported by a Client Component (${file}).`);
72
74
  if (item.name === 'instant') {
73
75
  if (analysis.directives.includes('use client')) throw new Error(`instant cannot be exported by a Client Component (${file}).`);
74
76
  if (value && typeof value === 'object' && Object.entries(value).every(([name, value]) => name === 'unstable_samples' ? validSamples(value) : name === 'level' ? ['warning', 'experimental-error'].includes(value) : ['unstable_disableValidation', 'unstable_disableDevValidation', 'unstable_disableBuildValidation'].includes(name) && value === true)) { config.instant = value; continue; }
@@ -104,7 +106,16 @@ export function mergeAppConfig(configs) {
104
106
  throw new Error('Incompatible App Router fetchCache guarantees across route segments.');
105
107
  }
106
108
  let forceDynamic = false;
109
+ const levels = ['auto', 'shell', 'prefetch', 'navigation'];
110
+ let staticLevel = 0;
111
+ const staticDisabled = configs.some(config => config.ensureStatic === false);
107
112
  for (const config of configs) {
113
+ if (config.ensureStatic !== undefined && config.ensureStatic !== 'auto') {
114
+ const level = levels.indexOf(config.ensureStatic);
115
+ if (level > 0 && staticDisabled) throw new Error('ensureStatic: false cannot be combined with a static requirement across route segments.');
116
+ if (level > 0 && level < staticLevel) throw new Error('A child ensureStatic cannot weaken its parent requirement.');
117
+ staticLevel = Math.max(staticLevel, level);
118
+ }
108
119
  if (config.maxDuration !== undefined) merged.maxDuration = config.maxDuration;
109
120
  if (config.runtime === 'edge') merged.runtime = 'edge';
110
121
  else if (config.runtime === 'nodejs') delete merged.runtime;
@@ -118,6 +129,8 @@ export function mergeAppConfig(configs) {
118
129
  if (config.fetchCache !== undefined) merged.fetchCache = config.fetchCache;
119
130
  if (config.fetchCache === 'force-no-store') merged.forceNoStore = true;
120
131
  }
132
+ if (staticDisabled) merged.ensureStatic = false;
133
+ else if (staticLevel) merged.ensureStatic = levels[staticLevel];
121
134
  if (forceDynamic) merged.dynamic = 'force-dynamic';
122
135
  if (policies.has('force-cache')) merged.fetchCache = 'force-cache';
123
136
  else if (!merged.forceNoStore && !forceDynamic) {
@@ -4,6 +4,8 @@ import path from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
5
5
  import { staticPath } from '../runtime/pages-paths.mjs';
6
6
  import {mapConcurrent} from './concurrent.mjs';
7
+ import {mapPrerender,prerenderPath} from './prerender-pool.mjs';
8
+ import {validateHtmlOnlyPrerender} from './html-only-config.mjs';
7
9
 
8
10
  const MAX_STATIC_PATHS = 10_000;
9
11
  const MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
@@ -50,7 +52,8 @@ export async function prerenderApp(stage, manifest, dev) {
50
52
  const handlers = routes.some(route => route.kind === 'api')
51
53
  ? await import(pathToFileURL(path.join(stage, 'runtime/route-static.mjs')).href) : null;
52
54
  const initialSeeds=[...manifest.prerendered];
53
- await mapConcurrent(routes,async route=>{
55
+ const jobs=[],seeds=new Map(),partialRoutes=new Set();
56
+ await mapConcurrent(routes,async(route,routeIndex)=>{
54
57
  const handler = route.kind === 'api';
55
58
  const options = { modulePath: path.join(stage, route.module), distDir: stage, manifest, route, production: true };
56
59
  // This fixed internal entry shares the root layout, but its parameter
@@ -61,7 +64,14 @@ export async function prerenderApp(stage, manifest, dev) {
61
64
  if (handler) route.handlerMethods = inspected.methods;
62
65
  const dynamic = route.pattern.includes('[');
63
66
  const entries = appStaticEntries(route.pattern, dynamic ? inspected.params : [{}]);
67
+ if(route.htmlOnly && dynamic && (!inspected.generated || !entries.length || inspected.params.some(params=>appStaticEntries(route.pattern,[params]).length!==1))) {
68
+ throw new Error(`htmlOnly route ${route.pattern} requires nonempty complete generateStaticParams results.`);
69
+ }
64
70
  const config = route.cacheConfig;
71
+ if (config.ensureStatic === 'navigation') {
72
+ if (config.runtime === 'edge' || config.dynamic === 'force-dynamic' || config.revalidate === 0 || config.forceNoStore) throw new Error(`Route ${route.pattern}: ensureStatic='navigation' conflicts with request-time rendering configuration.`);
73
+ if (dynamic && (!inspected.generated || !entries.length || inspected.params.some(params => appStaticEntries(route.pattern, [params]).length !== 1))) throw new Error(`Route ${route.pattern}: ensureStatic='navigation' requires generateStaticParams() with complete, nonempty parameter sets.`);
74
+ }
65
75
  // Keep generated-path restrictions and method metadata for Edge routes,
66
76
  // while separating dynamic rendering from the persistent Data Cache.
67
77
  if (config.runtime === 'edge') {
@@ -69,6 +79,7 @@ export async function prerenderApp(stage, manifest, dev) {
69
79
  return;
70
80
  }
71
81
  const partial = !handler && manifest.config?.cacheComponents === true && (!config.dynamic || config.dynamic === 'auto');
82
+ if(partial)partialRoutes.add(route);
72
83
  if (partial && dynamic && !inspected.generated) entries.length = 0;
73
84
  if (partial && dynamic && inspected.generated && inspected.params.length === 0) {
74
85
  throw new Error(`generateStaticParams for ${route.pattern} must return at least one parameter object when cacheComponents is enabled.`);
@@ -109,10 +120,12 @@ export async function prerenderApp(stage, manifest, dev) {
109
120
  }
110
121
  route.ssg = true;
111
122
  route.fallback = config.dynamicParams === false ? false : 'blocking';
112
- for (const entry of entries) {
123
+ for (const [entryIndex,entry] of entries.entries()) {
113
124
  if (occupied.has(entry.path)) throw new Error(`Multiple pages prerender the same path: ${entry.path}.`);
114
125
  occupied.add(entry.path);
115
- const rendered = await (handler ? handlers.prerenderAppRouteHandler : pages.prerenderAppRoute)({ ...options, path: entry.path, params: entry.params });
126
+ jobs.push({route,routeIndex,entryIndex,path:entry.path,run:async()=>{
127
+ const rendered = await prerenderPath(handler?'handler':'app',{ ...options, path: entry.path, params: entry.params },handler?handlers.prerenderAppRouteHandler:pages.prerenderAppRoute);
128
+ if(route.htmlOnly)validateHtmlOnlyPrerender(route,rendered);
116
129
  if (rendered.partial) {
117
130
  const file = `static/partial-${createHash('sha256').update(entry.path).digest('hex').slice(0, 20)}.json`;
118
131
  await writeFile(path.join(stage, file), JSON.stringify({ ...rendered.partial, ...rendered.metadata }));
@@ -120,7 +133,7 @@ export async function prerenderApp(stage, manifest, dev) {
120
133
  if (!dynamic) { delete route.ssg; delete route.fallback; }
121
134
  else (route.dynamicPaths ||= []).push(entry.path);
122
135
  occupied.add(entry.path);
123
- continue;
136
+ return;
124
137
  }
125
138
  if (rendered.dynamic) {
126
139
  route.dynamicReasons = [...new Set([...(route.dynamicReasons || []), rendered.reason || 'request-time data'])];
@@ -132,34 +145,43 @@ export async function prerenderApp(stage, manifest, dev) {
132
145
  // successful seeds survive, while ungenerated paths stay dynamic.
133
146
  if (handler && config.dynamicParams !== false) route.fallback = 'dynamic';
134
147
  }
135
- continue;
148
+ return;
136
149
  }
137
150
  const body = Buffer.isBuffer(rendered.body) ? rendered.body : Buffer.from(rendered.body);
138
- const flight = handler ? null : Buffer.isBuffer(rendered.flight) ? rendered.flight : Buffer.from(rendered.flight);
151
+ const flight = handler || route.htmlOnly ? null : Buffer.isBuffer(rendered.flight) ? rendered.flight : Buffer.from(rendered.flight);
139
152
  if (body.byteLength > MAX_RESPONSE_BYTES || (flight && flight.byteLength > MAX_RESPONSE_BYTES)) throw new Error(`Static App route ${entry.path} exceeds the 16 MiB response limit.`);
140
153
  const stem = `static/${handler ? 'route' : 'app'}-${createHash('sha256').update(entry.path).digest('hex').slice(0, 20)}`;
141
154
  const file = `${stem}.${handler ? 'body' : 'html'}`;
142
155
  // Flight is opaque text, not JSON; the native cache supplies text/x-component.
143
- const dataFile = handler ? undefined : `${stem}.txt`;
156
+ const dataFile = handler || route.htmlOnly ? undefined : `${stem}.txt`;
144
157
  await writeFile(path.join(stage, file), body);
145
158
  if (dataFile) await writeFile(path.join(stage, dataFile), flight);
146
159
  occupied.add(entry.path);
147
- manifest.prerendered.push({ routeId: route.id, path: entry.path, file, ...(dataFile ? { dataFile } : {}), status: rendered.status ?? 200,
160
+ seeds.set(entry.path,{ routeId: route.id, path: entry.path, file, ...(dataFile ? { dataFile } : {}), status: rendered.status ?? 200,
148
161
  headers: rendered.headers || {}, revalidate: rendered.revalidate ?? config.revalidate,
149
162
  generatedAt: rendered.generatedAt, tags: rendered.tags || [], paths: rendered.paths || [],
150
163
  });
164
+ }});
151
165
  }
166
+ });
167
+ jobs.sort((a,b)=>a.routeIndex-b.routeIndex||a.entryIndex-b.entryIndex);
168
+ await mapPrerender(jobs,job=>job.run());
169
+ manifest.prerendered=[...initialSeeds,...jobs.flatMap(job=>seeds.has(job.path)?[seeds.get(job.path)]:[])];
170
+ for(const route of routes){
171
+ const routeJobs=jobs.filter(job=>job.route===route);
172
+ if(route.dynamicPaths)route.dynamicPaths=routeJobs.filter(job=>route.dynamicPaths.includes(job.path)).map(job=>job.path);
173
+ if(route.ppr)route.ppr=Object.fromEntries(routeJobs.filter(job=>Object.hasOwn(route.ppr,job.path)).map(job=>[job.path,route.ppr[job.path]]));
174
+ if(route.dynamicReasons)route.dynamicReasons=[...new Set(route.dynamicReasons)].sort();
152
175
  // Unseen Cache Components paths are generated by the PPR runtime, which
153
176
  // handles either complete HTML/Flight or a resumable shell atomically.
154
- if (partial && dynamic) {
177
+ if (partialRoutes.has(route) && route.pattern.includes('[')) {
155
178
  if (manifest.prerendered.some(page => page.routeId === route.id)) {
156
179
  // Concrete complete seeds use the native invalidation-aware cache.
157
180
  // Other paths still reach the PPR runtime, including dynamic holes.
158
181
  route.ssg = true; route.fallback = 'ppr';
159
182
  } else { delete route.ssg; delete route.fallback; }
160
183
  }
161
- });
162
- const appSeeds=manifest.prerendered.slice(initialSeeds.length);
163
- manifest.prerendered=[...initialSeeds,...routes.flatMap(route=>appSeeds.filter(seed=>seed.routeId===route.id))];
184
+ if(route.htmlOnly)route.fallback=false;
185
+ }
164
186
  } finally { await closeAppRuntime(); }
165
187
  }
@@ -0,0 +1,79 @@
1
+ import path from 'node:path';
2
+
3
+ const stylesheet = /\.(?:css|scss|sass)$/i;
4
+ const conventions = ['layout', 'template', 'loading', 'error', 'notFound', 'not-found', 'default', 'page'];
5
+ // Native esbuild identifies virtual inputs with absolute paths. The webpack
6
+ // bridge uses the same namespaces with project-relative paths instead. Only
7
+ // strip framework namespaces here; a Windows drive letter is part of the path.
8
+ const styleNamespace = /^prnext-(?:css-mapping|compiled-css|app-empty|edge-empty):/;
9
+
10
+ /** Join RSC references with their real client dependency graphs, without
11
+ * flattening CSS @import/composes dependencies into extra stylesheets. */
12
+ export function createAppStyleGraph(root) {
13
+ const graph = new Map();
14
+ const file = name => path.resolve(root, name.replace(styleNamespace, ''));
15
+ return {
16
+ alias(source, target) {
17
+ if (file(source) !== file(target)) graph.set(file(source), new Set([file(target)]));
18
+ },
19
+ record(metafile) {
20
+ for (const [name, input] of Object.entries(metafile?.inputs || {})) {
21
+ const key = file(name), dependencies = graph.get(key) || new Set();
22
+ for (const dependency of input.imports || []) if (!dependency.external) dependencies.add(file(dependency.path));
23
+ graph.set(key, dependencies);
24
+ }
25
+ },
26
+ collect(roots) {
27
+ const seen = new Set(), styles = new Set();
28
+ const pending = [...roots].reverse();
29
+ while (pending.length) {
30
+ const key = file(pending.pop());
31
+ if (seen.has(key)) continue;
32
+ seen.add(key);
33
+ if (stylesheet.test(key)) { styles.add(key); continue; }
34
+ const dependencies = [...graph.get(key) || []];
35
+ for (let index = dependencies.length - 1; index >= 0; index--) pending.push(dependencies[index]);
36
+ }
37
+ return [...styles];
38
+ },
39
+ };
40
+ }
41
+
42
+ /** Layout scopes precede their descendants. Shared scopes produce the same
43
+ * content-addressed sheet on every route, while a page never owns a sibling's
44
+ * styles. A pruned parallel tree also includes its loading/error/default and
45
+ * intercepted branches so a retained slot cannot lose its stylesheet. */
46
+ export function appStyleEntries(route) {
47
+ const scopes = [];
48
+ function add(files, path) {
49
+ const roots = conventions.filter(name => name !== 'page' && name !== 'default').map(name => files?.[name]).filter(file => typeof file === 'string');
50
+ if (roots.length) scopes.push({ path, kind:'layout', roots });
51
+ for (const kind of ['default', 'page']) if (typeof files?.[kind] === 'string') scopes.push({ path, kind, roots:[files[kind]] });
52
+ }
53
+ if (route.routing) {
54
+ function visit(node) {
55
+ add(node.files, node.path || '');
56
+ for (const child of node.children || []) visit(child);
57
+ for (const name of Object.keys(node.slots || {}).sort()) visit(node.slots[name]);
58
+ }
59
+ visit(route.routing);
60
+ } else {
61
+ for (const segment of route.segments || []) add(segment, segment.path || '');
62
+ if (route.file) add({ page: route.file }, '');
63
+ }
64
+ return scopes;
65
+ }
66
+
67
+ export const appStyleScopes = route => appStyleEntries(route).map(scope => scope.roots);
68
+
69
+ export function planAppStyleEntries(route, graph) {
70
+ const included = new Set(), groups = [];
71
+ for (const scope of appStyleEntries(route)) {
72
+ const styles = graph.collect(scope.roots).filter(file => !included.has(file));
73
+ for (const file of styles) included.add(file);
74
+ if (styles.length) groups.push({ ...scope, styles });
75
+ }
76
+ return groups;
77
+ }
78
+
79
+ export const planAppStyles = (route, graph) => planAppStyleEntries(route, graph).map(scope => scope.styles);