Files
ridebus-website/lib/ridebus.ts
Zakhar Timoshenko 142ec97959 feat: add Open Graph image generation and SEO metadata
- Introduced `PageSeo` component for managing page metadata for social sharing.
- Created Open Graph image templates using SVG and static fonts for better visual representation.
- Implemented service alert management with functions to check active alerts and format route numbers.
- Added utility functions for generating and watching alert validity.
- Developed a script to generate Open Graph images dynamically based on page content.
- Included tests for service alert functionalities to ensure correctness and reliability.
2026-10-05 13:02:06 +03:00

352 lines
11 KiB
TypeScript

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