@rangojs/router 0.0.0-experimental.142 → 0.0.0-experimental.143
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 +3 -1
- package/package.json +3 -1
- package/skills/caching/SKILL.md +18 -0
- package/skills/composability/SKILL.md +32 -0
- package/skills/observability/SKILL.md +8 -0
- package/skills/parallel/SKILL.md +2 -0
- package/skills/ppr/SKILL.md +76 -10
- 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/src/cloudflare/tracing.ts +7 -8
- package/src/index.rsc.ts +1 -0
- package/src/index.ts +12 -8
- package/src/route-definition/helpers-types.ts +5 -4
- package/src/route-map-builder.ts +24 -1
- package/src/router/find-match.ts +15 -1
- package/src/router/instrument.ts +9 -4
- package/src/router/match-handlers.ts +170 -133
- package/src/router/middleware.ts +36 -29
- 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/tracing.ts +14 -5
- package/src/router.ts +15 -6
- package/src/rsc/handler.ts +46 -30
- package/src/rsc/shell-capture.ts +5 -0
- package/src/testing/dispatch.ts +142 -37
- package/src/urls/path-helper-types.ts +9 -4
- package/src/vercel/tracing.ts +7 -7
|
@@ -41,6 +41,106 @@ import {
|
|
|
41
41
|
} from "./telemetry.js";
|
|
42
42
|
import { _getRequestContext } from "../server/request-context.js";
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Per-call telemetry lifecycle emitter for match()/matchPartial(). Each method
|
|
46
|
+
* reproduces the exact event object the two functions used to emit inline and is
|
|
47
|
+
* gated on the same per-call flag (`enabled` = the request's `emitTelemetry`), so
|
|
48
|
+
* a PPR shell-capture run (enabled=false) emits nothing while a foreground run
|
|
49
|
+
* emits byte-identical events. Extracted so the two transactions can't drift;
|
|
50
|
+
* pinned by thrown-response-telemetry.test.ts and
|
|
51
|
+
* shell-capture-telemetry-suppression.test.ts.
|
|
52
|
+
*/
|
|
53
|
+
interface LifecycleEmitter {
|
|
54
|
+
start(): void;
|
|
55
|
+
end(segmentCount: number, cacheHit: boolean): void;
|
|
56
|
+
cacheDecision(
|
|
57
|
+
routeKey: string,
|
|
58
|
+
state: {
|
|
59
|
+
cacheHit: boolean;
|
|
60
|
+
cacheSource?: "runtime" | "prerender";
|
|
61
|
+
shouldRevalidate?: boolean;
|
|
62
|
+
},
|
|
63
|
+
segments: CacheSegmentSignal[],
|
|
64
|
+
): void;
|
|
65
|
+
error(error: Error, phase: string): void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function createLifecycleEmitter(args: {
|
|
69
|
+
enabled: boolean;
|
|
70
|
+
sink: TelemetrySink;
|
|
71
|
+
requestId: string | undefined;
|
|
72
|
+
method: string;
|
|
73
|
+
pathname: string;
|
|
74
|
+
transaction: "match" | "matchPartial";
|
|
75
|
+
isPartial: boolean;
|
|
76
|
+
matchStart: number;
|
|
77
|
+
}): LifecycleEmitter {
|
|
78
|
+
return {
|
|
79
|
+
start(): void {
|
|
80
|
+
if (!args.enabled) return;
|
|
81
|
+
safeEmit(args.sink, {
|
|
82
|
+
type: "request.start",
|
|
83
|
+
timestamp: args.matchStart,
|
|
84
|
+
requestId: args.requestId,
|
|
85
|
+
method: args.method,
|
|
86
|
+
pathname: args.pathname,
|
|
87
|
+
transaction: args.transaction,
|
|
88
|
+
isPartial: args.isPartial,
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
end(segmentCount: number, cacheHit: boolean): void {
|
|
92
|
+
if (!args.enabled) return;
|
|
93
|
+
safeEmit(args.sink, {
|
|
94
|
+
type: "request.end",
|
|
95
|
+
timestamp: performance.now(),
|
|
96
|
+
requestId: args.requestId,
|
|
97
|
+
method: args.method,
|
|
98
|
+
pathname: args.pathname,
|
|
99
|
+
transaction: args.transaction,
|
|
100
|
+
durationMs: performance.now() - args.matchStart,
|
|
101
|
+
segmentCount,
|
|
102
|
+
cacheHit,
|
|
103
|
+
});
|
|
104
|
+
},
|
|
105
|
+
cacheDecision(
|
|
106
|
+
routeKey: string,
|
|
107
|
+
state: {
|
|
108
|
+
cacheHit: boolean;
|
|
109
|
+
cacheSource?: "runtime" | "prerender";
|
|
110
|
+
shouldRevalidate?: boolean;
|
|
111
|
+
},
|
|
112
|
+
segments: CacheSegmentSignal[],
|
|
113
|
+
): void {
|
|
114
|
+
if (!args.enabled) return;
|
|
115
|
+
safeEmit(args.sink, {
|
|
116
|
+
type: "cache.decision",
|
|
117
|
+
timestamp: performance.now(),
|
|
118
|
+
requestId: args.requestId,
|
|
119
|
+
pathname: args.pathname,
|
|
120
|
+
routeKey,
|
|
121
|
+
hit: state.cacheHit,
|
|
122
|
+
shouldRevalidate: !!state.shouldRevalidate,
|
|
123
|
+
source: state.cacheSource,
|
|
124
|
+
segments,
|
|
125
|
+
});
|
|
126
|
+
},
|
|
127
|
+
error(error: Error, phase: string): void {
|
|
128
|
+
if (!args.enabled) return;
|
|
129
|
+
safeEmit(args.sink, {
|
|
130
|
+
type: "request.error",
|
|
131
|
+
timestamp: performance.now(),
|
|
132
|
+
requestId: args.requestId,
|
|
133
|
+
method: args.method,
|
|
134
|
+
pathname: args.pathname,
|
|
135
|
+
transaction: args.transaction,
|
|
136
|
+
error,
|
|
137
|
+
phase,
|
|
138
|
+
durationMs: performance.now() - args.matchStart,
|
|
139
|
+
});
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
44
144
|
export interface MatchHandlerDeps<TEnv = any> {
|
|
45
145
|
buildRouterContext: () => RouterContext<TEnv>;
|
|
46
146
|
callOnError: (error: unknown, phase: ErrorPhase, context: any) => void;
|
|
@@ -155,9 +255,20 @@ export function createMatchHandlers<TEnv = any>(
|
|
|
155
255
|
}
|
|
156
256
|
|
|
157
257
|
async function match(request: Request, env: TEnv): Promise<MatchResult> {
|
|
158
|
-
|
|
258
|
+
// Silence telemetry for the PPR background shell capture: it re-runs match()
|
|
259
|
+
// under a derived request context flagged _shellCaptureRun (shell-capture.ts
|
|
260
|
+
// attemptCapture), re-using the foreground Request — a second request.start/
|
|
261
|
+
// cache.decision/request.end stamped with the same WeakMap-keyed requestId
|
|
262
|
+
// would double-count dashboards. Derived here (inside the capture's active
|
|
263
|
+
// request-context ALS) so the read sees the derived context, not module state.
|
|
264
|
+
const emitTelemetry =
|
|
265
|
+
hasTelemetry && !_getRequestContext()?._shellCaptureRun;
|
|
266
|
+
const requestId = emitTelemetry ? getRequestId(request) : undefined;
|
|
159
267
|
return runWithRouterLogContext({ request, transaction: "match" }, () => {
|
|
160
268
|
const routerCtx = buildRouterContext();
|
|
269
|
+
// Also mute in-pipeline observeEvent emitters (revalidation.decision,
|
|
270
|
+
// cache-lookup's cache.decision) which read routerCtx.telemetry.
|
|
271
|
+
if (!emitTelemetry) routerCtx.telemetry = undefined;
|
|
161
272
|
routerCtx.requestId = requestId;
|
|
162
273
|
return runWithRouterContext(routerCtx, async () =>
|
|
163
274
|
withRouterLogScope("match", async () => {
|
|
@@ -165,34 +276,22 @@ export function createMatchHandlers<TEnv = any>(
|
|
|
165
276
|
const pathname =
|
|
166
277
|
_getRequestContext()?.url?.pathname ??
|
|
167
278
|
new URL(request.url).pathname;
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
279
|
+
const emitter = createLifecycleEmitter({
|
|
280
|
+
enabled: emitTelemetry,
|
|
281
|
+
sink: telemetry,
|
|
282
|
+
requestId,
|
|
283
|
+
method: request.method,
|
|
284
|
+
pathname,
|
|
285
|
+
transaction: "match",
|
|
286
|
+
isPartial: false,
|
|
287
|
+
matchStart,
|
|
288
|
+
});
|
|
289
|
+
emitter.start();
|
|
179
290
|
|
|
180
291
|
const result = await createMatchContextForFull(request, env);
|
|
181
292
|
|
|
182
293
|
if ("type" in result && result.type === "redirect") {
|
|
183
|
-
|
|
184
|
-
safeEmit(telemetry, {
|
|
185
|
-
type: "request.end",
|
|
186
|
-
timestamp: performance.now(),
|
|
187
|
-
requestId,
|
|
188
|
-
method: request.method,
|
|
189
|
-
pathname,
|
|
190
|
-
transaction: "match",
|
|
191
|
-
durationMs: performance.now() - matchStart,
|
|
192
|
-
segmentCount: 0,
|
|
193
|
-
cacheHit: false,
|
|
194
|
-
});
|
|
195
|
-
}
|
|
294
|
+
emitter.end(0, false);
|
|
196
295
|
return {
|
|
197
296
|
segments: [],
|
|
198
297
|
matched: [],
|
|
@@ -212,51 +311,25 @@ export function createMatchHandlers<TEnv = any>(
|
|
|
212
311
|
if (hasTelemetry || cacheSignalEnabled) {
|
|
213
312
|
const signalSegments = buildSignal(ctx.routeKey, state);
|
|
214
313
|
recordSignalIfEnabled(signalSegments);
|
|
215
|
-
|
|
216
|
-
safeEmit(telemetry, {
|
|
217
|
-
type: "cache.decision",
|
|
218
|
-
timestamp: performance.now(),
|
|
219
|
-
requestId,
|
|
220
|
-
pathname,
|
|
221
|
-
routeKey: ctx.routeKey,
|
|
222
|
-
hit: state.cacheHit,
|
|
223
|
-
shouldRevalidate: !!state.shouldRevalidate,
|
|
224
|
-
source: state.cacheSource,
|
|
225
|
-
segments: signalSegments,
|
|
226
|
-
});
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
if (hasTelemetry) {
|
|
230
|
-
safeEmit(telemetry, {
|
|
231
|
-
type: "request.end",
|
|
232
|
-
timestamp: performance.now(),
|
|
233
|
-
requestId,
|
|
234
|
-
method: request.method,
|
|
235
|
-
pathname,
|
|
236
|
-
transaction: "match",
|
|
237
|
-
durationMs: performance.now() - matchStart,
|
|
238
|
-
segmentCount: matchResult.segments.length,
|
|
239
|
-
cacheHit: state.cacheHit,
|
|
240
|
-
});
|
|
314
|
+
emitter.cacheDecision(ctx.routeKey, state, signalSegments);
|
|
241
315
|
}
|
|
316
|
+
emitter.end(matchResult.segments.length, state.cacheHit);
|
|
242
317
|
return matchResult;
|
|
243
318
|
} catch (error) {
|
|
244
|
-
if (
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
transaction: "match",
|
|
254
|
-
error: errorObj,
|
|
255
|
-
phase: error instanceof Response ? "redirect" : "routing",
|
|
256
|
-
durationMs: performance.now() - matchStart,
|
|
257
|
-
});
|
|
319
|
+
if (error instanceof Response) {
|
|
320
|
+
// A thrown Response (middleware short-circuit — redirect / auth
|
|
321
|
+
// gate) is a COMPLETED request from the consumer's seat, not an
|
|
322
|
+
// error: emit request.end (the same shape the non-thrown redirect
|
|
323
|
+
// result above already emits), never request.error with a
|
|
324
|
+
// synthetic "[object Response]" error. Rethrow so the caller
|
|
325
|
+
// drives the redirect.
|
|
326
|
+
emitter.end(0, false);
|
|
327
|
+
throw error;
|
|
258
328
|
}
|
|
259
|
-
|
|
329
|
+
emitter.error(
|
|
330
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
331
|
+
"routing",
|
|
332
|
+
);
|
|
260
333
|
callOnError(error, "routing", {
|
|
261
334
|
request,
|
|
262
335
|
url: ctx.url,
|
|
@@ -296,11 +369,16 @@ export function createMatchHandlers<TEnv = any>(
|
|
|
296
369
|
context: TEnv,
|
|
297
370
|
actionContext?: ActionContext,
|
|
298
371
|
): Promise<MatchResult | null> {
|
|
299
|
-
|
|
372
|
+
// See match() above: the PPR shell capture re-runs matchPartial() under a
|
|
373
|
+
// _shellCaptureRun context and must stay invisible to the sink.
|
|
374
|
+
const emitTelemetry =
|
|
375
|
+
hasTelemetry && !_getRequestContext()?._shellCaptureRun;
|
|
376
|
+
const partialRequestId = emitTelemetry ? getRequestId(request) : undefined;
|
|
300
377
|
return runWithRouterLogContext(
|
|
301
378
|
{ request, transaction: "matchPartial" },
|
|
302
379
|
() => {
|
|
303
380
|
const routerCtx = buildRouterContext();
|
|
381
|
+
if (!emitTelemetry) routerCtx.telemetry = undefined;
|
|
304
382
|
routerCtx.requestId = partialRequestId;
|
|
305
383
|
return runWithRouterContext(routerCtx, async () =>
|
|
306
384
|
withRouterLogScope("matchPartial", async () => {
|
|
@@ -308,17 +386,17 @@ export function createMatchHandlers<TEnv = any>(
|
|
|
308
386
|
const pathname =
|
|
309
387
|
_getRequestContext()?.url?.pathname ??
|
|
310
388
|
new URL(request.url).pathname;
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
389
|
+
const emitter = createLifecycleEmitter({
|
|
390
|
+
enabled: emitTelemetry,
|
|
391
|
+
sink: telemetry,
|
|
392
|
+
requestId: partialRequestId,
|
|
393
|
+
method: request.method,
|
|
394
|
+
pathname,
|
|
395
|
+
transaction: "matchPartial",
|
|
396
|
+
isPartial: true,
|
|
397
|
+
matchStart,
|
|
398
|
+
});
|
|
399
|
+
emitter.start();
|
|
322
400
|
|
|
323
401
|
const ctx = await createMatchContextForPartial(
|
|
324
402
|
request,
|
|
@@ -326,19 +404,7 @@ export function createMatchHandlers<TEnv = any>(
|
|
|
326
404
|
actionContext,
|
|
327
405
|
);
|
|
328
406
|
if (!ctx) {
|
|
329
|
-
|
|
330
|
-
safeEmit(telemetry, {
|
|
331
|
-
type: "request.end",
|
|
332
|
-
timestamp: performance.now(),
|
|
333
|
-
requestId: partialRequestId,
|
|
334
|
-
method: request.method,
|
|
335
|
-
pathname,
|
|
336
|
-
transaction: "matchPartial",
|
|
337
|
-
durationMs: performance.now() - matchStart,
|
|
338
|
-
segmentCount: 0,
|
|
339
|
-
cacheHit: false,
|
|
340
|
-
});
|
|
341
|
-
}
|
|
407
|
+
emitter.end(0, false);
|
|
342
408
|
return null;
|
|
343
409
|
}
|
|
344
410
|
|
|
@@ -365,53 +431,24 @@ export function createMatchHandlers<TEnv = any>(
|
|
|
365
431
|
if (hasTelemetry || cacheSignalEnabled) {
|
|
366
432
|
const signalSegments = buildSignal(ctx.routeKey, state);
|
|
367
433
|
recordSignalIfEnabled(signalSegments);
|
|
368
|
-
|
|
369
|
-
safeEmit(telemetry, {
|
|
370
|
-
type: "cache.decision",
|
|
371
|
-
timestamp: performance.now(),
|
|
372
|
-
requestId: partialRequestId,
|
|
373
|
-
pathname,
|
|
374
|
-
routeKey: ctx.routeKey,
|
|
375
|
-
hit: state.cacheHit,
|
|
376
|
-
shouldRevalidate: !!state.shouldRevalidate,
|
|
377
|
-
source: state.cacheSource,
|
|
378
|
-
segments: signalSegments,
|
|
379
|
-
});
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
if (hasTelemetry) {
|
|
383
|
-
safeEmit(telemetry, {
|
|
384
|
-
type: "request.end",
|
|
385
|
-
timestamp: performance.now(),
|
|
386
|
-
requestId: partialRequestId,
|
|
387
|
-
method: request.method,
|
|
388
|
-
pathname,
|
|
389
|
-
transaction: "matchPartial",
|
|
390
|
-
durationMs: performance.now() - matchStart,
|
|
391
|
-
segmentCount: matchResult.segments.length,
|
|
392
|
-
cacheHit: state.cacheHit,
|
|
393
|
-
});
|
|
434
|
+
emitter.cacheDecision(ctx.routeKey, state, signalSegments);
|
|
394
435
|
}
|
|
436
|
+
emitter.end(matchResult.segments.length, state.cacheHit);
|
|
395
437
|
return matchResult;
|
|
396
438
|
} catch (error) {
|
|
397
439
|
flushRevalidationTrace();
|
|
398
|
-
if (
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
requestId: partialRequestId,
|
|
406
|
-
method: request.method,
|
|
407
|
-
pathname,
|
|
408
|
-
transaction: "matchPartial",
|
|
409
|
-
error: errorObj,
|
|
410
|
-
phase: error instanceof Response ? "redirect" : phase,
|
|
411
|
-
durationMs: performance.now() - matchStart,
|
|
412
|
-
});
|
|
440
|
+
if (error instanceof Response) {
|
|
441
|
+
// A thrown Response (middleware short-circuit — redirect / auth
|
|
442
|
+
// gate) is a COMPLETED request, not an error: emit request.end
|
|
443
|
+
// (parity with match()), never request.error. Rethrow so the
|
|
444
|
+
// caller drives the redirect.
|
|
445
|
+
emitter.end(0, false);
|
|
446
|
+
throw error;
|
|
413
447
|
}
|
|
414
|
-
|
|
448
|
+
emitter.error(
|
|
449
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
450
|
+
actionContext ? "action" : "revalidation",
|
|
451
|
+
);
|
|
415
452
|
callOnError(error, actionContext ? "action" : "revalidation", {
|
|
416
453
|
request,
|
|
417
454
|
url: ctx.url,
|
package/src/router/middleware.ts
CHANGED
|
@@ -550,22 +550,28 @@ export async function executeMiddleware<TEnv>(
|
|
|
550
550
|
// when neither surface is active.
|
|
551
551
|
let result: Response | void;
|
|
552
552
|
try {
|
|
553
|
-
result = await observePhase(PHASES.middleware(metricLabel), () =>
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
553
|
+
result = await observePhase(PHASES.middleware(metricLabel), async () => {
|
|
554
|
+
try {
|
|
555
|
+
return await entry.handler(ctx, wrappedNext);
|
|
556
|
+
} catch (error) {
|
|
557
|
+
// Thrown Response is short-circuit control flow, not an error —
|
|
558
|
+
// absorb it INSIDE the span so the tracing runner settles the
|
|
559
|
+
// rango.middleware span as success, not STATUS_ERROR (every auth
|
|
560
|
+
// redirect would otherwise inflate trace error rates). Returning it
|
|
561
|
+
// routes through the `if (result instanceof Response)` branch below,
|
|
562
|
+
// so stub headers and request-context cookies merge identically to an
|
|
563
|
+
// explicit `return new Response(...)`. Segment handlers already follow
|
|
564
|
+
// this convention (segment-resolution/helpers.ts keeps result handling
|
|
565
|
+
// outside the span). Real errors propagate past the span.
|
|
566
|
+
if (error instanceof Response) return error;
|
|
567
|
+
throw error;
|
|
568
|
+
}
|
|
569
|
+
});
|
|
570
|
+
} finally {
|
|
571
|
+
// Settle the middleware own-time metric once on both the success and
|
|
572
|
+
// error paths (idempotent guard in finishMiddleware).
|
|
573
|
+
finishMiddleware();
|
|
567
574
|
}
|
|
568
|
-
finishMiddleware();
|
|
569
575
|
|
|
570
576
|
// Record post-next() processing time when middleware did work after
|
|
571
577
|
// the downstream chain resolved (e.g. adding headers, logging).
|
|
@@ -725,20 +731,21 @@ export async function executeInterceptMiddleware<TEnv>(
|
|
|
725
731
|
ordinal,
|
|
726
732
|
);
|
|
727
733
|
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
734
|
+
const result: Response | void = await observePhase(
|
|
735
|
+
PHASES.middleware(label),
|
|
736
|
+
async () => {
|
|
737
|
+
try {
|
|
738
|
+
return await middleware(ctx, guardedNext);
|
|
739
|
+
} catch (error) {
|
|
740
|
+
// Thrown Response is short-circuit control flow, parity with the
|
|
741
|
+
// explicit-return path below. Absorb it INSIDE the span so the tracing
|
|
742
|
+
// runner settles rango.middleware as success, not STATUS_ERROR (same
|
|
743
|
+
// reasoning as executeMiddleware's main chain). Real errors propagate.
|
|
744
|
+
if (error instanceof Response) return error;
|
|
745
|
+
throw error;
|
|
746
|
+
}
|
|
747
|
+
},
|
|
748
|
+
);
|
|
742
749
|
|
|
743
750
|
if (result instanceof Response) {
|
|
744
751
|
earlyResponse = result;
|
|
@@ -16,6 +16,7 @@ import type { RangoOptions, RootLayoutProps } from "./router-options.js";
|
|
|
16
16
|
import type { DefaultVars } from "../types/global-namespace.js";
|
|
17
17
|
import type { ResolvedTimeouts, OnTimeoutCallback } from "./timeout.js";
|
|
18
18
|
import type { ResolvedTracing } from "./tracing.js";
|
|
19
|
+
import type { TelemetrySink } from "./telemetry.js";
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* Options passed to router.fetch(), router.match(), and other request entrypoints.
|
|
@@ -350,6 +351,14 @@ export interface RangoInternal<
|
|
|
350
351
|
*/
|
|
351
352
|
readonly tracing?: ResolvedTracing;
|
|
352
353
|
|
|
354
|
+
/**
|
|
355
|
+
* Raw telemetry sink from RangoOptions, exposed so handler-level emitters
|
|
356
|
+
* (rsc/handler.ts timeout/origin/late-handle) can emit WITHOUT the
|
|
357
|
+
* RouterContext ALS, which only match()/matchPartial() enter. See
|
|
358
|
+
* observeEvent's emitter list in router/instrument.ts.
|
|
359
|
+
*/
|
|
360
|
+
readonly telemetry?: TelemetrySink;
|
|
361
|
+
|
|
353
362
|
/**
|
|
354
363
|
* Whether ?__debug_manifest is allowed in production.
|
|
355
364
|
* Always enabled in development.
|
|
@@ -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));
|