@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.
Files changed (113) hide show
  1. package/build/app.mjs +46 -78
  2. package/build/build-lock.mjs +2 -71
  3. package/build/build-retention.mjs +119 -0
  4. package/build/build-timings.mjs +38 -0
  5. package/build/bundle-runtime.mjs +17 -16
  6. package/build/cache-command.mjs +31 -0
  7. package/build/check.mjs +41 -3
  8. package/build/compilation-cache.mjs +32 -12
  9. package/build/compilation-storage.mjs +190 -0
  10. package/build/compiler.mjs +5 -4
  11. package/build/config.mjs +23 -2
  12. package/build/create.mjs +1 -1
  13. package/build/css-modules.mjs +4 -0
  14. package/build/dev-action-graph.mjs +21 -0
  15. package/build/flight-summary.mjs +64 -0
  16. package/build/font-cache.mjs +9 -6
  17. package/build/font-files.mjs +1 -1
  18. package/build/index.mjs +57 -36
  19. package/build/inspect.mjs +3 -0
  20. package/build/inspection-summary.mjs +7 -2
  21. package/build/native-discovery.mjs +18 -6
  22. package/build/native-ppr.mjs +32 -0
  23. package/build/npm-packages.mjs +2 -2
  24. package/build/og.mjs +121 -1
  25. package/build/parallel-plan.mjs +55 -0
  26. package/build/persistent-cache.mjs +29 -24
  27. package/build/production-check.mjs +34 -0
  28. package/build/react-compiler-report.mjs +31 -8
  29. package/build/react-compiler.mjs +101 -10
  30. package/build/server-config.mjs +35 -0
  31. package/build/source-analysis.mjs +108 -5
  32. package/build/standalone-copy.mjs +48 -0
  33. package/build/standalone.mjs +2 -22
  34. package/build/static-adapter.mjs +7 -3
  35. package/build/transform-cache.mjs +17 -3
  36. package/build/transform.mjs +38 -6
  37. package/build/typescript-dev.mjs +3 -1
  38. package/cli.mjs +27 -5
  39. package/compat/app-layout-context.cjs +7 -3
  40. package/compat/diagnostics.cjs +5 -4
  41. package/compat/flight-delta.cjs +47 -12
  42. package/compat/index.d.ts +24 -1
  43. package/compat/navigation-authorization.cjs +8 -1
  44. package/compat/runtime-sample.cjs +56 -0
  45. package/compat/use-cache.cjs +5 -2
  46. package/docs/ai/compatibility.md +113 -10
  47. package/docs/ai/deployment.md +144 -0
  48. package/docs/ai/quickstart.md +5 -0
  49. package/docs/ai/troubleshooting.md +22 -0
  50. package/package.json +25 -22
  51. package/release/fontkit.mjs +31 -0
  52. package/release/verify.mjs +3 -0
  53. package/runtime/api.mjs +5 -2
  54. package/runtime/app-client.mjs +246 -54
  55. package/runtime/app-partial.mjs +2 -10
  56. package/runtime/app-prefetch.mjs +2 -2
  57. package/runtime/app-render.mjs +42 -13
  58. package/runtime/app-stream-channel.mjs +20 -6
  59. package/runtime/app-stream-html.mjs +30 -11
  60. package/runtime/app-styles.mjs +22 -4
  61. package/runtime/cache-components-rsc.mjs +26 -6
  62. package/runtime/cache-result.mjs +58 -0
  63. package/runtime/compile-cache-retention.mjs +53 -0
  64. package/runtime/compile-cache.mjs +28 -1
  65. package/runtime/dev-client.mjs +32 -8
  66. package/runtime/dev-compiler-broker.mjs +20 -23
  67. package/runtime/dev-gateway.mjs +23 -6
  68. package/runtime/dev-route-selection.mjs +64 -0
  69. package/runtime/dev-runtime-pool.mjs +3 -2
  70. package/runtime/dev-runtime-spare.mjs +1 -1
  71. package/runtime/dev-session-retention.mjs +154 -0
  72. package/runtime/development-flight.mjs +59 -0
  73. package/runtime/development-queue.mjs +33 -0
  74. package/runtime/development.mjs +37 -16
  75. package/runtime/env.mjs +9 -0
  76. package/runtime/flight-delta.mjs +31 -5
  77. package/runtime/flight-diagnostics.mjs +48 -0
  78. package/runtime/flight-tap.mjs +10 -5
  79. package/runtime/html-envelope.mjs +34 -0
  80. package/runtime/loopback-rpc.mjs +92 -0
  81. package/runtime/native-ppr.mjs +88 -0
  82. package/runtime/og/NOTICE.txt +1 -1
  83. package/runtime/og/index.node.mjs +76 -31216
  84. package/runtime/og/upstream.json +115 -0
  85. package/runtime/persistent/client.mjs +5 -3
  86. package/runtime/persistent/common.mjs +7 -18
  87. package/runtime/persistent/daemon.mjs +4 -12
  88. package/runtime/persistent/health.mjs +58 -0
  89. package/runtime/persistent/native-proxy.mjs +1 -1
  90. package/runtime/persistent/watchdog.mjs +12 -27
  91. package/runtime/prewarm.mjs +35 -0
  92. package/runtime/project-lock.mjs +71 -0
  93. package/runtime/request-transport.mjs +112 -32
  94. package/runtime/rsc-worker.mjs +58 -8
  95. package/runtime/storage-path.mjs +36 -0
  96. package/runtime/trace.mjs +44 -4
  97. package/runtime/worker.mjs +37 -17
  98. package/vendor/fontkit/NOTICE.txt +5 -0
  99. package/vendor/fontkit/SOURCE-NOTICES.txt +209 -0
  100. package/vendor/fontkit/index.mjs +3 -0
  101. package/vendor/fontkit/licenses/@swc__helpers/LICENSE +201 -0
  102. package/vendor/fontkit/licenses/base64-js/LICENSE +21 -0
  103. package/vendor/fontkit/licenses/brotli/readme.md +61 -0
  104. package/vendor/fontkit/licenses/clone/LICENSE +18 -0
  105. package/vendor/fontkit/licenses/dfa/README.md +83 -0
  106. package/vendor/fontkit/licenses/fast-deep-equal/LICENSE +21 -0
  107. package/vendor/fontkit/licenses/fontkit/README.md +266 -0
  108. package/vendor/fontkit/licenses/restructure/LICENSE +21 -0
  109. package/vendor/fontkit/licenses/tiny-inflate/LICENSE +21 -0
  110. package/vendor/fontkit/licenses/tslib/LICENSE.txt +12 -0
  111. package/vendor/fontkit/licenses/unicode-properties/LICENSE +8 -0
  112. package/vendor/fontkit/licenses/unicode-trie/LICENSE +8 -0
  113. package/vendor/fontkit/upstream.json +215 -0
@@ -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, randomBytes } from 'node:crypto';
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
- await preloadAppRoutes([],options);
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
- worker.postMessage({type:'preload',id,value:{files,options}});
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 : developmentAuthorizationSecret, 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 };
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
- const message = { id, type: metadata ? 'start' : 'chunk', ...metadata,
46
- chunk: chunk.buffer, byteOffset: chunk.byteOffset, byteLength: chunk.byteLength, done: done && last };
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
- port.postMessage(message, [chunk.buffer]);
49
- if (message.done) ended = true;
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 tap = flightTap(result.body);
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(`<script${nonceAttribute}>(self.__PRNEXT_FLIGHT_STREAM__||=[]).push("${encoded}");document.currentScript.remove()</script>`);
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
- const status = result.rscError && result.status === 500 && ['GET', 'HEAD'].includes(request.method) ? 200 : result.status;
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
- const encoded = Buffer.from(next.value.buffer, next.value.byteOffset, next.value.byteLength).toString('base64');
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
- } finally { await cancel(); }
188
+ measurement?.finish('completed', status);
189
+ } catch (failure) { failed(failure); throw failure; }
190
+ finally { await cancel(); }
174
191
  })();
175
- return { status: route.parallel && navigationResponse(error)?.status === 404 ? 404 : 500, headers: { ...responseHeaders, ...result.headers, 'content-type': 'text/html; charset=utf-8' }, body, cancel };
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;
@@ -32,7 +32,23 @@ export function appStyleManager(document, { timeout = 15_000 } = {}) {
32
32
  if (!records.length) sheets.delete(href);
33
33
  }
34
34
  }
35
- function acquire(href, before, replace = false) {
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 : [...document.querySelectorAll('link[rel="stylesheet"]')].find(link => link.href === href && !tracked.has(link));
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(resolve, reject); } };
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
- chunks.push(Buffer.from(value));
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
- // Each chunk above is already an owned copy. A single chunk needs no
61
- // second allocation; never expose the producer's mutable stream storage.
62
- return chunks.length===1?chunks[0]:Buffer.concat(chunks, size);
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()&&current.ino===file.ino&&current.dev===file.dev&&current.mtimeMs===file.time&&current.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(()=>{pending=undefined;try{flushCompileCache();}catch{}},1000);pending.unref();
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
  }