1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import { FileSystem } from "effect/FileSystem";
4import * as Option from "effect/Option";
5import type { Path } from "effect/Path";
6import type { Stdio } from "effect/Stdio";
7import type { Terminal } from "effect/Terminal";
8import type { ChildProcessSpawner } from "effect/process/ChildProcessSpawner";
9import type { HttpEffect } from "../Http.ts";
10import * as Http from "../Http.ts";
11import * as Output from "../Output.ts";
12import { ManagedHttpShutdown } from "../Runtime/Bootstrap/ManagedHttpShutdown.ts";
13import {
14 packEnvValue,
15 unpackEnvValue,
16 type BaseRuntimeContext,
17} from "../RuntimeContext.ts";
18
19export type ProcessServices =
20 | ChildProcessSpawner
21 | FileSystem
22 | Path
23 | Stdio
24 | Terminal;
25
26export interface ProcessContext extends BaseRuntimeContext {
27 run: <Req = never, RunReq = never>(
28 effect: Effect.Effect<void, never, RunReq>,
29 ) => Effect.Effect<void, never, Req | RunReq>;
30}
31
32/**
33 * Long-running host loop registration (`run`). Provided by `Platform` when the
34 * execution context implements {@link ProcessContext} (i.e. carries `run`).
35 *
36 * `Platform` wires this automatically for every host runtime context that
37 * implements `run` (EC2 instances, ECS tasks, processes), so an inline program
38 * can `yield* ServerHost` and call `host.run(...)` during plan/deploy without
39 * the caller providing the layer itself.
40 */
41export class ServerHost extends Context.Service<
42 ServerHost,
43 Pick<ProcessContext, "run">
44>()("Alchemy::ServerHost") {}
45
46/**
47 * Deploy-time / plan-time host context for platforms that bundle a long-lived
48 * program. It collects background work registered via `run` and HTTP handlers
49 * registered via `serve` into a single `exports.program` effect that the
50 * generated container/instance entrypoint runs.
51 */
52export interface HostRuntimeContext extends ProcessContext {
53 serve: <Req = never>(
54 handler: HttpEffect<Req> | Effect.Effect<HttpEffect<Req>>,
55 options?: { shape?: Record<string, unknown> },
56 ) => Effect.Effect<void, never, Req>;
57 exports: Effect.Effect<{
58 readonly program: Effect.Effect<void, never, any>;
59 }>;
60}
61
62/**
63 * Build a {@link HostRuntimeContext} for a hosted platform of the given
64 * resource `type`. Both `run` (background loops) and `serve` (HTTP handlers)
65 * append to a single list of runners; `exports.program` runs them all
66 * concurrently. This is the shared host context used by `AWS.EC2.Instance` and
67 * `AWS.ECS.Task`. Managed Fly shutdown gives each runner a sequential resource
68 * scope; runner scopes close concurrently while HTTP and shared dependencies live.
69 * A shutdown signal starts an independent deadline, including for application-owned
70 * scopes. Without a signal, a nested `Effect.scoped` runner must finish its own
71 * finalizers before the host can observe completion or failure. Applications must
72 * bound that cleanup; the host cannot observe an opaque scope's earlier body exit.
73 */
74export const createHostRuntimeContext =
75 (type: string) =>
76 (id: string): HostRuntimeContext => {
77 const runners: Effect.Effect<void, never, any>[] = [];
78 const env: Record<string, any> = {};
79
80 return {
81 Type: type,
82 id,
83 env,
84 set: (bindingId: string, output: Output.Output) =>
85 Effect.sync(() => {
86 const key = bindingId.replaceAll(/[^a-zA-Z0-9]/g, "_");
87 // `packEnvValue` marker-packs Redacted values so they survive the
88 // Output → env round-trip.
89 env[key] = output.pipe(Output.map(packEnvValue));
90 return key;
91 }),
92 get: <T>(key: string) =>
93 // Read straight from `process.env` — see `unpackEnvValue` for why
94 // this must never resolve through `Config.String`.
95 Effect.sync(() => unpackEnvValue<T>(process.env[key]) as T),
96 run: (effect: Effect.Effect<void, never, any>) =>
97 Effect.sync(() => {
98 runners.push(effect);
99 }),
100 serve: ((handler) =>
101 Effect.sync(() => {
102 // Register the HTTP handler as a runner. At container runtime the
103 // ambient `HttpServer` (if provided) serves it; `Http.serve` is a
104 // no-op when no server is bound, so this never crashes plan/deploy.
105 runners.push(Http.serve(handler as HttpEffect<any>));
106 })) as HostRuntimeContext["serve"],
107 exports: Effect.sync(() => ({
108 program: Effect.serviceOption(ManagedHttpShutdown).pipe(
109 Effect.flatMap((managed) => {
110 let remaining = runners.length;
111 return Effect.all(
112 Option.isSome(managed)
113 ? runners.map((runner) =>
114 runner.pipe(
115 Effect.onExit((exit) =>
116 Effect.sync(() =>
117 managed.value.runnerFinished(exit, --remaining === 0),
118 ),
119 ),
120 Effect.scoped,
121 // Parent interruption may discard child finalizer defects.
122 Effect.onExit((exit) =>
123 Effect.sync(() =>
124 managed.value.runnerFinished(exit, false),
125 ),
126 ),
127 ),
128 )
129 : runners,
130 { concurrency: "unbounded" },
131 );
132 }),
133 ),
134 })),
135 } satisfies HostRuntimeContext;
136 };
137
138/**
139 * Host runtime context for container platforms (`AWS.ECS.Task`,
140 * `AWS.ECS.Service`, `Docker.Service`): extends the shared process host
141 * context so an impl shape's `run` effect is registered as a one-shot runner
142 * (the container exits when it completes) and the HTTP server only boots when
143 * the impl actually declares a `fetch` handler.
144 */
145export const createContainerRuntimeContext =
146 (type: string) =>
147 (id: string): HostRuntimeContext => {
148 const base = createHostRuntimeContext(type)(id);
149 // Capture the host serve BEFORE Object.assign overwrites `base.serve`
150 // with the wrapper below — calling `base.serve` inside the wrapper would
151 // resolve to the wrapper itself (property lookup happens at call time)
152 // and recurse without bound the moment an impl declares `fetch`.
153 const serveBase = base.serve;
154 const serve: HostRuntimeContext["serve"] = (handler, options) =>
155 Effect.gen(function* () {
156 const shape = options?.shape;
157 const run = shape?.run;
158 if (Effect.isEffect(run)) {
159 yield* base.run(run as Effect.Effect<void, never, any>);
160 }
161 // Boot the HTTP server only for an impl that declared `fetch` — a
162 // pure one-shot `{ run }` program must exit when `run` completes
163 // rather than parking behind the 404 fallback server forever.
164 if (shape === undefined || shape.fetch !== undefined) {
165 yield* serveBase(handler, options);
166 }
167 }) as Effect.Effect<void, never, never>;
168 return Object.assign(base, { serve });
169 };