reconcile feat/voice-core with current main

preserve Bilawal's merge history, OpenAI schema-combinator startup fix, and Contacts viewport note
keep the validated voice and analyst behavior while adopting the current provider-neutral feed routes and Google token fallback
This commit is contained in:
manjunath22466 committed 2026-10-05 04:36:14 +05:30
commit 6858976b01
49 files changed
+810 -302

No files matched your search

+1 -1
View File
@@ -129,7 +129,7 @@ PORT=4173
# Optional: Live AIS vessels layer # Optional: Live AIS vessels layer
# Browser fetch path. Keep same-origin so the private stream key stays server-side. # Browser fetch path. Keep same-origin so the private stream key stays server-side.
VITE_AIS_LIVE_API_URL=/api/ais-live VITE_AIS_LIVE_API_URL=/api/vessels
# Client render cap. The server can keep a larger AISStream cache, but drawing # Client render cap. The server can keep a larger AISStream cache, but drawing
# every global vessel at once can overwhelm WebGL on laptops. # every global vessel at once can overwhelm WebGL on laptops.
VITE_AIS_LIVE_MAX_ROWS=12000 VITE_AIS_LIVE_MAX_ROWS=12000
+2 -2
View File
@@ -18,8 +18,8 @@ The golden rule: **secret-bearing API keys stay on the server side.** The dev/pr
| Key | Where it lives | How the browser uses it | | Key | Where it lives | How the browser uses it |
|-----|----------------|--------------------------| |-----|----------------|--------------------------|
| `OPENAI_API_KEY` | Server only | Browser fetches a short-lived **ephemeral** Realtime session token from `/api/realtime/token`; the real key never ships | | `OPENAI_API_KEY` | Server only | Browser fetches a short-lived **ephemeral** Realtime session token from `/api/realtime/token`; the real key never ships |
| `AISSTREAM_API_KEY` | Server only | Server holds the AISStream websocket; browser polls the same-origin `/api/ais-live` cache | | `AISSTREAM_API_KEY` | Server only | Server holds the AISStream websocket; browser polls the same-origin `/api/vessels` cache |
| OpenSky OAuth (`OPENSKY_CLIENT_ID/SECRET`) | Server only | Server mints + refreshes the token behind `/api/opensky` | | OpenSky OAuth (`OPENSKY_CLIENT_ID/SECRET`) | Server only | Server mints + refreshes the token behind `/api/flights` |
| `GOOGLE_MAPS_SERVER_API_KEY` (optional, #33) | Server only | Server calls Places (`/api/google/nearby-places`, `/api/google/text-search`) and the Street View fallback with this key; falls back to `GOOGLE_MAPS_API_KEY` when unset | | `GOOGLE_MAPS_SERVER_API_KEY` (optional, #33) | Server only | Server calls Places (`/api/google/nearby-places`, `/api/google/text-search`) and the Street View fallback with this key; falls back to `GOOGLE_MAPS_API_KEY` when unset |
### Two deliberately client-side keys — restrict them ### Two deliberately client-side keys — restrict them
+9 -9
View File
@@ -1516,7 +1516,7 @@ This is the current runtime/source-of-truth snapshot for the project.
> dependency list rather than a fix for one layer, and the dependencies reach > dependency list rather than a fix for one layer, and the dependencies reach
> it by different routes: > it by different routes:
> - **AIS vessels is its reachable producer.** `enable()`/`update()` both > - **AIS vessels is its reachable producer.** `enable()`/`update()` both
> resolve as soon as the first `/api/ais-live` poll answers, so the manager's > resolve as soon as the first `/api/vessels` poll answers, so the manager's
> lifecycle settles to `enabled` — but until the server-side socket delivers > lifecycle settles to `enabled` — but until the server-side socket delivers
> a position, `firstConnectPhase` stays `'loading'` and `getStats()` reports > a position, `firstConnectPhase` stays `'loading'` and `getStats()` reports
> `loading: true`, `lastUpdate: null`, count 0, and an UNDEFINED status. > `loading: true`, `lastUpdate: null`, count 0, and an UNDEFINED status.
@@ -1920,7 +1920,7 @@ isCurrent })` gates BOTH halves of the map-stack switch, which is its only
> the share payload; Radio restores only its allowlisted filter and volume. > the share payload; Radio restores only its allowlisted filter and volume.
> - **AIS feed watchdog (2026-08-18):** feed liveness is judged by DATA, not > - **AIS feed watchdog (2026-08-18):** feed liveness is judged by DATA, not
> socket state — AISStream can complete the handshake and then deliver > socket state — AISStream can complete the handshake and then deliver
> nothing forever. `/api/ais-live` reports `live | stale | reconnecting | > nothing forever. `/api/vessels` reports `live | stale | reconnecting |
down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> `silentForMs`, `reconnectAttempt` and `nextAttemptAt`. Silence is REPORTED > `silentForMs`, `reconnectAttempt` and `nextAttemptAt`. Silence is REPORTED
> at 120s and ACTED ON at 300s; recovery walks a 5s/15s/60s/300s ladder and > at 120s and ACTED ON at 300s; recovery walks a 5s/15s/60s/300s ladder and
@@ -2966,9 +2966,9 @@ its criteria cannot be silently ignored.
| Layer | Source | File | Proxy | Update Interval | | Layer | Source | File | Proxy | Update Interval |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------- | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------- |
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/opensky` (OAuth + fallback) | 30s | | Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/adsblol/mil` | 15s | | Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/ais-live` | 60s (+800ms visibility pass) | | Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles | | Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s | | Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s | | Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
@@ -3698,7 +3698,7 @@ silently demoting every later lookup for the session.
A selected mission renders its orbit as four repeating tactical sectors, each containing one prominent cyan dot followed by one hundred thin translucent dashes. The bright dots act as orbit anchors while the subdued dash field remains depth-tested against the globe and is shown only for the selected mission. A selected mission renders its orbit as four repeating tactical sectors, each containing one prominent cyan dot followed by one hundred thin translucent dashes. The bright dots act as orbit anchors while the subdued dash field remains depth-tested against the globe and is shown only for the selected mission.
Close selected-pad views add one static 500 m-radius cyan launch-zone ring with a low-opacity translucent fill over the sampled photoreal launch-site surface. The single scene primitive is created only for the visible selected site and is otherwise dormant. It appears during Focus, sufficiently close manual zoom, and the replay countdown, but is suppressed above 120 km camera altitude, beyond 180 km direct camera-to-pad range, for unselected missions, and whenever Space Missions is inactive. Focus establishes a launch-site-centered camera transform once; subsequent manual heading and pitch changes remain centered on that site without an automated per-frame correction. Surface mission markers and labels use an additional conservative globe-limb margin before the exact ellipsoid occluder boundary, preventing near-horizon visibility from alternating between frames. Close selected-pad views add one static 500 m-radius cyan launch-zone ring with a low-opacity translucent fill over the sampled photoreal launch-site surface. The single scene primitive is created only for the visible selected site and is otherwise dormant. It appears during Focus, sufficiently close manual zoom, and the replay countdown, but is suppressed above 120 km camera altitude, beyond 180 km direct camera-to-pad range, for unselected missions, and whenever Space Missions is inactive. Focus establishes a launch-site-centered camera transform once; subsequent manual heading and pitch changes remain centered on that site without an automated per-frame correction. Surface mission markers and labels use an additional conservative globe-limb margin before the exact ellipsoid occluder boundary, preventing near-horizon visibility from alternating between frames.
- **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted. - **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted.
- **Track trails**: server accumulates per-MMSI ring buffers (`/api/ais-live/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/opensky-track` (OAuth, own credit bucket) and `/api/adsblol/trace` (tar1090 readsb, ~24h history, ODbL — credit adsb.lol). - **Track trails**: server accumulates per-MMSI ring buffers (`/api/vessels/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/flights/track` (OAuth, own credit bucket) and `/api/military/track` (tar1090 readsb, ~24h history, ODbL — credit adsb.lol).
- Shared `src/data/pickRegistry.js` stops the two flight layers' click handlers from fighting over the camera. - Shared `src/data/pickRegistry.js` stops the two flight layers' click handlers from fighting over the camera.
### Optional Overpass configuration ### Optional Overpass configuration
@@ -3917,7 +3917,7 @@ and unreachable upstream (502/504) separately from road geometry.
### Live AIS Vessels (June 2026) ### Live AIS Vessels (June 2026)
- Server-side `ws` websocket to `wss://stream.aisstream.io/v0/stream` maintained by Vite middleware; `AISSTREAM_API_KEY` never reaches the browser (AISStream has no browser CORS). The `ws` package is used rather than Node's built-in WebSocket specifically because only it can hard-abort a wedged socket (see the watchdog note in the delta block at the top). - Server-side `ws` websocket to `wss://stream.aisstream.io/v0/stream` maintained by Vite middleware; `AISSTREAM_API_KEY` never reaches the browser (AISStream has no browser CORS). The `ws` package is used rather than Node's built-in WebSocket specifically because only it can hard-abort a wedged socket (see the watchdog note in the delta block at the top).
- Browser polls same-origin `/api/ais-live` cache every 60s. - Browser polls same-origin `/api/vessels` cache every 60s.
- The first enable in a session starts one 30-second client grace timer. Until - The first enable in a session starts one 30-second client grace timer. Until
an accepted vessel position arrives, `live`/`open`/`connecting` transport reports an accepted vessel position arrives, `live`/`open`/`connecting` transport reports
`LOADING`; the timer is not restarted by the 60-second poll. Expiry or a `LOADING`; the timer is not restarted by the 60-second poll. Expiry or a
@@ -4190,13 +4190,13 @@ are omitted rather than framing the wrong part of the globe.
- Proxy error payloads are sanitized (no internal error details returned to clients). - Proxy error payloads are sanitized (no internal error details returned to clients).
- That holds for the OpenAI and CCTV media paths too: `/api/openai/hud-summary` never relays OpenAI's own `error.message`, `/api/realtime/token` passes successful ephemeral-token responses through but answers with a fixed error when minting fails or upstream rejects the request, and a failed CCTV media fetch stores a fixed camera health `message` — `GET /api/cctv/health` serializes that field and the CCTV panel renders it as a status label, so it is a client surface as much as the response body is. - That holds for the OpenAI and CCTV media paths too: `/api/openai/hud-summary` never relays OpenAI's own `error.message`, `/api/realtime/token` passes successful ephemeral-token responses through but answers with a fixed error when minting fails or upstream rejects the request, and a failed CCTV media fetch stores a fixed camera health `message` — `GET /api/cctv/health` serializes that field and the CCTV panel renders it as a status label, so it is a client surface as much as the response body is.
- `OPENAI_API_KEY` is server-side only; the browser receives ephemeral Realtime client secrets from `/api/realtime/token`. - `OPENAI_API_KEY` is server-side only; the browser receives ephemeral Realtime client secrets from `/api/realtime/token`.
- `AISSTREAM_API_KEY` is server-side only; the browser reads the same-origin `/api/ais-live` cache. - `AISSTREAM_API_KEY` is server-side only; the browser reads the same-origin `/api/vessels` cache.
- `/api/google/nearby-places` keeps the Google key out of Places requests issued for voice scene context. - `/api/google/nearby-places` keeps the Google key out of Places requests issued for voice scene context.
- `/api/google/text-search` keeps the Google key server-side for view-biased Places recovery used by annotation resolution. - `/api/google/text-search` keeps the Google key server-side for view-biased Places recovery used by annotation resolution.
- `/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/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/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/route` proxies bounded OSRM route requests for annotation routes, with profile allowlisting, distance caps, response caps, caching, and sanitized "no route found" errors.
- 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). - 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=` (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. - 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. - `/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.
+2 -2
View File
@@ -1,6 +1,6 @@
# OpenSky Auth Setup # OpenSky Auth Setup
God's Eye View uses explicit auth modes for `/api/opensky`: God's Eye View uses explicit auth modes for `/api/flights`:
- `OPENSKY_AUTH_MODE=oauth` (default, recommended) - `OPENSKY_AUTH_MODE=oauth` (default, recommended)
- `OPENSKY_AUTH_MODE=auto` (OAuth first, then Basic fallback) - `OPENSKY_AUTH_MODE=auto` (OAuth first, then Basic fallback)
@@ -54,7 +54,7 @@ OPENSKY_AUTH_MODE=basic ./scripts/dev-fresh.sh
Inspect proxy headers: Inspect proxy headers:
```bash ```bash
curl -si http://localhost:4173/api/opensky | grep -iE 'HTTP/|X-OpenSky-Auth' curl -si http://localhost:4173/api/flights | grep -iE 'HTTP/|X-OpenSky-Auth'
``` ```
Useful reasons: Useful reasons:
+4 -4
View File
@@ -253,19 +253,19 @@ async function main() {
const url = new URL(request.url()); const url = new URL(request.url());
if (url.origin !== APP_ORIGIN) return void request.continue(); if (url.origin !== APP_ORIGIN) return void request.continue();
// Silence the live feeds: the injected field is the only population. // Silence the live feeds: the injected field is the only population.
if (url.pathname === '/api/ais-live') { if (url.pathname === '/api/vessels') {
return void request.respond(stubJson({ return void request.respond(stubJson({
rows: [], source: 'QA', status: 'open', error: null, refreshing: false, rows: [], source: 'QA', status: 'open', error: null, refreshing: false,
newestPositionAt: null, lastMessageAt: null, newestPositionAt: null, lastMessageAt: null,
})); }));
} }
if (url.pathname === '/api/adsblol/mil') { if (url.pathname === '/api/military') {
return void request.respond(stubJson({ msg: 'No error', now: Date.now(), ac: [] })); return void request.respond(stubJson({ msg: 'No error', now: Date.now(), ac: [] }));
} }
if (url.pathname === '/api/opensky') { if (url.pathname === '/api/flights') {
return void request.respond(stubJson({ time: Math.floor(Date.now() / 1000), states: [] })); return void request.respond(stubJson({ time: Math.floor(Date.now() / 1000), states: [] }));
} }
if (url.pathname === '/api/opensky-track') { if (url.pathname === '/api/flights/track') {
return void request.respond(stubJson({ path: [] })); return void request.respond(stubJson({ path: [] }));
} }
if (url.pathname === '/api/openai/hud-summary') { if (url.pathname === '/api/openai/hud-summary') {
+5 -5
View File
@@ -42,7 +42,7 @@ page.on('pageerror', (error) => consoleErrors.push(error.message));
page.on('response', (response) => { page.on('response', (response) => {
const url = new URL(response.url()); const url = new URL(response.url());
const expectedOptionalTrackMiss = response.status() === 404 const expectedOptionalTrackMiss = response.status() === 404
&& url.pathname === '/api/opensky-track'; && url.pathname === '/api/flights/track';
if ( if (
url.origin === new URL(appUrl).origin url.origin === new URL(appUrl).origin
&& response.status() >= 400 && response.status() >= 400
@@ -108,7 +108,7 @@ try {
}); });
return; return;
} }
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/ais-live') { if (url.origin === new URL(appUrl).origin && url.pathname === '/api/vessels') {
request.respond({ request.respond({
status: 200, status: 200,
contentType: 'application/json', contentType: 'application/json',
@@ -124,7 +124,7 @@ try {
}); });
return; return;
} }
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/adsblol/mil') { if (url.origin === new URL(appUrl).origin && url.pathname === '/api/military') {
request.respond({ request.respond({
status: 200, status: 200,
contentType: 'application/json', contentType: 'application/json',
@@ -132,7 +132,7 @@ try {
}); });
return; return;
} }
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/opensky-track') { if (url.origin === new URL(appUrl).origin && url.pathname === '/api/flights/track') {
request.respond({ request.respond({
status: 200, status: 200,
contentType: 'application/json', contentType: 'application/json',
@@ -832,7 +832,7 @@ try {
await readinessPage.setRequestInterception(true); await readinessPage.setRequestInterception(true);
readinessPage.on('request', (request) => { readinessPage.on('request', (request) => {
const url = new URL(request.url()); const url = new URL(request.url());
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/opensky') { if (url.origin === new URL(appUrl).origin && url.pathname === '/api/flights') {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
request.respond({ request.respond({
status: 200, contentType: 'application/json', status: 200, contentType: 'application/json',
+3 -3
View File
@@ -12,7 +12,7 @@
* least 60 m above the rendered surface under it and under the view centre. * least 60 m above the rendered surface under it and under the view centre.
* *
* Contacts: serves two synthetic aircraft through the page's own * Contacts: serves two synthetic aircraft through the page's own
* /api/opensky response (one flying north over Camp Mabry at 2,500 m, one * /api/flights response (one flying north over Camp Mabry at 2,500 m, one
* near Fort Cavazos) and an empty military feed, starts from a wide Texas * near Fort Cavazos) and an empty military feed, starts from a wide Texas
* view, and presses CONTACTS as a user would. Asserts the Mapped * view, and presses CONTACTS as a user would. Asserts the Mapped
* installations row lists at least one site with a distance within 15 s, * installations row lists at least one site with a distance within 15 s,
@@ -110,7 +110,7 @@ async function openPage({ aircraft = false } = {}) {
} catch { } catch {
return realFetch(input, init); return realFetch(input, init);
} }
if (url.origin === location.origin && url.pathname === '/api/opensky') { if (url.origin === location.origin && url.pathname === '/api/flights') {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const seconds = (Date.now() - started) / 1000; const seconds = (Date.now() - started) / 1000;
const lat = 30.29 + (seconds * 120) / 111_320; const lat = 30.29 + (seconds * 120) / 111_320;
@@ -159,7 +159,7 @@ async function openPage({ aircraft = false } = {}) {
}; };
return Promise.resolve(Response.json(json)); return Promise.resolve(Response.json(json));
} }
if (url.origin === location.origin && url.pathname === '/api/adsblol/mil') if (url.origin === location.origin && url.pathname === '/api/military')
return Promise.resolve( return Promise.resolve(
Response.json({ ac: [], now: Date.now(), total: 0 }), Response.json({ ac: [], now: Date.now(), total: 0 }),
); );
+5 -5
View File
@@ -12,7 +12,7 @@
* honoring the politeness bounds. * honoring the politeness bounds.
* *
* Fetch shim (installed before app code, same pattern as qa-sprites-b5.mjs): * Fetch shim (installed before app code, same pattern as qa-sprites-b5.mjs):
* - /api/opensky → 12 straight-flying planes, category 0 * - /api/flights → 12 straight-flying planes, category 0
* - /api/adsbdb/type/:hex → varied REAL type codes (C172, B744, DH8D, H60, * - /api/adsbdb/type/:hex → varied REAL type codes (C172, B744, DH8D, H60,
* F16, GLID, B77W, A320, B738, PC12, R44) + one * F16, GLID, B77W, A320, B738, PC12, R44) + one
* found:false miss; responses are HELD until the * found:false miss; responses are HELD until the
@@ -260,14 +260,14 @@ async function main() {
}); });
} }
if (url.includes('/api/adsbdb/')) return Promise.resolve(jsonResponse({ found: false })); if (url.includes('/api/adsbdb/')) return Promise.resolve(jsonResponse({ found: false }));
if (url.includes('/api/opensky-track')) return Promise.resolve(jsonResponse({ path: [] })); if (url.includes('/api/flights/track')) return Promise.resolve(jsonResponse({ path: [] }));
if (url.includes('/api/adsblol/trace')) { if (url.includes('/api/military/track')) {
return Promise.resolve(jsonResponse({ timestamp: Math.floor(nowSec), trace: [] })); return Promise.resolve(jsonResponse({ timestamp: Math.floor(nowSec), trace: [] }));
} }
if (url.includes('/api/adsblol/mil')) { if (url.includes('/api/military')) {
return Promise.resolve(jsonResponse({ msg: 'No error', now: Date.now(), ac: [] })); return Promise.resolve(jsonResponse({ msg: 'No error', now: Date.now(), ac: [] }));
} }
if (url.includes('/api/opensky')) { if (url.includes('/api/flights')) {
const states = S.planes.map((f) => { const states = S.planes.map((f) => {
const s = S.stateAt(f, tRel); const s = S.stateAt(f, tRel);
return [ return [
+4 -2
View File
@@ -10,7 +10,7 @@
* upstream failures so the run is deterministic and never depends on live * upstream failures so the run is deterministic and never depends on live
* AISStream / CelesTrak availability: * AISStream / CelesTrak availability:
* *
* (i) AIS invalid key — /api/ais-live is answered with * (i) AIS invalid key — /api/vessels is answered with
* {rows:[],status:'error',error:'invalid key'}. Assert the layer's * {rows:[],status:'error',error:'invalid key'}. Assert the layer's
* getStats().error is set (feed down) — NOT a clean 'just now · 0'. * getStats().error is set (feed down) — NOT a clean 'just now · 0'.
* *
@@ -193,10 +193,12 @@ async function main() {
page.on('request', (req) => { page.on('request', (req) => {
const url = req.url(); const url = req.url();
// (i) AIS invalid-key: the exact payload the spec calls for. // (i) AIS invalid-key: the exact payload the spec calls for.
if (url.includes('/api/ais-live') && !url.includes('/track')) { if (url.includes('/api/vessels') && !url.includes('/track')) {
req.respond({ req.respond({
status: 200, status: 200,
contentType: 'application/json', contentType: 'application/json',
// The bundled server names its vessel provider; attribution reads it.
headers: { 'X-Feed-Source': 'AISStream' },
body: JSON.stringify({ rows: [], status: 'error', error: 'invalid key' }), body: JSON.stringify({ rows: [], status: 'error', error: 'invalid key' }),
}); });
return; return;
+2 -2
View File
@@ -89,7 +89,7 @@ await page.evaluateOnNewDocument((site, icao) => {
window.__TERRAIN.ok += 1; window.__TERRAIN.ok += 1;
return realFetch(input, init); return realFetch(input, init);
} }
if (app && url.pathname === '/api/opensky') { if (app && url.pathname === '/api/flights') {
const c = window.__CONTACT; const c = window.__CONTACT;
const t = Math.floor(Date.now() / 1000); const t = Math.floor(Date.now() / 1000);
// baro_altitude (7) and geo_altitude (13) both null, on_ground true. // baro_altitude (7) and geo_altitude (13) both null, on_ground true.
@@ -99,7 +99,7 @@ await page.evaluateOnNewDocument((site, icao) => {
null, true, 1.5, 90, 0, null, null, null, false, 0]], null, true, 1.5, 90, 0, null, null, null, false, 0]],
})); }));
} }
if (app && url.pathname === '/api/opensky-track') return Promise.resolve(json({ path: [] })); if (app && url.pathname === '/api/flights/track') return Promise.resolve(json({ path: [] }));
if (app && /^\/api\/adsbdb\//.test(url.pathname)) return Promise.resolve(json({})); if (app && /^\/api\/adsbdb\//.test(url.pathname)) return Promise.resolve(json({}));
return realFetch(input, init); return realFetch(input, init);
}; };
+4 -4
View File
@@ -268,11 +268,11 @@ async function installSyntheticFetches(page) {
})); }));
window.fetch = (input, init) => { window.fetch = (input, init) => {
const url = typeof input === 'string' ? input : input?.url || ''; const url = typeof input === 'string' ? input : input?.url || '';
if (url.includes('/api/opensky-track')) return json({ path: [] }); if (url.includes('/api/flights/track')) return json({ path: [] });
if (url.includes('/api/adsblol/trace')) return json({ timestamp: Date.now() / 1000, trace: [] }); if (url.includes('/api/military/track')) return json({ timestamp: Date.now() / 1000, trace: [] });
if (url.includes('/api/opensky')) return json({ time: Math.floor(Date.now() / 1000), states: [] }); if (url.includes('/api/flights')) return json({ time: Math.floor(Date.now() / 1000), states: [] });
if (url.includes('/api/adsbdb/')) return json({ found: false }); if (url.includes('/api/adsbdb/')) return json({ found: false });
if (url.includes('/api/ais-live')) return json({ status: 'open', rows: [] }); if (url.includes('/api/vessels')) return json({ status: 'open', rows: [] });
return realFetch(input, init); return realFetch(input, init);
}; };
}); });
+4 -4
View File
@@ -525,11 +525,11 @@ async function main() {
const nowSec = Date.now() / 1000 + (T.timeOffsetSec || 0); const nowSec = Date.now() / 1000 + (T.timeOffsetSec || 0);
const tRel = nowSec - T.epochMs / 1000; const tRel = nowSec - T.epochMs / 1000;
if (url.includes('/api/opensky-track')) return Promise.resolve(jsonResponse({ path: [] })); if (url.includes('/api/flights/track')) return Promise.resolve(jsonResponse({ path: [] }));
if (url.includes('/api/adsblol/trace')) { if (url.includes('/api/military/track')) {
return Promise.resolve(jsonResponse({ timestamp: Math.floor(nowSec), trace: [] })); return Promise.resolve(jsonResponse({ timestamp: Math.floor(nowSec), trace: [] }));
} }
if (url.includes('/api/opensky')) { if (url.includes('/api/flights')) {
T.__hitsGuard = ++window.__TURN_HITS.opensky; T.__hitsGuard = ++window.__TURN_HITS.opensky;
const row = (id, callsign, s, altM, category) => [ const row = (id, callsign, s, altM, category) => [
id, callsign, 'Synthetica', id, callsign, 'Synthetica',
@@ -548,7 +548,7 @@ async function main() {
if (T.heli65.active) states.push(row(T.heli65.icao, T.heli65.callsign, T.stateAt(T.heli65, tRel), T.heli65.altM, T.heli65.category)); if (T.heli65.active) states.push(row(T.heli65.icao, T.heli65.callsign, T.stateAt(T.heli65, tRel), T.heli65.altM, T.heli65.category));
return Promise.resolve(jsonResponse({ time: Math.floor(nowSec), states })); return Promise.resolve(jsonResponse({ time: Math.floor(nowSec), states }));
} }
if (url.includes('/api/adsblol/mil')) { if (url.includes('/api/military')) {
window.__TURN_HITS.mil++; window.__TURN_HITS.mil++;
const ac = T.military.map((m) => { const ac = T.military.map((m) => {
const s = T.stateAt(m, tRel); const s = T.stateAt(m, tRel);
+10 -10
View File
@@ -738,9 +738,9 @@ check({
}); });
check({ check({
id: 'B2', group: 'B', desc: 'Flights proxy returns live contacts (/api/opensky)', id: 'B2', group: 'B', desc: 'Flights proxy returns live contacts (/api/flights)',
run: async () => { run: async () => {
const r = await jget('/api/opensky?lamin=24&lomin=-125&lamax=50&lomax=-66', { timeoutMs: 45000 }); const r = await jget('/api/flights?lamin=24&lomin=-125&lamax=50&lomax=-66', { timeoutMs: 45000 });
if (!r.ok) return fail(`HTTP ${r.status}: ${r.text.slice(0, 120)}`); if (!r.ok) return fail(`HTTP ${r.status}: ${r.text.slice(0, 120)}`);
const n = r.json?.states?.length || 0; const n = r.json?.states?.length || 0;
return n > 0 ? pass(`${n} states, cache=${r.headers.get('x-opensky-cache') || 'n/a'}`) : fail('0 states returned'); return n > 0 ? pass(`${n} states, cache=${r.headers.get('x-opensky-cache') || 'n/a'}`) : fail('0 states returned');
@@ -750,7 +750,7 @@ check({
check({ check({
id: 'B3', group: 'B', desc: 'OpenSky credentials are actually in use (not the anonymous/fallback path)', id: 'B3', group: 'B', desc: 'OpenSky credentials are actually in use (not the anonymous/fallback path)',
run: async () => { run: async () => {
const r = await jget('/api/opensky?lamin=24&lomin=-125&lamax=50&lomax=-66', { timeoutMs: 45000 }); const r = await jget('/api/flights?lamin=24&lomin=-125&lamax=50&lomax=-66', { timeoutMs: 45000 });
// Header names are exact: X-OpenSky-Auth-Mode-Used / X-OpenSky-Auth-Reason. // Header names are exact: X-OpenSky-Auth-Mode-Used / X-OpenSky-Auth-Reason.
// (An earlier guess at these names made this check pass vacuously.) // (An earlier guess at these names made this check pass vacuously.)
const reason = r.headers.get('X-OpenSky-Auth-Reason') || ''; const reason = r.headers.get('X-OpenSky-Auth-Reason') || '';
@@ -826,9 +826,9 @@ check({
}); });
check({ check({
id: 'B8', group: 'B', desc: 'AIS vessel feed is live (/api/ais-live)', needsKey: 'AIS', id: 'B8', group: 'B', desc: 'AIS vessel feed is live (/api/vessels)', needsKey: 'AIS',
run: async () => { run: async () => {
const r = await jget('/api/ais-live', { timeoutMs: 40000 }); const r = await jget('/api/vessels', { timeoutMs: 40000 });
const rows = r.json?.rows?.length || 0; const rows = r.json?.rows?.length || 0;
const status = r.json?.status; const status = r.json?.status;
if (!r.ok) return fail(`HTTP ${r.status} status=${status}`); if (!r.ok) return fail(`HTTP ${r.status} status=${status}`);
@@ -864,7 +864,7 @@ check({
const guard = keyGuard('AIS', env.keys.AIS); const guard = keyGuard('AIS', env.keys.AIS);
if (guard) return guard; if (guard) return guard;
if (env.keys.AIS === true) return skip('server HAS an AISStream key', 'N/A'); if (env.keys.AIS === true) return skip('server HAS an AISStream key', 'N/A');
const r = await jget('/api/ais-live'); const r = await jget('/api/vessels');
return r.status === 503 && r.json?.status === 'missing-key' && Array.isArray(r.json?.rows) return r.status === 503 && r.json?.status === 'missing-key' && Array.isArray(r.json?.rows)
? pass(`503 status=missing-key, rows=[] — "${String(r.json?.error).slice(0, 60)}"`) ? pass(`503 status=missing-key, rows=[] — "${String(r.json?.error).slice(0, 60)}"`)
: fail(`expected 503/missing-key, got ${r.status} ${r.text.slice(0, 120)}`); : fail(`expected 503/missing-key, got ${r.status} ${r.text.slice(0, 120)}`);
@@ -1022,7 +1022,7 @@ check({
check({ check({
id: 'B21', group: 'B', desc: 'No proxy echoes credential material back to the client (P1-5 acceptance #4)', id: 'B21', group: 'B', desc: 'No proxy echoes credential material back to the client (P1-5 acceptance #4)',
run: async () => { run: async () => {
const paths = ['/api/cctv/sources', '/api/tomtom/status', '/api/firms/status', '/api/celestrak/stations', '/api/ais-live']; const paths = ['/api/cctv/sources', '/api/tomtom/status', '/api/firms/status', '/api/celestrak/stations', '/api/vessels'];
const leaked = []; const leaked = [];
const unscannable = []; const unscannable = [];
for (const p of paths) { for (const p of paths) {
@@ -1036,7 +1036,7 @@ check({
// An error page is not a payload. Scanning five 500s and finding no key // An error page is not a payload. Scanning five 500s and finding no key
// is trivially true and proves nothing — a broken app must not satisfy a // is trivially true and proves nothing — a broken app must not satisfy a
// negative assertion. The one documented exception is the keyless // negative assertion. The one documented exception is the keyless
// 503 {status:'missing-key'} from /api/ais-live, which IS its real shape. // 503 {status:'missing-key'} from /api/vessels, which IS its real shape.
const documentedKeyless = r.status === 503 const documentedKeyless = r.status === 503
&& (r.json?.status === 'missing-key' || r.json?.error === 'no_key' || /OPENAI_API_KEY is not set/.test(r.text)); && (r.json?.status === 'missing-key' || r.json?.error === 'no_key' || /OPENAI_API_KEY is not set/.test(r.text));
if (!r.ok && !documentedKeyless) { if (!r.ok && !documentedKeyless) {
@@ -2071,13 +2071,13 @@ async function preflight() {
env.keys.FIRMS = await statusKey('/api/firms/status'); env.keys.FIRMS = await statusKey('/api/firms/status');
env.keys.TOMTOM = await statusKey('/api/tomtom/status'); env.keys.TOMTOM = await statusKey('/api/tomtom/status');
try { try {
const ais = await jget('/api/ais-live'); const ais = await jget('/api/vessels');
if (ais.status === 503 && ais.json?.status === 'missing-key') env.keys.AIS = false; if (ais.status === 503 && ais.json?.status === 'missing-key') env.keys.AIS = false;
else if (ais.status === 200 && ais.json && Array.isArray(ais.json.rows)) env.keys.AIS = true; else if (ais.status === 200 && ais.json && Array.isArray(ais.json.rows)) env.keys.AIS = true;
else env.keys.AIS = 'error'; else env.keys.AIS = 'error';
} catch { env.keys.AIS = 'error'; } } catch { env.keys.AIS = 'error'; }
try { try {
const os = await jget('/api/opensky?lamin=29&lomin=-99&lamax=31&lomax=-97', { timeoutMs: 40000 }); const os = await jget('/api/flights?lamin=29&lomin=-99&lamax=31&lomax=-97', { timeoutMs: 40000 });
const reason = os.headers.get('X-OpenSky-Auth-Reason') || ''; const reason = os.headers.get('X-OpenSky-Auth-Reason') || '';
const used = os.headers.get('X-OpenSky-Auth-Mode-Used') || os.headers.get('X-OpenSky-Auth') || ''; const used = os.headers.get('X-OpenSky-Auth-Mode-Used') || os.headers.get('X-OpenSky-Auth') || '';
if (!os.ok) env.keys.OPENSKY = 'error'; if (!os.ok) env.keys.OPENSKY = 'error';
+1 -1
View File
@@ -180,7 +180,7 @@ async function installDeterministicDevEndpoints(page) {
}); });
return; return;
} }
if (url.pathname === '/api/ais-live') { if (url.pathname === '/api/vessels') {
await request.respond({ await request.respond({
status: 200, status: 200,
contentType: 'application/json', contentType: 'application/json',
+1 -1
View File
@@ -219,7 +219,7 @@ async function main() {
}); });
return; return;
} }
if (url.origin === APP_ORIGIN && url.pathname === '/api/ais-live') { if (url.origin === APP_ORIGIN && url.pathname === '/api/vessels') {
request.respond({ request.respond({
status: 200, status: 200,
contentType: 'application/json', contentType: 'application/json',
+4 -4
View File
@@ -229,11 +229,11 @@ async function main() {
const nowSec = Date.now() / 1000 + (S.timeOffsetSec || 0); const nowSec = Date.now() / 1000 + (S.timeOffsetSec || 0);
const tRel = nowSec - S.epochMs / 1000; const tRel = nowSec - S.epochMs / 1000;
if (url.includes('/api/opensky-track')) return Promise.resolve(jsonResponse({ path: [] })); if (url.includes('/api/flights/track')) return Promise.resolve(jsonResponse({ path: [] }));
if (url.includes('/api/adsblol/trace')) { if (url.includes('/api/military/track')) {
return Promise.resolve(jsonResponse({ timestamp: Math.floor(nowSec), trace: [] })); return Promise.resolve(jsonResponse({ timestamp: Math.floor(nowSec), trace: [] }));
} }
if (url.includes('/api/opensky')) { if (url.includes('/api/flights')) {
window.__SPR_HITS.opensky++; window.__SPR_HITS.opensky++;
const states = S.flights.map((f) => { const states = S.flights.map((f) => {
const s = S.stateAt(f, tRel); const s = S.stateAt(f, tRel);
@@ -249,7 +249,7 @@ async function main() {
}); });
return Promise.resolve(jsonResponse({ time: Math.floor(nowSec), states })); return Promise.resolve(jsonResponse({ time: Math.floor(nowSec), states }));
} }
if (url.includes('/api/adsblol/mil')) { if (url.includes('/api/military')) {
window.__SPR_HITS.mil++; window.__SPR_HITS.mil++;
const ac = S.military.map((m) => { const ac = S.military.map((m) => {
const s = S.stateAt(m, tRel); const s = S.stateAt(m, tRel);
+8 -8
View File
@@ -17,9 +17,9 @@
* `fetch` shim in the page that intercepts the poll endpoints and returns * `fetch` shim in the page that intercepts the poll endpoints and returns
* SYNTHETIC aircraft positioned near the camera, in the EXACT upstream payload * SYNTHETIC aircraft positioned near the camera, in the EXACT upstream payload
* shapes the layers parse: * shapes the layers parse:
* - flights → GET /api/opensky → { states: [ <state-vector[]> ] } * - flights → GET /api/flights → { states: [ <state-vector[]> ] }
* - military → GET /api/adsblol/mil → { ac: [ <adsblol-aircraft{}> ] } * - military → GET /api/military → { ac: [ <adsblol-aircraft{}> ] }
* - vessels → GET /api/ais-live → connected, zero-row snapshot * - vessels → GET /api/vessels → connected, zero-row snapshot
* The shim stays installed so the layers' setInterval pollers keep the * The shim stays installed so the layers' setInterval pollers keep the
* synthetic planes alive (under MISSING_POLL_LIMIT=3 so they're never pruned). * synthetic planes alive (under MISSING_POLL_LIMIT=3 so they're never pruned).
* *
@@ -343,7 +343,7 @@ async function main() {
const url = new URL(requestUrl, window.location.href); const url = new URL(requestUrl, window.location.href);
const isAppRequest = url.origin === appOrigin; const isAppRequest = url.origin === appOrigin;
// OpenSky (commercial flights): { states: [ state-vector[] ] } // OpenSky (commercial flights): { states: [ state-vector[] ] }
if (isAppRequest && url.pathname === '/api/opensky') { if (isAppRequest && url.pathname === '/api/flights') {
window.__SYNTH_HITS.opensky++; window.__SYNTH_HITS.opensky++;
// A withheld contact models the ordinary case where the recipient's // A withheld contact models the ordinary case where the recipient's
// first authoritative refresh does not yet carry the shared aircraft. // first authoritative refresh does not yet carry the shared aircraft.
@@ -371,11 +371,11 @@ async function main() {
// Trail backfill (fires on trackById). Stub with valid-but-empty // Trail backfill (fires on trackById). Stub with valid-but-empty
// payloads so the deterministic run never 404s against the dev proxy. // payloads so the deterministic run never 404s against the dev proxy.
// flights: { path: [ [time, lat, lon, baroAlt, true_track, on_ground] ] } // flights: { path: [ [time, lat, lon, baroAlt, true_track, on_ground] ] }
if (isAppRequest && url.pathname === '/api/opensky-track') { if (isAppRequest && url.pathname === '/api/flights/track') {
return Promise.resolve(jsonResponse({ path: [] })); return Promise.resolve(jsonResponse({ path: [] }));
} }
// military: { timestamp: <epochSec>, trace: [ [secAfter, lat, lon, ...] ] } // military: { timestamp: <epochSec>, trace: [ [secAfter, lat, lon, ...] ] }
if (isAppRequest && url.pathname === '/api/adsblol/trace') { if (isAppRequest && url.pathname === '/api/military/track') {
return Promise.resolve(jsonResponse({ timestamp: Math.floor(Date.now() / 1000), trace: [] })); return Promise.resolve(jsonResponse({ timestamp: Math.floor(Date.now() / 1000), trace: [] }));
} }
// adsbdb enrichment (fires for tracked/model-eligible planes): empty // adsbdb enrichment (fires for tracked/model-eligible planes): empty
@@ -388,7 +388,7 @@ async function main() {
// Contacts enables the optional AIS source alongside aircraft. Keep // Contacts enables the optional AIS source alongside aircraft. Keep
// this tracking harness independent of local AIS credentials while // this tracking harness independent of local AIS credentials while
// preserving the truthful "awaiting first message" lifecycle. // preserving the truthful "awaiting first message" lifecycle.
if (isAppRequest && url.pathname === '/api/ais-live') { if (isAppRequest && url.pathname === '/api/vessels') {
return Promise.resolve(jsonResponse({ return Promise.resolve(jsonResponse({
status: 'connected', status: 'connected',
rows: [], rows: [],
@@ -450,7 +450,7 @@ async function main() {
return Promise.resolve(jsonResponse({ places: [] })); return Promise.resolve(jsonResponse({ places: [] }));
} }
// adsb.lol military: { ac: [ aircraft{} ] } (must come AFTER /trace check) // adsb.lol military: { ac: [ aircraft{} ] } (must come AFTER /trace check)
if (isAppRequest && url.pathname === '/api/adsblol/mil') { if (isAppRequest && url.pathname === '/api/military') {
window.__SYNTH_HITS.mil++; window.__SYNTH_HITS.mil++;
const ac = window.__SYNTH.military.map((m) => ({ const ac = window.__SYNTH.military.map((m) => ({
hex: m.hex, hex: m.hex,
+8 -4
View File
@@ -1,7 +1,7 @@
/** /**
* Vite plugin: adsb.lol military aircraft proxy with 12 s response cache. * Vite plugin: adsb.lol military aircraft proxy with 12 s response cache.
* *
* Proxies GET /api/adsblol/mil to https://api.adsb.lol/v2/mil. When upstream * Serves GET /api/military from https://api.adsb.lol/v2/mil. When upstream
* fails — a thrown fetch OR a non-OK status such as 429 — the proxy serves its * fails — a thrown fetch OR a non-OK status such as 429 — the proxy serves its
* cached body as STALE instead of relaying the failure, and it backs off from * cached body as STALE instead of relaying the failure, and it backs off from
* upstream for the Retry-After period (bounded) so a rate limit is never * upstream for the Retry-After period (bounded) so a rate limit is never
@@ -55,10 +55,11 @@ export function adsbLolProxy() {
res.writeHead(status, { res.writeHead(status, {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Cache-Control': 'no-store', 'Cache-Control': 'no-store',
'X-ADS-B-Cache': cacheStatus, 'X-Feed-Source': 'adsb.lol',
'X-Feed-Cache': cacheStatus,
...(['HIT', 'STALE'].includes(cacheStatus) ...(['HIT', 'STALE'].includes(cacheStatus)
? { ? {
'X-ADS-B-Cache-Age-Ms': String(Math.max(0, Date.now() - _cacheAt)), 'X-Feed-Age-Ms': String(Math.max(0, Date.now() - _cacheAt)),
} }
: {}), : {}),
...extra, ...extra,
@@ -67,8 +68,11 @@ export function adsbLolProxy() {
} }
const installMiddleware = (server) => { const installMiddleware = (server) => {
server.middlewares.use('/api/adsblol/mil', async (req, res) => { server.middlewares.use('/api/military', async (req, res, next) => {
try { try {
// This mount also matches its /track route, served by its own handler.
if (new URL(req.url || '/', 'http://localhost').pathname !== '/')
return next();
const now = Date.now(); const now = Date.now();
if (_cache && now - _cacheAt < CACHE_MS) { if (_cache && now - _cacheAt < CACHE_MS) {
serve(res, 200, _cache, 'HIT'); serve(res, 200, _cache, 'HIT');
+5 -1
View File
@@ -380,8 +380,12 @@ function openSkySourceIsStale(sourceEpochMs, now = Date.now()) {
*/ */
export function openSkyProxy() { export function openSkyProxy() {
const installMiddleware = (server) => { const installMiddleware = (server) => {
server.middlewares.use('/api/opensky', async (req, res) => { server.middlewares.use('/api/flights', async (req, res, next) => {
try { try {
// This mount also matches its /track route, served by its own handler.
if (new URL(req.url || '/', 'http://localhost').pathname !== '/')
return next();
res.setHeader('X-Feed-Source', 'OpenSky Network');
const requestedMode = normalizeOpenSkyAuthMode( const requestedMode = normalizeOpenSkyAuthMode(
process.env.OPENSKY_AUTH_MODE, process.env.OPENSKY_AUTH_MODE,
); );
+4 -4
View File
@@ -3,11 +3,11 @@ import { readCappedResponseText } from '../common/http.js';
/** /**
* Vite plugin: aircraft track-history backfill proxies (PRD WS-F F1/F2). * Vite plugin: aircraft track-history backfill proxies (PRD WS-F F1/F2).
* *
* /api/opensky-track?icao24=<hex6> — OpenSky GET /tracks/all (experimental; * /api/flights/track?icao24=<hex6> — OpenSky GET /tracks/all (experimental;
* own credit bucket, 4 credits per call on the free tier). OAuth via the * own credit bucket, 4 credits per call on the free tier). OAuth via the
* shared coalesced token. 60s per-icao cache; 404/429 forwarded so the * shared coalesced token. 60s per-icao cache; 404/429 forwarded so the
* client can fall back to its accumulated trail silently. * client can fall back to its accumulated trail silently.
* /api/adsblol/trace?hex=<hex> — adsb.lol tar1090 readsb trace * /api/military/track?hex=<hex> — adsb.lol tar1090 readsb trace
* (undocumented but live; no browser CORS, hence this proxy). Up to ~24h * (undocumented but live; no browser CORS, hence this proxy). Up to ~24h
* of real history per aircraft. Treat as best-effort; data is ODbL — * of real history per aircraft. Treat as best-effort; data is ODbL —
* credit "adsb.lol (ODbL)" in the UI. * credit "adsb.lol (ODbL)" in the UI.
@@ -65,7 +65,7 @@ export function trackBackfillProxies() {
} }
function install(middlewares) { function install(middlewares) {
middlewares.use('/api/opensky-track', async (req, res) => { middlewares.use('/api/flights/track', async (req, res) => {
try { try {
const incoming = new URL(req.url || '', 'http://localhost'); const incoming = new URL(req.url || '', 'http://localhost');
const icao24 = String(incoming.searchParams.get('icao24') || '') const icao24 = String(incoming.searchParams.get('icao24') || '')
@@ -93,7 +93,7 @@ export function trackBackfillProxies() {
} }
}); });
middlewares.use('/api/adsblol/trace', async (req, res) => { middlewares.use('/api/military/track', async (req, res) => {
try { try {
const incoming = new URL(req.url || '', 'http://localhost'); const incoming = new URL(req.url || '', 'http://localhost');
const hex = String(incoming.searchParams.get('hex') || '') const hex = String(incoming.searchParams.get('hex') || '')
+5 -4
View File
@@ -71,14 +71,15 @@ let _aisWebSocketImpl;
*/ */
export function aisLiveProxy() { export function aisLiveProxy() {
function install(middlewares) { function install(middlewares) {
middlewares.use('/api/ais-live', async (req, res) => { middlewares.use('/api/vessels', async (req, res) => {
try { try {
res.setHeader('X-Feed-Source', 'AISStream');
ensureAisStreamConnection(); ensureAisStreamConnection();
const incoming = new URL(req.url || '', 'http://localhost'); const incoming = new URL(req.url || '', 'http://localhost');
// Track sub-route MUST be handled before the rows snapshot — this // Track sub-route MUST be handled before the rows snapshot — this
// mount prefix-matches every subpath, so without this branch // mount prefix-matches every subpath, so without this branch
// /api/ais-live/track would be silently answered with vessel rows. // /api/vessels/track would be silently answered with vessel rows.
if ( if (
incoming.pathname === '/track' || incoming.pathname === '/track' ||
incoming.pathname.startsWith('/track/') incoming.pathname.startsWith('/track/')
@@ -288,7 +289,7 @@ function aisKeyFingerprint() {
} }
/** /**
* Drive the watchdog once. Called on every /api/ais-live request and on the * Drive the watchdog once. Called on every /api/vessels request and on the
* background interval, so recovery does not depend on browser traffic. * background interval, so recovery does not depend on browser traffic.
*/ */
function ensureAisStreamConnection() { function ensureAisStreamConnection() {
@@ -307,7 +308,7 @@ function ensureAisStreamConnection() {
keyFingerprint: aisKeyFingerprint(), keyFingerprint: aisKeyFingerprint(),
}); });
} }
/** Status metadata for /api/ais-live, safe to call before the first connect. */ /** Status metadata for /api/vessels, safe to call before the first connect. */
function aisStreamStatusSnapshot() { function aisStreamStatusSnapshot() {
const snapshot = _aisAdapter ? _aisAdapter.snapshot() : null; const snapshot = _aisAdapter ? _aisAdapter.snapshot() : null;
if (snapshot) return snapshot; if (snapshot) return snapshot;
+3 -1
View File
@@ -19,6 +19,7 @@ import { describeError } from './errors.js';
export async function createApplicationScene({ export async function createApplicationScene({
requestServices, requestServices,
googleApiKey, googleApiKey,
googleTokens = null,
cesiumToken, cesiumToken,
credits, credits,
MapController = MapStackController, MapController = MapStackController,
@@ -59,11 +60,12 @@ export async function createApplicationScene({
registerDataCredits(viewer, credits); registerDataCredits(viewer, credits);
configureCreditKeyboardAccess(document); configureCreditKeyboardAccess(document);
loaderStatus.textContent = loaderStatus.textContent =
googleApiKey || cesiumToken googleApiKey || googleTokens || cesiumToken
? 'Loading Google 3D Tiles...' ? 'Loading Google 3D Tiles...'
: 'Loading the keyless globe...'; : 'Loading the keyless globe...';
const photoreal = await loadPhotorealisticTileset(Cesium, { const photoreal = await loadPhotorealisticTileset(Cesium, {
googleApiKey, googleApiKey,
googleTokens,
cesiumToken, cesiumToken,
}); });
const tileset = photoreal.tileset; const tileset = photoreal.tileset;
+1 -1
View File
@@ -28,7 +28,7 @@ test('ais analyst record: full record maps every contract field', () => {
courseDeg: 214.0, courseDeg: 214.0,
shipType: 'Cargo', shipType: 'Cargo',
destination: 'OAKLAND', destination: 'OAKLAND',
navStatus: null, // /api/ais-live does not surface NavigationalStatus navStatus: null, // /api/vessels does not surface NavigationalStatus
}); });
}); });
+3 -3
View File
@@ -1,10 +1,10 @@
import { createApplicationVessels } from '../app/layers/aisLiveVessels.js'; import { createApplicationVessels } from '../app/layers/aisLiveVessels.js';
import { createAisStreamSource } from '../sources/live/standalone.js'; import { createVesselSource } from '../sources/live/standalone.js';
const aisLiveVesselsLayer = createApplicationVessels({ const aisLiveVesselsLayer = createApplicationVessels({
source: createAisStreamSource({ source: createVesselSource({
apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live', apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/vessels',
// Resolved against the document's address, which a panel host may serve // Resolved against the document's address, which a panel host may serve
// from its own scheme. // from its own scheme.
origin: () => globalThis.document?.baseURI ?? 'http://localhost', origin: () => globalThis.document?.baseURI ?? 'http://localhost',
+2 -2
View File
@@ -1668,7 +1668,7 @@ test('a vessel analyst record carries the MMSI the tracker keys on', () => {
}); });
test('vessel selection passes the opaque source reference to optional history', async () => { test('vessel selection passes the opaque source reference to optional history', async () => {
const { createAisStreamSource } = await import('../sources/live/standalone.js'); const { createVesselSource } = await import('../sources/live/standalone.js');
_setVesselStateForTest({ enabled: false }); _setVesselStateForTest({ enabled: false });
const requests = []; const requests = [];
aisLiveVesselsLayer.setSource({ aisLiveVesselsLayer.setSource({
@@ -1688,6 +1688,6 @@ test('vessel selection passes the opaque source reference to optional history',
} finally { } finally {
harness.cleanup(); harness.cleanup();
_setVesselStateForTest({ enabled: false }); _setVesselStateForTest({ enabled: false });
aisLiveVesselsLayer.setSource(createAisStreamSource()); aisLiveVesselsLayer.setSource(createVesselSource());
} }
}); });
+1 -1
View File
@@ -450,7 +450,7 @@ export function createAisWatchdog(options = {}) {
return generation; return generation;
} }
/** Status metadata for /api/ais-live. */ /** Status metadata for /api/vessels. */
function snapshot() { function snapshot() {
const monoNow = clock.mono(); const monoNow = clock.mono();
const wallNow = clock.wall(); const wallNow = clock.wall();
+2 -2
View File
@@ -1,12 +1,12 @@
import { defaultSurface } from './surfaceServices.js'; import { defaultSurface } from './surfaceServices.js';
import { createApplicationFlights } from '../app/layers/flights.js'; import { createApplicationFlights } from '../app/layers/flights.js';
import { createOpenSkySource } from '../sources/live/standalone.js'; import { createFlightSource } from '../sources/live/standalone.js';
import * as militaryRegistry from './militaryRegistry.js'; import * as militaryRegistry from './militaryRegistry.js';
const flightsLayer = createApplicationFlights({ const flightsLayer = createApplicationFlights({
surface: defaultSurface, surface: defaultSurface,
source: createOpenSkySource(), source: createFlightSource(),
militaryRegistry, militaryRegistry,
}); });
export { TRACKED_MODEL_MAX_PX } from '../layers/flights/policy.js'; export { TRACKED_MODEL_MAX_PX } from '../layers/flights/policy.js';
+1 -1
View File
@@ -203,7 +203,7 @@ test('flights poll refreshes tracked callsign/FL/kts and marks a missed poll STA
const nowSec = Math.floor(Date.now() / 1000); const nowSec = Math.floor(Date.now() / 1000);
let openskyPoll = 0; let openskyPoll = 0;
globalThis.fetch = async (url) => { globalThis.fetch = async (url) => {
if (!String(url).startsWith('/api/opensky')) { if (!String(url).startsWith('/api/flights')) {
return { ok: true, status: 200, json: async () => ({ ac: [] }) }; return { ok: true, status: 200, json: async () => ({ ac: [] }) };
} }
const states = openskyPoll++ === 0 const states = openskyPoll++ === 0
+1 -1
View File
@@ -489,7 +489,7 @@ test('pending mapped installations render as unknown instead of a false zero', a
// the dependencies reach it by different routes: // the dependencies reach it by different routes:
// //
// - ais-live-vessels is its REACHABLE producer. enable()/update() both resolve // - ais-live-vessels is its REACHABLE producer. enable()/update() both resolve
// as soon as the first /api/ais-live poll answers, so the manager's // as soon as the first /api/vessels poll answers, so the manager's
// lifecycle settles to `enabled` — but until the server-side socket delivers // lifecycle settles to `enabled` — but until the server-side socket delivers
// a position, firstConnectPhase stays 'loading' and the module reports busy, // a position, firstConnectPhase stays 'loading' and the module reports busy,
// no lastUpdate, count 0, and an UNDEFINED status, so the status list alone // no lastUpdate, count 0, and an UNDEFINED status, so the status list alone
+2 -2
View File
@@ -1,12 +1,12 @@
import { defaultSurface } from './surfaceServices.js'; import { defaultSurface } from './surfaceServices.js';
import { createApplicationMilitary } from '../app/layers/militaryFlights.js'; import { createApplicationMilitary } from '../app/layers/militaryFlights.js';
import { createAdsbLolSource } from '../sources/live/standalone.js'; import { createMilitarySource } from '../sources/live/standalone.js';
import * as militaryRegistry from './militaryRegistry.js'; import * as militaryRegistry from './militaryRegistry.js';
const militaryFlightsLayer = createApplicationMilitary({ const militaryFlightsLayer = createApplicationMilitary({
surface: defaultSurface, surface: defaultSurface,
source: createAdsbLolSource(), source: createMilitarySource(),
militaryRegistry, militaryRegistry,
}); });
export { TRACKED_MODEL_MAX_PX } from '../layers/military/policy.js'; export { TRACKED_MODEL_MAX_PX } from '../layers/military/policy.js';
+305 -80
View File
@@ -24,8 +24,12 @@ test('share-Follow absence requires an accepted adsb.lol snapshot', async () =>
(await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234')).status, (await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234')).status,
'source-unavailable', 'source-unavailable',
); );
_setMilitaryTrackingRefreshOutcomeForTest({ status: 'accepted', ids: ['different'] }); _setMilitaryTrackingRefreshOutcomeForTest({
const missing = await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234'); status: 'accepted',
ids: ['different'],
});
const missing =
await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234');
assert.equal(missing.status, 'missing'); assert.equal(missing.status, 'missing');
assert.equal(missing.reason, 'target-absent-from-snapshot'); assert.equal(missing.reason, 'target-absent-from-snapshot');
}); });
@@ -44,9 +48,9 @@ const FULL_INFO = {
operator: 'United States Air Force', operator: 'United States Air Force',
}; };
test('military stats identify adsb.lol as the primary feed, not a fallback', () => { test('military stats name the feed until the server names its provider, not a fallback', () => {
const stats = militaryFlightsLayer.getStats(); const stats = militaryFlightsLayer.getStats();
assert.equal(stats.source, 'adsb.lol'); assert.equal(stats.source, 'Military aircraft');
assert.equal(stats.fallback, false); assert.equal(stats.fallback, false);
}); });
@@ -81,8 +85,14 @@ test('military analyst record: military is ALWAYS true, routes/country always nu
}); });
test('military analyst record: no callsign falls back to registration, then icao24', () => { test('military analyst record: no callsign falls back to registration, then icao24', () => {
assert.equal(mapAnalystRecord('ae01ce', { ...FULL_INFO, callsign: '' }).id, '05-8152'); assert.equal(
assert.equal(mapAnalystRecord('ae01ce', { callsign: '', registration: ' ' }).id, 'ae01ce'); mapAnalystRecord('ae01ce', { ...FULL_INFO, callsign: '' }).id,
'05-8152',
);
assert.equal(
mapAnalystRecord('ae01ce', { callsign: '', registration: ' ' }).id,
'ae01ce',
);
}); });
test('military analyst record: empty info yields nulls, never NaN/undefined', () => { test('military analyst record: empty info yields nulls, never NaN/undefined', () => {
@@ -91,7 +101,8 @@ test('military analyst record: empty info yields nulls, never NaN/undefined', ()
assert.equal(r.onGround, false); assert.equal(r.onGround, false);
for (const [key, value] of Object.entries(r)) { for (const [key, value] of Object.entries(r)) {
assert.notEqual(value, undefined, `${key} must not be undefined`); assert.notEqual(value, undefined, `${key} must not be undefined`);
if (typeof value === 'number') assert.ok(Number.isFinite(value), `${key} must not be NaN`); if (typeof value === 'number')
assert.ok(Number.isFinite(value), `${key} must not be NaN`);
} }
}); });
@@ -103,11 +114,16 @@ test('military analyst record: output is JSON-safe (no Cesium types)', () => {
test('military first update forwards caller cancellation into the feed request', async () => { test('military first update forwards caller cancellation into the feed request', async () => {
const realFetch = globalThis.fetch; const realFetch = globalThis.fetch;
let observedSignal = null; let observedSignal = null;
globalThis.fetch = (_url, options = {}) => new Promise((_resolve, reject) => { globalThis.fetch = (_url, options = {}) =>
new Promise((_resolve, reject) => {
observedSignal = options.signal; observedSignal = options.signal;
options.signal?.addEventListener('abort', () => { options.signal?.addEventListener(
'abort',
() => {
reject(new DOMException('aborted', 'AbortError')); reject(new DOMException('aborted', 'AbortError'));
}, { once: true }); },
{ once: true },
);
}); });
try { try {
const controller = new AbortController(); const controller = new AbortController();
@@ -138,11 +154,22 @@ test('nonempty adsb.lol payload with zero usable rows cannot prove share target
json: async () => ({ ac: [null, {}, { hex: 'ae1234' }] }), json: async () => ({ ac: [null, {}, { hex: 'ae1234' }] }),
}); });
try { try {
await militaryFlightsLayer.update({ camera: { positionCartographic: null }, scene: {} }); await militaryFlightsLayer.update({
const resolution = await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234'); camera: { positionCartographic: null },
scene: {},
});
const resolution =
await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234');
assert.equal(resolution.status, 'source-unavailable'); assert.equal(resolution.status, 'source-unavailable');
assert.match(militaryFlightsLayer.getStats().error, /Malformed adsb\.lol aircraft rows/); assert.match(
assert.equal(militaryFlightsLayer.getAnalystRecords().length, 1, 'warm aircraft data is preserved'); militaryFlightsLayer.getStats().error,
/Malformed adsb\.lol aircraft rows/,
);
assert.equal(
militaryFlightsLayer.getAnalystRecords().length,
1,
'warm aircraft data is preserved',
);
} finally { } finally {
globalThis.fetch = realFetch; globalThis.fetch = realFetch;
} }
@@ -188,7 +215,10 @@ test('military poll refreshes tracked callsign/altitude/kts and marks a missed p
ok: true, ok: true,
status: 200, status: 200,
json: async () => ({ json: async () => ({
ac: poll++ === 0 ? [{ ac:
poll++ === 0
? [
{
hex: icao24, hex: icao24,
lon: -96.9, lon: -96.9,
lat: 31.1, lat: 31.1,
@@ -202,7 +232,9 @@ test('military poll refreshes tracked callsign/altitude/kts and marks a missed p
t: 'C17', t: 'C17',
r: '05-8152', r: '05-8152',
ownOp: 'United States Air Force', ownOp: 'United States Air Force',
}] : [], },
]
: [],
}), }),
}); });
@@ -227,7 +259,10 @@ test('cached military snapshots retain their observation time and expose stale f
_setTrackedMilitaryRefreshStateForTest({ _setTrackedMilitaryRefreshStateForTest({
icao24: 'ae01ce', icao24: 'ae01ce',
entity: { gevLabelModel: { title: 'RCH451', details: [] } }, entity: { gevLabelModel: { title: 'RCH451', details: [] } },
billboard: { position: Cesium.Cartesian3.fromDegrees(-97, 31, 8000), show: false }, billboard: {
position: Cesium.Cartesian3.fromDegrees(-97, 31, 8000),
show: false,
},
billboardCollection: { show: false, remove() {} }, billboardCollection: { show: false, remove() {} },
viewer, viewer,
history, history,
@@ -235,21 +270,48 @@ test('cached military snapshots retain their observation time and expose stale f
}); });
let cache = 'MISS'; let cache = 'MISS';
t.mock.method(Date, 'now', () => now); t.mock.method(Date, 'now', () => now);
t.mock.method(globalThis, 'fetch', async () => Response.json({ t.mock.method(globalThis, 'fetch', async () =>
ac: [{ hex: 'ae01ce', lon: -97, lat: 31, alt_baro: 28000, Response.json(
track: 95, gs: 400, seen: 1, seen_pos: 2, flight: 'RCH451' }], {
}, { headers: { ac: [
'X-ADS-B-Cache': cache, {
...(cache === 'MISS' ? {} : { 'X-ADS-B-Cache-Age-Ms': String(now - receivedAt) }), hex: 'ae01ce',
} })); lon: -97,
lat: 31,
alt_baro: 28000,
track: 95,
gs: 400,
seen: 1,
seen_pos: 2,
flight: 'RCH451',
},
],
},
{
headers: {
'X-Feed-Cache': cache,
...(cache === 'MISS'
? {}
: { 'X-Feed-Age-Ms': String(now - receivedAt) }),
},
},
),
);
await militaryFlightsLayer.update(viewer); await militaryFlightsLayer.update(viewer);
assert.equal(history.length, 1); assert.equal(history.length, 1);
assert.equal(history[0].epochMs, receivedAt - 2000); assert.equal(history[0].epochMs, receivedAt - 2000);
for (const [status, age] of [['HIT', 5000], ['STALE', 45000]]) { for (const [status, age] of [
['HIT', 5000],
['STALE', 45000],
]) {
cache = status; cache = status;
now = receivedAt + age; now = receivedAt + age;
await militaryFlightsLayer.update(viewer); await militaryFlightsLayer.update(viewer);
assert.equal(history.length, 1, 'replayed positions cannot create a new fix'); assert.equal(
history.length,
1,
'replayed positions cannot create a new fix',
);
assert.equal(militaryFlightsLayer.getStats().lastUpdate, receivedAt); assert.equal(militaryFlightsLayer.getStats().lastUpdate, receivedAt);
assert.equal(militaryFlightsLayer.getStats().stale, status === 'STALE'); assert.equal(militaryFlightsLayer.getStats().stale, status === 'STALE');
assert.equal(militaryFlightsLayer.getStats().error, null); assert.equal(militaryFlightsLayer.getStats().error, null);
@@ -276,14 +338,18 @@ test('real military track path creates no native label and publishes every cache
trackedEntityChanged, trackedEntityChanged,
clock: { currentTime: now }, clock: { currentTime: now },
camera: { camera: {
cancelFlight() { cancelledFlights += 1; }, cancelFlight() {
cancelledFlights += 1;
},
position: new Cesium.Cartesian3(0, -10_000, 7_000), position: new Cesium.Cartesian3(0, -10_000, 7_000),
positionWC: new Cesium.Cartesian3(0, -10_000, 7_000), positionWC: new Cesium.Cartesian3(0, -10_000, 7_000),
direction: Cesium.Cartesian3.UNIT_Y, direction: Cesium.Cartesian3.UNIT_Y,
transform: Cesium.Matrix4.IDENTITY, transform: Cesium.Matrix4.IDENTITY,
viewMatrix: Cesium.Matrix4.IDENTITY, viewMatrix: Cesium.Matrix4.IDENTITY,
frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY }, frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY },
lookAtTransform() { appliedFrames += 1; }, lookAtTransform() {
appliedFrames += 1;
},
}, },
scene: { scene: {
canvas: { clientWidth: 1600, clientHeight: 900 }, canvas: { clientWidth: 1600, clientHeight: 900 },
@@ -322,9 +388,12 @@ test('real military track path creates no native label and publishes every cache
const realFetch = globalThis.fetch; const realFetch = globalThis.fetch;
globalThis.window = new EventTarget(); globalThis.window = new EventTarget();
const selectionEvents = []; const selectionEvents = [];
globalThis.window.addEventListener('gev:awareness-subject-selected', (event) => { globalThis.window.addEventListener(
'gev:awareness-subject-selected',
(event) => {
selectionEvents.push(event.detail); selectionEvents.push(event.detail);
}); },
);
globalThis.fetch = async () => ({ ok: false }); globalThis.fetch = async () => ({ ok: false });
_setTrackedOverlayHostForTest(host); _setTrackedOverlayHostForTest(host);
try { try {
@@ -336,13 +405,15 @@ test('real military track path creates no native label and publishes every cache
billboardCollection: { show: true, remove() {} }, billboardCollection: { show: true, remove() {} },
viewer, viewer,
tracked: false, tracked: false,
history: [{ history: [
{
time: now, time: now,
epochMs: Date.now(), epochMs: Date.now(),
position, position,
velocity: 231.5, velocity: 231.5,
track: 92.1, track: 92.1,
}], },
],
meta: { meta: {
...FULL_INFO, ...FULL_INFO,
type: 'C17', type: 'C17',
@@ -352,34 +423,72 @@ test('real military track path creates no native label and publishes every cache
}, },
}); });
assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }), true); assert.equal(
militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }),
true,
);
const entity = viewer.trackedEntity; const entity = viewer.trackedEntity;
assert.ok(entity instanceof Cesium.Entity, 'trackById must create the real Cesium entity'); assert.ok(
entity instanceof Cesium.Entity,
'trackById must create the real Cesium entity',
);
assert.equal(entity.label, undefined); assert.equal(entity.label, undefined);
assert.ok(entities.values.every((candidate) => candidate.label === undefined)); assert.ok(
entities.values.every((candidate) => candidate.label === undefined),
);
assert.deepEqual(entity.gevLabelModel, { assert.deepEqual(entity.gevLabelModel, {
title: 'RCH451', title: 'RCH451',
details: [ details: ['C17 · 05-8152', 'United States Air Force · 28000 ft · 450 kt'],
'C17 · 05-8152',
'United States Air Force · 28000 ft · 450 kt',
],
accent: '#ffd166', accent: '#ffd166',
}); });
viewer.scene.preUpdate.raiseEvent(); viewer.scene.preUpdate.raiseEvent();
const initialAppliedFrames = appliedFrames; const initialAppliedFrames = appliedFrames;
const initialCancelledFlights = cancelledFlights; const initialCancelledFlights = cancelledFlights;
assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'user' }), true); assert.equal(
assert.equal(cancelledFlights, initialCancelledFlights, 'ordinary repeated tracking stays camera-idempotent'); militaryFlightsLayer.trackById(icao24, { origin: 'user' }),
assert.equal(selectionEvents.at(-1)?.origin, 'user', 'same-target selection upgrades durable authority'); true,
);
assert.equal(
cancelledFlights,
initialCancelledFlights,
'ordinary repeated tracking stays camera-idempotent',
);
assert.equal(
selectionEvents.at(-1)?.origin,
'user',
'same-target selection upgrades durable authority',
);
assert.equal(entity.gevSelectionOrigin, 'user'); assert.equal(entity.gevSelectionOrigin, 'user');
assert.equal(militaryFlightsLayer.refocusTrackedById('different-flight'), false); assert.equal(
assert.equal(militaryFlightsLayer.refocusTrackedById(icao24, { origin: 'voice' }), true); militaryFlightsLayer.refocusTrackedById('different-flight'),
assert.equal(selectionEvents.at(-1)?.origin, 'voice', 'same-target refocus forwards explicit authority'); false,
);
assert.equal(
militaryFlightsLayer.refocusTrackedById(icao24, { origin: 'voice' }),
true,
);
assert.equal(
selectionEvents.at(-1)?.origin,
'voice',
'same-target refocus forwards explicit authority',
);
assert.equal(militaryFlightsLayer.refocusTrackedById(icao24), true); assert.equal(militaryFlightsLayer.refocusTrackedById(icao24), true);
viewer.scene.preUpdate.raiseEvent(); viewer.scene.preUpdate.raiseEvent();
assert.equal(viewer.trackedEntity, entity, 'refocus must retain the exact tracked entity'); assert.equal(
assert.equal(appliedFrames, initialAppliedFrames + 1, 'repeated refocus keeps one camera-frame owner'); viewer.trackedEntity,
assert.equal(cancelledFlights, initialCancelledFlights + 2, 'only explicit refocus requests cancel camera flights'); entity,
'refocus must retain the exact tracked entity',
);
assert.equal(
appliedFrames,
initialAppliedFrames + 1,
'repeated refocus keeps one camera-frame owner',
);
assert.equal(
cancelledFlights,
initialCancelledFlights + 2,
'only explicit refocus requests cancel camera flights',
);
const publication = publications.at(-1); const publication = publications.at(-1);
assert.equal(publication.sourceId, 'tracked'); assert.equal(publication.sourceId, 'tracked');
const entry = publication.entries[0]; const entry = publication.entries[0];
@@ -390,23 +499,40 @@ test('real military track path creates no native label and publishes every cache
const display = entity.position.getValue(now); const display = entity.position.getValue(now);
assert.ok(display, 'tracked position callback must seed its frame cache'); assert.ok(display, 'tracked position callback must seed its frame cache');
assert.equal(entry.position(), display, 'host must reuse the exact cached display Cartesian'); assert.equal(
entry.position(),
display,
'host must reuse the exact cached display Cartesian',
);
militaryFlightsLayer.setParams( militaryFlightsLayer.setParams(
{ selectedMilitaryTrackingId: 'late001' }, { selectedMilitaryTrackingId: 'late001' },
{ origin: 'share-restore' }, { origin: 'share-restore' },
); );
assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late001'); assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late001');
assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'user' }), true); assert.equal(
assert.equal(_pendingMilitaryTrackingRestoreForTest(), null, 'new user selection cancels stale restore'); militaryFlightsLayer.trackById(icao24, { origin: 'user' }),
true,
);
assert.equal(
_pendingMilitaryTrackingRestoreForTest(),
null,
'new user selection cancels stale restore',
);
_addMilitaryTrackingCandidateForTest({ _addMilitaryTrackingCandidateForTest({
icao24: 'late001', icao24: 'late001',
billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-96.9, 31.1, 8_000) }, billboard: {
...billboard,
position: Cesium.Cartesian3.fromDegrees(-96.9, 31.1, 8_000),
},
meta: { ...FULL_INFO, callsign: 'LATE1', rawLat: 31.1, rawLon: -96.9 }, meta: { ...FULL_INFO, callsign: 'LATE1', rawLat: 31.1, rawLon: -96.9 },
history: [], history: [],
}); });
_applyPendingMilitaryTrackingRestoreForTest(); _applyPendingMilitaryTrackingRestoreForTest();
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, icao24); assert.equal(
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
icao24,
);
militaryFlightsLayer.stopTracking(); militaryFlightsLayer.stopTracking();
militaryFlightsLayer.setParams( militaryFlightsLayer.setParams(
@@ -415,15 +541,25 @@ test('real military track path creates no native label and publishes every cache
); );
assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late002'); assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late002');
militaryFlightsLayer.stopTracking({ origin: 'user' }); militaryFlightsLayer.stopTracking({ origin: 'user' });
assert.equal(_pendingMilitaryTrackingRestoreForTest(), null, 'explicit clear cancels stale restore'); assert.equal(
_pendingMilitaryTrackingRestoreForTest(),
null,
'explicit clear cancels stale restore',
);
_addMilitaryTrackingCandidateForTest({ _addMilitaryTrackingCandidateForTest({
icao24: 'late002', icao24: 'late002',
billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-96.8, 31.2, 7_000) }, billboard: {
...billboard,
position: Cesium.Cartesian3.fromDegrees(-96.8, 31.2, 7_000),
},
meta: { ...FULL_INFO, callsign: 'LATE2', rawLat: 31.2, rawLon: -96.8 }, meta: { ...FULL_INFO, callsign: 'LATE2', rawLat: 31.2, rawLon: -96.8 },
history: [], history: [],
}); });
_applyPendingMilitaryTrackingRestoreForTest(); _applyPendingMilitaryTrackingRestoreForTest();
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, null); assert.equal(
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
null,
);
militaryFlightsLayer.setParams( militaryFlightsLayer.setParams(
{ selectedMilitaryTrackingId: 'late003' }, { selectedMilitaryTrackingId: 'late003' },
@@ -438,12 +574,18 @@ test('real military track path creates no native label and publishes every cache
); );
_addMilitaryTrackingCandidateForTest({ _addMilitaryTrackingCandidateForTest({
icao24: 'late003', icao24: 'late003',
billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.3, 30.6, 7_000) }, billboard: {
...billboard,
position: Cesium.Cartesian3.fromDegrees(-97.3, 30.6, 7_000),
},
meta: { ...FULL_INFO, callsign: 'LATE3', rawLat: 30.6, rawLon: -97.3 }, meta: { ...FULL_INFO, callsign: 'LATE3', rawLat: 30.6, rawLon: -97.3 },
history: [], history: [],
}); });
_applyPendingMilitaryTrackingRestoreForTest(); _applyPendingMilitaryTrackingRestoreForTest();
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, null); assert.equal(
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
null,
);
militaryFlightsLayer.setParams( militaryFlightsLayer.setParams(
{ selectedMilitaryTrackingId: 'late004' }, { selectedMilitaryTrackingId: 'late004' },
@@ -452,18 +594,27 @@ test('real military track path creates no native label and publishes every cache
assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late004'); assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late004');
_addMilitaryTrackingCandidateForTest({ _addMilitaryTrackingCandidateForTest({
icao24: 'late004', icao24: 'late004',
billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.2, 30.7, 6_000) }, billboard: {
...billboard,
position: Cesium.Cartesian3.fromDegrees(-97.2, 30.7, 6_000),
},
meta: { ...FULL_INFO, callsign: 'LATE4', rawLat: 30.7, rawLon: -97.2 }, meta: { ...FULL_INFO, callsign: 'LATE4', rawLat: 30.7, rawLon: -97.2 },
history: [], history: [],
}); });
assert.equal(_applyPendingMilitaryTrackingRestoreForTest(), true); assert.equal(_applyPendingMilitaryTrackingRestoreForTest(), true);
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, 'late004'); assert.equal(
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
'late004',
);
militaryFlightsLayer.setParams( militaryFlightsLayer.setParams(
{ selectedMilitaryTrackingId: 'late005' }, { selectedMilitaryTrackingId: 'late005' },
{ origin: 'local-restore' }, { origin: 'local-restore' },
); );
assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }), true); assert.equal(
militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }),
true,
);
assert.equal( assert.equal(
_pendingMilitaryTrackingRestoreForTest(), _pendingMilitaryTrackingRestoreForTest(),
'late005', 'late005',
@@ -471,12 +622,18 @@ test('real military track path creates no native label and publishes every cache
); );
_addMilitaryTrackingCandidateForTest({ _addMilitaryTrackingCandidateForTest({
icao24: 'late005', icao24: 'late005',
billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-96.6, 31.4, 5_000) }, billboard: {
...billboard,
position: Cesium.Cartesian3.fromDegrees(-96.6, 31.4, 5_000),
},
meta: { ...FULL_INFO, callsign: 'LATE5', rawLat: 31.4, rawLon: -96.6 }, meta: { ...FULL_INFO, callsign: 'LATE5', rawLat: 31.4, rawLon: -96.6 },
history: [], history: [],
}); });
assert.equal(_applyPendingMilitaryTrackingRestoreForTest(), true); assert.equal(_applyPendingMilitaryTrackingRestoreForTest(), true);
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, 'late005'); assert.equal(
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
'late005',
);
} finally { } finally {
militaryFlightsLayer.stopTracking(); militaryFlightsLayer.stopTracking();
viewer.scene.preUpdate.raiseEvent(); viewer.scene.preUpdate.raiseEvent();
@@ -521,21 +678,47 @@ test('a callsign-less contact is findable by the tail number the app displays',
// The identity analyst_query hands back for this contact. // The identity analyst_query hands back for this contact.
const analystId = mapAnalystRecord(icao24, { const analystId = mapAnalystRecord(icao24, {
callsign: null, registration: '6606', rawLat: 40.71, rawLon: -74.01, callsign: null,
registration: '6606',
rawLat: 40.71,
rawLon: -74.01,
}).id; }).id;
assert.equal(analystId, '6606', 'the analyst names a callsign-less contact by its tail'); assert.equal(
analystId,
'6606',
'the analyst names a callsign-less contact by its tail',
);
const found = militaryFlightsLayer.findByQuery(analystId); const found = militaryFlightsLayer.findByQuery(analystId);
assert.equal(found?.icao24, icao24, 'and the tracker must resolve that same identity'); assert.equal(
assert.equal(militaryFlightsLayer.findByQuery('660')?.icao24, icao24, 'prefix match too'); found?.icao24,
assert.equal(militaryFlightsLayer.findByQuery(icao24)?.icao24, icao24, 'hex still wins outright'); icao24,
'and the tracker must resolve that same identity',
);
assert.equal(
militaryFlightsLayer.findByQuery('660')?.icao24,
icao24,
'prefix match too',
);
assert.equal(
militaryFlightsLayer.findByQuery(icao24)?.icao24,
icao24,
'hex still wins outright',
);
}); });
test('the analyst record carries the hex key the tracker keys on', () => { test('the analyst record carries the hex key the tracker keys on', () => {
const record = mapAnalystRecord('ae7f01', { const record = mapAnalystRecord('ae7f01', {
callsign: null, registration: '6606', rawLat: 40.71, rawLon: -74.01, callsign: null,
registration: '6606',
rawLat: 40.71,
rawLon: -74.01,
}); });
assert.equal(record.icao24, 'ae7f01', 'a resolvable key must ride along with the display label'); assert.equal(
record.icao24,
'ae7f01',
'a resolvable key must ride along with the display label',
);
}); });
test('a registration never outranks another contact’s exact callsign', () => { test('a registration never outranks another contact’s exact callsign', () => {
@@ -548,18 +731,35 @@ test('a registration never outranks another contact’s exact callsign', () => {
show: true, show: true,
}); });
const base = { const base = {
type: 'C17', operator: 'USAF', altitudeFt: 25000, speedMps: 180, type: 'C17',
track: 90, klass: 'widebody', onGround: false, operator: 'USAF',
altitudeFt: 25000,
speedMps: 180,
track: 90,
klass: 'widebody',
onGround: false,
}; };
// Registered FIRST, so insertion order favours it. // Registered FIRST, so insertion order favours it.
_addMilitaryTrackingCandidateForTest({ _addMilitaryTrackingCandidateForTest({
icao24: 'ae0aa1', icao24: 'ae0aa1',
meta: { ...base, callsign: null, registration: 'ZULU777', rawLat: 31.0, rawLon: -97.0 }, meta: {
...base,
callsign: null,
registration: 'ZULU777',
rawLat: 31.0,
rawLon: -97.0,
},
billboard: billboardAt(-97.0, 31.0), billboard: billboardAt(-97.0, 31.0),
}); });
_addMilitaryTrackingCandidateForTest({ _addMilitaryTrackingCandidateForTest({
icao24: 'ae0bb2', icao24: 'ae0bb2',
meta: { ...base, callsign: 'ZULU777', registration: '05-9999', rawLat: 31.2, rawLon: -97.2 }, meta: {
...base,
callsign: 'ZULU777',
registration: '05-9999',
rawLat: 31.2,
rawLon: -97.2,
},
billboard: billboardAt(-97.2, 31.2), billboard: billboardAt(-97.2, 31.2),
}); });
@@ -582,24 +782,49 @@ test('two contacts matching at the same strength resolve deterministically', ()
show: true, show: true,
}); });
const base = { const base = {
type: 'C130', operator: 'USAF', altitudeFt: 21000, speedMps: 150, type: 'C130',
track: 45, klass: 'turboprop', onGround: false, operator: 'USAF',
altitudeFt: 21000,
speedMps: 150,
track: 45,
klass: 'turboprop',
onGround: false,
}; };
// Both registrations begin with QQ7 — a same-tier prefix tie. Registered // Both registrations begin with QQ7 — a same-tier prefix tie. Registered
// highest-hex first so insertion order would pick the wrong one. // highest-hex first so insertion order would pick the wrong one.
_addMilitaryTrackingCandidateForTest({ _addMilitaryTrackingCandidateForTest({
icao24: 'aef002', icao24: 'aef002',
meta: { ...base, callsign: null, registration: 'QQ7-222', rawLat: 32.0, rawLon: -98.0 }, meta: {
...base,
callsign: null,
registration: 'QQ7-222',
rawLat: 32.0,
rawLon: -98.0,
},
billboard: billboardAt(-98.0, 32.0), billboard: billboardAt(-98.0, 32.0),
}); });
_addMilitaryTrackingCandidateForTest({ _addMilitaryTrackingCandidateForTest({
icao24: 'aef001', icao24: 'aef001',
meta: { ...base, callsign: null, registration: 'QQ7-111', rawLat: 32.1, rawLon: -98.1 }, meta: {
...base,
callsign: null,
registration: 'QQ7-111',
rawLat: 32.1,
rawLon: -98.1,
},
billboard: billboardAt(-98.1, 32.1), billboard: billboardAt(-98.1, 32.1),
}); });
const first = militaryFlightsLayer.findByQuery('QQ7')?.icao24; const first = militaryFlightsLayer.findByQuery('QQ7')?.icao24;
const second = militaryFlightsLayer.findByQuery('qq7')?.icao24; const second = militaryFlightsLayer.findByQuery('qq7')?.icao24;
assert.equal(first, 'aef001', 'the stable key (lowest hex) wins, not the feed order'); assert.equal(
assert.equal(second, first, 'and the same query resolves the same way every time'); first,
'aef001',
'the stable key (lowest hex) wins, not the feed order',
);
assert.equal(
second,
first,
'and the same query resolves the same way every time',
);
}); });
+2 -2
View File
@@ -1,7 +1,7 @@
import { createMilitaryRegistry } from '../layers/aircraft/classification.js'; import { createMilitaryRegistry } from '../layers/aircraft/classification.js';
import { createAdsbLolSource } from '../sources/live/standalone.js'; import { createMilitarySource } from '../sources/live/standalone.js';
const registry = createMilitaryRegistry({ source: createAdsbLolSource() }); const registry = createMilitaryRegistry({ source: createMilitarySource() });
export const isMilitaryLayerActive = registry.isMilitaryLayerActive; export const isMilitaryLayerActive = registry.isMilitaryLayerActive;
export const setMilitaryLayerActive = registry.setMilitaryLayerActive; export const setMilitaryLayerActive = registry.setMilitaryLayerActive;
export const onMilitaryLayerActiveChange = registry.onMilitaryLayerActiveChange; export const onMilitaryLayerActiveChange = registry.onMilitaryLayerActiveChange;
+2 -2
View File
@@ -189,7 +189,7 @@ test('a conversion survives a poll refresh, in both the billboard and the tracke
const realFetch = globalThis.fetch; const realFetch = globalThis.fetch;
const nowSec = Math.floor(Date.now() / 1000); const nowSec = Math.floor(Date.now() / 1000);
globalThis.fetch = async (url) => { globalThis.fetch = async (url) => {
if (!String(url).startsWith('/api/opensky')) { if (!String(url).startsWith('/api/flights')) {
return { ok: true, status: 200, json: async () => ({ ac: [] }) }; return { ok: true, status: 200, json: async () => ({ ac: [] }) };
} }
return { return {
@@ -309,7 +309,7 @@ test('conversions are session-scoped and no lifecycle path clears them', async (
meta: { callsign: 'OLD1', altitude: 9_000, klass: 'airliner', rawLat: 30.2, rawLon: -97.7 }, meta: { callsign: 'OLD1', altitude: 9_000, klass: 'airliner', rawLat: 30.2, rawLon: -97.7 },
}); });
const realFetch = globalThis.fetch; const realFetch = globalThis.fetch;
globalThis.fetch = async (url) => (String(url).startsWith('/api/opensky') globalThis.fetch = async (url) => (String(url).startsWith('/api/flights')
? { ok: true, status: 200, headers: { get: () => null }, json: async () => ({ time: 0, states: [] }) } ? { ok: true, status: 200, headers: { get: () => null }, json: async () => ({ time: 0, states: [] }) }
: { ok: true, status: 200, json: async () => ({ ac: [] }) }); : { ok: true, status: 200, json: async () => ({ ac: [] }) });
try { try {
+1 -1
View File
@@ -35,7 +35,7 @@ export function createQueries({ state: layerState, services, parts, source }) {
// setInstallationStatus is only ever called with // setInstallationStatus is only ever called with
// loading/zoom-in/ready/stale/empty/unavailable. // loading/zoom-in/ready/stale/empty/unavailable.
// - ais-live-vessels is what the predicate below is FOR. Its enable() and // - ais-live-vessels is what the predicate below is FOR. Its enable() and
// update() both resolve as soon as the first /api/ais-live poll answers, // update() both resolve as soon as the first /api/vessels poll answers,
// so the lifecycle settles to `enabled` — but until the server-side socket // so the lifecycle settles to `enabled` — but until the server-side socket
// delivers a position, firstConnectPhase is 'loading' and getStats() // delivers a position, firstConnectPhase is 'loading' and getStats()
// reports loading: true, lastUpdate: null, count 0, and an UNDEFINED // reports loading: true, lastUpdate: null, count 0, and an UNDEFINED
+5 -5
View File
@@ -36,7 +36,7 @@ export function createQueries({
/** /**
* Chip text for a feed the server has reported as not delivering. * Chip text for a feed the server has reported as not delivering.
* @param {string} status - 'stale' | 'reconnecting' | 'down' * @param {string} status - 'stale' | 'reconnecting' | 'down'
* @param {Object} payload - Parsed /api/ais-live JSON. * @param {Object} payload - Parsed /api/vessels JSON.
* @returns {string} * @returns {string}
*/ */
@@ -61,12 +61,12 @@ export function createQueries({
} }
/** /**
* Derive a surfaced error string from an /api/ais-live payload, or null when the * Derive a surfaced error string from an /api/vessels payload, or null when the
* feed has accepted product data. Socket transport, message receipt, and usable * feed has accepted product data. Socket transport, message receipt, and usable
* vessel positions are separate health stages: an open socket with no message * vessel positions are separate health stages: an open socket with no message
* or no accepted positions must not read as a fresh successful update. * or no accepted positions must not read as a fresh successful update.
* *
* @param {Object|null|undefined} payload - Parsed /api/ais-live JSON. * @param {Object|null|undefined} payload - Parsed /api/vessels JSON.
* @param {number} acceptedRowCount - Number of rows accepted by vessel normalization. * @param {number} acceptedRowCount - Number of rows accepted by vessel normalization.
* @returns {string|null} A short reason for the chip, or null if healthy. * @returns {string|null} A short reason for the chip, or null if healthy.
*/ */
@@ -106,7 +106,7 @@ export function createQueries({
/** /**
* Classify one server snapshot before any destructive reconciliation. * Classify one server snapshot before any destructive reconciliation.
* @param {Object|null|undefined} payload - Parsed /api/ais-live payload. * @param {Object|null|undefined} payload - Parsed /api/vessels payload.
* @returns {{transportStatus: string|null, lastMessageAt: number|string|null, * @returns {{transportStatus: string|null, lastMessageAt: number|string|null,
* rawRows: Array<Object>, acceptedRows: Array<Object>, rawRowCount: number, * rawRows: Array<Object>, acceptedRows: Array<Object>, rawRowCount: number,
* acceptedRowCount: number, error: string|null}} * acceptedRowCount: number, error: string|null}}
@@ -140,7 +140,7 @@ export function createQueries({
* Map one internal vessel record to a plain JSON-safe analyst record * Map one internal vessel record to a plain JSON-safe analyst record
* (analyst query engine seam). Pure — no Cesium types. Missing/unknown * (analyst query engine seam). Pure — no Cesium types. Missing/unknown
* fields are null, never NaN/undefined. navStatus is always null: the * fields are null, never NaN/undefined. navStatus is always null: the
* /api/ais-live proxy does not surface AIS NavigationalStatus, so it * /api/vessels proxy does not surface AIS NavigationalStatus, so it
* cannot be derived client-side. * cannot be derived client-side.
* @param {Object|null|undefined} record - `state.records.byMmsi`/`state.records.all` entry. * @param {Object|null|undefined} record - `state.records.byMmsi`/`state.records.all` entry.
* @returns {{id: string|null, mmsi: string|null, name: string|null, * @returns {{id: string|null, mmsi: string|null, name: string|null,
+77 -1
View File
@@ -15,7 +15,16 @@ function fakeCesium(outcomes = []) {
return { return {
calls, calls,
Ion: { defaultAccessToken: undefined }, Ion: { defaultAccessToken: undefined },
GoogleMaps: { defaultApiKey: undefined }, GoogleMaps: {
defaultApiKey: undefined,
mapTilesApiEndpoint: 'https://tile.googleapis.com/',
getDefaultCredit: () => undefined,
},
Resource: class {
constructor(options) {
Object.assign(this, options);
}
},
async createGooglePhotorealistic3DTileset(options) { async createGooglePhotorealistic3DTileset(options) {
calls.push({ options, googleKey: options.key }); calls.push({ options, googleKey: options.key });
return next(); return next();
@@ -29,6 +38,7 @@ function fakeCesium(outcomes = []) {
async fromUrl(resource, options) { async fromUrl(resource, options) {
calls.push({ calls.push({
options, options,
resource,
ionToken: resource.accessToken, ionToken: resource.accessToken,
assetId: resource.assetId, assetId: resource.assetId,
}); });
@@ -140,3 +150,69 @@ test('independent source configurations never mutate shared SDK credentials', as
assert.equal(Cesium.Ion.defaultAccessToken, 'untouched-ion'); assert.equal(Cesium.Ion.defaultAccessToken, 'untouched-ion');
assert.equal(Cesium.GoogleMaps.defaultApiKey, 'untouched-google'); assert.equal(Cesium.GoogleMaps.defaultApiKey, 'untouched-google');
}); });
function tokenSource(...issued) {
const requests = [];
return {
requests,
async token(options = {}) {
requests.push(options);
return issued.shift() ?? null;
},
};
}
test('without a key, server tokens load Google 3D directly', async () => {
const tileset = { id: 'token' };
const Cesium = fakeCesium([tileset]);
const result = await loadPhotorealisticTileset(Cesium, {
googleTokens: tokenSource('short-lived'),
cesiumToken: 'ion-secret',
});
assert.equal(result.tileset, tileset);
assert.equal(result.route, 'google-token');
const { resource, options } = Cesium.calls[0];
assert.equal(
resource.url,
'https://tile.googleapis.com/v1/3dtiles/root.json',
);
assert.equal(resource.headers.Authorization, 'Bearer short-lived');
assert.equal(resource.queryParameters, undefined);
assert.equal(options.enableCollision, true);
});
test('a tile refused for an expired token retries once with a renewed one', async () => {
const Cesium = fakeCesium([{ id: 'token' }]);
const tokens = tokenSource('first', 'second');
await loadPhotorealisticTileset(Cesium, { googleTokens: tokens });
const { resource } = Cesium.calls[0];
const tile = { headers: { ...resource.headers } };
assert.equal(await resource.retryCallback(tile, { statusCode: 401 }), true);
assert.equal(tile.headers.Authorization, 'Bearer second');
assert.deepEqual(tokens.requests.at(-1), { replacing: 'first' });
assert.equal(resource.retryAttempts, 1);
assert.equal(await resource.retryCallback(tile, { statusCode: 500 }), false);
});
test('a key takes precedence over server tokens', async () => {
const Cesium = fakeCesium([{ id: 'direct' }]);
const tokens = tokenSource('short-lived');
const result = await loadPhotorealisticTileset(Cesium, {
googleApiKey: 'google-secret',
googleTokens: tokens,
});
assert.equal(result.route, 'google-direct');
assert.equal(tokens.requests.length, 0);
});
test('a server without tokens falls back to ion', async () => {
const tileset = { id: 'ion' };
const Cesium = fakeCesium([tileset]);
const result = await loadPhotorealisticTileset(Cesium, {
googleTokens: tokenSource(),
cesiumToken: 'ion-secret',
});
assert.equal(result.route, 'google-ion');
assert.equal(result.errors.length, 1);
assert.equal(Cesium.calls.length, 1);
});
+56 -10
View File
@@ -16,30 +16,43 @@ export function selectMapStartupRoute({
/** /**
* Load Google Photorealistic 3D Tiles through direct Google access when * Load Google Photorealistic 3D Tiles through direct Google access when
* configured, otherwise through Cesium ion's hosted Google asset. If the * configured, otherwise through Cesium ion's hosted Google asset. Direct
* direct request fails and an ion token is available, ion is the recovery path. * access uses a browser key, or else short-lived tokens from the app's
* server (see googleTokens.js). If direct access fails and an ion token is
* available, ion is the recovery path.
* *
* @param {object} Cesium * @param {object} Cesium
* @param {{googleApiKey?: string, cesiumToken?: string}} credentials * @param {{googleApiKey?: string, cesiumToken?: string, googleTokens?: {token: Function}|null}} credentials
* @returns {Promise<{tileset: object|null, route: 'google-direct'|'google-ion'|'osm', errors: Error[]}>} * @returns {Promise<{tileset: object|null, route: 'google-direct'|'google-token'|'google-ion'|'osm', errors: Error[]}>}
*/ */
export async function loadPhotorealisticTileset( export async function loadPhotorealisticTileset(
Cesium, Cesium,
{ googleApiKey = '', cesiumToken = '' } = {}, { googleApiKey = '', cesiumToken = '', googleTokens = null } = {},
) { ) {
const googleKey = clean(googleApiKey); const googleKey = clean(googleApiKey);
const ionToken = clean(cesiumToken); const ionToken = clean(cesiumToken);
const errors = []; const errors = [];
const attempts = []; const attempts = [];
if (googleKey) attempts.push({ route: 'google-direct', googleKey }); if (googleKey)
if (ionToken) attempts.push({ route: 'google-ion', googleKey: undefined }); attempts.push({
route: 'google-direct',
create: () => createGoogleDirectTileset(Cesium, googleKey),
});
else if (googleTokens)
attempts.push({
route: 'google-token',
create: () => createGoogleTokenTileset(Cesium, googleTokens),
});
if (ionToken)
attempts.push({
route: 'google-ion',
create: () => createGoogleIonTileset(Cesium, ionToken),
});
for (const attempt of attempts) { for (const attempt of attempts) {
try { try {
const tileset = attempt.googleKey const tileset = await attempt.create();
? await createGoogleDirectTileset(Cesium, attempt.googleKey)
: await createGoogleIonTileset(Cesium, ionToken);
return { tileset, route: attempt.route, errors }; return { tileset, route: attempt.route, errors };
} catch (error) { } catch (error) {
errors.push(error instanceof Error ? error : new Error(String(error))); errors.push(error instanceof Error ? error : new Error(String(error)));
@@ -60,6 +73,39 @@ export function createGoogleDirectTileset(Cesium, key) {
); );
} }
/**
* Google 3D with short-lived tokens instead of a key. Every tile request
* carries the token as a header; a tile refused for an expired token gets
* the renewed one and is tried once more.
*/
export async function createGoogleTokenTileset(Cesium, tokens) {
const token = await tokens.token();
if (!token) throw new Error('Google 3D tokens are not offered');
const credit = Cesium.GoogleMaps.getDefaultCredit?.();
const resource = new Cesium.Resource({
url: `${Cesium.GoogleMaps.mapTilesApiEndpoint}v1/3dtiles/root.json`,
headers: { Authorization: `Bearer ${token}` },
credits: credit ? [credit] : undefined,
retryAttempts: 1,
async retryCallback(failed, error) {
if (![401, 403].includes(error?.statusCode)) return false;
const used = failed.headers.Authorization?.replace(/^Bearer /, '');
const renewed = await tokens.token({ replacing: used });
if (!renewed || renewed === used) return false;
failed.headers.Authorization = `Bearer ${renewed}`;
return true;
},
});
// The settings Cesium's Google helper applies to the key route.
return Cesium.Cesium3DTileset.fromUrl(resource, {
cacheBytes: 1536 * 1024 * 1024,
maximumCacheOverflowBytes: 1024 * 1024 * 1024,
enableCollision: true,
// Tiles keep drawing their own texture while draped weather loads.
asynchronouslyLoadImagery: true,
});
}
export async function createGoogleIonTileset( export async function createGoogleIonTileset(
Cesium, Cesium,
accessToken, accessToken,
+62
View File
@@ -0,0 +1,62 @@
/**
* Short-lived Google access tokens the app's server may offer in place of a
* browser key. A server that offers them answers GET GOOGLE_TOKEN_PATH with
* `{ accessToken, expiresAt }` (expiresAt in epoch milliseconds); any other
* answer means it does not, and the app carries on without them.
*/
export const GOOGLE_TOKEN_PATH = '/api/google/tiles-token';
/** Renew a token this long before it expires. */
const RENEW_EARLY_MS = 60_000;
function parseToken(data, now) {
const accessToken = data?.accessToken;
const expiresAt = Number(data?.expiresAt);
if (typeof accessToken !== 'string' || accessToken.length === 0) return null;
if (!Number.isFinite(expiresAt) || expiresAt <= now) return null;
return { accessToken, expiresAt };
}
/**
* A source of tokens from the app's server. `token()` resolves the current
* token, renewing it shortly before it expires; `token({ replacing })`
* renews only if `replacing` is still the current token, so many tile
* requests rejected for the same expired token renew it once. Resolves null
* when the server does not offer tokens.
*/
export function createGoogleTokenSource({
fetchImpl = (...args) => fetch(...args),
url = GOOGLE_TOKEN_PATH,
now = () => Date.now(),
} = {}) {
let current = null;
let pending = null;
async function request() {
try {
const response = await fetchImpl(url, {
headers: { Accept: 'application/json' },
cache: 'no-store',
});
if (!response.ok) return null;
return parseToken(await response.json(), now());
} catch {
return null;
}
}
return {
async token({ replacing } = {}) {
const usable =
current &&
current.expiresAt - RENEW_EARLY_MS > now() &&
(replacing === undefined || current.accessToken !== replacing);
if (usable) return current.accessToken;
pending ??= request().finally(() => {
pending = null;
});
current = await pending;
return current?.accessToken ?? null;
},
};
}
+64
View File
@@ -0,0 +1,64 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createGoogleTokenSource, GOOGLE_TOKEN_PATH } from './googleTokens.js';
function server(answers) {
const calls = [];
return {
calls,
fetchImpl: async (url) => {
calls.push(url);
const answer = answers.shift();
if (answer instanceof Error) throw answer;
return answer;
},
};
}
const offer = (accessToken, expiresAt) =>
Response.json({ accessToken, expiresAt });
test('a token is reused until shortly before it expires', async () => {
let now = 1_000_000;
const { calls, fetchImpl } = server([
offer('first', now + 900_000),
offer('second', now + 1_800_000),
]);
const tokens = createGoogleTokenSource({ fetchImpl, now: () => now });
assert.equal(await tokens.token(), 'first');
assert.equal(await tokens.token(), 'first');
assert.deepEqual(calls, [GOOGLE_TOKEN_PATH]);
now += 850_000;
assert.equal(await tokens.token(), 'second');
assert.equal(calls.length, 2);
});
test('requests refused for the same token renew it once', async () => {
const now = 1_000_000;
const { calls, fetchImpl } = server([
offer('first', now + 900_000),
offer('second', now + 900_000),
]);
const tokens = createGoogleTokenSource({ fetchImpl, now: () => now });
await tokens.token();
const renewed = await Promise.all([
tokens.token({ replacing: 'first' }),
tokens.token({ replacing: 'first' }),
]);
assert.deepEqual(renewed, ['second', 'second']);
assert.equal(await tokens.token({ replacing: 'first' }), 'second');
assert.equal(calls.length, 2);
});
test('a server that does not offer tokens yields none', async () => {
for (const answer of [
new Response('Not found', { status: 404 }),
new Response('<!doctype html>', { status: 200 }),
Response.json({ accessToken: '', expiresAt: Date.now() + 60_000 }),
Response.json({ accessToken: 'stale', expiresAt: 1 }),
new TypeError('offline'),
]) {
const { fetchImpl } = server([answer]);
assert.equal(await createGoogleTokenSource({ fetchImpl }).token(), null);
}
});
+18 -18
View File
@@ -1,9 +1,9 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
createOpenSkySource, createFlightSource,
createAdsbLolSource, createMilitarySource,
createAisStreamSource, createVesselSource,
normalizeReadsbAircraft, normalizeReadsbAircraft,
normalizeVesselObservation, normalizeVesselObservation,
openSkySnapshot, openSkySnapshot,
@@ -133,7 +133,7 @@ test('construction is inert; adapters preserve routes, viewport query, cache epo
const requests = []; const requests = [];
const fetchImpl = async (url, init) => { const fetchImpl = async (url, init) => {
requests.push({ url, init }); requests.push({ url, init });
if (url.startsWith('/api/opensky?')) if (url.startsWith('/api/flights?'))
return response( return response(
{ time: now / 1000, states: [aircraft] }, { time: now / 1000, states: [aircraft] },
{ {
@@ -141,27 +141,27 @@ test('construction is inert; adapters preserve routes, viewport query, cache epo
'x-flight-coverage': '250 nm regional', 'x-flight-coverage': '250 nm regional',
}, },
); );
if (url.startsWith('/api/opensky-track')) if (url.startsWith('/api/flights/track'))
return response({ path: [[now / 1000 - 30, 30, -97, 1000, 45, false]] }); return response({ path: [[now / 1000 - 30, 30, -97, 1000, 45, false]] });
if (url === '/api/adsblol/mil') if (url === '/api/military')
return response( return response(
{ ac: [{ hex: 'abc123', lat: 30, lon: -97, seen_pos: 15 }] }, { ac: [{ hex: 'abc123', lat: 30, lon: -97, seen_pos: 15 }] },
{ 'x-ads-b-cache-age-ms': '60000', 'x-ads-b-cache': 'STALE' }, { 'x-feed-age-ms': '60000', 'x-feed-cache': 'STALE' },
); );
return response({ return response({
timestamp: now / 1000 - 60, timestamp: now / 1000 - 60,
trace: [[10, 30, -97, 1000]], trace: [[10, 30, -97, 1000]],
}); });
}; };
const civil = createOpenSkySource({ fetchImpl, now: () => now }); const civil = createFlightSource({ fetchImpl, now: () => now });
const military = createAdsbLolSource({ fetchImpl, now: () => now }); const military = createMilitarySource({ fetchImpl, now: () => now });
assert.equal(requests.length, 0); assert.equal(requests.length, 0);
const signal = new AbortController().signal; const signal = new AbortController().signal;
const snapshot = await civil.getSnapshot( const snapshot = await civil.getSnapshot(
{ latitude: 30.123456, longitude: -97 }, { latitude: 30.123456, longitude: -97 },
{ signal }, { signal },
); );
assert.equal(requests[0].url, '/api/opensky?lat=30.1235&lon=-97.0000'); assert.equal(requests[0].url, '/api/flights?lat=30.1235&lon=-97.0000');
assert.equal(requests[0].init.signal, signal); assert.equal(requests[0].init.signal, signal);
assert.equal(snapshot.source, 'adsb.lol'); assert.equal(snapshot.source, 'adsb.lol');
assert.equal(snapshot.coverage, '250 nm regional'); assert.equal(snapshot.coverage, '250 nm regional');
@@ -180,7 +180,7 @@ test('construction is inert; adapters preserve routes, viewport query, cache epo
test('cancellation after slow body parsing rejects even with a transport that ignores abort', async () => { test('cancellation after slow body parsing rejects even with a transport that ignores abort', async () => {
let release; let release;
const controller = new AbortController(); const controller = new AbortController();
const source = createOpenSkySource({ const source = createFlightSource({
fetchImpl: async () => ({ fetchImpl: async () => ({
ok: true, ok: true,
json: () => json: () =>
@@ -199,7 +199,7 @@ test('cancellation after slow body parsing rejects even with a transport that ig
test('denials and outages never start another source and do not echo arbitrary response bodies', async () => { test('denials and outages never start another source and do not echo arbitrary response bodies', async () => {
for (const status of [401, 403, 429, 502]) { for (const status of [401, 403, 429, 502]) {
let calls = 0; let calls = 0;
const source = createOpenSkySource({ const source = createFlightSource({
fetchImpl: async () => { fetchImpl: async () => {
calls++; calls++;
return response({ error: 'sensitive upstream detail' }, {}, status); return response({ error: 'sensitive upstream detail' }, {}, status);
@@ -216,12 +216,12 @@ test('denials and outages never start another source and do not echo arbitrary r
}); });
test('AIS reports limited received coverage and keeps connection state separate from positions', async () => { test('AIS reports limited received coverage and keeps connection state separate from positions', async () => {
const source = createAisStreamSource({ const source = createVesselSource({
origin: () => 'http://example.test', origin: () => 'http://example.test',
fetchImpl: async (url) => { fetchImpl: async (url) => {
if (url.includes('/track?')) if (url.includes('/track?'))
return response({ samples: [{ lat: 30, lon: -97, t: now / 1000 }] }); return response({ samples: [{ lat: 30, lon: -97, t: now / 1000 }] });
assert.equal(url, 'http://example.test/api/ais-live?maxRows=500'); assert.equal(url, 'http://example.test/api/vessels?maxRows=500');
return response({ return response({
status: 'reconnecting', status: 'reconnecting',
refreshing: true, refreshing: true,
@@ -249,7 +249,7 @@ test('AIS reports limited received coverage and keeps connection state separate
}); });
test('a malformed vessel row cannot prevent admission of valid positions', async () => { test('a malformed vessel row cannot prevent admission of valid positions', async () => {
const source = createAisStreamSource({ const source = createVesselSource({
fetchImpl: async () => fetchImpl: async () =>
response({ response({
status: 'live', status: 'live',
@@ -273,7 +273,7 @@ test('out-of-range source epochs do not reach Date or globe time constructors',
}); });
test('classification identities include positionless aircraft without admitting them to rendering', async () => { test('classification identities include positionless aircraft without admitting them to rendering', async () => {
const source = createAdsbLolSource({ const source = createMilitarySource({
fetchImpl: async () => fetchImpl: async () =>
response({ response({
ac: [ ac: [
@@ -293,12 +293,12 @@ test('classification identities include positionless aircraft without admitting
}); });
test('identity lookup validates its response and honors body-parse cancellation', async () => { test('identity lookup validates its response and honors body-parse cancellation', async () => {
const malformed = createAdsbLolSource({ const malformed = createMilitarySource({
fetchImpl: async () => response({ ac: [{}] }), fetchImpl: async () => response({ ac: [{}] }),
}); });
await assert.rejects(malformed.getIdentities(), /Malformed/); await assert.rejects(malformed.getIdentities(), /Malformed/);
const abort = new AbortController(); const abort = new AbortController();
const source = createAdsbLolSource({ const source = createMilitarySource({
fetchImpl: async () => ({ fetchImpl: async () => ({
ok: true, ok: true,
status: 200, status: 200,
+3 -3
View File
@@ -13,7 +13,7 @@ export {
vesselSnapshot, vesselSnapshot,
} from './vessels.js'; } from './vessels.js';
export { export {
createOpenSkySource, createFlightSource,
createAdsbLolSource, createMilitarySource,
createAisStreamSource, createVesselSource,
} from './standalone.js'; } from './standalone.js';
+50 -32
View File
@@ -17,7 +17,7 @@ const defaultFetch = (...args) => globalThis.fetch(...args);
const header = (response, name) => response.headers?.get?.(name); const header = (response, name) => response.headers?.get?.(name);
function openSkyError(response) { function openSkyError(response) {
const error = httpError(response, 'OpenSky'); const error = httpError(response, 'Flights');
const mode = String( const mode = String(
header(response, 'x-opensky-auth-mode-used') || header(response, 'x-opensky-auth-mode-used') ||
header(response, 'x-opensky-auth') || header(response, 'x-opensky-auth') ||
@@ -49,13 +49,24 @@ function openSkyError(response) {
return error; return error;
} }
/** Existing same-origin aircraft routes; no request starts during construction. */ /**
export function createOpenSkySource({ * The provider a feed response names in `X-Feed-Source`, so a server that
* answers a feed route from another provider is reported as that provider.
*/
function feedSource(response, fallback) {
const named = header(response, 'x-feed-source');
return typeof named === 'string' && named.trim()
? named.trim().slice(0, 80)
: fallback;
}
/** The flights feed (/api/flights); no request starts during construction. */
export function createFlightSource({
fetchImpl = defaultFetch, fetchImpl = defaultFetch,
now = () => Date.now(), now = () => Date.now(),
} = {}) { } = {}) {
return { return {
label: 'OpenSky Network', label: 'Flights',
async getSnapshot(query = {}, { signal } = {}) { async getSnapshot(query = {}, { signal } = {}) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (Number.isFinite(query.latitude) && Number.isFinite(query.longitude)) { if (Number.isFinite(query.latitude) && Number.isFinite(query.longitude)) {
@@ -64,14 +75,17 @@ export function createOpenSkySource({
} }
const { response, payload } = await readResponse( const { response, payload } = await readResponse(
fetchImpl, fetchImpl,
`/api/opensky${params.size ? '?' + params : ''}`, `/api/flights${params.size ? '?' + params : ''}`,
{ signal }, { signal },
'OpenSky', 'Flights',
); );
if (!response.ok) throw openSkyError(response); if (!response.ok) throw openSkyError(response);
return { return {
...openSkySnapshot(payload, { ...openSkySnapshot(payload, {
source: header(response, 'x-flight-source') || 'OpenSky Network', // A fallback provider names itself in X-Flight-Source.
source:
header(response, 'x-flight-source') ||
feedSource(response, 'Flights'),
coverage: coverage:
header(response, 'x-flight-coverage') || header(response, 'x-flight-coverage') ||
'worldwide upstream snapshot', 'worldwide upstream snapshot',
@@ -83,11 +97,11 @@ export function createOpenSkySource({
async getTrack(reference, { signal } = {}) { async getTrack(reference, { signal } = {}) {
const { response, payload } = await readResponse( const { response, payload } = await readResponse(
fetchImpl, fetchImpl,
'/api/opensky-track?icao24=' + encodeURIComponent(reference), '/api/flights/track?icao24=' + encodeURIComponent(reference),
{ signal }, { signal },
'OpenSky', 'Flights',
); );
if (!response.ok) throw httpError(response, 'OpenSky'); if (!response.ok) throw httpError(response, 'Flights');
return { return {
records: normalizeAircraftTrack(payload?.path), records: normalizeAircraftTrack(payload?.path),
complete: false, complete: false,
@@ -108,36 +122,37 @@ export function createOpenSkySource({
}; };
} }
export function createAdsbLolSource({ export function createMilitarySource({
fetchImpl = defaultFetch, fetchImpl = defaultFetch,
now = () => Date.now(), now = () => Date.now(),
} = {}) { } = {}) {
return { return {
label: 'adsb.lol', label: 'Military aircraft',
async getIdentities(_query = {}, { signal } = {}) { async getIdentities(_query = {}, { signal } = {}) {
const { response, payload } = await readResponse( const { response, payload } = await readResponse(
fetchImpl, fetchImpl,
'/api/adsblol/mil', '/api/military',
{ signal }, { signal },
'adsb.lol', 'Military aircraft',
); );
if (!response.ok) throw httpError(response, 'adsb.lol'); if (!response.ok) throw httpError(response, 'Military aircraft');
return readsbIdentities(payload); return readsbIdentities(payload);
}, },
async getSnapshot(_query = {}, { signal } = {}) { async getSnapshot(_query = {}, { signal } = {}) {
const { response, payload } = await readResponse( const { response, payload } = await readResponse(
fetchImpl, fetchImpl,
'/api/adsblol/mil', '/api/military',
{ signal }, { signal },
'adsb.lol', 'Military aircraft',
); );
if (!response.ok) throw httpError(response, 'adsb.lol'); if (!response.ok) throw httpError(response, 'Military aircraft');
const age = finite(header(response, 'x-ads-b-cache-age-ms')); const age = finite(header(response, 'x-feed-age-ms'));
return { return {
...readsbSnapshot(payload, { ...readsbSnapshot(payload, {
source: feedSource(response, 'Military aircraft'),
observedAtMs: now() - (age != null && age > 0 ? age : 0), observedAtMs: now() - (age != null && age > 0 ? age : 0),
now: now(), now: now(),
stale: header(response, 'x-ads-b-cache') === 'STALE', stale: header(response, 'x-feed-cache') === 'STALE',
}), }),
status: response.status, status: response.status,
}; };
@@ -145,11 +160,11 @@ export function createAdsbLolSource({
async getTrack(reference, { signal } = {}) { async getTrack(reference, { signal } = {}) {
const { response, payload } = await readResponse( const { response, payload } = await readResponse(
fetchImpl, fetchImpl,
'/api/adsblol/trace?hex=' + encodeURIComponent(reference), '/api/military/track?hex=' + encodeURIComponent(reference),
{ signal }, { signal },
'adsb.lol', 'Military aircraft',
); );
if (!response.ok) throw httpError(response, 'adsb.lol'); if (!response.ok) throw httpError(response, 'Military aircraft');
const baseTimeMs = epoch(payload?.timestamp, 1000); const baseTimeMs = epoch(payload?.timestamp, 1000);
return { return {
records: records:
@@ -165,13 +180,13 @@ export function createAdsbLolSource({
}; };
} }
export function createAisStreamSource({ export function createVesselSource({
fetchImpl = defaultFetch, fetchImpl = defaultFetch,
apiUrl = '/api/ais-live', apiUrl = '/api/vessels',
origin = () => globalThis.location?.origin || 'http://localhost', origin = () => globalThis.location?.origin || 'http://localhost',
} = {}) { } = {}) {
return { return {
label: 'AISStream', label: 'Vessels',
async getSnapshot({ maxRows = 12000, area } = {}, { signal } = {}) { async getSnapshot({ maxRows = 12000, area } = {}, { signal } = {}) {
const url = new URL(apiUrl, origin()); const url = new URL(apiUrl, origin());
url.searchParams.set('maxRows', String(maxRows)); url.searchParams.set('maxRows', String(maxRows));
@@ -190,10 +205,10 @@ export function createAisStreamSource({
fetchImpl, fetchImpl,
url.toString(), url.toString(),
{ signal, cache: 'no-store' }, { signal, cache: 'no-store' },
'AIS live', 'Vessels',
); );
if (!response.ok) { if (!response.ok) {
const error = httpError(response, 'AIS live'); const error = httpError(response, 'Vessels');
const reasons = { const reasons = {
'missing-key': 'AISSTREAM_API_KEY not set', 'missing-key': 'AISSTREAM_API_KEY not set',
'auth-failed': 'API key rejected — check AISSTREAM_API_KEY', 'auth-failed': 'API key rejected — check AISSTREAM_API_KEY',
@@ -204,16 +219,19 @@ export function createAisStreamSource({
error.message = reasons[payload?.status] || error.message; error.message = reasons[payload?.status] || error.message;
throw error; throw error;
} }
return { ...vesselSnapshot(payload), status: response.status }; return {
...vesselSnapshot(payload, { source: feedSource(response, 'Vessels') }),
status: response.status,
};
}, },
async getTrack(reference, { signal } = {}) { async getTrack(reference, { signal } = {}) {
const { response, payload } = await readResponse( const { response, payload } = await readResponse(
fetchImpl, fetchImpl,
'/api/ais-live/track?mmsi=' + encodeURIComponent(reference), '/api/vessels/track?mmsi=' + encodeURIComponent(reference),
{ signal }, { signal },
'AIS live', 'Vessels',
); );
if (!response.ok) throw httpError(response, 'AIS live'); if (!response.ok) throw httpError(response, 'Vessels');
return { return {
records: normalizeVesselTrack(payload?.samples), records: normalizeVesselTrack(payload?.samples),
complete: false, complete: false,
+4
View File
@@ -6,6 +6,7 @@ import { createStandaloneScene } from './scene.js';
import { createStandaloneControls } from './controls.js'; import { createStandaloneControls } from './controls.js';
import { createStandaloneData } from './data.js'; import { createStandaloneData } from './data.js';
import { createStandaloneTools } from './tools.js'; import { createStandaloneTools } from './tools.js';
import { createGoogleTokenSource } from '../maps/googleTokens.js';
// The existing controls and layer catalog contain page-scoped state. // The existing controls and layer catalog contain page-scoped state.
let constructed = false; let constructed = false;
@@ -39,6 +40,9 @@ export function createStandaloneApplication({
const scene = await createStandaloneScene({ const scene = await createStandaloneScene({
...context, ...context,
googleApiKey, googleApiKey,
// Without a key, Google 3D can still load with tokens from the
// app's server when it offers them.
googleTokens: googleApiKey ? null : createGoogleTokenSource(),
cesiumToken, cesiumToken,
loaderStatus, loaderStatus,
}); });
+7 -7
View File
@@ -1,8 +1,8 @@
import { createOpenFreeMapSource } from '../sources/openFreeMap.js'; import { createOpenFreeMapSource } from '../sources/openFreeMap.js';
import { import {
createOpenSkySource, createFlightSource,
createAdsbLolSource, createMilitarySource,
createAisStreamSource, createVesselSource,
} from '../sources/live/standalone.js'; } from '../sources/live/standalone.js';
import { createCctvSource } from '../layers/cctv/source.js'; import { createCctvSource } from '../layers/cctv/source.js';
import { createRadioSource } from '../layers/radio/source.js'; import { createRadioSource } from '../layers/radio/source.js';
@@ -25,10 +25,10 @@ export function createStandaloneLayerSources() {
const mapTiles = createOpenFreeMapSource(); const mapTiles = createOpenFreeMapSource();
return { return {
...createReferenceSources(), ...createReferenceSources(),
flights: createOpenSkySource(), flights: createFlightSource(),
military: createAdsbLolSource(), military: createMilitarySource(),
vessels: createAisStreamSource({ vessels: createVesselSource({
apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live', apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/vessels',
// Resolved against the document's address, which a panel host may // Resolved against the document's address, which a panel host may
// serve from its own scheme. // serve from its own scheme.
origin: () => globalThis.document?.baseURI ?? 'http://localhost', origin: () => globalThis.document?.baseURI ?? 'http://localhost',
+34 -34
View File
@@ -93,11 +93,11 @@ test('OpenSky state and track routes share tokens, retain cache and use regional
}); });
const states = install(providers.openSkyProxy()); const states = install(providers.openSkyProxy());
assert.equal( assert.equal(
(await states('/api/opensky', '?lat=30&lon=-97')).statusCode, (await states('/api/flights', '?lat=30&lon=-97')).statusCode,
200, 200,
); );
assert.equal( assert.equal(
(await states('/api/opensky', '?lat=30&lon=-97')).headers[ (await states('/api/flights', '?lat=30&lon=-97')).headers[
'x-opensky-cache' 'x-opensky-cache'
], ],
'HIT', 'HIT',
@@ -105,14 +105,14 @@ test('OpenSky state and track routes share tokens, retain cache and use regional
assert.equal(calls.length, 2); assert.equal(calls.length, 2);
const tracks = install(providers.trackBackfillProxies(), true); const tracks = install(providers.trackBackfillProxies(), true);
assert.equal( assert.equal(
(await tracks('/api/opensky-track', '?icao24=ABC123')).statusCode, (await tracks('/api/flights/track', '?icao24=ABC123')).statusCode,
200, 200,
); );
await tracks('/api/opensky-track', '?icao24=abc123'); await tracks('/api/flights/track', '?icao24=abc123');
assert.equal(calls.filter((call) => call.url.includes('/token')).length, 1); assert.equal(calls.filter((call) => call.url.includes('/token')).length, 1);
assert.equal(calls.filter((call) => call.url.includes('/tracks/')).length, 1); assert.equal(calls.filter((call) => call.url.includes('/tracks/')).length, 1);
assert.equal( assert.equal(
(await tracks('/api/adsblol/trace', '?hex=invalid')).statusCode, (await tracks('/api/military/track', '?hex=invalid')).statusCode,
400, 400,
); );
now += 130_000; now += 130_000;
@@ -131,7 +131,7 @@ test('OpenSky state and track routes share tokens, retain cache and use regional
); );
process.env.OPENSKY_AUTH_MODE = 'anon'; process.env.OPENSKY_AUTH_MODE = 'anon';
const fallback = await install(fresh.openSkyProxy())( const fallback = await install(fresh.openSkyProxy())(
'/api/opensky', '/api/flights',
'?lat=30&lon=-97', '?lat=30&lon=-97',
); );
assert.equal(fallback.statusCode, 200); assert.equal(fallback.statusCode, 200);
@@ -174,7 +174,7 @@ test('OpenSky global snapshot attempts are time-limited, retried once, then fall
`../../server/providers/aircraft/opensky.js?timeout=${Math.random()}` `../../server/providers/aircraft/opensky.js?timeout=${Math.random()}`
); );
const response = await install(fresh.openSkyProxy())( const response = await install(fresh.openSkyProxy())(
'/api/opensky', '/api/flights',
'?lat=30&lon=-97', '?lat=30&lon=-97',
); );
return { response, attempts }; return { response, attempts };
@@ -203,11 +203,11 @@ test('military aircraft route preserves fresh cache and stale response after ups
return Response.json({ ac: [{ hex: 'abc123' }] }); return Response.json({ ac: [{ hex: 'abc123' }] });
}); });
const request = install(providers.adsbLolProxy()); const request = install(providers.adsbLolProxy());
const first = await request('/api/adsblol/mil'); const first = await request('/api/military');
assert.equal((await request('/api/adsblol/mil')).body, first.body); assert.equal((await request('/api/military')).body, first.body);
assert.equal(calls, 1); assert.equal(calls, 1);
now += 13_000; now += 13_000;
assert.equal((await request('/api/adsblol/mil')).body, first.body); assert.equal((await request('/api/military')).body, first.body);
assert.equal(calls, 2); assert.equal(calls, 2);
}); });
@@ -261,7 +261,7 @@ test('AIS preview route ingests through the socket, returns tracks and disposes
const request = install(plugin, true); const request = install(plugin, true);
let res; let res;
for (let i = 0; i < 100; i++) { for (let i = 0; i < 100; i++) {
res = await request('/api/ais-live'); res = await request('/api/vessels');
if (JSON.parse(res.body).rows.length) break; if (JSON.parse(res.body).rows.length) break;
await delay(10); await delay(10);
} }
@@ -269,10 +269,10 @@ test('AIS preview route ingests through the socket, returns tracks and disposes
assert.equal(data.status, 'live'); assert.equal(data.status, 'live');
assert.equal(data.rows[0].mmsi, '123456789'); assert.equal(data.rows[0].mmsi, '123456789');
assert.equal(data.rows[0].heading, null); assert.equal(data.rows[0].heading, null);
const history = await request('/api/ais-live', '/track?mmsi=123456789'); const history = await request('/api/vessels', '/track?mmsi=123456789');
assert.equal(JSON.parse(history.body).samples.length, 2); assert.equal(JSON.parse(history.body).samples.length, 2);
assert.equal( assert.equal(
(await request('/api/ais-live', '/track?mmsi=bad')).statusCode, (await request('/api/vessels', '/track?mmsi=bad')).statusCode,
400, 400,
); );
assert.equal(sockets.length, 1); assert.equal(sockets.length, 1);
@@ -280,7 +280,7 @@ test('AIS preview route ingests through the socket, returns tracks and disposes
for (let i = 0; i < 100 && sockets[0].readyState !== 3; i++) await delay(10); for (let i = 0; i < 100 && sockets[0].readyState !== 3; i++) await delay(10);
assert.equal(sockets[0].readyState, 3); assert.equal(sockets[0].readyState, 3);
const restarted = install(plugin); const restarted = install(plugin);
await restarted('/api/ais-live'); await restarted('/api/vessels');
for (let i = 0; i < 100 && sockets.length < 2; i++) await delay(10); for (let i = 0; i < 100 && sockets.length < 2; i++) await delay(10);
assert.equal(sockets.length, 2); assert.equal(sockets.length, 2);
}); });
@@ -299,10 +299,10 @@ test('military aircraft route serves stale cache on an upstream 429 and cools do
}); });
}); });
const request = install(providers.adsbLolProxy()); const request = install(providers.adsbLolProxy());
const first = await request('/api/adsblol/mil'); const first = await request('/api/military');
assert.equal(first.statusCode, 200); assert.equal(first.statusCode, 200);
now += 13_000; now += 13_000;
const limited = await request('/api/adsblol/mil'); const limited = await request('/api/military');
assert.equal(calls, 2); assert.equal(calls, 2);
assert.equal( assert.equal(
limited.statusCode, limited.statusCode,
@@ -310,16 +310,16 @@ test('military aircraft route serves stale cache on an upstream 429 and cools do
'a 429 with a cached body is never relayed', 'a 429 with a cached body is never relayed',
); );
assert.equal(limited.body, first.body); assert.equal(limited.body, first.body);
assert.equal(limited.headers['x-ads-b-cache'], 'STALE'); assert.equal(limited.headers['x-feed-cache'], 'STALE');
assert.equal(limited.headers['x-ads-b-upstream-status'], '429'); assert.equal(limited.headers['x-ads-b-upstream-status'], '429');
assert.equal(limited.headers['x-ads-b-cache-age-ms'], '13000'); assert.equal(limited.headers['x-feed-age-ms'], '13000');
now += 5_000; now += 5_000;
const cooling = await request('/api/adsblol/mil'); const cooling = await request('/api/military');
assert.equal(calls, 2, 'no upstream call inside the Retry-After window'); assert.equal(calls, 2, 'no upstream call inside the Retry-After window');
assert.equal(cooling.headers['x-ads-b-cache'], 'STALE'); assert.equal(cooling.headers['x-feed-cache'], 'STALE');
assert.equal(cooling.headers['x-ads-b-cache-age-ms'], '18000'); assert.equal(cooling.headers['x-feed-age-ms'], '18000');
now += 16_000; now += 16_000;
await request('/api/adsblol/mil'); await request('/api/military');
assert.equal(calls, 3, 'upstream is retried once Retry-After elapses'); assert.equal(calls, 3, 'upstream is retried once Retry-After elapses');
}); });
@@ -331,10 +331,10 @@ test('military aircraft route relays an upstream 429 when nothing is cached', as
return new Response('{"error":"rate limited"}', { status: 429 }); return new Response('{"error":"rate limited"}', { status: 429 });
}); });
const request = install(providers.adsbLolProxy()); const request = install(providers.adsbLolProxy());
const limited = await request('/api/adsblol/mil'); const limited = await request('/api/military');
assert.equal(limited.statusCode, 429); assert.equal(limited.statusCode, 429);
assert.ok(limited.headers['retry-after']); assert.ok(limited.headers['retry-after']);
const again = await request('/api/adsblol/mil'); const again = await request('/api/military');
assert.equal(again.statusCode, 429); assert.equal(again.statusCode, 429);
assert.equal( assert.equal(
calls, calls,
@@ -369,21 +369,21 @@ test('military fallback cancels a stalled 5xx body and starts cooldown at receip
); );
}); });
const request = install(providers.adsbLolProxy()); const request = install(providers.adsbLolProxy());
await request('/api/adsblol/mil'); await request('/api/military');
now += 1000; now += 1000;
const hit = await request('/api/adsblol/mil'); const hit = await request('/api/military');
assert.equal(hit.headers['x-ads-b-cache-age-ms'], '1000'); assert.equal(hit.headers['x-feed-age-ms'], '1000');
now += 12000; now += 12000;
const fallback = await request('/api/adsblol/mil'); const fallback = await request('/api/military');
assert.equal(fallback.statusCode, 200); assert.equal(fallback.statusCode, 200);
assert.equal(cancelled, true); assert.equal(cancelled, true);
assert.equal(fallback.headers['x-ads-b-cache-age-ms'], '23000'); assert.equal(fallback.headers['x-feed-age-ms'], '23000');
assert.equal(fallback.headers['x-ads-b-retry-after-seconds'], '20'); assert.equal(fallback.headers['x-ads-b-retry-after-seconds'], '20');
now += 19000; now += 19000;
await request('/api/adsblol/mil'); await request('/api/military');
assert.equal(calls, 2); assert.equal(calls, 2);
now += 2000; now += 2000;
await request('/api/adsblol/mil'); await request('/api/military');
assert.equal(calls, 3); assert.equal(calls, 3);
}); });
@@ -403,7 +403,7 @@ test('military cooldown bounds untrusted Retry-After and defaults server errors'
headers: { 'Retry-After': raw }, headers: { 'Retry-After': raw },
}), }),
); );
const result = await install(providers.adsbLolProxy())('/api/adsblol/mil'); const result = await install(providers.adsbLolProxy())('/api/military');
assert.equal(Number(result.headers['retry-after']), seconds); assert.equal(Number(result.headers['retry-after']), seconds);
} }
}); });
@@ -423,7 +423,7 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches
}; };
}); });
const res1 = await tracks('/api/opensky-track', '?icao24=def456'); const res1 = await tracks('/api/flights/track', '?icao24=def456');
assert.equal(res1.statusCode, 502); assert.equal(res1.statusCode, 502);
assert.deepEqual(JSON.parse(res1.body), { assert.deepEqual(JSON.parse(res1.body), {
error: 'Upstream track response too large', error: 'Upstream track response too large',
@@ -431,7 +431,7 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches
// Cached as a 502 (never a 200): a retry inside the window neither // Cached as a 502 (never a 200): a retry inside the window neither
// reads as an empty track nor spends OpenSky credits on another download. // reads as an empty track nor spends OpenSky credits on another download.
const res2 = await tracks('/api/opensky-track', '?icao24=def456'); const res2 = await tracks('/api/flights/track', '?icao24=def456');
assert.equal(res2.statusCode, 502); assert.equal(res2.statusCode, 502);
assert.equal(callCount, 1); assert.equal(callCount, 1);
}); });
+2 -2
View File
@@ -97,8 +97,8 @@ test('real dev and built-preview servers serve provider JSON and terminate unkno
const origin = `http://127.0.0.1:${server.httpServer.address().port}`; const origin = `http://127.0.0.1:${server.httpServer.address().port}`;
try { try {
for (const [route, status] of [ for (const [route, status] of [
['/api/opensky', 200], ['/api/flights', 200],
['/api/adsblol/mil', 200], ['/api/military', 200],
['/api/adsbdb/type/invalid', 400], ['/api/adsbdb/type/invalid', 400],
['/api/firms/status', 200], ['/api/firms/status', 200],
['/api/fire-perimeters/inciweb/publication/invalid', 400], ['/api/fire-perimeters/inciweb/publication/invalid', 400],
+2 -2
View File
@@ -373,10 +373,10 @@ test('aircraft searches say when a feed that answered is stale', async () => {
}); });
test('the real OpenSky source marks an hour-old snapshot stale for searches', async () => { test('the real OpenSky source marks an hour-old snapshot stale for searches', async () => {
const { createOpenSkySource } = const { createFlightSource } =
await import('../../sources/live/standalone.js'); await import('../../sources/live/standalone.js');
const now = Date.UTC(2026, 0, 1, 12); const now = Date.UTC(2026, 0, 1, 12);
const opensky = createOpenSkySource({ const opensky = createFlightSource({
now: () => now, now: () => now,
fetchImpl: async () => fetchImpl: async () =>
Response.json({ time: now / 1000 - 3600, states: [] }), Response.json({ time: now / 1000 - 3600, states: [] }),
+3 -3
View File
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { composeCatalog, coreTools } from '../index.js'; import { composeCatalog, coreTools } from '../index.js';
import { LiveSourceError } from '../../sources/live/contract.js'; import { LiveSourceError } from '../../sources/live/contract.js';
import { createAisStreamSource } from '../../sources/live/standalone.js'; import { createVesselSource } from '../../sources/live/standalone.js';
import { AISSTREAM_CACHE_MAX } from '../../../server/providers/vessels/ais-store.js'; import { AISSTREAM_CACHE_MAX } from '../../../server/providers/vessels/ais-store.js';
const vessel = (id, latitude, longitude, extra = {}) => ({ const vessel = (id, latitude, longitude, extra = {}) => ({
@@ -184,7 +184,7 @@ test('a missing AIS key surfaces as an unavailable feed', async () => {
test('vessel searches ask for every vessel the server retains', async () => { test('vessel searches ask for every vessel the server retains', async () => {
const requested = []; const requested = [];
const source = createAisStreamSource({ const source = createVesselSource({
origin: () => 'http://localhost', origin: () => 'http://localhost',
fetchImpl: async (url) => { fetchImpl: async (url) => {
requested.push(new URL(url).searchParams.get('maxRows')); requested.push(new URL(url).searchParams.get('maxRows'));
@@ -204,7 +204,7 @@ test('vessel searches ask for every vessel the server retains', async () => {
test('an area search names its area to the server; a vessel lookup asks for every vessel', async () => { test('an area search names its area to the server; a vessel lookup asks for every vessel', async () => {
const requested = []; const requested = [];
const source = createAisStreamSource({ const source = createVesselSource({
origin: () => 'http://localhost', origin: () => 'http://localhost',
fetchImpl: async (url) => { fetchImpl: async (url) => {
requested.push(new URL(url).searchParams); requested.push(new URL(url).searchParams);
+6 -6
View File
@@ -31,9 +31,9 @@ import { createApplicationRequestServices } from '../services/requests.js';
import { createEventPackSource } from '../sources/eventPacks.js'; import { createEventPackSource } from '../sources/eventPacks.js';
import { createInfrastructureSource } from '../sources/infrastructureData.js'; import { createInfrastructureSource } from '../sources/infrastructureData.js';
import { import {
createAdsbLolSource, createMilitarySource,
createAisStreamSource, createVesselSource,
createOpenSkySource, createFlightSource,
} from '../sources/live/standalone.js'; } from '../sources/live/standalone.js';
import { import {
createGeocodePlaceService, createGeocodePlaceService,
@@ -51,9 +51,9 @@ export function createToolServices({ fetchImpl, appUrl, panelKey }) {
earthquakes: createUsgsEarthquakeSource({ fetchImpl }), earthquakes: createUsgsEarthquakeSource({ fetchImpl }),
fires: createFirmsSource({ fetchImpl }), fires: createFirmsSource({ fetchImpl }),
launches: createLaunchSource({ fetchImpl }), launches: createLaunchSource({ fetchImpl }),
aircraft: createOpenSkySource({ fetchImpl }), aircraft: createFlightSource({ fetchImpl }),
military: createAdsbLolSource({ fetchImpl }), military: createMilitarySource({ fetchImpl }),
vessels: createAisStreamSource({ vessels: createVesselSource({
fetchImpl, fetchImpl,
origin: () => new URL(appUrl).origin, origin: () => new URL(appUrl).origin,
}), }),