@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.
Files changed (55) hide show
  1. package/dist/vite/index.js +25 -6
  2. package/package.json +4 -2
  3. package/skills/cache-guide/SKILL.md +3 -1
  4. package/skills/caching/SKILL.md +41 -2
  5. package/skills/catalog.json +6 -0
  6. package/skills/composability/SKILL.md +32 -0
  7. package/skills/defer-hydration/SKILL.md +235 -0
  8. package/skills/loader/SKILL.md +5 -0
  9. package/skills/migrate-nextjs/SKILL.md +4 -2
  10. package/skills/observability/SKILL.md +8 -0
  11. package/skills/parallel/SKILL.md +4 -0
  12. package/skills/ppr/SKILL.md +110 -20
  13. package/skills/rango/SKILL.md +10 -0
  14. package/skills/route/SKILL.md +8 -0
  15. package/skills/typesafety/SKILL.md +1 -0
  16. package/skills/typesafety/generated-files-and-cli.md +30 -0
  17. package/skills/use-cache/SKILL.md +12 -2
  18. package/src/browser/partial-update.ts +7 -0
  19. package/src/cache/cache-key-utils.ts +29 -0
  20. package/src/cache/cache-scope.ts +2 -17
  21. package/src/cache/cache-tag.ts +60 -14
  22. package/src/cache/cf/cf-cache-store.ts +54 -20
  23. package/src/cache/document-cache.ts +17 -11
  24. package/src/cache/vercel/vercel-cache-store.ts +9 -19
  25. package/src/cloudflare/tracing.ts +7 -8
  26. package/src/index.rsc.ts +1 -0
  27. package/src/index.ts +12 -8
  28. package/src/redirect-origin.ts +14 -0
  29. package/src/route-definition/helpers-types.ts +5 -4
  30. package/src/route-map-builder.ts +41 -4
  31. package/src/router/find-match.ts +15 -1
  32. package/src/router/instrument.ts +9 -4
  33. package/src/router/lazy-includes.ts +8 -2
  34. package/src/router/loader-resolution.ts +14 -2
  35. package/src/router/match-handlers.ts +175 -133
  36. package/src/router/middleware.ts +40 -30
  37. package/src/router/router-interfaces.ts +9 -0
  38. package/src/router/segment-resolution/loader-snapshot.ts +98 -17
  39. package/src/router/telemetry-otel.ts +6 -8
  40. package/src/router/telemetry.ts +9 -1
  41. package/src/router/tracing.ts +14 -5
  42. package/src/router.ts +22 -14
  43. package/src/rsc/handler.ts +55 -32
  44. package/src/rsc/redirect-guard.ts +2 -1
  45. package/src/rsc/rsc-rendering.ts +35 -2
  46. package/src/rsc/shell-capture.ts +98 -20
  47. package/src/server/context.ts +47 -9
  48. package/src/server/cookie-store.ts +26 -5
  49. package/src/server/request-context.ts +22 -0
  50. package/src/ssr/index.tsx +145 -107
  51. package/src/testing/dispatch.ts +149 -37
  52. package/src/urls/path-helper-types.ts +9 -4
  53. package/src/vercel/tracing.ts +7 -7
  54. package/src/vite/inject-client-debug.ts +64 -12
  55. 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; a PENDING nested promise is a hole, replaced by
14
- * {@link LOADER_HOLE_MARKER}. The result is promise-free and
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), and
20
- * fresh-only paths pass through (they cannot contradict prelude
21
- * bytes that never rendered them).
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 are inlined (they baked); pending ones become hole markers; a
86
- * REJECTED nested promise poisons the record (error UI must never bake into a
87
- * shared shell) — the caller refuses the capture. Only plain objects/arrays are
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 elideLoaderContainer(value[i], seen);
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 elideLoaderContainer(value[key], seen);
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
- TracePhaseToggles,
44
+ TracingToggleOptions,
45
45
  } from "./tracing.js";
46
46
 
47
47
  // ---------------------------------------------------------------------------
@@ -92,13 +92,11 @@ const STATUS_ERROR = 2;
92
92
  // Tracing adapter: phase spans via startActiveSpan (the `tracing` slot)
93
93
  // ---------------------------------------------------------------------------
94
94
 
95
- /** Options for createOTelTracing. */
96
- export interface OTelTracingOptions {
97
- /** Master switch. Defaults to true. */
98
- enabled?: boolean;
99
- /** Per-phase span toggles. Omitted phases default to enabled. */
100
- spans?: TracePhaseToggles;
101
- }
95
+ /**
96
+ * Options for createOTelTracing. Alias of the shared {@link TracingToggleOptions}
97
+ * (`enabled` master switch + per-phase `spans` toggles); the name is public API.
98
+ */
99
+ export type OTelTracingOptions = TracingToggleOptions;
102
100
 
103
101
  /**
104
102
  * Create the tracing config that maps the router's phases onto OTel spans via
@@ -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":
@@ -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
- if (!reqCtx) {
428
- return createMetricsStore(true);
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
- const store = getHandleStore();
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
 
@@ -85,7 +85,8 @@ import {
85
85
  appendMetric,
86
86
  buildMetricsTiming,
87
87
  } from "../router/metrics.js";
88
- import { observePhase, observeEvent, PHASES } from "../router/instrument.js";
88
+ import { observePhase, PHASES } from "../router/instrument.js";
89
+ import { safeEmit, resolveSink, getRequestId } from "../router/telemetry.js";
89
90
  import {
90
91
  startSSRSetup,
91
92
  getSSRSetup,
@@ -246,16 +247,19 @@ export function createRSCHandler<
246
247
  metadata: { timeout: true, phase, durationMs },
247
248
  });
248
249
 
249
- observeEvent({
250
- type: "request.timeout",
251
- timestamp: performance.now(),
252
- phase,
253
- pathname: url.pathname,
254
- routeKey,
255
- actionId,
256
- durationMs,
257
- customHandler: !!router.onTimeout,
258
- });
250
+ if (router.telemetry) {
251
+ safeEmit(resolveSink(router.telemetry), {
252
+ type: "request.timeout",
253
+ timestamp: performance.now(),
254
+ requestId: getRequestId(request),
255
+ phase,
256
+ pathname: url.pathname,
257
+ routeKey,
258
+ actionId,
259
+ durationMs,
260
+ customHandler: !!router.onTimeout,
261
+ });
262
+ }
259
263
 
260
264
  if (router.onTimeout) {
261
265
  try {
@@ -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, use its requestStart to avoid a
573
- // negative startTime offset.
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
- response.headers.set("Server-Timing", fullTiming);
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
- observeEvent({
741
- type: "request.origin-rejected",
742
- timestamp: performance.now(),
743
- method: request.method,
744
- pathname: url.pathname,
745
- phase: originPhase,
746
- origin: request.headers.get("origin"),
747
- host: request.headers.get("host"),
748
- });
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
- observeEvent({
791
- type: "handler.error",
792
- timestamp: performance.now(),
793
- error,
794
- handledByBoundary: true,
795
- pathname: url.pathname,
796
- routeKey: reqCtx._routeName,
797
- params: reqCtx.params as Record<string, string>,
798
- });
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 && basename !== "/" ? 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 ` +
@@ -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(tail.redirect)})</script>`,
574
+ `<script>location.replace(${JSON.stringify(safeTarget)})</script>`,
542
575
  ),
543
576
  );
544
577
  }