73 lines
5.5 KiB
JavaScript
73 lines
5.5 KiB
JavaScript
// Public prototype providers, replaceable with self-hosted instances.
|
|
export class ServiceError extends Error {
|
|
constructor(status, message) { super(message); this.status = status; }
|
|
}
|
|
function number(params, name, min, max) {
|
|
const raw = params.get(name), n = raw?.trim() ? Number(raw) : NaN;
|
|
if (!Number.isFinite(n) || n < min || n > max) throw new ServiceError(400, `Coordonnée ${name} invalide`);
|
|
return n;
|
|
}
|
|
export function createServices({ fetcher = fetch, now = Date.now, env = process.env } = {}) {
|
|
const cache = new Map(), inflight = new Map(), last = new Map();
|
|
const endpoints = {
|
|
weather: env.WEATHER_URL || 'https://api.open-meteo.com/v1/forecast',
|
|
search: env.GEOCODER_URL || 'https://photon.komoot.io/api/',
|
|
route: env.ROUTER_URL || 'https://router.project-osrm.org/route/v1/driving/'
|
|
};
|
|
async function request(kind, url, ttl) {
|
|
const key = url.href, saved = cache.get(key);
|
|
if (saved && now() - saved.time < ttl) return saved.data;
|
|
if (inflight.has(key)) return inflight.get(key);
|
|
if (now() - (last.get(kind) ?? -Infinity) < 1100 || inflight.size >= 6) throw new ServiceError(429, 'Patientez un instant avant de réessayer.');
|
|
last.set(kind, now());
|
|
const promise = (async () => {
|
|
try {
|
|
const response = await fetcher(url, { signal: AbortSignal.timeout(8000), headers: { 'User-Agent': 'CARIA-Cockpit/0.2 (personal automotive interface)', Accept: 'application/json' } });
|
|
if (!response.ok) throw new Error('Upstream');
|
|
const data = await response.json();
|
|
if (cache.size >= 128) cache.delete(cache.keys().next().value);
|
|
cache.set(key, { time: now(), data }); return data;
|
|
} catch { throw new ServiceError(502, 'Service externe indisponible. Réessayez avec une connexion Internet.'); }
|
|
finally { inflight.delete(key); }
|
|
})();
|
|
inflight.set(key, promise); return promise;
|
|
}
|
|
return async (path, params) => {
|
|
if (path === '/api/map-config') return {
|
|
tileUrl: env.TILE_URL || 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
|
tileAttribution: env.TILE_ATTRIBUTION || '© OpenStreetMap contributors'
|
|
};
|
|
if (path === '/api/weather') {
|
|
const lat = number(params, 'lat', -90, 90), lon = number(params, 'lon', -180, 180);
|
|
const url = new URL(endpoints.weather);
|
|
url.search = new URLSearchParams({ latitude: lat.toFixed(2), longitude: lon.toFixed(2), current: 'temperature_2m,weather_code,wind_speed_10m', daily: 'temperature_2m_max,temperature_2m_min', timezone: 'auto', forecast_days: '1' });
|
|
const d = await request('weather', url, 15 * 60000);
|
|
if (![d.current?.temperature_2m, d.current?.wind_speed_10m, d.current?.weather_code, d.daily?.temperature_2m_min?.[0], d.daily?.temperature_2m_max?.[0]].every(Number.isFinite)) throw new ServiceError(502, 'Réponse météo incomplète');
|
|
return { source: 'Open-Meteo', fetchedAt: now(), observedAt: d.current.time, temperature: d.current.temperature_2m, code: d.current.weather_code, wind: d.current.wind_speed_10m, min: d.daily.temperature_2m_min[0], max: d.daily.temperature_2m_max[0] };
|
|
}
|
|
if (path === '/api/search') {
|
|
const q = params.get('q')?.trim();
|
|
if (!q || q.length < 3 || q.length > 180) throw new ServiceError(400, 'Saisissez entre 3 et 180 caractères.');
|
|
const url = new URL(endpoints.search); url.search = new URLSearchParams({ q, lang: 'fr', limit: '5' });
|
|
const d = await request('search', url, 24 * 3600000);
|
|
if (!Array.isArray(d.features)) throw new ServiceError(502, 'Réponse de recherche invalide');
|
|
return d.features.slice(0, 5).filter(f => f.geometry?.type === 'Point' && f.geometry.coordinates?.length === 2 && f.geometry.coordinates.every(Number.isFinite)).map(f => ({
|
|
lon: f.geometry.coordinates[0], lat: f.geometry.coordinates[1],
|
|
label: [...new Set([f.properties?.name, f.properties?.street, f.properties?.housenumber, f.properties?.postcode, f.properties?.city, f.properties?.country].filter(Boolean))].join(', ').slice(0, 300)
|
|
}));
|
|
}
|
|
if (path === '/api/route') {
|
|
const fromLat = number(params, 'fromLat', -85, 85), fromLon = number(params, 'fromLon', -180, 180);
|
|
const toLat = number(params, 'toLat', -85, 85), toLon = number(params, 'toLon', -180, 180);
|
|
const url = new URL(`${endpoints.route.replace(/\/$/, '')}/${fromLon},${fromLat};${toLon},${toLat}`);
|
|
url.search = new URLSearchParams({ overview: 'full', geometries: 'geojson', steps: 'true' });
|
|
const d = await request('route', url, 60000), route = d.routes?.[0];
|
|
if (d.code !== 'Ok' || !route || route.geometry?.type !== 'LineString' || !Array.isArray(route.geometry.coordinates) || route.geometry.coordinates.length < 2 || route.geometry.coordinates.length > 100000 || route.geometry.coordinates.some(c => !Array.isArray(c) || c.length !== 2 || !c.every(Number.isFinite)) || !Number.isFinite(route.distance) || !Number.isFinite(route.duration) || !Array.isArray(route.legs)) throw new ServiceError(422, 'Aucun itinéraire routier trouvé.');
|
|
const steps = route.legs.flatMap(leg => leg.steps || []);
|
|
if (!steps.length || steps.some(s => !s.maneuver || !Number.isFinite(s.distance) || !Number.isFinite(s.duration))) throw new ServiceError(502, 'Étapes de navigation incomplètes');
|
|
return { source: 'OSRM', distance: route.distance, duration: route.duration, geometry: route.geometry, steps: steps.map(s => ({ distance: s.distance, duration: s.duration, name: s.name, maneuver: s.maneuver })) };
|
|
}
|
|
throw new ServiceError(404, 'Introuvable');
|
|
};
|
|
}
|