| 1 | import * as Deferred from "effect/Deferred"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Fiber from "effect/Fiber"; |
| 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Option from "effect/Option"; |
| 6 | import * as Path from "effect/Path"; |
| 7 | import * as Stream from "effect/Stream"; |
| 8 | import * as ChildProcess from "effect/process/ChildProcess"; |
| 9 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 10 | import { fileURLToPath } from "node:url"; |
| 11 | import * as NodeV8 from "node:v8"; |
| 12 | import { AlchemyContext } from "../../AlchemyContext.ts"; |
| 13 | import { BundleError } from "../../Bundle/Bundle.ts"; |
| 14 | import { pipedColorEnv } from "../../Util/Terminal.ts"; |
| 15 | import { |
| 16 | fromProcessEnv, |
| 17 | RPC_SERVER_ENVIRONMENT_KEY, |
| 18 | type RpcServerEnvironment, |
| 19 | } from "../../Local/RpcServerEnvironment.ts"; |
| 20 | import { Stack } from "../../Stack.ts"; |
| 21 | import { unwrapRedacted } from "../../Util/index.ts"; |
| 22 | import { nodeLoaderArgs } from "../../Util/Node.ts"; |
| 23 | import { moduleExtension } from "../../Util/Node.ts"; |
| 24 | import { |
| 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 | |
| 32 | export 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. |
| 43 | const resolveRunner = (basename: string) => |
| 44 | fileURLToPath( |
| 45 | import.meta.resolve(`./${basename}${moduleExtension(import.meta.url)}`), |
| 46 | ); |
| 47 | |
| 48 | export 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. */ |
| 165 | const 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 | */ |
| 178 | export 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 | ); |