@rangojs/router 0.0.0-experimental.142 → 0.0.0-experimental.143

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.
@@ -41,7 +41,7 @@ import { runThenSettle } from "./tracing.js";
41
41
  import type {
42
42
  RouterTracingConfig,
43
43
  SpanRunner,
44
- TracePhaseToggles,
44
+ TracingToggleOptions,
45
45
  } from "./tracing.js";
46
46
 
47
47
  // ---------------------------------------------------------------------------
@@ -92,13 +92,11 @@ const STATUS_ERROR = 2;
92
92
  // Tracing adapter: phase spans via startActiveSpan (the `tracing` slot)
93
93
  // ---------------------------------------------------------------------------
94
94
 
95
- /** Options for createOTelTracing. */
96
- export interface OTelTracingOptions {
97
- /** Master switch. Defaults to true. */
98
- enabled?: boolean;
99
- /** Per-phase span toggles. Omitted phases default to enabled. */
100
- spans?: TracePhaseToggles;
101
- }
95
+ /**
96
+ * Options for createOTelTracing. Alias of the shared {@link TracingToggleOptions}
97
+ * (`enabled` master switch + per-phase `spans` toggles); the name is public API.
98
+ */
99
+ export type OTelTracingOptions = TracingToggleOptions;
102
100
 
103
101
  /**
104
102
  * Create the tracing config that maps the router's phases onto OTel spans via
@@ -81,17 +81,26 @@ export interface TracePhaseToggles {
81
81
  ssr?: boolean;
82
82
  }
83
83
 
84
+ /**
85
+ * The option pair shared by every tracing factory (enabled master switch +
86
+ * per-phase span toggles). Extended by OTelTracingOptions,
87
+ * CloudflareTracingOptions, VercelTracingOptions, and RouterTracingConfig so
88
+ * a phase added to TracePhaseToggles propagates everywhere from one place.
89
+ */
90
+ export interface TracingToggleOptions {
91
+ /** Master switch. Defaults to true. */
92
+ enabled?: boolean;
93
+ /** Per-phase span toggles. Omitted phases default to enabled. */
94
+ spans?: TracePhaseToggles;
95
+ }
96
+
84
97
  /**
85
98
  * Value passed to `createRouter({ tracing })`. Produced by a platform factory
86
99
  * such as `createCloudflareTracing()`.
87
100
  */
88
- export interface RouterTracingConfig {
101
+ export interface RouterTracingConfig extends TracingToggleOptions {
89
102
  /** Platform span runner. */
90
103
  runner: SpanRunner;
91
- /** Master switch. Defaults to true when a config object is provided. */
92
- enabled?: boolean;
93
- /** Per-phase span toggles. */
94
- spans?: TracePhaseToggles;
95
104
  }
96
105
 
97
106
  /**
package/src/router.ts CHANGED
@@ -420,13 +420,13 @@ export function createRouter<TEnv = any>(
420
420
 
421
421
  // Wrapper to pass debugPerformance to external createMetricsStore.
422
422
  // Also checks per-request flag set by ctx.debugPerformance() in middleware.
423
+ // With no active request context there is nowhere to hang the store, so return
424
+ // undefined: an orphan store would collect metrics no reader can reach (nothing
425
+ // holds it, and appendMetric(undefined, ...) is already a no-op).
423
426
  const getMetricsStore = () => {
424
427
  const reqCtx = _getRequestContext();
425
428
  const enabled = debugPerformance || !!reqCtx?._debugPerformance;
426
- if (!enabled) return undefined;
427
- if (!reqCtx) {
428
- return createMetricsStore(true);
429
- }
429
+ if (!enabled || !reqCtx) return undefined;
430
430
  reqCtx._metricsStore ??= createMetricsStore(true);
431
431
  return reqCtx._metricsStore;
432
432
  };
@@ -501,8 +501,12 @@ export function createRouter<TEnv = any>(
501
501
  ? getRequestId(errorContext.request)
502
502
  : undefined
503
503
  : undefined;
504
+ // Derived once here for both the loader.start and loader.end emits (the
505
+ // loader.error emit uses ctx.loaderName from wrapLoaderWithErrorHandling).
506
+ const loaderName = telemetrySink
507
+ ? segmentId.split(".").pop() || "unknown"
508
+ : "";
504
509
  if (telemetrySink) {
505
- const loaderName = segmentId.split(".").pop() || "unknown";
506
510
  safeEmit(telemetry, {
507
511
  type: "loader.start",
508
512
  timestamp: loaderStart,
@@ -556,7 +560,6 @@ export function createRouter<TEnv = any>(
556
560
 
557
561
  // Emit loader.end after the promise settles (fire-and-forget)
558
562
  if (telemetrySink) {
559
- const loaderName = segmentId.split(".").pop() || "unknown";
560
563
  result.then((r) => {
561
564
  safeEmit(telemetry, {
562
565
  type: "loader.end",
@@ -1008,6 +1011,12 @@ export function createRouter<TEnv = any>(
1008
1011
  // Expose resolved span tracing for the handler (Cloudflare custom spans)
1009
1012
  tracing: resolvedTracing,
1010
1013
 
1014
+ // Expose the raw telemetry sink so handler-level emitters (timeout, origin
1015
+ // rejection, late-handle handler.error) can emit outside the match ALS.
1016
+ // Raw (not the resolveSink no-op wrapper) so router.telemetry stays
1017
+ // undefined when unconfigured and call sites gate on truthiness.
1018
+ telemetry: telemetrySink,
1019
+
1011
1020
  // Expose debug manifest flag for handler
1012
1021
  allowDebugManifest: allowDebugManifestOption,
1013
1022
 
@@ -85,7 +85,8 @@ import {
85
85
  appendMetric,
86
86
  buildMetricsTiming,
87
87
  } from "../router/metrics.js";
88
- import { observePhase, observeEvent, PHASES } from "../router/instrument.js";
88
+ import { observePhase, PHASES } from "../router/instrument.js";
89
+ import { safeEmit, resolveSink, getRequestId } from "../router/telemetry.js";
89
90
  import {
90
91
  startSSRSetup,
91
92
  getSSRSetup,
@@ -246,16 +247,19 @@ export function createRSCHandler<
246
247
  metadata: { timeout: true, phase, durationMs },
247
248
  });
248
249
 
249
- observeEvent({
250
- type: "request.timeout",
251
- timestamp: performance.now(),
252
- phase,
253
- pathname: url.pathname,
254
- routeKey,
255
- actionId,
256
- durationMs,
257
- customHandler: !!router.onTimeout,
258
- });
250
+ if (router.telemetry) {
251
+ safeEmit(resolveSink(router.telemetry), {
252
+ type: "request.timeout",
253
+ timestamp: performance.now(),
254
+ requestId: getRequestId(request),
255
+ phase,
256
+ pathname: url.pathname,
257
+ routeKey,
258
+ actionId,
259
+ durationMs,
260
+ customHandler: !!router.onTimeout,
261
+ });
262
+ }
259
263
 
260
264
  if (router.onTimeout) {
261
265
  try {
@@ -590,7 +594,13 @@ export function createRSCHandler<
590
594
 
591
595
  const fullTiming = timingParts.join(", ");
592
596
  if (fullTiming && !isWebSocketUpgradeResponse(response)) {
593
- response.headers.set("Server-Timing", fullTiming);
597
+ try {
598
+ response.headers.set("Server-Timing", fullTiming);
599
+ } catch {
600
+ // Immutable headers (e.g. a passed-through platform Response) — drop
601
+ // the timing header, never the response. Instrumentation must not
602
+ // 500 a request.
603
+ }
594
604
  }
595
605
 
596
606
  // Single open-redirect chokepoint: every response (PE, full-page,
@@ -737,15 +747,18 @@ export function createRSCHandler<
737
747
  },
738
748
  });
739
749
 
740
- observeEvent({
741
- type: "request.origin-rejected",
742
- timestamp: performance.now(),
743
- method: request.method,
744
- pathname: url.pathname,
745
- phase: originPhase,
746
- origin: request.headers.get("origin"),
747
- host: request.headers.get("host"),
748
- });
750
+ if (router.telemetry) {
751
+ safeEmit(resolveSink(router.telemetry), {
752
+ type: "request.origin-rejected",
753
+ timestamp: performance.now(),
754
+ requestId: getRequestId(request),
755
+ method: request.method,
756
+ pathname: url.pathname,
757
+ phase: originPhase,
758
+ origin: request.headers.get("origin"),
759
+ host: request.headers.get("host"),
760
+ });
761
+ }
749
762
 
750
763
  return originResult;
751
764
  }
@@ -787,15 +800,18 @@ export function createRSCHandler<
787
800
  params: reqCtx.params as Record<string, string>,
788
801
  handledByBoundary: true,
789
802
  });
790
- observeEvent({
791
- type: "handler.error",
792
- timestamp: performance.now(),
793
- error,
794
- handledByBoundary: true,
795
- pathname: url.pathname,
796
- routeKey: reqCtx._routeName,
797
- params: reqCtx.params as Record<string, string>,
798
- });
803
+ if (router.telemetry) {
804
+ safeEmit(resolveSink(router.telemetry), {
805
+ type: "handler.error",
806
+ timestamp: performance.now(),
807
+ requestId: getRequestId(request),
808
+ error,
809
+ handledByBoundary: true,
810
+ pathname: url.pathname,
811
+ routeKey: reqCtx._routeName,
812
+ params: reqCtx.params as Record<string, string>,
813
+ });
814
+ }
799
815
  };
800
816
 
801
817
  // Set route params early so all execution paths can access ctx.params.
@@ -715,6 +715,11 @@ async function attemptCapture(
715
715
  derivedCtx._transitionWhen = [];
716
716
  derivedCtx._shellCaptureRun = true;
717
717
  derivedCtx._metricsStore = undefined;
718
+ // Spans, like perf metrics above, are a FOREGROUND surface: the capture
719
+ // re-render must not emit a second rango.render/loader/ssr set after the
720
+ // foreground rango.request span ended (orphan spans in the trace).
721
+ // _tracing is otherwise inherited through Object.create(reqCtx).
722
+ derivedCtx._tracing = undefined;
718
723
  // Bake-lane loader containers (loaders on entries with no renderable
719
724
  // loading() execute during capture — docs/design/loader-container-bake.md).
720
725
  // resolveLoaderData registers each container promise here; the drain in
@@ -55,6 +55,14 @@
55
55
  * metadata.category, while the request still degrades-to-miss exactly as before.
56
56
  * (A thrown response-route HANDLER error is the one onError path NOT covered —
57
57
  * see "DOES NOT support" below.)
58
+ * - createRouter({ telemetry }) match-transaction lifecycle: request.start opens
59
+ * the transaction before the global middleware chain, request.end closes it
60
+ * after finalizeResponse (segmentCount 0 / cacheHit false — dispatch renders no
61
+ * RSC segments and holds no match-cache state), and a thrown non-Response error
62
+ * emits request.error with phase "routing". All three carry the same requestId
63
+ * (getRequestId). Emission is gated entirely on a configured sink; with none,
64
+ * dispatch does zero new work and stays byte-identical for existing callers.
65
+ * Lets a consumer unit-test their sink wiring in-process instead of only at e2e.
58
66
  *
59
67
  * What dispatch DOES NOT support (and why):
60
68
  * - RSC component routes — rendering requires the Flight serializer + React
@@ -76,6 +84,13 @@
76
84
  * merged cookies/headers all match production; only the Flight-embedded
77
85
  * location-state entries are absent. Cover location-state restoration across a
78
86
  * partial redirect with an e2e test.
87
+ * - Telemetry cache.decision / loader.* / handler.error events: these fire from
88
+ * the real match()/matchPartial() + RSC render + loader pipeline (match-
89
+ * handlers.ts, loader-resolution.ts, segment-resolution), none of which
90
+ * dispatch runs. Synthesizing them here would be faking (the events would not
91
+ * reflect a real cache lookup or loader run), so dispatch emits only the
92
+ * request.start/end/error lifecycle above; cover cache/loader telemetry with an
93
+ * e2e test driving a real RSC request.
79
94
  *
80
95
  * dispatch reuses router.previewMatch(), which itself runs content negotiation
81
96
  * and resolves route middleware from the matched entry tree, so dispatch's
@@ -127,6 +142,8 @@ import { isWebSocketUpgradeResponse } from "../response-utils.js";
127
142
  import { invokeOnError } from "../router/error-handling.js";
128
143
  import type { OnErrorCallback } from "../types/error-types.js";
129
144
  import type { Rango } from "../router/router-interfaces.js";
145
+ import { getRequestId, resolveSink, safeEmit } from "../router/telemetry.js";
146
+ import type { TelemetrySink } from "../router/telemetry.js";
130
147
 
131
148
  /**
132
149
  * The internal subset of the router surface dispatch depends on. The public
@@ -140,6 +157,13 @@ interface DispatchableRouter<TEnv> {
140
157
  routeMap: Record<string, unknown>;
141
158
  middleware: MiddlewareEntry<TEnv>[];
142
159
  onError?: OnErrorCallback<TEnv>;
160
+ /**
161
+ * Optional telemetry sink from createRouter({ telemetry }) (RangoInternal
162
+ * field). dispatch emits the match-transaction lifecycle events onto it so a
163
+ * consumer can unit-test their sink wiring in-process; undefined keeps dispatch
164
+ * byte-identical for every existing caller.
165
+ */
166
+ telemetry?: TelemetrySink;
143
167
  findMatch(pathname: string): Promise<{
144
168
  redirectTo?: string;
145
169
  routeKey?: string;
@@ -444,6 +468,19 @@ export async function dispatch<TEnv = any>(
444
468
  const isPartial = url.searchParams.has("_rsc_partial");
445
469
  const isAction = url.searchParams.has("_rsc_action");
446
470
 
471
+ // Telemetry: mirror the router's match-transaction lifecycle onto the
472
+ // configured sink so a consumer can unit-test createRouter({ telemetry })
473
+ // wiring in-process (the dogfood gap the RSC-free dispatch left — see
474
+ // tests/cloudflare-basic/test/cache-status.test.ts). Every emit is gated on
475
+ // `sink` truthiness: with no sink configured dispatch does zero new work and
476
+ // stays byte-identical for existing callers. Only request.start/end/error are
477
+ // reachable here — cache.decision and loader.* originate in the real match()/
478
+ // matchPartial() + RSC render pipeline dispatch deliberately does not run
479
+ // (module header), so fabricating them would violate the no-fake rule.
480
+ const sink = router.telemetry;
481
+ const telemetryRequestId = sink ? getRequestId(req) : undefined;
482
+ const telemetryStart = sink ? performance.now() : 0;
483
+
447
484
  return runWithRequestContext(requestContext, async () => {
448
485
  // Set params before middleware/handler run, so global middleware sees
449
486
  // ctx.params (production sets them during matching, before middleware).
@@ -658,44 +695,112 @@ export async function dispatch<TEnv = any>(
658
695
  return callResponseRoute();
659
696
  };
660
697
 
661
- // Global (pattern-matched) middleware wraps coreHandler, exactly as
662
- // production wraps coreHandler with executeMiddleware (handler.ts).
663
- const globalMatches = matchMiddleware(url.pathname, router.middleware);
664
- const mwResponse =
665
- globalMatches.length === 0
666
- ? await coreHandler()
667
- : await executeMiddleware<TEnv>(
668
- globalMatches,
669
- req,
670
- env,
671
- variables,
672
- coreHandler,
673
- reverse,
674
- );
675
-
676
- // Match production's global-chain exit (handler.ts): on a partial/action
677
- // request a middleware 3xx redirect is converted to a Flight-safe response
678
- // so fetch() does not auto-follow it; every path then drains onResponse
679
- // callbacks via finalizeResponse. dispatch is RSC-free, so the
680
- // createRedirectFlightResponse stand-in falls back to the no-state
681
- // 204 + X-RSC-Redirect (see the location-state divergence in the header).
682
- let finalResponse: Response;
683
- if (isPartial || isAction) {
684
- const intercepted = interceptRedirectForPartial(
685
- mwResponse,
686
- (redirectUrl) => createSimpleRedirectResponse(redirectUrl),
687
- );
688
- finalResponse = finalizeResponse(intercepted ?? mwResponse);
689
- } else {
690
- finalResponse = finalizeResponse(mwResponse);
698
+ // request.start opens the match transaction, mirroring match-handlers.ts.
699
+ // transaction is always "match" (dispatch has no matchPartial split);
700
+ // isPartial carries the ?_rsc_partial signal the same way production does.
701
+ if (sink) {
702
+ safeEmit(resolveSink(sink), {
703
+ type: "request.start",
704
+ timestamp: telemetryStart,
705
+ requestId: telemetryRequestId,
706
+ method: req.method,
707
+ pathname: url.pathname,
708
+ transaction: "match",
709
+ isPartial,
710
+ });
691
711
  }
692
712
 
693
- // Mirror production's single open-redirect chokepoint (handler.ts): every
694
- // browser-followed (3xx + Location) redirect is same-origin guarded before
695
- // it leaves -- a cross-origin Location is rewritten to the basename root
696
- // unless redirect(url, { external: true }) opted out. Soft partial/action
697
- // redirects are 204 + X-RSC-Redirect and pass through untouched (the client
698
- // validates them), so this is a no-op for them.
699
- return guardOutgoingRedirect(finalResponse, url.origin, router.basename);
713
+ try {
714
+ // Global (pattern-matched) middleware wraps coreHandler, exactly as
715
+ // production wraps coreHandler with executeMiddleware (handler.ts).
716
+ const globalMatches = matchMiddleware(url.pathname, router.middleware);
717
+ const mwResponse =
718
+ globalMatches.length === 0
719
+ ? await coreHandler()
720
+ : await executeMiddleware<TEnv>(
721
+ globalMatches,
722
+ req,
723
+ env,
724
+ variables,
725
+ coreHandler,
726
+ reverse,
727
+ );
728
+
729
+ // Match production's global-chain exit (handler.ts): on a partial/action
730
+ // request a middleware 3xx redirect is converted to a Flight-safe response
731
+ // so fetch() does not auto-follow it; every path then drains onResponse
732
+ // callbacks via finalizeResponse. dispatch is RSC-free, so the
733
+ // createRedirectFlightResponse stand-in falls back to the no-state
734
+ // 204 + X-RSC-Redirect (see the location-state divergence in the header).
735
+ let finalResponse: Response;
736
+ if (isPartial || isAction) {
737
+ const intercepted = interceptRedirectForPartial(
738
+ mwResponse,
739
+ (redirectUrl) => createSimpleRedirectResponse(redirectUrl),
740
+ );
741
+ finalResponse = finalizeResponse(intercepted ?? mwResponse);
742
+ } else {
743
+ finalResponse = finalizeResponse(mwResponse);
744
+ }
745
+
746
+ // request.end closes the transaction. dispatch produces no RSC segments and
747
+ // holds no match-cache state, so segmentCount/cacheHit are 0/false — the
748
+ // same shape production emits for its own redirect (segment-less) result.
749
+ if (sink) {
750
+ safeEmit(resolveSink(sink), {
751
+ type: "request.end",
752
+ timestamp: performance.now(),
753
+ requestId: telemetryRequestId,
754
+ method: req.method,
755
+ pathname: url.pathname,
756
+ transaction: "match",
757
+ durationMs: performance.now() - telemetryStart,
758
+ segmentCount: 0,
759
+ cacheHit: false,
760
+ });
761
+ }
762
+
763
+ // Mirror production's single open-redirect chokepoint (handler.ts): every
764
+ // browser-followed (3xx + Location) redirect is same-origin guarded before
765
+ // it leaves -- a cross-origin Location is rewritten to the basename root
766
+ // unless redirect(url, { external: true }) opted out. Soft partial/action
767
+ // redirects are 204 + X-RSC-Redirect and pass through untouched (the client
768
+ // validates them), so this is a no-op for them.
769
+ return guardOutgoingRedirect(finalResponse, url.origin, router.basename);
770
+ } catch (error) {
771
+ if (sink) {
772
+ if (error instanceof Response) {
773
+ // executeMiddleware absorbs a middleware-thrown Response and returns it
774
+ // (middleware.ts:566), so a thrown Response never actually reaches this
775
+ // level in dispatch. Defensive: if one ever does it is a completed
776
+ // request from the consumer's seat (a short-circuit redirect), so mirror
777
+ // plan 002 / match-handlers.ts and emit request.end, not request.error.
778
+ safeEmit(resolveSink(sink), {
779
+ type: "request.end",
780
+ timestamp: performance.now(),
781
+ requestId: telemetryRequestId,
782
+ method: req.method,
783
+ pathname: url.pathname,
784
+ transaction: "match",
785
+ durationMs: performance.now() - telemetryStart,
786
+ segmentCount: 0,
787
+ cacheHit: false,
788
+ });
789
+ } else {
790
+ safeEmit(resolveSink(sink), {
791
+ type: "request.error",
792
+ timestamp: performance.now(),
793
+ requestId: telemetryRequestId,
794
+ method: req.method,
795
+ pathname: url.pathname,
796
+ transaction: "match",
797
+ error: error instanceof Error ? error : new Error(String(error)),
798
+ phase: "routing",
799
+ durationMs: performance.now() - telemetryStart,
800
+ });
801
+ }
802
+ }
803
+ throw error;
804
+ }
700
805
  });
701
806
  }
@@ -268,9 +268,16 @@ export type PathHelpers<TEnv> = {
268
268
  * `{ handler, use? }` whose `use` is scoped to that slot only. Per-slot
269
269
  * merge order is `handler.use` → shared `use` → slot-local `use`, with
270
270
  * narrowest scope winning for last-write-wins items like `loading()`.
271
+ *
272
+ * Not generic over the slots record: an inferred type parameter makes the
273
+ * object literal an inference site, which suppresses contextual typing of
274
+ * arrow slot handlers (`(ctx) => ...` was implicit any). Bare handlers infer
275
+ * now; a descriptor's `handler:` arrow still needs an explicit ctx annotation
276
+ * because StaticHandlerDefinition's own `.handler` joins the contextual union
277
+ * (two callables — see parallel-slot-handler-types.test.ts).
271
278
  */
272
- parallel: <
273
- TSlots extends Record<
279
+ parallel: (
280
+ slots: Record<
274
281
  `@${string}`,
275
282
  | Handler<any, any, TEnv>
276
283
  | ReactNode
@@ -283,8 +290,6 @@ export type PathHelpers<TEnv> = {
283
290
  use?: () => ParallelUseItem[];
284
291
  }
285
292
  >,
286
- >(
287
- slots: TSlots,
288
293
  use?: () => ParallelUseItem[],
289
294
  ) => ParallelItem;
290
295
 
@@ -44,15 +44,15 @@ import {
44
44
  } from "../router/telemetry-otel.js";
45
45
  import type {
46
46
  RouterTracingConfig,
47
- TracePhaseToggles,
47
+ TracingToggleOptions,
48
48
  } from "../router/tracing.js";
49
49
 
50
- /** Options for createVercelTracing. */
51
- export interface VercelTracingOptions {
52
- /** Master switch. Defaults to true. */
53
- enabled?: boolean;
54
- /** Per-phase span toggles. Omitted phases default to enabled. */
55
- spans?: TracePhaseToggles;
50
+ /**
51
+ * Options for createVercelTracing. Extends the shared
52
+ * {@link TracingToggleOptions} (`enabled` + per-phase `spans`) with the
53
+ * Vercel-specific tracer selectors.
54
+ */
55
+ export interface VercelTracingOptions extends TracingToggleOptions {
56
56
  /**
57
57
  * OTel instrumentation-scope name passed to `trace.getTracer()`. Defaults to
58
58
  * `"rango"`. Ignored when `tracer` is provided.