Files
gods-eye-view/server/providers/cyclones.js

422 lines
13 KiB
JavaScript

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;
// Data, not UI copy: src/layers/cyclones/index.js keeps the same English in
// msg() and translates it when shown (likewise 'Cyclone data unavailable').
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;
}
// NHC stamps an advisory with its nominal time (e.g. 03:00Z) and publishes it
// up to about half an hour before; allow that, but not a clock far ahead.
const EARLY_ADVISORY_MS = 90 * 60_000;
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 + EARLY_ADVISORY_MS ||
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);
},
};
}