Skip to content

πŸ› Bug Report β€” Runtime APIs: formData() corrupts binary file parts with typescript_implemented_streamsΒ #8037

Closed

Description

@james-elicx

Description

With typescript_implemented_streams, Body.formData() corrupts binary file parts: every byte that is not valid UTF-8 comes back as U+FFFD (EF BF BD). The parsed file is larger than the upload and its contents are wrong. The C++ streams implementation returns the bytes unchanged.

Any Worker that accepts binary uploads through request.formData() is affected. We found it while testing vinext (Next.js on Workers), where every route-handler and Server Action file upload broke. A 100,000-byte upload parsed to 180,948 bytes in a deployed Worker with the flag.

Reproduction

worker.js:

export default {
  async test() {
    const bytes = new Uint8Array([0x00, 0x7f, 0x80, 0xff]);
    const form = new FormData();
    form.append("file", new File([bytes], "file.bin"));

    const parsed = await new Request("https://example.com/", { method: "POST", body: form }).formData();
    const out = new Uint8Array(await parsed.get("file").arrayBuffer());

    console.log(`sent ${JSON.stringify([...bytes])}, parsed ${JSON.stringify([...out])}`);
    if (out.length !== bytes.length || out.some((byte, i) => byte !== bytes[i])) {
      throw new Error("formData() changed the file part's bytes");
    }
  },
};

config.capnp:

using Workerd = import "/workerd/workerd.capnp";

const config :Workerd.Config = (
  services = [(name = "main", worker = .worker)],
  # typescript_implemented_streams only starts with this autogate.
  autogates = ["workerd-autogate-per-isolate-javascript-bootstrap"],
);

const worker :Workerd.Worker = (
  modules = [(name = "worker", esModule = embed "worker.js")],
  compatibilityDate = "2026-02-12",
  compatibilityFlags = ["typescript_implemented_streams"],
);
workerd test --experimental config.capnp

With the flag (workerd 2026-10-27, @cloudflare/workerd-darwin-arm64@1.20261027.1):

sent [0,127,128,255], parsed [0,127,239,191,189,239,191,189]
[ FAIL ] main

With compatibilityFlags = [] (C++ streams):

sent [0,127,128,255], parsed [0,127,128,255]
[ PASS ] main

The same corruption happens for a real multipart request body, and for new Response(arrayBuffer, { headers }).formData(). Reading the same body with arrayBuffer() returns the correct bytes, and FormData serialisation is byte-identical between the two implementations, so only parsing is affected.

Expected behavior

formData() returns file parts byte-for-byte, as it does with the C++ implementation.

Suspected path

Body::formData() (src/workerd/api/http.c++) reads the body with bodyStream.text() and passes the resulting kj::String to FormData::parse(). With the C++ implementation, readAllText() returns the body bytes undecoded. With the TypeScript implementation, JsReadableStream::text() goes through getReadableStreamText() (src/workerd/api/js-readable-stream.c++), which calls consumeReadableStreamAsText. That decodes UTF-8 into a JS string, replacing invalid sequences with U+FFFD, before the string is converted back into a kj::String. Reading the bytes (consumeReadableStreamAsArrayBuffer) for formData() would avoid the decode.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions