mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
Add Brotli-first API compression for sidecar and nginx
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
34
deploy/nginx/brotli-api-proxy.conf
Normal file
34
deploy/nginx/brotli-api-proxy.conf
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user