import { getActiveAlerts } from './serviceAlerts.ts'; export type City = { id: number; name: string; slug?: string; emblem?: string; avatarUrl?: string; }; export type ServiceAlert = { id: number; cityId: number; type: 'delay' | 'cancellation' | 'detour' | 'info'; title: string; message: string; routeNumbers: string[]; routeIds: number[]; isCityWide: boolean; routeId: number | null; routeNumber: string | null; routeName: string | null; startTime: string; endTime: string | null; createdAt: string; }; export type Alerts = { alerts: ServiceAlert[]; updatedAt: string }; export type Stop = { id: number; name: string; direction: string; routesCount: number; }; export type Departure = { routeId: number; routeNumber: string; direction: string; scheduledTime: string; minutesUntil: number; }; export type Departures = { departures: Departure[]; updatedAt: string; partial: boolean; }; export class RideBusError extends Error { status: number; constructor(status: number) { super('RideBus data is unavailable'); this.status = status; } } type Query = Record; type Request = { resource: 'cities' | 'stops' | 'departures' | 'alerts'; cityId?: number; stopId?: number; }; function positiveId(value: unknown): number { if (typeof value !== 'string' || !/^[1-9]\d*$/.test(value)) { throw new RideBusError(400); } const id = Number(value); if (!Number.isSafeInteger(id)) throw new RideBusError(400); return id; } export function parseRequest(query: Query): Request { const resource = query.resource ?? 'cities'; if (resource === 'cities') return { resource }; if ( resource !== 'stops' && resource !== 'departures' && resource !== 'alerts' ) { throw new RideBusError(400); } const cityId = positiveId(query.cityId); return resource === 'stops' || resource === 'alerts' ? { resource, cityId } : { resource, cityId, stopId: positiveId(query.stopId) }; } function record(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new RideBusError(502); } return value as Record; } function list(value: unknown): unknown[] { if (!Array.isArray(value)) throw new RideBusError(502); return value; } function text(value: unknown): string { if (typeof value !== 'string' || !value.trim()) throw new RideBusError(502); return value.trim(); } function entityId(value: unknown): number { if (!Number.isSafeInteger(value) || Number(value) <= 0) { throw new RideBusError(502); } return Number(value); } function date(value: unknown): string { const result = text(value); const timestamp = Date.parse(result); if (!Number.isFinite(timestamp)) throw new RideBusError(502); return new Date(timestamp).toISOString(); } function cityAvatar(value: unknown, baseUrl: string): string | undefined { if ( typeof value !== 'string' || !/^\/uploads\/cities\/[a-zA-Z0-9_-]+\.(?:png|jpe?g|webp)$/.test(value) ) return undefined; return new URL(value, baseUrl).href; } type CacheEntry = { expiresAt: number; data: unknown }; // Credentials and the upstream client are only used by the server API route. export function createRideBusClient( options: { baseUrl?: string; apiKey?: string; fetch?: typeof fetch; now?: () => number; } = {} ) { const cache = new Map(); const pending = new Map>(); const now = options.now ?? Date.now; const fetchData = options.fetch ?? fetch; const apiBaseUrl = () => options.baseUrl ?? process.env.RIDEBUS_API_BASE_URL ?? 'https://api.ridebus.by/'; async function request(path: string, cityId?: number): Promise { const apiKey = options.apiKey ?? process.env.RIDEBUS_API_KEY; if (!apiKey?.trim()) throw new RideBusError(503); const baseUrl = apiBaseUrl(); const url = new URL(path, `${baseUrl.replace(/\/+$/, '')}/`); const headers: Record = { 'X-API-Key': apiKey, Accept: 'application/json', }; if (cityId) headers['X-City-Id'] = String(cityId); const response = await fetchData(url, { headers, signal: AbortSignal.timeout(10000), redirect: 'error', cache: 'no-store', }); if (!response.ok) throw new RideBusError(response.status === 404 ? 404 : 502); return response.json(); } async function load(input: Request): Promise { if (input.resource === 'cities') { return list(await request('api/v1/cities')).map((item): City => { const city = record(item); const avatarUrl = cityAvatar(city.avatar_url, apiBaseUrl()); return { id: entityId(city.id), name: text(city.name), ...(typeof city.slug === 'string' ? { slug: city.slug } : {}), ...(typeof city.emblem === 'string' && city.emblem.trim() ? { emblem: city.emblem.trim() } : {}), ...(avatarUrl ? { avatarUrl } : {}), }; }); } if (input.resource === 'alerts') { const cityId = entityId(input.cityId); const alerts = list(await request('api/v1/alerts/active', input.cityId)) .map(record) .filter( (alert) => alert.is_active !== false && (alert.city_id == null || entityId(alert.city_id) === cityId) ) .map((alert): ServiceAlert => { const startTime = date(alert.start_time); const type = alert.alert_type; const routeNumber = typeof alert.route_number === 'string' && alert.route_number.trim() ? alert.route_number.trim() : null; const routeId = alert.route_id == null ? null : entityId(alert.route_id); const routeNumbers = [ ...new Set( alert.route_numbers == null ? [] : list(alert.route_numbers).map(text) ), ]; const routeIds = [ ...new Set( alert.route_ids == null ? [] : list(alert.route_ids).map(entityId) ), ]; // Older responses only carry a single route. Check both formats before // classifying a notice as citywide, including notices with IDs only. if (!routeNumbers.length && routeNumber) routeNumbers.push(routeNumber); if (!routeIds.length && routeId) routeIds.push(routeId); return { id: entityId(alert.id), cityId, type: type === 'delay' || type === 'cancellation' || type === 'detour' ? type : 'info', title: text(alert.title), message: text(alert.message), routeNumbers, routeIds, isCityWide: !routeNumbers.length && !routeIds.length, routeId, routeNumber, routeName: typeof alert.route_name === 'string' && alert.route_name.trim() ? alert.route_name.trim() : null, startTime, endTime: alert.end_time ? date(alert.end_time) : null, createdAt: alert.created_at ? date(alert.created_at) : startTime, }; }) .sort((a, b) => Date.parse(b.startTime) - Date.parse(a.startTime)); return { alerts, updatedAt: new Date(now()).toISOString(), } satisfies Alerts; } if (input.resource === 'stops') { return list(await request('api/v1/stops', input.cityId)).map( (item): Stop => { const stop = record(item); return { id: entityId(stop.id), name: text(stop.name), direction: typeof stop.description === 'string' ? stop.description.trim() : '', routesCount: Math.max(0, Number(stop.routes_count) || 0), }; } ); } const data = record( await request(`api/v1/eta/stop/${input.stopId}`, input.cityId) ); const routes = list(data.routes).map(record); const departures: Departure[] = []; let partial = false; for (const route of routes) { if (route.error) { partial = true; continue; } for (const item of list(route.arrivals)) { const arrival = record(item); const time = typeof arrival.scheduledTime === 'string' ? /^(\d{1,2}):([0-5]\d)(?::[0-5]\d)?$/.exec(arrival.scheduledTime) : null; // ETA can include historical delay. The landing shows scheduled departures. const minutesUntil = Number(arrival.minutesUntil) - Number(arrival.delay ?? 0); if ( !time || Number(time[1]) > 23 || arrival.minutesUntil == null || !Number.isFinite(minutesUntil) || minutesUntil < 0 || minutesUntil >= 1440 ) { partial = true; continue; } departures.push({ routeId: entityId(route.id), routeNumber: text(route.route_number), direction: text(route.name), scheduledTime: `${time[1].padStart(2, '0')}:${time[2]}`, minutesUntil, }); } } return { departures: departures .sort((a, b) => a.minutesUntil - b.minutesUntil) .slice(0, 4), updatedAt: new Date(now()).toISOString(), partial, } satisfies Departures; } return { async get(input: Request): Promise { // An alert can expire before its cached response does. const current = (data: unknown) => { if (input.resource !== 'alerts') return data; const result = data as Alerts; return { ...result, alerts: getActiveAlerts(result.alerts, input.cityId!, now()), } satisfies Alerts; }; const key = `${input.resource}:${input.cityId ?? ''}:${input.stopId ?? ''}`; const cached = cache.get(key); if (cached && cached.expiresAt > now()) return current(cached.data); const inFlight = pending.get(key); if (inFlight) return inFlight.then(current); const ttl = input.resource === 'cities' ? 3600000 : input.resource === 'stops' ? 300000 : input.resource === 'alerts' ? 60000 : 30000; const result = load(input) .then((data) => { // Bound the cache even when visitors request many different stops. if (cache.size >= 200) cache.delete(cache.keys().next().value!); cache.set(key, { data, expiresAt: now() + ttl }); return data; }) .finally(() => pending.delete(key)); pending.set(key, result); return result.then(current); }, }; }