initial commit

This commit is contained in:
2026-10-08 22:56:41 +03:00
commit 4a0785d003
63 changed files with 4863 additions and 0 deletions

149
server/board.mjs Normal file
View File

@@ -0,0 +1,149 @@
import { loadTimetable } from './timetable.mjs';
export class ApiError extends Error {
constructor(status = 502, code = 'data_unavailable') { super(code); this.status = status; this.code = code; }
}
const clean = value => typeof value === 'string' ? value.trim() : '';
const ids = value => Array.isArray(value) ? value.map(Number).filter(id => Number.isSafeInteger(id) && id > 0) : [];
const strings = value => Array.isArray(value) ? value.map(clean).filter(Boolean) : [];
export const localDate = now => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Minsk', year: 'numeric', month: '2-digit', day: '2-digit' }).format(now);
export function scheduledInstant(time, now) {
const match = /^(\d{1,2}):([0-5]\d)(?::([0-5]\d))?$/.exec(clean(time));
if (!match || Number(match[1]) > 23) return null;
// ETA is a same-day timetable in Minsk. Never move an expired trip to tomorrow.
return `${localDate(now)}T${match[1].padStart(2, '0')}:${match[2]}:${match[3] || '00'}+03:00`;
}
function routeInfo(route) {
return { id: Number(route.id), number: clean(route.route_number), name: clean(route.name), type: route.type === 'minibus' ? 'minibus' : 'bus' };
}
export function normalizeDepartures(raw, now, stopNames = new Map()) {
if (!raw || !Array.isArray(raw.routes)) throw new ApiError();
let partial = false;
const departures = [];
const routes = [];
for (const item of raw.routes) {
if (!item || !Number.isSafeInteger(Number(item.id)) || !clean(item.route_number) || !clean(item.name)) { partial = true; continue; }
const route = routeInfo(item);
routes.push(route);
if (item.error || !Array.isArray(item.arrivals)) { partial = true; continue; }
for (const arrival of item.arrivals) {
const scheduledAt = scheduledInstant(arrival?.scheduledTime, now);
if (!scheduledAt) { partial = true; continue; }
if (Date.parse(scheduledAt) + 60000 <= now) continue;
const note = clean(arrival.trip?.note);
const terminal = stopNames.get(Number(arrival.trip?.endStopId));
const destination = terminal || route.name.split(/\s+[-–—]\s+/).at(-1) || route.name;
const identity = [route.id, scheduledAt, arrival.trip?.departureTime || '', arrival.trip?.startStopId || '', arrival.trip?.endStopId || '', note].join(':');
departures.push({ id: identity, routeId: route.id, routeNumber: route.number, routeName: route.name,
destination, type: route.type, scheduledAt, scheduledTime: clean(arrival.scheduledTime).slice(0, 5), note,
source: 'schedule' });
}
}
return { departures: [...new Map(departures.map(item => [item.id, item])).values()].sort((a, b) => Date.parse(a.scheduledAt) - Date.parse(b.scheduledAt)),
routes: routes.sort((a, b) => a.number.localeCompare(b.number, 'ru', { numeric: true })), partial };
}
export function normalizeAlerts(raw, routes, cityId, now) {
if (!Array.isArray(raw)) throw new ApiError();
const routeIds = new Set(routes.map(route => route.id));
const routeNumbers = new Set(routes.map(route => route.number));
return [...new Map(raw.filter(alert => {
if (!alert || alert.is_active === false || alert.city_id != null && Number(alert.city_id) !== cityId) return false;
const start = alert.start_time == null ? null : Date.parse(alert.start_time);
const end = alert.end_time == null ? null : Date.parse(alert.end_time);
if (start !== null && (!Number.isFinite(start) || start > now) || end !== null && (!Number.isFinite(end) || end <= now)) return false;
const targets = ids(alert.route_ids);
if (!targets.length && Number(alert.route_id) > 0) targets.push(Number(alert.route_id));
const numbers = strings(alert.route_numbers);
if (!numbers.length && clean(alert.route_number)) numbers.push(clean(alert.route_number));
return targets.length === 0 && numbers.length === 0 || targets.some(id => routeIds.has(id)) || numbers.some(number => routeNumbers.has(number));
}).map(alert => {
const numbers = strings(alert.route_numbers);
if (!numbers.length && clean(alert.route_number)) numbers.push(clean(alert.route_number));
const targets = ids(alert.route_ids);
if (!targets.length && Number(alert.route_id) > 0) targets.push(Number(alert.route_id));
const value = { id: String(alert.id), title: clean(alert.title) || 'Изменения движения', message: clean(alert.message),
type: ['cancellation', 'delay', 'detour'].includes(alert.alert_type) ? alert.alert_type : 'info',
routeNumbers: [...new Set(numbers)], routeIds: [...new Set(targets)],
startAt: alert.start_time || null, endAt: alert.end_time || null };
return [value.id, value];
})).values()];
}
export function createBoardClient({
baseUrl = process.env.RIDEBUS_API_BASE_URL || 'https://api.ridebus.by/', apiKey = process.env.RIDEBUS_API_KEY,
cityId = Number(process.env.BOARD_CITY_ID || 1), cityName = process.env.BOARD_CITY_NAME || 'Полоцк',
stopName = process.env.BOARD_STOP_NAME || 'Швейная фабрика', defaultStopId = Number(process.env.BOARD_STOP_ID || 1),
passengerBaseUrl = process.env.BOARD_PWA_URL || 'https://pwa.ridebus.by/',
fetchData = fetch, now = Date.now,
} = {}) {
const cache = new Map();
const pending = new Map();
const url = new URL(baseUrl.endsWith('/') ? baseUrl : baseUrl + '/');
if (!['https:', 'http:'].includes(url.protocol) || !Number.isSafeInteger(cityId) || cityId < 1) throw new Error('Invalid RideBus configuration');
async function upstream(path) {
if (!apiKey?.trim()) throw new ApiError(503, 'api_not_configured');
try {
const response = await fetchData(new URL(path, url), { headers: { Accept: 'application/json', 'X-API-Key': apiKey, 'X-City-Id': String(cityId) },
redirect: 'error', signal: AbortSignal.timeout(10000) });
if (!response.ok) throw new ApiError(response.status === 404 ? 404 : 502);
return await response.json();
} catch (error) { throw error instanceof ApiError ? error : new ApiError(); }
}
function cached(key, ttl, load) {
const entry = cache.get(key);
if (entry?.expires > now()) return Promise.resolve(entry.data);
if (pending.has(key)) return pending.get(key);
const request = load().then(data => { cache.set(key, { expires: now() + ttl, data }); return data; }).finally(() => pending.delete(key));
pending.set(key, request);
return request;
}
function catalog() {
return cached('catalog', 300000, async () => {
const raw = await upstream('api/v1/stops');
if (!Array.isArray(raw)) throw new ApiError();
return raw.filter(stop => Number(stop.id) > 0 && clean(stop.name));
});
}
async function stopDetails(stopId) {
if (!Number.isSafeInteger(stopId) || stopId < 1) throw new ApiError(400, 'invalid_stop');
if (!(await catalog()).some(stop => Number(stop.id) === stopId)) throw new ApiError(404, 'stop_not_found');
const stop = await cached(`stop:${stopId}`, 300000, () => upstream(`api/v1/stops/${stopId}`));
if (Number(stop.id) !== stopId || stop.city_id != null && Number(stop.city_id) !== cityId) throw new ApiError();
return stop;
}
const config = () => ({ cityId, cityName, stopName, defaultStopId, configured: Boolean(apiKey?.trim()), timeZone: 'Europe/Minsk', refreshSeconds: 20, passengerBaseUrl });
return {
config,
async coordinates(stopId) { const stop = await stopDetails(stopId); return { latitude: stop.latitude, longitude: stop.longitude }; },
async timetable(stopId) {
const stop = await stopDetails(stopId);
return cached(`timetable:${stopId}`, 1800000, () => loadTimetable({ upstream, cityId, cityName, stop, now: now() }));
},
async stops() { return (await catalog()).map(stop => ({ id: Number(stop.id), name: clean(stop.name), direction: clean(stop.description) })); },
async board(stopId) {
if (!Number.isSafeInteger(stopId) || stopId < 1) throw new ApiError(400, 'invalid_stop');
const stops = await catalog();
const catalogStop = stops.find(stop => Number(stop.id) === stopId);
if (!catalogStop) throw new ApiError(404, 'stop_not_found');
return cached(`board:${stopId}`, 12000, async () => {
const [stop, eta] = await Promise.all([
cached(`stop:${stopId}`, 300000, () => upstream(`api/v1/stops/${stopId}`)), upstream(`api/v1/eta/stop/${stopId}`),
]);
if (Number(stop.id) !== stopId || stop.city_id != null && Number(stop.city_id) !== cityId) throw new ApiError();
const generatedAt = now();
const data = normalizeDepartures(eta, generatedAt, new Map(stops.map(stop => [Number(stop.id), clean(stop.name)])));
const stopRoutes = Array.isArray(stop.routes) ? stop.routes.filter(route => route?.is_active !== false).map(routeInfo) : data.routes;
let alerts = [], alertsAvailable = true;
try { alerts = normalizeAlerts(await cached('alerts', 20000, () => upstream('api/v1/alerts/active')), stopRoutes, cityId, now()); }
catch { alertsAvailable = false; }
return { cityId, cityName, stop: { id: stopId, name: clean(stop.name), direction: clean(stop.description) },
...data, alerts, alertsAvailable, updatedAt: new Date(generatedAt).toISOString(), serviceDate: localDate(generatedAt), mode: 'live' };
});
},
};
}

59
server/index.mjs Normal file
View File

@@ -0,0 +1,59 @@
import express from 'express';
import { existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
import { ApiError, createBoardClient } from './board.mjs';
import { createWeatherClient } from './weather.mjs';
export function createApp(client = createBoardClient(), weatherClient = createWeatherClient()) {
const app = express();
app.disable('x-powered-by');
app.use((_req, res, next) => { res.setHeader('X-Content-Type-Options', 'nosniff'); next(); });
app.get('/healthz', (_req, res) => res.json({ status: 'ok' }));
app.use('/api', (req, res, next) => {
res.setHeader('Cache-Control', 'no-store');
if (req.method !== 'GET') { res.setHeader('Allow', 'GET'); res.status(405).json({ error: 'method_not_allowed' }); return; }
next();
});
app.get('/api/config', (_req, res) => res.json(client.config()));
app.get('/api/stops', async (_req, res) => { res.json(await client.stops()); });
app.get('/api/board', async (req, res) => {
const raw = req.query.stopId;
if (typeof raw !== 'string' || !/^[1-9]\d*$/.test(raw)) throw new ApiError(400, 'invalid_stop');
const data = await client.board(Number(raw));
res.json({ ...data, serverTime: new Date().toISOString() });
});
const stopId = req => {
if (typeof req.query.stopId !== 'string' || !/^[1-9]\d*$/.test(req.query.stopId) || !Number.isSafeInteger(Number(req.query.stopId))) throw new ApiError(400, 'invalid_stop');
return Number(req.query.stopId);
};
app.get('/api/timetable', async (req, res) => res.json(await client.timetable(stopId(req))));
app.get('/api/weather', async (req, res) => {
const id = stopId(req);
res.json(await weatherClient.weather(client.config().cityId, id, await client.coordinates(id)));
});
app.use('/api', (_req, res) => res.status(404).json({ error: 'not_found' }));
// Never return upstream bodies, stack traces or API credentials.
app.use((error, _req, res, _next) => { res.status(error instanceof ApiError ? error.status : 502).json({ error: error instanceof ApiError ? error.code : 'data_unavailable' }); });
return app;
}
async function start() {
const root = fileURLToPath(new URL('..', import.meta.url));
const dev = process.argv.includes('--dev');
const app = createApp();
if (dev) {
const vite = await (await import('vite')).createServer({ root, server: { middlewareMode: true }, appType: 'spa' });
app.use(vite.middlewares);
} else {
const dist = resolve(root, 'dist');
if (!existsSync(resolve(dist, 'index.html'))) throw new Error('Run npm run build first.');
app.use(express.static(dist, { setHeaders: (res, file) => res.setHeader('Cache-Control', file.includes('/assets/') ? 'public, max-age=31536000, immutable' : 'no-cache') }));
app.get('/{*path}', (_req, res) => res.sendFile(resolve(dist, 'index.html')));
}
const host = process.env.HOST || '127.0.0.1';
const port = Number(process.env.PORT || 5180);
const server = app.listen(port, host, () => console.log(`RideBus Board: http://${host}:${port} (${dev ? 'development' : 'production'})`));
server.on('error', error => { console.error(error.code === 'EADDRINUSE' ? `Port ${port} is in use.` : 'Unable to start server.'); process.exitCode = 1; });
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) start().catch(error => { console.error(error.message); process.exitCode = 1; });

73
server/timetable.mjs Normal file
View File

@@ -0,0 +1,73 @@
import { ApiError } from './board.mjs';
import { TIMETABLE_MAX_MS, localDate, shiftDate, validDate } from '../src/lib/timetable.mjs';
function normalizeSchedules(raw, route, rawStops, stopId) {
if (!Array.isArray(raw) || !Array.isArray(rawStops)) throw new ApiError();
const stops = new Map(rawStops.map(stop => [Number(stop.stop_id), stop]));
const target = stops.get(stopId);
if (!target || !Number.isFinite(Number(target.sequence))) throw new ApiError();
return raw.map(schedule => {
if (Number(schedule.route_id) !== route.id || !['weekday', 'saturday', 'sunday', 'holiday'].includes(schedule.day_type)) throw new ApiError();
let trips = schedule.trips;
if (typeof trips === 'string') { try { trips = JSON.parse(trips); } catch { throw new ApiError(); } }
if (!Array.isArray(trips) || !trips.length) {
if (!Array.isArray(schedule.departure_times)) throw new ApiError();
trips = schedule.departure_times.map(departureTime => ({ departureTime }));
}
const normalized = trips.flatMap(trip => {
const time = typeof trip?.departureTime === 'string' && /^([01]?\d|2[0-3]):([0-5]\d)(?::([0-5]\d))?$/.exec(trip.departureTime);
if (!time) throw new ApiError();
const start = trip.startStopId ? stops.get(Number(trip.startStopId)) : null;
const end = trip.endStopId ? stops.get(Number(trip.endStopId)) : null;
if (trip.startStopId && !start || trip.endStopId && !end) throw new ApiError();
const sequence = Number(target.sequence);
const reverse = start && end && Number(start.sequence) > Number(end.sequence);
if (reverse ? sequence > Number(start.sequence) || sequence < Number(end.sequence)
: start && sequence < Number(start.sequence) || end && sequence > Number(end.sequence)) return [];
const offset = start && Number(start.sequence) > sequence ? Number(start.time_offset_minutes || 0) - Number(target.time_offset_minutes || 0)
: Number(target.time_offset_minutes || 0) - Number(start?.time_offset_minutes || 0);
if (!Number.isFinite(offset) || offset < 0 || offset >= 1440) throw new ApiError();
const days = value => {
if (value == null) return [];
if (!Array.isArray(value) || value.some(day => !Number.isInteger(Number(day)) || Number(day) < 1 || Number(day) > 7)) throw new ApiError();
return [...new Set(value.map(Number))];
};
const terminal = end || [...rawStops].sort((a, b) => Number(b.sequence) - Number(a.sequence))[0];
return [{ arrivalSeconds: Number(time[1]) * 3600 + Number(time[2]) * 60 + Number(time[3] || 0) + offset * 60,
destination: terminal?.name?.trim() || route.name.split(/\s+[-–—]\s+/).at(-1) || route.name,
note: typeof trip.note === 'string' ? trip.note.trim() : '', daysOfWeek: days(trip.daysOfWeek), exceptDaysOfWeek: days(trip.exceptDaysOfWeek) }];
});
const date = value => {
if (value == null) return null;
const result = String(value).slice(0, 10);
if (!validDate(result)) throw new ApiError();
return result;
};
return { dayType: schedule.day_type, validFrom: date(schedule.valid_from), validUntil: date(schedule.valid_until), trips: normalized };
});
}
export async function loadTimetable({ upstream, cityId, cityName, stop, now }) {
if (!Array.isArray(stop.routes)) throw new ApiError();
const date = localDate(now);
const calendarYears = [...new Set([shiftDate(date, -1), shiftDate(date, 8)].map(value => Number(value.slice(0, 4))))];
const calendars = await Promise.all(calendarYears.map(year => upstream(`api/v1/holidays/year/${year}`)));
const holidays = calendars.flatMap(raw => {
if (!Array.isArray(raw)) throw new ApiError();
return raw.map(item => { const date = String(item.date).slice(0, 10); if (!validDate(date)) throw new ApiError(); return date; });
});
const routes = stop.routes.filter(route => route.is_active !== false).map(route => ({ id: Number(route.id), number: route.route_number?.trim(), name: route.name?.trim(), type: route.type === 'minibus' ? 'minibus' : 'bus' }));
if (routes.some(route => !Number.isSafeInteger(route.id) || !route.number || !route.name)) throw new ApiError();
// Bound fan-out. Publish only a complete bundle; one failed route retains the previous client cache.
const result = new Array(routes.length);
let index = 0;
await Promise.all(Array.from({ length: Math.min(4, routes.length) }, async () => {
while (index < routes.length) {
const current = index++, route = routes[current];
const [schedules, stops] = await Promise.all([upstream(`api/v1/routes/${route.id}/schedules`), upstream(`api/v1/routes/${route.id}/stops`)]);
result[current] = { ...route, schedules: normalizeSchedules(schedules, route, stops, Number(stop.id)) };
}
}));
return { version: 1, cityId, cityName, stop: { id: Number(stop.id), name: stop.name.trim(), direction: stop.description?.trim() || '' },
routes: result, holidays: [...new Set(holidays)], calendarYears, updatedAt: new Date(now).toISOString(), expiresAt: new Date(now + TIMETABLE_MAX_MS).toISOString() };
}

39
server/weather.mjs Normal file
View File

@@ -0,0 +1,39 @@
import { ApiError } from './board.mjs';
import { isWeather } from '../src/lib/weather.mjs';
export function createWeatherClient({ fetchData = fetch, now = Date.now, apiKey = process.env.OPEN_METEO_API_KEY } = {}) {
const cache = new Map(), pending = new Map(), failures = new Map();
return {
async weather(cityId, stopId, coordinates) {
const latitude = Number(coordinates.latitude), longitude = Number(coordinates.longitude);
if (coordinates.latitude == null || coordinates.longitude == null || !Number.isFinite(latitude) || Math.abs(latitude) > 90 || !Number.isFinite(longitude) || Math.abs(longitude) > 180) throw new ApiError(503, 'coordinates_unavailable');
const key = `${cityId}:${stopId}:${latitude.toFixed(4)}:${longitude.toFixed(4)}`;
if (cache.get(key)?.expires > now()) return cache.get(key).data;
if (pending.has(key)) return pending.get(key);
if (failures.get(key) > now()) throw new ApiError(502, 'weather_unavailable');
const request = (async () => {
try {
const url = new URL(apiKey ? 'https://customer-api.open-meteo.com/v1/forecast' : 'https://api.open-meteo.com/v1/forecast');
url.search = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude), timezone: 'Europe/Minsk', timeformat: 'unixtime',
current: 'temperature_2m,apparent_temperature,weather_code,is_day,wind_speed_10m',
hourly: 'temperature_2m,weather_code,precipitation_probability', forecast_days: '2', wind_speed_unit: 'ms', ...(apiKey ? { apikey: apiKey } : {}) }).toString();
// No transit credentials are sent to a weather provider.
const response = await fetchData(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(10000), redirect: 'error' });
if (!response.ok) throw new ApiError();
const raw = await response.json(), current = raw.current, hourly = raw.hourly;
if (!current || !Array.isArray(hourly?.time)) throw new ApiError();
const instant = seconds => typeof seconds === 'number' && Number.isFinite(seconds) ? new Date(seconds * 1000).toISOString() : '';
const data = { cityId, stopId, updatedAt: new Date(now()).toISOString(), observedAt: instant(current.time), temperature: current.temperature_2m,
apparentTemperature: current.apparent_temperature, code: current.weather_code, isDay: current.is_day === 1, windSpeed: current.wind_speed_10m,
hourly: hourly.time.map((time, index) => ({ at: instant(time), temperature: hourly.temperature_2m?.[index], code: hourly.weather_code?.[index], precipitationProbability: hourly.precipitation_probability?.[index] })) };
if (!isWeather(data, cityId, stopId)) throw new ApiError();
cache.set(key, { data, expires: now() + 600000 }); failures.delete(key);
return data;
} catch { failures.set(key, now() + 60000); throw new ApiError(502, 'weather_unavailable'); }
finally { pending.delete(key); }
})();
pending.set(key, request);
return request;
},
};
}