@thomas.f/prnext 0.1.16-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 (215) hide show
  1. package/AI.md +63 -0
  2. package/README.md +35 -503
  3. package/build/ADAPTERS.md +27 -2
  4. package/build/actions.mjs +6 -1
  5. package/build/analyze.mjs +340 -0
  6. package/build/app-config.mjs +20 -8
  7. package/build/app-static.mjs +41 -14
  8. package/build/app-styles.mjs +79 -0
  9. package/build/app.mjs +99 -31
  10. package/build/build-lock.mjs +6 -6
  11. package/build/bundle-runtime.mjs +39 -11
  12. package/build/cache-components.mjs +1 -1
  13. package/build/cache-handlers.mjs +1 -1
  14. package/build/check.mjs +56 -11
  15. package/build/compact-css.mjs +53 -0
  16. package/build/compilation-cache.mjs +269 -0
  17. package/build/compilation-store.mjs +142 -0
  18. package/build/compiler.mjs +51 -4
  19. package/build/concurrent.mjs +12 -0
  20. package/build/config.mjs +50 -13
  21. package/build/create.mjs +239 -0
  22. package/build/css-modules.mjs +65 -10
  23. package/build/deployment-assets.mjs +89 -0
  24. package/build/deployment-config.mjs +18 -0
  25. package/build/dev-compiler-health.mjs +11 -0
  26. package/build/dev-css-update.mjs +85 -0
  27. package/build/dev-dependencies.mjs +44 -0
  28. package/build/dev-discovery.mjs +28 -0
  29. package/build/dev-worker.mjs +14 -5
  30. package/build/dev.mjs +64 -6
  31. package/build/dynamic.mjs +1 -1
  32. package/build/edge.mjs +5 -4
  33. package/build/fonts.mjs +1 -1
  34. package/build/framework-imports.mjs +50 -0
  35. package/build/html-only-config.mjs +53 -0
  36. package/build/html-only-graph.mjs +67 -0
  37. package/build/incremental.mjs +8 -4
  38. package/build/index.mjs +140 -112
  39. package/build/inspect.mjs +3 -0
  40. package/build/inspection-summary.mjs +37 -4
  41. package/build/instrumentation.mjs +30 -0
  42. package/build/loader-cache.mjs +53 -26
  43. package/build/metadata-routes.mjs +3 -4
  44. package/build/migrate.mjs +60 -57
  45. package/build/migration-audit.mjs +1 -1
  46. package/build/native-type-config.mjs +37 -0
  47. package/build/npm-packages.mjs +3 -18
  48. package/build/package-manager.mjs +308 -0
  49. package/build/persistent-cache.mjs +112 -0
  50. package/build/precompress.mjs +62 -30
  51. package/build/prerender-cache.mjs +70 -0
  52. package/build/prerender-config.mjs +29 -0
  53. package/build/prerender-pages.mjs +100 -0
  54. package/build/prerender-pool.mjs +165 -0
  55. package/build/prerender-process-worker.mjs +58 -0
  56. package/build/prerender-worker.mjs +10 -0
  57. package/build/production-builder.mjs +129 -0
  58. package/build/react-compiler-report.mjs +225 -0
  59. package/build/react-compiler.mjs +139 -0
  60. package/build/runtime-contract.mjs +2 -2
  61. package/build/scan.mjs +4 -4
  62. package/build/source-analysis.mjs +42 -0
  63. package/build/static-adapter.mjs +13 -5
  64. package/build/static-export.mjs +4 -4
  65. package/build/static-input.mjs +48 -0
  66. package/build/styles.mjs +17 -3
  67. package/build/transform-cache.mjs +37 -4
  68. package/build/transform.mjs +1 -1
  69. package/build/type-command.mjs +41 -0
  70. package/build/typed-routes.mjs +95 -0
  71. package/build/typescript-dev-worker.mjs +18 -0
  72. package/build/typescript-dev.mjs +49 -0
  73. package/build/typescript-native-worker.mjs +55 -0
  74. package/build/typescript-native.mjs +18 -0
  75. package/build/typescript-toolchain.mjs +19 -0
  76. package/build/typescript-watch.mjs +22 -0
  77. package/build/typescript.mjs +11 -8
  78. package/build/webpack-graph.mjs +170 -30
  79. package/build/worker-memory.mjs +9 -0
  80. package/build/worker-preload.mjs +9 -0
  81. package/cli.mjs +162 -122
  82. package/compat/activity-cache.cjs +26 -0
  83. package/compat/after.cjs +6 -4
  84. package/compat/app-layout-context.cjs +52 -2
  85. package/compat/base64.cjs +10 -0
  86. package/compat/cache-handlers.cjs +12 -2
  87. package/compat/cache-wire.cjs +28 -0
  88. package/compat/cache.cjs +2 -1
  89. package/compat/cache.d.cts +4 -0
  90. package/compat/cookies.cjs +21 -5
  91. package/compat/data-cache.cjs +12 -8
  92. package/compat/flight-delta.cjs +150 -0
  93. package/compat/headers.cjs +4 -2
  94. package/compat/incremental-cache.cjs +20 -7
  95. package/compat/index.d.ts +89 -10
  96. package/compat/link.cjs +12 -9
  97. package/compat/link.d.cts +4 -3
  98. package/compat/navigation-authorization.cjs +9 -0
  99. package/compat/navigation-stages.cjs +19 -0
  100. package/compat/navigation.d.cts +4 -3
  101. package/compat/offline-store.cjs +11 -0
  102. package/compat/offline.cjs +5 -0
  103. package/compat/offline.d.cts +2 -0
  104. package/compat/redis-cache.cjs +201 -0
  105. package/compat/redis-cache.d.cts +37 -0
  106. package/compat/router.d.cts +4 -3
  107. package/compat/server-primitives.cjs +31 -11
  108. package/compat/server.d.cts +15 -2
  109. package/compat/static-generation.cjs +7 -0
  110. package/compat/typed-route.d.cts +17 -0
  111. package/compat/use-cache.cjs +3 -0
  112. package/docs/ai/compatibility.md +275 -0
  113. package/docs/ai/data.md +288 -0
  114. package/docs/ai/deployment.md +269 -0
  115. package/docs/ai/manifest.json +11 -0
  116. package/docs/ai/quickstart.md +171 -0
  117. package/docs/ai/routing.md +214 -0
  118. package/docs/ai/troubleshooting.md +155 -0
  119. package/index.mjs +2 -0
  120. package/native/diagnostics.mjs +34 -0
  121. package/native/resolve.mjs +7 -2
  122. package/package.json +44 -7
  123. package/release/ai-docs.mjs +48 -0
  124. package/release/verify.mjs +2 -0
  125. package/runtime/action-client.mjs +10 -4
  126. package/runtime/action-server.mjs +2 -0
  127. package/runtime/ai-docs.mjs +202 -0
  128. package/runtime/app-client.mjs +226 -68
  129. package/runtime/app-content.mjs +4 -3
  130. package/runtime/app-partial-client.mjs +3 -0
  131. package/runtime/app-partial.mjs +1 -1
  132. package/runtime/app-prefetch-constants.mjs +4 -0
  133. package/runtime/app-prefetch.mjs +2 -3
  134. package/runtime/app-render-key.mjs +5 -0
  135. package/runtime/app-render.mjs +100 -28
  136. package/runtime/app-routing.mjs +22 -9
  137. package/runtime/app-static.mjs +18 -8
  138. package/runtime/app-stream-html.mjs +10 -53
  139. package/runtime/app-style-selection.mjs +34 -0
  140. package/runtime/app-styles.mjs +154 -0
  141. package/runtime/cache-components-rsc.mjs +4 -1
  142. package/runtime/cache-request.mjs +2 -1
  143. package/runtime/cloud/archive.mjs +5 -6
  144. package/runtime/compile-cache.mjs +8 -0
  145. package/runtime/deployment-client.mjs +19 -0
  146. package/runtime/dev-ai-brands.mjs +26 -0
  147. package/runtime/dev-ai-style.mjs +99 -0
  148. package/runtime/dev-build-reasons.mjs +25 -0
  149. package/runtime/dev-cache-control.mjs +53 -0
  150. package/runtime/dev-client.mjs +245 -46
  151. package/runtime/dev-compiler-broker.mjs +42 -0
  152. package/runtime/dev-console-copy.mjs +19 -0
  153. package/runtime/dev-console.mjs +106 -0
  154. package/runtime/dev-dependency-graph.mjs +40 -0
  155. package/runtime/dev-element-picker-style.mjs +27 -0
  156. package/runtime/dev-element-picker.mjs +213 -0
  157. package/runtime/dev-error-overlay-style.mjs +41 -0
  158. package/runtime/dev-error-overlay.mjs +167 -0
  159. package/runtime/dev-error-report.mjs +121 -0
  160. package/runtime/dev-file-changes.mjs +1 -1
  161. package/runtime/dev-gateway.mjs +44 -0
  162. package/runtime/dev-host-relay.mjs +66 -0
  163. package/runtime/dev-indicator.mjs +191 -0
  164. package/runtime/dev-issue-motion.mjs +214 -0
  165. package/runtime/dev-lazy-tool.mjs +34 -0
  166. package/runtime/dev-logo.mjs +2 -0
  167. package/runtime/dev-notification.mjs +81 -0
  168. package/runtime/dev-popout.mjs +34 -0
  169. package/runtime/dev-position.mjs +107 -0
  170. package/runtime/dev-preview-touch.mjs +122 -0
  171. package/runtime/dev-runtime-pool.mjs +45 -0
  172. package/runtime/dev-runtime-spare.mjs +57 -0
  173. package/runtime/dev-tools-client.mjs +155 -0
  174. package/runtime/dev-tools-mcp.mjs +126 -0
  175. package/runtime/dev-tools-metrics.mjs +31 -0
  176. package/runtime/dev-tools-model.mjs +42 -0
  177. package/runtime/dev-tools-panel.mjs +341 -0
  178. package/runtime/dev-tools-result.mjs +37 -0
  179. package/runtime/dev-tools-server.mjs +256 -0
  180. package/runtime/dev-tools-source.mjs +104 -0
  181. package/runtime/dev-tooltip.mjs +94 -0
  182. package/runtime/dev-ui-icons.mjs +8 -0
  183. package/runtime/dev-workbench-style.mjs +106 -0
  184. package/runtime/dev-workbench.mjs +428 -0
  185. package/runtime/dev-worker-logs.mjs +31 -0
  186. package/runtime/development.mjs +324 -0
  187. package/runtime/document.mjs +1 -1
  188. package/runtime/edge.mjs +6 -5
  189. package/runtime/env.mjs +2 -1
  190. package/runtime/fetch-cache.mjs +10 -8
  191. package/runtime/flight-delta.mjs +50 -0
  192. package/runtime/flight-tap.mjs +28 -0
  193. package/runtime/html-only.mjs +27 -0
  194. package/runtime/http.mjs +2 -0
  195. package/runtime/incremental-cache.mjs +25 -4
  196. package/runtime/instrumentation.mjs +49 -0
  197. package/runtime/navigation-recovery.mjs +36 -0
  198. package/runtime/navigation.mjs +7 -0
  199. package/runtime/node-html-stream.mjs +47 -0
  200. package/runtime/pages-client.mjs +24 -8
  201. package/runtime/pages-navigation.mjs +54 -64
  202. package/runtime/persistent/client.mjs +6 -5
  203. package/runtime/persistent/daemon.mjs +66 -56
  204. package/runtime/persistent/native-proxy.mjs +63 -0
  205. package/runtime/prefetch-scheduler.mjs +110 -0
  206. package/runtime/preload.mjs +12 -0
  207. package/runtime/profile.mjs +14 -0
  208. package/runtime/profiles.json +2 -2
  209. package/runtime/render.mjs +3 -0
  210. package/runtime/route-pattern.mjs +44 -0
  211. package/runtime/rsc-worker.mjs +41 -12
  212. package/runtime/trace.mjs +78 -0
  213. package/runtime/typed-route.mjs +19 -0
  214. package/runtime/watch-project.mjs +29 -18
  215. package/runtime/worker.mjs +102 -24
package/build/ADAPTERS.md CHANGED
@@ -39,6 +39,31 @@ its implementation-specific fields are not made stable by this public envelope.
39
39
 
40
40
  ## Static adapter
41
41
 
42
+ Plain HTML/CSS/JavaScript sites need no package.json or React source tree:
43
+
44
+ ```sh
45
+ prnext build ./my-site
46
+ prnext start ./my-site
47
+ ```
48
+
49
+ An `index.html` at the project root is detected automatically. Explicitly select
50
+ it with `prnext build --adapter static --from .`. Root mode copies browser asset
51
+ extensions only; it omits dotfiles, npm metadata, JSON data, source/server folders,
52
+ configuration and server scripts. Put additional public data in a dedicated output
53
+ directory and import it with `--from`; do not put secrets in browser assets.
54
+
55
+ After running another builder, `prnext build --adapter auto` finds a completed
56
+ `dist`, `build`, `out` or `.output/public` directory. Multiple outputs require an
57
+ explicit `--from` choice. This command never runs another builder or its config.
58
+ Vite (including React, Vue, Svelte and Solid browser apps), Astro static output,
59
+ SvelteKit with `adapter-static`, and Nuxt generated output can use the same static
60
+ runtime. Keep each framework's server rendering and API routes out of the import.
61
+ See [Vite deployment](https://vite.dev/guide/static-deploy.html),
62
+ [Astro deployment](https://docs.astro.build/en/guides/deploy/),
63
+ [SvelteKit static generation](https://svelte.dev/docs/kit/adapter-static), and
64
+ [Nuxt deployment](https://nuxt.com/docs/4.x/getting-started/deployment).
65
+
66
+
42
67
  Build the application with its own tool, then import **only** its public browser
43
68
  output. Vite's default output is `dist`; see its [deployment guide](https://vite.dev/guide/static-deploy.html).
44
69
 
@@ -87,7 +112,7 @@ dotted client route names need explicit HTML output for those URLs. Configure th
87
112
  source builder's asset base for its deployed URL; the adapter does not rewrite JS
88
113
  or HTML assets or infer subpath hosting configuration.
89
114
 
90
- The source directory must be inside the project, separate from the destination,
115
+ Dedicated public source directories must be inside the project, separate from the destination,
91
116
  with real directories and files. Symlinks, dotfiles other than a root `.well-known`
92
117
  tree, `node_modules`, reserved `_next`/`_prnext` paths and a root `server` directory
93
118
  are rejected. Limits are 20,000 files, 512 MiB total and 64 directory levels.
@@ -114,7 +139,7 @@ change existing PRNext Host projects or enable generic SSR hosting.
114
139
 
115
140
  ## Extending adapters
116
141
 
117
- The current adapter names are `react` and `static`. Unknown adapters fail rather
142
+ The output adapter names are `react` and `static`. Build selection also accepts `auto`, which inspects completed output and never starts a third-party builder. Unknown adapters fail rather
118
143
  than being silently interpreted as React. Add a new adapter only with a documented
119
144
  runtime model and integration tests. A future generic Node SSR adapter needs its
120
145
  own request/response, streaming, cancellation, crash and shutdown contract; a
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}`);
@@ -202,6 +202,11 @@ export function createServerActions({ projectRoot }) {
202
202
 
203
203
  return {
204
204
  actions, actionKey,
205
+ // Only compiler-generated module-level action proxies bypass React Refresh.
206
+ // Their random reference IDs change each build; they are not component exports.
207
+ isServerModule(source,file) {
208
+ return source.includes('use server') && analyze(source,file).moduleServer === true;
209
+ },
205
210
  transform(source,file,mode) {
206
211
  if(!source.includes('use server')) return source;
207
212
  const result=analyze(source,file);
@@ -0,0 +1,340 @@
1
+ import {readFile,writeFile,stat,open} from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {createHash} from 'node:crypto';
4
+ import {gzip,brotliCompress,constants} from 'node:zlib';
5
+ import {promisify} from 'node:util';
6
+ import {mapConcurrent} from './concurrent.mjs';
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
+ ]);
17
+
18
+ export function recordBuildAnalysis(state,options,result){
19
+ if(!state?.stage||!result.metafile)return;
20
+ state.analysis ||= new Map();
21
+ state.analysisRoutes ||= new Map();
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]));
25
+ for(const [absolute,info] of outputs){
26
+ const relative=path.relative(state.stage,absolute).replaceAll(path.sep,'/');
27
+ if(!info.entryPoint||!relative.startsWith('server/'))continue;
28
+ const visited=new Set(),inputs=new Set(),pending=[absolute];
29
+ while(pending.length){
30
+ const next=pending.pop();if(visited.has(next))continue;visited.add(next);
31
+ const output=outputs.get(next);if(!output)continue;
32
+ for(const name of Object.keys(output.inputs||{}))inputs.add(path.relative(state.root,path.resolve(options.absWorkingDir||state.root,name)).replaceAll(path.sep,'/'));
33
+ for(const item of output.imports||[])if(!item.external)pending.push(path.resolve(options.absWorkingDir||state.root,item.path));
34
+ }
35
+ state.analysisRoutes.set(relative,inputs);
36
+ }
37
+ for(const [file,info] of Object.entries(result.metafile.outputs)){
38
+ const absolute=path.resolve(options.absWorkingDir||state.root,file);
39
+ const relative=path.relative(path.join(state.stage,'assets'),absolute).replaceAll(path.sep,'/');
40
+ if(relative.startsWith('../')||path.isAbsolute(relative)||!relative.endsWith('.js'))continue;
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}:{}),
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)});
59
+ }
60
+ }
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){
64
+ const seen=new Set(),pending=[entry];
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);}
66
+ return [...seen].sort();
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();}
113
+ const gzipAsync=promisify(gzip),brotliAsync=promisify(brotliCompress);
114
+ export async function writeBuildAnalysis({root,stage,destination,manifest,records,routeInputs,precompressed=false}){
115
+ const files=new Map(records||[]);
116
+ await mapConcurrent(files.values(),async info=>{
117
+ const file=path.join(stage,'assets',info.file),data=await readFile(file);
118
+ info.bytes=data.length;
119
+ // Sidecars in this unpublished stage have just passed decode/hash validation.
120
+ // Never trust arbitrary sidecars when analysis is called independently.
121
+ const size=async(extension,compress)=>{
122
+ if(precompressed)try{return(await stat(file+extension)).size;}catch(error){if(error.code!=='ENOENT')throw error;}
123
+ return(await compress()).length;
124
+ };
125
+ info.gzipBytes=await size('.gz',()=>gzipAsync(data));
126
+ info.brotliBytes=await size('.br',()=>brotliAsync(data,{params:{[constants.BROTLI_PARAM_QUALITY]:5}}));
127
+ });
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=[];
142
+ if(route.router==='app')for(const file of routeInputs?.get(route.module)||[]){
143
+ const id='client-'+createHash('sha256').update(file).digest('hex').slice(0,16);
144
+ const entry=manifest.app?.clientModules?.[id]?.browserModule;
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)});
156
+ }
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]))]))};
172
+ });
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.');}
175
+ await writeFile(path.join(stage,'build-analysis.json'),JSON.stringify(report));
176
+ return report;
177
+ }
178
+ const metrics={raw:'jsBytes',gzip:'gzipBytes',brotli:'brotliBytes'};
179
+ const size=value=>Number.isSafeInteger(value)&&value>=0;
180
+ const text=value=>typeof value==='string'&&value.length>0&&value.length<=4096&&!/[\0\r\n]/.test(value);
181
+ const object=value=>value!==null&&typeof value==='object'&&!Array.isArray(value);
182
+ async function boundedJson(file,label){
183
+ const handle=await open(file,'r'),limit=16*1024*1024;
184
+ try{
185
+ const info=await handle.stat();
186
+ if(!info.isFile()||info.size>limit)throw new Error(`${label} must be a regular JSON file of at most 16 MiB.`);
187
+ // Fixed allocation also bounds reads when a concurrent writer grows it.
188
+ const data=Buffer.alloc(limit+1);let length=0;
189
+ while(length<data.length){const {bytesRead}=await handle.read(data,length,data.length-length,null);if(!bytesRead)break;length+=bytesRead;}
190
+ if(length>limit)throw new Error(`${label} exceeds 16 MiB.`);
191
+ try{return JSON.parse(data.subarray(0,length).toString('utf8'));}catch{throw new Error(`${label} contains invalid JSON.`);}
192
+ }finally{await handle.close();}
193
+ }
194
+ function validateRoutes(routes,{complete=true}={}){
195
+ if(!Array.isArray(routes)||routes.length>10000)throw new Error('Invalid analysis routes (maximum 10,000).');
196
+ const seen=new Set();
197
+ for(const route of routes){
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.');
199
+ seen.add(route.route);
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.');
235
+ }
236
+ }
237
+ async function readReport(file){
238
+ const value=await boundedJson(file,'Bundle analysis');
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.');
240
+ validateRoutes(value.routes);
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.');
242
+ for(const file of value.files){
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.');
244
+ }
245
+ if(value.previous!==undefined){
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.');
247
+ validateRoutes(value.previous.routes,{complete:false});
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
+ }
253
+ // Retain only the versioned schema, rather than echoing arbitrary nested
254
+ // properties from a supplied baseline into a machine-readable report.
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)}}:{})};
262
+ }
263
+ function nonnegative(value,name,{integer=false}={}){
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'}.`);
265
+ return value;
266
+ }
267
+ export function parseAnalysisArgs(args){
268
+ const options={};
269
+ const numeric={'--max-js':'maxJs','--max-increase-bytes':'maxIncreaseBytes','--max-increase-percent':'maxIncreasePercent'};
270
+ const values={'--baseline':'baseline','--metric':'metric','--budgets':'budgets'};
271
+ for(let index=0;index<args.length;index++){
272
+ const flag=args[index],key=Object.hasOwn(numeric,flag)?numeric[flag]:Object.hasOwn(values,flag)?values[flag]:undefined;
273
+ if(flag==='--json'&&!options.json){options.json=true;continue;}
274
+ if(!key||options[key]!==undefined)throw new Error(`Unknown or duplicate analyze option: ${flag}`);
275
+ const value=args[++index];if(!value||value.startsWith('--'))throw new Error(`${flag} requires a value.`);
276
+ if(Object.hasOwn(numeric,flag)){
277
+ if(!/^\d+(?:\.\d+)?$/.test(value))throw new Error(`${flag} requires a nonnegative number.`);
278
+ options[key]=nonnegative(Number(value),flag,{integer:key==='maxIncreaseBytes'});
279
+ }else options[key]=value;
280
+ }
281
+ if(options.metric!==undefined&&!Object.hasOwn(metrics,options.metric))throw new Error('--metric must be raw, gzip or brotli.');
282
+ return options;
283
+ }
284
+ async function routeBudgets(filename){
285
+ if(!filename)return {};
286
+ const value=await boundedJson(filename,'Route budgets');
287
+ if(!object(value)||Object.keys(value).length>10000)throw new Error('Route budgets must be an object with at most 10,000 route keys.');
288
+ for(const [route,budget]of Object.entries(value)){
289
+ if(!text(route)||!route.startsWith('/')||!object(budget)||!Object.keys(budget).length)throw new Error('Each route budget must be a nonempty object keyed by an exact route pattern.');
290
+ for(const [key,limit]of Object.entries(budget)){
291
+ if(!['maxJs','maxIncreaseBytes','maxIncreasePercent'].includes(key))throw new Error(`Unknown route budget field: ${key}`);
292
+ nonnegative(limit,`Budget ${route}.${key}`,{integer:key==='maxIncreaseBytes'});
293
+ }
294
+ }
295
+ return value;
296
+ }
297
+ export async function analyzeBuild(root,{maxJs,baseline,metric='raw',maxIncreaseBytes,maxIncreasePercent,budgets}={}){
298
+ if(!Object.hasOwn(metrics,metric))throw new Error('Analysis metric must be raw, gzip or brotli.');
299
+ for(const [key,value]of Object.entries({maxJs,maxIncreaseBytes,maxIncreasePercent}))if(value!==undefined)nonnegative(value,key,{integer:key==='maxIncreaseBytes'});
300
+ const report=await readReport(path.join(root,await readBuildDirectory(root),'build-analysis.json'));
301
+ const limits=await routeBudgets(budgets?path.resolve(root,budgets):null);
302
+ const routeNames=new Set(report.routes.map(route=>route.route));
303
+ for(const route of Object.keys(limits))if(!routeNames.has(route))throw new Error(`Route budget does not match a current route: ${route}`);
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.');
306
+ const needsBaseline=maxIncreaseBytes!==undefined||maxIncreasePercent!==undefined||Object.values(limits).some(value=>value.maxIncreaseBytes!==undefined||value.maxIncreasePercent!==undefined);
307
+ if(needsBaseline&&!reference)throw new Error('Regression budgets require --baseline or a previous build analysis.');
308
+ const field=metrics[metric],previous=new Map((reference?.routes||[]).filter(route=>route.initial?.status!=='request-dependent').map(route=>[route.route,route[field]]));
309
+ report.metric=metric;
310
+ report.baseline=reference?{buildId:reference.buildId,source:baseline?'file':'previous-build'}:null;
311
+ report.routes=report.routes.map(route=>{
312
+ const budget={maxJs,maxIncreaseBytes,maxIncreasePercent,...(limits[route.route]||{})};
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;
315
+ const regressionBudget=budget.maxIncreaseBytes!==undefined||budget.maxIncreasePercent!==undefined;
316
+ const exceeded=[];
317
+ if(uncertain&&Object.values(budget).some(value=>value!==undefined))exceeded.push('initial-unknown');
318
+ if(budget.maxJs!==undefined&&current>budget.maxJs*1000)exceeded.push('maxJs');
319
+ if(regressionBudget&&!hasBaseline)exceeded.push('baseline-missing');
320
+ if(hasBaseline&&budget.maxIncreaseBytes!==undefined&&delta>budget.maxIncreaseBytes)exceeded.push('maxIncreaseBytes');
321
+ if(hasBaseline&&budget.maxIncreasePercent!==undefined&&(before===0?delta>0:delta/before*100>budget.maxIncreasePercent))exceeded.push('maxIncreasePercent');
322
+ return {...route,selectedBytes:current,baselineBytes:hasBaseline?before:null,deltaBytes:delta,deltaPercent:hasBaseline&&before>0?delta/before*100:hasBaseline&&current===0?0:null,
323
+ comparison:uncertain?'initial-unknown':hasBaseline?'compared':reference?'new-or-unavailable':'no-baseline',budget:{...budget,exceeded}};
324
+ }).sort((a,b)=>b.selectedBytes-a.selectedBytes||a.route.localeCompare(b.route));
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)};
326
+ report.ok=report.budget.exceeded.length===0;
327
+ return report;
328
+ }
329
+ export function printAnalysis(report){
330
+ const kb=n=>(n/1000).toFixed(1)+' kB';
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);
334
+ console.log('\nLargest browser modules (per output chunk):');
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}]`);
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(', ')}`);
340
+ }
@@ -1,5 +1,5 @@
1
1
  import { readFile } from 'node:fs/promises';
2
- import { parse } from '@babel/parser';
2
+ import {analyzeSource} from './source-analysis.mjs';
3
3
  import {validSamples} from '../compat/instant-samples.cjs';
4
4
  import { createHash } from 'node:crypto';
5
5
 
@@ -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', []],
@@ -23,19 +24,19 @@ const supported = new Map([
23
24
 
24
25
  function literal(node) {
25
26
  while (['TSAsExpression', 'TSSatisfiesExpression', 'TSNonNullExpression', 'TypeCastExpression'].includes(node?.type)) node = node.expression;
26
- if (node?.type === 'NullLiteral') return null;
27
+ if (node?.type === 'Literal' && node.value === null) return null;
27
28
  if (node?.type === 'ArrayExpression') { const values = node.elements.map(literal); return values.includes(undefined) ? undefined : values; }
28
29
  if (node?.type === 'ObjectExpression') {
29
30
  const output = {};
30
31
  for (const item of node.properties) {
31
- if (item.type !== 'ObjectProperty' || item.computed || item.shorthand) return undefined;
32
+ if (item.type !== 'Property' || item.kind !== 'init' || item.method || item.computed || item.shorthand) return undefined;
32
33
  const key = item.key.name ?? item.key.value, value = literal(item.value);
33
34
  if (typeof key !== 'string' || value === undefined || key === '__proto__') return undefined;
34
35
  Object.defineProperty(output, key, { value, enumerable: true });
35
36
  }
36
37
  return output;
37
38
  }
38
- return ['StringLiteral', 'BooleanLiteral', 'NumericLiteral'].includes(node?.type) ? node.value : undefined;
39
+ return node?.type === 'Literal' && ['string','boolean','number'].includes(typeof node.value) ? node.value : undefined;
39
40
  }
40
41
 
41
42
  export async function validateAppConfig(file) {
@@ -48,8 +49,7 @@ export async function validateAppConfig(file) {
48
49
  }
49
50
  parsedMisses++;
50
51
  if (hit) { parsedConfigs.delete(file); parsedBytes -= hit.size; }
51
- const ast = parse(source, { sourceType: 'unambiguous', sourceFilename: file,
52
- plugins: ['jsx', ...(/\.tsx?$/.test(file) ? ['typescript'] : [])] });
52
+ const analysis=analyzeSource(source,file), ast={program:analysis.program};
53
53
  const locals = new Map();
54
54
  const exports = [];
55
55
  for (const statement of ast.program.body) {
@@ -70,12 +70,13 @@ export async function validateAppConfig(file) {
70
70
  if (!supported.has(item.name)) continue;
71
71
  const value = locals.get(item.local);
72
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}).`);
73
74
  if (item.name === 'instant') {
74
- if (ast.program.directives.some(directive => directive.value.value === 'use client')) throw new Error(`instant cannot be exported by a Client Component (${file}).`);
75
+ if (analysis.directives.includes('use client')) throw new Error(`instant cannot be exported by a Client Component (${file}).`);
75
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; }
76
77
  }
77
78
  if (item.name === 'generateStaticParams') {
78
- if (ast.program.directives.some(directive => directive.value.value === 'use client')) throw new Error(`generateStaticParams cannot be exported by a Client Component (${file}).`);
79
+ if (analysis.directives.includes('use client')) throw new Error(`generateStaticParams cannot be exported by a Client Component (${file}).`);
79
80
  config.generateStaticParams = true;
80
81
  continue;
81
82
  }
@@ -105,7 +106,16 @@ export function mergeAppConfig(configs) {
105
106
  throw new Error('Incompatible App Router fetchCache guarantees across route segments.');
106
107
  }
107
108
  let forceDynamic = false;
109
+ const levels = ['auto', 'shell', 'prefetch', 'navigation'];
110
+ let staticLevel = 0;
111
+ const staticDisabled = configs.some(config => config.ensureStatic === false);
108
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
+ }
109
119
  if (config.maxDuration !== undefined) merged.maxDuration = config.maxDuration;
110
120
  if (config.runtime === 'edge') merged.runtime = 'edge';
111
121
  else if (config.runtime === 'nodejs') delete merged.runtime;
@@ -119,6 +129,8 @@ export function mergeAppConfig(configs) {
119
129
  if (config.fetchCache !== undefined) merged.fetchCache = config.fetchCache;
120
130
  if (config.fetchCache === 'force-no-store') merged.forceNoStore = true;
121
131
  }
132
+ if (staticDisabled) merged.ensureStatic = false;
133
+ else if (staticLevel) merged.ensureStatic = levels[staticLevel];
122
134
  if (forceDynamic) merged.dynamic = 'force-dynamic';
123
135
  if (policies.has('force-cache')) merged.fetchCache = 'force-cache';
124
136
  else if (!merged.forceNoStore && !forceDynamic) {