From 407498611263c24b7d88e4fb7aee0f0701ac87d4 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 12 Aug 2026 16:57:46 +0300 Subject: [PATCH] fix(relay): deliver an explicit empty body frame for zero-chunk body sources A request body stream that yields no chunks still declares hasBody in the request head; send one empty HttpBody frame so the host can tell an intentionally empty body apart from body frames lost in transit. --- .../ui/src/lib/relay/tunnel-client.test.ts | 21 +++++++++++++++++++ packages/ui/src/lib/relay/tunnel-client.ts | 9 ++++++++ 2 files changed, 30 insertions(+) diff --git a/packages/ui/src/lib/relay/tunnel-client.test.ts b/packages/ui/src/lib/relay/tunnel-client.test.ts index 7972b342..4616c7ad 100644 --- a/packages/ui/src/lib/relay/tunnel-client.test.ts +++ b/packages/ui/src/lib/relay/tunnel-client.test.ts @@ -312,6 +312,27 @@ describe('createRelayTunnelClient', () => { expect(await c.text()).toBe('payload-xyz'); }); + test('a body source with zero chunks still delivers an explicit empty body frame', async () => { + const frames: TunnelFrame[] = []; + const { client } = await setupClient({ recordFrame: (frame) => frames.push(frame) }); + track(client); + const emptyStream = new ReadableStream({ + start(controller) { + controller.close(); + }, + }); + const response = await client.fetch('/echo-body', { method: 'POST', body: emptyStream }); + expect(response.status).toBe(200); + expect(await response.text()).toBe(''); + // The head declares hasBody, so the host must see at least one HttpBody + // frame — otherwise it treats the body as lost in transit and aborts. + const request = frames.find((frame) => frame.frameType === TunnelFrameType.HttpRequest); + expect(request).toBeDefined(); + const bodyFrames = frames.filter((frame) => frame.frameType === TunnelFrameType.HttpBody && frame.streamId === request!.streamId); + expect(bodyFrames.length).toBe(1); + expect(bodyFrames[0]!.payload.length).toBe(0); + }); + test('streams a response body incrementally', async () => { const { client } = await setupClient(); track(client); diff --git a/packages/ui/src/lib/relay/tunnel-client.ts b/packages/ui/src/lib/relay/tunnel-client.ts index ac8bd9b0..24a9d985 100644 --- a/packages/ui/src/lib/relay/tunnel-client.ts +++ b/packages/ui/src/lib/relay/tunnel-client.ts @@ -824,15 +824,24 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela channel.send(encodeTunnelFrame(TunnelFrameType.HttpRequest, streamId, encodeJsonPayload(head))); void (async () => { try { + let sentBodyFrame = false; if (request.body) { for await (const chunk of request.body) { if (finished || channel.dead) return; for (const piece of chunkPayload(chunk)) { channel.send(encodeTunnelFrame(TunnelFrameType.HttpBody, streamId, piece)); + sentBodyFrame = true; } } } if (!finished && !channel.dead) { + // A body source that yielded no chunks (e.g. an empty stream) still + // declared hasBody in the head. Emit one empty body frame so the + // host can tell this apart from body frames lost in transit, which + // it aborts as an ambiguous transport failure. + if (request.body && !sentBodyFrame) { + channel.send(encodeTunnelFrame(TunnelFrameType.HttpBody, streamId, EMPTY_PAYLOAD)); + } channel.send(encodeTunnelFrame(TunnelFrameType.StreamEnd, streamId, EMPTY_PAYLOAD)); } } catch (error) {