• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

OneBusAway / wayfinder / 23038129192

13 Mar 2026 05:50AM UTC coverage: 80.047%. First build
23038129192

Pull #384

github

web-flow
Merge 3e55693e7 into 2f8481428
Pull Request #384: Release 2026.4

1751 of 1940 branches covered (90.26%)

Branch coverage included in aggregate %.

794 of 836 new or added lines in 24 files covered. (94.98%)

11235 of 14283 relevant lines covered (78.66%)

4.25 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.61
/src/lib/serverCache.js
1
import oba from '$lib/obaSdk.js';
1✔
2
import { calculateBoundsFromAgencies } from '$lib/mathUtils.js';
3
import { getAgencyFilter } from '$lib/agencyFilter.js';
4

5
/**
1✔
6
 * @typedef {Object} Agency
1✔
7
 * @property {string} agencyId
1✔
8
 * @property {string} name
1✔
9
 * @property {number} lat
1✔
10
 * @property {number} lon
1✔
11
 * @property {number} latSpan
1✔
12
 * @property {number} lonSpan
1✔
13
 */
1✔
14

15
/**
1✔
16
 * @typedef {Object} Route
1✔
17
 * @property {string} id
1✔
18
 * @property {string} agencyId
1✔
19
 * @property {string} shortName
1✔
20
 * @property {string} longName
1✔
21
 * @property {Object} [agencyInfo]
1✔
22
 */
1✔
23

24
/**
1✔
25
 * @typedef {Object} Bounds
1✔
26
 * @property {number} north
1✔
27
 * @property {number} south
1✔
28
 * @property {number} east
1✔
29
 * @property {number} west
1✔
30
 */
1✔
31

32
/**
1✔
33
 * @typedef {'uninitialized' | 'loading' | 'loaded' | 'error'} CacheState
1✔
34
 */
1✔
35

36
/** @type {Route[] | null} */
1✔
37
let routesCache = null;
1✔
38

39
/** @type {Agency[] | null} */
1✔
40
let agenciesCache = null;
1✔
41

42
/** @type {Bounds | null} */
1✔
43
let boundsCache = null;
1✔
44

45
/** @type {number | null} */
1✔
46
let cacheTimestamp = null;
1✔
47

48
/** @type {CacheState} */
1✔
49
let cacheState = 'uninitialized';
1✔
50

51
/** @type {Promise<Route[] | null> | null} */
1✔
52
let initializationPromise = null;
1✔
53

54
/** @type {boolean} */
1✔
55
let timedOut = false;
1✔
56

57
// Cache TTL: 1 hour
1✔
58
const CACHE_TTL = 3600000;
1✔
59

60
// Maximum wait for OBA during cold start before unblocking requests
1✔
61
const FETCH_TIMEOUT = 15_000;
1✔
62

63
// After a cold-start timeout, minimum gap before retrying
1✔
64
const ERROR_RETRY_DELAY = 30_000;
1✔
65

66
/** @type {number | null} */
1✔
67
let lastErrorTime = null;
1✔
68

69
/**
1✔
70
 * Races a promise against a timeout. The timeout timer is always cleared
1✔
71
 * regardless of which side wins, preventing timer leaks with fake timers.
1✔
72
 * @template T
1✔
73
 * @param {Promise<T>} promise
1✔
74
 * @param {number} ms
1✔
75
 * @param {string} label
1✔
76
 * @returns {Promise<T>}
1✔
77
 */
1✔
78
function withTimeout(promise, ms, label) {
34✔
79
        let timeoutId;
34✔
80
        const timeoutPromise = new Promise((_, reject) => {
34✔
81
                timeoutId = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
34✔
82
        });
34✔
83
        return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timeoutId));
34✔
84
}
34✔
85

86
/**
1✔
87
 * Fetches routes data from the OBA API
1✔
88
 * @returns {Promise<Route[] | null>}
1✔
89
 */
1✔
90
async function fetchRoutesData() {
36✔
91
        try {
36✔
92
                const agenciesResponse = await oba.agenciesWithCoverage.list();
36✔
93
                const allAgencies = agenciesResponse.data.list;
27✔
94
                const agencyFilter = getAgencyFilter();
27✔
95
                const agencies = agencyFilter
27✔
96
                        ? allAgencies.filter((a) => agencyFilter.has(a.agencyId))
36✔
97
                        : allAgencies;
36✔
98

99
                if (agencyFilter && agencies.length === 0) {
36✔
100
                        console.error(
1✔
101
                                'PRIVATE_OBA_AGENCY_FILTER is configured but matches no available agencies. All data will be empty.',
1✔
102
                                {
1✔
103
                                        configured: [...agencyFilter],
1✔
104
                                        available: allAgencies.map((a) => a.agencyId)
1✔
105
                                }
1✔
106
                        );
1✔
107
                }
1✔
108

109
                agenciesCache = agencies;
27✔
110
                boundsCache = calculateBoundsFromAgencies(agencies);
27✔
111

112
                const routesPromises = agencies.map(async (agency) => {
27✔
113
                        const routesResponse = await oba.routesForAgency.list(agency.agencyId);
44✔
114
                        const routes = routesResponse.data.list;
44✔
115
                        const references = routesResponse.data.references;
44✔
116

117
                        const agencyReferenceMap = new Map(references.agencies.map((agency) => [agency.id, agency]));
44✔
118

119
                        routes.forEach((route) => {
44✔
120
                                route.agencyInfo = agencyReferenceMap.get(route.agencyId);
65✔
121
                        });
44✔
122

123
                        return routes;
44✔
124
                });
27✔
125

126
                const routes = await Promise.all(routesPromises);
27✔
127
                return routes.flat();
27✔
128
        } catch (error) {
36✔
129
                console.error('Error fetching routes:', {
5✔
130
                        error: error.message,
5✔
131
                        stack: error.stack,
5✔
132
                        timestamp: new Date().toISOString()
5✔
133
                });
5✔
134
                return null;
5✔
135
        }
5✔
136
}
36✔
137

138
/**
1✔
139
 * Preloads routes data into cache with TTL support.
1✔
140
 *
1✔
141
 * Stale-while-revalidate: when cached data exists but is past TTL, a background
1✔
142
 * refresh is kicked off and the caller returns immediately so HTTP requests are
1✔
143
 * not blocked. On a cold start (no data at all) the caller waits up to
1✔
144
 * FETCH_TIMEOUT milliseconds before proceeding without data, preventing an
1✔
145
 * indefinitely-hanging handle hook when the OBA server is slow or unavailable.
1✔
146
 *
1✔
147
 * @param {boolean} [forceRefresh=false] - Force refresh even if cache is valid
1✔
148
 * @returns {Promise<void>}
1✔
149
 */
1✔
150
export async function preloadRoutesData(forceRefresh = false) {
1✔
151
        const now = Date.now();
44✔
152
        const isStale = cacheTimestamp !== null && now - cacheTimestamp > CACHE_TTL;
44✔
153

154
        // Fast path: cache is warm
44✔
155
        if (routesCache && !isStale && !forceRefresh) {
44✔
156
                return;
2✔
157
        }
2✔
158

159
        // Error cooldown: after a cold-start timeout don't immediately hammer OBA again.
42✔
160
        // Only applies when there is no cached data to serve.
42✔
161
        if (!routesCache && lastErrorTime !== null && now - lastErrorTime < ERROR_RETRY_DELAY) {
44✔
162
                console.debug('[serverCache] Within error cooldown — serving without cached data');
4✔
163
                return;
4✔
164
        }
4✔
165

166
        // A refresh is already in flight
38✔
167
        if (initializationPromise) {
44✔
168
                // Stale-while-revalidate or already timed out: serve existing data without blocking
2✔
169
                if (timedOut || (routesCache && !forceRefresh)) {
2!
NEW
170
                        return;
×
NEW
171
                }
×
172
                // Cold start or forced refresh: wait for the in-flight fetch
2✔
173
                await initializationPromise;
2✔
174
                return;
2✔
175
        }
2✔
176

177
        // Start a new refresh
36✔
178
        cacheState = 'loading';
36✔
179
        timedOut = false;
36✔
180
        const promise = fetchRoutesData()
36✔
181
                .then((routes) => {
36✔
182
                        routesCache = routes;
32✔
183
                        cacheTimestamp = routes ? Date.now() : null;
32✔
184
                        cacheState = routes ? 'loaded' : 'error';
32✔
185

186
                        if (routes) {
32✔
187
                                lastErrorTime = null;
27✔
188
                        } else {
32✔
189
                                lastErrorTime = Date.now();
5✔
190
                        }
5✔
191

192
                        return routes;
32✔
193
                })
36✔
194
                .catch((error) => {
36✔
195
                        console.error('Error in preloadRoutesData:', error);
×
196
                        cacheState = 'error';
×
197
                        return null;
×
198
                })
36✔
199
                .finally(() => {
36✔
200
                        initializationPromise = null;
32✔
201
                        timedOut = false;
32✔
202
                });
36✔
203

204
        initializationPromise = promise;
36✔
205

206
        // Stale-while-revalidate: background refresh started, return immediately
36✔
207
        if (routesCache && !forceRefresh) {
44✔
208
                return;
2✔
209
        }
2✔
210

211
        // Cold start: wait for initial data, but cap the wait so the handle hook
34✔
212
        // cannot block all requests indefinitely when OBA is slow or unreachable.
34✔
213
        try {
34✔
214
                await withTimeout(promise, FETCH_TIMEOUT, 'OBA routes fetch');
34✔
215
        } catch (err) {
44✔
216
                if (err.message?.includes('timed out')) {
6✔
217
                        console.warn(
6✔
218
                                '[serverCache] Routes fetch timed out — requests will proceed without cached data'
6✔
219
                        );
6✔
220
                } else {
6!
NEW
221
                        console.error('[serverCache] Routes fetch failed during cold start:', err);
×
NEW
222
                }
×
223

224
                cacheState = 'error';
6✔
225
                lastErrorTime = Date.now();
6✔
226
                timedOut = true;
6✔
227
                // Do NOT clear initializationPromise — the in-flight fetch continues in the
6✔
228
                // background. Subsequent callers see timedOut=true and return immediately,
6✔
229
                // preventing a second parallel fetch. When the background fetch eventually
6✔
230
                // resolves it will populate the cache and reset timedOut via .finally().
6✔
231
        }
6✔
232
}
44✔
233

234
/**
1✔
235
 * Gets the cached routes data
1✔
236
 * @returns {Route[] | null}
1✔
237
 */
1✔
238
export function getRoutesCache() {
1✔
239
        return routesCache;
16✔
240
}
16✔
241

242
/**
1✔
243
 * Gets the cached agencies data
1✔
244
 * @returns {Agency[] | null}
1✔
245
 */
1✔
246
export function getAgenciesCache() {
1✔
247
        return agenciesCache;
6✔
248
}
6✔
249

250
/**
1✔
251
 * Gets the cached bounds data
1✔
252
 * @returns {Bounds | null}
1✔
253
 */
1✔
254
export function getBoundsCache() {
1✔
255
        return boundsCache;
6✔
256
}
6✔
257

258
/**
1✔
259
 * Gets the current cache state
1✔
260
 * @returns {CacheState}
1✔
261
 */
1✔
262
export function getCacheState() {
1✔
263
        return cacheState;
13✔
264
}
13✔
265

266
/**
1✔
267
 * Gets the cache timestamp
1✔
268
 * @returns {number | null}
1✔
269
 */
1✔
270
export function getCacheTimestamp() {
1✔
271
        return cacheTimestamp;
5✔
272
}
5✔
273

274
/**
1✔
275
 * Clears all cached data (useful for testing and manual refresh)
1✔
276
 * @returns {void}
1✔
277
 */
1✔
278
export function clearCache() {
1✔
279
        routesCache = null;
38✔
280
        agenciesCache = null;
38✔
281
        boundsCache = null;
38✔
282
        cacheTimestamp = null;
38✔
283
        cacheState = 'uninitialized';
38✔
284
        initializationPromise = null;
38✔
285
        lastErrorTime = null;
38✔
286
        timedOut = false;
38✔
287
}
38✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc