@rangojs/router 0.0.0-experimental.20 → 0.0.0-experimental.20dbba0c

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 (189) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +172 -50
  3. package/dist/bin/rango.js +138 -50
  4. package/dist/vite/index.js +1160 -508
  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 +17 -16
  8. package/skills/breadcrumbs/SKILL.md +252 -0
  9. package/skills/cache-guide/SKILL.md +32 -0
  10. package/skills/caching/SKILL.md +49 -8
  11. package/skills/document-cache/SKILL.md +2 -2
  12. package/skills/handler-use/SKILL.md +362 -0
  13. package/skills/hooks/SKILL.md +61 -51
  14. package/skills/host-router/SKILL.md +218 -0
  15. package/skills/intercept/SKILL.md +20 -0
  16. package/skills/layout/SKILL.md +22 -0
  17. package/skills/links/SKILL.md +91 -17
  18. package/skills/loader/SKILL.md +107 -24
  19. package/skills/middleware/SKILL.md +34 -3
  20. package/skills/migrate-nextjs/SKILL.md +560 -0
  21. package/skills/migrate-react-router/SKILL.md +765 -0
  22. package/skills/parallel/SKILL.md +185 -0
  23. package/skills/prerender/SKILL.md +112 -70
  24. package/skills/rango/SKILL.md +24 -23
  25. package/skills/response-routes/SKILL.md +8 -0
  26. package/skills/route/SKILL.md +58 -4
  27. package/skills/router-setup/SKILL.md +95 -5
  28. package/skills/streams-and-websockets/SKILL.md +283 -0
  29. package/skills/typesafety/SKILL.md +38 -24
  30. package/src/__internal.ts +92 -0
  31. package/src/browser/app-shell.ts +52 -0
  32. package/src/browser/app-version.ts +14 -0
  33. package/src/browser/event-controller.ts +5 -0
  34. package/src/browser/link-interceptor.ts +4 -0
  35. package/src/browser/navigation-bridge.ts +175 -17
  36. package/src/browser/navigation-client.ts +177 -44
  37. package/src/browser/navigation-store.ts +68 -9
  38. package/src/browser/navigation-transaction.ts +11 -9
  39. package/src/browser/partial-update.ts +113 -17
  40. package/src/browser/prefetch/cache.ts +275 -28
  41. package/src/browser/prefetch/fetch.ts +191 -46
  42. package/src/browser/prefetch/policy.ts +6 -0
  43. package/src/browser/prefetch/queue.ts +123 -20
  44. package/src/browser/prefetch/resource-ready.ts +77 -0
  45. package/src/browser/rango-state.ts +53 -13
  46. package/src/browser/react/Link.tsx +98 -14
  47. package/src/browser/react/NavigationProvider.tsx +89 -14
  48. package/src/browser/react/context.ts +7 -2
  49. package/src/browser/react/use-handle.ts +9 -58
  50. package/src/browser/react/use-navigation.ts +22 -2
  51. package/src/browser/react/use-params.ts +11 -1
  52. package/src/browser/react/use-router.ts +29 -9
  53. package/src/browser/rsc-router.tsx +177 -66
  54. package/src/browser/scroll-restoration.ts +41 -42
  55. package/src/browser/segment-reconciler.ts +36 -9
  56. package/src/browser/server-action-bridge.ts +8 -6
  57. package/src/browser/types.ts +73 -5
  58. package/src/build/generate-manifest.ts +6 -6
  59. package/src/build/generate-route-types.ts +3 -0
  60. package/src/build/route-trie.ts +67 -25
  61. package/src/build/route-types/include-resolution.ts +8 -1
  62. package/src/build/route-types/router-processing.ts +223 -74
  63. package/src/build/route-types/scan-filter.ts +8 -1
  64. package/src/cache/cache-runtime.ts +15 -11
  65. package/src/cache/cache-scope.ts +48 -7
  66. package/src/cache/cf/cf-cache-store.ts +455 -15
  67. package/src/cache/cf/index.ts +5 -1
  68. package/src/cache/document-cache.ts +17 -7
  69. package/src/cache/index.ts +1 -0
  70. package/src/cache/taint.ts +55 -0
  71. package/src/client.rsc.tsx +2 -1
  72. package/src/client.tsx +85 -276
  73. package/src/context-var.ts +72 -2
  74. package/src/debug.ts +2 -2
  75. package/src/handle.ts +40 -0
  76. package/src/handles/breadcrumbs.ts +66 -0
  77. package/src/handles/index.ts +1 -0
  78. package/src/host/index.ts +0 -3
  79. package/src/index.rsc.ts +9 -36
  80. package/src/index.ts +79 -70
  81. package/src/outlet-context.ts +1 -1
  82. package/src/prerender/store.ts +57 -15
  83. package/src/prerender.ts +138 -77
  84. package/src/response-utils.ts +28 -0
  85. package/src/reverse.ts +27 -2
  86. package/src/route-definition/dsl-helpers.ts +240 -40
  87. package/src/route-definition/helpers-types.ts +67 -19
  88. package/src/route-definition/index.ts +3 -3
  89. package/src/route-definition/redirect.ts +11 -3
  90. package/src/route-definition/resolve-handler-use.ts +155 -0
  91. package/src/route-map-builder.ts +7 -1
  92. package/src/route-types.ts +18 -0
  93. package/src/router/content-negotiation.ts +100 -1
  94. package/src/router/find-match.ts +4 -2
  95. package/src/router/handler-context.ts +129 -26
  96. package/src/router/intercept-resolution.ts +11 -4
  97. package/src/router/lazy-includes.ts +10 -7
  98. package/src/router/loader-resolution.ts +160 -22
  99. package/src/router/logging.ts +5 -2
  100. package/src/router/manifest.ts +31 -16
  101. package/src/router/match-api.ts +128 -193
  102. package/src/router/match-middleware/background-revalidation.ts +30 -2
  103. package/src/router/match-middleware/cache-lookup.ts +94 -17
  104. package/src/router/match-middleware/cache-store.ts +53 -10
  105. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  106. package/src/router/match-middleware/segment-resolution.ts +61 -5
  107. package/src/router/match-result.ts +103 -18
  108. package/src/router/metrics.ts +238 -13
  109. package/src/router/middleware-types.ts +48 -27
  110. package/src/router/middleware.ts +201 -86
  111. package/src/router/navigation-snapshot.ts +182 -0
  112. package/src/router/pattern-matching.ts +77 -11
  113. package/src/router/prerender-match.ts +114 -10
  114. package/src/router/preview-match.ts +30 -102
  115. package/src/router/request-classification.ts +310 -0
  116. package/src/router/revalidation.ts +27 -7
  117. package/src/router/route-snapshot.ts +245 -0
  118. package/src/router/router-context.ts +6 -1
  119. package/src/router/router-interfaces.ts +50 -5
  120. package/src/router/router-options.ts +50 -19
  121. package/src/router/segment-resolution/fresh.ts +215 -19
  122. package/src/router/segment-resolution/helpers.ts +30 -25
  123. package/src/router/segment-resolution/loader-cache.ts +1 -0
  124. package/src/router/segment-resolution/revalidation.ts +454 -301
  125. package/src/router/segment-wrappers.ts +2 -0
  126. package/src/router/trie-matching.ts +30 -6
  127. package/src/router/types.ts +1 -0
  128. package/src/router/url-params.ts +49 -0
  129. package/src/router.ts +89 -17
  130. package/src/rsc/handler.ts +563 -364
  131. package/src/rsc/helpers.ts +69 -41
  132. package/src/rsc/index.ts +0 -20
  133. package/src/rsc/loader-fetch.ts +23 -3
  134. package/src/rsc/manifest-init.ts +5 -1
  135. package/src/rsc/progressive-enhancement.ts +37 -10
  136. package/src/rsc/response-route-handler.ts +14 -1
  137. package/src/rsc/rsc-rendering.ts +47 -44
  138. package/src/rsc/server-action.ts +24 -10
  139. package/src/rsc/ssr-setup.ts +128 -0
  140. package/src/rsc/types.ts +11 -1
  141. package/src/search-params.ts +16 -13
  142. package/src/segment-content-promise.ts +67 -0
  143. package/src/segment-loader-promise.ts +122 -0
  144. package/src/segment-system.tsx +109 -23
  145. package/src/server/context.ts +174 -19
  146. package/src/server/handle-store.ts +19 -0
  147. package/src/server/loader-registry.ts +9 -8
  148. package/src/server/request-context.ts +218 -65
  149. package/src/server.ts +6 -0
  150. package/src/ssr/index.tsx +4 -0
  151. package/src/static-handler.ts +18 -6
  152. package/src/theme/index.ts +4 -13
  153. package/src/types/cache-types.ts +4 -4
  154. package/src/types/handler-context.ts +140 -72
  155. package/src/types/loader-types.ts +41 -15
  156. package/src/types/request-scope.ts +126 -0
  157. package/src/types/route-config.ts +17 -8
  158. package/src/types/route-entry.ts +19 -1
  159. package/src/types/segments.ts +2 -5
  160. package/src/urls/include-helper.ts +24 -14
  161. package/src/urls/path-helper-types.ts +39 -6
  162. package/src/urls/path-helper.ts +48 -13
  163. package/src/urls/pattern-types.ts +12 -0
  164. package/src/urls/response-types.ts +18 -16
  165. package/src/use-loader.tsx +77 -5
  166. package/src/vite/discovery/bundle-postprocess.ts +61 -89
  167. package/src/vite/discovery/discover-routers.ts +7 -4
  168. package/src/vite/discovery/prerender-collection.ts +162 -88
  169. package/src/vite/discovery/state.ts +17 -13
  170. package/src/vite/index.ts +8 -3
  171. package/src/vite/plugin-types.ts +51 -79
  172. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  173. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  174. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  175. package/src/vite/plugins/expose-action-id.ts +1 -3
  176. package/src/vite/plugins/expose-id-utils.ts +12 -0
  177. package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
  178. package/src/vite/plugins/expose-internal-ids.ts +257 -40
  179. package/src/vite/plugins/performance-tracks.ts +88 -0
  180. package/src/vite/plugins/refresh-cmd.ts +127 -0
  181. package/src/vite/plugins/version-plugin.ts +13 -1
  182. package/src/vite/rango.ts +190 -217
  183. package/src/vite/router-discovery.ts +241 -45
  184. package/src/vite/utils/banner.ts +4 -4
  185. package/src/vite/utils/package-resolution.ts +34 -1
  186. package/src/vite/utils/prerender-utils.ts +97 -5
  187. package/src/vite/utils/shared-utils.ts +3 -2
  188. package/skills/testing/SKILL.md +0 -226
  189. package/src/route-definition/route-function.ts +0 -119
@@ -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
 
@@ -265,9 +312,9 @@ export function matchMiddleware<TEnv>(
265
312
  *
266
313
  * Features:
267
314
  * - `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`
315
+ * - `ctx.headers` available before and after `await next()`
316
+ * - `ctx.header()` shorthand for setting a single header
317
+ * - Forgiving: if middleware doesn't return, uses the downstream response
271
318
  * - Short-circuit: return Response to stop chain
272
319
  * - Error catching: try/catch around `next()` works
273
320
  */
@@ -309,19 +356,31 @@ export async function executeMiddleware<TEnv>(
309
356
  }
310
357
  });
311
358
  // 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).
359
+ // Dedup Set-Cookie: an inner executeMiddleware (route-level middleware)
360
+ // may have already merged the same reqCtx cookies into the response.
314
361
  const reqCtx = _getRequestContext();
315
362
  if (reqCtx) {
363
+ const stubCookies = reqCtx.res.headers.getSetCookie();
364
+ if (stubCookies.length > 0) {
365
+ const existing = new Set(mergedHeaders.getSetCookie());
366
+ for (const cookie of stubCookies) {
367
+ if (!existing.has(cookie)) {
368
+ mergedHeaders.append("set-cookie", cookie);
369
+ }
370
+ }
371
+ }
316
372
  reqCtx.res.headers.forEach((value, name) => {
317
- if (name.toLowerCase() === "set-cookie") {
318
- mergedHeaders.append(name, value);
319
- } else if (!mergedHeaders.has(name)) {
373
+ if (name !== "set-cookie" && !mergedHeaders.has(name)) {
320
374
  mergedHeaders.set(name, value);
321
375
  }
322
376
  });
323
377
  }
324
378
 
379
+ if (isWebSocketUpgradeResponse(response)) {
380
+ responseHolder.response = response;
381
+ return response;
382
+ }
383
+
325
384
  // Clone response with merged headers (mutable for post-next() modifications)
326
385
  responseHolder.response = new Response(response.body, {
327
386
  status: response.status,
@@ -332,6 +391,7 @@ export async function executeMiddleware<TEnv>(
332
391
  return responseHolder.response;
333
392
  }
334
393
 
394
+ const middlewareOrdinal = index;
335
395
  const { entry, params } = middlewares[index++];
336
396
  const ctx = createMiddlewareContext(
337
397
  request,
@@ -341,48 +401,81 @@ export async function executeMiddleware<TEnv>(
341
401
  responseHolder,
342
402
  reverse,
343
403
  );
404
+ const metricStart = performance.now();
405
+ const metricLabel = getMiddlewareMetricLabel(entry, middlewareOrdinal);
406
+ let middlewareFinished = false;
407
+ const finishMiddleware = () => {
408
+ if (!middlewareFinished) {
409
+ middlewareFinished = true;
410
+ appendMetric(
411
+ _getRequestContext()?._metricsStore,
412
+ `${metricLabel}:pre`,
413
+ metricStart,
414
+ performance.now() - metricStart,
415
+ MIDDLEWARE_METRIC_DEPTH,
416
+ );
417
+ }
418
+ };
344
419
 
345
420
  // Track if next() was called and capture its Promise.
346
421
  // Guard against double-calling: a second call would re-enter the
347
422
  // downstream chain and overwrite responseHolder.response.
348
423
  let nextPromise: Promise<Response> | null = null;
424
+ let nextResolvedAt: number | undefined;
349
425
  const wrappedNext = (): Promise<Response> => {
350
426
  if (nextPromise) {
351
427
  throw new Error(
352
428
  `[@rangojs/router] Middleware called next() more than once.`,
353
429
  );
354
430
  }
355
- nextPromise = next();
431
+ finishMiddleware();
432
+ const downstream = next();
433
+ nextPromise = downstream.then(
434
+ (res) => {
435
+ nextResolvedAt = performance.now();
436
+ return res;
437
+ },
438
+ (err) => {
439
+ nextResolvedAt = performance.now();
440
+ throw err;
441
+ },
442
+ );
356
443
  return nextPromise;
357
444
  };
358
445
 
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;
446
+ let result: Response | void;
447
+ try {
448
+ result = await entry.handler(ctx, wrappedNext);
449
+ } catch (error) {
450
+ finishMiddleware();
451
+ throw error;
452
+ }
453
+ finishMiddleware();
454
+
455
+ // Record post-next() processing time when middleware did work after
456
+ // the downstream chain resolved (e.g. adding headers, logging).
457
+ if (nextResolvedAt !== undefined) {
458
+ const postDur = performance.now() - nextResolvedAt;
459
+ if (postDur > POST_METRIC_MIN_DURATION_MS) {
460
+ appendMetric(
461
+ _getRequestContext()?._metricsStore,
462
+ `${metricLabel}:post`,
463
+ nextResolvedAt,
464
+ postDur,
465
+ MIDDLEWARE_METRIC_DEPTH,
466
+ );
467
+ }
367
468
  }
368
-
369
- const result = await entry.handler(ctx, wrappedNext);
370
469
 
371
470
  // Explicit return takes precedence (middleware short-circuit).
372
471
  // Merge stub headers (from ctx.header before this point) and
373
472
  // RequestContext stub headers (from ctx.setCookie) into the
374
473
  // returned Response so they are not lost.
375
474
  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);
475
+ if (isWebSocketUpgradeResponse(result)) {
476
+ responseHolder.response = result;
477
+ return result;
384
478
  }
385
-
386
479
  const mergedHeaders = new Headers(result.headers);
387
480
  stubResponse.headers.forEach((value, name) => {
388
481
  if (name.toLowerCase() === "set-cookie") {
@@ -391,13 +484,22 @@ export async function executeMiddleware<TEnv>(
391
484
  mergedHeaders.set(name, value);
392
485
  }
393
486
  });
394
- // Also merge shared RequestContext stub (cookies written via setCookie)
487
+ // Also merge shared RequestContext stub (cookies written via setCookie).
488
+ // Dedup Set-Cookie: an inner executeMiddleware (route-level middleware)
489
+ // may have already merged the same reqCtx cookies into the response.
395
490
  const reqCtx = _getRequestContext();
396
491
  if (reqCtx) {
492
+ const stubCookies = reqCtx.res.headers.getSetCookie();
493
+ if (stubCookies.length > 0) {
494
+ const existing = new Set(mergedHeaders.getSetCookie());
495
+ for (const cookie of stubCookies) {
496
+ if (!existing.has(cookie)) {
497
+ mergedHeaders.append("set-cookie", cookie);
498
+ }
499
+ }
500
+ }
397
501
  reqCtx.res.headers.forEach((value, name) => {
398
- if (name.toLowerCase() === "set-cookie") {
399
- mergedHeaders.append(name, value);
400
- } else if (!mergedHeaders.has(name)) {
502
+ if (name !== "set-cookie" && !mergedHeaders.has(name)) {
401
503
  mergedHeaders.set(name, value);
402
504
  }
403
505
  });
@@ -424,19 +526,6 @@ export async function executeMiddleware<TEnv>(
424
526
  // If middleware called next(), await it and return the response
425
527
  if (nextPromise) {
426
528
  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
529
  return responseHolder.response!;
441
530
  }
442
531
 
@@ -459,6 +548,32 @@ export async function executeMiddleware<TEnv>(
459
548
  throw new Error("No response generated by middleware chain");
460
549
  }
461
550
 
551
+ // Final re-merge: capture any RequestContext stub headers added after the
552
+ // last merge point (e.g. cookies().set() called after await next()).
553
+ // The reqCtx stub may have already been partially merged during finalHandler
554
+ // or early-return paths; only append *new* Set-Cookie entries to avoid dupes.
555
+ //
556
+ // Skip for upgrade responses: upgrade headers are semantically immutable and
557
+ // set-cookie on an upgrade is not meaningful.
558
+ const reqCtx = _getRequestContext();
559
+ if (reqCtx && !isWebSocketUpgradeResponse(finalResponse)) {
560
+ const stubCookies = reqCtx.res.headers.getSetCookie();
561
+ if (stubCookies.length > 0) {
562
+ const existingCookies = new Set(finalResponse.headers.getSetCookie());
563
+ for (const cookie of stubCookies) {
564
+ if (!existingCookies.has(cookie)) {
565
+ finalResponse.headers.append("set-cookie", cookie);
566
+ }
567
+ }
568
+ }
569
+ // Fill in non-cookie headers that aren't already on the response
570
+ reqCtx.res.headers.forEach((value, name) => {
571
+ if (name !== "set-cookie" && !finalResponse.headers.has(name)) {
572
+ finalResponse.headers.set(name, value);
573
+ }
574
+ });
575
+ }
576
+
462
577
  return finalResponse;
463
578
  }
464
579
 
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Navigation Snapshot
3
+ *
4
+ * Pure data type representing the navigation-specific state for partial requests.
5
+ * Consolidates the header parsing, previous-route matching, intercept-context
6
+ * detection, and segment ID filtering that previously lived inline in
7
+ * createMatchContextForPartial (match-api.ts).
8
+ *
9
+ * resolveNavigation() is the factory: given a request + URL + current route key,
10
+ * it returns a NavigationSnapshot (or null if no previous URL).
11
+ */
12
+
13
+ import type { RouteMatchResult } from "./pattern-matching.js";
14
+
15
+ /**
16
+ * Snapshot of navigation state for a partial (navigation/action) request.
17
+ *
18
+ * Contains the "where are we coming from?" data: previous route, intercept
19
+ * source, client segment state, and derived flags.
20
+ */
21
+ export interface NavigationSnapshot {
22
+ /** Previous page URL (from X-RSC-Router-Client-Path or Referer) */
23
+ prevUrl: URL;
24
+ /** Params from the previous route match */
25
+ prevParams: Record<string, string>;
26
+ /** Previous route match result (null if prev URL doesn't match any route) */
27
+ prevMatch: RouteMatchResult | null;
28
+
29
+ /** URL used as intercept context source */
30
+ interceptContextUrl: URL;
31
+ /** Route match for the intercept context URL */
32
+ interceptContextMatch: RouteMatchResult | null;
33
+
34
+ /** Raw segment IDs the client currently has */
35
+ clientSegmentIds: string[];
36
+ /** Set version for O(1) lookup */
37
+ clientSegmentSet: Set<string>;
38
+ /** Segment IDs filtered to remove parallel (.@) and loader (D\d+.) entries */
39
+ filteredSegmentIds: string[];
40
+
41
+ /** Whether client considers its cache stale */
42
+ stale: boolean;
43
+
44
+ /** Whether the intercept context route is the same as the current route */
45
+ isSameRouteNavigation: boolean;
46
+
47
+ /** Effective "from" URL (intercept source URL when present, else prevUrl) */
48
+ effectiveFromUrl: URL;
49
+ /** Effective "from" match (intercept source match when present, else prevMatch) */
50
+ effectiveFromMatch: RouteMatchResult | null;
51
+
52
+ /** Whether an intercept source header was present */
53
+ hasInterceptSource: boolean;
54
+
55
+ /** Whether an HMR request header was present */
56
+ isHmr: boolean;
57
+ }
58
+
59
+ export interface ResolveNavigationDeps {
60
+ findMatch: (pathname: string) => RouteMatchResult | null;
61
+ }
62
+
63
+ /**
64
+ * Resolve navigation state from a partial request.
65
+ *
66
+ * Returns null if no previous URL is available (required for partial navigation).
67
+ *
68
+ * @param request - The incoming HTTP request
69
+ * @param url - Parsed URL of the request
70
+ * @param currentRouteKey - Route key of the current (target) route match
71
+ * @param deps - Dependencies (findMatch)
72
+ */
73
+ export function resolveNavigation(
74
+ request: Request,
75
+ url: URL,
76
+ currentRouteKey: string,
77
+ deps: ResolveNavigationDeps,
78
+ ): NavigationSnapshot | null {
79
+ // Parse client state from RSC request params/headers
80
+ const clientSegmentIds =
81
+ url.searchParams.get("_rsc_segments")?.split(",").filter(Boolean) || [];
82
+ const stale = url.searchParams.get("_rsc_stale") === "true";
83
+ const previousUrl =
84
+ request.headers.get("X-RSC-Router-Client-Path") ||
85
+ request.headers.get("Referer");
86
+ const interceptSourceUrl = request.headers.get(
87
+ "X-RSC-Router-Intercept-Source",
88
+ );
89
+ const isHmr = !!request.headers.get("X-RSC-HMR");
90
+
91
+ if (!previousUrl) {
92
+ return null;
93
+ }
94
+
95
+ // Parse previous URL
96
+ let prevUrl: URL;
97
+ try {
98
+ prevUrl = new URL(previousUrl, url.origin);
99
+ } catch {
100
+ return null;
101
+ }
102
+
103
+ // Parse intercept context URL
104
+ let interceptContextUrl: URL;
105
+ try {
106
+ interceptContextUrl = interceptSourceUrl
107
+ ? new URL(interceptSourceUrl, url.origin)
108
+ : prevUrl;
109
+ } catch {
110
+ interceptContextUrl = prevUrl;
111
+ }
112
+
113
+ // Match previous and intercept context routes
114
+ const prevMatch = deps.findMatch(prevUrl.pathname);
115
+ const prevParams = prevMatch?.params || {};
116
+ const interceptContextMatch = interceptSourceUrl
117
+ ? deps.findMatch(interceptContextUrl.pathname)
118
+ : prevMatch;
119
+
120
+ // Derived state
121
+ const isSameRouteNavigation = !!(
122
+ interceptContextMatch && interceptContextMatch.routeKey === currentRouteKey
123
+ );
124
+
125
+ const hasInterceptSource = !!interceptSourceUrl;
126
+ const effectiveFromUrl = hasInterceptSource ? interceptContextUrl : prevUrl;
127
+ const effectiveFromMatch = hasInterceptSource
128
+ ? interceptContextMatch
129
+ : prevMatch;
130
+
131
+ // Filter segment IDs: remove parallel (.@) and loader (D\d+.) entries
132
+ const filteredSegmentIds = clientSegmentIds.filter((id) => {
133
+ if (id.includes(".@")) return false;
134
+ if (/D\d+\./.test(id)) return false;
135
+ return true;
136
+ });
137
+
138
+ const clientSegmentSet = new Set(clientSegmentIds);
139
+
140
+ return {
141
+ prevUrl,
142
+ prevParams,
143
+ prevMatch,
144
+ interceptContextUrl,
145
+ interceptContextMatch,
146
+ clientSegmentIds,
147
+ clientSegmentSet,
148
+ filteredSegmentIds,
149
+ stale,
150
+ isSameRouteNavigation,
151
+ effectiveFromUrl,
152
+ effectiveFromMatch,
153
+ hasInterceptSource,
154
+ isHmr,
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Test helper: create a NavigationSnapshot with sensible defaults and overrides.
160
+ */
161
+ export function createNavigationSnapshot(
162
+ overrides?: Partial<NavigationSnapshot>,
163
+ ): NavigationSnapshot {
164
+ const defaultUrl = new URL("http://localhost/");
165
+ return {
166
+ prevUrl: defaultUrl,
167
+ prevParams: {},
168
+ prevMatch: null,
169
+ interceptContextUrl: defaultUrl,
170
+ interceptContextMatch: null,
171
+ clientSegmentIds: [],
172
+ clientSegmentSet: new Set(),
173
+ filteredSegmentIds: [],
174
+ stale: false,
175
+ isSameRouteNavigation: false,
176
+ effectiveFromUrl: defaultUrl,
177
+ effectiveFromMatch: null,
178
+ hasInterceptSource: false,
179
+ isHmr: false,
180
+ ...overrides,
181
+ };
182
+ }