@freshjuice/zest 2.2.0 → 2.3.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/dist/zest.d.ts +7 -0
- package/dist/zest.de.js +658 -50
- package/dist/zest.de.js.map +1 -1
- package/dist/zest.de.min.js +1 -1
- package/dist/zest.en.js +658 -50
- package/dist/zest.en.js.map +1 -1
- package/dist/zest.en.min.js +1 -1
- package/dist/zest.es.js +658 -50
- package/dist/zest.es.js.map +1 -1
- package/dist/zest.es.min.js +1 -1
- package/dist/zest.esm.js +658 -50
- package/dist/zest.esm.js.map +1 -1
- package/dist/zest.esm.min.js +1 -1
- package/dist/zest.fr.js +658 -50
- package/dist/zest.fr.js.map +1 -1
- package/dist/zest.fr.min.js +1 -1
- package/dist/zest.headless.d.ts +7 -0
- package/dist/zest.headless.esm.js +612 -32
- package/dist/zest.headless.esm.js.map +1 -1
- package/dist/zest.headless.esm.min.js +1 -1
- package/dist/zest.it.js +658 -50
- package/dist/zest.it.js.map +1 -1
- package/dist/zest.it.min.js +1 -1
- package/dist/zest.ja.js +658 -50
- package/dist/zest.ja.js.map +1 -1
- package/dist/zest.ja.min.js +1 -1
- package/dist/zest.js +658 -50
- package/dist/zest.js.map +1 -1
- package/dist/zest.min.js +1 -1
- package/dist/zest.nl.js +658 -50
- package/dist/zest.nl.js.map +1 -1
- package/dist/zest.nl.min.js +1 -1
- package/dist/zest.pl.js +658 -50
- package/dist/zest.pl.js.map +1 -1
- package/dist/zest.pl.min.js +1 -1
- package/dist/zest.pt.js +658 -50
- package/dist/zest.pt.js.map +1 -1
- package/dist/zest.pt.min.js +1 -1
- package/dist/zest.ru.js +658 -50
- package/dist/zest.ru.js.map +1 -1
- package/dist/zest.ru.min.js +1 -1
- package/dist/zest.uk.js +658 -50
- package/dist/zest.uk.js.map +1 -1
- package/dist/zest.uk.min.js +1 -1
- package/dist/zest.zh.js +658 -50
- package/dist/zest.zh.js.map +1 -1
- package/dist/zest.zh.min.js +1 -1
- package/package.json +1 -1
- package/src/config/defaults.js +2 -1
- package/src/core/element-interceptor.js +374 -0
- package/src/core/network-interceptor.js +289 -0
- package/src/core-lifecycle.js +20 -1
- package/src/index.js +46 -18
- package/src/types/zest.d.ts +7 -0
- package/src/types/zest.headless.d.ts +7 -0
- package/zest.config.schema.json +26 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Network Interceptor - Intercepts fetch / XHR / sendBeacon calls
|
|
3
|
+
*
|
|
4
|
+
* Why this exists separately from the script blocker:
|
|
5
|
+
*
|
|
6
|
+
* Modern CMSes (HubSpot, Cloudflare Zaraz, server-side GTM, Shopify,
|
|
7
|
+
* Webflow) increasingly proxy their tracker code through the site's own
|
|
8
|
+
* origin to defeat ad-blockers. The <script> tag itself is first-party
|
|
9
|
+
* (e.g. /hs/scriptloader/{id}.js) so a hostname-based script blocker
|
|
10
|
+
* cannot match it. But at RUNTIME that script still phones home to the
|
|
11
|
+
* vendor's analytics endpoint via fetch / XHR / sendBeacon — and THAT
|
|
12
|
+
* URL is third-party.
|
|
13
|
+
*
|
|
14
|
+
* This interceptor sits on the network layer and uses the same
|
|
15
|
+
* customBlockedDomains + mode-based tracker list as the script blocker.
|
|
16
|
+
* Whatever the user told Zest to block (typically generated by an AI
|
|
17
|
+
* audit) gets blocked regardless of which API the tracker uses.
|
|
18
|
+
*
|
|
19
|
+
* No replay: network calls are one-shot and time-sensitive. Replaying a
|
|
20
|
+
* stale beacon after consent would create confusing / duplicated data,
|
|
21
|
+
* so blocked requests are dropped, not queued.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { getCategoryForScript, isThirdParty } from './known-trackers.js';
|
|
25
|
+
|
|
26
|
+
// Originals captured at install time. Stored for restoration tests and
|
|
27
|
+
// for any internal Zest network calls we may add later.
|
|
28
|
+
let originalFetch = null;
|
|
29
|
+
let originalXhrOpen = null;
|
|
30
|
+
let originalXhrSend = null;
|
|
31
|
+
let originalSendBeacon = null;
|
|
32
|
+
|
|
33
|
+
let blockingMode = 'safe';
|
|
34
|
+
let customBlockedDomains = [];
|
|
35
|
+
let installed = false;
|
|
36
|
+
|
|
37
|
+
let checkConsent = () => false;
|
|
38
|
+
|
|
39
|
+
const BLOCKABLE_CATEGORIES = new Set(['functional', 'analytics', 'marketing']);
|
|
40
|
+
|
|
41
|
+
export function setConsentChecker(fn) {
|
|
42
|
+
checkConsent = fn;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function setBlockingMode(mode) {
|
|
46
|
+
blockingMode = mode;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function setCustomBlockedDomains(domains) {
|
|
50
|
+
customBlockedDomains = Array.isArray(domains) ? domains : [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve a Request | URL | string to an absolute URL string. Returns
|
|
55
|
+
* null if the input cannot be parsed — callers treat null as "do not
|
|
56
|
+
* block" (we'd rather let an opaque request through than crash the page).
|
|
57
|
+
*/
|
|
58
|
+
function resolveUrl(input) {
|
|
59
|
+
try {
|
|
60
|
+
if (typeof input === 'string') {
|
|
61
|
+
return new URL(input, location.href).href;
|
|
62
|
+
}
|
|
63
|
+
if (input && typeof input === 'object') {
|
|
64
|
+
if (typeof input.url === 'string') {
|
|
65
|
+
// Request object
|
|
66
|
+
return new URL(input.url, location.href).href;
|
|
67
|
+
}
|
|
68
|
+
if (typeof input.href === 'string') {
|
|
69
|
+
// URL object
|
|
70
|
+
return input.href;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
} catch (e) {
|
|
74
|
+
// fallthrough
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Match a URL against the user's customBlockedDomains list. Mirrors
|
|
81
|
+
* matchesCustomDomains() in script-blocker.js — kept inline rather than
|
|
82
|
+
* shared so each interceptor can be lifted independently.
|
|
83
|
+
*/
|
|
84
|
+
function matchesCustomDomains(hostname) {
|
|
85
|
+
if (!hostname || customBlockedDomains.length === 0) return null;
|
|
86
|
+
const host = hostname.toLowerCase();
|
|
87
|
+
for (const entry of customBlockedDomains) {
|
|
88
|
+
const domain = (typeof entry === 'string' ? entry : entry?.domain || '').toLowerCase();
|
|
89
|
+
if (!domain) continue;
|
|
90
|
+
const category = typeof entry === 'string'
|
|
91
|
+
? 'marketing'
|
|
92
|
+
: (BLOCKABLE_CATEGORIES.has(entry?.category) ? entry.category : 'marketing');
|
|
93
|
+
if (host === domain || host.endsWith('.' + domain)) {
|
|
94
|
+
return category;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Decide whether a URL should be blocked and return its category, or
|
|
102
|
+
* null if it should pass through. Priority: customBlockedDomains >
|
|
103
|
+
* mode-based tracker list (matching script-blocker priority).
|
|
104
|
+
*/
|
|
105
|
+
function getBlockCategory(url) {
|
|
106
|
+
if (!url) return null;
|
|
107
|
+
let hostname;
|
|
108
|
+
try {
|
|
109
|
+
hostname = new URL(url, location.href).hostname;
|
|
110
|
+
} catch (e) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const customCategory = matchesCustomDomains(hostname);
|
|
115
|
+
if (customCategory) return customCategory;
|
|
116
|
+
|
|
117
|
+
switch (blockingMode) {
|
|
118
|
+
case 'manual':
|
|
119
|
+
return null;
|
|
120
|
+
case 'safe':
|
|
121
|
+
case 'strict':
|
|
122
|
+
return getCategoryForScript(url, blockingMode);
|
|
123
|
+
case 'doomsday':
|
|
124
|
+
if (isThirdParty(url)) {
|
|
125
|
+
return getCategoryForScript(url, 'strict') || 'marketing';
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
default:
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Should the request be blocked right now? Returns the category that
|
|
135
|
+
* caused the block (for logging / callbacks later) or null.
|
|
136
|
+
*/
|
|
137
|
+
function shouldBlock(url) {
|
|
138
|
+
const category = getBlockCategory(url);
|
|
139
|
+
if (!category) return null;
|
|
140
|
+
if (checkConsent(category)) return null;
|
|
141
|
+
return category;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Construct an empty, successful-looking Response for a blocked fetch.
|
|
146
|
+
* Status 204 (No Content) is the most honest "we deliberately returned
|
|
147
|
+
* nothing" signal. Trackers that .then(r => r.json()) will get an empty
|
|
148
|
+
* body and typically silently move on.
|
|
149
|
+
*/
|
|
150
|
+
function blockedResponse() {
|
|
151
|
+
// Some environments (older browsers, strict CSP) may not have Response
|
|
152
|
+
// — fall back to a thenable shape the most common tracker code expects.
|
|
153
|
+
if (typeof Response === 'function') {
|
|
154
|
+
return new Response(null, { status: 204, statusText: 'Blocked by Zest' });
|
|
155
|
+
}
|
|
156
|
+
const fake = {
|
|
157
|
+
ok: false,
|
|
158
|
+
status: 204,
|
|
159
|
+
statusText: 'Blocked by Zest',
|
|
160
|
+
json: () => Promise.resolve({}),
|
|
161
|
+
text: () => Promise.resolve(''),
|
|
162
|
+
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0))
|
|
163
|
+
};
|
|
164
|
+
return fake;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Install fetch hook. Captures the original so we can both restore it
|
|
169
|
+
* later and use it for any internal Zest network calls.
|
|
170
|
+
*/
|
|
171
|
+
function patchFetch() {
|
|
172
|
+
if (typeof window === 'undefined' || typeof window.fetch !== 'function') return;
|
|
173
|
+
originalFetch = window.fetch.bind(window);
|
|
174
|
+
|
|
175
|
+
window.fetch = function zestPatchedFetch(input, init) {
|
|
176
|
+
const url = resolveUrl(input);
|
|
177
|
+
if (shouldBlock(url)) {
|
|
178
|
+
return Promise.resolve(blockedResponse());
|
|
179
|
+
}
|
|
180
|
+
return originalFetch(input, init);
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Install XMLHttpRequest hook. We patch .open() to capture the URL on
|
|
186
|
+
* the instance, then .send() to decide whether to abort. Using a hidden
|
|
187
|
+
* symbol on the instance avoids leaking state and survives any wrapping
|
|
188
|
+
* code that reassigns request properties.
|
|
189
|
+
*/
|
|
190
|
+
const URL_KEY = Symbol('zestUrl');
|
|
191
|
+
|
|
192
|
+
function patchXhr() {
|
|
193
|
+
if (typeof XMLHttpRequest === 'undefined') return;
|
|
194
|
+
const proto = XMLHttpRequest.prototype;
|
|
195
|
+
originalXhrOpen = proto.open;
|
|
196
|
+
originalXhrSend = proto.send;
|
|
197
|
+
|
|
198
|
+
proto.open = function (method, url, ...rest) {
|
|
199
|
+
this[URL_KEY] = typeof url === 'string' ? url : (url && url.href) || '';
|
|
200
|
+
return originalXhrOpen.call(this, method, url, ...rest);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
proto.send = function (body) {
|
|
204
|
+
const url = this[URL_KEY];
|
|
205
|
+
if (shouldBlock(url)) {
|
|
206
|
+
// Mimic the failure mode of a network error: queueMicrotask is
|
|
207
|
+
// used so consumers that synchronously attach handlers after
|
|
208
|
+
// .send() still receive the events.
|
|
209
|
+
const xhr = this;
|
|
210
|
+
queueMicrotask(() => {
|
|
211
|
+
try {
|
|
212
|
+
// Best-effort — readonly props in some environments
|
|
213
|
+
Object.defineProperty(xhr, 'readyState', { value: 4, configurable: true });
|
|
214
|
+
Object.defineProperty(xhr, 'status', { value: 0, configurable: true });
|
|
215
|
+
} catch (e) {
|
|
216
|
+
// ignore
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
xhr.dispatchEvent(new Event('error'));
|
|
220
|
+
xhr.dispatchEvent(new Event('loadend'));
|
|
221
|
+
} catch (e) {
|
|
222
|
+
// ignore
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
return originalXhrSend.call(this, body);
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Install navigator.sendBeacon hook. Returning false matches the spec's
|
|
233
|
+
* "data was not queued" semantics; trackers that check the return value
|
|
234
|
+
* fall back to fetch (which we also block) or give up.
|
|
235
|
+
*/
|
|
236
|
+
function patchSendBeacon() {
|
|
237
|
+
if (typeof navigator === 'undefined' || typeof navigator.sendBeacon !== 'function') return;
|
|
238
|
+
originalSendBeacon = navigator.sendBeacon.bind(navigator);
|
|
239
|
+
|
|
240
|
+
navigator.sendBeacon = function zestPatchedSendBeacon(url, data) {
|
|
241
|
+
if (shouldBlock(typeof url === 'string' ? url : (url && url.href) || '')) {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
return originalSendBeacon(url, data);
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Install all network hooks. Safe to call multiple times — subsequent
|
|
250
|
+
* calls just refresh mode + custom domain config without re-wrapping.
|
|
251
|
+
*/
|
|
252
|
+
export function interceptNetwork(mode = 'safe', customDomains = []) {
|
|
253
|
+
blockingMode = mode;
|
|
254
|
+
customBlockedDomains = Array.isArray(customDomains) ? customDomains : [];
|
|
255
|
+
|
|
256
|
+
if (installed) return true;
|
|
257
|
+
patchFetch();
|
|
258
|
+
patchXhr();
|
|
259
|
+
patchSendBeacon();
|
|
260
|
+
installed = true;
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Test helper / opt-out. Restores the original APIs. Not called by
|
|
266
|
+
* coreInit — exposed for unit tests and headless consumers that need
|
|
267
|
+
* to tear down their environment between consent flows.
|
|
268
|
+
*/
|
|
269
|
+
export function restoreNetwork() {
|
|
270
|
+
if (!installed) return;
|
|
271
|
+
if (originalFetch && typeof window !== 'undefined') window.fetch = originalFetch;
|
|
272
|
+
if (originalXhrOpen && typeof XMLHttpRequest !== 'undefined') {
|
|
273
|
+
XMLHttpRequest.prototype.open = originalXhrOpen;
|
|
274
|
+
}
|
|
275
|
+
if (originalXhrSend && typeof XMLHttpRequest !== 'undefined') {
|
|
276
|
+
XMLHttpRequest.prototype.send = originalXhrSend;
|
|
277
|
+
}
|
|
278
|
+
if (originalSendBeacon && typeof navigator !== 'undefined') {
|
|
279
|
+
navigator.sendBeacon = originalSendBeacon;
|
|
280
|
+
}
|
|
281
|
+
installed = false;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* For tests: query install state.
|
|
286
|
+
*/
|
|
287
|
+
export function isInstalled() {
|
|
288
|
+
return installed;
|
|
289
|
+
}
|
package/src/core-lifecycle.js
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
import { interceptCookies, setConsentChecker as setCookieChecker, replayCookies } from './core/cookie-interceptor.js';
|
|
12
12
|
import { interceptStorage, setConsentChecker as setStorageChecker, replayStorage } from './core/storage-interceptor.js';
|
|
13
13
|
import { startScriptBlocking, setConsentChecker as setScriptChecker, replayScripts } from './core/script-blocker.js';
|
|
14
|
+
import { interceptNetwork, setConsentChecker as setNetworkChecker } from './core/network-interceptor.js';
|
|
15
|
+
import { interceptElements, setConsentChecker as setElementChecker, replayElements } from './core/element-interceptor.js';
|
|
14
16
|
import { setPatterns, appendPatternsToCategory } from './core/pattern-matcher.js';
|
|
15
17
|
import { getCategoryIds } from './core/categories.js';
|
|
16
18
|
import { isDoNotTrackEnabled } from './core/dnt.js';
|
|
@@ -42,6 +44,11 @@ function replayAll(categories) {
|
|
|
42
44
|
replayCookies(categories);
|
|
43
45
|
replayStorage(categories);
|
|
44
46
|
replayScripts(categories);
|
|
47
|
+
// Element-level replays (script/link/img/iframe URLs that were
|
|
48
|
+
// dropped at the prototype-setter / setAttribute layer). Network
|
|
49
|
+
// interceptor (fetch/XHR/sendBeacon) intentionally has no replay
|
|
50
|
+
// — beacons are one-shot and resending would duplicate analytics.
|
|
51
|
+
replayElements(categories);
|
|
45
52
|
}
|
|
46
53
|
|
|
47
54
|
/**
|
|
@@ -89,16 +96,28 @@ export function coreInit(userConfig = {}) {
|
|
|
89
96
|
setCookieChecker(checkConsent);
|
|
90
97
|
setStorageChecker(checkConsent);
|
|
91
98
|
setScriptChecker(checkConsent);
|
|
99
|
+
setNetworkChecker(checkConsent);
|
|
100
|
+
setElementChecker(checkConsent);
|
|
92
101
|
|
|
93
102
|
// Interceptor toggles. By default everything is intercepted (back-compat
|
|
94
103
|
// with v2.0 / v2.1). Consumers that gate scripts and storage themselves
|
|
95
104
|
// can opt out per channel via `intercept: { storage: false, … }`.
|
|
96
|
-
const intercept = currentConfig.intercept || { cookies: true, storage: true, scripts: true };
|
|
105
|
+
const intercept = currentConfig.intercept || { cookies: true, storage: true, scripts: true, network: true };
|
|
97
106
|
if (intercept.cookies !== false) interceptCookies();
|
|
98
107
|
if (intercept.storage !== false) interceptStorage();
|
|
99
108
|
if (intercept.scripts !== false) {
|
|
109
|
+
// Element-level synchronous interception (prototype setters +
|
|
110
|
+
// setAttribute) installs BEFORE startScriptBlocking so that the
|
|
111
|
+
// moment any later script does `el.src = "https://tracker..."`,
|
|
112
|
+
// we drop the URL before the browser fetches. The MutationObserver
|
|
113
|
+
// inside startScriptBlocking remains as a defence-in-depth net for
|
|
114
|
+
// anything that slips past (e.g. nodes constructed via cloneNode).
|
|
115
|
+
interceptElements(currentConfig.mode, currentConfig.blockedDomains);
|
|
100
116
|
startScriptBlocking(currentConfig.mode, currentConfig.blockedDomains);
|
|
101
117
|
}
|
|
118
|
+
if (intercept.network !== false) {
|
|
119
|
+
interceptNetwork(currentConfig.mode, currentConfig.blockedDomains);
|
|
120
|
+
}
|
|
102
121
|
|
|
103
122
|
const consent = loadConsent();
|
|
104
123
|
initialized = true;
|
package/src/index.js
CHANGED
|
@@ -119,18 +119,29 @@ function handleCloseModal() {
|
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
/**
|
|
122
|
-
*
|
|
122
|
+
* UI mount guard. We split UI mounting (which needs `<body>` and a parsed
|
|
123
|
+
* DOM) from interceptor installation (which must happen on script eval to
|
|
124
|
+
* gate any later `defer` / `async` tracker scripts). `coreInit()` is
|
|
125
|
+
* idempotent so calling init() before the DOM is ready is safe — the UI
|
|
126
|
+
* portion just gets queued.
|
|
123
127
|
*/
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
128
|
+
let uiMounted = false;
|
|
129
|
+
|
|
130
|
+
function mountUI() {
|
|
131
|
+
if (uiMounted) return;
|
|
132
|
+
|
|
133
|
+
// Banner needs document.body to mount its host element. If body isn't
|
|
134
|
+
// there yet, requeue on DOMContentLoaded.
|
|
135
|
+
if (!document || !document.body) {
|
|
136
|
+
document.addEventListener('DOMContentLoaded', mountUI, { once: true });
|
|
137
|
+
return;
|
|
129
138
|
}
|
|
130
139
|
|
|
140
|
+
uiMounted = true;
|
|
131
141
|
const config = getActiveConfig();
|
|
142
|
+
const decision = hasConsentDecision();
|
|
132
143
|
|
|
133
|
-
if (!
|
|
144
|
+
if (!decision) {
|
|
134
145
|
showBanner({
|
|
135
146
|
onAcceptAll: handleAcceptAll,
|
|
136
147
|
onRejectAll: handleRejectAll,
|
|
@@ -140,7 +151,27 @@ function init(userConfig = {}) {
|
|
|
140
151
|
} else if (config?.showWidget) {
|
|
141
152
|
showWidget({ onClick: handleShowSettings });
|
|
142
153
|
}
|
|
154
|
+
}
|
|
143
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Initialize Zest with UI.
|
|
158
|
+
*
|
|
159
|
+
* Splits into two phases:
|
|
160
|
+
*
|
|
161
|
+
* 1. `coreInit()` runs synchronously: interceptors install on the
|
|
162
|
+
* cookie / storage / script / network channels immediately so any
|
|
163
|
+
* `defer` or `async` script that fires later is already gated.
|
|
164
|
+
* Critical — DOMContentLoaded fires AFTER `defer` scripts execute,
|
|
165
|
+
* so deferring interceptor install means trackers fire first.
|
|
166
|
+
*
|
|
167
|
+
* 2. UI mount (banner / widget) is queued until `<body>` exists. If
|
|
168
|
+
* this script runs in `<head>` while the document is still
|
|
169
|
+
* parsing, that means waiting for DOMContentLoaded; if it runs
|
|
170
|
+
* after, mount happens immediately.
|
|
171
|
+
*/
|
|
172
|
+
function init(userConfig = {}) {
|
|
173
|
+
coreInit(userConfig);
|
|
174
|
+
mountUI();
|
|
144
175
|
return Zest;
|
|
145
176
|
}
|
|
146
177
|
|
|
@@ -249,17 +280,14 @@ if (typeof window !== 'undefined') {
|
|
|
249
280
|
window.Zest = Zest;
|
|
250
281
|
}
|
|
251
282
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
document.addEventListener('DOMContentLoaded', autoInit);
|
|
261
|
-
} else {
|
|
262
|
-
autoInit();
|
|
283
|
+
// Run init() synchronously on script eval. init() itself splits the
|
|
284
|
+
// work — interceptors install now, UI mount waits for <body> if
|
|
285
|
+
// needed. No DOMContentLoaded wait at this layer: deferring init()
|
|
286
|
+
// would let any `defer` / `async` tracker script fire its network
|
|
287
|
+
// calls before our interceptors are in place.
|
|
288
|
+
const cfg = getConfig();
|
|
289
|
+
if (cfg.autoInit !== false) {
|
|
290
|
+
init(window.ZestConfig);
|
|
263
291
|
}
|
|
264
292
|
}
|
|
265
293
|
|
package/src/types/zest.d.ts
CHANGED
|
@@ -95,6 +95,13 @@ export interface InterceptToggles {
|
|
|
95
95
|
cookies?: boolean;
|
|
96
96
|
storage?: boolean;
|
|
97
97
|
scripts?: boolean;
|
|
98
|
+
/**
|
|
99
|
+
* fetch / XMLHttpRequest / navigator.sendBeacon interception. Catches
|
|
100
|
+
* trackers that ship via CMS first-party proxies (HubSpot, Cloudflare
|
|
101
|
+
* Zaraz, server-side GTM) where the <script> tag is same-origin but
|
|
102
|
+
* the runtime beacon is third-party.
|
|
103
|
+
*/
|
|
104
|
+
network?: boolean;
|
|
98
105
|
}
|
|
99
106
|
|
|
100
107
|
/** Configuration accepted by `init()` and `window.ZestConfig`. */
|
|
@@ -82,6 +82,13 @@ export interface InterceptToggles {
|
|
|
82
82
|
cookies?: boolean;
|
|
83
83
|
storage?: boolean;
|
|
84
84
|
scripts?: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* fetch / XMLHttpRequest / navigator.sendBeacon interception. Catches
|
|
87
|
+
* trackers that ship via CMS first-party proxies (HubSpot, Cloudflare
|
|
88
|
+
* Zaraz, server-side GTM) where the <script> tag is same-origin but
|
|
89
|
+
* the runtime beacon is third-party.
|
|
90
|
+
*/
|
|
91
|
+
network?: boolean;
|
|
85
92
|
}
|
|
86
93
|
|
|
87
94
|
/** Configuration accepted by `init()`. */
|
package/zest.config.schema.json
CHANGED
|
@@ -212,6 +212,32 @@
|
|
|
212
212
|
}
|
|
213
213
|
}
|
|
214
214
|
},
|
|
215
|
+
"intercept": {
|
|
216
|
+
"type": "object",
|
|
217
|
+
"description": "Granular toggles for Zest's interceptor layer. Default is true on every channel.",
|
|
218
|
+
"properties": {
|
|
219
|
+
"cookies": {
|
|
220
|
+
"type": "boolean",
|
|
221
|
+
"default": true,
|
|
222
|
+
"description": "Intercept document.cookie writes"
|
|
223
|
+
},
|
|
224
|
+
"storage": {
|
|
225
|
+
"type": "boolean",
|
|
226
|
+
"default": true,
|
|
227
|
+
"description": "Intercept localStorage / sessionStorage writes"
|
|
228
|
+
},
|
|
229
|
+
"scripts": {
|
|
230
|
+
"type": "boolean",
|
|
231
|
+
"default": true,
|
|
232
|
+
"description": "Block third-party tracker <script> tags before they execute"
|
|
233
|
+
},
|
|
234
|
+
"network": {
|
|
235
|
+
"type": "boolean",
|
|
236
|
+
"default": true,
|
|
237
|
+
"description": "Intercept fetch / XMLHttpRequest / navigator.sendBeacon calls. Catches trackers that ship via CMS first-party proxies (HubSpot, Cloudflare Zaraz, server-side GTM) where the <script> tag is same-origin but the runtime beacon is third-party."
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
},
|
|
215
241
|
"callbacks": {
|
|
216
242
|
"type": "object",
|
|
217
243
|
"description": "Callback functions for consent events",
|