mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Rebased onto current main with review follow-ups folded in. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
196 lines
6.5 KiB
JavaScript
196 lines
6.5 KiB
JavaScript
import { isUnavailableCapability } from '../sources/capability.js';
|
|
import { createOverpassFeatureSource } from '../sources/overpassFeatures.js';
|
|
/** Parse bounded retry information from a service response. */
|
|
function retryAfterMs(value) {
|
|
if (value == null || String(value).trim() === '') return null;
|
|
const seconds = Number(value);
|
|
if (Number.isFinite(seconds) && seconds >= 0)
|
|
return Math.ceil(seconds * 1000);
|
|
const at = Date.parse(String(value));
|
|
return Number.isFinite(at) ? Math.max(0, at - Date.now()) : null;
|
|
}
|
|
|
|
/** Construct independent request services for compatible application protocols. */
|
|
export function createApplicationRequestServices({
|
|
fetchImpl = (...args) => fetch(...args),
|
|
signal: lifetime,
|
|
endpoints = {},
|
|
features,
|
|
boundaryProbe = { timeoutMs: 3000, retryMs: 30_000 },
|
|
} = {}) {
|
|
const urls = {
|
|
boundaries: '/api/overpass',
|
|
terrain: '/api/terrain/heights',
|
|
regional: '/api/regional-brief',
|
|
weather: '/api/weather-effects',
|
|
summary: '/api/openai/hud-summary',
|
|
...endpoints,
|
|
};
|
|
async function request(endpoint, { signal, ...init } = {}) {
|
|
signal = AbortSignal.any([lifetime, signal].filter(Boolean));
|
|
signal.throwIfAborted();
|
|
const response = await fetchImpl(endpoint, {
|
|
...init,
|
|
signal,
|
|
redirect: 'error',
|
|
});
|
|
signal.throwIfAborted();
|
|
let data = null;
|
|
try {
|
|
data = await response.json();
|
|
} catch {
|
|
/* Status remains authoritative for non-JSON errors. */
|
|
}
|
|
signal.throwIfAborted();
|
|
return {
|
|
ok: response.ok,
|
|
status: response.status,
|
|
headers: response.headers,
|
|
data,
|
|
};
|
|
}
|
|
function pointUrl(endpoint, latitude, longitude) {
|
|
if (
|
|
!Number.isFinite(latitude) ||
|
|
latitude < -90 ||
|
|
latitude > 90 ||
|
|
!Number.isFinite(longitude) ||
|
|
longitude < -180 ||
|
|
longitude > 180
|
|
)
|
|
throw new TypeError('Valid coordinates are required');
|
|
return `${endpoint}?${new URLSearchParams({ latitude: latitude.toFixed(5), longitude: longitude.toFixed(5) })}`;
|
|
}
|
|
function requireOk(response, label) {
|
|
if (!response.ok)
|
|
throw new Error(`${label} unavailable (${response.status})`);
|
|
return response.data;
|
|
}
|
|
// Remember valid capability decisions for the page, so an
|
|
// unconfigured server is never asked (and never answers with an error).
|
|
// Discovery is bounded; failed, malformed or timed-out probes are forgotten and not
|
|
// retried for a short backoff, during which queries go to the server as
|
|
// they would against an older server without the probe.
|
|
let boundaryCapability = null;
|
|
let boundaryProbeRetryAt = 0;
|
|
function boundariesConfigured() {
|
|
if (boundaryCapability) return boundaryCapability;
|
|
if (Date.now() < boundaryProbeRetryAt) return Promise.resolve(null);
|
|
boundaryCapability = request(`${urls.boundaries}/status`, {
|
|
signal: AbortSignal.timeout(boundaryProbe.timeoutMs),
|
|
}).then(
|
|
(response) => {
|
|
if (response.ok && typeof response.data?.configured === 'boolean')
|
|
return response.data.configured;
|
|
boundaryCapability = null;
|
|
boundaryProbeRetryAt = Date.now() + boundaryProbe.retryMs;
|
|
return null; // capability unknown: let this query ask the server
|
|
},
|
|
(error) => {
|
|
boundaryCapability = null;
|
|
if (lifetime?.aborted) throw error;
|
|
boundaryProbeRetryAt = Date.now() + boundaryProbe.retryMs;
|
|
return null;
|
|
},
|
|
);
|
|
return boundaryCapability;
|
|
}
|
|
/** Wait for a shared promise, but give up when this caller's signal aborts. */
|
|
function untilAborted(promise, signal) {
|
|
if (!signal) return promise;
|
|
signal.throwIfAborted();
|
|
return new Promise((resolve, reject) => {
|
|
const abort = () => reject(signal.reason);
|
|
signal.addEventListener('abort', abort, { once: true });
|
|
promise
|
|
.then(resolve, reject)
|
|
.finally(() => signal.removeEventListener('abort', abort));
|
|
});
|
|
}
|
|
const services = {
|
|
boundaries: {
|
|
async query(query, { signal } = {}) {
|
|
if ((await untilAborted(boundariesConfigured(), signal)) === false) {
|
|
signal?.throwIfAborted();
|
|
return {
|
|
unavailable: true,
|
|
code: 'OVERPASS_NOT_CONFIGURED',
|
|
retryable: false,
|
|
};
|
|
}
|
|
const response = await request(urls.boundaries, {
|
|
method: 'POST',
|
|
signal,
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: `data=${encodeURIComponent(query)}`,
|
|
});
|
|
if (isUnavailableCapability(response.data))
|
|
return {
|
|
unavailable: true,
|
|
code: 'OVERPASS_NOT_CONFIGURED',
|
|
retryable: false,
|
|
};
|
|
const retry = response.headers?.get?.('Retry-After');
|
|
if (
|
|
response.status === 406 ||
|
|
response.status === 429 ||
|
|
(response.status === 503 && retry != null)
|
|
)
|
|
return { rateLimited: true, retryAfterMs: retryAfterMs(retry) };
|
|
if (!response.ok) return null;
|
|
const remark = String(response.data?.remark || '').toLowerCase();
|
|
if (/runtime error|timed out|out of memory/.test(remark)) return null;
|
|
return Array.isArray(response.data?.elements)
|
|
? response.data.elements
|
|
: null;
|
|
},
|
|
},
|
|
terrain: {
|
|
async getHeights(points, { signal } = {}) {
|
|
const query = points
|
|
.map(({ lat, lon }) => `${lon.toFixed(5)},${lat.toFixed(5)}`)
|
|
.join(';');
|
|
return requireOk(
|
|
await request(`${urls.terrain}?points=${encodeURIComponent(query)}`, {
|
|
signal,
|
|
}),
|
|
'Terrain heights',
|
|
)?.results;
|
|
},
|
|
},
|
|
regional: {
|
|
async getBrief(latitude, longitude, options) {
|
|
return requireOk(
|
|
await request(pointUrl(urls.regional, latitude, longitude), options),
|
|
'Regional brief',
|
|
);
|
|
},
|
|
},
|
|
weather: {
|
|
async getConditions(latitude, longitude, options) {
|
|
return requireOk(
|
|
await request(pointUrl(urls.weather, latitude, longitude), options),
|
|
'Weather',
|
|
);
|
|
},
|
|
},
|
|
summary: {
|
|
async summarize(context, { signal } = {}) {
|
|
return request(urls.summary, {
|
|
method: 'POST',
|
|
signal,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(context),
|
|
});
|
|
},
|
|
},
|
|
};
|
|
services.features =
|
|
features ??
|
|
createOverpassFeatureSource({
|
|
boundarySource: services.boundaries,
|
|
signal: lifetime,
|
|
});
|
|
return services;
|
|
}
|