Skip to content

fix(sirv): send the requested byte range instead of the whole file - #330

Merged
magne4000 merged 1 commit into
mainfrom
fix/sirv-range-requests
Jul 21, 2026
Merged

magne4000 merged 1 commit into
mainfrom
fix/sirv-range-requests

Conversation

@magne4000

@magne4000 magne4000 commented Jul 21, 2026 •

Copy link
Copy Markdown
Owner

Problem

send() computed the range offsets into an opts object and then never passed it to createReadStream. Every Range request therefore answered with a partial Content-Length and Content-Range while writing the entire file to the socket.

Captured off the wire for Range: bytes=0-10 against a 124-byte file:

HTTP/1.1 206 Partial Content
accept-ranges: bytes
content-length: 11
content-range: bytes 0-10/124
content-type: text/javascript
...

fetch("/2018-01-31-hello-world.md")
  .then((r) => r.text())
  .then(console.log);
//# sourceMappingURL=bundle.67329.js.map

All 124 bytes are on the wire under content-length: 11. Node does not reject the mismatch, so the surplus 113 bytes stay in the connection buffer and the next response on a keep-alive connection is parsed out of them — Node's own client answers Parse Error: Expected HTTP/, RTSP/ or ICE/. This affects any client that sends Range: media seeking, PDF viewers, download resumption.

The parsing was independently broken, because parseInt(...) || fallback treats a valid 0 as absent and split("-") cannot express the grammar:

Request Before Expected
bytes=-10 (last 10 bytes) bytes 0-10/124 bytes 114-123/124
bytes=0-0 (first byte) bytes 0-123/124 — whole file bytes 0-0/124
bytes=-0 206, whole file 416
bytes=abc-def 206, whole file 200, ignored
bytes=0-1,5-6 206 of the first range only 200, ignored
items=0-10 206 200, ignored

Fix

Range parsing moves into parseRange(), built on a single regex, with a three-way result: a range, null for "valid but unsatisfiable" (416), or undefined for "must be ignored" (RFC 9110 §14.2 — a unit other than bytes, a syntax error, or a multi-range request we do not answer). The resolved offsets are then actually passed to createReadStream.

Two behaviour additions:

  • Accept-Ranges: bytes is now advertised on identity responses. Previously it was only set on a 206, so a client had no way to learn ranges were supported. This changes two existing assertions in sirv.spec.ts, which used the absence of Accept-Ranges on a plain 200 as a proxy for "no per-request header leaked"; they now assert the header is present while still asserting Content-Range does not leak.
  • A Range over a precompressed variant is ignored. Slicing a .gz/.br would hand the client a partial gzip/brotli stream it cannot decode, so those responses are served whole and omit Accept-Ranges.

The lenient bytes=2 spelling (no trailing -) that the existing suite pins keeps working.

Tests

packages/sirv/tests/ranges.spec.ts — 11 tests: body-matches-Content-Length for start/middle ranges in both dev and production modes, each parsing case in the table above, Accept-Ranges on a 200, and the precompressed-variant rule.

The body assertions go through a new sendRaw helper (node:http) rather than fetch, because fetch rejects on exactly the Content-Length mismatch under test and would hide it.

pnpm test in packages/sirv: 87 passed. Typecheck and Biome clean.

Summary by CodeRabbit

  • Bug Fixes
    • Static file requests using valid byte ranges now return only the requested portion of the file.
    • Added proper partial response headers and status codes for range requests.
    • Unsatisfiable ranges now return a clear 416 response.
    • Invalid or unsupported ranges continue to receive the complete file.
    • Range requests are safely ignored for precompressed files.
    • Static file responses now advertise byte-range support consistently.

`send()` computed the range offsets but never passed them to
`createReadStream`, so a `Range` request answered with a partial
`Content-Length`/`Content-Range` while writing the whole file to the socket.
Node does not enforce the mismatch, so the surplus bytes stay in the
connection buffer and the next response on a keep-alive connection is parsed
out of them.

Range parsing is rewritten around a single regex so that suffix ranges
(`bytes=-10`), zero offsets (`bytes=0-0`) and unsatisfiable ranges are handled
per RFC 9110, and so that ranges we do not implement (multi-range, non-`bytes`
units, syntax errors) are ignored in favour of the full representation instead
of being answered with a wrong 206.

`Accept-Ranges: bytes` is now advertised on identity responses, and a `Range`
over a precompressed variant is ignored rather than yielding an undecodable
partial gzip/brotli stream.
@changeset-bot

changeset-bot Bot commented Jul 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 43d6bca

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@universal-middleware/sirv Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Jul 21, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Sirv now supports single-byte HTTP Range requests, including suffix ranges, by returning 206 responses with matching headers and partial file streams. Unsatisfiable ranges return 416, while malformed, multi-range, unsupported, and precompressed requests receive full responses. Tests add raw HTTP response handling and cover range parsing, headers, body bytes, identity responses, and gzip behavior. A patch changeset documents the fix.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant SirvMiddleware
  participant send
  participant FileSystem
  Client->>SirvMiddleware: GET file with Range header
  SirvMiddleware->>send: build file response
  send->>FileSystem: stream requested byte range
  FileSystem-->>send: partial file bytes
  send-->>Client: 206 response with range metadata
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main fix: Sirv now serves the requested byte range instead of the full file.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/sirv-range-requests

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

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

@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.

🧹 Nitpick comments (1)
packages/sirv/src/middleware.ts (1)

105-110: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

416 response drops previously computed headers (ETag, Last-Modified, Content-Type).

newHeaders (spread from headers at Line 98, containing ETag/Last-Modified/Content-Type) is discarded here; the 416 response only ever carries Content-Range. Many servers (e.g. Apache/nginx) preserve validators on 416 so a client can tell whether the resource changed without a full re-fetch. If you adopt this, make sure to still exclude the full-file Content-Length that newHeaders carries, since the 416 body is null.

♻️ Optional: preserve validators on 416, excluding stale Content-Length
   if (range === "unsatisfiable") {
+    const { "Content-Length": _omit, ...preservedHeaders } = newHeaders;
     return new Response(null, {
       status: 416,
-      headers: { "Content-Range": `bytes */${stats.size}` },
+      headers: { ...preservedHeaders, "Content-Range": `bytes */${stats.size}` },
     });
   }
🤖 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 `@packages/sirv/src/middleware.ts` around lines 105 - 110, Update the range ===
"unsatisfiable" response to preserve the applicable headers from newHeaders,
including ETag, Last-Modified, and Content-Type, while explicitly excluding its
full-file Content-Length. Retain the 416 status and Content-Range bytes
*/${stats.size} header, and keep the null response body.
🤖 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.

Nitpick comments:
In `@packages/sirv/src/middleware.ts`:
- Around line 105-110: Update the range === "unsatisfiable" response to preserve
the applicable headers from newHeaders, including ETag, Last-Modified, and
Content-Type, while explicitly excluding its full-file Content-Length. Retain
the 416 status and Content-Range bytes */${stats.size} header, and keep the null
response body.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 61c4097d-ad03-4450-b5ce-ae7781ce58d2

📥 Commits

Reviewing files that changed from the base of the PR and between 7461719 and 43d6bca.

📒 Files selected for processing (5)
  • .changeset/tricky-moons-shake.md
  • packages/sirv/src/middleware.ts
  • packages/sirv/tests/helpers.ts
  • packages/sirv/tests/ranges.spec.ts
  • packages/sirv/tests/sirv.spec.ts

@magne4000
magne4000 merged commit 1a1efa6 into main Jul 21, 2026
45 checks passed
@magne4000
magne4000 deleted the fix/sirv-range-requests branch July 21, 2026 12:36
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.

1 participant