@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
Binary file
@@ -0,0 +1,8 @@
1
+ // Older native sharp backends can be supplied by the consuming application.
2
+ // Fall back to the bundled resvg renderer unless librsvg includes its RCE fix.
3
+ export function safeSvgBackend(backend) {
4
+ const version = backend?.versions?.rsvg;
5
+ if (typeof version !== 'string' || !/^\d+\.\d+\.\d+$/.test(version)) return false;
6
+ const [major, minor, patch] = version.split('.').map(Number);
7
+ return major > 2 || (major === 2 && (minor > 63 || (minor === 63 && patch >= 2)));
8
+ }
@@ -1,17 +1,4 @@
1
- // Upstream's Node bundle contains HarfBuzz's CommonJS globals and an adjacent
2
- // hb.wasm reference. Resolve these explicitly for direct prnext/og consumers.
3
- // Production builds prepare a relocatable module instead and avoid this loader.
4
- import { createRequire } from 'node:module';
5
- import { readFile } from 'node:fs/promises';
6
- import { pathToFileURL } from 'node:url';
7
- import path from 'node:path';
1
+ // Ship the reviewed renderer instead of resolving upstream's vulnerable pin.
8
2
  export async function loadImageRenderer() {
9
- const require = createRequire(import.meta.url);
10
- const entry = require.resolve('@vercel/og');
11
- const fontDirectory = path.dirname(entry);
12
- const satori = createRequire(entry).resolve('satori');
13
- const harfbuzzDirectory = path.dirname(createRequire(satori).resolve('harfbuzzjs/hb.wasm'));
14
- const source = (await readFile(entry, 'utf8')).replaceAll('import.meta.url', JSON.stringify(pathToFileURL(entry).href));
15
- const globals = `import {createRequire as __prnextRequire} from 'node:module';const require=__prnextRequire(${JSON.stringify(pathToFileURL(path.join(fontDirectory, 'index.node.js')).href)}),__filename=${JSON.stringify(entry)},__dirname=${JSON.stringify(harfbuzzDirectory)};\n`;
16
- return import('data:text/javascript;base64,' + Buffer.from(globals + source).toString('base64'));
3
+ return import('./og/index.node.mjs');
17
4
  }
@@ -1,5 +1,5 @@
1
1
  const MAX_HEADER = 256 * 1024;
2
- const MAX_BODY = 8 * 1024 * 1024;
2
+ const MAX_BODY = 32 * 1024 * 1024;
3
3
  const EMPTY_BODY = Buffer.alloc(0);
4
4
 
5
5
  // Awaiting the consumer preserves socket backpressure. At most one body is
@@ -30,7 +30,7 @@ function canonicalOptions(options) {
30
30
 
31
31
  function deadline(options, phase) {
32
32
  const controller = new AbortController();
33
- const timeoutMs = options.timeoutMs ?? 25_000;
33
+ const timeoutMs = options.timeoutMs ?? (options.route?.cacheConfig?.maxDuration ? options.route.cacheConfig.maxDuration * 1000 : 25_000);
34
34
  // Keep the timer alive while awaiting an otherwise handle-free import or
35
35
  // generator so Node reports the timeout rather than exiting on pending TLA.
36
36
  const timer = setTimeout(() => controller.abort(apiTimeoutError(phase, timeoutMs)), timeoutMs);
@@ -24,6 +24,7 @@ import { installFetchCache } from './fetch-cache.mjs';
24
24
  import { flightConsumer } from './cache-components-rsc.mjs';
25
25
  import { partialModel, isPartialHole, boundedPartialFlight } from './app-partial-model.mjs';
26
26
  import { PartialTemplates, canResumePartialDirectly, partialTemplateReuse } from './partial-template.mjs';
27
+ import { createAfterScope } from '../compat/after.cjs';
27
28
 
28
29
  installFetchCache();
29
30
  if (process.env.PRNEXT_INSPECT_DIR) parentPort.postMessage({type:'rsc-ready'});
@@ -75,7 +76,7 @@ function createTree(entry, request, fallback = null) {
75
76
  const pageKey = `${request.routePattern || new URL(request.url).pathname}:${JSON.stringify(request.params || {})}`;
76
77
  const keyScopes = [];
77
78
  const instantTrees = [];
78
- const instantDisabled = [entry.pageConfig, ...segments.map(segment => segment.staticConfig)].some(config => config?.instant && typeof config.instant === 'object' && (config.instant.unstable_disableValidation || (request.production ? config.instant.unstable_disableBuildValidation : config.instant.unstable_disableDevValidation)));
79
+ const instantDisabled = request.instantValidation && [entry.pageConfig, ...segments.map(segment => segment.staticConfig)].some(config => config?.instant && typeof config.instant === 'object' && (config.instant.unstable_disableValidation || (request.production ? config.instant.unstable_disableBuildValidation : config.instant.unstable_disableDevValidation)));
79
80
  function keyScope(prefix, values, liveProps) {
80
81
  const key = prefix + JSON.stringify(values);
81
82
  keyScopes.push({ key, prefix, names: Object.keys(values), ...(liveProps ? { liveProps } : {}) });
@@ -98,8 +99,9 @@ function createTree(entry, request, fallback = null) {
98
99
  scopes.set(segment, `${segment.path || ''}:${JSON.stringify(scopedParams.get(segment))}`);
99
100
  if (request.partialParams?.length) for (const prefix of ['', 'error:', 'navigation:', 'layout:']) keyScope(`${prefix}${segment.path || ''}:`, scopedParams.get(segment), prefix === 'layout:' ? ['segments'] : undefined);
100
101
  }
101
- let instantConfig = entry.pageConfig?.instant;
102
- for (const segment of [...(fallback ? segments.slice(0, fallback.index + 1) : segments)].reverse()) {
102
+ let instantConfig = request.instantValidation ? entry.pageConfig?.instant : undefined;
103
+ for (let index = fallback ? Math.min(fallback.index, segments.length - 1) : segments.length - 1; index >= 0; index--) {
104
+ const segment = segments[index];
103
105
  if (segment.loading) tree = React.createElement(React.Suspense, {
104
106
  fallback: React.createElement(component(segment.loading, 'loading')),
105
107
  }, tree);
@@ -126,7 +128,7 @@ function createTree(entry, request, fallback = null) {
126
128
  if (request.cacheComponents && segment === rootLayout) tree = React.createElement(React.Fragment, null, head, tree);
127
129
  tree = React.createElement(component(segment.layout, 'layout'), { params: staticParams(scopedParams.get(segment)), key: scopes.get(segment) }, tree);
128
130
  if (entry.LayoutProvider) tree = React.createElement(entry.LayoutProvider, { key: `layout:${scopes.get(segment)}`,
129
- segments: { children: selectedLayoutSegments(segments.slice(segments.indexOf(segment) + 1), request.params) } }, tree);
131
+ segments: { children: selectedLayoutSegments(segments, request.params, segments.indexOf(segment) + 1) } }, tree);
130
132
  }
131
133
  }
132
134
  // Keep module namespaces out of development owner props: they can contain
@@ -381,9 +383,11 @@ async function handleRequest(request, channel = null) {
381
383
 
382
384
  const streamChannels = new Map();
383
385
  const activeRequests = new Set();
386
+ const responseCompletions = new Map();
384
387
  const manifests = new Map();
385
388
  const artifacts = new Map();
386
389
  parentPort.on('message', async ({ id, request, type, value, token }) => {
390
+ if (type === 'after-response') { responseCompletions.get(id)?.(); return; }
387
391
  // Only the host arms these probes, while completed responses still have
388
392
  // tracked cache work. There is no timer or heartbeat in an idle RSC isolate.
389
393
  if (type === 'work-probe') { parentPort.postMessage({ type: 'work-responsive', token }); return; }
@@ -400,6 +404,17 @@ parentPort.on('message', async ({ id, request, type, value, token }) => {
400
404
  if (request[field + 'Id'] !== undefined) request[field] = manifests.get(request[field + 'Id']);
401
405
  }
402
406
  const channel = request.stream && (!request.action || request.action.id) ? workerStreamChannel(parentPort, id) : null;
407
+ let responseDone, responseTimer;
408
+ if (request.action && request.cacheConfig?.runtime !== 'edge') {
409
+ const timeoutMs = request.renderTimeoutMs ?? 25000;
410
+ request.afterScope = createAfterScope({ timeoutMs, onComplete: flushCacheWork,
411
+ onTimeout: error => parentPort.postMessage({ id, error: { message: error.message, retireWorker: true } }) });
412
+ if (request.waitForResponse) responseDone = new Promise(resolve => {
413
+ const finish = () => { clearTimeout(responseTimer); resolve(); };
414
+ responseCompletions.set(id, finish);
415
+ responseTimer = setTimeout(finish, timeoutMs);
416
+ });
417
+ }
403
418
  activeRequests.add(id);
404
419
  if (channel) streamChannels.set(id, channel);
405
420
  try {
@@ -422,6 +437,14 @@ parentPort.on('message', async ({ id, request, type, value, token }) => {
422
437
  code: error?.code, staticMode: error?.staticMode, dynamicReason: error?.dynamicReason,
423
438
  } });
424
439
  } finally {
440
+ // Flight completion can precede HTML decoding and socket backpressure.
441
+ // The HTTP worker acknowledges the actual response end/error/disconnect.
442
+ // Keep the RSC lease until bounded after-response work has settled.
443
+ if (request.afterScope) {
444
+ try { await responseDone; await request.afterScope.finish(); }
445
+ catch (error) { console.error('[prnext] Server Action after() failed:', error?.stack || error); }
446
+ }
447
+ clearTimeout(responseTimer); responseCompletions.delete(id);
425
448
  streamChannels.delete(id); activeRequests.delete(id);
426
449
  // A streamed terminal frame can precede asynchronous cache writes. Only
427
450
  // this acknowledgement confirms that all request work has actually ended.
@@ -1,3 +1,5 @@
1
+ const immediate = () => new Promise(resolve => setImmediate(resolve));
2
+
1
3
  export const STREAM_CHUNK_BYTES = 64 * 1024;
2
4
 
3
5
  export function abortError(reason = 'Response body was canceled') {
@@ -48,6 +50,7 @@ export function streamingBody(source, { signal, onCancel, onComplete, timeoutMs
48
50
 
49
51
  function clean() {
50
52
  clearTimeout(timer);
53
+ timer = undefined;
51
54
  signal?.removeEventListener('abort', aborted);
52
55
  chunk = undefined;
53
56
  if (reader) {
@@ -70,10 +73,14 @@ export function streamingBody(source, { signal, onCancel, onComplete, timeoutMs
70
73
  }
71
74
 
72
75
  function touch() {
73
- clearTimeout(timer);
74
76
  if (timeoutMs > 0) {
75
- timer = setTimeout(() => stop(apiTimeoutError('response idle', timeoutMs), true), timeoutMs);
76
- timer.unref?.();
77
+ // Keep one request-scoped timer: splitting or reading another chunk must
78
+ // extend the same idle deadline without allocating another async resource.
79
+ if (timer) timer.refresh();
80
+ else {
81
+ timer = setTimeout(() => stop(apiTimeoutError('response idle', timeoutMs), true), timeoutMs);
82
+ timer.unref?.();
83
+ }
77
84
  }
78
85
  }
79
86
  function aborted() { stop(signal.reason, true); }
@@ -87,6 +94,7 @@ export function streamingBody(source, { signal, onCancel, onComplete, timeoutMs
87
94
  if (stopped) return { done: true, value: undefined };
88
95
  if (nextInProgress) throw new Error('Response body reads must be sequential');
89
96
  nextInProgress = true;
97
+ let emptyReads = 0;
90
98
  try {
91
99
  while (!chunk || offset >= chunk.byteLength) {
92
100
  chunk = undefined;
@@ -105,6 +113,17 @@ export function streamingBody(source, { signal, onCancel, onComplete, timeoutMs
105
113
  if (!(next.value instanceof Uint8Array)) throw new TypeError('Response body chunks must be Uint8Array values');
106
114
  chunk = next.value;
107
115
  offset = 0;
116
+ if (!chunk.byteLength && ++emptyReads === 32) {
117
+ // Empty, immediately resolved reads produce no writable I/O and
118
+ // can otherwise starve cancellation, idle timers and other lanes.
119
+ // They are not progress: keep the original idle deadline running.
120
+ emptyReads = 0;
121
+ await immediate();
122
+ if (stopped) {
123
+ if (failure) throw failure;
124
+ return { done: true, value: undefined };
125
+ }
126
+ }
108
127
  }
109
128
  const value = chunk.subarray(offset, Math.min(offset + STREAM_CHUNK_BYTES, chunk.byteLength));
110
129
  offset += value.byteLength;
@@ -1,5 +1,8 @@
1
+ const yieldToEvents = () => new Promise(resolve => setImmediate(resolve));
2
+
1
3
  export const MAX_STREAM_CHUNK = 64 * 1024;
2
4
  const MAX_CONTROL_BYTES = 64 * 1024;
5
+ const STREAM_YIELD_CHUNKS = 32;
3
6
  const NO_BODY = new Set([204, 205, 304]);
4
7
 
5
8
  export class ProtocolWriteError extends Error {
@@ -85,6 +88,15 @@ export function createProtocolOutput(write, { cork, uncork } = {}) {
85
88
  }
86
89
  await send(head);
87
90
  responseStarted = true;
91
+ let chunksSinceYield = 0;
92
+ const yieldStream = async () => {
93
+ // A writable callback may settle on nextTick without polling I/O.
94
+ // Bound ready producer/write chains so other lanes can authenticate,
95
+ // timers can run and disconnects can abort this producer promptly.
96
+ chunksSinceYield = 0;
97
+ await yieldToEvents();
98
+ signalController?.signal.throwIfAborted();
99
+ };
88
100
  // Iterate the producer directly: an intermediate async generator adds
89
101
  // promise reactions and Buffer views for every already bounded chunk.
90
102
  // Each write still settles before the next chunk or producer read.
@@ -93,6 +105,7 @@ export function createProtocolOutput(write, { cork, uncork } = {}) {
93
105
  const value = bytes(responseBody);
94
106
  for (let offset = 0; offset < value.length; offset += MAX_STREAM_CHUNK) {
95
107
  await sendChunk(id, value.subarray(offset, offset + MAX_STREAM_CHUNK));
108
+ if (++chunksSinceYield === STREAM_YIELD_CHUNKS) await yieldStream();
96
109
  }
97
110
  } else {
98
111
  if (typeof responseBody[Symbol.asyncIterator] !== 'function' && typeof responseBody[Symbol.iterator] !== 'function') {
@@ -102,9 +115,12 @@ export function createProtocolOutput(write, { cork, uncork } = {}) {
102
115
  const value = bytes(item);
103
116
  if (value.length <= MAX_STREAM_CHUNK) {
104
117
  if (value.length) await sendChunk(id, value);
118
+ // Empty iterable chunks also consume work without writing bytes.
119
+ if (++chunksSinceYield === STREAM_YIELD_CHUNKS) await yieldStream();
105
120
  } else {
106
121
  for (let offset = 0; offset < value.length; offset += MAX_STREAM_CHUNK) {
107
122
  await sendChunk(id, value.subarray(offset, offset + MAX_STREAM_CHUNK));
123
+ if (++chunksSinceYield === STREAM_YIELD_CHUNKS) await yieldStream();
108
124
  }
109
125
  }
110
126
  }
@@ -1,5 +1,28 @@
1
1
  // Only build manifests belong here. Never register headers, models, closures or
2
2
  // request contexts. Eviction is ordered on the same MessagePort as requests.
3
+ const immutableClientManifests = new WeakSet();
4
+
5
+ // Production build metadata comes from JSON and does not change until a new
6
+ // worker loads the next build. Only explicitly sealed maps can skip validation;
7
+ // callers using mutable manifests (including development) keep the old checks.
8
+ export function freezeClientModules(value) {
9
+ if (!value || typeof value !== 'object') return value;
10
+ if (immutableClientManifests.has(value)) return value;
11
+ const entries = Object.getOwnPropertyDescriptors(value);
12
+ // An accessor may change even on a frozen object. Do not trust such a map.
13
+ if (!Object.values(entries).every(entry => Object.hasOwn(entry, 'value') &&
14
+ entry.value && typeof entry.value === 'object' &&
15
+ typeof Object.getOwnPropertyDescriptor(entry.value, 'ssrModule')?.value === 'string')) return value;
16
+ for (const entry of Object.values(entries)) Object.freeze(entry.value);
17
+ Object.freeze(value);
18
+ immutableClientManifests.add(value);
19
+ return value;
20
+ }
21
+
22
+ export function isImmutableClientModules(value) {
23
+ return immutableClientManifests.has(value);
24
+ }
25
+
3
26
  export function workerMetadata(port, limit = 32) {
4
27
  const entries = new Map();
5
28
  let sequence = 0;
@@ -2,6 +2,7 @@
2
2
  // prnext-concurrency:512
3
3
  import { requestFrames } from './request-transport.mjs';
4
4
  import { requestWork } from './request-work.mjs';
5
+ import { freezeClientModules } from './worker-metadata.mjs';
5
6
  import { AsyncResource } from 'node:async_hooks';
6
7
  const workScope = new AsyncResource('PRNextWorkObserver', { triggerAsyncId: 0 });
7
8
  import { readFile } from 'node:fs/promises';
@@ -35,6 +36,7 @@ loadEnvConfig(projectRoot, { dev: !!manifest.dev });
35
36
  // browser graph and the RSC worker's mode, including dev launched from CI.
36
37
  process.env.NODE_ENV = manifest.dev ? 'development' : 'production';
37
38
  const production = !manifest.dev;
39
+ if (production) freezeClientModules(manifest.app?.clientModules);
38
40
  // Load each renderer beside the application only when that request needs it.
39
41
  // Middleware and API workers do not pay for React or the Pages/App renderers.
40
42
  const runtimeModule = async name => {
@@ -58,6 +60,7 @@ let incrementalRuntime;
58
60
 
59
61
  async function handleRequest(input, output, signalController, responseComplete) {
60
62
  let responseStarted = false;
63
+ const finalizers = new Set();
61
64
  try {
62
65
  if (input.renderMode === 'incremental-cache' && input.routeId === '__prnext_incremental_cache') {
63
66
  incrementalRuntime ??= await runtimeModule('incremental-cache');
@@ -76,7 +79,7 @@ async function handleRequest(input, output, signalController, responseComplete)
76
79
  if (route.router !== 'app' && !['isr', 'error404', 'error500'].includes(input.renderMode) && route.fallback === false && !prerendered.has(requestedPath) && !prerendered.has(requestedPath.replace(/\/$/, '') || '/')) {
77
80
  await output(input.id, { status: 404, body: 'Not Found' }, input); return;
78
81
  }
79
- const options = { ...input, modulePath: path.resolve(distDir, route.module), route, production, manifest, distDir, signal: signalController.signal };
82
+ const options = { ...input, modulePath: path.resolve(distDir, route.module), route, production, manifest, distDir, signal: signalController.signal, deferFinalizer: finalize => finalizers.add(finalize) };
80
83
  let result;
81
84
  if (isMiddleware) {
82
85
  middlewareRuntime ??= await import(pathToFileURL(path.join(distDir, 'runtime/middleware.mjs')).href);
@@ -105,11 +108,9 @@ async function handleRequest(input, output, signalController, responseComplete)
105
108
  input.body = undefined;
106
109
  options.body = undefined;
107
110
  responseStarted = true;
108
- try {
109
- await output(input.id, result, { ...input, production, signalController });
110
- responseComplete?.();
111
- }
112
- finally { await result.finalizeCache?.(); }
111
+ if (result.finalizeCache) finalizers.add(result.finalizeCache);
112
+ await output(input.id, result, { ...input, production, signalController });
113
+ responseComplete?.();
113
114
  } catch (error) {
114
115
  const cancelled = signalController.signal.aborted || error instanceof ProtocolWriteError;
115
116
  signalController.abort(error);
@@ -122,6 +123,21 @@ async function handleRequest(input, output, signalController, responseComplete)
122
123
  // for its one error/redirect response. Do not mistake that cleanup signal
123
124
  // for a client disconnect while writing the replacement headers.
124
125
  await output(input?.id ?? null, navigationResponse(error) || errorResponse(error, production), { ...input, production });
126
+ responseComplete?.();
127
+ } finally {
128
+ if (finalizers.size) {
129
+ const pending = (async () => {
130
+ for (const finalize of finalizers) {
131
+ try { await finalize(); }
132
+ catch (error) { console.error('[prnext] Post-response work failed:', error?.stack || error); }
133
+ }
134
+ })();
135
+ // Release this socket's response lane while retaining worker activity.
136
+ // Otherwise an immediate follow-up request waits behind after() work.
137
+ const track = signalController.signal[requestWork];
138
+ if (track) track(pending);
139
+ else await pending;
140
+ }
125
141
  }
126
142
  }
127
143
 
@@ -173,7 +189,7 @@ if (token && laneLimit > 1) {
173
189
  if (invocations.size >= laneLimit) throw new Error('Worker invocation capacity exceeded');
174
190
  controller = new AbortController();
175
191
  const active = controller;
176
- let pendingWork = 0, unsupervisedWork = 0, finished = false;
192
+ let pendingWork = 0, unsupervisedWork = 0, finished = false, backgroundDeadline = 0;
177
193
  invocations.add(active);
178
194
  announceActivity();
179
195
  active.signal[requestWork] = (promise, options) => {
@@ -183,6 +199,10 @@ if (token && laneLimit > 1) {
183
199
  // legitimate cache work merely because its HTTP body has ended.
184
200
  const supervised = options?.supervised === true;
185
201
  if (!supervised) unsupervisedWork++;
202
+ if (!supervised && Number.isFinite(options?.timeoutMs) && options.timeoutMs > 0 && options.timeoutMs <= 300000) {
203
+ backgroundDeadline = Math.max(backgroundDeadline, performance.now() + options.timeoutMs);
204
+ if (active.signal.aborted) { clearTimeout(workTimer); retireIfAbandoned(); }
205
+ }
186
206
  const settled = () => {
187
207
  pendingWork--;
188
208
  if (!supervised) unsupervisedWork--;
@@ -199,7 +219,7 @@ if (token && laneLimit > 1) {
199
219
  if (pendingWork && !unsupervisedWork) return;
200
220
  workTimer = setTimeout(() => {
201
221
  if (invocations.has(active) && (!pendingWork || unsupervisedWork)) process.exit(70);
202
- }, 5000);
222
+ }, Math.max(5000, backgroundDeadline - performance.now()));
203
223
  workTimer.unref();
204
224
  };
205
225
  active.signal.addEventListener('abort', retireIfAbandoned, { once: true });
@@ -209,7 +229,7 @@ if (token && laneLimit > 1) {
209
229
  if (!pendingWork) { invocations.delete(active); clearTimeout(workTimer); }
210
230
  else {
211
231
  if (unsupervisedWork && !active.signal.aborted) {
212
- workTimer = setTimeout(() => { if (invocations.has(active) && unsupervisedWork) process.exit(70); }, 30000);
232
+ workTimer = setTimeout(() => { if (invocations.has(active) && unsupervisedWork) process.exit(70); }, Math.max(30000, backgroundDeadline - performance.now()));
213
233
  workTimer.unref();
214
234
  }
215
235
  }
@@ -259,4 +279,11 @@ if (token && laneLimit > 1) {
259
279
  try { await handleRequest(input, output, new AbortController()); }
260
280
  catch (error) { console.error('[prnext] Worker transport failed:', error?.stack || error); process.exit(1); }
261
281
  }
282
+ // The maintenance/ISR transport is serial and awaits its own finalizers.
283
+ // RSC settlement can still outlive its terminal frame; drain it explicitly
284
+ // before closing the isolate's MessagePorts after parent EOF.
285
+ if (appRuntime || appStaticRuntime) {
286
+ const runtime = appRuntime || await runtimeModule('app-render');
287
+ await runtime.drainAppRuntime();
288
+ }
262
289
  }