@prefetchru/prefetch 1.0.10 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/prefetch.js CHANGED
@@ -1,592 +1,960 @@
1
- /*!
2
- * prefetch.ru v1.0.10 - Мгновенная загрузка страниц
1
+ /*!
2
+ * prefetch.ru v1.1.1 - Мгновенная загрузка страниц
3
3
  * © 2026 Сергей Макаров | MIT License
4
4
  * https://prefetch.ru | https://github.com/prefetch-ru
5
5
  */
6
- ;(function () {
7
- 'use strict'
8
-
9
- // v1.0.9: guard от двойной инициализации
10
- if (window.Prefetch && window.Prefetch.__prefetchRu) return
11
-
12
- // Состояние
13
- var preloaded = new Set()
14
- var hoverTimers = new WeakMap()
15
-
16
- var lastTouchTime = 0
17
- var touchTimer = 0
18
- var touchCancel = null
19
-
20
- var isMobile = false
21
- var isIOS = false
22
- var chromiumVer = null
23
- var platform = null
24
- var saveData = false
25
- var connType = null
26
-
27
- // CSP / поддержка
28
- var scriptNonce = null
29
- var supportsLinkPrefetch = false
30
-
31
- // Настройки
32
- var hoverDelay = 65
33
- var touchDelay = 80
34
- var maxPreloads = 50
35
- var allowQuery = false
36
- var allowExternal = false
37
- var whitelist = false
38
-
39
- var useSpecRules = false
40
- var specMode = 'none' // 'none' | 'prefetch' | 'prerender'
41
- var prerenderAll = false
42
-
43
- var mousedownMode = false
44
- var viewportMode = false
45
- var observeDom = false
46
-
47
- // Инициализация
48
- ;(function init() {
49
- // CSP nonce (если скрипт подключён с nonce)
50
- try {
51
- var cs = document.currentScript
52
- if (cs && cs.nonce) scriptNonce = cs.nonce
53
- } catch (e) {}
54
-
55
- // rel=prefetch support
56
- try {
57
- var l = document.createElement('link')
58
- if (l.relList && typeof l.relList.supports === 'function') {
59
- supportsLinkPrefetch = l.relList.supports('prefetch')
60
- }
61
- } catch (e) {}
62
-
63
- var ua = navigator.userAgent
64
-
65
- // Определяем устройство
66
- isIOS =
67
- /iPad|iPhone/.test(ua) ||
68
- (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
69
- var isAndroid = /Android/.test(ua)
70
- isMobile = (isIOS || isAndroid) && Math.min(screen.width, screen.height) < 768
71
- if (isMobile) maxPreloads = 20
72
-
73
- // Chromium версия (для внешних ссылок)
74
- var cm = ua.match(/Chrome\/(\d+)/)
75
- if (cm) chromiumVer = parseInt(cm[1], 10)
76
-
77
- // Сеть
78
- var conn = navigator.connection
79
- if (conn) {
80
- connType = conn.effectiveType
81
- saveData = conn.saveData || false
6
+ (function () {
7
+ 'use strict';
8
+
9
+ /**
10
+ * Core prefetch logic - shared between IIFE and ESM builds.
11
+ * @param {Object} options
12
+ * @param {function(): string|null} options.getNonce - Function to get CSP nonce
13
+ * @param {boolean} options.isBrowser - Whether running in browser environment
14
+ * @returns {Object} Prefetch API
15
+ */
16
+ function createPrefetchCore(options) {
17
+
18
+ var getNonce = options && options.getNonce;
19
+ var isBrowser = options ? options.isBrowser : true;
20
+
21
+ // SSR/Non-browser guard
22
+ if (!isBrowser || typeof window === 'undefined' || typeof document === 'undefined') {
23
+ return {
24
+ __prefetchRu: true,
25
+ version: '1.1.1',
26
+ preload: function () {},
27
+ destroy: function () {},
28
+ refresh: function () {}
29
+ }
82
30
  }
83
31
 
84
- // Ждём DOM
85
- if (document.readyState === 'loading') {
86
- document.addEventListener('DOMContentLoaded', setup)
87
- } else {
88
- setup()
89
- }
90
- })()
91
-
92
- function setup() {
93
- var body = document.body
94
- if (!body) return
95
-
96
- platform = detectPlatform()
97
-
98
- // Читаем конфигурацию
99
- var ds = body.dataset
100
- allowQuery = 'prefetchAllowQueryString' in ds || 'instantAllowQueryString' in ds
101
- allowExternal = 'prefetchAllowExternalLinks' in ds || 'instantAllowExternalLinks' in ds
102
- whitelist = 'prefetchWhitelist' in ds || 'instantWhitelist' in ds
103
-
104
- // CSP nonce override через data-*
105
- // <body data-prefetch-nonce="...">
106
- if (ds.prefetchNonce) scriptNonce = ds.prefetchNonce
107
- if (!scriptNonce && ds.instantNonce) scriptNonce = ds.instantNonce
108
-
109
- // Speculation Rules — opt-in по наличию атрибута:
110
- // <body data-prefetch-specrules> (prefetch)
111
- // <body data-prefetch-specrules="prerender">
112
- // <body data-prefetch-specrules="no">
113
- var hasSr = 'prefetchSpecrules' in ds || 'instantSpecrules' in ds
114
- if (
115
- !isIOS &&
116
- hasSr &&
117
- HTMLScriptElement.supports &&
118
- HTMLScriptElement.supports('speculationrules')
119
- ) {
120
- var sr = ds.prefetchSpecrules || ds.instantSpecrules
121
- if (sr === 'prerender') {
122
- specMode = 'prerender'
123
- useSpecRules = true
124
- } else if (sr !== 'no') {
125
- specMode = 'prefetch'
126
- useSpecRules = true
32
+ // Состояние
33
+ var preloaded = new Set();
34
+ var hoverTimers = new WeakMap();
35
+ var disabled = false;
36
+
37
+ // v1.0.11: in-flight лимит (макс. параллельных запросов)
38
+ var inFlight = 0;
39
+ var maxInFlight = 4;
40
+ var queue = [];
41
+ var maxQueue = 50; // v1.0.11: лимит очереди (защита от переполнения)
42
+
43
+ // v1.0.13: Set активных AbortController для корректного destroy()
44
+ var activeControllers = new Set();
45
+
46
+ // v1.0.13: буфер для Speculation Rules (группируем URL и вставляем одним JSON)
47
+ var specBuffer = { prefetch: [], prerender: [], crossOrigin: [] };
48
+ var specFlushTimer = 0;
49
+
50
+ // v1.0.11: кэш ключа текущей страницы (избегаем new URL() в горячих местах)
51
+ var currentKey = '';
52
+
53
+ var lastTouchTime = 0;
54
+ var touchTimer = 0;
55
+ var touchCancel = null;
56
+
57
+ var isMobile = false;
58
+ var isIOS = false;
59
+ var platform = null;
60
+ var saveData = false;
61
+ var connType = null;
62
+
63
+ // CSP / поддержка
64
+ var scriptNonce = null;
65
+ var supportsLinkPrefetch = false;
66
+
67
+ // Настройки
68
+ var hoverDelay = 65;
69
+ var touchDelay = 80;
70
+ var maxPreloads = 50;
71
+ var allowQuery = false;
72
+ var allowExternal = false;
73
+ var whitelist = false;
74
+
75
+ var useSpecRules = false;
76
+ var specMode = 'none'; // 'none' | 'prefetch' | 'prerender'
77
+ var specRulesFallback = false; // v1.0.11: fallback при SpecRules (по умолчанию отключён)
78
+ var prerenderAll = false;
79
+
80
+ var mousedownMode = false;
81
+ var viewportMode = false;
82
+ var observeDom = false;
83
+
84
+ // v1.0.11: regex вынесены в верхний scope (perf — не создавать на каждый вызов)
85
+ var DANGEROUS_PATH_RE = /(^|\/)(login|logout|auth|register|cart|basket|add|delete|remove)(\/|$|\.)/i;
86
+ var FILE_EXT_RE = /\.(pdf|doc|docx|xls|xlsx|zip|rar|exe)($|\?)/i
87
+
88
+ // Инициализация
89
+ ;(function init() {
90
+ // CSP nonce через переданную функцию
91
+ if (getNonce) {
92
+ try {
93
+ scriptNonce = getNonce();
94
+ } catch (e) {}
95
+ }
96
+
97
+ // rel=prefetch support
98
+ try {
99
+ var l = document.createElement('link');
100
+ if (l.relList && typeof l.relList.supports === 'function') {
101
+ supportsLinkPrefetch = l.relList.supports('prefetch');
102
+ }
103
+ } catch (e) {}
104
+
105
+ var ua = navigator.userAgent;
106
+ var uaData = navigator.userAgentData; // v1.0.12: UA-CH API (более надёжно в долгосрочной перспективе)
107
+
108
+ // Определяем устройство
109
+ // v1.0.12: используем userAgentData если доступен, иначе fallback на UA
110
+ if (uaData) {
111
+ // UA-CH API: https://developer.mozilla.org/en-US/docs/Web/API/NavigatorUAData
112
+ isIOS = false; // UA-CH пока не поддерживается на iOS
113
+ var isAndroid = uaData.platform === 'Android';
114
+ isMobile = uaData.mobile || false;
115
+ // Chromium версия через brands
116
+ var brands = uaData.brands || [];
117
+ for (var i = 0; i < brands.length; i++) {
118
+ var b = brands[i];
119
+ if (b.brand === 'Chromium' || b.brand === 'Google Chrome') {
120
+ parseInt(b.version, 10);
121
+ break
122
+ }
123
+ }
124
+ } else {
125
+ // Fallback на традиционный UA sniffing
126
+ isIOS =
127
+ /iPad|iPhone/.test(ua) ||
128
+ (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
129
+ var isAndroid = /Android/.test(ua);
130
+ isMobile = (isIOS || isAndroid) && Math.min(screen.width, screen.height) < 768;
131
+ // Chromium версия из UA
132
+ var cm = ua.match(/Chrome\/(\d+)/);
133
+ if (cm) parseInt(cm[1], 10);
134
+ }
135
+ if (isMobile) maxPreloads = 20;
136
+
137
+ // Сеть
138
+ var conn = navigator.connection;
139
+ if (conn) {
140
+ connType = conn.effectiveType;
141
+ saveData = conn.saveData || false;
127
142
  }
143
+
144
+ // Ждём DOM
145
+ if (document.readyState === 'loading') {
146
+ document.addEventListener('DOMContentLoaded', setup);
147
+ } else {
148
+ setup();
149
+ }
150
+ })();
151
+
152
+ function setup() {
153
+ // v1.0.11: защита от вызова после destroy()
154
+ if (disabled) return
155
+
156
+ var body = document.body;
157
+ if (!body) return
158
+
159
+ // v1.0.11: кэшируем ключ текущей страницы
160
+ currentKey = location.origin + location.pathname + location.search;
161
+
162
+ platform = detectPlatform();
163
+
164
+ // Читаем конфигурацию
165
+ var ds = body.dataset;
166
+ allowQuery = 'prefetchAllowQueryString' in ds || 'instantAllowQueryString' in ds;
167
+ allowExternal = 'prefetchAllowExternalLinks' in ds || 'instantAllowExternalLinks' in ds;
168
+ whitelist = 'prefetchWhitelist' in ds || 'instantWhitelist' in ds;
169
+
170
+ // CSP nonce override через data-*
171
+ // <body data-prefetch-nonce="...">
172
+ if (ds.prefetchNonce) scriptNonce = ds.prefetchNonce;
173
+ if (!scriptNonce && ds.instantNonce) scriptNonce = ds.instantNonce;
174
+
175
+ // Speculation Rules — opt-in по наличию атрибута:
176
+ // <body data-prefetch-specrules> (prefetch)
177
+ // <body data-prefetch-specrules="prerender">
178
+ // <body data-prefetch-specrules="no">
179
+ var hasSr = 'prefetchSpecrules' in ds || 'instantSpecrules' in ds;
180
+ if (
181
+ !isIOS &&
182
+ hasSr &&
183
+ HTMLScriptElement.supports &&
184
+ HTMLScriptElement.supports('speculationrules')
185
+ ) {
186
+ var sr = ds.prefetchSpecrules || ds.instantSpecrules;
187
+ if (sr === 'prerender') {
188
+ specMode = 'prerender';
189
+ useSpecRules = true;
190
+ } else if (sr !== 'no') {
191
+ specMode = 'prefetch';
192
+ useSpecRules = true;
193
+ }
194
+ }
195
+
196
+ // v1.0.11: fallback при Speculation Rules (по умолчанию отключён для избежания двойного трафика)
197
+ // <body data-prefetch-specrules-fallback>
198
+ specRulesFallback = 'prefetchSpecrulesFallback' in ds || 'instantSpecrulesFallback' in ds;
199
+
200
+ // Разрешить "глобальный" prerender без whitelist (не рекомендуется, но бывает нужно)
201
+ // <body data-prefetch-prerender-all>
202
+ prerenderAll = 'prefetchPrerenderAll' in ds || 'instantPrerenderAll' in ds;
203
+
204
+ // Интенсивность
205
+ var intensity = ds.prefetchIntensity || ds.instantIntensity;
206
+ if (intensity === 'mousedown') {
207
+ mousedownMode = true;
208
+ } else if (intensity === 'viewport' || intensity === 'viewport-all') {
209
+ if (intensity === 'viewport-all' || (isMobile && isNetworkOk())) {
210
+ viewportMode = true;
211
+ }
212
+ } else if (intensity) {
213
+ var d = parseInt(intensity, 10);
214
+ if (!isNaN(d) && d >= 0) hoverDelay = d;
215
+ }
216
+
217
+ // На мобильных делаем touch-предзагрузку менее агрессивной
218
+ if (isMobile) {
219
+ touchDelay = Math.max(60, Math.min(hoverDelay || 0, 150));
220
+ }
221
+
222
+ // DOM observer для SPA
223
+ // v1.0.11: добавлен алиас instantObserveDom
224
+ observeDom = 'prefetchObserveDom' in ds || 'instantObserveDom' in ds;
225
+ if (!observeDom && (platform === 'bitrix' || platform === 'tilda')) {
226
+ observeDom = true;
227
+ }
228
+
229
+ // v1.0.11: обновляем currentKey при навигации (pushState, popstate, hashchange)
230
+ window.addEventListener('popstate', updateCurrentKey);
231
+ window.addEventListener('hashchange', updateCurrentKey);
232
+ // v1.0.12: pageshow для bfcache restore (popstate не всегда срабатывает при возврате из bfcache)
233
+ window.addEventListener('pageshow', onPageShow);
234
+
235
+ // Tilda — увеличиваем задержку из-за popup-ов
236
+ if (platform === 'tilda' && hoverDelay < 100) hoverDelay = 100;
237
+
238
+ // События
239
+ var opts = { capture: true, passive: true };
240
+ document.addEventListener('touchstart', onTouchStart, opts);
241
+ if (!mousedownMode) {
242
+ document.addEventListener('mouseover', onMouseOver, opts);
243
+ } else {
244
+ document.addEventListener('mousedown', onMouseDown, opts);
245
+ }
246
+
247
+ // Viewport observer
248
+ // v1.0.10: feature-detection — если IntersectionObserver недоступен, отключаем viewport режим
249
+ if (viewportMode && typeof IntersectionObserver === 'undefined') viewportMode = false;
250
+
251
+ if (viewportMode) {
252
+ var rIC = window.requestIdleCallback || function (cb) { setTimeout(cb, 1); };
253
+ rIC(startViewportObserver, { timeout: 1500 });
254
+ }
255
+
256
+ // v1.0.9: MutationObserver нужен только для viewport режима (отслеживать новые ссылки)
257
+ // v1.0.10: feature-detection — если MutationObserver недоступен, не запускаем
258
+ if (observeDom && viewportMode && typeof MutationObserver !== 'undefined') startMutationObserver();
128
259
  }
129
260
 
130
- // Разрешить "глобальный" prerender без whitelist (не рекомендуется, но бывает нужно)
131
- // <body data-prefetch-prerender-all>
132
- prerenderAll = 'prefetchPrerenderAll' in ds || 'instantPrerenderAll' in ds
133
-
134
- // Интенсивность
135
- var intensity = ds.prefetchIntensity || ds.instantIntensity
136
- if (intensity === 'mousedown') {
137
- mousedownMode = true
138
- } else if (intensity === 'viewport' || intensity === 'viewport-all') {
139
- if (intensity === 'viewport-all' || (isMobile && isNetworkOk())) {
140
- viewportMode = true
141
- }
142
- } else if (intensity) {
143
- var d = parseInt(intensity, 10)
144
- if (!isNaN(d) && d >= 0) hoverDelay = d
261
+ function detectPlatform() {
262
+ if (typeof window.BX !== 'undefined') return 'bitrix'
263
+ if (typeof window.B24 !== 'undefined' || typeof window.BX24 !== 'undefined') return 'bitrix24'
264
+ if (document.querySelector('.t-records') || typeof window.Tilda !== 'undefined') return 'tilda'
265
+ return null
145
266
  }
146
267
 
147
- // На мобильных делаем touch-предзагрузку менее агрессивной
148
- if (isMobile) {
149
- touchDelay = Math.max(60, Math.min(hoverDelay || 0, 150))
268
+ function isNetworkOk() {
269
+ if (saveData) return false
270
+ if (connType === 'slow-2g' || connType === '2g' || connType === '3g') return false
271
+ return true
150
272
  }
151
273
 
152
- // DOM observer для SPA
153
- observeDom = 'prefetchObserveDom' in ds
154
- if (!observeDom && (platform === 'bitrix' || platform === 'tilda')) {
155
- observeDom = true
274
+ // v1.0.11: обновление currentKey при навигации (SPA-гибриды, pushState)
275
+ function updateCurrentKey() {
276
+ currentKey = location.origin + location.pathname + location.search;
156
277
  }
157
278
 
158
- // Tilda увеличиваем задержку из-за popup-ов
159
- if (platform === 'tilda' && hoverDelay < 100) hoverDelay = 100
279
+ // v1.0.12: pageshow для bfcache restore
280
+ function onPageShow(e) {
281
+ // persisted === true означает восстановление из bfcache
282
+ if (e && e.persisted) {
283
+ updateCurrentKey();
284
+ }
285
+ }
160
286
 
161
- // События
162
- var opts = { capture: true, passive: true }
163
- document.addEventListener('touchstart', onTouchStart, opts)
164
- if (!mousedownMode) {
165
- document.addEventListener('mouseover', onMouseOver, opts)
166
- } else {
167
- document.addEventListener('mousedown', onMouseDown, opts)
287
+ function getAnchorFromEventTarget(t) {
288
+ if (!t) return null
289
+ if (t.nodeType && t.nodeType !== 1) t = t.parentElement;
290
+ if (!t || typeof t.closest !== 'function') return null
291
+ return t.closest('a')
168
292
  }
169
293
 
170
- // Viewport observer
171
- // v1.0.10: feature-detection если IntersectionObserver недоступен, отключаем viewport режим
172
- if (viewportMode && typeof IntersectionObserver === 'undefined') viewportMode = false
294
+ function onTouchStart(e) {
295
+ // v1.0.11: защита от синтетических событий и disabled режим
296
+ if (disabled) return
297
+ if (e && e.isTrusted === false) return
298
+
299
+ // v1.0.9: используем Date.now() для единой шкалы времени
300
+ lastTouchTime = Date.now();
301
+
302
+ var a = getAnchorFromEventTarget(e.target);
303
+ if (!canPreload(a)) return
304
+
305
+ // задержка + отмена на scroll/touchmove
306
+ if (touchTimer) {
307
+ clearTimeout(touchTimer);
308
+ touchTimer = 0;
309
+ }
310
+ if (touchCancel) {
311
+ document.removeEventListener('touchmove', touchCancel, true);
312
+ document.removeEventListener('scroll', touchCancel, true);
313
+ touchCancel = null;
314
+ }
173
315
 
174
- if (viewportMode) {
175
- var rIC = window.requestIdleCallback || function (cb) { setTimeout(cb, 1) }
176
- rIC(startViewportObserver, { timeout: 1500 })
316
+ var cancelled = false;
317
+ touchCancel = function () {
318
+ cancelled = true;
319
+ if (touchTimer) {
320
+ clearTimeout(touchTimer);
321
+ touchTimer = 0;
322
+ }
323
+ document.removeEventListener('touchmove', touchCancel, true);
324
+ document.removeEventListener('scroll', touchCancel, true);
325
+ touchCancel = null;
326
+ };
327
+
328
+ document.addEventListener('touchmove', touchCancel, { capture: true, passive: true, once: true });
329
+ document.addEventListener('scroll', touchCancel, { capture: true, passive: true, once: true });
330
+
331
+ touchTimer = setTimeout(function () {
332
+ if (touchCancel) {
333
+ document.removeEventListener('touchmove', touchCancel, true);
334
+ document.removeEventListener('scroll', touchCancel, true);
335
+ touchCancel = null;
336
+ }
337
+ touchTimer = 0;
338
+ if (!cancelled) preload(a.href, a);
339
+ }, touchDelay);
177
340
  }
178
341
 
179
- // v1.0.9: MutationObserver нужен только для viewport режима (отслеживать новые ссылки)
180
- // v1.0.10: feature-detection если MutationObserver недоступен, не запускаем
181
- if (observeDom && viewportMode && typeof MutationObserver !== 'undefined') startMutationObserver()
182
- }
342
+ function onMouseOver(e) {
343
+ // v1.0.11: защита от синтетических событий и disabled режим
344
+ if (disabled) return
345
+ if (e && e.isTrusted === false) return
183
346
 
184
- function detectPlatform() {
185
- if (typeof window.BX !== 'undefined') return 'bitrix'
186
- if (typeof window.B24 !== 'undefined' || typeof window.BX24 !== 'undefined') return 'bitrix24'
187
- if (document.querySelector('.t-records') || typeof window.Tilda !== 'undefined') return 'tilda'
188
- return null
189
- }
347
+ // v1.0.9: единая шкала времени Date.now()
348
+ if (lastTouchTime && Date.now() - lastTouchTime < 2500) return
190
349
 
191
- function isNetworkOk() {
192
- if (saveData) return false
193
- if (connType === 'slow-2g' || connType === '2g') return false
194
- return true
195
- }
350
+ var a = getAnchorFromEventTarget(e.target);
351
+ if (!a) return
196
352
 
197
- function getAnchorFromEventTarget(t) {
198
- if (!t) return null
199
- if (t.nodeType && t.nodeType !== 1) t = t.parentElement
200
- if (!t || typeof t.closest !== 'function') return null
201
- return t.closest('a')
202
- }
353
+ // v1.0.11: проверяем таймер ДО canPreload (perf — mouseover очень шумный)
354
+ if (hoverTimers.has(a)) return
203
355
 
204
- function onTouchStart(e) {
205
- // v1.0.9: используем Date.now() для единой шкалы времени
206
- lastTouchTime = Date.now()
356
+ if (!canPreload(a)) return
207
357
 
208
- var a = getAnchorFromEventTarget(e.target)
209
- if (!canPreload(a)) return
358
+ // mouseleave не срабатывает при перемещении внутри ссылки (в отличие от mouseout)
359
+ a.addEventListener('mouseleave', onMouseLeave, { passive: true, once: true });
210
360
 
211
- // задержка + отмена на scroll/touchmove
212
- if (touchTimer) {
213
- clearTimeout(touchTimer)
214
- touchTimer = 0
215
- }
216
- if (touchCancel) {
217
- document.removeEventListener('touchmove', touchCancel, true)
218
- document.removeEventListener('scroll', touchCancel, true)
219
- touchCancel = null
361
+ var t = setTimeout(function () {
362
+ preload(a.href, a);
363
+ hoverTimers.delete(a);
364
+ }, hoverDelay);
365
+ hoverTimers.set(a, t);
220
366
  }
221
367
 
222
- var cancelled = false
223
- touchCancel = function () {
224
- cancelled = true
225
- if (touchTimer) {
226
- clearTimeout(touchTimer)
227
- touchTimer = 0
368
+ function onMouseLeave(e) {
369
+ var a = e.currentTarget;
370
+ if (!a) return
371
+
372
+ var t = hoverTimers.get(a);
373
+ if (t) {
374
+ clearTimeout(t);
375
+ hoverTimers.delete(a);
228
376
  }
229
- document.removeEventListener('touchmove', touchCancel, true)
230
- document.removeEventListener('scroll', touchCancel, true)
231
- touchCancel = null
232
377
  }
233
378
 
234
- document.addEventListener('touchmove', touchCancel, { capture: true, passive: true, once: true })
235
- document.addEventListener('scroll', touchCancel, { capture: true, passive: true, once: true })
379
+ function onMouseDown(e) {
380
+ // v1.0.11: защита от синтетических событий и disabled режим
381
+ if (disabled) return
382
+ if (e && e.isTrusted === false) return
236
383
 
237
- touchTimer = setTimeout(function () {
238
- if (touchCancel) {
239
- document.removeEventListener('touchmove', touchCancel, true)
240
- document.removeEventListener('scroll', touchCancel, true)
241
- touchCancel = null
242
- }
243
- touchTimer = 0
244
- if (!cancelled) preload(a.href, a)
245
- }, touchDelay)
246
- }
384
+ // v1.0.12: button===1 (middle click) тоже открывает новую вкладку — префетч бессмысленен
385
+ if (typeof e.button === 'number' && (e.button === 1 || e.button === 2)) return
386
+ // v1.0.10: при модификаторах (Ctrl/Meta/Shift/Alt) открывается новая вкладка — префетч бессмысленен
387
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
388
+ // v1.0.9: единая шкала времени Date.now()
389
+ if (lastTouchTime && Date.now() - lastTouchTime < 2500) return
247
390
 
248
- function onMouseOver(e) {
249
- // v1.0.9: единая шкала времени Date.now()
250
- if (lastTouchTime && Date.now() - lastTouchTime < 2500) return
391
+ var a = getAnchorFromEventTarget(e.target);
392
+ if (canPreload(a)) preload(a.href, a);
393
+ }
251
394
 
252
- var a = getAnchorFromEventTarget(e.target)
253
- if (!canPreload(a)) return
395
+ function canPreload(a) {
396
+ if (!a) return false
254
397
 
255
- // v1.0.7: защита от множественных mouseover по вложенным элементам (не плодим таймеры)
256
- if (hoverTimers.has(a)) return
398
+ // v1.0.7: исключаем <a href=""> и <a> без href (часто используются как кнопки)
399
+ var hrefAttr = a.getAttribute('href');
400
+ if (hrefAttr === null || hrefAttr.trim() === '') return false
257
401
 
258
- // mouseleave не срабатывает при перемещении внутри ссылки (в отличие от mouseout)
259
- a.addEventListener('mouseleave', onMouseLeave, { passive: true, once: true })
402
+ if (!a.href) return false
260
403
 
261
- var t = setTimeout(function () {
262
- preload(a.href, a)
263
- hoverTimers.delete(a)
264
- }, hoverDelay)
265
- hoverTimers.set(a, t)
266
- }
404
+ // Не навигация в текущей вкладке
405
+ if (a.target && a.target !== '_self') return false
406
+ if (a.hasAttribute('download')) return false
267
407
 
268
- function onMouseLeave(e) {
269
- var a = e.currentTarget
270
- if (!a) return
408
+ // Явный запрет
409
+ if ('noPrefetch' in a.dataset || 'prefetchNo' in a.dataset) return false
271
410
 
272
- var t = hoverTimers.get(a)
273
- if (t) {
274
- clearTimeout(t)
275
- hoverTimers.delete(a)
276
- }
277
- }
411
+ // Белый список
412
+ if (whitelist && !('prefetch' in a.dataset) && !('instant' in a.dataset)) return false
278
413
 
279
- function onMouseDown(e) {
280
- if (typeof e.button === 'number' && e.button === 2) return
281
- // v1.0.10: при модификаторах (Ctrl/Meta/Shift/Alt) открывается новая вкладка префетч бессмысленен
282
- if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
283
- // v1.0.9: единая шкала времени Date.now()
284
- if (lastTouchTime && Date.now() - lastTouchTime < 2500) return
414
+ // Протокол
415
+ if (a.protocol !== 'http:' && a.protocol !== 'https:') return false
416
+ if (a.protocol === 'http:' && location.protocol === 'https:') return false
285
417
 
286
- var a = getAnchorFromEventTarget(e.target)
287
- if (canPreload(a)) preload(a.href, a)
288
- }
418
+ // Внешние ссылки
419
+ if (a.origin !== location.origin) {
420
+ if (!allowExternal && !('prefetch' in a.dataset) && !('instant' in a.dataset)) return false
421
+ // v1.1.1: убрано ограничение chromiumVer — Firefox и Safari тоже поддерживают cross-origin prefetch
422
+ }
289
423
 
290
- function canPreload(a) {
291
- if (!a) return false
424
+ // Query string
425
+ if (a.search && !allowQuery && !('prefetch' in a.dataset) && !('instant' in a.dataset)) return false
292
426
 
293
- // v1.0.7: исключаем <a href=""> и <a> без href (часто используются как кнопки)
294
- var hrefAttr = a.getAttribute('href')
295
- if (hrefAttr === null || hrefAttr.trim() === '') return false
427
+ // Якорь на той же странице
428
+ if (a.hash && a.pathname + a.search === location.pathname + location.search) return false
296
429
 
297
- if (!a.href) return false
430
+ // v1.0.11: используем свойства <a> напрямую вместо new URL() (perf)
431
+ var key = a.origin + a.pathname + a.search;
432
+ if (key === currentKey) return false
298
433
 
299
- // Не навигация в текущей вкладке
300
- if (a.target && a.target !== '_self') return false
301
- if (a.hasAttribute('download')) return false
434
+ // Уже загружено
435
+ if (preloaded.has(key)) return false
302
436
 
303
- // Явный запрет
304
- if ('noPrefetch' in a.dataset || 'prefetchNo' in a.dataset) return false
437
+ if (!checkPlatform(a)) return false
438
+ if (!checkAnalytics(a)) return false
305
439
 
306
- // Белый список
307
- if (whitelist && !('prefetch' in a.dataset) && !('instant' in a.dataset)) return false
440
+ return true
441
+ }
308
442
 
309
- // Протокол
310
- if (a.protocol !== 'http:' && a.protocol !== 'https:') return false
311
- if (a.protocol === 'http:' && location.protocol === 'https:') return false
443
+ function checkPlatform(a) {
444
+ var href = a.href;
312
445
 
313
- // Внешние ссылки
314
- if (a.origin !== location.origin) {
315
- if (!allowExternal && !('prefetch' in a.dataset) && !('instant' in a.dataset)) return false
316
- if (!chromiumVer) return false
317
- }
446
+ // v1.0.11: используем свойства <a> напрямую вместо new URL() (perf)
447
+ var pathname = a.pathname || '';
448
+ var hash = a.hash || '';
449
+
450
+ if (platform === 'bitrix' || platform === 'bitrix24') {
451
+ if (href.indexOf('/bitrix/') !== -1 || href.indexOf('sessid=') !== -1) return false
452
+ if (a.classList.contains('bx-ajax')) return false
453
+ }
318
454
 
319
- // Query string
320
- if (a.search && !allowQuery && !('prefetch' in a.dataset) && !('instant' in a.dataset)) return false
455
+ if (platform === 'tilda') {
456
+ // Можно проверять и по href, но hash надёжнее/дешевле
457
+ if (hash.indexOf('#popup:') !== -1 || hash.indexOf('#rec') !== -1) return false
458
+ }
321
459
 
322
- // Якорь на той же странице
323
- if (a.hash && a.pathname + a.search === location.pathname + location.search) return false
460
+ // v1.0.9: все опасные пути проверяем по сегментам pathname, не по подстроке href
461
+ // v1.0.11: regex вынесены в верхний scope (perf)
462
+ if (DANGEROUS_PATH_RE.test(pathname)) return false
463
+ // v1.0.11: проверяем расширение по pathname (href может содержать #hash)
464
+ if (FILE_EXT_RE.test(pathname)) return false
324
465
 
325
- // v1.0.7: не префетчим текущую страницу (в т.ч. для случаев вроде href="")
326
- if (urlKey(a.href) === urlKey(location.href)) return false
466
+ return true
467
+ }
327
468
 
328
- // Уже загружено (ключ НЕ модифицирует реальный URL запроса!)
329
- var key = urlKey(a.href)
330
- if (preloaded.has(key)) return false
469
+ function checkAnalytics(a) {
470
+ var cls = a.className || '';
331
471
 
332
- if (!checkPlatform(a)) return false
333
- if (!checkAnalytics(a)) return false
472
+ // v1.0.11: используем свойства <a> напрямую вместо new URL() (perf)
473
+ var host = a.hostname || '';
334
474
 
335
- return true
336
- }
475
+ if (cls.indexOf('ym-') !== -1) return false
476
+ if (host === 'mc.yandex.ru' || host === 'metrika.yandex.ru') return false
337
477
 
338
- function checkPlatform(a) {
339
- var href = a.href
340
-
341
- // v1.0.7: для точных проверок /add /delete /remove используем pathname
342
- var pathname = ''
343
- var hash = ''
344
- try {
345
- var u = new URL(href, location.href)
346
- pathname = u.pathname || ''
347
- hash = u.hash || ''
348
- } catch (e) {
349
- pathname = ''
350
- hash = ''
351
- }
478
+ if (cls.indexOf('ga-') !== -1 || cls.indexOf('gtm-') !== -1) return false
479
+ if (host === 'google-analytics.com' || host.endsWith('.google-analytics.com')) return false
480
+ if (host === 'googletagmanager.com' || host.endsWith('.googletagmanager.com')) return false
352
481
 
353
- if (platform === 'bitrix' || platform === 'bitrix24') {
354
- if (href.indexOf('/bitrix/') !== -1 || href.indexOf('sessid=') !== -1) return false
355
- if (a.classList.contains('bx-ajax')) return false
482
+ if (cls.indexOf('piwik') !== -1 || cls.indexOf('matomo') !== -1) return false
483
+ if (
484
+ host === 'matomo.org' ||
485
+ host.endsWith('.matomo.org') ||
486
+ host === 'piwik.org' ||
487
+ host.endsWith('.piwik.org')
488
+ ) return false
489
+
490
+ return true
356
491
  }
357
492
 
358
- if (platform === 'tilda') {
359
- // Можно проверять и по href, но hash надёжнее/дешевле
360
- if (hash.indexOf('#popup:') !== -1 || hash.indexOf('#rec') !== -1) return false
493
+ // v1.0.12: объединённая функция — парсим URL один раз вместо двух
494
+ // Возвращает { requestUrl, key } для preload()
495
+ function parseUrl(url) {
496
+ try {
497
+ var u = new URL(url, location.href);
498
+ var key = u.origin + u.pathname + u.search;
499
+ u.hash = '';
500
+ return { requestUrl: u.href, key: key }
501
+ } catch (e) {
502
+ return { requestUrl: url, key: url }
503
+ }
361
504
  }
362
505
 
363
- // v1.0.9: все опасные пути проверяем по сегментам pathname, не по подстроке href
364
- // Это исправляет ложные блокировки /author, /cartoon, /authentication и т.д.
365
- var isDangerousPath = /(^|\/)(login|logout|auth|register|cart|basket|add|delete|remove)(\/|$|\.)/i.test(pathname)
506
+ function resolveSpecMode(a) {
507
+ if (!useSpecRules || specMode === 'none') return 'none'
508
+ if (specMode !== 'prerender') return specMode
366
509
 
367
- if (isDangerousPath) return false
510
+ // specMode === 'prerender'
511
+ if (prerenderAll) return 'prerender'
512
+ if (whitelist) return 'prerender' // ссылки и так явно размечены
368
513
 
369
- if (/\.(pdf|doc|docx|xls|xlsx|zip|rar|exe)($|\?)/i.test(href)) return false
514
+ // иначе prerender только по ссылке:
515
+ if (a && a.dataset && (('prefetchPrerender' in a.dataset) || ('instantPrerender' in a.dataset))) {
516
+ return 'prerender'
517
+ }
518
+ return 'prefetch'
519
+ }
370
520
 
371
- return true
372
- }
521
+ function preload(url, a) {
522
+ if (disabled) return
523
+ if (!isNetworkOk()) return
373
524
 
374
- function checkAnalytics(a) {
375
- var href = a.href
376
- var cls = a.className || ''
525
+ // v1.0.12: парсим URL один раз вместо двух
526
+ var parsed = parseUrl(url);
527
+ var requestUrl = parsed.requestUrl;
528
+ var key = parsed.key;
377
529
 
378
- // Извлекаем hostname для проверки доменов аналитики
379
- var host = ''
380
- try { host = new URL(href, location.href).hostname } catch (e) { host = '' }
530
+ if (preloaded.has(key)) return
531
+ if (preloaded.size >= maxPreloads) {
532
+ preloaded.delete(preloaded.values().next().value);
533
+ }
534
+ preloaded.add(key);
381
535
 
382
- if (cls.indexOf('ym-') !== -1) return false
383
- if (host === 'mc.yandex.ru' || host === 'metrika.yandex.ru') return false
536
+ var mode = resolveSpecMode(a);
384
537
 
385
- if (cls.indexOf('ga-') !== -1 || cls.indexOf('gtm-') !== -1) return false
386
- if (host === 'google-analytics.com' || host.endsWith('.google-analytics.com')) return false
387
- if (host === 'googletagmanager.com' || host.endsWith('.googletagmanager.com')) return false
538
+ // v1.0.11: in-flight лимит ставим в очередь если превышен
539
+ if (inFlight >= maxInFlight) {
540
+ // v1.0.11: лимит очереди отбрасываем новые при переполнении
541
+ // v1.0.11: при drop удаляем ключ (иначе URL "навсегда" считается прогретым)
542
+ if (queue.length >= maxQueue) { preloaded.delete(key); return }
543
+ queue.push({ url: requestUrl, key: key, mode: mode });
544
+ return
545
+ }
388
546
 
389
- if (cls.indexOf('piwik') !== -1 || cls.indexOf('matomo') !== -1) return false
390
- if (
391
- host === 'matomo.org' ||
392
- host.endsWith('.matomo.org') ||
393
- host === 'piwik.org' ||
394
- host.endsWith('.piwik.org')
395
- ) return false
547
+ doPreload(requestUrl, key, mode);
548
+ }
396
549
 
397
- return true
398
- }
550
+ function doPreload(requestUrl, key, mode) {
551
+ if (mode !== 'none') {
552
+ // v1.0.11: try/catch для preloadSpec — при строгом CSP/Trusted Types может выбросить исключение
553
+ var specOk = false;
554
+ try {
555
+ preloadSpec(requestUrl, mode);
556
+ specOk = true;
557
+ } catch (e) {
558
+ // Ошибка в Speculation Rules — fallback обязателен
559
+ }
560
+
561
+ // v1.0.11: fallback только если явно включён ИЛИ если SpecRules не удался
562
+ // По умолчанию fallback отключён для избежания двойного трафика
563
+ if (specRulesFallback || !specOk) {
564
+ if (isIOS || !supportsLinkPrefetch) preloadFetch(requestUrl, key);
565
+ else preloadLink(requestUrl, key);
566
+ }
567
+ return
568
+ }
399
569
 
400
- // Ключ для дедупликации: НЕ трогаем pathname (включая / на конце), только убираем hash
401
- function urlKey(url) {
402
- try {
403
- var u = new URL(url, location.href)
404
- return u.origin + u.pathname + u.search
405
- } catch (e) {
406
- return url
570
+ if (isIOS || !supportsLinkPrefetch) preloadFetch(requestUrl, key);
571
+ else preloadLink(requestUrl, key);
407
572
  }
408
- }
409
573
 
410
- // URL для реального запроса: абсолютный, без hash, без "улучшений"
411
- function urlForRequest(url) {
412
- try {
413
- var u = new URL(url, location.href)
414
- u.hash = ''
415
- return u.href
416
- } catch (e) {
417
- return url
574
+ function processQueue() {
575
+ while (queue.length > 0 && inFlight < maxInFlight) {
576
+ var item = queue.shift();
577
+ doPreload(item.url, item.key, item.mode);
578
+ }
418
579
  }
419
- }
420
580
 
421
- function resolveSpecMode(a) {
422
- if (!useSpecRules || specMode === 'none') return 'none'
423
- if (specMode !== 'prerender') return specMode
581
+ function preloadSpec(url, mode) {
582
+ // v1.0.13: для cross-origin проверяем и модифицируем правила
583
+ var isCrossOrigin = false;
584
+ try {
585
+ var u = new URL(url, location.href);
586
+ isCrossOrigin = u.origin !== location.origin;
587
+ } catch (e) {}
588
+
589
+ // v1.0.13: для cross-origin никогда не делаем prerender (только prefetch)
590
+ if (isCrossOrigin && mode === 'prerender') {
591
+ mode = 'prefetch';
592
+ }
424
593
 
425
- // specMode === 'prerender'
426
- if (prerenderAll) return 'prerender'
427
- if (whitelist) return 'prerender' // ссылки и так явно размечены
594
+ // v1.0.13: буферизуем URL — вставляем одним JSON за idle tick
595
+ if (isCrossOrigin) {
596
+ specBuffer.crossOrigin.push(url);
597
+ } else if (mode === 'prerender') {
598
+ specBuffer.prerender.push(url);
599
+ } else {
600
+ specBuffer.prefetch.push(url);
601
+ }
428
602
 
429
- // иначе prerender только по ссылке:
430
- if (a && a.dataset && (('prefetchPrerender' in a.dataset) || ('instantPrerender' in a.dataset))) {
431
- return 'prerender'
603
+ // Планируем flush если ещё не запланирован
604
+ if (!specFlushTimer) {
605
+ var rIC = window.requestIdleCallback || function (cb) { setTimeout(cb, 1); };
606
+ specFlushTimer = rIC(flushSpecBuffer, { timeout: 50 });
607
+ }
432
608
  }
433
- return 'prefetch'
434
- }
435
609
 
436
- function preload(url, a) {
437
- if (!isNetworkOk()) return
610
+ // v1.0.13: вставляем все накопленные URL одним JSON
611
+ function flushSpecBuffer() {
612
+ specFlushTimer = 0;
438
613
 
439
- var requestUrl = urlForRequest(url)
440
- var key = urlKey(requestUrl)
614
+ // v1.0.13: проверяем disabled (flush может быть вызван после destroy())
615
+ if (disabled) return
441
616
 
442
- if (preloaded.has(key)) return
443
- if (preloaded.size >= maxPreloads) {
444
- preloaded.delete(preloaded.values().next().value)
445
- }
446
- preloaded.add(key)
617
+ var head = document.head;
618
+ if (!head) return
447
619
 
448
- var mode = resolveSpecMode(a)
620
+ var rules = {};
449
621
 
450
- if (mode !== 'none') {
451
- preloadSpec(requestUrl, mode)
622
+ // Same-origin prefetch
623
+ if (specBuffer.prefetch.length > 0) {
624
+ rules.prefetch = rules.prefetch || [];
625
+ rules.prefetch.push({ source: 'list', urls: specBuffer.prefetch.slice() });
626
+ specBuffer.prefetch.length = 0;
627
+ }
452
628
 
453
- // Страховка: обычный prefetch (чтобы под CSP/ограничениями SpecRules всё равно грелся кэш)
454
- if (isIOS || !supportsLinkPrefetch) preloadFetch(requestUrl, key)
455
- else preloadLink(requestUrl, key)
456
- return
457
- }
629
+ // Same-origin prerender
630
+ if (specBuffer.prerender.length > 0) {
631
+ rules.prerender = rules.prerender || [];
632
+ rules.prerender.push({ source: 'list', urls: specBuffer.prerender.slice() });
633
+ specBuffer.prerender.length = 0;
634
+ }
458
635
 
459
- if (isIOS || !supportsLinkPrefetch) preloadFetch(requestUrl, key)
460
- else preloadLink(requestUrl, key)
461
- }
636
+ // Cross-origin (только prefetch, с privacy requirements)
637
+ if (specBuffer.crossOrigin.length > 0) {
638
+ rules.prefetch = rules.prefetch || [];
639
+ rules.prefetch.push({
640
+ source: 'list',
641
+ urls: specBuffer.crossOrigin.slice(),
642
+ referrer_policy: 'no-referrer',
643
+ requires: ['anonymous-client-ip-when-cross-origin']
644
+ });
645
+ specBuffer.crossOrigin.length = 0;
646
+ }
462
647
 
463
- function preloadSpec(url, mode) {
464
- var head = document.head
465
- if (!head) return
648
+ // Если нет правил — выходим
649
+ if (!rules.prefetch && !rules.prerender) return
466
650
 
467
- var s = document.createElement('script')
468
- s.type = 'speculationrules'
469
- if (scriptNonce) s.nonce = scriptNonce
651
+ var s = document.createElement('script');
652
+ s.type = 'speculationrules';
653
+ if (scriptNonce) s.nonce = scriptNonce;
654
+ s.textContent = JSON.stringify(rules);
655
+ head.appendChild(s);
656
+ // v1.0.11: удаляем после вставки — браузер применяет правила на appendChild
657
+ head.removeChild(s);
658
+ }
470
659
 
471
- var rules = {}
472
- rules[mode] = [{ source: 'list', urls: [url] }]
473
- s.textContent = JSON.stringify(rules)
474
- head.appendChild(s)
475
- }
660
+ function preloadLink(url, key) {
661
+ var head = document.head;
662
+ // v1.0.10: если head недоступен, откатываем ключ
663
+ if (!head) { preloaded.delete(key); return }
664
+
665
+ inFlight++;
666
+
667
+ var l = document.createElement('link');
668
+ l.rel = 'prefetch';
669
+ l.href = url;
670
+ l.as = 'document';
671
+ try { l.fetchPriority = 'low'; } catch (e) {}
672
+
673
+ // v1.0.11: для cross-origin: referrerPolicy + crossOrigin
674
+ try {
675
+ var u = new URL(url, location.href);
676
+ if (u.origin !== location.origin) {
677
+ l.referrerPolicy = 'no-referrer';
678
+ l.crossOrigin = 'anonymous'; // не отправлять cookies на внешние домены
679
+ }
680
+ } catch (e) {}
681
+
682
+ // v1.0.13: safety timeout — предохранитель если onload/onerror не сработают
683
+ // (экзотические браузеры, сетевые ошибки без событий)
684
+ var safetyTimer = setTimeout(function () {
685
+ safetyTimer = 0;
686
+ // v1.0.13: при safety timeout считаем попытку неуспешной — удаляем key
687
+ preloaded.delete(key);
688
+ cleanup();
689
+ }, 30000);
690
+
691
+ function cleanup() {
692
+ if (safetyTimer) { clearTimeout(safetyTimer); safetyTimer = 0; }
693
+ l.onload = l.onerror = null;
694
+ if (l.parentNode) l.parentNode.removeChild(l);
695
+ inFlight--;
696
+ processQueue();
697
+ }
476
698
 
477
- function preloadLink(url, key) {
478
- var head = document.head
479
- // v1.0.10: если head недоступен, откатываем ключ
480
- if (!head) { preloaded.delete(key); return }
481
-
482
- var l = document.createElement('link')
483
- l.rel = 'prefetch'
484
- l.href = url
485
- l.as = 'document'
486
- try { l.fetchPriority = 'low' } catch (e) {}
487
-
488
- // v1.0.9: удаляем link после загрузки, чтобы не раздувать DOM
489
- function cleanup() {
490
- l.onload = l.onerror = null
491
- if (l.parentNode) l.parentNode.removeChild(l)
699
+ l.onload = cleanup;
700
+ l.onerror = function () { preloaded.delete(key); cleanup(); };
701
+ head.appendChild(l);
492
702
  }
493
703
 
494
- l.onload = cleanup
495
- l.onerror = function () { preloaded.delete(key); cleanup() }
496
- head.appendChild(l)
497
- }
704
+ function preloadFetch(url, key) {
705
+ // v1.0.10: если fetch недоступен, откатываем ключ
706
+ if (typeof fetch !== 'function') { preloaded.delete(key); return }
707
+
708
+ inFlight++;
709
+
710
+ // v1.0.11: settled флаг — защита от двойного вызова done() при abort+catch
711
+ var settled = false;
712
+ var ctrl = null;
713
+ var tid = 0;
498
714
 
499
- function preloadFetch(url, key) {
500
- // v1.0.10: если fetch недоступен, откатываем ключ
501
- if (typeof fetch !== 'function') { preloaded.delete(key); return }
715
+ function done(success) {
716
+ if (settled) return
717
+ settled = true;
502
718
 
503
- var ctrl = null
504
- var tid = 0
719
+ if (tid) clearTimeout(tid);
720
+ // v1.0.13: удаляем контроллер из активных
721
+ if (ctrl) activeControllers.delete(ctrl);
722
+ if (!success) preloaded.delete(key);
723
+ inFlight--;
724
+ processQueue();
725
+ }
726
+
727
+ if (typeof AbortController !== 'undefined') {
728
+ ctrl = new AbortController();
729
+ // v1.0.13: добавляем в Set для возможности abort при destroy()
730
+ activeControllers.add(ctrl);
731
+ tid = setTimeout(function () {
732
+ try { ctrl.abort(); } catch (e) {}
733
+ done(false);
734
+ }, 5000);
735
+ }
505
736
 
506
- if (typeof AbortController !== 'undefined') {
507
- ctrl = new AbortController()
508
- tid = setTimeout(function () {
509
- try { ctrl.abort() } catch (e) {}
510
- }, 5000)
737
+ // v1.0.13: определяем cross-origin для корректных настроек fetch
738
+ var isCrossOrigin = false;
739
+ try {
740
+ isCrossOrigin = new URL(url, location.href).origin !== location.origin;
741
+ } catch (e) {}
742
+
743
+ var opts = {
744
+ method: 'GET',
745
+ cache: 'force-cache',
746
+ // v1.0.13: для cross-origin: omit credentials, no-referrer (избегаем CORS preflight и утечки referrer)
747
+ credentials: isCrossOrigin ? 'omit' : 'same-origin',
748
+ // v1.1.1: для cross-origin используем no-cors mode (opaque response, но кэш прогревается)
749
+ mode: isCrossOrigin ? 'no-cors' : 'cors'
750
+ };
751
+
752
+ // v1.1.1: headers только для same-origin (no-cors не позволяет кастомные headers)
753
+ if (!isCrossOrigin) {
754
+ opts.headers = {
755
+ Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
756
+ Purpose: 'prefetch'
757
+ };
758
+ }
759
+
760
+ // v1.0.13: referrerPolicy для cross-origin (приватность)
761
+ if (isCrossOrigin) {
762
+ opts.referrerPolicy = 'no-referrer';
763
+ }
764
+
765
+ if (ctrl) opts.signal = ctrl.signal;
766
+
767
+ try {
768
+ fetch(url, opts)
769
+ .then(function (r) {
770
+ // v1.1.1: для no-cors mode response.type === 'opaque', r.ok === false, но кэш прогрет
771
+ // v1.0.12: 304 Not Modified тоже считаем успехом
772
+ done(r && (r.ok || r.status === 304 || r.type === 'opaque'));
773
+ })
774
+ .catch(function () {
775
+ done(false);
776
+ });
777
+ } catch (e) {
778
+ done(false);
779
+ }
511
780
  }
512
781
 
513
- var opts = {
514
- method: 'GET',
515
- credentials: 'same-origin',
516
- cache: 'force-cache',
517
- headers: { Purpose: 'prefetch' }
782
+ // Viewport Observer
783
+ var vpObserver = null;
784
+
785
+ function startViewportObserver() {
786
+ // v1.0.11: защита от вызова после destroy()
787
+ if (disabled) return
788
+ if (vpObserver) return
789
+ vpObserver = new IntersectionObserver(
790
+ function (entries) {
791
+ entries.forEach(function (entry) {
792
+ if (entry.isIntersecting) {
793
+ vpObserver.unobserve(entry.target);
794
+ if (canPreload(entry.target)) preload(entry.target.href, entry.target);
795
+ }
796
+ });
797
+ },
798
+ { rootMargin: isMobile ? '100px' : '200px' }
799
+ );
800
+ observeLinks();
518
801
  }
519
- if (ctrl) opts.signal = ctrl.signal
520
-
521
- try {
522
- fetch(url, opts)
523
- .then(function (r) {
524
- if (tid) clearTimeout(tid)
525
- if (!r || !r.ok) preloaded.delete(key)
526
- })
527
- .catch(function () {
528
- if (tid) clearTimeout(tid)
529
- preloaded.delete(key)
530
- })
531
- } catch (e) {
532
- if (tid) clearTimeout(tid)
533
- preloaded.delete(key)
802
+
803
+ function observeLinks() {
804
+ if (!vpObserver) return
805
+ document.querySelectorAll('a').forEach(function (a) {
806
+ if (canPreload(a)) vpObserver.observe(a);
807
+ });
534
808
  }
535
- }
536
809
 
537
- // Viewport Observer
538
- var vpObserver = null
539
-
540
- function startViewportObserver() {
541
- if (vpObserver) return
542
- vpObserver = new IntersectionObserver(
543
- function (entries) {
544
- entries.forEach(function (entry) {
545
- if (entry.isIntersecting) {
546
- vpObserver.unobserve(entry.target)
547
- if (canPreload(entry.target)) preload(entry.target.href, entry.target)
810
+ // Mutation Observer
811
+ var mutObserver = null;
812
+ var mutTimer = null;
813
+
814
+ function startMutationObserver() {
815
+ // v1.0.11: защита от вызова после destroy()
816
+ if (disabled) return
817
+ if (mutObserver) return
818
+ mutObserver = new MutationObserver(function (muts) {
819
+ // v1.0.13: оптимизация — обычный цикл вместо Array.from + querySelector вместо querySelectorAll
820
+ var hasLinks = false;
821
+ outer: for (var i = 0; i < muts.length; i++) {
822
+ var nodes = muts[i].addedNodes;
823
+ for (var j = 0; j < nodes.length; j++) {
824
+ var n = nodes[j];
825
+ if (n.nodeType === 1) {
826
+ if (n.tagName === 'A' || (n.querySelector && n.querySelector('a'))) {
827
+ hasLinks = true;
828
+ break outer
829
+ }
830
+ }
548
831
  }
549
- })
832
+ }
833
+ if (hasLinks && vpObserver) {
834
+ clearTimeout(mutTimer);
835
+ mutTimer = setTimeout(observeLinks, 100);
836
+ }
837
+ });
838
+ mutObserver.observe(document.body, { childList: true, subtree: true });
839
+ }
840
+
841
+ // v1.0.11: валидация URL для публичного API (усилена)
842
+ function isValidPrefetchUrl(url) {
843
+ if (!url || typeof url !== 'string') return false
844
+ url = url.trim(); // v1.0.11: trim для защиты от ' javascript:...'
845
+ if (!url) return false
846
+ // v1.0.11: блокируем protocol-relative URLs (//evil.com)
847
+ if (/^\/\//.test(url)) return false
848
+ // Блокируем опасные протоколы
849
+ if (/^(javascript|data|vbscript|file):/i.test(url)) return false
850
+ // Разрешаем только http(s) или относительные URL
851
+ if (!/^https?:\/\//i.test(url) && !/^\//.test(url) && !/^\./.test(url)) {
852
+ // Проверяем, что это не протокол
853
+ if (/^[a-z][a-z0-9+.-]*:/i.test(url)) return false
854
+ }
855
+ return true
856
+ }
857
+
858
+ // v1.0.11: destroy() — отключает библиотеку, снимает обработчики
859
+ function destroy() {
860
+ disabled = true;
861
+ queue.length = 0;
862
+
863
+ // v1.0.13: очищаем touch-таймер
864
+ if (touchTimer) {
865
+ clearTimeout(touchTimer);
866
+ touchTimer = 0;
867
+ }
868
+
869
+ // v1.0.13: удаляем touchCancel listener если есть
870
+ if (touchCancel) {
871
+ document.removeEventListener('touchmove', touchCancel, true);
872
+ document.removeEventListener('scroll', touchCancel, true);
873
+ touchCancel = null;
874
+ }
875
+
876
+ // v1.0.13: прерываем все активные fetch-запросы
877
+ activeControllers.forEach(function (ctrl) {
878
+ try { ctrl.abort(); } catch (e) {}
879
+ });
880
+ activeControllers.clear();
881
+
882
+ // v1.0.13: отменяем отложенный flush Speculation Rules
883
+ if (specFlushTimer) {
884
+ var cancelIC = window.cancelIdleCallback || clearTimeout;
885
+ cancelIC(specFlushTimer);
886
+ specFlushTimer = 0;
887
+ }
888
+ // Очищаем буфер
889
+ specBuffer.prefetch.length = specBuffer.prerender.length = specBuffer.crossOrigin.length = 0;
890
+
891
+ var opts = { capture: true, passive: true };
892
+ document.removeEventListener('touchstart', onTouchStart, opts);
893
+ document.removeEventListener('mouseover', onMouseOver, opts);
894
+ document.removeEventListener('mousedown', onMouseDown, opts);
895
+
896
+ // v1.0.11: снимаем слушатели навигации
897
+ window.removeEventListener('popstate', updateCurrentKey);
898
+ window.removeEventListener('hashchange', updateCurrentKey);
899
+ // v1.0.12: снимаем pageshow listener
900
+ window.removeEventListener('pageshow', onPageShow);
901
+
902
+ if (vpObserver) {
903
+ vpObserver.disconnect();
904
+ vpObserver = null;
905
+ }
906
+ if (mutObserver) {
907
+ mutObserver.disconnect();
908
+ mutObserver = null;
909
+ }
910
+ }
911
+
912
+ // Публичный API
913
+ var api = {
914
+ __prefetchRu: true,
915
+ version: '1.1.1',
916
+ preload: function (url) {
917
+ // v1.0.11: валидация URL + прогон через canPreload() (консистентность с авто-режимом)
918
+ if (!isValidPrefetchUrl(url)) return
919
+ // v1.0.11: создаём временный <a> для проверки через canPreload()
920
+ var a = document.createElement('a');
921
+ a.setAttribute('href', url.trim());
922
+ if (!canPreload(a)) return
923
+ preload(a.href, a);
550
924
  },
551
- { rootMargin: isMobile ? '100px' : '200px' }
552
- )
553
- observeLinks()
554
- }
925
+ destroy: destroy,
926
+ // v1.0.11: публичный метод для ручного обновления currentKey (SPA)
927
+ refresh: updateCurrentKey
928
+ };
555
929
 
556
- function observeLinks() {
557
- if (!vpObserver) return
558
- document.querySelectorAll('a').forEach(function (a) {
559
- if (canPreload(a)) vpObserver.observe(a)
560
- })
930
+ return api
561
931
  }
562
932
 
563
- // Mutation Observer
564
- var mutObserver = null
565
- var mutTimer = null
566
-
567
- function startMutationObserver() {
568
- if (mutObserver) return
569
- mutObserver = new MutationObserver(function (muts) {
570
- var hasLinks = muts.some(function (m) {
571
- return Array.from(m.addedNodes).some(function (n) {
572
- return (
573
- n.nodeType === 1 &&
574
- (n.tagName === 'A' || (n.querySelectorAll && n.querySelectorAll('a').length))
575
- )
576
- })
577
- })
578
- if (hasLinks && vpObserver) {
579
- clearTimeout(mutTimer)
580
- mutTimer = setTimeout(observeLinks, 100)
581
- }
582
- })
583
- mutObserver.observe(document.body, { childList: true, subtree: true })
584
- }
933
+ /**
934
+ * IIFE entry point for prefetch.ru
935
+ * This file is bundled into prefetch.js
936
+ * Rollup wraps this in an IIFE
937
+ */
938
+
939
+ // Guard от двойной инициализации (проверяем PrefetchRu первым)
940
+ if (!(window.PrefetchRu && window.PrefetchRu.__prefetchRu) &&
941
+ !(window.Prefetch && window.Prefetch.__prefetchRu)) {
942
+
943
+ var api = createPrefetchCore({
944
+ isBrowser: true,
945
+ getNonce: function () {
946
+ // IIFE: nonce доступен через document.currentScript
947
+ try {
948
+ var cs = document.currentScript;
949
+ if (cs && cs.nonce) return cs.nonce
950
+ } catch (e) {}
951
+ return null
952
+ }
953
+ });
585
954
 
586
- // Минимальный публичный API
587
- window.Prefetch = {
588
- __prefetchRu: true,
589
- version: '1.0.10',
590
- preload: function (url) { preload(url) }
955
+ // Регистрируем в window
956
+ window.PrefetchRu = api;
957
+ if (!window.Prefetch) window.Prefetch = api;
591
958
  }
592
- })()
959
+
960
+ })();