@rangojs/router 0.0.0-experimental.126 → 0.0.0-experimental.127
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/dist/bin/rango.js +5 -1
- package/dist/vite/index.js +55 -40
- package/package.json +23 -19
- package/skills/observability/SKILL.md +12 -3
- package/skills/prerender/SKILL.md +30 -11
- package/skills/router-setup/SKILL.md +11 -3
- package/src/build/route-types/codegen.ts +12 -1
- package/src/cache/cache-scope.ts +20 -0
- package/src/cloudflare/index.ts +11 -0
- package/src/cloudflare/tracing.ts +109 -0
- package/src/index.rsc.ts +19 -2
- package/src/index.ts +16 -1
- package/src/route-definition/dsl-helpers.ts +19 -0
- package/src/router/instrument.ts +230 -0
- package/src/router/loader-resolution.ts +15 -10
- package/src/router/match-middleware/cache-lookup.ts +9 -14
- package/src/router/match-middleware/cache-store.ts +12 -0
- package/src/router/middleware.ts +23 -2
- package/src/router/prerender-match.ts +5 -2
- package/src/router/router-context.ts +2 -1
- package/src/router/router-interfaces.ts +8 -0
- package/src/router/router-options.ts +58 -4
- package/src/router/segment-resolution/fresh.ts +15 -18
- package/src/router/segment-resolution/helpers.ts +6 -0
- package/src/router/segment-resolution/loader-cache.ts +5 -0
- package/src/router/segment-resolution/revalidation.ts +9 -18
- package/src/router/segment-wrappers.ts +3 -2
- package/src/router/telemetry-otel.ts +161 -179
- package/src/router/tracing.ts +198 -0
- package/src/router.ts +9 -0
- package/src/rsc/handler.ts +132 -134
- package/src/rsc/loader-fetch.ts +7 -1
- package/src/rsc/progressive-enhancement.ts +9 -2
- package/src/rsc/rsc-rendering.ts +38 -14
- package/src/rsc/server-action.ts +28 -7
- package/src/segment-system.tsx +4 -1
- package/src/server/request-context.ts +13 -5
- package/src/vite/discovery/prerender-collection.ts +26 -37
- package/src/vite/discovery/state.ts +6 -0
- package/src/vite/plugin-types.ts +25 -0
- package/src/vite/plugins/expose-ids/router-transform.ts +10 -0
- package/src/vite/rango.ts +1 -0
- package/src/vite/router-discovery.ts +9 -3
- package/src/vite/utils/prerender-utils.ts +36 -0
|
@@ -524,6 +524,22 @@ const middleware: RouteHelpers<any, any>["middleware"] = (...args: any[]) => {
|
|
|
524
524
|
} as MiddlewareItem;
|
|
525
525
|
};
|
|
526
526
|
|
|
527
|
+
// Slot names become part of segment ids: a parallel/intercept slot is encoded
|
|
528
|
+
// as `${shortCode}.${slotName}`, and loader segments append `D${index}.${loaderId}`.
|
|
529
|
+
// A "." in the slot name collides with that separator -- loaderParentId
|
|
530
|
+
// (segment-system.tsx) strips from the FIRST `D<index>.`, so a name like
|
|
531
|
+
// "@D3.foo" is mis-cut to "@" and the loader's data is silently dropped. Reject
|
|
532
|
+
// the dot at definition time so the failure is loud, not a corrupted tree at
|
|
533
|
+
// runtime. (A bare "D" without a trailing dot -- e.g. "@Detail" -- is fine.)
|
|
534
|
+
function assertValidSlotName(slotName: string): void {
|
|
535
|
+
invariant(
|
|
536
|
+
!slotName.includes("."),
|
|
537
|
+
`Slot name "${slotName}" must not contain ".". The dot is a reserved ` +
|
|
538
|
+
`segment-id separator; a name like "@D3.foo" corrupts loader segment-id ` +
|
|
539
|
+
`parsing and silently drops the loader's data. Rename the slot.`,
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
527
543
|
const parallel: RouteHelpers<any, any>["parallel"] = (slots, use) => {
|
|
528
544
|
const { store, ctx } = requireDslContext(
|
|
529
545
|
"parallel() must be called inside urls()",
|
|
@@ -539,6 +555,7 @@ const parallel: RouteHelpers<any, any>["parallel"] = (slots, use) => {
|
|
|
539
555
|
);
|
|
540
556
|
|
|
541
557
|
const slotNames = Object.keys(slots as Record<string, any>) as `@${string}`[];
|
|
558
|
+
for (const slotName of slotNames) assertValidSlotName(slotName);
|
|
542
559
|
|
|
543
560
|
const namespace = `${ctx.namespace}.$${store.getNextIndex("parallel")}`;
|
|
544
561
|
|
|
@@ -698,6 +715,8 @@ const intercept = (
|
|
|
698
715
|
"intercept() cannot be used inside parallel()",
|
|
699
716
|
);
|
|
700
717
|
|
|
718
|
+
assertValidSlotName(slotName);
|
|
719
|
+
|
|
701
720
|
const namespace = `${ctx.namespace}.$${store.getNextIndex("intercept")}.${slotName}`;
|
|
702
721
|
|
|
703
722
|
// Dot-prefixed = local (add include prefix), unprefixed = global (use as-is)
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase + event instrumentation — the single internal API for observing router
|
|
3
|
+
* work.
|
|
4
|
+
*
|
|
5
|
+
* The router exposes the same work on three surfaces, and the rule is: each
|
|
6
|
+
* surface has exactly one owner here, so they cannot drift.
|
|
7
|
+
*
|
|
8
|
+
* - observePhase(): a span of work. Co-emits the `debugPerformance` perf
|
|
9
|
+
* metric (metrics store -> [RSC Perf] timeline + Server-Timing) AND the
|
|
10
|
+
* platform span (tracing runner -> Cloudflare custom spans / OTel). From one
|
|
11
|
+
* wrap site, so the span set is always a subset of the perf phases and the
|
|
12
|
+
* two can't disagree. Phases that meter their own perf metric with a finer
|
|
13
|
+
* decomposition (request, middleware) pass `metric: false` and get the span
|
|
14
|
+
* only — still co-located, still one owner per surface.
|
|
15
|
+
* - observeEvent(): a discrete fact (TelemetrySink): cache decisions,
|
|
16
|
+
* revalidation decisions, handler errors, timeouts, origin rejections.
|
|
17
|
+
* Event-shaped, not phase-shaped — derived from the same call sites but a
|
|
18
|
+
* separate surface from spans.
|
|
19
|
+
*
|
|
20
|
+
* Why phases, not events, are the parent abstraction: Cloudflare's span API is
|
|
21
|
+
* callback-bound (enterSpan wraps the actual work), so the callback boundary is
|
|
22
|
+
* the source of truth — async-context nesting (a loader's KV/D1/fetch spans
|
|
23
|
+
* landing under rango.loader) cannot be faithfully reconstructed from
|
|
24
|
+
* after-the-fact start/end events. Spans drive; events are emitted alongside.
|
|
25
|
+
*
|
|
26
|
+
* Phase identity lives in the PHASES registry below, so the raw `rango.*` span
|
|
27
|
+
* names, perf-metric labels, and span attributes have a single definition each.
|
|
28
|
+
*
|
|
29
|
+
* When neither perf surface nor tracing is active on the request, observePhase
|
|
30
|
+
* is a direct call — no wrapper, no timestamp, no allocation.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { _getRequestContext } from "../server/request-context.js";
|
|
34
|
+
import { isAutoGeneratedRouteName } from "../route-name.js";
|
|
35
|
+
import { getRouterContext } from "./router-context.js";
|
|
36
|
+
import { resolveSink, safeEmit, type TelemetryEvent } from "./telemetry.js";
|
|
37
|
+
import { appendMetric } from "./metrics.js";
|
|
38
|
+
import {
|
|
39
|
+
NOOP_TRACE_SPAN,
|
|
40
|
+
traceSpan,
|
|
41
|
+
runThenSettle,
|
|
42
|
+
type TracePhase,
|
|
43
|
+
type TraceSpan,
|
|
44
|
+
} from "./tracing.js";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Perf-metric boundary for a phase, or `false` for span-only. `false` means the
|
|
48
|
+
* caller records its own perf metric with a finer decomposition than a single
|
|
49
|
+
* wrap (request: a grand total incl. pre-context bootstrap; middleware: pre/post
|
|
50
|
+
* own-time), so observePhase opens the span but records no metric of its own.
|
|
51
|
+
*/
|
|
52
|
+
export type PhaseMetric =
|
|
53
|
+
| { label: string | (() => string); depth?: number }
|
|
54
|
+
| false;
|
|
55
|
+
|
|
56
|
+
/** Describes one observable phase across the perf and span surfaces. */
|
|
57
|
+
export interface PhaseSpec {
|
|
58
|
+
/** Perf timeline label + Server-Timing name, or false for span-only. */
|
|
59
|
+
metric: PhaseMetric;
|
|
60
|
+
/** Span phase gate (per-phase toggle in the tracing config). */
|
|
61
|
+
tracePhase: TracePhase;
|
|
62
|
+
/** Span name (rango.*). */
|
|
63
|
+
spanName: string;
|
|
64
|
+
/** Span attributes set automatically when the span opens. */
|
|
65
|
+
attributes?: Record<string, string | number | boolean>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The router's observable phases. One definition per phase keeps the `rango.*`
|
|
70
|
+
* span names, perf-metric labels, and identifying attributes from spreading
|
|
71
|
+
* across call sites.
|
|
72
|
+
*/
|
|
73
|
+
export const PHASES = {
|
|
74
|
+
/** Whole request pipeline. Span only — handler:total is metered directly. */
|
|
75
|
+
request: {
|
|
76
|
+
metric: false,
|
|
77
|
+
tracePhase: "request",
|
|
78
|
+
spanName: "rango.request",
|
|
79
|
+
} as PhaseSpec,
|
|
80
|
+
|
|
81
|
+
/** One middleware (incl. its downstream onion). Span only — the perf metric
|
|
82
|
+
* is the middleware's exclusive pre/post own-time, recorded directly.
|
|
83
|
+
* `metricLabel` is that metric's label (e.g. "middleware:auth@*"); it doubles
|
|
84
|
+
* as the rango.middleware_name span attribute. */
|
|
85
|
+
middleware: (metricLabel: string): PhaseSpec => ({
|
|
86
|
+
metric: false,
|
|
87
|
+
tracePhase: "middleware",
|
|
88
|
+
spanName: "rango.middleware",
|
|
89
|
+
attributes: { "rango.middleware_name": metricLabel },
|
|
90
|
+
}),
|
|
91
|
+
|
|
92
|
+
/** The server-action execution (decode args + run the action body), before
|
|
93
|
+
* the revalidation render. The metric label carries the action id (the
|
|
94
|
+
* _rsc_action / action $$id) so the perf timeline shows WHICH action ran, not
|
|
95
|
+
* just "an action"; the span also gets it as rango.action_id. */
|
|
96
|
+
action: (id: string): PhaseSpec => ({
|
|
97
|
+
metric: { label: `action:${id}` },
|
|
98
|
+
tracePhase: "action",
|
|
99
|
+
spanName: "rango.action",
|
|
100
|
+
attributes: { "rango.action_id": id },
|
|
101
|
+
}),
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* One loader execution. `depth` is the perf-timeline indentation: 2 (default)
|
|
105
|
+
* for render-time loaders that nest under the render phase; 1 for a standalone
|
|
106
|
+
* fetchable `_rsc_loader` request, which has no render parent.
|
|
107
|
+
*/
|
|
108
|
+
loader: (id: string, depth: number = 2): PhaseSpec => ({
|
|
109
|
+
metric: { label: `loader:${id}`, depth },
|
|
110
|
+
tracePhase: "loader",
|
|
111
|
+
spanName: "rango.loader",
|
|
112
|
+
attributes: { "rango.loader_id": id },
|
|
113
|
+
}),
|
|
114
|
+
|
|
115
|
+
/** Whole render phase: match + serialize + SSR. The metric label is resolved
|
|
116
|
+
* lazily at record time (after match has set the route name) so the perf
|
|
117
|
+
* timeline shows WHICH route rendered: `render:total:<routeName>`, falling back
|
|
118
|
+
* to `render:total` when there is no named route (unmatched / auto-generated). */
|
|
119
|
+
render: {
|
|
120
|
+
metric: {
|
|
121
|
+
label: () => {
|
|
122
|
+
const routeName = _getRequestContext()?._routeName;
|
|
123
|
+
return routeName && !isAutoGeneratedRouteName(routeName)
|
|
124
|
+
? `render:total:${routeName}`
|
|
125
|
+
: "render:total";
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
tracePhase: "render",
|
|
129
|
+
spanName: "rango.render",
|
|
130
|
+
} as PhaseSpec,
|
|
131
|
+
|
|
132
|
+
/** SSR HTML render from the RSC stream. Colon-delimited like the other ssr:*
|
|
133
|
+
* setup metrics (ssr:module-load / ssr:stream-mode). */
|
|
134
|
+
ssr: {
|
|
135
|
+
metric: { label: "ssr:render-html" },
|
|
136
|
+
tracePhase: "ssr",
|
|
137
|
+
spanName: "rango.ssr",
|
|
138
|
+
} as PhaseSpec,
|
|
139
|
+
} as const;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Instrument one unit of work: open its span AND (unless `metric: false`) record
|
|
143
|
+
* its perf metric, from a single wrap site. fn is invoked exactly once with the
|
|
144
|
+
* span (a no-op span when tracing is off); its return value is returned
|
|
145
|
+
* unchanged and thrown errors / rejected promises propagate unchanged. When fn
|
|
146
|
+
* returns a promise both the metric duration and the span end when it settles.
|
|
147
|
+
*
|
|
148
|
+
* Reads the metrics store + tracing off the RequestContext ALS, which is active
|
|
149
|
+
* for the WHOLE request — contrast observeEvent, which reads the RouterContext
|
|
150
|
+
* ALS (entered later, during match).
|
|
151
|
+
*/
|
|
152
|
+
export function observePhase<T>(
|
|
153
|
+
spec: PhaseSpec,
|
|
154
|
+
fn: (span: TraceSpan) => T,
|
|
155
|
+
): T {
|
|
156
|
+
const reqCtx = _getRequestContext();
|
|
157
|
+
const store = reqCtx?._metricsStore;
|
|
158
|
+
const tracing = reqCtx?._tracing;
|
|
159
|
+
|
|
160
|
+
// Neither surface active: direct call, zero overhead.
|
|
161
|
+
if (!store && !tracing) return fn(NOOP_TRACE_SPAN);
|
|
162
|
+
|
|
163
|
+
// Attributes only land on a real span, so skip the wrapper when only the perf
|
|
164
|
+
// surface is active (traceSpan would apply them to NOOP_TRACE_SPAN for nothing).
|
|
165
|
+
const attributes = spec.attributes;
|
|
166
|
+
const wrapped: (span: TraceSpan) => T =
|
|
167
|
+
attributes && tracing
|
|
168
|
+
? (span) => {
|
|
169
|
+
for (const key in attributes) span.setAttribute(key, attributes[key]);
|
|
170
|
+
return fn(span);
|
|
171
|
+
}
|
|
172
|
+
: fn;
|
|
173
|
+
|
|
174
|
+
const runSpan = (): T =>
|
|
175
|
+
traceSpan(tracing, spec.tracePhase, spec.spanName, wrapped);
|
|
176
|
+
|
|
177
|
+
// Span-only — no perf metric to record (metric:false, or perf surface off).
|
|
178
|
+
const metric = spec.metric;
|
|
179
|
+
if (!store || metric === false) return runSpan();
|
|
180
|
+
|
|
181
|
+
// Record the phase duration on EVERY termination — success or failure — so a
|
|
182
|
+
// failed loader/render still shows its timing in the perf report (parity with
|
|
183
|
+
// the old track().finally() path it replaced).
|
|
184
|
+
const start = performance.now();
|
|
185
|
+
return runThenSettle(runSpan, () => {
|
|
186
|
+
// The label may be lazy (resolved at record time, e.g. render:total needs the
|
|
187
|
+
// route name that match sets partway through the wrapped work).
|
|
188
|
+
const label =
|
|
189
|
+
typeof metric.label === "function" ? metric.label() : metric.label;
|
|
190
|
+
appendMetric(store, label, start, performance.now() - start, metric.depth);
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Emit one discrete telemetry event (the event-shaped counterpart to
|
|
196
|
+
* observePhase). Resolves the sink from the active router context and stamps the
|
|
197
|
+
* request id when the event omits it. No-op (and total — never throws) when no
|
|
198
|
+
* sink is configured.
|
|
199
|
+
*
|
|
200
|
+
* This is the canonical emitter for SYNCHRONOUS facts that fire inside the
|
|
201
|
+
* request's ALS scope (handler errors, timeouts, origin rejections, revalidation
|
|
202
|
+
* decisions). A few emitters deliberately stay on the lower-level
|
|
203
|
+
* resolveSink + safeEmit because observeEvent's lazy, per-call
|
|
204
|
+
* getRouterContext() read does not fit them — keep this the complete list:
|
|
205
|
+
* - router.ts wrapLoaderPromise (loader.start/end/error) and
|
|
206
|
+
* segment-resolution/streamed-handler-telemetry.ts (streamed handler.error)
|
|
207
|
+
* capture the sink + request id EAGERLY and emit from a fire-and-forget
|
|
208
|
+
* continuation that runs after the ALS scope may have unwound.
|
|
209
|
+
* - router/match-handlers.ts resolves the sink ONCE for the hot match-pipeline
|
|
210
|
+
* loop (request.start/end/error, cache.decision, ...).
|
|
211
|
+
* - segment-resolution/helpers.ts emits via a caller-provided report.telemetry
|
|
212
|
+
* sink rather than the ALS router context.
|
|
213
|
+
*/
|
|
214
|
+
export function observeEvent(event: TelemetryEvent): void {
|
|
215
|
+
// getRouterContext() either throws (real impl, outside a router context — e.g.
|
|
216
|
+
// the build-time prerender path) or returns null/undefined (e.g. mocked).
|
|
217
|
+
// Either way there is no sink to emit to, so swallow and return.
|
|
218
|
+
let routerCtx: ReturnType<typeof getRouterContext> | null | undefined;
|
|
219
|
+
try {
|
|
220
|
+
routerCtx = getRouterContext();
|
|
221
|
+
} catch {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (!routerCtx?.telemetry) return;
|
|
225
|
+
const stamped =
|
|
226
|
+
event.requestId === undefined && routerCtx.requestId !== undefined
|
|
227
|
+
? ({ ...event, requestId: routerCtx.requestId } as TelemetryEvent)
|
|
228
|
+
: event;
|
|
229
|
+
safeEmit(resolveSink(routerCtx.telemetry), stamped);
|
|
230
|
+
}
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { ReactNode } from "react";
|
|
8
|
-
import { track } from "../server/context";
|
|
9
8
|
import type { EntryData } from "../server/context";
|
|
9
|
+
import { observePhase, PHASES } from "./instrument.js";
|
|
10
10
|
import { contextGet } from "../context-var.js";
|
|
11
11
|
import type {
|
|
12
12
|
ResolvedSegment,
|
|
@@ -382,7 +382,11 @@ function createLoaderExecutor<TEnv>(
|
|
|
382
382
|
},
|
|
383
383
|
};
|
|
384
384
|
|
|
385
|
-
|
|
385
|
+
// Meter this loader once via observePhase (loader:<id> perf metric +
|
|
386
|
+
// rango.loader span); loaderFn runs inside the span callback so its KV/D1/
|
|
387
|
+
// fetch spans nest under it. This is one of the observePhase loader funnels —
|
|
388
|
+
// see instrument.ts for the single-metering contract.
|
|
389
|
+
//
|
|
386
390
|
// Run the loader body inside loader scope so request-scoped reads
|
|
387
391
|
// (cookies()/headers() and non-cacheable ctx.get) are exempt from the
|
|
388
392
|
// cache-purity guards: loaders always run fresh, so their reads never leak
|
|
@@ -392,14 +396,15 @@ function createLoaderExecutor<TEnv>(
|
|
|
392
396
|
// throw. rendered() gating uses the captured isDslLoader (above), so this
|
|
393
397
|
// does not grant rendered() to handler-invoked loaders. Uses a body-only
|
|
394
398
|
// scope, so isInsideLoaderScope() / barrier / deadlock gating is unchanged.
|
|
395
|
-
const promise =
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
399
|
+
const promise = observePhase(PHASES.loader(loader.$$id), () =>
|
|
400
|
+
Promise.resolve(
|
|
401
|
+
runInsideLoaderBodyScope(() =>
|
|
402
|
+
loaderFn(loaderCtx as LoaderContext<any, TEnv>),
|
|
403
|
+
),
|
|
404
|
+
).finally(() => {
|
|
405
|
+
pendingLoaders.delete(loader.$$id);
|
|
406
|
+
}),
|
|
407
|
+
);
|
|
403
408
|
|
|
404
409
|
loaderPromises.set(loader.$$id, promise);
|
|
405
410
|
return promise;
|
|
@@ -94,7 +94,7 @@
|
|
|
94
94
|
import type { ResolvedSegment } from "../../types.js";
|
|
95
95
|
import type { MatchContext, MatchPipelineState } from "../match-context.js";
|
|
96
96
|
import { getRouterContext, type RouterContext } from "../router-context.js";
|
|
97
|
-
import {
|
|
97
|
+
import { observeEvent } from "../instrument.js";
|
|
98
98
|
import { pushRevalidationTraceEntry, isTraceActive } from "../logging.js";
|
|
99
99
|
import { treeHasStreaming } from "./segment-resolution.js";
|
|
100
100
|
import type { PrerenderStore, PrerenderEntry } from "../../prerender/store.js";
|
|
@@ -546,19 +546,14 @@ export function withCacheLookup<TEnv>(
|
|
|
546
546
|
traceSource: "cache-hit",
|
|
547
547
|
});
|
|
548
548
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
pathname: ctx.pathname,
|
|
558
|
-
routeKey: ctx.routeKey,
|
|
559
|
-
shouldRevalidate,
|
|
560
|
-
});
|
|
561
|
-
}
|
|
549
|
+
observeEvent({
|
|
550
|
+
type: "revalidation.decision",
|
|
551
|
+
timestamp: performance.now(),
|
|
552
|
+
segmentId: segment.id,
|
|
553
|
+
pathname: ctx.pathname,
|
|
554
|
+
routeKey: ctx.routeKey,
|
|
555
|
+
shouldRevalidate,
|
|
556
|
+
});
|
|
562
557
|
|
|
563
558
|
if (!shouldRevalidate) {
|
|
564
559
|
segment.component = null;
|
|
@@ -168,6 +168,18 @@ export function withCacheStore<TEnv>(
|
|
|
168
168
|
if (!requestCtx) return;
|
|
169
169
|
|
|
170
170
|
const cacheScope = ctx.cacheScope;
|
|
171
|
+
|
|
172
|
+
// Record the route's segment-DSL cache tags into the request tag union NOW,
|
|
173
|
+
// synchronously in the pipeline. The actual store write (cacheRoute) runs in
|
|
174
|
+
// requestCtx.waitUntil() below — and the proactive path re-resolves the whole
|
|
175
|
+
// tree first — so cacheRoute's own tag recording races the document cache's
|
|
176
|
+
// post-body-drain snapshot of _requestTags. On a first-write miss the document
|
|
177
|
+
// tag union could miss these tags and revalidateTag()/updateTag() would not
|
|
178
|
+
// invalidate the cached document. Recording here (before the snapshot) closes
|
|
179
|
+
// the window for both the direct and proactive write paths; the duplicate
|
|
180
|
+
// record inside cacheRoute is idempotent.
|
|
181
|
+
cacheScope.recordTags(requestCtx);
|
|
182
|
+
|
|
171
183
|
const reqId = INTERNAL_RANGO_DEBUG
|
|
172
184
|
? getOrCreateRequestId(ctx.request)
|
|
173
185
|
: undefined;
|
package/src/router/middleware.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from "../redirect-origin.js";
|
|
20
20
|
import { isAutoGeneratedRouteName } from "../route-name.js";
|
|
21
21
|
import { appendMetric, createMetricsStore } from "./metrics.js";
|
|
22
|
+
import { observePhase, PHASES } from "./instrument.js";
|
|
22
23
|
import { stripInternalParams } from "./handler-context.js";
|
|
23
24
|
import { isWebSocketUpgradeResponse } from "../response-utils.js";
|
|
24
25
|
|
|
@@ -478,9 +479,17 @@ export async function executeMiddleware<TEnv>(
|
|
|
478
479
|
return nextPromise;
|
|
479
480
|
};
|
|
480
481
|
|
|
482
|
+
// Wrap the middleware (including its downstream next() chain) in its span
|
|
483
|
+
// via the unified phase API. metric:false — the middleware's perf metric is
|
|
484
|
+
// its exclusive pre/post own-time, recorded directly above and below, finer
|
|
485
|
+
// than a single wrap. Spans nest by async context, so this onions
|
|
486
|
+
// middleware-over-middleware and the core handler underneath. Pass-through
|
|
487
|
+
// when neither surface is active.
|
|
481
488
|
let result: Response | void;
|
|
482
489
|
try {
|
|
483
|
-
result = await
|
|
490
|
+
result = await observePhase(PHASES.middleware(metricLabel), () =>
|
|
491
|
+
entry.handler(ctx, wrappedNext),
|
|
492
|
+
);
|
|
484
493
|
} catch (error) {
|
|
485
494
|
// Thrown Response is short-circuit control flow, not an error.
|
|
486
495
|
// Fall through to the `if (result instanceof Response)` branch below
|
|
@@ -622,6 +631,7 @@ export async function executeInterceptMiddleware<TEnv>(
|
|
|
622
631
|
return stubResponse;
|
|
623
632
|
}
|
|
624
633
|
|
|
634
|
+
const ordinal = index;
|
|
625
635
|
const middleware = middlewares[index++];
|
|
626
636
|
const ctx = createMiddlewareContext(
|
|
627
637
|
request,
|
|
@@ -643,9 +653,20 @@ export async function executeInterceptMiddleware<TEnv>(
|
|
|
643
653
|
return next();
|
|
644
654
|
};
|
|
645
655
|
|
|
656
|
+
// Span-wrap each intercept middleware as rango.middleware (metric:false —
|
|
657
|
+
// intercept runs inside the render phase already metered by render:total, so
|
|
658
|
+
// it contributes a span but no separate perf metric). Bare MiddlewareFns
|
|
659
|
+
// have no pattern, so the label is scoped to "*" like a pattern-less entry.
|
|
660
|
+
const label = getMiddlewareMetricLabel(
|
|
661
|
+
{ handler: middleware, pattern: null } as MiddlewareEntry<TEnv>,
|
|
662
|
+
ordinal,
|
|
663
|
+
);
|
|
664
|
+
|
|
646
665
|
let result: Response | void;
|
|
647
666
|
try {
|
|
648
|
-
result = await middleware(
|
|
667
|
+
result = await observePhase(PHASES.middleware(label), () =>
|
|
668
|
+
middleware(ctx, guardedNext),
|
|
669
|
+
);
|
|
649
670
|
} catch (error) {
|
|
650
671
|
// Thrown Response is short-circuit control flow, parity with the
|
|
651
672
|
// explicit-return path below. Real errors propagate.
|
|
@@ -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?:
|
|
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
|
-
|
|
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?:
|
|
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
|
|
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
|
-
*
|
|
580
|
-
*
|
|
581
|
-
*
|
|
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,
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|