@prefetchru/prefetch 1.0.10 → 1.1.0

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