@rangojs/router 0.0.0-experimental.e9c0b2f2 → 0.0.0-experimental.ea9f40f2

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 (222) hide show
  1. package/AGENTS.md +6 -10
  2. package/README.md +289 -938
  3. package/dist/bin/rango.js +271 -46
  4. package/dist/vite/index.js +673 -193
  5. package/package.json +10 -8
  6. package/skills/api-client/SKILL.md +1 -1
  7. package/skills/breadcrumbs/SKILL.md +31 -14
  8. package/skills/cache-guide/SKILL.md +5 -2
  9. package/skills/caching/SKILL.md +59 -4
  10. package/skills/catalog.json +271 -0
  11. package/skills/comparison/SKILL.md +50 -0
  12. package/skills/comparison/agents/openai.yaml +4 -0
  13. package/skills/comparison/references/framework-comparison.md +837 -0
  14. package/skills/composability/SKILL.md +83 -2
  15. package/skills/debug-manifest/SKILL.md +1 -1
  16. package/skills/defer-hydration/SKILL.md +235 -0
  17. package/skills/document-cache/SKILL.md +9 -1
  18. package/skills/fonts/SKILL.md +1 -1
  19. package/skills/handler-use/SKILL.md +8 -8
  20. package/skills/hooks/SKILL.md +54 -892
  21. package/skills/hooks/data.md +273 -0
  22. package/skills/hooks/handle-and-actions.md +103 -0
  23. package/skills/hooks/navigation.md +110 -0
  24. package/skills/hooks/outlets.md +41 -0
  25. package/skills/hooks/state.md +228 -0
  26. package/skills/hooks/urls.md +135 -0
  27. package/skills/host-router/SKILL.md +4 -4
  28. package/skills/i18n/SKILL.md +1 -1
  29. package/skills/intercept/SKILL.md +46 -14
  30. package/skills/layout/SKILL.md +27 -10
  31. package/skills/links/SKILL.md +1 -1
  32. package/skills/loader/SKILL.md +23 -1
  33. package/skills/middleware/SKILL.md +7 -3
  34. package/skills/migrate-nextjs/SKILL.md +167 -6
  35. package/skills/migrate-react-router/SKILL.md +59 -677
  36. package/skills/migrate-react-router/cloudflare-workers.md +129 -0
  37. package/skills/migrate-react-router/component-migration.md +196 -0
  38. package/skills/migrate-react-router/data-and-actions.md +225 -0
  39. package/skills/migrate-react-router/route-mapping.md +271 -0
  40. package/skills/mime-routes/SKILL.md +1 -1
  41. package/skills/observability/SKILL.md +9 -1
  42. package/skills/parallel/SKILL.md +23 -4
  43. package/skills/ppr/SKILL.md +622 -0
  44. package/skills/prerender/SKILL.md +28 -18
  45. package/skills/rango/SKILL.md +84 -25
  46. package/skills/response-routes/SKILL.md +15 -1
  47. package/skills/route/SKILL.md +71 -4
  48. package/skills/router-setup/SKILL.md +14 -3
  49. package/skills/scripts/SKILL.md +1 -1
  50. package/skills/server-actions/SKILL.md +3 -2
  51. package/skills/shell-manifest/SKILL.md +185 -0
  52. package/skills/streams-and-websockets/SKILL.md +1 -1
  53. package/skills/tailwind/SKILL.md +1 -1
  54. package/skills/testing/SKILL.md +2 -1
  55. package/skills/testing/handles.md +4 -2
  56. package/skills/testing/render-handler.md +15 -14
  57. package/skills/testing/reverse-and-types.md +8 -7
  58. package/skills/theme/SKILL.md +1 -1
  59. package/skills/typesafety/SKILL.md +45 -919
  60. package/skills/typesafety/env-and-bindings.md +254 -0
  61. package/skills/typesafety/generated-files-and-cli.md +335 -0
  62. package/skills/typesafety/params-and-search.md +153 -0
  63. package/skills/typesafety/route-types.md +209 -0
  64. package/skills/use-cache/SKILL.md +30 -3
  65. package/skills/vercel/SKILL.md +1 -1
  66. package/skills/view-transitions/SKILL.md +44 -1
  67. package/src/browser/event-controller.ts +62 -10
  68. package/src/browser/logging.ts +28 -0
  69. package/src/browser/merge-segment-loaders.ts +6 -4
  70. package/src/browser/navigation-bridge.ts +65 -16
  71. package/src/browser/navigation-client.ts +32 -2
  72. package/src/browser/navigation-store.ts +128 -14
  73. package/src/browser/network-error-handler.ts +34 -7
  74. package/src/browser/partial-update.ts +76 -17
  75. package/src/browser/prefetch/cache.ts +51 -11
  76. package/src/browser/prefetch/fetch.ts +59 -21
  77. package/src/browser/prefetch/queue.ts +19 -4
  78. package/src/browser/react/Link.tsx +13 -3
  79. package/src/browser/react/NavigationProvider.tsx +108 -4
  80. package/src/browser/response-adapter.ts +38 -9
  81. package/src/browser/rsc-router.tsx +54 -4
  82. package/src/browser/scroll-restoration.ts +7 -5
  83. package/src/browser/segment-reconciler.ts +31 -21
  84. package/src/browser/server-action-bridge.ts +22 -10
  85. package/src/browser/types.ts +54 -1
  86. package/src/build/generate-manifest.ts +155 -131
  87. package/src/build/index.ts +3 -1
  88. package/src/build/route-trie.ts +35 -7
  89. package/src/build/route-types/include-resolution.ts +347 -47
  90. package/src/build/runtime-discovery.ts +4 -1
  91. package/src/cache/cache-key-utils.ts +29 -0
  92. package/src/cache/cache-runtime.ts +262 -71
  93. package/src/cache/cache-scope.ts +2 -17
  94. package/src/cache/cache-tag.ts +60 -14
  95. package/src/cache/cf/cf-cache-store.ts +243 -20
  96. package/src/cache/document-cache.ts +54 -21
  97. package/src/cache/index.ts +1 -0
  98. package/src/cache/memory-segment-store.ts +110 -3
  99. package/src/cache/profile-registry.ts +15 -0
  100. package/src/cache/read-through-swr.ts +15 -1
  101. package/src/cache/segment-codec.ts +4 -4
  102. package/src/cache/shell-snapshot.ts +417 -0
  103. package/src/cache/types.ts +158 -0
  104. package/src/cache/vercel/vercel-cache-store.ts +401 -124
  105. package/src/client.rsc.tsx +0 -3
  106. package/src/client.tsx +0 -3
  107. package/src/cloudflare/tracing.ts +7 -8
  108. package/src/defer.ts +11 -22
  109. package/src/handle.ts +37 -15
  110. package/src/handles/MetaTags.tsx +16 -82
  111. package/src/handles/breadcrumbs.ts +12 -14
  112. package/src/handles/deferred-resolution.ts +127 -0
  113. package/src/handles/is-thenable.ts +7 -8
  114. package/src/handles/meta.ts +7 -44
  115. package/src/host/errors.ts +15 -0
  116. package/src/host/index.ts +1 -0
  117. package/src/index.rsc.ts +8 -2
  118. package/src/index.ts +19 -13
  119. package/src/internal-debug.ts +11 -8
  120. package/src/prerender.ts +17 -4
  121. package/src/redirect-origin.ts +14 -0
  122. package/src/render-error-thrower.tsx +20 -0
  123. package/src/route-content-wrapper.tsx +12 -5
  124. package/src/route-definition/dsl-helpers.ts +21 -32
  125. package/src/route-definition/helper-factories.ts +0 -2
  126. package/src/route-definition/helpers-types.ts +43 -43
  127. package/src/route-definition/index.ts +1 -2
  128. package/src/route-definition/resolve-handler-use.ts +0 -1
  129. package/src/route-definition/use-item-types.ts +3 -6
  130. package/src/route-map-builder.ts +41 -4
  131. package/src/route-types.ts +0 -5
  132. package/src/router/find-match.ts +86 -8
  133. package/src/router/instrument.ts +9 -4
  134. package/src/router/lazy-includes.ts +72 -12
  135. package/src/router/loader-resolution.ts +14 -2
  136. package/src/router/manifest.ts +56 -11
  137. package/src/router/match-api.ts +76 -32
  138. package/src/router/match-handlers.ts +181 -135
  139. package/src/router/match-middleware/background-revalidation.ts +40 -23
  140. package/src/router/match-middleware/cache-store.ts +39 -24
  141. package/src/router/match-result.ts +35 -15
  142. package/src/router/middleware.ts +64 -38
  143. package/src/router/navigation-snapshot.ts +7 -5
  144. package/src/router/parse-pattern.ts +115 -0
  145. package/src/router/pattern-matching.ts +53 -64
  146. package/src/router/prefetch-limits.ts +37 -0
  147. package/src/router/prerender-match.ts +11 -5
  148. package/src/router/preview-match.ts +3 -1
  149. package/src/router/request-classification.ts +23 -8
  150. package/src/router/route-snapshot.ts +14 -2
  151. package/src/router/router-context.ts +3 -1
  152. package/src/router/router-interfaces.ts +32 -1
  153. package/src/router/router-options.ts +30 -0
  154. package/src/router/segment-resolution/fresh.ts +39 -3
  155. package/src/router/segment-resolution/loader-cache.ts +93 -2
  156. package/src/router/segment-resolution/loader-mask.ts +60 -0
  157. package/src/router/segment-resolution/loader-snapshot.ts +259 -0
  158. package/src/router/segment-resolution/mask-nested.ts +83 -0
  159. package/src/router/segment-resolution/revalidation.ts +3 -0
  160. package/src/router/segment-resolution/view-transition-default.ts +35 -15
  161. package/src/router/substitute-pattern-params.ts +54 -35
  162. package/src/router/telemetry-otel.ts +6 -8
  163. package/src/router/telemetry.ts +9 -1
  164. package/src/router/tracing.ts +14 -5
  165. package/src/router/trie-matching.ts +19 -11
  166. package/src/router/url-params.ts +13 -0
  167. package/src/router.ts +47 -16
  168. package/src/rsc/full-payload.ts +70 -0
  169. package/src/rsc/handler.ts +60 -33
  170. package/src/rsc/manifest-init.ts +1 -1
  171. package/src/rsc/nonce.ts +10 -1
  172. package/src/rsc/progressive-enhancement.ts +61 -4
  173. package/src/rsc/redirect-guard.ts +2 -1
  174. package/src/rsc/rsc-rendering.ts +429 -37
  175. package/src/rsc/server-action.ts +25 -2
  176. package/src/rsc/shell-capture.ts +1190 -0
  177. package/src/rsc/shell-serve.ts +181 -0
  178. package/src/rsc/transition-gate.ts +89 -0
  179. package/src/rsc/types.ts +30 -0
  180. package/src/segment-loader-promise.ts +18 -0
  181. package/src/segment-system.tsx +149 -14
  182. package/src/server/context.ts +67 -9
  183. package/src/server/cookie-store.ts +73 -1
  184. package/src/server/loader-registry.ts +13 -1
  185. package/src/server/request-context.ts +169 -10
  186. package/src/ssr/index.tsx +462 -178
  187. package/src/ssr/inject-rsc-eager.ts +167 -0
  188. package/src/ssr/ssr-root.tsx +228 -0
  189. package/src/testing/collect-handle.ts +14 -8
  190. package/src/testing/dispatch.ts +152 -40
  191. package/src/testing/generated-routes.ts +27 -11
  192. package/src/testing/index.ts +6 -0
  193. package/src/testing/render-handler.ts +14 -0
  194. package/src/testing/render-route.tsx +13 -10
  195. package/src/testing/run-transition-when.ts +164 -0
  196. package/src/theme/ThemeProvider.tsx +36 -26
  197. package/src/types/handler-context.ts +1 -1
  198. package/src/types/index.ts +2 -0
  199. package/src/types/route-config.ts +19 -7
  200. package/src/types/segments.ts +100 -0
  201. package/src/urls/include-helper.ts +10 -8
  202. package/src/urls/include-provider.ts +71 -0
  203. package/src/urls/index.ts +1 -0
  204. package/src/urls/path-helper-types.ts +44 -12
  205. package/src/urls/path-helper.ts +5 -0
  206. package/src/urls/pattern-types.ts +36 -0
  207. package/src/urls/type-extraction.ts +43 -18
  208. package/src/urls/urls-function.ts +0 -1
  209. package/src/vercel/tracing.ts +7 -7
  210. package/src/vite/discovery/dev-prerender-cache.ts +117 -0
  211. package/src/vite/discovery/discover-routers.ts +1 -1
  212. package/src/vite/discovery/discovery-errors.ts +61 -0
  213. package/src/vite/index.ts +7 -0
  214. package/src/vite/inject-client-debug.ts +88 -0
  215. package/src/vite/plugins/vercel-output.ts +114 -25
  216. package/src/vite/plugins/version-injector.ts +22 -7
  217. package/src/vite/plugins/virtual-entries.ts +80 -22
  218. package/src/vite/rango.ts +29 -19
  219. package/src/vite/router-discovery.ts +171 -43
  220. package/src/vite/utils/prerender-utils.ts +17 -4
  221. package/src/vite/utils/shared-utils.ts +47 -0
  222. package/src/network-error-thrower.tsx +0 -18
@@ -0,0 +1,167 @@
1
+ import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
2
+
3
+ /**
4
+ * Eager Flight-payload injector for the PPR resume path.
5
+ *
6
+ * rsc-html-stream's injectRSCPayload starts forwarding Flight chunks only from
7
+ * inside its first transform() callback — i.e. AFTER the first HTML chunk flows.
8
+ * That policy exists for the normal document path (a <script> must not precede
9
+ * the doctype). On a PPR shell HIT it parks the ENTIRE hydration payload: the
10
+ * resumed fizz render emits its first chunk only when the first hole's data
11
+ * resolves (live loaders — measured ~1.5s on SFCC-backed pages), while the
12
+ * Flight root row is ready within ~30ms of the tail render starting. The client
13
+ * cannot call hydrateRoot until that root row arrives, so the lazy start held
14
+ * hydration hostage to the slowest loader for no structural reason: the stored
15
+ * prelude (a complete document through </body></html>) is already on the wire
16
+ * before the tail, so every tail byte is foster-parented and a Flight <script>
17
+ * is valid as the FIRST tail byte.
18
+ *
19
+ * This injector starts pumping Flight chunks immediately in start(). Ordering
20
+ * safety is kept by serializing ALL writes through one promise chain: fizz
21
+ * chunks buffered within a tick flush as one atomic task (same batching idea as
22
+ * the stock injector — never inject between two partial HTML chunks), and each
23
+ * Flight script is its own task, so scripts land only between batches. The
24
+ * trailer is stripped from passing HTML and re-appended once, after both
25
+ * streams complete — identical to the stock contract.
26
+ *
27
+ * RESUME/DATA-VARIANT ONLY. The normal document path must keep the stock
28
+ * injector: there the first bytes are the document head, and an eager script
29
+ * would precede the doctype.
30
+ */
31
+
32
+ const encoder = new TextEncoder();
33
+ const TRAILER = "</body></html>";
34
+
35
+ // Escape closing script tags and HTML comments in JS content (ported from
36
+ // rsc-html-stream/server; escapes the "s" instead of the slash so a regexp
37
+ // literal like `0</script/` stays valid JS).
38
+ function escapeScript(script: string): string {
39
+ return script.replace(/<!--/g, "<\\!--").replace(/<\/(script)/gi, "</\\$1");
40
+ }
41
+
42
+ function writeScript(
43
+ controller: TransformStreamDefaultController<Uint8Array>,
44
+ jsExpr: string,
45
+ nonce: string | undefined,
46
+ ): void {
47
+ controller.enqueue(
48
+ encoder.encode(
49
+ `<script${nonce ? ` nonce="${nonce}"` : ""}>${escapeScript(
50
+ `(self.__FLIGHT_DATA||=[]).push(${jsExpr})`,
51
+ )}</script>`,
52
+ ),
53
+ );
54
+ }
55
+
56
+ export function injectRSCPayloadEager(
57
+ rscStream: ReadableStream<Uint8Array>,
58
+ options?: { nonce?: string },
59
+ ): TransformStream<Uint8Array, Uint8Array> {
60
+ const nonce = options?.nonce;
61
+ const htmlDecoder = new TextDecoder();
62
+ const t0 = INTERNAL_RANGO_DEBUG ? performance.now() : 0;
63
+ let loggedFirstFlight = false;
64
+ let loggedFirstHtml = false;
65
+
66
+ // All output goes through this chain: one task per Flight script, one task
67
+ // per buffered-HTML batch. A script can therefore never split a batch.
68
+ let queue: Promise<void> = Promise.resolve();
69
+ const enqueueTask = (fn: () => void): Promise<void> => {
70
+ queue = queue.then(fn);
71
+ return queue;
72
+ };
73
+
74
+ let buffered: Uint8Array[] = [];
75
+ let timeout: ReturnType<typeof setTimeout> | null = null;
76
+ let rscDone: Promise<void> = Promise.resolve();
77
+
78
+ function flushBufferedHTML(
79
+ controller: TransformStreamDefaultController<Uint8Array>,
80
+ ): void {
81
+ if (INTERNAL_RANGO_DEBUG && !loggedFirstHtml && buffered.length > 0) {
82
+ loggedFirstHtml = true;
83
+ console.log(
84
+ `[Server][ppr] eager-inject: first resumed HTML batch +${Math.round(performance.now() - t0)}ms`,
85
+ );
86
+ }
87
+ for (const chunk of buffered) {
88
+ let buf = htmlDecoder.decode(chunk, { stream: true });
89
+ if (buf.endsWith(TRAILER)) buf = buf.slice(0, -TRAILER.length);
90
+ controller.enqueue(encoder.encode(buf));
91
+ }
92
+ const remaining = htmlDecoder.decode();
93
+ if (remaining.length) {
94
+ const out = remaining.endsWith(TRAILER)
95
+ ? remaining.slice(0, -TRAILER.length)
96
+ : remaining;
97
+ controller.enqueue(encoder.encode(out));
98
+ }
99
+ buffered.length = 0;
100
+ timeout = null;
101
+ }
102
+
103
+ async function pumpRSC(
104
+ controller: TransformStreamDefaultController<Uint8Array>,
105
+ ): Promise<void> {
106
+ const rscDecoder = new TextDecoder("utf-8", { fatal: true });
107
+ const reader = rscStream.getReader();
108
+ for (;;) {
109
+ const { done, value } = await reader.read();
110
+ if (done) break;
111
+ // String when the chunk is valid unicode, base64 round-trip otherwise —
112
+ // same fallback the stock injector uses.
113
+ let jsExpr: string;
114
+ try {
115
+ jsExpr = JSON.stringify(rscDecoder.decode(value, { stream: true }));
116
+ } catch {
117
+ const base64 = JSON.stringify(
118
+ btoa(String.fromCodePoint(...(value as Uint8Array))),
119
+ );
120
+ jsExpr = `Uint8Array.from(atob(${base64}), m => m.codePointAt(0))`;
121
+ }
122
+ await enqueueTask(() => {
123
+ if (INTERNAL_RANGO_DEBUG && !loggedFirstFlight) {
124
+ loggedFirstFlight = true;
125
+ console.log(
126
+ `[Server][ppr] eager-inject: first flight script +${Math.round(performance.now() - t0)}ms`,
127
+ );
128
+ }
129
+ writeScript(controller, jsExpr, nonce);
130
+ });
131
+ }
132
+ const remaining = rscDecoder.decode();
133
+ if (remaining.length) {
134
+ await enqueueTask(() =>
135
+ writeScript(controller, JSON.stringify(remaining), nonce),
136
+ );
137
+ }
138
+ }
139
+
140
+ return new TransformStream<Uint8Array, Uint8Array>({
141
+ start(controller) {
142
+ // The eager part: pump Flight immediately, before any HTML arrives.
143
+ rscDone = pumpRSC(controller).catch((err) => {
144
+ try {
145
+ controller.error(err);
146
+ } catch {
147
+ // Stream already errored/closed; nothing to signal.
148
+ }
149
+ });
150
+ },
151
+ transform(chunk, controller) {
152
+ buffered.push(chunk);
153
+ if (timeout) return;
154
+ // Batch same-tick fizz chunks so a Flight script cannot land between two
155
+ // partial HTML chunks of one logical write (stock injector's invariant).
156
+ timeout = setTimeout(() => {
157
+ void enqueueTask(() => flushBufferedHTML(controller));
158
+ }, 0);
159
+ },
160
+ async flush(controller) {
161
+ await rscDone;
162
+ if (timeout) clearTimeout(timeout);
163
+ await enqueueTask(() => flushBufferedHTML(controller));
164
+ controller.enqueue(encoder.encode(TRAILER));
165
+ },
166
+ });
167
+ }
@@ -0,0 +1,228 @@
1
+ import React from "react";
2
+ import { renderSegments } from "../segment-system.js";
3
+ import {
4
+ filterSegmentOrder,
5
+ filterRouteSegmentIds,
6
+ } from "../browser/react/filter-segment-order.js";
7
+ import { ThemeProvider } from "../theme/ThemeProvider.js";
8
+ import { NonceContext } from "../browser/react/nonce-context.js";
9
+ import { NavigationStoreContext } from "../browser/react/context.js";
10
+ import type { NavigationStoreContextValue } from "../browser/react/context.js";
11
+ import type { HandleData } from "../browser/types.js";
12
+ import type { ResolvedSegment } from "../types.js";
13
+ import type { ResolvedThemeConfig, Theme } from "../theme/types.js";
14
+ import type {
15
+ EventController,
16
+ DerivedNavigationState,
17
+ } from "../browser/event-controller.js";
18
+
19
+ /**
20
+ * createFromReadableStream from @rangojs/router/internal/deps/ssr.
21
+ * Deserializes the Flight branch used to build the SSR VDOM.
22
+ */
23
+ export type CreateFromReadableStream = <T>(
24
+ stream: ReadableStream<Uint8Array>,
25
+ ) => Promise<T>;
26
+
27
+ /**
28
+ * RSC payload type (minimal interface for SSR)
29
+ */
30
+ export interface RscPayload {
31
+ metadata?: {
32
+ segments?: ResolvedSegment[];
33
+ rootLayout?: React.ComponentType<{ children: React.ReactNode }>;
34
+ handles?: AsyncGenerator<HandleData, void, unknown>;
35
+ matched?: string[];
36
+ pathname?: string;
37
+ params?: Record<string, string>;
38
+ basename?: string;
39
+ themeConfig?: ResolvedThemeConfig | null;
40
+ initialTheme?: Theme;
41
+ version?: string;
42
+ };
43
+ }
44
+
45
+ /**
46
+ * Consume an async generator and return a Promise that resolves with the final value.
47
+ * Used for SSR where we need to await all handle data before rendering.
48
+ */
49
+ async function consumeAsyncGenerator(
50
+ generator: AsyncGenerator<HandleData, void, unknown>,
51
+ ): Promise<HandleData> {
52
+ let lastData: HandleData = {};
53
+ for await (const data of generator) {
54
+ lastData = data;
55
+ }
56
+ return lastData;
57
+ }
58
+
59
+ /**
60
+ * Create a minimal event controller for SSR.
61
+ * This provides the correct pathname so useNavigation returns the right value during SSR.
62
+ */
63
+ function createSsrEventController(opts: {
64
+ pathname: string;
65
+ params?: Record<string, string>;
66
+ handleData?: HandleData;
67
+ matched?: string[];
68
+ }): EventController {
69
+ const location = new URL(opts.pathname, "http://localhost");
70
+ let params = opts.params ?? {};
71
+ const rawMatched = opts.matched ?? [];
72
+ const handleState = {
73
+ data: opts.handleData ?? {},
74
+ segmentOrder: filterSegmentOrder(rawMatched),
75
+ routeSegmentIds: filterRouteSegmentIds(rawMatched),
76
+ };
77
+ const state: DerivedNavigationState = {
78
+ state: "idle",
79
+ isStreaming: false,
80
+ isNavigating: false,
81
+ location,
82
+ pendingUrl: null,
83
+ inflightActions: [],
84
+ };
85
+
86
+ return {
87
+ getState: () => state,
88
+ getLocation: () => location,
89
+ subscribe: () => () => {},
90
+ getActionState: () => ({
91
+ state: "idle",
92
+ actionId: null,
93
+ payload: null,
94
+ error: null,
95
+ result: null,
96
+ }),
97
+ subscribeToAction: () => () => {},
98
+ subscribeToHandles: () => () => {},
99
+ setHandleData: () => {},
100
+ getHandleState: () => handleState,
101
+ setRouteSegmentIds: () => {},
102
+ setParams: (nextParams) => {
103
+ params = nextParams;
104
+ },
105
+ getParams: () => params,
106
+ setLocation: () => {},
107
+ startNavigation: () => {
108
+ throw new Error("Navigation not supported during SSR");
109
+ },
110
+ abortNavigation: () => {},
111
+ startAction: () => {
112
+ throw new Error("Actions not supported during SSR");
113
+ },
114
+ abortAllActions: () => {},
115
+ getCurrentNavigation: () => null,
116
+ getInflightActions: () => new Map(),
117
+ hadAnyConcurrentActions: () => false,
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Options for {@link createSsrRootComponent}.
123
+ */
124
+ export interface SsrRootOptions {
125
+ /** createFromReadableStream for the SSR branch of the Flight stream. */
126
+ createFromReadableStream: CreateFromReadableStream;
127
+ /** The Flight stream branch to deserialize into the SSR VDOM. */
128
+ rscStream: ReadableStream<Uint8Array>;
129
+ /** Nonce for CSP; propagated to NonceContext. */
130
+ nonce?: string;
131
+ }
132
+
133
+ /**
134
+ * Build the closure component that deserializes the Flight payload, consumes
135
+ * handles to completion, builds the segment tree, and wraps it in the
136
+ * NavigationStore / Nonce / Theme providers.
137
+ *
138
+ * The full-fizz path (renderHTML), the shell prerender pass (capture), and the
139
+ * shell resume pass all render this identical tree. `resume` requires the tree
140
+ * above the postponed holes to match the prerendered tree; rendering the same
141
+ * builder over the same replayed segments is what makes that hold. A fresh
142
+ * component instance per pass is fine — replay matches structure, not function
143
+ * identity.
144
+ *
145
+ * The memo slots (payload/handles/context/root) are closure-scoped to the
146
+ * returned instance, so each render pass memoizes independently. renderSegments
147
+ * is async: React.use() on a fresh promise would suspend and replay SsrRoot,
148
+ * re-running the whole segment-tree build unless the promise is memoized.
149
+ */
150
+ export function createSsrRootComponent(opts: SsrRootOptions): React.FC {
151
+ const { createFromReadableStream, rscStream, nonce } = opts;
152
+
153
+ let payload: Promise<RscPayload> | undefined;
154
+ let handlesPromise: Promise<HandleData> | undefined;
155
+ let ssrContextValue: NavigationStoreContextValue | undefined;
156
+ let rootPromise: Promise<React.ReactNode> | undefined;
157
+
158
+ return function SsrRoot() {
159
+ payload ??= createFromReadableStream<RscPayload>(rscStream);
160
+ const resolved = React.use(payload);
161
+
162
+ const themeConfig = resolved.metadata?.themeConfig ?? null;
163
+ const pathname = resolved.metadata?.pathname ?? "/";
164
+
165
+ // Await handles before creating SSR event controller so hooks can
166
+ // read request-local handle data via NavigationStoreContext.
167
+ // The handles property is an async generator that yields on each push
168
+ // Memoize the promise since async generators can only be iterated once
169
+ let handleData: HandleData = {};
170
+ if (resolved.metadata?.handles) {
171
+ handlesPromise ??= consumeAsyncGenerator(resolved.metadata.handles);
172
+ handleData = React.use(handlesPromise);
173
+ }
174
+
175
+ // Create SSR context with request-local pathname/params/handles.
176
+ ssrContextValue ??= {
177
+ store: null as any,
178
+ eventController: createSsrEventController({
179
+ pathname,
180
+ params: resolved.metadata?.params,
181
+ handleData,
182
+ matched: resolved.metadata?.matched,
183
+ }),
184
+ navigate: async () => {},
185
+ refresh: async () => {},
186
+ version: resolved.metadata?.version,
187
+ basename: resolved.metadata?.basename,
188
+ };
189
+
190
+ // Build content tree from segments.
191
+ // Order must match NavigationProvider: NavigationStoreContext > NonceContext > ThemeProvider > content
192
+ // Memoize like payload/handles above: renderSegments is async, so
193
+ // React.use() on a fresh promise suspends and replays SsrRoot, which
194
+ // would re-run the entire segment-tree build on every initial render.
195
+ rootPromise ??= Promise.resolve(
196
+ renderSegments(resolved.metadata?.segments ?? [], {
197
+ rootLayout: resolved.metadata?.rootLayout,
198
+ }),
199
+ );
200
+ let content: React.ReactNode = React.use(rootPromise);
201
+
202
+ // Wrap content with ThemeProvider if theme is enabled
203
+ if (themeConfig) {
204
+ content = (
205
+ <ThemeProvider
206
+ config={themeConfig}
207
+ initialTheme={resolved.metadata?.initialTheme}
208
+ >
209
+ {content}
210
+ </ThemeProvider>
211
+ );
212
+ }
213
+
214
+ // Wrap with NonceContext so client components (e.g. MetaTags) can
215
+ // apply CSP nonces to inline scripts during SSR. Always present to
216
+ // match the browser-side NavigationProvider tree shape for hydration.
217
+ content = (
218
+ <NonceContext.Provider value={nonce}>{content}</NonceContext.Provider>
219
+ );
220
+
221
+ // Wrap with NavigationStoreContext for useNavigation hook
222
+ return (
223
+ <NavigationStoreContext.Provider value={ssrContextValue!}>
224
+ {content}
225
+ </NavigationStoreContext.Provider>
226
+ );
227
+ };
228
+ }
@@ -12,7 +12,9 @@
12
12
  * It relies on createHandle registering the collect even in a bare test (it
13
13
  * assigns a runtime fallback id when the Vite plugin did not inject one). If a
14
14
  * handle's module was never imported (so createHandle never ran), the collect is
15
- * unregistered and this falls back to a flat array with a warning.
15
+ * unregistered and this falls back to the default identity collect — with a
16
+ * warning, since a CUSTOM collect that failed to register silently returns the
17
+ * wrong shape.
16
18
  */
17
19
 
18
20
  import { getCollectFn, type Handle } from "../handle.js";
@@ -25,16 +27,20 @@ export function collectHandle<TData, TAccumulated>(
25
27
  | ((segments: TData[][]) => TAccumulated)
26
28
  | undefined;
27
29
 
30
+ // Drop empty arrays matching production behavior (segment count/indices).
31
+ const nonEmpty = segments.filter((seg) => seg.length > 0) as TData[][];
32
+
33
+ // No registered collect (the handle's module was not imported): fall back to the
34
+ // default identity collect — the per-segment arrays as-is, mirroring production
35
+ // collectHandleData. Warn, because a handle with a CUSTOM collect would silently
36
+ // get the wrong shape (the runtime can't tell it from an intended default).
28
37
  if (!collectFn) {
29
38
  console.warn(
30
- `[rango] collectHandle: handle "${handle.$$id}" has no registered collect ` +
31
- `function. Import the handle's module so createHandle() runs. Falling ` +
32
- `back to a flat array.`,
39
+ `[rango] collectHandle: handle "${handle.$$id}" has no registered collect ` +
40
+ `falling back to the identity (per-segment data as-is). Import the handle's ` +
41
+ `module so createHandle() runs if you expected a custom collect.`,
33
42
  );
34
- return segments.flat() as unknown as TAccumulated;
43
+ return nonEmpty as unknown as TAccumulated;
35
44
  }
36
-
37
- // Drop empty arrays matching production behavior (segment count/indices).
38
- const nonEmpty = segments.filter((seg) => seg.length > 0) as TData[][];
39
45
  return collectFn(nonEmpty);
40
46
  }
@@ -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,11 +157,18 @@ interface DispatchableRouter<TEnv> {
140
157
  routeMap: Record<string, unknown>;
141
158
  middleware: MiddlewareEntry<TEnv>[];
142
159
  onError?: OnErrorCallback<TEnv>;
143
- findMatch(pathname: string): {
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;
167
+ findMatch(pathname: string): Promise<{
144
168
  redirectTo?: string;
145
169
  routeKey?: string;
146
170
  params?: Record<string, string>;
147
- } | null;
171
+ } | null>;
148
172
  previewMatch(
149
173
  request: Request,
150
174
  input?: { env?: TEnv },
@@ -351,7 +375,7 @@ export async function dispatch<TEnv = any>(
351
375
 
352
376
  // findMatch carries trailing-slash/redirect targets and null on no match.
353
377
  // previewMatch swallows redirects, so detect them here first.
354
- const match = router.findMatch(url.pathname);
378
+ const match = await router.findMatch(url.pathname);
355
379
  const redirectTo = match?.redirectTo;
356
380
  const isUnmatched = !match;
357
381
 
@@ -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
  }