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