@thomas.f/prnext 0.1.11-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.
@@ -1,4 +1,5 @@
1
1
  import React from 'react';
2
+ import diagnostics from '../compat/diagnostics.cjs';
2
3
  import { runtimeProfile } from './profile.mjs';
3
4
  import { fontPreloads } from './font-preload.mjs';
4
5
  import { advancedRoutingSources } from './app-routing-state.mjs';
@@ -25,10 +26,14 @@ import { partialArtifact, partialShell } from './app-partial.mjs';
25
26
  import { partialKeyMap } from './app-partial-model.mjs';
26
27
  import { partialIdentity } from './partial-artifact-cache.mjs';
27
28
  import { isDraftRequest } from '../compat/draft.cjs';
29
+ import { createRscLifecycle } from './rsc-lifecycle.mjs';
30
+ import { trackRequestWork } from './request-work.mjs';
31
+ import { createRscWorkWatchdog } from './rsc-work-watchdog.mjs';
28
32
 
29
33
  installFetchCache();
30
34
 
31
35
  const workers = new Map();
36
+ const lifecycles = new WeakMap();
32
37
  const metadata = new WeakMap();
33
38
  const artifacts = new WeakMap();
34
39
  const edgeManifests = new WeakMap();
@@ -97,6 +102,7 @@ globalThis.__webpack_chunk_load__ = id => {
97
102
 
98
103
  function getWorker(production) {
99
104
  if (workers.has(production)) return workers.get(production);
105
+ const created = diagnostics.enabled ? performance.now() : 0;
100
106
  const current = new Worker(new URL('./rsc-worker.mjs', import.meta.url), {
101
107
  execArgv: ['--conditions=react-server'], stdout: true, stderr: true,
102
108
  // Production nursery policy; an explicit Node semi-space flag takes
@@ -105,37 +111,68 @@ function getWorker(production) {
105
111
  env: { ...process.env, NODE_ENV: production ? 'production' : 'development' },
106
112
  });
107
113
  workers.set(production, current);
114
+ if (diagnostics.enabled) {
115
+ current.once('online', () => diagnostics.record('wake', {phase:'rsc-online', durationMs:performance.now()-created}));
116
+ const ready = message => {
117
+ if (message.type !== 'rsc-ready') return;
118
+ diagnostics.record('wake', {phase:'rsc-ready', durationMs:performance.now()-created});
119
+ current.off('message', ready);
120
+ };
121
+ current.on('message', ready);
122
+ }
123
+ let workWatchdog;
124
+ const lifecycle = createRscLifecycle(() => { workWatchdog?.close(); return current.terminate(); });
125
+ workWatchdog = createRscWorkWatchdog({
126
+ probe: token => current.postMessage({ type: 'work-probe', token }),
127
+ retire: error => {
128
+ console.error('[prnext] RSC background worker stopped responding:', error);
129
+ return lifecycle.terminate(error);
130
+ },
131
+ });
132
+ lifecycles.set(current, lifecycle);
108
133
  metadata.set(current, workerMetadata(current));
109
134
  artifacts.set(current, workerArtifacts(current));
110
135
  // The HTTP worker's stdout belongs to its Rust transport. User code and React
111
136
  // diagnostics in the separate RSC environment must never corrupt that stream.
112
137
  current.stdout.pipe(process.stderr);
113
138
  current.stderr.pipe(process.stderr);
114
- function fail(error) {
115
- if (workers.get(production) === current) workers.delete(production);
139
+ function fail(error, exited = false) {
140
+ // Keep a retiring isolate registered until its actual exit; callers may
141
+ // wait for replacement but cannot start an unbounded series of retirees.
142
+ if (exited && workers.get(production) === current) workers.delete(production);
143
+ lifecycle.close(error);
144
+ workWatchdog.close();
116
145
  for (const [id, request] of pending) {
117
146
  if (request.worker !== current) continue;
118
147
  clearTimeout(request.timeout);
119
148
  pending.delete(id);
149
+ request.lease.release();
150
+ request.settle();
151
+ request.releaseWorkWatch?.();
120
152
  request.detach?.();
121
153
  request.channel?.error(error);
122
154
  request.reject(error);
123
155
  }
124
156
  }
125
- current.on('message', ({ id, type, chunk, byteOffset, byteLength, done, body, status, rscError, boundaryIndex, headers, error, staticParams, staticMetadata, navigation }) => {
157
+ current.on('message', ({ id, type, token, chunk, byteOffset, byteLength, done, body, status, rscError, boundaryIndex, headers, error, staticParams, staticMetadata, navigation }) => {
158
+ if (type === 'work-responsive') { workWatchdog.ack(token); return; }
126
159
  const request = pending.get(id);
127
160
  if (!request || request.worker !== current) return;
128
161
  // A timed-out mutation can still be running after its HTTP caller leaves.
129
162
  // Its retirement marker must be honored before discarding cancelled output.
130
163
  if (error?.retireWorker) {
131
- if (workers.get(production) === current) workers.delete(production);
132
- void current.terminate();
164
+ void lifecycle.terminate(new Error(error.message)).catch(error => console.error('[prnext] RSC worker termination failed:', error));
133
165
  }
134
166
  if (request.cancelled && type !== 'settled') return;
135
167
  if (type === 'settled') {
136
- if (!request.cancelled) return;
137
168
  pending.delete(id); clearTimeout(request.timeout);
138
- if (!hasPending(current)) current.unref();
169
+ request.lease.release();
170
+ request.detach?.();
171
+ request.settle();
172
+ request.releaseWorkWatch?.();
173
+ // terminate() keeps its exit event referenced. A late settlement must
174
+ // not undo that reference while build publication awaits shutdown.
175
+ if (!hasPending(current) && !lifecycle.retiring) current.unref();
139
176
  return;
140
177
  }
141
178
  if (type === 'start') {
@@ -145,11 +182,19 @@ function getWorker(production) {
145
182
  }
146
183
  if (type === 'chunk') {
147
184
  try { request.channel.chunk(chunk, byteOffset, byteLength); }
148
- catch (error) { request.channel.error(error); current.postMessage({ id, type: 'cancel' }); }
185
+ catch (error) { request.channel.error(error); current.postMessage({ id, type: 'cancel' }); request.cancel(error); }
149
186
  if (!done) return;
150
187
  }
151
- pending.delete(id);
188
+ if (request.completed) {
189
+ if (error) console.error('[prnext] RSC background work failed:', error.stack || error.message);
190
+ return;
191
+ }
192
+ request.completed = true;
193
+ // The response deadline ends with its successful terminal frame. Cache
194
+ // work can legitimately continue afterwards and retains its own bounds;
195
+ // supervise isolate responsiveness, not total background-task duration.
152
196
  clearTimeout(request.timeout);
197
+ request.releaseWorkWatch = workWatchdog.add();
153
198
  request.detach?.();
154
199
  if (type === 'end' || done) request.channel.end();
155
200
  else if (error) {
@@ -171,51 +216,71 @@ function getWorker(production) {
171
216
  // ArrayBuffer overload: a view of the transferred allocation, no copy.
172
217
  request.resolve({ body: Buffer.from(body), status: status || 200, rscError, boundaryIndex, headers, staticMetadata, navigation });
173
218
  }
174
- if (!hasPending(current)) current.unref();
219
+ // Keep only settlement bookkeeping after sending the body. The
220
+ // isolate can still be flushing cache writes after its terminal frame.
221
+ request.channel = undefined;
222
+ request.resolve = request.reject = () => {};
175
223
  });
176
224
  current.on('error', fail);
177
- current.on('exit', code => fail(new Error(`App Router worker exited (${code})`)));
225
+ current.on('exit', code => fail(new Error(`App Router worker exited (${code})`), true));
178
226
  current.unref();
179
227
  return current;
180
228
  }
181
229
 
182
230
  export function closeAppRuntime() {
183
231
  const current = [...workers.values()];
184
- workers.clear();
185
- return Promise.all(current.map(worker => worker.terminate()));
232
+ return Promise.all(current.map(worker => lifecycles.get(worker).terminate()));
186
233
  }
187
234
 
188
- export function renderFlight(request, { softTimeoutMs = 25_000, hardTimeoutMs = 28_000, signal } = {}) {
235
+ export async function renderFlight(request, { softTimeoutMs = 25_000, hardTimeoutMs = 28_000, signal } = {}) {
236
+ const deadline = performance.now() + hardTimeoutMs;
237
+ let current, lifecycle, lease;
238
+ for (;;) {
239
+ signal?.throwIfAborted();
240
+ current = getWorker(Boolean(request.production));
241
+ lifecycle = lifecycles.get(current);
242
+ if (lifecycle.retiring) {
243
+ await lifecycle.waitForRetirement({ signal, timeoutMs: deadline - performance.now() });
244
+ continue;
245
+ }
246
+ try { lease = await lifecycle.acquire({ signal, timeoutMs: deadline - performance.now() }); }
247
+ catch (error) { if (lifecycle.retiring) continue; throw error; }
248
+ if (lifecycle.closed) { lease.release(); continue; }
249
+ break;
250
+ }
189
251
  return new Promise((resolve, reject) => {
190
- const current = getWorker(Boolean(request.production));
252
+ if (signal?.aborted) { lease.release(); reject(signal.reason); return; }
253
+ if (performance.now() >= deadline) { lease.release(); reject(appTimeoutError('RSC admission', hardTimeoutMs)); return; }
191
254
  current.ref();
192
255
  const id = ++nextId;
256
+ let settle;
257
+ trackRequestWork(new Promise(resolve => { settle = resolve; }), signal, { deadline, supervised: true });
193
258
  const timeout = setTimeout(() => {
194
259
  // A blocking npm dependency cannot honor an AbortSignal. Retire the
195
260
  // isolate at the hard deadline so subsequent requests can recover.
196
- if (workers.get(Boolean(request.production)) === current) workers.delete(Boolean(request.production));
197
- void current.terminate();
261
+ void lifecycle.terminate(appTimeoutError('RSC worker', hardTimeoutMs)).finally(settle).catch(error => console.error('[prnext] RSC worker termination failed:', error));
198
262
  pending.delete(id);
263
+ lease.release();
199
264
  const error = appTimeoutError('RSC worker', hardTimeoutMs);
265
+ if (entry.completed) console.error('[prnext] RSC background work failed:', error);
200
266
  entry.channel?.error(error);
201
267
  entry.detach?.();
202
268
  reject(error);
203
- }, hardTimeoutMs);
204
- const entry = { worker: current, resolve, reject, timeout, operation: request.operation };
269
+ }, Math.max(0, deadline - performance.now()));
270
+ const entry = { worker: current, resolve, reject, timeout, operation: request.operation, lease, settle };
205
271
  function cancel(reason) {
272
+ if (entry.cancelled) return;
206
273
  // Keep the independent deadline until the isolate confirms settlement.
207
274
  // A disconnected client must not disable recovery of blocked npm code.
208
275
  entry.cancelled = true;
276
+ lease.cancel();
209
277
  entry.detach?.();
210
- // HTTP callers can leave faster than arbitrary npm work can unwind.
211
- // Bound abandoned work independently of the admission of live requests.
212
- if (hasPending(current, 16, true)) {
213
- if (workers.get(Boolean(request.production)) === current) workers.delete(Boolean(request.production));
214
- void current.terminate();
215
- }
216
- if (!hasPending(current)) current.unref();
278
+ // Pause new work at 16 abandoned requests while their acknowledgements
279
+ // settle. Existing mutation/cache work keeps its original hard deadline.
280
+ if (!hasPending(current) && !lifecycle.retiring) current.unref();
217
281
  reject(reason instanceof Error ? reason : new Error('App Router response was cancelled'));
218
282
  }
283
+ entry.cancel = cancel;
219
284
  if (request.stream) entry.channel = parentStreamChannel(current, id, cancel);
220
285
  if (signal) {
221
286
  const onAbort = () => {
@@ -225,11 +290,10 @@ export function renderFlight(request, { softTimeoutMs = 25_000, hardTimeoutMs =
225
290
  };
226
291
  entry.detach = () => signal.removeEventListener('abort', onAbort);
227
292
  signal.addEventListener('abort', onAbort, { once: true });
228
- if (signal.aborted) { clearTimeout(timeout); onAbort(); return; }
229
293
  }
230
294
  pending.set(id, entry);
231
295
  try {
232
- const wire = { ...request, renderTimeoutMs: softTimeoutMs };
296
+ const wire = { ...request, renderTimeoutMs: Math.min(softTimeoutMs, Math.max(1, deadline - performance.now())) };
233
297
  if (request.production) {
234
298
  const artifactId = artifacts.get(current)(wire.partialFlight);
235
299
  if (artifactId !== undefined) { wire.partialFlightId = artifactId; delete wire.partialFlight; }
@@ -243,9 +307,11 @@ export function renderFlight(request, { softTimeoutMs = 25_000, hardTimeoutMs =
243
307
  catch (error) {
244
308
  clearTimeout(timeout);
245
309
  pending.delete(id);
310
+ lease.release();
311
+ settle();
246
312
  entry.detach?.();
247
313
  reject(error);
248
- if (!hasPending(current)) current.unref();
314
+ if (!hasPending(current) && !lifecycle.retiring) current.unref();
249
315
  }
250
316
  });
251
317
  }
@@ -1,3 +1,4 @@
1
+ import { htmlEnvelope } from './html-envelope.mjs';
1
2
  import React from 'react';
2
3
  import { fontPreloads } from './font-preload.mjs';
3
4
  import { isDynamicBailout } from '../compat/dynamic-bailout.cjs';
@@ -12,58 +13,6 @@ import { renderAppRecoveryShell } from './app-recovery.mjs';
12
13
  import { partialModel } from './app-partial-model.mjs';
13
14
  import { streamSelector } from './stream-select.mjs';
14
15
 
15
- // Do not insert bootstrap scripts inside a split HTML tag or raw-text element.
16
- // React may split a large inline script across arbitrary byte chunks.
17
- function htmlEnvelope({ resumed = false } = {}) {
18
- let pending = '';
19
- let closing = '';
20
- let received = 0;
21
- return {
22
- append(text) {
23
- received += Buffer.byteLength(text);
24
- if (received > MAX_RESPONSE_BYTES) throw new Error('HTML response exceeds the 16 MiB PRNext limit');
25
- pending += text;
26
- let ready = '';
27
- while (pending) {
28
- const start = pending.indexOf('<');
29
- if (start < 0) { ready += pending; pending = ''; break; }
30
- if (start) { ready += pending.slice(0, start); pending = pending.slice(start); }
31
- let end = -1;
32
- if (pending.startsWith('<!--')) {
33
- const comment = pending.indexOf('-->');
34
- if (comment >= 0) end = comment + 2;
35
- } else {
36
- let quote;
37
- for (let index = 1; index < pending.length; index++) {
38
- const char = pending[index];
39
- if (quote) { if (char === quote) quote = undefined; }
40
- else if (char === '"' || char === "'") quote = char;
41
- else if (char === '>') { end = index; break; }
42
- }
43
- }
44
- if (end < 0) break;
45
- const tag = pending.slice(0, end + 1);
46
- const raw = /^<(script|style|textarea|title|svg|math)(?:\s|>)/i.exec(tag);
47
- if (raw && !tag.endsWith('/>')) {
48
- const match = new RegExp(`</${raw[1]}\\s*>`, 'i').exec(pending.slice(end + 1));
49
- if (!match) break;
50
- end += 1 + match.index + match[0].length;
51
- ready += pending.slice(0, end); pending = pending.slice(end);
52
- } else {
53
- if (/^<\/(body|html)\s*>$/i.test(tag)) closing += tag;
54
- else ready += tag;
55
- pending = pending.slice(end + 1);
56
- }
57
- }
58
- return ready;
59
- },
60
- finish() {
61
- if (pending || !(resumed ? /^(?:<\/body><\/html>){1,2}$/i : /^<\/body><\/html>$/i).test(closing)) throw new Error('App Router root layout did not close its body and html elements');
62
- return resumed ? '</body></html>' : closing;
63
- },
64
- };
65
- }
66
-
67
16
  // The SSR decoder and HTML bootstrap share each Flight allocation. Unlike
68
17
  // ReadableStream.tee(), this tap stops reading when the HTML consumer stalls.
69
18
  // Before a document shell exists, at most the overall 16 MiB response limit can
@@ -0,0 +1,73 @@
1
+ import { MAX_RESPONSE_BYTES } from './http.mjs';
2
+
3
+ // Do not insert Flight/bootstrap scripts inside a split tag or raw-text element.
4
+ // Scan by index: ordinary tags stay in one contiguous slice, instead of creating
5
+ // a new tail and output string for every cell of a rendered table.
6
+ export function htmlEnvelope({ resumed = false } = {}) {
7
+ let pending = '';
8
+ let closing = '';
9
+ let received = 0;
10
+ const rawStart = /<(script|style|textarea|title|svg|math)(?:\s|>)/iy;
11
+ const rawEnds = new Map();
12
+ const documentEnd = /<\/(body|html)\s*>/iy;
13
+ return {
14
+ append(text) {
15
+ received += Buffer.byteLength(text);
16
+ if (received > MAX_RESPONSE_BYTES) throw new Error('HTML response exceeds the 16 MiB PRNext limit');
17
+ pending += text;
18
+ let cursor = 0, output = 0, ready = '';
19
+ while (cursor < pending.length) {
20
+ const start = pending.indexOf('<', cursor);
21
+ if (start < 0) { cursor = pending.length; break; }
22
+ cursor = start;
23
+ let end = -1;
24
+ if (pending.startsWith('<!--', start)) {
25
+ const comment = pending.indexOf('-->', start);
26
+ if (comment >= 0) end = comment + 2;
27
+ } else {
28
+ let quote;
29
+ for (let index = start + 1; index < pending.length; index++) {
30
+ const char = pending[index];
31
+ if (quote) { if (char === quote) quote = undefined; }
32
+ else if (char === '"' || char === "'") quote = char;
33
+ else if (char === '>') { end = index; break; }
34
+ }
35
+ }
36
+ if (end < 0) break;
37
+ // Most tags need no special handling. Reject their ASCII prefixes
38
+ // before running the raw-element and document-closing expressions;
39
+ // the expressions still validate every candidate in full.
40
+ const first = pending.charCodeAt(start + 1) | 32;
41
+ const second = pending.charCodeAt(start + 2) | 32;
42
+ const rawCandidate = first === 115 && (second === 99 || second === 116 || second === 118) ||
43
+ first === 116 && (second === 101 || second === 105) || first === 109 && second === 97;
44
+ rawStart.lastIndex = start;
45
+ const raw = rawCandidate && rawStart.exec(pending);
46
+ if (raw && pending[end - 1] !== '/') {
47
+ const name = raw[1].toLowerCase();
48
+ let pattern = rawEnds.get(name);
49
+ if (!pattern) { pattern = new RegExp(`</${name}\\s*>`, 'ig'); rawEnds.set(name, pattern); }
50
+ pattern.lastIndex = end + 1;
51
+ const match = pattern.exec(pending);
52
+ if (!match) break;
53
+ cursor = pattern.lastIndex;
54
+ } else {
55
+ documentEnd.lastIndex = start;
56
+ if (first === 47 && (second === 98 || second === 104) && documentEnd.test(pending) && documentEnd.lastIndex === end + 1) {
57
+ ready += pending.slice(output, start);
58
+ closing += pending.slice(start, end + 1);
59
+ output = end + 1;
60
+ }
61
+ cursor = end + 1;
62
+ }
63
+ }
64
+ ready += pending.slice(output, cursor);
65
+ pending = pending.slice(cursor);
66
+ return ready;
67
+ },
68
+ finish() {
69
+ if (pending || !(resumed ? /^(?:<\/body><\/html>){1,2}$/i : /^<\/body><\/html>$/i).test(closing)) throw new Error('App Router root layout did not close its body and html elements');
70
+ return resumed ? '</body></html>' : closing;
71
+ },
72
+ };
73
+ }
package/runtime/http.mjs CHANGED
@@ -184,7 +184,7 @@ export function plainResponse(status, message) {
184
184
 
185
185
  export function errorResponse(error, production = process.env.NODE_ENV === 'production') {
186
186
  console.error('[prnext]', error?.stack || error);
187
- const status = [400, 504].includes(error?.statusCode) ? error.statusCode : 500;
188
- const message = status === 400 ? 'Bad Request' : status === 504 ? 'Gateway Timeout' : 'Internal Server Error';
187
+ const status = [400, 503, 504].includes(error?.statusCode) ? error.statusCode : 500;
188
+ const message = status === 400 ? 'Bad Request' : status === 503 ? 'Service Unavailable' : status === 504 ? 'Gateway Timeout' : 'Internal Server Error';
189
189
  return plainResponse(status, production || status === 400 ? message : String(error?.stack || error));
190
190
  }
@@ -6,7 +6,8 @@
6
6
  "renderStarts": 16,
7
7
  "liveResponses": 32,
8
8
  "apiConcurrency": 512,
9
- "responseBufferMiB": 8
9
+ "responseBufferMiB": 8,
10
+ "renderIdleSeconds": null
10
11
  },
11
12
  "compact": {
12
13
  "optimizeForSize": true,
@@ -15,16 +16,18 @@
15
16
  "renderStarts": 16,
16
17
  "liveResponses": 32,
17
18
  "apiConcurrency": 512,
18
- "responseBufferMiB": 8
19
+ "responseBufferMiB": 8,
20
+ "renderIdleSeconds": 30
19
21
  },
20
22
  "balanced": {
21
- "optimizeForSize": true,
22
- "semiSpaceMiB": 8,
23
+ "optimizeForSize": false,
24
+ "semiSpaceMiB": 4,
23
25
  "youngGenerationMiB": 16,
24
26
  "renderStarts": 16,
25
27
  "liveResponses": 32,
26
28
  "apiConcurrency": 256,
27
- "responseBufferMiB": 4
29
+ "responseBufferMiB": 4,
30
+ "renderIdleSeconds": 300
28
31
  },
29
32
  "speed": {
30
33
  "optimizeForSize": false,
@@ -33,15 +36,17 @@
33
36
  "renderStarts": 32,
34
37
  "liveResponses": 128,
35
38
  "apiConcurrency": 512,
36
- "responseBufferMiB": 16
39
+ "responseBufferMiB": 16,
40
+ "renderIdleSeconds": null
37
41
  },
38
42
  "memory": {
39
43
  "optimizeForSize": true,
40
44
  "semiSpaceMiB": 2,
41
45
  "youngGenerationMiB": 4,
42
46
  "renderStarts": 8,
43
- "liveResponses": 16,
47
+ "liveResponses": 28,
44
48
  "apiConcurrency": 64,
45
- "responseBufferMiB": 1
49
+ "responseBufferMiB": 1,
50
+ "renderIdleSeconds": 30
46
51
  }
47
52
  }
@@ -1,7 +1,7 @@
1
1
  // Track the actual application promise as well as the HTTP wrapper. A wrapper
2
2
  // can reject on abort while npm code keeps running and retaining its context.
3
3
  export const requestWork = Symbol.for('prnext.request-work');
4
- export function trackRequestWork(promise, signal) {
5
- signal?.[requestWork]?.(promise);
4
+ export function trackRequestWork(promise, signal, options) {
5
+ signal?.[requestWork]?.(promise, options);
6
6
  return promise;
7
7
  }
@@ -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
+ }