Skip to content

fetch: add automatic request body compression via compress option - #32416

Merged
Jarred-Sumner merged 14 commits into
mainfrom
claude/fetch-compress-request-body
Jun 23, 2026
Merged

fetch: add automatic request body compression via compress option#32416
Jarred-Sumner merged 14 commits into
mainfrom
claude/fetch-compress-request-body

Conversation

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Adds a compress option to fetch() that compresses the request body before sending and sets the Content-Encoding request header.

compress?: boolean | "gzip" | "deflate" | "br" | "zstd"
         | { encoding: "gzip" | "deflate" | "br" | "zstd"; level?: number }
  • true"gzip"
  • Only buffered bodies (string, ArrayBuffer/TypedArray, Blob) are compressed; ReadableStream and sendfile bodies are sent as-is.
  • Skipped if the request already has a Content-Encoding header, the body is empty, or the URL is s3://.
  • Content-Length automatically reflects the compressed size.

How did you verify your code works?

test/js/web/fetch/fetch-compress.test.ts — 24 tests covering all four encodings × {string, Uint8Array, Blob}, compress: true/false, {encoding, level}, explicit Content-Encoding skip, ReadableStream skip, empty body, >512 KiB body (slow path), and validation errors. Verified failing on USE_SYSTEM_BUN=1.

Implementation

Mirrors the response-decompression fast path in bun_http::InternalState: a thread-local libdeflate Compressor + 512 KiB shared scratch buffer (bun_core::boxed_zeroed, same shape as LibdeflateState in HTTPThread.rs) reused across calls. gzip/deflate go through libdeflate; brotli/zstd use their one-shot encoders into the same buffer. HTTP "deflate" is encoded as zlib-wrapped DEFLATE per RFC 9110 §8.4.1.2.

Accepts `boolean | "gzip" | "deflate" | "br" | "zstd" | { encoding, level? }`.
Compresses buffered bodies (string, ArrayBuffer/TypedArray, Blob) on the JS
thread using a thread-local libdeflate compressor + 512 KiB scratch buffer
(mirroring the response-decompression fast path), and injects the
`Content-Encoding` request header. ReadableStream and sendfile bodies are
left untouched, as are requests that already set `Content-Encoding`.
@Jarred-Sumner
Jarred-Sumner requested a review from alii as a code owner June 16, 2026 20:28
@robobun

robobun commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator
Updated 6:22 PM PT - Jun 16th, 2026

@Jarred-Sumner, your commit 6aec4e2 has 1 failures in Build #63018 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32416

That installs a local version of the PR into your bun-32416 executable, so you can run:

bun-32416 --bun

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use the existing pattern for parsing enums into a ComptimeStringMap - avoid calling .to_utf8 for this it's completely unnecessary.

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a compress option to fetch() that automatically compresses buffered request bodies. The TypeScript interface BunFetchRequestInit gains the compress property with boolean, string, and object-form variants. A new Rust module (compress_body.rs) provides encoding types, JS value parsing, thread-local libdeflate caching with a 512 KiB scratch buffer, and per-encoding implementations for gzip, deflate, brotli, and zstd. fetch_impl reads the option, adjusts sendfile heuristics to disable when compression is explicit, and replaces eligible AnyBlob bodies with compressed bytes plus a Content-Encoding header. Tests verify end-to-end correctness for all encodings and body types, plus two leak regression tests confirm no state leakage during compression or streaming decompression.

Changes

fetch() request-body compression

Layer / File(s) Summary
TypeScript type declaration for compress option
packages/bun-types/globals.d.ts
Adds compress?: boolean | "gzip" | "deflate" | "br" | "zstd" | { encoding: ...; level?: number } to BunFetchRequestInit with JSDoc covering buffered-body-only semantics and Content-Encoding interaction.
Compression module: types, JS parsing, thread-local state, and per-encoding implementations
src/runtime/webcore/fetch/compress_body.rs
Defines CompressEncoding, CompressOption with from_js validation and per-encoding level range checks, a thread-local cached libdeflate compressor paired with a 512 KiB scratch buffer, a dispatch function, and gzip/deflate/brotli/zstd compression functions with shared-buffer fast paths and heap-allocation fallbacks.
fetch_impl wiring: module import, option parsing, body replacement
src/runtime/webcore/fetch.rs
Declares and wires pub mod compress_body, extracts the compress option from the JS init object into CompressOption, adjusts sendfile heuristic to skip when compression is explicit, and applies compression to HTTPRequestBody::AnyBlob when no Content-Encoding is present and the destination is not S3.
End-to-end compression tests
test/js/web/fetch/fetch-compress.test.ts
Adds a Bun.serve helper that detects and decompresses by content-encoding; parameterizes round-trip tests for all four encodings and multiple body types; covers compress: true default, compress: false, identity header bypass, ReadableStream bypass, empty body bypass, large-payload fallback, invalid encoding error, invalid level error, and large file compression.
Leak regression tests for compression state and decompressor handles
test/js/web/fetch/fetch-leak.test.ts
Adds two memory safety tests: request-body compression with small and large payloads across all encodings with RSS delta checks, and streaming decompressor state across fragmented chunked responses with repeated decompress-release cycles and RSS validation.

Suggested reviewers

  • cirospaciari
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The pull request title clearly and concisely describes the main change: adding automatic request body compression to fetch() via a compress option.
Description check ✅ Passed The pull request description includes both required template sections with comprehensive details about what the PR does and how it was verified.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread src/runtime/webcore/fetch/compress_body.rs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/webcore/fetch/compress_body.rs`:
- Around line 161-167: The compressor_mut function signature allows returning a
mutable reference with an unconstrained lifetime 'a from an immutable reference
&self, which violates Rust's borrow rules. Fix this by changing the function
signature to properly constrain the lifetime of the returned mutable reference
to be tied to self's lifetime. Remove the explicit generic lifetime parameter 'a
from the function signature and instead let the Rust compiler infer the
lifetime, or change the parameter from &self to &mut self to properly express
the exclusive borrow semantics that the code actually relies on. The returned
reference to the compressor should not outlive the CompressorState instance it
belongs to.

In `@test/js/web/fetch/fetch-compress.test.ts`:
- Around line 186-187: The fetch calls in the negative validation tests are
using the external network host `http://example.com`, which can cause tests to
become non-hermetic and flaky if validation behavior shifts from sync to async.
Replace all occurrences of `http://example.com` with a local URL like
`http://127.0.0.1` or `http://localhost` to ensure tests remain hermetic and do
not contact external network hosts. The comment indicates this issue appears in
multiple test cases within the file.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4e679ecf-d001-4318-9469-008513caf4c5

📥 Commits

Reviewing files that changed from the base of the PR and between 62cc078 and ca9261c.

📒 Files selected for processing (4)
  • packages/bun-types/globals.d.ts
  • src/runtime/webcore/fetch.rs
  • src/runtime/webcore/fetch/compress_body.rs
  • test/js/web/fetch/fetch-compress.test.ts

Comment thread src/runtime/webcore/fetch/compress_body.rs Outdated
Comment thread test/js/web/fetch/fetch-compress.test.ts Outdated
…sor_mut

- Add fragmented-response leak test: drips gzip/br/zstd over chunked TE to
  force the per-request boxed Decompressor path (bypasses libdeflate one-shot
  fast path), paired with a 700 KiB compress: request body for multi-write send.
- Drop CompressorState::compressor_mut<'a>(&self) -> &'a mut and inline its
  single call site with a SAFETY comment; the unbounded-lifetime signature was
  correct (C heap handle disjoint from shared_buffer) but read as unsound.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/runtime/webcore/fetch/compress_body.rs (1)

166-173: ⚠️ Potential issue | 🟠 Major

Release the cached libdeflate compressor on thread exit.

The CompressorState is stored in a thread-local static (line 155-158) and the compressor raw pointer is allocated at line 167 via Compressor::alloc(). However, no Drop implementation exists to free this allocation when the thread exits. The comment at line 225-226 even acknowledges it is "never freed for the thread's lifetime." Only the temporary compressor (lines 231-237) is properly destroyed via scopeguard.

Suggested fix
 struct CompressorState {
     compressor: *mut bun_libdeflate_sys::libdeflate::Compressor,
     shared_buffer: [u8; SHARED_BUFFER_SIZE],
 }
+
+impl Drop for CompressorState {
+    fn drop(&mut self) {
+        if !self.compressor.is_null() {
+            // SAFETY: allocated via `Compressor::alloc`, owned by this state.
+            unsafe { bun_libdeflate_sys::libdeflate::Compressor::destroy(self.compressor) };
+            self.compressor = core::ptr::null_mut();
+        }
+    }
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/runtime/webcore/fetch/compress_body.rs` around lines 166 - 173, The
`CompressorState` struct, which holds a raw pointer to a libdeflate compressor
allocated via `Compressor::alloc()`, lacks a `Drop` implementation to properly
free this allocation when the thread-local static is destroyed at thread exit.
Implement the `Drop` trait for `CompressorState` to deallocate the compressor
pointer using the appropriate libdeflate deallocation method (similar to how the
temporary compressor is properly destroyed via scopeguard in the surrounding
code at lines 231-237). This ensures the cached compressor allocation is freed
instead of leaking for the thread's lifetime.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/runtime/webcore/fetch/compress_body.rs`:
- Around line 166-173: The `CompressorState` struct, which holds a raw pointer
to a libdeflate compressor allocated via `Compressor::alloc()`, lacks a `Drop`
implementation to properly free this allocation when the thread-local static is
destroyed at thread exit. Implement the `Drop` trait for `CompressorState` to
deallocate the compressor pointer using the appropriate libdeflate deallocation
method (similar to how the temporary compressor is properly destroyed via
scopeguard in the surrounding code at lines 231-237). This ensures the cached
compressor allocation is freed instead of leaking for the thread's lifetime.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ded26e03-3afd-4da2-81ce-62bad1723836

📥 Commits

Reviewing files that changed from the base of the PR and between 526e2a7 and f2a097f.

📒 Files selected for processing (3)
  • src/runtime/webcore/fetch/compress_body.rs
  • test/js/web/fetch/fetch-compress.test.ts
  • test/js/web/fetch/fetch-leak.test.ts

Comment thread src/runtime/webcore/fetch.rs Outdated
Comment thread src/runtime/webcore/fetch/compress_body.rs Outdated
…n-integer level

- Bun.file() bodies that qualified for the sendfile fast path (plain http,
  no proxy, >=32 KiB, non-Windows) silently skipped explicit compression
  while the same call over https/proxy/small-file/Windows compressed. Gate
  sendfile on compress.is_none() so an explicit request always wins.
- compress.level: NaN/5.5/Infinity passed is_number() and to_int32() mapped
  them to in-range values (NaN->0). Validate via as_number() + is_nan()/fract()
  like maxRedirects does.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/runtime/webcore/fetch/compress_body.rs (1)

172-179: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Release the cached libdeflate compressor when the TLS state drops.

Line 172 allocates a C compressor and Line 178 stores it in CompressorState, but the state has no Drop impl, so dropping the TLS Box<CompressorState> only frees the Rust struct and leaks the libdeflate handle. Add a Drop owner for the cached handle, mirroring the temporary compressor guard below.

Proposed fix
 struct CompressorState {
     compressor: *mut bun_libdeflate_sys::libdeflate::Compressor,
     shared_buffer: [u8; SHARED_BUFFER_SIZE],
 }
 
+impl Drop for CompressorState {
+    fn drop(&mut self) {
+        if !self.compressor.is_null() {
+            // SAFETY: `compressor` was returned by `Compressor::alloc` and is
+            // owned by this TLS state.
+            unsafe {
+                bun_libdeflate_sys::libdeflate::Compressor::destroy(self.compressor);
+            }
+            self.compressor = core::ptr::null_mut();
+        }
+    }
+}
+
 // SAFETY: `*mut T` (null) and `[u8; N]` are both valid at the all-zero bit pattern.
 unsafe impl bun_core::Zeroable for CompressorState {}

As per coding guidelines, “Pair every acquisition with its release at the acquisition site using Drop/RAII guards.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/runtime/webcore/fetch/compress_body.rs` around lines 172 - 179, The
CompressorState struct holds a libdeflate compressor handle allocated at line
172 but has no Drop implementation, causing a memory leak when the TLS-stored
state is dropped. Add a Drop implementation for the CompressorState struct that
properly releases the libdeflate compressor handle by calling the appropriate
deallocation function from bun_libdeflate_sys when the struct is dropped,
following the RAII pattern of pairing acquisition with release at the
acquisition site.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/webcore/fetch.rs`:
- Around line 1659-1662: The sendfile eligibility check around line 1662
disables sendfile whenever compress is set, but the compression logic at lines
1777-1781 skips actual compression if Content-Encoding is already present. Use
the same predicate in both locations: modify the condition checking
`compress.is_none()` at line 1662 to also verify that Content-Encoding is not
already set in the response headers, ensuring sendfile is only disabled when
compression will actually be applied to the body.

---

Outside diff comments:
In `@src/runtime/webcore/fetch/compress_body.rs`:
- Around line 172-179: The CompressorState struct holds a libdeflate compressor
handle allocated at line 172 but has no Drop implementation, causing a memory
leak when the TLS-stored state is dropped. Add a Drop implementation for the
CompressorState struct that properly releases the libdeflate compressor handle
by calling the appropriate deallocation function from bun_libdeflate_sys when
the struct is dropped, following the RAII pattern of pairing acquisition with
release at the acquisition site.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8aceeb66-ba2e-43a0-b151-6e15baac33b0

📥 Commits

Reviewing files that changed from the base of the PR and between f2a097f and 018a3db.

📒 Files selected for processing (3)
  • src/runtime/webcore/fetch.rs
  • src/runtime/webcore/fetch/compress_body.rs
  • test/js/web/fetch/fetch-compress.test.ts

Comment thread src/runtime/webcore/fetch.rs
Comment thread src/runtime/webcore/fetch/compress_body.rs Outdated
Jarred-Sumner and others added 4 commits June 16, 2026 22:47
Compression now runs in HTTPClient::start() on the HTTP thread, reusing
HttpThread.lazy_libdeflater (LibdeflateState gains a lazy compressor handle
and the existing 512 KiB shared_buffer is reused for the fast path).

- bun_http::compress_body: CompressEncoding/CompressOption + compress_into()
  taking &mut LibdeflateState; returns bun_core::Error on encoder failure.
- HTTPClient: new compress + compressed_request_body fields. start() compresses
  Bytes bodies before InternalState::init; compress is .take()n so retry/h2-retry
  re-entries don't double-compress. The compressed Vec is freed in
  on_async_http_callback_raw alongside redirect/prev_redirect (the threadlocal
  clone is dealloc'd without Drop, so clone-owned state is torn down explicitly).
- async_http::Options.compress threaded through AsyncHTTP::init.
- runtime/webcore/fetch/compress_body.rs is now just from_js() re-exporting the
  bun_http types; the JS-thread thread_local! CompressorState is gone.
- fetch_impl no longer compresses; it appends Content-Encoding and forwards the
  option via FetchOptions. Content-Length was already computed on the HTTP
  thread in build_request from original_request_body.len().
… send

Common case (h1, compressed bound <= 512 KiB, body fits in one socket write)
now allocates no per-request Vec:

- Compression moves from HTTPClient::start() to write time so the output can
  borrow LibdeflateState::shared_buffer for the synchronous send.
- state.original_request_body stays as the original uncompressed slice; only
  state.request_body (the cursor) is re-seated to compressed bytes. Redirects
  (307/308) and h2/idempotent retries re-read the original and re-compress on
  the next hop. New state.flags.body_compressed gates per-attempt re-entry.
- Content-Length comes from HTTPClient::body_len_for_send() (compressed_body_len
  when set, else original_request_body.len()); h1/h2/h3/proxy build_request
  call sites updated.
- h1: send_initial_request_payload compresses into shared_buffer, writes, then
  spill_compressed_body() copies any unsent tail into compressed_request_body
  before yielding to the event loop. Covers both the amount==0 early return
  and the normal return.
- h2/h3/proxy-tunnel: compress_body_for_send(false) writes straight into the
  Vec (their body sends span event-loop ticks).
- gzip/deflate slow path (bound > 512 KiB) now uses streaming zlib
  deflate(Z_FINISH) into a Vec growing in 64 KiB steps, instead of libdeflate
  one-shot which would prealloc the worst-case bound. zlib level clamped to 9.
- Tests: incompressible 600 KiB body (forces zlib streaming + spill); 307
  redirect with compressed body (re-compress from original on second hop).
Comment thread src/http/AsyncHTTP.rs
…ped test

- h2: withH2Server echo that gunzips the received body; small (32 KiB,
  shared-buffer fast path) and large (600 KiB, zlib-streaming spill) variants;
  asserts content-encoding, content-length == compressed length, round-trip.
- h3: new /raw-echo route; same small/large variants.
- h1: 600 KiB compress:"deflate" to verify the zlib-streaming slow path
  emits a zlib-wrapped (RFC 1950) stream that inflateSync decodes.
…r_exit

Mirrors the clone-only teardown in on_async_http_callback_raw so an in-flight
fetch({compress}) at shutdown_for_exit() doesn't leak its compressed Vec under
LSan.
Comment thread src/http/compress_body.rs Outdated
…AFETY

- compress_zlib_streaming: avail_in is c_uint; feed input in <=u32::MAX chunks
  with NoFlush until the tail, then Finish, so a >=4 GiB body isn't silently
  truncated to its low 32 bits. avail_out also clamped via try_from.
- Move the SAFETY comment inside the deflateEnd scopeguard closure
  (-D clippy::undocumented-unsafe-blocks).
Comment thread test/js/web/fetch/fetch-leak.test.ts Outdated
Comment thread test/js/web/fetch/fetch-http3-client.test.ts Outdated
@Jarred-Sumner
Jarred-Sumner merged commit c6be834 into main Jun 23, 2026
79 of 80 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the claude/fetch-compress-request-body branch June 23, 2026 06:47
cirospaciari added a commit that referenced this pull request Jun 23, 2026
Build #64279 left only darwin-14-aarch64 red after auto-retries:
- test-tls-client-destroy-soon.js (known flake, FLAKY-tagged in #32488)
- fetch-leak.test.ts (added on main by #32416; passes on all other runners)

ASAN and all other 70+ jobs green.
@robobun

robobun commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

The two leak tests added here have been flaking on the macOS arm64 Tart runners (deltaMB 25-43 vs the 32 threshold) and went red in build 71937; loosened in #33988.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants