@rangojs/router 0.0.0-experimental.126 → 0.0.0-experimental.128

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 (44) hide show
  1. package/dist/bin/rango.js +5 -1
  2. package/dist/vite/index.js +55 -40
  3. package/package.json +23 -19
  4. package/skills/observability/SKILL.md +39 -4
  5. package/skills/prerender/SKILL.md +30 -11
  6. package/skills/router-setup/SKILL.md +23 -3
  7. package/src/build/route-types/codegen.ts +12 -1
  8. package/src/cache/cache-scope.ts +20 -0
  9. package/src/cloudflare/index.ts +11 -0
  10. package/src/cloudflare/tracing.ts +112 -0
  11. package/src/index.rsc.ts +19 -2
  12. package/src/index.ts +16 -1
  13. package/src/route-definition/dsl-helpers.ts +19 -0
  14. package/src/router/instrument.ts +440 -0
  15. package/src/router/loader-resolution.ts +15 -10
  16. package/src/router/match-middleware/cache-lookup.ts +9 -14
  17. package/src/router/match-middleware/cache-store.ts +12 -0
  18. package/src/router/middleware.ts +23 -2
  19. package/src/router/prerender-match.ts +5 -2
  20. package/src/router/router-context.ts +2 -1
  21. package/src/router/router-interfaces.ts +8 -0
  22. package/src/router/router-options.ts +58 -4
  23. package/src/router/segment-resolution/fresh.ts +15 -18
  24. package/src/router/segment-resolution/helpers.ts +6 -0
  25. package/src/router/segment-resolution/loader-cache.ts +5 -0
  26. package/src/router/segment-resolution/revalidation.ts +9 -18
  27. package/src/router/segment-wrappers.ts +3 -2
  28. package/src/router/telemetry-otel.ts +161 -179
  29. package/src/router/tracing.ts +203 -0
  30. package/src/router.ts +9 -0
  31. package/src/rsc/handler.ts +140 -134
  32. package/src/rsc/loader-fetch.ts +7 -1
  33. package/src/rsc/progressive-enhancement.ts +9 -2
  34. package/src/rsc/rsc-rendering.ts +38 -14
  35. package/src/rsc/server-action.ts +28 -7
  36. package/src/segment-system.tsx +4 -1
  37. package/src/server/request-context.ts +23 -5
  38. package/src/vite/discovery/prerender-collection.ts +26 -37
  39. package/src/vite/discovery/state.ts +6 -0
  40. package/src/vite/plugin-types.ts +25 -0
  41. package/src/vite/plugins/expose-ids/router-transform.ts +10 -0
  42. package/src/vite/rango.ts +1 -0
  43. package/src/vite/router-discovery.ts +9 -3
  44. package/src/vite/utils/prerender-utils.ts +36 -0
@@ -17,6 +17,7 @@ import { setupBuildUse } from "./loader-resolution.js";
17
17
  import { loadManifest } from "./manifest.js";
18
18
  import { traverseBack } from "./pattern-matching.js";
19
19
  import type { RouterContext } from "./router-context.js";
20
+ import type { ResolveSegmentOptions } from "./segment-resolution.js";
20
21
  import { runWithRouterContext } from "./router-context.js";
21
22
  import type { EntryData, InterceptEntry } from "../server/context";
22
23
  import type {
@@ -40,7 +41,7 @@ export interface PrerenderMatchDeps<TEnv = any> {
40
41
  params: Record<string, string>,
41
42
  context: HandlerContext<any, TEnv>,
42
43
  loaderPromises: Map<string, Promise<any>>,
43
- options?: { skipLoaders?: boolean },
44
+ options?: ResolveSegmentOptions,
44
45
  ) => Promise<ResolvedSegment[]>;
45
46
  }
46
47
 
@@ -257,7 +258,9 @@ export async function matchForPrerender<TEnv = any>(
257
258
  matchedParams,
258
259
  buildCtx,
259
260
  loaderPromises,
260
- { skipLoaders: true },
261
+ // throwOnError: a render failure (or `throw new Skip()`) must reach the
262
+ // build/dev caller, not be baked into a frozen error page (issue #587).
263
+ { skipLoaders: true, throwOnError: true },
261
264
  );
262
265
 
263
266
  // 9. Detect passthrough sentinel: handler returned ctx.passthrough().
@@ -19,6 +19,7 @@ import type {
19
19
  } from "../types.js";
20
20
  import type { RouteMatchResult } from "./pattern-matching.js";
21
21
  import type { TelemetrySink } from "./telemetry.js";
22
+ import type { ResolveSegmentOptions } from "./segment-resolution.js";
22
23
 
23
24
  /**
24
25
  * Revalidation context passed to segment resolution
@@ -195,7 +196,7 @@ export interface RouterContext<TEnv = any> {
195
196
  params: Record<string, string>,
196
197
  handlerContext: HandlerContext<any, TEnv>,
197
198
  loaderPromises: Map<string, Promise<any>>,
198
- options?: { skipLoaders?: boolean },
199
+ options?: ResolveSegmentOptions,
199
200
  ) => Promise<ResolvedSegment[]>;
200
201
 
201
202
  resolveAllSegmentsGenerator?: (
@@ -14,6 +14,7 @@ import { RSC_ROUTER_BRAND } from "./router-registry.js";
14
14
  import type { RangoOptions, RootLayoutProps } from "./router-options.js";
15
15
  import type { DefaultVars } from "../types/global-namespace.js";
16
16
  import type { ResolvedTimeouts, OnTimeoutCallback } from "./timeout.js";
17
+ import type { ResolvedTracing } from "./tracing.js";
17
18
 
18
19
  /**
19
20
  * Options passed to router.fetch(), router.match(), and other request entrypoints.
@@ -320,6 +321,13 @@ export interface RangoInternal<
320
321
  */
321
322
  readonly debugPerformance?: boolean;
322
323
 
324
+ /**
325
+ * Resolved platform phase-span tracing (Cloudflare custom spans or OTel), or
326
+ * undefined when off. Threaded onto the request context and read at each
327
+ * traced phase.
328
+ */
329
+ readonly tracing?: ResolvedTracing;
330
+
323
331
  /**
324
332
  * Whether ?__debug_manifest is allowed in production.
325
333
  * Always enabled in development.
@@ -11,6 +11,7 @@ import type { UrlPatterns } from "../urls.js";
11
11
  import type { UrlBuilder } from "../urls/pattern-types.js";
12
12
  import type { NamedRouteEntry } from "./content-negotiation.js";
13
13
  import type { TelemetrySink } from "./telemetry.js";
14
+ import type { RouterTracingConfig } from "./tracing.js";
14
15
  import type { RouterTimeouts, OnTimeoutCallback } from "./timeout.js";
15
16
 
16
17
  /**
@@ -574,11 +575,14 @@ export interface RangoOptions<TEnv = any> {
574
575
  onTimeout?: OnTimeoutCallback<TEnv>;
575
576
 
576
577
  /**
577
- * Telemetry sink for structured lifecycle events.
578
+ * Telemetry sink for structured, discrete lifecycle EVENTS: request
579
+ * start/end/error, loader start/end/error, handler errors, cache decisions,
580
+ * revalidation decisions, timeouts, origin rejections.
578
581
  *
579
- * When provided, the router emits events for request start/end,
580
- * loader start/end/error, handler errors, cache decisions, and
581
- * revalidation decisions.
582
+ * This is the EVENT surface. Phase-duration SPANS (request/loader/render/ssr
583
+ * timing wired into a tracing backend) come from the separate `tracing`
584
+ * option below — a sink does not emit them, because async-context nesting
585
+ * cannot be faithfully reconstructed from after-the-fact start/end events.
582
586
  *
583
587
  * No-op when not configured (zero overhead).
584
588
  *
@@ -591,6 +595,18 @@ export interface RangoOptions<TEnv = any> {
591
595
  * });
592
596
  * ```
593
597
  *
598
+ * @example OpenTelemetry — pair the event sink with the tracing slot
599
+ * ```typescript
600
+ * import { createOTelTracing, createOTelSink } from "@rangojs/router";
601
+ * import { trace } from "@opentelemetry/api";
602
+ *
603
+ * const tracer = trace.getTracer("my-app");
604
+ * const router = createRouter({
605
+ * tracing: createOTelTracing(tracer), // phase spans
606
+ * telemetry: createOTelSink(tracer), // discrete-fact events
607
+ * });
608
+ * ```
609
+ *
594
610
  * @example Custom sink
595
611
  * ```typescript
596
612
  * const router = createRouter({
@@ -604,6 +620,44 @@ export interface RangoOptions<TEnv = any> {
604
620
  */
605
621
  telemetry?: TelemetrySink;
606
622
 
623
+ /**
624
+ * Span tracing for the router's performance phases (request, middleware, action,
625
+ * loaders, render, ssr). Connects the same phases shown in the
626
+ * `debugPerformance` timeline to the host platform's tracing system. This is
627
+ * the SPAN surface (the `telemetry` option above is the event surface).
628
+ *
629
+ * Two factories produce a config, both for this slot:
630
+ * - `createOTelTracing(tracer)` from `@rangojs/router` — any platform with an
631
+ * OpenTelemetry SDK (including Node). Bridges the phases onto
632
+ * `tracer.startActiveSpan`.
633
+ * - `createCloudflareTracing()` from `@rangojs/router/cloudflare` — Cloudflare
634
+ * Workers native custom spans, alongside the automatic KV/D1/fetch spans.
635
+ *
636
+ * When tracing is unset — or off-platform (no OTel SDK / no Cloudflare tracing
637
+ * destination) — every span call falls through to the work directly, so the
638
+ * request behaves exactly as if tracing were off.
639
+ *
640
+ * @example OpenTelemetry
641
+ * ```typescript
642
+ * import { createOTelTracing } from "@rangojs/router";
643
+ * import { trace } from "@opentelemetry/api";
644
+ *
645
+ * const router = createRouter({
646
+ * tracing: createOTelTracing(trace.getTracer("my-app")),
647
+ * });
648
+ * ```
649
+ *
650
+ * @example Cloudflare
651
+ * ```typescript
652
+ * import { createCloudflareTracing } from "@rangojs/router/cloudflare";
653
+ *
654
+ * const router = createRouter({
655
+ * tracing: createCloudflareTracing({ spans: { ssr: false } }),
656
+ * });
657
+ * ```
658
+ */
659
+ tracing?: RouterTracingConfig;
660
+
607
661
  /**
608
662
  * SSR configuration options.
609
663
  *
@@ -19,8 +19,6 @@ import type {
19
19
  } from "../../types";
20
20
  import type { SegmentResolutionDeps } from "../types.js";
21
21
  import { resolveLoaderData } from "./loader-cache.js";
22
- import { _getRequestContext } from "../../server/request-context.js";
23
- import { appendMetric } from "../metrics.js";
24
22
  import {
25
23
  handleHandlerResult,
26
24
  tryStaticHandler,
@@ -59,7 +57,6 @@ export async function resolveLoaders<TEnv>(
59
57
  const shortCode = shortCodeOverride ?? entry.shortCode;
60
58
  const hasLoading = "loading" in entry && entry.loading !== undefined;
61
59
  const loadingDisabled = hasLoading && entry.loading === false;
62
- const ms = _getRequestContext()?._metricsStore;
63
60
 
64
61
  if (!loadingDisabled) {
65
62
  // Streaming loaders: promises kick off now, settle during RSC serialization.
@@ -102,7 +99,6 @@ export async function resolveLoaders<TEnv>(
102
99
  const pendingLoaderData = loaderEntries.map((loaderEntry, i) => {
103
100
  const { loader } = loaderEntry;
104
101
  const segmentId = `${shortCode}D${i}.${loader.$$id}`;
105
- const start = performance.now();
106
102
  const wrapped = deps.wrapLoaderPromise(
107
103
  runInsideLoaderScope(() =>
108
104
  resolveLoaderData(loaderEntry, ctx, ctx.pathname),
@@ -111,26 +107,17 @@ export async function resolveLoaders<TEnv>(
111
107
  segmentId,
112
108
  ctx.pathname,
113
109
  );
114
- return { wrapped, start, segmentId, loaderId: loader.$$id };
110
+ return { wrapped, segmentId };
115
111
  });
116
112
  await Promise.all(pendingLoaderData.map((p) => p.wrapped));
117
113
 
118
114
  return loaderEntries.map((loaderEntry, i) => {
119
115
  const { loader } = loaderEntry;
120
116
  const pending = pendingLoaderData[i]!;
121
- if (ms && !ms.metrics.some((m) => m.label === `loader:${loader.$$id}`)) {
122
- // All loaders ran in parallel via Promise.all — each span covers
123
- // from its own kickoff to the batch settlement, giving a ceiling
124
- // on that loader's contribution to the overall wait.
125
- const batchEnd = performance.now();
126
- appendMetric(
127
- ms,
128
- `loader:${loader.$$id}`,
129
- pending.start,
130
- batchEnd - pending.start,
131
- 2,
132
- );
133
- }
117
+ // The "loader:<id>" perf metric is recorded by observePhase at the single
118
+ // loader-metering site (useLoader, reached via ctx.use during
119
+ // resolveLoaderData), with the real per-loader duration rather than a
120
+ // Promise.all batch ceiling.
134
121
  return {
135
122
  id: pending.segmentId,
136
123
  namespace: entry.id,
@@ -151,6 +138,15 @@ export async function resolveLoaders<TEnv>(
151
138
  export interface ResolveSegmentOptions {
152
139
  /** When true, skip resolveLoaders() calls (used for pre-rendering) */
153
140
  skipLoaders?: boolean;
141
+ /**
142
+ * When true, a thrown render error is re-thrown instead of being converted
143
+ * into an error-boundary segment. Set only by the pre-render path so a
144
+ * build-time render failure (and a `throw new Skip()` inside a render fn)
145
+ * surfaces to the build instead of being silently baked into a frozen error
146
+ * page served as a 200 (issue #587). The live request path leaves this unset,
147
+ * so error boundaries keep catching at request time.
148
+ */
149
+ throwOnError?: boolean;
154
150
  }
155
151
 
156
152
  /**
@@ -635,6 +631,7 @@ export async function resolveAllSegments<TEnv>(
635
631
  deps,
636
632
  { request: safeRequest, url: context.url, routeKey, telemetry },
637
633
  context.pathname,
634
+ options?.throwOnError,
638
635
  );
639
636
  doneEntry();
640
637
  // Deduplicate by segment ID. include() scopes can produce entries that
@@ -284,11 +284,17 @@ export async function resolveWithErrorBoundary<TEnv, TResult>(
284
284
  deps: SegmentResolutionDeps<TEnv>,
285
285
  report?: ErrorReportContext,
286
286
  pathname?: string,
287
+ throwOnError?: boolean,
287
288
  ): Promise<TResult> {
288
289
  try {
289
290
  return await resolveFn();
290
291
  } catch (error) {
291
292
  if (error instanceof Response) throw error;
293
+ // Pre-render surfaces render failures to the build instead of baking the
294
+ // error boundary as a frozen 200 (issue #587). A `throw new Skip()` in a
295
+ // render fn also propagates here so the build can skip that URL rather than
296
+ // bake its error page. The live request path leaves throwOnError unset.
297
+ if (throwOnError) throw error;
292
298
  const segment = catchSegmentError(
293
299
  error,
294
300
  entry,
@@ -113,6 +113,11 @@ function getLoaderStore(
113
113
  *
114
114
  * When the LoaderEntry has no cache config, delegates directly to ctx.use(loader).
115
115
  * When cached, checks store first and stores on miss via waitUntil.
116
+ *
117
+ * Loader metering is NOT done here — it lives at the ctx.use execution funnel
118
+ * (observePhase; see instrument.ts). A cache HIT returns without calling ctx.use,
119
+ * so it emits no loader phase (the loader did not execute; the hit is only a
120
+ * LoaderCache debug log).
116
121
  */
117
122
  export function resolveLoaderData<TEnv>(
118
123
  loaderEntry: LoaderEntry,
@@ -43,7 +43,7 @@ import {
43
43
  } from "./helpers.js";
44
44
  import { applyViewTransitionDefault } from "./view-transition-default.js";
45
45
  import { getRouterContext } from "../router-context.js";
46
- import { resolveSink, safeEmit } from "../telemetry.js";
46
+ import { observeEvent } from "../instrument.js";
47
47
  import { observeStreamedHandler } from "./streamed-handler-telemetry.js";
48
48
  import {
49
49
  track,
@@ -87,23 +87,14 @@ function emitRevalidationDecision(
87
87
  routeKey: string,
88
88
  shouldRevalidate: boolean,
89
89
  ): void {
90
- let routerCtx;
91
- try {
92
- routerCtx = getRouterContext();
93
- } catch {
94
- return;
95
- }
96
- if (routerCtx?.telemetry) {
97
- safeEmit(resolveSink(routerCtx.telemetry), {
98
- type: "revalidation.decision",
99
- timestamp: performance.now(),
100
- requestId: routerCtx.requestId,
101
- segmentId,
102
- pathname,
103
- routeKey,
104
- shouldRevalidate,
105
- });
106
- }
90
+ observeEvent({
91
+ type: "revalidation.decision",
92
+ timestamp: performance.now(),
93
+ segmentId,
94
+ pathname,
95
+ routeKey,
96
+ shouldRevalidate,
97
+ });
107
98
  }
108
99
 
109
100
  // ---------------------------------------------------------------------------
@@ -5,6 +5,7 @@ import type {
5
5
  ShouldRevalidateFn,
6
6
  } from "../types";
7
7
  import type { SegmentResolutionDeps } from "./types.js";
8
+ import type { ResolveSegmentOptions } from "./segment-resolution.js";
8
9
 
9
10
  import {
10
11
  resolveAllSegments as _resolveAllSegments,
@@ -29,7 +30,7 @@ export interface SegmentWrappers<TEnv = any> {
29
30
  params: Record<string, string>,
30
31
  context: HandlerContext<any, TEnv>,
31
32
  loaderPromises: Map<string, Promise<any>>,
32
- options?: { skipLoaders?: boolean },
33
+ options?: ResolveSegmentOptions,
33
34
  ) => Promise<ResolvedSegment[]>;
34
35
  resolveLoadersOnly: (
35
36
  entries: EntryData[],
@@ -123,7 +124,7 @@ export function createSegmentWrappers<TEnv = any>(
123
124
  params: Record<string, string>,
124
125
  context: HandlerContext<any, TEnv>,
125
126
  loaderPromises: Map<string, Promise<any>>,
126
- options?: { skipLoaders?: boolean },
127
+ options?: ResolveSegmentOptions,
127
128
  ): ReturnType<typeof _resolveAllSegments> {
128
129
  return _resolveAllSegments(
129
130
  entries,