@rangojs/router 0.5.2 → 0.7.0

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 (178) hide show
  1. package/dist/bin/rango.js +343 -125
  2. package/dist/types/browser/react/use-router.d.ts +10 -3
  3. package/dist/types/browser/react/use-search-params.d.ts +57 -10
  4. package/dist/types/browser/types.d.ts +22 -0
  5. package/dist/types/build/merge-full-manifests.d.ts +3 -0
  6. package/dist/types/build/route-trie.d.ts +4 -73
  7. package/dist/types/build/route-types/per-module-writer.d.ts +6 -4
  8. package/dist/types/build/route-types/router-processing.d.ts +2 -3
  9. package/dist/types/cache/cache-exec-scope.d.ts +31 -0
  10. package/dist/types/cache/taint.d.ts +12 -6
  11. package/dist/types/client-urls/client-root.d.ts +38 -0
  12. package/dist/types/client-urls/client-urls.d.ts +5 -0
  13. package/dist/types/client-urls/navigation.d.ts +38 -0
  14. package/dist/types/client-urls/revalidation-protocol.d.ts +25 -0
  15. package/dist/types/client-urls/server-projection.d.ts +71 -0
  16. package/dist/types/client-urls/types.d.ts +147 -0
  17. package/dist/types/client.d.ts +12 -4
  18. package/dist/types/client.rsc.d.ts +4 -1
  19. package/dist/types/decode-loader-results.d.ts +37 -0
  20. package/dist/types/errors.d.ts +1 -0
  21. package/dist/types/index.d.ts +1 -1
  22. package/dist/types/loader-redirect.d.ts +27 -0
  23. package/dist/types/outlet-context.d.ts +12 -0
  24. package/dist/types/outlet-provider.d.ts +3 -1
  25. package/dist/types/redirect-origin.d.ts +4 -0
  26. package/dist/types/route-content-wrapper.d.ts +42 -1
  27. package/dist/types/route-definition/helpers-types.d.ts +13 -2
  28. package/dist/types/router/error-handling.d.ts +35 -1
  29. package/dist/types/router/intercept-resolution.d.ts +12 -0
  30. package/dist/types/router/loader-resolution.d.ts +24 -2
  31. package/dist/types/router/revalidation.d.ts +7 -0
  32. package/dist/types/router/route-trie-builder.d.ts +77 -0
  33. package/dist/types/router/router-interfaces.d.ts +20 -0
  34. package/dist/types/router/segment-resolution/helpers.d.ts +1 -1
  35. package/dist/types/router/segment-resolution/loader-mask.d.ts +9 -9
  36. package/dist/types/router/trie-matching.d.ts +1 -1
  37. package/dist/types/rsc/manifest-init.d.ts +5 -5
  38. package/dist/types/rsc/shell-capture.d.ts +9 -0
  39. package/dist/types/rsc/shell-serve.d.ts +11 -0
  40. package/dist/types/rsc/types.d.ts +30 -0
  41. package/dist/types/segment-system.d.ts +2 -0
  42. package/dist/types/server/context.d.ts +10 -0
  43. package/dist/types/server/handle-store.d.ts +34 -3
  44. package/dist/types/server/request-context.d.ts +11 -1
  45. package/dist/types/server.d.ts +1 -0
  46. package/dist/types/ssr/index.d.ts +22 -0
  47. package/dist/types/ssr/ssr-root.d.ts +10 -0
  48. package/dist/types/testing/dom.entry.d.ts +1 -1
  49. package/dist/types/testing/render-route.d.ts +16 -6
  50. package/dist/types/testing/run-loader.d.ts +9 -0
  51. package/dist/types/types/boundaries.d.ts +22 -0
  52. package/dist/types/types/index.d.ts +1 -1
  53. package/dist/types/types/loader-types.d.ts +57 -5
  54. package/dist/types/types/segments.d.ts +7 -0
  55. package/dist/types/urls/path-helper-types.d.ts +13 -4
  56. package/dist/types/vite/discovery/client-urls-projection.d.ts +53 -0
  57. package/dist/types/vite/discovery/discover-routers.d.ts +1 -1
  58. package/dist/types/vite/discovery/state.d.ts +8 -1
  59. package/dist/types/vite/plugins/client-ref-dedup.d.ts +0 -11
  60. package/dist/vite/index.js +6159 -2959
  61. package/package.json +6 -5
  62. package/skills/breadcrumbs/SKILL.md +39 -9
  63. package/skills/catalog.json +7 -1
  64. package/skills/client-urls/SKILL.md +338 -0
  65. package/skills/comparison/references/framework-comparison.md +23 -9
  66. package/skills/hooks/SKILL.md +2 -2
  67. package/skills/hooks/data.md +11 -2
  68. package/skills/hooks/handle-and-actions.md +7 -0
  69. package/skills/hooks/outlets.md +26 -5
  70. package/skills/hooks/urls.md +40 -3
  71. package/skills/loader/SKILL.md +132 -20
  72. package/skills/migrate-nextjs/SKILL.md +70 -10
  73. package/skills/migrate-react-router/SKILL.md +49 -13
  74. package/skills/migrate-react-router/component-migration.md +18 -13
  75. package/skills/migrate-react-router/data-and-actions.md +14 -3
  76. package/skills/migrate-react-router/route-mapping.md +15 -2
  77. package/skills/parallel/SKILL.md +32 -1
  78. package/skills/ppr/SKILL.md +16 -6
  79. package/skills/prerender/SKILL.md +8 -4
  80. package/skills/rango/SKILL.md +21 -17
  81. package/skills/react-compiler/SKILL.md +3 -3
  82. package/skills/route/SKILL.md +5 -2
  83. package/skills/router-setup/SKILL.md +16 -2
  84. package/skills/scripts/SKILL.md +16 -6
  85. package/skills/shell-manifest/SKILL.md +16 -7
  86. package/skills/testing/SKILL.md +2 -2
  87. package/skills/testing/client-components.md +6 -0
  88. package/skills/testing/handles.md +30 -8
  89. package/skills/testing/loader.md +51 -49
  90. package/skills/testing/middleware.md +1 -1
  91. package/skills/theme/SKILL.md +8 -5
  92. package/src/bin/rango.ts +7 -3
  93. package/src/browser/navigation-bridge.ts +6 -0
  94. package/src/browser/navigation-client.ts +5 -0
  95. package/src/browser/partial-update.ts +65 -13
  96. package/src/browser/react/use-router.ts +40 -11
  97. package/src/browser/react/use-search-params.ts +140 -17
  98. package/src/browser/rsc-router.tsx +59 -0
  99. package/src/browser/server-action-bridge.ts +26 -0
  100. package/src/browser/types.ts +22 -0
  101. package/src/build/merge-full-manifests.ts +161 -0
  102. package/src/build/route-trie.ts +9 -332
  103. package/src/build/route-types/include-resolution.ts +66 -11
  104. package/src/build/route-types/per-module-writer.ts +11 -6
  105. package/src/build/route-types/router-processing.ts +184 -153
  106. package/src/build/runtime-discovery.ts +23 -12
  107. package/src/cache/cache-exec-scope.ts +47 -0
  108. package/src/cache/cache-runtime.ts +24 -25
  109. package/src/cache/taint.ts +28 -9
  110. package/src/client-urls/client-root.tsx +168 -0
  111. package/src/client-urls/client-urls.ts +776 -0
  112. package/src/client-urls/navigation.ts +237 -0
  113. package/src/client-urls/revalidation-protocol.ts +56 -0
  114. package/src/client-urls/server-projection.ts +670 -0
  115. package/src/client-urls/types.ts +201 -0
  116. package/src/client.rsc.tsx +12 -0
  117. package/src/client.tsx +49 -6
  118. package/src/decode-loader-results.ts +113 -0
  119. package/src/errors.ts +14 -0
  120. package/src/handles/deferred-resolution.ts +14 -7
  121. package/src/index.ts +1 -0
  122. package/src/loader-redirect.tsx +64 -0
  123. package/src/outlet-context.ts +12 -0
  124. package/src/outlet-provider.tsx +15 -1
  125. package/src/redirect-origin.ts +29 -0
  126. package/src/route-content-wrapper.tsx +96 -3
  127. package/src/route-definition/dsl-helpers.ts +28 -3
  128. package/src/route-definition/helpers-types.ts +13 -0
  129. package/src/route-definition/redirect.ts +17 -18
  130. package/src/router/error-handling.ts +65 -11
  131. package/src/router/intercept-resolution.ts +29 -0
  132. package/src/router/loader-resolution.ts +261 -28
  133. package/src/router/match-result.ts +7 -0
  134. package/src/router/revalidation.ts +24 -11
  135. package/src/router/route-trie-builder.ts +334 -0
  136. package/src/router/router-interfaces.ts +38 -0
  137. package/src/router/segment-resolution/fresh.ts +55 -2
  138. package/src/router/segment-resolution/helpers.ts +9 -11
  139. package/src/router/segment-resolution/loader-cache.ts +34 -23
  140. package/src/router/segment-resolution/loader-mask.ts +9 -9
  141. package/src/router/segment-resolution/revalidation.ts +23 -2
  142. package/src/router/trie-matching.ts +3 -3
  143. package/src/router.ts +46 -1
  144. package/src/rsc/full-payload.ts +6 -0
  145. package/src/rsc/handler.ts +10 -7
  146. package/src/rsc/loader-fetch.ts +2 -2
  147. package/src/rsc/manifest-init.ts +28 -9
  148. package/src/rsc/rsc-rendering.ts +15 -1
  149. package/src/rsc/shell-capture.ts +12 -0
  150. package/src/rsc/shell-serve.ts +15 -2
  151. package/src/rsc/ssr-setup.ts +10 -1
  152. package/src/rsc/types.ts +31 -2
  153. package/src/segment-system.tsx +83 -26
  154. package/src/server/context.ts +10 -0
  155. package/src/server/cookie-store.ts +19 -19
  156. package/src/server/handle-store.ts +185 -48
  157. package/src/server/request-context.ts +30 -6
  158. package/src/server.ts +7 -0
  159. package/src/ssr/index.tsx +37 -2
  160. package/src/ssr/ssr-root.tsx +29 -2
  161. package/src/testing/dom.entry.ts +1 -1
  162. package/src/testing/render-route.tsx +22 -8
  163. package/src/testing/run-loader.ts +51 -13
  164. package/src/types/boundaries.ts +19 -0
  165. package/src/types/index.ts +1 -0
  166. package/src/types/loader-types.ts +60 -5
  167. package/src/types/segments.ts +7 -0
  168. package/src/urls/include-helper.ts +22 -4
  169. package/src/urls/path-helper-types.ts +17 -1
  170. package/src/use-loader.tsx +67 -6
  171. package/src/vite/discovery/client-urls-projection.ts +322 -0
  172. package/src/vite/discovery/discover-routers.ts +43 -17
  173. package/src/vite/discovery/state.ts +11 -1
  174. package/src/vite/discovery/virtual-module-codegen.ts +20 -0
  175. package/src/vite/plugins/client-ref-dedup.ts +281 -19
  176. package/src/vite/plugins/expose-action-id.ts +45 -23
  177. package/src/vite/plugins/virtual-entries.ts +12 -3
  178. package/src/vite/router-discovery.ts +163 -12
@@ -37,6 +37,8 @@ import {
37
37
  import { mergeLocationState } from "./history-state.js";
38
38
  import { classifyActionOutcome } from "./action-coordinator.js";
39
39
  import { getAppVersion } from "./app-version.js";
40
+ import { collectClientRevalidationDecisions } from "../client-urls/navigation.js";
41
+ import { CLIENT_REVALIDATION_HEADER } from "../client-urls/revalidation-protocol.js";
40
42
 
41
43
  // Polyfill Symbol.dispose/asyncDispose for Safari and older browsers
42
44
  if (typeof Symbol.dispose === "undefined") {
@@ -294,12 +296,36 @@ export function createServerActionBridge(
294
296
  const onHandleAbort = () => fetchAbort.abort();
295
297
  handle.signal.addEventListener("abort", onHandleAbort, { once: true });
296
298
 
299
+ // Client-run per-loader revalidation for the action follow-up render:
300
+ // run the held clientUrls route's revalidate() predicates with the
301
+ // action context and ship their decisions. Fails soft to null (locked
302
+ // server defaults).
303
+ let clientRevalidation: string | null = null;
304
+ try {
305
+ const actionPageUrl = new URL(
306
+ segmentState.currentUrl,
307
+ window.location.origin,
308
+ );
309
+ clientRevalidation = collectClientRevalidationDecisions({
310
+ currentUrl: actionPageUrl,
311
+ nextUrl: actionPageUrl,
312
+ isAction: true,
313
+ actionId: id,
314
+ stale: false,
315
+ });
316
+ } catch {
317
+ clientRevalidation = null;
318
+ }
319
+
297
320
  // Send action request with stream tracking
298
321
  const responsePromise = fetch(url, {
299
322
  method: "POST",
300
323
  headers: {
301
324
  "rsc-action": id,
302
325
  "X-RSC-Router-Client-Path": segmentState.currentUrl,
326
+ ...(clientRevalidation && {
327
+ [CLIENT_REVALIDATION_HEADER]: clientRevalidation,
328
+ }),
303
329
  ...(tx && { "X-RSC-Router-Request-Id": tx.requestId }),
304
330
  ...(interceptSourceUrl && {
305
331
  "X-RSC-Router-Intercept-Source": interceptSourceUrl,
@@ -54,10 +54,25 @@ export interface RscMetadata {
54
54
  * Slots are used for intercepting routes during soft navigation
55
55
  */
56
56
  slots?: Record<string, SlotState>;
57
+ /**
58
+ * Intercept TARGET route names reachable from this location as a
59
+ * navigation origin. The browser-local clientUrls matcher declines its
60
+ * optimistic presentation for these targets (the canonical response would
61
+ * commit the intercept over the ORIGIN page, so destination loading would
62
+ * flash and revert). Missing/empty means no targets.
63
+ */
64
+ interceptTargets?: string[];
57
65
  /** Root layout component for browser-side re-renders */
58
66
  rootLayout?: ComponentType<{ children: ReactNode }>;
59
67
  /** Handle data accumulated across route segments (async generator that yields on each push) */
60
68
  handles?: AsyncGenerator<HandleData, void, unknown>;
69
+ /**
70
+ * Document-lane late handle channel: pushes landing after the handler
71
+ * barrier (streaming loader ctx.use(Handle) writes). Consumed non-blocking
72
+ * post-hydration (rsc-router.tsx); `handles` above is drained in blocking
73
+ * positions and must complete at the handler barrier.
74
+ */
75
+ handlesLate?: AsyncGenerator<HandleData, void, unknown>;
61
76
  /** Cached handle data (for back/forward navigation from cache) */
62
77
  cachedHandleData?: HandleData;
63
78
  /**
@@ -531,6 +546,13 @@ export interface FetchPartialOptions {
531
546
  signal?: AbortSignal;
532
547
  /** If true, this is a stale cache revalidation request - server should force revalidators */
533
548
  staleRevalidation?: boolean;
549
+ /**
550
+ * Encoded client-run per-loader revalidation decisions
551
+ * (clientUrls revalidate() predicates executed in the browser); sent as
552
+ * X-Rango-Client-Reval and honored only by materialized client-urls loader
553
+ * stubs. Null/absent = locked server defaults.
554
+ */
555
+ clientRevalidation?: string | null;
534
556
  interceptSourceUrl?: string;
535
557
  /** RSC version for cache invalidation detection */
536
558
  version?: string;
@@ -0,0 +1,161 @@
1
+ import type { FullManifest, PrefixTreeNode } from "./generate-manifest.js";
2
+
3
+ function clonePrefixNode(node: PrefixTreeNode): PrefixTreeNode {
4
+ return {
5
+ staticPrefix: node.staticPrefix,
6
+ fullPrefix: node.fullPrefix,
7
+ ...(node.namePrefix === undefined ? {} : { namePrefix: node.namePrefix }),
8
+ children: clonePrefixTree(node.children),
9
+ routes: [...node.routes],
10
+ };
11
+ }
12
+
13
+ function clonePrefixTree(
14
+ tree: Record<string, PrefixTreeNode>,
15
+ ): Record<string, PrefixTreeNode> {
16
+ return Object.fromEntries(
17
+ Object.entries(tree).map(([prefix, node]) => [
18
+ prefix,
19
+ clonePrefixNode(node),
20
+ ]),
21
+ );
22
+ }
23
+
24
+ function removeRoutesFromPrefixTree(
25
+ tree: Record<string, PrefixTreeNode>,
26
+ routeNames: ReadonlySet<string>,
27
+ ): Record<string, PrefixTreeNode> {
28
+ return Object.fromEntries(
29
+ Object.entries(tree).map(([prefix, node]) => [
30
+ prefix,
31
+ {
32
+ staticPrefix: node.staticPrefix,
33
+ fullPrefix: node.fullPrefix,
34
+ ...(node.namePrefix === undefined
35
+ ? {}
36
+ : { namePrefix: node.namePrefix }),
37
+ children: removeRoutesFromPrefixTree(node.children, routeNames),
38
+ routes: node.routes.filter((name) => !routeNames.has(name)),
39
+ },
40
+ ]),
41
+ );
42
+ }
43
+
44
+ function mergePrefixTrees(
45
+ earlier: Record<string, PrefixTreeNode>,
46
+ later: Record<string, PrefixTreeNode>,
47
+ ): Record<string, PrefixTreeNode> {
48
+ const merged = clonePrefixTree(earlier);
49
+ for (const [prefix, laterNode] of Object.entries(later)) {
50
+ const earlierNode = merged[prefix];
51
+ if (!earlierNode) {
52
+ merged[prefix] = clonePrefixNode(laterNode);
53
+ continue;
54
+ }
55
+
56
+ merged[prefix] = {
57
+ staticPrefix: laterNode.staticPrefix,
58
+ fullPrefix: laterNode.fullPrefix,
59
+ ...(laterNode.namePrefix === undefined
60
+ ? {}
61
+ : { namePrefix: laterNode.namePrefix }),
62
+ children: mergePrefixTrees(earlierNode.children, laterNode.children),
63
+ routes: [...new Set([...earlierNode.routes, ...laterNode.routes])],
64
+ };
65
+ }
66
+ return merged;
67
+ }
68
+
69
+ function mergeRouteRecord<T>(
70
+ earlier: Record<string, T> | undefined,
71
+ later: Record<string, T> | undefined,
72
+ overwrittenRoutes: ReadonlySet<string>,
73
+ cloneValue: (value: T) => T,
74
+ ): Record<string, T> | undefined {
75
+ const merged: Record<string, T> = {};
76
+ for (const [name, value] of Object.entries(earlier ?? {})) {
77
+ if (!overwrittenRoutes.has(name)) merged[name] = cloneValue(value);
78
+ }
79
+ for (const [name, value] of Object.entries(later ?? {})) {
80
+ merged[name] = cloneValue(value);
81
+ }
82
+ return Object.keys(merged).length > 0 ? merged : undefined;
83
+ }
84
+
85
+ function mergeRouteList(
86
+ earlier: readonly string[] | undefined,
87
+ later: readonly string[] | undefined,
88
+ overwrittenRoutes: ReadonlySet<string>,
89
+ ): string[] | undefined {
90
+ const merged = [
91
+ ...(earlier ?? []).filter((name) => !overwrittenRoutes.has(name)),
92
+ ...(later ?? []),
93
+ ];
94
+ const unique = [...new Set(merged)];
95
+ return unique.length > 0 ? unique : undefined;
96
+ }
97
+
98
+ /** Merge ordered per-mount manifests without mutating any input manifest. */
99
+ export function mergeFullManifests(
100
+ manifests: readonly FullManifest[],
101
+ ): FullManifest {
102
+ let merged: FullManifest = {
103
+ prefixTree: {},
104
+ routeManifest: {},
105
+ };
106
+
107
+ for (const manifest of manifests) {
108
+ const overwrittenRoutes = new Set(Object.keys(manifest.routeManifest));
109
+ const prefixTreeWithoutOverwrites = removeRoutesFromPrefixTree(
110
+ merged.prefixTree,
111
+ overwrittenRoutes,
112
+ );
113
+
114
+ merged = {
115
+ prefixTree: mergePrefixTrees(
116
+ prefixTreeWithoutOverwrites,
117
+ manifest.prefixTree,
118
+ ),
119
+ routeManifest: {
120
+ ...merged.routeManifest,
121
+ ...manifest.routeManifest,
122
+ },
123
+ routeTrailingSlash: mergeRouteRecord(
124
+ merged.routeTrailingSlash,
125
+ manifest.routeTrailingSlash,
126
+ overwrittenRoutes,
127
+ (value) => value,
128
+ ),
129
+ prerenderRoutes: mergeRouteList(
130
+ merged.prerenderRoutes,
131
+ manifest.prerenderRoutes,
132
+ overwrittenRoutes,
133
+ ),
134
+ passthroughRoutes: mergeRouteList(
135
+ merged.passthroughRoutes,
136
+ manifest.passthroughRoutes,
137
+ overwrittenRoutes,
138
+ ),
139
+ responseTypeRoutes: mergeRouteRecord(
140
+ merged.responseTypeRoutes,
141
+ manifest.responseTypeRoutes,
142
+ overwrittenRoutes,
143
+ (value) => value,
144
+ ),
145
+ routeSearchSchemas: mergeRouteRecord(
146
+ merged.routeSearchSchemas,
147
+ manifest.routeSearchSchemas,
148
+ overwrittenRoutes,
149
+ (value) => ({ ...value }),
150
+ ),
151
+ _prerenderDefs: mergeRouteRecord(
152
+ merged._prerenderDefs,
153
+ manifest._prerenderDefs,
154
+ overwrittenRoutes,
155
+ (value) => value,
156
+ ),
157
+ };
158
+ }
159
+
160
+ return merged;
161
+ }
@@ -1,153 +1,19 @@
1
1
  /**
2
2
  * Build-time Route Trie Construction
3
3
  *
4
- * Builds a serializable trie from the route manifest for O(path_length)
5
- * route matching at runtime.
4
+ * Adapts generated manifests to the runtime-owned route trie builder.
6
5
  */
7
6
 
8
- import {
9
- parsePattern,
10
- type ParsedSegment,
11
- } from "../router/pattern-matching.js";
12
- import { buildRouteToStaticPrefix } from "./prefix-tree-utils.js";
7
+ import { buildRouteTrie, type TrieNode } from "../router/route-trie-builder.js";
13
8
  import type { FullManifest } from "./generate-manifest.js";
9
+ import { buildRouteToStaticPrefix } from "./prefix-tree-utils.js";
14
10
 
15
- // -- Trie data structures (compact keys for JSON serialization) --
16
-
17
- /**
18
- * A response-type variant folded into a primary leaf's negotiate list. `pa` is
19
- * the variant's own positional param-name array, carried so the runtime can
20
- * re-key the matched params under the variant's names when it wins negotiation
21
- * (the trie match extracts params under the PRIMARY leaf's pa). Omitted when the
22
- * variant has no params; absent/identical pa means no re-key is needed.
23
- */
24
- export interface NegotiateVariant {
25
- routeKey: string;
26
- responseType: string;
27
- pa?: string[];
28
- }
29
-
30
- export interface TrieLeaf {
31
- /** Route name (e.g., "site.l1_500") */
32
- n: string;
33
- /** Static prefix of the entry (e.g., "/site") */
34
- sp: string;
35
- /** Constraint validation: paramName -> allowed values */
36
- cv?: Record<string, string[]>;
37
- /** Ordered param names for this route (positional) */
38
- pa?: string[];
39
- /** Trailing slash mode */
40
- ts?: string;
41
- /** Route has pre-rendered data available */
42
- pr?: true;
43
- /** Passthrough: handler kept in bundle for live fallback on unknown params */
44
- pt?: true;
45
- /** Response type for non-RSC routes (json, text, image, any) */
46
- rt?: string;
47
- /** Negotiate variants: response-type routes sharing this path */
48
- nv?: NegotiateVariant[];
49
- /** RSC-first: RSC route was defined before response-type variants */
50
- rf?: true;
51
- }
52
-
53
- export interface TrieNode {
54
- /** Route terminal at this node */
55
- r?: TrieLeaf;
56
- /** Static segment children */
57
- s?: Record<string, TrieNode>;
58
- /** Param child: { n: paramName, c: child node } */
59
- p?: { n: string; c: TrieNode };
60
- /** Suffix-param children keyed by suffix (e.g., ".html" → { n: "productId", c: ... }) */
61
- xp?: Record<string, { n: string; c: TrieNode }>;
62
- /**
63
- * Wildcard terminal: leaf + paramName (`pn`). `pn` is "*" for the bare `/*`
64
- * form and the param name for a named catch-all (`:name+`/`:name*`). `w1`
65
- * marks a one-or-more catch-all (`:name+`): the runtime walker then rejects
66
- * the zero-segment/empty-remainder case. Absent `w1` is zero-or-more.
67
- */
68
- w?: TrieLeaf & { pn: string; w1?: true };
69
- }
70
-
71
- /**
72
- * Build a route trie from build-time manifest data.
73
- *
74
- * @param routeManifest - Map of route name to full URL pattern
75
- * @param routeToStaticPrefix - Map of route name to its entry's staticPrefix
76
- * @param routeTrailingSlash - Optional map of route name to trailing slash mode
77
- * @param prerenderRouteNames - Optional set of prerendered route names (sets leaf.pr)
78
- * @param passthroughRouteNames - Optional set of passthrough route names (sets leaf.pt)
79
- * @param responseTypeRoutes - Optional map of route name to response type (sets leaf.rt)
80
- */
81
- export function buildRouteTrie(
82
- routeManifest: Record<string, string>,
83
- routeToStaticPrefix: Record<string, string>,
84
- routeTrailingSlash?: Record<string, string>,
85
- prerenderRouteNames?: Set<string>,
86
- passthroughRouteNames?: Set<string>,
87
- responseTypeRoutes?: Record<string, string>,
88
- ): TrieNode {
89
- const root: TrieNode = {};
90
-
91
- for (const [routeName, pattern] of Object.entries(routeManifest)) {
92
- const staticPrefix = routeToStaticPrefix[routeName] || "";
93
- const trailingSlash = routeTrailingSlash?.[routeName];
94
- const responseType = responseTypeRoutes?.[routeName];
95
-
96
- // Detect and strip trailing slash from pattern for parsing
97
- const hasTrailingSlash = pattern.length > 1 && pattern.endsWith("/");
98
- const normalizedPattern = hasTrailingSlash ? pattern.slice(0, -1) : pattern;
99
-
100
- const segments = parsePattern(normalizedPattern);
101
- insertRoute(root, segments, 0, {
102
- n: routeName,
103
- sp: staticPrefix,
104
- ...(trailingSlash ? { ts: trailingSlash } : {}),
105
- ...(prerenderRouteNames?.has(routeName) ? { pr: true } : {}),
106
- ...(passthroughRouteNames?.has(routeName) ? { pt: true } : {}),
107
- ...(responseType ? { rt: responseType } : {}),
108
- });
109
- }
110
-
111
- sortSuffixParams(root);
112
- return root;
113
- }
114
-
115
- /**
116
- * Sort every node's suffix-param map (`node.xp`) by descending suffix length so
117
- * the matcher tries the most specific suffix first. Overlapping suffixes like
118
- * `.min.js` and `.js` must resolve by specificity, not route declaration order:
119
- * a request for `/app.min.js` should match `:file.min.js`, not `:file.js`.
120
- *
121
- * This started as a bug — `walkTrie` iterates `node.xp` in object order and
122
- * returns the first suffix the segment ends with, so the winner depended on
123
- * which route was declared first. Sorting at build time fixes it allocation-free
124
- * on the match hot path: the serialized production trie preserves this key order
125
- * through JSON.parse, so dev (per-request rebuild) and production match
126
- * identically. Array.prototype.sort is stable (ES2019+), so equal-length
127
- * suffixes keep their declaration order — the router's existing tiebreak.
128
- */
129
- function sortSuffixParams(node: TrieNode): void {
130
- if (node.xp) {
131
- const sorted: Record<string, { n: string; c: TrieNode }> = {};
132
- for (const suffix of Object.keys(node.xp).sort(
133
- (a, b) => b.length - a.length,
134
- )) {
135
- sorted[suffix] = node.xp[suffix];
136
- }
137
- node.xp = sorted;
138
- for (const child of Object.values(node.xp)) {
139
- sortSuffixParams(child.c);
140
- }
141
- }
142
- if (node.s) {
143
- for (const child of Object.values(node.s)) {
144
- sortSuffixParams(child);
145
- }
146
- }
147
- if (node.p) {
148
- sortSuffixParams(node.p.c);
149
- }
150
- }
11
+ export { buildRouteTrie };
12
+ export type {
13
+ NegotiateVariant,
14
+ TrieLeaf,
15
+ TrieNode,
16
+ } from "../router/route-trie-builder.js";
151
17
 
152
18
  /**
153
19
  * Build a per-router trie from a generated manifest. This is the single
@@ -186,192 +52,3 @@ export function buildPerRouterTrie(manifest: FullManifest): TrieNode | null {
186
52
  manifest.responseTypeRoutes,
187
53
  );
188
54
  }
189
-
190
- /**
191
- * Insert a route into the trie. Optional params expand into two branches at
192
- * registration time (skip-first, then present), so each terminal lives at the
193
- * correct depth for its number of bound params and carries a branch-local
194
- * `pa` listing only those names. The trie's single-slot `node.p` is reused
195
- * across branches because matching ignores `node.p.n` — the leaf's `pa` is
196
- * the source of truth for naming. Skip-first ordering lets `mergeLeaf`'s
197
- * last-wins rule produce greedy-leftmost semantics for free at any shared
198
- * terminal depth.
199
- */
200
- function insertRoute(
201
- node: TrieNode,
202
- segments: ParsedSegment[],
203
- index: number,
204
- leaf: Omit<TrieLeaf, "cv" | "pa">,
205
- ): void {
206
- // cv (full constraint map) is route-level and identical on every terminal,
207
- // so compute it once on the shared base.
208
- const constraints: Record<string, string[]> = {};
209
-
210
- for (const seg of segments) {
211
- if (seg.type === "param") {
212
- if (seg.constraint) {
213
- constraints[seg.value] = seg.constraint;
214
- }
215
- }
216
- }
217
-
218
- const leafBase: Omit<TrieLeaf, "pa"> = {
219
- ...leaf,
220
- ...(Object.keys(constraints).length > 0 ? { cv: constraints } : {}),
221
- };
222
-
223
- insertSegments(node, segments, index, leafBase, []);
224
- }
225
-
226
- /**
227
- * Merge a new leaf with an existing leaf, handling content negotiation.
228
- * When an RSC route and response-type routes share the same URL pattern,
229
- * the RSC route becomes the primary leaf and response-type routes are
230
- * appended to the nv (negotiate variants) array.
231
- * Multiple response types on the same path are supported (json + text + xml).
232
- */
233
- /**
234
- * Build a negotiate-variant entry from a leaf being folded into another leaf's
235
- * nv list. Carries the variant's positional param names (`pa`) so the runtime
236
- * can re-key matched params under the variant's names; omitted when the variant
237
- * has none (the common case where primary and variant share the same names is a
238
- * no-op re-key regardless).
239
- */
240
- function toVariant(leaf: TrieLeaf, responseType: string): NegotiateVariant {
241
- return leaf.pa
242
- ? { routeKey: leaf.n, responseType, pa: leaf.pa }
243
- : { routeKey: leaf.n, responseType };
244
- }
245
-
246
- function mergeLeaves(existing: TrieLeaf | undefined, leaf: TrieLeaf): TrieLeaf {
247
- if (!existing) return leaf;
248
-
249
- if (existing.rt && leaf.rt) {
250
- // Both are response-type: preserve old as variant
251
- const merged = leaf;
252
- merged.nv = existing.nv || [];
253
- merged.nv.push(toVariant(existing, existing.rt));
254
- return merged;
255
- }
256
- if (leaf.rt && !existing.rt) {
257
- // RSC primary exists, new leaf is response-type: append variant
258
- // RSC was defined first (it was already the existing leaf)
259
- if (!existing.nv) {
260
- existing.nv = [];
261
- existing.rf = true;
262
- }
263
- existing.nv.push(toVariant(leaf, leaf.rt));
264
- return existing;
265
- }
266
- if (!leaf.rt && existing.rt) {
267
- // Response-type was primary, new leaf is RSC: swap and move old to variants
268
- // RSC was defined second (response-type was already the existing leaf)
269
- if (!leaf.nv) leaf.nv = [];
270
- if (existing.nv) leaf.nv.push(...existing.nv);
271
- leaf.nv.push(toVariant(existing, existing.rt));
272
- // rf intentionally not set — RSC came after response-type variants
273
- return leaf;
274
- }
275
- // Both RSC (last wins): overwrite
276
- return leaf;
277
- }
278
-
279
- function mergeLeaf(node: TrieNode, leaf: TrieLeaf): void {
280
- node.r = mergeLeaves(node.r, leaf);
281
- }
282
-
283
- function buildLeaf(
284
- leafBase: Omit<TrieLeaf, "pa">,
285
- paramNames: string[],
286
- ): TrieLeaf {
287
- return paramNames.length > 0
288
- ? { ...leafBase, pa: [...paramNames] }
289
- : { ...leafBase };
290
- }
291
-
292
- function insertSegments(
293
- node: TrieNode,
294
- segments: ParsedSegment[],
295
- index: number,
296
- leafBase: Omit<TrieLeaf, "pa">,
297
- paramNames: string[],
298
- ): void {
299
- // Base case: all segments consumed, add terminal with branch-local pa
300
- if (index >= segments.length) {
301
- mergeLeaf(node, buildLeaf(leafBase, paramNames));
302
- return;
303
- }
304
-
305
- const segment = segments[index];
306
-
307
- if (segment.type === "static") {
308
- if (!node.s) node.s = {};
309
- if (!node.s[segment.value]) node.s[segment.value] = {};
310
- insertSegments(
311
- node.s[segment.value],
312
- segments,
313
- index + 1,
314
- leafBase,
315
- paramNames,
316
- );
317
- } else if (segment.type === "param") {
318
- if (segment.optional) {
319
- // SKIP first: continue at the same node without binding this name.
320
- // Skip-first ordering means the present-branch's TAKE overwrites any
321
- // shared terminal later, giving greedy-leftmost semantics.
322
- insertSegments(node, segments, index + 1, leafBase, paramNames);
323
- }
324
- if (segment.suffix) {
325
- // Suffix param: keyed by suffix string (e.g., ".html")
326
- if (!node.xp) node.xp = {};
327
- if (!node.xp[segment.suffix]) {
328
- node.xp[segment.suffix] = { n: segment.value, c: {} };
329
- }
330
- insertSegments(node.xp[segment.suffix].c, segments, index + 1, leafBase, [
331
- ...paramNames,
332
- segment.value,
333
- ]);
334
- } else {
335
- if (!node.p) {
336
- node.p = { n: segment.value, c: {} };
337
- }
338
- insertSegments(node.p.c, segments, index + 1, leafBase, [
339
- ...paramNames,
340
- segment.value,
341
- ]);
342
- }
343
- } else if (segment.type === "wildcard") {
344
- // Wildcard consumes all remaining segments. Carry any params bound before
345
- // the wildcard in pa so they zip correctly against paramValues at match.
346
- // `pn` is "*" for the bare `/*` and the param name for a named catch-all;
347
- // `w1` marks the one-or-more variant (`:name+`) so the walker rejects the
348
- // empty-remainder case.
349
- const wildLeaf: TrieLeaf & { pn: string; w1?: true } = {
350
- ...buildLeaf(leafBase, paramNames),
351
- pn: segment.value,
352
- ...(segment.oneOrMore ? { w1: true as const } : {}),
353
- };
354
- const existing = node.w;
355
- // Merge when there's no existing wildcard, when this is a response-type
356
- // content-negotiation variant of the same catch-all (one side carries `rt`),
357
- // or when it's the SAME catch-all identity (same param name + arity).
358
- // Otherwise two DISTINCT catch-all forms (`/x/*` vs `/x/:p+`) would collide on
359
- // the single wildcard slot with no non-lossy merge — so keep the first-declared
360
- // (matching the regex matcher's declaration-order tiebreak) rather than let
361
- // mergeLeaves' last-wins overwrite silently drop its `pn`/`w1` identity (which
362
- // stranded the first route and fell through to a corrupt regex-fallback redirect).
363
- const canMerge =
364
- existing === undefined ||
365
- Boolean(existing.rt) ||
366
- Boolean(wildLeaf.rt) ||
367
- (existing.pn === wildLeaf.pn &&
368
- Boolean(existing.w1) === Boolean(wildLeaf.w1));
369
- if (canMerge) {
370
- const merged = mergeLeaves(
371
- existing ? ({ ...existing } as TrieLeaf) : undefined,
372
- wildLeaf,
373
- );
374
- node.w = merged as TrieLeaf & { pn: string; w1?: true };
375
- }
376
- }
377
- }