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