From ad56e0fd968078db56ac41f3f181be713c3d824d Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Fri, 14 Aug 2026 05:05:19 +0000
Subject: [PATCH 01/20] FormData: throw when serializing an S3-backed entry
instead of sending an empty part
Blob::from_dom_form_data builds the multipart body synchronously. For an
entry backed by an S3 store it wrote the part headers and no content, so
new Response(formData), new Request() and fetch() all succeeded and the
receiver stored an empty file. The serializer now throws a TypeError
naming the entry, and no longer resolves the entry's size first, which
for an S3 blob replaced the unknown size with 0.
---
src/runtime/webcore/Blob.rs | 13 ++-
.../FormData-multipart-serialization.test.ts | 91 +++++++++++++++++++
2 files changed, 101 insertions(+), 3 deletions(-)
diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs
index eb149d320b7a..918f3bbbb6b7 100644
--- a/src/runtime/webcore/Blob.rs
+++ b/src/runtime/webcore/Blob.rs
@@ -3939,7 +3939,9 @@ impl FormDataContext<'_> {
joiner.push_static(b"\r\n\r\n");
if blob.store.get().is_some() {
- if blob.size.get() == MAX_SIZE {
+ // `resolve_size` zeroes an S3 blob's size; the S3 arm below
+ // rejects the entry, so leave it untouched.
+ if blob.size.get() == MAX_SIZE && !blob.is_s3() {
blob.resolve_size();
}
let store = blob
@@ -3949,8 +3951,13 @@ impl FormDataContext<'_> {
.expect("infallible: store present");
match &store.data {
store::Data::S3(_) => {
- // TODO: s3
- // we need to make this async and use download/downloadSlice
+ // This serializer is synchronous and S3 objects can
+ // only be fetched asynchronously. Emitting the part
+ // with an empty body would silently upload nothing.
+ self.failed = true;
+ let _ = global_this.throw_type_error(format_args!(
+ "FormData entry \"{name}\" is an S3 file, which cannot be read while serializing the body. Read it first: formData.append(name, new Blob([await s3file.bytes()]), filename)"
+ ));
}
store::Data::File(file) => {
// TODO: make this async + lazy
diff --git a/test/js/web/html/FormData-multipart-serialization.test.ts b/test/js/web/html/FormData-multipart-serialization.test.ts
index b12321a2b9a8..370953a289c2 100644
--- a/test/js/web/html/FormData-multipart-serialization.test.ts
+++ b/test/js/web/html/FormData-multipart-serialization.test.ts
@@ -1,3 +1,4 @@
+import { S3Client } from "bun";
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, isLinux } from "harness";
@@ -212,3 +213,93 @@ describe("multipart serialization (new Response(formData))", () => {
expect(exitCode).toBe(0);
});
});
+
+// The serializer is synchronous and an S3 object's bytes can only be fetched
+// asynchronously, so an S3-backed entry cannot be serialized. It used to be
+// written out as the part headers followed by an empty body: no error, no S3
+// request, and the receiver stored an empty file.
+describe("multipart serialization of S3-backed entries", () => {
+ // Nothing below performs an S3 request, so the endpoint only has to parse.
+ const s3 = new S3Client({
+ endpoint: "http://127.0.0.1:1",
+ bucket: "bucket",
+ accessKeyId: "access-key",
+ secretAccessKey: "secret-key",
+ });
+
+ const expectedMessage =
+ 'FormData entry "report" is an S3 file, which cannot be read while serializing the body. ' +
+ "Read it first: formData.append(name, new Blob([await s3file.bytes()]), filename)";
+
+ function formDataWith(entry: Blob): FormData {
+ const formData = new FormData();
+ formData.append("before", "first");
+ formData.append("report", entry, "reports/2026.csv");
+ formData.append("after", "last");
+ return formData;
+ }
+
+ function thrownBy(fn: () => unknown): unknown {
+ try {
+ fn();
+ } catch (error) {
+ return error;
+ }
+ throw new Error("did not throw");
+ }
+
+ test.each([
+ ["S3Client.file()", () => s3.file("reports/2026.csv", { type: "text/csv" })],
+ ['Bun.file("s3://...")', () => Bun.file("s3://bucket/reports/2026.csv")],
+ ["a slice of an S3 file", () => s3.file("reports/2026.csv").slice(0, 16)],
+ ])("new Response(formData) throws for %s", (_label, makeEntry) => {
+ const formData = formDataWith(makeEntry());
+
+ const error = thrownBy(() => new Response(formData));
+ expect(error).toBeInstanceOf(TypeError);
+ expect((error as TypeError).message).toBe(expectedMessage);
+ });
+
+ // Serializing used to resolve the entry's size as well, which for an S3 blob
+ // turns the "unknown" NaN into 0.
+ test("serializing leaves the entry's unknown size alone", () => {
+ const formData = formDataWith(s3.file("reports/2026.csv"));
+ expect((formData.get("report") as Blob).size).toBeNaN();
+
+ try {
+ new Response(formData);
+ } catch {}
+
+ expect((formData.get("report") as Blob).size).toBeNaN();
+ });
+
+ test("new Request() throws", () => {
+ const formData = formDataWith(s3.file("reports/2026.csv"));
+
+ const error = thrownBy(() => new Request("http://localhost/upload", { method: "POST", body: formData }));
+ expect(error).toBeInstanceOf(TypeError);
+ expect((error as TypeError).message).toBe(expectedMessage);
+ });
+
+ test("fetch() rejects without sending a request", async () => {
+ const receivedBodies: string[] = [];
+ using server = Bun.serve({
+ port: 0,
+ async fetch(request) {
+ receivedBodies.push(await request.text());
+ return new Response("stored");
+ },
+ });
+ const formData = formDataWith(s3.file("reports/2026.csv"));
+
+ const error = await fetch(server.url, { method: "POST", body: formData }).then(
+ response => {
+ throw new Error(`fetch() resolved with status ${response.status}`);
+ },
+ rejection => rejection,
+ );
+ expect(error).toBeInstanceOf(TypeError);
+ expect((error as TypeError).message).toBe(expectedMessage);
+ expect(receivedBodies).toEqual([]);
+ });
+});
From a3c33a1a3f07f921a795f8ecab16b7476646378b Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Fri, 14 Aug 2026 05:32:19 +0000
Subject: [PATCH 02/20] Quote the entry name in the error and cover new
File([s3file]) entries
---
src/runtime/webcore/Blob.rs | 4 ++-
.../FormData-multipart-serialization.test.ts | 29 +++++++++++++++----
2 files changed, 27 insertions(+), 6 deletions(-)
diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs
index 918f3bbbb6b7..057e5a55f72f 100644
--- a/src/runtime/webcore/Blob.rs
+++ b/src/runtime/webcore/Blob.rs
@@ -3955,8 +3955,10 @@ impl FormDataContext<'_> {
// only be fetched asynchronously. Emitting the part
// with an empty body would silently upload nothing.
self.failed = true;
+ let entry_name = name.to_slice();
let _ = global_this.throw_type_error(format_args!(
- "FormData entry \"{name}\" is an S3 file, which cannot be read while serializing the body. Read it first: formData.append(name, new Blob([await s3file.bytes()]), filename)"
+ "FormData entry {} is an S3 file, which cannot be read while serializing the body. Read it first: formData.append(name, new Blob([await s3file.bytes()]), filename)",
+ bun_core::fmt::quote(entry_name.slice()),
));
}
store::Data::File(file) => {
diff --git a/test/js/web/html/FormData-multipart-serialization.test.ts b/test/js/web/html/FormData-multipart-serialization.test.ts
index 370953a289c2..eac24a90c3dc 100644
--- a/test/js/web/html/FormData-multipart-serialization.test.ts
+++ b/test/js/web/html/FormData-multipart-serialization.test.ts
@@ -227,14 +227,19 @@ describe("multipart serialization of S3-backed entries", () => {
secretAccessKey: "secret-key",
});
- const expectedMessage =
- 'FormData entry "report" is an S3 file, which cannot be read while serializing the body. ' +
- "Read it first: formData.append(name, new Blob([await s3file.bytes()]), filename)";
+ // `quotedName` is the entry name as the message renders it, quotes included.
+ function messageFor(quotedName: string): string {
+ return (
+ `FormData entry ${quotedName} is an S3 file, which cannot be read while serializing the body. ` +
+ "Read it first: formData.append(name, new Blob([await s3file.bytes()]), filename)"
+ );
+ }
+ const expectedMessage = messageFor('"report"');
- function formDataWith(entry: Blob): FormData {
+ function formDataWith(entry: Blob, name = "report"): FormData {
const formData = new FormData();
formData.append("before", "first");
- formData.append("report", entry, "reports/2026.csv");
+ formData.append(name, entry, "reports/2026.csv");
formData.append("after", "last");
return formData;
}
@@ -252,6 +257,8 @@ describe("multipart serialization of S3-backed entries", () => {
["S3Client.file()", () => s3.file("reports/2026.csv", { type: "text/csv" })],
['Bun.file("s3://...")', () => Bun.file("s3://bucket/reports/2026.csv")],
["a slice of an S3 file", () => s3.file("reports/2026.csv").slice(0, 16)],
+ // A single-blob File shares the source blob's store instead of copying it.
+ ["new File([s3file], name)", () => new File([s3.file("reports/2026.csv")], "wrapped.csv")],
])("new Response(formData) throws for %s", (_label, makeEntry) => {
const formData = formDataWith(makeEntry());
@@ -260,6 +267,18 @@ describe("multipart serialization of S3-backed entries", () => {
expect((error as TypeError).message).toBe(expectedMessage);
});
+ test.each([
+ ["a plain name", "report", '"report"'],
+ ["a non-ASCII name", "レポート ☺", '"レポート ☺"'],
+ ["a name containing a quote", 'quote"name', '"quote\\"name"'],
+ ["a name containing CRLF", "crlf\r\nname", '"crlf\\r\\nname"'],
+ ])("the message quotes %s", (_label, name, quotedName) => {
+ const formData = formDataWith(s3.file("reports/2026.csv"), name);
+
+ const error = thrownBy(() => new Response(formData));
+ expect((error as TypeError).message).toBe(messageFor(quotedName));
+ });
+
// Serializing used to resolve the entry's size as well, which for an S3 blob
// turns the "unknown" NaN into 0.
test("serializing leaves the entry's unknown size alone", () => {
From 82ad489fd6ba5294c9345e8287687f1b28636387 Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Fri, 14 Aug 2026 05:36:25 +0000
Subject: [PATCH 03/20] Trim comments in the S3 FormData arm
---
src/runtime/webcore/Blob.rs | 6 +-----
1 file changed, 1 insertion(+), 5 deletions(-)
diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs
index 057e5a55f72f..9249ec5172a2 100644
--- a/src/runtime/webcore/Blob.rs
+++ b/src/runtime/webcore/Blob.rs
@@ -3939,8 +3939,7 @@ impl FormDataContext<'_> {
joiner.push_static(b"\r\n\r\n");
if blob.store.get().is_some() {
- // `resolve_size` zeroes an S3 blob's size; the S3 arm below
- // rejects the entry, so leave it untouched.
+ // `resolve_size` would zero an S3 entry, which is rejected below.
if blob.size.get() == MAX_SIZE && !blob.is_s3() {
blob.resolve_size();
}
@@ -3951,9 +3950,6 @@ impl FormDataContext<'_> {
.expect("infallible: store present");
match &store.data {
store::Data::S3(_) => {
- // This serializer is synchronous and S3 objects can
- // only be fetched asynchronously. Emitting the part
- // with an empty body would silently upload nothing.
self.failed = true;
let entry_name = name.to_slice();
let _ = global_this.throw_type_error(format_args!(
From f110f423638f6ad9c7eab175155617b3aaa3736d Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Thu, 24 Sep 2026 13:30:37 +0000
Subject: [PATCH 04/20] blob: resolve_size leaves an S3 blob's size and slice
window untouched
resolve_size set the size of an S3-backed blob to 0. An S3 object has no
size before the download, and a slice already has its final window. Every
reader maps a size other than MAX_SIZE to a Range header, so a resolved
S3 blob requested `Range: bytes=N-N` and read one byte.
This broke the body stream of a Request or Response made from an S3 file
(.body, textStream(), HTMLRewriter, WebAssembly.compileStreaming), and
every read that came after Bun.inspect(request), expect().toHaveLength(),
structuredClone() or a FormData body.
The S3 arm of resolve_size and of resolved_size now keeps the size.
---
src/runtime/webcore/Blob.rs | 6 +-
test/js/bun/s3/s3.test.ts | 113 ++++++++++++++++++++++
test/js/web/fetch/body.test.ts | 165 ++++++++++++++++++++++++++++++++-
3 files changed, 281 insertions(+), 3 deletions(-)
diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs
index 54b8c275cdf7..a3474360047b 100644
--- a/src/runtime/webcore/Blob.rs
+++ b/src/runtime/webcore/Blob.rs
@@ -2142,7 +2142,9 @@ impl BlobExt for Blob {
}
self.size.set(0);
}
- store::DataTag::S3 => self.size.set(0),
+ // An S3 object's size is unknown until it is downloaded, and a
+ // slice's window is final: there is nothing to resolve.
+ store::DataTag::S3 => {}
}
}
@@ -2185,7 +2187,7 @@ impl BlobExt for Blob {
}
(self.offset.get(), 0)
}
- store::DataTag::S3 => (self.offset.get(), 0),
+ store::DataTag::S3 => (self.offset.get(), self.size.get()),
}
}
fn constructor(global_this: &JSGlobalObject, callframe: &CallFrame) -> JsResult<*mut Blob> {
diff --git a/test/js/bun/s3/s3.test.ts b/test/js/bun/s3/s3.test.ts
index ff6001c67df8..58b713041d31 100644
--- a/test/js/bun/s3/s3.test.ts
+++ b/test/js/bun/s3/s3.test.ts
@@ -2180,3 +2180,116 @@ describe("presigned url signature", () => {
}
});
});
+
+// The size of an S3 object is not known before the download. Whatever asks the file
+// for its size must leave it unknown: a file whose size became 0 requests one byte
+// (`Range: bytes=0-0`) on the next read and returns an empty Blob from slice().
+describe("s3 file size stays unknown", () => {
+ const payload = Buffer.alloc(100, "0123456789").toString();
+
+ // A local stand-in for the S3 endpoint. It records the Range header of each GET.
+ function s3Endpoint() {
+ const ranges: (string | null)[] = [];
+ const server = Bun.serve({
+ port: 0,
+ fetch(req) {
+ const range = req.headers.get("range");
+ ranges.push(range);
+ const match = range && /^bytes=(\d+)-(\d*)$/.exec(range);
+ if (!match) return new Response(payload);
+ const start = Number(match[1]);
+ const end = match[2] === "" ? payload.length - 1 : Math.min(Number(match[2]), payload.length - 1);
+ return new Response(payload.slice(start, end + 1), {
+ status: 206,
+ headers: { "Content-Range": `bytes ${start}-${end}/${payload.length}` },
+ });
+ },
+ });
+ const client = new S3Client({ endpoint: server.url.href, accessKeyId: "x", secretAccessKey: "y", bucket: "b" });
+ return { ranges, file: () => client.file("key"), [Symbol.dispose]: () => void server.stop(true) };
+ }
+
+ async function readsAfter(look: (file: Bun.S3File) => unknown) {
+ using endpoint = s3Endpoint();
+ const file = endpoint.file();
+ await look(file);
+ const requestsWhileLooking = endpoint.ranges.length;
+ return {
+ requestsWhileLooking,
+ size: file.size,
+ text: await file.text(),
+ stream: await Bun.readableStreamToText(file.stream()),
+ slice: await file.slice(10, 20).text(),
+ ranges: endpoint.ranges,
+ };
+ }
+ const untouched = {
+ requestsWhileLooking: 0,
+ size: NaN,
+ text: payload,
+ stream: payload,
+ slice: payload.slice(10, 20),
+ ranges: [null, null, "bytes=10-19"],
+ };
+
+ it("after expect().toHaveLength() and expect().toBeEmpty()", async () => {
+ expect(
+ await readsAfter(file => {
+ expect(() => expect(file).toHaveLength(0)).toThrow();
+ expect(() => expect(file).toBeEmpty()).toThrow();
+ }),
+ ).toEqual(untouched);
+ });
+
+ it("after structuredClone()", async () => {
+ expect(
+ await readsAfter(file => {
+ try {
+ structuredClone(file);
+ } catch {}
+ }),
+ ).toEqual(untouched);
+ });
+
+ it("for a FormData entry after the FormData became a body", async () => {
+ using endpoint = s3Endpoint();
+ const form = new FormData();
+ form.append("file", endpoint.file());
+ try {
+ await new Response(form).arrayBuffer();
+ } catch {}
+ const entry = form.get("file") as Blob;
+ expect({ size: entry.size, text: await entry.text(), ranges: endpoint.ranges }).toEqual({
+ size: NaN,
+ text: payload,
+ ranges: [null],
+ });
+ });
+
+ it("a slice requests its own window", async () => {
+ using endpoint = s3Endpoint();
+ const file = endpoint.file();
+ expect({
+ "slice(10, 20).text()": await file.slice(10, 20).text(),
+ "slice(0, 5).text()": await file.slice(0, 5).text(),
+ "slice(95).text()": await file.slice(95).text(),
+ "slice(10, 20).stream()": await Bun.readableStreamToText(file.slice(10, 20).stream()),
+ "slice(0, 5).stream()": await Bun.readableStreamToText(file.slice(0, 5).stream()),
+ sizes: [file.slice(10, 20).size, file.slice(0, 5).size],
+ }).toEqual({
+ "slice(10, 20).text()": payload.slice(10, 20),
+ "slice(0, 5).text()": payload.slice(0, 5),
+ "slice(95).text()": payload.slice(95),
+ "slice(10, 20).stream()": payload.slice(10, 20),
+ "slice(0, 5).stream()": payload.slice(0, 5),
+ sizes: [10, 5],
+ });
+ expect(endpoint.ranges.map(range => range?.replace(/^(bytes=95-)\d+$/, "$1"))).toEqual([
+ "bytes=10-19",
+ "bytes=0-4",
+ "bytes=95-",
+ "bytes=10-19",
+ "bytes=0-4",
+ ]);
+ });
+});
diff --git a/test/js/web/fetch/body.test.ts b/test/js/web/fetch/body.test.ts
index 0a71d6f50ad0..128e62ee9803 100644
--- a/test/js/web/fetch/body.test.ts
+++ b/test/js/web/fetch/body.test.ts
@@ -1,4 +1,4 @@
-import { file, spawn, version, type Socket } from "bun";
+import { file, S3Client, spawn, version, type Socket } from "bun";
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, exampleSite, tempDir } from "harness";
import net from "net";
@@ -2042,3 +2042,166 @@ describe("body stream bookkeeping does not depend on the body's source", () => {
expect(await text).toBe("payload");
});
});
+
+// The size of an S3 object is not known before the download. A body made from
+// one reads the whole object, or the whole slice, whatever looked at it first.
+describe("a body made from an S3 file", () => {
+ const payload = Buffer.alloc(100, "0123456789").toString();
+
+ // A local stand-in for the S3 endpoint. It records the Range header of each GET.
+ function s3Endpoint(object: string | Uint8Array = payload) {
+ const bytes = Buffer.from(object);
+ const ranges: (string | null)[] = [];
+ const server = Bun.serve({
+ port: 0,
+ fetch(req) {
+ const range = req.headers.get("range");
+ ranges.push(range);
+ const match = range && /^bytes=(\d+)-(\d*)$/.exec(range);
+ if (!match) return new Response(bytes);
+ const start = Number(match[1]);
+ const end = match[2] === "" ? bytes.length - 1 : Number(match[2]);
+ return new Response(bytes.subarray(start, end + 1), {
+ status: 206,
+ headers: { "Content-Range": `bytes ${start}-${end}/${bytes.length}` },
+ });
+ },
+ });
+ const s3 = new S3Client({ endpoint: server.url.href, accessKeyId: "x", secretAccessKey: "y", bucket: "b" });
+ return {
+ ranges,
+ file: (type?: string) => s3.file("key", { type }),
+ [Symbol.dispose]: () => void server.stop(true),
+ };
+ }
+
+ const owners = [
+ {
+ name: "Request",
+ make: async (blob: Blob) => new Request("http://example.com/", { method: "POST", body: blob }),
+ },
+ {
+ // new Response(s3file) is a redirect. fetch() of a blob: URL gives a Response that holds the S3 blob.
+ name: "Response",
+ make: async (blob: Blob) => {
+ const url = URL.createObjectURL(blob);
+ try {
+ return await fetch(url);
+ } finally {
+ URL.revokeObjectURL(url);
+ }
+ },
+ },
+ ];
+
+ for (const { name, make } of owners) {
+ describe(name, () => {
+ test(".body and textStream() download the whole object", async () => {
+ using endpoint = s3Endpoint();
+ const body = await Bun.readableStreamToText((await make(endpoint.file())).body!);
+ const textStream = (await Array.fromAsync((await make(endpoint.file())).textStream())).join("");
+ expect({ body, textStream, ranges: endpoint.ranges }).toEqual({
+ body: payload,
+ textStream: payload,
+ ranges: [null, null],
+ });
+ });
+
+ test(".body of a slice downloads the whole slice", async () => {
+ using endpoint = s3Endpoint();
+ const body = await Bun.readableStreamToText((await make(endpoint.file().slice(10, 30))).body!);
+ const textStream = (await Array.fromAsync((await make(endpoint.file().slice(10, 30))).textStream())).join("");
+ expect({ body, textStream, ranges: endpoint.ranges }).toEqual({
+ body: payload.slice(10, 30),
+ textStream: payload.slice(10, 30),
+ ranges: ["bytes=10-29", "bytes=10-29"],
+ });
+ });
+
+ test("a clone and its original both download the whole object", async () => {
+ using endpoint = s3Endpoint();
+ const original = await make(endpoint.file());
+ const clone = original.clone();
+ expect({
+ clone: await Bun.readableStreamToText(clone.body!),
+ original: await Bun.readableStreamToText(original.body!),
+ ranges: endpoint.ranges,
+ }).toEqual({ clone: payload, original: payload, ranges: [null, null] });
+ });
+
+ test("Bun.inspect() does not change what the body reads", async () => {
+ using endpoint = s3Endpoint();
+ const inspected = async () => {
+ const subject = await make(endpoint.file());
+ Bun.inspect(subject);
+ return subject;
+ };
+ const blob = await (await inspected()).blob();
+ expect({
+ body: await Bun.readableStreamToText((await inspected()).body!),
+ text: await (await inspected()).text(),
+ bytes: (await (await inspected()).bytes()).length,
+ blob: [blob.size, await blob.text()],
+ ranges: endpoint.ranges,
+ }).toEqual({
+ body: payload,
+ text: payload,
+ bytes: payload.length,
+ blob: [NaN, payload],
+ ranges: [null, null, null, null],
+ });
+ });
+ });
+ }
+
+ test("new Request(request) downloads the whole object", async () => {
+ using endpoint = s3Endpoint();
+ const request = new Request(new Request("http://example.com/", { method: "POST", body: endpoint.file() }));
+ expect({ body: await Bun.readableStreamToText(request.body!), ranges: endpoint.ranges }).toEqual({
+ body: payload,
+ ranges: [null],
+ });
+ });
+
+ test("fetch(request) uploads the whole object after Bun.inspect(request)", async () => {
+ using endpoint = s3Endpoint();
+ const uploads: string[] = [];
+ await using sink = Bun.serve({
+ port: 0,
+ async fetch(req) {
+ uploads.push(await req.text());
+ return new Response("ok");
+ },
+ });
+ const request = new Request(sink.url, { method: "POST", body: endpoint.file() });
+ Bun.inspect(request);
+ expect(await (await fetch(request)).text()).toBe("ok");
+ expect({ uploads, ranges: endpoint.ranges }).toEqual({ uploads: [payload], ranges: [null] });
+ });
+
+ test("HTMLRewriter.transform() reads the whole object", async () => {
+ using endpoint = s3Endpoint("
" + payload + "
");
+ const seen: string[] = [];
+ const response = await owners[1].make(endpoint.file());
+ const output = await new HTMLRewriter()
+ .on("p", { text: chunk => void seen.push(chunk.text) })
+ .transform(response as Response)
+ .text();
+ expect({ output, seen: seen.join(""), ranges: endpoint.ranges }).toEqual({
+ output: "" + payload + "
",
+ seen: payload,
+ ranges: [null],
+ });
+ });
+
+ test("WebAssembly.compileStreaming() reads the whole object", async () => {
+ // The smallest module: the magic number and the version.
+ using endpoint = s3Endpoint(new Uint8Array([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]));
+ const response = owners[1].make(endpoint.file("application/wasm")) as Promise;
+ const module = await WebAssembly.compileStreaming(response);
+ expect({ exports: WebAssembly.Module.exports(module), ranges: endpoint.ranges }).toEqual({
+ exports: [],
+ ranges: [null],
+ });
+ });
+});
From d631127196eb3355228cd4f729fd3aca587d19ca Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Thu, 24 Sep 2026 13:30:49 +0000
Subject: [PATCH 05/20] Body: print the unknown size of an S3 body as 0 in the
inspect header
Body::Value::size feeds only the `Request (size)` and `Response (size)`
header of Bun.inspect. An S3 body printed `0 KB` because resolve_size had
set its size to 0. With the size left unknown, get_size_for_bindings
returns u64::MAX, which the size formatter prints as `0.0 ZB`.
Map the unknown size of an S3 blob to 0, the value a Locked body with no
size hint reports. The header of an S3 body does not change, and no other
body is affected.
---
src/runtime/webcore/Body.rs | 6 +++++-
test/js/web/fetch/body.test.ts | 5 +++--
2 files changed, 8 insertions(+), 3 deletions(-)
diff --git a/src/runtime/webcore/Body.rs b/src/runtime/webcore/Body.rs
index 5325e8f00d34..73850a46b399 100644
--- a/src/runtime/webcore/Body.rs
+++ b/src/runtime/webcore/Body.rs
@@ -757,7 +757,11 @@ impl Value {
pub(crate) fn size(&mut self) -> blob::SizeType {
match self {
- Value::Blob(b) => b.get_size_for_bindings() as blob::SizeType,
+ Value::Blob(b) => match b.get_size_for_bindings() {
+ // The size of an S3 object is unknown: 0, like a `Locked` body with no hint.
+ u64::MAX if b.is_s3() => 0,
+ size => size as blob::SizeType,
+ },
Value::InternalBlob(b) => b.slice_const().len() as blob::SizeType,
Value::WTFStringImpl(s) => wtf_impl(s).utf8_byte_length() as blob::SizeType,
Value::Locked(l) => l.size_hint(),
diff --git a/test/js/web/fetch/body.test.ts b/test/js/web/fetch/body.test.ts
index 128e62ee9803..2f76ebd83b0f 100644
--- a/test/js/web/fetch/body.test.ts
+++ b/test/js/web/fetch/body.test.ts
@@ -2060,7 +2060,7 @@ describe("a body made from an S3 file", () => {
const match = range && /^bytes=(\d+)-(\d*)$/.exec(range);
if (!match) return new Response(bytes);
const start = Number(match[1]);
- const end = match[2] === "" ? bytes.length - 1 : Number(match[2]);
+ const end = match[2] === "" ? bytes.length - 1 : Math.min(Number(match[2]), bytes.length - 1);
return new Response(bytes.subarray(start, end + 1), {
status: 206,
headers: { "Content-Range": `bytes ${start}-${end}/${bytes.length}` },
@@ -2133,7 +2133,8 @@ describe("a body made from an S3 file", () => {
using endpoint = s3Endpoint();
const inspected = async () => {
const subject = await make(endpoint.file());
- Bun.inspect(subject);
+ // The size in the header is unknown, so it prints as 0.
+ expect(Bun.inspect(subject)).toStartWith(`${name} (0 KB) {`);
return subject;
};
const blob = await (await inspected()).blob();
From 1394f42bd0bafec2abee1710ee36bcccf041407b Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Thu, 24 Sep 2026 23:57:25 +0000
Subject: [PATCH 06/20] test: share the local S3 endpoint, assert the exact
result
Both test files had a copy of the server that plays the S3 endpoint. Move
it to test/js/bun/s3/s3-local-endpoint.ts.
The matcher assertions name the message of the length comparison, so a
throw for another reason does not pass. The bytes() reader is compared
with the payload, not with its length.
---
test/js/bun/s3/s3-local-endpoint.ts | 33 +++++++++++++++++++
test/js/bun/s3/s3.test.ts | 34 ++++----------------
test/js/web/fetch/body.test.ts | 50 +++++++----------------------
3 files changed, 52 insertions(+), 65 deletions(-)
create mode 100644 test/js/bun/s3/s3-local-endpoint.ts
diff --git a/test/js/bun/s3/s3-local-endpoint.ts b/test/js/bun/s3/s3-local-endpoint.ts
new file mode 100644
index 000000000000..5cf976dc71db
--- /dev/null
+++ b/test/js/bun/s3/s3-local-endpoint.ts
@@ -0,0 +1,33 @@
+import { S3Client } from "bun";
+
+/**
+ * A local stand-in for an S3 endpoint that holds one object, under the key "key".
+ * It records the Range header of each request. It answers a Range request like a
+ * server that clamps the end of the range to the end of the object.
+ */
+export function s3LocalEndpoint(object: string | Uint8Array) {
+ const bytes = Buffer.from(object);
+ const ranges: (string | null)[] = [];
+ const server = Bun.serve({
+ port: 0,
+ fetch(req) {
+ const range = req.headers.get("range");
+ ranges.push(range);
+ const match = range && /^bytes=(\d+)-(\d*)$/.exec(range);
+ if (!match) return new Response(bytes);
+ const start = Number(match[1]);
+ const end = match[2] === "" ? bytes.length - 1 : Math.min(Number(match[2]), bytes.length - 1);
+ return new Response(bytes.subarray(start, end + 1), {
+ status: 206,
+ headers: { "Content-Range": `bytes ${start}-${end}/${bytes.length}` },
+ });
+ },
+ });
+ const client = new S3Client({ endpoint: server.url.href, accessKeyId: "x", secretAccessKey: "y", bucket: "b" });
+ return {
+ /** The Range header of each request so far. `null` is a request with no Range header. */
+ ranges,
+ file: (type?: string) => client.file("key", { type }),
+ [Symbol.dispose]: () => void server.stop(true),
+ };
+}
diff --git a/test/js/bun/s3/s3.test.ts b/test/js/bun/s3/s3.test.ts
index 58b713041d31..4d9112d59c45 100644
--- a/test/js/bun/s3/s3.test.ts
+++ b/test/js/bun/s3/s3.test.ts
@@ -5,6 +5,7 @@ import child_process from "child_process";
import { createHash, createHmac, randomUUID } from "crypto";
import { bunEnv, bunExe, dockerExe, getSecret, isCI, isDockerEnabled, tempDir, tempDirWithFiles } from "harness";
import path from "path";
+import { s3LocalEndpoint } from "./s3-local-endpoint";
const s3 = (...args) => defaultS3.file(...args);
const S3 = (...args) => new S3Client(...args);
@@ -2187,30 +2188,8 @@ describe("presigned url signature", () => {
describe("s3 file size stays unknown", () => {
const payload = Buffer.alloc(100, "0123456789").toString();
- // A local stand-in for the S3 endpoint. It records the Range header of each GET.
- function s3Endpoint() {
- const ranges: (string | null)[] = [];
- const server = Bun.serve({
- port: 0,
- fetch(req) {
- const range = req.headers.get("range");
- ranges.push(range);
- const match = range && /^bytes=(\d+)-(\d*)$/.exec(range);
- if (!match) return new Response(payload);
- const start = Number(match[1]);
- const end = match[2] === "" ? payload.length - 1 : Math.min(Number(match[2]), payload.length - 1);
- return new Response(payload.slice(start, end + 1), {
- status: 206,
- headers: { "Content-Range": `bytes ${start}-${end}/${payload.length}` },
- });
- },
- });
- const client = new S3Client({ endpoint: server.url.href, accessKeyId: "x", secretAccessKey: "y", bucket: "b" });
- return { ranges, file: () => client.file("key"), [Symbol.dispose]: () => void server.stop(true) };
- }
-
async function readsAfter(look: (file: Bun.S3File) => unknown) {
- using endpoint = s3Endpoint();
+ using endpoint = s3LocalEndpoint(payload);
const file = endpoint.file();
await look(file);
const requestsWhileLooking = endpoint.ranges.length;
@@ -2235,8 +2214,8 @@ describe("s3 file size stays unknown", () => {
it("after expect().toHaveLength() and expect().toBeEmpty()", async () => {
expect(
await readsAfter(file => {
- expect(() => expect(file).toHaveLength(0)).toThrow();
- expect(() => expect(file).toBeEmpty()).toThrow();
+ expect(() => expect(file).toHaveLength(0)).toThrow("Received length");
+ expect(() => expect(file).toBeEmpty()).toThrow("Expected value to be empty");
}),
).toEqual(untouched);
});
@@ -2252,7 +2231,7 @@ describe("s3 file size stays unknown", () => {
});
it("for a FormData entry after the FormData became a body", async () => {
- using endpoint = s3Endpoint();
+ using endpoint = s3LocalEndpoint(payload);
const form = new FormData();
form.append("file", endpoint.file());
try {
@@ -2267,7 +2246,7 @@ describe("s3 file size stays unknown", () => {
});
it("a slice requests its own window", async () => {
- using endpoint = s3Endpoint();
+ using endpoint = s3LocalEndpoint(payload);
const file = endpoint.file();
expect({
"slice(10, 20).text()": await file.slice(10, 20).text(),
@@ -2284,6 +2263,7 @@ describe("s3 file size stays unknown", () => {
"slice(0, 5).stream()": payload.slice(0, 5),
sizes: [10, 5],
});
+ // The end that an open-ended slice sends is not pinned here, only its start.
expect(endpoint.ranges.map(range => range?.replace(/^(bytes=95-)\d+$/, "$1"))).toEqual([
"bytes=10-19",
"bytes=0-4",
diff --git a/test/js/web/fetch/body.test.ts b/test/js/web/fetch/body.test.ts
index 2f76ebd83b0f..89aa1228ed44 100644
--- a/test/js/web/fetch/body.test.ts
+++ b/test/js/web/fetch/body.test.ts
@@ -1,10 +1,11 @@
-import { file, S3Client, spawn, version, type Socket } from "bun";
+import { file, spawn, version, type Socket } from "bun";
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, exampleSite, tempDir } from "harness";
import net from "net";
import { join } from "node:path";
import { isDisturbed, isErrored, isReadable, Readable } from "node:stream";
import { finished } from "node:stream/promises";
+import { s3LocalEndpoint } from "../../bun/s3/s3-local-endpoint";
const exampleServer = exampleSite("http");
@@ -2048,33 +2049,6 @@ describe("body stream bookkeeping does not depend on the body's source", () => {
describe("a body made from an S3 file", () => {
const payload = Buffer.alloc(100, "0123456789").toString();
- // A local stand-in for the S3 endpoint. It records the Range header of each GET.
- function s3Endpoint(object: string | Uint8Array = payload) {
- const bytes = Buffer.from(object);
- const ranges: (string | null)[] = [];
- const server = Bun.serve({
- port: 0,
- fetch(req) {
- const range = req.headers.get("range");
- ranges.push(range);
- const match = range && /^bytes=(\d+)-(\d*)$/.exec(range);
- if (!match) return new Response(bytes);
- const start = Number(match[1]);
- const end = match[2] === "" ? bytes.length - 1 : Math.min(Number(match[2]), bytes.length - 1);
- return new Response(bytes.subarray(start, end + 1), {
- status: 206,
- headers: { "Content-Range": `bytes ${start}-${end}/${bytes.length}` },
- });
- },
- });
- const s3 = new S3Client({ endpoint: server.url.href, accessKeyId: "x", secretAccessKey: "y", bucket: "b" });
- return {
- ranges,
- file: (type?: string) => s3.file("key", { type }),
- [Symbol.dispose]: () => void server.stop(true),
- };
- }
-
const owners = [
{
name: "Request",
@@ -2097,7 +2071,7 @@ describe("a body made from an S3 file", () => {
for (const { name, make } of owners) {
describe(name, () => {
test(".body and textStream() download the whole object", async () => {
- using endpoint = s3Endpoint();
+ using endpoint = s3LocalEndpoint(payload);
const body = await Bun.readableStreamToText((await make(endpoint.file())).body!);
const textStream = (await Array.fromAsync((await make(endpoint.file())).textStream())).join("");
expect({ body, textStream, ranges: endpoint.ranges }).toEqual({
@@ -2108,7 +2082,7 @@ describe("a body made from an S3 file", () => {
});
test(".body of a slice downloads the whole slice", async () => {
- using endpoint = s3Endpoint();
+ using endpoint = s3LocalEndpoint(payload);
const body = await Bun.readableStreamToText((await make(endpoint.file().slice(10, 30))).body!);
const textStream = (await Array.fromAsync((await make(endpoint.file().slice(10, 30))).textStream())).join("");
expect({ body, textStream, ranges: endpoint.ranges }).toEqual({
@@ -2119,7 +2093,7 @@ describe("a body made from an S3 file", () => {
});
test("a clone and its original both download the whole object", async () => {
- using endpoint = s3Endpoint();
+ using endpoint = s3LocalEndpoint(payload);
const original = await make(endpoint.file());
const clone = original.clone();
expect({
@@ -2130,7 +2104,7 @@ describe("a body made from an S3 file", () => {
});
test("Bun.inspect() does not change what the body reads", async () => {
- using endpoint = s3Endpoint();
+ using endpoint = s3LocalEndpoint(payload);
const inspected = async () => {
const subject = await make(endpoint.file());
// The size in the header is unknown, so it prints as 0.
@@ -2141,13 +2115,13 @@ describe("a body made from an S3 file", () => {
expect({
body: await Bun.readableStreamToText((await inspected()).body!),
text: await (await inspected()).text(),
- bytes: (await (await inspected()).bytes()).length,
+ bytes: Buffer.from(await (await inspected()).bytes()).toString(),
blob: [blob.size, await blob.text()],
ranges: endpoint.ranges,
}).toEqual({
body: payload,
text: payload,
- bytes: payload.length,
+ bytes: payload,
blob: [NaN, payload],
ranges: [null, null, null, null],
});
@@ -2156,7 +2130,7 @@ describe("a body made from an S3 file", () => {
}
test("new Request(request) downloads the whole object", async () => {
- using endpoint = s3Endpoint();
+ using endpoint = s3LocalEndpoint(payload);
const request = new Request(new Request("http://example.com/", { method: "POST", body: endpoint.file() }));
expect({ body: await Bun.readableStreamToText(request.body!), ranges: endpoint.ranges }).toEqual({
body: payload,
@@ -2165,7 +2139,7 @@ describe("a body made from an S3 file", () => {
});
test("fetch(request) uploads the whole object after Bun.inspect(request)", async () => {
- using endpoint = s3Endpoint();
+ using endpoint = s3LocalEndpoint(payload);
const uploads: string[] = [];
await using sink = Bun.serve({
port: 0,
@@ -2181,7 +2155,7 @@ describe("a body made from an S3 file", () => {
});
test("HTMLRewriter.transform() reads the whole object", async () => {
- using endpoint = s3Endpoint("" + payload + "
");
+ using endpoint = s3LocalEndpoint("" + payload + "
");
const seen: string[] = [];
const response = await owners[1].make(endpoint.file());
const output = await new HTMLRewriter()
@@ -2197,7 +2171,7 @@ describe("a body made from an S3 file", () => {
test("WebAssembly.compileStreaming() reads the whole object", async () => {
// The smallest module: the magic number and the version.
- using endpoint = s3Endpoint(new Uint8Array([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]));
+ using endpoint = s3LocalEndpoint(new Uint8Array([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]));
const response = owners[1].make(endpoint.file("application/wasm")) as Promise;
const module = await WebAssembly.compileStreaming(response);
expect({ exports: WebAssembly.Module.exports(module), ranges: endpoint.ranges }).toEqual({
From e0df286e495429b6bb5e3df053d1ba8fbc1ca461 Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Fri, 25 Sep 2026 00:01:26 +0000
Subject: [PATCH 07/20] blob: put the comment on the S3 arm of resolve_size on
one line
---
src/runtime/webcore/Blob.rs | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs
index a3474360047b..c34bc073bae7 100644
--- a/src/runtime/webcore/Blob.rs
+++ b/src/runtime/webcore/Blob.rs
@@ -2142,8 +2142,7 @@ impl BlobExt for Blob {
}
self.size.set(0);
}
- // An S3 object's size is unknown until it is downloaded, and a
- // slice's window is final: there is nothing to resolve.
+ // An S3 object has no size before the download: there is nothing to resolve.
store::DataTag::S3 => {}
}
}
From 6f331de95717ac5757cfb9e67097367e67140b7e Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Sat, 10 Oct 2026 20:38:54 +0000
Subject: [PATCH 08/20] Read the entry name through EncodedSlice::to_utf8 after
the merge with main
---
src/runtime/webcore/Blob.rs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs
index 0784cc73770f..444b350f5de8 100644
--- a/src/runtime/webcore/Blob.rs
+++ b/src/runtime/webcore/Blob.rs
@@ -3684,7 +3684,7 @@ impl FormDataContext<'_> {
match &store.data {
store::Data::S3(_) => {
self.failed = true;
- let entry_name = name.to_slice();
+ let entry_name = name.to_utf8();
let _ = global_this.throw_type_error(format_args!(
"FormData entry {} is an S3 file, which cannot be read while serializing the body. Read it first: formData.append(name, new Blob([await s3file.bytes()]), filename)",
bun_core::fmt::quote(entry_name.slice()),
From 51e64d6040d993acec2456b9427a52e09f8e149f Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Fri, 23 Oct 2026 15:08:06 +0000
Subject: [PATCH 09/20] s3: leave the size of an S3 blob unknown in
resolve_size
The S3 arm of `resolve_size` set the size of the blob to 0. Each S3 reader
takes a known size as a window and sends a Range header, so the next read
sent `Range: bytes=0-0`. It got one byte of the object, or `InvalidRange`
for an object of 0 bytes.
`resolve_size` runs for `.body`, `textStream()`, `HTMLRewriter.transform()`
and `WebAssembly.compileStreaming()` of a body that holds an S3 file, for
`console.log()` of that Request or Response, for the length matchers of
`expect()`, for `structuredClone()` and for a FormData body. `.body` read
the whole object until 1.3.10.
The arm is now empty: the size of an S3 object stays unknown, and a slice
keeps its window. `resolved_size` answers the same way.
---
src/runtime/server/RequestContext.rs | 2 +-
src/runtime/webcore/Blob.rs | 5 +-
src/runtime/webcore/Body.rs | 2 +-
test/js/bun/http/bun-server.test.ts | 29 ++++
test/js/bun/s3/s3.test.ts | 121 +++++++++++++-
test/js/bun/util/inspect.test.js | 25 +++
test/js/web/fetch/body.test.ts | 230 ++++++++++++++++++++++++++-
7 files changed, 406 insertions(+), 8 deletions(-)
diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs
index 6813117c2cdf..9b3c21d4c60d 100644
--- a/src/runtime/server/RequestContext.rs
+++ b/src/runtime/server/RequestContext.rs
@@ -2655,7 +2655,7 @@ where
// (here, `blob`) may still be live across it. Nothing is written
// to the socket in between, so the wire output is unchanged.
let blob_size = if shim::blob_is_s3(blob) {
- // Match the body GET sends (`render_bytes`). `resolve_size()` would set the blob's size to 0.
+ // Match the body GET sends (`render_bytes`): no bytes. `size` is unknown, or the window of a slice.
blob.shared_view().len() as BlobSizeType
} else {
blob.resolve_size();
diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs
index 8eafdcde8b02..d1f4369afee6 100644
--- a/src/runtime/webcore/Blob.rs
+++ b/src/runtime/webcore/Blob.rs
@@ -2063,7 +2063,8 @@ impl BlobExt for Blob {
}
self.size.set(0);
}
- store::DataTag::S3 => self.size.set(0),
+ // An S3 object has no size before the download: there is nothing to resolve.
+ store::DataTag::S3 => {}
}
}
@@ -2106,7 +2107,7 @@ impl BlobExt for Blob {
}
(self.offset.get(), 0)
}
- store::DataTag::S3 => (self.offset.get(), 0),
+ store::DataTag::S3 => (self.offset.get(), self.size.get()),
}
}
fn constructor(
diff --git a/src/runtime/webcore/Body.rs b/src/runtime/webcore/Body.rs
index 43c3d7f789c9..094793f8ffa8 100644
--- a/src/runtime/webcore/Body.rs
+++ b/src/runtime/webcore/Body.rs
@@ -741,7 +741,7 @@ impl Value {
}
}
- /// `None` for a file that does not exist or is not seekable.
+ /// `None` for a file that does not exist or is not seekable, and for an S3 object that is not a slice.
pub(crate) fn known_size(&mut self) -> Option {
match self.size() {
u64::MAX => None,
diff --git a/test/js/bun/http/bun-server.test.ts b/test/js/bun/http/bun-server.test.ts
index 58f153dc2462..42e2cb9c0679 100644
--- a/test/js/bun/http/bun-server.test.ts
+++ b/test/js/bun/http/bun-server.test.ts
@@ -3168,6 +3168,35 @@ test("HEAD for a Response with an S3-backed Blob body doesn't wait for S3", asyn
expect(exitCode).toBe(0);
});
+// A slice of an S3 file knows its length, but GET sends none of the object for a Blob body.
+test("HEAD for a Response with a slice of an S3 file as its body reports the length that GET sends", async () => {
+ let s3Requests = 0;
+ await using s3Origin = Bun.serve({
+ port: 0,
+ fetch() {
+ s3Requests++;
+ return new Response(Buffer.alloc(100, "0123456789"));
+ },
+ });
+ const s3 = new Bun.S3Client({
+ accessKeyId: "test",
+ secretAccessKey: "test",
+ bucket: "my-bucket",
+ endpoint: s3Origin.url.href,
+ });
+ await using app = Bun.serve({
+ port: 0,
+ fetch: () => fetch(URL.createObjectURL(s3.file("object.txt").slice(10, 30))),
+ });
+ const head = await fetch(app.url, { method: "HEAD" });
+ const requestsForHead = s3Requests;
+ const get = await fetch(app.url);
+ expect({ head: head.headers.get("content-length"), requestsForHead }).toEqual({
+ head: get.headers.get("content-length"),
+ requestsForHead: 0,
+ });
+});
+
// Handler callbacks (fetch/error/websocket.*) are stored on the JS wrapper and
// traced by the GC rather than independently rooted. These tests lock in that
// reload/stop transitions never leave a window where a handler is collected
diff --git a/test/js/bun/s3/s3.test.ts b/test/js/bun/s3/s3.test.ts
index bb394b7301d2..8a49b0cd07c5 100644
--- a/test/js/bun/s3/s3.test.ts
+++ b/test/js/bun/s3/s3.test.ts
@@ -1,10 +1,11 @@
import type { S3File, S3Options } from "bun";
import { S3Client, s3 as defaultS3, file, randomUUIDv7 } from "bun";
-import { afterAll, describe, expect, it } from "bun:test";
+import { serialize } from "bun:jsc";
+import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { createHash, createHmac, randomUUID } from "crypto";
import { bunEnv, bunExe, getSecret, isCI, tempDir, tempDirWithFiles } from "harness";
import path from "path";
-import { spawnServer } from "s3-server";
+import { serve, spawnServer, type S3Server } from "s3-server";
const s3 = (...args: Parameters) => defaultS3.file(...args);
const S3 = (...args) => new S3Client(...args);
@@ -2170,3 +2171,119 @@ describe("presigned url signature", () => {
}
});
});
+
+// The size of an S3 object is not known before the download. A call that asks
+// for the size has to leave it unknown. A file whose size became 0 asked for one
+// byte (`Range: bytes=0-0`) on each later read, and a copy of it stored that byte.
+describe("an S3 file after a call that asks for its size", () => {
+ const payload = Buffer.alloc(100, "0123456789").toString();
+ // In this process, because only this server has a log of its requests.
+ let server: S3Server;
+ let client: S3Client;
+ beforeAll(() => {
+ server = serve({ buckets: ["bucket"] });
+ client = new S3Client(server.clientOptions("bucket"));
+ });
+ afterAll(() => server.stop());
+
+ /** Stores the payload under a new key. */
+ async function store() {
+ const key = randomUUIDv7();
+ await client.write(key, payload);
+ return key;
+ }
+ /** Each request for the key so far, as `[operation, Range header]`. */
+ const requests = (key: string) =>
+ server.requests
+ .filter(request => request.key === key)
+ .map(request => [request.operation, request.headers.get("range")]);
+
+ /** Makes the call. Whether it throws is not the subject of these tests: the file afterwards is. */
+ async function attempt(call: () => unknown) {
+ try {
+ await call();
+ } catch {}
+ }
+
+ /** Reads the blob and copies it, in each way. */
+ async function readAndCopy(blob: Blob) {
+ using dir = tempDir("s3-file-size", {});
+ const s3Copy = client.file(randomUUIDv7());
+ const localCopy = Bun.file(path.join(String(dir), "copy"));
+ return {
+ size: blob.size,
+ text: await blob.text(),
+ stream: await Bun.readableStreamToText(blob.stream()),
+ slice: await blob.slice(10, 20).text(),
+ s3Copy: [await s3Copy.write(blob), await s3Copy.text()],
+ localCopy: [await Bun.write(localCopy, blob), await localCopy.text()],
+ };
+ }
+ const whole = {
+ size: NaN,
+ text: payload,
+ stream: payload,
+ slice: payload.slice(10, 20),
+ s3Copy: [100, payload],
+ localCopy: [100, payload],
+ };
+ // One request for each read and each copy of `readAndCopy`. Only the slice has a Range header.
+ const requestsOfWhole = [
+ ["GetObject", null],
+ ["GetObject", null],
+ ["GetObject", "bytes=10-19"],
+ ["GetObject", null],
+ ["GetObject", null],
+ ];
+
+ const calls: Record unknown> = {
+ "expect().toHaveLength()": file => expect(file).toHaveLength(0),
+ "expect().toBeEmpty()": file => expect(file).toBeEmpty(),
+ "structuredClone()": file => structuredClone(file),
+ "MessagePort.postMessage()": file => {
+ const { port1, port2 } = new MessageChannel();
+ try {
+ port1.postMessage(file);
+ } finally {
+ port1.close();
+ port2.close();
+ }
+ },
+ "serialize() of bun:jsc": file => serialize(file),
+ };
+ for (const [name, call] of Object.entries(calls)) {
+ it.concurrent(`${name}, two times`, async () => {
+ const key = await store();
+ const file = client.file(key);
+ await attempt(() => call(file));
+ await attempt(() => call(file));
+ const before = requests(key);
+ expect({ before, ...(await readAndCopy(file)), requests: requests(key).slice(before.length) }).toEqual({
+ before: [["PutObject", null]],
+ ...whole,
+ requests: requestsOfWhole,
+ });
+ });
+ }
+
+ it.concurrent("a FormData body, for the entry that the FormData holds", async () => {
+ const key = await store();
+ const form = new FormData();
+ form.append("file", client.file(key));
+ await attempt(() => new Response(form).arrayBuffer());
+ const before = requests(key);
+ const entry = form.get("file") as Blob;
+ expect({ before, ...(await readAndCopy(entry)), requests: requests(key).slice(before.length) }).toEqual({
+ before: [["PutObject", null]],
+ ...whole,
+ requests: requestsOfWhole,
+ });
+ });
+
+ it.concurrent("expect() does not take the unknown length for 0", async () => {
+ const file = client.file(await store());
+ expect(() => expect(file).toHaveLength(0)).toThrow("Received length");
+ expect(() => expect(file).toBeEmpty()).toThrow("Expected value to be empty");
+ expect(file.slice(10, 30)).toHaveLength(20);
+ });
+});
diff --git a/test/js/bun/util/inspect.test.js b/test/js/bun/util/inspect.test.js
index f41ca41d67a1..dbf6609688a0 100644
--- a/test/js/bun/util/inspect.test.js
+++ b/test/js/bun/util/inspect.test.js
@@ -10,6 +10,7 @@ import {
withoutAggressiveGC,
} from "harness";
import { join } from "path";
+import { serve } from "s3-server";
import util from "util";
it("prototype", () => {
const prototypes = [
@@ -169,6 +170,30 @@ it("printing a Response over a Bun.file() that does not exist yet leaves its bod
});
});
+it("Response and Request print no size for an S3 file body, and send no request", async () => {
+ await using server = serve({ buckets: ["bucket"] });
+ const s3file = new Bun.S3Client(server.clientOptions("bucket")).file("key");
+ // new Response(s3file) is a redirect with no body. fetch() of a blob: URL gives a Response that holds the S3 blob.
+ const firstLines = async blob => {
+ const url = URL.createObjectURL(blob);
+ const response = await fetch(url);
+ URL.revokeObjectURL(url);
+ return [
+ Bun.inspect(response).split("\n")[0],
+ Bun.inspect(new Request("http://example.com/", { method: "POST", body: blob })).split("\n")[0],
+ ];
+ };
+ expect({
+ file: await firstLines(s3file),
+ slice: await firstLines(s3file.slice(10, 30)),
+ requests: server.requests.length,
+ }).toEqual({
+ file: ["Response {", "Request {"],
+ slice: ["Response (20 bytes) {", "Request (20 bytes) {"],
+ requests: 0,
+ });
+});
+
it("utf16 property name", () => {
var { Database } = require("bun:sqlite");
const db = Database.open(":memory:");
diff --git a/test/js/web/fetch/body.test.ts b/test/js/web/fetch/body.test.ts
index f859a062efd4..0b6036b88c4a 100644
--- a/test/js/web/fetch/body.test.ts
+++ b/test/js/web/fetch/body.test.ts
@@ -1,11 +1,12 @@
-import { file, spawn, version, type Socket } from "bun";
-import { describe, expect, test } from "bun:test";
+import { file, randomUUIDv7, S3Client, spawn, version, type Socket } from "bun";
+import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { bunEnv, bunExe, exampleSite, tempDir } from "harness";
import net from "net";
import { join } from "node:path";
// @ts-expect-error missing in @types/node
import { isDisturbed, isErrored, isReadable, Readable } from "node:stream";
import { finished } from "node:stream/promises";
+import { serve, type S3Server } from "s3-server";
const exampleServer = exampleSite("http");
@@ -1533,6 +1534,231 @@ describe.concurrent("a fetch() Response that cannot have a body", () => {
});
});
+// The size of an S3 object is not known before the download. A body made from
+// one reads the whole object, or the whole slice, whatever asked for its size first.
+describe("a body made from an S3 file", () => {
+ const payload = Buffer.alloc(100, "0123456789").toString();
+ let server: S3Server;
+ let client: S3Client;
+ beforeAll(() => {
+ server = serve({ buckets: ["bucket"] });
+ client = new S3Client(server.clientOptions("bucket"));
+ });
+ afterAll(() => server.stop());
+
+ /** Stores the bytes under a new key. */
+ async function store(bytes: string | Uint8Array, type?: string) {
+ const key = randomUUIDv7();
+ await client.write(key, bytes);
+ return {
+ file: () => client.file(key, { type }),
+ /** The Range header of each download of the object so far. `null` is a download with no Range header. */
+ ranges: () =>
+ server.requests
+ .filter(request => request.key === key && request.operation === "GetObject")
+ .map(request => request.headers.get("range")),
+ };
+ }
+
+ const toRequest = (blob: Blob) => new Request("http://example.com/", { method: "POST", body: blob });
+ // new Response(s3file) is a redirect. fetch() of a blob: URL gives a Response that holds the S3 blob.
+ async function toResponse(blob: Blob) {
+ const url = URL.createObjectURL(blob);
+ try {
+ return await fetch(url);
+ } finally {
+ URL.revokeObjectURL(url);
+ }
+ }
+
+ for (const [name, make] of [
+ ["Request", toRequest],
+ ["Response", toResponse],
+ ] as const) {
+ describe.concurrent(name, () => {
+ test(".body and textStream() download the whole object", async () => {
+ const object = await store(payload);
+ const body = await Bun.readableStreamToText((await make(object.file())).body!);
+ const textStream = (await Array.fromAsync((await make(object.file())).textStream())).join("");
+ expect({ body, textStream, ranges: object.ranges() }).toEqual({
+ body: payload,
+ textStream: payload,
+ ranges: [null, null],
+ });
+ });
+
+ test(".body and textStream() of a slice download the slice", async () => {
+ const object = await store(payload);
+ const body = await Bun.readableStreamToText((await make(object.file().slice(10, 30))).body!);
+ const textStream = (await Array.fromAsync((await make(object.file().slice(10, 30))).textStream())).join("");
+ expect({ body, textStream, ranges: object.ranges() }).toEqual({
+ body: payload.slice(10, 30),
+ textStream: payload.slice(10, 30),
+ ranges: ["bytes=10-29", "bytes=10-29"],
+ });
+ });
+
+ // Before, the read asked for `bytes=0-0`, and S3 rejects that range for an empty object.
+ test("an object of 0 bytes reads as empty", async () => {
+ const object = await store("");
+ const body = await Bun.readableStreamToText((await make(object.file())).body!);
+ const textStream = (await Array.fromAsync((await make(object.file())).textStream())).join("");
+ const printed = await make(object.file());
+ Bun.inspect(printed);
+ expect({ body, textStream, afterInspect: await printed.text(), ranges: object.ranges() }).toEqual({
+ body: "",
+ textStream: "",
+ afterInspect: "",
+ ranges: [null, null, null],
+ });
+ });
+
+ test("a clone and its original both download the whole object", async () => {
+ const object = await store(payload);
+ const original = await make(object.file());
+ const clone = original.clone();
+ expect({
+ clone: await Bun.readableStreamToText(clone.body!),
+ original: await Bun.readableStreamToText(original.body!),
+ ranges: object.ranges(),
+ }).toEqual({ clone: payload, original: payload, ranges: [null, null] });
+ });
+
+ const prints: Record unknown> = {
+ "Bun.inspect()": subject => Bun.inspect(subject),
+ "the message of a failed expect()": subject => expect(() => expect(subject).toBeNull()).toThrow(),
+ };
+ for (const [how, print] of Object.entries(prints)) {
+ test(`${how} does not change what the body reads`, async () => {
+ const object = await store(payload);
+ const printed = async () => {
+ const subject = await make(object.file());
+ print(subject);
+ return subject;
+ };
+ const blob = await (await printed()).blob();
+ expect({
+ body: await Bun.readableStreamToText((await printed()).body!),
+ text: await (await printed()).text(),
+ bytes: Buffer.from(await (await printed()).bytes()).toString(),
+ clone: await (await printed()).clone().text(),
+ blob: [blob.size, await blob.text()],
+ ranges: object.ranges(),
+ }).toEqual({
+ body: payload,
+ text: payload,
+ bytes: payload,
+ clone: payload,
+ blob: [NaN, payload],
+ ranges: [null, null, null, null, null],
+ });
+ });
+ }
+ });
+ }
+
+ test.concurrent("new Request(request) downloads the whole object", async () => {
+ const object = await store(payload);
+ const request = toRequest(object.file());
+ Bun.inspect(request);
+ expect({
+ body: await Bun.readableStreamToText(new Request(toRequest(object.file())).body!),
+ afterInspect: await new Request(request).text(),
+ ranges: object.ranges(),
+ }).toEqual({ body: payload, afterInspect: payload, ranges: [null, null] });
+ });
+
+ test.concurrent("fetch(request) uploads the whole object after Bun.inspect(request)", async () => {
+ const object = await store(payload);
+ const uploads: string[] = [];
+ await using sink = Bun.serve({
+ port: 0,
+ async fetch(req) {
+ uploads.push(await req.text());
+ return new Response("ok");
+ },
+ });
+ const request = new Request(sink.url.href, { method: "POST", body: object.file() });
+ Bun.inspect(request);
+ expect(await (await fetch(request)).text()).toBe("ok");
+ expect({ uploads, ranges: object.ranges() }).toEqual({ uploads: [payload], ranges: [null] });
+ });
+
+ test.concurrent("HTMLRewriter.transform() reads the whole object", async () => {
+ const object = await store("" + payload + "
");
+ const seen: string[] = [];
+ const output = await new HTMLRewriter()
+ .on("p", { text: chunk => void seen.push(chunk.text) })
+ .transform(await toResponse(object.file()))
+ .text();
+ expect({ output, seen: seen.join(""), ranges: object.ranges() }).toEqual({
+ output: "" + payload + "
",
+ seen: payload,
+ ranges: [null],
+ });
+ });
+
+ test.concurrent("WebAssembly.compileStreaming() and instantiateStreaming() read the whole object", async () => {
+ // The smallest module: the magic number and the version.
+ const object = await store(new Uint8Array([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]), "application/wasm");
+ const module = await WebAssembly.compileStreaming(toResponse(object.file()));
+ const { instance } = await WebAssembly.instantiateStreaming(toResponse(object.file()));
+ expect({
+ compiled: WebAssembly.Module.exports(module),
+ instantiated: Object.keys(instance.exports),
+ ranges: object.ranges(),
+ }).toEqual({ compiled: [], instantiated: [], ranges: [null, null] });
+ });
+
+ // These streams carried one byte of an S3 object before. This object takes many chunks.
+ // The fill is 251 bytes long, a prime, so chunks in a wrong order do not line up with it.
+ test("an object of 1 MiB arrives whole through each stream", async () => {
+ const html = "" + Buffer.alloc(1024 * 1024, Buffer.alloc(251, "a line of the object\n")).toString() + "
";
+ const object = await store(html);
+ const digest = (text: string) => ({ length: text.length, hash: Bun.hash(text) });
+
+ let seen = 0;
+ const rewritten = new HTMLRewriter()
+ .on("p", { text: chunk => void (seen += chunk.text.length) })
+ .transform(await toResponse(object.file()));
+ expect({
+ "Request.body": digest(await Bun.readableStreamToText(toRequest(object.file()).body!)),
+ "Response.body": digest(await Bun.readableStreamToText((await toResponse(object.file())).body!)),
+ "textStream()": digest((await Array.fromAsync(toRequest(object.file()).textStream())).join("")),
+ "HTMLRewriter": digest(await rewritten.text()),
+ seen,
+ ranges: object.ranges(),
+ }).toEqual({
+ "Request.body": digest(html),
+ "Response.body": digest(html),
+ "textStream()": digest(html),
+ "HTMLRewriter": digest(html),
+ seen: html.length - "
".length,
+ ranges: [null, null, null, null],
+ });
+ });
+
+ test("a module of 1 MiB arrives whole through WebAssembly.compileStreaming()", async () => {
+ // The header, then one custom section with the name "pad".
+ const pad = Buffer.alloc(1024 * 1024, Buffer.from(Array.from({ length: 251 }, (_, index) => index + 1)));
+ const name = [3, 0x70, 0x61, 0x64];
+ const sectionSize = name.length + pad.length;
+ // The size of the section as LEB128: 1048580 takes 3 bytes.
+ const leb128 = [(sectionSize & 0x7f) | 0x80, ((sectionSize >> 7) & 0x7f) | 0x80, sectionSize >> 14];
+ const object = await store(
+ Buffer.concat([Buffer.from([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0, ...leb128, ...name]), pad]),
+ "application/wasm",
+ );
+ const module = await WebAssembly.compileStreaming(toResponse(object.file()));
+ const [section] = WebAssembly.Module.customSections(module, "pad");
+ expect({ length: section.byteLength, hash: Bun.hash(section), ranges: object.ranges() }).toEqual({
+ length: pad.length,
+ hash: Bun.hash(pad),
+ ranges: [null],
+ });
+ });
+});
+
// A body's ReadableStream is one object no matter whether the body started out
// as a string, a Blob, a typed array, a JS ReadableStream, or zero bytes, so the
// disturbed/locked bookkeeping on it must not depend on what backs the body or
From c908e63fb254567394952a510688bac6be8ce5fd Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Sat, 24 Oct 2026 02:07:35 +0000
Subject: [PATCH 10/20] ci: retrigger
From efc5a5a882e1f7a3137b0a990b0592e75b79a0b7 Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Sat, 24 Oct 2026 02:20:14 +0000
Subject: [PATCH 11/20] FormData: keep an empty S3 slice an empty part, throw
after the walk
A zero-length slice of an S3 file has no bytes to read, so its part stays
empty, as on main. The serializing walk now only records that it met an S3
entry, and from_dom_form_data throws for it in a second walk, so the entries
it can serialize carry nothing for the refusal. The guard in front of
resolve_size() goes away: resolve_size() no longer writes an S3 size.
The message now names a File to pass instead of a formData.append() call,
because libraries build the FormData themselves.
---
src/runtime/webcore/Blob.rs | 61 +++++-
.../FormData-multipart-serialization.test.ts | 176 ++++++++++++------
2 files changed, 168 insertions(+), 69 deletions(-)
diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs
index d6114bb0a0e2..d3c3082c0939 100644
--- a/src/runtime/webcore/Blob.rs
+++ b/src/runtime/webcore/Blob.rs
@@ -803,6 +803,7 @@ impl BlobExt for Blob {
joiner: bun_core::string_joiner::StringJoiner::default(),
boundary,
failed: false,
+ s3_entry: false,
global_this,
};
// Size the node list up front: a file entry pushes at most 13 slices, a
@@ -868,6 +869,44 @@ impl BlobExt for Blob {
),
);
}
+ /// Throws the `TypeError` for the first entry that is an S3 object.
+ /// This is a second walk: the serializing walk only records that it
+ /// met one, so the entries it can serialize carry nothing for this
+ /// refusal.
+ #[cold]
+ fn throw_for_s3_entry(global_this: &JSGlobalObject, form_data: *mut jsc::DOMFormData) {
+ extern "C" fn find_thunk(
+ ctx_ptr: *mut c_void,
+ name: *mut EncodedSlice,
+ value_ptr: *mut c_void,
+ _filename: *mut EncodedSlice,
+ is_blob: u8,
+ ) {
+ // SAFETY: `ctx_ptr` is the `&JSGlobalObject` passed below.
+ let global_this = unsafe { &*ctx_ptr.cast::() };
+ if is_blob == 0 || global_this.has_exception() {
+ return;
+ }
+ // SAFETY: when `is_blob != 0`, `value_ptr` is the C++
+ // `JSBlob::m_ctx` (`*mut Blob`), valid for this callback.
+ let blob = unsafe { &*value_ptr.cast::() };
+ if !blob.is_s3() || blob.size.get() == 0 {
+ return;
+ }
+ // SAFETY: `name` is always a valid non-null `*EncodedSlice` for this callback.
+ let entry_name = unsafe { *name }.to_utf8();
+ let _ = global_this.throw_type_error(format_args!(
+ "FormData entry {} is an S3 file, which cannot be read while the body is serialized. Pass its bytes instead: new File([await s3file.bytes()], s3file.name, {{ type: s3file.type }})",
+ bun_core::fmt::quote(entry_name.slice()),
+ ));
+ }
+ DOMFormData__forEach(
+ form_data,
+ std::ptr::from_ref(global_this).cast_mut().cast::(),
+ find_thunk,
+ );
+ }
+
// C++ invokes the callback synchronously and does not retain `ctx`/`cb`
// past this call.
DOMFormData__forEach(
@@ -876,6 +915,9 @@ impl BlobExt for Blob {
for_each_thunk,
);
if context.failed {
+ if context.s3_entry {
+ throw_for_s3_entry(global_this, form_data);
+ }
// Drop the joiner (Drop runs StringJoiner::deinit) so every
// heap-owned slice already pushed — escaped names, non-ASCII
// conversions, NodeFS read_file result buffers — is freed.
@@ -3452,6 +3494,9 @@ struct FormDataContext<'a> {
joiner: StringJoiner<'a>,
boundary: &'a [u8], // borrowed; outlives the joiner
failed: bool,
+ /// The failure is an entry that is an S3 object. `from_dom_form_data`
+ /// throws for it after the walk.
+ s3_entry: bool,
global_this: &'a JSGlobalObject,
}
@@ -3585,8 +3630,7 @@ impl FormDataContext<'_> {
joiner.push_static(b"\r\n\r\n");
if blob.store.get().is_some() {
- // `resolve_size` would zero an S3 entry, which is rejected below.
- if blob.size.get() == MAX_SIZE && !blob.is_s3() {
+ if blob.size.get() == MAX_SIZE {
blob.resolve_size();
}
let store = blob
@@ -3596,12 +3640,13 @@ impl FormDataContext<'_> {
.expect("infallible: store present");
match &store.data {
store::Data::S3(_) => {
- self.failed = true;
- let entry_name = name.to_utf8();
- let _ = global_this.throw_type_error(format_args!(
- "FormData entry {} is an S3 file, which cannot be read while serializing the body. Read it first: formData.append(name, new Blob([await s3file.bytes()]), filename)",
- bun_core::fmt::quote(entry_name.slice()),
- ));
+ // An S3 object can only be read asynchronously. An
+ // empty window of one (`slice(n, n)`) has no bytes
+ // to read, so its part is empty.
+ if blob.size.get() != 0 {
+ self.failed = true;
+ self.s3_entry = true;
+ }
}
store::Data::File(file) => {
// TODO: make this async + lazy
diff --git a/test/js/web/html/FormData-multipart-serialization.test.ts b/test/js/web/html/FormData-multipart-serialization.test.ts
index f4c253d20f47..b12030f708cc 100644
--- a/test/js/web/html/FormData-multipart-serialization.test.ts
+++ b/test/js/web/html/FormData-multipart-serialization.test.ts
@@ -1,6 +1,6 @@
-import { S3Client } from "bun";
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, isLinux } from "harness";
+import { s3LocalEndpoint } from "../../bun/s3/s3-local-endpoint";
// Serializing a FormData body (`new Response(formData)` / `new Request(..., { body: formData })`)
// goes through Blob::from_dom_form_data, which joins the multipart parts out of
@@ -219,22 +219,16 @@ describe("multipart serialization (new Response(formData))", () => {
// written out as the part headers followed by an empty body: no error, no S3
// request, and the receiver stored an empty file.
describe("multipart serialization of S3-backed entries", () => {
- // Nothing below performs an S3 request, so the endpoint only has to parse.
- const s3 = new S3Client({
- endpoint: "http://127.0.0.1:1",
- bucket: "bucket",
- accessKeyId: "access-key",
- secretAccessKey: "secret-key",
- });
+ const object = "id,name\n1,bun\n";
+ const remedy = "new File([await s3file.bytes()], s3file.name, { type: s3file.type })";
// `quotedName` is the entry name as the message renders it, quotes included.
function messageFor(quotedName: string): string {
return (
- `FormData entry ${quotedName} is an S3 file, which cannot be read while serializing the body. ` +
- "Read it first: formData.append(name, new Blob([await s3file.bytes()]), filename)"
+ `FormData entry ${quotedName} is an S3 file, which cannot be read while the body is serialized. ` +
+ `Pass its bytes instead: ${remedy}`
);
}
- const expectedMessage = messageFor('"report"');
function formDataWith(entry: Blob, name = "report"): FormData {
const formData = new FormData();
@@ -244,27 +238,63 @@ describe("multipart serialization of S3-backed entries", () => {
return formData;
}
- function thrownBy(fn: () => unknown): unknown {
+ // What `fn` throws, or what the promise it returns rejects with.
+ async function failureOf(fn: () => unknown): Promise {
try {
- fn();
+ await fn();
} catch (error) {
return error;
}
- throw new Error("did not throw");
+ throw new Error("did not fail");
}
- test.each([
- ["S3Client.file()", () => s3.file("reports/2026.csv", { type: "text/csv" })],
- ['Bun.file("s3://...")', () => Bun.file("s3://bucket/reports/2026.csv")],
- ["a slice of an S3 file", () => s3.file("reports/2026.csv").slice(0, 16)],
- // A single-blob File shares the source blob's store instead of copying it.
- ["new File([s3file], name)", () => new File([s3.file("reports/2026.csv")], "wrapped.csv")],
- ])("new Response(formData) throws for %s", (_label, makeEntry) => {
- const formData = formDataWith(makeEntry());
-
- const error = thrownBy(() => new Response(formData));
+ const shapes: [string, (file: Bun.S3File) => Blob][] = [
+ ["S3Client.file()", file => file],
+ ["a slice of an S3 file", file => file.slice(2, 5)],
+ ["new File([s3file], name)", file => new File([file], "wrapped.csv")],
+ ];
+ const consumers: [string, (formData: FormData, url: URL) => unknown][] = [
+ ["new Response(formData)", formData => new Response(formData)],
+ ["new Request(url, { body })", (formData, url) => new Request(url, { method: "POST", body: formData })],
+ ["fetch(url, { body })", (formData, url) => fetch(url, { method: "POST", body: formData })],
+ ];
+
+ describe.each(shapes)("%s", (_shape, makeEntry) => {
+ test.each(consumers)("%s fails and sends nothing", async (_consumer, consume) => {
+ using endpoint = s3LocalEndpoint(object);
+ let received = 0;
+ using receiver = Bun.serve({
+ port: 0,
+ fetch() {
+ received++;
+ return new Response("stored");
+ },
+ });
+ const formData = formDataWith(makeEntry(endpoint.file("text/csv")));
+ const sizeBefore = (formData.get("report") as Blob).size;
+
+ const error = await failureOf(() => consume(formData, receiver.url));
+ expect(error).toBeInstanceOf(TypeError);
+ expect({
+ message: (error as TypeError).message,
+ s3Requests: endpoint.ranges,
+ received,
+ sizeAfter: (formData.get("report") as Blob).size,
+ }).toEqual({
+ message: messageFor('"report"'),
+ s3Requests: [],
+ received: 0,
+ sizeAfter: sizeBefore,
+ });
+ });
+ });
+
+ test('new Response(formData) fails for Bun.file("s3://...")', async () => {
+ const formData = formDataWith(Bun.file("s3://bucket/reports/2026.csv"));
+
+ const error = await failureOf(() => new Response(formData));
expect(error).toBeInstanceOf(TypeError);
- expect((error as TypeError).message).toBe(expectedMessage);
+ expect((error as TypeError).message).toBe(messageFor('"report"'));
});
test.each([
@@ -272,53 +302,77 @@ describe("multipart serialization of S3-backed entries", () => {
["a non-ASCII name", "レポート ☺", '"レポート ☺"'],
["a name containing a quote", 'quote"name', '"quote\\"name"'],
["a name containing CRLF", "crlf\r\nname", '"crlf\\r\\nname"'],
- ])("the message quotes %s", (_label, name, quotedName) => {
- const formData = formDataWith(s3.file("reports/2026.csv"), name);
+ ])("the message quotes %s", async (_label, name, quotedName) => {
+ using endpoint = s3LocalEndpoint(object);
+ const formData = formDataWith(endpoint.file(), name);
- const error = thrownBy(() => new Response(formData));
+ const error = await failureOf(() => new Response(formData));
expect((error as TypeError).message).toBe(messageFor(quotedName));
});
- // Serializing used to resolve the entry's size as well, which for an S3 blob
- // turns the "unknown" NaN into 0.
- test("serializing leaves the entry's unknown size alone", () => {
- const formData = formDataWith(s3.file("reports/2026.csv"));
- expect((formData.get("report") as Blob).size).toBeNaN();
-
- try {
- new Response(formData);
- } catch {}
+ // An empty window of an S3 object has no bytes to fetch.
+ test.each([
+ ["slice(0, 0)", 0],
+ ["slice(3, 3)", 3],
+ ])("s3file.%s serializes as an empty part", async (_label, at) => {
+ using endpoint = s3LocalEndpoint(object);
+ const formData = new FormData();
+ formData.append("report", endpoint.file("text/csv").slice(at, at), "empty.csv");
+ formData.append("after", "last");
- expect((formData.get("report") as Blob).size).toBeNaN();
+ const response = new Response(formData);
+ const contentType = response.headers.get("Content-Type")!;
+ const boundary = contentType.slice(contentType.indexOf("boundary=") + "boundary=".length);
+ expect({ body: await response.text(), s3Requests: endpoint.ranges }).toEqual({
+ body:
+ `--${boundary}\r\n` +
+ `Content-Disposition: form-data; name="report"; filename="empty.csv"\r\n` +
+ `Content-Type: text/csv\r\n\r\n\r\n` +
+ `--${boundary}\r\n` +
+ `Content-Disposition: form-data; name="after"\r\n\r\nlast\r\n` +
+ `--${boundary}--\r\n`,
+ s3Requests: [],
+ });
});
- test("new Request() throws", () => {
- const formData = formDataWith(s3.file("reports/2026.csv"));
+ // structuredClone() throws for an S3 file. It used to leave the size of the
+ // file at 0 as well, which made the file look like an empty window.
+ test("an S3 file still fails after structuredClone() refused it", async () => {
+ using endpoint = s3LocalEndpoint(object);
+ const s3file = endpoint.file("text/csv");
+ expect(() => structuredClone(s3file)).toThrow();
- const error = thrownBy(() => new Request("http://localhost/upload", { method: "POST", body: formData }));
+ const error = await failureOf(() => new Response(formDataWith(s3file)));
expect(error).toBeInstanceOf(TypeError);
- expect((error as TypeError).message).toBe(expectedMessage);
+ expect({ message: (error as TypeError).message, size: s3file.size, s3Requests: endpoint.ranges }).toEqual({
+ message: messageFor('"report"'),
+ size: NaN,
+ s3Requests: [],
+ });
});
- test("fetch() rejects without sending a request", async () => {
- const receivedBodies: string[] = [];
- using server = Bun.serve({
- port: 0,
- async fetch(request) {
- receivedBodies.push(await request.text());
- return new Response("stored");
- },
- });
- const formData = formDataWith(s3.file("reports/2026.csv"));
+ // Runs the expression the message prints, character for character.
+ test("the File that the message says to pass uploads the bytes, the type and the filename", async () => {
+ using endpoint = s3LocalEndpoint(object);
+ const s3file = endpoint.file("text/csv");
+ const error = await failureOf(() => new Response(formDataWith(s3file)));
+ const printed = (error as TypeError).message.slice((error as TypeError).message.indexOf("new File("));
+ expect(printed).toBe(remedy);
- const error = await fetch(server.url, { method: "POST", body: formData }).then(
- response => {
- throw new Error(`fetch() resolved with status ${response.status}`);
- },
- rejection => rejection,
- );
- expect(error).toBeInstanceOf(TypeError);
- expect((error as TypeError).message).toBe(expectedMessage);
- expect(receivedBodies).toEqual([]);
+ const AsyncFunction = (async () => {}).constructor as new (...args: string[]) => (s3file: Blob) => Promise;
+ const formData = new FormData();
+ formData.append("report", await new AsyncFunction("s3file", `return ${printed};`)(s3file));
+
+ const response = new Response(formData);
+ const contentType = response.headers.get("Content-Type")!;
+ const boundary = contentType.slice(contentType.indexOf("boundary=") + "boundary=".length);
+ expect({ body: await response.text(), s3Requests: endpoint.ranges }).toEqual({
+ body:
+ `--${boundary}\r\n` +
+ `Content-Disposition: form-data; name="report"; filename="key"\r\n` +
+ `Content-Type: text/csv\r\n\r\n${object}\r\n` +
+ `--${boundary}--\r\n`,
+ s3Requests: [null],
+ });
});
});
From a1b98cf583b9ce27cb4a3c7625d395532b97f419 Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Sat, 24 Oct 2026 02:21:03 +0000
Subject: [PATCH 12/20] test: move the S3 header and HEAD tests into
body.test.ts
They join the other tests of a body made from an S3 file, and use the S3
server of that block.
---
test/js/bun/http/bun-server.test.ts | 29 -----------------------
test/js/bun/util/inspect.test.js | 25 --------------------
test/js/web/fetch/body.test.ts | 36 +++++++++++++++++++++++++++++
3 files changed, 36 insertions(+), 54 deletions(-)
diff --git a/test/js/bun/http/bun-server.test.ts b/test/js/bun/http/bun-server.test.ts
index 42e2cb9c0679..58f153dc2462 100644
--- a/test/js/bun/http/bun-server.test.ts
+++ b/test/js/bun/http/bun-server.test.ts
@@ -3168,35 +3168,6 @@ test("HEAD for a Response with an S3-backed Blob body doesn't wait for S3", asyn
expect(exitCode).toBe(0);
});
-// A slice of an S3 file knows its length, but GET sends none of the object for a Blob body.
-test("HEAD for a Response with a slice of an S3 file as its body reports the length that GET sends", async () => {
- let s3Requests = 0;
- await using s3Origin = Bun.serve({
- port: 0,
- fetch() {
- s3Requests++;
- return new Response(Buffer.alloc(100, "0123456789"));
- },
- });
- const s3 = new Bun.S3Client({
- accessKeyId: "test",
- secretAccessKey: "test",
- bucket: "my-bucket",
- endpoint: s3Origin.url.href,
- });
- await using app = Bun.serve({
- port: 0,
- fetch: () => fetch(URL.createObjectURL(s3.file("object.txt").slice(10, 30))),
- });
- const head = await fetch(app.url, { method: "HEAD" });
- const requestsForHead = s3Requests;
- const get = await fetch(app.url);
- expect({ head: head.headers.get("content-length"), requestsForHead }).toEqual({
- head: get.headers.get("content-length"),
- requestsForHead: 0,
- });
-});
-
// Handler callbacks (fetch/error/websocket.*) are stored on the JS wrapper and
// traced by the GC rather than independently rooted. These tests lock in that
// reload/stop transitions never leave a window where a handler is collected
diff --git a/test/js/bun/util/inspect.test.js b/test/js/bun/util/inspect.test.js
index dbf6609688a0..f41ca41d67a1 100644
--- a/test/js/bun/util/inspect.test.js
+++ b/test/js/bun/util/inspect.test.js
@@ -10,7 +10,6 @@ import {
withoutAggressiveGC,
} from "harness";
import { join } from "path";
-import { serve } from "s3-server";
import util from "util";
it("prototype", () => {
const prototypes = [
@@ -170,30 +169,6 @@ it("printing a Response over a Bun.file() that does not exist yet leaves its bod
});
});
-it("Response and Request print no size for an S3 file body, and send no request", async () => {
- await using server = serve({ buckets: ["bucket"] });
- const s3file = new Bun.S3Client(server.clientOptions("bucket")).file("key");
- // new Response(s3file) is a redirect with no body. fetch() of a blob: URL gives a Response that holds the S3 blob.
- const firstLines = async blob => {
- const url = URL.createObjectURL(blob);
- const response = await fetch(url);
- URL.revokeObjectURL(url);
- return [
- Bun.inspect(response).split("\n")[0],
- Bun.inspect(new Request("http://example.com/", { method: "POST", body: blob })).split("\n")[0],
- ];
- };
- expect({
- file: await firstLines(s3file),
- slice: await firstLines(s3file.slice(10, 30)),
- requests: server.requests.length,
- }).toEqual({
- file: ["Response {", "Request {"],
- slice: ["Response (20 bytes) {", "Request (20 bytes) {"],
- requests: 0,
- });
-});
-
it("utf16 property name", () => {
var { Database } = require("bun:sqlite");
const db = Database.open(":memory:");
diff --git a/test/js/web/fetch/body.test.ts b/test/js/web/fetch/body.test.ts
index 0b6036b88c4a..7e8ed1605866 100644
--- a/test/js/web/fetch/body.test.ts
+++ b/test/js/web/fetch/body.test.ts
@@ -1657,6 +1657,42 @@ describe("a body made from an S3 file", () => {
});
}
+ test.concurrent(
+ "Bun.inspect() prints no size for the object, the size of a slice, and sends no request",
+ async () => {
+ const object = await store(payload);
+ const firstLine = (subject: Request | Response) => Bun.inspect(subject).split("\n")[0];
+ expect({
+ object: [firstLine(toRequest(object.file())), firstLine(await toResponse(object.file()))],
+ slice: [
+ firstLine(toRequest(object.file().slice(10, 30))),
+ firstLine(await toResponse(object.file().slice(10, 30))),
+ ],
+ ranges: object.ranges(),
+ }).toEqual({
+ object: ["Request {", "Response {"],
+ slice: ["Request (20 bytes) {", "Response (20 bytes) {"],
+ ranges: [],
+ });
+ },
+ );
+
+ // GET sends none of the object for a Response whose body is an S3 blob. A slice knows its size, and HEAD has to agree with GET.
+ test.concurrent("Bun.serve: HEAD for a slice of an S3 file reports the length that GET sends", async () => {
+ const object = await store(payload);
+ await using app = Bun.serve({
+ port: 0,
+ fetch: () => toResponse(object.file().slice(10, 30)),
+ });
+ const head = await fetch(app.url, { method: "HEAD" });
+ const rangesForHead = object.ranges();
+ const get = await fetch(app.url);
+ expect({ head: head.headers.get("content-length"), rangesForHead }).toEqual({
+ head: get.headers.get("content-length"),
+ rangesForHead: [],
+ });
+ });
+
test.concurrent("new Request(request) downloads the whole object", async () => {
const object = await store(payload);
const request = toRequest(object.file());
From 5f06dcb295b88ddc2396e0751b4ca4fb2f58e3ec Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Sat, 24 Oct 2026 02:35:41 +0000
Subject: [PATCH 13/20] test: compare the HEAD and GET lengths with the bytes
that GET sends
The HEAD test for a slice of an S3 file read only the two Content-Length
headers. It now reads the GET body and compares both headers with its
length.
---
test/js/web/fetch/body.test.ts | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/test/js/web/fetch/body.test.ts b/test/js/web/fetch/body.test.ts
index 7e8ed1605866..82449e2dcf56 100644
--- a/test/js/web/fetch/body.test.ts
+++ b/test/js/web/fetch/body.test.ts
@@ -1687,10 +1687,12 @@ describe("a body made from an S3 file", () => {
const head = await fetch(app.url, { method: "HEAD" });
const rangesForHead = object.ranges();
const get = await fetch(app.url);
- expect({ head: head.headers.get("content-length"), rangesForHead }).toEqual({
- head: get.headers.get("content-length"),
- rangesForHead: [],
- });
+ const sent = String((await get.arrayBuffer()).byteLength);
+ expect({
+ head: head.headers.get("content-length"),
+ get: get.headers.get("content-length"),
+ rangesForHead,
+ }).toEqual({ head: sent, get: sent, rangesForHead: [] });
});
test.concurrent("new Request(request) downloads the whole object", async () => {
From 987b11d8217756e63dab15678253ede8709d7f85 Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Sat, 24 Oct 2026 02:56:13 +0000
Subject: [PATCH 14/20] FormData: throw for the S3 entry after the second walk;
docs and tests
The second walk now only copies the name of the first S3 entry, and the
TypeError is thrown once the walk is over, so nothing allocates in JS while
C++ iterates the entries and the refusal always ends in an exception.
Tests: the message names the first S3 file after entries the serializer can
write; the local S3 endpoint binds 127.0.0.1. Docs and the S3File JSDoc say
that a FormData body needs the bytes first.
---
docs/runtime/s3.mdx | 11 ++++++-
packages/bun-types/s3.d.ts | 10 +++++++
src/runtime/server/RequestContext.rs | 2 +-
src/runtime/webcore/Blob.rs | 30 +++++++++----------
test/js/bun/s3/s3-local-endpoint.ts | 1 +
test/js/bun/s3/s3.test.ts | 6 ++--
.../FormData-multipart-serialization.test.ts | 28 ++++++++++++++---
7 files changed, 63 insertions(+), 25 deletions(-)
diff --git a/docs/runtime/s3.mdx b/docs/runtime/s3.mdx
index 9927d3260ff9..78237bd96fed 100644
--- a/docs/runtime/s3.mdx
+++ b/docs/runtime/s3.mdx
@@ -601,7 +601,16 @@ Like `Bun.file()`, `S3File` extends [`Blob`](https://developer.mozilla.org/en-US
| `s3File.stream()` | `ReadableStream` |
| `await s3File.arrayBuffer()` | `ArrayBuffer` |
-Because `S3File` extends `Blob`, `S3File` instances work with `fetch()`, `Response`, and other web APIs that accept `Blob` instances.
+Because `S3File` extends `Blob`, you can pass an `S3File` to `fetch()`, `Response`, and other web APIs that read a `Blob` asynchronously.
+
+A `FormData` body is the exception. Bun builds that body at once, and it downloads an S3 object only when you read it. A `FormData` body with an `S3File` entry throws a `TypeError`. Read the bytes first and append a `File`:
+
+```ts s3.ts icon="/icons/typescript.svg"
+const formData = new FormData();
+formData.append("report", new File([await s3file.bytes()], s3file.name, { type: s3file.type }));
+
+await fetch("https://example.com/upload", { method: "POST", body: formData });
+```
### Partial reads with `slice`
diff --git a/packages/bun-types/s3.d.ts b/packages/bun-types/s3.d.ts
index 1d1d62ec494c..1da84694c14e 100644
--- a/packages/bun-types/s3.d.ts
+++ b/packages/bun-types/s3.d.ts
@@ -473,6 +473,16 @@ declare module "bun" {
* Represents a file in an S3-compatible storage service.
* Extends the Blob interface for compatibility with web APIs.
*
+ * Bun downloads the object only when you read it. A `FormData` body is
+ * built at once, so a `FormData` body with an `S3File` entry throws a
+ * `TypeError`. Read the bytes first and append a `File`.
+ *
+ * @example
+ * ```ts
+ * const formData = new FormData();
+ * formData.append("report", new File([await file.bytes()], file.name, { type: file.type }));
+ * ```
+ *
* @category Cloud Storage
*/
interface S3File extends Blob {
diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs
index 6813117c2cdf..0c413ff508d8 100644
--- a/src/runtime/server/RequestContext.rs
+++ b/src/runtime/server/RequestContext.rs
@@ -2655,7 +2655,7 @@ where
// (here, `blob`) may still be live across it. Nothing is written
// to the socket in between, so the wire output is unchanged.
let blob_size = if shim::blob_is_s3(blob) {
- // Match the body GET sends (`render_bytes`). `resolve_size()` would set the blob's size to 0.
+ // Match the body GET sends (`render_bytes`). `resolve_size()` leaves the size of an S3 blob unknown.
blob.shared_view().len() as BlobSizeType
} else {
blob.resolve_size();
diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs
index d3c3082c0939..8b3307d5c37d 100644
--- a/src/runtime/webcore/Blob.rs
+++ b/src/runtime/webcore/Blob.rs
@@ -850,7 +850,7 @@ impl BlobExt for Blob {
unsafe extern "C" {
// `this` is the `&mut DOMFormData` param (coerced); `ctx`/`cb` are
// stored opaquely and only used synchronously. Module-private with
- // one call site below — no caller-side precondition remains. Kept
+ // two call sites below — no caller-side precondition remains. Kept
// `*mut` (not `&mut`) to match the `bun_jsc` decl and avoid
// `clashing_extern_declarations`.
safe fn DOMFormData__forEach(
@@ -882,29 +882,29 @@ impl BlobExt for Blob {
_filename: *mut EncodedSlice,
is_blob: u8,
) {
- // SAFETY: `ctx_ptr` is the `&JSGlobalObject` passed below.
- let global_this = unsafe { &*ctx_ptr.cast::() };
- if is_blob == 0 || global_this.has_exception() {
+ // SAFETY: `ctx_ptr` is the `&mut Option>` passed below.
+ let found = unsafe { bun_ptr::callback_ctx::>>(ctx_ptr) };
+ if is_blob == 0 || found.is_some() {
return;
}
- // SAFETY: when `is_blob != 0`, `value_ptr` is the C++
- // `JSBlob::m_ctx` (`*mut Blob`), valid for this callback.
+ // SAFETY: when `is_blob != 0`, `value_ptr` is the entry's
+ // `*mut Blob`, valid for this callback.
let blob = unsafe { &*value_ptr.cast::() };
- if !blob.is_s3() || blob.size.get() == 0 {
- return;
+ if blob.is_s3() && blob.size.get() != 0 {
+ // SAFETY: `name` is always a valid non-null `*EncodedSlice` for this callback.
+ *found = Some(unsafe { *name }.to_utf8().into_vec());
}
- // SAFETY: `name` is always a valid non-null `*EncodedSlice` for this callback.
- let entry_name = unsafe { *name }.to_utf8();
- let _ = global_this.throw_type_error(format_args!(
- "FormData entry {} is an S3 file, which cannot be read while the body is serialized. Pass its bytes instead: new File([await s3file.bytes()], s3file.name, {{ type: s3file.type }})",
- bun_core::fmt::quote(entry_name.slice()),
- ));
}
+ let mut entry_name: Option> = None;
DOMFormData__forEach(
form_data,
- std::ptr::from_ref(global_this).cast_mut().cast::(),
+ (&raw mut entry_name).cast::(),
find_thunk,
);
+ let _ = global_this.throw_type_error(format_args!(
+ "FormData entry {} is an S3 file, which cannot be read while the body is serialized. Pass its bytes instead: new File([await s3file.bytes()], s3file.name, {{ type: s3file.type }})",
+ bun_core::fmt::quote(entry_name.as_deref().unwrap_or_default()),
+ ));
}
// C++ invokes the callback synchronously and does not retain `ctx`/`cb`
diff --git a/test/js/bun/s3/s3-local-endpoint.ts b/test/js/bun/s3/s3-local-endpoint.ts
index 5cf976dc71db..138e58411b45 100644
--- a/test/js/bun/s3/s3-local-endpoint.ts
+++ b/test/js/bun/s3/s3-local-endpoint.ts
@@ -9,6 +9,7 @@ export function s3LocalEndpoint(object: string | Uint8Array) {
const bytes = Buffer.from(object);
const ranges: (string | null)[] = [];
const server = Bun.serve({
+ hostname: "127.0.0.1",
port: 0,
fetch(req) {
const range = req.headers.get("range");
diff --git a/test/js/bun/s3/s3.test.ts b/test/js/bun/s3/s3.test.ts
index e562cf86e1a8..7381cfe3a3f2 100644
--- a/test/js/bun/s3/s3.test.ts
+++ b/test/js/bun/s3/s3.test.ts
@@ -2220,13 +2220,11 @@ describe("s3 file size stays unknown", () => {
).toEqual(untouched);
});
- it("for a FormData entry after the FormData became a body", async () => {
+ it("for a FormData entry after the FormData was refused as a body", async () => {
using endpoint = s3LocalEndpoint(payload);
const form = new FormData();
form.append("file", endpoint.file());
- try {
- await new Response(form).arrayBuffer();
- } catch {}
+ expect(() => new Response(form)).toThrow(TypeError);
const entry = form.get("file") as Blob;
expect({ size: entry.size, text: await entry.text(), ranges: endpoint.ranges }).toEqual({
size: NaN,
diff --git a/test/js/web/html/FormData-multipart-serialization.test.ts b/test/js/web/html/FormData-multipart-serialization.test.ts
index b12030f708cc..3f2f2613676c 100644
--- a/test/js/web/html/FormData-multipart-serialization.test.ts
+++ b/test/js/web/html/FormData-multipart-serialization.test.ts
@@ -264,6 +264,7 @@ describe("multipart serialization of S3-backed entries", () => {
using endpoint = s3LocalEndpoint(object);
let received = 0;
using receiver = Bun.serve({
+ hostname: "127.0.0.1",
port: 0,
fetch() {
received++;
@@ -310,6 +311,23 @@ describe("multipart serialization of S3-backed entries", () => {
expect((error as TypeError).message).toBe(messageFor(quotedName));
});
+ // The entries before the S3 file are ones the serializer can write.
+ test("the message names the first S3 file, after a Blob and an empty S3 slice", async () => {
+ using endpoint = s3LocalEndpoint(object);
+ const formData = new FormData();
+ formData.append("blob", new Blob(["in memory"]), "blob.txt");
+ formData.append("empty", endpoint.file().slice(4, 4), "empty.csv");
+ formData.append("first", endpoint.file(), "first.csv");
+ formData.append("second", endpoint.file(), "second.csv");
+
+ const error = await failureOf(() => new Response(formData));
+ expect(error).toBeInstanceOf(TypeError);
+ expect({ message: (error as TypeError).message, s3Requests: endpoint.ranges }).toEqual({
+ message: messageFor('"first"'),
+ s3Requests: [],
+ });
+ });
+
// An empty window of an S3 object has no bytes to fetch.
test.each([
["slice(0, 0)", 0],
@@ -335,12 +353,14 @@ describe("multipart serialization of S3-backed entries", () => {
});
});
- // structuredClone() throws for an S3 file. It used to leave the size of the
- // file at 0 as well, which made the file look like an empty window.
- test("an S3 file still fails after structuredClone() refused it", async () => {
+ // structuredClone() used to leave the size of an S3 file at 0, which made
+ // the file look like an empty window.
+ test("an S3 file still fails after structuredClone() looked at it", async () => {
using endpoint = s3LocalEndpoint(object);
const s3file = endpoint.file("text/csv");
- expect(() => structuredClone(s3file)).toThrow();
+ try {
+ structuredClone(s3file);
+ } catch {}
const error = await failureOf(() => new Response(formDataWith(s3file)));
expect(error).toBeInstanceOf(TypeError);
From 08b437ff2478b16cf2f9d69888b59d8918342699 Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Sat, 24 Oct 2026 03:41:10 +0000
Subject: [PATCH 15/20] Shorten three comments in the FormData serializer
---
src/runtime/webcore/Blob.rs | 12 +++---------
1 file changed, 3 insertions(+), 9 deletions(-)
diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs
index 8b3307d5c37d..ca5337b273df 100644
--- a/src/runtime/webcore/Blob.rs
+++ b/src/runtime/webcore/Blob.rs
@@ -869,10 +869,7 @@ impl BlobExt for Blob {
),
);
}
- /// Throws the `TypeError` for the first entry that is an S3 object.
- /// This is a second walk: the serializing walk only records that it
- /// met one, so the entries it can serialize carry nothing for this
- /// refusal.
+ /// A second walk, so that the first one keeps no entry name for this.
#[cold]
fn throw_for_s3_entry(global_this: &JSGlobalObject, form_data: *mut jsc::DOMFormData) {
extern "C" fn find_thunk(
@@ -3494,8 +3491,7 @@ struct FormDataContext<'a> {
joiner: StringJoiner<'a>,
boundary: &'a [u8], // borrowed; outlives the joiner
failed: bool,
- /// The failure is an entry that is an S3 object. `from_dom_form_data`
- /// throws for it after the walk.
+ /// `from_dom_form_data` throws for an S3 entry after the walk.
s3_entry: bool,
global_this: &'a JSGlobalObject,
}
@@ -3640,9 +3636,7 @@ impl FormDataContext<'_> {
.expect("infallible: store present");
match &store.data {
store::Data::S3(_) => {
- // An S3 object can only be read asynchronously. An
- // empty window of one (`slice(n, n)`) has no bytes
- // to read, so its part is empty.
+ // An empty window (`slice(n, n)`) is an empty part.
if blob.size.get() != 0 {
self.failed = true;
self.s3_entry = true;
From 2a092e3a0df6b79c26c8a1b43d64ffd0a6e74f4d Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Sat, 24 Oct 2026 04:19:51 +0000
Subject: [PATCH 16/20] FormData: keep one failure state, and find the S3 entry
by its Blob
The serializer kept two flags, and the second one meant something only
when the first one was set. The failure is now one enum. Its S3 variant
holds the Blob of the entry, so the second walk finds the name by that
pointer and no longer repeats the size test.
---
src/runtime/webcore/Blob.rs | 67 ++++++++++++++++++++-----------------
1 file changed, 37 insertions(+), 30 deletions(-)
diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs
index ca5337b273df..ffaba5930346 100644
--- a/src/runtime/webcore/Blob.rs
+++ b/src/runtime/webcore/Blob.rs
@@ -802,8 +802,7 @@ impl BlobExt for Blob {
let mut context = FormDataContext {
joiner: bun_core::string_joiner::StringJoiner::default(),
boundary,
- failed: false,
- s3_entry: false,
+ failure: None,
global_this,
};
// Size the node list up front: a file entry pushes at most 13 slices, a
@@ -871,7 +870,15 @@ impl BlobExt for Blob {
}
/// A second walk, so that the first one keeps no entry name for this.
#[cold]
- fn throw_for_s3_entry(global_this: &JSGlobalObject, form_data: *mut jsc::DOMFormData) {
+ fn throw_for_s3_entry(
+ global_this: &JSGlobalObject,
+ form_data: *mut jsc::DOMFormData,
+ entry: *const Blob,
+ ) {
+ struct Find {
+ entry: *const Blob,
+ name: Option>,
+ }
extern "C" fn find_thunk(
ctx_ptr: *mut c_void,
name: *mut EncodedSlice,
@@ -879,28 +886,21 @@ impl BlobExt for Blob {
_filename: *mut EncodedSlice,
is_blob: u8,
) {
- // SAFETY: `ctx_ptr` is the `&mut Option>` passed below.
- let found = unsafe { bun_ptr::callback_ctx::>>(ctx_ptr) };
- if is_blob == 0 || found.is_some() {
- return;
- }
- // SAFETY: when `is_blob != 0`, `value_ptr` is the entry's
- // `*mut Blob`, valid for this callback.
- let blob = unsafe { &*value_ptr.cast::() };
- if blob.is_s3() && blob.size.get() != 0 {
+ // SAFETY: `ctx_ptr` is the `&mut Find` passed below.
+ let find = unsafe { bun_ptr::callback_ctx::(ctx_ptr) };
+ if is_blob != 0
+ && find.name.is_none()
+ && core::ptr::eq(value_ptr.cast::(), find.entry)
+ {
// SAFETY: `name` is always a valid non-null `*EncodedSlice` for this callback.
- *found = Some(unsafe { *name }.to_utf8().into_vec());
+ find.name = Some(unsafe { *name }.to_utf8().into_vec());
}
}
- let mut entry_name: Option> = None;
- DOMFormData__forEach(
- form_data,
- (&raw mut entry_name).cast::(),
- find_thunk,
- );
+ let mut find = Find { entry, name: None };
+ DOMFormData__forEach(form_data, (&raw mut find).cast::(), find_thunk);
let _ = global_this.throw_type_error(format_args!(
"FormData entry {} is an S3 file, which cannot be read while the body is serialized. Pass its bytes instead: new File([await s3file.bytes()], s3file.name, {{ type: s3file.type }})",
- bun_core::fmt::quote(entry_name.as_deref().unwrap_or_default()),
+ bun_core::fmt::quote(find.name.as_deref().unwrap_or_default()),
));
}
@@ -911,9 +911,9 @@ impl BlobExt for Blob {
(&raw mut context).cast::(),
for_each_thunk,
);
- if context.failed {
- if context.s3_entry {
- throw_for_s3_entry(global_this, form_data);
+ if let Some(failure) = context.failure {
+ if let FormDataFailure::S3Entry(entry) = failure {
+ throw_for_s3_entry(global_this, form_data, entry);
}
// Drop the joiner (Drop runs StringJoiner::deinit) so every
// heap-owned slice already pushed — escaped names, non-ASCII
@@ -3490,12 +3490,19 @@ where
struct FormDataContext<'a> {
joiner: StringJoiner<'a>,
boundary: &'a [u8], // borrowed; outlives the joiner
- failed: bool,
- /// `from_dom_form_data` throws for an S3 entry after the walk.
- s3_entry: bool,
+ failure: Option,
global_this: &'a JSGlobalObject,
}
+/// Why `FormDataContext::on_entry` stopped serializing.
+#[derive(Clone, Copy)]
+enum FormDataFailure {
+ /// `on_entry` threw.
+ Thrown,
+ /// The entry with this Blob is an S3 object. `from_dom_form_data` throws for it after the walk.
+ S3Entry(*const Blob),
+}
+
/// Which piece of a `multipart/form-data` entry a string is, selecting the
/// transforms the WHATWG "multipart/form-data encoding algorithm" applies to
/// it:
@@ -3583,7 +3590,7 @@ impl FormDataContext<'_> {
}
fn on_entry(&mut self, name: EncodedSlice, entry: FormDataEntry<'_>) {
- if self.failed {
+ if self.failure.is_some() {
return;
}
// Copy the borrowed refs out first (disjoint-field reads) so the
@@ -3638,8 +3645,8 @@ impl FormDataContext<'_> {
store::Data::S3(_) => {
// An empty window (`slice(n, n)`) is an empty part.
if blob.size.get() != 0 {
- self.failed = true;
- self.s3_entry = true;
+ self.failure =
+ Some(FormDataFailure::S3Entry(core::ptr::from_ref(blob)));
}
}
store::Data::File(file) => {
@@ -3657,7 +3664,7 @@ impl FormDataContext<'_> {
let res = node_fs.read_file(&rf_args, crate::node::fs::Flavor::Sync);
match res {
Err(err) => {
- self.failed = true;
+ self.failure = Some(FormDataFailure::Thrown);
let js_err = err.to_js(global_this);
let _ = global_this.throw_value(js_err);
}
From fe2e1ddf20f4091115444a6bd19a45b7252f908d Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Sat, 24 Oct 2026 04:20:03 +0000
Subject: [PATCH 17/20] Carry only the resolve_size hunks, as #46653 has them
#46653 replaces #43934. This branch now has the same three source hunks
as #46653, so the two merge without a conflict in either order. The
tests of #43934 and its S3 stand-in are gone from this branch. The
FormData tests use the S3 server in test/packages/s3-server.
---
src/runtime/server/RequestContext.rs | 2 +-
src/runtime/webcore/Body.rs | 8 +-
test/js/bun/s3/s3-local-endpoint.ts | 34 -----
test/js/bun/s3/s3.test.ts | 91 ------------
test/js/web/fetch/body.test.ts | 138 ------------------
.../FormData-multipart-serialization.test.ts | 96 ++++++++----
6 files changed, 72 insertions(+), 297 deletions(-)
delete mode 100644 test/js/bun/s3/s3-local-endpoint.ts
diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs
index 0c413ff508d8..9b3c21d4c60d 100644
--- a/src/runtime/server/RequestContext.rs
+++ b/src/runtime/server/RequestContext.rs
@@ -2655,7 +2655,7 @@ where
// (here, `blob`) may still be live across it. Nothing is written
// to the socket in between, so the wire output is unchanged.
let blob_size = if shim::blob_is_s3(blob) {
- // Match the body GET sends (`render_bytes`). `resolve_size()` leaves the size of an S3 blob unknown.
+ // Match the body GET sends (`render_bytes`): no bytes. `size` is unknown, or the window of a slice.
blob.shared_view().len() as BlobSizeType
} else {
blob.resolve_size();
diff --git a/src/runtime/webcore/Body.rs b/src/runtime/webcore/Body.rs
index bb945cafe5d1..094793f8ffa8 100644
--- a/src/runtime/webcore/Body.rs
+++ b/src/runtime/webcore/Body.rs
@@ -733,11 +733,7 @@ impl Value {
pub(crate) fn size(&mut self) -> blob::SizeType {
match self {
- Value::Blob(b) => match b.get_size_for_bindings() {
- // The size of an S3 object is unknown: 0, like a `Locked` body with no hint.
- u64::MAX if b.is_s3() => 0,
- size => size as blob::SizeType,
- },
+ Value::Blob(b) => b.get_size_for_bindings() as blob::SizeType,
Value::InternalBlob(b) => b.slice_const().len() as blob::SizeType,
Value::WTFStringImpl(s) => wtf_impl(s).utf8_byte_length() as blob::SizeType,
Value::Locked(l) => l.size_hint(),
@@ -745,7 +741,7 @@ impl Value {
}
}
- /// `None` for a file that does not exist or is not seekable.
+ /// `None` for a file that does not exist or is not seekable, and for an S3 object that is not a slice.
pub(crate) fn known_size(&mut self) -> Option {
match self.size() {
u64::MAX => None,
diff --git a/test/js/bun/s3/s3-local-endpoint.ts b/test/js/bun/s3/s3-local-endpoint.ts
deleted file mode 100644
index 138e58411b45..000000000000
--- a/test/js/bun/s3/s3-local-endpoint.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { S3Client } from "bun";
-
-/**
- * A local stand-in for an S3 endpoint that holds one object, under the key "key".
- * It records the Range header of each request. It answers a Range request like a
- * server that clamps the end of the range to the end of the object.
- */
-export function s3LocalEndpoint(object: string | Uint8Array) {
- const bytes = Buffer.from(object);
- const ranges: (string | null)[] = [];
- const server = Bun.serve({
- hostname: "127.0.0.1",
- port: 0,
- fetch(req) {
- const range = req.headers.get("range");
- ranges.push(range);
- const match = range && /^bytes=(\d+)-(\d*)$/.exec(range);
- if (!match) return new Response(bytes);
- const start = Number(match[1]);
- const end = match[2] === "" ? bytes.length - 1 : Math.min(Number(match[2]), bytes.length - 1);
- return new Response(bytes.subarray(start, end + 1), {
- status: 206,
- headers: { "Content-Range": `bytes ${start}-${end}/${bytes.length}` },
- });
- },
- });
- const client = new S3Client({ endpoint: server.url.href, accessKeyId: "x", secretAccessKey: "y", bucket: "b" });
- return {
- /** The Range header of each request so far. `null` is a request with no Range header. */
- ranges,
- file: (type?: string) => client.file("key", { type }),
- [Symbol.dispose]: () => void server.stop(true),
- };
-}
diff --git a/test/js/bun/s3/s3.test.ts b/test/js/bun/s3/s3.test.ts
index 7381cfe3a3f2..bb394b7301d2 100644
--- a/test/js/bun/s3/s3.test.ts
+++ b/test/js/bun/s3/s3.test.ts
@@ -5,7 +5,6 @@ import { createHash, createHmac, randomUUID } from "crypto";
import { bunEnv, bunExe, getSecret, isCI, tempDir, tempDirWithFiles } from "harness";
import path from "path";
import { spawnServer } from "s3-server";
-import { s3LocalEndpoint } from "./s3-local-endpoint";
const s3 = (...args: Parameters) => defaultS3.file(...args);
const S3 = (...args) => new S3Client(...args);
@@ -2171,93 +2170,3 @@ describe("presigned url signature", () => {
}
});
});
-
-// The size of an S3 object is not known before the download. Whatever asks the file
-// for its size must leave it unknown: a file whose size became 0 requests one byte
-// (`Range: bytes=0-0`) on the next read and returns an empty Blob from slice().
-describe("s3 file size stays unknown", () => {
- const payload = Buffer.alloc(100, "0123456789").toString();
-
- async function readsAfter(look: (file: Bun.S3File) => unknown) {
- using endpoint = s3LocalEndpoint(payload);
- const file = endpoint.file();
- await look(file);
- const requestsWhileLooking = endpoint.ranges.length;
- return {
- requestsWhileLooking,
- size: file.size,
- text: await file.text(),
- stream: await Bun.readableStreamToText(file.stream()),
- slice: await file.slice(10, 20).text(),
- ranges: endpoint.ranges,
- };
- }
- const untouched = {
- requestsWhileLooking: 0,
- size: NaN,
- text: payload,
- stream: payload,
- slice: payload.slice(10, 20),
- ranges: [null, null, "bytes=10-19"],
- };
-
- it("after expect().toHaveLength() and expect().toBeEmpty()", async () => {
- expect(
- await readsAfter(file => {
- expect(() => expect(file).toHaveLength(0)).toThrow("Received length");
- expect(() => expect(file).toBeEmpty()).toThrow("Expected value to be empty");
- }),
- ).toEqual(untouched);
- });
-
- it("after structuredClone()", async () => {
- expect(
- await readsAfter(file => {
- try {
- structuredClone(file);
- } catch {}
- }),
- ).toEqual(untouched);
- });
-
- it("for a FormData entry after the FormData was refused as a body", async () => {
- using endpoint = s3LocalEndpoint(payload);
- const form = new FormData();
- form.append("file", endpoint.file());
- expect(() => new Response(form)).toThrow(TypeError);
- const entry = form.get("file") as Blob;
- expect({ size: entry.size, text: await entry.text(), ranges: endpoint.ranges }).toEqual({
- size: NaN,
- text: payload,
- ranges: [null],
- });
- });
-
- it("a slice requests its own window", async () => {
- using endpoint = s3LocalEndpoint(payload);
- const file = endpoint.file();
- expect({
- "slice(10, 20).text()": await file.slice(10, 20).text(),
- "slice(0, 5).text()": await file.slice(0, 5).text(),
- "slice(95).text()": await file.slice(95).text(),
- "slice(10, 20).stream()": await Bun.readableStreamToText(file.slice(10, 20).stream()),
- "slice(0, 5).stream()": await Bun.readableStreamToText(file.slice(0, 5).stream()),
- sizes: [file.slice(10, 20).size, file.slice(0, 5).size],
- }).toEqual({
- "slice(10, 20).text()": payload.slice(10, 20),
- "slice(0, 5).text()": payload.slice(0, 5),
- "slice(95).text()": payload.slice(95),
- "slice(10, 20).stream()": payload.slice(10, 20),
- "slice(0, 5).stream()": payload.slice(0, 5),
- sizes: [10, 5],
- });
- // The end that an open-ended slice sends is not pinned here, only its start.
- expect(endpoint.ranges.map(range => range?.replace(/^(bytes=95-)\d+$/, "$1"))).toEqual([
- "bytes=10-19",
- "bytes=0-4",
- "bytes=95-",
- "bytes=10-19",
- "bytes=0-4",
- ]);
- });
-});
diff --git a/test/js/web/fetch/body.test.ts b/test/js/web/fetch/body.test.ts
index bb8e239cbc1f..f859a062efd4 100644
--- a/test/js/web/fetch/body.test.ts
+++ b/test/js/web/fetch/body.test.ts
@@ -6,7 +6,6 @@ import { join } from "node:path";
// @ts-expect-error missing in @types/node
import { isDisturbed, isErrored, isReadable, Readable } from "node:stream";
import { finished } from "node:stream/promises";
-import { s3LocalEndpoint } from "../../bun/s3/s3-local-endpoint";
const exampleServer = exampleSite("http");
@@ -2038,140 +2037,3 @@ describe("body stream bookkeeping does not depend on the body's source", () => {
expect(await text).toBe("payload");
});
});
-
-// The size of an S3 object is not known before the download. A body made from
-// one reads the whole object, or the whole slice, whatever looked at it first.
-describe("a body made from an S3 file", () => {
- const payload = Buffer.alloc(100, "0123456789").toString();
-
- const owners = [
- {
- name: "Request",
- make: async (blob: Blob) => new Request("http://example.com/", { method: "POST", body: blob }),
- },
- {
- // new Response(s3file) is a redirect. fetch() of a blob: URL gives a Response that holds the S3 blob.
- name: "Response",
- make: async (blob: Blob) => {
- const url = URL.createObjectURL(blob);
- try {
- return await fetch(url);
- } finally {
- URL.revokeObjectURL(url);
- }
- },
- },
- ];
-
- for (const { name, make } of owners) {
- describe(name, () => {
- test(".body and textStream() download the whole object", async () => {
- using endpoint = s3LocalEndpoint(payload);
- const body = await Bun.readableStreamToText((await make(endpoint.file())).body!);
- const textStream = (await Array.fromAsync((await make(endpoint.file())).textStream())).join("");
- expect({ body, textStream, ranges: endpoint.ranges }).toEqual({
- body: payload,
- textStream: payload,
- ranges: [null, null],
- });
- });
-
- test(".body of a slice downloads the whole slice", async () => {
- using endpoint = s3LocalEndpoint(payload);
- const body = await Bun.readableStreamToText((await make(endpoint.file().slice(10, 30))).body!);
- const textStream = (await Array.fromAsync((await make(endpoint.file().slice(10, 30))).textStream())).join("");
- expect({ body, textStream, ranges: endpoint.ranges }).toEqual({
- body: payload.slice(10, 30),
- textStream: payload.slice(10, 30),
- ranges: ["bytes=10-29", "bytes=10-29"],
- });
- });
-
- test("a clone and its original both download the whole object", async () => {
- using endpoint = s3LocalEndpoint(payload);
- const original = await make(endpoint.file());
- const clone = original.clone();
- expect({
- clone: await Bun.readableStreamToText(clone.body!),
- original: await Bun.readableStreamToText(original.body!),
- ranges: endpoint.ranges,
- }).toEqual({ clone: payload, original: payload, ranges: [null, null] });
- });
-
- test("Bun.inspect() does not change what the body reads", async () => {
- using endpoint = s3LocalEndpoint(payload);
- const inspected = async () => {
- const subject = await make(endpoint.file());
- // The size in the header is unknown, so it prints as 0.
- expect(Bun.inspect(subject)).toStartWith(`${name} (0 KB) {`);
- return subject;
- };
- const blob = await (await inspected()).blob();
- expect({
- body: await Bun.readableStreamToText((await inspected()).body!),
- text: await (await inspected()).text(),
- bytes: Buffer.from(await (await inspected()).bytes()).toString(),
- blob: [blob.size, await blob.text()],
- ranges: endpoint.ranges,
- }).toEqual({
- body: payload,
- text: payload,
- bytes: payload,
- blob: [NaN, payload],
- ranges: [null, null, null, null],
- });
- });
- });
- }
-
- test("new Request(request) downloads the whole object", async () => {
- using endpoint = s3LocalEndpoint(payload);
- const request = new Request(new Request("http://example.com/", { method: "POST", body: endpoint.file() }));
- expect({ body: await Bun.readableStreamToText(request.body!), ranges: endpoint.ranges }).toEqual({
- body: payload,
- ranges: [null],
- });
- });
-
- test("fetch(request) uploads the whole object after Bun.inspect(request)", async () => {
- using endpoint = s3LocalEndpoint(payload);
- const uploads: string[] = [];
- await using sink = Bun.serve({
- port: 0,
- async fetch(req) {
- uploads.push(await req.text());
- return new Response("ok");
- },
- });
- const request = new Request(sink.url, { method: "POST", body: endpoint.file() });
- Bun.inspect(request);
- expect(await (await fetch(request)).text()).toBe("ok");
- expect({ uploads, ranges: endpoint.ranges }).toEqual({ uploads: [payload], ranges: [null] });
- });
-
- test("HTMLRewriter.transform() reads the whole object", async () => {
- using endpoint = s3LocalEndpoint("" + payload + "
");
- const seen: string[] = [];
- const response = await owners[1].make(endpoint.file());
- const output = await new HTMLRewriter()
- .on("p", { text: chunk => void seen.push(chunk.text) })
- .transform(response as Response)
- .text();
- expect({ output, seen: seen.join(""), ranges: endpoint.ranges }).toEqual({
- output: "" + payload + "
",
- seen: payload,
- ranges: [null],
- });
- });
-
- test("WebAssembly.compileStreaming() reads the whole object", async () => {
- // The smallest module: the magic number and the version.
- using endpoint = s3LocalEndpoint(new Uint8Array([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]));
- const response = owners[1].make(endpoint.file("application/wasm")) as Promise;
- const module = await WebAssembly.compileStreaming(response);
- expect({ exports: WebAssembly.Module.exports(module), ranges: endpoint.ranges }).toEqual({
- exports: [],
- ranges: [null],
- });
- });
-});
diff --git a/test/js/web/html/FormData-multipart-serialization.test.ts b/test/js/web/html/FormData-multipart-serialization.test.ts
index 3f2f2613676c..4aa2325e4f85 100644
--- a/test/js/web/html/FormData-multipart-serialization.test.ts
+++ b/test/js/web/html/FormData-multipart-serialization.test.ts
@@ -1,6 +1,7 @@
-import { describe, expect, test } from "bun:test";
+import { randomUUIDv7, S3Client } from "bun";
+import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { bunEnv, bunExe, isLinux } from "harness";
-import { s3LocalEndpoint } from "../../bun/s3/s3-local-endpoint";
+import { serve, type S3Server } from "s3-server";
// Serializing a FormData body (`new Response(formData)` / `new Request(..., { body: formData })`)
// goes through Blob::from_dom_form_data, which joins the multipart parts out of
@@ -222,6 +223,28 @@ describe("multipart serialization of S3-backed entries", () => {
const object = "id,name\n1,bun\n";
const remedy = "new File([await s3file.bytes()], s3file.name, { type: s3file.type })";
+ let server: S3Server;
+ let client: S3Client;
+ beforeAll(() => {
+ server = serve({ buckets: ["bucket"] });
+ client = new S3Client(server.clientOptions("bucket"));
+ });
+ afterAll(() => server.stop());
+
+ /** An S3 file under a new key. Nothing is stored there: only one test downloads. */
+ function s3Object(type?: string) {
+ const key = `${randomUUIDv7()}.csv`;
+ return {
+ key,
+ file: () => client.file(key, { type }),
+ /** The operation and the Range header of each request for the key so far. */
+ requests: () =>
+ server.requests
+ .filter(request => request.key === key)
+ .map(request => [request.operation, request.headers.get("range")]),
+ };
+ }
+
// `quotedName` is the entry name as the message renders it, quotes included.
function messageFor(quotedName: string): string {
return (
@@ -261,7 +284,7 @@ describe("multipart serialization of S3-backed entries", () => {
describe.each(shapes)("%s", (_shape, makeEntry) => {
test.each(consumers)("%s fails and sends nothing", async (_consumer, consume) => {
- using endpoint = s3LocalEndpoint(object);
+ const stored = s3Object("text/csv");
let received = 0;
using receiver = Bun.serve({
hostname: "127.0.0.1",
@@ -271,14 +294,14 @@ describe("multipart serialization of S3-backed entries", () => {
return new Response("stored");
},
});
- const formData = formDataWith(makeEntry(endpoint.file("text/csv")));
+ const formData = formDataWith(makeEntry(stored.file()));
const sizeBefore = (formData.get("report") as Blob).size;
const error = await failureOf(() => consume(formData, receiver.url));
expect(error).toBeInstanceOf(TypeError);
expect({
message: (error as TypeError).message,
- s3Requests: endpoint.ranges,
+ s3Requests: stored.requests(),
received,
sizeAfter: (formData.get("report") as Blob).size,
}).toEqual({
@@ -304,8 +327,7 @@ describe("multipart serialization of S3-backed entries", () => {
["a name containing a quote", 'quote"name', '"quote\\"name"'],
["a name containing CRLF", "crlf\r\nname", '"crlf\\r\\nname"'],
])("the message quotes %s", async (_label, name, quotedName) => {
- using endpoint = s3LocalEndpoint(object);
- const formData = formDataWith(endpoint.file(), name);
+ const formData = formDataWith(s3Object().file(), name);
const error = await failureOf(() => new Response(formData));
expect((error as TypeError).message).toBe(messageFor(quotedName));
@@ -313,35 +335,47 @@ describe("multipart serialization of S3-backed entries", () => {
// The entries before the S3 file are ones the serializer can write.
test("the message names the first S3 file, after a Blob and an empty S3 slice", async () => {
- using endpoint = s3LocalEndpoint(object);
+ const stored = s3Object();
const formData = new FormData();
formData.append("blob", new Blob(["in memory"]), "blob.txt");
- formData.append("empty", endpoint.file().slice(4, 4), "empty.csv");
- formData.append("first", endpoint.file(), "first.csv");
- formData.append("second", endpoint.file(), "second.csv");
+ formData.append("empty", stored.file().slice(4, 4), "empty.csv");
+ formData.append("first", stored.file(), "first.csv");
+ formData.append("second", stored.file(), "second.csv");
const error = await failureOf(() => new Response(formData));
expect(error).toBeInstanceOf(TypeError);
- expect({ message: (error as TypeError).message, s3Requests: endpoint.ranges }).toEqual({
+ expect({ message: (error as TypeError).message, s3Requests: stored.requests() }).toEqual({
message: messageFor('"first"'),
s3Requests: [],
});
});
+ test("the message names the first of two entries that hold the same S3 file", async () => {
+ const s3file = s3Object().file();
+ const formData = new FormData();
+ formData.append("text", "value");
+ formData.append("one", s3file);
+ formData.append("two", s3file);
+
+ const error = await failureOf(() => new Response(formData));
+ expect(error).toBeInstanceOf(TypeError);
+ expect((error as TypeError).message).toBe(messageFor('"one"'));
+ });
+
// An empty window of an S3 object has no bytes to fetch.
test.each([
["slice(0, 0)", 0],
["slice(3, 3)", 3],
])("s3file.%s serializes as an empty part", async (_label, at) => {
- using endpoint = s3LocalEndpoint(object);
+ const stored = s3Object("text/csv");
const formData = new FormData();
- formData.append("report", endpoint.file("text/csv").slice(at, at), "empty.csv");
+ formData.append("report", stored.file().slice(at, at), "empty.csv");
formData.append("after", "last");
const response = new Response(formData);
const contentType = response.headers.get("Content-Type")!;
const boundary = contentType.slice(contentType.indexOf("boundary=") + "boundary=".length);
- expect({ body: await response.text(), s3Requests: endpoint.ranges }).toEqual({
+ expect({ body: await response.text(), s3Requests: stored.requests() }).toEqual({
body:
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="report"; filename="empty.csv"\r\n` +
@@ -353,18 +387,22 @@ describe("multipart serialization of S3-backed entries", () => {
});
});
- // structuredClone() used to leave the size of an S3 file at 0, which made
- // the file look like an empty window.
- test("an S3 file still fails after structuredClone() looked at it", async () => {
- using endpoint = s3LocalEndpoint(object);
- const s3file = endpoint.file("text/csv");
+ // Each of these calls used to leave the size of the S3 file at 0, which
+ // made the file look like an empty window.
+ test.each<[string, (s3file: Bun.S3File) => unknown]>([
+ ["structuredClone()", s3file => structuredClone(s3file)],
+ ["postMessage()", s3file => new MessageChannel().port1.postMessage(s3file)],
+ ["expect().toHaveLength()", s3file => expect(s3file).toHaveLength(0)],
+ ])("an S3 file still fails after %s asked for its size", async (_label, ask) => {
+ const stored = s3Object("text/csv");
+ const s3file = stored.file();
try {
- structuredClone(s3file);
+ ask(s3file);
} catch {}
const error = await failureOf(() => new Response(formDataWith(s3file)));
expect(error).toBeInstanceOf(TypeError);
- expect({ message: (error as TypeError).message, size: s3file.size, s3Requests: endpoint.ranges }).toEqual({
+ expect({ message: (error as TypeError).message, size: s3file.size, s3Requests: stored.requests() }).toEqual({
message: messageFor('"report"'),
size: NaN,
s3Requests: [],
@@ -373,8 +411,9 @@ describe("multipart serialization of S3-backed entries", () => {
// Runs the expression the message prints, character for character.
test("the File that the message says to pass uploads the bytes, the type and the filename", async () => {
- using endpoint = s3LocalEndpoint(object);
- const s3file = endpoint.file("text/csv");
+ const stored = s3Object("text/csv");
+ await client.write(stored.key, object);
+ const s3file = stored.file();
const error = await failureOf(() => new Response(formDataWith(s3file)));
const printed = (error as TypeError).message.slice((error as TypeError).message.indexOf("new File("));
expect(printed).toBe(remedy);
@@ -386,13 +425,16 @@ describe("multipart serialization of S3-backed entries", () => {
const response = new Response(formData);
const contentType = response.headers.get("Content-Type")!;
const boundary = contentType.slice(contentType.indexOf("boundary=") + "boundary=".length);
- expect({ body: await response.text(), s3Requests: endpoint.ranges }).toEqual({
+ expect({ body: await response.text(), s3Requests: stored.requests() }).toEqual({
body:
`--${boundary}\r\n` +
- `Content-Disposition: form-data; name="report"; filename="key"\r\n` +
+ `Content-Disposition: form-data; name="report"; filename="${stored.key}"\r\n` +
`Content-Type: text/csv\r\n\r\n${object}\r\n` +
`--${boundary}--\r\n`,
- s3Requests: [null],
+ s3Requests: [
+ ["PutObject", null],
+ ["GetObject", null],
+ ],
});
});
});
From f62476dd24221a7b0fddf2fb80cd3133c2b275ab Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Sat, 24 Oct 2026 04:20:03 +0000
Subject: [PATCH 18/20] docs: an empty S3 slice stays a FormData part, and a
snippet that type-checks
S3File.name is declared optional, so the old snippet failed with TS2345
under strict.
---
docs/runtime/s3.mdx | 4 +++-
packages/bun-types/s3.d.ts | 5 +++--
2 files changed, 6 insertions(+), 3 deletions(-)
diff --git a/docs/runtime/s3.mdx b/docs/runtime/s3.mdx
index 78237bd96fed..62402a480229 100644
--- a/docs/runtime/s3.mdx
+++ b/docs/runtime/s3.mdx
@@ -607,11 +607,13 @@ A `FormData` body is the exception. Bun builds that body at once, and it downloa
```ts s3.ts icon="/icons/typescript.svg"
const formData = new FormData();
-formData.append("report", new File([await s3file.bytes()], s3file.name, { type: s3file.type }));
+formData.append("file", new File([await s3file.bytes()], "123.json", { type: s3file.type }));
await fetch("https://example.com/upload", { method: "POST", body: formData });
```
+An empty slice, such as `s3file.slice(0, 0)`, has no bytes to download. Bun sends that entry as an empty part.
+
### Partial reads with `slice`
To read a partial range of a file, use the `slice` method.
diff --git a/packages/bun-types/s3.d.ts b/packages/bun-types/s3.d.ts
index 1da84694c14e..33e0f1aabd50 100644
--- a/packages/bun-types/s3.d.ts
+++ b/packages/bun-types/s3.d.ts
@@ -475,12 +475,13 @@ declare module "bun" {
*
* Bun downloads the object only when you read it. A `FormData` body is
* built at once, so a `FormData` body with an `S3File` entry throws a
- * `TypeError`. Read the bytes first and append a `File`.
+ * `TypeError`. Read the bytes first and append a `File`. An empty slice
+ * (`file.slice(0, 0)`) has no bytes to download and stays an empty part.
*
* @example
* ```ts
* const formData = new FormData();
- * formData.append("report", new File([await file.bytes()], file.name, { type: file.type }));
+ * formData.append("report", new File([await file.bytes()], "report.csv", { type: file.type }));
* ```
*
* @category Cloud Storage
From 7e0878ffdecee6bb008681db802a7eda33c121d9 Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Sat, 24 Oct 2026 05:33:12 +0000
Subject: [PATCH 19/20] FormData S3 entry: take the FormData by reference,
tighten the tests and the docs
- throw_for_s3_entry takes &mut DOMFormData, like its caller.
- The tests send one request to each server before they read its log, so
a request that was on its way is seen. New rows: an S3 file next to a
missing file, in both orders. The size rows check the size directly.
- The docs name who throws, and keep the sentence about Response as it was.
---
docs/runtime/s3.mdx | 4 +-
packages/bun-types/s3.d.ts | 16 +--
src/runtime/webcore/Blob.rs | 5 +-
.../FormData-multipart-serialization.test.ts | 106 ++++++++++--------
4 files changed, 71 insertions(+), 60 deletions(-)
diff --git a/docs/runtime/s3.mdx b/docs/runtime/s3.mdx
index 62402a480229..ec79a1cdb3cd 100644
--- a/docs/runtime/s3.mdx
+++ b/docs/runtime/s3.mdx
@@ -601,9 +601,9 @@ Like `Bun.file()`, `S3File` extends [`Blob`](https://developer.mozilla.org/en-US
| `s3File.stream()` | `ReadableStream` |
| `await s3File.arrayBuffer()` | `ArrayBuffer` |
-Because `S3File` extends `Blob`, you can pass an `S3File` to `fetch()`, `Response`, and other web APIs that read a `Blob` asynchronously.
+Because `S3File` extends `Blob`, `S3File` instances work with `fetch()`, `Response`, and other web APIs that accept `Blob` instances.
-A `FormData` body is the exception. Bun builds that body at once, and it downloads an S3 object only when you read it. A `FormData` body with an `S3File` entry throws a `TypeError`. Read the bytes first and append a `File`:
+A `FormData` body is the exception. Bun builds a `FormData` body synchronously, and Bun can only download an S3 object asynchronously. When an entry is an S3 file, `new Response(formData)` and `new Request()` throw a `TypeError`, and `fetch()` rejects with one. To send the object, read its bytes first and append a `File`:
```ts s3.ts icon="/icons/typescript.svg"
const formData = new FormData();
diff --git a/packages/bun-types/s3.d.ts b/packages/bun-types/s3.d.ts
index 33e0f1aabd50..a44dc26d288a 100644
--- a/packages/bun-types/s3.d.ts
+++ b/packages/bun-types/s3.d.ts
@@ -473,16 +473,12 @@ declare module "bun" {
* Represents a file in an S3-compatible storage service.
* Extends the Blob interface for compatibility with web APIs.
*
- * Bun downloads the object only when you read it. A `FormData` body is
- * built at once, so a `FormData` body with an `S3File` entry throws a
- * `TypeError`. Read the bytes first and append a `File`. An empty slice
- * (`file.slice(0, 0)`) has no bytes to download and stays an empty part.
- *
- * @example
- * ```ts
- * const formData = new FormData();
- * formData.append("report", new File([await file.bytes()], "report.csv", { type: file.type }));
- * ```
+ * Bun downloads the object only when you read it. Bun builds a `FormData`
+ * body synchronously. When an entry is an `S3File`, `new Response(formData)`
+ * and `new Request()` throw a `TypeError`, and `fetch()` rejects with one.
+ * Read the bytes first and append a `File`:
+ * `new File([await file.bytes()], "report.csv", { type: file.type })`.
+ * Bun sends an empty slice (`file.slice(0, 0)`) as an empty part.
*
* @category Cloud Storage
*/
diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs
index ffaba5930346..ef9b865aed24 100644
--- a/src/runtime/webcore/Blob.rs
+++ b/src/runtime/webcore/Blob.rs
@@ -872,7 +872,7 @@ impl BlobExt for Blob {
#[cold]
fn throw_for_s3_entry(
global_this: &JSGlobalObject,
- form_data: *mut jsc::DOMFormData,
+ form_data: &mut jsc::DOMFormData,
entry: *const Blob,
) {
struct Find {
@@ -898,6 +898,7 @@ impl BlobExt for Blob {
}
let mut find = Find { entry, name: None };
DOMFormData__forEach(form_data, (&raw mut find).cast::(), find_thunk);
+ debug_assert!(find.name.is_some(), "the first walk met this entry");
let _ = global_this.throw_type_error(format_args!(
"FormData entry {} is an S3 file, which cannot be read while the body is serialized. Pass its bytes instead: new File([await s3file.bytes()], s3file.name, {{ type: s3file.type }})",
bun_core::fmt::quote(find.name.as_deref().unwrap_or_default()),
@@ -3499,7 +3500,7 @@ struct FormDataContext<'a> {
enum FormDataFailure {
/// `on_entry` threw.
Thrown,
- /// The entry with this Blob is an S3 object. `from_dom_form_data` throws for it after the walk.
+ /// An S3 entry, known by the address of its Blob (compared, never read). `from_dom_form_data` throws for it after the walk.
S3Entry(*const Blob),
}
diff --git a/test/js/web/html/FormData-multipart-serialization.test.ts b/test/js/web/html/FormData-multipart-serialization.test.ts
index 4aa2325e4f85..227be3a37c30 100644
--- a/test/js/web/html/FormData-multipart-serialization.test.ts
+++ b/test/js/web/html/FormData-multipart-serialization.test.ts
@@ -1,6 +1,7 @@
import { randomUUIDv7, S3Client } from "bun";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
-import { bunEnv, bunExe, isLinux } from "harness";
+import { bunEnv, bunExe, isLinux, tempDir } from "harness";
+import { join } from "node:path";
import { serve, type S3Server } from "s3-server";
// Serializing a FormData body (`new Response(formData)` / `new Request(..., { body: formData })`)
@@ -237,13 +238,19 @@ describe("multipart serialization of S3-backed entries", () => {
return {
key,
file: () => client.file(key, { type }),
- /** The operation and the Range header of each request for the key so far. */
- requests: () =>
- server.requests
+ /**
+ * The operation and the Range header of each request for the key.
+ * A HEAD request goes first, so a request that is on its way is in the list before it.
+ */
+ async requests() {
+ await client.file(key).exists();
+ return server.requests
.filter(request => request.key === key)
- .map(request => [request.operation, request.headers.get("range")]),
+ .map(request => [request.operation, request.headers.get("range")]);
+ },
};
}
+ const noRequest = [["HeadObject", null]];
// `quotedName` is the entry name as the message renders it, quotes included.
function messageFor(quotedName: string): string {
@@ -298,16 +305,18 @@ describe("multipart serialization of S3-backed entries", () => {
const sizeBefore = (formData.get("report") as Blob).size;
const error = await failureOf(() => consume(formData, receiver.url));
+ // A request from `consume` would reach the receiver before this one.
+ await fetch(receiver.url);
expect(error).toBeInstanceOf(TypeError);
expect({
message: (error as TypeError).message,
- s3Requests: stored.requests(),
+ s3Requests: await stored.requests(),
received,
sizeAfter: (formData.get("report") as Blob).size,
}).toEqual({
message: messageFor('"report"'),
- s3Requests: [],
- received: 0,
+ s3Requests: noRequest,
+ received: 1,
sizeAfter: sizeBefore,
});
});
@@ -322,7 +331,6 @@ describe("multipart serialization of S3-backed entries", () => {
});
test.each([
- ["a plain name", "report", '"report"'],
["a non-ASCII name", "レポート ☺", '"レポート ☺"'],
["a name containing a quote", 'quote"name', '"quote\\"name"'],
["a name containing CRLF", "crlf\r\nname", '"crlf\\r\\nname"'],
@@ -344,22 +352,30 @@ describe("multipart serialization of S3-backed entries", () => {
const error = await failureOf(() => new Response(formData));
expect(error).toBeInstanceOf(TypeError);
- expect({ message: (error as TypeError).message, s3Requests: stored.requests() }).toEqual({
- message: messageFor('"first"'),
- s3Requests: [],
- });
+ expect((error as TypeError).message).toBe(messageFor('"first"'));
});
- test("the message names the first of two entries that hold the same S3 file", async () => {
- const s3file = s3Object().file();
+ // The serializer stops at the first entry that it cannot write.
+ test.each([
+ ["an S3 file and then a missing file", true],
+ ["a missing file and then an S3 file", false],
+ ])("%s: the first one decides the error", async (_label, s3First) => {
+ using dir = tempDir("formdata-s3-entry", {});
+ const s3Entry: [string, Blob] = ["s3", s3Object().file()];
+ const diskEntry: [string, Blob] = ["disk", Bun.file(join(String(dir), "missing.csv"))];
const formData = new FormData();
- formData.append("text", "value");
- formData.append("one", s3file);
- formData.append("two", s3file);
-
- const error = await failureOf(() => new Response(formData));
- expect(error).toBeInstanceOf(TypeError);
- expect((error as TypeError).message).toBe(messageFor('"one"'));
+ for (const [name, blob] of s3First ? [s3Entry, diskEntry] : [diskEntry, s3Entry]) formData.append(name, blob);
+
+ const error = (await failureOf(() => new Response(formData))) as Error & { code?: string };
+ expect({
+ type: error.constructor.name,
+ code: error.code,
+ namesS3Entry: error.message === messageFor('"s3"'),
+ }).toEqual(
+ s3First
+ ? { type: "TypeError", code: undefined, namesS3Entry: true }
+ : { type: "Error", code: "ENOENT", namesS3Entry: false },
+ );
});
// An empty window of an S3 object has no bytes to fetch.
@@ -375,7 +391,7 @@ describe("multipart serialization of S3-backed entries", () => {
const response = new Response(formData);
const contentType = response.headers.get("Content-Type")!;
const boundary = contentType.slice(contentType.indexOf("boundary=") + "boundary=".length);
- expect({ body: await response.text(), s3Requests: stored.requests() }).toEqual({
+ expect({ body: await response.text(), s3Requests: await stored.requests() }).toEqual({
body:
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="report"; filename="empty.csv"\r\n` +
@@ -383,34 +399,35 @@ describe("multipart serialization of S3-backed entries", () => {
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="after"\r\n\r\nlast\r\n` +
`--${boundary}--\r\n`,
- s3Requests: [],
+ s3Requests: noRequest,
});
});
- // Each of these calls used to leave the size of the S3 file at 0, which
- // made the file look like an empty window.
- test.each<[string, (s3file: Bun.S3File) => unknown]>([
- ["structuredClone()", s3file => structuredClone(s3file)],
- ["postMessage()", s3file => new MessageChannel().port1.postMessage(s3file)],
- ["expect().toHaveLength()", s3file => expect(s3file).toHaveLength(0)],
+ // Each of these calls asks the S3 file for its size. The answer used to be
+ // stored as 0, which made the file look like an empty window.
+ test.each<[string, (s3file: Bun.S3File) => void]>([
+ [
+ "structuredClone()",
+ s3file => {
+ // It throws for an S3 file. Only its size query matters here.
+ try {
+ structuredClone(s3file);
+ } catch {}
+ },
+ ],
+ ["expect().toHaveLength()", s3file => expect(s3file).not.toHaveLength(0)],
])("an S3 file still fails after %s asked for its size", async (_label, ask) => {
- const stored = s3Object("text/csv");
- const s3file = stored.file();
- try {
- ask(s3file);
- } catch {}
+ const s3file = s3Object("text/csv").file();
+ ask(s3file);
+ expect(s3file.size).toBeNaN();
const error = await failureOf(() => new Response(formDataWith(s3file)));
expect(error).toBeInstanceOf(TypeError);
- expect({ message: (error as TypeError).message, size: s3file.size, s3Requests: stored.requests() }).toEqual({
- message: messageFor('"report"'),
- size: NaN,
- s3Requests: [],
- });
+ expect((error as TypeError).message).toBe(messageFor('"report"'));
});
// Runs the expression the message prints, character for character.
- test("the File that the message says to pass uploads the bytes, the type and the filename", async () => {
+ test("the File that the message says to pass has the bytes, the type and the filename", async () => {
const stored = s3Object("text/csv");
await client.write(stored.key, object);
const s3file = stored.file();
@@ -425,16 +442,13 @@ describe("multipart serialization of S3-backed entries", () => {
const response = new Response(formData);
const contentType = response.headers.get("Content-Type")!;
const boundary = contentType.slice(contentType.indexOf("boundary=") + "boundary=".length);
- expect({ body: await response.text(), s3Requests: stored.requests() }).toEqual({
+ expect({ body: await response.text(), s3Requests: await stored.requests() }).toEqual({
body:
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="report"; filename="${stored.key}"\r\n` +
`Content-Type: text/csv\r\n\r\n${object}\r\n` +
`--${boundary}--\r\n`,
- s3Requests: [
- ["PutObject", null],
- ["GetObject", null],
- ],
+ s3Requests: [["PutObject", null], ["GetObject", null], ...noRequest],
});
});
});
From 56d0ece9d7efd357a1ccea0422b099af11d6d556 Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Sat, 24 Oct 2026 08:08:19 +0000
Subject: [PATCH 20/20] ci: retrigger