v4: the API's JSON and the page are sent compressed — a page of the feed went out as it was, twenty kilobytes where a fifth does: only the built frontend's files were compressed, at build time, and Deno's server leaves alone a response whose length Oak has set. compressMiddleware (api/middleware/compress.ts), installed after the error middleware and around everything that sets a body, sends an object or array, and a string whose type its route set, in brotli or gzip as the request's Accept-Encoding takes, a coding refused with q=0 not being taken, from a kilobyte up, the threshold the build compresses from. Brotli is at quality 5, the frontend's files, compressed once, staying at 11. Files, streams and bytes, a response already encoded, a HEAD and one marked no-transform go out as they are, and a compressible answer says Vary: Accept-Encoding whether compressed or not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-10 19:01:06 +00:00
parent 1bd5c924b0
commit 6ab398e687
4 changed files with 205 additions and 1 deletions

3
API.md
View File

@@ -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.

View File

@@ -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

108
api/middleware/compress.ts Normal file
View File

@@ -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<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);
}

View File

@@ -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 = `<!doctype html>${"<p>gerbeur</p>".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("<!doctype"), true);
});
Deno.test("compressMiddleware: short answers, bytes and encoded files are left alone", async () => {
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));
});