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:
3
API.md
3
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
|
thumbnail, waveform, cover or attachment takes the owner's media token as
|
||||||
`?token=`.
|
`?token=`.
|
||||||
|
|
||||||
|
JSON answers of a kilobyte or more are compressed, in brotli or gzip, for a
|
||||||
|
client that accepts it.
|
||||||
|
|
||||||
### me
|
### me
|
||||||
|
|
||||||
- `GET /api/me` — signed in. The caller's own user.
|
- `GET /api/me` — signed in. The caller's own user.
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
* jobs and listens on {@link LISTEN_HOST}:{@link PORT}, having first signed
|
* 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
|
* the users out of other instances if they may no longer sign in there
|
||||||
* (`FEDERATION.outgoing`). A request goes
|
* (`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,
|
* per resource of the API (`API.md` lists them), and last the built frontend,
|
||||||
* which answers every path no router did.
|
* which answers every path no router did.
|
||||||
*
|
*
|
||||||
@@ -46,6 +47,7 @@ import {
|
|||||||
PORT,
|
PORT,
|
||||||
PUBLIC_URL,
|
PUBLIC_URL,
|
||||||
} from "./config.ts";
|
} from "./config.ts";
|
||||||
|
import { compressMiddleware } from "./middleware/compress.ts";
|
||||||
import { errorMiddleware } from "./middleware/error.ts";
|
import { errorMiddleware } from "./middleware/error.ts";
|
||||||
import { ogMiddleware } from "./middleware/og.ts";
|
import { ogMiddleware } from "./middleware/og.ts";
|
||||||
import { routeStaticFilesFrom } from "./lib/static.ts";
|
import { routeStaticFilesFrom } from "./lib/static.ts";
|
||||||
@@ -68,6 +70,7 @@ const app: Application = new Application();
|
|||||||
const cors = oakCors({ origin: ALLOWED_ORIGINS });
|
const cors = oakCors({ origin: ALLOWED_ORIGINS });
|
||||||
|
|
||||||
app.use(errorMiddleware);
|
app.use(errorMiddleware);
|
||||||
|
app.use(compressMiddleware);
|
||||||
app.use(ogMiddleware);
|
app.use(ogMiddleware);
|
||||||
// oakCors calls next() without awaiting it when there is no origin, which
|
// 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
|
// corrupts the response in Oak's async chain. So it only sees requests from an
|
||||||
|
|||||||
108
api/middleware/compress.ts
Normal file
108
api/middleware/compress.ts
Normal 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);
|
||||||
|
}
|
||||||
90
api/test/middleware/compress.test.ts
Normal file
90
api/test/middleware/compress.test.ts
Normal 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));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user