@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
@@ -41,6 +41,109 @@ 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, status?: number): 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, status?: number): 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
+ // Only a thrown-Response short-circuit passes a status; a normal render
104
+ // completion omits it (the Response is built after match()).
105
+ ...(status !== undefined && { status }),
106
+ });
107
+ },
108
+ cacheDecision(
109
+ routeKey: string,
110
+ state: {
111
+ cacheHit: boolean;
112
+ cacheSource?: "runtime" | "prerender";
113
+ shouldRevalidate?: boolean;
114
+ },
115
+ segments: CacheSegmentSignal[],
116
+ ): void {
117
+ if (!args.enabled) return;
118
+ safeEmit(args.sink, {
119
+ type: "cache.decision",
120
+ timestamp: performance.now(),
121
+ requestId: args.requestId,
122
+ pathname: args.pathname,
123
+ routeKey,
124
+ hit: state.cacheHit,
125
+ shouldRevalidate: !!state.shouldRevalidate,
126
+ source: state.cacheSource,
127
+ segments,
128
+ });
129
+ },
130
+ error(error: Error, phase: string): void {
131
+ if (!args.enabled) return;
132
+ safeEmit(args.sink, {
133
+ type: "request.error",
134
+ timestamp: performance.now(),
135
+ requestId: args.requestId,
136
+ method: args.method,
137
+ pathname: args.pathname,
138
+ transaction: args.transaction,
139
+ error,
140
+ phase,
141
+ durationMs: performance.now() - args.matchStart,
142
+ });
143
+ },
144
+ };
145
+ }
146
+
44
147
  export interface MatchHandlerDeps<TEnv = any> {
45
148
  buildRouterContext: () => RouterContext<TEnv>;
46
149
  callOnError: (error: unknown, phase: ErrorPhase, context: any) => void;
@@ -155,9 +258,20 @@ export function createMatchHandlers<TEnv = any>(
155
258
  }
156
259
 
157
260
  async function match(request: Request, env: TEnv): Promise<MatchResult> {
158
- const requestId = hasTelemetry ? getRequestId(request) : undefined;
261
+ // Silence telemetry for the PPR background shell capture: it re-runs match()
262
+ // under a derived request context flagged _shellCaptureRun (shell-capture.ts
263
+ // attemptCapture), re-using the foreground Request — a second request.start/
264
+ // cache.decision/request.end stamped with the same WeakMap-keyed requestId
265
+ // would double-count dashboards. Derived here (inside the capture's active
266
+ // request-context ALS) so the read sees the derived context, not module state.
267
+ const emitTelemetry =
268
+ hasTelemetry && !_getRequestContext()?._shellCaptureRun;
269
+ const requestId = emitTelemetry ? getRequestId(request) : undefined;
159
270
  return runWithRouterLogContext({ request, transaction: "match" }, () => {
160
271
  const routerCtx = buildRouterContext();
272
+ // Also mute in-pipeline observeEvent emitters (revalidation.decision,
273
+ // cache-lookup's cache.decision) which read routerCtx.telemetry.
274
+ if (!emitTelemetry) routerCtx.telemetry = undefined;
161
275
  routerCtx.requestId = requestId;
162
276
  return runWithRouterContext(routerCtx, async () =>
163
277
  withRouterLogScope("match", async () => {
@@ -165,34 +279,22 @@ export function createMatchHandlers<TEnv = any>(
165
279
  const pathname =
166
280
  _getRequestContext()?.url?.pathname ??
167
281
  new URL(request.url).pathname;
168
- if (hasTelemetry) {
169
- safeEmit(telemetry, {
170
- type: "request.start",
171
- timestamp: matchStart,
172
- requestId,
173
- method: request.method,
174
- pathname,
175
- transaction: "match",
176
- isPartial: false,
177
- });
178
- }
282
+ const emitter = createLifecycleEmitter({
283
+ enabled: emitTelemetry,
284
+ sink: telemetry,
285
+ requestId,
286
+ method: request.method,
287
+ pathname,
288
+ transaction: "match",
289
+ isPartial: false,
290
+ matchStart,
291
+ });
292
+ emitter.start();
179
293
 
180
294
  const result = await createMatchContextForFull(request, env);
181
295
 
182
296
  if ("type" in result && result.type === "redirect") {
183
- if (hasTelemetry) {
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
- }
297
+ emitter.end(0, false);
196
298
  return {
197
299
  segments: [],
198
300
  matched: [],
@@ -212,51 +314,26 @@ export function createMatchHandlers<TEnv = any>(
212
314
  if (hasTelemetry || cacheSignalEnabled) {
213
315
  const signalSegments = buildSignal(ctx.routeKey, state);
214
316
  recordSignalIfEnabled(signalSegments);
215
- if (hasTelemetry) {
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
- });
317
+ emitter.cacheDecision(ctx.routeKey, state, signalSegments);
241
318
  }
319
+ emitter.end(matchResult.segments.length, state.cacheHit);
242
320
  return matchResult;
243
321
  } catch (error) {
244
- if (hasTelemetry) {
245
- const errorObj =
246
- error instanceof Error ? error : new Error(String(error));
247
- safeEmit(telemetry, {
248
- type: "request.error",
249
- timestamp: performance.now(),
250
- requestId,
251
- method: request.method,
252
- pathname,
253
- transaction: "match",
254
- error: errorObj,
255
- phase: error instanceof Response ? "redirect" : "routing",
256
- durationMs: performance.now() - matchStart,
257
- });
322
+ if (error instanceof Response) {
323
+ // A thrown Response (middleware short-circuit — redirect / auth
324
+ // gate) is a COMPLETED request from the consumer's seat, not an
325
+ // error: emit request.end (the same shape the non-thrown redirect
326
+ // result above already emits), never request.error with a
327
+ // synthetic "[object Response]" error. Rethrow so the caller
328
+ // drives the redirect. Carry the Response's status so a sink can
329
+ // tell a 3xx short-circuit from a 2xx completion.
330
+ emitter.end(0, false, error.status);
331
+ throw error;
258
332
  }
259
- if (error instanceof Response) throw error;
333
+ emitter.error(
334
+ error instanceof Error ? error : new Error(String(error)),
335
+ "routing",
336
+ );
260
337
  callOnError(error, "routing", {
261
338
  request,
262
339
  url: ctx.url,
@@ -296,11 +373,16 @@ export function createMatchHandlers<TEnv = any>(
296
373
  context: TEnv,
297
374
  actionContext?: ActionContext,
298
375
  ): Promise<MatchResult | null> {
299
- const partialRequestId = hasTelemetry ? getRequestId(request) : undefined;
376
+ // See match() above: the PPR shell capture re-runs matchPartial() under a
377
+ // _shellCaptureRun context and must stay invisible to the sink.
378
+ const emitTelemetry =
379
+ hasTelemetry && !_getRequestContext()?._shellCaptureRun;
380
+ const partialRequestId = emitTelemetry ? getRequestId(request) : undefined;
300
381
  return runWithRouterLogContext(
301
382
  { request, transaction: "matchPartial" },
302
383
  () => {
303
384
  const routerCtx = buildRouterContext();
385
+ if (!emitTelemetry) routerCtx.telemetry = undefined;
304
386
  routerCtx.requestId = partialRequestId;
305
387
  return runWithRouterContext(routerCtx, async () =>
306
388
  withRouterLogScope("matchPartial", async () => {
@@ -308,17 +390,17 @@ export function createMatchHandlers<TEnv = any>(
308
390
  const pathname =
309
391
  _getRequestContext()?.url?.pathname ??
310
392
  new URL(request.url).pathname;
311
- if (hasTelemetry) {
312
- safeEmit(telemetry, {
313
- type: "request.start",
314
- timestamp: matchStart,
315
- requestId: partialRequestId,
316
- method: request.method,
317
- pathname,
318
- transaction: "matchPartial",
319
- isPartial: true,
320
- });
321
- }
393
+ const emitter = createLifecycleEmitter({
394
+ enabled: emitTelemetry,
395
+ sink: telemetry,
396
+ requestId: partialRequestId,
397
+ method: request.method,
398
+ pathname,
399
+ transaction: "matchPartial",
400
+ isPartial: true,
401
+ matchStart,
402
+ });
403
+ emitter.start();
322
404
 
323
405
  const ctx = await createMatchContextForPartial(
324
406
  request,
@@ -326,19 +408,7 @@ export function createMatchHandlers<TEnv = any>(
326
408
  actionContext,
327
409
  );
328
410
  if (!ctx) {
329
- if (hasTelemetry) {
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
- }
411
+ emitter.end(0, false);
342
412
  return null;
343
413
  }
344
414
 
@@ -365,53 +435,25 @@ export function createMatchHandlers<TEnv = any>(
365
435
  if (hasTelemetry || cacheSignalEnabled) {
366
436
  const signalSegments = buildSignal(ctx.routeKey, state);
367
437
  recordSignalIfEnabled(signalSegments);
368
- if (hasTelemetry) {
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
- });
438
+ emitter.cacheDecision(ctx.routeKey, state, signalSegments);
394
439
  }
440
+ emitter.end(matchResult.segments.length, state.cacheHit);
395
441
  return matchResult;
396
442
  } catch (error) {
397
443
  flushRevalidationTrace();
398
- if (hasTelemetry) {
399
- const errorObj =
400
- error instanceof Error ? error : new Error(String(error));
401
- const phase = actionContext ? "action" : "revalidation";
402
- safeEmit(telemetry, {
403
- type: "request.error",
404
- timestamp: performance.now(),
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
- });
444
+ if (error instanceof Response) {
445
+ // A thrown Response (middleware short-circuit — redirect / auth
446
+ // gate) is a COMPLETED request, not an error: emit request.end
447
+ // (parity with match()), never request.error. Rethrow so the
448
+ // caller drives the redirect. Carry the Response's status so a
449
+ // sink can tell a 3xx short-circuit from a 2xx completion.
450
+ emitter.end(0, false, error.status);
451
+ throw error;
413
452
  }
414
- if (error instanceof Response) throw error;
453
+ emitter.error(
454
+ error instanceof Error ? error : new Error(String(error)),
455
+ actionContext ? "action" : "revalidation",
456
+ );
415
457
  callOnError(error, actionContext ? "action" : "revalidation", {
416
458
  request,
417
459
  url: ctx.url,
@@ -312,7 +312,10 @@ export function createMiddlewareContext<TEnv>(
312
312
  const reqCtx = _getRequestContext();
313
313
  if (reqCtx) {
314
314
  reqCtx._debugPerformance = true;
315
- reqCtx._metricsStore ??= createMetricsStore(true);
315
+ // Anchor to the true request entry (reqCtx._handlerStart) so phases that
316
+ // began before this opt-in report non-negative offsets; undefined falls
317
+ // back to performance.now() in createMetricsStore.
318
+ reqCtx._metricsStore ??= createMetricsStore(true, reqCtx._handlerStart);
316
319
  }
317
320
  },
318
321
  };
@@ -550,22 +553,28 @@ export async function executeMiddleware<TEnv>(
550
553
  // when neither surface is active.
551
554
  let result: Response | void;
552
555
  try {
553
- result = await observePhase(PHASES.middleware(metricLabel), () =>
554
- entry.handler(ctx, wrappedNext),
555
- );
556
- } catch (error) {
557
- // Thrown Response is short-circuit control flow, not an error.
558
- // Fall through to the `if (result instanceof Response)` branch below
559
- // so stub headers and request-context cookies merge as they do for
560
- // an explicit `return new Response(...)`. Real errors propagate.
561
- if (error instanceof Response) {
562
- result = error;
563
- } else {
564
- finishMiddleware();
565
- throw error;
566
- }
556
+ result = await observePhase(PHASES.middleware(metricLabel), async () => {
557
+ try {
558
+ return await entry.handler(ctx, wrappedNext);
559
+ } catch (error) {
560
+ // Thrown Response is short-circuit control flow, not an error
561
+ // absorb it INSIDE the span so the tracing runner settles the
562
+ // rango.middleware span as success, not STATUS_ERROR (every auth
563
+ // redirect would otherwise inflate trace error rates). Returning it
564
+ // routes through the `if (result instanceof Response)` branch below,
565
+ // so stub headers and request-context cookies merge identically to an
566
+ // explicit `return new Response(...)`. Segment handlers already follow
567
+ // this convention (segment-resolution/helpers.ts keeps result handling
568
+ // outside the span). Real errors propagate past the span.
569
+ if (error instanceof Response) return error;
570
+ throw error;
571
+ }
572
+ });
573
+ } finally {
574
+ // Settle the middleware own-time metric once on both the success and
575
+ // error paths (idempotent guard in finishMiddleware).
576
+ finishMiddleware();
567
577
  }
568
- finishMiddleware();
569
578
 
570
579
  // Record post-next() processing time when middleware did work after
571
580
  // the downstream chain resolved (e.g. adding headers, logging).
@@ -725,20 +734,21 @@ export async function executeInterceptMiddleware<TEnv>(
725
734
  ordinal,
726
735
  );
727
736
 
728
- let result: Response | void;
729
- try {
730
- result = await observePhase(PHASES.middleware(label), () =>
731
- middleware(ctx, guardedNext),
732
- );
733
- } catch (error) {
734
- // Thrown Response is short-circuit control flow, parity with the
735
- // explicit-return path below. Real errors propagate.
736
- if (error instanceof Response) {
737
- result = error;
738
- } else {
739
- throw error;
740
- }
741
- }
737
+ const result: Response | void = await observePhase(
738
+ PHASES.middleware(label),
739
+ async () => {
740
+ try {
741
+ return await middleware(ctx, guardedNext);
742
+ } catch (error) {
743
+ // Thrown Response is short-circuit control flow, parity with the
744
+ // explicit-return path below. Absorb it INSIDE the span so the tracing
745
+ // runner settles rango.middleware as success, not STATUS_ERROR (same
746
+ // reasoning as executeMiddleware's main chain). Real errors propagate.
747
+ if (error instanceof Response) return error;
748
+ throw error;
749
+ }
750
+ },
751
+ );
742
752
 
743
753
  if (result instanceof Response) {
744
754
  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.