mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
fix: register data providers for local preview
Adapt the preview middleware contribution from PR #324 to the extracted provider modules, including the existing OpenSky route.
This commit is contained in:
1 parent
276bb84904
commit
a0f07b752f
10 files changed
+1100
-1089
No files matched your search
@@ -13,49 +13,51 @@ export function adsbLolProxy() {
|
|||||||
let _cacheAt = 0;
|
let _cacheAt = 0;
|
||||||
/** Response cache TTL (ms). */
|
/** Response cache TTL (ms). */
|
||||||
const CACHE_MS = 12000;
|
const CACHE_MS = 12000;
|
||||||
return {
|
const installMiddleware = (server) => {
|
||||||
name: 'adsblol-proxy',
|
server.middlewares.use('/api/adsblol/mil', async (req, res) => {
|
||||||
configureServer(server) {
|
try {
|
||||||
server.middlewares.use('/api/adsblol/mil', async (req, res) => {
|
const now = Date.now();
|
||||||
try {
|
if (_cache && now - _cacheAt < CACHE_MS) {
|
||||||
const now = Date.now();
|
res.writeHead(200, {
|
||||||
if (_cache && now - _cacheAt < CACHE_MS) {
|
|
||||||
res.writeHead(200, {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Cache-Control': 'no-store',
|
|
||||||
'X-ADS-B-Cache': 'HIT',
|
|
||||||
});
|
|
||||||
res.end(_cache);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const upstream = await fetch('https://api.adsb.lol/v2/mil', {
|
|
||||||
headers: { 'User-Agent': 'gods-eye-view-adsblol-proxy/1.0' },
|
|
||||||
});
|
|
||||||
const body = await upstream.text();
|
|
||||||
if (upstream.ok) {
|
|
||||||
_cache = body;
|
|
||||||
_cacheAt = now;
|
|
||||||
}
|
|
||||||
res.writeHead(upstream.status, {
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Cache-Control': 'no-store',
|
'Cache-Control': 'no-store',
|
||||||
'X-ADS-B-Cache': 'MISS',
|
'X-ADS-B-Cache': 'HIT',
|
||||||
});
|
});
|
||||||
res.end(body);
|
res.end(_cache);
|
||||||
} catch (e) {
|
return;
|
||||||
console.error('[adsb.lol Proxy]', e.message);
|
|
||||||
if (_cache) {
|
|
||||||
res.writeHead(200, {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-ADS-B-Cache': 'STALE',
|
|
||||||
});
|
|
||||||
res.end(_cache);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ error: 'ADS-B proxy error' }));
|
|
||||||
}
|
}
|
||||||
});
|
const upstream = await fetch('https://api.adsb.lol/v2/mil', {
|
||||||
},
|
headers: { 'User-Agent': 'gods-eye-view-adsblol-proxy/1.0' },
|
||||||
|
});
|
||||||
|
const body = await upstream.text();
|
||||||
|
if (upstream.ok) {
|
||||||
|
_cache = body;
|
||||||
|
_cacheAt = now;
|
||||||
|
}
|
||||||
|
res.writeHead(upstream.status, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'X-ADS-B-Cache': 'MISS',
|
||||||
|
});
|
||||||
|
res.end(body);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[adsb.lol Proxy]', e.message);
|
||||||
|
if (_cache) {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-ADS-B-Cache': 'STALE',
|
||||||
|
});
|
||||||
|
res.end(_cache);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(502, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'ADS-B proxy error' }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
name: 'adsblol-proxy',
|
||||||
|
configureServer: installMiddleware,
|
||||||
|
configurePreviewServer: installMiddleware,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -107,45 +107,41 @@ export function adsbdbProxy() {
|
|||||||
return inflight.get(ik);
|
return inflight.get(ik);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const installMiddleware = (server) => {
|
||||||
|
server.middlewares.use('/api/adsbdb', async (req, res) => {
|
||||||
|
await loadOnce();
|
||||||
|
const send = (status, obj) => {
|
||||||
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(obj));
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const [, kind, rawKey] = String(req.url || '')
|
||||||
|
.split('?')[0]
|
||||||
|
.split('/');
|
||||||
|
if (kind === 'route') {
|
||||||
|
const cs = String(rawKey || '').toUpperCase();
|
||||||
|
if (!/^[A-Z0-9]{2,8}$/.test(cs))
|
||||||
|
return send(400, { error: 'invalid callsign' });
|
||||||
|
const data = await lookup('route', cs);
|
||||||
|
return send(200, data ? { found: true, ...data } : { found: false });
|
||||||
|
}
|
||||||
|
if (kind === 'type') {
|
||||||
|
const hex = String(rawKey || '').toLowerCase();
|
||||||
|
if (!/^[0-9a-f]{6}$/.test(hex))
|
||||||
|
return send(400, { error: 'invalid hex' });
|
||||||
|
const data = await lookup('aircraft', hex);
|
||||||
|
return send(200, data ? { found: true, ...data } : { found: false });
|
||||||
|
}
|
||||||
|
return send(404, { error: 'unknown endpoint' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[adsbdb-proxy] request failed');
|
||||||
|
return send(500, { error: 'adsbdb proxy error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
name: 'adsbdb-proxy',
|
name: 'adsbdb-proxy',
|
||||||
configureServer(server) {
|
configureServer: installMiddleware,
|
||||||
server.middlewares.use('/api/adsbdb', async (req, res) => {
|
configurePreviewServer: installMiddleware,
|
||||||
await loadOnce();
|
|
||||||
const send = (status, obj) => {
|
|
||||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify(obj));
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const [, kind, rawKey] = String(req.url || '')
|
|
||||||
.split('?')[0]
|
|
||||||
.split('/');
|
|
||||||
if (kind === 'route') {
|
|
||||||
const cs = String(rawKey || '').toUpperCase();
|
|
||||||
if (!/^[A-Z0-9]{2,8}$/.test(cs))
|
|
||||||
return send(400, { error: 'invalid callsign' });
|
|
||||||
const data = await lookup('route', cs);
|
|
||||||
return send(
|
|
||||||
200,
|
|
||||||
data ? { found: true, ...data } : { found: false },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (kind === 'type') {
|
|
||||||
const hex = String(rawKey || '').toLowerCase();
|
|
||||||
if (!/^[0-9a-f]{6}$/.test(hex))
|
|
||||||
return send(400, { error: 'invalid hex' });
|
|
||||||
const data = await lookup('aircraft', hex);
|
|
||||||
return send(
|
|
||||||
200,
|
|
||||||
data ? { found: true, ...data } : { found: false },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return send(404, { error: 'unknown endpoint' });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[adsbdb-proxy] request failed');
|
|
||||||
return send(500, { error: 'adsbdb proxy error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -348,158 +348,23 @@ function openSkySourceIsStale(sourceEpochMs, now = Date.now()) {
|
|||||||
* @returns {import('vite').Plugin}
|
* @returns {import('vite').Plugin}
|
||||||
*/
|
*/
|
||||||
export function openSkyProxy() {
|
export function openSkyProxy() {
|
||||||
return {
|
const installMiddleware = (server) => {
|
||||||
name: 'opensky-proxy',
|
server.middlewares.use('/api/opensky', async (req, res) => {
|
||||||
configureServer(server) {
|
try {
|
||||||
server.middlewares.use('/api/opensky', async (req, res) => {
|
const requestedMode = normalizeOpenSkyAuthMode(
|
||||||
try {
|
process.env.OPENSKY_AUTH_MODE,
|
||||||
const requestedMode = normalizeOpenSkyAuthMode(
|
);
|
||||||
process.env.OPENSKY_AUTH_MODE,
|
const now = Date.now();
|
||||||
);
|
const inCooldown = now < _openskyCooldownUntil;
|
||||||
const now = Date.now();
|
// Fresh-enough cache (adaptive TTL) OR any cache during a 429
|
||||||
const inCooldown = now < _openskyCooldownUntil;
|
// cooldown: serve it without touching upstream. Stale-during-cooldown
|
||||||
// Fresh-enough cache (adaptive TTL) OR any cache during a 429
|
// is deliberate (credit governor): last-good planes beat a dead layer.
|
||||||
// cooldown: serve it without touching upstream. Stale-during-cooldown
|
if (
|
||||||
// is deliberate (credit governor): last-good planes beat a dead layer.
|
_openskyCacheBody &&
|
||||||
|
(now - _openskyCacheTime < _openskyTtlMs || inCooldown)
|
||||||
|
) {
|
||||||
if (
|
if (
|
||||||
_openskyCacheBody &&
|
openSkySourceIsStale(_openskyCacheSourceEpochMs, now) &&
|
||||||
(now - _openskyCacheTime < _openskyTtlMs || inCooldown)
|
|
||||||
) {
|
|
||||||
if (
|
|
||||||
openSkySourceIsStale(_openskyCacheSourceEpochMs, now) &&
|
|
||||||
(await serveAdsbLolPointFallback(
|
|
||||||
req,
|
|
||||||
res,
|
|
||||||
requestedMode,
|
|
||||||
'opensky_snapshot_stale_regional_fallback',
|
|
||||||
))
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const cachedMeta = _openskyCacheMeta || {
|
|
||||||
requestedMode,
|
|
||||||
usedMode: 'unknown',
|
|
||||||
reason: 'cached',
|
|
||||||
};
|
|
||||||
const isStale = now - _openskyCacheTime >= _openskyTtlMs;
|
|
||||||
res.writeHead(
|
|
||||||
_openskyCacheStatus || 200,
|
|
||||||
buildOpenSkyHeaders({
|
|
||||||
cacheStatus: isStale ? 'STALE' : 'HIT',
|
|
||||||
requestedMode: cachedMeta.requestedMode || requestedMode,
|
|
||||||
usedMode: cachedMeta.usedMode || 'unknown',
|
|
||||||
reason: isStale
|
|
||||||
? 'rate_limited_serving_stale'
|
|
||||||
: cachedMeta.reason || 'cached',
|
|
||||||
staleSeconds: isStale
|
|
||||||
? (now - _openskyCacheTime) / 1000
|
|
||||||
: undefined,
|
|
||||||
retryAfterSeconds: inCooldown
|
|
||||||
? (_openskyCooldownUntil - now) / 1000
|
|
||||||
: undefined,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
res.end(_openskyCacheBody);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Cooling down with nothing cached (cold start into a rate limit):
|
|
||||||
// synthesize the 429 locally — hammering upstream mid-cooldown can't
|
|
||||||
// succeed and just burns goodwill.
|
|
||||||
if (inCooldown) {
|
|
||||||
if (
|
|
||||||
await serveAdsbLolPointFallback(
|
|
||||||
req,
|
|
||||||
res,
|
|
||||||
requestedMode,
|
|
||||||
'opensky_cooldown_regional_fallback',
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
res.writeHead(
|
|
||||||
429,
|
|
||||||
buildOpenSkyHeaders({
|
|
||||||
cacheStatus: 'COOLDOWN',
|
|
||||||
requestedMode,
|
|
||||||
usedMode: 'none',
|
|
||||||
reason: 'rate_limited',
|
|
||||||
retryAfterSeconds: (_openskyCooldownUntil - now) / 1000,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
res.end(
|
|
||||||
JSON.stringify({
|
|
||||||
error: 'OpenSky rate limited; proxy cooling down.',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const basicUser = process.env.OPENSKY_USERNAME || '';
|
|
||||||
const basicPass = process.env.OPENSKY_PASSWORD || '';
|
|
||||||
const hasBasicCreds = Boolean(basicUser && basicPass);
|
|
||||||
const headers = { Accept: 'application/json' };
|
|
||||||
let usedMode = 'anon';
|
|
||||||
let reason = 'forced_anonymous';
|
|
||||||
|
|
||||||
if (requestedMode === 'basic') {
|
|
||||||
if (hasBasicCreds) {
|
|
||||||
headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`;
|
|
||||||
usedMode = 'basic';
|
|
||||||
reason = 'basic_credentials';
|
|
||||||
} else {
|
|
||||||
reason = 'missing_basic_creds';
|
|
||||||
}
|
|
||||||
} else if (requestedMode === 'oauth') {
|
|
||||||
const token = await getOpenSkyToken();
|
|
||||||
if (token) {
|
|
||||||
headers.Authorization = `Bearer ${token}`;
|
|
||||||
usedMode = 'oauth';
|
|
||||||
reason = 'oauth_token';
|
|
||||||
} else {
|
|
||||||
reason = 'oauth_invalid_or_missing';
|
|
||||||
}
|
|
||||||
} else if (requestedMode === 'auto') {
|
|
||||||
const token = await getOpenSkyToken();
|
|
||||||
if (token) {
|
|
||||||
headers.Authorization = `Bearer ${token}`;
|
|
||||||
usedMode = 'oauth';
|
|
||||||
reason = 'oauth_token';
|
|
||||||
} else if (hasBasicCreds) {
|
|
||||||
headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`;
|
|
||||||
usedMode = 'basic';
|
|
||||||
reason = 'oauth_unavailable_fallback_basic';
|
|
||||||
} else {
|
|
||||||
reason = 'missing_oauth_and_basic_creds';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let upstream = await fetch(
|
|
||||||
'https://opensky-network.org/api/states/all?extended=1',
|
|
||||||
{ headers },
|
|
||||||
);
|
|
||||||
// Auto-mode fallback: if OAuth was rejected, retry with Basic credentials
|
|
||||||
if (
|
|
||||||
(upstream.status === 401 || upstream.status === 403) &&
|
|
||||||
requestedMode === 'auto' &&
|
|
||||||
usedMode === 'oauth' &&
|
|
||||||
hasBasicCreds
|
|
||||||
) {
|
|
||||||
const retryHeaders = {
|
|
||||||
Accept: 'application/json',
|
|
||||||
Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`,
|
|
||||||
};
|
|
||||||
upstream = await fetch(
|
|
||||||
'https://opensky-network.org/api/states/all?extended=1',
|
|
||||||
{ headers: retryHeaders },
|
|
||||||
);
|
|
||||||
usedMode = 'basic';
|
|
||||||
reason = 'oauth_rejected_fallback_basic';
|
|
||||||
}
|
|
||||||
|
|
||||||
let body = await upstream.text();
|
|
||||||
const sourceEpochMs = upstream.ok ? openSkySourceEpochMs(body) : null;
|
|
||||||
if (
|
|
||||||
upstream.ok &&
|
|
||||||
openSkySourceIsStale(sourceEpochMs, now) &&
|
|
||||||
(await serveAdsbLolPointFallback(
|
(await serveAdsbLolPointFallback(
|
||||||
req,
|
req,
|
||||||
res,
|
res,
|
||||||
@@ -507,193 +372,330 @@ export function openSkyProxy() {
|
|||||||
'opensky_snapshot_stale_regional_fallback',
|
'opensky_snapshot_stale_regional_fallback',
|
||||||
))
|
))
|
||||||
) {
|
) {
|
||||||
// Keep the last global snapshot available as a fail-soft cache,
|
|
||||||
// but do not label or render it as a fresh live result.
|
|
||||||
_openskyCacheBody = body;
|
|
||||||
_openskyCacheStatus = upstream.status;
|
|
||||||
_openskyCacheTime = now;
|
|
||||||
_openskyCacheSourceEpochMs = sourceEpochMs;
|
|
||||||
_openskyCacheMeta = { requestedMode, usedMode, reason };
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (upstream.status === 429) {
|
const cachedMeta = _openskyCacheMeta || {
|
||||||
reason = 'rate_limited';
|
requestedMode,
|
||||||
// Credit governor: honor OpenSky's retry-after (bounded 30 s … 30 min;
|
usedMode: 'unknown',
|
||||||
// 2 min when the header is absent) — no upstream attempts until then.
|
reason: 'cached',
|
||||||
const retryAfterSec = Number(
|
};
|
||||||
upstream.headers.get('x-rate-limit-retry-after-seconds'),
|
const isStale = now - _openskyCacheTime >= _openskyTtlMs;
|
||||||
);
|
|
||||||
const cooldownMs = Math.min(
|
|
||||||
Math.max(
|
|
||||||
Number.isFinite(retryAfterSec) ? retryAfterSec * 1000 : 120_000,
|
|
||||||
30_000,
|
|
||||||
),
|
|
||||||
30 * 60_000,
|
|
||||||
);
|
|
||||||
_openskyCooldownUntil = now + cooldownMs;
|
|
||||||
// Serve the last-good body instead of the 429 when we have one —
|
|
||||||
// the layer keeps rendering (STALE-cued) instead of dying.
|
|
||||||
if (_openskyCacheBody && _openskyCacheStatus === 200) {
|
|
||||||
res.writeHead(
|
|
||||||
200,
|
|
||||||
buildOpenSkyHeaders({
|
|
||||||
cacheStatus: 'STALE',
|
|
||||||
requestedMode,
|
|
||||||
usedMode,
|
|
||||||
reason: 'rate_limited_serving_stale',
|
|
||||||
staleSeconds: (now - _openskyCacheTime) / 1000,
|
|
||||||
retryAfterSeconds: cooldownMs / 1000,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
res.end(_openskyCacheBody);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!upstream.ok && !_openskyCacheBody) {
|
|
||||||
const servedFallback = await serveAdsbLolPointFallback(
|
|
||||||
req,
|
|
||||||
res,
|
|
||||||
requestedMode,
|
|
||||||
`opensky_http_${upstream.status}_regional_fallback`,
|
|
||||||
);
|
|
||||||
if (servedFallback) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (upstream.status === 401 || upstream.status === 403) {
|
|
||||||
if (requestedMode === 'basic' && !hasBasicCreds) {
|
|
||||||
body = JSON.stringify({
|
|
||||||
error:
|
|
||||||
'OpenSky auth missing. Basic mode requires OPENSKY_USERNAME and OPENSKY_PASSWORD.',
|
|
||||||
});
|
|
||||||
reason = 'missing_basic_creds';
|
|
||||||
} else if (requestedMode === 'oauth' && usedMode !== 'oauth') {
|
|
||||||
body = JSON.stringify({
|
|
||||||
error:
|
|
||||||
'OpenSky auth invalid. OAuth mode requires valid OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET.',
|
|
||||||
});
|
|
||||||
reason = 'oauth_invalid_or_missing';
|
|
||||||
} else if (usedMode === 'basic') {
|
|
||||||
body = JSON.stringify({
|
|
||||||
error: 'OpenSky auth invalid. Username/password were rejected.',
|
|
||||||
});
|
|
||||||
reason = 'basic_invalid_credentials';
|
|
||||||
} else if (usedMode === 'oauth') {
|
|
||||||
body = JSON.stringify({
|
|
||||||
error:
|
|
||||||
'OpenSky auth invalid. OAuth client credentials were rejected.',
|
|
||||||
});
|
|
||||||
reason = 'oauth_invalid_credentials';
|
|
||||||
} else if (requestedMode === 'auto' && !hasBasicCreds) {
|
|
||||||
body = JSON.stringify({
|
|
||||||
error:
|
|
||||||
'OpenSky auth missing. Provide basic credentials or valid OAuth client credentials.',
|
|
||||||
});
|
|
||||||
reason = 'missing_oauth_and_basic_creds';
|
|
||||||
} else {
|
|
||||||
body = JSON.stringify({
|
|
||||||
error: 'OpenSky auth required.',
|
|
||||||
});
|
|
||||||
reason = 'auth_required';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refine the reason string to reflect the actual outcome
|
|
||||||
if (upstream.ok && reason === 'forced_anonymous') {
|
|
||||||
reason = 'anonymous_ok';
|
|
||||||
} else if (
|
|
||||||
upstream.ok &&
|
|
||||||
usedMode === 'basic' &&
|
|
||||||
reason === 'basic_credentials'
|
|
||||||
) {
|
|
||||||
reason = 'basic_ok';
|
|
||||||
} else if (
|
|
||||||
upstream.ok &&
|
|
||||||
usedMode === 'oauth' &&
|
|
||||||
reason === 'oauth_token'
|
|
||||||
) {
|
|
||||||
reason = 'oauth_ok';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only cache successful responses — error responses (401/403/429/5xx)
|
|
||||||
// should not be served from cache on subsequent requests
|
|
||||||
if (upstream.ok) {
|
|
||||||
_openskyCacheBody = body;
|
|
||||||
_openskyCacheStatus = upstream.status;
|
|
||||||
_openskyCacheTime = now;
|
|
||||||
_openskyCacheSourceEpochMs = sourceEpochMs;
|
|
||||||
_openskyCacheMeta = {
|
|
||||||
requestedMode,
|
|
||||||
usedMode,
|
|
||||||
reason,
|
|
||||||
};
|
|
||||||
// Credit governor: adapt the cache TTL to the remaining daily
|
|
||||||
// budget so a continuously-open app stretches its polls instead of
|
|
||||||
// exhausting the quota mid-day. Success also clears any cooldown.
|
|
||||||
const remaining = Number(
|
|
||||||
upstream.headers.get('x-rate-limit-remaining'),
|
|
||||||
);
|
|
||||||
_openskyTtlMs = openskyAdaptiveTtlMs(remaining);
|
|
||||||
_openskyCooldownUntil = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.writeHead(
|
res.writeHead(
|
||||||
upstream.status,
|
_openskyCacheStatus || 200,
|
||||||
buildOpenSkyHeaders({
|
buildOpenSkyHeaders({
|
||||||
cacheStatus: 'MISS',
|
cacheStatus: isStale ? 'STALE' : 'HIT',
|
||||||
requestedMode,
|
requestedMode: cachedMeta.requestedMode || requestedMode,
|
||||||
usedMode,
|
usedMode: cachedMeta.usedMode || 'unknown',
|
||||||
reason,
|
reason: isStale
|
||||||
|
? 'rate_limited_serving_stale'
|
||||||
|
: cachedMeta.reason || 'cached',
|
||||||
|
staleSeconds: isStale
|
||||||
|
? (now - _openskyCacheTime) / 1000
|
||||||
|
: undefined,
|
||||||
|
retryAfterSeconds: inCooldown
|
||||||
|
? (_openskyCooldownUntil - now) / 1000
|
||||||
|
: undefined,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
res.end(body);
|
res.end(_openskyCacheBody);
|
||||||
} catch (e) {
|
return;
|
||||||
console.error('[OpenSky Proxy]', e.message);
|
}
|
||||||
if (_openskyCacheBody) {
|
// Cooling down with nothing cached (cold start into a rate limit):
|
||||||
const cachedMeta = _openskyCacheMeta || {
|
// synthesize the 429 locally — hammering upstream mid-cooldown can't
|
||||||
requestedMode: normalizeOpenSkyAuthMode(
|
// succeed and just burns goodwill.
|
||||||
process.env.OPENSKY_AUTH_MODE,
|
if (inCooldown) {
|
||||||
),
|
|
||||||
usedMode: 'unknown',
|
|
||||||
reason: 'cached_stale',
|
|
||||||
};
|
|
||||||
res.writeHead(
|
|
||||||
_openskyCacheStatus || 200,
|
|
||||||
buildOpenSkyHeaders({
|
|
||||||
cacheStatus: 'STALE',
|
|
||||||
requestedMode:
|
|
||||||
cachedMeta.requestedMode || OPENSKY_AUTH_MODE_DEFAULT,
|
|
||||||
usedMode: cachedMeta.usedMode || 'unknown',
|
|
||||||
reason: cachedMeta.reason || 'cached_stale',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
res.end(_openskyCacheBody);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const requestedMode = normalizeOpenSkyAuthMode(
|
|
||||||
process.env.OPENSKY_AUTH_MODE,
|
|
||||||
);
|
|
||||||
if (
|
if (
|
||||||
await serveAdsbLolPointFallback(
|
await serveAdsbLolPointFallback(
|
||||||
req,
|
req,
|
||||||
res,
|
res,
|
||||||
requestedMode,
|
requestedMode,
|
||||||
'opensky_proxy_error_regional_fallback',
|
'opensky_cooldown_regional_fallback',
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
res.writeHead(
|
res.writeHead(
|
||||||
502,
|
429,
|
||||||
buildOpenSkyHeaders({
|
buildOpenSkyHeaders({
|
||||||
cacheStatus: 'MISS',
|
cacheStatus: 'COOLDOWN',
|
||||||
requestedMode,
|
requestedMode,
|
||||||
usedMode: 'error',
|
usedMode: 'none',
|
||||||
reason: 'proxy_error',
|
reason: 'rate_limited',
|
||||||
|
retryAfterSeconds: (_openskyCooldownUntil - now) / 1000,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
res.end(JSON.stringify({ error: 'OpenSky proxy error' }));
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'OpenSky rate limited; proxy cooling down.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
},
|
const basicUser = process.env.OPENSKY_USERNAME || '';
|
||||||
|
const basicPass = process.env.OPENSKY_PASSWORD || '';
|
||||||
|
const hasBasicCreds = Boolean(basicUser && basicPass);
|
||||||
|
const headers = { Accept: 'application/json' };
|
||||||
|
let usedMode = 'anon';
|
||||||
|
let reason = 'forced_anonymous';
|
||||||
|
|
||||||
|
if (requestedMode === 'basic') {
|
||||||
|
if (hasBasicCreds) {
|
||||||
|
headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`;
|
||||||
|
usedMode = 'basic';
|
||||||
|
reason = 'basic_credentials';
|
||||||
|
} else {
|
||||||
|
reason = 'missing_basic_creds';
|
||||||
|
}
|
||||||
|
} else if (requestedMode === 'oauth') {
|
||||||
|
const token = await getOpenSkyToken();
|
||||||
|
if (token) {
|
||||||
|
headers.Authorization = `Bearer ${token}`;
|
||||||
|
usedMode = 'oauth';
|
||||||
|
reason = 'oauth_token';
|
||||||
|
} else {
|
||||||
|
reason = 'oauth_invalid_or_missing';
|
||||||
|
}
|
||||||
|
} else if (requestedMode === 'auto') {
|
||||||
|
const token = await getOpenSkyToken();
|
||||||
|
if (token) {
|
||||||
|
headers.Authorization = `Bearer ${token}`;
|
||||||
|
usedMode = 'oauth';
|
||||||
|
reason = 'oauth_token';
|
||||||
|
} else if (hasBasicCreds) {
|
||||||
|
headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`;
|
||||||
|
usedMode = 'basic';
|
||||||
|
reason = 'oauth_unavailable_fallback_basic';
|
||||||
|
} else {
|
||||||
|
reason = 'missing_oauth_and_basic_creds';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let upstream = await fetch(
|
||||||
|
'https://opensky-network.org/api/states/all?extended=1',
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
// Auto-mode fallback: if OAuth was rejected, retry with Basic credentials
|
||||||
|
if (
|
||||||
|
(upstream.status === 401 || upstream.status === 403) &&
|
||||||
|
requestedMode === 'auto' &&
|
||||||
|
usedMode === 'oauth' &&
|
||||||
|
hasBasicCreds
|
||||||
|
) {
|
||||||
|
const retryHeaders = {
|
||||||
|
Accept: 'application/json',
|
||||||
|
Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`,
|
||||||
|
};
|
||||||
|
upstream = await fetch(
|
||||||
|
'https://opensky-network.org/api/states/all?extended=1',
|
||||||
|
{ headers: retryHeaders },
|
||||||
|
);
|
||||||
|
usedMode = 'basic';
|
||||||
|
reason = 'oauth_rejected_fallback_basic';
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = await upstream.text();
|
||||||
|
const sourceEpochMs = upstream.ok ? openSkySourceEpochMs(body) : null;
|
||||||
|
if (
|
||||||
|
upstream.ok &&
|
||||||
|
openSkySourceIsStale(sourceEpochMs, now) &&
|
||||||
|
(await serveAdsbLolPointFallback(
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
requestedMode,
|
||||||
|
'opensky_snapshot_stale_regional_fallback',
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
// Keep the last global snapshot available as a fail-soft cache,
|
||||||
|
// but do not label or render it as a fresh live result.
|
||||||
|
_openskyCacheBody = body;
|
||||||
|
_openskyCacheStatus = upstream.status;
|
||||||
|
_openskyCacheTime = now;
|
||||||
|
_openskyCacheSourceEpochMs = sourceEpochMs;
|
||||||
|
_openskyCacheMeta = { requestedMode, usedMode, reason };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (upstream.status === 429) {
|
||||||
|
reason = 'rate_limited';
|
||||||
|
// Credit governor: honor OpenSky's retry-after (bounded 30 s … 30 min;
|
||||||
|
// 2 min when the header is absent) — no upstream attempts until then.
|
||||||
|
const retryAfterSec = Number(
|
||||||
|
upstream.headers.get('x-rate-limit-retry-after-seconds'),
|
||||||
|
);
|
||||||
|
const cooldownMs = Math.min(
|
||||||
|
Math.max(
|
||||||
|
Number.isFinite(retryAfterSec) ? retryAfterSec * 1000 : 120_000,
|
||||||
|
30_000,
|
||||||
|
),
|
||||||
|
30 * 60_000,
|
||||||
|
);
|
||||||
|
_openskyCooldownUntil = now + cooldownMs;
|
||||||
|
// Serve the last-good body instead of the 429 when we have one —
|
||||||
|
// the layer keeps rendering (STALE-cued) instead of dying.
|
||||||
|
if (_openskyCacheBody && _openskyCacheStatus === 200) {
|
||||||
|
res.writeHead(
|
||||||
|
200,
|
||||||
|
buildOpenSkyHeaders({
|
||||||
|
cacheStatus: 'STALE',
|
||||||
|
requestedMode,
|
||||||
|
usedMode,
|
||||||
|
reason: 'rate_limited_serving_stale',
|
||||||
|
staleSeconds: (now - _openskyCacheTime) / 1000,
|
||||||
|
retryAfterSeconds: cooldownMs / 1000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
res.end(_openskyCacheBody);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!upstream.ok && !_openskyCacheBody) {
|
||||||
|
const servedFallback = await serveAdsbLolPointFallback(
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
requestedMode,
|
||||||
|
`opensky_http_${upstream.status}_regional_fallback`,
|
||||||
|
);
|
||||||
|
if (servedFallback) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (upstream.status === 401 || upstream.status === 403) {
|
||||||
|
if (requestedMode === 'basic' && !hasBasicCreds) {
|
||||||
|
body = JSON.stringify({
|
||||||
|
error:
|
||||||
|
'OpenSky auth missing. Basic mode requires OPENSKY_USERNAME and OPENSKY_PASSWORD.',
|
||||||
|
});
|
||||||
|
reason = 'missing_basic_creds';
|
||||||
|
} else if (requestedMode === 'oauth' && usedMode !== 'oauth') {
|
||||||
|
body = JSON.stringify({
|
||||||
|
error:
|
||||||
|
'OpenSky auth invalid. OAuth mode requires valid OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET.',
|
||||||
|
});
|
||||||
|
reason = 'oauth_invalid_or_missing';
|
||||||
|
} else if (usedMode === 'basic') {
|
||||||
|
body = JSON.stringify({
|
||||||
|
error: 'OpenSky auth invalid. Username/password were rejected.',
|
||||||
|
});
|
||||||
|
reason = 'basic_invalid_credentials';
|
||||||
|
} else if (usedMode === 'oauth') {
|
||||||
|
body = JSON.stringify({
|
||||||
|
error:
|
||||||
|
'OpenSky auth invalid. OAuth client credentials were rejected.',
|
||||||
|
});
|
||||||
|
reason = 'oauth_invalid_credentials';
|
||||||
|
} else if (requestedMode === 'auto' && !hasBasicCreds) {
|
||||||
|
body = JSON.stringify({
|
||||||
|
error:
|
||||||
|
'OpenSky auth missing. Provide basic credentials or valid OAuth client credentials.',
|
||||||
|
});
|
||||||
|
reason = 'missing_oauth_and_basic_creds';
|
||||||
|
} else {
|
||||||
|
body = JSON.stringify({
|
||||||
|
error: 'OpenSky auth required.',
|
||||||
|
});
|
||||||
|
reason = 'auth_required';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refine the reason string to reflect the actual outcome
|
||||||
|
if (upstream.ok && reason === 'forced_anonymous') {
|
||||||
|
reason = 'anonymous_ok';
|
||||||
|
} else if (
|
||||||
|
upstream.ok &&
|
||||||
|
usedMode === 'basic' &&
|
||||||
|
reason === 'basic_credentials'
|
||||||
|
) {
|
||||||
|
reason = 'basic_ok';
|
||||||
|
} else if (
|
||||||
|
upstream.ok &&
|
||||||
|
usedMode === 'oauth' &&
|
||||||
|
reason === 'oauth_token'
|
||||||
|
) {
|
||||||
|
reason = 'oauth_ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only cache successful responses — error responses (401/403/429/5xx)
|
||||||
|
// should not be served from cache on subsequent requests
|
||||||
|
if (upstream.ok) {
|
||||||
|
_openskyCacheBody = body;
|
||||||
|
_openskyCacheStatus = upstream.status;
|
||||||
|
_openskyCacheTime = now;
|
||||||
|
_openskyCacheSourceEpochMs = sourceEpochMs;
|
||||||
|
_openskyCacheMeta = {
|
||||||
|
requestedMode,
|
||||||
|
usedMode,
|
||||||
|
reason,
|
||||||
|
};
|
||||||
|
// Credit governor: adapt the cache TTL to the remaining daily
|
||||||
|
// budget so a continuously-open app stretches its polls instead of
|
||||||
|
// exhausting the quota mid-day. Success also clears any cooldown.
|
||||||
|
const remaining = Number(
|
||||||
|
upstream.headers.get('x-rate-limit-remaining'),
|
||||||
|
);
|
||||||
|
_openskyTtlMs = openskyAdaptiveTtlMs(remaining);
|
||||||
|
_openskyCooldownUntil = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(
|
||||||
|
upstream.status,
|
||||||
|
buildOpenSkyHeaders({
|
||||||
|
cacheStatus: 'MISS',
|
||||||
|
requestedMode,
|
||||||
|
usedMode,
|
||||||
|
reason,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
res.end(body);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[OpenSky Proxy]', e.message);
|
||||||
|
if (_openskyCacheBody) {
|
||||||
|
const cachedMeta = _openskyCacheMeta || {
|
||||||
|
requestedMode: normalizeOpenSkyAuthMode(
|
||||||
|
process.env.OPENSKY_AUTH_MODE,
|
||||||
|
),
|
||||||
|
usedMode: 'unknown',
|
||||||
|
reason: 'cached_stale',
|
||||||
|
};
|
||||||
|
res.writeHead(
|
||||||
|
_openskyCacheStatus || 200,
|
||||||
|
buildOpenSkyHeaders({
|
||||||
|
cacheStatus: 'STALE',
|
||||||
|
requestedMode:
|
||||||
|
cachedMeta.requestedMode || OPENSKY_AUTH_MODE_DEFAULT,
|
||||||
|
usedMode: cachedMeta.usedMode || 'unknown',
|
||||||
|
reason: cachedMeta.reason || 'cached_stale',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
res.end(_openskyCacheBody);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const requestedMode = normalizeOpenSkyAuthMode(
|
||||||
|
process.env.OPENSKY_AUTH_MODE,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
await serveAdsbLolPointFallback(
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
requestedMode,
|
||||||
|
'opensky_proxy_error_regional_fallback',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
res.writeHead(
|
||||||
|
502,
|
||||||
|
buildOpenSkyHeaders({
|
||||||
|
cacheStatus: 'MISS',
|
||||||
|
requestedMode,
|
||||||
|
usedMode: 'error',
|
||||||
|
reason: 'proxy_error',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
res.end(JSON.stringify({ error: 'OpenSky proxy error' }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
name: 'opensky-proxy',
|
||||||
|
configureServer: installMiddleware,
|
||||||
|
configurePreviewServer: installMiddleware,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
+227
-228
@@ -119,276 +119,275 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
const installMiddleware = (server) => {
|
||||||
name: 'cctv-proxy',
|
server.middlewares.use('/api/cctv', async (req, res) => {
|
||||||
configureServer(server) {
|
try {
|
||||||
server.middlewares.use('/api/cctv', async (req, res) => {
|
const sources = await getCctvSources();
|
||||||
try {
|
const sourceById = new Map(
|
||||||
const sources = await getCctvSources();
|
sources.map((source) => [source.id, source]),
|
||||||
const sourceById = new Map(
|
);
|
||||||
sources.map((source) => [source.id, source]),
|
const url = new URL(req.url || '/', 'http://localhost');
|
||||||
);
|
|
||||||
const url = new URL(req.url || '/', 'http://localhost');
|
|
||||||
|
|
||||||
if (url.pathname === '/sources') {
|
if (url.pathname === '/sources') {
|
||||||
const body = {
|
const body = {
|
||||||
sources: sources.map((source) => ({
|
sources: sources.map((source) => ({
|
||||||
id: source.id,
|
id: source.id,
|
||||||
name: source.name,
|
name: source.name,
|
||||||
city: source.city,
|
city: source.city,
|
||||||
cityId: source.cityId,
|
cityId: source.cityId,
|
||||||
provider: source.provider,
|
provider: source.provider,
|
||||||
lat: source.lat,
|
lat: source.lat,
|
||||||
lon: source.lon,
|
lon: source.lon,
|
||||||
headingDeg: source.headingDeg,
|
headingDeg: source.headingDeg,
|
||||||
headingConfidence: source.headingConfidence || '',
|
headingConfidence: source.headingConfidence || '',
|
||||||
pitchDeg: source.pitchDeg,
|
pitchDeg: source.pitchDeg,
|
||||||
fovDeg: source.fovDeg,
|
fovDeg: source.fovDeg,
|
||||||
rangeM: source.rangeM,
|
rangeM: source.rangeM,
|
||||||
mountHeightM: source.mountHeightM,
|
mountHeightM: source.mountHeightM,
|
||||||
groundElevationM: source.groundElevationM,
|
groundElevationM: source.groundElevationM,
|
||||||
feedType: normalizeFeedType(source.feedType),
|
feedType: normalizeFeedType(source.feedType),
|
||||||
sourceKind:
|
sourceKind:
|
||||||
source.sourceKind || (source.url ? 'configured' : 'fallback'),
|
source.sourceKind || (source.url ? 'configured' : 'fallback'),
|
||||||
poseSource: source.poseSource,
|
poseSource: source.poseSource,
|
||||||
license: source.license,
|
license: source.license,
|
||||||
})),
|
})),
|
||||||
|
};
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
});
|
||||||
|
res.end(JSON.stringify(body));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/health') {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
});
|
||||||
|
res.end(JSON.stringify({ cameras: listHealth() }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.startsWith('/stream/')) {
|
||||||
|
const cameraId =
|
||||||
|
decodeURIComponent(url.pathname.replace('/stream/', '').trim()) ||
|
||||||
|
'camera';
|
||||||
|
const source = sourceById.get(cameraId);
|
||||||
|
const payload = buildStreamPayload(source, cameraId);
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
});
|
||||||
|
res.end(JSON.stringify(payload));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.startsWith('/media/')) {
|
||||||
|
const cameraId =
|
||||||
|
decodeURIComponent(url.pathname.replace('/media/', '').trim()) ||
|
||||||
|
'camera';
|
||||||
|
const source = sourceById.get(cameraId);
|
||||||
|
const mediaUrl = source?.url || '';
|
||||||
|
const feedType = normalizeFeedType(source?.feedType || 'image');
|
||||||
|
|
||||||
|
if (!mediaUrl || !/^https?:\/\//i.test(mediaUrl)) {
|
||||||
|
setHealth(cameraId, {
|
||||||
|
status: 'degraded',
|
||||||
|
sourceKind: 'fallback',
|
||||||
|
label: source?.provider || 'No upstream URL',
|
||||||
|
message: 'No stream URL configured',
|
||||||
|
});
|
||||||
|
res.writeHead(404, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
});
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'No media URL configured for this camera',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const upstreamHeaders = {
|
||||||
|
'User-Agent': 'gods-eye-view-cctv-proxy/1.0',
|
||||||
};
|
};
|
||||||
res.writeHead(200, {
|
const requestRange = req.headers?.range;
|
||||||
'Content-Type': 'application/json',
|
if (requestRange) upstreamHeaders.Range = requestRange;
|
||||||
'Cache-Control': 'no-store',
|
const upstream = await fetch(mediaUrl, {
|
||||||
|
headers: upstreamHeaders,
|
||||||
});
|
});
|
||||||
res.end(JSON.stringify(body));
|
const contentType = upstream.headers.get('content-type') || '';
|
||||||
return;
|
if (!upstream.ok) {
|
||||||
}
|
|
||||||
|
|
||||||
if (url.pathname === '/health') {
|
|
||||||
res.writeHead(200, {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Cache-Control': 'no-store',
|
|
||||||
});
|
|
||||||
res.end(JSON.stringify({ cameras: listHealth() }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.pathname.startsWith('/stream/')) {
|
|
||||||
const cameraId =
|
|
||||||
decodeURIComponent(url.pathname.replace('/stream/', '').trim()) ||
|
|
||||||
'camera';
|
|
||||||
const source = sourceById.get(cameraId);
|
|
||||||
const payload = buildStreamPayload(source, cameraId);
|
|
||||||
res.writeHead(200, {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Cache-Control': 'no-store',
|
|
||||||
});
|
|
||||||
res.end(JSON.stringify(payload));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.pathname.startsWith('/media/')) {
|
|
||||||
const cameraId =
|
|
||||||
decodeURIComponent(url.pathname.replace('/media/', '').trim()) ||
|
|
||||||
'camera';
|
|
||||||
const source = sourceById.get(cameraId);
|
|
||||||
const mediaUrl = source?.url || '';
|
|
||||||
const feedType = normalizeFeedType(source?.feedType || 'image');
|
|
||||||
|
|
||||||
if (!mediaUrl || !/^https?:\/\//i.test(mediaUrl)) {
|
|
||||||
setHealth(cameraId, {
|
setHealth(cameraId, {
|
||||||
status: 'degraded',
|
status: 'degraded',
|
||||||
sourceKind: 'fallback',
|
sourceKind: 'upstream',
|
||||||
label: source?.provider || 'No upstream URL',
|
label: source?.provider || 'Configured source',
|
||||||
message: 'No stream URL configured',
|
message: `Upstream HTTP ${upstream.status}`,
|
||||||
});
|
});
|
||||||
res.writeHead(404, {
|
res.writeHead(upstream.status, {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Cache-Control': 'no-store',
|
'Cache-Control': 'no-store',
|
||||||
});
|
});
|
||||||
res.end(
|
res.end(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: 'No media URL configured for this camera',
|
error: `Upstream returned ${upstream.status}`,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (
|
||||||
const upstreamHeaders = {
|
isVideoFeedType(feedType) &&
|
||||||
'User-Agent': 'gods-eye-view-cctv-proxy/1.0',
|
!(
|
||||||
};
|
contentType.startsWith('video/') ||
|
||||||
const requestRange = req.headers?.range;
|
contentType.includes('mpegurl')
|
||||||
if (requestRange) upstreamHeaders.Range = requestRange;
|
)
|
||||||
const upstream = await fetch(mediaUrl, {
|
) {
|
||||||
headers: upstreamHeaders,
|
|
||||||
});
|
|
||||||
const contentType = upstream.headers.get('content-type') || '';
|
|
||||||
if (!upstream.ok) {
|
|
||||||
setHealth(cameraId, {
|
|
||||||
status: 'degraded',
|
|
||||||
sourceKind: 'upstream',
|
|
||||||
label: source?.provider || 'Configured source',
|
|
||||||
message: `Upstream HTTP ${upstream.status}`,
|
|
||||||
});
|
|
||||||
res.writeHead(upstream.status, {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Cache-Control': 'no-store',
|
|
||||||
});
|
|
||||||
res.end(
|
|
||||||
JSON.stringify({
|
|
||||||
error: `Upstream returned ${upstream.status}`,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
isVideoFeedType(feedType) &&
|
|
||||||
!(
|
|
||||||
contentType.startsWith('video/') ||
|
|
||||||
contentType.includes('mpegurl')
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
setHealth(cameraId, {
|
|
||||||
status: 'degraded',
|
|
||||||
sourceKind: 'upstream',
|
|
||||||
label: source?.provider || 'Configured source',
|
|
||||||
message: `Unexpected media type ${contentType || 'unknown'}`,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setHealth(cameraId, {
|
|
||||||
status: 'ok',
|
|
||||||
sourceKind: isVideoFeedType(feedType) ? 'live' : 'snapshot',
|
|
||||||
label: source?.provider || 'Configured source',
|
|
||||||
message: isVideoFeedType(feedType)
|
|
||||||
? 'Live stream connected'
|
|
||||||
: 'Snapshot feed connected',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await proxyMediaResponse(res, upstream, {
|
|
||||||
sourceHeader: isVideoFeedType(feedType)
|
|
||||||
? 'live-media'
|
|
||||||
: 'upstream-image',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
} catch (error) {
|
|
||||||
setHealth(cameraId, {
|
setHealth(cameraId, {
|
||||||
status: 'degraded',
|
status: 'degraded',
|
||||||
sourceKind: 'upstream',
|
sourceKind: 'upstream',
|
||||||
label: source?.provider || 'Configured source',
|
label: source?.provider || 'Configured source',
|
||||||
message: error?.message || 'Media fetch failed',
|
message: `Unexpected media type ${contentType || 'unknown'}`,
|
||||||
});
|
});
|
||||||
res.writeHead(502, {
|
} else {
|
||||||
'Content-Type': 'application/json',
|
setHealth(cameraId, {
|
||||||
'Cache-Control': 'no-store',
|
status: 'ok',
|
||||||
|
sourceKind: isVideoFeedType(feedType) ? 'live' : 'snapshot',
|
||||||
|
label: source?.provider || 'Configured source',
|
||||||
|
message: isVideoFeedType(feedType)
|
||||||
|
? 'Live stream connected'
|
||||||
|
: 'Snapshot feed connected',
|
||||||
});
|
});
|
||||||
res.end(JSON.stringify({ error: 'Media proxy failed' }));
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!url.pathname.startsWith('/frame/')) {
|
await proxyMediaResponse(res, upstream, {
|
||||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
sourceHeader: isVideoFeedType(feedType)
|
||||||
res.end(JSON.stringify({ error: 'not found' }));
|
? 'live-media'
|
||||||
return;
|
: 'upstream-image',
|
||||||
}
|
|
||||||
|
|
||||||
const cameraId =
|
|
||||||
decodeURIComponent(url.pathname.replace('/frame/', '').trim()) ||
|
|
||||||
'camera';
|
|
||||||
const source = sourceById.get(cameraId);
|
|
||||||
const label =
|
|
||||||
url.searchParams.get('label') || source?.name || cameraId;
|
|
||||||
const city = url.searchParams.get('city') || source?.city || '';
|
|
||||||
const lat = Number(url.searchParams.get('lat') || source?.lat);
|
|
||||||
const lon = Number(url.searchParams.get('lon') || source?.lon);
|
|
||||||
const heading = Number(
|
|
||||||
url.searchParams.get('heading') || source?.headingDeg,
|
|
||||||
);
|
|
||||||
const fov = Number(url.searchParams.get('fov') || source?.fovDeg);
|
|
||||||
const pitch = Number(
|
|
||||||
url.searchParams.get('pitch') || source?.pitchDeg,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Only use server-registered upstream URLs — never accept client-supplied URLs
|
|
||||||
// (prevents SSRF via ?upstream= query parameter)
|
|
||||||
const upstreamCandidate =
|
|
||||||
source?.snapshotUrl ||
|
|
||||||
(!isVideoFeedType(normalizeFeedType(source?.feedType))
|
|
||||||
? source?.url
|
|
||||||
: '');
|
|
||||||
|
|
||||||
const upstreamImage =
|
|
||||||
await fetchCctvImageFromUpstream(upstreamCandidate);
|
|
||||||
if (upstreamImage?.ok) {
|
|
||||||
setHealth(cameraId, {
|
|
||||||
status: 'ok',
|
|
||||||
sourceKind: 'snapshot',
|
|
||||||
label: source?.provider || 'Configured source',
|
|
||||||
message: 'Upstream snapshot active',
|
|
||||||
});
|
});
|
||||||
res.writeHead(200, {
|
|
||||||
'Content-Type': upstreamImage.contentType,
|
|
||||||
'Cache-Control': 'no-store',
|
|
||||||
'X-CCTV-Source': 'upstream-image',
|
|
||||||
});
|
|
||||||
res.end(upstreamImage.body);
|
|
||||||
return;
|
return;
|
||||||
}
|
} catch (error) {
|
||||||
|
|
||||||
const sv = await streetViewFallback({
|
|
||||||
lat,
|
|
||||||
lon,
|
|
||||||
heading,
|
|
||||||
fov,
|
|
||||||
pitch,
|
|
||||||
});
|
|
||||||
if (sv?.ok) {
|
|
||||||
setHealth(cameraId, {
|
setHealth(cameraId, {
|
||||||
status: 'degraded',
|
status: 'degraded',
|
||||||
sourceKind: 'streetview',
|
sourceKind: 'upstream',
|
||||||
label: 'Google Street View',
|
label: source?.provider || 'Configured source',
|
||||||
message: 'Fallback Street View frame',
|
message: error?.message || 'Media fetch failed',
|
||||||
});
|
});
|
||||||
res.writeHead(200, {
|
res.writeHead(502, {
|
||||||
'Content-Type': sv.contentType,
|
'Content-Type': 'application/json',
|
||||||
'Cache-Control': 'no-store',
|
'Cache-Control': 'no-store',
|
||||||
'X-CCTV-Source': 'streetview',
|
|
||||||
});
|
});
|
||||||
res.end(sv.body);
|
res.end(JSON.stringify({ error: 'Media proxy failed' }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const svg = buildSyntheticCctvSvg({
|
if (!url.pathname.startsWith('/frame/')) {
|
||||||
cameraId,
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
label,
|
res.end(JSON.stringify({ error: 'not found' }));
|
||||||
city,
|
return;
|
||||||
status: source?.url
|
}
|
||||||
? 'UPSTREAM UNAVAILABLE'
|
|
||||||
: 'NO UPSTREAM CONFIGURED',
|
const cameraId =
|
||||||
|
decodeURIComponent(url.pathname.replace('/frame/', '').trim()) ||
|
||||||
|
'camera';
|
||||||
|
const source = sourceById.get(cameraId);
|
||||||
|
const label = url.searchParams.get('label') || source?.name || cameraId;
|
||||||
|
const city = url.searchParams.get('city') || source?.city || '';
|
||||||
|
const lat = Number(url.searchParams.get('lat') || source?.lat);
|
||||||
|
const lon = Number(url.searchParams.get('lon') || source?.lon);
|
||||||
|
const heading = Number(
|
||||||
|
url.searchParams.get('heading') || source?.headingDeg,
|
||||||
|
);
|
||||||
|
const fov = Number(url.searchParams.get('fov') || source?.fovDeg);
|
||||||
|
const pitch = Number(url.searchParams.get('pitch') || source?.pitchDeg);
|
||||||
|
|
||||||
|
// Only use server-registered upstream URLs — never accept client-supplied URLs
|
||||||
|
// (prevents SSRF via ?upstream= query parameter)
|
||||||
|
const upstreamCandidate =
|
||||||
|
source?.snapshotUrl ||
|
||||||
|
(!isVideoFeedType(normalizeFeedType(source?.feedType))
|
||||||
|
? source?.url
|
||||||
|
: '');
|
||||||
|
|
||||||
|
const upstreamImage =
|
||||||
|
await fetchCctvImageFromUpstream(upstreamCandidate);
|
||||||
|
if (upstreamImage?.ok) {
|
||||||
|
setHealth(cameraId, {
|
||||||
|
status: 'ok',
|
||||||
|
sourceKind: 'snapshot',
|
||||||
|
label: source?.provider || 'Configured source',
|
||||||
|
message: 'Upstream snapshot active',
|
||||||
});
|
});
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': upstreamImage.contentType,
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'X-CCTV-Source': 'upstream-image',
|
||||||
|
});
|
||||||
|
res.end(upstreamImage.body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sv = await streetViewFallback({
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
heading,
|
||||||
|
fov,
|
||||||
|
pitch,
|
||||||
|
});
|
||||||
|
if (sv?.ok) {
|
||||||
setHealth(cameraId, {
|
setHealth(cameraId, {
|
||||||
status: 'degraded',
|
status: 'degraded',
|
||||||
sourceKind: 'synthetic',
|
sourceKind: 'streetview',
|
||||||
label: source?.provider || 'Synthetic fallback',
|
label: 'Google Street View',
|
||||||
message: source?.url
|
message: 'Fallback Street View frame',
|
||||||
? 'Upstream unavailable'
|
|
||||||
: 'No source configured',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': 'image/svg+xml',
|
'Content-Type': sv.contentType,
|
||||||
'Cache-Control': 'no-store',
|
'Cache-Control': 'no-store',
|
||||||
'X-CCTV-Source': 'synthetic',
|
'X-CCTV-Source': 'streetview',
|
||||||
});
|
});
|
||||||
res.end(svg);
|
res.end(sv.body);
|
||||||
} catch (error) {
|
return;
|
||||||
console.error('[CCTV Proxy]', error?.message || String(error));
|
|
||||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ error: 'CCTV proxy error' }));
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
},
|
const svg = buildSyntheticCctvSvg({
|
||||||
|
cameraId,
|
||||||
|
label,
|
||||||
|
city,
|
||||||
|
status: source?.url
|
||||||
|
? 'UPSTREAM UNAVAILABLE'
|
||||||
|
: 'NO UPSTREAM CONFIGURED',
|
||||||
|
});
|
||||||
|
|
||||||
|
setHealth(cameraId, {
|
||||||
|
status: 'degraded',
|
||||||
|
sourceKind: 'synthetic',
|
||||||
|
label: source?.provider || 'Synthetic fallback',
|
||||||
|
message: source?.url
|
||||||
|
? 'Upstream unavailable'
|
||||||
|
: 'No source configured',
|
||||||
|
});
|
||||||
|
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'image/svg+xml',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'X-CCTV-Source': 'synthetic',
|
||||||
|
});
|
||||||
|
res.end(svg);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[CCTV Proxy]', error?.message || String(error));
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'CCTV proxy error' }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
name: 'cctv-proxy',
|
||||||
|
configureServer: installMiddleware,
|
||||||
|
configurePreviewServer: installMiddleware,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
+88
-86
@@ -167,93 +167,95 @@ export function firmsProxy() {
|
|||||||
return statusInflight;
|
return statusInflight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const installMiddleware = (server) => {
|
||||||
|
server.middlewares.use('/api/firms', async (req, res) => {
|
||||||
|
const sendJson = (status, obj) => {
|
||||||
|
if (res.headersSent) return;
|
||||||
|
res.writeHead(status, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
});
|
||||||
|
res.end(JSON.stringify(obj));
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const subPath = String(req.url || '').split('?')[0];
|
||||||
|
const key = mapKey();
|
||||||
|
await readDiskOnce();
|
||||||
|
|
||||||
|
if (subPath === '/status') {
|
||||||
|
if (!key) {
|
||||||
|
sendJson(200, {
|
||||||
|
hasKey: false,
|
||||||
|
lastFetch: null,
|
||||||
|
count: null,
|
||||||
|
stale: false,
|
||||||
|
ttlMs: TTL_MS,
|
||||||
|
transactions: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const transactions = await getTransactions(key);
|
||||||
|
sendJson(200, {
|
||||||
|
hasKey: true,
|
||||||
|
lastFetch: mem ? mem.at : null,
|
||||||
|
count: mem ? mem.fires.length : null,
|
||||||
|
stale: mem ? Date.now() - mem.at >= TTL_MS : false,
|
||||||
|
ttlMs: TTL_MS,
|
||||||
|
transactions,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
sendJson(503, { error: 'no_key' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = mem;
|
||||||
|
if (entry && Date.now() - entry.at < TTL_MS) {
|
||||||
|
sendJson(200, buildPayload(entry, false));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Stale or missing → refresh, single-flight (concurrent requests
|
||||||
|
// share one upstream pass). Capture the promise locally BEFORE
|
||||||
|
// awaiting: the .finally() nulls `inflight` the moment it settles.
|
||||||
|
if (!inflight) {
|
||||||
|
inflight = refreshUpstream(key)
|
||||||
|
.then(async (fresh) => {
|
||||||
|
mem = fresh;
|
||||||
|
await writeDisk(fresh);
|
||||||
|
return fresh;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn(
|
||||||
|
`[firms-proxy] refresh failed (${err?.message || err}) — serving cache if any`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inflight = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const pending = inflight;
|
||||||
|
const fresh = await pending;
|
||||||
|
if (fresh) {
|
||||||
|
sendJson(200, buildPayload(fresh, false));
|
||||||
|
} else if (entry) {
|
||||||
|
sendJson(200, buildPayload(entry, true)); // upstream down — stale beats empty
|
||||||
|
} else {
|
||||||
|
sendJson(502, {
|
||||||
|
error: 'firms fetch failed and no cache available',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[firms-proxy] error:', err?.message || err);
|
||||||
|
sendJson(500, { error: 'firms proxy error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
name: 'firms-proxy',
|
name: 'firms-proxy',
|
||||||
configureServer(server) {
|
configureServer: installMiddleware,
|
||||||
server.middlewares.use('/api/firms', async (req, res) => {
|
configurePreviewServer: installMiddleware,
|
||||||
const sendJson = (status, obj) => {
|
|
||||||
if (res.headersSent) return;
|
|
||||||
res.writeHead(status, {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Cache-Control': 'no-store',
|
|
||||||
});
|
|
||||||
res.end(JSON.stringify(obj));
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const subPath = String(req.url || '').split('?')[0];
|
|
||||||
const key = mapKey();
|
|
||||||
await readDiskOnce();
|
|
||||||
|
|
||||||
if (subPath === '/status') {
|
|
||||||
if (!key) {
|
|
||||||
sendJson(200, {
|
|
||||||
hasKey: false,
|
|
||||||
lastFetch: null,
|
|
||||||
count: null,
|
|
||||||
stale: false,
|
|
||||||
ttlMs: TTL_MS,
|
|
||||||
transactions: null,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const transactions = await getTransactions(key);
|
|
||||||
sendJson(200, {
|
|
||||||
hasKey: true,
|
|
||||||
lastFetch: mem ? mem.at : null,
|
|
||||||
count: mem ? mem.fires.length : null,
|
|
||||||
stale: mem ? Date.now() - mem.at >= TTL_MS : false,
|
|
||||||
ttlMs: TTL_MS,
|
|
||||||
transactions,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!key) {
|
|
||||||
sendJson(503, { error: 'no_key' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = mem;
|
|
||||||
if (entry && Date.now() - entry.at < TTL_MS) {
|
|
||||||
sendJson(200, buildPayload(entry, false));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Stale or missing → refresh, single-flight (concurrent requests
|
|
||||||
// share one upstream pass). Capture the promise locally BEFORE
|
|
||||||
// awaiting: the .finally() nulls `inflight` the moment it settles.
|
|
||||||
if (!inflight) {
|
|
||||||
inflight = refreshUpstream(key)
|
|
||||||
.then(async (fresh) => {
|
|
||||||
mem = fresh;
|
|
||||||
await writeDisk(fresh);
|
|
||||||
return fresh;
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.warn(
|
|
||||||
`[firms-proxy] refresh failed (${err?.message || err}) — serving cache if any`,
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
inflight = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const pending = inflight;
|
|
||||||
const fresh = await pending;
|
|
||||||
if (fresh) {
|
|
||||||
sendJson(200, buildPayload(fresh, false));
|
|
||||||
} else if (entry) {
|
|
||||||
sendJson(200, buildPayload(entry, true)); // upstream down — stale beats empty
|
|
||||||
} else {
|
|
||||||
sendJson(502, {
|
|
||||||
error: 'firms fetch failed and no cache available',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[firms-proxy] error:', err?.message || err);
|
|
||||||
sendJson(500, { error: 'firms proxy error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
+152
-150
@@ -21,161 +21,163 @@ const GBFS_PROXY_TIMEOUT_MS = 12000;
|
|||||||
* @returns {import('vite').Plugin}
|
* @returns {import('vite').Plugin}
|
||||||
*/
|
*/
|
||||||
export function gbfsProxy() {
|
export function gbfsProxy() {
|
||||||
return {
|
const installMiddleware = (server) => {
|
||||||
name: 'gbfs-proxy',
|
server.middlewares.use('/api/gbfs', async (req, res) => {
|
||||||
configureServer(server) {
|
try {
|
||||||
server.middlewares.use('/api/gbfs', async (req, res) => {
|
if (req.method !== 'GET') {
|
||||||
try {
|
res.writeHead(405, {
|
||||||
if (req.method !== 'GET') {
|
'Content-Type': 'application/json',
|
||||||
res.writeHead(405, {
|
'Cache-Control': 'no-store',
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Cache-Control': 'no-store',
|
|
||||||
});
|
|
||||||
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = new URL(req.url || '/', 'http://localhost');
|
|
||||||
const encodedTarget = url.pathname.replace(/^\/+/, '');
|
|
||||||
if (!encodedTarget) {
|
|
||||||
res.writeHead(400, {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Cache-Control': 'no-store',
|
|
||||||
});
|
|
||||||
res.end(JSON.stringify({ error: 'Missing GBFS upstream target' }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let decodedTarget = '';
|
|
||||||
try {
|
|
||||||
decodedTarget = decodeURIComponent(encodedTarget);
|
|
||||||
} catch {
|
|
||||||
res.writeHead(400, {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Cache-Control': 'no-store',
|
|
||||||
});
|
|
||||||
res.end(JSON.stringify({ error: 'Invalid GBFS target encoding' }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let upstreamUrl = null;
|
|
||||||
try {
|
|
||||||
upstreamUrl = new URL(decodedTarget);
|
|
||||||
} catch {
|
|
||||||
res.writeHead(400, {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Cache-Control': 'no-store',
|
|
||||||
});
|
|
||||||
res.end(JSON.stringify({ error: 'Invalid GBFS upstream URL' }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (upstreamUrl.protocol !== 'https:') {
|
|
||||||
res.writeHead(400, {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Cache-Control': 'no-store',
|
|
||||||
});
|
|
||||||
res.end(
|
|
||||||
JSON.stringify({ error: 'Only https GBFS targets are allowed' }),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAllowedGbfsHost(upstreamUrl.hostname)) {
|
|
||||||
res.writeHead(403, {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Cache-Control': 'no-store',
|
|
||||||
});
|
|
||||||
res.end(JSON.stringify({ error: 'GBFS host not allowed' }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAllowedGbfsPath(upstreamUrl.pathname)) {
|
|
||||||
res.writeHead(400, {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Cache-Control': 'no-store',
|
|
||||||
});
|
|
||||||
res.end(
|
|
||||||
JSON.stringify({
|
|
||||||
error:
|
|
||||||
'Only station_information/station_status endpoints are allowed',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeoutId = setTimeout(
|
|
||||||
() => controller.abort(),
|
|
||||||
GBFS_PROXY_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
let upstream;
|
|
||||||
try {
|
|
||||||
upstream = await fetch(upstreamUrl.toString(), {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
'User-Agent': 'gods-eye-view-gbfs-proxy/1.0',
|
|
||||||
},
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limit response size to prevent memory exhaustion from malicious upstream
|
|
||||||
const GBFS_MAX_BODY_BYTES = 5 * 1024 * 1024; // 5 MB
|
|
||||||
const contentLength = Number(upstream.headers.get('content-length'));
|
|
||||||
if (
|
|
||||||
Number.isFinite(contentLength) &&
|
|
||||||
contentLength > GBFS_MAX_BODY_BYTES
|
|
||||||
) {
|
|
||||||
res.writeHead(502, {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Cache-Control': 'no-store',
|
|
||||||
});
|
|
||||||
res.end(
|
|
||||||
JSON.stringify({ error: 'GBFS upstream response too large' }),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const body = await upstream.text();
|
|
||||||
if (Buffer.byteLength(body, 'utf8') > GBFS_MAX_BODY_BYTES) {
|
|
||||||
res.writeHead(502, {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Cache-Control': 'no-store',
|
|
||||||
});
|
|
||||||
res.end(
|
|
||||||
JSON.stringify({ error: 'GBFS upstream response too large' }),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const contentType =
|
|
||||||
upstream.headers.get('content-type') || 'application/json';
|
|
||||||
res.writeHead(upstream.status, {
|
|
||||||
'Content-Type': contentType,
|
|
||||||
'Cache-Control': gbfsCacheControl(upstreamUrl.pathname),
|
|
||||||
'X-GBFS-Upstream': upstreamUrl.hostname,
|
|
||||||
'X-GBFS-Cache': 'MISS',
|
|
||||||
});
|
});
|
||||||
res.end(body);
|
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
||||||
} catch (error) {
|
return;
|
||||||
if (error?.name === 'AbortError') {
|
}
|
||||||
res.writeHead(504, {
|
|
||||||
'Content-Type': 'application/json',
|
const url = new URL(req.url || '/', 'http://localhost');
|
||||||
'Cache-Control': 'no-store',
|
const encodedTarget = url.pathname.replace(/^\/+/, '');
|
||||||
});
|
if (!encodedTarget) {
|
||||||
res.end(JSON.stringify({ error: 'GBFS upstream timeout' }));
|
res.writeHead(400, {
|
||||||
return;
|
'Content-Type': 'application/json',
|
||||||
}
|
'Cache-Control': 'no-store',
|
||||||
console.error('[GBFS Proxy]', error?.message || String(error));
|
});
|
||||||
|
res.end(JSON.stringify({ error: 'Missing GBFS upstream target' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let decodedTarget = '';
|
||||||
|
try {
|
||||||
|
decodedTarget = decodeURIComponent(encodedTarget);
|
||||||
|
} catch {
|
||||||
|
res.writeHead(400, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
});
|
||||||
|
res.end(JSON.stringify({ error: 'Invalid GBFS target encoding' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let upstreamUrl = null;
|
||||||
|
try {
|
||||||
|
upstreamUrl = new URL(decodedTarget);
|
||||||
|
} catch {
|
||||||
|
res.writeHead(400, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
});
|
||||||
|
res.end(JSON.stringify({ error: 'Invalid GBFS upstream URL' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (upstreamUrl.protocol !== 'https:') {
|
||||||
|
res.writeHead(400, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
});
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({ error: 'Only https GBFS targets are allowed' }),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAllowedGbfsHost(upstreamUrl.hostname)) {
|
||||||
|
res.writeHead(403, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
});
|
||||||
|
res.end(JSON.stringify({ error: 'GBFS host not allowed' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAllowedGbfsPath(upstreamUrl.pathname)) {
|
||||||
|
res.writeHead(400, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
});
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
error:
|
||||||
|
'Only station_information/station_status endpoints are allowed',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(
|
||||||
|
() => controller.abort(),
|
||||||
|
GBFS_PROXY_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
let upstream;
|
||||||
|
try {
|
||||||
|
upstream = await fetch(upstreamUrl.toString(), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'User-Agent': 'gods-eye-view-gbfs-proxy/1.0',
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit response size to prevent memory exhaustion from malicious upstream
|
||||||
|
const GBFS_MAX_BODY_BYTES = 5 * 1024 * 1024; // 5 MB
|
||||||
|
const contentLength = Number(upstream.headers.get('content-length'));
|
||||||
|
if (
|
||||||
|
Number.isFinite(contentLength) &&
|
||||||
|
contentLength > GBFS_MAX_BODY_BYTES
|
||||||
|
) {
|
||||||
res.writeHead(502, {
|
res.writeHead(502, {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Cache-Control': 'no-store',
|
'Cache-Control': 'no-store',
|
||||||
});
|
});
|
||||||
res.end(JSON.stringify({ error: 'GBFS proxy error' }));
|
res.end(
|
||||||
|
JSON.stringify({ error: 'GBFS upstream response too large' }),
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
});
|
const body = await upstream.text();
|
||||||
},
|
if (Buffer.byteLength(body, 'utf8') > GBFS_MAX_BODY_BYTES) {
|
||||||
|
res.writeHead(502, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
});
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({ error: 'GBFS upstream response too large' }),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const contentType =
|
||||||
|
upstream.headers.get('content-type') || 'application/json';
|
||||||
|
res.writeHead(upstream.status, {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Cache-Control': gbfsCacheControl(upstreamUrl.pathname),
|
||||||
|
'X-GBFS-Upstream': upstreamUrl.hostname,
|
||||||
|
'X-GBFS-Cache': 'MISS',
|
||||||
|
});
|
||||||
|
res.end(body);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name === 'AbortError') {
|
||||||
|
res.writeHead(504, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
});
|
||||||
|
res.end(JSON.stringify({ error: 'GBFS upstream timeout' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error('[GBFS Proxy]', error?.message || String(error));
|
||||||
|
res.writeHead(502, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
});
|
||||||
|
res.end(JSON.stringify({ error: 'GBFS proxy error' }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
name: 'gbfs-proxy',
|
||||||
|
configureServer: installMiddleware,
|
||||||
|
configurePreviewServer: installMiddleware,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -764,9 +764,7 @@ export async function fetchOverpassPayload(body, maxResponseBytes = OVERPASS_MAX
|
|||||||
* @returns {import('vite').Plugin}
|
* @returns {import('vite').Plugin}
|
||||||
*/
|
*/
|
||||||
function overpassProxy() {
|
function overpassProxy() {
|
||||||
return {
|
const installMiddleware = (server) => {
|
||||||
name: 'overpass-proxy',
|
|
||||||
configureServer(server) {
|
|
||||||
server.middlewares.use('/api/overpass', async (req, res) => {
|
server.middlewares.use('/api/overpass', async (req, res) => {
|
||||||
// Hoisted out of the try so the catch's serve-stale lookup can see it
|
// Hoisted out of the try so the catch's serve-stale lookup can see it
|
||||||
// (a body-read failure would otherwise hit an out-of-scope reference).
|
// (a body-read failure would otherwise hit an out-of-scope reference).
|
||||||
@@ -896,7 +894,11 @@ function overpassProxy() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
installRouteMiddleware(server.middlewares);
|
installRouteMiddleware(server.middlewares);
|
||||||
},
|
};
|
||||||
|
return {
|
||||||
|
name: 'overpass-proxy',
|
||||||
|
configureServer: installMiddleware,
|
||||||
|
configurePreviewServer: installMiddleware,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,72 +66,74 @@ export function celestrakProxy() {
|
|||||||
return { at: Date.now(), body };
|
return { at: Date.now(), body };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const installMiddleware = (server) => {
|
||||||
name: 'celestrak-proxy',
|
server.middlewares.use('/api/celestrak', async (req, res) => {
|
||||||
configureServer(server) {
|
const group = String(req.url || '')
|
||||||
server.middlewares.use('/api/celestrak', async (req, res) => {
|
.replace(/^\//, '')
|
||||||
const group = String(req.url || '')
|
.split('?')[0];
|
||||||
.replace(/^\//, '')
|
if (!/^[a-z0-9-]+$/i.test(group)) {
|
||||||
.split('?')[0];
|
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
||||||
if (!/^[a-z0-9-]+$/i.test(group)) {
|
res.end('invalid group');
|
||||||
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
return;
|
||||||
res.end('invalid group');
|
}
|
||||||
|
const send = (status, body, cacheStatus) => {
|
||||||
|
// Guard against a double-send (e.g. a throw AFTER a response already
|
||||||
|
// went out routing into the catch's send): writeHead after headersSent
|
||||||
|
// throws "Cannot set headers after they are sent".
|
||||||
|
if (res.headersSent) return;
|
||||||
|
res.writeHead(status, {
|
||||||
|
'Content-Type': 'text/plain',
|
||||||
|
'x-tle-cache': cacheStatus,
|
||||||
|
});
|
||||||
|
res.end(body);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const now = Date.now();
|
||||||
|
let entry = mem.get(group);
|
||||||
|
if (!entry) {
|
||||||
|
entry = await readDisk(group);
|
||||||
|
if (entry) mem.set(group, entry);
|
||||||
|
}
|
||||||
|
if (entry && now - entry.at < TLE_TTL_MS) {
|
||||||
|
send(200, entry.body, 'HIT');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const send = (status, body, cacheStatus) => {
|
// Stale or missing → refresh, single-flight per group.
|
||||||
// Guard against a double-send (e.g. a throw AFTER a response already
|
if (!inflight.has(group)) {
|
||||||
// went out routing into the catch's send): writeHead after headersSent
|
inflight.set(
|
||||||
// throws "Cannot set headers after they are sent".
|
group,
|
||||||
if (res.headersSent) return;
|
fetchUpstream(group)
|
||||||
res.writeHead(status, {
|
.then(async (fresh) => {
|
||||||
'Content-Type': 'text/plain',
|
mem.set(group, fresh);
|
||||||
'x-tle-cache': cacheStatus,
|
await writeDisk(group, fresh);
|
||||||
});
|
return fresh;
|
||||||
res.end(body);
|
})
|
||||||
};
|
.catch((err) => {
|
||||||
try {
|
console.warn(
|
||||||
const now = Date.now();
|
'[celestrak-proxy] refresh failed — serving cache if any',
|
||||||
let entry = mem.get(group);
|
);
|
||||||
if (!entry) {
|
return null;
|
||||||
entry = await readDisk(group);
|
})
|
||||||
if (entry) mem.set(group, entry);
|
.finally(() => inflight.delete(group)),
|
||||||
}
|
);
|
||||||
if (entry && now - entry.at < TLE_TTL_MS) {
|
|
||||||
send(200, entry.body, 'HIT');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Stale or missing → refresh, single-flight per group.
|
|
||||||
if (!inflight.has(group)) {
|
|
||||||
inflight.set(
|
|
||||||
group,
|
|
||||||
fetchUpstream(group)
|
|
||||||
.then(async (fresh) => {
|
|
||||||
mem.set(group, fresh);
|
|
||||||
await writeDisk(group, fresh);
|
|
||||||
return fresh;
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.warn(
|
|
||||||
'[celestrak-proxy] refresh failed — serving cache if any',
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
.finally(() => inflight.delete(group)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const fresh = await inflight.get(group);
|
|
||||||
if (fresh) {
|
|
||||||
send(200, fresh.body, 'MISS');
|
|
||||||
} else if (entry) {
|
|
||||||
send(200, entry.body, 'STALE-ERROR'); // upstream down — stale beats empty
|
|
||||||
} else {
|
|
||||||
send(502, 'celestrak fetch failed and no cache available', 'NONE');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[celestrak-proxy] request failed');
|
|
||||||
send(500, 'celestrak proxy error', 'ERROR');
|
|
||||||
}
|
}
|
||||||
});
|
const fresh = await inflight.get(group);
|
||||||
},
|
if (fresh) {
|
||||||
|
send(200, fresh.body, 'MISS');
|
||||||
|
} else if (entry) {
|
||||||
|
send(200, entry.body, 'STALE-ERROR'); // upstream down — stale beats empty
|
||||||
|
} else {
|
||||||
|
send(502, 'celestrak fetch failed and no cache available', 'NONE');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[celestrak-proxy] request failed');
|
||||||
|
send(500, 'celestrak proxy error', 'ERROR');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
name: 'celestrak-proxy',
|
||||||
|
configureServer: installMiddleware,
|
||||||
|
configurePreviewServer: installMiddleware,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
+48
-46
@@ -131,53 +131,55 @@ export function terrainHeightsProxy() {
|
|||||||
return inflight.get(key);
|
return inflight.get(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const installMiddleware = (server) => {
|
||||||
|
server.middlewares.use('/api/terrain/heights', async (req, res) => {
|
||||||
|
const send = (status, bodyObj) => {
|
||||||
|
if (res.headersSent) return;
|
||||||
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(bodyObj));
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await loadDiskOnce();
|
||||||
|
const parsedUrl = new URL(req.url || '', 'http://internal');
|
||||||
|
const rawPoints = parsedUrl.searchParams.get('points');
|
||||||
|
const points = parseTerrainPoints(rawPoints);
|
||||||
|
if (!points) {
|
||||||
|
send(400, {
|
||||||
|
error:
|
||||||
|
'invalid points parameter — expected "lon,lat;lon,lat;…" with finite numbers',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (points.length > MAX_POINTS) {
|
||||||
|
send(500, {
|
||||||
|
error: `too many points (${points.length}); max ${MAX_POINTS} per request`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outcome = await resolveTerrainHeightRequest({
|
||||||
|
points,
|
||||||
|
cache: mem,
|
||||||
|
fetchMissing: fetchMissingSingleFlight,
|
||||||
|
ttlMs: TTL_MS,
|
||||||
|
});
|
||||||
|
if (outcome.cacheChanged) diskDirty = true;
|
||||||
|
if (outcome.upstreamError) {
|
||||||
|
console.warn(
|
||||||
|
'[terrain-heights-proxy] refresh incomplete' +
|
||||||
|
' — serving stale points when available',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
send(outcome.status, outcome.body);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[terrain-heights-proxy] request failed');
|
||||||
|
send(500, { error: 'terrain heights proxy error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
name: 'terrain-heights-proxy',
|
name: 'terrain-heights-proxy',
|
||||||
configureServer(server) {
|
configureServer: installMiddleware,
|
||||||
server.middlewares.use('/api/terrain/heights', async (req, res) => {
|
configurePreviewServer: installMiddleware,
|
||||||
const send = (status, bodyObj) => {
|
|
||||||
if (res.headersSent) return;
|
|
||||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify(bodyObj));
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
await loadDiskOnce();
|
|
||||||
const parsedUrl = new URL(req.url || '', 'http://internal');
|
|
||||||
const rawPoints = parsedUrl.searchParams.get('points');
|
|
||||||
const points = parseTerrainPoints(rawPoints);
|
|
||||||
if (!points) {
|
|
||||||
send(400, {
|
|
||||||
error:
|
|
||||||
'invalid points parameter — expected "lon,lat;lon,lat;…" with finite numbers',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (points.length > MAX_POINTS) {
|
|
||||||
send(500, {
|
|
||||||
error: `too many points (${points.length}); max ${MAX_POINTS} per request`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const outcome = await resolveTerrainHeightRequest({
|
|
||||||
points,
|
|
||||||
cache: mem,
|
|
||||||
fetchMissing: fetchMissingSingleFlight,
|
|
||||||
ttlMs: TTL_MS,
|
|
||||||
});
|
|
||||||
if (outcome.cacheChanged) diskDirty = true;
|
|
||||||
if (outcome.upstreamError) {
|
|
||||||
console.warn(
|
|
||||||
'[terrain-heights-proxy] refresh incomplete' +
|
|
||||||
' — serving stale points when available',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
send(outcome.status, outcome.body);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[terrain-heights-proxy] request failed');
|
|
||||||
send(500, { error: 'terrain heights proxy error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
+116
-114
@@ -144,121 +144,123 @@ export function tomtomProxy() {
|
|||||||
return buf;
|
return buf;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const installMiddleware = (server) => {
|
||||||
|
server.middlewares.use('/api/tomtom', async (req, res) => {
|
||||||
|
// Sanitized responses only (proxy/security baseline): no upstream
|
||||||
|
// error details, and never echo the key or the upstream URL.
|
||||||
|
const sendJson = (status, obj, extraHeaders = {}) => {
|
||||||
|
if (res.headersSent) return;
|
||||||
|
res.writeHead(status, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
...extraHeaders,
|
||||||
|
});
|
||||||
|
res.end(JSON.stringify(obj));
|
||||||
|
};
|
||||||
|
const sendTile = (buf, cacheStatus) => {
|
||||||
|
if (res.headersSent) return;
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'application/x-protobuf',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'x-tomtom-cache': cacheStatus,
|
||||||
|
});
|
||||||
|
res.end(buf);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadBudgetOnce();
|
||||||
|
const urlPath = String(req.url || '').split('?')[0];
|
||||||
|
|
||||||
|
if (urlPath === '/status') {
|
||||||
|
const hasKey = Boolean(process.env.TOMTOM_API_KEY);
|
||||||
|
const b = currentBudget();
|
||||||
|
sendJson(200, {
|
||||||
|
hasKey,
|
||||||
|
dailyCount: b.count,
|
||||||
|
budget: dailyBudgetLimit(),
|
||||||
|
date: b.date,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const m = urlPath.match(/^\/flow\/(\d+)\/(\d+)\/(\d+)\.pbf$/);
|
||||||
|
if (!m) {
|
||||||
|
sendJson(404, { error: 'not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const z = Number(m[1]);
|
||||||
|
const x = Number(m[2]);
|
||||||
|
const y = Number(m[3]);
|
||||||
|
if (!isValidTomTomTile(z, x, y)) {
|
||||||
|
sendJson(400, { error: 'invalid_tile' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!process.env.TOMTOM_API_KEY) {
|
||||||
|
sendJson(503, { error: 'no_key' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `${z}/${x}/${y}`;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
let entry = mem.get(key);
|
||||||
|
if (!entry) {
|
||||||
|
entry = await readDiskTile(key);
|
||||||
|
if (entry) memSet(key, entry);
|
||||||
|
}
|
||||||
|
// Fresh cache hit — never counts against the budget.
|
||||||
|
if (entry && now - entry.at < TILE_TTL_MS) {
|
||||||
|
sendTile(entry.buf, 'HIT');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Budget governor: over the soft cap, last-good data beats a dead layer.
|
||||||
|
if (isTomTomOverBudget(currentBudget(), dailyBudgetLimit())) {
|
||||||
|
if (entry) {
|
||||||
|
sendTile(entry.buf, 'STALE-BUDGET');
|
||||||
|
} else {
|
||||||
|
sendJson(429, { error: 'budget' });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stale or missing → refresh, single-flight per tile.
|
||||||
|
if (!inflight.has(key)) {
|
||||||
|
inflight.set(
|
||||||
|
key,
|
||||||
|
fetchUpstream(z, x, y)
|
||||||
|
.then(async (buf) => {
|
||||||
|
const fresh = { at: Date.now(), buf };
|
||||||
|
memSet(key, fresh);
|
||||||
|
await writeDiskTile(key, buf);
|
||||||
|
return fresh;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn(
|
||||||
|
`[tomtom-proxy] ${key} fetch failed (${err?.message || err}) — serving stale if any`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.finally(() => inflight.delete(key)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const fresh = await inflight.get(key);
|
||||||
|
if (fresh) {
|
||||||
|
sendTile(fresh.buf, 'MISS');
|
||||||
|
} else if (entry) {
|
||||||
|
sendTile(entry.buf, 'STALE-ERROR'); // upstream down — stale beats empty
|
||||||
|
} else {
|
||||||
|
sendJson(502, { error: 'upstream' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[tomtom-proxy] error:', err?.message || err);
|
||||||
|
sendJson(500, { error: 'proxy' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
name: 'tomtom-proxy',
|
name: 'tomtom-proxy',
|
||||||
configureServer(server) {
|
configureServer: installMiddleware,
|
||||||
server.middlewares.use('/api/tomtom', async (req, res) => {
|
configurePreviewServer: installMiddleware,
|
||||||
// Sanitized responses only (proxy/security baseline): no upstream
|
|
||||||
// error details, and never echo the key or the upstream URL.
|
|
||||||
const sendJson = (status, obj, extraHeaders = {}) => {
|
|
||||||
if (res.headersSent) return;
|
|
||||||
res.writeHead(status, {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Cache-Control': 'no-store',
|
|
||||||
...extraHeaders,
|
|
||||||
});
|
|
||||||
res.end(JSON.stringify(obj));
|
|
||||||
};
|
|
||||||
const sendTile = (buf, cacheStatus) => {
|
|
||||||
if (res.headersSent) return;
|
|
||||||
res.writeHead(200, {
|
|
||||||
'Content-Type': 'application/x-protobuf',
|
|
||||||
'Cache-Control': 'no-store',
|
|
||||||
'x-tomtom-cache': cacheStatus,
|
|
||||||
});
|
|
||||||
res.end(buf);
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await loadBudgetOnce();
|
|
||||||
const urlPath = String(req.url || '').split('?')[0];
|
|
||||||
|
|
||||||
if (urlPath === '/status') {
|
|
||||||
const hasKey = Boolean(process.env.TOMTOM_API_KEY);
|
|
||||||
const b = currentBudget();
|
|
||||||
sendJson(200, {
|
|
||||||
hasKey,
|
|
||||||
dailyCount: b.count,
|
|
||||||
budget: dailyBudgetLimit(),
|
|
||||||
date: b.date,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const m = urlPath.match(/^\/flow\/(\d+)\/(\d+)\/(\d+)\.pbf$/);
|
|
||||||
if (!m) {
|
|
||||||
sendJson(404, { error: 'not_found' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const z = Number(m[1]);
|
|
||||||
const x = Number(m[2]);
|
|
||||||
const y = Number(m[3]);
|
|
||||||
if (!isValidTomTomTile(z, x, y)) {
|
|
||||||
sendJson(400, { error: 'invalid_tile' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!process.env.TOMTOM_API_KEY) {
|
|
||||||
sendJson(503, { error: 'no_key' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = `${z}/${x}/${y}`;
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
let entry = mem.get(key);
|
|
||||||
if (!entry) {
|
|
||||||
entry = await readDiskTile(key);
|
|
||||||
if (entry) memSet(key, entry);
|
|
||||||
}
|
|
||||||
// Fresh cache hit — never counts against the budget.
|
|
||||||
if (entry && now - entry.at < TILE_TTL_MS) {
|
|
||||||
sendTile(entry.buf, 'HIT');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Budget governor: over the soft cap, last-good data beats a dead layer.
|
|
||||||
if (isTomTomOverBudget(currentBudget(), dailyBudgetLimit())) {
|
|
||||||
if (entry) {
|
|
||||||
sendTile(entry.buf, 'STALE-BUDGET');
|
|
||||||
} else {
|
|
||||||
sendJson(429, { error: 'budget' });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stale or missing → refresh, single-flight per tile.
|
|
||||||
if (!inflight.has(key)) {
|
|
||||||
inflight.set(
|
|
||||||
key,
|
|
||||||
fetchUpstream(z, x, y)
|
|
||||||
.then(async (buf) => {
|
|
||||||
const fresh = { at: Date.now(), buf };
|
|
||||||
memSet(key, fresh);
|
|
||||||
await writeDiskTile(key, buf);
|
|
||||||
return fresh;
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.warn(
|
|
||||||
`[tomtom-proxy] ${key} fetch failed (${err?.message || err}) — serving stale if any`,
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
.finally(() => inflight.delete(key)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const fresh = await inflight.get(key);
|
|
||||||
if (fresh) {
|
|
||||||
sendTile(fresh.buf, 'MISS');
|
|
||||||
} else if (entry) {
|
|
||||||
sendTile(entry.buf, 'STALE-ERROR'); // upstream down — stale beats empty
|
|
||||||
} else {
|
|
||||||
sendJson(502, { error: 'upstream' });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[tomtom-proxy] error:', err?.message || err);
|
|
||||||
sendJson(500, { error: 'proxy' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user