@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.
- package/dist/bin/rango.js +26 -14
- package/dist/types/browser/event-controller.d.ts +32 -0
- package/dist/types/browser/history-state.d.ts +16 -4
- package/dist/types/browser/navigation-transaction.d.ts +7 -0
- package/dist/types/browser/rango-state.d.ts +3 -1
- package/dist/types/browser/react/context.d.ts +11 -0
- package/dist/types/browser/react/location-state-shared.d.ts +84 -10
- package/dist/types/browser/react/location-state.d.ts +7 -2
- package/dist/types/browser/react/subscribe-to-nothing.d.ts +7 -0
- package/dist/types/cache/cache-scope.d.ts +4 -5
- package/dist/types/cache/cf/cf-cache-store.d.ts +27 -6
- package/dist/types/cache/cf/cf-cache-types.d.ts +12 -4
- package/dist/types/cache/handle-snapshot.d.ts +36 -50
- package/dist/types/cache/index.d.ts +2 -0
- package/dist/types/cache/shell-snapshot.d.ts +19 -3
- package/dist/types/cache/types.d.ts +21 -10
- package/dist/types/cache/vercel/vercel-cache-store.d.ts +28 -16
- package/dist/types/client-urls/navigation.d.ts +7 -1
- package/dist/types/client-urls/optimistic-location.d.ts +10 -4
- package/dist/types/handles/deferred-resolution.d.ts +11 -1
- package/dist/types/prerender/build-shell-capture.d.ts +8 -6
- package/dist/types/router/match-middleware/cache-lookup.d.ts +1 -1
- package/dist/types/router/navigation-snapshot.d.ts +8 -1
- package/dist/types/router/router-interfaces.d.ts +2 -1
- package/dist/types/router/router-options.d.ts +17 -7
- package/dist/types/router/segment-resolution/loader-cache.d.ts +21 -9
- package/dist/types/router-versions.d.ts +39 -0
- package/dist/types/rsc/handler-context.d.ts +5 -0
- package/dist/types/rsc/shell-build-manifest.d.ts +3 -2
- package/dist/types/rsc/types.d.ts +4 -6
- package/dist/types/server/build-version-table.d.ts +44 -0
- package/dist/types/server/handle-store.d.ts +87 -46
- package/dist/types/server/request-context.d.ts +22 -8
- package/dist/types/testing/build-versions.d.ts +32 -0
- package/dist/types/testing/dom.entry.d.ts +1 -1
- package/dist/types/testing/flight.entry.d.ts +1 -1
- package/dist/types/testing/index.d.ts +2 -0
- package/dist/types/testing/internal/seed-vars.d.ts +7 -2
- package/dist/types/testing/location-state-key.d.ts +5 -1
- package/dist/types/testing/render-route.d.ts +77 -6
- package/dist/types/testing/serve-shell-request.d.ts +21 -0
- package/dist/types/testing/vitest-stubs/version.d.ts +1 -0
- package/dist/types/types/handler-context.d.ts +8 -5
- package/dist/types/vite/discovery/build-versions.d.ts +393 -0
- package/dist/types/vite/discovery/installed-packages.d.ts +26 -0
- package/dist/types/vite/discovery/router-versions-phase.d.ts +52 -0
- package/dist/types/vite/discovery/shell-prerender-phase.d.ts +7 -17
- package/dist/types/vite/discovery/state.d.ts +14 -0
- package/dist/types/vite/encryption-key.d.ts +18 -0
- package/dist/types/vite/plugin-types.d.ts +27 -0
- package/dist/types/vite/plugins/{version-injector.d.ts → entry-bootstrap-injector.d.ts} +4 -6
- package/dist/types/vite/plugins/expose-ids/export-analysis.d.ts +1 -0
- package/dist/types/vite/plugins/expose-ids/router-transform.d.ts +14 -0
- package/dist/types/vite/plugins/stable-rsc-output.d.ts +51 -0
- package/dist/types/vite/plugins/version-plugin.d.ts +9 -11
- package/dist/types/vite/plugins/virtual-entries.d.ts +18 -1
- package/dist/types/vite/router-discovery.d.ts +6 -0
- package/dist/types/vite/utils/client-chunks.d.ts +2 -0
- package/dist/types/vite/utils/compare-strings.d.ts +7 -0
- package/dist/types/vite/utils/package-resolution.d.ts +8 -0
- package/dist/types/vite/utils/prerender-utils.d.ts +2 -0
- package/dist/vite/index.js +1537 -444
- package/package.json +1 -2
- package/skills/cache-guide/SKILL.md +84 -20
- package/skills/caching/SKILL.md +16 -5
- package/skills/client-urls/SKILL.md +16 -0
- package/skills/cloudflare/SKILL.md +2 -1
- package/skills/comparison/references/framework-comparison.md +16 -12
- package/skills/hooks/SKILL.md +3 -0
- package/skills/hooks/handle-and-actions.md +6 -0
- package/skills/hooks/navigation.md +28 -0
- package/skills/hooks/state.md +209 -8
- package/skills/hooks/urls.md +40 -1
- package/skills/host-router/SKILL.md +13 -0
- package/skills/intercept/SKILL.md +6 -0
- package/skills/loader/SKILL.md +19 -1
- package/skills/middleware/SKILL.md +2 -0
- package/skills/ppr/SKILL.md +119 -40
- package/skills/prerender/SKILL.md +7 -3
- package/skills/rango/SKILL.md +8 -7
- package/skills/router-setup/SKILL.md +15 -1
- package/skills/testing/SKILL.md +30 -26
- package/skills/testing/client-components.md +193 -8
- package/skills/testing/handles.md +2 -0
- package/skills/testing/loader.md +1 -1
- package/skills/testing/middleware.md +1 -1
- package/skills/testing/render-handler.md +18 -18
- package/skills/testing/serve-shell-request.md +63 -14
- package/skills/testing/server-actions.md +17 -17
- package/skills/testing/server-tree.md +1 -1
- package/skills/typesafety/generated-files-and-cli.md +1 -1
- package/skills/use-cache/SKILL.md +16 -5
- package/skills/vercel/SKILL.md +3 -3
- package/src/bin/rango.ts +1 -1
- package/src/browser/event-controller.ts +75 -2
- package/src/browser/history-state.ts +39 -17
- package/src/browser/navigation-bridge.ts +41 -18
- package/src/browser/navigation-transaction.ts +15 -10
- package/src/browser/partial-update.ts +10 -2
- package/src/browser/rango-state.ts +3 -1
- package/src/browser/react/Link.tsx +2 -3
- package/src/browser/react/NavigationProvider.tsx +25 -1
- package/src/browser/react/context.ts +13 -0
- package/src/browser/react/location-state-shared.ts +200 -34
- package/src/browser/react/location-state.ts +40 -75
- package/src/browser/react/subscribe-to-nothing.ts +7 -0
- package/src/browser/react/use-handle.ts +20 -1
- package/src/browser/rsc-router.tsx +33 -11
- package/src/browser/server-action-bridge.ts +17 -1
- package/src/cache/cache-scope.ts +18 -8
- package/src/cache/cf/cf-cache-store.ts +75 -55
- package/src/cache/cf/cf-cache-types.ts +12 -4
- package/src/cache/handle-snapshot.ts +69 -87
- package/src/cache/index.ts +4 -0
- package/src/cache/shell-snapshot.ts +35 -5
- package/src/cache/types.ts +21 -10
- package/src/cache/vercel/vercel-cache-store.ts +48 -20
- package/src/client-urls/client-root.tsx +1 -0
- package/src/client-urls/navigation.ts +10 -0
- package/src/client-urls/optimistic-location.ts +10 -4
- package/src/handles/deferred-resolution.ts +31 -0
- package/src/prerender/build-shell-capture.ts +11 -9
- package/src/router/manifest.ts +2 -0
- package/src/router/match-middleware/cache-lookup.ts +11 -18
- package/src/router/navigation-snapshot.ts +25 -6
- package/src/router/request-classification.ts +15 -12
- package/src/router/router-interfaces.ts +2 -1
- package/src/router/router-options.ts +17 -7
- package/src/router/segment-resolution/loader-cache.ts +95 -60
- package/src/router-versions.ts +60 -0
- package/src/rsc/full-payload.ts +6 -2
- package/src/rsc/handler-context.ts +5 -0
- package/src/rsc/handler.ts +14 -4
- package/src/rsc/rsc-rendering.ts +12 -1
- package/src/rsc/shell-build-manifest.ts +10 -7
- package/src/rsc/shell-capture.ts +36 -26
- package/src/rsc/types.ts +4 -6
- package/src/server/build-version-table.ts +115 -0
- package/src/server/handle-store.ts +344 -127
- package/src/server/request-context.ts +62 -11
- package/src/ssr/ssr-root.tsx +16 -4
- package/src/testing/build-versions.ts +64 -0
- package/src/testing/dispatch.ts +6 -0
- package/src/testing/dom.entry.ts +1 -0
- package/src/testing/flight.entry.ts +1 -0
- package/src/testing/index.ts +3 -0
- package/src/testing/internal/context.ts +2 -1
- package/src/testing/internal/seed-vars.ts +14 -2
- package/src/testing/location-state-key.ts +18 -8
- package/src/testing/render-handler.ts +2 -1
- package/src/testing/render-route.tsx +277 -43
- package/src/testing/serve-shell-request.ts +80 -11
- package/src/testing/vitest-stubs/version.ts +4 -1
- package/src/types/handler-context.ts +8 -5
- package/src/vite/discovery/build-versions.ts +1281 -0
- package/src/vite/discovery/installed-packages.ts +159 -0
- package/src/vite/discovery/prerender-collection.ts +38 -1
- package/src/vite/discovery/router-versions-phase.ts +336 -0
- package/src/vite/discovery/shell-prerender-phase.ts +28 -38
- package/src/vite/discovery/state.ts +19 -0
- package/src/vite/discovery/virtual-module-codegen.ts +7 -2
- package/src/vite/encryption-key.ts +101 -9
- package/src/vite/plugin-types.ts +28 -0
- package/src/vite/plugins/{version-injector.ts → entry-bootstrap-injector.ts} +7 -22
- package/src/vite/plugins/expose-ids/export-analysis.ts +1 -1
- package/src/vite/plugins/expose-ids/router-transform.ts +36 -15
- package/src/vite/plugins/expose-internal-ids.ts +12 -3
- package/src/vite/plugins/stable-rsc-output.ts +147 -0
- package/src/vite/plugins/version-plugin.ts +19 -24
- package/src/vite/plugins/version.d.ts +17 -2
- package/src/vite/plugins/virtual-entries.ts +31 -5
- package/src/vite/rango.ts +28 -3
- package/src/vite/router-discovery.ts +82 -13
- package/src/vite/utils/client-chunks.ts +5 -1
- package/src/vite/utils/compare-strings.ts +9 -0
- package/src/vite/utils/package-resolution.ts +9 -0
- package/src/vite/utils/prerender-utils.ts +4 -1
- package/src/vite/utils/shared-utils.ts +1 -1
|
@@ -0,0 +1,1281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-router cache versions, computed from the built server output.
|
|
3
|
+
*
|
|
4
|
+
* Every cache key and stored PPR shell is tied to a version. A router's
|
|
5
|
+
* versions are hashes of the code that router can run, so the same code gives
|
|
6
|
+
* the same versions and a new deployment finds the entries the previous one
|
|
7
|
+
* wrote. Design and measurements: docs/design/per-app-cache-version.md.
|
|
8
|
+
*
|
|
9
|
+
* Two steps, both pure over data the build hooks record:
|
|
10
|
+
*
|
|
11
|
+
* 1. Membership ({@link resolveRouterMembers}): which server modules can a
|
|
12
|
+
* router run. Walked on the MODULE graph, not the chunk graph, because the
|
|
13
|
+
* build has registries that name every app's code (plugin-rsc's
|
|
14
|
+
* server-reference map, the loader manifest, the routes manifest). A walk
|
|
15
|
+
* that followed them would put every router's code in every router's set.
|
|
16
|
+
* 2. Hashing ({@link computeRouterVersions}): the bytes on disk of the chunks
|
|
17
|
+
* holding those modules, read AFTER postprocessBundle rewrote them, with
|
|
18
|
+
* everything taken out that says where or how the build ran instead of what
|
|
19
|
+
* it built ({@link digestBundle}).
|
|
20
|
+
*
|
|
21
|
+
* One rule covers what the build cannot determine (a file it lists and cannot
|
|
22
|
+
* read, an import it cannot resolve): the build fails, or the version changes
|
|
23
|
+
* on every build. A version never silently stays the same.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { createHash } from "node:crypto";
|
|
27
|
+
import { isBuiltin } from "node:module";
|
|
28
|
+
import { posix, relative } from "node:path";
|
|
29
|
+
import {
|
|
30
|
+
DEFAULT_ROUTER_VERSIONS_KEY,
|
|
31
|
+
type RouterVersionsTable,
|
|
32
|
+
} from "../../router-versions.js";
|
|
33
|
+
import { escapeRegExp } from "../../regex-escape.js";
|
|
34
|
+
import {
|
|
35
|
+
ROUTER_VERSIONS_PLACEHOLDER,
|
|
36
|
+
VIRTUAL_IDS,
|
|
37
|
+
} from "../plugins/virtual-entries.js";
|
|
38
|
+
import { CLIENT_REFERENCES_MODULE_ID } from "../utils/client-chunks.js";
|
|
39
|
+
import { compareStrings } from "../utils/compare-strings.js";
|
|
40
|
+
import {
|
|
41
|
+
VIRTUAL_LOADER_MANIFEST_ID,
|
|
42
|
+
VIRTUAL_ROUTES_MANIFEST_ID,
|
|
43
|
+
} from "./state.js";
|
|
44
|
+
|
|
45
|
+
/** Import edges of one module, as the bundler resolved them. */
|
|
46
|
+
export interface BuildModuleEdges {
|
|
47
|
+
readonly imports: readonly string[];
|
|
48
|
+
readonly dynamicImports: readonly string[];
|
|
49
|
+
/**
|
|
50
|
+
* The module encrypts inline-action bound arguments, so what it renders can
|
|
51
|
+
* only be decrypted with the build's encryption key.
|
|
52
|
+
*/
|
|
53
|
+
readonly encryptsBoundArgs?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface BuildChunk {
|
|
57
|
+
readonly fileName: string;
|
|
58
|
+
/** The chunk's name without its content hash. */
|
|
59
|
+
readonly name: string;
|
|
60
|
+
readonly moduleIds: readonly string[];
|
|
61
|
+
/** Specifiers the chunk imports that the bundle does not contain. */
|
|
62
|
+
readonly externalImports?: readonly string[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface BuildAsset {
|
|
66
|
+
readonly fileName: string;
|
|
67
|
+
/** The asset's name without its content hash. */
|
|
68
|
+
readonly name: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The files one environment's bundle emits. */
|
|
72
|
+
export interface BundleFiles {
|
|
73
|
+
readonly chunks: readonly BuildChunk[];
|
|
74
|
+
readonly assets: readonly BuildAsset[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The RSC environment's bundle, from its real (not scan) build pass. */
|
|
78
|
+
export interface ServerBuildGraph extends BundleFiles {
|
|
79
|
+
readonly modules: ReadonlyMap<string, BuildModuleEdges>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The client environment's module graph and emitted file names. */
|
|
83
|
+
export interface ClientBuildGraph {
|
|
84
|
+
readonly modules: ReadonlyMap<string, BuildModuleEdges>;
|
|
85
|
+
readonly fileNames: readonly string[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** One discovered router: its runtime id and the module that creates it. */
|
|
89
|
+
export interface RouterSource {
|
|
90
|
+
readonly id: string;
|
|
91
|
+
/** Absolute path of the file calling createRouter(), when known. */
|
|
92
|
+
readonly moduleId: string | undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* One piece of data the build produced that a router's output depends on and
|
|
97
|
+
* that none of the router's chunks holds. Named by the digest of its bytes.
|
|
98
|
+
*
|
|
99
|
+
* The kinds feed different versions, by where the data can end up at run
|
|
100
|
+
* time:
|
|
101
|
+
*
|
|
102
|
+
* - A Static payload is a segment of an ordinary route, so a `cache()` entry
|
|
103
|
+
* of that route stores it. It is part of the DATA version.
|
|
104
|
+
* - A clientUrls() projection is what the server materializes a client-defined
|
|
105
|
+
* route group from: its loaders, loading states, transitions and route ids.
|
|
106
|
+
* It ships in the routes registry (virtual-module-codegen.ts), which a
|
|
107
|
+
* lazily mounted app's chunks do not include. DATA version.
|
|
108
|
+
* - A server component's stylesheet URLs are read at run time from plugin-rsc's
|
|
109
|
+
* assets manifest and rendered as `<link>`s into the router's Flight, so
|
|
110
|
+
* they are in cached payloads while no server chunk holds them. DATA version.
|
|
111
|
+
* - A Prerender payload is served from the build's own store and never
|
|
112
|
+
* written to the segment cache (withCacheLookup returns before the cache
|
|
113
|
+
* scope, match-middleware/cache-lookup.ts). Only stored HTML holds it: a PPR
|
|
114
|
+
* shell or a document-cache response of the route. It is part of the
|
|
115
|
+
* DOCUMENT version only.
|
|
116
|
+
*/
|
|
117
|
+
export interface BuildDataRecord {
|
|
118
|
+
readonly kind: "prerender" | "static" | "client-urls" | "server-css";
|
|
119
|
+
/**
|
|
120
|
+
* Prerender manifest key, Static handler id, clientUrls() module, or the
|
|
121
|
+
* module a `serverResources` entry is keyed by.
|
|
122
|
+
*/
|
|
123
|
+
readonly key: string;
|
|
124
|
+
/** sha256 of the payload, the projection, or the entry. */
|
|
125
|
+
readonly digest: string;
|
|
126
|
+
/** Router that rendered it (prerender). */
|
|
127
|
+
readonly routerId?: string;
|
|
128
|
+
/**
|
|
129
|
+
* Module the data belongs to (static: the handler's; client-urls: the
|
|
130
|
+
* clientUrls() module; server-css: the module that renders the links).
|
|
131
|
+
* Owned by {@link ownersOf}.
|
|
132
|
+
*/
|
|
133
|
+
readonly moduleId?: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface RouterMembers {
|
|
137
|
+
/** Server modules the router can run. */
|
|
138
|
+
readonly modules: ReadonlySet<string>;
|
|
139
|
+
/** Chunk files holding any of them, plus the router's route manifest. */
|
|
140
|
+
readonly chunkFiles: ReadonlySet<string>;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The slice of a plugin context the graph recorders read. */
|
|
144
|
+
interface ModuleGraphContext {
|
|
145
|
+
getModuleIds(): Iterable<string>;
|
|
146
|
+
getModuleInfo(id: string): {
|
|
147
|
+
importedIds?: readonly string[];
|
|
148
|
+
dynamicallyImportedIds?: readonly string[];
|
|
149
|
+
code?: string | null;
|
|
150
|
+
} | null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The slice of an output bundle the graph recorders read. */
|
|
154
|
+
type OutputBundleLike = Record<
|
|
155
|
+
string,
|
|
156
|
+
{
|
|
157
|
+
type: string;
|
|
158
|
+
fileName: string;
|
|
159
|
+
name?: string;
|
|
160
|
+
names?: readonly string[];
|
|
161
|
+
moduleIds?: readonly string[];
|
|
162
|
+
imports?: readonly string[];
|
|
163
|
+
dynamicImports?: readonly string[];
|
|
164
|
+
}
|
|
165
|
+
>;
|
|
166
|
+
|
|
167
|
+
function identifier(name: string): RegExp {
|
|
168
|
+
return new RegExp(`(?<![\\w$.])${escapeRegExp(name)}(?![\\w$])`, "g");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const ENCRYPTION_RUNTIME_ID = "/utils/encryption-runtime";
|
|
172
|
+
/** plugin-rsc 0.5.35 imports its encryption runtime as a namespace. */
|
|
173
|
+
const ENCRYPTION_RUNTIME_IMPORT =
|
|
174
|
+
/import\s*\*\s*as\s+([A-Za-z_$][\w$]*)\s+from\s*["'][^"'\n]*\/utils\/encryption-runtime[^"'\n]*["']/;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Whether a module that imports plugin-rsc's encryption runtime uses it.
|
|
178
|
+
*
|
|
179
|
+
* plugin-rsc prepends the import to every `"use server"` module it transforms
|
|
180
|
+
* and calls the runtime only where an inline action closes over a value, so
|
|
181
|
+
* the import alone is not a sign that a module encrypts. The test is any use
|
|
182
|
+
* of the namespace the import binds, not the name of the function called: a
|
|
183
|
+
* rename in plugin-rsc must not take the key out of the versions. A module
|
|
184
|
+
* whose code or import this cannot read counts as using it.
|
|
185
|
+
*/
|
|
186
|
+
function usesEncryptionRuntime(code: string | null | undefined): boolean {
|
|
187
|
+
if (code == null) return true;
|
|
188
|
+
const local = code.match(ENCRYPTION_RUNTIME_IMPORT)?.[1];
|
|
189
|
+
if (local === undefined) return true;
|
|
190
|
+
// The import statement itself is the first occurrence.
|
|
191
|
+
let occurrences = 0;
|
|
192
|
+
for (const _ of code.matchAll(identifier(local))) {
|
|
193
|
+
if (++occurrences > 1) return true;
|
|
194
|
+
}
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function recordModuleEdges(
|
|
199
|
+
context: ModuleGraphContext,
|
|
200
|
+
): Map<string, BuildModuleEdges> {
|
|
201
|
+
const modules = new Map<string, BuildModuleEdges>();
|
|
202
|
+
for (const id of context.getModuleIds()) {
|
|
203
|
+
const info = context.getModuleInfo(id);
|
|
204
|
+
const imports = info?.importedIds ?? [];
|
|
205
|
+
// `code` is read only for the few modules that import the runtime.
|
|
206
|
+
const encryptsBoundArgs =
|
|
207
|
+
imports.some((imported) => imported.includes(ENCRYPTION_RUNTIME_ID)) &&
|
|
208
|
+
usesEncryptionRuntime(info?.code);
|
|
209
|
+
modules.set(id, {
|
|
210
|
+
imports,
|
|
211
|
+
dynamicImports: info?.dynamicallyImportedIds ?? [],
|
|
212
|
+
...(encryptsBoundArgs ? { encryptsBoundArgs } : undefined),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
return modules;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The chunks and assets of a bundle. Call from generateBundle. */
|
|
219
|
+
export function recordBundleFiles(bundle: OutputBundleLike): BundleFiles {
|
|
220
|
+
const chunks: BuildChunk[] = [];
|
|
221
|
+
const assets: BuildAsset[] = [];
|
|
222
|
+
for (const file of Object.values(bundle)) {
|
|
223
|
+
if (file.type === "chunk") {
|
|
224
|
+
chunks.push({
|
|
225
|
+
fileName: file.fileName,
|
|
226
|
+
name: file.name ?? file.fileName,
|
|
227
|
+
moduleIds: file.moduleIds ?? [],
|
|
228
|
+
externalImports: [
|
|
229
|
+
...(file.imports ?? []),
|
|
230
|
+
...(file.dynamicImports ?? []),
|
|
231
|
+
].filter((specifier) => !Object.hasOwn(bundle, specifier)),
|
|
232
|
+
});
|
|
233
|
+
} else {
|
|
234
|
+
assets.push({
|
|
235
|
+
fileName: file.fileName,
|
|
236
|
+
name: file.names?.[0] ?? file.fileName,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return { chunks, assets };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Call from the RSC environment's generateBundle. */
|
|
244
|
+
export function recordServerGraph(
|
|
245
|
+
context: ModuleGraphContext,
|
|
246
|
+
bundle: OutputBundleLike,
|
|
247
|
+
): ServerBuildGraph {
|
|
248
|
+
return { modules: recordModuleEdges(context), ...recordBundleFiles(bundle) };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Call from the client environment's generateBundle. */
|
|
252
|
+
export function recordClientGraph(
|
|
253
|
+
context: ModuleGraphContext,
|
|
254
|
+
bundle: OutputBundleLike,
|
|
255
|
+
): ClientBuildGraph {
|
|
256
|
+
return {
|
|
257
|
+
modules: recordModuleEdges(context),
|
|
258
|
+
fileNames: Object.values(bundle).map((file) => file.fileName),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** plugin-rsc's build-time map of every server reference in the app. */
|
|
263
|
+
export const SERVER_REFERENCES_MODULE_ID =
|
|
264
|
+
"\0virtual:vite-rsc/server-references";
|
|
265
|
+
const LOADER_REGISTRY = "\0" + VIRTUAL_LOADER_MANIFEST_ID;
|
|
266
|
+
const ROUTES_REGISTRY = "\0" + VIRTUAL_ROUTES_MANIFEST_ID;
|
|
267
|
+
const VERSION_MODULE = "\0" + VIRTUAL_IDS.version;
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* plugin-rsc writes the key to this file next to the RSC entry and has chunks
|
|
271
|
+
* import it at run time (its `rsc:encryption-key` renderChunk), so the key is
|
|
272
|
+
* in no chunk and in no bundle graph. The file exists only when a rendered
|
|
273
|
+
* chunk reads the key.
|
|
274
|
+
*/
|
|
275
|
+
export const ENCRYPTION_KEY_FILE = "__vite_rsc_encryption_key.js";
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Modules that enumerate build-wide references and import each one. The walk
|
|
279
|
+
* stops at them: what they point to belongs to whichever router reaches it by
|
|
280
|
+
* its own imports, or through a client component it renders
|
|
281
|
+
* (resolveRouterMembers).
|
|
282
|
+
*/
|
|
283
|
+
function isRegistryModule(id: string): boolean {
|
|
284
|
+
return (
|
|
285
|
+
id === SERVER_REFERENCES_MODULE_ID ||
|
|
286
|
+
id === LOADER_REGISTRY ||
|
|
287
|
+
id === ROUTES_REGISTRY ||
|
|
288
|
+
id.startsWith(ROUTES_REGISTRY + "/") ||
|
|
289
|
+
id === CLIENT_REFERENCES_MODULE_ID ||
|
|
290
|
+
id.startsWith(CLIENT_REFERENCES_MODULE_ID + "/")
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function routeManifestModuleId(routerId: string): string {
|
|
295
|
+
return `${ROUTES_REGISTRY}/${routerId}`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function walk(
|
|
299
|
+
modules: ReadonlyMap<string, BuildModuleEdges>,
|
|
300
|
+
roots: Iterable<string>,
|
|
301
|
+
reached: Set<string>,
|
|
302
|
+
): void {
|
|
303
|
+
const stack = [...roots];
|
|
304
|
+
while (stack.length > 0) {
|
|
305
|
+
const id = stack.pop()!;
|
|
306
|
+
if (reached.has(id)) continue;
|
|
307
|
+
reached.add(id);
|
|
308
|
+
if (isRegistryModule(id)) continue;
|
|
309
|
+
const edges = modules.get(id);
|
|
310
|
+
if (!edges) continue;
|
|
311
|
+
for (const next of edges.imports) {
|
|
312
|
+
if (!reached.has(next)) stack.push(next);
|
|
313
|
+
}
|
|
314
|
+
for (const next of edges.dynamicImports) {
|
|
315
|
+
if (!reached.has(next)) stack.push(next);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Server actions and loaders the build registries point at. */
|
|
321
|
+
function registryTargets(server: ServerBuildGraph): Set<string> {
|
|
322
|
+
const targets = new Set<string>();
|
|
323
|
+
for (const id of [SERVER_REFERENCES_MODULE_ID, LOADER_REGISTRY]) {
|
|
324
|
+
for (const target of server.modules.get(id)?.dynamicImports ?? []) {
|
|
325
|
+
targets.add(target);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return targets;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The one ownership rule for everything a router's version covers that its
|
|
333
|
+
* own walk does not hand it: a registry target, a build-rendered payload, a
|
|
334
|
+
* clientUrls() projection, a server component's stylesheets, the encryption
|
|
335
|
+
* key. Each has subject modules. It belongs to the routers that reach one of
|
|
336
|
+
* them, and to EVERY router when none does: what no router can be shown to
|
|
337
|
+
* run can still be run through any of them (an action only the browser entry
|
|
338
|
+
* imports, a handler the graph does not show).
|
|
339
|
+
*
|
|
340
|
+
* Returns the owning router ids, or `undefined` for every router.
|
|
341
|
+
*/
|
|
342
|
+
function ownersOf(
|
|
343
|
+
subjects: readonly string[],
|
|
344
|
+
reachOf: ReadonlyMap<string, ReadonlySet<string>>,
|
|
345
|
+
): ReadonlySet<string> | undefined {
|
|
346
|
+
const owners = new Set<string>();
|
|
347
|
+
for (const [routerId, reached] of reachOf) {
|
|
348
|
+
if (subjects.some((id) => reached.has(id))) owners.add(routerId);
|
|
349
|
+
}
|
|
350
|
+
return owners.size > 0 ? owners : undefined;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** What one router's walk has covered so far, in each graph. */
|
|
354
|
+
interface Reach {
|
|
355
|
+
readonly server: Set<string>;
|
|
356
|
+
readonly client: Set<string>;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Add everything `roots` can run to `reach.server`.
|
|
361
|
+
*
|
|
362
|
+
* A `"use client"` module is a leaf in the server graph (plugin-rsc replaces it
|
|
363
|
+
* with reference proxies), so a server action or loader that only client code
|
|
364
|
+
* imports is not reachable from the router's module there. It is reachable in
|
|
365
|
+
* the CLIENT graph, where the same file imports it as a proxy. So: take every
|
|
366
|
+
* reached module that also exists in the client graph and the client walk has
|
|
367
|
+
* not covered, walk the client graph from it, and add the registry targets
|
|
368
|
+
* found. Repeat until nothing is added, since an action's own imports can
|
|
369
|
+
* render more client components.
|
|
370
|
+
*/
|
|
371
|
+
function extendReach(
|
|
372
|
+
server: ServerBuildGraph,
|
|
373
|
+
client: ClientBuildGraph | undefined,
|
|
374
|
+
targets: ReadonlySet<string>,
|
|
375
|
+
reach: Reach,
|
|
376
|
+
roots: Iterable<string>,
|
|
377
|
+
): void {
|
|
378
|
+
walk(server.modules, roots, reach.server);
|
|
379
|
+
if (!client || targets.size === 0) return;
|
|
380
|
+
|
|
381
|
+
for (;;) {
|
|
382
|
+
const seeds: string[] = [];
|
|
383
|
+
for (const id of reach.server) {
|
|
384
|
+
if (!reach.client.has(id) && client.modules.has(id)) seeds.push(id);
|
|
385
|
+
}
|
|
386
|
+
if (seeds.length === 0) return;
|
|
387
|
+
walk(client.modules, seeds, reach.client);
|
|
388
|
+
const found: string[] = [];
|
|
389
|
+
for (const target of targets) {
|
|
390
|
+
if (reach.client.has(target) && !reach.server.has(target)) {
|
|
391
|
+
found.push(target);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (found.length === 0) return;
|
|
395
|
+
walk(server.modules, found, reach.server);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Bundler ids can carry a query; discovery's source path never does. */
|
|
400
|
+
function findModuleId(
|
|
401
|
+
modules: ReadonlyMap<string, BuildModuleEdges>,
|
|
402
|
+
path: string,
|
|
403
|
+
): string | undefined {
|
|
404
|
+
if (modules.has(path)) return path;
|
|
405
|
+
for (const id of modules.keys()) {
|
|
406
|
+
const query = id.indexOf("?");
|
|
407
|
+
if (query !== -1 && id.slice(0, query) === path) return id;
|
|
408
|
+
}
|
|
409
|
+
return undefined;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** For each module, the modules that statically import it. */
|
|
413
|
+
function staticImporters(
|
|
414
|
+
modules: ReadonlyMap<string, BuildModuleEdges>,
|
|
415
|
+
): Map<string, string[]> {
|
|
416
|
+
const importers = new Map<string, string[]>();
|
|
417
|
+
for (const [id, edges] of modules) {
|
|
418
|
+
if (isRegistryModule(id)) continue;
|
|
419
|
+
for (const imported of edges.imports) {
|
|
420
|
+
const list = importers.get(imported);
|
|
421
|
+
if (list) list.push(id);
|
|
422
|
+
else importers.set(imported, [id]);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return importers;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Work out, per router, the server modules it can run and the chunk files
|
|
430
|
+
* holding them.
|
|
431
|
+
*
|
|
432
|
+
* The walk starts at the module calling createRouter() AND at every module
|
|
433
|
+
* that statically imports it, directly or through other static imports. What
|
|
434
|
+
* runs with a router is not only what its own module imports: the modules
|
|
435
|
+
* importing it attach middleware and handlers (`router.use(...)` in an app
|
|
436
|
+
* module) and wrap its fetch (the mounted handler). The climb stops at a
|
|
437
|
+
* dynamic import, which is how a host mounts an app it does not run with; so
|
|
438
|
+
* routers that one module imports statically share that module's code, and
|
|
439
|
+
* only lazily mounted apps are independent of each other.
|
|
440
|
+
*
|
|
441
|
+
* A router whose module the build cannot find is left out of the result; the
|
|
442
|
+
* runtime then serves it with the whole-build versions
|
|
443
|
+
* ({@link DEFAULT_ROUTER_VERSIONS_KEY}).
|
|
444
|
+
*
|
|
445
|
+
* Membership is per module, the hash is per chunk file. A chunk that holds one
|
|
446
|
+
* reached module is hashed whole, so a router's version also covers whatever
|
|
447
|
+
* the bundler placed next to its code: the conservative direction.
|
|
448
|
+
*/
|
|
449
|
+
export function resolveRouterMembers(
|
|
450
|
+
server: ServerBuildGraph,
|
|
451
|
+
client: ClientBuildGraph | undefined,
|
|
452
|
+
routers: readonly RouterSource[],
|
|
453
|
+
): Map<string, RouterMembers> {
|
|
454
|
+
const targets = registryTargets(server);
|
|
455
|
+
const importers = staticImporters(server.modules);
|
|
456
|
+
const reachByRouter = new Map<string, Reach>();
|
|
457
|
+
for (const router of routers) {
|
|
458
|
+
const moduleId =
|
|
459
|
+
router.moduleId !== undefined
|
|
460
|
+
? findModuleId(server.modules, router.moduleId)
|
|
461
|
+
: undefined;
|
|
462
|
+
if (moduleId === undefined) continue;
|
|
463
|
+
const climbed = new Set<string>();
|
|
464
|
+
const stack = [moduleId];
|
|
465
|
+
while (stack.length > 0) {
|
|
466
|
+
const id = stack.pop()!;
|
|
467
|
+
if (climbed.has(id)) continue;
|
|
468
|
+
climbed.add(id);
|
|
469
|
+
for (const importer of importers.get(id) ?? []) stack.push(importer);
|
|
470
|
+
}
|
|
471
|
+
const reach: Reach = { server: new Set(), client: new Set() };
|
|
472
|
+
extendReach(server, client, targets, reach, climbed);
|
|
473
|
+
reachByRouter.set(router.id, reach);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// A registry target no router reaches (an action only the browser entry
|
|
477
|
+
// imports, a loader nothing imports) is every router's: ownersOf.
|
|
478
|
+
const serverReach = new Map(
|
|
479
|
+
[...reachByRouter].map(([routerId, reach]) => [routerId, reach.server]),
|
|
480
|
+
);
|
|
481
|
+
const unattributed = [...targets].filter(
|
|
482
|
+
(target) => ownersOf([target], serverReach) === undefined,
|
|
483
|
+
);
|
|
484
|
+
if (unattributed.length > 0) {
|
|
485
|
+
for (const reach of reachByRouter.values()) {
|
|
486
|
+
extendReach(server, client, targets, reach, unattributed);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const chunkOfModule = new Map<string, string>();
|
|
491
|
+
for (const chunk of server.chunks) {
|
|
492
|
+
for (const id of chunk.moduleIds) chunkOfModule.set(id, chunk.fileName);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const members = new Map<string, RouterMembers>();
|
|
496
|
+
for (const [routerId, { server: modules }] of reachByRouter) {
|
|
497
|
+
const chunkFiles = new Set<string>();
|
|
498
|
+
for (const id of modules) {
|
|
499
|
+
const fileName = chunkOfModule.get(id);
|
|
500
|
+
if (fileName !== undefined) chunkFiles.add(fileName);
|
|
501
|
+
}
|
|
502
|
+
// The lazy route manifest is loaded through the routes registry
|
|
503
|
+
// (ensureRouterManifest), never imported by the router's own code.
|
|
504
|
+
const manifestChunk = chunkOfModule.get(routeManifestModuleId(routerId));
|
|
505
|
+
if (manifestChunk !== undefined) chunkFiles.add(manifestChunk);
|
|
506
|
+
members.set(routerId, { modules, chunkFiles });
|
|
507
|
+
}
|
|
508
|
+
return members;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export function sha256(value: string | Uint8Array): string {
|
|
512
|
+
return createHash("sha256").update(value).digest("hex");
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Bundle file names use `/` on every platform. */
|
|
516
|
+
const baseName = posix.basename;
|
|
517
|
+
|
|
518
|
+
function isAbsolutePath(path: string): boolean {
|
|
519
|
+
return path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* A pnpm store directory with its peer suffix:
|
|
524
|
+
* `.pnpm/@vitejs+plugin-rsc@0.5.35_react@19.3.0_<hash>`. The suffix names what
|
|
525
|
+
* the package was installed against, peers of peers included, so bumping any
|
|
526
|
+
* of those (TypeScript is an optional peer of many) renames the directory
|
|
527
|
+
* while the package's own files stay the same.
|
|
528
|
+
*/
|
|
529
|
+
const PNPM_PEER_SUFFIX = /(\/\.pnpm\/[^/]+?@[^/_]+)_[^/]+(?=\/)/g;
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* plugin-rsc names a client-reference group after the facade module of its
|
|
533
|
+
* server chunk, made relative to the root with `path.relative`. For a virtual
|
|
534
|
+
* facade (@cloudflare/vite-plugin's worker entry) that resolves the id
|
|
535
|
+
* against the WORKING directory first, so the group's module id carries the
|
|
536
|
+
* way from the root to wherever vite was started:
|
|
537
|
+
* `…/group/facade:__/__/\0virtual:cloudflare/worker-entry` from a repository
|
|
538
|
+
* root, `…/group/facade:\0virtual:cloudflare/worker-entry` in the app. Measured
|
|
539
|
+
* on tests/cloudflare-stress-demo: the SSR chunk holding that module got
|
|
540
|
+
* another identity, and the document version with it.
|
|
541
|
+
*/
|
|
542
|
+
function withoutWorkingDirectory(id: string): string {
|
|
543
|
+
const kind = /(?:facade|shared):/.exec(id);
|
|
544
|
+
const virtual = id.indexOf("\0");
|
|
545
|
+
if (!kind || virtual === -1) return id;
|
|
546
|
+
const facade = kind.index + kind[0].length;
|
|
547
|
+
return virtual < facade ? id : id.slice(0, facade) + id.slice(virtual);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* A module id without the build machine's checkout directory: root-relative
|
|
552
|
+
* inside the project, and from the outermost node_modules for an installed
|
|
553
|
+
* package. The outermost, not the innermost: two installed versions of one
|
|
554
|
+
* package differ only in the directories between (`.pnpm/foo@1…/node_modules/foo`,
|
|
555
|
+
* a nested `a/node_modules/foo`), and must not get the same id. A pnpm store
|
|
556
|
+
* directory is reduced to `name@version` ({@link PNPM_PEER_SUFFIX}).
|
|
557
|
+
*
|
|
558
|
+
* A virtual id can carry a file path in its query. plugin-rsc's server CSS
|
|
559
|
+
* module is `virtual:vite-rsc/css?type=rsc&id=<URI-encoded absolute importer>`,
|
|
560
|
+
* so those values are made portable too.
|
|
561
|
+
*/
|
|
562
|
+
export function portableModuleId(id: string, projectRoot: string): string {
|
|
563
|
+
const path = id.startsWith("\0") ? id.slice(1) : id;
|
|
564
|
+
const nodeModules = path.indexOf("/node_modules/");
|
|
565
|
+
if (nodeModules !== -1) {
|
|
566
|
+
return path.slice(nodeModules + 1).replace(PNPM_PEER_SUFFIX, "$1");
|
|
567
|
+
}
|
|
568
|
+
if (isAbsolutePath(path)) {
|
|
569
|
+
return relative(projectRoot, path).replaceAll("\\", "/");
|
|
570
|
+
}
|
|
571
|
+
return withoutWorkingDirectory(path).replace(
|
|
572
|
+
/([?&][^=&]+=)([^&]+)/g,
|
|
573
|
+
(whole, name, value) => {
|
|
574
|
+
let decoded: string;
|
|
575
|
+
try {
|
|
576
|
+
decoded = decodeURIComponent(value);
|
|
577
|
+
} catch {
|
|
578
|
+
return whole;
|
|
579
|
+
}
|
|
580
|
+
return isAbsolutePath(decoded)
|
|
581
|
+
? name + portableModuleId(decoded, projectRoot)
|
|
582
|
+
: whole;
|
|
583
|
+
},
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** plugin-rsc's server CSS module, as a portable id; captures the importer. */
|
|
588
|
+
const SERVER_CSS_MODULE = /^virtual:vite-rsc\/css\?type=rsc&id=([^&]+)/;
|
|
589
|
+
|
|
590
|
+
/** A sourcemap describes a file; it is not code the server runs. */
|
|
591
|
+
export function isSourceMap(fileName: string): boolean {
|
|
592
|
+
return fileName.endsWith(".map");
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* The bundler's region comments around plugin-rsc's server-reference map, as
|
|
597
|
+
* they appear in an unminified chunk (`\0` is printed as two characters).
|
|
598
|
+
*/
|
|
599
|
+
const SERVER_REFERENCES_REGION_START = `//#region \\0${SERVER_REFERENCES_MODULE_ID.slice(1)}\n`;
|
|
600
|
+
const REGION_END = "//#endregion";
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Drop the body of the server-reference map from a chunk's source.
|
|
604
|
+
*
|
|
605
|
+
* The map lists every `"use server"` module of the build with its export
|
|
606
|
+
* names, and it sits in a chunk every router runs (plugin-rsc's server
|
|
607
|
+
* runtime imports it). Hashed as is, the map would put every app's action
|
|
608
|
+
* list into every app's version. Its text is derived: each module it names is
|
|
609
|
+
* hashed by the routers that reach it (resolveRouterMembers), so nothing the
|
|
610
|
+
* map says is lost by skipping it.
|
|
611
|
+
*
|
|
612
|
+
* Works on the region comments, so a minified server build (which has none)
|
|
613
|
+
* keeps the map in every router's hash: more clearing, never less.
|
|
614
|
+
*/
|
|
615
|
+
export function stripServerReferenceMap(source: string): string {
|
|
616
|
+
const start = source.indexOf(SERVER_REFERENCES_REGION_START);
|
|
617
|
+
if (start === -1) return source;
|
|
618
|
+
const bodyStart = start + SERVER_REFERENCES_REGION_START.length;
|
|
619
|
+
const end = source.indexOf(REGION_END, bodyStart);
|
|
620
|
+
if (end === -1) return source;
|
|
621
|
+
return source.slice(0, bodyStart) + source.slice(end);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const REGION_LINE = /^\/\/#region .*$/gm;
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Take the module paths out of a chunk's region comments.
|
|
628
|
+
*
|
|
629
|
+
* The bundler prints one before each module's code, and the path says where
|
|
630
|
+
* the build ran, not what it built: it is relative to the directory `vite
|
|
631
|
+
* build` was started from (`vite build apps/web` from a repository root
|
|
632
|
+
* prints other paths than `vite build` in the app), it holds the pnpm store
|
|
633
|
+
* directory with its peer suffix, and a virtual id is printed verbatim,
|
|
634
|
+
* absolute paths in its query included. The code under the comment is hashed
|
|
635
|
+
* either way.
|
|
636
|
+
*/
|
|
637
|
+
export function stripRegionPaths(source: string): string {
|
|
638
|
+
return source.replace(REGION_LINE, "//#region");
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** One thing a version is a hash of: its name and the digest of its bytes. */
|
|
642
|
+
export type VersionInput = readonly [name: string, digest: string];
|
|
643
|
+
|
|
644
|
+
/** One bundle file, digested once for every router. */
|
|
645
|
+
export interface DigestedFile {
|
|
646
|
+
readonly fileName: string;
|
|
647
|
+
/**
|
|
648
|
+
* What the file is, independent of its content: the un-hashed name plus, for
|
|
649
|
+
* a chunk, a digest of the module ids inside it. Stands in for the file's
|
|
650
|
+
* hashed name wherever a router that owns the file refers to it.
|
|
651
|
+
*/
|
|
652
|
+
readonly identity: string;
|
|
653
|
+
/**
|
|
654
|
+
* Digest of the file's bytes with every reference to a bundle file replaced
|
|
655
|
+
* by one token ({@link FILE_REFERENCE}), so a content hash in a file name
|
|
656
|
+
* never reaches it.
|
|
657
|
+
*/
|
|
658
|
+
readonly base: string;
|
|
659
|
+
/** The files those references named, in order of appearance. */
|
|
660
|
+
readonly references: readonly string[];
|
|
661
|
+
/** Specifiers the file imports that the bundle does not contain. */
|
|
662
|
+
readonly externalImports: readonly string[];
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Stands for a file name in the text a chunk's `base` is a digest of. A NUL:
|
|
667
|
+
* no chunk has one in its text, so a reference cannot be confused with code
|
|
668
|
+
* around it (a `~` operator, say).
|
|
669
|
+
*/
|
|
670
|
+
const FILE_REFERENCE = "\0";
|
|
671
|
+
/** Stands for a reference to a file outside the router's set. */
|
|
672
|
+
const FOREIGN_FILE = "~";
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Digest every file of a bundle, as it is on disk.
|
|
676
|
+
*
|
|
677
|
+
* Three things are taken out of a chunk's text first, each because it says
|
|
678
|
+
* where or how the build ran: the server-reference map's body
|
|
679
|
+
* ({@link stripServerReferenceMap}), the paths in region comments
|
|
680
|
+
* ({@link stripRegionPaths}), and the hashed names of bundle files. A name
|
|
681
|
+
* carries the content hash of its file, and every chunk importing the file
|
|
682
|
+
* has the name in its bytes, so one router's change would otherwise move the
|
|
683
|
+
* digest of every chunk naming its chunk. Only names with a content hash are
|
|
684
|
+
* replaced; a fixed name such as the entry's "index.js" is the same in every
|
|
685
|
+
* build. An asset is data (a route manifest staged as a text module can be
|
|
686
|
+
* megabytes): hashed as bytes, never scanned.
|
|
687
|
+
*
|
|
688
|
+
* Throws when the bundle lists a file that is not on disk: its bytes would
|
|
689
|
+
* silently drop out of every version.
|
|
690
|
+
*/
|
|
691
|
+
export function digestBundle(
|
|
692
|
+
bundle: BundleFiles,
|
|
693
|
+
projectRoot: string,
|
|
694
|
+
read: (fileName: string) => Uint8Array | undefined,
|
|
695
|
+
label: string,
|
|
696
|
+
): DigestedFile[] {
|
|
697
|
+
const named: Array<{
|
|
698
|
+
fileName: string;
|
|
699
|
+
identity: string;
|
|
700
|
+
hashedName: boolean;
|
|
701
|
+
chunk?: BuildChunk;
|
|
702
|
+
}> = [];
|
|
703
|
+
for (const chunk of bundle.chunks) {
|
|
704
|
+
const ids = chunk.moduleIds
|
|
705
|
+
.map((id) => portableModuleId(id, projectRoot))
|
|
706
|
+
.sort();
|
|
707
|
+
const ext = posix.extname(chunk.fileName);
|
|
708
|
+
named.push({
|
|
709
|
+
fileName: chunk.fileName,
|
|
710
|
+
identity: `${chunk.name}~${sha256(ids.join("\n")).slice(0, 8)}${ext}`,
|
|
711
|
+
hashedName: baseName(chunk.fileName) !== chunk.name + ext,
|
|
712
|
+
chunk,
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
for (const asset of bundle.assets) {
|
|
716
|
+
if (isSourceMap(asset.fileName)) continue;
|
|
717
|
+
named.push({
|
|
718
|
+
fileName: asset.fileName,
|
|
719
|
+
identity: asset.name,
|
|
720
|
+
hashedName: baseName(asset.fileName) !== baseName(asset.name),
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const fileOfBaseName = new Map<string, string>();
|
|
725
|
+
for (const file of named) {
|
|
726
|
+
if (file.hashedName) {
|
|
727
|
+
fileOfBaseName.set(baseName(file.fileName), file.fileName);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
const reference =
|
|
731
|
+
fileOfBaseName.size > 0
|
|
732
|
+
? new RegExp(
|
|
733
|
+
[...fileOfBaseName.keys()]
|
|
734
|
+
.sort((a, b) => b.length - a.length)
|
|
735
|
+
.map(escapeRegExp)
|
|
736
|
+
.join("|"),
|
|
737
|
+
"g",
|
|
738
|
+
)
|
|
739
|
+
: undefined;
|
|
740
|
+
|
|
741
|
+
const decoder = new TextDecoder();
|
|
742
|
+
return named.map(({ fileName, identity, chunk }) => {
|
|
743
|
+
const bytes = read(fileName);
|
|
744
|
+
if (bytes === undefined) {
|
|
745
|
+
throw new Error(
|
|
746
|
+
`the ${label} bundle lists ${fileName}, which is not in its output directory`,
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
if (!chunk) {
|
|
750
|
+
return {
|
|
751
|
+
fileName,
|
|
752
|
+
identity,
|
|
753
|
+
base: sha256(bytes),
|
|
754
|
+
references: [],
|
|
755
|
+
externalImports: [],
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
const references: string[] = [];
|
|
759
|
+
const text = stripRegionPaths(
|
|
760
|
+
stripServerReferenceMap(decoder.decode(bytes)),
|
|
761
|
+
);
|
|
762
|
+
const blanked = reference
|
|
763
|
+
? text.replace(reference, (name) => {
|
|
764
|
+
references.push(fileOfBaseName.get(name)!);
|
|
765
|
+
return FILE_REFERENCE;
|
|
766
|
+
})
|
|
767
|
+
: text;
|
|
768
|
+
return {
|
|
769
|
+
fileName,
|
|
770
|
+
identity,
|
|
771
|
+
base: sha256(blanked),
|
|
772
|
+
references,
|
|
773
|
+
externalImports: chunk.externalImports ?? [],
|
|
774
|
+
};
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* A file's digest as the owner of `owned` sees it: each reference becomes the
|
|
780
|
+
* target's identity when the owner has the target, one fixed token when it
|
|
781
|
+
* does not. A change to a chunk a router does not own cannot reach its hash
|
|
782
|
+
* through a file name.
|
|
783
|
+
*/
|
|
784
|
+
function digestAsOwned(
|
|
785
|
+
file: DigestedFile,
|
|
786
|
+
identityOf: ReadonlyMap<string, string>,
|
|
787
|
+
owned: ReadonlySet<string>,
|
|
788
|
+
): string {
|
|
789
|
+
if (file.references.length === 0) return file.base;
|
|
790
|
+
return sha256(
|
|
791
|
+
JSON.stringify([
|
|
792
|
+
file.base,
|
|
793
|
+
...file.references.map((name) =>
|
|
794
|
+
owned.has(name) ? identityOf.get(name)! : FOREIGN_FILE,
|
|
795
|
+
),
|
|
796
|
+
]),
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** How the build reads what the bundles leave external. */
|
|
801
|
+
export interface ExternalResolver {
|
|
802
|
+
/**
|
|
803
|
+
* What is installed for a package: `name@version` of the package and of
|
|
804
|
+
* everything it depends on (installed-packages.ts). The chunk only says
|
|
805
|
+
* `from "pkg"`, whichever version that is. `undefined` when the package
|
|
806
|
+
* cannot be found.
|
|
807
|
+
*/
|
|
808
|
+
readonly describePackage: (name: string) => string | undefined;
|
|
809
|
+
/** The bytes of a file at an absolute path; `undefined` when it is absent. */
|
|
810
|
+
readonly readFile: (path: string) => Uint8Array | undefined;
|
|
811
|
+
/** Unique to this build: the digest of an input that cannot be determined. */
|
|
812
|
+
readonly buildId: string;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
export type ExternalImport =
|
|
816
|
+
| { readonly kind: "provided" }
|
|
817
|
+
| { readonly kind: "package"; readonly name: string }
|
|
818
|
+
| { readonly kind: "file" }
|
|
819
|
+
| { readonly kind: "unknown" };
|
|
820
|
+
|
|
821
|
+
/** What the runtime itself provides: nothing a build could install or change. */
|
|
822
|
+
const RUNTIME_SCHEME = /^(?:node|cloudflare|workerd|bun):/;
|
|
823
|
+
/**
|
|
824
|
+
* Specifiers another plugin replaces when it renders the chunk, for a file
|
|
825
|
+
* the versions already cover:
|
|
826
|
+
*
|
|
827
|
+
* - @cloudflare/vite-plugin imports a text, data or wasm module as
|
|
828
|
+
* `__CLOUDFLARE_MODULE__<type>__<path>__CLOUDFLARE_MODULE__`; the file it
|
|
829
|
+
* emits is a bundle asset the chunk names, hashed as one
|
|
830
|
+
* ({@link digestBundle}).
|
|
831
|
+
* - plugin-rsc resolves `virtual:vite-rsc/assets-manifest` as external and
|
|
832
|
+
* writes the manifest after every bundle. A data version covers what a
|
|
833
|
+
* router's server code renders from it, entry by entry (`server-css`
|
|
834
|
+
* records); the document version covers the client asset names.
|
|
835
|
+
*/
|
|
836
|
+
const REPLACED_MARKER =
|
|
837
|
+
/^(?:__CLOUDFLARE_MODULE__.*__CLOUDFLARE_MODULE__|virtual:vite-rsc\/assets-manifest)$/;
|
|
838
|
+
/** A name npm accepts: no leading `.` or `_`. */
|
|
839
|
+
const PACKAGE_NAME = /^(?:@[\w.~-]+\/)?[A-Za-z0-9~-][\w.~-]*$/;
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* What an import a bundle leaves external is. THE rule for all of them, and
|
|
843
|
+
* the only place it is decided:
|
|
844
|
+
*
|
|
845
|
+
* - `provided`: the version does not cover it. A runtime builtin (`node:fs`,
|
|
846
|
+
* bare `fs`, `cloudflare:workers`, `bun:sqlite`), a `data:` URL (its content
|
|
847
|
+
* is the specifier, which is in the chunk), or a marker of another plugin
|
|
848
|
+
* whose file is a bundle output the hash already covers.
|
|
849
|
+
* - `package`: an installed package. The version covers what is installed.
|
|
850
|
+
* - `file`: a relative or absolute path. The version covers the file's bytes.
|
|
851
|
+
* - `unknown`: anything else (`https:`, `npm:`, a name npm would reject).
|
|
852
|
+
*
|
|
853
|
+
* A package that is not installed, a path that does not resolve and every
|
|
854
|
+
* `unknown` import get a value unique to the build, and the build names them:
|
|
855
|
+
* the routers importing them get a new version on every build. Nothing the
|
|
856
|
+
* build cannot read is left out of a version.
|
|
857
|
+
*/
|
|
858
|
+
export function classifyExternal(specifier: string): ExternalImport {
|
|
859
|
+
if (/^[./\\]/.test(specifier) || isAbsolutePath(specifier)) {
|
|
860
|
+
return { kind: "file" };
|
|
861
|
+
}
|
|
862
|
+
if (
|
|
863
|
+
RUNTIME_SCHEME.test(specifier) ||
|
|
864
|
+
specifier.startsWith("data:") ||
|
|
865
|
+
isBuiltin(specifier) ||
|
|
866
|
+
REPLACED_MARKER.test(specifier)
|
|
867
|
+
) {
|
|
868
|
+
return { kind: "provided" };
|
|
869
|
+
}
|
|
870
|
+
if (specifier.includes(":")) return { kind: "unknown" };
|
|
871
|
+
const parts = specifier.split("/");
|
|
872
|
+
const name = specifier.startsWith("@")
|
|
873
|
+
? parts.slice(0, 2).join("/")
|
|
874
|
+
: parts[0]!;
|
|
875
|
+
return PACKAGE_NAME.test(name)
|
|
876
|
+
? { kind: "package", name }
|
|
877
|
+
: { kind: "unknown" };
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
interface ExternalContext {
|
|
881
|
+
readonly projectRoot: string;
|
|
882
|
+
readonly externals: ExternalResolver;
|
|
883
|
+
/** Read a file of the output directory the importing chunk is in. */
|
|
884
|
+
readonly read: (fileName: string) => Uint8Array | undefined;
|
|
885
|
+
/** Imports the build could not determine, as the build names them. */
|
|
886
|
+
readonly undetermined: Set<string>;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/** The version inputs for what one chunk leaves external ({@link classifyExternal}). */
|
|
890
|
+
function externalInputs(
|
|
891
|
+
file: DigestedFile,
|
|
892
|
+
context: ExternalContext,
|
|
893
|
+
): VersionInput[] {
|
|
894
|
+
const { externals, undetermined } = context;
|
|
895
|
+
const inputs: VersionInput[] = [];
|
|
896
|
+
const add = (name: string, value: string | Uint8Array | undefined): void => {
|
|
897
|
+
if (value === undefined) undetermined.add(name);
|
|
898
|
+
inputs.push([
|
|
899
|
+
`external ${name}`,
|
|
900
|
+
sha256(value ?? `undetermined ${externals.buildId}`),
|
|
901
|
+
]);
|
|
902
|
+
};
|
|
903
|
+
for (const specifier of file.externalImports) {
|
|
904
|
+
const external = classifyExternal(specifier);
|
|
905
|
+
if (external.kind === "package") {
|
|
906
|
+
add(external.name, externals.describePackage(external.name));
|
|
907
|
+
} else if (external.kind === "file") {
|
|
908
|
+
if (isAbsolutePath(specifier)) {
|
|
909
|
+
// Named root-relative inside the project. Outside it the path is the
|
|
910
|
+
// same on every checkout, and a relative one would count the
|
|
911
|
+
// directories between.
|
|
912
|
+
const inProject = portableModuleId(specifier, context.projectRoot);
|
|
913
|
+
add(
|
|
914
|
+
inProject.startsWith("..") ? specifier : inProject,
|
|
915
|
+
externals.readFile(specifier),
|
|
916
|
+
);
|
|
917
|
+
} else {
|
|
918
|
+
// As written in the chunk: relative to the chunk's own directory.
|
|
919
|
+
const fileName = posix.join(
|
|
920
|
+
posix.dirname(file.fileName),
|
|
921
|
+
specifier.replaceAll("\\", "/"),
|
|
922
|
+
);
|
|
923
|
+
add(fileName, context.read(fileName));
|
|
924
|
+
}
|
|
925
|
+
} else if (external.kind === "unknown") {
|
|
926
|
+
add(specifier, undefined);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
return inputs;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
export interface ComputeRouterVersionsInput {
|
|
933
|
+
readonly projectRoot: string;
|
|
934
|
+
readonly server: ServerBuildGraph;
|
|
935
|
+
readonly client: ClientBuildGraph;
|
|
936
|
+
/** The SSR environment's bundle, with the files plugin-rsc writes next to it. */
|
|
937
|
+
readonly ssr: BundleFiles;
|
|
938
|
+
readonly routers: readonly RouterSource[];
|
|
939
|
+
readonly buildData: readonly BuildDataRecord[];
|
|
940
|
+
/** Read a file of the RSC output directory; undefined when it is absent. */
|
|
941
|
+
readonly readServerFile: (fileName: string) => Uint8Array | undefined;
|
|
942
|
+
/** Read a file of the SSR output directory; undefined when it is absent. */
|
|
943
|
+
readonly readSsrFile: (fileName: string) => Uint8Array | undefined;
|
|
944
|
+
/** Vite's `base`, a prefix of every asset URL in stored HTML. */
|
|
945
|
+
readonly base: string;
|
|
946
|
+
/**
|
|
947
|
+
* plugin-rsc's `serverResources` from the built assets manifest: the
|
|
948
|
+
* root-relative id of a server module that imports CSS, to the text of its
|
|
949
|
+
* entry (the stylesheet URLs).
|
|
950
|
+
*/
|
|
951
|
+
readonly serverResources?: ReadonlyMap<string, string>;
|
|
952
|
+
readonly externals: ExternalResolver;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
export interface RouterVersionDetail {
|
|
956
|
+
readonly routerId: string;
|
|
957
|
+
readonly data: string;
|
|
958
|
+
readonly document: string;
|
|
959
|
+
/**
|
|
960
|
+
* Everything the data version is a hash of, in hashed order: `file
|
|
961
|
+
* <identity>` for a server file, `static <id>` for a Static payload,
|
|
962
|
+
* `client-urls <module>` for a clientUrls() projection, `server-css
|
|
963
|
+
* <module>` for a server component's stylesheets, `external <import>` for
|
|
964
|
+
* what the bundle left external, `encryption-key` for the key. Two builds'
|
|
965
|
+
* lists differ exactly where the version's inputs differ.
|
|
966
|
+
*/
|
|
967
|
+
readonly dataInputs: readonly VersionInput[];
|
|
968
|
+
/**
|
|
969
|
+
* What the document version adds to the data version: `ssr-and-client` (the
|
|
970
|
+
* SSR output and the client asset names) and `prerender <key>` for a
|
|
971
|
+
* Prerender payload.
|
|
972
|
+
*/
|
|
973
|
+
readonly documentInputs: readonly VersionInput[];
|
|
974
|
+
/** The key is part of the router's data version. */
|
|
975
|
+
readonly usesEncryptionKey: boolean;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* What stored HTML depends on besides a router's server code, one set for the
|
|
980
|
+
* build (the SSR output is not split by router). Hashed as the
|
|
981
|
+
* `ssr-and-client` input of every document version.
|
|
982
|
+
*/
|
|
983
|
+
export interface SsrAndClientInputs {
|
|
984
|
+
/** Vite's `base`, a prefix of every asset URL in stored HTML. */
|
|
985
|
+
readonly base: string;
|
|
986
|
+
/**
|
|
987
|
+
* The SSR output, file by file ({@link digestBundle}). It renders the HTML,
|
|
988
|
+
* and its own code can change without the client's.
|
|
989
|
+
*/
|
|
990
|
+
readonly ssr: readonly VersionInput[];
|
|
991
|
+
/** What the SSR output leaves external ({@link classifyExternal}). */
|
|
992
|
+
readonly externals: readonly VersionInput[];
|
|
993
|
+
/**
|
|
994
|
+
* The client asset names. A prelude and an open tab both hold them, and the
|
|
995
|
+
* names carry the bundler's content hashes.
|
|
996
|
+
*/
|
|
997
|
+
readonly client: readonly string[];
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
export interface ComputedRouterVersions {
|
|
1001
|
+
readonly table: RouterVersionsTable;
|
|
1002
|
+
/** One entry per attributed router, then the whole-build entry. */
|
|
1003
|
+
readonly details: readonly RouterVersionDetail[];
|
|
1004
|
+
/** What every document version covers besides its router's data version. */
|
|
1005
|
+
readonly ssrAndClient: SsrAndClientInputs;
|
|
1006
|
+
/** Server files no attributed router owns, by identity. */
|
|
1007
|
+
readonly unownedFiles: readonly string[];
|
|
1008
|
+
/** Imports the build could not determine ({@link classifyExternal}). */
|
|
1009
|
+
readonly undetermined: readonly string[];
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/** Length of a version string: 64 bits of the digest, in hex. */
|
|
1013
|
+
const VERSION_HEX_LENGTH = 16;
|
|
1014
|
+
|
|
1015
|
+
/** By name, then digest: a total order, whatever order the bundle listed. */
|
|
1016
|
+
function byName(a: VersionInput, b: VersionInput): number {
|
|
1017
|
+
return compareStrings(a[0], b[0]) || compareStrings(a[1], b[1]);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/** `inputs` in hashed order, each one once (two chunks can import one package). */
|
|
1021
|
+
function distinct(inputs: readonly VersionInput[]): VersionInput[] {
|
|
1022
|
+
const sorted = [...inputs].sort(byName);
|
|
1023
|
+
return sorted.filter(
|
|
1024
|
+
(item, index) => index === 0 || byName(item, sorted[index - 1]!) !== 0,
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function hashInputs(label: string, inputs: readonly VersionInput[]): string {
|
|
1029
|
+
// JSON keeps names and digests apart whatever characters a name holds (a
|
|
1030
|
+
// prerender key is a URL path).
|
|
1031
|
+
return sha256(`${label}\n${JSON.stringify(inputs)}`).slice(
|
|
1032
|
+
0,
|
|
1033
|
+
VERSION_HEX_LENGTH,
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/** A version input that is not a file: owned by {@link ownersOf}. */
|
|
1038
|
+
interface OwnedInput {
|
|
1039
|
+
readonly input: VersionInput;
|
|
1040
|
+
readonly version: "data" | "document";
|
|
1041
|
+
/** Owning routers; `undefined`: every router. */
|
|
1042
|
+
readonly owners: ReadonlySet<string> | undefined;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* The records for a build's `serverResources`. The links of an entry are
|
|
1047
|
+
* rendered by plugin-rsc's CSS module for it
|
|
1048
|
+
* (`virtual:vite-rsc/css?type=rsc&id=<module the entry is keyed by>`), which
|
|
1049
|
+
* is what a router has to reach. With an explicit
|
|
1050
|
+
* `import.meta.viteRsc.loadCss("./other")` that is not the keyed module.
|
|
1051
|
+
*/
|
|
1052
|
+
function serverCssRecords(
|
|
1053
|
+
server: ServerBuildGraph,
|
|
1054
|
+
projectRoot: string,
|
|
1055
|
+
serverResources: ReadonlyMap<string, string>,
|
|
1056
|
+
): BuildDataRecord[] {
|
|
1057
|
+
if (serverResources.size === 0) return [];
|
|
1058
|
+
const moduleOf = new Map<string, string>();
|
|
1059
|
+
for (const id of server.modules.keys()) {
|
|
1060
|
+
const key = portableModuleId(id, projectRoot).match(SERVER_CSS_MODULE)?.[1];
|
|
1061
|
+
if (key !== undefined) moduleOf.set(key, id);
|
|
1062
|
+
}
|
|
1063
|
+
return [...serverResources].map(([key, entry]) => ({
|
|
1064
|
+
kind: "server-css",
|
|
1065
|
+
key,
|
|
1066
|
+
digest: sha256(entry),
|
|
1067
|
+
moduleId: moduleOf.get(key),
|
|
1068
|
+
}));
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* Compute every router's data and document version, and the whole-build pair.
|
|
1073
|
+
*
|
|
1074
|
+
* A router's data version covers: its chunk files, the bundle assets those
|
|
1075
|
+
* files name (a route manifest staged as a text module), what those files
|
|
1076
|
+
* leave external, and the inputs it owns that no chunk holds
|
|
1077
|
+
* ({@link ownersOf}). Its document version is the data version plus the SSR
|
|
1078
|
+
* output, the client asset names, `base` and its Prerender payloads.
|
|
1079
|
+
*/
|
|
1080
|
+
export function computeRouterVersions(
|
|
1081
|
+
input: ComputeRouterVersionsInput,
|
|
1082
|
+
): ComputedRouterVersions {
|
|
1083
|
+
const { server, projectRoot } = input;
|
|
1084
|
+
const undetermined = new Set<string>();
|
|
1085
|
+
|
|
1086
|
+
const files = digestBundle(server, projectRoot, input.readServerFile, "RSC");
|
|
1087
|
+
const identityOf = new Map(
|
|
1088
|
+
files.map((file) => [file.fileName, file.identity]),
|
|
1089
|
+
);
|
|
1090
|
+
const assetFiles = new Set(server.assets.map((asset) => asset.fileName));
|
|
1091
|
+
const externalContext: ExternalContext = {
|
|
1092
|
+
projectRoot,
|
|
1093
|
+
externals: input.externals,
|
|
1094
|
+
read: input.readServerFile,
|
|
1095
|
+
undetermined,
|
|
1096
|
+
};
|
|
1097
|
+
const externalsOf = new Map(
|
|
1098
|
+
files.map((file) => [file.fileName, externalInputs(file, externalContext)]),
|
|
1099
|
+
);
|
|
1100
|
+
|
|
1101
|
+
const members = resolveRouterMembers(server, input.client, input.routers);
|
|
1102
|
+
const reachOf = new Map(
|
|
1103
|
+
[...members].map(([routerId, member]) => [routerId, member.modules]),
|
|
1104
|
+
);
|
|
1105
|
+
|
|
1106
|
+
const owned = [
|
|
1107
|
+
...input.buildData,
|
|
1108
|
+
...serverCssRecords(
|
|
1109
|
+
server,
|
|
1110
|
+
projectRoot,
|
|
1111
|
+
input.serverResources ?? new Map(),
|
|
1112
|
+
),
|
|
1113
|
+
].map(
|
|
1114
|
+
(record): OwnedInput => ({
|
|
1115
|
+
input: [`${record.kind} ${record.key}`, record.digest],
|
|
1116
|
+
version: record.kind === "prerender" ? "document" : "data",
|
|
1117
|
+
owners:
|
|
1118
|
+
record.routerId !== undefined
|
|
1119
|
+
? new Set([record.routerId])
|
|
1120
|
+
: ownersOf(
|
|
1121
|
+
record.moduleId === undefined ? [] : [record.moduleId],
|
|
1122
|
+
reachOf,
|
|
1123
|
+
),
|
|
1124
|
+
}),
|
|
1125
|
+
);
|
|
1126
|
+
|
|
1127
|
+
// The key belongs to the routers whose code encrypts with it. A key file
|
|
1128
|
+
// with no module recognised as encrypting means the recognition failed
|
|
1129
|
+
// (the file is written only when a chunk reads the key): every router's.
|
|
1130
|
+
const encrypting = [...server.modules]
|
|
1131
|
+
.filter(([, edges]) => edges.encryptsBoundArgs)
|
|
1132
|
+
.map(([id]) => id);
|
|
1133
|
+
const keyFile = input.readServerFile(ENCRYPTION_KEY_FILE);
|
|
1134
|
+
if (keyFile !== undefined) {
|
|
1135
|
+
owned.push({
|
|
1136
|
+
input: ["encryption-key", sha256(keyFile)],
|
|
1137
|
+
version: "data",
|
|
1138
|
+
owners: ownersOf(encrypting, reachOf),
|
|
1139
|
+
});
|
|
1140
|
+
} else if (encrypting.length > 0) {
|
|
1141
|
+
throw new Error(
|
|
1142
|
+
`${encrypting.length} server module(s) encrypt action arguments ` +
|
|
1143
|
+
`(${portableModuleId(encrypting[0]!, projectRoot)}), but ${ENCRYPTION_KEY_FILE} is not in the RSC ` +
|
|
1144
|
+
`output directory, so the key cannot be made part of their cache version. The installed ` +
|
|
1145
|
+
`@vitejs/plugin-rsc keeps the key somewhere @rangojs/router does not know. Report it ` +
|
|
1146
|
+
`with the plugin-rsc version.`,
|
|
1147
|
+
);
|
|
1148
|
+
}
|
|
1149
|
+
const keyInput = owned.find((item) => item.input[0] === "encryption-key");
|
|
1150
|
+
|
|
1151
|
+
// What stored HTML depends on besides a router's server code. One digest
|
|
1152
|
+
// for the build: the SSR output is not split by router.
|
|
1153
|
+
const ssrFiles = digestBundle(
|
|
1154
|
+
input.ssr,
|
|
1155
|
+
projectRoot,
|
|
1156
|
+
input.readSsrFile,
|
|
1157
|
+
"SSR",
|
|
1158
|
+
);
|
|
1159
|
+
const ssrIdentityOf = new Map(
|
|
1160
|
+
ssrFiles.map((file) => [file.fileName, file.identity]),
|
|
1161
|
+
);
|
|
1162
|
+
const everySsrFile = new Set(ssrFiles.map((file) => file.fileName));
|
|
1163
|
+
const ssrAndClient: SsrAndClientInputs = {
|
|
1164
|
+
base: input.base,
|
|
1165
|
+
ssr: distinct(
|
|
1166
|
+
ssrFiles.map((file) => [
|
|
1167
|
+
file.identity,
|
|
1168
|
+
digestAsOwned(file, ssrIdentityOf, everySsrFile),
|
|
1169
|
+
]),
|
|
1170
|
+
),
|
|
1171
|
+
externals: distinct(
|
|
1172
|
+
ssrFiles.flatMap((file) =>
|
|
1173
|
+
externalInputs(file, { ...externalContext, read: input.readSsrFile }),
|
|
1174
|
+
),
|
|
1175
|
+
),
|
|
1176
|
+
client: [...input.client.fileNames].sort(),
|
|
1177
|
+
};
|
|
1178
|
+
const documentDigest = sha256(JSON.stringify(ssrAndClient));
|
|
1179
|
+
|
|
1180
|
+
const table: Record<string, readonly [string, string]> = {};
|
|
1181
|
+
const details: RouterVersionDetail[] = [];
|
|
1182
|
+
const ownedByARouter = new Set<string>();
|
|
1183
|
+
/** `chunkFiles`: the router's, or `undefined` for the whole build. */
|
|
1184
|
+
const add = (routerId: string, chunkFiles?: ReadonlySet<string>): void => {
|
|
1185
|
+
const whole = chunkFiles === undefined;
|
|
1186
|
+
const ownedFiles = new Set(
|
|
1187
|
+
chunkFiles ?? files.map((file) => file.fileName),
|
|
1188
|
+
);
|
|
1189
|
+
for (const file of files) {
|
|
1190
|
+
if (!ownedFiles.has(file.fileName)) continue;
|
|
1191
|
+
for (const name of file.references) {
|
|
1192
|
+
if (assetFiles.has(name)) ownedFiles.add(name);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
const inputs: Record<"data" | "document", VersionInput[]> = {
|
|
1197
|
+
data: [],
|
|
1198
|
+
document: [["ssr-and-client", documentDigest]],
|
|
1199
|
+
};
|
|
1200
|
+
for (const file of files) {
|
|
1201
|
+
if (!ownedFiles.has(file.fileName)) continue;
|
|
1202
|
+
if (!whole) ownedByARouter.add(file.fileName);
|
|
1203
|
+
inputs.data.push(
|
|
1204
|
+
[`file ${file.identity}`, digestAsOwned(file, identityOf, ownedFiles)],
|
|
1205
|
+
...externalsOf.get(file.fileName)!,
|
|
1206
|
+
);
|
|
1207
|
+
}
|
|
1208
|
+
for (const item of owned) {
|
|
1209
|
+
if (whole || item.owners === undefined || item.owners.has(routerId)) {
|
|
1210
|
+
inputs[item.version].push(item.input);
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
const dataInputs = distinct(inputs.data);
|
|
1214
|
+
const documentInputs = distinct(inputs.document);
|
|
1215
|
+
|
|
1216
|
+
const data = hashInputs("rango-data-version", dataInputs);
|
|
1217
|
+
const document = hashInputs(
|
|
1218
|
+
`rango-document-version\n${data}`,
|
|
1219
|
+
documentInputs,
|
|
1220
|
+
);
|
|
1221
|
+
table[routerId] = [data, document];
|
|
1222
|
+
details.push({
|
|
1223
|
+
routerId,
|
|
1224
|
+
data,
|
|
1225
|
+
document,
|
|
1226
|
+
dataInputs,
|
|
1227
|
+
documentInputs,
|
|
1228
|
+
usesEncryptionKey:
|
|
1229
|
+
keyInput !== undefined &&
|
|
1230
|
+
(whole ||
|
|
1231
|
+
keyInput.owners === undefined ||
|
|
1232
|
+
keyInput.owners.has(routerId)),
|
|
1233
|
+
});
|
|
1234
|
+
};
|
|
1235
|
+
|
|
1236
|
+
for (const router of input.routers) {
|
|
1237
|
+
const member = members.get(router.id);
|
|
1238
|
+
if (member) add(router.id, member.chunkFiles);
|
|
1239
|
+
}
|
|
1240
|
+
add(DEFAULT_ROUTER_VERSIONS_KEY);
|
|
1241
|
+
return {
|
|
1242
|
+
table,
|
|
1243
|
+
details,
|
|
1244
|
+
ssrAndClient,
|
|
1245
|
+
unownedFiles: files
|
|
1246
|
+
.filter((file) => !ownedByARouter.has(file.fileName))
|
|
1247
|
+
.map((file) => file.identity)
|
|
1248
|
+
.sort(),
|
|
1249
|
+
undetermined: [...undetermined].sort(),
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/**
|
|
1254
|
+
* The chunk files of a bundle that hold the `@rangojs/router:version` module,
|
|
1255
|
+
* where the placeholder has to be replaced. Recorded for every environment:
|
|
1256
|
+
* the module is emitted wherever something imports it (a second worker that
|
|
1257
|
+
* uses a cache store), and an unreplaced placeholder throws on load.
|
|
1258
|
+
*/
|
|
1259
|
+
export function recordVersionModuleFiles(bundle: OutputBundleLike): string[] {
|
|
1260
|
+
return Object.values(bundle)
|
|
1261
|
+
.filter(
|
|
1262
|
+
(file) =>
|
|
1263
|
+
file.type === "chunk" && file.moduleIds?.includes(VERSION_MODULE),
|
|
1264
|
+
)
|
|
1265
|
+
.map((file) => file.fileName);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
/**
|
|
1269
|
+
* Replace the version module's placeholder with the table. Returns undefined
|
|
1270
|
+
* when `source` does not hold the placeholder.
|
|
1271
|
+
*/
|
|
1272
|
+
export function fillRouterVersions(
|
|
1273
|
+
source: string,
|
|
1274
|
+
table: RouterVersionsTable,
|
|
1275
|
+
): string | undefined {
|
|
1276
|
+
if (!source.includes(ROUTER_VERSIONS_PLACEHOLDER)) return undefined;
|
|
1277
|
+
// A replacer function: a consumer's router id may hold `$`, which a string
|
|
1278
|
+
// replacement would read as a pattern (`$$`, `$'`).
|
|
1279
|
+
const json = JSON.stringify(table);
|
|
1280
|
+
return source.replaceAll(ROUTER_VERSIONS_PLACEHOLDER, () => json);
|
|
1281
|
+
}
|