Files

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');
};
}