@rangojs/router 0.0.0-experimental.124 → 0.0.0-experimental.126

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 (235) hide show
  1. package/README.md +6 -4
  2. package/dist/bin/rango.js +3 -4
  3. package/dist/vite/index.js +315 -68
  4. package/package.json +19 -18
  5. package/skills/breadcrumbs/SKILL.md +60 -0
  6. package/skills/hooks/SKILL.md +2 -2
  7. package/skills/route/SKILL.md +6 -0
  8. package/skills/server-actions/SKILL.md +25 -1
  9. package/skills/testing/SKILL.md +17 -17
  10. package/skills/testing/cache-prerender.md +29 -3
  11. package/skills/testing/flight.md +13 -10
  12. package/skills/testing/render-handler.md +3 -0
  13. package/skills/testing/server-tree.md +1 -1
  14. package/skills/testing/setup.md +1 -1
  15. package/src/__internal.ts +0 -65
  16. package/src/browser/action-coordinator.ts +1 -1
  17. package/src/browser/action-fence.ts +10 -0
  18. package/src/browser/event-controller.ts +1 -83
  19. package/src/browser/navigation-store-handle.ts +3 -4
  20. package/src/browser/navigation-store.ts +0 -39
  21. package/src/browser/navigation-transaction.ts +0 -32
  22. package/src/browser/partial-update.ts +23 -84
  23. package/src/browser/prefetch/cache.ts +6 -45
  24. package/src/browser/prefetch/queue.ts +6 -3
  25. package/src/browser/rango-state.ts +2 -23
  26. package/src/browser/react/Link.tsx +0 -2
  27. package/src/browser/react/NavigationProvider.tsx +2 -1
  28. package/src/browser/react/ScrollRestoration.tsx +10 -6
  29. package/src/browser/react/filter-segment-order.ts +0 -2
  30. package/src/browser/react/index.ts +0 -45
  31. package/src/browser/react/location-state-shared.ts +0 -13
  32. package/src/browser/react/location-state.ts +0 -1
  33. package/src/browser/react/use-action.ts +6 -15
  34. package/src/browser/react/use-handle.ts +0 -5
  35. package/src/browser/react/use-link-status.ts +0 -4
  36. package/src/browser/react/use-navigation.ts +0 -3
  37. package/src/browser/react/use-params.ts +0 -2
  38. package/src/browser/react/use-router.ts +2 -1
  39. package/src/browser/react/use-search-params.ts +0 -5
  40. package/src/browser/react/use-segments.ts +0 -13
  41. package/src/browser/rsc-router.tsx +10 -3
  42. package/src/browser/server-action-bridge.ts +51 -3
  43. package/src/browser/types.ts +23 -5
  44. package/src/browser/validate-redirect-origin.ts +43 -16
  45. package/src/build/index.ts +8 -9
  46. package/src/build/route-trie.ts +46 -11
  47. package/src/build/route-types/param-extraction.ts +6 -3
  48. package/src/build/route-types/router-processing.ts +0 -8
  49. package/src/cache/cache-policy.ts +0 -54
  50. package/src/cache/cache-runtime.ts +48 -24
  51. package/src/cache/cache-scope.ts +0 -27
  52. package/src/cache/cache-tag.ts +0 -37
  53. package/src/cache/cf/cf-cache-store.ts +72 -45
  54. package/src/cache/cf/index.ts +0 -24
  55. package/src/cache/document-cache.ts +10 -36
  56. package/src/cache/handle-snapshot.ts +0 -40
  57. package/src/cache/index.ts +0 -27
  58. package/src/cache/memory-segment-store.ts +0 -52
  59. package/src/cache/profile-registry.ts +6 -30
  60. package/src/cache/read-through-swr.ts +41 -11
  61. package/src/cache/segment-codec.ts +0 -16
  62. package/src/cache/types.ts +0 -98
  63. package/src/client.rsc.tsx +4 -22
  64. package/src/client.tsx +19 -32
  65. package/src/context-var.ts +12 -0
  66. package/src/defer.ts +196 -0
  67. package/src/deps/ssr.ts +0 -1
  68. package/src/handle.ts +2 -12
  69. package/src/handles/MetaTags.tsx +0 -14
  70. package/src/handles/breadcrumbs.ts +16 -5
  71. package/src/handles/meta.ts +0 -39
  72. package/src/host/cookie-handler.ts +0 -36
  73. package/src/host/errors.ts +0 -24
  74. package/src/host/index.ts +6 -0
  75. package/src/host/pattern-matcher.ts +7 -50
  76. package/src/host/router.ts +1 -65
  77. package/src/host/testing.ts +0 -16
  78. package/src/host/types.ts +6 -2
  79. package/src/href-client.ts +0 -4
  80. package/src/index.rsc.ts +27 -2
  81. package/src/index.ts +7 -0
  82. package/src/internal-debug.ts +2 -4
  83. package/src/loader.rsc.ts +4 -15
  84. package/src/loader.ts +3 -9
  85. package/src/network-error-thrower.tsx +1 -6
  86. package/src/outlet-provider.tsx +1 -5
  87. package/src/prerender/param-hash.ts +10 -11
  88. package/src/prerender/store.ts +23 -30
  89. package/src/prerender.ts +34 -0
  90. package/src/redirect-origin.ts +100 -0
  91. package/src/root-error-boundary.tsx +1 -19
  92. package/src/route-content-wrapper.tsx +1 -44
  93. package/src/route-definition/dsl-helpers.ts +7 -19
  94. package/src/route-definition/helpers-types.ts +3 -3
  95. package/src/route-definition/redirect.ts +43 -9
  96. package/src/route-definition/resolve-handler-use.ts +6 -0
  97. package/src/route-map-builder.ts +0 -16
  98. package/src/router/content-negotiation.ts +0 -13
  99. package/src/router/error-handling.ts +12 -16
  100. package/src/router/find-match.ts +4 -31
  101. package/src/router/intercept-resolution.ts +10 -1
  102. package/src/router/lazy-includes.ts +1 -57
  103. package/src/router/loader-resolution.ts +25 -23
  104. package/src/router/logging.ts +0 -6
  105. package/src/router/manifest.ts +1 -25
  106. package/src/router/match-api.ts +0 -20
  107. package/src/router/match-context.ts +0 -22
  108. package/src/router/match-handlers.ts +0 -43
  109. package/src/router/match-middleware/background-revalidation.ts +0 -7
  110. package/src/router/match-middleware/cache-lookup.ts +96 -179
  111. package/src/router/match-middleware/cache-store.ts +0 -31
  112. package/src/router/match-middleware/intercept-resolution.ts +0 -22
  113. package/src/router/match-middleware/segment-resolution.ts +0 -22
  114. package/src/router/match-pipelines.ts +1 -42
  115. package/src/router/match-result.ts +1 -52
  116. package/src/router/metrics.ts +0 -34
  117. package/src/router/middleware-types.ts +0 -116
  118. package/src/router/middleware.ts +77 -60
  119. package/src/router/navigation-snapshot.ts +0 -51
  120. package/src/router/params-util.ts +23 -0
  121. package/src/router/pattern-matching.ts +5 -56
  122. package/src/router/prerender-match.ts +56 -51
  123. package/src/router/request-classification.ts +1 -38
  124. package/src/router/revalidation.ts +14 -62
  125. package/src/router/route-snapshot.ts +0 -1
  126. package/src/router/router-context.ts +0 -27
  127. package/src/router/router-interfaces.ts +10 -0
  128. package/src/router/segment-resolution/fresh.ts +25 -57
  129. package/src/router/segment-resolution/helpers.ts +34 -0
  130. package/src/router/segment-resolution/loader-cache.ts +35 -23
  131. package/src/router/segment-resolution/revalidation.ts +188 -283
  132. package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
  133. package/src/router/segment-resolution.ts +4 -1
  134. package/src/router/segment-wrappers.ts +0 -3
  135. package/src/router/telemetry-otel.ts +0 -20
  136. package/src/router/telemetry.ts +0 -22
  137. package/src/router/timeout.ts +0 -20
  138. package/src/router/trie-matching.ts +66 -45
  139. package/src/router/types.ts +1 -63
  140. package/src/router/url-params.ts +0 -5
  141. package/src/router.ts +8 -11
  142. package/src/rsc/handler-context.ts +1 -0
  143. package/src/rsc/handler.ts +20 -4
  144. package/src/rsc/helpers.ts +71 -3
  145. package/src/rsc/json-route-result.ts +38 -0
  146. package/src/rsc/origin-guard.ts +9 -15
  147. package/src/rsc/progressive-enhancement.ts +10 -1
  148. package/src/rsc/redirect-guard.ts +99 -0
  149. package/src/rsc/response-route-handler.ts +23 -18
  150. package/src/rsc/rsc-rendering.ts +2 -7
  151. package/src/rsc/runtime-warnings.ts +14 -0
  152. package/src/rsc/server-action.ts +34 -29
  153. package/src/rsc/types.ts +6 -3
  154. package/src/search-params.ts +0 -16
  155. package/src/segment-loader-promise.ts +14 -2
  156. package/src/segment-system.tsx +79 -88
  157. package/src/server/handle-store.ts +7 -24
  158. package/src/server/loader-registry.ts +5 -24
  159. package/src/server/request-context.ts +29 -92
  160. package/src/ssr/index.tsx +14 -14
  161. package/src/static-handler.ts +2 -27
  162. package/src/testing/cache-status.ts +44 -48
  163. package/src/testing/collect-handle.ts +1 -24
  164. package/src/testing/dispatch.ts +43 -6
  165. package/src/testing/e2e/index.ts +1 -22
  166. package/src/testing/e2e/matchers.ts +0 -16
  167. package/src/testing/flight-matchers.ts +0 -13
  168. package/src/testing/flight-normalize.ts +3 -30
  169. package/src/testing/flight.ts +46 -48
  170. package/src/testing/generated-routes.ts +1 -41
  171. package/src/testing/index.ts +1 -21
  172. package/src/testing/internal/context.ts +3 -45
  173. package/src/testing/internal/seed-vars.ts +0 -26
  174. package/src/testing/render-handler.ts +31 -61
  175. package/src/testing/render-route.tsx +75 -103
  176. package/src/testing/run-loader.ts +0 -96
  177. package/src/testing/run-middleware.ts +0 -26
  178. package/src/theme/ThemeProvider.tsx +0 -52
  179. package/src/theme/ThemeScript.tsx +0 -6
  180. package/src/theme/constants.ts +0 -12
  181. package/src/theme/index.ts +0 -7
  182. package/src/theme/theme-context.ts +1 -5
  183. package/src/theme/theme-script.ts +0 -14
  184. package/src/theme/use-theme.ts +0 -3
  185. package/src/types/boundaries.ts +0 -35
  186. package/src/types/error-types.ts +25 -89
  187. package/src/types/global-namespace.ts +4 -14
  188. package/src/types/handler-context.ts +28 -9
  189. package/src/types/index.ts +0 -10
  190. package/src/types/request-scope.ts +0 -19
  191. package/src/types/route-config.ts +6 -50
  192. package/src/types/route-entry.ts +0 -6
  193. package/src/types/segments.ts +0 -13
  194. package/src/urls/include-helper.ts +0 -4
  195. package/src/urls/index.ts +0 -6
  196. package/src/urls/path-helper-types.ts +2 -2
  197. package/src/urls/path-helper.ts +0 -54
  198. package/src/urls/urls-function.ts +0 -13
  199. package/src/use-loader.tsx +0 -186
  200. package/src/vite/discovery/bundle-postprocess.ts +2 -1
  201. package/src/vite/discovery/discover-routers.ts +28 -18
  202. package/src/vite/discovery/prerender-collection.ts +2 -4
  203. package/src/vite/discovery/state.ts +5 -0
  204. package/src/vite/discovery/virtual-module-codegen.ts +1 -11
  205. package/src/vite/plugin-types.ts +35 -9
  206. package/src/vite/plugins/cjs-to-esm.ts +0 -11
  207. package/src/vite/plugins/client-ref-dedup.ts +0 -11
  208. package/src/vite/plugins/client-ref-hashing.ts +0 -10
  209. package/src/vite/plugins/cloudflare-protocol-stub.ts +0 -20
  210. package/src/vite/plugins/expose-action-id.ts +2 -73
  211. package/src/vite/plugins/expose-id-utils.ts +0 -55
  212. package/src/vite/plugins/expose-ids/export-analysis.ts +0 -38
  213. package/src/vite/plugins/expose-ids/handler-transform.ts +0 -15
  214. package/src/vite/plugins/expose-ids/loader-transform.ts +0 -15
  215. package/src/vite/plugins/expose-ids/router-transform.ts +0 -13
  216. package/src/vite/plugins/expose-internal-ids.ts +10 -0
  217. package/src/vite/plugins/performance-tracks.ts +0 -3
  218. package/src/vite/plugins/refresh-cmd.ts +1 -1
  219. package/src/vite/plugins/use-cache-transform.ts +21 -46
  220. package/src/vite/plugins/version-injector.ts +0 -20
  221. package/src/vite/plugins/version-plugin.ts +1 -49
  222. package/src/vite/plugins/virtual-entries.ts +0 -15
  223. package/src/vite/rango.ts +2 -108
  224. package/src/vite/router-discovery.ts +9 -1
  225. package/src/vite/utils/ast-handler-extract.ts +0 -16
  226. package/src/vite/utils/bundle-analysis.ts +6 -13
  227. package/src/vite/utils/client-chunks.ts +0 -6
  228. package/src/vite/utils/forward-user-plugins.ts +0 -22
  229. package/src/vite/utils/manifest-utils.ts +0 -4
  230. package/src/vite/utils/package-resolution.ts +1 -73
  231. package/src/vite/utils/prerender-utils.ts +0 -35
  232. package/src/vite/utils/shared-utils.ts +3 -35
  233. package/src/browser/shallow.ts +0 -40
  234. package/src/handles/index.ts +0 -7
  235. package/src/router/middleware-cookies.ts +0 -55
package/src/defer.ts ADDED
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Deferred handle values — "decide synchronously, resolve late".
3
+ *
4
+ * A handle is pushed from code that holds `ctx` (a route/layout handler), so the
5
+ * decision to push lands before the handles stream seals. But the value often
6
+ * isn't known there — it may come from a deep async component far from the
7
+ * handler. `ctx.use(Handle).defer()` reserves the handle's slot now (synchronous,
8
+ * so ordering and the pre-seal timing hold) and returns a resolver with the SAME
9
+ * signature as the push: you call it later, anywhere in the render, with the same
10
+ * value you would have passed to the push.
11
+ *
12
+ * const breadcrumb = ctx.use(Breadcrumbs); // (item) => void & .defer()
13
+ * const resolve = breadcrumb.defer({ timeoutMs: 5000, else: null });
14
+ * // deep async component, far from ctx:
15
+ * resolve({ label, href, content }); // identical call, just deferred
16
+ *
17
+ * Under the hood the reserved slot is a Promise the renderer `use()`s; RSC Flight
18
+ * streams it as a late row, so a deferred-aware consumer reading the handle
19
+ * (`useHandle`) sees that entry as a `Promise` until it resolves (see
20
+ * {@link DeferredHandleEntry}). The hazard that guards against bugs: a deferred
21
+ * slot whose resolver is never called would keep the Flight stream — and the HTTP
22
+ * response — open forever. So a deferred auto-resolves to `else` after `timeoutMs`
23
+ * (default {@link DEFAULT_DEFER_TIMEOUT_MS}) if the resolver is never called,
24
+ * degrading gracefully (and warning in dev) instead of hanging the request.
25
+ */
26
+
27
+ /** Default auto-resolve window. Long enough for genuine deep async work, short
28
+ * enough that a forgotten resolve does not hang the response indefinitely. */
29
+ export const DEFAULT_DEFER_TIMEOUT_MS = 10_000;
30
+
31
+ /** Options for `ctx.use(Handle).defer()`. */
32
+ export interface DeferOptions<TData> {
33
+ /**
34
+ * Auto-resolve to `else` after this many ms if the resolver is never called,
35
+ * so a forgotten resolve cannot hold the Flight stream — and thus the HTTP
36
+ * response — open. Defaults to {@link DEFAULT_DEFER_TIMEOUT_MS}. `0` or
37
+ * `Infinity` disable the timeout intentionally (not recommended on a request
38
+ * path). Any other non-finite or negative value is treated as a mistake and
39
+ * falls back to the default rather than silently disabling the safety net.
40
+ * Named `timeoutMs` to match the router's `*Ms` duration convention.
41
+ */
42
+ timeoutMs?: number;
43
+ /**
44
+ * Value the slot resolves to if the timeout fires before the resolver is
45
+ * called. Defaults to `undefined` (the deferred item is skipped/empty). For
46
+ * renderable handle content, `null` is the usual graceful fallback, so the
47
+ * type admits `null` even when `TData` does not.
48
+ */
49
+ else?: TData | null;
50
+ }
51
+
52
+ /**
53
+ * The call signature shared by a handle push and the resolver returned by
54
+ * `.defer()`: a concrete value, a `Promise` of the value (Flight streams it as a
55
+ * late row), or a thunk returning a `Promise` (called immediately).
56
+ */
57
+ export type HandlePushFn<TData> = (
58
+ data: TData | Promise<TData> | (() => Promise<TData>),
59
+ ) => void;
60
+
61
+ /**
62
+ * The push function returned by `ctx.use(Handle)`. Call it to push a value now,
63
+ * or call `.defer()` to reserve the slot now and resolve the value later (e.g.
64
+ * from a deep async component) with a timeout safety net.
65
+ */
66
+ export type HandlePush<TData> = HandlePushFn<TData> & {
67
+ /**
68
+ * Reserve this handle's slot synchronously and return a resolver that is
69
+ * push-equal: it takes the same argument shapes as the push (value, Promise, or
70
+ * thunk) and behaves identically. Two things the resolver adds over a direct
71
+ * push: a timeout (if the resolver is never called, the slot auto-resolves to
72
+ * `options.else` after `options.timeoutMs`; calling the resolver cancels it),
73
+ * and — on the action/revalidation path only — a thunk it runs does NOT
74
+ * re-enter the deadlock-guard push-callback scope a direct push thunk gets,
75
+ * because a deferred resolver fires after the handler phase has closed.
76
+ *
77
+ * The reserved slot appears in the accumulated handle data as a pending
78
+ * `Promise` until it resolves (see {@link DeferredHandleEntry}); a
79
+ * deferred-aware consumer narrows thenable entries (`use()`/`await` + null
80
+ * check) before dereferencing.
81
+ */
82
+ defer(options?: DeferOptions<TData>): HandlePushFn<TData>;
83
+ };
84
+
85
+ /**
86
+ * A handle entry a deferred-aware consumer may read from `useHandle`: either a
87
+ * resolved value, or a pending `Promise` that resolves to the value, to `else`,
88
+ * or (when no `else` was given) `undefined` on timeout. Reading code should treat
89
+ * thenable entries as such and narrow before dereferencing.
90
+ */
91
+ export type DeferredHandleEntry<TData> =
92
+ | TData
93
+ | Promise<TData | null | undefined>;
94
+
95
+ // Internal: a timeout-bounded { promise, resolve }. Not part of the public API
96
+ // (the public surface is `ctx.use(Handle).defer()`); exported for `withDefer`
97
+ // and unit tests only. Resolves to `T`, the `else` fallback, or `undefined`.
98
+ export function createDeferred<T>(options?: {
99
+ timeoutMs?: number;
100
+ fallback?: T | null;
101
+ }): {
102
+ promise: Promise<T | null | undefined>;
103
+ resolve: (value: T | null | undefined) => void;
104
+ } {
105
+ let resolveInner!: (value: T | null | undefined) => void;
106
+ let settled = false;
107
+ let timer: ReturnType<typeof setTimeout> | undefined;
108
+
109
+ const promise = new Promise<T | null | undefined>((resolve) => {
110
+ resolveInner = resolve;
111
+ });
112
+
113
+ const finish = (value: T | null | undefined): void => {
114
+ if (settled) return;
115
+ settled = true;
116
+ if (timer !== undefined) {
117
+ clearTimeout(timer);
118
+ timer = undefined;
119
+ }
120
+ resolveInner(value);
121
+ };
122
+
123
+ // 0 and Infinity are documented intentional disables. Any other non-finite or
124
+ // negative value (NaN, -1, a bad parsed config/env) is a mistake — fall back to
125
+ // the default rather than SILENTLY disabling the safety net, which would let a
126
+ // forgotten resolve hang the Flight stream and the response forever.
127
+ const requested = options?.timeoutMs ?? DEFAULT_DEFER_TIMEOUT_MS;
128
+ let ms: number;
129
+ if (requested === 0 || requested === Infinity) {
130
+ ms = requested;
131
+ } else if (Number.isFinite(requested) && requested > 0) {
132
+ ms = requested;
133
+ } else {
134
+ if (process.env.NODE_ENV !== "production") {
135
+ console.warn(
136
+ `[rango] defer(): invalid timeout ${String(requested)}; using the ` +
137
+ `${DEFAULT_DEFER_TIMEOUT_MS}ms default so the safety net stays on. ` +
138
+ `Use 0 or Infinity to disable the timeout intentionally.`,
139
+ );
140
+ }
141
+ ms = DEFAULT_DEFER_TIMEOUT_MS;
142
+ }
143
+
144
+ if (ms > 0 && ms !== Infinity) {
145
+ timer = setTimeout(() => {
146
+ if (process.env.NODE_ENV !== "production") {
147
+ console.warn(
148
+ `[rango] A deferred handle value was not resolved within ${ms}ms; ` +
149
+ `resolving to the fallback so the response can flush. Call the ` +
150
+ `resolver from the component that produces the value, or raise timeoutMs.`,
151
+ );
152
+ }
153
+ finish(options?.fallback);
154
+ }, ms);
155
+ // Don't let a pending timer alone keep a Node process alive (no-op on workerd).
156
+ (timer as { unref?: () => void }).unref?.();
157
+ }
158
+
159
+ return { promise, resolve: finish };
160
+ }
161
+
162
+ /**
163
+ * Attach `.defer()` to a handle push function. The deferred slot is reserved by
164
+ * pushing the deferred promise through the same push (so ordering, sealing, and
165
+ * Flight streaming all reuse the existing path); the returned resolver settles it.
166
+ */
167
+ export function withDefer<TData>(push: HandlePushFn<TData>): HandlePush<TData> {
168
+ const handlePush = push as HandlePush<TData>;
169
+ // Safe to mutate push in place: each ctx.use(Handle) call (request-context.ts,
170
+ // loader-resolution.ts) builds a fresh closure, so .defer never leaks across
171
+ // handles or requests.
172
+ handlePush.defer = (options) => {
173
+ const deferred = createDeferred<TData>({
174
+ timeoutMs: options?.timeoutMs,
175
+ fallback: options?.else,
176
+ });
177
+ // Reserve the slot now by pushing the pending promise (the renderer use()s it).
178
+ push(deferred.promise as Promise<TData>);
179
+ // The resolver is push-equal: a thunk is invoked immediately (as push does)
180
+ // and a Promise is adopted by the reserved slot. Calling it settles the slot
181
+ // and cancels the timeout — the timeout only fires if it is never called.
182
+ const resolveSlot = deferred.resolve as (
183
+ value: TData | Promise<TData>,
184
+ ) => void;
185
+ return (data) => {
186
+ // The thunk runs without re-entering the push-callback scope a direct push
187
+ // thunk gets on the action/revalidation path (loader-resolution.ts): a
188
+ // deferred resolver fires from a deep component after the handler phase has
189
+ // closed, so there is no live deadlock-guard window to exempt.
190
+ resolveSlot(
191
+ typeof data === "function" ? (data as () => Promise<TData>)() : data,
192
+ );
193
+ };
194
+ };
195
+ return handlePush;
196
+ }
package/src/deps/ssr.ts CHANGED
@@ -1,2 +1 @@
1
- // Re-export @vitejs/plugin-rsc/ssr for internal use by virtual entries
2
1
  export { createFromReadableStream } from "@vitejs/plugin-rsc/ssr";
package/src/handle.ts CHANGED
@@ -46,15 +46,10 @@ function defaultCollect<T>(segments: T[][]): T[] {
46
46
  // Used by useHandle() to recover collect when handle is deserialized from RSC prop.
47
47
  const collectRegistry = new Map<string, (segments: unknown[][]) => unknown>();
48
48
 
49
- // Monotonic counter for runtime fallback ids (see createHandle). Module-scoped
50
- // and deterministic, so each createHandle() call gets a stable, unique id within
51
- // the process. Only used when no build id was injected (a bare unit test).
49
+ // Monotonic counter for runtime fallback ids (see createHandle). Only used
50
+ // when no build id was injected (a bare unit test).
52
51
  let runtimeHandleIdCounter = 0;
53
52
 
54
- /**
55
- * Look up a collect function from the registry by handle $$id.
56
- * Returns undefined if not registered (falls back to defaultCollect in useHandle).
57
- */
58
53
  export function getCollectFn(
59
54
  id: string,
60
55
  ): ((segments: unknown[][]) => unknown) | undefined {
@@ -127,8 +122,6 @@ export function createHandle<TData, TAccumulated = TData[]>(
127
122
  collect ??
128
123
  (defaultCollect as unknown as (segments: TData[][]) => TAccumulated);
129
124
 
130
- // Register collect in module-level registry so useHandle() can recover it
131
- // when the handle is deserialized from RSC props (toJSON strips collect).
132
125
  collectRegistry.set(
133
126
  handleId,
134
127
  collectFn as (segments: unknown[][]) => unknown,
@@ -140,9 +133,6 @@ export function createHandle<TData, TAccumulated = TData[]>(
140
133
  };
141
134
  }
142
135
 
143
- /**
144
- * Type guard to check if a value is a Handle.
145
- */
146
136
  export function isHandle(value: unknown): value is Handle<unknown, unknown> {
147
137
  return (
148
138
  typeof value === "object" &&
@@ -97,24 +97,18 @@ function isPromise(value: unknown): value is Promise<unknown> {
97
97
  return value !== null && typeof value === "object" && "then" in value;
98
98
  }
99
99
 
100
- /**
101
- * Render a single meta descriptor as a React element.
102
- */
103
100
  function renderMetaDescriptor(
104
101
  descriptor: MetaDescriptorBase,
105
102
  index: number,
106
103
  ): React.ReactNode {
107
- // charset
108
104
  if (hasCharSet(descriptor)) {
109
105
  return <meta key="charSet" charSet={descriptor.charSet} />;
110
106
  }
111
107
 
112
- // title
113
108
  if (hasTitle(descriptor)) {
114
109
  return <title key="title">{descriptor.title}</title>;
115
110
  }
116
111
 
117
- // name + content (description, viewport, etc.)
118
112
  if (hasNameContent(descriptor)) {
119
113
  return (
120
114
  <meta
@@ -125,7 +119,6 @@ function renderMetaDescriptor(
125
119
  );
126
120
  }
127
121
 
128
- // property + content (Open Graph, etc.)
129
122
  if (hasPropertyContent(descriptor)) {
130
123
  return (
131
124
  <meta
@@ -136,7 +129,6 @@ function renderMetaDescriptor(
136
129
  );
137
130
  }
138
131
 
139
- // http-equiv + content
140
132
  if (hasHttpEquivContent(descriptor)) {
141
133
  return (
142
134
  <meta
@@ -147,7 +139,6 @@ function renderMetaDescriptor(
147
139
  );
148
140
  }
149
141
 
150
- // JSON-LD structured data
151
142
  if (hasScriptLdJson(descriptor)) {
152
143
  const json = JSON.stringify(descriptor["script:ld+json"]);
153
144
  return (
@@ -159,7 +150,6 @@ function renderMetaDescriptor(
159
150
  );
160
151
  }
161
152
 
162
- // Custom tagName (meta or link with arbitrary attributes)
163
153
  if (hasTagName(descriptor)) {
164
154
  const { tagName, ...rest } = descriptor;
165
155
  if (tagName === "link") {
@@ -180,7 +170,6 @@ function renderMetaDescriptor(
180
170
  }
181
171
  }
182
172
 
183
- // Fallback: treat as meta attributes
184
173
  return (
185
174
  <meta
186
175
  key={`meta-fallback-${index}`}
@@ -189,9 +178,6 @@ function renderMetaDescriptor(
189
178
  );
190
179
  }
191
180
 
192
- /**
193
- * Wrapper component to resolve a Promise<MetaDescriptorBase> using use().
194
- */
195
181
  function AsyncMetaTag({
196
182
  promise,
197
183
  index,
@@ -39,18 +39,29 @@ export interface BreadcrumbItem {
39
39
  /**
40
40
  * Collect function for Breadcrumbs handle.
41
41
  * Flattens segments in parent-to-child order with deduplication by href
42
- * (last item for each href wins).
42
+ * (last item for each href wins). Deferred slots (`ctx.use(Breadcrumbs).defer()`)
43
+ * arrive as pending Promise entries with no href yet; they are passed through by
44
+ * identity and excluded from the href dedup so concurrent deferred crumbs do not
45
+ * all collapse under a single `undefined` href.
43
46
  */
44
47
  function collectBreadcrumbs(segments: BreadcrumbItem[][]): BreadcrumbItem[] {
45
48
  const all = segments.flat();
46
- const seen = new Map<string, number>();
47
49
 
50
+ const isResolvedItem = (item: unknown): item is BreadcrumbItem =>
51
+ item != null &&
52
+ typeof item === "object" &&
53
+ typeof (item as { then?: unknown }).then !== "function" &&
54
+ typeof (item as { href?: unknown }).href === "string";
55
+
56
+ const seen = new Map<string, number>();
48
57
  for (let i = 0; i < all.length; i++) {
49
- seen.set(all[i].href, i);
58
+ if (isResolvedItem(all[i])) seen.set(all[i].href, i);
50
59
  }
51
60
 
52
- // Return items in order, keeping only the last occurrence per href
53
- return all.filter((item, index) => seen.get(item.href) === index);
61
+ // Deferred items bypass dedup (excluded via !isResolvedItem check).
62
+ return all.filter(
63
+ (item, index) => !isResolvedItem(item) || seen.get(item.href) === index,
64
+ );
54
65
  }
55
66
 
56
67
  /**
@@ -35,9 +35,6 @@ import type {
35
35
  UnsetDescriptor,
36
36
  } from "../router/types.js";
37
37
 
38
- /**
39
- * Type guard for unset descriptor
40
- */
41
38
  function isUnsetDescriptor(
42
39
  descriptor: MetaDescriptor,
43
40
  ): descriptor is UnsetDescriptor {
@@ -49,9 +46,6 @@ function isUnsetDescriptor(
49
46
  );
50
47
  }
51
48
 
52
- /**
53
- * Type guard for title descriptor (any form)
54
- */
55
49
  function isTitleDescriptor(
56
50
  descriptor: MetaDescriptor,
57
51
  ): descriptor is { title: TitleDescriptor } {
@@ -62,9 +56,6 @@ function isTitleDescriptor(
62
56
  );
63
57
  }
64
58
 
65
- /**
66
- * Type guard for title template descriptor
67
- */
68
59
  function isTitleTemplate(
69
60
  title: TitleDescriptor,
70
61
  ): title is { template: string; default: string } {
@@ -76,21 +67,13 @@ function isTitleTemplate(
76
67
  );
77
68
  }
78
69
 
79
- /**
80
- * Type guard for absolute title descriptor
81
- */
82
70
  function isAbsoluteTitle(
83
71
  title: TitleDescriptor,
84
72
  ): title is { absolute: string } {
85
73
  return typeof title === "object" && title !== null && "absolute" in title;
86
74
  }
87
75
 
88
- /**
89
- * Get a unique key for a meta descriptor for deduplication.
90
- * Returns undefined for descriptors that shouldn't be deduplicated.
91
- */
92
76
  function getMetaKey(descriptor: MetaDescriptor): string | undefined {
93
- // Skip unset descriptors - they are processed separately
94
77
  if (isUnsetDescriptor(descriptor)) {
95
78
  return undefined;
96
79
  }
@@ -110,13 +93,10 @@ function getMetaKey(descriptor: MetaDescriptor): string | undefined {
110
93
  return `httpEquiv:${descriptor.httpEquiv}`;
111
94
  }
112
95
  if ("script:ld+json" in descriptor) {
113
- // JSON-LD scripts can have multiple, don't dedupe by default
114
96
  return undefined;
115
97
  }
116
98
  if ("tagName" in descriptor) {
117
- // For link tags, dedupe by rel if present
118
99
  if (descriptor.tagName === "link" && "rel" in descriptor) {
119
- // Some link rels should be unique (canonical), others not (stylesheet)
120
100
  const uniqueRels = ["canonical", "icon", "apple-touch-icon"];
121
101
  if (uniqueRels.includes(descriptor.rel as string)) {
122
102
  return `link:${descriptor.rel}`;
@@ -136,9 +116,6 @@ const defaultMetaDescriptors: MetaDescriptor[] = [
136
116
  { name: "viewport", content: "width=device-width, initial-scale=1" },
137
117
  ];
138
118
 
139
- /**
140
- * Helper to add or replace a descriptor in the result array
141
- */
142
119
  function addOrReplace(
143
120
  result: MetaDescriptor[],
144
121
  keyToIndex: Map<string, number>,
@@ -155,9 +132,6 @@ function addOrReplace(
155
132
  }
156
133
  }
157
134
 
158
- /**
159
- * Helper to update indices after removing an element
160
- */
161
135
  function updateIndicesAfterRemoval(
162
136
  keyToIndex: Map<string, number>,
163
137
  removedIndex: number,
@@ -169,17 +143,11 @@ function updateIndicesAfterRemoval(
169
143
  }
170
144
  }
171
145
 
172
- /**
173
- * Collect function for Meta handle.
174
- * Includes default meta descriptors, then deduplicates by key with later routes overriding earlier ones.
175
- * Supports title templates, absolute titles, and unset descriptors.
176
- */
177
146
  function collectMeta(segments: MetaDescriptor[][]): MetaDescriptor[] {
178
147
  const result: MetaDescriptor[] = [];
179
148
  const keyToIndex = new Map<string, number>();
180
149
  let titleTemplate: string | undefined;
181
150
 
182
- // Add defaults first so they can be overridden
183
151
  for (const descriptor of defaultMetaDescriptors) {
184
152
  const key = getMetaKey(descriptor);
185
153
  if (key !== undefined) {
@@ -190,7 +158,6 @@ function collectMeta(segments: MetaDescriptor[][]): MetaDescriptor[] {
190
158
 
191
159
  for (const descriptors of segments) {
192
160
  for (const descriptor of descriptors) {
193
- // Handle unset descriptors
194
161
  if (isUnsetDescriptor(descriptor)) {
195
162
  const keyToRemove = descriptor.unset;
196
163
  if (keyToIndex.has(keyToRemove)) {
@@ -202,14 +169,11 @@ function collectMeta(segments: MetaDescriptor[][]): MetaDescriptor[] {
202
169
  continue;
203
170
  }
204
171
 
205
- // Handle title descriptors with template/absolute support
206
172
  if (isTitleDescriptor(descriptor)) {
207
173
  const titleValue = descriptor.title;
208
174
 
209
175
  if (isTitleTemplate(titleValue)) {
210
- // Store template for subsequent title descriptors in child segments
211
176
  titleTemplate = titleValue.template;
212
- // Set the default title
213
177
  addOrReplace(
214
178
  result,
215
179
  keyToIndex,
@@ -220,7 +184,6 @@ function collectMeta(segments: MetaDescriptor[][]): MetaDescriptor[] {
220
184
  }
221
185
 
222
186
  if (isAbsoluteTitle(titleValue)) {
223
- // Absolute title bypasses any template
224
187
  addOrReplace(
225
188
  result,
226
189
  keyToIndex,
@@ -230,7 +193,6 @@ function collectMeta(segments: MetaDescriptor[][]): MetaDescriptor[] {
230
193
  continue;
231
194
  }
232
195
 
233
- // String title - apply template if one exists
234
196
  const finalTitle = titleTemplate
235
197
  ? titleTemplate.replace("%s", titleValue as string)
236
198
  : titleValue;
@@ -243,7 +205,6 @@ function collectMeta(segments: MetaDescriptor[][]): MetaDescriptor[] {
243
205
  continue;
244
206
  }
245
207
 
246
- // Handle all other descriptors
247
208
  const key = getMetaKey(descriptor);
248
209
  addOrReplace(result, keyToIndex, descriptor, key);
249
210
  }
@@ -1,9 +1,3 @@
1
- /**
2
- * Cookie Override Handler
3
- *
4
- * Manages cookie-based host override for development environments.
5
- */
6
-
7
1
  import type { HostOverrideConfig } from "./types.js";
8
2
  import type { RouterRequestInput } from "../router/router-interfaces.js";
9
3
  import { matchPattern, parseRequest } from "./pattern-matcher.js";
@@ -13,9 +7,6 @@ import {
13
7
  HostValidationError,
14
8
  } from "./errors.js";
15
9
 
16
- /**
17
- * Parse cookies from request
18
- */
19
10
  export function parseCookies(request: Request): Record<string, string> {
20
11
  const cookieHeader = request.headers.get("cookie");
21
12
  if (!cookieHeader) {
@@ -40,24 +31,15 @@ export function parseCookies(request: Request): Record<string, string> {
40
31
  return cookies;
41
32
  }
42
33
 
43
- /**
44
- * Get cookie value from request
45
- */
46
34
  export function getCookie(request: Request, name: string): string | undefined {
47
35
  const cookies = parseCookies(request);
48
36
  return cookies[name];
49
37
  }
50
38
 
51
- /**
52
- * Create Set-Cookie header to delete a cookie
53
- */
54
39
  export function createDeleteCookieHeader(name: string): string {
55
40
  return `${name}=; Max-Age=0; Path=/; Secure; HttpOnly`;
56
41
  }
57
42
 
58
- /**
59
- * Create error response with cookie deletion
60
- */
61
43
  export function createCookieErrorResponse(
62
44
  cookieName: string,
63
45
  message: string,
@@ -77,9 +59,6 @@ export function createCookieErrorResponse(
77
59
  );
78
60
  }
79
61
 
80
- /**
81
- * Check if current host is allowed to use override
82
- */
83
62
  export function isHostAllowed(
84
63
  request: Request,
85
64
  allowedHosts: string[],
@@ -95,12 +74,6 @@ export function isHostAllowed(
95
74
  return false;
96
75
  }
97
76
 
98
- /**
99
- * Handle cookie override logic
100
- *
101
- * Returns overridden hostname if valid, original hostname if no override.
102
- * Throws errors for invalid overrides.
103
- */
104
77
  export function handleCookieOverride(
105
78
  request: Request,
106
79
  config: HostOverrideConfig | undefined,
@@ -115,46 +88,37 @@ export function handleCookieOverride(
115
88
  const cookieValue = getCookie(request, cookieName);
116
89
  const { hostname: originalHostname } = parseRequest(request);
117
90
 
118
- // No cookie - return original hostname
119
91
  if (!cookieValue) {
120
92
  return originalHostname;
121
93
  }
122
94
 
123
- // Check if current host is allowed
124
95
  const allowed = isHostAllowed(request, allowedHosts);
125
96
 
126
- // If not allowed, throw error
127
97
  if (!allowed) {
128
98
  throw new HostOverrideNotAllowedError(originalHostname, cookieName, {
129
99
  cause: { cookieValue, currentHost: originalHostname },
130
100
  });
131
101
  }
132
102
 
133
- // If allowed and has custom validation, run it
134
103
  if (validate) {
135
104
  try {
136
105
  const validatedHostname = validate(request, cookieValue, input);
137
106
  return validatedHostname;
138
107
  } catch (error) {
139
- // Wrap in HostValidationError
140
108
  const message = error instanceof Error ? error.message : String(error);
141
109
  throw new HostValidationError(message, error);
142
110
  }
143
111
  }
144
112
 
145
- // Default validation - verify it's a valid hostname using URL constructor
146
113
  try {
147
- // Try to construct a URL with the hostname to validate it
148
114
  const testUrl = new URL(`https://${cookieValue}`);
149
115
 
150
- // Ensure the hostname matches what we provided (URL constructor normalizes it)
151
116
  if (testUrl.hostname !== cookieValue) {
152
117
  throw new InvalidHostnameError(cookieValue, {
153
118
  cause: { original: cookieValue, normalized: testUrl.hostname },
154
119
  });
155
120
  }
156
121
  } catch (error) {
157
- // If URL constructor failed, throw InvalidHostnameError with cause
158
122
  if (error instanceof InvalidHostnameError) {
159
123
  throw error;
160
124
  }
@@ -4,16 +4,10 @@
4
4
  * All host router errors extend HostRouterError for easy instance checking.
5
5
  */
6
6
 
7
- /**
8
- * Error options with cause
9
- */
10
7
  interface ErrorOptions {
11
8
  cause?: unknown;
12
9
  }
13
10
 
14
- /**
15
- * Base error class for all host router errors
16
- */
17
11
  export class HostRouterError extends Error {
18
12
  cause?: unknown;
19
13
 
@@ -27,9 +21,6 @@ export class HostRouterError extends Error {
27
21
  }
28
22
  }
29
23
 
30
- /**
31
- * Error thrown when pattern validation fails
32
- */
33
24
  export class InvalidPatternError extends HostRouterError {
34
25
  constructor(pattern: string, reason: string, options?: ErrorOptions) {
35
26
  super(`Invalid pattern "${pattern}": ${reason}`, options);
@@ -38,9 +29,6 @@ export class InvalidPatternError extends HostRouterError {
38
29
  }
39
30
  }
40
31
 
41
- /**
42
- * Error thrown when cookie override is not allowed
43
- */
44
32
  export class HostOverrideNotAllowedError extends HostRouterError {
45
33
  constructor(currentHost: string, cookieName: string, options?: ErrorOptions) {
46
34
  super(
@@ -52,9 +40,6 @@ export class HostOverrideNotAllowedError extends HostRouterError {
52
40
  }
53
41
  }
54
42
 
55
- /**
56
- * Error thrown when cookie hostname is invalid
57
- */
58
43
  export class InvalidHostnameError extends HostRouterError {
59
44
  constructor(hostname: string, options?: ErrorOptions) {
60
45
  super(`Invalid hostname format: "${hostname}"`, options);
@@ -63,9 +48,6 @@ export class InvalidHostnameError extends HostRouterError {
63
48
  }
64
49
  }
65
50
 
66
- /**
67
- * Error thrown when custom validation fails
68
- */
69
51
  export class HostValidationError extends HostRouterError {
70
52
  constructor(message: string, cause?: unknown) {
71
53
  super(message, { cause });
@@ -74,9 +56,6 @@ export class HostValidationError extends HostRouterError {
74
56
  }
75
57
  }
76
58
 
77
- /**
78
- * Error thrown when no route matches
79
- */
80
59
  export class NoRouteMatchError extends HostRouterError {
81
60
  constructor(hostname: string, pathname: string, options?: ErrorOptions) {
82
61
  super(`No route matched for ${hostname}${pathname}`, options);
@@ -85,9 +64,6 @@ export class NoRouteMatchError extends HostRouterError {
85
64
  }
86
65
  }
87
66
 
88
- /**
89
- * Error thrown when handler type is invalid
90
- */
91
67
  export class InvalidHandlerError extends HostRouterError {
92
68
  constructor(handler: unknown, options?: ErrorOptions) {
93
69
  super(`Invalid handler type: ${typeof handler}`, options);
package/src/host/index.ts CHANGED
@@ -20,6 +20,12 @@
20
20
  * }
21
21
  * };
22
22
  * ```
23
+ *
24
+ * The host surface (`Handler`, `Middleware`, `match`, `HostOverrideConfig.validate`)
25
+ * types `input` as `RouterRequestInput<any>` by design: a host router fans out to
26
+ * heterogeneous sub-apps with differing env/vars shapes, so there is no single
27
+ * `TEnv`/`TVars` to thread through. `input.env`/`input.vars` are therefore `any`
28
+ * here; the typed env shape lives on each sub-app's `createRouter<TEnv>()`.
23
29
  */
24
30
 
25
31
  // Core router