@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
|
@@ -226,6 +226,12 @@ export interface RequestContext<TEnv = DefaultEnv, TParams = Record<string, stri
|
|
|
226
226
|
keyPrefix?: "doc";
|
|
227
227
|
/** @internal Called only after the implicit cache hit decodes successfully. */
|
|
228
228
|
onHit?: () => void;
|
|
229
|
+
/**
|
|
230
|
+
* @internal Called when the seeded document record fails server-side
|
|
231
|
+
* deserialization. Partial replay uses it to replace the enclosing shell
|
|
232
|
+
* snapshot after serving the fresh fallback.
|
|
233
|
+
*/
|
|
234
|
+
onCorrupt?: () => void;
|
|
229
235
|
/**
|
|
230
236
|
* @internal The resolved key of the canonical document segment record.
|
|
231
237
|
* Written during a CAPTURE render by CacheScope.cacheRoute when a
|
|
@@ -256,15 +262,22 @@ export interface RequestContext<TEnv = DefaultEnv, TParams = Record<string, stri
|
|
|
256
262
|
onExplicitBypass?: () => void;
|
|
257
263
|
};
|
|
258
264
|
/**
|
|
259
|
-
* @internal
|
|
260
|
-
* render emit stored segment fragments VERBATIM into the payload
|
|
265
|
+
* @internal Fragment-passthrough marker: cache/prerender-store hits during
|
|
266
|
+
* THIS render emit stored segment fragments VERBATIM into the payload
|
|
261
267
|
* (segment-codec fragmentSegments) instead of deserialize -> re-serialize
|
|
262
|
-
* per request; the payload consumers (SSR resume
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
+
* per request; the payload consumers (SSR resume, browser hydration, and
|
|
269
|
+
* the client partial-navigation decode chokepoints) expand them
|
|
270
|
+
* (segment-fragments.ts, issue #700). Partial requests arm only after the
|
|
271
|
+
* browser advertises `X-Rango-Fragment-Passthrough: 1`; a decode failure
|
|
272
|
+
* retries without it so CacheScope can evict the bad record. Two arming sites,
|
|
273
|
+
* both scoped to a
|
|
274
|
+
* render/match window: serveShellHit's derived tail context (document
|
|
275
|
+
* HIT), and matchPartialWithPprReplay's mutate-restore around the partial
|
|
276
|
+
* match (matchPartialForReplay). It must never be visible to a capture
|
|
277
|
+
* render: the capture SSR-prerenders the payload AND serializes segments
|
|
278
|
+
* into records (cacheRoute), and an envelope reaching serializeSegments
|
|
279
|
+
* would store a double-encoded fragment — deriveShellCaptureContext resets
|
|
280
|
+
* it as an own property for exactly that reason.
|
|
268
281
|
*/
|
|
269
282
|
_shellFragmentPayload?: boolean;
|
|
270
283
|
/**
|
|
@@ -551,6 +564,12 @@ export interface RequestContext<TEnv = DefaultEnv, TParams = Record<string, stri
|
|
|
551
564
|
* to avoid a second resolveRoute call. Cleared on HMR invalidation.
|
|
552
565
|
*/
|
|
553
566
|
_classifiedRoute?: import("../router/route-snapshot.js").RouteSnapshot;
|
|
567
|
+
/**
|
|
568
|
+
* @internal Classified request mode from classifyRequest, read by the
|
|
569
|
+
* rango.response span tail (rsc/handler.ts) for the rango.response.mode
|
|
570
|
+
* attribute. Unset when middleware short-circuits before core execution.
|
|
571
|
+
*/
|
|
572
|
+
_requestMode?: import("../router/request-classification.js").RequestPlan["mode"];
|
|
554
573
|
/**
|
|
555
574
|
* @internal Coarse route-level cache signal for the X-Rango-Cache debug
|
|
556
575
|
* header. Populated by match/matchPartial only when the debug cache signal
|
|
@@ -566,7 +585,7 @@ export interface RequestContext<TEnv = DefaultEnv, TParams = Record<string, stri
|
|
|
566
585
|
* This is the type exported to library consumers. Internal code should
|
|
567
586
|
* use the full RequestContext interface directly.
|
|
568
587
|
*/
|
|
569
|
-
export type PublicRequestContext<TEnv = DefaultEnv, TParams = Record<string, string>> = Omit<RequestContext<TEnv, TParams>, "cookie" | "cookies" | "setCookie" | "deleteCookie" | "_handleStore" | "_transitionWhen" | "_pprTransitionDecisions" | "_pprReplayPostMatchReason" | "_cacheStore" | "_searchParamsFilter" | "_shellCaptureRun" | "_shellImplicitCache" | "_shellLoaderSeed" | "_shellCaptureLoaderRecords" | "_shellCaptureHandleLiveness" | "_shellCaptureLoaderHandleValues" | "_shellFragmentPayload" | "_shellCaptureGuardTripped" | "_shellCaptureGuardTrippedLoaderId" | "_explicitTaggedStores" | "_requestTags" | "_cacheProfiles" | "_onResponseCallbacks" | "_pendingBackgroundTasks" | "_themeConfig" | "_locationState" | "_routeName" | "_prevRouteKey" | "_gateCurrentUrl" | "_gateCurrentParams" | "_gateActionId" | "_gateActionUrl" | "_gateActionResult" | "_gateFormData" | "_inActionRevalidation" | "_reportedErrors" | "_renderBarrier" | "_resolveRenderBarrier" | "_renderBarrierSegmentOrder" | "_treeHasStreaming" | "_renderBarrierWaiters" | "_handlerLoaderDeps" | "_renderBarrierHandleSnapshot" | "_renderBarrierGuardClosed" | "_reportBackgroundError" | "_debugPerformance" | "_metricsStore" | "_renderForeground" | "_renderDiagnosticsEnabled" | "_handlerStart" | "_tracing" | "_basename" | "_routerId" | "_setStatus" | "_rotateStateCookie" | "_setKeepCacheDirective" | "_variables" | "_classifiedRoute" | "_cacheSignal" | "_dynamic" | "res">;
|
|
588
|
+
export type PublicRequestContext<TEnv = DefaultEnv, TParams = Record<string, string>> = Omit<RequestContext<TEnv, TParams>, "cookie" | "cookies" | "setCookie" | "deleteCookie" | "_handleStore" | "_transitionWhen" | "_pprTransitionDecisions" | "_pprReplayPostMatchReason" | "_cacheStore" | "_searchParamsFilter" | "_shellCaptureRun" | "_shellImplicitCache" | "_shellLoaderSeed" | "_shellCaptureLoaderRecords" | "_shellCaptureHandleLiveness" | "_shellCaptureLoaderHandleValues" | "_shellFragmentPayload" | "_shellCaptureGuardTripped" | "_shellCaptureGuardTrippedLoaderId" | "_explicitTaggedStores" | "_requestTags" | "_cacheProfiles" | "_onResponseCallbacks" | "_pendingBackgroundTasks" | "_themeConfig" | "_locationState" | "_routeName" | "_prevRouteKey" | "_gateCurrentUrl" | "_gateCurrentParams" | "_gateActionId" | "_gateActionUrl" | "_gateActionResult" | "_gateFormData" | "_inActionRevalidation" | "_reportedErrors" | "_renderBarrier" | "_resolveRenderBarrier" | "_renderBarrierSegmentOrder" | "_treeHasStreaming" | "_renderBarrierWaiters" | "_handlerLoaderDeps" | "_renderBarrierHandleSnapshot" | "_renderBarrierGuardClosed" | "_reportBackgroundError" | "_debugPerformance" | "_metricsStore" | "_renderForeground" | "_renderDiagnosticsEnabled" | "_handlerStart" | "_tracing" | "_basename" | "_routerId" | "_setStatus" | "_rotateStateCookie" | "_setKeepCacheDirective" | "_variables" | "_classifiedRoute" | "_requestMode" | "_cacheSignal" | "_dynamic" | "res">;
|
|
570
589
|
/**
|
|
571
590
|
* Marker for a waitUntil-scheduled fn whose task promise must NOT enter
|
|
572
591
|
* _pendingBackgroundTasks. Used by the PPR shell capture for its own task:
|
|
@@ -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 —
|
|
@@ -59,8 +60,8 @@ export interface VercelTracingOptions extends TracingToggleOptions {
|
|
|
59
60
|
/**
|
|
60
61
|
* Create the tracing config for a Vercel router. Pass the result to
|
|
61
62
|
* `createRouter({ tracing })`. Spans are emitted for the request, middleware,
|
|
62
|
-
* action, loaders, handler, render, and
|
|
63
|
-
* individual phases off.
|
|
63
|
+
* action, loaders, handler, render, ssr, response, and background phases; pass
|
|
64
|
+
* `spans` to turn individual phases off.
|
|
64
65
|
*
|
|
65
66
|
* @see createOTelTracing (`@rangojs/router`) for the underlying adapter on any
|
|
66
67
|
* platform with an OpenTelemetry SDK.
|
package/dist/vite/index.js
CHANGED
|
@@ -2530,7 +2530,7 @@ import { resolve } from "node:path";
|
|
|
2530
2530
|
// package.json
|
|
2531
2531
|
var package_default = {
|
|
2532
2532
|
name: "@rangojs/router",
|
|
2533
|
-
version: "0.4.
|
|
2533
|
+
version: "0.4.3",
|
|
2534
2534
|
description: "Django-inspired RSC router with composable URL patterns",
|
|
2535
2535
|
keywords: [
|
|
2536
2536
|
"react",
|
package/package.json
CHANGED
|
@@ -129,8 +129,10 @@ export const router = createRouter<AppBindings>({
|
|
|
129
129
|
});
|
|
130
130
|
```
|
|
131
131
|
|
|
132
|
-
|
|
133
|
-
|
|
132
|
+
PPR shells use Cache API as the per-colo L1 and KV as the durable cross-colo
|
|
133
|
+
L2; a KV hit promotes the coupled shell envelope back into L1. KV remains
|
|
134
|
+
required for this family. `CFCacheStore` without `kv` can still cache
|
|
135
|
+
segment/response entries in Cache API, but its shell family is inert.
|
|
134
136
|
|
|
135
137
|
## Commands
|
|
136
138
|
|
|
@@ -145,10 +145,39 @@ const router = createRouter({ document: Document, urls: urlpatterns, tracing });
|
|
|
145
145
|
These factories return a `RouterTracingConfig` for the same `tracing` slot;
|
|
146
146
|
`telemetry` stays independent (events only, no phase spans). Phase spans:
|
|
147
147
|
`rango.request`, `rango.middleware`, `rango.action`, `rango.loader`,
|
|
148
|
-
`rango.render`, `rango.ssr
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
148
|
+
`rango.handler`, `rango.render`, `rango.ssr`, `rango.response`,
|
|
149
|
+
`rango.background`. All share the `PHASES` registry and `observePhase`
|
|
150
|
+
execution boundary with `debugPerformance`; phases with `metric: false` either
|
|
151
|
+
use finer-grained perf rows or, for `response` and `background`, remain
|
|
152
|
+
span-only. Off-platform (no Cloudflare tracing destination / no OTel SDK) every
|
|
153
|
+
span call is a transparent pass-through, so the request behaves as if tracing
|
|
154
|
+
were off.
|
|
155
|
+
|
|
156
|
+
`rango.response` is the explicit handoff marker: at most one per traced
|
|
157
|
+
request, a direct child of `rango.request`, wrapping only response finalization
|
|
158
|
+
(redirect interception/guarding, `Server-Timing` mutation, final response
|
|
159
|
+
selection) and ending immediately before the handler returns the response to
|
|
160
|
+
the host. It is handoff-bound, never drain-bound — it never reads or awaits
|
|
161
|
+
`response.body`. Attributes: `http.response.status_code`,
|
|
162
|
+
`rango.response.mode` (classified request mode, or `middleware-short-circuit`),
|
|
163
|
+
`rango.response.body_kind` (`stream`/`empty`/`websocket`). For request modes
|
|
164
|
+
that render nothing (fetchable `_rsc_loader`, response routes, middleware
|
|
165
|
+
short-circuits) it shows the trace is complete rather than truncated — those
|
|
166
|
+
requests legitimately have no `rango.render`/`rango.ssr`/`rango.handler` spans.
|
|
167
|
+
On deployed Workers it may read 0 ms (frozen non-I/O timers); its position and
|
|
168
|
+
attributes are the value. Disable per-response billing overhead at volume with
|
|
169
|
+
`spans: { response: false }`.
|
|
170
|
+
|
|
171
|
+
`rango.background` wraps detached waitUntil work — PPR shell captures and SWR
|
|
172
|
+
background revalidations — that runs after the foreground spans ended. Without
|
|
173
|
+
it, a capture or revalidation shows up as an unexplained wave of orphan
|
|
174
|
+
KV/fetch spans minutes into a trace. `rango.background.kind` names the lane
|
|
175
|
+
(`shell-capture` / `document-revalidation` / `loader-revalidation` /
|
|
176
|
+
`use-cache-revalidation`); the shell-capture lane also carries
|
|
177
|
+
`rango.shell_key`, `rango.background.outcome`, and
|
|
178
|
+
`rango.background.queue_wait_ms` (a capture parked behind the per-isolate
|
|
179
|
+
capture queue reads as span duration, not dead air). Toggle with
|
|
180
|
+
`spans: { background: false }`.
|
|
152
181
|
|
|
153
182
|
Custom sinks implement `emit(event)`:
|
|
154
183
|
|
package/skills/ppr/SKILL.md
CHANGED
|
@@ -37,9 +37,9 @@ PPR is a DOCUMENT-level property declared on the page route via the `ppr` path
|
|
|
37
37
|
option. Serving is **integral to the router** — there is nothing to mount. The
|
|
38
38
|
only prerequisite is an app-level `createRouter({ cache })` store that
|
|
39
39
|
implements the shell family (`getShell`/`putShell`): `MemorySegmentCacheStore`
|
|
40
|
-
(dev/tests), `CFCacheStore` (
|
|
41
|
-
cache). A ppr route on a store without the family stays on axis 1 with
|
|
42
|
-
once-per-key warning.
|
|
40
|
+
(dev/tests), `CFCacheStore` (Cache API L1 + KV L2), or `VercelCacheStore`
|
|
41
|
+
(runtime cache). A ppr route on a store without the family stays on axis 1 with
|
|
42
|
+
a once-per-key warning.
|
|
43
43
|
|
|
44
44
|
```typescript
|
|
45
45
|
import { createRouter, urls } from "@rangojs/router";
|
|
@@ -258,11 +258,21 @@ Partial responses expose the actual decision as `x-rango-ppr-replay`:
|
|
|
258
258
|
`HIT; freshness=fresh|stale` or `BYPASS; reason=<bounded-token>`. With
|
|
259
259
|
performance metrics enabled, the same decision appears as
|
|
260
260
|
`ppr-navigation-replay` in `Server-Timing`. `HIT` means matching consumed the
|
|
261
|
-
seeded segment record
|
|
262
|
-
|
|
261
|
+
seeded segment record, not merely that a snapshot existed. Fragment-capable
|
|
262
|
+
clients decode its stored ReactNode fields after the response arrives; if that
|
|
263
|
+
decode fails, the client retries once through the server decode-and-evict path
|
|
264
|
+
and disables further passthrough for that browser document. The retry replaces
|
|
265
|
+
the fragment-capable response-cache slot, and a corrupt seeded snapshot is
|
|
266
|
+
recaptured at the key that supplied it. An explicit `cache()` scope that supplies
|
|
267
|
+
the match cannot produce a
|
|
263
268
|
false HIT — it reports `explicit-cache-hit`. There is still no Flight resume
|
|
264
269
|
API; this is segment replay followed by normal Flight streaming, not reuse of
|
|
265
|
-
the HTML `prelude`/`postponed` bytes.
|
|
270
|
+
the HTML `prelude`/`postponed` bytes. Replayed segments do ride the partial
|
|
271
|
+
payload as verbatim `__rangoFragment` envelopes (#700 fragment splice — the
|
|
272
|
+
stored per-segment Flight strings are string-copied instead of
|
|
273
|
+
deserialize→re-serialize per request); the client expands them at its
|
|
274
|
+
navigation/prefetch decode chokepoints before anything renders, so they are
|
|
275
|
+
consumer-invisible.
|
|
266
276
|
|
|
267
277
|
The bounded bypass tokens, grouped by when they are decided:
|
|
268
278
|
|
|
@@ -344,9 +354,10 @@ curl -s -D - -o /dev/null https://app.example.com/products/1 | grep -i x-rango-s
|
|
|
344
354
|
- A ppr-declared route that CANNOT be honored (missing shell store family,
|
|
345
355
|
per-request nonce) serves plain axis 1 with NO header and warns once per
|
|
346
356
|
key — no header + a declared `ppr` means look for that warning.
|
|
347
|
-
- On Cloudflare, `CFCacheStore`
|
|
348
|
-
|
|
349
|
-
|
|
357
|
+
- On Cloudflare, `CFCacheStore` reads PPR shells from the per-colo Cache API,
|
|
358
|
+
falls through to KV on a miss, and promotes the KV hit back into that colo.
|
|
359
|
+
WITHOUT a KV namespace its shell family remains inert: every ppr route stays
|
|
360
|
+
`MISS` forever. The store warns once per isolate — bind KV
|
|
350
361
|
(`new CFCacheStore({ ctx, kv: env.CACHE_KV })`) or use another store.
|
|
351
362
|
- Structured capture diagnostics: `createRouter({ debugShellCapture: true })`
|
|
352
363
|
logs one line per capture attempt/skip (outcome, durations, prelude and
|
|
@@ -356,6 +367,11 @@ curl -s -D - -o /dev/null https://app.example.com/products/1 | grep -i x-rango-s
|
|
|
356
367
|
later request. In dev, with `debugPerformance` on, the
|
|
357
368
|
last capture outcome for a key also rides the next document GET's
|
|
358
369
|
`Server-Timing` as `ppr-capture;desc="…"`.
|
|
370
|
+
- For deployed Cloudflare tier diagnostics, build with
|
|
371
|
+
`INTERNAL_RANGO_DEBUG=1` and run `wrangler tail`. `[CFCacheStore][shell]`
|
|
372
|
+
JSON events distinguish L1 hit/miss, KV fallback/promotion, tier writes, and
|
|
373
|
+
marker rejection, with `cf-ray`/colo and read timings. The flag is baked at
|
|
374
|
+
build time; setting only a Worker runtime variable is too late.
|
|
359
375
|
|
|
360
376
|
### Unit / integration testing (public primitives)
|
|
361
377
|
|
|
@@ -14,6 +14,12 @@ import {
|
|
|
14
14
|
} from "./logging.js";
|
|
15
15
|
import { getRangoState } from "./rango-state.js";
|
|
16
16
|
import { isActionFenceActive } from "./action-fence.js";
|
|
17
|
+
import {
|
|
18
|
+
expandPayloadFragments,
|
|
19
|
+
SEGMENT_FRAGMENT_CAPABILITY_HEADER,
|
|
20
|
+
SEGMENT_FRAGMENT_RECOVERY_HEADER,
|
|
21
|
+
SegmentFragmentDecodeError,
|
|
22
|
+
} from "../segment-fragments.js";
|
|
17
23
|
import {
|
|
18
24
|
extractRscHeaderUrl,
|
|
19
25
|
emptyResponse,
|
|
@@ -26,6 +32,8 @@ import {
|
|
|
26
32
|
buildSourceKey,
|
|
27
33
|
consumeInflightPrefetch,
|
|
28
34
|
consumePrefetch,
|
|
35
|
+
disableFragmentPassthrough,
|
|
36
|
+
isFragmentPassthroughEnabled,
|
|
29
37
|
type DecodedPrefetch,
|
|
30
38
|
} from "./prefetch/cache.js";
|
|
31
39
|
import { cancelAllPrefetches } from "./prefetch/loader.js";
|
|
@@ -46,7 +54,10 @@ import { cancelAllPrefetches } from "./prefetch/loader.js";
|
|
|
46
54
|
* @returns NavigationClient instance
|
|
47
55
|
*/
|
|
48
56
|
export function createNavigationClient(
|
|
49
|
-
deps: Pick<
|
|
57
|
+
deps: Pick<
|
|
58
|
+
RscBrowserDependencies,
|
|
59
|
+
"createFromFetch" | "createFromReadableStream"
|
|
60
|
+
>,
|
|
50
61
|
): NavigationClient {
|
|
51
62
|
return {
|
|
52
63
|
/**
|
|
@@ -150,12 +161,6 @@ export function createNavigationClient(
|
|
|
150
161
|
if (inflightEntryPromise) hitKey = wildcardKey;
|
|
151
162
|
}
|
|
152
163
|
}
|
|
153
|
-
// Track when the stream completes
|
|
154
|
-
let resolveStreamComplete: () => void;
|
|
155
|
-
const streamComplete = new Promise<void>((resolve) => {
|
|
156
|
-
resolveStreamComplete = resolve;
|
|
157
|
-
});
|
|
158
|
-
|
|
159
164
|
/**
|
|
160
165
|
* Validate RSC control headers on any response (fresh, cached, or
|
|
161
166
|
* in-flight). Handles version-mismatch reloads and server redirects.
|
|
@@ -164,6 +169,7 @@ export function createNavigationClient(
|
|
|
164
169
|
const validateRscHeaders = (
|
|
165
170
|
response: Response,
|
|
166
171
|
source: string,
|
|
172
|
+
resolveStreamComplete: () => void,
|
|
167
173
|
): Response | Promise<Response> => {
|
|
168
174
|
// Version mismatch — server wants a full page reload
|
|
169
175
|
const reloadResult = handleReloadHeader(response, {
|
|
@@ -213,20 +219,35 @@ export function createNavigationClient(
|
|
|
213
219
|
return response;
|
|
214
220
|
};
|
|
215
221
|
|
|
216
|
-
/** Start
|
|
217
|
-
const
|
|
222
|
+
/** Start and decode one fresh navigation fetch. */
|
|
223
|
+
const freshResult = (
|
|
224
|
+
fragmentPassthrough = isFragmentPassthroughEnabled(),
|
|
225
|
+
fragmentRecovery = false,
|
|
226
|
+
): {
|
|
227
|
+
payload: Promise<RscPayload>;
|
|
228
|
+
streamComplete: Promise<void>;
|
|
229
|
+
} => {
|
|
230
|
+
let resolveStreamComplete!: () => void;
|
|
231
|
+
const streamComplete = new Promise<void>((resolve) => {
|
|
232
|
+
resolveStreamComplete = resolve;
|
|
233
|
+
});
|
|
234
|
+
|
|
218
235
|
if (tx) {
|
|
219
236
|
browserDebugLog(tx, "fetching", {
|
|
220
237
|
path: `${fetchUrl.pathname}${fetchUrl.search}`,
|
|
238
|
+
fragmentPassthrough,
|
|
221
239
|
});
|
|
222
240
|
}
|
|
223
241
|
|
|
224
|
-
|
|
242
|
+
const responsePromise = fetch(fetchUrl, {
|
|
225
243
|
// During an action's flight the state is not rotated, so the old
|
|
226
244
|
// X-Rango-State still matches the Vary-keyed HTTP-cache entry; bypass
|
|
227
245
|
// it so a genuine mid-action navigation fetches fresh instead of being
|
|
228
|
-
// served the stale prefetched bytes.
|
|
229
|
-
|
|
246
|
+
// served the stale prefetched bytes. Fragment recovery also bypasses
|
|
247
|
+
// HTTP caches so it reaches the server's decode-and-evict path.
|
|
248
|
+
...((isActionFenceActive() || fragmentRecovery) && {
|
|
249
|
+
cache: "no-store" as RequestCache,
|
|
250
|
+
}),
|
|
230
251
|
headers: {
|
|
231
252
|
"X-RSC-Router-Client-Path": previousUrl,
|
|
232
253
|
// Reuse the single per-operation read (see rangoState above): the
|
|
@@ -234,6 +255,12 @@ export function createNavigationClient(
|
|
|
234
255
|
// cookie read has side effects (external-rotation notify) we do not
|
|
235
256
|
// want to fire twice per navigation.
|
|
236
257
|
"X-Rango-State": rangoState,
|
|
258
|
+
...(fragmentPassthrough && {
|
|
259
|
+
[SEGMENT_FRAGMENT_CAPABILITY_HEADER]: "1",
|
|
260
|
+
}),
|
|
261
|
+
...(fragmentRecovery && {
|
|
262
|
+
[SEGMENT_FRAGMENT_RECOVERY_HEADER]: "1",
|
|
263
|
+
}),
|
|
237
264
|
...(tx && { "X-RSC-Router-Request-Id": tx.requestId }),
|
|
238
265
|
...(interceptSourceUrl && {
|
|
239
266
|
"X-RSC-Router-Intercept-Source": interceptSourceUrl,
|
|
@@ -242,7 +269,11 @@ export function createNavigationClient(
|
|
|
242
269
|
},
|
|
243
270
|
signal,
|
|
244
271
|
}).then((response) => {
|
|
245
|
-
const validated = validateRscHeaders(
|
|
272
|
+
const validated = validateRscHeaders(
|
|
273
|
+
response,
|
|
274
|
+
"fetch",
|
|
275
|
+
resolveStreamComplete,
|
|
276
|
+
);
|
|
246
277
|
if (validated instanceof Promise) return validated;
|
|
247
278
|
|
|
248
279
|
return teeWithCompletion(
|
|
@@ -254,20 +285,12 @@ export function createNavigationClient(
|
|
|
254
285
|
signal,
|
|
255
286
|
);
|
|
256
287
|
});
|
|
257
|
-
};
|
|
258
288
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
const freshResult = (): {
|
|
265
|
-
payload: Promise<RscPayload>;
|
|
266
|
-
streamComplete: Promise<void>;
|
|
267
|
-
} => ({
|
|
268
|
-
payload: deps.createFromFetch<RscPayload>(doFreshFetch()),
|
|
269
|
-
streamComplete,
|
|
270
|
-
});
|
|
289
|
+
return {
|
|
290
|
+
payload: deps.createFromFetch<RscPayload>(responsePromise),
|
|
291
|
+
streamComplete,
|
|
292
|
+
};
|
|
293
|
+
};
|
|
271
294
|
|
|
272
295
|
let payloadPromise: Promise<RscPayload>;
|
|
273
296
|
let streamCompletePromise: Promise<void>;
|
|
@@ -332,7 +355,34 @@ export function createNavigationClient(
|
|
|
332
355
|
// model is not flushed early (server/runtime buffering, e.g. wrangler
|
|
333
356
|
// dev gzip); fast means the block, if any, is downstream in render.
|
|
334
357
|
const vtDebugStart = isBrowserDebugEnabled() ? performance.now() : 0;
|
|
335
|
-
|
|
358
|
+
let payload: RscPayload;
|
|
359
|
+
try {
|
|
360
|
+
payload = await payloadPromise;
|
|
361
|
+
// Fragment envelopes (#700): expand replay-HIT envelopes before any
|
|
362
|
+
// consumer renders — this await is the single point all three payload
|
|
363
|
+
// sources (fresh fetch, warm prefetch, adopted inflight) flow through.
|
|
364
|
+
await expandPayloadFragments(payload, deps.createFromReadableStream);
|
|
365
|
+
} catch (error) {
|
|
366
|
+
if (
|
|
367
|
+
!(error instanceof SegmentFragmentDecodeError) ||
|
|
368
|
+
signal?.aborted
|
|
369
|
+
) {
|
|
370
|
+
throw error;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// A fragment-only failure means the outer payload was valid but one
|
|
374
|
+
// stored segment was not. Retry once without the capability header:
|
|
375
|
+
// the server then takes CacheScope's decode-and-evict path and renders
|
|
376
|
+
// fresh. Never recurse; a failed recovery remains an ordinary
|
|
377
|
+
// unprocessable navigation response.
|
|
378
|
+
if (tx) browserDebugLog(tx, "fragment decode failed, retrying");
|
|
379
|
+
disableFragmentPassthrough();
|
|
380
|
+
const recovery = freshResult(false, true);
|
|
381
|
+
streamCompletePromise = recovery.streamComplete;
|
|
382
|
+
fullyPrefetched = false;
|
|
383
|
+
payload = await recovery.payload;
|
|
384
|
+
await expandPayloadFragments(payload, deps.createFromReadableStream);
|
|
385
|
+
}
|
|
336
386
|
if (isBrowserDebugEnabled()) {
|
|
337
387
|
debugLog("[VT-DIAG] payloadResolved", {
|
|
338
388
|
ms: Math.round(performance.now() - vtDebugStart),
|
|
@@ -10,12 +10,12 @@
|
|
|
10
10
|
* scopes are in play:
|
|
11
11
|
* - Wildcard (default): built by `buildPrefetchKey(rangoState, target)` —
|
|
12
12
|
* shape `rangoState\0/target?...`. Shared across all source pages and
|
|
13
|
-
*
|
|
14
|
-
* server-action invalidation).
|
|
13
|
+
* source segment trees; `_rsc_segments` is omitted from this key. Invalidated
|
|
14
|
+
* automatically when Rango state bumps (deploy or server-action invalidation).
|
|
15
15
|
* - Source-scoped: built by `buildSourceKey(rangoState, sourceHref, target)`
|
|
16
16
|
* — shape `rangoState\0sourceHref\0/target?...`. Embeds the Rango state
|
|
17
|
-
* (so rotation invalidates source-scoped entries too)
|
|
18
|
-
*
|
|
17
|
+
* (so rotation invalidates source-scoped entries too), the source href, and
|
|
18
|
+
* the exact source segment tree. Populated when the
|
|
19
19
|
* server tags a response with `X-RSC-Prefetch-Scope: source` (intercept
|
|
20
20
|
* modals etc.), OR when a Link opts in with `prefetchKey=":source"` — in
|
|
21
21
|
* both cases so source-sensitive responses cannot bleed into navigations
|
|
@@ -77,6 +77,7 @@ export interface DecodedPrefetch {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
let cacheTTL = 300_000;
|
|
80
|
+
let fragmentPassthroughEnabled = true;
|
|
80
81
|
|
|
81
82
|
// Max stored entries before FIFO eviction. Mirrors DEFAULT_PREFETCH_CACHE_SIZE
|
|
82
83
|
// (router/prefetch-limits.ts); kept as a local literal so the client bundle
|
|
@@ -140,7 +141,9 @@ let generation = 0;
|
|
|
140
141
|
* - Wildcard (source-agnostic): prefix is the Rango state value from
|
|
141
142
|
* `getRangoState()`. Shared across all source pages. Invalidated
|
|
142
143
|
* automatically when Rango state bumps (deploy or server-action).
|
|
143
|
-
*
|
|
144
|
+
* `_rsc_segments` is omitted so sibling payloads prefetched from one page
|
|
145
|
+
* remain reusable after another prefetched navigation commits. Key shape:
|
|
146
|
+
* `rangoState\0/target?...`.
|
|
144
147
|
* - Source-scoped: use `buildSourceKey()`. Key shape:
|
|
145
148
|
* `rangoState\0sourceHref\0/target?...` — embeds the Rango state so
|
|
146
149
|
* rotation invalidates source-scoped entries alongside wildcard ones,
|
|
@@ -149,12 +152,15 @@ let generation = 0;
|
|
|
149
152
|
* `X-RSC-Prefetch-Scope: source` (intercept modals, etc.) or when a
|
|
150
153
|
* Link opts in via `prefetchKey=":source"`.
|
|
151
154
|
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
+
* Source-scoped keys retain `_rsc_segments` because their exact source tree is
|
|
156
|
+
* part of the opt-in contract. Wildcard responses are source-agnostic by
|
|
157
|
+
* contract; callers use source scope when a custom revalidation decision makes
|
|
158
|
+
* the response depend on the current URL or segment tree.
|
|
155
159
|
*/
|
|
156
160
|
export function buildPrefetchKey(prefix: string, targetUrl: URL): string {
|
|
157
|
-
|
|
161
|
+
const normalizedTarget = new URL(targetUrl);
|
|
162
|
+
normalizedTarget.searchParams.delete("_rsc_segments");
|
|
163
|
+
return prefix + "\0" + normalizedTarget.pathname + normalizedTarget.search;
|
|
158
164
|
}
|
|
159
165
|
|
|
160
166
|
/**
|
|
@@ -170,7 +176,14 @@ export function buildSourceKey(
|
|
|
170
176
|
sourceHref: string,
|
|
171
177
|
targetUrl: URL,
|
|
172
178
|
): string {
|
|
173
|
-
return
|
|
179
|
+
return (
|
|
180
|
+
rangoState +
|
|
181
|
+
"\0" +
|
|
182
|
+
sourceHref +
|
|
183
|
+
"\0" +
|
|
184
|
+
targetUrl.pathname +
|
|
185
|
+
targetUrl.search
|
|
186
|
+
);
|
|
174
187
|
}
|
|
175
188
|
|
|
176
189
|
/**
|
|
@@ -369,6 +382,23 @@ export function clearPrefetchInflight(key: string): void {
|
|
|
369
382
|
});
|
|
370
383
|
}
|
|
371
384
|
|
|
385
|
+
/** Whether this document may request stored segment fragment envelopes. */
|
|
386
|
+
export function isFragmentPassthroughEnabled(): boolean {
|
|
387
|
+
return fragmentPassthroughEnabled;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Disable fragment passthrough for the rest of this document after one stored
|
|
392
|
+
* fragment fails to decode. The recovery replaces shared cache entries, while
|
|
393
|
+
* this guard prevents the current tab's HTTP cache from serving its already-
|
|
394
|
+
* cached corrupt variant again.
|
|
395
|
+
*/
|
|
396
|
+
export function disableFragmentPassthrough(): void {
|
|
397
|
+
if (!fragmentPassthroughEnabled) return;
|
|
398
|
+
fragmentPassthroughEnabled = false;
|
|
399
|
+
clearPrefetchCache(false);
|
|
400
|
+
}
|
|
401
|
+
|
|
372
402
|
export function clearPrefetchCache(rotateRangoState = true): void {
|
|
373
403
|
generation++;
|
|
374
404
|
inflight.clear();
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
removePrefetch,
|
|
26
26
|
clearPrefetchInflight,
|
|
27
27
|
currentGeneration,
|
|
28
|
+
isFragmentPassthroughEnabled,
|
|
28
29
|
type DecodedPrefetch,
|
|
29
30
|
} from "./cache.js";
|
|
30
31
|
import { getRangoState } from "../rango-state.js";
|
|
@@ -33,6 +34,7 @@ import { enqueuePrefetch } from "./queue.js";
|
|
|
33
34
|
import { shouldPrefetch } from "./policy.js";
|
|
34
35
|
import { debugLog, IS_BROWSER_DEBUG } from "../logging.js";
|
|
35
36
|
import { teeWithCompletion, isForeignRouterId } from "../response-adapter.js";
|
|
37
|
+
import { SEGMENT_FRAGMENT_CAPABILITY_HEADER } from "../../segment-fragments.js";
|
|
36
38
|
import type { RscPayload } from "../types.js";
|
|
37
39
|
|
|
38
40
|
/**
|
|
@@ -219,6 +221,9 @@ function executePrefetchFetch(
|
|
|
219
221
|
"X-Rango-State": rangoState,
|
|
220
222
|
"X-RSC-Router-Client-Path": window.location.href,
|
|
221
223
|
"X-Rango-Prefetch": "1",
|
|
224
|
+
...(isFragmentPassthroughEnabled() && {
|
|
225
|
+
[SEGMENT_FRAGMENT_CAPABILITY_HEADER]: "1",
|
|
226
|
+
}),
|
|
222
227
|
},
|
|
223
228
|
})
|
|
224
229
|
.then((response) => {
|
|
@@ -22,7 +22,7 @@ import type {
|
|
|
22
22
|
} from "./types.js";
|
|
23
23
|
import type { EventController } from "./event-controller.js";
|
|
24
24
|
import type { ResolvedThemeConfig, Theme } from "../theme/types.js";
|
|
25
|
-
import {
|
|
25
|
+
import { expandPayloadFragments } from "../segment-fragments.js";
|
|
26
26
|
import { initRangoState } from "./rango-state.js";
|
|
27
27
|
import { registerNavigationStore } from "./navigation-store-handle.js";
|
|
28
28
|
import { initPrefetchCache } from "./prefetch/cache.js";
|
|
@@ -177,9 +177,7 @@ export async function initBrowserApp(
|
|
|
177
177
|
// renderSegments, history cache). Non-HIT payloads have no envelopes and pay
|
|
178
178
|
// one field scan. The SSR resume pass ran the same expansion (ssr-root.tsx),
|
|
179
179
|
// so the hydrated tree matches the server-rendered one by construction.
|
|
180
|
-
await
|
|
181
|
-
deps.createFromReadableStream(stream),
|
|
182
|
-
);
|
|
180
|
+
await expandPayloadFragments(initialPayload, deps.createFromReadableStream);
|
|
183
181
|
|
|
184
182
|
// Extract themeConfig and initialTheme from payload if not explicitly provided
|
|
185
183
|
// This allows virtual entries to work without importing the router
|
|
@@ -312,8 +310,16 @@ export async function initBrowserApp(
|
|
|
312
310
|
}
|
|
313
311
|
|
|
314
312
|
// Wire the RSC decoder so prefetches decode eagerly and warm the route's
|
|
315
|
-
// client chunks (same createFromFetch the navigation client uses).
|
|
316
|
-
|
|
313
|
+
// client chunks (same createFromFetch the navigation client uses). Fragment
|
|
314
|
+
// envelopes (#700) expand BEFORE the decoded payload enters the prefetch
|
|
315
|
+
// cache, so cached entries only ever resolve to expanded segments; a
|
|
316
|
+
// fragment-decode failure rejects the entry's payload and rides the cache's
|
|
317
|
+
// existing eviction path.
|
|
318
|
+
setPrefetchDecoder(async (response) => {
|
|
319
|
+
const payload = await deps.createFromFetch<RscPayload>(response);
|
|
320
|
+
await expandPayloadFragments(payload, deps.createFromReadableStream);
|
|
321
|
+
return payload;
|
|
322
|
+
});
|
|
317
323
|
|
|
318
324
|
// Create a bound renderSegments that reads rootLayout through the shell ref.
|
|
319
325
|
// The shell is set once at init and not swapped within a session (a cross-app
|