import type { IncomingMessage } from "node:http"; import type { Etag } from "./etag"; type EtagMatchType = | { type: "match"; value: Etag[]; } | { type: "wildcard"; }; class EtagMatch { private constructor(private readonly etagMatch: EtagMatchType) {} static parse(etagMatch: string | undefined): EtagMatch | null { if (etagMatch === undefined) return null; if (etagMatch === "*") { return new EtagMatch({ type: "wildcard" }); } else { return new EtagMatch({ type: "match", value: etagMatch.split(",").map((etag) => etag.trim() as Etag), }); } } matches(etag: Etag): boolean { switch (this.etagMatch.type) { case "match": return this.etagMatch.value.includes(etag); case "wildcard": return true; } } } class DateMatch { private constructor(private readonly mtime: Date) {} static parse(dateMatch: string | undefined): DateMatch | null { if (dateMatch === undefined) return null; const time = Date.parse(dateMatch); if (isNaN(time)) return null; return new DateMatch(new Date(time)); } matches(mtime: Date): boolean { return this.mtime.getTime() / 1_000 === mtime.getTime() / 1_000; } } export class Options { /** * See * * Examples: * * ```text * If-Match: "xyzzy" * If-Match: "xyzzy", "r2d2xxxx", "c3piozzzz" * If-Match: * * ``` */ private ifMatch: EtagMatch | null; /** * See * * Examples: * * ```text * If-None-Match: "xyzzy" * If-None-Match: "xyzzy", "r2d2xxxx", "c3piozzzz" * If-None-Match: * * ``` */ private ifNoneMatch: EtagMatch | null; /** * See * * Examples: * * ```text * If-Modified-Since: Mon, 07 Jan 2002 19:43:36 GMT * ``` */ private ifModifiedSince: DateMatch | null; /** * See * * Examples: * * ```text * If-Unmodified-Since: Mon, 07 Jan 2002 19:43:36 GMT * ``` */ private ifUnmodifiedSince: DateMatch | null; constructor(request: IncomingMessage) { this.ifMatch = EtagMatch.parse(request.headers["if-match"]); this.ifNoneMatch = EtagMatch.parse(request.headers["if-none-match"]); this.ifModifiedSince = DateMatch.parse(request.headers["if-modified-since"]); this.ifUnmodifiedSince = DateMatch.parse(request.headers["if-unmodified-since"]); } /** * RFC 9110 section 13.1.1: If-Match precondition evaluation * RFC 9110 section 13.1.4: If-Unmodified-Since precondition evaluation * See * See */ preconditionFailed(etag: Etag, mtime: Date): boolean { // If-Match precondition takes precedence if (this.ifMatch !== null && !this.ifMatch.matches(etag)) { return true; } // If-Unmodified-Since precondition (only if no If-Match header) if (this.ifMatch === null && this.ifUnmodifiedSince !== null) { return !this.ifUnmodifiedSince.matches(mtime); } return false; } /** * RFC 9110 section 13.1.2: If-None-Match precondition evaluation * RFC 9110 section 13.1.3: If-Modified-Since precondition evaluation * See * See */ notModified(etag: Etag, mtime: Date): boolean { // If-None-Match takes precedence over If-Modified-Since if (this.ifNoneMatch !== null) { return this.ifNoneMatch.matches(etag); } // If-Modified-Since (only if no If-None-Match header) if (this.ifModifiedSince !== null) { return this.ifModifiedSince.matches(mtime); } return false; } }