1import * as Cause from "effect/Cause";
2import * as Config from "effect/Config";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as ErrorReporter from "effect/ErrorReporter";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as Scope from "effect/Scope";
9import type { HttpBodyError } from "effect/http/HttpBody";
10import {
11 causeResponse,
12 type HttpServerError,
13} from "effect/http/HttpServerError";
14import { HttpServerRequest } from "effect/http/HttpServerRequest";
15import * as HttpServerResponse from "effect/http/HttpServerResponse";
16import { ManagedHttpShutdown } from "./Runtime/Bootstrap/ManagedHttpShutdown.ts";
17
18export 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 */
31const scopeEjected = Symbol.for("effect/http/HttpEffect/scopeEjected");
32
33export const isScopeEjected = (scope: Scope.Scope) => scopeEjected in scope;
34
35export 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
58export 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
78export 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 */
121const 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
134export const resolvePort = (options: { port?: number } | undefined) =>
135 options?.port !== undefined
136 ? Effect.succeed(options.port)
137 : Config.Number("PORT").pipe(Config.withDefault(3000));
138
139export 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
153export 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
174export 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
183export 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 );