@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.
- package/dist/vite/index.js +148 -97
- package/package.json +1 -1
- package/skills/api-client/SKILL.md +211 -0
- package/skills/loader/SKILL.md +17 -17
- package/skills/mime-routes/SKILL.md +1 -1
- package/skills/rango/SKILL.md +1 -0
- package/skills/response-routes/SKILL.md +61 -43
- package/skills/typesafety/SKILL.md +3 -3
- package/src/__augment-tests__/augmented.check.ts +2 -3
- package/src/browser/navigation-client.ts +56 -68
- package/src/browser/prefetch/cache.ts +58 -27
- package/src/browser/prefetch/fetch.ts +92 -33
- package/src/browser/response-adapter.ts +7 -1
- package/src/browser/rsc-router.tsx +5 -0
- package/src/build/collect-fallback-refs.ts +107 -0
- package/src/build/generate-manifest.ts +28 -1
- package/src/build/index.ts +8 -1
- package/src/build/prefix-tree-utils.ts +123 -0
- package/src/build/route-trie.ts +43 -0
- package/src/client.tsx +4 -23
- package/src/errors.ts +0 -3
- package/src/href-client.ts +7 -8
- package/src/index.rsc.ts +1 -2
- package/src/index.ts +1 -2
- package/src/router/find-match.ts +54 -6
- package/src/router/lazy-includes.ts +33 -14
- package/src/router/loader-resolution.ts +63 -34
- package/src/router/manifest.ts +19 -6
- package/src/router/pattern-matching.ts +15 -2
- package/src/router/router-interfaces.ts +11 -0
- package/src/router/trie-matching.ts +22 -3
- package/src/router.ts +21 -7
- package/src/rsc/manifest-init.ts +28 -41
- package/src/rsc/response-error.ts +79 -12
- package/src/rsc/response-route-handler.ts +16 -13
- package/src/server/context.ts +32 -0
- package/src/server/request-context.ts +47 -9
- package/src/types/loader-types.ts +6 -3
- package/src/urls/index.ts +1 -2
- package/src/urls/type-extraction.ts +33 -24
- package/src/vite/discovery/discover-routers.ts +46 -29
- package/src/vite/discovery/state.ts +7 -0
- package/src/vite/plugins/client-ref-hashing.ts +12 -1
- package/src/vite/rango.ts +32 -4
- package/src/vite/utils/client-chunks.ts +41 -7
- package/src/vite/utils/manifest-utils.ts +8 -75
- 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 |
|
|
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
|
|
142
|
+
## JSON Wire Shape
|
|
143
143
|
|
|
144
|
-
`path.json()` handlers return plain data. The framework
|
|
145
|
-
|
|
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
|
-
{ "
|
|
150
|
-
|
|
151
|
-
// Error: HTTP 404 (or whatever status RouterError specifies)
|
|
152
|
-
|
|
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
|
|
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
|
-
###
|
|
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 {
|
|
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 (
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
//
|
|
219
|
-
|
|
220
|
-
console.log(
|
|
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
|
-
// =
|
|
244
|
+
// = { status: string; timestamp: number }
|
|
234
245
|
|
|
235
246
|
type ProductsData = RouteResponse<typeof apiPatterns, "products">;
|
|
236
|
-
// =
|
|
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
|
-
// =
|
|
278
|
+
// = { status: string; timestamp: number }
|
|
265
279
|
|
|
266
|
-
// RSC routes return
|
|
280
|
+
// RSC routes (no JSON payload) return never
|
|
267
281
|
type Home = Rango.PathResponse<"/">;
|
|
268
|
-
// =
|
|
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"); //
|
|
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 `
|
|
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
|
-
// =
|
|
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
|
|
433
|
+
// otherwise it resolves to never.
|
|
420
434
|
type BlogStats = Rango.PathResponse<"/blog/api/stats">;
|
|
421
|
-
// =
|
|
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
|
|
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 `
|
|
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"); //
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
115
|
+
let cachedEntry: DecodedPrefetch | null = null;
|
|
115
116
|
let hitKey: string | null = null;
|
|
116
117
|
if (canUsePrefetch) {
|
|
117
|
-
|
|
118
|
-
if (
|
|
118
|
+
cachedEntry = consumePrefetch(cacheKey);
|
|
119
|
+
if (cachedEntry) {
|
|
119
120
|
hitKey = cacheKey;
|
|
120
121
|
} else {
|
|
121
|
-
|
|
122
|
-
if (
|
|
122
|
+
cachedEntry = consumePrefetch(wildcardKey);
|
|
123
|
+
if (cachedEntry) hitKey = wildcardKey;
|
|
123
124
|
}
|
|
124
125
|
}
|
|
125
126
|
|
|
126
|
-
let
|
|
127
|
-
if (canUsePrefetch && !
|
|
128
|
-
|
|
129
|
-
if (
|
|
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
|
-
|
|
133
|
-
if (
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
//
|
|
261
|
-
// a different source page. Discard and refetch.
|
|
262
|
-
if (
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
277
|
+
({ payload: payloadPromise, streamComplete: streamCompletePromise } =
|
|
278
|
+
freshResult());
|
|
291
279
|
}
|
|
292
280
|
|
|
293
281
|
try {
|
|
294
|
-
const payload = await
|
|
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
|
|
5
|
-
* on subsequent navigation.
|
|
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
|
-
*
|
|
21
|
-
* still-downloading prefetch without
|
|
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
|
-
|
|
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<
|
|
68
|
-
*
|
|
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<
|
|
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
|
|
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
|
|
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
|
|
192
|
+
* for that (returns a Promise instead of a resolved entry).
|
|
161
193
|
*/
|
|
162
|
-
export function consumePrefetch(key: string):
|
|
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.
|
|
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
|
|
177
|
-
*
|
|
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<
|
|
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
|
|
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
|
-
|
|
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,
|
|
222
|
-
if (now -
|
|
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, {
|
|
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<
|
|
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<
|
|
297
|
+
promise: Promise<DecodedPrefetch | null>,
|
|
267
298
|
): void {
|
|
268
299
|
for (const k of keys) {
|
|
269
300
|
inflightPromises.set(k, promise);
|