@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.
Files changed (45) hide show
  1. package/dist/bin/rango.js +27 -2
  2. package/dist/vite/index.js +147 -30
  3. package/package.json +1 -1
  4. package/skills/breadcrumbs/SKILL.md +1 -1
  5. package/skills/cache-guide/SKILL.md +1 -0
  6. package/skills/caching/SKILL.md +1 -1
  7. package/skills/migrate-nextjs/SKILL.md +15 -0
  8. package/skills/migrate-react-router/SKILL.md +15 -2
  9. package/skills/ppr/SKILL.md +426 -0
  10. package/skills/rango/SKILL.md +28 -25
  11. package/skills/route/SKILL.md +43 -0
  12. package/src/build/route-trie.ts +35 -7
  13. package/src/cache/cf/cf-cache-store.ts +155 -0
  14. package/src/cache/index.ts +6 -0
  15. package/src/cache/memory-segment-store.ts +57 -1
  16. package/src/cache/shell-cache.ts +386 -0
  17. package/src/cache/types.ts +58 -0
  18. package/src/cache/vercel/vercel-cache-store.ts +159 -5
  19. package/src/index.rsc.ts +5 -0
  20. package/src/index.ts +17 -0
  21. package/src/router/middleware.ts +14 -5
  22. package/src/router/parse-pattern.ts +115 -0
  23. package/src/router/pattern-matching.ts +53 -64
  24. package/src/router/segment-resolution/fresh.ts +12 -1
  25. package/src/router/segment-resolution/loader-cache.ts +14 -0
  26. package/src/router/segment-resolution/loader-mask.ts +44 -0
  27. package/src/router/substitute-pattern-params.ts +54 -35
  28. package/src/router/trie-matching.ts +19 -11
  29. package/src/router/url-params.ts +13 -0
  30. package/src/rsc/full-payload.ts +70 -0
  31. package/src/rsc/rsc-rendering.ts +105 -51
  32. package/src/rsc/shell-capture.ts +439 -0
  33. package/src/rsc/types.ts +26 -0
  34. package/src/server/cookie-store.ts +45 -0
  35. package/src/server/live.ts +130 -0
  36. package/src/server/request-context.ts +49 -0
  37. package/src/ssr/index.tsx +377 -180
  38. package/src/ssr/ssr-root.tsx +228 -0
  39. package/src/testing/render-route.tsx +7 -9
  40. package/src/types/route-config.ts +19 -7
  41. package/src/urls/type-extraction.ts +43 -18
  42. package/src/vite/discovery/discovery-errors.ts +61 -0
  43. package/src/vite/plugins/virtual-entries.ts +27 -2
  44. package/src/vite/router-discovery.ts +69 -15
  45. 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
+ }