@rangojs/router 0.23.0 → 0.24.1

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 (132) hide show
  1. package/dist/bin/rango.js +4 -3
  2. package/dist/types/browser/merge-segment-loaders.d.ts +5 -2
  3. package/dist/types/browser/partial-update.d.ts +6 -4
  4. package/dist/types/browser/react/NavigationProvider.d.ts +11 -7
  5. package/dist/types/browser/react/context.d.ts +3 -2
  6. package/dist/types/browser/types.d.ts +5 -1
  7. package/dist/types/build/route-types/scan-filter.d.ts +7 -1
  8. package/dist/types/cache/document-cache-policy.d.ts +24 -0
  9. package/dist/types/cache/document-cache.d.ts +2 -10
  10. package/dist/types/client-urls/types.d.ts +8 -0
  11. package/dist/types/context-var.d.ts +12 -0
  12. package/dist/types/errors.d.ts +10 -0
  13. package/dist/types/handles/pushed-by.d.ts +8 -0
  14. package/dist/types/index.d.ts +4 -0
  15. package/dist/types/index.rsc.d.ts +2 -0
  16. package/dist/types/response-utils.d.ts +7 -0
  17. package/dist/types/reverse.d.ts +8 -8
  18. package/dist/types/route-definition/rewrite.d.ts +56 -0
  19. package/dist/types/route-map-builder.d.ts +7 -2
  20. package/dist/types/router/basename.d.ts +7 -0
  21. package/dist/types/router/handler-context.d.ts +1 -5
  22. package/dist/types/router/internal-params.d.ts +8 -0
  23. package/dist/types/router/middleware-types.d.ts +15 -0
  24. package/dist/types/router/revalidation.d.ts +1 -2
  25. package/dist/types/router/route-trie-builder.d.ts +10 -0
  26. package/dist/types/router/segment-resolution/mask-nested.d.ts +4 -2
  27. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +36 -13
  28. package/dist/types/rsc/full-payload.d.ts +11 -0
  29. package/dist/types/rsc/handler.d.ts +33 -0
  30. package/dist/types/rsc/helpers.d.ts +8 -0
  31. package/dist/types/rsc/loader-fetch.d.ts +16 -0
  32. package/dist/types/rsc/rewrite-passes.d.ts +74 -0
  33. package/dist/types/rsc/ssr-setup.d.ts +3 -1
  34. package/dist/types/server/request-context.d.ts +39 -2
  35. package/dist/types/server/rewrite-trail.d.ts +49 -0
  36. package/dist/types/server/slot-accessors.d.ts +29 -0
  37. package/dist/types/testing/index.d.ts +1 -1
  38. package/dist/types/testing/render-route.d.ts +8 -0
  39. package/dist/types/testing/run-middleware.d.ts +20 -0
  40. package/dist/types/testing/serve-shell-request.d.ts +2 -1
  41. package/dist/types/thenable-status.d.ts +6 -0
  42. package/dist/types/types/request-scope.d.ts +7 -0
  43. package/dist/types/vite/discovery/state.d.ts +12 -9
  44. package/dist/vite/index.js +164 -103
  45. package/package.json +1 -1
  46. package/skills/caching/SKILL.md +12 -0
  47. package/skills/catalog.json +1 -1
  48. package/skills/client-urls/SKILL.md +86 -1
  49. package/skills/hooks/SKILL.md +11 -6
  50. package/skills/hooks/data.md +3 -1
  51. package/skills/loader/SKILL.md +7 -1
  52. package/skills/middleware/SKILL.md +183 -2
  53. package/skills/ppr/SKILL.md +9 -0
  54. package/skills/rango/SKILL.md +1 -1
  55. package/skills/server-actions/SKILL.md +27 -0
  56. package/skills/testing/client-components.md +1 -0
  57. package/skills/testing/middleware.md +31 -10
  58. package/skills/testing/response-routes.md +1 -0
  59. package/skills/testing/serve-shell-request.md +19 -1
  60. package/skills/use-cache/SKILL.md +4 -2
  61. package/src/__augment-tests__/augmented.check.ts +20 -0
  62. package/src/browser/event-controller.ts +3 -0
  63. package/src/browser/merge-segment-loaders.ts +31 -18
  64. package/src/browser/navigation-bridge.ts +19 -3
  65. package/src/browser/partial-update.ts +27 -12
  66. package/src/browser/react/Link.tsx +7 -9
  67. package/src/browser/react/NavigationProvider.tsx +46 -26
  68. package/src/browser/react/context.ts +3 -2
  69. package/src/browser/react/use-router.ts +2 -4
  70. package/src/browser/server-action-bridge.ts +6 -0
  71. package/src/browser/types.ts +5 -1
  72. package/src/build/route-types/scan-filter.ts +11 -4
  73. package/src/cache/document-cache-policy.ts +131 -0
  74. package/src/cache/document-cache.ts +12 -119
  75. package/src/client-urls/client-root.tsx +3 -3
  76. package/src/client-urls/client-urls.ts +8 -1
  77. package/src/client-urls/types.ts +8 -0
  78. package/src/context-var.ts +32 -2
  79. package/src/errors.ts +19 -0
  80. package/src/handles/pushed-by.ts +26 -0
  81. package/src/index.rsc.ts +8 -0
  82. package/src/index.ts +7 -0
  83. package/src/response-utils.ts +19 -0
  84. package/src/reverse.ts +17 -14
  85. package/src/route-definition/redirect.ts +2 -15
  86. package/src/route-definition/rewrite.ts +146 -0
  87. package/src/route-map-builder.ts +14 -11
  88. package/src/router/basename.ts +20 -0
  89. package/src/router/handler-context.ts +3 -13
  90. package/src/router/intercept-resolution.ts +29 -12
  91. package/src/router/internal-params.ts +16 -0
  92. package/src/router/loader-resolution.ts +2 -0
  93. package/src/router/match-middleware/background-revalidation.ts +11 -9
  94. package/src/router/match-middleware/cache-lookup.ts +20 -15
  95. package/src/router/match-middleware/cache-store.ts +9 -11
  96. package/src/router/match-middleware/intercept-resolution.ts +4 -2
  97. package/src/router/middleware-types.ts +16 -0
  98. package/src/router/middleware.ts +127 -49
  99. package/src/router/revalidation.ts +1 -1
  100. package/src/router/route-trie-builder.ts +28 -1
  101. package/src/router/segment-resolution/loader-snapshot.ts +18 -4
  102. package/src/router/segment-resolution/mask-nested.ts +8 -4
  103. package/src/router/segment-resolution/prefetch-deferral.ts +57 -17
  104. package/src/router/segment-resolution/revalidation.ts +57 -33
  105. package/src/rsc/full-payload.ts +37 -5
  106. package/src/rsc/handler.ts +82 -55
  107. package/src/rsc/helpers.ts +24 -0
  108. package/src/rsc/loader-fetch.ts +79 -24
  109. package/src/rsc/response-cache-serve.ts +18 -2
  110. package/src/rsc/response-route-handler.ts +4 -7
  111. package/src/rsc/rewrite-passes.ts +203 -0
  112. package/src/rsc/rsc-rendering.ts +4 -2
  113. package/src/rsc/ssr-setup.ts +11 -5
  114. package/src/server/cookie-parse.ts +36 -12
  115. package/src/server/cookie-store.ts +7 -3
  116. package/src/server/handle-store.ts +48 -17
  117. package/src/server/request-context.ts +154 -63
  118. package/src/server/request-headers.ts +4 -14
  119. package/src/server/rewrite-trail.ts +190 -0
  120. package/src/server/slot-accessors.ts +57 -0
  121. package/src/ssr/index.tsx +118 -16
  122. package/src/testing/dispatch.ts +138 -67
  123. package/src/testing/index.ts +1 -0
  124. package/src/testing/render-route.tsx +16 -3
  125. package/src/testing/run-loader.ts +2 -0
  126. package/src/testing/run-middleware.ts +56 -11
  127. package/src/testing/serve-shell-request.ts +15 -2
  128. package/src/thenable-status.ts +11 -0
  129. package/src/types/request-scope.ts +8 -0
  130. package/src/vite/discovery/state.ts +21 -13
  131. package/src/vite/plugins/expose-internal-ids.ts +2 -3
  132. package/src/vite/router-discovery.ts +29 -27
@@ -119,7 +119,7 @@ __export(client_ref_hashing_exports, {
119
119
  hashRefKey: () => hashRefKey,
120
120
  transformClientRefs: () => transformClientRefs
121
121
  });
122
- import { relative as relative3 } from "node:path";
122
+ import { relative as relative4 } from "node:path";
123
123
  import { createHash as createHash3 } from "node:crypto";
124
124
  function hashRefKey(relativeId) {
125
125
  return createHash3("sha256").update(relativeId).digest("hex").slice(0, 12);
@@ -132,10 +132,10 @@ function computeProductionHash(projectRoot, refKey) {
132
132
  const absPath = decodeURIComponent(
133
133
  refKey.slice(CLIENT_IN_SERVER_PKG_PROXY_PREFIX.length)
134
134
  );
135
- toHash = relative3(projectRoot, absPath).replaceAll("\\", "/");
135
+ toHash = relative4(projectRoot, absPath).replaceAll("\\", "/");
136
136
  } else if (refKey.startsWith(FS_PREFIX)) {
137
137
  const absPath = refKey.slice(FS_PREFIX.length - 1);
138
- toHash = relative3(projectRoot, absPath).replaceAll("\\", "/");
138
+ toHash = relative4(projectRoot, absPath).replaceAll("\\", "/");
139
139
  } else if (refKey.startsWith("/")) {
140
140
  toHash = refKey.slice(1);
141
141
  } else {
@@ -1903,6 +1903,70 @@ function transformInlineHandlers(fnName, virtualPrefix, s, code, filePath, virtu
1903
1903
  return true;
1904
1904
  }
1905
1905
 
1906
+ // src/build/route-types/scan-filter.ts
1907
+ import { isAbsolute, join, relative } from "node:path";
1908
+ import { readdirSync } from "node:fs";
1909
+ import picomatch from "picomatch";
1910
+ var DEFAULT_EXCLUDE_PATTERNS = [
1911
+ "**/__tests__/**",
1912
+ "**/__mocks__/**",
1913
+ "**/dist/**",
1914
+ "**/coverage/**",
1915
+ "**/*.test.{ts,tsx,js,jsx}",
1916
+ "**/*.spec.{ts,tsx,js,jsx}"
1917
+ ];
1918
+ function createScanFilter(root, opts) {
1919
+ const { include, exclude } = opts;
1920
+ const hasInclude = include && include.length > 0;
1921
+ const hasCustomExclude = exclude !== void 0;
1922
+ if (!hasInclude && !hasCustomExclude) return void 0;
1923
+ const effectiveExclude = exclude ?? DEFAULT_EXCLUDE_PATTERNS;
1924
+ const includeMatcher = hasInclude ? picomatch(include) : null;
1925
+ const excludeMatcher = effectiveExclude.length > 0 ? picomatch(effectiveExclude) : null;
1926
+ return (absolutePath) => {
1927
+ const rel = relative(root, absolutePath);
1928
+ if (excludeMatcher && excludeMatcher(rel)) return false;
1929
+ if (includeMatcher) return includeMatcher(rel);
1930
+ return true;
1931
+ };
1932
+ }
1933
+ function isUniversallySkippedScanDir(name) {
1934
+ return name === "node_modules" || name === "dist" || name === "coverage" || name.startsWith(".");
1935
+ }
1936
+ function isInSkippedScanDir(root, absolutePath) {
1937
+ const rel = relative(root, absolutePath);
1938
+ if (isAbsolute(rel)) return false;
1939
+ const dirs = rel.split(/[\\/]/).slice(0, -1);
1940
+ if (dirs[0] === "..") return false;
1941
+ return dirs.some(isUniversallySkippedScanDir);
1942
+ }
1943
+ function isSkippedSourceScanDir(name) {
1944
+ return isUniversallySkippedScanDir(name) || name === "build";
1945
+ }
1946
+ function findTsFiles(dir, filter) {
1947
+ const results = [];
1948
+ let entries;
1949
+ try {
1950
+ entries = readdirSync(dir, { withFileTypes: true });
1951
+ } catch (err) {
1952
+ console.warn(
1953
+ `[rango] Failed to scan directory ${dir}: ${err.message}`
1954
+ );
1955
+ return results;
1956
+ }
1957
+ for (const entry of entries) {
1958
+ const fullPath = join(dir, entry.name);
1959
+ if (entry.isDirectory()) {
1960
+ if (isSkippedSourceScanDir(entry.name)) continue;
1961
+ results.push(...findTsFiles(fullPath, filter));
1962
+ } else if ((entry.name.endsWith(".ts") || entry.name.endsWith(".tsx") || entry.name.endsWith(".js") || entry.name.endsWith(".jsx")) && !entry.name.includes(".gen.")) {
1963
+ if (filter && !filter(fullPath)) continue;
1964
+ results.push(fullPath);
1965
+ }
1966
+ }
1967
+ return results;
1968
+ }
1969
+
1906
1970
  // src/vite/plugins/expose-ids/types.ts
1907
1971
  var PRERENDER_CONFIG = {
1908
1972
  fnName: "Prerender",
@@ -2443,8 +2507,14 @@ function compareStrings(a, b) {
2443
2507
  // src/vite/discovery/state.ts
2444
2508
  var VIRTUAL_ROUTES_MANIFEST_ID = "virtual:rsc-router/routes-manifest";
2445
2509
  var VIRTUAL_LOADER_MANIFEST_ID = "virtual:rsc-router/loader-manifest";
2446
- function canRefreshRuntimeDiscovery(state, hasMainRunner) {
2447
- return state.perRouterManifests.length > 0 || hasMainRunner && !!state.lastDiscoveryError;
2510
+ function recordDiscoveryFailure(state, err) {
2511
+ state.lastDiscoveryError = {
2512
+ message: err?.message ?? String(err),
2513
+ at: Date.now()
2514
+ };
2515
+ }
2516
+ function canRefreshRuntimeDiscovery(state) {
2517
+ return state.perRouterManifests.length > 0 || !!state.lastDiscoveryError;
2448
2518
  }
2449
2519
  function createDiscoveryState(entryPath, opts) {
2450
2520
  return {
@@ -2746,7 +2816,6 @@ ${lazyImports.join(",\n")}
2746
2816
  if (!isBuild) return;
2747
2817
  if (this.environment && this.environment.name !== "rsc") return;
2748
2818
  const fs2 = await import("node:fs/promises");
2749
- const SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", "build", "coverage"]);
2750
2819
  async function scanDir(dir) {
2751
2820
  const results = [];
2752
2821
  try {
@@ -2754,7 +2823,7 @@ ${lazyImports.join(",\n")}
2754
2823
  for (const entry of entries) {
2755
2824
  const fullPath = path4.join(dir, entry.name);
2756
2825
  if (entry.isDirectory()) {
2757
- if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".")) {
2826
+ if (!isSkippedSourceScanDir(entry.name)) {
2758
2827
  results.push(...await scanDir(fullPath));
2759
2828
  }
2760
2829
  } else if (/\.(ts|tsx|js|jsx)$/.test(entry.name)) {
@@ -4234,7 +4303,7 @@ function transitionWhenHoistPlugin() {
4234
4303
  import { normalizePath as normalizePath4, parseAst as parseAst5 } from "vite";
4235
4304
  import MagicString5 from "magic-string";
4236
4305
  import { createHash as createHash2 } from "node:crypto";
4237
- import { relative } from "node:path";
4306
+ import { relative as relative2 } from "node:path";
4238
4307
  import { hasDirective as hasDirective3 } from "@vitejs/plugin-rsc/transforms";
4239
4308
 
4240
4309
  // src/client-urls/hoisted-revalidate.ts
@@ -4379,7 +4448,7 @@ async function hoistClientUrlsRevalidates(code, id, deps) {
4379
4448
  directImport: false
4380
4449
  });
4381
4450
  if (calls.length === 0) return null;
4382
- const relativeFile = normalizePath4(relative(deps.root, file));
4451
+ const relativeFile = normalizePath4(relative2(deps.root, file));
4383
4452
  const s = new MagicString5(code);
4384
4453
  const used = /* @__PURE__ */ new Map();
4385
4454
  const entries = /* @__PURE__ */ new Map();
@@ -4600,7 +4669,7 @@ import ${JSON.stringify(`${file}?${CLIENT_URLS_REVALIDATE_QUERY}`)};
4600
4669
  // src/vite/plugins/client-ref-dedup.ts
4601
4670
  init_debug();
4602
4671
  import { existsSync, readFileSync } from "node:fs";
4603
- import { isAbsolute, join, normalize, relative as relative2 } from "node:path";
4672
+ import { isAbsolute as isAbsolute2, join as join2, normalize, relative as relative3 } from "node:path";
4604
4673
  var debug4 = createRangoDebugger(NS.transform);
4605
4674
  var CLIENT_IN_SERVER_PROXY_PREFIX = "virtual:vite-rsc/client-in-server-package-proxy/";
4606
4675
  var DEDUP_PREFIX = "\0rango:dedup/";
@@ -4633,7 +4702,7 @@ function readPackageMetadata(source, cache3) {
4633
4702
  if (!root) return;
4634
4703
  const cached = cache3.get(root);
4635
4704
  if (cached !== void 0) return cached ?? void 0;
4636
- const packageJsonPath = join(root, "package.json");
4705
+ const packageJsonPath = join2(root, "package.json");
4637
4706
  if (!existsSync(packageJsonPath)) {
4638
4707
  cache3.set(root, null);
4639
4708
  return;
@@ -4686,8 +4755,8 @@ function getPublicSpecifierCandidates(metadata, packageSpecifier, source) {
4686
4755
  return [];
4687
4756
  }
4688
4757
  const candidates = /* @__PURE__ */ new Set();
4689
- const sourceRelative = `./${relative2(metadata.root, source).replaceAll("\\", "/")}`;
4690
- if (sourceRelative.startsWith("./../") || isAbsolute(sourceRelative.slice(2))) {
4758
+ const sourceRelative = `./${relative3(metadata.root, source).replaceAll("\\", "/")}`;
4759
+ if (sourceRelative.startsWith("./../") || isAbsolute2(sourceRelative.slice(2))) {
4691
4760
  return [];
4692
4761
  }
4693
4762
  for (const [exportKey, target] of Object.entries(metadata.exports)) {
@@ -4774,7 +4843,7 @@ function clientRefDedup() {
4774
4843
  configResolved(config) {
4775
4844
  const clientEnv = config.environments?.["client"];
4776
4845
  clientExclude = clientEnv?.optimizeDeps?.exclude ?? config.optimizeDeps?.exclude ?? [];
4777
- rootImporter = join(config.root, "index.html");
4846
+ rootImporter = join2(config.root, "index.html");
4778
4847
  resolvePackage = config.createResolver({ scan: true });
4779
4848
  },
4780
4849
  buildEnd() {
@@ -4848,7 +4917,7 @@ import { parseAst as parseAst6 } from "vite";
4848
4917
  // src/vite/discovery/build-versions.ts
4849
4918
  import { createHash as createHash4 } from "node:crypto";
4850
4919
  import { isBuiltin } from "node:module";
4851
- import { posix, relative as relative4 } from "node:path";
4920
+ import { posix, relative as relative5 } from "node:path";
4852
4921
 
4853
4922
  // src/router-versions.ts
4854
4923
  var DEFAULT_ROUTER_VERSIONS_KEY = "*";
@@ -5443,7 +5512,7 @@ function portableModuleId(id, projectRoot) {
5443
5512
  return path6.slice(nodeModules + 1).replace(PNPM_PEER_SUFFIX, "$1");
5444
5513
  }
5445
5514
  if (isAbsolutePath(path6)) {
5446
- return relative4(projectRoot, path6).replaceAll("\\", "/");
5515
+ return relative5(projectRoot, path6).replaceAll("\\", "/");
5447
5516
  }
5448
5517
  return withoutWorkingDirectory(path6).replace(
5449
5518
  /([?&][^=&]+=)([^&]+)/g,
@@ -5860,7 +5929,7 @@ import { resolve } from "node:path";
5860
5929
  // package.json
5861
5930
  var package_default = {
5862
5931
  name: "@rangojs/router",
5863
- version: "0.23.0",
5932
+ version: "0.24.1",
5864
5933
  description: "Django-inspired RSC router with composable URL patterns",
5865
5934
  keywords: [
5866
5935
  "react",
@@ -6319,69 +6388,6 @@ declare global {
6319
6388
  `;
6320
6389
  }
6321
6390
 
6322
- // src/build/route-types/scan-filter.ts
6323
- import { isAbsolute as isAbsolute2, join as join2, relative as relative5 } from "node:path";
6324
- import { readdirSync } from "node:fs";
6325
- import picomatch from "picomatch";
6326
- var DEFAULT_EXCLUDE_PATTERNS = [
6327
- "**/__tests__/**",
6328
- "**/__mocks__/**",
6329
- "**/dist/**",
6330
- "**/coverage/**",
6331
- "**/*.test.{ts,tsx,js,jsx}",
6332
- "**/*.spec.{ts,tsx,js,jsx}"
6333
- ];
6334
- function createScanFilter(root, opts) {
6335
- const { include, exclude } = opts;
6336
- const hasInclude = include && include.length > 0;
6337
- const hasCustomExclude = exclude !== void 0;
6338
- if (!hasInclude && !hasCustomExclude) return void 0;
6339
- const effectiveExclude = exclude ?? DEFAULT_EXCLUDE_PATTERNS;
6340
- const includeMatcher = hasInclude ? picomatch(include) : null;
6341
- const excludeMatcher = effectiveExclude.length > 0 ? picomatch(effectiveExclude) : null;
6342
- return (absolutePath) => {
6343
- const rel = relative5(root, absolutePath);
6344
- if (excludeMatcher && excludeMatcher(rel)) return false;
6345
- if (includeMatcher) return includeMatcher(rel);
6346
- return true;
6347
- };
6348
- }
6349
- function isUniversallySkippedScanDir(name) {
6350
- return name === "node_modules" || name === "dist" || name === "coverage" || name.startsWith(".");
6351
- }
6352
- function isInSkippedScanDir(root, absolutePath) {
6353
- const rel = relative5(root, absolutePath);
6354
- if (isAbsolute2(rel)) return false;
6355
- const dirs = rel.split(/[\\/]/).slice(0, -1);
6356
- if (dirs[0] === "..") return false;
6357
- return dirs.some(isUniversallySkippedScanDir);
6358
- }
6359
- function findTsFiles(dir, filter) {
6360
- const results = [];
6361
- let entries;
6362
- try {
6363
- entries = readdirSync(dir, { withFileTypes: true });
6364
- } catch (err) {
6365
- console.warn(
6366
- `[rango] Failed to scan directory ${dir}: ${err.message}`
6367
- );
6368
- return results;
6369
- }
6370
- for (const entry of entries) {
6371
- const fullPath = join2(dir, entry.name);
6372
- if (entry.isDirectory()) {
6373
- if (isUniversallySkippedScanDir(entry.name) || entry.name === "build") {
6374
- continue;
6375
- }
6376
- results.push(...findTsFiles(fullPath, filter));
6377
- } else if ((entry.name.endsWith(".ts") || entry.name.endsWith(".tsx") || entry.name.endsWith(".js") || entry.name.endsWith(".jsx")) && !entry.name.includes(".gen.")) {
6378
- if (filter && !filter(fullPath)) continue;
6379
- results.push(fullPath);
6380
- }
6381
- }
6382
- return results;
6383
- }
6384
-
6385
6391
  // src/build/route-types/per-module-writer.ts
6386
6392
  import ts4 from "typescript";
6387
6393
 
@@ -8684,7 +8690,7 @@ function insertSegments(node, segments, index, leafBase, paramNames) {
8684
8690
  }
8685
8691
  const segment = segments[index];
8686
8692
  if (segment.type === "static") {
8687
- if (!node.s) node.s = {};
8693
+ if (!node.s) node.s = /* @__PURE__ */ Object.create(null);
8688
8694
  if (!node.s[segment.value]) node.s[segment.value] = {};
8689
8695
  insertSegments(
8690
8696
  node.s[segment.value],
@@ -8984,6 +8990,13 @@ function jsonParseExpression(value) {
8984
8990
  }
8985
8991
 
8986
8992
  // src/context-var.ts
8993
+ function createVar(options) {
8994
+ return {
8995
+ __brand: "context-var",
8996
+ key: /* @__PURE__ */ Symbol(),
8997
+ cache: options?.cache !== false
8998
+ };
8999
+ }
8987
9000
  function hasContextVars(variables) {
8988
9001
  return Object.keys(variables).length > 0 || Object.getOwnPropertySymbols(variables).length > 0;
8989
9002
  }
@@ -8996,7 +9009,11 @@ function getNonCacheableKeys(variables) {
8996
9009
  }
8997
9010
  return variables[NON_CACHEABLE_KEYS];
8998
9011
  }
8999
- var FORBIDDEN_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
9012
+ var FORBIDDEN_KEYS = /* @__PURE__ */ new Set([
9013
+ "__proto__",
9014
+ "constructor",
9015
+ "prototype"
9016
+ ]);
9000
9017
  function contextSet(variables, keyOrVar, value, options) {
9001
9018
  if (typeof keyOrVar === "string") {
9002
9019
  if (FORBIDDEN_KEYS.has(keyOrVar)) {
@@ -10084,6 +10101,14 @@ function getAppVersion() {
10084
10101
  return currentVersion2;
10085
10102
  }
10086
10103
 
10104
+ // src/router/basename.ts
10105
+ function prefixBasename(path6, basename2) {
10106
+ if (!basename2 || !path6.startsWith("/") || path6 === basename2 || path6.startsWith(basename2 + "/") || path6.startsWith(basename2 + "?") || path6.startsWith(basename2 + "#")) {
10107
+ return path6;
10108
+ }
10109
+ return path6 === "/" ? basename2 : basename2 + path6;
10110
+ }
10111
+
10087
10112
  // src/loader-redirect.tsx
10088
10113
  init_validate_redirect_origin();
10089
10114
  init_redirect_origin();
@@ -10493,13 +10518,10 @@ var Link = forwardRef(function Link2({
10493
10518
  }, ref) {
10494
10519
  const ctx = useContext15(NavigationStoreContext);
10495
10520
  const isExternal = isExternalUrl(to, ctx?.origin);
10496
- const resolvedTo = useMemo8(() => {
10497
- if (isExternal) return to;
10498
- const bn = ctx?.basename;
10499
- if (!bn || !to.startsWith("/") || to.startsWith(bn + "/") || to === bn)
10500
- return to;
10501
- return to === "/" ? bn : bn + to;
10502
- }, [to, isExternal, ctx?.basename]);
10521
+ const resolvedTo = useMemo8(
10522
+ () => isExternal ? to : prefixBasename(to, ctx?.basename),
10523
+ [to, isExternal, ctx?.basename]
10524
+ );
10503
10525
  const configuredStrategy = prefetch ?? ctx?.defaultPrefetch ?? ENVIRONMENT_DEFAULT_PREFETCH;
10504
10526
  const internalRef = useRef13(null);
10505
10527
  const setRef = useCallback5(
@@ -10756,6 +10778,19 @@ var ALL_USE_ITEM_TYPES = new Set(
10756
10778
 
10757
10779
  // src/server/request-context.ts
10758
10780
  import { AsyncLocalStorage as AsyncLocalStorage5 } from "node:async_hooks";
10781
+
10782
+ // src/server/slot-accessors.ts
10783
+ function slotAccessor(slot, enumerable) {
10784
+ return {
10785
+ get() {
10786
+ return this[slot]();
10787
+ },
10788
+ enumerable,
10789
+ configurable: true
10790
+ };
10791
+ }
10792
+
10793
+ // src/server/request-context.ts
10759
10794
  init_cookie_name();
10760
10795
 
10761
10796
  // src/router/segment-resolution/mask-nested.ts
@@ -10823,12 +10858,38 @@ var THEME_READ = {
10823
10858
  // src/router/segment-resolution/prefetch-deferral.ts
10824
10859
  init_prefetch_markers();
10825
10860
 
10861
+ // src/cache/document-cache-policy.ts
10862
+ init_cookie_name();
10863
+
10864
+ // src/server/rewrite-trail.ts
10865
+ var REWRITE_TRAIL = createVar();
10866
+
10826
10867
  // src/cache/invalidation-order.ts
10827
10868
  var order = globalThis[/* @__PURE__ */ Symbol.for("rangojs-router:tag-invalidation-order")] ??= { seq: 0, tags: /* @__PURE__ */ new Map(), forgotten: 0 };
10828
10869
  var responseStarts = globalThis[/* @__PURE__ */ Symbol.for("rangojs-router:response-execution-start")] ??= /* @__PURE__ */ new WeakMap();
10829
10870
 
10830
10871
  // src/server/request-context.ts
10831
10872
  var requestContextStorage = new AsyncLocalStorage5();
10873
+ var RES_SLOT = /* @__PURE__ */ Symbol("rango.requestContext.res");
10874
+ var RENDER_BARRIER_SLOT = /* @__PURE__ */ Symbol(
10875
+ "rango.requestContext.renderBarrier"
10876
+ );
10877
+ var RES_DESCRIPTOR = {
10878
+ ...slotAccessor(RES_SLOT, true),
10879
+ set(_) {
10880
+ throw new Error(
10881
+ "ctx.res is read-only. Use ctx.header() to set response headers, or cookies() for cookie mutations."
10882
+ );
10883
+ }
10884
+ };
10885
+ var RENDER_BARRIER_DESCRIPTOR = slotAccessor(
10886
+ RENDER_BARRIER_SLOT,
10887
+ true
10888
+ );
10889
+ var DERIVED_RENDER_BARRIER_DESCRIPTOR = {
10890
+ ...RENDER_BARRIER_DESCRIPTOR,
10891
+ enumerable: false
10892
+ };
10832
10893
 
10833
10894
  // src/route-definition/redirect.ts
10834
10895
  init_redirect_origin();
@@ -13213,12 +13274,18 @@ function createRouterDiscoveryPlugin(entryPath, opts) {
13213
13274
  "acquireBuildEnv",
13214
13275
  () => acquireBuildEnv(s, viteCommand, viteMode)
13215
13276
  );
13216
- tempRscEnv = (await timed(
13277
+ const temp = await timed(
13217
13278
  debugDiscovery,
13218
13279
  "getOrCreateTempServer",
13219
13280
  () => getOrCreateTempServer()
13220
- )).env;
13221
- if (tempRscEnv) {
13281
+ );
13282
+ tempRscEnv = temp.env;
13283
+ if (!tempRscEnv) {
13284
+ recordDiscoveryFailure(
13285
+ s,
13286
+ temp.error ?? "temp runner unavailable for discovery"
13287
+ );
13288
+ } else {
13222
13289
  optimizerHashBefore2 = tempRscEnv.depsOptimizer?.metadata?.browserHash;
13223
13290
  await timed(
13224
13291
  debugDiscovery,
@@ -13236,6 +13303,7 @@ function createRouterDiscoveryPlugin(entryPath, opts) {
13236
13303
  );
13237
13304
  }
13238
13305
  } catch (err) {
13306
+ recordDiscoveryFailure(s, err);
13239
13307
  emitDiscoveryFailure(
13240
13308
  err,
13241
13309
  optimizerHashBefore2,
@@ -13277,10 +13345,7 @@ function createRouterDiscoveryPlugin(entryPath, opts) {
13277
13345
  () => propagateDiscoveryState(rscEnv)
13278
13346
  );
13279
13347
  } catch (err) {
13280
- s.lastDiscoveryError = {
13281
- message: err?.message ?? String(err),
13282
- at: Date.now()
13283
- };
13348
+ recordDiscoveryFailure(s, err);
13284
13349
  emitDiscoveryFailure(
13285
13350
  err,
13286
13351
  optimizerHashBefore,
@@ -13685,7 +13750,6 @@ function createRouterDiscoveryPlugin(entryPath, opts) {
13685
13750
  const refreshRuntimeDiscovery = async () => {
13686
13751
  const rscEnv = server.environments?.rsc;
13687
13752
  const hasMainRunner = !!rscEnv?.runner;
13688
- if (!hasMainRunner && s.perRouterManifests.length === 0) return;
13689
13753
  let previousRouteShape = routeShapeSignature();
13690
13754
  await gate.runRefreshCycle(async () => {
13691
13755
  const hmrStart = performance.now();
@@ -13756,10 +13820,7 @@ function createRouterDiscoveryPlugin(entryPath, opts) {
13756
13820
  forceCloudflareWorkerReload(rscEnv, expectedEpoch);
13757
13821
  }
13758
13822
  } catch (err) {
13759
- s.lastDiscoveryError = {
13760
- message: err?.message ?? String(err),
13761
- at: Date.now()
13762
- };
13823
+ recordDiscoveryFailure(s, err);
13763
13824
  const whenError2 = showTransitionWhenError(err);
13764
13825
  if (whenError2) {
13765
13826
  console.error(whenError2.message);
@@ -13879,7 +13940,7 @@ function createRouterDiscoveryPlugin(entryPath, opts) {
13879
13940
  "watcher: regenerated gen files (%sms)",
13880
13941
  (performance.now() - regenStart).toFixed(1)
13881
13942
  );
13882
- if (canRefreshRuntimeDiscovery(s, !!rscEnv?.runner)) {
13943
+ if (canRefreshRuntimeDiscovery(s)) {
13883
13944
  refreshRuntimeDiscovery().catch((err) => {
13884
13945
  console.warn(
13885
13946
  `[rango] Runtime re-discovery error: ${err.message}`
@@ -14003,7 +14064,7 @@ function createRouterDiscoveryPlugin(entryPath, opts) {
14003
14064
  }
14004
14065
  s.cachedRouterFiles = void 0;
14005
14066
  }
14006
- if (canRefreshRuntimeDiscovery(s, !!mainRscEnv?.runner)) {
14067
+ if (canRefreshRuntimeDiscovery(s)) {
14007
14068
  gate.noteRouteEvent();
14008
14069
  }
14009
14070
  scheduleRouteRegeneration();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.23.0",
3
+ "version": "0.24.1",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -881,10 +881,22 @@ another, request-scoped APIs are guarded inside a cache scope:
881
881
  | Response writes: `ctx.headers.set()`, `setCookie()`, `setStatus()`, `onResponse()` | **throws** — response side effects lost on a hit |
882
882
  | `ctx.get(var)` where the var is `{ cache: false }` | **throws** on read |
883
883
  | `ctx.theme`, `getRequestContext().theme` | **throws** — the theme is the visitor's cookie |
884
+ | `ctx.rewrittenFrom` (any ctx, `getRequestContext().rewrittenFrom`) | **throws** — the visitor's URL of a rewritten request |
884
885
  | `ctx.set(var, value)` for a cacheable var | allowed (children are cached too) |
885
886
  | Any of the above **inside a registered loader** (`loader(...)`) | **allowed** — loaders always run fresh |
886
887
  | Any read in the boundary's own `key()`, `keyGenerator`, `condition()` or `tags()` | **allowed** — it picks or labels the entry, never rendered |
887
888
 
889
+ `ctx.rewrittenFrom` (the visitor's URL when middleware rewrote the request,
890
+ `/middleware` → "Rewrite") throws on every read inside a `cache()` boundary
891
+ or a `"use cache"` function, on a request nothing rewrote too, like
892
+ `cookies()`: the entry would hold whichever URL the first caller came from,
893
+ and a read that worked only on direct visits would break the first time a
894
+ rewritten request reached it. A PPR shell capture and a render the document
895
+ cache stores read it as `undefined` instead of throwing: those renders are
896
+ the target page as a direct visit renders it, and the rewritten visitor's
897
+ own render (outside them) still sees its URL. A response route under
898
+ `cache()` whose handler read it is not stored.
899
+
888
900
  A loader body invoked from a handler with `await ctx.use(Loader)` may read
889
901
  `cookies()`/`headers()`/`ctx.request.headers`, but its response writes still throw: on a hit the
890
902
  handler is skipped, so that loader never runs.
@@ -143,7 +143,7 @@
143
143
  },
144
144
  {
145
145
  "name": "middleware",
146
- "description": "Define global (router.use) and route (middleware()) middleware in @rangojs/router — auth gates, logging, response headers, context variables, redirects. Use when gating routes behind auth checks, logging requests, setting headers or ctx variables before a handler runs, or deciding whether middleware wraps server actions.",
146
+ "description": "Define global (router.use) and route (middleware()) middleware in @rangojs/router — auth gates, logging, response headers, context variables, redirects, rewrites (throw rewrite() renders another route at the same URL). Use when gating routes behind auth checks, logging requests, setting headers or ctx variables before a handler runs, showing a maintenance page at the visitor's URL, or deciding whether middleware wraps server actions.",
147
147
  "argumentHint": "[middleware-name]",
148
148
  "path": "skills/middleware/SKILL.md"
149
149
  },
@@ -287,6 +287,80 @@ Two scars worth copying:
287
287
  actions (`isAction(CartActions)`) while product/related loaders hold —
288
288
  three freshness outcomes in a single commit, decided per loader.
289
289
 
290
+ ## After an action: what the server decides
291
+
292
+ The browser sends the action with the ids of the segments the page holds.
293
+ Everything after that is decided on the server, while it handles that request:
294
+
295
+ - **The action** runs there, and its return value is what the call resolves
296
+ with (`await action()`, `useActionState`).
297
+ - **Which loaders rerun.** Each group loader's own `revalidate()` answers, with
298
+ the action's `actionResult` and `formData` (above). A loader that holds keeps
299
+ the data the page shows.
300
+ - **The browser's caches.** When the response arrives the browser marks its
301
+ history cache stale and drops its prefetches, unless the action called
302
+ `keepClientCache()` (`/server-actions` → "Client Cache After an Action").
303
+ - **A loader that fails on its rerun** reaches your `onError` on the server and
304
+ the page as an error, below.
305
+
306
+ A form's React state survives an action's revalidation in a group, as on a
307
+ `urls()` route: the commit renders into the mounted page instead of remounting
308
+ it, so a controlled input keeps what the user typed.
309
+
310
+ ### A loader that fails on its rerun
311
+
312
+ The action has already run when its loaders rerun, so a loader that throws
313
+ there does not undo it. Where the error lands decides what the user sees:
314
+
315
+ | The route | Called directly (`await action()`) | From `<form action>`, `useActionState` or `useTransition` |
316
+ | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
317
+ | A group route, or a `urls()` route without `loading()` | The call rejects with the loader's error (`"An error occurred"` in a build), although the action returned. Nothing of that revalidation commits: the page keeps its data and its React state. | React hands the rejection to the nearest React error boundary above the form. With none of yours, the router's root error boundary replaces the page. |
318
+ | A `urls()` route with `loading()` | The call resolves with the action's result. The commit renders, the loader's read throws, and the nearest React error boundary catches it; with none of yours, the root one replaces the page. | The same. |
319
+ | Under an `errorBoundary()` (for a group, around the mount) | Its fallback replaces the route: the page and its form state unmount. | The same. |
320
+
321
+ A group route's `loading()` does not change the first row. For a failure the
322
+ user can retry (a flaky upstream, a rate limit), return it as data instead of
323
+ throwing, and render it where the data goes:
324
+
325
+ ```tsx
326
+ // stock.loader.ts
327
+ export const StockLoader = createLoader(async (ctx) => {
328
+ try {
329
+ return { ok: true as const, stock: await inventory.count(ctx.params.sku) };
330
+ } catch {
331
+ return { ok: false as const, reason: "Stock is unavailable. Try again." };
332
+ }
333
+ });
334
+
335
+ // in the group's "use client" module
336
+ function Stock() {
337
+ const { data } = useLoader(StockLoader);
338
+ return data.ok ? (
339
+ <p>{data.stock} in stock</p>
340
+ ) : (
341
+ <p role="status">{data.reason}</p>
342
+ );
343
+ }
344
+ ```
345
+
346
+ The action resolves with its result, the page and its form state stay, and the
347
+ next action or navigation that reruns the loader replaces the message. Keep
348
+ throwing for a failure that should stop the page, with an `errorBoundary()`
349
+ around the mount for it.
350
+
351
+ A failure returned as data is an ordinary value, so whatever stores the
352
+ loader's value stores it too: a `cache()` on the loader serves it for its TTL,
353
+ a `ppr` shell bakes an `ssr: false` loader's value, and a prerender stores it
354
+ at build time. A thrown error is never stored (a stale entry keeps serving).
355
+ To cache the expensive part, cache the call the loader makes (`"use cache"` on
356
+ that function: a call that throws stores nothing) and turn the throw into
357
+ data in a loader that is not cached itself.
358
+
359
+ Pinned by dev + production e2e in the router repository (a group, its `urls()`
360
+ twin, each with and without `loading()`, and the group under an
361
+ `errorBoundary()`), and by a `serveShellRequest({ action })` test of the
362
+ response.
363
+
290
364
  ## Loaders are full citizens: signals and handles
291
365
 
292
366
  Everything a DSL loader can do in the server tree works here — the loader body
@@ -373,6 +447,16 @@ need it, not the route.
373
447
 
374
448
  Pinned end to end by dev + production e2e in the router repository.
375
449
 
450
+ **Without JavaScript.** A boundary that is not complete at the first flush
451
+ ships its fallback and swaps the content in with a script, which a browser
452
+ without JavaScript never runs. That includes a client component a loader's
453
+ answer draws under an inline `<Suspense>`: a fast loader's answer usually
454
+ makes the first flush, and `ssr: false` guarantees the loader's data does. It
455
+ does not cover the client component's module: the first document a build's
456
+ fresh server renders can still be waiting for it. The dev server matches a
457
+ build here: when the shell is ready, it waits for the client components the
458
+ payload has named and that are still loading, and for nothing else.
459
+
376
460
  ## `prefetch: false` — keep a loader out of prefetches
377
461
 
378
462
  A `<Link>` prefetch of a group route runs its loaders. Flag the expensive one
@@ -442,7 +526,8 @@ a hard load of the target URL renders the full route.
442
526
  reconciles instead of remounting. The canonical commit runs in a
443
527
  `startTransition` when no new segments mount, and the wrapping layout sees
444
528
  `useOutlet().pending === true` for the held window; a route-level
445
- `loading()` does not re-flash there. `transition()` is not needed for the
529
+ `loading()` does not re-flash there. Across different routes, a `layout()`
530
+ keeps its instance while the navigation stays under it. `transition()` is not needed for the
446
531
  hold; it adds the view-transition animation. Inline `<Suspense>` above each
447
532
  `useLoader` read is still the finer-grained tool when different reads on
448
533
  one route should wait independently.
@@ -112,12 +112,17 @@ cannot mismatch its server HTML.
112
112
  - Navigation state, params and action state: the component shows the live
113
113
  value right after it hydrates.
114
114
  - Handle data a loader pushed late, and the entry's location state: both wait
115
- until the whole document has streamed and been revealed. Location state
116
- lands in the barrier commit (one transition that also hydrates every
117
- boundary still dehydrated); handle data lands in the release notification
118
- right after that commit. `useLocationState` readers re-render when the
119
- entry's state object changes; `useHandle` readers when their selection
120
- changes.
115
+ until the whole document has streamed and been revealed, and every boundary
116
+ has hydrated, including any boundary still suspended on the client (a lazy
117
+ chunk, a deferred-hydration gate; that wait has no upper bound). Then
118
+ location state lands in a transition and handle data in the release
119
+ notification. `useLocationState` readers re-render when the entry's
120
+ state object changes; `useHandle` readers when their selection changes.
121
+ - State set before then by a commit that renders no new page (a shallow
122
+ navigation with `revalidate: false`, Back/Forward or a revalidation that
123
+ keeps every segment, an action's location state) waits the same way: readers
124
+ keep `undefined` until every boundary has hydrated, then show the latest
125
+ entry's state.
121
126
  - A navigation that commits before then mounts its page with the live values
122
127
  from its first render. A boundary of the document that persists across that
123
128
  navigation and has not hydrated yet then hydrates against the live values;
@@ -146,7 +146,9 @@ clears the shared error.
146
146
  **`load()` calls that stay local** (no broadcast, per-hook state, same
147
147
  semantics as the old per-component `useState`):
148
148
 
149
- - `load({ params: { ... } })` — explicit params.
149
+ - `load({ params: { ... } })` — explicit params. They are string values; a
150
+ `__proto__`, `constructor` or `prototype` key or a non-string value is
151
+ rejected and `load()` fails with `Invalid loader params`.
150
152
  - `load({ method: "POST", body })` — mutations.
151
153
  - Any `load()` on a `useFetchLoader(loader)` whose loader is **not**
152
154
  registered on the current route. Two unrelated components calling