@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/runtime/document.mjs
CHANGED
|
@@ -64,7 +64,7 @@ export async function renderDocument({ Document = DefaultDocument, App = Default
|
|
|
64
64
|
throw new TypeError('Document.getInitialProps must return an object with an html string');
|
|
65
65
|
}
|
|
66
66
|
if (Buffer.byteLength(initial.html) > MAX_RESPONSE_BYTES) throw new Error('Response exceeds the 16 MiB PRNext limit');
|
|
67
|
-
const payload = { props, appProps, router: snapshot, buildId, route: { pattern: snapshot.pathname }, dynamicIds: [...dynamicModules] };
|
|
67
|
+
const payload = { props, appProps, router: snapshot, buildId, ...(manifest?.config?.deploymentId ? {deploymentId:manifest.config.deploymentId} : {}), route: { pattern: snapshot.pathname }, dynamicIds: [...dynamicModules] };
|
|
68
68
|
const assetPrefix = manifest?.config?.assetPrefix || manifest?.config?.basePath || '';
|
|
69
69
|
const nextData = { props: { ...appProps, pageProps: props }, page: snapshot.pathname, query: snapshot.query, buildId,
|
|
70
70
|
...(snapshot.locale ? {locale:snapshot.locale,locales:snapshot.locales,defaultLocale:snapshot.defaultLocale} : {}),
|
package/runtime/edge.mjs
CHANGED
|
@@ -75,14 +75,15 @@ async function createEdgeVM(reactMode) {
|
|
|
75
75
|
export async function loadEdgeModule(code, name, reactMode) {
|
|
76
76
|
if (reactMode !== undefined && reactMode !== 'rsc' && reactMode !== 'ssr') throw new Error('Unknown Edge React mode');
|
|
77
77
|
let pending;
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
{
|
|
79
|
+
const key=reactMode||'handler';
|
|
80
|
+
pending = reactVMs.get(key);
|
|
80
81
|
if (!pending) {
|
|
81
82
|
pending = createEdgeVM(reactMode);
|
|
82
|
-
reactVMs.set(
|
|
83
|
-
void pending.catch(() => { if (reactVMs.get(
|
|
83
|
+
reactVMs.set(key, pending);
|
|
84
|
+
void pending.catch(() => { if (reactVMs.get(key) === pending) reactVMs.delete(key); });
|
|
84
85
|
}
|
|
85
|
-
}
|
|
86
|
+
}
|
|
86
87
|
// This runtime module belongs to one compiled build. Its two React realms
|
|
87
88
|
// are shared lazily by all pages/boundaries; each bundle has its own factory.
|
|
88
89
|
const vm = await pending;
|
package/runtime/fetch-cache.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {withTrace,propagatedFetch} from './trace.mjs';
|
|
1
2
|
import { createHash } from 'node:crypto';
|
|
2
3
|
import cache from '../compat/data-cache.cjs';
|
|
3
4
|
import { dynamicUsage, trackStaticDependency } from '../compat/static-generation.cjs';
|
|
@@ -12,6 +13,7 @@ const MAX_MEMO_BYTES = 8 * 1024 * 1024;
|
|
|
12
13
|
const CAPTURE_IDLE_MS = 1000;
|
|
13
14
|
const CAPTURE_LIFETIME_MS = 20_000;
|
|
14
15
|
let registration;
|
|
16
|
+
const telemetryFetch=(input,init)=>nativeFetch(...propagatedFetch(input,init));
|
|
15
17
|
|
|
16
18
|
class UncacheableResponse extends Error {
|
|
17
19
|
constructor(response) { super('The fetch response is not cacheable'); this.response = response; }
|
|
@@ -305,24 +307,24 @@ export function installFetchCache() {
|
|
|
305
307
|
}, error => { diagnostics.record('fetch', { ...fields, error: true, durationMs: performance.now() - started }); throw error; });
|
|
306
308
|
}
|
|
307
309
|
}
|
|
308
|
-
return
|
|
310
|
+
return telemetryFetch(input, init);
|
|
309
311
|
};
|
|
310
312
|
Object.defineProperty(fetch, PATCHED, { value: dispatcher });
|
|
311
313
|
globalThis.fetch = fetch;
|
|
312
314
|
}
|
|
313
315
|
if (registration && dispatcher.handlers.includes(registration)) return globalThis.fetch;
|
|
314
316
|
async function fetch(input, init, context) {
|
|
315
|
-
if (context.phase === 'pages' || init?.next?.internal) return
|
|
317
|
+
if (context.phase === 'pages' || init?.next?.internal) return telemetryFetch(input, init);
|
|
316
318
|
if (inCacheScope()) {
|
|
317
319
|
const next = init?.next || input?.next || {};
|
|
318
320
|
if (next.revalidate !== undefined && next.revalidate !== false && (typeof next.revalidate !== 'number' || Number.isNaN(next.revalidate) || next.revalidate < 0)) throw new TypeError('fetch next.revalidate must be a nonnegative number or false');
|
|
319
321
|
recordCacheDependency(next);
|
|
320
|
-
return
|
|
322
|
+
return telemetryFetch(input, init);
|
|
321
323
|
}
|
|
322
324
|
const policy = fetchPolicy(input, init, context);
|
|
323
325
|
if (context.staticState) trackStaticDependency({ tags: policy.tags, paths: getCachePaths(context),
|
|
324
326
|
revalidate: context.cacheConfig?.dynamic === 'force-static' && policy.revalidate === 0 ? null : policy.revalidate }, context);
|
|
325
|
-
if (init?.dispatcher || init?.agent) return
|
|
327
|
+
if (init?.dispatcher || init?.agent) return telemetryFetch(input, init);
|
|
326
328
|
const state = cacheState(context);
|
|
327
329
|
const explicitSignal = init?.signal !== undefined || input instanceof Request;
|
|
328
330
|
const method = String(init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase();
|
|
@@ -331,9 +333,9 @@ export function installFetchCache() {
|
|
|
331
333
|
const persist = policy.persist && !hardRefresh && !context.draftMode && !context.cacheConfig?.forceNoStore;
|
|
332
334
|
if (diagnostics.enabled && !persist) diagnostics.record('cache', { route: diagnostics.route(context), state: 'bypass',
|
|
333
335
|
reason: context.draftMode ? 'draft mode' : hardRefresh ? 'development hard refresh' : context.cacheConfig?.forceNoStore ? 'route disables caching' : 'fetch policy does not opt into persistent caching' });
|
|
334
|
-
if (!persist && !memoize) return
|
|
336
|
+
if (!persist && !memoize) return telemetryFetch(input, init);
|
|
335
337
|
const prepared = await prepareRequest(input, init);
|
|
336
|
-
if (!prepared) return
|
|
338
|
+
if (!prepared) return telemetryFetch(input, init);
|
|
337
339
|
const request = prepared.request;
|
|
338
340
|
request.signal.throwIfAborted();
|
|
339
341
|
const memo = memoize ? memoState(state) : null;
|
|
@@ -361,7 +363,7 @@ export function installFetchCache() {
|
|
|
361
363
|
entry = undefined;
|
|
362
364
|
}
|
|
363
365
|
const producer = async ({ background = false, signal, cache: canPersist = true } = {}) => {
|
|
364
|
-
const response = await
|
|
366
|
+
const response = await telemetryFetch(request, background && signal ? { signal: AbortSignal.any([request.signal, signal]) } : undefined);
|
|
365
367
|
// Set-Cookie responses must not replay a session mutation, even when the
|
|
366
368
|
// request itself explicitly opted into shared caching.
|
|
367
369
|
if (response.headers.has('set-cookie') || (persist && response.status !== 200) || (!canPersist && !entry)) throw new UncacheableResponse(response);
|
|
@@ -384,7 +386,7 @@ export function installFetchCache() {
|
|
|
384
386
|
throw error;
|
|
385
387
|
}
|
|
386
388
|
}
|
|
387
|
-
registration = { context: optionalRequestContext, fetch };
|
|
389
|
+
registration = { context: optionalRequestContext, fetch:(input,init,context)=>withTrace({kind:3},'prnext.fetch',()=>fetch(input,init,context)) };
|
|
388
390
|
dispatcher.handlers.push(registration);
|
|
389
391
|
return globalThis.fetch;
|
|
390
392
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { randomBytes, createHmac } from 'node:crypto';
|
|
2
|
+
import { deflateRawSync } from 'node:zlib';
|
|
3
|
+
import { TYPE, MAX_VALUE, collectStream, deltaStream } from '../compat/flight-delta.cjs';
|
|
4
|
+
|
|
5
|
+
export function createFlightStore({maxBytes=32*1024*1024,maxEntries=64,ttl=30000,maxCaptures=8,maxEncoders=2,now=Date.now,setTimer=setTimeout,clearTimer=clearTimeout}={}) {
|
|
6
|
+
if(!Number.isFinite(ttl)||ttl<0||typeof now!=='function'||typeof setTimer!=='function'||typeof clearTimer!=='function')throw new TypeError('Invalid Flight store expiration policy');
|
|
7
|
+
const entries=new Map();let size=0,captures=0,encoders=0,timer,timerExpires;
|
|
8
|
+
function reserve(kind){if(kind==='capture'?captures>=maxCaptures:encoders>=maxEncoders)return;kind==='capture'?captures++:encoders++;let released=false;return()=>{if(released)return;released=true;kind==='capture'?captures--:encoders--;};}
|
|
9
|
+
function remove(token){const entry=entries.get(token);if(entry){size-=entry.bytes.length;entries.delete(token);}}
|
|
10
|
+
function schedule(at){
|
|
11
|
+
let expires=Infinity;
|
|
12
|
+
for(const entry of entries.values())expires=Math.min(expires,entry.expires);
|
|
13
|
+
if(timer!==undefined&&timerExpires===expires)return;
|
|
14
|
+
if(timer!==undefined)clearTimer(timer);
|
|
15
|
+
timer=timerExpires=undefined;
|
|
16
|
+
if(!entries.size)return;
|
|
17
|
+
timerExpires=expires;
|
|
18
|
+
// One timer for the bounded store, not one per response. A cancelled cache
|
|
19
|
+
// entry must not leave its former deadline armed. Clamp long TTLs to Node's
|
|
20
|
+
// timer range; an early wake simply checks the authoritative clock again.
|
|
21
|
+
const handle=setTimer(()=>{
|
|
22
|
+
if(timer!==handle)return;
|
|
23
|
+
timer=timerExpires=undefined;prune(true);
|
|
24
|
+
},Math.min(2147483647,Math.max(1,expires-at)));
|
|
25
|
+
timer=handle;timer?.unref?.();
|
|
26
|
+
}
|
|
27
|
+
function prune(changed=false){
|
|
28
|
+
const at=now();
|
|
29
|
+
for(const[token,entry]of entries)if(entry.expires<=at){remove(token);changed=true;}
|
|
30
|
+
while(size>maxBytes||entries.size>maxEntries){remove(entries.keys().next().value);changed=true;}
|
|
31
|
+
// Ordinary hits keep the same timer without another full entry scan.
|
|
32
|
+
if(changed)schedule(at);
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
get(token,identity){prune();const entry=entries.get(token);if(entry?.identity!==identity)return;return entry?.bytes;},
|
|
36
|
+
set(token,identity,bytes){if(bytes.length>MAX_VALUE)return;remove(token);entries.set(token,{identity,bytes,expires:now()+ttl});size+=bytes.length;prune(true);},
|
|
37
|
+
capture:()=>reserve('capture'),encoder:()=>reserve('encoder'),get inFlight(){return {captures,encoders};},
|
|
38
|
+
get bytes(){return size;},get size(){return entries.size;},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const store=createFlightStore();
|
|
42
|
+
export function compressedDeltaPolicy(acceptEncoding='') {
|
|
43
|
+
const compressed=acceptEncoding.split(',').some(value=>{
|
|
44
|
+
const [name,...parameters]=value.trim().toLowerCase().split(';');
|
|
45
|
+
return /^(gzip|deflate|br|zstd|\*)$/.test(name)&&!parameters.some(p=>/^\s*q\s*=\s*0(?:\.0*)?\s*$/.test(p));
|
|
46
|
+
});
|
|
47
|
+
if(!compressed)return;
|
|
48
|
+
// This conservative, bounded probe is not the response compressor. It avoids
|
|
49
|
+
// choosing thousands of references to JSON that gzip already compresses well.
|
|
50
|
+
// A large/expensive chunk keeps ordinary Flight instead of delaying a flush.
|
|
51
|
+
return (raw,encoded)=>raw.length<=64*1024&&encoded.length+32<raw.length&&
|
|
52
|
+
deflateRawSync(encoded,{level:1}).length+32<deflateRawSync(raw,{level:1}).length*0.9;
|
|
53
|
+
}
|
|
54
|
+
export async function differentialFlight(response,options,cache=store) {
|
|
55
|
+
const headers=new Headers(options.headers), outgoing=new Headers(response.headers);
|
|
56
|
+
if((options.method||'GET')!=='GET'||headers.get('rsc')!=='1'||headers.get('x-prnext-flight-accept')!=='v1'||headers.get('x-prnext-prefetch')==='1'||response.status!==200||!outgoing.get('content-type')?.startsWith('text/x-component'))return response;
|
|
57
|
+
const secret=options.manifest?.app?.actionKey;if(!secret)return response;
|
|
58
|
+
const identity=createHmac('sha256',secret).update(JSON.stringify([options.manifest.cacheId,headers.get('cookie')||'',headers.get('authorization')||''])).digest('hex');
|
|
59
|
+
const releaseCapture=cache.capture(),token=releaseCapture?randomBytes(24).toString('hex'):undefined,previous=headers.get('x-prnext-flight-base'),candidate=cache.get(previous,identity),releaseEncoder=candidate&&cache.encoder(),base=releaseEncoder?candidate:undefined;
|
|
60
|
+
let encoded;
|
|
61
|
+
try{
|
|
62
|
+
const raw=response.body;
|
|
63
|
+
const body=raw instanceof ReadableStream?raw:raw?.[Symbol.asyncIterator]?ReadableStream.from(raw):new ReadableStream({start(controller){
|
|
64
|
+
controller.enqueue(raw instanceof Uint8Array?raw:raw instanceof ArrayBuffer?new Uint8Array(raw):new TextEncoder().encode(String(raw??'')));controller.close();
|
|
65
|
+
}});
|
|
66
|
+
const collected=collectStream(body,{capture:!!releaseCapture,onComplete:bytes=>cache.set(token,identity,bytes),onFinally:releaseCapture});
|
|
67
|
+
encoded=base?await deltaStream(collected,base,{assess:compressedDeltaPolicy(headers.get('accept-encoding')||'')}):{delta:false,body:collected};
|
|
68
|
+
if(!encoded.delta)releaseEncoder?.();
|
|
69
|
+
else encoded.body=collectStream(encoded.body,{capture:false,onFinally:releaseEncoder});
|
|
70
|
+
}catch(error){releaseEncoder?.();releaseCapture?.();throw error;}
|
|
71
|
+
if(token)outgoing.set('x-prnext-flight-token',token);else outgoing.delete('x-prnext-flight-token');outgoing.set('cache-control','private, no-store');outgoing.delete('content-length');
|
|
72
|
+
if(encoded.delta){outgoing.set('content-type',TYPE);outgoing.set('x-prnext-flight-base',previous);}
|
|
73
|
+
return {...response,headers:Object.fromEntries(outgoing),body:encoded.body,cancel:reason=>{releaseEncoder?.();releaseCapture?.();void encoded.body.cancel(reason).catch(()=>{});return response.cancel?.(reason);}};
|
|
74
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Share Flight allocations with the SSR decoder, then group only already
|
|
2
|
+
// available bootstrap bytes. Never wait for a future Suspense chunk to fill a
|
|
3
|
+
// block. The queue's pre-shell ceiling remains the overall response budget.
|
|
4
|
+
export function flightTap(source) {
|
|
5
|
+
const reader=source.getReader(),queue=[];
|
|
6
|
+
let head=0,bytes=0,done=false,failure,outputStarted=false,wakeData,wakeSpace,cancelled;
|
|
7
|
+
const finish=error=>{done=true;failure=error;wakeData?.();wakeData=undefined;wakeSpace?.();wakeSpace=undefined;};
|
|
8
|
+
const cancel=reason=>cancelled??=(async()=>{finish(reason);queue.length=0;head=0;bytes=0;await reader.cancel(reason).catch(()=>{});})();
|
|
9
|
+
const body=new ReadableStream({
|
|
10
|
+
async pull(controller){try{
|
|
11
|
+
while(!done&&outputStarted&&bytes>=128*1024)await new Promise(resolve=>{wakeSpace=resolve;});
|
|
12
|
+
if(done){if(failure)controller.error(failure);else controller.close();return;}
|
|
13
|
+
const next=await reader.read();if(next.done){finish();controller.close();return;}
|
|
14
|
+
queue.push(next.value);bytes+=next.value.byteLength;wakeData?.();wakeData=undefined;controller.enqueue(next.value);
|
|
15
|
+
}catch(error){finish(error);controller.error(error);}},cancel,
|
|
16
|
+
},{highWaterMark:0});
|
|
17
|
+
return {body,cancel,startOutput(){outputStarted=true;},async next(){
|
|
18
|
+
while(head===queue.length&&!done)await new Promise(resolve=>{wakeData=resolve;});
|
|
19
|
+
if(head<queue.length){
|
|
20
|
+
const parts=[];let size=0;
|
|
21
|
+
do{const value=queue[head];queue[head++]=undefined;parts.push(value);size+=value.byteLength;}while(head<queue.length&&size+queue[head].byteLength<=32*1024);
|
|
22
|
+
bytes-=size;if(head===queue.length){queue.length=0;head=0;}else if(head>=64){queue.splice(0,head);head=0;}
|
|
23
|
+
wakeSpace?.();wakeSpace=undefined;
|
|
24
|
+
return {value:parts.length===1?parts[0]:Buffer.concat(parts,size),done:false};
|
|
25
|
+
}
|
|
26
|
+
if(failure)throw failure;return {done:true};
|
|
27
|
+
}};
|
|
28
|
+
}
|
package/runtime/fs.mjs
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
import { rename as move } from 'node:fs/promises';
|
|
2
2
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
3
|
+
import path from 'node:path';
|
|
3
4
|
|
|
4
5
|
// Windows scanners and recently closed file handles can briefly deny rename.
|
|
5
6
|
// Keep the atomic swap: never delete the destination to work around a lock.
|
|
6
|
-
export
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
export function createAtomicRenamer({moveFile=move,wait=delay,platform=process.platform,now=()=>performance.now(),retryMs=2000}={}) {
|
|
8
|
+
return async function rename(source,destination) {
|
|
9
|
+
const started=now(),deadline=started+retryMs;
|
|
10
|
+
for(let attempt=1;;attempt++){
|
|
11
|
+
try{return await moveFile(source,destination);}
|
|
12
|
+
catch(error){
|
|
13
|
+
if(platform!=='win32'||!['EPERM','EACCES','EBUSY'].includes(error.code)||now()>=deadline){
|
|
14
|
+
error.atomicRename={operation:'rename',code:error.code||'UNKNOWN',attempts:attempt,elapsedMs:Math.max(0,now()-started),destination:path.basename(destination)};
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
await wait(Math.min(10*attempt,100,Math.max(0,deadline-now())));
|
|
18
|
+
}
|
|
13
19
|
}
|
|
14
|
-
}
|
|
20
|
+
};
|
|
15
21
|
}
|
|
22
|
+
export const rename=createAtomicRenamer();
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Reject executable markup instead of silently stripping author content or
|
|
2
|
+
* React reveal instructions. HTML-only documents must already be complete. */
|
|
3
|
+
export function assertHtmlOnlyDocument(html, pattern = '') {
|
|
4
|
+
const fail = reason => { throw new Error(`htmlOnly route ${pattern} emitted ${reason}. Use static HTML elements without JavaScript.`); };
|
|
5
|
+
if (typeof html !== 'string') fail('an invalid HTML document');
|
|
6
|
+
if (/<\s*(?:script|iframe|object|embed)(?=[\s/>])/i.test(html)) fail('a script or embedded browsing context');
|
|
7
|
+
const tags = /<[a-z][^<>"']*(?:(?:"[^"]*"|'[^']*')[^<>"']*)*>/gi;
|
|
8
|
+
const attributes = /([^\s/>=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
|
|
9
|
+
const decode = value => value.replace(/&#(?:x([\da-f]+)|(\d+));?/gi, (_, hex, decimal) => String.fromCodePoint(Math.min(0x10ffff, parseInt(hex || decimal, hex ? 16 : 10)))).replace(/&(?:colon|tab|newline);/gi, entity => ({':':':','&tab;':'\t','&newline;':'\n'})[entity.toLowerCase()]);
|
|
10
|
+
for (const match of html.matchAll(tags)) {
|
|
11
|
+
const tag = match[0], start = /^<[\w:-]+/.exec(tag)[0].length;
|
|
12
|
+
const values = new Map();
|
|
13
|
+
for (const attribute of tag.slice(start).matchAll(attributes)) {
|
|
14
|
+
const name = attribute[1].toLowerCase(), value = decode(attribute[2] ?? attribute[3] ?? attribute[4] ?? '');
|
|
15
|
+
// HTML ignores later duplicate attributes; preserve the same first value
|
|
16
|
+
// when interpreting link token lists and resource destinations.
|
|
17
|
+
if (!values.has(name)) values.set(name,value);
|
|
18
|
+
if (/^on[a-z]/.test(name) || name === 'srcdoc') fail(`interactive attribute ${name}`);
|
|
19
|
+
if (name === 'name' && value.startsWith('$ACTION_')) fail('a Server Action form');
|
|
20
|
+
if (['href','src','action','formaction','xlink:href'].includes(name)) {
|
|
21
|
+
if (/^javascript:/i.test(value.replace(/[\u0000-\u0020]/g, ''))) fail('a JavaScript URL');
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (/^<link\b/i.test(tag) && ((values.get('rel') || '').toLowerCase().split(/[\t\n\f\r ]+/).includes('modulepreload') || (values.get('as') || '').trim().toLowerCase() === 'script')) fail('a JavaScript preload');
|
|
25
|
+
}
|
|
26
|
+
return html;
|
|
27
|
+
}
|
package/runtime/http.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {reportRequestError} from './instrumentation.mjs';
|
|
1
2
|
import { createRequire } from 'node:module';
|
|
2
3
|
// ESM node:http evaluates lazy WebSocket exports and initializes Undici.
|
|
3
4
|
// Use the module object to avoid that startup cost while preserving HTTP patches.
|
|
@@ -186,6 +187,7 @@ export function plainResponse(status, message) {
|
|
|
186
187
|
}
|
|
187
188
|
|
|
188
189
|
export function errorResponse(error, production = process.env.NODE_ENV === 'production') {
|
|
190
|
+
reportRequestError(error);
|
|
189
191
|
console.error('[prnext]', error?.stack || error);
|
|
190
192
|
const status = [400, 503, 504].includes(error?.statusCode) ? error.statusCode : 500;
|
|
191
193
|
const message = status === 400 ? 'Bad Request' : status === 503 ? 'Service Unavailable' : status === 504 ? 'Gateway Timeout' : 'Internal Server Error';
|
|
@@ -35,6 +35,20 @@ export async function runIncrementalCache(options) {
|
|
|
35
35
|
const input = JSON.parse(Buffer.from(options.body, 'base64').toString());
|
|
36
36
|
if (!input || typeof input.key !== 'string' || input.key.length > 8192 || !kinds.has(input.kind)) throw new Error('Invalid incremental cache key or kind');
|
|
37
37
|
const context = { ...options, cacheHandler: options.manifest.config.cacheHandler, cacheMaxMemorySize: options.manifest.config.cacheMaxMemorySize };
|
|
38
|
+
if (input.op === 'acquire' || input.op === 'release') {
|
|
39
|
+
const { handler } = await legacy.load(context);
|
|
40
|
+
if (input.op === 'release') {
|
|
41
|
+
if (handler.prnextLeaseVersion === 1 && typeof handler.release === 'function' && input.lease) await legacy.invoke(handler, 'release', [input.key, input.lease], context);
|
|
42
|
+
return empty(204);
|
|
43
|
+
}
|
|
44
|
+
if (handler.prnextLeaseVersion !== 1 || typeof handler.acquire !== 'function') return empty(204);
|
|
45
|
+
let lease;
|
|
46
|
+
try { lease = await legacy.invoke(handler, 'acquire', [input.key, { signal: options.signal }], context); }
|
|
47
|
+
catch (error) { if (error.code === 'PRNEXT_REDIS_BUSY') return empty(425); throw error; }
|
|
48
|
+
const encoded = JSON.stringify(lease);
|
|
49
|
+
if (!lease || encoded.length > 4096 || /[\r\n]/.test(encoded)) throw new Error('Invalid incremental cache generation lease');
|
|
50
|
+
return { ...empty(204), headers: { 'x-prnext-cache-lease': encoded } };
|
|
51
|
+
}
|
|
38
52
|
if (input.op === 'invalidate') {
|
|
39
53
|
await legacy.invalidate(context, { tags: input.tags, mode: 'expire' });
|
|
40
54
|
return empty(204);
|
|
@@ -54,7 +68,8 @@ export async function runIncrementalCache(options) {
|
|
|
54
68
|
}
|
|
55
69
|
const tags = [...new Set([...info.tags, ...implicit(info.paths)])];
|
|
56
70
|
if (tags.length) value.headers = { ...value.headers, 'x-next-cache-tags': tags.join(',') };
|
|
57
|
-
await legacy.setEntry(input.key, value, context, { tags, cacheControl: { revalidate: info.revalidate }, isRoutePPREnabled: false });
|
|
71
|
+
const stored = await legacy.setEntry(input.key, value, context, { tags, cacheControl: { revalidate: info.revalidate }, isRoutePPREnabled: false, prnextLease: input.lease });
|
|
72
|
+
if (stored === false) return empty(409);
|
|
58
73
|
return empty(204);
|
|
59
74
|
}
|
|
60
75
|
if (input.op !== 'get') throw new Error('Unknown incremental cache operation');
|
|
@@ -73,11 +88,17 @@ export async function runIncrementalCache(options) {
|
|
|
73
88
|
data = input.kind === 'APP_PAGE' ? bytes(value.rscData) : Buffer.from(JSON.stringify(value.pageData));
|
|
74
89
|
}
|
|
75
90
|
if (html.length + data.length > MAX_BYTES) throw new Error('Incremental cache entry exceeds 16 MiB');
|
|
76
|
-
const
|
|
91
|
+
const softStale = entry.isStale === true;
|
|
92
|
+
const cacheVersion = createHash('sha256').update(JSON.stringify([entry.lastModified, softStale, value.status, value.headers, info])).update(html).update(data).digest('hex');
|
|
77
93
|
if (input.knownVersion === cacheVersion) return { ...empty(304), headers: { 'x-prnext-cache-version': cacheVersion } };
|
|
78
94
|
const headers = { ...value.headers }; delete headers['x-next-cache-tags'];
|
|
95
|
+
// A positive expired generation uses native stale-while-revalidate. -1/zero
|
|
96
|
+
// remains the existing hard-expiration path. The cacheVersion above stays
|
|
97
|
+
// stable even when replicas have slightly different wall clocks.
|
|
98
|
+
const revalidate = softStale && info.revalidate === false ? 1 : info.revalidate;
|
|
99
|
+
const lastModified = softStale ? Math.max(0, Math.min(Date.now(), entry.lastModified) - Math.max(1, revalidate || 1) * 1000 - 1) : entry.lastModified;
|
|
79
100
|
return { status: value.status, headers, body: [html, data],
|
|
80
|
-
isr: { ...(input.kind === 'APP_ROUTE' ? { kind: 'route' } : {}), revalidate: entry.lastModified < 0 ? 0 :
|
|
101
|
+
isr: { ...(input.kind === 'APP_ROUTE' ? { kind: 'route' } : {}), revalidate: entry.lastModified < 0 ? 0 : revalidate,
|
|
81
102
|
htmlLength: html.length, dataLength: data.length, tags: info.tags, paths: info.paths,
|
|
82
|
-
lastModified
|
|
103
|
+
lastModified, cacheVersion } };
|
|
83
104
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import {withTrace,markTraceError} from './trace.mjs';
|
|
2
|
+
import {readFile} from 'node:fs/promises';
|
|
3
|
+
import {AsyncLocalStorage} from 'node:async_hooks';
|
|
4
|
+
import {pathToFileURL} from 'node:url';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {isControlError} from './navigation.mjs';
|
|
7
|
+
|
|
8
|
+
const modules=new Map(),scope=new AsyncLocalStorage(),reported=new WeakSet();
|
|
9
|
+
function bounded(promise,milliseconds,label){
|
|
10
|
+
let timer;
|
|
11
|
+
return Promise.race([Promise.resolve(promise),new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error(label+' timed out')),milliseconds);})]).finally(()=>clearTimeout(timer));
|
|
12
|
+
}
|
|
13
|
+
export function initializeInstrumentation(distDir,manifest,{realm='node',reactMode,loader}={}) {
|
|
14
|
+
const file=manifest.instrumentation?.[realm];if(!file)return Promise.resolve({});
|
|
15
|
+
const filename=path.resolve(distDir,file),key=filename+':'+(reactMode||realm);let pending=modules.get(key);
|
|
16
|
+
loader??=realm==='edge'?async file=>{const {loadEdgeModule}=await import('./edge.mjs');const asset=file.replace(/\.mjs$/,'.edge.js');return loadEdgeModule(await readFile(asset,'utf8'),asset,reactMode);}:file=>import(pathToFileURL(file).href);
|
|
17
|
+
if(!pending){
|
|
18
|
+
pending=(async()=>{const module=await loader(filename);if(module.register!==undefined&&typeof module.register!=='function')throw Error('instrumentation.register must be a function');if(module.onRequestError!==undefined&&typeof module.onRequestError!=='function')throw Error('instrumentation.onRequestError must be a function');await bounded(Promise.resolve().then(()=>module.register?.()),30000,'instrumentation.register');return module;})();
|
|
19
|
+
// Retain failures too: a failed initialization must never run application code.
|
|
20
|
+
modules.set(key,pending);
|
|
21
|
+
}
|
|
22
|
+
return pending;
|
|
23
|
+
}
|
|
24
|
+
export function wasRequestErrorReported(error){return !!error&&typeof error==='object'&&(error.instrumentationReported===true||reported.has(error));}
|
|
25
|
+
export function acknowledgeRequestError(error){if(error&&typeof error==='object'){scope.getStore()?.seen.add(error);reported.add(error);}markTraceError();}
|
|
26
|
+
export function reportRequestError(error,extra={}) {
|
|
27
|
+
if(isControlError(error))return;markTraceError();
|
|
28
|
+
const state=scope.getStore();if(error?.instrumentationReported===true||!state?.module.onRequestError||state.reporting)return;
|
|
29
|
+
const value=error&&typeof error==='object'?error:new Error(String(error));
|
|
30
|
+
if(state.seen.has(value)||state.seen.size>=32)return;state.seen.add(value);reported.add(value);
|
|
31
|
+
const pending=scope.run({...state,reporting:true},()=>bounded(Promise.resolve().then(()=>state.module.onRequestError(value,state.request,{...state.context,...extra})),5000,'instrumentation.onRequestError')).catch(()=>{console.error('[prnext] instrumentation.onRequestError failed');});
|
|
32
|
+
state.pending.add(pending);void pending.finally(()=>state.pending.delete(pending));return pending;
|
|
33
|
+
}
|
|
34
|
+
export async function withInstrumentation(options,callback,{realm='node',module}={}) {
|
|
35
|
+
const edge=options.route?.cacheConfig?.runtime==='edge'||options.route?.handlerConfig?.runtime==='edge'||options.route?.runtime==='edge';
|
|
36
|
+
const renderRealm=realm;
|
|
37
|
+
if(edge)realm='edge';
|
|
38
|
+
module??=await initializeInstrumentation(options.distDir,options.manifest||{},{realm,reactMode:edge&&options.route?.router==='app'&&options.route?.kind==='page'?(renderRealm==='rsc'?'rsc':'ssr'):undefined});
|
|
39
|
+
const traced=()=>withTrace(options,renderRealm==='rsc'?'prnext.rsc':'prnext.node',callback);
|
|
40
|
+
if(!module.onRequestError)return traced();
|
|
41
|
+
const url=new URL(options.originalUrl||options.url||'http://localhost/'),route=options.route||{};
|
|
42
|
+
const request=Object.freeze({path:url.pathname+url.search,method:options.method||'GET',headers:Object.freeze(Object.fromEntries(new Headers(options.headers)))});
|
|
43
|
+
const context={routerKind:route.router==='app'?'App Router':'Pages Router',routePath:route.pattern||options.routePattern||url.pathname,
|
|
44
|
+
routeType:options.action||new Headers(options.headers).has('next-action')?'action':route.kind==='middleware'?'proxy':route.kind==='api'?'route':'render',
|
|
45
|
+
renderSource:renderRealm==='rsc'?'react-server-components':new Headers(options.headers).get('rsc')==='1'?'react-server-components-payload':'server-rendering',
|
|
46
|
+
revalidateReason:['stale','on-demand'].includes(options.revalidateReason)?options.revalidateReason:undefined,renderType:options.partialFlight?'dynamic-resume':'dynamic'};
|
|
47
|
+
const state={module,request,context,seen:new Set(),pending:new Set()};
|
|
48
|
+
return scope.run(state,async()=>{try{return await traced();}catch(error){if(!options.signal?.aborted||error?.statusCode===504)reportRequestError(error);throw error;}finally{await Promise.all([...state.pending]);}});
|
|
49
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Only emitted by the opt-in development compiler. Successful imports retain
|
|
2
|
+
// native ESM caching; failed compilation is retryable after a source correction.
|
|
3
|
+
const pending=new Map(),styles=new Map();
|
|
4
|
+
function stylesheet(url){
|
|
5
|
+
const existing=[...document.querySelectorAll('link[rel="stylesheet"]')].find(link=>link.href===new URL(url,location.href).href);
|
|
6
|
+
if(existing?.sheet)return Promise.resolve();
|
|
7
|
+
if(existing&&styles.has(url))return styles.get(url);
|
|
8
|
+
const promise=new Promise((resolve,reject)=>{
|
|
9
|
+
const link=existing||document.createElement('link');
|
|
10
|
+
const timer=setTimeout(()=>finish(Error('Deferred stylesheet timed out')),30000);
|
|
11
|
+
const finish=error=>{clearTimeout(timer);link.removeEventListener('load',loaded);link.removeEventListener('error',failed);if(error){styles.delete(url);if(!existing)link.remove();reject(error);}else resolve();};
|
|
12
|
+
const loaded=()=>finish(),failed=()=>finish(Error('Deferred stylesheet failed to load'));
|
|
13
|
+
link.addEventListener('load',loaded,{once:true});link.addEventListener('error',failed,{once:true});
|
|
14
|
+
if(!existing){link.rel='stylesheet';link.href=url;document.head.appendChild(link);}
|
|
15
|
+
});
|
|
16
|
+
styles.set(url,promise);
|
|
17
|
+
void promise.then(()=>styles.delete(url),()=>styles.delete(url));
|
|
18
|
+
return promise;
|
|
19
|
+
}
|
|
20
|
+
export function lazyImport(id,basePath=''){
|
|
21
|
+
const key=basePath+':'+id;
|
|
22
|
+
if(pending.has(key))return pending.get(key);
|
|
23
|
+
const task=(async()=>{
|
|
24
|
+
const path='/_prnext/lazy/'+id;
|
|
25
|
+
const response=await fetch(`${basePath}/_prnext/ensure?path=${encodeURIComponent(path)}`,{cache:'no-store',credentials:'same-origin',signal:AbortSignal.timeout(120000)});
|
|
26
|
+
if(!response.ok)throw Error(await response.text());
|
|
27
|
+
const value=await response.json(),target=value.lazy;
|
|
28
|
+
if(!target||typeof target.url!=='string'||!Array.isArray(target.css))throw Error('Invalid deferred import response');
|
|
29
|
+
for(const url of [target.url,...target.css]){
|
|
30
|
+
const parsed=new URL(url,location.href);
|
|
31
|
+
if(parsed.origin!==location.origin||!parsed.pathname.includes('/_prnext/assets/'))throw Error('Invalid deferred import asset URL');
|
|
32
|
+
}
|
|
33
|
+
await Promise.all(target.css.map(stylesheet));
|
|
34
|
+
const module=await import(target.url);
|
|
35
|
+
return target.native?module:module.__prnextLazyModule;
|
|
36
|
+
})().finally(()=>pending.delete(key));
|
|
37
|
+
pending.set(key,task);return task;
|
|
38
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import offline from '../compat/offline-store.cjs';
|
|
2
|
+
|
|
3
|
+
export function networkFailure(error){
|
|
4
|
+
return error?.name==='NetworkError'||((error instanceof TypeError||error?.name==='TypeError')&&/fetch|network|load failed|terminated/i.test(error.message||''))||/^Connection closed\.?$/.test(error?.message||'');
|
|
5
|
+
}
|
|
6
|
+
function waitForConnection({signal,events,online,milliseconds,waitingOffline}){
|
|
7
|
+
return new Promise((resolve,reject)=>{
|
|
8
|
+
const finish=callback=>value=>{clearTimeout(timer);signal?.removeEventListener('abort',aborted);events?.removeEventListener('online',connected);callback(value);};
|
|
9
|
+
const done=finish(resolve),fail=finish(reject),aborted=()=>fail(signal.reason||new DOMException('Navigation cancelled','AbortError'));
|
|
10
|
+
const connected=()=>{if(online())done();};
|
|
11
|
+
const timer=setTimeout(()=>done(),milliseconds);
|
|
12
|
+
signal?.addEventListener('abort',aborted,{once:true});events?.addEventListener('online',connected);
|
|
13
|
+
if(signal?.aborted)aborted();
|
|
14
|
+
else if(waitingOffline&&online())done();
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
// Called exclusively around navigation GETs. Server Actions never enter this
|
|
18
|
+
// path: a lost response does not prove their mutation failed to execute.
|
|
19
|
+
export async function recoverNavigation(read,{signal,events=globalThis.window,online=()=>globalThis.navigator?.onLine!==false,budgetMs=30000,maxAttempts=3}={}){
|
|
20
|
+
const until=performance.now()+budgetMs;let waiting,lastError,attempts=0;
|
|
21
|
+
const failure=()=>Object.assign(new Error('Unable to load the page. Check your connection and try again.',{cause:lastError}),{code:'PRNEXT_NAVIGATION_NETWORK'});
|
|
22
|
+
const deadline=new AbortController(),combined=signal?AbortSignal.any([signal,deadline.signal]):deadline.signal;
|
|
23
|
+
const timer=setTimeout(()=>deadline.abort(failure()),budgetMs);
|
|
24
|
+
try{
|
|
25
|
+
for(;;){
|
|
26
|
+
combined.throwIfAborted();
|
|
27
|
+
if(online()||attempts===0){
|
|
28
|
+
try{return await read(combined);}catch(error){if(combined.aborted)throw combined.reason;if(!networkFailure(error))throw error;lastError=error;attempts++;waiting||=offline.beginWait();}
|
|
29
|
+
}
|
|
30
|
+
const remaining=until-performance.now();
|
|
31
|
+
if(remaining<=0||attempts>=maxAttempts)throw failure();
|
|
32
|
+
const waitingOffline=!online();
|
|
33
|
+
await waitForConnection({signal:combined,events,online,waitingOffline,milliseconds:Math.min(remaining,waitingOffline?remaining:250*2**Math.max(0,attempts-1))});
|
|
34
|
+
}
|
|
35
|
+
}finally{clearTimeout(timer);waiting?.();}
|
|
36
|
+
}
|
package/runtime/navigation.mjs
CHANGED
|
@@ -14,3 +14,10 @@ export function navigationResponse(error) {
|
|
|
14
14
|
if (!['http:', 'https:'].includes(parsed.protocol)) return null;
|
|
15
15
|
return { status, headers: { location: destination, 'cache-control': 'no-store' }, body: Buffer.alloc(0) };
|
|
16
16
|
}
|
|
17
|
+
|
|
18
|
+
/** Expected routing/render controls must not become application error reports. */
|
|
19
|
+
export function isControlError(error) {
|
|
20
|
+
return !!navigationResponse(error) || error?.digest === 'BAILOUT_TO_CLIENT_SIDE_RENDERING'
|
|
21
|
+
|| error?.code === 'PRNEXT_DYNAMIC_SERVER_USAGE' || error?.code === 'PRNEXT_PPR_DYNAMIC'
|
|
22
|
+
|| error?.digest === 'PRNEXT_PPR_DYNAMIC';
|
|
23
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import {Writable} from 'node:stream';
|
|
2
|
+
import {renderToPipeableStream} from 'react-dom/server';
|
|
3
|
+
|
|
4
|
+
// One bounded Node stream, without a second Web reader queue. Partial React
|
|
5
|
+
// continuations keep using their original Web API and opaque resume state.
|
|
6
|
+
export function nodeHtmlReader(element,options={}){
|
|
7
|
+
return new Promise((resolve,reject)=>{
|
|
8
|
+
const {signal,...renderOptions}=options;
|
|
9
|
+
let renderer,settled=false,finished=false,slot,pending,acknowledge,failure;
|
|
10
|
+
const cleanup=()=>signal?.removeEventListener('abort',aborted);
|
|
11
|
+
const deliver=()=>{
|
|
12
|
+
if(!pending)return;
|
|
13
|
+
const {resolve,reject}=pending;
|
|
14
|
+
if(failure){pending=undefined;reject(failure);}
|
|
15
|
+
else if(slot){pending=undefined;const current=slot;slot=undefined;acknowledge=current.done;resolve({value:current.chunk,done:false});}
|
|
16
|
+
else if(finished){pending=undefined;resolve({done:true});}
|
|
17
|
+
};
|
|
18
|
+
// A single writable replaces PassThrough's writable+readable machinery.
|
|
19
|
+
// Keep its write callback until the next read: React may reuse a chunk only
|
|
20
|
+
// after its consumer has finished, and native backpressure stays bounded.
|
|
21
|
+
const stream=new Writable({highWaterMark:64*1024,
|
|
22
|
+
write(chunk,_encoding,done){slot={chunk,done};deliver();},
|
|
23
|
+
final(done){finished=true;cleanup();deliver();done();},
|
|
24
|
+
});
|
|
25
|
+
const fail=error=>{failure=error;if(!settled){settled=true;cleanup();reject(error);}deliver();stream.destroy(error);};
|
|
26
|
+
stream.on('error',error=>{failure=error;cleanup();deliver();});
|
|
27
|
+
const aborted=()=>{const error=signal.reason||new Error('HTML render was cancelled');renderer?.abort(error);fail(error);};
|
|
28
|
+
if(signal?.aborted){aborted();return;}
|
|
29
|
+
signal?.addEventListener('abort',aborted,{once:true});
|
|
30
|
+
try{
|
|
31
|
+
renderer=renderToPipeableStream(element,{...renderOptions,onShellError:fail,onShellReady(){
|
|
32
|
+
if(settled)return;settled=true;
|
|
33
|
+
resolve({
|
|
34
|
+
read(){
|
|
35
|
+
if(pending)return Promise.reject(new Error('HTML reads must be sequential.'));
|
|
36
|
+
const done=acknowledge;acknowledge=undefined;done?.();
|
|
37
|
+
return new Promise((resolve,reject)=>{pending={resolve,reject};deliver();});
|
|
38
|
+
},
|
|
39
|
+
async cancel(reason){if(finished)return;finished=true;cleanup();renderer.abort(reason);const done=acknowledge||slot?.done;acknowledge=undefined;slot=undefined;stream.destroy();done?.();deliver();},
|
|
40
|
+
releaseLock(){if(finished)cleanup();},
|
|
41
|
+
});
|
|
42
|
+
renderer.pipe(stream);
|
|
43
|
+
}});
|
|
44
|
+
}catch(error){fail(error);}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
export const useNodeHtml=()=>process.env.PRNEXT_SSR_STREAM==='node';
|
package/runtime/pages-client.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { deploymentHeaders, assertDeploymentResponse } from './deployment-client.mjs';
|
|
1
2
|
import React from 'react';
|
|
2
3
|
import { hydrateRoot } from 'react-dom/client';
|
|
3
4
|
import Router, { RouterProvider, useRouter, installPagesRouter } from '../compat/router.cjs';
|
|
@@ -62,9 +63,10 @@ async function readPageData(response, limit = MAX_DATA_BYTES, { discard = false,
|
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
export async function readPageDataResponse(response, visibleURL, {
|
|
65
|
-
maxBytes = MAX_DATA_BYTES, includeBytes = false, fetcher = globalThis.fetch, signal, requestURL = visibleURL,
|
|
66
|
+
maxBytes = MAX_DATA_BYTES, includeBytes = false, deploymentId, fetcher = globalThis.fetch, signal, requestURL = visibleURL,
|
|
66
67
|
} = {}) {
|
|
67
68
|
try {
|
|
69
|
+
await assertDeploymentResponse(response, deploymentId);
|
|
68
70
|
let legacy = false;
|
|
69
71
|
for (let redirects = 0; response.headers.get('x-prnext-legacy-navigation') === '1'; redirects++) {
|
|
70
72
|
legacy = true;
|
|
@@ -79,7 +81,8 @@ export async function readPageDataResponse(response, visibleURL, {
|
|
|
79
81
|
await response.body?.cancel();
|
|
80
82
|
signal?.throwIfAborted();
|
|
81
83
|
response = await fetcher(target.href, { credentials: 'same-origin', redirect: 'follow', signal,
|
|
82
|
-
headers: { accept: 'application/json', 'x-nextjs-data': '1', 'x-prnext-navigation': '1' } });
|
|
84
|
+
headers: { ...deploymentHeaders(deploymentId), accept: 'application/json', 'x-nextjs-data': '1', 'x-prnext-navigation': '1' } });
|
|
85
|
+
await assertDeploymentResponse(response, deploymentId);
|
|
83
86
|
requestURL = target.href;
|
|
84
87
|
}
|
|
85
88
|
if (legacy && !response.ok && (response.status < 300 || response.status >= 400)) throw new Error(`Page data request failed (${response.status})`);
|
|
@@ -140,9 +143,10 @@ function pagesView(props) {
|
|
|
140
143
|
}
|
|
141
144
|
|
|
142
145
|
let application;
|
|
143
|
-
async function startPages({ Page, App, pattern, manifestUrl, basePath = '', trailingSlash = false, skipTrailingSlashRedirect = false, assetBase, strictMode = false }) {
|
|
146
|
+
async function startPages({ deploymentId, Page, App, pattern, manifestUrl, basePath = '', trailingSlash = false, skipTrailingSlashRedirect = false, assetBase, strictMode = false }) {
|
|
144
147
|
const data = window.__PRNEXT_DATA__;
|
|
145
148
|
if (!data?.router || !data.props) throw new Error('Missing PRNext page data');
|
|
149
|
+
if (deploymentId && deploymentId !== data.deploymentId) throw new Error('The document and its JavaScript belong to different deployments.');
|
|
146
150
|
const rewrite = readRewriteMarker(document);
|
|
147
151
|
const initial = { ...data, router: { ...data.router, basePath, trailingSlash, skipTrailingSlashRedirect }, Page, App, ...(rewrite ? { rewrite } : {}) };
|
|
148
152
|
let buildId = data.buildId;
|
|
@@ -163,13 +167,13 @@ async function startPages({ Page, App, pattern, manifestUrl, basePath = '', trai
|
|
|
163
167
|
if (signal.aborted) cancel();
|
|
164
168
|
const timer = setTimeout(() => abort.abort(new Error('Page data request timed out')), 30_000);
|
|
165
169
|
try {
|
|
166
|
-
const requestURL = pageDataURL(buildId, url.href, basePath);
|
|
170
|
+
const requestURL = pageDataURL(window.__PRNEXT_STATIC_EXPORT__ && deploymentId ? buildId + '~' + deploymentId : buildId, url.href, basePath);
|
|
167
171
|
const response = await fetcher(requestURL, {
|
|
168
172
|
credentials: 'same-origin', redirect: 'manual', signal: abort.signal,
|
|
169
|
-
headers: { accept: 'application/json', 'x-nextjs-data': '1', 'x-prnext-navigation': '1', ...(speculative ? { purpose: 'prefetch' } : {}) },
|
|
173
|
+
headers: { ...deploymentHeaders(deploymentId), accept: 'application/json', 'x-nextjs-data': '1', 'x-prnext-navigation': '1', ...(speculative ? { purpose: 'prefetch' } : {}) },
|
|
170
174
|
});
|
|
171
175
|
return await readPageDataResponse(response, url.href, { maxBytes: speculative ? 2 * 1024 * 1024 : MAX_DATA_BYTES,
|
|
172
|
-
includeBytes: speculative, fetcher, signal: abort.signal, requestURL });
|
|
176
|
+
deploymentId: window.__PRNEXT_STATIC_EXPORT__ ? undefined : deploymentId, includeBytes: speculative, fetcher, signal: abort.signal, requestURL });
|
|
173
177
|
} finally { clearTimeout(timer); signal.removeEventListener('abort', cancel); }
|
|
174
178
|
},
|
|
175
179
|
});
|
|
@@ -201,6 +205,9 @@ export function bootstrapPages(options) {
|
|
|
201
205
|
if (process.env.NODE_ENV === 'development' && options.dev && globalThis.__PRNEXT_DEV__) {
|
|
202
206
|
return globalThis.__PRNEXT_DEV__.bootstrap('pages', options, () => startPages(options));
|
|
203
207
|
}
|
|
204
|
-
|
|
208
|
+
if (!application) {
|
|
209
|
+
application = startPages(options);
|
|
210
|
+
void application.catch(error => console.error('[PRNext] Pages startup failed:', error));
|
|
211
|
+
}
|
|
205
212
|
return application;
|
|
206
213
|
}
|