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