@rangojs/router 0.23.0 → 0.24.1

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 (132) hide show
  1. package/dist/bin/rango.js +4 -3
  2. package/dist/types/browser/merge-segment-loaders.d.ts +5 -2
  3. package/dist/types/browser/partial-update.d.ts +6 -4
  4. package/dist/types/browser/react/NavigationProvider.d.ts +11 -7
  5. package/dist/types/browser/react/context.d.ts +3 -2
  6. package/dist/types/browser/types.d.ts +5 -1
  7. package/dist/types/build/route-types/scan-filter.d.ts +7 -1
  8. package/dist/types/cache/document-cache-policy.d.ts +24 -0
  9. package/dist/types/cache/document-cache.d.ts +2 -10
  10. package/dist/types/client-urls/types.d.ts +8 -0
  11. package/dist/types/context-var.d.ts +12 -0
  12. package/dist/types/errors.d.ts +10 -0
  13. package/dist/types/handles/pushed-by.d.ts +8 -0
  14. package/dist/types/index.d.ts +4 -0
  15. package/dist/types/index.rsc.d.ts +2 -0
  16. package/dist/types/response-utils.d.ts +7 -0
  17. package/dist/types/reverse.d.ts +8 -8
  18. package/dist/types/route-definition/rewrite.d.ts +56 -0
  19. package/dist/types/route-map-builder.d.ts +7 -2
  20. package/dist/types/router/basename.d.ts +7 -0
  21. package/dist/types/router/handler-context.d.ts +1 -5
  22. package/dist/types/router/internal-params.d.ts +8 -0
  23. package/dist/types/router/middleware-types.d.ts +15 -0
  24. package/dist/types/router/revalidation.d.ts +1 -2
  25. package/dist/types/router/route-trie-builder.d.ts +10 -0
  26. package/dist/types/router/segment-resolution/mask-nested.d.ts +4 -2
  27. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +36 -13
  28. package/dist/types/rsc/full-payload.d.ts +11 -0
  29. package/dist/types/rsc/handler.d.ts +33 -0
  30. package/dist/types/rsc/helpers.d.ts +8 -0
  31. package/dist/types/rsc/loader-fetch.d.ts +16 -0
  32. package/dist/types/rsc/rewrite-passes.d.ts +74 -0
  33. package/dist/types/rsc/ssr-setup.d.ts +3 -1
  34. package/dist/types/server/request-context.d.ts +39 -2
  35. package/dist/types/server/rewrite-trail.d.ts +49 -0
  36. package/dist/types/server/slot-accessors.d.ts +29 -0
  37. package/dist/types/testing/index.d.ts +1 -1
  38. package/dist/types/testing/render-route.d.ts +8 -0
  39. package/dist/types/testing/run-middleware.d.ts +20 -0
  40. package/dist/types/testing/serve-shell-request.d.ts +2 -1
  41. package/dist/types/thenable-status.d.ts +6 -0
  42. package/dist/types/types/request-scope.d.ts +7 -0
  43. package/dist/types/vite/discovery/state.d.ts +12 -9
  44. package/dist/vite/index.js +164 -103
  45. package/package.json +1 -1
  46. package/skills/caching/SKILL.md +12 -0
  47. package/skills/catalog.json +1 -1
  48. package/skills/client-urls/SKILL.md +86 -1
  49. package/skills/hooks/SKILL.md +11 -6
  50. package/skills/hooks/data.md +3 -1
  51. package/skills/loader/SKILL.md +7 -1
  52. package/skills/middleware/SKILL.md +183 -2
  53. package/skills/ppr/SKILL.md +9 -0
  54. package/skills/rango/SKILL.md +1 -1
  55. package/skills/server-actions/SKILL.md +27 -0
  56. package/skills/testing/client-components.md +1 -0
  57. package/skills/testing/middleware.md +31 -10
  58. package/skills/testing/response-routes.md +1 -0
  59. package/skills/testing/serve-shell-request.md +19 -1
  60. package/skills/use-cache/SKILL.md +4 -2
  61. package/src/__augment-tests__/augmented.check.ts +20 -0
  62. package/src/browser/event-controller.ts +3 -0
  63. package/src/browser/merge-segment-loaders.ts +31 -18
  64. package/src/browser/navigation-bridge.ts +19 -3
  65. package/src/browser/partial-update.ts +27 -12
  66. package/src/browser/react/Link.tsx +7 -9
  67. package/src/browser/react/NavigationProvider.tsx +46 -26
  68. package/src/browser/react/context.ts +3 -2
  69. package/src/browser/react/use-router.ts +2 -4
  70. package/src/browser/server-action-bridge.ts +6 -0
  71. package/src/browser/types.ts +5 -1
  72. package/src/build/route-types/scan-filter.ts +11 -4
  73. package/src/cache/document-cache-policy.ts +131 -0
  74. package/src/cache/document-cache.ts +12 -119
  75. package/src/client-urls/client-root.tsx +3 -3
  76. package/src/client-urls/client-urls.ts +8 -1
  77. package/src/client-urls/types.ts +8 -0
  78. package/src/context-var.ts +32 -2
  79. package/src/errors.ts +19 -0
  80. package/src/handles/pushed-by.ts +26 -0
  81. package/src/index.rsc.ts +8 -0
  82. package/src/index.ts +7 -0
  83. package/src/response-utils.ts +19 -0
  84. package/src/reverse.ts +17 -14
  85. package/src/route-definition/redirect.ts +2 -15
  86. package/src/route-definition/rewrite.ts +146 -0
  87. package/src/route-map-builder.ts +14 -11
  88. package/src/router/basename.ts +20 -0
  89. package/src/router/handler-context.ts +3 -13
  90. package/src/router/intercept-resolution.ts +29 -12
  91. package/src/router/internal-params.ts +16 -0
  92. package/src/router/loader-resolution.ts +2 -0
  93. package/src/router/match-middleware/background-revalidation.ts +11 -9
  94. package/src/router/match-middleware/cache-lookup.ts +20 -15
  95. package/src/router/match-middleware/cache-store.ts +9 -11
  96. package/src/router/match-middleware/intercept-resolution.ts +4 -2
  97. package/src/router/middleware-types.ts +16 -0
  98. package/src/router/middleware.ts +127 -49
  99. package/src/router/revalidation.ts +1 -1
  100. package/src/router/route-trie-builder.ts +28 -1
  101. package/src/router/segment-resolution/loader-snapshot.ts +18 -4
  102. package/src/router/segment-resolution/mask-nested.ts +8 -4
  103. package/src/router/segment-resolution/prefetch-deferral.ts +57 -17
  104. package/src/router/segment-resolution/revalidation.ts +57 -33
  105. package/src/rsc/full-payload.ts +37 -5
  106. package/src/rsc/handler.ts +82 -55
  107. package/src/rsc/helpers.ts +24 -0
  108. package/src/rsc/loader-fetch.ts +79 -24
  109. package/src/rsc/response-cache-serve.ts +18 -2
  110. package/src/rsc/response-route-handler.ts +4 -7
  111. package/src/rsc/rewrite-passes.ts +203 -0
  112. package/src/rsc/rsc-rendering.ts +4 -2
  113. package/src/rsc/ssr-setup.ts +11 -5
  114. package/src/server/cookie-parse.ts +36 -12
  115. package/src/server/cookie-store.ts +7 -3
  116. package/src/server/handle-store.ts +48 -17
  117. package/src/server/request-context.ts +154 -63
  118. package/src/server/request-headers.ts +4 -14
  119. package/src/server/rewrite-trail.ts +190 -0
  120. package/src/server/slot-accessors.ts +57 -0
  121. package/src/ssr/index.tsx +118 -16
  122. package/src/testing/dispatch.ts +138 -67
  123. package/src/testing/index.ts +1 -0
  124. package/src/testing/render-route.tsx +16 -3
  125. package/src/testing/run-loader.ts +2 -0
  126. package/src/testing/run-middleware.ts +56 -11
  127. package/src/testing/serve-shell-request.ts +15 -2
  128. package/src/thenable-status.ts +11 -0
  129. package/src/types/request-scope.ts +8 -0
  130. package/src/vite/discovery/state.ts +21 -13
  131. package/src/vite/plugins/expose-internal-ids.ts +2 -3
  132. package/src/vite/router-discovery.ts +29 -27
@@ -154,12 +154,14 @@ interface LoadingItem extends ItemBase {
154
154
 
155
155
  interface FlattenContext {
156
156
  readonly layouts: readonly ComponentType[];
157
+ readonly layoutKeys: readonly number[];
157
158
  readonly loaders: readonly ClientUrlLoaderRecord[];
158
159
  readonly loading: ReactNode | undefined;
159
160
  }
160
161
 
161
162
  const EMPTY_CONTEXT: FlattenContext = {
162
163
  layouts: [],
164
+ layoutKeys: [],
163
165
  loaders: [],
164
166
  loading: undefined,
165
167
  };
@@ -588,6 +590,7 @@ function applyConfig(
588
590
 
589
591
  return {
590
592
  layouts: context.layouts,
593
+ layoutKeys: context.layoutKeys,
591
594
  loaders,
592
595
  loading,
593
596
  };
@@ -614,6 +617,7 @@ function addPathRecord(
614
617
  options,
615
618
  component: item.component,
616
619
  layouts: Object.freeze([...routeContext.layouts]),
620
+ layoutKeys: Object.freeze([...routeContext.layoutKeys]),
617
621
  loaders: Object.freeze([...routeContext.loaders]),
618
622
  loading: routeContext.loading,
619
623
  transition: transitionItem
@@ -625,6 +629,7 @@ function addPathRecord(
625
629
 
626
630
  function flattenLayout(
627
631
  item: LayoutItem,
632
+ ordinal: number,
628
633
  context: FlattenContext,
629
634
  routes: ClientUrlRouteRecord[],
630
635
  ): void {
@@ -633,6 +638,7 @@ function flattenLayout(
633
638
  {
634
639
  ...context,
635
640
  layouts: [...context.layouts, item.component],
641
+ layoutKeys: [...context.layoutKeys, ordinal],
636
642
  },
637
643
  item.children,
638
644
  );
@@ -650,11 +656,12 @@ function flattenRoutes(
650
656
  context: FlattenContext,
651
657
  routes: ClientUrlRouteRecord[],
652
658
  ): void {
659
+ let layoutOrdinal = 0;
653
660
  for (const item of items) {
654
661
  if (item.type === "path") {
655
662
  addPathRecord(item, context, routes);
656
663
  } else if (item.type === "layout") {
657
- flattenLayout(item, context, routes);
664
+ flattenLayout(item, layoutOrdinal++, context, routes);
658
665
  }
659
666
  }
660
667
  }
@@ -105,6 +105,14 @@ export interface ClientUrlRouteRecord {
105
105
  readonly options: Readonly<ClientPathOptions> | undefined;
106
106
  readonly component: ComponentType;
107
107
  readonly layouts: readonly ComponentType[];
108
+ /**
109
+ * One per entry in `layouts`: the layout() item's ordinal among its sibling
110
+ * layout()s, its React key. Routes under the same layout() share it, so
111
+ * ClientUrlsRoot keeps that layout's instance across them (#1124). Keys only
112
+ * need to be unique among siblings: a differing parent layout remounts the
113
+ * whole subtree anyway.
114
+ */
115
+ readonly layoutKeys: readonly number[];
108
116
  readonly loaders: readonly ClientUrlLoaderRecord[];
109
117
  readonly loading: ReactNode | undefined;
110
118
  readonly transition: Readonly<ClientTransitionConfig> | undefined;
@@ -125,8 +125,38 @@ export function contextGet(
125
125
  return variables[keyOrVar.key];
126
126
  }
127
127
 
128
- /** Keys that must never be used as string variable names */
129
- const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
128
+ /**
129
+ * Keys that must never be used as string variable names, nor as a fetchable
130
+ * loader's request params (rsc/loader-fetch.ts).
131
+ */
132
+ export const FORBIDDEN_KEYS: ReadonlySet<string> = new Set([
133
+ "__proto__",
134
+ "constructor",
135
+ "prototype",
136
+ ]);
137
+
138
+ /**
139
+ * `target[key] = value` as an own data property, `__proto__` included: that
140
+ * assignment would call Object.prototype's setter and replace the target's
141
+ * prototype, which Flight refuses to serialize (#1109). Loader data parsed
142
+ * from JSON, cookie names and the like can carry the key.
143
+ */
144
+ export function assignOwn(
145
+ target: Record<string, unknown>,
146
+ key: string,
147
+ value: unknown,
148
+ ): void {
149
+ if (key === "__proto__") {
150
+ Object.defineProperty(target, key, {
151
+ value,
152
+ writable: true,
153
+ enumerable: true,
154
+ configurable: true,
155
+ });
156
+ } else {
157
+ target[key] = value;
158
+ }
159
+ }
130
160
 
131
161
  export interface ContextSetOptions {
132
162
  /**
package/src/errors.ts CHANGED
@@ -296,6 +296,25 @@ export function isSkip(value: unknown): value is Skip {
296
296
  return value instanceof Skip;
297
297
  }
298
298
 
299
+ /**
300
+ * A request middleware rewrote (`throw rewrite()`) more than `maxHops` times,
301
+ * or to the URL it was on. Reported through `onError` (phase "routing") and
302
+ * answered with 508. `trail` lists the URLs, the visitor's first.
303
+ */
304
+ export class RewriteLoopError extends Error {
305
+ name = "RewriteLoopError" as const;
306
+ readonly trail: readonly string[];
307
+
308
+ constructor(trail: readonly string[], maxHops: number) {
309
+ super(
310
+ `Infinite rewrite loop: ${trail.join(" -> ")}. A request is rewritten ` +
311
+ `at most ${maxHops} times, and never to the URL it is on.`,
312
+ );
313
+ Object.setPrototypeOf(this, RewriteLoopError.prototype);
314
+ this.trail = trail;
315
+ }
316
+ }
317
+
299
318
  /**
300
319
  * Thrown by the partial updater when the server responds with a redirect payload
301
320
  * that carries location state. Caught by navigate() to re-navigate to the
@@ -49,6 +49,32 @@ type PushedBy = readonly (string | null)[] | undefined;
49
49
  const keysOf = (a: object | undefined, b: object | undefined): Set<string> =>
50
50
  new Set([...Object.keys(a ?? {}), ...Object.keys(b ?? {})]);
51
51
 
52
+ /**
53
+ * `target` with the buckets of the segments in `ids` taken from `source`.
54
+ * A pushedBy record is a snapshot key like a handle's, so a bucket's values
55
+ * and the record of the runs that pushed them always come from the same
56
+ * snapshot. New containers; the value arrays are shared, as cloneHandleData
57
+ * (navigation-store.ts) shares them.
58
+ */
59
+ export function withSegmentHandles(
60
+ target: HandleData,
61
+ source: HandleData,
62
+ ids: ReadonlySet<string>,
63
+ ): HandleData {
64
+ const out: HandleData = {};
65
+ for (const name of keysOf(target, source)) {
66
+ const buckets: Record<string, unknown[]> = {};
67
+ for (const [id, values] of Object.entries(target[name] ?? {})) {
68
+ if (!ids.has(id)) buckets[id] = values;
69
+ }
70
+ for (const [id, values] of Object.entries(source[name] ?? {})) {
71
+ if (ids.has(id)) buckets[id] = values;
72
+ }
73
+ if (Object.keys(buckets).length > 0) out[name] = buckets;
74
+ }
75
+ return out;
76
+ }
77
+
52
78
  /**
53
79
  * The handle data after a partial response's snapshot `incoming` (each stream
54
80
  * yield holds everything the response pushed so far). A segment outside
package/src/index.rsc.ts CHANGED
@@ -119,6 +119,14 @@ export {
119
119
  transition,
120
120
  } from "./route-definition.js";
121
121
 
122
+ // Render another route at the same URL from middleware (throw rewrite()).
123
+ export {
124
+ rewrite,
125
+ type Rewrite,
126
+ type RewriteFunction,
127
+ } from "./route-definition/rewrite.js";
128
+ export { RewriteLoopError } from "./errors.js";
129
+
122
130
  // Composition types for reusable callback factories
123
131
  export type {
124
132
  RouteUseItem,
package/src/index.ts CHANGED
@@ -155,6 +155,13 @@ export function redirect(): never {
155
155
  throw serverOnlyStubError("redirect");
156
156
  }
157
157
 
158
+ /**
159
+ * Error-throwing stub for server-only `rewrite` function.
160
+ */
161
+ export function rewrite(): never {
162
+ throw serverOnlyStubError("rewrite");
163
+ }
164
+
158
165
  // Handle API (universal - works on both server and client)
159
166
  export { createHandle, isHandle, type Handle } from "./handle.js";
160
167
  export {
@@ -60,3 +60,22 @@ export function isRedirectResponse(response: Response): boolean {
60
60
  Boolean(response.headers.get("Location"))
61
61
  );
62
62
  }
63
+
64
+ /**
65
+ * Set a header on a response the router hands to the host, skipping a
66
+ * WebSocket upgrade (its headers are not to be touched) and swallowing the
67
+ * TypeError of immutable headers (a passed-through platform Response):
68
+ * instrumentation and debug headers never fail a request.
69
+ */
70
+ export function trySetHeader(
71
+ response: Response,
72
+ name: string,
73
+ value: string,
74
+ ): void {
75
+ if (isWebSocketUpgradeResponse(response)) return;
76
+ try {
77
+ response.headers.set(name, value);
78
+ } catch {
79
+ // Immutable headers: drop the header, never the response.
80
+ }
81
+ }
package/src/reverse.ts CHANGED
@@ -149,6 +149,7 @@ export type ReverseFunction<TRoutes> = {
149
149
  export type ScopedReverseFunction<
150
150
  TLocalRoutes,
151
151
  TGlobalRoutes = TLocalRoutes,
152
+ TResult = string,
152
153
  > = {
153
154
  /**
154
155
  * Global route without params
@@ -159,7 +160,7 @@ export type ScopedReverseFunction<
159
160
  > extends true
160
161
  ? TName
161
162
  : never,
162
- ): string;
163
+ ): TResult;
163
164
 
164
165
  /**
165
166
  * Global route with params
@@ -167,7 +168,7 @@ export type ScopedReverseFunction<
167
168
  <TName extends keyof TGlobalRoutes & string>(
168
169
  name: TName,
169
170
  params: ExtractParams<RoutePatternFor<TGlobalRoutes, TName>>,
170
- ): string;
171
+ ): TResult;
171
172
 
172
173
  /**
173
174
  * Global route with params and search
@@ -176,7 +177,7 @@ export type ScopedReverseFunction<
176
177
  name: TName,
177
178
  params: ExtractParams<RoutePatternFor<TGlobalRoutes, TName>>,
178
179
  search: ResolveSearchSchema<ExtractSearchSchema<TGlobalRoutes, TName>>,
179
- ): string;
180
+ ): TResult;
180
181
 
181
182
  /**
182
183
  * Dot-prefixed local route without params
@@ -187,7 +188,7 @@ export type ScopedReverseFunction<
187
188
  > extends true
188
189
  ? `.${TName}`
189
190
  : never,
190
- ): string;
191
+ ): TResult;
191
192
 
192
193
  /**
193
194
  * Dot-prefixed local route with params
@@ -195,7 +196,7 @@ export type ScopedReverseFunction<
195
196
  <TName extends keyof TLocalRoutes & string>(
196
197
  name: `.${TName}`,
197
198
  params: ExtractParams<RoutePatternFor<TLocalRoutes, TName>>,
198
- ): string;
199
+ ): TResult;
199
200
 
200
201
  /**
201
202
  * Dot-prefixed local route with params and search
@@ -204,7 +205,7 @@ export type ScopedReverseFunction<
204
205
  name: `.${TName}`,
205
206
  params: ExtractParams<RoutePatternFor<TLocalRoutes, TName>>,
206
207
  search: ResolveSearchSchema<ExtractSearchSchema<TLocalRoutes, TName>>,
207
- ): string;
208
+ ): TResult;
208
209
  };
209
210
 
210
211
  /**
@@ -235,14 +236,16 @@ export type ScopedReverseFunction<
235
236
  * reverse(".post", { slug: "hello" }) // compile error: no include() scope
236
237
  * ```
237
238
  */
238
- export type GlobalReverseFunction<TGlobalRoutes> =
239
- string extends keyof TGlobalRoutes
240
- ? <TName extends string>(
241
- name: Exclude<TName, `.${string}`>,
242
- params?: Record<string, string>,
243
- search?: Record<string, unknown>,
244
- ) => string
245
- : ScopedReverseFunction<{}, TGlobalRoutes>;
239
+ export type GlobalReverseFunction<
240
+ TGlobalRoutes,
241
+ TResult = string,
242
+ > = string extends keyof TGlobalRoutes
243
+ ? <TName extends string>(
244
+ name: Exclude<TName, `.${string}`>,
245
+ params?: Record<string, string>,
246
+ search?: Record<string, unknown>,
247
+ ) => TResult
248
+ : ScopedReverseFunction<{}, TGlobalRoutes, TResult>;
246
249
 
247
250
  /**
248
251
  * Extract local routes type from UrlPatterns
@@ -8,6 +8,7 @@ import {
8
8
  attachRedirectState,
9
9
  markExternalRedirect,
10
10
  } from "../redirect-origin.js";
11
+ import { prefixBasename } from "../router/basename.js";
11
12
 
12
13
  /**
13
14
  * Create a soft redirect Response for middleware short-circuit
@@ -84,21 +85,7 @@ export function redirect(
84
85
  }
85
86
 
86
87
  // Auto-prefix root-relative URLs with basename for app-local redirects.
87
- // Treat the URL as already-prefixed when the basename is followed by a path
88
- // separator, a query, a fragment, or end-of-string, so "/admin?tab=x" and
89
- // "/admin#frag" are not double-prefixed into "/admin/admin?tab=x".
90
- const bn = _getRequestContext()?._basename;
91
- let resolvedUrl = url;
92
- if (
93
- bn &&
94
- url.startsWith("/") &&
95
- url !== bn &&
96
- !url.startsWith(bn + "/") &&
97
- !url.startsWith(bn + "?") &&
98
- !url.startsWith(bn + "#")
99
- ) {
100
- resolvedUrl = url === "/" ? bn : bn + url;
101
- }
88
+ const resolvedUrl = prefixBasename(url, _getRequestContext()?._basename);
102
89
 
103
90
  const headers: Record<string, string> = {
104
91
  Location: resolvedUrl,
@@ -0,0 +1,146 @@
1
+ /**
2
+ * `throw rewrite(target)`: middleware renders another route at the same URL.
3
+ *
4
+ * The value only: what middleware throws and how the router recognizes it.
5
+ * The pass loop that honors it lives in rsc/rewrite-passes.ts, the hop trail
6
+ * in server/rewrite-trail.ts.
7
+ */
8
+
9
+ import type { GlobalReverseFunction } from "../reverse.js";
10
+ import type { DefaultReverseRouteMap } from "../types/global-namespace.js";
11
+
12
+ /**
13
+ * Cross-realm brand: a Vite dev server can load this module twice (the RSC
14
+ * and the SSR graph), so `instanceof` alone misses a Rewrite from the other
15
+ * copy.
16
+ */
17
+ const REWRITE_BRAND: unique symbol = Symbol.for("rango.rewrite");
18
+
19
+ /** The target as written: a path, or a global route name with its params. */
20
+ export type RewriteTarget =
21
+ | { readonly path: string }
22
+ | {
23
+ readonly name: string;
24
+ readonly params?: Record<string, string>;
25
+ readonly search?: Record<string, unknown>;
26
+ };
27
+
28
+ const REWRITE_RULE =
29
+ "rewrite() is for middleware: throw it from router.use() or route " +
30
+ "middleware (middleware() in urls()), before await next(), while it " +
31
+ "handles a GET or HEAD page request. Loaders and route handlers can't " +
32
+ "rewrite: throw rewrite() from the route's middleware instead. Intercept " +
33
+ "middleware and loader fetches can't either. In a server action use " +
34
+ "redirect(); for a mutation return a Response from middleware.";
35
+
36
+ export function describeRewriteTarget(target: RewriteTarget): string {
37
+ return "path" in target
38
+ ? `rewrite(${JSON.stringify(target.path)})`
39
+ : `rewrite(${JSON.stringify(target.name)})`;
40
+ }
41
+
42
+ /**
43
+ * The value `rewrite()` returns, thrown by middleware. An Error, so a throw
44
+ * where a rewrite is not supported fails loudly with the rule in its message.
45
+ */
46
+ export class Rewrite extends Error {
47
+ name = "Rewrite" as const;
48
+ readonly target: RewriteTarget;
49
+
50
+ constructor(target: RewriteTarget) {
51
+ super(`${describeRewriteTarget(target)}: ${REWRITE_RULE}`);
52
+ Object.setPrototypeOf(this, Rewrite.prototype);
53
+ this.target = target;
54
+ }
55
+ }
56
+ Object.defineProperty(Rewrite.prototype, REWRITE_BRAND, { value: true });
57
+
58
+ /** Whether `value` is a Rewrite thrown by `rewrite()` (ctx.isRewrite). */
59
+ export function isRewrite(value: unknown): value is Rewrite {
60
+ return (
61
+ typeof value === "object" &&
62
+ value !== null &&
63
+ (value as { [REWRITE_BRAND]?: unknown })[REWRITE_BRAND] === true
64
+ );
65
+ }
66
+
67
+ /**
68
+ * `rewrite(path)` or `rewrite(name, params?, search?)`: a path in this app
69
+ * (basename-prefixed like `redirect()`, may carry `?search`), or a global
70
+ * route name with the params and search `ctx.reverse` takes.
71
+ */
72
+ export type RewriteFunction<TRoutes = DefaultReverseRouteMap> = ((
73
+ path: `/${string}`,
74
+ ) => Rewrite) &
75
+ GlobalReverseFunction<TRoutes, Rewrite>;
76
+
77
+ function createRewrite(
78
+ target: string,
79
+ params?: Record<string, string>,
80
+ search?: Record<string, unknown>,
81
+ ): Rewrite {
82
+ if (typeof target !== "string" || target === "") {
83
+ throw new TypeError(
84
+ 'rewrite() takes a path starting with "/" or a global route name.',
85
+ );
86
+ }
87
+ if (target.startsWith("/")) {
88
+ if (params !== undefined || search !== undefined) {
89
+ throw new TypeError(
90
+ `rewrite(${JSON.stringify(target)}) takes params only with a route name, not with a path.`,
91
+ );
92
+ }
93
+ return new Rewrite({ path: target });
94
+ }
95
+ if (target.startsWith(".")) {
96
+ throw new TypeError(
97
+ `rewrite(${JSON.stringify(target)}): a rewrite resolves global route names only (no include() scope).`,
98
+ );
99
+ }
100
+ return new Rewrite({ name: target, params, search });
101
+ }
102
+
103
+ /**
104
+ * Render another route at the same URL. Throw it from middleware, before
105
+ * `await next()`:
106
+ *
107
+ * ```ts
108
+ * router.use(async (ctx, next) => {
109
+ * if (!locked || ctx.pathname === "/maintenance") return next();
110
+ * throw rewrite("maintenance");
111
+ * });
112
+ * ```
113
+ *
114
+ * The request runs again as the target (global middleware, route
115
+ * middleware, render) with the same request context: values set before the
116
+ * throw stay readable. `ctx.url` is the target; `ctx.rewrittenFrom` is the
117
+ * visitor's URL. See the `/middleware` skill.
118
+ */
119
+ export const rewrite: RewriteFunction = createRewrite as RewriteFunction;
120
+
121
+ /** A dispatch site that cannot honor a rewrite. */
122
+ export type RewriteSite = "mutation" | "loader-fetch" | "intercept";
123
+
124
+ const SITE_MESSAGE: Record<RewriteSite, string> = {
125
+ mutation:
126
+ "middleware threw it while handling a non-GET request (a server action " +
127
+ "or a form post). Return a Response for mutations instead, e.g. " +
128
+ 'new Response("Maintenance", { status: 503 }).',
129
+ "loader-fetch":
130
+ "middleware threw it during a loader fetch (useFetchLoader, load(), " +
131
+ "useRefreshLoaders). Return a Response there.",
132
+ intercept:
133
+ "intercept middleware threw it. Rewrite from router.use() or the " +
134
+ "route's middleware(), or return a Response.",
135
+ };
136
+
137
+ /** The error a rewrite becomes at a site that cannot honor it. */
138
+ export function rewriteNotSupported(
139
+ rewrite: Rewrite,
140
+ site: RewriteSite,
141
+ ): Error {
142
+ return new Error(
143
+ `${describeRewriteTarget(rewrite.target)} is not supported here: ${SITE_MESSAGE[site]}`,
144
+ { cause: rewrite },
145
+ );
146
+ }
@@ -8,6 +8,11 @@
8
8
  * See docs/manifests.md for the full data flow.
9
9
  */
10
10
 
11
+ import {
12
+ nullPrototypeStaticChildren,
13
+ type TrieNode,
14
+ } from "./router/route-trie-builder.js";
15
+
11
16
  let globalRouteMap: Record<string, string> = {};
12
17
 
13
18
  let cachedManifest: Record<string, string> | null = null;
@@ -86,8 +91,7 @@ export function clearCachedManifest(): void {
86
91
  // precomputed entries so multi-router setups (e.g. site + admin via
87
92
  // createHostRouter()) don't see each other's routes.
88
93
  const perRouterManifestMap: Map<string, Record<string, string>> = new Map();
89
- const perRouterTrieMap: Map<string, import("./build/route-trie.js").TrieNode> =
90
- new Map();
94
+ const perRouterTrieMap: Map<string, TrieNode> = new Map();
91
95
  const perRouterPrecomputedEntriesMap: Map<
92
96
  string,
93
97
  Array<{ staticPrefix: string; routes: Record<string, string> }>
@@ -132,11 +136,12 @@ export function getRouterManifest(
132
136
  return perRouterManifestMap.get(routerId);
133
137
  }
134
138
 
135
- export function setRouterTrie(
136
- routerId: string,
137
- trie: import("./build/route-trie.js").TrieNode,
138
- ): void {
139
- perRouterTrieMap.set(routerId, trie);
139
+ /**
140
+ * Install a router's trie. A trie that arrives as JSON (the build manifest)
141
+ * or across the dev runner boundary gets null-prototype static maps first.
142
+ */
143
+ export function setRouterTrie(routerId: string, trie: TrieNode): void {
144
+ perRouterTrieMap.set(routerId, nullPrototypeStaticChildren(trie));
140
145
  }
141
146
 
142
147
  // Routers whose trie came from the COMPLETE build manifest (deserialized via
@@ -156,9 +161,7 @@ export function isRouterTrieAuthoritative(routerId: string): boolean {
156
161
  return authoritativeTrieRouters.has(routerId);
157
162
  }
158
163
 
159
- export function getRouterTrie(
160
- routerId: string,
161
- ): import("./build/route-trie.js").TrieNode | undefined {
164
+ export function getRouterTrie(routerId: string): TrieNode | undefined {
162
165
  return perRouterTrieMap.get(routerId);
163
166
  }
164
167
 
@@ -199,7 +202,7 @@ export async function ensureRouterManifest(routerId: string): Promise<void> {
199
202
  if (loader) {
200
203
  const mod = await loader();
201
204
  if (mod.trie) {
202
- perRouterTrieMap.set(routerId, mod.trie);
205
+ setRouterTrie(routerId, mod.trie);
203
206
  // A trie serialized into the build manifest comes from complete
204
207
  // discovery — misses are authoritative 404s (see find-match.ts).
205
208
  markRouterTrieAuthoritative(routerId);
@@ -12,3 +12,23 @@ export function normalizeBasename(basename?: string): string | undefined {
12
12
  const trimmed = basename.replace(/^\/+|\/+$/g, "");
13
13
  return trimmed ? "/" + trimmed : undefined;
14
14
  }
15
+
16
+ /**
17
+ * Prefix a root-relative path with the basename unless it already carries it
18
+ * (the basename followed by "/", "?", "#" or the end), so "/admin?tab=x" and
19
+ * "/admin#frag" under basename "/admin" are not prefixed twice. Shared by
20
+ * redirect(), rewrite() and useRouter().
21
+ */
22
+ export function prefixBasename(path: string, basename?: string): string {
23
+ if (
24
+ !basename ||
25
+ !path.startsWith("/") ||
26
+ path === basename ||
27
+ path.startsWith(basename + "/") ||
28
+ path.startsWith(basename + "?") ||
29
+ path.startsWith(basename + "#")
30
+ ) {
31
+ return path;
32
+ }
33
+ return path === "/" ? basename : basename + path;
34
+ }
@@ -30,6 +30,7 @@ import { isAutoGeneratedRouteName } from "../route-name.js";
30
30
  import { PRERENDER_PASSTHROUGH } from "../prerender.js";
31
31
  import { substitutePatternParams } from "./substitute-pattern-params.js";
32
32
  import { fireAndForgetWaitUntil } from "../types/request-scope.js";
33
+ import { defineRewrittenFrom } from "../server/rewrite-trail.js";
33
34
 
34
35
  // Mutating Headers methods guarded so they throw inside "use cache" / cache()
35
36
  // scope. Module-level constant (read-only, .has() lookups) so it is allocated
@@ -87,19 +88,7 @@ export function guardHeaderWrites(
87
88
  });
88
89
  }
89
90
 
90
- /**
91
- * Strip internal _rsc* query params from a URL.
92
- * Returns a new URL with only user-facing params.
93
- */
94
- export function stripInternalParams(url: URL): URL {
95
- const clean = new URL(url);
96
- for (const key of [...clean.searchParams.keys()]) {
97
- if (key.startsWith("_rsc")) {
98
- clean.searchParams.delete(key);
99
- }
100
- }
101
- return clean;
102
- }
91
+ export { stripInternalParams } from "./internal-params.js";
103
92
 
104
93
  /**
105
94
  * Resolve route name with namespace prefix support.
@@ -362,6 +351,7 @@ export function createHandlerContext<TEnv>(
362
351
  (ctx as { [THEME_REQUEST_CONTEXT]?: unknown })[THEME_REQUEST_CONTEXT] =
363
352
  requestContext;
364
353
  Object.defineProperty(ctx, "theme", THEME_DESCRIPTOR);
354
+ defineRewrittenFrom(ctx, requestContext);
365
355
  // Brand with taint symbol so "use cache" excludes ctx from cache keys
366
356
  (ctx as any)[NOCACHE_SYMBOL] = true;
367
357
  return ctx;