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 */
13import * as Cause from "effect/Cause"
14import * as Context from "effect/Context"
15import * as Effect from "effect/Effect"
16import type { LazyArg } from "effect/Function"
17import * as Cookies from "effect/http/Cookies"
18import * as Headers from "effect/http/Headers"
19import * as HttpClient from "effect/http/HttpClient"
20import * as HttpClientError from "effect/http/HttpClientError"
21import type * as HttpClientRequest from "effect/http/HttpClientRequest"
22import * as HttpClientResponse from "effect/http/HttpClientResponse"
23import * as HttpIncomingMessage from "effect/http/HttpIncomingMessage"
24import * as HeaderParser from "effect/http/MultipartParser/HeadersParser"
25import * as UrlParams from "effect/http/UrlParams"
26import * as Inspectable from "effect/Inspectable"
27import type * as Layer from "effect/Layer"
28import * as Option from "effect/Option"
29import { type Pipeable, pipeArguments } from "effect/Pipeable"
30import * as Queue from "effect/Queue"
31import type * as Schema from "effect/Schema"
32import * as Stream from "effect/Stream"
33
34// =============================================================================
35// Fetch
36// =============================================================================
37
38export {
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 */
77export 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 */
93export 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 */
104export 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 */
119export class XMLHttpRequest extends Context.Service<
120 XMLHttpRequest,
121 LazyArg<globalThis.XMLHttpRequest>
122>()("@effect/platform-browser/BrowserHttpClient/XMLHttpRequest") {}
123
124const makeXhrRequest = () => new globalThis.XMLHttpRequest()
125
126const 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
171const 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
209const encoder = new TextEncoder()
210
211abstract 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
385class 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 */
449export const layerXMLHttpRequest: Layer.Layer<HttpClient.HttpClient> = HttpClient.layerMergedContext(
450 Effect.succeed(makeXmlHttpRequest)
451)