From 3111ab92fccc2351454c7cc46960cf756d3d0bbf Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:25:29 +0000 Subject: [PATCH] fix(workspaces): send content-length for R2 processor requests The R2 storage migration (31e5e0f6) started streaming the R2 object body straight to the file processor with duplex: "half" but only a custom x-file-size header. Cloudflare Workers refuses to send a streamed request body without a known length, so every preview generation threw `TypeError: Provided readable stream must have a known length`. Declare the length via the standard content-length header using the already-threaded verified sizeBytes. This covers all processor paths (/preview/*, /parse/pdf, /validate/pdf) since they share the helper. Generated-By: PostHog Code Task-Id: 571bc333-acb2-4fc8-b784-76b09256b91d --- .../files/workspace-file-processor.test.ts | 66 +++++++++++++++++++ .../files/workspace-file-processor.ts | 3 + 2 files changed, 69 insertions(+) create mode 100644 src/features/workspaces/files/workspace-file-processor.test.ts diff --git a/src/features/workspaces/files/workspace-file-processor.test.ts b/src/features/workspaces/files/workspace-file-processor.test.ts new file mode 100644 index 00000000..b064e773 --- /dev/null +++ b/src/features/workspaces/files/workspace-file-processor.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { requestWorkspaceFileProcessor } from "#/features/workspaces/files/workspace-file-processor"; + +const getRandom = vi.hoisted(() => vi.fn()); + +vi.mock("@cloudflare/containers", () => ({ + Container: class {}, + getRandom, +})); + +describe("requestWorkspaceFileProcessor", () => { + beforeEach(() => { + getRandom.mockReset(); + }); + + it("declares a known content length so Workers can stream the R2 body", async () => { + const fetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); + getRandom.mockResolvedValue({ + fetch, + startAndWaitForPorts: vi.fn().mockResolvedValue(undefined), + }); + + await requestWorkspaceFileProcessor({} as Cloudflare.Env, { + body: streamOf(new Uint8Array([1, 2, 3])), + contentType: "application/pdf", + path: "/preview/pdf", + sizeBytes: 3, + }); + + expect(fetch).toHaveBeenCalledOnce(); + const request = fetch.mock.calls[0]![0] as Request; + expect(request.headers.get("content-length")).toBe("3"); + expect(request.headers.get("x-file-size")).toBe("3"); + expect(request.headers.get("content-type")).toBe("application/pdf"); + }); + + it("encodes the optional file name header", async () => { + const fetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); + getRandom.mockResolvedValue({ + fetch, + startAndWaitForPorts: vi.fn().mockResolvedValue(undefined), + }); + + await requestWorkspaceFileProcessor({} as Cloudflare.Env, { + body: streamOf(new Uint8Array([1])), + contentType: "image/png", + fileName: "my file.png", + path: "/preview/image", + sizeBytes: 1, + }); + + const request = fetch.mock.calls[0]![0] as Request; + expect(request.headers.get("x-file-name")).toBe("my%20file.png"); + }); +}); + +function streamOf(bytes: Uint8Array) { + const body = new Response(bytes.slice().buffer).body; + + if (!body) { + throw new Error("Test stream was not created."); + } + + return body; +} diff --git a/src/features/workspaces/files/workspace-file-processor.ts b/src/features/workspaces/files/workspace-file-processor.ts index f738a183..f900fca9 100644 --- a/src/features/workspaces/files/workspace-file-processor.ts +++ b/src/features/workspaces/files/workspace-file-processor.ts @@ -32,6 +32,9 @@ export async function requestWorkspaceFileProcessor( }); const headers = new Headers({ + // Cloudflare Workers refuses to send a streamed request body without a known + // length, so declare it up front from the source object's verified size. + "content-length": String(input.sizeBytes), "content-type": input.contentType, "x-file-size": String(input.sizeBytes), });