@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,88 @@
1
+ import type { Connect } from "vite";
2
+
3
+ /**
4
+ * Bake the resolved INTERNAL_RANGO_DEBUG value into the router's `internal-debug`
5
+ * module so the flag reaches the CLIENT debug logs by just setting the env var.
6
+ *
7
+ * internal-debug.ts normally reads the flag via `typeof __RANGO_DEBUG__`, a Vite
8
+ * define. That delivery is unreliable on the client: in dev Vite ships the define
9
+ * only as an injected global whose presence varies across consumer setups, so the
10
+ * module can fall through to `process.env` (undefined in the browser) and the FE
11
+ * debug flag silently stays false while the server logs work. A Vite `transform`
12
+ * runs on the module regardless of how (or whether) the define is delivered, in
13
+ * both dev and build and for every environment, so the discovery plugin uses this
14
+ * to replace the module with the resolved literal.
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.
36
+ * Returns null for any module that is not the router's internal-debug module.
37
+ */
38
+ export function injectClientDebugFlag(
39
+ id: string,
40
+ ): { code: string; map: null } | null {
41
+ // Cheap early-out: this hook runs on every module in every environment.
42
+ if (!isRouterInternalDebugId(id)) return null;
43
+ // Emit the whole module: internal-debug.ts has a single export, kept in sync.
44
+ return {
45
+ code: `export const INTERNAL_RANGO_DEBUG = ${!!process.env.INTERNAL_RANGO_DEBUG};\n`,
46
+ map: null,
47
+ };
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
+ }
@@ -46,7 +46,11 @@ import { existsSync } from "node:fs";
46
46
  import { resolve, join } from "node:path";
47
47
  import { createRequire } from "node:module";
48
48
  import { pathToFileURL } from "node:url";
49
- import type { RangoVercelOptions } from "../plugin-types.js";
49
+ import { escapeRegExp } from "../../regex-escape.js";
50
+ import type {
51
+ RangoVercelOptions,
52
+ VercelPresetOptions,
53
+ } from "../plugin-types.js";
50
54
 
51
55
  // Minimal structural types for the esbuild API we use, resolved dynamically from
52
56
  // the app so @rangojs/router does not depend on esbuild's type package.
@@ -100,14 +104,106 @@ export function assertVercelNodeRuntime(runtime: string | undefined): void {
100
104
  }
101
105
  }
102
106
 
107
+ /**
108
+ * The function name becomes a `.func` directory segment and the config.json
109
+ * route `dest` (`/${functionName}`). An empty or space/slash-containing value
110
+ * would land raw in both, producing a broken function path, so restrict it to a
111
+ * safe single path segment and fail loudly rather than emit unroutable output.
112
+ */
113
+ export function assertValidVercelFunctionName(functionName: string): void {
114
+ if (!/^[A-Za-z0-9._-]+$/.test(functionName)) {
115
+ throw new Error(
116
+ `[rango] preset "vercel": invalid functionName ${JSON.stringify(
117
+ functionName,
118
+ )}. Use letters, digits, ".", "_" or "-" only (it becomes the .func directory and the routing dest).`,
119
+ );
120
+ }
121
+ }
122
+
123
+ /** The `.vc-config.json` body: a Node serverless function with streaming. */
124
+ export function buildVercelVcConfig(
125
+ vercel: VercelPresetOptions,
126
+ ): Record<string, unknown> {
127
+ const vcConfig: Record<string, unknown> = {
128
+ runtime: vercel.runtime ?? "nodejs22.x",
129
+ handler: "index.mjs",
130
+ launcherType: "Nodejs",
131
+ shouldAddHelpers: false,
132
+ supportsResponseStreaming: true,
133
+ maxDuration: vercel.maxDuration ?? 30,
134
+ };
135
+ if (vercel.memory != null) vcConfig.memory = vercel.memory;
136
+ if (vercel.regions != null) vcConfig.regions = vercel.regions;
137
+ return vcConfig;
138
+ }
139
+
140
+ /**
141
+ * The Build Output API v3 `config.json` body. Routes, in order: long-cache the
142
+ * content-hashed assets under `${assetsDir}/` (safe to serve `immutable`;
143
+ * without this Vercel serves them `max-age=0, must-revalidate` and browsers
144
+ * re-request every asset on each visit), then the filesystem handler, then
145
+ * everything to the function. The header route uses `continue: true` so it falls
146
+ * through to the filesystem handler that actually serves the file.
147
+ *
148
+ * Route `src` values are REGEXES on Vercel, so the prefix is regex-escaped
149
+ * (an unescaped `assetsDir: "static.v2"` would immutable-stamp any
150
+ * `/static?v2/...` path, including function-rendered pages). An empty
151
+ * assetsDir (assets at the outDir root) gets no header route at all: there is
152
+ * no prefix separating hashed from non-hashed files, so fall back to Vercel's
153
+ * safe default headers. Two accepted edges, matching what other framework
154
+ * adapters (Astro, SvelteKit) emit: files a user places in
155
+ * `public/${assetsDir}/` land under the same prefix and are also stamped
156
+ * immutable (assemble() warns about the collision), and a request for a
157
+ * MISSING asset falls through to the function whose 404 carries the header.
158
+ */
159
+ export function buildVercelOutputConfig(
160
+ functionName: string,
161
+ assetsDir: string,
162
+ ): { version: number; routes: unknown[] } {
163
+ const assetsPrefix = assetsDir.replace(/^\/+|\/+$/g, "");
164
+ const assetHeaderRoute = assetsPrefix
165
+ ? [
166
+ {
167
+ src: `/${escapeRegExp(assetsPrefix)}/(.*)`,
168
+ headers: { "cache-control": "public, max-age=31536000, immutable" },
169
+ continue: true,
170
+ },
171
+ ]
172
+ : [];
173
+ return {
174
+ version: 3,
175
+ routes: [
176
+ ...assetHeaderRoute,
177
+ { handle: "filesystem" },
178
+ { src: "/(.*)", dest: `/${functionName}` },
179
+ ],
180
+ };
181
+ }
182
+
103
183
  async function assemble(
104
184
  root: string,
105
185
  options: RangoVercelOptions,
186
+ assetsDir: string,
187
+ publicDir: string,
106
188
  ): Promise<void> {
107
189
  const vercel = options.vercel ?? {};
108
190
  // Validate config before touching the build output.
109
191
  assertVercelNodeRuntime(vercel.runtime);
110
192
 
193
+ // Files in public/<assetsDir>/ are copied into the same output prefix as the
194
+ // content-hashed build assets, so the immutable cache-control route stamps
195
+ // them too -- replacing such a file after a deploy never reaches returning
196
+ // visitors. Warn instead of silently pinning.
197
+ const assetsPrefix = assetsDir.replace(/^\/+|\/+$/g, "");
198
+ if (publicDir && assetsPrefix && existsSync(join(publicDir, assetsPrefix))) {
199
+ console.warn(
200
+ `[rango] preset "vercel": ${join(publicDir, assetsPrefix)} exists. ` +
201
+ `Files under public/${assetsPrefix}/ share the /${assetsPrefix}/ URL prefix ` +
202
+ `with hashed build assets and will be served with a one-year immutable ` +
203
+ `cache-control header. Move un-hashed public files out of "${assetsPrefix}/".`,
204
+ );
205
+ }
206
+
111
207
  const dist = join(root, "dist");
112
208
  for (const dir of ["client", "rsc", "ssr"]) {
113
209
  if (!existsSync(join(dist, dir))) {
@@ -117,6 +213,7 @@ async function assemble(
117
213
  }
118
214
  }
119
215
  const functionName = vercel.functionName ?? "index";
216
+ assertValidVercelFunctionName(functionName);
120
217
  const out = join(root, ".vercel", "output");
121
218
  const funcDir = join(out, "functions", `${functionName}.func`);
122
219
 
@@ -235,35 +332,16 @@ async function assemble(
235
332
  );
236
333
 
237
334
  // 4. Function config: Node serverless with response streaming.
238
- const vcConfig: Record<string, unknown> = {
239
- runtime: vercel.runtime ?? "nodejs22.x",
240
- handler: "index.mjs",
241
- launcherType: "Nodejs",
242
- shouldAddHelpers: false,
243
- supportsResponseStreaming: true,
244
- maxDuration: vercel.maxDuration ?? 30,
245
- };
246
- if (vercel.memory != null) vcConfig.memory = vercel.memory;
247
- if (vercel.regions != null) vcConfig.regions = vercel.regions;
248
335
  await writeFile(
249
336
  join(funcDir, ".vc-config.json"),
250
- JSON.stringify(vcConfig, null, 2) + "\n",
337
+ JSON.stringify(buildVercelVcConfig(vercel), null, 2) + "\n",
251
338
  );
252
339
 
253
- // 5. Routing: filesystem (static/) first, then everything to the function.
340
+ // 5. Routing config (immutable assets -> filesystem -> function).
254
341
  await writeFile(
255
342
  join(out, "config.json"),
256
- JSON.stringify(
257
- {
258
- version: 3,
259
- routes: [
260
- { handle: "filesystem" },
261
- { src: "/(.*)", dest: `/${functionName}` },
262
- ],
263
- },
264
- null,
265
- 2,
266
- ) + "\n",
343
+ JSON.stringify(buildVercelOutputConfig(functionName, assetsDir), null, 2) +
344
+ "\n",
267
345
  );
268
346
 
269
347
  console.log(
@@ -274,11 +352,22 @@ async function assemble(
274
352
  export function createVercelOutputPlugin(options: RangoVercelOptions): Plugin {
275
353
  let root = process.cwd();
276
354
  let isBuild = false;
355
+ // The client build's assetsDir (Vite default "assets"); used to scope the
356
+ // immutable cache-control route to the content-hashed asset output.
357
+ let assetsDir = "assets";
358
+ // Resolved publicDir ("" when disabled); used to warn when public/<assetsDir>
359
+ // exists, since its un-hashed files share the immutable-header prefix.
360
+ let publicDir = "";
277
361
  return {
278
362
  name: "@rangojs/router:vercel-output",
279
363
  configResolved(config) {
280
364
  root = resolve(config.root);
281
365
  isBuild = config.command === "build";
366
+ assetsDir =
367
+ config.environments?.client?.build?.assetsDir ??
368
+ config.build?.assetsDir ??
369
+ "assets";
370
+ publicDir = config.publicDir || "";
282
371
  },
283
372
  // buildApp runs once after the whole multi-environment build (rsc, client,
284
373
  // ssr), so dist/ is complete here. closeBundle is unusable for this: it
@@ -288,7 +377,7 @@ export function createVercelOutputPlugin(options: RangoVercelOptions): Plugin {
288
377
  order: "post",
289
378
  async handler() {
290
379
  if (!isBuild) return;
291
- await assemble(root, options);
380
+ await assemble(root, options, assetsDir, publicDir);
292
381
  },
293
382
  },
294
383
  };
@@ -2,12 +2,25 @@ import type { Plugin } from "vite";
2
2
  import { resolve } from "node:path";
3
3
  import * as Vite from "vite";
4
4
  import { resolveRscEntryFromConfig } from "../utils/shared-utils.js";
5
+ import { RSC_ENTRY_BOOTSTRAP_IMPORTS } from "./virtual-entries.js";
5
6
 
6
7
  /**
7
- * Plugin that auto-injects VERSION and routes-manifest into custom entry.rsc files.
8
- * If a custom entry.rsc file uses createRSCHandler but doesn't pass version,
9
- * this transform adds the import and property automatically.
10
- * Also ensures the routes-manifest virtual module is always imported.
8
+ * Plugin that auto-injects VERSION, routes-manifest, and loader-manifest into
9
+ * custom entry.rsc files. If a custom entry.rsc file uses createRSCHandler but
10
+ * doesn't pass version, this transform adds the import and property
11
+ * automatically. It also ensures the routes-manifest and loader-manifest
12
+ * virtual modules are always imported.
13
+ *
14
+ * The loader-manifest import is what makes fetchable loaders resolvable on a
15
+ * custom worker entry. The virtual RSC entry (getVirtualEntryRSC) imports the
16
+ * loader manifest itself, but a hand-written entry (e.g. a Cloudflare
17
+ * worker.rsc.tsx) does not — so without this injection, setLoaderImports() is
18
+ * never bundled, lazyLoaderImports stays null at runtime, and a fetchable
19
+ * loader that is never imported by the server graph (not registered via
20
+ * loader(), reachable only through a client component) cannot be found by the
21
+ * _rsc_loader endpoint in production. Dev still works because it resolves
22
+ * loaders by parsing their id into a file path; only production relied on the
23
+ * manifest, hence the production-only failure this fixes.
11
24
  * @internal
12
25
  */
13
26
  export function createVersionInjectorPlugin(
@@ -36,9 +49,11 @@ export function createVersionInjectorPlugin(
36
49
  return null;
37
50
  }
38
51
 
39
- const prepend: string[] = [
40
- `import "virtual:rsc-router/routes-manifest";`,
41
- ];
52
+ // Same startup bootstrap imports the generated virtual RSC entry uses,
53
+ // from the single shared list so the two paths cannot drift.
54
+ const prepend: string[] = RSC_ENTRY_BOOTSTRAP_IMPORTS.map(
55
+ (id) => `import "${id}";`,
56
+ );
42
57
 
43
58
  let newCode = code;
44
59
  const needsVersion =
@@ -38,9 +38,14 @@ initializeApp().catch(console.error);
38
38
 
39
39
  export const VIRTUAL_ENTRY_SSR: string = `
40
40
  import { createFromReadableStream } from "@rangojs/router/internal/deps/ssr";
41
- import { renderToReadableStream } from "react-dom/server.edge";
41
+ import { renderToReadableStream, resume } from "react-dom/server.edge";
42
+ import { prerender } from "react-dom/static.edge";
42
43
  import { injectRSCPayload } from "@rangojs/router/internal/deps/html-stream-server";
43
- import { createSSRHandler } from "@rangojs/router/ssr";
44
+ import {
45
+ createSSRHandler,
46
+ createShellCaptureHandler,
47
+ createShellResumeHandler,
48
+ } from "@rangojs/router/ssr";
44
49
 
45
50
  export const renderHTML = createSSRHandler({
46
51
  createFromReadableStream,
@@ -49,9 +54,54 @@ export const renderHTML = createSSRHandler({
49
54
  loadBootstrapScriptContent: () =>
50
55
  import.meta.viteRsc.loadBootstrapScriptContent("index"),
51
56
  });
57
+
58
+ export const captureShellHTML = createShellCaptureHandler({
59
+ createFromReadableStream,
60
+ renderToReadableStream,
61
+ injectRSCPayload,
62
+ prerender,
63
+ resume,
64
+ loadBootstrapScriptContent: () =>
65
+ import.meta.viteRsc.loadBootstrapScriptContent("index"),
66
+ });
67
+
68
+ export const resumeShellHTML = createShellResumeHandler({
69
+ createFromReadableStream,
70
+ renderToReadableStream,
71
+ injectRSCPayload,
72
+ prerender,
73
+ resume,
74
+ loadBootstrapScriptContent: () =>
75
+ import.meta.viteRsc.loadBootstrapScriptContent("index"),
76
+ });
52
77
  `.trim();
53
78
 
79
+ /**
80
+ * Virtual modules an RSC entry must import at startup to register the data the
81
+ * request handler needs before the first request arrives:
82
+ *
83
+ * - routes-manifest: the pre-generated route map so href()/matching work on a
84
+ * cold start (full map in build, in-memory no-op in dev).
85
+ * - loader-manifest: setLoaderImports() for fetchable loaders. Critical for
86
+ * serverless/multi-process deployments and for fetchable loaders reachable
87
+ * only through a client component — without it those loaders are never
88
+ * registered for the _rsc_loader endpoint and fail in production.
89
+ *
90
+ * Single source of truth: both the generated virtual RSC entry below and the
91
+ * custom-entry injector (version-injector) consume this list, so a new
92
+ * bootstrap manifest cannot be added to one path and forgotten on the other.
93
+ * That exact drift (loader-manifest present here but missing from the injector)
94
+ * is what left fetchable loaders unresolved on custom worker entries.
95
+ */
96
+ export const RSC_ENTRY_BOOTSTRAP_IMPORTS: readonly string[] = [
97
+ "virtual:rsc-router/routes-manifest",
98
+ "virtual:rsc-router/loader-manifest",
99
+ ];
100
+
54
101
  export function getVirtualEntryRSC(routerPath: string): string {
102
+ const bootstrapImports = RSC_ENTRY_BOOTSTRAP_IMPORTS.map(
103
+ (id) => `import "${id}";`,
104
+ ).join("\n");
55
105
  return `
56
106
  import {
57
107
  renderToReadableStream,
@@ -65,15 +115,9 @@ import { router } from "${routerPath}";
65
115
  import { createRSCHandler } from "@rangojs/router/internal/rsc-handler";
66
116
  import { VERSION } from "@rangojs/router:version";
67
117
 
68
- // Import loader manifest to ensure all fetchable loaders are registered at startup
69
- // This is critical for serverless/multi-process deployments where the loader module
70
- // might not be imported before a GET request arrives
71
- import "virtual:rsc-router/loader-manifest";
72
-
73
- // Import pre-generated route manifest so href() works immediately on cold start.
74
- // In build mode, this contains the full route map generated at build time.
75
- // In dev mode, this is a no-op (manifest is populated in-memory by the discovery plugin).
76
- import "virtual:rsc-router/routes-manifest";
118
+ // Startup bootstrap imports (routes + loader manifests). See
119
+ // RSC_ENTRY_BOOTSTRAP_IMPORTS the same list the custom-entry injector uses.
120
+ ${bootstrapImports}
77
121
 
78
122
  // Lazily create the handler on first request so that ESM live bindings
79
123
  // have resolved by the time we read \`router\`. During HMR the module may
@@ -109,7 +153,7 @@ export default function handler(request, env) {
109
153
  export function getVirtualEntryRSCHost(hostEntryPath: string): string {
110
154
  return `
111
155
  import * as __hostEntry from "${hostEntryPath}";
112
- import { NoRouteMatchError } from "@rangojs/router/host";
156
+ import { isNoRouteMatchError } from "@rangojs/router/host";
113
157
 
114
158
  // Register every sub-app's fetchable loaders + route manifests at startup, same
115
159
  // as the single-router entry. Discovery's host fallback populates these for all
@@ -119,11 +163,12 @@ import "virtual:rsc-router/routes-manifest";
119
163
 
120
164
  // The host entry module must export the HostRouter instance (createHostRouter()),
121
165
  // as a default export or a named \`hostRouter\`/\`router\` export. A Cloudflare-style
122
- // \`export default { fetch }\` object is not a HostRouter and is rejected here.
166
+ // \`export default { fetch }\` object is not a HostRouter and is rejected (on first
167
+ // request; see the lazy resolution below).
123
168
  // We require BOTH .match() and .host(): a regular createRouter() also exposes
124
169
  // .match(), so matching on .match() alone would accept an ordinary router and then
125
170
  // return its MatchResult (not a Response) at runtime. .host() is unique to a
126
- // HostRouter, so it disambiguates a mistaken \`hostRouter\` path at build time.
171
+ // HostRouter, so it disambiguates a mistaken \`hostRouter\` path.
127
172
  // Exports are read dynamically (m[name]) so Rollup does not emit IMPORT_IS_UNDEFINED
128
173
  // warnings for the named exports a default-only host module legitimately omits.
129
174
  const __resolveHostRouter = (m) => {
@@ -138,13 +183,14 @@ const __resolveHostRouter = (m) => {
138
183
  }
139
184
  return undefined;
140
185
  };
141
- const hostRouter = __resolveHostRouter(__hostEntry);
142
186
 
143
- if (!hostRouter) {
144
- throw new Error(
145
- "[rango] The host entry (${hostEntryPath}) must export a HostRouter instance (createHostRouter()) for the node/vercel preset: a default export, or a named 'hostRouter'/'router' export. An ordinary createRouter() is not a host router (it has no .host()), and a Cloudflare-style 'export default { fetch }' object is not supported on this preset."
146
- );
147
- }
187
+ // Resolve + validate the HostRouter lazily on first request, mirroring the
188
+ // single-router entry's \`_handler\`. During HMR the host module can re-evaluate
189
+ // before its createHostRouter() export has resolved; validating at module-
190
+ // evaluation time would then throw a spurious "must export a HostRouter" error
191
+ // overlay for a perfectly valid app. Resolving on first request lets the live
192
+ // binding settle first.
193
+ let _hostRouter;
148
194
 
149
195
  // input = { env, ctx } from the launcher / node server. The host router threads
150
196
  // it unchanged to each matched sub-app's handler and cache factory.
@@ -152,10 +198,22 @@ if (!hostRouter) {
152
198
  // an unmatched host into a response: catch NoRouteMatchError and return 404
153
199
  // (parity with the documented Cloudflare catch). Other errors propagate.
154
200
  export default async function handler(request, input) {
201
+ if (!_hostRouter) {
202
+ _hostRouter = __resolveHostRouter(__hostEntry);
203
+ if (!_hostRouter) {
204
+ throw new Error(
205
+ "[rango] The host entry (${hostEntryPath}) must export a HostRouter instance (createHostRouter()) for the node/vercel preset: a default export, or a named 'hostRouter'/'router' export. An ordinary createRouter() is not a host router (it has no .host()), and a Cloudflare-style 'export default { fetch }' object is not supported on this preset."
206
+ );
207
+ }
208
+ }
155
209
  try {
156
- return await hostRouter.match(request, input);
210
+ return await _hostRouter.match(request, input);
157
211
  } catch (err) {
158
- if (err instanceof NoRouteMatchError) {
212
+ // isNoRouteMatchError also matches by name: a workspace with a duplicated
213
+ // @rangojs/router copy can throw a NoRouteMatchError whose prototype differs
214
+ // from this module's import, so a bare instanceof would turn an
215
+ // unmatched-host 404 into a 500.
216
+ if (isNoRouteMatchError(err)) {
159
217
  return new Response("Not Found", { status: 404 });
160
218
  }
161
219
  throw err;
package/src/vite/rango.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type PluginOption } from "vite";
2
- import { readFileSync } from "node:fs";
2
+ import { readFileSync, existsSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
4
  import { exposeActionId } from "./plugins/expose-action-id.js";
5
5
  import {
@@ -23,6 +23,7 @@ import { createVersionPlugin } from "./plugins/version-plugin.js";
23
23
  import {
24
24
  sharedRolldownOptions,
25
25
  createVirtualEntriesPlugin,
26
+ normalizeHostRouterEntry,
26
27
  onwarn,
27
28
  getManualChunks,
28
29
  } from "./utils/shared-utils.js";
@@ -266,7 +267,11 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
266
267
 
267
268
  // 1. Explicit host entry wins: serve the createHostRouter() instance.
268
269
  if (explicitHostRouter) {
269
- routerRef.path = explicitHostRouter.replaceAll("\\", "/");
270
+ routerRef.path = normalizeHostRouterEntry(
271
+ explicitHostRouter,
272
+ root,
273
+ existsSync,
274
+ );
270
275
  routerRef.kind = "host";
271
276
  return;
272
277
  }
@@ -279,7 +284,16 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
279
284
  // hostRouter.match()).
280
285
  const hostCandidates = findHostRouterFiles(root);
281
286
  if (hostCandidates.length === 1) {
282
- routerRef.path = toRootRelative(hostCandidates[0]);
287
+ const hostPath = toRootRelative(hostCandidates[0]);
288
+ // Auto-detection preempts single-router discovery, so name the winning
289
+ // file: a stale prototype still containing `createHostRouter(` would
290
+ // otherwise silently become the served entry in place of the app.
291
+ // eslint-disable-next-line no-console
292
+ console.info(
293
+ `[rango] Serving host router entry ${hostPath} (auto-detected). ` +
294
+ `Set the \`hostRouter\` option to override.`,
295
+ );
296
+ routerRef.path = hostPath;
283
297
  routerRef.kind = "host";
284
298
  return;
285
299
  }
@@ -335,21 +349,17 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
335
349
  // into the rsc + ssr builds instead of externalizing them (the node
336
350
  // default, which only works because `vite preview` runs where
337
351
  // node_modules exists). node: builtins stay external automatically.
352
+ //
353
+ // BUILD ONLY. In `vite dev` this must NOT apply: noExternal forces every
354
+ // server dependency through the RSC/SSR dev module runners, which cannot
355
+ // load many CJS packages (pg, mysql2, most SDKs, @vercel/functions) and
356
+ // crashes the dev server -- while the same app runs fine under
357
+ // preset: "node" and in the production build. Gating on `build` restores
358
+ // node-preset dev semantics (deps externalized to Node's require).
338
359
  const vercelServerEnv =
339
- preset === "vercel"
360
+ preset === "vercel" && configEnv.command === "build"
340
361
  ? { resolve: { noExternal: true as const } }
341
362
  : undefined;
342
- // Dev only: @vercel/functions is a CJS package (consumed at its root for
343
- // getCache/waitUntil) whose nested internal requires vite's RSC + SSR dev
344
- // module runners cannot load directly, so a vercel app using
345
- // VercelCacheStore crashes on `vite dev`. Pre-bundle it for the server
346
- // environments (the production build bundles it via noExternal above, so
347
- // this is serve-only). The preset already requires @vercel/functions at
348
- // build time, so the unconditional include matches the existing contract.
349
- const vercelServerInclude =
350
- preset === "vercel" && configEnv.command === "serve"
351
- ? ["@vercel/functions"]
352
- : [];
353
363
  return {
354
364
  ...(vercelDefine ? { define: vercelDefine } : {}),
355
365
  optimizeDeps: {
@@ -407,7 +417,6 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
407
417
  nested(
408
418
  "@vitejs/plugin-rsc/vendor/react-server-dom/client.edge",
409
419
  ),
410
- ...vercelServerInclude,
411
420
  ],
412
421
  exclude: excludeDeps,
413
422
  rolldownOptions: sharedRolldownOptions,
@@ -424,7 +433,6 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
424
433
  nested(
425
434
  "@vitejs/plugin-rsc/vendor/react-server-dom/server.edge",
426
435
  ),
427
- ...vercelServerInclude,
428
436
  ],
429
437
  // Vite 8 does not propagate the top-level optimizeDeps.exclude
430
438
  // (set in config()) to non-client envs, so the rsc env must set
@@ -532,8 +540,10 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
532
540
  );
533
541
 
534
542
  // Vercel preset: assemble .vercel/output from dist/ after the build. Pushed
535
- // last so its (ssr-gated) closeBundle runs after the discovery plugin's
536
- // rsc-env postprocess and after every environment has been written.
543
+ // last so its buildApp (order "post") hook runs after the discovery plugin's
544
+ // rsc-env postprocess. buildApp fires once after the whole multi-environment
545
+ // build, so dist/ is complete (closeBundle is unusable here -- it fires per
546
+ // environment, twice for ssr; see the plugin's own note).
537
547
  if (preset === "vercel") {
538
548
  plugins.push(
539
549
  createVercelOutputPlugin(resolvedOptions as RangoVercelOptions),