@rangojs/router 0.0.0-experimental.115 → 0.0.0-experimental.116
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 +17 -18
- package/skills/api-client/SKILL.md +211 -0
- 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/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/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/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
|
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Collect the `"use client"` client-reference keys reachable from an error /
|
|
2
|
+
// notFound boundary registration, for routing them into the dedicated
|
|
3
|
+
// `app-fallback` chunk (see vite/utils/client-chunks.ts).
|
|
4
|
+
//
|
|
5
|
+
// A boundary registration is not always a bare client element. The common,
|
|
6
|
+
// load-bearing pattern wraps the client boundary in providers a thrown handler
|
|
7
|
+
// needs (the layout that would normally supply them did not mount):
|
|
8
|
+
//
|
|
9
|
+
// defaultErrorBoundary: ({ error }) => (
|
|
10
|
+
// <FallbackIntl locales={...}>
|
|
11
|
+
// <ThemedError error={error} /> // <- the real "use client" boundary
|
|
12
|
+
// </FallbackIntl>
|
|
13
|
+
// )
|
|
14
|
+
//
|
|
15
|
+
// So the value may be (a) a handler FUNCTION returning a tree, or (b) an element
|
|
16
|
+
// tree with the client boundary nested below server wrappers. We:
|
|
17
|
+
// 1. If it's a function, CALL it with synthetic props to get the returned tree.
|
|
18
|
+
// This only constructs JSX — the inner components are element `type`s, never
|
|
19
|
+
// invoked — so no hooks run. Guarded: a boundary that needs a real render
|
|
20
|
+
// context (request globals, etc.) throws and is skipped (graceful: it simply
|
|
21
|
+
// stays on the default grouping, as before).
|
|
22
|
+
// 2. Walk the resulting tree and report every element whose `.type` is a
|
|
23
|
+
// plugin-rsc client reference.
|
|
24
|
+
//
|
|
25
|
+
// Limit: a boundary that *conditionally* renders different client components based
|
|
26
|
+
// on the runtime error cannot be resolved statically — only the branch taken with
|
|
27
|
+
// the synthetic error is seen. Such cases fall back to the default chunk; the
|
|
28
|
+
// custom `clientChunks` function is the escape hatch.
|
|
29
|
+
|
|
30
|
+
const CLIENT_REF = Symbol.for("react.client.reference");
|
|
31
|
+
const MAX_DEPTH = 40;
|
|
32
|
+
|
|
33
|
+
// Synthetic props covering the error-boundary (`{ error, reset }`) and notFound
|
|
34
|
+
// (`{ pathname }`) handler shapes. The handler destructures what it needs.
|
|
35
|
+
const SYNTHETIC_PROPS = {
|
|
36
|
+
error: new Error("rango: build-time fallback-chunk discovery"),
|
|
37
|
+
reset: () => {},
|
|
38
|
+
pathname: "/",
|
|
39
|
+
info: { componentStack: "" },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
interface MaybeElement {
|
|
43
|
+
type?: { $$typeof?: symbol; $$id?: string };
|
|
44
|
+
props?: Record<string, unknown>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isReactNodeLike(v: unknown): boolean {
|
|
48
|
+
return (
|
|
49
|
+
Array.isArray(v) ||
|
|
50
|
+
(typeof v === "object" && v !== null && "$$typeof" in (v as object))
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function walkElementTree(
|
|
55
|
+
node: unknown,
|
|
56
|
+
report: (refKey: string) => void,
|
|
57
|
+
depth: number,
|
|
58
|
+
): void {
|
|
59
|
+
if (node == null || depth > MAX_DEPTH) return;
|
|
60
|
+
if (Array.isArray(node)) {
|
|
61
|
+
for (const child of node) walkElementTree(child, report, depth + 1);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (typeof node !== "object") return;
|
|
65
|
+
|
|
66
|
+
const el = node as MaybeElement;
|
|
67
|
+
const type = el.type;
|
|
68
|
+
if (type?.$$typeof === CLIENT_REF && typeof type.$$id === "string") {
|
|
69
|
+
// $$id is `<referenceKey>#<exportName>` in build mode — keep the referenceKey.
|
|
70
|
+
report(type.$$id.split("#")[0]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const props = el.props;
|
|
74
|
+
if (props && typeof props === "object") {
|
|
75
|
+
// Children are always nodes; other props are followed only when they look
|
|
76
|
+
// like React nodes (slots/icons), never arbitrary data objects.
|
|
77
|
+
walkElementTree(props.children, report, depth + 1);
|
|
78
|
+
for (const key in props) {
|
|
79
|
+
if (key === "children") continue;
|
|
80
|
+
const value = props[key];
|
|
81
|
+
if (isReactNodeLike(value)) walkElementTree(value, report, depth + 1);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Report every `"use client"` client-reference key reachable from a single
|
|
88
|
+
* error/notFound boundary registration (handler function or element tree).
|
|
89
|
+
*/
|
|
90
|
+
export function collectFallbackClientRefs(
|
|
91
|
+
boundary: unknown,
|
|
92
|
+
report: (refKey: string) => void,
|
|
93
|
+
): void {
|
|
94
|
+
try {
|
|
95
|
+
let node = boundary;
|
|
96
|
+
if (typeof node === "function") {
|
|
97
|
+
node = (node as (props: unknown) => unknown)(SYNTHETIC_PROPS);
|
|
98
|
+
}
|
|
99
|
+
walkElementTree(node, report, 0);
|
|
100
|
+
} catch {
|
|
101
|
+
// The boundary needs a real render context (request globals, hooks at the
|
|
102
|
+
// top level) or its tree has hostile getters. Its client refs can't be
|
|
103
|
+
// resolved statically — skip. It stays on the default grouping (no
|
|
104
|
+
// regression vs. not collecting), and the custom clientChunks fn is the
|
|
105
|
+
// escape hatch for such cases.
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -16,6 +16,7 @@ import type { EntryData, TrackedInclude } from "../server/context.js";
|
|
|
16
16
|
import type { TrailingSlashMode } from "../types.js";
|
|
17
17
|
import { createRouteHelpers } from "../route-definition.js";
|
|
18
18
|
import MapRootLayout from "../server/root-layout.js";
|
|
19
|
+
import { collectFallbackClientRefs } from "./collect-fallback-refs.js";
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* Node in the prefix tree
|
|
@@ -290,7 +291,17 @@ export function generateManifest<TEnv>(
|
|
|
290
291
|
export function generateManifestFull<TEnv>(
|
|
291
292
|
urlpatterns: UrlPatterns<TEnv, any>,
|
|
292
293
|
mountIndex: number = 0,
|
|
293
|
-
options?: {
|
|
294
|
+
options?: {
|
|
295
|
+
urlPrefix?: string;
|
|
296
|
+
/**
|
|
297
|
+
* Called once per `"use client"` component registered as an
|
|
298
|
+
* errorBoundary/notFoundBoundary fallback, with its client-reference key
|
|
299
|
+
* (`$$id`). Lets the build collect fallback module ids for dedicated
|
|
300
|
+
* chunking without exposing the otherwise-discarded EntryData tree. The
|
|
301
|
+
* EntryData map built below is local; this is the only seam that surfaces it.
|
|
302
|
+
*/
|
|
303
|
+
collectClientFallbackRef?: (refKey: string) => void;
|
|
304
|
+
},
|
|
294
305
|
): FullManifest {
|
|
295
306
|
const routeManifest: Record<string, string> = {};
|
|
296
307
|
const routeAncestry: Record<string, string[]> = {};
|
|
@@ -328,6 +339,22 @@ export function generateManifestFull<TEnv>(
|
|
|
328
339
|
},
|
|
329
340
|
);
|
|
330
341
|
|
|
342
|
+
// Surface the "use client" components registered as error/notFound fallbacks
|
|
343
|
+
// (route-tree errorBoundary()/notFoundBoundary() helpers, stored on EntryData).
|
|
344
|
+
// The boundary may be a handler function and/or wrap the client boundary in
|
|
345
|
+
// server providers, so walk the whole tree (see collectFallbackClientRefs).
|
|
346
|
+
if (options?.collectClientFallbackRef) {
|
|
347
|
+
const report = options.collectClientFallbackRef;
|
|
348
|
+
const collect = (boundary: unknown[] | undefined) => {
|
|
349
|
+
for (const item of boundary ?? [])
|
|
350
|
+
collectFallbackClientRefs(item, report);
|
|
351
|
+
};
|
|
352
|
+
for (const entry of manifest.values()) {
|
|
353
|
+
collect(entry.errorBoundary);
|
|
354
|
+
collect(entry.notFoundBoundary);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
331
358
|
// Collect root-level routes and trailing slash config
|
|
332
359
|
const routeTrailingSlash: Record<string, string> = {};
|
|
333
360
|
for (const [name, pattern] of patternsMap.entries()) {
|
package/src/build/index.ts
CHANGED
|
@@ -22,7 +22,14 @@ export {
|
|
|
22
22
|
type GeneratedManifest,
|
|
23
23
|
} from "./generate-manifest.js";
|
|
24
24
|
|
|
25
|
-
export {
|
|
25
|
+
export {
|
|
26
|
+
buildRouteTrie,
|
|
27
|
+
buildPerRouterTrie,
|
|
28
|
+
type TrieNode,
|
|
29
|
+
type TrieLeaf,
|
|
30
|
+
} from "./route-trie.js";
|
|
31
|
+
|
|
32
|
+
export { collectFallbackClientRefs } from "./collect-fallback-refs.js";
|
|
26
33
|
|
|
27
34
|
export {
|
|
28
35
|
writePerModuleRouteTypes,
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure prefix-tree walks shared by the build/discovery layer and the runtime
|
|
3
|
+
* trie builder. Kept in `build/` (not `vite/utils`) so runtime code
|
|
4
|
+
* (rsc/manifest-init via build/route-trie) can consume them without importing
|
|
5
|
+
* from the vite layer. `vite/utils/manifest-utils` re-exports them so existing
|
|
6
|
+
* vite-side imports stay unchanged.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Flatten prefix tree leaf nodes into precomputed route entries.
|
|
11
|
+
* Leaf nodes have no children (no nested includes), so their routes can be
|
|
12
|
+
* used directly by evaluateLazyEntry() without running the handler.
|
|
13
|
+
* Non-leaf nodes are skipped because they have nested lazy includes that
|
|
14
|
+
* require the handler to run for discovery.
|
|
15
|
+
*
|
|
16
|
+
* A leaf is also skipped when its staticPrefix collides with an ancestor
|
|
17
|
+
* include node's staticPrefix. That happens when a dynamic param collapses the
|
|
18
|
+
* staticPrefix of nested includes onto the parent's (e.g. `/m/:id/edit` -> sp
|
|
19
|
+
* `/m`): precomputing such a leaf under the collapsed prefix would let the
|
|
20
|
+
* ancestor's lazy entry claim a route it cannot register (the route is behind
|
|
21
|
+
* further nested lazy includes), producing a RouteNotFoundError at request time
|
|
22
|
+
* (issue #506). Those routes are resolved via the handler chain instead.
|
|
23
|
+
*/
|
|
24
|
+
export function flattenLeafEntries(
|
|
25
|
+
prefixTree: Record<string, any>,
|
|
26
|
+
routeManifest: Record<string, string>,
|
|
27
|
+
result: Array<{ staticPrefix: string; routes: Record<string, string> }>,
|
|
28
|
+
): void {
|
|
29
|
+
function visit(node: any, ancestorStaticPrefixes: Set<string>): void {
|
|
30
|
+
const children = node.children || {};
|
|
31
|
+
if (
|
|
32
|
+
Object.keys(children).length === 0 &&
|
|
33
|
+
node.routes &&
|
|
34
|
+
node.routes.length > 0
|
|
35
|
+
) {
|
|
36
|
+
// Leaf node. Skip if its staticPrefix collides with an ancestor include
|
|
37
|
+
// node's staticPrefix (dynamic-param collapse) — see doc comment above.
|
|
38
|
+
if (ancestorStaticPrefixes.has(node.staticPrefix)) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
// Collect its routes from the manifest
|
|
42
|
+
const routes: Record<string, string> = {};
|
|
43
|
+
for (const name of node.routes) {
|
|
44
|
+
if (name in routeManifest) {
|
|
45
|
+
routes[name] = routeManifest[name];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
result.push({ staticPrefix: node.staticPrefix, routes });
|
|
49
|
+
} else {
|
|
50
|
+
// Non-leaf: recurse into children, tracking this node's staticPrefix as
|
|
51
|
+
// an ancestor so a collapsed nested leaf below it is not over-claimed.
|
|
52
|
+
const nextAncestors = new Set(ancestorStaticPrefixes);
|
|
53
|
+
nextAncestors.add(node.staticPrefix);
|
|
54
|
+
for (const child of Object.values(children)) {
|
|
55
|
+
visit(child, nextAncestors);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
for (const node of Object.values(prefixTree)) {
|
|
60
|
+
visit(node, new Set());
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build the staticPrefix -> routes lookup the runtime shortcut consumes from a
|
|
66
|
+
* flat precomputed-entry array.
|
|
67
|
+
*
|
|
68
|
+
* A staticPrefix owned by MORE THAN ONE leaf include cannot be collapsed to a
|
|
69
|
+
* single routes object: `new Map(entries.map(e => [e.staticPrefix, e.routes]))`
|
|
70
|
+
* is last-wins, so one include's routes are silently dropped and mis-assigned
|
|
71
|
+
* to whichever entry evaluates first. Two distinct includes legitimately share a
|
|
72
|
+
* staticPrefix when a dynamic param collapses their literal prefixes onto the
|
|
73
|
+
* same value (e.g. `include("/shop/:cat", ...)` and a nested
|
|
74
|
+
* `include("/shop/:brand", ...)` both extract "/shop/"). Merging them is also
|
|
75
|
+
* wrong — assigning the merged set to the first matching entry makes findMatch
|
|
76
|
+
* pick the wrong handler for routes belonging to the other include, which then
|
|
77
|
+
* fails its `Store.manifest.has(routeKey)` invariant at render (500 on a valid
|
|
78
|
+
* route, dev/prod identical).
|
|
79
|
+
*
|
|
80
|
+
* So any shared staticPrefix is OMITTED from the shortcut entirely. Those
|
|
81
|
+
* includes fall through to the handler path in evaluateLazyEntry(), which is the
|
|
82
|
+
* ground truth (identical to pre-precomputed behavior). The shortcut is purely an
|
|
83
|
+
* optimization, so dropping a prefix can only cost a handler run, never change a
|
|
84
|
+
* result.
|
|
85
|
+
*/
|
|
86
|
+
export function buildPrecomputedByPrefix(
|
|
87
|
+
entries: Array<{ staticPrefix: string; routes: Record<string, string> }>,
|
|
88
|
+
): Map<string, Record<string, string>> {
|
|
89
|
+
const byPrefix = new Map<string, Record<string, string>>();
|
|
90
|
+
const shared = new Set<string>();
|
|
91
|
+
for (const e of entries) {
|
|
92
|
+
if (byPrefix.has(e.staticPrefix)) {
|
|
93
|
+
shared.add(e.staticPrefix);
|
|
94
|
+
} else {
|
|
95
|
+
byPrefix.set(e.staticPrefix, e.routes);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
for (const sp of shared) {
|
|
99
|
+
byPrefix.delete(sp);
|
|
100
|
+
}
|
|
101
|
+
return byPrefix;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Walk prefix tree to map each route name to its scope's staticPrefix.
|
|
106
|
+
*/
|
|
107
|
+
export function buildRouteToStaticPrefix(
|
|
108
|
+
prefixTree: Record<string, any>,
|
|
109
|
+
result: Record<string, string>,
|
|
110
|
+
): void {
|
|
111
|
+
function visit(node: any): void {
|
|
112
|
+
const sp = node.staticPrefix || "";
|
|
113
|
+
for (const name of node.routes || []) {
|
|
114
|
+
result[name] = sp;
|
|
115
|
+
}
|
|
116
|
+
for (const child of Object.values(node.children || {})) {
|
|
117
|
+
visit(child);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
for (const node of Object.values(prefixTree)) {
|
|
121
|
+
visit(node);
|
|
122
|
+
}
|
|
123
|
+
}
|
package/src/build/route-trie.ts
CHANGED
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
parsePattern,
|
|
11
11
|
type ParsedSegment,
|
|
12
12
|
} from "../router/pattern-matching.js";
|
|
13
|
+
import { buildRouteToStaticPrefix } from "./prefix-tree-utils.js";
|
|
14
|
+
import type { FullManifest } from "./generate-manifest.js";
|
|
13
15
|
|
|
14
16
|
// -- Trie data structures (compact keys for JSON serialization) --
|
|
15
17
|
|
|
@@ -98,6 +100,47 @@ export function buildRouteTrie(
|
|
|
98
100
|
return root;
|
|
99
101
|
}
|
|
100
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Build a per-router trie from a generated manifest. This is the single
|
|
105
|
+
* construction path shared by build/discovery (discover-routers.ts, serialized
|
|
106
|
+
* into the production chunk) and the dev/HMR runtime rebuild
|
|
107
|
+
* (rsc/manifest-init.ts). Keeping one code path is what guarantees the dev
|
|
108
|
+
* runtime trie and the production serialized trie are byte-for-byte identical
|
|
109
|
+
* (modulo `leaf.a` ancestry, which embeds the mount index and is debug-only).
|
|
110
|
+
*
|
|
111
|
+
* Returns null when the manifest has no route ancestry (no routes), matching
|
|
112
|
+
* the prior guard at both call sites.
|
|
113
|
+
*/
|
|
114
|
+
export function buildPerRouterTrie(manifest: FullManifest): TrieNode | null {
|
|
115
|
+
const ancestry = manifest._routeAncestry;
|
|
116
|
+
if (!ancestry || Object.keys(ancestry).length === 0) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Seed every route to the root static prefix (""), then override with each
|
|
121
|
+
// route's include() scope prefix from the prefix tree so the trie returns the
|
|
122
|
+
// correct `sp` for lazy-entry lookup in find-match.
|
|
123
|
+
const routeToStaticPrefix: Record<string, string> = {};
|
|
124
|
+
for (const name of Object.keys(manifest.routeManifest)) {
|
|
125
|
+
routeToStaticPrefix[name] = "";
|
|
126
|
+
}
|
|
127
|
+
if (manifest.prefixTree) {
|
|
128
|
+
buildRouteToStaticPrefix(manifest.prefixTree, routeToStaticPrefix);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return buildRouteTrie(
|
|
132
|
+
manifest.routeManifest,
|
|
133
|
+
ancestry,
|
|
134
|
+
routeToStaticPrefix,
|
|
135
|
+
manifest.routeTrailingSlash,
|
|
136
|
+
manifest.prerenderRoutes ? new Set(manifest.prerenderRoutes) : undefined,
|
|
137
|
+
manifest.passthroughRoutes
|
|
138
|
+
? new Set(manifest.passthroughRoutes)
|
|
139
|
+
: undefined,
|
|
140
|
+
manifest.responseTypeRoutes,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
101
144
|
/**
|
|
102
145
|
* Insert a route into the trie. Optional params expand into two branches at
|
|
103
146
|
* registration time (skip-first, then present), so each terminal lives at the
|
package/src/client.tsx
CHANGED
|
@@ -415,29 +415,10 @@ export {
|
|
|
415
415
|
// href-client.ts) — no import needed.
|
|
416
416
|
export { href, type PatternToPath } from "./href-client.js";
|
|
417
417
|
|
|
418
|
-
//
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
* Type guard for checking if a response envelope contains an error.
|
|
423
|
-
*
|
|
424
|
-
* @example
|
|
425
|
-
* ```typescript
|
|
426
|
-
* const result: ResponseEnvelope<Product> = await fetch(url).then(r => r.json());
|
|
427
|
-
* if (isResponseError(result)) {
|
|
428
|
-
* console.log(result.error.message, result.error.code);
|
|
429
|
-
* return;
|
|
430
|
-
* }
|
|
431
|
-
* result.data // fully typed as Product
|
|
432
|
-
* ```
|
|
433
|
-
*/
|
|
434
|
-
export function isResponseError<T>(
|
|
435
|
-
result: import("./urls.js").ResponseEnvelope<T>,
|
|
436
|
-
): result is import("./urls.js").ResponseEnvelope<T> & {
|
|
437
|
-
error: import("./urls.js").ResponseError;
|
|
438
|
-
} {
|
|
439
|
-
return result.error !== undefined;
|
|
440
|
-
}
|
|
418
|
+
// Problem Details (RFC 9457) error body type for consuming JSON response routes.
|
|
419
|
+
// On a non-2xx response, `await res.json()` yields this shape; on success the
|
|
420
|
+
// body is the bare value (no envelope). Discriminate on `res.ok` / status.
|
|
421
|
+
export type { ProblemDetails } from "./urls.js";
|
|
441
422
|
|
|
442
423
|
// Mount context for include() scoped components
|
|
443
424
|
export { useMount } from "./browser/react/use-mount.js";
|
package/src/errors.ts
CHANGED
|
@@ -225,7 +225,6 @@ export function isNetworkError(error: unknown): boolean {
|
|
|
225
225
|
export class RouterError extends Error {
|
|
226
226
|
name = "RouterError" as const;
|
|
227
227
|
code: string;
|
|
228
|
-
type?: string;
|
|
229
228
|
status: number;
|
|
230
229
|
cause?: unknown;
|
|
231
230
|
|
|
@@ -234,7 +233,6 @@ export class RouterError extends Error {
|
|
|
234
233
|
message: string,
|
|
235
234
|
options?: {
|
|
236
235
|
status?: number;
|
|
237
|
-
type?: string;
|
|
238
236
|
cause?: unknown;
|
|
239
237
|
},
|
|
240
238
|
) {
|
|
@@ -242,7 +240,6 @@ export class RouterError extends Error {
|
|
|
242
240
|
Object.setPrototypeOf(this, RouterError.prototype);
|
|
243
241
|
this.code = code;
|
|
244
242
|
this.status = options?.status ?? 500;
|
|
245
|
-
this.type = options?.type;
|
|
246
243
|
this.cause = options?.cause;
|
|
247
244
|
}
|
|
248
245
|
}
|