@rangojs/router 0.0.0-experimental.80 → 0.0.0-experimental.81

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.
@@ -1864,7 +1864,7 @@ import { resolve } from "node:path";
1864
1864
  // package.json
1865
1865
  var package_default = {
1866
1866
  name: "@rangojs/router",
1867
- version: "0.0.0-experimental.80",
1867
+ version: "0.0.0-experimental.81",
1868
1868
  description: "Django-inspired RSC router with composable URL patterns",
1869
1869
  keywords: [
1870
1870
  "react",
@@ -1997,7 +1997,7 @@ var package_default = {
1997
1997
  tag: "experimental"
1998
1998
  },
1999
1999
  scripts: {
2000
- build: "pnpm dlx esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && pnpm dlx esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js",
2000
+ build: "pnpm dlx esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && mkdir -p dist/vite/plugins && cp src/vite/plugins/cloudflare-protocol-loader-hook.mjs dist/vite/plugins/cloudflare-protocol-loader-hook.mjs && pnpm dlx esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js",
2001
2001
  prepublishOnly: "pnpm build",
2002
2002
  typecheck: "tsc --noEmit",
2003
2003
  test: "playwright test",
@@ -3260,7 +3260,7 @@ function createCjsToEsmPlugin() {
3260
3260
  import { createServer as createViteServer } from "vite";
3261
3261
  import { resolve as resolve8 } from "node:path";
3262
3262
  import { readFileSync as readFileSync6 } from "node:fs";
3263
- import { createRequire } from "node:module";
3263
+ import { createRequire, register } from "node:module";
3264
3264
  import { pathToFileURL } from "node:url";
3265
3265
 
3266
3266
  // src/vite/plugins/virtual-stub-plugin.ts
@@ -3287,6 +3287,113 @@ function createVirtualStubPlugin() {
3287
3287
  };
3288
3288
  }
3289
3289
 
3290
+ // src/vite/plugins/cloudflare-protocol-stub.ts
3291
+ var VIRTUAL_PREFIX = "virtual:rango-cloudflare-stub-";
3292
+ var NULL_PREFIX = "\0" + VIRTUAL_PREFIX;
3293
+ var CF_PREFIX = "cloudflare:";
3294
+ var BUILD_ENV_GLOBAL_KEY = "__rango_build_env__";
3295
+ var SOURCE_EXT_RE = /\.[mc]?[jt]sx?$/;
3296
+ var IMPORT_NODE_TYPES = /* @__PURE__ */ new Set([
3297
+ "ImportDeclaration",
3298
+ "ImportExpression",
3299
+ "ExportNamedDeclaration",
3300
+ "ExportAllDeclaration"
3301
+ ]);
3302
+ var STUBS = {
3303
+ "cloudflare:workers": `
3304
+ export class DurableObject { constructor(_ctx, _env) {} }
3305
+ export class WorkerEntrypoint { constructor(_ctx, _env) {} }
3306
+ export class WorkflowEntrypoint { constructor(_ctx, _env) {} }
3307
+ export class RpcTarget {}
3308
+ export const env = globalThis[${JSON.stringify(BUILD_ENV_GLOBAL_KEY)}] ?? {};
3309
+ export default {};
3310
+ `,
3311
+ "cloudflare:email": `
3312
+ export class EmailMessage { constructor(_from, _to, _raw) {} }
3313
+ export default {};
3314
+ `,
3315
+ "cloudflare:sockets": `
3316
+ export function connect() { return {}; }
3317
+ export default {};
3318
+ `,
3319
+ "cloudflare:workflows": `
3320
+ export class NonRetryableError extends Error {
3321
+ constructor(message, name) { super(message); this.name = name ?? "NonRetryableError"; }
3322
+ }
3323
+ export default {};
3324
+ `
3325
+ };
3326
+ var FALLBACK_STUB = `export default {};
3327
+ `;
3328
+ function createCloudflareProtocolStubPlugin() {
3329
+ return {
3330
+ name: "@rangojs/router:cloudflare-protocol-stub",
3331
+ transform(code, id) {
3332
+ const cleanId = id.split("?")[0] ?? id;
3333
+ if (!SOURCE_EXT_RE.test(cleanId)) return null;
3334
+ if (!code.includes(CF_PREFIX)) return null;
3335
+ let ast;
3336
+ try {
3337
+ ast = this.parse(code);
3338
+ } catch {
3339
+ return null;
3340
+ }
3341
+ const hits = [];
3342
+ walk(ast, (node) => {
3343
+ if (!IMPORT_NODE_TYPES.has(node.type)) return;
3344
+ const source = node.source;
3345
+ if (!source || source.type !== "Literal") return;
3346
+ if (typeof source.value !== "string") return;
3347
+ if (!source.value.startsWith(CF_PREFIX)) return;
3348
+ if (typeof source.start !== "number" || typeof source.end !== "number")
3349
+ return;
3350
+ hits.push({
3351
+ start: source.start,
3352
+ end: source.end,
3353
+ value: source.value
3354
+ });
3355
+ });
3356
+ if (hits.length === 0) return null;
3357
+ hits.sort((a, b) => b.start - a.start);
3358
+ let out = code;
3359
+ for (const hit of hits) {
3360
+ const submodule = hit.value.slice(CF_PREFIX.length);
3361
+ const quote = code[hit.start] === "'" ? "'" : '"';
3362
+ out = out.slice(0, hit.start) + quote + VIRTUAL_PREFIX + submodule + quote + out.slice(hit.end);
3363
+ }
3364
+ return { code: out, map: null };
3365
+ },
3366
+ resolveId(id) {
3367
+ if (id.startsWith(VIRTUAL_PREFIX)) {
3368
+ return "\0" + id;
3369
+ }
3370
+ return null;
3371
+ },
3372
+ load(id) {
3373
+ if (!id.startsWith(NULL_PREFIX)) return null;
3374
+ const submodule = id.slice(NULL_PREFIX.length);
3375
+ const specifier = CF_PREFIX + submodule;
3376
+ return STUBS[specifier] ?? FALLBACK_STUB;
3377
+ }
3378
+ };
3379
+ }
3380
+ function walk(node, visit) {
3381
+ if (!node || typeof node !== "object") return;
3382
+ if (Array.isArray(node)) {
3383
+ for (const child of node) walk(child, visit);
3384
+ return;
3385
+ }
3386
+ const n = node;
3387
+ if (typeof n.type !== "string") return;
3388
+ visit(n);
3389
+ for (const key in n) {
3390
+ if (key === "loc" || key === "start" || key === "end" || key === "range") {
3391
+ continue;
3392
+ }
3393
+ walk(n[key], visit);
3394
+ }
3395
+ }
3396
+
3290
3397
  // src/vite/plugins/client-ref-hashing.ts
3291
3398
  import { relative } from "node:path";
3292
3399
  import { createHash as createHash2 } from "node:crypto";
@@ -4682,7 +4789,22 @@ function postprocessBundle(state) {
4682
4789
  }
4683
4790
 
4684
4791
  // src/vite/router-discovery.ts
4792
+ var loaderHookRegistered = false;
4793
+ function ensureCloudflareProtocolLoaderRegistered() {
4794
+ if (loaderHookRegistered) return;
4795
+ loaderHookRegistered = true;
4796
+ try {
4797
+ register(
4798
+ new URL("./plugins/cloudflare-protocol-loader-hook.mjs", import.meta.url)
4799
+ );
4800
+ } catch (err) {
4801
+ console.warn(
4802
+ `[rsc-router] Could not register Node ESM loader hook for cloudflare:* imports (${err?.message ?? err}). Falling back to Vite transform only.`
4803
+ );
4804
+ }
4805
+ }
4685
4806
  async function createTempRscServer(state, options = {}) {
4807
+ ensureCloudflareProtocolLoaderRegistered();
4686
4808
  const { default: rsc } = await import("@vitejs/plugin-rsc");
4687
4809
  return createViteServer({
4688
4810
  root: state.projectRoot,
@@ -4705,6 +4827,7 @@ async function createTempRscServer(state, options = {}) {
4705
4827
  ...options.forceBuild ? [hashClientRefs(state.projectRoot)] : [],
4706
4828
  createVersionPlugin(),
4707
4829
  createVirtualStubPlugin(),
4830
+ createCloudflareProtocolStubPlugin(),
4708
4831
  // Dev prerender must use dev-mode IDs (path-based) to match the workerd
4709
4832
  // runtime. forceBuild produces hashed IDs for production bundle consistency.
4710
4833
  exposeInternalIds(options.forceBuild ? { forceBuild: true } : void 0),
@@ -4756,6 +4879,7 @@ async function acquireBuildEnv(s, command, mode) {
4756
4879
  if (!result) return false;
4757
4880
  s.resolvedBuildEnv = result.env;
4758
4881
  s.buildEnvDispose = result.dispose ?? null;
4882
+ globalThis[BUILD_ENV_GLOBAL_KEY] = result.env;
4759
4883
  return true;
4760
4884
  }
4761
4885
  async function releaseBuildEnv(s) {
@@ -4768,6 +4892,7 @@ async function releaseBuildEnv(s) {
4768
4892
  s.buildEnvDispose = null;
4769
4893
  }
4770
4894
  s.resolvedBuildEnv = void 0;
4895
+ delete globalThis[BUILD_ENV_GLOBAL_KEY];
4771
4896
  }
4772
4897
  function createRouterDiscoveryPlugin(entryPath, opts) {
4773
4898
  const s = createDiscoveryState(entryPath, opts);
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.80",
3
+ "version": "0.0.0-experimental.81",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -132,15 +132,6 @@
132
132
  "access": "public",
133
133
  "tag": "experimental"
134
134
  },
135
- "scripts": {
136
- "build": "pnpm dlx esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && pnpm dlx esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js",
137
- "prepublishOnly": "pnpm build",
138
- "typecheck": "tsc --noEmit",
139
- "test": "playwright test",
140
- "test:ui": "playwright test --ui",
141
- "test:unit": "vitest run",
142
- "test:unit:watch": "vitest"
143
- },
144
135
  "dependencies": {
145
136
  "@vitejs/plugin-rsc": "^0.5.23",
146
137
  "magic-string": "^0.30.17",
@@ -150,12 +141,12 @@
150
141
  "devDependencies": {
151
142
  "@playwright/test": "^1.49.1",
152
143
  "@types/node": "^24.10.1",
153
- "@types/react": "catalog:",
154
- "@types/react-dom": "catalog:",
144
+ "@types/react": "^19.2.7",
145
+ "@types/react-dom": "^19.2.3",
155
146
  "esbuild": "^0.27.0",
156
147
  "jiti": "^2.6.1",
157
- "react": "catalog:",
158
- "react-dom": "catalog:",
148
+ "react": "^19.2.4",
149
+ "react-dom": "^19.2.4",
159
150
  "tinyexec": "^0.3.2",
160
151
  "typescript": "^5.3.0",
161
152
  "vitest": "^4.0.0"
@@ -173,5 +164,13 @@
173
164
  "vite": {
174
165
  "optional": true
175
166
  }
167
+ },
168
+ "scripts": {
169
+ "build": "pnpm dlx esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && mkdir -p dist/vite/plugins && cp src/vite/plugins/cloudflare-protocol-loader-hook.mjs dist/vite/plugins/cloudflare-protocol-loader-hook.mjs && pnpm dlx esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js",
170
+ "typecheck": "tsc --noEmit",
171
+ "test": "playwright test",
172
+ "test:ui": "playwright test --ui",
173
+ "test:unit": "vitest run",
174
+ "test:unit:watch": "vitest"
176
175
  }
177
- }
176
+ }
@@ -593,6 +593,12 @@ function ProductPage() {
593
593
  return <h1>Product {params.productId}</h1>;
594
594
  }
595
595
 
596
+ // Annotate the expected shape via a generic
597
+ function ProductPageTyped() {
598
+ const { productId } = useParams<{ productId: string }>();
599
+ return <h1>Product {productId}</h1>;
600
+ }
601
+
596
602
  // With selector for performance (re-renders only when selected value changes)
597
603
  function ProductId() {
598
604
  const productId = useParams((p) => p.productId);
@@ -600,7 +606,7 @@ function ProductId() {
600
606
  }
601
607
  ```
602
608
 
603
- Returns merged params from all matched route segments. Updates on navigation commit (not during pending navigation).
609
+ Returns merged params from all matched route segments as a `Readonly<T>` map. Updates on navigation commit (not during pending navigation).
604
610
 
605
611
  ### usePathname()
606
612
 
@@ -685,20 +691,20 @@ See `/links` for full URL generation guide including server-side `ctx.reverse`.
685
691
 
686
692
  ## Hook Summary
687
693
 
688
- | Hook | Purpose | Returns |
689
- | -------------------- | --------------------------------- | ----------------------------------------------- |
690
- | `useParams()` | Route params | `Record<string, string>` or selected value |
691
- | `usePathname()` | Current pathname | `string` |
692
- | `useSearchParams()` | URL search params | `ReadonlyURLSearchParams` |
693
- | `useHref()` | Mount-aware href | `(path) => string` |
694
- | `useMount()` | Current include() mount path | `string` |
695
- | `useNavigation()` | Reactive navigation state | state, location, isStreaming |
696
- | `useRouter()` | Stable router actions | push, replace, refresh, prefetch, back, forward |
697
- | `useSegments()` | URL path & segment IDs | path, segmentIds, location |
698
- | `useLinkStatus()` | Link pending state | { pending } |
699
- | `useLoader()` | Loader data (strict) | data, isLoading, error |
700
- | `useFetchLoader()` | Loader with on-demand fetch | data, load, isLoading |
701
- | `useHandle()` | Accumulated handle data | T (handle type) |
702
- | `useAction()` | Server action state | state, error, result |
703
- | `useLocationState()` | History state (persists or flash) | T \| undefined |
704
- | `useClientCache()` | Cache control | { clear } |
694
+ | Hook | Purpose | Returns |
695
+ | -------------------- | --------------------------------- | ------------------------------------------------------------------ |
696
+ | `useParams()` | Route params | `Readonly<T>` (default `Record<string, string>`) or selected value |
697
+ | `usePathname()` | Current pathname | `string` |
698
+ | `useSearchParams()` | URL search params | `ReadonlyURLSearchParams` |
699
+ | `useHref()` | Mount-aware href | `(path) => string` |
700
+ | `useMount()` | Current include() mount path | `string` |
701
+ | `useNavigation()` | Reactive navigation state | state, location, isStreaming |
702
+ | `useRouter()` | Stable router actions | push, replace, refresh, prefetch, back, forward |
703
+ | `useSegments()` | URL path & segment IDs | path, segmentIds, location |
704
+ | `useLinkStatus()` | Link pending state | { pending } |
705
+ | `useLoader()` | Loader data (strict) | data, isLoading, error |
706
+ | `useFetchLoader()` | Loader with on-demand fetch | data, load, isLoading |
707
+ | `useHandle()` | Accumulated handle data | T (handle type) |
708
+ | `useAction()` | Server action state | state, error, result |
709
+ | `useLocationState()` | History state (persists or flash) | T \| undefined |
710
+ | `useClientCache()` | Cache control | { clear } |
@@ -635,6 +635,7 @@ layout(<ShopLayout />, () => [
635
635
  | `useLocation().pathname` | `usePathname()` from `@rangojs/router/client` |
636
636
  | `useSearchParams()` | `useSearchParams()` from `@rangojs/router/client` |
637
637
  | `useParams()` | `useParams()` from `@rangojs/router/client` (or `ctx.params` in server handlers) |
638
+ | `useParams<T>()` | `useParams<T>()` — same generic annotation pattern |
638
639
  | `<NavLink>` | `<Link>` with `usePathname()` for active state |
639
640
 
640
641
  ### useNavigate → useRouter
@@ -345,8 +345,12 @@ export function NavigationProvider({
345
345
  metadata: update.metadata,
346
346
  });
347
347
 
348
- // Update route params
349
- eventController.setParams(update.metadata.params ?? {});
348
+ // Update route params. Only reset when the server actually sends a params
349
+ // map — an absent `params` field means "no change" (e.g., legacy action
350
+ // responses that omitted params). Explicit `{}` still clears correctly.
351
+ if (update.metadata.params !== undefined) {
352
+ eventController.setParams(update.metadata.params);
353
+ }
350
354
 
351
355
  // Update handle data progressively as it streams in
352
356
  if (update.metadata.handles) {
@@ -16,11 +16,21 @@ import { shallowEqual } from "./shallow-equal.js";
16
16
  * const params = useParams();
17
17
  * // { productId: "123" }
18
18
  *
19
+ * // Annotate the expected shape via a generic
20
+ * const { productId } = useParams<{ productId: string }>();
21
+ *
19
22
  * // With selector
20
23
  * const productId = useParams(p => p.productId);
21
24
  * ```
22
25
  */
23
- export function useParams(): Record<string, string>;
26
+ // `T extends object` (not `Record<string, string | undefined>`) so that
27
+ // interface shapes pass the constraint — interfaces lack an implicit
28
+ // index signature and would otherwise be rejected. The generic is a
29
+ // shape annotation, not a runtime check; the body always returns the
30
+ // underlying params map unchanged.
31
+ export function useParams<
32
+ T extends object = Record<string, string>,
33
+ >(): Readonly<T>;
24
34
  export function useParams<T>(
25
35
  selector: (params: Record<string, string>) => T,
26
36
  ): T;
@@ -248,6 +248,7 @@ export async function handleProgressiveEnhancement<TEnv>(
248
248
  segments: match.segments,
249
249
  matched: match.matched,
250
250
  diff: match.diff,
251
+ params: match.params,
251
252
  isPartial: false,
252
253
  rootLayout: ctx.router.rootLayout,
253
254
  handles: handleStore.stream(),
@@ -353,6 +354,7 @@ async function renderPeErrorBoundary<TEnv>(
353
354
  segments: errorResult.segments,
354
355
  matched: errorResult.matched,
355
356
  diff: errorResult.diff,
357
+ params: errorResult.params,
356
358
  isPartial: false,
357
359
  isError: true,
358
360
  rootLayout: ctx.router.rootLayout,
@@ -213,6 +213,7 @@ export async function executeServerAction<TEnv>(
213
213
  isPartial: true,
214
214
  matched: errorResult.matched,
215
215
  diff: errorResult.diff,
216
+ params: errorResult.params,
216
217
  isError: true,
217
218
  handles: handleStore.stream(),
218
219
  version: ctx.version,
@@ -323,6 +324,7 @@ export async function revalidateAfterAction<TEnv>(
323
324
  isPartial: true,
324
325
  matched: matchResult.matched,
325
326
  diff: matchResult.diff,
327
+ params: matchResult.params,
326
328
  slots: matchResult.slots,
327
329
  handles: handleStore.stream(),
328
330
  version: ctx.version,
@@ -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) 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
+ }
@@ -10,7 +10,7 @@ import type { Plugin } from "vite";
10
10
  import { createServer as createViteServer } from "vite";
11
11
  import { resolve } from "node:path";
12
12
  import { readFileSync } from "node:fs";
13
- import { createRequire } from "node:module";
13
+ import { createRequire, register } from "node:module";
14
14
  import { pathToFileURL } from "node:url";
15
15
  import {
16
16
  formatNestedRouterConflictError,
@@ -19,6 +19,10 @@ import {
19
19
  } from "../build/generate-route-types.js";
20
20
  import { createVersionPlugin } from "./plugins/version-plugin.js";
21
21
  import { createVirtualStubPlugin } from "./plugins/virtual-stub-plugin.js";
22
+ import {
23
+ BUILD_ENV_GLOBAL_KEY,
24
+ createCloudflareProtocolStubPlugin,
25
+ } from "./plugins/cloudflare-protocol-stub.js";
22
26
  import {
23
27
  exposeInternalIds,
24
28
  exposeRouterId,
@@ -47,6 +51,49 @@ import { resetStagedBuildAssets } from "./utils/prerender-utils.js";
47
51
 
48
52
  export { VIRTUAL_ROUTES_MANIFEST_ID };
49
53
 
54
+ // ============================================================================
55
+ // Node ESM Loader Hook Registration
56
+ // ============================================================================
57
+
58
+ /**
59
+ * Registers a Node ESM loader hook that resolves `cloudflare:*` specifiers
60
+ * to a data: URL stub. Defense-in-depth alongside the Vite transform in
61
+ * `cloudflare-protocol-stub.ts`:
62
+ *
63
+ * - The Vite transform catches `cloudflare:*` imports in modules that flow
64
+ * through Vite's plugin pipeline. That's the vast majority of cases.
65
+ * - The Node loader catches imports in modules that Vite/Rollup externalize
66
+ * (e.g. the `partyserver` package, which has a top-level
67
+ * `import { DurableObject, env } from "cloudflare:workers"` and ships
68
+ * shapes plugin-rsc marks as external). Externalized modules are loaded
69
+ * via Node's native ESM loader, which rejects URL schemes.
70
+ *
71
+ * Registration is process-global and one-shot. The hook only intercepts
72
+ * `cloudflare:*` specifiers; everything else passes through via
73
+ * `nextResolve()`. It runs in a separate worker thread (Node ESM loader
74
+ * architecture), so it can't read the `globalThis[BUILD_ENV_GLOBAL_KEY]`
75
+ * bridge that the Vite transform uses — the stubs served here always
76
+ * return `env = {}`. That's fine because externalized libraries don't
77
+ * typically access `env` at module top level; user source (where real
78
+ * `env` matters at build time) flows through the Vite transform.
79
+ */
80
+ let loaderHookRegistered = false;
81
+ function ensureCloudflareProtocolLoaderRegistered(): void {
82
+ if (loaderHookRegistered) return;
83
+ loaderHookRegistered = true;
84
+ try {
85
+ register(
86
+ new URL("./plugins/cloudflare-protocol-loader-hook.mjs", import.meta.url),
87
+ );
88
+ } catch (err: any) {
89
+ // register() requires Node 18.19+ / 20.6+. Older Node still has the
90
+ // Vite transform as primary defense.
91
+ console.warn(
92
+ `[rsc-router] Could not register Node ESM loader hook for cloudflare:* imports (${err?.message ?? err}). Falling back to Vite transform only.`,
93
+ );
94
+ }
95
+ }
96
+
50
97
  // ============================================================================
51
98
  // Temp Server Factory
52
99
  // ============================================================================
@@ -66,6 +113,11 @@ async function createTempRscServer(
66
113
  state: DiscoveryState,
67
114
  options: { forceBuild?: boolean; cacheDir?: string } = {},
68
115
  ) {
116
+ // Install the Node ESM loader hook before any module evaluation so
117
+ // `cloudflare:*` specifiers in externalized/loader-delegated modules
118
+ // (e.g. packages plugin-rsc marks as external) resolve to stubs
119
+ // instead of crashing Node's native loader.
120
+ ensureCloudflareProtocolLoaderRegistered();
69
121
  const { default: rsc } = await import("@vitejs/plugin-rsc");
70
122
  return createViteServer({
71
123
  root: state.projectRoot,
@@ -88,6 +140,7 @@ async function createTempRscServer(
88
140
  ...(options.forceBuild ? [hashClientRefs(state.projectRoot)] : []),
89
141
  createVersionPlugin(),
90
142
  createVirtualStubPlugin(),
143
+ createCloudflareProtocolStubPlugin(),
91
144
  // Dev prerender must use dev-mode IDs (path-based) to match the workerd
92
145
  // runtime. forceBuild produces hashed IDs for production bundle consistency.
93
146
  exposeInternalIds(options.forceBuild ? { forceBuild: true } : undefined),
@@ -177,6 +230,11 @@ async function acquireBuildEnv(
177
230
 
178
231
  s.resolvedBuildEnv = result.env;
179
232
  s.buildEnvDispose = result.dispose ?? null;
233
+ // Bridge the resolved env into `cloudflare:workers`'s stubbed `env`
234
+ // export so user code that does `import { env } from "cloudflare:workers"`
235
+ // sees the real bindings proxy during discovery + prerender instead of
236
+ // an empty object. The stub reads this global at module-evaluation time.
237
+ (globalThis as Record<string, unknown>)[BUILD_ENV_GLOBAL_KEY] = result.env;
180
238
  return true;
181
239
  }
182
240
 
@@ -193,6 +251,7 @@ async function releaseBuildEnv(s: DiscoveryState): Promise<void> {
193
251
  s.buildEnvDispose = null;
194
252
  }
195
253
  s.resolvedBuildEnv = undefined;
254
+ delete (globalThis as Record<string, unknown>)[BUILD_ENV_GLOBAL_KEY];
196
255
  }
197
256
 
198
257
  /**