/** * Node.js implementation of the Effect `HttpServer`. * * This module adapts a supplied Node `http.Server` into Effect's * platform-independent HTTP server service. It starts the server with Node * `listen` options, converts `request` events into `HttpServerRequest` values, * writes `HttpServerResponse` bodies through Node's `ServerResponse`, and * handles `upgrade` events by exposing the upgraded socket through * `HttpServerRequest.upgrade`. It also exports request and upgrade handler * constructors plus layers for the server alone, HTTP support services, the * combined server, configurable options, and tests. * * @since 4.0.0 */ import * as Cause from "effect/Cause" import * as Config from "effect/Config" import * as Context from "effect/Context" import * as Duration from "effect/Duration" import * as Effect from "effect/Effect" import * as Fiber from "effect/Fiber" import type * as FileSystem from "effect/FileSystem" import { flow, type LazyArg } from "effect/Function" import * as Latch from "effect/Latch" import * as Layer from "effect/Layer" import type * as Option from "effect/Option" import type * as Path from "effect/Path" import type * as Record from "effect/Record" import * as Scope from "effect/Scope" import * as Stream from "effect/Stream" import * as Cookies from "effect/unstable/http/Cookies" import * as Etag from "effect/unstable/http/Etag" import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient" import type * as Headers from "effect/unstable/http/Headers" import type { HttpClient } from "effect/unstable/http/HttpClient" import * as HttpEffect from "effect/unstable/http/HttpEffect" import * as HttpIncomingMessage from "effect/unstable/http/HttpIncomingMessage" import type { HttpMethod } from "effect/unstable/http/HttpMethod" import type * as Middleware from "effect/unstable/http/HttpMiddleware" import type * as HttpPlatform from "effect/unstable/http/HttpPlatform" import * as HttpServer from "effect/unstable/http/HttpServer" import { causeResponse, ClientAbort, HttpServerError, RequestParseError, ResponseError, ServeError } from "effect/unstable/http/HttpServerError" import * as Request from "effect/unstable/http/HttpServerRequest" import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest" import type { HttpServerResponse } from "effect/unstable/http/HttpServerResponse" import type * as Multipart from "effect/unstable/http/Multipart" import * as Socket from "effect/unstable/socket/Socket" import * as Http from "node:http" import type * as Net from "node:net" import type { Duplex } from "node:stream" import { Readable } from "node:stream" import { pipeline } from "node:stream/promises" import { NodeHttpIncomingMessage } from "./NodeHttpIncomingMessage.ts" import * as NodeHttpPlatform from "./NodeHttpPlatform.ts" import * as NodeMultipart from "./NodeMultipart.ts" import * as NodeServices from "./NodeServices.ts" import { NodeWS } from "./NodeSocket.ts" /** * Creates a scoped `HttpServer` from a Node `http.Server`, starts listening * with the supplied options, registers request and upgrade handling, and closes * the server during scope finalization with optional graceful-shutdown control. * * @category constructors * @since 4.0.0 */ export const make = Effect.fnUntraced(function*( evaluate: LazyArg, options: Net.ListenOptions & { readonly disablePreemptiveShutdown?: boolean | undefined readonly gracefulShutdownTimeout?: Duration.Input | undefined } ) { const scope = yield* Effect.scope const server = evaluate() const shutdown = yield* Effect.callback((resume) => { if (!server.listening) { return resume(Effect.void) } server.close((error) => { if (error) { resume(Effect.die(error)) } else { resume(Effect.void) } }) }).pipe(Effect.cached) const preemptiveShutdown = options.disablePreemptiveShutdown ? Effect.void : Effect.timeoutOrElse(shutdown, { duration: options.gracefulShutdownTimeout ?? Duration.seconds(20), orElse: () => Effect.void }) yield* Scope.addFinalizer(scope, shutdown) yield* Effect.callback((resume) => { function onError(cause: Error) { resume(Effect.fail(new ServeError({ cause }))) } server.on("error", onError) server.listen(options, () => { server.off("error", onError) resume(Effect.void) }) }) const address = server.address()! const wss = yield* Effect.acquireRelease( Effect.sync(() => new NodeWS.WebSocketServer({ noServer: true })), (wss) => Effect.callback((resume) => { wss.close(() => resume(Effect.void)) }) ).pipe( Scope.provide(scope), Effect.cached ) return HttpServer.make({ address: typeof address === "string" ? { _tag: "UnixAddress", path: address } : { _tag: "TcpAddress", hostname: address.address === "::" ? "0.0.0.0" : address.address, port: address.port }, serve: Effect.fnUntraced(function*(httpApp, middleware) { const serveScope = yield* Effect.scope const scope = Scope.forkUnsafe(serveScope, "parallel") const handler = yield* (makeHandler(httpApp, { middleware: middleware as any, scope }) as Effect.Effect<(nodeRequest: Http.IncomingMessage, nodeResponse: Http.ServerResponse) => void>) const upgradeHandler = yield* makeUpgradeHandler(wss, httpApp, { middleware: middleware as any, scope }) yield* Scope.addFinalizerExit(serveScope, () => { server.off("request", handler) server.off("upgrade", upgradeHandler) return preemptiveShutdown }) server.on("request", handler) server.on("upgrade", upgradeHandler) }) }) }) /** * Creates a Node `request` event handler for an Effect HTTP application, * injecting a `HttpServerRequest` and interrupting the request fiber if the * client closes the response before it finishes. * * @category handlers * @since 4.0.0 */ export const makeHandler = < R, E, App extends Effect.Effect = Effect.Effect >( httpEffect: Effect.Effect, options: { readonly scope: Scope.Scope readonly middleware?: Middleware.HttpMiddleware.Applied | undefined } ): Effect.Effect< (nodeRequest: Http.IncomingMessage, nodeResponse: Http.ServerResponse) => void, never, Exclude, HttpServerRequest | Scope.Scope> > => { const handled = HttpEffect.toHandled(httpEffect, handleResponse, options.middleware as any) return Effect.withFiber((parent) => { const services = parent.context return Effect.succeed(function handler( nodeRequest: Http.IncomingMessage, nodeResponse: Http.ServerResponse ) { const map = new Map(services.mapUnsafe) map.set(HttpServerRequest.key, new ServerRequestImpl(nodeRequest, nodeResponse)) const fiber = Fiber.runIn(Effect.runForkWith(Context.makeUnsafe(map))(handled), options.scope) nodeResponse.on("close", () => { if (!nodeResponse.writableEnded) { fiber.interruptUnsafe(parent.id, ClientAbort.annotation) } }) }) }) } /** * Creates a Node `upgrade` event handler for an Effect HTTP application, * exposing the upgraded WebSocket as the request's `upgrade` effect and * interrupting the request fiber when the socket closes early. * * @category handlers * @since 4.0.0 */ export const makeUpgradeHandler = < R, E, App extends Effect.Effect = Effect.Effect >( lazyWss: Effect.Effect, httpEffect: Effect.Effect, options: { readonly scope: Scope.Scope readonly middleware?: Middleware.HttpMiddleware.Applied | undefined } ): Effect.Effect< (nodeRequest: Http.IncomingMessage, socket: Duplex, head: Buffer) => void, never, Exclude, HttpServerRequest | Scope.Scope> > => { const handledApp = HttpEffect.toHandled(httpEffect, handleResponse, options.middleware as any) return Effect.withFiber((parent) => { const services = parent.context return Effect.succeed(function handler( nodeRequest: Http.IncomingMessage, socket: Duplex, head: Buffer ) { let nodeResponse_: Http.ServerResponse | undefined = undefined const nodeResponse = () => { if (nodeResponse_ === undefined) { nodeResponse_ = new Http.ServerResponse(nodeRequest) nodeResponse_.assignSocket(socket as any) nodeResponse_.on("finish", () => { socket.end() }) } return nodeResponse_ } const upgradeEffect = Socket.fromWebSocket(Effect.flatMap( lazyWss, (wss) => Effect.acquireRelease( Effect.callback((resume) => wss.handleUpgrade(nodeRequest, socket, head, (ws) => { resume(Effect.succeed(ws as any)) }) ), (ws) => Effect.sync(() => ws.close()) ) )) const map = new Map(services.mapUnsafe) map.set(HttpServerRequest.key, new ServerRequestImpl(nodeRequest, nodeResponse, upgradeEffect)) const fiber = Fiber.runIn(Effect.runForkWith(Context.makeUnsafe(map))(handledApp), options.scope) socket.on("close", () => { if (!socket.writableEnded) { fiber.interruptUnsafe(parent.id, ClientAbort.annotation) } }) }) }) } class ServerRequestImpl extends NodeHttpIncomingMessage implements HttpServerRequest { readonly [Request.TypeId]: typeof Request.TypeId readonly response: Http.ServerResponse | LazyArg private upgradeEffect?: Effect.Effect | undefined readonly url: string private headersOverride?: Headers.Headers | undefined constructor( source: Http.IncomingMessage, response: Http.ServerResponse | LazyArg, upgradeEffect?: Effect.Effect, url = source.url!, headersOverride?: Headers.Headers, remoteAddressOverride?: Option.Option ) { super(source, (cause) => new HttpServerError({ reason: new RequestParseError({ request: this, cause }) }), remoteAddressOverride) this[Request.TypeId] = Request.TypeId this.response = response this.upgradeEffect = upgradeEffect this.url = url this.headersOverride = headersOverride } private cachedCookies: Record.ReadonlyRecord | undefined get cookies() { if (this.cachedCookies) { return this.cachedCookies } return this.cachedCookies = Cookies.parseHeader(this.headers.cookie ?? "") } get resolvedResponse(): Http.ServerResponse { return typeof this.response === "function" ? this.response() : this.response } modify( options: { readonly url?: string | undefined readonly headers?: Headers.Headers | undefined readonly remoteAddress?: Option.Option | undefined } ) { return new ServerRequestImpl( this.source, this.response, this.upgradeEffect, options.url ?? this.url, options.headers ?? this.headersOverride, "remoteAddress" in options ? options.remoteAddress : this.remoteAddressOverride ) } get originalUrl(): string { return this.source.url! } get method(): HttpMethod { return this.source.method!.toUpperCase() as HttpMethod } override get headers(): Headers.Headers { this.headersOverride ??= this.source.headers as Headers.Headers return this.headersOverride } private multipartEffect: | Effect.Effect< Multipart.Persisted, Multipart.MultipartError, Scope.Scope | FileSystem.FileSystem | Path.Path > | undefined get multipart(): Effect.Effect< Multipart.Persisted, Multipart.MultipartError, Scope.Scope | FileSystem.FileSystem | Path.Path > { if (this.multipartEffect) { return this.multipartEffect } this.multipartEffect = Effect.runSync(Effect.cached( NodeMultipart.persisted(this.source, this.source.headers) )) return this.multipartEffect } get multipartStream(): Stream.Stream { return NodeMultipart.stream(this.source, this.source.headers) } get upgrade(): Effect.Effect { return this.upgradeEffect ?? Effect.fail( new HttpServerError({ reason: new RequestParseError({ request: this, description: "not an upgradeable ServerRequest" }) }) ) } override toString(): string { return `ServerRequest(${this.method} ${this.url})` } toJSON(): unknown { return HttpIncomingMessage.inspect(this, { _id: "HttpServerRequest", method: this.method, url: this.originalUrl }) } } /** * Provides an `HttpServer` by creating and managing a scoped Node * `http.Server` with the supplied listen and shutdown options. * * @category layers * @since 4.0.0 */ export const layerServer: ( evaluate: LazyArg>, options: Net.ListenOptions & { readonly disablePreemptiveShutdown?: boolean | undefined readonly gracefulShutdownTimeout?: Duration.Input | undefined } ) => Layer.Layer = flow(make, Layer.effect(HttpServer.HttpServer)) /** * Provides the Node HTTP support services used by `NodeHttpServer`, including * the HTTP platform, ETag generator, and core Node platform services. * * @category layers * @since 4.0.0 */ export const layerHttpServices: Layer.Layer< NodeServices.NodeServices | HttpPlatform.HttpPlatform | Etag.Generator > = Layer.mergeAll( NodeHttpPlatform.layer, Etag.layerWeak, NodeServices.layer ) /** * Provides a Node `HttpServer` together with the Node HTTP platform, ETag, and * core platform services required to serve requests. * * @category layers * @since 4.0.0 */ export const layer = ( evaluate: LazyArg, options: Net.ListenOptions & { readonly disablePreemptiveShutdown?: boolean | undefined readonly gracefulShutdownTimeout?: Duration.Input | undefined } ): Layer.Layer< HttpServer.HttpServer | NodeServices.NodeServices | HttpPlatform.HttpPlatform | Etag.Generator, ServeError > => Layer.mergeAll( layerServer(evaluate, options), layerHttpServices ) /** * Provides a Node `HttpServer` together with the Node HTTP platform, ETag, * and core Node platform services, reading the listen and shutdown options from * a `Config` value. * * @category layers * @since 4.0.0 */ export const layerConfig = ( evaluate: LazyArg, options: Config.Wrap< Net.ListenOptions & { readonly disablePreemptiveShutdown?: boolean | undefined readonly gracefulShutdownTimeout?: Duration.Input | undefined } > ): Layer.Layer< HttpServer.HttpServer | NodeServices.NodeServices | HttpPlatform.HttpPlatform | Etag.Generator, ServeError | Config.ConfigError > => Layer.mergeAll( Layer.effect(HttpServer.HttpServer)( Effect.flatMap(Config.unwrap(options), (options) => make(evaluate, options)) ), layerHttpServices ) /** * Provides a test HTTP server listening on an ephemeral port together with a * Fetch-backed `HttpClient` configured for server integration tests. * * @category testing * @since 4.0.0 */ export const layerTest: Layer.Layer< | HttpServer.HttpServer | FileSystem.FileSystem | Path.Path | HttpPlatform.HttpPlatform | Etag.Generator | HttpClient, ServeError, never > = HttpServer.layerTestClient.pipe( Layer.provide( Layer.fresh(FetchHttpClient.layer).pipe( Layer.provide(Layer.succeed(FetchHttpClient.RequestInit)({ keepalive: false })) ) ), Layer.provideMerge(layer(Http.createServer, { port: 0 })) ) // ----------------------------------------------------------------------------- // Internal // ----------------------------------------------------------------------------- const handleResponse = ( request: HttpServerRequest, response: HttpServerResponse ): Effect.Effect => { const nodeResponse = (request as ServerRequestImpl).resolvedResponse if (nodeResponse.writableEnded) { return Effect.void } let headers: Record> = response.headers if (!Cookies.isEmpty(response.cookies)) { headers = { ...headers } const toSet = Cookies.toSetCookieHeaders(response.cookies) if (headers["set-cookie"] !== undefined) { toSet.push(headers["set-cookie"] as string) } headers["set-cookie"] = toSet } if (request.method === "HEAD") { nodeResponse.writeHead(response.status, headers) return Effect.callback((resume) => { nodeResponse.end(() => resume(Effect.void)) }) } const body = response.body switch (body._tag) { case "Empty": { nodeResponse.writeHead(response.status, headers) nodeResponse.end() return Effect.void } case "Raw": { nodeResponse.writeHead(response.status, headers) if ( typeof body.body === "object" && body.body !== null && "pipe" in body.body && typeof body.body.pipe === "function" ) { return Effect.tryPromise({ try: (signal) => pipeline(body.body as any, nodeResponse, { signal, end: true }), catch: (cause) => new HttpServerError({ reason: new ResponseError({ request, response, description: "Error writing raw response", cause }) }) }).pipe( Effect.interruptible, Effect.tapCause(handleCause(nodeResponse, response)) ) } return Effect.callback((resume) => { nodeResponse.end(body.body, () => resume(Effect.void)) }) } case "Uint8Array": { nodeResponse.writeHead(response.status, headers) // If the body is less than 1MB, we skip the callback if (body.body.length < 1024 * 1024) { nodeResponse.end(body.body) return Effect.void } return Effect.callback((resume) => { nodeResponse.end(body.body, () => resume(Effect.void)) }) } case "FormData": { return Effect.suspend(() => { const r = new globalThis.Response(body.formData) nodeResponse.writeHead(response.status, { ...headers, ...Object.fromEntries(r.headers) }) return Effect.callback((resume, signal) => { Readable.fromWeb(r.body as any, { signal }) .pipe(nodeResponse) .on("error", (cause) => { resume(Effect.fail( new HttpServerError({ reason: new ResponseError({ request, response, description: "Error writing FormData response", cause }) }) )) }) .once("finish", () => { resume(Effect.void) }) }).pipe( Effect.interruptible, Effect.tapCause(handleCause(nodeResponse, response)) ) }) } case "Stream": { nodeResponse.writeHead(response.status, headers) const drainLatch = Latch.makeUnsafe() nodeResponse.on("drain", () => drainLatch.openUnsafe()) return body.stream.pipe( Stream.orDie, Stream.runForEachArray((array) => { let needDrain = false for (let i = 0; i < array.length; i++) { const written = nodeResponse.write(array[i]) if (!written && !needDrain) { needDrain = true drainLatch.closeUnsafe() } else if (written && needDrain) { needDrain = false } } if (!needDrain) return Effect.void return drainLatch.await }), Effect.interruptible, Effect.matchCauseEffect({ onSuccess: () => Effect.sync(() => nodeResponse.end()), onFailure: handleCause(nodeResponse, response) }) ) } } } const handleCause = ( nodeResponse: Http.ServerResponse, originalResponse: HttpServerResponse ) => (originalCause: Cause.Cause) => Effect.flatMap(causeResponse(originalCause), ([response, cause]) => { const headersSent = nodeResponse.headersSent if (!headersSent) { nodeResponse.writeHead(response.status) } if (!nodeResponse.writableEnded) { nodeResponse.end() } return Effect.failCause( headersSent ? Cause.combine(originalCause, Cause.die(originalResponse)) : cause ) })