@rangojs/router 0.0.0-experimental.d7eeaa75 → 0.0.0-experimental.dc2bd2b4

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 (253) hide show
  1. package/README.md +120 -25
  2. package/dist/bin/rango.js +147 -57
  3. package/dist/testing/vitest.js +48 -0
  4. package/dist/vite/index.js +2151 -846
  5. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  6. package/package.json +57 -11
  7. package/skills/breadcrumbs/SKILL.md +3 -1
  8. package/skills/bundle-analysis/SKILL.md +159 -0
  9. package/skills/cache-guide/SKILL.md +220 -30
  10. package/skills/caching/SKILL.md +116 -8
  11. package/skills/composability/SKILL.md +27 -2
  12. package/skills/document-cache/SKILL.md +78 -55
  13. package/skills/handler-use/SKILL.md +364 -0
  14. package/skills/hooks/SKILL.md +229 -20
  15. package/skills/host-router/SKILL.md +45 -20
  16. package/skills/i18n/SKILL.md +276 -0
  17. package/skills/intercept/SKILL.md +46 -4
  18. package/skills/layout/SKILL.md +28 -7
  19. package/skills/links/SKILL.md +247 -17
  20. package/skills/loader/SKILL.md +219 -9
  21. package/skills/middleware/SKILL.md +47 -12
  22. package/skills/migrate-nextjs/SKILL.md +562 -0
  23. package/skills/migrate-react-router/SKILL.md +769 -0
  24. package/skills/mime-routes/SKILL.md +27 -0
  25. package/skills/observability/SKILL.md +137 -0
  26. package/skills/parallel/SKILL.md +71 -6
  27. package/skills/prerender/SKILL.md +14 -33
  28. package/skills/rango/SKILL.md +242 -22
  29. package/skills/react-compiler/SKILL.md +168 -0
  30. package/skills/response-routes/SKILL.md +66 -9
  31. package/skills/route/SKILL.md +57 -4
  32. package/skills/router-setup/SKILL.md +3 -3
  33. package/skills/server-actions/SKILL.md +751 -0
  34. package/skills/streams-and-websockets/SKILL.md +283 -0
  35. package/skills/testing/SKILL.md +647 -0
  36. package/skills/typesafety/SKILL.md +319 -27
  37. package/skills/use-cache/SKILL.md +34 -5
  38. package/skills/view-transitions/SKILL.md +294 -0
  39. package/src/__augment-tests__/augment.ts +81 -0
  40. package/src/__augment-tests__/augmented.check.ts +117 -0
  41. package/src/browser/action-coordinator.ts +53 -36
  42. package/src/browser/app-shell.ts +52 -0
  43. package/src/browser/event-controller.ts +86 -70
  44. package/src/browser/history-state.ts +21 -0
  45. package/src/browser/index.ts +3 -3
  46. package/src/browser/navigation-bridge.ts +84 -11
  47. package/src/browser/navigation-client.ts +76 -28
  48. package/src/browser/navigation-store.ts +32 -9
  49. package/src/browser/navigation-transaction.ts +10 -28
  50. package/src/browser/partial-update.ts +64 -26
  51. package/src/browser/prefetch/cache.ts +129 -21
  52. package/src/browser/prefetch/fetch.ts +148 -16
  53. package/src/browser/prefetch/queue.ts +36 -5
  54. package/src/browser/rango-state.ts +53 -13
  55. package/src/browser/react/Link.tsx +30 -2
  56. package/src/browser/react/NavigationProvider.tsx +72 -31
  57. package/src/browser/react/filter-segment-order.ts +51 -7
  58. package/src/browser/react/index.ts +3 -0
  59. package/src/browser/react/location-state-shared.ts +175 -4
  60. package/src/browser/react/location-state.ts +39 -13
  61. package/src/browser/react/use-handle.ts +17 -9
  62. package/src/browser/react/use-navigation.ts +22 -2
  63. package/src/browser/react/use-params.ts +20 -8
  64. package/src/browser/react/use-reverse.ts +106 -0
  65. package/src/browser/react/use-router.ts +22 -2
  66. package/src/browser/react/use-segments.ts +11 -8
  67. package/src/browser/response-adapter.ts +25 -0
  68. package/src/browser/rsc-router.tsx +64 -22
  69. package/src/browser/scroll-restoration.ts +22 -14
  70. package/src/browser/segment-reconciler.ts +36 -14
  71. package/src/browser/segment-structure-assert.ts +2 -2
  72. package/src/browser/server-action-bridge.ts +23 -30
  73. package/src/browser/types.ts +21 -0
  74. package/src/build/collect-fallback-refs.ts +107 -0
  75. package/src/build/generate-manifest.ts +60 -35
  76. package/src/build/generate-route-types.ts +2 -0
  77. package/src/build/index.ts +2 -0
  78. package/src/build/route-trie.ts +52 -25
  79. package/src/build/route-types/codegen.ts +4 -4
  80. package/src/build/route-types/include-resolution.ts +1 -1
  81. package/src/build/route-types/per-module-writer.ts +7 -4
  82. package/src/build/route-types/router-processing.ts +55 -14
  83. package/src/build/route-types/scan-filter.ts +1 -1
  84. package/src/build/route-types/source-scan.ts +118 -0
  85. package/src/build/runtime-discovery.ts +9 -20
  86. package/src/cache/cache-scope.ts +28 -42
  87. package/src/cache/cf/cf-cache-store.ts +54 -13
  88. package/src/client.rsc.tsx +3 -0
  89. package/src/client.tsx +92 -182
  90. package/src/context-var.ts +5 -5
  91. package/src/decode-loader-results.ts +36 -0
  92. package/src/errors.ts +30 -1
  93. package/src/handle.ts +26 -13
  94. package/src/host/index.ts +2 -2
  95. package/src/host/router.ts +129 -57
  96. package/src/host/types.ts +31 -2
  97. package/src/host/utils.ts +1 -1
  98. package/src/href-client.ts +140 -20
  99. package/src/index.rsc.ts +9 -4
  100. package/src/index.ts +53 -15
  101. package/src/loader-store.ts +500 -0
  102. package/src/loader.rsc.ts +2 -5
  103. package/src/loader.ts +3 -10
  104. package/src/missing-id-error.ts +68 -0
  105. package/src/outlet-context.ts +1 -1
  106. package/src/prerender.ts +4 -4
  107. package/src/response-utils.ts +37 -0
  108. package/src/reverse.ts +65 -36
  109. package/src/route-content-wrapper.tsx +6 -28
  110. package/src/route-definition/dsl-helpers.ts +384 -257
  111. package/src/route-definition/helper-factories.ts +29 -139
  112. package/src/route-definition/helpers-types.ts +100 -28
  113. package/src/route-definition/resolve-handler-use.ts +6 -0
  114. package/src/route-definition/use-item-types.ts +32 -0
  115. package/src/route-types.ts +26 -41
  116. package/src/router/basename.ts +14 -0
  117. package/src/router/content-negotiation.ts +15 -2
  118. package/src/router/error-handling.ts +1 -1
  119. package/src/router/handler-context.ts +21 -38
  120. package/src/router/intercept-resolution.ts +4 -18
  121. package/src/router/lazy-includes.ts +8 -8
  122. package/src/router/loader-resolution.ts +19 -2
  123. package/src/router/manifest.ts +22 -13
  124. package/src/router/match-api.ts +4 -3
  125. package/src/router/match-handlers.ts +63 -20
  126. package/src/router/match-middleware/cache-lookup.ts +44 -91
  127. package/src/router/match-middleware/cache-store.ts +3 -2
  128. package/src/router/match-result.ts +53 -32
  129. package/src/router/metrics.ts +1 -1
  130. package/src/router/middleware-types.ts +15 -26
  131. package/src/router/middleware.ts +99 -84
  132. package/src/router/pattern-matching.ts +101 -17
  133. package/src/router/prerender-match.ts +1 -1
  134. package/src/router/preview-match.ts +3 -1
  135. package/src/router/request-classification.ts +4 -28
  136. package/src/router/revalidation.ts +58 -2
  137. package/src/router/router-interfaces.ts +45 -28
  138. package/src/router/router-options.ts +40 -1
  139. package/src/router/router-registry.ts +2 -5
  140. package/src/router/segment-resolution/fresh.ts +27 -6
  141. package/src/router/segment-resolution/revalidation.ts +147 -106
  142. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  143. package/src/router/substitute-pattern-params.ts +56 -0
  144. package/src/router/telemetry.ts +99 -0
  145. package/src/router/trie-matching.ts +18 -13
  146. package/src/router/types.ts +8 -0
  147. package/src/router/url-params.ts +49 -0
  148. package/src/router.ts +38 -23
  149. package/src/rsc/handler-context.ts +2 -2
  150. package/src/rsc/handler.ts +28 -69
  151. package/src/rsc/helpers.ts +91 -43
  152. package/src/rsc/index.ts +1 -1
  153. package/src/rsc/origin-guard.ts +28 -10
  154. package/src/rsc/progressive-enhancement.ts +4 -0
  155. package/src/rsc/response-route-handler.ts +46 -53
  156. package/src/rsc/rsc-rendering.ts +35 -51
  157. package/src/rsc/runtime-warnings.ts +9 -10
  158. package/src/rsc/server-action.ts +17 -37
  159. package/src/rsc/ssr-setup.ts +16 -0
  160. package/src/rsc/types.ts +8 -2
  161. package/src/search-params.ts +4 -4
  162. package/src/segment-content-promise.ts +67 -0
  163. package/src/segment-loader-promise.ts +122 -0
  164. package/src/segment-system.tsx +132 -116
  165. package/src/serialize.ts +243 -0
  166. package/src/server/context.ts +143 -53
  167. package/src/server/cookie-store.ts +28 -4
  168. package/src/server/request-context.ts +20 -42
  169. package/src/ssr/index.tsx +5 -1
  170. package/src/static-handler.ts +1 -1
  171. package/src/testing/cache-status.ts +166 -0
  172. package/src/testing/collect-handle.ts +63 -0
  173. package/src/testing/dispatch.ts +440 -0
  174. package/src/testing/dom.entry.ts +22 -0
  175. package/src/testing/e2e/fixture.ts +154 -0
  176. package/src/testing/e2e/index.ts +149 -0
  177. package/src/testing/e2e/matchers.ts +51 -0
  178. package/src/testing/e2e/page-helpers.ts +272 -0
  179. package/src/testing/e2e/parity.ts +306 -0
  180. package/src/testing/e2e/server.ts +183 -0
  181. package/src/testing/flight-matchers.ts +104 -0
  182. package/src/testing/flight-runtime.d.ts +21 -0
  183. package/src/testing/flight.entry.ts +22 -0
  184. package/src/testing/flight.ts +182 -0
  185. package/src/testing/generated-routes.ts +223 -0
  186. package/src/testing/index.ts +105 -0
  187. package/src/testing/internal/context.ts +193 -0
  188. package/src/testing/render-route.tsx +536 -0
  189. package/src/testing/run-loader.ts +296 -0
  190. package/src/testing/run-middleware.ts +170 -0
  191. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  192. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  193. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  194. package/src/testing/vitest-stubs/version.ts +5 -0
  195. package/src/testing/vitest.ts +183 -0
  196. package/src/types/global-namespace.ts +39 -26
  197. package/src/types/handler-context.ts +68 -50
  198. package/src/types/index.ts +1 -0
  199. package/src/types/loader-types.ts +5 -6
  200. package/src/types/request-scope.ts +126 -0
  201. package/src/types/route-entry.ts +11 -0
  202. package/src/types/segments.ts +35 -2
  203. package/src/urls/include-helper.ts +34 -67
  204. package/src/urls/index.ts +0 -3
  205. package/src/urls/path-helper-types.ts +41 -7
  206. package/src/urls/path-helper.ts +17 -52
  207. package/src/urls/pattern-types.ts +36 -19
  208. package/src/urls/response-types.ts +22 -29
  209. package/src/urls/type-extraction.ts +26 -116
  210. package/src/urls/urls-function.ts +1 -5
  211. package/src/use-loader.tsx +413 -42
  212. package/src/vite/debug.ts +185 -0
  213. package/src/vite/discovery/bundle-postprocess.ts +6 -6
  214. package/src/vite/discovery/discover-routers.ts +101 -51
  215. package/src/vite/discovery/discovery-errors.ts +194 -0
  216. package/src/vite/discovery/gate-state.ts +171 -0
  217. package/src/vite/discovery/prerender-collection.ts +67 -26
  218. package/src/vite/discovery/route-types-writer.ts +40 -84
  219. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  220. package/src/vite/discovery/state.ts +33 -0
  221. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  222. package/src/vite/index.ts +2 -0
  223. package/src/vite/plugin-types.ts +67 -0
  224. package/src/vite/plugins/cjs-to-esm.ts +8 -7
  225. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  226. package/src/vite/plugins/client-ref-hashing.ts +28 -5
  227. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  228. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  229. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  230. package/src/vite/plugins/expose-action-id.ts +54 -30
  231. package/src/vite/plugins/expose-id-utils.ts +12 -8
  232. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  233. package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
  234. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  235. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  236. package/src/vite/plugins/expose-internal-ids.ts +496 -486
  237. package/src/vite/plugins/performance-tracks.ts +29 -25
  238. package/src/vite/plugins/use-cache-transform.ts +65 -50
  239. package/src/vite/plugins/version-injector.ts +39 -23
  240. package/src/vite/plugins/version-plugin.ts +59 -2
  241. package/src/vite/plugins/virtual-entries.ts +2 -2
  242. package/src/vite/rango.ts +116 -29
  243. package/src/vite/router-discovery.ts +750 -100
  244. package/src/vite/utils/ast-handler-extract.ts +15 -15
  245. package/src/vite/utils/banner.ts +1 -1
  246. package/src/vite/utils/bundle-analysis.ts +4 -2
  247. package/src/vite/utils/client-chunks.ts +190 -0
  248. package/src/vite/utils/forward-user-plugins.ts +193 -0
  249. package/src/vite/utils/manifest-utils.ts +21 -5
  250. package/src/vite/utils/package-resolution.ts +41 -1
  251. package/src/vite/utils/prerender-utils.ts +21 -6
  252. package/src/vite/utils/shared-utils.ts +107 -26
  253. package/src/browser/action-response-classifier.ts +0 -99
@@ -20,6 +20,13 @@ export interface PluginOptions {
20
20
  buildEnv?: import("../plugin-types.js").BuildEnvOption;
21
21
  /** Deployment preset (needed for buildEnv "auto" resolution). */
22
22
  preset?: "node" | "cloudflare";
23
+ /**
24
+ * Shared context the built-in clientChunks strategy reads. Discovery populates
25
+ * it (registered fallback hashes + single-router name) before the client build
26
+ * invokes the strategy. Present only when the built-in strategy is active
27
+ * (`clientChunks: true`/default); undefined for `false` or a custom function.
28
+ */
29
+ clientChunkCtx?: import("../utils/client-chunks.js").ClientChunkContext;
23
30
  }
24
31
 
25
32
  export interface PrecomputedEntry {
@@ -45,6 +52,21 @@ export interface DiscoveryState {
45
52
  projectRoot: string;
46
53
  isBuildMode: boolean;
47
54
  userResolveAlias: any;
55
+ /**
56
+ * Data-only slice of the user's resolved config (resolve.* incl. native
57
+ * tsconfigPaths, define, oxc) mirrored into the discovery temp server so it
58
+ * resolves and transforms modules the same way the real environment does.
59
+ * See `utils/forward-user-plugins.ts`.
60
+ */
61
+ userRunnerConfig:
62
+ | import("../utils/forward-user-plugins.js").ForwardedRunnerConfig
63
+ | undefined;
64
+ /**
65
+ * User resolution plugins (resolveId/load), stripped to their resolution
66
+ * surface, forwarded into the discovery temp server. Lets third-party
67
+ * resolvers such as vite-tsconfig-paths participate in discovery.
68
+ */
69
+ userResolvePlugins: import("vite").Plugin[];
48
70
  scanFilter: ScanFilter | undefined;
49
71
  cachedRouterFiles: string[] | undefined;
50
72
  opts: PluginOptions | undefined;
@@ -76,6 +98,14 @@ export interface DiscoveryState {
76
98
  resolvedBuildEnv?: Record<string, unknown>;
77
99
  /** Cleanup function for build-time env resources (e.g., miniflare). */
78
100
  buildEnvDispose?: (() => Promise<void> | void) | null;
101
+
102
+ /**
103
+ * Set when the most recent HMR re-discovery threw. Cleared on the next
104
+ * successful discovery. Surfaced via debug logs so we can detect "manifest
105
+ * frozen at last-good after error → user fix in non-route file → no
106
+ * rediscovery trigger" scenarios.
107
+ */
108
+ lastDiscoveryError?: { message: string; at: number } | null;
79
109
  }
80
110
 
81
111
  export function createDiscoveryState(
@@ -87,6 +117,8 @@ export function createDiscoveryState(
87
117
  projectRoot: "",
88
118
  isBuildMode: false,
89
119
  userResolveAlias: undefined,
120
+ userRunnerConfig: undefined,
121
+ userResolvePlugins: [],
90
122
  scanFilter: undefined,
91
123
  cachedRouterFiles: undefined,
92
124
  opts,
@@ -113,5 +145,6 @@ export function createDiscoveryState(
113
145
  devServer: null,
114
146
  selfWrittenGenFiles: new Map(),
115
147
  SELF_WRITE_WINDOW_MS: 5_000,
148
+ lastDiscoveryError: null,
116
149
  };
117
150
  }
@@ -58,7 +58,7 @@ export function generateRoutesManifestModule(state: DiscoveryState): string {
58
58
  }
59
59
 
60
60
  const lines = [
61
- `import { setCachedManifest, setPrecomputedEntries, setRouteTrie, setRouterManifest, registerRouterManifestLoader, clearAllRouterData } from "@rangojs/router/server";`,
61
+ `import { setCachedManifest, setRouterManifest, registerRouterManifestLoader, clearAllRouterData } from "@rangojs/router/server";`,
62
62
  ...genFileImports,
63
63
  // Clear stale per-router cached data (manifest, trie, precomputed entries)
64
64
  // before re-populating. In Cloudflare dev mode, program reloads re-evaluate
@@ -101,28 +101,18 @@ export function generateRoutesManifestModule(state: DiscoveryState): string {
101
101
  }
102
102
  }
103
103
 
104
- // In dev mode, skip trie and precomputed entries injection. These are
105
- // computed once during initial discovery and become stale after route
106
- // changes. A stale trie would incorrectly match removed routes. The
107
- // handler falls back to Phase 2 regex matching against the live
108
- // router.urlpatterns, which is always correct after a program reload.
109
- // In build mode, the trie is always fresh (built from the final route
110
- // tree) so it's safe to inject.
111
- if (state.isBuildMode) {
112
- if (
113
- state.mergedPrecomputedEntries &&
114
- state.mergedPrecomputedEntries.length > 0
115
- ) {
116
- lines.push(
117
- `setPrecomputedEntries(${jsonParseExpression(state.mergedPrecomputedEntries)});`,
118
- );
119
- }
120
- if (state.mergedRouteTrie) {
121
- lines.push(
122
- `setRouteTrie(${jsonParseExpression(state.mergedRouteTrie)});`,
123
- );
124
- }
125
- }
104
+ // Per-router trie and precomputedEntries are NOT inlined eagerly.
105
+ // They live in the per-router lazy chunks (generatePerRouterModule) and
106
+ // are loaded via ensureRouterManifest(routerId), which is awaited before
107
+ // every request in router.fetch() and before findMatch is reached.
108
+ // Inlining the merged versions here would duplicate the per-router data
109
+ // (the merged trie/precomputedEntries equal the per-router data for
110
+ // single-router apps; for multi-router, the merged trie is dead code
111
+ // because find-match.ts only consumes per-router tries).
112
+ //
113
+ // In dev mode, the handler also falls back to Phase 2 regex matching
114
+ // against live router.urlpatterns, which is always correct after a
115
+ // program reload.
126
116
 
127
117
  // Register lazy loaders for per-router manifest modules.
128
118
  // Each import() uses a static string literal so Rollup creates separate chunks.
package/src/vite/index.ts CHANGED
@@ -13,6 +13,8 @@ export type {
13
13
  RangoNodeOptions,
14
14
  RangoCloudflareOptions,
15
15
  RangoOptions,
16
+ ClientChunks,
17
+ ClientChunkMeta,
16
18
  BuildEnvOption,
17
19
  BuildEnvFactory,
18
20
  BuildEnvFactoryContext,
@@ -47,6 +47,64 @@ export type BuildEnvOption =
47
47
  | Record<string, unknown>
48
48
  | BuildEnvFactory;
49
49
 
50
+ // -- Client chunking --------------------------------------------------------
51
+
52
+ /**
53
+ * Metadata for one client ("use client") module, passed to a {@link ClientChunks}
54
+ * function. Mirrors the shape `@vitejs/plugin-rsc` passes to its own
55
+ * `clientChunks` option.
56
+ */
57
+ export interface ClientChunkMeta {
58
+ /** Absolute module id of the "use client" file. */
59
+ id: string;
60
+ /** Normalized (posix) module id — convenient for path-based matching. */
61
+ normalizedId: string;
62
+ /**
63
+ * The RSC/server chunk that statically imports this client reference. This is
64
+ * the key used for the default grouping when no override is supplied: a single
65
+ * router that statically imports every route yields ONE `serverChunk`, hence
66
+ * one client chunk for all routes.
67
+ */
68
+ serverChunk: string;
69
+ }
70
+
71
+ /**
72
+ * Controls how client ("use client") components are grouped into browser
73
+ * chunks, i.e. per-route / per-feature code splitting of the client bundle.
74
+ *
75
+ * Without splitting, a single router ships ONE client chunk containing every
76
+ * route's client components (and their CSS) — navigating to one route downloads
77
+ * every other route's client code. (Host sub-apps loaded via a dynamic `import()`
78
+ * are the exception: each forms its own chunk.) This option controls how that
79
+ * monolith is split.
80
+ *
81
+ * Behavior branches:
82
+ * - `true` / omitted (**default**, pre-1.0): Rango's built-in **directory
83
+ * strategy**. It splits app `"use client"` modules by **route id** — the segment
84
+ * after a route-root directory (`routes`, `app`, `pages`, `features`, `handlers`,
85
+ * …) — so `routes/dashboard/**` becomes `app-dashboard` at any nesting depth.
86
+ * Where it finds NO route structure (a flat `src/components/`, or host sub-apps
87
+ * already split by a dynamic `import()`), it inherits the default grouping
88
+ * unchanged — so the shared `src/components` chunk stays shared and host apps do
89
+ * not leak across each other. Shared runtime (React, the router, `node_modules`)
90
+ * is never split.
91
+ * - `false`: opt out — inherit `@vitejs/plugin-rsc`'s default grouping everywhere
92
+ * (one chunk per router / per host sub-app).
93
+ * - function: full override. Return a chunk group name, or `undefined` to fall
94
+ * back to the default grouping for that one module. Forwarded directly to
95
+ * `@vitejs/plugin-rsc`'s `clientChunks`.
96
+ *
97
+ * Every module maps to exactly one group, so there is no byte duplication: a
98
+ * component used by two routes lives in one group and is fetched whenever it
99
+ * renders. Put genuinely shared client components OUTSIDE route directories so
100
+ * they land in the shared group rather than one route's chunk.
101
+ *
102
+ * @default true
103
+ */
104
+ export type ClientChunks =
105
+ | boolean
106
+ | ((meta: ClientChunkMeta) => string | undefined);
107
+
50
108
  // -- Plugin options ---------------------------------------------------------
51
109
 
52
110
  /**
@@ -59,6 +117,15 @@ interface RangoBaseOptions {
59
117
  */
60
118
  banner?: boolean;
61
119
 
120
+ /**
121
+ * Group client ("use client") components into browser chunks for per-route /
122
+ * per-feature code splitting. On by default (pre-1.0); pass `false` to opt out.
123
+ * See {@link ClientChunks}.
124
+ *
125
+ * @default true
126
+ */
127
+ clientChunks?: ClientChunks;
128
+
62
129
  /**
63
130
  * Environment bindings available to Prerender and Static handlers at build
64
131
  * time via `ctx.env`. Applies to both production build and dev on-demand
@@ -1,4 +1,7 @@
1
1
  import type { Plugin } from "vite";
2
+ import { createRangoDebugger, NS } from "../debug.js";
3
+
4
+ const debug = createRangoDebugger(NS.transform);
2
5
 
3
6
  /**
4
7
  * Transform CJS vendor files from @vitejs/plugin-rsc to ESM for browser compatibility.
@@ -9,18 +12,16 @@ export function createCjsToEsmPlugin(): Plugin {
9
12
  name: "@rangojs/router:cjs-to-esm",
10
13
  enforce: "pre",
11
14
  transform(code, id) {
12
- const cleanId = id.split("?")[0];
15
+ const cleanId = id.split("?")[0].replaceAll("\\", "/");
13
16
 
14
17
  // Transform the client.browser.js entry point to re-export from CJS
15
- if (
16
- cleanId.includes("vendor/react-server-dom/client.browser.js") ||
17
- cleanId.includes("vendor\\react-server-dom\\client.browser.js")
18
- ) {
18
+ if (cleanId.includes("vendor/react-server-dom/client.browser.js")) {
19
19
  const isProd = process.env.NODE_ENV === "production";
20
20
  const cjsFile = isProd
21
21
  ? "./cjs/react-server-dom-webpack-client.browser.production.js"
22
22
  : "./cjs/react-server-dom-webpack-client.browser.development.js";
23
23
 
24
+ debug?.("cjs-to-esm entry redirect %s", id);
24
25
  return {
25
26
  code: `export * from "${cjsFile}";`,
26
27
  map: null,
@@ -29,8 +30,7 @@ export function createCjsToEsmPlugin(): Plugin {
29
30
 
30
31
  // Transform the actual CJS files to ESM
31
32
  if (
32
- (cleanId.includes("vendor/react-server-dom/cjs/") ||
33
- cleanId.includes("vendor\\react-server-dom\\cjs\\")) &&
33
+ cleanId.includes("vendor/react-server-dom/cjs/") &&
34
34
  cleanId.includes("client.browser")
35
35
  ) {
36
36
  let transformed = code;
@@ -81,6 +81,7 @@ export function createCjsToEsmPlugin(): Plugin {
81
81
  // Reconstruct with license at the top
82
82
  transformed = license + "\n" + transformed;
83
83
 
84
+ debug?.("cjs-to-esm body rewrite %s", id);
84
85
  return {
85
86
  code: transformed,
86
87
  map: null,
@@ -1,4 +1,7 @@
1
1
  import type { Plugin, ResolvedConfig } from "vite";
2
+ import { createRangoDebugger, NS } from "../debug.js";
3
+
4
+ const debug = createRangoDebugger(NS.transform);
2
5
 
3
6
  const CLIENT_IN_SERVER_PROXY_PREFIX =
4
7
  "virtual:vite-rsc/client-in-server-package-proxy/";
@@ -62,6 +65,7 @@ export function extractPackageName(absolutePath: string): string | null {
62
65
  */
63
66
  export function clientRefDedup(): Plugin {
64
67
  let clientExclude: string[] = [];
68
+ const dedupedPackages = new Set<string>();
65
69
 
66
70
  return {
67
71
  name: "@rangojs/router:client-ref-dedup",
@@ -76,6 +80,16 @@ export function clientRefDedup(): Plugin {
76
80
  clientEnv?.optimizeDeps?.exclude ?? config.optimizeDeps?.exclude ?? [];
77
81
  },
78
82
 
83
+ buildEnd() {
84
+ if (debug && dedupedPackages.size > 0) {
85
+ debug(
86
+ "client-ref-dedup: redirected %d package(s) (%s)",
87
+ dedupedPackages.size,
88
+ [...dedupedPackages].join(","),
89
+ );
90
+ }
91
+ },
92
+
79
93
  resolveId(source, importer, options) {
80
94
  // Only intercept in the client environment
81
95
  if (this.environment?.name !== "client") return;
@@ -95,6 +109,8 @@ export function clientRefDedup(): Plugin {
95
109
  // Don't redirect packages that are excluded from optimization
96
110
  if (clientExclude.includes(packageName)) return;
97
111
 
112
+ if (debug) dedupedPackages.add(packageName);
113
+
98
114
  // Return a virtual module that re-exports via bare specifier
99
115
  return `\0rango:dedup/${packageName}`;
100
116
  },
@@ -1,6 +1,9 @@
1
1
  import type { Plugin } from "vite";
2
2
  import { relative } from "node:path";
3
3
  import { createHash } from "node:crypto";
4
+ import { createRangoDebugger, createCounter, NS } from "../debug.js";
5
+
6
+ const debug = createRangoDebugger(NS.transform);
4
7
 
5
8
  // Dev-mode client-reference key prefixes emitted by @vitejs/plugin-rsc
6
9
  const CLIENT_PKG_PROXY_PREFIX =
@@ -19,6 +22,17 @@ const FS_PREFIX = "/@fs/";
19
22
  * Returns the input unchanged if it doesn't match a known dev-mode pattern
20
23
  * (e.g., already a production hash).
21
24
  */
25
+ /**
26
+ * The production client-reference key hash: `sha256(relativeId).slice(0,12)`,
27
+ * matching @vitejs/plugin-rsc's `hashString`. Exported so the client-chunks
28
+ * strategy can hash a `clientChunks` callback's `meta.normalizedId` (already the
29
+ * project-root-relative id) and compare it against fallback hashes collected
30
+ * during discovery.
31
+ */
32
+ export function hashRefKey(relativeId: string): string {
33
+ return createHash("sha256").update(relativeId).digest("hex").slice(0, 12);
34
+ }
35
+
22
36
  export function computeProductionHash(
23
37
  projectRoot: string,
24
38
  refKey: string,
@@ -46,7 +60,7 @@ export function computeProductionHash(
46
60
  return refKey;
47
61
  }
48
62
 
49
- return createHash("sha256").update(toHash).digest("hex").slice(0, 12);
63
+ return hashRefKey(toHash);
50
64
  }
51
65
 
52
66
  // Regex to match registerClientReference() calls as emitted by @vitejs/plugin-rsc.
@@ -89,6 +103,7 @@ export function transformClientRefs(
89
103
  * regex replacement of Flight payloads.
90
104
  */
91
105
  export function hashClientRefs(projectRoot: string): Plugin {
106
+ const counter = createCounter(debug, "hash-client-refs");
92
107
  return {
93
108
  name: "@rangojs/router:hash-client-refs",
94
109
  // Run after the RSC plugin's transform (default enforce is normal)
@@ -96,10 +111,18 @@ export function hashClientRefs(projectRoot: string): Plugin {
96
111
  applyToEnvironment(env) {
97
112
  return env.name === "rsc";
98
113
  },
99
- transform(code, _id) {
100
- const result = transformClientRefs(code, projectRoot);
101
- if (result === null) return;
102
- return { code: result, map: null };
114
+ buildEnd() {
115
+ counter?.flush();
116
+ },
117
+ transform(code, id) {
118
+ const start = counter ? performance.now() : 0;
119
+ try {
120
+ const result = transformClientRefs(code, projectRoot);
121
+ if (result === null) return;
122
+ return { code: result, map: null };
123
+ } finally {
124
+ counter?.record(id, performance.now() - start);
125
+ }
103
126
  },
104
127
  };
105
128
  }
@@ -0,0 +1,23 @@
1
+ export interface LoaderResolveContext {
2
+ parentURL?: string;
3
+ conditions?: readonly string[];
4
+ importAttributes?: Record<string, string>;
5
+ }
6
+
7
+ export interface LoaderResolveResult {
8
+ shortCircuit?: boolean;
9
+ url: string;
10
+ format?: "module" | "commonjs" | "json" | "wasm" | null;
11
+ importAttributes?: Record<string, string>;
12
+ }
13
+
14
+ export type NextResolve = (
15
+ specifier: string,
16
+ context?: LoaderResolveContext,
17
+ ) => Promise<LoaderResolveResult>;
18
+
19
+ export function resolve(
20
+ specifier: string,
21
+ context: LoaderResolveContext,
22
+ nextResolve: NextResolve,
23
+ ): Promise<LoaderResolveResult>;
@@ -0,0 +1,76 @@
1
+ // Node ESM loader hook that resolves `cloudflare:*` imports to the same
2
+ // stub ESM the Vite transform produces for rewritten specifiers.
3
+ //
4
+ // Why both? The Vite transform (cloudflare-protocol-stub.ts) catches
5
+ // imports in modules that flow through Vite's plugin pipeline — covers
6
+ // user source and any node_modules package Vite fetches and transforms.
7
+ // But Vite/Rollup externalize certain packages (e.g. `partyserver`,
8
+ // which has `import { DurableObject, env } from "cloudflare:workers"`
9
+ // at its top level, and similar "workerd-native" libraries). Externalized
10
+ // modules bypass the transform: Rollup hands their resolution to Node's
11
+ // native ESM loader, which rejects URL-scheme specifiers. This loader
12
+ // hook registers via `module.register()` from `createTempRscServer` and
13
+ // intercepts `cloudflare:*` at Node's resolve layer — before the default
14
+ // loader throws ERR_UNSUPPORTED_ESM_URL_SCHEME.
15
+ //
16
+ // Lifecycle: the hook runs in a dedicated worker thread (Node ESM loader
17
+ // architecture) with its own globalThis. It cannot see the main thread's
18
+ // `__rango_build_env__` bridge, so the `env` export here is always `{}`.
19
+ // That's fine in practice — externalized libraries don't typically touch
20
+ // `env` at module top level; they read it at request time in workerd
21
+ // where the real module exists. Build-time prerender handlers in user
22
+ // source DO read `env`, but they flow through the Vite transform (which
23
+ // does bridge `env` from `getPlatformProxy()`), not through this loader.
24
+ //
25
+ // Keep STUBS in sync with cloudflare-protocol-stub.ts — both paths need
26
+ // to hand out the same base classes.
27
+
28
+ const CF_PREFIX = "cloudflare:";
29
+
30
+ const STUBS = {
31
+ "cloudflare:workers": `
32
+ export class DurableObject { constructor(_ctx, _env) {} }
33
+ export class WorkerEntrypoint { constructor(_ctx, _env) {} }
34
+ export class WorkflowEntrypoint { constructor(_ctx, _env) {} }
35
+ export class RpcTarget {}
36
+ export const env = {};
37
+ export default {};
38
+ `,
39
+ "cloudflare:email": `
40
+ export class EmailMessage { constructor(_from, _to, _raw) {} }
41
+ export default {};
42
+ `,
43
+ "cloudflare:sockets": `
44
+ export function connect() { return {}; }
45
+ export default {};
46
+ `,
47
+ "cloudflare:workflows": `
48
+ export class NonRetryableError extends Error {
49
+ constructor(message, name) { super(message); this.name = name ?? "NonRetryableError"; }
50
+ }
51
+ export default {};
52
+ `,
53
+ };
54
+
55
+ // Policy: unknown `cloudflare:*` specifiers resolve permissively to an
56
+ // empty default export rather than throwing. Same reasoning as
57
+ // cloudflare-protocol-stub.ts's FALLBACK_STUB — we prioritize
58
+ // dependency-graph resilience over strict validation, because third-party
59
+ // packages can pull `cloudflare:*` modules we haven't curated.
60
+ const FALLBACK_STUB = `export default {};\n`;
61
+
62
+ function dataUrlFor(specifier) {
63
+ const body = STUBS[specifier] ?? FALLBACK_STUB;
64
+ return "data:text/javascript;base64," + Buffer.from(body).toString("base64");
65
+ }
66
+
67
+ export async function resolve(specifier, context, nextResolve) {
68
+ if (specifier.startsWith(CF_PREFIX)) {
69
+ return {
70
+ shortCircuit: true,
71
+ url: dataUrlFor(specifier),
72
+ format: "module",
73
+ };
74
+ }
75
+ return nextResolve(specifier, context);
76
+ }
@@ -0,0 +1,214 @@
1
+ import type { Plugin } from "vite";
2
+
3
+ const VIRTUAL_PREFIX = "virtual:rango-cloudflare-stub-";
4
+ const NULL_PREFIX = "\0" + VIRTUAL_PREFIX;
5
+ const CF_PREFIX = "cloudflare:";
6
+
7
+ /**
8
+ * `globalThis` key the `cloudflare:workers` stub reads to populate its
9
+ * `env` export. Router discovery sets this to the resolved `buildEnv`
10
+ * proxy (from `wrangler.getPlatformProxy()` when `buildEnv: "auto"` is
11
+ * configured, or a user-supplied object otherwise) before importing the
12
+ * worker entry, and clears it after discovery disposes the proxy. When
13
+ * unset, the stub's `env` falls back to `{}`.
14
+ *
15
+ * Using `globalThis` is the only cross-module bridge that works here:
16
+ * the stub's `load` hook returns source text, not a live closure, but
17
+ * the stub module is evaluated in the same Node process as the
18
+ * discovery plugin — so reading a global at module-evaluation time
19
+ * reaches whatever the plugin assigned there. A symbol key would be
20
+ * cleaner in-process but awkward to name from the stub source.
21
+ *
22
+ * @internal
23
+ */
24
+ export const BUILD_ENV_GLOBAL_KEY = "__rango_build_env__";
25
+
26
+ const SOURCE_EXT_RE = /\.[mc]?[jt]sx?$/;
27
+
28
+ const IMPORT_NODE_TYPES = new Set([
29
+ "ImportDeclaration",
30
+ "ImportExpression",
31
+ "ExportNamedDeclaration",
32
+ "ExportAllDeclaration",
33
+ ]);
34
+
35
+ // Keep in sync with `STUBS` in cloudflare-protocol-loader-hook.mjs —
36
+ // both paths (Vite transform and Node loader) need to hand out the same
37
+ // classes. Unknown `cloudflare:*` modules fall back to an empty default
38
+ // export so third-party packages (e.g. the Cloudflare Agents SDK) can
39
+ // pull them into the graph without crashing discovery. Discovery only
40
+ // evaluates module top-level code — no handlers run — so missing named
41
+ // exports only fail if something does `class X extends Missing {}` at
42
+ // module scope, which is rare outside the already-stubbed classes.
43
+ const STUBS: Record<string, string> = {
44
+ "cloudflare:workers": `
45
+ export class DurableObject { constructor(_ctx, _env) {} }
46
+ export class WorkerEntrypoint { constructor(_ctx, _env) {} }
47
+ export class WorkflowEntrypoint { constructor(_ctx, _env) {} }
48
+ export class RpcTarget {}
49
+ export const env = globalThis[${JSON.stringify(BUILD_ENV_GLOBAL_KEY)}] ?? {};
50
+ export default {};
51
+ `,
52
+ "cloudflare:email": `
53
+ export class EmailMessage { constructor(_from, _to, _raw) {} }
54
+ export default {};
55
+ `,
56
+ "cloudflare:sockets": `
57
+ export function connect() { return {}; }
58
+ export default {};
59
+ `,
60
+ "cloudflare:workflows": `
61
+ export class NonRetryableError extends Error {
62
+ constructor(message, name) { super(message); this.name = name ?? "NonRetryableError"; }
63
+ }
64
+ export default {};
65
+ `,
66
+ };
67
+
68
+ // Policy: unknown `cloudflare:*` specifiers resolve permissively (empty
69
+ // default export) rather than throwing. We prioritize dependency-graph
70
+ // resilience over strict validation of user imports because third-party
71
+ // packages can pull `cloudflare:*` modules we haven't curated, and
72
+ // discovery should not fail just because those modules appear in the graph.
73
+ // Tradeoff: unsupported user-authored `cloudflare:*` imports may fail later
74
+ // with a generic JS/module error instead of a tailored rango-branded hint.
75
+ // The test below pins this behavior so dependency compatibility is not
76
+ // regressed accidentally.
77
+ const FALLBACK_STUB = `export default {};\n`;
78
+
79
+ interface AstNode {
80
+ type: string;
81
+ start?: number;
82
+ end?: number;
83
+ source?: AstNode | null;
84
+ value?: unknown;
85
+ [key: string]: unknown;
86
+ }
87
+
88
+ /**
89
+ * Stubs `cloudflare:*` imports for the discovery-time Node Vite server.
90
+ *
91
+ * Discovery only evaluates user module top-level code — it never invokes
92
+ * DurableObject / WorkerEntrypoint / Workflow handlers — so empty base
93
+ * classes are enough for `class X extends DurableObject {}` declarations
94
+ * to load in Node, where `cloudflare:*` is otherwise unresolvable.
95
+ *
96
+ * Interception point: a transform hook parses source with Rollup's
97
+ * plugin-context parser (`this.parse`) and rewrites only real import
98
+ * specifier spans (`import ... from "cloudflare:xxx"`,
99
+ * `import("cloudflare:xxx")`, `export ... from "cloudflare:xxx"`) to a
100
+ * plain virtual module name (`virtual:rango-cloudflare-stub-xxx`).
101
+ * This must be done in transform because Vite's module runner routes
102
+ * URL-scheme specifiers straight to Node's native ESM loader without
103
+ * consulting plugin `resolveId` hooks. Using the AST (instead of a
104
+ * text regex or a permissive lexer) guarantees that strings,
105
+ * comments, and template literals that merely contain import-like
106
+ * text are never mutated — the walker only looks at the four import
107
+ * node types.
108
+ *
109
+ * The transform runs on user source AND on compiled node_modules
110
+ * output: real-world CF packages (e.g. the Cloudflare Agents SDK)
111
+ * ship compiled JS that contains `import ... from "cloudflare:email"`
112
+ * and similar, so excluding node_modules would leave those imports
113
+ * unrewritten. Cost is small because the early exit (`code.includes`)
114
+ * skips files with no cloudflare: mention.
115
+ *
116
+ * The plugin intentionally runs at Vite's default ordering (no
117
+ * `enforce: "pre"`) so TS/JSX has already been compiled to plain JS
118
+ * by the time `this.parse` runs — acorn doesn't understand
119
+ * non-standard syntax.
120
+ *
121
+ * `cloudflare:workers`, `cloudflare:email`, `cloudflare:sockets`, and
122
+ * `cloudflare:workflows` each get curated stubs with the well-known
123
+ * symbols that appear in top-level `extends` positions. Any other
124
+ * `cloudflare:*` specifier falls back to an empty default export —
125
+ * discovery never executes the handlers, so an empty module is safe
126
+ * for anything the graph pulls in transitively.
127
+ *
128
+ * Only registered in the discovery temp server, not the user's runtime
129
+ * config.
130
+ * @internal
131
+ */
132
+ export function createCloudflareProtocolStubPlugin(): Plugin {
133
+ return {
134
+ name: "@rangojs/router:cloudflare-protocol-stub",
135
+ transform(code, id) {
136
+ const cleanId = id.split("?")[0] ?? id;
137
+ if (!SOURCE_EXT_RE.test(cleanId)) return null;
138
+ if (!code.includes(CF_PREFIX)) return null;
139
+
140
+ let ast: AstNode;
141
+ try {
142
+ ast = this.parse(code, { lang: "tsx" }) as unknown as AstNode;
143
+ } catch {
144
+ // Malformed source — let a downstream plugin surface the parse error.
145
+ return null;
146
+ }
147
+
148
+ const hits: Array<{ start: number; end: number; value: string }> = [];
149
+ walk(ast, (node) => {
150
+ if (!IMPORT_NODE_TYPES.has(node.type)) return;
151
+ const source = node.source;
152
+ if (!source || source.type !== "Literal") return;
153
+ if (typeof source.value !== "string") return;
154
+ if (!source.value.startsWith(CF_PREFIX)) return;
155
+ if (typeof source.start !== "number" || typeof source.end !== "number")
156
+ return;
157
+ hits.push({
158
+ start: source.start,
159
+ end: source.end,
160
+ value: source.value,
161
+ });
162
+ });
163
+
164
+ if (hits.length === 0) return null;
165
+
166
+ // Rewrite from last to first so earlier offsets stay valid. `start`/
167
+ // `end` span the full literal including quotes, so we re-emit the
168
+ // same quote character around the new specifier.
169
+ hits.sort((a, b) => b.start - a.start);
170
+ let out = code;
171
+ for (const hit of hits) {
172
+ const submodule = hit.value.slice(CF_PREFIX.length);
173
+ const quote = code[hit.start] === "'" ? "'" : '"';
174
+ out =
175
+ out.slice(0, hit.start) +
176
+ quote +
177
+ VIRTUAL_PREFIX +
178
+ submodule +
179
+ quote +
180
+ out.slice(hit.end);
181
+ }
182
+ return { code: out, map: null };
183
+ },
184
+ resolveId(id) {
185
+ if (id.startsWith(VIRTUAL_PREFIX)) {
186
+ return "\0" + id;
187
+ }
188
+ return null;
189
+ },
190
+ load(id) {
191
+ if (!id.startsWith(NULL_PREFIX)) return null;
192
+ const submodule = id.slice(NULL_PREFIX.length);
193
+ const specifier = CF_PREFIX + submodule;
194
+ return STUBS[specifier] ?? FALLBACK_STUB;
195
+ },
196
+ };
197
+ }
198
+
199
+ function walk(node: unknown, visit: (n: AstNode) => void): void {
200
+ if (!node || typeof node !== "object") return;
201
+ if (Array.isArray(node)) {
202
+ for (const child of node) walk(child, visit);
203
+ return;
204
+ }
205
+ const n = node as AstNode;
206
+ if (typeof n.type !== "string") return;
207
+ visit(n);
208
+ for (const key in n) {
209
+ if (key === "loc" || key === "start" || key === "end" || key === "range") {
210
+ continue;
211
+ }
212
+ walk(n[key], visit);
213
+ }
214
+ }