| 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 | */ |
| 27 | import * as NodeChildProcessSpawner from "@effect/platform-node/NodeChildProcessSpawner"; |
| 28 | import * as Effect from "effect/Effect"; |
| 29 | import * as FileSystem from "effect/FileSystem"; |
| 30 | import * as Layer from "effect/Layer"; |
| 31 | import * as Path from "effect/Path"; |
| 32 | import type { PlatformError } from "effect/PlatformError"; |
| 33 | import * as Stream from "effect/Stream"; |
| 34 | import * as ChildProcess from "effect/process/ChildProcess"; |
| 35 | import { fileURLToPath } from "node:url"; |
| 36 | import { readBuildOutput } from "./BuildOutput.ts"; |
| 37 | import type { BuildOutput } from "./BuildOutput.ts"; |
| 38 | import { 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 | */ |
| 46 | export 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). */ |
| 53 | export 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 | */ |
| 70 | const 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 | |
| 77 | export 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 | */ |
| 110 | export 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 | ); |