From 059a57d9b7106a759e6f705f6926ea906c432fdf Mon Sep 17 00:00:00 2001 From: Anthony Date: Tue, 11 Aug 2026 14:42:40 +0000 Subject: [PATCH] fix(server): stop the proxy returning empty bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The landing page returned 200 with a zero-byte body in production. fetch transparently decompresses what it receives, but the proxy forwarded the child's content-encoding header unchanged — so the response claimed gzip while carrying plain bytes, and the client decoded it to nothing. The API paths were unaffected because they are served in-process, and /today was a redirect with no body, so only the landing page showed it. The proxy now requests identity encoding from the child and strips content-encoding, content-length and transfer-encoding from the response it returns. Those describe the child's wire format, not this one. CI could not have caught this: plain curl sends no Accept-Encoding, so the child never compressed and the mismatch never arose. The landing check now uses --compressed, like a browser, and asserts a non-empty body. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 21 ++++++++++++++++----- apps/server/src/index.ts | 28 ++++++++++++++++++++++++++-- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84d250b..1cb5365 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,16 +122,27 @@ jobs: *) echo "expected the inbox or a redirect to /login"; docker logs og; exit 1 ;; esac - - name: The landing page is public + - name: The landing page is public and actually has a body run: | - # / must render for a signed-out visitor, not redirect to /login. - code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/) - echo "GET / -> $code" + # --compressed matters: plain curl sends no Accept-Encoding, but every + # browser does. A proxy that forwards the child's content-encoding + # while fetch has already decompressed the body returns a 200 with + # zero bytes — which shipped once, and which a bare curl cannot see. + code=$(curl -s --compressed -o /tmp/landing.html -w '%{http_code}' http://127.0.0.1:8080/) + bytes=$(wc -c < /tmp/landing.html) + echo "GET / -> $code, $bytes bytes" + [ "$code" = "200" ] || { echo "the landing page must be public"; docker logs og; exit 1; } + [ "$bytes" -gt 1000 ] || { echo "landing page body is empty or truncated"; docker logs og; exit 1; } - curl -s http://127.0.0.1:8080/ | grep -q "warm conversations" \ + grep -q "warm conversations" /tmp/landing.html \ || { echo "landing page did not render its content"; exit 1; } + # The signed-in shell must survive compression too. + login_bytes=$(curl -s --compressed http://127.0.0.1:8080/login | wc -c) + echo "GET /login -> $login_bytes bytes" + [ "$login_bytes" -gt 500 ] || { echo "/login body is empty"; docker logs og; exit 1; } + - name: Auth is enforced run: | code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/api/v1/recommendations) diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index cc96fb6..69687d3 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -113,10 +113,19 @@ async function proxyToWeb(request: Request): Promise { const incoming = new URL(request.url); const target = new URL(incoming.pathname + incoming.search, `http://127.0.0.1:${WEB_PORT}`); + // Ask the child for identity encoding. `fetch` transparently decompresses + // what it receives, so a forwarded `content-encoding: gzip` would describe a + // body that is no longer compressed — the client then fails to decode it and + // renders nothing. Not requesting compression over loopback avoids the + // mismatch entirely and costs nothing. + const headers = new Headers(request.headers); + headers.set('accept-encoding', 'identity'); + + let response: Response; try { - return await fetch(target, { + response = await fetch(target, { method: request.method, - headers: request.headers, + headers, body: request.method === 'GET' || request.method === 'HEAD' ? undefined : request.body, redirect: 'manual', // Streaming bodies require this; without it Bun buffers and rejects. @@ -126,6 +135,21 @@ async function proxyToWeb(request: Request): Promise { console.error('web proxy failed', error); return new Response('the web application is not responding', { status: 502 }); } + + // Strip hop-by-hop and body-framing headers. Whatever the child said about + // length or encoding describes its own wire format, not the one this + // response will be sent with. + const outbound = new Headers(response.headers); + outbound.delete('content-encoding'); + outbound.delete('content-length'); + outbound.delete('transfer-encoding'); + outbound.delete('connection'); + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: outbound, + }); } // ------------------------------------------------------------------- server