@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
@@ -24,6 +24,11 @@ import { mayNeedSSR } from "../rsc/ssr-setup.js";
24
24
  import { cacheKeyBase } from "./cache-key-utils.js";
25
25
  import { runBackground } from "./background-task.js";
26
26
  import { reportCacheError } from "./cache-error.js";
27
+ import { observePhase, PHASES } from "../router/instrument.js";
28
+ import {
29
+ SEGMENT_FRAGMENT_CAPABILITY_HEADER,
30
+ SEGMENT_FRAGMENT_RECOVERY_HEADER,
31
+ } from "../segment-fragments.js";
27
32
 
28
33
  const CACHE_STATUS_HEADER = "x-document-cache-status";
29
34
 
@@ -260,6 +265,8 @@ export function createDocumentCacheMiddleware<TEnv = any>(
260
265
  // pipeline (stripInternalParams), so _rsc_partial, _rsc_segments, etc.
261
266
  // are not visible on ctx.url in production.
262
267
  const rawUrl = new URL(ctx.request.url);
268
+ const isFragmentRecovery =
269
+ ctx.request.headers.get(SEGMENT_FRAGMENT_RECOVERY_HEADER) === "1";
263
270
 
264
271
  // Only cache GET requests — mutations and other methods must not be cached
265
272
  if (ctx.request.method !== "GET") {
@@ -317,6 +324,16 @@ export function createDocumentCacheMiddleware<TEnv = any>(
317
324
  const clientSegments = rawUrl.searchParams.get("_rsc_segments") || "";
318
325
  const segmentHash =
319
326
  isPartial && clientSegments ? `:${hashSegmentIds(clientSegments)}` : "";
327
+ // Fragment envelopes require a capable Rango decoder. Keep that wire
328
+ // variant out of the context-less/legacy partial slot; this middleware
329
+ // returns hits before request classification and cannot rely on the
330
+ // match-time capability gate.
331
+ const fragmentSuffix =
332
+ isPartial &&
333
+ (ctx.request.headers.get(SEGMENT_FRAGMENT_CAPABILITY_HEADER) === "1" ||
334
+ isFragmentRecovery)
335
+ ? ":fragments"
336
+ : "";
320
337
  const typeSuffix = isRscRequest ? ":rsc" : ":html";
321
338
 
322
339
  // Default key rides the shared host-namespaced base (cacheKeyBase) so the
@@ -326,7 +343,7 @@ export function createDocumentCacheMiddleware<TEnv = any>(
326
343
  // owns its own namespacing (auto-prefixing host would silently change their
327
344
  // existing keys and double any host they already include).
328
345
  const cacheKey = keyGenerator
329
- ? keyGenerator(url) + segmentHash + typeSuffix
346
+ ? keyGenerator(url) + segmentHash + fragmentSuffix + typeSuffix
330
347
  : cacheKeyBase(
331
348
  url.host,
332
349
  url.pathname,
@@ -335,9 +352,15 @@ export function createDocumentCacheMiddleware<TEnv = any>(
335
352
  requestCtx?._searchParamsFilter,
336
353
  ) +
337
354
  segmentHash +
355
+ fragmentSuffix +
338
356
  typeSuffix;
339
357
  // 1. Check cache
340
- const cached = await store.getResponse(cacheKey);
358
+ // Recovery must reach CacheScope's server decoder so it can evict the bad
359
+ // segment. Treat it as a miss, then let the ordinary write path replace
360
+ // the corrupt fragment-capable response with the valid fallback bytes.
361
+ const cached = isFragmentRecovery
362
+ ? null
363
+ : await store.getResponse(cacheKey);
341
364
 
342
365
  if (cached && cached.response.status === 200) {
343
366
  if (!cached.shouldRevalidate) {
@@ -356,29 +379,44 @@ export function createDocumentCacheMiddleware<TEnv = any>(
356
379
 
357
380
  runBackground(requestCtx, async () => {
358
381
  try {
359
- // Re-establish the request-context ALS around the background
360
- // re-render: next() re-runs the full handler pipeline, and on
361
- // workerd a waitUntil task runs detached from the request's I/O
362
- // context, so a handler/component reading getRequestContext() would
363
- // otherwise throw. Same fix as the route-level/use-cache background
364
- // revalidation paths.
365
- const fresh = await runWithRequestContext(requestCtx, () => next());
366
- const directives = shouldCacheResponse(fresh);
367
-
368
- if (directives && fresh.body) {
369
- // Background revalidation: nothing streams to a client, so drain
370
- // the fresh render fully before snapshotting tags (same
371
- // render-complete barrier as the miss path).
372
- const body = await new Response(fresh.body).arrayBuffer();
373
- await store.putResponse!(
374
- cacheKey,
375
- new Response(body, fresh),
376
- directives.sMaxAge!,
377
- directives.staleWhileRevalidate,
378
- collectRequestTags(requestCtx),
379
- );
380
- log(`[DocumentCache] REVALIDATED ${typeLabel}: ${url.pathname}`);
381
- }
382
+ // Re-establish the request-context ALS around the whole background
383
+ // task: next() re-runs the full handler pipeline, and on workerd a
384
+ // waitUntil task runs detached from the request's I/O context, so a
385
+ // handler/component reading getRequestContext() would otherwise
386
+ // throw. Same fix as the route-level/use-cache background
387
+ // revalidation paths. The rango.background span (kind=
388
+ // document-revalidation) wraps the re-render AND the store write so
389
+ // the task's spans — the re-run's own rango.* set and the drain/put
390
+ // platform spans — nest under one explanatory parent instead of
391
+ // dangling under the ended foreground phases. Running the put
392
+ // inside the ALS matches the MISS path, where putResponse already
393
+ // executes within the foreground request context.
394
+ await runWithRequestContext(requestCtx, () =>
395
+ observePhase(
396
+ PHASES.background("document-revalidation"),
397
+ async () => {
398
+ const fresh = await next();
399
+ const directives = shouldCacheResponse(fresh);
400
+
401
+ if (!directives || !fresh.body) return;
402
+
403
+ // Background revalidation: nothing streams to a client, so
404
+ // drain the fresh render fully before snapshotting tags
405
+ // (same render-complete barrier as the miss path).
406
+ const body = await new Response(fresh.body).arrayBuffer();
407
+ await store.putResponse!(
408
+ cacheKey,
409
+ new Response(body, fresh),
410
+ directives.sMaxAge!,
411
+ directives.staleWhileRevalidate,
412
+ collectRequestTags(requestCtx),
413
+ );
414
+ log(
415
+ `[DocumentCache] REVALIDATED ${typeLabel}: ${url.pathname}`,
416
+ );
417
+ },
418
+ ),
419
+ );
382
420
  } catch (error) {
383
421
  // Pass requestCtx explicitly: this runs in a detached waitUntil task
384
422
  // where the ALS context is gone, so onError only fires if we hand it
@@ -37,13 +37,14 @@ export interface ReadThroughItemConfig<T> {
37
37
  /** Execute the underlying function/loader on miss or revalidation */
38
38
  execute: () => Promise<T>;
39
39
  /**
40
- * Optional wrapper applied to execute() ONLY on the background
41
- * stale-revalidation path (not the foreground miss, where the caller's context
42
- * is already established). Used to re-establish the request-context ALS, which
43
- * a detached waitUntil task loses on workerd. Defaults to calling execute()
44
- * directly.
40
+ * Optional wrapper applied to the WHOLE background stale-revalidation task
41
+ * (execute + serialize + setItem) — not the foreground miss, where the
42
+ * caller's context is already established. Used to re-establish the
43
+ * request-context ALS, which a detached waitUntil task loses on workerd,
44
+ * and to open the rango.background span so the write is covered too.
45
+ * Defaults to running the task directly.
45
46
  */
46
- wrapBackground?: (run: () => Promise<T>) => Promise<T>;
47
+ wrapBackground?: (run: () => Promise<void>) => Promise<void>;
47
48
  /** Serialize result for storage. Return null to skip caching. */
48
49
  serialize: (data: T) => Promise<string | null>;
49
50
  /** Deserialize cached value back to the original type */
@@ -111,20 +112,25 @@ export async function readThroughItem<T>(
111
112
 
112
113
  // Stale hit — return stale data, revalidate in background
113
114
  onStale?.(cached);
115
+ // The whole task — execute AND serialize/setItem — goes through
116
+ // wrapBackground so the ALS re-establishment and the rango.background
117
+ // span cover the write too (a slow or failing store write is part of
118
+ // the revalidation, not an untraced tail). The foreground miss below
119
+ // stays unwrapped: its context is already established.
120
+ const revalidateTask = async () => {
121
+ const fresh = await execute();
122
+ const serialized = await serialize(fresh);
123
+ if (serialized !== null) {
124
+ await setItem(key, serialized, storeOptions);
125
+ }
126
+ };
114
127
  runBackground(
115
128
  host,
116
129
  async () => {
117
130
  try {
118
- // Re-establish the caller's context (request-context ALS) for the
119
- // detached background execution; the foreground miss below calls
120
- // execute() directly since its context is already established.
121
- const fresh = await (wrapBackground
122
- ? wrapBackground(execute)
123
- : execute());
124
- const serialized = await serialize(fresh);
125
- if (serialized !== null) {
126
- await setItem(key, serialized, storeOptions);
127
- }
131
+ await (wrapBackground
132
+ ? wrapBackground(revalidateTask)
133
+ : revalidateTask());
128
134
  } catch (error) {
129
135
  reportCacheError(
130
136
  error,
@@ -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
 
38
42
  import { _getRequestContext } from "../server/request-context.js";
@@ -91,8 +95,8 @@ const cloudflareSpanRunner: SpanRunner = (name, fn) => {
91
95
  /**
92
96
  * Create the tracing config for a Cloudflare router. Pass the result to
93
97
  * `createRouter({ tracing })`. Spans are emitted for the request, middleware,
94
- * action, loaders, handler, render, and ssr phases; pass `spans` to turn
95
- * individual phases off.
98
+ * action, loaders, handler, render, ssr, response, and background phases; pass
99
+ * `spans` to turn individual phases off.
96
100
  *
97
101
  * @see createOTelTracing (`@rangojs/router`) for the same slot on any platform
98
102
  * 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
@@ -85,6 +83,16 @@ function currentRouteName(): string | undefined {
85
83
  : undefined;
86
84
  }
87
85
 
86
+ /**
87
+ * The detached background lanes wrapped by the rango.background span (the
88
+ * `rango.background.kind` attribute). One name per task execution boundary.
89
+ */
90
+ export type BackgroundTaskKind =
91
+ | "shell-capture"
92
+ | "document-revalidation"
93
+ | "loader-revalidation"
94
+ | "use-cache-revalidation";
95
+
88
96
  /**
89
97
  * The router's observable phases. One definition per phase keeps the `rango.*`
90
98
  * span names, perf-metric labels, and identifying attributes from spreading
@@ -176,6 +184,43 @@ export const PHASES = {
176
184
  tracePhase: "ssr",
177
185
  spanName: "rango.ssr",
178
186
  } as PhaseSpec,
187
+
188
+ /**
189
+ * One detached background task (waitUntil work that outlives the response):
190
+ * a PPR shell capture or an SWR background revalidation. The wrapper is the
191
+ * explanatory parent for everything the task does — the platform's automatic
192
+ * KV/fetch/cache spans, and (for the revalidation lanes) the re-run's own
193
+ * rango.* phase spans — so post-handoff work never appears as orphan spans
194
+ * dangling under an ended foreground phase. `kind` identifies the lane.
195
+ * Span only (metric:false): the perf timeline is finalized with the
196
+ * response, so a background metric could never reach it. The shell-capture
197
+ * lane keeps its INNER phase spans suppressed (derived context strips
198
+ * _tracing — a capture re-render duplicating the foreground's span set was
199
+ * the original #670 leak); the revalidation lanes keep theirs, nested here.
200
+ */
201
+ background: (kind: BackgroundTaskKind): PhaseSpec => ({
202
+ metric: false,
203
+ tracePhase: "background",
204
+ spanName: "rango.background",
205
+ attributes: { "rango.background.kind": kind },
206
+ }),
207
+
208
+ /** Final response construction + host handoff: opened AFTER downstream
209
+ * middleware/core execution returns a Response, wrapping only finalization
210
+ * (redirect interception/guarding, Server-Timing mutation, final response
211
+ * selection) and ending immediately before the router handler returns the
212
+ * response to the host. Handoff-bound, never drain-bound — it must never
213
+ * read, tee, buffer, or await response.body, so it cannot regress streaming.
214
+ * At most one per traced request; absent when the request throws before a
215
+ * response exists. Span only (metric:false) — a co-emitted metric would
216
+ * circularly mutate the Server-Timing header this phase itself finalizes.
217
+ * On Cloudflare it may report 0 ms (non-I/O timers are frozen); its position
218
+ * and attributes (status/mode/body kind, set by the handler) are the value. */
219
+ response: {
220
+ metric: false,
221
+ tracePhase: "response",
222
+ spanName: "rango.response",
223
+ } as PhaseSpec,
179
224
  } as const;
180
225
 
181
226
  /** Apply a phase spec's static attributes to a span (the no-op span ignores them). */
@@ -209,14 +254,15 @@ function recordPhaseMetric(
209
254
  * returns a promise both the metric duration and the span end when it settles.
210
255
  *
211
256
  * This is the ONLY phase primitive: every phase (request/middleware/action/
212
- * loader/handler/render/ssr) is construction-bound the span and metric settle
213
- * when fn's own work completes (for the streaming phases, when the RSC/HTML
214
- * stream is constructed, NOT when the body drains). Instrumentation is strictly
215
- * best-effort: it never wraps or buffers the response and adds no work on the
216
- * streaming path, so it cannot regress response latency or streaming. A loader
217
- * that resolves while the body streams therefore keeps a rango.loader span that
218
- * may extend past its render parent overlapping spans are valid; the loader
219
- * really did take that long.
257
+ * loader/handler/render/ssr/response/background) settles when fn's own work
258
+ * completes. For streaming phases that means when the RSC/HTML stream is
259
+ * constructed, NOT when the body drains; for response, when finalization hands
260
+ * the stream to the host. Instrumentation is strictly best-effort: it never
261
+ * wraps or buffers the response and adds no work on the streaming path, so it
262
+ * cannot regress response latency or streaming. A loader that resolves while
263
+ * the body streams therefore keeps a rango.loader span that may extend past its
264
+ * render parent — overlapping spans are valid; the loader really did take that
265
+ * long.
220
266
  *
221
267
  * Reads the metrics store + tracing off the RequestContext ALS, which is active
222
268
  * for the WHOLE request — contrast observeEvent, which reads the RouterContext
@@ -26,6 +26,7 @@ import {
26
26
  _getRequestContext,
27
27
  runWithRequestContext,
28
28
  } from "../../server/request-context.js";
29
+ import { observePhase, PHASES } from "../instrument.js";
29
30
  import { sortedRouteParams } from "../../cache/cache-key-utils.js";
30
31
  import {
31
32
  resolveTtl,
@@ -328,7 +329,17 @@ function executeLoaderData<TEnv>(
328
329
  setItem: (k, v, o) => store.setItem!(k, v, o),
329
330
  key,
330
331
  execute: () => runMiss(loaderEntry.loader),
331
- wrapBackground: (run) => runWithRequestContext(requestCtxForExecute, run),
332
+ // The rango.background span (kind=loader-revalidation) wraps the WHOLE
333
+ // stale revalidation — the re-execution AND the serialize/setItem write
334
+ // (read-through-swr routes the full task through wrapBackground) — so
335
+ // the loader's rango.loader span, its fetch/KV platform spans, and the
336
+ // store write all nest under one explanatory parent instead of dangling
337
+ // under the ended foreground phases. Inside runWithRequestContext so
338
+ // observePhase can read tracing on workerd (ALS detaches in waitUntil).
339
+ wrapBackground: (run) =>
340
+ runWithRequestContext(requestCtxForExecute, () =>
341
+ observePhase(PHASES.background("loader-revalidation"), run),
342
+ ),
332
343
  serialize: (d) => codec.serializeResult(d),
333
344
  deserialize: (v) => codec.deserializeResult(v),
334
345
  storeOptions: { ttl, swr, tags },
@@ -33,6 +33,7 @@ import {
33
33
  isTraceActive,
34
34
  } from "../logging.js";
35
35
  import { resolveLoaderData } from "./loader-cache.js";
36
+ import { entryLoadingMasksLoaders } from "./loader-mask.js";
36
37
  import {
37
38
  handleHandlerResult,
38
39
  warnOnStreamedResponse,
@@ -206,6 +207,15 @@ export async function resolveLoadersWithRevalidation<TEnv>(
206
207
  // onError/loader.error. isPartial flags the reporting phase accordingly.
207
208
  const errorContext = { ...buildLoaderErrorContext(ctx), isPartial: true };
208
209
 
210
+ // PPR lane decision, same rule as the fresh funnel (fresh.ts): an entry
211
+ // without renderable loading() puts its loaders on the BAKE lane. The key
212
+ // must thread through EVERY resolveLoaderData funnel — omitting it here made
213
+ // a capture-active context whole-container-mask every loader on this path
214
+ // (a never-settling promise for a loader that should have executed).
215
+ // Outside capture the key only activates the _shellLoaderSeed overlay,
216
+ // which document-only serveShellHit seeds — inert for partial requests.
217
+ const bakeLane = !entryLoadingMasksLoaders(entry.loading);
218
+
209
219
  const loadersToRun = revalidationChecks.filter((c) => c.shouldRun);
210
220
  const segments: ResolvedSegment[] = loadersToRun.map(
211
221
  ({ loaderEntry, loader, segmentId, index }) => ({
@@ -218,7 +228,12 @@ export async function resolveLoadersWithRevalidation<TEnv>(
218
228
  loaderId: loader.$$id,
219
229
  loaderData: deps.wrapLoaderPromise(
220
230
  runInsideLoaderScope(() =>
221
- resolveLoaderData(loaderEntry, ctx, ctx.pathname),
231
+ resolveLoaderData(
232
+ loaderEntry,
233
+ ctx,
234
+ ctx.pathname,
235
+ bakeLane ? segmentId : null,
236
+ ),
222
237
  ),
223
238
  entry,
224
239
  segmentId,
@@ -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,
@@ -11,7 +11,18 @@ export interface RouterTimeouts {
11
11
  actionMs?: number;
12
12
  /** Timeout for initial render/response production (ms). */
13
13
  renderStartMs?: number;
14
- /** Timeout for idle streaming after render starts (ms). Reserved for PR 2. */
14
+ /**
15
+ * Timeout for idle streaming after the response is handed off (ms): when no
16
+ * chunk flows to the client for this long, the body stream is errored with a
17
+ * RouterTimeoutError ("stream-idle") and the source render is canceled —
18
+ * bounding hangs from never-settling promises embedded in the payload.
19
+ * END-TO-END semantics: a stalled slow client counts as idle the same as a
20
+ * wedged producer, so pick generous budgets (seconds). Opt-in (unset =
21
+ * unbounded, and the `timeout` shorthand deliberately does not apply);
22
+ * onTimeout does NOT fire — no replacement Response can be served
23
+ * mid-stream — the trip reports via onError + the request.timeout event.
24
+ * Enforcement: rsc/stream-idle.ts, wired at the handler's response tail.
25
+ */
15
26
  streamIdleMs?: number;
16
27
  }
17
28
 
@@ -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.
@@ -68,18 +85,17 @@ export type TracePhase =
68
85
  | "loader"
69
86
  | "handler"
70
87
  | "render"
71
- | "ssr";
88
+ | "ssr"
89
+ | "response"
90
+ | "background";
72
91
 
73
- /** Per-phase span toggles. Omitted phases default to enabled. */
74
- export interface TracePhaseToggles {
75
- request?: boolean;
76
- middleware?: boolean;
77
- action?: boolean;
78
- loader?: boolean;
79
- handler?: boolean;
80
- render?: boolean;
81
- ssr?: boolean;
82
- }
92
+ /**
93
+ * Per-phase span toggles. Omitted phases default to enabled. Derived from
94
+ * TracePhase so a new phase cannot be silently missing here — the other phase
95
+ * lists in this file (ALL_PHASES_ON, resolveTracing) are exhaustiveness-checked
96
+ * by their Record<TracePhase, boolean> annotations.
97
+ */
98
+ export type TracePhaseToggles = Partial<Record<TracePhase, boolean>>;
83
99
 
84
100
  /**
85
101
  * The option pair shared by every tracing factory (enabled master switch +
@@ -125,6 +141,8 @@ const ALL_PHASES_ON: Record<TracePhase, boolean> = {
125
141
  handler: true,
126
142
  render: true,
127
143
  ssr: true,
144
+ response: true,
145
+ background: true,
128
146
  };
129
147
 
130
148
  /**
@@ -153,6 +171,8 @@ export function resolveTracing(
153
171
  handler: spans.handler ?? true,
154
172
  render: spans.render ?? true,
155
173
  ssr: spans.ssr ?? true,
174
+ response: spans.response ?? true,
175
+ background: spans.background ?? true,
156
176
  }
157
177
  : ALL_PHASES_ON,
158
178
  };