| 1 | import * as Cause from "effect/Cause"; |
| 2 | import * as Config from "effect/Config"; |
| 3 | import * as Context from "effect/Context"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as ErrorReporter from "effect/ErrorReporter"; |
| 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as Option from "effect/Option"; |
| 8 | import * as Scope from "effect/Scope"; |
| 9 | import type { HttpBodyError } from "effect/http/HttpBody"; |
| 10 | import { |
| 11 | causeResponse, |
| 12 | type HttpServerError, |
| 13 | } from "effect/http/HttpServerError"; |
| 14 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 15 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 16 | import { ManagedHttpShutdown } from "./Runtime/Bootstrap/ManagedHttpShutdown.ts"; |
| 17 | |
| 18 | export type HttpEffect<Req = never> = Effect.Effect< |
| 19 | HttpServerResponse.HttpServerResponse, |
| 20 | HttpServerError | HttpBodyError, |
| 21 | HttpServerRequest | Scope.Scope | Req |
| 22 | >; |
| 23 | |
| 24 | /** |
| 25 | * `effect`'s HttpEffect brands a request scope as "ejected" when ownership |
| 26 | * is transferred to a consumer that outlives the handler's return — a |
| 27 | * streaming response body, a WebSocket upgrade, an RPC stream. Bridges check |
| 28 | * this before their close-on-return path: an ejected scope is closed by its |
| 29 | * new owner when it finishes, not by the bridge. |
| 30 | */ |
| 31 | const scopeEjected = Symbol.for("effect/http/HttpEffect/scopeEjected"); |
| 32 | |
| 33 | export const isScopeEjected = (scope: Scope.Scope) => scopeEjected in scope; |
| 34 | |
| 35 | export const serve = <Req = never>( |
| 36 | handler: Effect.Effect< |
| 37 | HttpServerResponse.HttpServerResponse, |
| 38 | HttpServerError | HttpBodyError, |
| 39 | HttpServerRequest | Scope.Scope | Req |
| 40 | >, |
| 41 | ) => |
| 42 | Effect.serviceOption(HttpServer).pipe( |
| 43 | Effect.map(Option.getOrUndefined), |
| 44 | Effect.flatMap((http) => |
| 45 | http |
| 46 | ? // `HttpServer.serve` registers the server on the ambient Scope and |
| 47 | // RETURNS; the server lives until that scope closes. A host program |
| 48 | // (ECS task/service, EC2 instance) must therefore park forever after |
| 49 | // a successful registration — otherwise a pure `{ fetch }` program |
| 50 | // completes immediately, `Effect.scoped` closes the scope, and the |
| 51 | // container exits 0 in a crash-loop. (One-shot `{ run }` programs |
| 52 | // never call `serve`, so they still exit when `run` completes.) |
| 53 | Effect.andThen(http.serve(handler), Effect.never) |
| 54 | : Effect.void, |
| 55 | ), |
| 56 | ); |
| 57 | |
| 58 | export class HttpServer extends Context.Service< |
| 59 | HttpServer, |
| 60 | { |
| 61 | serve: <Req = never>( |
| 62 | handler: Effect.Effect< |
| 63 | HttpServerResponse.HttpServerResponse, |
| 64 | HttpServerError | HttpBodyError, |
| 65 | Req |
| 66 | >, |
| 67 | options?: { |
| 68 | port?: number; |
| 69 | }, |
| 70 | ) => Effect.Effect< |
| 71 | void, |
| 72 | never, |
| 73 | Exclude<Req, HttpServerRequest> | Scope.Scope |
| 74 | >; |
| 75 | } |
| 76 | >()("HttpServer") {} |
| 77 | |
| 78 | export const safeHttpEffect = <Req = never>( |
| 79 | handler: HttpEffect<Req> | Effect.Effect<HttpEffect<Req>>, |
| 80 | ): Effect.Effect< |
| 81 | HttpServerResponse.HttpServerResponse, |
| 82 | never, |
| 83 | Req | HttpServerRequest | Scope.Scope |
| 84 | > => |
| 85 | Effect.catchCause( |
| 86 | handler.pipe( |
| 87 | // @ts-expect-error |
| 88 | Effect.flatMap((response) => |
| 89 | HttpServerResponse.isHttpServerResponse(response) |
| 90 | ? Effect.succeed(response) |
| 91 | : response, |
| 92 | ), |
| 93 | ) as any as HttpEffect<Req>, |
| 94 | (cause) => |
| 95 | // `causeResponse` is effect's native failure boundary: Respondable |
| 96 | // failures keep their intended response (e.g. RouteNotFound -> 404), |
| 97 | // client aborts map to 499, and everything else becomes an empty 500 — |
| 98 | // the cause is never echoed to the network, as it can contain sensitive |
| 99 | // data (prompt contents, API keys baked into error messages, internal |
| 100 | // file paths). |
| 101 | causeResponse(cause).pipe( |
| 102 | Effect.flatMap(([response, reportableCause]) => |
| 103 | Effect.withFiber((fiber) => |
| 104 | fiber.getRef(ErrorReporter.CurrentErrorReporters).size > 0 |
| 105 | ? ErrorReporter.report(reportableCause) |
| 106 | : logUnreportedCause(reportableCause), |
| 107 | ).pipe(Effect.as(response)), |
| 108 | ), |
| 109 | ), |
| 110 | ); |
| 111 | |
| 112 | /** |
| 113 | * No `ErrorReporter` is registered by default, so without a fallback a defect |
| 114 | * in a deployed Function/Worker would produce a bare 500 and vanish without a |
| 115 | * trace. Log the cause server-side so operators can debug, applying the same |
| 116 | * filtering `ErrorReporter.make` reporters do: interrupts (client aborts) and |
| 117 | * `ErrorReporter.ignore`-annotated values (Respondable errors like |
| 118 | * RouteNotFound, and the response `causeResponse` appends) are not failures |
| 119 | * and are skipped. |
| 120 | */ |
| 121 | const logUnreportedCause = (cause: Cause.Cause<unknown>) => { |
| 122 | const failures = cause.reasons.filter( |
| 123 | (reason) => |
| 124 | reason._tag !== "Interrupt" && |
| 125 | !ErrorReporter.isIgnored( |
| 126 | reason._tag === "Fail" ? reason.error : reason.defect, |
| 127 | ), |
| 128 | ); |
| 129 | return failures.length === 0 |
| 130 | ? Effect.void |
| 131 | : Effect.logError("HTTP handler failed", Cause.fromReasons(failures)); |
| 132 | }; |
| 133 | |
| 134 | export const resolvePort = (options: { port?: number } | undefined) => |
| 135 | options?.port !== undefined |
| 136 | ? Effect.succeed(options.port) |
| 137 | : Config.Number("PORT").pipe(Config.withDefault(3000)); |
| 138 | |
| 139 | export interface BunHttpServerOptions { |
| 140 | /** |
| 141 | * Network interface on which the Bun HTTP server listens. |
| 142 | * |
| 143 | * Always passed explicitly to `Bun.serve` so container bootstraps bind a |
| 144 | * predictable IPv4 wildcard regardless of the platform default |
| 145 | * (`@effect/platform-bun` ≥ 4.0.0-rc.115 defaults to `::`; rc.113/114 |
| 146 | * crash-looped on the omitted-hostname case). |
| 147 | * |
| 148 | * @default "0.0.0.0" |
| 149 | */ |
| 150 | hostname?: string; |
| 151 | } |
| 152 | |
| 153 | export const BunHttpServer = (serverOptions?: BunHttpServerOptions) => |
| 154 | Layer.effect( |
| 155 | HttpServer, |
| 156 | Effect.gen(function* () { |
| 157 | const BunHttpServerPlatform = yield* Effect.promise( |
| 158 | () => import("@effect/platform-bun/BunHttpServer"), |
| 159 | ); |
| 160 | return { |
| 161 | serve: (handler, options) => |
| 162 | Effect.gen(function* () { |
| 163 | const port = yield* resolvePort(options); |
| 164 | const server = yield* BunHttpServerPlatform.make({ |
| 165 | port, |
| 166 | hostname: serverOptions?.hostname ?? "0.0.0.0", |
| 167 | }); |
| 168 | yield* server.serve(safeHttpEffect(handler)); |
| 169 | }).pipe(Effect.orDie), |
| 170 | }; |
| 171 | }), |
| 172 | ); |
| 173 | |
| 174 | export interface NodeHttpServerOptions { |
| 175 | /** |
| 176 | * Address the Node HTTP server binds. Fly / Hetzner / Railway machines |
| 177 | * need `0.0.0.0`; omit to use that default. |
| 178 | * @default "0.0.0.0" |
| 179 | */ |
| 180 | hostname?: string; |
| 181 | } |
| 182 | |
| 183 | export const NodeHttpServer = (serverOptions?: NodeHttpServerOptions) => |
| 184 | Layer.effect( |
| 185 | HttpServer, |
| 186 | Effect.gen(function* () { |
| 187 | const NodeHttpServerPlatform = yield* Effect.promise( |
| 188 | () => import("@effect/platform-node/NodeHttpServer"), |
| 189 | ); |
| 190 | const NodeHttp = yield* Effect.promise(() => import("node:http")); |
| 191 | return { |
| 192 | serve: (handler, options) => |
| 193 | Effect.gen(function* () { |
| 194 | const port = yield* resolvePort(options); |
| 195 | const shutdown = yield* Effect.serviceOption(ManagedHttpShutdown); |
| 196 | const managed = Option.getOrUndefined(shutdown); |
| 197 | if (managed?.isStopping()) return yield* Effect.interrupt; |
| 198 | const serve = Effect.gen(function* () { |
| 199 | const server = yield* NodeHttpServerPlatform.make( |
| 200 | () => { |
| 201 | const server = NodeHttp.createServer(); |
| 202 | managed?.servers.add(server); |
| 203 | server.once("close", () => managed?.servers.delete(server)); |
| 204 | return server; |
| 205 | }, |
| 206 | { |
| 207 | port, |
| 208 | host: serverOptions?.hostname ?? "0.0.0.0", |
| 209 | gracefulShutdownTimeout: managed?.drainTimeoutMs, |
| 210 | }, |
| 211 | ); |
| 212 | // The Node bridge masks interruption, including streaming writes. |
| 213 | yield* managed |
| 214 | ? server.serve(safeHttpEffect(handler), (handled) => |
| 215 | Effect.withFiber((fiber) => { |
| 216 | // Observe outside the adapter's request-scope finalization. |
| 217 | managed.observeRequest(fiber); |
| 218 | return Effect.interruptible(handled); |
| 219 | }), |
| 220 | ) |
| 221 | : server.serve(safeHttpEffect(handler)); |
| 222 | }); |
| 223 | if (managed) { |
| 224 | const scope = yield* Scope.fork(managed.scope); |
| 225 | yield* serve.pipe(Scope.provide(scope)); |
| 226 | } else { |
| 227 | yield* serve; |
| 228 | } |
| 229 | }).pipe(Effect.orDie), |
| 230 | }; |
| 231 | }), |
| 232 | ); |