Skip to content

fix: preserve Response identity on Bun so Range works for new Response(Bun.file())#1869

Open
alexkahndev wants to merge 2 commits into
elysiajs:mainfrom
alexkahndev:fix/static-file-range-regression
Open

fix: preserve Response identity on Bun so Range works for new Response(Bun.file())#1869
alexkahndev wants to merge 2 commits into
elysiajs:mainfrom
alexkahndev:fix/static-file-range-regression

Conversation

@alexkahndev

@alexkahndev alexkahndev commented May 3, 2026

Copy link
Copy Markdown

Closes #1868.

createResponseHandler always rewraps the handler's Response in new Response(response.body, ...) since 1.4.23 (0d9b0401, "fix: can't modify immutable headers"). On Bun, that rewrap reads response.body as a generic ReadableStream, which severs the Bun.file association on the body and disables Bun.serve's automatic Range-request handling.

The original always-rewrap was a legitimate fix for Node / Cloudflare Workers, where response.headers is immutable. This PR keeps that path for those runtimes and adds an isBun && !needsRewrap fast path that mutates response.headers in place and returns the original Response — preserving every body-specific optimization Bun.serve provides.

Why

Real-world impact: every static asset served by @elysiajs/static is new Response(Bun.file()). The 1.4.23 rewrap silently broke 206 / Accept-Ranges for all of them on Bun. Mobile Safari refuses to play <video> without byte-range support (Apple requirement), so the regression broke video playback on iOS for every Elysia + Bun + static-plugin app. Desktop browsers tolerate it, so it went unnoticed.

Approach

In createResponseHandler:

  • Take the mutate-in-place path only when (a) we're on Bun (isBun), (b) the merged status equals the original response status (no rewrap forced for status reasons), and (c) the response doesn't need stream wrapping (content-length is set OR transfer-encoding is not chunked).
  • Otherwise fall through to the existing rewrap path unchanged.

A new helper mergeHeadersInPlace mutates a target Headers with non-conflicting entries from set.headers, mirroring the logic in mergeHeaders exactly except the target is mutated rather than constructed.

Tests

  • All existing range tests pass unchanged.
  • New: Response identity is preserved on the Bun mutate path.
  • New: Rewrap still happens when status changes.
  • New: End-to-end test under the real Bun.serve runtime (via app.listen/fetch) confirming new Response(Bun.file()) returns 206 with correct content-length, content-range, and body bytes for a Range request.

Full test suite: 1528 pass, 0 fail.

Related

#1768 (open) reports the same root cause — createResponseHandler always rewrapping the response — for a different downstream symptom (ReadableStream cancel callback never invoked when the client disconnects). This fix doesn't directly address that symptom, but the underlying bug is the same: rewrapping severs body identity. The reporter would benefit from this PR's structural change as a baseline.

elysiajs/elysia-static#19 and #79 document the downstream impact in the static plugin. With this fix, the static plugin's new Response(file) shape (1.4.7 and 1.4.10) gets Range support back without changes to @elysiajs/static.

Summary by CodeRabbit

  • Bug Fixes

    • Optimized response handling with a platform-specific fast path to avoid unnecessary response recreation
    • Ensures correct header merging (including Set-Cookie) without leaking headers across requests
    • Maintains correct HTTP Range support producing 206 byte-range responses
  • Tests

    • Added regression tests validating response identity preservation, header merging, and Range behavior

…Response(Bun.file())

createResponseHandler always rewraps the handler's Response in
`new Response(response.body, ...)` since 1.4.23 (commit 0d9b040, "fix:
can't modify immutable headers"). The rewrap reads `response.body` as a
generic ReadableStream, which severs the Bun.file association on the body
and disables Bun.serve's automatic Range-request handling for
`new Response(Bun.file())`.

This is the response shape `@elysiajs/static` returns for every static
asset, so the regression silently broke HTTP byte-range support for static
media on Bun. Most visible symptom: mobile Safari refuses to play `<video>`
(Apple requires byte-range support). Desktop browsers tolerate it, so the
regression went unnoticed.

The original always-rewrap was a legitimate fix for Node and Cloudflare
Workers, where response.headers is immutable. The fix here is to take the
mutate-in-place path only when both conditions hold: we're on Bun (where
mutation is safe) and the response doesn't need rewrapping anyway (status
unchanged, no streaming required). Otherwise fall through to the existing
rewrap path.

Tests:
- Existing range-handling tests pass unchanged (they exercise handleFile,
  not createResponseHandler).
- Regression test asserting Response identity is preserved on the mutate
  path.
- Regression test asserting rewrap still happens when status changes.
- End-to-end test via app.listen + fetch confirming 206 + correct
  content-length + correct body bytes for new Response(Bun.file()) under
  the actual Bun.serve runtime.

Related: elysiajs#1768 (ReadableStream cancel callback never invoked) reports the
same root cause (always-rewrap losing body identity) for a different
symptom. elysiajs/elysia-static#19 / elysiajs#79 are the downstream impact in the
static plugin.
@coderabbitai

coderabbitai Bot commented May 3, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9d1b6d3a-02c4-43bf-ae5c-f372ecf68c55

📥 Commits

Reviewing files that changed from the base of the PR and between 262c1da and e4ae65f.

📒 Files selected for processing (2)
  • src/adapter/utils.ts
  • test/response/range.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/response/range.test.ts

Walkthrough

This PR restores Bun’s auto-Range behavior for new Response(Bun.file()) by adding a Bun-specific fast path in createResponseHandler: it precomputes mergedStatus, and when running on Bun with an unchanged status and no streaming required, it clones the handler Response, merges set.headers into the clone in-place via mergeHeadersInPlace(), and returns the clone (avoiding rewrap). Otherwise it falls back to the existing rewrap path.

Changes

Response Identity Preservation & Range Request Fix

Layer / File(s) Summary
Header Mutation Helper
src/adapter/utils.ts
Adds mergeHeadersInPlace(target, source) which mutates a Headers target by copying non-conflicting entries from source and merging multiple set-cookie values (preserves existing set-cookie plus appends new ones).
Response Handler Fast Path
src/adapter/utils.ts
createResponseHandler now precomputes mergedStatus = mergeStatus(response.status, set.status). On Bun, if mergedStatus === response.status, it clones the handler Response, calls mergeHeadersInPlace() to merge set.headers, computes needsStreamHandling (missing content-length and no transfer-encoding: chunked), and if streaming is not needed returns the cloned Response early. Otherwise it falls through to the rewrap path using mergedStatus.
Rewrap (fallback) unchanged)
src/adapter/utils.ts
If status changed or streaming handling is required, the code still constructs new Response(response.body, { headers: mergeHeaders(...), status: mergedStatus }), preserving previous chunked/stream behavior.
Regression Tests
test/response/range.test.ts
Adds Bun-focused tests: ensure set.headers merging does not mutate handler-owned Response; ensure per-request set.headers do not leak when reusing a handler Response; assert rewrap occurs when set.status differs; and an end-to-end Range test verifying 206, Content-Range, Content-Length, and sliced body for new Response(Bun.file(...)).

Sequence Diagram

sequenceDiagram
    participant Client
    participant Elysia as Elysia Handler
    participant BunHTTP as Bun HTTP Layer

    rect rgba(200, 120, 120, 0.5)
    Note over Client,BunHTTP: Before Fix (always rewrap)
    Client->>Elysia: GET /file + Range
    Elysia->>Elysia: createResponseHandler rewraps<br/>Response.body read as stream
    Note over Elysia: Bun.file association lost
    Elysia->>BunHTTP: pass rewrapped Response
    BunHTTP->>Client: 200 OK (full file)
    end

    rect rgba(120, 200, 150, 0.5)
    Note over Client,BunHTTP: After Fix (Bun fast path)
    Client->>Elysia: GET /file + Range
    Elysia->>Elysia: compute mergedStatus<br/>check platform & streaming need
    alt Bun & status unchanged & no streaming
        Elysia->>Elysia: clone Response & mergeHeadersInPlace()
        Elysia->>BunHTTP: pass original/clone preserving Bun.file
        BunHTTP->>Client: 206 Partial Content
    else fallback
        Elysia->>Elysia: rewrap Response with merged headers/status
        Elysia->>BunHTTP: pass rewrapped Response
        BunHTTP->>Client: 200 or other as appropriate
    end
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

"You silly dev, you broke Range by rewrapping things~ ♡ (´∇`)
Now headers merge in-place so Bun keeps its file magic~ ♡ (๑˃ᴗ˂)و
No more leaking set.headers between requests, baka~ ♡ (¬‿¬)
Status changes still force rewrap — don’t be lazy~ ♡ (╯°□°)╯
Range tests added, so don’t break it again, got it? ~ ♡"

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly describes the main fix—preserving Response identity on Bun to enable Range request support for Bun.file() responses, which matches the core objective of this PR.
Linked Issues check ✅ Passed All coding requirements from #1868 are met: Bun fast path clones Response to preserve Bun.file identity, merges headers in place via mergeHeadersInPlace helper, skips rewrap when status unchanged and streaming unnecessary, and fallback rewrap preserves Node/CF compatibility. Tests verify identity preservation, no cross-request leakage, and end-to-end Range support.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the Range request regression: createResponseHandler optimization for Bun, mergeHeadersInPlace helper addition, and comprehensive test coverage validating the fix—no unrelated modifications present.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get your free trial and get 200 agent minutes per Slack user (a $50 value).


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 7/8 reviews remaining, refill in 7 minutes and 30 seconds.

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

@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 the current code and only fix it if needed.

Inline comments:
In `@src/adapter/utils.ts`:
- Around line 529-532: The predicate computing needsStreamHandling currently
inspects only response.headers; instead compute and use the merged headers (the
same headers used with mergeStatus) so additions from set.headers (e.g.,
transfer-encoding: chunked) are considered. Concretely, create mergedHeaders by
combining response.headers with set.headers (e.g., clone response.headers then
apply set.headers entries), then set needsStreamHandling based on
mergedHeaders.has('content-length') and mergedHeaders.get('transfer-encoding')
=== 'chunked', and use mergedHeaders (not response.headers) in the Bun fast-path
branch that checks streaming; keep references to mergedStatus and the existing
mergeStatus call intact.
- Around line 492-511: mergeHeadersInPlace currently mutates a handler-owned
Response headers in-place causing header leakage across requests; change it to
avoid in-place mutation by creating a new Headers instance (e.g., const merged =
new Headers(target); apply merges from source into merged instead of target),
return that merged Headers (or update callers to replace target.headers with the
returned Headers) so handlers that reuse a Response won't be polluted, and add a
regression test that registers a handler which returns the same Response object
for two sequential requests while passing different Context['set']['headers']
for each request and asserts the second response only contains its own merged
headers (no stale headers from the first request).
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 32ae8f44-159c-4811-a9cc-a08b21a9f40b

📥 Commits

Reviewing files that changed from the base of the PR and between 56310be and 262c1da.

📒 Files selected for processing (2)
  • src/adapter/utils.ts
  • test/response/range.test.ts

Comment thread src/adapter/utils.ts
Comment thread src/adapter/utils.ts Outdated
…g handler-owned Response

Addresses CodeRabbit review on PR elysiajs#1869:

1. Cross-request header pollution: mutating response.headers in place
   leaks set.headers across requests when the handler returns a cached
   or shared Response. Reproducer (without this commit, on Bun.serve):

       const shared = new Response('hello')
       new Elysia()
         .onRequest(({ set }) => { set.headers['x-request-id'] = String(++c) })
         .get('/', () => shared)

   Request 1 mutates shared.headers to {x-request-id: '1'}; request 2's
   non-conflicting merge skips because the key already exists, so the
   second response carries stale state and `shared` is permanently
   polluted. Fix: clone the response (preserves Bun.file body identity
   per Bun's Response.clone behavior — verified end-to-end) and merge
   set.headers into the clone instead.

2. Streaming decision must consider merged headers: the previous code
   inspected response.headers only, missing the case where set.headers
   provides transfer-encoding: chunked. Cloning + merging first means
   the streaming check naturally sees the post-merge headers via
   cloned.headers.

Tests added:
- Pristine handler-owned Response after merge.
- No header leakage across two real requests over Bun.serve when
  handler returns a shared Response (the bug CodeRabbit flagged).

Existing identity-preservation test removed (no longer the contract).
End-to-end Bun.file Range test still passes — clone() preserves body
identity so Bun.serve's auto-Range still fires.
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.

Range requests broken for new Response(Bun.file()) on Bun — regression since 1.4.23

2 participants