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