import * as Cookies from "@effect/platform/Cookies" import * as Headers from "@effect/platform/Headers" import * as Client from "@effect/platform/HttpClient" import * as Error from "@effect/platform/HttpClientError" import type * as ClientRequest from "@effect/platform/HttpClientRequest" import * as ClientResponse from "@effect/platform/HttpClientResponse" import * as IncomingMessage from "@effect/platform/HttpIncomingMessage" import * as UrlParams from "@effect/platform/UrlParams" import * as Context from "effect/Context" import * as Effect from "effect/Effect" import * as FiberRef from "effect/FiberRef" import { type LazyArg } from "effect/Function" import { globalValue } from "effect/GlobalValue" import * as Inspectable from "effect/Inspectable" import * as Option from "effect/Option" import * as Stream from "effect/Stream" import * as HeaderParser from "multipasta/HeadersParser" /** @internal */ export const xhrTagKey = "@effect/platform-browser/BrowserHttpClient/XMLHttpRequest" const xhrTag = Context.GenericTag>(xhrTagKey) /** @internal */ export const currentXHRResponseType = globalValue( "@effect/platform-browser/BrowserHttpClient/currentXHRResponseType", () => FiberRef.unsafeMake<"text" | "arraybuffer">("text") ) /** @internal */ export const withXHRArrayBuffer = (effect: Effect.Effect): Effect.Effect => Effect.locally( effect, currentXHRResponseType, "arraybuffer" ) const makeXhr = () => new XMLHttpRequest() const makeXMLHttpRequest = Client.make((request, url, signal, fiber) => Effect.suspend(() => { const xhr = Context.getOrElse( fiber.getFiberRef(FiberRef.currentContext), xhrTag, () => makeXhr )() signal.addEventListener("abort", () => { xhr.abort() xhr.onreadystatechange = null }, { once: true }) xhr.open(request.method, url.toString(), true) xhr.responseType = fiber.getFiberRef(currentXHRResponseType) Object.entries(request.headers).forEach(([k, v]) => { xhr.setRequestHeader(k, v) }) return Effect.zipRight( sendBody(xhr, request), Effect.async((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 Error.RequestError({ request, reason: "Transport", cause: xhr.statusText }) )) } onChange() return Effect.void }) ) }) ) const sendBody = ( xhr: XMLHttpRequest, request: ClientRequest.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)) 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 Error.RequestError({ request, reason: "Encode", cause }) ), onSuccess: (body) => Effect.sync(() => xhr.send(body)) } ) } } const encoder = new TextEncoder() /** @internal */ export abstract class IncomingMessageImpl extends Inspectable.Class implements IncomingMessage.HttpIncomingMessage { readonly [IncomingMessage.TypeId]: IncomingMessage.TypeId constructor( readonly source: XMLHttpRequest, readonly onError: (error: unknown) => E ) { super() this[IncomingMessage.TypeId] = IncomingMessage.TypeId 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.async((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.tryMap(this.text, { try: (_) => _ === "" ? null : JSON.parse(_) as unknown, catch: this.onError }) } get urlParamsBody(): Effect.Effect { return Effect.flatMap(this.text, (_) => Effect.try({ try: () => UrlParams.fromInput(new URLSearchParams(_)), catch: this.onError })) } get stream(): Stream.Stream { return Stream.async((emit) => { let offset = 0 const onReadyStateChange = () => { if (this.source.readyState === 3) { emit.single(encoder.encode(this.source.responseText.slice(offset))) offset = this.source.responseText.length } else if (this.source.readyState === 4) { if (offset < this.source.responseText.length) { emit.single(encoder.encode(this.source.responseText.slice(offset))) } emit.end() } } const onError = () => { emit.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.async((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 ClientResponse.HttpClientResponse { readonly [ClientResponse.TypeId]: ClientResponse.TypeId constructor( readonly request: ClientRequest.HttpClientRequest, source: XMLHttpRequest ) { super(source, (cause) => new Error.ResponseError({ request, response: this, reason: "Decode", cause })) this[ClientResponse.TypeId] = ClientResponse.TypeId } get status() { return this.source.status } get formData(): Effect.Effect { return Effect.die("Not implemented") } toString(): string { return `ClientResponse(${this.status})` } toJSON(): unknown { return IncomingMessage.inspect(this, { _id: "@effect/platform/HttpClientResponse", request: this.request.toJSON(), status: this.status }) } } /** @internal */ export const layerXMLHttpRequest = Client.layerMergedContext(Effect.succeed(makeXMLHttpRequest))