@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/vite/index.ts CHANGED
@@ -1,57 +1,16 @@
1
- // Plugins
2
- export { exposeActionId } from "./plugins/expose-action-id.js";
3
- export {
4
- exposeInternalIds,
5
- exposeRouterId,
6
- } from "./plugins/expose-internal-ids.js";
7
- export type { ExposeInternalIdsApi } from "./plugins/expose-internal-ids.js";
8
- export { createVersionPlugin } from "./plugins/version-plugin.js";
9
- export { createVirtualStubPlugin } from "./plugins/virtual-stub-plugin.js";
10
- export {
11
- computeProductionHash,
12
- transformClientRefs,
13
- hashClientRefs,
14
- } from "./plugins/client-ref-hashing.js";
15
- export { createVersionInjectorPlugin } from "./plugins/version-injector.js";
16
- export { createCjsToEsmPlugin } from "./plugins/cjs-to-esm.js";
1
+ /**
2
+ * Public API for @rangojs/router/vite
3
+ *
4
+ * Exports: rango() plugin factory, poke() dev utility plugin,
5
+ * and related option types. All other utilities are internal implementation
6
+ * details consumed via direct imports within the package.
7
+ */
8
+
9
+ export { rango } from "./rango.js";
10
+ export { poke } from "./plugins/refresh-cmd.js";
17
11
 
18
- // Types
19
12
  export type {
20
- RscEntries,
21
- RscPluginOptions,
22
13
  RangoNodeOptions,
23
14
  RangoCloudflareOptions,
24
15
  RangoOptions,
25
16
  } from "./plugin-types.js";
26
-
27
- // Utils
28
- export {
29
- sharedEsbuildOptions,
30
- createVirtualEntriesPlugin,
31
- onwarn,
32
- getManualChunks,
33
- } from "./utils/shared-utils.js";
34
- export {
35
- flattenLeafEntries,
36
- buildRouteToStaticPrefix,
37
- jsonParseExpression,
38
- } from "./utils/manifest-utils.js";
39
- export {
40
- findMatchingParenInBundle,
41
- extractHandlerExportsFromChunk,
42
- evictHandlerCode,
43
- } from "./utils/bundle-analysis.js";
44
- export {
45
- encodePathParam,
46
- runWithConcurrency,
47
- groupByConcurrency,
48
- notifyOnError,
49
- } from "./utils/prerender-utils.js";
50
- export { printBanner, rangoVersion } from "./utils/banner.js";
51
-
52
- // Core
53
- export {
54
- createRouterDiscoveryPlugin,
55
- VIRTUAL_ROUTES_MANIFEST_ID,
56
- } from "./router-discovery.js";
57
- export { rango } from "./rango.js";
@@ -0,0 +1,115 @@
1
+ import type { Plugin, ResolvedConfig } from "vite";
2
+
3
+ const CLIENT_IN_SERVER_PROXY_PREFIX =
4
+ "virtual:vite-rsc/client-in-server-package-proxy/";
5
+
6
+ /**
7
+ * Extract the bare package name from an absolute node_modules path.
8
+ * Handles scoped packages (@org/name) and nested node_modules.
9
+ * Returns null if the path doesn't contain a valid package reference.
10
+ *
11
+ * NOTE: This is a lossy transformation. It maps a specific submodule path
12
+ * (e.g., pkg/internal/context.js) to the package root (pkg). The load()
13
+ * hook then re-exports via the bare specifier, which resolves to the
14
+ * package entry point. This works for packages that barrel-export their
15
+ * "use client" symbols from the root, which covers the common case
16
+ * (component libraries like @mantine/core, @chakra-ui/react, etc.).
17
+ * Packages whose client symbols are only available from deep subpaths
18
+ * (not re-exported from the root) would lose those symbols after the
19
+ * rewrite. A more precise approach would resolve through the package's
20
+ * exports map to find the correct entry point, but that adds significant
21
+ * complexity for a rare edge case.
22
+ * See: https://github.com/cloudflare/vinext/pull/413
23
+ */
24
+ export function extractPackageName(absolutePath: string): string | null {
25
+ // Find the last /node_modules/ segment (handles nested node_modules)
26
+ const marker = "/node_modules/";
27
+ const idx = absolutePath.lastIndexOf(marker);
28
+ if (idx === -1) return null;
29
+
30
+ const afterModules = absolutePath.slice(idx + marker.length);
31
+
32
+ if (afterModules.startsWith("@")) {
33
+ // Scoped package: @org/name
34
+ const parts = afterModules.split("/");
35
+ if (parts.length < 2 || !parts[1]) return null;
36
+ return `${parts[0]}/${parts[1]}`;
37
+ }
38
+
39
+ // Unscoped package: name
40
+ const name = afterModules.split("/")[0];
41
+ return name || null;
42
+ }
43
+
44
+ /**
45
+ * Vite plugin that deduplicates client references from third-party packages
46
+ * in dev mode.
47
+ *
48
+ * When @vitejs/plugin-rsc encounters a "use client" submodule inside a
49
+ * package imported from a server component, it creates a
50
+ * client-in-server-package-proxy virtual module that re-exports from the
51
+ * absolute file path. In the client environment, this absolute path bypasses
52
+ * Vite's pre-bundling, while direct client imports of the same package go
53
+ * through .vite/deps/. Two separate module instances are created, breaking
54
+ * React contexts (createContext runs twice, provider/consumer mismatch).
55
+ *
56
+ * This plugin intercepts absolute node_modules imports from proxy modules
57
+ * in the client environment and rewrites them to bare specifier imports
58
+ * that go through pre-bundling, ensuring a single module instance.
59
+ *
60
+ * Dev-only: production builds use the SSR manifest which handles module
61
+ * identity correctly.
62
+ */
63
+ export function clientRefDedup(): Plugin {
64
+ let clientExclude: string[] = [];
65
+
66
+ return {
67
+ name: "@rangojs/router:client-ref-dedup",
68
+ enforce: "pre",
69
+ apply: "serve",
70
+
71
+ configResolved(config: ResolvedConfig) {
72
+ // Respect user's optimizeDeps.exclude — if a package is explicitly
73
+ // excluded from pre-bundling, we shouldn't redirect it there.
74
+ const clientEnv = config.environments?.["client"];
75
+ clientExclude =
76
+ clientEnv?.optimizeDeps?.exclude ?? config.optimizeDeps?.exclude ?? [];
77
+ },
78
+
79
+ resolveId(source, importer, options) {
80
+ // Only intercept in the client environment
81
+ if (this.environment?.name !== "client") return;
82
+
83
+ // Only handle imports from client-in-server-package-proxy virtual modules
84
+ if (!importer?.includes(CLIENT_IN_SERVER_PROXY_PREFIX)) return;
85
+
86
+ // Only handle absolute node_modules paths
87
+ if (!source.includes("/node_modules/")) return;
88
+
89
+ // Must have an importer
90
+ if (!importer) return;
91
+
92
+ const packageName = extractPackageName(source);
93
+ if (!packageName) return;
94
+
95
+ // Don't redirect packages that are excluded from optimization
96
+ if (clientExclude.includes(packageName)) return;
97
+
98
+ // Return a virtual module that re-exports via bare specifier
99
+ return `\0rango:dedup/${packageName}`;
100
+ },
101
+
102
+ load(id) {
103
+ if (!id.startsWith("\0rango:dedup/")) return;
104
+
105
+ const packageName = id.slice("\0rango:dedup/".length);
106
+
107
+ // Re-export via bare specifier so Vite routes through pre-bundling
108
+ return [
109
+ `export * from ${JSON.stringify(packageName)};`,
110
+ `import * as __all__ from ${JSON.stringify(packageName)};`,
111
+ `export default __all__.default;`,
112
+ ].join("\n");
113
+ },
114
+ };
115
+ }
@@ -1,5 +1,5 @@
1
1
  import type { Plugin } from "vite";
2
- import { relative, posix } from "node:path";
2
+ import { relative } from "node:path";
3
3
  import { createHash } from "node:crypto";
4
4
 
5
5
  // Dev-mode client-reference key prefixes emitted by @vitejs/plugin-rsc
@@ -33,11 +33,11 @@ export function computeProductionHash(
33
33
  const absPath = decodeURIComponent(
34
34
  refKey.slice(CLIENT_IN_SERVER_PKG_PROXY_PREFIX.length),
35
35
  );
36
- toHash = posix.normalize(relative(projectRoot, absPath));
36
+ toHash = relative(projectRoot, absPath).replaceAll("\\", "/");
37
37
  } else if (refKey.startsWith(FS_PREFIX)) {
38
38
  // /@fs/abs/path.tsx -> hash(relative(root, "/abs/path.tsx"))
39
39
  const absPath = refKey.slice(FS_PREFIX.length - 1); // keep leading /
40
- toHash = posix.normalize(relative(projectRoot, absPath));
40
+ toHash = relative(projectRoot, absPath).replaceAll("\\", "/");
41
41
  } else if (refKey.startsWith("/")) {
42
42
  // /src/Button.tsx -> hash("src/Button.tsx")
43
43
  toHash = refKey.slice(1);
@@ -170,6 +170,8 @@ ${lazyImports.join(",\n")}
170
170
 
171
171
  const fs = await import("node:fs/promises");
172
172
 
173
+ const SKIP_DIRS = new Set(["node_modules", "dist", "build", "coverage"]);
174
+
173
175
  async function scanDir(dir: string): Promise<string[]> {
174
176
  const results: string[] = [];
175
177
  try {
@@ -177,7 +179,7 @@ ${lazyImports.join(",\n")}
177
179
  for (const entry of entries) {
178
180
  const fullPath = path.join(dir, entry.name);
179
181
  if (entry.isDirectory()) {
180
- if (entry.name !== "node_modules") {
182
+ if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".")) {
181
183
  results.push(...(await scanDir(fullPath)));
182
184
  }
183
185
  } else if (/\.(ts|tsx|js|jsx)$/.test(entry.name)) {
@@ -191,8 +193,7 @@ ${lazyImports.join(",\n")}
191
193
  }
192
194
 
193
195
  try {
194
- const srcDir = path.join(projectRoot, "src");
195
- const files = await scanDir(srcDir);
196
+ const files = await scanDir(projectRoot);
196
197
 
197
198
  for (const filePath of files) {
198
199
  const content = await fs.readFile(filePath, "utf-8");
@@ -0,0 +1,65 @@
1
+ import type { Plugin } from "vite";
2
+
3
+ /**
4
+ * Vite plugin that triggers a full browser reload when Ctrl+R is pressed
5
+ * in the terminal running the dev server.
6
+ *
7
+ * Usage:
8
+ * ```ts
9
+ * import { poke } from "@rangojs/router/vite";
10
+ *
11
+ * export default defineConfig({
12
+ * plugins: [rango(), poke()],
13
+ * });
14
+ * ```
15
+ */
16
+ export function poke(): Plugin {
17
+ return {
18
+ name: "vite-plugin-poke",
19
+ apply: "serve",
20
+
21
+ configureServer(server) {
22
+ const stdin = process.stdin;
23
+
24
+ // Raw mode delivers individual keystrokes as immediate single-byte
25
+ // events instead of waiting for Enter (cooked/line-buffered mode).
26
+ // Without it, Ctrl+R (0x12) is never delivered as a discrete byte.
27
+ // When stdin is a pipe (CI, spawned process) setRawMode is unavailable
28
+ // but data already arrives unbuffered, so the isTTY guard suffices.
29
+ const previousRawMode = stdin.isTTY ? stdin.isRaw : null;
30
+ if (stdin.isTTY) {
31
+ stdin.setRawMode(true);
32
+ }
33
+
34
+ const onData = (data: Buffer) => {
35
+ if (data.length !== 1) return;
36
+
37
+ // Ctrl+C (0x03) — defensive fallback. This plugin enables raw mode
38
+ // before Vite's internal stdin handler is registered (user plugins
39
+ // run first), so there is a brief window where Ctrl+C would be
40
+ // swallowed. Re-emit SIGINT so the process exits as expected.
41
+ if (data[0] === 0x03) {
42
+ process.emit("SIGINT", "SIGINT");
43
+ return;
44
+ }
45
+
46
+ // Ctrl+R = 0x12 in raw mode
47
+ if (data[0] === 0x12) {
48
+ server.hot.send({ type: "full-reload", path: "*" });
49
+ server.config.logger.info(" browser reload (ctrl+r)", {
50
+ timestamp: true,
51
+ });
52
+ }
53
+ };
54
+
55
+ stdin.on("data", onData);
56
+
57
+ server.httpServer?.on("close", () => {
58
+ stdin.off("data", onData);
59
+ if (stdin.isTTY && previousRawMode !== null) {
60
+ stdin.setRawMode(previousRawMode);
61
+ }
62
+ });
63
+ },
64
+ };
65
+ }
@@ -97,7 +97,7 @@ export function useCacheTransform(): Plugin {
97
97
 
98
98
  // Check for function-level "use cache" / "use cache: profileName"
99
99
  // (only if there's no file-level directive but code still contains the string)
100
- return transformFunctionLevelUseCache(
100
+ const functionResult = transformFunctionLevelUseCache(
101
101
  code,
102
102
  ast,
103
103
  filePath,
@@ -105,6 +105,13 @@ export function useCacheTransform(): Plugin {
105
105
  isBuild,
106
106
  transformHoistInlineDirective,
107
107
  );
108
+
109
+ // Always check for near-miss directives, even when valid directives
110
+ // exist. A file may contain both valid and invalid "use cache" directives
111
+ // in different functions — the invalid ones should still warn.
112
+ warnOnNearMissDirectives(ast, id, this.warn.bind(this));
113
+
114
+ if (functionResult) return functionResult;
108
115
  },
109
116
  };
110
117
  }
@@ -118,19 +125,38 @@ function transformFileLevelUseCache(
118
125
  isLayoutOrTemplate: boolean,
119
126
  transformWrapExport: (typeof import("@vitejs/plugin-rsc/transforms"))["transformWrapExport"],
120
127
  ) {
128
+ // Collect non-function exports to report after wrapping
129
+ const nonFunctionExports: string[] = [];
130
+
121
131
  const { exportNames, output } = transformWrapExport(code, ast, {
122
132
  runtime: (value: string, name: string) => {
123
133
  const funcId = isBuild ? hashId(filePath, name) : `${filePath}#${name}`;
124
134
  return `__rango_registerCachedFunction(${value}, ${JSON.stringify(funcId)}, "default")`;
125
135
  },
126
136
  rejectNonAsyncFunction: false,
127
- filter: (name: string) => {
137
+ filter: (name: string, meta: { isFunction?: boolean }) => {
128
138
  // Skip default export of layout/template files (they receive children)
129
139
  if (name === "default" && isLayoutOrTemplate) return false;
140
+ // Non-function exports cannot be wrapped with registerCachedFunction
141
+ if (meta.isFunction === false) {
142
+ nonFunctionExports.push(name);
143
+ return false;
144
+ }
130
145
  return true;
131
146
  },
132
147
  });
133
148
 
149
+ if (nonFunctionExports.length > 0) {
150
+ throw new Error(
151
+ `[rango:use-cache] File-level "use cache" in ${sourceId} cannot wrap ` +
152
+ `non-function export${nonFunctionExports.length > 1 ? "s" : ""}: ` +
153
+ `${nonFunctionExports.map((n) => `"${n}"`).join(", ")}. ` +
154
+ `Only function exports can be cached. Either remove "use cache" from ` +
155
+ `the file level and add it inside individual functions, or move the ` +
156
+ `non-function exports to a separate module.`,
157
+ );
158
+ }
159
+
134
160
  if (exportNames.length === 0) {
135
161
  // Even if no exports were wrapped, strip the directive
136
162
  const s = new MagicString(code);
@@ -180,7 +206,7 @@ function transformFunctionLevelUseCache(
180
206
  ) {
181
207
  try {
182
208
  const { output, names } = transformHoistInlineDirective(code, ast, {
183
- directive: /^use cache(:\s*\w+)?$/,
209
+ directive: /^use cache(:\s*[\w-]+)?$/,
184
210
  runtime: (
185
211
  value: string,
186
212
  name: string,
@@ -233,3 +259,65 @@ function findFileLevelDirective(
233
259
  }
234
260
  return null;
235
261
  }
262
+
263
+ /**
264
+ * The valid directive regex (must stay in sync with transformFunctionLevelUseCache).
265
+ */
266
+ const VALID_DIRECTIVE_RE = /^use cache(:\s*[\w-]+)?$/;
267
+
268
+ /**
269
+ * Regex for near-miss: starts with "use cache:" but has invalid tokens.
270
+ */
271
+ const NEAR_MISS_RE = /^use cache:\s*.+$/;
272
+
273
+ /**
274
+ * Walk the AST looking for string literals that look like malformed
275
+ * "use cache" directives and emit a Vite warning for each.
276
+ *
277
+ * This catches cases like `"use cache: bad.name"` or `"use cache: "`
278
+ * that the transform regex silently ignores.
279
+ */
280
+ function warnOnNearMissDirectives(
281
+ ast: any,
282
+ fileId: string,
283
+ warn: (message: string) => void,
284
+ ): void {
285
+ const visit = (node: any) => {
286
+ if (!node || typeof node !== "object") return;
287
+
288
+ if (
289
+ node.type === "ExpressionStatement" &&
290
+ node.expression?.type === "Literal" &&
291
+ typeof node.expression.value === "string"
292
+ ) {
293
+ const value = node.expression.value;
294
+ if (
295
+ value.startsWith("use cache") &&
296
+ NEAR_MISS_RE.test(value) &&
297
+ !VALID_DIRECTIVE_RE.test(value)
298
+ ) {
299
+ const profilePart = value.slice("use cache:".length).trim();
300
+ warn(
301
+ `[rango:use-cache] "${value}" in ${fileId} has an invalid profile name "${profilePart}". ` +
302
+ `Profile names must match [a-zA-Z0-9_-]+. This directive will be ignored.`,
303
+ );
304
+ }
305
+ }
306
+
307
+ // Walk into function bodies where directives appear
308
+ for (const key of Object.keys(node)) {
309
+ const child = node[key];
310
+ if (Array.isArray(child)) {
311
+ for (const item of child) {
312
+ visit(item);
313
+ }
314
+ } else if (child && typeof child === "object" && child.type) {
315
+ visit(child);
316
+ }
317
+ }
318
+ };
319
+
320
+ for (const node of ast.body ?? []) {
321
+ visit(node);
322
+ }
323
+ }
@@ -1,6 +1,116 @@
1
- import type { Plugin } from "vite";
1
+ import { parseAst, type Plugin } from "vite";
2
2
  import { VIRTUAL_IDS, getVirtualVersionContent } from "./virtual-entries.js";
3
3
 
4
+ interface ClientModuleSignature {
5
+ key: string;
6
+ }
7
+
8
+ function isCodeModule(id: string): boolean {
9
+ return /\.(tsx?|jsx?)($|\?)/.test(id);
10
+ }
11
+
12
+ function normalizeModuleId(id: string): string {
13
+ return id.split("?", 1)[0];
14
+ }
15
+
16
+ function getClientModuleSignature(
17
+ source: string,
18
+ ): ClientModuleSignature | undefined {
19
+ let program: any;
20
+ try {
21
+ program = parseAst(source, { jsx: true });
22
+ } catch {
23
+ return undefined;
24
+ }
25
+
26
+ let isUseClient = false;
27
+ for (const node of program.body ?? []) {
28
+ if (
29
+ node?.type === "ExpressionStatement" &&
30
+ node.expression?.type === "Literal" &&
31
+ typeof node.expression.value === "string"
32
+ ) {
33
+ if (node.expression.value === "use client") {
34
+ isUseClient = true;
35
+ }
36
+ continue;
37
+ }
38
+ break;
39
+ }
40
+
41
+ if (!isUseClient) return undefined;
42
+
43
+ const exports = new Set<string>();
44
+ let hasDefault = false;
45
+ let hasExportAll = false;
46
+
47
+ const collectBindingNames = (pattern: any) => {
48
+ if (!pattern) return;
49
+ if (pattern.type === "Identifier") {
50
+ exports.add(pattern.name);
51
+ } else if (pattern.type === "ObjectPattern") {
52
+ for (const prop of pattern.properties ?? []) {
53
+ if (prop?.type === "RestElement") {
54
+ collectBindingNames(prop.argument);
55
+ } else {
56
+ collectBindingNames(prop?.value);
57
+ }
58
+ }
59
+ } else if (pattern.type === "ArrayPattern") {
60
+ for (const el of pattern.elements ?? []) {
61
+ if (el?.type === "RestElement") {
62
+ collectBindingNames(el.argument);
63
+ } else {
64
+ collectBindingNames(el);
65
+ }
66
+ }
67
+ }
68
+ };
69
+
70
+ const collectDeclarationNames = (declaration: any) => {
71
+ if (!declaration) return;
72
+ if (declaration.type === "VariableDeclaration") {
73
+ for (const decl of declaration.declarations ?? []) {
74
+ collectBindingNames(decl?.id);
75
+ }
76
+ return;
77
+ }
78
+ collectBindingNames(declaration.id);
79
+ };
80
+
81
+ for (const node of program.body ?? []) {
82
+ if (node?.type === "ExportDefaultDeclaration") {
83
+ hasDefault = true;
84
+ continue;
85
+ }
86
+ if (node?.type === "ExportAllDeclaration") {
87
+ hasExportAll = true;
88
+ continue;
89
+ }
90
+ if (node?.type !== "ExportNamedDeclaration") continue;
91
+
92
+ collectDeclarationNames(node.declaration);
93
+
94
+ for (const specifier of node.specifiers ?? []) {
95
+ const exportedName =
96
+ specifier?.exported?.name ?? specifier?.exported?.value;
97
+ if (exportedName === "default") {
98
+ hasDefault = true;
99
+ } else if (typeof exportedName === "string") {
100
+ exports.add(exportedName);
101
+ }
102
+ }
103
+ }
104
+
105
+ return {
106
+ key: JSON.stringify({
107
+ default: hasDefault,
108
+ exportAll: hasExportAll,
109
+ exports: [...exports].sort(),
110
+ }),
111
+ };
112
+ }
113
+
4
114
  /**
5
115
  * Plugin providing rsc-router:version virtual module.
6
116
  * Exports VERSION that changes when RSC modules change (dev) or at build time (production).
@@ -23,6 +133,20 @@ export function createVersionPlugin(): Plugin {
23
133
  let currentVersion = buildVersion;
24
134
  let isDev = false;
25
135
  let server: any = null;
136
+ const clientModuleSignatures = new Map<string, ClientModuleSignature>();
137
+
138
+ const bumpVersion = (reason: string) => {
139
+ currentVersion = Date.now().toString(16);
140
+ console.log(`[rsc-router] ${reason}, version updated: ${currentVersion}`);
141
+
142
+ const rscEnv = server?.environments?.rsc;
143
+ const versionMod = rscEnv?.moduleGraph?.getModuleById(
144
+ "\0" + VIRTUAL_IDS.version,
145
+ );
146
+ if (versionMod) {
147
+ rscEnv.moduleGraph.invalidateModule(versionMod);
148
+ }
149
+ };
26
150
 
27
151
  return {
28
152
  name: "@rangojs/router:version",
@@ -34,6 +158,13 @@ export function createVersionPlugin(): Plugin {
34
158
 
35
159
  configureServer(devServer) {
36
160
  server = devServer;
161
+
162
+ devServer.watcher.on("unlink", (filePath) => {
163
+ if (!isDev) return;
164
+ if (!clientModuleSignatures.has(filePath)) return;
165
+ clientModuleSignatures.delete(filePath);
166
+ bumpVersion("Client module removed");
167
+ });
37
168
  },
38
169
 
39
170
  resolveId(id) {
@@ -50,8 +181,27 @@ export function createVersionPlugin(): Plugin {
50
181
  return null;
51
182
  },
52
183
 
184
+ transform(code, id) {
185
+ if (!isDev || !isCodeModule(id)) return null;
186
+ const normalizedId = normalizeModuleId(id);
187
+ if (
188
+ !code.includes("use client") &&
189
+ !clientModuleSignatures.has(normalizedId)
190
+ ) {
191
+ return null;
192
+ }
193
+
194
+ const signature = getClientModuleSignature(code);
195
+ if (signature) {
196
+ clientModuleSignatures.set(normalizedId, signature);
197
+ } else {
198
+ clientModuleSignatures.delete(normalizedId);
199
+ }
200
+ return null;
201
+ },
202
+
53
203
  // Track RSC module changes and update version
54
- hotUpdate(ctx) {
204
+ async hotUpdate(ctx) {
55
205
  if (!isDev) return;
56
206
 
57
207
  // Check if this is an RSC environment update (not client/ssr)
@@ -59,26 +209,46 @@ export function createVersionPlugin(): Plugin {
59
209
  // In Vite 6, environment is accessed via `this.environment`
60
210
  const isRscModule = this.environment?.name === "rsc";
61
211
 
62
- if (isRscModule && ctx.modules.length > 0) {
63
- // Update version when RSC modules change
64
- currentVersion = Date.now().toString(16);
65
- console.log(
66
- `[rsc-router] RSC module changed, version updated: ${currentVersion}`,
67
- );
68
-
69
- // Invalidate the version module so it gets reloaded with new version
70
- if (server) {
71
- const rscEnv = server.environments?.rsc;
72
- if (rscEnv?.moduleGraph) {
73
- const versionMod = rscEnv.moduleGraph.getModuleById(
74
- "\0" + VIRTUAL_IDS.version,
75
- );
76
- if (versionMod) {
77
- rscEnv.moduleGraph.invalidateModule(versionMod);
212
+ if (!isRscModule) return;
213
+
214
+ if (isCodeModule(ctx.file)) {
215
+ const filePath = normalizeModuleId(ctx.file);
216
+ const previousSignature = clientModuleSignatures.get(filePath);
217
+ try {
218
+ const source = await ctx.read();
219
+ const nextSignature = getClientModuleSignature(source);
220
+ if (nextSignature) {
221
+ // "use client" file — compare export signatures.
222
+ // client-component-hmr may have cleared ctx.modules, so we
223
+ // cannot rely on ctx.modules.length for these files.
224
+ clientModuleSignatures.set(filePath, nextSignature);
225
+ if (
226
+ previousSignature &&
227
+ previousSignature.key === nextSignature.key
228
+ ) {
229
+ return;
78
230
  }
231
+ } else {
232
+ clientModuleSignatures.delete(filePath);
233
+ if (!previousSignature) {
234
+ // Not and never was "use client" — use module graph check.
235
+ // ctx.modules is reliable for pure server files (only
236
+ // client-component-hmr clears it for "use client" modules).
237
+ if (ctx.modules.length === 0) return;
238
+ }
239
+ // Was "use client" but directive removed — boundary changed,
240
+ // bump below.
79
241
  }
242
+ } catch {
243
+ // Fail open: if we can't read or parse the update, invalidate.
80
244
  }
245
+ } else {
246
+ // Non-code file (json, css, etc.) — only bump if it's actually
247
+ // referenced by the RSC module graph.
248
+ if (ctx.modules.length === 0) return;
81
249
  }
250
+
251
+ bumpVersion("RSC module changed");
82
252
  },
83
253
  };
84
254
  }