| 1 | /** |
| 2 | * Browser implementations of the Effect `HttpClient`. |
| 3 | * |
| 4 | * This module exposes HTTP client layers for code that runs in a browser. It |
| 5 | * re-exports the fetch-based `Fetch`, `RequestInit`, and `layerFetch` APIs for |
| 6 | * the common case where requests use the platform `fetch` implementation. It |
| 7 | * also provides an `XMLHttpRequest`-backed client, including controls for the |
| 8 | * XHR response type, an overridable `XMLHttpRequest` constructor service, and |
| 9 | * the `layerXMLHttpRequest` layer. |
| 10 | * |
| 11 | * @since 4.0.0 |
| 12 | */ |
| 13 | import * as Cause from "effect/Cause" |
| 14 | import * as Context from "effect/Context" |
| 15 | import * as Effect from "effect/Effect" |
| 16 | import type { LazyArg } from "effect/Function" |
| 17 | import * as Cookies from "effect/http/Cookies" |
| 18 | import * as Headers from "effect/http/Headers" |
| 19 | import * as HttpClient from "effect/http/HttpClient" |
| 20 | import * as HttpClientError from "effect/http/HttpClientError" |
| 21 | import type * as HttpClientRequest from "effect/http/HttpClientRequest" |
| 22 | import * as HttpClientResponse from "effect/http/HttpClientResponse" |
| 23 | import * as HttpIncomingMessage from "effect/http/HttpIncomingMessage" |
| 24 | import * as HeaderParser from "effect/http/MultipartParser/HeadersParser" |
| 25 | import * as UrlParams from "effect/http/UrlParams" |
| 26 | import * as Inspectable from "effect/Inspectable" |
| 27 | import type * as Layer from "effect/Layer" |
| 28 | import * as Option from "effect/Option" |
| 29 | import { type Pipeable, pipeArguments } from "effect/Pipeable" |
| 30 | import * as Queue from "effect/Queue" |
| 31 | import type * as Schema from "effect/Schema" |
| 32 | import * as Stream from "effect/Stream" |
| 33 | |
| 34 | // ============================================================================= |
| 35 | // Fetch |
| 36 | // ============================================================================= |
| 37 | |
| 38 | export { |
| 39 | /** |
| 40 | * Context reference for the `fetch` implementation used by the fetch-based HTTP client. |
| 41 | * |
| 42 | * @category services |
| 43 | * @since 4.0.0 |
| 44 | */ |
| 45 | Fetch, |
| 46 | /** |
| 47 | * Layer that provides an `HttpClient` implementation backed by the configured `Fetch` function. |
| 48 | * |
| 49 | * @category layers |
| 50 | * @since 4.0.0 |
| 51 | */ |
| 52 | layer as layerFetch, |
| 53 | /** |
| 54 | * Service that contains default fetch options for the browser fetch client. |
| 55 | * |
| 56 | * **When to use** |
| 57 | * |
| 58 | * Use to provide default credentials, cache, redirect, integrity, or other |
| 59 | * fetch options for browser HTTP requests. |
| 60 | * |
| 61 | * @category services |
| 62 | * @since 4.0.0 |
| 63 | */ |
| 64 | RequestInit |
| 65 | } from "effect/http/FetchHttpClient" |
| 66 | |
| 67 | // ============================================================================= |
| 68 | // XML Http Request |
| 69 | // ============================================================================= |
| 70 | |
| 71 | /** |
| 72 | * Allowed response body modes for the browser XHR HTTP client. |
| 73 | * |
| 74 | * @category models |
| 75 | * @since 4.0.0 |
| 76 | */ |
| 77 | export type XHRResponseType = "arraybuffer" | "text" |
| 78 | |
| 79 | /** |
| 80 | * Context reference for the `XMLHttpRequest.responseType` used by the browser XHR HTTP client, defaulting to `"text"`. |
| 81 | * |
| 82 | * **When to use** |
| 83 | * |
| 84 | * Use when you need XHR-backed HTTP requests to receive response bodies as text |
| 85 | * or raw `ArrayBuffer` values. |
| 86 | * |
| 87 | * @see {@link XHRResponseType} for the allowed response body modes |
| 88 | * @see {@link withXHRArrayBuffer} for scoping XHR response handling to `ArrayBuffer` |
| 89 | * |
| 90 | * @category services |
| 91 | * @since 4.0.0 |
| 92 | */ |
| 93 | export const CurrentXHRResponseType: Context.Reference<XHRResponseType> = Context.Reference( |
| 94 | "@effect/platform-browser/BrowserHttpClient/CurrentXHRResponseType", |
| 95 | { defaultValue: (): XHRResponseType => "text" } |
| 96 | ) |
| 97 | |
| 98 | /** |
| 99 | * Runs an effect with `CurrentXHRResponseType` set to `"arraybuffer"` so the XHR HTTP client receives response bodies as `ArrayBuffer` values. |
| 100 | * |
| 101 | * @category providing services |
| 102 | * @since 4.0.0 |
| 103 | */ |
| 104 | export const withXHRArrayBuffer = <A, E, R>( |
| 105 | self: Effect.Effect<A, E, R> |
| 106 | ): Effect.Effect<A, E, R> => |
| 107 | Effect.provideService( |
| 108 | self, |
| 109 | CurrentXHRResponseType, |
| 110 | "arraybuffer" |
| 111 | ) |
| 112 | |
| 113 | /** |
| 114 | * Service tag for the `XMLHttpRequest` constructor used by the browser XHR HTTP client. |
| 115 | * |
| 116 | * @category services |
| 117 | * @since 4.0.0 |
| 118 | */ |
| 119 | export class XMLHttpRequest extends Context.Service< |
| 120 | XMLHttpRequest, |
| 121 | LazyArg<globalThis.XMLHttpRequest> |
| 122 | >()("@effect/platform-browser/BrowserHttpClient/XMLHttpRequest") {} |
| 123 | |
| 124 | const makeXhrRequest = () => new globalThis.XMLHttpRequest() |
| 125 | |
| 126 | const makeXmlHttpRequest = HttpClient.make( |
| 127 | (request, url, signal, fiber) => |
| 128 | Effect.suspend(() => { |
| 129 | const xhr = Context.getOrElse( |
| 130 | fiber.context, |
| 131 | XMLHttpRequest, |
| 132 | () => makeXhrRequest |
| 133 | )() |
| 134 | signal.addEventListener("abort", () => { |
| 135 | xhr.abort() |
| 136 | xhr.onreadystatechange = null |
| 137 | }, { once: true }) |
| 138 | xhr.open(request.method, url.toString(), true) |
| 139 | xhr.responseType = fiber.getRef(CurrentXHRResponseType) |
| 140 | Object.entries(request.headers).forEach(([k, v]) => { |
| 141 | xhr.setRequestHeader(k, v) |
| 142 | }) |
| 143 | return Effect.andThen( |
| 144 | sendBody(xhr, request), |
| 145 | Effect.callback<ClientResponseImpl, HttpClientError.HttpClientError>((resume) => { |
| 146 | let sent = false |
| 147 | const onChange = () => { |
| 148 | if (!sent && xhr.readyState >= 2) { |
| 149 | sent = true |
| 150 | resume(Effect.succeed(new ClientResponseImpl(request, xhr, url.href))) |
| 151 | } |
| 152 | } |
| 153 | xhr.onreadystatechange = onChange |
| 154 | xhr.onerror = (_event) => { |
| 155 | resume(Effect.fail( |
| 156 | new HttpClientError.HttpClientError({ |
| 157 | reason: new HttpClientError.TransportError({ |
| 158 | request, |
| 159 | cause: xhr.statusText |
| 160 | }) |
| 161 | }) |
| 162 | )) |
| 163 | } |
| 164 | onChange() |
| 165 | return Effect.void |
| 166 | }) |
| 167 | ) |
| 168 | }) |
| 169 | ) |
| 170 | |
| 171 | const sendBody = ( |
| 172 | xhr: globalThis.XMLHttpRequest, |
| 173 | request: HttpClientRequest.HttpClientRequest |
| 174 | ): Effect.Effect<void, HttpClientError.HttpClientError> => { |
| 175 | const body = request.body |
| 176 | switch (body._tag) { |
| 177 | case "Empty": |
| 178 | return Effect.sync(() => xhr.send()) |
| 179 | case "Raw": |
| 180 | return Effect.sync(() => xhr.send(body.body as any)) |
| 181 | case "Uint8Array": |
| 182 | return Effect.sync(() => xhr.send(body.body as any)) |
| 183 | case "FormData": |
| 184 | return Effect.sync(() => xhr.send(body.formData)) |
| 185 | case "Stream": |
| 186 | return Effect.matchEffect( |
| 187 | Stream.runFold(body.stream, () => new Uint8Array(0), (acc, chunk) => { |
| 188 | const next = new Uint8Array(acc.length + chunk.length) |
| 189 | next.set(acc, 0) |
| 190 | next.set(chunk, acc.length) |
| 191 | return next |
| 192 | }), |
| 193 | { |
| 194 | onFailure: (cause) => |
| 195 | Effect.fail( |
| 196 | new HttpClientError.HttpClientError({ |
| 197 | reason: new HttpClientError.EncodeError({ |
| 198 | request, |
| 199 | cause |
| 200 | }) |
| 201 | }) |
| 202 | ), |
| 203 | onSuccess: (body) => Effect.sync(() => xhr.send(body)) |
| 204 | } |
| 205 | ) |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | const encoder = new TextEncoder() |
| 210 | |
| 211 | abstract class IncomingMessageImpl<E> extends Inspectable.Class implements HttpIncomingMessage.HttpIncomingMessage<E> { |
| 212 | readonly [HttpIncomingMessage.TypeId]: typeof HttpIncomingMessage.TypeId |
| 213 | readonly source: globalThis.XMLHttpRequest |
| 214 | readonly onError: (error: unknown) => E |
| 215 | |
| 216 | constructor(source: globalThis.XMLHttpRequest, onError: (error: unknown) => E) { |
| 217 | super() |
| 218 | this[HttpIncomingMessage.TypeId] = HttpIncomingMessage.TypeId |
| 219 | this.source = source |
| 220 | this.onError = onError |
| 221 | this._rawHeaderString = source.getAllResponseHeaders() |
| 222 | } |
| 223 | |
| 224 | private _rawHeaderString: string |
| 225 | private _rawHeaders: Record<string, string | Array<string>> | undefined |
| 226 | private _headers: Headers.Headers | undefined |
| 227 | get headers() { |
| 228 | if (this._headers) { |
| 229 | return this._headers |
| 230 | } |
| 231 | if (this._rawHeaderString === "") { |
| 232 | return this._headers = Headers.empty |
| 233 | } |
| 234 | const parser = HeaderParser.make() |
| 235 | const result = parser(encoder.encode(this._rawHeaderString + "\r\n"), 0) |
| 236 | this._rawHeaders = result._tag === "Headers" ? result.headers : undefined |
| 237 | const parsed = result._tag === "Headers" ? Headers.fromInput(result.headers) : Headers.empty |
| 238 | return this._headers = parsed |
| 239 | } |
| 240 | |
| 241 | cachedCookies: Cookies.Cookies | undefined |
| 242 | get cookies() { |
| 243 | if (this.cachedCookies) { |
| 244 | return this.cachedCookies |
| 245 | } |
| 246 | if (this._rawHeaders === undefined) { |
| 247 | return Cookies.empty |
| 248 | } else if (this._rawHeaders["set-cookie"] === undefined) { |
| 249 | return this.cachedCookies = Cookies.empty |
| 250 | } |
| 251 | return this.cachedCookies = Cookies.fromSetCookie(this._rawHeaders["set-cookie"]) |
| 252 | } |
| 253 | |
| 254 | get remoteAddress() { |
| 255 | return Option.none() |
| 256 | } |
| 257 | |
| 258 | _textEffect: Effect.Effect<string, E> | undefined |
| 259 | get text(): Effect.Effect<string, E> { |
| 260 | if (this._textEffect) { |
| 261 | return this._textEffect |
| 262 | } |
| 263 | if (this.source.responseType === "arraybuffer") { |
| 264 | return this._textEffect = this.arrayBuffer.pipe( |
| 265 | Effect.map((buffer) => new TextDecoder().decode(buffer)), |
| 266 | Effect.cached, |
| 267 | Effect.runSync |
| 268 | ) |
| 269 | } |
| 270 | return this._textEffect = Effect.callback<string, E>((resume) => { |
| 271 | if (this.source.readyState === 4) { |
| 272 | resume(Effect.succeed(this.source.responseText)) |
| 273 | return |
| 274 | } |
| 275 | |
| 276 | const onReadyStateChange = () => { |
| 277 | if (this.source.readyState === 4) { |
| 278 | resume(Effect.succeed(this.source.responseText)) |
| 279 | } |
| 280 | } |
| 281 | const onError = () => { |
| 282 | resume(Effect.fail(this.onError(this.source.statusText))) |
| 283 | } |
| 284 | this.source.addEventListener("readystatechange", onReadyStateChange) |
| 285 | this.source.addEventListener("error", onError) |
| 286 | return Effect.sync(() => { |
| 287 | this.source.removeEventListener("readystatechange", onReadyStateChange) |
| 288 | this.source.removeEventListener("error", onError) |
| 289 | }) |
| 290 | }).pipe( |
| 291 | Effect.cached, |
| 292 | Effect.runSync |
| 293 | ) |
| 294 | } |
| 295 | |
| 296 | get json(): Effect.Effect<Schema.Json, E> { |
| 297 | return Effect.flatMap(this.text, (text) => |
| 298 | Effect.try({ |
| 299 | try: () => text === "" ? null : JSON.parse(text), |
| 300 | catch: this.onError |
| 301 | })) |
| 302 | } |
| 303 | |
| 304 | get urlParamsBody(): Effect.Effect<UrlParams.UrlParams, E> { |
| 305 | return Effect.flatMap(this.text, (text) => |
| 306 | Effect.try({ |
| 307 | try: () => UrlParams.fromInput(new URLSearchParams(text)), |
| 308 | catch: this.onError |
| 309 | })) |
| 310 | } |
| 311 | |
| 312 | get stream(): Stream.Stream<Uint8Array, E> { |
| 313 | if (this.source.responseType === "arraybuffer") { |
| 314 | return Stream.fromEffect(Effect.map(this.arrayBuffer, (buffer) => new Uint8Array(buffer))) |
| 315 | } |
| 316 | return Stream.callback<Uint8Array, E>((queue) => { |
| 317 | let offset = 0 |
| 318 | const onReadyStateChange = () => { |
| 319 | if (this.source.readyState === 3) { |
| 320 | const encoded = encoder.encode(this.source.responseText.slice(offset)) |
| 321 | Queue.offerUnsafe(queue, encoded) |
| 322 | offset = this.source.responseText.length |
| 323 | } else if (this.source.readyState === 4) { |
| 324 | const encoded = encoder.encode(this.source.responseText.slice(offset)) |
| 325 | if (offset < this.source.responseText.length) { |
| 326 | Queue.offerUnsafe(queue, encoded) |
| 327 | } |
| 328 | Queue.endUnsafe(queue) |
| 329 | } |
| 330 | } |
| 331 | const onError = () => { |
| 332 | Queue.failCauseUnsafe(queue, Cause.fail(this.onError(this.source.statusText))) |
| 333 | } |
| 334 | this.source.addEventListener("readystatechange", onReadyStateChange) |
| 335 | this.source.addEventListener("error", onError) |
| 336 | onReadyStateChange() |
| 337 | return Effect.sync(() => { |
| 338 | this.source.removeEventListener("readystatechange", onReadyStateChange) |
| 339 | this.source.removeEventListener("error", onError) |
| 340 | }) |
| 341 | }) |
| 342 | } |
| 343 | |
| 344 | _arrayBufferEffect: Effect.Effect<ArrayBuffer, E> | undefined |
| 345 | get arrayBuffer(): Effect.Effect<ArrayBuffer, E> { |
| 346 | if (this._arrayBufferEffect) { |
| 347 | return this._arrayBufferEffect |
| 348 | } |
| 349 | return this._arrayBufferEffect = Effect.callback<ArrayBuffer, E>((resume) => { |
| 350 | if (this.source.readyState === 4) { |
| 351 | resume(Effect.succeed(this.source.response)) |
| 352 | return |
| 353 | } |
| 354 | |
| 355 | const onReadyStateChange = () => { |
| 356 | if (this.source.readyState === 4) { |
| 357 | resume(Effect.succeed(this.source.response)) |
| 358 | } |
| 359 | } |
| 360 | const onError = () => { |
| 361 | resume(Effect.fail(this.onError(this.source.statusText))) |
| 362 | } |
| 363 | this.source.addEventListener("readystatechange", onReadyStateChange) |
| 364 | this.source.addEventListener("error", onError) |
| 365 | return Effect.sync(() => { |
| 366 | this.source.removeEventListener("readystatechange", onReadyStateChange) |
| 367 | this.source.removeEventListener("error", onError) |
| 368 | }) |
| 369 | }).pipe( |
| 370 | Effect.map((response) => { |
| 371 | if (typeof response === "string") { |
| 372 | const arr = encoder.encode(response) |
| 373 | return arr.byteLength !== arr.buffer.byteLength |
| 374 | ? arr.buffer.slice(arr.byteOffset, arr.byteOffset + arr.byteLength) |
| 375 | : arr.buffer |
| 376 | } |
| 377 | return response |
| 378 | }), |
| 379 | Effect.cached, |
| 380 | Effect.runSync |
| 381 | ) |
| 382 | } |
| 383 | } |
| 384 | |
| 385 | class ClientResponseImpl extends IncomingMessageImpl<HttpClientError.HttpClientError> |
| 386 | implements HttpClientResponse.HttpClientResponse, Pipeable |
| 387 | { |
| 388 | readonly [HttpClientResponse.TypeId]: typeof HttpClientResponse.TypeId |
| 389 | readonly request: HttpClientRequest.HttpClientRequest |
| 390 | private readonly requestUrl: string |
| 391 | |
| 392 | constructor( |
| 393 | request: HttpClientRequest.HttpClientRequest, |
| 394 | source: globalThis.XMLHttpRequest, |
| 395 | requestUrl: string |
| 396 | ) { |
| 397 | super(source, (cause) => |
| 398 | new HttpClientError.HttpClientError({ |
| 399 | reason: new HttpClientError.DecodeError({ |
| 400 | request, |
| 401 | response: this, |
| 402 | cause |
| 403 | }) |
| 404 | })) |
| 405 | this.request = request |
| 406 | this.requestUrl = requestUrl |
| 407 | this[HttpClientResponse.TypeId] = HttpClientResponse.TypeId |
| 408 | } |
| 409 | |
| 410 | get status() { |
| 411 | return this.source.status |
| 412 | } |
| 413 | |
| 414 | get url() { |
| 415 | return (this.source.responseURL || this.requestUrl).split("#")[0] |
| 416 | } |
| 417 | |
| 418 | get formData(): Effect.Effect<FormData, HttpClientError.HttpClientError> { |
| 419 | return Effect.flatMap(this.arrayBuffer, (body) => |
| 420 | Effect.tryPromise({ |
| 421 | try: () => new globalThis.Response(body, { headers: this.headers }).formData(), |
| 422 | catch: this.onError |
| 423 | })) |
| 424 | } |
| 425 | |
| 426 | override toString(): string { |
| 427 | return `ClientResponse(${this.status})` |
| 428 | } |
| 429 | |
| 430 | toJSON(): unknown { |
| 431 | return HttpIncomingMessage.inspect(this, { |
| 432 | _id: "@effect/platform/HttpClientResponse", |
| 433 | request: this.request.toJSON(), |
| 434 | status: this.status |
| 435 | }) |
| 436 | } |
| 437 | |
| 438 | pipe() { |
| 439 | return pipeArguments(this, arguments) |
| 440 | } |
| 441 | } |
| 442 | |
| 443 | /** |
| 444 | * Layer that provides an `HttpClient` implementation backed by the browser `XMLHttpRequest` API. |
| 445 | * |
| 446 | * @category layers |
| 447 | * @since 4.0.0 |
| 448 | */ |
| 449 | export const layerXMLHttpRequest: Layer.Layer<HttpClient.HttpClient> = HttpClient.layerMergedContext( |
| 450 | Effect.succeed(makeXmlHttpRequest) |
| 451 | ) |