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