mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 06:12:18 +00:00
Merge branch 'main' into fix/realtime-voice-unicode-patterns
This commit is contained in:
89 files changed
+2980
-253
No files matched your search
@@ -83,6 +83,15 @@ export function adsbLolProxy() {
|
||||
// This mount also matches its /track route, served by its own handler.
|
||||
if (new URL(req.url || '/', 'http://localhost').pathname !== '/')
|
||||
return next();
|
||||
// After the /track hand-off, so /track keeps its own method handling.
|
||||
if (req.method !== 'GET') {
|
||||
res.writeHead(405, {
|
||||
'Content-Type': 'application/json',
|
||||
Allow: 'GET',
|
||||
});
|
||||
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (_cache && now - _cacheAt < CACHE_MS) {
|
||||
serve(res, 200, _cache, 'HIT');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import { normalizeAdsbLolPointResponse } from '../../../src/data/adsbLolFallback.js';
|
||||
import { encodedBody, gzipBody } from '../common/compress.js';
|
||||
import {
|
||||
coalesceProxyRequest,
|
||||
readResponseJsonCapped,
|
||||
@@ -18,6 +19,11 @@ 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 together with it.
|
||||
* Null when the body is not worth compressing.
|
||||
*/
|
||||
let _openskyCacheGzip = null;
|
||||
/** @type {number} HTTP status of the cached response. */
|
||||
let _openskyCacheStatus = 0;
|
||||
/** @type {number} Epoch-ms when the response was cached. */
|
||||
@@ -399,8 +405,12 @@ 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 once per entry; ADSBLOL_POINT_CACHE_MAX bounds the
|
||||
// extra memory.
|
||||
gzip: await gzipBody(body),
|
||||
cachedAt: Date.now(),
|
||||
count: normalized.states.length,
|
||||
};
|
||||
@@ -429,6 +439,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,
|
||||
@@ -439,8 +450,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;
|
||||
}
|
||||
|
||||
@@ -484,6 +496,15 @@ export function openSkyProxy() {
|
||||
// This mount also matches its /track route, served by its own handler.
|
||||
if (new URL(req.url || '/', 'http://localhost').pathname !== '/')
|
||||
return next();
|
||||
// After the /track hand-off, so /track keeps its own method handling.
|
||||
if (req.method !== 'GET') {
|
||||
res.writeHead(405, {
|
||||
'Content-Type': 'application/json',
|
||||
Allow: 'GET',
|
||||
});
|
||||
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
||||
return;
|
||||
}
|
||||
res.setHeader('X-Feed-Source', 'OpenSky Network');
|
||||
const requestedMode = normalizeOpenSkyAuthMode(
|
||||
process.env.OPENSKY_AUTH_MODE,
|
||||
@@ -514,9 +535,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',
|
||||
@@ -532,8 +557,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):
|
||||
@@ -628,7 +654,11 @@ 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) {
|
||||
// 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;
|
||||
_openskyCacheStatus = upstream.status;
|
||||
_openskyCacheTime = now;
|
||||
_openskyCacheSourceEpochMs = sourceEpochMs;
|
||||
@@ -681,9 +711,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,
|
||||
@@ -691,8 +725,9 @@ export function openSkyProxy() {
|
||||
staleSeconds: (now - _openskyCacheTime) / 1000,
|
||||
retryAfterSeconds: cooldownMs / 1000,
|
||||
}),
|
||||
);
|
||||
res.end(_openskyCacheBody);
|
||||
...encoded.headers,
|
||||
});
|
||||
res.end(encoded.body);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -734,16 +769,23 @@ export function openSkyProxy() {
|
||||
}
|
||||
}
|
||||
|
||||
res.writeHead(
|
||||
upstream.status,
|
||||
buildOpenSkyHeaders({
|
||||
// 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,
|
||||
_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) {
|
||||
@@ -754,17 +796,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(
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { gzip } from 'node:zlib';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const gzipAsync = promisify(gzip);
|
||||
|
||||
/**
|
||||
* Opt-in gzip for text bodies a proxy already holds in memory.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/** Skip compression for bodies smaller than 1 KiB. */
|
||||
const MIN_COMPRESSED_BYTES = 1024;
|
||||
|
||||
/**
|
||||
* 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 `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}
|
||||
*/
|
||||
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;
|
||||
const quality = parameters
|
||||
.map((parameter) => parameter.trim().toLowerCase())
|
||||
.find((parameter) => parameter.startsWith('q='));
|
||||
if (quality === undefined) return true;
|
||||
// `q=0` names gzip and refuses it.
|
||||
const value = quality.slice(2);
|
||||
return QVALUE.test(value) && Number(value) > 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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<Buffer|null>}
|
||||
*/
|
||||
export async function gzipBody(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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param {Buffer|null} compressed - `gzipBody(identity)`, or null.
|
||||
* @returns {{body: string|Buffer, headers: Record<string,string>}}
|
||||
*/
|
||||
export function encodedBody(request, identity, compressed) {
|
||||
if (compressed && acceptsGzip(request)) {
|
||||
return {
|
||||
body: compressed,
|
||||
headers: { 'Content-Encoding': 'gzip', Vary: 'Accept-Encoding' },
|
||||
};
|
||||
}
|
||||
return { body: identity, headers: { Vary: 'Accept-Encoding' } };
|
||||
}
|
||||
@@ -131,9 +131,9 @@ export async function fetchFirms(
|
||||
* NASA FIRMS live active-fire proxy with a memory + disk cache.
|
||||
* Upstream: https://firms.modaps.eosdis.nasa.gov/api/area/csv/{KEY}/{SOURCE}/world/2
|
||||
*
|
||||
* Merges three VIIRS NRT sources (NOAA-20, NOAA-21, Suomi-NPP — independent
|
||||
* satellites, no cross-source dedup) plus MODIS_NRT (combined Terra+Aqua,
|
||||
* ~1 km, numeric 0-100 confidence), fetched sequentially (quota courtesy)
|
||||
* Merges two VIIRS NRT sources (NOAA-20, NOAA-21 — independent satellites, no
|
||||
* cross-source dedup) plus MODIS_NRT (combined Terra+Aqua, ~1 km, numeric
|
||||
* 0-100 confidence), fetched sequentially (quota courtesy)
|
||||
* with `days=2` (`days=1` means "current UTC day", nearly empty just after
|
||||
* 00:00Z) and clamps to the trailing 24 h via src/data/firmsCsv.js. FIRMS
|
||||
* quota is 5,000 transactions / 10 min per MAP_KEY, so the cache is the point:
|
||||
@@ -141,6 +141,8 @@ export async function fetchFirms(
|
||||
* fresh-enough disk cache (.gev-cache/firms.json) prevents ANY upstream
|
||||
* fetch across dev-server restarts. Pattern mirrors celestrakProxy.
|
||||
*
|
||||
* VIIRS_SNPP_NRT is left out: NASA stopped delivering Suomi NPP on 2026-11-02.
|
||||
*
|
||||
* Routes:
|
||||
* GET /api/firms → {fetchedAt, stale, ttlMs, sources, count, fires}
|
||||
* GET /api/firms/status → {hasKey, lastFetch, count, stale, ttlMs, transactions}
|
||||
@@ -153,12 +155,7 @@ export async function fetchFirms(
|
||||
export function firmsProxy() {
|
||||
const TTL_MS = 30 * 60_000;
|
||||
const STATUS_TTL_MS = 5 * 60_000;
|
||||
const SOURCES = [
|
||||
'VIIRS_NOAA20_NRT',
|
||||
'VIIRS_NOAA21_NRT',
|
||||
'VIIRS_SNPP_NRT',
|
||||
'MODIS_NRT',
|
||||
];
|
||||
const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'MODIS_NRT'];
|
||||
/**
|
||||
* Per-source body cap. A VIIRS world/2 pull is ~130k rows x ~100 B, about
|
||||
* 13 MB per satellite in heavy fire season (MODIS runs smaller), so this is a
|
||||
|
||||
@@ -227,6 +227,11 @@ export function installRouteMiddleware(
|
||||
// only shape that asks the upstream for them. A response WITHOUT steps is
|
||||
// byte for byte what this endpoint has always returned.
|
||||
middlewares.use('/api/route', async (req, res) => {
|
||||
if (req.method !== 'GET') {
|
||||
res.writeHead(405, { 'Content-Type': 'application/json', Allow: 'GET' });
|
||||
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
||||
return;
|
||||
}
|
||||
const fail = (msg) => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: false, error: msg }));
|
||||
|
||||
Reference in new issue
Block a user