@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.
- package/dist/types/browser/navigation-client.d.ts +1 -1
- package/dist/types/browser/prefetch/cache.d.ts +20 -8
- package/dist/types/cache/cf/cf-cache-store.d.ts +19 -8
- package/dist/types/cache/cf/cf-cache-types.d.ts +4 -2
- package/dist/types/cache/read-through-swr.d.ts +7 -6
- package/dist/types/cloudflare/tracing.d.ts +16 -12
- package/dist/types/router/instrument.d.ts +45 -15
- package/dist/types/router/telemetry-otel.d.ts +4 -4
- package/dist/types/router/timeout.d.ts +12 -1
- package/dist/types/router/tracing.d.ts +31 -17
- package/dist/types/rsc/capture-queue.d.ts +22 -1
- package/dist/types/rsc/shell-capture.d.ts +17 -9
- package/dist/types/rsc/stream-idle.d.ts +60 -0
- package/dist/types/segment-fragments.d.ts +27 -4
- package/dist/types/server/request-context.d.ts +28 -9
- package/dist/types/vercel/tracing.d.ts +9 -8
- package/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/skills/cloudflare/SKILL.md +4 -2
- package/skills/observability/SKILL.md +33 -4
- package/skills/ppr/SKILL.md +25 -9
- package/src/browser/navigation-client.ts +77 -27
- package/src/browser/prefetch/cache.ts +40 -10
- package/src/browser/prefetch/fetch.ts +5 -0
- package/src/browser/rsc-router.tsx +12 -6
- package/src/cache/cache-runtime.ts +39 -25
- package/src/cache/cache-scope.ts +5 -1
- package/src/cache/cf/cf-cache-store.ts +423 -90
- package/src/cache/cf/cf-cache-types.ts +4 -2
- package/src/cache/document-cache.ts +63 -25
- package/src/cache/read-through-swr.ts +22 -16
- package/src/cloudflare/tracing.ts +16 -12
- package/src/router/instrument.ts +61 -15
- package/src/router/segment-resolution/loader-cache.ts +12 -1
- package/src/router/segment-resolution/revalidation.ts +16 -1
- package/src/router/telemetry-otel.ts +4 -4
- package/src/router/timeout.ts +12 -1
- package/src/router/tracing.ts +37 -17
- package/src/rsc/capture-queue.ts +72 -17
- package/src/rsc/handler.ts +171 -73
- package/src/rsc/rsc-rendering.ts +61 -6
- package/src/rsc/shell-capture.ts +83 -31
- package/src/rsc/stream-idle.ts +137 -0
- package/src/segment-fragments.ts +45 -4
- package/src/server/request-context.ts +29 -8
- package/src/testing/dispatch.ts +55 -1
- package/src/vercel/tracing.ts +9 -8
package/src/rsc/shell-capture.ts
CHANGED
|
@@ -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
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`:
|
|
949
|
-
*
|
|
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
|
|
999
|
-
*
|
|
1000
|
-
*
|
|
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
|
|
1399
|
-
*
|
|
1400
|
-
*
|
|
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)
|
|
1508
|
-
//
|
|
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
|
|
1512
|
-
//
|
|
1513
|
-
//
|
|
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
|
+
}
|
package/src/segment-fragments.ts
CHANGED
|
@@ -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)
|
|
10
|
-
*
|
|
11
|
-
* independently, exactly like the segment codec's
|
|
12
|
-
* there is no Flight row-id collision or shared-row
|
|
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
|
|
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
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
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"
|
package/src/testing/dispatch.ts
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/src/vercel/tracing.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Vercel OpenTelemetry tracing integration.
|
|
3
3
|
*
|
|
4
|
-
* Bridges the router's
|
|
5
|
-
*
|
|
6
|
-
* Vercel's trace waterfall next to the platform's automatic spans,
|
|
7
|
-
* nesting. Vercel exposes tracing through OpenTelemetry (not a
|
|
8
|
-
* API like Cloudflare), so this is a thin convenience over
|
|
9
|
-
* it reads the global OTel tracer that `@vercel/otel`'s
|
|
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
|
|
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.
|