@rangojs/router 0.0.0-experimental.ad1a365c → 0.0.0-experimental.b000c598

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 (65) hide show
  1. package/README.md +50 -20
  2. package/dist/vite/index.js +164 -13
  3. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  4. package/package.json +3 -3
  5. package/skills/breadcrumbs/SKILL.md +3 -1
  6. package/skills/hooks/SKILL.md +28 -20
  7. package/skills/links/SKILL.md +88 -16
  8. package/skills/loader/SKILL.md +35 -2
  9. package/skills/migrate-react-router/SKILL.md +1 -0
  10. package/skills/response-routes/SKILL.md +8 -0
  11. package/skills/streams-and-websockets/SKILL.md +283 -0
  12. package/skills/typesafety/SKILL.md +3 -1
  13. package/src/browser/app-shell.ts +52 -0
  14. package/src/browser/navigation-bridge.ts +51 -2
  15. package/src/browser/navigation-client.ts +33 -10
  16. package/src/browser/navigation-store.ts +25 -1
  17. package/src/browser/partial-update.ts +20 -1
  18. package/src/browser/prefetch/cache.ts +124 -26
  19. package/src/browser/prefetch/fetch.ts +114 -38
  20. package/src/browser/prefetch/queue.ts +36 -5
  21. package/src/browser/rango-state.ts +53 -13
  22. package/src/browser/react/Link.tsx +18 -13
  23. package/src/browser/react/NavigationProvider.tsx +50 -11
  24. package/src/browser/react/use-navigation.ts +22 -2
  25. package/src/browser/react/use-params.ts +11 -1
  26. package/src/browser/react/use-router.ts +8 -1
  27. package/src/browser/rsc-router.tsx +34 -6
  28. package/src/browser/types.ts +13 -0
  29. package/src/cache/cf/cf-cache-store.ts +5 -7
  30. package/src/index.rsc.ts +3 -0
  31. package/src/index.ts +3 -0
  32. package/src/outlet-context.ts +1 -1
  33. package/src/response-utils.ts +28 -0
  34. package/src/reverse.ts +3 -2
  35. package/src/route-definition/dsl-helpers.ts +16 -3
  36. package/src/route-definition/resolve-handler-use.ts +6 -0
  37. package/src/router/handler-context.ts +20 -3
  38. package/src/router/lazy-includes.ts +1 -1
  39. package/src/router/loader-resolution.ts +3 -0
  40. package/src/router/manifest.ts +6 -5
  41. package/src/router/match-api.ts +3 -3
  42. package/src/router/middleware-types.ts +2 -22
  43. package/src/router/middleware.ts +32 -4
  44. package/src/router/pattern-matching.ts +60 -9
  45. package/src/router/trie-matching.ts +10 -4
  46. package/src/router/url-params.ts +49 -0
  47. package/src/router.ts +1 -2
  48. package/src/rsc/handler.ts +8 -4
  49. package/src/rsc/helpers.ts +69 -41
  50. package/src/rsc/progressive-enhancement.ts +2 -0
  51. package/src/rsc/response-route-handler.ts +14 -1
  52. package/src/rsc/rsc-rendering.ts +7 -0
  53. package/src/rsc/server-action.ts +2 -0
  54. package/src/server/request-context.ts +10 -42
  55. package/src/types/handler-context.ts +2 -34
  56. package/src/types/loader-types.ts +5 -6
  57. package/src/types/request-scope.ts +126 -0
  58. package/src/urls/response-types.ts +2 -10
  59. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  60. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  61. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  62. package/src/vite/rango.ts +30 -9
  63. package/src/vite/router-discovery.ts +60 -1
  64. package/src/vite/utils/banner.ts +1 -1
  65. package/src/vite/utils/package-resolution.ts +33 -1
package/README.md CHANGED
@@ -161,13 +161,18 @@ const urlpatterns = urls(({ path }) => [
161
161
  ]);
162
162
  ```
163
163
 
164
- Use `reverse()` as the default way to link to routes:
164
+ Use `ctx.reverse()` from handler context as the default way to link to routes from server code:
165
165
 
166
166
  ```tsx
167
- router.reverse("product", { slug: "widget" }); // "/product/widget"
168
- router.reverse("search", undefined, { q: "rsc" }); // "/search?q=rsc"
167
+ const ProductPage: Handler<"product"> = (ctx) => {
168
+ const url = ctx.reverse("product", { slug: "widget" }); // "/product/widget"
169
+ const searchUrl = ctx.reverse("search", undefined, { q: "rsc" }); // "/search?q=rsc"
170
+ return <Link to={url}>Widget</Link>;
171
+ };
169
172
  ```
170
173
 
174
+ `router.reverse()` (exported from the router module) is the same function without a handler context, useful in scripts or tests. In request code, prefer `ctx.reverse()` — it auto-fills mount params from the current match.
175
+
171
176
  ### Composable URL Modules
172
177
 
173
178
  Local route names compose cleanly with `include(..., { name })`:
@@ -479,39 +484,64 @@ const urlpatterns = urls(({ path, loader }) => [
479
484
 
480
485
  ## Navigation & Links
481
486
 
482
- ### Named Routes with `reverse()` (Server Components)
487
+ ### Named Routes with `ctx.reverse()` (Server)
483
488
 
484
- In server components, use `reverse()` to generate URLs by route name:
489
+ In server components and handlers, use `ctx.reverse()` to generate URLs by route name. This is the default — it is typed, auto-fills mount params from the current match, and resolves both local (`.name`) and absolute (`name.sub`) names:
485
490
 
486
491
  ```tsx
487
492
  import { Link } from "@rangojs/router/client";
493
+ import type { Handler } from "@rangojs/router";
494
+
495
+ const BlogPostPage: Handler<"blogPost"> = (ctx) => {
496
+ const backUrl = ctx.reverse("blog");
497
+ return <Link to={backUrl}>Back to blog</Link>;
498
+ };
499
+ ```
500
+
501
+ `reverse()` is type-safe — route names and required params are checked at compile time. Included routes use dotted names: `ctx.reverse("api.health")`.
502
+
503
+ For scripts, tests, or other code without a handler context, import the router-level `reverse`:
504
+
505
+ ```tsx
488
506
  import { reverse } from "./router";
507
+ reverse("blogPost", { slug: "my-post" });
508
+ ```
509
+
510
+ ### Client Components
511
+
512
+ **`reverse()` is server-only.** It depends on the route manifest and handler context — neither is available in the browser bundle. Client components receive URLs as props, loader data, or server-action return values:
513
+
514
+ ```tsx
515
+ // server
516
+ function BlogIndex(ctx: HandlerContext) {
517
+ return (
518
+ <Nav
519
+ home={ctx.reverse("home")}
520
+ post={ctx.reverse("blogPost", { slug: "my-post" })}
521
+ />
522
+ );
523
+ }
524
+ ```
525
+
526
+ ```tsx
527
+ "use client";
528
+ import { Link } from "@rangojs/router/client";
489
529
 
490
- function BlogIndex() {
530
+ export function Nav({ home, post }: { home: string; post: string }) {
491
531
  return (
492
532
  <nav>
493
- <Link to={reverse("home")}>Home</Link>
494
- <Link to={reverse("blogPost", { slug: "my-post" })}>My Post</Link>
495
- <Link to={reverse("about")}>About</Link>
533
+ <Link to={home}>Home</Link>
534
+ <Link to={post}>My Post</Link>
496
535
  </nav>
497
536
  );
498
537
  }
499
538
  ```
500
539
 
501
- `reverse()` is type-safe route names and required params are checked at compile time. Included routes use dotted names: `reverse("api.health")`.
502
-
503
- Handlers also have `ctx.reverse()` directly on the context:
504
-
505
- ```tsx
506
- const BlogPostPage: Handler<"blogPost"> = (ctx) => {
507
- const backUrl = ctx.reverse("blog");
508
- return <Link to={backUrl}>Back to blog</Link>;
509
- };
510
- ```
540
+ For client-side navigation to static paths (no named-route lookup), use `href()` see below. For URLs tied to named routes, always generate on the server and pass the string in.
511
541
 
512
542
  ### `href()` for Path Validation (Client Components)
513
543
 
514
- In client components, use `href()` for compile-time path validation:
544
+ In client components, use `href()` for compile-time path validation on static path strings:
515
545
 
516
546
  ```tsx
517
547
  "use client";
@@ -1859,12 +1859,13 @@ function getVirtualVersionContent(version) {
1859
1859
 
1860
1860
  // src/vite/utils/package-resolution.ts
1861
1861
  import { existsSync } from "node:fs";
1862
+ import { createRequire } from "node:module";
1862
1863
  import { resolve } from "node:path";
1863
1864
 
1864
1865
  // package.json
1865
1866
  var package_default = {
1866
1867
  name: "@rangojs/router",
1867
- version: "0.0.0-experimental.ad1a365c",
1868
+ version: "0.0.0-experimental.b000c598",
1868
1869
  description: "Django-inspired RSC router with composable URL patterns",
1869
1870
  keywords: [
1870
1871
  "react",
@@ -1997,9 +1998,9 @@ var package_default = {
1997
1998
  tag: "experimental"
1998
1999
  },
1999
2000
  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",
2001
+ 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
2002
  prepublishOnly: "pnpm build",
2002
- typecheck: "tsc --noEmit",
2003
+ typecheck: "tsc --noEmit && tsc -p tsconfig.strict-check.json --noEmit",
2003
2004
  test: "playwright test",
2004
2005
  "test:ui": "playwright test --ui",
2005
2006
  "test:unit": "vitest run",
@@ -2041,6 +2042,7 @@ var package_default = {
2041
2042
  };
2042
2043
 
2043
2044
  // src/vite/utils/package-resolution.ts
2045
+ var require2 = createRequire(import.meta.url);
2044
2046
  var VIRTUAL_PACKAGE_NAME = "@rangojs/router";
2045
2047
  function getPublishedPackageName() {
2046
2048
  return package_default.name;
@@ -2081,6 +2083,20 @@ function getPackageAliases() {
2081
2083
  }
2082
2084
  return aliases;
2083
2085
  }
2086
+ function getVendorAliases() {
2087
+ const specs = [
2088
+ "@vitejs/plugin-rsc/vendor/react-server-dom/client.edge",
2089
+ "@vitejs/plugin-rsc/vendor/react-server-dom/server.edge"
2090
+ ];
2091
+ const aliases = {};
2092
+ for (const spec of specs) {
2093
+ try {
2094
+ aliases[spec] = require2.resolve(spec);
2095
+ } catch {
2096
+ }
2097
+ }
2098
+ return aliases;
2099
+ }
2084
2100
 
2085
2101
  // src/build/route-types/param-extraction.ts
2086
2102
  function extractParamsFromPattern(pattern) {
@@ -3260,7 +3276,7 @@ function createCjsToEsmPlugin() {
3260
3276
  import { createServer as createViteServer } from "vite";
3261
3277
  import { resolve as resolve8 } from "node:path";
3262
3278
  import { readFileSync as readFileSync6 } from "node:fs";
3263
- import { createRequire } from "node:module";
3279
+ import { createRequire as createRequire2, register } from "node:module";
3264
3280
  import { pathToFileURL } from "node:url";
3265
3281
 
3266
3282
  // src/vite/plugins/virtual-stub-plugin.ts
@@ -3287,6 +3303,113 @@ function createVirtualStubPlugin() {
3287
3303
  };
3288
3304
  }
3289
3305
 
3306
+ // src/vite/plugins/cloudflare-protocol-stub.ts
3307
+ var VIRTUAL_PREFIX = "virtual:rango-cloudflare-stub-";
3308
+ var NULL_PREFIX = "\0" + VIRTUAL_PREFIX;
3309
+ var CF_PREFIX = "cloudflare:";
3310
+ var BUILD_ENV_GLOBAL_KEY = "__rango_build_env__";
3311
+ var SOURCE_EXT_RE = /\.[mc]?[jt]sx?$/;
3312
+ var IMPORT_NODE_TYPES = /* @__PURE__ */ new Set([
3313
+ "ImportDeclaration",
3314
+ "ImportExpression",
3315
+ "ExportNamedDeclaration",
3316
+ "ExportAllDeclaration"
3317
+ ]);
3318
+ var STUBS = {
3319
+ "cloudflare:workers": `
3320
+ export class DurableObject { constructor(_ctx, _env) {} }
3321
+ export class WorkerEntrypoint { constructor(_ctx, _env) {} }
3322
+ export class WorkflowEntrypoint { constructor(_ctx, _env) {} }
3323
+ export class RpcTarget {}
3324
+ export const env = globalThis[${JSON.stringify(BUILD_ENV_GLOBAL_KEY)}] ?? {};
3325
+ export default {};
3326
+ `,
3327
+ "cloudflare:email": `
3328
+ export class EmailMessage { constructor(_from, _to, _raw) {} }
3329
+ export default {};
3330
+ `,
3331
+ "cloudflare:sockets": `
3332
+ export function connect() { return {}; }
3333
+ export default {};
3334
+ `,
3335
+ "cloudflare:workflows": `
3336
+ export class NonRetryableError extends Error {
3337
+ constructor(message, name) { super(message); this.name = name ?? "NonRetryableError"; }
3338
+ }
3339
+ export default {};
3340
+ `
3341
+ };
3342
+ var FALLBACK_STUB = `export default {};
3343
+ `;
3344
+ function createCloudflareProtocolStubPlugin() {
3345
+ return {
3346
+ name: "@rangojs/router:cloudflare-protocol-stub",
3347
+ transform(code, id) {
3348
+ const cleanId = id.split("?")[0] ?? id;
3349
+ if (!SOURCE_EXT_RE.test(cleanId)) return null;
3350
+ if (!code.includes(CF_PREFIX)) return null;
3351
+ let ast;
3352
+ try {
3353
+ ast = this.parse(code);
3354
+ } catch {
3355
+ return null;
3356
+ }
3357
+ const hits = [];
3358
+ walk(ast, (node) => {
3359
+ if (!IMPORT_NODE_TYPES.has(node.type)) return;
3360
+ const source = node.source;
3361
+ if (!source || source.type !== "Literal") return;
3362
+ if (typeof source.value !== "string") return;
3363
+ if (!source.value.startsWith(CF_PREFIX)) return;
3364
+ if (typeof source.start !== "number" || typeof source.end !== "number")
3365
+ return;
3366
+ hits.push({
3367
+ start: source.start,
3368
+ end: source.end,
3369
+ value: source.value
3370
+ });
3371
+ });
3372
+ if (hits.length === 0) return null;
3373
+ hits.sort((a, b) => b.start - a.start);
3374
+ let out = code;
3375
+ for (const hit of hits) {
3376
+ const submodule = hit.value.slice(CF_PREFIX.length);
3377
+ const quote = code[hit.start] === "'" ? "'" : '"';
3378
+ out = out.slice(0, hit.start) + quote + VIRTUAL_PREFIX + submodule + quote + out.slice(hit.end);
3379
+ }
3380
+ return { code: out, map: null };
3381
+ },
3382
+ resolveId(id) {
3383
+ if (id.startsWith(VIRTUAL_PREFIX)) {
3384
+ return "\0" + id;
3385
+ }
3386
+ return null;
3387
+ },
3388
+ load(id) {
3389
+ if (!id.startsWith(NULL_PREFIX)) return null;
3390
+ const submodule = id.slice(NULL_PREFIX.length);
3391
+ const specifier = CF_PREFIX + submodule;
3392
+ return STUBS[specifier] ?? FALLBACK_STUB;
3393
+ }
3394
+ };
3395
+ }
3396
+ function walk(node, visit) {
3397
+ if (!node || typeof node !== "object") return;
3398
+ if (Array.isArray(node)) {
3399
+ for (const child of node) walk(child, visit);
3400
+ return;
3401
+ }
3402
+ const n = node;
3403
+ if (typeof n.type !== "string") return;
3404
+ visit(n);
3405
+ for (const key in n) {
3406
+ if (key === "loc" || key === "start" || key === "end" || key === "range") {
3407
+ continue;
3408
+ }
3409
+ walk(n[key], visit);
3410
+ }
3411
+ }
3412
+
3290
3413
  // src/vite/plugins/client-ref-hashing.ts
3291
3414
  import { relative } from "node:path";
3292
3415
  import { createHash as createHash2 } from "node:crypto";
@@ -4682,7 +4805,22 @@ function postprocessBundle(state) {
4682
4805
  }
4683
4806
 
4684
4807
  // src/vite/router-discovery.ts
4808
+ var loaderHookRegistered = false;
4809
+ function ensureCloudflareProtocolLoaderRegistered() {
4810
+ if (loaderHookRegistered) return;
4811
+ loaderHookRegistered = true;
4812
+ try {
4813
+ register(
4814
+ new URL("./plugins/cloudflare-protocol-loader-hook.mjs", import.meta.url)
4815
+ );
4816
+ } catch (err) {
4817
+ console.warn(
4818
+ `[rsc-router] Could not register Node ESM loader hook for cloudflare:* imports (${err?.message ?? err}). Falling back to Vite transform only.`
4819
+ );
4820
+ }
4821
+ }
4685
4822
  async function createTempRscServer(state, options = {}) {
4823
+ ensureCloudflareProtocolLoaderRegistered();
4686
4824
  const { default: rsc } = await import("@vitejs/plugin-rsc");
4687
4825
  return createViteServer({
4688
4826
  root: state.projectRoot,
@@ -4705,6 +4843,7 @@ async function createTempRscServer(state, options = {}) {
4705
4843
  ...options.forceBuild ? [hashClientRefs(state.projectRoot)] : [],
4706
4844
  createVersionPlugin(),
4707
4845
  createVirtualStubPlugin(),
4846
+ createCloudflareProtocolStubPlugin(),
4708
4847
  // Dev prerender must use dev-mode IDs (path-based) to match the workerd
4709
4848
  // runtime. forceBuild produces hashed IDs for production bundle consistency.
4710
4849
  exposeInternalIds(options.forceBuild ? { forceBuild: true } : void 0),
@@ -4721,7 +4860,7 @@ async function resolveBuildEnv(option, factoryCtx) {
4721
4860
  );
4722
4861
  }
4723
4862
  try {
4724
- const userRequire = createRequire(
4863
+ const userRequire = createRequire2(
4725
4864
  resolve8(factoryCtx.root, "package.json")
4726
4865
  );
4727
4866
  const wranglerPath = userRequire.resolve("wrangler");
@@ -4756,6 +4895,7 @@ async function acquireBuildEnv(s, command, mode) {
4756
4895
  if (!result) return false;
4757
4896
  s.resolvedBuildEnv = result.env;
4758
4897
  s.buildEnvDispose = result.dispose ?? null;
4898
+ globalThis[BUILD_ENV_GLOBAL_KEY] = result.env;
4759
4899
  return true;
4760
4900
  }
4761
4901
  async function releaseBuildEnv(s) {
@@ -4768,6 +4908,7 @@ async function releaseBuildEnv(s) {
4768
4908
  s.buildEnvDispose = null;
4769
4909
  }
4770
4910
  s.resolvedBuildEnv = void 0;
4911
+ delete globalThis[BUILD_ENV_GLOBAL_KEY];
4771
4912
  }
4772
4913
  function createRouterDiscoveryPlugin(entryPath, opts) {
4773
4914
  const s = createDiscoveryState(entryPath, opts);
@@ -5273,7 +5414,7 @@ async function rango(options) {
5273
5414
  const preset = resolvedOptions.preset ?? "node";
5274
5415
  const showBanner = resolvedOptions.banner ?? true;
5275
5416
  const plugins = [];
5276
- const rangoAliases = getPackageAliases();
5417
+ const rangoAliases = { ...getPackageAliases(), ...getVendorAliases() };
5277
5418
  const excludeDeps = [
5278
5419
  ...getExcludeDeps(),
5279
5420
  // The public browser entry re-exports the RSDW browser client.
@@ -5284,6 +5425,8 @@ async function rango(options) {
5284
5425
  // cjs-to-esm transform can patch the real file.
5285
5426
  "@vitejs/plugin-rsc/vendor/react-server-dom/client.browser"
5286
5427
  ];
5428
+ const pkg = getPublishedPackageName();
5429
+ const nested = (spec) => `${pkg} > ${spec}`;
5287
5430
  const routerRef = { path: void 0 };
5288
5431
  const prerenderEnabled = true;
5289
5432
  if (preset === "cloudflare") {
@@ -5321,7 +5464,7 @@ async function rango(options) {
5321
5464
  // Pre-bundle rsc-html-stream to prevent discovery during first request
5322
5465
  // Exclude rsc-router modules to ensure same Context instance
5323
5466
  optimizeDeps: {
5324
- include: ["rsc-html-stream/client"],
5467
+ include: [nested("rsc-html-stream/client")],
5325
5468
  exclude: excludeDeps,
5326
5469
  esbuildOptions: sharedEsbuildOptions
5327
5470
  }
@@ -5346,8 +5489,10 @@ async function rango(options) {
5346
5489
  "react-dom/static.edge",
5347
5490
  "react/jsx-runtime",
5348
5491
  "react/jsx-dev-runtime",
5349
- "rsc-html-stream/server",
5350
- "@vitejs/plugin-rsc/vendor/react-server-dom/client.edge"
5492
+ nested("rsc-html-stream/server"),
5493
+ nested(
5494
+ "@vitejs/plugin-rsc/vendor/react-server-dom/client.edge"
5495
+ )
5351
5496
  ],
5352
5497
  exclude: excludeDeps,
5353
5498
  esbuildOptions: sharedEsbuildOptions
@@ -5362,7 +5507,9 @@ async function rango(options) {
5362
5507
  "react",
5363
5508
  "react/jsx-runtime",
5364
5509
  "react/jsx-dev-runtime",
5365
- "@vitejs/plugin-rsc/vendor/react-server-dom/server.edge"
5510
+ nested(
5511
+ "@vitejs/plugin-rsc/vendor/react-server-dom/server.edge"
5512
+ )
5366
5513
  ],
5367
5514
  exclude: excludeDeps,
5368
5515
  esbuildOptions: sharedEsbuildOptions
@@ -5443,7 +5590,7 @@ ${list}`);
5443
5590
  "react-dom",
5444
5591
  "react/jsx-runtime",
5445
5592
  "react/jsx-dev-runtime",
5446
- "rsc-html-stream/client"
5593
+ nested("rsc-html-stream/client")
5447
5594
  ],
5448
5595
  exclude: excludeDeps,
5449
5596
  esbuildOptions: sharedEsbuildOptions,
@@ -5460,7 +5607,9 @@ ${list}`);
5460
5607
  "react-dom/static.edge",
5461
5608
  "react/jsx-runtime",
5462
5609
  "react/jsx-dev-runtime",
5463
- "@vitejs/plugin-rsc/vendor/react-server-dom/client.edge"
5610
+ nested(
5611
+ "@vitejs/plugin-rsc/vendor/react-server-dom/client.edge"
5612
+ )
5464
5613
  ],
5465
5614
  exclude: excludeDeps,
5466
5615
  esbuildOptions: sharedEsbuildOptions
@@ -5473,7 +5622,9 @@ ${list}`);
5473
5622
  "react",
5474
5623
  "react/jsx-runtime",
5475
5624
  "react/jsx-dev-runtime",
5476
- "@vitejs/plugin-rsc/vendor/react-server-dom/server.edge"
5625
+ nested(
5626
+ "@vitejs/plugin-rsc/vendor/react-server-dom/server.edge"
5627
+ )
5477
5628
  ],
5478
5629
  esbuildOptions: sharedEsbuildOptions
5479
5630
  }
@@ -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.ad1a365c",
3
+ "version": "0.0.0-experimental.b000c598",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -133,9 +133,9 @@
133
133
  "tag": "experimental"
134
134
  },
135
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",
136
+ "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",
137
137
  "prepublishOnly": "pnpm build",
138
- "typecheck": "tsc --noEmit",
138
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.strict-check.json --noEmit",
139
139
  "test": "playwright test",
140
140
  "test:ui": "playwright test --ui",
141
141
  "test:unit": "vitest run",
@@ -141,9 +141,11 @@ path("/dashboard", (ctx) => {
141
141
  breadcrumb({ label: "Dashboard", href: "/dashboard" });
142
142
  return <DashboardNav handle={Breadcrumbs} />;
143
143
  });
144
+ ```
144
145
 
146
+ ```tsx
145
147
  // Client component
146
- ("use client");
148
+ "use client";
147
149
  import { useHandle, type Breadcrumbs } from "@rangojs/router/client";
148
150
 
149
151
  function DashboardNav({ handle }: { handle: typeof Breadcrumbs }) {
@@ -298,9 +298,11 @@ path("/dashboard", (ctx) => {
298
298
  push({ label: "Dashboard", href: "/dashboard" });
299
299
  return <DashboardNav handle={Breadcrumbs} />;
300
300
  });
301
+ ```
301
302
 
303
+ ```tsx
302
304
  // Client component — typeof infers the full Handle<T> type
303
- ("use client");
305
+ "use client";
304
306
  import { useHandle, type Breadcrumbs } from "@rangojs/router/client";
305
307
 
306
308
  function DashboardNav({ handle }: { handle: typeof Breadcrumbs }) {
@@ -593,6 +595,12 @@ function ProductPage() {
593
595
  return <h1>Product {params.productId}</h1>;
594
596
  }
595
597
 
598
+ // Annotate the expected shape via a generic
599
+ function ProductPageTyped() {
600
+ const { productId } = useParams<{ productId: string }>();
601
+ return <h1>Product {productId}</h1>;
602
+ }
603
+
596
604
  // With selector for performance (re-renders only when selected value changes)
597
605
  function ProductId() {
598
606
  const productId = useParams((p) => p.productId);
@@ -600,7 +608,7 @@ function ProductId() {
600
608
  }
601
609
  ```
602
610
 
603
- Returns merged params from all matched route segments. Updates on navigation commit (not during pending navigation).
611
+ Returns merged params from all matched route segments as a `Readonly<T>` map. Updates on navigation commit (not during pending navigation).
604
612
 
605
613
  ### usePathname()
606
614
 
@@ -681,24 +689,24 @@ function MountInfo() {
681
689
  }
682
690
  ```
683
691
 
684
- See `/links` for full URL generation guide including server-side `ctx.reverse`.
692
+ See `/links` for full URL generation guide. The default server API is `ctx.reverse()`; in client components, receive URLs as props, loader data, or server-action return values — `reverse()` is not available in the browser.
685
693
 
686
694
  ## Hook Summary
687
695
 
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 } |
696
+ | Hook | Purpose | Returns |
697
+ | -------------------- | --------------------------------- | ------------------------------------------------------------------ |
698
+ | `useParams()` | Route params | `Readonly<T>` (default `Record<string, string>`) or selected value |
699
+ | `usePathname()` | Current pathname | `string` |
700
+ | `useSearchParams()` | URL search params | `ReadonlyURLSearchParams` |
701
+ | `useHref()` | Mount-aware href | `(path) => string` |
702
+ | `useMount()` | Current include() mount path | `string` |
703
+ | `useNavigation()` | Reactive navigation state | state, location, isStreaming |
704
+ | `useRouter()` | Stable router actions | push, replace, refresh, prefetch, back, forward |
705
+ | `useSegments()` | URL path & segment IDs | path, segmentIds, location |
706
+ | `useLinkStatus()` | Link pending state | { pending } |
707
+ | `useLoader()` | Loader data (strict) | data, isLoading, error |
708
+ | `useFetchLoader()` | Loader with on-demand fetch | data, load, isLoading |
709
+ | `useHandle()` | Accumulated handle data | T (handle type) |
710
+ | `useAction()` | Server action state | state, error, result |
711
+ | `useLocationState()` | History state (persists or flash) | T \| undefined |
712
+ | `useClientCache()` | Cache control | { clear } |