@thomas.f/prnext 0.1.16-alpha → 0.1.18-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AI.md +63 -0
- package/README.md +35 -503
- package/build/ADAPTERS.md +27 -2
- package/build/actions.mjs +6 -1
- package/build/analyze.mjs +340 -0
- package/build/app-config.mjs +20 -8
- package/build/app-static.mjs +41 -14
- package/build/app-styles.mjs +79 -0
- package/build/app.mjs +99 -31
- package/build/build-lock.mjs +6 -6
- package/build/bundle-runtime.mjs +39 -11
- package/build/cache-components.mjs +1 -1
- package/build/cache-handlers.mjs +1 -1
- package/build/check.mjs +56 -11
- 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 +51 -4
- package/build/concurrent.mjs +12 -0
- package/build/config.mjs +50 -13
- package/build/create.mjs +239 -0
- package/build/css-modules.mjs +65 -10
- package/build/deployment-assets.mjs +89 -0
- package/build/deployment-config.mjs +18 -0
- package/build/dev-compiler-health.mjs +11 -0
- package/build/dev-css-update.mjs +85 -0
- package/build/dev-dependencies.mjs +44 -0
- package/build/dev-discovery.mjs +28 -0
- package/build/dev-worker.mjs +14 -5
- package/build/dev.mjs +64 -6
- package/build/dynamic.mjs +1 -1
- package/build/edge.mjs +5 -4
- package/build/fonts.mjs +1 -1
- package/build/framework-imports.mjs +50 -0
- package/build/html-only-config.mjs +53 -0
- package/build/html-only-graph.mjs +67 -0
- package/build/incremental.mjs +8 -4
- package/build/index.mjs +140 -112
- package/build/inspect.mjs +3 -0
- package/build/inspection-summary.mjs +37 -4
- package/build/instrumentation.mjs +30 -0
- package/build/loader-cache.mjs +53 -26
- package/build/metadata-routes.mjs +3 -4
- package/build/migrate.mjs +60 -57
- package/build/migration-audit.mjs +1 -1
- package/build/native-type-config.mjs +37 -0
- package/build/npm-packages.mjs +3 -18
- package/build/package-manager.mjs +308 -0
- package/build/persistent-cache.mjs +112 -0
- package/build/precompress.mjs +62 -30
- package/build/prerender-cache.mjs +70 -0
- package/build/prerender-config.mjs +29 -0
- package/build/prerender-pages.mjs +100 -0
- package/build/prerender-pool.mjs +165 -0
- package/build/prerender-process-worker.mjs +58 -0
- package/build/prerender-worker.mjs +10 -0
- package/build/production-builder.mjs +129 -0
- package/build/react-compiler-report.mjs +225 -0
- package/build/react-compiler.mjs +139 -0
- package/build/runtime-contract.mjs +2 -2
- package/build/scan.mjs +4 -4
- package/build/source-analysis.mjs +42 -0
- package/build/static-adapter.mjs +13 -5
- package/build/static-export.mjs +4 -4
- package/build/static-input.mjs +48 -0
- package/build/styles.mjs +17 -3
- package/build/transform-cache.mjs +37 -4
- package/build/transform.mjs +1 -1
- package/build/type-command.mjs +41 -0
- package/build/typed-routes.mjs +95 -0
- package/build/typescript-dev-worker.mjs +18 -0
- package/build/typescript-dev.mjs +49 -0
- 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-watch.mjs +22 -0
- package/build/typescript.mjs +11 -8
- package/build/webpack-graph.mjs +170 -30
- package/build/worker-memory.mjs +9 -0
- package/build/worker-preload.mjs +9 -0
- package/cli.mjs +162 -122
- package/compat/activity-cache.cjs +26 -0
- package/compat/after.cjs +6 -4
- package/compat/app-layout-context.cjs +52 -2
- package/compat/base64.cjs +10 -0
- package/compat/cache-handlers.cjs +12 -2
- 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 +150 -0
- package/compat/headers.cjs +4 -2
- package/compat/incremental-cache.cjs +20 -7
- package/compat/index.d.ts +89 -10
- package/compat/link.cjs +12 -9
- package/compat/link.d.cts +4 -3
- package/compat/navigation-authorization.cjs +9 -0
- package/compat/navigation-stages.cjs +19 -0
- package/compat/navigation.d.cts +4 -3
- 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/router.d.cts +4 -3
- package/compat/server-primitives.cjs +31 -11
- package/compat/server.d.cts +15 -2
- package/compat/static-generation.cjs +7 -0
- package/compat/typed-route.d.cts +17 -0
- package/compat/use-cache.cjs +3 -0
- package/docs/ai/compatibility.md +275 -0
- package/docs/ai/data.md +288 -0
- package/docs/ai/deployment.md +269 -0
- package/docs/ai/manifest.json +11 -0
- package/docs/ai/quickstart.md +171 -0
- package/docs/ai/routing.md +214 -0
- package/docs/ai/troubleshooting.md +155 -0
- package/index.mjs +2 -0
- package/native/diagnostics.mjs +34 -0
- package/native/resolve.mjs +7 -2
- package/package.json +44 -7
- package/release/ai-docs.mjs +48 -0
- package/release/verify.mjs +2 -0
- package/runtime/action-client.mjs +10 -4
- package/runtime/action-server.mjs +2 -0
- package/runtime/ai-docs.mjs +202 -0
- package/runtime/app-client.mjs +226 -68
- 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 +100 -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/cache-components-rsc.mjs +4 -1
- package/runtime/cache-request.mjs +2 -1
- package/runtime/cloud/archive.mjs +5 -6
- package/runtime/compile-cache.mjs +8 -0
- package/runtime/deployment-client.mjs +19 -0
- package/runtime/dev-ai-brands.mjs +26 -0
- package/runtime/dev-ai-style.mjs +99 -0
- package/runtime/dev-build-reasons.mjs +25 -0
- package/runtime/dev-cache-control.mjs +53 -0
- package/runtime/dev-client.mjs +245 -46
- package/runtime/dev-compiler-broker.mjs +42 -0
- package/runtime/dev-console-copy.mjs +19 -0
- package/runtime/dev-console.mjs +106 -0
- package/runtime/dev-dependency-graph.mjs +40 -0
- package/runtime/dev-element-picker-style.mjs +27 -0
- package/runtime/dev-element-picker.mjs +213 -0
- package/runtime/dev-error-overlay-style.mjs +41 -0
- package/runtime/dev-error-overlay.mjs +167 -0
- package/runtime/dev-error-report.mjs +121 -0
- package/runtime/dev-file-changes.mjs +1 -1
- package/runtime/dev-gateway.mjs +44 -0
- package/runtime/dev-host-relay.mjs +66 -0
- package/runtime/dev-indicator.mjs +191 -0
- package/runtime/dev-issue-motion.mjs +214 -0
- package/runtime/dev-lazy-tool.mjs +34 -0
- package/runtime/dev-logo.mjs +2 -0
- package/runtime/dev-notification.mjs +81 -0
- package/runtime/dev-popout.mjs +34 -0
- package/runtime/dev-position.mjs +107 -0
- package/runtime/dev-preview-touch.mjs +122 -0
- package/runtime/dev-runtime-pool.mjs +45 -0
- package/runtime/dev-runtime-spare.mjs +57 -0
- package/runtime/dev-tools-client.mjs +155 -0
- package/runtime/dev-tools-mcp.mjs +126 -0
- package/runtime/dev-tools-metrics.mjs +31 -0
- package/runtime/dev-tools-model.mjs +42 -0
- package/runtime/dev-tools-panel.mjs +341 -0
- package/runtime/dev-tools-result.mjs +37 -0
- package/runtime/dev-tools-server.mjs +256 -0
- package/runtime/dev-tools-source.mjs +104 -0
- package/runtime/dev-tooltip.mjs +94 -0
- package/runtime/dev-ui-icons.mjs +8 -0
- package/runtime/dev-workbench-style.mjs +106 -0
- package/runtime/dev-workbench.mjs +428 -0
- package/runtime/dev-worker-logs.mjs +31 -0
- package/runtime/development.mjs +324 -0
- package/runtime/document.mjs +1 -1
- package/runtime/edge.mjs +6 -5
- package/runtime/env.mjs +2 -1
- package/runtime/fetch-cache.mjs +10 -8
- package/runtime/flight-delta.mjs +50 -0
- package/runtime/flight-tap.mjs +28 -0
- 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/navigation-recovery.mjs +36 -0
- package/runtime/navigation.mjs +7 -0
- package/runtime/node-html-stream.mjs +47 -0
- package/runtime/pages-client.mjs +24 -8
- package/runtime/pages-navigation.mjs +54 -64
- package/runtime/persistent/client.mjs +6 -5
- package/runtime/persistent/daemon.mjs +66 -56
- package/runtime/persistent/native-proxy.mjs +63 -0
- package/runtime/prefetch-scheduler.mjs +110 -0
- package/runtime/preload.mjs +12 -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 +41 -12
- package/runtime/trace.mjs +78 -0
- package/runtime/typed-route.mjs +19 -0
- package/runtime/watch-project.mjs +29 -18
- package/runtime/worker.mjs +102 -24
package/build/ADAPTERS.md
CHANGED
|
@@ -39,6 +39,31 @@ its implementation-specific fields are not made stable by this public envelope.
|
|
|
39
39
|
|
|
40
40
|
## Static adapter
|
|
41
41
|
|
|
42
|
+
Plain HTML/CSS/JavaScript sites need no package.json or React source tree:
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
prnext build ./my-site
|
|
46
|
+
prnext start ./my-site
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
An `index.html` at the project root is detected automatically. Explicitly select
|
|
50
|
+
it with `prnext build --adapter static --from .`. Root mode copies browser asset
|
|
51
|
+
extensions only; it omits dotfiles, npm metadata, JSON data, source/server folders,
|
|
52
|
+
configuration and server scripts. Put additional public data in a dedicated output
|
|
53
|
+
directory and import it with `--from`; do not put secrets in browser assets.
|
|
54
|
+
|
|
55
|
+
After running another builder, `prnext build --adapter auto` finds a completed
|
|
56
|
+
`dist`, `build`, `out` or `.output/public` directory. Multiple outputs require an
|
|
57
|
+
explicit `--from` choice. This command never runs another builder or its config.
|
|
58
|
+
Vite (including React, Vue, Svelte and Solid browser apps), Astro static output,
|
|
59
|
+
SvelteKit with `adapter-static`, and Nuxt generated output can use the same static
|
|
60
|
+
runtime. Keep each framework's server rendering and API routes out of the import.
|
|
61
|
+
See [Vite deployment](https://vite.dev/guide/static-deploy.html),
|
|
62
|
+
[Astro deployment](https://docs.astro.build/en/guides/deploy/),
|
|
63
|
+
[SvelteKit static generation](https://svelte.dev/docs/kit/adapter-static), and
|
|
64
|
+
[Nuxt deployment](https://nuxt.com/docs/4.x/getting-started/deployment).
|
|
65
|
+
|
|
66
|
+
|
|
42
67
|
Build the application with its own tool, then import **only** its public browser
|
|
43
68
|
output. Vite's default output is `dist`; see its [deployment guide](https://vite.dev/guide/static-deploy.html).
|
|
44
69
|
|
|
@@ -87,7 +112,7 @@ dotted client route names need explicit HTML output for those URLs. Configure th
|
|
|
87
112
|
source builder's asset base for its deployed URL; the adapter does not rewrite JS
|
|
88
113
|
or HTML assets or infer subpath hosting configuration.
|
|
89
114
|
|
|
90
|
-
|
|
115
|
+
Dedicated public source directories must be inside the project, separate from the destination,
|
|
91
116
|
with real directories and files. Symlinks, dotfiles other than a root `.well-known`
|
|
92
117
|
tree, `node_modules`, reserved `_next`/`_prnext` paths and a root `server` directory
|
|
93
118
|
are rejected. Limits are 20,000 files, 512 MiB total and 64 directory levels.
|
|
@@ -114,7 +139,7 @@ change existing PRNext Host projects or enable generic SSR hosting.
|
|
|
114
139
|
|
|
115
140
|
## Extending adapters
|
|
116
141
|
|
|
117
|
-
The
|
|
142
|
+
The output adapter names are `react` and `static`. Build selection also accepts `auto`, which inspects completed output and never starts a third-party builder. Unknown adapters fail rather
|
|
118
143
|
than being silently interpreted as React. Add a new adapter only with a documented
|
|
119
144
|
runtime model and integration tests. A future generic Node SSR adapter needs its
|
|
120
145
|
own request/response, streaming, cancellation, crash and shutdown contract; a
|
package/build/actions.mjs
CHANGED
|
@@ -44,7 +44,7 @@ export function createServerActions({ projectRoot }) {
|
|
|
44
44
|
const id=createHmac('sha256',templateSalt).update(name).digest('hex');
|
|
45
45
|
registrations.push({name,binding,id});return id;
|
|
46
46
|
}
|
|
47
|
-
const ast=parse(source,{sourceType:'unambiguous',sourceFilename:file,plugins:['jsx',...(/\.tsx?$/.test(file)?['typescript']:[])]});
|
|
47
|
+
const ast=parse(source,{sourceType:'unambiguous',sourceFilename:file,plugins:['jsx',...(/\.[cm]?tsx?$/.test(file)?['typescript']:[])]});
|
|
48
48
|
const moduleServer=hasDirective(ast.program,'use server');
|
|
49
49
|
const moduleClient=hasDirective(ast.program,'use client');
|
|
50
50
|
if(moduleServer&&moduleClient) throw new Error(`A module cannot use both 'use client' and 'use server': ${file}`);
|
|
@@ -202,6 +202,11 @@ export function createServerActions({ projectRoot }) {
|
|
|
202
202
|
|
|
203
203
|
return {
|
|
204
204
|
actions, actionKey,
|
|
205
|
+
// Only compiler-generated module-level action proxies bypass React Refresh.
|
|
206
|
+
// Their random reference IDs change each build; they are not component exports.
|
|
207
|
+
isServerModule(source,file) {
|
|
208
|
+
return source.includes('use server') && analyze(source,file).moduleServer === true;
|
|
209
|
+
},
|
|
205
210
|
transform(source,file,mode) {
|
|
206
211
|
if(!source.includes('use server')) return source;
|
|
207
212
|
const result=analyze(source,file);
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import {readFile,writeFile,stat,open} from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {createHash} from 'node:crypto';
|
|
4
|
+
import {gzip,brotliCompress,constants} from 'node:zlib';
|
|
5
|
+
import {promisify} from 'node:util';
|
|
6
|
+
import {mapConcurrent} from './concurrent.mjs';
|
|
7
|
+
import {readBuildDirectory} from '../runtime/build-directory.mjs';
|
|
8
|
+
import {fileURLToPath} from 'node:url';
|
|
9
|
+
|
|
10
|
+
// These imports are awaited before App hydration. esbuild correctly describes
|
|
11
|
+
// them as dynamic imports, but they are not deferred by user interaction.
|
|
12
|
+
const appClient=fileURLToPath(new URL('../runtime/app-client.mjs',import.meta.url));
|
|
13
|
+
const hydrationImports=new Map([
|
|
14
|
+
['react-server-dom-webpack/client.browser','always'],
|
|
15
|
+
['./app-partial-client.mjs','cache-components'],
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
export function recordBuildAnalysis(state,options,result){
|
|
19
|
+
if(!state?.stage||!result.metafile)return;
|
|
20
|
+
state.analysis ||= new Map();
|
|
21
|
+
state.analysisRoutes ||= new Map();
|
|
22
|
+
const outputs=new Map(Object.entries(result.metafile.outputs).map(([file,info])=>[path.resolve(options.absWorkingDir||state.root,file),info]));
|
|
23
|
+
const working=options.absWorkingDir||state.root;
|
|
24
|
+
const entries=new Map([...outputs].filter(([,info])=>info.entryPoint).map(([file,info])=>[path.resolve(working,info.entryPoint),file]));
|
|
25
|
+
for(const [absolute,info] of outputs){
|
|
26
|
+
const relative=path.relative(state.stage,absolute).replaceAll(path.sep,'/');
|
|
27
|
+
if(!info.entryPoint||!relative.startsWith('server/'))continue;
|
|
28
|
+
const visited=new Set(),inputs=new Set(),pending=[absolute];
|
|
29
|
+
while(pending.length){
|
|
30
|
+
const next=pending.pop();if(visited.has(next))continue;visited.add(next);
|
|
31
|
+
const output=outputs.get(next);if(!output)continue;
|
|
32
|
+
for(const name of Object.keys(output.inputs||{}))inputs.add(path.relative(state.root,path.resolve(options.absWorkingDir||state.root,name)).replaceAll(path.sep,'/'));
|
|
33
|
+
for(const item of output.imports||[])if(!item.external)pending.push(path.resolve(options.absWorkingDir||state.root,item.path));
|
|
34
|
+
}
|
|
35
|
+
state.analysisRoutes.set(relative,inputs);
|
|
36
|
+
}
|
|
37
|
+
for(const [file,info] of Object.entries(result.metafile.outputs)){
|
|
38
|
+
const absolute=path.resolve(options.absWorkingDir||state.root,file);
|
|
39
|
+
const relative=path.relative(path.join(state.stage,'assets'),absolute).replaceAll(path.sep,'/');
|
|
40
|
+
if(relative.startsWith('../')||path.isAbsolute(relative)||!relative.endsWith('.js'))continue;
|
|
41
|
+
const imports=(info.imports||[]).filter(item=>!item.external).map(item=>({file:path.relative(path.join(state.stage,'assets'),path.resolve(working,item.path)).replaceAll(path.sep,'/'),kind:item.kind}));
|
|
42
|
+
for(const name of Object.keys(info.inputs||{})){
|
|
43
|
+
const source=path.resolve(working,name);
|
|
44
|
+
for(const input of result.metafile.inputs[name]?.imports||[]){
|
|
45
|
+
if(input.kind!=='dynamic-import'||input.external)continue;
|
|
46
|
+
const target=entries.get(path.resolve(working,input.path));if(!target)continue;
|
|
47
|
+
const asset=relativeAsset(state.stage,target);
|
|
48
|
+
const edge=imports.find(item=>item.file===asset&&item.kind==='dynamic-import');
|
|
49
|
+
if(!edge)continue;
|
|
50
|
+
const id='dynamic-'+createHash('sha256').update(relativePath(state.root,source)+'\0'+input.original).digest('hex').slice(0,20);
|
|
51
|
+
(edge.dynamicIds||=[]).push(id);
|
|
52
|
+
if(source===appClient&&hydrationImports.has(input.original))edge.initial=hydrationImports.get(input.original);
|
|
53
|
+
else if(source===path.join(state.stage,'.entries','app-runtime.mjs'))edge.initial='flight-reference';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for(const edge of imports)if(edge.dynamicIds)edge.dynamicIds=[...new Set(edge.dynamicIds)].sort();
|
|
57
|
+
state.analysis.set(relative,{file:relative,bytes:info.bytes,imports,...(result.metafile.initialGraphComplete===false?{initialIncomplete:true}:{}),
|
|
58
|
+
modules:Object.entries(info.inputs||{}).map(([name,record])=>({file:path.relative(state.root,path.resolve(options.absWorkingDir||state.root,name)).replaceAll(path.sep,'/').replace(/\.prnext-build-[^/]+\//g,'<generated>/'),bytes:record.bytesInOutput})).sort((a,b)=>b.bytes-a.bytes).slice(0,100)});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function relativePath(root,file){return path.relative(root,file).replaceAll(path.sep,'/');}
|
|
62
|
+
function relativeAsset(stage,file){return relativePath(path.join(stage,'assets'),file);}
|
|
63
|
+
function initialFiles(entry,files,cacheComponents=false){
|
|
64
|
+
const seen=new Set(),pending=[entry];
|
|
65
|
+
while(pending.length){const name=pending.pop();if(seen.has(name)||!files.has(name))continue;seen.add(name);for(const item of files.get(name).imports)if(item.kind!=='dynamic-import'||item.initial==='always'||item.initial==='cache-components'&&cacheComponents)pending.push(item.file);}
|
|
66
|
+
return [...seen].sort();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function initialSample(stage,seed,route,manifest){
|
|
70
|
+
// These documents were validated before publication and intentionally have
|
|
71
|
+
// no Flight sidecar. Its absence is exact zero JS, not missing coverage.
|
|
72
|
+
if(route.htmlOnly)return {entries:[],ids:[]};
|
|
73
|
+
if(route.router==='app'){
|
|
74
|
+
if(!seed.dataFile)return null;
|
|
75
|
+
const data=await readBoundedText(path.join(stage,seed.dataFile));
|
|
76
|
+
if(data===null)return null;
|
|
77
|
+
const entries=[],rows=new Map(),imports=[];
|
|
78
|
+
// Read only official import rows; do not execute, decode or rewrite Flight.
|
|
79
|
+
for(const line of data.split('\n')){
|
|
80
|
+
// Flight also carries length-framed raw/binary rows. Their bytes may
|
|
81
|
+
// contain newlines that resemble import rows; do not parse such a stream
|
|
82
|
+
// as newline-delimited JSON or claim complete hydration coverage for it.
|
|
83
|
+
if(/^[\da-f]+:[TAOobUSsLlGgMmV]/.test(line))return null;
|
|
84
|
+
const row=/^([\da-f]+):(I?)(.*)$/.exec(line);if(!row)continue;
|
|
85
|
+
let value;try{value=JSON.parse(row[3]);}catch{if(row[2])return null;continue;}
|
|
86
|
+
if(row[2])imports.push(value);else if(typeof value==='string')rows.set(row[1],value);
|
|
87
|
+
}
|
|
88
|
+
for(const value of imports){
|
|
89
|
+
const reference=value?.[0];
|
|
90
|
+
const id=typeof reference==='string'&&/^\$[\da-f]+$/.test(reference)?rows.get(reference.slice(1)):reference;
|
|
91
|
+
const module=manifest.app?.clientModules?.[id]?.browserModule;
|
|
92
|
+
if(!module)return null;
|
|
93
|
+
entries.push(assetName(module));
|
|
94
|
+
}
|
|
95
|
+
return {entries,ids:[]};
|
|
96
|
+
}
|
|
97
|
+
const html=await readBoundedText(path.join(stage,seed.file));
|
|
98
|
+
if(html===null)return null;
|
|
99
|
+
const match=/window\.__PRNEXT_DATA__=JSON\.parse\(("(?:\\.|[^"\\])*")\);/.exec(html);
|
|
100
|
+
if(!match)return null;
|
|
101
|
+
try{const value=JSON.parse(JSON.parse(match[1]));return Array.isArray(value.dynamicIds)?{entries:[],ids:value.dynamicIds}:null;}catch{return null;}
|
|
102
|
+
}
|
|
103
|
+
async function readBoundedText(file){
|
|
104
|
+
const handle=await open(file,'r');try{
|
|
105
|
+
const info=await handle.stat(),size=info.size;if(!info.isFile()||size>16*1024*1024)return null;
|
|
106
|
+
const data=Buffer.alloc(size+1);let length=0;
|
|
107
|
+
while(length<data.length){const result=await handle.read(data,length,data.length-length,null);if(!result.bytesRead)break;length+=result.bytesRead;}
|
|
108
|
+
if(length>size)return null;
|
|
109
|
+
return data.subarray(0,length).toString('utf8');
|
|
110
|
+
}finally{await handle.close();}
|
|
111
|
+
}
|
|
112
|
+
function assetName(url){return decodeURIComponent(new URL(url,'http://local').pathname).split('/').pop();}
|
|
113
|
+
const gzipAsync=promisify(gzip),brotliAsync=promisify(brotliCompress);
|
|
114
|
+
export async function writeBuildAnalysis({root,stage,destination,manifest,records,routeInputs,precompressed=false}){
|
|
115
|
+
const files=new Map(records||[]);
|
|
116
|
+
await mapConcurrent(files.values(),async info=>{
|
|
117
|
+
const file=path.join(stage,'assets',info.file),data=await readFile(file);
|
|
118
|
+
info.bytes=data.length;
|
|
119
|
+
// Sidecars in this unpublished stage have just passed decode/hash validation.
|
|
120
|
+
// Never trust arbitrary sidecars when analysis is called independently.
|
|
121
|
+
const size=async(extension,compress)=>{
|
|
122
|
+
if(precompressed)try{return(await stat(file+extension)).size;}catch(error){if(error.code!=='ENOENT')throw error;}
|
|
123
|
+
return(await compress()).length;
|
|
124
|
+
};
|
|
125
|
+
info.gzipBytes=await size('.gz',()=>gzipAsync(data));
|
|
126
|
+
info.brotliBytes=await size('.br',()=>brotliAsync(data,{params:{[constants.BROTLI_PARAM_QUALITY]:5}}));
|
|
127
|
+
});
|
|
128
|
+
const dynamicEntries=new Map();
|
|
129
|
+
for(const info of files.values())for(const edge of info.imports)for(const id of edge.dynamicIds||[]){
|
|
130
|
+
if(!dynamicEntries.has(id))dynamicEntries.set(id,new Set());dynamicEntries.get(id).add(edge.file);
|
|
131
|
+
}
|
|
132
|
+
const seedsByRoute=new Map();
|
|
133
|
+
for(const seed of manifest.prerendered||[])if(!seed.status||seed.status<300){
|
|
134
|
+
if(!seedsByRoute.has(seed.routeId))seedsByRoute.set(seed.routeId,[]);seedsByRoute.get(seed.routeId).push(seed);
|
|
135
|
+
}
|
|
136
|
+
const routes=await mapConcurrent(manifest.routes.filter(route=>!route.internal&&route.kind==='page'),async route=>{
|
|
137
|
+
const client=route.client?assetName(route.client):null;
|
|
138
|
+
const baseEntries=client?[client]:[];
|
|
139
|
+
const initialAssets=entries=>[...new Set(entries.flatMap(entry=>initialFiles(entry,files,manifest.config?.cacheComponents)))].sort();
|
|
140
|
+
const costs=assets=>({jsBytes:assets.reduce((total,file)=>total+files.get(file).bytes,0),gzipBytes:assets.reduce((total,file)=>total+files.get(file).gzipBytes,0),brotliBytes:assets.reduce((total,file)=>total+files.get(file).brotliBytes,0)});
|
|
141
|
+
const potential=[];
|
|
142
|
+
if(route.router==='app')for(const file of routeInputs?.get(route.module)||[]){
|
|
143
|
+
const id='client-'+createHash('sha256').update(file).digest('hex').slice(0,16);
|
|
144
|
+
const entry=manifest.app?.clientModules?.[id]?.browserModule;
|
|
145
|
+
if(entry)potential.push(assetName(entry));
|
|
146
|
+
}
|
|
147
|
+
const seeds=seedsByRoute.get(route.id)||[];
|
|
148
|
+
const samples=[];let failedSample=false;
|
|
149
|
+
for(const seed of seeds){
|
|
150
|
+
const sample=await initialSample(stage,seed,route,manifest);
|
|
151
|
+
if(!sample){failedSample=true;continue;}
|
|
152
|
+
const entries=[...baseEntries,...sample.entries];
|
|
153
|
+
for(const id of sample.ids)entries.push(...dynamicEntries.get(id)||[]);
|
|
154
|
+
const assets=initialAssets(entries);
|
|
155
|
+
samples.push({path:seed.path,files:assets,...costs(assets)});
|
|
156
|
+
}
|
|
157
|
+
// A route graph cannot reveal which conditional components request data
|
|
158
|
+
// selects. Never turn its lower bound into a successful initial-JS budget.
|
|
159
|
+
const runtime=route.ssp||route.gip||route.appGip||route.ppr||route.pprGeneric||
|
|
160
|
+
route.pattern.includes('[')&&route.fallback!==false||seeds.some(seed=>typeof seed.revalidate==='number');
|
|
161
|
+
const baseFiles=[...new Set([...initialAssets(baseEntries),...samples.flatMap(sample=>sample.files)])].sort();
|
|
162
|
+
const hasDynamic=(samples.length?samples:[{files:baseFiles}]).some(sample=>{
|
|
163
|
+
const known=new Set(sample.files);
|
|
164
|
+
return sample.files.some(file=>files.get(file).imports.some(edge=>edge.kind==='dynamic-import'&&!edge.initial&&!known.has(edge.file)));
|
|
165
|
+
});
|
|
166
|
+
const unknown=!!runtime||failedSample||(!samples.length&&potential.length>0)||hasDynamic||baseFiles.some(file=>files.get(file).initialIncomplete);
|
|
167
|
+
const values=samples.length?samples:[costs(baseFiles)];
|
|
168
|
+
// Route files are a union for inspection. Each budget metric is the actual
|
|
169
|
+
// largest sampled path, never the sum of mutually exclusive path chunks.
|
|
170
|
+
return {route:route.pattern,files:baseFiles,initial:{status:unknown?'request-dependent':samples.length?'prerendered':'static-graph',samplePaths:samples.map(sample=>sample.path),samples},
|
|
171
|
+
...Object.fromEntries(['jsBytes','gzipBytes','brotliBytes'].map(key=>[key,Math.max(...values.map(value=>value[key]))]))};
|
|
172
|
+
});
|
|
173
|
+
const report={version:2,buildId:manifest.buildId,routes,files:[...files.values()],totalJsBytes:[...files.values()].reduce((n,item)=>n+item.bytes,0)};
|
|
174
|
+
try{const previous=await readReport(path.join(destination,'build-analysis.json'));if(previous.version===2)report.previous={version:2,buildId:previous.buildId,totalJsBytes:previous.totalJsBytes,routes:previous.routes.map(({route,initial,jsBytes,gzipBytes,brotliBytes})=>({route,initial,jsBytes,gzipBytes,brotliBytes}))};else report.comparisonUnavailable='Initial asset accounting changed; rebuild the baseline with this PRNext version.';}catch(error){if(error.code!=='ENOENT')console.warn('Previous bundle analysis could not be read; comparison unavailable.');}
|
|
175
|
+
await writeFile(path.join(stage,'build-analysis.json'),JSON.stringify(report));
|
|
176
|
+
return report;
|
|
177
|
+
}
|
|
178
|
+
const metrics={raw:'jsBytes',gzip:'gzipBytes',brotli:'brotliBytes'};
|
|
179
|
+
const size=value=>Number.isSafeInteger(value)&&value>=0;
|
|
180
|
+
const text=value=>typeof value==='string'&&value.length>0&&value.length<=4096&&!/[\0\r\n]/.test(value);
|
|
181
|
+
const object=value=>value!==null&&typeof value==='object'&&!Array.isArray(value);
|
|
182
|
+
async function boundedJson(file,label){
|
|
183
|
+
const handle=await open(file,'r'),limit=16*1024*1024;
|
|
184
|
+
try{
|
|
185
|
+
const info=await handle.stat();
|
|
186
|
+
if(!info.isFile()||info.size>limit)throw new Error(`${label} must be a regular JSON file of at most 16 MiB.`);
|
|
187
|
+
// Fixed allocation also bounds reads when a concurrent writer grows it.
|
|
188
|
+
const data=Buffer.alloc(limit+1);let length=0;
|
|
189
|
+
while(length<data.length){const {bytesRead}=await handle.read(data,length,data.length-length,null);if(!bytesRead)break;length+=bytesRead;}
|
|
190
|
+
if(length>limit)throw new Error(`${label} exceeds 16 MiB.`);
|
|
191
|
+
try{return JSON.parse(data.subarray(0,length).toString('utf8'));}catch{throw new Error(`${label} contains invalid JSON.`);}
|
|
192
|
+
}finally{await handle.close();}
|
|
193
|
+
}
|
|
194
|
+
function validateRoutes(routes,{complete=true}={}){
|
|
195
|
+
if(!Array.isArray(routes)||routes.length>10000)throw new Error('Invalid analysis routes (maximum 10,000).');
|
|
196
|
+
const seen=new Set();
|
|
197
|
+
for(const route of routes){
|
|
198
|
+
if(!object(route)||!text(route.route)||!route.route.startsWith('/')||seen.has(route.route)||!size(route.jsBytes))throw new Error('Invalid or duplicate route in bundle analysis.');
|
|
199
|
+
seen.add(route.route);
|
|
200
|
+
for(const key of ['gzipBytes','brotliBytes'])if((complete||route[key]!==undefined)&&!size(route[key]))throw new Error(`Invalid ${key} in bundle analysis.`);
|
|
201
|
+
if(route.initial!==undefined){
|
|
202
|
+
const initial=route.initial;
|
|
203
|
+
if(!object(initial)||!['prerendered','static-graph','request-dependent'].includes(initial.status)||!Array.isArray(initial.samplePaths)||initial.samplePaths.length>10000||initial.samplePaths.some(value=>!text(value))||!Array.isArray(initial.samples)||initial.samples.length>10000)throw new Error('Invalid initial asset coverage in bundle analysis.');
|
|
204
|
+
for(const sample of initial.samples)if(!object(sample)||!text(sample.path)||!Array.isArray(sample.files)||sample.files.length>100000||sample.files.some(file=>!text(file))||['jsBytes','gzipBytes','brotliBytes'].some(key=>!size(sample[key])))throw new Error('Invalid initial asset sample in bundle analysis.');
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function validateInitialTotals(value){
|
|
209
|
+
const files=new Map();
|
|
210
|
+
for(const file of value.files){if(files.has(file.file))throw new Error('Duplicate asset in bundle analysis.');files.set(file.file,file);}
|
|
211
|
+
const fields={jsBytes:'bytes',gzipBytes:'gzipBytes',brotliBytes:'brotliBytes'};
|
|
212
|
+
const sum=(names,key)=>names.reduce((total,name)=>{
|
|
213
|
+
const file=files.get(name);if(!file)throw new Error('Unknown asset in initial bundle analysis.');
|
|
214
|
+
const result=total+file[fields[key]];if(!size(result))throw new Error('Initial asset totals exceed the supported range.');return result;
|
|
215
|
+
},0);
|
|
216
|
+
const unique=names=>new Set(names).size===names.length;
|
|
217
|
+
for(const route of value.routes){
|
|
218
|
+
if(!unique(route.files))throw new Error('Duplicate route asset in bundle analysis.');
|
|
219
|
+
const routeFiles=new Set(route.files);
|
|
220
|
+
const {initial}=route,samples=initial.samples;
|
|
221
|
+
if(!unique(initial.samplePaths)||initial.samplePaths.length!==samples.length||samples.some((sample,index)=>sample.path!==initial.samplePaths[index]))throw new Error('Initial sample paths do not match their measurements.');
|
|
222
|
+
if(initial.status==='prerendered'&&!samples.length||initial.status==='static-graph'&&samples.length)throw new Error('Invalid initial sample coverage status.');
|
|
223
|
+
for(const name of route.files)if(!files.has(name))throw new Error('Unknown route asset in bundle analysis.');
|
|
224
|
+
for(const sample of samples){
|
|
225
|
+
if(!unique(sample.files)||sample.files.some(file=>!routeFiles.has(file)))throw new Error('Invalid or duplicate sampled asset in bundle analysis.');
|
|
226
|
+
for(const key of Object.keys(fields))if(sum(sample.files,key)!==sample[key])throw new Error('Initial sample totals do not match their assets.');
|
|
227
|
+
}
|
|
228
|
+
const union=new Set(samples.flatMap(sample=>sample.files));
|
|
229
|
+
if(samples.length&&(union.size!==route.files.length||route.files.some(file=>!union.has(file))))throw new Error('Route asset union does not match its initial samples.');
|
|
230
|
+
for(const key of Object.keys(fields)){
|
|
231
|
+
const expected=samples.length?Math.max(...samples.map(sample=>sample[key])):sum(route.files,key);
|
|
232
|
+
if(expected!==route[key])throw new Error('Initial route totals do not match their samples.');
|
|
233
|
+
}
|
|
234
|
+
if(initial.status!=='request-dependent'&&route.files.some(file=>files.get(file).initialIncomplete))throw new Error('Incomplete metadata cannot have exact initial asset coverage.');
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
async function readReport(file){
|
|
238
|
+
const value=await boundedJson(file,'Bundle analysis');
|
|
239
|
+
if(!object(value)||![1,2].includes(value.version)||!text(value.buildId)||!size(value.totalJsBytes)||!Array.isArray(value.files)||value.files.length>100000)throw new Error('Invalid bundle analysis; rebuild with this PRNext version.');
|
|
240
|
+
validateRoutes(value.routes);
|
|
241
|
+
for(const route of value.routes)if(!Array.isArray(route.files)||route.files.length>100000||route.files.some(file=>!text(file)))throw new Error('Invalid route asset list in bundle analysis.');
|
|
242
|
+
for(const file of value.files){
|
|
243
|
+
if(!object(file)||!text(file.file)||!size(file.bytes)||!size(file.gzipBytes)||!size(file.brotliBytes)||file.initialIncomplete!==undefined&&typeof file.initialIncomplete!=='boolean'||!Array.isArray(file.modules)||file.modules.length>100||file.modules.some(module=>!object(module)||!text(module.file)||!size(module.bytes))||!Array.isArray(file.imports)||file.imports.length>100000||file.imports.some(item=>!object(item)||!text(item.file)||!text(item.kind)||item.initial!==undefined&&!['always','cache-components','flight-reference'].includes(item.initial)||item.dynamicIds!==undefined&&(!Array.isArray(item.dynamicIds)||item.dynamicIds.length>100000||item.dynamicIds.some(id=>!/^dynamic-[a-f\d]{20}$/.test(id)))))throw new Error('Invalid file, module or import in bundle analysis.');
|
|
244
|
+
}
|
|
245
|
+
if(value.previous!==undefined){
|
|
246
|
+
if(!object(value.previous)||!text(value.previous.buildId)||value.previous.totalJsBytes!==undefined&&!size(value.previous.totalJsBytes)||value.version===2&&value.previous.version!==2)throw new Error('Invalid previous bundle analysis.');
|
|
247
|
+
validateRoutes(value.previous.routes,{complete:false});
|
|
248
|
+
}
|
|
249
|
+
if(value.version===2){
|
|
250
|
+
if(value.routes.some(route=>!route.initial)||value.previous?.routes.some(route=>!route.initial))throw new Error('Missing initial asset coverage in bundle analysis.');
|
|
251
|
+
validateInitialTotals(value);
|
|
252
|
+
}
|
|
253
|
+
// Retain only the versioned schema, rather than echoing arbitrary nested
|
|
254
|
+
// properties from a supplied baseline into a machine-readable report.
|
|
255
|
+
const cleanInitial=initial=>initial?{status:initial.status,samplePaths:[...initial.samplePaths],samples:initial.samples.map(({path,files,jsBytes,gzipBytes,brotliBytes})=>({path,files:[...files],jsBytes,gzipBytes,brotliBytes}))}:undefined;
|
|
256
|
+
const cleanRoute=({route,files,initial,jsBytes,gzipBytes,brotliBytes})=>({route,files,...(initial?{initial:cleanInitial(initial)}:{}),jsBytes,gzipBytes,brotliBytes});
|
|
257
|
+
if(value.version===2&&value.routes.some(route=>!route.initial)||value.previous&&value.version===2&&value.previous.routes.some(route=>!route.initial))throw new Error('Missing initial asset coverage in bundle analysis.');
|
|
258
|
+
return {version:value.version,buildId:value.buildId,totalJsBytes:value.totalJsBytes,routes:value.routes.map(cleanRoute),
|
|
259
|
+
files:value.files.map(({file,bytes,gzipBytes,brotliBytes,imports,modules,initialIncomplete})=>({file,bytes,gzipBytes,brotliBytes,...(initialIncomplete?{initialIncomplete:true}:{}),imports:imports.map(({file,kind,initial,dynamicIds})=>({file,kind,...(initial?{initial}:{}),...(dynamicIds?{dynamicIds:[...dynamicIds]}:{})})),modules:modules.map(({file,bytes})=>({file,bytes}))})),
|
|
260
|
+
...(text(value.comparisonUnavailable)?{comparisonUnavailable:value.comparisonUnavailable}:{}),
|
|
261
|
+
...(value.previous?{previous:{version:value.previous.version||1,buildId:value.previous.buildId,totalJsBytes:value.previous.totalJsBytes,routes:value.previous.routes.map(cleanRoute)}}:{})};
|
|
262
|
+
}
|
|
263
|
+
function nonnegative(value,name,{integer=false}={}){
|
|
264
|
+
if(typeof value!=='number'||!Number.isFinite(value)||value<0||value>Number.MAX_SAFE_INTEGER||(integer&&!Number.isSafeInteger(value)))throw new Error(`${name} requires a finite nonnegative ${integer?'integer':'number'}.`);
|
|
265
|
+
return value;
|
|
266
|
+
}
|
|
267
|
+
export function parseAnalysisArgs(args){
|
|
268
|
+
const options={};
|
|
269
|
+
const numeric={'--max-js':'maxJs','--max-increase-bytes':'maxIncreaseBytes','--max-increase-percent':'maxIncreasePercent'};
|
|
270
|
+
const values={'--baseline':'baseline','--metric':'metric','--budgets':'budgets'};
|
|
271
|
+
for(let index=0;index<args.length;index++){
|
|
272
|
+
const flag=args[index],key=Object.hasOwn(numeric,flag)?numeric[flag]:Object.hasOwn(values,flag)?values[flag]:undefined;
|
|
273
|
+
if(flag==='--json'&&!options.json){options.json=true;continue;}
|
|
274
|
+
if(!key||options[key]!==undefined)throw new Error(`Unknown or duplicate analyze option: ${flag}`);
|
|
275
|
+
const value=args[++index];if(!value||value.startsWith('--'))throw new Error(`${flag} requires a value.`);
|
|
276
|
+
if(Object.hasOwn(numeric,flag)){
|
|
277
|
+
if(!/^\d+(?:\.\d+)?$/.test(value))throw new Error(`${flag} requires a nonnegative number.`);
|
|
278
|
+
options[key]=nonnegative(Number(value),flag,{integer:key==='maxIncreaseBytes'});
|
|
279
|
+
}else options[key]=value;
|
|
280
|
+
}
|
|
281
|
+
if(options.metric!==undefined&&!Object.hasOwn(metrics,options.metric))throw new Error('--metric must be raw, gzip or brotli.');
|
|
282
|
+
return options;
|
|
283
|
+
}
|
|
284
|
+
async function routeBudgets(filename){
|
|
285
|
+
if(!filename)return {};
|
|
286
|
+
const value=await boundedJson(filename,'Route budgets');
|
|
287
|
+
if(!object(value)||Object.keys(value).length>10000)throw new Error('Route budgets must be an object with at most 10,000 route keys.');
|
|
288
|
+
for(const [route,budget]of Object.entries(value)){
|
|
289
|
+
if(!text(route)||!route.startsWith('/')||!object(budget)||!Object.keys(budget).length)throw new Error('Each route budget must be a nonempty object keyed by an exact route pattern.');
|
|
290
|
+
for(const [key,limit]of Object.entries(budget)){
|
|
291
|
+
if(!['maxJs','maxIncreaseBytes','maxIncreasePercent'].includes(key))throw new Error(`Unknown route budget field: ${key}`);
|
|
292
|
+
nonnegative(limit,`Budget ${route}.${key}`,{integer:key==='maxIncreaseBytes'});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return value;
|
|
296
|
+
}
|
|
297
|
+
export async function analyzeBuild(root,{maxJs,baseline,metric='raw',maxIncreaseBytes,maxIncreasePercent,budgets}={}){
|
|
298
|
+
if(!Object.hasOwn(metrics,metric))throw new Error('Analysis metric must be raw, gzip or brotli.');
|
|
299
|
+
for(const [key,value]of Object.entries({maxJs,maxIncreaseBytes,maxIncreasePercent}))if(value!==undefined)nonnegative(value,key,{integer:key==='maxIncreaseBytes'});
|
|
300
|
+
const report=await readReport(path.join(root,await readBuildDirectory(root),'build-analysis.json'));
|
|
301
|
+
const limits=await routeBudgets(budgets?path.resolve(root,budgets):null);
|
|
302
|
+
const routeNames=new Set(report.routes.map(route=>route.route));
|
|
303
|
+
for(const route of Object.keys(limits))if(!routeNames.has(route))throw new Error(`Route budget does not match a current route: ${route}`);
|
|
304
|
+
const reference=baseline?await readReport(path.resolve(root,baseline)):report.previous;
|
|
305
|
+
if(reference&&report.version!==(reference.version||1))throw new Error('Initial asset accounting changed; rebuild the baseline with this PRNext version before comparing.');
|
|
306
|
+
const needsBaseline=maxIncreaseBytes!==undefined||maxIncreasePercent!==undefined||Object.values(limits).some(value=>value.maxIncreaseBytes!==undefined||value.maxIncreasePercent!==undefined);
|
|
307
|
+
if(needsBaseline&&!reference)throw new Error('Regression budgets require --baseline or a previous build analysis.');
|
|
308
|
+
const field=metrics[metric],previous=new Map((reference?.routes||[]).filter(route=>route.initial?.status!=='request-dependent').map(route=>[route.route,route[field]]));
|
|
309
|
+
report.metric=metric;
|
|
310
|
+
report.baseline=reference?{buildId:reference.buildId,source:baseline?'file':'previous-build'}:null;
|
|
311
|
+
report.routes=report.routes.map(route=>{
|
|
312
|
+
const budget={maxJs,maxIncreaseBytes,maxIncreasePercent,...(limits[route.route]||{})};
|
|
313
|
+
const uncertain=route.initial?.status==='request-dependent';
|
|
314
|
+
const before=previous.get(route.route),current=route[field],hasBaseline=!uncertain&&size(before),delta=hasBaseline?current-before:null;
|
|
315
|
+
const regressionBudget=budget.maxIncreaseBytes!==undefined||budget.maxIncreasePercent!==undefined;
|
|
316
|
+
const exceeded=[];
|
|
317
|
+
if(uncertain&&Object.values(budget).some(value=>value!==undefined))exceeded.push('initial-unknown');
|
|
318
|
+
if(budget.maxJs!==undefined&¤t>budget.maxJs*1000)exceeded.push('maxJs');
|
|
319
|
+
if(regressionBudget&&!hasBaseline)exceeded.push('baseline-missing');
|
|
320
|
+
if(hasBaseline&&budget.maxIncreaseBytes!==undefined&&delta>budget.maxIncreaseBytes)exceeded.push('maxIncreaseBytes');
|
|
321
|
+
if(hasBaseline&&budget.maxIncreasePercent!==undefined&&(before===0?delta>0:delta/before*100>budget.maxIncreasePercent))exceeded.push('maxIncreasePercent');
|
|
322
|
+
return {...route,selectedBytes:current,baselineBytes:hasBaseline?before:null,deltaBytes:delta,deltaPercent:hasBaseline&&before>0?delta/before*100:hasBaseline&¤t===0?0:null,
|
|
323
|
+
comparison:uncertain?'initial-unknown':hasBaseline?'compared':reference?'new-or-unavailable':'no-baseline',budget:{...budget,exceeded}};
|
|
324
|
+
}).sort((a,b)=>b.selectedBytes-a.selectedBytes||a.route.localeCompare(b.route));
|
|
325
|
+
report.budget={metric,maxJsBytes:maxJs===undefined?null:maxJs*1000,maxIncreaseBytes:maxIncreaseBytes??null,maxIncreasePercent:maxIncreasePercent??null,exceeded:report.routes.filter(route=>route.budget.exceeded.length).map(route=>route.route)};
|
|
326
|
+
report.ok=report.budget.exceeded.length===0;
|
|
327
|
+
return report;
|
|
328
|
+
}
|
|
329
|
+
export function printAnalysis(report){
|
|
330
|
+
const kb=n=>(n/1000).toFixed(1)+' kB';
|
|
331
|
+
console.log(`PRNext bundle analysis (${report.buildId})\n${report.version===2?'Hydration JavaScript per route, including required runtime imports and prerendered dynamic modules. Each metric is the largest sampled path; route file lists are unions. Shared files counted once; deferred imports excluded. Request-dependent route maxima are lower bounds.':'Legacy static-import-only accounting; rebuild to include hydration dependencies.'} Budget metric: ${report.metric||'raw'}.`);
|
|
332
|
+
for(const route of report.routes)console.log(`${route.route}: ${route.initial?.status==='request-dependent'?'>= ':''}${kb(route.jsBytes)} JS / ${kb(route.gzipBytes)} gzip / ${kb(route.brotliBytes)} br${route.initial?` [${route.initial.status}]`:''}${route.deltaBytes===null?'':` (${route.deltaBytes>=0?'+':''}${kb(route.deltaBytes)} ${report.metric||'raw'} vs baseline${route.deltaPercent===null?'':`, ${route.deltaPercent.toFixed(1)}%`})`}${route.budget?.exceeded.length?` [${route.budget.exceeded.join(', ')}]`:''}`);
|
|
333
|
+
if(report.comparisonUnavailable)console.log(report.comparisonUnavailable);
|
|
334
|
+
console.log('\nLargest browser modules (per output chunk):');
|
|
335
|
+
for(const item of report.files.flatMap(chunk=>chunk.modules.map(module=>({...module,chunk:chunk.file}))).sort((a,b)=>b.bytes-a.bytes).slice(0,10))console.log(` ${kb(item.bytes)} ${item.file} [${item.chunk}]`);
|
|
336
|
+
const unverified=report.routes.filter(route=>route.budget?.exceeded.some(reason=>reason==='initial-unknown'||reason==='baseline-missing')).map(route=>route.route);
|
|
337
|
+
const exceeded=report.routes.filter(route=>route.budget?.exceeded.some(reason=>!['initial-unknown','baseline-missing'].includes(reason))).map(route=>route.route);
|
|
338
|
+
if(exceeded.length)console.error(`JavaScript budget exceeded: ${exceeded.join(', ')}`);
|
|
339
|
+
if(unverified.length)console.error(`JavaScript budget not verified (initial coverage or baseline unavailable): ${unverified.join(', ')}`);
|
|
340
|
+
}
|
package/build/app-config.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
|
-
import {
|
|
2
|
+
import {analyzeSource} from './source-analysis.mjs';
|
|
3
3
|
import {validSamples} from '../compat/instant-samples.cjs';
|
|
4
4
|
import { createHash } from 'node:crypto';
|
|
5
5
|
|
|
@@ -14,6 +14,7 @@ const supported = new Map([
|
|
|
14
14
|
['revalidate', [false]],
|
|
15
15
|
['dynamicParams', [true, false]],
|
|
16
16
|
['instant', [true, false]],
|
|
17
|
+
['ensureStatic', ['auto', 'shell', 'prefetch', 'navigation', false]],
|
|
17
18
|
['fetchCache', ['auto', 'default-cache', 'only-cache', 'force-cache', 'force-no-store', 'default-no-store', 'only-no-store']],
|
|
18
19
|
['preferredRegion', ['auto']],
|
|
19
20
|
['maxDuration', []],
|
|
@@ -23,19 +24,19 @@ const supported = new Map([
|
|
|
23
24
|
|
|
24
25
|
function literal(node) {
|
|
25
26
|
while (['TSAsExpression', 'TSSatisfiesExpression', 'TSNonNullExpression', 'TypeCastExpression'].includes(node?.type)) node = node.expression;
|
|
26
|
-
if (node?.type === '
|
|
27
|
+
if (node?.type === 'Literal' && node.value === null) return null;
|
|
27
28
|
if (node?.type === 'ArrayExpression') { const values = node.elements.map(literal); return values.includes(undefined) ? undefined : values; }
|
|
28
29
|
if (node?.type === 'ObjectExpression') {
|
|
29
30
|
const output = {};
|
|
30
31
|
for (const item of node.properties) {
|
|
31
|
-
if (item.type !== '
|
|
32
|
+
if (item.type !== 'Property' || item.kind !== 'init' || item.method || item.computed || item.shorthand) return undefined;
|
|
32
33
|
const key = item.key.name ?? item.key.value, value = literal(item.value);
|
|
33
34
|
if (typeof key !== 'string' || value === undefined || key === '__proto__') return undefined;
|
|
34
35
|
Object.defineProperty(output, key, { value, enumerable: true });
|
|
35
36
|
}
|
|
36
37
|
return output;
|
|
37
38
|
}
|
|
38
|
-
return ['
|
|
39
|
+
return node?.type === 'Literal' && ['string','boolean','number'].includes(typeof node.value) ? node.value : undefined;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
export async function validateAppConfig(file) {
|
|
@@ -48,8 +49,7 @@ export async function validateAppConfig(file) {
|
|
|
48
49
|
}
|
|
49
50
|
parsedMisses++;
|
|
50
51
|
if (hit) { parsedConfigs.delete(file); parsedBytes -= hit.size; }
|
|
51
|
-
const
|
|
52
|
-
plugins: ['jsx', ...(/\.tsx?$/.test(file) ? ['typescript'] : [])] });
|
|
52
|
+
const analysis=analyzeSource(source,file), ast={program:analysis.program};
|
|
53
53
|
const locals = new Map();
|
|
54
54
|
const exports = [];
|
|
55
55
|
for (const statement of ast.program.body) {
|
|
@@ -70,12 +70,13 @@ export async function validateAppConfig(file) {
|
|
|
70
70
|
if (!supported.has(item.name)) continue;
|
|
71
71
|
const value = locals.get(item.local);
|
|
72
72
|
const allowed = supported.get(item.name);
|
|
73
|
+
if (item.name === 'ensureStatic' && analysis.directives.includes('use client')) throw new Error(`ensureStatic cannot be exported by a Client Component (${file}).`);
|
|
73
74
|
if (item.name === 'instant') {
|
|
74
|
-
if (
|
|
75
|
+
if (analysis.directives.includes('use client')) throw new Error(`instant cannot be exported by a Client Component (${file}).`);
|
|
75
76
|
if (value && typeof value === 'object' && Object.entries(value).every(([name, value]) => name === 'unstable_samples' ? validSamples(value) : name === 'level' ? ['warning', 'experimental-error'].includes(value) : ['unstable_disableValidation', 'unstable_disableDevValidation', 'unstable_disableBuildValidation'].includes(name) && value === true)) { config.instant = value; continue; }
|
|
76
77
|
}
|
|
77
78
|
if (item.name === 'generateStaticParams') {
|
|
78
|
-
if (
|
|
79
|
+
if (analysis.directives.includes('use client')) throw new Error(`generateStaticParams cannot be exported by a Client Component (${file}).`);
|
|
79
80
|
config.generateStaticParams = true;
|
|
80
81
|
continue;
|
|
81
82
|
}
|
|
@@ -105,7 +106,16 @@ export function mergeAppConfig(configs) {
|
|
|
105
106
|
throw new Error('Incompatible App Router fetchCache guarantees across route segments.');
|
|
106
107
|
}
|
|
107
108
|
let forceDynamic = false;
|
|
109
|
+
const levels = ['auto', 'shell', 'prefetch', 'navigation'];
|
|
110
|
+
let staticLevel = 0;
|
|
111
|
+
const staticDisabled = configs.some(config => config.ensureStatic === false);
|
|
108
112
|
for (const config of configs) {
|
|
113
|
+
if (config.ensureStatic !== undefined && config.ensureStatic !== 'auto') {
|
|
114
|
+
const level = levels.indexOf(config.ensureStatic);
|
|
115
|
+
if (level > 0 && staticDisabled) throw new Error('ensureStatic: false cannot be combined with a static requirement across route segments.');
|
|
116
|
+
if (level > 0 && level < staticLevel) throw new Error('A child ensureStatic cannot weaken its parent requirement.');
|
|
117
|
+
staticLevel = Math.max(staticLevel, level);
|
|
118
|
+
}
|
|
109
119
|
if (config.maxDuration !== undefined) merged.maxDuration = config.maxDuration;
|
|
110
120
|
if (config.runtime === 'edge') merged.runtime = 'edge';
|
|
111
121
|
else if (config.runtime === 'nodejs') delete merged.runtime;
|
|
@@ -119,6 +129,8 @@ export function mergeAppConfig(configs) {
|
|
|
119
129
|
if (config.fetchCache !== undefined) merged.fetchCache = config.fetchCache;
|
|
120
130
|
if (config.fetchCache === 'force-no-store') merged.forceNoStore = true;
|
|
121
131
|
}
|
|
132
|
+
if (staticDisabled) merged.ensureStatic = false;
|
|
133
|
+
else if (staticLevel) merged.ensureStatic = levels[staticLevel];
|
|
122
134
|
if (forceDynamic) merged.dynamic = 'force-dynamic';
|
|
123
135
|
if (policies.has('force-cache')) merged.fetchCache = 'force-cache';
|
|
124
136
|
else if (!merged.forceNoStore && !forceDynamic) {
|