@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
@@ -13,6 +13,7 @@ import {
13
13
  setRouteTrie,
14
14
  setRouterManifest,
15
15
  setRouterTrie,
16
+ setRouterPrecomputedEntries,
16
17
  } from "../route-map-builder.js";
17
18
 
18
19
  /**
@@ -36,47 +37,13 @@ export async function buildRouterTrieFromUrlpatterns(
36
37
  undefined,
37
38
  router.basename ? { urlPrefix: router.basename } : undefined,
38
39
  );
39
- if (
40
- generated._routeAncestry &&
41
- Object.keys(generated._routeAncestry).length > 0
42
- ) {
43
- const { buildRouteTrie } = await import("../build/route-trie.js");
44
- // Map each route to its include() staticPrefix so the trie
45
- // returns the correct sp for lazy entry lookup in findMatch.
46
- const routeToStaticPrefix: Record<string, string> = {};
47
- for (const name of Object.keys(generated.routeManifest)) {
48
- routeToStaticPrefix[name] = "";
49
- }
50
- // Override with prefix from include() entries so the trie
51
- // returns the correct sp for lazy entry lookup in findMatch.
52
- // Walk recursively to include routes in nested includes.
53
- if (generated.prefixTree) {
54
- const visitPrefixNode = (node: any): void => {
55
- const sp = node.staticPrefix || "";
56
- for (const route of node.routes || []) {
57
- routeToStaticPrefix[route] = sp;
58
- }
59
- for (const child of Object.values(node.children || {})) {
60
- visitPrefixNode(child);
61
- }
62
- };
63
- for (const node of Object.values(generated.prefixTree)) {
64
- visitPrefixNode(node);
65
- }
66
- }
67
- const trie = buildRouteTrie(
68
- generated.routeManifest,
69
- generated._routeAncestry,
70
- routeToStaticPrefix,
71
- generated.routeTrailingSlash,
72
- generated.prerenderRoutes
73
- ? new Set(generated.prerenderRoutes)
74
- : undefined,
75
- generated.passthroughRoutes
76
- ? new Set(generated.passthroughRoutes)
77
- : undefined,
78
- generated.responseTypeRoutes,
79
- );
40
+ // Build the trie through the SAME shared helper the production discovery uses
41
+ // (discover-routers.ts), so the dev runtime-rebuilt trie and the prod
42
+ // serialized trie cannot drift. buildPerRouterTrie returns null when there
43
+ // are no routes.
44
+ const { buildPerRouterTrie } = await import("../build/route-trie.js");
45
+ const trie = buildPerRouterTrie(generated);
46
+ if (trie) {
80
47
  setRouterTrie(router.id, trie);
81
48
  // Set global trie only if not already set by another router
82
49
  if (!getRouteTrie()) {
@@ -84,6 +51,26 @@ export async function buildRouterTrieFromUrlpatterns(
84
51
  }
85
52
  }
86
53
  setRouterManifest(router.id, generated.routeManifest);
54
+
55
+ // Match the production discovery path: precompute leaf-include entries so the
56
+ // match-time shortcut in evaluateLazyEntry applies in dev/Cloudflare too.
57
+ // Without this, dev re-runs each matched leaf include's handler at match time
58
+ // (evaluateLazyEntry) AND again at render time (loadManifest); with it, the
59
+ // match-time run is skipped and the handler runs once per first request.
60
+ // Identical route ownership to the handler path (the shortcut is guarded by
61
+ // the same prefixIsShared and #506 checks production uses).
62
+ const { flattenLeafEntries } = await import("../build/prefix-tree-utils.js");
63
+ const precomputed: Array<{
64
+ staticPrefix: string;
65
+ routes: Record<string, string>;
66
+ }> = [];
67
+ flattenLeafEntries(
68
+ generated.prefixTree,
69
+ generated.routeManifest,
70
+ precomputed,
71
+ );
72
+ setRouterPrecomputedEntries(router.id, precomputed);
73
+
87
74
  // Merge into global manifest (needed for reverse/href across routers)
88
75
  const existing = hasCachedManifest() ? getGlobalRouteMap() : {};
89
76
  setCachedManifest({ ...existing, ...generated.routeManifest });
@@ -1,37 +1,104 @@
1
1
  /**
2
- * Response Error Payload Builder
2
+ * Problem Details (RFC 9457) Builder
3
3
  *
4
- * Builds a ResponseError object from a caught error, controlling
5
- * what information is exposed based on error type and environment.
4
+ * Builds a problem+json error body from a caught error, controlling what
5
+ * information is exposed based on error type and environment.
6
6
  */
7
7
 
8
8
  import { RouterError } from "../errors.js";
9
- import type { ResponseError } from "../urls.js";
9
+ import type { ProblemDetails } from "../urls.js";
10
10
 
11
11
  /**
12
- * Build a ResponseError payload from a caught error.
13
- * RouterError messages are always exposed (developer-crafted).
12
+ * HTTP reason phrases for the problem `title` member. Inlined because the
13
+ * router targets edge/worker runtimes without node's `http.STATUS_CODES`;
14
+ * covers the full standard 4xx/5xx range, with a generic fallback for any
15
+ * non-standard status a handler might set.
16
+ */
17
+ const STATUS_PHRASES: Record<number, string> = {
18
+ 400: "Bad Request",
19
+ 401: "Unauthorized",
20
+ 402: "Payment Required",
21
+ 403: "Forbidden",
22
+ 404: "Not Found",
23
+ 405: "Method Not Allowed",
24
+ 406: "Not Acceptable",
25
+ 407: "Proxy Authentication Required",
26
+ 408: "Request Timeout",
27
+ 409: "Conflict",
28
+ 410: "Gone",
29
+ 411: "Length Required",
30
+ 412: "Precondition Failed",
31
+ 413: "Payload Too Large",
32
+ 414: "URI Too Long",
33
+ 415: "Unsupported Media Type",
34
+ 416: "Range Not Satisfiable",
35
+ 417: "Expectation Failed",
36
+ 418: "I'm a Teapot",
37
+ 421: "Misdirected Request",
38
+ 422: "Unprocessable Entity",
39
+ 423: "Locked",
40
+ 424: "Failed Dependency",
41
+ 425: "Too Early",
42
+ 426: "Upgrade Required",
43
+ 428: "Precondition Required",
44
+ 429: "Too Many Requests",
45
+ 431: "Request Header Fields Too Large",
46
+ 451: "Unavailable For Legal Reasons",
47
+ 500: "Internal Server Error",
48
+ 501: "Not Implemented",
49
+ 502: "Bad Gateway",
50
+ 503: "Service Unavailable",
51
+ 504: "Gateway Timeout",
52
+ 505: "HTTP Version Not Supported",
53
+ 506: "Variant Also Negotiates",
54
+ 507: "Insufficient Storage",
55
+ 508: "Loop Detected",
56
+ 510: "Not Extended",
57
+ 511: "Network Authentication Required",
58
+ };
59
+
60
+ function statusPhrase(status: number): string {
61
+ return STATUS_PHRASES[status] ?? "Error";
62
+ }
63
+
64
+ /**
65
+ * Build an RFC 9457 problem+json body from a caught error.
66
+ * RouterError messages/codes are always exposed (developer-crafted).
14
67
  * Standard Error messages are hidden in production.
68
+ *
69
+ * The `type` member is omitted in this phase: per RFC 9457 an absent `type` is
70
+ * treated as `"about:blank"` (no semantics beyond the HTTP status), so emitting
71
+ * it adds nothing. Per-route problem-type URIs arrive with the declared-errors
72
+ * map later. `code` is always present so consumers can branch on it
73
+ * (`"INTERNAL"` for non-RouterError failures).
15
74
  */
16
- export function createResponseErrorPayload(
75
+ export function createProblemDetails(
17
76
  error: unknown,
77
+ status: number,
18
78
  isDev: boolean,
19
- ): ResponseError {
79
+ ): ProblemDetails {
20
80
  if (error instanceof RouterError) {
21
81
  return {
22
- message: error.message,
82
+ title: statusPhrase(status),
83
+ status,
84
+ detail: error.message,
23
85
  code: error.code,
24
- ...(error.type ? { type: error.type } : {}),
25
86
  ...(isDev && error.stack ? { stack: error.stack } : {}),
26
87
  };
27
88
  }
28
89
  if (error instanceof Error) {
29
90
  return {
30
- message: isDev ? error.message : "Internal Server Error",
91
+ title: statusPhrase(status),
92
+ status,
93
+ detail: isDev ? error.message : "Internal Server Error",
94
+ code: "INTERNAL",
31
95
  ...(isDev && error.stack ? { stack: error.stack } : {}),
32
96
  };
33
97
  }
34
98
  return {
35
- message: isDev ? String(error) : "Internal Server Error",
99
+ title: statusPhrase(status),
100
+ status,
101
+ detail: isDev ? String(error) : "Internal Server Error",
102
+ code: "INTERNAL",
36
103
  };
37
104
  }
@@ -21,7 +21,7 @@ import {
21
21
  import type { MiddlewareFn } from "../router/middleware.js";
22
22
  import type { EntryData } from "../server/context.js";
23
23
  import type { HandlerContext } from "./handler-context.js";
24
- import { createResponseErrorPayload } from "./response-error.js";
24
+ import { createProblemDetails } from "./response-error.js";
25
25
  import {
26
26
  createResponseWithMergedHeaders,
27
27
  finalizeResponse,
@@ -131,13 +131,10 @@ export async function handleResponseRoute<TEnv>(
131
131
 
132
132
  // Handled before the MIME lookup (json is also a RESPONSE_TYPE_MIME key).
133
133
  if (preview.responseType === "json") {
134
- return createResponseWithMergedHeaders(
135
- JSON.stringify({ data: result }),
136
- {
137
- status: 200,
138
- headers: { "content-type": "application/json;charset=utf-8" },
139
- },
140
- );
134
+ return createResponseWithMergedHeaders(JSON.stringify(result), {
135
+ status: 200,
136
+ headers: { "content-type": "application/json;charset=utf-8" },
137
+ });
141
138
  }
142
139
 
143
140
  // Object.hasOwn (not truthiness) so prototype names like "toString" are not
@@ -157,16 +154,22 @@ export async function handleResponseRoute<TEnv>(
157
154
  } catch (error) {
158
155
  handlerCtx.callOnError(error, "handler", errorCtx);
159
156
  const isDev = process.env.NODE_ENV !== "production";
160
- const status = error instanceof RouterError ? error.status : 500;
157
+ const derivedStatus = error instanceof RouterError ? error.status : 500;
158
+ // Resolve the effective status the same way createResponseWithMergedHeaders
159
+ // will (ctx.res.status override) so the problem body's status/title match
160
+ // the actual HTTP status — e.g. when a handler called ctx.setStatus()
161
+ // before throwing.
162
+ const status =
163
+ reqCtx.res.status !== 200 ? reqCtx.res.status : derivedStatus;
161
164
 
162
165
  if (preview.responseType === "json") {
163
166
  return createResponseWithMergedHeaders(
164
- JSON.stringify({
165
- error: createResponseErrorPayload(error, isDev),
166
- }),
167
+ JSON.stringify(createProblemDetails(error, status, isDev)),
167
168
  {
168
169
  status,
169
- headers: { "content-type": "application/json;charset=utf-8" },
170
+ headers: {
171
+ "content-type": "application/problem+json;charset=utf-8",
172
+ },
170
173
  },
171
174
  );
172
175
  }
@@ -805,3 +805,35 @@ export function runInsideLoaderScope<T>(fn: () => T): T {
805
805
  export function runInsideLoaderBodyScope<T>(fn: () => T): T {
806
806
  return loaderBodyScopeALS.run({ active: true }, fn);
807
807
  }
808
+
809
+ // Scope for handle PUSH CALLBACKS (push(() => ...), including async ones).
810
+ // A push callback's value is stored as-is; if it is a promise it is NOT tracked
811
+ // by handleStore.settled and does not block segment resolution, so a
812
+ // ctx.use(loader) made from inside such a callback can never form a rendered()
813
+ // deadlock. This is an ALS (not a plain boolean) so the exemption survives the
814
+ // callback's own awaits — an async push callback that resumes after `await`
815
+ // still reads as "inside a push callback" and stays out of the deadlock guard.
816
+ const PUSH_CALLBACK_SCOPE_KEY = Symbol.for(
817
+ "rangojs-router:push-callback-scope",
818
+ );
819
+ const pushCallbackScopeALS: AsyncLocalStorage<{ active: true }> = ((
820
+ globalThis as any
821
+ )[PUSH_CALLBACK_SCOPE_KEY] ??= new AsyncLocalStorage<{ active: true }>());
822
+
823
+ /**
824
+ * Check if the current execution is inside a handle push callback (sync or an
825
+ * async callback's continuation). Used by the handler-to-loader deadlock guard
826
+ * to exempt push-callback continuations.
827
+ */
828
+ export function isInsidePushCallbackScope(): boolean {
829
+ return pushCallbackScopeALS.getStore()?.active === true;
830
+ }
831
+
832
+ /**
833
+ * Run `fn` inside a push-callback scope. Wraps the invocation of a handle push
834
+ * callback so that any ctx.use(loader) it makes — including after one of its own
835
+ * awaits — is exempt from the deadlock guard.
836
+ */
837
+ export function runInsidePushCallbackScope<T>(fn: () => T): T {
838
+ return pushCallbackScopeALS.run({ active: true }, fn);
839
+ }
@@ -273,7 +273,9 @@ export interface RequestContext<
273
273
 
274
274
  /**
275
275
  * @internal Set to true when the matched entry tree contains any `loading()`
276
- * entries (streaming). Used by rendered() to fail fast.
276
+ * entries (streaming). On a streaming tree rendered() waits for the streaming
277
+ * handlers to settle (via handleStore.settled) before resolving, and the
278
+ * deadlock guard state is kept live until that wait completes.
277
279
  */
278
280
  _treeHasStreaming?: boolean;
279
281
 
@@ -297,6 +299,18 @@ export interface RequestContext<
297
299
  */
298
300
  _renderBarrierHandleSnapshot?: HandleData;
299
301
 
302
+ /**
303
+ * @internal The deadlock guard window is closed (no further handler-awaits-
304
+ * loader cycle is possible). For non-streaming trees this is set when the
305
+ * barrier resolves. For streaming trees the window stays open until
306
+ * handleStore.settled — rendered() keeps waiting past the barrier and a
307
+ * loading() handler can still resume and await a still-waiting loader — so it
308
+ * is set only after settled. The guard (loader-resolution `setupLoaderAccess`)
309
+ * reads this instead of `_renderBarrierSegmentOrder` so it does not go blind
310
+ * during the streaming settle wait.
311
+ */
312
+ _renderBarrierGuardClosed?: boolean;
313
+
300
314
  /** @internal Per-request error dedup set for onError reporting */
301
315
  _reportedErrors: WeakSet<object>;
302
316
 
@@ -355,6 +369,7 @@ export type PublicRequestContext<
355
369
  | "_renderBarrierWaiters"
356
370
  | "_handlerLoaderDeps"
357
371
  | "_renderBarrierHandleSnapshot"
372
+ | "_renderBarrierGuardClosed"
358
373
  | "_reportBackgroundError"
359
374
  | "_debugPerformance"
360
375
  | "_metricsStore"
@@ -797,14 +812,37 @@ export function createRequestContext<TEnv>(
797
812
  .filter((s) => s.type !== "loader")
798
813
  .map((s) => s.id);
799
814
  ctx._renderBarrierSegmentOrder = segOrder;
800
- // Build and cache handle snapshot so loader ctx.use(handle) calls
801
- // don't rebuild it on every invocation.
802
- ctx._renderBarrierHandleSnapshot = buildHandleSnapshot(
803
- handleStore,
804
- segOrder,
805
- );
806
- ctx._renderBarrierWaiters = undefined;
807
- ctx._handlerLoaderDeps = undefined;
815
+
816
+ // Closing the guard window means no handler can still form a deadlock cycle
817
+ // with a rendered() loader: drop the dependency-tracking state and mark it
818
+ // closed. WHEN this runs is the only streaming/non-streaming difference.
819
+ const closeGuard = () => {
820
+ ctx._renderBarrierWaiters = undefined;
821
+ ctx._handlerLoaderDeps = undefined;
822
+ ctx._renderBarrierGuardClosed = true;
823
+ };
824
+
825
+ if (ctx._treeHasStreaming) {
826
+ // Streaming: rendered() keeps waiting on handleStore.settled past this
827
+ // point, and loading() handlers are still in flight. The eager snapshot
828
+ // here would be incomplete, so leave it unset — rendered() builds and
829
+ // caches the complete one after settled. Keep the guard window OPEN so a
830
+ // handler that resumes and awaits a still-waiting rendered() loader is
831
+ // still caught; close it once settled (every tracked handler has finished
832
+ // then, so none can await a loader anymore). settled resolves after
833
+ // rendered() seals; if no loader used rendered(), nothing seals and the
834
+ // (empty) guard state is simply GC'd at request end.
835
+ handleStore.settled.then(closeGuard);
836
+ } else {
837
+ // Non-streaming: all handlers have settled by now. Build and cache the
838
+ // snapshot so loader ctx.use(handle) calls don't rebuild it, and close the
839
+ // guard window immediately.
840
+ ctx._renderBarrierHandleSnapshot = buildHandleSnapshot(
841
+ handleStore,
842
+ segOrder,
843
+ );
844
+ closeGuard();
845
+ }
808
846
  if (resolveBarrier) resolveBarrier();
809
847
  };
810
848
  Object.defineProperty(ctx, "_renderBarrier", {
@@ -72,9 +72,12 @@ export type LoaderContext<
72
72
  * **Experimental.** Wait for all non-loader segments to settle.
73
73
  *
74
74
  * After the returned promise resolves, handle data is available via
75
- * `ctx.use(handle)`. Only supported in DSL loaders on non-streaming
76
- * trees (no `loading()`). Throws if called from a handler-invoked
77
- * loader or when the tree uses streaming.
75
+ * `ctx.use(handle)`. Supported in DSL loaders, including on streaming
76
+ * trees that use `loading()` the barrier waits for the streaming
77
+ * handlers to finish pushing before it resolves. Throws if called from a
78
+ * handler-invoked loader, or if a handler is already awaiting this loader
79
+ * via `ctx.use()` (that would deadlock — use a loader-to-loader
80
+ * dependency instead).
78
81
  *
79
82
  * @example
80
83
  * ```typescript
package/src/urls/index.ts CHANGED
@@ -21,8 +21,7 @@ export type {
21
21
  export type {
22
22
  ExtractRoutes,
23
23
  ExtractResponses,
24
- ResponseError,
25
- ResponseEnvelope,
24
+ ProblemDetails,
26
25
  RouteResponse,
27
26
  } from "./type-extraction.js";
28
27
 
@@ -220,44 +220,53 @@ export type ExtractResponses<T extends readonly any[]> =
220
220
  ExtractResponsesFromItems<T>;
221
221
 
222
222
  // ============================================================================
223
- // Response Envelope Types
223
+ // Response Error (RFC 9457 problem+json) Type
224
224
  // ============================================================================
225
225
 
226
226
  /**
227
- * Error shape returned in the `{ error }` side of a JSON response envelope.
228
- */
229
- export interface ResponseError {
230
- message: string;
231
- code?: string;
232
- type?: string;
233
- stack?: string;
234
- }
235
-
236
- /**
237
- * Discriminated union envelope for JSON response routes.
238
- * Consumers check `result.error` to discriminate between success and failure.
227
+ * RFC 9457 (problem+json) error body returned by JSON response routes on a
228
+ * non-2xx status. Sent verbatim as the response body (not wrapped) with
229
+ * content-type `application/problem+json`.
239
230
  *
240
231
  * @example
241
232
  * ```typescript
242
- * const result: ResponseEnvelope<Product> = await fetch(url).then(r => r.json());
243
- * if (result.error) {
244
- * console.log(result.error.message, result.error.code);
233
+ * const res = await fetch(url);
234
+ * if (!res.ok) {
235
+ * const problem: ProblemDetails = await res.json();
236
+ * console.log(problem.code, problem.detail); // "NOT_FOUND", "Product not found"
245
237
  * return;
246
238
  * }
247
- * result.data.name // fully typed
239
+ * const product = await res.json(); // bare value, no envelope
248
240
  * ```
249
241
  */
250
- export type ResponseEnvelope<T> =
251
- | { data: T; error?: undefined }
252
- | { data?: undefined; error: ResponseError };
242
+ export interface ProblemDetails {
243
+ /**
244
+ * URI reference identifying the problem type. Omitted in this phase (per RFC
245
+ * 9457 an absent `type` is treated as `"about:blank"` — no semantics beyond
246
+ * the HTTP status); per-route problem-type URIs arrive with the
247
+ * declared-errors map later.
248
+ */
249
+ type?: string;
250
+ /** Short, human-readable summary (the HTTP status reason phrase). */
251
+ title: string;
252
+ /** The HTTP status code. */
253
+ status: number;
254
+ /** Human-readable explanation specific to this occurrence (the error message). */
255
+ detail: string;
256
+ /** Stable machine-readable error code (`RouterError.code`, else `"INTERNAL"`). */
257
+ code: string;
258
+ /** Stack trace, included in development only. */
259
+ stack?: string;
260
+ }
253
261
 
254
262
  // ============================================================================
255
263
  // Response Type Consumer Utilities
256
264
  // ============================================================================
257
265
 
258
266
  /**
259
- * Extract the response data type for a named route from a UrlPatterns instance.
260
- * Wraps in ResponseEnvelope since JSON response routes return enveloped data.
267
+ * Extract the JSON response payload type for a named route from a UrlPatterns
268
+ * instance. JSON response routes send the handler's return value verbatim
269
+ * (bare), so this resolves to the wire value a consumer receives — no envelope.
261
270
  *
262
271
  * @example
263
272
  * ```typescript
@@ -266,7 +275,7 @@ export type ResponseEnvelope<T> =
266
275
  * ]);
267
276
  *
268
277
  * type HealthData = RouteResponse<typeof apiPatterns, "health">;
269
- * // ResponseEnvelope<{ status: string; timestamp: number }>
278
+ * // { status: string; timestamp: number }
270
279
  * ```
271
280
  *
272
281
  * The payload is the JSON wire shape (via `Rango.JsonSerialize`), matching
@@ -277,6 +286,6 @@ export type RouteResponse<TPatterns, TName extends string> = TPatterns extends {
277
286
  readonly _responses?: infer R;
278
287
  }
279
288
  ? TName extends keyof R
280
- ? ResponseEnvelope<JsonSerialize<Exclude<R[TName], Response>>>
289
+ ? JsonSerialize<Exclude<R[TName], Response>>
281
290
  : never
282
291
  : never;
@@ -26,6 +26,7 @@ import {
26
26
  type CaughtDiscoveryError,
27
27
  } from "./discovery-errors.js";
28
28
  import { createRangoDebugger, timed, NS } from "../debug.js";
29
+ import { computeProductionHash } from "../plugins/client-ref-hashing.js";
29
30
 
30
31
  const debug = createRangoDebugger(NS.discovery);
31
32
 
@@ -143,6 +144,28 @@ export async function discoverRouters(
143
144
  // Collect all manifests for trie building (avoid re-running generateManifest)
144
145
  const allManifests: Array<{ id: string; manifest: any }> = [];
145
146
 
147
+ // Built-in clientChunks context (present only when the built-in strategy is
148
+ // active). Collect the production hashes of "use client" error/notFound
149
+ // fallback modules so the strategy can route them into app-fallback.
150
+ const clientChunkCtx = state.opts?.clientChunkCtx;
151
+ const collectClientFallbackRef = clientChunkCtx
152
+ ? (refKey: string) =>
153
+ clientChunkCtx.fallbackRefs.add(
154
+ computeProductionHash(state.projectRoot, refKey),
155
+ )
156
+ : undefined;
157
+ // Router-level boundary defaults (`createRouter({ defaultErrorBoundary, ... })`)
158
+ // are NOT in EntryData, so generateManifestFull's walk misses them. Collect any
159
+ // "use client" default boundary directly off the router instance. The value is
160
+ // commonly a handler function wrapping the client boundary in server providers,
161
+ // so collectFallbackClientRefs invokes + walks the tree. Routed through buildMod
162
+ // so it runs in the same RSC runner realm the boundary value came from.
163
+ const collectFromBoundaryNode = (node: unknown): void => {
164
+ if (collectClientFallbackRef && buildMod.collectFallbackClientRefs) {
165
+ buildMod.collectFallbackClientRefs(node, collectClientFallbackRef);
166
+ }
167
+ };
168
+
146
169
  const manifestGenStart = debug ? performance.now() : 0;
147
170
  for (const [id, router] of registry) {
148
171
  if (!router.urlpatterns || !generateManifestFull) {
@@ -152,10 +175,23 @@ export async function discoverRouters(
152
175
  const manifest = generateManifestFull(
153
176
  router.urlpatterns,
154
177
  routerMountIndex,
155
- router.__basename ? { urlPrefix: router.__basename } : undefined,
178
+ {
179
+ ...(router.__basename ? { urlPrefix: router.__basename } : {}),
180
+ ...(collectClientFallbackRef ? { collectClientFallbackRef } : {}),
181
+ },
156
182
  );
157
183
  routerMountIndex++;
158
184
  allManifests.push({ id, manifest });
185
+
186
+ // Router-level "use client" boundary defaults -> app-fallback (the
187
+ // route-tree errorBoundary()/notFoundBoundary() helpers are already
188
+ // collected inside generateManifestFull via collectClientFallbackRef).
189
+ if (collectClientFallbackRef) {
190
+ collectFromBoundaryNode(router.__defaultErrorBoundary);
191
+ collectFromBoundaryNode(router.__defaultNotFoundBoundary);
192
+ collectFromBoundaryNode(router.__notFound);
193
+ }
194
+
159
195
  const routeCount = Object.keys(manifest.routeManifest).length;
160
196
  const staticRoutes = Object.values(manifest.routeManifest).filter(
161
197
  (p: any) => !p.includes(":") && !p.includes("*"),
@@ -304,36 +340,17 @@ export async function discoverRouters(
304
340
  mergedResponseTypeRoutes,
305
341
  );
306
342
 
307
- // Build per-router tries for multi-router isolation.
343
+ // Build per-router tries for multi-router isolation. Uses the single
344
+ // shared buildPerRouterTrie so the production serialized trie is built by
345
+ // exactly the same code as the dev/HMR runtime rebuild (manifest-init.ts).
346
+ const buildPerRouterTrie = buildMod.buildPerRouterTrie;
308
347
  for (const { id, manifest } of allManifests) {
309
- if (
310
- !manifest._routeAncestry ||
311
- Object.keys(manifest._routeAncestry).length === 0
312
- )
313
- continue;
314
- const perRouterStaticPrefix: Record<string, string> = {};
315
- for (const name of Object.keys(manifest.routeManifest)) {
316
- perRouterStaticPrefix[name] = "";
348
+ const perRouterTrie = buildPerRouterTrie
349
+ ? buildPerRouterTrie(manifest)
350
+ : null;
351
+ if (perRouterTrie) {
352
+ newPerRouterTrieMap.set(id, perRouterTrie);
317
353
  }
318
- buildRouteToStaticPrefix(manifest.prefixTree, perRouterStaticPrefix);
319
-
320
- const perRouterPrerenderNames = manifest.prerenderRoutes
321
- ? new Set<string>(manifest.prerenderRoutes)
322
- : undefined;
323
- const perRouterPassthroughNames = manifest.passthroughRoutes
324
- ? new Set<string>(manifest.passthroughRoutes)
325
- : undefined;
326
-
327
- const perRouterTrie = buildRouteTrie(
328
- manifest.routeManifest,
329
- manifest._routeAncestry,
330
- perRouterStaticPrefix,
331
- manifest.routeTrailingSlash,
332
- perRouterPrerenderNames,
333
- perRouterPassthroughNames,
334
- manifest.responseTypeRoutes,
335
- );
336
- newPerRouterTrieMap.set(id, perRouterTrie);
337
354
  }
338
355
  }
339
356
  }
@@ -20,6 +20,13 @@ export interface PluginOptions {
20
20
  buildEnv?: import("../plugin-types.js").BuildEnvOption;
21
21
  /** Deployment preset (needed for buildEnv "auto" resolution). */
22
22
  preset?: "node" | "cloudflare";
23
+ /**
24
+ * Shared context the built-in clientChunks strategy reads. Discovery populates
25
+ * it (registered fallback hashes + single-router name) before the client build
26
+ * invokes the strategy. Present only when the built-in strategy is active
27
+ * (`clientChunks: true`/default); undefined for `false` or a custom function.
28
+ */
29
+ clientChunkCtx?: import("../utils/client-chunks.js").ClientChunkContext;
23
30
  }
24
31
 
25
32
  export interface PrecomputedEntry {
@@ -22,6 +22,17 @@ const FS_PREFIX = "/@fs/";
22
22
  * Returns the input unchanged if it doesn't match a known dev-mode pattern
23
23
  * (e.g., already a production hash).
24
24
  */
25
+ /**
26
+ * The production client-reference key hash: `sha256(relativeId).slice(0,12)`,
27
+ * matching @vitejs/plugin-rsc's `hashString`. Exported so the client-chunks
28
+ * strategy can hash a `clientChunks` callback's `meta.normalizedId` (already the
29
+ * project-root-relative id) and compare it against fallback hashes collected
30
+ * during discovery.
31
+ */
32
+ export function hashRefKey(relativeId: string): string {
33
+ return createHash("sha256").update(relativeId).digest("hex").slice(0, 12);
34
+ }
35
+
25
36
  export function computeProductionHash(
26
37
  projectRoot: string,
27
38
  refKey: string,
@@ -49,7 +60,7 @@ export function computeProductionHash(
49
60
  return refKey;
50
61
  }
51
62
 
52
- return createHash("sha256").update(toHash).digest("hex").slice(0, 12);
63
+ return hashRefKey(toHash);
53
64
  }
54
65
 
55
66
  // Regex to match registerClientReference() calls as emitted by @vitejs/plugin-rsc.