@rangojs/router 0.0.0-experimental.d7eeaa75 → 0.0.0-experimental.d98a8e9d

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 (278) hide show
  1. package/README.md +120 -25
  2. package/dist/bin/rango.js +147 -57
  3. package/dist/testing/vitest.js +82 -0
  4. package/dist/vite/index.js +2154 -861
  5. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  6. package/package.json +57 -11
  7. package/skills/api-client/SKILL.md +211 -0
  8. package/skills/breadcrumbs/SKILL.md +3 -1
  9. package/skills/bundle-analysis/SKILL.md +159 -0
  10. package/skills/cache-guide/SKILL.md +220 -30
  11. package/skills/caching/SKILL.md +116 -8
  12. package/skills/composability/SKILL.md +27 -2
  13. package/skills/document-cache/SKILL.md +78 -55
  14. package/skills/handler-use/SKILL.md +364 -0
  15. package/skills/hooks/SKILL.md +229 -20
  16. package/skills/host-router/SKILL.md +45 -20
  17. package/skills/i18n/SKILL.md +276 -0
  18. package/skills/intercept/SKILL.md +46 -4
  19. package/skills/layout/SKILL.md +28 -7
  20. package/skills/links/SKILL.md +247 -17
  21. package/skills/loader/SKILL.md +219 -9
  22. package/skills/middleware/SKILL.md +47 -12
  23. package/skills/migrate-nextjs/SKILL.md +562 -0
  24. package/skills/migrate-react-router/SKILL.md +769 -0
  25. package/skills/mime-routes/SKILL.md +27 -0
  26. package/skills/observability/SKILL.md +137 -0
  27. package/skills/parallel/SKILL.md +71 -6
  28. package/skills/prerender/SKILL.md +14 -33
  29. package/skills/rango/SKILL.md +243 -22
  30. package/skills/react-compiler/SKILL.md +168 -0
  31. package/skills/response-routes/SKILL.md +122 -47
  32. package/skills/route/SKILL.md +57 -4
  33. package/skills/router-setup/SKILL.md +3 -3
  34. package/skills/server-actions/SKILL.md +751 -0
  35. package/skills/streams-and-websockets/SKILL.md +283 -0
  36. package/skills/testing/SKILL.md +128 -0
  37. package/skills/testing/bindings.md +89 -0
  38. package/skills/testing/cache-prerender.md +98 -0
  39. package/skills/testing/client-components.md +121 -0
  40. package/skills/testing/e2e-parity.md +124 -0
  41. package/skills/testing/flight.md +89 -0
  42. package/skills/testing/handles.md +127 -0
  43. package/skills/testing/loader.md +108 -0
  44. package/skills/testing/middleware.md +97 -0
  45. package/skills/testing/render-handler.md +102 -0
  46. package/skills/testing/response-routes.md +94 -0
  47. package/skills/testing/reverse-and-types.md +83 -0
  48. package/skills/testing/server-actions.md +89 -0
  49. package/skills/testing/server-tree.md +128 -0
  50. package/skills/testing/setup.md +120 -0
  51. package/skills/typesafety/SKILL.md +319 -27
  52. package/skills/use-cache/SKILL.md +34 -5
  53. package/skills/view-transitions/SKILL.md +294 -0
  54. package/src/__augment-tests__/augment.ts +81 -0
  55. package/src/__augment-tests__/augmented.check.ts +116 -0
  56. package/src/browser/action-coordinator.ts +53 -36
  57. package/src/browser/app-shell.ts +52 -0
  58. package/src/browser/event-controller.ts +86 -70
  59. package/src/browser/history-state.ts +21 -0
  60. package/src/browser/index.ts +3 -3
  61. package/src/browser/navigation-bridge.ts +84 -11
  62. package/src/browser/navigation-client.ts +104 -68
  63. package/src/browser/navigation-store.ts +32 -9
  64. package/src/browser/navigation-transaction.ts +10 -28
  65. package/src/browser/partial-update.ts +64 -26
  66. package/src/browser/prefetch/cache.ts +183 -44
  67. package/src/browser/prefetch/fetch.ts +228 -37
  68. package/src/browser/prefetch/queue.ts +36 -5
  69. package/src/browser/rango-state.ts +53 -13
  70. package/src/browser/react/Link.tsx +30 -2
  71. package/src/browser/react/NavigationProvider.tsx +72 -31
  72. package/src/browser/react/filter-segment-order.ts +51 -7
  73. package/src/browser/react/index.ts +3 -0
  74. package/src/browser/react/location-state-shared.ts +175 -4
  75. package/src/browser/react/location-state.ts +39 -13
  76. package/src/browser/react/use-handle.ts +17 -9
  77. package/src/browser/react/use-navigation.ts +22 -2
  78. package/src/browser/react/use-params.ts +20 -8
  79. package/src/browser/react/use-reverse.ts +106 -0
  80. package/src/browser/react/use-router.ts +22 -2
  81. package/src/browser/react/use-segments.ts +11 -8
  82. package/src/browser/response-adapter.ts +32 -1
  83. package/src/browser/rsc-router.tsx +69 -22
  84. package/src/browser/scroll-restoration.ts +22 -14
  85. package/src/browser/segment-reconciler.ts +36 -14
  86. package/src/browser/segment-structure-assert.ts +2 -2
  87. package/src/browser/server-action-bridge.ts +23 -30
  88. package/src/browser/types.ts +21 -0
  89. package/src/build/collect-fallback-refs.ts +107 -0
  90. package/src/build/generate-manifest.ts +60 -35
  91. package/src/build/generate-route-types.ts +2 -0
  92. package/src/build/index.ts +8 -1
  93. package/src/build/prefix-tree-utils.ts +123 -0
  94. package/src/build/route-trie.ts +95 -25
  95. package/src/build/route-types/codegen.ts +4 -4
  96. package/src/build/route-types/include-resolution.ts +1 -1
  97. package/src/build/route-types/per-module-writer.ts +7 -4
  98. package/src/build/route-types/router-processing.ts +55 -14
  99. package/src/build/route-types/scan-filter.ts +1 -1
  100. package/src/build/route-types/source-scan.ts +118 -0
  101. package/src/build/runtime-discovery.ts +9 -20
  102. package/src/cache/cache-scope.ts +28 -42
  103. package/src/cache/cf/cf-cache-store.ts +54 -13
  104. package/src/client.rsc.tsx +3 -0
  105. package/src/client.tsx +96 -205
  106. package/src/context-var.ts +5 -5
  107. package/src/decode-loader-results.ts +36 -0
  108. package/src/errors.ts +30 -4
  109. package/src/handle.ts +32 -14
  110. package/src/host/index.ts +2 -2
  111. package/src/host/router.ts +129 -57
  112. package/src/host/types.ts +31 -2
  113. package/src/host/utils.ts +1 -1
  114. package/src/href-client.ts +140 -21
  115. package/src/index.rsc.ts +10 -6
  116. package/src/index.ts +54 -17
  117. package/src/loader-store.ts +500 -0
  118. package/src/loader.rsc.ts +25 -7
  119. package/src/loader.ts +16 -9
  120. package/src/missing-id-error.ts +68 -0
  121. package/src/outlet-context.ts +1 -1
  122. package/src/prerender.ts +27 -6
  123. package/src/response-utils.ts +37 -0
  124. package/src/reverse.ts +65 -36
  125. package/src/route-content-wrapper.tsx +6 -28
  126. package/src/route-definition/dsl-helpers.ts +384 -257
  127. package/src/route-definition/helper-factories.ts +29 -139
  128. package/src/route-definition/helpers-types.ts +100 -28
  129. package/src/route-definition/resolve-handler-use.ts +6 -0
  130. package/src/route-definition/use-item-types.ts +32 -0
  131. package/src/route-types.ts +26 -41
  132. package/src/router/basename.ts +14 -0
  133. package/src/router/content-negotiation.ts +15 -2
  134. package/src/router/error-handling.ts +1 -1
  135. package/src/router/find-match.ts +54 -6
  136. package/src/router/handler-context.ts +21 -38
  137. package/src/router/intercept-resolution.ts +4 -18
  138. package/src/router/lazy-includes.ts +41 -22
  139. package/src/router/loader-resolution.ts +82 -36
  140. package/src/router/manifest.ts +41 -19
  141. package/src/router/match-api.ts +4 -3
  142. package/src/router/match-handlers.ts +63 -20
  143. package/src/router/match-middleware/cache-lookup.ts +44 -91
  144. package/src/router/match-middleware/cache-store.ts +3 -2
  145. package/src/router/match-result.ts +53 -32
  146. package/src/router/metrics.ts +1 -1
  147. package/src/router/middleware-types.ts +15 -26
  148. package/src/router/middleware.ts +99 -84
  149. package/src/router/pattern-matching.ts +116 -19
  150. package/src/router/prerender-match.ts +1 -1
  151. package/src/router/preview-match.ts +3 -1
  152. package/src/router/request-classification.ts +4 -28
  153. package/src/router/revalidation.ts +58 -2
  154. package/src/router/router-interfaces.ts +45 -28
  155. package/src/router/router-options.ts +40 -1
  156. package/src/router/router-registry.ts +2 -5
  157. package/src/router/segment-resolution/fresh.ts +27 -6
  158. package/src/router/segment-resolution/revalidation.ts +147 -106
  159. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  160. package/src/router/substitute-pattern-params.ts +56 -0
  161. package/src/router/telemetry.ts +99 -0
  162. package/src/router/trie-matching.ts +40 -16
  163. package/src/router/types.ts +8 -0
  164. package/src/router/url-params.ts +49 -0
  165. package/src/router.ts +52 -30
  166. package/src/rsc/handler-context.ts +2 -2
  167. package/src/rsc/handler.ts +28 -69
  168. package/src/rsc/helpers.ts +91 -43
  169. package/src/rsc/index.ts +1 -1
  170. package/src/rsc/manifest-init.ts +28 -41
  171. package/src/rsc/origin-guard.ts +28 -10
  172. package/src/rsc/progressive-enhancement.ts +4 -0
  173. package/src/rsc/response-error.ts +79 -12
  174. package/src/rsc/response-route-handler.ts +57 -61
  175. package/src/rsc/rsc-rendering.ts +35 -51
  176. package/src/rsc/runtime-warnings.ts +9 -10
  177. package/src/rsc/server-action.ts +17 -37
  178. package/src/rsc/ssr-setup.ts +16 -0
  179. package/src/rsc/types.ts +8 -2
  180. package/src/runtime-env.ts +18 -0
  181. package/src/search-params.ts +4 -4
  182. package/src/segment-content-promise.ts +67 -0
  183. package/src/segment-loader-promise.ts +122 -0
  184. package/src/segment-system.tsx +132 -116
  185. package/src/serialize.ts +243 -0
  186. package/src/server/context.ts +175 -53
  187. package/src/server/cookie-store.ts +28 -4
  188. package/src/server/request-context.ts +67 -51
  189. package/src/ssr/index.tsx +5 -1
  190. package/src/static-handler.ts +25 -3
  191. package/src/testing/cache-status.ts +166 -0
  192. package/src/testing/collect-handle.ts +63 -0
  193. package/src/testing/dispatch.ts +581 -0
  194. package/src/testing/dom.entry.ts +22 -0
  195. package/src/testing/e2e/fixture.ts +188 -0
  196. package/src/testing/e2e/index.ts +149 -0
  197. package/src/testing/e2e/matchers.ts +51 -0
  198. package/src/testing/e2e/page-helpers.ts +272 -0
  199. package/src/testing/e2e/parity.ts +326 -0
  200. package/src/testing/e2e/server.ts +195 -0
  201. package/src/testing/flight-matchers.ts +110 -0
  202. package/src/testing/flight-normalize.ts +38 -0
  203. package/src/testing/flight-runtime.d.ts +57 -0
  204. package/src/testing/flight-tree.ts +682 -0
  205. package/src/testing/flight.entry.ts +51 -0
  206. package/src/testing/flight.ts +234 -0
  207. package/src/testing/generated-routes.ts +223 -0
  208. package/src/testing/index.ts +106 -0
  209. package/src/testing/internal/context.ts +304 -0
  210. package/src/testing/internal/flight-client-globals.ts +30 -0
  211. package/src/testing/internal/seed-vars.ts +42 -0
  212. package/src/testing/render-handler.ts +323 -0
  213. package/src/testing/render-route.tsx +590 -0
  214. package/src/testing/run-loader.ts +363 -0
  215. package/src/testing/run-middleware.ts +205 -0
  216. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  217. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  218. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  219. package/src/testing/vitest-stubs/version.ts +5 -0
  220. package/src/testing/vitest.ts +285 -0
  221. package/src/types/global-namespace.ts +39 -26
  222. package/src/types/handler-context.ts +68 -50
  223. package/src/types/index.ts +1 -0
  224. package/src/types/loader-types.ts +11 -9
  225. package/src/types/request-scope.ts +126 -0
  226. package/src/types/route-entry.ts +11 -0
  227. package/src/types/segments.ts +35 -2
  228. package/src/urls/include-helper.ts +34 -67
  229. package/src/urls/index.ts +1 -5
  230. package/src/urls/path-helper-types.ts +41 -7
  231. package/src/urls/path-helper.ts +17 -52
  232. package/src/urls/pattern-types.ts +36 -19
  233. package/src/urls/response-types.ts +22 -29
  234. package/src/urls/type-extraction.ts +58 -139
  235. package/src/urls/urls-function.ts +1 -5
  236. package/src/use-loader.tsx +413 -42
  237. package/src/vite/debug.ts +185 -0
  238. package/src/vite/discovery/bundle-postprocess.ts +6 -6
  239. package/src/vite/discovery/discover-routers.ts +106 -75
  240. package/src/vite/discovery/discovery-errors.ts +194 -0
  241. package/src/vite/discovery/gate-state.ts +171 -0
  242. package/src/vite/discovery/prerender-collection.ts +67 -26
  243. package/src/vite/discovery/route-types-writer.ts +40 -84
  244. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  245. package/src/vite/discovery/state.ts +33 -0
  246. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  247. package/src/vite/index.ts +2 -0
  248. package/src/vite/plugin-types.ts +67 -0
  249. package/src/vite/plugins/cjs-to-esm.ts +8 -7
  250. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  251. package/src/vite/plugins/client-ref-hashing.ts +28 -5
  252. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  253. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  254. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  255. package/src/vite/plugins/expose-action-id.ts +54 -30
  256. package/src/vite/plugins/expose-id-utils.ts +12 -8
  257. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  258. package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
  259. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  260. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  261. package/src/vite/plugins/expose-internal-ids.ts +496 -486
  262. package/src/vite/plugins/performance-tracks.ts +29 -25
  263. package/src/vite/plugins/use-cache-transform.ts +65 -50
  264. package/src/vite/plugins/version-injector.ts +39 -23
  265. package/src/vite/plugins/version-plugin.ts +59 -2
  266. package/src/vite/plugins/virtual-entries.ts +2 -2
  267. package/src/vite/rango.ts +116 -29
  268. package/src/vite/router-discovery.ts +750 -100
  269. package/src/vite/utils/ast-handler-extract.ts +15 -15
  270. package/src/vite/utils/banner.ts +1 -1
  271. package/src/vite/utils/bundle-analysis.ts +4 -2
  272. package/src/vite/utils/client-chunks.ts +190 -0
  273. package/src/vite/utils/forward-user-plugins.ts +193 -0
  274. package/src/vite/utils/manifest-utils.ts +8 -59
  275. package/src/vite/utils/package-resolution.ts +41 -1
  276. package/src/vite/utils/prerender-utils.ts +21 -6
  277. package/src/vite/utils/shared-utils.ts +107 -26
  278. package/src/browser/action-response-classifier.ts +0 -99
@@ -0,0 +1,581 @@
1
+ /**
2
+ * dispatch — in-process request -> Response for unit/integration tests,
3
+ * WITHOUT the Flight RSC runtime.
4
+ *
5
+ * dispatch runs the router's real matching and middleware execution so that
6
+ * redirects, 404s, response routes (path.json / path.text / path.html / ...),
7
+ * and middleware short-circuits behave exactly as in production. It deliberately
8
+ * does NOT render React Server Components: there is no Flight stream, no SSR,
9
+ * and no DOM. Hit an RSC (component) route and dispatch throws a clear error
10
+ * directing you to renderToFlightString/renderServerTree/renderHandler or an e2e test.
11
+ *
12
+ * What dispatch DOES support:
13
+ * - Trailing-slash and other findMatch() redirects -> 308 with Location
14
+ * - Unmatched paths -> 404 Response
15
+ * Both the 308 and the 404 are produced INSIDE the global middleware chain
16
+ * (mirroring production, where coreHandler runs wrapped by executeMiddleware),
17
+ * so a global auth middleware can 401/redirect them and middleware-set
18
+ * cookies/headers merge onto the 308/404 the way createResponseWithMergedHeaders
19
+ * merges them in production.
20
+ * - Response routes (non-RSC) -> serialized Response
21
+ * - json: JSON.stringify(result) (bare value) with application/json
22
+ * - text/html/xml/md: String(result) with the mapped MIME type
23
+ * - handler returning a Response: re-wrapped like
24
+ * handleResponseRoute (stub headers/cookies merged, Set-Cookie preserved,
25
+ * WebSocket upgrade passed through without reconstruction)
26
+ * - handler throwing an error: typed 500 / RouterError
27
+ * status, matching handleResponseRoute (RFC 9457 problem+json body with
28
+ * application/problem+json for json routes, text/plain message otherwise)
29
+ * - content-negotiated route: Vary: Accept appended
30
+ * - Global middleware (router.use(...)) AND route-level middleware, with full
31
+ * next()/short-circuit/throw-Response/header+cookie-merge fidelity.
32
+ * - Partial (client-navigation) requests to a RESPONSE route (?_rsc_partial):
33
+ * global middleware runs first (so an auth gate can still 401/redirect),
34
+ * then — if it passes through — an X-RSC-Reload is returned. Route-level
35
+ * middleware is skipped on a partial, exactly as production skips it.
36
+ * - A middleware redirect (3xx + Location) on a partial/action request
37
+ * (?_rsc_partial / ?_rsc_action): converted to a 204 + X-RSC-Redirect via the
38
+ * real interceptRedirectForPartial, so fetch() does not auto-follow the 3xx —
39
+ * identical to production's no-location-state path.
40
+ *
41
+ * What dispatch DOES NOT support (and why):
42
+ * - RSC component routes — rendering requires the Flight serializer + React
43
+ * server runtime, which is the boundary this primitive is defined to avoid.
44
+ * This includes partial requests that resolve to a component route.
45
+ * - Server actions (?_rsc_action) — RSC protocol concerns handled by
46
+ * router.fetch().
47
+ * - ctx.onError() callbacks on a thrown response-route handler error: the
48
+ * error is serialized into the same typed 500 / RouterError Response as
49
+ * production, but registered onError handlers are NOT invoked here. Cover
50
+ * onError side effects with an e2e test.
51
+ * - Location-state-carrying redirects on a partial/action request: production
52
+ * embeds a Flight payload (createRedirectFlightResponse) so the client can
53
+ * restore location state across the redirect. dispatch is RSC-free, so it
54
+ * cannot emit that Flight stream. It falls back to the no-state behavior — a
55
+ * 204 + X-RSC-Redirect via createSimpleRedirectResponse — dropping the
56
+ * embedded location state. The 204 status, the X-RSC-Redirect header, and the
57
+ * merged cookies/headers all match production; only the Flight-embedded
58
+ * location-state entries are absent. Cover location-state restoration across a
59
+ * partial redirect with an e2e test.
60
+ *
61
+ * dispatch reuses router.previewMatch(), which itself runs content negotiation
62
+ * and resolves route middleware from the matched entry tree, so dispatch's
63
+ * route-middleware collection is exactly the router's, not a re-implementation.
64
+ */
65
+
66
+ import {
67
+ createRequestContext,
68
+ runWithRequestContext,
69
+ setRequestContextParams,
70
+ } from "../server/request-context.js";
71
+ import { executeMiddleware, matchMiddleware } from "../router/middleware.js";
72
+ import type {
73
+ MiddlewareEntry,
74
+ MiddlewareFn,
75
+ } from "../router/middleware-types.js";
76
+ import {
77
+ createReverseFunction,
78
+ stripInternalParams,
79
+ } from "../router/handler-context.js";
80
+ import { NOCACHE_SYMBOL } from "../cache/taint.js";
81
+ import type { SegmentCacheStore } from "../cache/types.js";
82
+ import type { CacheProfile } from "../cache/profile-registry.js";
83
+ import { setRouterManifest } from "../route-map-builder.js";
84
+ import { RESPONSE_TYPE_MIME } from "../router/content-negotiation.js";
85
+ import { RouterError } from "../errors.js";
86
+ import { createProblemDetails } from "../rsc/response-error.js";
87
+ import {
88
+ createResponseWithMergedHeaders,
89
+ createSimpleRedirectResponse,
90
+ finalizeResponse,
91
+ interceptRedirectForPartial,
92
+ mergeStubHeadersAndFinalize,
93
+ } from "../rsc/helpers.js";
94
+ import { isWebSocketUpgradeResponse } from "../response-utils.js";
95
+ import type { Rango } from "../router/router-interfaces.js";
96
+
97
+ /**
98
+ * The internal subset of the router surface dispatch depends on. The public
99
+ * `Rango` router carries these members at runtime (they are declared on the
100
+ * internal interface), so dispatch accepts a public `Rango` and reads them
101
+ * through this shape — the consumer never needs a cast.
102
+ */
103
+ interface DispatchableRouter<TEnv> {
104
+ id?: string;
105
+ routerId?: string;
106
+ routeMap: Record<string, unknown>;
107
+ middleware: MiddlewareEntry<TEnv>[];
108
+ findMatch(pathname: string): {
109
+ redirectTo?: string;
110
+ routeKey?: string;
111
+ params?: Record<string, string>;
112
+ } | null;
113
+ previewMatch(
114
+ request: Request,
115
+ input?: { env?: TEnv },
116
+ ): Promise<{
117
+ routeMiddleware?: Array<{
118
+ handler: MiddlewareFn<TEnv>;
119
+ params: Record<string, string>;
120
+ }>;
121
+ responseType?: string;
122
+ handler?: Function;
123
+ params?: Record<string, string>;
124
+ routeKey?: string;
125
+ negotiated?: boolean;
126
+ } | null>;
127
+ basename?: string;
128
+ cache?:
129
+ | { enabled?: boolean; store?: SegmentCacheStore }
130
+ | ((
131
+ env: TEnv,
132
+ executionContext: unknown,
133
+ ) => { enabled?: boolean; store?: SegmentCacheStore });
134
+ cacheProfiles?: Record<string, CacheProfile>;
135
+ }
136
+
137
+ /**
138
+ * Options for dispatch.
139
+ */
140
+ export interface DispatchOptions<TEnv = any> {
141
+ /** The request to dispatch: a `Request`, or a URL string (absolute or path). */
142
+ request: Request | string;
143
+ /** Environment bindings forwarded to matching and middleware. */
144
+ env?: TEnv;
145
+ }
146
+
147
+ const DEFAULT_ORIGIN = "http://localhost/";
148
+
149
+ function toRequest(request: Request | string): Request {
150
+ if (request instanceof Request) return request;
151
+ return new Request(new URL(request, DEFAULT_ORIGIN));
152
+ }
153
+
154
+ /**
155
+ * Serialize a NON-Response response-route handler result, mirroring the
156
+ * router's handleResponseRoute() contract:
157
+ * - "json" serializes the value verbatim (bare) with application/json,
158
+ * - text/html/xml/md stringify with the mapped MIME type.
159
+ *
160
+ * A handler-returned Response is NOT routed here — callHandler re-wraps it via
161
+ * rewrapHandlerResponse (mirroring handleResponseRoute's rewrapResponse) so the
162
+ * WebSocket-upgrade bypass and Set-Cookie-preserving header merge match
163
+ * production.
164
+ */
165
+ function serializeResponseRouteResult(
166
+ result: unknown,
167
+ responseType: string,
168
+ ): Response {
169
+ if (responseType === "json") {
170
+ return new Response(JSON.stringify(result), {
171
+ status: 200,
172
+ headers: { "content-type": "application/json;charset=utf-8" },
173
+ });
174
+ }
175
+
176
+ if (Object.hasOwn(RESPONSE_TYPE_MIME, responseType)) {
177
+ return new Response(String(result), {
178
+ status: 200,
179
+ headers: {
180
+ "content-type": `${RESPONSE_TYPE_MIME[responseType]};charset=utf-8`,
181
+ },
182
+ });
183
+ }
184
+
185
+ throw new Error(
186
+ `dispatch(): response route handler for "${responseType}" must return a ` +
187
+ `Response object, got ${typeof result}. Binary/streaming response types ` +
188
+ `(image, stream, any) must return a Response explicitly.`,
189
+ );
190
+ }
191
+
192
+ /**
193
+ * Serialize a thrown handler error into the same typed Response the router's
194
+ * handleResponseRoute() catch block produces:
195
+ * - "json" routes return an RFC 9457 problem+json body (application/problem+json),
196
+ * - all other types return a text/plain body (the RouterError message verbatim,
197
+ * the Error message in dev, else "Internal Server Error").
198
+ *
199
+ * `status` is the effective HTTP status resolved by the caller (RouterError.status
200
+ * or 500, overridden by ctx.setStatus()); it governs both the HTTP status and the
201
+ * problem body's `status`/`title` members. Reuses the production
202
+ * createProblemDetails so the error body is byte-identical rather than re-derived.
203
+ */
204
+ function serializeResponseRouteError(
205
+ error: unknown,
206
+ responseType: string,
207
+ status: number,
208
+ ): Response {
209
+ const isDev = process.env.NODE_ENV !== "production";
210
+
211
+ if (responseType === "json") {
212
+ return new Response(
213
+ JSON.stringify(createProblemDetails(error, status, isDev)),
214
+ {
215
+ status,
216
+ headers: { "content-type": "application/problem+json;charset=utf-8" },
217
+ },
218
+ );
219
+ }
220
+
221
+ const message =
222
+ error instanceof RouterError
223
+ ? error.message
224
+ : isDev && error instanceof Error
225
+ ? error.message
226
+ : "Internal Server Error";
227
+ return new Response(message, {
228
+ status,
229
+ headers: { "content-type": "text/plain;charset=utf-8" },
230
+ });
231
+ }
232
+
233
+ /**
234
+ * Re-wrap a handler-returned Response, byte-identical to handleResponseRoute's
235
+ * rewrapResponse:
236
+ * - A WebSocket upgrade (status 101 or a `webSocket` property) is returned via
237
+ * mergeStubHeadersAndFinalize WITHOUT reconstruction — the Response
238
+ * constructor rejects status 101, and an upgrade response's headers/socket
239
+ * must not be rebuilt.
240
+ * - Otherwise headers are copied into a fresh Headers (Set-Cookie appended to
241
+ * preserve duplicates, others set) and the Response is rebuilt through
242
+ * createResponseWithMergedHeaders so stub headers/cookies, the ctx.setStatus
243
+ * override, and onResponse callbacks merge exactly as in production. statusText
244
+ * is intentionally dropped (production does not carry it across the re-wrap).
245
+ *
246
+ * Must run inside runWithRequestContext (reads the ambient request context via
247
+ * the helpers), which callHandler guarantees.
248
+ */
249
+ function rewrapHandlerResponse(result: Response): Response {
250
+ if (isWebSocketUpgradeResponse(result)) {
251
+ return mergeStubHeadersAndFinalize(result);
252
+ }
253
+ const headers = new Headers();
254
+ result.headers.forEach((value, key) => {
255
+ if (key.toLowerCase() === "set-cookie") {
256
+ headers.append(key, value);
257
+ } else {
258
+ headers.set(key, value);
259
+ }
260
+ });
261
+ return createResponseWithMergedHeaders(result.body, {
262
+ status: result.status,
263
+ headers,
264
+ });
265
+ }
266
+
267
+ /**
268
+ * Run a request through the router in-process and return the Response.
269
+ *
270
+ * @example
271
+ * ```ts
272
+ * const router = createRouter<Env>({}).routes(urls(({ path }) => [
273
+ * path.json("/api/health", () => ({ ok: true }), { name: "health" }),
274
+ * ]));
275
+ *
276
+ * const res = await dispatch(router, { request: "/api/health" });
277
+ * expect(res.status).toBe(200);
278
+ * expect(await res.json()).toEqual({ ok: true });
279
+ * ```
280
+ */
281
+ export async function dispatch<TEnv = any>(
282
+ publicRouter: Rango<TEnv, any>,
283
+ opts: DispatchOptions<TEnv>,
284
+ ): Promise<Response> {
285
+ // The public Rango type intentionally hides the matching internals; read them
286
+ // through the dispatchable shape (present at runtime). Consumers pass their
287
+ // real router with no cast.
288
+ const router = publicRouter as unknown as DispatchableRouter<TEnv>;
289
+ const req = toRequest(opts.request);
290
+ const url = new URL(req.url);
291
+ const env = (opts.env ?? {}) as TEnv;
292
+
293
+ // Seed the per-router manifest so reverse() resolves during handler execution.
294
+ const routerId = router.id ?? router.routerId;
295
+ if (routerId) {
296
+ setRouterManifest(routerId, router.routeMap as Record<string, string>);
297
+ }
298
+
299
+ // findMatch carries trailing-slash/redirect targets and null on no match.
300
+ // previewMatch swallows redirects, so detect them here first.
301
+ const match = router.findMatch(url.pathname);
302
+ const redirectTo = match?.redirectTo;
303
+ const isUnmatched = !match;
304
+
305
+ // previewMatch resolves responseType, the response-route handler, and the
306
+ // route middleware from the matched entry tree (with content negotiation).
307
+ // Skip it for a redirect/unmatched path — there is no response route to
308
+ // resolve, and previewMatch would return null / a redirect marker anyway.
309
+ const preview =
310
+ redirectTo || isUnmatched ? null : await router.previewMatch(req, { env });
311
+
312
+ // A bare match with no responseType is an RSC route. The RSC-route throw is a
313
+ // hard boundary of this primitive (no Flight runtime), distinct from the
314
+ // 308/404 outcomes below, so it stays a pre-middleware guard.
315
+ const responseType = preview?.responseType;
316
+ const handler = preview?.handler;
317
+ const params = preview?.params ?? match?.params ?? {};
318
+ const routeKey = preview?.routeKey ?? match?.routeKey;
319
+
320
+ if (
321
+ !redirectTo &&
322
+ !isUnmatched &&
323
+ (!responseType || typeof handler !== "function")
324
+ ) {
325
+ throw new Error(
326
+ `dispatch() does not render RSC routes — the route matched at ` +
327
+ `"${url.pathname}" is a React Server Component route, not a response ` +
328
+ `route. Use renderHandler/renderServerTree/renderToFlightString or an ` +
329
+ `e2e test to exercise component rendering.`,
330
+ );
331
+ }
332
+
333
+ const variables: Record<string, unknown> = {};
334
+
335
+ // Resolve the router's cache store the way the production handler does, so a
336
+ // "use cache" inside a response-route handler reaches the request-scope
337
+ // (NOCACHE) detection below instead of bypassing on a missing store.
338
+ let cacheStore: SegmentCacheStore | undefined;
339
+ const cacheOption = router.cache;
340
+ if (cacheOption && !url.searchParams.has("__no_cache")) {
341
+ const cacheConfig =
342
+ typeof cacheOption === "function"
343
+ ? cacheOption(env, undefined)
344
+ : cacheOption;
345
+ if (cacheConfig.enabled !== false) cacheStore = cacheConfig.store;
346
+ }
347
+
348
+ const requestContext = createRequestContext<TEnv>({
349
+ env,
350
+ request: req,
351
+ url,
352
+ variables,
353
+ cacheStore,
354
+ cacheProfiles: router.cacheProfiles,
355
+ });
356
+ // Match production: the RSC handler stores the router's basename on the
357
+ // request context (handler.ts), and redirect() prefixes root-relative URLs
358
+ // with it. Mirror it so basename-redirect tests behave as they do in a real
359
+ // mounted app instead of always seeing no prefix.
360
+ requestContext._basename = router.basename;
361
+
362
+ // Match production's response-route reverse EXACTLY: the real handler builds
363
+ // it from the route map alone (response-route-handler.ts), with NO matched
364
+ // routeKey or params. Passing routeKey/params here would auto-fill params from
365
+ // the matched route, so ctx.reverse("name") could pass in a test while the
366
+ // real handler throws for the missing param.
367
+ const reverse = createReverseFunction(
368
+ router.routeMap as Record<string, string>,
369
+ ) as (
370
+ name: string,
371
+ p?: Record<string, string>,
372
+ search?: Record<string, unknown>,
373
+ ) => string;
374
+
375
+ const isPartial = url.searchParams.has("_rsc_partial");
376
+ const isAction = url.searchParams.has("_rsc_action");
377
+
378
+ return runWithRequestContext(requestContext, async () => {
379
+ // Set params before middleware/handler run, so global middleware sees
380
+ // ctx.params (production sets them during matching, before middleware).
381
+ // On a redirect/unmatched path there are no route params.
382
+ if (routeKey !== undefined) {
383
+ setRequestContextParams(params, routeKey);
384
+ } else {
385
+ requestContext.params = params;
386
+ }
387
+
388
+ // The response-route handler (with its own route middleware) lives inside
389
+ // coreHandler below, mirroring production where handleResponseRoute is
390
+ // nested inside coreHandler. Built lazily so a redirect/404 path never
391
+ // touches it.
392
+ const callResponseRoute = (): Promise<Response> => {
393
+ // Match production: a partial (client-navigation) request to a response
394
+ // route is short-circuited to X-RSC-Reload (handleResponseRoute), BEFORE
395
+ // route-level middleware runs. Route-level middleware is skipped on a
396
+ // partial, exactly as production skips it.
397
+ const partialFinalHandler = async (): Promise<Response> =>
398
+ createResponseWithMergedHeaders(null, {
399
+ status: 200,
400
+ headers: {
401
+ "X-RSC-Reload": stripInternalParams(url).toString(),
402
+ "content-type": "text/x-component;charset=utf-8",
403
+ },
404
+ });
405
+
406
+ const cleanUrl = new URL(req.url);
407
+ for (const key of [...cleanUrl.searchParams.keys()]) {
408
+ if (key.startsWith("_rsc")) cleanUrl.searchParams.delete(key);
409
+ }
410
+
411
+ // Lightweight response-handler context mirroring handleResponseRoute.
412
+ const responseHandlerCtx = {
413
+ request: req,
414
+ params,
415
+ env,
416
+ searchParams: cleanUrl.searchParams,
417
+ url: cleanUrl,
418
+ originalUrl: requestContext.originalUrl,
419
+ pathname: url.pathname,
420
+ reverse,
421
+ get: requestContext.get,
422
+ header: (name: string, value: string) =>
423
+ requestContext.header(name, value),
424
+ waitUntil: requestContext.waitUntil.bind(requestContext),
425
+ executionContext: requestContext.executionContext,
426
+ _responseType: responseType,
427
+ };
428
+ // Brand as request-scoped so a "use cache" inside a response-route handler
429
+ // is detected as a request-scope violation here exactly as in production
430
+ // (response-route-handler.ts brands the same shape).
431
+ (responseHandlerCtx as Record<symbol, unknown>)[NOCACHE_SYMBOL] = true;
432
+
433
+ const callHandler = async (): Promise<Response> => {
434
+ let merged: Response;
435
+ try {
436
+ const result = await (handler as Function)(responseHandlerCtx);
437
+ if (result instanceof Response) {
438
+ // Handler returned a Response: mirror handleResponseRoute's
439
+ // rewrapResponse (WebSocket-upgrade bypass + Set-Cookie-preserving
440
+ // header rebuild, statusText dropped) rather than the generic
441
+ // createResponseWithMergedHeaders re-wrap below.
442
+ merged = rewrapHandlerResponse(result);
443
+ } else {
444
+ // Route the serialized (json/text/...) body through the SAME
445
+ // production finalizer the RSC handler uses, so ctx.onResponse()
446
+ // callbacks fire and stub headers/cookies + the ctx.setStatus
447
+ // override merge identically to production. Runs inside
448
+ // runWithRequestContext, so _getRequestContext() resolves here.
449
+ const serialized = serializeResponseRouteResult(
450
+ result,
451
+ responseType as string,
452
+ );
453
+ merged = createResponseWithMergedHeaders(serialized.body, {
454
+ status: serialized.status,
455
+ headers: serialized.headers,
456
+ });
457
+ }
458
+ } catch (error) {
459
+ // Mirror handleResponseRoute's catch: a genuine handler error becomes
460
+ // the router's typed 500 / RouterError-status Response (NOT a rejected
461
+ // promise). Middleware short-circuit via thrown Response is handled by
462
+ // executeMiddleware and never reaches here.
463
+ const derivedStatus =
464
+ error instanceof RouterError ? error.status : 500;
465
+ // Resolve the effective status the way createResponseWithMergedHeaders
466
+ // (below) will (ctx.res.status override) BEFORE building the problem
467
+ // body, so the body's status/title match the actual HTTP status when a
468
+ // handler called ctx.setStatus() before throwing — exactly as
469
+ // handleResponseRoute resolves it.
470
+ const status =
471
+ requestContext.res.status !== 200
472
+ ? requestContext.res.status
473
+ : derivedStatus;
474
+ const serialized = serializeResponseRouteError(
475
+ error,
476
+ responseType as string,
477
+ status,
478
+ );
479
+ merged = createResponseWithMergedHeaders(serialized.body, {
480
+ status: serialized.status,
481
+ headers: serialized.headers,
482
+ });
483
+ }
484
+
485
+ // Append Vary: Accept on content-negotiated responses, matching
486
+ // handleResponseRoute's callHandlerWithVary. Skipped on WebSocket
487
+ // upgrade responses (immutable headers, Vary meaningless for a 101).
488
+ if (preview?.negotiated && !isWebSocketUpgradeResponse(merged)) {
489
+ merged.headers.append("Vary", "Accept");
490
+ }
491
+
492
+ return merged;
493
+ };
494
+
495
+ // On a partial request the reload IS the terminal handler and route
496
+ // middleware is skipped; otherwise the response-route handler is wrapped
497
+ // by route-level middleware (production order: route middleware runs
498
+ // inside handleResponseRoute, after the global chain).
499
+ if (isPartial) {
500
+ return partialFinalHandler();
501
+ }
502
+ const routeMiddlewareEntries = (preview?.routeMiddleware ?? []).map(
503
+ (mw) => ({
504
+ entry: {
505
+ pattern: null,
506
+ regex: null,
507
+ paramNames: [],
508
+ handler: mw.handler,
509
+ mountPrefix: null,
510
+ } as MiddlewareEntry<TEnv>,
511
+ params: mw.params,
512
+ }),
513
+ );
514
+ if (routeMiddlewareEntries.length === 0) {
515
+ return callHandler();
516
+ }
517
+ return executeMiddleware<TEnv>(
518
+ routeMiddlewareEntries,
519
+ req,
520
+ env,
521
+ variables,
522
+ callHandler,
523
+ reverse,
524
+ );
525
+ };
526
+
527
+ // coreHandler is the single terminal the global middleware chain wraps,
528
+ // mirroring production's coreHandler (handler.ts): a trailing-slash/redirect
529
+ // 308, an unmatched-path 404, or the response route. Both the 308 and the
530
+ // 404 are produced via createResponseWithMergedHeaders so middleware-set
531
+ // cookies/headers merge onto them, identical to production's
532
+ // rsc-rendering.ts redirect path — and because they sit inside the chain, a
533
+ // global middleware that short-circuits (e.g. an auth 401) runs first and
534
+ // wins, never reaching the 308/404.
535
+ const coreHandler = async (): Promise<Response> => {
536
+ if (redirectTo) {
537
+ return createResponseWithMergedHeaders(null, {
538
+ status: 308,
539
+ headers: { Location: redirectTo + url.search },
540
+ });
541
+ }
542
+ if (isUnmatched) {
543
+ return createResponseWithMergedHeaders("Not Found", {
544
+ status: 404,
545
+ headers: { "content-type": "text/plain;charset=utf-8" },
546
+ });
547
+ }
548
+ return callResponseRoute();
549
+ };
550
+
551
+ // Global (pattern-matched) middleware wraps coreHandler, exactly as
552
+ // production wraps coreHandler with executeMiddleware (handler.ts).
553
+ const globalMatches = matchMiddleware(url.pathname, router.middleware);
554
+ const mwResponse =
555
+ globalMatches.length === 0
556
+ ? await coreHandler()
557
+ : await executeMiddleware<TEnv>(
558
+ globalMatches,
559
+ req,
560
+ env,
561
+ variables,
562
+ coreHandler,
563
+ reverse,
564
+ );
565
+
566
+ // Match production's global-chain exit (handler.ts): on a partial/action
567
+ // request a middleware 3xx redirect is converted to a Flight-safe response
568
+ // so fetch() does not auto-follow it; every path then drains onResponse
569
+ // callbacks via finalizeResponse. dispatch is RSC-free, so the
570
+ // createRedirectFlightResponse stand-in falls back to the no-state
571
+ // 204 + X-RSC-Redirect (see the location-state divergence in the header).
572
+ if (isPartial || isAction) {
573
+ const intercepted = interceptRedirectForPartial(
574
+ mwResponse,
575
+ (redirectUrl) => createSimpleRedirectResponse(redirectUrl),
576
+ );
577
+ return finalizeResponse(intercepted ?? mwResponse);
578
+ }
579
+ return finalizeResponse(mwResponse);
580
+ });
581
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @rangojs/router/testing/dom
3
+ *
4
+ * Component-render testing: `renderRoute`, the React-Testing-Library-style stub
5
+ * for client components that read router context (useParams / useReverse /
6
+ * Outlet / useNavigation / useLoader).
7
+ *
8
+ * Separate from the main `@rangojs/router/testing` barrel so unit suites that
9
+ * only test loaders, middleware, or `dispatch` never reference React, the
10
+ * browser runtime, or `@testing-library/react` (an optional peer that
11
+ * `renderRoute` lazy-loads at call time). Run these tests in a DOM environment
12
+ * (`happy-dom` or `jsdom`).
13
+ */
14
+
15
+ export { renderRoute } from "./render-route.js";
16
+ export type {
17
+ RenderRouteSpec,
18
+ RenderRouteOptions,
19
+ TestRouterHandle,
20
+ RenderRouteResult,
21
+ HandleDataSeed,
22
+ } from "./render-route.js";