1/* 2 * The one place that decides how /api/autocomplete is called. 3 * 4 * The map search, the planner point inputs and the reachability origin field 5 * used to carry three different policies (700/300/250 ms debounce, 3/3/2 6 * character floors, only one of them sending a session id or caching). Every 7 * one of those requests is billed by the geocoding provider, so the policy is 8 * now shared: change it here and all three change together. 9 * 10 * The numbers below are the ones the public map was already tuned to â the 11 * busiest, fully anonymous page. Adopting them elsewhere lowers the request 12 * count rather than raising it. 13 */ 14export const AUTOCOMPLETE_POLICY = Object.freeze({ 15 /** Below this, a query is never worth a provider request. */ 16 minLength: 3, 17 /** Quiet period after the last keystroke. */ 18 debounceMs: 700, 19 /** Floor between two provider requests from the same field. */ 20 minRequestIntervalMs: 1400, 21 /** A spinner sooner than this reads as flicker. */ 22 loaderDelayMs: 200, 23 /** A pause this long ends the provider's search session. */ 24 sessionIdleMs: 30000, 25 /** How long an answer stays reusable without asking again. */ 26 cacheTtlMs: 60000, 27}); 28 29/** Shared across every field on the page: the same query is bought once. */ 30const responseCache = new Map(); 31const inFlight = new Map(); 32 33export function normalizeQuery(value) { 34 return String(value || '').trim().replace(/\s+/g, ' ').toLowerCase(); 35} 36 37export function createSessionId() { 38 if (globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function') { 39 return globalThis.crypto.randomUUID().replace(/-/g, ''); 40 } 41 42 return Date.now().toString(36) 43 + Math.random().toString(36).slice(2) 44 + Math.random().toString(36).slice(2); 45} 46 47function cacheKey(sessionId, country, query) { 48 return `${sessionId}|${country}|${normalizeQuery(query)}`; 49} 50 51function pruneCache() { 52 const now = Date.now(); 53 54 responseCache.forEach((entry, key) => { 55 if (!entry || entry.expiresAt <= now) { 56 responseCache.delete(key); 57 } 58 }); 59} 60 61function readCache(sessionId, country, query) { 62 const entry = responseCache.get(cacheKey(sessionId, country, query)); 63 64 if (!entry) return null; 65 66 if (entry.expiresAt <= Date.now()) { 67 responseCache.delete(cacheKey(sessionId, country, query)); 68 return null; 69 } 70 71 return entry.data; 72} 73 74function writeCache(sessionId, country, query, data) { 75 if (!sessionId) return; 76 77 pruneCache(); 78 79 responseCache.set(cacheKey(sessionId, country, query), { 80 data, 81 expiresAt: Date.now() + AUTOCOMPLETE_POLICY.cacheTtlMs, 82 }); 83} 84 85/** For tests, and for a page that wants a clean slate. */ 86export function clearAutocompleteCache() { 87 responseCache.clear(); 88 inFlight.clear(); 89} 90 91function releaseInFlight(state) { 92 const key = state.inFlightKey; 93 if (!key) return; 94 95 state.inFlightKey = null; 96 97 const entry = inFlight.get(key); 98 if (!entry) return; 99 100 entry.consumers.delete(state); 101 102 // Nobody is waiting for this answer any more: stop paying for it. 103 if (entry.consumers.size === 0) { 104 entry.controller.abort(); 105 inFlight.delete(key); 106 } 107} 108 109/** 110 * One request per (country, query) in flight, however many fields ask. 111 */ 112export function fetchAutocomplete(query, sessionId, country, state = {}) { 113 const cached = readCache(sessionId, country, query); 114 if (cached) { 115 return Promise.resolve(cached); 116 } 117 118 const key = `${country}|${normalizeQuery(query)}`; 119 const existing = inFlight.get(key); 120 121 if (existing) { 122 existing.consumers.add(state); 123 state.inFlightKey = key; 124 125 return existing.promise; 126 } 127 128 const controller = new AbortController(); 129 const url = new URL('/api/autocomplete', globalThis.location.origin); 130 131 url.searchParams.set('q', query); 132 url.searchParams.set('sid', sessionId); 133 url.searchParams.set('source', 'web'); 134 url.searchParams.set('country', country); 135 136 const promise = fetch(url.toString(), { 137 credentials: 'same-origin', 138 signal: controller.signal, 139 headers: {Accept: 'application/json'}, 140 }) 141 .then(response => { 142 if (!response.ok) { 143 throw new Error('Autocomplete API error: ' + response.status); 144 } 145 146 return response.json(); 147 }) 148 .then(data => { 149 const payload = data && typeof data === 'object' ? data : {}; 150 const responseSessionId = payload?.analytics?.search_session_id || sessionId; 151 152 payload.analytics = { 153 ...(payload.analytics || {}), 154 search_session_id: responseSessionId, 155 provider: payload?.analytics?.provider || 'mapbox', 156 }; 157 158 writeCache(responseSessionId, country, query, payload); 159 160 return payload; 161 }) 162 .finally(() => { 163 const current = inFlight.get(key); 164 165 if (current && current.promise === promise) {
166 current.consumers.forEach(consumer => { 167 if (consumer.inFlightKey === key) { 168 consumer.inFlightKey = null; 169 } 170 }); 171 172 inFlight.delete(key); 173 } 174 }); 175 176 inFlight.set(key, {promise, controller, consumers: new Set([state])}); 177 state.inFlightKey = key; 178 179 return promise; 180} 181 182/** 183 * A debounced, throttled, cached autocomplete source for a single field. 184 * 185 * The caller owns the markup and the keyboard; this owns when â and whether â 186 * the provider is asked. 187 * 188 * @param {object} options 189 * @param {Function} options.getCountry Two-letter code for the request. 190 * @param {Function} options.onTooShort Query below the floor; close the list. 191 * @param {Function} options.onLoading Request is late enough to show a spinner. 192 * @param {Function} options.onResults Answer for a query that is still current. 193 * @param {Function} options.onError Anything but an abort. 194 */ 195export function createAutocomplete(options = {}) { 196 const { 197 getCountry = () => 'UA', 198 onTooShort = () => {}, 199 onLoading = () => {}, 200 onResults = () => {}, 201 onError = () => {}, 202 } = options; 203 204 const state = { 205 timeout: null, 206 loaderTimeout: null, 207 requestSeq: 0, 208 sessionId: null, 209 latestQuery: '', 210 cachedQuery: '', 211 cachedData: null, 212 lastInputAt: 0, 213 lastRequestAt: 0, 214 inFlightKey: null, 215 }; 216 217 const stopTimers = () => { 218 clearTimeout(state.timeout); 219 clearTimeout(state.loaderTimeout); 220 }; 221 222 /** A pause longer than the idle window starts a new billing session. */ 223 const ensureSession = () => { 224 const now = Date.now(); 225 const idle = state.lastInputAt > 0 226 && now - state.lastInputAt > AUTOCOMPLETE_POLICY.sessionIdleMs; 227 228 if (!state.sessionId || idle) { 229 state.sessionId = createSessionId(); 230 state.lastRequestAt = 0; 231 } 232 233 state.lastInputAt = now; 234 235 return state.sessionId; 236 }; 237 238 const isStale = seq => seq !== state.requestSeq; 239 240 return { 241 policy: AUTOCOMPLETE_POLICY, 242 243 /** The answer already on hand for this query, or null. */ 244 cached(text) { 245 const query = String(text || '').trim(); 246 247 return query && query === state.cachedQuery ? state.cachedData : null; 248 }, 249 250 /** Drops pending work; keeps the session so typing on resumes it. */ 251 cancel() { 252 stopTimers(); 253 releaseInFlight(state); 254 state.requestSeq += 1; 255 }, 256 257 /** Field cleared or abandoned: forget the session and the answer too. */ 258 reset() { 259 this.cancel(); 260 state.sessionId = null; 261 state.latestQuery = ''; 262 state.cachedQuery = ''; 263 state.cachedData = null; 264 state.lastInputAt = 0; 265 state.lastRequestAt = 0; 266 }, 267 268 /** Feed every keystroke here. */ 269 query(text) { 270 const query = String(text || '').trim(); 271 272 stopTimers(); 273 releaseInFlight(state); 274 275 state.latestQuery = query; 276 277 if (query.length < AUTOCOMPLETE_POLICY.minLength) { 278 state.requestSeq += 1; 279 state.cachedQuery = ''; 280 state.cachedData = null; 281 282 if (query.length === 0) { 283 this.reset(); 284 } 285 286 onTooShort(query); 287 288 return; 289 } 290 291 if (query === state.cachedQuery && state.cachedData) { 292 onResults(state.cachedData, query); 293 294 return; 295 } 296 297 const sessionId = ensureSession(); 298 const seq = ++state.requestSeq; 299 const sinceLast = state.lastRequestAt > 0 300 ? Date.now() - state.lastRequestAt 301 : Number.POSITIVE_INFINITY; 302 const delay = Math.max( 303 AUTOCOMPLETE_POLICY.debounceMs, 304 AUTOCOMPLETE_POLICY.minRequestIntervalMs - sinceLast, 305 ); 306 307 state.loaderTimeout = setTimeout(() => { 308 if (isStale(seq) || state.latestQuery !== query) return; 309 310 onLoading(query); 311 }, AUTOCOMPLETE_POLICY.loaderDelayMs); 312 313 state.timeout = setTimeout(async () => { 314 if (isStale(seq) || state.latestQuery !== query) return; 315 316 clearTimeout(state.loaderTimeout); 317 onLoading(query); 318 319 state.lastRequestAt = Date.now(); 320 321 try { 322 const data = await fetchAutocomplete(query, sessionId, getCountry(), state); 323 324 if (isStale(seq) || state.latestQuery !== query) return; 325 326 state.sessionId = data?.analytics?.search_session_id || sessionId; 327 state.cachedQuery = query; 328 state.cachedData = data; 329 330 onResults(data, query); 331 } catch (error) { 332 if (error?.name === 'AbortError') return; 333 if (isStale(seq) || state.latestQuery !== query) return; 334 335 onError(error, query); 336 } 337 }, delay); 338 }, 339 }; 340} 341 342if (typeof window !== 'undefined') { 343 window.EVBAutocomplete = { 344 AUTOCOMPLETE_POLICY, 345 createAutocomplete, 346 fetchAutocomplete, 347 clearAutocompleteCache, 348 createSessionId, 349 normalizeQuery, 350 }; 351}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.