From daed0be99a03d8f416c59617f77c7cea455616eb Mon Sep 17 00:00:00 2001 From: daikaginza Date: Mon, 28 Sep 2026 14:17:20 +0700 Subject: [PATCH 01/21] 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. --- docs/CURRENT-STATE.md | 1 + scripts/package-boundaries.json | 1 + server/providers/aircraft/opensky.js | 91 ++++++++++++---- server/providers/common/compress.js | 94 +++++++++++++++++ src/tooling/liveProviders.test.mjs | 113 +++++++++++++++++++- src/tooling/responseCompression.test.mjs | 129 +++++++++++++++++++++++ 6 files changed, 405 insertions(+), 24 deletions(-) create mode 100644 server/providers/common/compress.js create mode 100644 src/tooling/responseCompression.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 22ce1eb3b..a03d2bd5d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4072,6 +4072,7 @@ are omitted rather than framing the wrong part of the globe. - `/api/overpass` is bounded by body/response caps, per-client/global rate limits, concurrency limits, operator-configured upstreams, in-flight dedupe, cache bounds, and static validation that every selector is spatially bounded. - `/api/military-installations` uses an independent limiter with the same 90-per-client/300-global one-minute bounds, so viewport installation refreshes never consume `/api/overpass` annotation capacity. - `/api/route` proxies bounded OSRM route requests for annotation routes, with profile allowlisting, distance caps, response caps, caching, and sanitized "no route found" errors. +- `/api/opensky` negotiates response encoding. The cached global snapshot and the regional adsb.lol fallback are each gzipped once per refresh, so a client that offers gzip receives roughly 860 KB as 250 KB and one that does not receives the same bytes uncompressed; both answers carry `Vary: Accept-Encoding`. Bodies the proxy does not cache — auth failures, cooldown notices — are never sent under a cached response's encoding. - Track endpoints: `/api/ais-live/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/opensky-track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/adsblol/trace?hex=` (60s cache, 5MB cap, ODbL attribution required in UI). - Realtime debug logs redact API keys, bearer tokens, client secrets, and image data URLs before writing to disk; request bodies are size-capped. - `/api/realtime/debug-log` enforces an always-on 120-per-client/400-global one-minute limiter (not the opt-in `GEV_RATELIMIT_OPENAI_PER_MIN` bucket the cost-bearing OpenAI routes share), appends asynchronously through a serialized queue, and rotates `realtime-conversations.jsonl` at 32 MB keeping one prior generation, so the sink is bounded at twice that regardless of session length. A malformed record answers 400 and a failed write 500, both with a fixed message. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 276b9e0db..363966ba9 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -35,6 +35,7 @@ "exports": ["./server/providers/live"], "modules": [ "server/providers/live.js", + "server/providers/common/compress.js", "server/providers/common/http.js", "server/providers/common/query.js", "server/providers/aircraft/opensky.js", diff --git a/server/providers/aircraft/opensky.js b/server/providers/aircraft/opensky.js index 612d01e11..a37315b9d 100644 --- a/server/providers/aircraft/opensky.js +++ b/server/providers/aircraft/opensky.js @@ -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( diff --git a/server/providers/common/compress.js b/server/providers/common/compress.js new file mode 100644 index 000000000..7d93b3594 --- /dev/null +++ b/server/providers/common/compress.js @@ -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} + */ +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}} + */ +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' } }; +} diff --git a/src/tooling/liveProviders.test.mjs b/src/tooling/liveProviders.test.mjs index 38953eff9..94bbb6a27 100644 --- a/src/tooling/liveProviders.test.mjs +++ b/src/tooling/liveProviders.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { once } from 'node:events'; +import { gunzipSync } from 'node:zlib'; import { WebSocketServer } from 'ws'; import { setTimeout as delay } from 'node:timers/promises'; import * as providers from '../../server/providers/live.js'; @@ -15,7 +16,7 @@ function install(plugin, preview = false) { }, }, }); - return async (route, url = '/', method = 'GET') => { + return async (route, url = '/', method = 'GET', headers = {}) => { assert.ok(routes.has(route), `registered route: ${route}`); const response = { statusCode: 200, @@ -32,7 +33,7 @@ function install(plugin, preview = false) { this.body = body; }, }; - await routes.get(route)({ url, method }, response); + await routes.get(route)({ url, method, headers }, response); return response; }; } @@ -381,3 +382,111 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches assert.equal(res2.statusCode, 502); assert.equal(callCount, 1); }); + +test('the OpenSky snapshot is compressed for clients that ask, and an error body never inherits the cached frame', async (t) => { + environment(t, { + OPENSKY_AUTH_MODE: 'anon', + OPENSKY_CLIENT_ID: undefined, + OPENSKY_CLIENT_SECRET: undefined, + OPENSKY_USERNAME: undefined, + OPENSKY_PASSWORD: undefined, + }); + let now = Date.now(); + t.mock.method(Date, 'now', () => now); + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'error', () => {}); + + let upstreamStatus = 200; + let snapshotAgeMs = 0; + t.mock.method(globalThis, 'fetch', async (url) => { + if (url.includes('/lat/')) + return Response.json({ + now: now / 1000, + ac: Array.from({ length: 200 }, (_, index) => ({ + hex: `abc${index}`, + lat: 30, + lon: -97, + alt_baro: 10000, + })), + }); + assert.ok(url.includes('/states/'), `unexpected upstream call: ${url}`); + if (upstreamStatus !== 200) + return new Response('{"error":"upstream"}', { status: upstreamStatus }); + const sourceSeconds = Math.floor((now - snapshotAgeMs) / 1000); + // A healthy credit budget, so the proxy keeps its 9 s base TTL and the + // clock advances below are unambiguously past it. + return Response.json( + { + time: sourceSeconds, + states: Array.from({ length: 200 }, (_, index) => [ + `abc${index}`, + 'CALLSIGN', + 'United States', + sourceSeconds, + -97.7, + 30.1, + 10000, + ]), + }, + { headers: { 'x-rate-limit-remaining': '4000' } }, + ); + }); + + // A module instance of its own: the snapshot cache, its frame and the + // adaptive TTL are module state, and this test reasons about all three. + const isolated = await import( + `../../server/providers/aircraft/opensky.js?compression=${now}` + ); + const states = install(isolated.openSkyProxy()); + const compressed = await states('/api/opensky', '/', 'GET', { + 'accept-encoding': 'gzip, deflate, br', + }); + assert.equal(compressed.statusCode, 200); + assert.equal(compressed.headers['content-encoding'], 'gzip'); + assert.equal(compressed.headers.vary, 'Accept-Encoding'); + assert.ok(Buffer.isBuffer(compressed.body)); + + // Same snapshot from cache, this time to a client that offered nothing: the + // identity bytes, and the compressed frame above must decode to exactly them. + const identity = await states('/api/opensky'); + assert.equal(identity.headers['x-opensky-cache'], 'HIT'); + assert.equal(identity.headers['content-encoding'], undefined); + assert.equal(identity.headers.vary, 'Accept-Encoding'); + assert.equal(gunzipSync(compressed.body).toString(), identity.body); + + // A client that names gzip and refuses it with q=0 is a non-gzip client. + const refused = await states('/api/opensky', '/', 'GET', { + 'accept-encoding': 'gzip;q=0', + }); + assert.equal(refused.headers['content-encoding'], undefined); + assert.equal(refused.body, identity.body); + + // An upstream rejection is not cached, so it has no frame of its own. It must + // not be sent under the previous snapshot's Content-Encoding. + upstreamStatus = 403; + now += 60000; // past the 9 s base TTL this fresh instance starts with + const rejected = await states('/api/opensky', '/', 'GET', { + 'accept-encoding': 'gzip', + }); + assert.equal(rejected.statusCode, 403); + assert.equal(rejected.headers['content-encoding'], undefined); + assert.ok(JSON.parse(rejected.body).error, 'a readable error body'); + + // The regional fallback is a fifth body this route can answer with. It is + // cached the same way and so it negotiates the same way: a snapshot too old + // to serve hands the request to adsb.lol, compressed. + upstreamStatus = 200; + snapshotAgeMs = 3600000; + now += 60000; + const regional = await states('/api/opensky', '?lat=30&lon=-97', 'GET', { + 'accept-encoding': 'gzip', + }); + assert.equal(regional.statusCode, 200); + assert.equal(regional.headers['x-flight-source'], 'adsb.lol'); + assert.equal(regional.headers['content-encoding'], 'gzip'); + assert.equal(regional.headers.vary, 'Accept-Encoding'); + assert.equal( + JSON.parse(gunzipSync(regional.body).toString()).states.length, + 200, + ); +}); diff --git a/src/tooling/responseCompression.test.mjs b/src/tooling/responseCompression.test.mjs new file mode 100644 index 000000000..92fee57a3 --- /dev/null +++ b/src/tooling/responseCompression.test.mjs @@ -0,0 +1,129 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { gunzipSync } from 'node:zlib'; +import { + acceptsGzip, + encodedBody, + gzipBody, +} from '../../server/providers/common/compress.js'; + +/** A request shaped like the one Node hands a middleware. */ +function request(acceptEncoding) { + return { + url: '/api/opensky', + method: 'GET', + headers: + acceptEncoding === undefined ? {} : { 'accept-encoding': acceptEncoding }, + }; +} + +/** Repetitive JSON, the shape of an actual snapshot body. */ +function snapshotBody(states = 200) { + return JSON.stringify({ + time: 1700000000, + states: Array.from({ length: states }, (_, index) => [ + `abc${index}`, + 'CALLSIGN', + 'United States', + 1700000000, + 30.1, + -97.7, + 10000, + ]), + }); +} + +/** + * Deterministic high-entropy bytes. Random data would do, but a seeded sequence + * keeps the "gzip made it bigger" assertion from depending on luck. + */ +function incompressibleBytes(length) { + const bytes = Buffer.alloc(length); + let state = 123456789; + for (let index = 0; index < length; index += 1) { + state = (state * 1103515245 + 12345) % 2147483648; + bytes[index] = (state >>> 16) & 0xff; + } + return bytes; +} + +test('accept-encoding is read the way clients actually write it', () => { + assert.equal(acceptsGzip(request('gzip')), true); + assert.equal(acceptsGzip(request('gzip, deflate, br')), true); + assert.equal(acceptsGzip(request('br;q=1.0, gzip;q=0.8')), true); + assert.equal(acceptsGzip(request('GZIP')), true); + assert.equal(acceptsGzip(request(' gzip ')), true); + + assert.equal(acceptsGzip(request('br, deflate')), false); + assert.equal(acceptsGzip(request('identity')), false); + assert.equal(acceptsGzip(request('')), false); + assert.equal(acceptsGzip(request(undefined)), false); + assert.equal(acceptsGzip({}), false); + assert.equal(acceptsGzip(undefined), false); + + // A quality of zero names gzip and refuses it (RFC 9110 section 12.4.2), + // and an unreadable quality falls back to the encoding every client accepts. + assert.equal(acceptsGzip(request('gzip;q=0')), false); + assert.equal(acceptsGzip(request('gzip;q=0.000')), false); + assert.equal(acceptsGzip(request('gzip;q=bogus')), false); + assert.equal(acceptsGzip(request('gzip;q=0.001')), true); + + // `gzip` is not matched as a substring of a neighbouring token. + assert.equal(acceptsGzip(request('x-gzip')), false); + assert.equal(acceptsGzip(request('gzipped')), false); +}); + +test('a body is only compressed when compression pays for itself', async () => { + assert.equal(await gzipBody(null), null); + assert.equal(await gzipBody(undefined), null); + assert.equal(await gzipBody(''), null); + assert.equal( + await gzipBody('{"states":[]}'), + null, + 'below the frame threshold', + ); + assert.equal(await gzipBody(Buffer.alloc(1023, 'a')), null, 'one byte short'); + + const identity = snapshotBody(); + assert.ok(identity.length > 1024, 'fixture is worth compressing'); + const compressed = await gzipBody(identity); + assert.ok(Buffer.isBuffer(compressed)); + assert.ok(compressed.byteLength < Buffer.byteLength(identity)); + assert.equal(gunzipSync(compressed).toString(), identity); + + // Buffers are accepted as-is, and data that gzip cannot shrink is refused + // rather than served as a larger response. + assert.ok((await gzipBody(Buffer.from(identity))) !== null); + assert.equal(await gzipBody(incompressibleBytes(4096)), null); +}); + +test('the encoded body and the header that names it are chosen together', async () => { + const identity = snapshotBody(); + const compressed = await gzipBody(identity); + + const negotiated = encodedBody(request('gzip'), identity, compressed); + assert.equal(negotiated.body, compressed); + assert.equal(negotiated.headers['Content-Encoding'], 'gzip'); + assert.equal(negotiated.headers.Vary, 'Accept-Encoding'); + assert.equal(gunzipSync(negotiated.body).toString(), identity); + + // Offered but not wanted: identity bytes, and no Content-Encoding to + // contradict them. + const refused = encodedBody(request('br'), identity, compressed); + assert.equal(refused.body, identity); + assert.equal(refused.headers['Content-Encoding'], undefined); + assert.equal(refused.headers.Vary, 'Accept-Encoding'); + + // Wanted but not available: same answer. + const unavailable = encodedBody(request('gzip'), identity, null); + assert.equal(unavailable.body, identity); + assert.equal(unavailable.headers['Content-Encoding'], undefined); + + // Vary is on both branches: without it a shared cache could hand a stored + // response to a client whose Accept-Encoding it was not selected for. + assert.equal(unavailable.headers.Vary, 'Accept-Encoding'); + assert.equal( + encodedBody(request(undefined), identity, compressed).headers.Vary, + 'Accept-Encoding', + ); +}); From 47b13ab18e4cafc961f049fd361cb0ed29fe51b1 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Mon, 28 Sep 2026 14:18:09 +0700 Subject: [PATCH 02/21] docs(changelog): note OpenSky snapshot compression (#795) --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58103d04d..aa36408a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +- The live flight snapshot is compressed on the wire. `/api/opensky` gzips its + cached global snapshot and its regional adsb.lol fallback once per upstream + refresh and serves that frame to clients offering gzip — roughly 860 KB + becomes roughly 258 KB — while a client that offers nothing receives the same + bytes uncompressed. Both answers carry `Vary: Accept-Encoding`, and a response + the proxy does not cache, such as an auth failure, is never sent under a + cached snapshot's encoding (daikaginza, #795). + - Public Overpass instances are no longer used by default. Street Traffic roads come from TomTom flow tiles, OpenFreeMap vector tiles, or both, chosen on the layer row (TomTom / OSM / Hybrid) or with `?trafficRoads=`. With a From e7dd71e9bcdd836e046aba84ba98d5889d482025 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Tue, 29 Sep 2026 11:13:36 +0700 Subject: [PATCH 03/21] fix(server): read the whole q value, not a numeric prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Number.parseFloat accepts any valid numeric prefix, so `gzip;q=1oops` read as 1 and the out-of-range `gzip;q=2` was accepted — both contradicting the comment two lines above, which promises that a quality this cannot read confidently fails closed. Matching the qvalue grammar of RFC 9110 section 12.4.2 makes the code do what it already claimed to do. Thanks to kvnloo for catching it on review. --- server/providers/common/compress.js | 21 +++++++++++++++------ src/tooling/responseCompression.test.mjs | 17 ++++++++++++++--- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/server/providers/common/compress.js b/server/providers/common/compress.js index 7d93b3594..ebf736eda 100644 --- a/server/providers/common/compress.js +++ b/server/providers/common/compress.js @@ -23,12 +23,20 @@ const gzipAsync = promisify(gzip); /** Below this, a gzip frame costs more than the bytes it saves. */ const MIN_COMPRESSED_BYTES = 1024; +/** + * The `qvalue` grammar of RFC 9110 section 12.4.2: zero to three decimals + * below one, or one with only zeros after it. Matching the whole token rather + * than parsing a numeric prefix is what makes `q=1oops` and the out-of-range + * `q=2` unreadable instead of quietly rounding to something acceptable. + */ +const QVALUE = /^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/; + /** * 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. + * Anything this cannot read confidently — a missing header, a malformed or + * out-of-range 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} @@ -39,12 +47,13 @@ export function acceptsGzip(request) { 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; + if (quality === undefined) return true; + // `q=0` is the one way a client can name an encoding and still refuse it. + const value = quality.slice(2); + return QVALUE.test(value) && Number(value) > 0; }); } diff --git a/src/tooling/responseCompression.test.mjs b/src/tooling/responseCompression.test.mjs index 92fee57a3..c5c70dae7 100644 --- a/src/tooling/responseCompression.test.mjs +++ b/src/tooling/responseCompression.test.mjs @@ -61,12 +61,23 @@ test('accept-encoding is read the way clients actually write it', () => { assert.equal(acceptsGzip({}), false); assert.equal(acceptsGzip(undefined), false); - // A quality of zero names gzip and refuses it (RFC 9110 section 12.4.2), - // and an unreadable quality falls back to the encoding every client accepts. + // A quality of zero names gzip and refuses it (RFC 9110 section 12.4.2). assert.equal(acceptsGzip(request('gzip;q=0')), false); assert.equal(acceptsGzip(request('gzip;q=0.000')), false); - assert.equal(acceptsGzip(request('gzip;q=bogus')), false); assert.equal(acceptsGzip(request('gzip;q=0.001')), true); + assert.equal(acceptsGzip(request('gzip;q=1')), true); + assert.equal(acceptsGzip(request('gzip;q=1.000')), true); + + // A quality that is not a whole `qvalue` is unreadable, not a number with + // trailing noise: the grammar allows three decimals and a ceiling of 1, so + // these must not round into acceptance. + assert.equal(acceptsGzip(request('gzip;q=bogus')), false); + assert.equal(acceptsGzip(request('gzip;q=1oops')), false); + assert.equal(acceptsGzip(request('gzip;q=0.5abc')), false); + assert.equal(acceptsGzip(request('gzip;q=2')), false); + assert.equal(acceptsGzip(request('gzip;q=1.5')), false); + assert.equal(acceptsGzip(request('gzip;q=0.0001')), false); + assert.equal(acceptsGzip(request('gzip;q=')), false); // `gzip` is not matched as a substring of a neighbouring token. assert.equal(acceptsGzip(request('x-gzip')), false); From 69777700674fa5f4dfcc3729a553b96a9a91ffe0 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sun, 18 Oct 2026 13:36:42 +0700 Subject: [PATCH 04/21] docs(changelog): move the entry into a changelog.d fragment Main now takes pending entries as changelog.d/-.md files (#787), so the entry leaves CHANGELOG.md for changelog.d/795-opensky-compress-cached-body.md. --- changelog.d/795-opensky-compress-cached-body.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 changelog.d/795-opensky-compress-cached-body.md diff --git a/changelog.d/795-opensky-compress-cached-body.md b/changelog.d/795-opensky-compress-cached-body.md new file mode 100644 index 000000000..c0bbadbed --- /dev/null +++ b/changelog.d/795-opensky-compress-cached-body.md @@ -0,0 +1,7 @@ +- The live flight snapshot is compressed on the wire. `/api/flights` gzips its + cached global snapshot and its regional adsb.lol fallback once per upstream + refresh and serves that frame to clients offering gzip — roughly 860 KB + becomes roughly 258 KB — while a client that offers nothing receives the same + bytes uncompressed. Both answers carry `Vary: Accept-Encoding`, and a response + the proxy does not cache, such as an auth failure, is never sent under a + cached snapshot's encoding (daikaginza, #795). From ae55963a0e5638b0aa2e2a90d7a66d8f10a40597 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Thu, 29 Oct 2026 07:10:06 -0700 Subject: [PATCH 05/21] fix(tools): wrap whole-degree headings at 360 to 0 The query tools rounded courses, headings, bearings and azimuths with Number(value.toFixed(0)) and no wrap, so a direction of 359.5 degrees or more came back as 360: find_vessel with course 359.7 and heading 359.6 returned course_deg 360 and heading_deg 360. Round them through the shared roundHeadingDegrees helper the layers and UI already use (src/data/headingDegrees.js), which wraps into 0 to 359: course_deg and heading_deg (vessels), course_deg (aircraft), bearing_deg (transit), heading_deg (cameras), and rise_azimuth_deg and azimuth_deg (satellites). Other rounding is unchanged. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 5 ++++- src/tools/queries/aviation.js | 3 ++- src/tools/queries/aviation.test.mjs | 13 +++++++++++++ src/tools/queries/maritime.js | 5 +++-- src/tools/queries/maritime.test.mjs | 19 +++++++++++++++++++ src/tools/queries/media.js | 3 ++- src/tools/queries/media.test.mjs | 14 ++++++++++++++ src/tools/queries/mobility.js | 3 ++- src/tools/queries/mobility.test.mjs | 17 +++++++++++++++++ src/tools/queries/space.js | 5 +++-- src/tools/queries/space.test.mjs | 18 ++++++++++++++++++ 11 files changed, 97 insertions(+), 8 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 35d81dd05..35db1b1bb 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -204,7 +204,10 @@ perimeters, terrain height, military installations and map features, plus a combined situation brief, military awareness around a point, the app's heads-up display caption, and a link that opens the app over an area. Tools reuse the layers' portable source factories, take a shared `area` argument (place name, bounding box, or point and radius) and cap -lists at 25 rows by default. `aircraft_in_area` and `vessels_in_area` can also +lists at 25 rows by default. Vessel `course_deg` and `heading_deg`, aircraft +`course_deg`, transit `bearing_deg`, camera `heading_deg` and satellite +`rise_azimuth_deg` and `azimuth_deg` are whole degrees from 0 to 359; a +direction of 359.5° or more reads 0 rather than 360. `aircraft_in_area` and `vessels_in_area` can also count every match by a field (`group_by`: airline, read from the ICAO designator in the callsign, or operator, country or type for aircraft; type or destination for ships; the ten largest groups, then other and unknown counts; diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js index 153e36e03..7c988c5ea 100644 --- a/src/tools/queries/aviation.js +++ b/src/tools/queries/aviation.js @@ -19,6 +19,7 @@ import { numericSummary, thinEvenly, } from '../results.js'; +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; const ICAO24_SCHEMA = Object.freeze({ type: 'string', @@ -75,7 +76,7 @@ function aircraftRow(record, from) { altitude_m: round(record.baroAltitudeM ?? record.ellipsoidAltitudeM, 0), on_ground: record.onGround === true, speed_mps: round(record.speedMps, 1), - course_deg: round(record.courseDeg, 0), + course_deg: roundHeadingDegrees(record.courseDeg), vertical_rate_mps: round(record.verticalRateMps, 1), type_code: record.typeCode || null, registration: record.registration || null, diff --git a/src/tools/queries/aviation.test.mjs b/src/tools/queries/aviation.test.mjs index 484719790..59a928672 100644 --- a/src/tools/queries/aviation.test.mjs +++ b/src/tools/queries/aviation.test.mjs @@ -146,6 +146,19 @@ test('the military feed is used on request and reported when missing', async () ); }); +test('an aircraft course that rounds to 360 reads as 0', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { + aircraft: feed([ + record('ddd444', 37.6, -122.3, { callsign: 'N360', courseDeg: 359.6 }), + ]), + }, + }); + const result = await catalog.call('find_aircraft', { callsign: 'N360' }); + assert.equal(result.data.rows[0].course_deg, 0); +}); + test('aircraft are found by exactly one identifier across feeds', async () => { const catalog = composeCatalog({ tools: coreTools, diff --git a/src/tools/queries/maritime.js b/src/tools/queries/maritime.js index 5798a0936..0e9d80373 100644 --- a/src/tools/queries/maritime.js +++ b/src/tools/queries/maritime.js @@ -20,6 +20,7 @@ import { numericSummary, thinEvenly, } from '../results.js'; +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; import { normalizeVesselType } from '../../data/vesselTypes.js'; const MMSI_SCHEMA = Object.freeze({ @@ -57,8 +58,8 @@ function vesselRow(record, from) { lat: round(record.latitude, 5), lon: round(record.longitude, 5), speed_mps: round(record.speedMps, 1), - course_deg: round(record.courseDeg, 0), - heading_deg: round(record.headingDeg, 0), + course_deg: roundHeadingDegrees(record.courseDeg), + heading_deg: roundHeadingDegrees(record.headingDeg), observed_at: isoTime(record.observedAtMs), ...(from ? { distance_km: round(distanceKm(from, point), 1) } : {}), }; diff --git a/src/tools/queries/maritime.test.mjs b/src/tools/queries/maritime.test.mjs index d86bdf352..f89e6bce3 100644 --- a/src/tools/queries/maritime.test.mjs +++ b/src/tools/queries/maritime.test.mjs @@ -351,6 +351,25 @@ test('find_vessel answers carry normalized types and destinations', async () => ); }); +test('a vessel course or heading that rounds to 360 reads as 0', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { + vessels: vessels({ + records: [ + vessel('366000003', 37.78, -122.4, { + courseDeg: 359.7, + headingDeg: 359.6, + }), + ], + }), + }, + }); + const [row] = (await catalog.call('find_vessel', { mmsi: '366000003' })).data + .rows; + assert.deepEqual([row.course_deg, row.heading_deg], [0, 0]); +}); + test('a destination with no letters counts as unknown', async () => { const catalog = composeCatalog({ tools: coreTools, diff --git a/src/tools/queries/media.js b/src/tools/queries/media.js index a03e88bb4..d154619db 100644 --- a/src/tools/queries/media.js +++ b/src/tools/queries/media.js @@ -10,6 +10,7 @@ import { resolveArea, } from '../area.js'; import { LIMIT_SCHEMA, capRows, countNoun, toBase64 } from '../results.js'; +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; const FRAME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']); const MAX_FRAME_BYTES = 3 * 1024 * 1024; @@ -85,7 +86,7 @@ export const findCctvCameras = defineTool({ provider: text(camera.provider), lat: round(camera.lat, 6), lon: round(camera.lon, 6), - heading_deg: round(camera.headingDeg, 0), + heading_deg: roundHeadingDegrees(camera.headingDeg), feed_type: text(camera.feedType), credit: text(camera.credit), distance_km: round(distanceKm(center, camera), 2), diff --git a/src/tools/queries/media.test.mjs b/src/tools/queries/media.test.mjs index 28c037a57..f690966c5 100644 --- a/src/tools/queries/media.test.mjs +++ b/src/tools/queries/media.test.mjs @@ -77,6 +77,20 @@ test('cameras in an area are listed nearest first', async () => { assert.equal(result.data.rows[1].credit, null); }); +test('a camera heading that rounds to 360 reads as 0', async () => { + const source = { + async getCatalog() { + return { sources: [{ ...cameras[0], headingDeg: 359.6 }] }; + }, + }; + const catalog = composeCatalog({ + tools: coreTools, + services: { cctv: source }, + }); + const result = await catalog.call('find_cctv_cameras', { area: downtown }); + assert.equal(result.data.rows[0].heading_deg, 0); +}); + test('a snapshot returns the frame as an image with its credit', async () => { const source = cctv(); const catalog = composeCatalog({ diff --git a/src/tools/queries/mobility.js b/src/tools/queries/mobility.js index 0a57ae3ae..c31f03863 100644 --- a/src/tools/queries/mobility.js +++ b/src/tools/queries/mobility.js @@ -19,6 +19,7 @@ import { resolveArea, } from '../area.js'; import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js'; +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; const MAX_SYSTEMS = 3; @@ -244,7 +245,7 @@ export const getTransitVehicles = defineTool({ trip_id: vehicle.tripId ?? null, lat: vehicle.lat, lon: vehicle.lon, - bearing_deg: round(vehicle.bearing, 0), + bearing_deg: roundHeadingDegrees(vehicle.bearing), speed_mps: round(vehicle.speedMps, 1), status: vehicle.status ?? null, occupancy: vehicle.occupancy ?? null, diff --git a/src/tools/queries/mobility.test.mjs b/src/tools/queries/mobility.test.mjs index 0ea882727..aaaa97bad 100644 --- a/src/tools/queries/mobility.test.mjs +++ b/src/tools/queries/mobility.test.mjs @@ -311,6 +311,23 @@ test('transit vehicles come from nearby feeds and can be limited to a route', as assert.equal(rapid.data.rows[0].id, 'v2'); }); +test('a transit bearing that rounds to 360 reads as 0', async () => { + const north = [{ ...vehicles[0], bearing: 359.6 }]; + const catalog = composeCatalog({ + tools: coreTools, + services: { + transit: transit({ + ok: true, + status: 200, + json: async () => ({ vehicles: north }), + }), + clock, + }, + }); + const result = await catalog.call('get_transit_vehicles', { area: downtown }); + assert.equal(result.data.rows[0].bearing_deg, 0); +}); + test('transit reports uncovered areas and unavailable feeds', async () => { const catalog = composeCatalog({ tools: coreTools, diff --git a/src/tools/queries/space.js b/src/tools/queries/space.js index ef59c0be5..a589e801c 100644 --- a/src/tools/queries/space.js +++ b/src/tools/queries/space.js @@ -10,6 +10,7 @@ import { parseTleText, tleCatalogNumber } from '../../sources/tle.js'; import { POINT_SCHEMA, resolvePoint } from '../area.js'; import { defineTool, ToolError } from '../catalog.js'; import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js'; +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; const text = (value) => (typeof value === 'string' && value ? value : null); @@ -241,7 +242,7 @@ export const nextSatellitePass = defineTool({ peak: isoTime(pass.maxElevMs), set: isoTime(pass.setMs), max_elevation_deg: round(pass.maxElevDeg, 1), - rise_azimuth_deg: round(pass.riseAzDeg, 0), + rise_azimuth_deg: roundHeadingDegrees(pass.riseAzDeg), rise_direction: compass(pass.riseAzDeg), visible: pass.visible, }, @@ -283,7 +284,7 @@ export const satellitesOverhead = defineTool({ name: entry.name, norad: entry.norad, elevation_deg: round(look.elevDeg, 1), - azimuth_deg: round(look.azDeg, 0), + azimuth_deg: roundHeadingDegrees(look.azDeg), direction: compass(look.azDeg), }, ] diff --git a/src/tools/queries/space.test.mjs b/src/tools/queries/space.test.mjs index 0eb5ce346..0e44fe208 100644 --- a/src/tools/queries/space.test.mjs +++ b/src/tools/queries/space.test.mjs @@ -296,6 +296,24 @@ test('satellites overhead are those above the elevation, highest first', async ( ); }); +test('satellite azimuths that round to 360 read as 0', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { satellites: satellites(), clock: CLOCK }, + }); + // From these points the ISS rises at 359.7° and stands at 359.8° at CLOCK. + const pass = await catalog.call('next_satellite_pass', { + location: { lat: 10, lon: 30 }, + }); + assert.equal(pass.data.pass.rise_azimuth_deg, 0); + assert.equal(pass.data.pass.rise_direction, 'N'); + const overhead = await catalog.call('satellites_overhead', { + location: { lat: 39.25, lon: 65.75 }, + }); + assert.equal(overhead.data.rows[0].name, 'ISS (ZARYA)'); + assert.equal(overhead.data.rows[0].azimuth_deg, 0); +}); + test('stale orbit and launch data are reported', async () => { const stale = { readGroup: async () => ({ ok: true, status: 200, text: TLE, stale: true }), From d1ed2e8e6f14d0c6e23c4b9f51949621d498dad6 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Tue, 3 Nov 2026 06:49:55 -0800 Subject: [PATCH 06/21] changelog: tool headings wrap at 360 (#1524) Co-Authored-By: Claude Opus 5.5 --- changelog.d/1524-tools-heading-wrap.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1524-tools-heading-wrap.md diff --git a/changelog.d/1524-tools-heading-wrap.md b/changelog.d/1524-tools-heading-wrap.md new file mode 100644 index 000000000..3aefce6c5 --- /dev/null +++ b/changelog.d/1524-tools-heading-wrap.md @@ -0,0 +1,4 @@ +- The agent and voice query tools report a direction of 359.5° or more as 0 + instead of 360: vessel course and heading, aircraft course, transit bearing, + camera heading and satellite azimuths now round through the shared heading + helper the layers use (@quanticsoul4772, #1524). From ccd83c5fa2ffdd9ad4f0564c4ec95976330d6109 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:00:27 -0800 Subject: [PATCH 07/21] docs(tools): say heading values wrap into 0-359, not that 359.5 and up read 0 Co-Authored-By: Claude Opus 5.5 --- changelog.d/1524-tools-heading-wrap.md | 8 ++++---- docs/CURRENT-STATE.md | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/changelog.d/1524-tools-heading-wrap.md b/changelog.d/1524-tools-heading-wrap.md index 3aefce6c5..2a630f600 100644 --- a/changelog.d/1524-tools-heading-wrap.md +++ b/changelog.d/1524-tools-heading-wrap.md @@ -1,4 +1,4 @@ -- The agent and voice query tools report a direction of 359.5° or more as 0 - instead of 360: vessel course and heading, aircraft course, transit bearing, - camera heading and satellite azimuths now round through the shared heading - helper the layers use (@quanticsoul4772, #1524). +- The agent and voice query tools report a direction that rounds to 360 as 0: + vessel course and heading, aircraft course, transit bearing, camera heading + and satellite azimuths now round through the shared heading helper the + layers use, which wraps into 0–359 (@quanticsoul4772, #1524). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 35db1b1bb..ecbdf5e0f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -207,7 +207,7 @@ shared `area` argument (place name, bounding box, or point and radius) and cap lists at 25 rows by default. Vessel `course_deg` and `heading_deg`, aircraft `course_deg`, transit `bearing_deg`, camera `heading_deg` and satellite `rise_azimuth_deg` and `azimuth_deg` are whole degrees from 0 to 359; a -direction of 359.5° or more reads 0 rather than 360. `aircraft_in_area` and `vessels_in_area` can also +direction that rounds to 360 reads 0, and larger or negative values wrap. `aircraft_in_area` and `vessels_in_area` can also count every match by a field (`group_by`: airline, read from the ICAO designator in the callsign, or operator, country or type for aircraft; type or destination for ships; the ten largest groups, then other and unknown counts; From 4f43ded5da41303d71258ffbf7b244e7f81b16f9 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:05:17 -0800 Subject: [PATCH 08/21] test(nominatim): check cache file modes only where POSIX modes exist The disk-cache test asserts the cache directory is 0o700 and its files 0o600. Windows has no POSIX mode bits (the directory stats as 0o666), so the test always failed there. Both checks now run only off Windows, as localServices.test.mjs and mcpPanelKey.test.mjs already do for their 0o600 checks. The cache code is unchanged. Co-Authored-By: Claude Opus 5.5 --- src/tooling/nominatimOutlineRoute.test.mjs | 30 ++++++++++++---------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/tooling/nominatimOutlineRoute.test.mjs b/src/tooling/nominatimOutlineRoute.test.mjs index c29b41f21..774099efe 100644 --- a/src/tooling/nominatimOutlineRoute.test.mjs +++ b/src/tooling/nominatimOutlineRoute.test.mjs @@ -684,25 +684,29 @@ test('the disk cache survives a restart and stays within its bounds', async () = await createNominatimCache({ dir, now: time.now }).get('k0'), null, ); - assert.equal( - fs.statSync(dir).mode & 0o777, - 0o700, - 'the query cache directory is private', - ); + // Windows has no POSIX mode bits (directories stat as 0o666), so only + // POSIX systems can check privacy this way. + if (process.platform !== 'win32') + assert.equal( + fs.statSync(dir).mode & 0o777, + 0o700, + 'the query cache directory is private', + ); const text = fs .readdirSync(dir) .filter((name) => name.endsWith('.json')) .map((name) => fs.readFileSync(path.join(dir, name), 'utf8')) .join('\n'); assert.equal(text.includes('k4'), false, 'disk entries never store raw keys'); - for (const name of fs - .readdirSync(dir) - .filter((entry) => entry.endsWith('.json'))) - assert.equal( - fs.statSync(path.join(dir, name)).mode & 0o777, - 0o600, - `${name} is private`, - ); + if (process.platform !== 'win32') + for (const name of fs + .readdirSync(dir) + .filter((entry) => entry.endsWith('.json'))) + assert.equal( + fs.statSync(path.join(dir, name)).mode & 0o777, + 0o600, + `${name} is private`, + ); fs.rmSync(dir, { recursive: true, force: true }); }); From 1c2cdc67752df80491ff6aa97708074b18d3029c Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:06:45 -0800 Subject: [PATCH 09/21] changelog: Nominatim cache mode test on Windows (#1547) Co-Authored-By: Claude Opus 5.5 --- changelog.d/1547-nominatim-cache-mode-windows.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1547-nominatim-cache-mode-windows.md diff --git a/changelog.d/1547-nominatim-cache-mode-windows.md b/changelog.d/1547-nominatim-cache-mode-windows.md new file mode 100644 index 000000000..9a48b1ee6 --- /dev/null +++ b/changelog.d/1547-nominatim-cache-mode-windows.md @@ -0,0 +1,3 @@ +- The Nominatim disk-cache test checks the cache directory and file modes + only where POSIX modes exist, so it no longer always fails on Windows + (quanticsoul4772, #1547). From e2ccac56ae9c8e8b087197c925c726281ae4fd7c Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 4 Nov 2026 21:58:42 -0500 Subject: [PATCH 10/21] fix(perimeters): show a 0.25-acre fire as 0.25 ac, not 0 ac The incident card rounded the WFIGS size to whole acres, so every fire under half an acre read "0 ac". The live perimeter feed carries many of these (0.01 to 0.37 acres). Sizes under one acre now keep the reported hundredths. --- docs/CURRENT-STATE.md | 4 ++++ src/layers/perimeters/cards.js | 5 ++++- src/layers/perimeters/cards.test.mjs | 31 ++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f1244947d..e954ae082 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4239,6 +4239,10 @@ by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot. Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. A stale snapshot is served for at most 24 hours; the layer row then reads STALE, dated by that snapshot's fetch, and `get_fire_perimeters` returns `stale` and `retrieved_at` with "(data may be stale)" in its summary. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment. +The incident card rounds the WFIGS size to whole acres, except under one acre, +where it keeps the reported hundredths: a 0.25-acre fire reads `0.25 ac`, not +`0 ac`. + Prohibited & Restricted Airspace is off by default (share-link token `8`) and sits in Infrastructure. The proxy pages `TYPE_CODE IN ('P','R')` from the FAA SUA FeatureServer, caps the body, and reuses a one-hour snapshot, serving the last diff --git a/src/layers/perimeters/cards.js b/src/layers/perimeters/cards.js index aef5913ce..a6cb3ac4d 100644 --- a/src/layers/perimeters/cards.js +++ b/src/layers/perimeters/cards.js @@ -159,7 +159,10 @@ export function buildIncidentCard(row, nowMs, { link = null } = {}) { // the known cause, behavior and complexity values and the wording around them. const facts = []; if (Number.isFinite(row.acres)) { - const acres = Math.round(row.acres); + // WFIGS reports sizes to the hundredth of an acre; a fire under one acre + // keeps them rather than reading "0 ac". + const acres = + row.acres < 1 ? Math.round(row.acres * 100) / 100 : Math.round(row.acres); facts.push( tn(acres, '{acres} ac', '{acres} ac', { acres: formatNumber(acres) }), ); diff --git a/src/layers/perimeters/cards.test.mjs b/src/layers/perimeters/cards.test.mjs index 0e064bff0..fe4aac06b 100644 --- a/src/layers/perimeters/cards.test.mjs +++ b/src/layers/perimeters/cards.test.mjs @@ -302,3 +302,34 @@ test('partial incident details render only the facts that exist', () => { 'Catron County · $85K to date', ]); }); + +test('a fire under one acre keeps its WFIGS size instead of reading 0 ac', () => { + const row = { + stableId: 'x', + name: 'Grey', + acres: 0.25, + containedPct: null, + state: 'US-WA', + category: 'WF', + discoveredTime: null, + updatedTime: null, + cause: null, + behavior: null, + personnel: null, + county: null, + costToDate: null, + complexity: null, + }; + assert.equal( + buildIncidentCard(row, 1758000000000).details[0], + '0.25 ac · containment unknown · US-WA', + ); + assert.equal( + buildIncidentCard({ ...row, acres: 0.01 }, 1758000000000).details[0], + '0.01 ac · containment unknown · US-WA', + ); + assert.equal( + buildIncidentCard({ ...row, acres: 1.4 }, 1758000000000).details[0], + '1 ac · containment unknown · US-WA', + ); +}); From 79c867c8cbd923b58bbed7873217eb43be12b643 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 4 Nov 2026 22:14:16 -0500 Subject: [PATCH 11/21] docs(changelog): add the perimeter size fragment for #1569 --- changelog.d/1569-perimeters-sub-acre-size.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1569-perimeters-sub-acre-size.md diff --git a/changelog.d/1569-perimeters-sub-acre-size.md b/changelog.d/1569-perimeters-sub-acre-size.md new file mode 100644 index 000000000..5ee4ffa98 --- /dev/null +++ b/changelog.d/1569-perimeters-sub-acre-size.md @@ -0,0 +1,3 @@ +- Show a fire perimeter under one acre at its reported size. The incident card + rounded WFIGS sizes to whole acres, so a 0.25-acre fire read "0 ac" + (Rudy Celekli, @rudycelekli, #1569). From e5ab5f945a3ea5ae28c21a200a63fd2277f4d0dd Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Thu, 5 Nov 2026 05:43:36 -0800 Subject: [PATCH 12/21] test(nominatim): share one POSIX guard and say the cache is private only on POSIX Co-Authored-By: Claude Opus 5.5 --- src/tooling/nominatimOutlineRoute.test.mjs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/tooling/nominatimOutlineRoute.test.mjs b/src/tooling/nominatimOutlineRoute.test.mjs index 774099efe..74f0b2d6a 100644 --- a/src/tooling/nominatimOutlineRoute.test.mjs +++ b/src/tooling/nominatimOutlineRoute.test.mjs @@ -684,29 +684,34 @@ test('the disk cache survives a restart and stays within its bounds', async () = await createNominatimCache({ dir, now: time.now }).get('k0'), null, ); - // Windows has no POSIX mode bits (directories stat as 0o666), so only - // POSIX systems can check privacy this way. - if (process.platform !== 'win32') + // The cache is private only through POSIX mode bits. Windows has none + // (directories stat as 0o666) and the cache keeps the checkout's ACLs, + // so these checks run, and privacy holds, only on POSIX systems. + const posixModes = process.platform !== 'win32'; + if (posixModes) { assert.equal( fs.statSync(dir).mode & 0o777, 0o700, - 'the query cache directory is private', + 'the query cache directory is private (POSIX modes)', ); + } const text = fs .readdirSync(dir) .filter((name) => name.endsWith('.json')) .map((name) => fs.readFileSync(path.join(dir, name), 'utf8')) .join('\n'); assert.equal(text.includes('k4'), false, 'disk entries never store raw keys'); - if (process.platform !== 'win32') + if (posixModes) { for (const name of fs .readdirSync(dir) - .filter((entry) => entry.endsWith('.json'))) + .filter((entry) => entry.endsWith('.json'))) { assert.equal( fs.statSync(path.join(dir, name)).mode & 0o777, 0o600, - `${name} is private`, + `${name} is private (POSIX modes)`, ); + } + } fs.rmSync(dir, { recursive: true, force: true }); }); From df87419e64815164062637b00769c502999a3bb7 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Thu, 5 Nov 2026 05:43:42 -0800 Subject: [PATCH 13/21] test(tools): pin the raw near-360 satellite azimuths and list the tools with the heading helper Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 9 ++++----- src/tools/queries/environment.js | 1 + src/tools/queries/space.test.mjs | 19 ++++++++++++++++++- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index e546fd563..d03064e8f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -246,10 +246,9 @@ perimeters, terrain height, military installations and map features, plus a combined situation brief, military awareness around a point, the app's heads-up display caption, and a link that opens the app over an area. Tools reuse the layers' portable source factories, take a shared `area` argument (place name, bounding box, or point and radius) and cap -lists at 25 rows by default. Vessel `course_deg` and `heading_deg`, aircraft -`course_deg`, transit `bearing_deg`, camera `heading_deg` and satellite -`rise_azimuth_deg` and `azimuth_deg` are whole degrees from 0 to 359; a -direction that rounds to 360 reads 0, and larger or negative values wrap. `aircraft_in_area` and `vessels_in_area` can also +lists at 25 rows by default. Their whole-degree directions round through +`roundHeadingDegrees`, listed with the heading readouts below. +`aircraft_in_area` and `vessels_in_area` can also count every match by a field (`group_by`: airline, read from the ICAO designator in the callsign, or operator, country or type for aircraft; type or destination for ships; the ten largest groups, then other and unknown counts; @@ -6091,7 +6090,7 @@ apply to `public/` assets or built-preview serving. - **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged. - **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; its destination label rounds to whole degrees in 000–359, wrapping rounded north to 000° while the chevron continues to use the relative bearing independently of label rounding; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, and `NOIR`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence. - Cockpit wind-direction labels wrap after rounding to whole degrees, so a direction of 359.5° displays as `N · 0°` rather than `N · 360°`. -- The transit, ALPR, vessel, CCTV, Local ADS-B, flight and military context, awareness, Cockpit and Street Level heading readouts round through `roundHeadingDegrees` in `src/data/headingDegrees.js` (whole degrees 0–359, `null` when not finite, so each caller keeps its own placeholder); `normalizeHeading` lives there too and `cockpitMath.js` re-exports it. The Cockpit's `TRACK ACQUIRED` signal uses `formatHeadingReadout`, so a 359.6° track reads `COURSE 000°`. +- The transit, ALPR, vessel, CCTV, Local ADS-B, flight and military context, awareness, Cockpit and Street Level heading readouts, and the query tools' vessel `course_deg` and `heading_deg`, aircraft `course_deg`, transit `bearing_deg`, camera `heading_deg` and satellite `rise_azimuth_deg` and `azimuth_deg`, round through `roundHeadingDegrees` in `src/data/headingDegrees.js` (whole degrees 0–359, `null` when not finite, so each caller keeps its own placeholder); `normalizeHeading` lives there too and `cockpitMath.js` re-exports it. The Cockpit's `TRACK ACQUIRED` signal uses `formatHeadingReadout`, so a 359.6° track reads `COURSE 000°`. - **Cockpit missing observations:** missing local weather observations remain unknown rather than being converted to zero or clear skies, in the briefing and in the cloud effects; a missing temperature drops the whole local weather record, including its wind and condition. - **Cockpit weather status:** the earlier multi-canvas atmospheric compositor remains fail-closed and is not attached to the live viewer. Cockpit clouds are a separate transparent WebGL pass with a capped 520×320 framebuffer, 24 ray steps, three FBM octaves, and a 12 FPS ceiling. It defaults off and starts only when local storage explicitly contains the persisted `WX ON` opt-in (`'1'`). When opted in, observations refresh after five minutes or 25 km of aircraft movement, fail transparent when unavailable or clear, and stop on exit or disable. `WX OFF` governs atmospheric rendering only: the briefing still fetches source-backed Natural Earth region, headline, and Open-Meteo local-information data, aborting and replacing any in-flight request when the selected aircraft changes. No weather effect runs in map mode and no synthetic fallback is shown. - **Cockpit trail visibility:** entering cockpit hides the selected aircraft's trail body and head so they cannot cross the first-person view; exit restores them. This cockpit-only presentation change does not alter the normal map-mode invariant that aircraft trails render through terrain using their depth-fail material. diff --git a/src/tools/queries/environment.js b/src/tools/queries/environment.js index 08db732fc..0af696e36 100644 --- a/src/tools/queries/environment.js +++ b/src/tools/queries/environment.js @@ -71,6 +71,7 @@ function weatherRow(weather) { precipitation_mm: weather.precipitationMm ?? null, cloud_cover_pct: weather.cloudCoverPct ?? null, wind_kph: weather.windKph ?? null, + // Raw Open-Meteo value, like the fields above; it reports north as 360. wind_direction_deg: weather.windDirectionDeg ?? null, visibility_m: weather.visibilityM ?? null, }; diff --git a/src/tools/queries/space.test.mjs b/src/tools/queries/space.test.mjs index 0e44fe208..41394e305 100644 --- a/src/tools/queries/space.test.mjs +++ b/src/tools/queries/space.test.mjs @@ -2,6 +2,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { twoline2satrec } from 'satellite.js'; import { composeCatalog, coreTools } from '../index.js'; +import { + findNextSatellitePass, + lookAnglesAt, +} from '../../data/satellitePass.js'; import { noradIdFromSatnum, parseTleText, @@ -301,7 +305,20 @@ test('satellite azimuths that round to 360 read as 0', async () => { tools: coreTools, services: { satellites: satellites(), clock: CLOCK }, }); - // From these points the ISS rises at 359.7° and stands at 359.8° at CLOCK. + // These points are chosen so the ISS's raw azimuths fall just below 360°; + // pin that first, so the test cannot pass on a raw value already near 0. + const [, line1, line2] = TLE.split('\n'); + const iss = twoline2satrec(line1, line2); + const nearNorth = (deg) => deg > 359.5 && deg < 360; + const rawPass = findNextSatellitePass({ + satrec: iss, + latDeg: 10, + lonDeg: 30, + fromMs: CLOCK.now(), + }); + assert.ok(nearNorth(rawPass.riseAzDeg), `rise ${rawPass.riseAzDeg}°`); + const rawLook = lookAnglesAt(iss, CLOCK.now(), 39.25, 65.75); + assert.ok(nearNorth(rawLook.azDeg), `azimuth ${rawLook.azDeg}°`); const pass = await catalog.call('next_satellite_pass', { location: { lat: 10, lon: 30 }, }); From b86c650428db01c0b09fadda54eabbf523ca506b Mon Sep 17 00:00:00 2001 From: Enzo Abyie Date: Fri, 6 Nov 2026 20:14:03 -0500 Subject: [PATCH 14/21] i18n(fr): typography, terminology and wording fixes --- src/i18n/locales/fr.json | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 738800324..485d3a93a 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1018,11 +1018,11 @@ "Longitudes must be within ±180°": "Les longitudes doivent être comprises entre −180° et +180°", "Select one side of the dateline": "Ne traversez pas la ligne de changement de date", "Box must enclose an area": "La zone doit délimiter une surface", - "Add the full Starlink broadband shell (thousands of extra points)": "Ajouter toute la constellation Starlink (des milliers de points en plus)", - "Loading the Starlink shell…": "Chargement de la constellation Starlink…", + "Add the full Starlink broadband shell (thousands of extra points)": "Ajouter toute la couche de la constellation Starlink (des milliers de points en plus)", + "Loading the Starlink shell…": "Chargement de la couche de la constellation Starlink…", "Starlink {detail} — click to retry": "Starlink {detail} — cliquer pour réessayer", "load failed": "échec du chargement", - "Showing the full Starlink shell — click for the core catalog only": "Constellation Starlink complète affichée — cliquer pour le catalogue de base seulement", + "Showing the full Starlink shell — click for the core catalog only": "Couche complète de la constellation Starlink affichée — cliquer pour le catalogue de base seulement", "DENSE ···": "DENSE ···", "DENSE ✕": "DENSE ✕", "DENSE": "DENSE", @@ -1105,7 +1105,7 @@ "Hybrid": "Hybride", "Lines": "Lignes", "Dots": "Points", - "Road lines colored by live flow: green to red": "Routes colorées selon le trafic en direct : du vert au rouge", + "Road lines colored by live flow: green to red": "Routes colorées selon le trafic en direct : du vert au rouge", "Moving dots colored by live flow": "Points mobiles colorés selon le trafic en direct", "Moving dots": "Points mobiles", "TomTom key unavailable": "Clé TomTom indisponible", @@ -1121,7 +1121,7 @@ "LIVE · Roads: {source} · No flow roads in view": "DIRECT · Routes : {source} · Aucune route avec flux dans la vue", "LIVE · Roads: {source} · Flow {percent}%": "DIRECT · Routes : {source} · Flux {percent} %", "SIMULATED · Roads: {source} · Flow {percent}%": "SIMULÉ · Routes : {source} · Flux {percent} %", - "STALE · Roads: {source} · Flow: cached TomTom tiles": "OBSOLÈTE · Routes : {source} · Flux : tuiles TomTom en cache", + "STALE · Roads: {source} · Flow: cached TomTom tiles": "OBSOLÈTE · Routes : {source} · Flux : tuiles TomTom en cache", "Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "Synchro du flux · Routes : OpenStreetMap · Flux : TomTom · Non appariées : simulées", "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "DIRECT · Routes : OpenStreetMap · Flux : TomTom · couv. {percent} % · Non appariées : simulées", "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "DIRECT · Routes : OpenStreetMap · Flux : TomTom · couv. {percent} %", @@ -1218,7 +1218,7 @@ "Contiguous US · gaps ≠ no rain": "États-Unis contigus · lacunes ≠ absence de pluie", "Americas + Pacific · not individual strikes": "Amériques + Pacifique · pas des impacts individuels", "Color: strikes/km²/min ×10³": "Couleur : impacts/km²/min ×10³", - "Color: {units}": "Couleur : {units}", + "Color: {units}": "Couleur : {units}", "RADAR · {units}": "RADAR · {units}", "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · retard habituel de 2 à 3 h", "North America · infrared imagery": "Amérique du Nord · imagerie infrarouge", @@ -1350,7 +1350,7 @@ "{label} is unavailable": "{label} est indisponible", "This map stack": "Ce fond de carte", "{label} unavailable: {hint}": "{label} indisponible : {hint}", - "Google 3D tiles unavailable — check the key's API restrictions, quota, or network": "Tuiles Google 3D indisponibles — vérifiez les restrictions d'API de la clé, le quota ou le réseau", + "Google 3D tiles unavailable — check the key's API restrictions, quota, or network": "Google 3D Tiles indisponible — vérifiez les restrictions d'API de la clé, le quota ou le réseau", "Needs {envVars} — add it in Provider Settings — or a Cesium ion token for the ion-hosted route": "Nécessite {envVars} — à ajouter dans les paramètres des fournisseurs — ou un jeton Cesium ion pour la variante hébergée par ion", "Exit cockpit to fly to a camera": "Quittez le cockpit pour rejoindre une caméra", "Exit cockpit to fly to a location": "Quittez le cockpit pour rejoindre un lieu", @@ -1489,7 +1489,7 @@ "Apply details": "Appliquer les détails", "Select a scene first": "Sélectionnez d'abord une scène", "Share selected scene": "Partager la scène sélectionnée", - "Scene JSON preserves authored settings and attribution. It does not include asset files. Bundles include only pack files you choose here or previously imported bundle files.": "Le JSON de scène conserve les réglages de l'auteur et l'attribution. Il n'inclut pas les fichiers de ressources. Les archives n'incluent que les fichiers de paquets choisis ici ou ceux d'archives déjà importées.", + "Scene JSON preserves authored settings and attribution. It does not include asset files. Bundles include only pack files you choose here or previously imported bundle files.": "Le JSON de scène conserve les paramètres de l'auteur et l'attribution. Il n'inclut pas les fichiers de ressources. Les archives n'incluent que les fichiers de paquets choisis ici ou ceux d'archives déjà importées.", "Download scene JSON": "Télécharger le JSON de scène", "Choose data-pack files": "Choisir des fichiers de paquet de données", "Or choose a data-pack folder": "Ou choisir un dossier de paquet de données", @@ -2574,7 +2574,7 @@ "{layer} is enabled on the {source} feed, but no airborne aircraft is loaded in the {place} view yet": "La couche {layer} est activée sur le flux {source}, mais aucun aéronef en vol n'est encore chargé dans la vue {place}", "{layer} is enabled, but no airborne aircraft is loaded in the {place} view yet": "La couche {layer} est activée, mais aucun aéronef en vol n'est encore chargé dans la vue {place}", "{layer} is enabled, but its aircraft feed is unavailable": "La couche {layer} est activée, mais son flux d'aéronefs est indisponible", - "{layer} is enabled, but its destination refresh did not complete": "La couche {layer} est activée, mais son actualisation à destination ne s'est pas terminée", + "{layer} is enabled, but its destination refresh did not complete": "La couche {layer} est activée, mais l'actualisation de ses destinations ne s'est pas terminée", "{layer} is enabled, but {source} is unavailable": "La couche {layer} est activée, mais {source} est indisponible", "Could not arrive at {place}": "Impossible d'atteindre {place}", "{name} is no longer loaded": "{name} n'est plus chargé", @@ -2810,7 +2810,7 @@ "AVAILABLE MISSIONS": "MISSIONS DISPONIBLES", "SELECT A MISSION TO INSPECT": "CHOISIR UNE MISSION À EXAMINER", "LOADING 30-DAY MISSION INDEX": "CHARGEMENT DE L'INDEX DES MISSIONS À 30 JOURS", - "Space Missions unavailable: the mission index could not load. Try again later.": "Missions spatiales indisponibles : impossible de charger l’index des missions. Réessayez plus tard.", + "Space Missions unavailable: the mission index could not load. Try again later.": "Missions spatiales indisponibles : impossible de charger l'index des missions. Réessayez plus tard.", "30-DAY MISSION INDEX UNAVAILABLE": "INDEX DES MISSIONS À 30 JOURS INDISPONIBLE", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB APERÇU · ENTER / SPACE SÉLECTIONNE", "Internet radio and local SDR": "Radio Internet et SDR local", @@ -2831,7 +2831,7 @@ "Radio volume": "Volume de la radio", "STATION SITE": "SITE DE LA STATION", "DIRECTORY: RADIO BROWSER": "ANNUAIRE : RADIO BROWSER", - "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Le flux audio se connecte directement au diffuseur dès que vous lancez la lecture, choisissez une station sur le globe ou la demandez par la voix. Votre IP est visible par ce diffuseur.", + "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Le flux audio se connecte directement au diffuseur lorsque vous lancez la lecture, que vous choisissez une station sur le globe ou que vous demandez sa lecture à la voix. Votre IP est visible par ce diffuseur.", "Local RTL-SDR receiver": "Récepteur RTL-SDR local", "LOCAL RTL-SDR": "RTL-SDR LOCAL", "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · un seul tuner local partagé entre la FM et la couche ADS-B local.", @@ -3194,7 +3194,7 @@ "The provider refused access for this key. Check the key's project, region or permissions.": "Le fournisseur a refusé l'accès pour cette clé. Vérifiez le projet, la région ou les autorisations de la clé.", "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Le compte du fournisseur n'a plus de crédit. Vérifiez la facturation ou choisissez un autre fournisseur d'IA dans les paramètres.", "SIGHTING": "OBSERVATION", - "Show {name} sighting": "Afficher l’observation de {name}", + "Show {name} sighting": "Afficher l'observation de {name}", "seen {age} ago": "vu il y a {age}", "observed {age} ago": "observé il y a {age}", "Be Whale Wise": "Respectez les cétacés", @@ -3232,7 +3232,7 @@ "Cetacean": "Cétacé", "Wildlife Sightings": "Observations naturalistes", "Exit cockpit to fly to a sighting": "Quittez le cockpit pour rejoindre une observation", - "Exit cockpit to fly to a sighting area": "Quittez le cockpit pour rejoindre une zone d’observation", + "Exit cockpit to fly to a sighting area": "Quittez le cockpit pour rejoindre une zone d'observation", "Wildlife": "Faune et flore", "Zoom in for local sightings": "Zoomez pour les observations locales", "Loading photo...": "Chargement de la photo…", @@ -3242,18 +3242,18 @@ "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", "{count} sightings in view": "{count} observations dans la vue", - "Close sighting": "Fermer l’observation", - "Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Faune observée au cours des dernières 48 h (cétacés 7 jours). La couleur et le pictogramme du badge indiquent le groupe taxonomique. Les espèces sensibles sont limitées à des cellules de 0,2° ; les signalements de mammifères marins Acartia en mer des Salish sont arrondis à 0,01°. Pas un recensement.", + "Close sighting": "Fermer l'observation", + "Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Faune observée au cours des dernières 48 h (cétacés 7 jours). La couleur et le pictogramme du badge indiquent le groupe taxonomique. Les espèces sensibles sont limitées à des cellules de 0,2° ; les signalements de mammifères marins Acartia en mer des Salish sont arrondis à 0,01°. Pas un recensement.", "Open {name} on iNaturalist": "Ouvrir {name} sur iNaturalist", "Open {name} on Acartia": "Ouvrir {name} sur Acartia", "iNaturalist": "iNaturalist", "iNaturalist contributors · licences per record": "Contributeurs iNaturalist · licences par observation", "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Contributeurs iNaturalist · Acartia {count} (mer des Salish) · licences par observation", - "{days}d": "{days}j", + "{days}d": "{days} j", "All markers": "Tous les marqueurs", "Show markers of every age": "Afficher les marqueurs de tout âge", - "Maximum event age: {days}d; undated markers are kept": "Ancienneté maximale : {days} j ; marqueurs sans date conservés", + "Maximum event age: {days}d; undated markers are kept": "Ancienneté maximale : {days} j ; marqueurs sans date conservés", "event age filter|All": "Tous", "Event age": "Ancienneté", - "Event age: {days}d · undated kept": "Ancienneté : {days} j · marqueurs sans date conservés" + "Event age: {days}d · undated kept": "Ancienneté : {days} j · marqueurs sans date conservés" } From 36c29d6ab49ada6ae6b89f912a10d6640e96ded5 Mon Sep 17 00:00:00 2001 From: Enzo Abyie Date: Fri, 6 Nov 2026 20:14:15 -0500 Subject: [PATCH 15/21] changelog: french catalog review --- changelog.d/1611-fr-catalog-review.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changelog.d/1611-fr-catalog-review.md diff --git a/changelog.d/1611-fr-catalog-review.md b/changelog.d/1611-fr-catalog-review.md new file mode 100644 index 000000000..dad302092 --- /dev/null +++ b/changelog.d/1611-fr-catalog-review.md @@ -0,0 +1,2 @@ +- French catalog review: missing narrow no-break spaces, apostrophes, one + mistranslated message and the Starlink shell wording (abiyeenzo, #1611). From c5bdcdf37f575543deea0424a46db1b221046e8c Mon Sep 17 00:00:00 2001 From: daika Date: Sun, 8 Nov 2026 04:02:47 +0700 Subject: [PATCH 16/21] chore(format): adopt responseCompression.test.mjs into the format scope --- scripts/format-scope.json | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index b23ab75b0..6c5354aa2 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1120,6 +1120,7 @@ "src/sky/starrySky.test.mjs", "src/tooling/skyAssets.test.mjs", "src/tooling/skyAssets.paintOut.test.mjs", + "src/tooling/responseCompression.test.mjs", "scripts/qa-anime-sky.mjs", "scripts/qa-sky-model-lighting.mjs", "server/providers/overpass/requestAdmission.test.mjs", From da6b63b6fdd95df533d7fbfce2ae0a3b65b1d21a Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Mon, 9 Nov 2026 16:45:11 +0400 Subject: [PATCH 17/21] docs(tools): tighten the heading-wrap changelog, comments and overview --- changelog.d/1524-tools-heading-wrap.md | 7 +++---- docs/CURRENT-STATE.md | 4 +--- src/tools/queries/environment.js | 2 +- src/tools/queries/space.test.mjs | 4 ++-- 4 files changed, 7 insertions(+), 10 deletions(-) diff --git a/changelog.d/1524-tools-heading-wrap.md b/changelog.d/1524-tools-heading-wrap.md index 2a630f600..3647bf9a8 100644 --- a/changelog.d/1524-tools-heading-wrap.md +++ b/changelog.d/1524-tools-heading-wrap.md @@ -1,4 +1,3 @@ -- The agent and voice query tools report a direction that rounds to 360 as 0: - vessel course and heading, aircraft course, transit bearing, camera heading - and satellite azimuths now round through the shared heading helper the - layers use, which wraps into 0–359 (@quanticsoul4772, #1524). +- The agent and voice query tools wrap whole-degree directions into 0–359, so + a vessel course or heading, aircraft course, transit bearing, camera heading + or satellite azimuth that rounds to 360 reads 0 (@quanticsoul4772, #1524). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 57daf7849..0ffb49ea8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -398,9 +398,7 @@ perimeters, terrain height, military installations and map features, plus a combined situation brief, military awareness around a point, the app's heads-up display caption, and a link that opens the app over an area. Tools reuse the layers' portable source factories, take a shared `area` argument (place name, bounding box, or point and radius) and cap -lists at 25 rows by default. Their whole-degree directions round through -`roundHeadingDegrees`, listed with the heading readouts below. -`aircraft_in_area` and `vessels_in_area` can also +lists at 25 rows by default. `aircraft_in_area` and `vessels_in_area` can also count every match by a field (`group_by`: airline, read from the ICAO designator in the callsign, or operator, country or type for aircraft; type or destination for ships; the ten largest groups, then other and unknown counts; diff --git a/src/tools/queries/environment.js b/src/tools/queries/environment.js index 0af696e36..1f5800d53 100644 --- a/src/tools/queries/environment.js +++ b/src/tools/queries/environment.js @@ -71,7 +71,7 @@ function weatherRow(weather) { precipitation_mm: weather.precipitationMm ?? null, cloud_cover_pct: weather.cloudCoverPct ?? null, wind_kph: weather.windKph ?? null, - // Raw Open-Meteo value, like the fields above; it reports north as 360. + // Raw Open-Meteo value, so north reads 360. wind_direction_deg: weather.windDirectionDeg ?? null, visibility_m: weather.visibilityM ?? null, }; diff --git a/src/tools/queries/space.test.mjs b/src/tools/queries/space.test.mjs index 41394e305..cb12dfcbe 100644 --- a/src/tools/queries/space.test.mjs +++ b/src/tools/queries/space.test.mjs @@ -305,8 +305,8 @@ test('satellite azimuths that round to 360 read as 0', async () => { tools: coreTools, services: { satellites: satellites(), clock: CLOCK }, }); - // These points are chosen so the ISS's raw azimuths fall just below 360°; - // pin that first, so the test cannot pass on a raw value already near 0. + // At these points the ISS's raw azimuths fall just below 360°; check that + // first so the test cannot pass on a raw value near 0. const [, line1, line2] = TLE.split('\n'); const iss = twoline2satrec(line1, line2); const nearNorth = (deg) => deg > 359.5 && deg < 360; From 394f3216f8ec6dc828bd1f836d1dae24444d7479 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Mon, 9 Nov 2026 17:06:56 +0400 Subject: [PATCH 18/21] Drop the sub-acre rounding comment --- src/layers/perimeters/cards.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/layers/perimeters/cards.js b/src/layers/perimeters/cards.js index a6cb3ac4d..4c73d5733 100644 --- a/src/layers/perimeters/cards.js +++ b/src/layers/perimeters/cards.js @@ -159,8 +159,6 @@ export function buildIncidentCard(row, nowMs, { link = null } = {}) { // the known cause, behavior and complexity values and the wording around them. const facts = []; if (Number.isFinite(row.acres)) { - // WFIGS reports sizes to the hundredth of an acre; a fire under one acre - // keeps them rather than reading "0 ac". const acres = row.acres < 1 ? Math.round(row.acres * 100) / 100 : Math.round(row.acres); facts.push( From 2ea4a78e7554243801e83bd5712683d1dfd56c3b Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Mon, 9 Nov 2026 18:12:48 +0400 Subject: [PATCH 19/21] Fix the fr review follow-ups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "destination refresh" is the refresh at the place the camera flew to, so restore "son actualisation à destination". - Radio note: "que vous la demandez par la voix", "Votre adresse IP". - "{count} / 30D": "30 J", like "{days} j". - Starlink: "la couche complète", as in the active-state tooltip. - "Show {name} sighting": colon form, so names starting with a vowel do not break the elision after "de". - Wildlife note: Acartia is the source, not a kind of marine mammal. - Straight apostrophes in the five strings added on main since. --- src/i18n/locales/fr.json | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index a6f5f7e18..24cd94528 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -820,7 +820,7 @@ "FLIGHT {count}": "VOL {count}", "REUSED": "RÉUTILISÉ", "NO STAGE RE-ENTRY / RECOVERY DATA": "AUCUNE DONNÉE DE RENTRÉE / RÉCUPÉRATION D'ÉTAGE", - "{count} / 30D": "{count} / 30J", + "{count} / 30D": "{count} / 30 J", "NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW": "AUCUNE MISSION DISPONIBLE DANS LA FENÊTRE ACTUELLE DE 30 JOURS", "DATE UNAVAILABLE": "DATE INDISPONIBLE", "UNSPECIFIED OPERATOR": "OPÉRATEUR NON PRÉCISÉ", @@ -1052,7 +1052,7 @@ "Longitudes must be within ±180°": "Les longitudes doivent être comprises entre −180° et +180°", "Select one side of the dateline": "Ne traversez pas la ligne de changement de date", "Box must enclose an area": "La zone doit délimiter une surface", - "Add the full Starlink broadband shell (thousands of extra points)": "Ajouter toute la couche de la constellation Starlink (des milliers de points en plus)", + "Add the full Starlink broadband shell (thousands of extra points)": "Ajouter la couche complète de la constellation Starlink (des milliers de points en plus)", "Loading the Starlink shell…": "Chargement de la couche de la constellation Starlink…", "Starlink {detail} — click to retry": "Starlink {detail} — cliquer pour réessayer", "load failed": "échec du chargement", @@ -2616,7 +2616,7 @@ "{layer} is enabled on the {source} feed, but no airborne aircraft is loaded in the {place} view yet": "La couche {layer} est activée sur le flux {source}, mais aucun aéronef en vol n'est encore chargé dans la vue {place}", "{layer} is enabled, but no airborne aircraft is loaded in the {place} view yet": "La couche {layer} est activée, mais aucun aéronef en vol n'est encore chargé dans la vue {place}", "{layer} is enabled, but its aircraft feed is unavailable": "La couche {layer} est activée, mais son flux d'aéronefs est indisponible", - "{layer} is enabled, but its destination refresh did not complete": "La couche {layer} est activée, mais l'actualisation de ses destinations ne s'est pas terminée", + "{layer} is enabled, but its destination refresh did not complete": "La couche {layer} est activée, mais son actualisation à destination ne s'est pas terminée", "{layer} is enabled, but {source} is unavailable": "La couche {layer} est activée, mais {source} est indisponible", "Could not arrive at {place}": "Impossible d'atteindre {place}", "{name} is no longer loaded": "{name} n'est plus chargé", @@ -2912,7 +2912,7 @@ "Radio volume": "Volume de la radio", "STATION SITE": "SITE DE LA STATION", "DIRECTORY: RADIO BROWSER": "ANNUAIRE : RADIO BROWSER", - "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Le flux audio se connecte directement au diffuseur lorsque vous lancez la lecture, que vous choisissez une station sur le globe ou que vous demandez sa lecture à la voix. Votre IP est visible par ce diffuseur.", + "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Le flux audio se connecte directement au diffuseur lorsque vous lancez la lecture, que vous choisissez une station sur le globe ou que vous la demandez par la voix. Votre adresse IP est visible par ce diffuseur.", "Local RTL-SDR receiver": "Récepteur RTL-SDR local", "LOCAL RTL-SDR": "RTL-SDR LOCAL", "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · un seul tuner local partagé entre la FM et la couche ADS-B local.", @@ -3275,7 +3275,7 @@ "The provider refused access for this key. Check the key's project, region or permissions.": "Le fournisseur a refusé l'accès pour cette clé. Vérifiez le projet, la région ou les autorisations de la clé.", "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Le compte du fournisseur n'a plus de crédit. Vérifiez la facturation ou choisissez un autre fournisseur d'IA dans les paramètres.", "SIGHTING": "OBSERVATION", - "Show {name} sighting": "Afficher l'observation de {name}", + "Show {name} sighting": "Afficher l'observation : {name}", "seen {age} ago": "vu il y a {age}", "observed {age} ago": "observé il y a {age}", "Be Whale Wise": "Respectez les cétacés", @@ -3324,7 +3324,7 @@ "CC BY 4.0": "CC BY 4.0", "{count} sightings in view": "{count} observations dans la vue", "Close sighting": "Fermer l'observation", - "Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Faune observée au cours des dernières 48 h (cétacés 7 jours). La couleur et le pictogramme du badge indiquent le groupe taxonomique. Les espèces sensibles sont limitées à des cellules de 0,2° ; les signalements de mammifères marins Acartia en mer des Salish sont arrondis à 0,01°. Pas un recensement.", + "Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Faune observée au cours des dernières 48 h (cétacés 7 jours). La couleur et le pictogramme du badge indiquent le groupe taxonomique. Les espèces sensibles sont limitées à des cellules de 0,2° ; les signalements de mammifères marins en mer des Salish provenant d'Acartia sont arrondis à 0,01°. Pas un recensement.", "Open {name} on iNaturalist": "Ouvrir {name} sur iNaturalist", "Open {name} on Acartia": "Ouvrir {name} sur Acartia", "iNaturalist": "iNaturalist", @@ -3337,12 +3337,12 @@ "event age filter|All": "Tous", "Event age": "Ancienneté", "Event age: {days}d · undated kept": "Ancienneté : {days} j · marqueurs sans date conservés", - "Close camera preview": "Fermer l’aperçu de la caméra", - "Refresh map still": "Actualiser l’image fixe sur la carte", - "The map still uses a separate request and may show a different moment.": "L’image fixe de la carte utilise une requête distincte et peut montrer un autre moment.", + "Close camera preview": "Fermer l'aperçu de la caméra", + "Refresh map still": "Actualiser l'image fixe sur la carte", + "The map still uses a separate request and may show a different moment.": "L'image fixe de la carte utilise une requête distincte et peut montrer un autre moment.", "Video plays from Québec 511, which sees your IP address.": "La vidéo est lue depuis Québec 511, qui voit votre adresse IP.", "STATIONS": "STATIONS", - "Crewed stations and the craft that visit them": "Stations habitées et les vaisseaux qui s’y rendent", + "Crewed stations and the craft that visit them": "Stations habitées et les vaisseaux qui s'y rendent", "IMAGING": "IMAGERIE", "Earth observation: optical, radar, weather and science": "Observation de la Terre : optique, radar, météo et science", "TELECOM": "TÉLÉCOM", @@ -3355,7 +3355,7 @@ "Rocket bodies, fragments and dead satellites": "Étages de fusée, fragments et satellites hors service", "Classes may lag: SATCAT is a cached copy": "Classes peut-être en retard : SATCAT est une copie en cache", "Classes may lag: SATCAT unavailable": "Classes peut-être en retard : SATCAT indisponible", - "CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "SATCAT de CelesTrak donne le type et l’état de chaque objet. Sans copie à jour, les classes reposent sur la base UCS, les noms et les groupes CelesTrak : un satellite hors service depuis peut encore apparaître comme actif.", + "CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "SATCAT de CelesTrak donne le type et l'état de chaque objet. Sans copie à jour, les classes reposent sur la base UCS, les noms et les groupes CelesTrak : un satellite hors service depuis peut encore apparaître comme actif.", "STARLINK ···": "STARLINK ···", "STARLINK ✕": "STARLINK ✕", "SENSOR SWATH {value} KM": "FAUCHÉE CAPTEUR {value} KM", From 09bf01630bf27515a31fd6b3fbcf98baa36784f0 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Mon, 9 Nov 2026 18:14:30 +0400 Subject: [PATCH 20/21] docs(server): tighten compression comments and docs --- .../795-opensky-compress-cached-body.md | 12 ++-- docs/CURRENT-STATE.md | 2 +- server/providers/aircraft/opensky.js | 20 +++---- server/providers/common/compress.js | 55 ++++++------------- src/tooling/liveProviders.test.mjs | 23 ++++---- src/tooling/responseCompression.test.mjs | 23 +++----- 6 files changed, 49 insertions(+), 86 deletions(-) diff --git a/changelog.d/795-opensky-compress-cached-body.md b/changelog.d/795-opensky-compress-cached-body.md index c0bbadbed..3dff6a908 100644 --- a/changelog.d/795-opensky-compress-cached-body.md +++ b/changelog.d/795-opensky-compress-cached-body.md @@ -1,7 +1,5 @@ -- The live flight snapshot is compressed on the wire. `/api/flights` gzips its - cached global snapshot and its regional adsb.lol fallback once per upstream - refresh and serves that frame to clients offering gzip — roughly 860 KB - becomes roughly 258 KB — while a client that offers nothing receives the same - bytes uncompressed. Both answers carry `Vary: Accept-Encoding`, and a response - the proxy does not cache, such as an auth failure, is never sent under a - cached snapshot's encoding (daikaginza, #795). +- Compress the live flight snapshot on the wire. `/api/flights` gzips its + cached global snapshot and its regional adsb.lol fallback once per refresh + and sends that frame to clients that offer gzip: about 860 KB becomes about + 250 KB. Other clients get the same bytes uncompressed, and both answers carry + `Vary: Accept-Encoding` (daikaginza, #795). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7e7349035..22d783d84 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -6186,7 +6186,7 @@ apply to `public/` assets or built-preview serving. - `/api/overpass` is bounded by body/response caps, per-client/global rate limits, concurrency limits, operator-configured upstreams, in-flight dedupe, cache bounds, and static validation that every selector is spatially bounded. - `/api/military-installations` uses an independent limiter with the same 90-per-client/300-global one-minute bounds, so viewport installation refreshes never consume `/api/overpass` annotation capacity. - `/api/route` proxies bounded OSRM route requests for annotation routes, with profile allowlisting, distance caps, response caps, caching, and sanitized "no route found" errors. -- `/api/flights` negotiates response encoding. The cached global snapshot and the regional adsb.lol fallback are each gzipped once per refresh, so a client that offers gzip receives roughly 860 KB as 250 KB and one that does not receives the same bytes uncompressed; both answers carry `Vary: Accept-Encoding`. Bodies the proxy does not cache — auth failures, cooldown notices — are never sent under a cached response's encoding. +- `/api/flights` negotiates gzip. The cached global snapshot and the regional adsb.lol fallback are each gzipped once per refresh (about 860 KB becomes about 250 KB). Clients that offer gzip get that frame; other clients, and bodies under 1 KiB or that gzip cannot shrink, get identity bytes. Both answers carry `Vary: Accept-Encoding`. Uncached bodies, such as auth failures, are never sent under a cached frame's encoding. - Track endpoints: `/api/vessels/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/flights/track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/military/track?hex=` (400 for an invalid hex, otherwise `503 history_unavailable` with `Retry-After: 1800` and `Cache-Control: no-store`, no upstream request; military tracking uses its locally accumulated trail). - Realtime debug logs redact API keys, bearer tokens, client secrets, and image data URLs before writing to disk; request bodies are size-capped. - `/api/realtime/debug-log` enforces an always-on 120-per-client/400-global one-minute limiter (not the opt-in `GEV_RATELIMIT_OPENAI_PER_MIN` bucket the cost-bearing OpenAI routes share), appends asynchronously through a serialized queue, and rotates `realtime-conversations.jsonl` at 32 MB keeping one prior generation, so the sink is bounded at twice that regardless of session length. A malformed record answers 400 and a failed write 500, both with a fixed message. diff --git a/server/providers/aircraft/opensky.js b/server/providers/aircraft/opensky.js index 0c948ef7a..b1b07c9d2 100644 --- a/server/providers/aircraft/opensky.js +++ b/server/providers/aircraft/opensky.js @@ -20,9 +20,8 @@ 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. + * @type {Buffer|null} Gzip of `_openskyCacheBody`, written together with it. + * Null when the body is not worth compressing. */ let _openskyCacheGzip = null; /** @type {number} HTTP status of the cached response. */ @@ -409,9 +408,8 @@ async function fetchAdsbLolPointFallback(req) { const body = JSON.stringify(normalized); const record = { 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. + // Compressed once per entry; ADSBLOL_POINT_CACHE_MAX bounds the + // extra memory. gzip: await gzipBody(body), cachedAt: Date.now(), count: normalized.states.length, @@ -647,9 +645,8 @@ 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) { - // 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. + // Compress before writing either slot, so a request arriving + // mid-refresh still reads a body with its own frame. const frame = await gzipBody(body); _openskyCacheBody = body; _openskyCacheGzip = frame; @@ -763,9 +760,8 @@ export function openSkyProxy() { } } - // Only the coalesced refresh caches, so the gzip slot belongs to this - // body only when this body is the one it stored. An error response is - // not cached and must not borrow the previous snapshot's frame. + // The frame belongs to this body only if this body was cached. An + // uncached error must not borrow the previous snapshot's frame. const encoded = encodedBody( req, body, diff --git a/server/providers/common/compress.js b/server/providers/common/compress.js index ebf736eda..d9938b87e 100644 --- a/server/providers/common/compress.js +++ b/server/providers/common/compress.js @@ -4,39 +4,25 @@ import { promisify } from 'node:util'; const gzipAsync = promisify(gzip); /** - * Response compression for proxies that already hold a whole body in memory. + * Opt-in gzip for text bodies a proxy already holds 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. + * Not a middleware: `cctv/media.js` streams bodies and answers range requests, + * and several providers forward binary upstream types that gzip only grows. + * `encodedBody` returns the body with its headers so the two cannot disagree. */ -/** Below this, a gzip frame costs more than the bytes it saves. */ +/** Skip compression for bodies smaller than 1 KiB. */ const MIN_COMPRESSED_BYTES = 1024; /** - * The `qvalue` grammar of RFC 9110 section 12.4.2: zero to three decimals - * below one, or one with only zeros after it. Matching the whole token rather - * than parsing a numeric prefix is what makes `q=1oops` and the out-of-range - * `q=2` unreadable instead of quietly rounding to something acceptable. + * RFC 9110 section 12.4.2 `qvalue`. Matching the whole token keeps `q=1oops` + * and `q=2` unreadable instead of rounding them into acceptance. */ const QVALUE = /^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/; /** - * Whether the client offered gzip in `Accept-Encoding`. - * - * Anything this cannot read confidently — a missing header, a malformed or - * out-of-range quality value, the `x-gzip` alias — reads as "no", because the - * fallback is an identity response every client can decode. + * Whether `Accept-Encoding` offers gzip. Anything unreadable (no header, a bad + * q value, `x-gzip`) reads as no: every client can decode identity. * * @param {import('node:http').IncomingMessage} request * @returns {boolean} @@ -51,23 +37,18 @@ export function acceptsGzip(request) { .map((parameter) => parameter.trim().toLowerCase()) .find((parameter) => parameter.startsWith('q=')); if (quality === undefined) return true; - // `q=0` is the one way a client can name an encoding and still refuse it. + // `q=0` names gzip and refuses it. const value = quality.slice(2); return QVALUE.test(value) && Number(value) > 0; }); } /** - * Gzip a body once, for callers that cache it and serve it many times. + * Gzip a body once for a caller that caches it. Async, so the ~860 KB OpenSky + * snapshot does not block other requests while it compresses. * - * 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". + * Returns null, meaning "serve identity", when the body is under 1 KiB or gzip + * does not shrink it. * * @param {string|Buffer} body - the exact bytes the identity response sends. * @returns {Promise} @@ -81,11 +62,9 @@ export async function gzipBody(body) { } /** - * 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. + * Pick the body and the headers that describe it. `Vary` is on both branches + * so a shared cache never serves one encoding to a client that asked for the + * other. * * @param {import('node:http').IncomingMessage} request * @param {string|Buffer} identity - the uncompressed body. diff --git a/src/tooling/liveProviders.test.mjs b/src/tooling/liveProviders.test.mjs index 70410ec2f..3198ee752 100644 --- a/src/tooling/liveProviders.test.mjs +++ b/src/tooling/liveProviders.test.mjs @@ -615,8 +615,7 @@ test('the OpenSky snapshot is compressed for clients that ask, and an error body if (upstreamStatus !== 200) return new Response('{"error":"upstream"}', { status: upstreamStatus }); const sourceSeconds = Math.floor((now - snapshotAgeMs) / 1000); - // A healthy credit budget, so the proxy keeps its 9 s base TTL and the - // clock advances below are unambiguously past it. + // Healthy credits keep the 9 s base TTL, so the clock jumps below clear it. return Response.json( { time: sourceSeconds, @@ -634,8 +633,7 @@ test('the OpenSky snapshot is compressed for clients that ask, and an error body ); }); - // A module instance of its own: the snapshot cache, its frame and the - // adaptive TTL are module state, and this test reasons about all three. + // A fresh module: the snapshot cache, its frame and the TTL are module state. const isolated = await import( `../../server/providers/aircraft/opensky.js?compression=${now}` ); @@ -648,25 +646,25 @@ test('the OpenSky snapshot is compressed for clients that ask, and an error body assert.equal(compressed.headers.vary, 'Accept-Encoding'); assert.ok(Buffer.isBuffer(compressed.body)); - // Same snapshot from cache, this time to a client that offered nothing: the - // identity bytes, and the compressed frame above must decode to exactly them. + // Same cached snapshot, no Accept-Encoding: identity bytes, which the frame + // above must decode to. const identity = await states('/api/flights'); assert.equal(identity.headers['x-opensky-cache'], 'HIT'); assert.equal(identity.headers['content-encoding'], undefined); assert.equal(identity.headers.vary, 'Accept-Encoding'); assert.equal(gunzipSync(compressed.body).toString(), identity.body); - // A client that names gzip and refuses it with q=0 is a non-gzip client. + // `gzip;q=0` refuses gzip. const refused = await states('/api/flights', '/', 'GET', { 'accept-encoding': 'gzip;q=0', }); assert.equal(refused.headers['content-encoding'], undefined); assert.equal(refused.body, identity.body); - // An upstream rejection is not cached, so it has no frame of its own. It must - // not be sent under the previous snapshot's Content-Encoding. + // An upstream rejection is not cached, so it must not carry the previous + // snapshot's Content-Encoding. upstreamStatus = 403; - now += 60000; // past the 9 s base TTL this fresh instance starts with + now += 60000; // past the 9 s base TTL const rejected = await states('/api/flights', '/', 'GET', { 'accept-encoding': 'gzip', }); @@ -674,9 +672,8 @@ test('the OpenSky snapshot is compressed for clients that ask, and an error body assert.equal(rejected.headers['content-encoding'], undefined); assert.ok(JSON.parse(rejected.body).error, 'a readable error body'); - // The regional fallback is a fifth body this route can answer with. It is - // cached the same way and so it negotiates the same way: a snapshot too old - // to serve hands the request to adsb.lol, compressed. + // A snapshot too old to serve falls back to adsb.lol, which negotiates the + // same way. upstreamStatus = 200; snapshotAgeMs = 3600000; now += 60000; diff --git a/src/tooling/responseCompression.test.mjs b/src/tooling/responseCompression.test.mjs index e2dff6459..6fbd53193 100644 --- a/src/tooling/responseCompression.test.mjs +++ b/src/tooling/responseCompression.test.mjs @@ -17,7 +17,7 @@ function request(acceptEncoding) { }; } -/** Repetitive JSON, the shape of an actual snapshot body. */ +/** Repetitive JSON shaped like a snapshot body. */ function snapshotBody(states = 200) { return JSON.stringify({ time: 1700000000, @@ -33,10 +33,7 @@ function snapshotBody(states = 200) { }); } -/** - * Deterministic high-entropy bytes. Random data would do, but a seeded sequence - * keeps the "gzip made it bigger" assertion from depending on luck. - */ +/** Seeded high-entropy bytes, so the "gzip made it bigger" check is stable. */ function incompressibleBytes(length) { const bytes = Buffer.alloc(length); let state = 123456789; @@ -61,16 +58,15 @@ test('accept-encoding is read the way clients actually write it', () => { assert.equal(acceptsGzip({}), false); assert.equal(acceptsGzip(undefined), false); - // A quality of zero names gzip and refuses it (RFC 9110 section 12.4.2). + // `q=0` names gzip and refuses it (RFC 9110 section 12.4.2). assert.equal(acceptsGzip(request('gzip;q=0')), false); assert.equal(acceptsGzip(request('gzip;q=0.000')), false); assert.equal(acceptsGzip(request('gzip;q=0.001')), true); assert.equal(acceptsGzip(request('gzip;q=1')), true); assert.equal(acceptsGzip(request('gzip;q=1.000')), true); - // A quality that is not a whole `qvalue` is unreadable, not a number with - // trailing noise: the grammar allows three decimals and a ceiling of 1, so - // these must not round into acceptance. + // A q that is not a whole `qvalue` is unreadable and must not round into + // acceptance. assert.equal(acceptsGzip(request('gzip;q=bogus')), false); assert.equal(acceptsGzip(request('gzip;q=1oops')), false); assert.equal(acceptsGzip(request('gzip;q=0.5abc')), false); @@ -102,8 +98,7 @@ test('a body is only compressed when compression pays for itself', async () => { assert.ok(compressed.byteLength < Buffer.byteLength(identity)); assert.equal(gunzipSync(compressed).toString(), identity); - // Buffers are accepted as-is, and data that gzip cannot shrink is refused - // rather than served as a larger response. + // Buffers are accepted as-is; data gzip cannot shrink stays uncompressed. assert.ok((await gzipBody(Buffer.from(identity))) !== null); assert.equal(await gzipBody(incompressibleBytes(4096)), null); }); @@ -118,8 +113,7 @@ test('the encoded body and the header that names it are chosen together', async assert.equal(negotiated.headers.Vary, 'Accept-Encoding'); assert.equal(gunzipSync(negotiated.body).toString(), identity); - // Offered but not wanted: identity bytes, and no Content-Encoding to - // contradict them. + // Offered but refused: identity bytes and no Content-Encoding. const refused = encodedBody(request('br'), identity, compressed); assert.equal(refused.body, identity); assert.equal(refused.headers['Content-Encoding'], undefined); @@ -130,8 +124,7 @@ test('the encoded body and the header that names it are chosen together', async assert.equal(unavailable.body, identity); assert.equal(unavailable.headers['Content-Encoding'], undefined); - // Vary is on both branches: without it a shared cache could hand a stored - // response to a client whose Accept-Encoding it was not selected for. + // Vary is on both branches, so a shared cache cannot serve the wrong encoding. assert.equal(unavailable.headers.Vary, 'Accept-Encoding'); assert.equal( encodedBody(request(undefined), identity, compressed).headers.Vary, From caad795fa2aa1bfd2acf647823847817c149a3ba Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Mon, 9 Nov 2026 18:15:13 +0400 Subject: [PATCH 21/21] refactor(server): drop the null and String paths from gzipBody --- server/providers/common/compress.js | 3 +-- src/tooling/responseCompression.test.mjs | 2 -- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/server/providers/common/compress.js b/server/providers/common/compress.js index d9938b87e..cbce21c6e 100644 --- a/server/providers/common/compress.js +++ b/server/providers/common/compress.js @@ -54,8 +54,7 @@ export function acceptsGzip(request) { * @returns {Promise} */ export async function gzipBody(body) { - if (body == null) return null; - const raw = Buffer.isBuffer(body) ? body : Buffer.from(String(body)); + const raw = Buffer.isBuffer(body) ? body : Buffer.from(body); if (raw.byteLength < MIN_COMPRESSED_BYTES) return null; const compressed = await gzipAsync(raw); return compressed.byteLength < raw.byteLength ? compressed : null; diff --git a/src/tooling/responseCompression.test.mjs b/src/tooling/responseCompression.test.mjs index 6fbd53193..011d767c3 100644 --- a/src/tooling/responseCompression.test.mjs +++ b/src/tooling/responseCompression.test.mjs @@ -81,8 +81,6 @@ test('accept-encoding is read the way clients actually write it', () => { }); test('a body is only compressed when compression pays for itself', async () => { - assert.equal(await gzipBody(null), null); - assert.equal(await gzipBody(undefined), null); assert.equal(await gzipBody(''), null); assert.equal( await gzipBody('{"states":[]}'),