mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
Merge main into pr-694
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
commit
fb8a93752f
370 files changed
+69030
-1176
No files matched your search
+132
-4
@@ -17,6 +17,7 @@ import {
|
||||
CCTV_MAX_SOURCES_CEILING,
|
||||
} from './cctv/constants.js';
|
||||
import { sanitizeCctvRangeHeader } from './cctv/range.js';
|
||||
import { createHlsPuller } from './cctv/stream.js';
|
||||
import { googleServerApiKey } from './places/google-key.js';
|
||||
export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream };
|
||||
/**
|
||||
@@ -40,6 +41,8 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
* CCTV_MAX_SOURCES ceiling so health/status observability is never evicted
|
||||
* for any catalog the proxy can actually serve. */
|
||||
const HEALTH_MAX_ENTRIES = CCTV_MAX_SOURCES_CEILING;
|
||||
/** Live HLS strategies (see ./cctv/stream.js). Shared across dev and preview. */
|
||||
const puller = createHlsPuller();
|
||||
|
||||
/** Update the health entry for a camera, evicting the oldest entry if at capacity. */
|
||||
const setHealth = (cameraId, patch) => {
|
||||
@@ -127,6 +130,9 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
};
|
||||
|
||||
const installMiddleware = (server) => {
|
||||
server.httpServer?.on('close', () => {
|
||||
puller.shutdown();
|
||||
});
|
||||
server.middlewares.use('/api/cctv', async (req, res) => {
|
||||
try {
|
||||
const sources = await getCctvSources();
|
||||
@@ -194,12 +200,124 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/media/')) {
|
||||
const cameraId =
|
||||
decodeURIComponent(url.pathname.replace('/media/', '').trim()) ||
|
||||
'camera';
|
||||
const match = /^\/media\/([^/]+)(?:\/(seg_(\d+)\.ts))?$/.exec(
|
||||
url.pathname,
|
||||
);
|
||||
if (!match) {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const cameraId = decodeURIComponent(match[1]);
|
||||
const source = sourceById.get(cameraId);
|
||||
const mediaUrl = source?.url || '';
|
||||
const feedType = normalizeFeedType(source?.feedType || 'image');
|
||||
const leaseId = url.searchParams.get('lease');
|
||||
if (feedType === 'hls' && !/^[a-f0-9-]{36}$/i.test(leaseId || '')) {
|
||||
res.writeHead(400);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (req.method === 'DELETE') {
|
||||
if (leaseId) puller.release(cameraId, leaseId);
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (req.method !== 'GET') {
|
||||
res.writeHead(405);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (feedType === 'hls') {
|
||||
if (
|
||||
!/^https?:\/\//i.test(mediaUrl) ||
|
||||
!/\.m3u8(?:\?|$)/i.test(mediaUrl)
|
||||
) {
|
||||
res.writeHead(503, { 'Content-Type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error:
|
||||
'This stream requires an unsupported transport; use the frame fallback',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (match[2]) {
|
||||
const body = puller.getSegment(
|
||||
cameraId,
|
||||
url.searchParams.get('session'),
|
||||
Number(match[3]),
|
||||
leaseId,
|
||||
);
|
||||
res.writeHead(body ? 200 : 404, {
|
||||
'Content-Type': 'video/mp2t',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(body || undefined);
|
||||
return;
|
||||
}
|
||||
const downstream = watchDownstreamClose(res);
|
||||
let entry;
|
||||
const cancelPending = () => {
|
||||
if (entry) puller.release(cameraId, leaseId);
|
||||
};
|
||||
try {
|
||||
entry = await puller.ensure(cameraId, mediaUrl, leaseId);
|
||||
if (downstream.closed) {
|
||||
cancelPending();
|
||||
return;
|
||||
}
|
||||
downstream.signal.addEventListener('abort', cancelPending, {
|
||||
once: true,
|
||||
});
|
||||
if (!(await puller.waitReady(entry, downstream.signal)))
|
||||
throw new Error('Stream unavailable');
|
||||
const playlist = await puller.buildPlaylist(
|
||||
entry,
|
||||
cameraId,
|
||||
leaseId,
|
||||
);
|
||||
if (downstream.closed) return;
|
||||
if (!playlist) throw new Error('Stream unavailable');
|
||||
setHealth(cameraId, {
|
||||
status: 'ok',
|
||||
sourceKind: 'live',
|
||||
label: source?.provider || 'Configured source',
|
||||
message: 'Live HLS connected',
|
||||
});
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/vnd.apple.mpegurl',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-CCTV-Source': 'hls-pull',
|
||||
'X-CCTV-Session': entry.token,
|
||||
});
|
||||
res.end(playlist);
|
||||
} catch {
|
||||
setHealth(cameraId, {
|
||||
status: 'degraded',
|
||||
sourceKind: 'fallback',
|
||||
label: source?.provider || 'Configured source',
|
||||
message: 'Live HLS unavailable',
|
||||
});
|
||||
if (!downstream.closed) {
|
||||
res.writeHead(503, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
'Retry-After': '2',
|
||||
});
|
||||
res.end(JSON.stringify({ error: 'Live stream unavailable' }));
|
||||
}
|
||||
} finally {
|
||||
downstream.signal.removeEventListener('abort', cancelPending);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (match[2] || req.method !== 'GET') {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mediaUrl || !/^https?:\/\//i.test(mediaUrl)) {
|
||||
setHealth(cameraId, {
|
||||
@@ -308,11 +426,21 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
}
|
||||
const timedOut =
|
||||
error?.name === 'AbortError' || error?.name === 'TimeoutError';
|
||||
// GET /api/cctv/health serializes `message`, and the CCTV panel
|
||||
// renders it as a status label, so the raw error would leave the
|
||||
// server by a different door than the sanitized body below and
|
||||
// land on screen.
|
||||
//
|
||||
// The log line drops the text too, unlike the catch at the bottom
|
||||
// of this file: a media-fetch failure names the camera's upstream
|
||||
// host, and these lines get pasted into issues. The status codes
|
||||
// below carry the diagnosis — 504 for a timeout, 502 otherwise.
|
||||
console.warn('[CCTV Proxy] media fetch failed');
|
||||
setHealth(cameraId, {
|
||||
status: 'degraded',
|
||||
sourceKind: 'upstream',
|
||||
label: source?.provider || 'Configured source',
|
||||
message: error?.message || 'Media fetch failed',
|
||||
message: 'Media fetch failed',
|
||||
});
|
||||
res.writeHead(timedOut ? 504 : 502, {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
loadWarendorfSourcesFromCatalog,
|
||||
loadNswSourcesFromOpenData,
|
||||
loadCalgarySourcesFromOpenData,
|
||||
loadDelDOTSourcesFromOpenData,
|
||||
} from './sources.js';
|
||||
|
||||
/** Env kill switch: unset or anything but "0" means enabled. */
|
||||
@@ -86,6 +87,11 @@ const LIVE_PACKS = [
|
||||
enabled: () => envEnabled('CCTV_CALGARY_ENABLED'),
|
||||
load: loadCalgarySourcesFromOpenData,
|
||||
},
|
||||
{
|
||||
name: 'deldot',
|
||||
enabled: () => envEnabled('CCTV_DELDOT_ENABLED'),
|
||||
load: loadDelDOTSourcesFromOpenData,
|
||||
},
|
||||
];
|
||||
/**
|
||||
* Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default).
|
||||
|
||||
@@ -236,6 +236,14 @@ export const CALGARY_DOWNTOWN = { lat: 51.0461, lon: -114.0626 };
|
||||
* body cannot be buffered without limit. */
|
||||
export const CALGARY_MAX_CATALOG_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
/** DelDOT CCTV: one keyless statewide JSON catalog; live video via RTMP-over-HTTP (rtmpt:80). */
|
||||
export const DELDOT_CCTV_URL = 'https://tmc.deldot.gov/json/videocamera.json';
|
||||
export const DEFAULT_DELDOT_MAX_SOURCES = 300;
|
||||
export const DELDOT_ANCHORS = [
|
||||
{ lat: 39.7459, lon: -75.5466 }, // Wilmington (New Castle)
|
||||
{ lat: 39.1582, lon: -75.5244 }, // Dover (Kent)
|
||||
{ lat: 38.6906, lon: -75.3877 }, // Georgetown (Sussex)
|
||||
];
|
||||
/** Camera CATALOGS change rarely; 15 min keeps multi-megabyte upstream list refetches (Austin rows.json + 4 Caltrans districts + TfL + Ontario 511) infrequent. Frames are fetched per-request and are unaffected. */
|
||||
export const CCTV_SOURCE_CACHE_MS = 15 * 60 * 1000;
|
||||
/** Per-provider catalog-fetch timeout. Bounds the worst-case refresh so one
|
||||
|
||||
@@ -56,6 +56,9 @@ import {
|
||||
CALGARY_DOWNTOWN,
|
||||
CALGARY_MAX_CATALOG_BYTES,
|
||||
CCTV_SOURCE_FETCH_TIMEOUT_MS,
|
||||
DELDOT_CCTV_URL,
|
||||
DEFAULT_DELDOT_MAX_SOURCES,
|
||||
DELDOT_ANCHORS,
|
||||
} from './constants.js';
|
||||
import {
|
||||
toFiniteNumber,
|
||||
@@ -1594,3 +1597,107 @@ export async function loadCalgarySourcesFromOpenData() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadDelDOTSourcesFromOpenData() {
|
||||
try {
|
||||
const resp = await fetch(DELDOT_CCTV_URL, {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
|
||||
redirect: 'error',
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn('[CCTV] DelDOT source download failed:', resp.status);
|
||||
return [];
|
||||
}
|
||||
const payload = await readResponseJsonCapped(resp, 2 * 1024 * 1024);
|
||||
const rows = Array.isArray(payload?.videoCameras)
|
||||
? payload.videoCameras
|
||||
: [];
|
||||
if (!rows.length) return [];
|
||||
|
||||
const cameras = [];
|
||||
for (const row of rows) {
|
||||
if (String(row?.status).toLowerCase() !== 'active') continue;
|
||||
const lat = toFiniteNumber(row?.lat);
|
||||
const lon = toFiniteNumber(row?.lon);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
// Delaware bounding box — a bad upstream coord can't place a camera out of state.
|
||||
if (lat < 38.4 || lat > 39.9 || lon < -75.85 || lon > -75.0) continue;
|
||||
|
||||
// Prefer the catalog's HTTPS HLS transport; no subprocess is required.
|
||||
let stream;
|
||||
try {
|
||||
stream = new URL(String(row?.urls?.m3u8s || ''));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
stream.origin !== 'https://video.deldot.gov' ||
|
||||
stream.username ||
|
||||
stream.password ||
|
||||
!/^\/live\/[A-Za-z0-9_.-]+\/playlist\.m3u8$/.test(stream.pathname)
|
||||
)
|
||||
continue;
|
||||
const streamUrl = stream.href;
|
||||
|
||||
const id = String(row?.id || '').trim();
|
||||
if (!/^[A-Za-z0-9_-]{1,80}$/.test(id)) continue;
|
||||
const cameraId = `deldot-${id.toLowerCase()}`;
|
||||
const title = String(row?.title || '').trim();
|
||||
|
||||
// Title carries an unambiguous travel token for ~1 in 5 cameras
|
||||
// ("US 13 SB @ ..."). directionToHeading (allowBare=false) matches only
|
||||
// NB/SB/EB/WB, never a bare "W"/"N" street prefix — "W NORTH ST" yields
|
||||
// no heading and falls back.
|
||||
const heading = directionToHeading(title);
|
||||
const hasHeading = Number.isFinite(heading);
|
||||
|
||||
cameras.push({
|
||||
id: cameraId,
|
||||
name: title || `DelDOT ${id}`,
|
||||
city: String(row?.county ? `${row.county} County` : 'Delaware'),
|
||||
cityId: `deldot-${String(row?.county || 'de')
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-')}`,
|
||||
provider: 'DelDOT',
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: hasHeading ? heading : fallbackHeadingFromId(cameraId),
|
||||
headingConfidence: hasHeading ? 'high' : 'low',
|
||||
// Fabricated RAW PRIOR poses (same personalities as Austin/Caltrans);
|
||||
// the client ground-snap + manual calibration own the truth.
|
||||
pitchDeg: hasHeading ? -24 : -18,
|
||||
fovDeg: hasHeading ? 56 : 44,
|
||||
rangeM: hasHeading ? 210 : 145,
|
||||
mountHeightM: hasHeading ? 10 : 8,
|
||||
groundElevationM: 10, // Estimated prior; client ground resolution owns placement.
|
||||
feedType: 'hls',
|
||||
url: streamUrl,
|
||||
snapshotUrl: '',
|
||||
sourceKind: 'deldot-open-data',
|
||||
license: 'Public DelDOT traffic camera',
|
||||
});
|
||||
}
|
||||
|
||||
const unique = Array.from(
|
||||
new Map(cameras.map((camera) => [camera.id, camera])).values(),
|
||||
);
|
||||
const maxRaw = Number(
|
||||
process.env.CCTV_DELDOT_MAX_SOURCES || DEFAULT_DELDOT_MAX_SOURCES,
|
||||
);
|
||||
const maxCount = Number.isFinite(maxRaw)
|
||||
? Math.max(8, Math.min(400, Math.floor(maxRaw)))
|
||||
: DEFAULT_DELDOT_MAX_SOURCES;
|
||||
const prioritized = prioritizeSources(unique, maxCount, DELDOT_ANCHORS);
|
||||
console.log(
|
||||
`[CCTV] Loaded DelDOT camera sources: ${unique.length} Active (using nearest ${prioritized.length})`,
|
||||
);
|
||||
return prioritized;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[CCTV] DelDOT source download error:',
|
||||
error?.message || error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
/** Bounded, demand-owned HLS sessions. No subprocess, disk cache or idle sweep. */
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export const HLS_LIMITS = Object.freeze({
|
||||
sessions: 2,
|
||||
leasesPerSession: 8,
|
||||
playlistBytes: 256 * 1024,
|
||||
segmentBytes: 4 * 1024 * 1024,
|
||||
sessionBytes: 24 * 1024 * 1024,
|
||||
segments: 12,
|
||||
pollMs: 2000,
|
||||
idleMs: 15000,
|
||||
timeoutMs: 10000,
|
||||
readyMs: 12000,
|
||||
});
|
||||
|
||||
/** Refuse redirects; count actual streamed bytes, including chunked responses. */
|
||||
export async function fetchHlsBytes(
|
||||
url,
|
||||
{
|
||||
fetchImpl = fetch,
|
||||
signal,
|
||||
maxBytes,
|
||||
timeoutMs = HLS_LIMITS.timeoutMs,
|
||||
} = {},
|
||||
) {
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort();
|
||||
if (signal?.aborted) controller.abort();
|
||||
else signal?.addEventListener('abort', abort, { once: true });
|
||||
const timer = setTimeout(abort, timeoutMs);
|
||||
let reader;
|
||||
let response;
|
||||
try {
|
||||
controller.signal.throwIfAborted();
|
||||
response = await fetchImpl(url, {
|
||||
redirect: 'error',
|
||||
signal: controller.signal,
|
||||
headers: { 'User-Agent': 'gods-eye-view-cctv-proxy/1.0' },
|
||||
});
|
||||
if (
|
||||
!response.ok ||
|
||||
response.redirected ||
|
||||
(response.url && new URL(response.url).href !== new URL(url).href)
|
||||
)
|
||||
throw new Error('HLS upstream refused');
|
||||
if (Number(response.headers.get('content-length')) > maxBytes)
|
||||
throw new Error('HLS response too large');
|
||||
reader = response.body?.getReader?.();
|
||||
if (!reader) throw new Error('HLS response is not streamable');
|
||||
const chunks = [];
|
||||
let length = 0;
|
||||
for (;;) {
|
||||
controller.signal.throwIfAborted();
|
||||
const { done, value } = await reader.read();
|
||||
controller.signal.throwIfAborted();
|
||||
if (done) break;
|
||||
length += value.byteLength;
|
||||
if (length > maxBytes) throw new Error('HLS response too large');
|
||||
chunks.push(Buffer.from(value));
|
||||
}
|
||||
return Buffer.concat(chunks, length);
|
||||
} finally {
|
||||
controller.abort();
|
||||
if (reader) await reader.cancel().catch(() => {});
|
||||
else await response?.body?.cancel().catch(() => {});
|
||||
reader?.releaseLock();
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', abort);
|
||||
}
|
||||
}
|
||||
|
||||
export function sameOriginHlsUrl(value, base) {
|
||||
const url = new URL(value, base);
|
||||
if (
|
||||
!['https:', 'http:'].includes(url.protocol) ||
|
||||
url.origin !== new URL(base).origin ||
|
||||
url.username ||
|
||||
url.password
|
||||
)
|
||||
throw new Error('HLS reference escapes registered origin');
|
||||
return url.href;
|
||||
}
|
||||
|
||||
/** Only clear MPEG-TS media playlists: encrypted, fMP4 and byte ranges fail closed. */
|
||||
export function parseHlsMedia(text, base, limit = HLS_LIMITS.segments) {
|
||||
if (
|
||||
!text.startsWith('#EXTM3U') ||
|
||||
/#EXT-X-(?:KEY|MAP|BYTERANGE|I-FRAMES-ONLY)/.test(text)
|
||||
)
|
||||
throw new Error('Unsupported HLS playlist');
|
||||
let seq = 0;
|
||||
let duration = null;
|
||||
let discontinuity = false;
|
||||
const segments = [];
|
||||
for (const raw of text.split('\n')) {
|
||||
const line = raw.trim();
|
||||
if (line.startsWith('#EXT-X-MEDIA-SEQUENCE:')) {
|
||||
seq = Number(line.slice(22));
|
||||
if (!Number.isSafeInteger(seq) || seq < 0)
|
||||
throw new Error('Invalid media sequence');
|
||||
} else if (line === '#EXT-X-DISCONTINUITY') {
|
||||
discontinuity = true;
|
||||
} else if (line.startsWith('#EXTINF:')) {
|
||||
duration = Number.parseFloat(line.slice(8));
|
||||
if (!Number.isFinite(duration) || duration <= 0 || duration > 60)
|
||||
throw new Error('Invalid segment duration');
|
||||
} else if (line && !line.startsWith('#')) {
|
||||
if (duration === null || !Number.isSafeInteger(seq))
|
||||
throw new Error('Invalid HLS segment');
|
||||
const uri = sameOriginHlsUrl(line, base);
|
||||
if (!new URL(uri).pathname.endsWith('.ts'))
|
||||
throw new Error('Only MPEG-TS segments are supported');
|
||||
segments.push({ seq: seq++, duration, uri, discontinuity });
|
||||
discontinuity = false;
|
||||
duration = null;
|
||||
}
|
||||
}
|
||||
return segments.slice(-limit);
|
||||
}
|
||||
|
||||
export function createHlsPuller({
|
||||
fetchImpl = fetch,
|
||||
limits = HLS_LIMITS,
|
||||
} = {}) {
|
||||
const active = new Map();
|
||||
let closed = false;
|
||||
const stop = (cameraId, token) => {
|
||||
const entry = active.get(cameraId);
|
||||
if (!entry || (token && token !== entry.token)) return;
|
||||
active.delete(cameraId);
|
||||
entry.stopping = true;
|
||||
entry.controller.abort();
|
||||
clearTimeout(entry.timer);
|
||||
for (const lease of entry.leases.values()) clearTimeout(lease.timer);
|
||||
entry.leases.clear();
|
||||
entry.segments.clear();
|
||||
entry.upstream.clear();
|
||||
entry.bytes = 0;
|
||||
};
|
||||
const release = (cameraId, leaseId) => {
|
||||
const entry = active.get(cameraId);
|
||||
const lease = entry?.leases.get(leaseId);
|
||||
if (!lease) return;
|
||||
clearTimeout(lease.timer);
|
||||
entry.leases.delete(leaseId);
|
||||
if (!entry.leases.size) stop(cameraId, entry.token);
|
||||
};
|
||||
const touch = (entry, leaseId, create = false) => {
|
||||
let lease = entry.leases.get(leaseId);
|
||||
if (!lease) {
|
||||
if (!create || entry.leases.size >= limits.leasesPerSession)
|
||||
throw new Error('HLS lease capacity reached');
|
||||
lease = {};
|
||||
entry.leases.set(leaseId, lease);
|
||||
}
|
||||
clearTimeout(lease.timer);
|
||||
lease.timer = setTimeout(
|
||||
() => release(entry.cameraId, leaseId),
|
||||
limits.idleMs,
|
||||
);
|
||||
lease.timer.unref?.();
|
||||
};
|
||||
const read = (entry, url, maxBytes) =>
|
||||
fetchHlsBytes(url, {
|
||||
fetchImpl,
|
||||
signal: entry.controller.signal,
|
||||
maxBytes,
|
||||
timeoutMs: limits.timeoutMs,
|
||||
});
|
||||
const poll = async (entry) => {
|
||||
try {
|
||||
let base = entry.chunklistUrl || entry.url;
|
||||
let text = (await read(entry, base, limits.playlistBytes)).toString(
|
||||
'utf8',
|
||||
);
|
||||
if (!text.includes('#EXTINF:')) {
|
||||
const variant = text
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line && !line.startsWith('#'));
|
||||
if (!variant || !text.includes('#EXT-X-STREAM-INF:'))
|
||||
throw new Error('Missing HLS variant');
|
||||
base = sameOriginHlsUrl(variant, entry.url);
|
||||
text = (await read(entry, base, limits.playlistBytes)).toString('utf8');
|
||||
}
|
||||
entry.chunklistUrl = base;
|
||||
const segments = parseHlsMedia(text, base, limits.segments);
|
||||
const newest = segments.at(-1)?.seq ?? -1;
|
||||
const restarted =
|
||||
newest < entry.upstreamNewest ||
|
||||
segments.some((segment) => {
|
||||
const prior = entry.upstream.get(segment.seq);
|
||||
return prior && prior !== segment.uri;
|
||||
});
|
||||
if (restarted) {
|
||||
entry.upstream.clear();
|
||||
entry.pendingDiscontinuity = true;
|
||||
}
|
||||
entry.upstreamNewest = newest;
|
||||
for (const segment of segments) {
|
||||
if (entry.stopping) return;
|
||||
if (entry.upstream.has(segment.seq)) continue;
|
||||
const body = await read(entry, segment.uri, limits.segmentBytes);
|
||||
if (entry.stopping) return;
|
||||
// Evict BEFORE retaining the incoming segment. One bounded download may
|
||||
// exist in addition to the cache; no unbounded disk output is possible.
|
||||
while (
|
||||
entry.segments.size &&
|
||||
(entry.bytes + body.length > limits.sessionBytes ||
|
||||
entry.segments.size >= limits.segments)
|
||||
) {
|
||||
const oldest = entry.segments.keys().next().value;
|
||||
const removed = entry.segments.get(oldest);
|
||||
entry.bytes -= removed.body.length;
|
||||
if (removed.discontinuity) entry.discontinuitiesRemoved++;
|
||||
entry.segments.delete(oldest);
|
||||
}
|
||||
if (body.length > limits.sessionBytes)
|
||||
throw new Error('Segment exceeds session budget');
|
||||
const seq = entry.nextSeq++;
|
||||
entry.segments.set(seq, {
|
||||
...segment,
|
||||
seq,
|
||||
body,
|
||||
discontinuity: segment.discontinuity || entry.pendingDiscontinuity,
|
||||
});
|
||||
entry.pendingDiscontinuity = false;
|
||||
entry.upstream.set(segment.seq, segment.uri);
|
||||
while (entry.upstream.size > limits.segments * 2)
|
||||
entry.upstream.delete(entry.upstream.keys().next().value);
|
||||
entry.bytes += body.length;
|
||||
}
|
||||
entry.failures = 0;
|
||||
} catch {
|
||||
entry.chunklistUrl = null;
|
||||
entry.failures++;
|
||||
if (entry.failures >= 3) {
|
||||
stop(entry.cameraId, entry.token);
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
if (!entry.stopping) {
|
||||
entry.timer = setTimeout(() => {
|
||||
entry.polling = poll(entry);
|
||||
}, limits.pollMs);
|
||||
entry.timer.unref?.();
|
||||
}
|
||||
}
|
||||
};
|
||||
const ensure = async (cameraId, url, leaseId = 'legacy') => {
|
||||
if (closed) throw new Error('HLS service closed');
|
||||
sameOriginHlsUrl(url, url);
|
||||
let entry = active.get(cameraId);
|
||||
if (entry && entry.url !== url) {
|
||||
stop(cameraId);
|
||||
entry = null;
|
||||
}
|
||||
if (entry) {
|
||||
touch(entry, leaseId, true);
|
||||
return entry;
|
||||
}
|
||||
// Reserve synchronously, before any await: concurrent creation is singleflight.
|
||||
if (active.size >= limits.sessions)
|
||||
throw new Error('HLS session capacity reached');
|
||||
entry = {
|
||||
cameraId,
|
||||
url,
|
||||
token: randomUUID(),
|
||||
controller: new AbortController(),
|
||||
segments: new Map(),
|
||||
bytes: 0,
|
||||
stopping: false,
|
||||
failures: 0,
|
||||
nextSeq: 0,
|
||||
upstream: new Map(),
|
||||
leases: new Map(),
|
||||
upstreamNewest: -1,
|
||||
pendingDiscontinuity: false,
|
||||
discontinuitiesRemoved: 0,
|
||||
};
|
||||
active.set(cameraId, entry);
|
||||
touch(entry, leaseId, true);
|
||||
entry.polling = poll(entry);
|
||||
return entry;
|
||||
};
|
||||
const waitReady = async (entry, signal) => {
|
||||
const until = Date.now() + limits.readyMs;
|
||||
while (!entry.stopping && !signal?.aborted && Date.now() < until) {
|
||||
if (entry.segments.size >= 2) return true;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const buildPlaylist = async (entry, cameraId, leaseId = 'legacy') => {
|
||||
if (entry.stopping || entry.segments.size < 2) return null;
|
||||
touch(entry, leaseId);
|
||||
const segments = [...entry.segments.values()].sort((a, b) => a.seq - b.seq);
|
||||
const lines = [
|
||||
'#EXTM3U',
|
||||
'#EXT-X-VERSION:3',
|
||||
`#EXT-X-TARGETDURATION:${Math.ceil(Math.max(...segments.map((s) => s.duration)))}`,
|
||||
`#EXT-X-MEDIA-SEQUENCE:${segments[0].seq}`,
|
||||
`#EXT-X-DISCONTINUITY-SEQUENCE:${entry.discontinuitiesRemoved}`,
|
||||
];
|
||||
let previous;
|
||||
for (const segment of segments) {
|
||||
if (
|
||||
segment.discontinuity ||
|
||||
(previous !== undefined && segment.seq !== previous + 1)
|
||||
)
|
||||
lines.push('#EXT-X-DISCONTINUITY');
|
||||
lines.push(
|
||||
`#EXTINF:${segment.duration.toFixed(3)},`,
|
||||
`/api/cctv/media/${encodeURIComponent(cameraId)}/seg_${segment.seq}.ts?session=${entry.token}&lease=${encodeURIComponent(leaseId)}`,
|
||||
);
|
||||
previous = segment.seq;
|
||||
}
|
||||
return lines.join('\n') + '\n';
|
||||
};
|
||||
const getSegment = (cameraId, token, seq, leaseId = 'legacy') => {
|
||||
const entry = active.get(cameraId);
|
||||
if (!entry || entry.token !== token || !entry.leases.has(leaseId))
|
||||
return null;
|
||||
const body = entry.segments.get(seq)?.body;
|
||||
if (body) touch(entry, leaseId);
|
||||
return body || null;
|
||||
};
|
||||
const shutdown = async () => {
|
||||
closed = true;
|
||||
const entries = [...active.values()];
|
||||
for (const entry of entries) stop(entry.cameraId);
|
||||
await Promise.allSettled(entries.map((entry) => entry.polling));
|
||||
};
|
||||
return {
|
||||
ensure,
|
||||
waitReady,
|
||||
buildPlaylist,
|
||||
getSegment,
|
||||
stop,
|
||||
release,
|
||||
shutdown,
|
||||
stats: () => ({
|
||||
sessions: active.size,
|
||||
bytes: [...active.values()].reduce((total, e) => total + e.bytes, 0),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -17,17 +17,29 @@ async function readRequestBodyCapped(req, maxBytes) {
|
||||
function readRequestBody(req, maxBytes = 1024 * 1024) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let total = 0;
|
||||
let bodyTooLarge = false;
|
||||
const chunks = [];
|
||||
req.on('data', (chunk) => {
|
||||
// Keep draining after the cap without retaining more bytes. Destroying
|
||||
// the request also destroys its socket, so the handler cannot deliver
|
||||
// the fixed error response it constructs for BODY_TOO_LARGE.
|
||||
if (bodyTooLarge) return;
|
||||
total += chunk.length;
|
||||
if (total > maxBytes) {
|
||||
reject(new Error(`Request body exceeds ${maxBytes} bytes`));
|
||||
req.destroy();
|
||||
// Same marker readRequestBodyCapped sets, so a caller can tell an
|
||||
// oversized body from a genuine failure without matching on text.
|
||||
bodyTooLarge = true;
|
||||
chunks.length = 0;
|
||||
const error = new Error(`Request body exceeds ${maxBytes} bytes`);
|
||||
error.code = 'BODY_TOO_LARGE';
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||
req.on('end', () => {
|
||||
if (!bodyTooLarge) resolve(Buffer.concat(chunks).toString('utf8'));
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
import { readResponseTextCapped } from './common/http.js';
|
||||
|
||||
const STATUS_URL = 'https://www.nhc.noaa.gov/CurrentStorms.json';
|
||||
const GIS =
|
||||
'https://mapservices.weather.noaa.gov/tropical/rest/services/tropical/NHC_tropical_weather_summary/MapServer';
|
||||
const HOUR = 3600_000;
|
||||
const COVERAGE =
|
||||
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.';
|
||||
const LAYERS = [
|
||||
{ id: 5, cap: 512 * 1024, count: 500, kind: 'points' },
|
||||
{ id: 6, cap: 512 * 1024, count: 32, kind: 'track' },
|
||||
{ id: 7, cap: 2 * 1024 * 1024, count: 32, kind: 'cone' },
|
||||
];
|
||||
|
||||
function invalid() {
|
||||
return new Error('invalid_cyclone_data');
|
||||
}
|
||||
function text(value, max = 80) {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!value.trim() ||
|
||||
value.length > max ||
|
||||
/[\u0000-\u001f<>]/.test(value)
|
||||
)
|
||||
throw invalid();
|
||||
return value.trim();
|
||||
}
|
||||
function advisory(value) {
|
||||
if (typeof value !== 'string' || !/^\d{1,3}[A-Z]?$/i.test(value))
|
||||
throw invalid();
|
||||
return value.replace(/^0+(?=\d)/, '').toUpperCase();
|
||||
}
|
||||
function number(value, min, max) {
|
||||
if (
|
||||
value === null ||
|
||||
value === undefined ||
|
||||
value === '' ||
|
||||
!['string', 'number'].includes(typeof value)
|
||||
)
|
||||
return null;
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) && n >= min && n <= max ? n : null;
|
||||
}
|
||||
function iso(value, now) {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{3})?Z$/.test(value)
|
||||
)
|
||||
throw invalid();
|
||||
const n = Date.parse(value);
|
||||
if (
|
||||
!Number.isFinite(n) ||
|
||||
n > now + 5 * 60_000 ||
|
||||
now - n > 12 * HOUR ||
|
||||
new Date(n).toISOString().replace('.000Z', 'Z') !==
|
||||
value.replace('.000Z', 'Z')
|
||||
)
|
||||
throw invalid();
|
||||
return new Date(n).toISOString();
|
||||
}
|
||||
function position(coordinates) {
|
||||
if (
|
||||
!Array.isArray(coordinates) ||
|
||||
coordinates.length !== 2 ||
|
||||
!coordinates.every((n) => typeof n === 'number' && Number.isFinite(n)) ||
|
||||
Math.abs(coordinates[0]) > 180 ||
|
||||
Math.abs(coordinates[1]) > 90
|
||||
)
|
||||
throw invalid();
|
||||
return { longitude: coordinates[0], latitude: coordinates[1] };
|
||||
}
|
||||
function officialLink(value) {
|
||||
if (typeof value !== 'string' || value.length > 256) return null;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.origin === 'https://www.nhc.noaa.gov' &&
|
||||
!url.username &&
|
||||
!url.password &&
|
||||
!url.search &&
|
||||
!url.hash &&
|
||||
/^\/text\/[A-Z0-9]+\.shtml$/.test(url.pathname)
|
||||
? url.href
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseCycloneStatus(payload, now = Date.now()) {
|
||||
if (!Array.isArray(payload?.activeStorms) || payload.activeStorms.length > 32)
|
||||
throw invalid();
|
||||
const seen = new Set();
|
||||
return payload.activeStorms.map((raw) => {
|
||||
if (
|
||||
typeof raw?.id !== 'string' ||
|
||||
!/^(?:al|ep|cp)\d{6}$/.test(raw.id) ||
|
||||
seen.has(raw.id)
|
||||
)
|
||||
throw invalid();
|
||||
seen.add(raw.id);
|
||||
const forecast = raw.forecastAdvisory;
|
||||
const issuedAt = iso(forecast?.issuance, now);
|
||||
const advisoryNumber = advisory(forecast?.advNum);
|
||||
const positionAt = iso(raw.lastUpdate, now);
|
||||
return {
|
||||
id: raw.id,
|
||||
name: text(raw.name),
|
||||
classification: text(raw.classification, 16),
|
||||
basin: raw.id.slice(0, 2).toUpperCase(),
|
||||
position: position([raw.longitudeNumeric, raw.latitudeNumeric]),
|
||||
positionAt,
|
||||
advisoryNumber,
|
||||
issuedAt,
|
||||
windKt: number(raw.intensity, 0, 300),
|
||||
pressureHpa: number(raw.pressure, 800, 1100),
|
||||
movement: {
|
||||
directionDegrees: number(raw.movementDir, 0, 360),
|
||||
speedKt: number(raw.movementSpeed, 0, 200),
|
||||
},
|
||||
advisoryUrl: officialLink(forecast.url),
|
||||
outlookUrl: raw.id.startsWith('al')
|
||||
? 'https://www.nhc.noaa.gov/gtwo.php?basin=atlc&fdays=7'
|
||||
: raw.id.startsWith('cp')
|
||||
? 'https://www.nhc.noaa.gov/gtwo.php?basin=cpac&fdays=7'
|
||||
: 'https://www.nhc.noaa.gov/gtwo.php?basin=epac&fdays=7',
|
||||
geometryStatus: 'pending',
|
||||
geometryAdvisoryNumber: null,
|
||||
forecastPoints: [],
|
||||
track: null,
|
||||
cone: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function geometry(raw, kind, budget) {
|
||||
const types =
|
||||
kind === 'points'
|
||||
? ['Point']
|
||||
: kind === 'track'
|
||||
? ['LineString', 'MultiLineString']
|
||||
: ['Polygon', 'MultiPolygon'];
|
||||
if (!types.includes(raw?.type)) throw invalid();
|
||||
const point = (value) => {
|
||||
position(value);
|
||||
if (++budget.count > 25_000) throw invalid();
|
||||
return [...value];
|
||||
};
|
||||
const line = (value, ring = false) => {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length < (ring ? 4 : 2) ||
|
||||
value.length > 10_000
|
||||
)
|
||||
throw invalid();
|
||||
const result = value.map(point);
|
||||
if (
|
||||
ring &&
|
||||
(result[0][0] !== result.at(-1)[0] || result[0][1] !== result.at(-1)[1])
|
||||
)
|
||||
throw invalid();
|
||||
return result;
|
||||
};
|
||||
const list = (value, read) => {
|
||||
if (!Array.isArray(value) || !value.length || value.length > 128)
|
||||
throw invalid();
|
||||
return value.map(read);
|
||||
};
|
||||
const polygon = (value) => list(value, (ring) => line(ring, true));
|
||||
const coordinates =
|
||||
raw.type === 'Point'
|
||||
? point(raw.coordinates)
|
||||
: raw.type === 'LineString'
|
||||
? line(raw.coordinates)
|
||||
: raw.type === 'MultiLineString'
|
||||
? list(raw.coordinates, (part) => line(part))
|
||||
: raw.type === 'Polygon'
|
||||
? polygon(raw.coordinates)
|
||||
: list(raw.coordinates, polygon);
|
||||
return { type: raw.type, coordinates };
|
||||
}
|
||||
|
||||
/** Never relabel earlier GIS geometry with the newer status advisory. */
|
||||
export function attachCycloneGeometry(storms, collections) {
|
||||
const budget = { count: 0 };
|
||||
const parsed = LAYERS.map((spec, index) => {
|
||||
const payload = collections[index];
|
||||
if (
|
||||
payload?.type !== 'FeatureCollection' ||
|
||||
payload.exceededTransferLimit ||
|
||||
!Array.isArray(payload.features) ||
|
||||
payload.features.length > spec.count
|
||||
)
|
||||
throw invalid();
|
||||
return payload.features.map((feature) => {
|
||||
if (feature?.type !== 'Feature') throw invalid();
|
||||
const p = feature.properties;
|
||||
const source =
|
||||
typeof p?.idp_source === 'string' &&
|
||||
p.idp_source.match(
|
||||
/^((?:al|ep|cp)\d{6})-(\d{1,3}[a-z]?)_5day_(pts|lin|pgn)$/i,
|
||||
);
|
||||
if (!source || source[3].toLowerCase() !== ['pts', 'lin', 'pgn'][index])
|
||||
throw invalid();
|
||||
const adv = advisory(p.advisnum);
|
||||
if (adv !== advisory(source[2])) throw invalid();
|
||||
return {
|
||||
id: source[1].toLowerCase(),
|
||||
advisoryNumber: adv,
|
||||
geometry: geometry(feature.geometry, spec.kind, budget),
|
||||
tauHours: number(p.tau, 0, 168),
|
||||
windKt: number(p.maxwind, 0, 300),
|
||||
gustKt: number(p.gust, 0, 350),
|
||||
};
|
||||
});
|
||||
});
|
||||
return storms.map((storm) => {
|
||||
const groups = parsed.map((items) =>
|
||||
items.filter(
|
||||
(item) =>
|
||||
item.id === storm.id && item.advisoryNumber === storm.advisoryNumber,
|
||||
),
|
||||
);
|
||||
if (groups[1].length > 1 || groups[2].length > 1) throw invalid();
|
||||
if (!groups[0].length || groups[1].length !== 1 || groups[2].length !== 1)
|
||||
return storm;
|
||||
const taus = new Set();
|
||||
const forecastPoints = groups[0]
|
||||
.map((item) => {
|
||||
if (item.tauHours === null || taus.has(item.tauHours)) throw invalid();
|
||||
taus.add(item.tauHours);
|
||||
return {
|
||||
position: position(item.geometry.coordinates),
|
||||
tauHours: item.tauHours,
|
||||
windKt: item.windKt,
|
||||
gustKt: item.gustKt,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.tauHours - b.tauHours);
|
||||
return {
|
||||
...storm,
|
||||
geometryStatus: 'current',
|
||||
geometryAdvisoryNumber: storm.advisoryNumber,
|
||||
forecastPoints,
|
||||
track: groups[1][0].geometry,
|
||||
cone: groups[2][0].geometry,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Fixed official endpoints; one shared bounded refresh, no user destinations. */
|
||||
export function cycloneProxy({
|
||||
fetchImpl = fetch,
|
||||
now = () => Date.now(),
|
||||
timeoutMs = 12_000,
|
||||
} = {}) {
|
||||
let cache = null;
|
||||
let operation = null;
|
||||
let attemptedAt = -Infinity;
|
||||
async function upstream(url, cap, signal) {
|
||||
signal.throwIfAborted();
|
||||
const response = await fetchImpl(url, {
|
||||
signal,
|
||||
redirect: 'error',
|
||||
headers: {
|
||||
Accept: 'application/geo+json,application/json',
|
||||
'User-Agent': 'Gods Eye View (public NOAA weather context)',
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new Error('cyclone_upstream_unavailable');
|
||||
}
|
||||
const result = JSON.parse(
|
||||
await readResponseTextCapped(response, cap, signal),
|
||||
);
|
||||
signal.throwIfAborted();
|
||||
return result;
|
||||
}
|
||||
async function refresh(signal) {
|
||||
let storms = parseCycloneStatus(
|
||||
await upstream(STATUS_URL, 128 * 1024, signal),
|
||||
now(),
|
||||
);
|
||||
if (storms.length) {
|
||||
try {
|
||||
const results = await Promise.allSettled(
|
||||
LAYERS.map(async (spec) => {
|
||||
const url = new URL(`${GIS}/${spec.id}/query`);
|
||||
url.search = new URLSearchParams({
|
||||
where: '1=1',
|
||||
outFields:
|
||||
spec.kind === 'points'
|
||||
? 'idp_source,advisnum,tau,maxwind,gust'
|
||||
: 'idp_source,advisnum',
|
||||
outSR: '4326',
|
||||
resultRecordCount: String(spec.count),
|
||||
geometryPrecision: '4',
|
||||
f: 'geojson',
|
||||
}).toString();
|
||||
return upstream(url.href, spec.cap, signal);
|
||||
}),
|
||||
);
|
||||
if (results.some((result) => result.status === 'rejected'))
|
||||
throw invalid();
|
||||
storms = attachCycloneGeometry(
|
||||
storms,
|
||||
results.map((result) => result.value),
|
||||
);
|
||||
} catch {
|
||||
storms = storms.map((storm) => ({
|
||||
...storm,
|
||||
geometryStatus: 'unavailable',
|
||||
}));
|
||||
}
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
cache = { storms, fetchedAt: now() };
|
||||
return cache;
|
||||
}
|
||||
async function acquire(signal) {
|
||||
signal.throwIfAborted();
|
||||
if (cache && now() - cache.fetchedAt < 300_000) return cache;
|
||||
if (operation?.controller.signal.aborted) operation = null;
|
||||
if (!operation) {
|
||||
if (now() - attemptedAt < 60_000) throw new Error('cyclone_retry_later');
|
||||
attemptedAt = now();
|
||||
const controller = new AbortController();
|
||||
const owned = { controller, waiters: 0 };
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
owned.promise = refresh(controller.signal).finally(() => {
|
||||
clearTimeout(timer);
|
||||
if (operation === owned) operation = null;
|
||||
});
|
||||
operation = owned;
|
||||
}
|
||||
const owned = operation;
|
||||
if (owned.waiters >= 32)
|
||||
throw Object.assign(new Error('cyclone_busy'), { status: 429 });
|
||||
owned.waiters++;
|
||||
let abort;
|
||||
const cancelled = new Promise((_, reject) => {
|
||||
abort = () => reject(signal.reason ?? new Error('cancelled'));
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
});
|
||||
try {
|
||||
return await Promise.race([owned.promise, cancelled]);
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abort);
|
||||
if (--owned.waiters === 0 && operation === owned) {
|
||||
owned.controller.abort();
|
||||
if (signal.aborted) attemptedAt = -Infinity;
|
||||
}
|
||||
}
|
||||
}
|
||||
function describe(value, stale = false) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
source: 'NOAA NHC / CPHC',
|
||||
attribution:
|
||||
'NOAA/NWS National Hurricane Center / Central Pacific Hurricane Center',
|
||||
coverage: COVERAGE,
|
||||
fetchedAt: value?.fetchedAt ?? null,
|
||||
stale: stale || !value,
|
||||
unavailable: !value,
|
||||
reason: !value
|
||||
? 'Cyclone data unavailable'
|
||||
: stale
|
||||
? 'Cached cyclone advisory; upstream unavailable'
|
||||
: null,
|
||||
storms: value?.storms ?? [],
|
||||
};
|
||||
}
|
||||
async function handler(req, res) {
|
||||
const controller = new AbortController();
|
||||
const close = () => controller.abort();
|
||||
res.once?.('close', close);
|
||||
const json = (status, value) => {
|
||||
if (controller.signal.aborted) return;
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
...(status === 429 ? { 'Retry-After': '2' } : {}),
|
||||
});
|
||||
res.end(JSON.stringify(value));
|
||||
};
|
||||
try {
|
||||
if (req.method !== 'GET')
|
||||
return json(405, { error: 'method_not_allowed' });
|
||||
if (req.url !== '/' && req.url !== '')
|
||||
return json(400, { error: 'invalid_cyclone_query' });
|
||||
try {
|
||||
json(200, describe(await acquire(controller.signal)));
|
||||
} catch (error) {
|
||||
if (error.status === 429) return json(429, { error: 'cyclone_busy' });
|
||||
const usable =
|
||||
cache &&
|
||||
now() - cache.fetchedAt <= 12 * HOUR &&
|
||||
cache.storms.every(
|
||||
(storm) => now() - Date.parse(storm.issuedAt) <= 12 * HOUR,
|
||||
);
|
||||
json(200, describe(usable ? cache : null, true));
|
||||
}
|
||||
} finally {
|
||||
res.removeListener?.('close', close);
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: 'cyclones',
|
||||
configureServer({ middlewares }) {
|
||||
middlewares.use('/api/cyclones', handler);
|
||||
},
|
||||
configurePreviewServer({ middlewares }) {
|
||||
middlewares.use('/api/cyclones', handler);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { normalizeFirePerimeterSnapshot } from '../../src/layers/perimeters/records.js';
|
||||
import {
|
||||
readResponseJsonCapped,
|
||||
readResponseTextCapped,
|
||||
coalesceProxyRequest,
|
||||
} from './common/http.js';
|
||||
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
|
||||
|
||||
// NIFC WFIGS current interagency fire perimeters (public, keyless).
|
||||
// maxAllowableOffset trades ~100 m of boundary fidelity for a payload small
|
||||
// enough to refresh continuously (~1 MB for a typical fire season).
|
||||
const API_URL =
|
||||
'https://services3.arcgis.com/T4QMspbfLg3qTGWY/arcgis/rest/services/' +
|
||||
'WFIGS_Interagency_Perimeters_Current/FeatureServer/0/query?' +
|
||||
new URLSearchParams({
|
||||
where: '1=1',
|
||||
outFields: [
|
||||
'poly_IncidentName',
|
||||
'attr_UniqueFireIdentifier',
|
||||
'attr_IncidentSize',
|
||||
'attr_PercentContained',
|
||||
'attr_POOState',
|
||||
'attr_IncidentTypeCategory',
|
||||
'attr_FireDiscoveryDateTime',
|
||||
'poly_DateCurrent',
|
||||
'attr_FireCause',
|
||||
'attr_FireBehaviorGeneral',
|
||||
'attr_TotalIncidentPersonnel',
|
||||
'attr_POOCounty',
|
||||
'attr_EstimatedCostToDate',
|
||||
'attr_IncidentComplexityLevel',
|
||||
'attr_CpxName',
|
||||
].join(','),
|
||||
maxAllowableOffset: '0.001',
|
||||
outSR: '4326',
|
||||
f: 'geojson',
|
||||
}).toString();
|
||||
|
||||
// The service caps a single response at its maxRecordCount (2000); a peak
|
||||
// season can exceed that, so follow exceededTransferLimit with offset pages.
|
||||
const MAX_PAGES = 5;
|
||||
|
||||
const exceededTransferLimit = (payload) =>
|
||||
payload?.exceededTransferLimit === true ||
|
||||
payload?.properties?.exceededTransferLimit === true;
|
||||
|
||||
const MIB = 1024 * 1024;
|
||||
|
||||
/** Fixed-origin, bounded WFIGS and InciWeb routes for dev and preview. */
|
||||
export function firePerimetersProxy({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
now = () => Date.now(),
|
||||
} = {}) {
|
||||
const cache = new Map();
|
||||
const publications = new Map();
|
||||
const inFlight = new Map();
|
||||
const allow = makeRateLimiter({ windowMs: 60_000, max: 60, globalMax: 1200 });
|
||||
|
||||
async function upstream(url, cap, timeout, options = {}) {
|
||||
const signal = AbortSignal.timeout(timeout);
|
||||
const response = await fetchImpl(url, {
|
||||
...options,
|
||||
signal,
|
||||
redirect: 'error',
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new Error('upstream_unavailable');
|
||||
}
|
||||
return readResponseJsonCapped(response, cap, signal);
|
||||
}
|
||||
|
||||
async function fetchPerimeters() {
|
||||
const features = [];
|
||||
for (let page = 0; page < MAX_PAGES; page++) {
|
||||
const url =
|
||||
page === 0 ? API_URL : `${API_URL}&resultOffset=${features.length}`;
|
||||
const payload = await upstream(url, 16 * MIB, 30_000);
|
||||
if (!Array.isArray(payload?.features))
|
||||
throw new Error('invalid_snapshot');
|
||||
features.push(...payload.features);
|
||||
if (!exceededTransferLimit(payload) || !payload.features.length) break;
|
||||
}
|
||||
const rows = normalizeFirePerimeterSnapshot({ features });
|
||||
return { fetchedAt: now(), rows };
|
||||
}
|
||||
|
||||
async function fetchIndex() {
|
||||
const rows = await upstream(
|
||||
'https://inciweb.wildfire.gov/api/single-publication/',
|
||||
4 * MIB,
|
||||
20_000,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: '' }),
|
||||
},
|
||||
);
|
||||
if (!Array.isArray(rows)) throw new Error('invalid_index');
|
||||
return rows;
|
||||
}
|
||||
|
||||
// InciWeb's publication API was retired; incident pages carry origin and update times.
|
||||
async function fetchPublication(id) {
|
||||
const signal = AbortSignal.timeout(15_000);
|
||||
let response = await fetchImpl(`https://inciweb.wildfire.gov/node/${id}`, {
|
||||
signal,
|
||||
redirect: 'manual',
|
||||
});
|
||||
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
||||
await response.body?.cancel();
|
||||
const location = response.headers.get('location');
|
||||
if (!location || /\.\.|[?%\\]/.test(location))
|
||||
throw new Error('invalid_redirect');
|
||||
const url = new URL(location, 'https://inciweb.wildfire.gov/');
|
||||
if (
|
||||
url.hostname !== 'inciweb.wildfire.gov' ||
|
||||
!/^\/[a-z0-9-]+(?:\/[a-z0-9-]+)?$/.test(url.pathname) ||
|
||||
!['http:', 'https:'].includes(url.protocol) ||
|
||||
url.port ||
|
||||
url.username ||
|
||||
url.password
|
||||
)
|
||||
throw new Error('invalid_redirect');
|
||||
url.protocol = 'https:';
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
response = await fetchImpl(url.href, { signal, redirect: 'error' });
|
||||
}
|
||||
if (response.status !== 200) {
|
||||
await response.body?.cancel();
|
||||
throw new Error('upstream_unavailable');
|
||||
}
|
||||
const html = await readResponseTextCapped(response, 2 * MIB, signal);
|
||||
const changed = Date.parse(
|
||||
html.match(/<meta\s+property="og:updated_time"\s+content="([^"]+)"/)?.[1],
|
||||
);
|
||||
const created = Date.parse(
|
||||
html.match(
|
||||
/Date of Origin<\/th>\s*<td[^>]*>\s*<time\s+datetime="([^"]+)"/,
|
||||
)?.[1],
|
||||
);
|
||||
const createdMs = Number.isFinite(created) ? created : null;
|
||||
const changedMs = Number.isFinite(changed) ? changed : null;
|
||||
if (createdMs === null && changedMs === null)
|
||||
throw new Error('invalid_incident_page');
|
||||
return { createdMs, changedMs };
|
||||
}
|
||||
|
||||
async function acquire(key, ttl, load, store) {
|
||||
const previous = store.get(key);
|
||||
if (previous && now() - previous.savedAt < ttl)
|
||||
return { value: previous.value, stale: false };
|
||||
// Bound distinct concurrent publication requests as well as retained entries.
|
||||
if (!inFlight.has(key) && inFlight.size >= 256)
|
||||
throw Object.assign(new Error('busy'), { status: 429 });
|
||||
try {
|
||||
const { promise } = coalesceProxyRequest(inFlight, key, async () => {
|
||||
const value = await load();
|
||||
store.delete(key);
|
||||
store.set(key, { value, savedAt: now() });
|
||||
if (store === publications && store.size > 256)
|
||||
store.delete(store.keys().next().value);
|
||||
return value;
|
||||
});
|
||||
return { value: await promise, stale: false };
|
||||
} catch (error) {
|
||||
if (previous) return { value: previous.value, stale: true };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function handler(req, res) {
|
||||
const json = (status, value, stale = false) => {
|
||||
if (res.destroyed) return;
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
...(status === 405 ? { Allow: 'GET' } : {}),
|
||||
...(status === 429 ? { 'Retry-After': '60' } : {}),
|
||||
...(stale ? { 'X-Data-Stale': 'true' } : {}),
|
||||
});
|
||||
res.end(JSON.stringify(value));
|
||||
};
|
||||
if (req.method !== 'GET') return json(405, { error: 'method_not_allowed' });
|
||||
const path = (req.url || '/').split('?')[0];
|
||||
let key,
|
||||
ttl,
|
||||
load,
|
||||
store = cache;
|
||||
if (path === '/' || path === '') {
|
||||
key = 'perimeters';
|
||||
ttl = 300_000;
|
||||
load = fetchPerimeters;
|
||||
} else if (path === '/inciweb/index') {
|
||||
key = 'index';
|
||||
ttl = 3_600_000;
|
||||
load = fetchIndex;
|
||||
} else if (path.startsWith('/inciweb/publication/')) {
|
||||
const id = path.slice('/inciweb/publication/'.length);
|
||||
if (!/^\d{1,9}$/.test(id))
|
||||
return json(400, { error: 'invalid_publication_id' });
|
||||
key = `publication:${id}`;
|
||||
ttl = 1_800_000;
|
||||
load = () => fetchPublication(id);
|
||||
store = publications;
|
||||
} else return json(404, { error: 'unknown_route' });
|
||||
if (!allow(clientKey(req))) return json(429, { error: 'rate_limited' });
|
||||
try {
|
||||
const { value, stale } = await acquire(key, ttl, load, store);
|
||||
json(
|
||||
200,
|
||||
key === 'perimeters' && stale ? { ...value, stale: true } : value,
|
||||
stale,
|
||||
);
|
||||
} catch (error) {
|
||||
json(error.status === 429 ? 429 : 502, {
|
||||
error: 'fire_perimeters_unavailable',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'fire-perimeters',
|
||||
configureServer({ middlewares }) {
|
||||
middlewares.use('/api/fire-perimeters', handler);
|
||||
},
|
||||
configurePreviewServer({ middlewares }) {
|
||||
middlewares.use('/api/fire-perimeters', handler);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -8,10 +8,11 @@ import { filterTrailing24h, parseFirmsCsv } from '../../src/data/firmsCsv.js';
|
||||
* Upstream: https://firms.modaps.eosdis.nasa.gov/api/area/csv/{KEY}/{SOURCE}/world/2
|
||||
*
|
||||
* Merges three VIIRS NRT sources (NOAA-20, NOAA-21, Suomi-NPP — independent
|
||||
* satellites, no cross-source dedup) fetched sequentially with `days=2`
|
||||
* (`days=1` means "current UTC day", nearly empty just after 00:00Z) and
|
||||
* clamps to the trailing 24 h via src/data/firmsCsv.js. FIRMS quota is
|
||||
* 5,000 transactions / 10 min per MAP_KEY, so the cache is the point:
|
||||
* satellites, no cross-source dedup) plus MODIS_NRT (combined Terra+Aqua,
|
||||
* ~1 km, numeric 0-100 confidence), fetched sequentially (quota courtesy)
|
||||
* with `days=2` (`days=1` means "current UTC day", nearly empty just after
|
||||
* 00:00Z) and clamps to the trailing 24 h via src/data/firmsCsv.js. FIRMS
|
||||
* quota is 5,000 transactions / 10 min per MAP_KEY, so the cache is the point:
|
||||
* TTL 30 min, single-flight refresh, serve-stale-on-failure, and a
|
||||
* fresh-enough disk cache (.gev-cache/firms.json) prevents ANY upstream
|
||||
* fetch across dev-server restarts. Pattern mirrors celestrakProxy.
|
||||
@@ -28,7 +29,12 @@ import { filterTrailing24h, parseFirmsCsv } from '../../src/data/firmsCsv.js';
|
||||
export function firmsProxy() {
|
||||
const TTL_MS = 30 * 60_000;
|
||||
const STATUS_TTL_MS = 5 * 60_000;
|
||||
const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'VIIRS_SNPP_NRT'];
|
||||
const SOURCES = [
|
||||
'VIIRS_NOAA20_NRT',
|
||||
'VIIRS_NOAA21_NRT',
|
||||
'VIIRS_SNPP_NRT',
|
||||
'MODIS_NRT',
|
||||
];
|
||||
const CACHE_DIR = path.join(process.cwd(), '.gev-cache');
|
||||
const CACHE_PATH = path.join(CACHE_DIR, 'firms.json');
|
||||
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
import { lookup as dnsLookup } from 'node:dns/promises';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import { Readable } from 'node:stream';
|
||||
import { coalesceProxyRequest, readResponseTextCapped } from './common/http.js';
|
||||
import { isLocalIpv4, parseTapAddress } from '../../src/data/tapAddress.js';
|
||||
import { normalizeDump1090Aircraft } from '../../src/sources/adsbRecords.js';
|
||||
|
||||
/**
|
||||
* Local decoder feeds for the Local ADS-B layer.
|
||||
*
|
||||
* A user who runs dump1090 / readsb / tar1090 (1090 MHz) or dump978-fa +
|
||||
* skyaware978 (978 MHz UAT) exposes an `aircraft.json` over HTTP. This route
|
||||
* reads those documents and returns them as Local ADS-B records.
|
||||
*
|
||||
* The feed list comes only from the operator's `LOCAL_RECEIVER_FEEDS`
|
||||
* environment value; the browser never supplies an address. Every host must
|
||||
* pass the shared receiver-tap address rule (`src/data/tapAddress.js`:
|
||||
* loopback, RFC1918, `localhost`, `*.local`), the scheme must be http(s) and
|
||||
* the path must end in `aircraft.json`. Invalid entries are logged at startup,
|
||||
* reported as `invalid` and never fetched.
|
||||
*
|
||||
* A name (`localhost`, `*.local`) is resolved before every read; each address
|
||||
* it resolves to must itself be loopback or RFC1918, and the connection is
|
||||
* pinned to exactly those validated addresses, so a later re-resolution
|
||||
* cannot redirect it anywhere else.
|
||||
*/
|
||||
|
||||
export const LOCAL_RECEIVERS_ROUTE = '/api/local-receivers/aircraft';
|
||||
export const LOCAL_RECEIVER_FEEDS_ENV = 'LOCAL_RECEIVER_FEEDS';
|
||||
export const LOCAL_RECEIVER_BANDS = Object.freeze(['1090', '978']);
|
||||
export const LOCAL_RECEIVER_TIMEOUT_MS = 2_000;
|
||||
export const LOCAL_RECEIVER_MAX_BODY_BYTES = 2 * 1024 * 1024;
|
||||
export const LOCAL_RECEIVER_CACHE_MS = 1_000;
|
||||
/** A feed whose own `now` is older than this is reported stale. */
|
||||
export const LOCAL_RECEIVER_STALE_MS = 10_000;
|
||||
const MAX_FEEDS = 8;
|
||||
const LOG_PREFIX = '[local-receivers]';
|
||||
|
||||
const BAND_LABELS = Object.freeze({ 1090: '1090 MHz', 978: '978 MHz UAT' });
|
||||
/** Statuses whose `Response` must be constructed without a body. */
|
||||
const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]);
|
||||
|
||||
function defaultPort(protocol) {
|
||||
return protocol === 'https:' ? 443 : 80;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one `band=url` entry.
|
||||
* @param {string} entry Raw entry text.
|
||||
* @returns {{band:string, url:string}|{band:string|null, reason:string}}
|
||||
*/
|
||||
function parseEntry(entry) {
|
||||
const separator = entry.indexOf('=');
|
||||
if (separator <= 0) return { band: null, reason: 'expected band=url' };
|
||||
const band = entry.slice(0, separator).trim();
|
||||
const rawUrl = entry.slice(separator + 1).trim();
|
||||
if (!LOCAL_RECEIVER_BANDS.includes(band))
|
||||
return { band: null, reason: 'band must be 1090 or 978' };
|
||||
let url;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch {
|
||||
return { band, reason: 'not a valid URL' };
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
||||
return { band, reason: 'scheme must be http or https' };
|
||||
if (url.username || url.password)
|
||||
return { band, reason: 'credentials are not allowed in the URL' };
|
||||
if (url.search || url.hash || rawUrl.includes('?') || rawUrl.includes('#'))
|
||||
return { band, reason: 'query and fragment are not allowed' };
|
||||
const port = url.port ? Number(url.port) : defaultPort(url.protocol);
|
||||
const address = parseTapAddress(`${url.hostname}:${port}`);
|
||||
if (!address)
|
||||
return {
|
||||
band,
|
||||
reason: 'host must be loopback, RFC1918, localhost or *.local',
|
||||
};
|
||||
if (!/(?:^|\/)aircraft\.json$/.test(url.pathname))
|
||||
return { band, reason: 'path must end in aircraft.json' };
|
||||
return {
|
||||
band,
|
||||
url: `${url.protocol}//${address.host}:${address.port}${url.pathname}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `LOCAL_RECEIVER_FEEDS` (`band=url[,band=url…]`).
|
||||
*
|
||||
* Labels name the band only (plus an ordinal when a band repeats), so feed
|
||||
* addresses never reach the browser.
|
||||
* @param {string|undefined} value Environment value.
|
||||
* @returns {{configured:boolean, feeds:object[]}} Each feed is
|
||||
* `{ id, band, label, url }` when valid or `{ id, band, label, reason }`
|
||||
* when invalid (never fetched).
|
||||
*/
|
||||
export function parseLocalReceiverFeeds(value) {
|
||||
const entries = String(value ?? '')
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
const feeds = [];
|
||||
const perBand = new Map();
|
||||
entries.forEach((entry, index) => {
|
||||
const parsed =
|
||||
index < MAX_FEEDS
|
||||
? parseEntry(entry)
|
||||
: { band: null, reason: `at most ${MAX_FEEDS} feeds are read` };
|
||||
const band = parsed.band;
|
||||
const ordinal = (perBand.get(band) || 0) + 1;
|
||||
perBand.set(band, ordinal);
|
||||
const base = band ? BAND_LABELS[band] : `entry ${index + 1}`;
|
||||
const label = band && ordinal > 1 ? `${base} #${ordinal}` : base;
|
||||
feeds.push({
|
||||
id: `feed-${index + 1}`,
|
||||
band,
|
||||
label,
|
||||
...(parsed.url ? { url: parsed.url } : { reason: parsed.reason }),
|
||||
});
|
||||
});
|
||||
// Ordinals are only needed when a band actually repeats.
|
||||
for (const feed of feeds) {
|
||||
if (feed.band && perBand.get(feed.band) > 1 && !feed.label.includes('#'))
|
||||
feed.label = `${feed.label} #1`;
|
||||
}
|
||||
return { configured: feeds.length > 0, feeds };
|
||||
}
|
||||
|
||||
class FeedError extends Error {
|
||||
constructor(code) {
|
||||
super(code);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a resolved address satisfies the receiver-tap rule: loopback or
|
||||
* RFC1918 IPv4, the IPv6 loopback, or an IPv4-mapped form of the former.
|
||||
* @param {string} address Resolved IP address.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isLocalReceiverAddress(address) {
|
||||
const value = String(address || '').toLowerCase();
|
||||
if (value === '::1') return true;
|
||||
const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(value);
|
||||
return isLocalIpv4(mapped ? mapped[1] : value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a feed host name and require every address to be local.
|
||||
* @param {string} hostname `localhost` or a `*.local` name.
|
||||
* @param {typeof dnsLookup} lookupImpl `dns.promises.lookup`-compatible.
|
||||
* @returns {Promise<Array<{address:string, family:number}>>}
|
||||
*/
|
||||
export async function resolveLocalReceiverAddresses(hostname, lookupImpl) {
|
||||
let resolved;
|
||||
try {
|
||||
resolved = await lookupImpl(hostname, { all: true, verbatim: true });
|
||||
} catch {
|
||||
throw new FeedError('UNRESOLVED');
|
||||
}
|
||||
const addresses = (Array.isArray(resolved) ? resolved : [resolved])
|
||||
.map((row) => ({
|
||||
address: String(row?.address || ''),
|
||||
family:
|
||||
Number(row?.family) || (String(row?.address).includes(':') ? 6 : 4),
|
||||
}))
|
||||
.filter((row) => row.address);
|
||||
if (!addresses.length) throw new FeedError('UNRESOLVED');
|
||||
if (addresses.some((row) => !isLocalReceiverAddress(row.address)))
|
||||
throw new FeedError('FORBIDDEN_ADDRESS');
|
||||
return addresses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle with `promise`, or reject with an AbortError as soon as `signal`
|
||||
* aborts; a later settlement of `promise` is ignored.
|
||||
*/
|
||||
function untilAborted(promise, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const onAbort = () => {
|
||||
const error = new Error('aborted');
|
||||
error.name = 'AbortError';
|
||||
reject(error);
|
||||
};
|
||||
// Handlers are attached first so a late rejection is never unhandled.
|
||||
promise.then(
|
||||
(value) => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
resolve(value);
|
||||
},
|
||||
(error) => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
if (signal.aborted) onAbort();
|
||||
else signal.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/** A `net`-style lookup that only ever answers the validated addresses. */
|
||||
function pinnedLookup(addresses) {
|
||||
return (_hostname, options, callback) => {
|
||||
const done = typeof options === 'function' ? options : callback;
|
||||
if (options?.all)
|
||||
done(
|
||||
null,
|
||||
addresses.map((row) => ({ ...row })),
|
||||
);
|
||||
else done(null, addresses[0].address, addresses[0].family);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Default transport. With `options.lookup` (a pinned feed name) the request
|
||||
* is made with node:http(s) so the socket connects only to the validated
|
||||
* addresses (TLS still verifies the name); otherwise it is a plain fetch of
|
||||
* an IP-literal URL. Redirects are never followed either way.
|
||||
* @param {string} url
|
||||
* @param {object} options
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
function fetchLocalReceiverFeed(url, options) {
|
||||
if (typeof options?.lookup !== 'function') return fetch(url, options);
|
||||
const client = new URL(url).protocol === 'https:' ? https : http;
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = client.request(
|
||||
url,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: options.headers,
|
||||
signal: options.signal,
|
||||
lookup: options.lookup,
|
||||
// A fresh socket per read: a pooled one could outlive the pin.
|
||||
agent: false,
|
||||
},
|
||||
(response) => {
|
||||
// Nothing thrown here may escape: this callback runs outside the
|
||||
// promise, so an exception would be uncaught and crash the server.
|
||||
try {
|
||||
const status = response.statusCode;
|
||||
// A `Response` only represents final statuses 200–599.
|
||||
if (!Number.isInteger(status) || status < 200 || status > 599)
|
||||
throw new FeedError('HTTP_STATUS');
|
||||
const headers = new Headers();
|
||||
for (const [name, value] of Object.entries(response.headers)) {
|
||||
if (Array.isArray(value))
|
||||
value.forEach((item) => headers.append(name, item));
|
||||
else if (value !== undefined) headers.set(name, String(value));
|
||||
}
|
||||
const nullBody = NULL_BODY_STATUSES.has(status);
|
||||
const upstream = new Response(
|
||||
nullBody ? null : Readable.toWeb(response),
|
||||
{ status, statusText: response.statusMessage || '', headers },
|
||||
);
|
||||
if (nullBody) response.destroy();
|
||||
resolve(upstream);
|
||||
} catch (error) {
|
||||
response.destroy();
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
request.on('error', reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch one validated feed document without following redirects and with the
|
||||
* body cap enforced while it streams.
|
||||
* @param {string} url Validated feed URL.
|
||||
* @param {object} options
|
||||
* @returns {Promise<object>} Parsed JSON document.
|
||||
*/
|
||||
export async function fetchLocalReceiverDocument(
|
||||
url,
|
||||
{
|
||||
fetchImpl = fetchLocalReceiverFeed,
|
||||
lookupImpl = dnsLookup,
|
||||
timeoutMs = LOCAL_RECEIVER_TIMEOUT_MS,
|
||||
maxBytes = LOCAL_RECEIVER_MAX_BODY_BYTES,
|
||||
} = {},
|
||||
) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
let upstream;
|
||||
try {
|
||||
const { hostname } = new URL(url);
|
||||
// IP literals were validated when the feed list was parsed; a name is
|
||||
// validated by what it resolves to now, and the socket is pinned there.
|
||||
// Resolution runs against the same deadline as the read: a stalled
|
||||
// lookup fails this feed instead of holding the whole snapshot.
|
||||
const lookup = isLocalIpv4(hostname)
|
||||
? undefined
|
||||
: pinnedLookup(
|
||||
await untilAborted(
|
||||
resolveLocalReceiverAddresses(hostname, lookupImpl),
|
||||
controller.signal,
|
||||
),
|
||||
);
|
||||
upstream = await fetchImpl(url, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
redirect: 'manual',
|
||||
signal: controller.signal,
|
||||
...(lookup ? { lookup } : {}),
|
||||
});
|
||||
if (upstream.status >= 300 && upstream.status < 400)
|
||||
throw new FeedError('REDIRECT_REFUSED');
|
||||
// Opaque redirect responses from `redirect: 'manual'` report status 0.
|
||||
if (upstream.type === 'opaqueredirect')
|
||||
throw new FeedError('REDIRECT_REFUSED');
|
||||
if (!upstream.ok) throw new FeedError('HTTP_STATUS');
|
||||
const text = await readResponseTextCapped(
|
||||
upstream,
|
||||
maxBytes,
|
||||
controller.signal,
|
||||
);
|
||||
let json;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
throw new FeedError('BAD_JSON');
|
||||
}
|
||||
if (!json || typeof json !== 'object' || !Array.isArray(json.aircraft))
|
||||
throw new FeedError('BAD_DOCUMENT');
|
||||
return json;
|
||||
} catch (error) {
|
||||
controller.abort();
|
||||
if (upstream?.body && !upstream.body.locked)
|
||||
void upstream.body.cancel().catch(() => {});
|
||||
if (error instanceof FeedError) throw error;
|
||||
if (error?.code === 'RESPONSE_TOO_LARGE')
|
||||
throw new FeedError('RESPONSE_TOO_LARGE');
|
||||
if (error?.name === 'AbortError' || controller.signal.aborted)
|
||||
throw new FeedError('TIMEOUT');
|
||||
throw new FeedError('UNREACHABLE');
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one feed and describe it.
|
||||
* @returns {Promise<{status:object, records:object[]}>}
|
||||
*/
|
||||
async function readFeed(
|
||||
feed,
|
||||
{ fetchImpl, lookupImpl, now, timeoutMs, maxBytes, log },
|
||||
) {
|
||||
const summary = { band: feed.band, label: feed.label };
|
||||
if (!feed.url)
|
||||
return {
|
||||
status: { ...summary, status: 'invalid', aircraft: 0, ageMs: null },
|
||||
records: [],
|
||||
};
|
||||
let json;
|
||||
try {
|
||||
json = await fetchLocalReceiverDocument(feed.url, {
|
||||
fetchImpl,
|
||||
lookupImpl,
|
||||
timeoutMs,
|
||||
maxBytes,
|
||||
});
|
||||
} catch (error) {
|
||||
log(feed, 'unreachable', error.code);
|
||||
return {
|
||||
status: { ...summary, status: 'unreachable', aircraft: 0, ageMs: null },
|
||||
records: [],
|
||||
};
|
||||
}
|
||||
const receivedAt = now();
|
||||
const feedNowS = Number(json.now);
|
||||
// The feed's own clock decides staleness. A clock ahead of ours reads as
|
||||
// age 0; a document without `now` is treated as current.
|
||||
const ageMs = Number.isFinite(feedNowS)
|
||||
? Math.max(0, Math.round(receivedAt - feedNowS * 1000))
|
||||
: 0;
|
||||
const status = ageMs > LOCAL_RECEIVER_STALE_MS ? 'stale' : 'live';
|
||||
log(feed, status);
|
||||
// Anchor record ages at the document time so a stale feed ages out
|
||||
// naturally, without trusting a receiver clock that runs ahead of ours.
|
||||
const records = normalizeDump1090Aircraft(json, receivedAt - ageMs, {
|
||||
band: feed.band,
|
||||
});
|
||||
return {
|
||||
status: { ...summary, status, aircraft: records.length, ageMs },
|
||||
records,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the route handler. Exposed for tests; the plugin wraps it.
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.feedsValue] `LOCAL_RECEIVER_FEEDS` value.
|
||||
* @param {typeof fetch} [options.fetchImpl] Receives `options.lookup` when
|
||||
* the feed host is a name; the connection must resolve only through it.
|
||||
* @param {Function} [options.lookupImpl] `dns.promises.lookup`-compatible.
|
||||
* @param {() => number} [options.now]
|
||||
* @param {{warn:Function, info?:Function}} [options.logger]
|
||||
* @returns {{handle:Function, config:object}}
|
||||
*/
|
||||
export function createLocalReceiversHandler({
|
||||
feedsValue = process.env[LOCAL_RECEIVER_FEEDS_ENV],
|
||||
fetchImpl = fetchLocalReceiverFeed,
|
||||
lookupImpl = dnsLookup,
|
||||
now = Date.now,
|
||||
logger = console,
|
||||
timeoutMs = LOCAL_RECEIVER_TIMEOUT_MS,
|
||||
maxBytes = LOCAL_RECEIVER_MAX_BODY_BYTES,
|
||||
cacheMs = LOCAL_RECEIVER_CACHE_MS,
|
||||
} = {}) {
|
||||
const config = parseLocalReceiverFeeds(feedsValue);
|
||||
for (const feed of config.feeds) {
|
||||
if (feed.reason)
|
||||
logger.warn(
|
||||
`${LOG_PREFIX} ${LOCAL_RECEIVER_FEEDS_ENV} ${feed.id} (${feed.label}) rejected: ${feed.reason}. It will not be fetched.`,
|
||||
);
|
||||
}
|
||||
const lastStatus = new Map();
|
||||
// Log a feed only when its status changes, so a feed that is down does not
|
||||
// write a line every second.
|
||||
const log = (feed, status, code) => {
|
||||
if (lastStatus.get(feed.id) === status) return;
|
||||
lastStatus.set(feed.id, status);
|
||||
if (status === 'live') logger.info?.(`${LOG_PREFIX} ${feed.label} live`);
|
||||
else
|
||||
logger.warn(
|
||||
`${LOG_PREFIX} ${feed.label} ${status}${code ? ` (${code})` : ''}`,
|
||||
);
|
||||
};
|
||||
const inFlight = new Map();
|
||||
let cached = null;
|
||||
|
||||
async function snapshot() {
|
||||
const results = await Promise.all(
|
||||
config.feeds.map((feed) =>
|
||||
readFeed(feed, {
|
||||
fetchImpl,
|
||||
lookupImpl,
|
||||
now,
|
||||
timeoutMs,
|
||||
maxBytes,
|
||||
log,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return {
|
||||
configured: true,
|
||||
generatedAt: now(),
|
||||
feeds: results.map((result) => result.status),
|
||||
records: results.flatMap((result) => result.records),
|
||||
};
|
||||
}
|
||||
|
||||
async function payload() {
|
||||
if (!config.configured)
|
||||
return { configured: false, feeds: [], records: [] };
|
||||
if (cached && now() - cached.at < cacheMs) return cached.payload;
|
||||
const { promise } = coalesceProxyRequest(inFlight, 'all', snapshot);
|
||||
const result = await promise;
|
||||
cached = { at: now(), payload: result };
|
||||
return result;
|
||||
}
|
||||
|
||||
async function handle(req, res) {
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
res.writeHead(405, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = await payload();
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(JSON.stringify(body));
|
||||
} catch {
|
||||
res.writeHead(502, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(JSON.stringify({ error: 'Local receiver feeds unavailable' }));
|
||||
}
|
||||
}
|
||||
|
||||
return { handle, config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Vite plugin mounting `GET /api/local-receivers/aircraft`.
|
||||
*
|
||||
* The environment is read when the server starts, so construction alone
|
||||
* neither parses configuration nor fetches anything.
|
||||
* @param {object} [options] Passed to {@link createLocalReceiversHandler}.
|
||||
* @returns {import('vite').Plugin}
|
||||
*/
|
||||
export function localReceiversProxy(options = {}) {
|
||||
const install = (server) => {
|
||||
const { handle } = createLocalReceiversHandler(options);
|
||||
server.middlewares.use(LOCAL_RECEIVERS_ROUTE, (req, res, next) => {
|
||||
const path = new URL(req.url || '/', 'http://localhost').pathname;
|
||||
if (path !== '/' && path !== '') {
|
||||
if (typeof next === 'function') return next();
|
||||
return undefined;
|
||||
}
|
||||
return handle(req, res);
|
||||
});
|
||||
};
|
||||
return {
|
||||
name: 'local-receivers-proxy',
|
||||
configureServer: install,
|
||||
configurePreviewServer: install,
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { cctvProxy } from './cctv.js';
|
||||
import { defaultSourceRoot } from './common/source-root.js';
|
||||
import { radioBrowserProxy } from './radio.js';
|
||||
import { gbfsProxy } from './gbfs.js';
|
||||
import { localReceiversProxy } from './local-receivers.js';
|
||||
import { transitProxy } from './transit.js';
|
||||
import { adsbLolProxy } from './aircraft/adsb-lol.js';
|
||||
import { aisLiveProxy } from './vessels/ais-live.js';
|
||||
@@ -20,6 +21,10 @@ import { trackBackfillProxies } from './aircraft/tracks.js';
|
||||
import { openAiRealtimeProxy } from './openai.js';
|
||||
import { googlePlacesContextProxy } from './places.js';
|
||||
import { keySetupEndpoint } from '../standalone/key-setup.js';
|
||||
import { weatherProxy } from './weather.js';
|
||||
import { firePerimetersProxy } from './firePerimeters.js';
|
||||
import { cycloneProxy } from './cyclones.js';
|
||||
import { windProxy } from './wind.js';
|
||||
|
||||
/** Construct the local provider plugins in their established order. */
|
||||
function localProviderPlugins() {
|
||||
@@ -39,12 +44,17 @@ function localProviderPlugins() {
|
||||
cctvProxy({ sourceRoot: defaultSourceRoot }),
|
||||
radioBrowserProxy(),
|
||||
gbfsProxy(),
|
||||
localReceiversProxy(),
|
||||
transitProxy(),
|
||||
adsbLolProxy(),
|
||||
aisLiveProxy(),
|
||||
trackBackfillProxies(),
|
||||
openAiRealtimeProxy(),
|
||||
googlePlacesContextProxy(),
|
||||
windProxy(),
|
||||
weatherProxy(),
|
||||
cycloneProxy(),
|
||||
firePerimetersProxy(),
|
||||
keySetupEndpoint(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,16 +1,65 @@
|
||||
import { defaultSourceRoot } from '../common/source-root.js';
|
||||
import path from 'node:path';
|
||||
import { readRequestBody } from '../common/request.js';
|
||||
import fs from 'node:fs';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import { makeRateLimiter, clientKey } from '../common/rate-limit.js';
|
||||
|
||||
/** Cap on one request body. */
|
||||
const REALTIME_DEBUG_LOG_MAX_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Ceiling for the live log before it rotates. One generation is kept
|
||||
* (`…jsonl.1`), so the sink is bounded at twice this however long a session
|
||||
* runs. REALTIME_DEBUG_LOG_MAX_BYTES caps a single request; it never bounded
|
||||
* the file those requests accumulate into.
|
||||
*/
|
||||
const REALTIME_DEBUG_LOG_MAX_FILE_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Per-IP write ceiling. Always on, unlike the opt-in limiter the cost-bearing
|
||||
* OpenAI routes share: throttling those by default would change what a user
|
||||
* spends, while this sink spends disk and event-loop time. A voice session
|
||||
* writes far below this, so the cap needs no configuration to stay invisible.
|
||||
*/
|
||||
const REALTIME_DEBUG_LOG_MAX_PER_MIN = 120;
|
||||
|
||||
function createDebugLogHandler({ sourceRoot = defaultSourceRoot } = {}) {
|
||||
const REALTIME_DEBUG_LOG_DIR = path.join(sourceRoot, '.gev-logs');
|
||||
const REALTIME_DEBUG_LOG_FILE = path.join(
|
||||
REALTIME_DEBUG_LOG_DIR,
|
||||
'realtime-conversations.jsonl',
|
||||
);
|
||||
const logDir = path.join(sourceRoot, '.gev-logs');
|
||||
const logFile = path.join(logDir, 'realtime-conversations.jsonl');
|
||||
const allow = makeRateLimiter({
|
||||
windowMs: 60_000,
|
||||
max: REALTIME_DEBUG_LOG_MAX_PER_MIN,
|
||||
globalMax: 400,
|
||||
});
|
||||
|
||||
// Appends are chained rather than fired concurrently because the rotation
|
||||
// check is a read-then-write on file size: run in parallel, every caller
|
||||
// stats a file still under the ceiling, none of them rotates, and the log
|
||||
// grows past it. The records themselves are O_APPEND and never interleave.
|
||||
let queue = Promise.resolve();
|
||||
|
||||
function append(line) {
|
||||
const done = queue.then(async () => {
|
||||
await fsp.mkdir(logDir, { recursive: true });
|
||||
const size = await fsp.stat(logFile).then(
|
||||
(stat) => stat.size,
|
||||
() => 0,
|
||||
);
|
||||
if (size + Buffer.byteLength(line) > REALTIME_DEBUG_LOG_MAX_FILE_BYTES) {
|
||||
// Node does not replace an existing destination on Windows. Remove the
|
||||
// retained generation explicitly so every rotation behaves the same on
|
||||
// supported platforms rather than failing after the first 64 MB.
|
||||
await fsp.rm(`${logFile}.1`, { force: true });
|
||||
await fsp.rename(logFile, `${logFile}.1`);
|
||||
}
|
||||
await fsp.appendFile(logFile, line);
|
||||
});
|
||||
// The caller still sees its own rejection, but one failed append must not
|
||||
// poison the chain for every request queued behind it.
|
||||
queue = done.catch(() => {});
|
||||
return done;
|
||||
}
|
||||
|
||||
return async (req, res) => {
|
||||
if (req.method !== 'POST') {
|
||||
res.statusCode = 405;
|
||||
@@ -19,12 +68,19 @@ function createDebugLogHandler({ sourceRoot = defaultSourceRoot } = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!allow(clientKey(req))) {
|
||||
res.writeHead(429, {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': '60',
|
||||
});
|
||||
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await readRequestBody(req, REALTIME_DEBUG_LOG_MAX_BYTES);
|
||||
const record = JSON.parse(body || '{}');
|
||||
fs.mkdirSync(REALTIME_DEBUG_LOG_DIR, { recursive: true });
|
||||
fs.appendFileSync(
|
||||
REALTIME_DEBUG_LOG_FILE,
|
||||
await append(
|
||||
`${JSON.stringify({
|
||||
loggedAt: new Date().toISOString(),
|
||||
...record,
|
||||
@@ -33,13 +89,16 @@ function createDebugLogHandler({ sourceRoot = defaultSourceRoot } = {}) {
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
} catch (error) {
|
||||
res.statusCode = 400;
|
||||
// A malformed record or an oversized body is the caller's fault; a failed
|
||||
// write is ours, and only the second is worth a log line. Neither answer
|
||||
// carries the error text, which for an fs failure is an errno and an
|
||||
// absolute path.
|
||||
const callerFault =
|
||||
error instanceof SyntaxError || error?.code === 'BODY_TOO_LARGE';
|
||||
if (!callerFault) console.warn('[realtime-debug-log] append failed');
|
||||
res.statusCode = callerFault ? 400 : 500;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: error?.message || 'Failed to write Realtime debug log',
|
||||
}),
|
||||
);
|
||||
res.end(JSON.stringify({ error: 'Failed to write Realtime debug log' }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { keylessHudSummaryResponse } from '../../../src/hudSummaryResponse.js';
|
||||
import {
|
||||
HUD_SUMMARY_INSTRUCTIONS,
|
||||
keylessHudSummaryResponse,
|
||||
} from '../../../src/hudSummaryResponse.js';
|
||||
import { enforceOptInRateLimit, openAiRateLimiter } from './rate-limit.js';
|
||||
import { readRequestBody } from '../common/request.js';
|
||||
import { OPENAI_HUD_SUMMARY_MODEL_DEFAULT } from './constants.js';
|
||||
@@ -61,13 +64,7 @@ async function handleHudSummary(req, res) {
|
||||
model:
|
||||
process.env.OPENAI_HUD_SUMMARY_MODEL ||
|
||||
OPENAI_HUD_SUMMARY_MODEL_DEFAULT,
|
||||
instructions: [
|
||||
"Write one concise intelligence-HUD summary for God's Eye View.",
|
||||
'Use only the supplied place, street, nearby-place, and enabled-layer text labels.',
|
||||
'Prefer the clearest named place and include a relevant enabled layer only when useful.',
|
||||
'Do not infer from coordinates or invent a place.',
|
||||
'Output exactly five words with no title, punctuation, markdown, or introductory phrase.',
|
||||
].join(' '),
|
||||
instructions: HUD_SUMMARY_INSTRUCTIONS,
|
||||
input: JSON.stringify(context),
|
||||
reasoning: { effort: 'minimal' },
|
||||
max_output_tokens: 100,
|
||||
@@ -78,20 +75,23 @@ async function handleHudSummary(req, res) {
|
||||
res.statusCode = response.ok && summary ? 200 : response.status || 502;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
if (!response.ok)
|
||||
console.warn(`[hud-summary] upstream HTTP ${response.status}`);
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
summary: summary || null,
|
||||
error: response.ok
|
||||
? null
|
||||
: data.error?.message || 'OpenAI HUD summary request failed',
|
||||
// Never relay `data.error.message`: that is OpenAI's own wording, and
|
||||
// it carries request ids, organization hints and quota phrasing.
|
||||
error: response.ok ? null : 'OpenAI HUD summary request failed',
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
console.warn('[hud-summary] request failed');
|
||||
res.statusCode = 502;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: error?.message || 'OpenAI HUD summary request failed',
|
||||
error: 'OpenAI HUD summary request failed',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ function realtimeInstructions(
|
||||
'For camera-motion requests — "orbit around this", "pan left", "tilt up", "stop moving" — call move_camera. For "fly the route" over a drawn route, call fly_route. Confirm with the RESULTING state ("Orbiting slowly", "Flying the route").',
|
||||
'analyst_query ANSWERS questions; it never moves the camera or starts tracking. For requests to FOLLOW or TRACK a specific aircraft/ship, call track_entity (get_entity_context first when the target is ambiguous), never analyst_query as the final or only action. For "follow/track the nearest aircraft", first call analyst_query with the aircraft layer(s), sortBy=distance, and limit=1, then call track_entity with the returned aircraft identity in the same turn. The lookup alone does not fulfill a follow/track command.',
|
||||
'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data: report the returned feed source briefly, never call it an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request. SELECT/FIND never implies Contacts or Cockpit unless the user explicitly asks for either mode.',
|
||||
'For ANALYTICAL questions about layer data — how many / which / fastest / highest / biggest / nearest flights, ships, fires, or earthquakes ("how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Narrate the count plus two or three notable examples by name, and reflect the result\'s coverage note honestly: the answer covers data loaded by enabled layers, not the whole world. If the needed layer is disabled, say so and offer to enable it. For follow-ups about the same set ("which of THOSE is closest?"), call analyst_query with followUp=true and only the new filter/sort.',
|
||||
'For ANALYTICAL questions about layer data — how many / which / fastest / highest / biggest / nearest flights, ships, fires, earthquakes, satellites, datacenters, or dams ("how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Narrate the count plus two or three notable examples by name, and reflect the result\'s coverage note honestly: the answer covers data loaded by enabled layers, not the whole world. If the needed layer is disabled, say so and offer to enable it. For follow-ups about the same set ("which of THOSE is closest?"), call analyst_query with followUp=true and only the new filter/sort.',
|
||||
'FEED STATE CONTRACT — analyst_query and get_current_view_state carry feedProvenance from the same layerFeedState the Data Layers chips use (nominal/loading/degraded/stale/fallback/unavailable, or off). NEVER narrate a count as live or current when feedProvenance.overall is not nominal. Say the feedState in the same breath as the count ("12 flights in view, STALE"). If unavailable, do not invent a count; say the feed is unavailable. Fallback is valid data — name the fallback source, never call it an enable failure. Confirm only what the tool result returned.',
|
||||
'COUNTING CONTRACT — what "near" means. (1) While Contacts is ACTIVE, "near / nearby / how many aircraft" means the Contacts window: answer from contactsWindow in the tool result — those are the exact numbers on the user\'s panel. set_context_mode, analyst_query, and get_current_view_state carry it after Contacts settles. For "Open Contacts and tell me how many aircraft are within 250 km", call set_context_mode{mode:"contacts"} first and answer from contactsWindow.aircraft; do not answer from a pre-Contacts analyst query. analyst_query\'s own count measures currently-loaded records and is usually lower; never give it as the window count. CENTER PRECEDENCE for a nearby/how-many ask, in order: an explicit place in the question ("over Texas", "near Austin") always wins and ignores Contacts state; else the CONTACTS SUBJECT when Contacts is active and has one — a selected datacenter, dam, fire, or cable does NOT silently become the center; else an entity the user explicitly names ("around this datacenter"); else the current view, said aloud ("nothing is selected, so this is the current view"). With Contacts active but NO subject yet, use the view and say so; never read an empty panel. (2) With Contacts OFF, "nearby" means in view; "near <place>" means a radius around that place. (3) EVERY count names its scope in words — "42 in your window", "8 in view", "about 30 within 250 km of Austin" — never a bare number; analyst_query returns scopeLabel for exactly this. Two different numbers with named scopes are not a contradiction; say both if asked. (4) State counts VERBATIM — never estimate, round, or hedge ("a few", "less than a dozen"): if a tool returns 46, say 46. (5) When it matters, add once: counts cover loaded data, and the flights layer loads where you look.',
|
||||
'While Cockpit is active, navigate with control_cockpit (next/previous, optionally targetLayer or aircraftClass). track_entity and fly_to_location are REFUSED by design while Cockpit owns the camera — that refusal is correct, not an error to retry. To go somewhere else, exit Cockpit first. control_cockpit enter establishes Contacts itself, so do not call set_context_mode before or after it.',
|
||||
'When the target layer is unknown, OMIT layerId in track_entity so it searches all enabled layers. Passing the wrong layerId ("flights" for a military contact) returns "Nothing matched" even though the contact is loaded.',
|
||||
@@ -48,7 +49,8 @@ function realtimeInstructions(
|
||||
'NAMED VIEWS are shorthand for tool calls you already have — there is no "mode" tool for them. Treat ONLY these as the shorthand: "infrastructure mode" / "the infrastructure view" / "show me global infrastructure" means three set_layer_visibility calls (local-datacenters, local-dams, telegeography-submarine-cables) plus zoom_to_globe; "environmental mode" / "earth watch" / "active events", said as the name of a view, means set_layer_visibility for local-firms and earthquakes plus zoom_to_globe. Anything vaguer is NOT this shorthand — an open-ended question about the world or the news is an ordinary question: answer it, or use analyst_query over the layers already on. Never switch a whole view on to answer a question nobody asked to see. When you do run one, make every call before speaking, then give one confirmation naming the resulting state; if the fires layer comes back unavailable because no FIRMS key is configured, say so plainly — the earthquakes still loaded. "Live contacts" and "space missions" are NOT this pattern: they stay set_context_mode{mode:"contacts"} and set_context_mode{mode:"space-missions"}.',
|
||||
'For visual filter requests, call set_visual_style with one of the allowed style IDs.',
|
||||
'Disambiguation table — basemap vs layer vs style: basemap switching requires an explicit stack name — "Bing aerial" means set_map_stack bing-aerial, "aerial with labels" means bing-labels, "OSM"/"road map" means osm, "Esri"/"Esri imagery" means esri-imagery, "Google 3D"/"photorealistic" means photoreal. Any mention of "satellite" or "satellites" ALWAYS means the satellites DATA LAYER via set_layer_visibility, never a basemap. "surveillance"/"night vision"/"thermal" are visual STYLES via set_visual_style.',
|
||||
'HUD requests ("hud on/off", "switch to operator/minimal/tactical layout") use set_hud. Detection requests ("detection on", "dense mode", "balanced mode", "sparse mode", "set density to 25", "use weighted allocation") use set_detection. Density snaps to 0/25/50/75/100 and derives Sparse/Balanced/Dense; panoptic is a legacy alias for Dense.',
|
||||
'HUD requests ("hud on/off", "switch to operator/minimal/tactical/cyber layout") use set_hud. Detection requests ("detection on", "dense mode", "balanced mode", "sparse mode", "set density to 25", "use weighted allocation") use set_detection. Density snaps to 0/25/50/75/100 and derives Sparse/Balanced/Dense; panoptic is a legacy alias for Dense.',
|
||||
'Cyber sonar requests use set_cyber_sonar: enabled, rings, rangePct, intensityPct (Power), opacityPct, sectorDeg. For settings questions read get_current_view_state.controls.hud.sonar. Sonar is not detection. Do not silently switch themes, reveal the HUD or enable sonar when changing a slider. If the user explicitly requests Cyber plus sonar settings, call set_hud first, wait for success, then set_cyber_sonar. Confirm returned values; mapSweepActive and contactSweepActive distinguish saved settings from active map effects, including in Cockpit or reduced motion. Do not claim visible contacts solely because enabled is true.',
|
||||
'Bloom/sharpen requests use set_post_processing. Scene requests ("play orbital watch", "stop the scene", "what scenes are there") use control_scene. CCTV camera requests ("next camera", "nearest camera", "select the Congress camera", "show coverage") use control_cctv — the CCTV layer must be enabled first.',
|
||||
'Radio playback requests use control_radio. "Turn on/start the radio" means action=play; action=enable only reveals Radio markers and must be reserved for explicit "show/enable the Radio layer/markers" requests. After a prepared playback result, briefly confirm any other completed actions and say "Turning on the radio"—never claim it is already playing. The client keeps Radio muted until playback is verified, then closes voice before restoring Radio volume. Examples: "play news near Austin" → select category=news locationId=austin; "play US news" → select category=news country=US; "Radio volume 30" → volume; pause/resume/stop/next/previous use the matching action. Radio selection never moves the camera.',
|
||||
'"Track/follow <something specific>" (a callsign, ship name, satellite name) uses track_entity. "Take me to the biggest fire" uses track_entity with query "biggest fire" (the fires layer must be enabled). Bare "orbit" means camera orbit of the current landmark. "Stop following/tracking" uses stop_tracking.',
|
||||
|
||||
@@ -132,26 +132,35 @@ function createRealtimeTokenHandler({
|
||||
});
|
||||
const body = await response.text();
|
||||
res.statusCode = response.status;
|
||||
res.setHeader(
|
||||
'Content-Type',
|
||||
response.headers.get('content-type') || 'application/json',
|
||||
);
|
||||
// Which tier/model this secret was actually minted for. The upstream
|
||||
// body is passed through untouched (the client parses it verbatim), so
|
||||
// these headers are the authoritative echo — including the case where a
|
||||
// bogus ?tier= was silently downgraded to standard.
|
||||
// success body is passed through untouched (the client parses it
|
||||
// verbatim), so these headers are the authoritative echo — including the
|
||||
// case where a bogus ?tier= was silently downgraded to standard.
|
||||
res.setHeader('X-GEV-Voice-Tier', tier);
|
||||
res.setHeader('X-GEV-Voice-Model', model);
|
||||
if (requestedTier && !isKnownVoiceTier(requestedTier)) {
|
||||
res.setHeader('X-GEV-Voice-Tier-Fallback', '1');
|
||||
}
|
||||
if (!response.ok) {
|
||||
console.warn(`[realtime-token] upstream HTTP ${response.status}`);
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.end(JSON.stringify({ error: 'Failed to create Realtime token' }));
|
||||
return;
|
||||
}
|
||||
res.setHeader(
|
||||
'Content-Type',
|
||||
response.headers.get('content-type') || 'application/json',
|
||||
);
|
||||
res.end(body);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// For a network fault this was a resolver message naming the upstream
|
||||
// host; the client only needs to know the mint failed.
|
||||
console.warn('[realtime-token] mint failed');
|
||||
res.statusCode = 502;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: error?.message || 'Failed to create Realtime token',
|
||||
error: 'Failed to create Realtime token',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ export const ACTION_DESCRIPTIONS = {
|
||||
properties: {
|
||||
layerId: {
|
||||
description:
|
||||
'Common-name mapping for the non-obvious ids: space mission(s) → rocket-launches; fires/wildfires/active fires → local-firms (NASA FIRMS); ships/vessels/boats → ais-live-vessels; undersea/submarine cables → telegeography-submarine-cables; datacenters → local-datacenters; dams → local-dams; bikes/bike share → bikeshare; street traffic/congestion → traffic; traffic cameras → cctv; internet radio/stations → radio; ALPR/license plate readers/Flock cameras → alpr-cameras.',
|
||||
'Common-name mapping for the non-obvious ids: space mission(s) → rocket-launches; fires/wildfires/active fires → local-firms (NASA FIRMS); ships/vessels/boats → ais-live-vessels; undersea/submarine cables → telegeography-submarine-cables; datacenters → local-datacenters; dams → local-dams; bikes/bike share → bikeshare; street traffic/congestion → traffic; traffic cameras → cctv; internet radio/stations → radio; ALPR/license plate readers/Flock cameras → alpr-cameras; local ADS-B/my receiver/my antenna (aircraft heard by a local RTL-SDR receiver) → local-adsb.',
|
||||
$position: 1,
|
||||
},
|
||||
},
|
||||
@@ -182,6 +182,29 @@ export const ACTION_DESCRIPTIONS = {
|
||||
},
|
||||
},
|
||||
},
|
||||
set_cyber_sonar: {
|
||||
description:
|
||||
'Adjust Cyber-only sonar controls. Requires the Cyber HUD layout; never switches layout or HUD visibility. Omitted fields stay unchanged. Returns actual settings and separate map/contact sweep activity. A saved setting does not mean the effect is active.',
|
||||
$position: 1,
|
||||
parameters: {
|
||||
properties: {
|
||||
enabled: { description: 'Explicitly turn sonar on or off.' },
|
||||
rings: { description: 'Number of decorative sonar rings, 3–12.' },
|
||||
rangePct: {
|
||||
description:
|
||||
'Visual ring range, 60–120 percent; not geographic distance.',
|
||||
},
|
||||
intensityPct: {
|
||||
description: 'Sonar Power slider, 0–100 percent. Zero is valid.',
|
||||
},
|
||||
opacityPct: {
|
||||
description:
|
||||
'Contact opacity floor between passes, 35–100 percent; labels have a derived floor. Not whole-scene dimming.',
|
||||
},
|
||||
sectorDeg: { description: 'Sonar sweep sector width, 8–60 degrees.' },
|
||||
},
|
||||
},
|
||||
},
|
||||
set_detection: {
|
||||
description:
|
||||
'Control the detection overlay: on/off, density-derived Sparse/Balanced/Dense profile, and Elastic/Weighted layer allocation.',
|
||||
@@ -505,7 +528,7 @@ export const ACTION_DESCRIPTIONS = {
|
||||
},
|
||||
analyst_query: {
|
||||
description:
|
||||
'Answer questions ABOUT the data currently loaded on the map — counts, lists, superlatives, and attribute filters over live layers (flights, military, ships, fires, earthquakes). Examples: "how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet", "fastest thing in view". Queries ONLY client-side data from ENABLED layers — if the needed layer is off, say so and offer to enable it. For a follow-up about the previous answer\'s set ("which of those is closest?"), set followUp=true and send only the new filters/sort.',
|
||||
'Answer questions ABOUT the data currently loaded on the map — counts, lists, superlatives, and attribute filters over live layers (flights, military, ships, fires, earthquakes, satellites, datacenters, dams). Examples: "how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet", "fastest thing in view". For satellites and infrastructure, counts and ranks cover only bounded examined loaded records; omitted records can change nearest/count. Queries ONLY client-side data from ENABLED layers — if the needed layer is off, say so and offer to enable it. For a follow-up about the previous answer\'s set ("which of those is closest?"), set followUp=true and send only the new filters/sort.',
|
||||
$position: 1,
|
||||
parameters: {
|
||||
properties: {
|
||||
@@ -549,7 +572,7 @@ export const ACTION_DESCRIPTIONS = {
|
||||
},
|
||||
next_iss_pass: {
|
||||
description:
|
||||
"When the user asks when the ISS / the space station will next fly over: returns the next visible ISS pass for the current camera location (or an explicit lat/lon) — rise time (ISO + minutes from now), rise compass direction, peak elevation, and duration. Requires the satellites layer to have loaded its catalog at least once this session; if it hasn't, tell the user to enable the satellites layer and try again.",
|
||||
"When the user asks when the ISS / the space station will next fly over: returns the next geometric ISS pass with estimated visibility for the current camera location (or an explicit lat/lon) — rise time (ISO + minutes from now), rise compass direction, peak elevation, and duration. Requires the satellites layer to have loaded its catalog at least once this session; if it hasn't, tell the user to enable the satellites layer and try again.",
|
||||
$position: 1,
|
||||
parameters: {
|
||||
properties: {
|
||||
@@ -571,4 +594,9 @@ export const ACTION_DESCRIPTIONS = {
|
||||
},
|
||||
},
|
||||
},
|
||||
next_satellite_pass: {
|
||||
description:
|
||||
'Predict the next pass in 24 hours for one satellite in the loaded catalog, identified by exact NORAD ID or name. Ambiguous names return candidates: ask for a choice rather than selecting one. Defaults to geometric passes; visibleOnly requires estimated illumination and a dark observer sky, which does not guarantee naked-eye visibility. Uses camera location unless coordinates are supplied.',
|
||||
$position: 1,
|
||||
},
|
||||
};
|
||||
@@ -23,9 +23,16 @@ import {
|
||||
* Budget governor (mirrors the OpenSky credit-governor philosophy — last-good
|
||||
* data beats a dead layer): a persistent counter (.gev-cache/tomtom/budget.json,
|
||||
* keyed by UTC date, reset on day change) counts upstream fetch attempts
|
||||
* against a soft cap (TOMTOM_DAILY_TILE_BUDGET, default 40,000 of the free
|
||||
* tier's ~50k/day). Over the cap the proxy serves stale tiles when available,
|
||||
* else 429 {error:'budget'}.
|
||||
* against a soft cap (TOMTOM_DAILY_TILE_BUDGET). Over the cap the proxy
|
||||
* serves stale tiles when available, else 429 {error:'budget'}.
|
||||
*
|
||||
* The default is derived from TomTom's published free allowance, which is
|
||||
* granted MONTHLY, not daily: 200,000 Traffic Flow & Incidents tile requests
|
||||
* per month (https://docs.tomtom.com/pricing/). The previous 40,000/day
|
||||
* default — and the "~50k/day" figure this comment used to cite — exhausted
|
||||
* a month's entire allowance in five days, leaving the traffic layer dead
|
||||
* for the rest of the billing period. 6,000/day keeps a full 31-day month
|
||||
* inside the allowance (186,000) with headroom to spare.
|
||||
*
|
||||
* GET /api/tomtom/status → {hasKey, dailyCount, budget, date}. Keyless mode:
|
||||
* status reports hasKey:false and the tile endpoint 503s {error:'no_key'}
|
||||
@@ -37,7 +44,8 @@ export function tomtomProxy() {
|
||||
const TILE_TTL_MS = 120_000;
|
||||
const CACHE_DIR = path.join(process.cwd(), '.gev-cache', 'tomtom');
|
||||
const BUDGET_PATH = path.join(CACHE_DIR, 'budget.json');
|
||||
const DEFAULT_DAILY_BUDGET = 40000;
|
||||
// 200,000/month free tier / 31 days = 6,451. Rounded down for headroom.
|
||||
const DEFAULT_DAILY_BUDGET = 6000;
|
||||
const MEM_MAX_ENTRIES = 256;
|
||||
const UPSTREAM_TIMEOUT_MS = 15000;
|
||||
|
||||
|
||||
@@ -72,8 +72,8 @@ export function ingestAisStreamEnvelope(envelope) {
|
||||
imo: stringValue(message.ImoNumber ?? message.IMO ?? staticData.imo),
|
||||
type: vesselTypeFromAis(message, staticData),
|
||||
destination: stringValue(message.Destination ?? staticData.destination),
|
||||
speed: numberValue(message.Sog ?? message.SOG),
|
||||
course: numberValue(message.Cog ?? message.COG),
|
||||
speed: normalizedSpeedOverGround(message.Sog ?? message.SOG),
|
||||
course: normalizedCourseOverGround(message.Cog ?? message.COG),
|
||||
heading: normalizedHeading(message.TrueHeading ?? message.Heading),
|
||||
last_position_UTC: normalizeAisTimestamp(
|
||||
metadata.time_utc ?? metadata.TimeUtc,
|
||||
@@ -277,6 +277,28 @@ function normalizedHeading(value) {
|
||||
return heading !== null && heading >= 0 && heading <= 360 ? heading : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Speed over ground in knots, or null when the report carries AIS's "not
|
||||
* available" code. ITU-R M.1371 encodes SOG in 0.1-knot units where 1022 means
|
||||
* "102.2 knots or higher" and 1023 means unavailable, so 102.3 is a sentinel
|
||||
* and not a reading. It is finite, so every consumer would otherwise treat it
|
||||
* as a genuine speed.
|
||||
*/
|
||||
function normalizedSpeedOverGround(value) {
|
||||
const speed = numberValue(value);
|
||||
return speed !== null && speed >= 0 && speed < 102.3 ? speed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Course over ground in degrees, or null when the report carries AIS's "not
|
||||
* available" code (3600 in 0.1-degree units, i.e. 360). Valid courses stop at
|
||||
* 359.9; heading keeps its own separate sentinel of 511.
|
||||
*/
|
||||
function normalizedCourseOverGround(value) {
|
||||
const course = numberValue(value);
|
||||
return course !== null && course >= 0 && course < 360 ? course : null;
|
||||
}
|
||||
|
||||
function normalizeAisTimestamp(value) {
|
||||
const text = stringValue(value);
|
||||
if (!text) return new Date().toISOString();
|
||||
|
||||
@@ -0,0 +1,610 @@
|
||||
import { readResponseTextCapped } from './common/http.js';
|
||||
import { readWindBody as readBytesCapped } from '../../src/sources/windBody.js';
|
||||
|
||||
const BASE = 'https://nowcoast.noaa.gov/geoserver/observations/';
|
||||
const HOUR = 3600_000;
|
||||
const PRODUCTS = Object.freeze({
|
||||
lightning: Object.freeze({
|
||||
service: 'lightning_detection',
|
||||
layer: 'ldn_lightning_strike_density',
|
||||
style: 'lightning_density',
|
||||
title: 'Lightning density · 15 min',
|
||||
coverage:
|
||||
'Pacific and Americas: 110°E across the dateline to 0°, 25°S–80°N; not global coverage.',
|
||||
description:
|
||||
'Observed 15-minute lightning strike density on an approximately 8 km grid, scaled as strikes/km²/min ×10³. Ground-network density, not individual GLM flashes.',
|
||||
attribution: 'NOAA/NWS nowCOAST; derived from Vaisala NLDN/GLD360',
|
||||
metadataTtlMs: 600_000,
|
||||
image: Object.freeze({ width: 4096, height: 2048 }),
|
||||
}),
|
||||
radar: Object.freeze({
|
||||
service: 'weather_radar',
|
||||
layer: 'conus_base_reflectivity_mosaic',
|
||||
style: 'weather_radar_base_reflectivity',
|
||||
title: 'CONUS radar reflectivity',
|
||||
coverage:
|
||||
'Contiguous United States; gaps do not establish absence of precipitation.',
|
||||
description:
|
||||
'Observed MRMS radar base reflectivity (dBZ), approximately 1 km and 4-minute updates; not a rainfall forecast.',
|
||||
image: Object.freeze({ width: 4096, height: 2048 }),
|
||||
}),
|
||||
clouds: Object.freeze({
|
||||
service: 'satellite',
|
||||
layer: 'global_longwave_imagery_mosaic',
|
||||
style: 'reflectance',
|
||||
title: 'Global satellite infrared',
|
||||
coverage:
|
||||
'Global mosaic with incomplete polar coverage; nominal coverage 60°S–60°N.',
|
||||
description:
|
||||
'Longwave infrared cloud and land/sea temperature patterns, approximately 3 km; hourly updates with 2–3 hour source latency. Not a cloud-only mask.',
|
||||
image: Object.freeze({ width: 2048, height: 1024 }),
|
||||
}),
|
||||
'clouds-regional': Object.freeze({
|
||||
service: 'satellite',
|
||||
layer: 'goes_longwave_imagery',
|
||||
style: 'goes-lir',
|
||||
title: 'GOES regional satellite infrared',
|
||||
coverage:
|
||||
'GOES East/West regional North American coverage; not a global image.',
|
||||
description:
|
||||
'GOES-19/18 longwave infrared Band 14 cloud and surface temperature patterns, approximately 2 km and 5-minute updates. Not a cloud-only mask.',
|
||||
image: Object.freeze({ width: 4096, height: 2048 }),
|
||||
}),
|
||||
});
|
||||
|
||||
// Whole-extent images: 2:1 sizes up to each product's largest (the default).
|
||||
const IMAGE_SIZES = Object.freeze(['1024x512', '2048x1024', '4096x2048']);
|
||||
// Detail windows: any product up to 4096×2048 (the default).
|
||||
const DETAIL_IMAGE = Object.freeze({ width: 4096, height: 2048 });
|
||||
const BBOX_STEP = 0.25;
|
||||
const MAX_IMAGE_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
function failure(code, status = 503) {
|
||||
return Object.assign(new Error(code), { code, status });
|
||||
}
|
||||
|
||||
/** Canonicalize only explicit UTC observations; never expand time intervals. */
|
||||
function observationTime(value) {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value)
|
||||
)
|
||||
return null;
|
||||
const date = new Date(value);
|
||||
if (!Number.isFinite(date.getTime())) return null;
|
||||
const canonical = date.toISOString();
|
||||
return canonical.replace('.000Z', 'Z') === value.replace('.000Z', 'Z')
|
||||
? canonical
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Read the named leaf from bounded capabilities without resolving XML entities. */
|
||||
export function parseWeatherCapabilities(xml, product, nowMs = Date.now()) {
|
||||
const spec = PRODUCTS[product];
|
||||
if (
|
||||
!spec ||
|
||||
typeof xml !== 'string' ||
|
||||
xml.length > 512 * 1024 ||
|
||||
/<!DOCTYPE|<!ENTITY/i.test(xml)
|
||||
)
|
||||
throw failure('invalid_weather_metadata');
|
||||
const stack = [];
|
||||
let leaf = null;
|
||||
let tags = 0;
|
||||
for (const match of xml.matchAll(/<\/?(?:[\w.-]+:)?Layer\b[^>]*>/g)) {
|
||||
if (++tags > 2048) throw failure('invalid_weather_metadata');
|
||||
if (!match[0].startsWith('</')) {
|
||||
if (stack.length) stack.at(-1).nested = true;
|
||||
if (stack.length >= 16 || match[0].endsWith('/>'))
|
||||
throw failure('invalid_weather_metadata');
|
||||
stack.push({ start: match.index + match[0].length, nested: false });
|
||||
} else {
|
||||
const opened = stack.pop();
|
||||
if (!opened) throw failure('invalid_weather_metadata');
|
||||
if (opened.nested) continue;
|
||||
const candidate = xml.slice(opened.start, match.index);
|
||||
const name = candidate
|
||||
.match(/<(?:[\w.-]+:)?Name\s*>([^<]+)<\/(?:[\w.-]+:)?Name>/)?.[1]
|
||||
?.trim();
|
||||
if (name === spec.layer) {
|
||||
if (leaf !== null) throw failure('invalid_weather_metadata');
|
||||
leaf = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (stack.length || !leaf) throw failure('invalid_weather_metadata');
|
||||
const box = leaf.match(
|
||||
/<(?:[\w.-]+:)?EX_GeographicBoundingBox\s*>([\s\S]*?)<\/(?:[\w.-]+:)?EX_GeographicBoundingBox>/,
|
||||
)?.[1];
|
||||
const bounds = {};
|
||||
for (const [key, tag] of Object.entries({
|
||||
west: 'westBoundLongitude',
|
||||
south: 'southBoundLatitude',
|
||||
east: 'eastBoundLongitude',
|
||||
north: 'northBoundLatitude',
|
||||
})) {
|
||||
const value = box
|
||||
?.match(
|
||||
new RegExp(`<(?:[\\w.-]+:)?${tag}\\s*>([^<]+)</(?:[\\w.-]+:)?${tag}>`),
|
||||
)?.[1]
|
||||
?.trim();
|
||||
bounds[key] = value ? Number(value) : NaN;
|
||||
}
|
||||
if (
|
||||
!Object.values(bounds).every(Number.isFinite) ||
|
||||
bounds.west < -180 ||
|
||||
bounds.east > 180 ||
|
||||
bounds.south < -90 ||
|
||||
bounds.north > 90 ||
|
||||
bounds.west >= bounds.east ||
|
||||
bounds.south >= bounds.north
|
||||
)
|
||||
throw failure('invalid_weather_metadata');
|
||||
const dimensions = [
|
||||
...leaf.matchAll(
|
||||
/<(?:[\w.-]+:)?Dimension\b([^>]*)>([^<]*)<\/(?:[\w.-]+:)?Dimension>/g,
|
||||
),
|
||||
].filter((entry) => /\bname\s*=\s*["']time["']/.test(entry[1]));
|
||||
if (
|
||||
dimensions.length !== 1 ||
|
||||
!/\bunits\s*=\s*["']ISO8601["']/.test(dimensions[0][1])
|
||||
)
|
||||
throw failure('invalid_weather_metadata');
|
||||
const raw = dimensions[0][2].trim().split(',');
|
||||
if (!raw.length || raw.length > 512)
|
||||
throw failure('invalid_weather_metadata');
|
||||
const times = raw.map((value) => observationTime(value.trim()));
|
||||
if (times.some((value) => !value || Date.parse(value) > nowMs + 5 * 60_000))
|
||||
throw failure('invalid_weather_metadata');
|
||||
const defaultTime = observationTime(
|
||||
dimensions[0][1].match(/\bdefault\s*=\s*["']([^"']+)["']/)?.[1],
|
||||
);
|
||||
if (!defaultTime || !times.includes(defaultTime))
|
||||
throw failure('invalid_weather_metadata');
|
||||
const recent = [...new Set(times)]
|
||||
.filter((value) => nowMs - Date.parse(value) <= 24 * HOUR)
|
||||
.sort()
|
||||
.slice(-26);
|
||||
if (!recent.length) throw failure('weather_observations_expired');
|
||||
return { bounds, times: recent.slice(-13), allowedTimes: recent };
|
||||
}
|
||||
|
||||
/** A detail window `west,south,east,north` in degrees, rounded to 0.25° so cache
|
||||
* keys repeat, with a 2:1 aspect within 1 %; null when absent. Containment in
|
||||
* the product bounds is checked once the metadata is known. */
|
||||
export function weatherImageBbox(value) {
|
||||
if (value === null) return null;
|
||||
const parts = value.split(',');
|
||||
if (
|
||||
parts.length !== 4 ||
|
||||
parts.some((part) => !/^-?\d{1,3}(?:\.\d{1,6})?$/.test(part))
|
||||
)
|
||||
throw failure('invalid_weather_bbox', 400);
|
||||
// `+ 0` turns a rounded -0 into 0 so equal windows share one key.
|
||||
const [west, south, east, north] = parts.map(
|
||||
(part) => Math.round(Number(part) / BBOX_STEP) * BBOX_STEP + 0,
|
||||
);
|
||||
if (
|
||||
west < -180 ||
|
||||
east > 180 ||
|
||||
south < -90 ||
|
||||
north > 90 ||
|
||||
west >= east ||
|
||||
south >= north ||
|
||||
Math.abs((east - west) / (north - south) / 2 - 1) > 0.01
|
||||
)
|
||||
throw failure('invalid_weather_bbox', 400);
|
||||
return [west, south, east, north];
|
||||
}
|
||||
|
||||
/** GeographicTilingScheme's two longitude tiles and one latitude tile at level zero. */
|
||||
export function weatherTileBounds(z, x, y) {
|
||||
if (
|
||||
![z, x, y].every(Number.isInteger) ||
|
||||
z < 0 ||
|
||||
z > 6 ||
|
||||
x < 0 ||
|
||||
y < 0 ||
|
||||
x >= 2 ** (z + 1) ||
|
||||
y >= 2 ** z
|
||||
)
|
||||
throw failure('invalid_weather_tile', 400);
|
||||
const span = 180 / 2 ** z;
|
||||
return [
|
||||
-180 + x * span,
|
||||
90 - (y + 1) * span,
|
||||
-180 + (x + 1) * span,
|
||||
90 - y * span,
|
||||
];
|
||||
}
|
||||
|
||||
function validatePng(bytes, width, height) {
|
||||
const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
if (
|
||||
buffer.length < 33 ||
|
||||
!buffer
|
||||
.subarray(0, 8)
|
||||
.equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) ||
|
||||
buffer.readUInt32BE(8) !== 13 ||
|
||||
buffer.toString('ascii', 12, 16) !== 'IHDR' ||
|
||||
buffer.readUInt32BE(16) !== width ||
|
||||
buffer.readUInt32BE(20) !== height
|
||||
)
|
||||
throw failure('invalid_weather_image');
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/** Fixed NOAA observed-weather metadata and WMS tiles; no client-supplied destinations. */
|
||||
export function weatherProxy({
|
||||
fetchImpl = fetch,
|
||||
now = () => Date.now(),
|
||||
timeoutMs = 12_000,
|
||||
} = {}) {
|
||||
const metadata = new Map();
|
||||
const attempts = new Map();
|
||||
const operations = new Map();
|
||||
const pending = [];
|
||||
const tiles = new Map();
|
||||
const tileFailures = new Map();
|
||||
let tileBytes = 0;
|
||||
let active = 0;
|
||||
|
||||
function pump() {
|
||||
while (active < 8 && pending.length) {
|
||||
const operation = pending.shift();
|
||||
if (operation.controller.signal.aborted) continue;
|
||||
active++;
|
||||
operation.started = true;
|
||||
Promise.resolve()
|
||||
.then(() => operation.work(operation.controller.signal))
|
||||
.then(operation.resolve, operation.reject)
|
||||
.finally(() => {
|
||||
active--;
|
||||
pump();
|
||||
});
|
||||
}
|
||||
}
|
||||
async function shared(key, work, clientSignal) {
|
||||
clientSignal.throwIfAborted();
|
||||
let operation = operations.get(key);
|
||||
if (operation?.controller.signal.aborted) {
|
||||
operations.delete(key);
|
||||
operation = null;
|
||||
}
|
||||
if (!operation) {
|
||||
if (operations.size >= 120 || pending.length >= 96)
|
||||
throw failure('weather_busy', 429);
|
||||
const controller = new AbortController();
|
||||
operation = { controller, work, waiters: 0, started: false };
|
||||
operation.promise = new Promise((resolve, reject) => {
|
||||
operation.resolve = resolve;
|
||||
operation.reject = reject;
|
||||
});
|
||||
const cancel = () => {
|
||||
const index = pending.indexOf(operation);
|
||||
if (index >= 0) pending.splice(index, 1);
|
||||
operation.reject(failure('weather_request_cancelled'));
|
||||
};
|
||||
controller.signal.addEventListener('abort', cancel, { once: true });
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
operation.promise = operation.promise.finally(() => {
|
||||
clearTimeout(timer);
|
||||
controller.signal.removeEventListener('abort', cancel);
|
||||
if (operations.get(key) === operation) operations.delete(key);
|
||||
});
|
||||
operations.set(key, operation);
|
||||
pending.push(operation);
|
||||
pump();
|
||||
}
|
||||
operation.waiters++;
|
||||
const abort = () => {
|
||||
if (--operation.waiters === 0) operation.controller.abort();
|
||||
};
|
||||
clientSignal.addEventListener('abort', abort, { once: true });
|
||||
try {
|
||||
const value = await operation.promise;
|
||||
clientSignal.throwIfAborted();
|
||||
return value;
|
||||
} finally {
|
||||
clientSignal.removeEventListener('abort', abort);
|
||||
if (!clientSignal.aborted) operation.waiters--;
|
||||
}
|
||||
}
|
||||
async function upstream(url, signal, image = null) {
|
||||
signal.throwIfAborted();
|
||||
const response = await fetchImpl(url, {
|
||||
signal,
|
||||
redirect: 'error',
|
||||
headers: { Accept: image ? 'image/png' : 'application/xml,text/xml' },
|
||||
});
|
||||
if (
|
||||
!response.ok ||
|
||||
(image &&
|
||||
!/^image\/png(?:;|$)/i.test(response.headers.get('content-type') || ''))
|
||||
) {
|
||||
await response.body?.cancel();
|
||||
throw failure('weather_upstream_unavailable');
|
||||
}
|
||||
const value = image
|
||||
? validatePng(
|
||||
await readBytesCapped(response, image.maxBytes, signal),
|
||||
image.width,
|
||||
image.height,
|
||||
)
|
||||
: await readResponseTextCapped(response, 512 * 1024, signal);
|
||||
signal.throwIfAborted();
|
||||
return value;
|
||||
}
|
||||
async function getMetadata(product, signal) {
|
||||
signal.throwIfAborted();
|
||||
const old = metadata.get(product);
|
||||
const spec = PRODUCTS[product];
|
||||
if (old && now() - old.fetchedAt < (spec.metadataTtlMs ?? 120_000))
|
||||
return { ...old, stale: false };
|
||||
try {
|
||||
if (
|
||||
now() - (attempts.get(product) ?? -Infinity) < 30_000 &&
|
||||
!operations.has(`metadata:${spec.service}`)
|
||||
)
|
||||
throw failure('weather_upstream_unavailable');
|
||||
attempts.set(product, now());
|
||||
const url = `${BASE}${spec.service}/ows?service=WMS&version=1.3.0&request=GetCapabilities`;
|
||||
const xml = await shared(
|
||||
`metadata:${spec.service}`,
|
||||
(activeSignal) => upstream(url, activeSignal),
|
||||
signal,
|
||||
);
|
||||
const value = {
|
||||
...parseWeatherCapabilities(xml, product, now()),
|
||||
fetchedAt: now(),
|
||||
};
|
||||
metadata.set(product, value);
|
||||
return { ...value, stale: false };
|
||||
} catch (error) {
|
||||
if (signal.aborted) attempts.delete(product);
|
||||
signal.throwIfAborted();
|
||||
if (error.status === 429) throw error;
|
||||
if (old && now() - old.fetchedAt <= HOUR) return { ...old, stale: true };
|
||||
throw failure('weather_metadata_unavailable');
|
||||
}
|
||||
}
|
||||
function rememberTile(key, bytes) {
|
||||
if (tiles.has(key)) tileBytes -= tiles.get(key).bytes.length;
|
||||
tiles.delete(key);
|
||||
while (tiles.size >= 128 || tileBytes + bytes.length > 16 * 1024 * 1024) {
|
||||
const oldest = tiles.keys().next().value;
|
||||
tileBytes -= tiles.get(oldest).bytes.length;
|
||||
tiles.delete(oldest);
|
||||
}
|
||||
tiles.set(key, { bytes, at: now() });
|
||||
tileBytes += bytes.length;
|
||||
}
|
||||
function describe(product, value) {
|
||||
const spec = PRODUCTS[product];
|
||||
const time = value?.times.at(-1) ?? null;
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
product,
|
||||
title: spec.title,
|
||||
coverage: spec.coverage,
|
||||
description: spec.description,
|
||||
source: 'NOAA nowCOAST',
|
||||
attribution: spec.attribution ?? 'NOAA/NWS/NESDIS nowCOAST',
|
||||
bounds: value?.bounds ?? null,
|
||||
times: value?.times ?? [],
|
||||
latest: time,
|
||||
time,
|
||||
observedAt: time,
|
||||
fetchedAt: value?.fetchedAt ?? null,
|
||||
stale: value?.stale ?? true,
|
||||
unavailable: !value,
|
||||
reason: !value
|
||||
? 'Weather imagery unavailable'
|
||||
: value.stale
|
||||
? 'Cached weather metadata; upstream unavailable'
|
||||
: null,
|
||||
tileSize: 256,
|
||||
maxLevel: 6,
|
||||
tilingScheme: 'geographic',
|
||||
tileTemplate: time
|
||||
? `/api/weather/tile?product=${product}&time=${encodeURIComponent(time)}&z={z}&x={x}&y={y}`
|
||||
: null,
|
||||
imageUrl: time
|
||||
? `/api/weather/image?product=${product}&time=${encodeURIComponent(time)}`
|
||||
: null,
|
||||
imageSize: { ...spec.image },
|
||||
};
|
||||
}
|
||||
function json(res, status, body) {
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
...(status === 429 ? { 'Retry-After': '2' } : {}),
|
||||
});
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
async function handler(req, res) {
|
||||
const controller = new AbortController();
|
||||
const close = () => controller.abort();
|
||||
res.once?.('close', close);
|
||||
try {
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
if (req.method !== 'GET')
|
||||
return json(res, 405, { error: 'method_not_allowed' });
|
||||
if (!['/manifest', '/tile', '/image'].includes(url.pathname))
|
||||
return json(res, 404, { error: 'not_found' });
|
||||
const allowed =
|
||||
url.pathname === '/manifest'
|
||||
? ['product']
|
||||
: url.pathname === '/image'
|
||||
? ['product', 'time', 'size', 'bbox']
|
||||
: ['product', 'time', 'z', 'x', 'y', 'size'];
|
||||
if (
|
||||
req.url.length > 512 ||
|
||||
[...url.searchParams.keys()].some(
|
||||
(key) =>
|
||||
!allowed.includes(key) || url.searchParams.getAll(key).length !== 1,
|
||||
)
|
||||
)
|
||||
return json(res, 400, { error: 'invalid_weather_query' });
|
||||
const product = url.searchParams.get('product');
|
||||
if (!Object.hasOwn(PRODUCTS, product))
|
||||
return json(res, 400, { error: 'unknown_weather_product' });
|
||||
const wholeImage = url.pathname === '/image';
|
||||
const detailBox = wholeImage
|
||||
? weatherImageBbox(url.searchParams.get('bbox'))
|
||||
: null;
|
||||
const largest = detailBox ? DETAIL_IMAGE : PRODUCTS[product].image;
|
||||
const size =
|
||||
url.searchParams.get('size') ??
|
||||
(wholeImage ? `${largest.width}x${largest.height}` : '256');
|
||||
if (
|
||||
wholeImage
|
||||
? !IMAGE_SIZES.includes(size) ||
|
||||
Number.parseInt(size, 10) > largest.width
|
||||
: !['256', '512', '1024'].includes(size)
|
||||
)
|
||||
throw failure(
|
||||
wholeImage
|
||||
? 'invalid_weather_image_size'
|
||||
: 'invalid_weather_tile_size',
|
||||
400,
|
||||
);
|
||||
if (url.pathname === '/manifest') {
|
||||
try {
|
||||
const value = await getMetadata(product, controller.signal);
|
||||
controller.signal.throwIfAborted();
|
||||
return json(res, 200, describe(product, value));
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
if (error.status === 429) throw error;
|
||||
return json(res, 200, describe(product, null));
|
||||
}
|
||||
}
|
||||
const coords = wholeImage
|
||||
? null
|
||||
: ['z', 'x', 'y'].map((key) => url.searchParams.get(key));
|
||||
if (coords?.some((value) => !/^(?:0|[1-9]\d{0,2})$/.test(value ?? '')))
|
||||
throw failure('invalid_weather_tile', 400);
|
||||
const tileBounds = coords
|
||||
? weatherTileBounds(...coords.map(Number))
|
||||
: null;
|
||||
const time = url.searchParams.get('time');
|
||||
if (!time || observationTime(time) !== time)
|
||||
throw failure('invalid_weather_time', 400);
|
||||
const value = await getMetadata(product, controller.signal);
|
||||
controller.signal.throwIfAborted();
|
||||
if (
|
||||
!value.allowedTimes.includes(time) ||
|
||||
now() - Date.parse(time) > 24 * HOUR
|
||||
)
|
||||
throw failure('unknown_weather_time', 400);
|
||||
if (
|
||||
detailBox &&
|
||||
(detailBox[0] < value.bounds.west ||
|
||||
detailBox[1] < value.bounds.south ||
|
||||
detailBox[2] > value.bounds.east ||
|
||||
detailBox[3] > value.bounds.north)
|
||||
)
|
||||
throw failure('invalid_weather_bbox', 400);
|
||||
// A single advertised extent keeps NOAA's global reflectance contrast
|
||||
// consistent across the image; independently normalized tiles create seams.
|
||||
const bbox =
|
||||
detailBox ??
|
||||
(wholeImage
|
||||
? [
|
||||
value.bounds.west,
|
||||
value.bounds.south,
|
||||
value.bounds.east,
|
||||
value.bounds.north,
|
||||
]
|
||||
: tileBounds);
|
||||
const [width, height] = wholeImage
|
||||
? size.split('x').map(Number)
|
||||
: [Number(size), Number(size)];
|
||||
const imageShape = {
|
||||
width,
|
||||
height,
|
||||
maxBytes: wholeImage
|
||||
? MAX_IMAGE_BYTES
|
||||
: Math.max(1024 * 1024, width ** 2 * 4 + 65_536),
|
||||
};
|
||||
// NOAA WMS capabilities advertise no MaxWidth/MaxHeight. One GetMap
|
||||
// returns a whole 4096×2048 extent or window, or a 1024 px tile, without
|
||||
// composition. Images are keyed by product, time, size and bbox.
|
||||
const key = `${product}:${time}:${wholeImage ? `image:${size}:${bbox.join(',')}` : `tile:${size}:${coords.join('/')}`}`;
|
||||
let cached = tiles.get(key);
|
||||
if (cached && now() - cached.at <= 24 * HOUR) {
|
||||
tiles.delete(key);
|
||||
tiles.set(key, cached);
|
||||
} else {
|
||||
// Never ask nearestValue=1 WMS to silently replace an expired frame.
|
||||
if (value.stale) throw failure('weather_metadata_stale');
|
||||
if (now() - (tileFailures.get(key) ?? -Infinity) < 30_000)
|
||||
throw failure('weather_upstream_unavailable');
|
||||
const spec = PRODUCTS[product];
|
||||
const upstreamUrl = new URL(`${BASE}${spec.service}/ows`);
|
||||
upstreamUrl.search = new URLSearchParams({
|
||||
service: 'WMS',
|
||||
version: '1.1.1',
|
||||
request: 'GetMap',
|
||||
layers: spec.layer,
|
||||
styles: spec.style,
|
||||
srs: 'EPSG:4326',
|
||||
bbox: bbox.join(','),
|
||||
width: String(imageShape.width),
|
||||
height: String(imageShape.height),
|
||||
format: 'image/png',
|
||||
transparent: 'true',
|
||||
time,
|
||||
}).toString();
|
||||
try {
|
||||
const bytes = await shared(
|
||||
`image:${key}`,
|
||||
(signal) => upstream(upstreamUrl.href, signal, imageShape),
|
||||
controller.signal,
|
||||
);
|
||||
rememberTile(key, bytes);
|
||||
cached = { bytes };
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted && error.status !== 429) {
|
||||
tileFailures.delete(key);
|
||||
tileFailures.set(key, now());
|
||||
while (tileFailures.size > 128)
|
||||
tileFailures.delete(tileFailures.keys().next().value);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'image/png',
|
||||
'Cache-Control': 'public, max-age=86400, immutable',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
});
|
||||
res.end(cached.bytes);
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted)
|
||||
json(
|
||||
res,
|
||||
error.status === 400 || error.status === 429 ? error.status : 503,
|
||||
{
|
||||
error:
|
||||
error.status === 400 || error.status === 429
|
||||
? error.code
|
||||
: 'weather_upstream_unavailable',
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
res.removeListener?.('close', close);
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: 'weather',
|
||||
configureServer({ middlewares }) {
|
||||
middlewares.use('/api/weather', handler);
|
||||
},
|
||||
configurePreviewServer({ middlewares }) {
|
||||
middlewares.use('/api/weather', handler);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { fetchGfsWind } from './wind/gfs.js';
|
||||
import { fetchIfsWind } from './wind/ifs.js';
|
||||
import { decodeWindGribMessage } from './wind/decode.js';
|
||||
import { weatherScalarMetadata, weatherScalarError } from './wind/grid.js';
|
||||
|
||||
/** Serve bounded, single-flight, per-model/overlay forecast snapshots from fixed providers. */
|
||||
export function windProxy({
|
||||
fetchImpl = fetch,
|
||||
now = () => Date.now(),
|
||||
decodeImpl = decodeWindGribMessage,
|
||||
targetDx = 1,
|
||||
ttlMs = 3600_000,
|
||||
timeoutMs = 40_000,
|
||||
models = { gfs: fetchGfsWind, ifs: fetchIfsWind },
|
||||
} = {}) {
|
||||
// Request validation limits these maps to 2 models × 3 overlays.
|
||||
const caches = new Map();
|
||||
const loadings = new Map();
|
||||
const grids = new Map();
|
||||
const attempts = new Map();
|
||||
const unavailable = (model, overlay) => ({
|
||||
manifest: {
|
||||
model,
|
||||
schemaVersion: 1,
|
||||
...(overlay === 'none' ? {} : { overlay }),
|
||||
unavailable: true,
|
||||
stale: true,
|
||||
reason: 'Wind upstream unavailable',
|
||||
},
|
||||
});
|
||||
const sendJson = (res, value, status = 200) => {
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(JSON.stringify(value));
|
||||
};
|
||||
function refresh(model, overlay, key) {
|
||||
const controller = new AbortController();
|
||||
const operation = { controller, waiters: 0, promise: null };
|
||||
loadings.set(key, operation);
|
||||
attempts.set(key, now());
|
||||
operation.promise = (async () => {
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const value = await models[model]({
|
||||
fetchImpl,
|
||||
now,
|
||||
decodeImpl,
|
||||
targetDx,
|
||||
overlay,
|
||||
signal: controller.signal,
|
||||
});
|
||||
controller.signal.throwIfAborted();
|
||||
const { grid, cycle } = value;
|
||||
const count = grid.nx * grid.ny;
|
||||
const expectedScalar = weatherScalarMetadata(overlay);
|
||||
const missingScalar =
|
||||
expectedScalar &&
|
||||
value.scalar === undefined &&
|
||||
value.scalarError === weatherScalarError(overlay);
|
||||
const scalar = missingScalar ? null : expectedScalar;
|
||||
if (
|
||||
!Number.isInteger(grid.nx) ||
|
||||
!Number.isInteger(grid.ny) ||
|
||||
grid.nx < 1 ||
|
||||
grid.ny < 1 ||
|
||||
count > 1_000_000 ||
|
||||
![grid.lo1, grid.la1, grid.dx, grid.dy].every(Number.isFinite) ||
|
||||
grid.dx <= 0 ||
|
||||
grid.dy <= 0 ||
|
||||
(scalar
|
||||
? ['kind', 'units', 'level'].some(
|
||||
(field) => value.scalar?.[field] !== scalar[field],
|
||||
)
|
||||
: value.scalar !== undefined || grid.scalar !== undefined) ||
|
||||
(!expectedScalar && value.scalarError !== undefined) ||
|
||||
(scalar && value.scalarError !== undefined)
|
||||
)
|
||||
throw new Error('Invalid wind grid');
|
||||
for (const values of [
|
||||
grid.u,
|
||||
grid.v,
|
||||
...(scalar ? [grid.scalar] : []),
|
||||
]) {
|
||||
if (
|
||||
!(values instanceof Float32Array) ||
|
||||
values.length !== count ||
|
||||
!values.every(Number.isFinite)
|
||||
)
|
||||
throw new Error('Invalid wind grid');
|
||||
}
|
||||
const suffix =
|
||||
overlay === 'none'
|
||||
? ''
|
||||
: `-${overlay}${missingScalar ? '-wind-only' : ''}`;
|
||||
const query = `model=${model}${overlay === 'none' ? '' : `&overlay=${overlay}`}`;
|
||||
const id = `${model}-${cycle.date}-${cycle.hour}-f${cycle.forecastHour || 0}-${targetDx}${suffix}`;
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
model,
|
||||
cycle,
|
||||
...(expectedScalar ? { overlay } : {}),
|
||||
...(scalar ? { scalar } : {}),
|
||||
...(missingScalar
|
||||
? { scalarError: weatherScalarError(overlay) }
|
||||
: {}),
|
||||
fetchedAt: now(),
|
||||
level: value.level,
|
||||
units: value.units,
|
||||
grid: {
|
||||
nx: grid.nx,
|
||||
ny: grid.ny,
|
||||
lo1: grid.lo1,
|
||||
la1: grid.la1,
|
||||
dx: grid.dx,
|
||||
dy: grid.dy,
|
||||
},
|
||||
stale: false,
|
||||
unavailable: false,
|
||||
reason: null,
|
||||
gridUrl: `/api/wind/grid/${id}.bin?${query}`,
|
||||
};
|
||||
const state = { id, grid, manifest, fetchedAt: now() };
|
||||
caches.set(key, state);
|
||||
// Retain the previous issued grid as well to cover a manifest/grid rollover.
|
||||
const history = grids.get(key) || new Map();
|
||||
history.set(id, state);
|
||||
while (history.size > 2) history.delete(history.keys().next().value);
|
||||
grids.set(key, history);
|
||||
return state;
|
||||
} catch {
|
||||
if (controller.signal.aborted && operation.waiters === 0) {
|
||||
attempts.delete(key);
|
||||
return unavailable(model, overlay);
|
||||
}
|
||||
const old = caches.get(key);
|
||||
if (old) {
|
||||
old.manifest = {
|
||||
...old.manifest,
|
||||
stale: true,
|
||||
reason: 'Wind upstream unavailable',
|
||||
};
|
||||
return old;
|
||||
}
|
||||
return unavailable(model, overlay);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
controller.abort(); // Cancel a sibling request if the other component failed.
|
||||
if (loadings.get(key) === operation) loadings.delete(key);
|
||||
}
|
||||
})();
|
||||
return operation;
|
||||
}
|
||||
const handler = async (req, res) => {
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
const model = url.searchParams.get('model') || 'gfs';
|
||||
const overlay = url.searchParams.get('overlay') || 'none';
|
||||
const key = `${model}:${overlay}`;
|
||||
if (req.method !== 'GET')
|
||||
return sendJson(res, { error: 'method_not_allowed' }, 405);
|
||||
if (!['gfs', 'ifs'].includes(model) || !Object.hasOwn(models, model))
|
||||
return sendJson(res, { error: 'unknown_model' }, 400);
|
||||
if (!['none', 'temperature', 'pressure'].includes(overlay))
|
||||
return sendJson(res, { error: 'unknown_overlay' }, 400);
|
||||
if (url.pathname.startsWith('/grid/')) {
|
||||
const id = url.pathname.slice(6).replace(/\.bin$/, '');
|
||||
const state =
|
||||
url.pathname === `/grid/${id}.bin` && grids.get(key)?.get(id);
|
||||
if (!state) return sendJson(res, { error: 'unknown_grid' }, 404);
|
||||
const components = [
|
||||
state.grid.u,
|
||||
state.grid.v,
|
||||
...(state.grid.scalar ? [state.grid.scalar] : []),
|
||||
];
|
||||
const bytes = Buffer.concat(
|
||||
components.map((values) =>
|
||||
Buffer.from(values.buffer, values.byteOffset, values.byteLength),
|
||||
),
|
||||
);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Cache-Control': 'public, max-age=3600, immutable',
|
||||
});
|
||||
return res.end(bytes);
|
||||
}
|
||||
if (!['/', '/manifest', '/status'].includes(url.pathname))
|
||||
return sendJson(res, { error: 'not_found' }, 404);
|
||||
let state = caches.get(key);
|
||||
if (!state || now() - state.fetchedAt >= ttlMs) {
|
||||
let operation = loadings.get(key);
|
||||
if (!operation && now() - (attempts.get(key) ?? -Infinity) >= 60_000)
|
||||
operation = refresh(model, overlay, key);
|
||||
if (operation) {
|
||||
let disconnected = false;
|
||||
operation.waiters += 1;
|
||||
const close = () => {
|
||||
disconnected = true;
|
||||
if (--operation.waiters === 0) operation.controller.abort();
|
||||
};
|
||||
res.once?.('close', close);
|
||||
try {
|
||||
state = await operation.promise;
|
||||
} finally {
|
||||
res.removeListener?.('close', close);
|
||||
if (!disconnected) operation.waiters -= 1;
|
||||
}
|
||||
if (disconnected) return;
|
||||
}
|
||||
}
|
||||
const manifest = state?.manifest || unavailable(model, overlay).manifest;
|
||||
if (url.pathname === '/status') {
|
||||
const { gridUrl, ...status } = manifest;
|
||||
return sendJson(res, status);
|
||||
}
|
||||
return sendJson(res, manifest);
|
||||
};
|
||||
return {
|
||||
name: 'wind',
|
||||
configureServer({ middlewares }) {
|
||||
middlewares.use('/api/wind', handler);
|
||||
},
|
||||
configurePreviewServer({ middlewares }) {
|
||||
middlewares.use('/api/wind', handler);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export const GFS_BUCKET = 'noaa-gfs-bdp-pds';
|
||||
|
||||
/** Build the public GFS object key. */
|
||||
export function gfsObjectKey({ date, hour, forecastHour = 0 }) {
|
||||
const hh = String(hour).padStart(2, '0');
|
||||
return `gfs.${date}/${hh}/atmos/gfs.t${hh}z.pgrb2.0p25.f${String(forecastHour).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
/** Select the latest GFS cycle that should be available. */
|
||||
export function selectLatestGfsCycle(
|
||||
nowMs,
|
||||
{ availabilityLagMs = 5 * 3600_000 } = {},
|
||||
) {
|
||||
const d = new Date(nowMs - availabilityLagMs);
|
||||
const date = new Date(
|
||||
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()),
|
||||
);
|
||||
const hour = Math.floor(d.getUTCHours() / 6) * 6;
|
||||
date.setUTCHours(hour);
|
||||
return { date: date.toISOString().slice(0, 10).replaceAll('-', ''), hour };
|
||||
}
|
||||
|
||||
/**
|
||||
* Round a time offset to the nearest published GFS forecast step so the field
|
||||
* shown is the one valid closest to now, not always the analysis (f000).
|
||||
* GFS 0.25° is hourly through f120, then every 3 h through f384.
|
||||
* @param {number} hours - Hours elapsed since the cycle run time.
|
||||
* @returns {number} A valid forecast step.
|
||||
*/
|
||||
export function nearestGfsStep(hours) {
|
||||
const value = Math.max(0, Number.isFinite(hours) ? hours : 0);
|
||||
const step = value <= 120 ? Math.round(value) : Math.round(value / 3) * 3;
|
||||
return Math.max(0, Math.min(384, step));
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/** Lazy shared ecCodes WASM instance, created only for an active wind request. */
|
||||
let eccodesPromise = null;
|
||||
function loadEccodes() {
|
||||
eccodesPromise ??= import('@meri-imperiumi/eccodes-wasm')
|
||||
.then((module) => module.createEccodes())
|
||||
.catch((error) => {
|
||||
eccodesPromise = null;
|
||||
throw error;
|
||||
});
|
||||
return eccodesPromise;
|
||||
}
|
||||
|
||||
/** Decode one bounded message in WASM memory without mounting the host filesystem. */
|
||||
export async function decodeWindGribMessage(buffer, { eccodesModule } = {}) {
|
||||
if (buffer.byteLength > 8 * 1024 * 1024)
|
||||
throw new Error('Wind GRIB exceeds byte budget');
|
||||
const eccodes = eccodesModule ?? (await loadEccodes());
|
||||
// Everything after this await is synchronous. A shared virtual file is safe
|
||||
// across U/V and model requests and its storage is released after each decode.
|
||||
const file = '/gev-wind.grib2';
|
||||
let handle = null;
|
||||
try {
|
||||
eccodes.writeFile(file, buffer);
|
||||
handle = eccodes.openGrib(file);
|
||||
const ni = handle.getLong('Ni');
|
||||
const nj = handle.getLong('Nj');
|
||||
if (
|
||||
!Number.isInteger(ni) ||
|
||||
!Number.isInteger(nj) ||
|
||||
ni < 1 ||
|
||||
nj < 1 ||
|
||||
ni * nj > 2_000_000
|
||||
)
|
||||
throw new Error('Invalid wind GRIB geometry');
|
||||
return {
|
||||
ni,
|
||||
nj,
|
||||
lo1: handle.getDouble('longitudeOfFirstGridPointInDegrees'),
|
||||
la1: handle.getDouble('latitudeOfFirstGridPointInDegrees'),
|
||||
di: handle.getDouble('iDirectionIncrementInDegrees'),
|
||||
dj: handle.getDouble('jDirectionIncrementInDegrees'),
|
||||
values: handle.getDoubleArray('values'),
|
||||
shortName: handle.getString('shortName'),
|
||||
level: handle.getLong('level'),
|
||||
units: handle.getString('units'),
|
||||
};
|
||||
} finally {
|
||||
handle?.delete();
|
||||
eccodes.writeFile(file, new Uint8Array(0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { readWindBody } from '../../../src/sources/windBody.js';
|
||||
/**
|
||||
* Parse a GFS `.idx` inventory into ordered message records.
|
||||
*
|
||||
* Each line is `msgNumber:byteOffset:d=YYYYMMDDHH:VAR:LEVEL:...:`. Malformed
|
||||
* lines are skipped; a payload with no valid lines is rejected so callers never
|
||||
* treat an HTML error page as an empty inventory.
|
||||
*
|
||||
* @param {string} text - Raw `.idx` body.
|
||||
* @returns {{ messages: Array<{index: number, offset: number, variable: string, level: string}> }}
|
||||
*/
|
||||
export function parseGfsIdx(text) {
|
||||
const messages = [];
|
||||
for (const line of String(text).split(/\r?\n/)) {
|
||||
if (!line) continue;
|
||||
const parts = line.split(':');
|
||||
if (
|
||||
parts.length < 6 ||
|
||||
!/^\d+$/.test(parts[0]) ||
|
||||
!/^\d+$/.test(parts[1]) ||
|
||||
!parts[3] ||
|
||||
!parts[4]
|
||||
)
|
||||
continue;
|
||||
messages.push({
|
||||
index: Number(parts[0]),
|
||||
offset: Number(parts[1]),
|
||||
variable: parts[3],
|
||||
level: parts[4],
|
||||
});
|
||||
}
|
||||
if (!messages.length) throw new Error('malformed GFS index');
|
||||
return { messages };
|
||||
}
|
||||
|
||||
/**
|
||||
* Byte ranges for the two 10 m wind components. A message runs from its own
|
||||
* offset to one byte before the next message in file order.
|
||||
*
|
||||
* @param {{messages: Array<object>}} parsed - {@link parseGfsIdx} result.
|
||||
* @param {{level?: string}} [options]
|
||||
* @returns {{u: {start: number, end: number}, v: {start: number, end: number}}}
|
||||
*/
|
||||
export function windMessageRanges(
|
||||
parsed,
|
||||
{ level = '10 m above ground', overlay = 'none' } = {},
|
||||
) {
|
||||
weatherScalarMetadata(overlay);
|
||||
const range = (variable, fieldLevel = level) => {
|
||||
const index = parsed.messages.findIndex(
|
||||
(message) =>
|
||||
message.variable === variable && message.level === fieldLevel,
|
||||
);
|
||||
const message = parsed.messages[index];
|
||||
const next = parsed.messages[index + 1];
|
||||
if (!message || !next)
|
||||
throw new Error(`missing ${variable} at ${fieldLevel}`);
|
||||
return { start: message.offset, end: next.offset - 1 };
|
||||
};
|
||||
return {
|
||||
u: range('UGRD'),
|
||||
v: range('VGRD'),
|
||||
...(overlay === 'temperature'
|
||||
? { scalar: range('TMP', '2 m above ground') }
|
||||
: {}),
|
||||
...(overlay === 'pressure'
|
||||
? { scalar: range('PRMSL', 'mean sea level') }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Fetch a bounded text response as a Buffer. */
|
||||
export async function fetchText({ url, fetchImpl = fetch, signal }) {
|
||||
const response = await fetchImpl(url, { signal, redirect: 'error' });
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new Error('Wind upstream unavailable');
|
||||
}
|
||||
return Buffer.from(await readWindBody(response, 2 * 1024 * 1024, signal));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch one bounded byte range. Rejects oversized ranges, non-206 responses
|
||||
* (except a whole-object 200 starting at zero), and short bodies.
|
||||
*/
|
||||
export async function fetchRange({
|
||||
url,
|
||||
start,
|
||||
end,
|
||||
fetchImpl = fetch,
|
||||
signal,
|
||||
maxBytes = 8 * 1024 * 1024,
|
||||
}) {
|
||||
if (
|
||||
!Number.isSafeInteger(start) ||
|
||||
!Number.isSafeInteger(end) ||
|
||||
start < 0 ||
|
||||
end < start ||
|
||||
end - start + 1 > maxBytes
|
||||
)
|
||||
throw new Error('range too large');
|
||||
const response = await fetchImpl(url, {
|
||||
signal,
|
||||
redirect: 'error',
|
||||
headers: { Range: `bytes=${start}-${end}` },
|
||||
});
|
||||
const validStatus =
|
||||
response.status === 206 || (response.status === 200 && start === 0);
|
||||
if (!validStatus || !response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new Error('Wind upstream unavailable');
|
||||
}
|
||||
const buffer = Buffer.from(
|
||||
await readWindBody(response, end - start + 1, signal),
|
||||
);
|
||||
if (buffer.length !== end - start + 1)
|
||||
throw new Error('invalid range length');
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/** A bounded optional request may fail without cancelling valid wind siblings. */
|
||||
export async function loadOptionalScalar({
|
||||
range,
|
||||
url,
|
||||
fetchImpl,
|
||||
decodeImpl,
|
||||
signal,
|
||||
timeoutMs = 12_000,
|
||||
}) {
|
||||
if (!range) return undefined;
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort(signal.reason);
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const bytes = await fetchRange({
|
||||
url,
|
||||
...range,
|
||||
fetchImpl,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const value = await decodeImpl(bytes);
|
||||
controller.signal.throwIfAborted();
|
||||
return value;
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
signal?.removeEventListener('abort', abort);
|
||||
}
|
||||
}
|
||||
|
||||
import {
|
||||
GFS_BUCKET,
|
||||
gfsObjectKey,
|
||||
selectLatestGfsCycle,
|
||||
nearestGfsStep,
|
||||
} from './catalog.js';
|
||||
import { decodeWindGribMessage } from './decode.js';
|
||||
import { resampleWeatherSnapshot, weatherScalarMetadata } from './grid.js';
|
||||
|
||||
/** Fetch and decode the GFS 10 m wind field valid closest to now. */
|
||||
export async function fetchGfsWind({
|
||||
fetchImpl = fetch,
|
||||
now = () => Date.now(),
|
||||
targetDx = 1,
|
||||
decodeImpl = decodeWindGribMessage,
|
||||
signal,
|
||||
overlay = 'none',
|
||||
} = {}) {
|
||||
weatherScalarMetadata(overlay);
|
||||
const nowMs = now();
|
||||
const cycle = selectLatestGfsCycle(nowMs);
|
||||
const runMs = Date.UTC(
|
||||
Number(cycle.date.slice(0, 4)),
|
||||
Number(cycle.date.slice(4, 6)) - 1,
|
||||
Number(cycle.date.slice(6, 8)),
|
||||
cycle.hour,
|
||||
);
|
||||
// Pick the forecast step whose valid time is closest to now, so the layer
|
||||
// shows the freshest field the cycle offers instead of the analysis.
|
||||
const forecastHour = nearestGfsStep((nowMs - runMs) / 3600_000);
|
||||
const base = `https://${GFS_BUCKET}.s3.amazonaws.com/${gfsObjectKey({ ...cycle, forecastHour })}`;
|
||||
const index = await fetchText({ url: `${base}.idx`, fetchImpl, signal });
|
||||
const inventory = parseGfsIdx(index.toString());
|
||||
const ranges = windMessageRanges(inventory);
|
||||
let scalarRange;
|
||||
if (overlay !== 'none') {
|
||||
try {
|
||||
scalarRange = windMessageRanges(inventory, { overlay }).scalar;
|
||||
} catch {
|
||||
/* A missing optional field does not discard valid wind. */
|
||||
}
|
||||
}
|
||||
// All selected messages come from this one issue/valid-time object.
|
||||
const load = async (range) =>
|
||||
decodeImpl(await fetchRange({ url: base, ...range, fetchImpl, signal }));
|
||||
const [u, v, scalar] = await Promise.all([
|
||||
load(ranges.u),
|
||||
load(ranges.v),
|
||||
loadOptionalScalar({
|
||||
range: scalarRange,
|
||||
url: base,
|
||||
fetchImpl,
|
||||
decodeImpl,
|
||||
signal,
|
||||
}),
|
||||
]);
|
||||
signal?.throwIfAborted();
|
||||
const fields = resampleWeatherSnapshot({ u, v, scalar, overlay, targetDx });
|
||||
const runIso = new Date(runMs).toISOString();
|
||||
const validIso = new Date(runMs + forecastHour * 3600_000).toISOString();
|
||||
return {
|
||||
cycle: { ...cycle, forecastHour, runIso, validIso },
|
||||
level: '10 m above ground',
|
||||
units: 'm/s',
|
||||
...fields,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Resample a GRIB wind grid into a compact equirectangular grid.
|
||||
*
|
||||
* Source grids are north-to-south (`la1` at the top, `dj` increasing toward the
|
||||
* south) and start at `lo1`. Longitude wraps across the seam; latitude clamps.
|
||||
*
|
||||
* @param {{u: ArrayLike<number>, v: ArrayLike<number>, ni: number, nj: number,
|
||||
* lo1: number, la1: number, di: number, dj: number, dx?: number, dy?: number}} input
|
||||
* @returns {{nx: number, ny: number, lo1: number, la1: number, dx: number,
|
||||
* dy: number, u: Float32Array, v: Float32Array}}
|
||||
*/
|
||||
export function resampleWindGrid({
|
||||
u,
|
||||
v,
|
||||
scalar,
|
||||
scalarScale = 1,
|
||||
scalarOffset = 0,
|
||||
ni,
|
||||
nj,
|
||||
lo1,
|
||||
la1,
|
||||
di,
|
||||
dj,
|
||||
dx = 1,
|
||||
dy = 1,
|
||||
}) {
|
||||
if (
|
||||
!Number.isInteger(ni) ||
|
||||
!Number.isInteger(nj) ||
|
||||
ni < 1 ||
|
||||
nj < 1 ||
|
||||
ni * nj > 2_000_000 ||
|
||||
u?.length !== ni * nj ||
|
||||
v?.length !== ni * nj ||
|
||||
(scalar !== undefined && scalar?.length !== ni * nj) ||
|
||||
![lo1, la1, di, dj, dx, dy, scalarScale, scalarOffset].every(
|
||||
Number.isFinite,
|
||||
) ||
|
||||
di <= 0 ||
|
||||
dj <= 0 ||
|
||||
dx < 0.25 ||
|
||||
dy < 0.25 ||
|
||||
dx > 180 ||
|
||||
dy > 180
|
||||
)
|
||||
throw new Error('Invalid wind grid geometry');
|
||||
for (const values of [u, v, ...(scalar === undefined ? [] : [scalar])]) {
|
||||
if (!Array.prototype.every.call(values, Number.isFinite))
|
||||
throw new Error('Invalid wind grid values');
|
||||
}
|
||||
const nx = Math.round(360 / dx);
|
||||
const ny = Math.round(180 / dy) + 1;
|
||||
if (nx * ny > 1_000_000)
|
||||
throw new Error('Resampled wind grid exceeds point budget');
|
||||
const outU = new Float32Array(nx * ny);
|
||||
const outV = new Float32Array(nx * ny);
|
||||
const outScalar = scalar === undefined ? null : new Float32Array(nx * ny);
|
||||
const sample = (source, lon, lat) => {
|
||||
const x = ((lon - lo1) / di + ni) % ni;
|
||||
const y = Math.max(0, Math.min(nj - 1, (la1 - lat) / dj));
|
||||
const x0 = Math.floor(x);
|
||||
const x1 = (x0 + 1) % ni;
|
||||
const y0 = Math.floor(y);
|
||||
const y1 = Math.min(nj - 1, y0 + 1);
|
||||
const fx = x - x0;
|
||||
const fy = y - y0;
|
||||
const top = source[y0 * ni + x0] * (1 - fx) + source[y0 * ni + x1] * fx;
|
||||
const bottom = source[y1 * ni + x0] * (1 - fx) + source[y1 * ni + x1] * fx;
|
||||
return top * (1 - fy) + bottom * fy;
|
||||
};
|
||||
for (let y = 0; y < ny; y += 1) {
|
||||
for (let x = 0; x < nx; x += 1) {
|
||||
const index = y * nx + x;
|
||||
const lon = x * dx;
|
||||
const lat = 90 - y * dy;
|
||||
outU[index] = sample(u, lon, lat);
|
||||
outV[index] = sample(v, lon, lat);
|
||||
if (outScalar)
|
||||
outScalar[index] =
|
||||
sample(scalar, lon, lat) * scalarScale + scalarOffset;
|
||||
}
|
||||
}
|
||||
for (const values of [outU, outV, ...(outScalar ? [outScalar] : [])]) {
|
||||
if (!values.every(Number.isFinite))
|
||||
throw new Error('Invalid resampled wind values');
|
||||
}
|
||||
return {
|
||||
nx,
|
||||
ny,
|
||||
lo1: 0,
|
||||
la1: 90,
|
||||
dx,
|
||||
dy,
|
||||
u: outU,
|
||||
v: outV,
|
||||
...(outScalar ? { scalar: outScalar } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Metadata for the two optional same-cycle weather fields. */
|
||||
export function weatherScalarMetadata(overlay = 'none') {
|
||||
if (overlay === 'none') return null;
|
||||
if (overlay === 'temperature')
|
||||
return { kind: overlay, units: '°C', level: '2 m above ground' };
|
||||
if (overlay === 'pressure')
|
||||
return { kind: overlay, units: 'hPa', level: 'mean sea level' };
|
||||
throw new Error('Unknown weather overlay');
|
||||
}
|
||||
|
||||
/** Reject shifted/mismatched components before sharing U's grid geometry. */
|
||||
export function resampleWeatherFields({
|
||||
u,
|
||||
v,
|
||||
scalar,
|
||||
overlay = 'none',
|
||||
targetDx = 1,
|
||||
}) {
|
||||
const metadata = weatherScalarMetadata(overlay);
|
||||
const fields = [u, v, ...(metadata ? [scalar] : [])];
|
||||
if (
|
||||
!u ||
|
||||
fields.some(
|
||||
(field) =>
|
||||
!field ||
|
||||
['ni', 'nj', 'lo1', 'la1', 'di', 'dj'].some(
|
||||
(key) => field[key] !== u[key],
|
||||
),
|
||||
)
|
||||
)
|
||||
throw new Error('Mismatched weather grid geometry');
|
||||
if (metadata && scalar.units !== (overlay === 'temperature' ? 'K' : 'Pa'))
|
||||
throw new Error('Unexpected weather scalar units');
|
||||
const grid = resampleWindGrid({
|
||||
u: u.values,
|
||||
v: v.values,
|
||||
ni: u.ni,
|
||||
nj: u.nj,
|
||||
lo1: u.lo1,
|
||||
la1: u.la1,
|
||||
di: u.di,
|
||||
dj: u.dj,
|
||||
dx: targetDx,
|
||||
dy: targetDx,
|
||||
...(metadata
|
||||
? {
|
||||
scalar: scalar.values,
|
||||
scalarScale: overlay === 'pressure' ? 0.01 : 1,
|
||||
scalarOffset: overlay === 'temperature' ? -273.15 : 0,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
return { grid, ...(metadata ? { scalar: metadata } : {}) };
|
||||
}
|
||||
|
||||
/** Preserve usable same-cycle wind when only its optional companion fails. */
|
||||
export function resampleWeatherSnapshot(options) {
|
||||
try {
|
||||
return resampleWeatherFields(options);
|
||||
} catch (error) {
|
||||
if ((options.overlay ?? 'none') === 'none') throw error;
|
||||
// Revalidate wind alone: a wind defect must never become a partial success.
|
||||
const wind = resampleWeatherFields({ ...options, overlay: 'none' });
|
||||
return { ...wind, scalarError: weatherScalarError(options.overlay) };
|
||||
}
|
||||
}
|
||||
|
||||
export function weatherScalarError(overlay) {
|
||||
weatherScalarMetadata(overlay);
|
||||
return overlay === 'temperature'
|
||||
? 'Temperature field unavailable'
|
||||
: 'Mean sea level pressure field unavailable';
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { fetchText, fetchRange, loadOptionalScalar } from './gfs.js';
|
||||
import { decodeWindGribMessage } from './decode.js';
|
||||
import { resampleWeatherSnapshot, weatherScalarMetadata } from './grid.js';
|
||||
|
||||
/** Keyless ECMWF Open Data root for real-time IFS forecasts. */
|
||||
export const IFS_BASE = 'https://data.ecmwf.int/forecasts';
|
||||
|
||||
/**
|
||||
* Select the latest IFS cycle that should be available.
|
||||
* @param {number} nowMs - Reference epoch milliseconds.
|
||||
* @param {{availabilityLagMs?: number}} [options]
|
||||
* @returns {{date: string, hour: number}}
|
||||
*/
|
||||
export function selectLatestIfsCycle(
|
||||
nowMs,
|
||||
{ availabilityLagMs = 6 * 3600_000 } = {},
|
||||
) {
|
||||
const shifted = new Date(nowMs - availabilityLagMs);
|
||||
return {
|
||||
date: shifted.toISOString().slice(0, 10).replaceAll('-', ''),
|
||||
hour: Math.floor(shifted.getUTCHours() / 6) * 6,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the IFS GRIB2 and `.index` URLs for one cycle and step.
|
||||
* @param {{date: string, hour: number, step?: number}} cycle
|
||||
* @returns {{grib: string, index: string}}
|
||||
*/
|
||||
export function ifsObjectUrls({ date, hour, step = 0 }) {
|
||||
const hh = String(hour).padStart(2, '0');
|
||||
const stem = `${IFS_BASE}/${date}/${hh}z/ifs/0p25/oper/${date}${hh}0000-${step}h-oper-fc`;
|
||||
// The inventory sits beside the GRIB2 as `<stem>.index`, NOT `<grib2>.index`.
|
||||
return { grib: `${stem}.grib2`, index: `${stem}.index` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the IFS JSON Lines inventory into `{param, offset, length}` records.
|
||||
* Malformed lines are skipped; a payload with no valid lines is rejected.
|
||||
* @param {string} text - Raw `.index` body.
|
||||
* @returns {Array<{param: string, offset: number, length: number}>}
|
||||
*/
|
||||
export function parseIfsIndex(text) {
|
||||
const entries = [];
|
||||
for (const line of String(text).split(/\r?\n/)) {
|
||||
if (!line) continue;
|
||||
let value;
|
||||
try {
|
||||
value = JSON.parse(line);
|
||||
} catch {
|
||||
continue; // non-JSON line in the index
|
||||
}
|
||||
if (
|
||||
value?.param &&
|
||||
Number.isFinite(value._offset) &&
|
||||
Number.isFinite(value._length)
|
||||
)
|
||||
entries.push({
|
||||
param: value.param,
|
||||
offset: value._offset,
|
||||
length: value._length,
|
||||
});
|
||||
}
|
||||
if (!entries.length) throw new Error('malformed IFS index');
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Byte ranges for the IFS 10 m wind components.
|
||||
* @param {Array<{param: string, offset: number, length: number}>} entries
|
||||
* @returns {{u: {start: number, end: number}, v: {start: number, end: number}}}
|
||||
*/
|
||||
export function ifsWindRanges(entries, { overlay = 'none' } = {}) {
|
||||
weatherScalarMetadata(overlay);
|
||||
const range = (param) => {
|
||||
const item = entries.find((entry) => entry.param === param);
|
||||
if (!item) throw new Error(`missing ${param}`);
|
||||
return { start: item.offset, end: item.offset + item.length - 1 };
|
||||
};
|
||||
return {
|
||||
u: range('10u'),
|
||||
v: range('10v'),
|
||||
...(overlay === 'temperature' ? { scalar: range('2t') } : {}),
|
||||
...(overlay === 'pressure' ? { scalar: range('msl') } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Round a time offset to the nearest published IFS forecast step. IFS 0.25°
|
||||
* oper is 3-hourly through f144, then 6-hourly to f360 for 00/12z.
|
||||
* @param {number} hours - Hours elapsed since the cycle run time.
|
||||
* @param {number} cycleHour - The cycle hour (0, 6, 12 or 18).
|
||||
* @returns {number} A valid forecast step.
|
||||
*/
|
||||
export function nearestIfsStep(hours, cycleHour) {
|
||||
const max = cycleHour === 6 || cycleHour === 18 ? 144 : 360;
|
||||
const value = Math.max(0, Number.isFinite(hours) ? hours : 0);
|
||||
const step =
|
||||
value <= 144 ? Math.round(value / 3) * 3 : Math.round(value / 6) * 6;
|
||||
return Math.max(0, Math.min(max, step));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and decode the IFS 10 m wind field valid closest to now.
|
||||
* @param {{fetchImpl?: Function, now?: Function, targetDx?: number,
|
||||
* decodeImpl?: Function}} [options]
|
||||
* @returns {Promise<{cycle: object, level: string, units: string, grid: object}>}
|
||||
*/
|
||||
export async function fetchIfsWind({
|
||||
fetchImpl = fetch,
|
||||
now = () => Date.now(),
|
||||
targetDx = 1,
|
||||
decodeImpl = decodeWindGribMessage,
|
||||
signal,
|
||||
overlay = 'none',
|
||||
} = {}) {
|
||||
weatherScalarMetadata(overlay);
|
||||
const nowMs = now();
|
||||
const cycle = selectLatestIfsCycle(nowMs);
|
||||
const runMs = Date.UTC(
|
||||
Number(cycle.date.slice(0, 4)),
|
||||
Number(cycle.date.slice(4, 6)) - 1,
|
||||
Number(cycle.date.slice(6, 8)),
|
||||
cycle.hour,
|
||||
);
|
||||
// Show the field valid closest to now rather than always the analysis.
|
||||
const forecastHour = nearestIfsStep((nowMs - runMs) / 3600_000, cycle.hour);
|
||||
const urls = ifsObjectUrls({ ...cycle, step: forecastHour });
|
||||
const index = await fetchText({ url: urls.index, fetchImpl, signal });
|
||||
const inventory = parseIfsIndex(index.toString());
|
||||
const ranges = ifsWindRanges(inventory);
|
||||
let scalarRange;
|
||||
if (overlay !== 'none') {
|
||||
try {
|
||||
scalarRange = ifsWindRanges(inventory, { overlay }).scalar;
|
||||
} catch {
|
||||
/* A missing optional field does not discard valid wind. */
|
||||
}
|
||||
}
|
||||
// All selected messages come from this one issue/valid-time object.
|
||||
const load = async (range) =>
|
||||
decodeImpl(
|
||||
await fetchRange({ url: urls.grib, ...range, fetchImpl, signal }),
|
||||
);
|
||||
const [u, v, scalar] = await Promise.all([
|
||||
load(ranges.u),
|
||||
load(ranges.v),
|
||||
loadOptionalScalar({
|
||||
range: scalarRange,
|
||||
url: urls.grib,
|
||||
fetchImpl,
|
||||
decodeImpl,
|
||||
signal,
|
||||
}),
|
||||
]);
|
||||
signal?.throwIfAborted();
|
||||
const fields = resampleWeatherSnapshot({ u, v, scalar, overlay, targetDx });
|
||||
const runIso = new Date(runMs).toISOString();
|
||||
const validIso = new Date(runMs + forecastHour * 3600_000).toISOString();
|
||||
return {
|
||||
cycle: { ...cycle, forecastHour, runIso, validIso },
|
||||
level: '10 m above ground',
|
||||
units: 'm/s',
|
||||
...fields,
|
||||
};
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { apiNotFoundPlugin } from './api-not-found.js';
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||||
|
||||
/** Load this checkout's configuration and attach its local provider middleware. */
|
||||
export default defineConfig(({ mode }) => {
|
||||
export default defineConfig(({ command, mode }) => {
|
||||
const loaded = loadEnv(mode, root, '');
|
||||
for (const [key, value] of Object.entries(loaded)) {
|
||||
if (process.env[key] === undefined) process.env[key] = value;
|
||||
@@ -18,5 +18,6 @@ export default defineConfig(({ mode }) => {
|
||||
cesiumToken: process.env.CESIUM_ION_TOKEN,
|
||||
host: process.env.HOST,
|
||||
port: process.env.PORT,
|
||||
command,
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user