@rangojs/router 0.4.1 → 0.4.3

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 (47) hide show
  1. package/dist/types/browser/navigation-client.d.ts +1 -1
  2. package/dist/types/browser/prefetch/cache.d.ts +20 -8
  3. package/dist/types/cache/cf/cf-cache-store.d.ts +19 -8
  4. package/dist/types/cache/cf/cf-cache-types.d.ts +4 -2
  5. package/dist/types/cache/read-through-swr.d.ts +7 -6
  6. package/dist/types/cloudflare/tracing.d.ts +16 -12
  7. package/dist/types/router/instrument.d.ts +45 -15
  8. package/dist/types/router/telemetry-otel.d.ts +4 -4
  9. package/dist/types/router/timeout.d.ts +12 -1
  10. package/dist/types/router/tracing.d.ts +31 -17
  11. package/dist/types/rsc/capture-queue.d.ts +22 -1
  12. package/dist/types/rsc/shell-capture.d.ts +17 -9
  13. package/dist/types/rsc/stream-idle.d.ts +60 -0
  14. package/dist/types/segment-fragments.d.ts +27 -4
  15. package/dist/types/server/request-context.d.ts +28 -9
  16. package/dist/types/vercel/tracing.d.ts +9 -8
  17. package/dist/vite/index.js +1 -1
  18. package/package.json +1 -1
  19. package/skills/cloudflare/SKILL.md +4 -2
  20. package/skills/observability/SKILL.md +33 -4
  21. package/skills/ppr/SKILL.md +25 -9
  22. package/src/browser/navigation-client.ts +77 -27
  23. package/src/browser/prefetch/cache.ts +40 -10
  24. package/src/browser/prefetch/fetch.ts +5 -0
  25. package/src/browser/rsc-router.tsx +12 -6
  26. package/src/cache/cache-runtime.ts +39 -25
  27. package/src/cache/cache-scope.ts +5 -1
  28. package/src/cache/cf/cf-cache-store.ts +423 -90
  29. package/src/cache/cf/cf-cache-types.ts +4 -2
  30. package/src/cache/document-cache.ts +63 -25
  31. package/src/cache/read-through-swr.ts +22 -16
  32. package/src/cloudflare/tracing.ts +16 -12
  33. package/src/router/instrument.ts +61 -15
  34. package/src/router/segment-resolution/loader-cache.ts +12 -1
  35. package/src/router/segment-resolution/revalidation.ts +16 -1
  36. package/src/router/telemetry-otel.ts +4 -4
  37. package/src/router/timeout.ts +12 -1
  38. package/src/router/tracing.ts +37 -17
  39. package/src/rsc/capture-queue.ts +72 -17
  40. package/src/rsc/handler.ts +171 -73
  41. package/src/rsc/rsc-rendering.ts +61 -6
  42. package/src/rsc/shell-capture.ts +83 -31
  43. package/src/rsc/stream-idle.ts +137 -0
  44. package/src/segment-fragments.ts +45 -4
  45. package/src/server/request-context.ts +29 -8
  46. package/src/testing/dispatch.ts +55 -1
  47. package/src/vercel/tracing.ts +9 -8
@@ -14,4 +14,4 @@ import type { NavigationClient, RscBrowserDependencies } from "./types.js";
14
14
  * @param deps - RSC browser dependencies (createFromFetch)
15
15
  * @returns NavigationClient instance
16
16
  */
17
- export declare function createNavigationClient(deps: Pick<RscBrowserDependencies, "createFromFetch">): NavigationClient;
17
+ export declare function createNavigationClient(deps: Pick<RscBrowserDependencies, "createFromFetch" | "createFromReadableStream">): NavigationClient;
@@ -10,12 +10,12 @@
10
10
  * scopes are in play:
11
11
  * - Wildcard (default): built by `buildPrefetchKey(rangoState, target)` —
12
12
  * shape `rangoState\0/target?...`. Shared across all source pages and
13
- * invalidated automatically when Rango state bumps (deploy or
14
- * server-action invalidation).
13
+ * source segment trees; `_rsc_segments` is omitted from this key. Invalidated
14
+ * automatically when Rango state bumps (deploy or server-action invalidation).
15
15
  * - Source-scoped: built by `buildSourceKey(rangoState, sourceHref, target)`
16
16
  * — shape `rangoState\0sourceHref\0/target?...`. Embeds the Rango state
17
- * (so rotation invalidates source-scoped entries too) plus the source
18
- * href (so each originating page gets its own slot). Populated when the
17
+ * (so rotation invalidates source-scoped entries too), the source href, and
18
+ * the exact source segment tree. Populated when the
19
19
  * server tags a response with `X-RSC-Prefetch-Scope: source` (intercept
20
20
  * modals etc.), OR when a Link opts in with `prefetchKey=":source"` — in
21
21
  * both cases so source-sensitive responses cannot bleed into navigations
@@ -91,7 +91,9 @@ export declare function isPrefetchCacheDisabled(): boolean;
91
91
  * - Wildcard (source-agnostic): prefix is the Rango state value from
92
92
  * `getRangoState()`. Shared across all source pages. Invalidated
93
93
  * automatically when Rango state bumps (deploy or server-action).
94
- * Key shape: `rangoState\0/target?...`.
94
+ * `_rsc_segments` is omitted so sibling payloads prefetched from one page
95
+ * remain reusable after another prefetched navigation commits. Key shape:
96
+ * `rangoState\0/target?...`.
95
97
  * - Source-scoped: use `buildSourceKey()`. Key shape:
96
98
  * `rangoState\0sourceHref\0/target?...` — embeds the Rango state so
97
99
  * rotation invalidates source-scoped entries alongside wildcard ones,
@@ -100,9 +102,10 @@ export declare function isPrefetchCacheDisabled(): boolean;
100
102
  * `X-RSC-Prefetch-Scope: source` (intercept modals, etc.) or when a
101
103
  * Link opts in via `prefetchKey=":source"`.
102
104
  *
103
- * The `_rsc_segments` query param that travels in the target URL means
104
- * clients with different mounted segment trees naturally get different
105
- * keys so segment-level diffs remain consistent across both scopes.
105
+ * Source-scoped keys retain `_rsc_segments` because their exact source tree is
106
+ * part of the opt-in contract. Wildcard responses are source-agnostic by
107
+ * contract; callers use source scope when a custom revalidation decision makes
108
+ * the response depend on the current URL or segment tree.
106
109
  */
107
110
  export declare function buildPrefetchKey(prefix: string, targetUrl: URL): string;
108
111
  /**
@@ -180,4 +183,13 @@ export declare function setInflightPromise(key: string, promise: Promise<Decoded
180
183
  */
181
184
  export declare function setInflightPromiseWithAliases(keys: string[], promise: Promise<DecodedPrefetch | null>): void;
182
185
  export declare function clearPrefetchInflight(key: string): void;
186
+ /** Whether this document may request stored segment fragment envelopes. */
187
+ export declare function isFragmentPassthroughEnabled(): boolean;
188
+ /**
189
+ * Disable fragment passthrough for the rest of this document after one stored
190
+ * fragment fails to decode. The recovery replaces shared cache entries, while
191
+ * this guard prevents the current tab's HTTP cache from serving its already-
192
+ * cached corrupt variant again.
193
+ */
194
+ export declare function disableFragmentPassthrough(): void;
183
195
  export declare function clearPrefetchCache(rotateRangoState?: boolean): void;
@@ -70,6 +70,8 @@ export declare class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<T
70
70
  * @internal
71
71
  */
72
72
  private emitDebug;
73
+ /** Build-time-gated shell tier trace for deployed cross-colo diagnostics. */
74
+ private debugShell;
73
75
  /**
74
76
  * Resolve the cache-key base URL for the current cache operation.
75
77
  * Prefers an explicit `baseUrl` option; otherwise derives it from the live
@@ -263,10 +265,10 @@ export declare class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<T
263
265
  */
264
266
  private warnShellFamilyInertOnce;
265
267
  /**
266
- * Get a cached PPR shell entry by key from KV (no L1). Applies the KV read
267
- * budget, corrupt-entry eviction, hard-expiry, and tag invalidation exactly
268
- * like kvGetItem, minus the L1 promote. SWR is a plain staleness flag — KV has
269
- * no REVALIDATING herd guard, so the capture scheduler's module-level
268
+ * Get a cached PPR shell entry from Cache API, falling through to KV and
269
+ * promoting a valid KV hit. Both tiers store one envelope so the prelude,
270
+ * postponed state, snapshot, versions, and generation metadata cannot mix.
271
+ * SWR remains a plain staleness flag; the capture scheduler's module-level
270
272
  * in-flight set is the recapture stampede guard.
271
273
  */
272
274
  getShell(key: string): Promise<{
@@ -274,12 +276,21 @@ export declare class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<T
274
276
  shouldRevalidate?: boolean;
275
277
  } | null>;
276
278
  /**
277
- * Store a PPR shell entry in KV with TTL and optional SWR window. The write is
278
- * registered with waitUntil and awaited so invalidation rejection can be
279
- * acknowledged to the capture scheduler. The tags/taggedAt ride in the envelope
280
- * so isGloballyInvalidated() can invalidate the shell via the shared KV markers.
279
+ * Store a PPR shell envelope in Cache API and, when its retention meets KV's
280
+ * 60-second floor, KV. The shared write is registered with waitUntil and
281
+ * awaited so invalidation rejection can be acknowledged to the capture
282
+ * scheduler. Short-lived shells remain useful in L1 even though KV rejects
283
+ * them.
281
284
  */
282
285
  putShell(key: string, entry: ShellCacheEntry, ttlSeconds?: number, swrSeconds?: number, tags?: string[]): Promise<"stored" | "invalidated" | void>;
286
+ /** Rebuild the public shell entry from its validated storage envelope. */
287
+ private shellEnvelopeToEntry;
288
+ /** Build the Cache API representation of the coupled shell envelope. */
289
+ private shellEnvelopeResponse;
290
+ /** KV shell fallback with corruption checks and background L1 promotion. */
291
+ private kvGetShell;
292
+ /** Promote a valid KV shell into the per-colo Cache API tier. */
293
+ private promoteShellToL1;
283
294
  /**
284
295
  * Convert string key to Request object for CF Cache API.
285
296
  * Includes version in URL if specified (for cache invalidation on code changes).
@@ -189,8 +189,10 @@ export interface CFCacheStoreOptions<TEnv = unknown> {
189
189
  * per-request memo is consulted (synchronously, no KV read), so a request
190
190
  * that ran `updateTag()` still masks its own not-yet-purged entries
191
191
  * (read-your-own-writes).
192
- * - The KV tier and PPR shells still use the KV markers those are written
193
- * regardless, and purge cannot reach KV so keep `kv` configured for L2.
192
+ * - The KV tier and PPR shell reads still use the KV markers. Runtime shell
193
+ * L1 entries are purgeable, but their taggedAt is the capture-start time: a
194
+ * capture can finish after an invalidation purge, so eviction alone cannot
195
+ * reject that older generation. Keep `kv` configured for shell L2 + markers.
194
196
  * Without `kv`, purge mode is the ONLY tag invalidation and the store is
195
197
  * L1-only: a supported configuration (previously tag invalidation without
196
198
  * KV had no read-side effect at all). One KV-less caveat: an entry whose
@@ -25,13 +25,14 @@ export interface ReadThroughItemConfig<T> {
25
25
  /** Execute the underlying function/loader on miss or revalidation */
26
26
  execute: () => Promise<T>;
27
27
  /**
28
- * Optional wrapper applied to execute() ONLY on the background
29
- * stale-revalidation path (not the foreground miss, where the caller's context
30
- * is already established). Used to re-establish the request-context ALS, which
31
- * a detached waitUntil task loses on workerd. Defaults to calling execute()
32
- * directly.
28
+ * Optional wrapper applied to the WHOLE background stale-revalidation task
29
+ * (execute + serialize + setItem) — not the foreground miss, where the
30
+ * caller's context is already established. Used to re-establish the
31
+ * request-context ALS, which a detached waitUntil task loses on workerd,
32
+ * and to open the rango.background span so the write is covered too.
33
+ * Defaults to running the task directly.
33
34
  */
34
- wrapBackground?: (run: () => Promise<T>) => Promise<T>;
35
+ wrapBackground?: (run: () => Promise<void>) => Promise<void>;
35
36
  /** Serialize result for storage. Return null to skip caching. */
36
37
  serialize: (data: T) => Promise<string | null>;
37
38
  /** Deserialize cached value back to the original type */
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Cloudflare custom-spans integration.
3
3
  *
4
- * Bridges the router's performance phases (request, middleware, action,
5
- * loaders, handler, render, ssr) onto Cloudflare Workers custom spans so they show
6
- * up in the trace waterfall and OpenTelemetry exports next to the platform's
7
- * automatic spans (KV reads, D1 queries, fetch calls), with correct nesting.
4
+ * Bridges the router's observable phases (request, middleware, action, loaders,
5
+ * handler, render, ssr, response, background) onto Cloudflare Workers custom
6
+ * spans so they show up in the trace waterfall and OpenTelemetry exports next
7
+ * to the platform's automatic spans (KV reads, D1 queries, fetch calls), with
8
+ * correct nesting.
8
9
  *
9
10
  * Usage (Cloudflare preset only):
10
11
  *
@@ -27,12 +28,15 @@
27
28
  *
28
29
  * Span duration note: enterSpan ends a span when its callback's returned value
29
30
  * (or promise) settles. For the streaming phases (request/render/ssr) that is at
30
- * stream CONSTRUCTION, not body-drain. Instrumentation is best-effort and never
31
- * wraps or buffers the response body, so it cannot regress streaming or latency.
32
- * A loader/Suspense child that resolves mid-stream therefore keeps a rango.loader
33
- * span that can extend past its render parent overlapping spans are valid. Uses
34
- * only the typed enterSpan API; spans bound work up to stream-handoff, matching
35
- * the co-emitted perf metric.
31
+ * stream CONSTRUCTION, not body-drain; rango.response marks that handoff
32
+ * boundary explicitly (it wraps only response finalization, never the body).
33
+ * Instrumentation is best-effort and never wraps or buffers the response body,
34
+ * so it cannot regress streaming or latency. A loader/Suspense child that
35
+ * resolves mid-stream therefore keeps a rango.loader span that can extend past
36
+ * its render parent — overlapping spans are valid. Uses only the typed enterSpan
37
+ * API; spans bound work up to stream-handoff, matching the co-emitted perf
38
+ * metric. On deployed Workers a rango.response span may report 0 ms (non-I/O
39
+ * timers are frozen); its position and attributes are the value.
36
40
  */
37
41
  import { type RouterTracingConfig, type TracingToggleOptions } from "../router/tracing.js";
38
42
  /**
@@ -44,8 +48,8 @@ export type CloudflareTracingOptions = TracingToggleOptions;
44
48
  /**
45
49
  * Create the tracing config for a Cloudflare router. Pass the result to
46
50
  * `createRouter({ tracing })`. Spans are emitted for the request, middleware,
47
- * action, loaders, handler, render, and ssr phases; pass `spans` to turn
48
- * individual phases off.
51
+ * action, loaders, handler, render, ssr, response, and background phases; pass
52
+ * `spans` to turn individual phases off.
49
53
  *
50
54
  * @see createOTelTracing (`@rangojs/router`) for the same slot on any platform
51
55
  * with an OpenTelemetry SDK.
@@ -5,13 +5,11 @@
5
5
  * The router exposes the same work on three surfaces, and the rule is: each
6
6
  * surface has exactly one owner here, so they cannot drift.
7
7
  *
8
- * - observePhase(): a span of work. Co-emits the `debugPerformance` perf
9
- * metric (metrics store -> [RSC Perf] timeline + Server-Timing) AND the
10
- * platform span (tracing runner -> Cloudflare custom spans / OTel). From one
11
- * wrap site, so the span set is always a subset of the perf phases and the
12
- * two can't disagree. Phases that meter their own perf metric with a finer
13
- * decomposition (request, middleware) pass `metric: false` and get the span
14
- * only — still co-located, still one owner per surface.
8
+ * - observePhase(): a span of work. From one wrap site, emits the platform
9
+ * span (tracing runner -> Cloudflare custom spans / OTel) and, when the
10
+ * phase has one, its `debugPerformance` metric (metrics store -> [RSC Perf]
11
+ * timeline + Server-Timing). Phases that meter their own perf metric with a
12
+ * finer decomposition, or are deliberately span-only, pass `metric: false`.
15
13
  * - observeEvent(): a discrete fact (TelemetrySink): cache decisions,
16
14
  * revalidation decisions, handler errors, timeouts, origin rejections.
17
15
  * Event-shaped, not phase-shaped — derived from the same call sites but a
@@ -59,6 +57,11 @@ export interface PhaseSpec {
59
57
  */
60
58
  lazyAttributes?: () => Record<string, string | number | boolean> | undefined;
61
59
  }
60
+ /**
61
+ * The detached background lanes wrapped by the rango.background span (the
62
+ * `rango.background.kind` attribute). One name per task execution boundary.
63
+ */
64
+ export type BackgroundTaskKind = "shell-capture" | "document-revalidation" | "loader-revalidation" | "use-cache-revalidation";
62
65
  /**
63
66
  * The router's observable phases. One definition per phase keeps the `rango.*`
64
67
  * span names, perf-metric labels, and identifying attributes from spreading
@@ -99,6 +102,32 @@ export declare const PHASES: {
99
102
  /** SSR HTML render from the RSC stream. Colon-delimited like the other ssr:*
100
103
  * setup metrics (ssr:module-load / ssr:stream-mode). */
101
104
  readonly ssr: PhaseSpec;
105
+ /**
106
+ * One detached background task (waitUntil work that outlives the response):
107
+ * a PPR shell capture or an SWR background revalidation. The wrapper is the
108
+ * explanatory parent for everything the task does — the platform's automatic
109
+ * KV/fetch/cache spans, and (for the revalidation lanes) the re-run's own
110
+ * rango.* phase spans — so post-handoff work never appears as orphan spans
111
+ * dangling under an ended foreground phase. `kind` identifies the lane.
112
+ * Span only (metric:false): the perf timeline is finalized with the
113
+ * response, so a background metric could never reach it. The shell-capture
114
+ * lane keeps its INNER phase spans suppressed (derived context strips
115
+ * _tracing — a capture re-render duplicating the foreground's span set was
116
+ * the original #670 leak); the revalidation lanes keep theirs, nested here.
117
+ */
118
+ readonly background: (kind: BackgroundTaskKind) => PhaseSpec;
119
+ /** Final response construction + host handoff: opened AFTER downstream
120
+ * middleware/core execution returns a Response, wrapping only finalization
121
+ * (redirect interception/guarding, Server-Timing mutation, final response
122
+ * selection) and ending immediately before the router handler returns the
123
+ * response to the host. Handoff-bound, never drain-bound — it must never
124
+ * read, tee, buffer, or await response.body, so it cannot regress streaming.
125
+ * At most one per traced request; absent when the request throws before a
126
+ * response exists. Span only (metric:false) — a co-emitted metric would
127
+ * circularly mutate the Server-Timing header this phase itself finalizes.
128
+ * On Cloudflare it may report 0 ms (non-I/O timers are frozen); its position
129
+ * and attributes (status/mode/body kind, set by the handler) are the value. */
130
+ readonly response: PhaseSpec;
102
131
  };
103
132
  /**
104
133
  * Instrument one unit of work: open its span AND (unless `metric: false`) record
@@ -108,14 +137,15 @@ export declare const PHASES: {
108
137
  * returns a promise both the metric duration and the span end when it settles.
109
138
  *
110
139
  * This is the ONLY phase primitive: every phase (request/middleware/action/
111
- * loader/handler/render/ssr) is construction-bound the span and metric settle
112
- * when fn's own work completes (for the streaming phases, when the RSC/HTML
113
- * stream is constructed, NOT when the body drains). Instrumentation is strictly
114
- * best-effort: it never wraps or buffers the response and adds no work on the
115
- * streaming path, so it cannot regress response latency or streaming. A loader
116
- * that resolves while the body streams therefore keeps a rango.loader span that
117
- * may extend past its render parent overlapping spans are valid; the loader
118
- * really did take that long.
140
+ * loader/handler/render/ssr/response/background) settles when fn's own work
141
+ * completes. For streaming phases that means when the RSC/HTML stream is
142
+ * constructed, NOT when the body drains; for response, when finalization hands
143
+ * the stream to the host. Instrumentation is strictly best-effort: it never
144
+ * wraps or buffers the response and adds no work on the streaming path, so it
145
+ * cannot regress response latency or streaming. A loader that resolves while
146
+ * the body streams therefore keeps a rango.loader span that may extend past its
147
+ * render parent — overlapping spans are valid; the loader really did take that
148
+ * long.
119
149
  *
120
150
  * Reads the metrics store + tracing off the RequestContext ALS, which is active
121
151
  * for the WHOLE request — contrast observeEvent, which reads the RouterContext
@@ -6,10 +6,10 @@
6
6
  * - createOTelTracing(tracer): the OTel adapter for the `tracing` SLOT — the
7
7
  * canonical phase-span layer. It bridges observePhase's callback boundary
8
8
  * onto OTel's callback-bound `startActiveSpan`, so the router's phase spans
9
- * (rango.request/middleware/loader/render/ssr) nest by async context and the
10
- * loader's own OTel spans (db/fetch) land under rango.loader. This is the
11
- * OTel equivalent of createCloudflareTracing pass it to
12
- * `createRouter({ tracing })`.
9
+ * (rango.request/middleware/action/loader/handler/render/ssr/response/
10
+ * background) nest by async context and the loader's own OTel spans
11
+ * (db/fetch) land under rango.loader. This is the OTel equivalent of
12
+ * createCloudflareTracing — pass it to `createRouter({ tracing })`.
13
13
  *
14
14
  * - createOTelSink(tracer): the OTel adapter for the `telemetry` SLOT — a
15
15
  * TelemetrySink for the EVENT-shaped facts (handler errors, cache decisions,
@@ -10,7 +10,18 @@ export interface RouterTimeouts {
10
10
  actionMs?: number;
11
11
  /** Timeout for initial render/response production (ms). */
12
12
  renderStartMs?: number;
13
- /** Timeout for idle streaming after render starts (ms). Reserved for PR 2. */
13
+ /**
14
+ * Timeout for idle streaming after the response is handed off (ms): when no
15
+ * chunk flows to the client for this long, the body stream is errored with a
16
+ * RouterTimeoutError ("stream-idle") and the source render is canceled —
17
+ * bounding hangs from never-settling promises embedded in the payload.
18
+ * END-TO-END semantics: a stalled slow client counts as idle the same as a
19
+ * wedged producer, so pick generous budgets (seconds). Opt-in (unset =
20
+ * unbounded, and the `timeout` shorthand deliberately does not apply);
21
+ * onTimeout does NOT fire — no replacement Response can be served
22
+ * mid-stream — the trip reports via onError + the request.timeout event.
23
+ * Enforcement: rsc/stream-idle.ts, wired at the handler's response tail.
24
+ */
14
25
  streamIdleMs?: number;
15
26
  }
16
27
  export type TimeoutPhase = "action" | "render-start" | "stream-idle";
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * Span tracing hook (platform-agnostic).
3
3
  *
4
- * The core router emits its existing performance phases (request, middleware, action,
5
- * loaders, render, ssr) as spans by calling traceSpan() at a small set of
6
- * execution boundaries. When no tracing is configured the call is a direct
7
- * pass-through: fn is invoked with a no-op span, with no wrapper and no
8
- * allocation, so a non-traced request behaves exactly as before.
4
+ * The core router emits its observable phases (request, middleware, action,
5
+ * loaders, handler, render, ssr, response, background) as spans by calling
6
+ * traceSpan() at a small set of execution boundaries. When no tracing is
7
+ * configured the call is a direct pass-through: fn is invoked with a no-op
8
+ * span, with no wrapper and no allocation, so a non-traced request behaves
9
+ * exactly as before.
9
10
  *
10
11
  * A platform integration supplies a SpanRunner that wraps fn in a real span.
11
12
  * Two runners ship: the Cloudflare one (createCloudflareTracing in
@@ -29,7 +30,10 @@
29
30
  * useLoader, plus the fetchable path), rango.handler (span-only, one per segment
30
31
  * route/layout handler execution; the handler:<id> perf metric is owned by the
31
32
  * track() at the call site), rango.render (render:total:<route>; normal AND
32
- * action-revalidation renders), rango.ssr (ssr:render-html).
33
+ * action-revalidation renders), rango.ssr (ssr:render-html), rango.response
34
+ * (span-only; final response construction + host handoff — the explicit
35
+ * stream-handoff marker), and rango.background (span-only; detached work, see
36
+ * below).
33
37
  *
34
38
  * Span-duration caveat (best-effort, never buffers): a span ends when its
35
39
  * callback's value (or promise) settles. For the streaming phases (request,
@@ -40,6 +44,19 @@
40
44
  * rango.loader span that can extend past its render parent; overlapping spans are
41
45
  * valid (the loader really did take that long). Phase spans bound the work up to
42
46
  * stream-handoff, which is also what the co-emitted perf metric measures.
47
+ * rango.response makes that handoff boundary explicit: it wraps only response
48
+ * finalization (redirect interception/guarding, Server-Timing mutation, final
49
+ * response selection) after downstream execution returns, ending immediately
50
+ * before the handler returns the Response to the host. It is handoff-bound,
51
+ * never drain-bound — it never reads, tees, or awaits response.body, and it is
52
+ * absent when the request throws before a response exists.
53
+ *
54
+ * rango.background wraps DETACHED work (waitUntil tasks that outlive the
55
+ * response): PPR shell captures and SWR background revalidations. It is the
56
+ * explanatory parent for spans those tasks produce after the foreground
57
+ * phases ended — without it, post-handoff work reads as orphan spans dangling
58
+ * under an ended parent. See PHASES.background (instrument.ts) for the lane
59
+ * kinds and per-lane inner-span policy.
43
60
  *
44
61
  * Both shipped runners (Cloudflare, OTel) keep the core agnostic: the
45
62
  * platform-specific bridge lives at the edge behind the SpanRunner contract.
@@ -58,17 +75,14 @@ export interface TraceSpan {
58
75
  */
59
76
  export type SpanRunner = <T>(name: string, fn: (span: TraceSpan) => T) => T;
60
77
  /** The router phases that can be wrapped in a span. */
61
- export type TracePhase = "request" | "middleware" | "action" | "loader" | "handler" | "render" | "ssr";
62
- /** Per-phase span toggles. Omitted phases default to enabled. */
63
- export interface TracePhaseToggles {
64
- request?: boolean;
65
- middleware?: boolean;
66
- action?: boolean;
67
- loader?: boolean;
68
- handler?: boolean;
69
- render?: boolean;
70
- ssr?: boolean;
71
- }
78
+ export type TracePhase = "request" | "middleware" | "action" | "loader" | "handler" | "render" | "ssr" | "response" | "background";
79
+ /**
80
+ * Per-phase span toggles. Omitted phases default to enabled. Derived from
81
+ * TracePhase so a new phase cannot be silently missing here — the other phase
82
+ * lists in this file (ALL_PHASES_ON, resolveTracing) are exhaustiveness-checked
83
+ * by their Record<TracePhase, boolean> annotations.
84
+ */
85
+ export type TracePhaseToggles = Partial<Record<TracePhase, boolean>>;
72
86
  /**
73
87
  * The option pair shared by every tracing factory (enabled master switch +
74
88
  * per-phase span toggles). Extended by OTelTracingOptions,
@@ -4,9 +4,30 @@ export declare const MAX_ADMITTED_CAPTURES: number;
4
4
  export declare class CaptureQueueFullError extends Error {
5
5
  constructor();
6
6
  }
7
+ /**
8
+ * Budget on how long a capture may WAIT in the queue before it is dropped
9
+ * instead of run. On workerd a queued capture rides the scheduling request's
10
+ * waitUntil, whose post-response lifetime is bounded (~30s): a capture that
11
+ * already burned 15s+ parked behind a slow predecessor would start its own
12
+ * up-to-15s attempt (captureTimeout) with no budget left and risk being
13
+ * killed mid-store-write. Dropping at the budget is safe — captures are
14
+ * best-effort by contract, and a later request re-probes the key (the drop
15
+ * does NOT mark backoff: the route is not doomed, the isolate was busy).
16
+ * Matches one attempt's SHELL_CAPTURE_MAX_WAIT_MS so wait + attempt fits the
17
+ * platform budget. Field-observed: a navigation-shell capture parked ~24s
18
+ * behind a predecessor's two-attempt cycle.
19
+ */
20
+ export declare const CAPTURE_QUEUE_WAIT_BUDGET_MS: number;
21
+ /** Thrown (async) when the queue wait exceeded the budget; the task never ran. */
22
+ export declare class CaptureQueueWaitTimeoutError extends Error {
23
+ readonly waitedMs: number;
24
+ constructor(waitedMs: number);
25
+ }
7
26
  /**
8
27
  * Run `task` after every previously enqueued capture has settled. Returns a
9
28
  * promise for THIS task's completion (rejections propagate to the caller —
10
29
  * the queue itself is insulated).
11
30
  */
12
- export declare function enqueueSerializedCapture(task: () => Promise<void>): Promise<void>;
31
+ export declare function enqueueSerializedCapture(task: () => Promise<void>, opts?: {
32
+ maxQueueWaitMs?: number;
33
+ }): Promise<void>;
@@ -125,10 +125,14 @@ export interface ShellCaptureDebugEvent {
125
125
  * - skip-backoff: the key is inside its refused-capture backoff window and
126
126
  * the capture was not attempted
127
127
  * - skip-capacity: the isolate capture queue is full; a later request may retry
128
+ * - skip-queue-timeout: the capture waited past CAPTURE_QUEUE_WAIT_BUDGET_MS
129
+ * behind other captures and was dropped unrun (no backoff — the route is
130
+ * not doomed, the isolate was busy; a later request re-probes). Carries
131
+ * queueWaitMs.
128
132
  * - backoff: the key entered (or escalated) backoff after a terminal
129
133
  * no-shell — carries the new backoff state
130
134
  */
131
- outcome: "stored" | "redirect" | "no-shell" | "refused" | "error" | "skip-in-flight" | "skip-backoff" | "skip-capacity" | "backoff";
135
+ outcome: "stored" | "redirect" | "no-shell" | "refused" | "error" | "skip-in-flight" | "skip-backoff" | "skip-capacity" | "skip-queue-timeout" | "backoff";
132
136
  /** Attempt number (1 = first, 2 = in-place retry). Absent on skips. */
133
137
  attempt?: number;
134
138
  /** Wall-clock ms of the whole attempt (barrier + render + drain + put). */
@@ -157,6 +161,8 @@ export interface ShellCaptureDebugEvent {
157
161
  backoffFailures?: number;
158
162
  /** Ms remaining in the key's backoff window, when one exists. */
159
163
  backoffRemainingMs?: number;
164
+ /** Ms the capture waited in the serialized queue (skip-queue-timeout). */
165
+ queueWaitMs?: number;
160
166
  }
161
167
  /**
162
168
  * Debug sink for the capture pipeline, mirroring {@link CFCacheDebug}: `true`
@@ -304,8 +310,9 @@ export declare function scheduleShellCapture(ctx: HandlerContext<any>, request:
304
310
  * I/O failure is reported separately and does NOT make the attempt retryable —
305
311
  * the capture itself worked).
306
312
  * - `redirect`: the matched route redirects, so there is no shell to capture.
307
- * - `no-shell`: the prelude came back trivial (no <body>) OR captureShellHTML
308
- * rejected with our own abort. This is the only RETRYABLE outcome.
313
+ * - `no-shell`: captureShellHTML returned null because the prelude was unusable
314
+ * or its private capture abort landed before the shell completed. This is the
315
+ * only RETRYABLE outcome.
309
316
  */
310
317
  type CaptureAttemptOutcome = "stored" | "redirect" | "no-shell" | "refused";
311
318
  /**
@@ -327,9 +334,9 @@ type CaptureAttemptStats = Pick<ShellCaptureDebugEvent, "barrierWaitMs" | "write
327
334
  * when we froze the shell; the attempt itself warmed the module graph, so a second
328
335
  * attempt a short beat later usually completes the shell in the SAME background
329
336
  * task. That kills the old multi-request warmup where the caller had to re-issue
330
- * several HTTP requests before a capture stuck. We retry ONLY on `no-shell` (and a
331
- * defensively-caught abort); a genuine render error is NOT retried — it propagates
332
- * to scheduleShellCapture's reportCacheError. See docs/design/ppr-shell-resume.md.
337
+ * several HTTP requests before a capture stuck. We retry ONLY on `no-shell`; a
338
+ * genuine render error is NOT retried — it propagates to scheduleShellCapture's
339
+ * reportCacheError. See docs/design/ppr-shell-resume.md.
333
340
  *
334
341
  * `retryDelayMs` is a parameter (defaulting to the module const) so unit tests can
335
342
  * drive the retry without a real 400ms wall-clock wait.
@@ -369,9 +376,10 @@ export declare function deriveShellCaptureContext(reqCtx: RequestContext<any>, d
369
376
  * capture worked; only the store I/O failed). `ssrModule.captureShellHTML` MUST be
370
377
  * present (eligibility is checked before scheduling).
371
378
  *
372
- * A `no-shell` result (trivial prelude, or a defensively-caught abort) is the only
373
- * retryable outcome; a genuine (non-abort) captureShellHTML error propagates so it
374
- * reaches reportCacheError and is NOT retried.
379
+ * A `no-shell` result from captureShellHTML is the only retryable outcome. Every
380
+ * captureShellHTML error propagates to reportCacheError and is NOT retried. The
381
+ * capture handler converts only its own private abort sentinel to null before this
382
+ * layer sees it.
375
383
  */
376
384
  declare function captureAndStoreShell(ssrModule: SSRModule, rscStream: ReadableStream<Uint8Array>, handleStore: HandleStore, reqCtx: RequestContext<any>, capture: ShellCaptureDescriptor, stats?: CaptureAttemptStats, captureStartedAt?: number): Promise<Exclude<CaptureAttemptOutcome, "redirect">>;
377
385
  export { runShellCapture, captureAndStoreShell, delay, SHELL_CAPTURE_RETRY_DELAY_MS, };
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Stream-idle timeout — the enforcement for `timeouts.streamIdleMs` (the
3
+ * "stream-idle" TimeoutPhase, reserved since the timeout API landed).
4
+ *
5
+ * WHY: nothing else bounds a streaming response body's lifetime.
6
+ * `renderStartMs` only bounds time-to-Response-construction; once the body is
7
+ * streaming, a single never-settling promise embedded in the payload (a
8
+ * deferred loader that never resolves, an orphaned ctx.rendered() barrier)
9
+ * holds the Flight/HTML stream — and the client's connection — open forever.
10
+ * Deferred HANDLES have a 10s auto-resolve net (defer.ts); loader promises
11
+ * and everything else have none. Field-observed as multi-second dead
12
+ * connections only closed by client cancellation.
13
+ *
14
+ * SEMANTICS — end-to-end idle flow, deliberately: the watchdog re-arms on
15
+ * every chunk that flows THROUGH to the client, so "idle" means "no bytes
16
+ * reached the wire for N ms". A stalled slow client counts as idle the same
17
+ * as a wedged producer — the two are indistinguishable here without buffering,
18
+ * and buffering is banned on the streaming path. Pick budgets accordingly
19
+ * (generous, seconds not millis); the feature is OPT-IN (unset = today's
20
+ * unbounded behavior, and the `timeout` shorthand deliberately does not apply
21
+ * to streamIdleMs — see resolveTimeouts).
22
+ *
23
+ * ON TRIP: the client-facing stream is errored with a RouterTimeoutError
24
+ * ("stream-idle") — the host terminates the response visibly (truncated
25
+ * chunked encoding), which is debuggable, unlike a silent clean close that
26
+ * would present a half-document as complete. Erroring the transform makes the
27
+ * pipe cancel its SOURCE, which aborts React's fizz/flight render — but only
28
+ * when the watchdog's branch is the sole consumer. When an upstream layer
29
+ * teed or cloned the body first (the document-cache MISS drain,
30
+ * response-cache clones), the watchdog holds one tee branch: tee semantics
31
+ * cancel the underlying source only once EVERY branch cancels, so the cache
32
+ * branch keeps the wedged render alive, bounded only by the platform's
33
+ * waitUntil budget — exactly as it was before this feature. The client-facing
34
+ * bound is unconditional; source teardown is best-effort. `onTimeout` does
35
+ * NOT apply: the response already left the handler, so no replacement
36
+ * Response can be served — the trip is reported via onError + the
37
+ * request.timeout telemetry event instead.
38
+ *
39
+ * COST: one identity TransformStream hop per chunk, only when the timeout is
40
+ * enabled. Never buffers, never reads ahead, adds no work when disabled.
41
+ */
42
+ import { RouterTimeoutError } from "../router/timeout.js";
43
+ /** What the watchdog observed when it tripped. */
44
+ export interface StreamIdleTrip {
45
+ /** ms since the watchdog armed (response handoff) — the stream's lifetime. */
46
+ totalMs: number;
47
+ /** Chunks forwarded before the trip. */
48
+ chunks: number;
49
+ /** The error the client-facing stream was terminated with. */
50
+ error: RouterTimeoutError;
51
+ }
52
+ /**
53
+ * Wrap `response`'s body in an idle watchdog. Returns a NEW Response with the
54
+ * same status/headers whose body errors (and cancels the source) once no chunk
55
+ * has flowed for `idleMs`. Callers gate on isTimeoutEnabled + body presence +
56
+ * NOT a websocket upgrade (a 101/webSocket response must never be
57
+ * reconstructed); this function assumes those checks were made. `onTrip` fires
58
+ * at most once, after the client-facing stream has been errored.
59
+ */
60
+ export declare function applyStreamIdleTimeout(response: Response, idleMs: number, onTrip: (trip: StreamIdleTrip) => void): Response;
@@ -6,10 +6,11 @@
6
6
  * fragment STRINGS ride the outgoing payload verbatim inside these envelopes
7
7
  * — the outer Flight render copies a string instead of re-serializing a whole
8
8
  * element tree — and the CONSUMER expands each envelope through its own Flight
9
- * deserializer: the SSR resume pass (ssr/ssr-root.tsx) and browser hydration
10
- * (browser/rsc-router.tsx). Each fragment is its own row space, decoded
11
- * independently, exactly like the segment codec's per-record decode — so
12
- * there is no Flight row-id collision or shared-row dedupe hazard.
9
+ * deserializer: the SSR resume pass (ssr/ssr-root.tsx), browser hydration, and
10
+ * capability-gated partial navigation/prefetch decoders. Each fragment is its
11
+ * own row space, decoded independently, exactly like the segment codec's
12
+ * per-record decode — so there is no Flight row-id collision or shared-row
13
+ * dedupe hazard.
13
14
  *
14
15
  * Envelopes appear ONLY on fields that hold ReactNodes (component / layout /
15
16
  * loading). A plain object is never a valid ReactNode, so the marker cannot
@@ -20,6 +21,15 @@
20
21
  * it must stay dependency-free: no plugin-rsc, no request-context.
21
22
  */
22
23
  import type { ResolvedSegment } from "./types.js";
24
+ /** Partial-request capability gate for browser-side fragment expansion. */
25
+ export declare const SEGMENT_FRAGMENT_CAPABILITY_HEADER: string;
26
+ /** One-shot request marker that forces server-side fragment validation. */
27
+ export declare const SEGMENT_FRAGMENT_RECOVERY_HEADER: string;
28
+ /** A stored fragment failed in its consumer-side Flight decoder. */
29
+ export declare class SegmentFragmentDecodeError extends Error {
30
+ readonly cause: unknown;
31
+ constructor(cause: unknown);
32
+ }
23
33
  /**
24
34
  * One RSC-encoded fragment traveling inside an RscPayload segment field.
25
35
  * `f` is the stored Flight document (segment-codec output) for that field.
@@ -49,6 +59,19 @@ export type FragmentDecoder = (stream: ReadableStream<Uint8Array>) => Promise<un
49
59
  * the same posture as a parseable-but-mismatched postponed blob.
50
60
  */
51
61
  export declare function expandSegmentFragments(segments: ResolvedSegment[] | undefined, decode: FragmentDecoder): Promise<void>;
62
+ /**
63
+ * Expand every envelope in an RscPayload-shaped object, in place. Decode
64
+ * failures use a distinct error so partial navigation can retry once through
65
+ * the server's decode-and-evict path. The
66
+ * hasSegmentFragments pre-scan keeps envelope-free payloads — every
67
+ * non-replay response — to one synchronous field walk; this runs on every
68
+ * navigation and prefetch decode.
69
+ */
70
+ export declare function expandPayloadFragments(payload: {
71
+ metadata?: {
72
+ segments?: ResolvedSegment[];
73
+ };
74
+ }, decode: FragmentDecoder): Promise<void>;
52
75
  /**
53
76
  * True when any segment carries an unexpanded envelope. Cheap scan used by
54
77
  * consumers that only need to know whether an expansion pass is required.