mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
An upstream answer whose zones all fail validation normalized to an empty list, and paging that stopped at the page limit while the service still flagged more records was accepted as complete. Both were cached as a fresh snapshot for an hour and replaced the last good one. Treat them as failed refreshes so stale-on-error keeps serving the previous snapshot.
149 lines
4.9 KiB
JavaScript
149 lines
4.9 KiB
JavaScript
import { normalizeDroneAirspaceSnapshot } from '../../src/layers/droneAirspace/records.js';
|
|
import { readResponseJsonCapped, coalesceProxyRequest } from './common/http.js';
|
|
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
|
|
|
|
// FAA Special Use Airspace (public, keyless). TYPE_CODE P (prohibited) and
|
|
// R (restricted) only — the small, always-in-force slice of the national
|
|
// SUA table. maxAllowableOffset trades ~100 m of boundary fidelity for a
|
|
// payload small enough to refresh (~330 KB for the current P+R set).
|
|
const API_URL =
|
|
'https://services6.arcgis.com/ssFJjBXIUyZDrSYZ/arcgis/rest/services/' +
|
|
'Special_Use_Airspace/FeatureServer/0/query?' +
|
|
new URLSearchParams({
|
|
where: "TYPE_CODE IN ('P','R')",
|
|
outFields: [
|
|
'NAME',
|
|
'TYPE_CODE',
|
|
'UPPER_VAL',
|
|
'UPPER_UOM',
|
|
'UPPER_CODE',
|
|
'LOWER_VAL',
|
|
'LOWER_UOM',
|
|
'LOWER_CODE',
|
|
'TIMESOFUSE',
|
|
'CONT_AGENT',
|
|
'CITY',
|
|
'STATE',
|
|
'OBJECTID',
|
|
].join(','),
|
|
maxAllowableOffset: '0.001',
|
|
outSR: '4326',
|
|
f: 'geojson',
|
|
}).toString();
|
|
|
|
const MAX_PAGES = 5;
|
|
const MIB = 1024 * 1024;
|
|
const SNAPSHOT_TTL_MS = 3_600_000;
|
|
|
|
const exceededTransferLimit = (payload) =>
|
|
payload?.exceededTransferLimit === true ||
|
|
payload?.properties?.exceededTransferLimit === true;
|
|
|
|
/** Fixed-origin, bounded FAA SUA routes for dev and preview. */
|
|
export function droneAirspaceProxy({
|
|
fetchImpl = (...args) => globalThis.fetch(...args),
|
|
now = () => Date.now(),
|
|
} = {}) {
|
|
const cache = new Map();
|
|
const inFlight = new Map();
|
|
const allow = makeRateLimiter({ windowMs: 60_000, max: 60, globalMax: 1200 });
|
|
|
|
async function upstream(url, cap, timeout) {
|
|
const signal = AbortSignal.timeout(timeout);
|
|
const response = await fetchImpl(url, {
|
|
signal,
|
|
redirect: 'error',
|
|
});
|
|
if (!response.ok) {
|
|
await response.body?.cancel();
|
|
throw new Error('upstream_unavailable');
|
|
}
|
|
return readResponseJsonCapped(response, cap, signal);
|
|
}
|
|
|
|
// A snapshot is accepted only once the service says it is complete. A page
|
|
// limit reached while still flagged, a flagged page that adds nothing, or
|
|
// no valid zone at all is a failed refresh, so the last good snapshot stays.
|
|
async function fetchZones() {
|
|
const features = [];
|
|
let complete = false;
|
|
for (let page = 0; page < MAX_PAGES; page++) {
|
|
const url =
|
|
page === 0 ? API_URL : `${API_URL}&resultOffset=${features.length}`;
|
|
const payload = await upstream(url, 8 * MIB, 30_000);
|
|
if (!Array.isArray(payload?.features))
|
|
throw new Error('invalid_snapshot');
|
|
features.push(...payload.features);
|
|
if (!exceededTransferLimit(payload)) {
|
|
complete = true;
|
|
break;
|
|
}
|
|
if (!payload.features.length) break;
|
|
}
|
|
if (!complete) throw new Error('incomplete_snapshot');
|
|
const rows = normalizeDroneAirspaceSnapshot({ features });
|
|
if (!rows?.length) throw new Error('invalid_snapshot');
|
|
return { fetchedAt: now(), rows };
|
|
}
|
|
|
|
async function acquire(key, ttl, load) {
|
|
const previous = cache.get(key);
|
|
if (previous && now() - previous.savedAt < ttl)
|
|
return { value: previous.value, stale: false };
|
|
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();
|
|
cache.set(key, { value, savedAt: now() });
|
|
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];
|
|
if (path !== '/' && path !== '')
|
|
return json(404, { error: 'unknown_route' });
|
|
if (!allow(clientKey(req))) return json(429, { error: 'rate_limited' });
|
|
try {
|
|
const { value, stale } = await acquire(
|
|
'zones',
|
|
SNAPSHOT_TTL_MS,
|
|
fetchZones,
|
|
);
|
|
json(200, stale ? { ...value, stale: true } : value, stale);
|
|
} catch (error) {
|
|
json(error.status === 429 ? 429 : 502, {
|
|
error: 'drone_airspace_unavailable',
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
name: 'drone-airspace',
|
|
configureServer({ middlewares }) {
|
|
middlewares.use('/api/drone-airspace', handler);
|
|
},
|
|
configurePreviewServer({ middlewares }) {
|
|
middlewares.use('/api/drone-airspace', handler);
|
|
},
|
|
};
|
|
}
|