@rangojs/router 0.0.0-experimental.145 → 0.0.0-experimental.147

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 (46) hide show
  1. package/dist/bin/rango.js +8 -40
  2. package/dist/vite/index.js +840 -243
  3. package/package.json +6 -1
  4. package/src/browser/event-controller.ts +16 -2
  5. package/src/browser/rsc-router.tsx +11 -0
  6. package/src/cache/cache-scope.ts +41 -3
  7. package/src/cache/cf/cf-cache-store.ts +23 -0
  8. package/src/cache/handle-snapshot.ts +22 -1
  9. package/src/cache/memory-segment-store.ts +32 -0
  10. package/src/cache/segment-codec.ts +47 -0
  11. package/src/cache/shell-snapshot.ts +47 -0
  12. package/src/cache/types.ts +27 -0
  13. package/src/cache/vercel/vercel-cache-store.ts +71 -2
  14. package/src/deps/ssr.ts +4 -1
  15. package/src/prerender/build-shell-capture.ts +237 -0
  16. package/src/prerender/shell-manifest-key.ts +20 -0
  17. package/src/prerender/store.ts +10 -1
  18. package/src/router/loader-resolution.ts +16 -0
  19. package/src/router/match-api.ts +9 -2
  20. package/src/router/match-handlers.ts +13 -0
  21. package/src/router/match-middleware/cache-lookup.ts +12 -1
  22. package/src/router/prerender-match.ts +21 -0
  23. package/src/router/segment-resolution/mask-nested.ts +19 -3
  24. package/src/rsc/capture-queue.ts +67 -0
  25. package/src/rsc/rsc-rendering.ts +136 -25
  26. package/src/rsc/shell-build-manifest.ts +244 -0
  27. package/src/rsc/shell-capture.ts +194 -43
  28. package/src/segment-fragments.ts +124 -0
  29. package/src/segment-system.tsx +49 -19
  30. package/src/server/request-context.ts +112 -11
  31. package/src/ssr/index.tsx +151 -22
  32. package/src/ssr/inject-rsc-eager.ts +2 -2
  33. package/src/ssr/preinit-client-references.ts +106 -0
  34. package/src/ssr/ssr-root.tsx +35 -2
  35. package/src/vite/discovery/discover-routers.ts +27 -0
  36. package/src/vite/discovery/prerender-collection.ts +16 -0
  37. package/src/vite/discovery/shell-prerender-phase.ts +395 -0
  38. package/src/vite/discovery/state.ts +42 -0
  39. package/src/vite/index.ts +1 -0
  40. package/src/vite/plugin-types.ts +33 -0
  41. package/src/vite/plugins/version-plugin.ts +8 -0
  42. package/src/vite/plugins/virtual-entries.ts +37 -4
  43. package/src/vite/rango.ts +11 -2
  44. package/src/vite/router-discovery.ts +292 -8
  45. package/src/vite/utils/prerender-utils.ts +25 -6
  46. package/src/vite/utils/shared-utils.ts +4 -2
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Producer B: build-time PPR shell capture for Prerender+ppr routes (#699).
3
+ *
4
+ * Runs in the RSC realm of the build's temp server, AFTER all bundles are
5
+ * written (the prelude embeds built client asset URLs — bootstrap module,
6
+ * chunk preloads — that only exist post-client-build). The capture core is
7
+ * producer A's, verbatim: deriveShellCaptureContext (mask funnel, liveness,
8
+ * snapshot recording, implicit doc-cache scope) + captureAndStoreShell (gates,
9
+ * quiesce, tags union, putShell barrier). The differences are only the base
10
+ * context (a synthetic build request created via createRequestContext over the
11
+ * build env — no ambient identity, so the identity guard is trivially
12
+ * satisfied) and the sink (an entry collector instead of a runtime store).
13
+ *
14
+ * The capture's match() re-enters withCacheLookup, HITs the in-realm prerender
15
+ * store seeded from the just-collected Flight payloads, and REPLAYS the
16
+ * build-time segments — no handler execution, exactly the runtime composition
17
+ * path (#697). Live-lane loaders mask into holes; bake-lane loaders execute
18
+ * under the build context and refuse the capture if they reject or read
19
+ * identity, the same eligibility rules as at runtime.
20
+ */
21
+
22
+ import type { ShellCacheEntry } from "../cache/types.js";
23
+ import { MemorySegmentCacheStore } from "../cache/memory-segment-store.js";
24
+ import {
25
+ createRequestContext,
26
+ runWithRequestContext,
27
+ setRequestContextParams,
28
+ } from "../server/request-context.js";
29
+ import {
30
+ deriveShellCaptureContext,
31
+ captureAndStoreShell,
32
+ delay,
33
+ SHELL_CAPTURE_RETRY_DELAY_MS,
34
+ type ShellCaptureDescriptor,
35
+ } from "../rsc/shell-capture.js";
36
+ import { buildFullPayload } from "../rsc/full-payload.js";
37
+ import type { RscPayload, SSRModule } from "../rsc/types.js";
38
+ import type { HandlerContext } from "../rsc/handler-context.js";
39
+ import { renderToReadableStream } from "../deps/rsc.js";
40
+ import {
41
+ resolvePprConfig,
42
+ type ResolvedPprConfig,
43
+ } from "../rsc/shell-serve.js";
44
+
45
+ /**
46
+ * Normalize a collected truthy `ppr` path option into the SAME concrete
47
+ * policy the runtime serve path derives — through resolvePprConfig itself,
48
+ * over a synthetic route entry — so the build-stamped ttl default can never
49
+ * drift from the serve-side one.
50
+ */
51
+ export function resolveBuildPprConfig(
52
+ ppr: true | { ttl?: number; swr?: number; tags?: string[] },
53
+ ): ResolvedPprConfig {
54
+ const resolved = resolvePprConfig({ type: "route", ppr } as any);
55
+ // resolvePprConfig returns null only for undefined/false ppr; the collector
56
+ // filtered those out. Guard for the type only.
57
+ if (!resolved) throw new Error("[rango] unreachable: ppr option was falsy");
58
+ return resolved;
59
+ }
60
+
61
+ export interface BuildShellCaptureOptions {
62
+ /** The router instance (from RouterRegistry in the same realm). */
63
+ router: any;
64
+ /** Concrete URL path to capture (e.g. "/pp/alpha"). */
65
+ urlPath: string;
66
+ /**
67
+ * The candidate's trie route key. The capture's match() must land on THIS
68
+ * route: the phase sweeps every registered router, and a router that does
69
+ * not own the URL matches something else (its catch-all, a 404 shape) —
70
+ * that capture must not be baked.
71
+ */
72
+ routeName: string;
73
+ /** Shell store key to stamp into the descriptor (host-free at build). */
74
+ key: string;
75
+ ttl?: number;
76
+ swr?: number;
77
+ /** The route's static ppr.tags (the capture unions render-recorded tags). */
78
+ tags?: string[];
79
+ /** Build-time env bindings (rango plugin buildEnv), if configured. */
80
+ buildEnv?: unknown;
81
+ /**
82
+ * The MAIN build's version (the version plugin's value folded into the
83
+ * shipped worker) — NOT the temp server's own version-plugin value. The
84
+ * serve-side isValidShellHit gate compares entry.buildVersion against the
85
+ * running worker's ctx.version; stamping the temp server's would make every
86
+ * build entry an eternal MISS.
87
+ */
88
+ buildVersion: string;
89
+ /**
90
+ * The SSR half, composed by the plugin from the temp server's SSR
91
+ * environment runner (react-dom/static prerender + Flight client), with the
92
+ * bootstrap script content overridden to the BUILT client entry URL.
93
+ */
94
+ captureShellHTML: NonNullable<SSRModule["captureShellHTML"]>;
95
+ /** Verbose per-attempt breadcrumbs (build log). */
96
+ debug?: boolean;
97
+ }
98
+
99
+ export interface BuildShellCaptureResult {
100
+ outcome:
101
+ | "stored"
102
+ | "no-shell"
103
+ | "redirect"
104
+ | "refused"
105
+ /** The router swept does not own this URL — try the next one. */
106
+ | "route-mismatch";
107
+ /** Present iff outcome === "stored". */
108
+ entry?: ShellCacheEntry;
109
+ /** The putShell-barrier tag union (static ppr.tags + render-recorded). */
110
+ tags?: string[];
111
+ /** On route-mismatch: what this router's match actually landed on. */
112
+ matchedRouteName?: string;
113
+ }
114
+
115
+ /**
116
+ * Capture the PPR shell for one prerendered URL at build time. Retries once
117
+ * in place on `no-shell` (the first attempt warms the temp server's SSR/Flight
118
+ * transform graph, mirroring producer A's cold-start retry — same delay).
119
+ */
120
+ export async function captureShellForBuild(
121
+ opts: BuildShellCaptureOptions,
122
+ ): Promise<BuildShellCaptureResult> {
123
+ const first = await attemptBuildCapture(opts);
124
+ if (first.outcome !== "no-shell") return first;
125
+ if (opts.debug) {
126
+ console.log(
127
+ `[rango] shell capture attempt 1/2 for ${opts.urlPath} produced no shell (cold graph?) — retrying`,
128
+ );
129
+ }
130
+ await delay(SHELL_CAPTURE_RETRY_DELAY_MS);
131
+ return attemptBuildCapture(opts);
132
+ }
133
+
134
+ /** One attempt: fresh base context, fresh derivation, fresh render. */
135
+ async function attemptBuildCapture(
136
+ opts: BuildShellCaptureOptions,
137
+ ): Promise<BuildShellCaptureResult> {
138
+ const router = opts.router;
139
+ const url = new URL(opts.urlPath, "http://build.invalid");
140
+ const request = new Request(url, { method: "GET" });
141
+
142
+ // Synthetic build request context: same factory the runtime handler uses,
143
+ // so the capture's ALS surface (cookie machinery, variables, waitUntil,
144
+ // theme resolution) is production-shaped. No cookie header → theme resolves
145
+ // to the app default, exactly like a first anonymous visitor's capture.
146
+ const baseCtx = createRequestContext({
147
+ env: (opts.buildEnv ?? {}) as any,
148
+ request,
149
+ url,
150
+ variables: {},
151
+ // Fresh empty store per attempt: cache()/"use cache" reads MISS, execute,
152
+ // and are recorded into the snapshot by the derivation's RecordingShell
153
+ // wrapper — the entry pins its own generation, nothing preexisting leaks.
154
+ cacheStore: new MemorySegmentCacheStore(),
155
+ themeConfig: router.themeConfig ?? null,
156
+ stateCookieName: router.resolvedStateCookieName,
157
+ version: opts.buildVersion,
158
+ });
159
+
160
+ const { derivedCtx, freshHandleStore } = deriveShellCaptureContext(baseCtx, {
161
+ ttl: opts.ttl,
162
+ swr: opts.swr,
163
+ });
164
+
165
+ // Entry collector: captureAndStoreShell's sink. putShell never fails here,
166
+ // so a "stored" outcome always carries the entry.
167
+ let collected: { entry: ShellCacheEntry; tags?: string[] } | null = null;
168
+ const collector = {
169
+ putShell: async (
170
+ _key: string,
171
+ entry: ShellCacheEntry,
172
+ _ttl?: number,
173
+ _swr?: number,
174
+ tags?: string[],
175
+ ): Promise<void> => {
176
+ collected = { entry, tags };
177
+ },
178
+ };
179
+
180
+ const descriptor: ShellCaptureDescriptor = {
181
+ key: opts.key,
182
+ buildVersion: opts.buildVersion,
183
+ ttl: opts.ttl,
184
+ swr: opts.swr,
185
+ tags: opts.tags,
186
+ store: collector as any,
187
+ debug: opts.debug,
188
+ };
189
+
190
+ let mismatchedRouteName: string | undefined;
191
+ const outcome = await runWithRequestContext(derivedCtx, async () => {
192
+ const match = await router.match(request, { env: opts.buildEnv ?? {} });
193
+ if (match.routeName !== opts.routeName) {
194
+ mismatchedRouteName = match.routeName;
195
+ return "route-mismatch" as const;
196
+ }
197
+ if (match.redirect) return "redirect" as const;
198
+
199
+ setRequestContextParams(match.params, match.routeName);
200
+
201
+ const payload = buildFullPayload(
202
+ match,
203
+ // buildFullPayload reads only ctx.router.* and ctx.version.
204
+ { router, version: opts.buildVersion } as unknown as HandlerContext<any>,
205
+ url,
206
+ derivedCtx,
207
+ freshHandleStore,
208
+ );
209
+ const rscStream = renderToReadableStream<RscPayload>(payload, {
210
+ onError: (error: unknown) => {
211
+ if (opts.debug) {
212
+ console.warn(
213
+ `[rango] shell capture render error for ${opts.urlPath}:`,
214
+ error,
215
+ );
216
+ }
217
+ },
218
+ });
219
+
220
+ return captureAndStoreShell(
221
+ { captureShellHTML: opts.captureShellHTML } as SSRModule,
222
+ rscStream,
223
+ freshHandleStore,
224
+ derivedCtx,
225
+ descriptor,
226
+ );
227
+ });
228
+
229
+ if (outcome === "stored" && collected !== null) {
230
+ const hit: { entry: ShellCacheEntry; tags?: string[] } = collected;
231
+ return { outcome, entry: hit.entry, tags: hit.tags };
232
+ }
233
+ if (outcome === "route-mismatch") {
234
+ return { outcome, matchedRouteName: mismatchedRouteName };
235
+ }
236
+ return { outcome };
237
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Build shell manifest key, shared by the producer (the build's shell
3
+ * prerender phase, vite/discovery/shell-prerender-phase.ts) and the consumer
4
+ * (the runtime read-through, rsc/shell-build-manifest.ts) so the format
5
+ * cannot drift. Dependency-free: the producer runs node-side in the plugin,
6
+ * the consumer in the RSC runtime.
7
+ *
8
+ * PATHNAME-ONLY by design. Host-free because the build knows no request
9
+ * host. Router-free because the router id ($$id) is a hash of
10
+ * filePath:lineNumber of the TRANSFORMED source, which differs between the
11
+ * discovery temp server's dev-style transform chain and the main build's —
12
+ * a temp-realm router id can never be looked up by the shipped worker. The
13
+ * producer instead detects pathname collisions across routers at build time
14
+ * and declines both entries (loudly), keeping the key unambiguous. This is
15
+ * a manifest namespace, never a store keyspace: the runtime shell key
16
+ * (host + pathname + search + ":shell") stays untouched.
17
+ */
18
+ export function buildShellManifestKey(pathname: string): string {
19
+ return pathname;
20
+ }
@@ -73,7 +73,16 @@ export function createDevPrerenderStore(devUrl: string): PrerenderStore {
73
73
  if (isIntercept) url += "&intercept=1";
74
74
  if (meta.isPassthroughRoute) url += "&passthrough=1";
75
75
  try {
76
- const res = await fetch(url);
76
+ // Bounded: this fetch also runs inside the PPR shell capture (a
77
+ // workerd waitUntil task), where an unsettled fetch does not reject —
78
+ // it pends until the task is torn down. Unbounded, one pending fetch
79
+ // wedged the per-isolate capture queue on GH runners (rotating dev
80
+ // capture failures; endpoint instrumentation showed 3-4ms memo HITs,
81
+ // so latency was never the issue — settlement was). On timeout the
82
+ // catch degrades to a store miss and the pipeline falls through to
83
+ // the live handler render (the documented dev fall-through), so the
84
+ // capture still lands with live content.
85
+ const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
77
86
  if (!res.ok) return null;
78
87
  return res.json();
79
88
  } catch {
@@ -430,6 +430,22 @@ function createLoaderExecutor<TEnv>(
430
430
  // nested deps inherit isDslLoader=false only when the CHAIN started in a
431
431
  // handler; a chain started by the segment funnel stays DSL (the loader
432
432
  // scope ALS survives the body's awaits).
433
+ // Shell fast path eligibility: a HANDLER-invoked loader executing during a
434
+ // capture is handler-layer dynamism — on a handler-free (replayed) HIT it
435
+ // would never re-run, freezing its consumption-lane value (#672's "fresh
436
+ // per serve" slot shape). Mark the capture; the entry declines the fast
437
+ // path and keeps the full tail. DSL loaders re-run on every HIT and never
438
+ // set this.
439
+ if (!isDslLoader) {
440
+ const captureCtx = _getRequestContext();
441
+ if (
442
+ captureCtx?._shellCaptureRun &&
443
+ captureCtx._shellCaptureHandleLiveness
444
+ ) {
445
+ captureCtx._shellCaptureHandleLiveness.handlerInvokedLoader = true;
446
+ }
447
+ }
448
+
433
449
  const promise = observePhase(PHASES.loader(loader.$$id), () =>
434
450
  Promise.resolve(
435
451
  runInsideLoaderBodyScope(
@@ -1,4 +1,8 @@
1
- import { CacheScope, createCacheScope } from "../cache/cache-scope.js";
1
+ import {
2
+ CacheScope,
3
+ createCacheScope,
4
+ resolveShellImplicitCacheScope,
5
+ } from "../cache/cache-scope.js";
2
6
  import { RouteNotFoundError } from "../errors";
3
7
  import {
4
8
  createErrorInfo,
@@ -180,7 +184,10 @@ export async function createMatchContextForFull<TEnv>(
180
184
  },
181
185
  isSameRouteNavigation: false,
182
186
  interceptResult: null,
183
- cacheScope: snapshot.cacheScope,
187
+ // Shell fast path: a capture or an eligible HIT tail may substitute an
188
+ // implicit doc-level scope (marker-gated; a route-derived scope wins).
189
+ // Full matches only — partial navigations never serve a shell.
190
+ cacheScope: resolveShellImplicitCacheScope(snapshot.cacheScope),
184
191
  isIntercept: false,
185
192
  actionContext: undefined,
186
193
  isAction: false,
@@ -1,4 +1,5 @@
1
1
  import type { ReactNode } from "react";
2
+ import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
2
3
  import { sanitizeError } from "../errors";
3
4
  import type { ErrorInfo, ErrorPhase, MatchResult } from "../types";
4
5
  import type {
@@ -291,7 +292,13 @@ export function createMatchHandlers<TEnv = any>(
291
292
  });
292
293
  emitter.start();
293
294
 
295
+ const ctxBuildStart = INTERNAL_RANGO_DEBUG ? performance.now() : 0;
294
296
  const result = await createMatchContextForFull(request, env);
297
+ if (INTERNAL_RANGO_DEBUG) {
298
+ console.log(
299
+ `[Server][match] context built +${Math.round(performance.now() - ctxBuildStart)}ms (abs ${Math.round(performance.now())})`,
300
+ );
301
+ }
295
302
 
296
303
  if ("type" in result && result.type === "redirect") {
297
304
  emitter.end(0, false);
@@ -310,7 +317,13 @@ export function createMatchHandlers<TEnv = any>(
310
317
  try {
311
318
  const state = createPipelineState();
312
319
  const pipeline = createMatchPartialPipeline(ctx, state);
320
+ const pipeStart = INTERNAL_RANGO_DEBUG ? performance.now() : 0;
313
321
  const matchResult = await collectMatchResult(pipeline, ctx, state);
322
+ if (INTERNAL_RANGO_DEBUG) {
323
+ console.log(
324
+ `[Server][match] pipeline collected +${Math.round(performance.now() - pipeStart)}ms (abs ${Math.round(performance.now())})`,
325
+ );
326
+ }
314
327
  if (hasTelemetry || cacheSignalEnabled) {
315
328
  const signalSegments = buildSignal(ctx.routeKey, state);
316
329
  recordSignalIfEnabled(signalSegments);
@@ -112,6 +112,9 @@ let prerenderStoreInstance: PrerenderStore | null | undefined;
112
112
  let _deserializeSegments:
113
113
  | typeof import("../../cache/segment-codec.js").deserializeSegments
114
114
  | undefined;
115
+ let _fragmentSegments:
116
+ | typeof import("../../cache/segment-codec.js").fragmentSegments
117
+ | undefined;
115
118
  let _restoreHandles:
116
119
  | typeof import("../../cache/handle-snapshot.js").restoreHandles
117
120
  | undefined;
@@ -135,6 +138,7 @@ async function ensurePrerenderDeps() {
135
138
  import("../../prerender/store.js"),
136
139
  ]);
137
140
  _deserializeSegments = codec.deserializeSegments;
141
+ _fragmentSegments = codec.fragmentSegments;
138
142
  _restoreHandles = snapshot.restoreHandles;
139
143
  _decodeHandles = snapshot.decodeHandles;
140
144
  _hashParams = paramHash.hashParams;
@@ -237,6 +241,7 @@ async function* yieldFromStore<TEnv>(
237
241
 
238
242
  if (
239
243
  !_deserializeSegments ||
244
+ !_fragmentSegments ||
240
245
  !_restoreHandles ||
241
246
  !_decodeHandles ||
242
247
  !_hashParams ||
@@ -245,7 +250,13 @@ async function* yieldFromStore<TEnv>(
245
250
  throw new Error("yieldFromStore called before ensurePrerenderDeps");
246
251
  }
247
252
 
248
- const segments = await _deserializeSegments(entry.segments);
253
+ // Shell-HIT tail (issue #700): a Prerender+ppr route's tail serves from THIS
254
+ // store (the prerender lookup runs before the cache scope), so the fragment
255
+ // splice must apply here too — otherwise producer B entries re-serialize the
256
+ // whole tree per request while producer A entries do not.
257
+ const segments = _getRequestContext()?._shellFragmentPayload
258
+ ? await _fragmentSegments(entry.segments)
259
+ : await _deserializeSegments(entry.segments);
249
260
 
250
261
  // Replay handle data (same as runtime cache hit path). entry.handles is a
251
262
  // Flight-encoded string ("" when none) — decode before restore so
@@ -21,6 +21,7 @@ import type { RouterContext } from "./router-context.js";
21
21
  import type { ResolveSegmentOptions } from "./segment-resolution.js";
22
22
  import { runWithRouterContext } from "./router-context.js";
23
23
  import type { EntryData, InterceptEntry } from "../server/context";
24
+ import type { PartialPrerenderProps } from "../urls/pattern-types.js";
24
25
  import type {
25
26
  HandlerContext,
26
27
  InternalHandlerContext,
@@ -74,6 +75,13 @@ export async function matchForPrerender<TEnv = any>(
74
75
  * the sinks store it as-is (no longer merge raw records). */
75
76
  interceptHandles?: string;
76
77
  passthrough?: true;
78
+ /**
79
+ * The matched route entry's `ppr` path option, surfaced so the build
80
+ * collection can flag Prerender+ppr routes as build-time shell candidates
81
+ * (issue #699 producer B). Runtime-only today; the build otherwise never
82
+ * sees the option (it lives on EntryData, not the trie).
83
+ */
84
+ ppr?: boolean | PartialPrerenderProps;
77
85
  } | null> {
78
86
  // 1. Find the matching route entry
79
87
  const matched = await deps.findMatch(pathname);
@@ -306,6 +314,18 @@ export async function matchForPrerender<TEnv = any>(
306
314
  // Use the trie-level route key (e.g., "docs", "docs.article")
307
315
  const routeName = matched.routeKey;
308
316
 
317
+ // Surface the matched route entry's ppr option for the build collection
318
+ // (leaf-first: the deepest type:"route" entry in the ancestor chain is
319
+ // the matched page route; ancestors are layouts/includes).
320
+ let routePpr: boolean | PartialPrerenderProps | undefined;
321
+ for (let i = entries.length - 1; i >= 0; i--) {
322
+ const e = entries[i]!;
323
+ if (e.type === "route") {
324
+ routePpr = e.ppr;
325
+ break;
326
+ }
327
+ }
328
+
309
329
  // 14. Resolve intercept segments for this route (if any ancestor defines
310
330
  // an intercept targeting this route). At build time we skip when()
311
331
  // evaluation -- we pre-render all intercepts unconditionally and let
@@ -420,6 +440,7 @@ export async function matchForPrerender<TEnv = any>(
420
440
  params: matchedParams,
421
441
  interceptSegments,
422
442
  interceptHandles,
443
+ ppr: routePpr,
423
444
  };
424
445
  });
425
446
  });
@@ -51,11 +51,27 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
51
51
  * rule, semantic-matrix PPR3) shares the raw container and must keep real
52
52
  * values. Cycles are preserved as cycles in the copy.
53
53
  */
54
+ /**
55
+ * Optional single-walk report for maskNestedContainerThenables: `thenable`
56
+ * flips true when the walk masked at least one thenable. The shell fast path
57
+ * reads it at handle-push time — a pushed container with a nested thenable
58
+ * declares per-request data, and a shell entry whose HANDLER layer made such
59
+ * a declaration cannot be served handler-free (the hole would never fill).
60
+ * Same single-pass shape as elideLoaderContainer's `hasHole`.
61
+ */
62
+ export interface MaskReport {
63
+ thenable: boolean;
64
+ }
65
+
54
66
  export function maskNestedContainerThenables(
55
67
  value: unknown,
56
68
  seen: Map<object, unknown> = new Map(),
69
+ report?: MaskReport,
57
70
  ): unknown {
58
- if (isThenable(value)) return createMaskedLoaderPromise();
71
+ if (isThenable(value)) {
72
+ if (report) report.thenable = true;
73
+ return createMaskedLoaderPromise();
74
+ }
59
75
 
60
76
  if (Array.isArray(value)) {
61
77
  const cached = seen.get(value);
@@ -63,7 +79,7 @@ export function maskNestedContainerThenables(
63
79
  const out: unknown[] = new Array(value.length);
64
80
  seen.set(value, out);
65
81
  for (let i = 0; i < value.length; i++) {
66
- out[i] = maskNestedContainerThenables(value[i], seen);
82
+ out[i] = maskNestedContainerThenables(value[i], seen, report);
67
83
  }
68
84
  return out;
69
85
  }
@@ -74,7 +90,7 @@ export function maskNestedContainerThenables(
74
90
  const out: Record<string, unknown> = {};
75
91
  seen.set(value, out);
76
92
  for (const key of Object.keys(value)) {
77
- out[key] = maskNestedContainerThenables(value[key], seen);
93
+ out[key] = maskNestedContainerThenables(value[key], seen, report);
78
94
  }
79
95
  return out;
80
96
  }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Per-isolate shell-capture serialization.
3
+ *
4
+ * Captures are CPU-bound background renders whose quiet detection is
5
+ * task-quantized (FLIGHT_QUIET_HOPS macrotask hops with zero new bytes). Two
6
+ * captures running concurrently starve each other: one grinding capture —
7
+ * e.g. a prerender+ppr route whose capture round-trips the dev
8
+ * /__rsc_prerender endpoint, whose per-request module re-import can peg a
9
+ * slow CI runner for seconds — keeps the sibling's render byte-silent past
10
+ * its abort budget, so the sibling freezes a trivial prelude and stores
11
+ * nothing. Observed on GH runners as ROTATING eternal-MISS victims (the
12
+ * warmup on one shard, a composition probe, then /ppr-shell?probe=stream once
13
+ * the first was quieted) while every local run passed.
14
+ *
15
+ * Serializing capture execution removes the cross-talk: each capture's quiet
16
+ * window observes only its own work. Captures are TTL-scale background work,
17
+ * so queueing costs latency-to-HIT only — never a served response. On workerd
18
+ * a queued capture rides the scheduling request's waitUntil, whose lifetime
19
+ * is bounded; a capture killed mid-queue by that bound simply recaptures on a
20
+ * later request (the existing best-effort contract).
21
+ *
22
+ * The chain link resolves in `finally` and the prior link is awaited with a
23
+ * swallow, so one rejected capture can never wedge every later one.
24
+ */
25
+ let captureQueue: Promise<void> = Promise.resolve();
26
+
27
+ /**
28
+ * Upper bound on how long one queue link may hold the queue. A capture task
29
+ * normally settles well inside this (attempt + in-place retry + writes), but
30
+ * a task wedged on never-settling I/O — a workerd waitUntil fetch that pends
31
+ * instead of rejecting (seen on GH runners with the dev prerender store
32
+ * before its fetch was time-bounded) — must not block every later capture in
33
+ * the isolate. At the cap the QUEUE is released; the wedged task itself stays
34
+ * detached (its own per-key guards clean up when/if it settles).
35
+ */
36
+ const QUEUE_LINK_CAP_MS = 60_000;
37
+
38
+ /**
39
+ * Run `task` after every previously enqueued capture has settled. Returns a
40
+ * promise for THIS task's completion (rejections propagate to the caller —
41
+ * the queue itself is insulated).
42
+ */
43
+ export function enqueueSerializedCapture(
44
+ task: () => Promise<void>,
45
+ ): Promise<void> {
46
+ const prior = captureQueue;
47
+ let releaseQueue!: () => void;
48
+ captureQueue = new Promise<void>((resolve) => {
49
+ releaseQueue = resolve;
50
+ });
51
+ return (async () => {
52
+ await prior.catch(() => {});
53
+ let capTimer: ReturnType<typeof setTimeout> | undefined;
54
+ try {
55
+ await Promise.race([
56
+ task(),
57
+ new Promise<void>((resolve) => {
58
+ capTimer = setTimeout(resolve, QUEUE_LINK_CAP_MS);
59
+ (capTimer as { unref?: () => void }).unref?.();
60
+ }),
61
+ ]);
62
+ } finally {
63
+ if (capTimer) clearTimeout(capTimer);
64
+ releaseQueue();
65
+ }
66
+ })();
67
+ }