1/**
2 * Child-process isolation for framework production builds.
3 *
4 * Framework builds load and execute USER code — `astro.config.*`,
5 * `nuxt.config.ts` modules, `vite.config.*` plugins, `open-next.config.ts` —
6 * which may read `process.cwd()`, mutate `process.env`, or `process.chdir`.
7 * Some toolchains additionally require `cwd === project root` outright
8 * (kit's postbuild workers re-derive `outDir` from the cwd; waku/rolldown
9 * resolve inputs from the cwd). None of that may run inside the engine
10 * process, where many deploys share one event loop. The same class of
11 * problem alchemy solved for Vite (#1102) and Next.js (`runner.mjs`).
12 *
13 * Instead, every production build runs in a disposable child process whose
14 * working directory IS the project root — no chdir anywhere, no lock,
15 * unbounded build concurrency:
16 *
17 * - The **parent** ({@link runBuildChild}) spawns the shared
18 * `core/BuildChildRunner.ts` entry, forwards the build logs, and reads the
19 * {@link BuildOutput} back from a temp `build.json` (`readBuildOutput`
20 * revives Buffers and the workspaces Set).
21 * - The **child** (`BuildChildRunner`) imports the framework's source module
22 * and calls its exported `buildInChild(config)` — a PURE function
23 * returning an Effect (see {@link BuildChildModule}), so importing any
24 * source module never has side effects; process concerns (argv, exit
25 * codes) live only in the runner entry, which is never imported.
26 */
27import * as NodeChildProcessSpawner from "@effect/platform-node/NodeChildProcessSpawner";
28import * as Effect from "effect/Effect";
29import * as FileSystem from "effect/FileSystem";
30import * as Layer from "effect/Layer";
31import * as Path from "effect/Path";
32import type { PlatformError } from "effect/PlatformError";
33import * as Stream from "effect/Stream";
34import * as ChildProcess from "effect/process/ChildProcess";
35import { fileURLToPath } from "node:url";
36import { readBuildOutput } from "./BuildOutput.ts";
37import type { BuildOutput } from "./BuildOutput.ts";
38import { FrameworkError } from "./Framework.ts";
39
40/**
41 * The contract a framework source module fulfills to be buildable in a
42 * child: a pure `buildInChild` export that turns the JSON config into the
43 * build Effect. Tests can call it directly in-process; `runBuildChild`
44 * executes it in the shared runner entry.
45 */
46export interface BuildChildModule {
47 readonly buildInChild: (
48 config: never,
49 ) => Effect.Effect<BuildOutput, unknown, FileSystem.FileSystem | Path.Path>;
50}
51
52/** The runner's `argv[2]` payload (JSON). */
53export interface BuildChildPayload {
54 /** File URL of the framework source module exporting `buildInChild`. */
55 readonly module: string;
56 /** JSON-serializable config handed to the module's `buildInChild`. */
57 readonly config: unknown;
58 /** Where the runner persists the resulting `BuildOutput` (build.json). */
59 readonly outputPath: string;
60 /** Where the runner persists the failure message when the build fails. */
61 readonly errorPath: string;
62}
63
64/**
65 * Node CLI flags that transparently strip TypeScript types, so a `.ts`
66 * source module works as a child entry when running from `src/` under plain
67 * Node (Bun handles `.ts` natively). Mirrors alchemy's
68 * `Util/Node.transformTypesFlags`.
69 */
70const transformTypesFlags = (): Array<string> => {
71 const [major = 0, minor = 0] = process.versions.node.split(".").map(Number);
72 return (major === 22 && minor >= 7) || (major >= 23 && major < 26)
73 ? ["--experimental-transform-types", "--no-warnings=ExperimentalWarning"]
74 : [];
75};
76
77export interface BuildChildOptions {
78 /** Use Node from PATH for toolchains that cannot build under Bun. Requires native TypeScript support when running source modules. */
79 readonly runtime?: "node" | undefined;
80 /**
81 * File URL of the module exporting `buildInChild` — pass
82 * `import.meta.url`. The shared runner entry (resolved as a sibling of
83 * this module's `core/` directory) imports it in the child.
84 */
85 readonly module: string;
86 /** Project root — becomes the child's working directory. */
87 readonly rootDir: string;
88 /** JSON-serializable config handed to the module's `buildInChild`. */
89 readonly config: unknown;
90 /** Framework name for error attribution (e.g. "sveltekit", "waku"). */
91 readonly framework: string;
92 /**
93 * Extra process env for the child only. Merged over the parent's env
94 * at spawn time so Vite/nitro/`import.meta.env` see site `env` without
95 * the parent mutating `process.env`. NODE_ENV defaults to production;
96 * an explicit value here overrides that default (plugins in the child may still
97 * mutate theirs — that is why this is a child).
98 */
99 readonly env?: Record<string, string> | undefined;
100}
101
102/**
103 * Run a framework production build in a disposable child process with
104 * `cwd = rootDir` and return its {@link BuildOutput}.
105 *
106 * Build logs are re-emitted through the parent's own `process.stdout` /
107 * `process.stderr` JS streams — NOT `stdio: "inherit"` — so in-process
108 * capture (a test runner's log file) sees them.
109 */
110export const runBuildChild = (
111 options: BuildChildOptions,
112): Effect.Effect<
113 BuildOutput,
114 FrameworkError,
115 FileSystem.FileSystem | Path.Path
116> =>
117 Effect.scoped(
118 Effect.gen(function* () {
119 const fs = yield* FileSystem.FileSystem;
120 const path = yield* Path.Path;
121 const fail =
122 (message: string) =>
123 (cause?: unknown): FrameworkError =>
124 new FrameworkError({
125 framework: options.framework,
126 message,
127 cause,
128 });
129
130 const outputDir = yield* fs
131 .makeTempDirectoryScoped({ prefix: "alchemy-framework-build-" })
132 .pipe(
133 Effect.mapError(
134 fail("Failed to create the build child's temp directory"),
135 ),
136 );
137 const outputPath = path.join(outputDir, "build.json");
138 const errorPath = path.join(outputDir, "error.txt");
139
140 // The runner entry lives beside this module's core/ directory in both
141 // layouts (src/{fw}/source.ts → src/core/BuildChildRunner.ts,
142 // dist/{fw}/source.js → dist/core/BuildChildRunner.js), so it resolves
143 // relative to the FRAMEWORK module's own stable URL.
144 const entry = fileURLToPath(
145 new URL(
146 options.module.endsWith(".ts")
147 ? "../core/BuildChildRunner.ts"
148 : "../core/BuildChildRunner.js",
149 options.module,
150 ),
151 );
152 const isBun =
153 options.runtime !== "node" &&
154 typeof (globalThis as { Bun?: unknown }).Bun !== "undefined";
155 const executable = options.runtime === "node" ? "node" : process.execPath;
156 const payload: BuildChildPayload = {
157 module: options.module,
158 config: options.config,
159 outputPath,
160 errorPath,
161 };
162 const args = [
163 ...(isBun
164 ? ["run"]
165 : entry.endsWith(".ts") && options.runtime !== "node"
166 ? transformTypesFlags()
167 : []),
168 entry,
169 JSON.stringify(payload),
170 ];
171
172 const spawnerLayer = NodeChildProcessSpawner.layer.pipe(
173 Layer.provide(
174 Layer.merge(
175 Layer.succeed(FileSystem.FileSystem)(fs),
176 Layer.succeed(Path.Path)(path),
177 ),
178 ),
179 );
180
181 const exitCode = yield* Effect.gen(function* () {
182 const child = yield* ChildProcess.make(executable, args, {
183 cwd: options.rootDir,
184 stdin: "ignore",
185 stdout: "pipe",
186 stderr: "pipe",
187 // Default builds to production rather than inheriting the CLI/test
188 // runner's mode, while preserving deliberate build-env overrides.
189 env: { ...process.env, NODE_ENV: "production", ...options.env },
190 }).pipe(
191 Effect.mapError(
192 fail(
193 `Failed to spawn the ${options.framework} build child (${executable})`,
194 ),
195 ),
196 );
197 const forward = (
198 stream: Stream.Stream<Uint8Array, PlatformError>,
199 dest: NodeJS.WriteStream,
200 ) =>
201 Stream.runForEach(stream, (chunk) =>
202 Effect.sync(() => dest.write(chunk)),
203 );
204 const { code } = yield* Effect.all(
205 {
206 code: child.exitCode,
207 stdout: forward(child.stdout, process.stdout),
208 stderr: forward(child.stderr, process.stderr),
209 },
210 { concurrency: "unbounded" },
211 ).pipe(
212 Effect.mapError(
213 fail(
214 `Failed reading the ${options.framework} build child's output`,
215 ),
216 ),
217 );
218 return code;
219 }).pipe(Effect.provide(spawnerLayer));
220
221 if (exitCode !== 0) {
222 // The child's own failure message (e.g. a framework config the target
223 // rejects) beats the bare exit code; its stack already went to stderr.
224 const reason = yield* fs
225 .readFileString(errorPath)
226 .pipe(Effect.orElseSucceed(() => undefined));
227 return yield* Effect.fail(
228 fail(
229 reason ??
230 `The ${options.framework} build child exited with code ${exitCode}`,
231 )(undefined),
232 );
233 }
234
235 return yield* readBuildOutput(outputPath).pipe(
236 Effect.mapError(
237 fail(
238 `The ${options.framework} build child exited 0 but produced no readable build output`,
239 ),
240 ),
241 );
242 }),
243 );