/** * Compresses what the routes answer in JSON, and the HTML page, for the * browsers that accept it: a page of the feed is some twenty kilobytes as it * is, and a fifth of that compressed. main.ts installs it after the error * middleware, around everything that sets a body. The built frontend's files * aren't its concern: lib/static.ts sends the copies compressed at build time. * * @module */ import type { Context, Next } from "@oak/oak"; import { promisify } from "node:util"; import { brotliCompress, constants as zlib, gzip } from "node:zlib"; const brotli = promisify(brotliCompress); const gz = promisify(gzip); /** * Below this, the bytes saved aren't worth the work; it is the threshold the * build compresses the frontend's files from (vite.config.ts). */ const MIN_BYTES = 1024; /** * Brotli's quality for a response made on the spot. Past 5 it takes several * times longer for a few percent; the frontend's files, compressed once, are * at 11. */ const BROTLI_QUALITY = 5; /** * Returns the coding to answer with for an `Accept-Encoding` header, brotli * before gzip, or null when the client takes neither. A coding refused with * `q=0` isn't taken. */ function pickCoding(header: string | null): "br" | "gzip" | null { if (!header) return null; const accepted = new Set(); for (const part of header.split(",")) { const [name, ...params] = part.trim().toLowerCase().split(";"); const q = params.map((p) => p.trim().match(/^q=([\d.]+)$/)?.[1]) .find((v) => v !== undefined); if (q === undefined || Number(q) > 0) accepted.add(name.trim()); } return accepted.has("br") ? "br" : accepted.has("gzip") ? "gzip" : null; } /** * Returns the text of a body this middleware compresses, or null for any * other: an object or array, which Oak would send as JSON, or a string whose * type its route has set. Files, streams and bytes are left alone, as they * are images, media and files compressed already. */ function textOf(ctx: Context): string | null { const body = ctx.response.body; if (typeof body === "string") { // Oak guesses an untyped string's type from the string itself, which it // couldn't from its compressed bytes. return ctx.response.type || ctx.response.headers.has("Content-Type") ? body : null; } if (body === null || typeof body !== "object") return null; const proto = Object.getPrototypeOf(body); if (proto !== Object.prototype && !Array.isArray(body)) return null; ctx.response.type = "json"; return JSON.stringify(body); } /** * Sends the response's JSON or text compressed, in brotli or gzip as the * request accepts, when it is at least {@link MIN_BYTES} long. A response * already encoded, a `HEAD`, and one marked `no-transform` go out as they are. */ export async function compressMiddleware( ctx: Context, next: Next, ): Promise { await next(); const headers = ctx.response.headers; if ( ctx.request.method === "HEAD" || headers.has("Content-Encoding") || headers.get("Cache-Control")?.includes("no-transform") ) return; const text = textOf(ctx); if (text === null) return; const raw = new TextEncoder().encode(text); if (raw.length < MIN_BYTES) return; // A cache must not hand the compressed copy to a client that can't read it. headers.append("Vary", "Accept-Encoding"); const coding = pickCoding(ctx.request.headers.get("Accept-Encoding")); if (!coding) return; const compressed = coding === "br" ? await brotli(raw, { params: { [zlib.BROTLI_PARAM_MODE]: zlib.BROTLI_MODE_TEXT, [zlib.BROTLI_PARAM_QUALITY]: BROTLI_QUALITY, [zlib.BROTLI_PARAM_SIZE_HINT]: raw.length, }, }) : await gz(raw); headers.set("Content-Encoding", coding); ctx.response.body = new Uint8Array(compressed); }