@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.
- package/dist/vite/index.js +148 -97
- package/package.json +17 -18
- package/skills/api-client/SKILL.md +211 -0
- package/skills/mime-routes/SKILL.md +1 -1
- package/skills/rango/SKILL.md +1 -0
- package/skills/response-routes/SKILL.md +61 -43
- package/skills/typesafety/SKILL.md +3 -3
- package/src/__augment-tests__/augmented.check.ts +2 -3
- package/src/build/collect-fallback-refs.ts +107 -0
- package/src/build/generate-manifest.ts +28 -1
- package/src/build/index.ts +8 -1
- package/src/build/prefix-tree-utils.ts +123 -0
- package/src/build/route-trie.ts +43 -0
- package/src/client.tsx +4 -23
- package/src/errors.ts +0 -3
- package/src/href-client.ts +7 -8
- package/src/index.rsc.ts +1 -2
- package/src/index.ts +1 -2
- package/src/router/find-match.ts +54 -6
- package/src/router/lazy-includes.ts +33 -14
- package/src/router/manifest.ts +19 -6
- package/src/router/pattern-matching.ts +15 -2
- package/src/router/router-interfaces.ts +11 -0
- package/src/router/trie-matching.ts +22 -3
- package/src/router.ts +21 -7
- package/src/rsc/manifest-init.ts +28 -41
- package/src/rsc/response-error.ts +79 -12
- package/src/rsc/response-route-handler.ts +16 -13
- package/src/urls/index.ts +1 -2
- package/src/urls/type-extraction.ts +33 -24
- package/src/vite/discovery/discover-routers.ts +46 -29
- package/src/vite/discovery/state.ts +7 -0
- package/src/vite/plugins/client-ref-hashing.ts +12 -1
- package/src/vite/rango.ts +32 -4
- package/src/vite/utils/client-chunks.ts +41 -7
- package/src/vite/utils/manifest-utils.ts +8 -75
- package/src/vite/utils/shared-utils.ts +58 -0
package/dist/vite/index.js
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
4855
|
-
|
|
4856
|
-
|
|
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
|
|
6432
|
-
|
|
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.
|
|
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": "
|
|
158
|
-
"@types/react-dom": "
|
|
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": "
|
|
162
|
-
"react-dom": "
|
|
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 `
|
|
136
|
+
no payloads, so response types resolve to `never`.
|
|
137
137
|
|
|
138
138
|
## How It Works
|
|
139
139
|
|
package/skills/rango/SKILL.md
CHANGED
|
@@ -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` |
|