@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
@@ -241,19 +241,19 @@ let originalCookieDescriptor = null;
241
241
  // Upper bound on the number of queued cookies awaiting consent replay.
242
242
  // An unbounded queue is a memory-exhaustion DoS vector — a hostile
243
243
  // script could flood it with document.cookie writes.
244
- const MAX_QUEUE_SIZE$2 = 100;
244
+ const MAX_QUEUE_SIZE$3 = 100;
245
245
 
246
246
  // Queue for blocked cookies
247
247
  const cookieQueue = [];
248
248
 
249
249
  // Reference to consent checker function
250
- let checkConsent$3 = () => false;
250
+ let checkConsent$5 = () => false;
251
251
 
252
252
  /**
253
253
  * Set the consent checker function
254
254
  */
255
- function setConsentChecker$2(fn) {
256
- checkConsent$3 = fn;
255
+ function setConsentChecker$4(fn) {
256
+ checkConsent$5 = fn;
257
257
  }
258
258
 
259
259
  /**
@@ -309,10 +309,10 @@ function interceptCookies() {
309
309
 
310
310
  const category = getCategoryForName(name);
311
311
 
312
- if (checkConsent$3(category)) {
312
+ if (checkConsent$5(category)) {
313
313
  // Consent given - set cookie
314
314
  originalCookieDescriptor.set.call(document, value);
315
- } else if (cookieQueue.length < MAX_QUEUE_SIZE$2) {
315
+ } else if (cookieQueue.length < MAX_QUEUE_SIZE$3) {
316
316
  // No consent - queue for later (capped to prevent DoS)
317
317
  cookieQueue.push({
318
318
  value,
@@ -337,7 +337,7 @@ function interceptCookies() {
337
337
 
338
338
  // Upper bound on queued operations awaiting consent replay — unbounded
339
339
  // growth would be a memory-exhaustion DoS vector.
340
- const MAX_QUEUE_SIZE$1 = 200;
340
+ const MAX_QUEUE_SIZE$2 = 200;
341
341
 
342
342
  // Store originals
343
343
  let originalLocalStorage = null;
@@ -348,13 +348,13 @@ const localStorageQueue = [];
348
348
  const sessionStorageQueue = [];
349
349
 
350
350
  // Reference to consent checker function
351
- let checkConsent$2 = () => false;
351
+ let checkConsent$4 = () => false;
352
352
 
353
353
  /**
354
354
  * Set the consent checker function
355
355
  */
356
- function setConsentChecker$1(fn) {
357
- checkConsent$2 = fn;
356
+ function setConsentChecker$3(fn) {
357
+ checkConsent$4 = fn;
358
358
  }
359
359
 
360
360
  /**
@@ -367,9 +367,9 @@ function createStorageProxy(storage, queue, storageName) {
367
367
  return (key, value) => {
368
368
  const category = getCategoryForName(key);
369
369
 
370
- if (checkConsent$2(category)) {
370
+ if (checkConsent$4(category)) {
371
371
  target.setItem(key, value);
372
- } else if (queue.length < MAX_QUEUE_SIZE$1) {
372
+ } else if (queue.length < MAX_QUEUE_SIZE$2) {
373
373
  queue.push({
374
374
  key,
375
375
  value,
@@ -652,11 +652,11 @@ function isThirdParty(url) {
652
652
 
653
653
  // Categories the author has declared blockable. A script can self-label
654
654
  // into one of these, but not into 'essential' (a common bypass).
655
- const BLOCKABLE_CATEGORIES = new Set(['functional', 'analytics', 'marketing']);
655
+ const BLOCKABLE_CATEGORIES$2 = new Set(['functional', 'analytics', 'marketing']);
656
656
 
657
657
  // Upper bound on queued scripts awaiting consent replay — prevents a
658
658
  // hostile page from flooding the queue with <script> nodes.
659
- const MAX_QUEUE_SIZE = 500;
659
+ const MAX_QUEUE_SIZE$1 = 500;
660
660
 
661
661
  // Queue for blocked scripts — the authoritative source for replay,
662
662
  // snapshotting src/inline BEFORE any DOM mutation so later tampering
@@ -667,31 +667,31 @@ const scriptQueue = [];
667
667
  let observer = null;
668
668
 
669
669
  // Current blocking mode
670
- let blockingMode = 'safe';
670
+ let blockingMode$2 = 'safe';
671
671
 
672
672
  // Custom blocked domains (user-defined)
673
- let customBlockedDomains = [];
673
+ let customBlockedDomains$2 = [];
674
674
 
675
675
  // Reference to consent checker function
676
- let checkConsent$1 = () => false;
676
+ let checkConsent$3 = () => false;
677
677
 
678
678
  /**
679
679
  * Set the consent checker function
680
680
  */
681
- function setConsentChecker(fn) {
682
- checkConsent$1 = fn;
681
+ function setConsentChecker$2(fn) {
682
+ checkConsent$3 = fn;
683
683
  }
684
684
 
685
685
  /**
686
686
  * Check if script URL matches custom blocked domains
687
687
  */
688
- function matchesCustomDomains(url) {
689
- if (!url || customBlockedDomains.length === 0) return null;
688
+ function matchesCustomDomains$2(url) {
689
+ if (!url || customBlockedDomains$2.length === 0) return null;
690
690
 
691
691
  try {
692
692
  const hostname = new URL(url).hostname.toLowerCase();
693
693
 
694
- for (const entry of customBlockedDomains) {
694
+ for (const entry of customBlockedDomains$2) {
695
695
  const domain = typeof entry === 'string' ? entry : entry.domain;
696
696
  const category = typeof entry === 'string' ? 'marketing' : (entry.category || 'marketing');
697
697
 
@@ -724,7 +724,7 @@ function getScriptBlockCategory(script) {
724
724
  // Only honor values from the blockable set; 'essential' and unknown
725
725
  // values fall through to the other checks.
726
726
  const explicitCategory = script.getAttribute('data-consent-category');
727
- const explicitBlockable = explicitCategory && BLOCKABLE_CATEGORIES.has(explicitCategory)
727
+ const explicitBlockable = explicitCategory && BLOCKABLE_CATEGORIES$2.has(explicitCategory)
728
728
  ? explicitCategory
729
729
  : null;
730
730
 
@@ -736,17 +736,17 @@ function getScriptBlockCategory(script) {
736
736
  }
737
737
 
738
738
  // 2. Check custom blocked domains
739
- const customCategory = matchesCustomDomains(src);
739
+ const customCategory = matchesCustomDomains$2(src);
740
740
 
741
741
  // 3. Mode-based blocking
742
742
  let modeCategory = null;
743
- switch (blockingMode) {
743
+ switch (blockingMode$2) {
744
744
  case 'manual':
745
745
  break;
746
746
 
747
747
  case 'safe':
748
748
  case 'strict':
749
- modeCategory = getCategoryForScript(src, blockingMode);
749
+ modeCategory = getCategoryForScript(src, blockingMode$2);
750
750
  break;
751
751
 
752
752
  case 'doomsday':
@@ -780,7 +780,7 @@ function blockScript(script) {
780
780
  return false;
781
781
  }
782
782
 
783
- if (checkConsent$1(category)) {
783
+ if (checkConsent$3(category)) {
784
784
  // Consent already given - allow script
785
785
  script.setAttribute('data-zest-processed', 'allowed');
786
786
  return false;
@@ -814,7 +814,7 @@ function blockScript(script) {
814
814
  script.removeAttribute('src');
815
815
  }
816
816
 
817
- if (scriptQueue.length < MAX_QUEUE_SIZE) {
817
+ if (scriptQueue.length < MAX_QUEUE_SIZE$1) {
818
818
  scriptQueue.push(scriptInfo);
819
819
  }
820
820
  return true;
@@ -895,8 +895,8 @@ function handleMutations(mutations) {
895
895
  * Start observing for new scripts
896
896
  */
897
897
  function startScriptBlocking(mode = 'safe', customDomains = []) {
898
- blockingMode = mode;
899
- customBlockedDomains = customDomains;
898
+ blockingMode$2 = mode;
899
+ customBlockedDomains$2 = customDomains;
900
900
 
901
901
  // Process existing scripts
902
902
  processExistingScripts();
@@ -912,6 +912,568 @@ function startScriptBlocking(mode = 'safe', customDomains = []) {
912
912
  return true;
913
913
  }
914
914
 
915
+ /**
916
+ * Network Interceptor - Intercepts fetch / XHR / sendBeacon calls
917
+ *
918
+ * Why this exists separately from the script blocker:
919
+ *
920
+ * Modern CMSes (HubSpot, Cloudflare Zaraz, server-side GTM, Shopify,
921
+ * Webflow) increasingly proxy their tracker code through the site's own
922
+ * origin to defeat ad-blockers. The <script> tag itself is first-party
923
+ * (e.g. /hs/scriptloader/{id}.js) so a hostname-based script blocker
924
+ * cannot match it. But at RUNTIME that script still phones home to the
925
+ * vendor's analytics endpoint via fetch / XHR / sendBeacon — and THAT
926
+ * URL is third-party.
927
+ *
928
+ * This interceptor sits on the network layer and uses the same
929
+ * customBlockedDomains + mode-based tracker list as the script blocker.
930
+ * Whatever the user told Zest to block (typically generated by an AI
931
+ * audit) gets blocked regardless of which API the tracker uses.
932
+ *
933
+ * No replay: network calls are one-shot and time-sensitive. Replaying a
934
+ * stale beacon after consent would create confusing / duplicated data,
935
+ * so blocked requests are dropped, not queued.
936
+ */
937
+
938
+
939
+ // Originals captured at install time. Stored for restoration tests and
940
+ // for any internal Zest network calls we may add later.
941
+ let originalFetch = null;
942
+ let originalXhrOpen = null;
943
+ let originalXhrSend = null;
944
+ let originalSendBeacon = null;
945
+
946
+ let blockingMode$1 = 'safe';
947
+ let customBlockedDomains$1 = [];
948
+ let installed$1 = false;
949
+
950
+ let checkConsent$2 = () => false;
951
+
952
+ const BLOCKABLE_CATEGORIES$1 = new Set(['functional', 'analytics', 'marketing']);
953
+
954
+ function setConsentChecker$1(fn) {
955
+ checkConsent$2 = fn;
956
+ }
957
+
958
+ /**
959
+ * Resolve a Request | URL | string to an absolute URL string. Returns
960
+ * null if the input cannot be parsed — callers treat null as "do not
961
+ * block" (we'd rather let an opaque request through than crash the page).
962
+ */
963
+ function resolveUrl(input) {
964
+ try {
965
+ if (typeof input === 'string') {
966
+ return new URL(input, location.href).href;
967
+ }
968
+ if (input && typeof input === 'object') {
969
+ if (typeof input.url === 'string') {
970
+ // Request object
971
+ return new URL(input.url, location.href).href;
972
+ }
973
+ if (typeof input.href === 'string') {
974
+ // URL object
975
+ return input.href;
976
+ }
977
+ }
978
+ } catch (e) {
979
+ // fallthrough
980
+ }
981
+ return null;
982
+ }
983
+
984
+ /**
985
+ * Match a URL against the user's customBlockedDomains list. Mirrors
986
+ * matchesCustomDomains() in script-blocker.js — kept inline rather than
987
+ * shared so each interceptor can be lifted independently.
988
+ */
989
+ function matchesCustomDomains$1(hostname) {
990
+ if (!hostname || customBlockedDomains$1.length === 0) return null;
991
+ const host = hostname.toLowerCase();
992
+ for (const entry of customBlockedDomains$1) {
993
+ const domain = (typeof entry === 'string' ? entry : entry?.domain || '').toLowerCase();
994
+ if (!domain) continue;
995
+ const category = typeof entry === 'string'
996
+ ? 'marketing'
997
+ : (BLOCKABLE_CATEGORIES$1.has(entry?.category) ? entry.category : 'marketing');
998
+ if (host === domain || host.endsWith('.' + domain)) {
999
+ return category;
1000
+ }
1001
+ }
1002
+ return null;
1003
+ }
1004
+
1005
+ /**
1006
+ * Decide whether a URL should be blocked and return its category, or
1007
+ * null if it should pass through. Priority: customBlockedDomains >
1008
+ * mode-based tracker list (matching script-blocker priority).
1009
+ */
1010
+ function getBlockCategory$1(url) {
1011
+ if (!url) return null;
1012
+ let hostname;
1013
+ try {
1014
+ hostname = new URL(url, location.href).hostname;
1015
+ } catch (e) {
1016
+ return null;
1017
+ }
1018
+
1019
+ const customCategory = matchesCustomDomains$1(hostname);
1020
+ if (customCategory) return customCategory;
1021
+
1022
+ switch (blockingMode$1) {
1023
+ case 'manual':
1024
+ return null;
1025
+ case 'safe':
1026
+ case 'strict':
1027
+ return getCategoryForScript(url, blockingMode$1);
1028
+ case 'doomsday':
1029
+ if (isThirdParty(url)) {
1030
+ return getCategoryForScript(url, 'strict') || 'marketing';
1031
+ }
1032
+ return null;
1033
+ default:
1034
+ return null;
1035
+ }
1036
+ }
1037
+
1038
+ /**
1039
+ * Should the request be blocked right now? Returns the category that
1040
+ * caused the block (for logging / callbacks later) or null.
1041
+ */
1042
+ function shouldBlock$1(url) {
1043
+ const category = getBlockCategory$1(url);
1044
+ if (!category) return null;
1045
+ if (checkConsent$2(category)) return null;
1046
+ return category;
1047
+ }
1048
+
1049
+ /**
1050
+ * Construct an empty, successful-looking Response for a blocked fetch.
1051
+ * Status 204 (No Content) is the most honest "we deliberately returned
1052
+ * nothing" signal. Trackers that .then(r => r.json()) will get an empty
1053
+ * body and typically silently move on.
1054
+ */
1055
+ function blockedResponse() {
1056
+ // Some environments (older browsers, strict CSP) may not have Response
1057
+ // — fall back to a thenable shape the most common tracker code expects.
1058
+ if (typeof Response === 'function') {
1059
+ return new Response(null, { status: 204, statusText: 'Blocked by Zest' });
1060
+ }
1061
+ const fake = {
1062
+ ok: false,
1063
+ status: 204,
1064
+ statusText: 'Blocked by Zest',
1065
+ json: () => Promise.resolve({}),
1066
+ text: () => Promise.resolve(''),
1067
+ arrayBuffer: () => Promise.resolve(new ArrayBuffer(0))
1068
+ };
1069
+ return fake;
1070
+ }
1071
+
1072
+ /**
1073
+ * Install fetch hook. Captures the original so we can both restore it
1074
+ * later and use it for any internal Zest network calls.
1075
+ */
1076
+ function patchFetch() {
1077
+ if (typeof window === 'undefined' || typeof window.fetch !== 'function') return;
1078
+ originalFetch = window.fetch.bind(window);
1079
+
1080
+ window.fetch = function zestPatchedFetch(input, init) {
1081
+ const url = resolveUrl(input);
1082
+ if (shouldBlock$1(url)) {
1083
+ return Promise.resolve(blockedResponse());
1084
+ }
1085
+ return originalFetch(input, init);
1086
+ };
1087
+ }
1088
+
1089
+ /**
1090
+ * Install XMLHttpRequest hook. We patch .open() to capture the URL on
1091
+ * the instance, then .send() to decide whether to abort. Using a hidden
1092
+ * symbol on the instance avoids leaking state and survives any wrapping
1093
+ * code that reassigns request properties.
1094
+ */
1095
+ const URL_KEY = Symbol('zestUrl');
1096
+
1097
+ function patchXhr() {
1098
+ if (typeof XMLHttpRequest === 'undefined') return;
1099
+ const proto = XMLHttpRequest.prototype;
1100
+ originalXhrOpen = proto.open;
1101
+ originalXhrSend = proto.send;
1102
+
1103
+ proto.open = function (method, url, ...rest) {
1104
+ this[URL_KEY] = typeof url === 'string' ? url : (url && url.href) || '';
1105
+ return originalXhrOpen.call(this, method, url, ...rest);
1106
+ };
1107
+
1108
+ proto.send = function (body) {
1109
+ const url = this[URL_KEY];
1110
+ if (shouldBlock$1(url)) {
1111
+ // Mimic the failure mode of a network error: queueMicrotask is
1112
+ // used so consumers that synchronously attach handlers after
1113
+ // .send() still receive the events.
1114
+ const xhr = this;
1115
+ queueMicrotask(() => {
1116
+ try {
1117
+ // Best-effort — readonly props in some environments
1118
+ Object.defineProperty(xhr, 'readyState', { value: 4, configurable: true });
1119
+ Object.defineProperty(xhr, 'status', { value: 0, configurable: true });
1120
+ } catch (e) {
1121
+ // ignore
1122
+ }
1123
+ try {
1124
+ xhr.dispatchEvent(new Event('error'));
1125
+ xhr.dispatchEvent(new Event('loadend'));
1126
+ } catch (e) {
1127
+ // ignore
1128
+ }
1129
+ });
1130
+ return;
1131
+ }
1132
+ return originalXhrSend.call(this, body);
1133
+ };
1134
+ }
1135
+
1136
+ /**
1137
+ * Install navigator.sendBeacon hook. Returning false matches the spec's
1138
+ * "data was not queued" semantics; trackers that check the return value
1139
+ * fall back to fetch (which we also block) or give up.
1140
+ */
1141
+ function patchSendBeacon() {
1142
+ if (typeof navigator === 'undefined' || typeof navigator.sendBeacon !== 'function') return;
1143
+ originalSendBeacon = navigator.sendBeacon.bind(navigator);
1144
+
1145
+ navigator.sendBeacon = function zestPatchedSendBeacon(url, data) {
1146
+ if (shouldBlock$1(typeof url === 'string' ? url : (url && url.href) || '')) {
1147
+ return false;
1148
+ }
1149
+ return originalSendBeacon(url, data);
1150
+ };
1151
+ }
1152
+
1153
+ /**
1154
+ * Install all network hooks. Safe to call multiple times — subsequent
1155
+ * calls just refresh mode + custom domain config without re-wrapping.
1156
+ */
1157
+ function interceptNetwork(mode = 'safe', customDomains = []) {
1158
+ blockingMode$1 = mode;
1159
+ customBlockedDomains$1 = Array.isArray(customDomains) ? customDomains : [];
1160
+
1161
+ if (installed$1) return true;
1162
+ patchFetch();
1163
+ patchXhr();
1164
+ patchSendBeacon();
1165
+ installed$1 = true;
1166
+ return true;
1167
+ }
1168
+
1169
+ /**
1170
+ * Element Interceptor - Catches tracker elements BEFORE the browser fetches them.
1171
+ *
1172
+ * The script-blocker uses MutationObserver. That fires asynchronously
1173
+ * (microtask after the DOM mutation), so by the time we can react the
1174
+ * browser has already kicked off the network request for the src/href.
1175
+ * The script may not execute (we flip type to text/plain) but the
1176
+ * fetch already left the building — and to ConsentTheater / a privacy
1177
+ * audit that fetch IS a pre-consent leak.
1178
+ *
1179
+ * This interceptor patches the prototype setters and Element.setAttribute
1180
+ * synchronously, so when code does:
1181
+ *
1182
+ * const s = document.createElement('script');
1183
+ * s.src = 'https://tracker.example/track.js'; // ← intercepted HERE
1184
+ * document.head.appendChild(s); // ← src is already empty,
1185
+ * // no fetch ever fired
1186
+ *
1187
+ * Covers four element types and both ways to set the URL:
1188
+ *
1189
+ * - HTMLScriptElement src
1190
+ * - HTMLLinkElement href (stylesheets, prefetch, preload, dns-prefetch)
1191
+ * - HTMLImageElement src (tracking pixels)
1192
+ * - HTMLIFrameElement src (tracking iframes)
1193
+ *
1194
+ * Plus the global Image() constructor used by classic pixel trackers.
1195
+ *
1196
+ * What this does NOT catch: inline HTML <script src=...> / <link href=...>
1197
+ * tags parsed from the original HTML response. The browser starts those
1198
+ * fetches as soon as it encounters the tag during parsing, BEFORE any
1199
+ * JavaScript runs. The only complete fix for that class is server-side
1200
+ * CSP or template-time removal.
1201
+ */
1202
+
1203
+
1204
+ // Upper bound on queued blocked elements. Unbounded growth would be a
1205
+ // memory-exhaustion vector if a page (or a hostile script) tried to
1206
+ // flood us with src writes.
1207
+ const MAX_QUEUE_SIZE = 500;
1208
+
1209
+ // Queue of blocked element writes. Each entry remembers enough to
1210
+ // re-apply the original URL via the ORIGINAL setter once consent
1211
+ // arrives for its category. Without this queue, blocked scripts /
1212
+ // stylesheets / images would be lost forever and require a page
1213
+ // reload to come back.
1214
+ const elementQueue = [];
1215
+
1216
+ let blockingMode = 'safe';
1217
+ let customBlockedDomains = [];
1218
+ let installed = false;
1219
+ let checkConsent$1 = () => false;
1220
+
1221
+ const BLOCKABLE_CATEGORIES = new Set(['functional', 'analytics', 'marketing']);
1222
+
1223
+ // Map of tag name -> attribute name that carries a URL we may want to
1224
+ // block. Lowercased on both sides; setAttribute() gating uses this.
1225
+ const URL_ATTRS = {
1226
+ script: 'src',
1227
+ link: 'href',
1228
+ img: 'src',
1229
+ iframe: 'src'
1230
+ };
1231
+
1232
+ function setConsentChecker(fn) {
1233
+ checkConsent$1 = fn;
1234
+ }
1235
+
1236
+ function matchesCustomDomains(hostname) {
1237
+ if (!hostname || customBlockedDomains.length === 0) return null;
1238
+ const host = hostname.toLowerCase();
1239
+ for (const entry of customBlockedDomains) {
1240
+ const domain = (typeof entry === 'string' ? entry : entry?.domain || '').toLowerCase();
1241
+ if (!domain) continue;
1242
+ const category = typeof entry === 'string'
1243
+ ? 'marketing'
1244
+ : (BLOCKABLE_CATEGORIES.has(entry?.category) ? entry.category : 'marketing');
1245
+ if (host === domain || host.endsWith('.' + domain)) {
1246
+ return category;
1247
+ }
1248
+ }
1249
+ return null;
1250
+ }
1251
+
1252
+ function getBlockCategory(url) {
1253
+ if (!url) return null;
1254
+ let hostname;
1255
+ try {
1256
+ hostname = new URL(url, location.href).hostname;
1257
+ } catch (e) {
1258
+ return null;
1259
+ }
1260
+
1261
+ const customCategory = matchesCustomDomains(hostname);
1262
+ if (customCategory) return customCategory;
1263
+
1264
+ switch (blockingMode) {
1265
+ case 'manual':
1266
+ return null;
1267
+ case 'safe':
1268
+ case 'strict':
1269
+ return getCategoryForScript(url, blockingMode);
1270
+ case 'doomsday':
1271
+ if (isThirdParty(url)) {
1272
+ return getCategoryForScript(url, 'strict') || 'marketing';
1273
+ }
1274
+ return null;
1275
+ default:
1276
+ return null;
1277
+ }
1278
+ }
1279
+
1280
+ function shouldBlock(url) {
1281
+ const category = getBlockCategory(url);
1282
+ if (!category) return null;
1283
+ if (checkConsent$1(category)) return null;
1284
+ return category;
1285
+ }
1286
+
1287
+ /**
1288
+ * Replace the property setter for `prop` on `ProtoCtor.prototype` with
1289
+ * a gated version. Returns the original descriptor so we can restore.
1290
+ */
1291
+ function patchUrlSetter(ProtoCtor, prop) {
1292
+ if (typeof ProtoCtor !== 'function' || !ProtoCtor.prototype) return null;
1293
+ const proto = ProtoCtor.prototype;
1294
+ const desc = Object.getOwnPropertyDescriptor(proto, prop);
1295
+ if (!desc || typeof desc.set !== 'function') return null;
1296
+
1297
+ Object.defineProperty(proto, prop, {
1298
+ configurable: true,
1299
+ enumerable: desc.enumerable,
1300
+ get: desc.get,
1301
+ set(value) {
1302
+ if (typeof value === 'string') {
1303
+ const category = shouldBlock(value);
1304
+ if (category) {
1305
+ // Don't pass through to the original setter — the URL never
1306
+ // touches the element. Stash the element + URL + category
1307
+ // + original descriptor in the queue so replayElements()
1308
+ // can reinstate it once consent arrives.
1309
+ if (elementQueue.length < MAX_QUEUE_SIZE) {
1310
+ elementQueue.push({
1311
+ element: this,
1312
+ setter: desc.set,
1313
+ prop,
1314
+ value,
1315
+ category,
1316
+ method: 'property'
1317
+ });
1318
+ }
1319
+ return;
1320
+ }
1321
+ }
1322
+ return desc.set.call(this, value);
1323
+ }
1324
+ });
1325
+
1326
+ return desc;
1327
+ }
1328
+
1329
+ function patchSetAttribute() {
1330
+ if (typeof Element === 'undefined' || !Element.prototype) return null;
1331
+ const orig = Element.prototype.setAttribute;
1332
+
1333
+ Element.prototype.setAttribute = function patchedSetAttribute(name, value) {
1334
+ // Fast path: bail out for anything not on our watchlist before doing
1335
+ // any string work. setAttribute is hot — keep this cheap.
1336
+ if (typeof name !== 'string' || typeof value !== 'string' || !this || !this.tagName) {
1337
+ return orig.call(this, name, value);
1338
+ }
1339
+ const tag = this.tagName.toLowerCase();
1340
+ const watched = URL_ATTRS[tag];
1341
+ if (!watched) {
1342
+ return orig.call(this, name, value);
1343
+ }
1344
+ const attr = name.toLowerCase();
1345
+ if (attr !== watched) {
1346
+ return orig.call(this, name, value);
1347
+ }
1348
+ const category = shouldBlock(value);
1349
+ if (category) {
1350
+ // Drop silently and queue for replay. The element keeps any
1351
+ // other attributes you set before / after.
1352
+ if (elementQueue.length < MAX_QUEUE_SIZE) {
1353
+ elementQueue.push({
1354
+ element: this,
1355
+ setter: orig, // setAttribute itself, called like orig.call(el, name, value)
1356
+ prop: name,
1357
+ value,
1358
+ category,
1359
+ method: 'attribute'
1360
+ });
1361
+ }
1362
+ return;
1363
+ }
1364
+ return orig.call(this, name, value);
1365
+ };
1366
+
1367
+ return orig;
1368
+ }
1369
+
1370
+ function patchImageConstructor() {
1371
+ if (typeof window === 'undefined' || typeof window.Image !== 'function') return null;
1372
+ const OrigImage = window.Image;
1373
+
1374
+ function PatchedImage(width, height) {
1375
+ const img = arguments.length >= 2
1376
+ ? new OrigImage(width, height)
1377
+ : new OrigImage();
1378
+ // No work needed here — the .src setter patch on HTMLImageElement
1379
+ // will catch any later assignment. PatchedImage exists mainly to
1380
+ // expose the .src patch via this path for `new Image()` users.
1381
+ return img;
1382
+ }
1383
+ PatchedImage.prototype = OrigImage.prototype;
1384
+ // Copy any static fields just in case.
1385
+ for (const key of Object.keys(OrigImage)) {
1386
+ try { PatchedImage[key] = OrigImage[key]; } catch (e) { /* ignore */ }
1387
+ }
1388
+
1389
+ try {
1390
+ Object.defineProperty(window, 'Image', {
1391
+ configurable: true,
1392
+ writable: true,
1393
+ value: PatchedImage
1394
+ });
1395
+ } catch (e) {
1396
+ window.Image = PatchedImage;
1397
+ }
1398
+
1399
+ return OrigImage;
1400
+ }
1401
+
1402
+ /**
1403
+ * Replay blocked element writes for newly-allowed categories.
1404
+ *
1405
+ * For each queued entry whose category is in `allowedCategories`:
1406
+ * - If the element is still connected to the DOM, re-apply the
1407
+ * URL via the ORIGINAL setter / setAttribute. The browser starts
1408
+ * the fetch as if nothing had been intercepted.
1409
+ * - If the element has since been removed (no `isConnected`), drop
1410
+ * the entry — calling code lost its reference and we have no
1411
+ * parent to attach to.
1412
+ *
1413
+ * Queue ordering is preserved so that scripts/stylesheets re-execute
1414
+ * in the same order the page originally requested them.
1415
+ */
1416
+ function replayElements(allowedCategories) {
1417
+ if (!Array.isArray(allowedCategories) || elementQueue.length === 0) return;
1418
+ const remaining = [];
1419
+
1420
+ for (const item of elementQueue) {
1421
+ if (!allowedCategories.includes(item.category)) {
1422
+ remaining.push(item);
1423
+ continue;
1424
+ }
1425
+
1426
+ const el = item.element;
1427
+ if (!el || !el.isConnected) {
1428
+ // Element is detached or gone — nothing to re-apply against.
1429
+ continue;
1430
+ }
1431
+
1432
+ try {
1433
+ if (item.method === 'attribute') {
1434
+ item.setter.call(el, item.prop, item.value);
1435
+ } else {
1436
+ item.setter.call(el, item.value);
1437
+ }
1438
+ } catch (e) {
1439
+ // Restoration failed (rare — element might be in a weird state).
1440
+ // Don't requeue; one failure is enough.
1441
+ }
1442
+ }
1443
+
1444
+ elementQueue.length = 0;
1445
+ elementQueue.push(...remaining);
1446
+ }
1447
+
1448
+ /**
1449
+ * Install all element-level interceptors. Idempotent — second call
1450
+ * just refreshes mode + customDomains without rewrapping.
1451
+ */
1452
+ function interceptElements(mode = 'safe', customDomains = []) {
1453
+ blockingMode = mode;
1454
+ customBlockedDomains = Array.isArray(customDomains) ? customDomains : [];
1455
+
1456
+ if (installed) return true;
1457
+
1458
+ if (typeof HTMLScriptElement !== 'undefined') {
1459
+ patchUrlSetter(HTMLScriptElement, 'src');
1460
+ }
1461
+ if (typeof HTMLLinkElement !== 'undefined') {
1462
+ patchUrlSetter(HTMLLinkElement, 'href');
1463
+ }
1464
+ if (typeof HTMLImageElement !== 'undefined') {
1465
+ patchUrlSetter(HTMLImageElement, 'src');
1466
+ }
1467
+ if (typeof HTMLIFrameElement !== 'undefined') {
1468
+ patchUrlSetter(HTMLIFrameElement, 'src');
1469
+ }
1470
+ patchSetAttribute();
1471
+ patchImageConstructor();
1472
+
1473
+ installed = true;
1474
+ return true;
1475
+ }
1476
+
915
1477
  /**
916
1478
  * Default consent categories
917
1479
  */
@@ -1707,7 +2269,8 @@ const DEFAULTS = {
1707
2269
  intercept: {
1708
2270
  cookies: true,
1709
2271
  storage: true,
1710
- scripts: true
2272
+ scripts: true,
2273
+ network: true
1711
2274
  },
1712
2275
 
1713
2276
  // Strictly-necessary declarations. Both fields *append* to whatever
@@ -2138,6 +2701,11 @@ function replayAll(categories) {
2138
2701
  replayCookies(categories);
2139
2702
  replayStorage(categories);
2140
2703
  replayScripts(categories);
2704
+ // Element-level replays (script/link/img/iframe URLs that were
2705
+ // dropped at the prototype-setter / setAttribute layer). Network
2706
+ // interceptor (fetch/XHR/sendBeacon) intentionally has no replay
2707
+ // — beacons are one-shot and resending would duplicate analytics.
2708
+ replayElements(categories);
2141
2709
  }
2142
2710
 
2143
2711
  /**
@@ -2182,6 +2750,8 @@ function coreInit(userConfig = {}) {
2182
2750
  });
2183
2751
  }
2184
2752
 
2753
+ setConsentChecker$4(checkConsent);
2754
+ setConsentChecker$3(checkConsent);
2185
2755
  setConsentChecker$2(checkConsent);
2186
2756
  setConsentChecker$1(checkConsent);
2187
2757
  setConsentChecker(checkConsent);
@@ -2189,12 +2759,22 @@ function coreInit(userConfig = {}) {
2189
2759
  // Interceptor toggles. By default everything is intercepted (back-compat
2190
2760
  // with v2.0 / v2.1). Consumers that gate scripts and storage themselves
2191
2761
  // can opt out per channel via `intercept: { storage: false, … }`.
2192
- const intercept = currentConfig.intercept || { cookies: true, storage: true, scripts: true };
2762
+ const intercept = currentConfig.intercept || { cookies: true, storage: true, scripts: true, network: true };
2193
2763
  if (intercept.cookies !== false) interceptCookies();
2194
2764
  if (intercept.storage !== false) interceptStorage();
2195
2765
  if (intercept.scripts !== false) {
2766
+ // Element-level synchronous interception (prototype setters +
2767
+ // setAttribute) installs BEFORE startScriptBlocking so that the
2768
+ // moment any later script does `el.src = "https://tracker..."`,
2769
+ // we drop the URL before the browser fetches. The MutationObserver
2770
+ // inside startScriptBlocking remains as a defence-in-depth net for
2771
+ // anything that slips past (e.g. nodes constructed via cloneNode).
2772
+ interceptElements(currentConfig.mode, currentConfig.blockedDomains);
2196
2773
  startScriptBlocking(currentConfig.mode, currentConfig.blockedDomains);
2197
2774
  }
2775
+ if (intercept.network !== false) {
2776
+ interceptNetwork(currentConfig.mode, currentConfig.blockedDomains);
2777
+ }
2198
2778
 
2199
2779
  const consent = loadConsent();
2200
2780
  initialized = true;