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