@thomas.f/prnext 0.1.15-alpha → 0.1.17-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 (126) hide show
  1. package/AI.md +63 -0
  2. package/README.md +387 -12
  3. package/build/ADAPTERS.md +27 -2
  4. package/build/actions.mjs +5 -0
  5. package/build/analyze.mjs +184 -0
  6. package/build/app-config.mjs +7 -8
  7. package/build/app-static.mjs +12 -7
  8. package/build/app.mjs +23 -10
  9. package/build/build-lock.mjs +6 -6
  10. package/build/bundle-runtime.mjs +20 -5
  11. package/build/check.mjs +56 -11
  12. package/build/compiler.mjs +25 -1
  13. package/build/concurrent.mjs +12 -0
  14. package/build/config.mjs +21 -13
  15. package/build/css-modules.mjs +15 -4
  16. package/build/dev-compiler-health.mjs +11 -0
  17. package/build/dev-css-update.mjs +83 -0
  18. package/build/dev-dependencies.mjs +44 -0
  19. package/build/dev-discovery.mjs +28 -0
  20. package/build/dev-worker.mjs +14 -5
  21. package/build/dev.mjs +55 -4
  22. package/build/edge.mjs +3 -2
  23. package/build/framework-imports.mjs +40 -0
  24. package/build/incremental.mjs +8 -4
  25. package/build/index.mjs +96 -101
  26. package/build/inspect.mjs +3 -0
  27. package/build/inspection-summary.mjs +37 -4
  28. package/build/loader-cache.mjs +53 -26
  29. package/build/metadata-routes.mjs +2 -3
  30. package/build/migrate.mjs +60 -57
  31. package/build/npm-packages.mjs +3 -18
  32. package/build/package-manager.mjs +308 -0
  33. package/build/persistent-cache.mjs +97 -0
  34. package/build/precompress.mjs +62 -30
  35. package/build/prerender-pages.mjs +90 -0
  36. package/build/prerender-worker.mjs +10 -0
  37. package/build/production-builder.mjs +80 -0
  38. package/build/scan.mjs +4 -4
  39. package/build/source-analysis.mjs +42 -0
  40. package/build/static-adapter.mjs +13 -5
  41. package/build/static-input.mjs +48 -0
  42. package/build/styles.mjs +15 -3
  43. package/build/transform-cache.mjs +37 -4
  44. package/build/type-command.mjs +41 -0
  45. package/build/typed-routes.mjs +91 -0
  46. package/build/typescript-dev-worker.mjs +18 -0
  47. package/build/typescript-dev.mjs +22 -0
  48. package/build/typescript-watch.mjs +22 -0
  49. package/build/typescript.mjs +5 -4
  50. package/build/webpack-graph.mjs +164 -27
  51. package/build/worker-preload.mjs +9 -0
  52. package/cli.mjs +105 -121
  53. package/compat/index.d.ts +21 -7
  54. package/compat/link.d.cts +4 -3
  55. package/compat/navigation.d.cts +4 -3
  56. package/compat/router.d.cts +4 -3
  57. package/compat/typed-route.d.cts +17 -0
  58. package/docs/ai/compatibility.md +106 -0
  59. package/docs/ai/data.md +128 -0
  60. package/docs/ai/deployment.md +111 -0
  61. package/docs/ai/manifest.json +11 -0
  62. package/docs/ai/quickstart.md +146 -0
  63. package/docs/ai/routing.md +132 -0
  64. package/docs/ai/troubleshooting.md +120 -0
  65. package/index.mjs +2 -0
  66. package/native/diagnostics.mjs +34 -0
  67. package/native/resolve.mjs +7 -2
  68. package/package.json +28 -10
  69. package/release/ai-docs.mjs +48 -0
  70. package/release/verify.mjs +6 -0
  71. package/runtime/ai-docs.mjs +202 -0
  72. package/runtime/app-client.mjs +17 -6
  73. package/runtime/app-render.mjs +12 -0
  74. package/runtime/cloud/archive.mjs +5 -6
  75. package/runtime/compile-cache.mjs +8 -0
  76. package/runtime/dev-ai-brands.mjs +26 -0
  77. package/runtime/dev-ai-style.mjs +99 -0
  78. package/runtime/dev-build-reasons.mjs +25 -0
  79. package/runtime/dev-cache-control.mjs +53 -0
  80. package/runtime/dev-client.mjs +239 -45
  81. package/runtime/dev-console-copy.mjs +19 -0
  82. package/runtime/dev-console.mjs +106 -0
  83. package/runtime/dev-dependency-graph.mjs +40 -0
  84. package/runtime/dev-element-picker-style.mjs +27 -0
  85. package/runtime/dev-element-picker.mjs +213 -0
  86. package/runtime/dev-error-overlay-style.mjs +41 -0
  87. package/runtime/dev-error-overlay.mjs +167 -0
  88. package/runtime/dev-error-report.mjs +121 -0
  89. package/runtime/dev-file-changes.mjs +1 -1
  90. package/runtime/dev-gateway.mjs +44 -0
  91. package/runtime/dev-host-relay.mjs +66 -0
  92. package/runtime/dev-indicator.mjs +186 -0
  93. package/runtime/dev-issue-motion.mjs +214 -0
  94. package/runtime/dev-lazy-tool.mjs +18 -0
  95. package/runtime/dev-logo.mjs +2 -0
  96. package/runtime/dev-notification.mjs +81 -0
  97. package/runtime/dev-popout.mjs +34 -0
  98. package/runtime/dev-position.mjs +107 -0
  99. package/runtime/dev-preview-touch.mjs +122 -0
  100. package/runtime/dev-runtime-pool.mjs +45 -0
  101. package/runtime/dev-runtime-spare.mjs +57 -0
  102. package/runtime/dev-tools-client.mjs +155 -0
  103. package/runtime/dev-tools-mcp.mjs +126 -0
  104. package/runtime/dev-tools-metrics.mjs +31 -0
  105. package/runtime/dev-tools-model.mjs +42 -0
  106. package/runtime/dev-tools-panel.mjs +341 -0
  107. package/runtime/dev-tools-result.mjs +37 -0
  108. package/runtime/dev-tools-server.mjs +256 -0
  109. package/runtime/dev-tools-source.mjs +104 -0
  110. package/runtime/dev-tooltip.mjs +94 -0
  111. package/runtime/dev-ui-icons.mjs +8 -0
  112. package/runtime/dev-workbench-style.mjs +106 -0
  113. package/runtime/dev-workbench.mjs +428 -0
  114. package/runtime/dev-worker-logs.mjs +31 -0
  115. package/runtime/development.mjs +307 -0
  116. package/runtime/env.mjs +2 -1
  117. package/runtime/pages-client.mjs +10 -1
  118. package/runtime/pages-navigation.mjs +6 -1
  119. package/runtime/persistent/client.mjs +6 -5
  120. package/runtime/persistent/daemon.mjs +66 -56
  121. package/runtime/persistent/native-proxy.mjs +63 -0
  122. package/runtime/preload.mjs +12 -0
  123. package/runtime/rsc-worker.mjs +7 -0
  124. package/runtime/typed-route.mjs +19 -0
  125. package/runtime/watch-project.mjs +12 -5
  126. package/runtime/worker.mjs +36 -2
@@ -0,0 +1,184 @@
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
+
9
+ export function recordBuildAnalysis(state,options,result){
10
+ if(!state?.stage||!result.metafile)return;
11
+ state.analysis ||= new Map();
12
+ state.analysisRoutes ||= new Map();
13
+ const outputs=new Map(Object.entries(result.metafile.outputs).map(([file,info])=>[path.resolve(options.absWorkingDir||state.root,file),info]));
14
+ for(const [absolute,info] of outputs){
15
+ const relative=path.relative(state.stage,absolute).replaceAll(path.sep,'/');
16
+ if(!info.entryPoint||!relative.startsWith('server/'))continue;
17
+ const visited=new Set(),inputs=new Set(),pending=[absolute];
18
+ while(pending.length){
19
+ const next=pending.pop();if(visited.has(next))continue;visited.add(next);
20
+ const output=outputs.get(next);if(!output)continue;
21
+ for(const name of Object.keys(output.inputs||{}))inputs.add(path.relative(state.root,path.resolve(options.absWorkingDir||state.root,name)).replaceAll(path.sep,'/'));
22
+ for(const item of output.imports||[])if(!item.external)pending.push(path.resolve(options.absWorkingDir||state.root,item.path));
23
+ }
24
+ state.analysisRoutes.set(relative,inputs);
25
+ }
26
+ for(const [file,info] of Object.entries(result.metafile.outputs)){
27
+ const absolute=path.resolve(options.absWorkingDir||state.root,file);
28
+ const relative=path.relative(path.join(state.stage,'assets'),absolute).replaceAll(path.sep,'/');
29
+ 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})),
31
+ 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
+ }
33
+ }
34
+ function initialFiles(entry,files){
35
+ 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);}
37
+ return [...seen].sort();
38
+ }
39
+ const gzipAsync=promisify(gzip),brotliAsync=promisify(brotliCompress);
40
+ export async function writeBuildAnalysis({root,stage,destination,manifest,records,routeInputs,precompressed=false}){
41
+ const files=new Map(records||[]);
42
+ await mapConcurrent(files.values(),async info=>{
43
+ const file=path.join(stage,'assets',info.file),data=await readFile(file);
44
+ info.bytes=data.length;
45
+ // Sidecars in this unpublished stage have just passed decode/hash validation.
46
+ // Never trust arbitrary sidecars when analysis is called independently.
47
+ const size=async(extension,compress)=>{
48
+ if(precompressed)try{return(await stat(file+extension)).size;}catch(error){if(error.code!=='ENOENT')throw error;}
49
+ return(await compress()).length;
50
+ };
51
+ info.gzipBytes=await size('.gz',()=>gzipAsync(data));
52
+ info.brotliBytes=await size('.br',()=>brotliAsync(data,{params:{[constants.BROTLI_PARAM_QUALITY]:5}}));
53
+ });
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]:[];
57
+ if(route.router==='app')for(const file of routeInputs?.get(route.module)||[]){
58
+ const id='client-'+createHash('sha256').update(file).digest('hex').slice(0,16);
59
+ const entry=manifest.app?.clientModules?.[id]?.browserModule;
60
+ if(entry)entries.push(new URL(entry,'http://local').pathname.split('/').pop());
61
+ }
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)};
64
+ });
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.');}
67
+ await writeFile(path.join(stage,'build-analysis.json'),JSON.stringify(report));
68
+ return report;
69
+ }
70
+ const metrics={raw:'jsBytes',gzip:'gzipBytes',brotli:'brotliBytes'};
71
+ const size=value=>Number.isSafeInteger(value)&&value>=0;
72
+ const text=value=>typeof value==='string'&&value.length>0&&value.length<=4096&&!/[\0\r\n]/.test(value);
73
+ const object=value=>value!==null&&typeof value==='object'&&!Array.isArray(value);
74
+ async function boundedJson(file,label){
75
+ const handle=await open(file,'r'),limit=16*1024*1024;
76
+ try{
77
+ const info=await handle.stat();
78
+ if(!info.isFile()||info.size>limit)throw new Error(`${label} must be a regular JSON file of at most 16 MiB.`);
79
+ // Fixed allocation also bounds reads when a concurrent writer grows it.
80
+ const data=Buffer.alloc(limit+1);let length=0;
81
+ while(length<data.length){const {bytesRead}=await handle.read(data,length,data.length-length,null);if(!bytesRead)break;length+=bytesRead;}
82
+ if(length>limit)throw new Error(`${label} exceeds 16 MiB.`);
83
+ try{return JSON.parse(data.subarray(0,length).toString('utf8'));}catch{throw new Error(`${label} contains invalid JSON.`);}
84
+ }finally{await handle.close();}
85
+ }
86
+ function validateRoutes(routes,{complete=true}={}){
87
+ if(!Array.isArray(routes)||routes.length>10000)throw new Error('Invalid analysis routes (maximum 10,000).');
88
+ const seen=new Set();
89
+ for(const route of routes){
90
+ 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
+ seen.add(route.route);
92
+ for(const key of ['gzipBytes','brotliBytes'])if((complete||route[key]!==undefined)&&!size(route[key]))throw new Error(`Invalid ${key} in bundle analysis.`);
93
+ }
94
+ }
95
+ async function readReport(file){
96
+ 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.');
98
+ validateRoutes(value.routes);
99
+ 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
+ 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.');
102
+ }
103
+ 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.');
105
+ validateRoutes(value.previous.routes,{complete:false});
106
+ }
107
+ // Retain only the versioned schema, rather than echoing arbitrary nested
108
+ // 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}))}}:{})};
113
+ }
114
+ function nonnegative(value,name,{integer=false}={}){
115
+ 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'}.`);
116
+ return value;
117
+ }
118
+ export function parseAnalysisArgs(args){
119
+ const options={};
120
+ const numeric={'--max-js':'maxJs','--max-increase-bytes':'maxIncreaseBytes','--max-increase-percent':'maxIncreasePercent'};
121
+ const values={'--baseline':'baseline','--metric':'metric','--budgets':'budgets'};
122
+ for(let index=0;index<args.length;index++){
123
+ const flag=args[index],key=Object.hasOwn(numeric,flag)?numeric[flag]:Object.hasOwn(values,flag)?values[flag]:undefined;
124
+ if(flag==='--json'&&!options.json){options.json=true;continue;}
125
+ if(!key||options[key]!==undefined)throw new Error(`Unknown or duplicate analyze option: ${flag}`);
126
+ const value=args[++index];if(!value||value.startsWith('--'))throw new Error(`${flag} requires a value.`);
127
+ if(Object.hasOwn(numeric,flag)){
128
+ if(!/^\d+(?:\.\d+)?$/.test(value))throw new Error(`${flag} requires a nonnegative number.`);
129
+ options[key]=nonnegative(Number(value),flag,{integer:key==='maxIncreaseBytes'});
130
+ }else options[key]=value;
131
+ }
132
+ if(options.metric!==undefined&&!Object.hasOwn(metrics,options.metric))throw new Error('--metric must be raw, gzip or brotli.');
133
+ return options;
134
+ }
135
+ async function routeBudgets(filename){
136
+ if(!filename)return {};
137
+ const value=await boundedJson(filename,'Route budgets');
138
+ if(!object(value)||Object.keys(value).length>10000)throw new Error('Route budgets must be an object with at most 10,000 route keys.');
139
+ for(const [route,budget]of Object.entries(value)){
140
+ 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.');
141
+ for(const [key,limit]of Object.entries(budget)){
142
+ if(!['maxJs','maxIncreaseBytes','maxIncreasePercent'].includes(key))throw new Error(`Unknown route budget field: ${key}`);
143
+ nonnegative(limit,`Budget ${route}.${key}`,{integer:key==='maxIncreaseBytes'});
144
+ }
145
+ }
146
+ return value;
147
+ }
148
+ export async function analyzeBuild(root,{maxJs,baseline,metric='raw',maxIncreaseBytes,maxIncreasePercent,budgets}={}){
149
+ if(!Object.hasOwn(metrics,metric))throw new Error('Analysis metric must be raw, gzip or brotli.');
150
+ for(const [key,value]of Object.entries({maxJs,maxIncreaseBytes,maxIncreasePercent}))if(value!==undefined)nonnegative(value,key,{integer:key==='maxIncreaseBytes'});
151
+ const report=await readReport(path.join(root,await readBuildDirectory(root),'build-analysis.json'));
152
+ const limits=await routeBudgets(budgets?path.resolve(root,budgets):null);
153
+ const routeNames=new Set(report.routes.map(route=>route.route));
154
+ for(const route of Object.keys(limits))if(!routeNames.has(route))throw new Error(`Route budget does not match a current route: ${route}`);
155
+ const reference=baseline?await readReport(path.resolve(root,baseline)):report.previous;
156
+ const needsBaseline=maxIncreaseBytes!==undefined||maxIncreasePercent!==undefined||Object.values(limits).some(value=>value.maxIncreaseBytes!==undefined||value.maxIncreasePercent!==undefined);
157
+ 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]]));
159
+ report.metric=metric;
160
+ report.baseline=reference?{buildId:reference.buildId,source:baseline?'file':'previous-build'}:null;
161
+ report.routes=report.routes.map(route=>{
162
+ 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;
164
+ const regressionBudget=budget.maxIncreaseBytes!==undefined||budget.maxIncreasePercent!==undefined;
165
+ const exceeded=[];
166
+ if(budget.maxJs!==undefined&&current>budget.maxJs*1000)exceeded.push('maxJs');
167
+ if(regressionBudget&&!hasBaseline)exceeded.push('baseline-missing');
168
+ if(hasBaseline&&budget.maxIncreaseBytes!==undefined&&delta>budget.maxIncreaseBytes)exceeded.push('maxIncreaseBytes');
169
+ if(hasBaseline&&budget.maxIncreasePercent!==undefined&&(before===0?delta>0:delta/before*100>budget.maxIncreasePercent))exceeded.push('maxIncreasePercent');
170
+ 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}};
172
+ }).sort((a,b)=>b.selectedBytes-a.selectedBytes||a.route.localeCompare(b.route));
173
+ 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
+ report.ok=report.budget.exceeded.length===0;
175
+ return report;
176
+ }
177
+ export function printAnalysis(report){
178
+ 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(', ')}]`:''}`);
181
+ console.log('\nLargest browser modules (per output chunk):');
182
+ 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(', ')}`);
184
+ }
@@ -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
 
@@ -23,19 +23,19 @@ const supported = new Map([
23
23
 
24
24
  function literal(node) {
25
25
  while (['TSAsExpression', 'TSSatisfiesExpression', 'TSNonNullExpression', 'TypeCastExpression'].includes(node?.type)) node = node.expression;
26
- if (node?.type === 'NullLiteral') return null;
26
+ if (node?.type === 'Literal' && node.value === null) return null;
27
27
  if (node?.type === 'ArrayExpression') { const values = node.elements.map(literal); return values.includes(undefined) ? undefined : values; }
28
28
  if (node?.type === 'ObjectExpression') {
29
29
  const output = {};
30
30
  for (const item of node.properties) {
31
- if (item.type !== 'ObjectProperty' || item.computed || item.shorthand) return undefined;
31
+ if (item.type !== 'Property' || item.kind !== 'init' || item.method || item.computed || item.shorthand) return undefined;
32
32
  const key = item.key.name ?? item.key.value, value = literal(item.value);
33
33
  if (typeof key !== 'string' || value === undefined || key === '__proto__') return undefined;
34
34
  Object.defineProperty(output, key, { value, enumerable: true });
35
35
  }
36
36
  return output;
37
37
  }
38
- return ['StringLiteral', 'BooleanLiteral', 'NumericLiteral'].includes(node?.type) ? node.value : undefined;
38
+ return node?.type === 'Literal' && ['string','boolean','number'].includes(typeof node.value) ? node.value : undefined;
39
39
  }
40
40
 
41
41
  export async function validateAppConfig(file) {
@@ -48,8 +48,7 @@ export async function validateAppConfig(file) {
48
48
  }
49
49
  parsedMisses++;
50
50
  if (hit) { parsedConfigs.delete(file); parsedBytes -= hit.size; }
51
- const ast = parse(source, { sourceType: 'unambiguous', sourceFilename: file,
52
- plugins: ['jsx', ...(/\.tsx?$/.test(file) ? ['typescript'] : [])] });
51
+ const analysis=analyzeSource(source,file), ast={program:analysis.program};
53
52
  const locals = new Map();
54
53
  const exports = [];
55
54
  for (const statement of ast.program.body) {
@@ -71,11 +70,11 @@ export async function validateAppConfig(file) {
71
70
  const value = locals.get(item.local);
72
71
  const allowed = supported.get(item.name);
73
72
  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}).`);
73
+ if (analysis.directives.includes('use client')) throw new Error(`instant cannot be exported by a Client Component (${file}).`);
75
74
  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
75
  }
77
76
  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}).`);
77
+ if (analysis.directives.includes('use client')) throw new Error(`generateStaticParams cannot be exported by a Client Component (${file}).`);
79
78
  config.generateStaticParams = true;
80
79
  continue;
81
80
  }
@@ -3,6 +3,7 @@ import { writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
5
5
  import { staticPath } from '../runtime/pages-paths.mjs';
6
+ import {mapConcurrent} from './concurrent.mjs';
6
7
 
7
8
  const MAX_STATIC_PATHS = 10_000;
8
9
  const MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
@@ -48,7 +49,8 @@ export async function prerenderApp(stage, manifest, dev) {
48
49
  ? await import(pathToFileURL(path.join(stage, 'runtime/app-static.mjs')).href) : null;
49
50
  const handlers = routes.some(route => route.kind === 'api')
50
51
  ? await import(pathToFileURL(path.join(stage, 'runtime/route-static.mjs')).href) : null;
51
- for (const route of routes) {
52
+ const initialSeeds=[...manifest.prerendered];
53
+ await mapConcurrent(routes,async route=>{
52
54
  const handler = route.kind === 'api';
53
55
  const options = { modulePath: path.join(stage, route.module), distDir: stage, manifest, route, production: true };
54
56
  // This fixed internal entry shares the root layout, but its parameter
@@ -64,7 +66,7 @@ export async function prerenderApp(stage, manifest, dev) {
64
66
  // while separating dynamic rendering from the persistent Data Cache.
65
67
  if (config.runtime === 'edge') {
66
68
  if (dynamic && config.dynamicParams === false) route.allowedPaths = entries.map(entry => entry.path);
67
- continue;
69
+ return;
68
70
  }
69
71
  const partial = !handler && manifest.config?.cacheComponents === true && (!config.dynamic || config.dynamic === 'auto');
70
72
  if (partial && dynamic && !inspected.generated) entries.length = 0;
@@ -99,16 +101,17 @@ export async function prerenderApp(stage, manifest, dev) {
99
101
  if (partial) route.pprFallback = config.dynamicParams !== false;
100
102
  if (dynamic && config.dynamicParams === false) route.allowedPaths = entries.map(entry => entry.path);
101
103
  if (handler) {
102
- if (!inspected.staticEligible) continue;
103
- if (dynamic && !inspected.generated && !['force-static', 'error'].includes(config.dynamic)) continue;
104
+ if (!inspected.staticEligible) return;
105
+ if (dynamic && !inspected.generated && !['force-static', 'error'].includes(config.dynamic)) return;
104
106
  } else {
105
- if (config.dynamic === 'force-dynamic' || (config.dynamic !== 'force-static' && (config.revalidate === 0 || config.forceNoStore))) continue;
106
- if (dynamic && !inspected.generated && !partial && !['force-static', 'error'].includes(config.dynamic)) continue;
107
+ if (config.dynamic === 'force-dynamic' || (config.dynamic !== 'force-static' && (config.revalidate === 0 || config.forceNoStore))) return;
108
+ if (dynamic && !inspected.generated && !partial && !['force-static', 'error'].includes(config.dynamic)) return;
107
109
  }
108
110
  route.ssg = true;
109
111
  route.fallback = config.dynamicParams === false ? false : 'blocking';
110
112
  for (const entry of entries) {
111
113
  if (occupied.has(entry.path)) throw new Error(`Multiple pages prerender the same path: ${entry.path}.`);
114
+ occupied.add(entry.path);
112
115
  const rendered = await (handler ? handlers.prerenderAppRouteHandler : pages.prerenderAppRoute)({ ...options, path: entry.path, params: entry.params });
113
116
  if (rendered.partial) {
114
117
  const file = `static/partial-${createHash('sha256').update(entry.path).digest('hex').slice(0, 20)}.json`;
@@ -155,6 +158,8 @@ export async function prerenderApp(stage, manifest, dev) {
155
158
  route.ssg = true; route.fallback = 'ppr';
156
159
  } else { delete route.ssg; delete route.fallback; }
157
160
  }
158
- }
161
+ });
162
+ const appSeeds=manifest.prerendered.slice(initialSeeds.length);
163
+ manifest.prerendered=[...initialSeeds,...routes.flatMap(route=>appSeeds.filter(seed=>seed.routeId===route.id))];
159
164
  } finally { await closeAppRuntime(); }
160
165
  }
package/build/app.mjs CHANGED
@@ -1,6 +1,6 @@
1
- import {frameworkImportName, frameworkRuntimeImportPattern, frameworkSubpathPattern} from './framework-imports.mjs';
1
+ import {applicationEntry, frameworkImportName, frameworkRuntimeImportPattern, frameworkSubpathPattern} from './framework-imports.mjs';
2
2
  import { moduleResolutionPlugin } from './module-resolution.mjs';
3
- import { build as bundle, compilerSource } from './compiler.mjs';
3
+ import { build as bundle, compilerSource,readCompilerFile } from './compiler.mjs';
4
4
  import { readFile, writeFile, mkdir, cp, realpath } from 'node:fs/promises';
5
5
  import path from 'node:path';
6
6
  import { createHash } from 'node:crypto';
@@ -14,7 +14,7 @@ import { createServerActions } from './actions.mjs';
14
14
  import { expandActionExports } from './action-exports.mjs';
15
15
  import { compileEdge } from './edge.mjs';
16
16
  import { createNativePackages } from './native-packages.mjs';
17
- import { createRefreshTransform, devSingletonsPlugin, devClientFile } from './dev.mjs';
17
+ import { createRefreshTransform, createSourceLocationTransform, devSingletonsPlugin, devClientFile } from './dev.mjs';
18
18
  import { npmPackagesPlugin } from './npm-packages.mjs';
19
19
  import { transformCacheComponents } from './cache-components.mjs';
20
20
 
@@ -174,7 +174,8 @@ function routeEntry(route, globalError, dev) {
174
174
 
175
175
  /** Compile independent React Server and client graphs. The RSC graph contains only references to client boundaries. */
176
176
  export async function compileApp({ project, stage, manifest, cssModules, fonts, images, dev, defineEnvironment = {}, productionBrowserSourceMaps = false, serverExternalPackages = [], moduleResolution = {} }) {
177
- const refresh = dev ? createRefreshTransform(project.root) : undefined;
177
+ const refresh = dev ? createRefreshTransform(project.root, {generation:manifest.cacheId,forceGeneration:manifest.devForceRefresh,forceFiles:manifest.devAffectedModules}) : undefined;
178
+ const locations = dev ? createSourceLocationTransform(project.root) : undefined;
178
179
  const { basePath = '', assetPrefix = '', assetBase = '/_prnext/assets', trailingSlash = false, skipTrailingSlashRedirect = false } = manifest.config || {};
179
180
  const publicPath = assetBase + '/';
180
181
  const routes = project.routes.filter(route => route.router === 'app');
@@ -234,6 +235,7 @@ export async function compileApp({ project, stage, manifest, cssModules, fonts,
234
235
  return {
235
236
  name: `prnext-app-${mode}`,
236
237
  setup(esbuild) {
238
+ applicationEntry(esbuild,readCompilerFile);
237
239
  esbuild.onResolve({ filter: /^prnext-internal:use-cache$/ }, () => browser
238
240
  ? { errors: [{ text: "'use cache' cannot enter a Client Component graph" }] }
239
241
  : { path: '../compat/use-cache.cjs', external: true });
@@ -296,7 +298,8 @@ export async function compileApp({ project, stage, manifest, cssModules, fonts,
296
298
  }
297
299
  esbuild.onLoad({ filter: /\.(?:[cm]?js|jsx|tsx?)$/, namespace: 'file' }, async args => {
298
300
  if (browser && args.path === project.document) return { errors: [{ text: 'pages/_document is server-only and cannot be imported by a Client Component.' }] };
299
- const source = await fonts.transform(await expandActionExports(args.prnextSource ?? await readFile(args.path, 'utf8'), args.path, async (specifier, importer) => {
301
+ const original=await readCompilerFile(args.path,args.prnextSource);
302
+ const source = await fonts.transform(await expandActionExports(locations?await locations(original,args.path):original, args.path, async (specifier, importer) => {
300
303
  const resolved = await esbuild.resolve(specifier, { resolveDir: path.dirname(importer), kind: 'import-statement', pluginData: { prnextExportScan: true } });
301
304
  return resolved.errors.length || resolved.external || resolved.namespace !== 'file' ? undefined : resolved.path;
302
305
  }), args.path);
@@ -315,7 +318,7 @@ export async function compileApp({ project, stage, manifest, cssModules, fonts,
315
318
  return { contents: `import {registerClientReference} from 'react-server-dom-webpack/server';\n${references.join('\n')}`, loader: 'js', resolveDir: path.dirname(args.path) };
316
319
  }
317
320
  let contents = transformDynamicImports(actionSource, args.path, { projectRoot: project.root, mode: rsc ? 'rsc' : browser ? 'browser' : 'server' });
318
- if (browser && refresh) contents = await refresh(contents, args.path);
321
+ if (browser && refresh && !actions.isServerModule(cachedSource,args.path)) contents = await refresh(contents, args.path);
319
322
  return { contents, loader: extensions[path.extname(args.path)] || 'jsx', resolveDir: path.dirname(args.path) };
320
323
  });
321
324
  },
@@ -331,6 +334,7 @@ export async function compileApp({ project, stage, manifest, cssModules, fonts,
331
334
  const rsc = mode === 'rsc';
332
335
  const framework = new Set(['link', 'image', 'head', 'router', 'compat-router', 'next-router-context', 'next-app-router-context', 'navigation', 'script', 'app-context', 'app-layout-context', 'app-navigation-boundary', 'app-client-page', 'app-error-boundary', 'app-dynamic']);
333
336
  return { name: 'prnext-edge-app', setup(esbuild) {
337
+ applicationEntry(esbuild,readCompilerFile);
334
338
  esbuild.onResolve({filter:/^prnext-internal:action-(?:crypto|ssr)$/}, args => ({path:args.path.slice('prnext-internal:'.length),namespace:'prnext-edge-action'}));
335
339
  esbuild.onLoad({filter:/.*/,namespace:'prnext-edge-action'}, args => ({loader:'js',contents:(args.path==='action-crypto'?['encryptBoundArgs','decryptBoundArgs','bindEncryptedReference']:['createServerReference']).map(name=>`export const ${name}=globalThis.__PRNEXT_EDGE_ACTIONS[${JSON.stringify(args.path)}][${JSON.stringify(name)}];`).join('\n')}));
336
340
  esbuild.onResolve({ filter: frameworkSubpathPattern }, args => {
@@ -375,7 +379,7 @@ export async function compileApp({ project, stage, manifest, cssModules, fonts,
375
379
  async function edgeAppBuild(file, outfile, mode, defaultExport = false) {
376
380
  const result = await compileEdge({ file, outfile, projectRoot: project.root, dev, defineEnvironment, reactMode: mode, defaultExport,
377
381
  plugins: [moduleResolutionPlugin(moduleResolution, project.root), edgeGraphPlugin(mode), images.plugin(), fonts.plugin(), cssModules.plugin(false)],
378
- transformSource: async (source, file, builder) => actionTransform(await fonts.transform(await expandActionExports(source, file, async (specifier, importer) => {
382
+ transformSource: async (source, file, builder) => actionTransform(await fonts.transform(await expandActionExports(locations?await locations(source,file):source, file, async (specifier, importer) => {
379
383
  const resolved = await builder.resolve(specifier, {resolveDir:path.dirname(importer),kind:'import-statement',pluginData:{prnextExportScan:true}});
380
384
  return resolved.errors.length || resolved.external || resolved.namespace !== 'file' ? undefined : resolved.path;
381
385
  }), file), file, mode, 'edge') });
@@ -481,11 +485,16 @@ export async function compileApp({ project, stage, manifest, cssModules, fonts,
481
485
  }
482
486
  manifest.app = { clientModules: {}, actions: {}, actionKey: actions.actionKey };
483
487
  const actionRevision = () => [...actions.actions.values()].map(action=>action.id+':'+[...(action.runtimes || [])].sort().join(',')).join(';');
488
+ const boundaryRevision = () => [...boundaries.values()].map(boundary=>boundary.id+':'+!!boundary.edge).join(';');
489
+ const browserRevision = () => `${boundaryRevision()}:${cssFiles.size}:${globalErrorCssFiles.size}:${nativePackages.version}`;
484
490
  // Route Handlers have a Node React environment and must not share RSC chunks
485
491
  // that import registerServerReference or client-reference registration.
486
492
  await serverBuild(handlerEntries, 'server', 'route');
487
493
  for (;;) {
488
- const revision = `${actionRevision()}:${boundaries.size}:${cssFiles.size}:${globalErrorCssFiles.size}`;
494
+ // Each graph consumes discoveries from the preceding one. Discovering a
495
+ // client boundary in RSC is normal: SSR and browser below already include
496
+ // it. Only discoveries made too late for a graph require another pass.
497
+ const emittedActions = actionRevision();
489
498
  for (const action of actions.actions.values()) {
490
499
  const entry = path.join(stage, '.entries', 'action-' + action.id + '.mjs');
491
500
  await writeFile(entry, `export {${JSON.stringify(action.binding)} as invoke} from ${JSON.stringify(action.file)};`);
@@ -493,6 +502,8 @@ export async function compileApp({ project, stage, manifest, cssModules, fonts,
493
502
  if (!action.runtimes?.size || action.runtimes.has('nodejs')) entries['action-' + action.id] = entry;
494
503
  }
495
504
  await serverBuild(entries, 'server', 'rsc');
505
+ const serverNativeVersion = nativePackages.version;
506
+ const emittedBoundaries = boundaryRevision();
496
507
  const ssrEntries = {};
497
508
  const globalSsrEntries = {};
498
509
  for (const boundary of boundaries.values()) {
@@ -512,6 +523,7 @@ export async function compileApp({ project, stage, manifest, cssModules, fonts,
512
523
  await collectBrowserStyles(browserEntries);
513
524
  globalErrorOnlyCss.clear();
514
525
  for (const file of globalErrorCssFiles) if (!cssFiles.has(file)) globalErrorOnlyCss.add(file);
526
+ const emittedBrowserInputs = browserRevision();
515
527
  if (routes.some(route => route.kind === 'page')) {
516
528
  let globalErrorCss = [];
517
529
  if (globalErrorCssFiles.size) {
@@ -525,7 +537,7 @@ export async function compileApp({ project, stage, manifest, cssModules, fonts,
525
537
  if (globalErrorId) manifest.app.globalError = { id: globalErrorId, name: 'default', css: globalErrorCss };
526
538
  const runtimeEntry = path.join(stage, '.entries', 'app-runtime.mjs');
527
539
  const importers = Object.entries(browserEntries).map(([id, file]) => `${JSON.stringify(id)}:()=>import(${JSON.stringify(file)})`);
528
- await writeFile(runtimeEntry, `${dev ? `import ${JSON.stringify(devClientFile)};\n` : ''}${[...cssFiles].map(file => `import ${JSON.stringify(file)};`).join('\n')}\nimport {bootstrapApp} from ${JSON.stringify(path.join(packageRoot, 'runtime/app-client.mjs'))};\nbootstrapApp({clientModules:{${importers.join(',')}},...${JSON.stringify({ basePath, assetPrefix, assetBase, trailingSlash, skipTrailingSlashRedirect, strictMode: manifest.config.reactStrictMode !== false, cacheComponents: manifest.config.cacheComponents === true, ...(globalErrorId ? { globalErrorId, globalErrorCss } : {}), ...(dev ? { dev: { buildId: manifest.buildId, clientManifest: manifest.devClient } } : {}) })}});\n`);
540
+ await writeFile(runtimeEntry, `${dev ? `import ${JSON.stringify(devClientFile)};\n` : ''}${[...cssFiles].map(file => `import ${JSON.stringify(file)};`).join('\n')}\nimport {bootstrapApp} from ${JSON.stringify(path.join(packageRoot, 'runtime/app-client.mjs'))};\nbootstrapApp({clientModules:{${importers.join(',')}},...${JSON.stringify({ basePath, assetPrefix, assetBase, trailingSlash, skipTrailingSlashRedirect, strictMode: manifest.config.reactStrictMode !== false, cacheComponents: manifest.config.cacheComponents === true, ...(globalErrorId ? { globalErrorId, globalErrorCss } : {}), ...(dev ? { dev: { buildId: manifest.buildId, clientManifest: manifest.devClient, onDemand:manifest.devOnDemand===true, sequence:manifest.devSequence, session:manifest.devSession, indicator:manifest.devIndicators, ...(manifest.devTools ? {tools:manifest.devTools} : {}) } } : {}) })}});\n`);
529
541
  const clientEntries = { 'app-runtime': runtimeEntry, ...browserEntries };
530
542
  const browser = await bundle({ ...common, entryPoints: clientEntries, outdir: path.join(stage, 'assets'), entryNames: '[name]-[hash]', chunkNames: 'app-chunk-[hash]', platform: 'browser', format: 'esm', target: ['es2022'], splitting: true, minify: !dev, sourcemap: dev || productionBrowserSourceMaps,
531
543
  define: { 'process.env': '{}', ...common.define }, plugins: [moduleResolutionPlugin(moduleResolution, project.root), ...(dev ? [devSingletonsPlugin()] : []), images.plugin(), fonts.plugin(), graphPlugin('browser'), nativePackages.plugin('browser'), cssModules.plugin(true, { emitStyles: file => !globalErrorOnlyCss.has(file) })] });
@@ -548,7 +560,8 @@ export async function compileApp({ project, stage, manifest, cssModules, fonts,
548
560
  module: `server/action-${action.runtimes?.has('nodejs')?'':'edge-'}${action.id}.mjs`, export: 'invoke',
549
561
  ...(action.runtimes?.has('edge') ? {edgeModule:`server/action-edge-${action.id}.mjs`} : {}),
550
562
  }]));
551
- if (revision === `${actionRevision()}:${boundaries.size}:${cssFiles.size}:${globalErrorCssFiles.size}`) break;
563
+ if (emittedActions === actionRevision() && emittedBoundaries === boundaryRevision() &&
564
+ serverNativeVersion === nativePackages.version && emittedBrowserInputs === browserRevision()) break;
552
565
  }
553
566
 
554
567
  }
@@ -11,7 +11,7 @@ function alive(pid) {
11
11
  }
12
12
  async function owner(directory) {
13
13
  const info = await lstat(directory);
14
- if (!info.isDirectory() || info.isSymbolicLink()) throw busy('The build lock is not a real directory. Check .prnext-build.lock before retrying.');
14
+ if (!info.isDirectory() || info.isSymbolicLink()) throw busy(`The project lock is not a real directory. Check ${path.basename(directory)} before retrying.`);
15
15
  const file = await open(path.join(directory, 'owner.json'), 'r');
16
16
  try {
17
17
  if ((await file.stat()).size > 4096) throw new Error('oversized owner');
@@ -22,8 +22,8 @@ async function owner(directory) {
22
22
  }
23
23
 
24
24
  /** One publisher per canonical project, covering React and direct static imports. */
25
- export async function withBuildLock(directory, action) {
26
- const root = await realpath(directory), lock = path.join(root, LOCK);
25
+ export async function withBuildLock(directory, action, {development = false} = {}) {
26
+ const root = await realpath(directory), lock = path.join(root, development ? '.prnext-dev.lock' : LOCK);
27
27
  const identity = { pid: process.pid, host: hostname(), token: randomUUID(), createdAt: new Date().toISOString() };
28
28
  const prepared = path.join(root, `.prnext-lock-${identity.token}`);
29
29
  await mkdir(prepared, { mode: 0o700 });
@@ -37,16 +37,16 @@ export async function withBuildLock(directory, action) {
37
37
  try { previous = await owner(lock); }
38
38
  catch (cause) {
39
39
  if (cause.code === 'ENOENT' && attempt < 8) continue;
40
- throw busy('Cannot verify the build lock. Check .prnext-build.lock before retrying.');
40
+ throw busy(`Cannot verify the project lock. Check ${path.basename(lock)} before retrying.`);
41
41
  }
42
- if (previous.host !== identity.host || alive(previous.pid)) throw busy(`A build is already running for this project (PID ${previous.pid}). Wait for it to finish.`);
42
+ if (previous.host !== identity.host || alive(previous.pid)) throw busy(development ? `A development server is already running for this project (PID ${previous.pid}). Stop it before starting another.` : `A build is already running for this project (PID ${previous.pid}). Wait for it to finish.`);
43
43
  // Exactly one contender can recover this generation. Never delete a lock
44
44
  // merely because it is old: long builds and suspended processes are valid.
45
45
  let claim;
46
46
  try { claim = await open(path.join(lock, 'recovery'), 'wx', 0o600); }
47
47
  catch (cause) {
48
48
  if (cause.code === 'ENOENT' && attempt < 8) continue;
49
- throw busy('Build lock recovery is in progress. Retry shortly; if recovery was interrupted, check .prnext-build.lock manually.');
49
+ throw busy(`Project lock recovery is in progress. Retry shortly; if recovery was interrupted, check ${path.basename(lock)} manually.`);
50
50
  }
51
51
  const retired = path.join(root, `.prnext-lock-retired-${identity.token}`);
52
52
  try {
@@ -1,6 +1,9 @@
1
1
  import { build } from 'esbuild';
2
- import { lstat, realpath, readdir, writeFile, unlink } from 'node:fs/promises';
2
+ import { lstat, realpath, readdir, writeFile, unlink,readFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
+ import {createHash} from 'node:crypto';
5
+ import {version as esbuildVersion} from 'esbuild';
6
+ import {privateCacheDirectory,readCacheRecord,writeCacheRecord} from './persistent-cache.mjs';
4
7
 
5
8
  // Paths used by the native worker, generated application modules, prerender,
6
9
  // standalone tracing, compatibility modules and the runtime lifecycle contract.
@@ -12,7 +15,7 @@ export const runtimeEntryNames = Object.freeze([
12
15
  ].sort());
13
16
 
14
17
  /** Run once, after compiling application/Edge graphs and before any prerender. */
15
- export async function bundleRuntime(stage) {
18
+ export async function bundleRuntime(stage,{projectRoot}={}) {
16
19
  const resolvedStage = path.resolve(stage);
17
20
  if (!path.basename(resolvedStage).startsWith('.prnext-build-')) {
18
21
  throw new Error('Runtime bundling requires an unpublished PRNext build stage.');
@@ -34,11 +37,21 @@ export async function bundleRuntime(stage) {
34
37
  for (const name of runtimeEntryNames) {
35
38
  if (!originals.some(entry => entry.name === `${name}.mjs`)) throw new Error(`Missing runtime entry ${name}.mjs`);
36
39
  }
40
+ let directory,key,cached;
41
+ if(projectRoot){
42
+ directory=await privateCacheDirectory(projectRoot,'runtime-bundle-v1');
43
+ if(directory){
44
+ const hash=createHash('sha256').update(esbuildVersion).update(JSON.stringify(runtimeEntryNames)).update(await readFile(new URL('./bundle-runtime.mjs',import.meta.url)));
45
+ for(const entry of [...originals].sort((a,b)=>a.name.localeCompare(b.name)))hash.update(JSON.stringify([entry.name,createHash('sha256').update(await readFile(path.join(runtime,entry.name))).digest('hex')]));
46
+ key=hash.digest('hex');cached=await readCacheRecord(directory,key);
47
+ }
48
+ }
37
49
  // write:false makes compilation atomic with respect to the source runtime:
38
50
  // no files change if parsing or resolution fails. The virtual output is a
39
51
  // sibling so all relative paths have the same depth as the final runtime.
40
52
  const virtualOutput = path.join(resolvedStage, '.runtime-bundle');
41
- const result = await build({
53
+ const validCache=cached&&Array.isArray(cached.files)&&cached.files.length<=128&&cached.files.every(item=>Array.isArray(item)&&item.length===2&&/^[\w-]+\.mjs$/.test(item[0])&&typeof item[1]==='string')&&new Set(cached.files.map(([name])=>name)).size===cached.files.length&&runtimeEntryNames.every(name=>cached.files.some(([file])=>file===name+'.mjs'));
54
+ const result = validCache?null:await build({
42
55
  absWorkingDir: runtime,
43
56
  entryPoints: runtimeEntryNames.map(name => path.join(runtime, `${name}.mjs`)),
44
57
  outdir: virtualOutput,
@@ -62,13 +75,15 @@ export async function bundleRuntime(stage) {
62
75
  logLevel: 'silent',
63
76
  });
64
77
  const emitted = new Map();
65
- for (const file of result.outputFiles) {
78
+ if(validCache)for(const [name,contents]of cached.files)emitted.set(name,Buffer.from(contents));
79
+ for (const file of result?.outputFiles||[]) {
66
80
  if (path.dirname(file.path) !== virtualOutput || !file.path.endsWith('.mjs')) {
67
81
  throw new Error('Runtime bundler emitted a file outside its flat output directory.');
68
82
  }
69
83
  emitted.set(path.basename(file.path), file.contents);
70
84
  }
71
85
  if (runtimeEntryNames.some(name => !emitted.has(`${name}.mjs`))) throw new Error('Runtime bundle omitted a required entry.');
86
+ if(directory&&!validCache)await writeCacheRecord(directory,key,{files:[...emitted].map(([name,bytes])=>[name,Buffer.from(bytes).toString()])});
72
87
  // This is an unpublished, disposable stage. On any write failure buildProject
73
88
  // discards the stage and preserves the previous published build unchanged.
74
89
  for (const [name, contents] of emitted) await writeFile(path.join(runtime, name), contents);
@@ -77,6 +92,6 @@ export async function bundleRuntime(stage) {
77
92
  }
78
93
  // JSON, CJS, license files and the OG assets directory are never traversed or
79
94
  // removed. The caller writes the normal runtime contract after prerender.
80
- return { entryCount: runtimeEntryNames.length, moduleCount: emitted.size,
95
+ return { entryCount: runtimeEntryNames.length, moduleCount: emitted.size,cached:!!validCache,
81
96
  bytes: [...emitted.values()].reduce((sum, contents) => sum + contents.length, 0) };
82
97
  }