@rangojs/router 0.0.0-experimental.132 → 0.0.0-experimental.133

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 (141) hide show
  1. package/AGENTS.md +8 -0
  2. package/README.md +43 -2
  3. package/dist/bin/rango.js +92 -16
  4. package/dist/vite/index.js +166 -70
  5. package/package.json +19 -18
  6. package/skills/breadcrumbs/SKILL.md +1 -1
  7. package/skills/bundle-analysis/SKILL.md +2 -2
  8. package/skills/cache-guide/SKILL.md +2 -2
  9. package/skills/caching/SKILL.md +16 -9
  10. package/skills/debug-manifest/SKILL.md +4 -2
  11. package/skills/document-cache/SKILL.md +2 -2
  12. package/skills/handler-use/SKILL.md +1 -1
  13. package/skills/hooks/SKILL.md +2 -2
  14. package/skills/host-router/SKILL.md +1 -1
  15. package/skills/intercept/SKILL.md +1 -1
  16. package/skills/loader/SKILL.md +2 -0
  17. package/skills/migrate-react-router/SKILL.md +4 -2
  18. package/skills/mime-routes/SKILL.md +1 -1
  19. package/skills/prerender/SKILL.md +2 -0
  20. package/skills/rango/SKILL.md +12 -11
  21. package/skills/response-routes/SKILL.md +2 -2
  22. package/skills/route/SKILL.md +4 -0
  23. package/skills/router-setup/SKILL.md +3 -0
  24. package/skills/scripts/SKILL.md +179 -0
  25. package/skills/testing/SKILL.md +1 -1
  26. package/skills/testing/bindings.md +20 -6
  27. package/skills/testing/cache-prerender.md +5 -2
  28. package/skills/testing/client-components.md +2 -0
  29. package/skills/testing/e2e-parity.md +1 -1
  30. package/skills/testing/flight.md +8 -9
  31. package/skills/testing/render-handler.md +1 -1
  32. package/skills/testing/response-routes.md +1 -1
  33. package/skills/testing/server-actions.md +11 -11
  34. package/skills/testing/setup.md +3 -0
  35. package/skills/typesafety/SKILL.md +3 -2
  36. package/skills/use-cache/SKILL.md +10 -9
  37. package/src/browser/event-controller.ts +109 -2
  38. package/src/browser/partial-update.ts +12 -0
  39. package/src/browser/prefetch/cache.ts +17 -0
  40. package/src/browser/prefetch/fetch.ts +69 -2
  41. package/src/browser/react/Link.tsx +30 -5
  42. package/src/browser/react/NavigationProvider.tsx +12 -2
  43. package/src/browser/react/location-state-shared.ts +14 -2
  44. package/src/browser/react/use-href.tsx +8 -1
  45. package/src/browser/react/use-link-status.ts +23 -2
  46. package/src/browser/response-adapter.ts +14 -3
  47. package/src/browser/rsc-router.tsx +3 -0
  48. package/src/browser/scroll-restoration.ts +8 -3
  49. package/src/browser/server-action-bridge.ts +46 -11
  50. package/src/browser/types.ts +6 -0
  51. package/src/build/generate-route-types.ts +0 -1
  52. package/src/build/route-trie.ts +33 -9
  53. package/src/build/route-types/include-resolution.ts +7 -1
  54. package/src/build/route-types/router-processing.ts +0 -6
  55. package/src/build/route-types/source-scan.ts +105 -7
  56. package/src/cache/cache-policy.ts +42 -8
  57. package/src/cache/cache-runtime.ts +65 -5
  58. package/src/cache/cache-scope.ts +71 -11
  59. package/src/cache/cache-tag.ts +7 -2
  60. package/src/cache/cf/cf-base64.ts +33 -0
  61. package/src/cache/cf/cf-cache-constants.ts +127 -0
  62. package/src/cache/cf/cf-cache-store.ts +85 -613
  63. package/src/cache/cf/cf-cache-types.ts +349 -0
  64. package/src/cache/cf/cf-kv-utils.ts +46 -0
  65. package/src/cache/cf/cf-tag-marker-memo.ts +105 -0
  66. package/src/cache/document-cache.ts +11 -0
  67. package/src/cache/handle-snapshot.ts +8 -1
  68. package/src/cache/profile-registry.ts +25 -1
  69. package/src/cache/segment-codec.ts +9 -1
  70. package/src/cache/types.ts +4 -0
  71. package/src/client.rsc.tsx +38 -0
  72. package/src/client.tsx +11 -0
  73. package/src/components/DefaultDocument.tsx +8 -2
  74. package/src/context-var.ts +1 -1
  75. package/src/decode-loader-results.ts +7 -1
  76. package/src/escape-script.ts +52 -0
  77. package/src/handles/MetaTags.tsx +56 -5
  78. package/src/handles/Scripts.tsx +183 -0
  79. package/src/handles/breadcrumbs.ts +29 -11
  80. package/src/handles/is-thenable.ts +19 -0
  81. package/src/handles/meta.ts +46 -0
  82. package/src/handles/script.ts +244 -0
  83. package/src/host/cookie-handler.ts +7 -3
  84. package/src/host/pattern-matcher.ts +16 -2
  85. package/src/index.rsc.ts +5 -0
  86. package/src/index.ts +5 -0
  87. package/src/response-utils.ts +25 -0
  88. package/src/route-definition/dsl-helpers.ts +7 -0
  89. package/src/route-definition/redirect.ts +1 -2
  90. package/src/router/content-negotiation.ts +58 -10
  91. package/src/router/intercept-resolution.ts +9 -0
  92. package/src/router/match-middleware/cache-store.ts +10 -1
  93. package/src/router/middleware.ts +10 -3
  94. package/src/router/pattern-matching.ts +25 -23
  95. package/src/router/prefetch-cache-ttl.ts +51 -0
  96. package/src/router/router-interfaces.ts +7 -0
  97. package/src/router/router-options.ts +23 -0
  98. package/src/router/segment-resolution/fresh.ts +10 -0
  99. package/src/router/segment-resolution/helpers.ts +35 -1
  100. package/src/router/segment-resolution/loader-cache.ts +10 -6
  101. package/src/router/segment-resolution/revalidation.ts +6 -0
  102. package/src/router/segment-resolution.ts +1 -0
  103. package/src/router/trie-matching.ts +14 -9
  104. package/src/router.ts +18 -10
  105. package/src/rsc/handler.ts +52 -13
  106. package/src/rsc/helpers.ts +7 -1
  107. package/src/rsc/index.ts +1 -4
  108. package/src/rsc/loader-fetch.ts +107 -37
  109. package/src/rsc/progressive-enhancement.ts +18 -6
  110. package/src/rsc/response-cache-serve.ts +238 -0
  111. package/src/rsc/response-route-handler.ts +16 -133
  112. package/src/rsc/rsc-rendering.ts +13 -4
  113. package/src/rsc/server-action.ts +52 -6
  114. package/src/rsc/types.ts +7 -0
  115. package/src/search-params.ts +24 -5
  116. package/src/segment-loader-promise.ts +17 -2
  117. package/src/server/loader-registry.ts +16 -18
  118. package/src/server/request-context.ts +47 -20
  119. package/src/testing/dispatch.ts +108 -25
  120. package/src/testing/flight.ts +25 -0
  121. package/src/testing/internal/context.ts +25 -2
  122. package/src/testing/render-handler.ts +3 -1
  123. package/src/testing/render-route.tsx +15 -0
  124. package/src/testing/run-loader.ts +10 -3
  125. package/src/theme/ThemeProvider.tsx +20 -6
  126. package/src/theme/ThemeScript.tsx +7 -3
  127. package/src/theme/constants.ts +54 -3
  128. package/src/theme/theme-script.ts +22 -7
  129. package/src/types/request-scope.ts +8 -3
  130. package/src/vite/plugins/cjs-to-esm.ts +8 -1
  131. package/src/vite/plugins/expose-id-utils.ts +10 -1
  132. package/src/vite/plugins/expose-ids/handler-transform.ts +5 -16
  133. package/src/vite/plugins/expose-ids/loader-transform.ts +12 -5
  134. package/src/vite/plugins/expose-ids/router-transform.ts +6 -1
  135. package/src/vite/plugins/expose-internal-ids.ts +0 -1
  136. package/src/vite/plugins/version-plugin.ts +5 -17
  137. package/src/vite/plugins/virtual-entries.ts +12 -2
  138. package/src/vite/rango.ts +15 -6
  139. package/src/vite/utils/ast-handler-extract.ts +11 -4
  140. package/src/vite/utils/directive-prologue.ts +40 -0
  141. package/src/vite/utils/prerender-utils.ts +17 -2
@@ -165,7 +165,9 @@ function countArgs(code, startPos, endPos) {
165
165
  while (i < endPos) {
166
166
  const skipped = skipStringOrComment(code, i);
167
167
  if (skipped > i) {
168
- hasContent = true;
168
+ const ch = code[i];
169
+ const isComment = ch === "/" && i + 1 < code.length && (code[i + 1] === "/" || code[i + 1] === "*");
170
+ if (!isComment) hasContent = true;
169
171
  i = skipped;
170
172
  continue;
171
173
  }
@@ -530,10 +532,10 @@ import path4 from "node:path";
530
532
  function isDirectivePrologueStatement(node) {
531
533
  return node?.type === "ExpressionStatement" && typeof node.directive === "string";
532
534
  }
533
- function findImportInsertionPos(code, parseAst4) {
535
+ function findImportInsertionPos(code, parseAst5) {
534
536
  let program;
535
537
  try {
536
- program = parseAst4(code, { lang: "tsx" });
538
+ program = parseAst5(code, { lang: "tsx" });
537
539
  } catch {
538
540
  return 0;
539
541
  }
@@ -570,10 +572,10 @@ function walkNode(node, parent, ancestors, enter) {
570
572
  }
571
573
  ancestors.pop();
572
574
  }
573
- function findHandlerCalls(code, fnName, parseAst4) {
575
+ function findHandlerCalls(code, fnName, parseAst5) {
574
576
  let program;
575
577
  try {
576
- program = parseAst4(code, { lang: "tsx" });
578
+ program = parseAst5(code, { lang: "tsx" });
577
579
  } catch {
578
580
  return [];
579
581
  }
@@ -645,18 +647,18 @@ function getImportedLocalNamesFromProgram(program, importedName) {
645
647
  }
646
648
  return localNames;
647
649
  }
648
- function getImportedLocalNames(code, importedName, parseAst4) {
650
+ function getImportedLocalNames(code, importedName, parseAst5) {
649
651
  try {
650
- const program = parseAst4(code, { lang: "tsx" });
652
+ const program = parseAst5(code, { lang: "tsx" });
651
653
  return getImportedLocalNamesFromProgram(program, importedName);
652
654
  } catch {
653
655
  return /* @__PURE__ */ new Set();
654
656
  }
655
657
  }
656
- function extractImportDeclarations(code, parseAst4) {
658
+ function extractImportDeclarations(code, parseAst5) {
657
659
  let program;
658
660
  try {
659
- program = parseAst4(code, { lang: "tsx" });
661
+ program = parseAst5(code, { lang: "tsx" });
660
662
  } catch {
661
663
  return [];
662
664
  }
@@ -680,13 +682,18 @@ function isInertExpression(node) {
680
682
  (e) => e === null || isInertExpression(e)
681
683
  );
682
684
  case "ObjectExpression":
683
- return (node.properties ?? []).every(
684
- (p) => p.type === "Property" && (!p.computed || isInertExpression(p.key)) && isInertExpression(p.value)
685
- );
685
+ return (node.properties ?? []).every((p) => {
686
+ if (p.type === "SpreadElement" || p.type === "RestElement") {
687
+ return isInertExpression(p.argument);
688
+ }
689
+ return p.type === "Property" && (!p.computed || isInertExpression(p.key)) && isInertExpression(p.value);
690
+ });
686
691
  case "UnaryExpression":
687
692
  return isInertExpression(node.argument);
688
693
  case "BinaryExpression":
689
694
  return isInertExpression(node.left) && isInertExpression(node.right);
695
+ case "LogicalExpression":
696
+ return isInertExpression(node.left) && isInertExpression(node.right);
690
697
  case "ConditionalExpression":
691
698
  return isInertExpression(node.test) && isInertExpression(node.consequent) && isInertExpression(node.alternate);
692
699
  case "SpreadElement":
@@ -708,10 +715,10 @@ function isSafeVariableDeclaration(node, handlerNames) {
708
715
  (d) => isSafeDeclaratorInit(d.init) && !(d.init?.type === "CallExpression" && d.init.callee?.type === "Identifier" && handlerNames.has(d.init.callee.name))
709
716
  );
710
717
  }
711
- function extractModuleLevelDeclarations(code, parseAst4, handlerNames) {
718
+ function extractModuleLevelDeclarations(code, parseAst5, handlerNames) {
712
719
  let program;
713
720
  try {
714
- program = parseAst4(code, { lang: "tsx" });
721
+ program = parseAst5(code, { lang: "tsx" });
715
722
  } catch {
716
723
  return [];
717
724
  }
@@ -742,17 +749,17 @@ function extractModuleLevelDeclarations(code, parseAst4, handlerNames) {
742
749
  }
743
750
  return declarations;
744
751
  }
745
- function transformInlineHandlers(fnName, virtualPrefix, s, code, filePath, virtualRegistry, moduleId, parseAst4) {
746
- const sites = findHandlerCalls(code, fnName, parseAst4);
752
+ function transformInlineHandlers(fnName, virtualPrefix, s, code, filePath, virtualRegistry, moduleId, parseAst5) {
753
+ const sites = findHandlerCalls(code, fnName, parseAst5);
747
754
  const inlineSites = sites.filter((site) => site.exportInfo === null);
748
755
  if (inlineSites.length === 0) return false;
749
- const imports = extractImportDeclarations(code, parseAst4);
750
- const staticNames = getImportedLocalNames(code, "Static", parseAst4);
751
- const prerenderNames = getImportedLocalNames(code, "Prerender", parseAst4);
756
+ const imports = extractImportDeclarations(code, parseAst5);
757
+ const staticNames = getImportedLocalNames(code, "Static", parseAst5);
758
+ const prerenderNames = getImportedLocalNames(code, "Prerender", parseAst5);
752
759
  const handlerNames = /* @__PURE__ */ new Set([...staticNames, ...prerenderNames]);
753
760
  const declarations = extractModuleLevelDeclarations(
754
761
  code,
755
- parseAst4,
762
+ parseAst5,
756
763
  handlerNames
757
764
  );
758
765
  const importStatements = [];
@@ -775,7 +782,7 @@ function transformInlineHandlers(fnName, virtualPrefix, s, code, filePath, virtu
775
782
  }
776
783
  if (importStatements.length > 0) {
777
784
  const importBlock = importStatements.join("\n") + "\n";
778
- const insertionPos = findImportInsertionPos(code, parseAst4);
785
+ const insertionPos = findImportInsertionPos(code, parseAst5);
779
786
  if (insertionPos === 0) {
780
787
  s.prepend(importBlock);
781
788
  } else {
@@ -808,25 +815,55 @@ function isLineTerminator(ch) {
808
815
  const c = ch.charCodeAt(0);
809
816
  return c === 10 || c === 13 || c === 8232 || c === 8233;
810
817
  }
818
+ var REGEX_PRECEDING_KEYWORDS = /* @__PURE__ */ new Set([
819
+ "return",
820
+ "typeof",
821
+ "instanceof",
822
+ "in",
823
+ "of",
824
+ "new",
825
+ "delete",
826
+ "void",
827
+ "do",
828
+ "else",
829
+ "yield",
830
+ "await",
831
+ "case",
832
+ "throw"
833
+ ]);
834
+ function isRegexPositionAt(code, slashPos, prevChar) {
835
+ if (prevChar === void 0) return true;
836
+ if (prevChar === ")" || prevChar === "]" || prevChar === "}") return false;
837
+ if (!/[\w$]/.test(prevChar)) return true;
838
+ let k = slashPos - 1;
839
+ while (k >= 0 && /\s/.test(code[k])) k--;
840
+ const wordEnd = k + 1;
841
+ while (k >= 0 && /[\w$]/.test(code[k])) k--;
842
+ return REGEX_PRECEDING_KEYWORDS.has(code.slice(k + 1, wordEnd));
843
+ }
811
844
  function makeCodeClassifier(code) {
812
845
  const n = code.length;
813
846
  let i = 0;
814
847
  let skipStart = -1;
815
848
  let skipEnd = -1;
849
+ let lastSig;
816
850
  return (q) => {
817
851
  if (q >= skipStart && q < skipEnd) return false;
818
852
  while (i < n && i <= q) {
819
853
  const c = code[i];
820
854
  const d = i + 1 < n ? code[i + 1] : "";
821
855
  let end = -1;
856
+ let transparent = false;
822
857
  if (c === "/" && d === "/") {
823
858
  let j = i + 2;
824
859
  while (j < n && !isLineTerminator(code[j])) j++;
825
860
  end = j;
861
+ transparent = true;
826
862
  } else if (c === "/" && d === "*") {
827
863
  let j = i + 2;
828
864
  while (j < n && !(code[j] === "*" && code[j + 1] === "/")) j++;
829
865
  end = Math.min(n, j + 2);
866
+ transparent = true;
830
867
  } else if (c === '"' || c === "'" || c === "`") {
831
868
  let j = i + 1;
832
869
  while (j < n) {
@@ -841,6 +878,29 @@ function makeCodeClassifier(code) {
841
878
  j++;
842
879
  }
843
880
  end = j;
881
+ } else if (c === "/" && d !== "/" && d !== "*" && isRegexPositionAt(code, i, lastSig)) {
882
+ let j = i + 1;
883
+ let inClass = false;
884
+ let closed = false;
885
+ while (j < n && !isLineTerminator(code[j])) {
886
+ const r = code[j];
887
+ if (r === "\\") {
888
+ j += 2;
889
+ continue;
890
+ }
891
+ if (r === "[") inClass = true;
892
+ else if (r === "]") inClass = false;
893
+ else if (r === "/" && !inClass) {
894
+ j++;
895
+ closed = true;
896
+ break;
897
+ }
898
+ j++;
899
+ }
900
+ if (closed) {
901
+ while (j < n && /[a-z]/.test(code[j])) j++;
902
+ end = j;
903
+ }
844
904
  }
845
905
  if (end >= 0) {
846
906
  if (q < end) {
@@ -849,7 +909,9 @@ function makeCodeClassifier(code) {
849
909
  return false;
850
910
  }
851
911
  i = end;
912
+ if (!transparent) lastSig = "x";
852
913
  } else {
914
+ if (!/\s/.test(c)) lastSig = c;
853
915
  i++;
854
916
  }
855
917
  }
@@ -1134,11 +1196,13 @@ function generateClientLoaderStubs(bindings, code, filePath, isBuild) {
1134
1196
  if (!isExportOnlyFile(code, bindings)) return null;
1135
1197
  const lines = [];
1136
1198
  for (const binding of bindings) {
1137
- for (const name of binding.exportNames) {
1138
- const loaderId = makeStubId(filePath, name, isBuild);
1139
- lines.push(
1140
- `export const ${name} = { __brand: "loader", $$id: "${loaderId}" };`
1141
- );
1199
+ const primaryName = binding.exportNames[0];
1200
+ const loaderId = makeStubId(filePath, primaryName, isBuild);
1201
+ lines.push(
1202
+ `export const ${primaryName} = { __brand: "loader", $$id: "${loaderId}" };`
1203
+ );
1204
+ for (const alias of binding.exportNames.slice(1)) {
1205
+ lines.push(`export const ${alias} = ${primaryName};`);
1142
1206
  }
1143
1207
  }
1144
1208
  return { code: lines.join("\n") + "\n" };
@@ -1159,22 +1223,13 @@ ${binding.localName}.$$id = "${loaderId}";`;
1159
1223
  }
1160
1224
 
1161
1225
  // src/vite/plugins/expose-ids/handler-transform.ts
1162
- function analyzeCreateHandleArgs(code, startPos, endPos) {
1163
- const content = code.slice(startPos, endPos).trim();
1164
- return { hasArgs: content.length > 0 };
1165
- }
1166
- function transformHandles(bindings, s, code, filePath, isBuild) {
1226
+ function transformHandles(bindings, s, filePath, isBuild) {
1167
1227
  let hasChanges = false;
1168
1228
  for (const binding of bindings) {
1169
1229
  const exportName = binding.exportNames[0];
1170
- const args = analyzeCreateHandleArgs(
1171
- code,
1172
- binding.callOpenParenPos + 1,
1173
- binding.callCloseParenPos
1174
- );
1175
1230
  const handleId = makeStubId(filePath, exportName, isBuild);
1176
1231
  let paramInjection;
1177
- if (!args.hasArgs) {
1232
+ if (binding.argCount === 0) {
1178
1233
  paramInjection = `undefined, "${handleId}"`;
1179
1234
  } else {
1180
1235
  paramInjection = `, "${handleId}"`;
@@ -1287,7 +1342,7 @@ function transformRouter(code, filePath, routerFnNames, absolutePath, warn) {
1287
1342
  if (parenPos === -1) continue;
1288
1343
  const closeParen = findMatchingParen(code, parenPos + 1);
1289
1344
  const callArgs = code.slice(parenPos + 1, closeParen);
1290
- if (callArgs.includes("$$id")) continue;
1345
+ if (callArgs.includes(`$$routeNames: ${routeNamesVar}`)) continue;
1291
1346
  const sourceFilePath = absolutePath ?? filePath;
1292
1347
  const lineNumber = code.slice(0, callStart).split("\n").length;
1293
1348
  const hash = createHash("sha256").update(`${filePath}:${lineNumber}`).digest("hex").slice(0, 8);
@@ -1831,7 +1886,6 @@ ${lazyImports.join(",\n")}
1831
1886
  changed = transformHandles(
1832
1887
  getBindings(code, fnNames),
1833
1888
  s,
1834
- code,
1835
1889
  filePath,
1836
1890
  isBuild
1837
1891
  ) || changed;
@@ -1908,8 +1962,8 @@ function useCacheTransform() {
1908
1962
  } = rscTransforms;
1909
1963
  let ast;
1910
1964
  try {
1911
- const { parseAst: parseAst4 } = await import("vite");
1912
- ast = parseAst4(code, { lang: "tsx" });
1965
+ const { parseAst: parseAst5 } = await import("vite");
1966
+ ast = parseAst5(code, { lang: "tsx" });
1913
1967
  } catch {
1914
1968
  return;
1915
1969
  }
@@ -2141,11 +2195,16 @@ async function initializeApp() {
2141
2195
  createTemporaryReferenceSet,
2142
2196
  };
2143
2197
 
2144
- await initBrowserApp({ rscStream, deps });
2198
+ // initBrowserApp resolves the initial payload and returns the browser app
2199
+ // context, including strictMode (default true) from createRouter. StrictMode
2200
+ // is the default; createRouter({ strictMode: false }) ships the opt-out in the
2201
+ // payload metadata. StrictMode emits no DOM, so toggling never changes markup.
2202
+ const { strictMode } = await initBrowserApp({ rscStream, deps });
2145
2203
 
2204
+ const app = createElement(Rango);
2146
2205
  hydrateRoot(
2147
2206
  document,
2148
- createElement(StrictMode, null, createElement(Rango))
2207
+ strictMode === false ? app : createElement(StrictMode, null, app)
2149
2208
  );
2150
2209
  }
2151
2210
 
@@ -2198,6 +2257,11 @@ export default function handler(request, env) {
2198
2257
  _handler = createRSCHandler({
2199
2258
  router,
2200
2259
  version: VERSION,
2260
+ // Forward the router's CSP nonce provider. createRSCHandler reads the
2261
+ // provider only from options.nonce; without this, createRouter({ nonce })
2262
+ // is silently dropped on the Node preset (the Cloudflare path wires it via
2263
+ // router.fetch). router.nonce is undefined when unconfigured, a safe no-op.
2264
+ nonce: router.nonce,
2201
2265
  deps: {
2202
2266
  renderToReadableStream,
2203
2267
  decodeReply,
@@ -2232,7 +2296,7 @@ import { resolve } from "node:path";
2232
2296
  // package.json
2233
2297
  var package_default = {
2234
2298
  name: "@rangojs/router",
2235
- version: "0.0.0-experimental.132",
2299
+ version: "0.0.0-experimental.133",
2236
2300
  description: "Django-inspired RSC router with composable URL patterns",
2237
2301
  keywords: [
2238
2302
  "react",
@@ -2785,7 +2849,7 @@ function extractIncludesWithDiagnostics(code, sourceFileArg) {
2785
2849
  return { resolved, unresolvable };
2786
2850
  }
2787
2851
  function resolveImportedVariable(code, localName) {
2788
- const importRegex = /import\s*\{([^}]+)\}\s*from\s*["']([^"']+)["']/g;
2852
+ const importRegex = /import\s*(?:[\w$]+\s*,\s*)?\{([^}]+)\}\s*from\s*["']([^"']+)["']/g;
2789
2853
  let match;
2790
2854
  while ((match = importRegex.exec(code)) !== null) {
2791
2855
  const imports = match[1];
@@ -3313,7 +3377,28 @@ function writeCombinedRouteTypes(root, knownRouterFiles, opts) {
3313
3377
  }
3314
3378
 
3315
3379
  // src/vite/plugins/version-plugin.ts
3380
+ import { parseAst as parseAst4 } from "vite";
3381
+
3382
+ // src/vite/utils/directive-prologue.ts
3316
3383
  import { parseAst as parseAst3 } from "vite";
3384
+ function hasUseClientDirective(source) {
3385
+ let program;
3386
+ try {
3387
+ program = parseAst3(source, { lang: "tsx" });
3388
+ } catch {
3389
+ return false;
3390
+ }
3391
+ for (const node of program.body ?? []) {
3392
+ if (node?.type === "ExpressionStatement" && node.expression?.type === "Literal" && typeof node.expression.value === "string") {
3393
+ if (node.expression.value === "use client") return true;
3394
+ continue;
3395
+ }
3396
+ break;
3397
+ }
3398
+ return false;
3399
+ }
3400
+
3401
+ // src/vite/plugins/version-plugin.ts
3317
3402
  function isCodeModule(id) {
3318
3403
  return /\.(tsx?|jsx?)($|\?)/.test(id);
3319
3404
  }
@@ -3321,23 +3406,13 @@ function normalizeModuleId(id) {
3321
3406
  return id.split("?", 1)[0];
3322
3407
  }
3323
3408
  function getClientModuleSignature(source) {
3409
+ if (!hasUseClientDirective(source)) return void 0;
3324
3410
  let program;
3325
3411
  try {
3326
- program = parseAst3(source, { lang: "tsx" });
3412
+ program = parseAst4(source, { lang: "tsx" });
3327
3413
  } catch {
3328
3414
  return void 0;
3329
3415
  }
3330
- let isUseClient = false;
3331
- for (const node of program.body ?? []) {
3332
- if (node?.type === "ExpressionStatement" && node.expression?.type === "Literal" && typeof node.expression.value === "string") {
3333
- if (node.expression.value === "use client") {
3334
- isUseClient = true;
3335
- }
3336
- continue;
3337
- }
3338
- break;
3339
- }
3340
- if (!isUseClient) return void 0;
3341
3416
  const exports = /* @__PURE__ */ new Set();
3342
3417
  let hasDefault = false;
3343
3418
  let hasExportAll = false;
@@ -3890,13 +3965,17 @@ function createVersionInjectorPlugin(rscEntryPath) {
3890
3965
  // src/vite/plugins/cjs-to-esm.ts
3891
3966
  var debug8 = createRangoDebugger(NS.transform);
3892
3967
  function createCjsToEsmPlugin() {
3968
+ let isProduction = false;
3893
3969
  return {
3894
3970
  name: "@rangojs/router:cjs-to-esm",
3895
3971
  enforce: "pre",
3972
+ configResolved(config) {
3973
+ isProduction = config.isProduction;
3974
+ },
3896
3975
  transform(code, id) {
3897
3976
  const cleanId = id.split("?")[0].replaceAll("\\", "/");
3898
3977
  if (cleanId.includes("vendor/react-server-dom/client.browser.js")) {
3899
- const isProd = process.env.NODE_ENV === "production";
3978
+ const isProd = isProduction;
3900
3979
  const cjsFile = isProd ? "./cjs/react-server-dom-webpack-client.browser.production.js" : "./cjs/react-server-dom-webpack-client.browser.development.js";
3901
3980
  debug8?.("cjs-to-esm entry redirect %s", id);
3902
3981
  return {
@@ -4353,6 +4432,9 @@ function sortSuffixParams(node) {
4353
4432
  sorted[suffix] = node.xp[suffix];
4354
4433
  }
4355
4434
  node.xp = sorted;
4435
+ for (const child of Object.values(node.xp)) {
4436
+ sortSuffixParams(child.c);
4437
+ }
4356
4438
  }
4357
4439
  if (node.s) {
4358
4440
  for (const child of Object.values(node.s)) {
@@ -4362,11 +4444,6 @@ function sortSuffixParams(node) {
4362
4444
  if (node.p) {
4363
4445
  sortSuffixParams(node.p.c);
4364
4446
  }
4365
- if (node.xp) {
4366
- for (const child of Object.values(node.xp)) {
4367
- sortSuffixParams(child.c);
4368
- }
4369
- }
4370
4447
  }
4371
4448
  function buildPerRouterTrie(manifest) {
4372
4449
  const ancestry = manifest._routeAncestry;
@@ -4405,12 +4482,15 @@ function insertRoute(node, segments, index, leaf) {
4405
4482
  };
4406
4483
  insertSegments(node, segments, index, leafBase, []);
4407
4484
  }
4485
+ function toVariant(leaf, responseType) {
4486
+ return leaf.pa ? { routeKey: leaf.n, responseType, pa: leaf.pa } : { routeKey: leaf.n, responseType };
4487
+ }
4408
4488
  function mergeLeaves(existing, leaf) {
4409
4489
  if (!existing) return leaf;
4410
4490
  if (existing.rt && leaf.rt) {
4411
4491
  const merged = leaf;
4412
4492
  merged.nv = existing.nv || [];
4413
- merged.nv.push({ routeKey: existing.n, responseType: existing.rt });
4493
+ merged.nv.push(toVariant(existing, existing.rt));
4414
4494
  return merged;
4415
4495
  }
4416
4496
  if (leaf.rt && !existing.rt) {
@@ -4418,13 +4498,13 @@ function mergeLeaves(existing, leaf) {
4418
4498
  existing.nv = [];
4419
4499
  existing.rf = true;
4420
4500
  }
4421
- existing.nv.push({ routeKey: leaf.n, responseType: leaf.rt });
4501
+ existing.nv.push(toVariant(leaf, leaf.rt));
4422
4502
  return existing;
4423
4503
  }
4424
4504
  if (!leaf.rt && existing.rt) {
4425
4505
  if (!leaf.nv) leaf.nv = [];
4426
4506
  if (existing.nv) leaf.nv.push(...existing.nv);
4427
- leaf.nv.push({ routeKey: existing.n, responseType: existing.rt });
4507
+ leaf.nv.push(toVariant(existing, existing.rt));
4428
4508
  return leaf;
4429
4509
  }
4430
4510
  return leaf;
@@ -4621,13 +4701,24 @@ async function runWithConcurrency(items, concurrency, fn) {
4621
4701
  return;
4622
4702
  }
4623
4703
  let nextIndex = 0;
4704
+ let firstError;
4705
+ let failed = false;
4624
4706
  async function worker() {
4625
- while (nextIndex < items.length) {
4707
+ while (nextIndex < items.length && !failed) {
4626
4708
  const idx = nextIndex++;
4627
- await fn(items[idx]);
4709
+ try {
4710
+ await fn(items[idx]);
4711
+ } catch (err) {
4712
+ if (!failed) {
4713
+ failed = true;
4714
+ firstError = err;
4715
+ }
4716
+ return;
4717
+ }
4628
4718
  }
4629
4719
  }
4630
4720
  await Promise.all(Array.from({ length: limit }, () => worker()));
4721
+ if (failed) throw firstError;
4631
4722
  }
4632
4723
  function groupByConcurrency(entries) {
4633
4724
  const map = /* @__PURE__ */ new Map();
@@ -7151,6 +7242,12 @@ ${list}`);
7151
7242
  "@vitejs/plugin-rsc/vendor/react-server-dom/server.edge"
7152
7243
  )
7153
7244
  ],
7245
+ // Vite 8 does not propagate the top-level optimizeDeps.exclude
7246
+ // (set in config()) to non-client envs, so the rsc env must set
7247
+ // it explicitly — mirroring the node ssr env and the cloudflare
7248
+ // rsc env. Without it a strict-pnpm npm-installed app can try to
7249
+ // pre-bundle the router's own subpath entries and fail.
7250
+ exclude: excludeDeps,
7154
7251
  rolldownOptions: sharedRolldownOptions
7155
7252
  }
7156
7253
  }
@@ -7193,8 +7290,7 @@ ${list}`);
7193
7290
  return;
7194
7291
  try {
7195
7292
  const source = readFileSync7(file, "utf-8");
7196
- const trimmed = source.trimStart();
7197
- if (trimmed.startsWith('"use client"') || trimmed.startsWith("'use client'")) {
7293
+ if (hasUseClientDirective(source)) {
7198
7294
  return [];
7199
7295
  }
7200
7296
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.132",
3
+ "version": "0.0.0-experimental.133",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -162,6 +162,17 @@
162
162
  "access": "public",
163
163
  "tag": "experimental"
164
164
  },
165
+ "scripts": {
166
+ "build": "pnpm exec 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 exec esbuild src/testing/vitest.ts --bundle --format=esm --outfile=dist/testing/vitest.js --platform=node --packages=external && pnpm exec 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",
167
+ "prepublishOnly": "pnpm build",
168
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.strict-check.json --noEmit && tsc -p tsconfig.augment-check.json --noEmit",
169
+ "test": "playwright test",
170
+ "test:ui": "playwright test --ui",
171
+ "test:hmr-local": "playwright test --project=dev-warmup --project=hmr-routes --project=hmr-basename --project=hmr-prerender --no-deps --workers=1",
172
+ "test:unit": "vitest run",
173
+ "test:unit:watch": "vitest",
174
+ "test:unit:rsc": "vitest run --config vitest.rsc.config.ts"
175
+ },
165
176
  "dependencies": {
166
177
  "@types/debug": "^4.1.12",
167
178
  "@vitejs/plugin-rsc": "^0.5.26",
@@ -173,19 +184,19 @@
173
184
  },
174
185
  "devDependencies": {
175
186
  "@playwright/test": "^1.49.1",
187
+ "@shared/e2e": "workspace:*",
176
188
  "@testing-library/dom": "^10.4.1",
177
189
  "@testing-library/react": "^16.3.2",
178
190
  "@types/node": "^24.10.1",
179
- "@types/react": "^19.2.7",
180
- "@types/react-dom": "^19.2.3",
191
+ "@types/react": "catalog:",
192
+ "@types/react-dom": "catalog:",
181
193
  "esbuild": "^0.27.0",
182
194
  "happy-dom": "^20.10.1",
183
195
  "jiti": "^2.6.1",
184
- "react": "^19.2.6",
185
- "react-dom": "^19.2.6",
196
+ "react": "catalog:",
197
+ "react-dom": "catalog:",
186
198
  "typescript": "^5.3.0",
187
- "vitest": "^4.0.0",
188
- "@shared/e2e": "0.0.1"
199
+ "vitest": "^4.0.0"
189
200
  },
190
201
  "peerDependencies": {
191
202
  "@cloudflare/vite-plugin": "^1.38.0",
@@ -216,15 +227,5 @@
216
227
  },
217
228
  "engines": {
218
229
  "node": "^20.19.0 || >=22.12.0"
219
- },
220
- "scripts": {
221
- "build": "pnpm exec 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 exec esbuild src/testing/vitest.ts --bundle --format=esm --outfile=dist/testing/vitest.js --platform=node --packages=external && pnpm exec 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",
222
- "typecheck": "tsc --noEmit && tsc -p tsconfig.strict-check.json --noEmit && tsc -p tsconfig.augment-check.json --noEmit",
223
- "test": "playwright test",
224
- "test:ui": "playwright test --ui",
225
- "test:hmr-local": "playwright test --project=dev-warmup --project=hmr-routes --project=hmr-basename --project=hmr-prerender --no-deps --workers=1",
226
- "test:unit": "vitest run",
227
- "test:unit:watch": "vitest",
228
- "test:unit:rsc": "vitest run --config vitest.rsc.config.ts"
229
230
  }
230
- }
231
+ }
@@ -206,7 +206,7 @@ path("/dashboard", (ctx) => {
206
206
  ```tsx
207
207
  // Client component
208
208
  "use client";
209
- import { useHandle, type Breadcrumbs } from "@rangojs/router/client";
209
+ import { useHandle, Breadcrumbs } from "@rangojs/router/client";
210
210
 
211
211
  function DashboardNav({ handle }: { handle: typeof Breadcrumbs }) {
212
212
  const crumbs = useHandle(handle);
@@ -41,7 +41,7 @@ import { join } from "node:path";
41
41
  // ... your other imports ...
42
42
 
43
43
  function analyze(): PluginOption[] {
44
- if (!process.env.ANALYZE) return [];
44
+ if (!process.env.RANGO_ANALYZE) return [];
45
45
  return (["client", "ssr", "rsc"] as const).map((envName) => {
46
46
  const inner = visualizer({
47
47
  filename: join("bundle-stats", `${envName}.html`),
@@ -79,7 +79,7 @@ Add `bundle-stats/` to your `.gitignore`.
79
79
  ## Step 3: Build with the analyzer enabled
80
80
 
81
81
  ```bash
82
- ANALYZE=1 pnpm exec vite build
82
+ RANGO_ANALYZE=1 pnpm exec vite build
83
83
  ```
84
84
 
85
85
  You'll get three HTML reports in `bundle-stats/`:
@@ -424,7 +424,7 @@ subsequent siblings. Everything below the cache boundary is cached as one unit:
424
424
 
425
425
  ```typescript
426
426
  path("/dashboard", DashboardPage, { name: "dashboard" }, () => [
427
- cache("long"),
427
+ cache({ ttl: 300 }),
428
428
  layout(DashboardSidebar, () => [
429
429
  parallel("@stats", StatsPanel),
430
430
  parallel("@activity", ActivityFeed),
@@ -444,7 +444,7 @@ boundary are not cached and always re-render:
444
444
  layout(RootLayout, () => [
445
445
  // RootLayout is NOT cached — runs every request
446
446
  path("/products/:slug", ProductPage, { name: "product" }, () => [
447
- cache("long"),
447
+ cache({ ttl: 300 }),
448
448
  layout(ProductSidebar),
449
449
  parallel("@reviews", ReviewsPanel),
450
450
  parallel("@related", RelatedProducts),
@@ -153,10 +153,11 @@ Purge-by-tag is available on all plans (since April 2025), subject to per-plan
153
153
  rate limits, so the batched single call matters. With a purge wired, `tagCacheTtl`
154
154
  becomes a pure read-cost reducer + fallback window.
155
155
 
156
- ## Named Profile Shorthand
156
+ ## Named Cache Profiles
157
157
 
158
- Use a named cache profile string instead of an options object. The profile must be
159
- defined in `createRouter({ cacheProfiles })`. Unknown names throw at boot time.
158
+ Define named profiles in `createRouter({ cacheProfiles })` so the same TTL/SWR
159
+ values can be shared across the DSL and `"use cache"` functions without repetition.
160
+ Unknown names throw at boot time.
160
161
 
161
162
  ```typescript
162
163
  // Define profiles in router
@@ -167,19 +168,25 @@ createRouter({
167
168
  long: { ttl: 3600, swr: 7200 },
168
169
  },
169
170
  });
171
+ ```
172
+
173
+ In the DSL, pass the profile's options directly to `cache()`:
170
174
 
171
- // Use by name in urls
175
+ ```typescript
172
176
  export const urlpatterns = urls(({ path, cache }) => [
173
- cache("long", () => [path("/blog", BlogIndex, { name: "blog" })]),
177
+ cache({ ttl: 3600, swr: 7200 }, () => [
178
+ path("/blog", BlogIndex, { name: "blog" }),
179
+ ]),
174
180
 
175
- // Also works without children (orphan cache boundary)
176
- cache("short"),
181
+ // Orphan cache boundary (covers subsequent siblings)
182
+ cache({ ttl: 60, swr: 120 }),
177
183
  path("/feed", FeedPage, { name: "feed" }),
178
184
  ]);
179
185
  ```
180
186
 
181
- These profile names are shared with the `"use cache: <name>"` directive. See
182
- `/use-cache` for function-level caching.
187
+ The DSL `cache()` helper does NOT accept a string profile name strings are only
188
+ valid in the `"use cache: <name>"` directive inside server functions. See
189
+ `/use-cache` for function-level caching with named profiles.
183
190
 
184
191
  ## Loader-Level Caching
185
192