Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 155 additions & 5 deletions src/static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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 : "";
}
Comment thread
pi0 marked this conversation as resolved.

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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}"`;
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading