@freshjuice/zest 2.1.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 +40 -0
- package/dist/zest.de.js +763 -51
- package/dist/zest.de.js.map +1 -1
- package/dist/zest.de.min.js +1 -1
- package/dist/zest.en.js +763 -51
- package/dist/zest.en.js.map +1 -1
- package/dist/zest.en.min.js +1 -1
- package/dist/zest.es.js +763 -51
- package/dist/zest.es.js.map +1 -1
- package/dist/zest.es.min.js +1 -1
- package/dist/zest.esm.js +763 -51
- package/dist/zest.esm.js.map +1 -1
- package/dist/zest.esm.min.js +1 -1
- package/dist/zest.fr.js +763 -51
- package/dist/zest.fr.js.map +1 -1
- package/dist/zest.fr.min.js +1 -1
- package/dist/zest.headless.d.ts +40 -0
- package/dist/zest.headless.esm.js +717 -33
- package/dist/zest.headless.esm.js.map +1 -1
- package/dist/zest.headless.esm.min.js +1 -1
- package/dist/zest.it.js +763 -51
- package/dist/zest.it.js.map +1 -1
- package/dist/zest.it.min.js +1 -1
- package/dist/zest.ja.js +763 -51
- package/dist/zest.ja.js.map +1 -1
- package/dist/zest.ja.min.js +1 -1
- package/dist/zest.js +763 -51
- package/dist/zest.js.map +1 -1
- package/dist/zest.min.js +1 -1
- package/dist/zest.nl.js +763 -51
- package/dist/zest.nl.js.map +1 -1
- package/dist/zest.nl.min.js +1 -1
- package/dist/zest.pl.js +763 -51
- package/dist/zest.pl.js.map +1 -1
- package/dist/zest.pl.min.js +1 -1
- package/dist/zest.pt.js +763 -51
- package/dist/zest.pt.js.map +1 -1
- package/dist/zest.pt.min.js +1 -1
- package/dist/zest.ru.js +763 -51
- package/dist/zest.ru.js.map +1 -1
- package/dist/zest.ru.min.js +1 -1
- package/dist/zest.uk.js +763 -51
- package/dist/zest.uk.js.map +1 -1
- package/dist/zest.uk.min.js +1 -1
- package/dist/zest.zh.js +763 -51
- 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 +49 -0
- package/src/core/element-interceptor.js +374 -0
- package/src/core/network-interceptor.js +289 -0
- package/src/core/pattern-matcher.js +37 -0
- package/src/core-lifecycle.js +43 -5
- package/src/index.js +46 -18
- package/src/types/zest.d.ts +40 -0
- package/src/types/zest.headless.d.ts +40 -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
|
+
}
|
|
@@ -51,10 +51,47 @@ export const DEFAULT_PATTERNS = {
|
|
|
51
51
|
|
|
52
52
|
let patterns = { ...DEFAULT_PATTERNS };
|
|
53
53
|
|
|
54
|
+
/** Escape a string so it can be embedded in a regex literal verbatim. */
|
|
55
|
+
function escapeRegex(value) {
|
|
56
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Append patterns to a single category without replacing what's already
|
|
61
|
+
* there. Used by `essentialKeys` and `essentialPatterns` config to extend
|
|
62
|
+
* the strictly-necessary category with consumer-specific entries while
|
|
63
|
+
* keeping the built-in defaults (zest_*, csrf*, xsrf*, etc.).
|
|
64
|
+
*
|
|
65
|
+
* `keys` is an array of exact storage/cookie names; each one is
|
|
66
|
+
* compiled as a fully-anchored regex via `escapeRegex`.
|
|
67
|
+
* `patternStrings` is an array of regex source strings, each validated
|
|
68
|
+
* via `safeRegExp`. Invalid entries are dropped silently.
|
|
69
|
+
*/
|
|
70
|
+
export function appendPatternsToCategory(category, { keys = [], patternStrings = [] } = {}) {
|
|
71
|
+
if (!patterns[category]) patterns[category] = [];
|
|
72
|
+
|
|
73
|
+
for (const key of keys) {
|
|
74
|
+
if (typeof key !== 'string' || !key) continue;
|
|
75
|
+
const re = safeRegExp(`^${escapeRegex(key)}$`);
|
|
76
|
+
if (re) patterns[category].push(re);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const p of patternStrings) {
|
|
80
|
+
if (typeof p !== 'string' || !p) continue;
|
|
81
|
+
const re = safeRegExp(p);
|
|
82
|
+
if (re) patterns[category].push(re);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
54
86
|
/**
|
|
55
87
|
* Set custom patterns. User-supplied strings are validated with safeRegExp,
|
|
56
88
|
* which rejects catastrophic-backtracking shapes and syntax errors.
|
|
57
89
|
* Invalid patterns are silently dropped with a console warning.
|
|
90
|
+
*
|
|
91
|
+
* Note: this REPLACES the patterns for any category present in
|
|
92
|
+
* `customPatterns`. To extend the essential category without losing the
|
|
93
|
+
* built-in defaults, use `appendPatternsToCategory()` (or pass
|
|
94
|
+
* `essentialKeys` / `essentialPatterns` to `Zest.init()`).
|
|
58
95
|
*/
|
|
59
96
|
export function setPatterns(customPatterns) {
|
|
60
97
|
patterns = { ...DEFAULT_PATTERNS };
|
package/src/core-lifecycle.js
CHANGED
|
@@ -11,7 +11,9 @@
|
|
|
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 {
|
|
14
|
+
import { interceptNetwork, setConsentChecker as setNetworkChecker } from './core/network-interceptor.js';
|
|
15
|
+
import { interceptElements, setConsentChecker as setElementChecker, replayElements } from './core/element-interceptor.js';
|
|
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';
|
|
17
19
|
import { safeInvoke } from './core/security.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
|
/**
|
|
@@ -73,13 +80,44 @@ export function coreInit(userConfig = {}) {
|
|
|
73
80
|
setPatterns(currentConfig.patterns);
|
|
74
81
|
}
|
|
75
82
|
|
|
83
|
+
// Append consumer-declared strictly-necessary entries on top of
|
|
84
|
+
// whatever's already in the essential category. This is the friendly
|
|
85
|
+
// alternative to overriding via `patterns.essential` directly.
|
|
86
|
+
if (
|
|
87
|
+
(Array.isArray(currentConfig.essentialKeys) && currentConfig.essentialKeys.length > 0) ||
|
|
88
|
+
(Array.isArray(currentConfig.essentialPatterns) && currentConfig.essentialPatterns.length > 0)
|
|
89
|
+
) {
|
|
90
|
+
appendPatternsToCategory('essential', {
|
|
91
|
+
keys: currentConfig.essentialKeys,
|
|
92
|
+
patternStrings: currentConfig.essentialPatterns
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
76
96
|
setCookieChecker(checkConsent);
|
|
77
97
|
setStorageChecker(checkConsent);
|
|
78
98
|
setScriptChecker(checkConsent);
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
99
|
+
setNetworkChecker(checkConsent);
|
|
100
|
+
setElementChecker(checkConsent);
|
|
101
|
+
|
|
102
|
+
// Interceptor toggles. By default everything is intercepted (back-compat
|
|
103
|
+
// with v2.0 / v2.1). Consumers that gate scripts and storage themselves
|
|
104
|
+
// can opt out per channel via `intercept: { storage: false, … }`.
|
|
105
|
+
const intercept = currentConfig.intercept || { cookies: true, storage: true, scripts: true, network: true };
|
|
106
|
+
if (intercept.cookies !== false) interceptCookies();
|
|
107
|
+
if (intercept.storage !== false) interceptStorage();
|
|
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);
|
|
116
|
+
startScriptBlocking(currentConfig.mode, currentConfig.blockedDomains);
|
|
117
|
+
}
|
|
118
|
+
if (intercept.network !== false) {
|
|
119
|
+
interceptNetwork(currentConfig.mode, currentConfig.blockedDomains);
|
|
120
|
+
}
|
|
83
121
|
|
|
84
122
|
const consent = loadConsent();
|
|
85
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
|
@@ -83,6 +83,27 @@ export interface ZestCallbacks {
|
|
|
83
83
|
onReady?: (consent: ConsentState) => void;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Granular toggles for Zest's interceptor layer. Default is `true` on
|
|
88
|
+
* every channel — back-compat with previous versions.
|
|
89
|
+
*
|
|
90
|
+
* Consumers that gate optional scripts and storage themselves can
|
|
91
|
+
* disable interception per channel and use Zest as a pure consent-state
|
|
92
|
+
* engine.
|
|
93
|
+
*/
|
|
94
|
+
export interface InterceptToggles {
|
|
95
|
+
cookies?: boolean;
|
|
96
|
+
storage?: boolean;
|
|
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;
|
|
105
|
+
}
|
|
106
|
+
|
|
86
107
|
/** Configuration accepted by `init()` and `window.ZestConfig`. */
|
|
87
108
|
export interface InitOptions {
|
|
88
109
|
/** Display language. `'auto'` detects from `<html lang>` / browser. */
|
|
@@ -110,6 +131,25 @@ export interface InitOptions {
|
|
|
110
131
|
respectDNT?: boolean;
|
|
111
132
|
/** What to do when DNT/GPC is on. Default `'reject'`. */
|
|
112
133
|
dntBehavior?: DNTBehavior;
|
|
134
|
+
/** Disable individual interceptors. Default: all on. */
|
|
135
|
+
intercept?: InterceptToggles;
|
|
136
|
+
/**
|
|
137
|
+
* Exact storage / cookie names to treat as strictly-necessary. Each
|
|
138
|
+
* is appended to the essential category as a fully-anchored regex,
|
|
139
|
+
* so the built-in essential patterns (zest_*, csrf*, …) stay intact.
|
|
140
|
+
*/
|
|
141
|
+
essentialKeys?: string[];
|
|
142
|
+
/**
|
|
143
|
+
* Regex source strings to treat as strictly-necessary. Validated via
|
|
144
|
+
* safeRegExp, appended (not replaced) to the essential category.
|
|
145
|
+
*/
|
|
146
|
+
essentialPatterns?: string[];
|
|
147
|
+
/**
|
|
148
|
+
* Override patterns per category. Note: this REPLACES the category's
|
|
149
|
+
* built-in patterns. Prefer `essentialKeys` / `essentialPatterns` if
|
|
150
|
+
* you only want to add to the essential category.
|
|
151
|
+
*/
|
|
152
|
+
patterns?: Partial<Record<ConsentCategory, string[]>>;
|
|
113
153
|
/** Consumer callbacks. */
|
|
114
154
|
callbacks?: ZestCallbacks;
|
|
115
155
|
/** Anything else — Zest tolerates unknown keys at runtime. */
|
|
@@ -70,6 +70,27 @@ export interface ZestCallbacks {
|
|
|
70
70
|
onReady?: (consent: ConsentState) => void;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Granular toggles for Zest's interceptor layer. Default is `true` on
|
|
75
|
+
* every channel — back-compat with previous versions.
|
|
76
|
+
*
|
|
77
|
+
* Consumers that gate optional scripts and storage themselves (typical
|
|
78
|
+
* for headless integrations) can disable interception per channel and
|
|
79
|
+
* use Zest as a pure consent-state engine.
|
|
80
|
+
*/
|
|
81
|
+
export interface InterceptToggles {
|
|
82
|
+
cookies?: boolean;
|
|
83
|
+
storage?: boolean;
|
|
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;
|
|
92
|
+
}
|
|
93
|
+
|
|
73
94
|
/** Configuration accepted by `init()`. */
|
|
74
95
|
export interface InitOptions {
|
|
75
96
|
/** Respect Do Not Track / Global Privacy Control. Default `true`. */
|
|
@@ -78,6 +99,25 @@ export interface InitOptions {
|
|
|
78
99
|
dntBehavior?: DNTBehavior;
|
|
79
100
|
/** Cookie expiration in days. Default `365`. */
|
|
80
101
|
expiration?: number;
|
|
102
|
+
/** Disable individual interceptors. Default: all on. */
|
|
103
|
+
intercept?: InterceptToggles;
|
|
104
|
+
/**
|
|
105
|
+
* Exact storage / cookie names to treat as strictly-necessary. Each
|
|
106
|
+
* is appended to the essential category as a fully-anchored regex,
|
|
107
|
+
* so the built-in essential patterns (zest_*, csrf*, …) stay intact.
|
|
108
|
+
*/
|
|
109
|
+
essentialKeys?: string[];
|
|
110
|
+
/**
|
|
111
|
+
* Regex source strings to treat as strictly-necessary. Validated via
|
|
112
|
+
* safeRegExp, appended (not replaced) to the essential category.
|
|
113
|
+
*/
|
|
114
|
+
essentialPatterns?: string[];
|
|
115
|
+
/**
|
|
116
|
+
* Override patterns per category. Note: this REPLACES the category's
|
|
117
|
+
* built-in patterns. Prefer `essentialKeys` / `essentialPatterns` if
|
|
118
|
+
* you only want to add to the essential category.
|
|
119
|
+
*/
|
|
120
|
+
patterns?: Partial<Record<ConsentCategory, string[]>>;
|
|
81
121
|
/** Consumer callbacks. */
|
|
82
122
|
callbacks?: ZestCallbacks;
|
|
83
123
|
/** Anything else — Zest tolerates unknown keys at runtime. */
|
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",
|