@rangojs/router 0.0.0-experimental.8a4d0430 → 0.0.0-experimental.8bcfea43

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 (174) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +126 -38
  3. package/dist/bin/rango.js +138 -50
  4. package/dist/vite/index.js +1171 -461
  5. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  6. package/package.json +19 -16
  7. package/skills/breadcrumbs/SKILL.md +3 -1
  8. package/skills/cache-guide/SKILL.md +32 -0
  9. package/skills/caching/SKILL.md +45 -4
  10. package/skills/handler-use/SKILL.md +362 -0
  11. package/skills/hooks/SKILL.md +28 -20
  12. package/skills/intercept/SKILL.md +20 -0
  13. package/skills/layout/SKILL.md +22 -0
  14. package/skills/links/SKILL.md +91 -17
  15. package/skills/loader/SKILL.md +88 -45
  16. package/skills/middleware/SKILL.md +34 -3
  17. package/skills/migrate-nextjs/SKILL.md +560 -0
  18. package/skills/migrate-react-router/SKILL.md +765 -0
  19. package/skills/parallel/SKILL.md +185 -0
  20. package/skills/prerender/SKILL.md +110 -68
  21. package/skills/rango/SKILL.md +24 -22
  22. package/skills/response-routes/SKILL.md +8 -0
  23. package/skills/route/SKILL.md +55 -0
  24. package/skills/router-setup/SKILL.md +87 -2
  25. package/skills/streams-and-websockets/SKILL.md +283 -0
  26. package/skills/typesafety/SKILL.md +13 -1
  27. package/src/__internal.ts +1 -1
  28. package/src/browser/app-shell.ts +52 -0
  29. package/src/browser/app-version.ts +14 -0
  30. package/src/browser/event-controller.ts +5 -0
  31. package/src/browser/navigation-bridge.ts +90 -16
  32. package/src/browser/navigation-client.ts +167 -59
  33. package/src/browser/navigation-store.ts +68 -9
  34. package/src/browser/navigation-transaction.ts +11 -9
  35. package/src/browser/partial-update.ts +113 -17
  36. package/src/browser/prefetch/cache.ts +184 -16
  37. package/src/browser/prefetch/fetch.ts +180 -33
  38. package/src/browser/prefetch/policy.ts +6 -0
  39. package/src/browser/prefetch/queue.ts +123 -20
  40. package/src/browser/prefetch/resource-ready.ts +77 -0
  41. package/src/browser/rango-state.ts +53 -13
  42. package/src/browser/react/Link.tsx +81 -9
  43. package/src/browser/react/NavigationProvider.tsx +89 -14
  44. package/src/browser/react/context.ts +7 -2
  45. package/src/browser/react/use-handle.ts +9 -58
  46. package/src/browser/react/use-navigation.ts +22 -2
  47. package/src/browser/react/use-params.ts +11 -1
  48. package/src/browser/react/use-router.ts +29 -9
  49. package/src/browser/rsc-router.tsx +168 -65
  50. package/src/browser/scroll-restoration.ts +41 -42
  51. package/src/browser/segment-reconciler.ts +36 -9
  52. package/src/browser/server-action-bridge.ts +8 -6
  53. package/src/browser/types.ts +49 -5
  54. package/src/build/generate-manifest.ts +6 -6
  55. package/src/build/generate-route-types.ts +3 -0
  56. package/src/build/route-trie.ts +50 -24
  57. package/src/build/route-types/include-resolution.ts +8 -1
  58. package/src/build/route-types/router-processing.ts +223 -74
  59. package/src/build/route-types/scan-filter.ts +8 -1
  60. package/src/cache/cache-runtime.ts +15 -11
  61. package/src/cache/cache-scope.ts +48 -7
  62. package/src/cache/cf/cf-cache-store.ts +455 -15
  63. package/src/cache/cf/index.ts +5 -1
  64. package/src/cache/document-cache.ts +17 -7
  65. package/src/cache/index.ts +1 -0
  66. package/src/cache/taint.ts +55 -0
  67. package/src/client.tsx +84 -230
  68. package/src/context-var.ts +72 -2
  69. package/src/debug.ts +2 -2
  70. package/src/handle.ts +40 -0
  71. package/src/index.rsc.ts +6 -1
  72. package/src/index.ts +49 -6
  73. package/src/outlet-context.ts +1 -1
  74. package/src/prerender/store.ts +5 -4
  75. package/src/prerender.ts +138 -77
  76. package/src/response-utils.ts +28 -0
  77. package/src/reverse.ts +27 -2
  78. package/src/route-definition/dsl-helpers.ts +240 -40
  79. package/src/route-definition/helpers-types.ts +67 -19
  80. package/src/route-definition/index.ts +3 -0
  81. package/src/route-definition/redirect.ts +11 -3
  82. package/src/route-definition/resolve-handler-use.ts +155 -0
  83. package/src/route-map-builder.ts +7 -1
  84. package/src/route-types.ts +18 -0
  85. package/src/router/content-negotiation.ts +100 -1
  86. package/src/router/find-match.ts +4 -2
  87. package/src/router/handler-context.ts +101 -25
  88. package/src/router/intercept-resolution.ts +11 -4
  89. package/src/router/lazy-includes.ts +10 -7
  90. package/src/router/loader-resolution.ts +159 -21
  91. package/src/router/logging.ts +5 -2
  92. package/src/router/manifest.ts +31 -16
  93. package/src/router/match-api.ts +127 -192
  94. package/src/router/match-middleware/background-revalidation.ts +30 -2
  95. package/src/router/match-middleware/cache-lookup.ts +94 -17
  96. package/src/router/match-middleware/cache-store.ts +53 -10
  97. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  98. package/src/router/match-middleware/segment-resolution.ts +61 -5
  99. package/src/router/match-result.ts +104 -10
  100. package/src/router/metrics.ts +6 -1
  101. package/src/router/middleware-types.ts +8 -30
  102. package/src/router/middleware.ts +36 -10
  103. package/src/router/navigation-snapshot.ts +182 -0
  104. package/src/router/pattern-matching.ts +60 -9
  105. package/src/router/prerender-match.ts +110 -10
  106. package/src/router/preview-match.ts +30 -102
  107. package/src/router/request-classification.ts +310 -0
  108. package/src/router/route-snapshot.ts +245 -0
  109. package/src/router/router-context.ts +6 -1
  110. package/src/router/router-interfaces.ts +36 -4
  111. package/src/router/router-options.ts +37 -11
  112. package/src/router/segment-resolution/fresh.ts +198 -20
  113. package/src/router/segment-resolution/helpers.ts +29 -24
  114. package/src/router/segment-resolution/loader-cache.ts +1 -0
  115. package/src/router/segment-resolution/revalidation.ts +438 -300
  116. package/src/router/segment-wrappers.ts +2 -0
  117. package/src/router/trie-matching.ts +10 -4
  118. package/src/router/types.ts +1 -0
  119. package/src/router/url-params.ts +49 -0
  120. package/src/router.ts +60 -8
  121. package/src/rsc/handler.ts +478 -374
  122. package/src/rsc/helpers.ts +69 -41
  123. package/src/rsc/loader-fetch.ts +23 -3
  124. package/src/rsc/manifest-init.ts +5 -1
  125. package/src/rsc/progressive-enhancement.ts +16 -2
  126. package/src/rsc/response-route-handler.ts +14 -1
  127. package/src/rsc/rsc-rendering.ts +19 -1
  128. package/src/rsc/server-action.ts +10 -0
  129. package/src/rsc/ssr-setup.ts +2 -2
  130. package/src/rsc/types.ts +9 -1
  131. package/src/segment-content-promise.ts +67 -0
  132. package/src/segment-loader-promise.ts +122 -0
  133. package/src/segment-system.tsx +109 -23
  134. package/src/server/context.ts +166 -17
  135. package/src/server/handle-store.ts +19 -0
  136. package/src/server/loader-registry.ts +9 -8
  137. package/src/server/request-context.ts +194 -60
  138. package/src/ssr/index.tsx +4 -0
  139. package/src/static-handler.ts +18 -6
  140. package/src/types/cache-types.ts +4 -4
  141. package/src/types/handler-context.ts +137 -65
  142. package/src/types/loader-types.ts +41 -15
  143. package/src/types/request-scope.ts +126 -0
  144. package/src/types/route-entry.ts +19 -1
  145. package/src/types/segments.ts +2 -0
  146. package/src/urls/include-helper.ts +24 -14
  147. package/src/urls/path-helper-types.ts +39 -6
  148. package/src/urls/path-helper.ts +48 -13
  149. package/src/urls/pattern-types.ts +12 -0
  150. package/src/urls/response-types.ts +18 -16
  151. package/src/use-loader.tsx +77 -5
  152. package/src/vite/debug.ts +55 -0
  153. package/src/vite/discovery/bundle-postprocess.ts +30 -33
  154. package/src/vite/discovery/discover-routers.ts +5 -1
  155. package/src/vite/discovery/prerender-collection.ts +128 -74
  156. package/src/vite/discovery/state.ts +13 -6
  157. package/src/vite/index.ts +4 -0
  158. package/src/vite/plugin-types.ts +51 -79
  159. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  160. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  161. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  162. package/src/vite/plugins/expose-action-id.ts +1 -3
  163. package/src/vite/plugins/expose-id-utils.ts +12 -0
  164. package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
  165. package/src/vite/plugins/expose-internal-ids.ts +257 -40
  166. package/src/vite/plugins/performance-tracks.ts +86 -0
  167. package/src/vite/plugins/refresh-cmd.ts +88 -26
  168. package/src/vite/plugins/version-plugin.ts +13 -1
  169. package/src/vite/rango.ts +204 -217
  170. package/src/vite/router-discovery.ts +335 -64
  171. package/src/vite/utils/banner.ts +4 -4
  172. package/src/vite/utils/package-resolution.ts +41 -1
  173. package/src/vite/utils/prerender-utils.ts +37 -5
  174. package/src/vite/utils/shared-utils.ts +3 -2
@@ -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) 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
+ }
@@ -278,9 +278,7 @@ export function exposeActionId(): Plugin {
278
278
  if (!rscPluginApi) {
279
279
  throw new Error(
280
280
  "[rsc-router] Could not find @vitejs/plugin-rsc. " +
281
- "@rangojs/router requires the Vite RSC plugin.\n" +
282
- "The RSC plugin should be included automatically. If you disabled it with\n" +
283
- "rango({ rsc: false }), add rsc() before rango() in your config.",
281
+ "@rangojs/router requires the Vite RSC plugin, which is included automatically by rango().",
284
282
  );
285
283
  }
286
284
 
@@ -19,6 +19,18 @@ export function hashId(filePath: string, exportName: string): string {
19
19
  return `${hash.slice(0, 8)}#${exportName}`;
20
20
  }
21
21
 
22
+ /**
23
+ * Build a stable ID for an export binding. Uses hashed IDs in production
24
+ * builds (short + opaque) and readable path#name IDs in dev.
25
+ */
26
+ export function makeStubId(
27
+ filePath: string,
28
+ exportName: string,
29
+ isBuild: boolean,
30
+ ): string {
31
+ return isBuild ? hashId(filePath, exportName) : `${filePath}#${exportName}`;
32
+ }
33
+
22
34
  /**
23
35
  * Generate an 8-char hex hash for an inline static handler call site.
24
36
  * Uses file path and line number (plus optional index for same-line collisions).
@@ -138,6 +138,36 @@ export function generateExprStubs(
138
138
  };
139
139
  }
140
140
 
141
+ /**
142
+ * Replace handler call expressions with lightweight stub objects on an
143
+ * existing MagicString. Unlike generateExprStubs (which creates its own
144
+ * MagicString and returns the full result), this integrates into the
145
+ * unified transform pipeline so all transforms share one sourcemap.
146
+ */
147
+ export function stubHandlerExprs(
148
+ cfg: HandlerTransformConfig,
149
+ bindings: CreateExportBinding[],
150
+ s: MagicString,
151
+ filePath: string,
152
+ isBuild: boolean,
153
+ ): boolean {
154
+ let hasChanges = false;
155
+ for (const binding of bindings) {
156
+ const exportName = binding.exportNames[0];
157
+ const handlerId = isBuild
158
+ ? hashId(filePath, exportName)
159
+ : `${filePath}#${exportName}`;
160
+
161
+ s.overwrite(
162
+ binding.callExprStart,
163
+ binding.callCloseParenPos + 1,
164
+ `{ __brand: "${cfg.brand}", $$id: "${handlerId}" }`,
165
+ );
166
+ hasChanges = true;
167
+ }
168
+ return hasChanges;
169
+ }
170
+
141
171
  /**
142
172
  * Inject $$id into export const handler calls in RSC environments.
143
173
  */
@@ -2,7 +2,12 @@ import type { Plugin, ResolvedConfig } from "vite";
2
2
  import { parseAst } from "vite";
3
3
  import MagicString from "magic-string";
4
4
  import path from "node:path";
5
- import { normalizePath, hashId, detectImports } from "./expose-id-utils.js";
5
+ import {
6
+ normalizePath,
7
+ hashId,
8
+ makeStubId,
9
+ detectImports,
10
+ } from "./expose-id-utils.js";
6
11
  import {
7
12
  transformInlineHandlers,
8
13
  type VirtualHandlerEntry,
@@ -23,6 +28,7 @@ import {
23
28
  getImportedFnNames,
24
29
  collectCreateExportBindings,
25
30
  buildUnsupportedShapeWarning,
31
+ isExportOnlyFile,
26
32
  } from "./expose-ids/export-analysis.js";
27
33
  import {
28
34
  hasCreateLoaderImport,
@@ -34,6 +40,7 @@ import {
34
40
  transformLocationState,
35
41
  generateWholeFileStubs,
36
42
  generateExprStubs,
43
+ stubHandlerExprs,
37
44
  transformHandlerIds,
38
45
  } from "./expose-ids/handler-transform.js";
39
46
 
@@ -385,7 +392,9 @@ ${lazyImports.join(",\n")}
385
392
  if (stubResult) return stubResult;
386
393
  }
387
394
 
388
- // --- PrerenderHandler: non-RSC stub replacement ---
395
+ // --- PrerenderHandler: non-RSC whole-file stub replacement ---
396
+ // When ALL exports are Prerender() calls, replace the entire file.
397
+ // Mixed-export files are handled in the unified pipeline below.
389
398
  if (hasPrerenderHandlerCode && !isRscEnv) {
390
399
  const fnNames = getFnNames(PRERENDER_CONFIG.fnName);
391
400
  const bindings = getBindings(code, fnNames);
@@ -397,16 +406,6 @@ ${lazyImports.join(",\n")}
397
406
  isBuild,
398
407
  );
399
408
  if (wholeFile) return wholeFile;
400
-
401
- const exprStubs = generateExprStubs(
402
- PRERENDER_CONFIG,
403
- bindings,
404
- code,
405
- filePath,
406
- id,
407
- isBuild,
408
- );
409
- if (exprStubs) return exprStubs;
410
409
  }
411
410
 
412
411
  // --- PrerenderHandler: RSC build module tracking ---
@@ -467,7 +466,8 @@ ${lazyImports.join(",\n")}
467
466
  }
468
467
  }
469
468
 
470
- // --- StaticHandler: non-RSC stub replacement ---
469
+ // --- StaticHandler: non-RSC whole-file stub replacement ---
470
+ // When ALL exports are Static() calls, replace the entire file.
471
471
  if (hasStaticHandlerCode && !isRscEnv) {
472
472
  const fnNames = getFnNames(STATIC_CONFIG.fnName);
473
473
  const bindings = getBindings(code, fnNames);
@@ -479,16 +479,212 @@ ${lazyImports.join(",\n")}
479
479
  isBuild,
480
480
  );
481
481
  if (wholeFile) return wholeFile;
482
+ }
482
483
 
483
- const exprStubs = generateExprStubs(
484
- STATIC_CONFIG,
485
- bindings,
486
- code,
487
- filePath,
488
- id,
489
- isBuild,
490
- );
491
- if (exprStubs) return exprStubs;
484
+ // --- Mixed-type whole-file stub replacement (non-RSC) ---
485
+ // When the individual whole-file checks above fail (each only checks
486
+ // one type), the file has mixed exports (e.g. createLoader + Prerender).
487
+ // Gather ALL stub-safe bindings and check if they cover every export.
488
+ // If yes, replace the entire file with stubs — this strips server-only
489
+ // imports (node:fs, DB clients, etc.) that would crash in the browser.
490
+ //
491
+ // Only applies when the file contains Prerender/Static (the handler
492
+ // types that bring server-only code). Files with only loaders, handles,
493
+ // or locationState are handled correctly by the unified pipeline below.
494
+ //
495
+ // Loader, Prerender, and Static exports become plain { __brand, $$id }
496
+ // stubs. createHandle and createLocationState need their create*()
497
+ // functions to execute (collect registration / __rsc_ls_key), so their
498
+ // call expressions are preserved with only a @rangojs/router import.
499
+ // This strips all server-only imports while keeping the correct
500
+ // client contract for every export type.
501
+ if (!isRscEnv && (hasPrerenderHandlerCode || hasStaticHandlerCode)) {
502
+ const prerenderFnNames = hasPrerenderHandlerCode
503
+ ? getFnNames(PRERENDER_CONFIG.fnName)
504
+ : [];
505
+ const staticFnNames = hasStaticHandlerCode
506
+ ? getFnNames(STATIC_CONFIG.fnName)
507
+ : [];
508
+ const loaderFnNames = hasLoaderCode ? getFnNames("createLoader") : [];
509
+ const handleFnNames = hasHandleCode ? getFnNames("createHandle") : [];
510
+ const lsFnNames = hasLocationStateCode
511
+ ? getFnNames("createLocationState")
512
+ : [];
513
+
514
+ // Collect ALL recognized bindings to check export coverage
515
+ const allBindings: CreateExportBinding[] = [];
516
+ for (const fnNames of [
517
+ prerenderFnNames,
518
+ staticFnNames,
519
+ loaderFnNames,
520
+ handleFnNames,
521
+ lsFnNames,
522
+ ]) {
523
+ if (fnNames.length > 0) {
524
+ allBindings.push(...getBindings(code, fnNames));
525
+ }
526
+ }
527
+
528
+ // Check if preserved createHandle/createLocationState calls
529
+ // reference non-exported locals (e.g. helper functions, constants).
530
+ // If so, the whole-file stub would strip those locals, breaking
531
+ // the call. Fall through to the unified pipeline instead.
532
+ let canStubWholeFile =
533
+ allBindings.length > 0 && isExportOnlyFile(code, allBindings);
534
+
535
+ if (
536
+ canStubWholeFile &&
537
+ (handleFnNames.length > 0 || lsFnNames.length > 0)
538
+ ) {
539
+ const exportedLocals = new Set(allBindings.map((b) => b.localName));
540
+ // Collect bindings that would be stripped by whole-file replacement:
541
+ // local declarations and imported bindings from non-@rangojs/router
542
+ // modules. This is a regex-based heuristic — it intentionally skips
543
+ // edge cases (class decls, destructured bindings, combined
544
+ // default+named imports) since those rarely appear in route files.
545
+ const strippedBindings: string[] = [];
546
+
547
+ // Skip React Fast Refresh temporaries (_c, _c2, ...) which are
548
+ // injected by @vitejs/plugin-react in the client environment and
549
+ // would falsely trigger the bailout.
550
+ const localDeclPattern =
551
+ /(?:^|;|\n)\s*(?:const|let|var|function)\s+(\w+)/g;
552
+ let declMatch: RegExpExecArray | null;
553
+ while ((declMatch = localDeclPattern.exec(code)) !== null) {
554
+ const name = declMatch[1];
555
+ if (!exportedLocals.has(name) && !/^_c\d*$/.test(name)) {
556
+ strippedBindings.push(name);
557
+ }
558
+ }
559
+
560
+ const importPattern =
561
+ /import\s*\{([^}]*)\}\s*from\s*["'](?!@rangojs\/router)[^"']*["']/g;
562
+ let importMatch: RegExpExecArray | null;
563
+ while ((importMatch = importPattern.exec(code)) !== null) {
564
+ for (const spec of importMatch[1].split(",")) {
565
+ const m = spec
566
+ .trim()
567
+ .match(/^[A-Za-z_$][\w$]*(?:\s+as\s+([A-Za-z_$][\w$]*))?$/);
568
+ if (m) strippedBindings.push(m[1] || m[0].trim().split(/\s/)[0]);
569
+ }
570
+ }
571
+ const defaultImportPattern =
572
+ /import\s+([A-Za-z_$][\w$]*)\s+from\s*["'](?!@rangojs\/router)[^"']*["']/g;
573
+ while ((importMatch = defaultImportPattern.exec(code)) !== null) {
574
+ strippedBindings.push(importMatch[1]);
575
+ }
576
+ const nsImportPattern =
577
+ /import\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s*["'](?!@rangojs\/router)[^"']*["']/g;
578
+ while ((importMatch = nsImportPattern.exec(code)) !== null) {
579
+ strippedBindings.push(importMatch[1]);
580
+ }
581
+
582
+ if (strippedBindings.length > 0) {
583
+ const preservedBindings = allBindings.filter((b) => {
584
+ const fc = code.slice(b.callExprStart, b.callOpenParenPos + 1);
585
+ return (
586
+ handleFnNames.some((n) => fc.includes(n)) ||
587
+ lsFnNames.some((n) => fc.includes(n))
588
+ );
589
+ });
590
+ const strippedRe = new RegExp(
591
+ `\\b(?:${strippedBindings.join("|")})\\b`,
592
+ );
593
+ canStubWholeFile = !preservedBindings.some((b) => {
594
+ const expr = code.slice(b.callExprStart, b.callCloseParenPos + 1);
595
+ return strippedRe.test(expr);
596
+ });
597
+ }
598
+ }
599
+
600
+ if (canStubWholeFile) {
601
+ const lines: string[] = [];
602
+ const neededImports: string[] = [];
603
+ if (handleFnNames.length > 0) neededImports.push("createHandle");
604
+ if (lsFnNames.length > 0) neededImports.push("createLocationState");
605
+ if (neededImports.length > 0) {
606
+ lines.push(
607
+ `import { ${neededImports.join(", ")} } from "@rangojs/router";`,
608
+ );
609
+ }
610
+
611
+ for (const binding of allBindings) {
612
+ const fnCall = code.slice(
613
+ binding.callExprStart,
614
+ binding.callOpenParenPos + 1,
615
+ );
616
+ const isHandle = handleFnNames.some((n) => fnCall.includes(n));
617
+ const isLocationState = lsFnNames.some((n) => fnCall.includes(n));
618
+
619
+ // Aliases share the primary name's ID (matches server transforms).
620
+ const primaryName = binding.exportNames[0];
621
+ const stubId = makeStubId(filePath, primaryName, isBuild);
622
+
623
+ if (isHandle || isLocationState) {
624
+ // Rewrite alias to canonical name since the stub file only
625
+ // imports canonical names from @rangojs/router.
626
+ // Strip React Fast Refresh `_c = ` wrappers from args
627
+ // (e.g. `_c = (segments) => ...` → `(segments) => ...`)
628
+ const rawArgs = code
629
+ .slice(binding.callOpenParenPos + 1, binding.callCloseParenPos)
630
+ .replace(/\b_c\d*\s*=\s*/g, "");
631
+ const canonicalName = isHandle
632
+ ? "createHandle"
633
+ : "createLocationState";
634
+ const activeFnNames = isHandle ? handleFnNames : lsFnNames;
635
+
636
+ // Reconstruct the function name (handling aliases + generics)
637
+ let rawCallee = code.slice(
638
+ binding.callExprStart,
639
+ binding.callOpenParenPos,
640
+ );
641
+ for (const alias of activeFnNames) {
642
+ if (alias !== canonicalName && rawCallee.startsWith(alias)) {
643
+ rawCallee = canonicalName + rawCallee.slice(alias.length);
644
+ break;
645
+ }
646
+ }
647
+
648
+ if (isHandle) {
649
+ // createHandle checks __injectedId DURING the call, so $$id
650
+ // must be a parameter, not a post-call property assignment.
651
+ const idParam =
652
+ binding.argCount === 0
653
+ ? `undefined, "${stubId}"`
654
+ : `, "${stubId}"`;
655
+ lines.push(
656
+ `export const ${primaryName} = ${rawCallee}(${rawArgs}${idParam});`,
657
+ );
658
+ lines.push(`${primaryName}.$$id = "${stubId}";`);
659
+ } else {
660
+ lines.push(
661
+ `export const ${primaryName} = ${rawCallee}(${rawArgs});`,
662
+ );
663
+ lines.push(
664
+ `${primaryName}.__rsc_ls_key = "__rsc_ls_${stubId}";`,
665
+ );
666
+ }
667
+ for (const name of binding.exportNames.slice(1)) {
668
+ lines.push(`export const ${name} = ${primaryName};`);
669
+ }
670
+ } else {
671
+ let brand = "loader";
672
+ if (prerenderFnNames.some((n) => fnCall.includes(n))) {
673
+ brand = PRERENDER_CONFIG.brand;
674
+ } else if (staticFnNames.some((n) => fnCall.includes(n))) {
675
+ brand = STATIC_CONFIG.brand;
676
+ }
677
+ lines.push(
678
+ `export const ${primaryName} = { __brand: "${brand}", $$id: "${stubId}" };`,
679
+ );
680
+ for (const name of binding.exportNames.slice(1)) {
681
+ lines.push(`export const ${name} = ${primaryName};`);
682
+ }
683
+ }
684
+ }
685
+
686
+ return { code: lines.join("\n") + "\n", map: null };
687
+ }
492
688
  }
493
689
 
494
690
  // --- StaticHandler: RSC build module tracking ---
@@ -535,27 +731,48 @@ ${lazyImports.join(",\n")}
535
731
  isBuild,
536
732
  ) || changed;
537
733
  }
538
- if (hasPrerenderHandlerCode && isRscEnv) {
734
+ if (hasPrerenderHandlerCode) {
539
735
  const fnNames = getFnNames(PRERENDER_CONFIG.fnName);
540
- changed =
541
- transformHandlerIds(
542
- PRERENDER_CONFIG,
543
- getBindings(code, fnNames),
544
- s,
545
- filePath,
546
- isBuild,
547
- ) || changed;
736
+ const bindings = getBindings(code, fnNames);
737
+ if (isRscEnv) {
738
+ changed =
739
+ transformHandlerIds(
740
+ PRERENDER_CONFIG,
741
+ bindings,
742
+ s,
743
+ filePath,
744
+ isBuild,
745
+ ) || changed;
746
+ } else {
747
+ // Non-RSC mixed-export file: replace Prerender() calls with stubs
748
+ // on the shared MagicString so sourcemaps stay accurate.
749
+ changed =
750
+ stubHandlerExprs(
751
+ PRERENDER_CONFIG,
752
+ bindings,
753
+ s,
754
+ filePath,
755
+ isBuild,
756
+ ) || changed;
757
+ }
548
758
  }
549
- if (hasStaticHandlerCode && isRscEnv) {
759
+ if (hasStaticHandlerCode) {
550
760
  const fnNames = getFnNames(STATIC_CONFIG.fnName);
551
- changed =
552
- transformHandlerIds(
553
- STATIC_CONFIG,
554
- getBindings(code, fnNames),
555
- s,
556
- filePath,
557
- isBuild,
558
- ) || changed;
761
+ const bindings = getBindings(code, fnNames);
762
+ if (isRscEnv) {
763
+ changed =
764
+ transformHandlerIds(
765
+ STATIC_CONFIG,
766
+ bindings,
767
+ s,
768
+ filePath,
769
+ isBuild,
770
+ ) || changed;
771
+ } else {
772
+ changed =
773
+ stubHandlerExprs(STATIC_CONFIG, bindings, s, filePath, isBuild) ||
774
+ changed;
775
+ }
559
776
  }
560
777
 
561
778
  if (!changed) return;