diff --git a/src/static.ts b/src/static.ts index 424c5e01..c3c4bc24 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; + // 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 @@ -366,11 +390,18 @@ 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; 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 +436,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 +487,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 +565,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(rangeRequest, 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 +664,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 +702,72 @@ 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, 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 value === new Date(lastModifiedMs).toUTCString(); +} + +// 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 { + // 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 = 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. (A bare `bytes=-`, both sides empty, is malformed.) + if (startStr === "") { + if (endStr === "") { + return null; + } + const n = Number(endStr); + if (n === 0 || size === 0) { + return "unsatisfiable"; + } + return { start: n >= size ? 0 : size - n, end: size - 1 }; + } + const start = Number(startStr); + // `A-` runs to the end; `A-B` clamps B to the last byte. `A > B` is malformed. + let end = size - 1; + if (endStr !== "") { + const to = Number(endStr); + if (to < start) { + return null; + } + if (to < end) { + end = to; + } + } + // 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..4f93dd7b 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"), `