@rangojs/router 0.0.0-experimental.142 → 0.0.0-experimental.144
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/vite/index.js +25 -6
- package/package.json +4 -2
- package/skills/cache-guide/SKILL.md +3 -1
- package/skills/caching/SKILL.md +41 -2
- package/skills/catalog.json +6 -0
- package/skills/composability/SKILL.md +32 -0
- package/skills/defer-hydration/SKILL.md +235 -0
- package/skills/loader/SKILL.md +5 -0
- package/skills/migrate-nextjs/SKILL.md +4 -2
- package/skills/observability/SKILL.md +8 -0
- package/skills/parallel/SKILL.md +4 -0
- package/skills/ppr/SKILL.md +110 -20
- package/skills/rango/SKILL.md +10 -0
- package/skills/route/SKILL.md +8 -0
- package/skills/typesafety/SKILL.md +1 -0
- package/skills/typesafety/generated-files-and-cli.md +30 -0
- package/skills/use-cache/SKILL.md +12 -2
- package/src/browser/partial-update.ts +7 -0
- package/src/cache/cache-key-utils.ts +29 -0
- package/src/cache/cache-scope.ts +2 -17
- package/src/cache/cache-tag.ts +60 -14
- package/src/cache/cf/cf-cache-store.ts +54 -20
- package/src/cache/document-cache.ts +17 -11
- package/src/cache/vercel/vercel-cache-store.ts +9 -19
- package/src/cloudflare/tracing.ts +7 -8
- package/src/index.rsc.ts +1 -0
- package/src/index.ts +12 -8
- package/src/redirect-origin.ts +14 -0
- package/src/route-definition/helpers-types.ts +5 -4
- package/src/route-map-builder.ts +41 -4
- package/src/router/find-match.ts +15 -1
- package/src/router/instrument.ts +9 -4
- package/src/router/lazy-includes.ts +8 -2
- package/src/router/loader-resolution.ts +14 -2
- package/src/router/match-handlers.ts +175 -133
- package/src/router/middleware.ts +40 -30
- package/src/router/router-interfaces.ts +9 -0
- package/src/router/segment-resolution/loader-snapshot.ts +98 -17
- package/src/router/telemetry-otel.ts +6 -8
- package/src/router/telemetry.ts +9 -1
- package/src/router/tracing.ts +14 -5
- package/src/router.ts +22 -14
- package/src/rsc/handler.ts +55 -32
- package/src/rsc/redirect-guard.ts +2 -1
- package/src/rsc/rsc-rendering.ts +35 -2
- package/src/rsc/shell-capture.ts +98 -20
- package/src/server/context.ts +47 -9
- package/src/server/cookie-store.ts +26 -5
- package/src/server/request-context.ts +22 -0
- package/src/ssr/index.tsx +145 -107
- package/src/testing/dispatch.ts +149 -37
- package/src/urls/path-helper-types.ts +9 -4
- package/src/vercel/tracing.ts +7 -7
- package/src/vite/inject-client-debug.ts +64 -12
- package/src/vite/router-discovery.ts +9 -1
|
@@ -10,15 +10,20 @@
|
|
|
10
10
|
*
|
|
11
11
|
* - elide: deep-walk the settled container; a SETTLED nested promise baked
|
|
12
12
|
* its value (physics: it won the quiet window), so it is pinned as
|
|
13
|
-
* that value
|
|
14
|
-
*
|
|
13
|
+
* that value wrapped in a SETTLED marker — the wrapper remembers
|
|
14
|
+
* "this was a promise" so the overlay can rehydrate the shape; a
|
|
15
|
+
* PENDING nested promise is a hole, replaced by
|
|
16
|
+
* {@link LOADER_HOLE_KEY}. The result is promise-free and
|
|
15
17
|
* Flight-serializable.
|
|
16
18
|
* - overlay: on a HIT the loader runs fresh (only the loader body can mint
|
|
17
19
|
* the live nested promises), then the recorded container is laid
|
|
18
20
|
* over it: recorded paths win (they are what the prelude froze),
|
|
19
|
-
* marker paths take the fresh run's value (the live hole),
|
|
20
|
-
*
|
|
21
|
-
*
|
|
21
|
+
* hole-marker paths take the fresh run's value (the live hole),
|
|
22
|
+
* SETTLED-marker paths become Promise.resolve(pinned) — consumers
|
|
23
|
+
* wrote use(data.x) against a promise-shaped container, and
|
|
24
|
+
* handing them the raw value throws React #438 on every HIT (the
|
|
25
|
+
* storefront PDP regression) — and fresh-only paths pass through
|
|
26
|
+
* (they cannot contradict prelude bytes that never rendered them).
|
|
22
27
|
*/
|
|
23
28
|
|
|
24
29
|
import { isThenable } from "../../handles/is-thenable.js";
|
|
@@ -42,6 +47,37 @@ export function isLoaderHoleMarker(value: unknown): value is LoaderHoleMarker {
|
|
|
42
47
|
);
|
|
43
48
|
}
|
|
44
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Marker wrapping the inlined value of a NESTED promise that settled during
|
|
52
|
+
* capture. The value baked (physics), but the container key was a promise —
|
|
53
|
+
* the overlay must hand consumers a Promise.resolve(value), not the raw value,
|
|
54
|
+
* or an unconditional use(data.x) throws React #438 on every HIT. The ROOT
|
|
55
|
+
* container is never wrapped: loader-cache overlays against the awaited fresh
|
|
56
|
+
* container value.
|
|
57
|
+
*/
|
|
58
|
+
export const LOADER_SETTLED_KEY = "$rangoLoaderSettled" as const;
|
|
59
|
+
|
|
60
|
+
export interface LoaderSettledMarker {
|
|
61
|
+
[LOADER_SETTLED_KEY]: 1;
|
|
62
|
+
value: unknown;
|
|
63
|
+
/**
|
|
64
|
+
* Present when the pinned subtree contains hole markers. Computed once at
|
|
65
|
+
* capture (elide already visits every node) so the per-HIT overlay never
|
|
66
|
+
* rescans the pinned structure to pick its rehydration path.
|
|
67
|
+
*/
|
|
68
|
+
holes?: 1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function isLoaderSettledMarker(
|
|
72
|
+
value: unknown,
|
|
73
|
+
): value is LoaderSettledMarker {
|
|
74
|
+
return (
|
|
75
|
+
typeof value === "object" &&
|
|
76
|
+
value !== null &&
|
|
77
|
+
(value as Record<string, unknown>)[LOADER_SETTLED_KEY] === 1
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
45
81
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
46
82
|
if (typeof value !== "object" || value === null) return false;
|
|
47
83
|
const proto = Object.getPrototypeOf(value);
|
|
@@ -77,17 +113,22 @@ async function probeSettled(
|
|
|
77
113
|
}
|
|
78
114
|
|
|
79
115
|
export type ElideResult =
|
|
80
|
-
| { state: "ok"; value: unknown }
|
|
116
|
+
| { state: "ok"; value: unknown; hasHole: boolean }
|
|
81
117
|
| { state: "rejected" };
|
|
82
118
|
|
|
83
119
|
/**
|
|
84
120
|
* Deep-elide a settled bake-lane container for recording. Settled nested
|
|
85
|
-
* promises
|
|
86
|
-
*
|
|
87
|
-
*
|
|
121
|
+
* promises pin their value behind a settled marker (they baked, but consumers
|
|
122
|
+
* hold a promise-shaped key); pending ones become hole markers; a REJECTED
|
|
123
|
+
* nested promise poisons the record (error UI must never bake into a shared
|
|
124
|
+
* shell) — the caller refuses the capture. Only plain objects/arrays are
|
|
88
125
|
* traversed; anything else (Date, Map, class instance) is a pinned leaf.
|
|
89
126
|
* Cycles are cut as pinned references (best effort — Flight rejects true
|
|
90
127
|
* cycles later regardless).
|
|
128
|
+
*
|
|
129
|
+
* The ROOT container promise-chain unwraps with NO marker: loader-cache
|
|
130
|
+
* overlays against the AWAITED fresh container, so the recorded root must be
|
|
131
|
+
* the unwrapped structure. Everything below it goes through elideNested.
|
|
91
132
|
*/
|
|
92
133
|
export async function elideLoaderContainer(
|
|
93
134
|
value: unknown,
|
|
@@ -96,37 +137,61 @@ export async function elideLoaderContainer(
|
|
|
96
137
|
if (isThenable(value)) {
|
|
97
138
|
const probed = await probeSettled(value);
|
|
98
139
|
if (probed.state === "pending") {
|
|
99
|
-
return { state: "ok", value: { [LOADER_HOLE_KEY]: 1 } };
|
|
140
|
+
return { state: "ok", value: { [LOADER_HOLE_KEY]: 1 }, hasHole: true };
|
|
100
141
|
}
|
|
101
142
|
if (probed.state === "rejected") return { state: "rejected" };
|
|
102
143
|
return elideLoaderContainer(probed.value, seen);
|
|
103
144
|
}
|
|
145
|
+
return elideNested(value, seen);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function elideNested(
|
|
149
|
+
value: unknown,
|
|
150
|
+
seen: Set<object>,
|
|
151
|
+
): Promise<ElideResult> {
|
|
152
|
+
if (isThenable(value)) {
|
|
153
|
+
const probed = await probeSettled(value);
|
|
154
|
+
if (probed.state === "pending") {
|
|
155
|
+
return { state: "ok", value: { [LOADER_HOLE_KEY]: 1 }, hasHole: true };
|
|
156
|
+
}
|
|
157
|
+
if (probed.state === "rejected") return { state: "rejected" };
|
|
158
|
+
const inner = await elideNested(probed.value, seen);
|
|
159
|
+
if (inner.state === "rejected") return inner;
|
|
160
|
+
const marker: LoaderSettledMarker = inner.hasHole
|
|
161
|
+
? { [LOADER_SETTLED_KEY]: 1, value: inner.value, holes: 1 }
|
|
162
|
+
: { [LOADER_SETTLED_KEY]: 1, value: inner.value };
|
|
163
|
+
return { state: "ok", value: marker, hasHole: inner.hasHole };
|
|
164
|
+
}
|
|
104
165
|
|
|
105
166
|
if (Array.isArray(value)) {
|
|
106
|
-
if (seen.has(value)) return { state: "ok", value };
|
|
167
|
+
if (seen.has(value)) return { state: "ok", value, hasHole: false };
|
|
107
168
|
seen.add(value);
|
|
108
169
|
const out: unknown[] = new Array(value.length);
|
|
170
|
+
let hasHole = false;
|
|
109
171
|
for (let i = 0; i < value.length; i++) {
|
|
110
|
-
const r = await
|
|
172
|
+
const r = await elideNested(value[i], seen);
|
|
111
173
|
if (r.state === "rejected") return r;
|
|
112
174
|
out[i] = r.value;
|
|
175
|
+
hasHole ||= r.hasHole;
|
|
113
176
|
}
|
|
114
|
-
return { state: "ok", value: out };
|
|
177
|
+
return { state: "ok", value: out, hasHole };
|
|
115
178
|
}
|
|
116
179
|
|
|
117
180
|
if (isPlainObject(value)) {
|
|
118
|
-
if (seen.has(value)) return { state: "ok", value };
|
|
181
|
+
if (seen.has(value)) return { state: "ok", value, hasHole: false };
|
|
119
182
|
seen.add(value);
|
|
120
183
|
const out: Record<string, unknown> = {};
|
|
184
|
+
let hasHole = false;
|
|
121
185
|
for (const key of Object.keys(value)) {
|
|
122
|
-
const r = await
|
|
186
|
+
const r = await elideNested(value[key], seen);
|
|
123
187
|
if (r.state === "rejected") return r;
|
|
124
188
|
out[key] = r.value;
|
|
189
|
+
hasHole ||= r.hasHole;
|
|
125
190
|
}
|
|
126
|
-
return { state: "ok", value: out };
|
|
191
|
+
return { state: "ok", value: out, hasHole };
|
|
127
192
|
}
|
|
128
193
|
|
|
129
|
-
return { state: "ok", value };
|
|
194
|
+
return { state: "ok", value, hasHole: false };
|
|
130
195
|
}
|
|
131
196
|
|
|
132
197
|
/**
|
|
@@ -142,6 +207,22 @@ export function overlayLoaderContainer(
|
|
|
142
207
|
): unknown {
|
|
143
208
|
if (isLoaderHoleMarker(recorded)) return fresh;
|
|
144
209
|
|
|
210
|
+
if (isLoaderSettledMarker(recorded)) {
|
|
211
|
+
const pinned = recorded.value;
|
|
212
|
+
// Deep holes inside a settled container (capture-computed `holes` bit)
|
|
213
|
+
// need the fresh promise's resolved value to fill them; a fully-pinned
|
|
214
|
+
// container resolves immediately (the prelude already shows it — never
|
|
215
|
+
// gate it on fresh latency). A rejecting fresh run degrades holes to
|
|
216
|
+
// undefined instead of poisoning the pin.
|
|
217
|
+
if (recorded.holes === 1) {
|
|
218
|
+
return Promise.resolve(fresh).then(
|
|
219
|
+
(freshValue) => overlayLoaderContainer(freshValue, pinned),
|
|
220
|
+
() => overlayLoaderContainer(undefined, pinned),
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return Promise.resolve(overlayLoaderContainer(undefined, pinned));
|
|
224
|
+
}
|
|
225
|
+
|
|
145
226
|
if (Array.isArray(recorded)) {
|
|
146
227
|
const freshArr = Array.isArray(fresh) ? fresh : [];
|
|
147
228
|
return recorded.map((item, i) => overlayLoaderContainer(freshArr[i], item));
|
|
@@ -41,7 +41,7 @@ import { runThenSettle } from "./tracing.js";
|
|
|
41
41
|
import type {
|
|
42
42
|
RouterTracingConfig,
|
|
43
43
|
SpanRunner,
|
|
44
|
-
|
|
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
|
-
/**
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
package/src/router/telemetry.ts
CHANGED
|
@@ -38,6 +38,14 @@ export interface RequestEndEvent extends BaseEvent {
|
|
|
38
38
|
durationMs: number;
|
|
39
39
|
segmentCount: number;
|
|
40
40
|
cacheHit: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* HTTP status when a Response ended the transaction — a thrown-Response
|
|
43
|
+
* short-circuit (redirect / auth gate carries the Response's status, e.g. 302),
|
|
44
|
+
* or dispatch()'s final response status. Absent for a normal render completion:
|
|
45
|
+
* the Response is built after match(), so match()/matchPartial() have no status
|
|
46
|
+
* to stamp there. Lets a sink split 3xx short-circuits from 2xx completions.
|
|
47
|
+
*/
|
|
48
|
+
status?: number;
|
|
41
49
|
}
|
|
42
50
|
|
|
43
51
|
export interface RequestErrorEvent extends BaseEvent {
|
|
@@ -320,7 +328,7 @@ export function createConsoleSink(): TelemetrySink {
|
|
|
320
328
|
break;
|
|
321
329
|
case "request.end":
|
|
322
330
|
console.log(
|
|
323
|
-
`[telemetry] ${event.type} ${event.method} ${event.pathname} ${event.durationMs.toFixed(1)}ms segments=${event.segmentCount} cache=${event.cacheHit}`,
|
|
331
|
+
`[telemetry] ${event.type} ${event.method} ${event.pathname} ${event.durationMs.toFixed(1)}ms segments=${event.segmentCount} cache=${event.cacheHit}${event.status !== undefined ? ` status=${event.status}` : ""}`,
|
|
324
332
|
);
|
|
325
333
|
break;
|
|
326
334
|
case "request.error":
|
package/src/router/tracing.ts
CHANGED
|
@@ -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,14 +420,17 @@ 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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
reqCtx._metricsStore ??= createMetricsStore(true);
|
|
429
|
+
if (!enabled || !reqCtx) return undefined;
|
|
430
|
+
// Anchor a mid-request store to the true request entry (reqCtx._handlerStart),
|
|
431
|
+
// not this call's performance.now(); undefined falls back to now() inside
|
|
432
|
+
// createMetricsStore (metrics.ts).
|
|
433
|
+
reqCtx._metricsStore ??= createMetricsStore(true, reqCtx._handlerStart);
|
|
431
434
|
return reqCtx._metricsStore;
|
|
432
435
|
};
|
|
433
436
|
|
|
@@ -438,11 +441,6 @@ export function createRouter<TEnv = any>(
|
|
|
438
441
|
const findNearestNotFoundBoundary = (entry: EntryData | null) =>
|
|
439
442
|
findNotFoundBoundary(entry, defaultNotFoundBoundary);
|
|
440
443
|
|
|
441
|
-
// Helper to get handleStore from request context
|
|
442
|
-
const getHandleStore = (): HandleStore | undefined => {
|
|
443
|
-
return _getRequestContext()?._handleStore;
|
|
444
|
-
};
|
|
445
|
-
|
|
446
444
|
// Track a pending handler promise (non-blocking).
|
|
447
445
|
// Attaches a side-effect .catch() to report streaming handler errors to onError
|
|
448
446
|
// without altering the rejection chain (React's streaming error boundary still handles it).
|
|
@@ -453,13 +451,14 @@ export function createRouter<TEnv = any>(
|
|
|
453
451
|
segmentType?: string;
|
|
454
452
|
},
|
|
455
453
|
): Promise<T> => {
|
|
456
|
-
|
|
454
|
+
// One ALS read serves both the store lookup and the onError closure.
|
|
455
|
+
const reqCtx = _getRequestContext();
|
|
456
|
+
const store = reqCtx?._handleStore;
|
|
457
457
|
const tracked = store ? store.track(promise) : promise;
|
|
458
458
|
|
|
459
459
|
// Report streaming handler errors to onError as a side-effect.
|
|
460
460
|
// The rejection still propagates to the RSC stream for client error boundaries.
|
|
461
461
|
// Captures request context eagerly (closure) so the catch handler has full context.
|
|
462
|
-
const reqCtx = _getRequestContext();
|
|
463
462
|
if (reqCtx && onError) {
|
|
464
463
|
tracked.catch((error) => {
|
|
465
464
|
callOnError(error, "handler", {
|
|
@@ -501,8 +500,12 @@ export function createRouter<TEnv = any>(
|
|
|
501
500
|
? getRequestId(errorContext.request)
|
|
502
501
|
: undefined
|
|
503
502
|
: undefined;
|
|
503
|
+
// Derived once here for both the loader.start and loader.end emits (the
|
|
504
|
+
// loader.error emit uses ctx.loaderName from wrapLoaderWithErrorHandling).
|
|
505
|
+
const loaderName = telemetrySink
|
|
506
|
+
? segmentId.split(".").pop() || "unknown"
|
|
507
|
+
: "";
|
|
504
508
|
if (telemetrySink) {
|
|
505
|
-
const loaderName = segmentId.split(".").pop() || "unknown";
|
|
506
509
|
safeEmit(telemetry, {
|
|
507
510
|
type: "loader.start",
|
|
508
511
|
timestamp: loaderStart,
|
|
@@ -556,7 +559,6 @@ export function createRouter<TEnv = any>(
|
|
|
556
559
|
|
|
557
560
|
// Emit loader.end after the promise settles (fire-and-forget)
|
|
558
561
|
if (telemetrySink) {
|
|
559
|
-
const loaderName = segmentId.split(".").pop() || "unknown";
|
|
560
562
|
result.then((r) => {
|
|
561
563
|
safeEmit(telemetry, {
|
|
562
564
|
type: "loader.end",
|
|
@@ -1008,6 +1010,12 @@ export function createRouter<TEnv = any>(
|
|
|
1008
1010
|
// Expose resolved span tracing for the handler (Cloudflare custom spans)
|
|
1009
1011
|
tracing: resolvedTracing,
|
|
1010
1012
|
|
|
1013
|
+
// Expose the raw telemetry sink so handler-level emitters (timeout, origin
|
|
1014
|
+
// rejection, late-handle handler.error) can emit outside the match ALS.
|
|
1015
|
+
// Raw (not the resolveSink no-op wrapper) so router.telemetry stays
|
|
1016
|
+
// undefined when unconfigured and call sites gate on truthiness.
|
|
1017
|
+
telemetry: telemetrySink,
|
|
1018
|
+
|
|
1011
1019
|
// Expose debug manifest flag for handler
|
|
1012
1020
|
allowDebugManifest: allowDebugManifestOption,
|
|
1013
1021
|
|
package/src/rsc/handler.ts
CHANGED
|
@@ -85,7 +85,8 @@ import {
|
|
|
85
85
|
appendMetric,
|
|
86
86
|
buildMetricsTiming,
|
|
87
87
|
} from "../router/metrics.js";
|
|
88
|
-
import { observePhase,
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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 {
|
|
@@ -462,6 +466,11 @@ export function createRSCHandler<
|
|
|
462
466
|
stateCookieName: router.resolvedStateCookieName,
|
|
463
467
|
version,
|
|
464
468
|
});
|
|
469
|
+
// Thread the true request entry timestamp onto the context so a metrics
|
|
470
|
+
// store created MID-request (ctx.debugPerformance() / getMetricsStore) anchors
|
|
471
|
+
// to the real start, not the opt-in moment — phases that began earlier then
|
|
472
|
+
// report non-negative offsets. Set unconditionally: debug may be enabled later.
|
|
473
|
+
requestContext._handlerStart = handlerStart;
|
|
465
474
|
if (earlyMetricsStore) {
|
|
466
475
|
requestContext._debugPerformance = true;
|
|
467
476
|
requestContext._metricsStore = earlyMetricsStore;
|
|
@@ -569,8 +578,10 @@ export function createRSCHandler<
|
|
|
569
578
|
if (metricsStore) {
|
|
570
579
|
// When the store was created at handler start (earlyMetricsStore),
|
|
571
580
|
// handler:total covers the full request. When ctx.debugPerformance()
|
|
572
|
-
// created the store mid-request
|
|
573
|
-
//
|
|
581
|
+
// created the store mid-request its requestStart is now the threaded
|
|
582
|
+
// _handlerStart (== handlerStart), so both branches yield the true
|
|
583
|
+
// request entry; reading the store's own anchor keeps this correct even
|
|
584
|
+
// if a store ever lands without the threading (falls back to its start).
|
|
574
585
|
const totalStart = earlyMetricsStore
|
|
575
586
|
? handlerStart
|
|
576
587
|
: metricsStore.requestStart;
|
|
@@ -590,7 +601,13 @@ export function createRSCHandler<
|
|
|
590
601
|
|
|
591
602
|
const fullTiming = timingParts.join(", ");
|
|
592
603
|
if (fullTiming && !isWebSocketUpgradeResponse(response)) {
|
|
593
|
-
|
|
604
|
+
try {
|
|
605
|
+
response.headers.set("Server-Timing", fullTiming);
|
|
606
|
+
} catch {
|
|
607
|
+
// Immutable headers (e.g. a passed-through platform Response) — drop
|
|
608
|
+
// the timing header, never the response. Instrumentation must not
|
|
609
|
+
// 500 a request.
|
|
610
|
+
}
|
|
594
611
|
}
|
|
595
612
|
|
|
596
613
|
// Single open-redirect chokepoint: every response (PE, full-page,
|
|
@@ -737,15 +754,18 @@ export function createRSCHandler<
|
|
|
737
754
|
},
|
|
738
755
|
});
|
|
739
756
|
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
757
|
+
if (router.telemetry) {
|
|
758
|
+
safeEmit(resolveSink(router.telemetry), {
|
|
759
|
+
type: "request.origin-rejected",
|
|
760
|
+
timestamp: performance.now(),
|
|
761
|
+
requestId: getRequestId(request),
|
|
762
|
+
method: request.method,
|
|
763
|
+
pathname: url.pathname,
|
|
764
|
+
phase: originPhase,
|
|
765
|
+
origin: request.headers.get("origin"),
|
|
766
|
+
host: request.headers.get("host"),
|
|
767
|
+
});
|
|
768
|
+
}
|
|
749
769
|
|
|
750
770
|
return originResult;
|
|
751
771
|
}
|
|
@@ -787,15 +807,18 @@ export function createRSCHandler<
|
|
|
787
807
|
params: reqCtx.params as Record<string, string>,
|
|
788
808
|
handledByBoundary: true,
|
|
789
809
|
});
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
810
|
+
if (router.telemetry) {
|
|
811
|
+
safeEmit(resolveSink(router.telemetry), {
|
|
812
|
+
type: "handler.error",
|
|
813
|
+
timestamp: performance.now(),
|
|
814
|
+
requestId: getRequestId(request),
|
|
815
|
+
error,
|
|
816
|
+
handledByBoundary: true,
|
|
817
|
+
pathname: url.pathname,
|
|
818
|
+
routeKey: reqCtx._routeName,
|
|
819
|
+
params: reqCtx.params as Record<string, string>,
|
|
820
|
+
});
|
|
821
|
+
}
|
|
799
822
|
};
|
|
800
823
|
|
|
801
824
|
// Set route params early so all execution paths can access ctx.params.
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
resolveSameOriginRedirect,
|
|
39
39
|
resolveExternalRedirect,
|
|
40
40
|
isExternalRedirect,
|
|
41
|
+
safeSameOriginLanding,
|
|
41
42
|
EXTERNAL_REDIRECT_MARKER,
|
|
42
43
|
} from "../redirect-origin.js";
|
|
43
44
|
import { carryOverRedirectHeaders } from "./helpers.js";
|
|
@@ -79,7 +80,7 @@ export function guardOutgoingRedirect(
|
|
|
79
80
|
|
|
80
81
|
// Cross-origin (or unsafe-scheme external): neutralize to a safe same-origin
|
|
81
82
|
// landing.
|
|
82
|
-
const safeTarget = basename
|
|
83
|
+
const safeTarget = safeSameOriginLanding(basename);
|
|
83
84
|
if (process.env.NODE_ENV !== "production") {
|
|
84
85
|
console.error(
|
|
85
86
|
`[rango] Blocked cross-origin redirect to "${location}"; sent to ` +
|
package/src/rsc/rsc-rendering.ts
CHANGED
|
@@ -44,6 +44,10 @@ import {
|
|
|
44
44
|
warnPprNonceActiveOnce,
|
|
45
45
|
} from "./shell-serve.js";
|
|
46
46
|
import { contextGet } from "../context-var.js";
|
|
47
|
+
import {
|
|
48
|
+
resolveSameOriginRedirect,
|
|
49
|
+
safeSameOriginLanding,
|
|
50
|
+
} from "../redirect-origin.js";
|
|
47
51
|
import { nonce as nonceToken } from "./nonce.js";
|
|
48
52
|
import { reportCacheError } from "../cache/cache-error.js";
|
|
49
53
|
import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
|
|
@@ -413,6 +417,29 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
413
417
|
return response;
|
|
414
418
|
}
|
|
415
419
|
|
|
420
|
+
/**
|
|
421
|
+
* Neutralize the shell-HIT degradation redirect target.
|
|
422
|
+
*
|
|
423
|
+
* The inline `location.replace` emitted by serveShellHit when a shell HIT lands
|
|
424
|
+
* on a URL whose route became redirecting mid-TTL is a document-native redirect
|
|
425
|
+
* exit that BYPASSES the 3xx chokepoint (guardOutgoingRedirect acts only on 3xx
|
|
426
|
+
* + Location responses, never a committed 200 body). So it reuses the ONE
|
|
427
|
+
* same-origin resolver directly: a cross-origin/unparseable/unsafe target
|
|
428
|
+
* neutralizes to the same safe same-origin landing as redirect-guard.ts
|
|
429
|
+
* (basename root, or "/" when unset) rather than navigating the user off-host.
|
|
430
|
+
* A safe same-origin/relative target passes through as its normalized href.
|
|
431
|
+
*/
|
|
432
|
+
export function resolveShellHitRedirectTarget(
|
|
433
|
+
rawTarget: string,
|
|
434
|
+
requestOrigin: string,
|
|
435
|
+
basename: string | undefined,
|
|
436
|
+
): string {
|
|
437
|
+
return (
|
|
438
|
+
resolveSameOriginRedirect(rawTarget, requestOrigin) ??
|
|
439
|
+
safeSameOriginLanding(basename)
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
416
443
|
/**
|
|
417
444
|
* Serve a validated shell HIT: commit the composed response NOW — the stored
|
|
418
445
|
* prelude bytes are the first thing on the wire — and run the live tail
|
|
@@ -535,10 +562,16 @@ function serveShellHit(
|
|
|
535
562
|
// a shell (capture bails on redirects), so a HIT on a redirecting URL
|
|
536
563
|
// requires the route to have BECOME redirecting within the shell TTL.
|
|
537
564
|
// The 200 + prelude are already committed; degrade to a client-side
|
|
538
|
-
// replace so the user still lands on the target.
|
|
565
|
+
// replace so the user still lands on the target. The target is
|
|
566
|
+
// neutralized first (see resolveShellHitRedirectTarget).
|
|
567
|
+
const safeTarget = resolveShellHitRedirectTarget(
|
|
568
|
+
tail.redirect,
|
|
569
|
+
url.origin,
|
|
570
|
+
ctx.router.basename,
|
|
571
|
+
);
|
|
539
572
|
controller.enqueue(
|
|
540
573
|
new TextEncoder().encode(
|
|
541
|
-
`<script>location.replace(${JSON.stringify(
|
|
574
|
+
`<script>location.replace(${JSON.stringify(safeTarget)})</script>`,
|
|
542
575
|
),
|
|
543
576
|
);
|
|
544
577
|
}
|