@prefetchru/prefetch 1.0.9 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -5
- package/dist/prefetch.esm.min.js +2 -2
- package/dist/prefetch.min.js +2 -2
- package/package.json +3 -1
- package/prefetch.esm.js +638 -274
- package/prefetch.js +852 -477
- package/src/core.js +923 -0
- package/src/entry-esm.js +58 -0
- package/src/entry-iife.js +27 -0
package/prefetch.js
CHANGED
|
@@ -1,584 +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
|
-
|
|
173
|
-
|
|
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
|
+
}
|
|
316
|
+
|
|
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);
|
|
174
341
|
}
|
|
175
342
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
343
|
+
function onMouseOver(e) {
|
|
344
|
+
// v1.0.11: защита от синтетических событий и disabled режим
|
|
345
|
+
if (disabled) return
|
|
346
|
+
if (e && e.isTrusted === false) return
|
|
179
347
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if (typeof window.B24 !== 'undefined' || typeof window.BX24 !== 'undefined') return 'bitrix24'
|
|
183
|
-
if (document.querySelector('.t-records') || typeof window.Tilda !== 'undefined') return 'tilda'
|
|
184
|
-
return null
|
|
185
|
-
}
|
|
348
|
+
// v1.0.9: единая шкала времени Date.now()
|
|
349
|
+
if (lastTouchTime && Date.now() - lastTouchTime < 2500) return
|
|
186
350
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
if (connType === 'slow-2g' || connType === '2g') return false
|
|
190
|
-
return true
|
|
191
|
-
}
|
|
351
|
+
var a = getAnchorFromEventTarget(e.target);
|
|
352
|
+
if (!a) return
|
|
192
353
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
if (t.nodeType && t.nodeType !== 1) t = t.parentElement
|
|
196
|
-
if (!t || typeof t.closest !== 'function') return null
|
|
197
|
-
return t.closest('a')
|
|
198
|
-
}
|
|
354
|
+
// v1.0.11: проверяем таймер ДО canPreload (perf — mouseover очень шумный)
|
|
355
|
+
if (hoverTimers.has(a)) return
|
|
199
356
|
|
|
200
|
-
|
|
201
|
-
// v1.0.9: используем Date.now() для единой шкалы времени
|
|
202
|
-
lastTouchTime = Date.now()
|
|
357
|
+
if (!canPreload(a)) return
|
|
203
358
|
|
|
204
|
-
|
|
205
|
-
|
|
359
|
+
// mouseleave не срабатывает при перемещении внутри ссылки (в отличие от mouseout)
|
|
360
|
+
a.addEventListener('mouseleave', onMouseLeave, { passive: true, once: true });
|
|
206
361
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
if (touchCancel) {
|
|
213
|
-
document.removeEventListener('touchmove', touchCancel, true)
|
|
214
|
-
document.removeEventListener('scroll', touchCancel, true)
|
|
215
|
-
touchCancel = null
|
|
362
|
+
var t = setTimeout(function () {
|
|
363
|
+
preload(a.href, a);
|
|
364
|
+
hoverTimers.delete(a);
|
|
365
|
+
}, hoverDelay);
|
|
366
|
+
hoverTimers.set(a, t);
|
|
216
367
|
}
|
|
217
368
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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);
|
|
224
377
|
}
|
|
225
|
-
document.removeEventListener('touchmove', touchCancel, true)
|
|
226
|
-
document.removeEventListener('scroll', touchCancel, true)
|
|
227
|
-
touchCancel = null
|
|
228
378
|
}
|
|
229
379
|
|
|
230
|
-
|
|
231
|
-
|
|
380
|
+
function onMouseDown(e) {
|
|
381
|
+
// v1.0.11: защита от синтетических событий и disabled режим
|
|
382
|
+
if (disabled) return
|
|
383
|
+
if (e && e.isTrusted === false) return
|
|
232
384
|
|
|
233
|
-
|
|
234
|
-
if (
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
|
391
|
+
|
|
392
|
+
var a = getAnchorFromEventTarget(e.target);
|
|
393
|
+
if (canPreload(a)) preload(a.href, a);
|
|
394
|
+
}
|
|
243
395
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
if (lastTouchTime && Date.now() - lastTouchTime < 2500) return
|
|
396
|
+
function canPreload(a) {
|
|
397
|
+
if (!a) return false
|
|
247
398
|
|
|
248
|
-
|
|
249
|
-
|
|
399
|
+
// v1.0.7: исключаем <a href=""> и <a> без href (часто используются как кнопки)
|
|
400
|
+
var hrefAttr = a.getAttribute('href');
|
|
401
|
+
if (hrefAttr === null || hrefAttr.trim() === '') return false
|
|
250
402
|
|
|
251
|
-
|
|
252
|
-
if (hoverTimers.has(a)) return
|
|
403
|
+
if (!a.href) return false
|
|
253
404
|
|
|
254
|
-
|
|
255
|
-
|
|
405
|
+
// Не навигация в текущей вкладке
|
|
406
|
+
if (a.target && a.target !== '_self') return false
|
|
407
|
+
if (a.hasAttribute('download')) return false
|
|
256
408
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
hoverTimers.delete(a)
|
|
260
|
-
}, hoverDelay)
|
|
261
|
-
hoverTimers.set(a, t)
|
|
262
|
-
}
|
|
409
|
+
// Явный запрет
|
|
410
|
+
if ('noPrefetch' in a.dataset || 'prefetchNo' in a.dataset) return false
|
|
263
411
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
if (!a) return
|
|
412
|
+
// Белый список
|
|
413
|
+
if (whitelist && !('prefetch' in a.dataset) && !('instant' in a.dataset)) return false
|
|
267
414
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
hoverTimers.delete(a)
|
|
272
|
-
}
|
|
273
|
-
}
|
|
415
|
+
// Протокол
|
|
416
|
+
if (a.protocol !== 'http:' && a.protocol !== 'https:') return false
|
|
417
|
+
if (a.protocol === 'http:' && location.protocol === 'https:') return false
|
|
274
418
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
+
}
|
|
279
424
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
425
|
+
// Query string
|
|
426
|
+
if (a.search && !allowQuery && !('prefetch' in a.dataset) && !('instant' in a.dataset)) return false
|
|
427
|
+
|
|
428
|
+
// Якорь на той же странице
|
|
429
|
+
if (a.hash && a.pathname + a.search === location.pathname + location.search) return false
|
|
283
430
|
|
|
284
|
-
|
|
285
|
-
|
|
431
|
+
// v1.0.11: используем свойства <a> напрямую вместо new URL() (perf)
|
|
432
|
+
var key = a.origin + a.pathname + a.search;
|
|
433
|
+
if (key === currentKey) return false
|
|
286
434
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
if (hrefAttr === null || hrefAttr.trim() === '') return false
|
|
435
|
+
// Уже загружено
|
|
436
|
+
if (preloaded.has(key)) return false
|
|
290
437
|
|
|
291
|
-
|
|
438
|
+
if (!checkPlatform(a)) return false
|
|
439
|
+
if (!checkAnalytics(a)) return false
|
|
292
440
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
if (a.hasAttribute('download')) return false
|
|
441
|
+
return true
|
|
442
|
+
}
|
|
296
443
|
|
|
297
|
-
|
|
298
|
-
|
|
444
|
+
function checkPlatform(a) {
|
|
445
|
+
var href = a.href;
|
|
299
446
|
|
|
300
|
-
|
|
301
|
-
|
|
447
|
+
// v1.0.11: используем свойства <a> напрямую вместо new URL() (perf)
|
|
448
|
+
var pathname = a.pathname || '';
|
|
449
|
+
var hash = a.hash || '';
|
|
302
450
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
+
}
|
|
306
455
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
}
|
|
456
|
+
if (platform === 'tilda') {
|
|
457
|
+
// Можно проверять и по href, но hash надёжнее/дешевле
|
|
458
|
+
if (hash.indexOf('#popup:') !== -1 || hash.indexOf('#rec') !== -1) return false
|
|
459
|
+
}
|
|
312
460
|
|
|
313
|
-
|
|
314
|
-
|
|
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
|
|
315
466
|
|
|
316
|
-
|
|
317
|
-
|
|
467
|
+
return true
|
|
468
|
+
}
|
|
318
469
|
|
|
319
|
-
|
|
320
|
-
|
|
470
|
+
function checkAnalytics(a) {
|
|
471
|
+
var cls = a.className || '';
|
|
321
472
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
if (preloaded.has(key)) return false
|
|
473
|
+
// v1.0.11: используем свойства <a> напрямую вместо new URL() (perf)
|
|
474
|
+
var host = a.hostname || '';
|
|
325
475
|
|
|
326
|
-
|
|
327
|
-
|
|
476
|
+
if (cls.indexOf('ym-') !== -1) return false
|
|
477
|
+
if (host === 'mc.yandex.ru' || host === 'metrika.yandex.ru') return false
|
|
328
478
|
|
|
329
|
-
|
|
330
|
-
|
|
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
|
|
331
482
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
var u = new URL(href, location.href)
|
|
340
|
-
pathname = u.pathname || ''
|
|
341
|
-
hash = u.hash || ''
|
|
342
|
-
} catch (e) {
|
|
343
|
-
pathname = ''
|
|
344
|
-
hash = ''
|
|
345
|
-
}
|
|
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
|
|
346
490
|
|
|
347
|
-
|
|
348
|
-
if (href.indexOf('/bitrix/') !== -1 || href.indexOf('sessid=') !== -1) return false
|
|
349
|
-
if (a.classList.contains('bx-ajax')) return false
|
|
491
|
+
return true
|
|
350
492
|
}
|
|
351
493
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
+
}
|
|
355
505
|
}
|
|
356
506
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
507
|
+
function resolveSpecMode(a) {
|
|
508
|
+
if (!useSpecRules || specMode === 'none') return 'none'
|
|
509
|
+
if (specMode !== 'prerender') return specMode
|
|
360
510
|
|
|
361
|
-
|
|
511
|
+
// specMode === 'prerender'
|
|
512
|
+
if (prerenderAll) return 'prerender'
|
|
513
|
+
if (whitelist) return 'prerender' // ссылки и так явно размечены
|
|
362
514
|
|
|
363
|
-
|
|
515
|
+
// иначе prerender только по ссылке:
|
|
516
|
+
if (a && a.dataset && (('prefetchPrerender' in a.dataset) || ('instantPrerender' in a.dataset))) {
|
|
517
|
+
return 'prerender'
|
|
518
|
+
}
|
|
519
|
+
return 'prefetch'
|
|
520
|
+
}
|
|
364
521
|
|
|
365
|
-
|
|
366
|
-
|
|
522
|
+
function preload(url, a) {
|
|
523
|
+
if (disabled) return
|
|
524
|
+
if (!isNetworkOk()) return
|
|
367
525
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
526
|
+
// v1.0.12: парсим URL один раз вместо двух
|
|
527
|
+
var parsed = parseUrl(url);
|
|
528
|
+
var requestUrl = parsed.requestUrl;
|
|
529
|
+
var key = parsed.key;
|
|
371
530
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
531
|
+
if (preloaded.has(key)) return
|
|
532
|
+
if (preloaded.size >= maxPreloads) {
|
|
533
|
+
preloaded.delete(preloaded.values().next().value);
|
|
534
|
+
}
|
|
535
|
+
preloaded.add(key);
|
|
375
536
|
|
|
376
|
-
|
|
377
|
-
if (host === 'mc.yandex.ru' || host === 'metrika.yandex.ru') return false
|
|
537
|
+
var mode = resolveSpecMode(a);
|
|
378
538
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
+
}
|
|
382
547
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
host === 'matomo.org' ||
|
|
386
|
-
host.endsWith('.matomo.org') ||
|
|
387
|
-
host === 'piwik.org' ||
|
|
388
|
-
host.endsWith('.piwik.org')
|
|
389
|
-
) return false
|
|
548
|
+
doPreload(requestUrl, key, mode);
|
|
549
|
+
}
|
|
390
550
|
|
|
391
|
-
|
|
392
|
-
|
|
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
|
+
}
|
|
393
570
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
try {
|
|
397
|
-
var u = new URL(url, location.href)
|
|
398
|
-
return u.origin + u.pathname + u.search
|
|
399
|
-
} catch (e) {
|
|
400
|
-
return url
|
|
571
|
+
if (isIOS || !supportsLinkPrefetch) preloadFetch(requestUrl, key);
|
|
572
|
+
else preloadLink(requestUrl, key);
|
|
401
573
|
}
|
|
402
|
-
}
|
|
403
574
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
return u.href
|
|
410
|
-
} catch (e) {
|
|
411
|
-
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
|
+
}
|
|
412
580
|
}
|
|
413
|
-
}
|
|
414
581
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
+
}
|
|
418
594
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
+
}
|
|
422
603
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
604
|
+
// Планируем flush если ещё не запланирован
|
|
605
|
+
if (!specFlushTimer) {
|
|
606
|
+
var rIC = window.requestIdleCallback || function (cb) { setTimeout(cb, 1); };
|
|
607
|
+
specFlushTimer = rIC(flushSpecBuffer, { timeout: 50 });
|
|
608
|
+
}
|
|
426
609
|
}
|
|
427
|
-
return 'prefetch'
|
|
428
|
-
}
|
|
429
610
|
|
|
430
|
-
|
|
431
|
-
|
|
611
|
+
// v1.0.13: вставляем все накопленные URL одним JSON
|
|
612
|
+
function flushSpecBuffer() {
|
|
613
|
+
specFlushTimer = 0;
|
|
432
614
|
|
|
433
|
-
|
|
434
|
-
|
|
615
|
+
// v1.0.13: проверяем disabled (flush может быть вызван после destroy())
|
|
616
|
+
if (disabled) return
|
|
435
617
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
618
|
+
var head = document.head;
|
|
619
|
+
if (!head) return
|
|
620
|
+
|
|
621
|
+
var rules = {};
|
|
622
|
+
|
|
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
|
+
}
|
|
629
|
+
|
|
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
|
+
}
|
|
441
636
|
|
|
442
|
-
|
|
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
|
+
}
|
|
443
648
|
|
|
444
|
-
|
|
445
|
-
|
|
649
|
+
// Если нет правил — выходим
|
|
650
|
+
if (!rules.prefetch && !rules.prerender) return
|
|
446
651
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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);
|
|
451
659
|
}
|
|
452
660
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
+
}
|
|
456
699
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
700
|
+
l.onload = cleanup;
|
|
701
|
+
l.onerror = function () { preloaded.delete(key); cleanup(); };
|
|
702
|
+
head.appendChild(l);
|
|
703
|
+
}
|
|
460
704
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
705
|
+
function preloadFetch(url, key) {
|
|
706
|
+
// v1.0.10: если fetch недоступен, откатываем ключ
|
|
707
|
+
if (typeof fetch !== 'function') { preloaded.delete(key); return }
|
|
464
708
|
|
|
465
|
-
|
|
466
|
-
rules[mode] = [{ source: 'list', urls: [url] }]
|
|
467
|
-
s.textContent = JSON.stringify(rules)
|
|
468
|
-
head.appendChild(s)
|
|
469
|
-
}
|
|
709
|
+
inFlight++;
|
|
470
710
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
711
|
+
// v1.0.11: settled флаг — защита от двойного вызова done() при abort+catch
|
|
712
|
+
var settled = false;
|
|
713
|
+
var ctrl = null;
|
|
714
|
+
var tid = 0;
|
|
474
715
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
l.as = 'document'
|
|
479
|
-
try { l.fetchPriority = 'low' } catch (e) {}
|
|
716
|
+
function done(success) {
|
|
717
|
+
if (settled) return
|
|
718
|
+
settled = true;
|
|
480
719
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
+
}
|
|
486
727
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
+
}
|
|
491
737
|
|
|
492
|
-
|
|
493
|
-
|
|
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
|
+
}
|
|
494
759
|
|
|
495
|
-
|
|
496
|
-
|
|
760
|
+
// v1.0.13: referrerPolicy для cross-origin (приватность)
|
|
761
|
+
if (isCrossOrigin) {
|
|
762
|
+
opts.referrerPolicy = 'no-referrer';
|
|
763
|
+
}
|
|
497
764
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
+
}
|
|
503
779
|
}
|
|
504
780
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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();
|
|
510
800
|
}
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
if (!r || !r.ok) preloaded.delete(key)
|
|
518
|
-
})
|
|
519
|
-
.catch(function () {
|
|
520
|
-
if (tid) clearTimeout(tid)
|
|
521
|
-
preloaded.delete(key)
|
|
522
|
-
})
|
|
523
|
-
} catch (e) {
|
|
524
|
-
if (tid) clearTimeout(tid)
|
|
525
|
-
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
|
+
});
|
|
526
807
|
}
|
|
527
|
-
}
|
|
528
808
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
+
}
|
|
540
830
|
}
|
|
541
|
-
}
|
|
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);
|
|
542
923
|
},
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
924
|
+
destroy: destroy,
|
|
925
|
+
// v1.0.11: публичный метод для ручного обновления currentKey (SPA)
|
|
926
|
+
refresh: updateCurrentKey
|
|
927
|
+
};
|
|
547
928
|
|
|
548
|
-
|
|
549
|
-
if (!vpObserver) return
|
|
550
|
-
document.querySelectorAll('a').forEach(function (a) {
|
|
551
|
-
if (canPreload(a)) vpObserver.observe(a)
|
|
552
|
-
})
|
|
929
|
+
return api
|
|
553
930
|
}
|
|
554
931
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
}
|
|
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
|
+
});
|
|
577
953
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
version: '1.0.9',
|
|
582
|
-
preload: function (url) { preload(url) }
|
|
954
|
+
// Регистрируем в window
|
|
955
|
+
window.PrefetchRu = api;
|
|
956
|
+
if (!window.Prefetch) window.Prefetch = api;
|
|
583
957
|
}
|
|
584
|
-
|
|
958
|
+
|
|
959
|
+
})();
|