@rangojs/router 0.20.0 → 0.21.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 (178) hide show
  1. package/dist/bin/rango.js +26 -14
  2. package/dist/types/browser/event-controller.d.ts +32 -0
  3. package/dist/types/browser/history-state.d.ts +16 -4
  4. package/dist/types/browser/navigation-transaction.d.ts +7 -0
  5. package/dist/types/browser/rango-state.d.ts +3 -1
  6. package/dist/types/browser/react/context.d.ts +11 -0
  7. package/dist/types/browser/react/location-state-shared.d.ts +84 -10
  8. package/dist/types/browser/react/location-state.d.ts +7 -2
  9. package/dist/types/browser/react/subscribe-to-nothing.d.ts +7 -0
  10. package/dist/types/cache/cache-scope.d.ts +4 -5
  11. package/dist/types/cache/cf/cf-cache-store.d.ts +27 -6
  12. package/dist/types/cache/cf/cf-cache-types.d.ts +12 -4
  13. package/dist/types/cache/handle-snapshot.d.ts +36 -50
  14. package/dist/types/cache/index.d.ts +2 -0
  15. package/dist/types/cache/shell-snapshot.d.ts +19 -3
  16. package/dist/types/cache/types.d.ts +21 -10
  17. package/dist/types/cache/vercel/vercel-cache-store.d.ts +28 -16
  18. package/dist/types/client-urls/navigation.d.ts +7 -1
  19. package/dist/types/client-urls/optimistic-location.d.ts +10 -4
  20. package/dist/types/handles/deferred-resolution.d.ts +11 -1
  21. package/dist/types/prerender/build-shell-capture.d.ts +8 -6
  22. package/dist/types/router/match-middleware/cache-lookup.d.ts +1 -1
  23. package/dist/types/router/navigation-snapshot.d.ts +8 -1
  24. package/dist/types/router/router-interfaces.d.ts +2 -1
  25. package/dist/types/router/router-options.d.ts +17 -7
  26. package/dist/types/router/segment-resolution/loader-cache.d.ts +21 -9
  27. package/dist/types/router-versions.d.ts +39 -0
  28. package/dist/types/rsc/handler-context.d.ts +5 -0
  29. package/dist/types/rsc/shell-build-manifest.d.ts +3 -2
  30. package/dist/types/rsc/types.d.ts +4 -6
  31. package/dist/types/server/build-version-table.d.ts +44 -0
  32. package/dist/types/server/handle-store.d.ts +87 -46
  33. package/dist/types/server/request-context.d.ts +22 -8
  34. package/dist/types/testing/build-versions.d.ts +32 -0
  35. package/dist/types/testing/dom.entry.d.ts +1 -1
  36. package/dist/types/testing/flight.entry.d.ts +1 -1
  37. package/dist/types/testing/index.d.ts +2 -0
  38. package/dist/types/testing/internal/seed-vars.d.ts +7 -2
  39. package/dist/types/testing/location-state-key.d.ts +5 -1
  40. package/dist/types/testing/render-route.d.ts +77 -6
  41. package/dist/types/testing/serve-shell-request.d.ts +21 -0
  42. package/dist/types/testing/vitest-stubs/version.d.ts +1 -0
  43. package/dist/types/types/handler-context.d.ts +8 -5
  44. package/dist/types/vite/discovery/build-versions.d.ts +393 -0
  45. package/dist/types/vite/discovery/installed-packages.d.ts +26 -0
  46. package/dist/types/vite/discovery/router-versions-phase.d.ts +52 -0
  47. package/dist/types/vite/discovery/shell-prerender-phase.d.ts +7 -17
  48. package/dist/types/vite/discovery/state.d.ts +14 -0
  49. package/dist/types/vite/encryption-key.d.ts +18 -0
  50. package/dist/types/vite/plugin-types.d.ts +27 -0
  51. package/dist/types/vite/plugins/{version-injector.d.ts → entry-bootstrap-injector.d.ts} +4 -6
  52. package/dist/types/vite/plugins/expose-ids/export-analysis.d.ts +1 -0
  53. package/dist/types/vite/plugins/expose-ids/router-transform.d.ts +14 -0
  54. package/dist/types/vite/plugins/stable-rsc-output.d.ts +51 -0
  55. package/dist/types/vite/plugins/version-plugin.d.ts +9 -11
  56. package/dist/types/vite/plugins/virtual-entries.d.ts +18 -1
  57. package/dist/types/vite/router-discovery.d.ts +6 -0
  58. package/dist/types/vite/utils/client-chunks.d.ts +2 -0
  59. package/dist/types/vite/utils/compare-strings.d.ts +7 -0
  60. package/dist/types/vite/utils/package-resolution.d.ts +8 -0
  61. package/dist/types/vite/utils/prerender-utils.d.ts +2 -0
  62. package/dist/vite/index.js +1537 -444
  63. package/package.json +1 -2
  64. package/skills/cache-guide/SKILL.md +84 -20
  65. package/skills/caching/SKILL.md +16 -5
  66. package/skills/client-urls/SKILL.md +16 -0
  67. package/skills/cloudflare/SKILL.md +2 -1
  68. package/skills/comparison/references/framework-comparison.md +16 -12
  69. package/skills/hooks/SKILL.md +3 -0
  70. package/skills/hooks/handle-and-actions.md +6 -0
  71. package/skills/hooks/navigation.md +28 -0
  72. package/skills/hooks/state.md +209 -8
  73. package/skills/hooks/urls.md +40 -1
  74. package/skills/host-router/SKILL.md +13 -0
  75. package/skills/intercept/SKILL.md +6 -0
  76. package/skills/loader/SKILL.md +19 -1
  77. package/skills/middleware/SKILL.md +2 -0
  78. package/skills/ppr/SKILL.md +119 -40
  79. package/skills/prerender/SKILL.md +7 -3
  80. package/skills/rango/SKILL.md +8 -7
  81. package/skills/router-setup/SKILL.md +15 -1
  82. package/skills/testing/SKILL.md +30 -26
  83. package/skills/testing/client-components.md +193 -8
  84. package/skills/testing/handles.md +2 -0
  85. package/skills/testing/loader.md +1 -1
  86. package/skills/testing/middleware.md +1 -1
  87. package/skills/testing/render-handler.md +18 -18
  88. package/skills/testing/serve-shell-request.md +63 -14
  89. package/skills/testing/server-actions.md +17 -17
  90. package/skills/testing/server-tree.md +1 -1
  91. package/skills/typesafety/generated-files-and-cli.md +1 -1
  92. package/skills/use-cache/SKILL.md +16 -5
  93. package/skills/vercel/SKILL.md +3 -3
  94. package/src/bin/rango.ts +1 -1
  95. package/src/browser/event-controller.ts +75 -2
  96. package/src/browser/history-state.ts +39 -17
  97. package/src/browser/navigation-bridge.ts +41 -18
  98. package/src/browser/navigation-transaction.ts +15 -10
  99. package/src/browser/partial-update.ts +10 -2
  100. package/src/browser/rango-state.ts +3 -1
  101. package/src/browser/react/Link.tsx +2 -3
  102. package/src/browser/react/NavigationProvider.tsx +25 -1
  103. package/src/browser/react/context.ts +13 -0
  104. package/src/browser/react/location-state-shared.ts +200 -34
  105. package/src/browser/react/location-state.ts +40 -75
  106. package/src/browser/react/subscribe-to-nothing.ts +7 -0
  107. package/src/browser/react/use-handle.ts +20 -1
  108. package/src/browser/rsc-router.tsx +33 -11
  109. package/src/browser/server-action-bridge.ts +17 -1
  110. package/src/cache/cache-scope.ts +18 -8
  111. package/src/cache/cf/cf-cache-store.ts +75 -55
  112. package/src/cache/cf/cf-cache-types.ts +12 -4
  113. package/src/cache/handle-snapshot.ts +69 -87
  114. package/src/cache/index.ts +4 -0
  115. package/src/cache/shell-snapshot.ts +35 -5
  116. package/src/cache/types.ts +21 -10
  117. package/src/cache/vercel/vercel-cache-store.ts +48 -20
  118. package/src/client-urls/client-root.tsx +1 -0
  119. package/src/client-urls/navigation.ts +10 -0
  120. package/src/client-urls/optimistic-location.ts +10 -4
  121. package/src/handles/deferred-resolution.ts +31 -0
  122. package/src/prerender/build-shell-capture.ts +11 -9
  123. package/src/router/manifest.ts +2 -0
  124. package/src/router/match-middleware/cache-lookup.ts +11 -18
  125. package/src/router/navigation-snapshot.ts +25 -6
  126. package/src/router/request-classification.ts +15 -12
  127. package/src/router/router-interfaces.ts +2 -1
  128. package/src/router/router-options.ts +17 -7
  129. package/src/router/segment-resolution/loader-cache.ts +95 -60
  130. package/src/router-versions.ts +60 -0
  131. package/src/rsc/full-payload.ts +6 -2
  132. package/src/rsc/handler-context.ts +5 -0
  133. package/src/rsc/handler.ts +14 -4
  134. package/src/rsc/rsc-rendering.ts +12 -1
  135. package/src/rsc/shell-build-manifest.ts +10 -7
  136. package/src/rsc/shell-capture.ts +36 -26
  137. package/src/rsc/types.ts +4 -6
  138. package/src/server/build-version-table.ts +115 -0
  139. package/src/server/handle-store.ts +344 -127
  140. package/src/server/request-context.ts +62 -11
  141. package/src/ssr/ssr-root.tsx +16 -4
  142. package/src/testing/build-versions.ts +64 -0
  143. package/src/testing/dispatch.ts +6 -0
  144. package/src/testing/dom.entry.ts +1 -0
  145. package/src/testing/flight.entry.ts +1 -0
  146. package/src/testing/index.ts +3 -0
  147. package/src/testing/internal/context.ts +2 -1
  148. package/src/testing/internal/seed-vars.ts +14 -2
  149. package/src/testing/location-state-key.ts +18 -8
  150. package/src/testing/render-handler.ts +2 -1
  151. package/src/testing/render-route.tsx +277 -43
  152. package/src/testing/serve-shell-request.ts +80 -11
  153. package/src/testing/vitest-stubs/version.ts +4 -1
  154. package/src/types/handler-context.ts +8 -5
  155. package/src/vite/discovery/build-versions.ts +1281 -0
  156. package/src/vite/discovery/installed-packages.ts +159 -0
  157. package/src/vite/discovery/prerender-collection.ts +38 -1
  158. package/src/vite/discovery/router-versions-phase.ts +336 -0
  159. package/src/vite/discovery/shell-prerender-phase.ts +28 -38
  160. package/src/vite/discovery/state.ts +19 -0
  161. package/src/vite/discovery/virtual-module-codegen.ts +7 -2
  162. package/src/vite/encryption-key.ts +101 -9
  163. package/src/vite/plugin-types.ts +28 -0
  164. package/src/vite/plugins/{version-injector.ts → entry-bootstrap-injector.ts} +7 -22
  165. package/src/vite/plugins/expose-ids/export-analysis.ts +1 -1
  166. package/src/vite/plugins/expose-ids/router-transform.ts +36 -15
  167. package/src/vite/plugins/expose-internal-ids.ts +12 -3
  168. package/src/vite/plugins/stable-rsc-output.ts +147 -0
  169. package/src/vite/plugins/version-plugin.ts +19 -24
  170. package/src/vite/plugins/version.d.ts +17 -2
  171. package/src/vite/plugins/virtual-entries.ts +31 -5
  172. package/src/vite/rango.ts +28 -3
  173. package/src/vite/router-discovery.ts +82 -13
  174. package/src/vite/utils/client-chunks.ts +5 -1
  175. package/src/vite/utils/compare-strings.ts +9 -0
  176. package/src/vite/utils/package-resolution.ts +9 -0
  177. package/src/vite/utils/prerender-utils.ts +4 -1
  178. package/src/vite/utils/shared-utils.ts +1 -1
@@ -10,6 +10,7 @@ import type { ScanFilter } from "../../build/generate-route-types.js";
10
10
  import type { ClientUrlProjection } from "../../client-urls/server-projection.js";
11
11
 
12
12
  export const VIRTUAL_ROUTES_MANIFEST_ID = "virtual:rsc-router/routes-manifest";
13
+ export const VIRTUAL_LOADER_MANIFEST_ID = "virtual:rsc-router/loader-manifest";
13
14
 
14
15
  export interface PluginOptions {
15
16
  enableBuildPrerender?: boolean;
@@ -147,6 +148,19 @@ export interface DiscoveryState {
147
148
  * buildEnd (aborted build).
148
149
  */
149
150
  shellPhaseTempServer: import("vite").ViteDevServer | null;
151
+ /**
152
+ * Inputs of the per-router cache versions (build-versions.ts), recorded by
153
+ * generateBundle. plugin-rsc builds the RSC and SSR environments twice (a
154
+ * scan pass, then the real pass); each pass overwrites its slot, so the
155
+ * buildApp post hook reads the real pass.
156
+ */
157
+ serverBuildGraph: import("./build-versions.js").ServerBuildGraph | null;
158
+ clientBuildGraph: import("./build-versions.js").ClientBuildGraph | null;
159
+ ssrBundle: import("./build-versions.js").BundleFiles | null;
160
+ /** Per environment, the chunk files holding the version module. */
161
+ versionModuleFiles: Map<string, string[]>;
162
+ /** Prerender and Static payloads the build rendered, by content digest. */
163
+ buildData: import("./build-versions.js").BuildDataRecord[];
150
164
  handlerChunkInfoMap: Map<string, ChunkInfo>;
151
165
  staticHandlerChunkInfoMap: Map<string, ChunkInfo>;
152
166
  rscEntryFileName: string | null;
@@ -204,6 +218,11 @@ export function createDiscoveryState(
204
218
  shellCandidates: null,
205
219
  prerenderPayloadValues: null,
206
220
  shellPhaseTempServer: null,
221
+ serverBuildGraph: null,
222
+ clientBuildGraph: null,
223
+ ssrBundle: null,
224
+ versionModuleFiles: new Map(),
225
+ buildData: [],
207
226
  handlerChunkInfoMap: new Map(),
208
227
  staticHandlerChunkInfoMap: new Map(),
209
228
  rscEntryFileName: null,
@@ -8,6 +8,7 @@
8
8
  import { mkdirSync, writeFileSync } from "node:fs";
9
9
  import { dirname, basename, join } from "node:path";
10
10
  import { jsonParseExpression } from "../utils/manifest-utils.js";
11
+ import { compareStrings } from "../utils/compare-strings.js";
11
12
  import { VIRTUAL_ROUTES_MANIFEST_ID } from "./state.js";
12
13
  import type { DiscoveryState } from "./state.js";
13
14
 
@@ -111,8 +112,12 @@ export function generateRoutesManifestModule(state: DiscoveryState): string {
111
112
 
112
113
  if (hasClientUrlModules) {
113
114
  lines.push(`clearClientUrlProjections();`);
114
- for (const [referenceId, projection] of state.clientUrlProjectionMap ??
115
- []) {
115
+ // Sorted: the map fills in transform order, which varies between runs,
116
+ // and this module's bytes feed cache versions (build-versions.ts).
117
+ const projections = [...(state.clientUrlProjectionMap ?? [])].sort(
118
+ ([a], [b]) => compareStrings(a, b),
119
+ );
120
+ for (const [referenceId, projection] of projections) {
116
121
  lines.push(
117
122
  `setClientUrlProjection(${JSON.stringify(referenceId)}, ${jsonParseExpression(projection)});`,
118
123
  );
@@ -9,17 +9,104 @@ import { randomBytes } from "node:crypto";
9
9
  // via plugin-rsc's `defineEncryptionKey` so build-time-encrypted bound args
10
10
  // decrypt at runtime.
11
11
  //
12
- // Cached per process so the temp server (created during the main build's
13
- // buildStart) and the main build share one key. Overridable via
14
- // RANGO_ENCRYPTION_KEY for a stable key across builds/deploys; otherwise a fresh
15
- // key is generated per build (matching plugin-rsc's default posture, where the
16
- // key is emitted into the build output regardless).
17
- let cachedKey: string | undefined;
12
+ // Resolved once per process so the temp server (created during the main
13
+ // build's buildStart) and the main build share one key. Source order:
14
+ // rango({ encryptionKey }), then RANGO_ENCRYPTION_KEY, then a key generated for
15
+ // this build. Only the first two are stable across builds.
16
+ //
17
+ // The key is part of the cache version of every router whose server code
18
+ // encrypts with it (discovery/build-versions.ts adds the emitted key file to
19
+ // that router's hash): a payload cached under the old key carries bound args
20
+ // the new build cannot decrypt. So a generated key gives those routers a new
21
+ // version on every build, which is correct and also means their cache is
22
+ // cleared on every deploy.
23
+
24
+ /** AES key sizes Web Crypto imports for AES-GCM, in bytes. */
25
+ const AES_KEY_BYTES: ReadonlySet<number> = new Set([16, 24, 32]);
26
+
27
+ const EXPECTED =
28
+ "a base64-encoded AES key of 16, 24 or 32 bytes (openssl rand -base64 32)";
29
+
30
+ interface ResolvedEncryptionKey {
31
+ key: string;
32
+ /** True when the consumer supplied the key (option or env variable). */
33
+ stable: boolean;
34
+ }
35
+
36
+ let resolvedKey: ResolvedEncryptionKey | undefined;
37
+
38
+ /**
39
+ * Throws unless `value` is the base64 form of an AES key (16, 24 or 32 bytes).
40
+ * `source` names where the value came from, for the message.
41
+ */
42
+ export function assertValidEncryptionKey(value: unknown, source: string): void {
43
+ if (typeof value !== "string") {
44
+ throw new Error(
45
+ `[rango] ${source} must be a string, received ${typeof value}. Expected ${EXPECTED}.`,
46
+ );
47
+ }
48
+ if (value === "") {
49
+ throw new Error(
50
+ `[rango] ${source} is an empty string. Unset it to generate a key per build, ` +
51
+ `or set it to ${EXPECTED}.`,
52
+ );
53
+ }
54
+ const bytes = /^[A-Za-z0-9+/]+={0,2}$/.test(value)
55
+ ? Buffer.from(value, "base64")
56
+ : undefined;
57
+ // Buffer.from tolerates malformed base64 by dropping what it cannot decode;
58
+ // the round-trip rejects those inputs instead of using a shorter key.
59
+ if (
60
+ !bytes ||
61
+ bytes.toString("base64").replace(/=+$/, "") !== value.replace(/=+$/, "")
62
+ ) {
63
+ throw new Error(
64
+ `[rango] ${source} is not valid base64. Expected ${EXPECTED}.`,
65
+ );
66
+ }
67
+ if (!AES_KEY_BYTES.has(bytes.length)) {
68
+ throw new Error(
69
+ `[rango] ${source} decodes to ${bytes.length} bytes. Expected ${EXPECTED}.`,
70
+ );
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Resolve the build's encryption key from rango({ encryptionKey }) or the
76
+ * RANGO_ENCRYPTION_KEY fallback, validating whichever is set. Called by
77
+ * rango() at config time, before any plugin-rsc instance reads the key, so an
78
+ * invalid key fails the config load instead of the first encrypted action.
79
+ * A key generated earlier in the process is kept when neither source is set:
80
+ * the discovery temp server must encrypt with the main build's key.
81
+ */
82
+ export function configureEncryptionKey(option: string | undefined): void {
83
+ if (option !== undefined) {
84
+ assertValidEncryptionKey(option, "rango({ encryptionKey })");
85
+ resolvedKey = { key: option, stable: true };
86
+ return;
87
+ }
88
+ const fromEnv = process.env.RANGO_ENCRYPTION_KEY;
89
+ if (fromEnv !== undefined) {
90
+ assertValidEncryptionKey(fromEnv, "RANGO_ENCRYPTION_KEY");
91
+ resolvedKey = { key: fromEnv, stable: true };
92
+ return;
93
+ }
94
+ if (resolvedKey && !resolvedKey.stable) return;
95
+ resolvedKey = { key: randomBytes(32).toString("base64"), stable: false };
96
+ }
97
+
98
+ function resolved(): ResolvedEncryptionKey {
99
+ if (!resolvedKey) configureEncryptionKey(undefined);
100
+ return resolvedKey!;
101
+ }
18
102
 
19
103
  export function buildEncryptionKey(): string {
20
- cachedKey ??=
21
- process.env.RANGO_ENCRYPTION_KEY ?? randomBytes(32).toString("base64");
22
- return cachedKey;
104
+ return resolved().key;
105
+ }
106
+
107
+ /** False when this build generated its own key (it differs on every build). */
108
+ export function isEncryptionKeyStable(): boolean {
109
+ return resolved().stable;
23
110
  }
24
111
 
25
112
  // The value plugin-rsc inlines as `export default () => (<expr>)`. A JSON string
@@ -27,3 +114,8 @@ export function buildEncryptionKey(): string {
27
114
  export function defineEncryptionKeyExpr(): string {
28
115
  return JSON.stringify(buildEncryptionKey());
29
116
  }
117
+
118
+ /** @internal Test seam: forget the process-wide key. */
119
+ export function resetEncryptionKeyForTests(): void {
120
+ resolvedKey = undefined;
121
+ }
@@ -239,6 +239,34 @@ interface RangoBaseOptions {
239
239
  prerender?: {
240
240
  onError?: "fail" | "warn";
241
241
  };
242
+
243
+ /**
244
+ * The key inline server actions encrypt their bound arguments with:
245
+ * base64-encoded 32 bytes (`openssl rand -base64 32`). Pass it from the
246
+ * environment, never as a literal in the config file:
247
+ *
248
+ * ```ts
249
+ * rango({ encryptionKey: process.env.RANGO_ENCRYPTION_KEY })
250
+ * ```
251
+ *
252
+ * Why set it: the key is part of the cache version of every router whose
253
+ * server code encrypts with it, because a payload cached under one key
254
+ * carries arguments another key cannot decrypt. Without a stable key rango
255
+ * generates one per build, so those routers get a new version, and a cleared
256
+ * cache, on every deploy. With a stable key their cache survives a deploy
257
+ * that does not change their server code. It also lets a build decrypt
258
+ * arguments an earlier build encrypted (a cached or prerendered payload, an
259
+ * open tab).
260
+ *
261
+ * Validated when `rango()` is called: a value that is not base64 or does not
262
+ * decode to an AES key size (16, 24 or 32 bytes) throws. `undefined` (the
263
+ * variable is unset) falls back to the `RANGO_ENCRYPTION_KEY` environment
264
+ * variable, then to a generated key. Rotating the key clears the cache of
265
+ * the routers that use it.
266
+ *
267
+ * @default process.env.RANGO_ENCRYPTION_KEY, else a key generated per build
268
+ */
269
+ encryptionKey?: string;
242
270
  }
243
271
 
244
272
  /**
@@ -5,11 +5,9 @@ import { resolveRscEntryFromConfig } from "../utils/shared-utils.js";
5
5
  import { RSC_ENTRY_BOOTSTRAP_IMPORTS } from "./virtual-entries.js";
6
6
 
7
7
  /**
8
- * Plugin that auto-injects VERSION, routes-manifest, and loader-manifest into
9
- * custom entry.rsc files. If a custom entry.rsc file uses createRSCHandler but
10
- * doesn't pass version, this transform adds the import and property
11
- * automatically. It also ensures the routes-manifest and loader-manifest
12
- * virtual modules are always imported.
8
+ * Plugin that auto-injects the routes-manifest and loader-manifest imports
9
+ * into a custom entry.rsc file (the Cloudflare preset, where the consumer owns
10
+ * the worker entry).
13
11
  *
14
12
  * The loader-manifest import is what makes fetchable loaders resolvable on a
15
13
  * custom worker entry. The virtual RSC entry (getVirtualEntryRSC) imports the
@@ -23,13 +21,13 @@ import { RSC_ENTRY_BOOTSTRAP_IMPORTS } from "./virtual-entries.js";
23
21
  * manifest, hence the production-only failure this fixes.
24
22
  * @internal
25
23
  */
26
- export function createVersionInjectorPlugin(
24
+ export function createEntryBootstrapInjectorPlugin(
27
25
  rscEntryPath: string | undefined,
28
26
  ): Plugin {
29
27
  let resolvedEntryPath = "";
30
28
 
31
29
  return {
32
- name: "@rangojs/router:version-injector",
30
+ name: "@rangojs/router:entry-bootstrap-injector",
33
31
  enforce: "pre",
34
32
 
35
33
  configResolved(config) {
@@ -54,21 +52,8 @@ export function createVersionInjectorPlugin(
54
52
  const prepend: string[] = RSC_ENTRY_BOOTSTRAP_IMPORTS.map(
55
53
  (id) => `import "${id}";`,
56
54
  );
57
- let newCode = code;
58
- const needsVersion =
59
- code.includes("createRSCHandler") &&
60
- !code.includes("@rangojs/router:version") &&
61
- /createRSCHandler\s*\(\s*\{/.test(code);
62
55
 
63
- if (needsVersion) {
64
- prepend.push(`import { VERSION } from "@rangojs/router:version";`);
65
- newCode = newCode.replace(
66
- /createRSCHandler\s*\(\s*\{/,
67
- "createRSCHandler({\n version: VERSION,",
68
- );
69
- }
70
-
71
- const lines = newCode.split("\n");
56
+ const lines = code.split("\n");
72
57
  let insertAt = 0;
73
58
  while (insertAt < lines.length) {
74
59
  const trimmed = lines[insertAt]!.trim();
@@ -78,7 +63,7 @@ export function createVersionInjectorPlugin(
78
63
  break;
79
64
  }
80
65
  }
81
- newCode = [
66
+ const newCode = [
82
67
  ...lines.slice(0, insertAt),
83
68
  ...prepend,
84
69
  ...lines.slice(insertAt),
@@ -55,7 +55,7 @@ export function isExportOnlyFile(
55
55
  // (which may be nested, e.g. createLoader<A<B>>(...)) and the call paren are
56
56
  // resolved per match via findCallParenAfterGenerics, so a nested `>` no longer
57
57
  // defeats the scan the way `<[^>]*>` did (it stopped at the first `>`).
58
- function createCallPattern(fnNames: string[]): RegExp {
58
+ export function createCallPattern(fnNames: string[]): RegExp {
59
59
  return new RegExp(`\\b(?:${fnNames.map(escapeRegExp).join("|")})\\b`, "g");
60
60
  }
61
61
 
@@ -8,10 +8,11 @@ import {
8
8
  findCallParenAfterGenerics,
9
9
  skipStringOrComment,
10
10
  } from "../expose-id-utils.js";
11
- import { escapeRegExp } from "../../../regex-escape.js";
12
11
  import {
13
12
  getImportedFnNames,
14
13
  buildUnsupportedShapeWarning,
14
+ createCallPattern,
15
+ offsetToLineColumn,
15
16
  } from "./export-analysis.js";
16
17
  import { codeMatchIndices } from "../../../build/route-types/source-scan.js";
17
18
  import { createRangoDebugger, createCounter, NS } from "../../debug.js";
@@ -42,6 +43,26 @@ function skipLeadingTrivia(code: string, start: number, end: number): number {
42
43
  return i;
43
44
  }
44
45
 
46
+ /**
47
+ * A router's `$$id`: a hash of its root-relative file and of its position
48
+ * among the file's createRouter() calls.
49
+ *
50
+ * Not of the call's line. `code` is TS-transformed, and a dev server keeps
51
+ * comments there that a build drops, so a line of `code` gave build-time
52
+ * discovery and the bundle different ids for any router with a comment above
53
+ * it (e2e/test-app, tests/cloudflare-basic): the build registered the lazy
54
+ * route manifest under an id no running router had. And a line of the file as
55
+ * written changes with every blank line or comment added above the call,
56
+ * which renamed the state cookie, the route manifest chunk and the router's
57
+ * cache versions for an edit that changed nothing.
58
+ */
59
+ export function routerId(filePath: string, callIndex: number): string {
60
+ return createHash("sha256")
61
+ .update(`${filePath}#${callIndex}`)
62
+ .digest("hex")
63
+ .slice(0, 8);
64
+ }
65
+
45
66
  export function transformRouter(
46
67
  code: string,
47
68
  filePath: string,
@@ -53,14 +74,12 @@ export function transformRouter(
53
74
  // e.g. createRouter<Config<Env>>(...)) and the opening paren are located
54
75
  // separately via findCallParenAfterGenerics so a nested `>` does not defeat
55
76
  // the scan (a `<[^>]*>` regex stopped at the first `>`).
56
- const pat = new RegExp(
57
- `\\b(?:${routerFnNames.map(escapeRegExp).join("|")})\\b`,
58
- "g",
59
- );
77
+ const pat = createCallPattern(routerFnNames);
60
78
  let match: RegExpExecArray | null;
61
79
  const s = new MagicString(code);
62
80
  let changed = false;
63
81
  const unsupportedSites: Array<{ line: number; column: number }> = [];
82
+ let callIndex = -1;
64
83
 
65
84
  // Compute the import path for the generated route names file.
66
85
  // filePath is relative to project root (e.g., "src/router.tsx")
@@ -86,6 +105,7 @@ export function transformRouter(
86
105
  // type position) yields -1 and is skipped.
87
106
  const parenPos = findCallParenAfterGenerics(code, calleeEnd);
88
107
  if (parenPos === -1) continue;
108
+ callIndex++;
89
109
 
90
110
  const closeParen = findMatchingParen(code, parenPos + 1);
91
111
  const callArgs = code.slice(parenPos + 1, closeParen);
@@ -98,12 +118,7 @@ export function transformRouter(
98
118
  if (callArgs.includes(`$$routeNames: ${routeNamesVar}`)) continue;
99
119
 
100
120
  const sourceFilePath = absolutePath ?? filePath;
101
- const lineNumber = code.slice(0, callStart).split("\n").length;
102
- const hash = createHash("sha256")
103
- .update(`${filePath}:${lineNumber}`)
104
- .digest("hex")
105
- .slice(0, 8);
106
- const injected = ` $$id: "${hash}", $$sourceFile: "${sourceFilePath}", $$routeNames: ${routeNamesVar},`;
121
+ const injected = ` $$id: "${routerId(filePath, callIndex)}", $$sourceFile: "${sourceFilePath}", $$routeNames: ${routeNamesVar},`;
107
122
 
108
123
  // Skip a leading comment/whitespace run so `createRouter(/* c *\/ {...})`
109
124
  // and a newline-then-comment prefix still resolve to the object literal.
@@ -124,9 +139,7 @@ export function transformRouter(
124
139
  // stable $$id can be injected here. Record it so the plugin can warn —
125
140
  // and crucially do NOT mark changed, so a dead named-routes.gen import is
126
141
  // not prepended for a call we never touched.
127
- const lastNl = code.lastIndexOf("\n", callStart - 1);
128
- const column = callStart - (lastNl + 1) + 1;
129
- unsupportedSites.push({ line: lineNumber, column });
142
+ unsupportedSites.push(offsetToLineColumn(code, callStart));
130
143
  }
131
144
  }
132
145
 
@@ -156,11 +169,19 @@ export function transformRouter(
156
169
  */
157
170
  export function exposeRouterId(): Plugin {
158
171
  let projectRoot = "";
172
+ // $$sourceFile is read by route discovery and the CLI, which run on a dev
173
+ // server (the build's discovery temp server included) and need the absolute
174
+ // path. Nothing reads it when serving, so a production build ships the
175
+ // root-relative path: an absolute one would put the build machine's checkout
176
+ // directory in every router's chunk and give the same source a different
177
+ // cache version per directory (discovery/build-versions.ts hashes the chunk).
178
+ let rootRelativeSourceFile = false;
159
179
  const counter = createCounter(debug, "expose-router-id");
160
180
  return {
161
181
  name: "@rangojs/router:expose-router-id",
162
182
  configResolved(config) {
163
183
  projectRoot = config.root;
184
+ rootRelativeSourceFile = config.command === "build";
164
185
  },
165
186
  buildEnd() {
166
187
  counter?.flush();
@@ -188,7 +209,7 @@ export function exposeRouterId(): Plugin {
188
209
  code,
189
210
  filePath,
190
211
  routerFnNames,
191
- normalizePath(id),
212
+ rootRelativeSourceFile ? undefined : normalizePath(id),
192
213
  warn,
193
214
  );
194
215
  } finally {
@@ -45,6 +45,8 @@ import {
45
45
  transformHandlerIds,
46
46
  } from "./expose-ids/handler-transform.js";
47
47
  import { createRangoDebugger, createCounter, NS } from "../debug.js";
48
+ import { compareStrings } from "../utils/compare-strings.js";
49
+ import { VIRTUAL_LOADER_MANIFEST_ID } from "../discovery/state.js";
48
50
 
49
51
  const debug = createRangoDebugger(NS.transform);
50
52
 
@@ -56,7 +58,7 @@ export type { ExposeInternalIdsApi } from "./expose-ids/types.js";
56
58
  // Virtual module for loader manifest
57
59
  // ---------------------------------------------------------------------------
58
60
 
59
- const VIRTUAL_LOADER_MANIFEST = "virtual:rsc-router/loader-manifest";
61
+ const VIRTUAL_LOADER_MANIFEST = VIRTUAL_LOADER_MANIFEST_ID;
60
62
  const RESOLVED_VIRTUAL_LOADER_MANIFEST = "\0" + VIRTUAL_LOADER_MANIFEST;
61
63
 
62
64
  // ---------------------------------------------------------------------------
@@ -245,10 +247,17 @@ setLoaderImports({});
245
247
  `;
246
248
  }
247
249
 
248
- // Build mode: generate lazy import map
250
+ // Build mode: generate lazy import map. Sorted by id: the registry fills
251
+ // in directory-scan order (the pre-scan below) and transform order, both
252
+ // of which vary between machines and runs, and this module's bytes feed
253
+ // the cache version of every router bundled with it
254
+ // (discovery/build-versions.ts).
249
255
  const lazyImports: string[] = [];
256
+ const sortedLoaders = [...loaderRegistry].sort(([a], [b]) =>
257
+ compareStrings(a, b),
258
+ );
250
259
 
251
- for (const [hashedId, { filePath, exportName }] of loaderRegistry) {
260
+ for (const [hashedId, { filePath, exportName }] of sortedLoaders) {
252
261
  lazyImports.push(
253
262
  ` "${hashedId}": () => import("/${filePath}").then(m => m.${exportName})`,
254
263
  );
@@ -0,0 +1,147 @@
1
+ import { parseAst, type Plugin } from "vite";
2
+ import { escapeRegExp } from "../../regex-escape.js";
3
+ import { compareStrings } from "../utils/compare-strings.js";
4
+ import { SERVER_REFERENCES_MODULE_ID } from "../discovery/build-versions.js";
5
+
6
+ /** One property of an object literal, by its position in the source. */
7
+ export interface LiteralProperty {
8
+ readonly key: string;
9
+ /** Range of the whole property, key included. */
10
+ readonly start: number;
11
+ readonly end: number;
12
+ /** The value's AST node. */
13
+ readonly value: any;
14
+ }
15
+
16
+ /**
17
+ * The properties of an object-literal AST node, or undefined when it is not an
18
+ * object literal of plain keys (a spread, a computed key).
19
+ */
20
+ export function literalProperties(object: any): LiteralProperty[] | undefined {
21
+ if (object?.type !== "ObjectExpression") return undefined;
22
+ const properties: LiteralProperty[] = [];
23
+ for (const property of object.properties ?? []) {
24
+ const key =
25
+ property?.type === "Property" && !property.computed
26
+ ? (property.key?.value ?? property.key?.name)
27
+ : undefined;
28
+ if (typeof key !== "string") return undefined;
29
+ properties.push({
30
+ key,
31
+ start: property.start,
32
+ end: property.end,
33
+ value: property.value,
34
+ });
35
+ }
36
+ return properties;
37
+ }
38
+
39
+ /**
40
+ * The properties of a module's `export default { ... }`, or undefined when
41
+ * the module does not parse or is not that shape. plugin-rsc generates its
42
+ * registries (the server-reference map, the assets manifest) in this form.
43
+ */
44
+ export function defaultExportProperties(
45
+ code: string,
46
+ ): LiteralProperty[] | undefined {
47
+ let program: any;
48
+ try {
49
+ program = parseAst(code);
50
+ } catch {
51
+ return undefined;
52
+ }
53
+ return literalProperties(
54
+ (program.body ?? []).find(
55
+ (node: any) => node?.type === "ExportDefaultDeclaration",
56
+ )?.declaration,
57
+ );
58
+ }
59
+
60
+ /**
61
+ * Return `code` with the properties of its default-exported object literal
62
+ * sorted by key, or undefined when there is nothing to reorder (or the module
63
+ * is not that shape).
64
+ */
65
+ export function sortDefaultExportProperties(code: string): string | undefined {
66
+ const properties = defaultExportProperties(code);
67
+ if (!properties || properties.length < 2) return undefined;
68
+ const sorted = [...properties].sort((a, b) => compareStrings(a.key, b.key));
69
+ if (sorted.every((property, index) => property === properties[index])) {
70
+ return undefined;
71
+ }
72
+ return (
73
+ code.slice(0, properties[0]!.start) +
74
+ sorted
75
+ .map((property) => code.slice(property.start, property.end))
76
+ .join(",\n") +
77
+ code.slice(properties[properties.length - 1]!.end)
78
+ );
79
+ }
80
+
81
+ const ASSETS_MANIFEST_ID = "virtual:vite-rsc/assets-manifest";
82
+ const ASSETS_MANIFEST_IMPORT =
83
+ /import\s+([A-Za-z_$][\w$]*)\s+from\s*["']virtual:vite-rsc\/assets-manifest["']/;
84
+ /** The local name plugin-rsc's own runtime modules import the manifest under. */
85
+ const ASSETS_MANIFEST_BINDING = "assetsManifest";
86
+ /**
87
+ * The names plugin-rsc's GENERATED code imports it under. Only these are
88
+ * renamed: the rename is textual, and a name an app author picked
89
+ * (`import manifest from ...`) can also be a word in that module's strings.
90
+ */
91
+ const GENERATED_BINDINGS: ReadonlySet<string> = new Set([
92
+ "__vite_rsc_assets_manifest__",
93
+ "__vite_rsc_assets_manifest",
94
+ ]);
95
+
96
+ function identifier(name: string): RegExp {
97
+ return new RegExp(`(?<![\\w$.])${escapeRegExp(name)}(?![\\w$])`, "g");
98
+ }
99
+
100
+ /**
101
+ * Return `code` with its default import of plugin-rsc's assets manifest
102
+ * renamed to {@link ASSETS_MANIFEST_BINDING}, or undefined when there is
103
+ * nothing to rename (no such import under a generated name, or the module
104
+ * uses the canonical name for something else).
105
+ */
106
+ export function canonicalAssetsManifestBinding(
107
+ code: string,
108
+ ): string | undefined {
109
+ const local = code.match(ASSETS_MANIFEST_IMPORT)?.[1];
110
+ if (local === undefined || !GENERATED_BINDINGS.has(local)) return undefined;
111
+ if (identifier(ASSETS_MANIFEST_BINDING).test(code)) return undefined;
112
+ return code.replace(identifier(local), ASSETS_MANIFEST_BINDING);
113
+ }
114
+
115
+ /**
116
+ * Removes two run-to-run differences from plugin-rsc 0.5.35's build output.
117
+ * The cache versions hash these bytes (discovery/build-versions.ts).
118
+ *
119
+ * 1. Property order of the server-reference map. plugin-rsc writes it in the
120
+ * order its transforms first saw each `"use server"` module
121
+ * (`ServerReferencesManager.claimMap`), which varies with the concurrent
122
+ * scan builds (e2e/test-app: 2 of 2 consecutive builds differed). Sorted
123
+ * here so a minified server build, where stripServerReferenceMap finds no
124
+ * region comments, still hashes the same bytes.
125
+ * 2. Local name of the assets-manifest import. plugin-rsc imports the manifest
126
+ * under three names; the bundler merges them into one import per chunk and
127
+ * keeps a different one from build to build (the cache-versions fixture:
128
+ * 1 of 10 concurrent builds). One name in, one name out.
129
+ * @internal
130
+ */
131
+ export function stableRscOutput(): Plugin {
132
+ return {
133
+ name: "@rangojs/router:stable-rsc-output",
134
+ apply: "build",
135
+ // After plugin-rsc's transforms, which add the manifest import.
136
+ enforce: "post",
137
+ transform(code, id) {
138
+ const stable =
139
+ id === SERVER_REFERENCES_MODULE_ID
140
+ ? sortDefaultExportProperties(code)
141
+ : code.includes(ASSETS_MANIFEST_ID)
142
+ ? canonicalAssetsManifestBinding(code)
143
+ : undefined;
144
+ return stable === undefined ? null : { code: stable, map: null };
145
+ },
146
+ };
147
+ }
@@ -1,5 +1,9 @@
1
1
  import { parseAst, type Plugin } from "vite";
2
- import { VIRTUAL_IDS, getVirtualVersionContent } from "./virtual-entries.js";
2
+ import {
3
+ VIRTUAL_IDS,
4
+ getVirtualBuildVersionContent,
5
+ getVirtualVersionContent,
6
+ } from "./virtual-entries.js";
3
7
  import { hasUseClientDirective } from "../utils/directive-prologue.js";
4
8
 
5
9
  interface ClientModuleSignature {
@@ -100,25 +104,22 @@ function getClientModuleSignature(
100
104
  }
101
105
 
102
106
  /**
103
- * Plugin providing rsc-router:version virtual module.
104
- * Exports VERSION that changes when RSC modules change (dev) or at build time (production).
107
+ * Plugin providing the `@rangojs/router:version` virtual module.
105
108
  *
106
- * The version is used for:
107
- * 1. Cache invalidation - CFCacheStore uses VERSION to invalidate stale cache
108
- * 2. Version mismatch detection - client sends version, server reloads on mismatch
109
+ * Dev: exports a VERSION stamp used for both versions of every router. It
110
+ * updates when the server starts and when an RSC module changes via HMR (the
111
+ * version module is invalidated). Client-only HMR changes do not update it:
112
+ * they do not affect server-rendered content or cached RSC payloads.
109
113
  *
110
- * In dev mode, the version updates when:
111
- * - Server starts (initial version)
112
- * - RSC modules change via HMR (triggers version module invalidation)
113
- *
114
- * Client-only HMR changes don't update the version since they don't affect
115
- * server-rendered content or cached RSC payloads.
114
+ * Build: exports a placeholder for the per-router versions table. The module
115
+ * holds nothing that varies between builds; the table is written into the
116
+ * built chunk after the server output is hashed (the buildApp post hook in
117
+ * router-discovery.ts). See docs/design/per-app-cache-version.md.
116
118
  * @internal
117
119
  */
118
120
  export function createVersionPlugin(): Plugin {
119
- // Generate version at plugin creation time (build/server start)
120
- const buildVersion = Date.now().toString(16);
121
- let currentVersion = buildVersion;
121
+ // Dev stamp, generated at plugin creation (server start).
122
+ let currentVersion = Date.now().toString(16);
122
123
  let isDev = false;
123
124
  let server: any = null;
124
125
  let resolvedCacheDir: string | undefined;
@@ -142,14 +143,6 @@ export function createVersionPlugin(): Plugin {
142
143
  name: "@rangojs/router:version",
143
144
  enforce: "pre",
144
145
 
145
- // The build-time shell capture phase (producer B, #699) stamps entries
146
- // with THIS instance's version — the value folded into the shipped worker
147
- // — never the discovery temp server's own version-plugin value (a
148
- // different Date.now() stamp that would fail the serve-side gate).
149
- api: {
150
- getBuildVersion: (): string => currentVersion,
151
- },
152
-
153
146
  configResolved(config) {
154
147
  isDev = config.command === "serve";
155
148
  resolvedCacheDir = config.cacheDir
@@ -177,7 +170,9 @@ export function createVersionPlugin(): Plugin {
177
170
 
178
171
  load(id) {
179
172
  if (id === "\0" + VIRTUAL_IDS.version) {
180
- return getVirtualVersionContent(currentVersion);
173
+ return isDev
174
+ ? getVirtualVersionContent(currentVersion)
175
+ : getVirtualBuildVersionContent();
181
176
  }
182
177
  return null;
183
178
  },