feat(server): compress the cached OpenSky snapshot for clients that accept gzip

The ~860 KB snapshot is held for an adaptive 9-300 s TTL and was served as
identity bytes to every request; no response in server/ set Content-Encoding or
Vary. Gzip it once per refresh beside the cached body, off the event loop, and
return the body and the header that names it together so the two can never
disagree. Live against an anonymous snapshot: 874,002 B to 258,012 B, 3.39x,
decompressing byte-identical to the uncompressed response.
This commit is contained in:
daikaginza committed 2026-09-29 10:57:45 +07:00
1 parent e7707d9a0f
commit daed0be99a
6 files changed
+405 -24

No files matched your search

+69 -22
View File
@@ -1,4 +1,5 @@
import { normalizeAdsbLolPointResponse } from '../../../src/data/adsbLolFallback.js';
import { encodedBody, gzipBody } from '../common/compress.js';
import {
coalesceProxyRequest,
readResponseJsonCapped,
@@ -15,6 +16,12 @@ let _openskyTokenExpiry = 0;
let _openskyTokenPromise = null;
/** @type {string|null} Cached upstream response body (JSON text). */
let _openskyCacheBody = null;
/**
* @type {Buffer|null} Gzip of `_openskyCacheBody`, written in the same step as
* the body it encodes so the two can never describe different snapshots. Null
* whenever the cached body is too small to be worth a frame.
*/
let _openskyCacheGzip = null;
/** @type {number} HTTP status of the cached response. */
let _openskyCacheStatus = 0;
/** @type {number} Epoch-ms when the response was cached. */
@@ -271,8 +278,13 @@ async function fetchAdsbLolPointFallback(req) {
ADSBLOL_POINT_MAX_RESPONSE_BYTES,
);
const normalized = normalizeAdsbLolPointResponse(payload);
const body = JSON.stringify(normalized);
const record = {
body: JSON.stringify(normalized),
body,
// Compressed with the body it belongs to, so a cache entry served
// many times pays for the frame once. Entries are bounded by
// ADSBLOL_POINT_CACHE_MAX, so the extra residency is bounded too.
gzip: await gzipBody(body),
cachedAt: Date.now(),
count: normalized.states.length,
};
@@ -301,6 +313,7 @@ async function fetchAdsbLolPointFallback(req) {
async function serveAdsbLolPointFallback(req, res, requestedMode, reason) {
const fallback = await fetchAdsbLolPointFallback(req);
if (!fallback) return false;
const encoded = encodedBody(req, fallback.body, fallback.gzip);
res.writeHead(200, {
...buildOpenSkyHeaders({
cacheStatus: fallback.cacheStatus,
@@ -311,8 +324,9 @@ async function serveAdsbLolPointFallback(req, res, requestedMode, reason) {
'X-Flight-Source': 'adsb.lol',
'X-Flight-Coverage': `${ADSBLOL_POINT_RADIUS_NM}nm regional fallback`,
'X-Flight-Count': String(fallback.count),
...encoded.headers,
});
res.end(fallback.body);
res.end(encoded.body);
return true;
}
@@ -380,9 +394,13 @@ export function openSkyProxy() {
reason: 'cached',
};
const isStale = now - _openskyCacheTime >= _openskyTtlMs;
res.writeHead(
_openskyCacheStatus || 200,
buildOpenSkyHeaders({
const encoded = encodedBody(
req,
_openskyCacheBody,
_openskyCacheGzip,
);
res.writeHead(_openskyCacheStatus || 200, {
...buildOpenSkyHeaders({
cacheStatus: isStale ? 'STALE' : 'HIT',
requestedMode: cachedMeta.requestedMode || requestedMode,
usedMode: cachedMeta.usedMode || 'unknown',
@@ -396,8 +414,9 @@ export function openSkyProxy() {
? (_openskyCooldownUntil - now) / 1000
: undefined,
}),
);
res.end(_openskyCacheBody);
...encoded.headers,
});
res.end(encoded.body);
return;
}
// Cooling down with nothing cached (cold start into a rate limit):
@@ -507,7 +526,12 @@ export function openSkyProxy() {
) {
// Keep the last global snapshot available as a fail-soft cache,
// but do not label or render it as a fresh live result.
// The frame is built before either slot is written so the two are
// always published together: a request arriving mid-refresh reads the
// previous body with the previous body's frame, never a mismatch.
const frame = await gzipBody(body);
_openskyCacheBody = body;
_openskyCacheGzip = frame;
_openskyCacheStatus = upstream.status;
_openskyCacheTime = now;
_openskyCacheSourceEpochMs = sourceEpochMs;
@@ -532,9 +556,13 @@ export function openSkyProxy() {
// Serve the last-good body instead of the 429 when we have one —
// the layer keeps rendering (STALE-cued) instead of dying.
if (_openskyCacheBody && _openskyCacheStatus === 200) {
res.writeHead(
200,
buildOpenSkyHeaders({
const encoded = encodedBody(
req,
_openskyCacheBody,
_openskyCacheGzip,
);
res.writeHead(200, {
...buildOpenSkyHeaders({
cacheStatus: 'STALE',
requestedMode,
usedMode,
@@ -542,8 +570,9 @@ export function openSkyProxy() {
staleSeconds: (now - _openskyCacheTime) / 1000,
retryAfterSeconds: cooldownMs / 1000,
}),
);
res.end(_openskyCacheBody);
...encoded.headers,
});
res.end(encoded.body);
return;
}
}
@@ -616,7 +645,10 @@ export function openSkyProxy() {
// Only cache successful responses — error responses (401/403/429/5xx)
// should not be served from cache on subsequent requests
if (upstream.ok) {
// Published as a pair, for the reason given at the other cache fill.
const frame = await gzipBody(body);
_openskyCacheBody = body;
_openskyCacheGzip = frame;
_openskyCacheStatus = upstream.status;
_openskyCacheTime = now;
_openskyCacheSourceEpochMs = sourceEpochMs;
@@ -635,16 +667,26 @@ export function openSkyProxy() {
_openskyCooldownUntil = 0;
}
res.writeHead(
upstream.status,
buildOpenSkyHeaders({
// Only the branch above caches, so the gzip slot belongs to this body
// only when this body is the one it just stored. An error response
// (401/403/429/5xx) is not cached and must not borrow the previous
// snapshot's frame — identity comparison, not `upstream.ok`, because it
// stays correct if the caching condition above ever changes.
const encoded = encodedBody(
req,
body,
_openskyCacheBody === body ? _openskyCacheGzip : null,
);
res.writeHead(upstream.status, {
...buildOpenSkyHeaders({
cacheStatus: 'MISS',
requestedMode,
usedMode,
reason,
}),
);
res.end(body);
...encoded.headers,
});
res.end(encoded.body);
} catch (e) {
console.error('[OpenSky Proxy]', e.message);
if (_openskyCacheBody) {
@@ -655,17 +697,22 @@ export function openSkyProxy() {
usedMode: 'unknown',
reason: 'cached_stale',
};
res.writeHead(
_openskyCacheStatus || 200,
buildOpenSkyHeaders({
const encoded = encodedBody(
req,
_openskyCacheBody,
_openskyCacheGzip,
);
res.writeHead(_openskyCacheStatus || 200, {
...buildOpenSkyHeaders({
cacheStatus: 'STALE',
requestedMode:
cachedMeta.requestedMode || OPENSKY_AUTH_MODE_DEFAULT,
usedMode: cachedMeta.usedMode || 'unknown',
reason: cachedMeta.reason || 'cached_stale',
}),
);
res.end(_openskyCacheBody);
...encoded.headers,
});
res.end(encoded.body);
return;
}
const requestedMode = normalizeOpenSkyAuthMode(
+94
View File
@@ -0,0 +1,94 @@
import { gzip } from 'node:zlib';
import { promisify } from 'node:util';
const gzipAsync = promisify(gzip);
/**
* Response compression for proxies that already hold a whole body in memory.
*
* Deliberately not a middleware. Two response paths in this tree must never be
* wrapped: `cctv/media.js` streams a piped body and answers byte-range requests
* with 206, and `wind/gfs.js` also serves ranges — a wrapper that buffered or
* re-encoded those would break playback and range semantics. Seven providers
* additionally forward an upstream `content-type` verbatim, including already
* compressed binary formats that gzip would only make larger.
*
* So compression is opt-in at the call site, for bodies the proxy holds and
* knows to be text. The failure mode of forgetting a site is a response that is
* merely uncompressed, never a corrupt one — but only as long as the encoded
* body and its `Content-Encoding` are chosen together, which is why
* `encodedBody` returns both rather than exposing them separately.
*/
/** Below this, a gzip frame costs more than the bytes it saves. */
const MIN_COMPRESSED_BYTES = 1024;
/**
* Whether the client offered gzip in `Accept-Encoding`.
*
* Anything this cannot read confidently — a missing header, a malformed
* quality value, the `x-gzip` alias — reads as "no", because the fallback is an
* identity response every client can decode.
*
* @param {import('node:http').IncomingMessage} request
* @returns {boolean}
*/
export function acceptsGzip(request) {
const header = request?.headers?.['accept-encoding'];
if (typeof header !== 'string') return false;
return header.split(',').some((part) => {
const [token, ...parameters] = part.split(';');
if (token.trim().toLowerCase() !== 'gzip') return false;
// `q=0` is the one way a client can name an encoding and still refuse it
// (RFC 9110 section 12.4.2).
const quality = parameters
.map((parameter) => parameter.trim().toLowerCase())
.find((parameter) => parameter.startsWith('q='));
return quality === undefined || Number.parseFloat(quality.slice(2)) > 0;
});
}
/**
* Gzip a body once, for callers that cache it and serve it many times.
*
* OpenSky's snapshot is ~860 KB and is held for the cache TTL, so this is paid
* per refresh rather than per request. It runs off the event loop on purpose:
* on that snapshot the synchronous call stalls every other in-flight request by
* 11 ms, while this stalls them by under 1 ms and costs the caller — which is
* already waiting on an upstream fetch — about 5 ms more of its own latency.
*
* Returns null when the body is too small to be worth a frame, so callers can
* store the result directly and treat null as "serve identity".
*
* @param {string|Buffer} body - the exact bytes the identity response sends.
* @returns {Promise<Buffer|null>}
*/
export async function gzipBody(body) {
if (body == null) return null;
const raw = Buffer.isBuffer(body) ? body : Buffer.from(String(body));
if (raw.byteLength < MIN_COMPRESSED_BYTES) return null;
const compressed = await gzipAsync(raw);
return compressed.byteLength < raw.byteLength ? compressed : null;
}
/**
* Pick the body to send and the headers that describe it, together.
*
* `Vary: Accept-Encoding` is set on both branches, not only the compressed one:
* a cache that stored an identity response without it would later hand those
* bytes to a client that asked for gzip, and vice versa.
*
* @param {import('node:http').IncomingMessage} request
* @param {string|Buffer} identity - the uncompressed body.
* @param {Buffer|null} compressed - `gzipBody(identity)`, or null.
* @returns {{body: string|Buffer, headers: Record<string,string>}}
*/
export function encodedBody(request, identity, compressed) {
if (compressed && acceptsGzip(request)) {
return {
body: compressed,
headers: { 'Content-Encoding': 'gzip', Vary: 'Accept-Encoding' },
};
}
return { body: identity, headers: { Vary: 'Accept-Encoding' } };
}