mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge remote-tracking branch 'upstream/main' into fix/363-geocode-proxy
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
5083a74413
542 files changed
+96845
-3034
No files matched your search
@@ -45,7 +45,9 @@ export function trackBackfillProxies() {
|
||||
RESPONSE_CAP_BYTES,
|
||||
);
|
||||
let body;
|
||||
let status = upstream.status;
|
||||
if (tooLarge) {
|
||||
status = 502;
|
||||
body = JSON.stringify({ error: 'Upstream track response too large' });
|
||||
} else if (!upstream.ok) {
|
||||
// Sanitize upstream error surface; status code is signal enough
|
||||
@@ -53,8 +55,10 @@ export function trackBackfillProxies() {
|
||||
} else {
|
||||
body = text;
|
||||
}
|
||||
cachePut(key, { at: Date.now(), status: upstream.status, body });
|
||||
res.statusCode = upstream.status;
|
||||
// Cache the 502 like any upstream error: /tracks/all costs OpenSky
|
||||
// credits, so a retry inside the window must not re-download the body.
|
||||
cachePut(key, { at: Date.now(), status, body });
|
||||
res.statusCode = status;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.end(body);
|
||||
|
||||
+121
-3
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
@@ -253,5 +261,10 @@ export const CCTV_FRAME_MAX_BODY_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
/** Deadline for upstream response headers; live bodies keep streaming afterward. */
|
||||
export const CCTV_MEDIA_FETCH_TIMEOUT_MS = 15 * 1000;
|
||||
/** Silence a live body may carry before the relay gives up on it. Twice the
|
||||
* header deadline, because a camera that is merely slow between frames is far
|
||||
* more common than one that has died mid-stream, and a viewer would rather
|
||||
* wait than be dropped. */
|
||||
export const CCTV_MEDIA_IDLE_TIMEOUT_MS = 30 * 1000;
|
||||
/** Declared size ceiling for fixed media responses. */
|
||||
export const CCTV_MEDIA_MAX_BODY_BYTES = 64 * 1024 * 1024;
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
CCTV_FRAME_FETCH_TIMEOUT_MS,
|
||||
CCTV_FRAME_MAX_BODY_BYTES,
|
||||
CCTV_MEDIA_FETCH_TIMEOUT_MS,
|
||||
CCTV_MEDIA_IDLE_TIMEOUT_MS,
|
||||
CCTV_MEDIA_MAX_BODY_BYTES,
|
||||
NSW_IMAGE_ORIGIN,
|
||||
NSW_IMAGE_USER_AGENT,
|
||||
@@ -103,11 +104,17 @@ export function toReadable(body) {
|
||||
* @param {Response} upstream - fetch() Response object.
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.sourceHeader='upstream'] - Value for X-CCTV-Source header.
|
||||
* @param {number} [opts.idleTimeoutMs=CCTV_MEDIA_IDLE_TIMEOUT_MS] - Silence
|
||||
* allowed between upstream chunks before the stream is released. Injectable
|
||||
* only to keep the deadline unit-testable.
|
||||
*/
|
||||
export async function proxyMediaResponse(
|
||||
res,
|
||||
upstream,
|
||||
{ sourceHeader = 'upstream' } = {},
|
||||
{
|
||||
sourceHeader = 'upstream',
|
||||
idleTimeoutMs = CCTV_MEDIA_IDLE_TIMEOUT_MS,
|
||||
} = {},
|
||||
) {
|
||||
const contentType =
|
||||
upstream.headers.get('content-type') || 'application/octet-stream';
|
||||
@@ -153,7 +160,20 @@ export async function proxyMediaResponse(
|
||||
return;
|
||||
}
|
||||
|
||||
// The header deadline only covers the wait for a response line. Past that an
|
||||
// upstream can hold the connection open and send nothing at all, and the
|
||||
// relay would wait on it for as long as the camera host cared to. The
|
||||
// deadline below measures the gap between upstream chunks rather than the
|
||||
// life of the stream, so a feed that keeps delivering keeps its connection.
|
||||
let idleTimer = null;
|
||||
const clearIdleDeadline = () => {
|
||||
if (!idleTimer) return;
|
||||
clearTimeout(idleTimer);
|
||||
idleTimer = null;
|
||||
};
|
||||
|
||||
stream.on('error', () => {
|
||||
clearIdleDeadline();
|
||||
if (!res.writableEnded) res.end();
|
||||
});
|
||||
|
||||
@@ -164,6 +184,7 @@ export async function proxyMediaResponse(
|
||||
const releaseUpstream = () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
clearIdleDeadline();
|
||||
stream.unpipe(res);
|
||||
// Destroying the Node stream cancels the web body it wraps; the direct
|
||||
// cancel covers a body that was never wrapped, and rejects harmlessly when
|
||||
@@ -176,14 +197,37 @@ export async function proxyMediaResponse(
|
||||
/* already closed */
|
||||
}
|
||||
};
|
||||
const armIdleDeadline = () => {
|
||||
clearIdleDeadline();
|
||||
idleTimer = setTimeout(onIdleDeadline, idleTimeoutMs);
|
||||
idleTimer.unref?.();
|
||||
};
|
||||
const onIdleDeadline = () => {
|
||||
// A viewer who cannot keep up pauses the pipe, and no upstream bytes arrive
|
||||
// while it is paused. That is a slow client rather than a dead camera, so
|
||||
// it gets the deadline again instead of a teardown.
|
||||
if (res.writableNeedDrain) {
|
||||
armIdleDeadline();
|
||||
return;
|
||||
}
|
||||
releaseUpstream();
|
||||
if (!res.writableEnded) res.end();
|
||||
};
|
||||
res.once('close', () => {
|
||||
clearIdleDeadline();
|
||||
if (!res.writableEnded) releaseUpstream();
|
||||
});
|
||||
res.once('error', releaseUpstream);
|
||||
stream.once('end', () => {
|
||||
clearIdleDeadline();
|
||||
released = true;
|
||||
});
|
||||
armIdleDeadline();
|
||||
stream.pipe(res);
|
||||
// Attached after the pipe because a data listener resumes the stream, and
|
||||
// flowing before the destination is attached would spill chunks nobody
|
||||
// forwards. Only bytes from upstream renew the deadline.
|
||||
stream.on('data', armIdleDeadline);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -5,7 +5,11 @@ import {
|
||||
MILITARY_INSTALLATION_DISK_TTL_MS,
|
||||
MILITARY_INSTALLATION_STALE_MS,
|
||||
} from './military-installations/constants.js';
|
||||
import { fetchOverpassPayload } from './overpass/transport.js';
|
||||
import {
|
||||
fetchOverpassPayload,
|
||||
overpassNotConfigured,
|
||||
} from './overpass/transport.js';
|
||||
import { resolveOverpassUpstreams } from './overpass/constants.js';
|
||||
import {
|
||||
_militaryInstallationCache,
|
||||
trimMilitaryInstallationCache,
|
||||
@@ -45,6 +49,7 @@ function militaryInstallationsProxy() {
|
||||
throw Object.assign(
|
||||
new Error('Mapped installation upstream unavailable'),
|
||||
{
|
||||
retryAfterMs: upstream.retryAfterMs,
|
||||
installationReason: upstream.rateLimited
|
||||
? 'rate_limited'
|
||||
: upstream.status === 504
|
||||
@@ -83,14 +88,6 @@ function militaryInstallationsProxy() {
|
||||
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
||||
return;
|
||||
}
|
||||
if (!_militaryInstallationsRateLimiter(clientKey(req))) {
|
||||
res.writeHead(429, {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': '5',
|
||||
});
|
||||
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
||||
return;
|
||||
}
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
const requested = validMilitaryInstallationBox(url.searchParams);
|
||||
if (!requested) {
|
||||
@@ -117,6 +114,36 @@ function militaryInstallationsProxy() {
|
||||
: militaryInstallationCacheKey(box);
|
||||
const now = Date.now();
|
||||
const cached = _militaryInstallationCache.get(key);
|
||||
if (!resolveOverpassUpstreams().length) {
|
||||
const stale =
|
||||
cached || (await readMilitaryInstallationDisk(key, Infinity));
|
||||
const disabled = overpassNotConfigured();
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Military-Installations': stale ? 'STALE' : 'DISABLED',
|
||||
});
|
||||
res.end(
|
||||
stale
|
||||
? JSON.stringify({
|
||||
...stale.payload,
|
||||
status: 'stale',
|
||||
retrievedAt:
|
||||
stale.payload.retrievedAt ||
|
||||
new Date(stale.cachedAt).toISOString(),
|
||||
})
|
||||
: disabled.body,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!_militaryInstallationsRateLimiter(clientKey(req))) {
|
||||
res.writeHead(429, {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': '5',
|
||||
});
|
||||
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
||||
return;
|
||||
}
|
||||
const preflight = await resolveMilitaryInstallationTier({
|
||||
cacheKey: key,
|
||||
memoryCache: _militaryInstallationCache,
|
||||
@@ -176,6 +203,9 @@ function militaryInstallationsProxy() {
|
||||
return;
|
||||
}
|
||||
res.writeHead(503, {
|
||||
...(error?.retryAfterMs
|
||||
? { 'Retry-After': String(Math.ceil(error.retryAfterMs / 1000)) }
|
||||
: {}),
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
@@ -183,6 +213,7 @@ function militaryInstallationsProxy() {
|
||||
JSON.stringify({
|
||||
error: 'Mapped installation context is temporarily unavailable',
|
||||
reason: militaryInstallationFailureReason(error),
|
||||
retryAfterMs: error?.retryAfterMs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { readRequestBodyCapped } from './common/request.js';
|
||||
import {
|
||||
OVERPASS_MAX_BODY_BYTES,
|
||||
OVERPASS_MAX_CONCURRENT,
|
||||
resolveOverpassUpstreams,
|
||||
} from './overpass/constants.js';
|
||||
import { sanitizeOverpassBody } from './overpass/query.js';
|
||||
import {
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
import {
|
||||
overpassPayloadIsData,
|
||||
fetchOverpassPayload,
|
||||
overpassNotConfigured,
|
||||
} from './overpass/transport.js';
|
||||
import { installRouteMiddleware } from './places/routes.js';
|
||||
|
||||
@@ -41,9 +43,16 @@ const _overpassRateLimiter = makeRateLimiter({
|
||||
function sendOverpassResponse(res, payload, cacheStatus = 'MISS') {
|
||||
res.writeHead(payload.status, {
|
||||
'Content-Type': payload.contentType || 'application/json',
|
||||
'Cache-Control': 'public, max-age=15',
|
||||
'Cache-Control': overpassPayloadIsData(payload)
|
||||
? 'public, max-age=15'
|
||||
: 'no-store',
|
||||
'X-Overpass-Cache': cacheStatus,
|
||||
'X-Overpass-Upstream': payload.endpoint || 'unknown',
|
||||
...(Number.isFinite(payload.cachedAt)
|
||||
? { 'X-Overpass-Cached-At': new Date(payload.cachedAt).toISOString() }
|
||||
: {}),
|
||||
...(payload.retryAfterMs
|
||||
? { 'Retry-After': String(Math.ceil(payload.retryAfterMs / 1000)) }
|
||||
: {}),
|
||||
});
|
||||
res.end(payload.body || '');
|
||||
}
|
||||
@@ -52,7 +61,7 @@ function sendOverpassResponse(res, payload, cacheStatus = 'MISS') {
|
||||
* Vite plugin: Overpass API proxy with response caching and request coalescing.
|
||||
*
|
||||
* Accepts POST requests at /api/overpass, normalizes the query body for
|
||||
* cache keying, and fans out to multiple Overpass mirrors with per-upstream
|
||||
* cache keying, and uses only operator-configured endpoints with per-upstream
|
||||
* timeout and rate-limit detection. Successful responses are cached for
|
||||
* OVERPASS_CACHE_MS. Concurrent identical queries share a single upstream
|
||||
* request via the in-flight map.
|
||||
@@ -66,6 +75,19 @@ function overpassProxy({ routing = {} } = {}) {
|
||||
// (a body-read failure would otherwise hit an out-of-scope reference).
|
||||
let cacheKey = null;
|
||||
try {
|
||||
// Capability probe: lets clients skip queries when nothing is configured.
|
||||
if (req.method === 'GET' && req.url?.split('?')[0] === '/status') {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
configured: resolveOverpassUpstreams().length > 0,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (req.method !== 'POST') {
|
||||
res.writeHead(405, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
||||
@@ -104,12 +126,21 @@ function overpassProxy({ routing = {} } = {}) {
|
||||
|
||||
// Normalize whitespace so semantically identical Overpass QL queries share cache entries
|
||||
cacheKey = safeBody.replace(/\s+/g, ' ').trim();
|
||||
if (!resolveOverpassUpstreams().length) {
|
||||
const cached = await readStaleOverpass(cacheKey);
|
||||
sendOverpassResponse(
|
||||
res,
|
||||
cached || overpassNotConfigured(),
|
||||
cached ? 'STALE' : 'DISABLED',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const preflight = await resolveOverpassPreflight({
|
||||
cacheKey,
|
||||
memoryCache: _overpassCache,
|
||||
inFlight: _overpassInFlight,
|
||||
// Fresh-enough disk entries survive restarts and skip the public
|
||||
// mirrors; boundary-class queries keep their month-long TTL.
|
||||
// Fresh-enough disk entries survive restarts and skip upstream
|
||||
// requests; boundary-class queries keep their month-long TTL.
|
||||
readDisk: () =>
|
||||
readOverpassDisk(cacheKey, overpassDiskTtlMs(cacheKey)),
|
||||
allowUpstream: () => _overpassRateLimiter(clientKey(req)),
|
||||
@@ -194,7 +225,7 @@ function overpassProxy({ routing = {} } = {}) {
|
||||
sendOverpassResponse(res, stale, 'STALE');
|
||||
return;
|
||||
}
|
||||
console.error('[Overpass Proxy]', e.message);
|
||||
console.error('[Overpass Proxy] request failed');
|
||||
res.writeHead(502, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Overpass proxy error' }));
|
||||
}
|
||||
|
||||
@@ -3,30 +3,39 @@ import path from 'node:path';
|
||||
// ---------------------------------------------------------------------------
|
||||
// Overpass API proxy constants and cache state
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* User-Agent sent to every Overpass mirror.
|
||||
*
|
||||
* The OSM API usage policy asks for a "Valid User-Agent identifying application
|
||||
* and version"; a generic proxy label is not one. A mirror is free to refuse a
|
||||
* client it cannot identify, and `src/overpassProxy.test.mjs` pins what that
|
||||
* costs: a refusal is never data, so the query falls through to whatever
|
||||
* mirrors are left. Keep this honest and stable — if it is ever refused, the
|
||||
* answer is less query volume, not a new name.
|
||||
*/
|
||||
/** Stable application identity for operator-configured Overpass instances. */
|
||||
const OVERPASS_USER_AGENT =
|
||||
'gods-eye-view/0.1 (+https://github.com/bilawalsidhu/gods-eye-view)';
|
||||
|
||||
/** Ordered list of Overpass API mirrors; tried sequentially on failure/rate-limit. */
|
||||
const OVERPASS_UPSTREAMS = [
|
||||
'https://overpass-api.de/api/interpreter',
|
||||
'https://overpass.kumi.systems/api/interpreter',
|
||||
'https://lz4.overpass-api.de/api/interpreter',
|
||||
// Community full-planet instance (privateforge nonprofit) — added 2026-07-30
|
||||
// when all three mirrors above refused this IP (likely a dev-traffic rate
|
||||
// ban; refused connections fail in ms, so healthy mirrors above still win).
|
||||
// Verified: planet coverage (Texas query), CORS *, ~5-20 s cold latency.
|
||||
'https://overpass.private.coffee/api/interpreter',
|
||||
];
|
||||
/** Parse only operator-supplied HTTP(S) endpoints; private instances are allowed. */
|
||||
function parseOverpassUpstreams(raw) {
|
||||
const endpoints = [];
|
||||
for (const token of String(raw || '').split(',')) {
|
||||
try {
|
||||
const url = new URL(token.trim());
|
||||
if (
|
||||
!['http:', 'https:'].includes(url.protocol) ||
|
||||
!url.hostname ||
|
||||
url.hash
|
||||
)
|
||||
continue;
|
||||
if (!endpoints.includes(url.href)) endpoints.push(url.href);
|
||||
} catch {
|
||||
// Invalid configuration never becomes an upstream or appears in logs.
|
||||
}
|
||||
}
|
||||
return endpoints.slice(0, 8);
|
||||
}
|
||||
|
||||
let upstreamMemo = { raw: null, endpoints: [] };
|
||||
|
||||
/** Resolve after environment loading. Public Overpass instances are not used by default. */
|
||||
function resolveOverpassUpstreams() {
|
||||
const raw = process.env.OVERPASS_UPSTREAMS || '';
|
||||
if (upstreamMemo.raw !== raw)
|
||||
upstreamMemo = { raw, endpoints: parseOverpassUpstreams(raw) };
|
||||
return [...upstreamMemo.endpoints];
|
||||
}
|
||||
|
||||
/**
|
||||
* TTL for FRESH cached Overpass responses (ms). Road geometry is static for
|
||||
@@ -142,7 +151,8 @@ export {
|
||||
OVERPASS_SIMPLIFY_MIN_POINTS,
|
||||
OVERPASS_SIMPLIFY_TOLERANCE_DEG,
|
||||
OVERPASS_MAX_RESPONSE_BYTES,
|
||||
OVERPASS_UPSTREAMS,
|
||||
parseOverpassUpstreams,
|
||||
resolveOverpassUpstreams,
|
||||
OVERPASS_USER_AGENT,
|
||||
OVERPASS_TIMEOUT_MS,
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
OVERPASS_MAX_RESPONSE_BYTES,
|
||||
OVERPASS_UPSTREAMS,
|
||||
resolveOverpassUpstreams,
|
||||
OVERPASS_USER_AGENT,
|
||||
OVERPASS_TIMEOUT_MS,
|
||||
} from './constants.js';
|
||||
@@ -60,99 +60,147 @@ function overpassPayloadIsData(payload) {
|
||||
);
|
||||
}
|
||||
|
||||
const cooldowns = new Map();
|
||||
|
||||
/** A stable, non-retryable capability response shared by every Overpass route. */
|
||||
export function overpassNotConfigured() {
|
||||
return {
|
||||
status: 503,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
error: 'Detailed OpenStreetMap queries are not configured',
|
||||
code: 'OVERPASS_NOT_CONFIGURED',
|
||||
retryable: false,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Honor Retry-After dates/seconds; absent values use bounded exponential backoff. */
|
||||
function retryDelay(value, failures, now) {
|
||||
const seconds = Number(value);
|
||||
const explicit =
|
||||
value && Number.isFinite(seconds)
|
||||
? seconds * 1000
|
||||
: Date.parse(value) - now;
|
||||
return Number.isFinite(explicit)
|
||||
? Math.max(1000, explicit)
|
||||
: Math.min(300_000, 30_000 * 2 ** Math.min(failures, 4));
|
||||
}
|
||||
|
||||
function refusal(status, retryAfterMs) {
|
||||
return {
|
||||
status,
|
||||
contentType: 'application/json',
|
||||
rateLimited: status === 429 || status === 406,
|
||||
retryAfterMs,
|
||||
body: JSON.stringify({
|
||||
error: 'Configured Overpass upstream unavailable',
|
||||
code: 'OVERPASS_UNAVAILABLE',
|
||||
retryable: true,
|
||||
retryAfterMs,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Try each mirror once, retaining response-size and per-mirror timeout caps.
|
||||
* Refusals and body-level failures rotate; total failure returns the last
|
||||
* rate-limit payload, otherwise the first refusal, or throws a network error.
|
||||
* @param {string} body URL-encoded Overpass QL query body.
|
||||
* @param {number} [maxResponseBytes] Endpoint-specific response cap.
|
||||
* @param {object} [options] Server-only endpoint and I/O overrides for tests.
|
||||
* @returns {Promise<{status:number,body:string,contentType:string,endpoint:string,rateLimited:boolean}>}
|
||||
* Query only the configured chain with capped reads, timeouts and per-endpoint cooldowns.
|
||||
* Explicit endpoint/I/O overrides are server-only test seams. Empty data is valid.
|
||||
*/
|
||||
async function fetchOverpassPayload(
|
||||
body,
|
||||
maxResponseBytes = OVERPASS_MAX_RESPONSE_BYTES,
|
||||
{
|
||||
endpoints = OVERPASS_UPSTREAMS,
|
||||
endpoints = resolveOverpassUpstreams(),
|
||||
fetchImpl = fetch,
|
||||
readBody = readResponseTextCapped,
|
||||
simplify = simplifyOverpassPayloadBody,
|
||||
now = Date.now,
|
||||
} = {},
|
||||
) {
|
||||
let lastError = null;
|
||||
let lastRateLimitPayload = null;
|
||||
let lastRefusalPayload = null;
|
||||
|
||||
if (!endpoints.length) return overpassNotConfigured();
|
||||
let failure = refusal(502, 30_000);
|
||||
for (const endpoint of endpoints) {
|
||||
const previous = cooldowns.get(endpoint);
|
||||
if (previous?.until > now()) {
|
||||
failure = refusal(previous.status, previous.until - now());
|
||||
continue;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), OVERPASS_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const upstream = await fetchImpl(endpoint, {
|
||||
const requestUrl = new URL(endpoint);
|
||||
const authorization =
|
||||
requestUrl.username || requestUrl.password
|
||||
? 'Basic ' +
|
||||
Buffer.from(
|
||||
`${decodeURIComponent(requestUrl.username)}:${decodeURIComponent(requestUrl.password)}`,
|
||||
).toString('base64')
|
||||
: null;
|
||||
requestUrl.username = '';
|
||||
requestUrl.password = '';
|
||||
const upstream = await fetchImpl(requestUrl.href, {
|
||||
method: 'POST',
|
||||
redirect: 'error',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'User-Agent': OVERPASS_USER_AGENT,
|
||||
...(authorization ? { Authorization: authorization } : {}),
|
||||
},
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const responseBody = await readBody(upstream, maxResponseBytes);
|
||||
const contentType =
|
||||
upstream.headers.get('content-type') || 'application/json';
|
||||
const status = upstream.status;
|
||||
const rateLimited =
|
||||
status === 429 || overpassLooksRateLimited(responseBody);
|
||||
const runtimeError = overpassLooksRuntimeError(responseBody);
|
||||
const payload = {
|
||||
status,
|
||||
body: responseBody,
|
||||
contentType,
|
||||
endpoint,
|
||||
rateLimited,
|
||||
runtimeError,
|
||||
};
|
||||
|
||||
if (rateLimited) {
|
||||
lastRateLimitPayload = payload;
|
||||
continue;
|
||||
}
|
||||
// A 200 body carrying a runtime error / timeout is a transient upstream
|
||||
// failure — skip to the next mirror rather than returning or caching it.
|
||||
if (runtimeError) {
|
||||
lastError = new Error(`Overpass runtime error (${endpoint})`);
|
||||
continue;
|
||||
}
|
||||
// Anything but 2xx is this mirror declining, not an answer. Only 5xx used
|
||||
// to rotate, so a 4xx ended the fan-out and was returned — and cached —
|
||||
// as data: a mirror refusing this client answers 406 while the others
|
||||
// answer 200 to the very same request, so every Overpass-backed layer
|
||||
// failed on an error page with healthy mirrors untried. The first
|
||||
// refusal is kept so a genuinely bad query still reports what upstream
|
||||
// said, but only after every mirror has had the chance to answer it.
|
||||
if (status < 200 || status >= 300) {
|
||||
if (!lastRefusalPayload) lastRefusalPayload = payload;
|
||||
lastError = new Error(
|
||||
`Overpass upstream returned ${status} (${endpoint})`,
|
||||
upstream.status === 429 ||
|
||||
upstream.status === 406 ||
|
||||
overpassLooksRateLimited(responseBody);
|
||||
if (
|
||||
rateLimited ||
|
||||
upstream.status < 200 ||
|
||||
upstream.status >= 300 ||
|
||||
overpassLooksRuntimeError(responseBody)
|
||||
) {
|
||||
const failures = (previous?.failures || 0) + 1;
|
||||
const delay = retryDelay(
|
||||
upstream.headers.get('retry-after'),
|
||||
failures - 1,
|
||||
now(),
|
||||
);
|
||||
const status = rateLimited
|
||||
? upstream.status === 406
|
||||
? 406
|
||||
: 429
|
||||
: 502;
|
||||
cooldowns.set(endpoint, { until: now() + delay, failures, status });
|
||||
while (cooldowns.size > 64)
|
||||
cooldowns.delete(cooldowns.keys().next().value);
|
||||
failure = refusal(status, delay);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Success: decimate giant boundary geometry before it reaches the cache,
|
||||
// the disk, or the client (what makes the 32 MB read cap safe to hold).
|
||||
payload.body = simplify(payload.body);
|
||||
return payload;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const parsed = JSON.parse(responseBody);
|
||||
if (!Array.isArray(parsed?.elements) || parsed.remark)
|
||||
throw new Error('Malformed Overpass response');
|
||||
cooldowns.delete(endpoint);
|
||||
return {
|
||||
status: upstream.status,
|
||||
body: simplify(responseBody),
|
||||
contentType: 'application/json',
|
||||
// Never retain a secret-bearing endpoint in cache or response metadata.
|
||||
endpoint: 'configured',
|
||||
rateLimited: false,
|
||||
};
|
||||
} catch {
|
||||
const failures = (previous?.failures || 0) + 1;
|
||||
const delay = retryDelay(null, failures - 1, now());
|
||||
cooldowns.set(endpoint, { until: now() + delay, failures, status: 502 });
|
||||
while (cooldowns.size > 64)
|
||||
cooldowns.delete(cooldowns.keys().next().value);
|
||||
failure = refusal(502, delay);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
if (lastRateLimitPayload) return lastRateLimitPayload;
|
||||
if (lastRefusalPayload) return lastRefusalPayload;
|
||||
throw lastError || new Error('All Overpass upstreams failed');
|
||||
return failure;
|
||||
}
|
||||
|
||||
export { overpassPayloadIsData, fetchOverpassPayload };
|
||||
@@ -1,7 +1,7 @@
|
||||
import { makeRateLimiter, clientKey } from '../common/rate-limit.js';
|
||||
import { coalesceProxyRequest } from '../common/http.js';
|
||||
import { fetchRegionalJson } from './http.js';
|
||||
import { normalizeRegionalPlace } from '../../../src/data/regionalModel.js';
|
||||
import { naturalRegionAtPoint } from '../../../src/data/naturalEarthRegions.js';
|
||||
import {
|
||||
nominatimToGeocodeResult,
|
||||
nominatimViewboxFromBounds,
|
||||
@@ -51,8 +51,8 @@ const NOMINATIM_SEARCH_MAX_QUERY = 200;
|
||||
// The pacer is deliberately module state while everything else here is
|
||||
// per-instance. One request per second is a budget for the whole application,
|
||||
// not for each provider object: two instances each pacing themselves would
|
||||
// send two requests a second between them. Reverse lookups and searches
|
||||
// therefore queue together.
|
||||
// send two requests a second between them. Only last-resort forward searches
|
||||
// use this queue.
|
||||
let _nominatimQueue = Promise.resolve();
|
||||
|
||||
let _nominatimLastRequestAt = 0;
|
||||
@@ -77,9 +77,7 @@ function abandonedError() {
|
||||
* Run one piece of upstream work, never closer than the policy spacing to the
|
||||
* last one.
|
||||
*
|
||||
* `bounded` applies the queue-depth and staleness limits. The cockpit's reverse
|
||||
* lookups are a slow background trickle and stay unbounded; the search route,
|
||||
* which a person can fire as fast as they can type, does not.
|
||||
* `bounded` applies queue-depth and staleness limits to interactive forward searches.
|
||||
*
|
||||
* @param {() => Promise<unknown>} work
|
||||
* @param {{bounded?: boolean, signal?: AbortSignal}} [options]
|
||||
@@ -111,30 +109,9 @@ function enqueueNominatim(work, { bounded = false, signal } = {}) {
|
||||
return task;
|
||||
}
|
||||
|
||||
/** Construct the serialized Nominatim reverse adapter with a trusted endpoint. */
|
||||
export function createRegionalPlaceProvider({
|
||||
endpoint = 'https://nominatim.openstreetmap.org/reverse',
|
||||
requestJson = fetchRegionalJson,
|
||||
} = {}) {
|
||||
function fetchRegionalPlace(point) {
|
||||
return enqueueNominatim(async () => {
|
||||
const params = new URLSearchParams({
|
||||
format: 'jsonv2',
|
||||
lat: point.latitude.toFixed(5),
|
||||
lon: point.longitude.toFixed(5),
|
||||
zoom: '10',
|
||||
addressdetails: '1',
|
||||
'accept-language': 'en',
|
||||
});
|
||||
const payload = await requestJson(`${endpoint}?${params}`, {
|
||||
headers: NOMINATIM_HEADERS,
|
||||
redirect: 'error',
|
||||
});
|
||||
return normalizeRegionalPlace(payload);
|
||||
});
|
||||
}
|
||||
|
||||
return fetchRegionalPlace;
|
||||
/** Construct the offline regional context provider from bundled Natural Earth polygons. */
|
||||
export function createRegionalPlaceProvider() {
|
||||
return (point) => naturalRegionAtPoint(point.latitude, point.longitude);
|
||||
}
|
||||
|
||||
export const fetchRegionalPlace = createRegionalPlaceProvider();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user