1import type { NonEmptyArray } from "effect/Array";
2import * as Cause from "effect/Cause";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6import type { PlatformError } from "effect/PlatformError";
7import type * as vite from "vite";
8import { cachedFunction } from "../Util/cached-function.ts";
9import { sha256 } from "../Util/index.ts";
10import {
11 BundleError,
12 bundleErrorFromUnknown,
13 bundleOutputFromFiles,
14 type BundleFile,
15 type BundleOutput,
16} from "./Bundle.ts";
17
18export interface ViteBuildOutput {
19 readonly clientDirectory: string | undefined;
20 /**
21 * The client environment's resolved Vite `base`. The build rewrites
22 * every emitted asset URL with it, so the uploaded asset manifest must
23 * be keyed with the same prefix to agree with the HTML.
24 */
25 readonly base: string | undefined;
26 // This is emitted as an Effect instead of a value so we can process it in parallel with reading the client assets.
27 readonly serverBundle: Effect.Effect<BundleOutput | undefined, BundleError>;
28 readonly externalWorkspaces: Effect.Effect<Set<string>, PlatformError>;
29}
30
31// `@vitejs/plugin-rsc` writes these modules separately after build completes instead of emitting them as chunks.
32// So, we need to detect them and read them from the file system manually.
33const RSC_MANIFEST = {
34 "virtual:vite-rsc/assets-manifest": "__vite_rsc_assets_manifest.js",
35 "virtual:vite-rsc/environment-imports": "__vite_rsc_env_imports_manifest.js",
36} as const;
37type RscManifestId = keyof typeof RSC_MANIFEST;
38
39/**
40 * Structural subset of `vite.Environment` used by the bundling helpers.
41 * Typed structurally rather than as `vite.Environment` so plugin hooks whose
42 * `this` context is typed against a different vite copy in the install graph
43 * (bun peer-variant duplication) still assign.
44 */
45interface EnvironmentLike {
46 readonly name: string;
47 readonly config: {
48 readonly base: string;
49 readonly root: string;
50 readonly build: { readonly outDir: string };
51 };
52}
53
54/**
55 * A Vite plugin that collects the output of the build and makes it available as an Effect.
56 * @param entryEnvironment - The environment to use as the entry point for the server bundle. Defaults to "ssr".
57 */
58export const viteBuildOutputPlugin = Effect.fn(function* ({
59 entryEnvironment = "ssr",
60}: {
61 entryEnvironment?: string;
62}) {
63 const fs = yield* FileSystem.FileSystem;
64 const path = yield* Path.Path;
65 let clientDirectory: string | undefined;
66 let base: string | undefined;
67 let serverEntry: string | undefined;
68 const serverChunks = new Map<
69 string,
70 Effect.Effect<BundleFile, BundleError>
71 >();
72 const maybeExternalWorkspaces = new Set<string>();
73
74 const findUp = yield* cachedFunction(
75 (
76 dir: string,
77 filenames: Array<string>,
78 ): Effect.Effect<string | undefined, PlatformError> =>
79 Effect.filter(
80 filenames.map((filename) => path.join(dir, filename)),
81 fs.exists,
82 { concurrency: "unbounded" },
83 ).pipe(
84 Effect.flatMap(([match]) => {
85 if (match) {
86 return Effect.succeed(match);
87 }
88 const parent = path.dirname(dir);
89 if (parent === dir) {
90 return Effect.undefined;
91 }
92 return findUp(parent, filenames);
93 }),
94 ),
95 );
96 const collectExternalWorkspaces = (): Effect.Effect<
97 Set<string>,
98 PlatformError
99 > =>
100 Effect.forEach(maybeExternalWorkspaces, (directory) =>
101 findUp(directory, ["package.json"]),
102 ).pipe(
103 Effect.map(
104 (paths) =>
105 new Set(paths.filter((file) => file !== undefined).map(path.dirname)),
106 ),
107 );
108 const plugin: vite.Plugin = {
109 name: "alchemy:build-output",
110 sharedDuringBuild: true,
111 // Collect the client output directory and server chunks as each
112 // environment writes its bundle. We deliberately do NOT resolve the build
113 // result from a `buildApp` hook: on Vite 8, when a project declares no
114 // `builder.buildApp` (e.g. a plain client-only SPA), `builder.buildApp()`
115 // runs post-order `buildApp` hooks *before* the default environment builds,
116 // so a hook fires while the output is still empty (issue #792). Instead,
117 // `viteBuild` reads `output` *after* `builder.buildApp()` resolves — by
118 // which point every environment that actually built has run `writeBundle`.
119 async writeBundle(_, bundle) {
120 const root = path.resolve(this.environment.config.root);
121 for (const id of this.getModuleIds()) {
122 if (
123 !path.isAbsolute(id) ||
124 id.includes("node_modules") ||
125 id.startsWith(root)
126 ) {
127 continue;
128 }
129 maybeExternalWorkspaces.add(path.dirname(id));
130 }
131 if (this.environment.name === "client") {
132 clientDirectory = path.resolve(
133 root,
134 this.environment.config.build.outDir,
135 );
136 base = this.environment.config.base;
137 return;
138 }
139 const files = Object.values(bundle);
140 if (this.environment.name === entryEnvironment) {
141 const entryChunk = files.find(
142 (file) => file.type === "chunk" && file.isEntry,
143 );
144 if (!entryChunk) {
145 throw new Error(
146 `Entry chunk not found for environment "${this.environment.name}"`,
147 );
148 }
149 serverEntry = fileName(entryChunk.fileName, this.environment);
150 }
151 await Promise.all(
152 files.map(async (file) => {
153 if (file.type === "chunk") {
154 file.imports
155 .filter(
156 (self): self is keyof typeof RSC_MANIFEST =>
157 self in RSC_MANIFEST,
158 )
159 .forEach((id) => {
160 // Key by the environment-prefixed path, NOT the bare manifest
161 // filename. `@vitejs/plugin-rsc` emits a copy of the same-named
162 // manifest into every environment's outDir (e.g. both
163 // `dist/rsc/__vite_rsc_assets_manifest.js` and
164 // `dist/ssr/__vite_rsc_assets_manifest.js`), and each
165 // environment's chunks import their own copy via a relative
166 // specifier. Keying by the bare filename collapses them into a
167 // single entry, dropping every copy but the last — so the
168 // entry worker's `import "../__vite_rsc_assets_manifest.js"`
169 // resolves to a module that isn't in the bundle and the
170 // deployed Worker fails at startup.
171 serverChunks.set(
172 fileName(RSC_MANIFEST[id], this.environment),
173 readRscManifestChunk(id, this.environment),
174 );
175 });
176 }
177 const name = fileName(file.fileName, this.environment);
178 const content = file.type === "chunk" ? file.code : file.source;
179 serverChunks.set(
180 name,
181 sha256(content).pipe(
182 Effect.map((hash) => ({ path: name, content, hash })),
183 ),
184 );
185 }),
186 );
187 },
188 };
189
190 // The server bundle may include chunks from more than one Vite environment, so we need to prefix them with the environment-specific output directory.
191 // Flattening them doesn't work because of relative imports between environments.
192 //
193 // `build.outDir` can be resolved to an absolute path (e.g. `@vitejs/plugin-rsc`
194 // does this), which would leak the build machine's filesystem path into the
195 // worker module names and produce non-portable, leading-`/` specifiers that
196 // Cloudflare rejects. Normalize it back to a path relative to the project root
197 // so module names match the single-environment case (`dist/ssr/worker.js`).
198 const fileName = (name: string, environment: EnvironmentLike) => {
199 const outDir = environment.config.build.outDir;
200 const relativeOutDir = path.isAbsolute(outDir)
201 ? path.relative(environment.config.root, outDir)
202 : outDir;
203 // `path.relative` (and user-configured outDirs) yield backslashes on
204 // Windows; worker module names are import specifiers, so a name like
205 // `dist\ssr/index.js` deploys fine but can never be resolved by the
206 // entry's `import "ssr/index.js"` — the worker 1101s at request time.
207 return `${relativeOutDir.replaceAll("\\", "/")}/${name}`;
208 };
209
210 // Manually read the RSC manifest chunk from the file system.
211 // This is only safe to run *after* the build has completed.
212 const readRscManifestChunk = (
213 id: RscManifestId,
214 environment: EnvironmentLike,
215 ) => {
216 const name = RSC_MANIFEST[id];
217 return fs
218 .readFile(
219 path.resolve(
220 environment.config.root,
221 environment.config.build.outDir,
222 name,
223 ),
224 )
225 .pipe(
226 Effect.flatMap((content) =>
227 sha256(content).pipe(
228 Effect.map((hash): BundleFile => ({
229 path: fileName(name, environment),
230 content,
231 hash,
232 })),
233 ),
234 ),
235 Effect.mapError(bundleErrorFromUnknown),
236 );
237 };
238
239 const makeServerBundle = () => {
240 if (!serverEntry && !serverChunks.size) return Effect.undefined;
241 if (!serverEntry)
242 return Effect.die(new Cause.NoSuchElementError("Missing server entry"));
243 const filePaths = Array.from(serverChunks.keys()).sort((a, b) =>
244 a.localeCompare(b),
245 );
246 const server: NonEmptyArray<Effect.Effect<BundleFile, BundleError>> = [
247 getChunk(serverEntry),
248 ];
249 for (const filePath of filePaths) {
250 if (filePath === serverEntry) continue;
251 server.push(getChunk(filePath));
252 }
253 return Effect.all(server, { concurrency: "unbounded" }).pipe(
254 Effect.flatMap(bundleOutputFromFiles),
255 );
256 };
257
258 const getChunk = (path: string) => {
259 const chunk = serverChunks.get(path);
260 if (!chunk) {
261 return Effect.die(
262 new Cause.NoSuchElementError(`Chunk ${path} not found`),
263 );
264 }
265 return chunk;
266 };
267
268 return {
269 plugin,
270 // Read lazily so callers observe the state collected during the build.
271 // Only safe to await *after* `builder.buildApp()` has resolved.
272 output: Effect.sync((): ViteBuildOutput => ({
273 clientDirectory,
274 base,
275 serverBundle: makeServerBundle(),
276 externalWorkspaces: collectExternalWorkspaces(),
277 })),
278 };
279});