130 lines
4.9 KiB
TypeScript
130 lines
4.9 KiB
TypeScript
import type { Stats } from "node:fs";
|
|
import { stat } from "node:fs/promises";
|
|
|
|
export type StaticContentEncoding = "br" | "gzip" | "identity";
|
|
|
|
export type StaticAssetRepresentation = {
|
|
encoding: StaticContentEncoding;
|
|
filePath: string;
|
|
stat: Stats;
|
|
};
|
|
|
|
export type StaticAssetMetadata = {
|
|
identity: StaticAssetRepresentation;
|
|
br?: StaticAssetRepresentation;
|
|
gzip?: StaticAssetRepresentation;
|
|
};
|
|
|
|
const metadataCache = new Map<string, Promise<StaticAssetMetadata | null>>();
|
|
|
|
export async function getStaticAssetMetadata(filePath: string): Promise<StaticAssetMetadata | null> {
|
|
const cached = metadataCache.get(filePath);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
|
|
const pending = loadStaticAssetMetadata(filePath);
|
|
metadataCache.set(filePath, pending);
|
|
const metadata = await pending;
|
|
if (metadata === null) {
|
|
// Do not let attacker-controlled misses grow a process-lifetime cache.
|
|
metadataCache.delete(filePath);
|
|
}
|
|
return metadata;
|
|
}
|
|
|
|
async function loadStaticAssetMetadata(filePath: string): Promise<StaticAssetMetadata | null> {
|
|
const identityStat = await stat(filePath).catch(() => null);
|
|
if (!identityStat?.isFile()) {
|
|
return null;
|
|
}
|
|
|
|
const [brStat, gzipStat] = await Promise.all([stat(`${filePath}.br`).catch(() => null), stat(`${filePath}.gz`).catch(() => null)]);
|
|
|
|
return {
|
|
identity: { encoding: "identity", filePath, stat: identityStat },
|
|
...(brStat?.isFile() ? { br: { encoding: "br" as const, filePath: `${filePath}.br`, stat: brStat } } : {}),
|
|
...(gzipStat?.isFile() ? { gzip: { encoding: "gzip" as const, filePath: `${filePath}.gz`, stat: gzipStat } } : {}),
|
|
};
|
|
}
|
|
|
|
export function selectStaticRepresentation(metadata: StaticAssetMetadata, acceptEncoding: string | undefined): StaticAssetRepresentation {
|
|
const accepted = parseAcceptEncoding(acceptEncoding);
|
|
const candidates = [
|
|
metadata.br ? { quality: accepted.get("br") ?? accepted.get("*") ?? 0, representation: metadata.br, preference: 2 } : null,
|
|
metadata.gzip ? { quality: accepted.get("gzip") ?? accepted.get("*") ?? 0, representation: metadata.gzip, preference: 1 } : null,
|
|
]
|
|
.filter((candidate): candidate is NonNullable<typeof candidate> => candidate !== null && candidate.quality > 0)
|
|
.sort((left, right) => right.quality - left.quality || right.preference - left.preference);
|
|
|
|
return candidates[0]?.representation ?? metadata.identity;
|
|
}
|
|
|
|
function parseAcceptEncoding(value: string | undefined): Map<string, number> {
|
|
const accepted = new Map<string, number>();
|
|
for (const item of value?.split(",") ?? []) {
|
|
const [rawEncoding, ...parameters] = item.trim().toLowerCase().split(";");
|
|
if (!rawEncoding) continue;
|
|
let quality = 1;
|
|
for (const parameter of parameters) {
|
|
const match = /^q\s*=\s*(0(?:\.\d+)?|1(?:\.0+)?)$/.exec(parameter.trim());
|
|
if (match) {
|
|
quality = Number(match[1]);
|
|
}
|
|
}
|
|
accepted.set(rawEncoding, quality);
|
|
}
|
|
return accepted;
|
|
}
|
|
|
|
export function createStaticEtag(representation: StaticAssetRepresentation): string {
|
|
const size = representation.stat.size.toString(16);
|
|
const modified = Math.trunc(representation.stat.mtimeMs).toString(16);
|
|
return `W/"${size}-${modified}-${representation.encoding}"`;
|
|
}
|
|
|
|
export function isStaticAssetNotModified(input: { etag: string; ifModifiedSince?: string; ifNoneMatch?: string; mtimeMs: number }): boolean {
|
|
if (input.ifNoneMatch !== undefined) {
|
|
const expected = normalizeWeakEtag(input.etag);
|
|
return input.ifNoneMatch
|
|
.split(",")
|
|
.map((value) => value.trim())
|
|
.some((value) => value === "*" || normalizeWeakEtag(value) === expected);
|
|
}
|
|
|
|
if (input.ifModifiedSince === undefined) {
|
|
return false;
|
|
}
|
|
const since = Date.parse(input.ifModifiedSince);
|
|
if (!Number.isFinite(since)) {
|
|
return false;
|
|
}
|
|
return Math.floor(input.mtimeMs / 1000) <= Math.floor(since / 1000);
|
|
}
|
|
|
|
function normalizeWeakEtag(value: string): string {
|
|
return value.trim().replace(/^W\//i, "");
|
|
}
|
|
|
|
export function getStaticCacheControl(pathname: string, localeIsRevisioned: boolean): string {
|
|
if (pathname === "/assets" || pathname.startsWith("/assets/")) {
|
|
return "public, max-age=31536000, immutable";
|
|
}
|
|
if (pathname === "/locales" || pathname.startsWith("/locales/")) {
|
|
return localeIsRevisioned ? "public, max-age=31536000, immutable" : "public, max-age=300, must-revalidate, stale-while-revalidate=86400";
|
|
}
|
|
if (pathname === "/fonts" || pathname.startsWith("/fonts/") || pathname === "/images" || pathname.startsWith("/images/")) {
|
|
return "public, max-age=86400, stale-while-revalidate=604800";
|
|
}
|
|
return "public, max-age=0, must-revalidate";
|
|
}
|
|
|
|
export function hasCompressedRepresentation(metadata: StaticAssetMetadata): boolean {
|
|
return metadata.br !== undefined || metadata.gzip !== undefined;
|
|
}
|
|
|
|
/** Test-only hook for deterministic metadata-cache assertions. */
|
|
export function __resetStaticAssetMetadataCacheForTests(): void {
|
|
metadataCache.clear();
|
|
}
|