@thomas.f/prnext 0.1.1-alpha → 0.1.12-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.
@@ -0,0 +1,119 @@
1
+ import { appTimeoutError } from './app-errors.mjs';
2
+
3
+ // Per-isolate admission includes abandoned requests until the isolate confirms
4
+ // settlement. A burst of cooperative cancellations gets time to acknowledge
5
+ // without immediately terminating unrelated active responses.
6
+ export function createRscLifecycle(retire, {
7
+ cancellationLimit = 16, maxActive = 512, maxWaiters = 512,
8
+ } = {}) {
9
+ for (const [name, value] of Object.entries({ cancellationLimit, maxActive, maxWaiters })) {
10
+ if (!Number.isSafeInteger(value) || value <= 0) throw new TypeError(`${name} must be a positive safe integer`);
11
+ }
12
+ if (typeof retire !== 'function') throw new TypeError('retire must be a function');
13
+ const active = new Set(), waiters = new Set(), retirementWaiters = new Set();
14
+ let cancelled = 0, closedError, retirement, retired = false, retirementFailed = false, retirementError;
15
+ const timeoutError = milliseconds => appTimeoutError('RSC admission', milliseconds);
16
+ const available = () => !closedError && cancelled < cancellationLimit && active.size < maxActive;
17
+
18
+ function lease() {
19
+ const token = { cancelled: false };
20
+ active.add(token);
21
+ return {
22
+ cancel() {
23
+ if (!active.has(token) || token.cancelled) return;
24
+ token.cancelled = true;
25
+ cancelled++;
26
+ // Pressure closes admission, never shortens an existing request's
27
+ // deadline. Mutations and cache work may legitimately outlive callers.
28
+ },
29
+ release() {
30
+ if (!active.delete(token)) return;
31
+ if (token.cancelled) cancelled--;
32
+ drain();
33
+ },
34
+ };
35
+ }
36
+
37
+ function drain() {
38
+ for (const waiter of waiters) {
39
+ if (!available()) break;
40
+ waiter.cleanup();
41
+ if (waiter.signal?.aborted) waiter.reject(waiter.signal.reason);
42
+ else if (performance.now() >= waiter.deadline) waiter.reject(timeoutError(waiter.timeoutMs));
43
+ else waiter.resolve(lease());
44
+ }
45
+ }
46
+
47
+ function acquire({ signal, timeoutMs = 28000 } = {}) {
48
+ if (signal?.aborted) return Promise.reject(signal.reason);
49
+ if (closedError) return Promise.reject(closedError);
50
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return Promise.reject(timeoutError(timeoutMs));
51
+ if (available()) return Promise.resolve(lease());
52
+ return queue(waiters, signal, timeoutMs);
53
+ }
54
+
55
+ function queue(collection, signal, timeoutMs) {
56
+ if (waiters.size + retirementWaiters.size >= maxWaiters) {
57
+ const error = new Error('App Router RSC admission queue is full');
58
+ error.statusCode = 503;
59
+ return Promise.reject(error);
60
+ }
61
+ return new Promise((resolve, reject) => {
62
+ let timer;
63
+ const onAbort = () => { waiter.cleanup(); reject(signal.reason); };
64
+ const waiter = { signal, timeoutMs, deadline: performance.now() + timeoutMs, resolve, reject,
65
+ cleanup() { collection.delete(waiter); clearTimeout(timer); signal?.removeEventListener('abort', onAbort); },
66
+ };
67
+ collection.add(waiter);
68
+ signal?.addEventListener('abort', onAbort, { once: true });
69
+ timer = setTimeout(() => { waiter.cleanup(); reject(timeoutError(timeoutMs)); }, timeoutMs);
70
+ });
71
+ }
72
+
73
+ function waitForRetirement({ signal, timeoutMs = 28000 } = {}) {
74
+ if (signal?.aborted) return Promise.reject(signal.reason);
75
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return Promise.reject(timeoutError(timeoutMs));
76
+ if (!retirement) return Promise.reject(new Error('App Router worker is not retiring'));
77
+ if (retired) return retirementFailed ? Promise.reject(retirementError) : Promise.resolve();
78
+ return queue(retirementWaiters, signal, timeoutMs);
79
+ }
80
+
81
+ function finishRetirement(error, failed = false) {
82
+ retired = true;
83
+ retirementFailed = failed;
84
+ retirementError = error;
85
+ for (const waiter of retirementWaiters) {
86
+ waiter.cleanup();
87
+ if (waiter.signal?.aborted) waiter.reject(waiter.signal.reason);
88
+ else if (performance.now() >= waiter.deadline) waiter.reject(timeoutError(waiter.timeoutMs));
89
+ else if (failed) waiter.reject(error);
90
+ else waiter.resolve();
91
+ }
92
+ }
93
+
94
+ function close(error = new Error('App Router worker closed')) {
95
+ if (closedError) return;
96
+ closedError = error;
97
+ for (const waiter of waiters) { waiter.cleanup(); waiter.reject(error); }
98
+ }
99
+
100
+ function terminate(error = new Error('App Router worker retired')) {
101
+ close(error);
102
+ // Worker.terminate() adds an exit listener. Share exactly one call/promise
103
+ // across all request deadlines, cancellation pressure and shutdown paths.
104
+ if (!retirement) {
105
+ retirement = Promise.resolve().then(retire);
106
+ // One observer per isolate, rather than one Promise reaction retained
107
+ // for every caller that times out/aborts while the isolate is stopping.
108
+ retirement.then(() => finishRetirement(), error => finishRetirement(error, true));
109
+ }
110
+ return retirement;
111
+ }
112
+
113
+ return {
114
+ acquire, close, terminate, waitForRetirement,
115
+ get retiring() { return retirement ?? null; },
116
+ get closed() { return !!closedError; },
117
+ get stats() { return { active: active.size, cancelled, waiting: waiters.size + retirementWaiters.size, closed: !!closedError }; },
118
+ };
119
+ }
@@ -0,0 +1,75 @@
1
+ // Response deadlines stop when a response completes. While its cache work is
2
+ // still settling, probe the shared isolate's responsiveness instead of imposing
3
+ // another deadline on legitimate asynchronous work. Fully idle isolates have
4
+ // no watchdog timer.
5
+ export function createRscWorkWatchdog({ probe, retire, intervalMs = 5000, timeoutMs = 30000 } = {}) {
6
+ if (typeof probe !== 'function' || typeof retire !== 'function') throw new TypeError('probe and retire must be functions');
7
+ for (const [name, value] of Object.entries({ intervalMs, timeoutMs })) {
8
+ if (!Number.isSafeInteger(value) || value <= 0) throw new TypeError(`${name} must be a positive safe integer`);
9
+ }
10
+ let work = 0, timer, expected, nextToken = 0, closed = false;
11
+
12
+ function clear() {
13
+ clearTimeout(timer);
14
+ timer = undefined;
15
+ expected = undefined;
16
+ }
17
+
18
+ function close() {
19
+ if (closed) return;
20
+ closed = true;
21
+ work = 0;
22
+ clear();
23
+ }
24
+
25
+ function fail(token, error) {
26
+ if (closed || !work || expected !== token) return;
27
+ close();
28
+ // Close first so concurrent timeout/probe failures cannot retire twice.
29
+ void Promise.resolve().then(() => retire(error)).catch(failure => {
30
+ console.error('[prnext] RSC work watchdog retirement failed:', failure);
31
+ });
32
+ }
33
+
34
+ function schedule() {
35
+ if (closed || !work || timer !== undefined) return;
36
+ timer = setTimeout(() => {
37
+ timer = undefined;
38
+ if (closed || !work) return;
39
+ const token = ++nextToken;
40
+ expected = token;
41
+ // The default exceeds the existing foreground render deadline: a
42
+ // synchronous foreground render must not lose its established budget.
43
+ timer = setTimeout(() => fail(token, new Error(`App Router worker did not acknowledge its background-work probe within ${timeoutMs} ms`)), timeoutMs);
44
+ timer.unref();
45
+ try {
46
+ Promise.resolve(probe(token)).catch(error => fail(token, error));
47
+ } catch (error) { fail(token, error); }
48
+ }, intervalMs);
49
+ timer.unref();
50
+ }
51
+
52
+ function add() {
53
+ if (closed) return () => {};
54
+ work++;
55
+ schedule();
56
+ let released = false;
57
+ return () => {
58
+ if (released) return;
59
+ released = true;
60
+ if (closed) return;
61
+ if (--work === 0) clear();
62
+ };
63
+ }
64
+
65
+ function ack(token) {
66
+ if (closed || !work || expected === undefined || token !== expected) return;
67
+ clear();
68
+ schedule();
69
+ }
70
+
71
+ return {
72
+ add, ack, close,
73
+ get stats() { return { work, scheduled: timer !== undefined, awaitingAck: expected !== undefined, closed }; },
74
+ };
75
+ }
@@ -26,6 +26,7 @@ import { partialModel, isPartialHole, boundedPartialFlight } from './app-partial
26
26
  import { PartialTemplates, canResumePartialDirectly, partialTemplateReuse } from './partial-template.mjs';
27
27
 
28
28
  installFetchCache();
29
+ if (process.env.PRNEXT_INSPECT_DIR) parentPort.postMessage({type:'rsc-ready'});
29
30
 
30
31
  const MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
31
32
  const routeEntries = new Map();
@@ -380,18 +381,19 @@ async function handleRequest(request, channel = null) {
380
381
 
381
382
  const streamChannels = new Map();
382
383
  const activeRequests = new Set();
383
- const cancelledRequests = new Set();
384
384
  const manifests = new Map();
385
385
  const artifacts = new Map();
386
- parentPort.on('message', async ({ id, request, type, value }) => {
386
+ parentPort.on('message', async ({ id, request, type, value, token }) => {
387
+ // Only the host arms these probes, while completed responses still have
388
+ // tracked cache work. There is no timer or heartbeat in an idle RSC isolate.
389
+ if (type === 'work-probe') { parentPort.postMessage({ type: 'work-responsive', token }); return; }
387
390
  if (type === 'metadata') { manifests.set(id, value); return; }
388
391
  if (type === 'metadata-drop') { manifests.delete(id); return; }
389
392
  if (type === 'artifact') { artifacts.set(id, value); return; }
390
393
  if (type === 'artifact-drop') { artifacts.delete(id); return; }
391
394
  if (type === 'credit') { streamChannels.get(id)?.credit(); return; }
392
395
  if (type === 'cancel') {
393
- if (activeRequests.has(id)) cancelledRequests.add(id);
394
- else parentPort.postMessage({ id, type: 'settled' });
396
+ if (!activeRequests.has(id)) parentPort.postMessage({ id, type: 'settled' });
395
397
  streamChannels.get(id)?.cancel(); return;
396
398
  }
397
399
  for (const field of ['clientModules', 'actions']) {
@@ -421,6 +423,8 @@ parentPort.on('message', async ({ id, request, type, value }) => {
421
423
  } });
422
424
  } finally {
423
425
  streamChannels.delete(id); activeRequests.delete(id);
424
- if (cancelledRequests.delete(id)) parentPort.postMessage({ id, type: 'settled' });
426
+ // A streamed terminal frame can precede asynchronous cache writes. Only
427
+ // this acknowledgement confirms that all request work has actually ended.
428
+ parentPort.postMessage({ id, type: 'settled' });
425
429
  }
426
430
  });
@@ -2,6 +2,10 @@ export const MAX_STREAM_CHUNK = 64 * 1024;
2
2
  const MAX_CONTROL_BYTES = 64 * 1024;
3
3
  const NO_BODY = new Set([204, 205, 304]);
4
4
 
5
+ export class ProtocolWriteError extends Error {
6
+ constructor(cause) { super('Request transport write failed', { cause }); }
7
+ }
8
+
5
9
  function bytes(value) {
6
10
  if (typeof value === 'string') return Buffer.from(value);
7
11
  if (value instanceof ArrayBuffer) return Buffer.from(value);
@@ -9,27 +13,12 @@ function bytes(value) {
9
13
  throw new TypeError('A response stream must yield bytes');
10
14
  }
11
15
 
12
- async function* responseChunks(body) {
13
- if (body === null || body === undefined) return;
14
- if (typeof body === 'string' || body instanceof ArrayBuffer || ArrayBuffer.isView(body)) {
15
- const value = bytes(body);
16
- for (let offset = 0; offset < value.length; offset += MAX_STREAM_CHUNK) yield value.subarray(offset, offset + MAX_STREAM_CHUNK);
17
- return;
18
- }
19
- if (typeof body[Symbol.asyncIterator] !== 'function' && typeof body[Symbol.iterator] !== 'function') {
20
- throw new TypeError('Invalid response stream');
21
- }
22
- for await (const item of body) {
23
- const value = bytes(item);
24
- for (let offset = 0; offset < value.length; offset += MAX_STREAM_CHUNK) yield value.subarray(offset, offset + MAX_STREAM_CHUNK);
25
- }
26
- }
27
-
28
16
  // A resolved write means the pipe has accepted the bytes. Awaiting every frame
29
17
  // prevents Node's writable queue from growing with a slow Rust/HTTP consumer.
30
18
  export function createProtocolOutput(write, { cork, uncork } = {}) {
31
19
  const send = value => new Promise((resolve, reject) => {
32
- write(value, error => error ? reject(error) : resolve());
20
+ try { write(value, error => error ? reject(new ProtocolWriteError(error)) : resolve()); }
21
+ catch (error) { reject(new ProtocolWriteError(error)); }
33
22
  });
34
23
  const controlLine = frame => {
35
24
  const value = JSON.stringify(frame) + '\n';
@@ -42,11 +31,11 @@ export function createProtocolOutput(write, { cork, uncork } = {}) {
42
31
  try {
43
32
  for (let index = 0; index < parts.length; index++) {
44
33
  write(parts[index], error => {
45
- if (error) reject(error);
34
+ if (error) reject(new ProtocolWriteError(error));
46
35
  else if (index === parts.length - 1) resolve();
47
36
  });
48
37
  }
49
- } catch (error) { reject(error); }
38
+ } catch (error) { reject(new ProtocolWriteError(error)); }
50
39
  finally { uncork(); }
51
40
  });
52
41
  const sendChunk = (id, chunk) => {
@@ -57,48 +46,79 @@ export function createProtocolOutput(write, { cork, uncork } = {}) {
57
46
  };
58
47
 
59
48
  return async function output(id, result, { stream = false, compactResponse = false, method = 'GET', production = true, signalController } = {}) {
60
- const responseBody = result.bufferedBody ?? result.body;
61
- const pageError = { ...(result.pageError ? { pageError: result.pageError } : {}),
62
- ...(result.pageFailure ? { pageFailure: result.pageFailure } : {}) };
63
- if (stream && !result.isr && (method === 'HEAD' || NO_BODY.has(result.status))) {
64
- await result.cancel?.();
65
- if (result.body?.cancel && !result.body.locked) await result.body.cancel();
66
- await send(JSON.stringify({ id, status: result.status, headers: result.headers || {}, body: '', ...pageError }) + '\n');
67
- return;
68
- }
69
- if (!stream && !result.isr) {
70
- await send(JSON.stringify({ id, status: result.status, headers: result.headers || {}, body: Buffer.from(result.body || '').toString('base64'), ...pageError }) + '\n');
71
- return;
72
- }
73
- const headers = Object.fromEntries(Object.entries(result.headers || {}).filter(([name]) => name.toLowerCase() !== 'content-length'));
74
- if (compactResponse && !result.isr && (typeof responseBody === 'string' || responseBody instanceof ArrayBuffer || ArrayBuffer.isView(responseBody))) {
75
- const body = bytes(responseBody);
76
- if (body.length <= 16 * 1024) {
77
- const complete = controlLine({ id, type: 'complete', status: result.status, headers, length: body.length, ...pageError });
78
- if (cork && uncork) await sendParts(body.length ? [complete, body] : [complete]);
79
- else { await send(complete); if (body.length) await send(body); }
49
+ let responseStarted = false, bodyCancelled = false;
50
+ try {
51
+ signalController?.signal.throwIfAborted();
52
+ const responseBody = result.bufferedBody ?? result.body;
53
+ const pageError = { ...(result.pageError ? { pageError: result.pageError } : {}),
54
+ ...(result.pageFailure ? { pageFailure: result.pageFailure } : {}) };
55
+ if (stream && !result.isr && (method === 'HEAD' || NO_BODY.has(result.status))) {
56
+ bodyCancelled = true;
57
+ await result.cancel?.();
58
+ if (result.body?.cancel && !result.body.locked) await result.body.cancel();
59
+ await send(JSON.stringify({ id, status: result.status, headers: result.headers || {}, body: '', ...pageError }) + '\n');
80
60
  return;
81
61
  }
82
- }
83
- const head = controlLine({ id, type: 'head', status: result.status, headers, ...pageError, ...(result.isr ? { isr: result.isr } : {}) });
84
- if (cork && uncork && (typeof responseBody === 'string' || responseBody instanceof ArrayBuffer || ArrayBuffer.isView(responseBody))) {
85
- const body = bytes(responseBody);
86
- if (body.length <= MAX_STREAM_CHUNK) {
87
- // Pages HTML and buffered action responses already know their entire
88
- // small body. Send their frames together, keeping the same wire format.
89
- await sendParts([head, ...(body.length ? [controlLine({id,type:'chunk',length:body.length}),body] : []), controlLine({id,type:'end'})]);
62
+ if (!stream && !result.isr) {
63
+ await send(JSON.stringify({ id, status: result.status, headers: result.headers || {}, body: Buffer.from(result.body || '').toString('base64'), ...pageError }) + '\n');
90
64
  return;
91
65
  }
92
- }
93
- await send(head);
94
- try {
95
- for await (const chunk of responseChunks(responseBody)) {
96
- await sendChunk(id, chunk);
66
+ const headers = Object.fromEntries(Object.entries(result.headers || {}).filter(([name]) => name.toLowerCase() !== 'content-length'));
67
+ if (compactResponse && !result.isr && (typeof responseBody === 'string' || responseBody instanceof ArrayBuffer || ArrayBuffer.isView(responseBody))) {
68
+ const body = bytes(responseBody);
69
+ if (body.length <= 16 * 1024) {
70
+ const complete = controlLine({ id, type: 'complete', status: result.status, headers, length: body.length, ...pageError });
71
+ if (cork && uncork) await sendParts(body.length ? [complete, body] : [complete]);
72
+ else { await send(complete); if (body.length) await send(body); }
73
+ return;
74
+ }
75
+ }
76
+ const head = controlLine({ id, type: 'head', status: result.status, headers, ...pageError, ...(result.isr ? { isr: result.isr } : {}) });
77
+ if (cork && uncork && (typeof responseBody === 'string' || responseBody instanceof ArrayBuffer || ArrayBuffer.isView(responseBody))) {
78
+ const body = bytes(responseBody);
79
+ if (body.length <= MAX_STREAM_CHUNK) {
80
+ // Pages HTML and buffered action responses already know their entire
81
+ // small body. Send their frames together, keeping the same wire format.
82
+ await sendParts([head, ...(body.length ? [controlLine({id,type:'chunk',length:body.length}),body] : []), controlLine({id,type:'end'})]);
83
+ return;
84
+ }
85
+ }
86
+ await send(head);
87
+ responseStarted = true;
88
+ // Iterate the producer directly: an intermediate async generator adds
89
+ // promise reactions and Buffer views for every already bounded chunk.
90
+ // Each write still settles before the next chunk or producer read.
91
+ if (responseBody !== null && responseBody !== undefined) {
92
+ if (typeof responseBody === 'string' || responseBody instanceof ArrayBuffer || ArrayBuffer.isView(responseBody)) {
93
+ const value = bytes(responseBody);
94
+ for (let offset = 0; offset < value.length; offset += MAX_STREAM_CHUNK) {
95
+ await sendChunk(id, value.subarray(offset, offset + MAX_STREAM_CHUNK));
96
+ }
97
+ } else {
98
+ if (typeof responseBody[Symbol.asyncIterator] !== 'function' && typeof responseBody[Symbol.iterator] !== 'function') {
99
+ throw new TypeError('Invalid response stream');
100
+ }
101
+ for await (const item of responseBody) {
102
+ const value = bytes(item);
103
+ if (value.length <= MAX_STREAM_CHUNK) {
104
+ if (value.length) await sendChunk(id, value);
105
+ } else {
106
+ for (let offset = 0; offset < value.length; offset += MAX_STREAM_CHUNK) {
107
+ await sendChunk(id, value.subarray(offset, offset + MAX_STREAM_CHUNK));
108
+ }
109
+ }
110
+ }
111
+ }
97
112
  }
98
113
  await control({ id, type: 'end' });
99
114
  } catch (error) {
115
+ const cancelled = signalController?.signal.aborted || error instanceof ProtocolWriteError;
100
116
  signalController?.abort(error);
101
- await result.cancel?.(error);
117
+ if (!bodyCancelled) await result.cancel?.(error);
118
+ // Socket cancellation has no writable peer and no protocol recovery on
119
+ // this lane. Cancel its producer once; never attempt a second write (and
120
+ // secondary EPIPE) or report a client disconnect as an application error.
121
+ if (cancelled || !responseStarted) throw error;
102
122
  console.error('[prnext] Response stream failed:', error?.stack || error);
103
123
  // Headers are already on the wire: terminate this response, never emit a
104
124
  // replacement HTTP response or retry application code after a mutation.
@@ -12,8 +12,11 @@ import net from 'node:net';
12
12
  import { timingSafeEqual } from 'node:crypto';
13
13
  import { Console } from 'node:console';
14
14
  import { navigationResponse } from './navigation.mjs';
15
- import { createProtocolOutput } from './transport.mjs';
15
+ import { createProtocolOutput, ProtocolWriteError } from './transport.mjs';
16
16
  import { loadEnvConfig } from './env.mjs';
17
+ import diagnostics from '../compat/diagnostics.cjs';
18
+
19
+ if (diagnostics.enabled) diagnostics.record('wake', { phase: 'node-entry', uptimeMs: process.uptime() * 1000, bootstrapMs: performance.nodeTiming.bootstrapComplete });
17
20
 
18
21
  // stdout is exclusively the transport, including when user packages log during import.
19
22
  const writeProtocol = process.stdout.write.bind(process.stdout);
@@ -34,7 +37,12 @@ process.env.NODE_ENV = manifest.dev ? 'development' : 'production';
34
37
  const production = !manifest.dev;
35
38
  // Load each renderer beside the application only when that request needs it.
36
39
  // Middleware and API workers do not pay for React or the Pages/App renderers.
37
- const runtimeModule = name => import(pathToFileURL(path.join(distDir, `runtime/${name}.mjs`)).href);
40
+ const runtimeModule = async name => {
41
+ const started = diagnostics.enabled ? performance.now() : 0;
42
+ const module = await import(pathToFileURL(path.join(distDir, `runtime/${name}.mjs`)).href);
43
+ if (diagnostics.enabled) diagnostics.record('wake', { phase: 'module', module: name, durationMs: performance.now() - started });
44
+ return module;
45
+ };
38
46
  let errorResponse;
39
47
  try { ({ errorResponse } = await runtimeModule('http')); }
40
48
  catch (error) { console.error('PRNext worker could not load the project runtime:', error.message); process.exit(1); }
@@ -48,7 +56,7 @@ let pagesRuntime;
48
56
  let apiRuntime;
49
57
  let incrementalRuntime;
50
58
 
51
- async function handleRequest(input, output, signalController) {
59
+ async function handleRequest(input, output, signalController, responseComplete) {
52
60
  let responseStarted = false;
53
61
  try {
54
62
  if (input.renderMode === 'incremental-cache' && input.routeId === '__prnext_incremental_cache') {
@@ -78,7 +86,7 @@ async function handleRequest(input, output, signalController) {
78
86
  appStaticRuntime ??= await import(pathToFileURL(path.join(distDir, 'runtime/app-static.mjs')).href);
79
87
  result = await appStaticRuntime.renderAppIsrPage(options);
80
88
  } else {
81
- appRuntime ??= await import(pathToFileURL(path.join(distDir, 'runtime/app-render.mjs')).href);
89
+ appRuntime ??= await runtimeModule('app-render');
82
90
  result = await appRuntime.renderAppPage(options);
83
91
  }
84
92
  } else if (route.router === 'app' && route.kind === 'api' && input.renderMode === 'isr') {
@@ -97,16 +105,23 @@ async function handleRequest(input, output, signalController) {
97
105
  input.body = undefined;
98
106
  options.body = undefined;
99
107
  responseStarted = true;
100
- try { await output(input.id, result, { ...input, production, signalController }); }
108
+ try {
109
+ await output(input.id, result, { ...input, production, signalController });
110
+ responseComplete?.();
111
+ }
101
112
  finally { await result.finalizeCache?.(); }
102
113
  } catch (error) {
114
+ const cancelled = signalController.signal.aborted || error instanceof ProtocolWriteError;
103
115
  signalController.abort(error);
104
- if (responseStarted) {
116
+ if (responseStarted || cancelled) {
105
117
  // A broken pipe or invalid transport cannot be repaired with a second
106
- // response on the same request. Rust replaces this failed worker.
118
+ // response on the same request. Rust discards the incomplete lane.
107
119
  throw error;
108
120
  }
109
- await output(input?.id ?? null, navigationResponse(error) || errorResponse(error, production), { ...input, production, signalController });
121
+ // The application's scope was aborted above, but the lane is still valid
122
+ // for its one error/redirect response. Do not mistake that cleanup signal
123
+ // for a client disconnect while writing the replacement headers.
124
+ await output(input?.id ?? null, navigationResponse(error) || errorResponse(error, production), { ...input, production });
110
125
  }
111
126
  }
112
127
 
@@ -114,12 +129,22 @@ async function handleRequest(input, output, signalController) {
114
129
  // response never blocks another response's bytes or aborts its request context.
115
130
  const laneLimit = Math.min(512, Math.max(1, Number(process.env.PRNEXT_WORKER_SOCKET) || 1));
116
131
  const token = process.env.PRNEXT_WORKER_TOKEN;
132
+ const reportActivity = process.env.PRNEXT_WORKER_ACTIVITY === '1';
117
133
  delete process.env.PRNEXT_WORKER_TOKEN;
118
134
  delete process.env.PRNEXT_WORKER_SOCKET;
135
+ delete process.env.PRNEXT_WORKER_ACTIVITY;
119
136
  if (token && laneLimit > 1) {
120
137
  const expected = Buffer.from(token);
121
138
  const sockets = new Set();
122
139
  const invocations = new Set();
140
+ let shuttingDown = false;
141
+ let reportedBusy = false;
142
+ const announceActivity = () => {
143
+ if (!reportActivity || !invocations.size || reportedBusy || shuttingDown) return;
144
+ reportedBusy = true;
145
+ writeProtocol('busy\n');
146
+ };
147
+ const exitWhenDrained = () => { if (shuttingDown && !invocations.size) process.exit(0); };
123
148
  const server = net.createServer(socket => {
124
149
  if (sockets.size >= laneLimit) { socket.destroy(); return; }
125
150
  sockets.add(socket); socket.setNoDelay(true);
@@ -134,7 +159,6 @@ if (token && laneLimit > 1) {
134
159
  const abandoned = controller;
135
160
  if (!abandoned || abandoned.signal.aborted) return;
136
161
  abandoned.abort(new Error('Request transport closed'));
137
-
138
162
  };
139
163
  socket.on('end', disconnected);
140
164
  socket.on('close', disconnected);
@@ -149,42 +173,83 @@ if (token && laneLimit > 1) {
149
173
  if (invocations.size >= laneLimit) throw new Error('Worker invocation capacity exceeded');
150
174
  controller = new AbortController();
151
175
  const active = controller;
152
- let pendingWork = 0, finished = false;
176
+ let pendingWork = 0, unsupervisedWork = 0, finished = false;
153
177
  invocations.add(active);
154
- active.signal[requestWork] = promise => {
178
+ announceActivity();
179
+ active.signal[requestWork] = (promise, options) => {
155
180
  pendingWork++;
156
- const settled = () => { if (--pendingWork === 0 && finished) invocations.delete(active); };
181
+ // RSC owns both its foreground deadline and its post-response
182
+ // responsiveness watchdog. Do not apply legacy orphan timeouts to
183
+ // legitimate cache work merely because its HTTP body has ended.
184
+ const supervised = options?.supervised === true;
185
+ if (!supervised) unsupervisedWork++;
186
+ const settled = () => {
187
+ pendingWork--;
188
+ if (!supervised) unsupervisedWork--;
189
+ if (pendingWork && !unsupervisedWork) clearTimeout(workTimer);
190
+ if (!pendingWork && finished) {
191
+ clearTimeout(workTimer);
192
+ invocations.delete(active); exitWhenDrained();
193
+ }
194
+ };
157
195
  workScope.runInAsyncScope(() => Promise.resolve(promise).then(settled, settled));
158
196
  };
197
+ let workTimer;
159
198
  const retireIfAbandoned = () => {
160
- const timer = setTimeout(() => { if (invocations.has(active)) process.exit(70); }, 5000);
161
- timer.unref();
199
+ if (pendingWork && !unsupervisedWork) return;
200
+ workTimer = setTimeout(() => {
201
+ if (invocations.has(active) && (!pendingWork || unsupervisedWork)) process.exit(70);
202
+ }, 5000);
203
+ workTimer.unref();
162
204
  };
163
205
  active.signal.addEventListener('abort', retireIfAbandoned, { once: true });
164
- try { await handleRequest(input, output, active); }
206
+ try { await handleRequest(input, output, active, () => { controller = undefined; }); }
165
207
  finally {
166
208
  finished = true;
167
- if (!pendingWork) invocations.delete(active);
168
- else if (!active.signal.aborted) {
169
- // Background code after sending a complete response still has a
170
- // finite lifetime, matching the native request deadline.
171
- const timer = setTimeout(() => { if (invocations.has(active)) process.exit(70); }, 30000);
172
- timer.unref();
209
+ if (!pendingWork) { invocations.delete(active); clearTimeout(workTimer); }
210
+ else {
211
+ if (unsupervisedWork && !active.signal.aborted) {
212
+ workTimer = setTimeout(() => { if (invocations.has(active) && unsupervisedWork) process.exit(70); }, 30000);
213
+ workTimer.unref();
214
+ }
173
215
  }
174
216
  controller = undefined;
217
+ exitWhenDrained();
175
218
  }
176
219
  }
177
220
  } catch (error) {
178
- if (!socket.destroyed) console.error('[prnext] Request lane failed:', error?.message || error);
221
+ if (!socket.destroyed && !socket.readableEnded && !(error instanceof ProtocolWriteError)) console.error('[prnext] Request lane failed:', error?.message || error);
179
222
  } finally { socket.destroy(); }
180
223
  })();
181
224
  });
182
225
  // The stdin pipe is a parent-lifetime guard even though requests use sockets.
226
+ let heartbeat;
227
+ const shutdown = () => {
228
+ if (shuttingDown) return;
229
+ shuttingDown = true;
230
+ clearInterval(heartbeat);
231
+ for (const socket of sockets) socket.destroy();
232
+ server.close();
233
+ exitWhenDrained();
234
+ };
183
235
  process.stdin.resume();
184
- process.stdin.once('end', () => { for (const socket of sockets) socket.destroy(); server.close(); process.exit(0); });
236
+ process.stdin.once('end', shutdown);
237
+ // The native pool can close its control reader before Node observes stdin
238
+ // EOF. Treat that broken parent pipe as the same graceful shutdown; ordinary
239
+ // stdout failures remain fatal instead of silently losing protocol errors.
240
+ process.stdout.on('error', error => {
241
+ if (error.code === 'EPIPE') shutdown();
242
+ else throw error;
243
+ });
185
244
  server.listen(0, '127.0.0.1', () => {
186
- writeProtocol(JSON.stringify({ port: server.address().port, binary: true, heartbeat: true }) + '\n');
187
- setInterval(() => writeProtocol('alive\n'), 1000).unref();
245
+ writeProtocol(JSON.stringify({ port: server.address().port, binary: true, heartbeat: true, activity: reportActivity, gracefulShutdown: true }) + '\n');
246
+ heartbeat = setInterval(() => {
247
+ // Coalesce the idle transition with the existing heartbeat. Normal
248
+ // request completion adds no stdout messages, and short cache bursts
249
+ // cannot add two messages for every response.
250
+ reportedBusy = reportActivity && invocations.size > 0;
251
+ writeProtocol(reportedBusy ? 'busy\n' : 'alive\n');
252
+ }, 1000).unref();
188
253
  });
189
254
  } else {
190
255
  const output = createProtocolOutput(writeProtocol, { cork: () => process.stdout.cork(), uncork: () => process.stdout.uncork() });