From 6dd08bc5633e65c54db8193d1090d36b130dc6ed Mon Sep 17 00:00:00 2001 From: "pouriade.com" Date: Tue, 27 Oct 2026 19:53:35 -0700 Subject: [PATCH] Compress API answers Answers under /api are gzipped for clients that accept it, through hono/compress. A full chat download is about 70% smaller. Event streams stay uncompressed so events aren't held back, and pages are left alone since Start may stream them. --- CHANGELOG.md | 4 ++++ apps/server/src/app.ts | 5 +++++ apps/server/tests/app.test.ts | 15 ++++++++++++++- apps/server/tests/storage/routes.test.ts | 8 ++++++-- specs/web-ui.md | 2 +- 5 files changed, 30 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89a19d0..340dc4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ What changed in each release of SCN Chat, for the people who use it, run it, or ## [Unreleased] +### Changed + +- The server now compresses its API answers, such as a chat's messages and the chat list, for browsers that accept it, so they download faster without a reverse proxy doing it. Live event streams are left uncompressed. + ## [0.7.0] - 2026-10-27 ### Added diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 92ea0d0..c70c6f4 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -1,6 +1,7 @@ import { randomBytes } from 'node:crypto' import { serveStatic } from '@hono/node-server/serve-static' import { Hono, type MiddlewareHandler } from 'hono' +import { compress } from 'hono/compress' import { sql } from 'kysely' import { siteConfigRoutes } from './admin/config-routes.ts' import { type AdminRoutesDeps, adminRoutes } from './admin/routes.ts' @@ -102,6 +103,10 @@ export function createApp(deps: AppDeps) { ) }) + // A conversation or the chat index can be large JSON. Event streams are left alone, since + // compressing them would hold events back until a block filled. + app.use('/api/*', compress()) + if (deps.auth) { app.use('/api/*', sessionMiddleware(deps.auth), originCheck(config), accessGate(deps.auth)) app.route('/', oauthRoutes(deps.auth)) diff --git a/apps/server/tests/app.test.ts b/apps/server/tests/app.test.ts index 795a80d..135ce11 100644 --- a/apps/server/tests/app.test.ts +++ b/apps/server/tests/app.test.ts @@ -1,7 +1,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { brotliCompressSync, brotliDecompressSync } from 'node:zlib' +import { brotliCompressSync, brotliDecompressSync, gunzipSync } from 'node:zlib' import SqliteDatabase from 'better-sqlite3' import { Hono } from 'hono' import { Kysely, SqliteDialect } from 'kysely' @@ -35,6 +35,19 @@ describe.each(dialects)('GET /api/health on $name', ({ create }) => { }) describe('GET /api/health', () => { + it('compresses the answer for a client that accepts it', async () => { + const db = createSqliteDb() + const app = createApp({ config, db, logger, settings: named(db, 'Test Chat') }) + const res = await app.request('/api/health', { headers: { 'accept-encoding': 'gzip' } }) + expect(res.headers.get('content-encoding')).toBe('gzip') + expect(res.headers.get('vary')).toBe('Accept-Encoding') + expect(JSON.parse(gunzipSync(Buffer.from(await res.arrayBuffer())).toString())).toEqual({ + status: 'ok', + appName: 'Test Chat', + }) + await db.destroy() + }) + it('returns 503 when the database query fails', async () => { const sqlite = new SqliteDatabase(':memory:') const db: Db = new Kysely({ dialect: new SqliteDialect({ database: sqlite }) }) diff --git a/apps/server/tests/storage/routes.test.ts b/apps/server/tests/storage/routes.test.ts index 02a8b30..37d5ad1 100644 --- a/apps/server/tests/storage/routes.test.ts +++ b/apps/server/tests/storage/routes.test.ts @@ -213,10 +213,14 @@ describe('sync on open and the sync button', () => { }) describe('GET /api/events', () => { - it('streams change events for the signed-in user only', async () => { + it('streams change events for the signed-in user only, uncompressed so none wait', async () => { const { app, cookie, events } = await setup('space') const controller = new AbortController() - const res = await app.request('/api/events', { headers: { cookie }, signal: controller.signal }) + const res = await app.request('/api/events', { + headers: { cookie, 'accept-encoding': 'gzip' }, + signal: controller.signal, + }) + expect(res.headers.get('content-encoding')).toBeNull() const reader = res.body?.getReader() if (!reader) throw new Error('no stream') events.emit('conversation:changed', { did: 'did:plc:bob', skey: 'nope' }) diff --git a/specs/web-ui.md b/specs/web-ui.md index 9910779..9d15fc5 100644 --- a/specs/web-ui.md +++ b/specs/web-ui.md @@ -28,7 +28,7 @@ The first build used Vite and React with `wouter` and hand-written data hooks. I The server process runs Hono, and Hono runs Start, so the server and plugins stay unbundled on Node's type stripping. -- **Production.** `vite build` writes `apps/web/dist/client` and `apps/web/dist/server/server.js`, whose default export is a fetch handler. Hono answers `/api`, `/oauth`, `/oauth-client-metadata.json`, `/xrpc`, `/.well-known`, and `/robots.txt` first, then serves files from `dist/client`, then passes every other request to Start's handler. The build writes a brotli and a gzip copy beside each text or wasm file over a kilobyte, which the server sends to a browser that accepts them, and the files under `/assets`, named by a hash of their content, are sent with `Cache-Control: public, max-age=31536000, immutable`. +- **Production.** `vite build` writes `apps/web/dist/client` and `apps/web/dist/server/server.js`, whose default export is a fetch handler. Hono answers `/api`, `/oauth`, `/oauth-client-metadata.json`, `/xrpc`, `/.well-known`, and `/robots.txt` first, then serves files from `dist/client`, then passes every other request to Start's handler. The build writes a brotli and a gzip copy beside each text or wasm file over a kilobyte, which the server sends to a browser that accepts them, and the files under `/assets`, named by a hash of their content, are sent with `Cache-Control: public, max-age=31536000, immutable`. Answers under `/api` are compressed with gzip or deflate as they go, for a client that accepts it, apart from event streams, which would otherwise hold events back. Pages are not, since Start may stream them. - **Loading fast.** - A route's file loads with the app's entry, and only its `component` is split into a chunk of its own, so whatever a route names directly, such as its pending or error page or its loader's queries, stays light. The shared page's skeleton and error page live apart from the page, which draws messages and so brings the Markdown renderer. - The chat routes' head preloads the store's worker and SQLite's wasm, their largest downloads, so they download alongside the app's code rather than once the worker runs, and preconnects to the avatar's image CDN. Their loader starts the models, attachment types, and profile queries without waiting for them, so they don't wait for the page's code. -- knotmirror 0.0.1