Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
26 changes: 14 additions & 12 deletions docs/runtime/http/routing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -213,22 +213,24 @@ Bun.serve({
system call when possible, enabling zero-copy file transfers in the kernel—the fastest way to send files.
</Info>

To send part of a file, use the [`slice(start, end)`](https://developer.mozilla.org/en-US/docs/Web/API/Blob/slice) method on the `Bun.file` object. Bun sets the `Content-Range` and `Content-Length` headers on the `Response` object automatically.
To send part of a file, use the [`slice(start, end)`](https://developer.mozilla.org/en-US/docs/Web/API/Blob/slice) method on the `Bun.file` object. The slice is the response body, so Bun sets `Content-Length` to the slice's length.

```ts
Bun.serve({
fetch(req) {
// parse `Range` header
const [start = 0, end = Infinity] = req.headers
.get("Range") // Range: bytes=0-100
.split("=") // ["Range: bytes", "0-100"]
.at(-1) // "0-100"
.split("-") // ["0", "100"]
.map(Number); // [0, 100]

// return a slice of the file
const bigFile = Bun.file("./big-video.mp4");
return new Response(bigFile.slice(start, end));
// send the first megabyte of a file
return new Response(Bun.file("./big-video.mp4").slice(0, 1024 * 1024));
},
});
```

To support HTTP [`Range` requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests), return the whole file. Bun resolves the client's `Range` header against it and responds with `206 Partial Content` and the matching `Content-Range` header automatically.

```ts
Bun.serve({
fetch(req) {
// `Range: bytes=0-100` → `206` with `Content-Range: bytes 0-100/<size>`
return new Response(Bun.file("./big-video.mp4"));
},
});
```
Expand Down
97 changes: 45 additions & 52 deletions src/runtime/server/RequestContext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1764,30 +1764,18 @@ where
AnyBlob::Blob(b) => b.size.get(),
_ => unreachable!(),
};
let stat_size: BlobSizeType = BlobSizeType::try_from(stat.st_size.max(0)).unwrap();
if let AnyBlob::Blob(b) = &mut self.blob {
b.size.set(if is_regular {
stat_size
} else {
original_size.min(stat_size)
});
}

self.flags.set_needs_content_length(true);
let blob_offset = match &self.blob {
AnyBlob::Blob(b) => b.offset.get(),
_ => unreachable!(),
};
let stat_size: BlobSizeType = BlobSizeType::try_from(stat.st_size.max(0)).unwrap();

self.flags.set_needs_content_length(true);
self.sendfile = SendfileContext {
remain: blob_offset + original_size,
offset: blob_offset,
total: 0,
};
if is_regular && auto_close {
self.flags.set_needs_content_range(
self.sendfile.remain.saturating_sub(self.sendfile.offset) != stat_size,
);
}
if is_regular {
self.sendfile.offset = self.sendfile.offset.min(stat_size);
self.sendfile.remain = self
Expand All @@ -1797,17 +1785,31 @@ where
.min(stat_size)
.saturating_sub(self.sendfile.offset);
}
// Resolve the blob's size now that the file is stat'd. For a regular
// file the clamped `sendfile.remain` is exactly the byte count we will
// send, which is the HTTP entity length. In particular a `.slice()`
// keeps its own length rather than inheriting the whole file's, so
// `render_metadata` frames it as a plain 200 instead of inventing a
// 206 + Content-Range the client never asked for.
if let AnyBlob::Blob(b) = &mut self.blob {
b.size.set(if is_regular {
self.sendfile.remain
} else {
original_size.min(stat_size)
});
}

// Honor an incoming Range: header for whole-file responses. We
// don't compose Range with a user-supplied .slice() because the
// Content-Range arithmetic gets ambiguous; the slice path keeps
// its existing slice-as-range behavior. `offset == 0` alone is
// insufficient — `Bun.file(p).slice(0, n)` has offset 0 — so we
// also check the size: an unsliced blob has either the unset-size
// sentinel or, if JS already read `.size`, the stat'd size; a
// `.slice(0, n)` blob has `n < stat_size`. Skip if the user
// already set Content-Range or a non-200 status — they're
// managing partial responses themselves.
// Content-Range arithmetic gets ambiguous; a sliced body ignores
// the Range header and is served as a plain 200 whose entity is
// the slice. `offset == 0` alone is insufficient —
// `Bun.file(p).slice(0, n)` has offset 0 — so we also check the
// size: an unsliced blob has either the unset-size sentinel or,
// if JS already read `.size`, the stat'd size; a `.slice(0, n)`
// blob has `n < stat_size`. Skip if the user already set
// Content-Range or a non-200 status — they're managing partial
// responses themselves.
let user_handles_range = if let Some(r) = self.response_weakref.get() {
r.status_code() != 200
|| r.get_init_headers_mut()
Expand Down Expand Up @@ -3486,15 +3488,22 @@ where
// an async hop keep the Response rooted via response_protected.
let response: &mut Response = self.response_weakref.get().unwrap();
let mut status = response.status_code();
let mut needs_content_range = self.flags.needs_content_range()
&& (self.sendfile.total > 0 || self.sendfile.remain < self.blob.size());
// Set only when `do_sendfile` resolved an incoming `Range:` header to
// a satisfiable range. A `.slice()` body is never a partial response:
// the slice is the whole entity, and `do_sendfile` resolves the blob
// size to the slice's length so Content-Length describes it directly.
let needs_content_range = self.flags.needs_content_range();

let size = if needs_content_range {
self.sendfile.remain
} else {
self.blob.size()
};

if needs_content_range {
status = 206;
}

let (content_type, needs_content_type, content_type_needs_free) =
get_content_type(response.get_init_headers_mut(), &self.blob);
// NOTE: `MimeType` owns a `Cow<'static, [u8]>`; Drop handles the owned case.
Expand All @@ -3503,32 +3512,20 @@ where
// Drop of `content_type` (moved into closure capture below would
// change borrow lifetimes); rely on natural end-of-scope drop.
});
// Take the headers out before `do_write_status` borrows `self` so the
// `response` reference (which also borrows `self`) is no longer live.
// The status line must still hit the wire before any header.
let headers = response.swap_init_headers();
self.do_write_status(status);
let mut has_content_disposition = false;
let mut has_content_range = false;
if let Some(mut headers_) = response.swap_init_headers() {
if let Some(mut headers_) = headers {
has_content_disposition = headers_.fast_has(jsc::HTTPHeaderName::ContentDisposition);
has_content_range = headers_.fast_has(jsc::HTTPHeaderName::ContentRange);
// For .slice()-driven ranges, only promote to 206 if the user
// also set Content-Range (preserves the old contract). For an
// incoming Range: header (sendfile.total > 0) we always 206.
needs_content_range =
needs_content_range && (self.sendfile.total > 0 || has_content_range);
if needs_content_range {
status = 206;
}

self.do_write_status(status);
self.do_write_headers(&mut headers_);
// `HeadersRef` is RAII — its Drop
// already calls `WebCore__FetchHeaders__deref`, so an explicit
// `.deref()` here would resolve (via DerefMut) to the inherent
// `FetchHeaders::deref` and double-free the C++ object.
drop(headers_);
} else if needs_content_range {
status = 206;
self.do_write_status(status);
} else {
self.do_write_status(status);
}

if let Some(mut cookies) = self.cookies.take() {
Expand Down Expand Up @@ -3604,25 +3601,21 @@ where
self.flags.set_needs_content_length(false);
}

if needs_content_range && !has_content_range {
if needs_content_range {
let mut crbuf = [0u8; RangeRequest::CONTENT_RANGE_BUF];
let end = self.sendfile.offset + self.sendfile.remain.saturating_sub(1);
// `total > 0` ⇒ we resolved an incoming Range header against the
// stat'd size, so the full size is meaningful. Otherwise this is a
// `.slice()`-driven range — omit the full size (it can change
// between requests and may leak PII).
// `sendfile.total` is the stat'd size the incoming Range header
// was resolved against, so the full size is always meaningful.
let header_value = RangeRequest::format_content_range(
&mut crbuf,
RangeRequest::Result::Satisfiable {
start: self.sendfile.offset,
end,
},
(self.sendfile.total > 0).then_some(self.sendfile.total),
Some(self.sendfile.total),
);
resp.write_header(b"content-range", header_value);
if self.sendfile.total > 0 {
resp.write_header(b"accept-ranges", b"bytes");
}
resp.write_header(b"accept-ranges", b"bytes");
self.flags.set_needs_content_range(false);
}
}
Expand Down
97 changes: 62 additions & 35 deletions src/runtime/webcore/Blob.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,11 @@ pub type Ref = bun_ptr::ExternalShared<Blob>;
/// 2: Added byte for whether it's a dom file, length and bytes for `stored_name`,
/// and f64 for `last_modified`.
/// 3: Added File name serialization for File objects (when is_jsdom_file is true)
const SERIALIZATION_VERSION: u8 = 3;
/// 4: Added the blob's `size` (u64). `offset` alone cannot reconstruct a
/// file-backed `.slice(start, end)`: without the length the clone widens
/// to the rest of the file. `MAX_SIZE` on the wire means "unresolved": the
/// receiver resolves it lazily against its own path / fd.
const SERIALIZATION_VERSION: u8 = 4;

pub use bun_jsc::generated::JSBlob as js;

Expand Down Expand Up @@ -726,6 +730,15 @@ impl BlobExt for Blob {
} else {
false
};
// Version 4: the blob's `size`, written at the very end. Capture it
// before the `resolve_size()` below mutates it: a slice's concrete
// length must survive the round-trip (`offset` alone widens the clone
// to the rest of the file), but an unresolved file blob must keep the
// `MAX_SIZE` unknown-size sentinel so the receiving side resolves it
// lazily against its own path / fd. Resolving here and pinning the
// result would serialize e.g. the 0 that `resolve_size()` reports for
// an fd that does not stat in *this* process.
let size = self.size.get();

writer.write_int_le::<u8>(SERIALIZATION_VERSION)?;
writer.write_int_le::<u64>(if is_memory_backed {
Expand Down Expand Up @@ -780,6 +793,8 @@ impl BlobExt for Blob {
writer.write_int_le::<u32>(0)?;
}
}

writer.write_int_le::<u64>(size)?;
Ok(())
}

Expand Down Expand Up @@ -2358,20 +2373,12 @@ impl BlobExt for Blob {
// the raw-ptr deref so each read here is a fresh, safe borrow.
match store.data_mut().tag() {
store::DataTag::Bytes => {
let offset = self.offset.get();
let store_size = store.size();
if store_size != MAX_SIZE {
self.offset.set(store_size.min(offset));
let available = store_size - self.offset.get();
// Only resolve an unknown size. A slice already has a concrete
// `size`; overwriting it with `store_size - offset` would widen
// the view to the end of the backing store. Clamp a known size
// to `available` so a bogus size can't report past the store end.
if self.size.get() == MAX_SIZE {
self.size.set(available);
} else {
self.size.set(self.size.get().min(available));
}
let (offset, size) =
clamp_view_to_store(self.offset.get(), self.size.get(), store_size);
self.offset.set(offset);
self.size.set(size);
}
}
store::DataTag::File => {
Expand All @@ -2382,10 +2389,10 @@ impl BlobExt for Blob {
let file = store.data_mut().as_file();

if file.seekable.is_some() && file.max_size != MAX_SIZE {
let store_size = file.max_size;
let offset = self.offset.get();
self.offset.set(store_size.min(offset));
self.size.set(store_size.saturating_sub(offset));
let (offset, size) =
clamp_view_to_store(self.offset.get(), self.size.get(), file.max_size);
self.offset.set(offset);
self.size.set(size);
return;
}

Expand Down Expand Up @@ -2414,21 +2421,9 @@ impl BlobExt for Blob {
// `Deref`-produced `&Data`/`&File` is live across the mutating call.
match store.data_mut().tag() {
store::DataTag::Bytes => {
let offset = self.offset.get();
let store_size = store.size();
if store_size != MAX_SIZE {
let offset = store_size.min(offset);
let available = store_size - offset;
// Matches `resolve_size`: a known size (e.g. a slice) is
// authoritative; only an unknown size falls back to the
// remainder of the backing store. Clamp to `available` so a
// bogus size can't report past the store end.
let size = if self.size.get() == MAX_SIZE {
available
} else {
self.size.get().min(available)
};
return (offset, size);
return clamp_view_to_store(self.offset.get(), self.size.get(), store_size);
}
(self.offset.get(), self.size.get())
}
Expand All @@ -2439,9 +2434,7 @@ impl BlobExt for Blob {
// Fresh borrow after possible mutation by `resolve_file_stat`.
let file = store.data_mut().as_file();
if file.seekable.is_some() && file.max_size != MAX_SIZE {
let store_size = file.max_size;
let offset = self.offset.get();
return (store_size.min(offset), store_size.saturating_sub(offset));
return clamp_view_to_store(self.offset.get(), self.size.get(), file.max_size);
}
if file.seekable == Some(false) {
return (self.offset.get(), self.size.get());
Expand Down Expand Up @@ -4343,15 +4336,28 @@ fn _on_structured_clone_deserialize<B: AsRef<[u8]>>(
if version == 3 {
break 'versions;
}

// Version 4: the blob's `size`. Required to reconstruct a file-backed
// `.slice(start, end)`: `offset` alone widens the view to the rest of
// the file. `MAX_SIZE` means the sender never resolved it (an unsliced
// `Bun.file(p)` / `Bun.file(fd)`), so it stays lazy and resolves here
// against this process's own path / fd. Version 3 payloads fall back
// to the older (size-less, always lazy) behavior.
blob.size.set(reader.read_int_le::<u64>()? as SizeType);

if version == 4 {
break 'versions;
}
}

debug_assert!(
blob.is_heap_allocated(),
"expected blob to be heap-allocated"
);

// `offset` comes from untrusted bytes. Clamp it so a crafted payload cannot
// make shared_view() slice past the end of the backing store (OOB heap read).
// `offset` and `size` come from untrusted bytes. Clamp them so a crafted
// payload cannot make shared_view() slice past the end of the backing
// store (OOB heap read).
blob.offset.set(offset as SizeType); // intentional truncate
if let Some(store) = blob.store.get() {
let store_size = store.size();
Expand All @@ -4362,6 +4368,7 @@ fn _on_structured_clone_deserialize<B: AsRef<[u8]>>(
}
} else {
blob.offset.set(0);
blob.size.set(0);
}

if !content_type.is_empty() {
Expand Down Expand Up @@ -6285,6 +6292,26 @@ fn stat_to_js_mtime(stat: &bun_sys::Stat) -> jsc::JSTimeType {
}

/// resolve file stat like size, last_modified
/// Clamp a blob's `(offset, size)` view to a backing store whose size is now
/// known. Only the `MAX_SIZE` unknown-size sentinel resolves to the remainder
/// of the store: a slice already has a concrete `size`, and overwriting it
/// with `store_size - offset` would widen the view to the end of the backing
/// store. Both results are capped to the bytes the store actually has.
fn clamp_view_to_store(
offset: SizeType,
size: SizeType,
store_size: SizeType,
) -> (SizeType, SizeType) {
let offset = store_size.min(offset);
let available = store_size - offset;
let size = if size == MAX_SIZE {
available
} else {
size.min(available)
};
(offset, size)
}

fn resolve_file_stat(store: &StoreRef) {
// `StoreRef::data_mut` encapsulates the raw-pointer deref under the
// `StoreRef` liveness invariant; the caller holds the only ref across
Expand Down
Loading
Loading