diff --git a/.changeset/http-incoming-message-inspect-no-consume-body.md b/.changeset/http-incoming-message-inspect-no-consume-body.md new file mode 100644 index 00000000000..ff081e6d505 --- /dev/null +++ b/.changeset/http-incoming-message-inspect-no-consume-body.md @@ -0,0 +1,5 @@ +--- +"@effect/platform": patch +--- + +Stop `HttpIncomingMessage` inspection from consuming the body. Logging or `toJSON`-ing an `HttpClientResponse` (or server request) with a streamed body previously read the body eagerly, which locked the underlying one-shot `ReadableStream` and made subsequent `response.stream` reads fail with `ReadableStream is locked`. diff --git a/packages/platform/src/HttpIncomingMessage.ts b/packages/platform/src/HttpIncomingMessage.ts index b7417a87ee3..d54e8523a49 100644 --- a/packages/platform/src/HttpIncomingMessage.ts +++ b/packages/platform/src/HttpIncomingMessage.ts @@ -101,28 +101,12 @@ export const withMaxBodySize = dual< * @since 1.0.0 */ export const inspect = (self: HttpIncomingMessage, that: object): object => { - const contentType = self.headers["content-type"] ?? "" - let body: unknown - if (contentType.includes("application/json")) { - try { - body = Effect.runSync(self.json) - } catch { - // - } - } else if (contentType.includes("text/") || contentType.includes("urlencoded")) { - try { - body = Effect.runSync(self.text) - } catch { - // - } - } - const obj: any = { + // NOTE: intentionally does not read the body here. Reading `self.text` / + // `self.json` eagerly consumes (and locks) the underlying one-shot body + // stream, which corrupts the message just by logging/inspecting it. + return { ...that, headers: Inspectable.redact(self.headers), remoteAddress: self.remoteAddress.toJSON() } - if (body !== undefined) { - obj.body = body - } - return obj } diff --git a/packages/platform/test/HttpClient.test.ts b/packages/platform/test/HttpClient.test.ts index d9dfba4f4f8..f27dd9f3f08 100644 --- a/packages/platform/test/HttpClient.test.ts +++ b/packages/platform/test/HttpClient.test.ts @@ -293,6 +293,28 @@ describe("HttpClient", () => { }) }) + it.effect("logging a streamed response does not lock its body", () => + Effect.gen(function*() { + const request = HttpClientRequest.get("http://example.com") + const source = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("data: hello\n\n")) + controller.enqueue(new TextEncoder().encode("data: world\n\n")) + controller.close() + } + }), + { headers: { "content-type": "text/event-stream; charset=utf-8" } } + ) + const response = HttpClientResponse.fromWeb(request, source) + + // Inspecting/logging the response must not consume its one-shot body. + Inspectable.toStringUnknown(response) + + const body = yield* response.stream.pipe(Stream.decodeText(), Stream.mkString) + strictEqual(body, "data: hello\n\ndata: world\n\n") + })) + it.effect("followRedirects", () => Effect.gen(function*() { const defaultClient = yield* HttpClient.HttpClient