@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/README.md +33 -5
- package/dist/prefetch.esm.min.js +2 -2
- package/dist/prefetch.min.js +2 -2
- package/package.json +3 -1
- package/prefetch.esm.js +638 -274
- package/prefetch.js +852 -477
- package/src/core.js +923 -0
- package/src/entry-esm.js +58 -0
- package/src/entry-iife.js +27 -0
package/prefetch.esm.js
CHANGED
|
@@ -1,205 +1,259 @@
|
|
|
1
|
-
/*!
|
|
2
|
-
* prefetch.ru v1.0
|
|
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
|
-
|
|
7
|
-
|
|
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 {
|
|
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
|
-
|
|
19
|
-
var
|
|
20
|
-
var
|
|
43
|
+
// v1.0.13: буфер для Speculation Rules (группируем URL и вставляем одним JSON)
|
|
44
|
+
var specBuffer = { prefetch: [], prerender: [], crossOrigin: [] };
|
|
45
|
+
var specFlushTimer = 0;
|
|
21
46
|
|
|
22
|
-
|
|
23
|
-
var
|
|
24
|
-
|
|
25
|
-
var
|
|
26
|
-
var
|
|
27
|
-
var
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
var
|
|
47
|
-
var
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
// fallback: на случай окружений, где currentScript всё же доступен
|
|
73
|
-
if (!scriptNonce) {
|
|
88
|
+
// CSP nonce через переданную функцию
|
|
89
|
+
if (getNonce) {
|
|
74
90
|
try {
|
|
75
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
151
|
+
// v1.0.11: защита от вызова после destroy()
|
|
152
|
+
if (disabled) return
|
|
153
|
+
|
|
154
|
+
var body = document.body;
|
|
119
155
|
if (!body) return
|
|
120
156
|
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
348
|
+
var a = getAnchorFromEventTarget(e.target);
|
|
349
|
+
if (!a) return
|
|
275
350
|
|
|
276
|
-
// v1.0.
|
|
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
|
-
|
|
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.
|
|
345
|
-
|
|
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
|
-
// Уже загружено
|
|
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.
|
|
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
|
-
//
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
if (
|
|
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
|
|
394
|
-
var cls = a.className || ''
|
|
468
|
+
var cls = a.className || '';
|
|
395
469
|
|
|
396
|
-
//
|
|
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
|
-
//
|
|
419
|
-
|
|
420
|
-
|
|
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.
|
|
433
|
-
|
|
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
|
-
|
|
458
|
-
var
|
|
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
|
-
|
|
545
|
+
doPreload(requestUrl, key, mode);
|
|
546
|
+
}
|
|
467
547
|
|
|
548
|
+
function doPreload(requestUrl, key, mode) {
|
|
468
549
|
if (mode !== 'none') {
|
|
469
|
-
preloadSpec
|
|
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
|
-
//
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
|
|
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
|
|
486
|
-
|
|
487
|
-
|
|
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
|
|
490
|
-
|
|
491
|
-
s.
|
|
492
|
-
|
|
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
|
-
|
|
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
|
-
|
|
508
|
-
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
541
|
-
|
|
767
|
+
// v1.0.12: 304 Not Modified тоже считаем успехом (кэш прогрет)
|
|
768
|
+
done(r && (r.ok || r.status === 304));
|
|
542
769
|
})
|
|
543
770
|
.catch(function () {
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
})
|
|
771
|
+
done(false);
|
|
772
|
+
});
|
|
547
773
|
} catch (e) {
|
|
548
|
-
|
|
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
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
-
//
|
|
908
|
+
// Публичный API
|
|
603
909
|
var api = {
|
|
604
910
|
__prefetchRu: true,
|
|
605
|
-
version: '1.0
|
|
606
|
-
preload: function (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
|
-
|
|
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.
|
|
616
|
-
? window.
|
|
617
|
-
:
|
|
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 };
|