@rangojs/router 0.0.0-experimental.139 → 0.0.0-experimental.140
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/dist/bin/rango.js +27 -2
- package/dist/vite/index.js +147 -30
- package/package.json +1 -1
- package/skills/breadcrumbs/SKILL.md +1 -1
- package/skills/cache-guide/SKILL.md +1 -0
- package/skills/caching/SKILL.md +1 -1
- package/skills/migrate-nextjs/SKILL.md +15 -0
- package/skills/migrate-react-router/SKILL.md +15 -2
- package/skills/ppr/SKILL.md +426 -0
- package/skills/rango/SKILL.md +28 -25
- package/skills/route/SKILL.md +43 -0
- package/src/build/route-trie.ts +35 -7
- package/src/cache/cf/cf-cache-store.ts +155 -0
- package/src/cache/index.ts +6 -0
- package/src/cache/memory-segment-store.ts +57 -1
- package/src/cache/shell-cache.ts +386 -0
- package/src/cache/types.ts +58 -0
- package/src/cache/vercel/vercel-cache-store.ts +159 -5
- package/src/index.rsc.ts +5 -0
- package/src/index.ts +17 -0
- package/src/router/middleware.ts +14 -5
- package/src/router/parse-pattern.ts +115 -0
- package/src/router/pattern-matching.ts +53 -64
- package/src/router/segment-resolution/fresh.ts +12 -1
- package/src/router/segment-resolution/loader-cache.ts +14 -0
- package/src/router/segment-resolution/loader-mask.ts +44 -0
- package/src/router/substitute-pattern-params.ts +54 -35
- package/src/router/trie-matching.ts +19 -11
- package/src/router/url-params.ts +13 -0
- package/src/rsc/full-payload.ts +70 -0
- package/src/rsc/rsc-rendering.ts +105 -51
- package/src/rsc/shell-capture.ts +439 -0
- package/src/rsc/types.ts +26 -0
- package/src/server/cookie-store.ts +45 -0
- package/src/server/live.ts +130 -0
- package/src/server/request-context.ts +49 -0
- package/src/ssr/index.tsx +377 -180
- package/src/ssr/ssr-root.tsx +228 -0
- package/src/testing/render-route.tsx +7 -9
- package/src/types/route-config.ts +19 -7
- package/src/urls/type-extraction.ts +43 -18
- package/src/vite/discovery/discovery-errors.ts +61 -0
- package/src/vite/plugins/virtual-entries.ts +27 -2
- package/src/vite/router-discovery.ts +69 -15
- package/src/vite/utils/prerender-utils.ts +17 -4
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PPR shell capture orchestration (Axis 2, see docs/design/ppr-shell-resume.md).
|
|
3
|
+
*
|
|
4
|
+
* Capture does NOT flow through the HTTP middleware pipeline. The shell-cache
|
|
5
|
+
* middleware sets a `_shellCapture` DESCRIPTOR before its single foreground
|
|
6
|
+
* next(); the render layer (rsc-rendering.ts) reads it after building the served
|
|
7
|
+
* response and calls scheduleShellCapture. The capture then runs as a background
|
|
8
|
+
* task that re-derives the shell via `ctx.router.match()` under its OWN derived
|
|
9
|
+
* request context — fresh handle store, `_shellCaptureRun: true` so loaders mask
|
|
10
|
+
* (loader-mask.ts) and every loader-consuming subtree postpones. It drives the
|
|
11
|
+
* static prerender to a quiescent shell, aborts to freeze the prelude + postponed
|
|
12
|
+
* state, and stores the pair via putShell. Because it uses match() rather than a
|
|
13
|
+
* second next(), the middleware chain (auth, logging, the single-use next() latch)
|
|
14
|
+
* never re-runs — and the capture inherits the foreground's post-middleware
|
|
15
|
+
* context state (variables, cache store) it delegates to.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import React from "react";
|
|
19
|
+
import { bufferToBase64 } from "../cache/cf/cf-base64.js";
|
|
20
|
+
import { reportCacheError } from "../cache/cache-error.js";
|
|
21
|
+
import { runBackground } from "../cache/background-task.js";
|
|
22
|
+
import { observePhase, PHASES } from "../router/instrument.js";
|
|
23
|
+
import {
|
|
24
|
+
runWithRequestContext,
|
|
25
|
+
setRequestContextParams,
|
|
26
|
+
type RequestContext,
|
|
27
|
+
} from "../server/request-context.js";
|
|
28
|
+
import { createHandleStore, type HandleStore } from "../server/handle-store.js";
|
|
29
|
+
import type { ShellCacheEntry } from "../cache/types.js";
|
|
30
|
+
import type { HandlerContext } from "./handler-context.js";
|
|
31
|
+
import type { RscPayload, SSRModule } from "./types.js";
|
|
32
|
+
import { buildFullPayload } from "./full-payload.js";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Task-quantized quiesce: the number of consecutive macrotask hops with zero new
|
|
36
|
+
* Flight bytes that marks the shell "quiet". This replaces the old 50ms
|
|
37
|
+
* wall-clock debounce.
|
|
38
|
+
*
|
|
39
|
+
* The capture Flight render is a REGULAR renderToReadableStream (not a static
|
|
40
|
+
* prerender), so React schedules both its retries and its byte-flush on
|
|
41
|
+
* setTimeout(0) MACROTASKS (verified against the vendored edge production
|
|
42
|
+
* react-server-dom build: pingTask uses scheduleMicrotask only when
|
|
43
|
+
* request.type === PRERENDER, otherwise setTimeout; enqueueFlush is always
|
|
44
|
+
* setTimeout). Masked loaders are the live lane — their rows never emit — so once
|
|
45
|
+
* the shell rows finish flushing the stream goes permanently byte-silent, and K
|
|
46
|
+
* consecutive quiet macrotask hops after the last observed byte declare quiesce.
|
|
47
|
+
*
|
|
48
|
+
* K=2 gives a race window of ~two event-loop turns: shell work still producing
|
|
49
|
+
* bytes keeps resetting the counter; anything not producing bytes within the
|
|
50
|
+
* window (the masked loaders, and any genuinely pending I/O) becomes a hole. The
|
|
51
|
+
* only residual is raw per-request I/O rendered directly in shell (not via a
|
|
52
|
+
* loader) that resolves inside the window — a documented shell anti-pattern; put
|
|
53
|
+
* per-request data in loaders or behind live(). See docs/design/ppr-shell-resume.md.
|
|
54
|
+
*/
|
|
55
|
+
const FLIGHT_QUIET_HOPS = 2;
|
|
56
|
+
|
|
57
|
+
/** Default upper bound on the capture prerender wait before forcing the abort. */
|
|
58
|
+
const SHELL_CAPTURE_MAX_WAIT_MS = 5000;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Module-level in-flight key set: the stampede guard for background captures, and
|
|
62
|
+
* its single owner. One capture runs per key per isolate; concurrent MISS/stale
|
|
63
|
+
* requests for the same key coalesce onto the first (the rest see the key present
|
|
64
|
+
* in scheduleShellCapture and skip). Added when a capture is scheduled and cleared
|
|
65
|
+
* in the task's finally once it settles, so a later request can recapture when TTL
|
|
66
|
+
* rolls. Living here (not split across the middleware) keeps the add/clear
|
|
67
|
+
* lifecycle in one layer.
|
|
68
|
+
*/
|
|
69
|
+
const inFlightCaptures = new Set<string>();
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Keys already warned about a refused (null) capture, so the eternal-MISS shape
|
|
73
|
+
* logs once per key per isolate instead of on every request.
|
|
74
|
+
*/
|
|
75
|
+
const warnedNullCaptures = new Set<string>();
|
|
76
|
+
|
|
77
|
+
function warnNullCaptureOnce(key: string): void {
|
|
78
|
+
if (warnedNullCaptures.has(key)) return;
|
|
79
|
+
warnedNullCaptures.add(key);
|
|
80
|
+
console.warn(
|
|
81
|
+
`[rango] Shell capture for "${key}" produced no usable shell (empty or ` +
|
|
82
|
+
"not-ready prelude); nothing was stored, so this request stays on MISS. A later " +
|
|
83
|
+
"request re-captures - if the route NEVER flips to HIT, the most common cause is " +
|
|
84
|
+
"a loader route without a route-level loading() boundary: its loader data is " +
|
|
85
|
+
"awaited at tree-build, so under capture's masked loaders no shell exists above " +
|
|
86
|
+
"<body>. Add loading() to the loader route (and keep shell material in a layout) " +
|
|
87
|
+
"to make it PPR-capturable. See docs/design/ppr-shell-resume.md.",
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface FlightCaptureGate {
|
|
92
|
+
/** Identity passthrough of the source stream; feed this to captureShellHTML. */
|
|
93
|
+
stream: ReadableStream<Uint8Array>;
|
|
94
|
+
/**
|
|
95
|
+
* Resolves once the source has been byte-quiet for FLIGHT_QUIET_HOPS macrotask
|
|
96
|
+
* hops (or has closed — the DATA variant). At that instant the gate FREEZES:
|
|
97
|
+
* no further source byte reaches the fizz side, and the readable is left open
|
|
98
|
+
* (never closed / errored) so fizz postpones the still-pending references
|
|
99
|
+
* instead of seeing "Connection closed".
|
|
100
|
+
*/
|
|
101
|
+
quiesce: Promise<void>;
|
|
102
|
+
/**
|
|
103
|
+
* Stop the internal macrotask-hop loop. captureShellHTML's maxWaitMs bounds the
|
|
104
|
+
* overall wait; dispose() is the clean shutdown for the pathological case where
|
|
105
|
+
* the source never goes byte-quiet (quiesce never fires), so the hop loop would
|
|
106
|
+
* otherwise keep rescheduling after captureShellHTML has already aborted and
|
|
107
|
+
* returned.
|
|
108
|
+
*/
|
|
109
|
+
dispose(): void;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Wrap the capture Flight stream so the fizz shell prerender reads a stream that
|
|
114
|
+
* (a) forwards the shell rows unchanged, (b) resolves `quiesce` after the rows go
|
|
115
|
+
* byte-silent for FLIGHT_QUIET_HOPS macrotask hops, and (c) FREEZES at that
|
|
116
|
+
* instant — dropping any later byte without closing or erroring the readable, so
|
|
117
|
+
* the pending masked-loader references stay pending and fizz postpones them (the
|
|
118
|
+
* "unclosing stream" property, here for free because the masked rows never emit).
|
|
119
|
+
* Freezing also guarantees no post-quiesce byte — including an error row from any
|
|
120
|
+
* later abort/cancel of the underlying render — can corrupt the frozen prelude.
|
|
121
|
+
*
|
|
122
|
+
* Quiet is measured in TASKS, not wall-clock: after the first byte a macrotask
|
|
123
|
+
* hop loop compares a byte counter each turn and fires after K quiet turns. The
|
|
124
|
+
* hop timers are unref'd so they never keep a Node process alive, and the source
|
|
125
|
+
* closing (no holes) fires quiesce immediately for the DATA variant — the
|
|
126
|
+
* TransformStream then closes the readable, so fizz completes with postponed null.
|
|
127
|
+
*/
|
|
128
|
+
export function gateFlightForCapture(
|
|
129
|
+
source: ReadableStream<Uint8Array>,
|
|
130
|
+
quietHops: number = FLIGHT_QUIET_HOPS,
|
|
131
|
+
): FlightCaptureGate {
|
|
132
|
+
let resolveQuiet!: () => void;
|
|
133
|
+
const quiesce = new Promise<void>((resolve) => {
|
|
134
|
+
resolveQuiet = resolve;
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
let bytesSeen = 0;
|
|
138
|
+
let armed = false;
|
|
139
|
+
let settled = false;
|
|
140
|
+
let disposed = false;
|
|
141
|
+
let frozen = false;
|
|
142
|
+
|
|
143
|
+
const fire = (): void => {
|
|
144
|
+
if (settled) return;
|
|
145
|
+
settled = true;
|
|
146
|
+
frozen = true;
|
|
147
|
+
resolveQuiet();
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const scheduleHop = (fn: () => void): void => {
|
|
151
|
+
const t = setTimeout(fn, 0);
|
|
152
|
+
// Never let the quiet-detection hop alone keep a Node process alive
|
|
153
|
+
// (no-op on workerd).
|
|
154
|
+
(t as { unref?: () => void }).unref?.();
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// The hop loop starts only after the first byte, so it can never declare
|
|
158
|
+
// quiesce before fizz has begun pulling rows through the transform.
|
|
159
|
+
const arm = (): void => {
|
|
160
|
+
if (armed || settled || disposed) return;
|
|
161
|
+
armed = true;
|
|
162
|
+
let lastSeen = bytesSeen;
|
|
163
|
+
let quiet = 0;
|
|
164
|
+
const hop = (): void => {
|
|
165
|
+
if (settled || disposed) return;
|
|
166
|
+
if (bytesSeen === lastSeen) {
|
|
167
|
+
quiet += 1;
|
|
168
|
+
if (quiet >= quietHops) {
|
|
169
|
+
fire();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
} else {
|
|
173
|
+
lastSeen = bytesSeen;
|
|
174
|
+
quiet = 0;
|
|
175
|
+
}
|
|
176
|
+
scheduleHop(hop);
|
|
177
|
+
};
|
|
178
|
+
scheduleHop(hop);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const monitor = new TransformStream<Uint8Array, Uint8Array>({
|
|
182
|
+
transform(chunk, controller) {
|
|
183
|
+
// Post-quiesce: drop the byte. Do NOT enqueue and do NOT close/error — the
|
|
184
|
+
// frozen fizz input must stay a fixed byte set behind an open (unclosing)
|
|
185
|
+
// readable so still-pending references postpone.
|
|
186
|
+
if (frozen) return;
|
|
187
|
+
bytesSeen += chunk.length;
|
|
188
|
+
arm();
|
|
189
|
+
controller.enqueue(chunk);
|
|
190
|
+
},
|
|
191
|
+
flush() {
|
|
192
|
+
// Source closed with no freeze => DATA variant (no holes): quiet
|
|
193
|
+
// immediately. The TransformStream then closes the readable, so fizz
|
|
194
|
+
// completes and postponed comes back null.
|
|
195
|
+
fire();
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
stream: source.pipeThrough(monitor),
|
|
201
|
+
quiesce,
|
|
202
|
+
dispose(): void {
|
|
203
|
+
disposed = true;
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Schedule the background shell capture for a served document. Stampede-guarded:
|
|
210
|
+
* one capture per key per isolate. Runs via runBackground (waitUntil on workerd,
|
|
211
|
+
* fire-and-forget in Node dev), so the served response is never blocked on it. Any
|
|
212
|
+
* error is routed through reportCacheError — capture is best-effort; a failure just
|
|
213
|
+
* means the next request recaptures.
|
|
214
|
+
*
|
|
215
|
+
* Eligibility (nonce/allReady/partial/status/strategy) is decided by the caller
|
|
216
|
+
* (rsc-rendering.ts maybeScheduleShellCapture); this function only owns the
|
|
217
|
+
* stampede guard and the background dispatch.
|
|
218
|
+
*/
|
|
219
|
+
export function scheduleShellCapture(
|
|
220
|
+
ctx: HandlerContext<any>,
|
|
221
|
+
request: Request,
|
|
222
|
+
env: any,
|
|
223
|
+
url: URL,
|
|
224
|
+
reqCtx: RequestContext<any>,
|
|
225
|
+
ssrModule: SSRModule,
|
|
226
|
+
descriptor: NonNullable<RequestContext["_shellCapture"]>,
|
|
227
|
+
): void {
|
|
228
|
+
const key = descriptor.key;
|
|
229
|
+
if (inFlightCaptures.has(key)) return;
|
|
230
|
+
inFlightCaptures.add(key);
|
|
231
|
+
runBackground(reqCtx, async () => {
|
|
232
|
+
try {
|
|
233
|
+
await runShellCapture(
|
|
234
|
+
ctx,
|
|
235
|
+
request,
|
|
236
|
+
env,
|
|
237
|
+
url,
|
|
238
|
+
reqCtx,
|
|
239
|
+
ssrModule,
|
|
240
|
+
descriptor,
|
|
241
|
+
);
|
|
242
|
+
} catch (error) {
|
|
243
|
+
// Detached background task — pass reqCtx so onError still fires when the ALS
|
|
244
|
+
// context is gone. Best-effort: a failure just means the next request
|
|
245
|
+
// recaptures.
|
|
246
|
+
reportCacheError(error, "cache-write", "[ShellCache] capture", reqCtx);
|
|
247
|
+
} finally {
|
|
248
|
+
inFlightCaptures.delete(key);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Run the shell capture in a DERIVED request context, then store the result.
|
|
255
|
+
*
|
|
256
|
+
* The derived context is `Object.create(reqCtx)` so it inherits the foreground's
|
|
257
|
+
* post-middleware state (variables, cache store, env/request/url, waitUntil) while
|
|
258
|
+
* overriding the render-scoped accumulators as own properties:
|
|
259
|
+
* - _handleStore: a fresh store. The foreground store is already drained to
|
|
260
|
+
* completion (its stream() flipped `completed` on settle) and would throw
|
|
261
|
+
* LateHandlePushError on any re-push. Every downstream reader resolves the
|
|
262
|
+
* store off the ambient context (setupLoaderAccess captures
|
|
263
|
+
* _getRequestContext()._handleStore; trackHandler reads it), so the fresh
|
|
264
|
+
* store on the derived context is what the capture match() writes handles to.
|
|
265
|
+
* - _requestTags: a fresh Set. The capture collects its OWN shell tags here —
|
|
266
|
+
* non-loader tags only, since loaders are masked — which is exactly the tag
|
|
267
|
+
* set a shell entry should be invalidatable by (loader tags belong to holes).
|
|
268
|
+
* - _transitionWhen: a fresh [] so the capture's transition gating is its own.
|
|
269
|
+
* - _shellCaptureRun: true — the switch loaders/cookies/headers guards read.
|
|
270
|
+
* - _shellCapture: the descriptor (informational; putShell target/ttl/swr).
|
|
271
|
+
* - _metricsStore: undefined so the capture never appends to the foreground's
|
|
272
|
+
* (already-finalized) metrics.
|
|
273
|
+
*/
|
|
274
|
+
async function runShellCapture(
|
|
275
|
+
ctx: HandlerContext<any>,
|
|
276
|
+
request: Request,
|
|
277
|
+
env: any,
|
|
278
|
+
url: URL,
|
|
279
|
+
reqCtx: RequestContext<any>,
|
|
280
|
+
ssrModule: SSRModule,
|
|
281
|
+
descriptor: NonNullable<RequestContext["_shellCapture"]>,
|
|
282
|
+
): Promise<void> {
|
|
283
|
+
const freshHandleStore = createHandleStore();
|
|
284
|
+
freshHandleStore.onError = reqCtx._handleStore.onError;
|
|
285
|
+
|
|
286
|
+
const derivedCtx: RequestContext = Object.create(reqCtx);
|
|
287
|
+
derivedCtx._handleStore = freshHandleStore;
|
|
288
|
+
derivedCtx._requestTags = new Set<string>();
|
|
289
|
+
derivedCtx._transitionWhen = [];
|
|
290
|
+
derivedCtx._shellCaptureRun = true;
|
|
291
|
+
derivedCtx._shellCapture = descriptor;
|
|
292
|
+
derivedCtx._metricsStore = undefined;
|
|
293
|
+
|
|
294
|
+
await runWithRequestContext(derivedCtx, async () => {
|
|
295
|
+
const match = await ctx.router.match(request, { env });
|
|
296
|
+
// A route that redirects has no shell to capture — bail (no store write).
|
|
297
|
+
if (match.redirect) return;
|
|
298
|
+
setRequestContextParams(match.params, match.routeName);
|
|
299
|
+
|
|
300
|
+
const payload = buildFullPayload(
|
|
301
|
+
match,
|
|
302
|
+
ctx,
|
|
303
|
+
url,
|
|
304
|
+
derivedCtx,
|
|
305
|
+
freshHandleStore,
|
|
306
|
+
);
|
|
307
|
+
const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
|
|
308
|
+
onError: (error: unknown) => {
|
|
309
|
+
ctx.callOnError(error, "rendering", { request, url, env });
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// Shell tags = the non-loader request tags the capture render recorded on its
|
|
314
|
+
// own fresh _requestTags. Loaders are masked, so loader cache tags (which
|
|
315
|
+
// belong to the holes, not the shell) are correctly excluded.
|
|
316
|
+
const tags =
|
|
317
|
+
derivedCtx._requestTags.size > 0
|
|
318
|
+
? [...derivedCtx._requestTags]
|
|
319
|
+
: undefined;
|
|
320
|
+
|
|
321
|
+
await captureAndStoreShell(
|
|
322
|
+
ssrModule,
|
|
323
|
+
rscStream,
|
|
324
|
+
freshHandleStore,
|
|
325
|
+
derivedCtx,
|
|
326
|
+
{
|
|
327
|
+
...descriptor,
|
|
328
|
+
tags,
|
|
329
|
+
},
|
|
330
|
+
);
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Seal handles, derive the quiesce signal, prerender + abort via the SSR module's
|
|
336
|
+
* captureShellHTML, and store the result. Never throws out of the store write: a
|
|
337
|
+
* failed putShell is routed through reportCacheError so the background task stays
|
|
338
|
+
* best-effort. `ssrModule.captureShellHTML` MUST be present (eligibility is
|
|
339
|
+
* checked before scheduling).
|
|
340
|
+
*/
|
|
341
|
+
async function captureAndStoreShell(
|
|
342
|
+
ssrModule: SSRModule,
|
|
343
|
+
rscStream: ReadableStream<Uint8Array>,
|
|
344
|
+
handleStore: HandleStore,
|
|
345
|
+
reqCtx: RequestContext<any>,
|
|
346
|
+
capture: NonNullable<RequestContext["_shellCapture"]>,
|
|
347
|
+
): Promise<void> {
|
|
348
|
+
const captureShellHTML = ssrModule.captureShellHTML!;
|
|
349
|
+
|
|
350
|
+
// Seal the handle store so the payload's handles generator (resolvedHandleStream
|
|
351
|
+
// -> handleStore.stream()) converges and completes even though masked loaders
|
|
352
|
+
// never resolve. handleStore.settled gates ONLY on tracked HANDLER promises
|
|
353
|
+
// (handleStore.track, via trackHandler) — NOT on deferred handle VALUES pushed
|
|
354
|
+
// through ctx.use(Handle).defer(), which are plain pushed promises. So seal()
|
|
355
|
+
// does not reject or hang on outstanding defers: settled resolves once the
|
|
356
|
+
// handlers settle, and each deferred slot resolves on its own createDeferred
|
|
357
|
+
// timeout (defer.ts, default 10s) or when its resolver fires. A defer whose
|
|
358
|
+
// resolver depends on a masked loader can never fire, so it stays pending until
|
|
359
|
+
// that 10s timeout — longer than maxWaitMs (5s). At the abort the handles
|
|
360
|
+
// generator has not yielded, SsrRoot suspends at the root (consumeAsyncGenerator
|
|
361
|
+
// sits above every boundary), the prelude comes back trivial, and
|
|
362
|
+
// captureShellHTML's sanity gate returns null: the designed fail-safe no-op, not
|
|
363
|
+
// an error. This mirrors the __prerender_collect seal+settled regime, which also
|
|
364
|
+
// excludes loaders. See docs/design/ppr-shell-resume.md ("Loaders and handles").
|
|
365
|
+
handleStore.seal();
|
|
366
|
+
|
|
367
|
+
const gate = gateFlightForCapture(rscStream);
|
|
368
|
+
// Quiesce = handles settled AND the Flight shell rows went task-quiet. Either
|
|
369
|
+
// half stalling is bounded by captureShellHTML's maxWaitMs.
|
|
370
|
+
const quiesce = Promise.all([handleStore.settled, gate.quiesce]).then(
|
|
371
|
+
() => {},
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
try {
|
|
375
|
+
// captureShellHTML CONSUMES the (gated) stream — it is not also SSR'd.
|
|
376
|
+
const result = await observePhase(PHASES.ssr, () =>
|
|
377
|
+
captureShellHTML(gate.stream, {
|
|
378
|
+
quiesce,
|
|
379
|
+
maxWaitMs: SHELL_CAPTURE_MAX_WAIT_MS,
|
|
380
|
+
}),
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
// null = sanity gate refused (trivial/empty prelude, no <body>). Store
|
|
384
|
+
// nothing; the route stays on axis 1 and every future request re-captures to
|
|
385
|
+
// the same refusal, so surface it once per key: the dominant cause is a
|
|
386
|
+
// route shape with no capturable shell — a loader route WITHOUT a route-level
|
|
387
|
+
// loading() boundary awaits its loader data at tree-build (renderSegments'
|
|
388
|
+
// loading-less branch), so the masked loader pins the whole tree above
|
|
389
|
+
// <body>. Silent refusal made that shape an undiagnosable eternal MISS.
|
|
390
|
+
if (result === null) {
|
|
391
|
+
warnNullCaptureOnce(capture.key);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Store per the flag's key/ttl/swr/tags, into the flag's store: the middleware
|
|
396
|
+
// threads the SAME store it resolved for its getShell read (options.store ??
|
|
397
|
+
// _cacheStore), so a store-attached middleware writes captures where it reads
|
|
398
|
+
// them. The _cacheStore fallback covers a flag armed without a store (tests).
|
|
399
|
+
// reactVersion is read from the same React.version import the middleware
|
|
400
|
+
// validates reads against, so capture and serve always agree.
|
|
401
|
+
const store = capture.store ?? reqCtx._cacheStore;
|
|
402
|
+
if (store?.putShell) {
|
|
403
|
+
try {
|
|
404
|
+
const entry: ShellCacheEntry = {
|
|
405
|
+
// slice() copies just this view's bytes into a fresh ArrayBuffer, so a
|
|
406
|
+
// prelude that is a subarray of a larger backing buffer encodes only its
|
|
407
|
+
// own region — bufferToBase64 reads the whole ArrayBuffer it is handed.
|
|
408
|
+
prelude: bufferToBase64(result.prelude.slice().buffer as ArrayBuffer),
|
|
409
|
+
postponed: result.postponed,
|
|
410
|
+
reactVersion: React.version,
|
|
411
|
+
createdAt: Date.now(),
|
|
412
|
+
};
|
|
413
|
+
await store.putShell(
|
|
414
|
+
capture.key,
|
|
415
|
+
entry,
|
|
416
|
+
capture.ttl,
|
|
417
|
+
capture.swr,
|
|
418
|
+
capture.tags,
|
|
419
|
+
);
|
|
420
|
+
} catch (error) {
|
|
421
|
+
// Best-effort: a failed put must never throw out of the background task.
|
|
422
|
+
reportCacheError(
|
|
423
|
+
error,
|
|
424
|
+
"cache-write",
|
|
425
|
+
"[ShellCache] capture put",
|
|
426
|
+
reqCtx,
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
} finally {
|
|
431
|
+
// Stop the hop loop for the pathological never-quiets path (quiesce never
|
|
432
|
+
// fired, capture returned via maxWaitMs). On the normal path the loop already
|
|
433
|
+
// stopped when it fired quiesce; dispose() is then a no-op.
|
|
434
|
+
gate.dispose();
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Exported for unit tests that drive the capture core directly.
|
|
439
|
+
export { runShellCapture, captureAndStoreShell };
|
package/src/rsc/types.ts
CHANGED
|
@@ -164,6 +164,32 @@ export interface SSRModule {
|
|
|
164
164
|
rscStream: ReadableStream<Uint8Array>,
|
|
165
165
|
options?: SSRRenderOptions,
|
|
166
166
|
) => Promise<ReadableStream<Uint8Array>>;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* PPR shell CAPTURE strategy (Axis 2). Prerenders the loader-masked shell over
|
|
170
|
+
* the Flight stream, aborts once quiescent, and returns the prelude bytes plus
|
|
171
|
+
* the postponed resume state — or null when the prelude degraded and must not
|
|
172
|
+
* be stored. Present only when the SSR virtual entry wires
|
|
173
|
+
* createShellCaptureHandler; the render layer feature-detects it. See
|
|
174
|
+
* docs/design/ppr-shell-resume.md.
|
|
175
|
+
*/
|
|
176
|
+
captureShellHTML?: (
|
|
177
|
+
rscStream: ReadableStream<Uint8Array>,
|
|
178
|
+
options: { quiesce: Promise<void>; maxWaitMs?: number },
|
|
179
|
+
) => Promise<{ prelude: Uint8Array; postponed: string | null } | null>;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* PPR shell RESUME strategy (Axis 2). Produces the per-request live portion of
|
|
183
|
+
* the document: resumes fizz over a fresh SsrRoot to emit only the postponed
|
|
184
|
+
* holes (or, for the DATA variant with postponed === null, just the fresh Flight
|
|
185
|
+
* payload scripts). The caller prepends the stored prelude bytes to form the
|
|
186
|
+
* composite response. Present only when the SSR virtual entry wires
|
|
187
|
+
* createShellResumeHandler; the render layer feature-detects it.
|
|
188
|
+
*/
|
|
189
|
+
resumeShellHTML?: (
|
|
190
|
+
rscStream: ReadableStream<Uint8Array>,
|
|
191
|
+
options: { postponed: string | null; nonce?: string },
|
|
192
|
+
) => Promise<ReadableStream<Uint8Array>>;
|
|
167
193
|
}
|
|
168
194
|
|
|
169
195
|
/**
|
|
@@ -62,6 +62,7 @@ export interface CookieStore {
|
|
|
62
62
|
export function cookies(): CookieStore {
|
|
63
63
|
const ctx = getRequestContext();
|
|
64
64
|
assertNotInsideCacheContext(ctx, "cookies");
|
|
65
|
+
assertNotInsideShellCapture(ctx, "cookies");
|
|
65
66
|
return createCookieStore(ctx);
|
|
66
67
|
}
|
|
67
68
|
|
|
@@ -132,6 +133,49 @@ function assertNotInsideCacheContext(ctx: unknown, fnName: string): void {
|
|
|
132
133
|
}
|
|
133
134
|
}
|
|
134
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Throw if called during the ACTIVE background shell-capture render
|
|
138
|
+
* (`_shellCaptureRun` true on the derived request context built by
|
|
139
|
+
* shell-capture.ts). The captured shell prelude is shared across every user
|
|
140
|
+
* hitting the URL, so a request-scoped read here would bake one user's
|
|
141
|
+
* cookies/headers into markup served to others — same hazard as the cache
|
|
142
|
+
* scopes above, at the document tier. Loaders need no exemption: they are
|
|
143
|
+
* masked (never executed) during capture and remain the per-request holes of
|
|
144
|
+
* the shell.
|
|
145
|
+
*
|
|
146
|
+
* Keys off `_shellCaptureRun`, NOT the `_shellCapture` descriptor: the descriptor
|
|
147
|
+
* is also present during the FOREGROUND render (it means "a capture is wanted"),
|
|
148
|
+
* and the foreground must read cookies/headers normally to serve the real user.
|
|
149
|
+
* Only the derived capture context sets `_shellCaptureRun`.
|
|
150
|
+
*
|
|
151
|
+
* Applies only to the READ surfaces (cookies(), headers()) whose values
|
|
152
|
+
* become markup. Response directives (invalidateClientCache(),
|
|
153
|
+
* keepClientCache()) stay callable: during capture they are header effects on
|
|
154
|
+
* a discarded response, and on the live HIT path the full pipeline runs so their
|
|
155
|
+
* headers flow to the client normally.
|
|
156
|
+
*
|
|
157
|
+
* The throw makes such a route PPR-ineligible by construction: the capture
|
|
158
|
+
* render errors, nothing is stored, and every request keeps getting the
|
|
159
|
+
* normal axis-1 render.
|
|
160
|
+
*/
|
|
161
|
+
function assertNotInsideShellCapture(ctx: unknown, fnName: string): void {
|
|
162
|
+
if (
|
|
163
|
+
ctx !== null &&
|
|
164
|
+
typeof ctx === "object" &&
|
|
165
|
+
(ctx as { _shellCaptureRun?: unknown })._shellCaptureRun === true
|
|
166
|
+
) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`${fnName}() cannot be called while capturing a shared shell ` +
|
|
169
|
+
`(shell-cache middleware). The captured shell is served to every user ` +
|
|
170
|
+
`of this URL, so request-scoped data read here would leak one user's ` +
|
|
171
|
+
`${fnName === "cookies" ? "cookies" : "headers"} to others. Read it ` +
|
|
172
|
+
`inside a loader instead — loaders are never captured and always run ` +
|
|
173
|
+
`fresh per request:\n\n` +
|
|
174
|
+
` loader("user", () => getUser(cookies().get("session")?.value));`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
135
179
|
const HEADERS_MUTATION_METHODS = new Set(["set", "append", "delete"]);
|
|
136
180
|
|
|
137
181
|
/**
|
|
@@ -152,6 +196,7 @@ const HEADERS_MUTATION_METHODS = new Set(["set", "append", "delete"]);
|
|
|
152
196
|
export function headers(): ReadonlyHeaders {
|
|
153
197
|
const ctx = getRequestContext();
|
|
154
198
|
assertNotInsideCacheContext(ctx, "headers");
|
|
199
|
+
assertNotInsideShellCapture(ctx, "headers");
|
|
155
200
|
return new Proxy(ctx.request.headers, {
|
|
156
201
|
get(target, prop, receiver) {
|
|
157
202
|
if (typeof prop === "string" && HEADERS_MUTATION_METHODS.has(prop)) {
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* live() — the deterministic PPR hole primitive (docs/design/ppr-shell-resume.md).
|
|
3
|
+
*
|
|
4
|
+
* A PPR shell is captured by masking loaders and freezing everything that
|
|
5
|
+
* settles synchronously or on a microtask into the shared prelude. That freeze
|
|
6
|
+
* has a sharp edge: a value that is ALREADY resolved — `Promise.resolve(x)`, an
|
|
7
|
+
* in-memory lookup, a cached read — settles during the capture's quiet window
|
|
8
|
+
* and gets baked into the shell, served to every user of the URL. That is
|
|
9
|
+
* usually what you want (deterministic content belongs in the shell), but not
|
|
10
|
+
* when the value is per-request. `live()` is the escape hatch: it makes its
|
|
11
|
+
* boundary a deterministic HOLE regardless of how fast the data resolves, so the
|
|
12
|
+
* capture postpones there and the resumed serve pass streams the fresh value in.
|
|
13
|
+
*
|
|
14
|
+
* It is the userland analogue of the loader mask (loader-mask.ts): during the
|
|
15
|
+
* background shell-capture render `live()` returns a never-settling promise so
|
|
16
|
+
* the consuming Suspense subtree suspends and React's static prerender postpones
|
|
17
|
+
* it. Outside capture — the ordinary serve pass, and the client — it is a
|
|
18
|
+
* passthrough: the thunk runs, or the promise passes through unchanged.
|
|
19
|
+
*
|
|
20
|
+
* Two forms:
|
|
21
|
+
*
|
|
22
|
+
* // Thunk (preferred): during capture the fn NEVER runs — no fetch, no cost.
|
|
23
|
+
* const price = await live(() => fetchPrice());
|
|
24
|
+
*
|
|
25
|
+
* // Value: the work already fired before live() saw it, so during capture the
|
|
26
|
+
* // real promise is discarded and a hole is returned in its place. Use the
|
|
27
|
+
* // thunk form unless you already hold the promise.
|
|
28
|
+
* const price = await live(pricePromise);
|
|
29
|
+
*
|
|
30
|
+
* The consumer story in one line: a hole even when the data is already resolved —
|
|
31
|
+
* const x = await live(() => Promise.resolve(value)); // postpones under capture
|
|
32
|
+
*
|
|
33
|
+
* @see docs/design/ppr-shell-resume.md ("The live() hole primitive")
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { _getRequestContext } from "./request-context.js";
|
|
37
|
+
import { isInsideCacheScope } from "./context.js";
|
|
38
|
+
import { INSIDE_CACHE_EXEC } from "../cache/taint.js";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A promise that never settles — the capture-time hole. Same mechanism and
|
|
42
|
+
* lifecycle as the loader mask (loader-mask.ts createMaskedLoaderPromise): the
|
|
43
|
+
* consuming Suspense subtree suspends forever, so the static prerender postpones
|
|
44
|
+
* it as a hole instead of baking a per-request value into the shared shell.
|
|
45
|
+
* Nothing awaits it to settle — the capture aborts fizz to freeze the prelude
|
|
46
|
+
* (maxWaitMs in captureShellHTML bounds that), and workerd/GC reclaims the
|
|
47
|
+
* pending promise when the capture render tree is dropped. Kept never-settling
|
|
48
|
+
* (not reject-on-abort) deliberately, to stay identical to the loader mask: a
|
|
49
|
+
* capture-scoped reject signal would buy no capture-behavior difference, since
|
|
50
|
+
* the abort — not the hole promise — is what ends the render.
|
|
51
|
+
*/
|
|
52
|
+
function captureHole<T>(): Promise<T> {
|
|
53
|
+
return new Promise<T>(() => {});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** True only inside the background shell-capture render (shell-capture.ts sets
|
|
57
|
+
* `_shellCaptureRun` on its derived context). Non-throwing: outside any request
|
|
58
|
+
* context this is simply false, so live() passes through. */
|
|
59
|
+
function isShellCaptureActive(): boolean {
|
|
60
|
+
return _getRequestContext()?._shellCaptureRun === true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Mark a Suspense boundary as a deterministic PPR hole (see the module doc).
|
|
65
|
+
*
|
|
66
|
+
* @param fn - Thunk producing the live value. During shell capture it is NOT
|
|
67
|
+
* invoked (no side effects, no cost); a never-settling promise is returned so
|
|
68
|
+
* the boundary postpones. Outside capture it runs and its result is returned
|
|
69
|
+
* as a promise.
|
|
70
|
+
*/
|
|
71
|
+
export function live<T>(fn: () => Promise<T> | T): Promise<T>;
|
|
72
|
+
/**
|
|
73
|
+
* @param promise - A promise whose work has already fired. During shell capture
|
|
74
|
+
* the promise is discarded and a hole is returned in its place (the work still
|
|
75
|
+
* ran — prefer the thunk form to avoid that). Outside capture the promise
|
|
76
|
+
* passes through unchanged.
|
|
77
|
+
*/
|
|
78
|
+
export function live<T>(promise: Promise<T>): Promise<T>;
|
|
79
|
+
export function live<T>(
|
|
80
|
+
input: (() => Promise<T> | T) | Promise<T>,
|
|
81
|
+
): Promise<T> {
|
|
82
|
+
assertNotInsideCacheBoundary();
|
|
83
|
+
if (isShellCaptureActive()) {
|
|
84
|
+
return captureHole<T>();
|
|
85
|
+
}
|
|
86
|
+
return typeof input === "function"
|
|
87
|
+
? Promise.resolve((input as () => Promise<T> | T)())
|
|
88
|
+
: input;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Throw when live() is called inside a cache boundary — a "use cache" function
|
|
93
|
+
* (INSIDE_CACHE_EXEC stamped on the request context) or a cache() DSL scope.
|
|
94
|
+
*
|
|
95
|
+
* live() only masks during the SHELL capture (ring 4). The inner cache rings
|
|
96
|
+
* freeze first: a cache()/prerender write deep-settles the promise and stores
|
|
97
|
+
* its VALUE in the segment cache, and the handler never re-runs on replay — so
|
|
98
|
+
* a live() there is silently inert, and if the value is per-request it is the
|
|
99
|
+
* same shared-cache leak cookies()/headers() guard against, defeated by the
|
|
100
|
+
* very primitive the caller believed made it safe. A "use cache" miss during a
|
|
101
|
+
* capture render is worse: the fn body runs under the capture flag, live()
|
|
102
|
+
* returns a never-settling promise, and the cache write wedges awaiting it.
|
|
103
|
+
* Same guard shape as assertNotInsideCacheContext in cookie-store.ts.
|
|
104
|
+
*/
|
|
105
|
+
function assertNotInsideCacheBoundary(): void {
|
|
106
|
+
const ctx = _getRequestContext();
|
|
107
|
+
if (
|
|
108
|
+
ctx !== null &&
|
|
109
|
+
ctx !== undefined &&
|
|
110
|
+
(INSIDE_CACHE_EXEC as symbol) in (ctx as unknown as Record<symbol, unknown>)
|
|
111
|
+
) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`live() cannot be called inside a "use cache" function. The cached ` +
|
|
114
|
+
`function's value is stored and replayed, so nothing inside it can ` +
|
|
115
|
+
`stay live — and per-request data would be frozen into a shared ` +
|
|
116
|
+
`cache entry. Read live data in a loader instead (loaders are never ` +
|
|
117
|
+
`cached), or move the live() call outside the cached function.`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
if (isInsideCacheScope()) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`live() cannot be called inside a cache() boundary. The segment cache ` +
|
|
123
|
+
`deep-settles and stores the resolved VALUE at write time, and the ` +
|
|
124
|
+
`handler never re-runs on a cache hit — so live() cannot keep this ` +
|
|
125
|
+
`value live, and per-request data would be frozen into the shared ` +
|
|
126
|
+
`cached segments. Use a loader behind loading() instead: loaders are ` +
|
|
127
|
+
`the live lane through every cache ring.`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|