Files
gerbeur/api/middleware/compress.ts

109 lines
3.7 KiB
TypeScript

/**
* 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<string>();
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<void> {
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);
}