321 lines
9.6 KiB
TypeScript
321 lines
9.6 KiB
TypeScript
export type City = {
|
|
id: number;
|
|
name: string;
|
|
slug?: string;
|
|
emblem?: string;
|
|
avatarUrl?: string;
|
|
};
|
|
export type ServiceAlert = {
|
|
id: number;
|
|
type: 'delay' | 'cancellation' | 'detour' | 'info';
|
|
title: string;
|
|
message: string;
|
|
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<string, string | string[] | undefined>;
|
|
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<string, unknown> {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new RideBusError(502);
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
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 };
|
|
|
|
// This module is only imported at runtime by the API route. UI imports are type-only.
|
|
export function createRideBusClient(
|
|
options: {
|
|
baseUrl?: string;
|
|
apiKey?: string;
|
|
fetch?: typeof fetch;
|
|
now?: () => number;
|
|
} = {}
|
|
) {
|
|
const cache = new Map<string, CacheEntry>();
|
|
const pending = new Map<string, Promise<unknown>>();
|
|
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<unknown> {
|
|
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<string, string> = {
|
|
'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<unknown> {
|
|
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 alerts = list(await request('api/v1/alerts/active', input.cityId))
|
|
.map(record)
|
|
.filter((alert) => alert.is_active !== false)
|
|
.map((alert): ServiceAlert => {
|
|
const startTime = date(alert.start_time);
|
|
const type = alert.alert_type;
|
|
return {
|
|
id: entityId(alert.id),
|
|
type:
|
|
type === 'delay' || type === 'cancellation' || type === 'detour'
|
|
? type
|
|
: 'info',
|
|
title: text(alert.title),
|
|
message: text(alert.message),
|
|
routeNumber:
|
|
typeof alert.route_number === 'string' &&
|
|
alert.route_number.trim()
|
|
? alert.route_number.trim()
|
|
: null,
|
|
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<unknown> {
|
|
// An alert can expire before its cached response does.
|
|
const current = (data: unknown) => {
|
|
if (input.resource !== 'alerts') return data;
|
|
const result = data as Alerts;
|
|
const timestamp = now();
|
|
return {
|
|
...result,
|
|
alerts: result.alerts.filter(
|
|
(alert) =>
|
|
Date.parse(alert.startTime) <= timestamp &&
|
|
(!alert.endTime || Date.parse(alert.endTime) >= timestamp)
|
|
),
|
|
} 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);
|
|
},
|
|
};
|
|
}
|