PageSourceSearch

https://www.evboost.com.ua/components/autocomplete-client.mjs?v=20260909-user-location1

js evboost.com.ua collected 2026-09-24 14:06:39 UTC 10,636 bytes, 351 lines download raw bytes

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.