| 1 | import * as Floci from "@alchemy.run/floci"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as FileSystem from "effect/FileSystem"; |
| 4 | import * as Option from "effect/Option"; |
| 5 | import * as Schema from "effect/Schema"; |
| 6 | import * as Command from "effect/cli/Command"; |
| 7 | import * as ChildProcess from "effect/process/ChildProcess"; |
| 8 | import { fileURLToPath } from "node:url"; |
| 9 | import { SPAWNER_URL_ENV_KEY } from "../../Local/RpcProviderProxy.ts"; |
| 10 | import * as RpcSpawner from "../../Local/RpcSpawner.ts"; |
| 11 | import { resolveStackEntrypoint } from "../../Alchemist/Entrypoint.ts"; |
| 12 | import { nodeLoaderArgs } from "../../Util/Node.ts"; |
| 13 | import { DEV_RELOAD_EXIT_CODE, DevOptions } from "../DevOptions.ts"; |
| 14 | import { |
| 15 | configPath, |
| 16 | envFile, |
| 17 | force, |
| 18 | include, |
| 19 | exclude, |
| 20 | devStage, |
| 21 | optionalConfig, |
| 22 | profile, |
| 23 | resolveStackArgs, |
| 24 | } from "./flags.ts"; |
| 25 | import { 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 | |
| 39 | export 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")); |