@rangojs/router 0.0.0-experimental.259 → 0.0.0-experimental.26

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 (225) hide show
  1. package/README.md +294 -28
  2. package/dist/bin/rango.js +355 -47
  3. package/dist/vite/index.js +1658 -1239
  4. package/package.json +3 -3
  5. package/skills/cache-guide/SKILL.md +9 -5
  6. package/skills/caching/SKILL.md +4 -4
  7. package/skills/document-cache/SKILL.md +2 -2
  8. package/skills/hooks/SKILL.md +40 -29
  9. package/skills/host-router/SKILL.md +218 -0
  10. package/skills/intercept/SKILL.md +79 -0
  11. package/skills/layout/SKILL.md +62 -2
  12. package/skills/loader/SKILL.md +229 -15
  13. package/skills/middleware/SKILL.md +109 -30
  14. package/skills/parallel/SKILL.md +57 -2
  15. package/skills/prerender/SKILL.md +189 -19
  16. package/skills/rango/SKILL.md +1 -2
  17. package/skills/response-routes/SKILL.md +3 -3
  18. package/skills/route/SKILL.md +44 -3
  19. package/skills/router-setup/SKILL.md +80 -3
  20. package/skills/theme/SKILL.md +5 -4
  21. package/skills/typesafety/SKILL.md +59 -16
  22. package/skills/use-cache/SKILL.md +16 -2
  23. package/src/__internal.ts +1 -1
  24. package/src/bin/rango.ts +56 -19
  25. package/src/browser/action-coordinator.ts +97 -0
  26. package/src/browser/event-controller.ts +29 -48
  27. package/src/browser/history-state.ts +80 -0
  28. package/src/browser/intercept-utils.ts +1 -1
  29. package/src/browser/link-interceptor.ts +19 -3
  30. package/src/browser/merge-segment-loaders.ts +9 -2
  31. package/src/browser/navigation-bridge.ts +66 -443
  32. package/src/browser/navigation-client.ts +34 -62
  33. package/src/browser/navigation-store.ts +4 -33
  34. package/src/browser/navigation-transaction.ts +295 -0
  35. package/src/browser/partial-update.ts +103 -151
  36. package/src/browser/prefetch/cache.ts +67 -0
  37. package/src/browser/prefetch/fetch.ts +137 -0
  38. package/src/browser/prefetch/observer.ts +65 -0
  39. package/src/browser/prefetch/policy.ts +42 -0
  40. package/src/browser/prefetch/queue.ts +88 -0
  41. package/src/browser/rango-state.ts +112 -0
  42. package/src/browser/react/Link.tsx +154 -44
  43. package/src/browser/react/NavigationProvider.tsx +32 -0
  44. package/src/browser/react/context.ts +6 -0
  45. package/src/browser/react/filter-segment-order.ts +11 -0
  46. package/src/browser/react/index.ts +2 -6
  47. package/src/browser/react/location-state-shared.ts +29 -11
  48. package/src/browser/react/location-state.ts +6 -4
  49. package/src/browser/react/nonce-context.ts +23 -0
  50. package/src/browser/react/shallow-equal.ts +27 -0
  51. package/src/browser/react/use-action.ts +23 -45
  52. package/src/browser/react/use-client-cache.ts +5 -3
  53. package/src/browser/react/use-handle.ts +21 -64
  54. package/src/browser/react/use-navigation.ts +7 -32
  55. package/src/browser/react/use-params.ts +5 -34
  56. package/src/browser/react/use-pathname.ts +2 -3
  57. package/src/browser/react/use-router.ts +3 -6
  58. package/src/browser/react/use-search-params.ts +2 -1
  59. package/src/browser/react/use-segments.ts +75 -114
  60. package/src/browser/response-adapter.ts +73 -0
  61. package/src/browser/rsc-router.tsx +46 -22
  62. package/src/browser/scroll-restoration.ts +10 -7
  63. package/src/browser/server-action-bridge.ts +458 -405
  64. package/src/browser/types.ts +21 -35
  65. package/src/browser/validate-redirect-origin.ts +29 -0
  66. package/src/build/generate-manifest.ts +38 -13
  67. package/src/build/generate-route-types.ts +4 -0
  68. package/src/build/index.ts +1 -0
  69. package/src/build/route-trie.ts +19 -3
  70. package/src/build/route-types/codegen.ts +13 -4
  71. package/src/build/route-types/include-resolution.ts +13 -0
  72. package/src/build/route-types/per-module-writer.ts +15 -3
  73. package/src/build/route-types/router-processing.ts +170 -18
  74. package/src/build/runtime-discovery.ts +13 -1
  75. package/src/cache/background-task.ts +34 -0
  76. package/src/cache/cache-key-utils.ts +44 -0
  77. package/src/cache/cache-policy.ts +125 -0
  78. package/src/cache/cache-runtime.ts +136 -123
  79. package/src/cache/cache-scope.ts +76 -83
  80. package/src/cache/cf/cf-cache-store.ts +12 -7
  81. package/src/cache/document-cache.ts +93 -69
  82. package/src/cache/handle-capture.ts +81 -0
  83. package/src/cache/index.ts +0 -15
  84. package/src/cache/memory-segment-store.ts +43 -69
  85. package/src/cache/profile-registry.ts +43 -8
  86. package/src/cache/read-through-swr.ts +134 -0
  87. package/src/cache/segment-codec.ts +140 -117
  88. package/src/cache/taint.ts +30 -3
  89. package/src/cache/types.ts +1 -115
  90. package/src/client.rsc.tsx +0 -1
  91. package/src/client.tsx +53 -76
  92. package/src/errors.ts +6 -1
  93. package/src/handle.ts +1 -1
  94. package/src/handles/MetaTags.tsx +5 -2
  95. package/src/host/cookie-handler.ts +8 -3
  96. package/src/host/index.ts +0 -3
  97. package/src/host/router.ts +14 -1
  98. package/src/href-client.ts +3 -1
  99. package/src/index.rsc.ts +53 -10
  100. package/src/index.ts +73 -43
  101. package/src/loader.rsc.ts +12 -4
  102. package/src/loader.ts +8 -0
  103. package/src/prerender/store.ts +60 -18
  104. package/src/prerender.ts +76 -18
  105. package/src/reverse.ts +11 -7
  106. package/src/root-error-boundary.tsx +30 -26
  107. package/src/route-definition/dsl-helpers.ts +9 -6
  108. package/src/route-definition/index.ts +0 -3
  109. package/src/route-definition/redirect.ts +15 -3
  110. package/src/route-map-builder.ts +38 -2
  111. package/src/route-name.ts +53 -0
  112. package/src/route-types.ts +7 -0
  113. package/src/router/content-negotiation.ts +1 -1
  114. package/src/router/debug-manifest.ts +16 -3
  115. package/src/router/handler-context.ts +96 -17
  116. package/src/router/intercept-resolution.ts +6 -4
  117. package/src/router/lazy-includes.ts +4 -0
  118. package/src/router/loader-resolution.ts +6 -11
  119. package/src/router/logging.ts +100 -3
  120. package/src/router/manifest.ts +32 -3
  121. package/src/router/match-api.ts +62 -54
  122. package/src/router/match-context.ts +3 -0
  123. package/src/router/match-handlers.ts +185 -11
  124. package/src/router/match-middleware/background-revalidation.ts +65 -85
  125. package/src/router/match-middleware/cache-lookup.ts +78 -10
  126. package/src/router/match-middleware/cache-store.ts +2 -0
  127. package/src/router/match-pipelines.ts +8 -43
  128. package/src/router/match-result.ts +0 -9
  129. package/src/router/metrics.ts +233 -13
  130. package/src/router/middleware-types.ts +34 -39
  131. package/src/router/middleware.ts +290 -130
  132. package/src/router/pattern-matching.ts +61 -10
  133. package/src/router/prerender-match.ts +36 -6
  134. package/src/router/preview-match.ts +7 -1
  135. package/src/router/revalidation.ts +61 -2
  136. package/src/router/router-context.ts +15 -0
  137. package/src/router/router-interfaces.ts +158 -40
  138. package/src/router/router-options.ts +223 -1
  139. package/src/router/router-registry.ts +5 -2
  140. package/src/router/segment-resolution/fresh.ts +165 -242
  141. package/src/router/segment-resolution/helpers.ts +263 -0
  142. package/src/router/segment-resolution/loader-cache.ts +102 -98
  143. package/src/router/segment-resolution/revalidation.ts +394 -272
  144. package/src/router/segment-resolution/static-store.ts +2 -2
  145. package/src/router/segment-resolution.ts +1 -3
  146. package/src/router/segment-wrappers.ts +3 -0
  147. package/src/router/telemetry-otel.ts +299 -0
  148. package/src/router/telemetry.ts +300 -0
  149. package/src/router/timeout.ts +148 -0
  150. package/src/router/trie-matching.ts +20 -2
  151. package/src/router/types.ts +7 -1
  152. package/src/router.ts +203 -18
  153. package/src/rsc/handler-context.ts +13 -2
  154. package/src/rsc/handler.ts +489 -438
  155. package/src/rsc/helpers.ts +125 -5
  156. package/src/rsc/index.ts +0 -20
  157. package/src/rsc/loader-fetch.ts +84 -42
  158. package/src/rsc/manifest-init.ts +3 -2
  159. package/src/rsc/origin-guard.ts +141 -0
  160. package/src/rsc/progressive-enhancement.ts +245 -19
  161. package/src/rsc/response-route-handler.ts +347 -0
  162. package/src/rsc/rsc-rendering.ts +47 -43
  163. package/src/rsc/runtime-warnings.ts +42 -0
  164. package/src/rsc/server-action.ts +166 -66
  165. package/src/rsc/ssr-setup.ts +128 -0
  166. package/src/rsc/types.ts +20 -2
  167. package/src/search-params.ts +38 -23
  168. package/src/server/context.ts +61 -7
  169. package/src/server/cookie-store.ts +190 -0
  170. package/src/server/fetchable-loader-store.ts +11 -6
  171. package/src/server/handle-store.ts +84 -12
  172. package/src/server/loader-registry.ts +11 -46
  173. package/src/server/request-context.ts +275 -49
  174. package/src/server.ts +6 -0
  175. package/src/ssr/index.tsx +67 -28
  176. package/src/static-handler.ts +7 -0
  177. package/src/theme/ThemeProvider.tsx +6 -1
  178. package/src/theme/index.ts +4 -18
  179. package/src/theme/theme-context.ts +1 -28
  180. package/src/theme/theme-script.ts +2 -1
  181. package/src/types/cache-types.ts +6 -1
  182. package/src/types/error-types.ts +3 -0
  183. package/src/types/global-namespace.ts +22 -0
  184. package/src/types/handler-context.ts +103 -16
  185. package/src/types/index.ts +1 -1
  186. package/src/types/loader-types.ts +9 -6
  187. package/src/types/route-config.ts +17 -26
  188. package/src/types/route-entry.ts +28 -0
  189. package/src/types/segments.ts +0 -5
  190. package/src/urls/include-helper.ts +49 -8
  191. package/src/urls/index.ts +1 -0
  192. package/src/urls/path-helper-types.ts +30 -12
  193. package/src/urls/path-helper.ts +17 -2
  194. package/src/urls/pattern-types.ts +21 -1
  195. package/src/urls/response-types.ts +29 -7
  196. package/src/urls/type-extraction.ts +23 -15
  197. package/src/use-loader.tsx +27 -9
  198. package/src/vite/discovery/bundle-postprocess.ts +32 -52
  199. package/src/vite/discovery/discover-routers.ts +52 -26
  200. package/src/vite/discovery/prerender-collection.ts +58 -41
  201. package/src/vite/discovery/route-types-writer.ts +7 -7
  202. package/src/vite/discovery/state.ts +7 -7
  203. package/src/vite/discovery/virtual-module-codegen.ts +5 -2
  204. package/src/vite/index.ts +10 -51
  205. package/src/vite/plugins/client-ref-dedup.ts +115 -0
  206. package/src/vite/plugins/client-ref-hashing.ts +3 -3
  207. package/src/vite/plugins/expose-internal-ids.ts +4 -3
  208. package/src/vite/plugins/refresh-cmd.ts +65 -0
  209. package/src/vite/plugins/use-cache-transform.ts +91 -3
  210. package/src/vite/plugins/version-plugin.ts +188 -18
  211. package/src/vite/rango.ts +61 -36
  212. package/src/vite/router-discovery.ts +173 -100
  213. package/src/vite/utils/prerender-utils.ts +81 -0
  214. package/src/vite/utils/shared-utils.ts +19 -9
  215. package/skills/testing/SKILL.md +0 -226
  216. package/src/browser/lru-cache.ts +0 -61
  217. package/src/browser/react/prefetch.ts +0 -27
  218. package/src/browser/request-controller.ts +0 -164
  219. package/src/cache/memory-store.ts +0 -253
  220. package/src/href-context.ts +0 -33
  221. package/src/route-definition/route-function.ts +0 -119
  222. package/src/router.gen.ts +0 -6
  223. package/src/static-handler.gen.ts +0 -5
  224. package/src/urls.gen.ts +0 -8
  225. /package/{CLAUDE.md → AGENTS.md} +0 -0
@@ -11,7 +11,6 @@
11
11
 
12
12
  import { contextGet, contextSet } from "../context-var.js";
13
13
  import type {
14
- CookieOptions,
15
14
  CollectedMiddleware,
16
15
  MiddlewareCollectableEntry,
17
16
  MiddlewareContext,
@@ -19,7 +18,9 @@ import type {
19
18
  MiddlewareFn,
20
19
  ResponseHolder,
21
20
  } from "./middleware-types.js";
22
- import { parseCookies, serializeCookie } from "./middleware-cookies.js";
21
+ import { _getRequestContext } from "../server/request-context.js";
22
+ import { isAutoGeneratedRouteName } from "../route-name.js";
23
+ import { appendMetric, createMetricsStore } from "./metrics.js";
23
24
 
24
25
  // Re-export types and cookie utilities for backward compatibility
25
26
  export type {
@@ -33,6 +34,52 @@ export type {
33
34
  } from "./middleware-types.js";
34
35
  export { parseCookies, serializeCookie } from "./middleware-cookies.js";
35
36
 
37
+ // W5: Deduplicate by function reference so each distinct middleware warns once,
38
+ // regardless of whether it is named or anonymous.
39
+ let warnedRedirectMiddleware = new WeakSet<Function>();
40
+
41
+ function warnCtxSetBeforeRedirect(handler: Function): void {
42
+ if (warnedRedirectMiddleware.has(handler)) return;
43
+ warnedRedirectMiddleware.add(handler);
44
+ const label = handler.name || "(anonymous)";
45
+ console.warn(
46
+ `[rango] Route middleware "${label}" called ctx.set() then returned a ` +
47
+ `redirect. Context variables are per-request and won't be available ` +
48
+ `on the redirect target. Use cookies to persist state across ` +
49
+ `redirects, or move ctx.set() to the target route's middleware.`,
50
+ );
51
+ }
52
+
53
+ const MIDDLEWARE_METRIC_DEPTH = 1;
54
+ /** Ignore post-next() durations below this threshold (measurement noise). */
55
+ const POST_METRIC_MIN_DURATION_MS = 0.01;
56
+
57
+ function getMiddlewareMetricBase<TEnv>(
58
+ entry: MiddlewareEntry<TEnv>,
59
+ ordinal: number,
60
+ ): string {
61
+ const handlerName = entry.handler.name?.trim();
62
+ const scope = entry.pattern ?? "*";
63
+
64
+ if (handlerName) {
65
+ return `${handlerName}@${scope}`;
66
+ }
67
+
68
+ return `${scope}#${ordinal + 1}`;
69
+ }
70
+
71
+ function getMiddlewareMetricLabel<TEnv>(
72
+ entry: MiddlewareEntry<TEnv>,
73
+ ordinal: number,
74
+ ): string {
75
+ return `middleware:${getMiddlewareMetricBase(entry, ordinal)}`;
76
+ }
77
+
78
+ /** Reset W5 deduplication state (for tests only). */
79
+ export function _resetW5Warnings(): void {
80
+ warnedRedirectMiddleware = new WeakSet();
81
+ }
82
+
36
83
  /**
37
84
  * Parse a route pattern into regex and param names
38
85
  * Supports: *, /path, /path/*, /path/:param, /path/:param/*
@@ -107,7 +154,7 @@ export function extractParams(
107
154
  *
108
155
  * Note: The implementation uses runtime values while the interface provides
109
156
  * compile-time type safety. The env/get/set types are resolved at call sites
110
- * via conditional types based on TEnv extending RouterEnv.
157
+ * via conditional types based on TEnv from createRouter<TBindings>().
111
158
  */
112
159
  export function createMiddlewareContext<TEnv>(
113
160
  request: Request,
@@ -122,9 +169,20 @@ export function createMiddlewareContext<TEnv>(
122
169
  ) => string,
123
170
  ): MiddlewareContext<TEnv> {
124
171
  const url = new URL(request.url);
125
- const cookieHeader = request.headers.get("Cookie");
126
- let parsedCookies: Record<string, string> | null = null;
127
172
 
173
+ // Track the initial response to detect pre/post-next() phase.
174
+ // Before next(): responseHolder.response === initialResponse (the stub).
175
+ // After next(): responseHolder.response is the real downstream response.
176
+ const initialResponse = responseHolder.response;
177
+ const isPreNext = () => responseHolder.response === initialResponse;
178
+
179
+ // Delegation strategy for RequestContext (reqCtx):
180
+ // - res getter: before next() returns shared reqCtx stub; after next() returns
181
+ // the real downstream response.
182
+ // - header(): before next() delegates to reqCtx; after next() writes to the
183
+ // real downstream response.
184
+ // Cookie operations are handled by the standalone cookies() function which
185
+ // delegates to the shared RequestContext internally.
128
186
  // The runtime implementation - types are enforced at call sites via MiddlewareContext<TEnv>
129
187
  return {
130
188
  request,
@@ -133,9 +191,23 @@ export function createMiddlewareContext<TEnv>(
133
191
  searchParams: url.searchParams,
134
192
  env: env as MiddlewareContext<TEnv>["env"],
135
193
  params,
194
+ // Getter: re-derives from request context on each access so that global
195
+ // middleware sees the matched route name after await next().
196
+ get routeName(): MiddlewareContext<TEnv>["routeName"] {
197
+ const reqCtx = _getRequestContext();
198
+ const raw = reqCtx?._routeName;
199
+ return (
200
+ raw && !isAutoGeneratedRouteName(raw) ? raw : undefined
201
+ ) as MiddlewareContext<TEnv>["routeName"];
202
+ },
136
203
 
137
- // res getter - returns the stub or real response (always available)
138
204
  get res(): Response {
205
+ // Before next(): return shared RequestContext stub so headers
206
+ // set via ctx.header() are visible on ctx.res.
207
+ if (isPreNext()) {
208
+ const reqCtx = _getRequestContext();
209
+ if (reqCtx) return reqCtx.res;
210
+ }
139
211
  if (!responseHolder.response) {
140
212
  throw new Error(
141
213
  "ctx.res is not available - responseHolder was not initialized",
@@ -143,50 +215,9 @@ export function createMiddlewareContext<TEnv>(
143
215
  }
144
216
  return responseHolder.response;
145
217
  },
146
-
147
- // res setter - allows middleware to replace the response
148
- set res(response: Response) {
149
- responseHolder.response = response;
150
- },
151
-
152
- cookie(name: string): string | undefined {
153
- if (!parsedCookies) {
154
- parsedCookies = parseCookies(cookieHeader);
155
- }
156
- return parsedCookies[name];
157
- },
158
-
159
- cookies(): Record<string, string> {
160
- if (!parsedCookies) {
161
- parsedCookies = parseCookies(cookieHeader);
162
- }
163
- return { ...parsedCookies };
164
- },
165
-
166
- setCookie(name: string, value: string, options?: CookieOptions): void {
167
- if (!responseHolder.response) {
168
- throw new Error(
169
- "ctx.setCookie() is not available - responseHolder was not initialized",
170
- );
171
- }
172
- responseHolder.response.headers.append(
173
- "Set-Cookie",
174
- serializeCookie(name, value, options),
175
- );
176
- },
177
-
178
- deleteCookie(
179
- name: string,
180
- options?: Pick<CookieOptions, "domain" | "path">,
181
- ): void {
182
- if (!responseHolder.response) {
183
- throw new Error(
184
- "ctx.deleteCookie() is not available - responseHolder was not initialized",
185
- );
186
- }
187
- responseHolder.response.headers.append(
188
- "Set-Cookie",
189
- serializeCookie(name, "", { ...options, maxAge: 0 }),
218
+ set res(_: Response) {
219
+ throw new Error(
220
+ "ctx.res is read-only. Use ctx.header() to set response headers, or cookies() for cookie mutations.",
190
221
  );
191
222
  },
192
223
 
@@ -198,6 +229,15 @@ export function createMiddlewareContext<TEnv>(
198
229
  }) as MiddlewareContext<TEnv>["set"],
199
230
 
200
231
  header(name: string, value: string): void {
232
+ // Before next(): delegate to shared RequestContext stub
233
+ if (isPreNext()) {
234
+ const reqCtx = _getRequestContext();
235
+ if (reqCtx) {
236
+ reqCtx.header(name, value);
237
+ return;
238
+ }
239
+ }
240
+ // After next() or standalone: write to current response
201
241
  if (!responseHolder.response) {
202
242
  throw new Error(
203
243
  "ctx.header() is not available - responseHolder was not initialized",
@@ -213,6 +253,14 @@ export function createMiddlewareContext<TEnv>(
213
253
  `ctx.reverse() is not available - route map was not provided to middleware context`,
214
254
  );
215
255
  }),
256
+
257
+ debugPerformance(): void {
258
+ const reqCtx = _getRequestContext();
259
+ if (reqCtx) {
260
+ reqCtx._debugPerformance = true;
261
+ reqCtx._metricsStore ??= createMetricsStore(true);
262
+ }
263
+ },
216
264
  };
217
265
  }
218
266
 
@@ -284,8 +332,8 @@ export async function executeMiddleware<TEnv>(
284
332
  // End of chain - call actual RSC handler
285
333
  const response = await finalHandler();
286
334
 
287
- // Merge headers set on stub into the real response
288
- // Use append for Set-Cookie to preserve multiple cookies
335
+ // Merge headers set on stub into the real response.
336
+ // Use append for Set-Cookie to preserve multiple cookies.
289
337
  const mergedHeaders = new Headers(response.headers);
290
338
  stubResponse.headers.forEach((value, name) => {
291
339
  if (name.toLowerCase() === "set-cookie") {
@@ -294,6 +342,26 @@ export async function executeMiddleware<TEnv>(
294
342
  mergedHeaders.set(name, value);
295
343
  }
296
344
  });
345
+ // Also merge shared RequestContext stub (cookies written via cookies().set()).
346
+ // Dedup Set-Cookie: an inner executeMiddleware (route-level middleware)
347
+ // may have already merged the same reqCtx cookies into the response.
348
+ const reqCtx = _getRequestContext();
349
+ if (reqCtx) {
350
+ const stubCookies = reqCtx.res.headers.getSetCookie();
351
+ if (stubCookies.length > 0) {
352
+ const existing = new Set(mergedHeaders.getSetCookie());
353
+ for (const cookie of stubCookies) {
354
+ if (!existing.has(cookie)) {
355
+ mergedHeaders.append("set-cookie", cookie);
356
+ }
357
+ }
358
+ }
359
+ reqCtx.res.headers.forEach((value, name) => {
360
+ if (name !== "set-cookie" && !mergedHeaders.has(name)) {
361
+ mergedHeaders.set(name, value);
362
+ }
363
+ });
364
+ }
297
365
 
298
366
  // Clone response with merged headers (mutable for post-next() modifications)
299
367
  responseHolder.response = new Response(response.body, {
@@ -305,6 +373,7 @@ export async function executeMiddleware<TEnv>(
305
373
  return responseHolder.response;
306
374
  }
307
375
 
376
+ const middlewareOrdinal = index;
308
377
  const { entry, params } = middlewares[index++];
309
378
  const ctx = createMiddlewareContext(
310
379
  request,
@@ -314,21 +383,132 @@ export async function executeMiddleware<TEnv>(
314
383
  responseHolder,
315
384
  reverse,
316
385
  );
386
+ const metricStart = performance.now();
387
+ const metricLabel = getMiddlewareMetricLabel(entry, middlewareOrdinal);
388
+ let middlewareFinished = false;
389
+ const finishMiddleware = () => {
390
+ if (!middlewareFinished) {
391
+ middlewareFinished = true;
392
+ appendMetric(
393
+ _getRequestContext()?._metricsStore,
394
+ `${metricLabel}:pre`,
395
+ metricStart,
396
+ performance.now() - metricStart,
397
+ MIDDLEWARE_METRIC_DEPTH,
398
+ );
399
+ }
400
+ };
317
401
 
318
- // Track if next() was called and capture its Promise
319
- // This handles the case where middleware calls next() synchronously without await
402
+ // Track if next() was called and capture its Promise.
403
+ // Guard against double-calling: a second call would re-enter the
404
+ // downstream chain and overwrite responseHolder.response.
320
405
  let nextPromise: Promise<Response> | null = null;
406
+ let nextResolvedAt: number | undefined;
321
407
  const wrappedNext = (): Promise<Response> => {
322
- nextPromise = next();
408
+ if (nextPromise) {
409
+ throw new Error(
410
+ `[@rangojs/router] Middleware called next() more than once.`,
411
+ );
412
+ }
413
+ finishMiddleware();
414
+ const downstream = next();
415
+ nextPromise = downstream.then(
416
+ (res) => {
417
+ nextResolvedAt = performance.now();
418
+ return res;
419
+ },
420
+ (err) => {
421
+ nextResolvedAt = performance.now();
422
+ throw err;
423
+ },
424
+ );
323
425
  return nextPromise;
324
426
  };
325
427
 
326
- const result = await entry.handler(ctx, wrappedNext);
428
+ // W5: track whether ctx.set() is called during this middleware
429
+ let ctxSetCalled = false;
430
+ if (process.env.NODE_ENV !== "production") {
431
+ const originalSet = ctx.set;
432
+ ctx.set = ((...args: any[]) => {
433
+ ctxSetCalled = true;
434
+ return (originalSet as Function).apply(ctx, args);
435
+ }) as typeof ctx.set;
436
+ }
437
+
438
+ let result: Response | void;
439
+ try {
440
+ result = await entry.handler(ctx, wrappedNext);
441
+ } catch (error) {
442
+ finishMiddleware();
443
+ throw error;
444
+ }
445
+ finishMiddleware();
446
+
447
+ // Record post-next() processing time when middleware did work after
448
+ // the downstream chain resolved (e.g. adding headers, logging).
449
+ if (nextResolvedAt !== undefined) {
450
+ const postDur = performance.now() - nextResolvedAt;
451
+ if (postDur > POST_METRIC_MIN_DURATION_MS) {
452
+ appendMetric(
453
+ _getRequestContext()?._metricsStore,
454
+ `${metricLabel}:post`,
455
+ nextResolvedAt,
456
+ postDur,
457
+ MIDDLEWARE_METRIC_DEPTH,
458
+ );
459
+ }
460
+ }
327
461
 
328
- // Explicit return takes precedence
462
+ // Explicit return takes precedence (middleware short-circuit).
463
+ // Merge stub headers (from ctx.header before this point) and
464
+ // RequestContext stub headers (from ctx.setCookie) into the
465
+ // returned Response so they are not lost.
329
466
  if (result instanceof Response) {
330
- responseHolder.response = result;
331
- return result;
467
+ // W5: warn if ctx.set() was called but middleware returned a redirect
468
+ if (
469
+ process.env.NODE_ENV !== "production" &&
470
+ ctxSetCalled &&
471
+ result.status >= 300 &&
472
+ result.status < 400
473
+ ) {
474
+ warnCtxSetBeforeRedirect(entry.handler);
475
+ }
476
+
477
+ const mergedHeaders = new Headers(result.headers);
478
+ stubResponse.headers.forEach((value, name) => {
479
+ if (name.toLowerCase() === "set-cookie") {
480
+ mergedHeaders.append(name, value);
481
+ } else if (!mergedHeaders.has(name)) {
482
+ mergedHeaders.set(name, value);
483
+ }
484
+ });
485
+ // Also merge shared RequestContext stub (cookies written via setCookie).
486
+ // Dedup Set-Cookie: an inner executeMiddleware (route-level middleware)
487
+ // may have already merged the same reqCtx cookies into the response.
488
+ const reqCtx = _getRequestContext();
489
+ if (reqCtx) {
490
+ const stubCookies = reqCtx.res.headers.getSetCookie();
491
+ if (stubCookies.length > 0) {
492
+ const existing = new Set(mergedHeaders.getSetCookie());
493
+ for (const cookie of stubCookies) {
494
+ if (!existing.has(cookie)) {
495
+ mergedHeaders.append("set-cookie", cookie);
496
+ }
497
+ }
498
+ }
499
+ reqCtx.res.headers.forEach((value, name) => {
500
+ if (name !== "set-cookie" && !mergedHeaders.has(name)) {
501
+ mergedHeaders.set(name, value);
502
+ }
503
+ });
504
+ }
505
+ const merged = new Response(result.body, {
506
+ status: result.status,
507
+ statusText: result.statusText,
508
+ headers: mergedHeaders,
509
+ });
510
+ responseHolder.response = merged;
511
+ return merged;
332
512
  }
333
513
 
334
514
  // Warn about unexpected return values (non-Response, non-undefined)
@@ -344,6 +524,19 @@ export async function executeMiddleware<TEnv>(
344
524
  // If middleware called next(), await it and return the response
345
525
  if (nextPromise) {
346
526
  await nextPromise;
527
+
528
+ // W5: warn if ctx.set() was called but the downstream response is a redirect.
529
+ // The ctx.set() values will be lost because the redirect navigates away.
530
+ if (
531
+ process.env.NODE_ENV !== "production" &&
532
+ ctxSetCalled &&
533
+ responseHolder.response &&
534
+ responseHolder.response.status >= 300 &&
535
+ responseHolder.response.status < 400
536
+ ) {
537
+ warnCtxSetBeforeRedirect(entry.handler);
538
+ }
539
+
347
540
  return responseHolder.response!;
348
541
  }
349
542
 
@@ -366,70 +559,30 @@ export async function executeMiddleware<TEnv>(
366
559
  throw new Error("No response generated by middleware chain");
367
560
  }
368
561
 
369
- return finalResponse;
370
- }
371
-
372
- /**
373
- * Execute middleware for server actions
374
- *
375
- * Server actions can't return Response directly, but headers/cookies set
376
- * on ctx.res (from getRequestContext().res) will be merged into the final response.
377
- *
378
- * - Runs middleware for auth checks, variable setting, headers, cookies
379
- * - Throws if middleware returns Response (can't short-circuit server action)
380
- */
381
- export async function executeServerActionMiddleware<TEnv>(
382
- middlewares: MiddlewareFn<TEnv>[],
383
- request: Request,
384
- env: TEnv,
385
- params: Record<string, string>,
386
- variables: Record<string, any>,
387
- stubResponse: Response,
388
- reverse?: (
389
- name: string,
390
- params?: Record<string, string>,
391
- search?: Record<string, unknown>,
392
- ) => string,
393
- ): Promise<void> {
394
- if (middlewares.length === 0) {
395
- return;
396
- }
397
-
398
- let index = 0;
399
- const responseHolder: ResponseHolder = { response: stubResponse };
400
-
401
- const next = async (): Promise<Response> => {
402
- if (index >= middlewares.length) {
403
- return stubResponse;
404
- }
405
-
406
- const middleware = middlewares[index++];
407
- const ctx = createMiddlewareContext(
408
- request,
409
- env,
410
- params,
411
- variables,
412
- responseHolder,
413
- reverse,
414
- );
415
-
416
- const result = await middleware(ctx, next);
417
-
418
- // If middleware returned a Response, throw an error
419
- // Server actions can't short-circuit with a Response
420
- if (result instanceof Response) {
421
- throw new Error(
422
- `Loader middleware returned a Response (status: ${result.status}). ` +
423
- `Server actions cannot return Response. ` +
424
- `Use GET-based loader fetching for redirects, or throw an error instead.`,
425
- );
562
+ // Final re-merge: capture any RequestContext stub headers added after the
563
+ // last merge point (e.g. cookies().set() called after await next()).
564
+ // The reqCtx stub may have already been partially merged during finalHandler
565
+ // or early-return paths; only append *new* Set-Cookie entries to avoid dupes.
566
+ const reqCtx = _getRequestContext();
567
+ if (reqCtx) {
568
+ const stubCookies = reqCtx.res.headers.getSetCookie();
569
+ if (stubCookies.length > 0) {
570
+ const existingCookies = new Set(finalResponse.headers.getSetCookie());
571
+ for (const cookie of stubCookies) {
572
+ if (!existingCookies.has(cookie)) {
573
+ finalResponse.headers.append("set-cookie", cookie);
574
+ }
575
+ }
426
576
  }
577
+ // Fill in non-cookie headers that aren't already on the response
578
+ reqCtx.res.headers.forEach((value, name) => {
579
+ if (name !== "set-cookie" && !finalResponse.headers.has(name)) {
580
+ finalResponse.headers.set(name, value);
581
+ }
582
+ });
583
+ }
427
584
 
428
- return stubResponse;
429
- };
430
-
431
- await next();
432
- // Headers/cookies set on stubResponse will be merged by the caller
585
+ return finalResponse;
433
586
  }
434
587
 
435
588
  /**
@@ -485,19 +638,24 @@ export async function executeInterceptMiddleware<TEnv>(
485
638
  reverse,
486
639
  );
487
640
 
488
- const result = await middleware(ctx, next);
641
+ let nextCalled = false;
642
+ const guardedNext = (): Promise<Response> => {
643
+ if (nextCalled) {
644
+ throw new Error(
645
+ `[@rangojs/router] Intercept middleware called next() more than once.`,
646
+ );
647
+ }
648
+ nextCalled = true;
649
+ return next();
650
+ };
651
+
652
+ const result = await middleware(ctx, guardedNext);
489
653
 
490
654
  if (result instanceof Response) {
491
655
  earlyResponse = result;
492
656
  return result;
493
657
  }
494
658
 
495
- // Check if middleware replaced ctx.res with a different response
496
- if (responseHolder.response && responseHolder.response !== stubResponse) {
497
- earlyResponse = responseHolder.response;
498
- return earlyResponse;
499
- }
500
-
501
659
  return stubResponse;
502
660
  };
503
661
 
@@ -515,12 +673,14 @@ export async function executeInterceptMiddleware<TEnv>(
515
673
  });
516
674
 
517
675
  if (hasStubHeaders) {
518
- // Clone and merge headers from stub into early response
676
+ // Clone and merge headers from stub into early response.
677
+ // Only fill in missing headers — the returned Response's explicit
678
+ // headers take precedence, matching executeMiddleware behavior.
519
679
  const mergedHeaders = new Headers(response.headers);
520
680
  stubResponse.headers.forEach((value, name) => {
521
681
  if (name.toLowerCase() === "set-cookie") {
522
682
  mergedHeaders.append(name, value);
523
- } else {
683
+ } else if (!mergedHeaders.has(name)) {
524
684
  mergedHeaders.set(name, value);
525
685
  }
526
686
  });