@rangojs/router 0.0.0-experimental.139 → 0.0.0-experimental.140

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 (45) hide show
  1. package/dist/bin/rango.js +27 -2
  2. package/dist/vite/index.js +147 -30
  3. package/package.json +1 -1
  4. package/skills/breadcrumbs/SKILL.md +1 -1
  5. package/skills/cache-guide/SKILL.md +1 -0
  6. package/skills/caching/SKILL.md +1 -1
  7. package/skills/migrate-nextjs/SKILL.md +15 -0
  8. package/skills/migrate-react-router/SKILL.md +15 -2
  9. package/skills/ppr/SKILL.md +426 -0
  10. package/skills/rango/SKILL.md +28 -25
  11. package/skills/route/SKILL.md +43 -0
  12. package/src/build/route-trie.ts +35 -7
  13. package/src/cache/cf/cf-cache-store.ts +155 -0
  14. package/src/cache/index.ts +6 -0
  15. package/src/cache/memory-segment-store.ts +57 -1
  16. package/src/cache/shell-cache.ts +386 -0
  17. package/src/cache/types.ts +58 -0
  18. package/src/cache/vercel/vercel-cache-store.ts +159 -5
  19. package/src/index.rsc.ts +5 -0
  20. package/src/index.ts +17 -0
  21. package/src/router/middleware.ts +14 -5
  22. package/src/router/parse-pattern.ts +115 -0
  23. package/src/router/pattern-matching.ts +53 -64
  24. package/src/router/segment-resolution/fresh.ts +12 -1
  25. package/src/router/segment-resolution/loader-cache.ts +14 -0
  26. package/src/router/segment-resolution/loader-mask.ts +44 -0
  27. package/src/router/substitute-pattern-params.ts +54 -35
  28. package/src/router/trie-matching.ts +19 -11
  29. package/src/router/url-params.ts +13 -0
  30. package/src/rsc/full-payload.ts +70 -0
  31. package/src/rsc/rsc-rendering.ts +105 -51
  32. package/src/rsc/shell-capture.ts +439 -0
  33. package/src/rsc/types.ts +26 -0
  34. package/src/server/cookie-store.ts +45 -0
  35. package/src/server/live.ts +130 -0
  36. package/src/server/request-context.ts +49 -0
  37. package/src/ssr/index.tsx +377 -180
  38. package/src/ssr/ssr-root.tsx +228 -0
  39. package/src/testing/render-route.tsx +7 -9
  40. package/src/types/route-config.ts +19 -7
  41. package/src/urls/type-extraction.ts +43 -18
  42. package/src/vite/discovery/discovery-errors.ts +61 -0
  43. package/src/vite/plugins/virtual-entries.ts +27 -2
  44. package/src/vite/router-discovery.ts +69 -15
  45. package/src/vite/utils/prerender-utils.ts +17 -4
package/dist/bin/rango.js CHANGED
@@ -1437,9 +1437,14 @@ initializeApp().catch(console.error);
1437
1437
  `.trim();
1438
1438
  VIRTUAL_ENTRY_SSR = `
1439
1439
  import { createFromReadableStream } from "@rangojs/router/internal/deps/ssr";
1440
- import { renderToReadableStream } from "react-dom/server.edge";
1440
+ import { renderToReadableStream, resume } from "react-dom/server.edge";
1441
+ import { prerender } from "react-dom/static.edge";
1441
1442
  import { injectRSCPayload } from "@rangojs/router/internal/deps/html-stream-server";
1442
- import { createSSRHandler } from "@rangojs/router/ssr";
1443
+ import {
1444
+ createSSRHandler,
1445
+ createShellCaptureHandler,
1446
+ createShellResumeHandler,
1447
+ } from "@rangojs/router/ssr";
1443
1448
 
1444
1449
  export const renderHTML = createSSRHandler({
1445
1450
  createFromReadableStream,
@@ -1448,6 +1453,26 @@ export const renderHTML = createSSRHandler({
1448
1453
  loadBootstrapScriptContent: () =>
1449
1454
  import.meta.viteRsc.loadBootstrapScriptContent("index"),
1450
1455
  });
1456
+
1457
+ export const captureShellHTML = createShellCaptureHandler({
1458
+ createFromReadableStream,
1459
+ renderToReadableStream,
1460
+ injectRSCPayload,
1461
+ prerender,
1462
+ resume,
1463
+ loadBootstrapScriptContent: () =>
1464
+ import.meta.viteRsc.loadBootstrapScriptContent("index"),
1465
+ });
1466
+
1467
+ export const resumeShellHTML = createShellResumeHandler({
1468
+ createFromReadableStream,
1469
+ renderToReadableStream,
1470
+ injectRSCPayload,
1471
+ prerender,
1472
+ resume,
1473
+ loadBootstrapScriptContent: () =>
1474
+ import.meta.viteRsc.loadBootstrapScriptContent("index"),
1475
+ });
1451
1476
  `.trim();
1452
1477
  VIRTUAL_IDS = {
1453
1478
  browser: "virtual:rsc-router/entry.browser.js",
@@ -2212,9 +2212,14 @@ initializeApp().catch(console.error);
2212
2212
  `.trim();
2213
2213
  var VIRTUAL_ENTRY_SSR = `
2214
2214
  import { createFromReadableStream } from "@rangojs/router/internal/deps/ssr";
2215
- import { renderToReadableStream } from "react-dom/server.edge";
2215
+ import { renderToReadableStream, resume } from "react-dom/server.edge";
2216
+ import { prerender } from "react-dom/static.edge";
2216
2217
  import { injectRSCPayload } from "@rangojs/router/internal/deps/html-stream-server";
2217
- import { createSSRHandler } from "@rangojs/router/ssr";
2218
+ import {
2219
+ createSSRHandler,
2220
+ createShellCaptureHandler,
2221
+ createShellResumeHandler,
2222
+ } from "@rangojs/router/ssr";
2218
2223
 
2219
2224
  export const renderHTML = createSSRHandler({
2220
2225
  createFromReadableStream,
@@ -2223,6 +2228,26 @@ export const renderHTML = createSSRHandler({
2223
2228
  loadBootstrapScriptContent: () =>
2224
2229
  import.meta.viteRsc.loadBootstrapScriptContent("index"),
2225
2230
  });
2231
+
2232
+ export const captureShellHTML = createShellCaptureHandler({
2233
+ createFromReadableStream,
2234
+ renderToReadableStream,
2235
+ injectRSCPayload,
2236
+ prerender,
2237
+ resume,
2238
+ loadBootstrapScriptContent: () =>
2239
+ import.meta.viteRsc.loadBootstrapScriptContent("index"),
2240
+ });
2241
+
2242
+ export const resumeShellHTML = createShellResumeHandler({
2243
+ createFromReadableStream,
2244
+ renderToReadableStream,
2245
+ injectRSCPayload,
2246
+ prerender,
2247
+ resume,
2248
+ loadBootstrapScriptContent: () =>
2249
+ import.meta.viteRsc.loadBootstrapScriptContent("index"),
2250
+ });
2226
2251
  `.trim();
2227
2252
  var RSC_ENTRY_BOOTSTRAP_IMPORTS = [
2228
2253
  "virtual:rsc-router/routes-manifest",
@@ -4874,10 +4899,31 @@ var INTERNAL_RANGO_DEBUG = typeof process !== "undefined" && Boolean(process.env
4874
4899
  // src/router/logging.ts
4875
4900
  var routerLogContext = new AsyncLocalStorage();
4876
4901
 
4877
- // src/router/pattern-matching.ts
4902
+ // src/router/url-params.ts
4903
+ var PATH_SAFE_ESCAPES = {
4904
+ "%3A": ":",
4905
+ "%40": "@",
4906
+ "%24": "$",
4907
+ "%26": "&",
4908
+ "%2B": "+",
4909
+ "%2C": ",",
4910
+ "%3B": ";",
4911
+ "%3D": "="
4912
+ };
4913
+ function encodePathSegment(value) {
4914
+ return encodeURIComponent(value).replace(
4915
+ /%(?:3A|40|24|26|2B|2C|3B|3D)/gi,
4916
+ (match) => PATH_SAFE_ESCAPES[match.toUpperCase()] ?? match
4917
+ );
4918
+ }
4919
+ function encodePathRemainder(value, encode = encodePathSegment) {
4920
+ return value.split("/").map(encode).join("/");
4921
+ }
4922
+
4923
+ // src/router/parse-pattern.ts
4878
4924
  function parsePattern(pattern) {
4879
4925
  const segments = [];
4880
- const segmentRegex = /\/(:([a-zA-Z_][a-zA-Z0-9_]*)(\(([^)]+)\))?(\?)?([^/]*)|(\*)|([^/]+))/g;
4926
+ const segmentRegex = /\/(:([a-zA-Z_][a-zA-Z0-9_]*)(\(([^)]+)\))?(\?)?([+*])?([^/]*)|(\*)|([^/]+))/g;
4881
4927
  let match;
4882
4928
  while ((match = segmentRegex.exec(pattern)) !== null) {
4883
4929
  const [
@@ -4887,6 +4933,7 @@ function parsePattern(pattern) {
4887
4933
  ,
4888
4934
  constraint,
4889
4935
  optional,
4936
+ repeat,
4890
4937
  suffix,
4891
4938
  wildcard,
4892
4939
  staticText
@@ -4894,17 +4941,38 @@ function parsePattern(pattern) {
4894
4941
  if (wildcard) {
4895
4942
  segments.push({ type: "wildcard", value: "*", optional: false });
4896
4943
  } else if (paramName) {
4897
- segments.push({
4898
- type: "param",
4899
- value: paramName,
4900
- optional: optional === "?",
4901
- constraint: constraint ? constraint.split("|") : void 0,
4902
- suffix: suffix || void 0
4903
- });
4944
+ if (repeat && !suffix && optional !== "?" && !constraint) {
4945
+ segments.push({
4946
+ type: "wildcard",
4947
+ value: paramName,
4948
+ optional: false,
4949
+ oneOrMore: repeat === "+"
4950
+ });
4951
+ } else {
4952
+ segments.push({
4953
+ type: "param",
4954
+ value: paramName,
4955
+ optional: optional === "?",
4956
+ constraint: constraint ? constraint.split("|") : void 0,
4957
+ // Fold a non-modifier `+`/`*` back into the literal suffix.
4958
+ suffix: (repeat ?? "") + (suffix ?? "") || void 0
4959
+ });
4960
+ }
4904
4961
  } else if (staticText) {
4905
4962
  segments.push({ type: "static", value: staticText, optional: false });
4906
4963
  }
4907
4964
  }
4965
+ for (let i = 0; i < segments.length - 1; i++) {
4966
+ const s = segments[i];
4967
+ if (s.type === "wildcard" && s.value !== "*") {
4968
+ segments[i] = {
4969
+ type: "param",
4970
+ value: s.value,
4971
+ optional: false,
4972
+ suffix: s.oneOrMore ? "+" : "*"
4973
+ };
4974
+ }
4975
+ }
4908
4976
  return segments;
4909
4977
  }
4910
4978
 
@@ -5107,11 +5175,18 @@ function insertSegments(node, segments, index, leafBase, paramNames) {
5107
5175
  } else if (segment.type === "wildcard") {
5108
5176
  const wildLeaf = {
5109
5177
  ...buildLeaf(leafBase, paramNames),
5110
- pn: "*"
5178
+ pn: segment.value,
5179
+ ...segment.oneOrMore ? { w1: true } : {}
5111
5180
  };
5112
- const existing = node.w ? { ...node.w } : void 0;
5113
- const merged = mergeLeaves(existing, wildLeaf);
5114
- node.w = merged;
5181
+ const existing = node.w;
5182
+ const canMerge = existing === void 0 || Boolean(existing.rt) || Boolean(wildLeaf.rt) || existing.pn === wildLeaf.pn && Boolean(existing.w1) === Boolean(wildLeaf.w1);
5183
+ if (canMerge) {
5184
+ const merged = mergeLeaves(
5185
+ existing ? { ...existing } : void 0,
5186
+ wildLeaf
5187
+ );
5188
+ node.w = merged;
5189
+ }
5115
5190
  }
5116
5191
  }
5117
5192
 
@@ -5213,13 +5288,23 @@ import {
5213
5288
  } from "node:fs";
5214
5289
  import { resolve as resolve7 } from "node:path";
5215
5290
  function encodePathParam(value) {
5216
- return String(value).split("/").map((segment) => encodeURIComponent(segment)).join("/");
5291
+ return encodePathRemainder(String(value), encodeURIComponent);
5217
5292
  }
5218
5293
  function substituteRouteParams(pattern, params, encode = encodeURIComponent) {
5219
5294
  let result = pattern;
5220
5295
  let hadOmittedOptional = false;
5221
5296
  for (const [key, value] of Object.entries(params)) {
5222
5297
  const escaped = escapeRegExp(key);
5298
+ const catchAllRe = new RegExp(`:${escaped}[+*]`);
5299
+ if (catchAllRe.test(result)) {
5300
+ if (value === "") {
5301
+ result = result.replace(catchAllRe, "");
5302
+ hadOmittedOptional = true;
5303
+ } else {
5304
+ result = result.replace(catchAllRe, encodePathRemainder(value, encode));
5305
+ }
5306
+ continue;
5307
+ }
5223
5308
  if (value === "") {
5224
5309
  result = result.replace(
5225
5310
  new RegExp(`:${escaped}(\\([^)]*\\))?(?!\\?)`),
@@ -5799,6 +5884,28 @@ var DiscoveryError = class _DiscoveryError extends Error {
5799
5884
  Object.setPrototypeOf(this, _DiscoveryError.prototype);
5800
5885
  }
5801
5886
  };
5887
+ function describeDiscoveryFailure(err, opts = {}) {
5888
+ if (err instanceof DiscoveryError && err.caught.length === 0) {
5889
+ const entry = err.entryPath ?? "the router entry";
5890
+ if (opts.reoptimizeObserved) {
5891
+ return {
5892
+ level: "warn",
5893
+ message: `[rango] No routers found while Vite was re-optimizing dependencies on dev boot. This is transient: routes are served per-request and discovery re-runs automatically, so it clears on the next boot. If routes still 404, confirm ${entry} calls createRouter().`
5894
+ };
5895
+ }
5896
+ return {
5897
+ level: "error",
5898
+ message: `${err.message}
5899
+ Ensure ${entry} calls createRouter() at module top level and that the configured router entry path is correct.`
5900
+ };
5901
+ }
5902
+ const e = err;
5903
+ const detail = e?.stack ?? e?.message ?? String(err);
5904
+ return {
5905
+ level: "error",
5906
+ message: `[rango] Router discovery failed: ${detail}`
5907
+ };
5908
+ }
5802
5909
 
5803
5910
  // src/vite/discovery/discover-routers.ts
5804
5911
  var debug10 = createRangoDebugger(NS.discovery);
@@ -6861,6 +6968,15 @@ function createRouterDiscoveryPlugin(entryPath, opts) {
6861
6968
  await importEntryAndRegistry(tempRscEnv);
6862
6969
  return tempRscEnv;
6863
6970
  }
6971
+ const emitDiscoveryFailure = (err, hashBefore, hashAfter) => {
6972
+ const reoptimizeObserved = hashBefore !== void 0 && hashAfter !== void 0 && hashBefore !== hashAfter;
6973
+ const report = describeDiscoveryFailure(err, { reoptimizeObserved });
6974
+ if (report.level === "warn") {
6975
+ console.warn(report.message);
6976
+ } else {
6977
+ console.error(report.message);
6978
+ }
6979
+ };
6864
6980
  const discover = async () => {
6865
6981
  const discoverStart = performance.now();
6866
6982
  const rscEnv = server.environments?.rsc;
@@ -6870,18 +6986,21 @@ function createRouterDiscoveryPlugin(entryPath, opts) {
6870
6986
  globalThis.__rscRouterDiscoveryActive ?? false
6871
6987
  );
6872
6988
  s.devServerOrigin = getDevServerOrigin();
6989
+ let tempRscEnv;
6990
+ let optimizerHashBefore2;
6873
6991
  try {
6874
6992
  await timed(
6875
6993
  debugDiscovery,
6876
6994
  "acquireBuildEnv",
6877
6995
  () => acquireBuildEnv(s, viteCommand, viteMode)
6878
6996
  );
6879
- const tempRscEnv = await timed(
6997
+ tempRscEnv = await timed(
6880
6998
  debugDiscovery,
6881
6999
  "getOrCreateTempServer",
6882
7000
  () => getOrCreateTempServer()
6883
7001
  );
6884
7002
  if (tempRscEnv) {
7003
+ optimizerHashBefore2 = tempRscEnv.depsOptimizer?.metadata?.browserHash;
6885
7004
  await timed(
6886
7005
  debugDiscovery,
6887
7006
  "discoverRouters (cloudflare)",
@@ -6894,9 +7013,10 @@ function createRouterDiscoveryPlugin(entryPath, opts) {
6894
7013
  );
6895
7014
  }
6896
7015
  } catch (err) {
6897
- console.warn(
6898
- `[rango] Cloudflare dev discovery failed: ${err.message}
6899
- ${err.stack}`
7016
+ emitDiscoveryFailure(
7017
+ err,
7018
+ optimizerHashBefore2,
7019
+ tempRscEnv?.depsOptimizer?.metadata?.browserHash
6900
7020
  );
6901
7021
  }
6902
7022
  debugDiscovery?.(
@@ -6906,6 +7026,7 @@ ${err.stack}`
6906
7026
  resolveDiscovery();
6907
7027
  return;
6908
7028
  }
7029
+ const optimizerHashBefore = rscEnv.depsOptimizer?.metadata?.browserHash;
6909
7030
  try {
6910
7031
  debugDiscovery?.("dev: node path start");
6911
7032
  await timed(
@@ -6915,17 +7036,12 @@ ${err.stack}`
6915
7036
  );
6916
7037
  const serverMod = await timed(
6917
7038
  debugDiscovery,
6918
- "import @rangojs/router/server",
6919
- () => rscEnv.runner.import("@rangojs/router/server")
7039
+ "discoverRouters",
7040
+ () => discoverRouters(s, rscEnv)
6920
7041
  );
6921
7042
  if (serverMod?.setManifestReadyPromise) {
6922
7043
  serverMod.setManifestReadyPromise(discoveryPromise);
6923
7044
  }
6924
- await timed(
6925
- debugDiscovery,
6926
- "discoverRouters",
6927
- () => discoverRouters(s, rscEnv)
6928
- );
6929
7045
  s.devServerOrigin = getDevServerOrigin();
6930
7046
  timedSync(
6931
7047
  debugDiscovery,
@@ -6938,9 +7054,10 @@ ${err.stack}`
6938
7054
  () => propagateDiscoveryState(rscEnv)
6939
7055
  );
6940
7056
  } catch (err) {
6941
- console.warn(
6942
- `[rango] Router discovery failed: ${err.message}
6943
- ${err.stack}`
7057
+ emitDiscoveryFailure(
7058
+ err,
7059
+ optimizerHashBefore,
7060
+ rscEnv.depsOptimizer?.metadata?.browserHash
6944
7061
  );
6945
7062
  } finally {
6946
7063
  debugDiscovery?.(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.139",
3
+ "version": "0.0.0-experimental.140",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: breadcrumbs
3
3
  description: Built-in Breadcrumbs handle for accumulating breadcrumb navigation across route segments
4
- argument-hint: [setup]
4
+ argument-hint: "[setup]"
5
5
  ---
6
6
 
7
7
  # Breadcrumbs
@@ -484,3 +484,4 @@ overrides — see `/loader` for the full reference.
484
484
  - `/caching` — cache() DSL setup, stores, nested boundaries
485
485
  - `/use-cache` — "use cache" directive details, profiles, transforms, guards
486
486
  - `/document-cache` — Edge caching with Cache-Control headers (different layer)
487
+ - `/ppr` — PPR shell caching: cached HTML shell + live loader holes (different layer)
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: caching
3
3
  description: Configure segment caching with memory or Cloudflare KV stores in @rangojs/router
4
- argument-hint: [setup]
4
+ argument-hint: "[setup]"
5
5
  ---
6
6
 
7
7
  # Caching
@@ -88,6 +88,21 @@ The Document component replaces `app/layout.tsx`'s `<html>` wrapper. See `/route
88
88
  | `app/shop/[...path]/page.tsx` | `path("/shop/:path+", CatchAll, { name: "shopCatchAll" })` |
89
89
  | `app/docs/[[...slug]]/page.tsx` | `path("/docs/:slug*", Docs, { name: "docs" })` |
90
90
 
91
+ The catch-all remainder is a single string at `ctx.params.<name>` with the `/`
92
+ separators preserved — split it to recover the array Next gives you:
93
+
94
+ ```typescript
95
+ // app/docs/[[...slug]]/page.tsx -> params.slug is string[] | undefined in Next
96
+ path("/docs/:slug*", (ctx) => {
97
+ // "" for /docs, "a/b/c" for /docs/a/b/c
98
+ const slug = ctx.params.slug === "" ? [] : ctx.params.slug.split("/");
99
+ return <Docs slug={slug} />;
100
+ }, { name: "docs" });
101
+ ```
102
+
103
+ `[...path]` (required, ≥1 segment) maps to `:path+`; `[[...slug]]` (optional,
104
+ matches the bare parent too) maps to `:slug*` — which binds `""` at `/docs`.
105
+
91
106
  ### Layouts
92
107
 
93
108
  ```typescript
@@ -199,7 +199,7 @@ Key shift: the route module's scattered exports consolidate into the handler
199
199
  | `app/routes/_index.tsx` | `path("/", HomePage, { name: "home" })` |
200
200
  | `app/routes/about.tsx` | `path("/about", AboutPage, { name: "about" })` |
201
201
  | `app/routes/blog.$slug.tsx` | `path("/blog/:slug", BlogPost, { name: "blogPost" })` |
202
- | `app/routes/files.$.tsx` (splat) | `path("/files/:path+", FileBrowser, { name: "files" })` |
202
+ | `app/routes/files.$.tsx` (splat) | `path("/files/:path*", FileBrowser, { name: "files" })` |
203
203
  | `app/routes/dashboard.tsx` (layout) | `layout(<DashboardLayout />, () => [...])` |
204
204
  | `app/routes/dashboard._index.tsx` | `path("/dashboard", DashboardIndex, { name: "dashboard" })` |
205
205
  | `app/routes/dashboard.settings.tsx` | `path("/dashboard/settings", Settings, { name: "settings" })` |
@@ -213,9 +213,22 @@ Key shift: the route module's scattered exports consolidate into the handler
213
213
  | `path: "/"` | `path("/", HomePage, { name: "home" })` |
214
214
  | `path: "about"` | `path("/about", AboutPage, { name: "about" })` |
215
215
  | `path: "blog/:slug"` | `path("/blog/:slug", BlogPost, { name: "blogPost" })` |
216
- | `path: "files/*"` (splat) | `path("/files/:path+", FileBrowser, { name: "files" })` |
216
+ | `path: "files/*"` (splat) | `path("/files/:path*", FileBrowser, { name: "files" })` |
217
217
  | `path: "docs/:lang?"` (optional param) | `path("/docs/:lang?", Docs, { name: "docs" })` |
218
218
 
219
+ The RR splat (`$` / `*`) matches the bare parent too (`/files` binds `""`), so
220
+ it maps to `:path*` (zero-or-more). Use `:path+` only when you require at least
221
+ one trailing segment. RR reads the splat at `params["*"]`; Rango exposes it as a
222
+ named string at `ctx.params.path` with the `/` separators preserved (split to
223
+ recover RR's array):
224
+
225
+ ```typescript
226
+ path("/files/:path*", (ctx) => {
227
+ const parts = ctx.params.path === "" ? [] : ctx.params.path.split("/");
228
+ return <FileBrowser path={parts} />;
229
+ }, { name: "files" });
230
+ ```
231
+
219
232
  ### Layouts
220
233
 
221
234
  React Router layouts use `<Outlet />` — same concept in Rango: