431 lines
14 KiB
TypeScript
431 lines
14 KiB
TypeScript
/**
|
|
* 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<XHRResponseType> = 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 = <A, E, R>(
|
|
self: Effect.Effect<A, E, R>
|
|
): Effect.Effect<A, E, R> =>
|
|
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<globalThis.XMLHttpRequest>
|
|
>()("@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<ClientResponseImpl, HttpClientError.HttpClientError>((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<void, HttpClientError.HttpClientError> => {
|
|
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<E> extends Inspectable.Class implements HttpIncomingMessage.HttpIncomingMessage<E> {
|
|
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<string, string | Array<string>> | 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<string, E> | undefined
|
|
get text(): Effect.Effect<string, E> {
|
|
if (this._textEffect) {
|
|
return this._textEffect
|
|
}
|
|
return this._textEffect = Effect.callback<string, E>((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<Schema.Json, E> {
|
|
return Effect.flatMap(this.text, (text) =>
|
|
Effect.try({
|
|
try: () => text === "" ? null : JSON.parse(text),
|
|
catch: this.onError
|
|
}))
|
|
}
|
|
|
|
get urlParamsBody(): Effect.Effect<UrlParams.UrlParams, E> {
|
|
return Effect.flatMap(this.text, (text) =>
|
|
Effect.try({
|
|
try: () => UrlParams.fromInput(new URLSearchParams(text)),
|
|
catch: this.onError
|
|
}))
|
|
}
|
|
|
|
get stream(): Stream.Stream<Uint8Array, E> {
|
|
return Stream.callback<Uint8Array, E>((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<ArrayBuffer, E> | undefined
|
|
get arrayBuffer(): Effect.Effect<ArrayBuffer, E> {
|
|
if (this._arrayBufferEffect) {
|
|
return this._arrayBufferEffect
|
|
}
|
|
return this._arrayBufferEffect = Effect.callback<ArrayBuffer, E>((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<HttpClientError.HttpClientError>
|
|
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<FormData, HttpClientError.HttpClientError> {
|
|
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.HttpClient> = HttpClient.layerMergedContext(
|
|
Effect.succeed(makeXmlHttpRequest)
|
|
)
|