1import * as Deferred from "effect/Deferred";
2import * as Effect from "effect/Effect";
3import * as Fiber from "effect/Fiber";
4import * as FileSystem from "effect/FileSystem";
5import * as Option from "effect/Option";
6import * as Path from "effect/Path";
7import * as Stream from "effect/Stream";
8import * as ChildProcess from "effect/process/ChildProcess";
9import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
10import { fileURLToPath } from "node:url";
11import * as NodeV8 from "node:v8";
12import { AlchemyContext } from "../../AlchemyContext.ts";
13import { BundleError } from "../../Bundle/Bundle.ts";
14import { pipedColorEnv } from "../../Util/Terminal.ts";
15import {
16 fromProcessEnv,
17 RPC_SERVER_ENVIRONMENT_KEY,
18 type RpcServerEnvironment,
19} from "../../Local/RpcServerEnvironment.ts";
20import { Stack } from "../../Stack.ts";
21import { unwrapRedacted } from "../../Util/index.ts";
22import { nodeLoaderArgs } from "../../Util/Node.ts";
23import { moduleExtension } from "../../Util/Node.ts";
24import {
25 type ViteBuildChildConfig,
26 type ViteBuildChildResult,
27 type ViteChildConfig,
28 VITE_CHILD_READY_PREFIX,
29 VITE_CHILD_READY_SUFFIX,
30} from "./ViteChild.shared.ts";
31
32export interface ViteChildHandle {
33 readonly url: URL;
34 readonly pid: number;
35 readonly exitCode: Effect.Effect<number>;
36}
37
38// Resolved lazily at spawn time, NOT at module load: this module can be
39// carried into bundled user code (Workers), where `import.meta.resolve`
40// does not exist (workerd) — a module-level call would crash the bundle
41// at startup even though no child is ever spawned there. At spawn time
42// we are guaranteed to be in Node/bun.
43const resolveRunner = (basename: string) =>
44 fileURLToPath(
45 import.meta.resolve(`./${basename}${moduleExtension(import.meta.url)}`),
46 );
47
48export const startViteChild = (
49 config: ViteChildConfig,
50 onOutput: (channel: "stdout" | "stderr", line: string) => Effect.Effect<void>,
51) =>
52 Effect.gen(function* () {
53 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
54 const runner = resolveRunner("ViteChildRunner");
55 const isBun = typeof globalThis.Bun !== "undefined";
56 // A source provider can pin the dev child to node (e.g. `next dev`
57 // cold-starts broken under bun). Honored only when node is installed;
58 // otherwise fall back to the engine's own runtime.
59 const nodeExecPath =
60 isBun && config.source?.descriptor.runtime === "node"
61 ? (globalThis.Bun.which("node") ?? undefined)
62 : undefined;
63 // Redacted values can't cross the process boundary — the config is
64 // plain data once unwrapped. bun's `v8.serialize` output is not
65 // readable by real V8 (and vice versa), so a cross-runtime spawn
66 // sends JSON instead; the runner sniffs the encoding (V8 payloads
67 // start with 0xFF, JSON with `{`).
68 const serializedConfig =
69 nodeExecPath !== undefined
70 ? Buffer.from(JSON.stringify(unwrapRedacted(config)))
71 : NodeV8.serialize(unwrapRedacted(config));
72 // The Vite child boots the legacy single-stack environment
73 // (RpcServerEnvironment.fromEnv). The sidecar's own process env no
74 // longer carries a stack (one sidecar serves many stacks; sessions
75 // carry theirs), so pass the CURRENT session's stack explicitly, with
76 // profile/envFile taken from the sidecar's spawn-time env when present.
77 // Resolved via serviceOption so this helper's requirements stay
78 // unchanged; the provider context this runs in always carries both.
79 const alchemyContext = yield* Effect.serviceOption(AlchemyContext);
80 const stack = yield* Effect.serviceOption(Stack);
81 const base = yield* fromProcessEnv.pipe(
82 Effect.orElseSucceed((): RpcServerEnvironment => ({
83 profile: process.env.ALCHEMY_PROFILE,
84 envFile: undefined,
85 })),
86 );
87 const childEnvironment: RpcServerEnvironment = {
88 profile: base.profile,
89 envFile: base.envFile,
90 alchemyContext: Option.getOrElse(
91 alchemyContext,
92 () => base.alchemyContext as AlchemyContext["Service"],
93 ),
94 stack: Option.getOrElse(
95 Option.map(stack, (s) => ({ name: s.name, stage: s.stage })),
96 () => base.stack as { name: string; stage: string },
97 ),
98 };
99 const childEnv = {
100 ...process.env,
101 [RPC_SERVER_ENVIRONMENT_KEY]: JSON.stringify(childEnvironment),
102 };
103 // The parent CLI process sets NODE_ENV for its own React/renderer
104 // needs; that hack must not leak into the user's dev server (Vite
105 // derives its default mode from NODE_ENV, so an inherited value would
106 // silently override the project's own mode).
107 delete childEnv.NODE_ENV;
108 const child = yield* spawner.spawn(
109 ChildProcess.make(
110 nodeExecPath ?? process.execPath,
111 isBun && nodeExecPath === undefined
112 ? ["run", runner]
113 : [...nodeLoaderArgs(runner), runner],
114 {
115 cwd: config.rootDir,
116 stdin: Stream.succeed(serializedConfig),
117 stdout: "pipe",
118 stderr: "pipe",
119 env: childEnv,
120 extendEnv: false,
121 killSignal: "SIGKILL",
122 },
123 ),
124 );
125
126 const ready = yield* Deferred.make<URL>();
127 yield* child.stdout.pipe(
128 Stream.decodeText,
129 Stream.splitLines,
130 Stream.runForEach((line) => {
131 const start = line.indexOf(VITE_CHILD_READY_PREFIX);
132 const end = line.indexOf(VITE_CHILD_READY_SUFFIX);
133 if (start !== -1 && end > start) {
134 const value = line.slice(start + VITE_CHILD_READY_PREFIX.length, end);
135 return Deferred.succeed(ready, new URL(value));
136 }
137 return onOutput("stdout", line);
138 }),
139 Effect.forkScoped,
140 );
141 yield* child.stderr.pipe(
142 Stream.decodeText,
143 Stream.splitLines,
144 Stream.runForEach((line) => onOutput("stderr", line)),
145 Effect.forkScoped,
146 );
147
148 const exitCode = child.exitCode.pipe(Effect.orDie);
149 const url = yield* Effect.raceAllFirst([
150 Deferred.await(ready),
151 exitCode.pipe(
152 Effect.flatMap((exitCode) =>
153 Effect.die(
154 new Error(
155 `Vite child exited with code ${exitCode} before becoming ready`,
156 ),
157 ),
158 ),
159 ),
160 ]);
161 return { url, pid: child.pid, exitCode } satisfies ViteChildHandle;
162 });
163
164/** Number of trailing output lines echoed into a failed build's error. */
165const BUILD_ERROR_TAIL = 50;
166
167/**
168 * Run a one-shot production Vite build in a child process whose working
169 * directory is the project root, and read back the serialized result.
170 *
171 * Isolation is the point (the dev server already runs this way): an
172 * in-process build both suffers from and causes transient `process.cwd()`
173 * changes — cross-spawn `process.chdir`s the hosting process while
174 * resolving a spawn's binary, and vite/plugins resolve relative paths
175 * against live cwd — which corrupts concurrent deploys sharing the
176 * process. See `ViteBuildChildRunner.ts` for the child side.
177 */
178export const runViteBuildChild = (
179 config: Omit<ViteBuildChildConfig, "outputPath">,
180 onOutput: (channel: "stdout" | "stderr", line: string) => Effect.Effect<void>,
181) =>
182 Effect.scoped(
183 Effect.gen(function* () {
184 const fs = yield* FileSystem.FileSystem;
185 const path = yield* Path.Path;
186 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
187 const runner = resolveRunner("ViteBuildChildRunner");
188 const isBun = typeof globalThis.Bun !== "undefined";
189 const outputDir = yield* fs.makeTempDirectoryScoped({
190 prefix: "alchemy-vite-build-",
191 });
192 const outputPath = path.join(outputDir, "result.v8");
193 // Redacted values can't cross the process boundary — the config is
194 // plain data once unwrapped.
195 const serializedConfig = NodeV8.serialize(
196 unwrapRedacted({ ...config, outputPath }),
197 );
198 const child = yield* spawner.spawn(
199 ChildProcess.make(
200 process.execPath,
201 isBun ? ["run", runner] : [...nodeLoaderArgs(runner), runner],
202 {
203 cwd: config.rootDir,
204 stdin: Stream.succeed(serializedConfig),
205 stdout: "pipe",
206 stderr: "pipe",
207 extendEnv: true,
208 // A production build must not inherit the parent's NODE_ENV: a
209 // deploy driven from a test process (`bun test` sets
210 // NODE_ENV=test) would otherwise bake `import.meta.env.DEV`
211 // into the server bundle (e.g. SolidStart then ships its
212 // dev-only manifest and every SSR request 500s).
213 env: { ...pipedColorEnv(), NODE_ENV: "production" },
214 killSignal: "SIGKILL",
215 },
216 ),
217 );
218
219 const tail: string[] = [];
220 const forward = (channel: "stdout" | "stderr") => (line: string) =>
221 Effect.sync(() => {
222 tail.push(line);
223 if (tail.length > BUILD_ERROR_TAIL) tail.shift();
224 }).pipe(Effect.andThen(onOutput(channel, line)));
225 const stdoutFiber = yield* Effect.forkChild(
226 child.stdout.pipe(
227 Stream.decodeText,
228 Stream.splitLines,
229 Stream.runForEach(forward("stdout")),
230 ),
231 );
232 const stderrFiber = yield* Effect.forkChild(
233 child.stderr.pipe(
234 Stream.decodeText,
235 Stream.splitLines,
236 Stream.runForEach(forward("stderr")),
237 ),
238 );
239
240 const exitCode = yield* child.exitCode.pipe(Effect.orDie);
241 // Drain both pipes before reporting so the error tail is complete.
242 yield* Fiber.join(stdoutFiber).pipe(Effect.ignore);
243 yield* Fiber.join(stderrFiber).pipe(Effect.ignore);
244 if (exitCode !== 0) {
245 return yield* Effect.fail(
246 new BundleError({
247 message: `Vite build child exited with code ${exitCode}:\n${tail.join("\n")}`,
248 }),
249 );
250 }
251 const bytes = yield* fs.readFile(outputPath).pipe(Effect.orDie);
252 return NodeV8.deserialize(Buffer.from(bytes)) as ViteBuildChildResult;
253 }),
254 );