@rangojs/router 0.22.1 → 0.24.0

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 (259) hide show
  1. package/dist/bin/rango.js +10 -4
  2. package/dist/testing/vitest.js +31 -1
  3. package/dist/types/browser/defer.d.ts +48 -0
  4. package/dist/types/browser/document-revealed.d.ts +17 -0
  5. package/dist/types/browser/event-controller.d.ts +7 -1
  6. package/dist/types/browser/merge-segment-loaders.d.ts +5 -2
  7. package/dist/types/browser/navigation-transaction.d.ts +21 -7
  8. package/dist/types/browser/partial-update.d.ts +39 -8
  9. package/dist/types/browser/react/NavigationProvider.d.ts +19 -5
  10. package/dist/types/browser/react/context.d.ts +3 -2
  11. package/dist/types/browser/react/filter-segment-order.d.ts +7 -0
  12. package/dist/types/browser/response-adapter.d.ts +7 -0
  13. package/dist/types/browser/types.d.ts +24 -10
  14. package/dist/types/browser/validate-redirect-origin.d.ts +11 -0
  15. package/dist/types/build/generate-route-types.d.ts +1 -1
  16. package/dist/types/build/route-types/router-processing.d.ts +1 -1
  17. package/dist/types/build/route-types/scan-filter.d.ts +22 -2
  18. package/dist/types/cache/cf/cf-cache-store.d.ts +16 -1
  19. package/dist/types/cache/cf/cf-cache-types.d.ts +23 -2
  20. package/dist/types/cache/document-cache-policy.d.ts +24 -0
  21. package/dist/types/cache/document-cache.d.ts +2 -10
  22. package/dist/types/client-urls/client-urls.d.ts +1 -1
  23. package/dist/types/client-urls/hoisted-revalidate.d.ts +13 -0
  24. package/dist/types/client-urls/navigation.d.ts +0 -37
  25. package/dist/types/client-urls/revalidate-registry.d.ts +4 -0
  26. package/dist/types/client-urls/server-projection.d.ts +29 -1
  27. package/dist/types/client-urls/types.d.ts +34 -69
  28. package/dist/types/client.d.ts +0 -1
  29. package/dist/types/context-var.d.ts +12 -0
  30. package/dist/types/errors.d.ts +15 -0
  31. package/dist/types/handles/deferred-resolution.d.ts +2 -1
  32. package/dist/types/handles/pushed-by.d.ts +31 -0
  33. package/dist/types/index.d.ts +4 -0
  34. package/dist/types/index.rsc.d.ts +2 -0
  35. package/dist/types/loader-store.d.ts +8 -0
  36. package/dist/types/prefetch-markers.d.ts +11 -0
  37. package/dist/types/response-utils.d.ts +7 -0
  38. package/dist/types/reverse.d.ts +8 -8
  39. package/dist/types/route-definition/helpers-types.d.ts +27 -0
  40. package/dist/types/route-definition/rewrite.d.ts +56 -0
  41. package/dist/types/route-map-builder.d.ts +7 -2
  42. package/dist/types/router/basename.d.ts +7 -0
  43. package/dist/types/router/handler-context.d.ts +1 -5
  44. package/dist/types/router/internal-params.d.ts +8 -0
  45. package/dist/types/router/is-action.d.ts +2 -1
  46. package/dist/types/router/middleware-types.d.ts +15 -0
  47. package/dist/types/router/revalidation.d.ts +1 -2
  48. package/dist/types/router/route-trie-builder.d.ts +10 -0
  49. package/dist/types/router/segment-resolution/loader-mask.d.ts +3 -2
  50. package/dist/types/router/segment-resolution/mask-nested.d.ts +4 -2
  51. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +264 -0
  52. package/dist/types/rsc/full-payload.d.ts +11 -0
  53. package/dist/types/rsc/handler.d.ts +33 -0
  54. package/dist/types/rsc/helpers.d.ts +8 -0
  55. package/dist/types/rsc/loader-fetch.d.ts +16 -0
  56. package/dist/types/rsc/rewrite-passes.d.ts +74 -0
  57. package/dist/types/rsc/ssr-setup.d.ts +3 -1
  58. package/dist/types/server/context.d.ts +21 -1
  59. package/dist/types/server/handle-store.d.ts +9 -1
  60. package/dist/types/server/request-context.d.ts +52 -2
  61. package/dist/types/server/rewrite-trail.d.ts +49 -0
  62. package/dist/types/server/slot-accessors.d.ts +29 -0
  63. package/dist/types/server.d.ts +1 -0
  64. package/dist/types/testing/flight.entry.d.ts +1 -1
  65. package/dist/types/testing/index.d.ts +4 -4
  66. package/dist/types/testing/internal/action-id.d.ts +10 -1
  67. package/dist/types/testing/internal/client-urls-projection.d.ts +16 -0
  68. package/dist/types/testing/internal/test-action.d.ts +8 -0
  69. package/dist/types/testing/render-route.d.ts +8 -0
  70. package/dist/types/testing/run-loader.d.ts +2 -1
  71. package/dist/types/testing/run-middleware.d.ts +20 -0
  72. package/dist/types/testing/run-revalidate.d.ts +74 -0
  73. package/dist/types/testing/run-transition-when.d.ts +2 -3
  74. package/dist/types/testing/serve-shell-request.d.ts +40 -1
  75. package/dist/types/testing/vitest.d.ts +11 -0
  76. package/dist/types/thenable-status.d.ts +6 -0
  77. package/dist/types/types/boundaries.d.ts +9 -0
  78. package/dist/types/types/handler-context.d.ts +14 -0
  79. package/dist/types/types/loader-types.d.ts +30 -0
  80. package/dist/types/types/request-scope.d.ts +7 -0
  81. package/dist/types/types/segments.d.ts +21 -4
  82. package/dist/types/urls/path-helper-types.d.ts +25 -4
  83. package/dist/types/urls/type-extraction.d.ts +6 -2
  84. package/dist/types/vite/discovery/discover-routers.d.ts +9 -0
  85. package/dist/types/vite/discovery/state.d.ts +14 -4
  86. package/dist/types/vite/plugins/client-urls-revalidate-hoist.d.ts +54 -0
  87. package/dist/types/vite/plugins/hoist/literal.d.ts +83 -0
  88. package/dist/types/vite/plugins/hoist/scope.d.ts +40 -0
  89. package/dist/types/vite/plugins/hoist/source.d.ts +41 -0
  90. package/dist/types/vite/plugins/transition-when-hoist.d.ts +8 -11
  91. package/dist/types/vite/router-discovery.d.ts +10 -0
  92. package/dist/vite/index.js +974 -379
  93. package/package.json +1 -1
  94. package/skills/caching/SKILL.md +54 -5
  95. package/skills/catalog.json +3 -3
  96. package/skills/client-urls/SKILL.md +197 -24
  97. package/skills/cloudflare/SKILL.md +14 -9
  98. package/skills/hooks/SKILL.md +16 -3
  99. package/skills/hooks/data.md +3 -1
  100. package/skills/links/SKILL.md +24 -0
  101. package/skills/loader/SKILL.md +199 -2
  102. package/skills/middleware/SKILL.md +183 -2
  103. package/skills/migrate-nextjs/SKILL.md +2 -2
  104. package/skills/migrate-react-router/SKILL.md +10 -7
  105. package/skills/migrate-react-router/data-and-actions.md +1 -1
  106. package/skills/ppr/SKILL.md +22 -0
  107. package/skills/prerender/SKILL.md +22 -8
  108. package/skills/rango/SKILL.md +13 -7
  109. package/skills/route/SKILL.md +139 -0
  110. package/skills/server-actions/SKILL.md +27 -0
  111. package/skills/testing/SKILL.md +3 -2
  112. package/skills/testing/client-components.md +2 -1
  113. package/skills/testing/middleware.md +31 -10
  114. package/skills/testing/predicates.md +67 -32
  115. package/skills/testing/response-routes.md +1 -0
  116. package/skills/testing/serve-shell-request.md +107 -19
  117. package/skills/testing/setup.md +2 -2
  118. package/skills/typesafety/route-types.md +2 -2
  119. package/skills/use-cache/SKILL.md +4 -2
  120. package/skills/view-transitions/SKILL.md +26 -0
  121. package/src/__augment-tests__/augmented.check.ts +20 -0
  122. package/src/browser/defer.ts +82 -0
  123. package/src/browser/document-revealed.ts +52 -0
  124. package/src/browser/event-controller.ts +46 -32
  125. package/src/browser/merge-segment-loaders.ts +31 -18
  126. package/src/browser/navigation-bridge.ts +82 -16
  127. package/src/browser/navigation-client.ts +48 -36
  128. package/src/browser/navigation-store.ts +13 -0
  129. package/src/browser/navigation-transaction.ts +25 -8
  130. package/src/browser/partial-update.ts +554 -146
  131. package/src/browser/prefetch/fetch.ts +2 -1
  132. package/src/browser/react/Link.tsx +7 -9
  133. package/src/browser/react/NavigationProvider.tsx +88 -49
  134. package/src/browser/react/context.ts +3 -2
  135. package/src/browser/react/filter-segment-order.ts +13 -2
  136. package/src/browser/react/use-router.ts +2 -4
  137. package/src/browser/response-adapter.ts +20 -0
  138. package/src/browser/rsc-router.tsx +17 -45
  139. package/src/browser/server-action-bridge.ts +25 -63
  140. package/src/browser/types.ts +24 -10
  141. package/src/browser/validate-redirect-origin.ts +16 -0
  142. package/src/build/generate-route-types.ts +1 -0
  143. package/src/build/route-types/router-processing.ts +3 -6
  144. package/src/build/route-types/scan-filter.ts +45 -11
  145. package/src/cache/cf/cf-cache-store.ts +59 -21
  146. package/src/cache/cf/cf-cache-types.ts +24 -2
  147. package/src/cache/document-cache-policy.ts +131 -0
  148. package/src/cache/document-cache.ts +21 -107
  149. package/src/client-urls/client-urls.ts +22 -6
  150. package/src/client-urls/hoisted-revalidate.ts +25 -0
  151. package/src/client-urls/navigation.ts +0 -96
  152. package/src/client-urls/revalidate-registry.ts +30 -0
  153. package/src/client-urls/server-projection.ts +151 -65
  154. package/src/client-urls/types.ts +39 -71
  155. package/src/client.tsx +9 -5
  156. package/src/context-var.ts +32 -2
  157. package/src/errors.ts +34 -0
  158. package/src/handles/deferred-resolution.ts +28 -5
  159. package/src/handles/pushed-by.ts +148 -0
  160. package/src/index.rsc.ts +8 -0
  161. package/src/index.ts +7 -0
  162. package/src/loader-store.ts +18 -7
  163. package/src/prefetch-markers.ts +13 -0
  164. package/src/response-utils.ts +19 -0
  165. package/src/reverse.ts +17 -14
  166. package/src/route-content-wrapper.tsx +18 -4
  167. package/src/route-definition/dsl-helpers.ts +27 -4
  168. package/src/route-definition/helpers-types.ts +27 -1
  169. package/src/route-definition/redirect.ts +2 -15
  170. package/src/route-definition/rewrite.ts +146 -0
  171. package/src/route-map-builder.ts +14 -11
  172. package/src/router/basename.ts +20 -0
  173. package/src/router/handler-context.ts +3 -13
  174. package/src/router/intercept-resolution.ts +35 -16
  175. package/src/router/internal-params.ts +16 -0
  176. package/src/router/is-action.ts +2 -1
  177. package/src/router/loader-resolution.ts +85 -4
  178. package/src/router/match-api.ts +36 -1
  179. package/src/router/match-middleware/background-revalidation.ts +16 -4
  180. package/src/router/match-middleware/cache-lookup.ts +35 -7
  181. package/src/router/match-middleware/cache-store.ts +15 -1
  182. package/src/router/match-middleware/intercept-resolution.ts +4 -2
  183. package/src/router/match-result.ts +14 -2
  184. package/src/router/middleware-types.ts +16 -0
  185. package/src/router/middleware.ts +127 -49
  186. package/src/router/navigation-snapshot.ts +2 -1
  187. package/src/router/revalidation.ts +1 -1
  188. package/src/router/route-trie-builder.ts +28 -1
  189. package/src/router/segment-resolution/fresh.ts +22 -18
  190. package/src/router/segment-resolution/loader-cache.ts +2 -1
  191. package/src/router/segment-resolution/loader-mask.ts +5 -3
  192. package/src/router/segment-resolution/loader-snapshot.ts +18 -4
  193. package/src/router/segment-resolution/mask-nested.ts +8 -4
  194. package/src/router/segment-resolution/prefetch-deferral.ts +583 -0
  195. package/src/router/segment-resolution/revalidation.ts +215 -82
  196. package/src/rsc/full-payload.ts +37 -5
  197. package/src/rsc/handler.ts +82 -55
  198. package/src/rsc/helpers.ts +24 -0
  199. package/src/rsc/loader-fetch.ts +79 -24
  200. package/src/rsc/response-cache-serve.ts +18 -2
  201. package/src/rsc/response-route-handler.ts +4 -7
  202. package/src/rsc/rewrite-passes.ts +203 -0
  203. package/src/rsc/rsc-rendering.ts +54 -9
  204. package/src/rsc/shell-capture.ts +2 -1
  205. package/src/rsc/ssr-setup.ts +11 -5
  206. package/src/segment-loader-promise.ts +23 -20
  207. package/src/segment-system.tsx +60 -26
  208. package/src/server/context.ts +39 -5
  209. package/src/server/cookie-parse.ts +36 -12
  210. package/src/server/cookie-store.ts +7 -3
  211. package/src/server/handle-store.ts +152 -25
  212. package/src/server/request-context.ts +175 -64
  213. package/src/server/request-headers.ts +4 -14
  214. package/src/server/rewrite-trail.ts +190 -0
  215. package/src/server/slot-accessors.ts +57 -0
  216. package/src/server.ts +4 -0
  217. package/src/ssr/index.tsx +118 -16
  218. package/src/testing/action-form.ts +2 -11
  219. package/src/testing/dispatch.ts +138 -67
  220. package/src/testing/e2e/page-helpers.ts +3 -1
  221. package/src/testing/flight.entry.ts +1 -0
  222. package/src/testing/index.ts +7 -3
  223. package/src/testing/internal/action-id.ts +16 -1
  224. package/src/testing/internal/client-urls-projection.ts +70 -0
  225. package/src/testing/internal/test-action.ts +25 -0
  226. package/src/testing/render-route.tsx +16 -3
  227. package/src/testing/run-loader.ts +4 -1
  228. package/src/testing/run-middleware.ts +56 -11
  229. package/src/testing/run-revalidate.ts +142 -0
  230. package/src/testing/run-transition-when.ts +7 -8
  231. package/src/testing/serve-shell-request.ts +131 -8
  232. package/src/testing/vitest.ts +67 -1
  233. package/src/thenable-status.ts +11 -0
  234. package/src/types/boundaries.ts +12 -0
  235. package/src/types/handler-context.ts +14 -0
  236. package/src/types/loader-types.ts +30 -0
  237. package/src/types/request-scope.ts +8 -0
  238. package/src/types/segments.ts +26 -4
  239. package/src/urls/path-helper-types.ts +27 -5
  240. package/src/urls/type-extraction.ts +30 -10
  241. package/src/use-loader.tsx +18 -13
  242. package/src/vite/discovery/client-urls-projection.ts +20 -21
  243. package/src/vite/discovery/discover-routers.ts +94 -26
  244. package/src/vite/discovery/state.ts +28 -4
  245. package/src/vite/discovery/virtual-module-codegen.ts +22 -17
  246. package/src/vite/plugins/client-urls-revalidate-hoist.ts +564 -0
  247. package/src/vite/plugins/expose-internal-ids.ts +2 -3
  248. package/src/vite/plugins/hoist/literal.ts +417 -0
  249. package/src/vite/plugins/hoist/scope.ts +370 -0
  250. package/src/vite/plugins/hoist/source.ts +84 -0
  251. package/src/vite/plugins/transition-when-hoist.ts +113 -779
  252. package/src/vite/rango.ts +2 -0
  253. package/src/vite/router-discovery.ts +116 -53
  254. package/dist/types/client-urls/revalidate-chain.d.ts +0 -33
  255. package/dist/types/client-urls/revalidation-protocol.d.ts +0 -25
  256. package/dist/types/testing/run-client-revalidate.d.ts +0 -43
  257. package/src/client-urls/revalidate-chain.ts +0 -83
  258. package/src/client-urls/revalidation-protocol.ts +0 -56
  259. package/src/testing/run-client-revalidate.ts +0 -92
@@ -0,0 +1,564 @@
1
+ import type { Plugin } from "vite";
2
+ import { normalizePath, parseAst } from "vite";
3
+ import MagicString from "magic-string";
4
+ import { createHash } from "node:crypto";
5
+ import { relative } from "node:path";
6
+ import { hasDirective } from "@vitejs/plugin-rsc/transforms";
7
+ import { HOISTED_REVALIDATE_KEY } from "../../client-urls/hoisted-revalidate.js";
8
+ import { hasUseClientDirective } from "../utils/directive-prologue.js";
9
+ import { offsetToLineColumn } from "./expose-ids/export-analysis.js";
10
+ import {
11
+ analyzeScopes,
12
+ collectImports,
13
+ TYPE_KEYS,
14
+ type ImportBinding,
15
+ type Node,
16
+ } from "./hoist/scope.js";
17
+ import {
18
+ collectFreeReferences,
19
+ emitImports,
20
+ findHelperCalls,
21
+ findLexicalCapture,
22
+ isFunctionLiteral,
23
+ parseLang,
24
+ unwrapExpression,
25
+ type LexicalCaptureMessages,
26
+ } from "./hoist/literal.js";
27
+ import {
28
+ loadEmittedOrDisk,
29
+ readSource,
30
+ recordEmitted,
31
+ resolveSourceTarget,
32
+ SOURCE_FILE_RE,
33
+ type EmittedEntry,
34
+ } from "./hoist/source.js";
35
+
36
+ /**
37
+ * clientUrls() loader `revalidate()` runs on the server, with the arguments a
38
+ * urls() loader's predicate gets. The predicate is declared in a
39
+ * "use client" module, so the build moves it into a server module (the
40
+ * reverse of transition-when-hoist.ts):
41
+ *
42
+ * loader(CartLoader, () => [
43
+ * revalidate((ctx) => ctx.isAction(Cart) ? ctx.actionResult?.ok : undefined),
44
+ * ])
45
+ *
46
+ * becomes, in the client and SSR copies of the module,
47
+ *
48
+ * loader(CartLoader, () => [revalidate({ $$rangoRevalidate: "<key>" })])
49
+ *
50
+ * and `<file>?rango-revalidate` (a query on the file itself, so relative
51
+ * imports resolve as they did there and an edit invalidates it) is
52
+ *
53
+ * import { registerClientUrlRevalidators } from "@rangojs/router/server";
54
+ * <the module-level imports the predicates reference>
55
+ * registerClientUrlRevalidators({ "<key>": (ctx) => ... });
56
+ *
57
+ * plugin-rsc replaces the RSC copy of a "use client" module with client
58
+ * reference proxies and drops everything else, so a post transform appends
59
+ * `import "<file>?rango-revalidate"` to the proxy: the predicates register
60
+ * when the group's module loads on the server (a lazy include stays lazy).
61
+ * Route discovery serializes the SSR copy, so the projection carries the
62
+ * keys; materialization (client-urls/server-projection.ts) attaches the
63
+ * registered predicates to the loader stubs.
64
+ *
65
+ * A key hashes the project-relative file and the predicate's syntax tree
66
+ * (formatting, comments and TypeScript excluded, so a re-printing transform
67
+ * in one environment keeps it): two environments that saw a different
68
+ * predicate fail loudly at materialization instead of pairing a loader with
69
+ * the wrong one.
70
+ *
71
+ * Keyed on calls of the `revalidate` helper a clientUrls() builder callback
72
+ * receives (destructured, renamed, or as `helpers.revalidate`), with
73
+ * clientUrls imported from "@rangojs/router/client". The argument may
74
+ * reference imports and globals only: a binding of the module or of an
75
+ * enclosing function, `this`/`arguments`/`super`/`new.target`, `import()`,
76
+ * `import.meta` other than `import.meta.env`, a browser-only global, an
77
+ * `async` or generator function, or an import of a "use client" module is a
78
+ * LOUD error naming file:line. Imports stay in the "use client" module too
79
+ * (they are not pruned). Vitest does not run the plugin, so in unit tests the
80
+ * predicate stays a plain function.
81
+ */
82
+
83
+ const CLIENT_URLS_REVALIDATE_QUERY = "rango-revalidate";
84
+ const REGISTER = "__rango_registerClientUrlRevalidators";
85
+
86
+ const CLIENT_SOURCES: ReadonlySet<string> = new Set(["@rangojs/router/client"]);
87
+
88
+ const BROWSER_GLOBALS: ReadonlySet<string> = new Set([
89
+ "window",
90
+ "document",
91
+ "navigator",
92
+ "localStorage",
93
+ "sessionStorage",
94
+ "location",
95
+ "history",
96
+ ]);
97
+
98
+ const NOT_A_FUNCTION = new Set([
99
+ "ObjectExpression",
100
+ "ArrayExpression",
101
+ "Literal",
102
+ "TemplateLiteral",
103
+ "ClassExpression",
104
+ ]);
105
+
106
+ const CAPTURE_MESSAGES: LexicalCaptureMessages = {
107
+ lexical: {
108
+ this: "`this` in an arrow is the enclosing function's `this`.",
109
+ arguments: "`arguments` in an arrow is the enclosing function's.",
110
+ super: "`super` refers to the enclosing object.",
111
+ "new.target": "`new.target` in an arrow is the enclosing function's.",
112
+ },
113
+ captureHint: "Read what you need from the predicate's argument.",
114
+ loadsHint: "Import server-safe modules statically.",
115
+ importMetaWhy: 'is the "use client" module\'s.',
116
+ importMetaMemberWhy:
117
+ 'is resolved against the "use client" module; only `import.meta.env` ' +
118
+ "is available to a hoisted predicate.",
119
+ dynamicImportWhy:
120
+ "loads a module at run time, which the build cannot check is server-safe.",
121
+ };
122
+
123
+ // Cheap pre-filter: every site needs a "use client" module (the directive's
124
+ // text), clientUrls imported from the router, and a `revalidate` helper. The
125
+ // AST walk is the authority.
126
+ const CLIENT_URLS_RE = /\bclientUrls\b/;
127
+ const CLIENT_IMPORT_RE = /["']@rangojs\/router\/client["']/;
128
+ const REVALIDATE_RE = /\brevalidate\b/;
129
+
130
+ function mayHoist(code: string): boolean {
131
+ return (
132
+ code.includes("use client") &&
133
+ CLIENT_URLS_RE.test(code) &&
134
+ CLIENT_IMPORT_RE.test(code) &&
135
+ REVALIDATE_RE.test(code)
136
+ );
137
+ }
138
+
139
+ function revalidateError(
140
+ file: string,
141
+ code: string,
142
+ node: Node,
143
+ detail: string,
144
+ ): Error {
145
+ const { line, column } = offsetToLineColumn(code, node.start);
146
+ return new Error(
147
+ `[rango] clientUrls() revalidate() at ${file}:${line}:${column}: ` +
148
+ `revalidate() runs on the server; ${detail}`,
149
+ );
150
+ }
151
+
152
+ /** Fields that change with formatting, comments or TS stripping. */
153
+ const FINGERPRINT_SKIP: ReadonlySet<string> = new Set([
154
+ "type",
155
+ "start",
156
+ "end",
157
+ "loc",
158
+ "range",
159
+ "raw",
160
+ "extra",
161
+ "shorthand",
162
+ // TS-only flags (`let x!: T`), lost with the types.
163
+ "definite",
164
+ ...TYPE_KEYS,
165
+ ]);
166
+
167
+ /**
168
+ * The predicate's syntax tree, serialized without what a re-printing
169
+ * transform changes: whitespace, comments, parentheses, quotes, shorthand
170
+ * properties and TypeScript. plugin-react's React Compiler re-prints a
171
+ * "use client" module in the client and SSR environments only, so a key over
172
+ * the source text would differ from the RSC environment's.
173
+ */
174
+ function fingerprint(node: Node): string {
175
+ const out: string[] = [];
176
+ const visit = (value: unknown): void => {
177
+ if (value === null || value === undefined) return;
178
+ if (Array.isArray(value)) {
179
+ out.push("[");
180
+ for (const item of value) visit(item);
181
+ out.push("]");
182
+ return;
183
+ }
184
+ if (typeof value !== "object") {
185
+ out.push(JSON.stringify(typeof value === "bigint" ? `${value}n` : value));
186
+ return;
187
+ }
188
+ let entry = value as Node;
189
+ if (typeof entry.type === "string") {
190
+ entry = unwrapExpression(entry);
191
+ // Type-only syntax; an instantiation `f<T>` keeps its expression.
192
+ if (entry.type.startsWith("TS")) {
193
+ visit(entry.expression);
194
+ return;
195
+ }
196
+ out.push(entry.type, "(");
197
+ // A tag sees the raw text: String.raw`\x41` is not String.raw`A`.
198
+ if (entry.type === "TemplateElement") {
199
+ out.push(JSON.stringify(entry.value.raw));
200
+ }
201
+ }
202
+ for (const key of Object.keys(entry)) {
203
+ if (FINGERPRINT_SKIP.has(key)) continue;
204
+ // `x?: T` loses its `?` with its type; `a?.b` keeps it.
205
+ if (key === "optional" && entry.type === "Identifier") continue;
206
+ out.push(key);
207
+ visit(entry[key]);
208
+ }
209
+ if (typeof entry.type === "string") out.push(")");
210
+ };
211
+ visit(node);
212
+ return out.join(" ");
213
+ }
214
+
215
+ function keyOf(relativeFile: string, predicate: Node): string {
216
+ return createHash("sha256")
217
+ .update(relativeFile)
218
+ .update("\0")
219
+ .update(fingerprint(predicate))
220
+ .digest("hex")
221
+ .slice(0, 12);
222
+ }
223
+
224
+ /**
225
+ * The key of the predicate `source` declared in `relativeFile` (relative to
226
+ * the project root): a hash of the file and the predicate's syntax, so the
227
+ * same predicate gets one key in every environment and however it is
228
+ * formatted.
229
+ */
230
+ export function revalidateKey(relativeFile: string, source: string): string {
231
+ const program: Node = parseAst(`(${source});`, { lang: "tsx" });
232
+ return keyOf(relativeFile, unwrapExpression(program.body[0].expression));
233
+ }
234
+
235
+ interface HoistClientUrlsRevalidateDeps {
236
+ /** Project root: keys hash the file relative to it. */
237
+ readonly root: string;
238
+ /**
239
+ * Whether a module a predicate imports is a "use client" module beyond its
240
+ * specifier (the plugin resolves it and reads its directive prologue).
241
+ */
242
+ isClientModule?(source: string): Promise<boolean> | boolean;
243
+ }
244
+
245
+ interface HoistClientUrlsRevalidateResult {
246
+ /** The module with each predicate replaced by its marker. */
247
+ readonly code: string;
248
+ readonly map: ReturnType<MagicString["generateMap"]>;
249
+ /** The server module that registers every hoisted predicate. */
250
+ readonly module: string;
251
+ /** The keys, in declaration order, duplicates removed. */
252
+ readonly keys: readonly string[];
253
+ }
254
+
255
+ /**
256
+ * Hoist every clientUrls() `revalidate()` predicate of a "use client"
257
+ * module. Returns null when there is none. Throws, naming file:line, for a
258
+ * predicate that is not pure.
259
+ */
260
+ export async function hoistClientUrlsRevalidates(
261
+ code: string,
262
+ id: string,
263
+ deps: HoistClientUrlsRevalidateDeps,
264
+ ): Promise<HoistClientUrlsRevalidateResult | null> {
265
+ if (!mayHoist(code)) return null;
266
+ const file = normalizePath(id.split("?", 1)[0]!);
267
+ let program: Node;
268
+ try {
269
+ program = parseAst(code, { lang: parseLang(file) });
270
+ } catch {
271
+ return null;
272
+ }
273
+ if (!hasDirective(program.body, "use client")) return null;
274
+
275
+ const { moduleScope, references, scopeOf } = analyzeScopes(program);
276
+ const imports = collectImports(program);
277
+ const calls = findHelperCalls(program, scopeOf, moduleScope, imports, {
278
+ sources: CLIENT_SOURCES,
279
+ builder: "clientUrls",
280
+ helper: "revalidate",
281
+ directImport: false,
282
+ });
283
+ if (calls.length === 0) return null;
284
+
285
+ const relativeFile = normalizePath(relative(deps.root, file));
286
+ const s = new MagicString(code);
287
+ const used = new Map<string, boolean>();
288
+ const entries = new Map<string, string>();
289
+
290
+ for (const { call } of calls) {
291
+ const [arg, extra] = call.arguments;
292
+ if (!arg || extra || arg.type === "SpreadElement") {
293
+ throw revalidateError(
294
+ file,
295
+ code,
296
+ call,
297
+ "`revalidate()` takes exactly one predicate.",
298
+ );
299
+ }
300
+ const value = unwrapExpression(arg);
301
+ if (NOT_A_FUNCTION.has(value.type)) {
302
+ throw revalidateError(
303
+ file,
304
+ code,
305
+ value,
306
+ "`revalidate()` expects a predicate function.",
307
+ );
308
+ }
309
+ if (isFunctionLiteral(value) && (value.async || value.generator)) {
310
+ throw revalidateError(
311
+ file,
312
+ code,
313
+ value,
314
+ `a${value.async ? "n `async`" : " generator"} function returns ` +
315
+ `${value.async ? "a Promise" : "an iterator"}, which the router ` +
316
+ "ignores: return a boolean, `{ defaultShouldRevalidate }`, or " +
317
+ "nothing.",
318
+ );
319
+ }
320
+
321
+ const lexical = findLexicalCapture(
322
+ value,
323
+ references,
324
+ code,
325
+ CAPTURE_MESSAGES,
326
+ );
327
+ if (lexical)
328
+ throw revalidateError(file, code, lexical.node, lexical.detail);
329
+
330
+ const free = collectFreeReferences(value, references, moduleScope, imports);
331
+ const binding = free.bindings[0];
332
+ if (binding) {
333
+ const name = binding.node.name;
334
+ throw revalidateError(
335
+ file,
336
+ code,
337
+ binding.node,
338
+ binding.scope.lookup(name) === moduleScope
339
+ ? `\`${name}\` is declared in this "use client" module. Move the ` +
340
+ 'predicate to its own module (without "use client") and ' +
341
+ "import it, or write it inline."
342
+ : `\`${name}\` is a binding of the enclosing function. Read what ` +
343
+ "you need from the predicate's argument (`currentParams`, " +
344
+ "`nextUrl`, `context`, `actionResult`, ...).",
345
+ );
346
+ }
347
+ const browserGlobal = free.globals.find((ref) =>
348
+ BROWSER_GLOBALS.has(ref.node.name),
349
+ );
350
+ if (browserGlobal) {
351
+ throw revalidateError(
352
+ file,
353
+ code,
354
+ browserGlobal.node,
355
+ `\`${browserGlobal.node.name}\` is a browser global. Put the input ` +
356
+ "on the request (a cookie or a search param) and read it from the " +
357
+ "predicate's argument, or refresh the loader with useRefreshLoaders.",
358
+ );
359
+ }
360
+
361
+ const unchecked = [...free.imports].filter(
362
+ ([local, valueUse]) => valueUse && !used.get(local),
363
+ );
364
+ const clientOnly = await Promise.all(
365
+ unchecked.map(([local]) => isClientOnly(imports.get(local)!, deps)),
366
+ );
367
+ const offender = unchecked.find((_, i) => clientOnly[i]);
368
+ if (offender) {
369
+ const [local] = offender;
370
+ throw revalidateError(
371
+ file,
372
+ code,
373
+ value,
374
+ `\`${local}\` is imported from "${imports.get(local)!.source}", a ` +
375
+ 'client-only module: on the server a "use client" module\'s ' +
376
+ "exports are client references and cannot be called. Import the " +
377
+ 'predicate\'s inputs from a module without "use client".',
378
+ );
379
+ }
380
+ for (const [local, valueUse] of free.imports) {
381
+ used.set(local, (used.get(local) ?? false) || valueUse);
382
+ }
383
+
384
+ const source = code.slice(value.start, value.end);
385
+ const key = keyOf(relativeFile, value);
386
+ entries.set(key, source);
387
+ s.overwrite(
388
+ arg.start,
389
+ arg.end,
390
+ `{ ${HOISTED_REVALIDATE_KEY}: ${JSON.stringify(key)} }`,
391
+ );
392
+ }
393
+
394
+ const importLines = await emitImports(used, imports, () => {});
395
+ const module = [
396
+ `import { registerClientUrlRevalidators as ${REGISTER} } from "@rangojs/router/server";`,
397
+ ...importLines,
398
+ `${REGISTER}({`,
399
+ ...[...entries].map(
400
+ ([key, source]) => ` ${JSON.stringify(key)}: ${source},`,
401
+ ),
402
+ `});`,
403
+ "",
404
+ ].join("\n");
405
+
406
+ return {
407
+ code: s.toString(),
408
+ map: s.generateMap({ hires: true, source: file }),
409
+ module,
410
+ keys: [...entries.keys()],
411
+ };
412
+ }
413
+
414
+ async function isClientOnly(
415
+ binding: ImportBinding,
416
+ deps: HoistClientUrlsRevalidateDeps,
417
+ ): Promise<boolean> {
418
+ return (
419
+ binding.source === "client-only" ||
420
+ Boolean(await deps.isClientModule?.(binding.source))
421
+ );
422
+ }
423
+
424
+ /** The file a `?rango-revalidate` id names, or null. */
425
+ export function parseHoistedRevalidateId(id: string): string | null {
426
+ const q = id.indexOf("?");
427
+ if (q === -1) return null;
428
+ const params = new URLSearchParams(id.slice(q + 1));
429
+ return params.has(CLIENT_URLS_REVALIDATE_QUERY) ? id.slice(0, q) : null;
430
+ }
431
+
432
+ // The hook filters below are conservative screens (a superset of what the
433
+ // handlers accept): the handlers keep their exact checks.
434
+ const HOISTED_ID_RE = new RegExp(
435
+ `[?&]${CLIENT_URLS_REVALIDATE_QUERY}(?:[=&]|$)`,
436
+ );
437
+ const SOURCE_ID_RE = /\.[cm]?[jt]sx?(?:\?|$)/;
438
+ const NODE_MODULES_RE = /\/node_modules\//;
439
+
440
+ /**
441
+ * The plugins: a pre transform that hoists in every environment (markers in
442
+ * the client and SSR copies, the server module recorded from the RSC
443
+ * transform) and serves `?rango-revalidate`, and a post transform that
444
+ * appends its import to the RSC client-reference proxy.
445
+ *
446
+ * `load` serves the module the RSC transform emitted, which hoisted the
447
+ * pipeline code; the disk source is re-hoisted only when the transform has
448
+ * not seen the current file (loadEmittedOrDisk). A file whose edit removed
449
+ * every predicate serves an empty module to a proxy that still imports it.
450
+ *
451
+ * The transform's code filter skips a module that never mentions clientUrls,
452
+ * so a file that stops doing so would keep its `emitted` entry: watchChange
453
+ * drops it, together with the cached "use client" answer for an edited
454
+ * dependency (isClientModule memoizes per resolved file; buildStart resets
455
+ * it).
456
+ */
457
+ export function clientUrlsRevalidateHoistPlugin(): Plugin[] {
458
+ const emitted = new Map<string, EmittedEntry<string>>();
459
+ const clientModules = new Map<string, Promise<boolean>>();
460
+ let root = process.cwd();
461
+
462
+ const isClientFile = (file: string): Promise<boolean> => {
463
+ let known = clientModules.get(file);
464
+ if (!known) {
465
+ known = readSource(file).then(
466
+ (text) =>
467
+ text !== null &&
468
+ text.includes("use client") &&
469
+ hasUseClientDirective(text),
470
+ );
471
+ clientModules.set(file, known);
472
+ }
473
+ return known;
474
+ };
475
+
476
+ return [
477
+ {
478
+ name: "@rangojs/router:client-urls-revalidate-hoist",
479
+ enforce: "pre",
480
+
481
+ configResolved(config) {
482
+ root = config.root;
483
+ },
484
+
485
+ buildStart() {
486
+ clientModules.clear();
487
+ },
488
+
489
+ watchChange(id) {
490
+ const file = normalizePath(id);
491
+ clientModules.delete(file);
492
+ emitted.delete(file);
493
+ },
494
+
495
+ load: {
496
+ filter: { id: HOISTED_ID_RE },
497
+ async handler(id) {
498
+ const hoistedFile = parseHoistedRevalidateId(id);
499
+ if (hoistedFile === null) return;
500
+ const module = await loadEmittedOrDisk(
501
+ emitted,
502
+ hoistedFile,
503
+ async (disk) =>
504
+ (await hoistClientUrlsRevalidates(disk, hoistedFile, { root }))
505
+ ?.module,
506
+ );
507
+ return module ?? "export {};\n";
508
+ },
509
+ },
510
+
511
+ transform: {
512
+ filter: {
513
+ id: {
514
+ include: SOURCE_ID_RE,
515
+ exclude: [NODE_MODULES_RE, HOISTED_ID_RE],
516
+ },
517
+ code: CLIENT_URLS_RE,
518
+ },
519
+ async handler(code, id) {
520
+ if (id.includes("/node_modules/") || parseHoistedRevalidateId(id)) {
521
+ return;
522
+ }
523
+ const file = normalizePath(id.split("?", 1)[0]!);
524
+ if (!SOURCE_FILE_RE.test(file)) return;
525
+ const rsc = this.environment?.name === "rsc";
526
+ if (rsc) emitted.delete(file);
527
+ const result = await hoistClientUrlsRevalidates(code, id, {
528
+ root,
529
+ isClientModule: rsc
530
+ ? async (source) => {
531
+ const target = await resolveSourceTarget(this, source, id, {
532
+ skipNodeModules: false,
533
+ });
534
+ return target !== null && (await isClientFile(target));
535
+ }
536
+ : undefined,
537
+ });
538
+ if (!result) return;
539
+ if (rsc) await recordEmitted(emitted, file, result.module);
540
+ return { code: result.code, map: result.map };
541
+ },
542
+ },
543
+ },
544
+ {
545
+ name: "@rangojs/router:client-urls-revalidate-register",
546
+ enforce: "post",
547
+ applyToEnvironment: (env) => env.name === "rsc",
548
+
549
+ transform: {
550
+ filter: { code: "registerClientReference" },
551
+ handler(code, id) {
552
+ if (parseHoistedRevalidateId(id)) return;
553
+ const file = normalizePath(id.split("?", 1)[0]!);
554
+ if (!emitted.has(file)) return;
555
+ const s = new MagicString(code);
556
+ s.append(
557
+ `\nimport ${JSON.stringify(`${file}?${CLIENT_URLS_REVALIDATE_QUERY}`)};\n`,
558
+ );
559
+ return { code: s.toString(), map: s.generateMap({ hires: true }) };
560
+ },
561
+ },
562
+ },
563
+ ];
564
+ }
@@ -12,6 +12,7 @@ import {
12
12
  transformInlineHandlers,
13
13
  type VirtualHandlerEntry,
14
14
  } from "../utils/ast-handler-extract.js";
15
+ import { isSkippedSourceScanDir } from "../../build/route-types/scan-filter.js";
15
16
 
16
17
  import type {
17
18
  HandlerTransformConfig,
@@ -297,8 +298,6 @@ ${lazyImports.join(",\n")}
297
298
 
298
299
  const fs = await import("node:fs/promises");
299
300
 
300
- const SKIP_DIRS = new Set(["node_modules", "dist", "build", "coverage"]);
301
-
302
301
  async function scanDir(dir: string): Promise<string[]> {
303
302
  const results: string[] = [];
304
303
  try {
@@ -306,7 +305,7 @@ ${lazyImports.join(",\n")}
306
305
  for (const entry of entries) {
307
306
  const fullPath = path.join(dir, entry.name);
308
307
  if (entry.isDirectory()) {
309
- if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".")) {
308
+ if (!isSkippedSourceScanDir(entry.name)) {
310
309
  results.push(...(await scanDir(fullPath)));
311
310
  }
312
311
  } else if (/\.(ts|tsx|js|jsx)$/.test(entry.name)) {