1import cloudflare, {
2 type CloudflareVitePluginOptions,
3} from "@alchemy.run/cloudflare-runtime/vite";
4import * as ConsoleService from "effect/Console";
5import * as Effect from "effect/Effect";
6import * as Path from "effect/Path";
7import * as Redacted from "effect/Redacted";
8import { createRequire } from "node:module";
9import nodePath from "node:path";
10import { pathToFileURL } from "node:url";
11import type * as vite from "vite";
12import {
13 viteBuildOutputPlugin,
14 type ViteBuildOutput,
15} from "../../../Bundle/Vite.ts";
16import { viteSupportsPortZero } from "@alchemy.run/cloudflare-runtime/core/internal/Port";
17import {
18 makeResourceLogger,
19 makeResourceOutput,
20} from "../../../Util/ResourceOutput.ts";
21import { hashDirectory, type MemoOptions } from "../../../Command/Memo.ts";
22import { findAvailablePort, initialCwd } from "../../../Util/Node.ts";
23import { sha256Object } from "../../../Util/sha256.ts";
24import { readAssets } from "../Assets.ts";
25import type { SourceDevHandle, SourceProvider } from "../Source.ts";
26import { runViteBuildChild } from "../ViteChild.ts";
27import type { ViteOptions } from "../Worker.ts";
28import { isWorkerLoader } from "../WorkerLoader.ts";
29
30/**
31 * This module statically imports `@alchemy.run/cloudflare-runtime/vite`
32 * (~0.5s to load), which is only needed for vite-based workers. Importers
33 * MUST load it lazily (`Effect.promise(() => import("./Sources/Vite.ts"))`
34 * from the dispatch in `Source.ts`, or the legacy vite arms in the
35 * Worker providers) so the module cost is only paid when a vite worker
36 * is actually built, hashed, or served.
37 */
38
39/**
40 * Route Vite's logger through the ambient Effect `Console` service instead of
41 * its default stdout logger. Under the CLI this is the global console
42 * (identical output); under environments that override the Console — e.g.
43 * alchemy-test's per-test buffering console — the build output is captured
44 * with the test instead of leaking to the terminal.
45 */
46const makeViteLogger = (console: ConsoleService.Console): vite.Logger => {
47 const loggedErrors = new WeakSet<object>();
48 let hasWarned = false;
49 return {
50 info: (msg) => console.log(msg),
51 warn: (msg) => {
52 hasWarned = true;
53 console.warn(msg);
54 },
55 warnOnce: (msg) => {
56 hasWarned = true;
57 console.warn(msg);
58 },
59 error: (msg, options) => {
60 if (options?.error != null) loggedErrors.add(options.error);
61 console.error(msg);
62 },
63 clearScreen: () => {},
64 hasErrorLogged: (error) => loggedErrors.has(error),
65 get hasWarned() {
66 return hasWarned;
67 },
68 };
69};
70
71/**
72 * Signals to the app's own Vite config that Alchemy is injecting its
73 * resource-aware Cloudflare plugin into this build/dev run.
74 *
75 * Apps that also build standalone (plain `vite build` in CI, no Alchemy)
76 * need the Cloudflare plugin in their `vite.config.ts`. Without a guard,
77 * an Alchemy-orchestrated run instantiates that config-file instance
78 * *alongside* the injected one: two same-named plugin stacks whose
79 * cross-plugin API lookups resolve by name, and — in dev — two workerd
80 * runtimes, only one of which carries the Worker's bindings. Guarding on
81 * this variable lets the config-file instance stand down:
82 *
83 * ```ts
84 * // vite.config.ts
85 * process.env.ALCHEMY_CLOUDFLARE_VITE_INJECTED === "1"
86 * ? null
87 * : cloudflare({ ... })
88 * ```
89 *
90 * The variable is set process-locally by `viteDev`/`viteBuild`, so it is
91 * correct regardless of which process hosts Vite (`alchemy dev` runs the
92 * dev server in the spawned local-provider host, not in the process that
93 * evaluates the user's alchemy.run.ts — an env variable set there never
94 * reaches the config).
95 *
96 * Contract: the value is `"1"` while the process is Alchemy-orchestrated;
97 * absence means not injected. It is deliberately never unset — Vite
98 * re-evaluates the app config on dev-server restarts long after
99 * `viteDev` returned, and concurrent `viteBuild`s in one process would
100 * race a save/restore. A process that ran an Alchemy build never also
101 * runs a standalone (non-Alchemy) Vite build, so the flag staying set is
102 * correct for the process lifetime.
103 */
104const ALCHEMY_CLOUDFLARE_VITE_INJECTED = "ALCHEMY_CLOUDFLARE_VITE_INJECTED";
105
106export const viteDev = (
107 rootDir: string = initialCwd,
108 env: Record<string, unknown>,
109 pluginOptions: CloudflareVitePluginOptions,
110 serverOptions: vite.ServerOptions,
111) =>
112 Effect.gen(function* () {
113 yield* Effect.sync(() => {
114 process.env[ALCHEMY_CLOUDFLARE_VITE_INJECTED] = "1";
115 });
116 const vite = yield* Effect.promise(() => loadVite(rootDir));
117 // `port: 0` is a true OS-assigned random port on Vite >= 8.2.1
118 // (vitejs/vite#23158); older Vite treats it as "no port given" and
119 // hunts upward from the 5173 default — colliding with (or
120 // IPv6-shadowing) user-facing dev ports. Substitute a probed
121 // ephemeral port there; `strictPort: false` handles the small
122 // probe→bind race by moving to the next ephemeral port.
123 const server =
124 serverOptions.port === 0 && !viteSupportsPortZero(vite.version)
125 ? {
126 ...serverOptions,
127 port: yield* findAvailablePort(
128 typeof serverOptions.host === "string"
129 ? serverOptions.host
130 : undefined,
131 ).pipe(Effect.orDie),
132 }
133 : serverOptions;
134 return yield* Effect.acquireRelease(
135 ConsoleService.consoleWith((console) =>
136 Effect.promise(async () => {
137 const devServer = await vite.createServer({
138 root: rootDir,
139 define: getDefine(env),
140 plugins: [cloudflare(pluginOptions)],
141 server,
142 customLogger: makeViteLogger(console),
143 });
144 await devServer.listen();
145 return devServer;
146 }),
147 ),
148 (devServer) =>
149 Effect.promise(async () => {
150 await devServer.close();
151 }),
152 );
153 });
154
155/**
156 * Run a production Vite build in a child process rooted at the project
157 * directory and adapt the result to the in-process {@link ViteBuildOutput}
158 * shape.
159 *
160 * The child boundary is what makes concurrent builds safe: vite resolves a
161 * relative root against live `process.cwd()`, plugins read cwd freely, and
162 * build-time spawns chdir the hosting process transiently (cross-spawn's
163 * PATH resolution) — so an in-process build both breaks under and causes
164 * cwd races when the engine runs builds concurrently.
165 */
166export const viteBuild = (
167 rootDir: string = initialCwd,
168 env: Record<string, unknown>,
169 pluginOptions: CloudflareVitePluginOptions,
170 fqn: string,
171) =>
172 Effect.gen(function* () {
173 const logResourceOutput = makeResourceLogger(fqn);
174 const result = yield* runViteBuildChild(
175 {
176 // Anchor to the initial cwd so the resolution itself can't race a
177 // transient chdir; the child's own cwd is this resolved root.
178 rootDir: nodePath.resolve(initialCwd, rootDir),
179 // Only `VITE_`-prefixed entries participate in the build (see
180 // `getDefine`); the rest may hold non-serializable values.
181 env: Object.fromEntries(
182 Object.entries(env).filter(([key]) => key.startsWith("VITE_")),
183 ),
184 main: pluginOptions.main,
185 compatibilityDate: pluginOptions.compatibilityDate,
186 compatibilityFlags: pluginOptions.compatibilityFlags,
187 viteEnvironments: pluginOptions.viteEnvironments,
188 },
189 logResourceOutput,
190 );
191 return {
192 clientDirectory: result.clientDirectory,
193 base: result.base,
194 serverBundle: Effect.succeed(result.serverBundle),
195 externalWorkspaces: Effect.succeed(new Set(result.externalWorkspaces)),
196 } satisfies ViteBuildOutput;
197 });
198
199/**
200 * The in-process build implementation. ONLY safe inside the dedicated
201 * build child (`ViteBuildChildRunner.ts`), whose cwd is the project root
202 * and which hosts no concurrent work — never call it from the engine
203 * process (see {@link viteBuild}).
204 */
205export const viteBuildInProcess = (
206 rootDir: string,
207 env: Record<string, unknown>,
208 pluginOptions: CloudflareVitePluginOptions,
209) =>
210 Effect.gen(function* () {
211 const outputPlugin = yield* viteBuildOutputPlugin({
212 entryEnvironment: pluginOptions.viteEnvironments?.entry ?? "ssr",
213 });
214 const console = yield* ConsoleService.Console;
215 yield* Effect.promise(async () => {
216 process.env[ALCHEMY_CLOUDFLARE_VITE_INJECTED] = "1";
217 const vite = await loadVite(rootDir);
218 const builder = await vite.createBuilder(
219 {
220 root: rootDir,
221 define: getDefine(env),
222 plugins: [cloudflare(pluginOptions), outputPlugin.plugin],
223 customLogger: makeViteLogger(console),
224 // Disables the NATIVE rolldown progress reporter ("transforming…",
225 // "rendering chunks…", "computing gzip size…"): it prints from
226 // Rust straight to fd 1 and cannot be intercepted from JS — vite
227 // only enables it when logLevel >= info. Info-level build
228 // summaries are suppressed with it; warnings and errors still
229 // reach the customLogger above.
230 logLevel: "warn",
231 },
232 // This is the `useLegacyBuilder` option. The Vite CLI implementation uses `null` here.
233 // Originally we used `undefined` here, but this caused the static site build to fail.
234 // https://github.com/vitejs/vite/blob/a07a4bd052ac75f916391c999c408ad5f2867e61/packages/vite/src/node/cli.ts#L367
235 null,
236 );
237 await builder.buildApp();
238 });
239 return yield* outputPlugin.output;
240 });
241
242// Emulate `vite build` env semantics for `props.env`: only
243// keys with Vite's default `VITE_` prefix are inlined into
244// the bundle as `import.meta.env.*`. `Redacted` values are
245// unwrapped — by prefixing with `VITE_` the user is opting
246// them into the public bundle.
247const getDefine = (env: Record<string, unknown>) =>
248 Object.fromEntries(
249 Object.entries(env).flatMap(([key, raw]) => {
250 if (!key.startsWith("VITE_")) return [];
251 const value = Redacted.isRedacted(raw) ? Redacted.value(raw) : raw;
252 return [[`import.meta.env.${key}`, JSON.stringify(value)] as const];
253 }),
254 );
255
256type ViteModule = typeof import("vite");
257
258/**
259 * Dynamically load Vite from the project root. Falls back to the bundled
260 * copy if the project doesn't have its own Vite installation.
261 */
262async function loadVite(projectRoot: string = initialCwd): Promise<ViteModule> {
263 try {
264 const require = createRequire(nodePath.join(projectRoot, "package.json"));
265 const vitePath = require.resolve("vite");
266 // On Windows, absolute paths must be file:// URLs for ESM import().
267 const viteUrl = pathToFileURL(vitePath);
268 return await import(/* @vite-ignore */ viteUrl.href);
269 } catch {
270 // Fallback: try to import vite from the global node_modules (works for non-linked installs)
271 // The fallback is a bare specifier and works as-is.
272 return await import("vite");
273 }
274}
275
276/**
277 * Resolve `props.env` to the literal values vite's `import.meta.env`
278 * defines are computed from: strings pass through, `Redacted<string>`s
279 * are unwrapped, env-bound Effects are evaluated, and `WorkerLoader`s
280 * (bindings that happen to be Effects) are skipped.
281 */
282const resolveViteEnv = (env: Record<string, unknown>) =>
283 Effect.gen(function* () {
284 return Object.fromEntries(
285 (yield* Effect.all(
286 Object.entries(env).map(
287 Effect.fn(function* ([key, value]) {
288 return [
289 key,
290 typeof value === "string"
291 ? value
292 : Redacted.isRedacted(value) &&
293 typeof Redacted.value(value) === "string"
294 ? Redacted.value(value)
295 : // A `WorkerLoader` is a real Effect that also carries
296 // the `~alchemy/Kind` marker — it is a binding, not a
297 // runnable env value. Check it before `Effect.isEffect`
298 // so we don't execute it as an inlined env entry.
299 isWorkerLoader(value)
300 ? undefined
301 : Effect.isEffect(value)
302 ? yield* value as any as Effect.Effect<any>
303 : undefined,
304 ];
305 }),
306 ),
307 )).filter(([_, value]) => value !== undefined),
308 );
309 });
310
311/**
312 * Hash the vite project's input tree (root + workspaces + lockfiles) —
313 * the rebuild-free change signal for the `input` hash slot.
314 */
315export const hashViteInput = Effect.fn(function* <E, R>(
316 rootDir: string = initialCwd,
317 options: ViteOptions["memo"],
318 additionalWorkspaces: Effect.Effect<Iterable<string>, E, R>,
319) {
320 const path = yield* Path.Path;
321 // Resolved once: every workspace cwd is relative to the Vite root, so
322 // the root must be an absolute base. Resolving it per call and passing
323 // `rootDir` as its own cwd would apply a relative root twice
324 // (`path.resolve("app", "app")` → `<cwd>/app/app`), hashing a
325 // directory that doesn't exist — a constant hash that never registers
326 // an edit, so the deploy no-ops forever. See issue #1016. Anchored to
327 // the initial cwd so a transient chdir can't skew the resolution.
328 const resolvedRoot = path.resolve(initialCwd, rootDir);
329 // Relative paths participate in memo hashes and surface in outputs;
330 // keep them POSIX so Windows and CI agree.
331 const relativeToRoot = (cwd: string) =>
332 path
333 .relative(resolvedRoot, path.resolve(resolvedRoot, cwd))
334 .replaceAll("\\", "/");
335 const hashWorkspaceDirectory = (cwd: string, memo?: MemoOptions) =>
336 hashDirectory({ cwd: path.resolve(resolvedRoot, cwd), memo }).pipe(
337 Effect.map((hash) => `${relativeToRoot(cwd)}:${hash}`),
338 );
339 // `"."` — the root itself, never re-applied on top of itself.
340 const hashRoot = hashWorkspaceDirectory(".", options);
341 if (Array.isArray(options?.workspaces)) {
342 return yield* Effect.all(
343 [
344 hashRoot,
345 ...options.workspaces.map(({ cwd, ...options }) =>
346 hashWorkspaceDirectory(cwd, options),
347 ),
348 ],
349 { concurrency: "unbounded" },
350 ).pipe(
351 Effect.flatMap(([root, ...workspaces]) =>
352 sha256Object([root, ...workspaces.sort()]),
353 ),
354 Effect.map((hash) => ({ hash, workspaces: undefined })),
355 );
356 }
357 const [root, workspaces] = yield* Effect.all(
358 [hashRoot, additionalWorkspaces],
359 { concurrency: "unbounded" },
360 );
361 const workspaceHashes = yield* Effect.forEach(
362 workspaces,
363 (cwd) => hashWorkspaceDirectory(cwd),
364 { concurrency: "unbounded" },
365 );
366 const hash = yield* sha256Object([root, ...workspaceHashes.sort()]);
367 return { hash, workspaces: Array.from(workspaces).map(relativeToRoot) };
368});
369
370/**
371 * Source provider for vite-based workers (`props.vite`, set by
372 * `Website.Vite`): the vite builder produces the client assets and the
373 * server bundle in one pass; diff never builds — the `input` hash over
374 * the project tree is the change signal.
375 *
376 * This module is the lazy-import boundary for the vite toolchain (see
377 * the module note above): the dispatch in `Source.ts` dynamically
378 * imports it, so its ~0.5s module cost is only paid for vite-based
379 * workers.
380 */
381export const makeViteSource = (vite: ViteOptions): SourceProvider => ({
382 ownsAssets: true,
383 build: Effect.fn(function* (ctx) {
384 const path = yield* Path.Path;
385 const env = yield* resolveViteEnv(ctx.env ?? {});
386 const { clientDirectory, serverBundle, externalWorkspaces } =
387 yield* viteBuild(
388 vite.rootDir,
389 env,
390 {
391 main: vite.main,
392 compatibilityDate: ctx.compatibility.date,
393 compatibilityFlags: ctx.compatibility.flags,
394 viteEnvironments: vite.viteEnvironments,
395 },
396 ctx.fqn,
397 );
398 const [assets, bundle, input] = yield* Effect.all(
399 [
400 clientDirectory
401 ? readAssets({
402 ...(ctx.assets && typeof ctx.assets !== "string"
403 ? ctx.assets
404 : undefined),
405 // `clientDirectory` from the build child is absolute; the
406 // base only matters for the in-process legacy shape.
407 directory: path.resolve(
408 initialCwd,
409 vite.rootDir ?? ".",
410 clientDirectory,
411 ),
412 })
413 : Effect.undefined,
414 serverBundle,
415 hashViteInput(vite.rootDir, vite.memo, externalWorkspaces),
416 ],
417 { concurrency: "unbounded" },
418 );
419 if (!assets && !bundle) {
420 return yield* Effect.die(
421 new Error("Vite build produced neither assets nor server output"),
422 );
423 }
424 return {
425 bundle,
426 assets,
427 hash: {
428 bundle: bundle?.hash,
429 assets: assets?.hash,
430 input: input.hash,
431 additionalWorkspaces: input.workspaces,
432 },
433 };
434 }),
435 hash: Effect.fn(function* (_ctx, previous) {
436 const { hash, workspaces } = yield* hashViteInput(
437 vite.rootDir,
438 vite.memo,
439 Effect.succeed(previous?.additionalWorkspaces ?? []),
440 );
441 return { input: hash, additionalWorkspaces: workspaces };
442 }),
443 dev: Effect.fn(function* (ctx) {
444 const devServer = yield* viteDev(
445 vite.rootDir,
446 ctx.env ?? {},
447 {
448 main: vite.main,
449 compatibilityDate: ctx.compatibility.date,
450 compatibilityFlags: ctx.compatibility.flags,
451 viteEnvironments: vite.viteEnvironments,
452 worker: {
453 name: ctx.workerName,
454 bindings: ctx.worker.bindings,
455 durableObjectNamespaces: ctx.worker.durableObjectNamespaces,
456 hyperdrives: ctx.worker.hyperdrives,
457 queueConsumers: yield* ctx.worker.queueConsumers,
458 assets: ctx.worker.assets,
459 },
460 context: ctx.runtimeContext,
461 },
462 { port: 0 },
463 );
464 return {
465 mode: "server",
466 url: new URL(devServer.resolvedUrls!.local[0]),
467 } satisfies SourceDevHandle;
468 }),
469});