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