@rangojs/router 0.0.0-experimental.259 → 0.0.0-experimental.26

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 (225) hide show
  1. package/README.md +294 -28
  2. package/dist/bin/rango.js +355 -47
  3. package/dist/vite/index.js +1658 -1239
  4. package/package.json +3 -3
  5. package/skills/cache-guide/SKILL.md +9 -5
  6. package/skills/caching/SKILL.md +4 -4
  7. package/skills/document-cache/SKILL.md +2 -2
  8. package/skills/hooks/SKILL.md +40 -29
  9. package/skills/host-router/SKILL.md +218 -0
  10. package/skills/intercept/SKILL.md +79 -0
  11. package/skills/layout/SKILL.md +62 -2
  12. package/skills/loader/SKILL.md +229 -15
  13. package/skills/middleware/SKILL.md +109 -30
  14. package/skills/parallel/SKILL.md +57 -2
  15. package/skills/prerender/SKILL.md +189 -19
  16. package/skills/rango/SKILL.md +1 -2
  17. package/skills/response-routes/SKILL.md +3 -3
  18. package/skills/route/SKILL.md +44 -3
  19. package/skills/router-setup/SKILL.md +80 -3
  20. package/skills/theme/SKILL.md +5 -4
  21. package/skills/typesafety/SKILL.md +59 -16
  22. package/skills/use-cache/SKILL.md +16 -2
  23. package/src/__internal.ts +1 -1
  24. package/src/bin/rango.ts +56 -19
  25. package/src/browser/action-coordinator.ts +97 -0
  26. package/src/browser/event-controller.ts +29 -48
  27. package/src/browser/history-state.ts +80 -0
  28. package/src/browser/intercept-utils.ts +1 -1
  29. package/src/browser/link-interceptor.ts +19 -3
  30. package/src/browser/merge-segment-loaders.ts +9 -2
  31. package/src/browser/navigation-bridge.ts +66 -443
  32. package/src/browser/navigation-client.ts +34 -62
  33. package/src/browser/navigation-store.ts +4 -33
  34. package/src/browser/navigation-transaction.ts +295 -0
  35. package/src/browser/partial-update.ts +103 -151
  36. package/src/browser/prefetch/cache.ts +67 -0
  37. package/src/browser/prefetch/fetch.ts +137 -0
  38. package/src/browser/prefetch/observer.ts +65 -0
  39. package/src/browser/prefetch/policy.ts +42 -0
  40. package/src/browser/prefetch/queue.ts +88 -0
  41. package/src/browser/rango-state.ts +112 -0
  42. package/src/browser/react/Link.tsx +154 -44
  43. package/src/browser/react/NavigationProvider.tsx +32 -0
  44. package/src/browser/react/context.ts +6 -0
  45. package/src/browser/react/filter-segment-order.ts +11 -0
  46. package/src/browser/react/index.ts +2 -6
  47. package/src/browser/react/location-state-shared.ts +29 -11
  48. package/src/browser/react/location-state.ts +6 -4
  49. package/src/browser/react/nonce-context.ts +23 -0
  50. package/src/browser/react/shallow-equal.ts +27 -0
  51. package/src/browser/react/use-action.ts +23 -45
  52. package/src/browser/react/use-client-cache.ts +5 -3
  53. package/src/browser/react/use-handle.ts +21 -64
  54. package/src/browser/react/use-navigation.ts +7 -32
  55. package/src/browser/react/use-params.ts +5 -34
  56. package/src/browser/react/use-pathname.ts +2 -3
  57. package/src/browser/react/use-router.ts +3 -6
  58. package/src/browser/react/use-search-params.ts +2 -1
  59. package/src/browser/react/use-segments.ts +75 -114
  60. package/src/browser/response-adapter.ts +73 -0
  61. package/src/browser/rsc-router.tsx +46 -22
  62. package/src/browser/scroll-restoration.ts +10 -7
  63. package/src/browser/server-action-bridge.ts +458 -405
  64. package/src/browser/types.ts +21 -35
  65. package/src/browser/validate-redirect-origin.ts +29 -0
  66. package/src/build/generate-manifest.ts +38 -13
  67. package/src/build/generate-route-types.ts +4 -0
  68. package/src/build/index.ts +1 -0
  69. package/src/build/route-trie.ts +19 -3
  70. package/src/build/route-types/codegen.ts +13 -4
  71. package/src/build/route-types/include-resolution.ts +13 -0
  72. package/src/build/route-types/per-module-writer.ts +15 -3
  73. package/src/build/route-types/router-processing.ts +170 -18
  74. package/src/build/runtime-discovery.ts +13 -1
  75. package/src/cache/background-task.ts +34 -0
  76. package/src/cache/cache-key-utils.ts +44 -0
  77. package/src/cache/cache-policy.ts +125 -0
  78. package/src/cache/cache-runtime.ts +136 -123
  79. package/src/cache/cache-scope.ts +76 -83
  80. package/src/cache/cf/cf-cache-store.ts +12 -7
  81. package/src/cache/document-cache.ts +93 -69
  82. package/src/cache/handle-capture.ts +81 -0
  83. package/src/cache/index.ts +0 -15
  84. package/src/cache/memory-segment-store.ts +43 -69
  85. package/src/cache/profile-registry.ts +43 -8
  86. package/src/cache/read-through-swr.ts +134 -0
  87. package/src/cache/segment-codec.ts +140 -117
  88. package/src/cache/taint.ts +30 -3
  89. package/src/cache/types.ts +1 -115
  90. package/src/client.rsc.tsx +0 -1
  91. package/src/client.tsx +53 -76
  92. package/src/errors.ts +6 -1
  93. package/src/handle.ts +1 -1
  94. package/src/handles/MetaTags.tsx +5 -2
  95. package/src/host/cookie-handler.ts +8 -3
  96. package/src/host/index.ts +0 -3
  97. package/src/host/router.ts +14 -1
  98. package/src/href-client.ts +3 -1
  99. package/src/index.rsc.ts +53 -10
  100. package/src/index.ts +73 -43
  101. package/src/loader.rsc.ts +12 -4
  102. package/src/loader.ts +8 -0
  103. package/src/prerender/store.ts +60 -18
  104. package/src/prerender.ts +76 -18
  105. package/src/reverse.ts +11 -7
  106. package/src/root-error-boundary.tsx +30 -26
  107. package/src/route-definition/dsl-helpers.ts +9 -6
  108. package/src/route-definition/index.ts +0 -3
  109. package/src/route-definition/redirect.ts +15 -3
  110. package/src/route-map-builder.ts +38 -2
  111. package/src/route-name.ts +53 -0
  112. package/src/route-types.ts +7 -0
  113. package/src/router/content-negotiation.ts +1 -1
  114. package/src/router/debug-manifest.ts +16 -3
  115. package/src/router/handler-context.ts +96 -17
  116. package/src/router/intercept-resolution.ts +6 -4
  117. package/src/router/lazy-includes.ts +4 -0
  118. package/src/router/loader-resolution.ts +6 -11
  119. package/src/router/logging.ts +100 -3
  120. package/src/router/manifest.ts +32 -3
  121. package/src/router/match-api.ts +62 -54
  122. package/src/router/match-context.ts +3 -0
  123. package/src/router/match-handlers.ts +185 -11
  124. package/src/router/match-middleware/background-revalidation.ts +65 -85
  125. package/src/router/match-middleware/cache-lookup.ts +78 -10
  126. package/src/router/match-middleware/cache-store.ts +2 -0
  127. package/src/router/match-pipelines.ts +8 -43
  128. package/src/router/match-result.ts +0 -9
  129. package/src/router/metrics.ts +233 -13
  130. package/src/router/middleware-types.ts +34 -39
  131. package/src/router/middleware.ts +290 -130
  132. package/src/router/pattern-matching.ts +61 -10
  133. package/src/router/prerender-match.ts +36 -6
  134. package/src/router/preview-match.ts +7 -1
  135. package/src/router/revalidation.ts +61 -2
  136. package/src/router/router-context.ts +15 -0
  137. package/src/router/router-interfaces.ts +158 -40
  138. package/src/router/router-options.ts +223 -1
  139. package/src/router/router-registry.ts +5 -2
  140. package/src/router/segment-resolution/fresh.ts +165 -242
  141. package/src/router/segment-resolution/helpers.ts +263 -0
  142. package/src/router/segment-resolution/loader-cache.ts +102 -98
  143. package/src/router/segment-resolution/revalidation.ts +394 -272
  144. package/src/router/segment-resolution/static-store.ts +2 -2
  145. package/src/router/segment-resolution.ts +1 -3
  146. package/src/router/segment-wrappers.ts +3 -0
  147. package/src/router/telemetry-otel.ts +299 -0
  148. package/src/router/telemetry.ts +300 -0
  149. package/src/router/timeout.ts +148 -0
  150. package/src/router/trie-matching.ts +20 -2
  151. package/src/router/types.ts +7 -1
  152. package/src/router.ts +203 -18
  153. package/src/rsc/handler-context.ts +13 -2
  154. package/src/rsc/handler.ts +489 -438
  155. package/src/rsc/helpers.ts +125 -5
  156. package/src/rsc/index.ts +0 -20
  157. package/src/rsc/loader-fetch.ts +84 -42
  158. package/src/rsc/manifest-init.ts +3 -2
  159. package/src/rsc/origin-guard.ts +141 -0
  160. package/src/rsc/progressive-enhancement.ts +245 -19
  161. package/src/rsc/response-route-handler.ts +347 -0
  162. package/src/rsc/rsc-rendering.ts +47 -43
  163. package/src/rsc/runtime-warnings.ts +42 -0
  164. package/src/rsc/server-action.ts +166 -66
  165. package/src/rsc/ssr-setup.ts +128 -0
  166. package/src/rsc/types.ts +20 -2
  167. package/src/search-params.ts +38 -23
  168. package/src/server/context.ts +61 -7
  169. package/src/server/cookie-store.ts +190 -0
  170. package/src/server/fetchable-loader-store.ts +11 -6
  171. package/src/server/handle-store.ts +84 -12
  172. package/src/server/loader-registry.ts +11 -46
  173. package/src/server/request-context.ts +275 -49
  174. package/src/server.ts +6 -0
  175. package/src/ssr/index.tsx +67 -28
  176. package/src/static-handler.ts +7 -0
  177. package/src/theme/ThemeProvider.tsx +6 -1
  178. package/src/theme/index.ts +4 -18
  179. package/src/theme/theme-context.ts +1 -28
  180. package/src/theme/theme-script.ts +2 -1
  181. package/src/types/cache-types.ts +6 -1
  182. package/src/types/error-types.ts +3 -0
  183. package/src/types/global-namespace.ts +22 -0
  184. package/src/types/handler-context.ts +103 -16
  185. package/src/types/index.ts +1 -1
  186. package/src/types/loader-types.ts +9 -6
  187. package/src/types/route-config.ts +17 -26
  188. package/src/types/route-entry.ts +28 -0
  189. package/src/types/segments.ts +0 -5
  190. package/src/urls/include-helper.ts +49 -8
  191. package/src/urls/index.ts +1 -0
  192. package/src/urls/path-helper-types.ts +30 -12
  193. package/src/urls/path-helper.ts +17 -2
  194. package/src/urls/pattern-types.ts +21 -1
  195. package/src/urls/response-types.ts +29 -7
  196. package/src/urls/type-extraction.ts +23 -15
  197. package/src/use-loader.tsx +27 -9
  198. package/src/vite/discovery/bundle-postprocess.ts +32 -52
  199. package/src/vite/discovery/discover-routers.ts +52 -26
  200. package/src/vite/discovery/prerender-collection.ts +58 -41
  201. package/src/vite/discovery/route-types-writer.ts +7 -7
  202. package/src/vite/discovery/state.ts +7 -7
  203. package/src/vite/discovery/virtual-module-codegen.ts +5 -2
  204. package/src/vite/index.ts +10 -51
  205. package/src/vite/plugins/client-ref-dedup.ts +115 -0
  206. package/src/vite/plugins/client-ref-hashing.ts +3 -3
  207. package/src/vite/plugins/expose-internal-ids.ts +4 -3
  208. package/src/vite/plugins/refresh-cmd.ts +65 -0
  209. package/src/vite/plugins/use-cache-transform.ts +91 -3
  210. package/src/vite/plugins/version-plugin.ts +188 -18
  211. package/src/vite/rango.ts +61 -36
  212. package/src/vite/router-discovery.ts +173 -100
  213. package/src/vite/utils/prerender-utils.ts +81 -0
  214. package/src/vite/utils/shared-utils.ts +19 -9
  215. package/skills/testing/SKILL.md +0 -226
  216. package/src/browser/lru-cache.ts +0 -61
  217. package/src/browser/react/prefetch.ts +0 -27
  218. package/src/browser/request-controller.ts +0 -164
  219. package/src/cache/memory-store.ts +0 -253
  220. package/src/href-context.ts +0 -33
  221. package/src/route-definition/route-function.ts +0 -119
  222. package/src/router.gen.ts +0 -6
  223. package/src/static-handler.gen.ts +0 -5
  224. package/src/urls.gen.ts +0 -8
  225. /package/{CLAUDE.md → AGENTS.md} +0 -0
package/src/rsc/types.ts CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  import type { ResolvedSegment, SlotState } from "../types.js";
9
9
  import type { HandleData } from "../server/handle-store.js";
10
- import type { RSCRouter } from "../router.js";
10
+ import type { RSCRouterInternal } from "../router/router-interfaces.js";
11
11
  import type { ResolvedThemeConfig, Theme } from "../theme/types.js";
12
12
 
13
13
  /**
@@ -114,6 +114,14 @@ export interface SSRRenderOptions {
114
114
  * Nonce for Content Security Policy (CSP)
115
115
  */
116
116
  nonce?: string;
117
+
118
+ /**
119
+ * SSR stream mode.
120
+ *
121
+ * - `"stream"` (default) — start flushing HTML immediately.
122
+ * - `"allReady"` — await `stream.allReady` before returning.
123
+ */
124
+ streamMode?: import("../router/router-options.js").SSRStreamMode;
117
125
  }
118
126
 
119
127
  /**
@@ -161,7 +169,7 @@ export interface CreateRSCHandlerOptions<
161
169
  /**
162
170
  * The RSC router instance
163
171
  */
164
- router: RSCRouter<TEnv, TRoutes>;
172
+ router: RSCRouterInternal<TEnv, TRoutes>;
165
173
 
166
174
  /**
167
175
  * RSC dependencies from @vitejs/plugin-rsc/rsc.
@@ -238,6 +246,16 @@ export interface CreateRSCHandlerOptions<
238
246
  * nonce: (request, env) => env.nonce,
239
247
  * });
240
248
  * ```
249
+ *
250
+ * @example Access nonce in middleware
251
+ * ```tsx
252
+ * import { nonce } from "@rangojs/router";
253
+ *
254
+ * const cspMiddleware: Middleware = async (ctx, next) => {
255
+ * const value = ctx.get(nonce); // string | undefined
256
+ * await next();
257
+ * };
258
+ * ```
241
259
  */
242
260
  nonce?: NonceProvider<TEnv>;
243
261
  }
@@ -55,14 +55,22 @@ type Simplify<T> = { [K in keyof T]: T[K] };
55
55
  /**
56
56
  * Resolve a SearchSchema to its typed object.
57
57
  *
58
+ * Both required and optional params resolve to `T | undefined` at the handler
59
+ * level. The required/optional distinction is a consumer-facing contract
60
+ * (e.g., for href() and reverse() autocomplete) — it tells callers which
61
+ * params the route expects, but the handler must still check for undefined
62
+ * since the framework cannot trust the client to send all required params.
63
+ *
58
64
  * @example
59
65
  * type S = { q: "string"; page: "number?"; sort: "string?" };
60
66
  * type R = ResolveSearchSchema<S>;
61
- * // { q: string; page?: number; sort?: string }
67
+ * // { q: string | undefined; page?: number; sort?: string }
62
68
  */
63
69
  export type ResolveSearchSchema<T extends SearchSchema> = Simplify<
64
70
  {
65
- [K in RequiredKeys<T> & string]: ResolveBaseType<BaseType<T[K]>>;
71
+ [K in RequiredKeys<T> & string]:
72
+ | ResolveBaseType<BaseType<T[K]>>
73
+ | undefined;
66
74
  } & {
67
75
  [K in OptionalKeys<T> & string]?: ResolveBaseType<BaseType<T[K]>>;
68
76
  }
@@ -127,20 +135,32 @@ type ExtractRouteParamsFromMap<TRouteMap, TName> = TName extends keyof TRouteMap
127
135
  : {}
128
136
  : {};
129
137
 
138
+ /** Parse "a|b|c" into "a" | "b" | "c" */
139
+ type ParseConstraint<T extends string> =
140
+ T extends `${infer First}|${infer Rest}` ? First | ParseConstraint<Rest> : T;
141
+
130
142
  /** Minimal inline param extraction (avoids importing from types.ts to prevent circular deps). */
131
143
  type ExtractParamsFromPattern<T extends string> =
132
144
  T extends `${string}:${infer Param}/${infer Rest}`
133
- ? Param extends `${infer Name}?`
134
- ? { [K in Name]?: string } & ExtractParamsFromPattern<`/${Rest}`>
135
- : Param extends `${infer Name}(${string})`
136
- ? { [K in Name]: string } & ExtractParamsFromPattern<`/${Rest}`>
137
- : { [K in Param]: string } & ExtractParamsFromPattern<`/${Rest}`>
145
+ ? Param extends `${infer Name}(${infer C})?`
146
+ ? {
147
+ [K in Name]?: ParseConstraint<C>;
148
+ } & ExtractParamsFromPattern<`/${Rest}`>
149
+ : Param extends `${infer Name}(${infer C})`
150
+ ? {
151
+ [K in Name]: ParseConstraint<C>;
152
+ } & ExtractParamsFromPattern<`/${Rest}`>
153
+ : Param extends `${infer Name}?`
154
+ ? { [K in Name]?: string } & ExtractParamsFromPattern<`/${Rest}`>
155
+ : { [K in Param]: string } & ExtractParamsFromPattern<`/${Rest}`>
138
156
  : T extends `${string}:${infer Param}`
139
- ? Param extends `${infer Name}?`
140
- ? { [K in Name]?: string }
141
- : Param extends `${infer Name}(${string})`
142
- ? { [K in Name]: string }
143
- : { [K in Param]: string }
157
+ ? Param extends `${infer Name}(${infer C})?`
158
+ ? { [K in Name]?: ParseConstraint<C> }
159
+ : Param extends `${infer Name}(${infer C})`
160
+ ? { [K in Name]: ParseConstraint<C> }
161
+ : Param extends `${infer Name}?`
162
+ ? { [K in Name]?: string }
163
+ : { [K in Param]: string }
144
164
  : {};
145
165
 
146
166
  // ============================================================================
@@ -154,7 +174,9 @@ type ExtractParamsFromPattern<T extends string> =
154
174
  * - `"number"` / `"number?"` - coerced via `Number()`; NaN treated as missing
155
175
  * - `"boolean"` / `"boolean?"` - `"true"` / `"1"` -> true, `"false"` / `"0"` / `""` -> false
156
176
  *
157
- * Missing required params are set to their zero value (empty string / 0 / false).
177
+ * Missing params (both required and optional) are omitted from the result
178
+ * (undefined). The required/optional distinction is a consumer-facing contract
179
+ * only — the handler must check for undefined.
158
180
  */
159
181
  export function parseSearchParams<T extends SearchSchema>(
160
182
  searchParams: URLSearchParams,
@@ -168,13 +190,7 @@ export function parseSearchParams<T extends SearchSchema>(
168
190
  const raw = searchParams.get(key);
169
191
 
170
192
  if (raw === null) {
171
- if (!isOptional) {
172
- // Required param missing: use zero value
173
- if (baseType === "string") result[key] = "";
174
- else if (baseType === "number") result[key] = 0;
175
- else if (baseType === "boolean") result[key] = false;
176
- }
177
- // Optional params are omitted (undefined)
193
+ // Missing params are omitted (undefined) regardless of required/optional
178
194
  continue;
179
195
  }
180
196
 
@@ -182,11 +198,10 @@ export function parseSearchParams<T extends SearchSchema>(
182
198
  result[key] = raw;
183
199
  } else if (baseType === "number") {
184
200
  const num = Number(raw);
185
- if (Number.isNaN(num)) {
186
- if (!isOptional) result[key] = 0;
187
- } else {
201
+ if (!Number.isNaN(num)) {
188
202
  result[key] = num;
189
203
  }
204
+ // NaN treated as missing (undefined)
190
205
  } else if (baseType === "boolean") {
191
206
  result[key] = raw === "true" || raw === "1";
192
207
  }
@@ -11,6 +11,7 @@ import type {
11
11
  TransitionConfig,
12
12
  } from "../types";
13
13
  import { invariant } from "../errors";
14
+ import type { DefaultRouteName } from "../types/global-namespace.js";
14
15
 
15
16
  // ============================================================================
16
17
  // Performance Metrics Types
@@ -25,6 +26,7 @@ export interface PerformanceMetric {
25
26
  label: string; // e.g., "route-matching", "loader:UserLoader"
26
27
  duration: number; // milliseconds
27
28
  startTime: number; // relative to request start
29
+ depth?: number; // nesting level for hierarchical display (0 = top-level)
28
30
  }
29
31
 
30
32
  /**
@@ -120,6 +122,8 @@ export type InterceptSelectorContext<TEnv = any> = {
120
122
  request: Request; // The HTTP request object
121
123
  env: TEnv; // Platform bindings (Cloudflare env, etc.)
122
124
  segments: InterceptSegmentsState; // Client's current segments (where navigating FROM)
125
+ fromRouteName?: DefaultRouteName; // Named route being navigated away from (undefined for unnamed routes)
126
+ toRouteName?: DefaultRouteName; // Named route being navigated to (undefined for unnamed routes)
123
127
  };
124
128
 
125
129
  /**
@@ -254,10 +258,18 @@ interface HelperContext {
254
258
  urlPrefix?: string;
255
259
  /** Name prefix from include() - applied to all named routes */
256
260
  namePrefix?: string;
261
+ /** True when this scope is at root level (no named include boundary above).
262
+ * Routes at root scope allow dot-local reverse to fall back to bare names. */
263
+ rootScoped?: boolean;
257
264
  /** Run helper for cleaner middleware code */
258
265
  run?: <T>(fn: () => T | Promise<T>) => T | Promise<T>;
259
266
  /** Tracked includes for build-time manifest generation */
260
267
  trackedIncludes?: TrackedInclude[];
268
+ /** Cache profiles for DSL-time cache("profileName") resolution */
269
+ cacheProfiles?: Record<
270
+ string,
271
+ import("../cache/profile-registry.js").CacheProfile
272
+ >;
261
273
  }
262
274
  // Use a global symbol key so the AsyncLocalStorage instance survives HMR
263
275
  // module re-evaluation. Without this, Vite's RSC module runner may create
@@ -399,7 +411,9 @@ export const getContext = (): {
399
411
  searchSchemas: store.searchSchemas,
400
412
  urlPrefix: store.urlPrefix,
401
413
  namePrefix: store.namePrefix,
414
+ rootScoped: store.rootScoped,
402
415
  trackedIncludes: store.trackedIncludes,
416
+ cacheProfiles: store.cacheProfiles,
403
417
  },
404
418
  callback,
405
419
  );
@@ -436,7 +450,9 @@ export const getContext = (): {
436
450
  searchSchemas,
437
451
  urlPrefix: store?.urlPrefix,
438
452
  namePrefix: store?.namePrefix,
453
+ rootScoped: store?.rootScoped,
439
454
  trackedIncludes: store?.trackedIncludes,
455
+ cacheProfiles: store?.cacheProfiles,
440
456
  },
441
457
  callback,
442
458
  );
@@ -469,17 +485,41 @@ export function runWithPrefixes<T>(
469
485
  } else {
470
486
  combinedUrlPrefix = urlPrefix;
471
487
  }
472
- const combinedNamePrefix = namePrefix
473
- ? store.namePrefix
474
- ? `${store.namePrefix}.${namePrefix}`
475
- : namePrefix
476
- : store.namePrefix;
488
+ const combinedNamePrefix =
489
+ namePrefix !== undefined
490
+ ? namePrefix === ""
491
+ ? store.namePrefix
492
+ : store.namePrefix
493
+ ? `${store.namePrefix}.${namePrefix}`
494
+ : namePrefix
495
+ : store.namePrefix;
496
+
497
+ // Track root scope for dot-local reverse resolution.
498
+ //
499
+ // The flag answers: "can this route reach bare names at root scope?"
500
+ // It propagates through the include chain:
501
+ //
502
+ // { name: "" } — transparent: inherit parent, default true
503
+ // { name: "foo" } — inherit parent if already set, else create boundary (false)
504
+ // no name — inherit parent unchanged
505
+ //
506
+ // This means { name: "" } + nested { name: "sub" } keeps rootScoped=true
507
+ // (the outer transparent include establishes root access, and the inner
508
+ // named include inherits it). But a direct { name: "sub" } at root gets
509
+ // rootScoped=false (no prior root-access grant, so it creates a boundary).
510
+ const combinedRootScoped =
511
+ namePrefix === ""
512
+ ? (store.rootScoped ?? true)
513
+ : namePrefix !== undefined
514
+ ? (store.rootScoped ?? false)
515
+ : store.rootScoped;
477
516
 
478
517
  return RSCRouterContext.run(
479
518
  {
480
519
  ...store,
481
520
  urlPrefix: combinedUrlPrefix,
482
521
  namePrefix: combinedNamePrefix,
522
+ rootScoped: combinedRootScoped,
483
523
  },
484
524
  callback,
485
525
  );
@@ -501,6 +541,15 @@ export function getNamePrefix(): string | undefined {
501
541
  return store?.namePrefix;
502
542
  }
503
543
 
544
+ /**
545
+ * Get whether the current scope is at root level (no named include boundary above).
546
+ * Returns true at root or inside { name: "" } includes, false inside named includes.
547
+ */
548
+ export function getRootScoped(): boolean {
549
+ const store = RSCRouterContext.getStore();
550
+ return store?.rootScoped ?? true;
551
+ }
552
+
504
553
  // Export HelperContext type for use in other modules
505
554
  export type { HelperContext };
506
555
 
@@ -519,7 +568,7 @@ export type { HelperContext };
519
568
  * done(); // Records duration
520
569
  * ```
521
570
  */
522
- export function track(label: string): () => void {
571
+ export function track(label: string, depth?: number): () => void {
523
572
  const store = RSCRouterContext.getStore();
524
573
 
525
574
  // No-op if context unavailable or metrics not enabled
@@ -532,6 +581,11 @@ export function track(label: string): () => void {
532
581
  return () => {
533
582
  const duration =
534
583
  performance.now() - store.metrics!.requestStart - startTime;
535
- store.metrics!.metrics.push({ label, duration, startTime });
584
+ store.metrics!.metrics.push({
585
+ label,
586
+ duration,
587
+ startTime,
588
+ ...(depth != null ? { depth } : {}),
589
+ });
536
590
  };
537
591
  }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Cookie Store — Next.js-style cookie facade backed by the response-derived model.
3
+ *
4
+ * `cookies()` returns a CookieStore scoped to the current request.
5
+ * Reads merge the original Cookie header with Set-Cookie mutations
6
+ * already queued on the response stub (last-write-wins).
7
+ * Writes append Set-Cookie to the response stub.
8
+ */
9
+
10
+ import type { CookieOptions } from "../router/middleware-types.js";
11
+ import { getRequestContext } from "./request-context.js";
12
+ import { INSIDE_CACHE_EXEC } from "../cache/taint.js";
13
+
14
+ /**
15
+ * A single cookie entry returned by get() and getAll().
16
+ */
17
+ export interface Cookie {
18
+ name: string;
19
+ value: string;
20
+ }
21
+
22
+ /**
23
+ * Request-scoped cookie store.
24
+ *
25
+ * Reads see the effective merged view (original request + same-request mutations).
26
+ * Writes append Set-Cookie headers to the shared response stub.
27
+ */
28
+ export interface CookieStore {
29
+ /** Get a single cookie by name. Returns undefined if not set or deleted. */
30
+ get(name: string): Cookie | undefined;
31
+
32
+ /** Get all effective cookies, or all cookies with a given name. */
33
+ getAll(name?: string): Cookie[];
34
+
35
+ /** Check whether a cookie exists in the effective view. */
36
+ has(name: string): boolean;
37
+
38
+ /** Set a cookie (appends Set-Cookie to the response stub). */
39
+ set(name: string, value: string, options?: CookieOptions): void;
40
+
41
+ /** Delete a cookie (appends Set-Cookie with maxAge=0 to the response stub). */
42
+ delete(name: string, options?: Pick<CookieOptions, "domain" | "path">): void;
43
+ }
44
+
45
+ /**
46
+ * Get the request-scoped cookie store.
47
+ *
48
+ * Must be called inside a request context (middleware, handler, loader, action).
49
+ * Throws if called outside request scope.
50
+ *
51
+ * @example
52
+ * ```typescript
53
+ * import { cookies } from "@rangojs/router";
54
+ *
55
+ * // In a handler, loader, or action:
56
+ * const session = cookies().get("session")?.value;
57
+ * cookies().set("session", "new-token", { httpOnly: true });
58
+ * cookies().delete("session");
59
+ * ```
60
+ */
61
+ export function cookies(): CookieStore {
62
+ const ctx = getRequestContext();
63
+ assertNotInsideCacheContext(ctx, "cookies");
64
+ return createCookieStore(ctx);
65
+ }
66
+
67
+ /**
68
+ * Read-only view of HTTP headers.
69
+ * Exposes only the read methods of the Headers API.
70
+ */
71
+ export interface ReadonlyHeaders {
72
+ get(name: string): string | null;
73
+ has(name: string): boolean;
74
+ entries(): HeadersIterator<[string, string]>;
75
+ keys(): HeadersIterator<string>;
76
+ values(): HeadersIterator<string>;
77
+ forEach(
78
+ callback: (value: string, name: string, parent: ReadonlyHeaders) => void,
79
+ ): void;
80
+ [Symbol.iterator](): HeadersIterator<[string, string]>;
81
+ }
82
+
83
+ // Minimal iterator interface (avoids pulling IterableIterator from lib.dom)
84
+ type HeadersIterator<T> = IterableIterator<T>;
85
+
86
+ /**
87
+ * Throw if called inside a "use cache" function.
88
+ * Reading request-scoped data (cookies, headers) inside a cached function
89
+ * produces results that vary per request but the cache key does not include
90
+ * those values, leading to one user's data being served to another.
91
+ */
92
+ function assertNotInsideCacheContext(ctx: unknown, fnName: string): void {
93
+ if (
94
+ ctx !== null &&
95
+ ctx !== undefined &&
96
+ typeof ctx === "object" &&
97
+ (INSIDE_CACHE_EXEC as symbol) in (ctx as Record<symbol, unknown>)
98
+ ) {
99
+ throw new Error(
100
+ `${fnName}() cannot be called inside a "use cache" function. ` +
101
+ `Request-scoped data (cookies, headers) varies per request but is not ` +
102
+ `reflected in the cache key, so cached results would be served to the ` +
103
+ `wrong users. Extract the value before the cached function and pass it ` +
104
+ `as an argument:\n\n` +
105
+ ` const locale = cookies().get("locale")?.value ?? "en";\n` +
106
+ ` const data = await getCachedData(locale); // locale is now in the cache key`,
107
+ );
108
+ }
109
+ }
110
+
111
+ const HEADERS_MUTATION_METHODS = new Set(["set", "append", "delete"]);
112
+
113
+ /**
114
+ * Get the original request headers (read-only).
115
+ *
116
+ * Must be called inside a request context.
117
+ * Returns a read-only view of the incoming request's headers.
118
+ * Mutation methods (set, append, delete) throw at runtime.
119
+ *
120
+ * @example
121
+ * ```typescript
122
+ * import { headers } from "@rangojs/router";
123
+ *
124
+ * const auth = headers().get("authorization");
125
+ * const contentType = headers().get("content-type");
126
+ * ```
127
+ */
128
+ export function headers(): ReadonlyHeaders {
129
+ const ctx = getRequestContext();
130
+ assertNotInsideCacheContext(ctx, "headers");
131
+ return new Proxy(ctx.request.headers, {
132
+ get(target, prop, receiver) {
133
+ if (typeof prop === "string" && HEADERS_MUTATION_METHODS.has(prop)) {
134
+ return () => {
135
+ throw new Error(
136
+ `headers().${prop}() is not allowed. headers() returns a read-only view of request headers. ` +
137
+ `Use ctx.header() to set response headers.`,
138
+ );
139
+ };
140
+ }
141
+ const value = Reflect.get(target, prop, receiver);
142
+ return typeof value === "function" ? value.bind(target) : value;
143
+ },
144
+ }) as unknown as ReadonlyHeaders;
145
+ }
146
+
147
+ /**
148
+ * Create a CookieStore backed by a RequestContext.
149
+ * @internal Shared between cookies() shorthand and context methods.
150
+ */
151
+ function createCookieStore(ctx: {
152
+ cookie(name: string): string | undefined;
153
+ cookies(): Record<string, string>;
154
+ setCookie(name: string, value: string, options?: CookieOptions): void;
155
+ deleteCookie(
156
+ name: string,
157
+ options?: Pick<CookieOptions, "domain" | "path">,
158
+ ): void;
159
+ }): CookieStore {
160
+ return {
161
+ get(name: string): Cookie | undefined {
162
+ const value = ctx.cookie(name);
163
+ return value !== undefined ? { name, value } : undefined;
164
+ },
165
+
166
+ getAll(name?: string): Cookie[] {
167
+ const all = ctx.cookies();
168
+ if (name !== undefined) {
169
+ const value = all[name];
170
+ return value !== undefined ? [{ name, value }] : [];
171
+ }
172
+ return Object.entries(all).map(([n, v]) => ({ name: n, value: v }));
173
+ },
174
+
175
+ has(name: string): boolean {
176
+ return ctx.cookie(name) !== undefined;
177
+ },
178
+
179
+ set(name: string, value: string, options?: CookieOptions): void {
180
+ ctx.setCookie(name, value, options);
181
+ },
182
+
183
+ delete(
184
+ name: string,
185
+ options?: Pick<CookieOptions, "domain" | "path">,
186
+ ): void {
187
+ ctx.deleteCookie(name, options);
188
+ },
189
+ };
190
+ }
@@ -12,21 +12,26 @@
12
12
  import type { LoaderFn } from "../types.js";
13
13
  import type { MiddlewareFn } from "../router/middleware.js";
14
14
 
15
- const fetchableLoaderRegistry = new Map<
16
- string,
17
- { fn: LoaderFn<any, any, any>; middleware: MiddlewareFn[] }
18
- >();
15
+ export interface LoaderRegistryEntry {
16
+ fn: LoaderFn<any, any, any>;
17
+ middleware: MiddlewareFn[];
18
+ /** Whether this loader is fetchable via the _rsc_loader endpoint. */
19
+ fetchable: boolean;
20
+ }
21
+
22
+ const fetchableLoaderRegistry = new Map<string, LoaderRegistryEntry>();
19
23
 
20
24
  export function registerFetchableLoader(
21
25
  id: string,
22
26
  fn: LoaderFn<any, any, any>,
23
27
  middleware: MiddlewareFn[],
28
+ fetchable: boolean,
24
29
  ): void {
25
- fetchableLoaderRegistry.set(id, { fn, middleware });
30
+ fetchableLoaderRegistry.set(id, { fn, middleware, fetchable });
26
31
  }
27
32
 
28
33
  export function getFetchableLoader(
29
34
  id: string,
30
- ): { fn: LoaderFn<any, any, any>; middleware: MiddlewareFn[] } | undefined {
35
+ ): LoaderRegistryEntry | undefined {
31
36
  return fetchableLoaderRegistry.get(id);
32
37
  }
@@ -13,6 +13,19 @@
13
13
  */
14
14
  export type HandleData = Record<string, Record<string, unknown[]>>;
15
15
 
16
+ function createLateHandlePushError(
17
+ handleName: string,
18
+ segmentId: string,
19
+ ): Error {
20
+ const error = new Error(
21
+ `Handle "${handleName}" for segment "${segmentId}" was pushed after handle collection completed. ` +
22
+ `This usually means an async JSX subtree suspended and later tried to push a handle during streaming. ` +
23
+ `Push handles from the route/layout handler or during the initial synchronous JSX render instead.`,
24
+ );
25
+ error.name = "LateHandlePushError";
26
+ return error;
27
+ }
28
+
16
29
  /**
17
30
  * Deep clone handle data to create a snapshot.
18
31
  * @internal
@@ -44,11 +57,26 @@ export interface HandleStore {
44
57
  track<T>(promise: Promise<T>): Promise<T>;
45
58
 
46
59
  /**
47
- * Promise that resolves when all tracked handlers have settled.
48
- * Does not reject - uses Promise.allSettled internally.
60
+ * Signal that no more track() calls will be made.
61
+ * settled will not resolve until seal() is called AND all tracked
62
+ * promises have settled. Calling stream() or getData() auto-seals.
63
+ */
64
+ seal(): void;
65
+
66
+ /**
67
+ * Promise that resolves when the store is sealed AND all tracked
68
+ * handlers have settled.
49
69
  */
50
70
  readonly settled: Promise<void>;
51
71
 
72
+ /**
73
+ * Optional error callback for late streaming-handle failures.
74
+ * Called when push() throws LateHandlePushError (handle pushed after
75
+ * stream completion). Allows the router to surface these errors
76
+ * to onError and telemetry.
77
+ */
78
+ onError?: (error: Error) => void;
79
+
52
80
  /**
53
81
  * Push handle data for a specific handle and segment.
54
82
  * Multiple pushes to the same handle/segment accumulate in an array.
@@ -58,9 +86,7 @@ export interface HandleStore {
58
86
 
59
87
  /**
60
88
  * Get all collected handle data after all handlers have settled.
61
- * Returns a promise that waits for `settled`, then returns the data.
62
- * The data may contain unresolved promises which RSC will stream.
63
- * @deprecated Use stream() for progressive updates
89
+ * Waits for `settled`, then returns the finalized data.
64
90
  */
65
91
  getData(): Promise<HandleData>;
66
92
 
@@ -108,9 +134,31 @@ export interface HandleStore {
108
134
  * ```
109
135
  */
110
136
  export function createHandleStore(): HandleStore {
111
- const pending: Promise<unknown>[] = [];
112
137
  const data: HandleData = {};
113
138
 
139
+ // Settlement barrier: resolved only when sealed AND inflight === 0.
140
+ // seal() signals "no more track() calls". Each track() increments
141
+ // inflightCount, each promise.finally() decrements. settled resolves
142
+ // once both conditions are met — even if tracks are added while
143
+ // earlier ones are still in flight.
144
+ let sealed = false;
145
+ let inflightCount = 0;
146
+ let drainWaiters: (() => void)[] = [];
147
+
148
+ function notifyDrain() {
149
+ if (sealed && inflightCount === 0 && drainWaiters.length > 0) {
150
+ const waiters = drainWaiters;
151
+ drainWaiters = [];
152
+ for (const resolve of waiters) resolve();
153
+ }
154
+ }
155
+
156
+ function sealInternal() {
157
+ if (sealed) return;
158
+ sealed = true;
159
+ notifyDrain();
160
+ }
161
+
114
162
  // Queue for pending emissions and resolver for waiting consumer
115
163
  let pendingEmissions: HandleData[] = [];
116
164
  let emissionResolver: (() => void) | null = null;
@@ -137,18 +185,38 @@ export function createHandleStore(): HandleStore {
137
185
 
138
186
  return {
139
187
  track<T>(promise: Promise<T>): Promise<T> {
140
- pending.push(promise);
188
+ inflightCount++;
189
+ // Use .then(onSettle, onSettle) instead of .finally() to avoid
190
+ // creating an unhandled rejection branch when the tracked promise
191
+ // rejects (e.g. error route handlers). .finally() re-throws the
192
+ // rejection on a new branch that nobody catches, which can crash
193
+ // the server process.
194
+ const onSettle = () => {
195
+ inflightCount--;
196
+ notifyDrain();
197
+ };
198
+ promise.then(onSettle, onSettle);
141
199
  return promise;
142
200
  },
143
201
 
202
+ seal() {
203
+ sealInternal();
204
+ },
205
+
144
206
  get settled(): Promise<void> {
145
- if (pending.length === 0) {
146
- return Promise.resolve();
147
- }
148
- return Promise.allSettled(pending).then(() => {});
207
+ if (sealed && inflightCount === 0) return Promise.resolve();
208
+ return new Promise<void>((resolve) => {
209
+ drainWaiters.push(resolve);
210
+ });
149
211
  },
150
212
 
151
213
  push(handleName: string, segmentId: string, value: unknown): void {
214
+ if (completed) {
215
+ const error = createLateHandlePushError(handleName, segmentId);
216
+ if (this.onError) this.onError(error);
217
+ throw error;
218
+ }
219
+
152
220
  if (!data[handleName]) {
153
221
  data[handleName] = {};
154
222
  }
@@ -163,10 +231,14 @@ export function createHandleStore(): HandleStore {
163
231
  },
164
232
 
165
233
  getData(): Promise<HandleData> {
166
- return this.settled.then(() => data);
234
+ sealInternal();
235
+ return this.settled.then(() => cloneHandleData(data));
167
236
  },
168
237
 
169
238
  async *stream(): AsyncGenerator<HandleData, void, unknown> {
239
+ // Auto-seal: stream() is called after all track() registrations.
240
+ sealInternal();
241
+
170
242
  // Set up completion handler
171
243
  this.settled.then(() => {
172
244
  completed = true;