Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/httpclient-response-to-web.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions packages/effect/src/unstable/http/HttpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
37 changes: 36 additions & 1 deletion packages/effect/src/unstable/http/HttpClientResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,17 @@ export const TypeId = "~effect/http/HttpClientResponse"
export interface HttpClientResponse extends HttpIncomingMessage.HttpIncomingMessage<Error.HttpClientError>, 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.
Expand All @@ -76,6 +87,30 @@ export interface HttpClientResponse extends HttpIncomingMessage.HttpIncomingMess
readonly formData: Effect.Effect<FormData, Error.HttpClientError>
}

/**
* 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`.
*
Expand Down Expand Up @@ -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,
Expand Down
36 changes: 36 additions & 0 deletions packages/effect/test/unstable/http/HttpClientResponse.test.ts
Original file line number Diff line number Diff line change
@@ -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)
}))
})
})
18 changes: 18 additions & 0 deletions packages/effect/typetest/unstable/http/HttpClientResponse.tst.ts
Original file line number Diff line number Diff line change
@@ -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<object | undefined>()
})
})

describe("toWeb", () => {
it("should return the original Web Response when available", () => {
expect(HttpClientResponse.toWeb(response)).type.toBe<globalThis.Response | undefined>()
})
})
})