@rangojs/router 0.0.0-experimental.20 → 0.0.0-experimental.204030a9

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 (293) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +242 -55
  3. package/dist/bin/rango.js +277 -99
  4. package/dist/vite/index.js +2929 -1132
  5. package/dist/vite/index.js.bak +5448 -0
  6. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  7. package/package.json +68 -21
  8. package/skills/breadcrumbs/SKILL.md +252 -0
  9. package/skills/bundle-analysis/SKILL.md +159 -0
  10. package/skills/cache-guide/SKILL.md +243 -21
  11. package/skills/caching/SKILL.md +159 -10
  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 +262 -51
  16. package/skills/host-router/SKILL.md +243 -0
  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 +249 -17
  21. package/skills/loader/SKILL.md +291 -31
  22. package/skills/middleware/SKILL.md +49 -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 +197 -6
  28. package/skills/prerender/SKILL.md +125 -102
  29. package/skills/rango/SKILL.md +242 -23
  30. package/skills/react-compiler/SKILL.md +168 -0
  31. package/skills/response-routes/SKILL.md +66 -9
  32. package/skills/route/SKILL.md +91 -8
  33. package/skills/router-setup/SKILL.md +98 -8
  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 +511 -188
  37. package/skills/typesafety/SKILL.md +354 -50
  38. package/skills/use-cache/SKILL.md +34 -5
  39. package/skills/view-transitions/SKILL.md +294 -0
  40. package/src/__augment-tests__/augment.ts +81 -0
  41. package/src/__augment-tests__/augmented.check.ts +117 -0
  42. package/src/__internal.ts +92 -0
  43. package/src/browser/action-coordinator.ts +53 -36
  44. package/src/browser/app-shell.ts +52 -0
  45. package/src/browser/app-version.ts +14 -0
  46. package/src/browser/event-controller.ts +91 -70
  47. package/src/browser/history-state.ts +21 -0
  48. package/src/browser/index.ts +3 -3
  49. package/src/browser/link-interceptor.ts +4 -0
  50. package/src/browser/navigation-bridge.ts +183 -18
  51. package/src/browser/navigation-client.ts +187 -57
  52. package/src/browser/navigation-store.ts +75 -17
  53. package/src/browser/navigation-transaction.ts +21 -37
  54. package/src/browser/partial-update.ts +143 -40
  55. package/src/browser/prefetch/cache.ts +275 -28
  56. package/src/browser/prefetch/fetch.ts +191 -46
  57. package/src/browser/prefetch/policy.ts +6 -0
  58. package/src/browser/prefetch/queue.ts +123 -20
  59. package/src/browser/prefetch/resource-ready.ts +77 -0
  60. package/src/browser/rango-state.ts +53 -13
  61. package/src/browser/react/Link.tsx +98 -14
  62. package/src/browser/react/NavigationProvider.tsx +110 -33
  63. package/src/browser/react/context.ts +7 -2
  64. package/src/browser/react/filter-segment-order.ts +51 -7
  65. package/src/browser/react/index.ts +3 -0
  66. package/src/browser/react/location-state-shared.ts +175 -4
  67. package/src/browser/react/location-state.ts +39 -13
  68. package/src/browser/react/use-handle.ts +23 -64
  69. package/src/browser/react/use-navigation.ts +22 -2
  70. package/src/browser/react/use-params.ts +20 -8
  71. package/src/browser/react/use-reverse.ts +106 -0
  72. package/src/browser/react/use-router.ts +43 -10
  73. package/src/browser/react/use-segments.ts +11 -8
  74. package/src/browser/response-adapter.ts +25 -0
  75. package/src/browser/rsc-router.tsx +200 -75
  76. package/src/browser/scroll-restoration.ts +46 -39
  77. package/src/browser/segment-reconciler.ts +36 -9
  78. package/src/browser/segment-structure-assert.ts +2 -2
  79. package/src/browser/server-action-bridge.ts +31 -36
  80. package/src/browser/types.ts +81 -5
  81. package/src/build/collect-fallback-refs.ts +107 -0
  82. package/src/build/generate-manifest.ts +65 -40
  83. package/src/build/generate-route-types.ts +5 -0
  84. package/src/build/index.ts +2 -0
  85. package/src/build/route-trie.ts +69 -26
  86. package/src/build/route-types/codegen.ts +4 -4
  87. package/src/build/route-types/include-resolution.ts +9 -2
  88. package/src/build/route-types/per-module-writer.ts +7 -4
  89. package/src/build/route-types/router-processing.ts +278 -88
  90. package/src/build/route-types/scan-filter.ts +9 -2
  91. package/src/build/route-types/source-scan.ts +118 -0
  92. package/src/build/runtime-discovery.ts +9 -20
  93. package/src/cache/cache-runtime.ts +15 -11
  94. package/src/cache/cache-scope.ts +76 -49
  95. package/src/cache/cf/cf-cache-store.ts +501 -18
  96. package/src/cache/cf/index.ts +5 -1
  97. package/src/cache/document-cache.ts +17 -7
  98. package/src/cache/index.ts +1 -0
  99. package/src/cache/taint.ts +55 -0
  100. package/src/client.rsc.tsx +5 -1
  101. package/src/client.tsx +95 -284
  102. package/src/context-var.ts +72 -2
  103. package/src/debug.ts +2 -2
  104. package/src/decode-loader-results.ts +36 -0
  105. package/src/errors.ts +30 -1
  106. package/src/handle.ts +65 -12
  107. package/src/handles/breadcrumbs.ts +66 -0
  108. package/src/handles/index.ts +1 -0
  109. package/src/host/index.ts +2 -5
  110. package/src/host/router.ts +129 -57
  111. package/src/host/types.ts +31 -2
  112. package/src/host/utils.ts +1 -1
  113. package/src/href-client.ts +140 -20
  114. package/src/index.rsc.ts +15 -40
  115. package/src/index.ts +92 -76
  116. package/src/loader-store.ts +500 -0
  117. package/src/loader.rsc.ts +2 -5
  118. package/src/loader.ts +3 -10
  119. package/src/missing-id-error.ts +68 -0
  120. package/src/outlet-context.ts +1 -1
  121. package/src/prerender/store.ts +57 -15
  122. package/src/prerender.ts +141 -80
  123. package/src/response-utils.ts +37 -0
  124. package/src/reverse.ts +65 -15
  125. package/src/route-content-wrapper.tsx +6 -28
  126. package/src/route-definition/dsl-helpers.ts +435 -260
  127. package/src/route-definition/helper-factories.ts +29 -139
  128. package/src/route-definition/helpers-types.ts +110 -34
  129. package/src/route-definition/index.ts +3 -3
  130. package/src/route-definition/redirect.ts +11 -3
  131. package/src/route-definition/resolve-handler-use.ts +155 -0
  132. package/src/route-definition/use-item-types.ts +32 -0
  133. package/src/route-map-builder.ts +7 -1
  134. package/src/route-types.ts +37 -41
  135. package/src/router/basename.ts +14 -0
  136. package/src/router/content-negotiation.ts +113 -1
  137. package/src/router/error-handling.ts +1 -1
  138. package/src/router/find-match.ts +4 -2
  139. package/src/router/handler-context.ts +105 -39
  140. package/src/router/intercept-resolution.ts +15 -22
  141. package/src/router/lazy-includes.ts +12 -9
  142. package/src/router/loader-resolution.ts +175 -23
  143. package/src/router/logging.ts +5 -2
  144. package/src/router/manifest.ts +31 -16
  145. package/src/router/match-api.ts +129 -193
  146. package/src/router/match-handlers.ts +63 -20
  147. package/src/router/match-middleware/background-revalidation.ts +30 -2
  148. package/src/router/match-middleware/cache-lookup.ts +136 -106
  149. package/src/router/match-middleware/cache-store.ts +54 -10
  150. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  151. package/src/router/match-middleware/segment-resolution.ts +61 -5
  152. package/src/router/match-result.ts +124 -18
  153. package/src/router/metrics.ts +239 -14
  154. package/src/router/middleware-types.ts +61 -31
  155. package/src/router/middleware.ts +226 -124
  156. package/src/router/navigation-snapshot.ts +182 -0
  157. package/src/router/pattern-matching.ts +118 -19
  158. package/src/router/prerender-match.ts +114 -10
  159. package/src/router/preview-match.ts +32 -102
  160. package/src/router/request-classification.ts +286 -0
  161. package/src/router/revalidation.ts +85 -9
  162. package/src/router/route-snapshot.ts +245 -0
  163. package/src/router/router-context.ts +6 -1
  164. package/src/router/router-interfaces.ts +91 -29
  165. package/src/router/router-options.ts +89 -19
  166. package/src/router/router-registry.ts +2 -5
  167. package/src/router/segment-resolution/fresh.ts +240 -23
  168. package/src/router/segment-resolution/helpers.ts +30 -25
  169. package/src/router/segment-resolution/loader-cache.ts +1 -0
  170. package/src/router/segment-resolution/revalidation.ts +483 -289
  171. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  172. package/src/router/segment-wrappers.ts +2 -0
  173. package/src/router/substitute-pattern-params.ts +56 -0
  174. package/src/router/telemetry.ts +99 -0
  175. package/src/router/trie-matching.ts +38 -15
  176. package/src/router/types.ts +9 -0
  177. package/src/router/url-params.ts +49 -0
  178. package/src/router.ts +120 -32
  179. package/src/rsc/handler-context.ts +2 -2
  180. package/src/rsc/handler.ts +524 -370
  181. package/src/rsc/helpers.ts +91 -43
  182. package/src/rsc/index.ts +1 -21
  183. package/src/rsc/loader-fetch.ts +23 -3
  184. package/src/rsc/manifest-init.ts +5 -1
  185. package/src/rsc/origin-guard.ts +28 -10
  186. package/src/rsc/progressive-enhancement.ts +39 -10
  187. package/src/rsc/response-route-handler.ts +46 -53
  188. package/src/rsc/rsc-rendering.ts +69 -89
  189. package/src/rsc/runtime-warnings.ts +9 -10
  190. package/src/rsc/server-action.ts +39 -47
  191. package/src/rsc/ssr-setup.ts +144 -0
  192. package/src/rsc/types.ts +19 -3
  193. package/src/search-params.ts +20 -17
  194. package/src/segment-content-promise.ts +67 -0
  195. package/src/segment-loader-promise.ts +122 -0
  196. package/src/segment-system.tsx +219 -67
  197. package/src/serialize.ts +243 -0
  198. package/src/server/context.ts +285 -63
  199. package/src/server/cookie-store.ts +28 -4
  200. package/src/server/handle-store.ts +19 -0
  201. package/src/server/loader-registry.ts +9 -8
  202. package/src/server/request-context.ts +228 -65
  203. package/src/server.ts +6 -0
  204. package/src/ssr/index.tsx +9 -1
  205. package/src/static-handler.ts +19 -7
  206. package/src/testing/cache-status.ts +166 -0
  207. package/src/testing/collect-handle.ts +63 -0
  208. package/src/testing/dispatch.ts +440 -0
  209. package/src/testing/dom.entry.ts +22 -0
  210. package/src/testing/e2e/fixture.ts +154 -0
  211. package/src/testing/e2e/index.ts +149 -0
  212. package/src/testing/e2e/matchers.ts +51 -0
  213. package/src/testing/e2e/page-helpers.ts +272 -0
  214. package/src/testing/e2e/parity.ts +306 -0
  215. package/src/testing/e2e/server.ts +183 -0
  216. package/src/testing/flight-matchers.ts +104 -0
  217. package/src/testing/flight-runtime.d.ts +21 -0
  218. package/src/testing/flight.entry.ts +22 -0
  219. package/src/testing/flight.ts +182 -0
  220. package/src/testing/generated-routes.ts +223 -0
  221. package/src/testing/index.ts +98 -0
  222. package/src/testing/internal/context.ts +151 -0
  223. package/src/testing/render-route.tsx +536 -0
  224. package/src/testing/run-loader.ts +296 -0
  225. package/src/testing/run-middleware.ts +170 -0
  226. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  227. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  228. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  229. package/src/testing/vitest-stubs/version.ts +5 -0
  230. package/src/testing/vitest.ts +112 -0
  231. package/src/theme/index.ts +4 -13
  232. package/src/types/cache-types.ts +4 -4
  233. package/src/types/global-namespace.ts +39 -26
  234. package/src/types/handler-context.ts +197 -79
  235. package/src/types/index.ts +1 -0
  236. package/src/types/loader-types.ts +41 -15
  237. package/src/types/request-scope.ts +126 -0
  238. package/src/types/route-config.ts +17 -8
  239. package/src/types/route-entry.ts +19 -1
  240. package/src/types/segments.ts +37 -6
  241. package/src/urls/include-helper.ts +34 -67
  242. package/src/urls/index.ts +0 -3
  243. package/src/urls/path-helper-types.ts +50 -9
  244. package/src/urls/path-helper.ts +63 -63
  245. package/src/urls/pattern-types.ts +48 -19
  246. package/src/urls/response-types.ts +25 -22
  247. package/src/urls/type-extraction.ts +26 -116
  248. package/src/urls/urls-function.ts +1 -5
  249. package/src/use-loader.tsx +487 -44
  250. package/src/vite/debug.ts +185 -0
  251. package/src/vite/discovery/bundle-postprocess.ts +63 -91
  252. package/src/vite/discovery/discover-routers.ts +106 -53
  253. package/src/vite/discovery/discovery-errors.ts +194 -0
  254. package/src/vite/discovery/gate-state.ts +171 -0
  255. package/src/vite/discovery/prerender-collection.ts +222 -107
  256. package/src/vite/discovery/route-types-writer.ts +40 -84
  257. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  258. package/src/vite/discovery/state.ts +50 -13
  259. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  260. package/src/vite/index.ts +10 -3
  261. package/src/vite/plugin-types.ts +111 -72
  262. package/src/vite/plugins/cjs-to-esm.ts +8 -7
  263. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  264. package/src/vite/plugins/client-ref-hashing.ts +28 -5
  265. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  266. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  267. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  268. package/src/vite/plugins/expose-action-id.ts +55 -33
  269. package/src/vite/plugins/expose-id-utils.ts +24 -8
  270. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  271. package/src/vite/plugins/expose-ids/handler-transform.ts +12 -35
  272. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  273. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  274. package/src/vite/plugins/expose-internal-ids.ts +544 -317
  275. package/src/vite/plugins/performance-tracks.ts +92 -0
  276. package/src/vite/plugins/refresh-cmd.ts +127 -0
  277. package/src/vite/plugins/use-cache-transform.ts +65 -50
  278. package/src/vite/plugins/version-injector.ts +39 -23
  279. package/src/vite/plugins/version-plugin.ts +72 -3
  280. package/src/vite/plugins/virtual-entries.ts +2 -2
  281. package/src/vite/rango.ts +265 -226
  282. package/src/vite/router-discovery.ts +924 -137
  283. package/src/vite/utils/ast-handler-extract.ts +15 -15
  284. package/src/vite/utils/banner.ts +4 -4
  285. package/src/vite/utils/bundle-analysis.ts +4 -2
  286. package/src/vite/utils/client-chunks.ts +190 -0
  287. package/src/vite/utils/forward-user-plugins.ts +193 -0
  288. package/src/vite/utils/manifest-utils.ts +21 -5
  289. package/src/vite/utils/package-resolution.ts +41 -1
  290. package/src/vite/utils/prerender-utils.ts +98 -5
  291. package/src/vite/utils/shared-utils.ts +109 -27
  292. package/src/browser/action-response-classifier.ts +0 -99
  293. package/src/route-definition/route-function.ts +0 -119
@@ -12,6 +12,9 @@ import type {
12
12
  DefaultVars,
13
13
  } from "../types/global-namespace.js";
14
14
  import type { ScopedReverseFunction } from "../reverse.js";
15
+ import type { Theme } from "../theme/types.js";
16
+ import type { LocationStateEntry } from "../browser/react/location-state-shared.js";
17
+ import type { RequestScope } from "../types/request-scope.js";
15
18
 
16
19
  /**
17
20
  * Get variable function type
@@ -25,8 +28,12 @@ type GetVariableFn = {
25
28
  * Set variable function type
26
29
  */
27
30
  type SetVariableFn = {
28
- <T>(contextVar: ContextVar<T>, value: T): void;
29
- <K extends keyof DefaultVars>(key: K, value: DefaultVars[K]): void;
31
+ <T>(contextVar: ContextVar<T>, value: T, options?: { cache?: boolean }): void;
32
+ <K extends keyof DefaultVars>(
33
+ key: K,
34
+ value: DefaultVars[K],
35
+ options?: { cache?: boolean },
36
+ ): void;
30
37
  };
31
38
 
32
39
  /**
@@ -46,38 +53,24 @@ export interface CookieOptions {
46
53
  * Context passed to middleware
47
54
  *
48
55
  * @template TEnv - Environment type (bindings, variables) - defaults to any for internal flexibility
49
- * @template TParams - URL params type (typed for route middleware, Record<string, string> for global middleware)
56
+ * @template TParams - URL params type (typed for route middleware,
57
+ * `Record<string, string | undefined>` for global middleware — absent
58
+ * optional segments are omitted from the params record at runtime, so
59
+ * the index signature must include `undefined`)
50
60
  */
51
61
  export interface MiddlewareContext<
52
62
  TEnv = any,
53
- TParams = Record<string, string>,
54
- > {
55
- /** Original request */
56
- request: Request;
57
-
58
- /** Parsed URL */
59
- url: URL;
60
-
61
- /** URL pathname */
62
- pathname: string;
63
-
64
- /** URL search params */
65
- searchParams: URLSearchParams;
66
-
67
- /** Platform bindings (Cloudflare, etc.) */
68
- env: TEnv;
69
-
63
+ TParams = Record<string, string | undefined>,
64
+ > extends RequestScope<TEnv> {
70
65
  /** URL params extracted from route/middleware pattern */
71
66
  params: TParams;
72
67
 
73
68
  /**
74
- * Response stub (read-only). Before `next()`, returns the shared response stub
75
- * where headers and cookies accumulate. After `next()`, returns the downstream response.
76
- *
77
- * Use `ctx.header()` to set response headers, or `cookies()` for cookie mutations.
78
- * To replace the response entirely, return a new `Response` from the middleware.
69
+ * Response headers.
70
+ * Before `next()`, returns headers from the shared response stub.
71
+ * After `next()`, returns headers from the downstream response.
79
72
  */
80
- readonly res: Response;
73
+ readonly headers: Headers;
81
74
 
82
75
  /** Get a context variable (shared with route handlers) */
83
76
  get: GetVariableFn;
@@ -86,11 +79,10 @@ export interface MiddlewareContext<
86
79
  set: SetVariableFn;
87
80
 
88
81
  /**
89
- * Set a response header - can be called before or after `next()`
82
+ * Set a response header - can be called before or after `next()`.
90
83
  *
91
84
  * When called before `next()`, headers are queued and merged into the final response.
92
85
  * When called after `next()`, headers are set directly on the response.
93
- * Shorthand for `ctx.res.headers.set()`.
94
86
  */
95
87
  header(name: string, value: string): void;
96
88
 
@@ -100,6 +92,38 @@ export interface MiddlewareContext<
100
92
  */
101
93
  routeName?: DefaultRouteName;
102
94
 
95
+ /**
96
+ * Enable performance metrics for this request.
97
+ * When called, granular timing breakdown is logged to console and
98
+ * included in the Server-Timing response header, regardless of the
99
+ * router-level `debugPerformance` option.
100
+ *
101
+ * Call **before** `await next()` so the metrics store exists when
102
+ * downstream phases (route matching, rendering, SSR) record their
103
+ * spans. Calling after `next()` returns still emits `handler:total`
104
+ * but misses all upstream metrics.
105
+ */
106
+ debugPerformance(): void;
107
+
108
+ /**
109
+ * Current theme (from cookie or default).
110
+ * Only available when theme is enabled in router config.
111
+ */
112
+ theme?: Theme;
113
+
114
+ /**
115
+ * Set the theme (only available when theme is enabled in router config).
116
+ * Sets a cookie with the new theme value.
117
+ */
118
+ setTheme?: (theme: Theme) => void;
119
+
120
+ /**
121
+ * Attach location state entries to this response.
122
+ * State is delivered to the client via history.pushState and accessible
123
+ * through the useLocationState() hook.
124
+ */
125
+ setLocationState(entries: LocationStateEntry | LocationStateEntry[]): void;
126
+
103
127
  /**
104
128
  * Generate URLs from route names.
105
129
  * - `name` — global route, from the named-routes definition
@@ -116,7 +140,7 @@ export interface MiddlewareContext<
116
140
  * @template TEnv - Environment type - defaults to any for internal flexibility
117
141
  * @template TParams - URL params type (typed for route middleware)
118
142
  *
119
- * When using middleware with global augmentation (RSCRouter.Env), explicitly
143
+ * When using middleware with global augmentation (Rango.Env), explicitly
120
144
  * annotate your middleware functions, or the types will be inferred from context:
121
145
  *
122
146
  * @example
@@ -128,7 +152,10 @@ export interface MiddlewareContext<
128
152
  * router.use((ctx, next) => {...}) // ctx is typed from router's TEnv
129
153
  * ```
130
154
  */
131
- export type MiddlewareFn<TEnv = any, TParams = Record<string, string>> = (
155
+ export type MiddlewareFn<
156
+ TEnv = any,
157
+ TParams = Record<string, string | undefined>,
158
+ > = (
132
159
  ctx: MiddlewareContext<TEnv, TParams>,
133
160
  next: () => Promise<Response>,
134
161
  ) => Response | void | Promise<Response | void>;
@@ -155,7 +182,7 @@ export interface MiddlewareEntry<TEnv = any> {
155
182
  }
156
183
 
157
184
  /**
158
- * Mutable response holder - allows ctx.res to be updated after next() is called
185
+ * Mutable response holder - tracks the current response through the middleware chain.
159
186
  */
160
187
  export interface ResponseHolder {
161
188
  response: Response | null;
@@ -175,5 +202,8 @@ export interface MiddlewareCollectableEntry {
175
202
  */
176
203
  export interface CollectedMiddleware {
177
204
  handler: MiddlewareFn<any, any>;
205
+ // Internal shape only. The user-facing `MiddlewareContext.params` is
206
+ // typed `Record<string, string | undefined>` to reflect that absent
207
+ // optional segments are omitted from the params record at runtime.
178
208
  params: Record<string, string>;
179
209
  }
@@ -10,6 +10,8 @@
10
10
  */
11
11
 
12
12
  import { contextGet, contextSet } from "../context-var.js";
13
+ import { safeDecodeURIComponent } from "./url-params.js";
14
+ import { fireAndForgetWaitUntil } from "../types/request-scope.js";
13
15
  import type {
14
16
  CollectedMiddleware,
15
17
  MiddlewareCollectableEntry,
@@ -20,6 +22,9 @@ import type {
20
22
  } from "./middleware-types.js";
21
23
  import { _getRequestContext } from "../server/request-context.js";
22
24
  import { isAutoGeneratedRouteName } from "../route-name.js";
25
+ import { appendMetric, createMetricsStore } from "./metrics.js";
26
+ import { stripInternalParams } from "./handler-context.js";
27
+ import { isWebSocketUpgradeResponse } from "../response-utils.js";
23
28
 
24
29
  // Re-export types and cookie utilities for backward compatibility
25
30
  export type {
@@ -33,25 +38,29 @@ export type {
33
38
  } from "./middleware-types.js";
34
39
  export { parseCookies, serializeCookie } from "./middleware-cookies.js";
35
40
 
36
- // W5: Deduplicate by function reference so each distinct middleware warns once,
37
- // regardless of whether it is named or anonymous.
38
- let warnedRedirectMiddleware = new WeakSet<Function>();
39
-
40
- function warnCtxSetBeforeRedirect(handler: Function): void {
41
- if (warnedRedirectMiddleware.has(handler)) return;
42
- warnedRedirectMiddleware.add(handler);
43
- const label = handler.name || "(anonymous)";
44
- console.warn(
45
- `[rango] Route middleware "${label}" called ctx.set() then returned a ` +
46
- `redirect. Context variables are per-request and won't be available ` +
47
- `on the redirect target. Use cookies to persist state across ` +
48
- `redirects, or move ctx.set() to the target route's middleware.`,
49
- );
41
+ const MIDDLEWARE_METRIC_DEPTH = 1;
42
+ /** Ignore post-next() durations below this threshold (measurement noise). */
43
+ const POST_METRIC_MIN_DURATION_MS = 0.01;
44
+
45
+ function getMiddlewareMetricBase<TEnv>(
46
+ entry: MiddlewareEntry<TEnv>,
47
+ ordinal: number,
48
+ ): string {
49
+ const handlerName = entry.handler.name?.trim();
50
+ const scope = entry.pattern ?? "*";
51
+
52
+ if (handlerName) {
53
+ return `${handlerName}@${scope}`;
54
+ }
55
+
56
+ return `${scope}#${ordinal + 1}`;
50
57
  }
51
58
 
52
- /** Reset W5 deduplication state (for tests only). */
53
- export function _resetW5Warnings(): void {
54
- warnedRedirectMiddleware = new WeakSet();
59
+ function getMiddlewareMetricLabel<TEnv>(
60
+ entry: MiddlewareEntry<TEnv>,
61
+ ordinal: number,
62
+ ): string {
63
+ return `middleware:${getMiddlewareMetricBase(entry, ordinal)}`;
55
64
  }
56
65
 
57
66
  /**
@@ -106,7 +115,12 @@ function escapeRegex(str: string): string {
106
115
  }
107
116
 
108
117
  /**
109
- * Extract params from a pathname using a pattern's regex and param names
118
+ * Extract params from a pathname using a pattern's regex and param names.
119
+ *
120
+ * Values are URL-decoded so apps see the raw string (e.g. "ivo@example.com")
121
+ * instead of the percent-encoded form ("ivo%40example.com"). This matches the
122
+ * contract assumed by ctx.reverse (which re-encodes) and aligns with
123
+ * Express/React Router/Fastify/Koa.
110
124
  */
111
125
  export function extractParams(
112
126
  pathname: string,
@@ -118,7 +132,7 @@ export function extractParams(
118
132
 
119
133
  const params: Record<string, string> = {};
120
134
  for (let i = 0; i < paramNames.length; i++) {
121
- params[paramNames[i]] = match[i + 1] || "";
135
+ params[paramNames[i]] = safeDecodeURIComponent(match[i + 1] || "");
122
136
  }
123
137
  return params;
124
138
  }
@@ -142,7 +156,7 @@ export function createMiddlewareContext<TEnv>(
142
156
  search?: Record<string, unknown>,
143
157
  ) => string,
144
158
  ): MiddlewareContext<TEnv> {
145
- const url = new URL(request.url);
159
+ const url = stripInternalParams(new URL(request.url));
146
160
 
147
161
  // Track the initial response to detect pre/post-next() phase.
148
162
  // Before next(): responseHolder.response === initialResponse (the stub).
@@ -158,13 +172,37 @@ export function createMiddlewareContext<TEnv>(
158
172
  // Cookie operations are handled by the standalone cookies() function which
159
173
  // delegates to the shared RequestContext internally.
160
174
  // The runtime implementation - types are enforced at call sites via MiddlewareContext<TEnv>
175
+ // Internal helper: resolve the current response (stub before next(), real after).
176
+ // Not exposed on the public MiddlewareContext type — use ctx.headers instead.
177
+ const getResponse = (): Response => {
178
+ if (isPreNext()) {
179
+ const reqCtx = _getRequestContext();
180
+ if (reqCtx) return reqCtx.res;
181
+ }
182
+ if (!responseHolder.response) {
183
+ throw new Error(
184
+ "Response is not available - responseHolder was not initialized",
185
+ );
186
+ }
187
+ return responseHolder.response;
188
+ };
189
+
190
+ // Capture reqCtx once: the request-scoped platform fields
191
+ // (originalUrl, executionContext, waitUntil) are immutable per request,
192
+ // so snapshotting beats re-reading ALS on every access. The lazy getters
193
+ // below (routeName, theme, setTheme) stay lazy because those can change
194
+ // during `await next()`.
195
+ const reqCtx = _getRequestContext();
161
196
  return {
162
197
  request,
163
198
  url,
199
+ originalUrl: reqCtx?.originalUrl ?? new URL(request.url),
164
200
  pathname: url.pathname,
165
201
  searchParams: url.searchParams,
166
202
  env: env as MiddlewareContext<TEnv>["env"],
167
203
  params,
204
+ executionContext: reqCtx?.executionContext,
205
+ waitUntil: reqCtx ? reqCtx.waitUntil.bind(reqCtx) : fireAndForgetWaitUntil,
168
206
  // Getter: re-derives from request context on each access so that global
169
207
  // middleware sees the matched route name after await next().
170
208
  get routeName(): MiddlewareContext<TEnv>["routeName"] {
@@ -175,33 +213,16 @@ export function createMiddlewareContext<TEnv>(
175
213
  ) as MiddlewareContext<TEnv>["routeName"];
176
214
  },
177
215
 
178
- get res(): Response {
179
- // Before next(): return shared RequestContext stub so headers
180
- // set via ctx.header() are visible on ctx.res.
181
- if (isPreNext()) {
182
- const reqCtx = _getRequestContext();
183
- if (reqCtx) return reqCtx.res;
184
- }
185
- if (!responseHolder.response) {
186
- throw new Error(
187
- "ctx.res is not available - responseHolder was not initialized",
188
- );
189
- }
190
- return responseHolder.response;
191
- },
192
- set res(_: Response) {
193
- throw new Error(
194
- "ctx.res is read-only. Use ctx.header() to set response headers, or cookies() for cookie mutations.",
195
- );
216
+ get headers(): Headers {
217
+ return getResponse().headers;
196
218
  },
197
219
 
198
220
  get: ((keyOrVar: any) =>
199
221
  contextGet(variables, keyOrVar)) as MiddlewareContext<TEnv>["get"],
200
222
 
201
- set: ((keyOrVar: any, value: unknown) => {
202
- contextSet(variables, keyOrVar, value);
223
+ set: ((keyOrVar: any, value: unknown, options?: any) => {
224
+ contextSet(variables, keyOrVar, value, options);
203
225
  }) as MiddlewareContext<TEnv>["set"],
204
-
205
226
  header(name: string, value: string): void {
206
227
  // Before next(): delegate to shared RequestContext stub
207
228
  if (isPreNext()) {
@@ -220,6 +241,24 @@ export function createMiddlewareContext<TEnv>(
220
241
  responseHolder.response.headers.set(name, value);
221
242
  },
222
243
 
244
+ get theme(): MiddlewareContext<TEnv>["theme"] {
245
+ return _getRequestContext()?.theme;
246
+ },
247
+
248
+ get setTheme(): MiddlewareContext<TEnv>["setTheme"] {
249
+ return _getRequestContext()?.setTheme;
250
+ },
251
+
252
+ setLocationState(entries) {
253
+ const reqCtx = _getRequestContext();
254
+ if (!reqCtx) {
255
+ throw new Error(
256
+ "setLocationState() is not available outside a request context",
257
+ );
258
+ }
259
+ reqCtx.setLocationState(entries);
260
+ },
261
+
223
262
  reverse:
224
263
  reverse ??
225
264
  ((name: string) => {
@@ -227,6 +266,14 @@ export function createMiddlewareContext<TEnv>(
227
266
  `ctx.reverse() is not available - route map was not provided to middleware context`,
228
267
  );
229
268
  }),
269
+
270
+ debugPerformance(): void {
271
+ const reqCtx = _getRequestContext();
272
+ if (reqCtx) {
273
+ reqCtx._debugPerformance = true;
274
+ reqCtx._metricsStore ??= createMetricsStore(true);
275
+ }
276
+ },
230
277
  };
231
278
  }
232
279
 
@@ -260,14 +307,54 @@ export function matchMiddleware<TEnv>(
260
307
  return matches;
261
308
  }
262
309
 
310
+ // Set-Cookie is appended; for other headers stubOverridesNonCookie=true
311
+ // overwrites (chain ran to completion), false fills only missing slots (an
312
+ // explicit short-circuit Response's own headers win).
313
+ function mergeStubHeaders(
314
+ target: Headers,
315
+ stub: Headers,
316
+ stubOverridesNonCookie: boolean,
317
+ ): void {
318
+ stub.forEach((value, name) => {
319
+ if (name.toLowerCase() === "set-cookie") {
320
+ target.append(name, value);
321
+ } else if (stubOverridesNonCookie || !target.has(name)) {
322
+ target.set(name, value);
323
+ }
324
+ });
325
+ }
326
+
327
+ // Set-Cookie is deduped so a nested inner executeMiddleware that already merged
328
+ // the same reqCtx cookies does not duplicate them; other headers fill if missing.
329
+ function mergeReqCtxStub(
330
+ target: Headers,
331
+ reqCtx: ReturnType<typeof _getRequestContext>,
332
+ ): void {
333
+ if (!reqCtx) return;
334
+ const stubCookies = reqCtx.res.headers.getSetCookie();
335
+ if (stubCookies.length > 0) {
336
+ const existing = new Set(target.getSetCookie());
337
+ for (const cookie of stubCookies) {
338
+ if (!existing.has(cookie)) {
339
+ target.append("set-cookie", cookie);
340
+ }
341
+ }
342
+ }
343
+ reqCtx.res.headers.forEach((value, name) => {
344
+ if (name !== "set-cookie" && !target.has(name)) {
345
+ target.set(name, value);
346
+ }
347
+ });
348
+ }
349
+
263
350
  /**
264
351
  * Execute middleware chain
265
352
  *
266
353
  * Features:
267
354
  * - `await next()` returns actual Response
268
- * - `ctx.res` available after `await next()` (like Hono's `c.res`)
269
- * - `ctx.header()` shorthand for setting headers
270
- * - Forgiving: if middleware doesn't return, uses `ctx.res`
355
+ * - `ctx.headers` available before and after `await next()`
356
+ * - `ctx.header()` shorthand for setting a single header
357
+ * - Forgiving: if middleware doesn't return, uses the downstream response
271
358
  * - Short-circuit: return Response to stop chain
272
359
  * - Error catching: try/catch around `next()` works
273
360
  */
@@ -298,28 +385,13 @@ export async function executeMiddleware<TEnv>(
298
385
  // End of chain - call actual RSC handler
299
386
  const response = await finalHandler();
300
387
 
301
- // Merge headers set on stub into the real response.
302
- // Use append for Set-Cookie to preserve multiple cookies.
303
388
  const mergedHeaders = new Headers(response.headers);
304
- stubResponse.headers.forEach((value, name) => {
305
- if (name.toLowerCase() === "set-cookie") {
306
- mergedHeaders.append(name, value);
307
- } else {
308
- mergedHeaders.set(name, value);
309
- }
310
- });
311
- // Also merge shared RequestContext stub (cookies written via cookies().set()).
312
- // Set-Cookie duplication is prevented by createResponseWithMergedHeaders
313
- // draining Set-Cookie from ctx.res after merging (helpers.ts).
314
- const reqCtx = _getRequestContext();
315
- if (reqCtx) {
316
- reqCtx.res.headers.forEach((value, name) => {
317
- if (name.toLowerCase() === "set-cookie") {
318
- mergedHeaders.append(name, value);
319
- } else if (!mergedHeaders.has(name)) {
320
- mergedHeaders.set(name, value);
321
- }
322
- });
389
+ mergeStubHeaders(mergedHeaders, stubResponse.headers, true);
390
+ mergeReqCtxStub(mergedHeaders, _getRequestContext());
391
+
392
+ if (isWebSocketUpgradeResponse(response)) {
393
+ responseHolder.response = response;
394
+ return response;
323
395
  }
324
396
 
325
397
  // Clone response with merged headers (mutable for post-next() modifications)
@@ -332,6 +404,7 @@ export async function executeMiddleware<TEnv>(
332
404
  return responseHolder.response;
333
405
  }
334
406
 
407
+ const middlewareOrdinal = index;
335
408
  const { entry, params } = middlewares[index++];
336
409
  const ctx = createMiddlewareContext(
337
410
  request,
@@ -341,67 +414,92 @@ export async function executeMiddleware<TEnv>(
341
414
  responseHolder,
342
415
  reverse,
343
416
  );
417
+ const metricStart = performance.now();
418
+ const metricLabel = getMiddlewareMetricLabel(entry, middlewareOrdinal);
419
+ let middlewareFinished = false;
420
+ const finishMiddleware = () => {
421
+ if (!middlewareFinished) {
422
+ middlewareFinished = true;
423
+ appendMetric(
424
+ _getRequestContext()?._metricsStore,
425
+ `${metricLabel}:pre`,
426
+ metricStart,
427
+ performance.now() - metricStart,
428
+ MIDDLEWARE_METRIC_DEPTH,
429
+ );
430
+ }
431
+ };
344
432
 
345
433
  // Track if next() was called and capture its Promise.
346
434
  // Guard against double-calling: a second call would re-enter the
347
435
  // downstream chain and overwrite responseHolder.response.
348
436
  let nextPromise: Promise<Response> | null = null;
437
+ let nextResolvedAt: number | undefined;
349
438
  const wrappedNext = (): Promise<Response> => {
350
439
  if (nextPromise) {
351
440
  throw new Error(
352
441
  `[@rangojs/router] Middleware called next() more than once.`,
353
442
  );
354
443
  }
355
- nextPromise = next();
444
+ finishMiddleware();
445
+ const downstream = next();
446
+ nextPromise = downstream.then(
447
+ (res) => {
448
+ nextResolvedAt = performance.now();
449
+ return res;
450
+ },
451
+ (err) => {
452
+ nextResolvedAt = performance.now();
453
+ throw err;
454
+ },
455
+ );
356
456
  return nextPromise;
357
457
  };
358
458
 
359
- // W5: track whether ctx.set() is called during this middleware
360
- let ctxSetCalled = false;
361
- if (process.env.NODE_ENV !== "production") {
362
- const originalSet = ctx.set;
363
- ctx.set = ((...args: any[]) => {
364
- ctxSetCalled = true;
365
- return (originalSet as Function).apply(ctx, args);
366
- }) as typeof ctx.set;
459
+ let result: Response | void;
460
+ try {
461
+ result = await entry.handler(ctx, wrappedNext);
462
+ } catch (error) {
463
+ // Thrown Response is short-circuit control flow, not an error.
464
+ // Fall through to the `if (result instanceof Response)` branch below
465
+ // so stub headers and request-context cookies merge as they do for
466
+ // an explicit `return new Response(...)`. Real errors propagate.
467
+ if (error instanceof Response) {
468
+ result = error;
469
+ } else {
470
+ finishMiddleware();
471
+ throw error;
472
+ }
473
+ }
474
+ finishMiddleware();
475
+
476
+ // Record post-next() processing time when middleware did work after
477
+ // the downstream chain resolved (e.g. adding headers, logging).
478
+ if (nextResolvedAt !== undefined) {
479
+ const postDur = performance.now() - nextResolvedAt;
480
+ if (postDur > POST_METRIC_MIN_DURATION_MS) {
481
+ appendMetric(
482
+ _getRequestContext()?._metricsStore,
483
+ `${metricLabel}:post`,
484
+ nextResolvedAt,
485
+ postDur,
486
+ MIDDLEWARE_METRIC_DEPTH,
487
+ );
488
+ }
367
489
  }
368
-
369
- const result = await entry.handler(ctx, wrappedNext);
370
490
 
371
491
  // Explicit return takes precedence (middleware short-circuit).
372
492
  // Merge stub headers (from ctx.header before this point) and
373
493
  // RequestContext stub headers (from ctx.setCookie) into the
374
494
  // returned Response so they are not lost.
375
495
  if (result instanceof Response) {
376
- // W5: warn if ctx.set() was called but middleware returned a redirect
377
- if (
378
- process.env.NODE_ENV !== "production" &&
379
- ctxSetCalled &&
380
- result.status >= 300 &&
381
- result.status < 400
382
- ) {
383
- warnCtxSetBeforeRedirect(entry.handler);
496
+ if (isWebSocketUpgradeResponse(result)) {
497
+ responseHolder.response = result;
498
+ return result;
384
499
  }
385
-
386
500
  const mergedHeaders = new Headers(result.headers);
387
- stubResponse.headers.forEach((value, name) => {
388
- if (name.toLowerCase() === "set-cookie") {
389
- mergedHeaders.append(name, value);
390
- } else if (!mergedHeaders.has(name)) {
391
- mergedHeaders.set(name, value);
392
- }
393
- });
394
- // Also merge shared RequestContext stub (cookies written via setCookie)
395
- const reqCtx = _getRequestContext();
396
- if (reqCtx) {
397
- reqCtx.res.headers.forEach((value, name) => {
398
- if (name.toLowerCase() === "set-cookie") {
399
- mergedHeaders.append(name, value);
400
- } else if (!mergedHeaders.has(name)) {
401
- mergedHeaders.set(name, value);
402
- }
403
- });
404
- }
501
+ mergeStubHeaders(mergedHeaders, stubResponse.headers, false);
502
+ mergeReqCtxStub(mergedHeaders, _getRequestContext());
405
503
  const merged = new Response(result.body, {
406
504
  status: result.status,
407
505
  statusText: result.statusText,
@@ -424,19 +522,6 @@ export async function executeMiddleware<TEnv>(
424
522
  // If middleware called next(), await it and return the response
425
523
  if (nextPromise) {
426
524
  await nextPromise;
427
-
428
- // W5: warn if ctx.set() was called but the downstream response is a redirect.
429
- // The ctx.set() values will be lost because the redirect navigates away.
430
- if (
431
- process.env.NODE_ENV !== "production" &&
432
- ctxSetCalled &&
433
- responseHolder.response &&
434
- responseHolder.response.status >= 300 &&
435
- responseHolder.response.status < 400
436
- ) {
437
- warnCtxSetBeforeRedirect(entry.handler);
438
- }
439
-
440
525
  return responseHolder.response!;
441
526
  }
442
527
 
@@ -459,6 +544,18 @@ export async function executeMiddleware<TEnv>(
459
544
  throw new Error("No response generated by middleware chain");
460
545
  }
461
546
 
547
+ // Final re-merge: capture any RequestContext stub headers added after the
548
+ // last merge point (e.g. cookies().set() called after await next()).
549
+ // The reqCtx stub may have already been partially merged during finalHandler
550
+ // or early-return paths; only append *new* Set-Cookie entries to avoid dupes.
551
+ //
552
+ // Skip for upgrade responses: upgrade headers are semantically immutable and
553
+ // set-cookie on an upgrade is not meaningful.
554
+ const reqCtx = _getRequestContext();
555
+ if (reqCtx && !isWebSocketUpgradeResponse(finalResponse)) {
556
+ mergeReqCtxStub(finalResponse.headers, reqCtx);
557
+ }
558
+
462
559
  return finalResponse;
463
560
  }
464
561
 
@@ -526,7 +623,18 @@ export async function executeInterceptMiddleware<TEnv>(
526
623
  return next();
527
624
  };
528
625
 
529
- const result = await middleware(ctx, guardedNext);
626
+ let result: Response | void;
627
+ try {
628
+ result = await middleware(ctx, guardedNext);
629
+ } catch (error) {
630
+ // Thrown Response is short-circuit control flow, parity with the
631
+ // explicit-return path below. Real errors propagate.
632
+ if (error instanceof Response) {
633
+ result = error;
634
+ } else {
635
+ throw error;
636
+ }
637
+ }
530
638
 
531
639
  if (result instanceof Response) {
532
640
  earlyResponse = result;
@@ -554,13 +662,7 @@ export async function executeInterceptMiddleware<TEnv>(
554
662
  // Only fill in missing headers — the returned Response's explicit
555
663
  // headers take precedence, matching executeMiddleware behavior.
556
664
  const mergedHeaders = new Headers(response.headers);
557
- stubResponse.headers.forEach((value, name) => {
558
- if (name.toLowerCase() === "set-cookie") {
559
- mergedHeaders.append(name, value);
560
- } else if (!mergedHeaders.has(name)) {
561
- mergedHeaders.set(name, value);
562
- }
563
- });
665
+ mergeStubHeaders(mergedHeaders, stubResponse.headers, false);
564
666
  return new Response(response.body, {
565
667
  status: response.status,
566
668
  statusText: response.statusText,