1import type { Crypto } from "effect/Crypto";
2import type { Input as DurationInput } from "effect/Duration";
3import * as Effect from "effect/Effect";
4import type { FileSystem } from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import type { Path } from "effect/Path";
7import { defaultTeardown, type Teardown } from "effect/Runtime";
8import type { Stdio } from "effect/Stdio";
9import type { Terminal } from "effect/Terminal";
10import type { HttpServer } from "effect/http/HttpServer";
11import type { ServeError } from "effect/http/HttpServerError";
12import type { ChildProcessSpawner } from "effect/process/ChildProcessSpawner";
13import type { WebSocketConstructor } from "effect/socket/Socket";
14import { disableCrossSpawnChdir } from "./Node.ts";
15
16const isBun = typeof Bun !== "undefined";
17
18const 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 */
36export 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
50export 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
60export 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 */
101const 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
109export 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
137export 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 });