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) {