@rangojs/router 0.0.0-experimental.115 → 0.0.0-experimental.116

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 (37) hide show
  1. package/dist/vite/index.js +148 -97
  2. package/package.json +17 -18
  3. package/skills/api-client/SKILL.md +211 -0
  4. package/skills/mime-routes/SKILL.md +1 -1
  5. package/skills/rango/SKILL.md +1 -0
  6. package/skills/response-routes/SKILL.md +61 -43
  7. package/skills/typesafety/SKILL.md +3 -3
  8. package/src/__augment-tests__/augmented.check.ts +2 -3
  9. package/src/build/collect-fallback-refs.ts +107 -0
  10. package/src/build/generate-manifest.ts +28 -1
  11. package/src/build/index.ts +8 -1
  12. package/src/build/prefix-tree-utils.ts +123 -0
  13. package/src/build/route-trie.ts +43 -0
  14. package/src/client.tsx +4 -23
  15. package/src/errors.ts +0 -3
  16. package/src/href-client.ts +7 -8
  17. package/src/index.rsc.ts +1 -2
  18. package/src/index.ts +1 -2
  19. package/src/router/find-match.ts +54 -6
  20. package/src/router/lazy-includes.ts +33 -14
  21. package/src/router/manifest.ts +19 -6
  22. package/src/router/pattern-matching.ts +15 -2
  23. package/src/router/router-interfaces.ts +11 -0
  24. package/src/router/trie-matching.ts +22 -3
  25. package/src/router.ts +21 -7
  26. package/src/rsc/manifest-init.ts +28 -41
  27. package/src/rsc/response-error.ts +79 -12
  28. package/src/rsc/response-route-handler.ts +16 -13
  29. package/src/urls/index.ts +1 -2
  30. package/src/urls/type-extraction.ts +33 -24
  31. package/src/vite/discovery/discover-routers.ts +46 -29
  32. package/src/vite/discovery/state.ts +7 -0
  33. package/src/vite/plugins/client-ref-hashing.ts +12 -1
  34. package/src/vite/rango.ts +32 -4
  35. package/src/vite/utils/client-chunks.ts +41 -7
  36. package/src/vite/utils/manifest-utils.ts +8 -75
  37. package/src/vite/utils/shared-utils.ts +58 -0
@@ -2130,7 +2130,7 @@ import { resolve } from "node:path";
2130
2130
  // package.json
2131
2131
  var package_default = {
2132
2132
  name: "@rangojs/router",
2133
- version: "0.0.0-experimental.115",
2133
+ version: "0.0.0-experimental.116",
2134
2134
  description: "Django-inspired RSC router with composable URL patterns",
2135
2135
  keywords: [
2136
2136
  "react",
@@ -3415,10 +3415,31 @@ function createVirtualEntriesPlugin(entries, routerPathRef) {
3415
3415
  }
3416
3416
  };
3417
3417
  }
3418
+ function isContentHashedAssetConflict(message) {
3419
+ if (!message) return false;
3420
+ const match = /The emitted file "?([^"\s]+)"? overwrites a previously emitted file/.exec(
3421
+ message
3422
+ );
3423
+ if (!match) return false;
3424
+ const fileName = match[1];
3425
+ const base = fileName.slice(fileName.lastIndexOf("/") + 1);
3426
+ const dot = base.lastIndexOf(".");
3427
+ if (dot <= 0) return false;
3428
+ const stem = base.slice(0, dot);
3429
+ const HASH_LEN = 8;
3430
+ if (stem.length < HASH_LEN + 1 || stem[stem.length - HASH_LEN - 1] !== "-") {
3431
+ return false;
3432
+ }
3433
+ const hash = stem.slice(-HASH_LEN);
3434
+ return /^[A-Za-z0-9_-]+$/.test(hash) && /[A-Z0-9]/.test(hash);
3435
+ }
3418
3436
  function onwarn(warning, defaultHandler) {
3419
3437
  if (warning.code === "MODULE_LEVEL_DIRECTIVE" || warning.code === "SOURCEMAP_ERROR" || warning.code === "EMPTY_BUNDLE" || warning.code === "INEFFECTIVE_DYNAMIC_IMPORT") {
3420
3438
  return;
3421
3439
  }
3440
+ if (warning.code === "FILE_NAME_CONFLICT" && isContentHashedAssetConflict(warning.message)) {
3441
+ return;
3442
+ }
3422
3443
  if (warning.message?.includes("Sourcemap is likely to be incorrect")) {
3423
3444
  return;
3424
3445
  }
@@ -3441,6 +3462,75 @@ function getManualChunks(id) {
3441
3462
  return void 0;
3442
3463
  }
3443
3464
 
3465
+ // src/vite/plugins/client-ref-hashing.ts
3466
+ import { relative } from "node:path";
3467
+ import { createHash as createHash2 } from "node:crypto";
3468
+ var debug7 = createRangoDebugger(NS.transform);
3469
+ var CLIENT_PKG_PROXY_PREFIX = "/@id/__x00__virtual:vite-rsc/client-package-proxy/";
3470
+ var CLIENT_IN_SERVER_PKG_PROXY_PREFIX = "/@id/__x00__virtual:vite-rsc/client-in-server-package-proxy/";
3471
+ var FS_PREFIX = "/@fs/";
3472
+ function hashRefKey(relativeId) {
3473
+ return createHash2("sha256").update(relativeId).digest("hex").slice(0, 12);
3474
+ }
3475
+ function computeProductionHash(projectRoot, refKey) {
3476
+ let toHash;
3477
+ if (refKey.startsWith(CLIENT_PKG_PROXY_PREFIX)) {
3478
+ toHash = refKey.slice(CLIENT_PKG_PROXY_PREFIX.length);
3479
+ } else if (refKey.startsWith(CLIENT_IN_SERVER_PKG_PROXY_PREFIX)) {
3480
+ const absPath = decodeURIComponent(
3481
+ refKey.slice(CLIENT_IN_SERVER_PKG_PROXY_PREFIX.length)
3482
+ );
3483
+ toHash = relative(projectRoot, absPath).replaceAll("\\", "/");
3484
+ } else if (refKey.startsWith(FS_PREFIX)) {
3485
+ const absPath = refKey.slice(FS_PREFIX.length - 1);
3486
+ toHash = relative(projectRoot, absPath).replaceAll("\\", "/");
3487
+ } else if (refKey.startsWith("/")) {
3488
+ toHash = refKey.slice(1);
3489
+ } else {
3490
+ return refKey;
3491
+ }
3492
+ return hashRefKey(toHash);
3493
+ }
3494
+ var REGISTER_CLIENT_REF_RE = /registerClientReference\(\s*(?:(?:\([^)]*\))|(?:\(\)[\s\S]*?\}))\s*,\s*"([^"]+)"\s*,\s*"[^"]+"\s*\)/g;
3495
+ function transformClientRefs(code, projectRoot) {
3496
+ if (!code.includes("registerClientReference")) return null;
3497
+ let hasReplacement = false;
3498
+ const result = code.replace(
3499
+ REGISTER_CLIENT_REF_RE,
3500
+ (match, refKey) => {
3501
+ const hash = computeProductionHash(projectRoot, refKey);
3502
+ if (hash === refKey) return match;
3503
+ hasReplacement = true;
3504
+ return match.replace(`"${refKey}"`, `"${hash}"`);
3505
+ }
3506
+ );
3507
+ return hasReplacement ? result : null;
3508
+ }
3509
+ function hashClientRefs(projectRoot) {
3510
+ const counter = createCounter(debug7, "hash-client-refs");
3511
+ return {
3512
+ name: "@rangojs/router:hash-client-refs",
3513
+ // Run after the RSC plugin's transform (default enforce is normal)
3514
+ enforce: "post",
3515
+ applyToEnvironment(env) {
3516
+ return env.name === "rsc";
3517
+ },
3518
+ buildEnd() {
3519
+ counter?.flush();
3520
+ },
3521
+ transform(code, id) {
3522
+ const start = counter ? performance.now() : 0;
3523
+ try {
3524
+ const result = transformClientRefs(code, projectRoot);
3525
+ if (result === null) return;
3526
+ return { code: result, map: null };
3527
+ } finally {
3528
+ counter?.record(id, performance.now() - start);
3529
+ }
3530
+ }
3531
+ };
3532
+ }
3533
+
3444
3534
  // src/vite/utils/client-chunks.ts
3445
3535
  var debugChunks = createRangoDebugger(NS.chunks);
3446
3536
  function isSharedRuntime(meta) {
@@ -3467,10 +3557,14 @@ var ROUTE_ROOT_DIRS = /* @__PURE__ */ new Set([
3467
3557
  "screens",
3468
3558
  "sections"
3469
3559
  ]);
3470
- function directoryClientChunks(meta) {
3560
+ function directoryClientChunks(meta, ctx) {
3471
3561
  if (isSharedRuntime(meta)) {
3472
3562
  return void 0;
3473
3563
  }
3564
+ if (ctx?.fallbackRefs.size && ctx.fallbackRefs.has(hashRefKey(meta.normalizedId))) {
3565
+ debugChunks?.("fallback %s -> app-fallback", meta.normalizedId);
3566
+ return "app-fallback";
3567
+ }
3474
3568
  const segments = meta.normalizedId.split("/").filter(Boolean);
3475
3569
  const dirCount = segments.length - 1;
3476
3570
  if (dirCount >= 1) {
@@ -3488,9 +3582,9 @@ function directoryClientChunks(meta) {
3488
3582
  );
3489
3583
  return void 0;
3490
3584
  }
3491
- function resolveClientChunks(option) {
3585
+ function resolveClientChunks(option, ctx) {
3492
3586
  if (!option) return void 0;
3493
- if (option === true) return directoryClientChunks;
3587
+ if (option === true) return (meta) => directoryClientChunks(meta, ctx);
3494
3588
  return option;
3495
3589
  }
3496
3590
 
@@ -3578,7 +3672,7 @@ function createVersionInjectorPlugin(rscEntryPath) {
3578
3672
  }
3579
3673
 
3580
3674
  // src/vite/plugins/cjs-to-esm.ts
3581
- var debug7 = createRangoDebugger(NS.transform);
3675
+ var debug8 = createRangoDebugger(NS.transform);
3582
3676
  function createCjsToEsmPlugin() {
3583
3677
  return {
3584
3678
  name: "@rangojs/router:cjs-to-esm",
@@ -3588,7 +3682,7 @@ function createCjsToEsmPlugin() {
3588
3682
  if (cleanId.includes("vendor/react-server-dom/client.browser.js")) {
3589
3683
  const isProd = process.env.NODE_ENV === "production";
3590
3684
  const cjsFile = isProd ? "./cjs/react-server-dom-webpack-client.browser.production.js" : "./cjs/react-server-dom-webpack-client.browser.development.js";
3591
- debug7?.("cjs-to-esm entry redirect %s", id);
3685
+ debug8?.("cjs-to-esm entry redirect %s", id);
3592
3686
  return {
3593
3687
  code: `export * from "${cjsFile}";`,
3594
3688
  map: null
@@ -3624,7 +3718,7 @@ function createCjsToEsmPlugin() {
3624
3718
  "export const $1 ="
3625
3719
  );
3626
3720
  transformed = license + "\n" + transformed;
3627
- debug7?.("cjs-to-esm body rewrite %s", id);
3721
+ debug8?.("cjs-to-esm body rewrite %s", id);
3628
3722
  return {
3629
3723
  code: transformed,
3630
3724
  map: null
@@ -3773,72 +3867,6 @@ function walk(node, visit) {
3773
3867
  }
3774
3868
  }
3775
3869
 
3776
- // src/vite/plugins/client-ref-hashing.ts
3777
- import { relative } from "node:path";
3778
- import { createHash as createHash2 } from "node:crypto";
3779
- var debug8 = createRangoDebugger(NS.transform);
3780
- var CLIENT_PKG_PROXY_PREFIX = "/@id/__x00__virtual:vite-rsc/client-package-proxy/";
3781
- var CLIENT_IN_SERVER_PKG_PROXY_PREFIX = "/@id/__x00__virtual:vite-rsc/client-in-server-package-proxy/";
3782
- var FS_PREFIX = "/@fs/";
3783
- function computeProductionHash(projectRoot, refKey) {
3784
- let toHash;
3785
- if (refKey.startsWith(CLIENT_PKG_PROXY_PREFIX)) {
3786
- toHash = refKey.slice(CLIENT_PKG_PROXY_PREFIX.length);
3787
- } else if (refKey.startsWith(CLIENT_IN_SERVER_PKG_PROXY_PREFIX)) {
3788
- const absPath = decodeURIComponent(
3789
- refKey.slice(CLIENT_IN_SERVER_PKG_PROXY_PREFIX.length)
3790
- );
3791
- toHash = relative(projectRoot, absPath).replaceAll("\\", "/");
3792
- } else if (refKey.startsWith(FS_PREFIX)) {
3793
- const absPath = refKey.slice(FS_PREFIX.length - 1);
3794
- toHash = relative(projectRoot, absPath).replaceAll("\\", "/");
3795
- } else if (refKey.startsWith("/")) {
3796
- toHash = refKey.slice(1);
3797
- } else {
3798
- return refKey;
3799
- }
3800
- return createHash2("sha256").update(toHash).digest("hex").slice(0, 12);
3801
- }
3802
- var REGISTER_CLIENT_REF_RE = /registerClientReference\(\s*(?:(?:\([^)]*\))|(?:\(\)[\s\S]*?\}))\s*,\s*"([^"]+)"\s*,\s*"[^"]+"\s*\)/g;
3803
- function transformClientRefs(code, projectRoot) {
3804
- if (!code.includes("registerClientReference")) return null;
3805
- let hasReplacement = false;
3806
- const result = code.replace(
3807
- REGISTER_CLIENT_REF_RE,
3808
- (match, refKey) => {
3809
- const hash = computeProductionHash(projectRoot, refKey);
3810
- if (hash === refKey) return match;
3811
- hasReplacement = true;
3812
- return match.replace(`"${refKey}"`, `"${hash}"`);
3813
- }
3814
- );
3815
- return hasReplacement ? result : null;
3816
- }
3817
- function hashClientRefs(projectRoot) {
3818
- const counter = createCounter(debug8, "hash-client-refs");
3819
- return {
3820
- name: "@rangojs/router:hash-client-refs",
3821
- // Run after the RSC plugin's transform (default enforce is normal)
3822
- enforce: "post",
3823
- applyToEnvironment(env) {
3824
- return env.name === "rsc";
3825
- },
3826
- buildEnd() {
3827
- counter?.flush();
3828
- },
3829
- transform(code, id) {
3830
- const start = counter ? performance.now() : 0;
3831
- try {
3832
- const result = transformClientRefs(code, projectRoot);
3833
- if (result === null) return;
3834
- return { code: result, map: null };
3835
- } finally {
3836
- counter?.record(id, performance.now() - start);
3837
- }
3838
- }
3839
- };
3840
- }
3841
-
3842
3870
  // src/vite/utils/bundle-analysis.ts
3843
3871
  function findMatchingParenInBundle(code, openParenPos) {
3844
3872
  let depth = 1;
@@ -3991,7 +4019,7 @@ function checkSelfGenWrite(state, filePath, consume) {
3991
4019
  }
3992
4020
  }
3993
4021
 
3994
- // src/vite/utils/manifest-utils.ts
4022
+ // src/build/prefix-tree-utils.ts
3995
4023
  function flattenLeafEntries(prefixTree, routeManifest, result) {
3996
4024
  function visit(node, ancestorStaticPrefixes) {
3997
4025
  const children = node.children || {};
@@ -4032,6 +4060,8 @@ function buildRouteToStaticPrefix(prefixTree, result) {
4032
4060
  visit(node);
4033
4061
  }
4034
4062
  }
4063
+
4064
+ // src/vite/utils/manifest-utils.ts
4035
4065
  function jsonParseExpression(value) {
4036
4066
  const json = JSON.stringify(value);
4037
4067
  const escaped = json.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
@@ -4729,6 +4759,15 @@ async function discoverRouters(state, rscEnv) {
4729
4759
  let mergedRouteTrailingSlash = {};
4730
4760
  let routerMountIndex = 0;
4731
4761
  const allManifests = [];
4762
+ const clientChunkCtx = state.opts?.clientChunkCtx;
4763
+ const collectClientFallbackRef = clientChunkCtx ? (refKey) => clientChunkCtx.fallbackRefs.add(
4764
+ computeProductionHash(state.projectRoot, refKey)
4765
+ ) : void 0;
4766
+ const collectFromBoundaryNode = (node) => {
4767
+ if (collectClientFallbackRef && buildMod.collectFallbackClientRefs) {
4768
+ buildMod.collectFallbackClientRefs(node, collectClientFallbackRef);
4769
+ }
4770
+ };
4732
4771
  const manifestGenStart = debug10 ? performance.now() : 0;
4733
4772
  for (const [id, router] of registry) {
4734
4773
  if (!router.urlpatterns || !generateManifestFull) {
@@ -4737,10 +4776,18 @@ async function discoverRouters(state, rscEnv) {
4737
4776
  const manifest = generateManifestFull(
4738
4777
  router.urlpatterns,
4739
4778
  routerMountIndex,
4740
- router.__basename ? { urlPrefix: router.__basename } : void 0
4779
+ {
4780
+ ...router.__basename ? { urlPrefix: router.__basename } : {},
4781
+ ...collectClientFallbackRef ? { collectClientFallbackRef } : {}
4782
+ }
4741
4783
  );
4742
4784
  routerMountIndex++;
4743
4785
  allManifests.push({ id, manifest });
4786
+ if (collectClientFallbackRef) {
4787
+ collectFromBoundaryNode(router.__defaultErrorBoundary);
4788
+ collectFromBoundaryNode(router.__defaultNotFoundBoundary);
4789
+ collectFromBoundaryNode(router.__notFound);
4790
+ }
4744
4791
  const routeCount = Object.keys(manifest.routeManifest).length;
4745
4792
  const staticRoutes = Object.values(manifest.routeManifest).filter(
4746
4793
  (p) => !p.includes(":") && !p.includes("*")
@@ -4850,26 +4897,12 @@ async function discoverRouters(state, rscEnv) {
4850
4897
  passthroughRouteNames,
4851
4898
  mergedResponseTypeRoutes
4852
4899
  );
4900
+ const buildPerRouterTrie = buildMod.buildPerRouterTrie;
4853
4901
  for (const { id, manifest } of allManifests) {
4854
- if (!manifest._routeAncestry || Object.keys(manifest._routeAncestry).length === 0)
4855
- continue;
4856
- const perRouterStaticPrefix = {};
4857
- for (const name of Object.keys(manifest.routeManifest)) {
4858
- perRouterStaticPrefix[name] = "";
4902
+ const perRouterTrie = buildPerRouterTrie ? buildPerRouterTrie(manifest) : null;
4903
+ if (perRouterTrie) {
4904
+ newPerRouterTrieMap.set(id, perRouterTrie);
4859
4905
  }
4860
- buildRouteToStaticPrefix(manifest.prefixTree, perRouterStaticPrefix);
4861
- const perRouterPrerenderNames = manifest.prerenderRoutes ? new Set(manifest.prerenderRoutes) : void 0;
4862
- const perRouterPassthroughNames = manifest.passthroughRoutes ? new Set(manifest.passthroughRoutes) : void 0;
4863
- const perRouterTrie = buildRouteTrie(
4864
- manifest.routeManifest,
4865
- manifest._routeAncestry,
4866
- perRouterStaticPrefix,
4867
- manifest.routeTrailingSlash,
4868
- perRouterPrerenderNames,
4869
- perRouterPassthroughNames,
4870
- manifest.responseTypeRoutes
4871
- );
4872
- newPerRouterTrieMap.set(id, perRouterTrie);
4873
4906
  }
4874
4907
  }
4875
4908
  }
@@ -6428,9 +6461,10 @@ async function rango(options) {
6428
6461
  const resolvedOptions = options ?? { preset: "node" };
6429
6462
  const preset = resolvedOptions.preset ?? "node";
6430
6463
  const showBanner = resolvedOptions.banner ?? true;
6431
- const clientChunks = resolveClientChunks(
6432
- resolvedOptions.clientChunks ?? true
6433
- );
6464
+ const clientChunksOption = resolvedOptions.clientChunks ?? true;
6465
+ const useBuiltInClientChunks = clientChunksOption === true;
6466
+ const clientChunkCtx = useBuiltInClientChunks ? { fallbackRefs: /* @__PURE__ */ new Set() } : void 0;
6467
+ const clientChunks = resolveClientChunks(clientChunksOption, clientChunkCtx);
6434
6468
  debugConfig?.("rango(%s) setup start", preset);
6435
6469
  const plugins = [];
6436
6470
  const rangoAliases = { ...getPackageAliases(), ...getVendorAliases() };
@@ -6486,6 +6520,14 @@ async function rango(options) {
6486
6520
  client: {
6487
6521
  build: {
6488
6522
  rollupOptions: {
6523
+ // FILE_NAME_CONFLICT (and any other client-build warning) is
6524
+ // emitted by the CLIENT environment build, which consults THIS
6525
+ // env's onwarn -- Vite 8's environment builds do NOT propagate
6526
+ // the top-level build.rollupOptions.onwarn into the client env.
6527
+ // Wire it here so the suppression runs where the conflicts
6528
+ // originate (the top-level handler is invoked 0x for these; the
6529
+ // client-env handler is invoked for all of them).
6530
+ onwarn,
6489
6531
  output: {
6490
6532
  manualChunks: getManualChunks
6491
6533
  }
@@ -6614,6 +6656,14 @@ ${list}`);
6614
6656
  client: {
6615
6657
  build: {
6616
6658
  rollupOptions: {
6659
+ // FILE_NAME_CONFLICT (and any other client-build warning) is
6660
+ // emitted by the CLIENT environment build, which consults THIS
6661
+ // env's onwarn -- Vite 8's environment builds do NOT propagate
6662
+ // the top-level build.rollupOptions.onwarn into the client env.
6663
+ // Wire it here so the suppression runs where the conflicts
6664
+ // originate (the top-level handler is invoked 0x for these; the
6665
+ // client-env handler is invoked for all of them).
6666
+ onwarn,
6617
6667
  output: {
6618
6668
  manualChunks: getManualChunks
6619
6669
  }
@@ -6727,7 +6777,8 @@ ${list}`);
6727
6777
  routerPathRef: discoveryRouterRef,
6728
6778
  enableBuildPrerender: prerenderEnabled,
6729
6779
  buildEnv: options?.buildEnv,
6730
- preset
6780
+ preset,
6781
+ clientChunkCtx
6731
6782
  })
6732
6783
  );
6733
6784
  debugConfig?.(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.115",
3
+ "version": "0.0.0-experimental.116",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -132,16 +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 && 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
- "prepublishOnly": "pnpm build",
138
- "typecheck": "tsc --noEmit && tsc -p tsconfig.strict-check.json --noEmit && tsc -p tsconfig.augment-check.json --noEmit",
139
- "test": "playwright test",
140
- "test:ui": "playwright test --ui",
141
- "test:hmr-local": "playwright test --project=dev-warmup --project=hmr-routes --project=hmr-basename --project=hmr-prerender --no-deps --workers=1",
142
- "test:unit": "vitest run",
143
- "test:unit:watch": "vitest"
144
- },
145
135
  "dependencies": {
146
136
  "@types/debug": "^4.1.12",
147
137
  "@vitejs/plugin-rsc": "^0.5.26",
@@ -152,17 +142,17 @@
152
142
  },
153
143
  "devDependencies": {
154
144
  "@playwright/test": "^1.49.1",
155
- "@shared/e2e": "workspace:*",
156
145
  "@types/node": "^24.10.1",
157
- "@types/react": "catalog:",
158
- "@types/react-dom": "catalog:",
146
+ "@types/react": "^19.2.7",
147
+ "@types/react-dom": "^19.2.3",
159
148
  "esbuild": "^0.27.0",
160
149
  "jiti": "^2.6.1",
161
- "react": "catalog:",
162
- "react-dom": "catalog:",
150
+ "react": "^19.2.6",
151
+ "react-dom": "^19.2.6",
163
152
  "tinyexec": "^0.3.2",
164
153
  "typescript": "^5.3.0",
165
- "vitest": "^4.0.0"
154
+ "vitest": "^4.0.0",
155
+ "@shared/e2e": "0.0.1"
166
156
  },
167
157
  "peerDependencies": {
168
158
  "@cloudflare/vite-plugin": "^1.38.0",
@@ -178,5 +168,14 @@
178
168
  "vite": {
179
169
  "optional": true
180
170
  }
171
+ },
172
+ "scripts": {
173
+ "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",
174
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.strict-check.json --noEmit && tsc -p tsconfig.augment-check.json --noEmit",
175
+ "test": "playwright test",
176
+ "test:ui": "playwright test --ui",
177
+ "test:hmr-local": "playwright test --project=dev-warmup --project=hmr-routes --project=hmr-basename --project=hmr-prerender --no-deps --workers=1",
178
+ "test:unit": "vitest run",
179
+ "test:unit:watch": "vitest"
181
180
  }
182
- }
181
+ }
@@ -0,0 +1,211 @@
1
+ ---
2
+ name: api-client
3
+ description: Build a typed client for consuming your own response-route JSON APIs (no codegen)
4
+ ---
5
+
6
+ # Typed API Client
7
+
8
+ Response routes (`path.json()`) already ship typed responses — `RouteResponse<typeof patterns, "name">` resolves to the **bare payload**, inferred from your handler with no codegen. This skill wraps that inference in a small **typed client** so first-party TypeScript code calls your endpoints like functions instead of hand-writing `fetch` + URL building per call site.
9
+
10
+ This is a **recipe, not a framework feature** — copy the helper below into your app. It depends only on **type-only** imports from `@rangojs/router` (`RouteResponse`, `ExtractParams`, `ProblemDetails`), which are erased at build time, so it runs anywhere a `fetch` does — **browser, worker, or server**. Nothing new to install or version.
11
+
12
+ > **Scope:** the typed client is a **first-party TypeScript** convenience. External/third-party consumers use the plain wire directly — bare JSON on success, RFC 9457 `application/problem+json` on error — which needs no client. (Language-agnostic OpenAPI generation is a separate, future feature.)
13
+
14
+ ## What you get
15
+
16
+ ```ts
17
+ const api = createApiClient(apiShopPatterns, routes, { baseUrl });
18
+
19
+ await api.health.get(); // no params → callable bare
20
+ await api.product.get({ params: { productId } }); // params typed + required
21
+ await api.cart.post({ body: { productId, qty: 2 } }); // body sent as JSON
22
+ // ^ result is the bare payload type (RouteResponse), not `any`, no `.data`
23
+ ```
24
+
25
+ - **Output typed** from the handler's return (`RouteResponse`), zero codegen.
26
+ - **Params required + typed** from the route pattern (`/catalog/:productId` → `{ productId: string }`); a missing or misspelled param is a **compile error**, not a runtime 404.
27
+ - **Autocomplete** over every route name; rename-safe.
28
+ - **Errors throw a typed `ApiError`** carrying the `ProblemDetails` body.
29
+
30
+ (`search` and `body` are _not_ route-typed — see Notes.)
31
+
32
+ ## The two inputs
33
+
34
+ 1. **The `urls()` patterns value** — the type source. `typeof apiShopPatterns` carries the per-route response payloads (`_responses`) and patterns (`_routes`).
35
+ 2. **The generated route map** — the name → pattern source. `rango generate` emits a per-module `<name>.gen.ts` exporting `routes`:
36
+
37
+ ```ts
38
+ // api-shop.gen.ts (generated — do not edit)
39
+ export const routes = {
40
+ catalog: "/catalog",
41
+ product: "/catalog/:productId",
42
+ cart: "/cart",
43
+ // ...
44
+ } as const;
45
+ ```
46
+
47
+ Routes that declare a **search schema** are generated as objects instead — `index: { path: "/", search: { q: "string" } }`. The helper accepts both the string and `{ path }` forms. If a `urls()` block is mounted under a name prefix, build a local-keyed map from your global `NamedRoutes` so the keys match the block's route names (e.g. `{ catalog: NamedRoutes["apiShop.catalog"], ... } as const`).
48
+
49
+ ## The helper (copy into your app)
50
+
51
+ ```ts
52
+ // lib/api-client.ts
53
+ import type {
54
+ RouteResponse,
55
+ ExtractParams,
56
+ ProblemDetails,
57
+ } from "@rangojs/router";
58
+
59
+ type SearchParams = Record<string, string | number | boolean>;
60
+
61
+ // A generated route-map entry is a pattern string, or an object with `path`
62
+ // (routes that declare a search schema generate the object form).
63
+ type RouteMapEntry = string | { readonly path: string };
64
+ type PatternOf<E> = E extends string
65
+ ? E
66
+ : E extends { readonly path: infer P extends string }
67
+ ? P
68
+ : never;
69
+
70
+ // `params` is optional when the route has no *required* params (incl.
71
+ // optional-only routes like `/:locale?`), required otherwise. Typed as
72
+ // `ExtractParams` (not `undefined`) so optional params can still be passed.
73
+ type Args<TPattern extends string> =
74
+ {} extends ExtractParams<TPattern>
75
+ ? {
76
+ params?: ExtractParams<TPattern>;
77
+ search?: SearchParams;
78
+ body?: unknown;
79
+ }
80
+ : {
81
+ params: ExtractParams<TPattern>;
82
+ search?: SearchParams;
83
+ body?: unknown;
84
+ };
85
+
86
+ type Method<TPatterns, K extends string, TEntry> =
87
+ {} extends ExtractParams<PatternOf<TEntry>>
88
+ ? (args?: Args<PatternOf<TEntry>>) => Promise<RouteResponse<TPatterns, K>>
89
+ : (args: Args<PatternOf<TEntry>>) => Promise<RouteResponse<TPatterns, K>>;
90
+
91
+ type ApiClient<TPatterns, TRouteMap extends Record<string, RouteMapEntry>> = {
92
+ [K in keyof TRouteMap & string]: {
93
+ get: Method<TPatterns, K, TRouteMap[K]>;
94
+ post: Method<TPatterns, K, TRouteMap[K]>;
95
+ put: Method<TPatterns, K, TRouteMap[K]>;
96
+ patch: Method<TPatterns, K, TRouteMap[K]>;
97
+ delete: Method<TPatterns, K, TRouteMap[K]>;
98
+ };
99
+ };
100
+
101
+ /** Thrown on a non-2xx response; carries the RFC 9457 problem body. */
102
+ export class ApiError extends Error {
103
+ status: number;
104
+ problem: ProblemDetails;
105
+ constructor(status: number, problem: ProblemDetails) {
106
+ super(problem.detail || `HTTP ${status}`);
107
+ this.name = "ApiError";
108
+ this.status = status;
109
+ this.problem = problem;
110
+ }
111
+ }
112
+
113
+ // Client-safe path builder: substitutes :params (incl. optional/constrained
114
+ // forms) into the pattern. No dependency on the server-only createReverse.
115
+ function fillPath(pattern: string, params?: Record<string, string>): string {
116
+ return pattern
117
+ .replace(/:([A-Za-z0-9_]+)(?:\([^)]*\))?\??/g, (_m, name: string) => {
118
+ const v = params?.[name];
119
+ return v == null ? "" : encodeURIComponent(String(v));
120
+ })
121
+ .replace(/\/{2,}/g, "/");
122
+ }
123
+
124
+ export function createApiClient<
125
+ TPatterns,
126
+ const TRouteMap extends Record<string, RouteMapEntry>,
127
+ >(
128
+ _patterns: TPatterns,
129
+ routeMap: TRouteMap,
130
+ opts: { baseUrl?: string; fetch?: typeof fetch } = {},
131
+ ): ApiClient<TPatterns, TRouteMap> {
132
+ const doFetch = opts.fetch ?? fetch;
133
+ const baseUrl = opts.baseUrl ?? "";
134
+ const call =
135
+ (name: string, method: string) =>
136
+ async (args?: {
137
+ params?: Record<string, string>;
138
+ search?: SearchParams;
139
+ body?: unknown;
140
+ }) => {
141
+ const entry = routeMap[name];
142
+ const pattern = typeof entry === "string" ? entry : entry.path;
143
+ let url = baseUrl + fillPath(pattern, args?.params);
144
+ if (args?.search) {
145
+ const qs = new URLSearchParams();
146
+ for (const [k, v] of Object.entries(args.search)) {
147
+ if (v != null) qs.append(k, String(v));
148
+ }
149
+ const s = qs.toString();
150
+ if (s) url += (url.includes("?") ? "&" : "?") + s;
151
+ }
152
+ const res = await doFetch(url, {
153
+ method,
154
+ ...(args?.body !== undefined
155
+ ? {
156
+ body: JSON.stringify(args.body),
157
+ headers: { "content-type": "application/json" },
158
+ }
159
+ : {}),
160
+ });
161
+ if (!res.ok) {
162
+ const problem = (await res.json().catch(() => ({}))) as ProblemDetails;
163
+ throw new ApiError(res.status, problem);
164
+ }
165
+ return res.json();
166
+ };
167
+ return new Proxy({} as any, {
168
+ get: (_t, name: string) => ({
169
+ get: call(name, "GET"),
170
+ post: call(name, "POST"),
171
+ put: call(name, "PUT"),
172
+ patch: call(name, "PATCH"),
173
+ delete: call(name, "DELETE"),
174
+ }),
175
+ }) as ApiClient<TPatterns, TRouteMap>;
176
+ }
177
+ ```
178
+
179
+ ## Using it
180
+
181
+ ```ts
182
+ import { apiShopPatterns } from "./urls/api-shop";
183
+ import { routes } from "./urls/api-shop.gen";
184
+ import { createApiClient, ApiError } from "./lib/api-client";
185
+
186
+ const api = createApiClient(apiShopPatterns, routes, {
187
+ baseUrl: import.meta.env.VITE_API_URL ?? "",
188
+ });
189
+
190
+ try {
191
+ const product = await api.product.get({ params: { productId: "42" } });
192
+ // `product` is the handler's bare return type — e.g. `product.name` is typed.
193
+ } catch (err) {
194
+ if (err instanceof ApiError && err.status === 404) {
195
+ console.warn(err.problem.code, err.problem.detail); // typed ProblemDetails
196
+ } else {
197
+ throw err;
198
+ }
199
+ }
200
+ ```
201
+
202
+ ## Notes
203
+
204
+ - **Client-safe by construction.** The helper imports only **types** from `@rangojs/router` (erased at build) and builds URLs itself by substituting `:params` into the pattern — it does **not** use `createReverse`, which is a server/RSC-only export that throws in the browser. So `createApiClient` works in client components, workers, and on the server alike.
205
+ - **Params are route-typed; search and body are not.** Path params come from the route pattern (`ExtractParams`), so they are precise and required. `search` is generically typed (`Record<string, string | number | boolean>`), and `body` is `unknown` (serialized to JSON). Typed request **input** needs a declared schema layer, which is intentionally out of scope here — thread per-route schemas in yourself if you want typed search/body.
206
+ - **Verb-agnostic wire.** Rango response routes do not dispatch on HTTP method — `.get`/`.post`/etc. set the request method but hit the same handler. Use whichever verb reads best for the operation.
207
+ - **Path building.** `fillPath` handles standard `:param`, optional `:param?`, and constrained `:param(a|b)` forms. For exotic patterns or strict trailing-slash policies, swap in your own builder (or the router's `reverse` on the server).
208
+ - **Want a return-based style instead of throwing?** Branch on `res.ok` yourself: the wire is the bare value on 2xx and `ProblemDetails` on non-2xx (see `/response-routes`). Wrapping the calls in a `{ ok, data } | { ok: false, error }` result type is a small variation on the same helper.
209
+ - **Third parties.** The typed client is TypeScript-only and needs your route types. External consumers in any language use the plain wire as-is (bare JSON + problem+json); no client required.
210
+
211
+ See `/response-routes` for the endpoint side and `/typesafety` for how `RouteResponse` / `PathResponse` inference works.
@@ -133,7 +133,7 @@ declare global {
133
133
 
134
134
  `RegisteredRoutes` is what exposes the richer routeMap entries containing
135
135
  response payload metadata. Without it, URL-pattern response lookup has paths but
136
- no payloads, so response types resolve to `ResponseEnvelope<never>`.
136
+ no payloads, so response types resolve to `never`.
137
137
 
138
138
  ## How It Works
139
139
 
@@ -192,6 +192,7 @@ Grouped by concern — read when you need to…
192
192
  | `/host-router` | Multi-app host routing with domain/subdomain patterns |
193
193
  | `/links` | URL generation: ctx.reverse, href, useHref, useMount, scopedReverse |
194
194
  | `/response-routes` | JSON/text/HTML/XML/stream endpoints with `path.json()`, `path.text()` |
195
+ | `/api-client` | Typed client for consuming your own response-route JSON APIs (recipe) |
195
196
  | `/mime-routes` | Content negotiation — same URL, different response types via Accept header |
196
197
  | `/streams-and-websockets` | SSE via `path.stream` and WebSocket upgrades via `path.any` |
197
198
  | `/handler-use` | Attach default loaders/middleware to a handler via `handler.use` |