@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 +1 -1
- package/dist/vite/index.js +37 -1
- package/package.json +1 -1
- package/skills/hooks/SKILL.md +0 -17
- package/skills/route/SKILL.md +2 -2
- package/skills/router-setup/SKILL.md +1 -1
- package/skills/typesafety/SKILL.md +1 -1
- package/src/browser/react/Link.tsx +5 -5
- package/src/build/route-trie.ts +19 -3
- package/src/client.rsc.tsx +0 -1
- package/src/client.tsx +0 -46
- package/src/router/match-result.ts +0 -8
- package/src/router/metrics.ts +138 -34
- package/src/router/middleware-types.ts +4 -3
- package/src/router/middleware.ts +31 -2
- package/src/router/pattern-matching.ts +20 -5
- package/src/router/trie-matching.ts +20 -2
- package/src/rsc/handler.ts +104 -14
- package/src/rsc/progressive-enhancement.ts +21 -8
- package/src/rsc/rsc-rendering.ts +12 -58
- package/src/rsc/server-action.ts +2 -17
- package/src/rsc/ssr-setup.ts +128 -0
- package/src/search-params.ts +16 -13
- package/src/types/route-config.ts +17 -8
- package/src/types/segments.ts +0 -5
- package/src/vite/index.ts +4 -3
- package/src/vite/plugins/refresh-cmd.ts +65 -0
package/README.md
CHANGED
package/dist/vite/index.js
CHANGED
|
@@ -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.
|
|
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
package/skills/hooks/SKILL.md
CHANGED
|
@@ -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 |
|
package/skills/route/SKILL.md
CHANGED
|
@@ -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
|
-
|
|
107
|
-
|
|
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
|
|
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
|
|
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
|
-
* - "
|
|
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
|
-
| "
|
|
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
|
|
184
|
+
// Resolve adaptive: viewport on touch devices, hover on pointer devices
|
|
185
185
|
const resolvedStrategy =
|
|
186
|
-
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);
|
package/src/build/route-trie.ts
CHANGED
|
@@ -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 (
|
|
239
|
-
|
|
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: "*" };
|
package/src/client.rsc.tsx
CHANGED
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,
|
package/src/router/metrics.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
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 =
|
|
125
|
-
const durationValues =
|
|
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(
|
|
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 <
|
|
150
|
-
const
|
|
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(
|
|
153
|
-
const duration = formatMs(
|
|
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(
|
|
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
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
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
|
|
package/src/router/middleware.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|