@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
@@ -55,6 +55,14 @@
55
55
  * metadata.category, while the request still degrades-to-miss exactly as before.
56
56
  * (A thrown response-route HANDLER error is the one onError path NOT covered —
57
57
  * see "DOES NOT support" below.)
58
+ * - createRouter({ telemetry }) match-transaction lifecycle: request.start opens
59
+ * the transaction before the global middleware chain, request.end closes it
60
+ * after finalizeResponse (segmentCount 0 / cacheHit false — dispatch renders no
61
+ * RSC segments and holds no match-cache state), and a thrown non-Response error
62
+ * emits request.error with phase "routing". All three carry the same requestId
63
+ * (getRequestId). Emission is gated entirely on a configured sink; with none,
64
+ * dispatch does zero new work and stays byte-identical for existing callers.
65
+ * Lets a consumer unit-test their sink wiring in-process instead of only at e2e.
58
66
  *
59
67
  * What dispatch DOES NOT support (and why):
60
68
  * - RSC component routes — rendering requires the Flight serializer + React
@@ -76,6 +84,13 @@
76
84
  * merged cookies/headers all match production; only the Flight-embedded
77
85
  * location-state entries are absent. Cover location-state restoration across a
78
86
  * partial redirect with an e2e test.
87
+ * - Telemetry cache.decision / loader.* / handler.error events: these fire from
88
+ * the real match()/matchPartial() + RSC render + loader pipeline (match-
89
+ * handlers.ts, loader-resolution.ts, segment-resolution), none of which
90
+ * dispatch runs. Synthesizing them here would be faking (the events would not
91
+ * reflect a real cache lookup or loader run), so dispatch emits only the
92
+ * request.start/end/error lifecycle above; cover cache/loader telemetry with an
93
+ * e2e test driving a real RSC request.
79
94
  *
80
95
  * dispatch reuses router.previewMatch(), which itself runs content negotiation
81
96
  * and resolves route middleware from the matched entry tree, so dispatch's
@@ -127,6 +142,8 @@ import { isWebSocketUpgradeResponse } from "../response-utils.js";
127
142
  import { invokeOnError } from "../router/error-handling.js";
128
143
  import type { OnErrorCallback } from "../types/error-types.js";
129
144
  import type { Rango } from "../router/router-interfaces.js";
145
+ import { getRequestId, resolveSink, safeEmit } from "../router/telemetry.js";
146
+ import type { TelemetrySink } from "../router/telemetry.js";
130
147
 
131
148
  /**
132
149
  * The internal subset of the router surface dispatch depends on. The public
@@ -140,6 +157,13 @@ interface DispatchableRouter<TEnv> {
140
157
  routeMap: Record<string, unknown>;
141
158
  middleware: MiddlewareEntry<TEnv>[];
142
159
  onError?: OnErrorCallback<TEnv>;
160
+ /**
161
+ * Optional telemetry sink from createRouter({ telemetry }) (RangoInternal
162
+ * field). dispatch emits the match-transaction lifecycle events onto it so a
163
+ * consumer can unit-test their sink wiring in-process; undefined keeps dispatch
164
+ * byte-identical for every existing caller.
165
+ */
166
+ telemetry?: TelemetrySink;
143
167
  findMatch(pathname: string): Promise<{
144
168
  redirectTo?: string;
145
169
  routeKey?: string;
@@ -444,6 +468,19 @@ export async function dispatch<TEnv = any>(
444
468
  const isPartial = url.searchParams.has("_rsc_partial");
445
469
  const isAction = url.searchParams.has("_rsc_action");
446
470
 
471
+ // Telemetry: mirror the router's match-transaction lifecycle onto the
472
+ // configured sink so a consumer can unit-test createRouter({ telemetry })
473
+ // wiring in-process (the dogfood gap the RSC-free dispatch left — see
474
+ // tests/cloudflare-basic/test/cache-status.test.ts). Every emit is gated on
475
+ // `sink` truthiness: with no sink configured dispatch does zero new work and
476
+ // stays byte-identical for existing callers. Only request.start/end/error are
477
+ // reachable here — cache.decision and loader.* originate in the real match()/
478
+ // matchPartial() + RSC render pipeline dispatch deliberately does not run
479
+ // (module header), so fabricating them would violate the no-fake rule.
480
+ const sink = router.telemetry;
481
+ const telemetryRequestId = sink ? getRequestId(req) : undefined;
482
+ const telemetryStart = sink ? performance.now() : 0;
483
+
447
484
  return runWithRequestContext(requestContext, async () => {
448
485
  // Set params before middleware/handler run, so global middleware sees
449
486
  // ctx.params (production sets them during matching, before middleware).
@@ -658,44 +695,119 @@ export async function dispatch<TEnv = any>(
658
695
  return callResponseRoute();
659
696
  };
660
697
 
661
- // Global (pattern-matched) middleware wraps coreHandler, exactly as
662
- // production wraps coreHandler with executeMiddleware (handler.ts).
663
- const globalMatches = matchMiddleware(url.pathname, router.middleware);
664
- const mwResponse =
665
- globalMatches.length === 0
666
- ? await coreHandler()
667
- : await executeMiddleware<TEnv>(
668
- globalMatches,
669
- req,
670
- env,
671
- variables,
672
- coreHandler,
673
- reverse,
674
- );
675
-
676
- // Match production's global-chain exit (handler.ts): on a partial/action
677
- // request a middleware 3xx redirect is converted to a Flight-safe response
678
- // so fetch() does not auto-follow it; every path then drains onResponse
679
- // callbacks via finalizeResponse. dispatch is RSC-free, so the
680
- // createRedirectFlightResponse stand-in falls back to the no-state
681
- // 204 + X-RSC-Redirect (see the location-state divergence in the header).
682
- let finalResponse: Response;
683
- if (isPartial || isAction) {
684
- const intercepted = interceptRedirectForPartial(
685
- mwResponse,
686
- (redirectUrl) => createSimpleRedirectResponse(redirectUrl),
687
- );
688
- finalResponse = finalizeResponse(intercepted ?? mwResponse);
689
- } else {
690
- finalResponse = finalizeResponse(mwResponse);
698
+ // request.start opens the match transaction, mirroring match-handlers.ts.
699
+ // transaction is always "match" (dispatch has no matchPartial split);
700
+ // isPartial carries the ?_rsc_partial signal the same way production does.
701
+ if (sink) {
702
+ safeEmit(resolveSink(sink), {
703
+ type: "request.start",
704
+ timestamp: telemetryStart,
705
+ requestId: telemetryRequestId,
706
+ method: req.method,
707
+ pathname: url.pathname,
708
+ transaction: "match",
709
+ isPartial,
710
+ });
691
711
  }
692
712
 
693
- // Mirror production's single open-redirect chokepoint (handler.ts): every
694
- // browser-followed (3xx + Location) redirect is same-origin guarded before
695
- // it leaves -- a cross-origin Location is rewritten to the basename root
696
- // unless redirect(url, { external: true }) opted out. Soft partial/action
697
- // redirects are 204 + X-RSC-Redirect and pass through untouched (the client
698
- // validates them), so this is a no-op for them.
699
- return guardOutgoingRedirect(finalResponse, url.origin, router.basename);
713
+ try {
714
+ // Global (pattern-matched) middleware wraps coreHandler, exactly as
715
+ // production wraps coreHandler with executeMiddleware (handler.ts).
716
+ const globalMatches = matchMiddleware(url.pathname, router.middleware);
717
+ const mwResponse =
718
+ globalMatches.length === 0
719
+ ? await coreHandler()
720
+ : await executeMiddleware<TEnv>(
721
+ globalMatches,
722
+ req,
723
+ env,
724
+ variables,
725
+ coreHandler,
726
+ reverse,
727
+ );
728
+
729
+ // Match production's global-chain exit (handler.ts): on a partial/action
730
+ // request a middleware 3xx redirect is converted to a Flight-safe response
731
+ // so fetch() does not auto-follow it; every path then drains onResponse
732
+ // callbacks via finalizeResponse. dispatch is RSC-free, so the
733
+ // createRedirectFlightResponse stand-in falls back to the no-state
734
+ // 204 + X-RSC-Redirect (see the location-state divergence in the header).
735
+ let finalResponse: Response;
736
+ if (isPartial || isAction) {
737
+ const intercepted = interceptRedirectForPartial(
738
+ mwResponse,
739
+ (redirectUrl) => createSimpleRedirectResponse(redirectUrl),
740
+ );
741
+ finalResponse = finalizeResponse(intercepted ?? mwResponse);
742
+ } else {
743
+ finalResponse = finalizeResponse(mwResponse);
744
+ }
745
+
746
+ // request.end closes the transaction. dispatch produces no RSC segments and
747
+ // holds no match-cache state, so segmentCount/cacheHit are 0/false — the
748
+ // same shape production emits for its own redirect (segment-less) result.
749
+ if (sink) {
750
+ safeEmit(resolveSink(sink), {
751
+ type: "request.end",
752
+ timestamp: performance.now(),
753
+ requestId: telemetryRequestId,
754
+ method: req.method,
755
+ pathname: url.pathname,
756
+ transaction: "match",
757
+ durationMs: performance.now() - telemetryStart,
758
+ segmentCount: 0,
759
+ cacheHit: false,
760
+ // dispatch's final response IS built before request.end (unlike
761
+ // match()/matchPartial(), whose Response is built after), so stamp its
762
+ // status — the same field a thrown-Response short-circuit carries.
763
+ status: finalResponse.status,
764
+ });
765
+ }
766
+
767
+ // Mirror production's single open-redirect chokepoint (handler.ts): every
768
+ // browser-followed (3xx + Location) redirect is same-origin guarded before
769
+ // it leaves -- a cross-origin Location is rewritten to the basename root
770
+ // unless redirect(url, { external: true }) opted out. Soft partial/action
771
+ // redirects are 204 + X-RSC-Redirect and pass through untouched (the client
772
+ // validates them), so this is a no-op for them.
773
+ return guardOutgoingRedirect(finalResponse, url.origin, router.basename);
774
+ } catch (error) {
775
+ if (sink) {
776
+ if (error instanceof Response) {
777
+ // executeMiddleware absorbs a middleware-thrown Response and returns it
778
+ // (middleware.ts:566), so a thrown Response never actually reaches this
779
+ // level in dispatch. Defensive: if one ever does it is a completed
780
+ // request from the consumer's seat (a short-circuit redirect), so mirror
781
+ // plan 002 / match-handlers.ts and emit request.end, not request.error.
782
+ safeEmit(resolveSink(sink), {
783
+ type: "request.end",
784
+ timestamp: performance.now(),
785
+ requestId: telemetryRequestId,
786
+ method: req.method,
787
+ pathname: url.pathname,
788
+ transaction: "match",
789
+ durationMs: performance.now() - telemetryStart,
790
+ segmentCount: 0,
791
+ cacheHit: false,
792
+ // Carry the short-circuit Response's status (parity with
793
+ // match-handlers.ts's thrown-Response request.end).
794
+ status: error.status,
795
+ });
796
+ } else {
797
+ safeEmit(resolveSink(sink), {
798
+ type: "request.error",
799
+ timestamp: performance.now(),
800
+ requestId: telemetryRequestId,
801
+ method: req.method,
802
+ pathname: url.pathname,
803
+ transaction: "match",
804
+ error: error instanceof Error ? error : new Error(String(error)),
805
+ phase: "routing",
806
+ durationMs: performance.now() - telemetryStart,
807
+ });
808
+ }
809
+ }
810
+ throw error;
811
+ }
700
812
  });
701
813
  }
@@ -268,9 +268,16 @@ export type PathHelpers<TEnv> = {
268
268
  * `{ handler, use? }` whose `use` is scoped to that slot only. Per-slot
269
269
  * merge order is `handler.use` → shared `use` → slot-local `use`, with
270
270
  * narrowest scope winning for last-write-wins items like `loading()`.
271
+ *
272
+ * Not generic over the slots record: an inferred type parameter makes the
273
+ * object literal an inference site, which suppresses contextual typing of
274
+ * arrow slot handlers (`(ctx) => ...` was implicit any). Bare handlers infer
275
+ * now; a descriptor's `handler:` arrow still needs an explicit ctx annotation
276
+ * because StaticHandlerDefinition's own `.handler` joins the contextual union
277
+ * (two callables — see parallel-slot-handler-types.test.ts).
271
278
  */
272
- parallel: <
273
- TSlots extends Record<
279
+ parallel: (
280
+ slots: Record<
274
281
  `@${string}`,
275
282
  | Handler<any, any, TEnv>
276
283
  | ReactNode
@@ -283,8 +290,6 @@ export type PathHelpers<TEnv> = {
283
290
  use?: () => ParallelUseItem[];
284
291
  }
285
292
  >,
286
- >(
287
- slots: TSlots,
288
293
  use?: () => ParallelUseItem[],
289
294
  ) => ParallelItem;
290
295
 
@@ -44,15 +44,15 @@ import {
44
44
  } from "../router/telemetry-otel.js";
45
45
  import type {
46
46
  RouterTracingConfig,
47
- TracePhaseToggles,
47
+ TracingToggleOptions,
48
48
  } from "../router/tracing.js";
49
49
 
50
- /** Options for createVercelTracing. */
51
- export interface VercelTracingOptions {
52
- /** Master switch. Defaults to true. */
53
- enabled?: boolean;
54
- /** Per-phase span toggles. Omitted phases default to enabled. */
55
- spans?: TracePhaseToggles;
50
+ /**
51
+ * Options for createVercelTracing. Extends the shared
52
+ * {@link TracingToggleOptions} (`enabled` + per-phase `spans`) with the
53
+ * Vercel-specific tracer selectors.
54
+ */
55
+ export interface VercelTracingOptions extends TracingToggleOptions {
56
56
  /**
57
57
  * OTel instrumentation-scope name passed to `trace.getTracer()`. Defaults to
58
58
  * `"rango"`. Ignored when `tracer` is provided.
@@ -1,3 +1,5 @@
1
+ import type { Connect } from "vite";
2
+
1
3
  /**
2
4
  * Bake the resolved INTERNAL_RANGO_DEBUG value into the router's `internal-debug`
3
5
  * module so the flag reaches the CLIENT debug logs by just setting the env var.
@@ -10,27 +12,77 @@
10
12
  * runs on the module regardless of how (or whether) the define is delivered, in
11
13
  * both dev and build and for every environment, so the discovery plugin uses this
12
14
  * to replace the module with the resolved literal.
13
- *
15
+ */
16
+
17
+ /**
18
+ * Scope to the router's own internal-debug module: the published package
19
+ * (`/@rangojs/router/`, incl. pnpm's nested layout) or the monorepo workspace
20
+ * (`/packages/rangojs-router/`). The package-anchored path avoids matching a
21
+ * consumer file that merely sits under a directory named `rangojs-router`.
22
+ * Accepts module ids and dev-server URLs (`/@fs/...internal-debug.ts?v=abc`).
23
+ */
24
+ export function isRouterInternalDebugId(id: string): boolean {
25
+ if (!id.includes("internal-debug")) return false;
26
+ const norm = id.replace(/\\/g, "/");
27
+ return (
28
+ /\/internal-debug\.[cm]?[jt]sx?(\?|$)/.test(norm) &&
29
+ (norm.includes("/@rangojs/router/") ||
30
+ norm.includes("/packages/rangojs-router/"))
31
+ );
32
+ }
33
+
34
+ /**
35
+ * Transform: replace the module with the resolved literal.
14
36
  * Returns null for any module that is not the router's internal-debug module.
15
37
  */
16
38
  export function injectClientDebugFlag(
17
39
  id: string,
18
40
  ): { code: string; map: null } | null {
19
41
  // Cheap early-out: this hook runs on every module in every environment.
20
- if (!id.includes("internal-debug")) return null;
21
- const norm = id.replace(/\\/g, "/");
22
- // Scope to the router's own internal-debug module: the published package
23
- // (`/@rangojs/router/`, incl. pnpm's nested layout) or the monorepo workspace
24
- // (`/packages/rangojs-router/`). The package-anchored path avoids matching a
25
- // consumer file that merely sits under a directory named `rangojs-router`.
26
- const isInternalDebug =
27
- /\/internal-debug\.[cm]?[jt]sx?(\?|$)/.test(norm) &&
28
- (norm.includes("/@rangojs/router/") ||
29
- norm.includes("/packages/rangojs-router/"));
30
- if (!isInternalDebug) return null;
42
+ if (!isRouterInternalDebugId(id)) return null;
31
43
  // Emit the whole module: internal-debug.ts has a single export, kept in sync.
32
44
  return {
33
45
  code: `export const INTERNAL_RANGO_DEBUG = ${!!process.env.INTERNAL_RANGO_DEBUG};\n`,
34
46
  map: null,
35
47
  };
36
48
  }
49
+
50
+ /**
51
+ * Dev middleware companion: serve the internal-debug module `no-cache` so the
52
+ * browser revalidates it (etag) instead of trusting an immutable cache entry.
53
+ *
54
+ * Why: the transform bakes the flag into module CONTENT, but a published
55
+ * consumer resolves the module into node_modules, where dev serves it as
56
+ * `internal-debug.ts?v=<hash>` with `Cache-Control: max-age=31536000,immutable`.
57
+ * That `?v=` hash does not vary with env vars (verified on Vite 8: getConfigHash
58
+ * hashes NODE_ENV, resolve, plugin names, optimizeDeps -- not arbitrary env
59
+ * state), so toggling the flag changed the content under an unchanged immutable
60
+ * URL: a browser that ever loaded the app with the flag off kept the
61
+ * baked-`false` module across dev-server restarts, and INTERNAL_RANGO_DEBUG
62
+ * never reached the FE logs while the server logs worked. The monorepo was
63
+ * immune -- workspace source is outside node_modules and served no-cache --
64
+ * which is why this bit only npm consumers.
65
+ *
66
+ * internal-debug.ts is the ONLY flag-varying module in the graph (its importers
67
+ * are byte-identical across flag states), so forcing revalidation for this one
68
+ * tiny module is sufficient and costs one conditional request per session. The
69
+ * middleware is unconditional (not gated on the flag) so an already-poisoned
70
+ * cache heals in both directions. Alternatives that do not work: a plugin
71
+ * `resolveId` appending a flag query never fires in Vite 8 dev for
72
+ * fs-resolvable relative imports, and the pre-#621 `define` no longer rotates
73
+ * the optimizer hash (define contents are not part of getConfigHash).
74
+ */
75
+ export function internalDebugNoCacheMiddleware(): Connect.NextHandleFunction {
76
+ return function rangoInternalDebugNoCache(req, res, next) {
77
+ if (req.url && isRouterInternalDebugId(req.url)) {
78
+ const setHeader = res.setHeader.bind(res);
79
+ res.setHeader = (name, value) => {
80
+ return setHeader(
81
+ name,
82
+ name.toLowerCase() === "cache-control" ? "no-cache" : value,
83
+ );
84
+ };
85
+ }
86
+ next();
87
+ };
88
+ }
@@ -19,7 +19,10 @@ import {
19
19
  createScanFilter,
20
20
  } from "../build/generate-route-types.js";
21
21
  import { firstCodeMatchIndex } from "../build/route-types/source-scan.js";
22
- import { injectClientDebugFlag } from "./inject-client-debug.js";
22
+ import {
23
+ injectClientDebugFlag,
24
+ internalDebugNoCacheMiddleware,
25
+ } from "./inject-client-debug.js";
23
26
  import { createVersionPlugin } from "./plugins/version-plugin.js";
24
27
  import { createVirtualStubPlugin } from "./plugins/virtual-stub-plugin.js";
25
28
  import {
@@ -392,6 +395,11 @@ export function createRouterDiscoveryPlugin(
392
395
  if ((globalThis as any).__rscRouterDiscoveryActive) return;
393
396
  s.devServer = server;
394
397
 
398
+ // Serve the internal-debug module no-cache: consumers resolve it into
399
+ // node_modules, where dev's immutable `?v=` caching pinned browsers to a
400
+ // stale baked INTERNAL_RANGO_DEBUG. See internalDebugNoCacheMiddleware.
401
+ server.middlewares.use(internalDebugNoCacheMiddleware());
402
+
395
403
  // Discovery promise that the handler can await if requests arrive
396
404
  // before discovery completes
397
405
  let resolveDiscovery: () => void;