@thomas.f/prnext 0.1.12-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 (50) hide show
  1. package/README.md +262 -33
  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/compiler.mjs +3 -1
  7. package/build/config.mjs +9 -2
  8. package/build/font-cache.mjs +50 -0
  9. package/build/font-files.mjs +5 -3
  10. package/build/fonts.mjs +1 -1
  11. package/build/index.mjs +5 -0
  12. package/build/metadata-conflicts.mjs +13 -0
  13. package/build/metadata-images.mjs +3 -3
  14. package/build/metadata-routes.mjs +3 -3
  15. package/build/native-packages.mjs +5 -1
  16. package/build/npm-packages.mjs +2 -1
  17. package/build/optimize-imports.mjs +112 -0
  18. package/build/proxy-body-config.mjs +10 -0
  19. package/build/scan.mjs +9 -7
  20. package/cli.mjs +16 -0
  21. package/compat/after.cjs +51 -0
  22. package/compat/index.d.ts +3 -1
  23. package/compat/server.cjs +2 -1
  24. package/compat/server.d.cts +2 -0
  25. package/native/launch.mjs +19 -0
  26. package/package.json +7 -7
  27. package/runtime/api.mjs +15 -5
  28. package/runtime/app-render.mjs +78 -33
  29. package/runtime/app-route-plan.mjs +45 -0
  30. package/runtime/app-routing-state.mjs +101 -21
  31. package/runtime/app-routing.mjs +37 -18
  32. package/runtime/app-segments.mjs +25 -11
  33. package/runtime/app-static.mjs +1 -1
  34. package/runtime/app-stream-html.mjs +2 -1
  35. package/runtime/cache-request.mjs +10 -2
  36. package/runtime/cloud/archive.mjs +316 -0
  37. package/runtime/cloud/client.mjs +658 -0
  38. package/runtime/cloud/environment.mjs +60 -0
  39. package/runtime/cloud/state.mjs +141 -0
  40. package/runtime/cloud/upload.mjs +48 -0
  41. package/runtime/html-envelope.mjs +70 -7
  42. package/runtime/http.mjs +7 -4
  43. package/runtime/middleware.mjs +3 -3
  44. package/runtime/request-transport.mjs +1 -1
  45. package/runtime/route-static.mjs +1 -1
  46. package/runtime/rsc-worker.mjs +27 -4
  47. package/runtime/stream-utils.mjs +22 -3
  48. package/runtime/transport.mjs +16 -0
  49. package/runtime/worker-metadata.mjs +23 -0
  50. package/runtime/worker.mjs +36 -9
package/runtime/api.mjs CHANGED
@@ -178,6 +178,7 @@ function parseBody(bytes, contentType = '') {
178
178
  }
179
179
 
180
180
  export function runApi(options) {
181
+ if (options.timeoutMs === undefined && options.route?.cacheConfig?.maxDuration) options = { ...options, timeoutMs: options.route.cacheConfig.maxDuration * 1000 };
181
182
  return withCacheRequest(options, options.route?.router === 'app' ? 'route' : 'pages', context => runApiInner(options, context));
182
183
  }
183
184
 
@@ -189,11 +190,19 @@ async function runApiInner({ modulePath, route = {}, manifest = {}, production =
189
190
  const mod = (pages && loadedPagesModule(modulePath)) || await withSignal(loadModule(modulePath, pages), stream ? signal : undefined);
190
191
  const requestOriginal = originalUrl ? new URL(originalUrl, url) : undefined;
191
192
  if (requestOriginal) requestOriginal.pathname = removeBasePath(requestOriginal.pathname, manifest.config?.basePath || '');
192
- const { request, bytes } = createRequest({ url, originalUrl: requestOriginal?.href, params, method, headers, body });
193
- request.draftMode = cacheContext.draftMode;
194
- request.previewData = cacheContext.previewData;
195
- request.preview = cacheContext.previewData !== false;
196
- if (typeof mod.default !== 'function') {
193
+ const appHandler = typeof mod.default !== 'function';
194
+ // App Route Handlers receive a NextRequest below. Constructing the Pages
195
+ // IncomingMessage here duplicates URL, header and cookie work and allocates
196
+ // an unused Duplex. Keep the existing Pages request path unchanged.
197
+ const { request, bytes } = appHandler
198
+ ? { bytes: Buffer.isBuffer(body) ? body : Buffer.from(body || '', 'base64') }
199
+ : createRequest({ url, originalUrl: requestOriginal?.href, params, method, headers, body });
200
+ if (!appHandler) {
201
+ request.draftMode = cacheContext.draftMode;
202
+ request.previewData = cacheContext.previewData;
203
+ request.preview = cacheContext.previewData !== false;
204
+ }
205
+ if (appHandler) {
197
206
  const {NextRequest, proxyRouteRequest} = await appRequestPrimitives();
198
207
  if (route.router === 'app' && manifest.dev) {
199
208
  const listing = await collectStaticParams({ page: mod, pageConfig: route.handlerConfig, segments: [] }, route.pattern);
@@ -221,6 +230,7 @@ async function runApiInner({ modulePath, route = {}, manifest = {}, production =
221
230
  const timeout = lifecycle && setTimeout(() => lifecycle.controller.abort(apiTimeoutError('response headers', timeoutMs)), timeoutMs);
222
231
  timeout?.unref();
223
232
  const pendingResponse = runRequestContext({ url, method, headers, params, production, cacheComponents: manifest.config?.cacheComponents, cacheLife: manifest.config?.cacheLife, cacheHandlers: manifest.config?.cacheHandlers, cacheHandler: manifest.config?.cacheHandler, cacheMaxMemorySize: manifest.config?.cacheMaxMemorySize, distDir: path.dirname(path.dirname(modulePath)), signal: lifecycle?.controller.signal || signal, previewModeId: manifest.previewModeId, basePath: manifest.config?.basePath || '', routePattern: route.pattern, cacheConfig: route.cacheConfig, phase: 'route', cacheState: cacheContext.cacheState, staticState: cacheContext.staticState, mutableCookies: true }, async () => {
233
+ currentRequest().afterScope = cacheContext.afterScope;
224
234
  let result;
225
235
  try { result = await handler(proxyRouteRequest(new NextRequest(requestOriginal || url, init), currentRequest()), { params: Promise.resolve(params) }); }
226
236
  catch (error) {
@@ -7,7 +7,7 @@ import { addBasePath, removeBasePath } from '../compat/paths.cjs';
7
7
  import { isDynamicBailout } from '../compat/dynamic-bailout.cjs';
8
8
  import { Readable } from 'node:stream';
9
9
  import { Worker } from 'node:worker_threads';
10
- import { workerMetadata } from './worker-metadata.mjs';
10
+ import { workerMetadata, freezeClientModules, isImmutableClientModules } from './worker-metadata.mjs';
11
11
  import { workerArtifacts } from './worker-artifacts.mjs';
12
12
  import path from 'node:path';
13
13
  import { PARTIAL_PREFETCH_TYPE, PARTIAL_PREFETCH_BYTES } from './app-prefetch.mjs';
@@ -40,6 +40,7 @@ const edgeManifests = new WeakMap();
40
40
  const emptyManifest = Object.freeze({});
41
41
  let nextId = 0;
42
42
  const pending = new Map();
43
+ const noop = () => {};
43
44
  // Avoid temporary arrays on every completion/cancellation at high concurrency.
44
45
  function hasPending(worker, minimum = 1, cancelledOnly = false) {
45
46
  let count = 0;
@@ -72,14 +73,14 @@ function flightDecoder(production) {
72
73
  // React's development decoder guards module initialization with NODE_ENV.
73
74
  // This require is synchronous; restore the host before any stream work.
74
75
  process.env.NODE_ENV = production ? 'production' : 'development';
75
- let decoder = require(filename).createFromNodeStream;
76
- if (typeof decoder !== 'function') {
76
+ let decoder = require(filename);
77
+ if (typeof decoder.createFromNodeStream !== 'function') {
77
78
  // A consumer may have previously required the guarded development file
78
79
  // in production, leaving an empty module in Node's cache.
79
80
  delete require.cache[filename];
80
- decoder = require(filename).createFromNodeStream;
81
+ decoder = require(filename);
81
82
  }
82
- 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');
83
84
  flightDecoders.set(production, decoder);
84
85
  return decoder;
85
86
  } finally {
@@ -182,7 +183,7 @@ function getWorker(production) {
182
183
  }
183
184
  if (type === 'chunk') {
184
185
  try { request.channel.chunk(chunk, byteOffset, byteLength); }
185
- catch (error) { request.channel.error(error); current.postMessage({ id, type: 'cancel' }); request.cancel(error); }
186
+ catch (error) { request.channel.error(error); current.postMessage({ id, type: 'cancel' }); request.cancel(error); return; }
186
187
  if (!done) return;
187
188
  }
188
189
  if (request.completed) {
@@ -194,6 +195,8 @@ function getWorker(production) {
194
195
  // work can legitimately continue afterwards and retains its own bounds;
195
196
  // supervise isolate responsiveness, not total background-task duration.
196
197
  clearTimeout(request.timeout);
198
+ // A destroyed Timeout can still own its AsyncLocalStorage frame.
199
+ request.timeout = undefined;
197
200
  request.releaseWorkWatch = workWatchdog.add();
198
201
  request.detach?.();
199
202
  if (type === 'end' || done) request.channel.end();
@@ -219,7 +222,11 @@ function getWorker(production) {
219
222
  // Keep only settlement bookkeeping after sending the body. The
220
223
  // isolate can still be flushing cache writes after its terminal frame.
221
224
  request.channel = undefined;
222
- request.resolve = request.reject = () => {};
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;
223
230
  });
224
231
  current.on('error', fail);
225
232
  current.on('exit', code => fail(new Error(`App Router worker exited (${code})`), true));
@@ -227,12 +234,41 @@ function getWorker(production) {
227
234
  return current;
228
235
  }
229
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
+
230
246
  export function closeAppRuntime() {
231
247
  const current = [...workers.values()];
232
248
  return Promise.all(current.map(worker => lifecycles.get(worker).terminate()));
233
249
  }
234
250
 
235
- export async function renderFlight(request, { softTimeoutMs = 25_000, hardTimeoutMs = 28_000, signal } = {}) {
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);
267
+ }
268
+
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;
236
272
  const deadline = performance.now() + hardTimeoutMs;
237
273
  let current, lifecycle, lease;
238
274
  for (;;) {
@@ -254,20 +290,10 @@ export async function renderFlight(request, { softTimeoutMs = 25_000, hardTimeou
254
290
  current.ref();
255
291
  const id = ++nextId;
256
292
  let settle;
257
- trackRequestWork(new Promise(resolve => { settle = resolve; }), signal, { deadline, supervised: true });
258
- const timeout = setTimeout(() => {
259
- // A blocking npm dependency cannot honor an AbortSignal. Retire the
260
- // isolate at the hard deadline so subsequent requests can recover.
261
- void lifecycle.terminate(appTimeoutError('RSC worker', hardTimeoutMs)).finally(settle).catch(error => console.error('[prnext] RSC worker termination failed:', error));
262
- pending.delete(id);
263
- lease.release();
264
- const error = appTimeoutError('RSC worker', hardTimeoutMs);
265
- if (entry.completed) console.error('[prnext] RSC background work failed:', error);
266
- entry.channel?.error(error);
267
- entry.detach?.();
268
- reject(error);
269
- }, Math.max(0, deadline - performance.now()));
270
- const entry = { worker: current, resolve, reject, timeout, operation: request.operation, lease, 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()));
271
297
  function cancel(reason) {
272
298
  if (entry.cancelled) return;
273
299
  // Keep the independent deadline until the isolate confirms settlement.
@@ -278,7 +304,11 @@ export async function renderFlight(request, { softTimeoutMs = 25_000, hardTimeou
278
304
  // Pause new work at 16 abandoned requests while their acknowledgements
279
305
  // settle. Existing mutation/cache work keeps its original hard deadline.
280
306
  if (!hasPending(current) && !lifecycle.retiring) current.unref();
281
- reject(reason instanceof Error ? reason : new Error('App Router response was cancelled'));
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;
282
312
  }
283
313
  entry.cancel = cancel;
284
314
  if (request.stream) entry.channel = parentStreamChannel(current, id, cancel);
@@ -294,6 +324,10 @@ export async function renderFlight(request, { softTimeoutMs = 25_000, hardTimeou
294
324
  pending.set(id, entry);
295
325
  try {
296
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
+ }
297
331
  if (request.production) {
298
332
  const artifactId = artifacts.get(current)(wire.partialFlight);
299
333
  if (artifactId !== undefined) { wire.partialFlightId = artifactId; delete wire.partialFlight; }
@@ -317,11 +351,12 @@ export async function renderFlight(request, { softTimeoutMs = 25_000, hardTimeou
317
351
  }
318
352
 
319
353
  export async function decodeFlight(body, clientModules = {}, distDir, { production = process.env.NODE_ENV === 'production' } = {}) {
320
- const ids = Object.keys(clientModules);
321
354
  let cached = ssrManifests.get(clientModules);
322
355
  // Keep only the manifest's module mapping, never a decoded React model or
323
356
  // request context. Weak ownership releases it with the build manifest.
324
- 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)))) {
325
360
  const moduleMap = Object.create(null), paths = new Map();
326
361
  for (const id of ids) {
327
362
  const item = clientModules[id];
@@ -332,13 +367,20 @@ export async function decodeFlight(body, clientModules = {}, distDir, { producti
332
367
  moduleMap[id] = { '*': { id: moduleUrl, chunks: [moduleUrl, moduleUrl], name: '*' } };
333
368
  paths.set(id, item.ssrModule);
334
369
  }
335
- cached = { distDir, paths, moduleMap };
370
+ cached = { distDir, paths, moduleMap, immutable };
336
371
  ssrManifests.set(clientModules, cached);
337
- }
372
+ } else if (immutable) cached.immutable = true;
338
373
  // Hosts can build/render both modes in one process. The encoded protocol,
339
374
  // rather than the host's current environment or import cache, selects React.
340
375
  const decoder = flightDecoder(Boolean(production));
341
- 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);
342
384
  }
343
385
 
344
386
  export async function renderHtml(tree, router, { timeoutMs = 25_000, formState = null, staticGeneration = false, nonce, strictMode = true } = {}) {
@@ -414,13 +456,16 @@ export async function renderAppPage(options) {
414
456
  return response;
415
457
  }
416
458
 
417
- 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 }) {
418
460
  let clientModules = manifest.app?.clientModules || emptyManifest;
419
461
  if (route.cacheConfig?.runtime === 'edge') {
420
462
  let edge = !manifest.dev && edgeManifests.get(clientModules);
421
463
  if (!edge) {
422
464
  edge = Object.fromEntries(Object.entries(clientModules).map(([id, item]) => [id, item.edgeSsrModule ? { ...item, ssrModule: item.edgeSsrModule } : item]));
423
- if (!manifest.dev) edgeManifests.set(clientModules, edge);
465
+ if (!manifest.dev) {
466
+ if (isImmutableClientModules(clientModules)) freezeClientModules(edge);
467
+ edgeManifests.set(clientModules, edge);
468
+ }
424
469
  }
425
470
  clientModules = edge;
426
471
  }
@@ -490,7 +535,7 @@ async function renderAppPageInner({ modulePath, route = {}, manifest = {}, distD
490
535
  if (stream && (!request.action || request.action.id)) {
491
536
  let progressive;
492
537
  try {
493
- progressive = await renderFlight({ ...request, stream: true }, { signal });
538
+ progressive = await renderFlight({ ...request, stream: true }, { signal, deferFinalizer });
494
539
  const rsc = Object.entries(headers).some(([name, value]) => name.toLowerCase() === 'rsc' && value === '1');
495
540
  if (rsc || request.action?.id) {
496
541
  if (method === 'HEAD') await progressive.body.cancel();
@@ -514,7 +559,7 @@ async function renderAppPageInner({ modulePath, route = {}, manifest = {}, distD
514
559
  }
515
560
  }
516
561
  let result;
517
- try { result = await renderFlight(request, { signal }); }
562
+ try { result = await renderFlight(request, { signal, deferFinalizer }); }
518
563
  catch (error) {
519
564
  const result = navigationResponse(error);
520
565
  if (result) return { ...result, headers: { ...responseHeaders, ...result.headers, ...error.responseHeaders } };
@@ -531,7 +576,7 @@ async function renderAppPageInner({ modulePath, route = {}, manifest = {}, distD
531
576
  let html, model, recovery = false;
532
577
  try {
533
578
  model = await decodeFlight(result.body, clientModules, distDir, { production });
534
- 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 });
535
580
  } catch (error) {
536
581
  if (error?.statusCode === 504) throw error;
537
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
- }
@@ -6,8 +6,9 @@ import path from 'node:path';
6
6
  import { pathToFileURL } from 'node:url';
7
7
  import { currentRequest, runRequestContext } from '../compat/headers.cjs';
8
8
  import { staticParams } from '../compat/static-generation.cjs';
9
+ import { appRoutePlan } from './app-route-plan.mjs';
9
10
 
10
- import { matchAppPattern, readRouterState } from './app-routing-state.mjs';
11
+ import { matchAppPattern, createAppMatcher, readRouterState } from './app-routing-state.mjs';
11
12
  export { matchAppPattern, readRouterState, advancedRoutingSources } from './app-routing-state.mjs';
12
13
 
13
14
  export async function loadAdvancedRouting(entry, request) {
@@ -51,10 +52,12 @@ function selectedSegments(chain, params) {
51
52
  /** Parallel slots retain their original Flight children on soft navigation. */
52
53
  export function createAdvancedTree(entry, request, searchParams) {
53
54
  const pathname = removeBasePath(new URL(request.url).pathname, request.basePath || '');
55
+ const matchCurrent = createAppMatcher(pathname);
54
56
  const old = readRouterState(request), slots = {}, activeParams = { ...request.params };
55
57
  const rebuild = !!old && (old.refresh || old.restore || request.method === 'POST');
56
58
  let invalid = false;
57
59
  const root = entry.routing;
60
+ const plan = appRoutePlan(root);
58
61
  let metadataHead;
59
62
  const keyScopes = new Map();
60
63
  const instantTrees = [];
@@ -62,18 +65,20 @@ export function createAdvancedTree(entry, request, searchParams) {
62
65
  if (request.partialParams?.length) keyScopes.set(key, { key, prefix, suffix, names: Object.keys(values), pattern: node.pattern, ...(liveProps ? { liveProps } : {}), ...(segments ? { segments } : {}) });
63
66
  return key;
64
67
  }
65
- const rootLayouts = new Set();
68
+ const rootLayouts = plan?.rootLayouts || new Set();
66
69
  const metadataSelections = new Map();
67
70
  let missing = entry.interceptionOnly && !old, rootPage;
68
71
  const all = [];
69
72
  function walk(node, chain = []) {
70
73
  const next = [...chain, node];
71
74
  if (node.files.layout && !chain.some(parent => parent.files.layout)) rootLayouts.add(node.path);
72
- if (node.files.page) all.push({ node, chain: next, params: matchAppPattern(node.pattern, pathname) });
75
+ if (node.files.page) all.push({ node, chain: next, params: matchCurrent(node.pattern) });
73
76
  for (const child of node.children) walk(child, next);
74
77
  for (const child of Object.values(node.slots)) walk(child, next);
75
78
  }
76
- walk(root);
79
+ if (plan) {
80
+ for (const page of plan.pages) all.push({ ...page, params: matchCurrent(page.node.pattern) });
81
+ } else walk(root);
77
82
  let activeInterceptions;
78
83
  function canIntercept(candidate) {
79
84
  if (!candidate.node.interception || !old) return false;
@@ -97,14 +102,29 @@ export function createAdvancedTree(entry, request, searchParams) {
97
102
  function candidates(node, chain = []) {
98
103
  const result = [], next = [...chain, node];
99
104
  if (node.files.page) {
100
- const params = matchAppPattern(node.pattern, pathname);
105
+ const params = matchCurrent(node.pattern);
101
106
  if (params && (!node.interception || canIntercept({ node }))) result.push({ node, chain: next, params });
102
107
  }
103
108
  for (const child of node.children) result.push(...candidates(child, next));
104
109
  return result.sort((left, right) => Number(!!right.node.interception) - Number(!!left.node.interception) || score(right.node.pattern) - score(left.node.pattern));
105
110
  }
111
+ function firstCandidate(node) {
112
+ if (!plan) return candidates(node)[0];
113
+ const scope = plan.nodes.get(node);
114
+ let best;
115
+ for (const candidate of all) {
116
+ if (!candidate.params || candidate.branch !== scope.branch || candidate.start < scope.start || candidate.start >= scope.end
117
+ || (candidate.node.interception && !canIntercept(candidate))) continue;
118
+ if (!best || Number(!!candidate.node.interception) > Number(!!best.node.interception)
119
+ || (!!candidate.node.interception === !!best.node.interception && candidate.score > best.score)) best = candidate;
120
+ }
121
+ // Separate request-owned params from the interception/scaffolding candidates,
122
+ // as in the live traversal. No parameter objects survive this request.
123
+ return best && { node: best.node, chain: best.chain.slice(scope.depth), params: matchCurrent(best.node.pattern) };
124
+ }
125
+ const slotEntries = node => plan?.nodes.get(node).slots || Object.entries(node.slots);
106
126
  function scopedParams(node) {
107
- const current = matchAppPattern(node.pattern, pathname, true) || (old && matchAppPattern(node.pattern, old.source, true));
127
+ const current = matchCurrent(node.pattern, true) || (old && matchAppPattern(node.pattern, old.source, true));
108
128
  if (current) return current;
109
129
  for (const [key, value] of Object.entries(old?.slots || {})) {
110
130
  if (!key.startsWith(`${node.path}::`)) continue;
@@ -117,7 +137,7 @@ export function createAdvancedTree(entry, request, searchParams) {
117
137
  return registerKey(`${node.path}::${JSON.stringify(scopedParams(node))}`, node, `${node.path}::`);
118
138
  }
119
139
  function sourceURL(node, previous) {
120
- return matchAppPattern(node.pattern, pathname, true) ? pathname + new URL(request.url).search : previous?.source || previous?.url || old?.source || pathname;
140
+ return matchCurrent(node.pattern, true) ? pathname + new URL(request.url).search : previous?.source || previous?.url || old?.source || pathname;
121
141
  }
122
142
  function accessURL(url, previous) {
123
143
  if (previous?.url === url && previous.accessUrl) return previous.accessUrl;
@@ -148,8 +168,7 @@ export function createAdvancedTree(entry, request, searchParams) {
148
168
  function slot(node, owner, name) {
149
169
  const key = registerKey(`${scope(owner)}::${name}`, owner, `${owner.path}::`, `::${name}`, ['id']);
150
170
  const previous = old?.slots[key];
151
- let choices = candidates(node);
152
- let selected = rebuild && previous ? savedBranch(node, previous) : choices[0];
171
+ let selected = rebuild && previous ? savedBranch(node, previous) : firstCandidate(node);
153
172
  if (rebuild && previous && !selected) { invalid = true; return { tree: null, segments: [] }; }
154
173
  if (!rebuild) {
155
174
  for (const candidate of interceptions) {
@@ -189,9 +208,9 @@ export function createAdvancedTree(entry, request, searchParams) {
189
208
  // Find the nearest default whose route prefix contains the destination.
190
209
  const defaults = [];
191
210
  function findDefault(value, chain = []) {
192
- if (!matchAppPattern(value.pattern, pathname, true) || (value.interception && !old)) return;
211
+ if (!matchCurrent(value.pattern, true) || (value.interception && !old)) return;
193
212
  const next = [...chain, value];
194
- if (value.files.default) defaults.push({ node: value, chain: next, params: matchAppPattern(value.pattern, pathname, true), default: true });
213
+ if (value.files.default) defaults.push({ node: value, chain: next, params: matchCurrent(value.pattern, true), default: true });
195
214
  for (const child of value.children) findDefault(child, next);
196
215
  }
197
216
  findDefault(node);
@@ -207,9 +226,9 @@ export function createAdvancedTree(entry, request, searchParams) {
207
226
  let selectedTree = renderPath(selected.chain, selected, 0, name === 'children' && owner === node);
208
227
  const context = request.routingContexts?.get(selectedURL);
209
228
  if (context && request.renderRoutingBranch) selectedTree = request.renderRoutingBranch(selectedTree, context);
210
- const instant=selected.node.config?.page?.instant ?? [...selected.chain].reverse().find(item=>item.config?.layout?.instant!==undefined)?.config.layout.instant;
211
- if(request.instantValidation && name!=='children' && instantEnabled(instant,request.production)){
212
- instantTrees.push({path:(owner.path || '/')+' @'+name,tree:selectedTree});
229
+ if(request.instantValidation && name!=='children'){
230
+ const instant=selected.node.config?.page?.instant ?? selected.chain.findLast(item=>item.config?.layout?.instant!==undefined)?.config.layout.instant;
231
+ if(instantEnabled(instant,request.production)) instantTrees.push({path:(owner.path || '/')+' @'+name,tree:selectedTree});
213
232
  }
214
233
  return { tree: React.createElement(entry.LayoutSlot, { key, id: key, preserve: false }, selectedTree), segments: selected.default ? [] : selectedSegments(selected.chain.slice(node === owner ? 1 : 0), selected.params) };
215
234
  }
@@ -248,7 +267,7 @@ export function createAdvancedTree(entry, request, searchParams) {
248
267
  const selected = { children: selection.default ? [] : selectedSegments(chain.slice(index + 1), selection.params) };
249
268
  if (preserveChild) selected.children = null;
250
269
  const parallel = {};
251
- for (const [name, child] of Object.entries(node.slots)) {
270
+ for (const [name, child] of slotEntries(node)) {
252
271
  const rendered = slot(child, node, name);
253
272
  parallel[name] = rendered.tree; selected[name] = rendered.segments;
254
273
  }
@@ -286,7 +305,7 @@ export function createAdvancedTree(entry, request, searchParams) {
286
305
  if (layout) metadataItems.push({ module: node.files.layout, params: staticParams(layout.params, currentRequest()), filePath: page ? undefined : node.path, context: layout.context });
287
306
  // Next orders paths containing named slots before ordinary children,
288
307
  // with slot names in locale order (compareAppPaths in its app loader).
289
- for (const [, child] of Object.entries(node.slots).sort(([a], [b]) => a.localeCompare(b))) visit(child);
308
+ for (const [, child] of plan?.nodes.get(node).metadataSlots || Object.entries(node.slots).sort(([a], [b]) => a.localeCompare(b))) visit(child);
290
309
  if (page) metadataItems.push({ module: node.files[page.default ? 'default' : 'page'], params: staticParams(page.params, currentRequest()), searchParams: page.searchParams || searchParams, filePath: node.path, context: request.routingContexts?.get(page.url) });
291
310
  for (const child of node.children) visit(child);
292
311
  }
@@ -300,7 +319,7 @@ export function createAdvancedTree(entry, request, searchParams) {
300
319
  metadataHead = head;
301
320
  const main = slot(root, root, 'children');
302
321
  const parallel = {}, selected = { children: main.segments };
303
- for (const [name, child] of Object.entries(root.slots)) {
322
+ for (const [name, child] of slotEntries(root)) {
304
323
  const result = slot(child, root, name);
305
324
  parallel[name] = result.tree; selected[name] = result.segments;
306
325
  }
@@ -310,7 +329,7 @@ export function createAdvancedTree(entry, request, searchParams) {
310
329
  const content = React.createElement(React.Fragment, null, main.tree, ...Object.values(parallel));
311
330
  instantTrees.push({ path: root.path || '/', tree: root.files.loading ? React.createElement(React.Suspense, { fallback: React.createElement(component(root.files.loading, 'loading')) }, content) : content });
312
331
  }
313
- const instantDisabled = all.flatMap(item => item.chain).some(node => Object.values(node.config || {}).some(config => config.instant && typeof config.instant === 'object' && (config.instant.unstable_disableValidation || (request.production ? config.instant.unstable_disableBuildValidation : config.instant.unstable_disableDevValidation))));
332
+ const instantDisabled = request.instantValidation && all.some(item => item.chain.some(node => Object.values(node.config || {}).some(config => config.instant && typeof config.instant === 'object' && (config.instant.unstable_disableValidation || (request.production ? config.instant.unstable_disableBuildValidation : config.instant.unstable_disableDevValidation)))));
314
333
  let tree = wrap(root, main.tree, parallel, selected, scope(root), {}, rootPage?.chain.slice(1).map(item => item.segment), rootPage?.node);
315
334
  if (missing) {
316
335
  request.responseStatus = 404;