@rangojs/router 0.0.0-experimental.112 → 0.0.0-experimental.114
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/bin/rango.js +74 -3
- package/dist/vite/index.js +133 -18
- package/package.json +1 -1
- package/skills/cache-guide/SKILL.md +35 -24
- package/skills/caching/SKILL.md +115 -7
- package/skills/document-cache/SKILL.md +78 -55
- package/skills/hooks/SKILL.md +40 -22
- package/skills/links/SKILL.md +10 -10
- package/skills/loader/SKILL.md +3 -3
- package/skills/rango/SKILL.md +16 -10
- package/skills/react-compiler/SKILL.md +168 -0
- package/skills/use-cache/SKILL.md +34 -5
- package/skills/view-transitions/SKILL.md +85 -3
- package/src/browser/react/location-state-shared.ts +93 -3
- package/src/browser/react/use-reverse.ts +19 -12
- package/src/build/route-types/per-module-writer.ts +4 -1
- package/src/build/route-types/router-processing.ts +14 -1
- package/src/build/route-types/source-scan.ts +118 -0
- package/src/cache/cache-scope.ts +28 -42
- package/src/cache/cf/cf-cache-store.ts +49 -6
- package/src/handle.ts +3 -5
- package/src/loader-store.ts +62 -25
- package/src/loader.rsc.ts +2 -5
- package/src/loader.ts +3 -10
- package/src/missing-id-error.ts +68 -0
- package/src/reverse.ts +16 -13
- package/src/route-definition/dsl-helpers.ts +5 -2
- package/src/route-definition/helpers-types.ts +31 -10
- package/src/router/loader-resolution.ts +16 -2
- package/src/router/match-middleware/cache-lookup.ts +44 -91
- package/src/router/match-middleware/cache-store.ts +3 -2
- package/src/router/router-options.ts +24 -0
- package/src/router/segment-resolution/fresh.ts +17 -4
- package/src/router/segment-resolution/revalidation.ts +17 -4
- package/src/router/segment-resolution/view-transition-default.ts +36 -0
- package/src/router/types.ts +8 -0
- package/src/router.ts +2 -0
- package/src/segment-system.tsx +59 -10
- package/src/server/context.ts +26 -0
- package/src/server/cookie-store.ts +28 -4
- package/src/types/handler-context.ts +5 -2
- package/src/types/segments.ts +18 -1
- package/src/urls/path-helper-types.ts +9 -1
- package/src/use-loader.tsx +89 -42
- package/src/vite/plugins/expose-ids/export-analysis.ts +68 -12
- package/src/vite/plugins/expose-internal-ids.ts +12 -4
- package/src/vite/plugins/use-cache-transform.ts +12 -10
- package/src/vite/router-discovery.ts +14 -2
package/dist/bin/rango.js
CHANGED
|
@@ -543,7 +543,7 @@ function writePerModuleRouteTypesForFile(filePath) {
|
|
|
543
543
|
} else {
|
|
544
544
|
routes = extractRoutesFromSource(source);
|
|
545
545
|
}
|
|
546
|
-
const genPath = filePath.replace(/\.(tsx?)$/, ".gen.ts");
|
|
546
|
+
const genPath = filePath.replace(/\.(tsx?|jsx?)$/, ".gen.ts");
|
|
547
547
|
if (routes.length === 0) {
|
|
548
548
|
if (varNames.length > 0 && !existsSync2(genPath)) {
|
|
549
549
|
writeFileSync(genPath, generatePerModuleTypesSource([]));
|
|
@@ -576,6 +576,75 @@ var init_per_module_writer = __esm({
|
|
|
576
576
|
}
|
|
577
577
|
});
|
|
578
578
|
|
|
579
|
+
// src/build/route-types/source-scan.ts
|
|
580
|
+
function isLineTerminator(ch) {
|
|
581
|
+
const c = ch.charCodeAt(0);
|
|
582
|
+
return c === 10 || c === 13 || c === 8232 || c === 8233;
|
|
583
|
+
}
|
|
584
|
+
function makeCodeClassifier(code) {
|
|
585
|
+
const n = code.length;
|
|
586
|
+
let i = 0;
|
|
587
|
+
let skipStart = -1;
|
|
588
|
+
let skipEnd = -1;
|
|
589
|
+
return (q) => {
|
|
590
|
+
if (q >= skipStart && q < skipEnd) return false;
|
|
591
|
+
while (i < n && i <= q) {
|
|
592
|
+
const c = code[i];
|
|
593
|
+
const d = i + 1 < n ? code[i + 1] : "";
|
|
594
|
+
let end = -1;
|
|
595
|
+
if (c === "/" && d === "/") {
|
|
596
|
+
let j = i + 2;
|
|
597
|
+
while (j < n && !isLineTerminator(code[j])) j++;
|
|
598
|
+
end = j;
|
|
599
|
+
} else if (c === "/" && d === "*") {
|
|
600
|
+
let j = i + 2;
|
|
601
|
+
while (j < n && !(code[j] === "*" && code[j + 1] === "/")) j++;
|
|
602
|
+
end = Math.min(n, j + 2);
|
|
603
|
+
} else if (c === '"' || c === "'" || c === "`") {
|
|
604
|
+
let j = i + 1;
|
|
605
|
+
while (j < n) {
|
|
606
|
+
if (code[j] === "\\") {
|
|
607
|
+
j += 2;
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
if (code[j] === c) {
|
|
611
|
+
j++;
|
|
612
|
+
break;
|
|
613
|
+
}
|
|
614
|
+
j++;
|
|
615
|
+
}
|
|
616
|
+
end = j;
|
|
617
|
+
}
|
|
618
|
+
if (end >= 0) {
|
|
619
|
+
if (q < end) {
|
|
620
|
+
skipStart = i;
|
|
621
|
+
skipEnd = end;
|
|
622
|
+
return false;
|
|
623
|
+
}
|
|
624
|
+
i = end;
|
|
625
|
+
} else {
|
|
626
|
+
i++;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return true;
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
function firstCodeMatchIndex(code, pattern) {
|
|
633
|
+
const inCode = makeCodeClassifier(code);
|
|
634
|
+
pattern.lastIndex = 0;
|
|
635
|
+
let m;
|
|
636
|
+
while ((m = pattern.exec(code)) !== null) {
|
|
637
|
+
if (inCode(m.index)) return m.index;
|
|
638
|
+
if (pattern.lastIndex <= m.index) pattern.lastIndex = m.index + 1;
|
|
639
|
+
}
|
|
640
|
+
return -1;
|
|
641
|
+
}
|
|
642
|
+
var init_source_scan = __esm({
|
|
643
|
+
"src/build/route-types/source-scan.ts"() {
|
|
644
|
+
"use strict";
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
|
|
579
648
|
// src/build/route-types/router-processing.ts
|
|
580
649
|
import {
|
|
581
650
|
readFileSync as readFileSync3,
|
|
@@ -630,7 +699,7 @@ function findRouterFilesRecursive(dir, filter, results) {
|
|
|
630
699
|
if (filter && !filter(fullPath)) continue;
|
|
631
700
|
try {
|
|
632
701
|
const source = readFileSync3(fullPath, "utf-8");
|
|
633
|
-
if (ROUTER_CALL_PATTERN.test(source)) {
|
|
702
|
+
if (ROUTER_CALL_PATTERN.test(source) && firstCodeMatchIndex(source, ROUTER_CALL_PATTERN_G) >= 0) {
|
|
634
703
|
routerFilesInDir.push(fullPath);
|
|
635
704
|
}
|
|
636
705
|
} catch {
|
|
@@ -971,15 +1040,17 @@ function writeCombinedRouteTypes(root, knownRouterFiles, opts) {
|
|
|
971
1040
|
}
|
|
972
1041
|
}
|
|
973
1042
|
}
|
|
974
|
-
var ROUTER_CALL_PATTERN;
|
|
1043
|
+
var ROUTER_CALL_PATTERN, ROUTER_CALL_PATTERN_G;
|
|
975
1044
|
var init_router_processing = __esm({
|
|
976
1045
|
"src/build/route-types/router-processing.ts"() {
|
|
977
1046
|
"use strict";
|
|
978
1047
|
init_codegen();
|
|
1048
|
+
init_source_scan();
|
|
979
1049
|
init_include_resolution();
|
|
980
1050
|
init_per_module_writer();
|
|
981
1051
|
init_route_name();
|
|
982
1052
|
ROUTER_CALL_PATTERN = /\bcreateRouter\s*[<(]/;
|
|
1053
|
+
ROUTER_CALL_PATTERN_G = /\bcreateRouter\s*[<(]/g;
|
|
983
1054
|
}
|
|
984
1055
|
});
|
|
985
1056
|
|
package/dist/vite/index.js
CHANGED
|
@@ -744,6 +744,83 @@ var STRICT_CREATE_CONFIGS = [
|
|
|
744
744
|
|
|
745
745
|
// src/vite/plugins/expose-ids/export-analysis.ts
|
|
746
746
|
import { parseAst } from "vite";
|
|
747
|
+
|
|
748
|
+
// src/build/route-types/source-scan.ts
|
|
749
|
+
function isLineTerminator(ch) {
|
|
750
|
+
const c = ch.charCodeAt(0);
|
|
751
|
+
return c === 10 || c === 13 || c === 8232 || c === 8233;
|
|
752
|
+
}
|
|
753
|
+
function makeCodeClassifier(code) {
|
|
754
|
+
const n = code.length;
|
|
755
|
+
let i = 0;
|
|
756
|
+
let skipStart = -1;
|
|
757
|
+
let skipEnd = -1;
|
|
758
|
+
return (q) => {
|
|
759
|
+
if (q >= skipStart && q < skipEnd) return false;
|
|
760
|
+
while (i < n && i <= q) {
|
|
761
|
+
const c = code[i];
|
|
762
|
+
const d = i + 1 < n ? code[i + 1] : "";
|
|
763
|
+
let end = -1;
|
|
764
|
+
if (c === "/" && d === "/") {
|
|
765
|
+
let j = i + 2;
|
|
766
|
+
while (j < n && !isLineTerminator(code[j])) j++;
|
|
767
|
+
end = j;
|
|
768
|
+
} else if (c === "/" && d === "*") {
|
|
769
|
+
let j = i + 2;
|
|
770
|
+
while (j < n && !(code[j] === "*" && code[j + 1] === "/")) j++;
|
|
771
|
+
end = Math.min(n, j + 2);
|
|
772
|
+
} else if (c === '"' || c === "'" || c === "`") {
|
|
773
|
+
let j = i + 1;
|
|
774
|
+
while (j < n) {
|
|
775
|
+
if (code[j] === "\\") {
|
|
776
|
+
j += 2;
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
if (code[j] === c) {
|
|
780
|
+
j++;
|
|
781
|
+
break;
|
|
782
|
+
}
|
|
783
|
+
j++;
|
|
784
|
+
}
|
|
785
|
+
end = j;
|
|
786
|
+
}
|
|
787
|
+
if (end >= 0) {
|
|
788
|
+
if (q < end) {
|
|
789
|
+
skipStart = i;
|
|
790
|
+
skipEnd = end;
|
|
791
|
+
return false;
|
|
792
|
+
}
|
|
793
|
+
i = end;
|
|
794
|
+
} else {
|
|
795
|
+
i++;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
return true;
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
function firstCodeMatchIndex(code, pattern) {
|
|
802
|
+
const inCode = makeCodeClassifier(code);
|
|
803
|
+
pattern.lastIndex = 0;
|
|
804
|
+
let m;
|
|
805
|
+
while ((m = pattern.exec(code)) !== null) {
|
|
806
|
+
if (inCode(m.index)) return m.index;
|
|
807
|
+
if (pattern.lastIndex <= m.index) pattern.lastIndex = m.index + 1;
|
|
808
|
+
}
|
|
809
|
+
return -1;
|
|
810
|
+
}
|
|
811
|
+
function codeMatchIndices(code, pattern) {
|
|
812
|
+
const inCode = makeCodeClassifier(code);
|
|
813
|
+
const indices = [];
|
|
814
|
+
pattern.lastIndex = 0;
|
|
815
|
+
let m;
|
|
816
|
+
while ((m = pattern.exec(code)) !== null) {
|
|
817
|
+
if (inCode(m.index)) indices.push(m.index);
|
|
818
|
+
if (pattern.lastIndex <= m.index) pattern.lastIndex = m.index + 1;
|
|
819
|
+
}
|
|
820
|
+
return indices;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// src/vite/plugins/expose-ids/export-analysis.ts
|
|
747
824
|
function isExportOnlyFile(code, bindings) {
|
|
748
825
|
if (bindings.length === 0) return false;
|
|
749
826
|
const knownLocals = /* @__PURE__ */ new Set();
|
|
@@ -772,12 +849,30 @@ function isExportOnlyFile(code, bindings) {
|
|
|
772
849
|
}
|
|
773
850
|
return true;
|
|
774
851
|
}
|
|
775
|
-
function
|
|
776
|
-
|
|
852
|
+
function createCallPattern(fnNames) {
|
|
853
|
+
return new RegExp(
|
|
777
854
|
`\\b(?:${fnNames.map(escapeRegExp).join("|")})\\s*(?:<[^>]*>\\s*)?\\(`,
|
|
778
855
|
"g"
|
|
779
856
|
);
|
|
780
|
-
|
|
857
|
+
}
|
|
858
|
+
function countCreateCallsForNames(code, fnNames) {
|
|
859
|
+
return codeMatchIndices(code, createCallPattern(fnNames)).length;
|
|
860
|
+
}
|
|
861
|
+
function offsetToLineColumn(code, index) {
|
|
862
|
+
let line = 1;
|
|
863
|
+
let lineStart = 0;
|
|
864
|
+
const end = Math.min(index, code.length);
|
|
865
|
+
for (let i = 0; i < end; i++) {
|
|
866
|
+
if (code[i] === "\n") {
|
|
867
|
+
line++;
|
|
868
|
+
lineStart = i + 1;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
return { line, column: index - lineStart + 1 };
|
|
872
|
+
}
|
|
873
|
+
function findUnsupportedCreateCallSites(code, fnNames, supportedBindings) {
|
|
874
|
+
const supported = new Set(supportedBindings.map((b) => b.callExprStart));
|
|
875
|
+
return codeMatchIndices(code, createCallPattern(fnNames)).filter((index) => !supported.has(index)).map((index) => offsetToLineColumn(code, index));
|
|
781
876
|
}
|
|
782
877
|
function getImportedFnNames(code, importedName) {
|
|
783
878
|
const importPattern = /import\s*\{([^}]*)\}\s*from\s*["']@rangojs\/router(?:\/[^"']*)?["']/g;
|
|
@@ -936,9 +1031,20 @@ function collectCreateExportBindings(code, fnNames, program) {
|
|
|
936
1031
|
}
|
|
937
1032
|
return bindings;
|
|
938
1033
|
}
|
|
939
|
-
function buildUnsupportedShapeWarning(filePath, fnName) {
|
|
940
|
-
|
|
941
|
-
|
|
1034
|
+
function buildUnsupportedShapeWarning(filePath, fnName, sites = []) {
|
|
1035
|
+
const lines = [`[rango] Unsupported ${fnName} shape in "${filePath}".`];
|
|
1036
|
+
if (sites.length === 1) {
|
|
1037
|
+
const s = sites[0];
|
|
1038
|
+
lines.push(
|
|
1039
|
+
`The ${fnName}(...) call at ${filePath}:${s.line}:${s.column} has no stable $$id injected \u2014 it is not in a supported shape.`
|
|
1040
|
+
);
|
|
1041
|
+
} else if (sites.length > 1) {
|
|
1042
|
+
lines.push(
|
|
1043
|
+
`These ${fnName}(...) calls have no stable $$id injected \u2014 they are not in a supported shape:`
|
|
1044
|
+
);
|
|
1045
|
+
for (const s of sites) lines.push(` - ${filePath}:${s.line}:${s.column}`);
|
|
1046
|
+
}
|
|
1047
|
+
lines.push(
|
|
942
1048
|
`Supported shapes are:`,
|
|
943
1049
|
` - export const X = ${fnName}(...)`,
|
|
944
1050
|
` - const X = ${fnName}(...); export { X }`,
|
|
@@ -946,7 +1052,8 @@ function buildUnsupportedShapeWarning(filePath, fnName) {
|
|
|
946
1052
|
`Potentially unsupported forms include:`,
|
|
947
1053
|
` - export let/var X = ${fnName}(...)`,
|
|
948
1054
|
` - inline ${fnName}(...) calls`
|
|
949
|
-
|
|
1055
|
+
);
|
|
1056
|
+
return lines.join("\n");
|
|
950
1057
|
}
|
|
951
1058
|
|
|
952
1059
|
// src/vite/plugins/expose-ids/loader-transform.ts
|
|
@@ -1366,13 +1473,16 @@ ${lazyImports.join(",\n")}
|
|
|
1366
1473
|
const hasCode = cfg.fnName === "createLoader" ? hasLoaderCode : cfg.fnName === "createHandle" ? hasHandleCode : hasLocationStateCode;
|
|
1367
1474
|
if (!hasCode) continue;
|
|
1368
1475
|
const fnNames = getFnNames(cfg.fnName);
|
|
1369
|
-
const
|
|
1370
|
-
|
|
1371
|
-
|
|
1476
|
+
const sites = findUnsupportedCreateCallSites(
|
|
1477
|
+
code,
|
|
1478
|
+
fnNames,
|
|
1479
|
+
getBindings(code, fnNames)
|
|
1480
|
+
);
|
|
1481
|
+
if (sites.length === 0) continue;
|
|
1372
1482
|
const warnKey = `${id}::${cfg.fnName}`;
|
|
1373
1483
|
if (unsupportedShapeWarnings.has(warnKey)) continue;
|
|
1374
1484
|
unsupportedShapeWarnings.add(warnKey);
|
|
1375
|
-
this.warn(buildUnsupportedShapeWarning(filePath, cfg.fnName));
|
|
1485
|
+
this.warn(buildUnsupportedShapeWarning(filePath, cfg.fnName, sites));
|
|
1376
1486
|
}
|
|
1377
1487
|
if (hasLoaderCode && isRscEnv) {
|
|
1378
1488
|
const fnNames = getFnNames("createLoader");
|
|
@@ -1658,6 +1768,7 @@ import MagicString4 from "magic-string";
|
|
|
1658
1768
|
var debug4 = createRangoDebugger(NS.transform);
|
|
1659
1769
|
var CACHE_RUNTIME_IMPORT = "@rangojs/router/cache-runtime";
|
|
1660
1770
|
var LAYOUT_TEMPLATE_PATTERN = /\/(layout|template)\.(tsx?|jsx?)$/;
|
|
1771
|
+
var USE_CACHE_DIRECTIVE_RE = /^use cache(:\s*[\w-]+)?$/;
|
|
1661
1772
|
function useCacheTransform() {
|
|
1662
1773
|
let projectRoot = "";
|
|
1663
1774
|
let isBuild = false;
|
|
@@ -1786,7 +1897,7 @@ function transformFileLevelUseCache(code, ast, filePath, sourceId, isBuild, isLa
|
|
|
1786
1897
|
function transformFunctionLevelUseCache(code, ast, filePath, sourceId, isBuild, transformHoistInlineDirective) {
|
|
1787
1898
|
try {
|
|
1788
1899
|
const { output, names } = transformHoistInlineDirective(code, ast, {
|
|
1789
|
-
directive:
|
|
1900
|
+
directive: USE_CACHE_DIRECTIVE_RE,
|
|
1790
1901
|
runtime: (value, name, meta) => {
|
|
1791
1902
|
const funcId = isBuild ? hashId(filePath, name) : `${filePath}#${name}`;
|
|
1792
1903
|
const profileMatch = meta.directiveMatch[1];
|
|
@@ -1816,14 +1927,13 @@ function findFileLevelDirective(ast) {
|
|
|
1816
1927
|
}
|
|
1817
1928
|
return null;
|
|
1818
1929
|
}
|
|
1819
|
-
var VALID_DIRECTIVE_RE = /^use cache(:\s*[\w-]+)?$/;
|
|
1820
1930
|
var NEAR_MISS_RE = /^use cache:\s*.+$/;
|
|
1821
1931
|
function warnOnNearMissDirectives(ast, fileId, warn) {
|
|
1822
1932
|
const visit = (node) => {
|
|
1823
1933
|
if (!node || typeof node !== "object") return;
|
|
1824
1934
|
if (node.type === "ExpressionStatement" && node.expression?.type === "Literal" && typeof node.expression.value === "string") {
|
|
1825
1935
|
const value = node.expression.value;
|
|
1826
|
-
if (value.startsWith("use cache") && NEAR_MISS_RE.test(value) && !
|
|
1936
|
+
if (value.startsWith("use cache") && NEAR_MISS_RE.test(value) && !USE_CACHE_DIRECTIVE_RE.test(value)) {
|
|
1827
1937
|
const profilePart = value.slice("use cache:".length).trim();
|
|
1828
1938
|
warn(
|
|
1829
1939
|
`[rango:use-cache] "${value}" in ${fileId} has an invalid profile name "${profilePart}". Profile names must match [a-zA-Z0-9_-]+. This directive will be ignored.`
|
|
@@ -2019,7 +2129,7 @@ import { resolve } from "node:path";
|
|
|
2019
2129
|
// package.json
|
|
2020
2130
|
var package_default = {
|
|
2021
2131
|
name: "@rangojs/router",
|
|
2022
|
-
version: "0.0.0-experimental.
|
|
2132
|
+
version: "0.0.0-experimental.114",
|
|
2023
2133
|
description: "Django-inspired RSC router with composable URL patterns",
|
|
2024
2134
|
keywords: [
|
|
2025
2135
|
"react",
|
|
@@ -2677,6 +2787,7 @@ function countPublicRouteEntries(source) {
|
|
|
2677
2787
|
return count;
|
|
2678
2788
|
}
|
|
2679
2789
|
var ROUTER_CALL_PATTERN = /\bcreateRouter\s*[<(]/;
|
|
2790
|
+
var ROUTER_CALL_PATTERN_G = /\bcreateRouter\s*[<(]/g;
|
|
2680
2791
|
function isRoutableSourceFile(name) {
|
|
2681
2792
|
return (name.endsWith(".ts") || name.endsWith(".tsx") || name.endsWith(".js") || name.endsWith(".jsx")) && !name.includes(".gen.") && !name.includes(".test.") && !name.includes(".spec.");
|
|
2682
2793
|
}
|
|
@@ -2704,7 +2815,7 @@ function findRouterFilesRecursive(dir, filter, results) {
|
|
|
2704
2815
|
if (filter && !filter(fullPath)) continue;
|
|
2705
2816
|
try {
|
|
2706
2817
|
const source = readFileSync2(fullPath, "utf-8");
|
|
2707
|
-
if (ROUTER_CALL_PATTERN.test(source)) {
|
|
2818
|
+
if (ROUTER_CALL_PATTERN.test(source) && firstCodeMatchIndex(source, ROUTER_CALL_PATTERN_G) >= 0) {
|
|
2708
2819
|
routerFilesInDir.push(fullPath);
|
|
2709
2820
|
}
|
|
2710
2821
|
} catch {
|
|
@@ -5946,8 +6057,12 @@ ${err.stack}`
|
|
|
5946
6057
|
const trimmed = source.trimStart();
|
|
5947
6058
|
const isUseClient = trimmed.startsWith('"use client"') || trimmed.startsWith("'use client'");
|
|
5948
6059
|
if (!inRecoveryMode && isUseClient) return;
|
|
5949
|
-
|
|
5950
|
-
|
|
6060
|
+
let hasUrls = source.includes("urls(");
|
|
6061
|
+
let hasCreateRouter = /\bcreateRouter\s*[<(]/.test(source);
|
|
6062
|
+
if (hasUrls) hasUrls = firstCodeMatchIndex(source, /urls\(/g) >= 0;
|
|
6063
|
+
if (hasCreateRouter) {
|
|
6064
|
+
hasCreateRouter = firstCodeMatchIndex(source, /\bcreateRouter\s*[<(]/g) >= 0;
|
|
6065
|
+
}
|
|
5951
6066
|
if (!inRecoveryMode && !hasUrls && !hasCreateRouter) return;
|
|
5952
6067
|
if (inRecoveryMode) {
|
|
5953
6068
|
debugDiscovery?.(
|
package/package.json
CHANGED
|
@@ -6,8 +6,9 @@ argument-hint:
|
|
|
6
6
|
|
|
7
7
|
# cache() vs "use cache" — When to Use Which
|
|
8
8
|
|
|
9
|
-
Both mechanisms share the same backing store
|
|
10
|
-
|
|
9
|
+
Both mechanisms share the same backing store and cache profiles, and both accept
|
|
10
|
+
an optional `tags` field (not yet honored by the built-in stores — see "Two axes"
|
|
11
|
+
below). They differ in scope, cache key, execution model, and runtime control.
|
|
11
12
|
|
|
12
13
|
## Two axes — do not conflate
|
|
13
14
|
|
|
@@ -17,7 +18,10 @@ caching:
|
|
|
17
18
|
|
|
18
19
|
1. **Stored-value freshness** — _is a cached value still good?_
|
|
19
20
|
→ `"use cache"` (fn/component), `cache()` (segment), loader `cache()` (loader data).
|
|
20
|
-
Entries
|
|
21
|
+
Entries expire by **TTL/SWR**. They accept an optional `tags` field, but the
|
|
22
|
+
built-in stores (`MemorySegmentCacheStore`, `CFCacheStore`) do not yet index or
|
|
23
|
+
invalidate by tag, so tag-based invalidation (`revalidateTag`) is a
|
|
24
|
+
forward-looking API requiring a custom store with secondary indices.
|
|
21
25
|
2. **Client-update selection** — _should this segment re-run and stream to the
|
|
22
26
|
client on this navigation/action?_
|
|
23
27
|
→ `revalidate()`. Covered in `/loader` and `/route`, **not here**.
|
|
@@ -58,12 +62,16 @@ There are two guard models to keep separate. Both block response side effects
|
|
|
58
62
|
else they allow:
|
|
59
63
|
|
|
60
64
|
- **`cache()` boundary guard** (route-level) — fires while the handler runs on a
|
|
61
|
-
miss. `
|
|
62
|
-
cached
|
|
65
|
+
miss. `cookies()` and `headers()` throw (request-scoped data would be baked into
|
|
66
|
+
the shared cached shell), `ctx.get(nonCacheableVar)` throws (a tainted value
|
|
67
|
+
would be baked in), and response side effects (`ctx.header()`, `ctx.setCookie()`,
|
|
63
68
|
`ctx.setStatus()`, `ctx.onResponse()`) throw. `ctx.set()` of a cacheable var is
|
|
64
|
-
**allowed** — children are cached too and can read it.
|
|
65
|
-
|
|
66
|
-
|
|
69
|
+
**allowed** — children are cached too and can read it. **Loaders are exempt**
|
|
70
|
+
(they always run fresh) — read request data inside a loader.
|
|
71
|
+
- **`"use cache"` exec-guard** (function-level) — the same request-scoped APIs
|
|
72
|
+
throw inside the cached function (`cookies()`, `headers()`, `ctx.set()`,
|
|
73
|
+
`ctx.header()`); additionally, tainted `ctx`/`env`/`req` args are excluded from
|
|
74
|
+
the cache key.
|
|
67
75
|
|
|
68
76
|
### Cross-deploy safety: version-segmented store keys
|
|
69
77
|
|
|
@@ -331,18 +339,21 @@ specifies `cache: false`, the value is non-cacheable.
|
|
|
331
339
|
|
|
332
340
|
**Behavior inside a `cache()` boundary:**
|
|
333
341
|
|
|
334
|
-
| Operation
|
|
335
|
-
|
|
|
336
|
-
| `
|
|
337
|
-
| `ctx.get(
|
|
338
|
-
| `ctx.
|
|
339
|
-
| `ctx.
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
`ctx.get(
|
|
345
|
-
|
|
342
|
+
| Operation | Inside a `cache()` boundary |
|
|
343
|
+
| ----------------------------------------- | ------------------------------------------------------ |
|
|
344
|
+
| `cookies()` / `headers()` (read or write) | Throws (request-scoped, would poison the shared entry) |
|
|
345
|
+
| `ctx.get(cacheableVar)` | Allowed |
|
|
346
|
+
| `ctx.get(nonCacheableVar)` | Throws (would be baked in) |
|
|
347
|
+
| `ctx.set(var, value)` (cacheable) | Allowed |
|
|
348
|
+
| `ctx.header()` / cookie writes | Throws (response side effect would be lost on hit) |
|
|
349
|
+
| Any of the above **inside a loader** | Allowed (loaders always run fresh) |
|
|
350
|
+
|
|
351
|
+
(Both scopes block the same request-scoped APIs — `cookies()`, `headers()`,
|
|
352
|
+
response side effects, and non-cacheable `ctx.get()` — because each would leak
|
|
353
|
+
per-request data into a shared cache entry. The `cache()` boundary tracks the
|
|
354
|
+
scope via `isInsideCacheScope()`; `"use cache"` uses the exec guard and also
|
|
355
|
+
excludes tainted `ctx`/`env`/`req` args from the cache key. Loaders are exempt in
|
|
356
|
+
both — see "Headers and Cookies" and the precise guarantee below.)
|
|
346
357
|
|
|
347
358
|
Write is dumb — `ctx.set()` stores the cache metadata but does not enforce.
|
|
348
359
|
Enforcement happens at read time (`ctx.get()`), where ALS detects the cache
|
|
@@ -379,10 +390,10 @@ layout((ctx) => {
|
|
|
379
390
|
So do **not** read this as "you can't cache user data" — that overstates it and
|
|
380
391
|
breeds the false confidence that makes the derived leak _more_ likely. The guard
|
|
381
392
|
is deliberately non-propagating (propagation would cost a wrapper per derivation
|
|
382
|
-
on the hot path), and it is scoped to the `cache()` segment boundary
|
|
383
|
-
cache"` functions
|
|
384
|
-
|
|
385
|
-
|
|
393
|
+
on the hot path), and it is scoped to the `cache()` segment boundary. `"use
|
|
394
|
+
cache"` functions block the same request-scoped reads (`cookies()` / `headers()`
|
|
395
|
+
throw inside them) and additionally exclude tainted `ctx`/`env`/`req` args from
|
|
396
|
+
the cache key. The pattern that stays safe is also the natural one:
|
|
386
397
|
**read tainted context at the point of use, in the path that needs it (a loader or
|
|
387
398
|
live segment) — never extract user data into a plain value and cache that.**
|
|
388
399
|
Loaders are exempt because they run outside the cache scope and resolve fresh
|
package/skills/caching/SKILL.md
CHANGED
|
@@ -8,6 +8,46 @@ argument-hint: [setup]
|
|
|
8
8
|
|
|
9
9
|
@rangojs/router supports segment-level caching with stale-while-revalidate (SWR) for optimal performance.
|
|
10
10
|
|
|
11
|
+
> SWR support is store-specific. `CFCacheStore` revalidates segment, response,
|
|
12
|
+
> and `"use cache"` entries in the background. `MemorySegmentCacheStore`
|
|
13
|
+
> supports SWR for response and `"use cache"` item entries, but its
|
|
14
|
+
> route-segment entries expire at TTL with no background revalidation — use
|
|
15
|
+
> `CFCacheStore` for real segment SWR. See `/cache-guide`.
|
|
16
|
+
|
|
17
|
+
## cache() is Partial Prerendering (PPR)
|
|
18
|
+
|
|
19
|
+
`cache()` caches **everything except loaders**. On a cache hit, the cached
|
|
20
|
+
segments (layouts, route components, parallels — including any resolved
|
|
21
|
+
Suspense) are served from the store, and **loaders re-run fresh on every
|
|
22
|
+
request**, streaming their results into the same response. Loaders are the
|
|
23
|
+
dynamic "holes" of an otherwise-cached tree.
|
|
24
|
+
|
|
25
|
+
This means a `cache()` boundary at the document root **is** whole-document
|
|
26
|
+
Partial Prerendering: the static shell is cached and served instantly while
|
|
27
|
+
per-request/per-user data stays live — in one streamed response, no extra round
|
|
28
|
+
trip. The browser cannot tell the shell came from cache.
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
cache({ ttl: 60, swr: 300 }, () => [
|
|
32
|
+
layout(<RootLayout />), // cached shell
|
|
33
|
+
path("/dashboard", Dashboard, { name: "dashboard" }, () => [
|
|
34
|
+
loader(StatsLoader), // DYNAMIC HOLE — re-runs every request
|
|
35
|
+
]),
|
|
36
|
+
]);
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The consumer rule: **want it cached? render it inline. want it dynamic? put it
|
|
40
|
+
in a loader and read it with `useLoader()` in a client component.** Anything
|
|
41
|
+
read with `cookies()`, `headers()`, or a non-cacheable variable belongs in a
|
|
42
|
+
loader (loaders always run fresh). Reading it directly in a cached handler
|
|
43
|
+
throws; awaiting it with `ctx.use()` and rendering the result in a cached
|
|
44
|
+
handler silently bakes per-request data into the shared shell (see "Cache purity
|
|
45
|
+
& tainted objects" below).
|
|
46
|
+
|
|
47
|
+
Pre-rendering (`/prerender`) is the build-time twin: it caches the same shell at
|
|
48
|
+
build time instead of on first request. Both feed the segment system
|
|
49
|
+
identically, and loaders always run fresh at request time.
|
|
50
|
+
|
|
11
51
|
## Route-Level Caching with cache()
|
|
12
52
|
|
|
13
53
|
Use the `cache()` DSL function to cache routes:
|
|
@@ -116,7 +156,6 @@ import { MemorySegmentCacheStore } from "@rangojs/router/cache";
|
|
|
116
156
|
|
|
117
157
|
const store = new MemorySegmentCacheStore({
|
|
118
158
|
defaults: { ttl: 60, swr: 300 },
|
|
119
|
-
maxSize: 1000, // Max entries
|
|
120
159
|
});
|
|
121
160
|
```
|
|
122
161
|
|
|
@@ -173,13 +212,82 @@ const router = createRouter<AppBindings>({
|
|
|
173
212
|
KV entries require `expirationTtl >= 60s`. Short-lived entries (< 60s total TTL)
|
|
174
213
|
are only cached in L1.
|
|
175
214
|
|
|
176
|
-
##
|
|
215
|
+
## Cache purity & tainted objects
|
|
216
|
+
|
|
217
|
+
A `cache()` boundary caches everything except loaders, so anything read inside a
|
|
218
|
+
cached handler is **frozen into the shared cache entry** and served to every
|
|
219
|
+
subsequent visitor. To stop one user's request-scoped data from leaking to
|
|
220
|
+
another, request-scoped APIs are guarded inside a cache scope:
|
|
221
|
+
|
|
222
|
+
| Inside a `cache()` boundary | Behavior |
|
|
223
|
+
| --------------------------------------------------------------- | --------------------------------------------------- |
|
|
224
|
+
| `cookies()` / `headers()` (read or write) | **throws** — request-scoped, would poison the entry |
|
|
225
|
+
| `ctx.header()` / `setCookie()` / `setStatus()` / `onResponse()` | **throws** — response side effects lost on a hit |
|
|
226
|
+
| `ctx.get(var)` where the var is `{ cache: false }` | **throws** on read |
|
|
227
|
+
| `ctx.set(var, value)` for a cacheable var | allowed (children are cached too) |
|
|
228
|
+
| Any of the above **inside a loader** | **allowed** — loaders always run fresh |
|
|
229
|
+
|
|
230
|
+
**Tainted objects.** Request-scoped objects (`ctx`, `env`, `request`) carry an
|
|
231
|
+
internal taint symbol so they are excluded from `"use cache"` cache keys, and
|
|
232
|
+
the cache scope is tracked via async-local state. Two flags back the guards:
|
|
233
|
+
`INSIDE_CACHE_EXEC` (set while a `"use cache"` function runs) and the `cache()`
|
|
234
|
+
DSL scope (`isInsideCacheScope()`). `isInsideCacheScope()` deliberately returns
|
|
235
|
+
`false` inside loaders — which is exactly why loaders are the dynamic holes:
|
|
236
|
+
they may read `cookies()`/`headers()` and re-run on every request.
|
|
237
|
+
|
|
238
|
+
The fix for "I need request data in a cached route": register a `loader()` and
|
|
239
|
+
**consume it with `useLoader()` in a client component**. The loader is the
|
|
240
|
+
dynamic hole — its data rides the fresh (never-cached) loader segment and is
|
|
241
|
+
rendered in the client component, so it never lands in the cached shell.
|
|
242
|
+
|
|
243
|
+
This is NOT the same as awaiting the loader in the handler. A cached handler
|
|
244
|
+
that does `await ctx.use(Loader)` and renders the result bakes that per-request
|
|
245
|
+
data straight into the shared cached segment — the loader running "fresh" does
|
|
246
|
+
not help, because its output was inlined into the cached parent, and `ctx.use()`
|
|
247
|
+
is **not** guarded. `ctx.use()` is a server-side escape hatch for non-rendered
|
|
248
|
+
uses (set a ctx var, make a routing decision); never render its result inside a
|
|
249
|
+
cached handler.
|
|
250
|
+
|
|
251
|
+
```typescript
|
|
252
|
+
// WRONG — throws: cookies() read directly in a cached handler
|
|
253
|
+
cache({ ttl: 60 }, () => [
|
|
254
|
+
path("/me", () => <Profile id={cookies().get("uid")?.value} />),
|
|
255
|
+
]);
|
|
256
|
+
|
|
257
|
+
// ALSO WRONG (unguarded, but leaks) — the awaited loader data is rendered into
|
|
258
|
+
// the cached handler, so the user's data is frozen into the shared shell.
|
|
259
|
+
cache({ ttl: 60 }, () => [
|
|
260
|
+
path(
|
|
261
|
+
"/me",
|
|
262
|
+
async (ctx) => {
|
|
263
|
+
const { user } = await ctx.use(MeLoader); // runs fresh…
|
|
264
|
+
return <Profile user={user} />; // …but inlined into the CACHED segment → leak
|
|
265
|
+
},
|
|
266
|
+
{ name: "me" },
|
|
267
|
+
() => [loader(MeLoader)],
|
|
268
|
+
),
|
|
269
|
+
]);
|
|
270
|
+
|
|
271
|
+
// RIGHT — consume the loader in a CLIENT component via useLoader(). The cached
|
|
272
|
+
// route segment holds only the <Profile/> reference; the user data rides the
|
|
273
|
+
// fresh loader segment and renders client-side.
|
|
274
|
+
|
|
275
|
+
// profile.tsx (client component)
|
|
276
|
+
"use client";
|
|
277
|
+
import { useLoader } from "@rangojs/router/client";
|
|
278
|
+
|
|
279
|
+
export function Profile() {
|
|
280
|
+
const { user } = useLoader(MeLoader); // fresh per request; never cached
|
|
281
|
+
return <span>{user.name}</span>;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// urls — register the loader; MeLoader reads cookies() inside the loader (allowed)
|
|
285
|
+
cache({ ttl: 60 }, () => [
|
|
286
|
+
path("/me", () => <Profile />, { name: "me" }, () => [loader(MeLoader)]),
|
|
287
|
+
]);
|
|
288
|
+
```
|
|
177
289
|
|
|
178
|
-
|
|
179
|
-
written inside `cache()` scopes. Variables marked with `{ cache: false }` (at
|
|
180
|
-
the var level or write level) throw when read inside a cache scope. Response
|
|
181
|
-
side effects (`ctx.header()`, `ctx.cookie()`) always throw inside cache
|
|
182
|
-
boundaries. See `/cache-guide` for the full cache safety table.
|
|
290
|
+
See `/cache-guide` for the full decision guide and the `cache()` vs `"use cache"` comparison.
|
|
183
291
|
|
|
184
292
|
## Nested Cache Boundaries
|
|
185
293
|
|