diff --git a/README.md b/README.md index 792aa6cc2..9f3c31467 100644 --- a/README.md +++ b/README.md @@ -860,7 +860,7 @@ All three variants run on three platforms that work together: │ ┌───────────────────────────────────┐ │ │ Tauri Desktop (Rust + Node) │ │ │ OS keychain · Token-auth sidecar │ - │ │ 60+ local API handlers · gzip │ + │ │ 60+ local API handlers · br/gzip │ │ │ Cloud fallback · Traffic logging │ │ └───────────────────────────────────┘ │ @@ -883,7 +883,7 @@ All three variants run on three platforms that work together: The Vercel edge functions connect to Railway via `WS_RELAY_URL` (server-side, HTTPS) while browser clients connect via `VITE_WS_RELAY_URL` (client-side, WSS). This separation keeps the relay URL configurable per deployment without leaking server-side configuration to the browser. -All Railway relay responses are gzip-compressed (zlib `gzipSync`) when the client accepts it and the payload exceeds 1KB, reducing egress by ~80% for JSON and XML responses. +All Railway relay responses are gzip-compressed (zlib `gzipSync`) when the client accepts it and the payload exceeds 1KB, reducing egress by ~80% for JSON and XML responses. The desktop local sidecar now prefers Brotli (`br`) and falls back to gzip for payloads larger than 1KB, setting `Content-Encoding` and `Vary: Accept-Encoding` automatically. --- @@ -969,7 +969,7 @@ Static assets use content-hash filenames with 1-year immutable cache headers. Th ### Railway Relay Compression -All relay server responses pass through `gzipSync` when the client accepts gzip and the payload exceeds 1KB. This applies to OpenSky aircraft JSON, RSS XML feeds, UCDP event data, AIS snapshots, and health checks — reducing wire size by approximately 80%. +All relay server responses pass through `gzipSync` when the client accepts gzip and the payload exceeds 1KB. Sidecar API responses prefer Brotli and use gzip fallback with proper `Content-Encoding`/`Vary` headers for the same threshold. This applies to OpenSky aircraft JSON, RSS XML feeds, UCDP event data, AIS snapshots, and health checks — reducing wire size by approximately 50–80%. ### Frontend Polling Intervals diff --git a/deploy/nginx/brotli-api-proxy.conf b/deploy/nginx/brotli-api-proxy.conf new file mode 100644 index 000000000..026efccd5 --- /dev/null +++ b/deploy/nginx/brotli-api-proxy.conf @@ -0,0 +1,34 @@ +# Nginx API proxy compression baseline for WorldMonitor. +# Requires ngx_brotli (or Nginx Plus Brotli module) to be installed. + +# Prefer Brotli for HTTPS clients and keep gzip as fallback. +brotli on; +brotli_comp_level 5; +brotli_min_length 1024; +brotli_types application/json application/javascript text/css text/plain application/xml text/xml; + +gzip on; +gzip_comp_level 5; +gzip_min_length 1024; +gzip_vary on; +gzip_proxied any; +gzip_types application/json application/javascript text/css text/plain application/xml text/xml; + +server { + listen 443 ssl; + server_name api.worldmonitor.local; + + location /api/ { + proxy_pass http://127.0.0.1:8787; + proxy_http_version 1.1; + + # Preserve upstream compression behavior and pass through client preferences. + proxy_set_header Accept-Encoding $http_accept_encoding; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # If upstream sends pre-compressed content, do not decompress. + gunzip off; + } +} diff --git a/src-tauri/sidecar/local-api-server.mjs b/src-tauri/sidecar/local-api-server.mjs index efeec0f01..ced4fe31a 100644 --- a/src-tauri/sidecar/local-api-server.mjs +++ b/src-tauri/sidecar/local-api-server.mjs @@ -3,10 +3,13 @@ import http, { createServer } from 'node:http'; import https from 'node:https'; import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { readdir } from 'node:fs/promises'; -import { gzipSync } from 'node:zlib'; +import { promisify } from 'node:util'; +import { brotliCompress, gzipSync } from 'node:zlib'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; +const brotliCompressAsync = promisify(brotliCompress); + // Monkey-patch globalThis.fetch to force IPv4 for HTTPS requests. // Node.js built-in fetch (undici) tries IPv6 first via Happy Eyeballs. // Government APIs (EIA, NASA FIRMS, FRED) publish AAAA records but their @@ -96,6 +99,28 @@ function json(data, status = 200, extraHeaders = {}) { }); } +function canCompress(headers, body) { + return body.length > 1024 && !headers['content-encoding']; +} + +async function maybeCompressResponseBody(body, headers, acceptEncoding = '') { + if (!canCompress(headers, body)) return body; + const varyValue = headers['vary']; + headers['vary'] = varyValue ? `${varyValue}, Accept-Encoding` : 'Accept-Encoding'; + + if (acceptEncoding.includes('br')) { + headers['content-encoding'] = 'br'; + return brotliCompressAsync(body); + } + + if (acceptEncoding.includes('gzip')) { + headers['content-encoding'] = 'gzip'; + return gzipSync(body); + } + + return body; +} + function isBracketSegment(segment) { return segment.startsWith('[') && segment.endsWith(']'); } @@ -898,10 +923,10 @@ export async function createLocalApiServer(options = {}) { } const acceptEncoding = req.headers['accept-encoding'] || ''; - if (acceptEncoding.includes('gzip') && body.length > 1024) { - body = gzipSync(body); - headers['content-encoding'] = 'gzip'; - headers['vary'] = 'Accept-Encoding'; + body = await maybeCompressResponseBody(body, headers, acceptEncoding); + + if (headers['content-encoding']) { + delete headers['content-length']; } res.writeHead(response.status, headers); diff --git a/src-tauri/sidecar/local-api-server.test.mjs b/src-tauri/sidecar/local-api-server.test.mjs index 0e719468b..59040b017 100644 --- a/src-tauri/sidecar/local-api-server.test.mjs +++ b/src-tauri/sidecar/local-api-server.test.mjs @@ -1,6 +1,7 @@ import { strict as assert } from 'node:assert'; import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; import { createServer } from 'node:http'; +import { brotliDecompressSync, gunzipSync } from 'node:zlib'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; @@ -656,3 +657,86 @@ test('auth-required behavior unchanged — rejects unauthenticated requests when await localApi.cleanup(); } }); + + +test('prefers Brotli compression for payloads larger than 1KB when supported by the client', async () => { + const remote = await setupRemoteServer(); + const localApi = await setupApiDir({ + 'compression-check.js': ` + export default async function handler() { + const payload = { value: 'x'.repeat(3000) }; + return new Response(JSON.stringify(payload), { + status: 200, + headers: { 'content-type': 'application/json' } + }); + } + `, + }); + + const app = await createLocalApiServer({ + port: 0, + apiDir: localApi.apiDir, + remoteBase: remote.remoteBase, + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/api/compression-check`, { + headers: { 'Accept-Encoding': 'gzip, br' }, + }); + assert.equal(response.status, 200); + assert.equal(response.headers.get('content-encoding'), 'br'); + + const compressed = Buffer.from(await response.arrayBuffer()); + const decompressed = brotliDecompressSync(compressed).toString('utf8'); + const body = JSON.parse(decompressed); + assert.equal(body.value.length, 3000); + assert.equal(remote.hits.length, 0); + } finally { + await app.close(); + await localApi.cleanup(); + await remote.close(); + } +}); + +test('uses gzip compression when Brotli is unavailable but gzip is accepted', async () => { + const remote = await setupRemoteServer(); + const localApi = await setupApiDir({ + 'compression-check.js': ` + export default async function handler() { + const payload = { value: 'x'.repeat(3000) }; + return new Response(JSON.stringify(payload), { + status: 200, + headers: { 'content-type': 'application/json' } + }); + } + `, + }); + + const app = await createLocalApiServer({ + port: 0, + apiDir: localApi.apiDir, + remoteBase: remote.remoteBase, + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/api/compression-check`, { + headers: { 'Accept-Encoding': 'gzip' }, + }); + assert.equal(response.status, 200); + assert.equal(response.headers.get('content-encoding'), 'gzip'); + + const compressed = Buffer.from(await response.arrayBuffer()); + const decompressed = gunzipSync(compressed).toString('utf8'); + const body = JSON.parse(decompressed); + assert.equal(body.value.length, 3000); + assert.equal(remote.hits.length, 0); + } finally { + await app.close(); + await localApi.cleanup(); + await remote.close(); + } +});