Compression across the board: directory answers, uploads, metrics, traces, the page (#253)

Measured 2026-10-16, uncompressed until now: the directory's answers (the feed list ipx reads,
603 KB of JSON, ~134 ms of a 159 ms Directory request), ipx's snapshot upload (~100 KB each half
hour), both /metrics (4.7 and 0.9 KB every 15 s) and every trace export to Tempo. Already
compressed: ipx-web's answers (zstd, #239), the home page and the directory's public pages
through Cloudflare (brotli, zstd).

- The directory compresses with brotli, zstd or gzip as asked, and unpacks a gzipped request.
- ipx sends its snapshot gzipped; the directory is deployed first.
- Both /metrics answer gzipped, as Prometheus asks.
- opentelemetry-otlp's gzip-http: OTEL_EXPORTER_OTLP_COMPRESSION=gzip compresses the export, set
  for all three in production's compose file; Caddy's exporter reads the same variable.
- web/build.mjs writes .br (quality 11) and .gz (level 9) beside each text file, and Caddy serves
  them in place (file_server precompressed): app.js 30 KB where zstd on the fly gave 37, app.css
  11 where 14. A .br or .gz asked for by name is refused, or /admin.js.br would be the admin
  page's script without the check that keeps it to admins; in a handle of its own, since a bare
  respond runs after the handles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 14:02:39 +00:00
parent 3d94bce4f5
commit 1949f3e493
14 changed files with 117 additions and 8 deletions

View File

@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- The page reports how long each list you open takes to show, from the click to the list drawn, for the dashboard (#246).
- The page's files are compressed once when built, brotli at its strongest and gzip, and sent in place of the files: the script 30 KB where it was 37, the stylesheet 11 where it was 14. Traces go to the collector compressed (#253).
- The page and the API's answers are sent compressed, zstd or gzip, which the feed list and item lists, over 100 KB each, were not; live updates and audio are not compressed (#239).
## [1.0.2] - 2026-10-15

View File

@@ -63,6 +63,15 @@ http://{$WEB_BIND::8080} {
@compress not path /api/events /media/*
encode @compress zstd gzip
# The copies web/build.mjs compressed are served in place of their file, never asked for by
# name: /admin.js.br would be the admin page's script without the check below that keeps it to
# admins (#253).
# A handle of its own: a bare respond runs after the handles, and the last one serves any file.
@packed path *.br *.gz
handle @packed {
respond 404
}
# Before anything else, so no page or API call sees them: only ipx sets these.
@forged not remote_ip {$TUNNEL_PEER:255.255.255.255}
request_header @forged -Cf-Access-Authenticated-User-Email
@@ -97,7 +106,9 @@ http://{$WEB_BIND::8080} {
rewrite / /index.html
rewrite /admin /admin.html
header Cache-Control no-cache
file_server
file_server {
precompressed br gzip
}
}
# The sign-in form, except for someone Cloudflare Access vouches for, who is sent to the app.
@@ -108,7 +119,9 @@ http://{$WEB_BIND::8080} {
}
rewrite * /login.html
header Cache-Control no-cache
file_server
file_server {
precompressed br gzip
}
}
# Scripts and the stylesheet are named by a hash of their contents (web/build.mjs), so a browser
@@ -120,6 +133,8 @@ http://{$WEB_BIND::8080} {
@icons path *.png *.svg *.ico *.woff2
header @icons Cache-Control "public, max-age=86400"
handle {
file_server
file_server {
precompressed br gzip
}
}
}

View File

@@ -12,6 +12,7 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import zlib from 'node:zlib';
import { fileURLToPath } from 'node:url';
import swc from '@swc/core';
import html from '@swc/html';
@@ -113,4 +114,13 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
fs.writeFileSync(path.join(out, name), html);
fs.writeFileSync(path.join(out, script), js);
}
// Each text file compressed once here, as hard as it goes, brotli and gzip beside it, for Caddy
// to send in its place (#253): smaller than compressing each answer as it goes, and no work then.
for (const f of fs.readdirSync(out).filter(f => /\.(html|js|css|svg|txt)$/.test(f))) {
const body = fs.readFileSync(path.join(out, f));
fs.writeFileSync(path.join(out, f + '.br'), zlib.brotliCompressSync(body, {
params: { [zlib.constants.BROTLI_PARAM_QUALITY]: zlib.constants.BROTLI_MAX_QUALITY, [zlib.constants.BROTLI_PARAM_SIZE_HINT]: body.length },
}));
fs.writeFileSync(path.join(out, f + '.gz'), zlib.gzipSync(body, { level: 9 }));
}
}