@thomas.f/prnext 0.1.11-alpha → 0.1.13-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +395 -29
  2. package/build/actions.mjs +4 -0
  3. package/build/app-config.mjs +2 -0
  4. package/build/app.mjs +20 -5
  5. package/build/bundle-runtime.mjs +82 -0
  6. package/build/cache-handlers.mjs +4 -1
  7. package/build/compiler.mjs +4 -1
  8. package/build/config.mjs +20 -2
  9. package/build/font-cache.mjs +50 -0
  10. package/build/font-files.mjs +5 -3
  11. package/build/fonts.mjs +1 -1
  12. package/build/index.mjs +12 -1
  13. package/build/metadata-conflicts.mjs +13 -0
  14. package/build/metadata-images.mjs +3 -3
  15. package/build/metadata-routes.mjs +3 -3
  16. package/build/native-packages.mjs +5 -1
  17. package/build/npm-packages.mjs +14 -2
  18. package/build/optimize-imports.mjs +112 -0
  19. package/build/proxy-body-config.mjs +10 -0
  20. package/build/runtime-contract.mjs +16 -0
  21. package/build/scan.mjs +9 -7
  22. package/build/typescript-check.mjs +20 -0
  23. package/build/typescript.mjs +49 -0
  24. package/build/worker-retirement.mjs +14 -0
  25. package/cli.mjs +17 -0
  26. package/compat/after.cjs +51 -0
  27. package/compat/index.d.ts +13 -1
  28. package/compat/server.cjs +2 -1
  29. package/compat/server.d.cts +2 -0
  30. package/native/launch.mjs +19 -0
  31. package/package.json +25 -9
  32. package/release/verify.mjs +9 -0
  33. package/runtime/api.mjs +15 -5
  34. package/runtime/app-render.mjs +164 -53
  35. package/runtime/app-route-plan.mjs +45 -0
  36. package/runtime/app-routing-state.mjs +101 -21
  37. package/runtime/app-routing.mjs +37 -18
  38. package/runtime/app-segments.mjs +25 -11
  39. package/runtime/app-static.mjs +1 -1
  40. package/runtime/app-stream-html.mjs +3 -53
  41. package/runtime/cache-request.mjs +10 -2
  42. package/runtime/cloud/archive.mjs +316 -0
  43. package/runtime/cloud/client.mjs +658 -0
  44. package/runtime/cloud/environment.mjs +60 -0
  45. package/runtime/cloud/state.mjs +141 -0
  46. package/runtime/cloud/upload.mjs +48 -0
  47. package/runtime/html-envelope.mjs +136 -0
  48. package/runtime/http.mjs +9 -6
  49. package/runtime/middleware.mjs +3 -3
  50. package/runtime/profiles.json +13 -8
  51. package/runtime/request-transport.mjs +1 -1
  52. package/runtime/request-work.mjs +2 -2
  53. package/runtime/route-static.mjs +1 -1
  54. package/runtime/rsc-lifecycle.mjs +119 -0
  55. package/runtime/rsc-work-watchdog.mjs +75 -0
  56. package/runtime/rsc-worker.mjs +36 -9
  57. package/runtime/stream-utils.mjs +22 -3
  58. package/runtime/transport.mjs +89 -53
  59. package/runtime/worker-metadata.mjs +23 -0
  60. package/runtime/worker.mjs +119 -27
@@ -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
+ }
@@ -24,8 +24,10 @@ 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();
30
+ if (process.env.PRNEXT_INSPECT_DIR) parentPort.postMessage({type:'rsc-ready'});
29
31
 
30
32
  const MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
31
33
  const routeEntries = new Map();
@@ -74,7 +76,7 @@ function createTree(entry, request, fallback = null) {
74
76
  const pageKey = `${request.routePattern || new URL(request.url).pathname}:${JSON.stringify(request.params || {})}`;
75
77
  const keyScopes = [];
76
78
  const instantTrees = [];
77
- 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)));
78
80
  function keyScope(prefix, values, liveProps) {
79
81
  const key = prefix + JSON.stringify(values);
80
82
  keyScopes.push({ key, prefix, names: Object.keys(values), ...(liveProps ? { liveProps } : {}) });
@@ -97,8 +99,9 @@ function createTree(entry, request, fallback = null) {
97
99
  scopes.set(segment, `${segment.path || ''}:${JSON.stringify(scopedParams.get(segment))}`);
98
100
  if (request.partialParams?.length) for (const prefix of ['', 'error:', 'navigation:', 'layout:']) keyScope(`${prefix}${segment.path || ''}:`, scopedParams.get(segment), prefix === 'layout:' ? ['segments'] : undefined);
99
101
  }
100
- let instantConfig = entry.pageConfig?.instant;
101
- 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];
102
105
  if (segment.loading) tree = React.createElement(React.Suspense, {
103
106
  fallback: React.createElement(component(segment.loading, 'loading')),
104
107
  }, tree);
@@ -125,7 +128,7 @@ function createTree(entry, request, fallback = null) {
125
128
  if (request.cacheComponents && segment === rootLayout) tree = React.createElement(React.Fragment, null, head, tree);
126
129
  tree = React.createElement(component(segment.layout, 'layout'), { params: staticParams(scopedParams.get(segment)), key: scopes.get(segment) }, tree);
127
130
  if (entry.LayoutProvider) tree = React.createElement(entry.LayoutProvider, { key: `layout:${scopes.get(segment)}`,
128
- segments: { children: selectedLayoutSegments(segments.slice(segments.indexOf(segment) + 1), request.params) } }, tree);
131
+ segments: { children: selectedLayoutSegments(segments, request.params, segments.indexOf(segment) + 1) } }, tree);
129
132
  }
130
133
  }
131
134
  // Keep module namespaces out of development owner props: they can contain
@@ -380,24 +383,38 @@ async function handleRequest(request, channel = null) {
380
383
 
381
384
  const streamChannels = new Map();
382
385
  const activeRequests = new Set();
383
- const cancelledRequests = new Set();
386
+ const responseCompletions = new Map();
384
387
  const manifests = new Map();
385
388
  const artifacts = new Map();
386
- parentPort.on('message', async ({ id, request, type, value }) => {
389
+ parentPort.on('message', async ({ id, request, type, value, token }) => {
390
+ if (type === 'after-response') { responseCompletions.get(id)?.(); return; }
391
+ // Only the host arms these probes, while completed responses still have
392
+ // tracked cache work. There is no timer or heartbeat in an idle RSC isolate.
393
+ if (type === 'work-probe') { parentPort.postMessage({ type: 'work-responsive', token }); return; }
387
394
  if (type === 'metadata') { manifests.set(id, value); return; }
388
395
  if (type === 'metadata-drop') { manifests.delete(id); return; }
389
396
  if (type === 'artifact') { artifacts.set(id, value); return; }
390
397
  if (type === 'artifact-drop') { artifacts.delete(id); return; }
391
398
  if (type === 'credit') { streamChannels.get(id)?.credit(); return; }
392
399
  if (type === 'cancel') {
393
- if (activeRequests.has(id)) cancelledRequests.add(id);
394
- else parentPort.postMessage({ id, type: 'settled' });
400
+ if (!activeRequests.has(id)) parentPort.postMessage({ id, type: 'settled' });
395
401
  streamChannels.get(id)?.cancel(); return;
396
402
  }
397
403
  for (const field of ['clientModules', 'actions']) {
398
404
  if (request[field + 'Id'] !== undefined) request[field] = manifests.get(request[field + 'Id']);
399
405
  }
400
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
+ }
401
418
  activeRequests.add(id);
402
419
  if (channel) streamChannels.set(id, channel);
403
420
  try {
@@ -420,7 +437,17 @@ parentPort.on('message', async ({ id, request, type, value }) => {
420
437
  code: error?.code, staticMode: error?.staticMode, dynamicReason: error?.dynamicReason,
421
438
  } });
422
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);
423
448
  streamChannels.delete(id); activeRequests.delete(id);
424
- if (cancelledRequests.delete(id)) parentPort.postMessage({ id, type: 'settled' });
449
+ // A streamed terminal frame can precede asynchronous cache writes. Only
450
+ // this acknowledgement confirms that all request work has actually ended.
451
+ parentPort.postMessage({ id, type: 'settled' });
425
452
  }
426
453
  });
@@ -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,7 +1,14 @@
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
 
8
+ export class ProtocolWriteError extends Error {
9
+ constructor(cause) { super('Request transport write failed', { cause }); }
10
+ }
11
+
5
12
  function bytes(value) {
6
13
  if (typeof value === 'string') return Buffer.from(value);
7
14
  if (value instanceof ArrayBuffer) return Buffer.from(value);
@@ -9,27 +16,12 @@ function bytes(value) {
9
16
  throw new TypeError('A response stream must yield bytes');
10
17
  }
11
18
 
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
19
  // A resolved write means the pipe has accepted the bytes. Awaiting every frame
29
20
  // prevents Node's writable queue from growing with a slow Rust/HTTP consumer.
30
21
  export function createProtocolOutput(write, { cork, uncork } = {}) {
31
22
  const send = value => new Promise((resolve, reject) => {
32
- write(value, error => error ? reject(error) : resolve());
23
+ try { write(value, error => error ? reject(new ProtocolWriteError(error)) : resolve()); }
24
+ catch (error) { reject(new ProtocolWriteError(error)); }
33
25
  });
34
26
  const controlLine = frame => {
35
27
  const value = JSON.stringify(frame) + '\n';
@@ -42,11 +34,11 @@ export function createProtocolOutput(write, { cork, uncork } = {}) {
42
34
  try {
43
35
  for (let index = 0; index < parts.length; index++) {
44
36
  write(parts[index], error => {
45
- if (error) reject(error);
37
+ if (error) reject(new ProtocolWriteError(error));
46
38
  else if (index === parts.length - 1) resolve();
47
39
  });
48
40
  }
49
- } catch (error) { reject(error); }
41
+ } catch (error) { reject(new ProtocolWriteError(error)); }
50
42
  finally { uncork(); }
51
43
  });
52
44
  const sendChunk = (id, chunk) => {
@@ -57,48 +49,92 @@ export function createProtocolOutput(write, { cork, uncork } = {}) {
57
49
  };
58
50
 
59
51
  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); }
52
+ let responseStarted = false, bodyCancelled = false;
53
+ try {
54
+ signalController?.signal.throwIfAborted();
55
+ const responseBody = result.bufferedBody ?? result.body;
56
+ const pageError = { ...(result.pageError ? { pageError: result.pageError } : {}),
57
+ ...(result.pageFailure ? { pageFailure: result.pageFailure } : {}) };
58
+ if (stream && !result.isr && (method === 'HEAD' || NO_BODY.has(result.status))) {
59
+ bodyCancelled = true;
60
+ await result.cancel?.();
61
+ if (result.body?.cancel && !result.body.locked) await result.body.cancel();
62
+ await send(JSON.stringify({ id, status: result.status, headers: result.headers || {}, body: '', ...pageError }) + '\n');
80
63
  return;
81
64
  }
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'})]);
65
+ if (!stream && !result.isr) {
66
+ await send(JSON.stringify({ id, status: result.status, headers: result.headers || {}, body: Buffer.from(result.body || '').toString('base64'), ...pageError }) + '\n');
90
67
  return;
91
68
  }
92
- }
93
- await send(head);
94
- try {
95
- for await (const chunk of responseChunks(responseBody)) {
96
- await sendChunk(id, chunk);
69
+ const headers = Object.fromEntries(Object.entries(result.headers || {}).filter(([name]) => name.toLowerCase() !== 'content-length'));
70
+ if (compactResponse && !result.isr && (typeof responseBody === 'string' || responseBody instanceof ArrayBuffer || ArrayBuffer.isView(responseBody))) {
71
+ const body = bytes(responseBody);
72
+ if (body.length <= 16 * 1024) {
73
+ const complete = controlLine({ id, type: 'complete', status: result.status, headers, length: body.length, ...pageError });
74
+ if (cork && uncork) await sendParts(body.length ? [complete, body] : [complete]);
75
+ else { await send(complete); if (body.length) await send(body); }
76
+ return;
77
+ }
78
+ }
79
+ const head = controlLine({ id, type: 'head', status: result.status, headers, ...pageError, ...(result.isr ? { isr: result.isr } : {}) });
80
+ if (cork && uncork && (typeof responseBody === 'string' || responseBody instanceof ArrayBuffer || ArrayBuffer.isView(responseBody))) {
81
+ const body = bytes(responseBody);
82
+ if (body.length <= MAX_STREAM_CHUNK) {
83
+ // Pages HTML and buffered action responses already know their entire
84
+ // small body. Send their frames together, keeping the same wire format.
85
+ await sendParts([head, ...(body.length ? [controlLine({id,type:'chunk',length:body.length}),body] : []), controlLine({id,type:'end'})]);
86
+ return;
87
+ }
88
+ }
89
+ await send(head);
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
+ };
100
+ // Iterate the producer directly: an intermediate async generator adds
101
+ // promise reactions and Buffer views for every already bounded chunk.
102
+ // Each write still settles before the next chunk or producer read.
103
+ if (responseBody !== null && responseBody !== undefined) {
104
+ if (typeof responseBody === 'string' || responseBody instanceof ArrayBuffer || ArrayBuffer.isView(responseBody)) {
105
+ const value = bytes(responseBody);
106
+ for (let offset = 0; offset < value.length; offset += MAX_STREAM_CHUNK) {
107
+ await sendChunk(id, value.subarray(offset, offset + MAX_STREAM_CHUNK));
108
+ if (++chunksSinceYield === STREAM_YIELD_CHUNKS) await yieldStream();
109
+ }
110
+ } else {
111
+ if (typeof responseBody[Symbol.asyncIterator] !== 'function' && typeof responseBody[Symbol.iterator] !== 'function') {
112
+ throw new TypeError('Invalid response stream');
113
+ }
114
+ for await (const item of responseBody) {
115
+ const value = bytes(item);
116
+ if (value.length <= MAX_STREAM_CHUNK) {
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();
120
+ } else {
121
+ for (let offset = 0; offset < value.length; offset += MAX_STREAM_CHUNK) {
122
+ await sendChunk(id, value.subarray(offset, offset + MAX_STREAM_CHUNK));
123
+ if (++chunksSinceYield === STREAM_YIELD_CHUNKS) await yieldStream();
124
+ }
125
+ }
126
+ }
127
+ }
97
128
  }
98
129
  await control({ id, type: 'end' });
99
130
  } catch (error) {
131
+ const cancelled = signalController?.signal.aborted || error instanceof ProtocolWriteError;
100
132
  signalController?.abort(error);
101
- await result.cancel?.(error);
133
+ if (!bodyCancelled) await result.cancel?.(error);
134
+ // Socket cancellation has no writable peer and no protocol recovery on
135
+ // this lane. Cancel its producer once; never attempt a second write (and
136
+ // secondary EPIPE) or report a client disconnect as an application error.
137
+ if (cancelled || !responseStarted) throw error;
102
138
  console.error('[prnext] Response stream failed:', error?.stack || error);
103
139
  // Headers are already on the wire: terminate this response, never emit a
104
140
  // replacement HTTP response or retry application code after a mutation.
@@ -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;