diff --git a/.changeset/httpclient-response-to-web.md b/.changeset/httpclient-response-to-web.md new file mode 100644 index 00000000000..fefc88eaaa9 --- /dev/null +++ b/.changeset/httpclient-response-to-web.md @@ -0,0 +1,7 @@ +--- +"effect": patch +--- + +Add `HttpClientResponse.toWeb`, which returns the original Web `Response` backing a client response when the client exposes one. + +The returned response keeps runtime-specific body capabilities that a stream rebuilt in JavaScript cannot carry. On workerd, `HttpClientResponse.toWeb(response)?.body` reports a known length to `R2Bucket.put`, while `Stream.toReadableStreamEffect(response.stream)` produces a rebuilt stream that does not. diff --git a/packages/effect/src/unstable/http/HttpClient.ts b/packages/effect/src/unstable/http/HttpClient.ts index 979108d2df9..a112ea28dbf 100644 --- a/packages/effect/src/unstable/http/HttpClient.ts +++ b/packages/effect/src/unstable/http/HttpClient.ts @@ -1721,6 +1721,10 @@ class InterruptibleResponse implements HttpClientResponse.HttpClientResponse, Pi return this.original.request } + get source() { + return this.original.source + } + get url() { return this.original.url } diff --git a/packages/effect/src/unstable/http/HttpClientResponse.ts b/packages/effect/src/unstable/http/HttpClientResponse.ts index 487bf29fc81..57c97918cf5 100644 --- a/packages/effect/src/unstable/http/HttpClientResponse.ts +++ b/packages/effect/src/unstable/http/HttpClientResponse.ts @@ -66,6 +66,17 @@ export const TypeId = "~effect/http/HttpClientResponse" export interface HttpClientResponse extends HttpIncomingMessage.HttpIncomingMessage, Pipeable { readonly [TypeId]: typeof TypeId readonly request: HttpClientRequest.HttpClientRequest + /** + * The platform response this value was built from, when the client exposes one. + * + * **Details** + * + * `FetchHttpClient` exposes the original Web `Response`. Keeping that object + * matters on runtimes that attach capabilities to it: on workerd, only a + * native response body reports a known length to `R2Bucket.put`, and any + * stream rebuilt in JavaScript loses it. Use `toWeb` to narrow this value. + */ + readonly source?: object | undefined /** * The resolved URL, including query parameters and excluding the hash. * Uses the final URL when redirects are followed. Empty if unknown. @@ -76,6 +87,30 @@ export interface HttpClientResponse extends HttpIncomingMessage.HttpIncomingMess readonly formData: Effect.Effect } +/** + * Returns the original Web `Response` backing a client response, when available. + * + * **When to use** + * + * Use when you need the native response object instead of the Effect body + * accessors, for example to hand its body to a runtime API that inspects it. + * + * **Details** + * + * The returned `Response` is the same object the client received, so its body + * keeps runtime-specific capabilities that a rebuilt stream cannot carry. On + * workerd, `Response.body` reports a known length to `R2Bucket.put` while + * `Stream.toReadableStream` output does not. Returns `undefined` for clients + * that do not expose a Web `Response`. + * + * @category converting + * @since 4.0.0 + */ +export const toWeb = (self: HttpClientResponse): globalThis.Response | undefined => + typeof globalThis.Response !== "undefined" && self.source instanceof globalThis.Response + ? self.source + : undefined + /** * Wraps a Web `Response` and its original `HttpClientRequest` as an `HttpClientResponse`. * @@ -257,7 +292,7 @@ class WebHttpClientResponse extends Inspectable.Class implements HttpClientRespo readonly [TypeId]: typeof TypeId readonly request: HttpClientRequest.HttpClientRequest - private readonly source: globalThis.Response + readonly source: globalThis.Response constructor( request: HttpClientRequest.HttpClientRequest, diff --git a/packages/effect/test/unstable/http/HttpClientResponse.test.ts b/packages/effect/test/unstable/http/HttpClientResponse.test.ts new file mode 100644 index 00000000000..d3ea21dfcb6 --- /dev/null +++ b/packages/effect/test/unstable/http/HttpClientResponse.test.ts @@ -0,0 +1,36 @@ +import { assert, describe, it } from "@effect/vitest" +import { Effect } from "effect" +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" + +describe("HttpClientResponse", () => { + describe("toWeb", () => { + it.effect("returns the original Web Response instead of rebuilding it", () => + Effect.sync(() => { + const source = new Response(new Uint8Array([1, 2, 3])) + const response = HttpClientResponse.fromWeb(HttpClientRequest.get("https://example.com/"), source) + + assert.strictEqual(HttpClientResponse.toWeb(response), source) + // The body must be the native one, not a stream rebuilt in JavaScript, + // so runtime-specific capabilities (e.g. workerd's known length) survive. + assert.strictEqual(HttpClientResponse.toWeb(response)?.body, source.body) + })) + + it.effect("returns the original Web Response through HttpClient.execute", () => + Effect.gen(function*() { + const source = new Response(new Uint8Array([1, 2, 3])) + const client = HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, source))) + + const response = yield* HttpClient.execute(HttpClientRequest.get("https://example.com/")).pipe( + Effect.provideService(HttpClient.HttpClient, client) + ) + + assert.strictEqual(HttpClientResponse.toWeb(response), source) + })) + + it.effect("returns undefined when no Web Response backs the response", () => + Effect.sync(() => { + const response = { source: {} } as unknown as HttpClientResponse.HttpClientResponse + assert.strictEqual(HttpClientResponse.toWeb(response), undefined) + })) + }) +}) diff --git a/packages/effect/typetest/unstable/http/HttpClientResponse.tst.ts b/packages/effect/typetest/unstable/http/HttpClientResponse.tst.ts new file mode 100644 index 00000000000..ae263235646 --- /dev/null +++ b/packages/effect/typetest/unstable/http/HttpClientResponse.tst.ts @@ -0,0 +1,18 @@ +import { HttpClientResponse } from "effect/unstable/http" +import { describe, expect, it } from "tstyche" + +declare const response: HttpClientResponse.HttpClientResponse + +describe("HttpClientResponse", () => { + describe("source", () => { + it("should be an optional platform object", () => { + expect(response.source).type.toBe() + }) + }) + + describe("toWeb", () => { + it("should return the original Web Response when available", () => { + expect(HttpClientResponse.toWeb(response)).type.toBe() + }) + }) +})