@rangojs/router 0.0.0-experimental.23 → 0.0.0-experimental.25

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.
@@ -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.23",
1748
+ version: "0.0.0-experimental.25",
1749
1749
  description: "Django-inspired RSC router with composable URL patterns",
1750
1750
  keywords: [
1751
1751
  "react",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.23",
3
+ "version": "0.0.0-experimental.25",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -108,7 +108,7 @@
108
108
  */
109
109
  import type { MatchResult, ResolvedSegment } from "../types.js";
110
110
  import type { MatchContext, MatchPipelineState } from "./match-context.js";
111
- import { generateServerTiming, logMetrics } from "./metrics.js";
111
+ import { generateServerTiming } from "./metrics.js";
112
112
  import { debugLog } from "./logging.js";
113
113
 
114
114
  /**
@@ -189,7 +189,6 @@ export function buildMatchResult<TEnv>(
189
189
  // Output metrics if enabled
190
190
  let serverTiming: string | undefined;
191
191
  if (ctx.metricsStore) {
192
- logMetrics(ctx.request.method, ctx.pathname, ctx.metricsStore);
193
192
  serverTiming = generateServerTiming(ctx.metricsStore);
194
193
  }
195
194
 
@@ -69,6 +69,42 @@ export function createMetricsStore(
69
69
  };
70
70
  }
71
71
 
72
+ /**
73
+ * Append a metric to the request store using an absolute start timestamp.
74
+ */
75
+ export function appendMetric(
76
+ metricsStore: MetricsStore | undefined,
77
+ label: string,
78
+ start: number,
79
+ duration: number,
80
+ depth?: number,
81
+ ): void {
82
+ if (!metricsStore) return;
83
+ metricsStore.metrics.push({
84
+ label,
85
+ duration,
86
+ startTime: start - metricsStore.requestStart,
87
+ depth,
88
+ });
89
+ }
90
+
91
+ /**
92
+ * 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
+ */
95
+ export function buildMetricsTiming(
96
+ method: string,
97
+ pathname: string,
98
+ metricsStore: MetricsStore | undefined,
99
+ fallback?: string,
100
+ ): string | undefined {
101
+ if (!metricsStore) {
102
+ return fallback;
103
+ }
104
+ logMetrics(method, pathname, metricsStore);
105
+ return generateServerTiming(metricsStore) || undefined;
106
+ }
107
+
72
108
  /**
73
109
  * Log metrics to console in a formatted way.
74
110
  * Uses a shared-axis timeline so overlapping work stays visible.
@@ -20,6 +20,7 @@ import type {
20
20
  } from "./middleware-types.js";
21
21
  import { _getRequestContext } from "../server/request-context.js";
22
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 {
@@ -49,6 +50,29 @@ function warnCtxSetBeforeRedirect(handler: Function): void {
49
50
  );
50
51
  }
51
52
 
53
+ const MIDDLEWARE_METRIC_DEPTH = 1;
54
+
55
+ function getMiddlewareMetricBase<TEnv>(
56
+ entry: MiddlewareEntry<TEnv>,
57
+ ordinal: number,
58
+ ): string {
59
+ const handlerName = entry.handler.name?.trim();
60
+ const scope = entry.pattern ?? "*";
61
+
62
+ if (handlerName) {
63
+ return `${handlerName}@${scope}`;
64
+ }
65
+
66
+ return `${scope}#${ordinal + 1}`;
67
+ }
68
+
69
+ function getMiddlewareMetricLabel<TEnv>(
70
+ entry: MiddlewareEntry<TEnv>,
71
+ ordinal: number,
72
+ ): string {
73
+ return `middleware:${getMiddlewareMetricBase(entry, ordinal)}`;
74
+ }
75
+
52
76
  /** Reset W5 deduplication state (for tests only). */
53
77
  export function _resetW5Warnings(): void {
54
78
  warnedRedirectMiddleware = new WeakSet();
@@ -232,6 +256,7 @@ export function createMiddlewareContext<TEnv>(
232
256
  const reqCtx = _getRequestContext();
233
257
  if (reqCtx) {
234
258
  reqCtx._debugPerformance = true;
259
+ reqCtx._metricsStore ??= createMetricsStore(true);
235
260
  }
236
261
  },
237
262
  };
@@ -346,6 +371,7 @@ export async function executeMiddleware<TEnv>(
346
371
  return responseHolder.response;
347
372
  }
348
373
 
374
+ const middlewareOrdinal = index;
349
375
  const { entry, params } = middlewares[index++];
350
376
  const ctx = createMiddlewareContext(
351
377
  request,
@@ -355,6 +381,20 @@ export async function executeMiddleware<TEnv>(
355
381
  responseHolder,
356
382
  reverse,
357
383
  );
384
+ const metricStart = performance.now();
385
+ let middlewareFinished = false;
386
+ const finishMiddleware = () => {
387
+ if (!middlewareFinished) {
388
+ middlewareFinished = true;
389
+ appendMetric(
390
+ _getRequestContext()?._metricsStore,
391
+ getMiddlewareMetricLabel(entry, middlewareOrdinal),
392
+ metricStart,
393
+ performance.now() - metricStart,
394
+ MIDDLEWARE_METRIC_DEPTH,
395
+ );
396
+ }
397
+ };
358
398
 
359
399
  // Track if next() was called and capture its Promise.
360
400
  // Guard against double-calling: a second call would re-enter the
@@ -366,6 +406,7 @@ export async function executeMiddleware<TEnv>(
366
406
  `[@rangojs/router] Middleware called next() more than once.`,
367
407
  );
368
408
  }
409
+ finishMiddleware();
369
410
  nextPromise = next();
370
411
  return nextPromise;
371
412
  };
@@ -380,7 +421,14 @@ export async function executeMiddleware<TEnv>(
380
421
  }) as typeof ctx.set;
381
422
  }
382
423
 
383
- const result = await entry.handler(ctx, wrappedNext);
424
+ let result: Response | void;
425
+ try {
426
+ result = await entry.handler(ctx, wrappedNext);
427
+ } catch (error) {
428
+ finishMiddleware();
429
+ throw error;
430
+ }
431
+ finishMiddleware();
384
432
 
385
433
  // Explicit return takes precedence (middleware short-circuit).
386
434
  // Merge stub headers (from ctx.header before this point) and
@@ -269,6 +269,12 @@ export interface RSCRouterInternal<
269
269
  */
270
270
  readonly warmupEnabled: boolean;
271
271
 
272
+ /**
273
+ * Whether router-wide performance debugging is enabled.
274
+ * Used by the request handler to create metrics before middleware runs.
275
+ */
276
+ readonly debugPerformance?: boolean;
277
+
272
278
  /**
273
279
  * Whether ?__debug_manifest is allowed in production.
274
280
  * Always enabled in development.
package/src/router.ts CHANGED
@@ -359,10 +359,16 @@ export function createRouter<TEnv = any>(
359
359
 
360
360
  // Wrapper to pass debugPerformance to external createMetricsStore.
361
361
  // Also checks per-request flag set by ctx.debugPerformance() in middleware.
362
- const getMetricsStore = () =>
363
- createMetricsStore(
364
- debugPerformance || !!_getRequestContext()?._debugPerformance,
365
- );
362
+ const getMetricsStore = () => {
363
+ const reqCtx = _getRequestContext();
364
+ const enabled = debugPerformance || !!reqCtx?._debugPerformance;
365
+ if (!enabled) return undefined;
366
+ if (!reqCtx) {
367
+ return createMetricsStore(true);
368
+ }
369
+ reqCtx._metricsStore ??= createMetricsStore(true);
370
+ return reqCtx._metricsStore;
371
+ };
366
372
 
367
373
  // Wrapper to pass defaults to error/notFound boundary finders
368
374
  const findNearestErrorBoundary = (entry: EntryData | null) =>
@@ -879,6 +885,9 @@ export function createRouter<TEnv = any>(
879
885
  // Expose warmup enabled flag for handler and client
880
886
  warmupEnabled,
881
887
 
888
+ // Expose router-wide performance debugging for request-level metrics setup
889
+ debugPerformance,
890
+
882
891
  // Expose debug manifest flag for handler
883
892
  allowDebugManifest: allowDebugManifestOption,
884
893
 
@@ -66,6 +66,7 @@ import {
66
66
  createDefaultTimeoutResponse,
67
67
  type TimeoutPhase,
68
68
  } from "../router/timeout.js";
69
+ import { createMetricsStore } from "../router/metrics.js";
69
70
 
70
71
  /**
71
72
  * Create an RSC request handler.
@@ -381,6 +382,10 @@ export function createRSCHandler<
381
382
  executionContext: executionCtx,
382
383
  themeConfig: router.themeConfig,
383
384
  });
385
+ if (router.debugPerformance) {
386
+ requestContext._debugPerformance = true;
387
+ requestContext._metricsStore ??= createMetricsStore(true);
388
+ }
384
389
  // Wire background error reporting so "use cache" and other subsystems
385
390
  // can surface non-fatal errors through the router's onError callback.
386
391
  requestContext._reportBackgroundError = (
@@ -12,6 +12,7 @@ import {
12
12
  getLocationState,
13
13
  } from "../server/request-context.js";
14
14
  import { resolveLocationStateEntries } from "../browser/react/location-state-shared.js";
15
+ import { appendMetric, buildMetricsTiming } from "../router/metrics.js";
15
16
  import type { RscPayload } from "./types.js";
16
17
  import {
17
18
  createResponseWithMergedHeaders,
@@ -166,10 +167,20 @@ export async function handleRscRendering<TEnv>(
166
167
  }
167
168
  }
168
169
 
170
+ const metricsStore = reqCtx._metricsStore;
171
+ const renderStart = performance.now();
172
+
169
173
  // Serialize to RSC stream
170
174
  const rscSerializeStart = performance.now();
171
175
  const rscStream = ctx.renderToReadableStream<RscPayload>(payload);
172
176
  const rscSerializeDur = performance.now() - rscSerializeStart;
177
+ // This measures synchronous stream creation, not end-to-end stream consumption.
178
+ appendMetric(
179
+ metricsStore,
180
+ "rsc-serialize",
181
+ rscSerializeStart,
182
+ rscSerializeDur,
183
+ );
173
184
 
174
185
  // Determine if this is an RSC request or HTML request.
175
186
  // Partial requests (_rsc_partial) are always RSC -- they come from client-side
@@ -183,12 +194,19 @@ export async function handleRscRendering<TEnv>(
183
194
 
184
195
  // Build complete Server-Timing: handler phases + match/manifest + RSC serialize
185
196
  const timingParts: string[] = [...handlerTimingArr];
186
- if (serverTiming) {
187
- timingParts.push(serverTiming);
188
- }
189
- timingParts.push(`rsc-serialize;dur=${rscSerializeDur.toFixed(2)}`);
190
197
 
191
198
  if (isRscRequest) {
199
+ const renderDur = performance.now() - renderStart;
200
+ appendMetric(metricsStore, "render:total", renderStart, renderDur);
201
+ const metricsTiming = buildMetricsTiming(
202
+ request.method,
203
+ url.pathname,
204
+ metricsStore,
205
+ serverTiming,
206
+ );
207
+ if (metricsTiming) {
208
+ timingParts.push(metricsTiming);
209
+ }
192
210
  const fullTiming = timingParts.join(", ");
193
211
  const rscHeaders: Record<string, string> = {
194
212
  "content-type": "text/x-component;charset=utf-8",
@@ -220,7 +238,7 @@ export async function handleRscRendering<TEnv>(
220
238
  ctx.resolveStreamMode(request, env, url),
221
239
  ]);
222
240
  const ssrSetupDur = performance.now() - ssrSetupStart;
223
- timingParts.push(`ssr-setup;dur=${ssrSetupDur.toFixed(2)}`);
241
+ appendMetric(metricsStore, "ssr-setup", ssrSetupStart, ssrSetupDur);
224
242
 
225
243
  const ssrRenderStart = performance.now();
226
244
  const htmlStream = await ssrModule.renderHTML(rscStream, {
@@ -228,7 +246,7 @@ export async function handleRscRendering<TEnv>(
228
246
  streamMode,
229
247
  });
230
248
  const ssrRenderDur = performance.now() - ssrRenderStart;
231
- timingParts.push(`ssr-render-html;dur=${ssrRenderDur.toFixed(2)}`);
249
+ appendMetric(metricsStore, "ssr-render-html", ssrRenderStart, ssrRenderDur);
232
250
 
233
251
  // Add total handler duration
234
252
  if (handlerStart) {
@@ -236,6 +254,18 @@ export async function handleRscRendering<TEnv>(
236
254
  timingParts.push(`handler-total;dur=${totalHandler.toFixed(2)}`);
237
255
  }
238
256
 
257
+ const renderDur = performance.now() - renderStart;
258
+ appendMetric(metricsStore, "render:total", renderStart, renderDur);
259
+ const metricsTiming = buildMetricsTiming(
260
+ request.method,
261
+ url.pathname,
262
+ metricsStore,
263
+ serverTiming,
264
+ );
265
+ if (metricsTiming) {
266
+ timingParts.push(metricsTiming);
267
+ }
268
+
239
269
  const fullTiming = timingParts.join(", ");
240
270
  const htmlHeaders: Record<string, string> = {
241
271
  "content-type": "text/html;charset=utf-8",
@@ -21,6 +21,7 @@ import {
21
21
  getLocationState,
22
22
  } from "../server/request-context.js";
23
23
  import { resolveLocationStateEntries } from "../browser/react/location-state-shared.js";
24
+ import { appendMetric, buildMetricsTiming } from "../router/metrics.js";
24
25
  import type { RscPayload } from "./types.js";
25
26
  import {
26
27
  hasBodyContent,
@@ -274,6 +275,8 @@ export async function revalidateAfterAction<TEnv>(
274
275
  ): Promise<Response> {
275
276
  const { returnValue, actionStatus, temporaryReferences, actionContext } =
276
277
  continuation;
278
+ const reqCtx = requireRequestContext();
279
+ const metricsStore = reqCtx._metricsStore;
277
280
 
278
281
  const matchResult = await ctx.router.matchPartial(
279
282
  request,
@@ -326,15 +329,31 @@ export async function revalidateAfterAction<TEnv>(
326
329
 
327
330
  attachLocationState(payload);
328
331
 
332
+ const renderStart = performance.now();
329
333
  const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
330
334
  temporaryReferences,
331
335
  });
336
+ const rscSerializeDur = performance.now() - renderStart;
337
+ // This measures synchronous stream creation, not end-to-end stream consumption.
338
+ appendMetric(metricsStore, "rsc-serialize", renderStart, rscSerializeDur);
339
+ appendMetric(
340
+ metricsStore,
341
+ "render:total",
342
+ renderStart,
343
+ performance.now() - renderStart,
344
+ );
332
345
 
333
346
  const actionHeaders: Record<string, string> = {
334
347
  "content-type": "text/x-component;charset=utf-8",
335
348
  };
336
- if (serverTiming) {
337
- actionHeaders["Server-Timing"] = serverTiming;
349
+ const metricsTiming = buildMetricsTiming(
350
+ request.method,
351
+ url.pathname,
352
+ metricsStore,
353
+ serverTiming,
354
+ );
355
+ if (metricsTiming) {
356
+ actionHeaders["Server-Timing"] = metricsTiming;
338
357
  }
339
358
 
340
359
  return createResponseWithMergedHeaders(rscStream, {
@@ -23,7 +23,7 @@ import type { Handle } from "../handle.js";
23
23
  import { type ContextVar, contextGet, contextSet } from "../context-var.js";
24
24
  import { createHandleStore, type HandleStore } from "./handle-store.js";
25
25
  import { isHandle } from "../handle.js";
26
- import { track } from "./context.js";
26
+ import { track, type MetricsStore } from "./context.js";
27
27
  import { getFetchableLoader } from "./fetchable-loader-store.js";
28
28
  import type { SegmentCacheStore } from "../cache/types.js";
29
29
  import type { Theme, ResolvedThemeConfig } from "../theme/types.js";
@@ -269,6 +269,9 @@ export interface RequestContext<
269
269
 
270
270
  /** @internal Per-request debug performance override (set via ctx.debugPerformance()) */
271
271
  _debugPerformance?: boolean;
272
+
273
+ /** @internal Request-scoped performance metrics store */
274
+ _metricsStore?: MetricsStore;
272
275
  }
273
276
 
274
277
  /**
@@ -297,6 +300,7 @@ export type PublicRequestContext<
297
300
  | "_reportedErrors"
298
301
  | "_reportBackgroundError"
299
302
  | "_debugPerformance"
303
+ | "_metricsStore"
300
304
  >;
301
305
 
302
306
  // AsyncLocalStorage instance for request context
@@ -681,6 +685,7 @@ export function createRequestContext<TEnv>(
681
685
  _locationState: undefined,
682
686
 
683
687
  _reportedErrors: new WeakSet<object>(),
688
+ _metricsStore: undefined,
684
689
 
685
690
  reverse: createReverseFunction(getGlobalRouteMap(), undefined, {}),
686
691
  };