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