@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
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Span tracing hook (platform-agnostic).
|
|
3
|
+
*
|
|
4
|
+
* The core router emits its existing performance phases (request, middleware, action,
|
|
5
|
+
* loaders, render, ssr) as spans by calling traceSpan() at a small set of
|
|
6
|
+
* execution boundaries. When no tracing is configured the call is a direct
|
|
7
|
+
* pass-through: fn is invoked with a no-op span, with no wrapper and no
|
|
8
|
+
* allocation, so a non-traced request behaves exactly as before.
|
|
9
|
+
*
|
|
10
|
+
* A platform integration supplies a SpanRunner that wraps fn in a real span.
|
|
11
|
+
* Two runners ship: the Cloudflare one (createCloudflareTracing in
|
|
12
|
+
* src/cloudflare/tracing.ts), which bridges onto executionContext.tracing.
|
|
13
|
+
* enterSpan, and the OTel one (createOTelTracing in router/telemetry-otel.ts),
|
|
14
|
+
* which bridges onto tracer.startActiveSpan. Both wrap the actual work — not a
|
|
15
|
+
* post-hoc event — so spans nest by async context and the platform's automatic
|
|
16
|
+
* spans (KV/D1/fetch) nest under the right phase.
|
|
17
|
+
*
|
|
18
|
+
* traceSpan() below is the low-level wrap primitive. It is INTERNAL: the only
|
|
19
|
+
* caller is observePhase() (instrument.ts), the single phase-instrumentation
|
|
20
|
+
* API, which co-emits the span AND the debugPerformance perf metric from one
|
|
21
|
+
* wrap site (or just the span, for metric:false phases) so the two surfaces
|
|
22
|
+
* can't drift. Every router phase routes through observePhase via the PHASES
|
|
23
|
+
* registry; do not call traceSpan directly from new code.
|
|
24
|
+
*
|
|
25
|
+
* Phase coverage (all via observePhase): rango.request (span-only; handler:total
|
|
26
|
+
* metered directly), rango.middleware (span-only incl. intercept middleware;
|
|
27
|
+
* pre/post metered directly), rango.action (action:<id>; server-action
|
|
28
|
+
* execution, JS + no-JS/PE), rango.loader (loader:<id>; single metering site at
|
|
29
|
+
* useLoader, plus the fetchable path), rango.render (render:total:<route>; normal AND
|
|
30
|
+
* action-revalidation renders), rango.ssr (ssr:render-html).
|
|
31
|
+
*
|
|
32
|
+
* Span-duration caveat: a span ends when its callback's value (or promise)
|
|
33
|
+
* settles. For the streaming phases (request/render/ssr) that is when the
|
|
34
|
+
* Response / HTML / RSC stream is constructed, NOT when the body finishes
|
|
35
|
+
* draining. Loader/Suspense work that settles during stream drain extends past
|
|
36
|
+
* the parent span's end, so parent durations under-report streamed time and a
|
|
37
|
+
* rango.loader child can end after its parent. This is the streaming + end-on-
|
|
38
|
+
* settle contract, not a defect; phase spans bound setup-to-stream-handoff.
|
|
39
|
+
*
|
|
40
|
+
* Both shipped runners (Cloudflare, OTel) keep the core agnostic: the
|
|
41
|
+
* platform-specific bridge lives at the edge behind the SpanRunner contract.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Minimal span handle passed to traced work. Structurally compatible with both
|
|
46
|
+
* Cloudflare's `Span` and OTel's `Span` (only setAttribute is used here).
|
|
47
|
+
*/
|
|
48
|
+
export interface TraceSpan {
|
|
49
|
+
setAttribute(key: string, value: string | number | boolean): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Wraps a unit of work in a span. A runner MUST invoke fn exactly once, pass it
|
|
54
|
+
* a span, return fn's result unchanged, and propagate thrown errors / rejected
|
|
55
|
+
* promises unchanged. When fn returns a promise the span ends once it settles.
|
|
56
|
+
*/
|
|
57
|
+
export type SpanRunner = <T>(name: string, fn: (span: TraceSpan) => T) => T;
|
|
58
|
+
|
|
59
|
+
/** The router phases that can be wrapped in a span. */
|
|
60
|
+
export type TracePhase =
|
|
61
|
+
| "request"
|
|
62
|
+
| "middleware"
|
|
63
|
+
| "action"
|
|
64
|
+
| "loader"
|
|
65
|
+
| "render"
|
|
66
|
+
| "ssr";
|
|
67
|
+
|
|
68
|
+
/** Per-phase span toggles. Omitted phases default to enabled. */
|
|
69
|
+
export interface TracePhaseToggles {
|
|
70
|
+
request?: boolean;
|
|
71
|
+
middleware?: boolean;
|
|
72
|
+
action?: boolean;
|
|
73
|
+
loader?: boolean;
|
|
74
|
+
render?: boolean;
|
|
75
|
+
ssr?: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Value passed to `createRouter({ tracing })`. Produced by a platform factory
|
|
80
|
+
* such as `createCloudflareTracing()`.
|
|
81
|
+
*/
|
|
82
|
+
export interface RouterTracingConfig {
|
|
83
|
+
/** Platform span runner. */
|
|
84
|
+
runner: SpanRunner;
|
|
85
|
+
/** Master switch. Defaults to true when a config object is provided. */
|
|
86
|
+
enabled?: boolean;
|
|
87
|
+
/** Per-phase span toggles. */
|
|
88
|
+
spans?: TracePhaseToggles;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Resolved tracing state stored on the router/request context. `undefined`
|
|
93
|
+
* means tracing is fully disabled and every traceSpan() call is a pass-through.
|
|
94
|
+
*/
|
|
95
|
+
export interface ResolvedTracing {
|
|
96
|
+
runner: SpanRunner;
|
|
97
|
+
phases: Record<TracePhase, boolean>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Shared no-op span. setAttribute is a no-op so disabled call sites stay free. */
|
|
101
|
+
export const NOOP_TRACE_SPAN: TraceSpan = {
|
|
102
|
+
setAttribute() {},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const ALL_PHASES_ON: Record<TracePhase, boolean> = {
|
|
106
|
+
request: true,
|
|
107
|
+
middleware: true,
|
|
108
|
+
action: true,
|
|
109
|
+
loader: true,
|
|
110
|
+
render: true,
|
|
111
|
+
ssr: true,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Resolve a user-supplied tracing config into the fast internal form, or
|
|
116
|
+
* `undefined` when tracing is off (no config, `enabled: false`, or no runner).
|
|
117
|
+
*/
|
|
118
|
+
export function resolveTracing(
|
|
119
|
+
config: RouterTracingConfig | undefined,
|
|
120
|
+
): ResolvedTracing | undefined {
|
|
121
|
+
if (
|
|
122
|
+
!config ||
|
|
123
|
+
config.enabled === false ||
|
|
124
|
+
typeof config.runner !== "function"
|
|
125
|
+
) {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
const spans = config.spans;
|
|
129
|
+
return {
|
|
130
|
+
runner: config.runner,
|
|
131
|
+
phases: spans
|
|
132
|
+
? {
|
|
133
|
+
request: spans.request ?? true,
|
|
134
|
+
middleware: spans.middleware ?? true,
|
|
135
|
+
action: spans.action ?? true,
|
|
136
|
+
loader: spans.loader ?? true,
|
|
137
|
+
render: spans.render ?? true,
|
|
138
|
+
ssr: spans.ssr ?? true,
|
|
139
|
+
}
|
|
140
|
+
: ALL_PHASES_ON,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Wrap `fn` in a span for `phase`. When tracing is off (or the phase is
|
|
146
|
+
* disabled) fn runs directly with a no-op span — identical to the untraced
|
|
147
|
+
* path. Otherwise the platform runner wraps fn so the span covers the real
|
|
148
|
+
* work and nests by async context.
|
|
149
|
+
*/
|
|
150
|
+
export function traceSpan<T>(
|
|
151
|
+
tracing: ResolvedTracing | undefined,
|
|
152
|
+
phase: TracePhase,
|
|
153
|
+
name: string,
|
|
154
|
+
fn: (span: TraceSpan) => T,
|
|
155
|
+
): T {
|
|
156
|
+
if (tracing === undefined || tracing.phases[phase] === false) {
|
|
157
|
+
return fn(NOOP_TRACE_SPAN);
|
|
158
|
+
}
|
|
159
|
+
return tracing.runner(name, fn);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Run `fn` once and invoke `onSettle` exactly once when it terminates — on a
|
|
164
|
+
* synchronous return, a synchronous throw, an async resolution, or an async
|
|
165
|
+
* rejection. `onSettle` receives the error (or `undefined` on success). fn's
|
|
166
|
+
* value is returned and errors propagate unchanged.
|
|
167
|
+
*
|
|
168
|
+
* Centralizes the run-once-then-settle control flow shared by the two span
|
|
169
|
+
* surfaces: observePhase records the perf metric on settle, and the OTel runner
|
|
170
|
+
* ends (or error-marks) the span on settle. The Cloudflare runner delegates
|
|
171
|
+
* settling to enterSpan, so it does not use this.
|
|
172
|
+
*/
|
|
173
|
+
export function runThenSettle<T>(
|
|
174
|
+
fn: () => T,
|
|
175
|
+
onSettle: (error: unknown) => void,
|
|
176
|
+
): T {
|
|
177
|
+
let out: T;
|
|
178
|
+
try {
|
|
179
|
+
out = fn();
|
|
180
|
+
} catch (error) {
|
|
181
|
+
onSettle(error);
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
if (out instanceof Promise) {
|
|
185
|
+
return out.then(
|
|
186
|
+
(value) => {
|
|
187
|
+
onSettle(undefined);
|
|
188
|
+
return value;
|
|
189
|
+
},
|
|
190
|
+
(error) => {
|
|
191
|
+
onSettle(error);
|
|
192
|
+
throw error;
|
|
193
|
+
},
|
|
194
|
+
) as unknown as T;
|
|
195
|
+
}
|
|
196
|
+
onSettle(undefined);
|
|
197
|
+
return out;
|
|
198
|
+
}
|
package/src/router.ts
CHANGED
|
@@ -73,6 +73,7 @@ import {
|
|
|
73
73
|
traverseBack,
|
|
74
74
|
} from "./router/pattern-matching.js";
|
|
75
75
|
import { resolveSink, safeEmit, getRequestId } from "./router/telemetry.js";
|
|
76
|
+
import { resolveTracing } from "./router/tracing.js";
|
|
76
77
|
import { evaluateRevalidation } from "./router/revalidation.js";
|
|
77
78
|
import {
|
|
78
79
|
type RouterContext,
|
|
@@ -152,6 +153,7 @@ export function createRouter<TEnv = any>(
|
|
|
152
153
|
warmup: warmupOption,
|
|
153
154
|
allowDebugManifest: allowDebugManifestOption = false,
|
|
154
155
|
telemetry: telemetrySink,
|
|
156
|
+
tracing: tracingOption,
|
|
155
157
|
ssr: ssrOption,
|
|
156
158
|
timeout: timeoutShorthand,
|
|
157
159
|
timeouts: timeoutsOption,
|
|
@@ -178,6 +180,10 @@ export function createRouter<TEnv = any>(
|
|
|
178
180
|
// Resolve telemetry sink (no-op when not configured)
|
|
179
181
|
const telemetry = resolveSink(telemetrySink);
|
|
180
182
|
|
|
183
|
+
// Resolve span tracing (undefined when not configured; every traceSpan() call
|
|
184
|
+
// is then a direct pass-through with zero behavior change).
|
|
185
|
+
const resolvedTracing = resolveTracing(tracingOption);
|
|
186
|
+
|
|
181
187
|
// Resolve cache profiles: merge user config with the guaranteed default
|
|
182
188
|
// profile. This resolved map is threaded onto each request context; the
|
|
183
189
|
// "use cache: <profile>" runtime path reads it request-scoped.
|
|
@@ -968,6 +974,9 @@ export function createRouter<TEnv = any>(
|
|
|
968
974
|
// Expose router-wide performance debugging for request-level metrics setup
|
|
969
975
|
debugPerformance,
|
|
970
976
|
|
|
977
|
+
// Expose resolved span tracing for the handler (Cloudflare custom spans)
|
|
978
|
+
tracing: resolvedTracing,
|
|
979
|
+
|
|
971
980
|
// Expose debug manifest flag for handler
|
|
972
981
|
allowDebugManifest: allowDebugManifestOption,
|
|
973
982
|
|
package/src/rsc/handler.ts
CHANGED
|
@@ -46,8 +46,6 @@ import {
|
|
|
46
46
|
createReverseFunction,
|
|
47
47
|
stripInternalParams,
|
|
48
48
|
} from "../router/handler-context.js";
|
|
49
|
-
import { getRouterContext } from "../router/router-context.js";
|
|
50
|
-
import { resolveSink, safeEmit } from "../router/telemetry.js";
|
|
51
49
|
import { contextSet } from "../context-var.js";
|
|
52
50
|
import {
|
|
53
51
|
hasCachedManifest,
|
|
@@ -84,6 +82,7 @@ import {
|
|
|
84
82
|
appendMetric,
|
|
85
83
|
buildMetricsTiming,
|
|
86
84
|
} from "../router/metrics.js";
|
|
85
|
+
import { observePhase, observeEvent, PHASES } from "../router/instrument.js";
|
|
87
86
|
import {
|
|
88
87
|
startSSRSetup,
|
|
89
88
|
getSSRSetup,
|
|
@@ -244,24 +243,16 @@ export function createRSCHandler<
|
|
|
244
243
|
metadata: { timeout: true, phase, durationMs },
|
|
245
244
|
});
|
|
246
245
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
actionId,
|
|
258
|
-
durationMs,
|
|
259
|
-
customHandler: !!router.onTimeout,
|
|
260
|
-
});
|
|
261
|
-
}
|
|
262
|
-
} catch {
|
|
263
|
-
// Router context may not be available
|
|
264
|
-
}
|
|
246
|
+
observeEvent({
|
|
247
|
+
type: "request.timeout",
|
|
248
|
+
timestamp: performance.now(),
|
|
249
|
+
phase,
|
|
250
|
+
pathname: url.pathname,
|
|
251
|
+
routeKey,
|
|
252
|
+
actionId,
|
|
253
|
+
durationMs,
|
|
254
|
+
customHandler: !!router.onTimeout,
|
|
255
|
+
});
|
|
265
256
|
|
|
266
257
|
if (router.onTimeout) {
|
|
267
258
|
try {
|
|
@@ -499,86 +490,103 @@ export function createRSCHandler<
|
|
|
499
490
|
// Store basename on request context (scoped per-request via existing ALS)
|
|
500
491
|
requestContext._basename = router.basename;
|
|
501
492
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
493
|
+
// Resolved span tracing for this request (read at each traced phase).
|
|
494
|
+
requestContext._tracing = router.tracing;
|
|
495
|
+
|
|
496
|
+
// The "rango.request" span is opened inside the request context so the
|
|
497
|
+
// Cloudflare runner can read executionContext.tracing, and so every nested
|
|
498
|
+
// phase span (and the platform's automatic KV/D1/fetch spans) nests under
|
|
499
|
+
// it. metric:false — handler:total is metered directly below (a grand total
|
|
500
|
+
// incl. the pre-context bootstrap timings, finer than a single wrap). When
|
|
501
|
+
// tracing is off this is a direct pass-through.
|
|
502
|
+
return runWithRequestContext(requestContext, () =>
|
|
503
|
+
observePhase(PHASES.request, async (span) => {
|
|
504
|
+
span.setAttribute("http.method", request.method);
|
|
505
|
+
// The matched route template is not known until match() runs later, so
|
|
506
|
+
// emit the concrete path as url.path (low-level), NOT http.route — the
|
|
507
|
+
// latter is reserved for the low-cardinality template (OTel convention).
|
|
508
|
+
span.setAttribute("url.path", url.pathname);
|
|
509
|
+
|
|
510
|
+
// Core handler logic (wrapped by middleware)
|
|
511
|
+
const coreHandler = async (): Promise<Response> => {
|
|
512
|
+
return coreRequestHandler(request, env, url, variables, nonce);
|
|
513
|
+
};
|
|
519
514
|
|
|
520
|
-
if
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
515
|
+
// Execute middleware chain if any, otherwise call core handler directly
|
|
516
|
+
let response: Response;
|
|
517
|
+
if (matchedMiddleware.length > 0) {
|
|
518
|
+
const mwResponse = await executeMiddleware(
|
|
519
|
+
matchedMiddleware,
|
|
520
|
+
request,
|
|
521
|
+
env,
|
|
522
|
+
variables,
|
|
523
|
+
coreHandler,
|
|
524
|
+
createReverseFunction(getRequiredRouteMap()),
|
|
527
525
|
);
|
|
528
|
-
|
|
526
|
+
|
|
527
|
+
if (
|
|
528
|
+
url.searchParams.has("_rsc_partial") ||
|
|
529
|
+
url.searchParams.has("_rsc_action")
|
|
530
|
+
) {
|
|
531
|
+
const intercepted = interceptRedirectForPartial(
|
|
532
|
+
mwResponse,
|
|
533
|
+
createRedirectFlightResponse,
|
|
534
|
+
);
|
|
535
|
+
response = intercepted ?? finalizeResponse(mwResponse);
|
|
536
|
+
} else {
|
|
537
|
+
response = finalizeResponse(mwResponse);
|
|
538
|
+
}
|
|
529
539
|
} else {
|
|
530
|
-
response =
|
|
540
|
+
response = await coreHandler();
|
|
531
541
|
}
|
|
532
|
-
} else {
|
|
533
|
-
response = await coreHandler();
|
|
534
|
-
}
|
|
535
542
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
543
|
+
// Finalize metrics after all middleware (including post-next work)
|
|
544
|
+
// has completed so :post spans are captured in the timeline.
|
|
545
|
+
// Handler timing parts are always emitted (even without debug metrics)
|
|
546
|
+
// so non-debug requests still get bootstrap Server-Timing entries.
|
|
547
|
+
const handlerTimingArr: string[] = variables.__handlerTiming || [];
|
|
548
|
+
// Preserve any existing Server-Timing set by response routes or middleware
|
|
549
|
+
const existingTiming = response.headers.get("Server-Timing");
|
|
550
|
+
const timingParts = existingTiming
|
|
551
|
+
? [existingTiming, ...handlerTimingArr]
|
|
552
|
+
: [...handlerTimingArr];
|
|
553
|
+
|
|
554
|
+
const metricsStore = requestContext._metricsStore;
|
|
555
|
+
if (metricsStore) {
|
|
556
|
+
// When the store was created at handler start (earlyMetricsStore),
|
|
557
|
+
// handler:total covers the full request. When ctx.debugPerformance()
|
|
558
|
+
// created the store mid-request, use its requestStart to avoid a
|
|
559
|
+
// negative startTime offset.
|
|
560
|
+
const totalStart = earlyMetricsStore
|
|
561
|
+
? handlerStart
|
|
562
|
+
: metricsStore.requestStart;
|
|
563
|
+
appendMetric(
|
|
564
|
+
metricsStore,
|
|
565
|
+
"handler:total",
|
|
566
|
+
totalStart,
|
|
567
|
+
performance.now() - totalStart,
|
|
568
|
+
);
|
|
569
|
+
const metricsTiming = buildMetricsTiming(
|
|
570
|
+
request.method,
|
|
571
|
+
url.pathname,
|
|
572
|
+
metricsStore,
|
|
573
|
+
);
|
|
574
|
+
if (metricsTiming) timingParts.push(metricsTiming);
|
|
575
|
+
}
|
|
569
576
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
577
|
+
const fullTiming = timingParts.join(", ");
|
|
578
|
+
if (fullTiming && !isWebSocketUpgradeResponse(response)) {
|
|
579
|
+
response.headers.set("Server-Timing", fullTiming);
|
|
580
|
+
}
|
|
574
581
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
+
// Single open-redirect chokepoint: every response (PE, full-page,
|
|
583
|
+
// middleware short-circuit, response-route) funnels through here, so
|
|
584
|
+
// guarding browser-followed (3xx) redirects once covers them all and any
|
|
585
|
+
// future redirect exit. Soft SPA/Flight redirects are 200/204 and pass
|
|
586
|
+
// through untouched (validated client-side instead).
|
|
587
|
+
return guardOutgoingRedirect(response, url.origin, router.basename);
|
|
588
|
+
}),
|
|
589
|
+
);
|
|
582
590
|
};
|
|
583
591
|
|
|
584
592
|
// Core request handling logic (separated for middleware wrapping).
|
|
@@ -715,23 +723,15 @@ export function createRSCHandler<
|
|
|
715
723
|
},
|
|
716
724
|
});
|
|
717
725
|
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
phase: originPhase,
|
|
728
|
-
origin: request.headers.get("origin"),
|
|
729
|
-
host: request.headers.get("host"),
|
|
730
|
-
});
|
|
731
|
-
}
|
|
732
|
-
} catch {
|
|
733
|
-
// Router context may not be available
|
|
734
|
-
}
|
|
726
|
+
observeEvent({
|
|
727
|
+
type: "request.origin-rejected",
|
|
728
|
+
timestamp: performance.now(),
|
|
729
|
+
method: request.method,
|
|
730
|
+
pathname: url.pathname,
|
|
731
|
+
phase: originPhase,
|
|
732
|
+
origin: request.headers.get("origin"),
|
|
733
|
+
host: request.headers.get("host"),
|
|
734
|
+
});
|
|
735
735
|
|
|
736
736
|
return originResult;
|
|
737
737
|
}
|
|
@@ -773,23 +773,15 @@ export function createRSCHandler<
|
|
|
773
773
|
params: reqCtx.params as Record<string, string>,
|
|
774
774
|
handledByBoundary: true,
|
|
775
775
|
});
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
pathname: url.pathname,
|
|
786
|
-
routeKey: reqCtx._routeName,
|
|
787
|
-
params: reqCtx.params as Record<string, string>,
|
|
788
|
-
});
|
|
789
|
-
}
|
|
790
|
-
} catch {
|
|
791
|
-
// Router context may not be available (e.g. prerender path)
|
|
792
|
-
}
|
|
776
|
+
observeEvent({
|
|
777
|
+
type: "handler.error",
|
|
778
|
+
timestamp: performance.now(),
|
|
779
|
+
error,
|
|
780
|
+
handledByBoundary: true,
|
|
781
|
+
pathname: url.pathname,
|
|
782
|
+
routeKey: reqCtx._routeName,
|
|
783
|
+
params: reqCtx.params as Record<string, string>,
|
|
784
|
+
});
|
|
793
785
|
};
|
|
794
786
|
|
|
795
787
|
// Set route params early so all execution paths can access ctx.params.
|
|
@@ -894,14 +886,20 @@ export function createRSCHandler<
|
|
|
894
886
|
if (plan.mode === "action") {
|
|
895
887
|
let actionContinuation: ActionContinuation | undefined;
|
|
896
888
|
try {
|
|
889
|
+
// Instrument the action execution as its own phase (action:<actionId> +
|
|
890
|
+
// rango.action), so a POST shows the mutation time AND which action ran,
|
|
891
|
+
// not just the downstream revalidation render. The action's own
|
|
892
|
+
// loaders/fetches nest under rango.action.
|
|
897
893
|
const actionOutcome = await withTimeout(
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
894
|
+
observePhase(PHASES.action(plan.actionId), () =>
|
|
895
|
+
executeServerAction(
|
|
896
|
+
handlerCtx,
|
|
897
|
+
request,
|
|
898
|
+
env,
|
|
899
|
+
url,
|
|
900
|
+
plan.actionId,
|
|
901
|
+
handleStore,
|
|
902
|
+
),
|
|
905
903
|
),
|
|
906
904
|
router.timeouts.actionMs,
|
|
907
905
|
"action",
|
package/src/rsc/loader-fetch.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { getLoaderLazy } from "../server/loader-registry.js";
|
|
15
15
|
import { executeLoaderMiddleware } from "../router/middleware.js";
|
|
16
16
|
import { requireRequestContext } from "../server/request-context.js";
|
|
17
|
+
import { observePhase, PHASES } from "../router/instrument.js";
|
|
17
18
|
import {
|
|
18
19
|
createReverseFunction,
|
|
19
20
|
stripInternalParams,
|
|
@@ -162,7 +163,12 @@ export async function handleLoaderFetch<TEnv>(
|
|
|
162
163
|
...(loaderFormData ? { formData: loaderFormData } : {}),
|
|
163
164
|
};
|
|
164
165
|
|
|
165
|
-
|
|
166
|
+
// Meter the fetchable-loader execution via observePhase, the sole
|
|
167
|
+
// funnel for this path (fn is called directly, not via ctx.use).
|
|
168
|
+
// depth:1 — a fetchable request has no render-phase parent.
|
|
169
|
+
const result = await observePhase(PHASES.loader(loaderId, 1), () =>
|
|
170
|
+
fn(loaderCtx),
|
|
171
|
+
);
|
|
166
172
|
|
|
167
173
|
interface LoaderPayload {
|
|
168
174
|
loaderResult: unknown;
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { getSSRSetup } from "./ssr-setup.js";
|
|
14
14
|
import type { MiddlewareFn } from "../router/middleware.js";
|
|
15
15
|
import { executeMiddleware } from "../router/middleware.js";
|
|
16
|
+
import { observePhase, PHASES } from "../router/instrument.js";
|
|
16
17
|
import type { RscPayload, ReactFormState } from "./types.js";
|
|
17
18
|
import {
|
|
18
19
|
createResponseWithMergedHeaders,
|
|
@@ -124,7 +125,11 @@ export async function handleProgressiveEnhancement<TEnv>(
|
|
|
124
125
|
const boundAction = await ctx.decodeAction(formData);
|
|
125
126
|
// React's custom .bind() preserves $$id on server references.
|
|
126
127
|
useActionStateId = (boundAction as { $$id?: string }).$$id ?? undefined;
|
|
127
|
-
|
|
128
|
+
// Meter the no-JS form action as the action phase, same as the JS path.
|
|
129
|
+
actionResult = await observePhase(
|
|
130
|
+
PHASES.action(useActionStateId ?? "useActionState"),
|
|
131
|
+
() => boundAction(),
|
|
132
|
+
);
|
|
128
133
|
} catch (error) {
|
|
129
134
|
// Handle thrown redirect (e.g., throw redirect('/path'))
|
|
130
135
|
const redirectResponse = extractRedirectResponse(error);
|
|
@@ -172,7 +177,9 @@ export async function handleProgressiveEnhancement<TEnv>(
|
|
|
172
177
|
|
|
173
178
|
try {
|
|
174
179
|
const loadedAction = await ctx.loadServerAction(directActionId);
|
|
175
|
-
actionResult = await
|
|
180
|
+
actionResult = await observePhase(PHASES.action(directActionId), () =>
|
|
181
|
+
loadedAction.apply(null, args),
|
|
182
|
+
);
|
|
176
183
|
} catch (error) {
|
|
177
184
|
// Handle thrown redirect (e.g., throw redirect('/path'))
|
|
178
185
|
const redirectResponse = extractRedirectResponse(error);
|