@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.
- package/README.md +395 -29
- package/build/actions.mjs +4 -0
- package/build/app-config.mjs +2 -0
- package/build/app.mjs +20 -5
- package/build/bundle-runtime.mjs +82 -0
- package/build/cache-handlers.mjs +4 -1
- package/build/compiler.mjs +4 -1
- package/build/config.mjs +20 -2
- package/build/font-cache.mjs +50 -0
- package/build/font-files.mjs +5 -3
- package/build/fonts.mjs +1 -1
- package/build/index.mjs +12 -1
- package/build/metadata-conflicts.mjs +13 -0
- package/build/metadata-images.mjs +3 -3
- package/build/metadata-routes.mjs +3 -3
- package/build/native-packages.mjs +5 -1
- package/build/npm-packages.mjs +14 -2
- package/build/optimize-imports.mjs +112 -0
- package/build/proxy-body-config.mjs +10 -0
- package/build/runtime-contract.mjs +16 -0
- package/build/scan.mjs +9 -7
- package/build/typescript-check.mjs +20 -0
- package/build/typescript.mjs +49 -0
- package/build/worker-retirement.mjs +14 -0
- package/cli.mjs +17 -0
- package/compat/after.cjs +51 -0
- package/compat/index.d.ts +13 -1
- package/compat/server.cjs +2 -1
- package/compat/server.d.cts +2 -0
- package/native/launch.mjs +19 -0
- package/package.json +25 -9
- package/release/verify.mjs +9 -0
- package/runtime/api.mjs +15 -5
- package/runtime/app-render.mjs +164 -53
- package/runtime/app-route-plan.mjs +45 -0
- package/runtime/app-routing-state.mjs +101 -21
- package/runtime/app-routing.mjs +37 -18
- package/runtime/app-segments.mjs +25 -11
- package/runtime/app-static.mjs +1 -1
- package/runtime/app-stream-html.mjs +3 -53
- package/runtime/cache-request.mjs +10 -2
- package/runtime/cloud/archive.mjs +316 -0
- package/runtime/cloud/client.mjs +658 -0
- package/runtime/cloud/environment.mjs +60 -0
- package/runtime/cloud/state.mjs +141 -0
- package/runtime/cloud/upload.mjs +48 -0
- package/runtime/html-envelope.mjs +136 -0
- package/runtime/http.mjs +9 -6
- package/runtime/middleware.mjs +3 -3
- package/runtime/profiles.json +13 -8
- package/runtime/request-transport.mjs +1 -1
- package/runtime/request-work.mjs +2 -2
- package/runtime/route-static.mjs +1 -1
- package/runtime/rsc-lifecycle.mjs +119 -0
- package/runtime/rsc-work-watchdog.mjs +75 -0
- package/runtime/rsc-worker.mjs +36 -9
- package/runtime/stream-utils.mjs +22 -3
- package/runtime/transport.mjs +89 -53
- package/runtime/worker-metadata.mjs +23 -0
- 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
|
+
}
|
package/runtime/rsc-worker.mjs
CHANGED
|
@@ -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 (
|
|
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.
|
|
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
|
|
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))
|
|
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
|
-
|
|
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
|
});
|
package/runtime/stream-utils.mjs
CHANGED
|
@@ -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
|
-
|
|
76
|
-
|
|
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;
|
package/runtime/transport.mjs
CHANGED
|
@@ -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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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;
|