| 1 | import type { Crypto } from "effect/Crypto"; |
| 2 | import type { Input as DurationInput } from "effect/Duration"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import type { FileSystem } from "effect/FileSystem"; |
| 5 | import * as Layer from "effect/Layer"; |
| 6 | import type { Path } from "effect/Path"; |
| 7 | import { defaultTeardown, type Teardown } from "effect/Runtime"; |
| 8 | import type { Stdio } from "effect/Stdio"; |
| 9 | import type { Terminal } from "effect/Terminal"; |
| 10 | import type { HttpServer } from "effect/http/HttpServer"; |
| 11 | import type { ServeError } from "effect/http/HttpServerError"; |
| 12 | import type { ChildProcessSpawner } from "effect/process/ChildProcessSpawner"; |
| 13 | import type { WebSocketConstructor } from "effect/socket/Socket"; |
| 14 | import { disableCrossSpawnChdir } from "./Node.ts"; |
| 15 | |
| 16 | const isBun = typeof Bun !== "undefined"; |
| 17 | |
| 18 | const importPlatformPeer = <A>( |
| 19 | peerDependency: "@effect/platform-bun" | "@effect/platform-node", |
| 20 | load: () => Promise<A>, |
| 21 | ): Promise<A> => |
| 22 | load().catch(() => { |
| 23 | console.error( |
| 24 | [ |
| 25 | `Alchemy could not load the required peer dependency "${peerDependency}".`, |
| 26 | `Install a compatible version of "${peerDependency}" in the same project as "alchemy".`, |
| 27 | "If it is already installed, you may be running a globally installed Alchemy CLI; run the project-local CLI instead.", |
| 28 | ].join("\n"), |
| 29 | ); |
| 30 | process.exit(1); |
| 31 | }); |
| 32 | |
| 33 | /** |
| 34 | * Constructs a layer with different implementations for Bun and Node. |
| 35 | */ |
| 36 | export const platformLayer = <A, E, R>(constructors: { |
| 37 | bun: () => Promise<Layer.Layer<A, E, R>>; |
| 38 | node: () => Promise<Layer.Layer<A, E, R>>; |
| 39 | }) => |
| 40 | Layer.unwrap( |
| 41 | Effect.promise(async () => { |
| 42 | if (isBun) { |
| 43 | return await constructors.bun(); |
| 44 | } else { |
| 45 | return await constructors.node(); |
| 46 | } |
| 47 | }), |
| 48 | ); |
| 49 | |
| 50 | export type PlatformServices = |
| 51 | | ChildProcessSpawner |
| 52 | | Crypto |
| 53 | | FileSystem |
| 54 | | Path |
| 55 | | Stdio |
| 56 | | Terminal |
| 57 | // WebSocketConstructor is not included in NodeServices/BunServices, but required for Workers tail |
| 58 | | WebSocketConstructor; |
| 59 | |
| 60 | export const PlatformServices: Layer.Layer<PlatformServices> = platformLayer({ |
| 61 | bun: async () => { |
| 62 | const [BunServices, BunSocket] = await Promise.all([ |
| 63 | importPlatformPeer( |
| 64 | "@effect/platform-bun", |
| 65 | () => import("@effect/platform-bun/BunServices"), |
| 66 | ), |
| 67 | importPlatformPeer( |
| 68 | "@effect/platform-bun", |
| 69 | () => import("@effect/platform-bun/BunSocket"), |
| 70 | ), |
| 71 | ]); |
| 72 | return Layer.merge(BunServices.layer, BunSocket.layerWebSocketConstructor); |
| 73 | }, |
| 74 | node: async () => { |
| 75 | const [NodeServices, NodeSocket] = await Promise.all([ |
| 76 | importPlatformPeer( |
| 77 | "@effect/platform-node", |
| 78 | () => import("@effect/platform-node/NodeServices"), |
| 79 | ), |
| 80 | importPlatformPeer( |
| 81 | "@effect/platform-node", |
| 82 | () => import("@effect/platform-node/NodeSocket"), |
| 83 | ), |
| 84 | ]); |
| 85 | return Layer.merge( |
| 86 | NodeServices.layer, |
| 87 | NodeSocket.layerWebSocketConstructor, |
| 88 | ); |
| 89 | }, |
| 90 | }); |
| 91 | |
| 92 | /** |
| 93 | * ALWAYS exit once the main effect completes. The platform `runMain` default |
| 94 | * only force-exits on failure or signal interruption — a *successful* |
| 95 | * `alchemy deploy`/`destroy` otherwise lingers for ~100 seconds after "Done" |
| 96 | * while keep-alive sockets (Cloudflare API agents, provider sidecar handles) |
| 97 | * pin the event loop until the remote side closes them. All Effect finalizers |
| 98 | * have already run by the time teardown is invoked; the macrotask hop lets |
| 99 | * any buffered stdout drain before the process exits. |
| 100 | */ |
| 101 | const exitingTeardown: Teardown = (exit, onExit) => { |
| 102 | defaultTeardown(exit, (code) => { |
| 103 | const finalCode = code !== 0 ? code : Number(process.exitCode ?? 0) || 0; |
| 104 | setTimeout(() => process.exit(finalCode), 0); |
| 105 | onExit(finalCode); |
| 106 | }); |
| 107 | }; |
| 108 | |
| 109 | export const runMain = <E, A>( |
| 110 | effect: Effect.Effect<A, E>, |
| 111 | options?: { |
| 112 | readonly disableErrorReporting?: boolean | undefined; |
| 113 | readonly teardown?: Teardown | undefined; |
| 114 | }, |
| 115 | ): void => { |
| 116 | // Every alchemy process (CLI, exec child, sidecar, build/dev runners) |
| 117 | // runs concurrent effects that depend on a stable working directory — |
| 118 | // forbid cross-spawn's transient process-wide chdir (see Util/Node.ts). |
| 119 | disableCrossSpawnChdir(); |
| 120 | const opts = { |
| 121 | ...options, |
| 122 | teardown: options?.teardown ?? exitingTeardown, |
| 123 | }; |
| 124 | if (isBun) { |
| 125 | void importPlatformPeer( |
| 126 | "@effect/platform-bun", |
| 127 | () => import("@effect/platform-bun/BunRuntime"), |
| 128 | ).then((BunRuntime) => BunRuntime.runMain(effect, opts)); |
| 129 | } else { |
| 130 | void importPlatformPeer( |
| 131 | "@effect/platform-node", |
| 132 | () => import("@effect/platform-node/NodeRuntime"), |
| 133 | ).then((NodeRuntime) => NodeRuntime.runMain(effect, opts)); |
| 134 | } |
| 135 | }; |
| 136 | |
| 137 | export const httpServer = ( |
| 138 | port: number = 0, |
| 139 | host: string = "127.0.0.1", |
| 140 | options?: { |
| 141 | readonly gracefulShutdownTimeout?: DurationInput | undefined; |
| 142 | }, |
| 143 | ): Layer.Layer<HttpServer, ServeError> => |
| 144 | platformLayer({ |
| 145 | bun: async () => { |
| 146 | const BunHttpServer = await importPlatformPeer( |
| 147 | "@effect/platform-bun", |
| 148 | () => import("@effect/platform-bun/BunHttpServer"), |
| 149 | ); |
| 150 | // `idleTimeout: 0` disables Bun's default 10s request idle timeout. |
| 151 | // The RPC spawner serves a long-lived `/logs` stream (sidecar output |
| 152 | // forwarded to the exec child's renderer) that can legitimately sit |
| 153 | // idle between lines; Bun would otherwise sever it mid-session. |
| 154 | return BunHttpServer.layer({ hostname: host, port, idleTimeout: 0 }); |
| 155 | }, |
| 156 | node: async () => { |
| 157 | const [NodeHttpServer, Http] = await Promise.all([ |
| 158 | importPlatformPeer( |
| 159 | "@effect/platform-node", |
| 160 | () => import("@effect/platform-node/NodeHttpServer"), |
| 161 | ), |
| 162 | import("node:http"), |
| 163 | ]); |
| 164 | return NodeHttpServer.layerServer(Http.createServer, { |
| 165 | host, |
| 166 | port, |
| 167 | gracefulShutdownTimeout: options?.gracefulShutdownTimeout, |
| 168 | }); |
| 169 | }, |
| 170 | }); |