@thomas.f/prnext 0.1.19-alpha → 0.1.21-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/build/app.mjs +46 -78
- package/build/build-lock.mjs +2 -71
- package/build/build-retention.mjs +119 -0
- package/build/build-timings.mjs +38 -0
- package/build/bundle-runtime.mjs +17 -16
- package/build/cache-command.mjs +31 -0
- package/build/check.mjs +41 -3
- package/build/compilation-cache.mjs +32 -12
- package/build/compilation-storage.mjs +190 -0
- package/build/compiler.mjs +5 -4
- package/build/config.mjs +23 -2
- package/build/create.mjs +1 -1
- package/build/css-modules.mjs +4 -0
- package/build/dev-action-graph.mjs +21 -0
- package/build/flight-summary.mjs +64 -0
- package/build/font-cache.mjs +9 -6
- package/build/font-files.mjs +1 -1
- package/build/index.mjs +57 -36
- package/build/inspect.mjs +3 -0
- package/build/inspection-summary.mjs +7 -2
- package/build/native-discovery.mjs +18 -6
- package/build/native-ppr.mjs +32 -0
- package/build/npm-packages.mjs +2 -2
- package/build/og.mjs +121 -1
- package/build/parallel-plan.mjs +55 -0
- package/build/persistent-cache.mjs +29 -24
- package/build/production-check.mjs +34 -0
- package/build/react-compiler-report.mjs +31 -8
- package/build/react-compiler.mjs +101 -10
- package/build/server-config.mjs +35 -0
- package/build/source-analysis.mjs +108 -5
- package/build/standalone-copy.mjs +48 -0
- package/build/standalone.mjs +2 -22
- package/build/static-adapter.mjs +7 -3
- package/build/transform-cache.mjs +17 -3
- package/build/transform.mjs +38 -6
- package/build/typescript-dev.mjs +3 -1
- package/cli.mjs +27 -5
- package/compat/app-layout-context.cjs +7 -3
- package/compat/diagnostics.cjs +5 -4
- package/compat/flight-delta.cjs +47 -12
- package/compat/index.d.ts +24 -1
- package/compat/navigation-authorization.cjs +8 -1
- package/compat/runtime-sample.cjs +56 -0
- package/compat/use-cache.cjs +5 -2
- package/docs/ai/compatibility.md +113 -10
- package/docs/ai/deployment.md +144 -0
- package/docs/ai/quickstart.md +5 -0
- package/docs/ai/troubleshooting.md +22 -0
- package/package.json +25 -22
- package/release/fontkit.mjs +31 -0
- package/release/verify.mjs +3 -0
- package/runtime/api.mjs +5 -2
- package/runtime/app-client.mjs +246 -54
- package/runtime/app-partial.mjs +2 -10
- package/runtime/app-prefetch.mjs +2 -2
- package/runtime/app-render.mjs +42 -13
- package/runtime/app-stream-channel.mjs +20 -6
- package/runtime/app-stream-html.mjs +30 -11
- package/runtime/app-styles.mjs +22 -4
- package/runtime/cache-components-rsc.mjs +26 -6
- package/runtime/cache-result.mjs +58 -0
- package/runtime/compile-cache-retention.mjs +53 -0
- package/runtime/compile-cache.mjs +28 -1
- package/runtime/dev-client.mjs +32 -8
- package/runtime/dev-compiler-broker.mjs +20 -23
- package/runtime/dev-gateway.mjs +23 -6
- package/runtime/dev-route-selection.mjs +64 -0
- package/runtime/dev-runtime-pool.mjs +3 -2
- package/runtime/dev-runtime-spare.mjs +1 -1
- package/runtime/dev-session-retention.mjs +154 -0
- package/runtime/development-flight.mjs +59 -0
- package/runtime/development-queue.mjs +33 -0
- package/runtime/development.mjs +37 -16
- package/runtime/env.mjs +9 -0
- package/runtime/flight-delta.mjs +31 -5
- package/runtime/flight-diagnostics.mjs +48 -0
- package/runtime/flight-tap.mjs +10 -5
- package/runtime/html-envelope.mjs +34 -0
- package/runtime/loopback-rpc.mjs +92 -0
- package/runtime/native-ppr.mjs +88 -0
- package/runtime/og/NOTICE.txt +1 -1
- package/runtime/og/index.node.mjs +76 -31216
- package/runtime/og/upstream.json +115 -0
- package/runtime/persistent/client.mjs +5 -3
- package/runtime/persistent/common.mjs +7 -18
- package/runtime/persistent/daemon.mjs +4 -12
- package/runtime/persistent/health.mjs +58 -0
- package/runtime/persistent/native-proxy.mjs +1 -1
- package/runtime/persistent/watchdog.mjs +12 -27
- package/runtime/prewarm.mjs +35 -0
- package/runtime/project-lock.mjs +71 -0
- package/runtime/request-transport.mjs +112 -32
- package/runtime/rsc-worker.mjs +58 -8
- package/runtime/storage-path.mjs +36 -0
- package/runtime/trace.mjs +44 -4
- package/runtime/worker.mjs +37 -17
- package/vendor/fontkit/NOTICE.txt +5 -0
- package/vendor/fontkit/SOURCE-NOTICES.txt +209 -0
- package/vendor/fontkit/index.mjs +3 -0
- package/vendor/fontkit/licenses/@swc__helpers/LICENSE +201 -0
- package/vendor/fontkit/licenses/base64-js/LICENSE +21 -0
- package/vendor/fontkit/licenses/brotli/readme.md +61 -0
- package/vendor/fontkit/licenses/clone/LICENSE +18 -0
- package/vendor/fontkit/licenses/dfa/README.md +83 -0
- package/vendor/fontkit/licenses/fast-deep-equal/LICENSE +21 -0
- package/vendor/fontkit/licenses/fontkit/README.md +266 -0
- package/vendor/fontkit/licenses/restructure/LICENSE +21 -0
- package/vendor/fontkit/licenses/tiny-inflate/LICENSE +21 -0
- package/vendor/fontkit/licenses/tslib/LICENSE.txt +12 -0
- package/vendor/fontkit/licenses/unicode-properties/LICENSE +8 -0
- package/vendor/fontkit/licenses/unicode-trie/LICENSE +8 -0
- package/vendor/fontkit/upstream.json +215 -0
package/runtime/app-render.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import {reportRequestError,acknowledgeRequestError} from './instrumentation.mjs';
|
|
2
2
|
import {currentTraceparent} from './trace.mjs';
|
|
3
3
|
import React from 'react';
|
|
4
|
-
import { randomUUID, createHmac
|
|
4
|
+
import { randomUUID, createHmac } from 'node:crypto';
|
|
5
|
+
import { developmentNavigationSecret } from '../compat/navigation-authorization.cjs';
|
|
5
6
|
import diagnostics from '../compat/diagnostics.cjs';
|
|
6
7
|
import { rscResourceLimits } from './profile.mjs';
|
|
7
8
|
import { fontPreloads } from './font-preload.mjs';
|
|
@@ -14,7 +15,7 @@ import { workerMetadata, freezeClientModules, isImmutableClientModules } from '.
|
|
|
14
15
|
import { workerArtifacts } from './worker-artifacts.mjs';
|
|
15
16
|
import path from 'node:path';
|
|
16
17
|
import { PARTIAL_PREFETCH_TYPE, PARTIAL_PREFETCH_BYTES } from './app-prefetch.mjs';
|
|
17
|
-
import { pathToFileURL } from 'node:url';
|
|
18
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
18
19
|
import { createRequire } from 'node:module';
|
|
19
20
|
import { AppRouterProvider } from '../compat/app-context.cjs';
|
|
20
21
|
import { appContent } from './app-content.mjs';
|
|
@@ -28,6 +29,7 @@ import { installFetchCache } from './fetch-cache.mjs';
|
|
|
28
29
|
import { partialArtifact, partialShell } from './app-partial.mjs';
|
|
29
30
|
import { partialKeyMap } from './app-partial-model.mjs';
|
|
30
31
|
import { partialIdentity } from './partial-artifact-cache.mjs';
|
|
32
|
+
import {nativePprArtifact} from './native-ppr.mjs';
|
|
31
33
|
import { isDraftRequest } from '../compat/draft.cjs';
|
|
32
34
|
import { createRscLifecycle } from './rsc-lifecycle.mjs';
|
|
33
35
|
import { trackRequestWork } from './request-work.mjs';
|
|
@@ -43,7 +45,6 @@ const metadata = new WeakMap();
|
|
|
43
45
|
const artifacts = new WeakMap();
|
|
44
46
|
const edgeManifests = new WeakMap();
|
|
45
47
|
const emptyManifest = Object.freeze({});
|
|
46
|
-
const developmentAuthorizationSecret = randomBytes(32).toString('hex');
|
|
47
48
|
let nextId = 0;
|
|
48
49
|
const pending = new Map();
|
|
49
50
|
const noop = () => {};
|
|
@@ -242,19 +243,44 @@ function getWorker(production) {
|
|
|
242
243
|
return current;
|
|
243
244
|
}
|
|
244
245
|
export async function prewarmAppRenderer(options){
|
|
246
|
+
if(!Number.isFinite(options?.timeoutMs)||options.timeoutMs<=0||options.timeoutMs>30000||!Number.isFinite(options?.maxRssMb)||options.maxRssMb<=0||options.maxRssMb>2048)throw new TypeError('Invalid renderer prewarm budget');
|
|
247
|
+
const deadline=performance.now()+options.timeoutMs;
|
|
248
|
+
const remaining=()=>{
|
|
249
|
+
options.signal?.throwIfAborted();
|
|
250
|
+
if(performance.now()>=deadline||process.memoryUsage().rss>=options.maxRssMb*1024*1024)throw new Error('Renderer prewarm budget exhausted');
|
|
251
|
+
return Math.max(1,deadline-performance.now());
|
|
252
|
+
};
|
|
253
|
+
remaining();
|
|
245
254
|
flightDecoder(true);
|
|
246
|
-
|
|
255
|
+
// The progressive HTML renderer is otherwise imported by the first document
|
|
256
|
+
// request. This prepares framework code only, never an application route.
|
|
257
|
+
htmlRuntime ??= await import('./app-stream-html.mjs');
|
|
258
|
+
const flight=await preloadAppRoutes([],{maxRssMb:options.maxRssMb,timeoutMs:remaining(),signal:options.signal,frameworkWarmup:true});
|
|
259
|
+
remaining();
|
|
260
|
+
if(!(flight instanceof ArrayBuffer)||flight.byteLength>65536)throw new Error('Invalid renderer prewarm Flight');
|
|
261
|
+
const body=new ReadableStream({start(controller){controller.enqueue(new Uint8Array(flight));controller.close();}});
|
|
262
|
+
let response,htmlBytes=0;
|
|
263
|
+
try{
|
|
264
|
+
response=await htmlRuntime.renderProgressiveAppHtml({result:{body,status:200},request:{production:true,method:'GET',clientModules:{},distDir:path.dirname(fileURLToPath(import.meta.url)),strictMode:true},route:{},responseHeaders:{},decodeFlight,signal:options.signal,timeoutMs:remaining()});
|
|
265
|
+
for await(const chunk of response.body){remaining();htmlBytes+=chunk.byteLength;if(htmlBytes>65536)throw new Error('Renderer prewarm HTML exceeds its byte budget');}
|
|
266
|
+
remaining();
|
|
267
|
+
return{flightBytes:flight.byteLength,htmlBytes,synthetic:true};
|
|
268
|
+
}finally{await response?.cancel();}
|
|
247
269
|
}
|
|
248
270
|
export async function preloadAppRoutes(files,options){
|
|
271
|
+
const {signal,...wireOptions}=options;
|
|
272
|
+
signal?.throwIfAborted();
|
|
249
273
|
const worker=getWorker(true),id=`preload-${++nextId}`;
|
|
250
274
|
worker.ref();
|
|
251
|
-
try{await new Promise((resolve,reject)=>{
|
|
252
|
-
const clean=()=>{clearTimeout(timer);worker.off('message',message);worker.off('error',error);worker.off('exit',exit);};
|
|
253
|
-
const message=value=>{if(value.type==='preloaded'&&value.id===id){clean();value.error?reject(new Error(value.error)):resolve();}};
|
|
275
|
+
try{return await new Promise((resolve,reject)=>{
|
|
276
|
+
const clean=()=>{clearTimeout(timer);worker.off('message',message);worker.off('error',error);worker.off('exit',exit);signal?.removeEventListener('abort',cancel);};
|
|
277
|
+
const message=value=>{if(value.type==='preloaded'&&value.id===id){clean();value.error?reject(new Error(value.error)):resolve(value.frameworkFlight);}};
|
|
254
278
|
const error=value=>{clean();reject(value);};const exit=()=>error(new Error('RSC worker exited while preloading'));
|
|
279
|
+
const cancel=()=>{try{worker.postMessage({type:'preload-cancel',id});}catch{}finally{error(signal.reason);}};
|
|
255
280
|
const timer=setTimeout(()=>{clean();void lifecycles.get(worker)?.terminate(new Error('RSC preload timed out'));reject(new Error('RSC preload timed out'));},options.timeoutMs);
|
|
256
281
|
worker.on('message',message);worker.once('error',error);worker.once('exit',exit);
|
|
257
|
-
|
|
282
|
+
signal?.addEventListener('abort',cancel,{once:true});
|
|
283
|
+
try{worker.postMessage({type:'preload',id,value:{files,options:wireOptions}});}catch(failure){error(failure);}
|
|
258
284
|
});}finally{if(!hasPending(worker)&&!lifecycles.get(worker)?.retiring)worker.unref();}
|
|
259
285
|
}
|
|
260
286
|
|
|
@@ -506,7 +532,7 @@ export async function renderAppPage(options) {
|
|
|
506
532
|
return differentialFlight(response, options);
|
|
507
533
|
}
|
|
508
534
|
|
|
509
|
-
async function renderAppPageInner({ modulePath, route = {}, manifest = {}, distDir = path.dirname(path.dirname(modulePath)), url = 'http://localhost/', originalUrl, params = {}, method = 'GET', headers = {}, routingRequestHeaders, routingResolver, body = '', production = process.env.NODE_ENV === 'production', stream = false, signal, deferFinalizer }) {
|
|
535
|
+
async function renderAppPageInner({ modulePath, route = {}, manifest = {}, distDir = path.dirname(path.dirname(modulePath)), url = 'http://localhost/', originalUrl, params = {}, method = 'GET', headers = {}, routingRequestHeaders, routingResolver, body = '', production = process.env.NODE_ENV === 'production', stream = false, signal, deferFinalizer, renderMode }) {
|
|
510
536
|
if (route.htmlOnly) return { status:409, headers:{'content-type':'text/plain; charset=utf-8','cache-control':'private, no-store','x-prnext-document-navigation':'1'}, body:Buffer.from('This HTML-only page requires its prerendered document.') };
|
|
511
537
|
let clientModules = manifest.app?.clientModules || emptyManifest;
|
|
512
538
|
if (route.cacheConfig?.runtime === 'edge') {
|
|
@@ -522,7 +548,7 @@ async function renderAppPageInner({ modulePath, route = {}, manifest = {}, distD
|
|
|
522
548
|
}
|
|
523
549
|
const responseHeaders = { 'cache-control': 'private, no-cache, no-store, max-age=0', vary: 'RSC' };
|
|
524
550
|
const request = { modulePath, routePattern: route.pattern, cacheConfig: route.cacheConfig, css: route.css || [], cssScopes: route.cssScopes || [], fonts: route.fonts || [], nonce: scriptNonce(headers), basePath: manifest.config?.basePath || '', trailingSlash: manifest?.config?.trailingSlash || false, skipTrailingSlashRedirect: manifest?.config?.skipTrailingSlashRedirect || false, url, originalUrl, params, method, headers, clientModules, production,
|
|
525
|
-
distDir, traceparent:currentTraceparent(), instrumentation:manifest.instrumentation, strictMode: manifest.config?.reactStrictMode !== false, cacheComponents: manifest.config?.cacheComponents, navigationCache: manifest.config?.navigationCache, authorizationSecret: production ? manifest.app?.actionKey :
|
|
551
|
+
distDir, traceparent:currentTraceparent(), instrumentation:manifest.instrumentation, strictMode: manifest.config?.reactStrictMode !== false, cacheComponents: manifest.config?.cacheComponents, navigationCache: manifest.config?.navigationCache, authorizationSecret: production ? manifest.app?.actionKey : developmentNavigationSecret(manifest.app?.actionKey), partialPrefetching: manifest.config?.partialPrefetching, cacheLife: manifest.config?.cacheLife, cacheHandlers: manifest.config?.cacheHandlers, cacheHandler: manifest.config?.cacheHandler, cacheMaxMemorySize: manifest.config?.cacheMaxMemorySize, serverActions: manifest.config?.serverActions, previewModeId: manifest.previewModeId, actions: manifest.app?.actions || emptyManifest, actionKey: manifest.app?.actionKey };
|
|
526
552
|
if (route.parallel) {
|
|
527
553
|
request.routingResolver = routingResolver;
|
|
528
554
|
request.skipProxyUrlNormalize = manifest.config?.skipProxyUrlNormalize;
|
|
@@ -554,6 +580,8 @@ async function renderAppPageInner({ modulePath, route = {}, manifest = {}, distD
|
|
|
554
580
|
}
|
|
555
581
|
}
|
|
556
582
|
const header = name => Object.entries(headers).find(([key]) => key.toLowerCase() === name)?.[1];
|
|
583
|
+
const native = await nativePprArtifact({renderMode, manifest, route, url, distDir});
|
|
584
|
+
if (native) request.nativePpr = native.descriptor;
|
|
557
585
|
if (request.partialPrefetching && manifest.app?.actionKey) {
|
|
558
586
|
// Preview identity includes authorization, but exposes no cookie material or
|
|
559
587
|
// guessable credential hash. A changed login cannot reveal an old preview.
|
|
@@ -591,7 +619,7 @@ async function renderAppPageInner({ modulePath, route = {}, manifest = {}, distD
|
|
|
591
619
|
const generic = route.pprGeneric?.find(item => Object.entries(item.params).every(([name, value]) => JSON.stringify(params[name]) === JSON.stringify(value)));
|
|
592
620
|
const filename = (!originalUrl ? route.ppr?.[pathname] : undefined) || generic?.file;
|
|
593
621
|
if (filename || route.pprFallback) {
|
|
594
|
-
const artifact = await partialArtifact({ request, route, manifest, filename, identity: filename === generic?.file ? filename : `${pathname}:${originalUrl ? new URL(originalUrl).pathname : ''}`, signal,
|
|
622
|
+
const artifact = native?.artifact || await partialArtifact({ request, route, manifest, filename, identity: filename === generic?.file ? filename : `${pathname}:${originalUrl ? new URL(originalUrl).pathname : ''}`, signal,
|
|
595
623
|
regenerate: async build => (await import('./app-static.mjs')).prerenderAppRoute({ modulePath, route, manifest, distDir, production, renderMode:'isr',
|
|
596
624
|
path: build?.generic?.path || pathname, originalUrl: build?.generic ? undefined : originalUrl,
|
|
597
625
|
params: build?.generic?.params || params, partialParams: build?.generic?.unknown }),
|
|
@@ -672,6 +700,7 @@ async function renderPartialAppPage({ request, route, artifact, responseHeaders,
|
|
|
672
700
|
const keys = [...partialKeyMap(artifact.keyScopes, request.params, removeBasePath(new URL(request.url).pathname, request.basePath))];
|
|
673
701
|
const partialRequest = { ...request, partialFlight: artifact.flight, ...(keys.length ? { partialKeys: keys } : {}) };
|
|
674
702
|
const headers = { ...artifact.headers, ...responseHeaders, 'x-prnext-prerender': artifact.complete ? 'complete' : 'partial', 'content-type': rsc ? 'text/x-component; charset=utf-8' : 'text/html; charset=utf-8' };
|
|
703
|
+
if (request.nativePpr) headers['x-prnext-native-ppr'] = request.nativePpr.artifactHash;
|
|
675
704
|
const identity = partialIdentity(artifact);
|
|
676
705
|
headers['x-prnext-ppr-id'] = identity;
|
|
677
706
|
if (prefetch) {
|
|
@@ -703,7 +732,7 @@ async function renderPartialAppPage({ request, route, artifact, responseHeaders,
|
|
|
703
732
|
let rendered;
|
|
704
733
|
const cancel = async reason => { abort.abort(reason || new Error('Partial response cancelled')); await rendered?.cancel?.(reason); };
|
|
705
734
|
const body = (async function* () {
|
|
706
|
-
let sent = 0;
|
|
735
|
+
let sent = request.nativePpr ? Buffer.byteLength(shell.replace(/<\/body><\/html>$/i, '')) : 0;
|
|
707
736
|
const bounded = chunk => {
|
|
708
737
|
sent += chunk.byteLength;
|
|
709
738
|
if (sent > MAX_RESPONSE_BYTES) throw new Error('Response exceeds the 16 MiB PRNext limit');
|
|
@@ -712,7 +741,7 @@ async function renderPartialAppPage({ request, route, artifact, responseHeaders,
|
|
|
712
741
|
try {
|
|
713
742
|
// The committed bytes come straight from prerender. Even a blocked
|
|
714
743
|
// request component cannot hold back this shared document shell.
|
|
715
|
-
yield bounded(Buffer.from(shell.replace(/<\/body><\/html>$/i, '')));
|
|
744
|
+
if (!request.nativePpr) yield bounded(Buffer.from(shell.replace(/<\/body><\/html>$/i, '')));
|
|
716
745
|
const result = await continueFlight(combinedSignal, true);
|
|
717
746
|
rendered = await renderProgressiveAppHtml({ result, request, route, responseHeaders, decodeFlight, signal: combinedSignal,
|
|
718
747
|
partial: { shell, postponed: artifact.postponed, shellSent: true, resumeKeys: keys.map(([from, to]) => [to, from]) } });
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// One initial transferable chunk, then one chunk per reader pull. The initial
|
|
2
2
|
// credit removes a round trip while keeping the queue bounded to 64 KiB.
|
|
3
|
+
import { ownFlightStream } from '../compat/flight-delta.cjs';
|
|
3
4
|
export const APP_STREAM_CHUNK_BYTES = 64 * 1024;
|
|
4
5
|
|
|
5
6
|
export function workerStreamChannel(port, id) {
|
|
@@ -11,6 +12,8 @@ export function workerStreamChannel(port, id) {
|
|
|
11
12
|
let ended = false;
|
|
12
13
|
let previousSignal;
|
|
13
14
|
let combinedSignal;
|
|
15
|
+
let chunkMessage;
|
|
16
|
+
let transfer;
|
|
14
17
|
const wait = async signal => {
|
|
15
18
|
while (!credit) {
|
|
16
19
|
signal.throwIfAborted();
|
|
@@ -42,11 +45,22 @@ export function workerStreamChannel(port, id) {
|
|
|
42
45
|
if (!Buffer.isBuffer(bytes) && offset === 0 && length === bytes.byteLength && bytes.buffer.byteLength <= APP_STREAM_CHUNK_BYTES) chunk = bytes;
|
|
43
46
|
else { chunk = new Uint8Array(length); chunk.set(bytes.subarray(offset, offset + length)); }
|
|
44
47
|
const last = offset + length === bytes.byteLength;
|
|
45
|
-
|
|
46
|
-
|
|
48
|
+
// MessagePort serializes synchronously. Reuse this request's ordinary
|
|
49
|
+
// chunk envelope and transfer list after the first frame rather than
|
|
50
|
+
// allocating both for every chunk. The one-frame case keeps its old
|
|
51
|
+
// allocation count; no envelope or payload is shared across requests.
|
|
52
|
+
const message = metadata ? { id, type: 'start', ...metadata }
|
|
53
|
+
: chunkMessage ??= { id, type: 'chunk' };
|
|
54
|
+
const complete = done && last;
|
|
55
|
+
message.chunk = chunk.buffer;
|
|
56
|
+
message.byteOffset = chunk.byteOffset;
|
|
57
|
+
message.byteLength = chunk.byteLength;
|
|
58
|
+
message.done = complete;
|
|
47
59
|
metadata = undefined;
|
|
48
|
-
|
|
49
|
-
|
|
60
|
+
(transfer ??= [undefined])[0] = chunk.buffer;
|
|
61
|
+
try { port.postMessage(message, transfer); }
|
|
62
|
+
finally { transfer[0] = message.chunk = undefined; }
|
|
63
|
+
if (complete) ended = true;
|
|
50
64
|
}
|
|
51
65
|
},
|
|
52
66
|
end() {
|
|
@@ -62,7 +76,7 @@ export function parentStreamChannel(port, id, cancelled) {
|
|
|
62
76
|
let controller;
|
|
63
77
|
let settlePull;
|
|
64
78
|
let closed = false;
|
|
65
|
-
const body = new ReadableStream({
|
|
79
|
+
const body = ownFlightStream(new ReadableStream({
|
|
66
80
|
start(value) { controller = value; },
|
|
67
81
|
pull() {
|
|
68
82
|
return new Promise(resolve => {
|
|
@@ -76,7 +90,7 @@ export function parentStreamChannel(port, id, cancelled) {
|
|
|
76
90
|
port.postMessage({ id, type: 'cancel' });
|
|
77
91
|
cancelled(reason);
|
|
78
92
|
},
|
|
79
|
-
}, { highWaterMark: 0 });
|
|
93
|
+
}, { highWaterMark: 0 }));
|
|
80
94
|
return {
|
|
81
95
|
body,
|
|
82
96
|
chunk(buffer, offset = 0, length = buffer?.byteLength) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {reportRequestError,acknowledgeRequestError} from './instrumentation.mjs';
|
|
2
|
-
import { htmlEnvelope } from './html-envelope.mjs';
|
|
2
|
+
import { htmlEnvelope, flightBootstrap } from './html-envelope.mjs';
|
|
3
3
|
import React from 'react';
|
|
4
4
|
import { fontPreloads } from './font-preload.mjs';
|
|
5
5
|
import { isDynamicBailout } from '../compat/dynamic-bailout.cjs';
|
|
@@ -16,11 +16,14 @@ import { streamSelector } from './stream-select.mjs';
|
|
|
16
16
|
import {nodeHtmlReader,useNodeHtml} from './node-html-stream.mjs';
|
|
17
17
|
|
|
18
18
|
import {flightTap} from './flight-tap.mjs';
|
|
19
|
+
import {createFlightMeasurement} from './flight-diagnostics.mjs';
|
|
19
20
|
|
|
20
21
|
export async function renderProgressiveAppHtml({ result, request, route, responseHeaders, decodeFlight, signal, timeoutMs, partial }) {
|
|
21
22
|
timeoutMs ??= route?.cacheConfig?.maxDuration ? route.cacheConfig.maxDuration * 1000 : 25_000;
|
|
22
23
|
const nonceAttribute = request.nonce ? ` nonce="${escapeHtml(request.nonce)}"` : '';
|
|
23
|
-
const
|
|
24
|
+
const measurement = createFlightMeasurement(request, 'ssr');
|
|
25
|
+
const tap = flightTap(result.body, measurement);
|
|
26
|
+
let status = result.status;
|
|
24
27
|
const abort = new AbortController();
|
|
25
28
|
const timeout = setTimeout(() => abort.abort(appTimeoutError('HTML stream', timeoutMs)), timeoutMs);
|
|
26
29
|
const combinedSignal = signal ? AbortSignal.any([signal, abort.signal]) : abort.signal;
|
|
@@ -29,7 +32,11 @@ export async function renderProgressiveAppHtml({ result, request, route, respons
|
|
|
29
32
|
let committed = Boolean(partial?.shellSent);
|
|
30
33
|
let control;
|
|
31
34
|
let model;
|
|
35
|
+
let bootstrapFlight;
|
|
36
|
+
const failed = error => measurement?.finish(error?.statusCode === 504 || combinedSignal.reason?.statusCode === 504
|
|
37
|
+
? 'timedOut' : combinedSignal.aborted ? 'cancelled' : 'failed', status);
|
|
32
38
|
const cancel = async reason => {
|
|
39
|
+
measurement?.finish(reason?.statusCode === 504 ? 'timedOut' : 'cancelled', status);
|
|
33
40
|
clearTimeout(timeout);
|
|
34
41
|
combinedSignal.removeEventListener('abort', onAbort);
|
|
35
42
|
if (!abort.signal.aborted) abort.abort(reason || new Error('App Router response was cancelled'));
|
|
@@ -39,6 +46,7 @@ export async function renderProgressiveAppHtml({ result, request, route, respons
|
|
|
39
46
|
combinedSignal.addEventListener('abort', onAbort, { once: true });
|
|
40
47
|
try {
|
|
41
48
|
model = await decodeFlight(tap.body, request.clientModules, request.distDir, { production: request.production });
|
|
49
|
+
measurement?.mark('modelReady');
|
|
42
50
|
const renderOptions = {
|
|
43
51
|
signal: combinedSignal,
|
|
44
52
|
nonce: request.nonce,
|
|
@@ -81,15 +89,17 @@ export async function renderProgressiveAppHtml({ result, request, route, respons
|
|
|
81
89
|
}
|
|
82
90
|
const styles = fontPreloads(route.fonts, request.nonce) + (model.css ?? route.css ?? []).map(href => `<link rel="stylesheet" href="${escapeHtml(href)}">`).join('');
|
|
83
91
|
if (styles && !partial) prefix = prefix.replace(/<\/head>/i, `${styles}</head>`);
|
|
92
|
+
measurement?.mark('shellReady');
|
|
84
93
|
tap.startOutput();
|
|
85
94
|
committed = true;
|
|
86
95
|
const body = (async function* () {
|
|
87
96
|
let total = 0;
|
|
88
97
|
const envelope = htmlEnvelope({ resumed: Boolean(partial) });
|
|
89
98
|
const encode = value => {
|
|
90
|
-
const bytes = Buffer.from(value);
|
|
99
|
+
const bytes = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
91
100
|
total += bytes.byteLength;
|
|
92
101
|
if (total > MAX_RESPONSE_BYTES) throw new Error('Response exceeds the 16 MiB PRNext limit');
|
|
102
|
+
if (measurement) measurement.responseBytes = total;
|
|
93
103
|
return bytes;
|
|
94
104
|
};
|
|
95
105
|
let flightDone = false;
|
|
@@ -116,9 +126,8 @@ export async function renderProgressiveAppHtml({ result, request, route, respons
|
|
|
116
126
|
} else {
|
|
117
127
|
if (next.done) { flightDone = true; }
|
|
118
128
|
else {
|
|
119
|
-
const encoded = Buffer.from(next.value.buffer, next.value.byteOffset, next.value.byteLength).toString('base64');
|
|
120
129
|
selection.watch('flight', tap.next());
|
|
121
|
-
yield encode(
|
|
130
|
+
yield encode((bootstrapFlight ??= flightBootstrap(nonceAttribute))(next.value));
|
|
122
131
|
}
|
|
123
132
|
}
|
|
124
133
|
if (control) {
|
|
@@ -128,6 +137,10 @@ export async function renderProgressiveAppHtml({ result, request, route, respons
|
|
|
128
137
|
}
|
|
129
138
|
}
|
|
130
139
|
yield encode(`<script${nonceAttribute}>self.__PRNEXT_FLIGHT_STREAM__.push(null);document.currentScript.remove()</script>` + envelope.finish());
|
|
140
|
+
measurement?.finish('completed', status);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
failed(error);
|
|
143
|
+
throw error;
|
|
131
144
|
} finally {
|
|
132
145
|
selection.close();
|
|
133
146
|
combinedSignal.removeEventListener('abort', onAbort);
|
|
@@ -136,7 +149,7 @@ export async function renderProgressiveAppHtml({ result, request, route, respons
|
|
|
136
149
|
})();
|
|
137
150
|
// An error encoded in Flight may be contained by a valid Suspense shell.
|
|
138
151
|
// Ordinary GET errors select HTTP 500 only when that shell also fails.
|
|
139
|
-
|
|
152
|
+
status = result.rscError && result.status === 500 && ['GET', 'HEAD'].includes(request.method) ? 200 : result.status;
|
|
140
153
|
return { status, headers: { ...responseHeaders, ...result.headers, 'content-type': 'text/html; charset=utf-8' }, body, cancel };
|
|
141
154
|
} catch (error) {
|
|
142
155
|
if (!committed && !combinedSignal.aborted && error?.statusCode !== 504 &&
|
|
@@ -151,13 +164,16 @@ export async function renderProgressiveAppHtml({ result, request, route, respons
|
|
|
151
164
|
(route.client ? `<script type="module" async${nonceAttribute} src="${escapeHtml(route.client)}"></script>` : '');
|
|
152
165
|
const prefix = shell.slice(0, end) + bootstrap;
|
|
153
166
|
const closing = shell.slice(end);
|
|
167
|
+
status = route.parallel && navigationResponse(error)?.status === 404 ? 404 : 500;
|
|
168
|
+
measurement?.mark('shellReady');
|
|
154
169
|
tap.startOutput();
|
|
155
170
|
const body = (async function* () {
|
|
156
171
|
let total = 0;
|
|
157
172
|
const encode = value => {
|
|
158
|
-
const bytes = Buffer.from(value);
|
|
173
|
+
const bytes = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
159
174
|
total += bytes.byteLength;
|
|
160
175
|
if (total > MAX_RESPONSE_BYTES) throw new Error('Response exceeds the 16 MiB PRNext limit');
|
|
176
|
+
if (measurement) measurement.responseBytes = total;
|
|
161
177
|
return bytes;
|
|
162
178
|
};
|
|
163
179
|
try {
|
|
@@ -166,18 +182,21 @@ export async function renderProgressiveAppHtml({ result, request, route, respons
|
|
|
166
182
|
combinedSignal.throwIfAborted();
|
|
167
183
|
const next = await tap.next();
|
|
168
184
|
if (next.done) break;
|
|
169
|
-
|
|
170
|
-
yield encode(`<script${nonceAttribute}>(self.__PRNEXT_FLIGHT_STREAM__||=[]).push("${encoded}");document.currentScript.remove()</script>`);
|
|
185
|
+
yield encode((bootstrapFlight ??= flightBootstrap(nonceAttribute))(next.value));
|
|
171
186
|
}
|
|
172
187
|
yield encode(`<script${nonceAttribute}>self.__PRNEXT_FLIGHT_STREAM__.push(null);document.currentScript.remove()</script>` + closing);
|
|
173
|
-
|
|
188
|
+
measurement?.finish('completed', status);
|
|
189
|
+
} catch (failure) { failed(failure); throw failure; }
|
|
190
|
+
finally { await cancel(); }
|
|
174
191
|
})();
|
|
175
|
-
return { status
|
|
192
|
+
return { status, headers: { ...responseHeaders, ...result.headers, 'content-type': 'text/html; charset=utf-8' }, body, cancel };
|
|
176
193
|
} catch (recoveryError) {
|
|
194
|
+
failed(recoveryError);
|
|
177
195
|
await cancel(recoveryError);
|
|
178
196
|
throw combinedSignal.reason?.statusCode === 504 ? combinedSignal.reason : recoveryError;
|
|
179
197
|
}
|
|
180
198
|
}
|
|
199
|
+
failed(error);
|
|
181
200
|
combinedSignal.removeEventListener('abort', onAbort);
|
|
182
201
|
await cancel(error);
|
|
183
202
|
if (combinedSignal.reason?.statusCode === 504) throw combinedSignal.reason;
|
package/runtime/app-styles.mjs
CHANGED
|
@@ -32,7 +32,23 @@ export function appStyleManager(document, { timeout = 15_000 } = {}) {
|
|
|
32
32
|
if (!records.length) sheets.delete(href);
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
|
-
|
|
35
|
+
// Initial HTML can contain many sheets. Index it once per synchronous
|
|
36
|
+
// prepare/commit operation, lazily when an untracked URL needs adoption.
|
|
37
|
+
// Never retain this snapshot across operations: external links may change.
|
|
38
|
+
function initialSheets() {
|
|
39
|
+
let index;
|
|
40
|
+
return (href, tracked) => {
|
|
41
|
+
if (!index) {
|
|
42
|
+
index = new Map();
|
|
43
|
+
for (const link of document.querySelectorAll('link[rel="stylesheet"]')) {
|
|
44
|
+
const url = link.href, links = index.get(url) || [];
|
|
45
|
+
links.push(link); index.set(url, links);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return index.get(href)?.find(link => !tracked.has(link));
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function acquire(href, before, replace = false, initial = initialSheets()) {
|
|
36
52
|
const records = sheets.get(href) || [];
|
|
37
53
|
const current = records.at(-1);
|
|
38
54
|
if (current && !replace) return current;
|
|
@@ -40,7 +56,7 @@ export function appStyleManager(document, { timeout = 15_000 } = {}) {
|
|
|
40
56
|
// in place until commit. Discard older abandoned candidates immediately.
|
|
41
57
|
if (replace) for (const record of [...records]) if (!record.active && !record.recovery && !reserved(record)) remove(href, record);
|
|
42
58
|
const tracked = new Set(records.map(record => record.link));
|
|
43
|
-
const existing = replace ? undefined :
|
|
59
|
+
const existing = replace ? undefined : initial(href, tracked);
|
|
44
60
|
const link = existing || document.createElement('link');
|
|
45
61
|
const sheet = { link, active: !!existing, ready: !!existing?.sheet, recovery: false };
|
|
46
62
|
const retained = sheets.get(href) || [];
|
|
@@ -77,6 +93,7 @@ export function appStyleManager(document, { timeout = 15_000 } = {}) {
|
|
|
77
93
|
async prepare(values = [], signal) {
|
|
78
94
|
if (signal?.aborted) throw aborted();
|
|
79
95
|
const wanted = urls(values);
|
|
96
|
+
const initial = initialSheets();
|
|
80
97
|
const prepared = [];
|
|
81
98
|
let before;
|
|
82
99
|
// Establish cascade order before downloading. Re-inserting an already
|
|
@@ -85,7 +102,7 @@ export function appStyleManager(document, { timeout = 15_000 } = {}) {
|
|
|
85
102
|
// when an existing sheet would otherwise need to move across that suffix.
|
|
86
103
|
for (let index = wanted.length - 1; index >= 0; index--) {
|
|
87
104
|
const href = wanted[index];
|
|
88
|
-
let sheet = acquire(href, before);
|
|
105
|
+
let sheet = acquire(href, before, false, initial);
|
|
89
106
|
if (before && !(sheet.link.compareDocumentPosition(before) & 4)) sheet = acquire(href, before, true);
|
|
90
107
|
prepared.push(sheet.promise);
|
|
91
108
|
before = sheet.link;
|
|
@@ -113,12 +130,13 @@ export function appStyleManager(document, { timeout = 15_000 } = {}) {
|
|
|
113
130
|
commit(values = []) {
|
|
114
131
|
cleanup++;
|
|
115
132
|
const wanted = new Set(urls(values));
|
|
133
|
+
const initial = initialSheets();
|
|
116
134
|
const handedUrls = [...handed.keys()];
|
|
117
135
|
const pinned = wanted.size === handed.size && [...wanted].every((href, index) => handedUrls[index] === href) ? handed : null;
|
|
118
136
|
for (const href of wanted) {
|
|
119
137
|
const records = sheets.get(href) || [];
|
|
120
138
|
const prior = !pinned && handed.size ? records.find(record => record.active) || records.find(record => record.recovery) : undefined;
|
|
121
|
-
const sheet = pinned?.get(href) || prior || records.findLast(record => record.ready) || acquire(href);
|
|
139
|
+
const sheet = pinned?.get(href) || prior || records.findLast(record => record.ready) || acquire(href, undefined, false, initial);
|
|
122
140
|
// Initial HTML links are render-blocking. A missing sheet is restored
|
|
123
141
|
// on explicit recovery; normal navigation always prepares first.
|
|
124
142
|
sheet.active = true; sheet.recovery = false; sheet.link.media = '';
|
|
@@ -4,6 +4,7 @@ import { actionManifest, registerCacheClientModule } from './action-server.mjs';
|
|
|
4
4
|
import { codecSymbol } from '../compat/use-cache.cjs';
|
|
5
5
|
import { encodeCacheArguments } from './cache-arguments.mjs';
|
|
6
6
|
import { flightBundler } from './worker-metadata.mjs';
|
|
7
|
+
import {primitiveCacheResult,flightCacheResult,isCacheResultPacket,readCacheResult,CACHE_RESULT_KEY_VERSION,CACHE_RESULT_LIMIT} from './cache-result.mjs';
|
|
7
8
|
const consumers = new WeakMap();
|
|
8
9
|
const emptyModules = Object.freeze({});
|
|
9
10
|
|
|
@@ -32,6 +33,7 @@ export function flightConsumer(context) {
|
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
globalThis[codecSymbol] = {
|
|
36
|
+
cacheKeyVersion:CACHE_RESULT_KEY_VERSION,
|
|
35
37
|
async arguments(values, context) {
|
|
36
38
|
const temporaryReferences = clientReferences();
|
|
37
39
|
const body = await encodeReply(values, { temporaryReferences, signal: context.signal });
|
|
@@ -41,27 +43,45 @@ globalThis[codecSymbol] = {
|
|
|
41
43
|
return { key, values: decoded, temporaryReferences, serverTemporaryReferences };
|
|
42
44
|
},
|
|
43
45
|
async render(callback, encoded, context) {
|
|
46
|
+
context.signal?.throwIfAborted();
|
|
44
47
|
const bundler = flightBundler(context.clientModules, false);
|
|
45
48
|
let failure;
|
|
46
49
|
// React calls then inside its rendering context, preserving React.cache isolation.
|
|
47
50
|
// React can subscribe more than once while unwrapping a thenable. Start
|
|
48
51
|
// lazily in its rendering context, but never execute the producer twice.
|
|
49
|
-
let execution;
|
|
50
|
-
const result = { then(resolve, reject) { (execution ||= Promise.resolve().then(callback)).then(
|
|
52
|
+
let execution,checked=false,primitive;
|
|
53
|
+
const result = { then(resolve, reject) { (execution ||= Promise.resolve().then(callback)).then(value=>{
|
|
54
|
+
try{
|
|
55
|
+
context.signal?.throwIfAborted();
|
|
56
|
+
if(!checked){primitive=primitiveCacheResult(value);checked=true;}
|
|
57
|
+
// The producer still runs inside the official renderer exactly once.
|
|
58
|
+
// Do not return the original object through another Promise: that would
|
|
59
|
+
// inspect a user-defined then getter again before React receives it.
|
|
60
|
+
resolve(primitive?null:value);
|
|
61
|
+
}catch(error){reject(error);}
|
|
62
|
+
},reject); } };
|
|
51
63
|
const stream = renderToReadableStream(result, bundler, { signal: context.signal, temporaryReferences: encoded.serverTemporaryReferences, onError(error) { failure ||= error; return 'PRNextCacheRenderError'; } });
|
|
52
64
|
const chunks = [];
|
|
53
65
|
let size = 0;
|
|
54
66
|
for await (const value of stream) {
|
|
55
67
|
size += value.byteLength;
|
|
56
68
|
if (size > 16 * 1024 * 1024) throw new Error('Cached React result exceeds the 16 MiB rendering limit');
|
|
57
|
-
|
|
69
|
+
// React exposes an official type:'bytes' Web stream: each delivered
|
|
70
|
+
// Uint8Array owns its storage. Keep it until the sole packet aggregate;
|
|
71
|
+
// copy Buffers/other representations defensively instead.
|
|
72
|
+
chunks.push(value instanceof Uint8Array&&!Buffer.isBuffer(value)?value:Buffer.from(value));
|
|
58
73
|
}
|
|
59
74
|
if (failure) throw failure;
|
|
60
|
-
|
|
61
|
-
//
|
|
62
|
-
return
|
|
75
|
+
context.signal?.throwIfAborted();
|
|
76
|
+
// The packet always owns a final aggregate; no producer storage escapes.
|
|
77
|
+
return primitive||flightCacheResult(chunks,size);
|
|
63
78
|
},
|
|
64
79
|
async decode(bytes, encoded, context) {
|
|
80
|
+
if(isCacheResultPacket(bytes)){
|
|
81
|
+
const result=readCacheResult(bytes);
|
|
82
|
+
if(!result.flight)return result.value;
|
|
83
|
+
bytes=result.flight;
|
|
84
|
+
}else if(bytes.length>CACHE_RESULT_LIMIT)throw new Error('Invalid RSC cache result envelope');
|
|
65
85
|
return createFromReadableStream(new ReadableStream({ start(controller) { controller.enqueue(bytes); controller.close(); } }), {
|
|
66
86
|
serverConsumerManifest: flightConsumer(context), temporaryReferences: encoded.temporaryReferences, replayConsoleLogs: false,
|
|
67
87
|
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Private, versioned cache bytes. These packets never become browser Flight.
|
|
2
|
+
import {isUtf8} from 'node:buffer';
|
|
3
|
+
const MAGIC=Buffer.from([0x50,0x52,0x4e,0x43,2]);
|
|
4
|
+
const HEADER=MAGIC.length+1;
|
|
5
|
+
export const CACHE_RESULT_LIMIT=16*1024*1024;
|
|
6
|
+
export const CACHE_RESULT_KEY_VERSION='flight-primitives-v2';
|
|
7
|
+
const SCALAR_LIMIT=CACHE_RESULT_LIMIT-64*1024;
|
|
8
|
+
function packet(type,payload=Buffer.alloc(0)){
|
|
9
|
+
if(payload.length>CACHE_RESULT_LIMIT)throw new Error('Cached React result exceeds the 16 MiB rendering limit');
|
|
10
|
+
const bytes=Buffer.allocUnsafe(HEADER+payload.length);MAGIC.copy(bytes);bytes[MAGIC.length]=type;payload.copy(bytes,HEADER);return bytes;
|
|
11
|
+
}
|
|
12
|
+
function textPacket(type,value,length){
|
|
13
|
+
const bytes=Buffer.allocUnsafe(HEADER+length);MAGIC.copy(bytes);bytes[MAGIC.length]=type;bytes.write(value,HEADER,length,'utf8');return bytes;
|
|
14
|
+
}
|
|
15
|
+
export function primitiveCacheResult(value){
|
|
16
|
+
if(value===null)return packet(1);
|
|
17
|
+
switch(typeof value){
|
|
18
|
+
case 'undefined':return packet(2);
|
|
19
|
+
case 'boolean':return packet(value?3:4);
|
|
20
|
+
case 'number':{const bytes=Buffer.allocUnsafe(8);bytes.writeDoubleLE(value);return packet(5,bytes);}
|
|
21
|
+
case 'bigint':{const text=String(value);return text.length>SCALAR_LIMIT?undefined:textPacket(6,text,text.length);}
|
|
22
|
+
case 'string':{
|
|
23
|
+
// Leave framing-sensitive boundary values to the official Flight limit.
|
|
24
|
+
if(value.length>SCALAR_LIMIT)return undefined;
|
|
25
|
+
// Flight's small JSON strings and large UTF8 text rows do not normalize
|
|
26
|
+
// lone surrogates or BOMs alike. Keep any such string on the fallback,
|
|
27
|
+
// independently of React's text-row threshold or chunk boundaries.
|
|
28
|
+
if(!value.isWellFormed()||value.includes('\ufeff'))return undefined;
|
|
29
|
+
const length=Buffer.byteLength(value);
|
|
30
|
+
if(length>SCALAR_LIMIT)return undefined;
|
|
31
|
+
return textPacket(7,value,length);
|
|
32
|
+
}
|
|
33
|
+
// In particular, do not enumerate objects, inspect then, or read a proxy or
|
|
34
|
+
// getter. React owns every nonprimitive result and its temporary references.
|
|
35
|
+
default:return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function flightCacheResult(chunks,size){
|
|
39
|
+
if(size>CACHE_RESULT_LIMIT)throw new Error('Cached React result exceeds the 16 MiB rendering limit');
|
|
40
|
+
// Ordinary Flight keeps its original bytes and sole aggregate copy. Reserve
|
|
41
|
+
// the whole first-byte family for this private protocol, without assuming
|
|
42
|
+
// anything about React's current or future row syntax. Inspect the aggregate
|
|
43
|
+
// so an empty leading chunk cannot hide a reserved first byte.
|
|
44
|
+
const bytes=Buffer.concat(chunks,size);
|
|
45
|
+
return isCacheResultPacket(bytes)?packet(0,bytes):bytes;
|
|
46
|
+
}
|
|
47
|
+
export function isCacheResultPacket(bytes){return bytes[0]===MAGIC[0];}
|
|
48
|
+
export function readCacheResult(bytes){
|
|
49
|
+
if(!Buffer.isBuffer(bytes)||bytes.length<HEADER||bytes.length>HEADER+CACHE_RESULT_LIMIT||!bytes.subarray(0,MAGIC.length).equals(MAGIC))throw new Error('Invalid RSC cache result envelope');
|
|
50
|
+
const type=bytes[MAGIC.length],payload=bytes.subarray(HEADER);
|
|
51
|
+
// Unwrap an escaped raw payload once; never interpret it as another packet.
|
|
52
|
+
if(type===0&&isCacheResultPacket(payload))return {flight:payload};
|
|
53
|
+
if(type>=1&&type<=4&&payload.length===0)return {value:type===1?null:type===2?undefined:type===3};
|
|
54
|
+
if(type===5&&payload.length===8)return {value:payload.readDoubleLE()};
|
|
55
|
+
if(type===6&&payload.length&&/^(?:0|-?[1-9]\d*)$/.test(payload.toString()))return {value:BigInt(payload.toString())};
|
|
56
|
+
if(type===7&&isUtf8(payload))return {value:payload.toString()};
|
|
57
|
+
throw new Error('Invalid RSC cache result envelope');
|
|
58
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {lstat,opendir,unlink,realpath} from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {captureStorageDirectory,verifyStorageDirectories,sameStoragePath} from './storage-path.mjs';
|
|
4
|
+
import {withBuildLock} from './project-lock.mjs';
|
|
5
|
+
|
|
6
|
+
/** Bounded maintenance of a known, disposable compiler namespace. Open V8
|
|
7
|
+
* cache readers remain valid; an evicted future lookup is a cache miss. */
|
|
8
|
+
export async function pruneCompilerCache(directory,{maxBytes=64*1024*1024,maxFiles=2048}={}){
|
|
9
|
+
if(!directory)return;
|
|
10
|
+
try{
|
|
11
|
+
const snapshot=await captureStorageDirectory(directory);if(!snapshot)return;
|
|
12
|
+
return await withBuildLock(directory,async canonical=>{
|
|
13
|
+
if(!sameStoragePath(canonical,directory)||!await verifyStorageDirectories([snapshot]))return;
|
|
14
|
+
return prune(canonical,{maxBytes,maxFiles});
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
catch{/* A busy or unverifiable maintenance owner wins; never wait on it. */}
|
|
18
|
+
}
|
|
19
|
+
async function prune(directory,{maxBytes,maxFiles}){
|
|
20
|
+
try{
|
|
21
|
+
const files=[];let scanned=0,complete=true;
|
|
22
|
+
const input=await lstat(directory);if(!input.isDirectory()||input.isSymbolicLink())return;
|
|
23
|
+
directory=await realpath(directory);
|
|
24
|
+
const initial=await captureStorageDirectory(directory);if(!initial)return;
|
|
25
|
+
async function collect(folder,depth,parents){
|
|
26
|
+
if(depth>3){complete=false;return;}
|
|
27
|
+
const snapshot=await captureStorageDirectory(folder),guards=[...parents,snapshot];
|
|
28
|
+
if(!snapshot||!await verifyStorageDirectories(guards)){complete=false;return;}
|
|
29
|
+
const entries=await opendir(folder);
|
|
30
|
+
for await(const entry of entries){
|
|
31
|
+
if(++scanned>8192){complete=false;break;}
|
|
32
|
+
if(entry.name.startsWith('.'))continue;
|
|
33
|
+
const name=path.join(folder,entry.name),info=await lstat(name).catch(()=>null);
|
|
34
|
+
if(!info||info.isSymbolicLink())continue;
|
|
35
|
+
if(info.isDirectory())await collect(name,depth+1,guards);
|
|
36
|
+
else if(info.isFile())files.push({name,size:info.size,time:info.mtimeMs,ino:info.ino,dev:info.dev,guards});
|
|
37
|
+
if(!complete)break;
|
|
38
|
+
}
|
|
39
|
+
if(!await verifyStorageDirectories(guards))complete=false;
|
|
40
|
+
}
|
|
41
|
+
const info=await lstat(directory);if(!info.isDirectory()||info.isSymbolicLink())return;
|
|
42
|
+
await collect(directory,0,[initial]);if(!complete)return;
|
|
43
|
+
files.sort((a,b)=>b.time-a.time);let bytes=0,retained=0;
|
|
44
|
+
for(let i=0;i<files.length;i++){
|
|
45
|
+
const file=files[i];
|
|
46
|
+
if(retained>=maxFiles||bytes+file.size>maxBytes){
|
|
47
|
+
if(!await verifyStorageDirectories(file.guards))return;
|
|
48
|
+
const current=await lstat(file.name).catch(()=>null);
|
|
49
|
+
if(current?.isFile()&&!current.isSymbolicLink()&¤t.ino===file.ino&¤t.dev===file.dev&¤t.mtimeMs===file.time&¤t.size===file.size&&sameStoragePath(await realpath(file.name),file.name)&&await verifyStorageDirectories(file.guards))await unlink(file.name).catch(()=>{});
|
|
50
|
+
}else{bytes+=file.size;retained++;}
|
|
51
|
+
}
|
|
52
|
+
}catch{/* Compiler cache retention cannot fail a request or build. */}
|
|
53
|
+
}
|
|
@@ -1,8 +1,35 @@
|
|
|
1
1
|
import {flushCompileCache} from 'node:module';
|
|
2
|
+
import {lstatSync,realpathSync,writeFileSync,unlinkSync} from 'node:fs';
|
|
3
|
+
import {randomUUID} from 'node:crypto';
|
|
4
|
+
import {hostname} from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {pruneCompilerCache} from './compile-cache-retention.mjs';
|
|
2
7
|
let pending;
|
|
8
|
+
let managedDirectory;
|
|
9
|
+
let lastMaintenance=0,maintenance;
|
|
10
|
+
// Native workers opt in explicitly. Never maintain a caller-supplied global
|
|
11
|
+
// NODE_COMPILE_CACHE, and reject links in both framework-owned path components.
|
|
12
|
+
try{
|
|
13
|
+
const root=process.env.PRNEXT_MANAGED_COMPILE_CACHE_ROOT;
|
|
14
|
+
if(root&&process.env.NODE_COMPILE_CACHE&&process.env.NODE_DISABLE_COMPILE_CACHE!=='1'){
|
|
15
|
+
const canonical=realpathSync(root),base=path.join(canonical,'.prnext-cache'),directory=path.join(base,'node-compile-v1');
|
|
16
|
+
if(realpathSync(process.env.NODE_COMPILE_CACHE)===directory&&[base,directory,process.env.NODE_COMPILE_CACHE].every(folder=>{const info=lstatSync(folder);return info.isDirectory()&&!info.isSymbolicLink();})){
|
|
17
|
+
const lease=path.join(directory,`.lease-${process.pid}-${randomUUID()}.json`);
|
|
18
|
+
writeFileSync(lease,JSON.stringify({pid:process.pid,host:hostname()}),{flag:'wx',mode:0o600});
|
|
19
|
+
managedDirectory=directory;
|
|
20
|
+
process.once('exit',()=>{try{unlinkSync(lease);}catch{}});
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}catch{/* A lease failure leaves this process's cache unmanaged. */}
|
|
3
24
|
// Flush after imports settle so later workers can reuse compiled code even
|
|
4
25
|
// while this worker stays warm. Disk failure is an optimization miss only.
|
|
5
26
|
export function scheduleCompileCacheFlush(){
|
|
6
27
|
if(pending||!process.env.NODE_COMPILE_CACHE||process.env.NODE_DISABLE_COMPILE_CACHE==='1')return;
|
|
7
|
-
pending=setTimeout(()=>{
|
|
28
|
+
pending=setTimeout(()=>{
|
|
29
|
+
pending=undefined;try{flushCompileCache();}catch{}
|
|
30
|
+
if(managedDirectory&&!maintenance&&Date.now()-lastMaintenance>=60000){
|
|
31
|
+
lastMaintenance=Date.now();
|
|
32
|
+
maintenance=pruneCompilerCache(managedDirectory,{maxBytes:128*1024*1024,maxFiles:4096}).finally(()=>{maintenance=undefined;});
|
|
33
|
+
}
|
|
34
|
+
},1000);pending.unref();
|
|
8
35
|
}
|