Version 2.3.0
Two new interceptor layers (network + element-level setter patches) plus a timing fix that lands interceptors before any defer/async tracker can fire
@freshjuice/zest@2.3.0 closes three real gaps surfaced while auditing HubSpot CMS, Cloudflare Zaraz, and server-side GTM sites. See the full release on GitHub.
Zero runtime change for v2.2 consumers. Every default is preserved. Interceptors now install slightly earlier (script eval instead of
DOMContentLoaded), which is strictly more protective.
Highlights
Fixed: interceptors install on script eval, not on DOMContentLoaded
The previous full-build entry waited for DOMContentLoaded before calling Zest.init(). That meant all interceptors (cookies, storage, scripts) installed AFTER defer and async tracker scripts had already executed. So HubSpot, GTM, and similar defer-loaded trackers got to fire their pre-consent calls before our hooks were in place.
init() now runs synchronously when the bundle is evaluated. Only the UI mount (banner / widget, which needs <body>) is deferred until the DOM is ready.
No config change required. Sites that inline or load Zest at the top of <head> (the documented setup) immediately get the interceptor coverage they always thought they had.
Added: network interceptor
Patches window.fetch, XMLHttpRequest, and navigator.sendBeacon. Matches every outbound URL against the same blockedDomains + mode-based tracker list that the script blocker already uses. Blocked requests resolve as:
| API | Blocked behavior |
|---|---|
fetch(url) | Resolves to Response with status 204 and empty body |
XMLHttpRequest | Fires error event with readyState=4, status=0 |
navigator.sendBeacon(url, data) | Returns false (spec's "not queued" signal) |
Zest.init({
blockedDomains: [
{ domain: 'track-eu1.hubspot.com', category: 'analytics' }
]
});The HubSpot scriptloader at /hs/scriptloader/{portalId}.js still loads (first-party path, not blockable by hostname) — but its runtime beacons to track-eu1.hubspot.com are now caught. Same pattern applies to Cloudflare Zaraz (/cdn-cgi/zaraz/), server-side GTM custom subdomains, Shopify, Webflow.
No replay. Network requests are one-shot and time-sensitive — replaying a stale beacon after consent would create duplicate data. Blocked requests are dropped, not queued.
Added: element interceptor (synchronous setter patches)
The MutationObserver-based script blocker fires asynchronously. By the time the observer runs, the browser has already kicked off the fetch for any <script src> / <link href> / <img src> that just got added to the DOM. The script may not execute (we flip type to text/plain), but the network request already went out — and to a privacy auditor that fetch IS a pre-consent leak.
Zest now also installs synchronous prototype-setter patches:
| Patched | Catches |
|---|---|
HTMLScriptElement.src setter | script.src = "..." |
HTMLLinkElement.href setter | link.href = "..." (stylesheets, preload, prefetch) |
HTMLImageElement.src setter | img.src = "..." (tracking pixels) |
HTMLIFrameElement.src setter | iframe.src = "..." (tracking iframes) |
Element.prototype.setAttribute | el.setAttribute("src", "...") for the same four element types |
window.Image constructor | new Image() flow |
const s = document.createElement('script');
s.src = 'https://tracker.example/track.js'; // ← intercepted HERE,
// URL never lands on the element
document.head.appendChild(s); // ← appendChild fires nothingReplay supported. Blocked element writes are queued with their category. When consent arrives, replayElements() re-applies the URL via the ORIGINAL setter for every element still connected to the DOM, so previously-blocked scripts / stylesheets / images execute without a page reload — same UX as the existing cookie / storage / script replay path.
What it does NOT catch: inline HTML <script src="..."> and <link rel="stylesheet" href="..."> tags parsed from the original document response. The browser fetches those during HTML parsing, BEFORE any JavaScript runs. The only complete fix for that class is server-side CSP or template-time removal (e.g. self-host Google Fonts instead of using fonts.googleapis.com).
Element interception is gated by the same intercept.scripts toggle as the MutationObserver blocker — they're both element-level defences and ship together.
Added: intercept.network toggle
Defaults to true. Headless integrations that route their own network calls through the consent layer can opt out:
Zest.init({
intercept: { network: false }
});Why this release exists
CMSes increasingly proxy tracker scripts through the site's own origin to defeat ad-blockers (HubSpot CMS /hs/scriptloader/, Cloudflare Zaraz /cdn-cgi/zaraz/, server-side GTM, Shopify, Webflow). A hostname-based script blocker cannot match those <script> tags because the hostname is first-party. But at runtime the proxied script still phones home to the vendor's analytics endpoint via fetch / XHR / beacon — and that URL IS third-party. The network and element interceptors close those gaps from two complementary angles.
TypeScript types
InterceptToggles now exposes network as a first-class typed field in both zest.d.ts and zest.headless.d.ts.
import Zest, { InitOptions } from '@freshjuice/zest/headless';
const config: InitOptions = {
respectDNT: true,
intercept: { network: false }
};
Zest.init(config);Migration from v2.2
None required. Every existing config keeps working exactly as before. The timing fix is strictly more protective, the two new interceptors default to on, and the new intercept.network toggle defaults to true (matches the implicit prior behaviour of "no network interception" by simply not having had it — there's nothing to migrate).
Install
npm install @freshjuice/zest// Full build (banner + modal + widget, auto-init)
import Zest from '@freshjuice/zest';
// Headless (BYO UI, manual init)
import Zest from '@freshjuice/zest/headless';
Zest.init({
intercept: { network: false }
});Or via CDN:
<script src="https://cdn.jsdelivr.net/npm/@freshjuice/zest@2"></script> 