From e74403e12f17d712bb67617d0eab35dd62326245 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Tue, 29 Sep 2026 22:03:56 +0200 Subject: [PATCH] perf(server): compress the web app's static files and HTML shells Self-hosted Docker installs served the SPA's CSS, JS and HTML shells without any Content-Encoding. Hono's compress() now wraps only the web routes: it is registered after every API, MCP and upload route, so their streams are never buffered or re-encoded. The Vercel app keeps relying on its CDN, which already compresses. --- apps/server/src/http/app.test.ts | 27 +++++++++++++++++++++++++++ apps/server/src/http/app.ts | 6 ++++++ 2 files changed, 33 insertions(+) diff --git a/apps/server/src/http/app.test.ts b/apps/server/src/http/app.test.ts index 572b290a6..479c9e9c4 100644 --- a/apps/server/src/http/app.test.ts +++ b/apps/server/src/http/app.test.ts @@ -1,3 +1,4 @@ +import { gunzipSync } from "node:zlib"; import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ @@ -205,6 +206,32 @@ describe("createApp", () => { expect(mocks.handleWebApp).toHaveBeenCalledWith(request); expect(mocks.serveWebDistStatic).not.toHaveBeenCalled(); }); + + it("compresses the web app's HTML but never API streams or the Vercel app", async () => { + const { createApp } = await import("./app"); + const html = `${"

Reactive Resume

".repeat(200)}`; + const htmlResponse = () => + new Response(html, { + headers: { "Content-Type": "text/html; charset=UTF-8", "Cache-Control": "private, no-store", Vary: "Cookie" }, + }); + const stream = () => new Response("data: x\n\n".repeat(500), { headers: { "Content-Type": "application/json" } }); + mocks.handleWebApp.mockImplementation(async () => htmlResponse()); + mocks.handleRpc.mockImplementation(async () => stream()); + mocks.handleMcp.mockImplementation(async () => stream()); + const headers = { "Accept-Encoding": "br, gzip" }; + + const page = await createApp().request("http://localhost:3000/", { headers }); + const rpc = await createApp().request("http://localhost:3000/api/rpc/agent/chat", { headers }); + const mcp = await createApp().request("http://localhost:3000/mcp", { headers }); + const vercelPage = await createApp({ serveStatic: false }).request("http://localhost:3000/", { headers }); + + expect(page.headers.get("content-encoding")).toBe("gzip"); + expect(page.headers.get("vary")).toBe("Cookie, Accept-Encoding"); + expect(page.headers.get("cache-control")).toBe("private, no-store"); + expect(gunzipSync(Buffer.from(await page.arrayBuffer())).toString()).toBe(html); + for (const response of [rpc, mcp, vercelPage]) expect(response.headers.get("content-encoding")).toBeNull(); + expect(vercelPage.headers.get("vary")).toBe("Cookie"); + }); }); it.each(["/auth/consent", "/auth/consent/", "/auth/login"])("prevents framing or caching %s", async (path) => { diff --git a/apps/server/src/http/app.ts b/apps/server/src/http/app.ts index d554b7cda..f6207de66 100644 --- a/apps/server/src/http/app.ts +++ b/apps/server/src/http/app.ts @@ -3,6 +3,7 @@ import type { Context } from "hono"; import { isIP } from "node:net"; import { getConnInfo } from "@hono/node-server/conninfo"; import { Hono } from "hono"; +import { compress } from "hono/compress"; import { prepareStagedBody, withStagedBody } from "@reactive-resume/api/features/storage/transport"; import { handleMcp } from "../mcp/handler"; import { handleOpenApi } from "../openapi/handler"; @@ -81,6 +82,11 @@ export function createApp(options: AppOptions = {}) { app.on(["GET", "HEAD"], "/sitemap.xml", (c) => handleSitemap({ head: c.req.method === "HEAD" })); app.on(["GET", "HEAD"], "/llms.txt", (c) => handleLlms({ head: c.req.method === "HEAD" })); + // Compresses only the web app's files and HTML shells: every route registered above answers before reaching + // it, so API, MCP, and upload streams are never buffered or re-encoded. Where a CDN serves the static files + // (Vercel), it also compresses at its edge. + if (options.serveStatic !== false) app.use("/*", compress()); + // Must precede the static middleware: serveStatic resolves "/" to dist/index.html and would // return it verbatim, skipping the OpenGraph/Twitter/canonical/JSON-LD injection in handleWebApp. app.on(["GET", "HEAD"], "/", (c) => handleWebApp(c.req.raw));