@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.
Files changed (37) hide show
  1. package/dist/vite/index.js +148 -97
  2. package/package.json +17 -18
  3. package/skills/api-client/SKILL.md +211 -0
  4. package/skills/mime-routes/SKILL.md +1 -1
  5. package/skills/rango/SKILL.md +1 -0
  6. package/skills/response-routes/SKILL.md +61 -43
  7. package/skills/typesafety/SKILL.md +3 -3
  8. package/src/__augment-tests__/augmented.check.ts +2 -3
  9. package/src/build/collect-fallback-refs.ts +107 -0
  10. package/src/build/generate-manifest.ts +28 -1
  11. package/src/build/index.ts +8 -1
  12. package/src/build/prefix-tree-utils.ts +123 -0
  13. package/src/build/route-trie.ts +43 -0
  14. package/src/client.tsx +4 -23
  15. package/src/errors.ts +0 -3
  16. package/src/href-client.ts +7 -8
  17. package/src/index.rsc.ts +1 -2
  18. package/src/index.ts +1 -2
  19. package/src/router/find-match.ts +54 -6
  20. package/src/router/lazy-includes.ts +33 -14
  21. package/src/router/manifest.ts +19 -6
  22. package/src/router/pattern-matching.ts +15 -2
  23. package/src/router/router-interfaces.ts +11 -0
  24. package/src/router/trie-matching.ts +22 -3
  25. package/src/router.ts +21 -7
  26. package/src/rsc/manifest-init.ts +28 -41
  27. package/src/rsc/response-error.ts +79 -12
  28. package/src/rsc/response-route-handler.ts +16 -13
  29. package/src/urls/index.ts +1 -2
  30. package/src/urls/type-extraction.ts +33 -24
  31. package/src/vite/discovery/discover-routers.ts +46 -29
  32. package/src/vite/discovery/state.ts +7 -0
  33. package/src/vite/plugins/client-ref-hashing.ts +12 -1
  34. package/src/vite/rango.ts +32 -4
  35. package/src/vite/utils/client-chunks.ts +41 -7
  36. package/src/vite/utils/manifest-utils.ts +8 -75
  37. package/src/vite/utils/shared-utils.ts +58 -0
@@ -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
  }
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.
package/src/vite/rango.ts CHANGED
@@ -23,7 +23,10 @@ import {
23
23
  onwarn,
24
24
  getManualChunks,
25
25
  } from "./utils/shared-utils.js";
26
- import { resolveClientChunks } from "./utils/client-chunks.js";
26
+ import {
27
+ resolveClientChunks,
28
+ type ClientChunkContext,
29
+ } from "./utils/client-chunks.js";
27
30
  import type { RangoOptions } from "./plugin-types.js";
28
31
  import { printBanner, rangoVersion } from "./utils/banner.js";
29
32
  import { createVersionInjectorPlugin } from "./plugins/version-injector.js";
@@ -69,9 +72,17 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
69
72
  // @vitejs/plugin-rsc in both presets. The built-in strategy only splits where it
70
73
  // recognizes a route structure, so this default is a no-op for flat / host-split
71
74
  // apps and never duplicates the shared runtime.
72
- const clientChunks = resolveClientChunks(
73
- resolvedOptions.clientChunks ?? true,
74
- );
75
+ const clientChunksOption = resolvedOptions.clientChunks ?? true;
76
+ // Shared context the built-in strategy reads at build time: the production
77
+ // hashes of registered error/notFound fallback modules (-> app-fallback).
78
+ // Populated by the discovery plugin in buildStart, before the client build
79
+ // invokes the strategy. Only wired when the built-in strategy is active; a
80
+ // custom function owns its own grouping.
81
+ const useBuiltInClientChunks = clientChunksOption === true;
82
+ const clientChunkCtx: ClientChunkContext | undefined = useBuiltInClientChunks
83
+ ? { fallbackRefs: new Set<string>() }
84
+ : undefined;
85
+ const clientChunks = resolveClientChunks(clientChunksOption, clientChunkCtx);
75
86
  debugConfig?.("rango(%s) setup start", preset);
76
87
 
77
88
  const plugins: PluginOption[] = [];
@@ -157,6 +168,14 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
157
168
  client: {
158
169
  build: {
159
170
  rollupOptions: {
171
+ // FILE_NAME_CONFLICT (and any other client-build warning) is
172
+ // emitted by the CLIENT environment build, which consults THIS
173
+ // env's onwarn -- Vite 8's environment builds do NOT propagate
174
+ // the top-level build.rollupOptions.onwarn into the client env.
175
+ // Wire it here so the suppression runs where the conflicts
176
+ // originate (the top-level handler is invoked 0x for these; the
177
+ // client-env handler is invoked for all of them).
178
+ onwarn,
160
179
  output: {
161
180
  manualChunks: getManualChunks,
162
181
  },
@@ -317,6 +336,14 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
317
336
  client: {
318
337
  build: {
319
338
  rollupOptions: {
339
+ // FILE_NAME_CONFLICT (and any other client-build warning) is
340
+ // emitted by the CLIENT environment build, which consults THIS
341
+ // env's onwarn -- Vite 8's environment builds do NOT propagate
342
+ // the top-level build.rollupOptions.onwarn into the client env.
343
+ // Wire it here so the suppression runs where the conflicts
344
+ // originate (the top-level handler is invoked 0x for these; the
345
+ // client-env handler is invoked for all of them).
346
+ onwarn,
320
347
  output: {
321
348
  manualChunks: getManualChunks,
322
349
  },
@@ -508,6 +535,7 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
508
535
  enableBuildPrerender: prerenderEnabled,
509
536
  buildEnv: options?.buildEnv,
510
537
  preset,
538
+ clientChunkCtx,
511
539
  }),
512
540
  );
513
541
 
@@ -6,10 +6,28 @@
6
6
 
7
7
  import type { ClientChunkMeta, ClientChunks } from "../plugin-types.js";
8
8
  import { createRangoDebugger, NS } from "../debug.js";
9
+ import { hashRefKey } from "../plugins/client-ref-hashing.js";
9
10
 
10
11
  /** The callback shape @vitejs/plugin-rsc's `clientChunks` option accepts. */
11
12
  export type RscClientChunksFn = (meta: ClientChunkMeta) => string | undefined;
12
13
 
14
+ /**
15
+ * Build-time context the discovery pass populates and the built-in strategy
16
+ * reads. It refines how the catch-all (no route-root marker) modules are grouped
17
+ * without touching marker splits or the shared runtime:
18
+ *
19
+ * - `fallbackRefs`: production hashes of the `"use client"` modules a consumer
20
+ * registered as `errorBoundary`/`notFoundBoundary` fallbacks. Pulled into a
21
+ * dedicated `app-fallback` chunk so the error UI is not co-bundled with the
22
+ * very route code it exists to catch failures for (resilience), and so the
23
+ * chunk it would otherwise sit in gets named after a real module rather than
24
+ * the boundary. Populated by reading each fallback element's client-reference
25
+ * `$$id` during discovery (see discover-routers).
26
+ */
27
+ export interface ClientChunkContext {
28
+ fallbackRefs: Set<string>;
29
+ }
30
+
13
31
  /**
14
32
  * Opt-in observability for the built-in strategy. The route-root marker list is
15
33
  * intentionally finite (see {@link ROUTE_ROOT_DIRS}); a consumer whose layout
@@ -101,18 +119,31 @@ const ROUTE_ROOT_DIRS = new Set([
101
119
  * `app-components`), re-introducing cross-app leakage.
102
120
  *
103
121
  * Resolution order:
104
- * 1. If the path passes through a {@link ROUTE_ROOT_DIRS} marker that has a
105
- * directory after it, key on that next segment (the route id) robust to any
106
- * nesting depth below it (`routes/foo/components/ui/X.tsx` -> `app-foo`).
107
- * 2. Otherwise return `undefined` (inherit the default `serverChunk` grouping).
122
+ * 1. Shared runtime (React / router / node_modules) -> `undefined` (never split).
123
+ * 2. A registered error/notFound fallback (`ctx.fallbackRefs`) -> `app-fallback`,
124
+ * regardless of location, so the error UI is decoupled from the happy path.
125
+ * 3. A {@link ROUTE_ROOT_DIRS} marker with a directory after it -> key on that
126
+ * next segment (the route id), robust to any nesting depth.
127
+ * 4. Otherwise `undefined` (inherit the default `serverChunk` grouping).
108
128
  */
109
129
  export function directoryClientChunks(
110
130
  meta: ClientChunkMeta,
131
+ ctx?: ClientChunkContext,
111
132
  ): string | undefined {
112
133
  if (isSharedRuntime(meta)) {
113
134
  // React / router runtime / node_modules: always shared, expected, uninteresting.
114
135
  return undefined;
115
136
  }
137
+ // Registered error/notFound fallbacks -> a dedicated chunk. The error UI must
138
+ // not co-bundle with the code it catches failures for, and removing it lets the
139
+ // chunk it would otherwise anchor be named after a real module, not the boundary.
140
+ if (
141
+ ctx?.fallbackRefs.size &&
142
+ ctx.fallbackRefs.has(hashRefKey(meta.normalizedId))
143
+ ) {
144
+ debugChunks?.("fallback %s -> app-fallback", meta.normalizedId);
145
+ return "app-fallback";
146
+ }
116
147
  const segments = meta.normalizedId.split("/").filter(Boolean);
117
148
  const dirCount = segments.length - 1; // exclude the filename
118
149
  if (dirCount >= 1) {
@@ -144,13 +175,16 @@ export function directoryClientChunks(
144
175
  * grouping.
145
176
  *
146
177
  * - `false` / `undefined` -> `undefined` (no override).
147
- * - `true` -> the built-in {@link directoryClientChunks} strategy.
148
- * - function -> the user's function, used verbatim.
178
+ * - `true` -> the built-in {@link directoryClientChunks} strategy,
179
+ * bound to the discovery-populated {@link ClientChunkContext} (fallback chunk).
180
+ * - function -> the user's function, used verbatim (full control; the
181
+ * fallback refinement does not apply — the consumer owns the grouping).
149
182
  */
150
183
  export function resolveClientChunks(
151
184
  option: ClientChunks | undefined,
185
+ ctx?: ClientChunkContext,
152
186
  ): RscClientChunksFn | undefined {
153
187
  if (!option) return undefined;
154
- if (option === true) return directoryClientChunks;
188
+ if (option === true) return (meta) => directoryClientChunks(meta, ctx);
155
189
  return option;
156
190
  }
@@ -1,78 +1,11 @@
1
- /**
2
- * Flatten prefix tree leaf nodes into precomputed route entries.
3
- * Leaf nodes have no children (no nested includes), so their routes can be
4
- * used directly by evaluateLazyEntry() without running the handler.
5
- * Non-leaf nodes are skipped because they have nested lazy includes that
6
- * require the handler to run for discovery.
7
- *
8
- * A leaf is also skipped when its staticPrefix collides with an ancestor
9
- * include node's staticPrefix. That happens when a dynamic param collapses the
10
- * staticPrefix of nested includes onto the parent's (e.g. `/m/:id/edit` -> sp
11
- * `/m`): precomputing such a leaf under the collapsed prefix would let the
12
- * ancestor's lazy entry claim a route it cannot register (the route is behind
13
- * further nested lazy includes), producing a RouteNotFoundError at request time
14
- * (issue #506). Those routes are resolved via the handler chain instead.
15
- */
16
- export function flattenLeafEntries(
17
- prefixTree: Record<string, any>,
18
- routeManifest: Record<string, string>,
19
- result: Array<{ staticPrefix: string; routes: Record<string, string> }>,
20
- ): void {
21
- function visit(node: any, ancestorStaticPrefixes: Set<string>): void {
22
- const children = node.children || {};
23
- if (
24
- Object.keys(children).length === 0 &&
25
- node.routes &&
26
- node.routes.length > 0
27
- ) {
28
- // Leaf node. Skip if its staticPrefix collides with an ancestor include
29
- // node's staticPrefix (dynamic-param collapse) — see doc comment above.
30
- if (ancestorStaticPrefixes.has(node.staticPrefix)) {
31
- return;
32
- }
33
- // Collect its routes from the manifest
34
- const routes: Record<string, string> = {};
35
- for (const name of node.routes) {
36
- if (name in routeManifest) {
37
- routes[name] = routeManifest[name];
38
- }
39
- }
40
- result.push({ staticPrefix: node.staticPrefix, routes });
41
- } else {
42
- // Non-leaf: recurse into children, tracking this node's staticPrefix as
43
- // an ancestor so a collapsed nested leaf below it is not over-claimed.
44
- const nextAncestors = new Set(ancestorStaticPrefixes);
45
- nextAncestors.add(node.staticPrefix);
46
- for (const child of Object.values(children)) {
47
- visit(child, nextAncestors);
48
- }
49
- }
50
- }
51
- for (const node of Object.values(prefixTree)) {
52
- visit(node, new Set());
53
- }
54
- }
55
-
56
- /**
57
- * Walk prefix tree to map each route name to its scope's staticPrefix.
58
- */
59
- export function buildRouteToStaticPrefix(
60
- prefixTree: Record<string, any>,
61
- result: Record<string, string>,
62
- ): void {
63
- function visit(node: any): void {
64
- const sp = node.staticPrefix || "";
65
- for (const name of node.routes || []) {
66
- result[name] = sp;
67
- }
68
- for (const child of Object.values(node.children || {})) {
69
- visit(child);
70
- }
71
- }
72
- for (const node of Object.values(prefixTree)) {
73
- visit(node);
74
- }
75
- }
1
+ // Pure prefix-tree walks live in the build layer so runtime code can consume
2
+ // them without importing from vite/. Re-exported here for the vite-side
3
+ // callers (discover-routers, virtual-module-codegen) that already import them
4
+ // from this module.
5
+ export {
6
+ flattenLeafEntries,
7
+ buildRouteToStaticPrefix,
8
+ } from "../../build/prefix-tree-utils.js";
76
9
 
77
10
  /**
78
11
  * Wrap a value as `JSON.parse('...')` instead of a JS object literal.