PageSourceSearch

https://www.sae.edu/aut/wp-content/plugins/sae-algolia-bot-guard/js/guard.js?ver=1.0.5

js sae.edu collected 2026-09-24 08:29:16 UTC 25,072 bytes, 1,042 lines download raw bytes

1/**
2 * SAE Algolia Bot Guard - frontend guard.
3 *
4 * Runs in the document head, before the theme bundle and before any polyfill,
5 * therefore plain ES5 without Promise or fetch.
6 *
7 * Two jobs:
8 *  1. Debounce the search modal input and drop queries below the minimum length.
9 *  2. Gate every outgoing Algolia request: inject a short lived key, enforce a
10 *     session rate limit, deduplicate identical queries and, when the server
11 *     quota is exhausted, escalate to a Turnstile challenge.
12 *
13 * Blocked requests are rewritten to a local endpoint that answers with an empty
14 * but well formed Algolia response, so the UI degrades to "no results" instead
15 * of hanging or throwing.
16 */
17(function (window, document) {
18	'use strict';
19
20	var cfg = window.saeAlgoliaGuard;
21
22	if (!cfg || !cfg.tokenUrl || !window.XMLHttpRequest) {
23		return;
24	}
25
26	var MIN_LENGTH = int(cfg.minLength, 3);
27	var DEBOUNCE_MS = int(cfg.debounceMs, 350);
28	var SESSION_LIMIT = int(cfg.sessionLimit, 40);
29	var SESSION_WINDOW = int(cfg.sessionWindow, 600) * 1000;
30	var DEDUPE_MS = int(cfg.dedupeMs, 1500);
31	var STORAGE_KEY = 'saeAlgoliaGuardHits';
32	var DOWN_KEY = 'saeAlgoliaGuardDown';
33	var GRACE_BUDGET = 5;
34	var EMPTY_MODE = cfg.emptyQueryMode || 'cache';
35	var TEXT_MODE = cfg.textQueryMode || 'direct';
36
37	function int(value, fallback) {
38		var parsed = parseInt(value, 10);
39
40		return isNaN(parsed) ? fallback : parsed;
41	}
42
43	function log() {
44		if (cfg.debug && window.console && window.console.log) {
45			window.console.log.apply(window.console, ['[algolia-guard]'].concat([].slice.call(arguments)));
46		}
47	}
48
49	/* ------------------------------------------------------------------ */
50	/* 1. Debounce and minimum length for the search modal                 */
51	/* ------------------------------------------------------------------ */
52
53	function matches(element, selector) {
54		if (!element || element.nodeType !== 1) {
55			return false;
56		}
57
58		var fn = element.matches || element.msMatchesSelector || element.webkitMatchesSelector;
59
60		return fn ? fn.call(element, selector) : false;
61	}
62
63	function forwardInput(input) {
64		var event;
65
66		try {
67			event = new Event('input', { bubbles: true, cancelable: false });
68		} catch (e) {
69			event = document.createEvent('Event');
70			event.initEvent('input', true, false);
71		}
72
73		event.saeGuardPass = true;
74		input.dispatchEvent(event);
75	}
76
77	function installInputGuard() {
78		if (!cfg.inputSelector || (DEBOUNCE_MS <= 0 && MIN_LENGTH <= 0)) {
79			return;
80		}
81
82		// A capture listener on an ancestor always runs before the listeners
83		// bound to the input itself, which is how the theme's handler gets
84		// suppressed without touching the compiled bundle.
85		document.addEventListener(
86			'input',
87			function (event) {
88				var input = event.target;
89
90				if (event.saeGuardPass || !matches(input, cfg.inputSelector)) {
91					return;
92				}
93
94				event.stopPropagation();
95
96				var value = input.value || '';
97
98				if (input.saeGuardTimer) {
99					window.clearTimeout(input.saeGuardTimer);
100					input.saeGuardTimer = null;
101				}
102
103				if (value.length === 0) {
104					// Clearing the field must reset the results immediately.
105					forwardInput(input);
106
107					return;
108				}
109
110				if (value.length < MIN_LENGTH) {
111					return;
112				}
113
114				input.saeGuardTimer = window.setTimeout(function () {
115					input.saeGuardTimer = null;
116					forwardInput(input);
117				}, DEBOUNCE_MS);
118			},
119			true
120		);
121	}
122
123	/* ------------------------------------------------------------------ */
124	/* 2. Session rate limit                                               */
125	/* ------------------------------------------------------------------ */
126
127	var hits = [];
128
129	function loadHits() {
130		try {
131			var raw = window.sessionStorage.getItem(STORAGE_KEY);
132			var parsed = raw ? JSON.parse(raw) : [];
133
134			if (Object.prototype.toString.call(parsed) === '[object Array]') {
135				hits = parsed;
136			}
137		} catch (e) {
138			hits = [];
139		}
140	}
141
142	function persistHits() {
143		try {
144			window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(hits));
145		} catch (e) {
146			/* storage disabled, the in memory list still applies */
147		}
148	}
149
150	function pruneHits(now) {
151		var kept = [];
152
153		for (var i = 0; i < hits.length; i++) {
154			if (now - hits[i] < SESSION_WINDOW) {
155				kept.push(hits[i]);
156			}
157		}
158
159		hits = kept;
160	}
161
162	function sessionLimitReached() {
163		var now = new Date().getTime();
164
165		pruneHits(now);
166
167		return hits.length >= SESSION_LIMIT;
168	}
169
170	function countHit() {
171		hits.push(new Date().getTime());
172		persistHits();
173	}
174
175	/* ------------------------------------------------------------------ */
176	/* 3. Token handling                                                   */
177	/* ------------------------------------------------------------------ */
178
179	var OriginalXhr = window.XMLHttpRequest;
180	var proto = OriginalXhr.prototype;
181	var origOpen = proto.open;
182	var origSend = proto.send;
183	var origSetHeader = proto.setRequestHeader;
184	var origAbort = proto.abort;
185
186	var token = {
187		key: null,
188		budget: 0,
189		expires: 0,
190		fetching: false,
191		blocked: false,
192		waiters: []
193	};
194
195	function fallbackKey() {
196		var key = window.algolia && window.algolia.search_api_key;
197
198		// The placeholder handed to crawlers is not a usable key.
199		if (!key || key === 'sae-guard-cache-only') {
200			return null;
201		}
202
203		return key;
204	}
205
206	function tokenUsable() {
207		return token.key && token.budget > 0 && (!token.expires || new Date().getTime() / 1000 < token.expires);
208	}
209
210	function requestToken(cfToken, callback) {
211		var xhr = new OriginalXhr();
212		var url = cfg.tokenUrl + (cfg.tokenUrl.indexOf('?') === -1 ? '?' : '&') + '_=' + new Date().getTime();
213
214		xhr.open('POST', url, true);
215		xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
216		xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
217
218		xhr.onreadystatechange = function () {
219			if (xhr.readyState !== 4) {
220				return;
221			}
222
223			var payload = null;
224
225			try {
226				payload = JSON.parse(xhr.responseText);
227			} catch (e) {
228				payload = null;
229			}
230
231			callback(xhr.status, payload);
232		};
233
234		try {
235			xhr.send(cfToken ? 'cf_token=' + encodeURIComponent(cfToken) : null);
236		} catch (e) {
237			callback(0, null);
238		}
239	}
240
241	function flushWaiters(key) {
242		var waiters = token.waiters;
243		token.waiters = [];
244
245		for (var i = 0; i < waiters.length; i++) {
246			waiters[i](key);
247		}
248	}
249
250	function ensureKey(callback) {
251		if (tokenUsable()) {
252			callback(token.key);
253
254			return;
255		}
256
257		if (token.blocked) {
258			callback(null);
259
260			return;
261		}
262
263		token.waiters.push(callback);
264
265		if (token.fetching) {
266			return;
267		}
268
269		token.fetching = true;
270
271		requestToken(null, function (status, payload) {
272			token.fetching = false;
273			handleTokenResponse(status, payload);
274		});
275	}
276
277	function handleTokenResponse(status, payload) {
278		if (status === 200 && payload && payload.key) {
279			token.key = payload.key;
280			token.budget = int(payload.budget, 1);
281			token.expires = int(payload.expires, 0);
282			token.blocked = false;
283
284			log('token granted, budget', token.budget);
285			flushWaiters(token.key);
286
287			return;
288		}
289
290		if (payload && payload.challenge_required && cfg.turnstileKey) {
291			token.blocked = true;
292			log('quota exceeded, showing challenge');
293			showChallenge();
294			flushWaiters(null);
295
296			return;
297		}
298
299		if (status === 429 || status === 403) {
300			token.blocked = true;
301			log('blocked by server, status', status);
302			flushWaiters(null);
303
304			return;
305		}
306
307		// The endpoint is unreachable. Degrade to the key that is already in the
308		// page so the site keeps working, but only for a handful of queries.
309		if (status === 0 || status === 401 || status === 404 || status >= 500) {
310			markRestDown('token', status);
311		}
312
313		var fallback = fallbackKey();
314
315		if (fallback) {
316			token.key = fallback;
317			token.budget = GRACE_BUDGET;
318			token.expires = 0;
319			log('token endpoint failed, using page key with grace budget');
320		}
321
322		flushWaiters(token.key);
323	}
324
325	/* ------------------------------------------------------------------ */
326	/* 4. Turnstile escalation                                             */
327	/* ------------------------------------------------------------------ */
328
329	var challengeVisible = false;
330
331	function showChallenge() {
332		if (challengeVisible || !cfg.turnstileKey) {
333			return;
334		}
335
336		challengeVisible = true;
337
338		var overlay = document.createElement('div');
339		overlay.setAttribute('id', 'sae-algolia-guard-challenge');
340		overlay.setAttribute(
341			'style',
342			'position:fixed;z-index:2147483000;left:50%;bottom:24px;transform:translateX(-50%);' +
343				'background:#fff;color:#111;padding:16px 20px;border-radius:8px;' +
344				'box-shadow:0 8px 30px rgba(0,0,0,.35);font:14px/1.4 sans-serif;max-width:90vw;'
345		);
346
347		var text = document.createElement('p');
348		text.setAttribute('style', 'margin:0 0 12px;');
349		text.appendChild(document.createTextNode(cfg.challengeText || 'Please confirm that you are not a robot.'));
350
351		var widget = document.createElement('div');
352		widget.setAttribute('id', 'sae-algolia-guard-widget');
353
354		overlay.appendChild(text);
355		overlay.appendChild(widget);
356		(document.body || document.documentElement).appendChild(overlay);
357
358		window.saeAlgoliaGuardSolved = function (cfToken) {
359			requestToken(cfToken, function (status, payload) {
360				if (status === 200 && payload && payload.key) {
361					removeChallenge();
362					token.blocked = false;
363					handleTokenResponse(status, payload);
364				}
365			});
366		};
367
368		loadTurnstile(function () {
369			if (window.turnstile && window.turnstile.render) {
370				window.turnstile.render('#sae-algolia-guard-widget', {
371					sitekey: cfg.turnstileKey,
372					callback: window.saeAlgoliaGuardSolved
373				});
374			}
375		});
376	}
377
378	function removeChallenge() {
379		var overlay = document.getElementById('sae-algolia-guard-challenge');
380
381		if (overlay && overlay.parentNode) {
382			overlay.parentNode.removeChild(overlay);
383		}
384
385		challengeVisible = false;
386	}
387
388	function loadTurnstile(onReady) {
389		if (window.turnstile) {
390			onReady();
391
392			return;
393		}
394
395		var script = document.createElement('script');
396		script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
397		script.async = true;
398		script.defer = true;
399		script.onload = onReady;
400		(document.head || document.documentElement).appendChild(script);
401	}
402
403	/* ------------------------------------------------------------------ */
404	/* 5. Network gate                                                     */
405	/* ------------------------------------------------------------------ */
406
407	var recent = {};
408
409	/**
410	 * Whether our own endpoints answer at all.
411	 *
412	 * Every gate in here routes through the REST API, so a WAF, a security
413	 * plugin or a locked down /wp-json turns each of them into an error the
414	 * Algolia client cannot read, and the page ends up without results. When
415	 * that happens the guard steps aside for the rest of the session: requests
416	 * go straight to Algolia with the key in the page, which is the behaviour
417	 * from before the plugin, and the site keeps working.
418	 */
419	var restDown = false;
420
421	try {
422		restDown = window.sessionStorage.getItem(DOWN_KEY) === '1';
423	} catch (e) {
424		/* storage disabled */
425	}
426
427	function looksDown(status) {
428		return status === 0 || status === 401 || status === 403 || status === 404 || status >= 500;
429	}
430
431	function markRestDown(where, status) {
432		if (restDown) {
433			return;
434		}
435
436		restDown = true;
437		log('endpoint', where, 'answered', status, '- standing down for this session');
438
439		try {
440			window.sessionStorage.setItem(DOWN_KEY, '1');
441		} catch (e) {
442			/* storage disabled, the in memory flag still applies */
443		}
444	}
445
446	/**
447	 * Watch one of our own requests and stand down when it fails.
448	 *
449	 * readystatechange fires before the load event the Algolia client listens
450	 * on, but the response of this very request can no longer be changed, so
451	 * only the requests after it are spared.
452	 */
453	function watchGuardRoute(xhr, where) {
454		if (!xhr.addEventListener) {
455			return;
456		}
457
458		xhr.addEventListener('readystatechange', function () {
459			if (xhr.readyState !== 4 || xhr.saeAborted) {
460				return;
461			}
462
463			if (looksDown(xhr.status)) {
464				markRestDown(where, xhr.status);
465			}
466		});
467	}
468
469	function isAlgoliaUrl(url) {
470		if (typeof url !== 'string') {
471			return false;
472		}
473
474		return /^https?:\/\/[^/]*(algolia\.net|algolianet\.com)(\/|:|$)/i.test(url);
475	}
476
477	function replaceKeyInUrl(url, key) {
478		if (url.indexOf('x-algolia-api-key=') === -1) {
479			return url;
480		}
481
482		return url.replace(/([?&])x-algolia-api-key=[^&]*/i, '$1x-algolia-api-key=' + encodeURIComponent(key));
483	}
484
485	function parseBody(body) {
486		if (typeof body !== 'string' || !body) {
487			return null;
488		}
489
490		try {
491			return JSON.parse(body);
492		} catch (e) {
493			return null;
494		}
495	}
496
497	/**
498	 * The shortest query text the request asks for, or null when unknown.
499	 */
500	function queryTextOf(parsed) {
501		if (!parsed) {
502			return null;
503		}
504
505		if (typeof parsed.query === 'string') {
506			return parsed.query;
507		}
508
509		if (parsed.requests && parsed.requests.length) {
510			for (var i = 0; i < parsed.requests.length; i++) {
511				var request = parsed.requests[i];
512
513				if (request && typeof request.query === 'string' && request.query.length) {
514					return request.query;
515				}
516			}
517
518			return '';
519		}
520
521		return null;
522	}
523
524	/**
525	 * Shape the empty response has to imitate for this endpoint.
526	 */
527	function emptyShapeFor(url, parsed) {
528		if (/\/facets\//i.test(url)) {
529			return { shape: 'facets', n: 1 };
530		}
531
532		if (/\/\*\/queries/i.test(url) || (parsed && parsed.requests)) {
533			return { shape: 'multi', n: parsed && parsed.requests ? parsed.requests.length : 1 };
534		}
535
536		return { shape: 'search', n: 1 };
537	}
538
539	function emptyUrlFor(shape) {
540		var separator = cfg.emptyUrl.indexOf('?') === -1 ? '?' : '&';
541
542		return cfg.emptyUrl + separator + 'shape=' + shape.shape + '&n=' + shape.n;
543	}
544
545	/**
546	 * Whether the request asks for the unfiltered default result set.
547	 *
548	 * The filter pages fire exactly this on every page load, for every visitor,
549	 * which is what makes it both the most expensive and the most cacheable
550	 * request on the site.
551	 */
552	function isEmptyQuery(parsed) {
553		if (!parsed) {
554			return false;
555		}
556
557		if (parsed.requests && parsed.requests.length) {
558			for (var i = 0; i < parsed.requests.length; i++) {
559				if (queryOf(parsed.requests[i]).length > 0) {
560					return false;
561				}
562			}
563
564			return true;
565		}
566
567		return typeof parsed.query === 'string' && parsed.query.length === 0;
568	}
569
570	/**
571	 * Search text of a request, also when it hides in a serialized params string.
572	 */
573	function queryOf(request) {
574		if (!request) {
575			return '';
576		}
577
578		if (typeof request.query === 'string') {
579			return request.query;
580		}
581
582		if (typeof request.params === 'string') {
583			var match = /(^|&)query=([^&]*)/.exec(request.params);
584
585			if (match) {
586				return decodeURIComponent(match[2].replace(/\+/g, ' '));
587			}
588		}
589
590		return '';
591	}
592
593	/**
594	 * Whether the answer to an empty query is actually used by the page.
595	 *
596	 * A filter page restricts by facets or asks for facet counts. Clearing the
597	 * modal input sends a bare empty query whose result the theme throws away.
598	 */
599	function hasFilters(parsed) {
600		if (!parsed) {
601			return false;
602		}
603
604		if (parsed.requests && parsed.requests.length) {
605			for (var i = 0; i < parsed.requests.length; i++) {
606				if (hasFilters(expandParams(parsed.requests[i]))) {
607					return true;
608				}
609			}
610
611			return false;
612		}
613
614		var names = ['facetFilters', 'numericFilters', 'filters', 'tagFilters', 'facets'];
615
616		for (var n = 0; n < names.length; n++) {
617			var value = parsed[names[n]];
618
619			if (!value) {
620				continue;
621			}
622
623			if (typeof value === 'string' && value.length) {
624				return true;
625			}
626
627			if (value.length) {
628				return true;
629			}
630		}
631
632		return false;
633	}
634
635	/**
636	 * Merge a serialized params string back into the request object.
637	 */
638	function expandParams(request) {
639		if (!request || typeof request.params !== 'string') {
640			return request;
641		}
642
643		var merged = {};
644		var name;
645
646		for (name in request) {
647			if (Object.prototype.hasOwnProperty.call(request, name) && name !== 'params') {
648				merged[name] = request[name];
649			}
650		}
651
652		var pairs = request.params.split('&');
653
654		for (var i = 0; i < pairs.length; i++) {
655			var pair = pairs[i].split('=');
656
657			if (!pair[0]) {
658				continue;
659			}
660
661			var key = decodeURIComponent(pair[0]);
662			var raw = decodeURIComponent((pair[1] || '').replace(/\+/g, ' '));
663
664			if (merged[key] === undefined) {
665				merged[key] = raw;
666			}
667		}
668
669		return merged;
670	}
671
672	/**
673	 * Index name taken from the Algolia request path.
674	 */
675	function indexFromUrl(url) {
676		var match = /\/1\/indexes\/([^/?]+)\//i.exec(String(url || ''));
677
678		if (!match) {
679			return '';
680		}
681
682		try {
683			return decodeURIComponent(match[1]);
684		} catch (e) {
685			return match[1];
686		}
687	}
688
689	/**
690	 * Whether empty query handling applies to this endpoint at all. Facet value
691	 * searches carry their own query text and are gated normally.
692	 */
693	function handlesEmpty(shape) {
694		if (restDown || EMPTY_MODE === 'passthrough' || shape.shape === 'facets') {
695			return false;
696		}
697
698		return EMPTY_MODE === 'block' || !!cfg.cacheUrl;
699	}
700
701	/**
702	 * Whether a typed search may be answered from the server side cache.
703	 *
704	 * Facet value lookups keep their own path: they carry text as well, but the
705	 * cache route only speaks the ordinary query endpoints.
706	 */
707	function handlesText(shape, queryText) {
708		if (restDown || TEXT_MODE !== 'cache' || shape.shape === 'facets' || !cfg.cacheUrl) {
709			return false;
710		}
711
712		return queryText !== null && queryText.length > 0;
713	}
714
715	function cachedUrlFor(url, isMulti) {
716		var separator = cfg.cacheUrl.indexOf('?') === -1 ? '?' : '&';
717
718		return (
719			cfg.cacheUrl +
720			separator +
721			'index=' +
722			encodeURIComponent(indexFromUrl(url)) +
723			'&multi=' +
724			(isMulti ? '1' : '0')
725		);
726	}
727
728	/**
729	 * Send an empty query to the server side cache instead of to Algolia.
730	 */
731	function sendCached(xhr, body, isMulti) {
732		if (xhr.saeAborted) {
733			return;
734		}
735
736		try {
737			origOpen.call(xhr, 'POST', cachedUrlFor(xhr.saeUrl, isMulti), true);
738			origSetHeader.call(xhr, 'Content-Type', 'application/json');
739			watchGuardRoute(xhr, 'cached');
740
741			return origSend.call(xhr, body);
742		} catch (e) {
743			log('could not reach the cache route', e);
744		}
745	}
746
747	function signatureOf(url, body) {
748		var path = url.replace(/^https?:\/\/[^/]+/i, '').replace(/[?&]x-algolia-api-key=[^&]*/i, '');
749
750		return path + '|' + (typeof body === 'string' ? body : '');
751	}
752
753	function isDuplicate(signature) {
754		var now = new Date().getTime();
755
756		for (var key in recent) {
757			if (Object.prototype.hasOwnProperty.call(recent, key) && now - recent[key] > DEDUPE_MS) {
758				delete recent[key];
759			}
760		}
761
762		if (recent[signature] && now - recent[signature] < DEDUPE_MS) {
763			return true;
764		}
765
766		recent[signature] = now;
767
768		return false;
769	}
770
771	proto.open = function (method, url) {
772		this.saeAlgolia = isAlgoliaUrl(url);
773		this.saeAborted = false;
774
775		if (this.saeAlgolia) {
776			this.saeMethod = method;
777			this.saeUrl = url;
778			this.saeHeaders = [];
779		}
780
781		return origOpen.apply(this, arguments);
782	};
783
784	// A request the client gave up on must not be revived by a deferred send.
785	proto.abort = function () {
786		this.saeAborted = true;
787
788		return origAbort.apply(this, arguments);
789	};
790
791	proto.setRequestHeader = function (name, value) {
792		if (this.saeAlgolia && this.saeHeaders) {
793			this.saeHeaders.push([name, value]);
794		}
795
796		return origSetHeader.apply(this, arguments);
797	};
798
799	proto.send = function (body) {
800		var xhr = this;
801
802		if (!xhr.saeAlgolia || restDown) {
803			return origSend.apply(xhr, arguments);
804		}
805
806		var parsed = parseBody(body);
807		var queryText = queryTextOf(parsed);
808		var shape = emptyShapeFor(xhr.saeUrl, parsed);
809
810		if (handlesEmpty(shape) && isEmptyQuery(parsed)) {
811			// Identical requests still collapse into one, but an empty query
812			// neither needs a token nor counts against the session limit.
813			if (isDuplicate(signatureOf(xhr.saeUrl, body))) {
814				log('dropped, duplicate empty query');
815
816				return sendEmpty(xhr, shape);
817			}
818
819			if (EMPTY_MODE === 'block' || !hasFilters(parsed)) {
820				log('empty query answered locally');
821
822				return sendEmpty(xhr, shape);
823			}
824
825			log('empty query routed to the cache');
826
827			return sendCached(xhr, body, shape.shape === 'multi');
828		}
829
830		if (queryText !== null && queryText.length > 0 && queryText.length < MIN_LENGTH) {
831			log('dropped, query too short:', queryText);
832
833			return sendEmpty(xhr, shape);
834		}
835
836		if (isDuplicate(signatureOf(xhr.saeUrl, body))) {
837			log('dropped, duplicate request');
838
839			return sendEmpty(xhr, shape);
840		}
841
842		if (sessionLimitReached()) {
843			log('dropped, session limit reached');
844			showChallenge();
845
846			return sendEmpty(xhr, shape);
847		}
848
849		// A cached term costs nothing at Algolia, but it still counts against
850		// the session limit, because the limit is there to slow down scraping.
851		if (handlesText(shape, queryText)) {
852			countHit();
853			log('text query routed to the cache');
854
855			return sendCached(xhr, body, shape.shape === 'multi');
856		}
857
858		ensureKey(function (key) {
859			if (xhr.saeAborted) {
860				return;
861			}
862
863			if (!key) {
864				sendEmpty(xhr, shape);
865
866				return;
867			}
868
869			token.budget--;
870			countHit();
871
872			var url = replaceKeyInUrl(xhr.saeUrl, key);
873
874			try {
875				origOpen.call(xhr, xhr.saeMethod, url, true);
876				replayHeaders(xhr, url === xhr.saeUrl ? key : null);
877				origSend.call(xhr, body);
878			} catch (e) {
879				log('could not re-issue request', e);
880			}
881		});
882	};
883
884	/**
885	 * Re-apply the headers the Algolia client set before we reopened the request.
886	 *
887	 * @param {XMLHttpRequest} xhr        The request.
888	 * @param {string|null}    headerKey  Key to inject when it does not travel in the query string.
889	 */
890	function replayHeaders(xhr, headerKey) {
891		var headers = xhr.saeHeaders || [];
892		var seenKeyHeader = false;
893
894		for (var i = 0; i < headers.length; i++) {
895			var name = headers[i][0];
896			var value = headers[i][1];
897
898			if (headerKey && String(name).toLowerCase() === 'x-algolia-api-key') {
899				value = headerKey;
900				seenKeyHeader = true;
901			}
902
903			try {
904				origSetHeader.call(xhr, name, value);
905			} catch (e) {
906				/* forbidden header, ignore */
907			}
908		}
909
910		if (headerKey && !seenKeyHeader) {
911			try {
912				origSetHeader.call(xhr, 'x-algolia-api-key', headerKey);
913			} catch (e) {
914				/* ignore */
915			}
916		}
917	}
918
919	/**
920	 * Answer a blocked request from the local endpoint.
921	 */
922	function sendEmpty(xhr, shape) {
923		if (xhr.saeAborted) {
924			return;
925		}
926
927		try {
928			origOpen.call(xhr, 'GET', emptyUrlFor(shape), true);
929			watchGuardRoute(xhr, 'empty');
930
931			return origSend.call(xhr, null);
932		} catch (e) {
933			log('could not serve empty response', e);
934		}
935	}
936
937	/* ------------------------------------------------------------------ */
938	/* 6. fetch, in case a future client stops using XHR                   */
939	/* ------------------------------------------------------------------ */
940
941	function installFetchGuard() {
942		if (!window.fetch) {
943			return;
944		}
945
946		var origFetch = window.fetch;
947
948		function watched(promise, where) {
949			return promise.then(
950				function (response) {
951					if (response && looksDown(response.status)) {
952						markRestDown(where, response.status);
953					}
954
955					return response;
956				},
957				function (error) {
958					markRestDown(where, 0);
959
960					throw error;
961				}
962			);
963		}
964
965		window.fetch = function (input, init) {
966			var url = typeof input === 'string' ? input : input && input.url;
967
968			if (!isAlgoliaUrl(url) || restDown) {
969				return origFetch.apply(window, arguments);
970			}
971
972			var body = init && typeof init.body === 'string' ? init.body : null;
973			var parsed = parseBody(body);
974			var shape = emptyShapeFor(url, parsed);
975			var queryText = queryTextOf(parsed);
976
977			if (handlesEmpty(shape) && isEmptyQuery(parsed)) {
978				if (isDuplicate(signatureOf(url, body)) || EMPTY_MODE === 'block' || !hasFilters(parsed)) {
979					return watched(origFetch.call(window, emptyUrlFor(shape)), 'empty');
980				}
981
982				return watched(
983					origFetch.call(window, cachedUrlFor(url, shape.shape === 'multi'), {
984						method: 'POST',
985						headers: { 'Content-Type': 'application/json' },
986						body: body
987					}),
988					'cached'
989				);
990			}
991
992			if (
993				(queryText !== null && queryText.length > 0 && queryText.length < MIN_LENGTH) ||
994				isDuplicate(signatureOf(url, body)) ||
995				sessionLimitReached()
996			) {
997				return watched(origFetch.call(window, emptyUrlFor(shape)), 'empty');
998			}
999
1000			if (handlesText(shape, queryText)) {
1001				countHit();
1002
1003				return watched(
1004					origFetch.call(window, cachedUrlFor(url, shape.shape === 'multi'), {
1005						method: 'POST',
1006						headers: { 'Content-Type': 'application/json' },
1007						body: body
1008					}),
1009					'cached'
1010				);
1011			}
1012
1013			var args = arguments;
1014
1015			return new window.Promise(function (resolve, reject) {
1016				ensureKey(function (key) {
1017					if (!key) {
1018						watched(origFetch.call(window, emptyUrlFor(shape)), 'empty').then(resolve, reject);
1019
1020						return;
1021					}
1022
1023					token.budget--;
1024					countHit();
1025
1026					if (typeof input === 'string') {
1027						args[0] = replaceKeyInUrl(url, key);
1028					}
1029
1030					origFetch.apply(window, args).then(resolve, reject);
1031				});
1032			});
1033		};
1034	}
1035
1036	loadHits();
1037	installInputGuard();
1038
1039	if (window.Promise) {
1040		installFetchGuard();
1041	}
1042})(window, document);

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.