initial commit
This commit is contained in:
149
server/board.mjs
Normal file
149
server/board.mjs
Normal 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
59
server/index.mjs
Normal 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
73
server/timetable.mjs
Normal 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
39
server/weather.mjs
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user