@prefetchru/prefetch 1.0.9 → 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,584 +1,959 @@
1
- /*!
2
- * prefetch.ru v1.0.9 - Мгновенная загрузка страниц
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
- if (viewportMode) {
172
- var rIC = window.requestIdleCallback || function (cb) { setTimeout(cb, 1) }
173
- rIC(startViewportObserver, { timeout: 1500 })
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
+ }
316
+
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);
174
341
  }
175
342
 
176
- // v1.0.9: MutationObserver нужен только для viewport режима (отслеживать новые ссылки)
177
- if (observeDom && viewportMode) startMutationObserver()
178
- }
343
+ function onMouseOver(e) {
344
+ // v1.0.11: защита от синтетических событий и disabled режим
345
+ if (disabled) return
346
+ if (e && e.isTrusted === false) return
179
347
 
180
- function detectPlatform() {
181
- if (typeof window.BX !== 'undefined') return 'bitrix'
182
- if (typeof window.B24 !== 'undefined' || typeof window.BX24 !== 'undefined') return 'bitrix24'
183
- if (document.querySelector('.t-records') || typeof window.Tilda !== 'undefined') return 'tilda'
184
- return null
185
- }
348
+ // v1.0.9: единая шкала времени Date.now()
349
+ if (lastTouchTime && Date.now() - lastTouchTime < 2500) return
186
350
 
187
- function isNetworkOk() {
188
- if (saveData) return false
189
- if (connType === 'slow-2g' || connType === '2g') return false
190
- return true
191
- }
351
+ var a = getAnchorFromEventTarget(e.target);
352
+ if (!a) return
192
353
 
193
- function getAnchorFromEventTarget(t) {
194
- if (!t) return null
195
- if (t.nodeType && t.nodeType !== 1) t = t.parentElement
196
- if (!t || typeof t.closest !== 'function') return null
197
- return t.closest('a')
198
- }
354
+ // v1.0.11: проверяем таймер ДО canPreload (perf — mouseover очень шумный)
355
+ if (hoverTimers.has(a)) return
199
356
 
200
- function onTouchStart(e) {
201
- // v1.0.9: используем Date.now() для единой шкалы времени
202
- lastTouchTime = Date.now()
357
+ if (!canPreload(a)) return
203
358
 
204
- var a = getAnchorFromEventTarget(e.target)
205
- if (!canPreload(a)) return
359
+ // mouseleave не срабатывает при перемещении внутри ссылки (в отличие от mouseout)
360
+ a.addEventListener('mouseleave', onMouseLeave, { passive: true, once: true });
206
361
 
207
- // задержка + отмена на scroll/touchmove
208
- if (touchTimer) {
209
- clearTimeout(touchTimer)
210
- touchTimer = 0
211
- }
212
- if (touchCancel) {
213
- document.removeEventListener('touchmove', touchCancel, true)
214
- document.removeEventListener('scroll', touchCancel, true)
215
- touchCancel = null
362
+ var t = setTimeout(function () {
363
+ preload(a.href, a);
364
+ hoverTimers.delete(a);
365
+ }, hoverDelay);
366
+ hoverTimers.set(a, t);
216
367
  }
217
368
 
218
- var cancelled = false
219
- touchCancel = function () {
220
- cancelled = true
221
- if (touchTimer) {
222
- clearTimeout(touchTimer)
223
- 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);
224
377
  }
225
- document.removeEventListener('touchmove', touchCancel, true)
226
- document.removeEventListener('scroll', touchCancel, true)
227
- touchCancel = null
228
378
  }
229
379
 
230
- document.addEventListener('touchmove', touchCancel, { capture: true, passive: true, once: true })
231
- 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
232
384
 
233
- touchTimer = setTimeout(function () {
234
- if (touchCancel) {
235
- document.removeEventListener('touchmove', touchCancel, true)
236
- document.removeEventListener('scroll', touchCancel, true)
237
- touchCancel = null
238
- }
239
- touchTimer = 0
240
- if (!cancelled) preload(a.href, a)
241
- }, touchDelay)
242
- }
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
391
+
392
+ var a = getAnchorFromEventTarget(e.target);
393
+ if (canPreload(a)) preload(a.href, a);
394
+ }
243
395
 
244
- function onMouseOver(e) {
245
- // v1.0.9: единая шкала времени Date.now()
246
- if (lastTouchTime && Date.now() - lastTouchTime < 2500) return
396
+ function canPreload(a) {
397
+ if (!a) return false
247
398
 
248
- var a = getAnchorFromEventTarget(e.target)
249
- if (!canPreload(a)) return
399
+ // v1.0.7: исключаем <a href=""> и <a> без href (часто используются как кнопки)
400
+ var hrefAttr = a.getAttribute('href');
401
+ if (hrefAttr === null || hrefAttr.trim() === '') return false
250
402
 
251
- // v1.0.7: защита от множественных mouseover по вложенным элементам (не плодим таймеры)
252
- if (hoverTimers.has(a)) return
403
+ if (!a.href) return false
253
404
 
254
- // mouseleave не срабатывает при перемещении внутри ссылки (в отличие от mouseout)
255
- a.addEventListener('mouseleave', onMouseLeave, { passive: true, once: true })
405
+ // Не навигация в текущей вкладке
406
+ if (a.target && a.target !== '_self') return false
407
+ if (a.hasAttribute('download')) return false
256
408
 
257
- var t = setTimeout(function () {
258
- preload(a.href, a)
259
- hoverTimers.delete(a)
260
- }, hoverDelay)
261
- hoverTimers.set(a, t)
262
- }
409
+ // Явный запрет
410
+ if ('noPrefetch' in a.dataset || 'prefetchNo' in a.dataset) return false
263
411
 
264
- function onMouseLeave(e) {
265
- var a = e.currentTarget
266
- if (!a) return
412
+ // Белый список
413
+ if (whitelist && !('prefetch' in a.dataset) && !('instant' in a.dataset)) return false
267
414
 
268
- var t = hoverTimers.get(a)
269
- if (t) {
270
- clearTimeout(t)
271
- hoverTimers.delete(a)
272
- }
273
- }
415
+ // Протокол
416
+ if (a.protocol !== 'http:' && a.protocol !== 'https:') return false
417
+ if (a.protocol === 'http:' && location.protocol === 'https:') return false
274
418
 
275
- function onMouseDown(e) {
276
- if (typeof e.button === 'number' && e.button === 2) return
277
- // v1.0.9: единая шкала времени Date.now()
278
- if (lastTouchTime && Date.now() - lastTouchTime < 2500) return
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
+ }
279
424
 
280
- var a = getAnchorFromEventTarget(e.target)
281
- if (canPreload(a)) preload(a.href, a)
282
- }
425
+ // Query string
426
+ if (a.search && !allowQuery && !('prefetch' in a.dataset) && !('instant' in a.dataset)) return false
427
+
428
+ // Якорь на той же странице
429
+ if (a.hash && a.pathname + a.search === location.pathname + location.search) return false
283
430
 
284
- function canPreload(a) {
285
- if (!a) 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
286
434
 
287
- // v1.0.7: исключаем <a href=""> и <a> без href (часто используются как кнопки)
288
- var hrefAttr = a.getAttribute('href')
289
- if (hrefAttr === null || hrefAttr.trim() === '') return false
435
+ // Уже загружено
436
+ if (preloaded.has(key)) return false
290
437
 
291
- if (!a.href) return false
438
+ if (!checkPlatform(a)) return false
439
+ if (!checkAnalytics(a)) return false
292
440
 
293
- // Не навигация в текущей вкладке
294
- if (a.target && a.target !== '_self') return false
295
- if (a.hasAttribute('download')) return false
441
+ return true
442
+ }
296
443
 
297
- // Явный запрет
298
- if ('noPrefetch' in a.dataset || 'prefetchNo' in a.dataset) return false
444
+ function checkPlatform(a) {
445
+ var href = a.href;
299
446
 
300
- // Белый список
301
- if (whitelist && !('prefetch' in a.dataset) && !('instant' in a.dataset)) return false
447
+ // v1.0.11: используем свойства <a> напрямую вместо new URL() (perf)
448
+ var pathname = a.pathname || '';
449
+ var hash = a.hash || '';
302
450
 
303
- // Протокол
304
- if (a.protocol !== 'http:' && a.protocol !== 'https:') return false
305
- if (a.protocol === 'http:' && location.protocol === 'https:') return false
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
+ }
306
455
 
307
- // Внешние ссылки
308
- if (a.origin !== location.origin) {
309
- if (!allowExternal && !('prefetch' in a.dataset) && !('instant' in a.dataset)) return false
310
- if (!chromiumVer) return false
311
- }
456
+ if (platform === 'tilda') {
457
+ // Можно проверять и по href, но hash надёжнее/дешевле
458
+ if (hash.indexOf('#popup:') !== -1 || hash.indexOf('#rec') !== -1) return false
459
+ }
312
460
 
313
- // Query string
314
- if (a.search && !allowQuery && !('prefetch' in a.dataset) && !('instant' in a.dataset)) 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
315
466
 
316
- // Якорь на той же странице
317
- if (a.hash && a.pathname + a.search === location.pathname + location.search) return false
467
+ return true
468
+ }
318
469
 
319
- // v1.0.7: не префетчим текущую страницу (в т.ч. для случаев вроде href="")
320
- if (urlKey(a.href) === urlKey(location.href)) return false
470
+ function checkAnalytics(a) {
471
+ var cls = a.className || '';
321
472
 
322
- // Уже загружено (ключ НЕ модифицирует реальный URL запроса!)
323
- var key = urlKey(a.href)
324
- if (preloaded.has(key)) return false
473
+ // v1.0.11: используем свойства <a> напрямую вместо new URL() (perf)
474
+ var host = a.hostname || '';
325
475
 
326
- if (!checkPlatform(a)) return false
327
- if (!checkAnalytics(a)) return false
476
+ if (cls.indexOf('ym-') !== -1) return false
477
+ if (host === 'mc.yandex.ru' || host === 'metrika.yandex.ru') return false
328
478
 
329
- return true
330
- }
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
331
482
 
332
- function checkPlatform(a) {
333
- var href = a.href
334
-
335
- // v1.0.7: для точных проверок /add /delete /remove используем pathname
336
- var pathname = ''
337
- var hash = ''
338
- try {
339
- var u = new URL(href, location.href)
340
- pathname = u.pathname || ''
341
- hash = u.hash || ''
342
- } catch (e) {
343
- pathname = ''
344
- hash = ''
345
- }
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
346
490
 
347
- if (platform === 'bitrix' || platform === 'bitrix24') {
348
- if (href.indexOf('/bitrix/') !== -1 || href.indexOf('sessid=') !== -1) return false
349
- if (a.classList.contains('bx-ajax')) return false
491
+ return true
350
492
  }
351
493
 
352
- if (platform === 'tilda') {
353
- // Можно проверять и по href, но hash надёжнее/дешевле
354
- 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
+ }
355
505
  }
356
506
 
357
- // v1.0.9: все опасные пути проверяем по сегментам pathname, не по подстроке href
358
- // Это исправляет ложные блокировки /author, /cartoon, /authentication и т.д.
359
- 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
360
510
 
361
- if (isDangerousPath) return false
511
+ // specMode === 'prerender'
512
+ if (prerenderAll) return 'prerender'
513
+ if (whitelist) return 'prerender' // ссылки и так явно размечены
362
514
 
363
- 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
+ }
364
521
 
365
- return true
366
- }
522
+ function preload(url, a) {
523
+ if (disabled) return
524
+ if (!isNetworkOk()) return
367
525
 
368
- function checkAnalytics(a) {
369
- var href = a.href
370
- var cls = a.className || ''
526
+ // v1.0.12: парсим URL один раз вместо двух
527
+ var parsed = parseUrl(url);
528
+ var requestUrl = parsed.requestUrl;
529
+ var key = parsed.key;
371
530
 
372
- // Извлекаем hostname для проверки доменов аналитики
373
- var host = ''
374
- 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);
375
536
 
376
- if (cls.indexOf('ym-') !== -1) return false
377
- if (host === 'mc.yandex.ru' || host === 'metrika.yandex.ru') return false
537
+ var mode = resolveSpecMode(a);
378
538
 
379
- if (cls.indexOf('ga-') !== -1 || cls.indexOf('gtm-') !== -1) return false
380
- if (host === 'google-analytics.com' || host.endsWith('.google-analytics.com')) return false
381
- 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
+ }
382
547
 
383
- if (cls.indexOf('piwik') !== -1 || cls.indexOf('matomo') !== -1) return false
384
- if (
385
- host === 'matomo.org' ||
386
- host.endsWith('.matomo.org') ||
387
- host === 'piwik.org' ||
388
- host.endsWith('.piwik.org')
389
- ) return false
548
+ doPreload(requestUrl, key, mode);
549
+ }
390
550
 
391
- return true
392
- }
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
+ }
393
570
 
394
- // Ключ для дедупликации: НЕ трогаем pathname (включая / на конце), только убираем hash
395
- function urlKey(url) {
396
- try {
397
- var u = new URL(url, location.href)
398
- return u.origin + u.pathname + u.search
399
- } catch (e) {
400
- return url
571
+ if (isIOS || !supportsLinkPrefetch) preloadFetch(requestUrl, key);
572
+ else preloadLink(requestUrl, key);
401
573
  }
402
- }
403
574
 
404
- // URL для реального запроса: абсолютный, без hash, без "улучшений"
405
- function urlForRequest(url) {
406
- try {
407
- var u = new URL(url, location.href)
408
- u.hash = ''
409
- return u.href
410
- } catch (e) {
411
- 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
+ }
412
580
  }
413
- }
414
581
 
415
- function resolveSpecMode(a) {
416
- if (!useSpecRules || specMode === 'none') return 'none'
417
- 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
+ }
418
594
 
419
- // specMode === 'prerender'
420
- if (prerenderAll) return 'prerender'
421
- 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
+ }
422
603
 
423
- // иначе prerender только по ссылке:
424
- if (a && a.dataset && (('prefetchPrerender' in a.dataset) || ('instantPrerender' in a.dataset))) {
425
- return 'prerender'
604
+ // Планируем flush если ещё не запланирован
605
+ if (!specFlushTimer) {
606
+ var rIC = window.requestIdleCallback || function (cb) { setTimeout(cb, 1); };
607
+ specFlushTimer = rIC(flushSpecBuffer, { timeout: 50 });
608
+ }
426
609
  }
427
- return 'prefetch'
428
- }
429
610
 
430
- function preload(url, a) {
431
- if (!isNetworkOk()) return
611
+ // v1.0.13: вставляем все накопленные URL одним JSON
612
+ function flushSpecBuffer() {
613
+ specFlushTimer = 0;
432
614
 
433
- var requestUrl = urlForRequest(url)
434
- var key = urlKey(requestUrl)
615
+ // v1.0.13: проверяем disabled (flush может быть вызван после destroy())
616
+ if (disabled) return
435
617
 
436
- if (preloaded.has(key)) return
437
- if (preloaded.size >= maxPreloads) {
438
- preloaded.delete(preloaded.values().next().value)
439
- }
440
- preloaded.add(key)
618
+ var head = document.head;
619
+ if (!head) return
620
+
621
+ var rules = {};
622
+
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
+ }
629
+
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
+ }
441
636
 
442
- var mode = resolveSpecMode(a)
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
+ }
443
648
 
444
- if (mode !== 'none') {
445
- preloadSpec(requestUrl, mode)
649
+ // Если нет правил — выходим
650
+ if (!rules.prefetch && !rules.prerender) return
446
651
 
447
- // Страховка: обычный prefetch (чтобы под CSP/ограничениями SpecRules всё равно грелся кэш)
448
- if (isIOS || !supportsLinkPrefetch) preloadFetch(requestUrl, key)
449
- else preloadLink(requestUrl, key)
450
- return
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);
451
659
  }
452
660
 
453
- if (isIOS || !supportsLinkPrefetch) preloadFetch(requestUrl, key)
454
- else preloadLink(requestUrl, key)
455
- }
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
+ }
456
699
 
457
- function preloadSpec(url, mode) {
458
- var head = document.head
459
- if (!head) return
700
+ l.onload = cleanup;
701
+ l.onerror = function () { preloaded.delete(key); cleanup(); };
702
+ head.appendChild(l);
703
+ }
460
704
 
461
- var s = document.createElement('script')
462
- s.type = 'speculationrules'
463
- if (scriptNonce) s.nonce = scriptNonce
705
+ function preloadFetch(url, key) {
706
+ // v1.0.10: если fetch недоступен, откатываем ключ
707
+ if (typeof fetch !== 'function') { preloaded.delete(key); return }
464
708
 
465
- var rules = {}
466
- rules[mode] = [{ source: 'list', urls: [url] }]
467
- s.textContent = JSON.stringify(rules)
468
- head.appendChild(s)
469
- }
709
+ inFlight++;
470
710
 
471
- function preloadLink(url, key) {
472
- var head = document.head
473
- if (!head) return
711
+ // v1.0.11: settled флаг — защита от двойного вызова done() при abort+catch
712
+ var settled = false;
713
+ var ctrl = null;
714
+ var tid = 0;
474
715
 
475
- var l = document.createElement('link')
476
- l.rel = 'prefetch'
477
- l.href = url
478
- l.as = 'document'
479
- try { l.fetchPriority = 'low' } catch (e) {}
716
+ function done(success) {
717
+ if (settled) return
718
+ settled = true;
480
719
 
481
- // v1.0.9: удаляем link после загрузки, чтобы не раздувать DOM
482
- function cleanup() {
483
- l.onload = l.onerror = null
484
- if (l.parentNode) l.parentNode.removeChild(l)
485
- }
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
+ }
486
727
 
487
- l.onload = cleanup
488
- l.onerror = function () { preloaded.delete(key); cleanup() }
489
- head.appendChild(l)
490
- }
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
+ }
491
737
 
492
- function preloadFetch(url, key) {
493
- if (typeof fetch !== 'function') return
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
+ }
494
759
 
495
- var ctrl = null
496
- var tid = 0
760
+ // v1.0.13: referrerPolicy для cross-origin (приватность)
761
+ if (isCrossOrigin) {
762
+ opts.referrerPolicy = 'no-referrer';
763
+ }
497
764
 
498
- if (typeof AbortController !== 'undefined') {
499
- ctrl = new AbortController()
500
- tid = setTimeout(function () {
501
- try { ctrl.abort() } catch (e) {}
502
- }, 5000)
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
+ }
503
779
  }
504
780
 
505
- var opts = {
506
- method: 'GET',
507
- credentials: 'same-origin',
508
- cache: 'force-cache',
509
- 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();
510
800
  }
511
- if (ctrl) opts.signal = ctrl.signal
512
-
513
- try {
514
- fetch(url, opts)
515
- .then(function (r) {
516
- if (tid) clearTimeout(tid)
517
- if (!r || !r.ok) preloaded.delete(key)
518
- })
519
- .catch(function () {
520
- if (tid) clearTimeout(tid)
521
- preloaded.delete(key)
522
- })
523
- } catch (e) {
524
- if (tid) clearTimeout(tid)
525
- 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
+ });
526
807
  }
527
- }
528
808
 
529
- // Viewport Observer
530
- var vpObserver = null
531
-
532
- function startViewportObserver() {
533
- if (vpObserver) return
534
- vpObserver = new IntersectionObserver(
535
- function (entries) {
536
- entries.forEach(function (entry) {
537
- if (entry.isIntersecting) {
538
- vpObserver.unobserve(entry.target)
539
- 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
+ }
540
830
  }
541
- })
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);
542
923
  },
543
- { rootMargin: isMobile ? '100px' : '200px' }
544
- )
545
- observeLinks()
546
- }
924
+ destroy: destroy,
925
+ // v1.0.11: публичный метод для ручного обновления currentKey (SPA)
926
+ refresh: updateCurrentKey
927
+ };
547
928
 
548
- function observeLinks() {
549
- if (!vpObserver) return
550
- document.querySelectorAll('a').forEach(function (a) {
551
- if (canPreload(a)) vpObserver.observe(a)
552
- })
929
+ return api
553
930
  }
554
931
 
555
- // Mutation Observer
556
- var mutObserver = null
557
- var mutTimer = null
558
-
559
- function startMutationObserver() {
560
- if (mutObserver) return
561
- mutObserver = new MutationObserver(function (muts) {
562
- var hasLinks = muts.some(function (m) {
563
- return Array.from(m.addedNodes).some(function (n) {
564
- return (
565
- n.nodeType === 1 &&
566
- (n.tagName === 'A' || (n.querySelectorAll && n.querySelectorAll('a').length))
567
- )
568
- })
569
- })
570
- if (hasLinks && vpObserver) {
571
- clearTimeout(mutTimer)
572
- mutTimer = setTimeout(observeLinks, 100)
573
- }
574
- })
575
- mutObserver.observe(document.body, { childList: true, subtree: true })
576
- }
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
+ });
577
953
 
578
- // Минимальный публичный API
579
- window.Prefetch = {
580
- __prefetchRu: true,
581
- version: '1.0.9',
582
- preload: function (url) { preload(url) }
954
+ // Регистрируем в window
955
+ window.PrefetchRu = api;
956
+ if (!window.Prefetch) window.Prefetch = api;
583
957
  }
584
- })()
958
+
959
+ })();