@rangojs/router 0.0.0-experimental.25 → 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.
package/README.md CHANGED
@@ -515,7 +515,7 @@ function Nav() {
515
515
  return (
516
516
  <nav>
517
517
  <Link to={href("/")}>Home</Link>
518
- <Link to={href("/blog")} prefetch="hybrid">
518
+ <Link to={href("/blog")} prefetch="adaptive">
519
519
  Blog
520
520
  </Link>
521
521
  <Link to={href("/about")}>About</Link>
@@ -1745,7 +1745,7 @@ import { resolve } from "node:path";
1745
1745
  // package.json
1746
1746
  var package_default = {
1747
1747
  name: "@rangojs/router",
1748
- version: "0.0.0-experimental.25",
1748
+ version: "0.0.0-experimental.26",
1749
1749
  description: "Django-inspired RSC router with composable URL patterns",
1750
1750
  keywords: [
1751
1751
  "react",
@@ -5173,6 +5173,42 @@ ${list}`
5173
5173
  );
5174
5174
  return plugins;
5175
5175
  }
5176
+
5177
+ // src/vite/plugins/refresh-cmd.ts
5178
+ function poke() {
5179
+ return {
5180
+ name: "vite-plugin-poke",
5181
+ apply: "serve",
5182
+ configureServer(server) {
5183
+ const stdin = process.stdin;
5184
+ const previousRawMode = stdin.isTTY ? stdin.isRaw : null;
5185
+ if (stdin.isTTY) {
5186
+ stdin.setRawMode(true);
5187
+ }
5188
+ const onData = (data) => {
5189
+ if (data.length !== 1) return;
5190
+ if (data[0] === 3) {
5191
+ process.emit("SIGINT", "SIGINT");
5192
+ return;
5193
+ }
5194
+ if (data[0] === 18) {
5195
+ server.hot.send({ type: "full-reload", path: "*" });
5196
+ server.config.logger.info(" browser reload (ctrl+r)", {
5197
+ timestamp: true
5198
+ });
5199
+ }
5200
+ };
5201
+ stdin.on("data", onData);
5202
+ server.httpServer?.on("close", () => {
5203
+ stdin.off("data", onData);
5204
+ if (stdin.isTTY && previousRawMode !== null) {
5205
+ stdin.setRawMode(previousRawMode);
5206
+ }
5207
+ });
5208
+ }
5209
+ };
5210
+ }
5176
5211
  export {
5212
+ poke,
5177
5213
  rango
5178
5214
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.25",
3
+ "version": "0.0.0-experimental.26",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -239,22 +239,6 @@ export const FileUploadLoader = createLoader(async (ctx) => {
239
239
  }, true); // true = fetchable (can be called from the client via load())
240
240
  ```
241
241
 
242
- ### useLoaderData()
243
-
244
- Get all loader data in current context:
245
-
246
- ```tsx
247
- "use client";
248
- import { useLoaderData } from "@rangojs/router/client";
249
-
250
- function DebugPanel() {
251
- const allData = useLoaderData();
252
- // Record<string, any> - Map of loader ID to data
253
-
254
- return <pre>{JSON.stringify(allData, null, 2)}</pre>;
255
- }
256
- ```
257
-
258
242
  ## Handle Hooks
259
243
 
260
244
  ### useHandle()
@@ -696,7 +680,6 @@ See `/links` for full URL generation guide including server-side `ctx.reverse`.
696
680
  | `useLinkStatus()` | Link pending state | { pending } |
697
681
  | `useLoader()` | Loader data (strict) | data, isLoading, error |
698
682
  | `useFetchLoader()` | Loader with on-demand fetch | data, load, isLoading |
699
- | `useLoaderData()` | All loader data | Record<string, any> |
700
683
  | `useHandle()` | Accumulated handle data | T (handle type) |
701
684
  | `useAction()` | Server action state | state, error, result |
702
685
  | `useLocationState()` | History state (persists or flash) | T \| undefined |
@@ -103,8 +103,8 @@ export const SearchPage: Handler<"search"> = (ctx) => {
103
103
  ```
104
104
 
105
105
  Supported types: `"string"`, `"number"`, `"boolean"`, with `?` suffix for optional.
106
- Required params default to zero values when missing (`""`, `0`, `false`).
107
- Optional params are omitted from the result when not in the query string.
106
+ Missing params are `undefined` regardless of required/optional. The required/optional
107
+ distinction is a consumer-facing contract (for `href()` and `reverse()` autocomplete).
108
108
 
109
109
  Use `RouteSearchParams<"name">` and `RouteParams<"name">` to extract types for props:
110
110
 
@@ -78,7 +78,7 @@ interface RSCRouterOptions<TEnv> {
78
78
  // Document component wrapping entire app
79
79
  document?: ComponentType<{ children: ReactNode }>;
80
80
 
81
- // Enable performance metrics
81
+ // Enable per-request performance timeline (console waterfall + Server-Timing header)
82
82
  debugPerformance?: boolean;
83
83
 
84
84
  // Default error boundary
@@ -281,7 +281,7 @@ import type { RouteSearchParams, RouteParams } from "@rangojs/router";
281
281
 
282
282
  // RouteSearchParams<"name"> resolves the search schema to a typed object
283
283
  type SP = RouteSearchParams<"search">;
284
- // { q: string; page?: number; sort?: string }
284
+ // { q: string | undefined; page?: number; sort?: string }
285
285
 
286
286
  // RouteParams<"name"> resolves URL params from the route pattern
287
287
  type P = RouteParams<"blogPost">;
@@ -37,7 +37,7 @@ import {
37
37
  unobserveForPrefetch,
38
38
  } from "../prefetch/observer.js";
39
39
 
40
- // Touch device detection for hybrid strategy.
40
+ // Touch device detection for adaptive strategy.
41
41
  // Checked once at module load (Link.tsx is "use client", runs only in browser).
42
42
  const isTouchDevice =
43
43
  typeof window !== "undefined" && window.matchMedia("(hover: none)").matches;
@@ -47,14 +47,14 @@ const isTouchDevice =
47
47
  * - "hover": Prefetch on mouse enter (direct, no queue)
48
48
  * - "viewport": Prefetch when link enters viewport (queued, waits for idle)
49
49
  * - "render": Prefetch on component mount regardless of visibility (queued, waits for idle)
50
- * - "hybrid": Hover on pointer devices, viewport on touch devices
50
+ * - "adaptive": Hover on pointer devices, viewport on touch devices
51
51
  * - "none": No prefetching (default)
52
52
  */
53
53
  export type PrefetchStrategy =
54
54
  | "hover"
55
55
  | "viewport"
56
56
  | "render"
57
- | "hybrid"
57
+ | "adaptive"
58
58
  | "none";
59
59
 
60
60
  /**
@@ -181,9 +181,9 @@ export const Link: ForwardRefExoticComponent<
181
181
  const ctx = useContext(NavigationStoreContext);
182
182
  const isExternal = isExternalUrl(to);
183
183
 
184
- // Resolve hybrid: viewport on touch devices, hover on pointer devices
184
+ // Resolve adaptive: viewport on touch devices, hover on pointer devices
185
185
  const resolvedStrategy =
186
- prefetch === "hybrid" ? (isTouchDevice ? "viewport" : "hover") : prefetch;
186
+ prefetch === "adaptive" ? (isTouchDevice ? "viewport" : "hover") : prefetch;
187
187
 
188
188
  // Internal ref for viewport observation; merge with forwarded ref
189
189
  const internalRef = useRef<HTMLAnchorElement | null>(null);
@@ -47,6 +47,8 @@ export interface TrieNode {
47
47
  s?: Record<string, TrieNode>;
48
48
  /** Param child: { n: paramName, c: child node } */
49
49
  p?: { n: string; c: TrieNode };
50
+ /** Suffix-param children keyed by suffix (e.g., ".html" → { n: "productId", c: ... }) */
51
+ xp?: Record<string, { n: string; c: TrieNode }>;
50
52
  /** Wildcard terminal: leaf + paramName */
51
53
  w?: TrieLeaf & { pn: string };
52
54
  }
@@ -158,6 +160,11 @@ export function extractAncestryFromTrie(
158
160
  visit(child);
159
161
  }
160
162
  }
163
+ if (node.xp) {
164
+ for (const child of Object.values(node.xp)) {
165
+ visit(child.c);
166
+ }
167
+ }
161
168
  if (node.p) {
162
169
  visit(node.p.c);
163
170
  }
@@ -235,10 +242,19 @@ function insertSegments(
235
242
  mergeLeaf(node, leaf);
236
243
  // AND continue with param child (param present)
237
244
  }
238
- if (!node.p) {
239
- node.p = { n: segment.value, c: {} };
245
+ if (segment.suffix) {
246
+ // Suffix param: keyed by suffix string (e.g., ".html")
247
+ if (!node.xp) node.xp = {};
248
+ if (!node.xp[segment.suffix]) {
249
+ node.xp[segment.suffix] = { n: segment.value, c: {} };
250
+ }
251
+ insertSegments(node.xp[segment.suffix].c, segments, index + 1, leaf);
252
+ } else {
253
+ if (!node.p) {
254
+ node.p = { n: segment.value, c: {} };
255
+ }
256
+ insertSegments(node.p.c, segments, index + 1, leaf);
240
257
  }
241
- insertSegments(node.p.c, segments, index + 1, leaf);
242
258
  } else if (segment.type === "wildcard") {
243
259
  // Wildcard consumes all remaining segments
244
260
  const wildLeaf = { ...leaf, pn: "*" };
@@ -17,7 +17,6 @@ export {
17
17
  OutletProvider,
18
18
  useOutlet,
19
19
  useLoader,
20
- useLoaderData,
21
20
  ErrorBoundary,
22
21
  type ErrorBoundaryProps,
23
22
  } from "./client.js";
package/src/client.tsx CHANGED
@@ -313,52 +313,6 @@ export {
313
313
  type UseLoaderOptions,
314
314
  } from "./use-loader.js";
315
315
 
316
- /**
317
- * Hook to access all loader data in the current context
318
- *
319
- * Returns a record of all loader data available in the current outlet context
320
- * and all parent contexts. Useful for debugging or when you need access to
321
- * multiple loaders.
322
- *
323
- * @returns Record of loader name to data, or empty object if no loaders
324
- *
325
- * @example
326
- * ```tsx
327
- * "use client";
328
- * import { useLoaderData } from "rsc-router/client";
329
- *
330
- * export function DebugPanel() {
331
- * const loaderData = useLoaderData();
332
- * return <pre>{JSON.stringify(loaderData, null, 2)}</pre>;
333
- * }
334
- * ```
335
- */
336
- export function useLoaderData(): Record<string, any> {
337
- const context = useContext(OutletContext);
338
-
339
- // Collect all loader data from the context chain
340
- // Child loaders override parent loaders with the same name
341
- const result: Record<string, any> = {};
342
- const stack: OutletContextValue[] = [];
343
-
344
- // Build stack from current to root
345
- let current: OutletContextValue | null | undefined = context;
346
- while (current) {
347
- stack.push(current);
348
- current = current.parent;
349
- }
350
-
351
- // Apply from root to current (so children override parents)
352
- for (let i = stack.length - 1; i >= 0; i--) {
353
- const ctx = stack[i];
354
- if (ctx.loaderData) {
355
- Object.assign(result, ctx.loaderData);
356
- }
357
- }
358
-
359
- return result;
360
- }
361
-
362
316
  /**
363
317
  * Client-safe createLoader factory
364
318
  *
@@ -108,7 +108,6 @@
108
108
  */
109
109
  import type { MatchResult, ResolvedSegment } from "../types.js";
110
110
  import type { MatchContext, MatchPipelineState } from "./match-context.js";
111
- import { generateServerTiming } from "./metrics.js";
112
111
  import { debugLog } from "./logging.js";
113
112
 
114
113
  /**
@@ -186,19 +185,12 @@ export function buildMatchResult<TEnv>(
186
185
  segmentIds: segmentsToRender.map((s) => s.id),
187
186
  });
188
187
 
189
- // Output metrics if enabled
190
- let serverTiming: string | undefined;
191
- if (ctx.metricsStore) {
192
- serverTiming = generateServerTiming(ctx.metricsStore);
193
- }
194
-
195
188
  return {
196
189
  segments: segmentsToRender,
197
190
  matched: allIds,
198
191
  diff: segmentsToRender.map((s) => s.id),
199
192
  params: ctx.matched.params,
200
193
  routeName: ctx.routeKey,
201
- serverTiming,
202
194
  slots: Object.keys(state.slots).length > 0 ? state.slots : undefined,
203
195
  routeMiddleware:
204
196
  ctx.routeMiddleware.length > 0 ? ctx.routeMiddleware : undefined,
@@ -11,14 +11,19 @@ const DEPTH_INDENT = 2;
11
11
  const TIMELINE_WIDTH = 40;
12
12
 
13
13
  function formatMs(value: number): string {
14
- return `${value.toFixed(1)}ms`;
14
+ return `${value.toFixed(2)}ms`;
15
15
  }
16
16
 
17
17
  function sortMetrics(metrics: PerformanceMetric[]): PerformanceMetric[] {
18
18
  return [...metrics].sort((a, b) => a.startTime - b.startTime);
19
19
  }
20
20
 
21
- function renderTimeline(metric: PerformanceMetric, total: number): string {
21
+ interface Span {
22
+ startTime: number;
23
+ duration: number;
24
+ }
25
+
26
+ function renderTimeline(spans: Span[], total: number): string {
22
27
  if (TIMELINE_WIDTH <= 0) {
23
28
  return "||";
24
29
  }
@@ -30,21 +35,24 @@ function renderTimeline(metric: PerformanceMetric, total: number): string {
30
35
  return `|${cells.join("")}|`;
31
36
  }
32
37
 
33
- const start = Math.max(0, metric.startTime);
34
- const end = Math.max(start, metric.startTime + metric.duration);
35
- const startColumn = Math.min(
36
- TIMELINE_WIDTH - 1,
37
- Math.floor((start / total) * TIMELINE_WIDTH),
38
- );
39
- const endColumn = Math.max(
40
- startColumn + 1,
41
- Math.min(
42
- TIMELINE_WIDTH,
43
- Math.ceil((Math.min(total, end) / total) * TIMELINE_WIDTH),
44
- ),
45
- );
38
+ for (const span of spans) {
39
+ const start = Math.max(0, span.startTime);
40
+ const end = Math.max(start, span.startTime + span.duration);
41
+ const startColumn = Math.min(
42
+ TIMELINE_WIDTH - 1,
43
+ Math.floor((start / total) * TIMELINE_WIDTH),
44
+ );
45
+ const endColumn = Math.max(
46
+ startColumn + 1,
47
+ Math.min(
48
+ TIMELINE_WIDTH,
49
+ Math.ceil((Math.min(total, end) / total) * TIMELINE_WIDTH),
50
+ ),
51
+ );
52
+
53
+ cells.fill("#", startColumn, endColumn);
54
+ }
46
55
 
47
- cells.fill("#", startColumn, endColumn);
48
56
  return `|${cells.join("")}|`;
49
57
  }
50
58
 
@@ -56,15 +64,18 @@ function createTimelineAxis(total: number): string {
56
64
  }
57
65
 
58
66
  /**
59
- * Create a metrics store for the request if debugPerformance is enabled
67
+ * Create a metrics store for the request if debugPerformance is enabled.
68
+ * An optional `requestStart` timestamp can anchor the store to an earlier
69
+ * point (e.g. handler start) so that handler:total has startTime=0.
60
70
  */
61
71
  export function createMetricsStore(
62
72
  debugPerformance: boolean,
73
+ requestStart?: number,
63
74
  ): MetricsStore | undefined {
64
75
  if (!debugPerformance) return undefined;
65
76
  return {
66
77
  enabled: true,
67
- requestStart: performance.now(),
78
+ requestStart: requestStart ?? performance.now(),
68
79
  metrics: [],
69
80
  };
70
81
  }
@@ -90,24 +101,115 @@ export function appendMetric(
90
101
 
91
102
  /**
92
103
  * Log the current request metrics and return the corresponding Server-Timing value.
93
- * Falls back to an existing header value when no metrics store is active.
94
104
  */
95
105
  export function buildMetricsTiming(
96
106
  method: string,
97
107
  pathname: string,
98
108
  metricsStore: MetricsStore | undefined,
99
- fallback?: string,
100
109
  ): string | undefined {
101
- if (!metricsStore) {
102
- return fallback;
103
- }
110
+ if (!metricsStore) return undefined;
104
111
  logMetrics(method, pathname, metricsStore);
105
112
  return generateServerTiming(metricsStore) || undefined;
106
113
  }
107
114
 
115
+ /** Display row produced by merging :pre/:post metric pairs. */
116
+ interface DisplayRow {
117
+ label: string;
118
+ startTime: number;
119
+ duration: number;
120
+ depth: number | undefined;
121
+ spans: Span[];
122
+ }
123
+
124
+ /**
125
+ * Build display rows from sorted metrics, merging :pre/:post pairs into
126
+ * a single row with disjoint timeline segments.
127
+ */
128
+ function buildDisplayRows(sorted: PerformanceMetric[]): DisplayRow[] {
129
+ // Index :pre and :post metrics by their base label
130
+ const preMap = new Map<string, PerformanceMetric>();
131
+ const postMap = new Map<string, PerformanceMetric>();
132
+ const consumed = new Set<PerformanceMetric>();
133
+
134
+ for (const m of sorted) {
135
+ if (m.label.endsWith(":pre")) {
136
+ preMap.set(m.label.slice(0, -4), m);
137
+ } else if (m.label.endsWith(":post")) {
138
+ postMap.set(m.label.slice(0, -5), m);
139
+ }
140
+ }
141
+
142
+ const rows: DisplayRow[] = [];
143
+
144
+ for (const m of sorted) {
145
+ if (consumed.has(m)) continue;
146
+
147
+ if (m.label.endsWith(":pre")) {
148
+ const base = m.label.slice(0, -4);
149
+ const post = postMap.get(base);
150
+ if (post) {
151
+ // Merge into a single row with two disjoint spans
152
+ consumed.add(m);
153
+ consumed.add(post);
154
+ rows.push({
155
+ label: base,
156
+ startTime: m.startTime,
157
+ duration: m.duration + post.duration,
158
+ depth: m.depth,
159
+ spans: [
160
+ { startTime: m.startTime, duration: m.duration },
161
+ { startTime: post.startTime, duration: post.duration },
162
+ ],
163
+ });
164
+ continue;
165
+ }
166
+ // Lone :pre — display with base label
167
+ consumed.add(m);
168
+ rows.push({
169
+ label: base,
170
+ startTime: m.startTime,
171
+ duration: m.duration,
172
+ depth: m.depth,
173
+ spans: [{ startTime: m.startTime, duration: m.duration }],
174
+ });
175
+ continue;
176
+ }
177
+
178
+ if (m.label.endsWith(":post")) {
179
+ const base = m.label.slice(0, -5);
180
+ if (preMap.has(base)) {
181
+ // Already consumed as part of the pair above
182
+ continue;
183
+ }
184
+ // Lone :post — display with base label
185
+ consumed.add(m);
186
+ rows.push({
187
+ label: base,
188
+ startTime: m.startTime,
189
+ duration: m.duration,
190
+ depth: m.depth,
191
+ spans: [{ startTime: m.startTime, duration: m.duration }],
192
+ });
193
+ continue;
194
+ }
195
+
196
+ // Regular metric
197
+ rows.push({
198
+ label: m.label,
199
+ startTime: m.startTime,
200
+ duration: m.duration,
201
+ depth: m.depth,
202
+ spans: [{ startTime: m.startTime, duration: m.duration }],
203
+ });
204
+ }
205
+
206
+ return rows;
207
+ }
208
+
108
209
  /**
109
210
  * Log metrics to console in a formatted way.
110
211
  * Uses a shared-axis timeline so overlapping work stays visible.
212
+ * Merges :pre/:post pairs onto one row with disjoint timeline segments.
111
213
  */
112
214
  export function logMetrics(
113
215
  method: string,
@@ -117,12 +219,14 @@ export function logMetrics(
117
219
  const total = performance.now() - metricsStore.requestStart;
118
220
 
119
221
  const sorted = sortMetrics(metricsStore.metrics);
120
- const labels = sorted.map(
121
- (m) =>
122
- `${" ".repeat(BASE_INDENT + (m.depth ?? 0) * DEPTH_INDENT)}${m.label}`,
222
+ const displayRows = buildDisplayRows(sorted);
223
+
224
+ const labels = displayRows.map(
225
+ (r) =>
226
+ `${" ".repeat(BASE_INDENT + (r.depth ?? 0) * DEPTH_INDENT)}${r.label}`,
123
227
  );
124
- const startValues = sorted.map((m) => formatMs(m.startTime));
125
- const durationValues = sorted.map((m) => formatMs(m.duration));
228
+ const startValues = displayRows.map((r) => formatMs(r.startTime));
229
+ const durationValues = displayRows.map((r) => formatMs(r.duration));
126
230
  const startWidth = Math.max(
127
231
  "start".length,
128
232
  ...startValues.map((v) => v.length),
@@ -140,20 +244,20 @@ export function logMetrics(
140
244
  startWidth + 2 + durationWidth + 2 + spanWidth + 2,
141
245
  );
142
246
 
143
- console.log(`[RSC Perf] ${method} ${pathname} (${total.toFixed(1)}ms)`);
247
+ console.log(`[RSC Perf] ${method} ${pathname} (${total.toFixed(2)}ms)`);
144
248
  console.log(
145
249
  `${"start".padStart(startWidth)} ${"dur".padStart(durationWidth)} ${"span".padEnd(spanWidth)} timeline`,
146
250
  );
147
251
  console.log(`${timelinePadding}${createTimelineAxis(total)}`);
148
252
 
149
- for (let index = 0; index < sorted.length; index++) {
150
- const metric = sorted[index];
253
+ for (let index = 0; index < displayRows.length; index++) {
254
+ const row = displayRows[index];
151
255
  const label = labels[index].padEnd(spanWidth);
152
- const start = formatMs(metric.startTime).padStart(startWidth);
153
- const duration = formatMs(metric.duration).padStart(durationWidth);
256
+ const start = formatMs(row.startTime).padStart(startWidth);
257
+ const duration = formatMs(row.duration).padStart(durationWidth);
154
258
 
155
259
  console.log(
156
- `${start} ${duration} ${label} ${renderTimeline(metric, total)}`,
260
+ `${start} ${duration} ${label} ${renderTimeline(row.spans, total)}`,
157
261
  );
158
262
  }
159
263
  }
@@ -106,9 +106,10 @@ export interface MiddlewareContext<
106
106
  * included in the Server-Timing response header, regardless of the
107
107
  * router-level `debugPerformance` option.
108
108
  *
109
- * Must be called **before** `await next()` the metrics store is
110
- * created at the start of route matching inside `next()`, so calling
111
- * this after `next()` returns has no effect.
109
+ * Call **before** `await next()` so the metrics store exists when
110
+ * downstream phases (route matching, rendering, SSR) record their
111
+ * spans. Calling after `next()` returns still emits `handler:total`
112
+ * but misses all upstream metrics.
112
113
  */
113
114
  debugPerformance(): void;
114
115
 
@@ -51,6 +51,8 @@ function warnCtxSetBeforeRedirect(handler: Function): void {
51
51
  }
52
52
 
53
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;
54
56
 
55
57
  function getMiddlewareMetricBase<TEnv>(
56
58
  entry: MiddlewareEntry<TEnv>,
@@ -382,13 +384,14 @@ export async function executeMiddleware<TEnv>(
382
384
  reverse,
383
385
  );
384
386
  const metricStart = performance.now();
387
+ const metricLabel = getMiddlewareMetricLabel(entry, middlewareOrdinal);
385
388
  let middlewareFinished = false;
386
389
  const finishMiddleware = () => {
387
390
  if (!middlewareFinished) {
388
391
  middlewareFinished = true;
389
392
  appendMetric(
390
393
  _getRequestContext()?._metricsStore,
391
- getMiddlewareMetricLabel(entry, middlewareOrdinal),
394
+ `${metricLabel}:pre`,
392
395
  metricStart,
393
396
  performance.now() - metricStart,
394
397
  MIDDLEWARE_METRIC_DEPTH,
@@ -400,6 +403,7 @@ export async function executeMiddleware<TEnv>(
400
403
  // Guard against double-calling: a second call would re-enter the
401
404
  // downstream chain and overwrite responseHolder.response.
402
405
  let nextPromise: Promise<Response> | null = null;
406
+ let nextResolvedAt: number | undefined;
403
407
  const wrappedNext = (): Promise<Response> => {
404
408
  if (nextPromise) {
405
409
  throw new Error(
@@ -407,7 +411,17 @@ export async function executeMiddleware<TEnv>(
407
411
  );
408
412
  }
409
413
  finishMiddleware();
410
- nextPromise = next();
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
+ );
411
425
  return nextPromise;
412
426
  };
413
427
 
@@ -430,6 +444,21 @@ export async function executeMiddleware<TEnv>(
430
444
  }
431
445
  finishMiddleware();
432
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
+ }
461
+
433
462
  // Explicit return takes precedence (middleware short-circuit).
434
463
  // Merge stub headers (from ctx.header before this point) and
435
464
  // RequestContext stub headers (from ctx.setCookie) into the