| 1 | import type { NonEmptyArray } from "effect/Array"; |
| 2 | import * as Cause from "effect/Cause"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Path from "effect/Path"; |
| 6 | import type { PlatformError } from "effect/PlatformError"; |
| 7 | import type * as vite from "vite"; |
| 8 | import { cachedFunction } from "../Util/cached-function.ts"; |
| 9 | import { sha256 } from "../Util/index.ts"; |
| 10 | import { |
| 11 | BundleError, |
| 12 | bundleErrorFromUnknown, |
| 13 | bundleOutputFromFiles, |
| 14 | type BundleFile, |
| 15 | type BundleOutput, |
| 16 | } from "./Bundle.ts"; |
| 17 | |
| 18 | export 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. |
| 33 | const 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; |
| 37 | type 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 | */ |
| 45 | interface 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 | */ |
| 58 | export 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 | }); |