@thomas.f/prnext 0.1.11-alpha → 0.1.13-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 (60) hide show
  1. package/README.md +395 -29
  2. package/build/actions.mjs +4 -0
  3. package/build/app-config.mjs +2 -0
  4. package/build/app.mjs +20 -5
  5. package/build/bundle-runtime.mjs +82 -0
  6. package/build/cache-handlers.mjs +4 -1
  7. package/build/compiler.mjs +4 -1
  8. package/build/config.mjs +20 -2
  9. package/build/font-cache.mjs +50 -0
  10. package/build/font-files.mjs +5 -3
  11. package/build/fonts.mjs +1 -1
  12. package/build/index.mjs +12 -1
  13. package/build/metadata-conflicts.mjs +13 -0
  14. package/build/metadata-images.mjs +3 -3
  15. package/build/metadata-routes.mjs +3 -3
  16. package/build/native-packages.mjs +5 -1
  17. package/build/npm-packages.mjs +14 -2
  18. package/build/optimize-imports.mjs +112 -0
  19. package/build/proxy-body-config.mjs +10 -0
  20. package/build/runtime-contract.mjs +16 -0
  21. package/build/scan.mjs +9 -7
  22. package/build/typescript-check.mjs +20 -0
  23. package/build/typescript.mjs +49 -0
  24. package/build/worker-retirement.mjs +14 -0
  25. package/cli.mjs +17 -0
  26. package/compat/after.cjs +51 -0
  27. package/compat/index.d.ts +13 -1
  28. package/compat/server.cjs +2 -1
  29. package/compat/server.d.cts +2 -0
  30. package/native/launch.mjs +19 -0
  31. package/package.json +25 -9
  32. package/release/verify.mjs +9 -0
  33. package/runtime/api.mjs +15 -5
  34. package/runtime/app-render.mjs +164 -53
  35. package/runtime/app-route-plan.mjs +45 -0
  36. package/runtime/app-routing-state.mjs +101 -21
  37. package/runtime/app-routing.mjs +37 -18
  38. package/runtime/app-segments.mjs +25 -11
  39. package/runtime/app-static.mjs +1 -1
  40. package/runtime/app-stream-html.mjs +3 -53
  41. package/runtime/cache-request.mjs +10 -2
  42. package/runtime/cloud/archive.mjs +316 -0
  43. package/runtime/cloud/client.mjs +658 -0
  44. package/runtime/cloud/environment.mjs +60 -0
  45. package/runtime/cloud/state.mjs +141 -0
  46. package/runtime/cloud/upload.mjs +48 -0
  47. package/runtime/html-envelope.mjs +136 -0
  48. package/runtime/http.mjs +9 -6
  49. package/runtime/middleware.mjs +3 -3
  50. package/runtime/profiles.json +13 -8
  51. package/runtime/request-transport.mjs +1 -1
  52. package/runtime/request-work.mjs +2 -2
  53. package/runtime/route-static.mjs +1 -1
  54. package/runtime/rsc-lifecycle.mjs +119 -0
  55. package/runtime/rsc-work-watchdog.mjs +75 -0
  56. package/runtime/rsc-worker.mjs +36 -9
  57. package/runtime/stream-utils.mjs +22 -3
  58. package/runtime/transport.mjs +89 -53
  59. package/runtime/worker-metadata.mjs +23 -0
  60. package/runtime/worker.mjs +119 -27
@@ -1,4 +1,5 @@
1
1
  import React from 'react';
2
+ import diagnostics from '../compat/diagnostics.cjs';
2
3
  import { runtimeProfile } from './profile.mjs';
3
4
  import { fontPreloads } from './font-preload.mjs';
4
5
  import { advancedRoutingSources } from './app-routing-state.mjs';
@@ -6,7 +7,7 @@ import { addBasePath, removeBasePath } from '../compat/paths.cjs';
6
7
  import { isDynamicBailout } from '../compat/dynamic-bailout.cjs';
7
8
  import { Readable } from 'node:stream';
8
9
  import { Worker } from 'node:worker_threads';
9
- import { workerMetadata } from './worker-metadata.mjs';
10
+ import { workerMetadata, freezeClientModules, isImmutableClientModules } from './worker-metadata.mjs';
10
11
  import { workerArtifacts } from './worker-artifacts.mjs';
11
12
  import path from 'node:path';
12
13
  import { PARTIAL_PREFETCH_TYPE, PARTIAL_PREFETCH_BYTES } from './app-prefetch.mjs';
@@ -25,16 +26,21 @@ import { partialArtifact, partialShell } from './app-partial.mjs';
25
26
  import { partialKeyMap } from './app-partial-model.mjs';
26
27
  import { partialIdentity } from './partial-artifact-cache.mjs';
27
28
  import { isDraftRequest } from '../compat/draft.cjs';
29
+ import { createRscLifecycle } from './rsc-lifecycle.mjs';
30
+ import { trackRequestWork } from './request-work.mjs';
31
+ import { createRscWorkWatchdog } from './rsc-work-watchdog.mjs';
28
32
 
29
33
  installFetchCache();
30
34
 
31
35
  const workers = new Map();
36
+ const lifecycles = new WeakMap();
32
37
  const metadata = new WeakMap();
33
38
  const artifacts = new WeakMap();
34
39
  const edgeManifests = new WeakMap();
35
40
  const emptyManifest = Object.freeze({});
36
41
  let nextId = 0;
37
42
  const pending = new Map();
43
+ const noop = () => {};
38
44
  // Avoid temporary arrays on every completion/cancellation at high concurrency.
39
45
  function hasPending(worker, minimum = 1, cancelledOnly = false) {
40
46
  let count = 0;
@@ -67,14 +73,14 @@ function flightDecoder(production) {
67
73
  // React's development decoder guards module initialization with NODE_ENV.
68
74
  // This require is synchronous; restore the host before any stream work.
69
75
  process.env.NODE_ENV = production ? 'production' : 'development';
70
- let decoder = require(filename).createFromNodeStream;
71
- if (typeof decoder !== 'function') {
76
+ let decoder = require(filename);
77
+ if (typeof decoder.createFromNodeStream !== 'function') {
72
78
  // A consumer may have previously required the guarded development file
73
79
  // in production, leaving an empty module in Node's cache.
74
80
  delete require.cache[filename];
75
- decoder = require(filename).createFromNodeStream;
81
+ decoder = require(filename);
76
82
  }
77
- if (typeof decoder !== 'function') throw new Error('React Flight decoder is unavailable for the requested render mode');
83
+ if (typeof decoder.createFromNodeStream !== 'function') throw new Error('React Flight decoder is unavailable for the requested render mode');
78
84
  flightDecoders.set(production, decoder);
79
85
  return decoder;
80
86
  } finally {
@@ -97,6 +103,7 @@ globalThis.__webpack_chunk_load__ = id => {
97
103
 
98
104
  function getWorker(production) {
99
105
  if (workers.has(production)) return workers.get(production);
106
+ const created = diagnostics.enabled ? performance.now() : 0;
100
107
  const current = new Worker(new URL('./rsc-worker.mjs', import.meta.url), {
101
108
  execArgv: ['--conditions=react-server'], stdout: true, stderr: true,
102
109
  // Production nursery policy; an explicit Node semi-space flag takes
@@ -105,37 +112,68 @@ function getWorker(production) {
105
112
  env: { ...process.env, NODE_ENV: production ? 'production' : 'development' },
106
113
  });
107
114
  workers.set(production, current);
115
+ if (diagnostics.enabled) {
116
+ current.once('online', () => diagnostics.record('wake', {phase:'rsc-online', durationMs:performance.now()-created}));
117
+ const ready = message => {
118
+ if (message.type !== 'rsc-ready') return;
119
+ diagnostics.record('wake', {phase:'rsc-ready', durationMs:performance.now()-created});
120
+ current.off('message', ready);
121
+ };
122
+ current.on('message', ready);
123
+ }
124
+ let workWatchdog;
125
+ const lifecycle = createRscLifecycle(() => { workWatchdog?.close(); return current.terminate(); });
126
+ workWatchdog = createRscWorkWatchdog({
127
+ probe: token => current.postMessage({ type: 'work-probe', token }),
128
+ retire: error => {
129
+ console.error('[prnext] RSC background worker stopped responding:', error);
130
+ return lifecycle.terminate(error);
131
+ },
132
+ });
133
+ lifecycles.set(current, lifecycle);
108
134
  metadata.set(current, workerMetadata(current));
109
135
  artifacts.set(current, workerArtifacts(current));
110
136
  // The HTTP worker's stdout belongs to its Rust transport. User code and React
111
137
  // diagnostics in the separate RSC environment must never corrupt that stream.
112
138
  current.stdout.pipe(process.stderr);
113
139
  current.stderr.pipe(process.stderr);
114
- function fail(error) {
115
- if (workers.get(production) === current) workers.delete(production);
140
+ function fail(error, exited = false) {
141
+ // Keep a retiring isolate registered until its actual exit; callers may
142
+ // wait for replacement but cannot start an unbounded series of retirees.
143
+ if (exited && workers.get(production) === current) workers.delete(production);
144
+ lifecycle.close(error);
145
+ workWatchdog.close();
116
146
  for (const [id, request] of pending) {
117
147
  if (request.worker !== current) continue;
118
148
  clearTimeout(request.timeout);
119
149
  pending.delete(id);
150
+ request.lease.release();
151
+ request.settle();
152
+ request.releaseWorkWatch?.();
120
153
  request.detach?.();
121
154
  request.channel?.error(error);
122
155
  request.reject(error);
123
156
  }
124
157
  }
125
- current.on('message', ({ id, type, chunk, byteOffset, byteLength, done, body, status, rscError, boundaryIndex, headers, error, staticParams, staticMetadata, navigation }) => {
158
+ current.on('message', ({ id, type, token, chunk, byteOffset, byteLength, done, body, status, rscError, boundaryIndex, headers, error, staticParams, staticMetadata, navigation }) => {
159
+ if (type === 'work-responsive') { workWatchdog.ack(token); return; }
126
160
  const request = pending.get(id);
127
161
  if (!request || request.worker !== current) return;
128
162
  // A timed-out mutation can still be running after its HTTP caller leaves.
129
163
  // Its retirement marker must be honored before discarding cancelled output.
130
164
  if (error?.retireWorker) {
131
- if (workers.get(production) === current) workers.delete(production);
132
- void current.terminate();
165
+ void lifecycle.terminate(new Error(error.message)).catch(error => console.error('[prnext] RSC worker termination failed:', error));
133
166
  }
134
167
  if (request.cancelled && type !== 'settled') return;
135
168
  if (type === 'settled') {
136
- if (!request.cancelled) return;
137
169
  pending.delete(id); clearTimeout(request.timeout);
138
- if (!hasPending(current)) current.unref();
170
+ request.lease.release();
171
+ request.detach?.();
172
+ request.settle();
173
+ request.releaseWorkWatch?.();
174
+ // terminate() keeps its exit event referenced. A late settlement must
175
+ // not undo that reference while build publication awaits shutdown.
176
+ if (!hasPending(current) && !lifecycle.retiring) current.unref();
139
177
  return;
140
178
  }
141
179
  if (type === 'start') {
@@ -145,11 +183,21 @@ function getWorker(production) {
145
183
  }
146
184
  if (type === 'chunk') {
147
185
  try { request.channel.chunk(chunk, byteOffset, byteLength); }
148
- catch (error) { request.channel.error(error); current.postMessage({ id, type: 'cancel' }); }
186
+ catch (error) { request.channel.error(error); current.postMessage({ id, type: 'cancel' }); request.cancel(error); return; }
149
187
  if (!done) return;
150
188
  }
151
- pending.delete(id);
189
+ if (request.completed) {
190
+ if (error) console.error('[prnext] RSC background work failed:', error.stack || error.message);
191
+ return;
192
+ }
193
+ request.completed = true;
194
+ // The response deadline ends with its successful terminal frame. Cache
195
+ // work can legitimately continue afterwards and retains its own bounds;
196
+ // supervise isolate responsiveness, not total background-task duration.
152
197
  clearTimeout(request.timeout);
198
+ // A destroyed Timeout can still own its AsyncLocalStorage frame.
199
+ request.timeout = undefined;
200
+ request.releaseWorkWatch = workWatchdog.add();
153
201
  request.detach?.();
154
202
  if (type === 'end' || done) request.channel.end();
155
203
  else if (error) {
@@ -171,51 +219,98 @@ function getWorker(production) {
171
219
  // ArrayBuffer overload: a view of the transferred allocation, no copy.
172
220
  request.resolve({ body: Buffer.from(body), status: status || 200, rscError, boundaryIndex, headers, staticMetadata, navigation });
173
221
  }
174
- if (!hasPending(current)) current.unref();
222
+ // Keep only settlement bookkeeping after sending the body. The
223
+ // isolate can still be flushing cache writes after its terminal frame.
224
+ request.channel = undefined;
225
+ // Both closures capture the original response promise / AbortSignal.
226
+ // Detaching the listener alone does not release those references while
227
+ // legitimate background work keeps this entry in the pending map.
228
+ request.cancel = request.detach = undefined;
229
+ request.resolve = request.reject = noop;
175
230
  });
176
231
  current.on('error', fail);
177
- current.on('exit', code => fail(new Error(`App Router worker exited (${code})`)));
232
+ current.on('exit', code => fail(new Error(`App Router worker exited (${code})`), true));
178
233
  current.unref();
179
234
  return current;
180
235
  }
181
236
 
237
+ // Stdio workers can retain referenced MessagePorts after rendering (including
238
+ // diagnostics piped from the RSC isolate). EOF must drain real cache/after()
239
+ // settlement before explicitly closing those isolates, not wait for Node's
240
+ // incidental event-loop liveness or terminate unfinished application work.
241
+ export async function drainAppRuntime() {
242
+ while (pending.size) await Promise.all([...pending.values()].map(request => request.settled));
243
+ await closeAppRuntime();
244
+ }
245
+
182
246
  export function closeAppRuntime() {
183
247
  const current = [...workers.values()];
184
- workers.clear();
185
- return Promise.all(current.map(worker => worker.terminate()));
248
+ return Promise.all(current.map(worker => lifecycles.get(worker).terminate()));
249
+ }
250
+
251
+ function requestDeadline(entry, lifecycle, id, hardTimeoutMs, delayMs) {
252
+ // Keep this callback outside renderFlight's request closure: cancelled work
253
+ // retains its hard deadline, but must not thereby retain the HTTP signal or
254
+ // response promise until an unrelated cache write settles.
255
+ return setTimeout(() => {
256
+ // A blocking npm dependency cannot honor an AbortSignal. Retire the
257
+ // isolate at the hard deadline so subsequent requests can recover.
258
+ void lifecycle.terminate(appTimeoutError('RSC worker', hardTimeoutMs)).finally(entry.settle).catch(error => console.error('[prnext] RSC worker termination failed:', error));
259
+ pending.delete(id);
260
+ entry.lease.release();
261
+ const error = appTimeoutError('RSC worker', hardTimeoutMs);
262
+ if (entry.completed) console.error('[prnext] RSC background work failed:', error);
263
+ entry.channel?.error(error);
264
+ entry.detach?.();
265
+ entry.reject(error);
266
+ }, delayMs);
186
267
  }
187
268
 
188
- export function renderFlight(request, { softTimeoutMs = 25_000, hardTimeoutMs = 28_000, signal } = {}) {
269
+ export async function renderFlight(request, { softTimeoutMs, hardTimeoutMs, signal, deferFinalizer } = {}) {
270
+ softTimeoutMs ??= request.cacheConfig?.maxDuration ? request.cacheConfig.maxDuration * 1000 : 25_000;
271
+ hardTimeoutMs ??= request.cacheConfig?.maxDuration ? request.cacheConfig.maxDuration * 1000 : 28_000;
272
+ const deadline = performance.now() + hardTimeoutMs;
273
+ let current, lifecycle, lease;
274
+ for (;;) {
275
+ signal?.throwIfAborted();
276
+ current = getWorker(Boolean(request.production));
277
+ lifecycle = lifecycles.get(current);
278
+ if (lifecycle.retiring) {
279
+ await lifecycle.waitForRetirement({ signal, timeoutMs: deadline - performance.now() });
280
+ continue;
281
+ }
282
+ try { lease = await lifecycle.acquire({ signal, timeoutMs: deadline - performance.now() }); }
283
+ catch (error) { if (lifecycle.retiring) continue; throw error; }
284
+ if (lifecycle.closed) { lease.release(); continue; }
285
+ break;
286
+ }
189
287
  return new Promise((resolve, reject) => {
190
- const current = getWorker(Boolean(request.production));
288
+ if (signal?.aborted) { lease.release(); reject(signal.reason); return; }
289
+ if (performance.now() >= deadline) { lease.release(); reject(appTimeoutError('RSC admission', hardTimeoutMs)); return; }
191
290
  current.ref();
192
291
  const id = ++nextId;
193
- const timeout = setTimeout(() => {
194
- // A blocking npm dependency cannot honor an AbortSignal. Retire the
195
- // isolate at the hard deadline so subsequent requests can recover.
196
- if (workers.get(Boolean(request.production)) === current) workers.delete(Boolean(request.production));
197
- void current.terminate();
198
- pending.delete(id);
199
- const error = appTimeoutError('RSC worker', hardTimeoutMs);
200
- entry.channel?.error(error);
201
- entry.detach?.();
202
- reject(error);
203
- }, hardTimeoutMs);
204
- const entry = { worker: current, resolve, reject, timeout, operation: request.operation };
292
+ let settle;
293
+ const settled = new Promise(resolve => { settle = resolve; });
294
+ trackRequestWork(settled, signal, { deadline, supervised: true });
295
+ const entry = { worker: current, resolve, reject, operation: request.operation, lease, settle, settled };
296
+ const timeout = entry.timeout = requestDeadline(entry, lifecycle, id, hardTimeoutMs, Math.max(0, deadline - performance.now()));
205
297
  function cancel(reason) {
298
+ if (entry.cancelled) return;
206
299
  // Keep the independent deadline until the isolate confirms settlement.
207
300
  // A disconnected client must not disable recovery of blocked npm code.
208
301
  entry.cancelled = true;
302
+ lease.cancel();
209
303
  entry.detach?.();
210
- // HTTP callers can leave faster than arbitrary npm work can unwind.
211
- // Bound abandoned work independently of the admission of live requests.
212
- if (hasPending(current, 16, true)) {
213
- if (workers.get(Boolean(request.production)) === current) workers.delete(Boolean(request.production));
214
- void current.terminate();
215
- }
216
- if (!hasPending(current)) current.unref();
217
- reject(reason instanceof Error ? reason : new Error('App Router response was cancelled'));
304
+ // Pause new work at 16 abandoned requests while their acknowledgements
305
+ // settle. Existing mutation/cache work keeps its original hard deadline.
306
+ if (!hasPending(current) && !lifecycle.retiring) current.unref();
307
+ entry.reject(reason instanceof Error ? reason : new Error('App Router response was cancelled'));
308
+ // Settlement/deadline tracking remains live, but a disconnected caller
309
+ // no longer needs its stream, promise or signal retained by this entry.
310
+ entry.channel = entry.cancel = entry.detach = undefined;
311
+ entry.resolve = entry.reject = noop;
218
312
  }
313
+ entry.cancel = cancel;
219
314
  if (request.stream) entry.channel = parentStreamChannel(current, id, cancel);
220
315
  if (signal) {
221
316
  const onAbort = () => {
@@ -225,11 +320,14 @@ export function renderFlight(request, { softTimeoutMs = 25_000, hardTimeoutMs =
225
320
  };
226
321
  entry.detach = () => signal.removeEventListener('abort', onAbort);
227
322
  signal.addEventListener('abort', onAbort, { once: true });
228
- if (signal.aborted) { clearTimeout(timeout); onAbort(); return; }
229
323
  }
230
324
  pending.set(id, entry);
231
325
  try {
232
- const wire = { ...request, renderTimeoutMs: softTimeoutMs };
326
+ const wire = { ...request, renderTimeoutMs: Math.min(softTimeoutMs, Math.max(1, deadline - performance.now())) };
327
+ if (request.action && deferFinalizer) {
328
+ wire.waitForResponse = true;
329
+ deferFinalizer(() => { if (pending.has(id)) current.postMessage({ id, type: 'after-response' }); });
330
+ }
233
331
  if (request.production) {
234
332
  const artifactId = artifacts.get(current)(wire.partialFlight);
235
333
  if (artifactId !== undefined) { wire.partialFlightId = artifactId; delete wire.partialFlight; }
@@ -243,19 +341,22 @@ export function renderFlight(request, { softTimeoutMs = 25_000, hardTimeoutMs =
243
341
  catch (error) {
244
342
  clearTimeout(timeout);
245
343
  pending.delete(id);
344
+ lease.release();
345
+ settle();
246
346
  entry.detach?.();
247
347
  reject(error);
248
- if (!hasPending(current)) current.unref();
348
+ if (!hasPending(current) && !lifecycle.retiring) current.unref();
249
349
  }
250
350
  });
251
351
  }
252
352
 
253
353
  export async function decodeFlight(body, clientModules = {}, distDir, { production = process.env.NODE_ENV === 'production' } = {}) {
254
- const ids = Object.keys(clientModules);
255
354
  let cached = ssrManifests.get(clientModules);
256
355
  // Keep only the manifest's module mapping, never a decoded React model or
257
356
  // request context. Weak ownership releases it with the build manifest.
258
- if (!cached || cached.distDir !== distDir || ids.length !== cached.paths.size || ids.some(id => cached.paths.get(id) !== clientModules[id].ssrModule)) {
357
+ const immutable = isImmutableClientModules(clientModules);
358
+ const ids = cached && cached.distDir === distDir && cached.immutable && immutable ? null : Object.keys(clientModules);
359
+ if (!cached || cached.distDir !== distDir || (ids && (ids.length !== cached.paths.size || ids.some(id => cached.paths.get(id) !== clientModules[id].ssrModule)))) {
259
360
  const moduleMap = Object.create(null), paths = new Map();
260
361
  for (const id of ids) {
261
362
  const item = clientModules[id];
@@ -266,13 +367,20 @@ export async function decodeFlight(body, clientModules = {}, distDir, { producti
266
367
  moduleMap[id] = { '*': { id: moduleUrl, chunks: [moduleUrl, moduleUrl], name: '*' } };
267
368
  paths.set(id, item.ssrModule);
268
369
  }
269
- cached = { distDir, paths, moduleMap };
370
+ cached = { distDir, paths, moduleMap, immutable };
270
371
  ssrManifests.set(clientModules, cached);
271
- }
372
+ } else if (immutable) cached.immutable = true;
272
373
  // Hosts can build/render both modes in one process. The encoded protocol,
273
374
  // rather than the host's current environment or import cache, selects React.
274
375
  const decoder = flightDecoder(Boolean(production));
275
- return decoder(body instanceof ReadableStream ? Readable.fromWeb(body) : Readable.from([body]), { moduleMap: cached.moduleMap, serverModuleMap: null, moduleLoading: null });
376
+ const serverConsumerManifest = { moduleMap: cached.moduleMap, serverModuleMap: null, moduleLoading: null };
377
+ // React's Node decoder can consume Web streams directly. Keep the Flight
378
+ // bytes in their original stream instead of allocating a Node Readable,
379
+ // forwarding its events and buffering a second queue for each response.
380
+ if (body instanceof ReadableStream && typeof decoder.createFromReadableStream === 'function') {
381
+ return decoder.createFromReadableStream(body, { serverConsumerManifest });
382
+ }
383
+ return decoder.createFromNodeStream(body instanceof ReadableStream ? Readable.fromWeb(body) : Readable.from([body]), serverConsumerManifest);
276
384
  }
277
385
 
278
386
  export async function renderHtml(tree, router, { timeoutMs = 25_000, formState = null, staticGeneration = false, nonce, strictMode = true } = {}) {
@@ -348,13 +456,16 @@ export async function renderAppPage(options) {
348
456
  return response;
349
457
  }
350
458
 
351
- 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 }) {
459
+ 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 }) {
352
460
  let clientModules = manifest.app?.clientModules || emptyManifest;
353
461
  if (route.cacheConfig?.runtime === 'edge') {
354
462
  let edge = !manifest.dev && edgeManifests.get(clientModules);
355
463
  if (!edge) {
356
464
  edge = Object.fromEntries(Object.entries(clientModules).map(([id, item]) => [id, item.edgeSsrModule ? { ...item, ssrModule: item.edgeSsrModule } : item]));
357
- if (!manifest.dev) edgeManifests.set(clientModules, edge);
465
+ if (!manifest.dev) {
466
+ if (isImmutableClientModules(clientModules)) freezeClientModules(edge);
467
+ edgeManifests.set(clientModules, edge);
468
+ }
358
469
  }
359
470
  clientModules = edge;
360
471
  }
@@ -424,7 +535,7 @@ async function renderAppPageInner({ modulePath, route = {}, manifest = {}, distD
424
535
  if (stream && (!request.action || request.action.id)) {
425
536
  let progressive;
426
537
  try {
427
- progressive = await renderFlight({ ...request, stream: true }, { signal });
538
+ progressive = await renderFlight({ ...request, stream: true }, { signal, deferFinalizer });
428
539
  const rsc = Object.entries(headers).some(([name, value]) => name.toLowerCase() === 'rsc' && value === '1');
429
540
  if (rsc || request.action?.id) {
430
541
  if (method === 'HEAD') await progressive.body.cancel();
@@ -448,7 +559,7 @@ async function renderAppPageInner({ modulePath, route = {}, manifest = {}, distD
448
559
  }
449
560
  }
450
561
  let result;
451
- try { result = await renderFlight(request, { signal }); }
562
+ try { result = await renderFlight(request, { signal, deferFinalizer }); }
452
563
  catch (error) {
453
564
  const result = navigationResponse(error);
454
565
  if (result) return { ...result, headers: { ...responseHeaders, ...result.headers, ...error.responseHeaders } };
@@ -465,7 +576,7 @@ async function renderAppPageInner({ modulePath, route = {}, manifest = {}, distD
465
576
  let html, model, recovery = false;
466
577
  try {
467
578
  model = await decodeFlight(result.body, clientModules, distDir, { production });
468
- html = await renderHtml(model.tree, model.router, { formState: model.formState, nonce: request.nonce, strictMode: manifest.config?.reactStrictMode !== false });
579
+ html = await renderHtml(model.tree, model.router, { timeoutMs: route.cacheConfig?.maxDuration ? route.cacheConfig.maxDuration * 1000 : undefined, formState: model.formState, nonce: request.nonce, strictMode: manifest.config?.reactStrictMode !== false });
469
580
  } catch (error) {
470
581
  if (error?.statusCode === 504) throw error;
471
582
  const navigation = navigationResponse(error);
@@ -0,0 +1,45 @@
1
+ // Cache only the compiler-owned, immutable routing scaffold. Component modules,
2
+ // configuration, request parameters and rendered trees are never memoized here.
3
+ const plans = new WeakMap();
4
+ const liveTrees = new WeakSet();
5
+ const ownValue = (object, name) => Object.getOwnPropertyDescriptor(object, name)?.value;
6
+ function frozenRecord(value) {
7
+ if (!value || typeof value !== 'object' || !Object.isFrozen(value)) return false;
8
+ const prototype = Object.getPrototypeOf(value);
9
+ if (Array.isArray(value) ? prototype !== Array.prototype : prototype !== Object.prototype && prototype !== null) return false;
10
+ if (Array.isArray(value) && Object.hasOwn(value, Symbol.iterator)) return false;
11
+ return Reflect.ownKeys(value).every(key => 'value' in Object.getOwnPropertyDescriptor(value, key));
12
+ }
13
+
14
+ /** Mutable development/custom trees continue through the live traversal. */
15
+ export function appRoutePlan(root) {
16
+ if (!root || !Object.isFrozen(root)) return null;
17
+ const existing = plans.get(root);
18
+ if (existing) return existing;
19
+ if (liveTrees.has(root)) return null;
20
+ const pages = [], nodes = new Map(), rootLayouts = new Set();
21
+ let sequence = 0;
22
+ function visit(node, chain, branch, hasLayout) {
23
+ if (!frozenRecord(node)) return false;
24
+ const children = ownValue(node, 'children'), slots = ownValue(node, 'slots'), files = ownValue(node, 'files');
25
+ if (!Array.isArray(children) || !frozenRecord(children) || !frozenRecord(slots) || !frozenRecord(files)
26
+ || typeof ownValue(node, 'path') !== 'string' || typeof ownValue(node, 'pattern') !== 'string'
27
+ || typeof ownValue(node, 'segment') !== 'string' || nodes.has(node)) return false;
28
+ // Bound retained chains even for hand-written frozen manifests.
29
+ if (sequence >= 4096 || chain.length >= 64) return false;
30
+ const next = [...chain, node], start = sequence++;
31
+ const info = { start, end: start, depth: chain.length, branch, slots: Object.entries(slots), metadataSlots: Object.entries(slots).sort(([a], [b]) => a.localeCompare(b)) };
32
+ nodes.set(node, info);
33
+ if (files.layout && !hasLayout) rootLayouts.add(node.path);
34
+ if (files.page) pages.push({ node, chain: next, start, branch,
35
+ score: node.pattern.split('/').filter(Boolean).reduce((sum, value) => sum + (value.startsWith('[[...') ? 0 : value.startsWith('[...') ? 1 : value.startsWith('[') ? 3 : 6), 0) });
36
+ for (const child of children) if (!visit(child, next, branch, hasLayout || !!files.layout)) return false;
37
+ for (const child of Object.values(slots)) if (!visit(child, next, child, hasLayout || !!files.layout)) return false;
38
+ info.end = sequence;
39
+ return true;
40
+ }
41
+ if (!visit(root, [], root, false)) { liveTrees.add(root); return null; }
42
+ const plan = { pages, nodes, rootLayouts };
43
+ plans.set(root, plan);
44
+ return plan;
45
+ }
@@ -1,26 +1,98 @@
1
1
  // Route matching and navigation state do not need React or metadata rendering.
2
2
  import { removeBasePath } from '../compat/paths.cjs';
3
3
 
4
- export function matchAppPattern(pattern, pathname, prefix = false) {
5
- const expected = pattern.split('/').filter(Boolean), raw = pathname.split('/').filter(Boolean);
6
- let parts;
7
- try { parts = raw.map(decodeURIComponent); } catch { return null; }
8
- const params = {};
9
- for (let index = 0; index < expected.length; index++) {
10
- const segment = expected[index];
4
+ // Only trusted route syntax is cached, never URLs, decoded parameters or request
5
+ // state. Bound the shared lookup's entry count and retained text/segments.
6
+ const patterns = new Map();
7
+ // Live manifest entries own their parsed syntax weakly. Large route tables must
8
+ // not churn the bounded cross-call cache once for every saved parallel slot.
9
+ // Their lifetime follows the existing manifest; no old manifest is retained.
10
+ // Re-read the pattern on each use so mutable development manifests stay live.
11
+ const routePatterns = new WeakMap();
12
+ let patternCharacters = 0, patternSegments = 0;
13
+ function compilePattern(pattern) {
14
+ let compiled = patterns.get(pattern);
15
+ if (compiled) return compiled;
16
+ const segments = pattern.split('/').filter(Boolean);
17
+ const kinds = new Uint8Array(segments.length);
18
+ let score = 0;
19
+ for (let index = 0; index < segments.length; index++) {
20
+ const segment = segments[index];
21
+ if (segment[0] !== '[') { score += 6; continue; }
22
+ score += segment.startsWith('[[...') ? 0 : segment.startsWith('[...') ? 1 : 3;
11
23
  const optional = /^\[\[\.\.\.([^\]]+)\]\]$/.exec(segment);
12
24
  const catchAll = /^\[\.\.\.([^\]]+)\]$/.exec(segment);
13
25
  if (optional || catchAll) {
14
- if (catchAll && index >= parts.length) return null;
15
- if (index < parts.length) params[(optional || catchAll)[1]] = parts.slice(index);
16
- return params;
26
+ kinds[index] = optional ? 3 : 2;
27
+ segments[index] = (optional || catchAll)[1];
28
+ } else {
29
+ const dynamic = /^\[([^\]]+)\]$/.exec(segment);
30
+ if (dynamic) { kinds[index] = 1; segments[index] = dynamic[1]; }
31
+ }
32
+ }
33
+ compiled = { segments, kinds, score, characters: pattern.length };
34
+ if (pattern.length <= 2048 && segments.length <= 64) {
35
+ while (patterns.size >= 1024 || patternCharacters + pattern.length > 128 * 1024 || patternSegments + segments.length > 8192) {
36
+ const oldest = patterns.keys().next().value;
37
+ const removed = patterns.get(oldest);
38
+ patterns.delete(oldest);
39
+ patternCharacters -= removed.characters;
40
+ patternSegments -= removed.segments.length;
17
41
  }
18
- const dynamic = /^\[([^\]]+)\]$/.exec(segment);
19
- if (index >= parts.length) return null;
20
- if (dynamic) params[dynamic[1]] = parts[index];
21
- else if (segment !== parts[index]) return null;
42
+ patterns.set(pattern, compiled);
43
+ patternCharacters += pattern.length;
44
+ patternSegments += segments.length;
22
45
  }
23
- return prefix || expected.length === parts.length ? params : null;
46
+ return compiled;
47
+ }
48
+
49
+ function pathSegments(pathname) {
50
+ const raw = pathname.split('/').filter(Boolean);
51
+ try { return raw.map(decodeURIComponent); }
52
+ catch { return null; }
53
+ }
54
+
55
+ function compiledRoute(route) {
56
+ const pattern = route.pattern;
57
+ const cached = routePatterns.get(route);
58
+ if (cached && cached.pattern === pattern) return cached.compiled;
59
+ const compiled = compilePattern(pattern);
60
+ if (pattern.length <= 2048 && compiled.segments.length <= 64) routePatterns.set(route, { pattern, compiled });
61
+ else routePatterns.delete(route);
62
+ return compiled;
63
+ }
64
+
65
+ function matchesParts({ segments, kinds }, parts, prefix = false, params) {
66
+ for (let index = 0; index < segments.length; index++) {
67
+ const kind = kinds[index];
68
+ if (kind >= 2) {
69
+ if (kind === 2 && index >= parts.length) return false;
70
+ if (params && index < parts.length) params[segments[index]] = parts.slice(index);
71
+ return true;
72
+ }
73
+ if (index >= parts.length) return false;
74
+ if (kind === 1) { if (params) params[segments[index]] = parts[index]; }
75
+ else if (segments[index] !== parts[index]) return false;
76
+ }
77
+ return prefix || segments.length === parts.length;
78
+ }
79
+
80
+ export function matchAppPattern(pattern, pathname, prefix = false) {
81
+ const compiled = compilePattern(pattern), parts = pathSegments(pathname);
82
+ if (!parts) return null;
83
+ const params = {};
84
+ return matchesParts(compiled, parts, prefix, params) ? params : null;
85
+ }
86
+
87
+ /** Decode a request pathname once; each match still owns its parameter values. */
88
+ export function createAppMatcher(pathname) {
89
+ const parts = pathSegments(pathname);
90
+ return (pattern, prefix = false) => {
91
+ const compiled = compilePattern(pattern);
92
+ if (!parts) return null;
93
+ const params = {};
94
+ return matchesParts(compiled, parts, prefix, params) ? params : null;
95
+ };
24
96
  }
25
97
 
26
98
  export function readRouterState(request) {
@@ -49,13 +121,21 @@ export function advancedRoutingSources(request, routes) {
49
121
  const modules = new Set();
50
122
  for (const saved of Object.values(state.slots)) {
51
123
  const pathname = new URL(saved.url, 'http://prnext.local').pathname;
52
- const route = routes.filter(item => item.router === 'app' && item.kind === 'page' && !item.internal && matchAppPattern(item.pattern, pathname))
53
- .sort((a, b) => score(b.pattern) - score(a.pattern))[0];
124
+ const parts = pathSegments(pathname);
125
+ if (!parts) continue;
126
+ let route, bestScore = -1;
127
+ for (let index = 0, length = routes.length; index < length; index++) {
128
+ if (!(index in routes)) continue;
129
+ const item = routes[index];
130
+ if (item.router !== 'app' || item.kind !== 'page' || item.internal) continue;
131
+ const compiled = compiledRoute(item);
132
+ // The original stable sort kept the first route when scores were equal.
133
+ // Lower-scoring routes cannot replace a match already found.
134
+ if (compiled.score <= bestScore || !matchesParts(compiled, parts)) continue;
135
+ route = item;
136
+ bestScore = compiled.score;
137
+ }
54
138
  if (route) modules.add(route.module);
55
139
  }
56
140
  return [...modules];
57
141
  }
58
-
59
- function score(pattern) {
60
- return pattern.split('/').filter(Boolean).reduce((sum, value) => sum + (value.startsWith('[[...') ? 0 : value.startsWith('[...') ? 1 : value.startsWith('[') ? 3 : 6), 0);
61
- }