@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
@@ -121,7 +121,7 @@ export interface NavigationState {
121
121
  /** Whether RSC data is currently streaming (initial load or navigation) */
122
122
  isStreaming: boolean;
123
123
 
124
- /** Current location (updated optimistically) */
124
+ /** Current location */
125
125
  location: NavigationLocation;
126
126
 
127
127
  /** URL being navigated to (null when idle) */
@@ -178,7 +178,7 @@ export type ActionStateListener = (state: TrackedActionState) => void;
178
178
 
179
179
  /**
180
180
  * Cache interface for storing segments
181
- * Compatible with both Map and LRUCache
181
+ * Compatible with Map
182
182
  *
183
183
  * @internal This type is an implementation detail and may change without notice.
184
184
  */
@@ -215,7 +215,7 @@ export interface NavigationUpdate {
215
215
  /**
216
216
  * State value for navigate/Link
217
217
  * - LocationStateEntry[]: Type-safe state entries (recommended)
218
- * - unknown: Legacy format for backwards compatibility
218
+ * - unknown: Plain state format (object or getter function)
219
219
  */
220
220
  export type HistoryState =
221
221
  | import("./react/location-state-shared.js").LocationStateEntry[]
@@ -234,14 +234,22 @@ export interface NavigateOptions {
234
234
  * @example
235
235
  * ```tsx
236
236
  * // Type-safe state (recommended)
237
- * const ProductState = createLocationState<{ name: string }>("product");
237
+ * const ProductState = createLocationState<{ name: string }>();
238
238
  * navigate("/product/123", { state: [ProductState({ name: "Widget" })] });
239
239
  *
240
+ * // Type-safe just-in-time state (getter called at navigation time)
241
+ * navigate("/product/123", {
242
+ * state: [ProductState(() => ({ name: computeName() }))],
243
+ * });
244
+ *
240
245
  * // Multiple states
241
246
  * navigate("/checkout", { state: [ProductState(p), CartState(c)] });
242
247
  *
243
- * // Legacy format (backwards compatible)
248
+ * // Plain static state
244
249
  * navigate("/product", { state: { from: "list" } });
250
+ *
251
+ * // Plain just-in-time state
252
+ * navigate("/product", { state: () => ({ from: window.location.pathname }) });
245
253
  * ```
246
254
  */
247
255
  state?: HistoryState;
@@ -394,35 +402,6 @@ export interface NavigationStore {
394
402
  ): () => void;
395
403
  }
396
404
 
397
- // ============================================================================
398
- // Request Controller Types
399
- // ============================================================================
400
-
401
- /**
402
- * Disposable abort controller with automatic cleanup
403
- */
404
- export interface DisposableAbortController extends Disposable {
405
- controller: AbortController;
406
- }
407
-
408
- /**
409
- * Request controller for managing concurrent requests
410
- *
411
- * Separates navigation requests (aborted on new navigation) from
412
- * action requests (complete independently of navigation).
413
- */
414
- export interface RequestController {
415
- create(): AbortController;
416
- createDisposable(): DisposableAbortController;
417
- /** Create a disposable controller for actions (not aborted by navigation) */
418
- createActionDisposable(): DisposableAbortController;
419
- /** Abort all navigation requests (not actions) */
420
- abortAll(): void;
421
- /** Abort all action requests (used for error handling) */
422
- abortAllActions(): void;
423
- remove(controller: AbortController): void;
424
- }
425
-
426
405
  // ============================================================================
427
406
  // Navigation Client Types
428
407
  // ============================================================================
@@ -480,7 +459,6 @@ export interface LinkInterceptorOptions {
480
459
  */
481
460
  export interface ServerActionBridge {
482
461
  register(): void;
483
- unregister(): void;
484
462
  }
485
463
 
486
464
  /**
@@ -526,3 +504,11 @@ export interface NavigationBridgeConfig {
526
504
 
527
505
  // Re-export ResolvedSegment for convenience
528
506
  export type { ResolvedSegment };
507
+
508
+ /**
509
+ * Token for tracking an active stream.
510
+ * Call end() when the stream completes.
511
+ */
512
+ export interface StreamingToken {
513
+ end(): void;
514
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Validate that a client-consumed redirect URL (from headers or Flight payload)
3
+ * targets the same origin as the current page. Prevents open-redirect attacks
4
+ * via crafted responses.
5
+ *
6
+ * @returns The canonical (normalized) URL string on success, or null if blocked.
7
+ */
8
+ export function validateRedirectOrigin(
9
+ url: string,
10
+ currentOrigin: string,
11
+ ): string | null {
12
+ try {
13
+ const target = new URL(url, currentOrigin);
14
+ if (target.origin !== currentOrigin) {
15
+ console.error(
16
+ `[rango] Redirect blocked: origin mismatch (${target.origin})`,
17
+ );
18
+ return null;
19
+ }
20
+ // Return pathname+search+hash for relative inputs, full href for absolute.
21
+ // This normalizes protocol-relative and other ambiguous forms.
22
+ return target.href.startsWith(currentOrigin)
23
+ ? target.href
24
+ : target.pathname + target.search + target.hash;
25
+ } catch {
26
+ console.error(`[rango] Redirect blocked: invalid URL "${url}"`);
27
+ return null;
28
+ }
29
+ }
@@ -51,8 +51,6 @@ export interface GeneratedManifest {
51
51
  responseTypeRoutes?: Record<string, string>;
52
52
  /** Route name -> search schema descriptor for typed URL helpers */
53
53
  routeSearchSchemas?: Record<string, Record<string, string>>;
54
- /** Generation timestamp */
55
- generatedAt: string;
56
54
  }
57
55
 
58
56
  /**
@@ -202,6 +200,11 @@ function buildPrefixTreeNode(
202
200
  }
203
201
  }
204
202
 
203
+ // Remove from visited so sibling branches can reuse the same patterns
204
+ // without false circular-include detection. Only ancestors in the current
205
+ // recursion path should trigger the cycle guard.
206
+ visited.delete(patterns);
207
+
205
208
  return {
206
209
  staticPrefix: extractStaticPrefix(urlPrefix),
207
210
  fullPrefix: urlPrefix,
@@ -232,11 +235,20 @@ function captureAncestry(
232
235
  }
233
236
 
234
237
  /**
235
- * Generate manifest from UrlPatterns
238
+ * Internal manifest result including build-pipeline-only fields.
239
+ * Not part of the public API — use generateManifest() for the public surface.
240
+ */
241
+ export interface FullManifest extends GeneratedManifest {
242
+ _routeAncestry: Record<string, string[]>;
243
+ _prerenderDefs?: Record<string, any>;
244
+ }
245
+
246
+ /**
247
+ * Generate manifest from UrlPatterns (public API).
236
248
  *
237
- * This runs all patterns (including lazy ones) at build time to extract:
238
- * - The complete prefix tree for short-circuit optimization
239
- * - The complete route manifest for href()
249
+ * Returns only the public GeneratedManifest fields. Internal build pipeline
250
+ * consumers that need _routeAncestry or _prerenderDefs should use
251
+ * generateManifestFull() instead.
240
252
  *
241
253
  * @example
242
254
  * ```typescript
@@ -254,10 +266,26 @@ function captureAncestry(
254
266
  export function generateManifest<TEnv>(
255
267
  urlpatterns: UrlPatterns<TEnv, any>,
256
268
  mountIndex: number = 0,
257
- ): GeneratedManifest & {
258
- _routeAncestry: Record<string, string[]>;
259
- _prerenderDefs?: Record<string, any>;
260
- } {
269
+ ): GeneratedManifest {
270
+ const {
271
+ _routeAncestry: _,
272
+ _prerenderDefs: __,
273
+ ...publicManifest
274
+ } = generateManifestFull(urlpatterns, mountIndex);
275
+ return publicManifest;
276
+ }
277
+
278
+ /**
279
+ * Generate manifest with internal build-pipeline fields.
280
+ *
281
+ * Used by the Vite plugin (discover-routers via dynamic import through
282
+ * @rangojs/router/build), manifest-init (direct import), and trie
283
+ * building. Not intended for external use.
284
+ */
285
+ export function generateManifestFull<TEnv>(
286
+ urlpatterns: UrlPatterns<TEnv, any>,
287
+ mountIndex: number = 0,
288
+ ): FullManifest {
261
289
  const routeManifest: Record<string, string> = {};
262
290
  const routeAncestry: Record<string, string[]> = {};
263
291
  const prefixTree: Record<string, PrefixTreeNode> = {};
@@ -376,8 +404,6 @@ export function generateManifest<TEnv>(
376
404
  Object.keys(routeSearchSchemas).length > 0
377
405
  ? routeSearchSchemas
378
406
  : undefined,
379
- generatedAt: new Date().toISOString(),
380
- // Internal: routeAncestry is used only for trie building, not exported
381
407
  _routeAncestry: routeAncestry,
382
408
  // Internal: prerender handler definitions for build-time getParams() access
383
409
  _prerenderDefs:
@@ -401,7 +427,6 @@ export function generateManifestCode<TEnv>(
401
427
 
402
428
  return `/**
403
429
  * Auto-generated route manifest
404
- * Generated at: ${manifest.generatedAt}
405
430
  *
406
431
  * DO NOT EDIT - This file is generated by @rangojs/router
407
432
  */
@@ -27,6 +27,10 @@ export {
27
27
  extractUrlsVariableFromRouter,
28
28
  buildCombinedRouteMapForRouterFile,
29
29
  detectUnresolvableIncludes,
30
+ detectUnresolvableIncludesForUrlsFile,
31
+ findNestedRouterConflict,
32
+ formatNestedRouterConflictError,
30
33
  findRouterFiles,
31
34
  writeCombinedRouteTypes,
32
35
  } from "./route-types/router-processing.js";
36
+ export { findUrlsVariableNames } from "./route-types/per-module-writer.js";
@@ -17,6 +17,7 @@
17
17
 
18
18
  export {
19
19
  generateManifest,
20
+ generateManifestFull,
20
21
  generateManifestCode,
21
22
  type GeneratedManifest,
22
23
  } from "./generate-manifest.js";
@@ -47,6 +47,8 @@ export interface TrieNode {
47
47
  s?: Record<string, TrieNode>;
48
48
  /** Param child: { n: paramName, c: child node } */
49
49
  p?: { n: string; c: TrieNode };
50
+ /** Suffix-param children keyed by suffix (e.g., ".html" → { n: "productId", c: ... }) */
51
+ xp?: Record<string, { n: string; c: TrieNode }>;
50
52
  /** Wildcard terminal: leaf + paramName */
51
53
  w?: TrieLeaf & { pn: string };
52
54
  }
@@ -158,6 +160,11 @@ export function extractAncestryFromTrie(
158
160
  visit(child);
159
161
  }
160
162
  }
163
+ if (node.xp) {
164
+ for (const child of Object.values(node.xp)) {
165
+ visit(child.c);
166
+ }
167
+ }
161
168
  if (node.p) {
162
169
  visit(node.p.c);
163
170
  }
@@ -235,10 +242,19 @@ function insertSegments(
235
242
  mergeLeaf(node, leaf);
236
243
  // AND continue with param child (param present)
237
244
  }
238
- if (!node.p) {
239
- node.p = { n: segment.value, c: {} };
245
+ if (segment.suffix) {
246
+ // Suffix param: keyed by suffix string (e.g., ".html")
247
+ if (!node.xp) node.xp = {};
248
+ if (!node.xp[segment.suffix]) {
249
+ node.xp[segment.suffix] = { n: segment.value, c: {} };
250
+ }
251
+ insertSegments(node.xp[segment.suffix].c, segments, index + 1, leaf);
252
+ } else {
253
+ if (!node.p) {
254
+ node.p = { n: segment.value, c: {} };
255
+ }
256
+ insertSegments(node.p.c, segments, index + 1, leaf);
240
257
  }
241
- insertSegments(node.p.c, segments, index + 1, leaf);
242
258
  } else if (segment.type === "wildcard") {
243
259
  // Wildcard consumes all remaining segments
244
260
  const wildLeaf = { ...leaf, pn: "*" };
@@ -2,6 +2,7 @@ import {
2
2
  extractParamsFromPattern,
3
3
  formatRouteEntry,
4
4
  } from "./param-extraction.js";
5
+ import { isAutoGeneratedRouteName } from "../../route-name.js";
5
6
 
6
7
  // ---------------------------------------------------------------------------
7
8
  // Code generation
@@ -66,15 +67,23 @@ export function generateRouteTypesSource(
66
67
  routeManifest: Record<string, string>,
67
68
  searchSchemas?: Record<string, Record<string, string>>,
68
69
  ): string {
69
- const entries = Object.entries(routeManifest).sort(([a], [b]) =>
70
- a.localeCompare(b),
71
- );
70
+ const entries = Object.entries(routeManifest)
71
+ .filter(([name]) => !isAutoGeneratedRouteName(name))
72
+ .sort(([a], [b]) => a.localeCompare(b));
73
+
74
+ const filteredSearchSchemas = searchSchemas
75
+ ? Object.fromEntries(
76
+ Object.entries(searchSchemas).filter(
77
+ ([name]) => !isAutoGeneratedRouteName(name),
78
+ ),
79
+ )
80
+ : undefined;
72
81
 
73
82
  const objectBody = entries
74
83
  .map(([name, pattern]) => {
75
84
  const key = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) ? name : `"${name}"`;
76
85
  const params = extractParamsFromPattern(pattern);
77
- const search = searchSchemas?.[name];
86
+ const search = filteredSearchSchemas?.[name];
78
87
  return formatRouteEntry(key, pattern, params, search);
79
88
  })
80
89
  .join("\n");
@@ -325,6 +325,13 @@ function buildRouteMapFromBlock(
325
325
  );
326
326
  }
327
327
 
328
+ // Includes without a name keep their child names private to the mounted
329
+ // module. They remain active at runtime via an internal scope prefix, but
330
+ // they are intentionally omitted from generated public route maps.
331
+ if (namePrefix === null) {
332
+ continue;
333
+ }
334
+
328
335
  // Apply prefixes
329
336
  for (const [name, pattern] of Object.entries(childResult.routes)) {
330
337
  const prefixedName = namePrefix ? `${namePrefix}.${name}` : name;
@@ -394,5 +401,11 @@ export function buildCombinedRouteMapWithSearch(
394
401
  searchSchemas,
395
402
  diagnosticsOut,
396
403
  );
404
+
405
+ // Remove from visited so sibling branches can include the same variable
406
+ // without false circular-include detection. Only ancestors in the current
407
+ // recursion path should trigger the cycle guard.
408
+ visited.delete(key);
409
+
397
410
  return { routes, searchSchemas };
398
411
  }
@@ -26,7 +26,7 @@ export function writePerModuleRouteTypes(
26
26
  * Find all variable names assigned to urls() calls in source code.
27
27
  * e.g. `export const patterns = urls(...)` -> ["patterns"]
28
28
  */
29
- function findUrlsVariableNames(code: string): string[] {
29
+ export function findUrlsVariableNames(code: string): string[] {
30
30
  const sourceFile = ts.createSourceFile(
31
31
  "input.tsx",
32
32
  code,
@@ -97,9 +97,21 @@ export function writePerModuleRouteTypesForFile(filePath: string): void {
97
97
  routes = extractRoutesFromSource(source);
98
98
  }
99
99
 
100
- if (routes.length === 0) return;
101
-
102
100
  const genPath = filePath.replace(/\.(tsx?)$/, ".gen.ts");
101
+
102
+ // When a urls() variable was found but static resolution yields zero
103
+ // routes, write an empty placeholder so generated imports stay
104
+ // resolvable until runtime discovery fills them in.
105
+ if (routes.length === 0) {
106
+ if (varNames.length > 0 && !existsSync(genPath)) {
107
+ writeFileSync(genPath, generatePerModuleTypesSource([]));
108
+ console.log(
109
+ `[rsc-router] Generated route types (placeholder) -> ${genPath}`,
110
+ );
111
+ }
112
+ return;
113
+ }
114
+
103
115
  const genSource = generatePerModuleTypesSource(routes);
104
116
  const existing = existsSync(genPath)
105
117
  ? readFileSync(genPath, "utf-8")
@@ -1,15 +1,146 @@
1
- import { readFileSync, writeFileSync, existsSync, unlinkSync } from "node:fs";
2
- import { join, dirname, resolve, basename as pathBasename } from "node:path";
1
+ import {
2
+ readFileSync,
3
+ writeFileSync,
4
+ existsSync,
5
+ unlinkSync,
6
+ readdirSync,
7
+ } from "node:fs";
8
+ import {
9
+ join,
10
+ dirname,
11
+ resolve,
12
+ sep,
13
+ basename as pathBasename,
14
+ } from "node:path";
3
15
  import ts from "typescript";
4
16
  import { generateRouteTypesSource } from "./codegen.js";
5
17
  import type { ScanFilter } from "./scan-filter.js";
6
- import { findTsFiles } from "./scan-filter.js";
7
18
  import {
8
19
  resolveImportedVariable,
9
20
  resolveImportPath,
10
21
  buildCombinedRouteMapWithSearch,
11
22
  type UnresolvableInclude,
12
23
  } from "./include-resolution.js";
24
+ import { findUrlsVariableNames } from "./per-module-writer.js";
25
+ import { isAutoGeneratedRouteName } from "../../route-name.js";
26
+
27
+ function countPublicRouteEntries(source: string): number {
28
+ const matches =
29
+ source.matchAll(/^\s+(?:"([^"]+)"|([a-zA-Z_$][^:]*)):\s*["{]/gm) ?? [];
30
+ let count = 0;
31
+ for (const match of matches) {
32
+ const routeName = match[1] || match[2];
33
+ if (routeName && !isAutoGeneratedRouteName(routeName.trim())) {
34
+ count++;
35
+ }
36
+ }
37
+ return count;
38
+ }
39
+
40
+ const ROUTER_CALL_PATTERN = /\bcreateRouter\s*[<(]/;
41
+
42
+ function isRoutableSourceFile(name: string): boolean {
43
+ return (
44
+ (name.endsWith(".ts") ||
45
+ name.endsWith(".tsx") ||
46
+ name.endsWith(".js") ||
47
+ name.endsWith(".jsx")) &&
48
+ !name.includes(".gen.")
49
+ );
50
+ }
51
+
52
+ function findRouterFilesRecursive(
53
+ dir: string,
54
+ filter: ScanFilter | undefined,
55
+ results: string[],
56
+ ): void {
57
+ let entries;
58
+ try {
59
+ entries = readdirSync(dir, { withFileTypes: true });
60
+ } catch (err) {
61
+ console.warn(
62
+ `[rsc-router] Failed to scan directory ${dir}: ${(err as Error).message}`,
63
+ );
64
+ return;
65
+ }
66
+
67
+ const childDirs: string[] = [];
68
+ const routerFilesInDir: string[] = [];
69
+
70
+ for (const entry of entries) {
71
+ const fullPath = join(dir, entry.name);
72
+ if (entry.isDirectory()) {
73
+ if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
74
+ childDirs.push(fullPath);
75
+ continue;
76
+ }
77
+
78
+ if (!isRoutableSourceFile(entry.name)) continue;
79
+ if (filter && !filter(fullPath)) continue;
80
+
81
+ try {
82
+ const source = readFileSync(fullPath, "utf-8");
83
+ if (ROUTER_CALL_PATTERN.test(source)) {
84
+ routerFilesInDir.push(fullPath);
85
+ }
86
+ } catch {
87
+ continue;
88
+ }
89
+ }
90
+
91
+ // A directory that contains a router file is treated as a router root.
92
+ // Once found, deeper directories are skipped to avoid redundant scans.
93
+ if (routerFilesInDir.length > 0) {
94
+ results.push(...routerFilesInDir);
95
+ return;
96
+ }
97
+
98
+ for (const childDir of childDirs) {
99
+ findRouterFilesRecursive(childDir, filter, results);
100
+ }
101
+ }
102
+
103
+ export function findNestedRouterConflict(
104
+ routerFiles: string[],
105
+ ): { ancestor: string; nested: string } | null {
106
+ const routerDirs = [
107
+ ...new Set(routerFiles.map((filePath) => dirname(resolve(filePath)))),
108
+ ].sort((a, b) => a.length - b.length);
109
+
110
+ for (let i = 0; i < routerDirs.length; i++) {
111
+ const ancestorDir = routerDirs[i];
112
+ const prefix = ancestorDir.endsWith(sep)
113
+ ? ancestorDir
114
+ : `${ancestorDir}${sep}`;
115
+ for (let j = i + 1; j < routerDirs.length; j++) {
116
+ const nestedDir = routerDirs[j];
117
+ if (!nestedDir.startsWith(prefix)) continue;
118
+ const ancestorFile = routerFiles.find(
119
+ (filePath) => dirname(resolve(filePath)) === ancestorDir,
120
+ );
121
+ const nestedFile = routerFiles.find(
122
+ (filePath) => dirname(resolve(filePath)) === nestedDir,
123
+ );
124
+ if (ancestorFile && nestedFile) {
125
+ return { ancestor: ancestorFile, nested: nestedFile };
126
+ }
127
+ }
128
+ }
129
+
130
+ return null;
131
+ }
132
+
133
+ export function formatNestedRouterConflictError(
134
+ conflict: { ancestor: string; nested: string },
135
+ prefix = "[rsc-router]",
136
+ ): string {
137
+ return (
138
+ `${prefix} Nested router roots are not supported.\n` +
139
+ `Router root: ${conflict.ancestor}\n` +
140
+ `Nested router: ${conflict.nested}\n` +
141
+ `Move the nested router into a sibling directory or configure it as a separate app root.`
142
+ );
143
+ }
13
144
 
14
145
  // ---------------------------------------------------------------------------
15
146
  // Router file URL extraction
@@ -184,6 +315,33 @@ export function detectUnresolvableIncludes(
184
315
  return diagnostics;
185
316
  }
186
317
 
318
+ /**
319
+ * Walk the include tree for a standalone urls() module file and detect
320
+ * all unresolvable includes. Mirrors detectUnresolvableIncludes() but
321
+ * operates on urls() variable declarations instead of going through
322
+ * createRouter().
323
+ */
324
+ export function detectUnresolvableIncludesForUrlsFile(
325
+ filePath: string,
326
+ ): UnresolvableInclude[] {
327
+ const realPath = resolve(filePath);
328
+ let source: string;
329
+ try {
330
+ source = readFileSync(realPath, "utf-8");
331
+ } catch {
332
+ return [];
333
+ }
334
+
335
+ const varNames = findUrlsVariableNames(source);
336
+ if (varNames.length === 0) return [];
337
+
338
+ const diagnostics: UnresolvableInclude[] = [];
339
+ for (const varName of varNames) {
340
+ buildCombinedRouteMapWithSearch(realPath, varName, new Set(), diagnostics);
341
+ }
342
+ return diagnostics;
343
+ }
344
+
187
345
  // ---------------------------------------------------------------------------
188
346
  // Per-router named-routes.gen.ts writer
189
347
  // ---------------------------------------------------------------------------
@@ -193,19 +351,8 @@ export function detectUnresolvableIncludes(
193
351
  * Call once at startup; the result can be reused on subsequent watcher triggers.
194
352
  */
195
353
  export function findRouterFiles(root: string, filter?: ScanFilter): string[] {
196
- const files = findTsFiles(root, filter);
197
354
  const result: string[] = [];
198
- for (const filePath of files) {
199
- if (filePath.includes(".gen.")) continue;
200
- try {
201
- const source = readFileSync(filePath, "utf-8");
202
- if (/\bcreateRouter\s*[<(]/.test(source)) {
203
- result.push(filePath);
204
- }
205
- } catch {
206
- continue;
207
- }
208
- }
355
+ findRouterFilesRecursive(root, filter, result);
209
356
  return result;
210
357
  }
211
358
 
@@ -234,6 +381,11 @@ export function writeCombinedRouteTypes(
234
381
  const routerFilePaths = knownRouterFiles ?? findRouterFiles(root);
235
382
  if (routerFilePaths.length === 0) return;
236
383
 
384
+ const nestedRouterConflict = findNestedRouterConflict(routerFilePaths);
385
+ if (nestedRouterConflict) {
386
+ throw new Error(formatNestedRouterConflictError(nestedRouterConflict));
387
+ }
388
+
237
389
  for (const routerFilePath of routerFilePaths) {
238
390
  let routerSource: string;
239
391
  try {
@@ -300,10 +452,10 @@ export function writeCombinedRouteTypes(
300
452
  // or other dynamic code. During HMR (file watcher), always write so
301
453
  // newly added routes appear immediately.
302
454
  if (opts?.preserveIfLarger && existing) {
303
- const existingCount = (
304
- existing.match(/^\s+["a-zA-Z_$][^:]*:\s*["{]/gm) || []
455
+ const existingCount = countPublicRouteEntries(existing);
456
+ const newCount = Object.keys(result.routes).filter(
457
+ (name) => !isAutoGeneratedRouteName(name),
305
458
  ).length;
306
- const newCount = Object.keys(result.routes).length;
307
459
  if (existingCount > newCount) {
308
460
  continue;
309
461
  }
@@ -4,6 +4,7 @@ import {
4
4
  generateRouteTypesSource,
5
5
  buildCombinedRouteMapForRouterFile,
6
6
  } from "./generate-route-types.ts";
7
+ import { isAutoGeneratedRouteName } from "../route-name.js";
7
8
 
8
9
  export interface RuntimeDiscoveryOptions {
9
10
  /** Project root directory (where package.json / node_modules live). */
@@ -140,7 +141,18 @@ export async function discoverAndWriteRouteTypes(
140
141
  const manifest = generateManifest(router.urlpatterns, routerMountIndex);
141
142
  routerMountIndex++;
142
143
 
143
- const routeManifest: Record<string, string> = manifest.routeManifest;
144
+ // Filter out auto-generated route names that the runtime creates for
145
+ // unnamed routes (path() with no name option). These get names like
146
+ // "$path__health" at root level or "docs.$path__health" under include().
147
+ // Match the Vite discovery writer's predicate: any name starting with "$"
148
+ // is internal. For prefixed names, check each dot-separated segment.
149
+ const rawManifest: Record<string, string> = manifest.routeManifest;
150
+ const routeManifest: Record<string, string> = {};
151
+ for (const [name, pattern] of Object.entries(rawManifest)) {
152
+ if (!isAutoGeneratedRouteName(name)) {
153
+ routeManifest[name] = pattern;
154
+ }
155
+ }
144
156
  let routeSearchSchemas:
145
157
  | Record<string, Record<string, string>>
146
158
  | undefined = manifest.routeSearchSchemas;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Background Task Runner
3
+ *
4
+ * Unified helper for scheduling async work via waitUntil.
5
+ * When waitUntil is unavailable, falls back to blocking or skipping.
6
+ */
7
+
8
+ interface WaitUntilHost {
9
+ waitUntil?: (fn: () => Promise<void>) => void;
10
+ }
11
+
12
+ /**
13
+ * Schedule an async task in the background via waitUntil.
14
+ *
15
+ * @param host - Object with optional waitUntil (request context or similar)
16
+ * @param task - Async function to execute
17
+ * @param blockWhenNoWaitUntil - If true, awaits the task when waitUntil is
18
+ * unavailable (e.g., Node.js dev server). If false (default), the task
19
+ * is silently skipped when waitUntil is unavailable.
20
+ * @returns A promise when blocking fallback is used, void otherwise.
21
+ */
22
+ export function runBackground(
23
+ host: WaitUntilHost | null | undefined,
24
+ task: () => Promise<void>,
25
+ blockWhenNoWaitUntil = false,
26
+ ): Promise<void> | void {
27
+ if (host?.waitUntil) {
28
+ host.waitUntil(task);
29
+ return;
30
+ }
31
+ if (blockWhenNoWaitUntil) {
32
+ return task();
33
+ }
34
+ }