Compress our own responses (#288)
* Compress our own responses The bundle went out uncompressed unless a proxy in front did the work: 933 KB on the wire where 311 KB does, on every first load. Both example proxy configs compress, but that only helps deployments that copied them, and the default should not depend on reading the examples. Hono's middleware, with the proxy routes held back. `/api/blob`, `/api/image`, `/api/ics` and `/api/upload` forward somebody else's bytes under a content-length copied from upstream, and issue #76 was a silent truncation caused by exactly that header disagreeing with its body. Re-encoding them would be safe in principle -- the length is dropped and the response goes out chunked -- but they carry attachments and images that are already compressed, so there is nothing to win and a scar to respect. `/api/events` is listed with them even though Hono already skips text/event-stream by content type, so that changing the push route's type cannot quietly start buffering the stream. `/api/health` is excluded for the opposite reason: at 47 bytes gzip made it 73. Hono's size threshold cannot catch that on its own, because it only applies when a response carries a content-length and `c.json()` does not set one. The other JSON routes stay compressed -- a JMAP response has just as unknown a length and can run to hundreds of kilobytes. Verified against the built image: assets come back gzipped with Vary set, 662 KB to 209 KB; /api/events still returns text/event-stream with no content-encoding and delivered a StateChange while mail was being written; health is 47 bytes either way. No user-visible strings, so no catalogue work. * Word the comment for either side compressing The app compresses its own responses as of the follow-on change, so a note saying the bundle ships uncompressed would be wrong as soon as that lands. nginx passes through what the upstream already encoded rather than re-encoding it -- verified single-encoded with both layers active -- so the directives are correct either way and the comment now says so without asserting which side does the work. * Test compression against a fixture, not the web build The compression tests asked for `/` and asserted a gzipped 200. That passes locally, where `web/dist` is lying around from an earlier build, and fails in CI, which runs `npm test` before `npm run build`: with no bundle the shell route serves the "web build not found" fallback, which is short, plain text and correctly uncompressed. The failure read as compression being broken when the tests were simply depending on a build step that had not run. They now build their own static root in a temp directory and point STATIC_DIR at it, in a separate file so the environment is set before the app module is imported. Checked by moving web/dist aside and running the suite the way CI does.
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
/*
|
||||
* A static root of our own, built before the app is imported.
|
||||
*
|
||||
* CI runs `npm test` before `npm run build`, so `web/dist` does not exist when
|
||||
* these run: pointing at it would serve the "web build not found" fallback,
|
||||
* which is short, plain text and rightly uncompressed. That failure looked
|
||||
* exactly like compression being broken.
|
||||
*/
|
||||
const root = mkdtempSync(join(tmpdir(), "ihasmail-compress-"));
|
||||
mkdirSync(join(root, "assets"));
|
||||
const script = `/* ${"x".repeat(40_000)} */\n`;
|
||||
writeFileSync(join(root, "assets", "app.js"), script);
|
||||
writeFileSync(join(root, "index.html"), `<!doctype html><title>t</title>${"<p>hello</p>".repeat(400)}`);
|
||||
|
||||
process.env.STATIC_DIR = root;
|
||||
process.env.STALWART_URL = "http://127.0.0.1:1";
|
||||
const { createApp } = await import("./app.js");
|
||||
|
||||
test("an asset is gzipped when the client asks for it", async () => {
|
||||
const res = await createApp().request("/assets/app.js", { headers: { "accept-encoding": "gzip" } });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.get("content-encoding"), "gzip");
|
||||
assert.match(res.headers.get("vary") ?? "", /accept-encoding/i);
|
||||
});
|
||||
|
||||
test("a client that does not ask for gzip does not get it", async () => {
|
||||
const res = await createApp().request("/assets/app.js", { headers: { "accept-encoding": "identity" } });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.get("content-encoding"), null);
|
||||
});
|
||||
|
||||
test("gzip actually makes the asset smaller", async () => {
|
||||
const plain = await (await createApp().request("/assets/app.js", { headers: { "accept-encoding": "identity" } })).arrayBuffer();
|
||||
const gz = await (await createApp().request("/assets/app.js", { headers: { "accept-encoding": "gzip" } })).arrayBuffer();
|
||||
assert.ok(gz.byteLength < plain.byteLength / 2, `${gz.byteLength} should be well under ${plain.byteLength}`);
|
||||
});
|
||||
|
||||
test("a gzipped response decodes to the bytes we would have sent plain", async () => {
|
||||
const plain = await (await createApp().request("/assets/app.js", { headers: { "accept-encoding": "identity" } })).arrayBuffer();
|
||||
const res = await createApp().request("/assets/app.js", { headers: { "accept-encoding": "gzip" } });
|
||||
const decoded = await new Response(res.body!.pipeThrough(new DecompressionStream("gzip"))).arrayBuffer();
|
||||
assert.deepEqual(Buffer.from(decoded), Buffer.from(plain));
|
||||
});
|
||||
|
||||
test("the app shell is gzipped", async () => {
|
||||
const res = await createApp().request("/", { headers: { "accept-encoding": "gzip" } });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.get("content-encoding"), "gzip");
|
||||
});
|
||||
|
||||
test("proxy routes that forward upstream bytes are never compressed", async () => {
|
||||
// Unauthenticated, so these stop at 401 -- enough to prove the middleware
|
||||
// declines the path, which is what issue #76 was about.
|
||||
const app = createApp();
|
||||
for (const path of ["/api/blob/a/b/c.pdf", "/api/image?url=https://example.com/x.png", "/api/ics?url=https://example.com/x.ics"]) {
|
||||
const res = await app.request(path, { headers: { "accept-encoding": "gzip" } });
|
||||
assert.equal(res.headers.get("content-encoding"), null, `${path} must not be compressed`);
|
||||
}
|
||||
});
|
||||
|
||||
test("the push stream is never compressed", async () => {
|
||||
const res = await createApp().request("/api/events", { headers: { "accept-encoding": "gzip" } });
|
||||
assert.equal(res.headers.get("content-encoding"), null);
|
||||
});
|
||||
|
||||
test("the liveness probe is not compressed, since gzip would make it bigger", async () => {
|
||||
const res = await createApp().request("/api/health", { headers: { "accept-encoding": "gzip" } });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.get("content-encoding"), null);
|
||||
});
|
||||
Reference in New Issue
Block a user