@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.
Files changed (57) hide show
  1. package/dist/zest.d.ts +40 -0
  2. package/dist/zest.de.js +763 -51
  3. package/dist/zest.de.js.map +1 -1
  4. package/dist/zest.de.min.js +1 -1
  5. package/dist/zest.en.js +763 -51
  6. package/dist/zest.en.js.map +1 -1
  7. package/dist/zest.en.min.js +1 -1
  8. package/dist/zest.es.js +763 -51
  9. package/dist/zest.es.js.map +1 -1
  10. package/dist/zest.es.min.js +1 -1
  11. package/dist/zest.esm.js +763 -51
  12. package/dist/zest.esm.js.map +1 -1
  13. package/dist/zest.esm.min.js +1 -1
  14. package/dist/zest.fr.js +763 -51
  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 +40 -0
  18. package/dist/zest.headless.esm.js +717 -33
  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 +763 -51
  22. package/dist/zest.it.js.map +1 -1
  23. package/dist/zest.it.min.js +1 -1
  24. package/dist/zest.ja.js +763 -51
  25. package/dist/zest.ja.js.map +1 -1
  26. package/dist/zest.ja.min.js +1 -1
  27. package/dist/zest.js +763 -51
  28. package/dist/zest.js.map +1 -1
  29. package/dist/zest.min.js +1 -1
  30. package/dist/zest.nl.js +763 -51
  31. package/dist/zest.nl.js.map +1 -1
  32. package/dist/zest.nl.min.js +1 -1
  33. package/dist/zest.pl.js +763 -51
  34. package/dist/zest.pl.js.map +1 -1
  35. package/dist/zest.pl.min.js +1 -1
  36. package/dist/zest.pt.js +763 -51
  37. package/dist/zest.pt.js.map +1 -1
  38. package/dist/zest.pt.min.js +1 -1
  39. package/dist/zest.ru.js +763 -51
  40. package/dist/zest.ru.js.map +1 -1
  41. package/dist/zest.ru.min.js +1 -1
  42. package/dist/zest.uk.js +763 -51
  43. package/dist/zest.uk.js.map +1 -1
  44. package/dist/zest.uk.min.js +1 -1
  45. package/dist/zest.zh.js +763 -51
  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 +49 -0
  50. package/src/core/element-interceptor.js +374 -0
  51. package/src/core/network-interceptor.js +289 -0
  52. package/src/core/pattern-matcher.js +37 -0
  53. package/src/core-lifecycle.js +43 -5
  54. package/src/index.js +46 -18
  55. package/src/types/zest.d.ts +40 -0
  56. package/src/types/zest.headless.d.ts +40 -0
  57. package/zest.config.schema.json +26 -0
package/dist/zest.esm.js CHANGED
@@ -255,10 +255,47 @@ const DEFAULT_PATTERNS = {
255
255
 
256
256
  let patterns = { ...DEFAULT_PATTERNS };
257
257
 
258
+ /** Escape a string so it can be embedded in a regex literal verbatim. */
259
+ function escapeRegex(value) {
260
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
261
+ }
262
+
263
+ /**
264
+ * Append patterns to a single category without replacing what's already
265
+ * there. Used by `essentialKeys` and `essentialPatterns` config to extend
266
+ * the strictly-necessary category with consumer-specific entries while
267
+ * keeping the built-in defaults (zest_*, csrf*, xsrf*, etc.).
268
+ *
269
+ * `keys` is an array of exact storage/cookie names; each one is
270
+ * compiled as a fully-anchored regex via `escapeRegex`.
271
+ * `patternStrings` is an array of regex source strings, each validated
272
+ * via `safeRegExp`. Invalid entries are dropped silently.
273
+ */
274
+ function appendPatternsToCategory(category, { keys = [], patternStrings = [] } = {}) {
275
+ if (!patterns[category]) patterns[category] = [];
276
+
277
+ for (const key of keys) {
278
+ if (typeof key !== 'string' || !key) continue;
279
+ const re = safeRegExp(`^${escapeRegex(key)}$`);
280
+ if (re) patterns[category].push(re);
281
+ }
282
+
283
+ for (const p of patternStrings) {
284
+ if (typeof p !== 'string' || !p) continue;
285
+ const re = safeRegExp(p);
286
+ if (re) patterns[category].push(re);
287
+ }
288
+ }
289
+
258
290
  /**
259
291
  * Set custom patterns. User-supplied strings are validated with safeRegExp,
260
292
  * which rejects catastrophic-backtracking shapes and syntax errors.
261
293
  * Invalid patterns are silently dropped with a console warning.
294
+ *
295
+ * Note: this REPLACES the patterns for any category present in
296
+ * `customPatterns`. To extend the essential category without losing the
297
+ * built-in defaults, use `appendPatternsToCategory()` (or pass
298
+ * `essentialKeys` / `essentialPatterns` to `Zest.init()`).
262
299
  */
263
300
  function setPatterns(customPatterns) {
264
301
  patterns = { ...DEFAULT_PATTERNS };
@@ -318,19 +355,19 @@ let originalCookieDescriptor = null;
318
355
  // Upper bound on the number of queued cookies awaiting consent replay.
319
356
  // An unbounded queue is a memory-exhaustion DoS vector — a hostile
320
357
  // script could flood it with document.cookie writes.
321
- const MAX_QUEUE_SIZE$2 = 100;
358
+ const MAX_QUEUE_SIZE$3 = 100;
322
359
 
323
360
  // Queue for blocked cookies
324
361
  const cookieQueue = [];
325
362
 
326
363
  // Reference to consent checker function
327
- let checkConsent$3 = () => false;
364
+ let checkConsent$5 = () => false;
328
365
 
329
366
  /**
330
367
  * Set the consent checker function
331
368
  */
332
- function setConsentChecker$2(fn) {
333
- checkConsent$3 = fn;
369
+ function setConsentChecker$4(fn) {
370
+ checkConsent$5 = fn;
334
371
  }
335
372
 
336
373
  /**
@@ -386,10 +423,10 @@ function interceptCookies() {
386
423
 
387
424
  const category = getCategoryForName(name);
388
425
 
389
- if (checkConsent$3(category)) {
426
+ if (checkConsent$5(category)) {
390
427
  // Consent given - set cookie
391
428
  originalCookieDescriptor.set.call(document, value);
392
- } else if (cookieQueue.length < MAX_QUEUE_SIZE$2) {
429
+ } else if (cookieQueue.length < MAX_QUEUE_SIZE$3) {
393
430
  // No consent - queue for later (capped to prevent DoS)
394
431
  cookieQueue.push({
395
432
  value,
@@ -414,7 +451,7 @@ function interceptCookies() {
414
451
 
415
452
  // Upper bound on queued operations awaiting consent replay — unbounded
416
453
  // growth would be a memory-exhaustion DoS vector.
417
- const MAX_QUEUE_SIZE$1 = 200;
454
+ const MAX_QUEUE_SIZE$2 = 200;
418
455
 
419
456
  // Store originals
420
457
  let originalLocalStorage = null;
@@ -425,13 +462,13 @@ const localStorageQueue = [];
425
462
  const sessionStorageQueue = [];
426
463
 
427
464
  // Reference to consent checker function
428
- let checkConsent$2 = () => false;
465
+ let checkConsent$4 = () => false;
429
466
 
430
467
  /**
431
468
  * Set the consent checker function
432
469
  */
433
- function setConsentChecker$1(fn) {
434
- checkConsent$2 = fn;
470
+ function setConsentChecker$3(fn) {
471
+ checkConsent$4 = fn;
435
472
  }
436
473
 
437
474
  /**
@@ -444,9 +481,9 @@ function createStorageProxy(storage, queue, storageName) {
444
481
  return (key, value) => {
445
482
  const category = getCategoryForName(key);
446
483
 
447
- if (checkConsent$2(category)) {
484
+ if (checkConsent$4(category)) {
448
485
  target.setItem(key, value);
449
- } else if (queue.length < MAX_QUEUE_SIZE$1) {
486
+ } else if (queue.length < MAX_QUEUE_SIZE$2) {
450
487
  queue.push({
451
488
  key,
452
489
  value,
@@ -729,11 +766,11 @@ function isThirdParty(url) {
729
766
 
730
767
  // Categories the author has declared blockable. A script can self-label
731
768
  // into one of these, but not into 'essential' (a common bypass).
732
- const BLOCKABLE_CATEGORIES = new Set(['functional', 'analytics', 'marketing']);
769
+ const BLOCKABLE_CATEGORIES$2 = new Set(['functional', 'analytics', 'marketing']);
733
770
 
734
771
  // Upper bound on queued scripts awaiting consent replay — prevents a
735
772
  // hostile page from flooding the queue with <script> nodes.
736
- const MAX_QUEUE_SIZE = 500;
773
+ const MAX_QUEUE_SIZE$1 = 500;
737
774
 
738
775
  // Queue for blocked scripts — the authoritative source for replay,
739
776
  // snapshotting src/inline BEFORE any DOM mutation so later tampering
@@ -744,31 +781,31 @@ const scriptQueue = [];
744
781
  let observer = null;
745
782
 
746
783
  // Current blocking mode
747
- let blockingMode = 'safe';
784
+ let blockingMode$2 = 'safe';
748
785
 
749
786
  // Custom blocked domains (user-defined)
750
- let customBlockedDomains = [];
787
+ let customBlockedDomains$2 = [];
751
788
 
752
789
  // Reference to consent checker function
753
- let checkConsent$1 = () => false;
790
+ let checkConsent$3 = () => false;
754
791
 
755
792
  /**
756
793
  * Set the consent checker function
757
794
  */
758
- function setConsentChecker(fn) {
759
- checkConsent$1 = fn;
795
+ function setConsentChecker$2(fn) {
796
+ checkConsent$3 = fn;
760
797
  }
761
798
 
762
799
  /**
763
800
  * Check if script URL matches custom blocked domains
764
801
  */
765
- function matchesCustomDomains(url) {
766
- if (!url || customBlockedDomains.length === 0) return null;
802
+ function matchesCustomDomains$2(url) {
803
+ if (!url || customBlockedDomains$2.length === 0) return null;
767
804
 
768
805
  try {
769
806
  const hostname = new URL(url).hostname.toLowerCase();
770
807
 
771
- for (const entry of customBlockedDomains) {
808
+ for (const entry of customBlockedDomains$2) {
772
809
  const domain = typeof entry === 'string' ? entry : entry.domain;
773
810
  const category = typeof entry === 'string' ? 'marketing' : (entry.category || 'marketing');
774
811
 
@@ -801,7 +838,7 @@ function getScriptBlockCategory(script) {
801
838
  // Only honor values from the blockable set; 'essential' and unknown
802
839
  // values fall through to the other checks.
803
840
  const explicitCategory = script.getAttribute('data-consent-category');
804
- const explicitBlockable = explicitCategory && BLOCKABLE_CATEGORIES.has(explicitCategory)
841
+ const explicitBlockable = explicitCategory && BLOCKABLE_CATEGORIES$2.has(explicitCategory)
805
842
  ? explicitCategory
806
843
  : null;
807
844
 
@@ -813,17 +850,17 @@ function getScriptBlockCategory(script) {
813
850
  }
814
851
 
815
852
  // 2. Check custom blocked domains
816
- const customCategory = matchesCustomDomains(src);
853
+ const customCategory = matchesCustomDomains$2(src);
817
854
 
818
855
  // 3. Mode-based blocking
819
856
  let modeCategory = null;
820
- switch (blockingMode) {
857
+ switch (blockingMode$2) {
821
858
  case 'manual':
822
859
  break;
823
860
 
824
861
  case 'safe':
825
862
  case 'strict':
826
- modeCategory = getCategoryForScript(src, blockingMode);
863
+ modeCategory = getCategoryForScript(src, blockingMode$2);
827
864
  break;
828
865
 
829
866
  case 'doomsday':
@@ -857,7 +894,7 @@ function blockScript(script) {
857
894
  return false;
858
895
  }
859
896
 
860
- if (checkConsent$1(category)) {
897
+ if (checkConsent$3(category)) {
861
898
  // Consent already given - allow script
862
899
  script.setAttribute('data-zest-processed', 'allowed');
863
900
  return false;
@@ -891,7 +928,7 @@ function blockScript(script) {
891
928
  script.removeAttribute('src');
892
929
  }
893
930
 
894
- if (scriptQueue.length < MAX_QUEUE_SIZE) {
931
+ if (scriptQueue.length < MAX_QUEUE_SIZE$1) {
895
932
  scriptQueue.push(scriptInfo);
896
933
  }
897
934
  return true;
@@ -972,8 +1009,8 @@ function handleMutations(mutations) {
972
1009
  * Start observing for new scripts
973
1010
  */
974
1011
  function startScriptBlocking(mode = 'safe', customDomains = []) {
975
- blockingMode = mode;
976
- customBlockedDomains = customDomains;
1012
+ blockingMode$2 = mode;
1013
+ customBlockedDomains$2 = customDomains;
977
1014
 
978
1015
  // Process existing scripts
979
1016
  processExistingScripts();
@@ -989,6 +1026,568 @@ function startScriptBlocking(mode = 'safe', customDomains = []) {
989
1026
  return true;
990
1027
  }
991
1028
 
1029
+ /**
1030
+ * Network Interceptor - Intercepts fetch / XHR / sendBeacon calls
1031
+ *
1032
+ * Why this exists separately from the script blocker:
1033
+ *
1034
+ * Modern CMSes (HubSpot, Cloudflare Zaraz, server-side GTM, Shopify,
1035
+ * Webflow) increasingly proxy their tracker code through the site's own
1036
+ * origin to defeat ad-blockers. The <script> tag itself is first-party
1037
+ * (e.g. /hs/scriptloader/{id}.js) so a hostname-based script blocker
1038
+ * cannot match it. But at RUNTIME that script still phones home to the
1039
+ * vendor's analytics endpoint via fetch / XHR / sendBeacon — and THAT
1040
+ * URL is third-party.
1041
+ *
1042
+ * This interceptor sits on the network layer and uses the same
1043
+ * customBlockedDomains + mode-based tracker list as the script blocker.
1044
+ * Whatever the user told Zest to block (typically generated by an AI
1045
+ * audit) gets blocked regardless of which API the tracker uses.
1046
+ *
1047
+ * No replay: network calls are one-shot and time-sensitive. Replaying a
1048
+ * stale beacon after consent would create confusing / duplicated data,
1049
+ * so blocked requests are dropped, not queued.
1050
+ */
1051
+
1052
+
1053
+ // Originals captured at install time. Stored for restoration tests and
1054
+ // for any internal Zest network calls we may add later.
1055
+ let originalFetch = null;
1056
+ let originalXhrOpen = null;
1057
+ let originalXhrSend = null;
1058
+ let originalSendBeacon = null;
1059
+
1060
+ let blockingMode$1 = 'safe';
1061
+ let customBlockedDomains$1 = [];
1062
+ let installed$1 = false;
1063
+
1064
+ let checkConsent$2 = () => false;
1065
+
1066
+ const BLOCKABLE_CATEGORIES$1 = new Set(['functional', 'analytics', 'marketing']);
1067
+
1068
+ function setConsentChecker$1(fn) {
1069
+ checkConsent$2 = fn;
1070
+ }
1071
+
1072
+ /**
1073
+ * Resolve a Request | URL | string to an absolute URL string. Returns
1074
+ * null if the input cannot be parsed — callers treat null as "do not
1075
+ * block" (we'd rather let an opaque request through than crash the page).
1076
+ */
1077
+ function resolveUrl(input) {
1078
+ try {
1079
+ if (typeof input === 'string') {
1080
+ return new URL(input, location.href).href;
1081
+ }
1082
+ if (input && typeof input === 'object') {
1083
+ if (typeof input.url === 'string') {
1084
+ // Request object
1085
+ return new URL(input.url, location.href).href;
1086
+ }
1087
+ if (typeof input.href === 'string') {
1088
+ // URL object
1089
+ return input.href;
1090
+ }
1091
+ }
1092
+ } catch (e) {
1093
+ // fallthrough
1094
+ }
1095
+ return null;
1096
+ }
1097
+
1098
+ /**
1099
+ * Match a URL against the user's customBlockedDomains list. Mirrors
1100
+ * matchesCustomDomains() in script-blocker.js — kept inline rather than
1101
+ * shared so each interceptor can be lifted independently.
1102
+ */
1103
+ function matchesCustomDomains$1(hostname) {
1104
+ if (!hostname || customBlockedDomains$1.length === 0) return null;
1105
+ const host = hostname.toLowerCase();
1106
+ for (const entry of customBlockedDomains$1) {
1107
+ const domain = (typeof entry === 'string' ? entry : entry?.domain || '').toLowerCase();
1108
+ if (!domain) continue;
1109
+ const category = typeof entry === 'string'
1110
+ ? 'marketing'
1111
+ : (BLOCKABLE_CATEGORIES$1.has(entry?.category) ? entry.category : 'marketing');
1112
+ if (host === domain || host.endsWith('.' + domain)) {
1113
+ return category;
1114
+ }
1115
+ }
1116
+ return null;
1117
+ }
1118
+
1119
+ /**
1120
+ * Decide whether a URL should be blocked and return its category, or
1121
+ * null if it should pass through. Priority: customBlockedDomains >
1122
+ * mode-based tracker list (matching script-blocker priority).
1123
+ */
1124
+ function getBlockCategory$1(url) {
1125
+ if (!url) return null;
1126
+ let hostname;
1127
+ try {
1128
+ hostname = new URL(url, location.href).hostname;
1129
+ } catch (e) {
1130
+ return null;
1131
+ }
1132
+
1133
+ const customCategory = matchesCustomDomains$1(hostname);
1134
+ if (customCategory) return customCategory;
1135
+
1136
+ switch (blockingMode$1) {
1137
+ case 'manual':
1138
+ return null;
1139
+ case 'safe':
1140
+ case 'strict':
1141
+ return getCategoryForScript(url, blockingMode$1);
1142
+ case 'doomsday':
1143
+ if (isThirdParty(url)) {
1144
+ return getCategoryForScript(url, 'strict') || 'marketing';
1145
+ }
1146
+ return null;
1147
+ default:
1148
+ return null;
1149
+ }
1150
+ }
1151
+
1152
+ /**
1153
+ * Should the request be blocked right now? Returns the category that
1154
+ * caused the block (for logging / callbacks later) or null.
1155
+ */
1156
+ function shouldBlock$1(url) {
1157
+ const category = getBlockCategory$1(url);
1158
+ if (!category) return null;
1159
+ if (checkConsent$2(category)) return null;
1160
+ return category;
1161
+ }
1162
+
1163
+ /**
1164
+ * Construct an empty, successful-looking Response for a blocked fetch.
1165
+ * Status 204 (No Content) is the most honest "we deliberately returned
1166
+ * nothing" signal. Trackers that .then(r => r.json()) will get an empty
1167
+ * body and typically silently move on.
1168
+ */
1169
+ function blockedResponse() {
1170
+ // Some environments (older browsers, strict CSP) may not have Response
1171
+ // — fall back to a thenable shape the most common tracker code expects.
1172
+ if (typeof Response === 'function') {
1173
+ return new Response(null, { status: 204, statusText: 'Blocked by Zest' });
1174
+ }
1175
+ const fake = {
1176
+ ok: false,
1177
+ status: 204,
1178
+ statusText: 'Blocked by Zest',
1179
+ json: () => Promise.resolve({}),
1180
+ text: () => Promise.resolve(''),
1181
+ arrayBuffer: () => Promise.resolve(new ArrayBuffer(0))
1182
+ };
1183
+ return fake;
1184
+ }
1185
+
1186
+ /**
1187
+ * Install fetch hook. Captures the original so we can both restore it
1188
+ * later and use it for any internal Zest network calls.
1189
+ */
1190
+ function patchFetch() {
1191
+ if (typeof window === 'undefined' || typeof window.fetch !== 'function') return;
1192
+ originalFetch = window.fetch.bind(window);
1193
+
1194
+ window.fetch = function zestPatchedFetch(input, init) {
1195
+ const url = resolveUrl(input);
1196
+ if (shouldBlock$1(url)) {
1197
+ return Promise.resolve(blockedResponse());
1198
+ }
1199
+ return originalFetch(input, init);
1200
+ };
1201
+ }
1202
+
1203
+ /**
1204
+ * Install XMLHttpRequest hook. We patch .open() to capture the URL on
1205
+ * the instance, then .send() to decide whether to abort. Using a hidden
1206
+ * symbol on the instance avoids leaking state and survives any wrapping
1207
+ * code that reassigns request properties.
1208
+ */
1209
+ const URL_KEY = Symbol('zestUrl');
1210
+
1211
+ function patchXhr() {
1212
+ if (typeof XMLHttpRequest === 'undefined') return;
1213
+ const proto = XMLHttpRequest.prototype;
1214
+ originalXhrOpen = proto.open;
1215
+ originalXhrSend = proto.send;
1216
+
1217
+ proto.open = function (method, url, ...rest) {
1218
+ this[URL_KEY] = typeof url === 'string' ? url : (url && url.href) || '';
1219
+ return originalXhrOpen.call(this, method, url, ...rest);
1220
+ };
1221
+
1222
+ proto.send = function (body) {
1223
+ const url = this[URL_KEY];
1224
+ if (shouldBlock$1(url)) {
1225
+ // Mimic the failure mode of a network error: queueMicrotask is
1226
+ // used so consumers that synchronously attach handlers after
1227
+ // .send() still receive the events.
1228
+ const xhr = this;
1229
+ queueMicrotask(() => {
1230
+ try {
1231
+ // Best-effort — readonly props in some environments
1232
+ Object.defineProperty(xhr, 'readyState', { value: 4, configurable: true });
1233
+ Object.defineProperty(xhr, 'status', { value: 0, configurable: true });
1234
+ } catch (e) {
1235
+ // ignore
1236
+ }
1237
+ try {
1238
+ xhr.dispatchEvent(new Event('error'));
1239
+ xhr.dispatchEvent(new Event('loadend'));
1240
+ } catch (e) {
1241
+ // ignore
1242
+ }
1243
+ });
1244
+ return;
1245
+ }
1246
+ return originalXhrSend.call(this, body);
1247
+ };
1248
+ }
1249
+
1250
+ /**
1251
+ * Install navigator.sendBeacon hook. Returning false matches the spec's
1252
+ * "data was not queued" semantics; trackers that check the return value
1253
+ * fall back to fetch (which we also block) or give up.
1254
+ */
1255
+ function patchSendBeacon() {
1256
+ if (typeof navigator === 'undefined' || typeof navigator.sendBeacon !== 'function') return;
1257
+ originalSendBeacon = navigator.sendBeacon.bind(navigator);
1258
+
1259
+ navigator.sendBeacon = function zestPatchedSendBeacon(url, data) {
1260
+ if (shouldBlock$1(typeof url === 'string' ? url : (url && url.href) || '')) {
1261
+ return false;
1262
+ }
1263
+ return originalSendBeacon(url, data);
1264
+ };
1265
+ }
1266
+
1267
+ /**
1268
+ * Install all network hooks. Safe to call multiple times — subsequent
1269
+ * calls just refresh mode + custom domain config without re-wrapping.
1270
+ */
1271
+ function interceptNetwork(mode = 'safe', customDomains = []) {
1272
+ blockingMode$1 = mode;
1273
+ customBlockedDomains$1 = Array.isArray(customDomains) ? customDomains : [];
1274
+
1275
+ if (installed$1) return true;
1276
+ patchFetch();
1277
+ patchXhr();
1278
+ patchSendBeacon();
1279
+ installed$1 = true;
1280
+ return true;
1281
+ }
1282
+
1283
+ /**
1284
+ * Element Interceptor - Catches tracker elements BEFORE the browser fetches them.
1285
+ *
1286
+ * The script-blocker uses MutationObserver. That fires asynchronously
1287
+ * (microtask after the DOM mutation), so by the time we can react the
1288
+ * browser has already kicked off the network request for the src/href.
1289
+ * The script may not execute (we flip type to text/plain) but the
1290
+ * fetch already left the building — and to ConsentTheater / a privacy
1291
+ * audit that fetch IS a pre-consent leak.
1292
+ *
1293
+ * This interceptor patches the prototype setters and Element.setAttribute
1294
+ * synchronously, so when code does:
1295
+ *
1296
+ * const s = document.createElement('script');
1297
+ * s.src = 'https://tracker.example/track.js'; // ← intercepted HERE
1298
+ * document.head.appendChild(s); // ← src is already empty,
1299
+ * // no fetch ever fired
1300
+ *
1301
+ * Covers four element types and both ways to set the URL:
1302
+ *
1303
+ * - HTMLScriptElement src
1304
+ * - HTMLLinkElement href (stylesheets, prefetch, preload, dns-prefetch)
1305
+ * - HTMLImageElement src (tracking pixels)
1306
+ * - HTMLIFrameElement src (tracking iframes)
1307
+ *
1308
+ * Plus the global Image() constructor used by classic pixel trackers.
1309
+ *
1310
+ * What this does NOT catch: inline HTML <script src=...> / <link href=...>
1311
+ * tags parsed from the original HTML response. The browser starts those
1312
+ * fetches as soon as it encounters the tag during parsing, BEFORE any
1313
+ * JavaScript runs. The only complete fix for that class is server-side
1314
+ * CSP or template-time removal.
1315
+ */
1316
+
1317
+
1318
+ // Upper bound on queued blocked elements. Unbounded growth would be a
1319
+ // memory-exhaustion vector if a page (or a hostile script) tried to
1320
+ // flood us with src writes.
1321
+ const MAX_QUEUE_SIZE = 500;
1322
+
1323
+ // Queue of blocked element writes. Each entry remembers enough to
1324
+ // re-apply the original URL via the ORIGINAL setter once consent
1325
+ // arrives for its category. Without this queue, blocked scripts /
1326
+ // stylesheets / images would be lost forever and require a page
1327
+ // reload to come back.
1328
+ const elementQueue = [];
1329
+
1330
+ let blockingMode = 'safe';
1331
+ let customBlockedDomains = [];
1332
+ let installed = false;
1333
+ let checkConsent$1 = () => false;
1334
+
1335
+ const BLOCKABLE_CATEGORIES = new Set(['functional', 'analytics', 'marketing']);
1336
+
1337
+ // Map of tag name -> attribute name that carries a URL we may want to
1338
+ // block. Lowercased on both sides; setAttribute() gating uses this.
1339
+ const URL_ATTRS = {
1340
+ script: 'src',
1341
+ link: 'href',
1342
+ img: 'src',
1343
+ iframe: 'src'
1344
+ };
1345
+
1346
+ function setConsentChecker(fn) {
1347
+ checkConsent$1 = fn;
1348
+ }
1349
+
1350
+ function matchesCustomDomains(hostname) {
1351
+ if (!hostname || customBlockedDomains.length === 0) return null;
1352
+ const host = hostname.toLowerCase();
1353
+ for (const entry of customBlockedDomains) {
1354
+ const domain = (typeof entry === 'string' ? entry : entry?.domain || '').toLowerCase();
1355
+ if (!domain) continue;
1356
+ const category = typeof entry === 'string'
1357
+ ? 'marketing'
1358
+ : (BLOCKABLE_CATEGORIES.has(entry?.category) ? entry.category : 'marketing');
1359
+ if (host === domain || host.endsWith('.' + domain)) {
1360
+ return category;
1361
+ }
1362
+ }
1363
+ return null;
1364
+ }
1365
+
1366
+ function getBlockCategory(url) {
1367
+ if (!url) return null;
1368
+ let hostname;
1369
+ try {
1370
+ hostname = new URL(url, location.href).hostname;
1371
+ } catch (e) {
1372
+ return null;
1373
+ }
1374
+
1375
+ const customCategory = matchesCustomDomains(hostname);
1376
+ if (customCategory) return customCategory;
1377
+
1378
+ switch (blockingMode) {
1379
+ case 'manual':
1380
+ return null;
1381
+ case 'safe':
1382
+ case 'strict':
1383
+ return getCategoryForScript(url, blockingMode);
1384
+ case 'doomsday':
1385
+ if (isThirdParty(url)) {
1386
+ return getCategoryForScript(url, 'strict') || 'marketing';
1387
+ }
1388
+ return null;
1389
+ default:
1390
+ return null;
1391
+ }
1392
+ }
1393
+
1394
+ function shouldBlock(url) {
1395
+ const category = getBlockCategory(url);
1396
+ if (!category) return null;
1397
+ if (checkConsent$1(category)) return null;
1398
+ return category;
1399
+ }
1400
+
1401
+ /**
1402
+ * Replace the property setter for `prop` on `ProtoCtor.prototype` with
1403
+ * a gated version. Returns the original descriptor so we can restore.
1404
+ */
1405
+ function patchUrlSetter(ProtoCtor, prop) {
1406
+ if (typeof ProtoCtor !== 'function' || !ProtoCtor.prototype) return null;
1407
+ const proto = ProtoCtor.prototype;
1408
+ const desc = Object.getOwnPropertyDescriptor(proto, prop);
1409
+ if (!desc || typeof desc.set !== 'function') return null;
1410
+
1411
+ Object.defineProperty(proto, prop, {
1412
+ configurable: true,
1413
+ enumerable: desc.enumerable,
1414
+ get: desc.get,
1415
+ set(value) {
1416
+ if (typeof value === 'string') {
1417
+ const category = shouldBlock(value);
1418
+ if (category) {
1419
+ // Don't pass through to the original setter — the URL never
1420
+ // touches the element. Stash the element + URL + category
1421
+ // + original descriptor in the queue so replayElements()
1422
+ // can reinstate it once consent arrives.
1423
+ if (elementQueue.length < MAX_QUEUE_SIZE) {
1424
+ elementQueue.push({
1425
+ element: this,
1426
+ setter: desc.set,
1427
+ prop,
1428
+ value,
1429
+ category,
1430
+ method: 'property'
1431
+ });
1432
+ }
1433
+ return;
1434
+ }
1435
+ }
1436
+ return desc.set.call(this, value);
1437
+ }
1438
+ });
1439
+
1440
+ return desc;
1441
+ }
1442
+
1443
+ function patchSetAttribute() {
1444
+ if (typeof Element === 'undefined' || !Element.prototype) return null;
1445
+ const orig = Element.prototype.setAttribute;
1446
+
1447
+ Element.prototype.setAttribute = function patchedSetAttribute(name, value) {
1448
+ // Fast path: bail out for anything not on our watchlist before doing
1449
+ // any string work. setAttribute is hot — keep this cheap.
1450
+ if (typeof name !== 'string' || typeof value !== 'string' || !this || !this.tagName) {
1451
+ return orig.call(this, name, value);
1452
+ }
1453
+ const tag = this.tagName.toLowerCase();
1454
+ const watched = URL_ATTRS[tag];
1455
+ if (!watched) {
1456
+ return orig.call(this, name, value);
1457
+ }
1458
+ const attr = name.toLowerCase();
1459
+ if (attr !== watched) {
1460
+ return orig.call(this, name, value);
1461
+ }
1462
+ const category = shouldBlock(value);
1463
+ if (category) {
1464
+ // Drop silently and queue for replay. The element keeps any
1465
+ // other attributes you set before / after.
1466
+ if (elementQueue.length < MAX_QUEUE_SIZE) {
1467
+ elementQueue.push({
1468
+ element: this,
1469
+ setter: orig, // setAttribute itself, called like orig.call(el, name, value)
1470
+ prop: name,
1471
+ value,
1472
+ category,
1473
+ method: 'attribute'
1474
+ });
1475
+ }
1476
+ return;
1477
+ }
1478
+ return orig.call(this, name, value);
1479
+ };
1480
+
1481
+ return orig;
1482
+ }
1483
+
1484
+ function patchImageConstructor() {
1485
+ if (typeof window === 'undefined' || typeof window.Image !== 'function') return null;
1486
+ const OrigImage = window.Image;
1487
+
1488
+ function PatchedImage(width, height) {
1489
+ const img = arguments.length >= 2
1490
+ ? new OrigImage(width, height)
1491
+ : new OrigImage();
1492
+ // No work needed here — the .src setter patch on HTMLImageElement
1493
+ // will catch any later assignment. PatchedImage exists mainly to
1494
+ // expose the .src patch via this path for `new Image()` users.
1495
+ return img;
1496
+ }
1497
+ PatchedImage.prototype = OrigImage.prototype;
1498
+ // Copy any static fields just in case.
1499
+ for (const key of Object.keys(OrigImage)) {
1500
+ try { PatchedImage[key] = OrigImage[key]; } catch (e) { /* ignore */ }
1501
+ }
1502
+
1503
+ try {
1504
+ Object.defineProperty(window, 'Image', {
1505
+ configurable: true,
1506
+ writable: true,
1507
+ value: PatchedImage
1508
+ });
1509
+ } catch (e) {
1510
+ window.Image = PatchedImage;
1511
+ }
1512
+
1513
+ return OrigImage;
1514
+ }
1515
+
1516
+ /**
1517
+ * Replay blocked element writes for newly-allowed categories.
1518
+ *
1519
+ * For each queued entry whose category is in `allowedCategories`:
1520
+ * - If the element is still connected to the DOM, re-apply the
1521
+ * URL via the ORIGINAL setter / setAttribute. The browser starts
1522
+ * the fetch as if nothing had been intercepted.
1523
+ * - If the element has since been removed (no `isConnected`), drop
1524
+ * the entry — calling code lost its reference and we have no
1525
+ * parent to attach to.
1526
+ *
1527
+ * Queue ordering is preserved so that scripts/stylesheets re-execute
1528
+ * in the same order the page originally requested them.
1529
+ */
1530
+ function replayElements(allowedCategories) {
1531
+ if (!Array.isArray(allowedCategories) || elementQueue.length === 0) return;
1532
+ const remaining = [];
1533
+
1534
+ for (const item of elementQueue) {
1535
+ if (!allowedCategories.includes(item.category)) {
1536
+ remaining.push(item);
1537
+ continue;
1538
+ }
1539
+
1540
+ const el = item.element;
1541
+ if (!el || !el.isConnected) {
1542
+ // Element is detached or gone — nothing to re-apply against.
1543
+ continue;
1544
+ }
1545
+
1546
+ try {
1547
+ if (item.method === 'attribute') {
1548
+ item.setter.call(el, item.prop, item.value);
1549
+ } else {
1550
+ item.setter.call(el, item.value);
1551
+ }
1552
+ } catch (e) {
1553
+ // Restoration failed (rare — element might be in a weird state).
1554
+ // Don't requeue; one failure is enough.
1555
+ }
1556
+ }
1557
+
1558
+ elementQueue.length = 0;
1559
+ elementQueue.push(...remaining);
1560
+ }
1561
+
1562
+ /**
1563
+ * Install all element-level interceptors. Idempotent — second call
1564
+ * just refreshes mode + customDomains without rewrapping.
1565
+ */
1566
+ function interceptElements(mode = 'safe', customDomains = []) {
1567
+ blockingMode = mode;
1568
+ customBlockedDomains = Array.isArray(customDomains) ? customDomains : [];
1569
+
1570
+ if (installed) return true;
1571
+
1572
+ if (typeof HTMLScriptElement !== 'undefined') {
1573
+ patchUrlSetter(HTMLScriptElement, 'src');
1574
+ }
1575
+ if (typeof HTMLLinkElement !== 'undefined') {
1576
+ patchUrlSetter(HTMLLinkElement, 'href');
1577
+ }
1578
+ if (typeof HTMLImageElement !== 'undefined') {
1579
+ patchUrlSetter(HTMLImageElement, 'src');
1580
+ }
1581
+ if (typeof HTMLIFrameElement !== 'undefined') {
1582
+ patchUrlSetter(HTMLIFrameElement, 'src');
1583
+ }
1584
+ patchSetAttribute();
1585
+ patchImageConstructor();
1586
+
1587
+ installed = true;
1588
+ return true;
1589
+ }
1590
+
992
1591
  /**
993
1592
  * Default consent categories
994
1593
  */
@@ -1777,6 +2376,33 @@ const DEFAULTS = {
1777
2376
  // Blocking mode: 'manual' | 'safe' | 'strict' | 'doomsday'
1778
2377
  mode: 'safe',
1779
2378
 
2379
+ // Interceptor toggles. By default Zest installs cookie + storage
2380
+ // interceptors that route writes through the consent layer. Consumers
2381
+ // who manage gating themselves (typically headless mode with custom
2382
+ // analytics integrations) can opt out per channel.
2383
+ intercept: {
2384
+ cookies: true,
2385
+ storage: true,
2386
+ scripts: true,
2387
+ network: true
2388
+ },
2389
+
2390
+ // Strictly-necessary declarations. Both fields *append* to whatever
2391
+ // the essential category already matches via the pattern matcher
2392
+ // defaults — they do not replace.
2393
+ //
2394
+ // - essentialKeys: array of exact storage / cookie names to treat
2395
+ // as strictly-necessary. Easiest case.
2396
+ // - essentialPatterns: array of regex source strings, validated via
2397
+ // safeRegExp. For prefix or family matches.
2398
+ //
2399
+ // Use these instead of `patterns.essential` when you only want to
2400
+ // ADD entries to the essential category without replacing the
2401
+ // built-in patterns (zest_*, csrf*, xsrf*, session*, __host-*,
2402
+ // __secure-*).
2403
+ essentialKeys: [],
2404
+ essentialPatterns: [],
2405
+
1780
2406
  // Custom domains to block (in addition to mode-based blocking)
1781
2407
  blockedDomains: [], // days
1782
2408
 
@@ -1859,6 +2485,28 @@ function mergeConfig(userConfig) {
1859
2485
  config.patterns = userConfig.patterns;
1860
2486
  }
1861
2487
 
2488
+ // Interceptor toggles — shallow-merge so consumers can pass partial
2489
+ // overrides like `intercept: { storage: false }` without losing the
2490
+ // other defaults.
2491
+ if (userConfig.intercept && typeof userConfig.intercept === 'object') {
2492
+ config.intercept = {
2493
+ ...DEFAULTS.intercept,
2494
+ ...userConfig.intercept
2495
+ };
2496
+ }
2497
+
2498
+ // Strictly-necessary declarations
2499
+ if (Array.isArray(userConfig.essentialKeys)) {
2500
+ config.essentialKeys = userConfig.essentialKeys.filter(
2501
+ (k) => typeof k === 'string' && k.length > 0 && k.length <= 200
2502
+ );
2503
+ }
2504
+ if (Array.isArray(userConfig.essentialPatterns)) {
2505
+ config.essentialPatterns = userConfig.essentialPatterns.filter(
2506
+ (p) => typeof p === 'string' && p.length > 0 && p.length <= 500
2507
+ );
2508
+ }
2509
+
1862
2510
  return config;
1863
2511
  }
1864
2512
 
@@ -2270,6 +2918,11 @@ function replayAll(categories) {
2270
2918
  replayCookies(categories);
2271
2919
  replayStorage(categories);
2272
2920
  replayScripts(categories);
2921
+ // Element-level replays (script/link/img/iframe URLs that were
2922
+ // dropped at the prototype-setter / setAttribute layer). Network
2923
+ // interceptor (fetch/XHR/sendBeacon) intentionally has no replay
2924
+ // — beacons are one-shot and resending would duplicate analytics.
2925
+ replayElements(categories);
2273
2926
  }
2274
2927
 
2275
2928
  /**
@@ -2301,13 +2954,44 @@ function coreInit(userConfig = {}) {
2301
2954
  setPatterns(currentConfig.patterns);
2302
2955
  }
2303
2956
 
2957
+ // Append consumer-declared strictly-necessary entries on top of
2958
+ // whatever's already in the essential category. This is the friendly
2959
+ // alternative to overriding via `patterns.essential` directly.
2960
+ if (
2961
+ (Array.isArray(currentConfig.essentialKeys) && currentConfig.essentialKeys.length > 0) ||
2962
+ (Array.isArray(currentConfig.essentialPatterns) && currentConfig.essentialPatterns.length > 0)
2963
+ ) {
2964
+ appendPatternsToCategory('essential', {
2965
+ keys: currentConfig.essentialKeys,
2966
+ patternStrings: currentConfig.essentialPatterns
2967
+ });
2968
+ }
2969
+
2970
+ setConsentChecker$4(checkConsent);
2971
+ setConsentChecker$3(checkConsent);
2304
2972
  setConsentChecker$2(checkConsent);
2305
2973
  setConsentChecker$1(checkConsent);
2306
2974
  setConsentChecker(checkConsent);
2307
2975
 
2308
- interceptCookies();
2309
- interceptStorage();
2310
- startScriptBlocking(currentConfig.mode, currentConfig.blockedDomains);
2976
+ // Interceptor toggles. By default everything is intercepted (back-compat
2977
+ // with v2.0 / v2.1). Consumers that gate scripts and storage themselves
2978
+ // can opt out per channel via `intercept: { storage: false, … }`.
2979
+ const intercept = currentConfig.intercept || { cookies: true, storage: true, scripts: true, network: true };
2980
+ if (intercept.cookies !== false) interceptCookies();
2981
+ if (intercept.storage !== false) interceptStorage();
2982
+ if (intercept.scripts !== false) {
2983
+ // Element-level synchronous interception (prototype setters +
2984
+ // setAttribute) installs BEFORE startScriptBlocking so that the
2985
+ // moment any later script does `el.src = "https://tracker..."`,
2986
+ // we drop the URL before the browser fetches. The MutationObserver
2987
+ // inside startScriptBlocking remains as a defence-in-depth net for
2988
+ // anything that slips past (e.g. nodes constructed via cloneNode).
2989
+ interceptElements(currentConfig.mode, currentConfig.blockedDomains);
2990
+ startScriptBlocking(currentConfig.mode, currentConfig.blockedDomains);
2991
+ }
2992
+ if (intercept.network !== false) {
2993
+ interceptNetwork(currentConfig.mode, currentConfig.blockedDomains);
2994
+ }
2311
2995
 
2312
2996
  const consent = loadConsent();
2313
2997
  initialized = true;
@@ -3446,18 +4130,29 @@ function handleCloseModal() {
3446
4130
  }
3447
4131
 
3448
4132
  /**
3449
- * Initialize Zest with UI.
4133
+ * UI mount guard. We split UI mounting (which needs `<body>` and a parsed
4134
+ * DOM) from interceptor installation (which must happen on script eval to
4135
+ * gate any later `defer` / `async` tracker scripts). `coreInit()` is
4136
+ * idempotent so calling init() before the DOM is ready is safe — the UI
4137
+ * portion just gets queued.
3450
4138
  */
3451
- function init(userConfig = {}) {
3452
- const { alreadyInitialized, consent, hasDecision, dntApplied } = coreInit(userConfig);
3453
- if (alreadyInitialized) {
3454
- console.warn('[Zest] Already initialized');
3455
- return Zest;
4139
+ let uiMounted = false;
4140
+
4141
+ function mountUI() {
4142
+ if (uiMounted) return;
4143
+
4144
+ // Banner needs document.body to mount its host element. If body isn't
4145
+ // there yet, requeue on DOMContentLoaded.
4146
+ if (!document || !document.body) {
4147
+ document.addEventListener('DOMContentLoaded', mountUI, { once: true });
4148
+ return;
3456
4149
  }
3457
4150
 
4151
+ uiMounted = true;
3458
4152
  const config = getActiveConfig();
4153
+ const decision = hasConsentDecision();
3459
4154
 
3460
- if (!hasDecision && !dntApplied) {
4155
+ if (!decision) {
3461
4156
  showBanner({
3462
4157
  onAcceptAll: handleAcceptAll,
3463
4158
  onRejectAll: handleRejectAll,
@@ -3467,7 +4162,27 @@ function init(userConfig = {}) {
3467
4162
  } else if (config?.showWidget) {
3468
4163
  showWidget({ onClick: handleShowSettings });
3469
4164
  }
4165
+ }
3470
4166
 
4167
+ /**
4168
+ * Initialize Zest with UI.
4169
+ *
4170
+ * Splits into two phases:
4171
+ *
4172
+ * 1. `coreInit()` runs synchronously: interceptors install on the
4173
+ * cookie / storage / script / network channels immediately so any
4174
+ * `defer` or `async` script that fires later is already gated.
4175
+ * Critical — DOMContentLoaded fires AFTER `defer` scripts execute,
4176
+ * so deferring interceptor install means trackers fire first.
4177
+ *
4178
+ * 2. UI mount (banner / widget) is queued until `<body>` exists. If
4179
+ * this script runs in `<head>` while the document is still
4180
+ * parsing, that means waiting for DOMContentLoaded; if it runs
4181
+ * after, mount happens immediately.
4182
+ */
4183
+ function init(userConfig = {}) {
4184
+ coreInit(userConfig);
4185
+ mountUI();
3471
4186
  return Zest;
3472
4187
  }
3473
4188
 
@@ -3576,17 +4291,14 @@ if (typeof window !== 'undefined') {
3576
4291
  window.Zest = Zest;
3577
4292
  }
3578
4293
 
3579
- const autoInit = () => {
3580
- const cfg = getConfig();
3581
- if (cfg.autoInit !== false) {
3582
- init(window.ZestConfig);
3583
- }
3584
- };
3585
-
3586
- if (document.readyState === 'loading') {
3587
- document.addEventListener('DOMContentLoaded', autoInit);
3588
- } else {
3589
- autoInit();
4294
+ // Run init() synchronously on script eval. init() itself splits the
4295
+ // work interceptors install now, UI mount waits for <body> if
4296
+ // needed. No DOMContentLoaded wait at this layer: deferring init()
4297
+ // would let any `defer` / `async` tracker script fire its network
4298
+ // calls before our interceptors are in place.
4299
+ const cfg = getConfig();
4300
+ if (cfg.autoInit !== false) {
4301
+ init(window.ZestConfig);
3590
4302
  }
3591
4303
  }
3592
4304