| 1 | import * as Context from "effect/Context"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import { FileSystem } from "effect/FileSystem"; |
| 4 | import * as Option from "effect/Option"; |
| 5 | import type { Path } from "effect/Path"; |
| 6 | import type { Stdio } from "effect/Stdio"; |
| 7 | import type { Terminal } from "effect/Terminal"; |
| 8 | import type { ChildProcessSpawner } from "effect/process/ChildProcessSpawner"; |
| 9 | import type { HttpEffect } from "../Http.ts"; |
| 10 | import * as Http from "../Http.ts"; |
| 11 | import * as Output from "../Output.ts"; |
| 12 | import { ManagedHttpShutdown } from "../Runtime/Bootstrap/ManagedHttpShutdown.ts"; |
| 13 | import { |
| 14 | packEnvValue, |
| 15 | unpackEnvValue, |
| 16 | type BaseRuntimeContext, |
| 17 | } from "../RuntimeContext.ts"; |
| 18 | |
| 19 | export type ProcessServices = |
| 20 | | ChildProcessSpawner |
| 21 | | FileSystem |
| 22 | | Path |
| 23 | | Stdio |
| 24 | | Terminal; |
| 25 | |
| 26 | export 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 | */ |
| 41 | export 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 | */ |
| 52 | export 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 | */ |
| 74 | export 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 | */ |
| 145 | export 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 | }; |