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.
This commit is contained in:
Bohdan Triapitsyn
2026-08-12 16:57:46 +03:00
parent 95c5a0a69c
commit 4074986112
2 changed files with 30 additions and 0 deletions
@@ -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<Uint8Array>({
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);
@@ -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) {