@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.esm.js CHANGED
@@ -1,205 +1,259 @@
1
- /*!
2
- * prefetch.ru v1.0.9 (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.9', 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
246
+ // v1.0.10: feature-detection — если IntersectionObserver недоступен, отключаем viewport режим
247
+ if (viewportMode && typeof IntersectionObserver === 'undefined') viewportMode = false;
248
+
196
249
  if (viewportMode) {
197
- var rIC = window.requestIdleCallback || function (cb) { setTimeout(cb, 1) }
198
- rIC(startViewportObserver, { timeout: 1500 })
250
+ var rIC = window.requestIdleCallback || function (cb) { setTimeout(cb, 1); };
251
+ rIC(startViewportObserver, { timeout: 1500 });
199
252
  }
200
253
 
201
254
  // v1.0.9: MutationObserver нужен только для viewport режима (отслеживать новые ссылки)
202
- if (observeDom && viewportMode) startMutationObserver()
255
+ // v1.0.10: feature-detection если MutationObserver недоступен, не запускаем
256
+ if (observeDom && viewportMode && typeof MutationObserver !== 'undefined') startMutationObserver();
203
257
  }
204
258
 
205
259
  function detectPlatform() {
@@ -211,106 +265,136 @@ function createPrefetch(importMetaUrl) {
211
265
 
212
266
  function isNetworkOk() {
213
267
  if (saveData) return false
214
- if (connType === 'slow-2g' || connType === '2g') return false
268
+ if (connType === 'slow-2g' || connType === '2g' || connType === '3g') return false
215
269
  return true
216
270
  }
217
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
+
218
285
  function getAnchorFromEventTarget(t) {
219
286
  if (!t) return null
220
- if (t.nodeType && t.nodeType !== 1) t = t.parentElement
287
+ if (t.nodeType && t.nodeType !== 1) t = t.parentElement;
221
288
  if (!t || typeof t.closest !== 'function') return null
222
289
  return t.closest('a')
223
290
  }
224
291
 
225
292
  function onTouchStart(e) {
293
+ // v1.0.11: защита от синтетических событий и disabled режим
294
+ if (disabled) return
295
+ if (e && e.isTrusted === false) return
296
+
226
297
  // v1.0.9: используем Date.now() для единой шкалы времени
227
- lastTouchTime = Date.now()
298
+ lastTouchTime = Date.now();
228
299
 
229
- var a = getAnchorFromEventTarget(e.target)
300
+ var a = getAnchorFromEventTarget(e.target);
230
301
  if (!canPreload(a)) return
231
302
 
232
303
  // задержка + отмена на scroll/touchmove
233
304
  if (touchTimer) {
234
- clearTimeout(touchTimer)
235
- touchTimer = 0
305
+ clearTimeout(touchTimer);
306
+ touchTimer = 0;
236
307
  }
237
308
  if (touchCancel) {
238
- document.removeEventListener('touchmove', touchCancel, true)
239
- document.removeEventListener('scroll', touchCancel, true)
240
- touchCancel = null
309
+ document.removeEventListener('touchmove', touchCancel, true);
310
+ document.removeEventListener('scroll', touchCancel, true);
311
+ touchCancel = null;
241
312
  }
242
313
 
243
- var cancelled = false
314
+ var cancelled = false;
244
315
  touchCancel = function () {
245
- cancelled = true
316
+ cancelled = true;
246
317
  if (touchTimer) {
247
- clearTimeout(touchTimer)
248
- touchTimer = 0
318
+ clearTimeout(touchTimer);
319
+ touchTimer = 0;
249
320
  }
250
- document.removeEventListener('touchmove', touchCancel, true)
251
- document.removeEventListener('scroll', touchCancel, true)
252
- touchCancel = null
253
- }
321
+ document.removeEventListener('touchmove', touchCancel, true);
322
+ document.removeEventListener('scroll', touchCancel, true);
323
+ touchCancel = null;
324
+ };
254
325
 
255
- document.addEventListener('touchmove', touchCancel, { capture: true, passive: true, once: true })
256
- 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 });
257
328
 
258
329
  touchTimer = setTimeout(function () {
259
330
  if (touchCancel) {
260
- document.removeEventListener('touchmove', touchCancel, true)
261
- document.removeEventListener('scroll', touchCancel, true)
262
- touchCancel = null
331
+ document.removeEventListener('touchmove', touchCancel, true);
332
+ document.removeEventListener('scroll', touchCancel, true);
333
+ touchCancel = null;
263
334
  }
264
- touchTimer = 0
265
- if (!cancelled) preload(a.href, a)
266
- }, touchDelay)
335
+ touchTimer = 0;
336
+ if (!cancelled) preload(a.href, a);
337
+ }, touchDelay);
267
338
  }
268
339
 
269
340
  function onMouseOver(e) {
341
+ // v1.0.11: защита от синтетических событий и disabled режим
342
+ if (disabled) return
343
+ if (e && e.isTrusted === false) return
344
+
270
345
  // v1.0.9: единая шкала времени Date.now()
271
346
  if (lastTouchTime && Date.now() - lastTouchTime < 2500) return
272
347
 
273
- var a = getAnchorFromEventTarget(e.target)
274
- if (!canPreload(a)) return
348
+ var a = getAnchorFromEventTarget(e.target);
349
+ if (!a) return
275
350
 
276
- // v1.0.7: защита от множественных mouseover по вложенным элементам (не плодим таймеры)
351
+ // v1.0.11: проверяем таймер ДО canPreload (perf mouseover очень шумный)
277
352
  if (hoverTimers.has(a)) return
278
353
 
354
+ if (!canPreload(a)) return
355
+
279
356
  // mouseleave не срабатывает при перемещении внутри ссылки (в отличие от mouseout)
280
- a.addEventListener('mouseleave', onMouseLeave, { passive: true, once: true })
357
+ a.addEventListener('mouseleave', onMouseLeave, { passive: true, once: true });
281
358
 
282
359
  var t = setTimeout(function () {
283
- preload(a.href, a)
284
- hoverTimers.delete(a)
285
- }, hoverDelay)
286
- hoverTimers.set(a, t)
360
+ preload(a.href, a);
361
+ hoverTimers.delete(a);
362
+ }, hoverDelay);
363
+ hoverTimers.set(a, t);
287
364
  }
288
365
 
289
366
  function onMouseLeave(e) {
290
- var a = e.currentTarget
367
+ var a = e.currentTarget;
291
368
  if (!a) return
292
369
 
293
- var t = hoverTimers.get(a)
370
+ var t = hoverTimers.get(a);
294
371
  if (t) {
295
- clearTimeout(t)
296
- hoverTimers.delete(a)
372
+ clearTimeout(t);
373
+ hoverTimers.delete(a);
297
374
  }
298
375
  }
299
376
 
300
377
  function onMouseDown(e) {
301
- 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
384
+ // v1.0.10: при модификаторах (Ctrl/Meta/Shift/Alt) открывается новая вкладка — префетч бессмысленен
385
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
302
386
  // v1.0.9: единая шкала времени Date.now()
303
387
  if (lastTouchTime && Date.now() - lastTouchTime < 2500) return
304
388
 
305
- var a = getAnchorFromEventTarget(e.target)
306
- if (canPreload(a)) preload(a.href, a)
389
+ var a = getAnchorFromEventTarget(e.target);
390
+ if (canPreload(a)) preload(a.href, a);
307
391
  }
308
392
 
309
393
  function canPreload(a) {
310
394
  if (!a) return false
311
395
 
312
396
  // v1.0.7: исключаем <a href=""> и <a> без href (часто используются как кнопки)
313
- var hrefAttr = a.getAttribute('href')
397
+ var hrefAttr = a.getAttribute('href');
314
398
  if (hrefAttr === null || hrefAttr.trim() === '') return false
315
399
 
316
400
  if (!a.href) return false
@@ -341,11 +425,11 @@ function createPrefetch(importMetaUrl) {
341
425
  // Якорь на той же странице
342
426
  if (a.hash && a.pathname + a.search === location.pathname + location.search) return false
343
427
 
344
- // v1.0.7: не префетчим текущую страницу т.ч. для случаев вроде href="")
345
- 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
346
431
 
347
- // Уже загружено (ключ НЕ модифицирует реальный URL запроса!)
348
- var key = urlKey(a.href)
432
+ // Уже загружено
349
433
  if (preloaded.has(key)) return false
350
434
 
351
435
  if (!checkPlatform(a)) return false
@@ -355,19 +439,11 @@ function createPrefetch(importMetaUrl) {
355
439
  }
356
440
 
357
441
  function checkPlatform(a) {
358
- var href = a.href
442
+ var href = a.href;
359
443
 
360
- // v1.0.7: для точных проверок /add /delete /remove используем pathname
361
- var pathname = ''
362
- var hash = ''
363
- try {
364
- var u = new URL(href, location.href)
365
- pathname = u.pathname || ''
366
- hash = u.hash || ''
367
- } catch (e) {
368
- pathname = ''
369
- hash = ''
370
- }
444
+ // v1.0.11: используем свойства <a> напрямую вместо new URL() (perf)
445
+ var pathname = a.pathname || '';
446
+ var hash = a.hash || '';
371
447
 
372
448
  if (platform === 'bitrix' || platform === 'bitrix24') {
373
449
  if (href.indexOf('/bitrix/') !== -1 || href.indexOf('sessid=') !== -1) return false
@@ -375,27 +451,24 @@ function createPrefetch(importMetaUrl) {
375
451
  }
376
452
 
377
453
  if (platform === 'tilda') {
454
+ // Можно проверять и по href, но hash надёжнее/дешевле
378
455
  if (hash.indexOf('#popup:') !== -1 || hash.indexOf('#rec') !== -1) return false
379
456
  }
380
457
 
381
458
  // v1.0.9: все опасные пути проверяем по сегментам pathname, не по подстроке href
382
- // Это исправляет ложные блокировки /author, /cartoon, /authentication и т.д.
383
- var isDangerousPath = /(^|\/)(login|logout|auth|register|cart|basket|add|delete|remove)(\/|$|\.)/i.test(pathname)
384
-
385
- if (isDangerousPath) return false
386
-
387
- 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
388
463
 
389
464
  return true
390
465
  }
391
466
 
392
467
  function checkAnalytics(a) {
393
- var href = a.href
394
- var cls = a.className || ''
468
+ var cls = a.className || '';
395
469
 
396
- // Извлекаем hostname для проверки доменов аналитики
397
- var host = ''
398
- try { host = new URL(href, location.href).hostname } catch (e) { host = '' }
470
+ // v1.0.11: используем свойства <a> напрямую вместо new URL() (perf)
471
+ var host = a.hostname || '';
399
472
 
400
473
  if (cls.indexOf('ym-') !== -1) return false
401
474
  if (host === 'mc.yandex.ru' || host === 'metrika.yandex.ru') return false
@@ -415,24 +488,16 @@ function createPrefetch(importMetaUrl) {
415
488
  return true
416
489
  }
417
490
 
418
- // Ключ для дедупликации: НЕ трогаем pathname (включая / на конце), только убираем hash
419
- function urlKey(url) {
420
- try {
421
- var u = new URL(url, location.href)
422
- return u.origin + u.pathname + u.search
423
- } catch (e) {
424
- return url
425
- }
426
- }
427
-
428
- // URL для реального запроса: абсолютный, без hash, без "улучшений"
429
- function urlForRequest(url) {
491
+ // v1.0.12: объединённая функция парсим URL один раз вместо двух
492
+ // Возвращает { requestUrl, key } для preload()
493
+ function parseUrl(url) {
430
494
  try {
431
- var u = new URL(url, location.href)
432
- u.hash = ''
433
- 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 }
434
499
  } catch (e) {
435
- return url
500
+ return { requestUrl: url, key: url }
436
501
  }
437
502
  }
438
503
 
@@ -452,169 +517,468 @@ function createPrefetch(importMetaUrl) {
452
517
  }
453
518
 
454
519
  function preload(url, a) {
520
+ if (disabled) return
455
521
  if (!isNetworkOk()) return
456
522
 
457
- var requestUrl = urlForRequest(url)
458
- var key = urlKey(requestUrl)
523
+ // v1.0.12: парсим URL один раз вместо двух
524
+ var parsed = parseUrl(url);
525
+ var requestUrl = parsed.requestUrl;
526
+ var key = parsed.key;
459
527
 
460
528
  if (preloaded.has(key)) return
461
529
  if (preloaded.size >= maxPreloads) {
462
- 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
463
543
  }
464
- preloaded.add(key)
465
544
 
466
- var mode = resolveSpecMode(a)
545
+ doPreload(requestUrl, key, mode);
546
+ }
467
547
 
548
+ function doPreload(requestUrl, key, mode) {
468
549
  if (mode !== 'none') {
469
- 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
+ }
470
558
 
471
- // Страховка: обычный prefetch (чтобы под CSP/ограничениями SpecRules всё равно грелся кэш)
472
- if (isIOS || !supportsLinkPrefetch) preloadFetch(requestUrl, key)
473
- 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
+ }
474
565
  return
475
566
  }
476
567
 
477
- if (isIOS || !supportsLinkPrefetch) preloadFetch(requestUrl, key)
478
- 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
+ }
479
577
  }
480
578
 
481
579
  function preloadSpec(url, mode) {
482
- 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;
483
616
  if (!head) return
484
617
 
485
- var s = document.createElement('script')
486
- s.type = 'speculationrules'
487
- 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
+ }
626
+
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
488
648
 
489
- var rules = {}
490
- rules[mode] = [{ source: 'list', urls: [url] }]
491
- s.textContent = JSON.stringify(rules)
492
- head.appendChild(s)
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);
493
656
  }
494
657
 
495
658
  function preloadLink(url, key) {
496
- var head = document.head
497
- if (!head) return
659
+ var head = document.head;
660
+ // v1.0.10: если head недоступен, откатываем ключ
661
+ if (!head) { preloaded.delete(key); return }
662
+
663
+ inFlight++;
498
664
 
499
- var l = document.createElement('link')
500
- l.rel = 'prefetch'
501
- l.href = url
502
- l.as = 'document'
503
- try { l.fetchPriority = 'low' } catch (e) {}
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);
504
688
 
505
- // v1.0.9: удаляем link после загрузки, чтобы не раздувать DOM
506
689
  function cleanup() {
507
- l.onload = l.onerror = null
508
- 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();
509
695
  }
510
696
 
511
- l.onload = cleanup
512
- l.onerror = function () { preloaded.delete(key); cleanup() }
513
- head.appendChild(l)
697
+ l.onload = cleanup;
698
+ l.onerror = function () { preloaded.delete(key); cleanup(); };
699
+ head.appendChild(l);
514
700
  }
515
701
 
516
702
  function preloadFetch(url, key) {
517
- if (typeof fetch !== 'function') return
518
-
519
- var ctrl = null
520
- var tid = 0
703
+ // v1.0.10: если fetch недоступен, откатываем ключ
704
+ if (typeof fetch !== 'function') { preloaded.delete(key); return }
705
+
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
+ }
521
724
 
522
725
  if (typeof AbortController !== 'undefined') {
523
- ctrl = new AbortController()
726
+ ctrl = new AbortController();
727
+ // v1.0.13: добавляем в Set для возможности abort при destroy()
728
+ activeControllers.add(ctrl);
524
729
  tid = setTimeout(function () {
525
- try { ctrl.abort() } catch (e) {}
526
- }, 5000)
730
+ try { ctrl.abort(); } catch (e) {}
731
+ done(false);
732
+ }, 5000);
527
733
  }
528
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
+
529
741
  var opts = {
530
742
  method: 'GET',
531
- credentials: 'same-origin',
532
743
  cache: 'force-cache',
533
- 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';
534
755
  }
535
- 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;
536
763
 
537
764
  try {
538
765
  fetch(url, opts)
539
766
  .then(function (r) {
540
- if (tid) clearTimeout(tid)
541
- if (!r || !r.ok) preloaded.delete(key)
767
+ // v1.0.12: 304 Not Modified тоже считаем успехом (кэш прогрет)
768
+ done(r && (r.ok || r.status === 304));
542
769
  })
543
770
  .catch(function () {
544
- if (tid) clearTimeout(tid)
545
- preloaded.delete(key)
546
- })
771
+ done(false);
772
+ });
547
773
  } catch (e) {
548
- if (tid) clearTimeout(tid)
549
- preloaded.delete(key)
774
+ done(false);
550
775
  }
551
776
  }
552
777
 
553
778
  // Viewport Observer
554
- var vpObserver = null
779
+ var vpObserver = null;
555
780
 
556
781
  function startViewportObserver() {
782
+ // v1.0.11: защита от вызова после destroy()
783
+ if (disabled) return
557
784
  if (vpObserver) return
558
785
  vpObserver = new IntersectionObserver(
559
786
  function (entries) {
560
787
  entries.forEach(function (entry) {
561
788
  if (entry.isIntersecting) {
562
- vpObserver.unobserve(entry.target)
563
- 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);
564
791
  }
565
- })
792
+ });
566
793
  },
567
794
  { rootMargin: isMobile ? '100px' : '200px' }
568
- )
569
- observeLinks()
795
+ );
796
+ observeLinks();
570
797
  }
571
798
 
572
799
  function observeLinks() {
573
800
  if (!vpObserver) return
574
801
  document.querySelectorAll('a').forEach(function (a) {
575
- if (canPreload(a)) vpObserver.observe(a)
576
- })
802
+ if (canPreload(a)) vpObserver.observe(a);
803
+ });
577
804
  }
578
805
 
579
806
  // Mutation Observer
580
- var mutObserver = null
581
- var mutTimer = null
807
+ var mutObserver = null;
808
+ var mutTimer = null;
582
809
 
583
810
  function startMutationObserver() {
811
+ // v1.0.11: защита от вызова после destroy()
812
+ if (disabled) return
584
813
  if (mutObserver) return
585
814
  mutObserver = new MutationObserver(function (muts) {
586
- var hasLinks = muts.some(function (m) {
587
- return Array.from(m.addedNodes).some(function (n) {
588
- return (
589
- n.nodeType === 1 &&
590
- (n.tagName === 'A' || (n.querySelectorAll && n.querySelectorAll('a').length))
591
- )
592
- })
593
- })
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
+ }
594
829
  if (hasLinks && vpObserver) {
595
- clearTimeout(mutTimer)
596
- mutTimer = setTimeout(observeLinks, 100)
830
+ clearTimeout(mutTimer);
831
+ mutTimer = setTimeout(observeLinks, 100);
597
832
  }
598
- })
599
- 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
+ }
600
906
  }
601
907
 
602
- // Минимальный публичный API
908
+ // Публичный API
603
909
  var api = {
604
910
  __prefetchRu: true,
605
- version: '1.0.9',
606
- preload: function (url) { preload(url) }
607
- }
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
+ };
608
925
 
609
- window.Prefetch = api
610
926
  return api
611
927
  }
612
928
 
613
- // 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 от двойной инициализации
614
957
  var Prefetch =
615
- (typeof window !== 'undefined' && window.Prefetch && window.Prefetch.__prefetchRu)
616
- ? window.Prefetch
617
- : 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
+ }
618
983
 
619
- export { Prefetch }
620
- export default Prefetch
984
+ export { Prefetch, Prefetch as default };