Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ad56e0f
FormData: throw when serializing an S3-backed entry instead of sendin…
robobun Aug 14, 2026
a3c33a1
Quote the entry name in the error and cover new File([s3file]) entries
robobun Aug 14, 2026
82ad489
Trim comments in the S3 FormData arm
robobun Aug 14, 2026
f110f42
blob: resolve_size leaves an S3 blob's size and slice window untouched
robobun Sep 24, 2026
d631127
Body: print the unknown size of an S3 body as 0 in the inspect header
robobun Sep 24, 2026
1394f42
test: share the local S3 endpoint, assert the exact result
robobun Sep 24, 2026
e0df286
blob: put the comment on the S3 arm of resolve_size on one line
robobun Sep 25, 2026
af22f60
Merge remote-tracking branch 'origin/main' into farm/33100eed/formdat…
robobun Oct 10, 2026
6f331de
Read the entry name through EncodedSlice::to_utf8 after the merge wit…
robobun Oct 10, 2026
51e64d6
s3: leave the size of an S3 blob unknown in resolve_size
robobun Oct 23, 2026
739680a
Merge remote-tracking branch 'origin/main' into farm/33100eed/formdat…
robobun Oct 24, 2026
413bc0f
Merge the resolve_size change of #43934 (an S3 size stays unknown)
robobun Oct 24, 2026
c908e63
ci: retrigger
robobun Oct 24, 2026
efc5a5a
FormData: keep an empty S3 slice an empty part, throw after the walk
robobun Oct 24, 2026
a1b98cf
test: move the S3 header and HEAD tests into body.test.ts
robobun Oct 24, 2026
5f06dcb
test: compare the HEAD and GET lengths with the bytes that GET sends
robobun Oct 24, 2026
987b11d
FormData: throw for the S3 entry after the second walk; docs and tests
robobun Oct 24, 2026
08b437f
Shorten three comments in the FormData serializer
robobun Oct 24, 2026
2a092e3
FormData: keep one failure state, and find the S3 entry by its Blob
robobun Oct 24, 2026
fe2e1dd
Carry only the resolve_size hunks, as #46653 has them
robobun Oct 24, 2026
f62476d
docs: an empty S3 slice stays a FormData part, and a snippet that typ…
robobun Oct 24, 2026
7e0878f
FormData S3 entry: take the FormData by reference, tighten the tests …
robobun Oct 24, 2026
d75d336
Merge the resolve_size change of #46653 with its tests
robobun Oct 24, 2026
56d0ece
ci: retrigger
robobun Oct 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/runtime/s3.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,17 @@ Like `Bun.file()`, `S3File` extends [`Blob`](https://developer.mozilla.org/en-US

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 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();
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.
Expand Down
7 changes: 7 additions & 0 deletions packages/bun-types/s3.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,13 @@ 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. 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
*/
interface S3File extends Blob {
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/server/RequestContext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
73 changes: 63 additions & 10 deletions src/runtime/webcore/Blob.rs
Original file line number Diff line number Diff line change
Expand Up @@ -802,7 +802,7 @@ impl BlobExt for Blob {
let mut context = FormDataContext {
joiner: bun_core::string_joiner::StringJoiner::default(),
boundary,
failed: false,
failure: None,
global_this,
};
// Size the node list up front: a file entry pushes at most 13 slices, a
Expand Down Expand Up @@ -849,7 +849,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(
Expand All @@ -868,14 +868,54 @@ 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,
entry: *const Blob,
) {
struct Find {
entry: *const Blob,
name: Option<Vec<u8>>,
}
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 `&mut Find` passed below.
let find = unsafe { bun_ptr::callback_ctx::<Find>(ctx_ptr) };
if is_blob != 0
&& find.name.is_none()
&& core::ptr::eq(value_ptr.cast::<Blob>(), find.entry)
{
// SAFETY: `name` is always a valid non-null `*EncodedSlice` for this callback.
find.name = Some(unsafe { *name }.to_utf8().into_vec());
}
}
let mut find = Find { entry, name: None };
DOMFormData__forEach(form_data, (&raw mut find).cast::<c_void>(), 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()),
));
}

// C++ invokes the callback synchronously and does not retain `ctx`/`cb`
// past this call.
DOMFormData__forEach(
form_data,
(&raw mut context).cast::<c_void>(),
for_each_thunk,
);
if context.failed {
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
// conversions, NodeFS read_file result buffers — is freed.
Expand Down Expand Up @@ -2063,7 +2103,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 => {}
Comment thread
robobun marked this conversation as resolved.
Resolved
Comment thread
robobun marked this conversation as resolved.
Resolved
}
}

Expand Down Expand Up @@ -2106,7 +2147,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(
Expand Down Expand Up @@ -3450,10 +3491,19 @@ where
struct FormDataContext<'a> {
joiner: StringJoiner<'a>,
boundary: &'a [u8], // borrowed; outlives the joiner
failed: bool,
failure: Option<FormDataFailure>,
global_this: &'a JSGlobalObject,
}

/// Why `FormDataContext::on_entry` stopped serializing.
#[derive(Clone, Copy)]
enum FormDataFailure {
/// `on_entry` threw.
Thrown,
/// 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),
}

/// Which piece of a `multipart/form-data` entry a string is, selecting the
/// transforms the WHATWG "multipart/form-data encoding algorithm" applies to
/// it: <https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#multipart-form-data>
Expand Down Expand Up @@ -3541,7 +3591,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
Expand Down Expand Up @@ -3594,8 +3644,11 @@ impl FormDataContext<'_> {
.expect("infallible: store present");
match &store.data {
store::Data::S3(_) => {
// TODO: s3
// we need to make this async and use download/downloadSlice
// An empty window (`slice(n, n)`) is an empty part.
if blob.size.get() != 0 {
self.failure =
Some(FormDataFailure::S3Entry(core::ptr::from_ref(blob)));
}
}
store::Data::File(file) => {
// TODO: make this async + lazy
Expand All @@ -3612,7 +3665,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);
}
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/webcore/Body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize> {
match self.size() {
u64::MAX => None,
Expand Down
121 changes: 119 additions & 2 deletions test/js/bun/s3/s3.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof defaultS3.file>) => defaultS3.file(...args);
const S3 = (...args) => new S3Client(...args);

Expand Down Expand Up @@ -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<string, (file: S3File) => 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);
});
});
Loading
Loading