Merge upstream/main into pr/mapillary-layer

Upstream took share token '2' for fire perimeters, so Street Level moves to
'3'. mapillary-js joins upstream's optimizeDeps include list instead of
overriding it; catalog and registry tests count the new upstream layers.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Fable 5.1 committed 2026-09-25 17:58:27 +01:00
commit 9ba6f96d87
204 files changed
+24525 -314

No files matched your search

+6 -2
View File
@@ -45,7 +45,9 @@ export function trackBackfillProxies() {
RESPONSE_CAP_BYTES,
);
let body;
let status = upstream.status;
if (tooLarge) {
status = 502;
body = JSON.stringify({ error: 'Upstream track response too large' });
} else if (!upstream.ok) {
// Sanitize upstream error surface; status code is signal enough
@@ -53,8 +55,10 @@ export function trackBackfillProxies() {
} else {
body = text;
}
cachePut(key, { at: Date.now(), status: upstream.status, body });
res.statusCode = upstream.status;
// Cache the 502 like any upstream error: /tracks/all costs OpenSky
// credits, so a retry inside the window must not re-download the body.
cachePut(key, { at: Date.now(), status, body });
res.statusCode = status;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.end(body);
+5
View File
@@ -261,5 +261,10 @@ export const CCTV_FRAME_MAX_BODY_BYTES = 16 * 1024 * 1024;
/** Deadline for upstream response headers; live bodies keep streaming afterward. */
export const CCTV_MEDIA_FETCH_TIMEOUT_MS = 15 * 1000;
/** Silence a live body may carry before the relay gives up on it. Twice the
* header deadline, because a camera that is merely slow between frames is far
* more common than one that has died mid-stream, and a viewer would rather
* wait than be dropped. */
export const CCTV_MEDIA_IDLE_TIMEOUT_MS = 30 * 1000;
/** Declared size ceiling for fixed media responses. */
export const CCTV_MEDIA_MAX_BODY_BYTES = 64 * 1024 * 1024;
+45 -1
View File
@@ -4,6 +4,7 @@ import {
CCTV_FRAME_FETCH_TIMEOUT_MS,
CCTV_FRAME_MAX_BODY_BYTES,
CCTV_MEDIA_FETCH_TIMEOUT_MS,
CCTV_MEDIA_IDLE_TIMEOUT_MS,
CCTV_MEDIA_MAX_BODY_BYTES,
NSW_IMAGE_ORIGIN,
NSW_IMAGE_USER_AGENT,
@@ -103,11 +104,17 @@ export function toReadable(body) {
* @param {Response} upstream - fetch() Response object.
* @param {object} [opts]
* @param {string} [opts.sourceHeader='upstream'] - Value for X-CCTV-Source header.
* @param {number} [opts.idleTimeoutMs=CCTV_MEDIA_IDLE_TIMEOUT_MS] - Silence
* allowed between upstream chunks before the stream is released. Injectable
* only to keep the deadline unit-testable.
*/
export async function proxyMediaResponse(
res,
upstream,
{ sourceHeader = 'upstream' } = {},
{
sourceHeader = 'upstream',
idleTimeoutMs = CCTV_MEDIA_IDLE_TIMEOUT_MS,
} = {},
) {
const contentType =
upstream.headers.get('content-type') || 'application/octet-stream';
@@ -153,7 +160,20 @@ export async function proxyMediaResponse(
return;
}
// The header deadline only covers the wait for a response line. Past that an
// upstream can hold the connection open and send nothing at all, and the
// relay would wait on it for as long as the camera host cared to. The
// deadline below measures the gap between upstream chunks rather than the
// life of the stream, so a feed that keeps delivering keeps its connection.
let idleTimer = null;
const clearIdleDeadline = () => {
if (!idleTimer) return;
clearTimeout(idleTimer);
idleTimer = null;
};
stream.on('error', () => {
clearIdleDeadline();
if (!res.writableEnded) res.end();
});
@@ -164,6 +184,7 @@ export async function proxyMediaResponse(
const releaseUpstream = () => {
if (released) return;
released = true;
clearIdleDeadline();
stream.unpipe(res);
// Destroying the Node stream cancels the web body it wraps; the direct
// cancel covers a body that was never wrapped, and rejects harmlessly when
@@ -176,14 +197,37 @@ export async function proxyMediaResponse(
/* already closed */
}
};
const armIdleDeadline = () => {
clearIdleDeadline();
idleTimer = setTimeout(onIdleDeadline, idleTimeoutMs);
idleTimer.unref?.();
};
const onIdleDeadline = () => {
// A viewer who cannot keep up pauses the pipe, and no upstream bytes arrive
// while it is paused. That is a slow client rather than a dead camera, so
// it gets the deadline again instead of a teardown.
if (res.writableNeedDrain) {
armIdleDeadline();
return;
}
releaseUpstream();
if (!res.writableEnded) res.end();
};
res.once('close', () => {
clearIdleDeadline();
if (!res.writableEnded) releaseUpstream();
});
res.once('error', releaseUpstream);
stream.once('end', () => {
clearIdleDeadline();
released = true;
});
armIdleDeadline();
stream.pipe(res);
// Attached after the pipe because a data listener resumes the stream, and
// flowing before the destination is attached would spill chunks nobody
// forwards. Only bytes from upstream renew the deadline.
stream.on('data', armIdleDeadline);
}
/**
+232
View File
@@ -0,0 +1,232 @@
import { normalizeFirePerimeterSnapshot } from '../../src/layers/perimeters/records.js';
import {
readResponseJsonCapped,
readResponseTextCapped,
coalesceProxyRequest,
} from './common/http.js';
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
// NIFC WFIGS current interagency fire perimeters (public, keyless).
// maxAllowableOffset trades ~100 m of boundary fidelity for a payload small
// enough to refresh continuously (~1 MB for a typical fire season).
const API_URL =
'https://services3.arcgis.com/T4QMspbfLg3qTGWY/arcgis/rest/services/' +
'WFIGS_Interagency_Perimeters_Current/FeatureServer/0/query?' +
new URLSearchParams({
where: '1=1',
outFields: [
'poly_IncidentName',
'attr_UniqueFireIdentifier',
'attr_IncidentSize',
'attr_PercentContained',
'attr_POOState',
'attr_IncidentTypeCategory',
'attr_FireDiscoveryDateTime',
'poly_DateCurrent',
'attr_FireCause',
'attr_FireBehaviorGeneral',
'attr_TotalIncidentPersonnel',
'attr_POOCounty',
'attr_EstimatedCostToDate',
'attr_IncidentComplexityLevel',
'attr_CpxName',
].join(','),
maxAllowableOffset: '0.001',
outSR: '4326',
f: 'geojson',
}).toString();
// The service caps a single response at its maxRecordCount (2000); a peak
// season can exceed that, so follow exceededTransferLimit with offset pages.
const MAX_PAGES = 5;
const exceededTransferLimit = (payload) =>
payload?.exceededTransferLimit === true ||
payload?.properties?.exceededTransferLimit === true;
const MIB = 1024 * 1024;
/** Fixed-origin, bounded WFIGS and InciWeb routes for dev and preview. */
export function firePerimetersProxy({
fetchImpl = (...args) => globalThis.fetch(...args),
now = () => Date.now(),
} = {}) {
const cache = new Map();
const publications = new Map();
const inFlight = new Map();
const allow = makeRateLimiter({ windowMs: 60_000, max: 60, globalMax: 1200 });
async function upstream(url, cap, timeout, options = {}) {
const signal = AbortSignal.timeout(timeout);
const response = await fetchImpl(url, {
...options,
signal,
redirect: 'error',
});
if (!response.ok) {
await response.body?.cancel();
throw new Error('upstream_unavailable');
}
return readResponseJsonCapped(response, cap, signal);
}
async function fetchPerimeters() {
const features = [];
for (let page = 0; page < MAX_PAGES; page++) {
const url =
page === 0 ? API_URL : `${API_URL}&resultOffset=${features.length}`;
const payload = await upstream(url, 16 * MIB, 30_000);
if (!Array.isArray(payload?.features))
throw new Error('invalid_snapshot');
features.push(...payload.features);
if (!exceededTransferLimit(payload) || !payload.features.length) break;
}
const rows = normalizeFirePerimeterSnapshot({ features });
return { fetchedAt: now(), rows };
}
async function fetchIndex() {
const rows = await upstream(
'https://inciweb.wildfire.gov/api/single-publication/',
4 * MIB,
20_000,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: '' }),
},
);
if (!Array.isArray(rows)) throw new Error('invalid_index');
return rows;
}
// InciWeb's publication API was retired; incident pages carry origin and update times.
async function fetchPublication(id) {
const signal = AbortSignal.timeout(15_000);
let response = await fetchImpl(`https://inciweb.wildfire.gov/node/${id}`, {
signal,
redirect: 'manual',
});
if ([301, 302, 303, 307, 308].includes(response.status)) {
await response.body?.cancel();
const location = response.headers.get('location');
if (!location || /\.\.|[?%\\]/.test(location))
throw new Error('invalid_redirect');
const url = new URL(location, 'https://inciweb.wildfire.gov/');
if (
url.hostname !== 'inciweb.wildfire.gov' ||
!/^\/[a-z0-9-]+(?:\/[a-z0-9-]+)?$/.test(url.pathname) ||
!['http:', 'https:'].includes(url.protocol) ||
url.port ||
url.username ||
url.password
)
throw new Error('invalid_redirect');
url.protocol = 'https:';
url.search = '';
url.hash = '';
response = await fetchImpl(url.href, { signal, redirect: 'error' });
}
if (response.status !== 200) {
await response.body?.cancel();
throw new Error('upstream_unavailable');
}
const html = await readResponseTextCapped(response, 2 * MIB, signal);
const changed = Date.parse(
html.match(/<meta\s+property="og:updated_time"\s+content="([^"]+)"/)?.[1],
);
const created = Date.parse(
html.match(
/Date of Origin<\/th>\s*<td[^>]*>\s*<time\s+datetime="([^"]+)"/,
)?.[1],
);
const createdMs = Number.isFinite(created) ? created : null;
const changedMs = Number.isFinite(changed) ? changed : null;
if (createdMs === null && changedMs === null)
throw new Error('invalid_incident_page');
return { createdMs, changedMs };
}
async function acquire(key, ttl, load, store) {
const previous = store.get(key);
if (previous && now() - previous.savedAt < ttl)
return { value: previous.value, stale: false };
// Bound distinct concurrent publication requests as well as retained entries.
if (!inFlight.has(key) && inFlight.size >= 256)
throw Object.assign(new Error('busy'), { status: 429 });
try {
const { promise } = coalesceProxyRequest(inFlight, key, async () => {
const value = await load();
store.delete(key);
store.set(key, { value, savedAt: now() });
if (store === publications && store.size > 256)
store.delete(store.keys().next().value);
return value;
});
return { value: await promise, stale: false };
} catch (error) {
if (previous) return { value: previous.value, stale: true };
throw error;
}
}
async function handler(req, res) {
const json = (status, value, stale = false) => {
if (res.destroyed) return;
res.writeHead(status, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
...(status === 405 ? { Allow: 'GET' } : {}),
...(status === 429 ? { 'Retry-After': '60' } : {}),
...(stale ? { 'X-Data-Stale': 'true' } : {}),
});
res.end(JSON.stringify(value));
};
if (req.method !== 'GET') return json(405, { error: 'method_not_allowed' });
const path = (req.url || '/').split('?')[0];
let key,
ttl,
load,
store = cache;
if (path === '/' || path === '') {
key = 'perimeters';
ttl = 300_000;
load = fetchPerimeters;
} else if (path === '/inciweb/index') {
key = 'index';
ttl = 3_600_000;
load = fetchIndex;
} else if (path.startsWith('/inciweb/publication/')) {
const id = path.slice('/inciweb/publication/'.length);
if (!/^\d{1,9}$/.test(id))
return json(400, { error: 'invalid_publication_id' });
key = `publication:${id}`;
ttl = 1_800_000;
load = () => fetchPublication(id);
store = publications;
} else return json(404, { error: 'unknown_route' });
if (!allow(clientKey(req))) return json(429, { error: 'rate_limited' });
try {
const { value, stale } = await acquire(key, ttl, load, store);
json(
200,
key === 'perimeters' && stale ? { ...value, stale: true } : value,
stale,
);
} catch (error) {
json(error.status === 429 ? 429 : 502, {
error: 'fire_perimeters_unavailable',
});
}
}
return {
name: 'fire-perimeters',
configureServer({ middlewares }) {
middlewares.use('/api/fire-perimeters', handler);
},
configurePreviewServer({ middlewares }) {
middlewares.use('/api/fire-perimeters', handler);
},
};
}
+11 -5
View File
@@ -8,10 +8,11 @@ import { filterTrailing24h, parseFirmsCsv } from '../../src/data/firmsCsv.js';
* 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) fetched sequentially 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:
* 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:
* TTL 30 min, single-flight refresh, serve-stale-on-failure, and a
* fresh-enough disk cache (.gev-cache/firms.json) prevents ANY upstream
* fetch across dev-server restarts. Pattern mirrors celestrakProxy.
@@ -28,7 +29,12 @@ import { filterTrailing24h, parseFirmsCsv } from '../../src/data/firmsCsv.js';
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'];
const SOURCES = [
'VIIRS_NOAA20_NRT',
'VIIRS_NOAA21_NRT',
'VIIRS_SNPP_NRT',
'MODIS_NRT',
];
const CACHE_DIR = path.join(process.cwd(), '.gev-cache');
const CACHE_PATH = path.join(CACHE_DIR, 'firms.json');
+521
View File
@@ -0,0 +1,521 @@
import { lookup as dnsLookup } from 'node:dns/promises';
import http from 'node:http';
import https from 'node:https';
import { Readable } from 'node:stream';
import { coalesceProxyRequest, readResponseTextCapped } from './common/http.js';
import { isLocalIpv4, parseTapAddress } from '../../src/data/tapAddress.js';
import { normalizeDump1090Aircraft } from '../../src/sources/adsbRecords.js';
/**
* Local decoder feeds for the Local ADS-B layer.
*
* A user who runs dump1090 / readsb / tar1090 (1090 MHz) or dump978-fa +
* skyaware978 (978 MHz UAT) exposes an `aircraft.json` over HTTP. This route
* reads those documents and returns them as Local ADS-B records.
*
* The feed list comes only from the operator's `LOCAL_RECEIVER_FEEDS`
* environment value; the browser never supplies an address. Every host must
* pass the shared receiver-tap address rule (`src/data/tapAddress.js`:
* loopback, RFC1918, `localhost`, `*.local`), the scheme must be http(s) and
* the path must end in `aircraft.json`. Invalid entries are logged at startup,
* reported as `invalid` and never fetched.
*
* A name (`localhost`, `*.local`) is resolved before every read; each address
* it resolves to must itself be loopback or RFC1918, and the connection is
* pinned to exactly those validated addresses, so a later re-resolution
* cannot redirect it anywhere else.
*/
export const LOCAL_RECEIVERS_ROUTE = '/api/local-receivers/aircraft';
export const LOCAL_RECEIVER_FEEDS_ENV = 'LOCAL_RECEIVER_FEEDS';
export const LOCAL_RECEIVER_BANDS = Object.freeze(['1090', '978']);
export const LOCAL_RECEIVER_TIMEOUT_MS = 2_000;
export const LOCAL_RECEIVER_MAX_BODY_BYTES = 2 * 1024 * 1024;
export const LOCAL_RECEIVER_CACHE_MS = 1_000;
/** A feed whose own `now` is older than this is reported stale. */
export const LOCAL_RECEIVER_STALE_MS = 10_000;
const MAX_FEEDS = 8;
const LOG_PREFIX = '[local-receivers]';
const BAND_LABELS = Object.freeze({ 1090: '1090 MHz', 978: '978 MHz UAT' });
/** Statuses whose `Response` must be constructed without a body. */
const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]);
function defaultPort(protocol) {
return protocol === 'https:' ? 443 : 80;
}
/**
* Validate one `band=url` entry.
* @param {string} entry Raw entry text.
* @returns {{band:string, url:string}|{band:string|null, reason:string}}
*/
function parseEntry(entry) {
const separator = entry.indexOf('=');
if (separator <= 0) return { band: null, reason: 'expected band=url' };
const band = entry.slice(0, separator).trim();
const rawUrl = entry.slice(separator + 1).trim();
if (!LOCAL_RECEIVER_BANDS.includes(band))
return { band: null, reason: 'band must be 1090 or 978' };
let url;
try {
url = new URL(rawUrl);
} catch {
return { band, reason: 'not a valid URL' };
}
if (url.protocol !== 'http:' && url.protocol !== 'https:')
return { band, reason: 'scheme must be http or https' };
if (url.username || url.password)
return { band, reason: 'credentials are not allowed in the URL' };
if (url.search || url.hash || rawUrl.includes('?') || rawUrl.includes('#'))
return { band, reason: 'query and fragment are not allowed' };
const port = url.port ? Number(url.port) : defaultPort(url.protocol);
const address = parseTapAddress(`${url.hostname}:${port}`);
if (!address)
return {
band,
reason: 'host must be loopback, RFC1918, localhost or *.local',
};
if (!/(?:^|\/)aircraft\.json$/.test(url.pathname))
return { band, reason: 'path must end in aircraft.json' };
return {
band,
url: `${url.protocol}//${address.host}:${address.port}${url.pathname}`,
};
}
/**
* Parse `LOCAL_RECEIVER_FEEDS` (`band=url[,band=url…]`).
*
* Labels name the band only (plus an ordinal when a band repeats), so feed
* addresses never reach the browser.
* @param {string|undefined} value Environment value.
* @returns {{configured:boolean, feeds:object[]}} Each feed is
* `{ id, band, label, url }` when valid or `{ id, band, label, reason }`
* when invalid (never fetched).
*/
export function parseLocalReceiverFeeds(value) {
const entries = String(value ?? '')
.split(',')
.map((entry) => entry.trim())
.filter(Boolean);
const feeds = [];
const perBand = new Map();
entries.forEach((entry, index) => {
const parsed =
index < MAX_FEEDS
? parseEntry(entry)
: { band: null, reason: `at most ${MAX_FEEDS} feeds are read` };
const band = parsed.band;
const ordinal = (perBand.get(band) || 0) + 1;
perBand.set(band, ordinal);
const base = band ? BAND_LABELS[band] : `entry ${index + 1}`;
const label = band && ordinal > 1 ? `${base} #${ordinal}` : base;
feeds.push({
id: `feed-${index + 1}`,
band,
label,
...(parsed.url ? { url: parsed.url } : { reason: parsed.reason }),
});
});
// Ordinals are only needed when a band actually repeats.
for (const feed of feeds) {
if (feed.band && perBand.get(feed.band) > 1 && !feed.label.includes('#'))
feed.label = `${feed.label} #1`;
}
return { configured: feeds.length > 0, feeds };
}
class FeedError extends Error {
constructor(code) {
super(code);
this.code = code;
}
}
/**
* Whether a resolved address satisfies the receiver-tap rule: loopback or
* RFC1918 IPv4, the IPv6 loopback, or an IPv4-mapped form of the former.
* @param {string} address Resolved IP address.
* @returns {boolean}
*/
export function isLocalReceiverAddress(address) {
const value = String(address || '').toLowerCase();
if (value === '::1') return true;
const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(value);
return isLocalIpv4(mapped ? mapped[1] : value);
}
/**
* Resolve a feed host name and require every address to be local.
* @param {string} hostname `localhost` or a `*.local` name.
* @param {typeof dnsLookup} lookupImpl `dns.promises.lookup`-compatible.
* @returns {Promise<Array<{address:string, family:number}>>}
*/
export async function resolveLocalReceiverAddresses(hostname, lookupImpl) {
let resolved;
try {
resolved = await lookupImpl(hostname, { all: true, verbatim: true });
} catch {
throw new FeedError('UNRESOLVED');
}
const addresses = (Array.isArray(resolved) ? resolved : [resolved])
.map((row) => ({
address: String(row?.address || ''),
family:
Number(row?.family) || (String(row?.address).includes(':') ? 6 : 4),
}))
.filter((row) => row.address);
if (!addresses.length) throw new FeedError('UNRESOLVED');
if (addresses.some((row) => !isLocalReceiverAddress(row.address)))
throw new FeedError('FORBIDDEN_ADDRESS');
return addresses;
}
/**
* Settle with `promise`, or reject with an AbortError as soon as `signal`
* aborts; a later settlement of `promise` is ignored.
*/
function untilAborted(promise, signal) {
return new Promise((resolve, reject) => {
const onAbort = () => {
const error = new Error('aborted');
error.name = 'AbortError';
reject(error);
};
// Handlers are attached first so a late rejection is never unhandled.
promise.then(
(value) => {
signal.removeEventListener('abort', onAbort);
resolve(value);
},
(error) => {
signal.removeEventListener('abort', onAbort);
reject(error);
},
);
if (signal.aborted) onAbort();
else signal.addEventListener('abort', onAbort, { once: true });
});
}
/** A `net`-style lookup that only ever answers the validated addresses. */
function pinnedLookup(addresses) {
return (_hostname, options, callback) => {
const done = typeof options === 'function' ? options : callback;
if (options?.all)
done(
null,
addresses.map((row) => ({ ...row })),
);
else done(null, addresses[0].address, addresses[0].family);
};
}
/**
* Default transport. With `options.lookup` (a pinned feed name) the request
* is made with node:http(s) so the socket connects only to the validated
* addresses (TLS still verifies the name); otherwise it is a plain fetch of
* an IP-literal URL. Redirects are never followed either way.
* @param {string} url
* @param {object} options
* @returns {Promise<Response>}
*/
function fetchLocalReceiverFeed(url, options) {
if (typeof options?.lookup !== 'function') return fetch(url, options);
const client = new URL(url).protocol === 'https:' ? https : http;
return new Promise((resolve, reject) => {
const request = client.request(
url,
{
method: 'GET',
headers: options.headers,
signal: options.signal,
lookup: options.lookup,
// A fresh socket per read: a pooled one could outlive the pin.
agent: false,
},
(response) => {
// Nothing thrown here may escape: this callback runs outside the
// promise, so an exception would be uncaught and crash the server.
try {
const status = response.statusCode;
// A `Response` only represents final statuses 200–599.
if (!Number.isInteger(status) || status < 200 || status > 599)
throw new FeedError('HTTP_STATUS');
const headers = new Headers();
for (const [name, value] of Object.entries(response.headers)) {
if (Array.isArray(value))
value.forEach((item) => headers.append(name, item));
else if (value !== undefined) headers.set(name, String(value));
}
const nullBody = NULL_BODY_STATUSES.has(status);
const upstream = new Response(
nullBody ? null : Readable.toWeb(response),
{ status, statusText: response.statusMessage || '', headers },
);
if (nullBody) response.destroy();
resolve(upstream);
} catch (error) {
response.destroy();
reject(error);
}
},
);
request.on('error', reject);
request.end();
});
}
/**
* Fetch one validated feed document without following redirects and with the
* body cap enforced while it streams.
* @param {string} url Validated feed URL.
* @param {object} options
* @returns {Promise<object>} Parsed JSON document.
*/
export async function fetchLocalReceiverDocument(
url,
{
fetchImpl = fetchLocalReceiverFeed,
lookupImpl = dnsLookup,
timeoutMs = LOCAL_RECEIVER_TIMEOUT_MS,
maxBytes = LOCAL_RECEIVER_MAX_BODY_BYTES,
} = {},
) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
let upstream;
try {
const { hostname } = new URL(url);
// IP literals were validated when the feed list was parsed; a name is
// validated by what it resolves to now, and the socket is pinned there.
// Resolution runs against the same deadline as the read: a stalled
// lookup fails this feed instead of holding the whole snapshot.
const lookup = isLocalIpv4(hostname)
? undefined
: pinnedLookup(
await untilAborted(
resolveLocalReceiverAddresses(hostname, lookupImpl),
controller.signal,
),
);
upstream = await fetchImpl(url, {
method: 'GET',
headers: { Accept: 'application/json' },
redirect: 'manual',
signal: controller.signal,
...(lookup ? { lookup } : {}),
});
if (upstream.status >= 300 && upstream.status < 400)
throw new FeedError('REDIRECT_REFUSED');
// Opaque redirect responses from `redirect: 'manual'` report status 0.
if (upstream.type === 'opaqueredirect')
throw new FeedError('REDIRECT_REFUSED');
if (!upstream.ok) throw new FeedError('HTTP_STATUS');
const text = await readResponseTextCapped(
upstream,
maxBytes,
controller.signal,
);
let json;
try {
json = JSON.parse(text);
} catch {
throw new FeedError('BAD_JSON');
}
if (!json || typeof json !== 'object' || !Array.isArray(json.aircraft))
throw new FeedError('BAD_DOCUMENT');
return json;
} catch (error) {
controller.abort();
if (upstream?.body && !upstream.body.locked)
void upstream.body.cancel().catch(() => {});
if (error instanceof FeedError) throw error;
if (error?.code === 'RESPONSE_TOO_LARGE')
throw new FeedError('RESPONSE_TOO_LARGE');
if (error?.name === 'AbortError' || controller.signal.aborted)
throw new FeedError('TIMEOUT');
throw new FeedError('UNREACHABLE');
} finally {
clearTimeout(timeoutId);
}
}
/**
* Read one feed and describe it.
* @returns {Promise<{status:object, records:object[]}>}
*/
async function readFeed(
feed,
{ fetchImpl, lookupImpl, now, timeoutMs, maxBytes, log },
) {
const summary = { band: feed.band, label: feed.label };
if (!feed.url)
return {
status: { ...summary, status: 'invalid', aircraft: 0, ageMs: null },
records: [],
};
let json;
try {
json = await fetchLocalReceiverDocument(feed.url, {
fetchImpl,
lookupImpl,
timeoutMs,
maxBytes,
});
} catch (error) {
log(feed, 'unreachable', error.code);
return {
status: { ...summary, status: 'unreachable', aircraft: 0, ageMs: null },
records: [],
};
}
const receivedAt = now();
const feedNowS = Number(json.now);
// The feed's own clock decides staleness. A clock ahead of ours reads as
// age 0; a document without `now` is treated as current.
const ageMs = Number.isFinite(feedNowS)
? Math.max(0, Math.round(receivedAt - feedNowS * 1000))
: 0;
const status = ageMs > LOCAL_RECEIVER_STALE_MS ? 'stale' : 'live';
log(feed, status);
// Anchor record ages at the document time so a stale feed ages out
// naturally, without trusting a receiver clock that runs ahead of ours.
const records = normalizeDump1090Aircraft(json, receivedAt - ageMs, {
band: feed.band,
});
return {
status: { ...summary, status, aircraft: records.length, ageMs },
records,
};
}
/**
* Build the route handler. Exposed for tests; the plugin wraps it.
* @param {object} [options]
* @param {string} [options.feedsValue] `LOCAL_RECEIVER_FEEDS` value.
* @param {typeof fetch} [options.fetchImpl] Receives `options.lookup` when
* the feed host is a name; the connection must resolve only through it.
* @param {Function} [options.lookupImpl] `dns.promises.lookup`-compatible.
* @param {() => number} [options.now]
* @param {{warn:Function, info?:Function}} [options.logger]
* @returns {{handle:Function, config:object}}
*/
export function createLocalReceiversHandler({
feedsValue = process.env[LOCAL_RECEIVER_FEEDS_ENV],
fetchImpl = fetchLocalReceiverFeed,
lookupImpl = dnsLookup,
now = Date.now,
logger = console,
timeoutMs = LOCAL_RECEIVER_TIMEOUT_MS,
maxBytes = LOCAL_RECEIVER_MAX_BODY_BYTES,
cacheMs = LOCAL_RECEIVER_CACHE_MS,
} = {}) {
const config = parseLocalReceiverFeeds(feedsValue);
for (const feed of config.feeds) {
if (feed.reason)
logger.warn(
`${LOG_PREFIX} ${LOCAL_RECEIVER_FEEDS_ENV} ${feed.id} (${feed.label}) rejected: ${feed.reason}. It will not be fetched.`,
);
}
const lastStatus = new Map();
// Log a feed only when its status changes, so a feed that is down does not
// write a line every second.
const log = (feed, status, code) => {
if (lastStatus.get(feed.id) === status) return;
lastStatus.set(feed.id, status);
if (status === 'live') logger.info?.(`${LOG_PREFIX} ${feed.label} live`);
else
logger.warn(
`${LOG_PREFIX} ${feed.label} ${status}${code ? ` (${code})` : ''}`,
);
};
const inFlight = new Map();
let cached = null;
async function snapshot() {
const results = await Promise.all(
config.feeds.map((feed) =>
readFeed(feed, {
fetchImpl,
lookupImpl,
now,
timeoutMs,
maxBytes,
log,
}),
),
);
return {
configured: true,
generatedAt: now(),
feeds: results.map((result) => result.status),
records: results.flatMap((result) => result.records),
};
}
async function payload() {
if (!config.configured)
return { configured: false, feeds: [], records: [] };
if (cached && now() - cached.at < cacheMs) return cached.payload;
const { promise } = coalesceProxyRequest(inFlight, 'all', snapshot);
const result = await promise;
cached = { at: now(), payload: result };
return result;
}
async function handle(req, res) {
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.writeHead(405, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
return;
}
try {
const body = await payload();
res.writeHead(200, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(JSON.stringify(body));
} catch {
res.writeHead(502, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(JSON.stringify({ error: 'Local receiver feeds unavailable' }));
}
}
return { handle, config };
}
/**
* Vite plugin mounting `GET /api/local-receivers/aircraft`.
*
* The environment is read when the server starts, so construction alone
* neither parses configuration nor fetches anything.
* @param {object} [options] Passed to {@link createLocalReceiversHandler}.
* @returns {import('vite').Plugin}
*/
export function localReceiversProxy(options = {}) {
const install = (server) => {
const { handle } = createLocalReceiversHandler(options);
server.middlewares.use(LOCAL_RECEIVERS_ROUTE, (req, res, next) => {
const path = new URL(req.url || '/', 'http://localhost').pathname;
if (path !== '/' && path !== '') {
if (typeof next === 'function') return next();
return undefined;
}
return handle(req, res);
});
};
return {
name: 'local-receivers-proxy',
configureServer: install,
configurePreviewServer: install,
};
}
+4
View File
@@ -13,6 +13,7 @@ import { cctvProxy } from './cctv.js';
import { defaultSourceRoot } from './common/source-root.js';
import { radioBrowserProxy } from './radio.js';
import { gbfsProxy } from './gbfs.js';
import { localReceiversProxy } from './local-receivers.js';
import { transitProxy } from './transit.js';
import { adsbLolProxy } from './aircraft/adsb-lol.js';
import { aisLiveProxy } from './vessels/ais-live.js';
@@ -22,6 +23,7 @@ import { googlePlacesContextProxy } from './places.js';
import { mapillaryProxy } from './mapillary.js';
import { keySetupEndpoint } from '../standalone/key-setup.js';
import { weatherProxy } from './weather.js';
import { firePerimetersProxy } from './firePerimeters.js';
import { cycloneProxy } from './cyclones.js';
import { windProxy } from './wind.js';
@@ -43,6 +45,7 @@ function localProviderPlugins() {
cctvProxy({ sourceRoot: defaultSourceRoot }),
radioBrowserProxy(),
gbfsProxy(),
localReceiversProxy(),
transitProxy(),
adsbLolProxy(),
aisLiveProxy(),
@@ -53,6 +56,7 @@ function localProviderPlugins() {
weatherProxy(),
cycloneProxy(),
mapillaryProxy(),
firePerimetersProxy(),
keySetupEndpoint(),
];
}
+2 -1
View File
@@ -49,7 +49,8 @@ function realtimeInstructions(
'NAMED VIEWS are shorthand for tool calls you already have — there is no "mode" tool for them. Treat ONLY these as the shorthand: "infrastructure mode" / "the infrastructure view" / "show me global infrastructure" means three set_layer_visibility calls (local-datacenters, local-dams, telegeography-submarine-cables) plus zoom_to_globe; "environmental mode" / "earth watch" / "active events", said as the name of a view, means set_layer_visibility for local-firms and earthquakes plus zoom_to_globe. Anything vaguer is NOT this shorthand — an open-ended question about the world or the news is an ordinary question: answer it, or use analyst_query over the layers already on. Never switch a whole view on to answer a question nobody asked to see. When you do run one, make every call before speaking, then give one confirmation naming the resulting state; if the fires layer comes back unavailable because no FIRMS key is configured, say so plainly — the earthquakes still loaded. "Live contacts" and "space missions" are NOT this pattern: they stay set_context_mode{mode:"contacts"} and set_context_mode{mode:"space-missions"}.',
'For visual filter requests, call set_visual_style with one of the allowed style IDs.',
'Disambiguation table — basemap vs layer vs style: basemap switching requires an explicit stack name — "Bing aerial" means set_map_stack bing-aerial, "aerial with labels" means bing-labels, "OSM"/"road map" means osm, "Esri"/"Esri imagery" means esri-imagery, "Google 3D"/"photorealistic" means photoreal. Any mention of "satellite" or "satellites" ALWAYS means the satellites DATA LAYER via set_layer_visibility, never a basemap. "surveillance"/"night vision"/"thermal" are visual STYLES via set_visual_style.',
'HUD requests ("hud on/off", "switch to operator/minimal/tactical layout") use set_hud. Detection requests ("detection on", "dense mode", "balanced mode", "sparse mode", "set density to 25", "use weighted allocation") use set_detection. Density snaps to 0/25/50/75/100 and derives Sparse/Balanced/Dense; panoptic is a legacy alias for Dense.',
'HUD requests ("hud on/off", "switch to operator/minimal/tactical/cyber layout") use set_hud. Detection requests ("detection on", "dense mode", "balanced mode", "sparse mode", "set density to 25", "use weighted allocation") use set_detection. Density snaps to 0/25/50/75/100 and derives Sparse/Balanced/Dense; panoptic is a legacy alias for Dense.',
'Cyber sonar requests use set_cyber_sonar: enabled, rings, rangePct, intensityPct (Power), opacityPct, sectorDeg. For settings questions read get_current_view_state.controls.hud.sonar. Sonar is not detection. Do not silently switch themes, reveal the HUD or enable sonar when changing a slider. If the user explicitly requests Cyber plus sonar settings, call set_hud first, wait for success, then set_cyber_sonar. Confirm returned values; mapSweepActive and contactSweepActive distinguish saved settings from active map effects, including in Cockpit or reduced motion. Do not claim visible contacts solely because enabled is true.',
'Bloom/sharpen requests use set_post_processing. Scene requests ("play orbital watch", "stop the scene", "what scenes are there") use control_scene. CCTV camera requests ("next camera", "nearest camera", "select the Congress camera", "show coverage") use control_cctv — the CCTV layer must be enabled first.',
'Radio playback requests use control_radio. "Turn on/start the radio" means action=play; action=enable only reveals Radio markers and must be reserved for explicit "show/enable the Radio layer/markers" requests. After a prepared playback result, briefly confirm any other completed actions and say "Turning on the radio"—never claim it is already playing. The client keeps Radio muted until playback is verified, then closes voice before restoring Radio volume. Examples: "play news near Austin" → select category=news locationId=austin; "play US news" → select category=news country=US; "Radio volume 30" → volume; pause/resume/stop/next/previous use the matching action. Radio selection never moves the camera.',
'"Track/follow <something specific>" (a callsign, ship name, satellite name) uses track_entity. "Take me to the biggest fire" uses track_entity with query "biggest fire" (the fires layer must be enabled). Bare "orbit" means camera orbit of the current landmark. "Stop following/tracking" uses stop_tracking.',
+24 -1
View File
@@ -82,7 +82,7 @@ export const ACTION_DESCRIPTIONS = {
properties: {
layerId: {
description:
'Common-name mapping for the non-obvious ids: space mission(s) → rocket-launches; fires/wildfires/active fires → local-firms (NASA FIRMS); ships/vessels/boats → ais-live-vessels; undersea/submarine cables → telegeography-submarine-cables; datacenters → local-datacenters; dams → local-dams; bikes/bike share → bikeshare; street traffic/congestion → traffic; traffic cameras → cctv; internet radio/stations → radio; ALPR/license plate readers/Flock cameras → alpr-cameras.',
'Common-name mapping for the non-obvious ids: space mission(s) → rocket-launches; fires/wildfires/active fires → local-firms (NASA FIRMS); ships/vessels/boats → ais-live-vessels; undersea/submarine cables → telegeography-submarine-cables; datacenters → local-datacenters; dams → local-dams; bikes/bike share → bikeshare; street traffic/congestion → traffic; traffic cameras → cctv; internet radio/stations → radio; ALPR/license plate readers/Flock cameras → alpr-cameras; local ADS-B/my receiver/my antenna (aircraft heard by a local RTL-SDR receiver) → local-adsb.',
$position: 1,
},
},
@@ -182,6 +182,29 @@ export const ACTION_DESCRIPTIONS = {
},
},
},
set_cyber_sonar: {
description:
'Adjust Cyber-only sonar controls. Requires the Cyber HUD layout; never switches layout or HUD visibility. Omitted fields stay unchanged. Returns actual settings and separate map/contact sweep activity. A saved setting does not mean the effect is active.',
$position: 1,
parameters: {
properties: {
enabled: { description: 'Explicitly turn sonar on or off.' },
rings: { description: 'Number of decorative sonar rings, 3–12.' },
rangePct: {
description:
'Visual ring range, 60–120 percent; not geographic distance.',
},
intensityPct: {
description: 'Sonar Power slider, 0–100 percent. Zero is valid.',
},
opacityPct: {
description:
'Contact opacity floor between passes, 35–100 percent; labels have a derived floor. Not whole-scene dimming.',
},
sectorDeg: { description: 'Sonar sweep sector width, 8–60 degrees.' },
},
},
},
set_detection: {
description:
'Control the detection overlay: on/off, density-derived Sparse/Balanced/Dense profile, and Elastic/Weighted layer allocation.',
+5 -1
View File
@@ -123,7 +123,11 @@ export function hardenCredentialFile(
if (platform !== 'win32') {
try {
if (platform === 'darwin') {
const aclRemoval = spawn('chmod', ['-N', filepath], {
// `-N` (strip the ACL) is Apple-only. Spawn /bin/chmod by absolute
// path, never by name: a Nix/Homebrew coreutils profile puts GNU chmod
// first on PATH, and GNU chmod rejects `-N` — which was treated as a
// hardening failure and refused every save on such machines.
const aclRemoval = spawn('/bin/chmod', ['-N', filepath], {
stdio: 'ignore',
});
if (!commandCompletedSuccessfully(aclRemoval)) return false;
+2 -1
View File
@@ -7,7 +7,7 @@ import { apiNotFoundPlugin } from './api-not-found.js';
const root = fileURLToPath(new URL('../../', import.meta.url));
/** Load this checkout's configuration and attach its local provider middleware. */
export default defineConfig(({ mode }) => {
export default defineConfig(({ command, mode }) => {
const loaded = loadEnv(mode, root, '');
for (const [key, value] of Object.entries(loaded)) {
if (process.env[key] === undefined) process.env[key] = value;
@@ -19,5 +19,6 @@ export default defineConfig(({ mode }) => {
mapillaryToken: process.env.MAPILLARY_CLIENT_TOKEN,
host: process.env.HOST,
port: process.env.PORT,
command,
});
});