@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
@@ -22,11 +22,13 @@ import { reportCacheError } from "../cache/cache-error.js";
22
22
  import { runBackground } from "../cache/background-task.js";
23
23
  import {
24
24
  CaptureQueueFullError,
25
+ CaptureQueueWaitTimeoutError,
25
26
  enqueueSerializedCapture,
26
27
  } from "./capture-queue.js";
27
28
  import { SHELL_CAPTURE_MAX_WAIT_MS } from "./shell-capture-constants.js";
28
29
  import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
29
30
  import { observePhase, PHASES } from "../router/instrument.js";
31
+ import type { TraceSpan } from "../router/tracing.js";
30
32
  import {
31
33
  runWithRequestContext,
32
34
  setRequestContextParams,
@@ -416,6 +418,10 @@ export interface ShellCaptureDebugEvent {
416
418
  * - skip-backoff: the key is inside its refused-capture backoff window and
417
419
  * the capture was not attempted
418
420
  * - skip-capacity: the isolate capture queue is full; a later request may retry
421
+ * - skip-queue-timeout: the capture waited past CAPTURE_QUEUE_WAIT_BUDGET_MS
422
+ * behind other captures and was dropped unrun (no backoff — the route is
423
+ * not doomed, the isolate was busy; a later request re-probes). Carries
424
+ * queueWaitMs.
419
425
  * - backoff: the key entered (or escalated) backoff after a terminal
420
426
  * no-shell — carries the new backoff state
421
427
  */
@@ -428,6 +434,7 @@ export interface ShellCaptureDebugEvent {
428
434
  | "skip-in-flight"
429
435
  | "skip-backoff"
430
436
  | "skip-capacity"
437
+ | "skip-queue-timeout"
431
438
  | "backoff";
432
439
  /** Attempt number (1 = first, 2 = in-place retry). Absent on skips. */
433
440
  attempt?: number;
@@ -457,6 +464,8 @@ export interface ShellCaptureDebugEvent {
457
464
  backoffFailures?: number;
458
465
  /** Ms remaining in the key's backoff window, when one exists. */
459
466
  backoffRemainingMs?: number;
467
+ /** Ms the capture waited in the serialized queue (skip-queue-timeout). */
468
+ queueWaitMs?: number;
460
469
  }
461
470
 
462
471
  /**
@@ -503,6 +512,9 @@ export function describeShellCaptureEvent(
503
512
  if (event.backoffRemainingMs !== undefined) {
504
513
  parts.push(`backoff-remaining=${event.backoffRemainingMs}ms`);
505
514
  }
515
+ if (event.queueWaitMs !== undefined) {
516
+ parts.push(`queue-wait=${event.queueWaitMs}ms`);
517
+ }
506
518
  return parts.join(" ");
507
519
  }
508
520
 
@@ -853,7 +865,7 @@ export function scheduleShellCapture(
853
865
  return;
854
866
  }
855
867
  inFlightCaptures.add(key);
856
- const captureTask = async () => {
868
+ const captureTask = async (span: TraceSpan) => {
857
869
  try {
858
870
  const setupUrl = descriptor.navigationOnly
859
871
  ? stripInternalParams(url)
@@ -881,6 +893,7 @@ export function scheduleShellCapture(
881
893
  resolvedSsrModule,
882
894
  descriptor,
883
895
  );
896
+ span.setAttribute("rango.background.outcome", outcome);
884
897
  // Update the negative cache off the terminal outcome. A stored shell clears
885
898
  // any prior backoff; a `no-shell` (after the in-place retry) backs the key
886
899
  // off so the next requests don't re-probe it. A `redirect` has no shell but
@@ -899,6 +912,7 @@ export function scheduleShellCapture(
899
912
  // context is gone. A genuine failure recurs, so back it off too (re-probe
900
913
  // once per window, not every request) and report it once.
901
914
  markCaptureBackoff(key);
915
+ span.setAttribute("rango.background.outcome", "error");
902
916
  publishCaptureDebugEvent(descriptor, {
903
917
  key,
904
918
  outcome: "error",
@@ -914,20 +928,58 @@ export function scheduleShellCapture(
914
928
  // capture makes the sibling freeze a trivial prelude and store nothing
915
929
  // (rotating eternal-MISS victims on GH runners). The stampede guard above
916
930
  // stays per-key (dedupe while queued); the queue is cross-key.
931
+ //
932
+ // The whole serialized task — queue wait INCLUDED — is wrapped in the
933
+ // rango.background span (kind=shell-capture). The span is the explanatory
934
+ // parent for the capture's platform KV/fetch/cache spans (the capture's own
935
+ // rango.* phase spans stay suppressed — deriveShellCaptureContext strips
936
+ // _tracing), and queue_wait_ms makes a capture parked behind a slow
937
+ // predecessor link visible instead of reading as an unexplained dead gap
938
+ // (observed in production: ~24s of zero I/O before capture start).
939
+ // observePhase reads tracing off the ALS context captured when runBackground
940
+ // registered the task — the foreground request context, tracing intact.
917
941
  const serializedTask = async () => {
918
- try {
919
- await enqueueSerializedCapture(captureTask);
920
- } catch (error) {
921
- if (error instanceof CaptureQueueFullError) {
922
- inFlightCaptures.delete(key);
923
- publishCaptureDebugEvent(descriptor, {
924
- key,
925
- outcome: "skip-capacity",
942
+ await observePhase(PHASES.background("shell-capture"), async (span) => {
943
+ span.setAttribute("rango.shell_key", key);
944
+ const queueStart = performance.now();
945
+ try {
946
+ await enqueueSerializedCapture(() => {
947
+ span.setAttribute(
948
+ "rango.background.queue_wait_ms",
949
+ Math.round(performance.now() - queueStart),
950
+ );
951
+ return captureTask(span);
926
952
  });
927
- return;
953
+ } catch (error) {
954
+ if (error instanceof CaptureQueueFullError) {
955
+ inFlightCaptures.delete(key);
956
+ span.setAttribute("rango.background.outcome", "skip-capacity");
957
+ publishCaptureDebugEvent(descriptor, {
958
+ key,
959
+ outcome: "skip-capacity",
960
+ });
961
+ return;
962
+ }
963
+ if (error instanceof CaptureQueueWaitTimeoutError) {
964
+ // Dropped unrun after waiting past the queue budget: no backoff (the
965
+ // route is not doomed, the isolate was busy) — a later request
966
+ // re-probes the key.
967
+ inFlightCaptures.delete(key);
968
+ span.setAttribute("rango.background.outcome", "skip-queue-timeout");
969
+ span.setAttribute(
970
+ "rango.background.queue_wait_ms",
971
+ Math.round(error.waitedMs),
972
+ );
973
+ publishCaptureDebugEvent(descriptor, {
974
+ key,
975
+ outcome: "skip-queue-timeout",
976
+ queueWaitMs: Math.round(error.waitedMs),
977
+ });
978
+ return;
979
+ }
980
+ throw error;
928
981
  }
929
- throw error;
930
- }
982
+ });
931
983
  };
932
984
  // The capture's own task must NOT enter reqCtx._pendingBackgroundTasks: the
933
985
  // capture drains that list before rendering (the write-barrier ordering edge),
@@ -945,8 +997,9 @@ export function scheduleShellCapture(
945
997
  * I/O failure is reported separately and does NOT make the attempt retryable —
946
998
  * the capture itself worked).
947
999
  * - `redirect`: the matched route redirects, so there is no shell to capture.
948
- * - `no-shell`: the prelude came back trivial (no <body>) OR captureShellHTML
949
- * rejected with our own abort. This is the only RETRYABLE outcome.
1000
+ * - `no-shell`: captureShellHTML returned null because the prelude was unusable
1001
+ * or its private capture abort landed before the shell completed. This is the
1002
+ * only RETRYABLE outcome.
950
1003
  */
951
1004
  type CaptureAttemptOutcome = "stored" | "redirect" | "no-shell" | "refused";
952
1005
 
@@ -995,9 +1048,9 @@ type CaptureAttemptStats = Pick<
995
1048
  * when we froze the shell; the attempt itself warmed the module graph, so a second
996
1049
  * attempt a short beat later usually completes the shell in the SAME background
997
1050
  * task. That kills the old multi-request warmup where the caller had to re-issue
998
- * several HTTP requests before a capture stuck. We retry ONLY on `no-shell` (and a
999
- * defensively-caught abort); a genuine render error is NOT retried — it propagates
1000
- * to scheduleShellCapture's reportCacheError. See docs/design/ppr-shell-resume.md.
1051
+ * several HTTP requests before a capture stuck. We retry ONLY on `no-shell`; a
1052
+ * genuine render error is NOT retried — it propagates to scheduleShellCapture's
1053
+ * reportCacheError. See docs/design/ppr-shell-resume.md.
1001
1054
  *
1002
1055
  * `retryDelayMs` is a parameter (defaulting to the module const) so unit tests can
1003
1056
  * drive the retry without a real 400ms wall-clock wait.
@@ -1329,6 +1382,10 @@ export function deriveShellCaptureContext(
1329
1382
  derivedCtx._explicitTaggedStores = new Set();
1330
1383
  derivedCtx._transitionWhen = [];
1331
1384
  derivedCtx._shellCaptureRun = true;
1385
+ // Own-property reset: an inherited flag would make serializeSegments store
1386
+ // a double-encoded fragment. No current arming site reaches a capture
1387
+ // render (kept as defense-in-depth for a future one).
1388
+ derivedCtx._shellFragmentPayload = false;
1332
1389
  derivedCtx._metricsStore = undefined;
1333
1390
  // Spans, like perf metrics above, are a FOREGROUND surface: the capture
1334
1391
  // re-render must not emit a second rango.render/loader/ssr set after the
@@ -1395,9 +1452,10 @@ export function deriveShellCaptureContext(
1395
1452
  * capture worked; only the store I/O failed). `ssrModule.captureShellHTML` MUST be
1396
1453
  * present (eligibility is checked before scheduling).
1397
1454
  *
1398
- * A `no-shell` result (trivial prelude, or a defensively-caught abort) is the only
1399
- * retryable outcome; a genuine (non-abort) captureShellHTML error propagates so it
1400
- * reaches reportCacheError and is NOT retried.
1455
+ * A `no-shell` result from captureShellHTML is the only retryable outcome. Every
1456
+ * captureShellHTML error propagates to reportCacheError and is NOT retried. The
1457
+ * capture handler converts only its own private abort sentinel to null before this
1458
+ * layer sees it.
1401
1459
  */
1402
1460
  async function captureAndStoreShell(
1403
1461
  ssrModule: SSRModule,
@@ -1504,19 +1562,13 @@ async function captureAndStoreShell(
1504
1562
  }),
1505
1563
  );
1506
1564
  } catch (error) {
1507
- // Guard-tripped rejection arrives here (not at the drain) refuse
1508
- // BEFORE the AbortError-vs-rethrow decision below.
1565
+ // Guard-tripped rejection arrives here (not at the drain), so refuse it
1566
+ // before propagating other capture errors.
1509
1567
  const refused = refuseOnGuardTrip();
1510
1568
  if (refused) return refused;
1511
- // captureShellHTML normally converts its OWN deliberate abort to a null
1512
- // return (index.tsx). This catch is defensive: if an AbortError still escapes
1513
- // (a runtime where the abort surfaces as a stream rejection outside its
1514
- // guard), treat it as the same retryable "no usable shell" degradation rather
1515
- // than a failure — do NOT report it as an error. A genuine (non-abort) render
1516
- // error is a real failure: rethrow so it reaches reportCacheError (no retry).
1517
- if ((error as { name?: string } | null)?.name === "AbortError") {
1518
- return "no-shell";
1519
- }
1569
+ // captureShellHTML converts its OWN deliberate abort to null by sentinel
1570
+ // identity. An escaped AbortError can therefore be a component's real
1571
+ // cancellation and must not be hidden or retried by name.
1520
1572
  throw error;
1521
1573
  }
1522
1574
 
@@ -0,0 +1,137 @@
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
+
43
+ import { RouterTimeoutError } from "../router/timeout.js";
44
+
45
+ /** What the watchdog observed when it tripped. */
46
+ export interface StreamIdleTrip {
47
+ /** ms since the watchdog armed (response handoff) — the stream's lifetime. */
48
+ totalMs: number;
49
+ /** Chunks forwarded before the trip. */
50
+ chunks: number;
51
+ /** The error the client-facing stream was terminated with. */
52
+ error: RouterTimeoutError;
53
+ }
54
+
55
+ /**
56
+ * Wrap `response`'s body in an idle watchdog. Returns a NEW Response with the
57
+ * same status/headers whose body errors (and cancels the source) once no chunk
58
+ * has flowed for `idleMs`. Callers gate on isTimeoutEnabled + body presence +
59
+ * NOT a websocket upgrade (a 101/webSocket response must never be
60
+ * reconstructed); this function assumes those checks were made. `onTrip` fires
61
+ * at most once, after the client-facing stream has been errored.
62
+ */
63
+ export function applyStreamIdleTimeout(
64
+ response: Response,
65
+ idleMs: number,
66
+ onTrip: (trip: StreamIdleTrip) => void,
67
+ ): Response {
68
+ const source = response.body;
69
+ if (!source) return response;
70
+
71
+ const start = performance.now();
72
+ let timer: ReturnType<typeof setTimeout> | undefined;
73
+ let chunks = 0;
74
+ let settled = false;
75
+ let controllerRef!: TransformStreamDefaultController<unknown>;
76
+
77
+ const disarm = () => {
78
+ settled = true;
79
+ if (timer) clearTimeout(timer);
80
+ };
81
+
82
+ const trip = () => {
83
+ if (settled) return;
84
+ const error = new RouterTimeoutError(
85
+ "stream-idle",
86
+ performance.now() - start,
87
+ );
88
+ try {
89
+ controllerRef.error(error);
90
+ } catch {
91
+ // The stream already terminated (raced a close/cancel) — nothing was
92
+ // interrupted, so do not report a timeout.
93
+ return;
94
+ }
95
+ // Erroring the transform makes the pipe abort and CANCEL the source
96
+ // stream (React aborts the wedged render). Mark settled AFTER the error
97
+ // took, then report.
98
+ disarm();
99
+ onTrip({ totalMs: error.durationMs, chunks, error });
100
+ };
101
+
102
+ const arm = () => {
103
+ if (settled) return;
104
+ if (timer) clearTimeout(timer);
105
+ timer = setTimeout(trip, idleMs);
106
+ (timer as { unref?: () => void }).unref?.();
107
+ };
108
+
109
+ // `cancel` is the spec's newer transformer hook (client-side cancellation);
110
+ // TS's lib.dom Transformer type lags it, hence the intersection. Runtimes
111
+ // without it fall back to the settled/try-catch guards in trip().
112
+ const transformer: Transformer<unknown, unknown> & { cancel?: () => void } = {
113
+ start(controller) {
114
+ controllerRef = controller;
115
+ arm();
116
+ },
117
+ transform(chunk, controller) {
118
+ controller.enqueue(chunk);
119
+ chunks++;
120
+ arm();
121
+ },
122
+ flush() {
123
+ // Natural end of the source stream.
124
+ disarm();
125
+ },
126
+ cancel() {
127
+ disarm();
128
+ },
129
+ };
130
+ const watchdog = new TransformStream<unknown, unknown>(transformer);
131
+
132
+ return new Response(source.pipeThrough(watchdog) as ReadableStream, {
133
+ status: response.status,
134
+ statusText: response.statusText,
135
+ headers: response.headers,
136
+ });
137
+ }
@@ -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
@@ -22,6 +23,25 @@
22
23
 
23
24
  import type { ResolvedSegment } from "./types.js";
24
25
 
26
+ /** Partial-request capability gate for browser-side fragment expansion. */
27
+ export const SEGMENT_FRAGMENT_CAPABILITY_HEADER: string =
28
+ "X-Rango-Fragment-Passthrough";
29
+
30
+ /** One-shot request marker that forces server-side fragment validation. */
31
+ export const SEGMENT_FRAGMENT_RECOVERY_HEADER: string =
32
+ "X-Rango-Fragment-Recovery";
33
+
34
+ /** A stored fragment failed in its consumer-side Flight decoder. */
35
+ export class SegmentFragmentDecodeError extends Error {
36
+ readonly cause: unknown;
37
+
38
+ constructor(cause: unknown) {
39
+ super("Failed to decode a stored RSC segment fragment");
40
+ this.name = "SegmentFragmentDecodeError";
41
+ this.cause = cause;
42
+ }
43
+ }
44
+
25
45
  /**
26
46
  * One RSC-encoded fragment traveling inside an RscPayload segment field.
27
47
  * `f` is the stored Flight document (segment-codec output) for that field.
@@ -107,6 +127,27 @@ export async function expandSegmentFragments(
107
127
  if (decodes.length > 0) await Promise.all(decodes);
108
128
  }
109
129
 
130
+ /**
131
+ * Expand every envelope in an RscPayload-shaped object, in place. Decode
132
+ * failures use a distinct error so partial navigation can retry once through
133
+ * the server's decode-and-evict path. The
134
+ * hasSegmentFragments pre-scan keeps envelope-free payloads — every
135
+ * non-replay response — to one synchronous field walk; this runs on every
136
+ * navigation and prefetch decode.
137
+ */
138
+ export async function expandPayloadFragments(
139
+ payload: { metadata?: { segments?: ResolvedSegment[] } },
140
+ decode: FragmentDecoder,
141
+ ): Promise<void> {
142
+ const segments = payload.metadata?.segments;
143
+ if (!hasSegmentFragments(segments)) return;
144
+ try {
145
+ await expandSegmentFragments(segments, decode);
146
+ } catch (error) {
147
+ throw new SegmentFragmentDecodeError(error);
148
+ }
149
+ }
150
+
110
151
  /**
111
152
  * True when any segment carries an unexpanded envelope. Cheap scan used by
112
153
  * consumers that only need to know whether an expansion pass is required.
@@ -303,6 +303,12 @@ export interface RequestContext<
303
303
  keyPrefix?: "doc";
304
304
  /** @internal Called only after the implicit cache hit decodes successfully. */
305
305
  onHit?: () => void;
306
+ /**
307
+ * @internal Called when the seeded document record fails server-side
308
+ * deserialization. Partial replay uses it to replace the enclosing shell
309
+ * snapshot after serving the fresh fallback.
310
+ */
311
+ onCorrupt?: () => void;
306
312
  /**
307
313
  * @internal The resolved key of the canonical document segment record.
308
314
  * Written during a CAPTURE render by CacheScope.cacheRoute when a
@@ -334,15 +340,22 @@ export interface RequestContext<
334
340
  };
335
341
 
336
342
  /**
337
- * @internal Shell-HIT tail marker: cache/prerender-store hits during THIS
338
- * render emit stored segment fragments VERBATIM into the payload
343
+ * @internal Fragment-passthrough marker: cache/prerender-store hits during
344
+ * THIS render emit stored segment fragments VERBATIM into the payload
339
345
  * (segment-codec fragmentSegments) instead of deserialize -> re-serialize
340
- * per request; the payload consumers (SSR resume + browser hydration)
341
- * expand them (segment-fragments.ts, issue #700). Own property of
342
- * serveShellHit's derived tail context ONLY it must never be visible to a
343
- * capture render: the capture SSR-prerenders the payload AND serializes
344
- * segments into records (cacheRoute), and an envelope reaching
345
- * serializeSegments would store a double-encoded fragment.
346
+ * per request; the payload consumers (SSR resume, browser hydration, and
347
+ * the client partial-navigation decode chokepoints) expand them
348
+ * (segment-fragments.ts, issue #700). Partial requests arm only after the
349
+ * browser advertises `X-Rango-Fragment-Passthrough: 1`; a decode failure
350
+ * retries without it so CacheScope can evict the bad record. Two arming sites,
351
+ * both scoped to a
352
+ * render/match window: serveShellHit's derived tail context (document
353
+ * HIT), and matchPartialWithPprReplay's mutate-restore around the partial
354
+ * match (matchPartialForReplay). It must never be visible to a capture
355
+ * render: the capture SSR-prerenders the payload AND serializes segments
356
+ * into records (cacheRoute), and an envelope reaching serializeSegments
357
+ * would store a double-encoded fragment — deriveShellCaptureContext resets
358
+ * it as an own property for exactly that reason.
346
359
  */
347
360
  _shellFragmentPayload?: boolean;
348
361
 
@@ -678,6 +691,13 @@ export interface RequestContext<
678
691
  */
679
692
  _classifiedRoute?: import("../router/route-snapshot.js").RouteSnapshot;
680
693
 
694
+ /**
695
+ * @internal Classified request mode from classifyRequest, read by the
696
+ * rango.response span tail (rsc/handler.ts) for the rango.response.mode
697
+ * attribute. Unset when middleware short-circuits before core execution.
698
+ */
699
+ _requestMode?: import("../router/request-classification.js").RequestPlan["mode"];
700
+
681
701
  /**
682
702
  * @internal Coarse route-level cache signal for the X-Rango-Cache debug
683
703
  * header. Populated by match/matchPartial only when the debug cache signal
@@ -757,6 +777,7 @@ export type PublicRequestContext<
757
777
  | "_setKeepCacheDirective"
758
778
  | "_variables"
759
779
  | "_classifiedRoute"
780
+ | "_requestMode"
760
781
  | "_cacheSignal"
761
782
  | "_dynamic"
762
783
  | "res"
@@ -151,9 +151,11 @@ import type { TelemetrySink } from "../router/telemetry.js";
151
151
  import {
152
152
  RouterTimeoutError,
153
153
  createDefaultTimeoutResponse,
154
+ isTimeoutEnabled,
154
155
  withTimeout,
155
156
  } from "../router/timeout.js";
156
157
  import type { OnTimeoutCallback, ResolvedTimeouts } from "../router/timeout.js";
158
+ import { applyStreamIdleTimeout } from "../rsc/stream-idle.js";
157
159
 
158
160
  /**
159
161
  * The internal subset of the router surface dispatch depends on. The public
@@ -854,7 +856,59 @@ export async function dispatch<TEnv = any>(
854
856
  // it leaves -- a cross-origin Location is rewritten to the basename root
855
857
  // unless redirect(url, { external: true }) opted out. Soft partial/action
856
858
  // redirects are already resolved at createSimpleRedirectResponse time.
857
- return guardOutgoingRedirect(finalResponse, url.origin, router.basename);
859
+ const guardedResponse = guardOutgoingRedirect(
860
+ finalResponse,
861
+ url.origin,
862
+ router.basename,
863
+ );
864
+
865
+ // Mirror production's stream-idle watchdog (handler.ts response tail),
866
+ // same gating and reporting — dispatch is the userland dogfood surface
867
+ // for timeouts.streamIdleMs, so the primitive carries the real wiring,
868
+ // not a stub. Websocket check FIRST (an upgrade response's body getter
869
+ // must never be poked); onTimeout does not apply mid-stream.
870
+ const streamIdleMs = router.timeouts?.streamIdleMs;
871
+ if (
872
+ isTimeoutEnabled(streamIdleMs) &&
873
+ !isWebSocketUpgradeResponse(guardedResponse) &&
874
+ guardedResponse.body
875
+ ) {
876
+ return applyStreamIdleTimeout(
877
+ guardedResponse,
878
+ streamIdleMs!,
879
+ (trip) => {
880
+ invokeOnError(
881
+ router.onError,
882
+ trip.error,
883
+ "handler",
884
+ {
885
+ request: req,
886
+ url,
887
+ env,
888
+ handledByBoundary: false,
889
+ metadata: {
890
+ timeout: true,
891
+ phase: "stream-idle",
892
+ durationMs: trip.totalMs,
893
+ },
894
+ },
895
+ "RSC",
896
+ );
897
+ if (sink) {
898
+ safeEmit(resolveSink(sink), {
899
+ type: "request.timeout",
900
+ timestamp: performance.now(),
901
+ requestId: telemetryRequestId,
902
+ phase: "stream-idle",
903
+ pathname: url.pathname,
904
+ durationMs: trip.totalMs,
905
+ customHandler: false,
906
+ });
907
+ }
908
+ },
909
+ );
910
+ }
911
+ return guardedResponse;
858
912
  } catch (error) {
859
913
  if (sink) {
860
914
  if (error instanceof Response) {
@@ -1,12 +1,13 @@
1
1
  /**
2
2
  * Vercel OpenTelemetry tracing integration.
3
3
  *
4
- * Bridges the router's performance phases (request, middleware, action,
5
- * loaders, handler, render, ssr) onto OpenTelemetry spans so they show up in
6
- * Vercel's trace waterfall next to the platform's automatic spans, with correct
7
- * nesting. Vercel exposes tracing through OpenTelemetry (not a native import-free
8
- * API like Cloudflare), so this is a thin convenience over `createOTelTracing`:
9
- * it reads the global OTel tracer that `@vercel/otel`'s `registerOTel()` installs.
4
+ * Bridges the router's observable phases (request, middleware, action, loaders,
5
+ * handler, render, ssr, response, background) onto OpenTelemetry spans so they
6
+ * show up in Vercel's trace waterfall next to the platform's automatic spans,
7
+ * with correct nesting. Vercel exposes tracing through OpenTelemetry (not a
8
+ * native import-free API like Cloudflare), so this is a thin convenience over
9
+ * `createOTelTracing`: it reads the global OTel tracer that `@vercel/otel`'s
10
+ * `registerOTel()` installs.
10
11
  *
11
12
  * Usage (vercel preset). A Rango/Vite app does NOT auto-load `instrumentation.ts`
12
13
  * the way Next.js does, so export the tracing config from there and import it —
@@ -69,8 +70,8 @@ export interface VercelTracingOptions extends TracingToggleOptions {
69
70
  /**
70
71
  * Create the tracing config for a Vercel router. Pass the result to
71
72
  * `createRouter({ tracing })`. Spans are emitted for the request, middleware,
72
- * action, loaders, handler, render, and ssr phases; pass `spans` to turn
73
- * individual phases off.
73
+ * action, loaders, handler, render, ssr, response, and background phases; pass
74
+ * `spans` to turn individual phases off.
74
75
  *
75
76
  * @see createOTelTracing (`@rangojs/router`) for the underlying adapter on any
76
77
  * platform with an OpenTelemetry SDK.