@rangojs/router 0.0.0-experimental.115 → 0.0.0-experimental.117

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/vite/index.js +148 -97
  2. package/package.json +1 -1
  3. package/skills/api-client/SKILL.md +211 -0
  4. package/skills/loader/SKILL.md +17 -17
  5. package/skills/mime-routes/SKILL.md +1 -1
  6. package/skills/rango/SKILL.md +1 -0
  7. package/skills/response-routes/SKILL.md +61 -43
  8. package/skills/typesafety/SKILL.md +3 -3
  9. package/src/__augment-tests__/augmented.check.ts +2 -3
  10. package/src/browser/navigation-client.ts +56 -68
  11. package/src/browser/prefetch/cache.ts +58 -27
  12. package/src/browser/prefetch/fetch.ts +92 -33
  13. package/src/browser/response-adapter.ts +7 -1
  14. package/src/browser/rsc-router.tsx +5 -0
  15. package/src/build/collect-fallback-refs.ts +107 -0
  16. package/src/build/generate-manifest.ts +28 -1
  17. package/src/build/index.ts +8 -1
  18. package/src/build/prefix-tree-utils.ts +123 -0
  19. package/src/build/route-trie.ts +43 -0
  20. package/src/client.tsx +4 -23
  21. package/src/errors.ts +0 -3
  22. package/src/href-client.ts +7 -8
  23. package/src/index.rsc.ts +1 -2
  24. package/src/index.ts +1 -2
  25. package/src/router/find-match.ts +54 -6
  26. package/src/router/lazy-includes.ts +33 -14
  27. package/src/router/loader-resolution.ts +63 -34
  28. package/src/router/manifest.ts +19 -6
  29. package/src/router/pattern-matching.ts +15 -2
  30. package/src/router/router-interfaces.ts +11 -0
  31. package/src/router/trie-matching.ts +22 -3
  32. package/src/router.ts +21 -7
  33. package/src/rsc/manifest-init.ts +28 -41
  34. package/src/rsc/response-error.ts +79 -12
  35. package/src/rsc/response-route-handler.ts +16 -13
  36. package/src/server/context.ts +32 -0
  37. package/src/server/request-context.ts +47 -9
  38. package/src/types/loader-types.ts +6 -3
  39. package/src/urls/index.ts +1 -2
  40. package/src/urls/type-extraction.ts +33 -24
  41. package/src/vite/discovery/discover-routers.ts +46 -29
  42. package/src/vite/discovery/state.ts +7 -0
  43. package/src/vite/plugins/client-ref-hashing.ts +12 -1
  44. package/src/vite/rango.ts +32 -4
  45. package/src/vite/utils/client-chunks.ts +41 -7
  46. package/src/vite/utils/manifest-utils.ts +8 -75
  47. package/src/vite/utils/shared-utils.ts +58 -0
@@ -68,16 +68,16 @@ export const urlpatterns = urls(({ path, layout, include }) => [
68
68
 
69
69
  ## Available Tags
70
70
 
71
- | Tag | Usage | Handler returns | Auto-wrap |
72
- | -------- | --------------- | ------------------ | ------------------------ |
73
- | `json` | `path.json()` | plain object/array | `{ data: T }` envelope |
74
- | `text` | `path.text()` | string | text/plain Response |
75
- | `html` | `path.html()` | string | text/html Response |
76
- | `xml` | `path.xml()` | string | application/xml Response |
77
- | `md` | `path.md()` | string | text/markdown Response |
78
- | `image` | `path.image()` | Response | pass-through |
79
- | `stream` | `path.stream()` | Response | pass-through |
80
- | `any` | `path.any()` | Response | pass-through |
71
+ | Tag | Usage | Handler returns | Auto-wrap |
72
+ | -------- | --------------- | ------------------ | ----------------------------- |
73
+ | `json` | `path.json()` | plain object/array | bare JSON value (no envelope) |
74
+ | `text` | `path.text()` | string | text/plain Response |
75
+ | `html` | `path.html()` | string | text/html Response |
76
+ | `xml` | `path.xml()` | string | application/xml Response |
77
+ | `md` | `path.md()` | string | text/markdown Response |
78
+ | `image` | `path.image()` | Response | pass-through |
79
+ | `stream` | `path.stream()` | Response | pass-through |
80
+ | `any` | `path.any()` | Response | pass-through |
81
81
 
82
82
  ## ResponseHandlerContext
83
83
 
@@ -139,22 +139,31 @@ path.json(
139
139
  );
140
140
  ```
141
141
 
142
- ## JSON Envelope
142
+ ## JSON Wire Shape
143
143
 
144
- `path.json()` handlers return plain data. The framework auto-wraps it
145
- in a `ResponseEnvelope<T>` discriminated union:
144
+ `path.json()` handlers return plain data. The framework serializes the handler's
145
+ return value **verbatim** (no envelope) on success, and an RFC 9457 `problem+json`
146
+ body on error. Discriminate with `res.ok` / the HTTP status — there is no in-body
147
+ `data`/`error` union:
146
148
 
147
149
  ```typescript
148
- // Success: HTTP 200
149
- { "data": { "status": "ok", "timestamp": 1700000000 } }
150
-
151
- // Error: HTTP 404 (or whatever status RouterError specifies)
152
- { "error": { "message": "Product 999 not found", "code": "NOT_FOUND" } }
150
+ // Success: HTTP 200, content-type application/json
151
+ { "status": "ok", "timestamp": 1700000000 }
152
+
153
+ // Error: HTTP 404 (or whatever status RouterError specifies),
154
+ // content-type application/problem+json
155
+ {
156
+ "title": "Not Found",
157
+ "status": 404,
158
+ "detail": "Product 999 not found",
159
+ "code": "NOT_FOUND"
160
+ // "stack": included in development only
161
+ }
153
162
  ```
154
163
 
155
164
  ### Error Handling with RouterError
156
165
 
157
- Throw `RouterError` to return structured error envelopes:
166
+ Throw `RouterError` to return a structured `problem+json` body:
158
167
 
159
168
  ```typescript
160
169
  import { RouterError } from "@rangojs/router";
@@ -199,25 +208,27 @@ path.json(
199
208
 
200
209
  ## Client-Side Type Safety
201
210
 
202
- ### ResponseEnvelope and isResponseError
211
+ ### Discriminating success vs. error with res.ok
212
+
213
+ Success bodies are the bare value; error bodies are RFC 9457 `ProblemDetails`.
214
+ Branch on `res.ok` (or the HTTP status) — not an in-body union:
203
215
 
204
216
  ```typescript
205
217
  "use client";
206
- import type { ResponseEnvelope, ResponseError } from "@rangojs/router/client";
207
- import { isResponseError } from "@rangojs/router/client";
218
+ import type { ProblemDetails } from "@rangojs/router";
208
219
 
209
220
  // Fetch a typed response
210
221
  const res = await fetch("/api/products/1");
211
- const result: ResponseEnvelope<Product> = await res.json();
212
222
 
213
- if (isResponseError(result)) {
214
- // result.error: ResponseError (message, code?, type?)
215
- // result.data: undefined
216
- console.error(result.error.message);
223
+ if (!res.ok) {
224
+ // Error body: application/problem+json
225
+ const problem: ProblemDetails = await res.json();
226
+ // problem.detail: string, problem.code: string, problem.status: number
227
+ console.error(problem.code, problem.detail);
217
228
  } else {
218
- // result.data: Product
219
- // result.error: undefined
220
- console.log(result.data.name);
229
+ // Success body: the bare value (no envelope)
230
+ const product: Product = await res.json();
231
+ console.log(product.name);
221
232
  }
222
233
  ```
223
234
 
@@ -230,20 +241,23 @@ import type { RouteResponse } from "@rangojs/router";
230
241
 
231
242
  // From the apiPatterns module (before include)
232
243
  type HealthData = RouteResponse<typeof apiPatterns, "health">;
233
- // = ResponseEnvelope<{ status: string; timestamp: number }>
244
+ // = { status: string; timestamp: number }
234
245
 
235
246
  type ProductsData = RouteResponse<typeof apiPatterns, "products">;
236
- // = ResponseEnvelope<{ id: string; name: string; price: number }[]>
247
+ // = { id: string; name: string; price: number }[]
237
248
  ```
238
249
 
250
+ `RouteResponse` is the bare success payload (the JSON wire shape) — the same value
251
+ a `fetch().then(r => r.json())` yields on a 2xx. Error bodies are `ProblemDetails`,
252
+ keyed off `res.ok` at runtime, not part of this type.
253
+
239
254
  ### Rango.PathResponse (global lookup by URL pattern or concrete path)
240
255
 
241
256
  `Rango.PathResponse` is ambient (no import) and reads from `RegisteredRoutes`,
242
257
  which carries response payload metadata. That surface is **not** auto-wired —
243
258
  without the augmentation below, `Rango.PathResponse` falls back to the generated
244
259
  path/search map, or to a permissive map when nothing is generated. Either way, it
245
- has no response payload metadata, so response routes resolve to
246
- `ResponseEnvelope<never>`:
260
+ has no response payload metadata, so response routes resolve to `never`:
247
261
 
248
262
  ```typescript
249
263
  // router.tsx
@@ -261,11 +275,11 @@ With that in place, look up the response type by URL pattern (ambient, no import
261
275
  ```typescript
262
276
  // After include("/api", apiPatterns) in main urls
263
277
  type Health = Rango.PathResponse<"/api/health">;
264
- // = ResponseEnvelope<{ status: string; timestamp: number }>
278
+ // = { status: string; timestamp: number }
265
279
 
266
- // RSC routes return ResponseEnvelope<never>
280
+ // RSC routes (no JSON payload) return never
267
281
  type Home = Rango.PathResponse<"/">;
268
- // = ResponseEnvelope<never>
282
+ // = never
269
283
  ```
270
284
 
271
285
  `Rango.PathResponse` also accepts a **concrete path**, so it types a `fetch`
@@ -280,7 +294,7 @@ async function get<T extends Rango.Path>(
280
294
  return fetch(href(path)).then((r) => r.json());
281
295
  }
282
296
 
283
- const product = await get("/api/products/42"); // ResponseEnvelope<Product>
297
+ const product = await get("/api/products/42"); // Product (bare value)
284
298
  ```
285
299
 
286
300
  Pattern keys (`/:id`) match exactly; a concrete path under a _nested_ dynamic
@@ -288,7 +302,7 @@ route can match several patterns and union their responses.
288
302
 
289
303
  `Rango.PathResponse` reports the JSON **wire** shape, not the handler's raw
290
304
  return: `path.json()` serializes with `JSON.stringify`, so a handler returning
291
- `{ createdAt: Date }` resolves to `ResponseEnvelope<{ createdAt: string }>`. This
305
+ `{ createdAt: Date }` resolves to the bare `{ createdAt: string }`. This
292
306
  runs through the ambient `Rango.JsonSerialize<T>` transform (`Date -> string`,
293
307
  honors `toJSON()`, drops functions/`undefined`, `bigint -> never`). The
294
308
  `RouteResponse` surface below applies the same `Rango.JsonSerialize` transform, so
@@ -412,13 +426,13 @@ import type { ParamsFor } from "@rangojs/router/client";
412
426
 
413
427
  // Scoped (before mount) -- use the module directly, no global wiring needed
414
428
  type Stats = RouteResponse<typeof blogApiPatterns, "stats">;
415
- // = ResponseEnvelope<{ views: number; visitors: number }>
429
+ // = { views: number; visitors: number }
416
430
 
417
431
  // After mounting -- names get prefixed.
418
432
  // Rango.PathResponse needs `RegisteredRoutes extends typeof router.routeMap` (see above),
419
- // otherwise it resolves to ResponseEnvelope<never>.
433
+ // otherwise it resolves to never.
420
434
  type BlogStats = Rango.PathResponse<"/blog/api/stats">;
421
- // = ResponseEnvelope<{ views: number; visitors: number }>
435
+ // = { views: number; visitors: number }
422
436
 
423
437
  // Params work through nested includes
424
438
  type LikesParams = ParamsFor<"blog.api.likes">;
@@ -462,7 +476,11 @@ best-effort basis.
462
476
  1. `path.json()` tags the route at the trie level with a MIME type
463
477
  2. `coreRequestHandler()` checks the tag before the RSC pipeline
464
478
  3. Tagged routes short-circuit: handler runs, Response is returned directly
465
- 4. JSON routes auto-wrap return values in `{ data }` / `{ error }` envelope
479
+ 4. JSON routes serialize the return value verbatim (bare) on success; a thrown error becomes an RFC 9457 `problem+json` body (`application/problem+json`)
466
480
  5. Client-side navigation to response routes gets `X-RSC-Reload` header, triggering hard navigation
467
481
  6. Response types flow through `_responses` phantom type on `UrlPatterns`, propagated by `include()`
468
482
  7. When multiple routes share a URL pattern, the trie merges them for content negotiation (see `/mime-routes`)
483
+
484
+ ## Consuming response routes
485
+
486
+ To call your own response-route JSON APIs from first-party TypeScript with a typed client (typed params, typed payloads inferred from the handler, no `.data`, typed `ProblemDetails` errors), see `/api-client` — a copy-paste recipe over `RouteResponse` + `ExtractParams` + a client-safe path builder. External/third-party consumers use the plain wire directly: bare JSON on success, `application/problem+json` on error.
@@ -57,7 +57,7 @@ the auto-generated `GeneratedRouteMap`, so **`rango generate` alone gives you
57
57
  path-checked `href()`** with no manual augmentation. Response and MIME payload
58
58
  inference is the exception: it comes only from `typeof router.routeMap` (via
59
59
  `RegisteredRoutes`), because `GeneratedRouteMap` carries paths + search but no
60
- payloads — so `Rango.PathResponse` resolves to `ResponseEnvelope<never>` until you wire
60
+ payloads — so `Rango.PathResponse` resolves to `never` until you wire
61
61
  `RegisteredRoutes`.
62
62
 
63
63
  Recommended setup:
@@ -237,7 +237,7 @@ async function get<T extends Rango.Path>(
237
237
  ): Promise<Rango.PathResponse<T>> {
238
238
  return fetch(href(path)).then((r) => r.json());
239
239
  }
240
- const product = await get("/api/products/42"); // ResponseEnvelope<Product>
240
+ const product = await get("/api/products/42"); // Product (bare value)
241
241
  ```
242
242
 
243
243
  Pattern keys (`/:id`) match exactly; a concrete path under a _nested_ dynamic
@@ -245,7 +245,7 @@ route can match several patterns and union their responses.
245
245
 
246
246
  `Rango.PathResponse` describes the JSON **wire** shape, not the handler's raw
247
247
  return. A `path.json()` handler returning `{ createdAt: Date }` resolves here to
248
- `ResponseEnvelope<{ createdAt: string }>`, matching what `r.json()` yields. This
248
+ `{ createdAt: string }` (bare value), matching what `r.json()` yields. This
249
249
  is applied via the ambient `Rango.JsonSerialize<T>` transform (`Date -> string`,
250
250
  honors `toJSON()`, drops functions/`undefined`, `bigint -> never`). A separate
251
251
  `Rango.FlightSerialize<T>` models the higher-fidelity RSC Flight boundary
@@ -9,7 +9,6 @@ import "./augment.js";
9
9
  import type { Handler, RouteParams, RouteSearchParams } from "../index.js";
10
10
  import type { DefaultRouteName } from "../types/global-namespace.js";
11
11
  import { href } from "../href-client.js";
12
- import type { ResponseEnvelope } from "../urls.js";
13
12
  import type { Money, TestBindings } from "./augment.js";
14
13
 
15
14
  type Expect<T extends true> = T;
@@ -79,13 +78,13 @@ wrappedHref("/not-a-route");
79
78
  type _orderWireByPattern = Expect<
80
79
  Equal<
81
80
  Rango.PathResponse<"/orders/:id">,
82
- ResponseEnvelope<{ id: string; total: number; placedAt: string }>
81
+ { id: string; total: number; placedAt: string }
83
82
  >
84
83
  >;
85
84
  type _orderWireByPath = Expect<
86
85
  Equal<
87
86
  Rango.PathResponse<"/orders/42">,
88
- ResponseEnvelope<{ id: string; total: number; placedAt: string }>
87
+ { id: string; total: number; placedAt: string }
89
88
  >
90
89
  >;
91
90
 
@@ -23,6 +23,7 @@ import {
23
23
  buildSourceKey,
24
24
  consumeInflightPrefetch,
25
25
  consumePrefetch,
26
+ type DecodedPrefetch,
26
27
  } from "./prefetch/cache.js";
27
28
 
28
29
  /**
@@ -111,26 +112,26 @@ export function createNavigationClient(
111
112
  const wildcardKey = buildPrefetchKey(rangoState, fetchUrl);
112
113
  const cacheKey = buildSourceKey(rangoState, previousUrl, fetchUrl);
113
114
 
114
- let cachedResponse: Response | null = null;
115
+ let cachedEntry: DecodedPrefetch | null = null;
115
116
  let hitKey: string | null = null;
116
117
  if (canUsePrefetch) {
117
- cachedResponse = consumePrefetch(cacheKey);
118
- if (cachedResponse) {
118
+ cachedEntry = consumePrefetch(cacheKey);
119
+ if (cachedEntry) {
119
120
  hitKey = cacheKey;
120
121
  } else {
121
- cachedResponse = consumePrefetch(wildcardKey);
122
- if (cachedResponse) hitKey = wildcardKey;
122
+ cachedEntry = consumePrefetch(wildcardKey);
123
+ if (cachedEntry) hitKey = wildcardKey;
123
124
  }
124
125
  }
125
126
 
126
- let inflightResponsePromise: Promise<Response | null> | null = null;
127
- if (canUsePrefetch && !cachedResponse) {
128
- inflightResponsePromise = consumeInflightPrefetch(cacheKey);
129
- if (inflightResponsePromise) {
127
+ let inflightEntryPromise: Promise<DecodedPrefetch | null> | null = null;
128
+ if (canUsePrefetch && !cachedEntry) {
129
+ inflightEntryPromise = consumeInflightPrefetch(cacheKey);
130
+ if (inflightEntryPromise) {
130
131
  hitKey = cacheKey;
131
132
  } else {
132
- inflightResponsePromise = consumeInflightPrefetch(wildcardKey);
133
- if (inflightResponsePromise) hitKey = wildcardKey;
133
+ inflightEntryPromise = consumeInflightPrefetch(wildcardKey);
134
+ if (inflightEntryPromise) hitKey = wildcardKey;
134
135
  }
135
136
  }
136
137
  // Track when the stream completes
@@ -217,29 +218,32 @@ export function createNavigationClient(
217
218
  });
218
219
  };
219
220
 
220
- let responsePromise: Promise<Response>;
221
+ // A warm prefetch hit returns its eagerly-decoded payload directly: the
222
+ // route's chunks were imported during the prefetch, so this click runs
223
+ // no decode and no network. Only the fresh path runs createFromFetch and
224
+ // resolves the local streamComplete (via doFreshFetch's teeWithCompletion
225
+ // and the control-header short-circuits in validateRscHeaders).
226
+ const freshResult = (): {
227
+ payload: Promise<RscPayload>;
228
+ streamComplete: Promise<void>;
229
+ } => ({
230
+ payload: deps.createFromFetch<RscPayload>(doFreshFetch()),
231
+ streamComplete,
232
+ });
233
+
234
+ let payloadPromise: Promise<RscPayload>;
235
+ let streamCompletePromise: Promise<void>;
221
236
 
222
- if (cachedResponse) {
237
+ if (cachedEntry) {
223
238
  if (tx) {
224
- browserDebugLog(tx, "prefetch cache hit", {
239
+ browserDebugLog(tx, "prefetch cache hit (warm)", {
225
240
  key: hitKey,
226
241
  wildcard: hitKey === wildcardKey,
227
242
  });
228
243
  }
229
- responsePromise = Promise.resolve(cachedResponse).then((response) => {
230
- const validated = validateRscHeaders(response, "prefetch cache");
231
- if (validated instanceof Promise) return validated;
232
-
233
- return teeWithCompletion(
234
- validated,
235
- () => {
236
- if (tx) browserDebugLog(tx, "stream complete (from cache)");
237
- resolveStreamComplete();
238
- },
239
- signal,
240
- );
241
- });
242
- } else if (inflightResponsePromise) {
244
+ payloadPromise = cachedEntry.payload;
245
+ streamCompletePromise = cachedEntry.streamComplete;
246
+ } else if (inflightEntryPromise) {
243
247
  if (tx) {
244
248
  browserDebugLog(tx, "reusing inflight prefetch", {
245
249
  key: hitKey,
@@ -247,51 +251,35 @@ export function createNavigationClient(
247
251
  });
248
252
  }
249
253
  const adoptedViaWildcard = hitKey === wildcardKey;
250
- responsePromise = inflightResponsePromise.then(async (response) => {
251
- if (!response) {
252
- if (tx) {
253
- browserDebugLog(tx, "inflight prefetch unavailable, refetching");
254
- }
255
- return doFreshFetch();
254
+ const entry = await inflightEntryPromise;
255
+ if (!entry) {
256
+ if (tx) {
257
+ browserDebugLog(tx, "inflight prefetch unavailable, refetching");
256
258
  }
257
-
258
- // Cross-source safety: an inflight promise adopted via the
259
- // wildcard key may turn out to be source-scoped (server emitted
260
- // `X-RSC-Prefetch-Scope: source`), which means it was built for
261
- // a different source page. Discard and refetch.
262
- if (
263
- adoptedViaWildcard &&
264
- response.headers.get("x-rsc-prefetch-scope") === "source"
265
- ) {
266
- if (tx) {
267
- browserDebugLog(
268
- tx,
269
- "wildcard inflight turned out source-scoped, refetching",
270
- );
271
- }
272
- return doFreshFetch();
259
+ ({ payload: payloadPromise, streamComplete: streamCompletePromise } =
260
+ freshResult());
261
+ } else if (adoptedViaWildcard && entry.scope === "source") {
262
+ // A wildcard-adopted inflight that turned out source-scoped was
263
+ // built for a different source page. Discard and refetch.
264
+ if (tx) {
265
+ browserDebugLog(
266
+ tx,
267
+ "wildcard inflight turned out source-scoped, refetching",
268
+ );
273
269
  }
274
-
275
- const validated = validateRscHeaders(response, "inflight prefetch");
276
- if (validated instanceof Promise) return validated;
277
-
278
- return teeWithCompletion(
279
- validated,
280
- () => {
281
- if (tx) {
282
- browserDebugLog(tx, "stream complete (from inflight prefetch)");
283
- }
284
- resolveStreamComplete();
285
- },
286
- signal,
287
- );
288
- });
270
+ ({ payload: payloadPromise, streamComplete: streamCompletePromise } =
271
+ freshResult());
272
+ } else {
273
+ payloadPromise = entry.payload;
274
+ streamCompletePromise = entry.streamComplete;
275
+ }
289
276
  } else {
290
- responsePromise = doFreshFetch();
277
+ ({ payload: payloadPromise, streamComplete: streamCompletePromise } =
278
+ freshResult());
291
279
  }
292
280
 
293
281
  try {
294
- const payload = await deps.createFromFetch<RscPayload>(responsePromise);
282
+ const payload = await payloadPromise;
295
283
 
296
284
  if (tx) {
297
285
  browserDebugLog(tx, "response received", {
@@ -300,7 +288,7 @@ export function createNavigationClient(
300
288
  diffCount: payload.metadata?.diff?.length ?? 0,
301
289
  });
302
290
  }
303
- return { payload, streamComplete };
291
+ return { payload, streamComplete: streamCompletePromise };
304
292
  } catch (error) {
305
293
  // Convert network-level errors to NetworkError for proper handling
306
294
  if (isNetworkError(error)) {
@@ -1,8 +1,13 @@
1
1
  /**
2
2
  * Prefetch Cache
3
3
  *
4
- * In-memory cache storing prefetched Response objects for instant cache hits
5
- * on subsequent navigation. Two key scopes are in play:
4
+ * In-memory cache storing eagerly-decoded prefetch payloads for instant,
5
+ * already-warm cache hits on subsequent navigation. A prefetch fetches the
6
+ * RSC partial AND decodes it (createFromFetch) up front — decoding the Flight
7
+ * stream resolves the route's client references, so the route's JS chunks are
8
+ * imported during prefetch rather than on click. The decoded payload is reused
9
+ * verbatim by navigation, so a prefetched click loads no new code. Two key
10
+ * scopes are in play:
6
11
  * - Wildcard (default): built by `buildPrefetchKey(rangoState, target)` —
7
12
  * shape `rangoState\0/target?...`. Shared across all source pages and
8
13
  * invalidated automatically when Rango state bumps (deploy or
@@ -17,8 +22,8 @@
17
22
  * from other pages.
18
23
  *
19
24
  * Also tracks in-flight prefetch promises. Each promise resolves to the
20
- * navigation branch of a tee'd Response, allowing navigation to adopt a
21
- * still-downloading prefetch without reparsing or buffering the body. A
25
+ * decoded prefetch entry (or null), letting navigation adopt a
26
+ * still-downloading prefetch without issuing a duplicate request. A
22
27
  * single promise can be registered under multiple alias keys (see
23
28
  * `setInflightPromiseWithAliases`) so same-source navigations adopt via
24
29
  * their source key while cross-source ones fall through to the wildcard
@@ -30,6 +35,31 @@
30
35
 
31
36
  import { abortAllPrefetches } from "./queue.js";
32
37
  import { invalidateRangoState } from "../rango-state.js";
38
+ import type { RscPayload } from "../types.js";
39
+
40
+ /**
41
+ * A prefetch that has been fetched AND eagerly decoded. Storing the decoded
42
+ * payload (not the raw Response) is what makes a prefetched navigation "warm":
43
+ * decoding the Flight stream during prefetch pulls the route's client chunks,
44
+ * so the click reuses ready elements and loads no new JS.
45
+ */
46
+ export interface DecodedPrefetch {
47
+ /** The eagerly-decoded RSC payload. Reused verbatim by navigation. */
48
+ payload: Promise<RscPayload>;
49
+ /**
50
+ * Resolves when the underlying RSC stream finishes draining. Navigation
51
+ * forwards this as its streamComplete so scroll/revalidation gating is
52
+ * unchanged from the fresh-fetch path.
53
+ */
54
+ streamComplete: Promise<void>;
55
+ /**
56
+ * Prefetch scope as tagged by the server via `X-RSC-Prefetch-Scope`.
57
+ * `"source"` means the response is source-page-sensitive and must not be
58
+ * reused by a navigation from a different page — navigation enforces this
59
+ * when it adopted an inflight entry through the wildcard key.
60
+ */
61
+ scope: "source" | "wildcard";
62
+ }
33
63
 
34
64
  // Default TTL: 5 minutes. Overridden by initPrefetchCache() with
35
65
  // the server-configured prefetchCacheTTL from router options.
@@ -55,7 +85,7 @@ export function isPrefetchCacheDisabled(): boolean {
55
85
  const MAX_PREFETCH_CACHE_SIZE = 50;
56
86
 
57
87
  interface PrefetchCacheEntry {
58
- response: Response;
88
+ entry: DecodedPrefetch;
59
89
  timestamp: number;
60
90
  }
61
91
 
@@ -63,17 +93,19 @@ const cache = new Map<string, PrefetchCacheEntry>();
63
93
  const inflight = new Set<string>();
64
94
 
65
95
  /**
66
- * In-flight promise map. When a prefetch fetch is in progress, its
67
- * Promise<Response | null> is stored here so navigation can await
68
- * it instead of starting a duplicate request.
96
+ * In-flight promise map. When a prefetch fetch+decode is in progress, its
97
+ * Promise<DecodedPrefetch | null> is stored here so navigation can await it
98
+ * instead of starting a duplicate request. Resolves to null when the prefetch
99
+ * failed, was aborted, or carried a control header (reload/redirect) that the
100
+ * navigation must re-fetch to act on.
69
101
  */
70
- const inflightPromises = new Map<string, Promise<Response | null>>();
102
+ const inflightPromises = new Map<string, Promise<DecodedPrefetch | null>>();
71
103
 
72
104
  /**
73
105
  * Alias map for in-flight promises registered under multiple keys (see
74
106
  * dual inflight in prefetch/fetch.ts). Records each key's sibling set so
75
107
  * that consuming or clearing any one key atomically removes every alias —
76
- * guaranteeing a single consumer for the shared Response stream.
108
+ * guaranteeing a single consumer for the shared decode.
77
109
  */
78
110
  const inflightAliases = new Map<string, string[]>();
79
111
 
@@ -152,14 +184,14 @@ export function hasPrefetch(key: string): boolean {
152
184
  }
153
185
 
154
186
  /**
155
- * Consume a cached prefetch response. Returns null if not found or expired.
156
- * One-time consumption: the entry is deleted after retrieval.
187
+ * Consume a cached, eagerly-decoded prefetch. Returns null if not found or
188
+ * expired. One-time consumption: the entry is deleted after retrieval.
157
189
  * Returns null when caching is disabled (TTL <= 0).
158
190
  *
159
191
  * Does NOT check in-flight prefetches — use consumeInflightPrefetch()
160
- * for that (returns a Promise instead of a Response).
192
+ * for that (returns a Promise instead of a resolved entry).
161
193
  */
162
- export function consumePrefetch(key: string): Response | null {
194
+ export function consumePrefetch(key: string): DecodedPrefetch | null {
163
195
  if (cacheTTL <= 0) return null;
164
196
  const entry = cache.get(key);
165
197
  if (!entry) return null;
@@ -168,13 +200,14 @@ export function consumePrefetch(key: string): Response | null {
168
200
  return null;
169
201
  }
170
202
  cache.delete(key);
171
- return entry.response;
203
+ return entry.entry;
172
204
  }
173
205
 
174
206
  /**
175
207
  * Consume an in-flight prefetch promise. Returns null if no prefetch is
176
- * in-flight for this key. The returned Promise resolves to the buffered
177
- * Response (or null if the fetch failed/was aborted).
208
+ * in-flight for this key. The returned Promise resolves to the decoded
209
+ * prefetch entry (or null if the fetch failed/was aborted, or carried a
210
+ * control header the navigation must re-fetch to honor).
178
211
  *
179
212
  * One-time consumption: the promise entry is removed (along with any
180
213
  * sibling aliases registered via `setInflightPromiseWithAliases`) so a
@@ -188,7 +221,7 @@ export function consumePrefetch(key: string): Response | null {
188
221
  */
189
222
  export function consumeInflightPrefetch(
190
223
  key: string,
191
- ): Promise<Response | null> | null {
224
+ ): Promise<DecodedPrefetch | null> | null {
192
225
  const promise = inflightPromises.get(key);
193
226
  if (!promise) return null;
194
227
  // Remove the promise under every alias so a second consumer cannot
@@ -201,16 +234,14 @@ export function consumeInflightPrefetch(
201
234
  }
202
235
 
203
236
  /**
204
- * Store a prefetch response in the in-memory cache.
205
- * The response should be a clone() of the original so the caller can
206
- * still consume the body. The clone's body streams independently.
237
+ * Store an eagerly-decoded prefetch in the in-memory cache.
207
238
  *
208
239
  * Skips storage if the generation has changed since the fetch started
209
240
  * (a server action invalidated the cache mid-flight).
210
241
  */
211
242
  export function storePrefetch(
212
243
  key: string,
213
- response: Response,
244
+ entry: DecodedPrefetch,
214
245
  fetchGeneration: number,
215
246
  ): void {
216
247
  if (cacheTTL <= 0) return;
@@ -218,8 +249,8 @@ export function storePrefetch(
218
249
 
219
250
  // Evict expired entries
220
251
  const now = Date.now();
221
- for (const [k, entry] of cache) {
222
- if (now - entry.timestamp > cacheTTL) {
252
+ for (const [k, cached] of cache) {
253
+ if (now - cached.timestamp > cacheTTL) {
223
254
  cache.delete(k);
224
255
  }
225
256
  }
@@ -230,7 +261,7 @@ export function storePrefetch(
230
261
  if (oldest) cache.delete(oldest);
231
262
  }
232
263
 
233
- cache.set(key, { response, timestamp: now });
264
+ cache.set(key, { entry, timestamp: now });
234
265
  }
235
266
 
236
267
  /**
@@ -250,7 +281,7 @@ export function markPrefetchInflight(key: string): void {
250
281
  */
251
282
  export function setInflightPromise(
252
283
  key: string,
253
- promise: Promise<Response | null>,
284
+ promise: Promise<DecodedPrefetch | null>,
254
285
  ): void {
255
286
  inflightPromises.set(key, promise);
256
287
  }
@@ -263,7 +294,7 @@ export function setInflightPromise(
263
294
  */
264
295
  export function setInflightPromiseWithAliases(
265
296
  keys: string[],
266
- promise: Promise<Response | null>,
297
+ promise: Promise<DecodedPrefetch | null>,
267
298
  ): void {
268
299
  for (const k of keys) {
269
300
  inflightPromises.set(k, promise);