diff --git a/API.md b/API.md index 31344ae6..22c60c56 100644 --- a/API.md +++ b/API.md @@ -105,6 +105,9 @@ status. A list answers `{ "items", "hasMore" }` and takes `?page=` (from 1) and thumbnail, waveform, cover or attachment takes the owner's media token as `?token=`. +JSON answers of a kilobyte or more are compressed, in brotli or gzip, for a +client that accepts it. + ### me - `GET /api/me` — signed in. The caller's own user. diff --git a/api/main.ts b/api/main.ts index 11eb1b20..5e8c2390 100644 --- a/api/main.ts +++ b/api/main.ts @@ -3,7 +3,8 @@ * jobs and listens on {@link LISTEN_HOST}:{@link PORT}, having first signed * the users out of other instances if they may no longer sign in there * (`FEDERATION.outgoing`). A request goes - * through the error middleware, the OG tags and CORS, then the routers, one + * through the error middleware, the compression of what is answered, the OG + * tags and CORS, then the routers, one * per resource of the API (`API.md` lists them), and last the built frontend, * which answers every path no router did. * @@ -46,6 +47,7 @@ import { PORT, PUBLIC_URL, } from "./config.ts"; +import { compressMiddleware } from "./middleware/compress.ts"; import { errorMiddleware } from "./middleware/error.ts"; import { ogMiddleware } from "./middleware/og.ts"; import { routeStaticFilesFrom } from "./lib/static.ts"; @@ -68,6 +70,7 @@ const app: Application = new Application(); const cors = oakCors({ origin: ALLOWED_ORIGINS }); app.use(errorMiddleware); +app.use(compressMiddleware); app.use(ogMiddleware); // oakCors calls next() without awaiting it when there is no origin, which // corrupts the response in Oak's async chain. So it only sees requests from an diff --git a/api/middleware/compress.ts b/api/middleware/compress.ts new file mode 100644 index 00000000..4651616d --- /dev/null +++ b/api/middleware/compress.ts @@ -0,0 +1,108 @@ +/** + * 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); +} diff --git a/api/test/middleware/compress.test.ts b/api/test/middleware/compress.test.ts new file mode 100644 index 00000000..ae8847e9 --- /dev/null +++ b/api/test/middleware/compress.test.ts @@ -0,0 +1,90 @@ +import { BASE_URL } from "../helpers.ts"; +import { assertEquals } from "@std/assert"; +import { Application } from "@oak/oak"; +import { brotliDecompressSync, gunzipSync } from "node:zlib"; +import { compressMiddleware } from "../../middleware/compress.ts"; + +const BIG = { success: true, data: { items: Array(200).fill("dump title") } }; + +function makeApp(): Application { + const app = new Application(); + app.use(compressMiddleware); + app.use((ctx) => { + const path = ctx.request.url.pathname; + if (path === "/big") ctx.response.body = BIG; + if (path === "/small") ctx.response.body = { success: true }; + if (path === "/html") { + ctx.response.type = "text/html"; + ctx.response.body = `${"

gerbeur

".repeat(200)}`; + } + if (path === "/bytes") ctx.response.body = new Uint8Array(4096); + if (path === "/encoded") { + ctx.response.headers.set("Content-Encoding", "br"); + ctx.response.type = "text/css"; + ctx.response.body = "x".repeat(4096); + } + }); + return app; +} + +async function get(path: string, acceptEncoding?: string) { + const res = await makeApp().handle( + new Request(`${BASE_URL}${path}`, { + headers: acceptEncoding ? { "Accept-Encoding": acceptEncoding } : {}, + }), + ); + if (!res) throw new Error("no response"); + return { res, bytes: new Uint8Array(await res.arrayBuffer()) }; +} + +const text = (bytes: Uint8Array) => new TextDecoder().decode(bytes); + +Deno.test("compressMiddleware: JSON goes out in brotli, or gzip for a client without it", async () => { + const br = await get("/big", "gzip, deflate, br"); + assertEquals(br.res.headers.get("Content-Encoding"), "br"); + assertEquals( + br.res.headers.get("Content-Type"), + "application/json; charset=UTF-8", + ); + assertEquals(br.res.headers.get("Vary"), "Accept-Encoding"); + assertEquals(JSON.parse(text(brotliDecompressSync(br.bytes))), BIG); + + const gzip = await get("/big", "gzip"); + assertEquals(gzip.res.headers.get("Content-Encoding"), "gzip"); + assertEquals(JSON.parse(text(gunzipSync(gzip.bytes))), BIG); + + const refused = await get("/big", "br;q=0, gzip;q=0.5"); + assertEquals(refused.res.headers.get("Content-Encoding"), "gzip"); +}); + +Deno.test("compressMiddleware: a client that accepts no coding gets the JSON as it is", async () => { + for (const accept of [undefined, "identity", "br;q=0"]) { + const { res, bytes } = await get("/big", accept); + assertEquals(res.headers.get("Content-Encoding"), null); + // A cache still has to tell this answer from the compressed one. + assertEquals(res.headers.get("Vary"), "Accept-Encoding"); + assertEquals(JSON.parse(text(bytes)), BIG); + } +}); + +Deno.test("compressMiddleware: the HTML page is compressed too", async () => { + const { res, bytes } = await get("/html", "br"); + assertEquals(res.headers.get("Content-Encoding"), "br"); + assertEquals(res.headers.get("Content-Type"), "text/html; charset=UTF-8"); + assertEquals(text(brotliDecompressSync(bytes)).startsWith(" { + const small = await get("/small", "br"); + assertEquals(small.res.headers.get("Content-Encoding"), null); + assertEquals(small.res.headers.get("Vary"), null); + assertEquals(JSON.parse(text(small.bytes)), { success: true }); + + const bytes = await get("/bytes", "br"); + assertEquals(bytes.res.headers.get("Content-Encoding"), null); + assertEquals(bytes.bytes.length, 4096); + + const encoded = await get("/encoded", "br"); + assertEquals(encoded.res.headers.get("Content-Encoding"), "br"); + assertEquals(text(encoded.bytes), "x".repeat(4096)); +});