@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
package/runtime/app-render.mjs
CHANGED
|
@@ -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';
|
|
@@ -6,7 +7,7 @@ import { addBasePath, removeBasePath } from '../compat/paths.cjs';
|
|
|
6
7
|
import { isDynamicBailout } from '../compat/dynamic-bailout.cjs';
|
|
7
8
|
import { Readable } from 'node:stream';
|
|
8
9
|
import { Worker } from 'node:worker_threads';
|
|
9
|
-
import { workerMetadata } from './worker-metadata.mjs';
|
|
10
|
+
import { workerMetadata, freezeClientModules, isImmutableClientModules } from './worker-metadata.mjs';
|
|
10
11
|
import { workerArtifacts } from './worker-artifacts.mjs';
|
|
11
12
|
import path from 'node:path';
|
|
12
13
|
import { PARTIAL_PREFETCH_TYPE, PARTIAL_PREFETCH_BYTES } from './app-prefetch.mjs';
|
|
@@ -25,16 +26,21 @@ 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();
|
|
35
40
|
const emptyManifest = Object.freeze({});
|
|
36
41
|
let nextId = 0;
|
|
37
42
|
const pending = new Map();
|
|
43
|
+
const noop = () => {};
|
|
38
44
|
// Avoid temporary arrays on every completion/cancellation at high concurrency.
|
|
39
45
|
function hasPending(worker, minimum = 1, cancelledOnly = false) {
|
|
40
46
|
let count = 0;
|
|
@@ -67,14 +73,14 @@ function flightDecoder(production) {
|
|
|
67
73
|
// React's development decoder guards module initialization with NODE_ENV.
|
|
68
74
|
// This require is synchronous; restore the host before any stream work.
|
|
69
75
|
process.env.NODE_ENV = production ? 'production' : 'development';
|
|
70
|
-
let decoder = require(filename)
|
|
71
|
-
if (typeof decoder !== 'function') {
|
|
76
|
+
let decoder = require(filename);
|
|
77
|
+
if (typeof decoder.createFromNodeStream !== 'function') {
|
|
72
78
|
// A consumer may have previously required the guarded development file
|
|
73
79
|
// in production, leaving an empty module in Node's cache.
|
|
74
80
|
delete require.cache[filename];
|
|
75
|
-
decoder = require(filename)
|
|
81
|
+
decoder = require(filename);
|
|
76
82
|
}
|
|
77
|
-
if (typeof decoder !== 'function') throw new Error('React Flight decoder is unavailable for the requested render mode');
|
|
83
|
+
if (typeof decoder.createFromNodeStream !== 'function') throw new Error('React Flight decoder is unavailable for the requested render mode');
|
|
78
84
|
flightDecoders.set(production, decoder);
|
|
79
85
|
return decoder;
|
|
80
86
|
} finally {
|
|
@@ -97,6 +103,7 @@ globalThis.__webpack_chunk_load__ = id => {
|
|
|
97
103
|
|
|
98
104
|
function getWorker(production) {
|
|
99
105
|
if (workers.has(production)) return workers.get(production);
|
|
106
|
+
const created = diagnostics.enabled ? performance.now() : 0;
|
|
100
107
|
const current = new Worker(new URL('./rsc-worker.mjs', import.meta.url), {
|
|
101
108
|
execArgv: ['--conditions=react-server'], stdout: true, stderr: true,
|
|
102
109
|
// Production nursery policy; an explicit Node semi-space flag takes
|
|
@@ -105,37 +112,68 @@ function getWorker(production) {
|
|
|
105
112
|
env: { ...process.env, NODE_ENV: production ? 'production' : 'development' },
|
|
106
113
|
});
|
|
107
114
|
workers.set(production, current);
|
|
115
|
+
if (diagnostics.enabled) {
|
|
116
|
+
current.once('online', () => diagnostics.record('wake', {phase:'rsc-online', durationMs:performance.now()-created}));
|
|
117
|
+
const ready = message => {
|
|
118
|
+
if (message.type !== 'rsc-ready') return;
|
|
119
|
+
diagnostics.record('wake', {phase:'rsc-ready', durationMs:performance.now()-created});
|
|
120
|
+
current.off('message', ready);
|
|
121
|
+
};
|
|
122
|
+
current.on('message', ready);
|
|
123
|
+
}
|
|
124
|
+
let workWatchdog;
|
|
125
|
+
const lifecycle = createRscLifecycle(() => { workWatchdog?.close(); return current.terminate(); });
|
|
126
|
+
workWatchdog = createRscWorkWatchdog({
|
|
127
|
+
probe: token => current.postMessage({ type: 'work-probe', token }),
|
|
128
|
+
retire: error => {
|
|
129
|
+
console.error('[prnext] RSC background worker stopped responding:', error);
|
|
130
|
+
return lifecycle.terminate(error);
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
lifecycles.set(current, lifecycle);
|
|
108
134
|
metadata.set(current, workerMetadata(current));
|
|
109
135
|
artifacts.set(current, workerArtifacts(current));
|
|
110
136
|
// The HTTP worker's stdout belongs to its Rust transport. User code and React
|
|
111
137
|
// diagnostics in the separate RSC environment must never corrupt that stream.
|
|
112
138
|
current.stdout.pipe(process.stderr);
|
|
113
139
|
current.stderr.pipe(process.stderr);
|
|
114
|
-
function fail(error) {
|
|
115
|
-
|
|
140
|
+
function fail(error, exited = false) {
|
|
141
|
+
// Keep a retiring isolate registered until its actual exit; callers may
|
|
142
|
+
// wait for replacement but cannot start an unbounded series of retirees.
|
|
143
|
+
if (exited && workers.get(production) === current) workers.delete(production);
|
|
144
|
+
lifecycle.close(error);
|
|
145
|
+
workWatchdog.close();
|
|
116
146
|
for (const [id, request] of pending) {
|
|
117
147
|
if (request.worker !== current) continue;
|
|
118
148
|
clearTimeout(request.timeout);
|
|
119
149
|
pending.delete(id);
|
|
150
|
+
request.lease.release();
|
|
151
|
+
request.settle();
|
|
152
|
+
request.releaseWorkWatch?.();
|
|
120
153
|
request.detach?.();
|
|
121
154
|
request.channel?.error(error);
|
|
122
155
|
request.reject(error);
|
|
123
156
|
}
|
|
124
157
|
}
|
|
125
|
-
current.on('message', ({ id, type, chunk, byteOffset, byteLength, done, body, status, rscError, boundaryIndex, headers, error, staticParams, staticMetadata, navigation }) => {
|
|
158
|
+
current.on('message', ({ id, type, token, chunk, byteOffset, byteLength, done, body, status, rscError, boundaryIndex, headers, error, staticParams, staticMetadata, navigation }) => {
|
|
159
|
+
if (type === 'work-responsive') { workWatchdog.ack(token); return; }
|
|
126
160
|
const request = pending.get(id);
|
|
127
161
|
if (!request || request.worker !== current) return;
|
|
128
162
|
// A timed-out mutation can still be running after its HTTP caller leaves.
|
|
129
163
|
// Its retirement marker must be honored before discarding cancelled output.
|
|
130
164
|
if (error?.retireWorker) {
|
|
131
|
-
|
|
132
|
-
void current.terminate();
|
|
165
|
+
void lifecycle.terminate(new Error(error.message)).catch(error => console.error('[prnext] RSC worker termination failed:', error));
|
|
133
166
|
}
|
|
134
167
|
if (request.cancelled && type !== 'settled') return;
|
|
135
168
|
if (type === 'settled') {
|
|
136
|
-
if (!request.cancelled) return;
|
|
137
169
|
pending.delete(id); clearTimeout(request.timeout);
|
|
138
|
-
|
|
170
|
+
request.lease.release();
|
|
171
|
+
request.detach?.();
|
|
172
|
+
request.settle();
|
|
173
|
+
request.releaseWorkWatch?.();
|
|
174
|
+
// terminate() keeps its exit event referenced. A late settlement must
|
|
175
|
+
// not undo that reference while build publication awaits shutdown.
|
|
176
|
+
if (!hasPending(current) && !lifecycle.retiring) current.unref();
|
|
139
177
|
return;
|
|
140
178
|
}
|
|
141
179
|
if (type === 'start') {
|
|
@@ -145,11 +183,21 @@ function getWorker(production) {
|
|
|
145
183
|
}
|
|
146
184
|
if (type === 'chunk') {
|
|
147
185
|
try { request.channel.chunk(chunk, byteOffset, byteLength); }
|
|
148
|
-
catch (error) { request.channel.error(error); current.postMessage({ id, type: 'cancel' }); }
|
|
186
|
+
catch (error) { request.channel.error(error); current.postMessage({ id, type: 'cancel' }); request.cancel(error); return; }
|
|
149
187
|
if (!done) return;
|
|
150
188
|
}
|
|
151
|
-
|
|
189
|
+
if (request.completed) {
|
|
190
|
+
if (error) console.error('[prnext] RSC background work failed:', error.stack || error.message);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
request.completed = true;
|
|
194
|
+
// The response deadline ends with its successful terminal frame. Cache
|
|
195
|
+
// work can legitimately continue afterwards and retains its own bounds;
|
|
196
|
+
// supervise isolate responsiveness, not total background-task duration.
|
|
152
197
|
clearTimeout(request.timeout);
|
|
198
|
+
// A destroyed Timeout can still own its AsyncLocalStorage frame.
|
|
199
|
+
request.timeout = undefined;
|
|
200
|
+
request.releaseWorkWatch = workWatchdog.add();
|
|
153
201
|
request.detach?.();
|
|
154
202
|
if (type === 'end' || done) request.channel.end();
|
|
155
203
|
else if (error) {
|
|
@@ -171,51 +219,98 @@ function getWorker(production) {
|
|
|
171
219
|
// ArrayBuffer overload: a view of the transferred allocation, no copy.
|
|
172
220
|
request.resolve({ body: Buffer.from(body), status: status || 200, rscError, boundaryIndex, headers, staticMetadata, navigation });
|
|
173
221
|
}
|
|
174
|
-
|
|
222
|
+
// Keep only settlement bookkeeping after sending the body. The
|
|
223
|
+
// isolate can still be flushing cache writes after its terminal frame.
|
|
224
|
+
request.channel = undefined;
|
|
225
|
+
// Both closures capture the original response promise / AbortSignal.
|
|
226
|
+
// Detaching the listener alone does not release those references while
|
|
227
|
+
// legitimate background work keeps this entry in the pending map.
|
|
228
|
+
request.cancel = request.detach = undefined;
|
|
229
|
+
request.resolve = request.reject = noop;
|
|
175
230
|
});
|
|
176
231
|
current.on('error', fail);
|
|
177
|
-
current.on('exit', code => fail(new Error(`App Router worker exited (${code})`)));
|
|
232
|
+
current.on('exit', code => fail(new Error(`App Router worker exited (${code})`), true));
|
|
178
233
|
current.unref();
|
|
179
234
|
return current;
|
|
180
235
|
}
|
|
181
236
|
|
|
237
|
+
// Stdio workers can retain referenced MessagePorts after rendering (including
|
|
238
|
+
// diagnostics piped from the RSC isolate). EOF must drain real cache/after()
|
|
239
|
+
// settlement before explicitly closing those isolates, not wait for Node's
|
|
240
|
+
// incidental event-loop liveness or terminate unfinished application work.
|
|
241
|
+
export async function drainAppRuntime() {
|
|
242
|
+
while (pending.size) await Promise.all([...pending.values()].map(request => request.settled));
|
|
243
|
+
await closeAppRuntime();
|
|
244
|
+
}
|
|
245
|
+
|
|
182
246
|
export function closeAppRuntime() {
|
|
183
247
|
const current = [...workers.values()];
|
|
184
|
-
|
|
185
|
-
|
|
248
|
+
return Promise.all(current.map(worker => lifecycles.get(worker).terminate()));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function requestDeadline(entry, lifecycle, id, hardTimeoutMs, delayMs) {
|
|
252
|
+
// Keep this callback outside renderFlight's request closure: cancelled work
|
|
253
|
+
// retains its hard deadline, but must not thereby retain the HTTP signal or
|
|
254
|
+
// response promise until an unrelated cache write settles.
|
|
255
|
+
return setTimeout(() => {
|
|
256
|
+
// A blocking npm dependency cannot honor an AbortSignal. Retire the
|
|
257
|
+
// isolate at the hard deadline so subsequent requests can recover.
|
|
258
|
+
void lifecycle.terminate(appTimeoutError('RSC worker', hardTimeoutMs)).finally(entry.settle).catch(error => console.error('[prnext] RSC worker termination failed:', error));
|
|
259
|
+
pending.delete(id);
|
|
260
|
+
entry.lease.release();
|
|
261
|
+
const error = appTimeoutError('RSC worker', hardTimeoutMs);
|
|
262
|
+
if (entry.completed) console.error('[prnext] RSC background work failed:', error);
|
|
263
|
+
entry.channel?.error(error);
|
|
264
|
+
entry.detach?.();
|
|
265
|
+
entry.reject(error);
|
|
266
|
+
}, delayMs);
|
|
186
267
|
}
|
|
187
268
|
|
|
188
|
-
export function renderFlight(request, { softTimeoutMs
|
|
269
|
+
export async function renderFlight(request, { softTimeoutMs, hardTimeoutMs, signal, deferFinalizer } = {}) {
|
|
270
|
+
softTimeoutMs ??= request.cacheConfig?.maxDuration ? request.cacheConfig.maxDuration * 1000 : 25_000;
|
|
271
|
+
hardTimeoutMs ??= request.cacheConfig?.maxDuration ? request.cacheConfig.maxDuration * 1000 : 28_000;
|
|
272
|
+
const deadline = performance.now() + hardTimeoutMs;
|
|
273
|
+
let current, lifecycle, lease;
|
|
274
|
+
for (;;) {
|
|
275
|
+
signal?.throwIfAborted();
|
|
276
|
+
current = getWorker(Boolean(request.production));
|
|
277
|
+
lifecycle = lifecycles.get(current);
|
|
278
|
+
if (lifecycle.retiring) {
|
|
279
|
+
await lifecycle.waitForRetirement({ signal, timeoutMs: deadline - performance.now() });
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
try { lease = await lifecycle.acquire({ signal, timeoutMs: deadline - performance.now() }); }
|
|
283
|
+
catch (error) { if (lifecycle.retiring) continue; throw error; }
|
|
284
|
+
if (lifecycle.closed) { lease.release(); continue; }
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
189
287
|
return new Promise((resolve, reject) => {
|
|
190
|
-
|
|
288
|
+
if (signal?.aborted) { lease.release(); reject(signal.reason); return; }
|
|
289
|
+
if (performance.now() >= deadline) { lease.release(); reject(appTimeoutError('RSC admission', hardTimeoutMs)); return; }
|
|
191
290
|
current.ref();
|
|
192
291
|
const id = ++nextId;
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
pending.delete(id);
|
|
199
|
-
const error = appTimeoutError('RSC worker', hardTimeoutMs);
|
|
200
|
-
entry.channel?.error(error);
|
|
201
|
-
entry.detach?.();
|
|
202
|
-
reject(error);
|
|
203
|
-
}, hardTimeoutMs);
|
|
204
|
-
const entry = { worker: current, resolve, reject, timeout, operation: request.operation };
|
|
292
|
+
let settle;
|
|
293
|
+
const settled = new Promise(resolve => { settle = resolve; });
|
|
294
|
+
trackRequestWork(settled, signal, { deadline, supervised: true });
|
|
295
|
+
const entry = { worker: current, resolve, reject, operation: request.operation, lease, settle, settled };
|
|
296
|
+
const timeout = entry.timeout = requestDeadline(entry, lifecycle, id, hardTimeoutMs, Math.max(0, deadline - performance.now()));
|
|
205
297
|
function cancel(reason) {
|
|
298
|
+
if (entry.cancelled) return;
|
|
206
299
|
// Keep the independent deadline until the isolate confirms settlement.
|
|
207
300
|
// A disconnected client must not disable recovery of blocked npm code.
|
|
208
301
|
entry.cancelled = true;
|
|
302
|
+
lease.cancel();
|
|
209
303
|
entry.detach?.();
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
if (hasPending(current
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
304
|
+
// Pause new work at 16 abandoned requests while their acknowledgements
|
|
305
|
+
// settle. Existing mutation/cache work keeps its original hard deadline.
|
|
306
|
+
if (!hasPending(current) && !lifecycle.retiring) current.unref();
|
|
307
|
+
entry.reject(reason instanceof Error ? reason : new Error('App Router response was cancelled'));
|
|
308
|
+
// Settlement/deadline tracking remains live, but a disconnected caller
|
|
309
|
+
// no longer needs its stream, promise or signal retained by this entry.
|
|
310
|
+
entry.channel = entry.cancel = entry.detach = undefined;
|
|
311
|
+
entry.resolve = entry.reject = noop;
|
|
218
312
|
}
|
|
313
|
+
entry.cancel = cancel;
|
|
219
314
|
if (request.stream) entry.channel = parentStreamChannel(current, id, cancel);
|
|
220
315
|
if (signal) {
|
|
221
316
|
const onAbort = () => {
|
|
@@ -225,11 +320,14 @@ export function renderFlight(request, { softTimeoutMs = 25_000, hardTimeoutMs =
|
|
|
225
320
|
};
|
|
226
321
|
entry.detach = () => signal.removeEventListener('abort', onAbort);
|
|
227
322
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
228
|
-
if (signal.aborted) { clearTimeout(timeout); onAbort(); return; }
|
|
229
323
|
}
|
|
230
324
|
pending.set(id, entry);
|
|
231
325
|
try {
|
|
232
|
-
const wire = { ...request, renderTimeoutMs: softTimeoutMs };
|
|
326
|
+
const wire = { ...request, renderTimeoutMs: Math.min(softTimeoutMs, Math.max(1, deadline - performance.now())) };
|
|
327
|
+
if (request.action && deferFinalizer) {
|
|
328
|
+
wire.waitForResponse = true;
|
|
329
|
+
deferFinalizer(() => { if (pending.has(id)) current.postMessage({ id, type: 'after-response' }); });
|
|
330
|
+
}
|
|
233
331
|
if (request.production) {
|
|
234
332
|
const artifactId = artifacts.get(current)(wire.partialFlight);
|
|
235
333
|
if (artifactId !== undefined) { wire.partialFlightId = artifactId; delete wire.partialFlight; }
|
|
@@ -243,19 +341,22 @@ export function renderFlight(request, { softTimeoutMs = 25_000, hardTimeoutMs =
|
|
|
243
341
|
catch (error) {
|
|
244
342
|
clearTimeout(timeout);
|
|
245
343
|
pending.delete(id);
|
|
344
|
+
lease.release();
|
|
345
|
+
settle();
|
|
246
346
|
entry.detach?.();
|
|
247
347
|
reject(error);
|
|
248
|
-
if (!hasPending(current)) current.unref();
|
|
348
|
+
if (!hasPending(current) && !lifecycle.retiring) current.unref();
|
|
249
349
|
}
|
|
250
350
|
});
|
|
251
351
|
}
|
|
252
352
|
|
|
253
353
|
export async function decodeFlight(body, clientModules = {}, distDir, { production = process.env.NODE_ENV === 'production' } = {}) {
|
|
254
|
-
const ids = Object.keys(clientModules);
|
|
255
354
|
let cached = ssrManifests.get(clientModules);
|
|
256
355
|
// Keep only the manifest's module mapping, never a decoded React model or
|
|
257
356
|
// request context. Weak ownership releases it with the build manifest.
|
|
258
|
-
|
|
357
|
+
const immutable = isImmutableClientModules(clientModules);
|
|
358
|
+
const ids = cached && cached.distDir === distDir && cached.immutable && immutable ? null : Object.keys(clientModules);
|
|
359
|
+
if (!cached || cached.distDir !== distDir || (ids && (ids.length !== cached.paths.size || ids.some(id => cached.paths.get(id) !== clientModules[id].ssrModule)))) {
|
|
259
360
|
const moduleMap = Object.create(null), paths = new Map();
|
|
260
361
|
for (const id of ids) {
|
|
261
362
|
const item = clientModules[id];
|
|
@@ -266,13 +367,20 @@ export async function decodeFlight(body, clientModules = {}, distDir, { producti
|
|
|
266
367
|
moduleMap[id] = { '*': { id: moduleUrl, chunks: [moduleUrl, moduleUrl], name: '*' } };
|
|
267
368
|
paths.set(id, item.ssrModule);
|
|
268
369
|
}
|
|
269
|
-
cached = { distDir, paths, moduleMap };
|
|
370
|
+
cached = { distDir, paths, moduleMap, immutable };
|
|
270
371
|
ssrManifests.set(clientModules, cached);
|
|
271
|
-
}
|
|
372
|
+
} else if (immutable) cached.immutable = true;
|
|
272
373
|
// Hosts can build/render both modes in one process. The encoded protocol,
|
|
273
374
|
// rather than the host's current environment or import cache, selects React.
|
|
274
375
|
const decoder = flightDecoder(Boolean(production));
|
|
275
|
-
|
|
376
|
+
const serverConsumerManifest = { moduleMap: cached.moduleMap, serverModuleMap: null, moduleLoading: null };
|
|
377
|
+
// React's Node decoder can consume Web streams directly. Keep the Flight
|
|
378
|
+
// bytes in their original stream instead of allocating a Node Readable,
|
|
379
|
+
// forwarding its events and buffering a second queue for each response.
|
|
380
|
+
if (body instanceof ReadableStream && typeof decoder.createFromReadableStream === 'function') {
|
|
381
|
+
return decoder.createFromReadableStream(body, { serverConsumerManifest });
|
|
382
|
+
}
|
|
383
|
+
return decoder.createFromNodeStream(body instanceof ReadableStream ? Readable.fromWeb(body) : Readable.from([body]), serverConsumerManifest);
|
|
276
384
|
}
|
|
277
385
|
|
|
278
386
|
export async function renderHtml(tree, router, { timeoutMs = 25_000, formState = null, staticGeneration = false, nonce, strictMode = true } = {}) {
|
|
@@ -348,13 +456,16 @@ export async function renderAppPage(options) {
|
|
|
348
456
|
return response;
|
|
349
457
|
}
|
|
350
458
|
|
|
351
|
-
async function renderAppPageInner({ modulePath, route = {}, manifest = {}, distDir = path.dirname(path.dirname(modulePath)), url = 'http://localhost/', originalUrl, params = {}, method = 'GET', headers = {}, routingRequestHeaders, routingResolver, body = '', production = process.env.NODE_ENV === 'production', stream = false, signal }) {
|
|
459
|
+
async function renderAppPageInner({ modulePath, route = {}, manifest = {}, distDir = path.dirname(path.dirname(modulePath)), url = 'http://localhost/', originalUrl, params = {}, method = 'GET', headers = {}, routingRequestHeaders, routingResolver, body = '', production = process.env.NODE_ENV === 'production', stream = false, signal, deferFinalizer }) {
|
|
352
460
|
let clientModules = manifest.app?.clientModules || emptyManifest;
|
|
353
461
|
if (route.cacheConfig?.runtime === 'edge') {
|
|
354
462
|
let edge = !manifest.dev && edgeManifests.get(clientModules);
|
|
355
463
|
if (!edge) {
|
|
356
464
|
edge = Object.fromEntries(Object.entries(clientModules).map(([id, item]) => [id, item.edgeSsrModule ? { ...item, ssrModule: item.edgeSsrModule } : item]));
|
|
357
|
-
if (!manifest.dev)
|
|
465
|
+
if (!manifest.dev) {
|
|
466
|
+
if (isImmutableClientModules(clientModules)) freezeClientModules(edge);
|
|
467
|
+
edgeManifests.set(clientModules, edge);
|
|
468
|
+
}
|
|
358
469
|
}
|
|
359
470
|
clientModules = edge;
|
|
360
471
|
}
|
|
@@ -424,7 +535,7 @@ async function renderAppPageInner({ modulePath, route = {}, manifest = {}, distD
|
|
|
424
535
|
if (stream && (!request.action || request.action.id)) {
|
|
425
536
|
let progressive;
|
|
426
537
|
try {
|
|
427
|
-
progressive = await renderFlight({ ...request, stream: true }, { signal });
|
|
538
|
+
progressive = await renderFlight({ ...request, stream: true }, { signal, deferFinalizer });
|
|
428
539
|
const rsc = Object.entries(headers).some(([name, value]) => name.toLowerCase() === 'rsc' && value === '1');
|
|
429
540
|
if (rsc || request.action?.id) {
|
|
430
541
|
if (method === 'HEAD') await progressive.body.cancel();
|
|
@@ -448,7 +559,7 @@ async function renderAppPageInner({ modulePath, route = {}, manifest = {}, distD
|
|
|
448
559
|
}
|
|
449
560
|
}
|
|
450
561
|
let result;
|
|
451
|
-
try { result = await renderFlight(request, { signal }); }
|
|
562
|
+
try { result = await renderFlight(request, { signal, deferFinalizer }); }
|
|
452
563
|
catch (error) {
|
|
453
564
|
const result = navigationResponse(error);
|
|
454
565
|
if (result) return { ...result, headers: { ...responseHeaders, ...result.headers, ...error.responseHeaders } };
|
|
@@ -465,7 +576,7 @@ async function renderAppPageInner({ modulePath, route = {}, manifest = {}, distD
|
|
|
465
576
|
let html, model, recovery = false;
|
|
466
577
|
try {
|
|
467
578
|
model = await decodeFlight(result.body, clientModules, distDir, { production });
|
|
468
|
-
html = await renderHtml(model.tree, model.router, { formState: model.formState, nonce: request.nonce, strictMode: manifest.config?.reactStrictMode !== false });
|
|
579
|
+
html = await renderHtml(model.tree, model.router, { timeoutMs: route.cacheConfig?.maxDuration ? route.cacheConfig.maxDuration * 1000 : undefined, formState: model.formState, nonce: request.nonce, strictMode: manifest.config?.reactStrictMode !== false });
|
|
469
580
|
} catch (error) {
|
|
470
581
|
if (error?.statusCode === 504) throw error;
|
|
471
582
|
const navigation = navigationResponse(error);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Cache only the compiler-owned, immutable routing scaffold. Component modules,
|
|
2
|
+
// configuration, request parameters and rendered trees are never memoized here.
|
|
3
|
+
const plans = new WeakMap();
|
|
4
|
+
const liveTrees = new WeakSet();
|
|
5
|
+
const ownValue = (object, name) => Object.getOwnPropertyDescriptor(object, name)?.value;
|
|
6
|
+
function frozenRecord(value) {
|
|
7
|
+
if (!value || typeof value !== 'object' || !Object.isFrozen(value)) return false;
|
|
8
|
+
const prototype = Object.getPrototypeOf(value);
|
|
9
|
+
if (Array.isArray(value) ? prototype !== Array.prototype : prototype !== Object.prototype && prototype !== null) return false;
|
|
10
|
+
if (Array.isArray(value) && Object.hasOwn(value, Symbol.iterator)) return false;
|
|
11
|
+
return Reflect.ownKeys(value).every(key => 'value' in Object.getOwnPropertyDescriptor(value, key));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Mutable development/custom trees continue through the live traversal. */
|
|
15
|
+
export function appRoutePlan(root) {
|
|
16
|
+
if (!root || !Object.isFrozen(root)) return null;
|
|
17
|
+
const existing = plans.get(root);
|
|
18
|
+
if (existing) return existing;
|
|
19
|
+
if (liveTrees.has(root)) return null;
|
|
20
|
+
const pages = [], nodes = new Map(), rootLayouts = new Set();
|
|
21
|
+
let sequence = 0;
|
|
22
|
+
function visit(node, chain, branch, hasLayout) {
|
|
23
|
+
if (!frozenRecord(node)) return false;
|
|
24
|
+
const children = ownValue(node, 'children'), slots = ownValue(node, 'slots'), files = ownValue(node, 'files');
|
|
25
|
+
if (!Array.isArray(children) || !frozenRecord(children) || !frozenRecord(slots) || !frozenRecord(files)
|
|
26
|
+
|| typeof ownValue(node, 'path') !== 'string' || typeof ownValue(node, 'pattern') !== 'string'
|
|
27
|
+
|| typeof ownValue(node, 'segment') !== 'string' || nodes.has(node)) return false;
|
|
28
|
+
// Bound retained chains even for hand-written frozen manifests.
|
|
29
|
+
if (sequence >= 4096 || chain.length >= 64) return false;
|
|
30
|
+
const next = [...chain, node], start = sequence++;
|
|
31
|
+
const info = { start, end: start, depth: chain.length, branch, slots: Object.entries(slots), metadataSlots: Object.entries(slots).sort(([a], [b]) => a.localeCompare(b)) };
|
|
32
|
+
nodes.set(node, info);
|
|
33
|
+
if (files.layout && !hasLayout) rootLayouts.add(node.path);
|
|
34
|
+
if (files.page) pages.push({ node, chain: next, start, branch,
|
|
35
|
+
score: node.pattern.split('/').filter(Boolean).reduce((sum, value) => sum + (value.startsWith('[[...') ? 0 : value.startsWith('[...') ? 1 : value.startsWith('[') ? 3 : 6), 0) });
|
|
36
|
+
for (const child of children) if (!visit(child, next, branch, hasLayout || !!files.layout)) return false;
|
|
37
|
+
for (const child of Object.values(slots)) if (!visit(child, next, child, hasLayout || !!files.layout)) return false;
|
|
38
|
+
info.end = sequence;
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
if (!visit(root, [], root, false)) { liveTrees.add(root); return null; }
|
|
42
|
+
const plan = { pages, nodes, rootLayouts };
|
|
43
|
+
plans.set(root, plan);
|
|
44
|
+
return plan;
|
|
45
|
+
}
|
|
@@ -1,26 +1,98 @@
|
|
|
1
1
|
// Route matching and navigation state do not need React or metadata rendering.
|
|
2
2
|
import { removeBasePath } from '../compat/paths.cjs';
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
4
|
+
// Only trusted route syntax is cached, never URLs, decoded parameters or request
|
|
5
|
+
// state. Bound the shared lookup's entry count and retained text/segments.
|
|
6
|
+
const patterns = new Map();
|
|
7
|
+
// Live manifest entries own their parsed syntax weakly. Large route tables must
|
|
8
|
+
// not churn the bounded cross-call cache once for every saved parallel slot.
|
|
9
|
+
// Their lifetime follows the existing manifest; no old manifest is retained.
|
|
10
|
+
// Re-read the pattern on each use so mutable development manifests stay live.
|
|
11
|
+
const routePatterns = new WeakMap();
|
|
12
|
+
let patternCharacters = 0, patternSegments = 0;
|
|
13
|
+
function compilePattern(pattern) {
|
|
14
|
+
let compiled = patterns.get(pattern);
|
|
15
|
+
if (compiled) return compiled;
|
|
16
|
+
const segments = pattern.split('/').filter(Boolean);
|
|
17
|
+
const kinds = new Uint8Array(segments.length);
|
|
18
|
+
let score = 0;
|
|
19
|
+
for (let index = 0; index < segments.length; index++) {
|
|
20
|
+
const segment = segments[index];
|
|
21
|
+
if (segment[0] !== '[') { score += 6; continue; }
|
|
22
|
+
score += segment.startsWith('[[...') ? 0 : segment.startsWith('[...') ? 1 : 3;
|
|
11
23
|
const optional = /^\[\[\.\.\.([^\]]+)\]\]$/.exec(segment);
|
|
12
24
|
const catchAll = /^\[\.\.\.([^\]]+)\]$/.exec(segment);
|
|
13
25
|
if (optional || catchAll) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
26
|
+
kinds[index] = optional ? 3 : 2;
|
|
27
|
+
segments[index] = (optional || catchAll)[1];
|
|
28
|
+
} else {
|
|
29
|
+
const dynamic = /^\[([^\]]+)\]$/.exec(segment);
|
|
30
|
+
if (dynamic) { kinds[index] = 1; segments[index] = dynamic[1]; }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
compiled = { segments, kinds, score, characters: pattern.length };
|
|
34
|
+
if (pattern.length <= 2048 && segments.length <= 64) {
|
|
35
|
+
while (patterns.size >= 1024 || patternCharacters + pattern.length > 128 * 1024 || patternSegments + segments.length > 8192) {
|
|
36
|
+
const oldest = patterns.keys().next().value;
|
|
37
|
+
const removed = patterns.get(oldest);
|
|
38
|
+
patterns.delete(oldest);
|
|
39
|
+
patternCharacters -= removed.characters;
|
|
40
|
+
patternSegments -= removed.segments.length;
|
|
17
41
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
else if (segment !== parts[index]) return null;
|
|
42
|
+
patterns.set(pattern, compiled);
|
|
43
|
+
patternCharacters += pattern.length;
|
|
44
|
+
patternSegments += segments.length;
|
|
22
45
|
}
|
|
23
|
-
return
|
|
46
|
+
return compiled;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function pathSegments(pathname) {
|
|
50
|
+
const raw = pathname.split('/').filter(Boolean);
|
|
51
|
+
try { return raw.map(decodeURIComponent); }
|
|
52
|
+
catch { return null; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function compiledRoute(route) {
|
|
56
|
+
const pattern = route.pattern;
|
|
57
|
+
const cached = routePatterns.get(route);
|
|
58
|
+
if (cached && cached.pattern === pattern) return cached.compiled;
|
|
59
|
+
const compiled = compilePattern(pattern);
|
|
60
|
+
if (pattern.length <= 2048 && compiled.segments.length <= 64) routePatterns.set(route, { pattern, compiled });
|
|
61
|
+
else routePatterns.delete(route);
|
|
62
|
+
return compiled;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function matchesParts({ segments, kinds }, parts, prefix = false, params) {
|
|
66
|
+
for (let index = 0; index < segments.length; index++) {
|
|
67
|
+
const kind = kinds[index];
|
|
68
|
+
if (kind >= 2) {
|
|
69
|
+
if (kind === 2 && index >= parts.length) return false;
|
|
70
|
+
if (params && index < parts.length) params[segments[index]] = parts.slice(index);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
if (index >= parts.length) return false;
|
|
74
|
+
if (kind === 1) { if (params) params[segments[index]] = parts[index]; }
|
|
75
|
+
else if (segments[index] !== parts[index]) return false;
|
|
76
|
+
}
|
|
77
|
+
return prefix || segments.length === parts.length;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function matchAppPattern(pattern, pathname, prefix = false) {
|
|
81
|
+
const compiled = compilePattern(pattern), parts = pathSegments(pathname);
|
|
82
|
+
if (!parts) return null;
|
|
83
|
+
const params = {};
|
|
84
|
+
return matchesParts(compiled, parts, prefix, params) ? params : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Decode a request pathname once; each match still owns its parameter values. */
|
|
88
|
+
export function createAppMatcher(pathname) {
|
|
89
|
+
const parts = pathSegments(pathname);
|
|
90
|
+
return (pattern, prefix = false) => {
|
|
91
|
+
const compiled = compilePattern(pattern);
|
|
92
|
+
if (!parts) return null;
|
|
93
|
+
const params = {};
|
|
94
|
+
return matchesParts(compiled, parts, prefix, params) ? params : null;
|
|
95
|
+
};
|
|
24
96
|
}
|
|
25
97
|
|
|
26
98
|
export function readRouterState(request) {
|
|
@@ -49,13 +121,21 @@ export function advancedRoutingSources(request, routes) {
|
|
|
49
121
|
const modules = new Set();
|
|
50
122
|
for (const saved of Object.values(state.slots)) {
|
|
51
123
|
const pathname = new URL(saved.url, 'http://prnext.local').pathname;
|
|
52
|
-
const
|
|
53
|
-
|
|
124
|
+
const parts = pathSegments(pathname);
|
|
125
|
+
if (!parts) continue;
|
|
126
|
+
let route, bestScore = -1;
|
|
127
|
+
for (let index = 0, length = routes.length; index < length; index++) {
|
|
128
|
+
if (!(index in routes)) continue;
|
|
129
|
+
const item = routes[index];
|
|
130
|
+
if (item.router !== 'app' || item.kind !== 'page' || item.internal) continue;
|
|
131
|
+
const compiled = compiledRoute(item);
|
|
132
|
+
// The original stable sort kept the first route when scores were equal.
|
|
133
|
+
// Lower-scoring routes cannot replace a match already found.
|
|
134
|
+
if (compiled.score <= bestScore || !matchesParts(compiled, parts)) continue;
|
|
135
|
+
route = item;
|
|
136
|
+
bestScore = compiled.score;
|
|
137
|
+
}
|
|
54
138
|
if (route) modules.add(route.module);
|
|
55
139
|
}
|
|
56
140
|
return [...modules];
|
|
57
141
|
}
|
|
58
|
-
|
|
59
|
-
function score(pattern) {
|
|
60
|
-
return pattern.split('/').filter(Boolean).reduce((sum, value) => sum + (value.startsWith('[[...') ? 0 : value.startsWith('[...') ? 1 : value.startsWith('[') ? 3 : 6), 0);
|
|
61
|
-
}
|