| 1 | /** |
| 2 | * Child-process isolation for framework DEV servers. |
| 3 | * |
| 4 | * Several framework toolchains require `cwd === project root` at dev-server |
| 5 | * startup (SolidStart resolves the app from the cwd at config-load time, |
| 6 | * Waku resolves its html shell and relative inputs from the cwd), so their |
| 7 | * integrations `process.chdir` around startup — and SvelteKit's plugin |
| 8 | * construction races when several projects load `@sveltejs/kit` graphs |
| 9 | * concurrently in one process. None of that may run in a process hosting |
| 10 | * MANY dev servers (the alchemy dev sidecar serves every website of a run): |
| 11 | * concurrent chdir windows interleave, cwd-relative reads and spawns race, |
| 12 | * and a transient chdir into a since-deleted temp root breaks unrelated |
| 13 | * sites. The same class of problem production builds solved with |
| 14 | * `core/BuildChild.ts` (and Next.js dev solved with its `next dev` child). |
| 15 | * |
| 16 | * Instead, each such dev server runs in a DEDICATED child process whose |
| 17 | * working directory IS the project root — no chdir anywhere, no lock: |
| 18 | * |
| 19 | * - The **parent** ({@link runDevChild}) spawns the shared |
| 20 | * `core/DevChildRunner.ts` entry with `cwd = rootDir`, forwards the dev |
| 21 | * server's output, and resolves once the child prints the |
| 22 | * {@link DEV_CHILD_URL_REGEX} readiness marker. The child lives in the |
| 23 | * ambient Scope — the same lifetime an in-process dev handle has — and |
| 24 | * is stopped (SIGTERM, then SIGKILL) when that scope closes. |
| 25 | * - The **child** (`DevChildRunner`) imports the framework-integration |
| 26 | * module (a bare specifier, self-resolved within this package), calls |
| 27 | * `make(config.makeOptions).dev(config.devOptions)`, prints the marker, |
| 28 | * and serves until SIGTERM interrupts it. The runner sets |
| 29 | * {@link DEV_CHILD_ENV_KEY} so the integration's `dev` takes its |
| 30 | * in-process path inside the child instead of recursing. |
| 31 | */ |
| 32 | import * as Deferred from "effect/Deferred"; |
| 33 | import * as Effect from "effect/Effect"; |
| 34 | import type * as Scope from "effect/Scope"; |
| 35 | import * as NodeChildProcess from "node:child_process"; |
| 36 | import { fileURLToPath } from "node:url"; |
| 37 | import { FrameworkError, type FrameworkDevServer } from "./Framework.ts"; |
| 38 | |
| 39 | /** |
| 40 | * Set on the spawned runner (and inherited by its descendants) so a |
| 41 | * framework integration's `dev` runs the dev server IN-PROCESS when it is |
| 42 | * already inside a dev child. |
| 43 | */ |
| 44 | export const DEV_CHILD_ENV_KEY = "ALCHEMY_FRAMEWORK_DEV_CHILD"; |
| 45 | |
| 46 | /** `true` when this process IS a spawned dev child (or one of its children). */ |
| 47 | export const isInsideDevChild = (): boolean => |
| 48 | process.env[DEV_CHILD_ENV_KEY] === "1"; |
| 49 | |
| 50 | /** The runner's `argv[2]` payload (JSON). */ |
| 51 | export interface DevChildPayload { |
| 52 | /** |
| 53 | * Framework-integration module specifier (e.g. |
| 54 | * `"@alchemy.run/frontend-frameworks/waku"`). Imported by the runner — |
| 55 | * a bare self-reference resolves within this package. |
| 56 | */ |
| 57 | readonly module: string; |
| 58 | /** JSON-serializable options handed to the module's `make(...)`. */ |
| 59 | readonly makeOptions: Record<string, unknown>; |
| 60 | /** Options handed to the built service's `dev(...)`. */ |
| 61 | readonly devOptions: { |
| 62 | readonly root: string; |
| 63 | readonly port?: number | undefined; |
| 64 | readonly host?: string | undefined; |
| 65 | }; |
| 66 | } |
| 67 | |
| 68 | /** Marker line the child prints once the dev server is listening. */ |
| 69 | export const DEV_CHILD_URL_REGEX = |
| 70 | /<ALCHEMY_DEV_CHILD_URL>(.+)<\/ALCHEMY_DEV_CHILD_URL>/; |
| 71 | |
| 72 | export const devChildUrlMarker = (url: string) => |
| 73 | `<ALCHEMY_DEV_CHILD_URL>${url}</ALCHEMY_DEV_CHILD_URL>`; |
| 74 | |
| 75 | /** |
| 76 | * `true` when `value` survives a JSON round-trip losslessly — i.e. it |
| 77 | * contains no functions, symbols, bigints, class instances, or cycles a |
| 78 | * child process could not reconstruct. Integrations use this to decide |
| 79 | * whether their `make` options can cross the process boundary; when they |
| 80 | * cannot (e.g. the e2e harness passes deploy-target VALUES or live vite |
| 81 | * plugins), `dev` falls back to the in-process path. |
| 82 | */ |
| 83 | export const isJsonSerializable = (value: unknown): boolean => { |
| 84 | const check = (input: unknown): boolean => { |
| 85 | if (input === null) return true; |
| 86 | switch (typeof input) { |
| 87 | case "string": |
| 88 | case "number": |
| 89 | case "boolean": |
| 90 | case "undefined": |
| 91 | return true; |
| 92 | case "object": |
| 93 | break; |
| 94 | default: |
| 95 | return false; |
| 96 | } |
| 97 | if (Array.isArray(input)) return input.every(check); |
| 98 | const proto = Object.getPrototypeOf(input); |
| 99 | if (proto !== Object.prototype && proto !== null) return false; |
| 100 | return Object.values(input as Record<string, unknown>).every(check); |
| 101 | }; |
| 102 | try { |
| 103 | return check(value); |
| 104 | } catch { |
| 105 | // cyclic structures overflow the recursion — not serializable |
| 106 | return false; |
| 107 | } |
| 108 | }; |
| 109 | |
| 110 | /** |
| 111 | * Node CLI flags that transparently strip TypeScript types, so the `.ts` |
| 112 | * runner entry works when running from `src/` under plain Node (Bun handles |
| 113 | * `.ts` natively). Mirrors `core/BuildChild.ts`. |
| 114 | */ |
| 115 | const transformTypesFlags = (): Array<string> => { |
| 116 | const [major = 0, minor = 0] = process.versions.node.split(".").map(Number); |
| 117 | return (major === 22 && minor >= 7) || (major >= 23 && major < 26) |
| 118 | ? ["--experimental-transform-types", "--no-warnings=ExperimentalWarning"] |
| 119 | : []; |
| 120 | }; |
| 121 | |
| 122 | export interface DevChildOptions { |
| 123 | /** Use Node from PATH for toolchains that cannot run under Bun. Requires native TypeScript support when running source modules. */ |
| 124 | readonly runtime?: "node" | undefined; |
| 125 | /** Framework name for error attribution (e.g. "solidstart", "waku"). */ |
| 126 | readonly framework: string; |
| 127 | /** Bare module specifier the runner imports (see {@link DevChildPayload}). */ |
| 128 | readonly module: string; |
| 129 | /** |
| 130 | * `import.meta.url` of the CALLING framework module. The shared runner |
| 131 | * entry is resolved as `../core/DevChildRunner.*` relative to it — which |
| 132 | * lands on the stable entry file in both layouts |
| 133 | * (`src/{fw}/X.ts` → `src/core/DevChildRunner.ts`, |
| 134 | * `dist/_chunks/X.js` → `dist/core/DevChildRunner.js`). |
| 135 | */ |
| 136 | readonly callerUrl: string; |
| 137 | /** Project root — becomes the child's working directory. */ |
| 138 | readonly rootDir: string; |
| 139 | /** JSON-serializable options for the module's `make(...)`. */ |
| 140 | readonly makeOptions: Record<string, unknown>; |
| 141 | /** Options for the built service's `dev(...)`. */ |
| 142 | readonly devOptions: DevChildPayload["devOptions"]; |
| 143 | } |
| 144 | |
| 145 | interface DevChildHandle { |
| 146 | readonly child: NodeChildProcess.ChildProcess; |
| 147 | exited: boolean; |
| 148 | output: string; |
| 149 | } |
| 150 | |
| 151 | /** |
| 152 | * Start a framework dev server in a dedicated child process with |
| 153 | * `cwd = rootDir` and resolve with its local URL at readiness. The child |
| 154 | * lives in the ambient Scope; closing it stops the dev server. |
| 155 | */ |
| 156 | export const runDevChild = ( |
| 157 | options: DevChildOptions, |
| 158 | ): Effect.Effect<FrameworkDevServer, FrameworkError, Scope.Scope> => |
| 159 | Effect.gen(function* () { |
| 160 | const fail = |
| 161 | (message: string) => |
| 162 | (cause?: unknown): FrameworkError => |
| 163 | new FrameworkError({ |
| 164 | framework: options.framework, |
| 165 | message, |
| 166 | cause, |
| 167 | }); |
| 168 | |
| 169 | const entry = fileURLToPath( |
| 170 | new URL( |
| 171 | options.callerUrl.endsWith(".ts") |
| 172 | ? "../core/DevChildRunner.ts" |
| 173 | : "../core/DevChildRunner.js", |
| 174 | options.callerUrl, |
| 175 | ), |
| 176 | ); |
| 177 | const isBun = |
| 178 | options.runtime !== "node" && |
| 179 | typeof (globalThis as { Bun?: unknown }).Bun !== "undefined"; |
| 180 | const executable = options.runtime === "node" ? "node" : process.execPath; |
| 181 | const payload: DevChildPayload = { |
| 182 | module: options.module, |
| 183 | makeOptions: options.makeOptions, |
| 184 | devOptions: options.devOptions, |
| 185 | }; |
| 186 | const args = [ |
| 187 | ...(isBun |
| 188 | ? ["run"] |
| 189 | : entry.endsWith(".ts") && options.runtime !== "node" |
| 190 | ? transformTypesFlags() |
| 191 | : []), |
| 192 | entry, |
| 193 | JSON.stringify(payload), |
| 194 | ]; |
| 195 | |
| 196 | const spawnFailure = fail( |
| 197 | `Failed to spawn the ${options.framework} dev child (${executable})`, |
| 198 | ); |
| 199 | const spawnError = yield* Deferred.make<never, FrameworkError>(); |
| 200 | const handle = yield* Effect.acquireRelease( |
| 201 | Effect.try({ |
| 202 | try: (): DevChildHandle => { |
| 203 | const child = NodeChildProcess.spawn(executable, args, { |
| 204 | cwd: options.rootDir, |
| 205 | stdio: ["ignore", "pipe", "pipe"], |
| 206 | detached: false, |
| 207 | env: { ...process.env, [DEV_CHILD_ENV_KEY]: "1" }, |
| 208 | }); |
| 209 | const handle: DevChildHandle = { child, exited: false, output: "" }; |
| 210 | child.on("error", (cause) => { |
| 211 | Deferred.doneUnsafe(spawnError, Effect.fail(spawnFailure(cause))); |
| 212 | }); |
| 213 | child.once("exit", () => { |
| 214 | handle.exited = true; |
| 215 | }); |
| 216 | return handle; |
| 217 | }, |
| 218 | catch: spawnFailure, |
| 219 | }), |
| 220 | ({ child, exited }) => |
| 221 | Effect.callback<void>((resume) => { |
| 222 | if (child.pid === undefined || exited || child.exitCode !== null) { |
| 223 | resume(Effect.void); |
| 224 | return; |
| 225 | } |
| 226 | const killTimer = setTimeout(() => child.kill("SIGKILL"), 3000); |
| 227 | child.once("exit", () => { |
| 228 | clearTimeout(killTimer); |
| 229 | resume(Effect.void); |
| 230 | }); |
| 231 | child.kill("SIGTERM"); |
| 232 | }), |
| 233 | ); |
| 234 | |
| 235 | const url = yield* Effect.callback<string, FrameworkError>((resume) => { |
| 236 | const { child } = handle; |
| 237 | let lineBuffer = ""; |
| 238 | let ready = false; |
| 239 | const capture = (text: string) => { |
| 240 | handle.output += text; |
| 241 | if (handle.output.length > 65536) { |
| 242 | handle.output = handle.output.slice(-32768); |
| 243 | } |
| 244 | }; |
| 245 | // Dev-server logs are re-emitted through the parent's own JS streams |
| 246 | // (not `stdio: "inherit"`) so in-process capture (a test runner's log |
| 247 | // buffer, the dev renderer) sees them. |
| 248 | child.stdout?.on("data", (chunk: unknown) => { |
| 249 | const text = String(chunk); |
| 250 | capture(text); |
| 251 | if (ready) { |
| 252 | process.stdout.write(text); |
| 253 | return; |
| 254 | } |
| 255 | lineBuffer += text; |
| 256 | const lines = lineBuffer.split("\n"); |
| 257 | lineBuffer = lines.pop() ?? ""; |
| 258 | for (const line of lines) { |
| 259 | const match = line.match(DEV_CHILD_URL_REGEX); |
| 260 | if (match !== null) { |
| 261 | ready = true; |
| 262 | resume(Effect.succeed(match[1])); |
| 263 | } else { |
| 264 | process.stdout.write(`${line}\n`); |
| 265 | } |
| 266 | } |
| 267 | }); |
| 268 | child.stderr?.on("data", (chunk: unknown) => { |
| 269 | const text = String(chunk); |
| 270 | capture(text); |
| 271 | process.stderr.write(text); |
| 272 | }); |
| 273 | child.once("exit", (code) => { |
| 274 | if (!ready) { |
| 275 | resume( |
| 276 | Effect.fail( |
| 277 | fail( |
| 278 | `The ${options.framework} dev child exited with code ${String(code)} before becoming ready:\n${handle.output.slice(-4000)}`, |
| 279 | )(undefined), |
| 280 | ), |
| 281 | ); |
| 282 | } |
| 283 | }); |
| 284 | }).pipe(Effect.raceFirst(Deferred.await(spawnError))); |
| 285 | |
| 286 | return { url }; |
| 287 | }); |