@thomas.f/prnext 0.1.12-alpha → 0.1.15-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 (73) hide show
  1. package/README.md +305 -65
  2. package/build/ADAPTERS.md +121 -0
  3. package/build/actions.mjs +4 -0
  4. package/build/app-config.mjs +2 -0
  5. package/build/app.mjs +23 -5
  6. package/build/build-lock.mjs +71 -0
  7. package/build/bundle-runtime.mjs +82 -0
  8. package/build/compiler.mjs +3 -1
  9. package/build/config.mjs +9 -2
  10. package/build/font-cache.mjs +50 -0
  11. package/build/font-files.mjs +5 -3
  12. package/build/fonts.mjs +1 -1
  13. package/build/index.mjs +12 -1
  14. package/build/inspect.mjs +20 -2
  15. package/build/inspection-summary.mjs +37 -0
  16. package/build/metadata-conflicts.mjs +13 -0
  17. package/build/metadata-images.mjs +3 -3
  18. package/build/metadata-routes.mjs +3 -3
  19. package/build/native-packages.mjs +5 -1
  20. package/build/npm-packages.mjs +2 -1
  21. package/build/og.mjs +3 -9
  22. package/build/optimize-imports.mjs +112 -0
  23. package/build/output-contract.mjs +41 -0
  24. package/build/precompress.mjs +13 -0
  25. package/build/proxy-body-config.mjs +10 -0
  26. package/build/scan.mjs +10 -8
  27. package/build/static-adapter.mjs +100 -0
  28. package/cli.mjs +64 -11
  29. package/compat/after.cjs +51 -0
  30. package/compat/diagnostics.cjs +4 -0
  31. package/compat/index.d.ts +32 -1
  32. package/compat/server.cjs +2 -1
  33. package/compat/server.d.cts +2 -0
  34. package/index.mjs +2 -0
  35. package/native/launch.mjs +19 -0
  36. package/package.json +8 -9
  37. package/release/verify.mjs +2 -0
  38. package/runtime/api.mjs +20 -7
  39. package/runtime/app-render.mjs +78 -33
  40. package/runtime/app-route-plan.mjs +45 -0
  41. package/runtime/app-routing-state.mjs +101 -21
  42. package/runtime/app-routing.mjs +37 -18
  43. package/runtime/app-segments.mjs +25 -11
  44. package/runtime/app-static.mjs +1 -1
  45. package/runtime/app-stream-html.mjs +2 -1
  46. package/runtime/cache-request.mjs +10 -2
  47. package/runtime/cloud/archive.mjs +317 -0
  48. package/runtime/cloud/client.mjs +641 -0
  49. package/runtime/cloud/environment.mjs +60 -0
  50. package/runtime/cloud/state.mjs +156 -0
  51. package/runtime/cloud/upload.mjs +48 -0
  52. package/runtime/html-envelope.mjs +70 -7
  53. package/runtime/http.mjs +7 -4
  54. package/runtime/middleware.mjs +3 -3
  55. package/runtime/og/Geist-Regular.ttf +0 -0
  56. package/runtime/og/LICENSE +373 -0
  57. package/runtime/og/LICENSE.Geist +93 -0
  58. package/runtime/og/LICENSE.fflate +21 -0
  59. package/runtime/og/LICENSE.harfbuzzjs +22 -0
  60. package/runtime/og/LICENSE.satori +373 -0
  61. package/runtime/og/NOTICE.txt +4 -0
  62. package/runtime/og/hb.wasm +0 -0
  63. package/runtime/og/index.node.mjs +31260 -0
  64. package/runtime/og/resvg.wasm +0 -0
  65. package/runtime/og-backend.mjs +8 -0
  66. package/runtime/og-loader.mjs +2 -15
  67. package/runtime/request-transport.mjs +1 -1
  68. package/runtime/route-static.mjs +1 -1
  69. package/runtime/rsc-worker.mjs +27 -4
  70. package/runtime/stream-utils.mjs +22 -3
  71. package/runtime/transport.mjs +16 -0
  72. package/runtime/worker-metadata.mjs +23 -0
  73. package/runtime/worker.mjs +36 -9
package/compat/index.d.ts CHANGED
@@ -118,6 +118,8 @@ export interface NextConfig {
118
118
  workerRetirement?: { idleSeconds?: number; laneIdleSeconds?: number; keepWarm?: boolean };
119
119
  /** Next dev page-retention hints; PRNext builds all routes without that eviction queue. */
120
120
  onDemandEntries?: { maxInactiveAge?: number; pagesBufferLength?: number };
121
+ /** Disable the Next development indicator. Error overlays and Fast Refresh remain enabled. */
122
+ devIndicators?: false;
121
123
  i18n?: { locales: string[]; defaultLocale: string; localeDetection?: boolean; domains?: Array<{domain: string; defaultLocale: string; locales?: string[]; http?: boolean}> };
122
124
  webpack?: (config: import('webpack').Configuration, context: { buildId: string; dev: boolean; isServer: boolean; nextRuntime?: 'nodejs' | 'edge'; webpack: typeof import('webpack'); defaultLoaders: {babel: {loader: string}} }) => import('webpack').Configuration | Promise<import('webpack').Configuration>;
123
125
  turbopack?: { root?: string; rules?: Record<string, TurbopackRule | TurbopackRule[]>; resolveAlias?: Record<string, string | { browser: string }>; resolveExtensions?: string[] };
@@ -141,7 +143,7 @@ export interface NextConfig {
141
143
  implementation?: 'sass' | 'sass-embedded';
142
144
  [option: string]: unknown;
143
145
  };
144
- experimental?: { nextScriptWorkers?: boolean; serverActions?: true | { allowedOrigins?: string[]; bodySizeLimit?: number | string } };
146
+ experimental?: { nextScriptWorkers?: boolean; optimizePackageImports?: string[]; proxyClientMaxBodySize?: number | string; serverActions?: true | { allowedOrigins?: string[]; bodySizeLimit?: number | string } };
145
147
  trailingSlash?: boolean;
146
148
  skipTrailingSlashRedirect?: boolean;
147
149
  skipMiddlewareUrlNormalize?: boolean;
@@ -166,4 +168,33 @@ export interface NextConfig {
166
168
  rewrites?: () => Rewrite[] | { beforeFiles?: Rewrite[]; afterFiles?: Rewrite[]; fallback?: Rewrite[] } | Promise<Rewrite[] | { beforeFiles?: Rewrite[]; afterFiles?: Rewrite[]; fallback?: Rewrite[] }>;
167
169
  }
168
170
  export type PRNextConfig = NextConfig;
171
+ /** Public build envelope; internal manifest fields are adapter-specific. */
172
+ export type BuildOutput = {
173
+ version: 1;
174
+ buildId: string;
175
+ manifest: 'manifest.json';
176
+ } & ({
177
+ adapter: 'react';
178
+ assets: 'assets';
179
+ server: { entry: 'runtime/worker.mjs'; runtimeContract: 'runtime-contract.json' };
180
+ fallback: null;
181
+ } | {
182
+ adapter: 'static';
183
+ assets: 'static';
184
+ server: null;
185
+ fallback: null | 'index.html';
186
+ });
187
+ export interface StaticBuildOptions {
188
+ from?: string;
189
+ spa?: boolean;
190
+ distDir?: string;
191
+ dev?: false;
192
+ }
193
+ export interface BuildResult { buildId: string; outputDirectory: string; }
194
+ export interface StaticBuildResult extends BuildResult { files: number; bytes: number; }
195
+ export function build(projectRoot: string, options: StaticBuildOptions & { adapter: 'static' }): Promise<StaticBuildResult>;
196
+ export function build(projectRoot: string, options?: { adapter?: 'react'; dev?: boolean; envMode?: 'production' | 'development' | 'test' }): Promise<BuildResult>;
197
+ export function buildStatic(projectRoot: string, options?: StaticBuildOptions): Promise<StaticBuildResult>;
198
+ export function readBuildOutput(buildDirectory: string): Promise<BuildOutput>;
199
+ export function validateBuildOutput(value: unknown): BuildOutput;
169
200
  export type { DocumentContext, DocumentInitialProps, DocumentProps } from './document.cjs';
package/compat/server.cjs CHANGED
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
  const { NextRequest, NextResponse, NextURL } = require('./server-primitives.cjs');
3
+ const { after } = require('./after.cjs');
3
4
 
4
5
  async function connection() {
5
6
  const context = require('./headers.cjs').currentRequest();
@@ -10,4 +11,4 @@ async function connection() {
10
11
  dynamicUsage('connection()', context);
11
12
  }
12
13
 
13
- module.exports = { NextRequest, NextResponse, NextURL, connection };
14
+ module.exports = { NextRequest, NextResponse, NextURL, connection, after };
@@ -35,3 +35,5 @@ export interface MiddlewareConfig {
35
35
  export interface ProxyConfig { matcher?: MiddlewareConfig['matcher'] }
36
36
  /** Exclude the following render from prerendering until a request is available. */
37
37
  export function connection(): Promise<void>;
38
+ /** Post-response work in Node Route Handlers. Other after() lifecycles fail explicitly. */
39
+ export function after(task: Promise<unknown> | (() => unknown)): void;
package/index.mjs CHANGED
@@ -1 +1,3 @@
1
1
  export { build } from './build/index.mjs';
2
+ export { buildStatic } from './build/static-adapter.mjs';
3
+ export { readBuildOutput, validateBuildOutput } from './build/output-contract.mjs';
@@ -0,0 +1,19 @@
1
+ // A production CLI has nothing to supervise once the native server starts.
2
+ // On supported Linux Node releases, replace it instead of retaining a Node heap.
3
+ // Keep the portable parent/pipe lifecycle for Windows, older Node, IPC callers
4
+ // and instrumentation which may own descriptors or need JS exit handlers.
5
+ export function replaceWithNative(binary, args, env, runtime = process) {
6
+ const options = [...(runtime.execArgv || []), env.NODE_OPTIONS || ''].join(' ');
7
+ // Only heap sizing is known not to require the Node launcher's lifetime.
8
+ // Be conservative for all other Node flags, including future diagnostics,
9
+ // loaders, preloads, permissions and signal-triggered reports/snapshots.
10
+ const customRuntime = options.replace(/(?:^|\s)--max[-_](?:semi|old)[-_]space[-_]size(?:=|\s+)\d+(?=\s|$)/g, '').trim();
11
+ if (runtime.platform !== 'linux' || typeof runtime.execve !== 'function'
12
+ || typeof runtime.send === 'function' || env.NODE_V8_COVERAGE || customRuntime) return false;
13
+ // execve keeps stdin/out/err and the PID. No parent pipe is needed: signals
14
+ // reach Rust directly, and its worker pipes still supervise every Node child.
15
+ // The marker also makes an old native binary fail clearly instead of using
16
+ // this lifecycle without the Linux parent-death guard it requires.
17
+ runtime.execve(binary, [binary, ...args, '--cli-exec'], env);
18
+ return true; // Only reached by injected test runtimes; real execve never returns.
19
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thomas.f/prnext",
3
- "version": "0.1.12-alpha",
3
+ "version": "0.1.15-alpha",
4
4
  "license": "MIT",
5
5
  "description": "Experimental Rust-powered runtime for Next.js apps, React and npm",
6
6
  "homepage": "https://prnext.dev",
@@ -111,7 +111,6 @@
111
111
  "@babel/types": "^7.29.0",
112
112
  "@edge-runtime/vm": "5.0.0",
113
113
  "@vercel/nft": "1.11.0",
114
- "@vercel/og": "1.0.3",
115
114
  "dotenv": "16.6.1",
116
115
  "dotenv-expand": "11.0.7",
117
116
  "esbuild": "^0.28.2",
@@ -133,14 +132,14 @@
133
132
  "react-server-dom-webpack": "19.3.0"
134
133
  },
135
134
  "optionalDependencies": {
136
- "prnext-darwin-arm64": "0.1.12-alpha",
137
- "prnext-darwin-x64": "0.1.12-alpha",
138
- "prnext-linux-arm64-gnu": "0.1.12-alpha",
139
- "prnext-linux-x64-gnu": "0.1.12-alpha",
140
- "@thomas.f/prnext-win32-x64-msvc": "0.1.12-alpha",
141
- "@thomas.f/prnext-win32-arm64-msvc": "0.1.12-alpha"
135
+ "prnext-darwin-arm64": "0.1.15-alpha",
136
+ "prnext-darwin-x64": "0.1.15-alpha",
137
+ "prnext-linux-arm64-gnu": "0.1.15-alpha",
138
+ "prnext-linux-x64-gnu": "0.1.15-alpha",
139
+ "@thomas.f/prnext-win32-x64-msvc": "0.1.15-alpha",
140
+ "@thomas.f/prnext-win32-arm64-msvc": "0.1.15-alpha"
142
141
  },
143
142
  "engines": {
144
- "node": ">=22"
143
+ "node": "^22.23.4 || ^24.22.1 || >=26.12.1"
145
144
  }
146
145
  }
@@ -22,6 +22,8 @@ export async function verifyPackage() {
22
22
  }
23
23
  await exported(pkg.exports);
24
24
  await access(path.join(packageRoot, 'runtime/profiles.json'));
25
+ for (const name of ['index.node.mjs', 'resvg.wasm', 'hb.wasm', 'Geist-Regular.ttf', 'LICENSE', 'LICENSE.fflate', 'LICENSE.Geist', 'LICENSE.harfbuzzjs', 'LICENSE.satori', 'NOTICE.txt']) await access(path.join(packageRoot, 'runtime/og', name));
26
+ if (pkg.dependencies['@vercel/og'] || (await readFile(path.join(packageRoot, 'runtime/og/index.node.mjs'), 'utf8')).includes('fflate@0.7.3')) throw new Error('OG renderer still depends on vulnerable upstream fflate. Run scripts/prepare-og.mjs.');
25
27
  const source = sourceCheckout();
26
28
  if (source) {
27
29
  const cargo = await readFile(path.join(source, 'crates/prnext/Cargo.toml'), 'utf8');
package/runtime/api.mjs CHANGED
@@ -131,7 +131,7 @@ async function runStreamingPagesApi(handler, request, { method, signal, timeoutM
131
131
  const timer = setTimeout(() => cancelRequest(apiTimeoutError('response headers', timeoutMs)), timeoutMs);
132
132
  timer.unref();
133
133
  if (controller.signal.aborted) cancelRequest(abortError(controller.signal.reason));
134
- else trackRequestWork(Promise.resolve().then(() => handler(request, response)), signal).then(result => {
134
+ else trackRequestWork(Promise.resolve().then(() => handler(request, response)), signal, { timeoutMs }).then(result => {
135
135
  if (result !== undefined && result !== response && !response.writableEnded) {
136
136
  throw new Error('Pages API handlers must send their response with res.send(), res.json(), or res.end()');
137
137
  }
@@ -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) {
@@ -245,7 +255,10 @@ async function runApiInner({ modulePath, route = {}, manifest = {}, production =
245
255
  if (lifecycle) pendingResponse.then(({ result }) => {
246
256
  if (lifecycle.controller.signal.aborted) result?.body?.cancel(lifecycle.controller.signal.reason).catch(() => {});
247
257
  }, () => {});
248
- trackRequestWork(pendingResponse, signal);
258
+ // A client disconnect does not cancel arbitrary npm/database promises.
259
+ // Let them settle within this route's existing deadline before retiring the
260
+ // shared worker; the shorter orphan guard would interrupt other visitors.
261
+ trackRequestWork(pendingResponse, signal, { timeoutMs });
249
262
  let result, changedCookies, runInContext;
250
263
  try {
251
264
  ({ result, changedCookies, runInContext } = await withSignal(pendingResponse, lifecycle?.controller.signal));
@@ -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
- }