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 */
32import * as Deferred from "effect/Deferred";
33import * as Effect from "effect/Effect";
34import type * as Scope from "effect/Scope";
35import * as NodeChildProcess from "node:child_process";
36import { fileURLToPath } from "node:url";
37import { 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 */
44export 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). */
47export const isInsideDevChild = (): boolean =>
48 process.env[DEV_CHILD_ENV_KEY] === "1";
49
50/** The runner's `argv[2]` payload (JSON). */
51export 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. */
69export const DEV_CHILD_URL_REGEX =
70 /<ALCHEMY_DEV_CHILD_URL>(.+)<\/ALCHEMY_DEV_CHILD_URL>/;
71
72export 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 */
83export 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 */
115const 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
122export 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
145interface 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 */
156export 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 });