@rangojs/router 0.19.1 → 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 (185) 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/ssr/entry-preload-priority.d.ts +69 -0
  35. package/dist/types/ssr/index.d.ts +1 -0
  36. package/dist/types/ssr/preinit-client-references.d.ts +17 -5
  37. package/dist/types/testing/build-versions.d.ts +32 -0
  38. package/dist/types/testing/dom.entry.d.ts +1 -1
  39. package/dist/types/testing/flight.entry.d.ts +1 -1
  40. package/dist/types/testing/index.d.ts +2 -0
  41. package/dist/types/testing/internal/seed-vars.d.ts +7 -2
  42. package/dist/types/testing/location-state-key.d.ts +5 -1
  43. package/dist/types/testing/render-route.d.ts +77 -6
  44. package/dist/types/testing/serve-shell-request.d.ts +21 -0
  45. package/dist/types/testing/vitest-stubs/version.d.ts +1 -0
  46. package/dist/types/types/handler-context.d.ts +8 -5
  47. package/dist/types/vite/discovery/build-versions.d.ts +393 -0
  48. package/dist/types/vite/discovery/installed-packages.d.ts +26 -0
  49. package/dist/types/vite/discovery/router-versions-phase.d.ts +52 -0
  50. package/dist/types/vite/discovery/shell-prerender-phase.d.ts +7 -17
  51. package/dist/types/vite/discovery/state.d.ts +14 -0
  52. package/dist/types/vite/encryption-key.d.ts +18 -0
  53. package/dist/types/vite/plugin-types.d.ts +47 -6
  54. package/dist/types/vite/plugins/{version-injector.d.ts → entry-bootstrap-injector.d.ts} +4 -6
  55. package/dist/types/vite/plugins/expose-ids/export-analysis.d.ts +1 -0
  56. package/dist/types/vite/plugins/expose-ids/router-transform.d.ts +14 -0
  57. package/dist/types/vite/plugins/stable-rsc-output.d.ts +51 -0
  58. package/dist/types/vite/plugins/version-plugin.d.ts +9 -11
  59. package/dist/types/vite/plugins/virtual-entries.d.ts +21 -2
  60. package/dist/types/vite/router-discovery.d.ts +6 -0
  61. package/dist/types/vite/utils/client-chunks.d.ts +24 -2
  62. package/dist/types/vite/utils/compare-strings.d.ts +7 -0
  63. package/dist/types/vite/utils/package-resolution.d.ts +8 -0
  64. package/dist/types/vite/utils/prerender-utils.d.ts +2 -0
  65. package/dist/vite/index.js +1544 -420
  66. package/package.json +1 -2
  67. package/skills/bundle-analysis/SKILL.md +1 -1
  68. package/skills/cache-guide/SKILL.md +84 -20
  69. package/skills/caching/SKILL.md +16 -5
  70. package/skills/client-urls/SKILL.md +16 -0
  71. package/skills/cloudflare/SKILL.md +2 -1
  72. package/skills/comparison/references/framework-comparison.md +16 -12
  73. package/skills/hooks/SKILL.md +3 -0
  74. package/skills/hooks/handle-and-actions.md +6 -0
  75. package/skills/hooks/navigation.md +28 -0
  76. package/skills/hooks/state.md +209 -8
  77. package/skills/hooks/urls.md +40 -1
  78. package/skills/host-router/SKILL.md +13 -0
  79. package/skills/intercept/SKILL.md +6 -0
  80. package/skills/loader/SKILL.md +19 -1
  81. package/skills/middleware/SKILL.md +2 -0
  82. package/skills/ppr/SKILL.md +119 -40
  83. package/skills/prerender/SKILL.md +7 -3
  84. package/skills/rango/SKILL.md +8 -7
  85. package/skills/router-setup/SKILL.md +23 -3
  86. package/skills/testing/SKILL.md +30 -26
  87. package/skills/testing/client-components.md +193 -8
  88. package/skills/testing/handles.md +2 -0
  89. package/skills/testing/loader.md +1 -1
  90. package/skills/testing/middleware.md +1 -1
  91. package/skills/testing/render-handler.md +18 -18
  92. package/skills/testing/serve-shell-request.md +63 -14
  93. package/skills/testing/server-actions.md +17 -17
  94. package/skills/testing/server-tree.md +1 -1
  95. package/skills/typesafety/generated-files-and-cli.md +1 -1
  96. package/skills/use-cache/SKILL.md +16 -5
  97. package/skills/vercel/SKILL.md +3 -3
  98. package/src/bin/rango.ts +1 -1
  99. package/src/browser/event-controller.ts +75 -2
  100. package/src/browser/history-state.ts +39 -17
  101. package/src/browser/navigation-bridge.ts +41 -18
  102. package/src/browser/navigation-transaction.ts +15 -10
  103. package/src/browser/partial-update.ts +10 -2
  104. package/src/browser/rango-state.ts +3 -1
  105. package/src/browser/react/Link.tsx +2 -3
  106. package/src/browser/react/NavigationProvider.tsx +25 -1
  107. package/src/browser/react/context.ts +13 -0
  108. package/src/browser/react/location-state-shared.ts +200 -34
  109. package/src/browser/react/location-state.ts +40 -75
  110. package/src/browser/react/subscribe-to-nothing.ts +7 -0
  111. package/src/browser/react/use-handle.ts +20 -1
  112. package/src/browser/rsc-router.tsx +33 -11
  113. package/src/browser/server-action-bridge.ts +17 -1
  114. package/src/cache/cache-scope.ts +18 -8
  115. package/src/cache/cf/cf-cache-store.ts +75 -55
  116. package/src/cache/cf/cf-cache-types.ts +12 -4
  117. package/src/cache/handle-snapshot.ts +69 -87
  118. package/src/cache/index.ts +4 -0
  119. package/src/cache/shell-snapshot.ts +35 -5
  120. package/src/cache/types.ts +21 -10
  121. package/src/cache/vercel/vercel-cache-store.ts +48 -20
  122. package/src/client-urls/client-root.tsx +1 -0
  123. package/src/client-urls/navigation.ts +10 -0
  124. package/src/client-urls/optimistic-location.ts +10 -4
  125. package/src/handles/deferred-resolution.ts +31 -0
  126. package/src/prerender/build-shell-capture.ts +11 -9
  127. package/src/router/manifest.ts +2 -0
  128. package/src/router/match-middleware/cache-lookup.ts +11 -18
  129. package/src/router/navigation-snapshot.ts +25 -6
  130. package/src/router/request-classification.ts +15 -12
  131. package/src/router/router-interfaces.ts +2 -1
  132. package/src/router/router-options.ts +17 -7
  133. package/src/router/segment-resolution/loader-cache.ts +95 -60
  134. package/src/router-versions.ts +60 -0
  135. package/src/rsc/full-payload.ts +6 -2
  136. package/src/rsc/handler-context.ts +5 -0
  137. package/src/rsc/handler.ts +14 -4
  138. package/src/rsc/rsc-rendering.ts +12 -1
  139. package/src/rsc/shell-build-manifest.ts +10 -7
  140. package/src/rsc/shell-capture.ts +36 -26
  141. package/src/rsc/types.ts +4 -6
  142. package/src/server/build-version-table.ts +115 -0
  143. package/src/server/handle-store.ts +344 -127
  144. package/src/server/request-context.ts +62 -11
  145. package/src/ssr/entry-preload-priority.ts +308 -0
  146. package/src/ssr/index.tsx +46 -9
  147. package/src/ssr/preinit-client-references.ts +17 -5
  148. package/src/ssr/ssr-root.tsx +16 -4
  149. package/src/testing/build-versions.ts +64 -0
  150. package/src/testing/dispatch.ts +6 -0
  151. package/src/testing/dom.entry.ts +1 -0
  152. package/src/testing/flight.entry.ts +1 -0
  153. package/src/testing/index.ts +3 -0
  154. package/src/testing/internal/context.ts +2 -1
  155. package/src/testing/internal/seed-vars.ts +14 -2
  156. package/src/testing/location-state-key.ts +18 -8
  157. package/src/testing/render-handler.ts +2 -1
  158. package/src/testing/render-route.tsx +277 -43
  159. package/src/testing/serve-shell-request.ts +80 -11
  160. package/src/testing/vitest-stubs/version.ts +4 -1
  161. package/src/types/handler-context.ts +8 -5
  162. package/src/vite/discovery/build-versions.ts +1281 -0
  163. package/src/vite/discovery/installed-packages.ts +159 -0
  164. package/src/vite/discovery/prerender-collection.ts +38 -1
  165. package/src/vite/discovery/router-versions-phase.ts +336 -0
  166. package/src/vite/discovery/shell-prerender-phase.ts +28 -38
  167. package/src/vite/discovery/state.ts +19 -0
  168. package/src/vite/discovery/virtual-module-codegen.ts +7 -2
  169. package/src/vite/encryption-key.ts +101 -9
  170. package/src/vite/plugin-types.ts +48 -6
  171. package/src/vite/plugins/{version-injector.ts → entry-bootstrap-injector.ts} +7 -22
  172. package/src/vite/plugins/expose-ids/export-analysis.ts +1 -1
  173. package/src/vite/plugins/expose-ids/router-transform.ts +36 -15
  174. package/src/vite/plugins/expose-internal-ids.ts +12 -3
  175. package/src/vite/plugins/stable-rsc-output.ts +147 -0
  176. package/src/vite/plugins/version-plugin.ts +19 -24
  177. package/src/vite/plugins/version.d.ts +17 -2
  178. package/src/vite/plugins/virtual-entries.ts +40 -7
  179. package/src/vite/rango.ts +30 -3
  180. package/src/vite/router-discovery.ts +82 -13
  181. package/src/vite/utils/client-chunks.ts +84 -14
  182. package/src/vite/utils/compare-strings.ts +9 -0
  183. package/src/vite/utils/package-resolution.ts +9 -0
  184. package/src/vite/utils/prerender-utils.ts +4 -1
  185. package/src/vite/utils/shared-utils.ts +1 -1
@@ -0,0 +1,159 @@
1
+ /**
2
+ * What is installed for a package a server build leaves external.
3
+ *
4
+ * The node preset bundles only packages that need the React server condition
5
+ * (plugin-rsc's `noExternal`); every other dependency stays an import the
6
+ * deployed process resolves from node_modules. The built chunk then says
7
+ * `from "pkg"` whichever version is installed, so a lockfile bump changes what
8
+ * the server renders and nothing the cache versions hash
9
+ * (discovery/build-versions.ts). This module supplies the missing input: the
10
+ * `name@version` of the package and of everything it depends on, as installed
11
+ * next to the project at build time.
12
+ *
13
+ * A registry package's code is fixed by its version. A linked package (a
14
+ * workspace package, resolved to a directory outside node_modules) is not.
15
+ * Vite bundles those unless the config lists one in `resolve.external`; then
16
+ * it comes through here and its files are digested instead.
17
+ */
18
+
19
+ import { createHash } from "node:crypto";
20
+ import {
21
+ existsSync,
22
+ readdirSync,
23
+ readFileSync,
24
+ realpathSync,
25
+ statSync,
26
+ type Stats,
27
+ } from "node:fs";
28
+ import { dirname, join, sep } from "node:path";
29
+ import { compareStrings } from "../utils/compare-strings.js";
30
+
31
+ interface InstalledPackage {
32
+ readonly label: string;
33
+ readonly dependencies: readonly string[];
34
+ }
35
+
36
+ /** What a symlink points at, or nothing for a dangling or circular one: an
37
+ * import could not load it either. */
38
+ function linkTarget(path: string): Stats | undefined {
39
+ try {
40
+ return statSync(path);
41
+ } catch {
42
+ return undefined;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Digest of every file under `dir`, by relative path, skipping installs.
48
+ * Symlinks are followed (a linked file is importable like any other); a
49
+ * directory is entered once, so a link back up the tree ends there.
50
+ */
51
+ function digestDirectory(dir: string): string {
52
+ const hash = createHash("sha256");
53
+ const entered = new Set<string>();
54
+ const visit = (current: string, prefix: string): void => {
55
+ const real = realpathSync(current);
56
+ if (entered.has(real)) return;
57
+ entered.add(real);
58
+ const entries = readdirSync(current, { withFileTypes: true }).sort((a, b) =>
59
+ compareStrings(a.name, b.name),
60
+ );
61
+ for (const entry of entries) {
62
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
63
+ const path = join(current, entry.name);
64
+ const name = `${prefix}${entry.name}`;
65
+ const kind = entry.isSymbolicLink() ? linkTarget(path) : entry;
66
+ if (kind?.isDirectory()) {
67
+ visit(path, `${name}/`);
68
+ } else if (kind?.isFile()) {
69
+ const bytes = readFileSync(path);
70
+ hash.update(`${name}\0${bytes.byteLength}\0`);
71
+ hash.update(bytes);
72
+ }
73
+ }
74
+ };
75
+ visit(dir, "");
76
+ return hash.digest("hex");
77
+ }
78
+
79
+ /** Node's lookup for a bare specifier: the nearest node_modules holding it. */
80
+ function findPackageDir(name: string, from: string): string | undefined {
81
+ for (let dir = from; ; ) {
82
+ const candidate = join(dir, "node_modules", name);
83
+ if (existsSync(join(candidate, "package.json"))) {
84
+ return realpathSync(candidate);
85
+ }
86
+ const parent = dirname(dir);
87
+ if (parent === dir) return undefined;
88
+ dir = parent;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Returns a function describing an installed package by name: the sorted
94
+ * `name@version` lines of the package and its transitive dependencies
95
+ * (dependencies, optionalDependencies and peerDependencies, each resolved from
96
+ * the package that declares it), with a digest of its files for a linked
97
+ * one, or `undefined` when the package is not installed. Results are kept per
98
+ * name for the life of the returned function.
99
+ */
100
+ export function createInstalledPackageDescriber(
101
+ projectRoot: string,
102
+ ): (name: string) => string | undefined {
103
+ const packages = new Map<string, InstalledPackage | undefined>();
104
+ const read = (dir: string): InstalledPackage | undefined => {
105
+ if (packages.has(dir)) return packages.get(dir);
106
+ let manifest: any;
107
+ try {
108
+ manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8"));
109
+ } catch {
110
+ packages.set(dir, undefined);
111
+ return undefined;
112
+ }
113
+ // Outside the try: a linked package whose files cannot be read must fail
114
+ // the build, not drop out of the version.
115
+ const linked = !dir.split(sep).includes("node_modules");
116
+ const found: InstalledPackage = {
117
+ label:
118
+ `${manifest.name}@${manifest.version}` +
119
+ (linked ? ` files:${digestDirectory(dir)}` : ""),
120
+ dependencies: Object.keys({
121
+ ...manifest.dependencies,
122
+ ...manifest.optionalDependencies,
123
+ ...manifest.peerDependencies,
124
+ }),
125
+ };
126
+ packages.set(dir, found);
127
+ return found;
128
+ };
129
+
130
+ // Packages share most of their dependencies (the same peers, the same
131
+ // utilities): each lookup is done once for every package described.
132
+ const located = new Map<string, string | undefined>();
133
+ const locate = (name: string, from: string): string | undefined => {
134
+ const key = `${from}\0${name}`;
135
+ if (!located.has(key)) located.set(key, findPackageDir(name, from));
136
+ return located.get(key);
137
+ };
138
+
139
+ const described = new Map<string, string | undefined>();
140
+ return (name) => {
141
+ if (described.has(name)) return described.get(name);
142
+ const labels = new Set<string>();
143
+ const seen = new Set<string>();
144
+ const pending: Array<[name: string, from: string]> = [[name, projectRoot]];
145
+ while (pending.length > 0) {
146
+ const [dependency, from] = pending.pop()!;
147
+ const dir = locate(dependency, from);
148
+ if (dir === undefined || seen.has(dir)) continue;
149
+ seen.add(dir);
150
+ const installed = read(dir);
151
+ if (!installed) continue;
152
+ labels.add(installed.label);
153
+ for (const next of installed.dependencies) pending.push([next, dir]);
154
+ }
155
+ const text = labels.size > 0 ? [...labels].sort().join("\n") : undefined;
156
+ described.set(name, text);
157
+ return text;
158
+ };
159
+ }
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { contextSet, hasContextVars } from "../../context-var.js";
10
+ import { sha256 } from "./build-versions.js";
10
11
  import {
11
12
  encodePathParam,
12
13
  substituteRouteParams,
@@ -21,6 +22,27 @@ import { createRangoDebugger, NS } from "../debug.js";
21
22
 
22
23
  const debug = createRangoDebugger(NS.prerender);
23
24
 
25
+ /**
26
+ * Note a payload the build rendered and will ship as an asset module, for the
27
+ * owning router's cache version (build-versions.ts). The handler that produced
28
+ * it is evicted from the bundle, so the payload is the only thing left in the
29
+ * server output that changes when the handler or its build-time data does.
30
+ */
31
+ function recordBuildData(
32
+ state: DiscoveryState,
33
+ kind: "prerender" | "static",
34
+ key: string,
35
+ value: string,
36
+ owner: { routerId?: string; moduleId?: string },
37
+ ): void {
38
+ state.buildData.push({
39
+ kind,
40
+ key,
41
+ digest: sha256(value),
42
+ ...owner,
43
+ });
44
+ }
45
+
24
46
  /**
25
47
  * Expand prerender routes into concrete URLs and render them via the
26
48
  * RSC runner. Stages asset modules and stores key-to-file entries in
@@ -248,7 +270,7 @@ export async function expandPrerenderRoutes(
248
270
  group.concurrency,
249
271
  async (entry) => {
250
272
  const startUrl = performance.now();
251
- for (const [, routerInstance] of registry) {
273
+ for (const [routerId, routerInstance] of registry) {
252
274
  if (!routerInstance.matchForPrerender) continue;
253
275
  try {
254
276
  const result = await routerInstance.matchForPrerender(
@@ -281,6 +303,9 @@ export async function expandPrerenderRoutes(
281
303
  "__pr",
282
304
  mainValue,
283
305
  );
306
+ recordBuildData(state, "prerender", mainKey, mainValue, {
307
+ routerId,
308
+ });
284
309
  // Prerender + ppr composition: flag the URL as a build-time shell
285
310
  // candidate for the post-build capture phase (producer B, #699).
286
311
  // The payload JSON is retained in memory so that phase can seed an
@@ -310,6 +335,15 @@ export async function expandPrerenderRoutes(
310
335
  "__pr",
311
336
  interceptValue,
312
337
  );
338
+ recordBuildData(
339
+ state,
340
+ "prerender",
341
+ interceptKey,
342
+ interceptValue,
343
+ {
344
+ routerId,
345
+ },
346
+ );
313
347
  }
314
348
  const elapsed = (performance.now() - startUrl).toFixed(0);
315
349
  console.log(
@@ -447,6 +481,9 @@ export async function renderStaticHandlers(
447
481
  "__st",
448
482
  exportValue,
449
483
  );
484
+ recordBuildData(state, "static", def.$$id, exportValue, {
485
+ moduleId,
486
+ });
450
487
  const elapsed = (performance.now() - startHandler).toFixed(0);
451
488
  console.log(`[rango] OK ${name.padEnd(40)} (${elapsed}ms)`);
452
489
  staticDone++;
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Post-build cache version phase.
3
+ *
4
+ * Runs from the buildApp post hook, after every environment bundle is written
5
+ * and postprocessBundle has rewritten the RSC chunks, and before the shell
6
+ * capture phase, which stamps its entries with the document versions computed
7
+ * here. Reads the bytes that ship, computes each router's versions
8
+ * (build-versions.ts) and writes the table into the built
9
+ * `@rangojs/router:version` module in place of its placeholder.
10
+ */
11
+
12
+ import { randomUUID } from "node:crypto";
13
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { dirname, join, resolve } from "node:path";
15
+ import {
16
+ DEFAULT_ROUTER_VERSIONS_KEY,
17
+ type RouterVersionsTable,
18
+ } from "../../router-versions.js";
19
+ import { isEncryptionKeyStable } from "../encryption-key.js";
20
+ import { createRangoDebugger, NS } from "../debug.js";
21
+ import {
22
+ defaultExportProperties,
23
+ literalProperties,
24
+ } from "../plugins/stable-rsc-output.js";
25
+ import { RANGO_BUILD_DIR } from "../utils/prerender-utils.js";
26
+ import {
27
+ computeRouterVersions,
28
+ fillRouterVersions,
29
+ portableModuleId,
30
+ sha256,
31
+ type BuildDataRecord,
32
+ type ComputedRouterVersions,
33
+ } from "./build-versions.js";
34
+ import { createInstalledPackageDescriber } from "./installed-packages.js";
35
+ import type { DiscoveryState } from "./state.js";
36
+
37
+ const debug = createRangoDebugger(NS.build);
38
+
39
+ /** plugin-rsc's client asset manifest, written into the SSR and RSC out dirs. */
40
+ const ASSETS_MANIFEST_FILE = "__vite_rsc_assets_manifest.js";
41
+ const EMPTY_SERVER_RESOURCES = /"serverResources":\s*\{\s*\}/;
42
+
43
+ /** Minimal builder surface the post-build phases read. */
44
+ export interface BuilderLike {
45
+ config?: { base?: string };
46
+ environments?: Record<
47
+ string,
48
+ { config?: { build?: { outDir?: string } } } | undefined
49
+ >;
50
+ }
51
+
52
+ /**
53
+ * An environment's output directory, or Vite's default layout without one.
54
+ * Resolved against the root: a relative `build.outDir` is relative to it, not
55
+ * to the directory `vite build <root>` was run from.
56
+ */
57
+ export function environmentOutDir(
58
+ builder: BuilderLike | undefined,
59
+ projectRoot: string,
60
+ environment: string,
61
+ ): string {
62
+ return resolve(
63
+ projectRoot,
64
+ builder?.environments?.[environment]?.config?.build?.outDir ??
65
+ join("dist", environment),
66
+ );
67
+ }
68
+
69
+ /**
70
+ * Where the build leaves what each version was computed from. Outside the
71
+ * output directories, so it never ships.
72
+ */
73
+ export const VERSIONS_REPORT_FILE: string = `${RANGO_BUILD_DIR}/cache-versions.json`;
74
+
75
+ function readIfPresent(path: string): Buffer | undefined {
76
+ try {
77
+ return readFileSync(path);
78
+ } catch (err: any) {
79
+ if (err?.code === "ENOENT") return undefined;
80
+ throw err;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * The `serverResources` map of plugin-rsc's assets manifest
86
+ * (`export default { ..., serverResources: { "<module>": { js, css } } }`):
87
+ * each server module that imports CSS, to the source text of its entry. Text,
88
+ * not a parsed value: with `renderBuiltUrl` an entry holds runtime expressions.
89
+ * Throws when the manifest is not that shape, so a plugin-rsc that moves it
90
+ * fails the build instead of dropping stylesheets from the versions.
91
+ */
92
+ export function readServerResources(manifest: string): Map<string, string> {
93
+ // Most apps import no CSS from a server component: skip parsing a manifest
94
+ // that lists every client reference. (Inside a JSON string the quotes would
95
+ // be escaped, so this text can only be the property itself.)
96
+ if (EMPTY_SERVER_RESOURCES.test(manifest)) return new Map();
97
+ const entries = literalProperties(
98
+ defaultExportProperties(manifest)?.find(
99
+ (property) => property.key === "serverResources",
100
+ )?.value,
101
+ );
102
+ if (!entries) {
103
+ throw new Error(
104
+ `${ASSETS_MANIFEST_FILE} has no serverResources object literal: the installed ` +
105
+ `@vitejs/plugin-rsc writes a manifest @rangojs/router cannot read. ` +
106
+ `Report it with the plugin-rsc version.`,
107
+ );
108
+ }
109
+ return new Map(
110
+ entries.map((entry) => [
111
+ entry.key,
112
+ manifest.slice(entry.value.start, entry.value.end),
113
+ ]),
114
+ );
115
+ }
116
+
117
+ /**
118
+ * One record per clientUrls() module: the projection the server materializes
119
+ * the group from. The map holds it under each of the module's reference ids.
120
+ */
121
+ function clientUrlRecords(s: DiscoveryState): BuildDataRecord[] {
122
+ const projectionOf = new Map<string, string>();
123
+ for (const [referenceId, projection] of s.clientUrlProjectionMap ?? []) {
124
+ projectionOf.set(
125
+ s.clientUrlSourceByReferenceId?.get(referenceId) ?? referenceId,
126
+ JSON.stringify(projection),
127
+ );
128
+ }
129
+ return [...projectionOf].map(([source, projection]) => ({
130
+ kind: "client-urls",
131
+ key: portableModuleId(source, s.projectRoot),
132
+ digest: sha256(projection),
133
+ moduleId: source,
134
+ }));
135
+ }
136
+
137
+ /**
138
+ * Write every version's inputs as `name: digest`. A version is one hash, so a
139
+ * changed version says nothing about what changed; diffing two builds' reports
140
+ * names the file, payload or key that did.
141
+ */
142
+ function writeVersionsReport(
143
+ projectRoot: string,
144
+ computed: ComputedRouterVersions,
145
+ sourceOf: ReadonlyMap<string, string>,
146
+ ): void {
147
+ const routers: Record<string, unknown> = {};
148
+ for (const detail of computed.details) {
149
+ routers[detail.routerId] = {
150
+ source: sourceOf.get(detail.routerId) ?? "",
151
+ data: detail.data,
152
+ document: detail.document,
153
+ dataInputs: Object.fromEntries(detail.dataInputs),
154
+ documentInputs: Object.fromEntries(detail.documentInputs),
155
+ };
156
+ }
157
+ // Listed once, not per router. `ssrAndClient`: what the `ssr-and-client`
158
+ // input of every document version is a digest of. `unownedFiles`: server
159
+ // files in no router's version but the whole build's (the host entry, the
160
+ // registries); a change there keeps every app's cache.
161
+ const report = {
162
+ routers,
163
+ ssrAndClient: {
164
+ base: computed.ssrAndClient.base,
165
+ ssr: Object.fromEntries(computed.ssrAndClient.ssr),
166
+ externals: Object.fromEntries(computed.ssrAndClient.externals),
167
+ client: computed.ssrAndClient.client,
168
+ },
169
+ unownedFiles: computed.unownedFiles,
170
+ };
171
+ try {
172
+ const path = resolve(projectRoot, VERSIONS_REPORT_FILE);
173
+ mkdirSync(dirname(path), { recursive: true });
174
+ writeFileSync(path, JSON.stringify(report, null, 2) + "\n");
175
+ } catch (err: any) {
176
+ debug?.("could not write %s: %s", VERSIONS_REPORT_FILE, err?.message);
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Compute the versions, write them into the built version module and return
182
+ * the table (`undefined` outside a build). Fails the build when an input was
183
+ * not recorded or cannot be read: a version computed without it would stay
184
+ * the same when it changes.
185
+ */
186
+ export function runRouterVersionsPhase(
187
+ s: DiscoveryState,
188
+ builder: BuilderLike | undefined,
189
+ ): RouterVersionsTable | undefined {
190
+ if (!s.isBuildMode) return undefined;
191
+ const { serverBuildGraph: server, clientBuildGraph: client } = s;
192
+ const ssr = s.ssrBundle;
193
+ const rscVersionFiles = s.versionModuleFiles.get("rsc") ?? [];
194
+ // The built version module holds a placeholder only this phase replaces: a
195
+ // build that skipped it would throw a ReferenceError on its first import.
196
+ if (!server || !client || !ssr || rscVersionFiles.length === 0) {
197
+ const missing = [
198
+ !server && "the RSC bundle",
199
+ !client && "the client bundle",
200
+ !ssr && "the SSR bundle",
201
+ rscVersionFiles.length === 0 &&
202
+ "the RSC chunk that holds the version module",
203
+ ].filter(Boolean);
204
+ throw new Error(
205
+ `[rango] The build did not record ${missing.join(", ")}, so the cache versions cannot be computed. ` +
206
+ "This is a bug in @rangojs/router; please report it.",
207
+ );
208
+ }
209
+
210
+ const start = performance.now();
211
+ const rscOutDir = environmentOutDir(builder, s.projectRoot, "rsc");
212
+ const ssrOutDir = environmentOutDir(builder, s.projectRoot, "ssr");
213
+
214
+ let computed: ComputedRouterVersions;
215
+ try {
216
+ const assetsManifest = readIfPresent(join(rscOutDir, ASSETS_MANIFEST_FILE));
217
+ if (assetsManifest === undefined) {
218
+ throw new Error(
219
+ `${ASSETS_MANIFEST_FILE} is not in the RSC output directory, so the stylesheets ` +
220
+ `server components link cannot be read`,
221
+ );
222
+ }
223
+ computed = computeRouterVersions({
224
+ projectRoot: s.projectRoot,
225
+ server,
226
+ client,
227
+ // plugin-rsc writes its manifest next to the SSR bundle, not into it.
228
+ ssr: {
229
+ chunks: ssr.chunks,
230
+ assets: [
231
+ ...ssr.assets.filter(
232
+ (asset) => asset.fileName !== ASSETS_MANIFEST_FILE,
233
+ ),
234
+ { fileName: ASSETS_MANIFEST_FILE, name: ASSETS_MANIFEST_FILE },
235
+ ],
236
+ },
237
+ routers: s.perRouterManifests.map((entry) => ({
238
+ id: entry.id,
239
+ moduleId: entry.sourceFile,
240
+ })),
241
+ buildData: [...s.buildData, ...clientUrlRecords(s)],
242
+ readServerFile: (fileName) => readIfPresent(join(rscOutDir, fileName)),
243
+ readSsrFile: (fileName) => readIfPresent(join(ssrOutDir, fileName)),
244
+ base: builder?.config?.base ?? "/",
245
+ serverResources: readServerResources(assetsManifest.toString("utf-8")),
246
+ externals: {
247
+ describePackage: createInstalledPackageDescriber(s.projectRoot),
248
+ readFile: readIfPresent,
249
+ buildId: randomUUID(),
250
+ },
251
+ });
252
+ } catch (err: any) {
253
+ throw new Error(
254
+ `[rango] Could not compute the cache versions: ${err?.message ?? err}`,
255
+ { cause: err },
256
+ );
257
+ }
258
+ const { table, details } = computed;
259
+
260
+ for (const [environment, fileNames] of s.versionModuleFiles) {
261
+ const outDir = environmentOutDir(builder, s.projectRoot, environment);
262
+ let filled = 0;
263
+ for (const fileName of fileNames) {
264
+ const path = join(outDir, fileName);
265
+ const source = readIfPresent(path);
266
+ const next =
267
+ source === undefined
268
+ ? undefined
269
+ : fillRouterVersions(source.toString("utf-8"), table);
270
+ if (next === undefined) continue;
271
+ writeFileSync(path, next);
272
+ filled++;
273
+ }
274
+ if (environment === "rsc" && filled === 0) {
275
+ throw new Error(
276
+ `[rango] Could not write the cache versions into the server build: no placeholder in ${fileNames.join(", ")} under ${outDir}. ` +
277
+ `The build would fail to start. This is a bug in @rangojs/router; please report it.`,
278
+ );
279
+ }
280
+ }
281
+
282
+ const elapsed = (performance.now() - start).toFixed(1);
283
+ const sourceOf = new Map(
284
+ s.perRouterManifests.map((entry) => [
285
+ entry.id,
286
+ entry.sourceFile ? portableModuleId(entry.sourceFile, s.projectRoot) : "",
287
+ ]),
288
+ );
289
+ const detailOf = new Map(details.map((detail) => [detail.routerId, detail]));
290
+ const wholeBuild = detailOf.get(DEFAULT_ROUTER_VERSIONS_KEY)!;
291
+ let keyed = 0;
292
+ if (s.perRouterManifests.length > 0) {
293
+ console.log(
294
+ `[rango] Cache versions for ${s.perRouterManifests.length} router(s), data / document (${elapsed}ms):`,
295
+ );
296
+ for (const { id } of s.perRouterManifests) {
297
+ // A router whose module is not in the server bundle serves with the
298
+ // whole-build pair: any change clears it. Say so, it is not isolated.
299
+ const own = detailOf.get(id);
300
+ const detail = own ?? wholeBuild;
301
+ if (detail.usesEncryptionKey) keyed++;
302
+ console.log(
303
+ `[rango] ${detail.data} / ${detail.document} ${sourceOf.get(id) || id}${own ? "" : " (whole build)"}`,
304
+ );
305
+ }
306
+ }
307
+ debug?.(
308
+ "router versions: %d server file(s), %sms, table=%o",
309
+ server.chunks.length + server.assets.length,
310
+ elapsed,
311
+ table,
312
+ );
313
+ writeVersionsReport(s.projectRoot, computed, sourceOf);
314
+
315
+ if (computed.undetermined.length > 0) {
316
+ console.log(
317
+ `[rango] ${computed.undetermined.length} import(s) the server build leaves external could not be read ` +
318
+ `at build time (${computed.undetermined.join(", ")}): a package that is not installed where the build ` +
319
+ `can find it, a path that does not resolve, or a URL. What they resolve to at run time is unknown, so ` +
320
+ `the routers importing them get a new cache version on every build.`,
321
+ );
322
+ }
323
+
324
+ // The one condition under which a deploy of unchanged code still clears a
325
+ // cache: the key is part of these routers' versions and this build made its
326
+ // own. Said once per build, and only when it applies.
327
+ if (keyed > 0 && !isEncryptionKeyStable()) {
328
+ console.log(
329
+ `[rango] No stable encryption key: ${keyed} router(s) encrypt server-action arguments ` +
330
+ `with a key generated for this build, so their cache version changes on every build and each ` +
331
+ `deploy clears their cache. To keep it, set rango({ encryptionKey: process.env.RANGO_ENCRYPTION_KEY }) ` +
332
+ `with a base64 32-byte key (openssl rand -base64 32).`,
333
+ );
334
+ }
335
+ return table;
336
+ }
@@ -24,6 +24,14 @@ import { join, resolve } from "node:path";
24
24
  import { jsonParseExpression } from "../utils/manifest-utils.js";
25
25
  import { writeBuildAssetModule } from "../utils/prerender-utils.js";
26
26
  import { buildShellManifestKey } from "../../prerender/shell-manifest-key.js";
27
+ import {
28
+ resolveVersionsFrom,
29
+ type RouterVersionsTable,
30
+ } from "../../router-versions.js";
31
+ import {
32
+ environmentOutDir,
33
+ type BuilderLike,
34
+ } from "./router-versions-phase.js";
27
35
  // Type-only: the producer stages exactly the record shape the runtime
28
36
  // read-through consumes, so the contract cannot drift (the KEY half of that
29
37
  // contract is shell-manifest-key.ts). No runtime coupling — the RSC-runtime
@@ -35,30 +43,22 @@ import { createRangoDebugger, NS } from "../debug.js";
35
43
  const debug = createRangoDebugger(NS.prerender);
36
44
 
37
45
  /**
38
- * Minimal builder surface the phase reads: resolved plugins (for the main
39
- * build's version plugin) and per-environment outDirs.
46
+ * @param versionsTable The versions the shipped build serves with
47
+ * (router-versions-phase.ts), `undefined` outside a build. Never the temp
48
+ * server's own version-plugin stamp, which is a dev Date.now() and would
49
+ * fail the serve-side isValidShellHit gate forever.
40
50
  */
41
- interface BuilderLike {
42
- config?: {
43
- base?: string;
44
- plugins?: readonly unknown[];
45
- };
46
- environments?: Record<
47
- string,
48
- { config?: { build?: { outDir?: string } } } | undefined
49
- >;
50
- }
51
-
52
51
  export async function runShellPrerenderPhase(
53
52
  s: DiscoveryState,
54
53
  builder: BuilderLike | undefined,
54
+ versionsTable: RouterVersionsTable | undefined,
55
55
  ): Promise<void> {
56
56
  // The kept temp server (and the buildEnv deferred with it) is OWNED by the
57
57
  // callers — the buildApp post hook's finally on success, buildEnd on an
58
58
  // aborted build — so this function stays a pure producer: it only tears
59
59
  // down the globals it installs itself.
60
60
  const tempServer = s.shellPhaseTempServer;
61
- if (!s.isBuildMode || !s.shellCandidates?.length || !tempServer) return;
61
+ if (!versionsTable || !s.shellCandidates?.length || !tempServer) return;
62
62
 
63
63
  const candidates: ShellPrerenderCandidate[] = s.shellCandidates;
64
64
  const startTotal = performance.now();
@@ -87,21 +87,6 @@ export async function runShellPrerenderPhase(
87
87
  return;
88
88
  }
89
89
 
90
- // The MAIN build's version (the value folded into the shipped worker) —
91
- // never the temp server's own version-plugin stamp, which is a different
92
- // Date.now() and would fail the serve-side isValidShellHit gate forever.
93
- const versionPlugin = (builder?.config?.plugins ?? []).find(
94
- (p: any) => p?.name === "@rangojs/router:version",
95
- ) as { api?: { getBuildVersion?: () => string } } | undefined;
96
- const buildVersion = versionPlugin?.api?.getBuildVersion?.();
97
- if (!buildVersion) {
98
- console.warn(
99
- "[rango] shell prerender: main build version unavailable; skipping — " +
100
- "routes keep runtime shell capture.",
101
- );
102
- return;
103
- }
104
-
105
90
  // In-realm prerender store over the retained phase-A payloads, so the
106
91
  // capture's match() re-enters withCacheLookup and REPLAYS the build-time
107
92
  // segments (globalThis is shared between the plugin process and the temp
@@ -130,9 +115,7 @@ export async function runShellPrerenderPhase(
130
115
  );
131
116
 
132
117
  // Built client bootstrap: the prelude must embed the BUILT entry URL.
133
- const clientOutDir =
134
- builder?.environments?.client?.config?.build?.outDir ??
135
- resolve(s.projectRoot, "dist/client");
118
+ const clientOutDir = environmentOutDir(builder, s.projectRoot, "client");
136
119
  const clientAssetsDir = join(clientOutDir, "assets");
137
120
  const entryFile = existsSync(clientAssetsDir)
138
121
  ? readdirSync(clientAssetsDir).find((f) => /^index-.*\.js$/.test(f))
@@ -245,8 +228,14 @@ export async function runShellPrerenderPhase(
245
228
  const mainKey = `${cand.routeName}/${cand.paramHash}`;
246
229
  let handled = false;
247
230
  const mismatches: string[] = [];
248
- for (const [, routerInstance] of registry) {
231
+ for (const [routerId, routerInstance] of registry) {
249
232
  if (typeof routerInstance.match !== "function") continue;
233
+ // The build's table always holds the whole-build pair.
234
+ const versions = resolveVersionsFrom(
235
+ versionsTable,
236
+ routerId,
237
+ routerInstance.version,
238
+ )!;
250
239
  try {
251
240
  const res = await captureMod.captureShellForBuild({
252
241
  router: routerInstance,
@@ -259,7 +248,7 @@ export async function runShellPrerenderPhase(
259
248
  maxSnapshotBytes: policy.maxSnapshotBytes,
260
249
  captureTimeout: policy.captureTimeout,
261
250
  buildEnv: s.resolvedBuildEnv,
262
- buildVersion,
251
+ versions,
263
252
  captureShellHTML,
264
253
  debug: !!debug,
265
254
  });
@@ -323,10 +312,11 @@ export async function runShellPrerenderPhase(
323
312
  }
324
313
 
325
314
  if (staged.length > 0) {
326
- const rscOutDir =
327
- builder?.environments?.rsc?.config?.build?.outDir ??
328
- resolve(s.projectRoot, "dist/rsc");
329
- writeShellManifest(s, rscOutDir, staged);
315
+ writeShellManifest(
316
+ s,
317
+ environmentOutDir(builder, s.projectRoot, "rsc"),
318
+ staged,
319
+ );
330
320
  }
331
321
 
332
322
  const totalElapsed = (performance.now() - startTotal).toFixed(0);