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