@rangojs/router 0.0.0-experimental.20 → 0.0.0-experimental.20dbba0c

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 (189) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +172 -50
  3. package/dist/bin/rango.js +138 -50
  4. package/dist/vite/index.js +1160 -508
  5. package/dist/vite/index.js.bak +5448 -0
  6. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  7. package/package.json +17 -16
  8. package/skills/breadcrumbs/SKILL.md +252 -0
  9. package/skills/cache-guide/SKILL.md +32 -0
  10. package/skills/caching/SKILL.md +49 -8
  11. package/skills/document-cache/SKILL.md +2 -2
  12. package/skills/handler-use/SKILL.md +362 -0
  13. package/skills/hooks/SKILL.md +61 -51
  14. package/skills/host-router/SKILL.md +218 -0
  15. package/skills/intercept/SKILL.md +20 -0
  16. package/skills/layout/SKILL.md +22 -0
  17. package/skills/links/SKILL.md +91 -17
  18. package/skills/loader/SKILL.md +107 -24
  19. package/skills/middleware/SKILL.md +34 -3
  20. package/skills/migrate-nextjs/SKILL.md +560 -0
  21. package/skills/migrate-react-router/SKILL.md +765 -0
  22. package/skills/parallel/SKILL.md +185 -0
  23. package/skills/prerender/SKILL.md +112 -70
  24. package/skills/rango/SKILL.md +24 -23
  25. package/skills/response-routes/SKILL.md +8 -0
  26. package/skills/route/SKILL.md +58 -4
  27. package/skills/router-setup/SKILL.md +95 -5
  28. package/skills/streams-and-websockets/SKILL.md +283 -0
  29. package/skills/typesafety/SKILL.md +38 -24
  30. package/src/__internal.ts +92 -0
  31. package/src/browser/app-shell.ts +52 -0
  32. package/src/browser/app-version.ts +14 -0
  33. package/src/browser/event-controller.ts +5 -0
  34. package/src/browser/link-interceptor.ts +4 -0
  35. package/src/browser/navigation-bridge.ts +175 -17
  36. package/src/browser/navigation-client.ts +177 -44
  37. package/src/browser/navigation-store.ts +68 -9
  38. package/src/browser/navigation-transaction.ts +11 -9
  39. package/src/browser/partial-update.ts +113 -17
  40. package/src/browser/prefetch/cache.ts +275 -28
  41. package/src/browser/prefetch/fetch.ts +191 -46
  42. package/src/browser/prefetch/policy.ts +6 -0
  43. package/src/browser/prefetch/queue.ts +123 -20
  44. package/src/browser/prefetch/resource-ready.ts +77 -0
  45. package/src/browser/rango-state.ts +53 -13
  46. package/src/browser/react/Link.tsx +98 -14
  47. package/src/browser/react/NavigationProvider.tsx +89 -14
  48. package/src/browser/react/context.ts +7 -2
  49. package/src/browser/react/use-handle.ts +9 -58
  50. package/src/browser/react/use-navigation.ts +22 -2
  51. package/src/browser/react/use-params.ts +11 -1
  52. package/src/browser/react/use-router.ts +29 -9
  53. package/src/browser/rsc-router.tsx +177 -66
  54. package/src/browser/scroll-restoration.ts +41 -42
  55. package/src/browser/segment-reconciler.ts +36 -9
  56. package/src/browser/server-action-bridge.ts +8 -6
  57. package/src/browser/types.ts +73 -5
  58. package/src/build/generate-manifest.ts +6 -6
  59. package/src/build/generate-route-types.ts +3 -0
  60. package/src/build/route-trie.ts +67 -25
  61. package/src/build/route-types/include-resolution.ts +8 -1
  62. package/src/build/route-types/router-processing.ts +223 -74
  63. package/src/build/route-types/scan-filter.ts +8 -1
  64. package/src/cache/cache-runtime.ts +15 -11
  65. package/src/cache/cache-scope.ts +48 -7
  66. package/src/cache/cf/cf-cache-store.ts +455 -15
  67. package/src/cache/cf/index.ts +5 -1
  68. package/src/cache/document-cache.ts +17 -7
  69. package/src/cache/index.ts +1 -0
  70. package/src/cache/taint.ts +55 -0
  71. package/src/client.rsc.tsx +2 -1
  72. package/src/client.tsx +85 -276
  73. package/src/context-var.ts +72 -2
  74. package/src/debug.ts +2 -2
  75. package/src/handle.ts +40 -0
  76. package/src/handles/breadcrumbs.ts +66 -0
  77. package/src/handles/index.ts +1 -0
  78. package/src/host/index.ts +0 -3
  79. package/src/index.rsc.ts +9 -36
  80. package/src/index.ts +79 -70
  81. package/src/outlet-context.ts +1 -1
  82. package/src/prerender/store.ts +57 -15
  83. package/src/prerender.ts +138 -77
  84. package/src/response-utils.ts +28 -0
  85. package/src/reverse.ts +27 -2
  86. package/src/route-definition/dsl-helpers.ts +240 -40
  87. package/src/route-definition/helpers-types.ts +67 -19
  88. package/src/route-definition/index.ts +3 -3
  89. package/src/route-definition/redirect.ts +11 -3
  90. package/src/route-definition/resolve-handler-use.ts +155 -0
  91. package/src/route-map-builder.ts +7 -1
  92. package/src/route-types.ts +18 -0
  93. package/src/router/content-negotiation.ts +100 -1
  94. package/src/router/find-match.ts +4 -2
  95. package/src/router/handler-context.ts +129 -26
  96. package/src/router/intercept-resolution.ts +11 -4
  97. package/src/router/lazy-includes.ts +10 -7
  98. package/src/router/loader-resolution.ts +160 -22
  99. package/src/router/logging.ts +5 -2
  100. package/src/router/manifest.ts +31 -16
  101. package/src/router/match-api.ts +128 -193
  102. package/src/router/match-middleware/background-revalidation.ts +30 -2
  103. package/src/router/match-middleware/cache-lookup.ts +94 -17
  104. package/src/router/match-middleware/cache-store.ts +53 -10
  105. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  106. package/src/router/match-middleware/segment-resolution.ts +61 -5
  107. package/src/router/match-result.ts +103 -18
  108. package/src/router/metrics.ts +238 -13
  109. package/src/router/middleware-types.ts +48 -27
  110. package/src/router/middleware.ts +201 -86
  111. package/src/router/navigation-snapshot.ts +182 -0
  112. package/src/router/pattern-matching.ts +77 -11
  113. package/src/router/prerender-match.ts +114 -10
  114. package/src/router/preview-match.ts +30 -102
  115. package/src/router/request-classification.ts +310 -0
  116. package/src/router/revalidation.ts +27 -7
  117. package/src/router/route-snapshot.ts +245 -0
  118. package/src/router/router-context.ts +6 -1
  119. package/src/router/router-interfaces.ts +50 -5
  120. package/src/router/router-options.ts +50 -19
  121. package/src/router/segment-resolution/fresh.ts +215 -19
  122. package/src/router/segment-resolution/helpers.ts +30 -25
  123. package/src/router/segment-resolution/loader-cache.ts +1 -0
  124. package/src/router/segment-resolution/revalidation.ts +454 -301
  125. package/src/router/segment-wrappers.ts +2 -0
  126. package/src/router/trie-matching.ts +30 -6
  127. package/src/router/types.ts +1 -0
  128. package/src/router/url-params.ts +49 -0
  129. package/src/router.ts +89 -17
  130. package/src/rsc/handler.ts +563 -364
  131. package/src/rsc/helpers.ts +69 -41
  132. package/src/rsc/index.ts +0 -20
  133. package/src/rsc/loader-fetch.ts +23 -3
  134. package/src/rsc/manifest-init.ts +5 -1
  135. package/src/rsc/progressive-enhancement.ts +37 -10
  136. package/src/rsc/response-route-handler.ts +14 -1
  137. package/src/rsc/rsc-rendering.ts +47 -44
  138. package/src/rsc/server-action.ts +24 -10
  139. package/src/rsc/ssr-setup.ts +128 -0
  140. package/src/rsc/types.ts +11 -1
  141. package/src/search-params.ts +16 -13
  142. package/src/segment-content-promise.ts +67 -0
  143. package/src/segment-loader-promise.ts +122 -0
  144. package/src/segment-system.tsx +109 -23
  145. package/src/server/context.ts +174 -19
  146. package/src/server/handle-store.ts +19 -0
  147. package/src/server/loader-registry.ts +9 -8
  148. package/src/server/request-context.ts +218 -65
  149. package/src/server.ts +6 -0
  150. package/src/ssr/index.tsx +4 -0
  151. package/src/static-handler.ts +18 -6
  152. package/src/theme/index.ts +4 -13
  153. package/src/types/cache-types.ts +4 -4
  154. package/src/types/handler-context.ts +140 -72
  155. package/src/types/loader-types.ts +41 -15
  156. package/src/types/request-scope.ts +126 -0
  157. package/src/types/route-config.ts +17 -8
  158. package/src/types/route-entry.ts +19 -1
  159. package/src/types/segments.ts +2 -5
  160. package/src/urls/include-helper.ts +24 -14
  161. package/src/urls/path-helper-types.ts +39 -6
  162. package/src/urls/path-helper.ts +48 -13
  163. package/src/urls/pattern-types.ts +12 -0
  164. package/src/urls/response-types.ts +18 -16
  165. package/src/use-loader.tsx +77 -5
  166. package/src/vite/discovery/bundle-postprocess.ts +61 -89
  167. package/src/vite/discovery/discover-routers.ts +7 -4
  168. package/src/vite/discovery/prerender-collection.ts +162 -88
  169. package/src/vite/discovery/state.ts +17 -13
  170. package/src/vite/index.ts +8 -3
  171. package/src/vite/plugin-types.ts +51 -79
  172. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  173. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  174. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  175. package/src/vite/plugins/expose-action-id.ts +1 -3
  176. package/src/vite/plugins/expose-id-utils.ts +12 -0
  177. package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
  178. package/src/vite/plugins/expose-internal-ids.ts +257 -40
  179. package/src/vite/plugins/performance-tracks.ts +88 -0
  180. package/src/vite/plugins/refresh-cmd.ts +127 -0
  181. package/src/vite/plugins/version-plugin.ts +13 -1
  182. package/src/vite/rango.ts +190 -217
  183. package/src/vite/router-discovery.ts +241 -45
  184. package/src/vite/utils/banner.ts +4 -4
  185. package/src/vite/utils/package-resolution.ts +34 -1
  186. package/src/vite/utils/prerender-utils.ts +97 -5
  187. package/src/vite/utils/shared-utils.ts +3 -2
  188. package/skills/testing/SKILL.md +0 -226
  189. package/src/route-definition/route-function.ts +0 -119
@@ -400,6 +400,14 @@ path(
400
400
  Multiple response types can share the same URL pattern. See `/mime-routes` for the
401
401
  full content negotiation API (Accept header matching, Vary: Accept, multi-variant routes).
402
402
 
403
+ ## Long-Lived Responses (SSE / WebSocket)
404
+
405
+ For Server-Sent Events (`path.stream`) and WebSocket upgrades (`path.any`
406
+ returning a 101 / `webSocket` Response), see `/streams-and-websockets`.
407
+ Upgrade responses flow through without reconstruction; `Vary` and
408
+ `Server-Timing` are skipped, and stub headers are applied in place on a
409
+ best-effort basis.
410
+
403
411
  ## How It Works
404
412
 
405
413
  1. `path.json()` tags the route at the trie level with a MIME type
@@ -103,8 +103,8 @@ export const SearchPage: Handler<"search"> = (ctx) => {
103
103
  ```
104
104
 
105
105
  Supported types: `"string"`, `"number"`, `"boolean"`, with `?` suffix for optional.
106
- Required params default to zero values when missing (`""`, `0`, `false`).
107
- Optional params are omitted from the result when not in the query string.
106
+ Missing params are `undefined` regardless of required/optional. The required/optional
107
+ distinction is a consumer-facing contract (for `href()` and `reverse()` autocomplete).
108
108
 
109
109
  Use `RouteSearchParams<"name">` and `RouteParams<"name">` to extract types for props:
110
110
 
@@ -181,6 +181,37 @@ String keys still work (`ctx.set("key", value)` / `ctx.get("key")`), but
181
181
  Only route handlers and middleware can call `ctx.set()`. Layouts, parallels,
182
182
  and intercepts can only read via `ctx.get()`.
183
183
 
184
+ #### Non-cacheable context variables
185
+
186
+ Mark a var as non-cacheable when it holds inherently request-specific data
187
+ (sessions, auth tokens, per-request IDs). There are two ways:
188
+
189
+ ```typescript
190
+ // Var-level: every value written to this var is non-cacheable
191
+ const Session = createVar<SessionData>({ cache: false });
192
+
193
+ // Write-level: escalate a normally-cacheable var for this specific write
194
+ const Theme = createVar<string>();
195
+ ctx.set(Theme, userTheme, { cache: false });
196
+ ```
197
+
198
+ "Least cacheable wins" — if either the var definition or the write site says
199
+ `cache: false`, the value is non-cacheable.
200
+
201
+ Reading a non-cacheable var inside `cache()` or `"use cache"` throws at
202
+ runtime. This prevents request-specific data from leaking into cached output:
203
+
204
+ ```typescript
205
+ // This throws — Session is non-cacheable
206
+ async function CachedWidget(ctx) {
207
+ "use cache";
208
+ const session = ctx.get(Session); // Error: non-cacheable var read inside cache scope
209
+ return <Widget />;
210
+ }
211
+ ```
212
+
213
+ Cacheable vars (the default) can be read freely inside cache scopes.
214
+
184
215
  ### Revalidation Contracts for Handler Data
185
216
 
186
217
  Handler-first guarantees apply within a single full render pass. For partial
@@ -352,11 +383,34 @@ urls(({ path, layout }) => [
352
383
  ])
353
384
  ```
354
385
 
386
+ ## Handler-attached `.use`
387
+
388
+ Page handlers can carry their own loader, middleware, error boundaries, parallels, and other defaults via a `.use` callback — so the page is self-contained and reusable across mount sites without re-wiring the same items.
389
+
390
+ ```typescript
391
+ const ProductPage: Handler<"/product/:slug"> = async (ctx) => {
392
+ const product = await ctx.use(ProductLoader);
393
+ return <ProductView product={product} />;
394
+ };
395
+ ProductPage.use = () => [
396
+ loader(ProductLoader),
397
+ loading(<ProductSkeleton />),
398
+ middleware(async (ctx, next) => {
399
+ await next();
400
+ ctx.header("Cache-Control", "private, max-age=60");
401
+ }),
402
+ ];
403
+
404
+ // Mount site has no per-page wiring — defaults travel with the handler.
405
+ path("/product/:slug", ProductPage, { name: "product" });
406
+ ```
407
+
408
+ Explicit `use()` at the mount site merges with `handler.use` (handler defaults first, explicit second). See [skills/handler-use](../handler-use/SKILL.md) for the merge order, allowed item types per mount site, and override semantics.
409
+
355
410
  ## Complete Example
356
411
 
357
412
  ```typescript
358
- import { urls } from "@rangojs/router";
359
- import { Breadcrumbs } from "./handles/breadcrumbs";
413
+ import { urls, Breadcrumbs } from "@rangojs/router";
360
414
 
361
415
  export const urlpatterns = urls(({ path, layout, loader, loading }) => [
362
416
  // Simple route
@@ -78,16 +78,21 @@ interface RSCRouterOptions<TEnv> {
78
78
  // Document component wrapping entire app
79
79
  document?: ComponentType<{ children: ReactNode }>;
80
80
 
81
- // Enable performance metrics
81
+ // URL prefix for sub-path deployments (e.g. "/admin")
82
+ // All routes, reverse(), href(), Link, redirect(), and router.use()
83
+ // patterns are automatically prefixed. Route names stay unprefixed.
84
+ basename?: string;
85
+
86
+ // Enable per-request performance timeline (console waterfall + Server-Timing header)
82
87
  debugPerformance?: boolean;
83
88
 
84
89
  // Default error boundary
85
90
  defaultErrorBoundary?: ReactNode | ErrorBoundaryHandler;
86
91
 
87
- // Default not-found boundary
92
+ // Default not-found boundary for notFound() thrown in handlers/loaders
88
93
  defaultNotFoundBoundary?: ReactNode | NotFoundBoundaryHandler;
89
94
 
90
- // Component for 404 routes
95
+ // Component for 404 (no route match, or notFound() without a boundary)
91
96
  notFound?: ReactNode | ((props: { pathname: string }) => ReactNode);
92
97
 
93
98
  // Error logging callback
@@ -108,6 +113,11 @@ interface RSCRouterOptions<TEnv> {
108
113
  // Connection warmup (default: true)
109
114
  warmup?: boolean;
110
115
 
116
+ // Prefetch cache TTL in seconds (default: 300)
117
+ // Controls in-memory cache duration and Cache-Control max-age for prefetch responses.
118
+ // Set to false to disable prefetch caching.
119
+ prefetchCacheTTL?: number | false;
120
+
111
121
  // CSP nonce provider (for router.fetch)
112
122
  nonce?: (
113
123
  request: Request,
@@ -119,6 +129,36 @@ interface RSCRouterOptions<TEnv> {
119
129
  }
120
130
  ```
121
131
 
132
+ ## Basename (Sub-Path Deployment)
133
+
134
+ When your app is served under a sub-path (e.g. `/admin` or `/v2`), set `basename`:
135
+
136
+ ```typescript
137
+ const router = createRouter({
138
+ basename: "/admin",
139
+ document: Document,
140
+ }).routes(({ path, include }) => [
141
+ path("/", Dashboard, { name: "home" }), // matches /admin
142
+ path("/users", Users, { name: "users" }), // matches /admin/users
143
+ include("/api", apiPatterns, { name: "api" }), // matches /admin/api/*
144
+ ]);
145
+
146
+ router.reverse("home"); // "/admin"
147
+ router.reverse("users"); // "/admin/users"
148
+ ```
149
+
150
+ Router-owned APIs are basename-aware:
151
+
152
+ - `reverse()` returns prefixed paths
153
+ - `<Link to="/users">` renders `<a href="/admin/users">`
154
+ - `redirect("/login")` redirects to `"/admin/login"`
155
+ - `router.use("/users/*", mw)` matches `/admin/users/*`
156
+ - `useRouter().push("/users")` navigates to `/admin/users`
157
+ - Route names stay unprefixed (`"home"`, not `"admin.home"`)
158
+
159
+ Note: `href()` is a raw path helper and does **not** auto-prefix with basename.
160
+ Use `reverse()` or `<Link>` for basename-aware URLs.
161
+
122
162
  ## Using the Request Handler
123
163
 
124
164
  The router provides a `fetch` method to handle RSC requests:
@@ -285,6 +325,56 @@ const router = createRouter({
285
325
  export default router;
286
326
  ```
287
327
 
328
+ ## Not Found Handling
329
+
330
+ Two distinct 404 scenarios:
331
+
332
+ **1. No route matches the URL** — the router renders the `notFound` component from `createRouter()` config. This is automatic.
333
+
334
+ **2. A handler/loader calls `notFound()`** — signals that the route matched but the data doesn't exist (e.g., invalid product ID).
335
+
336
+ ```typescript
337
+ import { notFound } from "@rangojs/router";
338
+
339
+ // In a handler or loader
340
+ path("/product/:slug", async (ctx) => {
341
+ const product = await db.getProduct(ctx.params.slug);
342
+ if (!product) notFound("Product not found");
343
+ return <ProductPage product={product} />;
344
+ });
345
+ ```
346
+
347
+ ### Fallback chain for `notFound()`
348
+
349
+ When `notFound()` is thrown, the router looks for a fallback in this order:
350
+
351
+ 1. **`notFoundBoundary()`** — nearest boundary in the route tree (route-level)
352
+ 2. **`defaultNotFoundBoundary`** — from `createRouter()` config (app-level)
353
+ 3. **`notFound`** — from `createRouter()` config (same component used for no-route-match)
354
+ 4. **Default `<h1>Not Found</h1>`** — built-in fallback
355
+
356
+ All cases set HTTP 404 status.
357
+
358
+ ### notFoundBoundary
359
+
360
+ Wrap routes with `notFoundBoundary()` for route-specific not-found UI:
361
+
362
+ ```typescript
363
+ urls(({ path, layout }) => [
364
+ layout(ShopLayout, () => [
365
+ notFoundBoundary(({ notFound: info }) => (
366
+ <div>
367
+ <h1>Not Found</h1>
368
+ <p>{info.message}</p>
369
+ </div>
370
+ )),
371
+ path("/product/:slug", ProductPage),
372
+ ]),
373
+ ]);
374
+ ```
375
+
376
+ `notFoundBoundary` receives `{ notFound: NotFoundInfo }` where `NotFoundInfo` contains `message`, `segmentId`, `segmentType`, and `pathname`.
377
+
288
378
  ## Including Sub-patterns
289
379
 
290
380
  ```typescript
@@ -297,10 +387,10 @@ export const shopPatterns = urls(({ path, layout }) => [
297
387
  ]);
298
388
 
299
389
  // src/urls.tsx
300
- import { urls, include } from "@rangojs/router";
390
+ import { urls } from "@rangojs/router";
301
391
  import { shopPatterns } from "./urls/shop";
302
392
 
303
- export const urlpatterns = urls(({ path }) => [
393
+ export const urlpatterns = urls(({ path, include }) => [
304
394
  path("/", HomePage, { name: "home" }),
305
395
  include("/shop", shopPatterns, { name: "shop" }),
306
396
  ]);
@@ -0,0 +1,283 @@
1
+ ---
2
+ name: streams-and-websockets
3
+ description: Long-lived Response handlers — Server-Sent Events (SSE) via path.stream and WebSocket upgrades via path.any on Cloudflare Workers, including middleware interaction and runtime caveats.
4
+ argument-hint: "[sse | websocket | agents]"
5
+ ---
6
+
7
+ # Streams and WebSockets
8
+
9
+ Response routes can return long-lived responses — SSE streams and WebSocket
10
+ upgrades. Both require a `Response` that the router must forward through the
11
+ middleware chain without reconstruction.
12
+
13
+ ## When each fits
14
+
15
+ | Shape | Tag | Status | Body | Runtime |
16
+ | ----------- | --------------- | ------ | ------------------------------- | -------------------------------- |
17
+ | Server-Sent | `path.stream()` | 200 | `ReadableStream` (event-stream) | any runtime (Node, workerd, bun) |
18
+ | WebSocket | `path.any()` | 101 | `null` + `webSocket` property | Cloudflare Workers (workerd) |
19
+
20
+ - **SSE** is a regular 200 response with `content-type: text/event-stream`
21
+ and a `ReadableStream` body. Works everywhere, flows through middleware
22
+ normally.
23
+ - **WebSocket upgrades** produce a status-101 response with a non-standard
24
+ `webSocket` property (Cloudflare). The router detects these and forwards
25
+ them without reconstruction; `Vary` and `Server-Timing` are skipped, and
26
+ stub headers are merged in place on a best-effort basis.
27
+
28
+ ## Server-Sent Events (SSE)
29
+
30
+ Use `path.stream()` (or `path.any()` if you need full control) to return a
31
+ `ReadableStream`. Each chunk is an `event-stream` frame:
32
+
33
+ ```typescript
34
+ import { urls } from "@rangojs/router";
35
+
36
+ export const urlpatterns = urls(({ path }) => [
37
+ path.stream(
38
+ "/events/ticks",
39
+ (ctx) => {
40
+ const encoder = new TextEncoder();
41
+
42
+ const stream = new ReadableStream({
43
+ async start(controller) {
44
+ let count = 0;
45
+ const interval = setInterval(() => {
46
+ controller.enqueue(
47
+ encoder.encode(`event: tick\ndata: ${++count}\n\n`),
48
+ );
49
+ }, 1000);
50
+
51
+ // Honor client disconnect — signal comes from ctx.request.signal
52
+ ctx.request.signal.addEventListener("abort", () => {
53
+ clearInterval(interval);
54
+ controller.close();
55
+ });
56
+ },
57
+ });
58
+
59
+ return new Response(stream, {
60
+ headers: {
61
+ "content-type": "text/event-stream",
62
+ "cache-control": "no-store",
63
+ // Disable proxy buffering on Nginx/Traefik deployments
64
+ "x-accel-buffering": "no",
65
+ },
66
+ });
67
+ },
68
+ { name: "ticks" },
69
+ ),
70
+ ]);
71
+ ```
72
+
73
+ ### Client
74
+
75
+ ```typescript
76
+ "use client";
77
+ const source = new EventSource("/events/ticks");
78
+ source.addEventListener("tick", (e) => console.log("tick", e.data));
79
+ ```
80
+
81
+ ### SSE caveats
82
+
83
+ - **Never wrap SSE routes in `cache()`** — a cached `ReadableStream` is read
84
+ once and would replay an empty body on the next hit. `path.stream` is
85
+ already excluded from response-route caching, but don't layer a custom
86
+ cache() middleware on top.
87
+ - **Middleware is fine.** Global/route middleware rewraps the SSE `Response`
88
+ as `new Response(response.body, { status, headers })` to merge stub headers.
89
+ The `ReadableStream` body is passed by reference, not consumed, so the
90
+ client sees the stream unchanged. (WebSocket upgrades are the exception —
91
+ those bypass rewrap entirely; see below.)
92
+ - **Honor `ctx.request.signal`.** Without wiring abort to your source
93
+ (timer, DB cursor, upstream fetch), the stream leaks when the client
94
+ disconnects.
95
+ - **Disable Nginx/CDN buffering** via `x-accel-buffering: no` and ensure
96
+ no intermediate proxy rebuffers. On Cloudflare Workers this is a non-issue.
97
+
98
+ ## WebSockets (Cloudflare Workers)
99
+
100
+ WebSocket upgrades on workerd produce a response with `status: 101` and a
101
+ non-standard `webSocket` property. The router detects this shape and forwards
102
+ the `Response` without reconstruction — the 101 status and the `webSocket`
103
+ property are preserved. `Vary` and `Server-Timing` writes are skipped, and
104
+ stub-header merging (cookies/custom headers set via `ctx.header()` or
105
+ `cookies().set()`) is best-effort: the router attempts to apply them in
106
+ place, but silently skips any write rejected by a runtime that exposes
107
+ immutable upgrade headers.
108
+
109
+ ### Minimal upgrade handler
110
+
111
+ ```typescript
112
+ import { urls } from "@rangojs/router";
113
+
114
+ export const urlpatterns = urls(({ path }) => [
115
+ path.any(
116
+ "/ws",
117
+ (ctx) => {
118
+ // Manual WebSocketPair on workerd
119
+ const upgrade = ctx.request.headers.get("upgrade");
120
+ if (upgrade !== "websocket") {
121
+ return new Response("expected upgrade: websocket", { status: 426 });
122
+ }
123
+
124
+ const { 0: client, 1: server } = new WebSocketPair();
125
+ server.accept();
126
+ server.addEventListener("message", (e) => {
127
+ server.send(`echo: ${e.data}`);
128
+ });
129
+
130
+ return new Response(null, {
131
+ status: 101,
132
+ webSocket: client,
133
+ } as ResponseInit);
134
+ },
135
+ { name: "ws" },
136
+ ),
137
+ ]);
138
+ ```
139
+
140
+ ### Durable Object pattern
141
+
142
+ Route into a Durable Object that owns the connection:
143
+
144
+ ```typescript
145
+ export const urlpatterns = urls(({ path }) => [
146
+ path.any(
147
+ "/rooms/:roomId",
148
+ async (ctx) => {
149
+ const id = ctx.env.ROOMS.idFromName(ctx.params.roomId);
150
+ const stub = ctx.env.ROOMS.get(id);
151
+ // The DO's fetch handler calls handleWebSocketUpgrade(request)
152
+ // and returns the 101 Response. We forward it unchanged.
153
+ return stub.fetch(ctx.request);
154
+ },
155
+ { name: "room" },
156
+ ),
157
+ ]);
158
+ ```
159
+
160
+ ### Using the `agents` library
161
+
162
+ `routeAgentRequest` from `agents` returns a 101 `Response` targeted at a
163
+ Durable Object. Return it directly from `path.any()`:
164
+
165
+ ```typescript
166
+ import { routeAgentRequest } from "agents";
167
+ import { urls } from "@rangojs/router";
168
+
169
+ export const urlpatterns = urls(({ path }) => [
170
+ path.any("/agents/*", async (ctx) => {
171
+ const response = await routeAgentRequest(ctx.request, ctx.env);
172
+ if (!response) {
173
+ return new Response("not found", { status: 404 });
174
+ }
175
+ return response;
176
+ }),
177
+ ]);
178
+ ```
179
+
180
+ ## Middleware interaction
181
+
182
+ ### Forwarded, not reconstructed
183
+
184
+ When a middleware is matched for the upgrade URL, the middleware still runs
185
+ **before** `next()` — but the Response from `next()` is forwarded as-is
186
+ rather than re-wrapped. This preserves:
187
+
188
+ - The 101 status (which would otherwise throw `RangeError: Responses may
189
+ only be constructed with status codes in the range 200 to 599, inclusive`
190
+ on standards-compliant runtimes).
191
+ - The Cloudflare `webSocket` property (which would otherwise be silently
192
+ dropped by `new Response(body, ...)` on workerd).
193
+
194
+ ```typescript
195
+ // This works — logger runs, but the 101 flows through unchanged.
196
+ router.use(async (ctx, next) => {
197
+ console.log("ws request", ctx.url.pathname);
198
+ return next();
199
+ });
200
+ ```
201
+
202
+ ### Don't try to set cookies on an upgrade
203
+
204
+ Stub cookie/header writes made before `await next()` are applied to the
205
+ upgrade response on a best-effort basis — the router attempts an in-place
206
+ merge and skips any write rejected by runtimes that expose immutable 101
207
+ headers. Either way, a browser completing a WS handshake never reads them.
208
+ Do not rely on this for auth or state propagation: set cookies via a prior
209
+ HTTP request instead (e.g. during login), then read them at upgrade time
210
+ via `ctx.request.headers.get("cookie")`.
211
+
212
+ ```typescript
213
+ // Avoid: this cookie may not land on the upgrade response, and the client
214
+ // never reads it during the handshake regardless.
215
+ router.use(async (ctx, next) => {
216
+ cookies().set("last-ws-at", Date.now().toString());
217
+ return next();
218
+ });
219
+
220
+ // Prefer: authenticate by reading a cookie set on a prior HTTP request.
221
+ path.any("/ws", (ctx) => {
222
+ const session = parseCookie(ctx.request.headers.get("cookie"))?.session;
223
+ if (!verify(session)) return new Response("unauthorized", { status: 401 });
224
+ // ...upgrade
225
+ });
226
+ ```
227
+
228
+ ### Short-circuit before upgrade
229
+
230
+ Middleware can return a non-101 Response to deny the upgrade outright:
231
+
232
+ ```typescript
233
+ router.use(async (ctx, next) => {
234
+ if (!isAllowed(ctx.request)) {
235
+ return new Response("forbidden", { status: 403 });
236
+ }
237
+ return next();
238
+ });
239
+ ```
240
+
241
+ ## Caching
242
+
243
+ - **SSE** — do not combine with `cache()` (streams can't be replayed).
244
+ - **WebSocket** — `cache()` is inert because only `status === 200` is cacheable.
245
+
246
+ ## Runtime caveats
247
+
248
+ | Runtime | SSE | WebSocket upgrade (101) |
249
+ | -------------------------------------- | --- | ---------------------------------------------------- |
250
+ | Cloudflare Workers (workerd) | OK | OK (native `WebSocketPair`, DO, `agents`) |
251
+ | Node (undici fetch) | OK | N/A — Node's HTTP server must upgrade |
252
+ | Bun | OK | Bun's native `upgrade()` — not a Response-based path |
253
+ | Dev (Vite + `@cloudflare/vite-plugin`) | OK | OK via workerd emulation |
254
+
255
+ When running in pure Node without workerd, a `status: 101` Response cannot
256
+ even be constructed (`new Response(null, { status: 101 })` throws). For
257
+ tests, fabricate upgrade-style responses by overriding `.status` on a real
258
+ Response instance:
259
+
260
+ ```typescript
261
+ const upgrade = new Response(null, { status: 200 });
262
+ Object.defineProperty(upgrade, "status", { value: 101, configurable: true });
263
+ // optional: attach a webSocket stub
264
+ Object.defineProperty(upgrade, "webSocket", {
265
+ value: { stub: "ws" },
266
+ configurable: true,
267
+ enumerable: true,
268
+ });
269
+ ```
270
+
271
+ ## Testing
272
+
273
+ - Unit tests: `isWebSocketUpgradeResponse` and `executeMiddleware` passthrough
274
+ cases live in `src/rsc/__tests__/helpers.test.ts` and
275
+ `src/router/middleware.test.ts`.
276
+ - E2E: cover both dev and production modes against a workerd target. SSE
277
+ can be tested on any runtime; WS upgrades need workerd (use
278
+ `@cloudflare/vite-plugin` or `wrangler dev`).
279
+
280
+ ## See also
281
+
282
+ - `response-routes` — the parent skill for `path.json/text/html/stream/any`.
283
+ - `middleware` — how global and route-level middleware compose with handlers.
@@ -281,7 +281,7 @@ import type { RouteSearchParams, RouteParams } from "@rangojs/router";
281
281
 
282
282
  // RouteSearchParams<"name"> resolves the search schema to a typed object
283
283
  type SP = RouteSearchParams<"search">;
284
- // { q: string; page?: number; sort?: string }
284
+ // { q: string | undefined; page?: number; sort?: string }
285
285
 
286
286
  // RouteParams<"name"> resolves URL params from the route pattern
287
287
  type P = RouteParams<"blogPost">;
@@ -334,7 +334,7 @@ export const ProductLoader = createLoader(async (ctx) => {
334
334
  });
335
335
 
336
336
  // In server component - type is inferred
337
- import { useLoader } from "@rangojs/router";
337
+ import { useLoader } from "@rangojs/router/client";
338
338
 
339
339
  async function ProductPage() {
340
340
  const product = await useLoader(ProductLoader);
@@ -369,8 +369,18 @@ interface PaginationData {
369
369
  perPage: number;
370
370
  }
371
371
  export const Pagination = createVar<PaginationData>();
372
+
373
+ // Non-cacheable var — reading inside cache() or "use cache" throws at runtime
374
+ const Session = createVar<SessionData>({ cache: false });
372
375
  ```
373
376
 
377
+ `createVar` accepts an optional options object. The `cache` option (default
378
+ `true`) controls whether the var's values can be read inside cache scopes.
379
+ Write-level escalation is also supported: `ctx.set(Var, value, { cache: false })`
380
+ marks a specific write as non-cacheable even if the var itself is cacheable.
381
+ "Least cacheable wins" — if either says `cache: false`, the value throws on
382
+ read inside `cache()` or `"use cache"`.
383
+
374
384
  ### Producer (handler or middleware)
375
385
 
376
386
  ```typescript
@@ -414,26 +424,30 @@ Both approaches coexist: `ctx.get("user")` (global via Vars) and
414
424
  Handles have typed data:
415
425
 
416
426
  ```typescript
417
- // handles/breadcrumbs.ts
418
- import { createHandle } from "@rangojs/router";
419
-
420
- // All export patterns work: export const, const + export { X }, export { X as Y }
421
- export const Breadcrumbs = createHandle<{ label: string; href: string }>();
422
-
423
- // In route definition - use handle() DSL
424
- import { urls } from "@rangojs/router";
425
-
426
- export const urlpatterns = urls(({ path, handle }) => [
427
- path("/shop/product/:slug", ProductPage, { name: "product" }, () => [
428
- handle(Breadcrumbs, { label: "Products", href: "/shop/products" }),
429
- ]),
430
- ]);
431
-
432
- // In client - typed array
427
+ // Built-in Breadcrumbs handle — import from "@rangojs/router"
428
+ import { Breadcrumbs } from "@rangojs/router";
429
+ // Type: Handle<BreadcrumbItem, BreadcrumbItem[]>
430
+ // BreadcrumbItem: { label: string; href: string; content?: ReactNode | Promise<ReactNode> }
431
+
432
+ // In route handler — push is fully typed
433
+ path("/shop/product/:slug", (ctx) => {
434
+ const breadcrumb = ctx.use(Breadcrumbs);
435
+ breadcrumb({ label: "Products", href: "/shop/products" });
436
+ return <ProductPage />;
437
+ }, { name: "product" });
438
+
439
+ // In client — typed array
440
+ import { useHandle, Breadcrumbs } from "@rangojs/router/client";
433
441
  function BreadcrumbNav() {
434
442
  const crumbs = useHandle(Breadcrumbs);
435
- // crumbs: Array<{ label: string; href: string }>
443
+ // crumbs: BreadcrumbItem[]
436
444
  }
445
+
446
+ // Custom handles also work the same way
447
+ import { createHandle } from "@rangojs/router";
448
+ export const PageTitle = createHandle<string, string>(
449
+ (segments) => segments.flat().at(-1) ?? "Default Title"
450
+ );
437
451
  ```
438
452
 
439
453
  ## Ref Prop Type Safety (Loaders & Handles)
@@ -447,14 +461,14 @@ export const ProductLoader = createLoader(async (ctx) => {
447
461
  return { product: await fetchProduct(ctx.params.slug) };
448
462
  });
449
463
 
450
- // handles.ts
451
- export const Breadcrumbs = createHandle<{ label: string; href: string }>();
464
+ // Built-in Breadcrumbs — or any custom handle created with createHandle()
465
+ ```
452
466
 
467
+ ```tsx
453
468
  // Client component — typeof infers all generics
454
- ("use client");
455
- import { useLoader, useHandle } from "@rangojs/router/client";
469
+ "use client";
470
+ import { useLoader, useHandle, type Breadcrumbs } from "@rangojs/router/client";
456
471
  import type { ProductLoader } from "../loaders";
457
- import type { Breadcrumbs } from "../handles";
458
472
 
459
473
  function MyComponent({
460
474
  loader,