/** * Node.js implementations of the Effect `HttpClient`. * * This module supplies Node runtime backends for the platform-independent * Effect HTTP client API. It re-exports the fetch-based `Fetch`, `RequestInit`, * and `layerFetch` APIs, defines an Undici-backed client with dispatcher * services and request options, and defines a lower-level `node:http` / * `node:https` client with scoped HTTP agent layers. * * @since 4.0.0 */ import * as Context from "effect/Context" import * as Effect from "effect/Effect" import { flow } from "effect/Function" import * as Inspectable from "effect/Inspectable" import * as Layer from "effect/Layer" import * as Option from "effect/Option" import { type Pipeable, pipeArguments } from "effect/Pipeable" import type * as Schema from "effect/Schema" import type * as Scope from "effect/Scope" import * as Stream from "effect/Stream" import * as Cookies from "effect/unstable/http/Cookies" import * as Headers from "effect/unstable/http/Headers" import type * as Body from "effect/unstable/http/HttpBody" import * as Client from "effect/unstable/http/HttpClient" import * as Error from "effect/unstable/http/HttpClientError" import type { HttpClientRequest } from "effect/unstable/http/HttpClientRequest" import * as Response from "effect/unstable/http/HttpClientResponse" import type { HttpClientResponse } from "effect/unstable/http/HttpClientResponse" import * as IncomingMessage from "effect/unstable/http/HttpIncomingMessage" import * as UrlParams from "effect/unstable/http/UrlParams" import * as Http from "node:http" import * as Https from "node:https" import { Readable } from "node:stream" import { pipeline } from "node:stream/promises" import { NodeHttpIncomingMessage } from "./NodeHttpIncomingMessage.ts" import * as NodeSink from "./NodeSink.ts" import * as NodeStream from "./NodeStream.ts" import * as Undici from "./Undici.ts" // ----------------------------------------------------------------------------- // Fetch // ----------------------------------------------------------------------------- export { /** * Provides a fetch-based HTTP client implementation for Node.js. * * **When to use** * * Use to access or override the fetch implementation used by the Node * fetch-based HTTP client. * * @category fetch * @since 4.0.0 */ Fetch, /** * Layer that provides the fetch-based HTTP client implementation. * * @category fetch * @since 4.0.0 */ layer as layerFetch, /** * Provides request initialization options accepted by the fetch-based HTTP client. * * **When to use** * * Use to provide default fetch request options for Node HTTP requests. * * @category fetch * @since 4.0.0 */ RequestInit } from "effect/unstable/http/FetchHttpClient" // ----------------------------------------------------------------------------- // Undici // ----------------------------------------------------------------------------- /** * Service tag for the Undici `Dispatcher` used by the Undici-backed HTTP * client. * * @category Dispatcher * @since 4.0.0 */ export class Dispatcher extends Context.Service()( "@effect/platform-node/NodeHttpClient/Dispatcher" ) {} /** * Acquires a new Undici `Agent` dispatcher and destroys it when the enclosing * scope is finalized. * * @category Dispatcher * @since 4.0.0 */ export const makeDispatcher: Effect.Effect = Effect.acquireRelease( Effect.sync(() => new Undici.Agent()), (dispatcher) => Effect.promise(() => dispatcher.destroy()) ) /** * Provides the `Dispatcher` service using a scoped Undici `Agent`. * * @category Dispatcher * @since 4.0.0 */ export const layerDispatcher: Layer.Layer = Layer.effect(Dispatcher)(makeDispatcher) /** * Provides the `Dispatcher` service from Undici's process-global dispatcher, * without creating or owning a new agent. * * @category Dispatcher * @since 4.0.0 */ export const dispatcherLayerGlobal: Layer.Layer = Layer.sync(Dispatcher)(() => Undici.getGlobalDispatcher()) /** * Fiber reference containing default Undici request options applied to requests * sent by `makeUndici`. * * @category Undici * @since 4.0.0 */ export const UndiciOptions = Context.Reference>( "@effect/platform-node/NodeHttpClient/UndiciOptions", { defaultValue: () => ({}) } ) /** * Creates an `HttpClient` that sends requests through the current Undici * `Dispatcher`, converts Effect HTTP bodies to Undici bodies, and maps * transport and decode failures to `HttpClientError`. * * @category Undici * @since 4.0.0 */ export const makeUndici = Effect.gen(function*() { const dispatcher = yield* Dispatcher return Client.make((request, url, signal, fiber) => convertBody(request.body).pipe( Effect.flatMap((body) => Effect.tryPromise({ try: () => dispatcher.request({ ...fiber.getRef(UndiciOptions), signal, method: request.method, headers: request.headers, origin: url.origin, path: url.pathname + url.search + url.hash, body, // leave timeouts to Effect.timeout etc headersTimeout: 60 * 60 * 1000, bodyTimeout: 0 }), catch: (cause) => new Error.HttpClientError({ reason: new Error.TransportError({ request, cause }) }) }) ), Effect.map((response) => new UndiciResponse(request, response)) ) ) }) function convertBody( body: Body.HttpBody ): Effect.Effect> { switch (body._tag) { case "Empty": { return Effect.succeed(null) } case "Uint8Array": case "Raw": { return Effect.succeed(body.body as Uint8Array) } case "FormData": { return Effect.succeed(body.formData as any) } case "Stream": { return NodeStream.toReadable(body.stream) } } } function noopErrorHandler(_: any) {} class UndiciResponse extends Inspectable.Class implements HttpClientResponse, Pipeable { readonly [IncomingMessage.TypeId]: typeof IncomingMessage.TypeId readonly [Response.TypeId]: typeof Response.TypeId readonly request: HttpClientRequest readonly source: Undici.Dispatcher.ResponseData constructor( request: HttpClientRequest, source: Undici.Dispatcher.ResponseData ) { super() this[IncomingMessage.TypeId] = IncomingMessage.TypeId this[Response.TypeId] = Response.TypeId this.request = request this.source = source source.body.on("error", noopErrorHandler) } get status() { return this.source.statusCode! } get statusText() { return undefined } get headers(): Headers.Headers { return Headers.fromInput(this.source.headers) } cachedCookies?: Cookies.Cookies get cookies(): Cookies.Cookies { if (this.cachedCookies !== undefined) { return this.cachedCookies } const header = this.source.headers["set-cookie"] return this.cachedCookies = header ? Cookies.fromSetCookie(header) : Cookies.empty } get remoteAddress(): Option.Option { return Option.none() } get stream(): Stream.Stream { return NodeStream.fromReadable({ evaluate: () => this.source.body, onError: (cause) => new Error.HttpClientError({ reason: new Error.DecodeError({ request: this.request, response: this, cause }) }) }) } get json(): Effect.Effect { return Effect.flatMap(this.text, (text) => Effect.try({ try: () => text === "" ? null : JSON.parse(text), catch: (cause) => new Error.HttpClientError({ reason: new Error.DecodeError({ request: this.request, response: this, cause }) }) })) } private textBody?: Effect.Effect get text(): Effect.Effect { if (this.textBody) { return this.textBody } this.textBody = Effect.tryPromise({ try: () => this.source.body.text(), catch: (cause) => new Error.HttpClientError({ reason: new Error.DecodeError({ request: this.request, response: this, cause }) }) }).pipe(Effect.cached, Effect.runSync) this.arrayBufferBody = Effect.map(this.textBody, (_) => new TextEncoder().encode(_).buffer) return this.textBody } get urlParamsBody(): Effect.Effect { return Effect.flatMap(this.text, (_) => Effect.try({ try: () => UrlParams.fromInput(new URLSearchParams(_)), catch: (cause) => new Error.HttpClientError({ reason: new Error.DecodeError({ request: this.request, response: this, cause }) }) })) } private formDataBody?: Effect.Effect get formData(): Effect.Effect { return this.formDataBody ??= Effect.tryPromise({ try: () => this.source.body.formData() as Promise, catch: (cause) => new Error.HttpClientError({ reason: new Error.DecodeError({ request: this.request, response: this, cause }) }) }).pipe(Effect.cached, Effect.runSync) } private arrayBufferBody?: Effect.Effect get arrayBuffer(): Effect.Effect { if (this.arrayBufferBody) { return this.arrayBufferBody } this.arrayBufferBody = Effect.tryPromise({ try: () => this.source.body.arrayBuffer(), catch: (cause) => new Error.HttpClientError({ reason: new Error.DecodeError({ request: this.request, response: this, cause }) }) }).pipe(Effect.cached, Effect.runSync) this.textBody = Effect.map(this.arrayBufferBody, (_) => new TextDecoder().decode(_)) return this.arrayBufferBody } toJSON(): unknown { return IncomingMessage.inspect(this, { _id: "effect/http/HttpClientResponse", request: this.request.toJSON(), status: this.status }) } pipe() { return pipeArguments(this, arguments) } } /** * Provides an Undici-backed `HttpClient` using the current `Dispatcher` * service. * * @category Undici * @since 4.0.0 */ export const layerUndiciNoDispatcher: Layer.Layer< Client.HttpClient, never, Dispatcher > = Client.layerMergedContext(makeUndici) /** * Provides an Undici-backed `HttpClient` together with a scoped default * Undici `Agent` dispatcher. * * @category Undici * @since 4.0.0 */ export const layerUndici: Layer.Layer = Layer.provide(layerUndiciNoDispatcher, layerDispatcher) // ----------------------------------------------------------------------------- // node:http // ----------------------------------------------------------------------------- /** * Service tag for the paired Node `http` and `https` agents used by the * node:http-backed HTTP client. * * @category HttpAgent * @since 4.0.0 */ export class HttpAgent extends Context.Service()("@effect/platform-node/NodeHttpClient/HttpAgent") {} /** * Acquires Node `http` and `https` agents with the supplied options and * destroys both agents when the enclosing scope is finalized. * * @category HttpAgent * @since 4.0.0 */ export const makeAgent = (options?: Https.AgentOptions): Effect.Effect => Effect.zipWith( Effect.acquireRelease( Effect.sync(() => new Http.Agent(options)), (agent) => Effect.sync(() => agent.destroy()) ), Effect.acquireRelease( Effect.sync(() => new Https.Agent(options)), (agent) => Effect.sync(() => agent.destroy()) ), (http, https) => ({ http, https }) ) /** * Provides the `HttpAgent` service using scoped Node `http` and `https` * agents configured with the supplied options. * * @category HttpAgent * @since 4.0.0 */ export const layerAgentOptions: (options?: Https.AgentOptions | undefined) => Layer.Layer< HttpAgent > = flow(makeAgent, Layer.effect(HttpAgent)) /** * Provides the `HttpAgent` service using default scoped Node `http` and * `https` agents. * * @category HttpAgent * @since 4.0.0 */ export const layerAgent: Layer.Layer = layerAgentOptions() /** * Creates an `HttpClient` backed by Node `http` and `https`, using the * current `HttpAgent`, streaming request bodies, and wrapping Node responses * as `HttpClientResponse` values. * * @category node:http * @since 4.0.0 */ export const makeNodeHttp = Effect.gen(function*() { const agent = yield* HttpAgent return Client.make((request, url, signal) => { const nodeRequest = url.protocol === "https:" ? Https.request(url, { agent: agent.https, method: request.method, headers: request.headers, signal }) : Http.request(url, { agent: agent.http, method: request.method, headers: request.headers, signal }) return Effect.forkChild(sendBody(nodeRequest, request, request.body)).pipe( Effect.flatMap(() => waitForResponse(nodeRequest, request)), Effect.map((_) => new NodeHttpResponse(request, _)) ) }) }) const sendBody = ( nodeRequest: Http.ClientRequest, request: HttpClientRequest, body: Body.HttpBody ): Effect.Effect => Effect.suspend((): Effect.Effect => { switch (body._tag) { case "Empty": { nodeRequest.end() return waitForFinish(nodeRequest, request) } case "Uint8Array": case "Raw": { nodeRequest.end(body.body) return waitForFinish(nodeRequest, request) } case "FormData": { const response = new globalThis.Response(body.formData) response.headers.forEach((value, key) => { nodeRequest.setHeader(key, value) }) return Effect.tryPromise({ try: () => pipeline(Readable.fromWeb(response.body! as any), nodeRequest), catch: (cause) => new Error.HttpClientError({ reason: new Error.TransportError({ request, cause }) }) }) } case "Stream": { return Stream.run( Stream.mapError(body.stream, (cause) => new Error.HttpClientError({ reason: new Error.EncodeError({ request, cause }) })), NodeSink.fromWritable({ evaluate: () => nodeRequest, onError: (cause) => new Error.HttpClientError({ reason: new Error.TransportError({ request, cause }) }) }) ) } } }) const waitForResponse = (nodeRequest: Http.ClientRequest, request: HttpClientRequest) => Effect.callback((resume) => { function onError(cause: Error) { resume(Effect.fail( new Error.HttpClientError({ reason: new Error.TransportError({ request, cause }) }) )) } nodeRequest.on("error", onError) function onResponse(response: Http.IncomingMessage) { nodeRequest.off("error", onError) resume(Effect.succeed(response)) } nodeRequest.on("upgrade", onResponse) nodeRequest.on("response", onResponse) return Effect.sync(() => { nodeRequest.off("error", onError) nodeRequest.off("upgrade", onResponse) nodeRequest.off("response", onResponse) }) }) const waitForFinish = (nodeRequest: Http.ClientRequest, request: HttpClientRequest) => Effect.callback((resume) => { function onError(cause: Error) { resume(Effect.fail( new Error.HttpClientError({ reason: new Error.TransportError({ request, cause }) }) )) } nodeRequest.once("error", onError) function onFinish() { nodeRequest.off("error", onError) resume(Effect.void) } nodeRequest.once("finish", onFinish) return Effect.sync(() => { nodeRequest.off("error", onError) nodeRequest.off("finish", onFinish) }) }) class NodeHttpResponse extends NodeHttpIncomingMessage implements HttpClientResponse, Pipeable { readonly [Response.TypeId]: typeof Response.TypeId readonly request: HttpClientRequest constructor( request: HttpClientRequest, source: Http.IncomingMessage ) { super(source, (cause) => new Error.HttpClientError({ reason: new Error.DecodeError({ request, response: this, cause }) })) this[Response.TypeId] = Response.TypeId this.request = request } get status() { return this.source.statusCode! } cachedCookies?: Cookies.Cookies get cookies(): Cookies.Cookies { if (this.cachedCookies !== undefined) { return this.cachedCookies } const header = this.source.headers["set-cookie"] return this.cachedCookies = header ? Cookies.fromSetCookie(header) : Cookies.empty } get formData(): Effect.Effect { return Effect.tryPromise({ try: () => { const init: { headers: HeadersInit status?: number statusText?: string } = { headers: new globalThis.Headers(this.source.headers as any) } if (this.source.statusCode) { init.status = this.source.statusCode } if (this.source.statusMessage) { init.statusText = this.source.statusMessage } return new globalThis.Response(Readable.toWeb(this.source) as any, init).formData() }, catch: this.onError }) } toJSON(): unknown { return IncomingMessage.inspect(this, { _id: "effect/http/HttpClientResponse", request: this.request.toJSON(), status: this.status }) } pipe() { return pipeArguments(this, arguments) } } /** * Provides a node:http-backed `HttpClient` using the current `HttpAgent` * service. * * @category node:http * @since 4.0.0 */ export const layerNodeHttpNoAgent: Layer.Layer< Client.HttpClient, never, HttpAgent > = Client.layerMergedContext(makeNodeHttp) /** * Provides a node:http-backed `HttpClient` together with default scoped Node * `http` and `https` agents. * * @category node:http * @since 4.0.0 */ export const layerNodeHttp: Layer.Layer = Layer.provide(layerNodeHttpNoAgent, layerAgent)