import { ApiError } from './errors.mjs'; import { journeyStops, previousDate, routeDepartures, tripIdentity, validDate } from './timetable.mjs'; import { normalizeAlerts, normalizeNearby } from './features.mjs'; import { coordinates } from './coordinates.mjs'; import { normalizeWalking } from './walking.mjs'; export { ApiError } from './errors.mjs'; function positiveId(value) { if (typeof value !== 'string' || !/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(Number(value))) { throw new ApiError(400, 'invalid_request'); } return Number(value); } export function parseRequest(params) { const resource = params.get('resource'); if (resource === 'cities') return { resource }; if (!['stops', 'stop', 'routes', 'routeStops', 'walk', 'departures', 'timetable', 'alerts', 'nearby', 'journey'].includes(resource)) throw new ApiError(400, 'invalid_request'); const cityId = positiveId(params.get('cityId')); if (resource === 'alerts') return { resource, cityId }; if (resource === 'routes') return { resource, cityId }; if (resource === 'routeStops') return { resource, cityId, routeId: positiveId(params.get('routeId')) }; if (resource === 'nearby' || resource === 'walk') { const coordinate = (name, limit) => { const value = params.get(name); if (!value || !/^-?\d+(?:\.\d+)?$/.test(value) || Math.abs(Number(value)) > limit) throw new ApiError(400, 'invalid_request'); return Number(value); }; if (resource === 'walk') return { resource, cityId, stopId: positiveId(params.get('stopId')), lat: coordinate('lat', 90), lon: coordinate('lon', 180) }; const radius = params.has('radius') ? positiveId(params.get('radius')) : 1000; if (radius < 100 || radius > 2000) throw new ApiError(400, 'invalid_request'); return { resource, cityId, lat: coordinate('lat', 90), lon: coordinate('lon', 180), radius }; } if (resource === 'journey') { const at = params.get('at'); const time = Date.parse(at); if (!Number.isFinite(time) || new Date(time).toISOString() !== at) throw new ApiError(400, 'invalid_request'); const date = new Date(time + 10800000).toISOString().slice(0, 10); if (date < '2000-01-02' || date > '2100-12-31') throw new ApiError(400, 'invalid_request'); const tripKey = params.get('tripKey') || undefined; if (tripKey && (!/^[\w-]+$/.test(tripKey) || tripKey.length > 2000)) throw new ApiError(400, 'invalid_request'); return { resource, cityId, stopId: positiveId(params.get('stopId')), routeId: positiveId(params.get('routeId')), at, ...(tripKey ? { tripKey } : {}) }; } if (resource === 'timetable') { const date = params.get('date'); if (!validDate(date) || date < '2000-01-02' || date > '2100-12-31') throw new ApiError(400, 'invalid_request'); return { resource, cityId, stopId: positiveId(params.get('stopId')), date }; } return resource === 'stops' ? { resource, cityId } : { resource, cityId, stopId: positiveId(params.get('stopId')) }; } const record = value => { if (!value || typeof value !== 'object' || Array.isArray(value)) throw new ApiError(502); return value; }; const list = value => { if (!Array.isArray(value)) throw new ApiError(502); return value; }; const text = value => { if (typeof value !== 'string' || !value.trim()) throw new ApiError(502); return value.trim(); }; const id = value => { const result = Number(value); if (!Number.isSafeInteger(result) || result <= 0) throw new ApiError(502); return result; }; export function normalizeCities(raw) { return list(raw).filter(item => item?.is_active !== false).map(item => { const city = record(item); return { id: id(city.id), name: text(city.name), slug: typeof city.slug === 'string' ? city.slug : '', ...coordinates(city.center_lat, city.center_lon) }; }); } export function normalizeStops(raw) { return list(raw).map(item => { const stop = record(item); return { id: id(stop.id), name: text(stop.name), direction: typeof stop.description === 'string' ? stop.description.trim() : '', routesCount: Math.max(0, Number(stop.routes_count) || 0), address: typeof stop.address === 'string' ? stop.address.trim() : '', ...coordinates(stop.latitude, stop.longitude), }; }); } export function normalizeStop(raw) { const stop = record(raw); const routes = [...new Map(list(stop.routes).filter(route => route?.is_active !== false).map(route => { const value = { id: id(route.id), number: text(route.route_number), name: text(route.name) }; return [value.id, value]; })).values()].sort((a, b) => a.number.localeCompare(b.number, 'ru', { numeric: true })); return { ...normalizeStops([{ ...stop, routes_count: routes.length }])[0], routes }; } export function normalizeRoutes(raw) { return [...new Map(list(raw).filter(route => route?.is_active !== false).map(route => { const value = { id: id(route.id), number: text(route.route_number), name: text(route.name) }; return [value.id, value]; })).values()].sort((a, b) => a.number.localeCompare(b.number, 'ru', { numeric: true }) || a.id - b.id); } export function normalizeRouteStops(raw) { const route = record(raw); const stops = list(route.stops).filter(stop => stop?.is_active !== false).map(stop => ({ ...normalizeStops([stop])[0], sequence: id(stop.sequence), })).sort((a, b) => a.sequence - b.sequence); return { route: { id: id(route.id), number: text(route.route_number), name: text(route.name) }, stops }; } export function normalizeDepartures(raw, now = Date.now()) { const departures = []; const routes = []; let partial = false; for (const item of list(record(raw).routes)) { const route = record(item); const routeInfo = { id: id(route.id), number: text(route.route_number), name: text(route.name) }; routes.push(routeInfo); if (route.error) { partial = true; continue; } for (const value of list(route.arrivals)) { const arrival = record(value); const time = typeof arrival.scheduledTime === 'string' && /^(\d{1,2}):([0-5]\d)(?::[0-5]\d)?$/.exec(arrival.scheduledTime); // RideBus ETA includes historical delay; this view explicitly shows the timetable. const minutes = Number(arrival.minutesUntil) - Number(arrival.delay ?? 0); if (!time || Number(time[1]) > 23 || arrival.minutesUntil == null || !Number.isFinite(minutes)) { partial = true; continue; } if (minutes < 0 || minutes >= 1440) continue; const scheduledAt = new Date(now + minutes * 60000).toISOString(); let serviceDate = new Date(Date.parse(scheduledAt) + 10800000).toISOString().slice(0, 10); const originTime = typeof arrival.trip?.departureTime === 'string' && /^(\d{1,2}):([0-5]\d)/.exec(arrival.trip.departureTime); if (originTime && Number(time[1]) * 60 + Number(time[2]) < Number(originTime[1]) * 60 + Number(originTime[2])) serviceDate = previousDate(serviceDate); departures.push({ routeId: routeInfo.id, routeNumber: routeInfo.number, direction: routeInfo.name, scheduledTime: `${time[1].padStart(2, '0')}:${time[2]}`, // Use an absolute instant so old snapshots expire, including across midnight. scheduledAt, ...(arrival.trip ? { tripKey: tripIdentity(arrival.trip, serviceDate) } : {}), }); } } return { departures: departures.sort((a, b) => Date.parse(a.scheduledAt) - Date.parse(b.scheduledAt)), routes: routes.sort((a, b) => a.number.localeCompare(b.number, 'ru', { numeric: true })), updatedAt: new Date(now).toISOString(), partial, }; } export function createRideBusClient({ baseUrl = process.env.RIDEBUS_API_BASE_URL || 'https://api.ridebus.by/', apiKey = process.env.RIDEBUS_API_KEY, fetchData = fetch, now = Date.now, } = {}) { const cache = new Map(); const pending = new Map(); const rawCache = new Map(); const rawPending = new Map(); const queue = []; let active = 0; async function upstream(path, cityId, allowMissing = false, allowRoutingError = false) { if (!apiKey?.trim()) throw new ApiError(503, 'api_not_configured'); if (active >= 6) await new Promise(resolve => queue.push(resolve)); else active++; const headers = { Accept: 'application/json', 'X-API-Key': apiKey }; if (cityId) headers['X-City-Id'] = String(cityId); try { const response = await fetchData(new URL(path, `${baseUrl.replace(/\/+$/, '')}/`), { headers, redirect: 'error', signal: AbortSignal.timeout(12000), }); if (allowMissing && response.status === 404) return null; if (!response.ok && !(allowRoutingError && response.status === 400)) throw new ApiError(response.status === 404 ? 404 : 502); return await response.json(); } catch (error) { throw error instanceof ApiError ? error : new ApiError(502); } finally { const next = queue.shift(); if (next) next(); else active--; } } async function cachedUpstream(path, cityId, allowMissing = false) { const key = `${cityId}:${path}`; const entry = rawCache.get(key); if (entry && entry.expires > now()) return entry.data; if (rawPending.has(key)) return rawPending.get(key); const request = upstream(path, cityId, allowMissing).then(data => { if (rawCache.size >= 200) rawCache.delete(rawCache.keys().next().value); rawCache.set(key, { data, expires: now() + 300000 }); return data; }).finally(() => rawPending.delete(key)); rawPending.set(key, request); return request; } async function timetable(input) { const stop = record(await cachedUpstream(`api/v1/stops/${input.stopId}`, input.cityId)); const routes = list(stop.routes).map(route => ({ id: id(route.id), number: text(route.route_number), name: text(route.name) })); const dates = [previousDate(input.date), input.date]; const results = await Promise.allSettled(routes.map(async route => { const [stops, ...schedules] = await Promise.all([ cachedUpstream(`api/v1/routes/${route.id}/stops`, input.cityId), ...dates.map(date => cachedUpstream(`api/v1/routes/${route.id}/schedule/today?date=${date}`, input.cityId, true)), ]); const days = schedules.map((schedule, index) => schedule ? routeDepartures(route, stops, schedule, input.stopId, dates[index]) : { departures: [], partial: false }); return { departures: days.flatMap(day => day.departures).filter(departure => new Date(Date.parse(departure.scheduledAt) + 10800000).toISOString().slice(0, 10) === input.date), partial: days.some(day => day.partial), isHoliday: !!schedules[1]?.isHoliday, holidayName: typeof schedules[1]?.holidayName === 'string' ? schedules[1].holidayName : null }; })); if (routes.length && results.every(result => result.status === 'rejected')) throw new ApiError(502); const days = results.filter(result => result.status === 'fulfilled').map(result => result.value); const departures = days.flatMap(day => day.departures).map(({ trip, serviceDate, ...departure }) => departure); return { date: input.date, routes: routes.sort((a, b) => a.number.localeCompare(b.number, 'ru', { numeric: true })), departures: departures.sort((a, b) => Date.parse(a.scheduledAt) - Date.parse(b.scheduledAt)), updatedAt: new Date(now()).toISOString(), partial: days.some(day => day.partial) || days.length < routes.length, isHoliday: days.some(day => day.isHoliday), holidayName: days.find(day => day.holidayName)?.holidayName ?? null, }; } async function journey(input) { const rawRoute = record(await cachedUpstream(`api/v1/routes/${input.routeId}`, input.cityId)); const route = { id: id(rawRoute.id), number: text(rawRoute.route_number), name: text(rawRoute.name) }; const stops = list(rawRoute.stops).map(stop => ({ ...stop, stop_id: stop.id })); const date = new Date(Date.parse(input.at) + 10800000).toISOString().slice(0, 10); const dates = [previousDate(date), date]; const schedules = await Promise.all(dates.map(value => cachedUpstream(`api/v1/routes/${input.routeId}/schedule/today?date=${value}`, input.cityId, true))); const candidates = schedules.flatMap((schedule, index) => schedule ? routeDepartures(route, stops, schedule, input.stopId, dates[index]).departures : []) .filter(item => Math.abs(Date.parse(item.scheduledAt) - Date.parse(input.at)) < 60000 && (!input.tripKey || item.tripKey === input.tripKey)); const unique = [...new Map(candidates.map(item => [item.tripKey, item])).values()]; if (!unique.length) throw new ApiError(404, 'journey_not_found'); if (unique.length > 1) throw new ApiError(409, 'journey_ambiguous'); const departure = unique[0]; return { route, stops: journeyStops(stops, departure), selectedStopId: input.stopId, note: departure.note, updatedAt: new Date(now()).toISOString(), tripKey: departure.tripKey }; } async function load(input) { if (input.resource === 'routes') return normalizeRoutes(await cachedUpstream('api/v1/routes', input.cityId)); if (input.resource === 'routeStops') return normalizeRouteStops(await cachedUpstream(`api/v1/routes/${input.routeId}`, input.cityId)); if (input.resource === 'walk') { const stop = record(await cachedUpstream(`api/v1/stops/${input.stopId}`, input.cityId)); const target = coordinates(stop.latitude, stop.longitude); if (target.latitude === undefined) throw new ApiError(404, 'walking_not_found'); const points = `${input.lon.toFixed(6)},${input.lat.toFixed(6)};${target.longitude.toFixed(6)},${target.latitude.toFixed(6)}`; return normalizeWalking(await upstream(`routing/foot/route/v1/foot/${points}?alternatives=false&steps=false&overview=full&geometries=geojson&skip_waypoints=true`, input.cityId, false, true)); } if (input.resource === 'stop') return normalizeStop(await cachedUpstream(`api/v1/stops/${input.stopId}`, input.cityId)); if (input.resource === 'timetable') return timetable(input); if (input.resource === 'journey') return journey(input); if (input.resource === 'alerts') return normalizeAlerts(await upstream('api/v1/alerts/active', input.cityId), input.cityId); if (input.resource === 'nearby') return normalizeNearby(await upstream(`api/v1/stops/nearby?${new URLSearchParams({ lat: String(input.lat), lon: String(input.lon), radius: String(input.radius) })}`, input.cityId)); const path = input.resource === 'cities' ? 'api/v1/cities' : input.resource === 'stops' ? 'api/v1/stops' : `api/v1/eta/stop/${input.stopId}`; const raw = await upstream(path, input.cityId); return input.resource === 'cities' ? normalizeCities(raw) : input.resource === 'stops' ? normalizeStops(raw) : normalizeDepartures(raw, now()); } return { async get(input) { // Location searches are ephemeral: coordinates and results never enter caches. if (['nearby', 'walk'].includes(input.resource)) return load(input); const key = `${input.resource}:${input.cityId ?? ''}:${input.stopId ?? ''}:${input.date ?? ''}:${input.routeId ?? ''}:${input.at ?? ''}:${input.tripKey ?? ''}`; const entry = cache.get(key); if (entry && entry.expires > now()) return entry.data; if (pending.has(key)) return pending.get(key); const request = load(input).then(data => { if (cache.size >= 200) cache.delete(cache.keys().next().value); const ttl = input.resource === 'cities' ? 3600000 : ['stops', 'stop', 'routes', 'routeStops', 'timetable', 'journey'].includes(input.resource) ? 300000 : 30000; cache.set(key, { data, expires: now() + ttl }); return data; }).finally(() => pending.delete(key)); pending.set(key, request); return request; }, }; }