1import * as Floci from "@alchemy.run/floci";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";
6import * as Command from "effect/cli/Command";
7import * as ChildProcess from "effect/process/ChildProcess";
8import { fileURLToPath } from "node:url";
9import { SPAWNER_URL_ENV_KEY } from "../../Local/RpcProviderProxy.ts";
10import * as RpcSpawner from "../../Local/RpcSpawner.ts";
11import { resolveStackEntrypoint } from "../../Alchemist/Entrypoint.ts";
12import { nodeLoaderArgs } from "../../Util/Node.ts";
13import { DEV_RELOAD_EXIT_CODE, DevOptions } from "../DevOptions.ts";
14import {
15 configPath,
16 envFile,
17 force,
18 include,
19 exclude,
20 devStage,
21 optionalConfig,
22 profile,
23 resolveStackArgs,
24} from "./flags.ts";
25import { suppressInterruptMessages } from "./errors.ts";
26
27/**
28 * Trust the Floci emulator CA in `alchemy dev` so cross-cloud data planes
29 * terminated by the emulator's self-signed cert (e.g. an AWS Lambda MicroVM
30 * bound to a local Cloudflare Worker) are reachable from local workerd. workerd
31 * reads its trusted certificates from `NODE_EXTRA_CA_CERTS` at runtime init, so
32 * this must be present in the env of every spawned child (the exec worker, the
33 * RPC sidecar, and the workerd instances they start). `ensureFloci` refreshes
34 * the bundle at this stable path (and sets the variable itself once written);
35 * never clobber a value the caller set, and don't point at a missing file —
36 * Node/Bun warn about it on every startup.
37 */
38
39export const devCommand = Command.make(
40 "dev",
41 {
42 force,
43 include,
44 exclude,
45 config: optionalConfig,
46 configPath,
47 envFile,
48 stage: devStage,
49 profile,
50 },
51 Effect.fn(
52 function* (rawArgs) {
53 const args = yield* resolveStackArgs("dev")(rawArgs);
54 // This process is only the exec child's supervisor; the child owns the
55 // terminal and announces the Ctrl+C shutdown. Without this, a SIGINT
56 // hits both processes and the interrupt message prints twice.
57 yield* suppressInterruptMessages;
58 const options = yield* Schema.encodeEffect(DevOptions)(args);
59 // A missing entry is this process's error to report, not a stack
60 // trace out of the exec child.
61 yield* resolveStackEntrypoint(options.main);
62 const fs = yield* FileSystem.FileSystem;
63 // Set on THIS process too, so the RPC spawner's sidecars (and the workerd
64 // they launch) inherit it — they are forked from here, not from the exec
65 // child below.
66 if (yield* fs.exists(Floci.FLOCI_CA_PATH)) {
67 process.env.NODE_EXTRA_CA_CERTS ??= Floci.FLOCI_CA_PATH;
68 }
69 const spawner = yield* RpcSpawner.RpcSpawner;
70 // Neither runtime uses its native `--watch`: those hard-restart the exec
71 // child with no teardown, leaving the previous generation's widget in
72 // scrollback and never saying what changed. exec.ts watches the user's
73 // stack graph itself. Under Node it reloads the graph in-process; under
74 // Bun (which cannot evict evaluated modules) it tears down and exits
75 // with DEV_RELOAD_EXIT_CODE, and this supervisor starts a fresh child.
76 let command: [string, ...string[]];
77 const entry = fileURLToPath(import.meta.resolve("alchemy/bin/exec.js"));
78 if (typeof globalThis.Bun !== "undefined") {
79 command = ["bun", "run", ...process.execArgv, entry];
80 } else {
81 // Node: the exec entry runs with alchemy's Oxc loader hooks,
82 // exactly as bin/cli.js started this process. Select the loader from
83 // this module's source or compiled location; the entry is always JS.
84 // Node's own TypeScript support is never relied on. `process.execPath`,
85 // not "node": the hooks are gated on THIS node's version. A
86 // duplicate --import inherited via execArgv is harmless — the
87 // second import of the same URL hits the module cache.
88 command = [
89 process.execPath,
90 ...process.execArgv,
91 ...nodeLoaderArgs(import.meta.url),
92 entry,
93 ];
94 }
95 const runChild = ChildProcess.make(command[0], command.slice(1), {
96 stdin: "inherit",
97 stdout: "inherit",
98 stderr: "inherit",
99 env: {
100 ALCHEMY_EXEC_OPTIONS: JSON.stringify(options),
101 ALCHEMY_DEV: "true",
102 ...(process.env.NODE_EXTRA_CA_CERTS
103 ? { NODE_EXTRA_CA_CERTS: process.env.NODE_EXTRA_CA_CERTS }
104 : {}),
105 [SPAWNER_URL_ENV_KEY]: spawner.url,
106 },
107 extendEnv: true,
108 // Same process group as this supervisor: the exec child owns the
109 // terminal (TUI stdin), so the tty's Ctrl+C must reach it directly.
110 detached: false,
111 }).pipe(
112 Effect.flatMap((child) => child.exitCode),
113 Effect.scoped,
114 );
115 // Each child gets its own scope so a reload exit releases the old
116 // handle before the replacement starts; the sidecar spawner lives in
117 // the command scope and survives every restart.
118 yield* Effect.repeat(runChild, {
119 until: (code) => code !== DEV_RELOAD_EXIT_CODE,
120 });
121 },
122 (effect, args) =>
123 Effect.provide(
124 RpcSpawner.layerServer({
125 profile: args.profile,
126 envFile: Option.getOrUndefined(args.envFile),
127 }),
128 )(effect),
129 ),
130).pipe(Command.withDescription("Develop a stack with live reload"));