From bc6c48e62b295b45304d00727aa9632061f0af3f Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 17 Jul 2026 21:45:54 +0000 Subject: [PATCH 1/3] feat(static): add byte-range support (206/416, If-Range, Accept-Ranges) Co-Authored-By: Claude Fable 5 --- src/static.ts | 165 +++++++++++++++++++++++++++++++++++-- test/static.test.ts | 194 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 354 insertions(+), 5 deletions(-) diff --git a/src/static.ts b/src/static.ts index 424c5e01..e3f67c5d 100644 --- a/src/static.ts +++ b/src/static.ts @@ -98,6 +98,20 @@ export interface ServeStaticOptions { */ immutable?: boolean; + /** + * Answer a single byte-range GET request with `206 Partial Content`, and + * advertise `Accept-Ranges: bytes` on responses that could serve one. + * + * A range request is served the identity bytes only — content negotiation is + * skipped — since a range over an on-the-fly (chunked) encoding is not + * expressible, and range consumers (media seek, download resumption) target + * already-compressed types. `false` disables it: no `206`/`416`, and the + * header is never sent. + * + * @default true + */ + ranges?: boolean; + /** * A function to modify the HTML content before serving it. */ @@ -221,6 +235,7 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { const lastModified = options.lastModified ?? true; const etag = options.etag ?? true; + const ranges = options.ranges ?? true; // Depends only on the options, so it is built once. Empty when `maxAge` is // unset — the header is fully opt-in. @@ -345,6 +360,15 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { } // Parsed lazily: unmatched routes (all non-static traffic) never need it. let acceptEncodings: EncodingSpec[] | undefined; + // A range request bypasses negotiation entirely (identity bytes only): a + // range over a chunked on-the-fly encoding is not expressible, and range + // consumers — media seek, download resumption — target already-compressed + // types. GET only, as RFC 9110 defines range handling for GET alone, so HEAD + // (and any other configured method) ignores the header. Only a `bytes=` unit + // is a range request; an unknown unit is treated as if no header were sent + // and leaves negotiation untouched. + const rangeHeader = ranges && req.method === "GET" ? req.headers.get("range") : null; + const rangeRequest = rangeHeader !== null && rangeHeader.startsWith("bytes="); for (const candidate of paths) { const filePath = join(dir, candidate); // Cheap lexical pre-filter — `isServable` is the real boundary. Also @@ -370,7 +394,7 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { let encoding = ""; let servePath = filePath; let file: ServableFile | null = null; - if (compressible) { + if (compressible && !rangeRequest) { acceptEncodings ??= parseAcceptEncoding(req.headers.get("accept-encoding"), served); for (const spec of acceptEncodings) { if (!spec.ext) { @@ -405,6 +429,7 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { let compressor: ((sizeHint: number) => Transform) | undefined; if ( compressible && + !rangeRequest && !encoding && file.size >= COMPRESS_MIN_SIZE && file.size <= COMPRESS_MAX_SIZE @@ -455,6 +480,14 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { if (encoding) { headers["Content-Encoding"] = encoding; } + // Advertise range support (RFC 9110 §14.3) on any identity response — a + // HEAD mirrors GET's headers, so an identity HEAD carries it too. An + // encoded response (a disk variant or an on-the-fly compressor, both of + // which set `encoding`) omits it: a range is served identity-only, and a + // range over chunked output is not expressible in the first place. + if (ranges && !encoding) { + headers["Accept-Ranges"] = "bytes"; + } if (varyOnEncoding && compressible) { // Also set on the identity response: shared caches must key on the // header either way. @@ -525,6 +558,47 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { } return new FastResponse(null, { status: conditionalStatus, headers: conditionalHeaders }); } + // Range evaluation runs only after the conditionals above pass (RFC 9110 + // §13.2.2): a 304/412 outranks a 206. `rangeRequest` is already GET-only + // and identity-only (negotiation was skipped), so `headers` here is the + // full 200's, minus a body. + if (rangeRequest) { + // `If-Range` (RFC 9110 §13.1.5) guards the range against a changed + // representation. Its entity-tag form demands a *strong* comparison, and + // our tags are weak, so it never matches — the range is dropped for a + // full 200. The HTTP-date form is a strong comparison that matches only + // the exact second we emit as `Last-Modified`; validated against + // `lastModifiedMs` regardless of the `lastModified` option, since an + // exact-second match can only reproduce a date the server itself sent. + const ifRange = req.headers.get("if-range"); + if (ifRange === null || matchesIfRange(ifRange, lastModifiedMs)) { + const parsed = parseRange(rangeHeader!, file.size); + if (parsed === "unsatisfiable") { + await file.handle.close().catch(() => {}); + // A 416 is a plain error: like the 412 path it drops the + // representation headers and validators, keeping only the + // `Content-Range` that reports the valid extent (`*`/size). + return new FastResponse(null, { + status: 416, + headers: { "Content-Range": `bytes */${file.size}` }, + }); + } + if (parsed) { + // Node's `end` is inclusive, matching the byte range's own bounds. + const stream = file.handle.createReadStream({ start: parsed.start, end: parsed.end }); + return new FastResponse(stream as any, { + status: 206, + headers: { + ...headers, + "Content-Range": `bytes ${parsed.start}-${parsed.end}/${file.size}`, + "Content-Length": (parsed.end - parsed.start + 1).toString(), + }, + }); + } + // `parsed === null`: malformed, multi-range, or otherwise ignorable — + // fall through to the full 200 below. + } + } if (req.method === "HEAD") { // Node discards a HEAD body at the http layer, so skip the read — and // with it the compression a GET would pay for. The headers still @@ -583,10 +657,13 @@ function isCompressible(mimeType: string): boolean { } // A weak validator over the served representation. Weak (`W/`), not strong: an -// on-the-fly encode is not byte-stable across runs, and a strong tag's one real -// advantage — byte-range requests — is something this middleware does not answer. -// Size and mtime pin the file; the encoding is folded in so a gzip and a brotli -// response under one URL never collide, which a cache keying on `Vary` relies on. +// on-the-fly encode is not byte-stable across runs. The one cost is `If-Range`, +// whose entity-tag form requires a strong comparison — a range request carrying +// one of these tags never matches, so the client falls back to a full 200 — but +// that is the right trade for a tag derived from size and mtime rather than the +// bytes. Size and mtime pin the file; the encoding is folded in so a gzip and a +// brotli response under one URL never collide, which a cache keying on `Vary` +// relies on. function computeETag(size: number, mtimeMs: number, encoding: string): string { const tag = `${size.toString(16)}-${Math.trunc(mtimeMs).toString(16)}`; return `W/"${encoding ? `${tag}-${encoding}` : tag}"`; @@ -618,6 +695,84 @@ function matchesIfModifiedSince(header: string | null, lastModifiedMs: number): return !Number.isNaN(since) && lastModifiedMs <= since; } +// RFC 9110 §13.1.5 — whether an `If-Range` lets the range apply. The entity-tag +// form (a value opening with `"` or `W/`) requires a strong comparison, which +// our weak tags can never pass, so it always drops the range to a full 200. The +// HTTP-date form is a strong date comparison: it matches only when the date is +// the exact second we emit as `Last-Modified` (`lastModifiedMs`, already floored +// to the second). An unparseable value is a mismatch. A failure here is never an +// error status — it just serves the whole representation. +function matchesIfRange(header: string, lastModifiedMs: number): boolean { + const value = header.trim(); + if (value.startsWith('"') || value.startsWith("W/")) { + return false; + } + return Date.parse(value) === lastModifiedMs; +} + +// Parse a single `Range: bytes=...` against `size` (the fd's `fstat`, the bytes +// actually served — not the earlier `stat` probe). Returns the inclusive +// `{ start, end }` to serve, `"unsatisfiable"` for a `416`, or `null` to ignore +// the header and serve a full `200` (malformed syntax, a non-`bytes` unit, or a +// syntactically valid multi-range, which RFC 9110 §14.2 lets a server answer in +// full). RFC 9110 §14.1.2. +function parseRange( + header: string, + size: number, +): { start: number; end: number } | "unsatisfiable" | null { + // The caller only reaches here on a `bytes=` header, but re-check so the + // helper stands alone. A non-`bytes` unit is not a range request at all. + if (!header.startsWith("bytes=")) { + return null; + } + const spec = header.slice(6); + // A single range only: a comma means a multi-range set, ignored wholesale. + if (spec.includes(",")) { + return null; + } + const dash = spec.indexOf("-"); + if (dash === -1) { + return null; + } + const startStr = spec.slice(0, dash); + const endStr = spec.slice(dash + 1); + // Suffix form `-N`: the final N bytes. `-0` asks for zero bytes and is + // unsatisfiable; N past the file size is the whole file. An empty file cannot + // satisfy any range. + if (startStr === "") { + if (!/^\d+$/.test(endStr)) { + return null; + } + const n = Number(endStr); + if (n === 0 || size === 0) { + return "unsatisfiable"; + } + return { start: n >= size ? 0 : size - n, end: size - 1 }; + } + if (!/^\d+$/.test(startStr)) { + return null; + } + const start = Number(startStr); + // `A-` runs to the end; `A-B` clamps B to the last byte. `A > B` is malformed. + let end: number; + if (endStr === "") { + end = size - 1; + } else if (/^\d+$/.test(endStr)) { + end = Number(endStr); + if (end < start) { + return null; + } + if (end > size - 1) { + end = size - 1; + } + } else { + return null; + } + // Satisfiable only when the start falls within the file (which also rejects + // every range on an empty file). + return start >= size ? "unsatisfiable" : { start, end }; +} + /** * Encodings from `served` the client accepts, in server-preference order. * `q=0` is honored as "not acceptable"; `*` applies to encodings not named explicitly. diff --git a/test/static.test.ts b/test/static.test.ts index 1034fff6..f413e0ab 100644 --- a/test/static.test.ts +++ b/test/static.test.ts @@ -25,6 +25,9 @@ const BIG_GZ_MARKER = `GZIP_BIG_FROM_DISK ${"z".repeat(2048)}`; // The ceiling past which a file is served as-is rather than compressed per request. const COMPRESS_MAX_SIZE = 10 * 1024 * 1024; +// Ten bytes, each its own index, so a served slice reads back as its offsets. +const RANGE_BODY = "0123456789"; + beforeAll(async () => { tmp = await mkdtemp(join(tmpdir(), "srvx-static-")); @@ -90,6 +93,9 @@ beforeAll(async () => { await writeFile(join(dir, "big.png"), BIG_JS); await writeFile(join(dir, "big.html"), `

${"x".repeat(2048)}

`); + // A 10-byte body whose every slice is legible, for byte-range assertions. + await writeFile(join(dir, "range.txt"), RANGE_BODY); + // Extension-less files, reachable at their exact name. await writeFile(join(dir, "LICENSE"), "LICENSE_BODY"); await writeFile(join(dir, "apple-app-site-association"), "AASA_BODY"); @@ -1133,6 +1139,194 @@ describe("serveStatic", () => { }); }); + describe("byte ranges", () => { + const fetchRange = (path: string, range: string, opts: Partial = {}) => + fetchStatic(path, opts, { headers: { range } }); + + test("serves a leading slice as 206", async () => { + const res = await fetchRange("/range.txt", "bytes=0-4"); + expect(res.status).toBe(206); + expect(res.headers.get("content-range")).toBe(`bytes 0-4/${RANGE_BODY.length}`); + expect(res.headers.get("content-length")).toBe("5"); + expect(res.headers.get("accept-ranges")).toBe("bytes"); + await expect(res.text()).resolves.toBe("01234"); + }); + + test("serves an open-ended range to the end", async () => { + const res = await fetchRange("/range.txt", "bytes=5-"); + expect(res.status).toBe(206); + expect(res.headers.get("content-range")).toBe(`bytes 5-9/${RANGE_BODY.length}`); + await expect(res.text()).resolves.toBe("56789"); + }); + + test("serves a suffix range of the last N bytes", async () => { + const res = await fetchRange("/range.txt", "bytes=-5"); + expect(res.status).toBe(206); + expect(res.headers.get("content-range")).toBe(`bytes 5-9/${RANGE_BODY.length}`); + await expect(res.text()).resolves.toBe("56789"); + }); + + test("clamps an end past the file to the last byte", async () => { + const res = await fetchRange("/range.txt", "bytes=0-999999"); + expect(res.status).toBe(206); + expect(res.headers.get("content-range")).toBe(`bytes 0-9/${RANGE_BODY.length}`); + expect(res.headers.get("content-length")).toBe(String(RANGE_BODY.length)); + await expect(res.text()).resolves.toBe(RANGE_BODY); + }); + + test("caps a suffix larger than the file at the whole file", async () => { + const res = await fetchRange("/range.txt", "bytes=-999999"); + expect(res.status).toBe(206); + expect(res.headers.get("content-range")).toBe(`bytes 0-9/${RANGE_BODY.length}`); + await expect(res.text()).resolves.toBe(RANGE_BODY); + }); + + test.each([ + ["a multi-range set", "bytes=0-1,5-6"], + ["a non-numeric range", "bytes=abc"], + ["an inverted range", "bytes=5-2"], + ])("ignores %s and serves the full 200", async (_why, range) => { + const res = await fetchRange("/range.txt", range); + expect(res.status).toBe(200); + expect(res.headers.get("content-range")).toBe(null); + expect(res.headers.get("accept-ranges")).toBe("bytes"); + await expect(res.text()).resolves.toBe(RANGE_BODY); + }); + + test("ignores a non-bytes unit without disturbing negotiation", async () => { + // Not a range request at all, so the normal on-the-fly encoding still runs. + const res = await fetchStatic( + "/big.js", + {}, + { headers: { range: "items=0-1", "accept-encoding": "br" } }, + ); + expect(res.status).toBe(200); + expect(res.headers.get("content-range")).toBe(null); + expect(res.headers.get("content-encoding")).toBe("br"); + }); + + test.each([ + ["a start past the file", "bytes=999999-"], + ["a zero-length suffix", "bytes=-0"], + ])("answers %s with 416", async (_why, range) => { + const res = await fetchRange("/range.txt", range); + expect(res.status).toBe(416); + expect(res.headers.get("content-range")).toBe(`bytes */${RANGE_BODY.length}`); + // A 416 is a plain error: no validators or representation headers ride it. + expect(res.headers.get("etag")).toBe(null); + expect(res.headers.get("last-modified")).toBe(null); + await expect(res.text()).resolves.toBe(""); + }); + + test("applies the range when If-Range matches Last-Modified", async () => { + const lastModified = (await fetchStatic("/range.txt")).headers.get("last-modified")!; + const res = await fetchStatic( + "/range.txt", + {}, + { headers: { range: "bytes=0-4", "if-range": lastModified } }, + ); + expect(res.status).toBe(206); + await expect(res.text()).resolves.toBe("01234"); + }); + + test("serves the full 200 when If-Range is a stale date", async () => { + const res = await fetchStatic( + "/range.txt", + {}, + { headers: { range: "bytes=0-4", "if-range": new Date(0).toUTCString() } }, + ); + expect(res.status).toBe(200); + expect(res.headers.get("content-range")).toBe(null); + await expect(res.text()).resolves.toBe(RANGE_BODY); + }); + + test("serves the full 200 when If-Range carries the weak ETag", async () => { + // Our tags are weak, and If-Range demands a strong comparison, so an + // entity-tag If-Range never matches — the whole representation is served. + const etag = (await fetchStatic("/range.txt")).headers.get("etag")!; + expect(etag).toMatch(/^W\//); + const res = await fetchStatic( + "/range.txt", + {}, + { headers: { range: "bytes=0-4", "if-range": etag } }, + ); + expect(res.status).toBe(200); + expect(res.headers.get("content-range")).toBe(null); + await expect(res.text()).resolves.toBe(RANGE_BODY); + }); + + test("ignores the Range header on a HEAD request", async () => { + const res = await fetchStatic( + "/range.txt", + {}, + { method: "HEAD", headers: { range: "bytes=0-4" } }, + ); + expect(res.status).toBe(200); + expect(res.headers.get("content-range")).toBe(null); + expect(res.headers.get("content-length")).toBe(String(RANGE_BODY.length)); + expect(res.headers.get("accept-ranges")).toBe("bytes"); + await expect(res.text()).resolves.toBe(""); + }); + + test("serves identity bytes for a range on a compressible file", async () => { + // A range bypasses negotiation: the client accepts br/gzip, but the 206 is + // the raw bytes with no Content-Encoding. + const res = await fetchStatic( + "/big.js", + {}, + { headers: { range: "bytes=0-9", "accept-encoding": "br, gzip" } }, + ); + expect(res.status).toBe(206); + expect(res.headers.get("content-encoding")).toBe(null); + expect(res.headers.get("accept-ranges")).toBe("bytes"); + await expect(res.text()).resolves.toBe(BIG_JS.slice(0, 10)); + }); + + test("ignores a precompressed variant for a range request", async () => { + // `/big-gz.js` has a `.gz` beside it and the lookup is enabled, but a range + // is served identity-only — the disk variant's marker never appears. + const res = await fetchStatic( + "/big-gz.js", + { encodings: true }, + { headers: { range: "bytes=0-9", "accept-encoding": "br, gzip" } }, + ); + expect(res.status).toBe(206); + expect(res.headers.get("content-encoding")).toBe(null); + await expect(res.text()).resolves.toBe(BIG_JS.slice(0, 10)); + }); + + test("lets a matching If-None-Match 304 win over the Range", async () => { + const etag = (await fetchStatic("/range.txt")).headers.get("etag")!; + const res = await fetchStatic( + "/range.txt", + {}, + { headers: { "if-none-match": etag, range: "bytes=0-4" } }, + ); + expect(res.status).toBe(304); + expect(res.headers.get("content-range")).toBe(null); + await expect(res.text()).resolves.toBe(""); + }); + + test("serves the full 200 for a range request with ranges: false", async () => { + const res = await fetchRange("/range.txt", "bytes=0-4", { ranges: false }); + expect(res.status).toBe(200); + expect(res.headers.get("content-range")).toBe(null); + expect(res.headers.get("accept-ranges")).toBe(null); + await expect(res.text()).resolves.toBe(RANGE_BODY); + }); + + test("advertises Accept-Ranges on an identity 200 but not a compressed one", async () => { + expect((await fetchStatic("/range.txt")).headers.get("accept-ranges")).toBe("bytes"); + // An on-the-fly encoded response omits it. + const encoded = await fetchEncoded("/big.js", "br"); + expect(encoded.headers.get("content-encoding")).toBe("br"); + expect(encoded.headers.get("accept-ranges")).toBe(null); + // As does a precompressed variant. + const variant = await fetchVariant("/app.js", "br"); + expect(variant.headers.get("accept-ranges")).toBe(null); + }); + }); + describe("unresolved pathname", () => { // Every request here bypasses `new Request()` — see `rawReq`. Without that, // the pathname arrives already collapsed and these assert nothing: they pass From 9f9d7346b01a8b16dfc782291d5a38986bafabcb Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 17 Jul 2026 21:58:03 +0000 Subject: [PATCH 2/3] fix(static): accept only the emitted IMF-fixdate in If-Range's date form Co-Authored-By: Claude Fable 5 --- src/static.ts | 11 ++++++----- test/static.test.ts | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/static.ts b/src/static.ts index e3f67c5d..f1116492 100644 --- a/src/static.ts +++ b/src/static.ts @@ -698,16 +698,17 @@ function matchesIfModifiedSince(header: string | null, lastModifiedMs: number): // RFC 9110 §13.1.5 — whether an `If-Range` lets the range apply. The entity-tag // form (a value opening with `"` or `W/`) requires a strong comparison, which // our weak tags can never pass, so it always drops the range to a full 200. The -// HTTP-date form is a strong date comparison: it matches only when the date is -// the exact second we emit as `Last-Modified` (`lastModifiedMs`, already floored -// to the second). An unparseable value is a mismatch. A failure here is never an -// error status — it just serves the whole representation. +// HTTP-date form is a strong date comparison, taken literally: the only date a +// client can legitimately hold is the IMF-fixdate this middleware emitted as +// `Last-Modified`, so match that exact string — not whatever `Date.parse` also +// accepts (an ISO timestamp naming the same second included). A failure here is +// never an error status — it just serves the whole representation. function matchesIfRange(header: string, lastModifiedMs: number): boolean { const value = header.trim(); if (value.startsWith('"') || value.startsWith("W/")) { return false; } - return Date.parse(value) === lastModifiedMs; + return value === new Date(lastModifiedMs).toUTCString(); } // Parse a single `Range: bytes=...` against `size` (the fd's `fstat`, the bytes diff --git a/test/static.test.ts b/test/static.test.ts index f413e0ab..4f93dd7b 100644 --- a/test/static.test.ts +++ b/test/static.test.ts @@ -1229,6 +1229,22 @@ describe("serveStatic", () => { await expect(res.text()).resolves.toBe("01234"); }); + test("serves the full 200 when If-Range is not an HTTP-date", async () => { + // Strong date comparison, taken literally: only the IMF-fixdate emitted as + // `Last-Modified` matches, so an ISO timestamp naming the same second — + // which `Date.parse` would happily read — does not revalidate the range. + const lastModified = (await fetchStatic("/range.txt")).headers.get("last-modified")!; + const iso = new Date(lastModified).toISOString(); + const res = await fetchStatic( + "/range.txt", + {}, + { headers: { range: "bytes=0-4", "if-range": iso } }, + ); + expect(res.status).toBe(200); + expect(res.headers.get("content-range")).toBe(null); + await expect(res.text()).resolves.toBe(RANGE_BODY); + }); + test("serves the full 200 when If-Range is a stale date", async () => { const res = await fetchStatic( "/range.txt", From 3d6f39eadf62e83f68142667982ce078ce1009e2 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 17 Jul 2026 22:15:12 +0000 Subject: [PATCH 3/3] refactor(static): read the Range header lazily and collapse parseRange's grammar Co-Authored-By: Claude Fable 5 --- src/static.ts | 72 +++++++++++++++++++++++---------------------------- 1 file changed, 33 insertions(+), 39 deletions(-) diff --git a/src/static.ts b/src/static.ts index f1116492..c3c4bc24 100644 --- a/src/static.ts +++ b/src/static.ts @@ -360,15 +360,15 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { } // Parsed lazily: unmatched routes (all non-static traffic) never need it. let acceptEncodings: EncodingSpec[] | undefined; - // A range request bypasses negotiation entirely (identity bytes only): a - // range over a chunked on-the-fly encoding is not expressible, and range - // consumers — media seek, download resumption — target already-compressed - // types. GET only, as RFC 9110 defines range handling for GET alone, so HEAD - // (and any other configured method) ignores the header. Only a `bytes=` unit - // is a range request; an unknown unit is treated as if no header were sent - // and leaves negotiation untouched. - const rangeHeader = ranges && req.method === "GET" ? req.headers.get("range") : null; - const rangeRequest = rangeHeader !== null && rangeHeader.startsWith("bytes="); + // The `Range` header when this is a range request, `""` otherwise. A range + // request bypasses negotiation entirely (identity bytes only): a range over + // a chunked on-the-fly encoding is not expressible, and range consumers — + // media seek, download resumption — target already-compressed types. GET + // only, as RFC 9110 defines range handling for GET alone, so HEAD (and any + // other configured method) ignores the header. Only a `bytes=` unit is a + // range request; an unknown unit is treated as if no header were sent and + // leaves negotiation untouched. + let rangeRequest: string | undefined; for (const candidate of paths) { const filePath = join(dir, candidate); // Cheap lexical pre-filter — `isServable` is the real boundary. Also @@ -390,6 +390,13 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { // routes are excluded too: a variant on disk would not match the rendered // output, and the rendered `Response` is the caller's to encode. const compressible = !renderHTML && isCompressible(contentType); + // Resolved lazily, like `acceptEncodings`: the Node adapter materializes + // its headers wrapper on the first `.get()`, and pure-miss traffic (every + // non-static route falling through to `next()`) touches no header at all. + if (rangeRequest === undefined) { + const header = ranges && req.method === "GET" ? req.headers.get("range") : null; + rangeRequest = header !== null && header.startsWith("bytes=") ? header : ""; + } let encoding = ""; let servePath = filePath; @@ -572,7 +579,7 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { // exact-second match can only reproduce a date the server itself sent. const ifRange = req.headers.get("if-range"); if (ifRange === null || matchesIfRange(ifRange, lastModifiedMs)) { - const parsed = parseRange(rangeHeader!, file.size); + const parsed = parseRange(rangeRequest, file.size); if (parsed === "unsatisfiable") { await file.handle.close().catch(() => {}); // A 416 is a plain error: like the 412 path it drops the @@ -721,27 +728,21 @@ function parseRange( header: string, size: number, ): { start: number; end: number } | "unsatisfiable" | null { - // The caller only reaches here on a `bytes=` header, but re-check so the - // helper stands alone. A non-`bytes` unit is not a range request at all. - if (!header.startsWith("bytes=")) { - return null; - } - const spec = header.slice(6); - // A single range only: a comma means a multi-range set, ignored wholesale. - if (spec.includes(",")) { - return null; - } - const dash = spec.indexOf("-"); - if (dash === -1) { + // One anchored pattern is the whole grammar: the `bytes=` unit (re-checked so + // the helper stands alone), then digits-or-empty on each side of the dash. A + // non-`bytes` unit, malformed syntax, and a multi-range set (a comma can't + // match `\d*`) all fall out as a non-match. + const match = /^bytes=(\d*)-(\d*)$/.exec(header); + if (!match) { return null; } - const startStr = spec.slice(0, dash); - const endStr = spec.slice(dash + 1); + const startStr = match[1]!; + const endStr = match[2]!; // Suffix form `-N`: the final N bytes. `-0` asks for zero bytes and is // unsatisfiable; N past the file size is the whole file. An empty file cannot - // satisfy any range. + // satisfy any range. (A bare `bytes=-`, both sides empty, is malformed.) if (startStr === "") { - if (!/^\d+$/.test(endStr)) { + if (endStr === "") { return null; } const n = Number(endStr); @@ -750,24 +751,17 @@ function parseRange( } return { start: n >= size ? 0 : size - n, end: size - 1 }; } - if (!/^\d+$/.test(startStr)) { - return null; - } const start = Number(startStr); // `A-` runs to the end; `A-B` clamps B to the last byte. `A > B` is malformed. - let end: number; - if (endStr === "") { - end = size - 1; - } else if (/^\d+$/.test(endStr)) { - end = Number(endStr); - if (end < start) { + let end = size - 1; + if (endStr !== "") { + const to = Number(endStr); + if (to < start) { return null; } - if (end > size - 1) { - end = size - 1; + if (to < end) { + end = to; } - } else { - return null; } // Satisfiable only when the start falls within the file (which also rejects // every range on an empty file).