109 lines
3.7 KiB
TypeScript
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);
|
|
}
|