From 3130f5cf5af538559d169d17aac1d7465bbf0e91 Mon Sep 17 00:00:00 2001 From: Xia Chao Date: Wed, 23 Sep 2026 16:20:39 +0800 Subject: [PATCH] feat(http): expose the original Web Response on HttpClientResponse HttpClientResponse now exposes the platform response it was built from and adds toWeb, which returns the original Web Response when the client provides one. Runtimes can attach capabilities to native response bodies that a stream rebuilt in JavaScript cannot carry. On workerd, R2Bucket.put accepts toWeb(response).body but rejects Stream.toReadableStreamEffect(response.stream) with "Provided readable stream must have a known length". Refs #8205 --- .changeset/httpclient-response-to-web.md | 7 ++++ packages/effect/src/http/HttpClient.ts | 4 ++ .../effect/src/http/HttpClientResponse.ts | 38 ++++++++++++++++++- .../test/http/HttpClientResponse.test.ts | 36 ++++++++++++++++++ .../typetest/http/HttpClientResponse.tst.ts | 18 +++++++++ 5 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 .changeset/httpclient-response-to-web.md create mode 100644 packages/effect/test/http/HttpClientResponse.test.ts create mode 100644 packages/effect/typetest/http/HttpClientResponse.tst.ts 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/http/HttpClient.ts b/packages/effect/src/http/HttpClient.ts index 184bfb0ba0b..14741fcebd7 100644 --- a/packages/effect/src/http/HttpClient.ts +++ b/packages/effect/src/http/HttpClient.ts @@ -1790,6 +1790,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/http/HttpClientResponse.ts b/packages/effect/src/http/HttpClientResponse.ts index 1e076991685..74ce7dfaf74 100644 --- a/packages/effect/src/http/HttpClientResponse.ts +++ b/packages/effect/src/http/HttpClientResponse.ts @@ -75,6 +75,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. @@ -85,6 +96,31 @@ 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`. + * + * @stability unstable + * @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`. * @@ -273,7 +309,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/http/HttpClientResponse.test.ts b/packages/effect/test/http/HttpClientResponse.test.ts new file mode 100644 index 00000000000..a31108ba546 --- /dev/null +++ b/packages/effect/test/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/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/http/HttpClientResponse.tst.ts b/packages/effect/typetest/http/HttpClientResponse.tst.ts new file mode 100644 index 00000000000..a80b1c2c6d7 --- /dev/null +++ b/packages/effect/typetest/http/HttpClientResponse.tst.ts @@ -0,0 +1,18 @@ +import { HttpClientResponse } from "effect/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() + }) + }) +})