@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.
Files changed (56) hide show
  1. package/dist/zest.d.ts +7 -0
  2. package/dist/zest.de.js +658 -50
  3. package/dist/zest.de.js.map +1 -1
  4. package/dist/zest.de.min.js +1 -1
  5. package/dist/zest.en.js +658 -50
  6. package/dist/zest.en.js.map +1 -1
  7. package/dist/zest.en.min.js +1 -1
  8. package/dist/zest.es.js +658 -50
  9. package/dist/zest.es.js.map +1 -1
  10. package/dist/zest.es.min.js +1 -1
  11. package/dist/zest.esm.js +658 -50
  12. package/dist/zest.esm.js.map +1 -1
  13. package/dist/zest.esm.min.js +1 -1
  14. package/dist/zest.fr.js +658 -50
  15. package/dist/zest.fr.js.map +1 -1
  16. package/dist/zest.fr.min.js +1 -1
  17. package/dist/zest.headless.d.ts +7 -0
  18. package/dist/zest.headless.esm.js +612 -32
  19. package/dist/zest.headless.esm.js.map +1 -1
  20. package/dist/zest.headless.esm.min.js +1 -1
  21. package/dist/zest.it.js +658 -50
  22. package/dist/zest.it.js.map +1 -1
  23. package/dist/zest.it.min.js +1 -1
  24. package/dist/zest.ja.js +658 -50
  25. package/dist/zest.ja.js.map +1 -1
  26. package/dist/zest.ja.min.js +1 -1
  27. package/dist/zest.js +658 -50
  28. package/dist/zest.js.map +1 -1
  29. package/dist/zest.min.js +1 -1
  30. package/dist/zest.nl.js +658 -50
  31. package/dist/zest.nl.js.map +1 -1
  32. package/dist/zest.nl.min.js +1 -1
  33. package/dist/zest.pl.js +658 -50
  34. package/dist/zest.pl.js.map +1 -1
  35. package/dist/zest.pl.min.js +1 -1
  36. package/dist/zest.pt.js +658 -50
  37. package/dist/zest.pt.js.map +1 -1
  38. package/dist/zest.pt.min.js +1 -1
  39. package/dist/zest.ru.js +658 -50
  40. package/dist/zest.ru.js.map +1 -1
  41. package/dist/zest.ru.min.js +1 -1
  42. package/dist/zest.uk.js +658 -50
  43. package/dist/zest.uk.js.map +1 -1
  44. package/dist/zest.uk.min.js +1 -1
  45. package/dist/zest.zh.js +658 -50
  46. package/dist/zest.zh.js.map +1 -1
  47. package/dist/zest.zh.min.js +1 -1
  48. package/package.json +1 -1
  49. package/src/config/defaults.js +2 -1
  50. package/src/core/element-interceptor.js +374 -0
  51. package/src/core/network-interceptor.js +289 -0
  52. package/src/core-lifecycle.js +20 -1
  53. package/src/index.js +46 -18
  54. package/src/types/zest.d.ts +7 -0
  55. package/src/types/zest.headless.d.ts +7 -0
  56. 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
+ }
@@ -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
- * Initialize Zest with UI.
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
- function init(userConfig = {}) {
125
- const { alreadyInitialized, consent, hasDecision, dntApplied } = coreInit(userConfig);
126
- if (alreadyInitialized) {
127
- console.warn('[Zest] Already initialized');
128
- return Zest;
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 (!hasDecision && !dntApplied) {
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
- const autoInit = () => {
253
- const cfg = getConfig();
254
- if (cfg.autoInit !== false) {
255
- init(window.ZestConfig);
256
- }
257
- };
258
-
259
- if (document.readyState === 'loading') {
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
 
@@ -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()`. */
@@ -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",