@prefetchru/prefetch 1.0.10 → 1.1.1

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