/** * Browser implementations of the Effect `HttpClient`. * * This module exposes HTTP client layers for code that runs in a browser. It * re-exports the fetch-based `Fetch`, `RequestInit`, and `layerFetch` APIs for * the common case where requests use the platform `fetch` implementation. It * also provides an `XMLHttpRequest`-backed client, including controls for the * XHR response type, an overridable `XMLHttpRequest` constructor service, and * the `layerXMLHttpRequest` layer. * * @since 4.0.0 */ import * as Cause from "effect/Cause" import * as Context from "effect/Context" import * as Effect from "effect/Effect" import type { LazyArg } from "effect/Function" import * as Inspectable from "effect/Inspectable" import type * as Layer from "effect/Layer" import * as Option from "effect/Option" import { type Pipeable, pipeArguments } from "effect/Pipeable" import * as Queue from "effect/Queue" import type * as Schema from "effect/Schema" import * as Stream from "effect/Stream" import * as Cookies from "effect/unstable/http/Cookies" import * as Headers from "effect/unstable/http/Headers" import * as HttpClient from "effect/unstable/http/HttpClient" import * as HttpClientError from "effect/unstable/http/HttpClientError" import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest" import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse" import * as HttpIncomingMessage from "effect/unstable/http/HttpIncomingMessage" import * as UrlParams from "effect/unstable/http/UrlParams" import * as HeaderParser from "multipasta/HeadersParser" // ============================================================================= // Fetch // ============================================================================= export { /** * Context reference for the `fetch` implementation used by the fetch-based HTTP client. * * @category fetch * @since 4.0.0 */ Fetch, /** * Layer that provides an `HttpClient` implementation backed by the configured `Fetch` function. * * @category fetch * @since 4.0.0 */ layer as layerFetch, /** * Service that contains default fetch options for the browser fetch client. * * **When to use** * * Use to provide default credentials, cache, redirect, integrity, or other * fetch options for browser HTTP requests. * * @category fetch * @since 4.0.0 */ RequestInit } from "effect/unstable/http/FetchHttpClient" // ============================================================================= // XML Http Request // ============================================================================= /** * Allowed response body modes for the browser XHR HTTP client. * * @category models * @since 4.0.0 */ export type XHRResponseType = "arraybuffer" | "text" /** * Context reference for the `XMLHttpRequest.responseType` used by the browser XHR HTTP client, defaulting to `"text"`. * * **When to use** * * Use when you need XHR-backed HTTP requests to receive response bodies as text * or raw `ArrayBuffer` values. * * @see {@link XHRResponseType} for the allowed response body modes * @see {@link withXHRArrayBuffer} for scoping XHR response handling to `ArrayBuffer` * * @category references * @since 4.0.0 */ export const CurrentXHRResponseType: Context.Reference = Context.Reference( "@effect/platform-browser/BrowserHttpClient/CurrentXHRResponseType", { defaultValue: (): XHRResponseType => "text" } ) /** * Runs an effect with `CurrentXHRResponseType` set to `"arraybuffer"` so the XHR HTTP client receives response bodies as `ArrayBuffer` values. * * @category references * @since 4.0.0 */ export const withXHRArrayBuffer = ( self: Effect.Effect ): Effect.Effect => Effect.provideService( self, CurrentXHRResponseType, "arraybuffer" ) /** * Service tag for the `XMLHttpRequest` constructor used by the browser XHR HTTP client. * * @category services * @since 4.0.0 */ export class XMLHttpRequest extends Context.Service< XMLHttpRequest, LazyArg >()("@effect/platform-browser/BrowserHttpClient/XMLHttpRequest") {} const makeXhrRequest = () => new globalThis.XMLHttpRequest() const makeXmlHttpRequest = HttpClient.make( (request, url, signal, fiber) => Effect.suspend(() => { const xhr = Context.getOrElse( fiber.context, XMLHttpRequest, () => makeXhrRequest )() signal.addEventListener("abort", () => { xhr.abort() xhr.onreadystatechange = null }, { once: true }) xhr.open(request.method, url.toString(), true) xhr.responseType = fiber.getRef(CurrentXHRResponseType) Object.entries(request.headers).forEach(([k, v]) => { xhr.setRequestHeader(k, v) }) return Effect.andThen( sendBody(xhr, request), Effect.callback((resume) => { let sent = false const onChange = () => { if (!sent && xhr.readyState >= 2) { sent = true resume(Effect.succeed(new ClientResponseImpl(request, xhr))) } } xhr.onreadystatechange = onChange xhr.onerror = (_event) => { resume(Effect.fail( new HttpClientError.HttpClientError({ reason: new HttpClientError.TransportError({ request, cause: xhr.statusText }) }) )) } onChange() return Effect.void }) ) }) ) const sendBody = ( xhr: globalThis.XMLHttpRequest, request: HttpClientRequest.HttpClientRequest ): Effect.Effect => { const body = request.body switch (body._tag) { case "Empty": return Effect.sync(() => xhr.send()) case "Raw": return Effect.sync(() => xhr.send(body.body as any)) case "Uint8Array": return Effect.sync(() => xhr.send(body.body as any)) case "FormData": return Effect.sync(() => xhr.send(body.formData)) case "Stream": return Effect.matchEffect( Stream.runFold(body.stream, () => new Uint8Array(0), (acc, chunk) => { const next = new Uint8Array(acc.length + chunk.length) next.set(acc, 0) next.set(chunk, acc.length) return next }), { onFailure: (cause) => Effect.fail( new HttpClientError.HttpClientError({ reason: new HttpClientError.EncodeError({ request, cause }) }) ), onSuccess: (body) => Effect.sync(() => xhr.send(body)) } ) } } const encoder = new TextEncoder() abstract class IncomingMessageImpl extends Inspectable.Class implements HttpIncomingMessage.HttpIncomingMessage { readonly [HttpIncomingMessage.TypeId]: typeof HttpIncomingMessage.TypeId readonly source: globalThis.XMLHttpRequest readonly onError: (error: unknown) => E constructor(source: globalThis.XMLHttpRequest, onError: (error: unknown) => E) { super() this[HttpIncomingMessage.TypeId] = HttpIncomingMessage.TypeId this.source = source this.onError = onError this._rawHeaderString = source.getAllResponseHeaders() } private _rawHeaderString: string private _rawHeaders: Record> | undefined private _headers: Headers.Headers | undefined get headers() { if (this._headers) { return this._headers } if (this._rawHeaderString === "") { return this._headers = Headers.empty } const parser = HeaderParser.make() const result = parser(encoder.encode(this._rawHeaderString + "\r\n"), 0) this._rawHeaders = result._tag === "Headers" ? result.headers : undefined const parsed = result._tag === "Headers" ? Headers.fromInput(result.headers) : Headers.empty return this._headers = parsed } cachedCookies: Cookies.Cookies | undefined get cookies() { if (this.cachedCookies) { return this.cachedCookies } if (this._rawHeaders === undefined) { return Cookies.empty } else if (this._rawHeaders["set-cookie"] === undefined) { return this.cachedCookies = Cookies.empty } return this.cachedCookies = Cookies.fromSetCookie(this._rawHeaders["set-cookie"]) } get remoteAddress() { return Option.none() } _textEffect: Effect.Effect | undefined get text(): Effect.Effect { if (this._textEffect) { return this._textEffect } return this._textEffect = Effect.callback((resume) => { if (this.source.readyState === 4) { resume(Effect.succeed(this.source.responseText)) return } const onReadyStateChange = () => { if (this.source.readyState === 4) { resume(Effect.succeed(this.source.responseText)) } } const onError = () => { resume(Effect.fail(this.onError(this.source.statusText))) } this.source.addEventListener("readystatechange", onReadyStateChange) this.source.addEventListener("error", onError) return Effect.sync(() => { this.source.removeEventListener("readystatechange", onReadyStateChange) this.source.removeEventListener("error", onError) }) }).pipe( Effect.cached, Effect.runSync ) } get json(): Effect.Effect { return Effect.flatMap(this.text, (text) => Effect.try({ try: () => text === "" ? null : JSON.parse(text), catch: this.onError })) } get urlParamsBody(): Effect.Effect { return Effect.flatMap(this.text, (text) => Effect.try({ try: () => UrlParams.fromInput(new URLSearchParams(text)), catch: this.onError })) } get stream(): Stream.Stream { return Stream.callback((queue) => { let offset = 0 const onReadyStateChange = () => { if (this.source.readyState === 3) { const encoded = encoder.encode(this.source.responseText.slice(offset)) Queue.offerUnsafe(queue, encoded) offset = this.source.responseText.length } else if (this.source.readyState === 4) { const encoded = encoder.encode(this.source.responseText.slice(offset)) if (offset < this.source.responseText.length) { Queue.offerUnsafe(queue, encoded) } Queue.endUnsafe(queue) } } const onError = () => { Queue.failCauseUnsafe(queue, Cause.fail(this.onError(this.source.statusText))) } this.source.addEventListener("readystatechange", onReadyStateChange) this.source.addEventListener("error", onError) onReadyStateChange() return Effect.sync(() => { this.source.removeEventListener("readystatechange", onReadyStateChange) this.source.removeEventListener("error", onError) }) }) } _arrayBufferEffect: Effect.Effect | undefined get arrayBuffer(): Effect.Effect { if (this._arrayBufferEffect) { return this._arrayBufferEffect } return this._arrayBufferEffect = Effect.callback((resume) => { if (this.source.readyState === 4) { resume(Effect.succeed(this.source.response)) return } const onReadyStateChange = () => { if (this.source.readyState === 4) { resume(Effect.succeed(this.source.response)) } } const onError = () => { resume(Effect.fail(this.onError(this.source.statusText))) } this.source.addEventListener("readystatechange", onReadyStateChange) this.source.addEventListener("error", onError) return Effect.sync(() => { this.source.removeEventListener("readystatechange", onReadyStateChange) this.source.removeEventListener("error", onError) }) }).pipe( Effect.map((response) => { if (typeof response === "string") { const arr = encoder.encode(response) return arr.byteLength !== arr.buffer.byteLength ? arr.buffer.slice(arr.byteOffset, arr.byteOffset + arr.byteLength) : arr.buffer } return response }), Effect.cached, Effect.runSync ) } } class ClientResponseImpl extends IncomingMessageImpl implements HttpClientResponse.HttpClientResponse, Pipeable { readonly [HttpClientResponse.TypeId]: typeof HttpClientResponse.TypeId readonly request: HttpClientRequest.HttpClientRequest constructor( request: HttpClientRequest.HttpClientRequest, source: globalThis.XMLHttpRequest ) { super(source, (cause) => new HttpClientError.HttpClientError({ reason: new HttpClientError.DecodeError({ request, response: this, cause }) })) this.request = request this[HttpClientResponse.TypeId] = HttpClientResponse.TypeId } get status() { return this.source.status } get formData(): Effect.Effect { return Effect.die("Not implemented") } override toString(): string { return `ClientResponse(${this.status})` } toJSON(): unknown { return HttpIncomingMessage.inspect(this, { _id: "@effect/platform/HttpClientResponse", request: this.request.toJSON(), status: this.status }) } pipe() { return pipeArguments(this, arguments) } } /** * Layer that provides an `HttpClient` implementation backed by the browser `XMLHttpRequest` API. * * @category layers * @since 4.0.0 */ export const layerXMLHttpRequest: Layer.Layer = HttpClient.layerMergedContext( Effect.succeed(makeXmlHttpRequest) )