17 bidirectional woof events. Polymorphic placement abstraction. 103+ E2E tests.
Lines of Source
Woof Events
E2E Spec Files
Test Assertions
The cross-frame system splits the wrapper into two cooperating halves:
a Core that runs the full Prebid auction pipeline, and one or more
lightweight Workers that proxy the publisher's googletag API
and relay results via @hbdev/woof postMessage.
IIFE entry point. Hooks googletag.cmd synchronously before any publisher
code runs. Kicks off role resolution if $crossFrame$ build flag is true.
Single-phase probe: quickProbe (50ms) with CORE_ELECTED broadcast. Top-level frames fast-path to CORE. Nested frames race probe vs broadcast — first to finish wins naturally.
Creates woof core instance, registers all WORKER_TO_CORE_EVENTS,
then dynamically loads the full wrapper (index.js) and
crossFrameCore.js.
566-line proxy. No Prebid, no auction code. Hooks googletag API,
relays events to core, handles targeting/refresh/render commands from core.
504-line core-side handler. Registers hooks at priority 15 for winner targeting, auction delivery, and Google refresh. Manages worker placement lifecycle.
Polymorphic placement extending the base placement interface. Same API as DFP placement, different transport: woof messages instead of local GAM calls.
Every frame loading the wrapper must decide: am I the Core (runs auctions) or a Worker (proxies GAM)? The algorithm resolves to exactly one core per page within ~50ms, regardless of nesting depth or load order. No election, no random priorities — first frame to finish its probe wins naturally.
__woof_core_elected__.@hbdev/woof 0.3.1 quickProbe with 50ms timeout and 5ms ping interval. Races against a __woof_core_elected__ listener.__woof_core_elected__ to all frames, preventing simultaneous CORE assignment.startProbeResponder() begins listening for __woof_ping__ and replying __woof_pong__.deepNesting spec.// Top-level frame: CORE fast path if (isTopLevel) { startProbeResponder(); broadcastCoreElected(); return ROLE_CORE; } // Nested frame: race quickProbe vs CORE_ELECTED broadcast const found = await Promise.race([ probePromise.then(result => result ? 'probe' : false), coreElectedPromise.then(elected => elected ? 'elected' : false), ]); if (found) return ROLE_WORKER; // No core found -- first to load wins startProbeResponder(); broadcastCoreElected(); return ROLE_CORE;
The worker is a 566-line file that contains zero auction logic. It is a pure proxy: hook the publisher's GAM calls, relay to core, execute core's commands. Broken into five logical sections:
Section 1: Bootstrap (lines 1–60)
window.vpb.isWorker = true for runtime role detectionresolvePageContext()crossFrame.events.jsSection 2: Core → Worker Handlers (lines 70–175)
SET_TARGETING → slot.setTargeting(key, value) — mirrors core's auction results onto local GAM slotCLEAR_TARGETING → slot.clearTargeting(key) — HB cleanup after renderTRIGGER_REFRESH → nativeRefs.pubadsRefresh.call() — triggers actual GAM refreshSET_PAGE_TARGETING → pubads().setTargeting() — page-level keysRECREATE_SLOT → Destroys old slot, defines new one, re-announces via SLOT_DEFINEDRENDER_PASSBACK → Injects passback HTML, re-executes <script> tagsSection 3: Secure Creative Relay (lines 176–316)
__pb_locator__ iframe inserted — PUC stops at worker, not corePREBID_REQUEST → core responds via PREBID_RESPONSE → worker forwards to PUCRENDER_AD → worker creates about:blank iframe → sends PREBID_REQUEST → receives PREBID_RESPONSE → writes creative via srcdocresetBodyStyle applied after load for clean renderingSection 4: googletag Hooking & IO Viewability (lines 387–509)
googletag.defineSlot → native call-through + SLOT_DEFINED to coreslot.defineSizeMapping → native call-through + SIZE_MAP_UPDATED to coregoogletag.display → suppressed (core triggers fetch via TRIGGER_REFRESH)googletag.pubads().refresh → intercepted → REFRESH to coreslotRenderEnded listener → SLOT_RENDER_ENDED to coreIntersectionObserver (threshold 0.5) + 1-second timer per element → IMPRESSION_VIEWABLE to coreNot Yet Supported
googletag.defineOutOfPageSlot — interstitials, anchor ads, rewarded ads not yet proxiedgoogletag.destroySlots — core is not notified of slot destructiongoogletag.pubads().enableLazyLoad() — acknowledged TODOgoogletag.pubads().collapseEmptyDivs() — not interceptedslot.setTargeting() (publisher-side) — only core-initiated targeting is relayedSection 5: Bootstrap Handshake (lines 536–566)
WORKER_READY sent immediately on woof connectionCORE_READY before unblockGoogle() flushes the googletag queuedefineSlot/display run through hooks// Worker sends readiness signal to core woof.send({ action: WORKER_READY }); // Core-side: on receiving WORKER_READY, sends CORE_READY back // Worker: on receiving CORE_READY, flushes blocked googletag.cmd queue woof.on(CORE_READY, () => { unblockGoogle(); // Publisher's defineSlot, display, refresh calls now execute // through our hooks and relay to core });
All events are defined in crossFrame.events.js (40 lines) and imported by both
core and worker. No magic strings. The protocol is fully typed by convention.
| Event | Trigger | Params |
|---|---|---|
WORKER_READY |
Worker connected | {} |
SLOT_DEFINED |
googletag.defineSlot() |
elementId, adUnitPath, sizes, targeting, origin, pageRef |
DISPLAY |
googletag.display() |
elementId |
REFRESH |
pubads().refresh() |
elementIds[] |
SLOT_RENDER_ENDED |
slotRenderEnded event |
elementId, isEmpty, size, responseInfo |
PREBID_REQUEST |
PUC or RENDER_AD relay | adId, adServerDomain |
IMPRESSION_VIEWABLE |
impressionViewable event |
elementId |
SIZE_MAP_UPDATED |
slot.defineSizeMapping() |
elementId, sizes |
| Event | Trigger | Params |
|---|---|---|
CORE_READY |
initCrossFrameCore() |
{} |
SET_TARGETING |
winnerTargetingHook | elementId, key, value |
CLEAR_TARGETING |
SLOT_RENDER_ENDED cleanup | elementId, key |
TRIGGER_REFRESH |
auctionDeliveryHook | elementIds[] |
PREBID_RESPONSE |
prebid secureCreatives | ad HTML, width, height, adId |
RENDER_AD |
withDFP=0 winner | elementId, adId, width, height, secureUri |
SET_PAGE_TARGETING |
crossFrame:setPageTargeting | key, value |
RECREATE_SLOT |
crossFrame:recreateSlot | elementId, adUnitPath, sizes |
RENDER_PASSBACK |
passback manager | elementId, html |
// --- Worker → Core events --- export const WORKER_READY = 'workerReady'; export const SLOT_DEFINED = 'slotDefined'; export const DISPLAY = 'display'; export const REFRESH = 'refresh'; export const SLOT_RENDER_ENDED = 'slotRenderEnded'; export const PREBID_REQUEST = 'prebidRequest'; export const IMPRESSION_VIEWABLE = 'impressionViewable'; export const SIZE_MAP_UPDATED = 'sizeMapUpdated'; // --- Core → Worker events --- export const CORE_READY = 'coreReady'; export const SET_TARGETING = 'setTargeting'; export const CLEAR_TARGETING = 'clearTargeting'; export const TRIGGER_REFRESH = 'triggerRefresh'; export const PREBID_RESPONSE = 'prebidResponse'; export const RENDER_AD = 'renderAd'; export const SET_PAGE_TARGETING = 'setPageTargeting'; export const RECREATE_SLOT = 'recreateSlot'; export const RENDER_PASSBACK = 'renderPassback';
The cross-frame placement extends the base placement with the same interface as a DFP placement. Hooks do not know whether they are operating on a local GAM slot or a remote one in a worker frame. This polymorphism is the key architectural decision that lets all existing auction, targeting, and refresh logic work unchanged.
setSlotTargeting(k, v) { this.slot.setTargeting(k, v); } clearSlotTargeting(k) { this.slot.clearTargeting(k); } getSizes() { return this.slot.getSizes(); }
setSlotTargeting(k, v) { targeting[k] = v; woofCore.send({ action: SET_TARGETING, params: { elementId, key: k, value: v } }, workerId); } clearSlotTargeting(k) { delete targeting[k]; woofCore.send({ action: CLEAR_TARGETING, params: { elementId, key: k } }, workerId); }
Same interface, different transport. The winnerTargetingHook and
auctionDeliveryHook call placement.setSlotTargeting() and
placement.clearSlotTargeting() without knowing whether the slot is local or remote.
| Method | Woof Event | Description |
|---|---|---|
setSlotTargeting(k, v) | SET_TARGETING | Mirror HB key-values onto worker's GAM slot |
clearSlotTargeting(k) | CLEAR_TARGETING | Remove HB targeting after render |
getSlotTargeting(k) | — | Read from local targeting map (no woof) |
getSizes() | — | Return sizes from SLOT_DEFINED payload |
prepareForAuction() | — | Build adUnit config for prebid requestBids |
renderWinner() | RENDER_AD | withDFP=0: send creative to worker for FIF render |
renderPassback() | RENDER_PASSBACK | Inject passback HTML in worker frame |
Refresh is the most complex cross-frame operation, with two distinct paths depending on
the withDFP flag. Both paths must handle the core/worker split transparently.
Both paths result in the same iframe depth (4 levels). The real advantage of withDFP=0
is fewer network round-trips: no GAM ad request, no PUC fetch, no SafeFrame
initialization.
| Aspect | withDFP=1 | withDFP=0 |
|---|---|---|
| GAM request | Yes | No |
| Creative delivery | SafeFrame | Friendly iframe, srcdoc |
| Targeting | SET_TARGETING before GAM | Not needed |
| PUC involved | Yes | No (1 fewer HTTP round-trip) |
Viewability tracking uses the browser's native IntersectionObserver API in the
worker frame to detect when an ad slot becomes 50%+ visible (IO threshold 0.5).
After a 1-second timer (IAB compliance), the worker sends an IMPRESSION_VIEWABLE
woof message to the core, which triggers statistics events and bridges the signal to Prebid
adapters via $pbjs$.markBidViewable().
IntersectionObserver is configured with a threshold of 0.5, meaning the callback fires when 50% of the ad slot is visible.viewTimers Map ensures the slot remains continuously visible for 1 full second.latestAdIdByElement Map: Tracks the current winning adId per element ID non-destructively.winnerTargetingHook registers a permanent listener on visibility:{code}:visible1sec for every placement.resetViewability() clears the existing timer and unobserves the container. After the new creative renders, observeViewability() re-arms the IO.const viewTimers = new Map(); function observeViewability(elementId, container) { const observer = new IntersectionObserver((entries) => { for (const entry of entries) { if (entry.intersectionRatio >= 0.5) { viewTimers.set(elementId, setTimeout(() => { woof.send({ action: IMPRESSION_VIEWABLE, params: { elementId } }); }, 1000)); } else { clearTimeout(viewTimers.get(elementId)); } } }, { threshold: 0.5 }); observer.observe(container); } function resetViewability(elementId) { clearTimeout(viewTimers.get(elementId)); viewTimers.delete(elementId); // observer.unobserve(container) called internally }
Prebid's Publisher Universal Creative (PUC) uses postMessage to
request the winning ad markup from the wrapper. In cross-frame mode, the PUC lives inside
the worker's SafeFrame, but Prebid's cache lives in the core. The relay bridges this gap.
__pb_locator__ is Needed
Without the locator iframe in the worker, Prebid's PUC would climb the frame hierarchy
all the way up to the core frame. When the core's resizeRemoteCreative() fires,
it would try to find the ad container element — but the element lives in the
worker frame, not the core frame. The resize would silently fail.
By inserting __pb_locator__ in the worker, PUC stops climbing at the worker level.
The worker intercepts the Prebid Request, relays it to the core via woof, gets the response
back, and forwards it to PUC. The worker also handles resizeAdSlot locally,
where the DOM element actually lives.
// Insert the locator iframe so PUC stops at this frame const locator = document.createElement('iframe'); locator.name = '__pb_locator__'; locator.style.display = 'none'; document.body.appendChild(locator); // Listen for Prebid Request messages from PUC window.addEventListener('message', (event) => { if (event.data.message === 'Prebid Request') { // Relay to core via woof woof.send({ action: PREBID_REQUEST, params: { adId: event.data.adId } }); } });
The worker supports both SafeFrame (PUC) and Friendly Iframe (FIF) rendering paths. This is a deliberate architectural choice providing publishers with maximum flexibility:
Full cross-origin creative isolation via GAM SafeFrame.
PUC uses postMessage for creative delivery. The worker intercepts and relays via woof.
This is the standard, security-hardened path.
For publishers who configure FIF, the worker creates an
about:blank iframe and writes the creative directly via srcdoc.
Publishers accept the tradeoffs in exchange for richer creative interactions.
For FIF, postMessage uses a wildcard target origin ('*') for
about:blank iframes (which have a null origin). This is correct per
the HTML spec and is consistent with how Prebid.js handles FIF rendering in non-cross-frame setups.
The TCF (Transparency and Consent Framework) API relies on a __tcfapiLocator
iframe to establish a cross-frame communication channel. In cross-frame mode, the CMP
may load in a different frame than the worker, requiring careful proxy setup.
__tcfapiLocator iframetcfapi.js walks up the frame hierarchy and finds the locatorpostMessage proxy with the correct (cmd, version, callback, arg) signature__tcfapi normally — the CMP responds directly| Group | Scenario | Expected Behavior |
|---|---|---|
| A | Full consent, host same-origin | All bidders active, GAM request sent |
| B | Full consent, host cross-origin | TCF proxy via postMessage, all bidders active |
| C | No consent | GDPR-blocked bidders filtered, GAM consent mode |
| D | No CMP present | Bidders run without GDPR flags (non-EU assumption) |
| E | GDPR not applicable | All bidders active, no consent string sent |
| F | Delayed CMP load | Wrapper waits for CMP, then proceeds |
| G | Awaiting user action | Wrapper waits for user consent decision |
| H | Partial consent (vendor 410) | Specific vendor blocked, others proceed |
| I | Partial consent (vendor 755) | Specific vendor blocked, others proceed |
All 9 scenarios are covered by 25 test assertions in
tcfConsent.spec.js, making this one of the most thoroughly tested
cross-frame subsystems.
14 spec files, 103+ test assertions. Every topology, timing edge case, and feature combination is covered. The specs run as part of the Playwright E2E suite.
| Spec File | Tests | Coverage Area |
|---|---|---|
sameOrigin.spec.js |
6 | Role negotiation, woof connection, placement creation, GAM dedup, resize |
crossOrigin.spec.js |
5 | Cross-origin role resolution, woof across origins |
deepNesting.spec.js |
6 | Relay frames, split-brain regression (5 back-to-back loads) |
siblings.spec.js |
6 | Peer role resolution without host frame |
hostSameOrigin.spec.js |
5 | Host-as-core topology |
hostCrossOrigin.spec.js |
5 | Primary production topology |
hostBlankIframe.spec.js |
6 | FIF about:blank injection path |
hostEmptySrc.spec.js |
6 | FIF empty-src edge case |
multiWorker.spec.js |
5 | 3 iframes, 1 core + 2 workers |
workerFirst.spec.js |
5 | Staggered 2s delay timing |
refresh.spec.js |
2 | withDFP=1 and withDFP=0 paths |
workerFeatures.spec.js |
7 | AdSense attrs, sizemap, hb_strategy, passback, FIF |
tcfConsent.spec.js |
25 | 9 TCF/GDPR scenarios (groups A through I) |
viewability.spec.js |
4 | IO viewability fires events 18/20 and adapter callback on initial + refresh |
hostDualLoader.spec.js |
3 | Duplicate loader guard |
| TOTAL | ~103 |
page.addInitScript() — no filesystem writese2e/common/: tools.js, configFeatures.js, configAds.js, testConfig.jscrossFrame.utils.js
Each demo page tests a specific topology or feature. Open browser DevTools →
Console and set localStorage.vpbdebug = 3 for full trace output.
All demos are served from /cross-frame/scenarios/.
The entire cross-frame system is 1,423 lines across 7 source files. No external
dependencies beyond @hbdev/woof for the postMessage transport.
| File | Lines | Role | Purpose |
|---|---|---|---|
workerEntry.js |
566 | WORKER | Worker-side proxy: googletag hooks, event relay, creative rendering |
crossFrameCore.js |
504 | CORE | Core-side handlers: hook registrations at priority 15, worker lifecycle |
crossFramePlacement.js |
144 | CORE | Polymorphic placement: same API as DFP placement, woof transport |
crossFrame.events.js |
40 | SHARED | Event constants: 8 worker-to-core + 9 core-to-worker |
roleResolver.js |
119 | LOADER | Single-phase role resolution: quickProbe (50ms) + CORE_ELECTED broadcast |
constants.js |
5 | LOADER | Shared constants (CORE/WORKER role strings) |
crossFrameLoader.js |
55 | LOADER | Creates woof core, loads wrapper + crossFrameCore dynamically |
| TOTAL | 1,433 |