From e514c1cde878d8d46e476ef98265b137e1d63bec Mon Sep 17 00:00:00 2001 From: Hello2021Year <78727568+Hello2021Year@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:36:26 +0800 Subject: [PATCH] fix: preserve decoded HTTP proxy response lengths --- lib/utils/resolve-file-proxy.ts | 3 +- routes/proxy.ts | 4 + tests/routes/proxy-compression.test.ts | 143 +++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 tests/routes/proxy-compression.test.ts diff --git a/lib/utils/resolve-file-proxy.ts b/lib/utils/resolve-file-proxy.ts index 3145b4e..f653df4 100644 --- a/lib/utils/resolve-file-proxy.ts +++ b/lib/utils/resolve-file-proxy.ts @@ -75,7 +75,8 @@ async function resolveHttpProxy( headers.set("Content-Type", contentType) } const contentLength = response.headers.get("Content-Length") - if (contentLength) { + // A decoded response body no longer has the upstream compressed length. + if (contentLength && !response.headers.has("Content-Encoding")) { headers.set("Content-Length", contentLength) } const fileName = relativePath.split("/").pop() || "file" diff --git a/routes/proxy.ts b/routes/proxy.ts index eb7848a..5fdc4bb 100644 --- a/routes/proxy.ts +++ b/routes/proxy.ts @@ -82,6 +82,10 @@ export default withRouteSpec({ // Create a new response with the target's body but without problematic headers const responseHeaders = new Headers(response.headers) + // fetch decodes compressed bodies but retains their encoded byte length. + if (req.method !== "HEAD" && responseHeaders.has("content-encoding")) { + responseHeaders.delete("content-length") + } responseHeaders.delete("content-encoding") // Ensure no content-encoding in response return new Response(response.body, { diff --git a/tests/routes/proxy-compression.test.ts b/tests/routes/proxy-compression.test.ts new file mode 100644 index 0000000..adca13e --- /dev/null +++ b/tests/routes/proxy-compression.test.ts @@ -0,0 +1,143 @@ +import { afterEach, expect, test } from "bun:test" +import { getTestServer } from "tests/fixtures/get-test-server" +import { createFetchHandlerFromDir } from "winterspec/adapters/node" +import { createDatabase } from "lib/db/db-client" +import { join } from "node:path" + +const content = "compressible payload ".repeat(1000) +const bytes = new TextEncoder().encode(content) +const compressed = Bun.gzipSync(bytes) + +const getUpstream = (gzip: boolean) => { + const body = gzip ? compressed : bytes + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch: () => + new Response(body, { + headers: { + "Content-Type": "text/plain", + "Content-Length": body.byteLength.toString(), + ...(gzip ? { "Content-Encoding": "gzip" } : {}), + }, + }), + }) + afterEach(() => server.stop(true)) + return server.url.toString() +} + +for (const path of [ + "/proxy", + "/files/download?file_path=compressed/test.txt", + "/files/download/compressed/test.txt", + "/files/static/compressed/test.txt", +]) { + test(`proxy response length matches its decoded body via ${path}`, async () => { + const upstream = getUpstream(true) + const db = createDatabase() + const handler = await createFetchHandlerFromDir( + join(import.meta.dir, "../../routes"), + { + middleware: [ + async (req, ctx, next) => { + Object.assign(ctx, { db }) + return next(req, ctx) + }, + ], + }, + ) + await handler("http://localhost/file_proxies/create", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + proxy_type: "http", + http_target_url: upstream, + matching_pattern: "compressed/*", + }), + }) + + // Inspect the returned Response before Bun.serve can repair its length. + // Node HTTP adapters use this header as-is and otherwise truncate the body. + const response = await handler(`http://localhost${path}`, { + headers: { "X-Target-Url": upstream }, + }) + expect(response.status).toBe(200) + expect(response.headers.get("content-encoding")).toBeNull() + const body = await response.arrayBuffer() + expect(new TextDecoder().decode(body)).toBe(content) + const length = response.headers.get("content-length") + if (length !== null) { + expect(Number(length)).toBe(body.byteLength) + } + }) +} + +for (const path of [ + "/files/download?file_path=compressed/test.txt", + "/files/download/compressed/test.txt", + "/files/static/compressed/test.txt", +]) { + test(`HTTP file proxy returns complete decompressed content via ${path}`, async () => { + const upstream = getUpstream(true) + const { axios, url } = await getTestServer() + await axios.post("/file_proxies/create", { + proxy_type: "http", + http_target_url: upstream, + matching_pattern: "compressed/*", + }) + + const response = await fetch(`${url}${path}`) + expect(response.status).toBe(200) + expect(response.headers.get("content-type")).toBe("text/plain") + expect(response.headers.get("content-encoding")).toBeNull() + expect(await response.text()).toBe(content) + }) +} + +test("general proxy returns complete decompressed content", async () => { + const upstream = getUpstream(true) + const { url } = await getTestServer() + const response = await fetch(`${url}/proxy`, { + headers: { "X-Target-Url": upstream }, + }) + + expect(response.status).toBe(200) + expect(response.headers.get("content-type")).toBe("text/plain") + expect(response.headers.get("content-encoding")).toBeNull() + expect(await response.text()).toBe(content) +}) + +for (const gzip of [false, true]) { + test(`general proxy preserves HEAD content length (gzip: ${gzip})`, async () => { + const upstream = getUpstream(gzip) + const { url } = await getTestServer() + const response = await fetch(`${url}/proxy`, { + method: "HEAD", + headers: { "X-Target-Url": upstream }, + }) + + expect(response.status).toBe(200) + expect(response.headers.get("content-length")).toBe( + (gzip ? compressed : bytes).byteLength.toString(), + ) + expect(await response.text()).toBe("") + }) +} + +test("HTTP file proxy still returns complete uncompressed content", async () => { + const upstream = getUpstream(false) + const { axios, url } = await getTestServer() + await axios.post("/file_proxies/create", { + proxy_type: "http", + http_target_url: upstream, + matching_pattern: "plain/*", + }) + + const response = await fetch(`${url}/files/download/plain/test.txt`) + expect(response.status).toBe(200) + expect(response.headers.get("content-type")).toBe("text/plain") + expect(response.headers.get("content-disposition")).toBe( + 'attachment; filename="test.txt"', + ) + expect(await response.text()).toBe(content) +})