@rangojs/router 0.0.0-experimental.121 → 0.0.0-experimental.124

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 (120) hide show
  1. package/dist/bin/rango.js +7 -2
  2. package/dist/vite/index.js +47 -6
  3. package/package.json +61 -21
  4. package/skills/cache-guide/SKILL.md +8 -6
  5. package/skills/caching/SKILL.md +148 -1
  6. package/skills/hooks/SKILL.md +38 -27
  7. package/skills/host-router/SKILL.md +16 -2
  8. package/skills/intercept/SKILL.md +4 -2
  9. package/skills/layout/SKILL.md +11 -6
  10. package/skills/loader/SKILL.md +6 -2
  11. package/skills/middleware/SKILL.md +4 -2
  12. package/skills/migrate-nextjs/SKILL.md +38 -16
  13. package/skills/parallel/SKILL.md +9 -4
  14. package/skills/rango/SKILL.md +27 -15
  15. package/skills/route/SKILL.md +4 -2
  16. package/skills/testing/SKILL.md +129 -0
  17. package/skills/testing/bindings.md +89 -0
  18. package/skills/testing/cache-prerender.md +98 -0
  19. package/skills/testing/client-components.md +122 -0
  20. package/skills/testing/e2e-parity.md +125 -0
  21. package/skills/testing/flight.md +89 -0
  22. package/skills/testing/handles.md +129 -0
  23. package/skills/testing/loader.md +128 -0
  24. package/skills/testing/middleware.md +99 -0
  25. package/skills/testing/render-handler.md +118 -0
  26. package/skills/testing/response-routes.md +95 -0
  27. package/skills/testing/reverse-and-types.md +84 -0
  28. package/skills/testing/server-actions.md +107 -0
  29. package/skills/testing/server-tree.md +128 -0
  30. package/skills/testing/setup.md +120 -0
  31. package/skills/use-cache/SKILL.md +9 -7
  32. package/src/browser/action-fence.ts +37 -0
  33. package/src/browser/cookie-name.ts +140 -0
  34. package/src/browser/invalidate-client-cache.ts +52 -0
  35. package/src/browser/navigation-bridge.ts +14 -1
  36. package/src/browser/navigation-client.ts +14 -1
  37. package/src/browser/navigation-store-handle.ts +39 -0
  38. package/src/browser/navigation-store.ts +26 -12
  39. package/src/browser/prefetch/fetch.ts +7 -0
  40. package/src/browser/rango-state.ts +176 -97
  41. package/src/browser/react/index.ts +0 -6
  42. package/src/browser/rsc-router.tsx +12 -4
  43. package/src/browser/server-action-bridge.ts +77 -15
  44. package/src/browser/types.ts +7 -1
  45. package/src/cache/cache-error.ts +104 -0
  46. package/src/cache/cache-policy.ts +95 -1
  47. package/src/cache/cache-runtime.ts +79 -13
  48. package/src/cache/cache-scope.ts +55 -4
  49. package/src/cache/cache-tag.ts +135 -0
  50. package/src/cache/cf/cf-cache-store.ts +2080 -224
  51. package/src/cache/cf/index.ts +15 -1
  52. package/src/cache/document-cache.ts +74 -7
  53. package/src/cache/index.ts +17 -0
  54. package/src/cache/memory-segment-store.ts +164 -14
  55. package/src/cache/tag-invalidation.ts +230 -0
  56. package/src/cache/types.ts +27 -0
  57. package/src/client.rsc.tsx +1 -1
  58. package/src/client.tsx +0 -6
  59. package/src/component-utils.ts +19 -0
  60. package/src/handle.ts +29 -9
  61. package/src/host/testing.ts +43 -14
  62. package/src/index.rsc.ts +29 -1
  63. package/src/index.ts +43 -1
  64. package/src/loader.rsc.ts +24 -3
  65. package/src/loader.ts +16 -2
  66. package/src/prerender.ts +24 -3
  67. package/src/router/basename.ts +14 -0
  68. package/src/router/match-handlers.ts +62 -20
  69. package/src/router/prerender-match.ts +6 -0
  70. package/src/router/router-interfaces.ts +7 -0
  71. package/src/router/router-options.ts +30 -0
  72. package/src/router/segment-resolution/loader-cache.ts +8 -17
  73. package/src/router/state-cookie-name.ts +33 -0
  74. package/src/router/telemetry.ts +99 -0
  75. package/src/router.ts +36 -7
  76. package/src/rsc/handler.ts +13 -1
  77. package/src/rsc/helpers.ts +19 -0
  78. package/src/rsc/progressive-enhancement.ts +2 -0
  79. package/src/rsc/response-route-handler.ts +8 -1
  80. package/src/rsc/rsc-rendering.ts +2 -0
  81. package/src/rsc/types.ts +2 -0
  82. package/src/runtime-env.ts +18 -0
  83. package/src/server/cookie-store.ts +52 -1
  84. package/src/server/request-context.ts +105 -2
  85. package/src/static-handler.ts +25 -3
  86. package/src/testing/cache-status.ts +166 -0
  87. package/src/testing/collect-handle.ts +63 -0
  88. package/src/testing/dispatch.ts +581 -0
  89. package/src/testing/dom.entry.ts +22 -0
  90. package/src/testing/e2e/fixture.ts +188 -0
  91. package/src/testing/e2e/index.ts +149 -0
  92. package/src/testing/e2e/matchers.ts +51 -0
  93. package/src/testing/e2e/page-helpers.ts +272 -0
  94. package/src/testing/e2e/parity.ts +387 -0
  95. package/src/testing/e2e/server.ts +195 -0
  96. package/src/testing/flight-matchers.ts +110 -0
  97. package/src/testing/flight-normalize.ts +38 -0
  98. package/src/testing/flight-runtime.d.ts +57 -0
  99. package/src/testing/flight-tree.ts +682 -0
  100. package/src/testing/flight.entry.ts +52 -0
  101. package/src/testing/flight.ts +234 -0
  102. package/src/testing/generated-routes.ts +223 -0
  103. package/src/testing/index.ts +119 -0
  104. package/src/testing/internal/context.ts +390 -0
  105. package/src/testing/internal/flight-client-globals.ts +30 -0
  106. package/src/testing/internal/seed-vars.ts +80 -0
  107. package/src/testing/render-handler.ts +360 -0
  108. package/src/testing/render-route.tsx +594 -0
  109. package/src/testing/run-loader.ts +474 -0
  110. package/src/testing/run-middleware.ts +231 -0
  111. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  112. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  113. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  114. package/src/testing/vitest-stubs/version.ts +5 -0
  115. package/src/testing/vitest.ts +305 -0
  116. package/src/types/cache-types.ts +13 -4
  117. package/src/types/error-types.ts +5 -1
  118. package/src/types/global-namespace.ts +11 -1
  119. package/src/types/handler-context.ts +16 -5
  120. package/src/browser/react/use-client-cache.ts +0 -58
package/dist/bin/rango.js CHANGED
@@ -1,8 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  var __defProp = Object.defineProperty;
3
3
  var __getOwnPropNames = Object.getOwnPropertyNames;
4
- var __esm = (fn, res) => function __init() {
5
- return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
4
+ var __esm = (fn, res, err) => function __init() {
5
+ if (err) throw err[0];
6
+ try {
7
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
8
+ } catch (e) {
9
+ throw err = [e], e;
10
+ }
6
11
  };
7
12
  var __export = (target, all) => {
8
13
  for (var name in all)
@@ -2130,7 +2130,7 @@ import { resolve } from "node:path";
2130
2130
  // package.json
2131
2131
  var package_default = {
2132
2132
  name: "@rangojs/router",
2133
- version: "0.0.0-experimental.121",
2133
+ version: "0.0.0-experimental.124",
2134
2134
  description: "Django-inspired RSC router with composable URL patterns",
2135
2135
  keywords: [
2136
2136
  "react",
@@ -2256,6 +2256,31 @@ var package_default = {
2256
2256
  "./host/testing": {
2257
2257
  types: "./src/host/testing.ts",
2258
2258
  default: "./src/host/testing.ts"
2259
+ },
2260
+ "./testing": {
2261
+ types: "./src/testing/index.ts",
2262
+ default: "./src/testing/index.ts"
2263
+ },
2264
+ "./testing/vitest": {
2265
+ types: "./src/testing/vitest.ts",
2266
+ default: "./dist/testing/vitest.js"
2267
+ },
2268
+ "./testing/dom": {
2269
+ types: "./src/testing/dom.entry.ts",
2270
+ default: "./src/testing/dom.entry.ts"
2271
+ },
2272
+ "./testing/e2e": {
2273
+ types: "./src/testing/e2e/index.ts",
2274
+ default: "./src/testing/e2e/index.ts"
2275
+ },
2276
+ "./testing/flight": {
2277
+ types: "./src/testing/flight.entry.ts",
2278
+ "react-server": "./src/testing/flight.entry.ts",
2279
+ default: "./src/testing/flight.entry.ts"
2280
+ },
2281
+ "./testing/flight-matchers": {
2282
+ types: "./src/testing/flight-matchers.ts",
2283
+ default: "./src/testing/flight-matchers.ts"
2259
2284
  }
2260
2285
  },
2261
2286
  publishConfig: {
@@ -2263,14 +2288,15 @@ var package_default = {
2263
2288
  tag: "experimental"
2264
2289
  },
2265
2290
  scripts: {
2266
- build: "pnpm dlx esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && mkdir -p dist/vite/plugins && cp src/vite/plugins/cloudflare-protocol-loader-hook.mjs dist/vite/plugins/cloudflare-protocol-loader-hook.mjs && pnpm dlx esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js",
2291
+ build: "pnpm dlx esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && mkdir -p dist/vite/plugins && cp src/vite/plugins/cloudflare-protocol-loader-hook.mjs dist/vite/plugins/cloudflare-protocol-loader-hook.mjs && pnpm dlx esbuild src/testing/vitest.ts --bundle --format=esm --outfile=dist/testing/vitest.js --platform=node --packages=external && pnpm dlx esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js",
2267
2292
  prepublishOnly: "pnpm build",
2268
2293
  typecheck: "tsc --noEmit && tsc -p tsconfig.strict-check.json --noEmit && tsc -p tsconfig.augment-check.json --noEmit",
2269
2294
  test: "playwright test",
2270
2295
  "test:ui": "playwright test --ui",
2271
2296
  "test:hmr-local": "playwright test --project=dev-warmup --project=hmr-routes --project=hmr-basename --project=hmr-prerender --no-deps --workers=1",
2272
2297
  "test:unit": "vitest run",
2273
- "test:unit:watch": "vitest"
2298
+ "test:unit:watch": "vitest",
2299
+ "test:unit:rsc": "vitest run --config vitest.rsc.config.ts"
2274
2300
  },
2275
2301
  dependencies: {
2276
2302
  "@types/debug": "^4.1.12",
@@ -2278,35 +2304,50 @@ var package_default = {
2278
2304
  debug: "^4.4.1",
2279
2305
  "magic-string": "^0.30.17",
2280
2306
  picomatch: "^4.0.3",
2281
- "rsc-html-stream": "^0.0.7"
2307
+ "rsc-html-stream": "^0.0.7",
2308
+ tinyexec: "^0.3.2"
2282
2309
  },
2283
2310
  devDependencies: {
2284
2311
  "@playwright/test": "^1.49.1",
2285
2312
  "@shared/e2e": "workspace:*",
2313
+ "@testing-library/dom": "^10.4.1",
2314
+ "@testing-library/react": "^16.3.2",
2286
2315
  "@types/node": "^24.10.1",
2287
2316
  "@types/react": "catalog:",
2288
2317
  "@types/react-dom": "catalog:",
2289
2318
  esbuild: "^0.27.0",
2319
+ "happy-dom": "^20.10.1",
2290
2320
  jiti: "^2.6.1",
2291
2321
  react: "catalog:",
2292
2322
  "react-dom": "catalog:",
2293
- tinyexec: "^0.3.2",
2294
2323
  typescript: "^5.3.0",
2295
2324
  vitest: "^4.0.0"
2296
2325
  },
2297
2326
  peerDependencies: {
2298
2327
  "@cloudflare/vite-plugin": "^1.38.0",
2328
+ "@playwright/test": "^1.49.1",
2329
+ "@testing-library/react": ">=16",
2299
2330
  "@vitejs/plugin-rsc": "^0.5.26",
2300
2331
  react: ">=19.2.6 <20",
2301
2332
  "react-dom": ">=19.2.6 <20",
2302
- vite: "^8.0.0"
2333
+ vite: "^8.0.0",
2334
+ vitest: ">=3"
2303
2335
  },
2304
2336
  peerDependenciesMeta: {
2305
2337
  "@cloudflare/vite-plugin": {
2306
2338
  optional: true
2307
2339
  },
2340
+ "@playwright/test": {
2341
+ optional: true
2342
+ },
2343
+ "@testing-library/react": {
2344
+ optional: true
2345
+ },
2308
2346
  vite: {
2309
2347
  optional: true
2348
+ },
2349
+ vitest: {
2350
+ optional: true
2310
2351
  }
2311
2352
  }
2312
2353
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.121",
3
+ "version": "0.0.0-experimental.124",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -126,57 +126,97 @@
126
126
  "./host/testing": {
127
127
  "types": "./src/host/testing.ts",
128
128
  "default": "./src/host/testing.ts"
129
+ },
130
+ "./testing": {
131
+ "types": "./src/testing/index.ts",
132
+ "default": "./src/testing/index.ts"
133
+ },
134
+ "./testing/vitest": {
135
+ "types": "./src/testing/vitest.ts",
136
+ "default": "./dist/testing/vitest.js"
137
+ },
138
+ "./testing/dom": {
139
+ "types": "./src/testing/dom.entry.ts",
140
+ "default": "./src/testing/dom.entry.ts"
141
+ },
142
+ "./testing/e2e": {
143
+ "types": "./src/testing/e2e/index.ts",
144
+ "default": "./src/testing/e2e/index.ts"
145
+ },
146
+ "./testing/flight": {
147
+ "types": "./src/testing/flight.entry.ts",
148
+ "react-server": "./src/testing/flight.entry.ts",
149
+ "default": "./src/testing/flight.entry.ts"
150
+ },
151
+ "./testing/flight-matchers": {
152
+ "types": "./src/testing/flight-matchers.ts",
153
+ "default": "./src/testing/flight-matchers.ts"
129
154
  }
130
155
  },
131
156
  "publishConfig": {
132
157
  "access": "public",
133
158
  "tag": "experimental"
134
159
  },
135
- "scripts": {
136
- "build": "pnpm dlx esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && mkdir -p dist/vite/plugins && cp src/vite/plugins/cloudflare-protocol-loader-hook.mjs dist/vite/plugins/cloudflare-protocol-loader-hook.mjs && pnpm dlx esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js",
137
- "prepublishOnly": "pnpm build",
138
- "typecheck": "tsc --noEmit && tsc -p tsconfig.strict-check.json --noEmit && tsc -p tsconfig.augment-check.json --noEmit",
139
- "test": "playwright test",
140
- "test:ui": "playwright test --ui",
141
- "test:hmr-local": "playwright test --project=dev-warmup --project=hmr-routes --project=hmr-basename --project=hmr-prerender --no-deps --workers=1",
142
- "test:unit": "vitest run",
143
- "test:unit:watch": "vitest"
144
- },
145
160
  "dependencies": {
146
161
  "@types/debug": "^4.1.12",
147
162
  "@vitejs/plugin-rsc": "^0.5.26",
148
163
  "debug": "^4.4.1",
149
164
  "magic-string": "^0.30.17",
150
165
  "picomatch": "^4.0.3",
151
- "rsc-html-stream": "^0.0.7"
166
+ "rsc-html-stream": "^0.0.7",
167
+ "tinyexec": "^0.3.2"
152
168
  },
153
169
  "devDependencies": {
154
170
  "@playwright/test": "^1.49.1",
155
- "@shared/e2e": "workspace:*",
171
+ "@testing-library/dom": "^10.4.1",
172
+ "@testing-library/react": "^16.3.2",
156
173
  "@types/node": "^24.10.1",
157
- "@types/react": "catalog:",
158
- "@types/react-dom": "catalog:",
174
+ "@types/react": "^19.2.7",
175
+ "@types/react-dom": "^19.2.3",
159
176
  "esbuild": "^0.27.0",
177
+ "happy-dom": "^20.10.1",
160
178
  "jiti": "^2.6.1",
161
- "react": "catalog:",
162
- "react-dom": "catalog:",
163
- "tinyexec": "^0.3.2",
179
+ "react": "^19.2.6",
180
+ "react-dom": "^19.2.6",
164
181
  "typescript": "^5.3.0",
165
- "vitest": "^4.0.0"
182
+ "vitest": "^4.0.0",
183
+ "@shared/e2e": "0.0.1"
166
184
  },
167
185
  "peerDependencies": {
168
186
  "@cloudflare/vite-plugin": "^1.38.0",
187
+ "@playwright/test": "^1.49.1",
188
+ "@testing-library/react": ">=16",
169
189
  "@vitejs/plugin-rsc": "^0.5.26",
170
190
  "react": ">=19.2.6 <20",
171
191
  "react-dom": ">=19.2.6 <20",
172
- "vite": "^8.0.0"
192
+ "vite": "^8.0.0",
193
+ "vitest": ">=3"
173
194
  },
174
195
  "peerDependenciesMeta": {
175
196
  "@cloudflare/vite-plugin": {
176
197
  "optional": true
177
198
  },
199
+ "@playwright/test": {
200
+ "optional": true
201
+ },
202
+ "@testing-library/react": {
203
+ "optional": true
204
+ },
178
205
  "vite": {
179
206
  "optional": true
207
+ },
208
+ "vitest": {
209
+ "optional": true
180
210
  }
211
+ },
212
+ "scripts": {
213
+ "build": "pnpm dlx esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && mkdir -p dist/vite/plugins && cp src/vite/plugins/cloudflare-protocol-loader-hook.mjs dist/vite/plugins/cloudflare-protocol-loader-hook.mjs && pnpm dlx esbuild src/testing/vitest.ts --bundle --format=esm --outfile=dist/testing/vitest.js --platform=node --packages=external && pnpm dlx esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js",
214
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.strict-check.json --noEmit && tsc -p tsconfig.augment-check.json --noEmit",
215
+ "test": "playwright test",
216
+ "test:ui": "playwright test --ui",
217
+ "test:hmr-local": "playwright test --project=dev-warmup --project=hmr-routes --project=hmr-basename --project=hmr-prerender --no-deps --workers=1",
218
+ "test:unit": "vitest run",
219
+ "test:unit:watch": "vitest",
220
+ "test:unit:rsc": "vitest run --config vitest.rsc.config.ts"
181
221
  }
182
- }
222
+ }
@@ -7,8 +7,9 @@ argument-hint:
7
7
  # cache() vs "use cache" — When to Use Which
8
8
 
9
9
  Both mechanisms share the same backing store and cache profiles, and both accept
10
- an optional `tags` field (not yet honored by the built-in stores — see "Two axes"
11
- below). They differ in scope, cache key, execution model, and runtime control.
10
+ an optional `tags` field (honored by the built-in stores — invalidate with
11
+ `updateTag`/`revalidateTag`; see "Two axes" below). They differ in scope, cache
12
+ key, execution model, and runtime control.
12
13
 
13
14
  ## Two axes — do not conflate
14
15
 
@@ -18,10 +19,11 @@ caching:
18
19
 
19
20
  1. **Stored-value freshness** — _is a cached value still good?_
20
21
  → `"use cache"` (fn/component), `cache()` (segment), loader `cache()` (loader data).
21
- Entries expire by **TTL/SWR**. They accept an optional `tags` field, but the
22
- built-in stores (`MemorySegmentCacheStore`, `CFCacheStore`) do not yet index or
23
- invalidate by tag, so tag-based invalidation (`revalidateTag`) is a
24
- forward-looking API requiring a custom store with secondary indices.
22
+ Entries expire by **TTL/SWR** and can be tagged (`cache({ tags })` or runtime
23
+ `cacheTag(...tags)`). Built-in stores (`MemorySegmentCacheStore`, `CFCacheStore`)
24
+ index by tag; invalidate on demand with `updateTag(...tags)` (awaitable,
25
+ read-your-own-writes) or `revalidateTag(...tags)` (background, non-blocking).
26
+ Both hard-purge; the difference is awaitability, not stale-serving.
25
27
  2. **Client-update selection** — _should this segment re-run and stream to the
26
28
  client on this navigation/action?_
27
29
  → `revalidate()`. Covered in `/loader` and `/route`, **not here**.
@@ -81,6 +81,78 @@ cache(
81
81
  );
82
82
  ```
83
83
 
84
+ ## Tag-Based Invalidation
85
+
86
+ Tag cached entries, then invalidate them on demand. Tags can be attached three ways:
87
+
88
+ ```typescript
89
+ // 1. Static tags in the cache() DSL
90
+ cache({ ttl: 300, tags: ["products"] }, () => [path("/products", List)]);
91
+
92
+ // 2. Dynamic tags (function of ctx)
93
+ cache(
94
+ { ttl: 300, tags: (ctx) => [`product:${ctx.params.id}`, "products"] },
95
+ () => [path("/products/:id", Detail)],
96
+ );
97
+
98
+ // 3. Runtime tags inside a "use cache" function
99
+ async function getProduct(id: string) {
100
+ "use cache";
101
+ cacheTag(`product:${id}`, "products"); // variadic, additive
102
+ return db.getProduct(id);
103
+ }
104
+ ```
105
+
106
+ Invalidate with one of two server-only verbs (both variadic, imported from
107
+ `@rangojs/router`):
108
+
109
+ ```typescript
110
+ // Server Action — read-your-own-writes. Await it so the action's own re-render
111
+ // (and the next navigation) sees fresh data.
112
+ async function updateProduct(formData: FormData) {
113
+ "use server";
114
+ await db.updateProduct(formData);
115
+ await updateTag("products");
116
+ }
117
+
118
+ // Route handler / webhook — background, non-blocking (waitUntil). Hard-purge:
119
+ // the next read re-renders fresh (NOT stale-while-revalidate).
120
+ export async function POST() {
121
+ "use server";
122
+ revalidateTag("products");
123
+ return new Response("ok");
124
+ }
125
+ ```
126
+
127
+ | API | Timing | Use in | Semantics |
128
+ | ------------------------ | --------------------------- | ------------------------- | ----------------------------------------------------- |
129
+ | `updateTag(...tags)` | awaitable (`Promise<void>`) | server actions | immediate; next read is fresh |
130
+ | `revalidateTag(...tags)` | background (`void`) | route handlers / webhooks | background (non-blocking); next read re-renders fresh |
131
+
132
+ Both built-in stores support tags. For `CFCacheStore`, distributed (cross-colo)
133
+ invalidation requires a `kv` namespace — the tag-invalidation markers live in
134
+ that same namespace; there is **no** separate tag-invalidation store to wire.
135
+ If no tag-capable store is configured, `updateTag`/`revalidateTag` warn and no-op.
136
+
137
+ By default `CFCacheStore` reads the KV marker on every tagged cache read
138
+ (strongest invalidation latency). To cut KV reads on hot tagged routes, set
139
+ `tagCacheTtl` (seconds) to cache each marker in the per-colo edge cache for that
140
+ window — the colo running `updateTag`/`revalidateTag` writes the fresh marker
141
+ into its own edge cache immediately (read-your-own-writes), while other colos
142
+ converge within `tagCacheTtl` (the **maximum extra cross-colo invalidation
143
+ latency** when no purge is wired). Keep it small (e.g. 30–60), or wire a purge
144
+ (below) and set it large. (Contrast `tagInvalidationTtl`, which must be _large_
145
+ — it bounds how long the KV marker itself lives and must exceed your max entry
146
+ TTL+SWR.)
147
+
148
+ To make other colos prompt without a short `tagCacheTtl`, pass `onRevalidateTag`:
149
+ each cached marker carries a namespaced Cloudflare `Cache-Tag`, and the hook is
150
+ handed exactly those tags (batched, once per `updateTag`/`revalidateTag` call) to
151
+ feed Cloudflare's purge-by-tag API — evicting the cached lookups everywhere.
152
+ Purge-by-tag is available on all plans (since April 2025), subject to per-plan
153
+ rate limits, so the batched single call matters. With a purge wired, `tagCacheTtl`
154
+ becomes a pure read-cost reducer + fallback window.
155
+
84
156
  ## Named Profile Shorthand
85
157
 
86
158
  Use a named cache profile string instead of an options object. The profile must be
@@ -212,6 +284,80 @@ const router = createRouter<AppBindings>({
212
284
  KV entries require `expirationTtl >= 60s`. Short-lived entries (< 60s total TTL)
213
285
  are only cached in L1.
214
286
 
287
+ ### Resilience & latency budgets
288
+
289
+ Every cache read is **fail-safe**: a degraded tier never stalls or fails the
290
+ request — it degrades to the next tier (L1 → L2 → render). Three optional latency
291
+ budgets (milliseconds) bound each tier so a slow colo or KV namespace cannot pin
292
+ a request behind it:
293
+
294
+ | Option | Default | Bounds |
295
+ | --------------------- | ------- | ----------------------------------- |
296
+ | `edgeLookupTimeoutMs` | `10` | L1 `cache.match` (the lookup) |
297
+ | `edgeReadTimeoutMs` | `20` | L1 body read (CF streams it lazily) |
298
+ | `kvReadTimeoutMs` | `170` | L2 / KV read |
299
+
300
+ Set any to `0` (or a negative value) to disable that budget and always await the
301
+ read. A non-finite value (e.g. `Number(env.UNSET)`) falls back to the default.
302
+ The tag-invalidation marker reads inherit these same budgets and **fail open** on
303
+ a KV timeout — the entry is served rather than wrongly treated as invalidated.
304
+
305
+ ```typescript
306
+ new CFCacheStore({
307
+ ctx,
308
+ kv: env.CACHE_KV,
309
+ defaults: { ttl: 60, swr: 300 },
310
+ // Raise a budget only if your HEALTHY reads legitimately run slower (large
311
+ // Flight payloads, far-from-colo regions); measure the p99 first. These are
312
+ // degradation guard-rails, not tuning levers for "slow is normal here".
313
+ kvReadTimeoutMs: 250,
314
+ });
315
+ ```
316
+
317
+ Failure handling, by kind — none of these fail the request:
318
+
319
+ | Failure | Behavior |
320
+ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
321
+ | Transient read error (5xx/blip) | Degrade to the next tier; entry left intact |
322
+ | Read budget exceeded (timeout) | Abandon the read, degrade to the next tier |
323
+ | Corrupt / unparseable L1 entry | Reported corrupt; degrade to L2 (served if present). The L1 entry is evicted ONLY when L2 has no copy — so the evict can't race the L2→L1 promote |
324
+ | Corrupt / unparseable KV entry | Reported corrupt; evicted (self-heal) + render (no tier below it) |
325
+ | Write failure | No-op (entry simply not cached); never throws |
326
+
327
+ Each is surfaced to the router's `onError` callback (phase `"cache"`, with
328
+ `metadata.category` one of `cache-read`, `cache-corrupt`, `cache-write`,
329
+ `cache-delete`, `cache-invalidate`, `stale-revalidation`) so you can observe
330
+ cache health without affecting users.
331
+
332
+ ### Validating cache behavior with `debug`
333
+
334
+ Pass `debug` to emit one structured event per L1 read — use it to confirm on a
335
+ real deployment (via `wrangler tail`) that the store behaves as expected before
336
+ relying on it. It is intended for validation, not steady-state production.
337
+
338
+ ```typescript
339
+ new CFCacheStore({
340
+ ctx,
341
+ kv: env.CACHE_KV,
342
+ debug: true, // logs each CFCacheReadDebugEvent to the console
343
+ // ...or capture programmatically:
344
+ // debug: (event) => myTelemetry.record(event),
345
+ });
346
+ ```
347
+
348
+ Each event reports which tier answered and why (`outcome`: `l1-fresh`,
349
+ `l1-stale-revalidate`, `l1-revalidating-guarded`, `match-timeout`, `match-error`,
350
+ `body-timeout`, `body-error`, `non-200`, `tag-invalidated`, `l1-miss`, `kv-fresh`,
351
+ `kv-stale`, `kv-stale-suppressed`, `kv-miss`, `kv-timeout`, `error`), the
352
+ staleness / revalidating timestamps, and the measured per-tier durations:
353
+ `matchMs` (the L1 `match`), `markerMs` (the tag-marker resolution tail for a
354
+ tagged entry, between `matchMs` and `bodyReadMs`; absent or 0 for an untagged
355
+ entry or a per-request memo hit), and `bodyReadMs` (the L1 body read). A
356
+ persistently large `markerMs` signals a degraded KV namespace; on a healthy
357
+ deployment KV keeps markers hot in its per-colo edge cache, so it stays a few
358
+ milliseconds. `match-error` (a transient `cache.match` rejection that falls
359
+ through to L2) is kept distinct from a plain `l1-miss`.
360
+
215
361
  ## Cache purity & tainted objects
216
362
 
217
363
  A `cache()` boundary caches everything except loaders, so anything read inside a
@@ -325,6 +471,7 @@ cache({ store: checkoutCache }, () => [
325
471
  ```typescript
326
472
  import { urls } from "@rangojs/router";
327
473
  import { MemorySegmentCacheStore } from "@rangojs/router/cache";
474
+ import * as CartActions from "./actions/cart";
328
475
 
329
476
  // Custom store for checkout (short TTL)
330
477
  const checkoutCache = new MemorySegmentCacheStore({
@@ -353,7 +500,7 @@ export const urlpatterns = urls(({ path, layout, cache, loader, revalidate }) =>
353
500
  path("/shop/product/:slug", ProductPage, { name: "product" }, () => [
354
501
  loader(ProductLoader, () => [cache({ ttl: 120 })]),
355
502
  loader(CartLoader, () => [
356
- revalidate(({ actionId }) => actionId?.includes("Cart") || undefined),
503
+ revalidate((ctx) => ctx.isAction(CartActions) || undefined),
357
504
  ]),
358
505
  ]),
359
506
  ]),
@@ -683,33 +683,44 @@ ProductState.delete();
683
683
  | `.write()` | yes (replace this slot) | no | throws |
684
684
  | `.delete()` | yes (remove this slot) | no | throws |
685
685
 
686
- ## Cache Hooks
686
+ ## Cache Control
687
687
 
688
- ### useClientCache()
688
+ ### invalidateClientCache()
689
689
 
690
- Manually control client-side navigation cache:
690
+ Force the client's caches to miss after a mutation the router can't see (a REST
691
+ call, a WebSocket push, a login). It is a plain function, not a hook, so it works
692
+ from module-level callbacks too. Imported from the root entry `@rangojs/router`,
693
+ it is selected by export conditions: in a client component it marks the caches
694
+ stale immediately; from a handler/server component it writes a rotated
695
+ `Set-Cookie` for the responding client.
691
696
 
692
697
  ```tsx
693
698
  "use client";
694
- import { useClientCache } from "@rangojs/router/client";
699
+ import { invalidateClientCache } from "@rangojs/router";
695
700
 
696
701
  function SaveButton() {
697
- const { clear } = useClientCache();
698
-
699
702
  const handleSave = async () => {
700
703
  await fetch("/api/data", {
701
704
  method: "POST",
702
705
  body: JSON.stringify(data),
703
706
  });
704
707
 
705
- // Invalidate cache after mutation
706
- clear();
708
+ // Invalidate the client's caches after the mutation
709
+ invalidateClientCache();
707
710
  };
708
711
 
709
712
  return <button onClick={handleSave}>Save</button>;
710
713
  }
711
714
  ```
712
715
 
716
+ A module-level subscription works the same way (no component needed):
717
+
718
+ ```ts
719
+ import { invalidateClientCache } from "@rangojs/router";
720
+
721
+ socket.on("catalog-updated", () => invalidateClientCache());
722
+ ```
723
+
713
724
  **Use cases**: REST API mutations, WebSocket updates, non-RSC data changes.
714
725
 
715
726
  ## Outlet Components
@@ -892,22 +903,22 @@ See `/links` for the full URL generation guide. `ctx.reverse()` is server-only;
892
903
 
893
904
  ## Hook Summary
894
905
 
895
- | Hook | Purpose | Returns |
896
- | --------------------- | --------------------------------- | ------------------------------------------------------------------ |
897
- | `useParams()` | Route params | `Readonly<T>` (default `Record<string, string>`) or selected value |
898
- | `usePathname()` | Current pathname | `string` |
899
- | `useSearchParams()` | URL search params | `ReadonlyURLSearchParams` |
900
- | `useHref()` | Mount-aware href | `(path) => string` |
901
- | `useMount()` | Current include() mount path | `string` |
902
- | `useReverse()` | Local reverse for imported routes | `(name, params?, search?) => string` |
903
- | `useNavigation()` | Reactive navigation state | state, location, isStreaming |
904
- | `useRouter()` | Stable router actions | push, replace, refresh, prefetch, back, forward |
905
- | `useSegments()` | URL path & segment IDs | path, segmentIds, location |
906
- | `useLinkStatus()` | Link pending state | { pending } |
907
- | `useLoader()` | Loader data (strict) | data, isLoading, error |
908
- | `useFetchLoader()` | Loader with on-demand fetch | data, load, isLoading |
909
- | `useRefreshLoaders()` | Refresh cross-loader group(s) | `() => (groups: string \| string[]) => Promise<void>` |
910
- | `useHandle()` | Accumulated handle data | T (handle type) |
911
- | `useAction()` | Server action state | state, error, result |
912
- | `useLocationState()` | History state (persists or flash) | T \| undefined |
913
- | `useClientCache()` | Cache control | { clear } |
906
+ | Hook | Purpose | Returns |
907
+ | ------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------ |
908
+ | `useParams()` | Route params | `Readonly<T>` (default `Record<string, string>`) or selected value |
909
+ | `usePathname()` | Current pathname | `string` |
910
+ | `useSearchParams()` | URL search params | `ReadonlyURLSearchParams` |
911
+ | `useHref()` | Mount-aware href | `(path) => string` |
912
+ | `useMount()` | Current include() mount path | `string` |
913
+ | `useReverse()` | Local reverse for imported routes | `(name, params?, search?) => string` |
914
+ | `useNavigation()` | Reactive navigation state | state, location, isStreaming |
915
+ | `useRouter()` | Stable router actions | push, replace, refresh, prefetch, back, forward |
916
+ | `useSegments()` | URL path & segment IDs | path, segmentIds, location |
917
+ | `useLinkStatus()` | Link pending state | { pending } |
918
+ | `useLoader()` | Loader data (strict) | data, isLoading, error |
919
+ | `useFetchLoader()` | Loader with on-demand fetch | data, load, isLoading |
920
+ | `useRefreshLoaders()` | Refresh cross-loader group(s) | `() => (groups: string \| string[]) => Promise<void>` |
921
+ | `useHandle()` | Accumulated handle data | T (handle type) |
922
+ | `useAction()` | Server action state | state, error, result |
923
+ | `useLocationState()` | History state (persists or flash) | T \| undefined |
924
+ | `invalidateClientCache()` | Force client caches to miss (function, not a hook; root entry) | `void` |
@@ -189,12 +189,26 @@ Logs pattern matching, route registration, and cookie override decisions to cons
189
189
  ## Testing
190
190
 
191
191
  ```typescript
192
- import { createTestRequest, testPattern } from "@rangojs/router/host/testing";
192
+ import {
193
+ createTestRequest,
194
+ testPattern,
195
+ matchesHost,
196
+ } from "@rangojs/router/host/testing";
193
197
 
194
- // Test pattern matching
198
+ // Test pattern matching (host-only)
195
199
  testPattern("admin.*", "admin.example.com"); // true
196
200
  testPattern([".", "www.*"], "example.com"); // true
197
201
 
202
+ // Path-based patterns need the third pathname arg (defaults to "/", so a
203
+ // host-only pattern still works with two args):
204
+ testPattern("**.workers.dev/admin", "foo.workers.dev", "/admin"); // true
205
+
206
+ // Or match a pattern against a real Request (hostname + pathname from the URL):
207
+ matchesHost(
208
+ "**.workers.dev/admin",
209
+ new Request("https://foo.workers.dev/admin"),
210
+ ); // true
211
+
198
212
  // Create requests for integration tests
199
213
  const request = createTestRequest({
200
214
  host: "admin.example.com",
@@ -107,8 +107,10 @@ Use named revalidation contracts on both the outer producer and the intercept
107
107
  consumer when they share `ctx.set()` data:
108
108
 
109
109
  ```typescript
110
- export const revalidateProductShell = ({ actionId }) =>
111
- actionId?.includes("src/actions/product.ts#") || undefined;
110
+ import * as ProductActions from "./actions/product";
111
+
112
+ export const revalidateProductShell = (ctx) =>
113
+ ctx.isAction(ProductActions) || undefined;
112
114
 
113
115
  layout(ProductLayout, () => [
114
116
  revalidate(revalidateProductShell), // producer reruns