Cross-Frame Technical Architecture
Technical Deep Dive

Cross-Frame Header Bidding
Technical Architecture

17 bidirectional woof events. Polymorphic placement abstraction. 103+ E2E tests.

1,423

Lines of Source

17

Woof Events

14

E2E Spec Files

103+

Test Assertions

01 / 13

Architecture Overview

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.

flowchart LR subgraph Publisher Page L[loader.js] --> RR[roleResolver.js] RR -->|CORE| CF[crossFrameLoader.js] RR -->|WORKER| WE[workerEntry.js] CF --> IDX[index.js full wrapper] CF --> CFC[crossFrameCore.js] end subgraph Worker Frame WE2[workerEntry.js] --> WOOF[woof postMessage] end CFC <-->|woof| WOOF

Component Breakdown

LOADER loader.js

IIFE entry point. Hooks googletag.cmd synchronously before any publisher code runs. Kicks off role resolution if $crossFrame$ build flag is true.

LOADER roleResolver.js

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.

CORE crossFrameLoader.js

Creates woof core instance, registers all WORKER_TO_CORE_EVENTS, then dynamically loads the full wrapper (index.js) and crossFrameCore.js.

WORKER workerEntry.js

566-line proxy. No Prebid, no auction code. Hooks googletag API, relays events to core, handles targeting/refresh/render commands from core.

CORE crossFrameCore.js

504-line core-side handler. Registers hooks at priority 15 for winner targeting, auction delivery, and Google refresh. Manages worker placement lifecycle.

CORE crossFramePlacement.js

Polymorphic placement extending the base placement interface. Same API as DFP placement, different transport: woof messages instead of local GAM calls.

02 / 13

Role Resolution

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.

flowchart TD A[loader.js IIFE] --> B{$crossFrame$?} B -->|false| C[Legacy: load wrapper directly] B -->|true| D[resolveRole] D --> E{window === window.parent?} E -->|yes| F["CORE fast path + startProbeResponder + broadcastCoreElected"] E -->|no| G["Race: quickProbe 50ms vs CORE_ELECTED listener"] G -->|probe pong received| H[WORKER] G -->|CORE_ELECTED broadcast| H G -->|neither within 50ms| I["CORE + startProbeResponder + broadcastCoreElected"]

Key Implementation Details

Resolution Mechanics
  • Top-level frames always become CORE via fast path — no probe overhead. Immediately starts a probe responder and broadcasts __woof_core_elected__.
  • Single-phase probe (50ms): Uses @hbdev/woof 0.3.1 quickProbe with 50ms timeout and 5ms ping interval. Races against a __woof_core_elected__ listener.
  • CORE_ELECTED broadcast: When a frame becomes CORE, it immediately broadcasts __woof_core_elected__ to all frames, preventing simultaneous CORE assignment.
  • Probe responder bridge: On becoming CORE, startProbeResponder() begins listening for __woof_ping__ and replying __woof_pong__.
  • Split-brain prevention: Regression-tested with 5 back-to-back page loads in the deepNesting spec.
  • Worst-case latency: ~50ms (down from ~700ms).
src/loader/roleResolver.js — fast path + probe race
// 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;
03 / 13

workerEntry.js

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)

  • No Prebid imported, no auction code — just woof + googletag hooks
  • Sets window.vpb.isWorker = true for runtime role detection
  • Resolves publisher page context via resolvePageContext()
  • Imports event constants from crossFrame.events.js

Section 2: Core → Worker Handlers (lines 70–175)

  • SET_TARGETINGslot.setTargeting(key, value) — mirrors core's auction results onto local GAM slot
  • CLEAR_TARGETINGslot.clearTargeting(key) — HB cleanup after render
  • TRIGGER_REFRESHnativeRefs.pubadsRefresh.call() — triggers actual GAM refresh
  • SET_PAGE_TARGETINGpubads().setTargeting() — page-level keys
  • RECREATE_SLOT → Destroys old slot, defines new one, re-announces via SLOT_DEFINED
  • RENDER_PASSBACK → Injects passback HTML, re-executes <script> tags

Section 3: Secure Creative Relay (lines 176–316)

  • __pb_locator__ iframe inserted — PUC stops at worker, not core
  • PUC path: SafeFrame creative → "Prebid Request" → worker intercepts → relays to core via PREBID_REQUEST → core responds via PREBID_RESPONSE → worker forwards to PUC
  • FIF path: Core sends RENDER_AD → worker creates about:blank iframe → sends PREBID_REQUEST → receives PREBID_RESPONSE → writes creative via srcdoc
  • resetBodyStyle applied after load for clean rendering

Section 4: googletag Hooking & IO Viewability (lines 387–509)

  • googletag.defineSlot → native call-through + SLOT_DEFINED to core
  • slot.defineSizeMapping → native call-through + SIZE_MAP_UPDATED to core
  • googletag.displaysuppressed (core triggers fetch via TRIGGER_REFRESH)
  • googletag.pubads().refresh → intercepted → REFRESH to core
  • slotRenderEnded listener → SLOT_RENDER_ENDED to core
  • IO Viewability: IntersectionObserver (threshold 0.5) + 1-second timer per element → IMPRESSION_VIEWABLE to core

Not Yet Supported

  • googletag.defineOutOfPageSlot — interstitials, anchor ads, rewarded ads not yet proxied
  • googletag.destroySlots — core is not notified of slot destruction
  • googletag.pubads().enableLazyLoad() — acknowledged TODO
  • googletag.pubads().collapseEmptyDivs() — not intercepted
  • slot.setTargeting() (publisher-side) — only core-initiated targeting is relayed
  • Amazon TAM integration — acknowledged TODO

Section 5: Bootstrap Handshake (lines 536–566)

  • WORKER_READY sent immediately on woof connection
  • Waits for CORE_READY before unblockGoogle() flushes the googletag queue
  • Prevents message loss: hooks install first, then publisher's defineSlot/display run through hooks
workerEntry.js — handshake pattern
// 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
});
04 / 13

Event Protocol — 17 Bidirectional Events

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.

WORKER → CORE 8 Events

EventTriggerParams
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

CORE → WORKER 9 Events

EventTriggerParams
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
src/crossFrame/crossFrame.events.js
// --- 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';
05 / 13

Placement Abstraction

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.

LOCAL DFP Placement

googleHook.js
setSlotTargeting(k, v) {
    this.slot.setTargeting(k, v);
}

clearSlotTargeting(k) {
    this.slot.clearTargeting(k);
}

getSizes() {
    return this.slot.getSizes();
}

REMOTE Cross-Frame Placement

crossFramePlacement.js
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.

Key Methods on crossFramePlacement

MethodWoof EventDescription
setSlotTargeting(k, v)SET_TARGETINGMirror HB key-values onto worker's GAM slot
clearSlotTargeting(k)CLEAR_TARGETINGRemove 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_ADwithDFP=0: send creative to worker for FIF render
renderPassback()RENDER_PASSBACKInject passback HTML in worker frame
06 / 13

Refresh Flows

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.

Decision Tree

flowchart TD A[Refresh timer fires] --> B[prebidManager.startRefreshSlot] B --> C[Strategy resolves refresh opts] C --> D{withDFP?} D -->|withDFP=1| E[Run prebid auction] E --> F[Set HB targeting on placement] F --> G{isCrossFrame?} G -->|yes| H[SET_TARGETING via woof] H --> I[TRIGGER_REFRESH via woof] I --> J[Worker calls native GAM refresh] J --> K[GAM renders creative in SafeFrame] K --> L[Worker sends SLOT_RENDER_ENDED] L --> M[Core processes render result] M --> N[Schedule next refresh] G -->|no| O[Set targeting on local GAM slot] O --> P[googletag.pubads.refresh] P --> Q[slotRenderEnded callback] Q --> N D -->|withDFP=0| R[Run prebid auction] R --> S{winner?} S -->|no| N S -->|yes| T{isCrossFrame?} T -->|no| U[renderAdOnPage locally] U --> N T -->|yes| V[RENDER_AD via woof] V --> W[Worker creates about:blank iframe] W --> X[Worker sends PREBID_REQUEST to core] X --> Y[Core dispatches to prebid secureCreatives] Y --> Z[PREBID_RESPONSE back to worker] Z --> AA[Worker writes creative HTML into iframe] AA --> AB[Worker resizes ad slot] AB --> N

withDFP=1 — GAM Request Path

sequenceDiagram participant Timer as Refresh Timer participant PM as prebidManager (Core) participant Prebid as pbjs (Core) participant CFC as crossFrameCore (Core) participant Woof as woof channel participant Worker as workerEntry (Worker) participant GAM as googletag (Worker) Timer->>PM: startRefreshSlot(withDFP=1) PM->>Prebid: requestBids() Prebid-->>PM: onEnd(winner) Note over PM: withDFP=1 → set targeting + send GAM request PM->>CFC: winnerTargetingHook runs CFC->>Woof: SET_TARGETING(hb_adid, hb_pb, ...) Woof->>Worker: SET_TARGETING Worker->>GAM: slot.setTargeting(key, value) CFC->>Woof: TRIGGER_REFRESH(elementIds) Woof->>Worker: TRIGGER_REFRESH Worker->>GAM: pubads().refresh(slots) GAM-->>Worker: slotRenderEnded event Worker->>Woof: SLOT_RENDER_ENDED(elementId, isEmpty, responseInfo) Woof->>CFC: SLOT_RENDER_ENDED CFC->>PM: googleHook processes render result PM->>Timer: schedule next refresh

withDFP=0 — Direct Render Path

sequenceDiagram participant Timer as Refresh Timer participant PM as prebidManager (Core) participant CFC as crossFrameCore (Core) participant Prebid as pbjs (Core) participant Woof as woof channel participant Worker as workerEntry (Worker) Timer->>PM: startRefreshSlot(withDFP=0) PM->>Prebid: requestBids() Prebid-->>PM: onEnd(winner) Note over PM: withDFP=0 + isCrossFrame → RENDER_AD PM->>Woof: RENDER_AD(elementId, adId, width, height) Woof->>Worker: RENDER_AD Worker->>Worker: Create about:blank iframe in ad slot Worker->>Woof: PREBID_REQUEST(adId) Woof->>CFC: PREBID_REQUEST Note over CFC: Dispatches to prebid secureCreatives via MessageChannel CFC-->>Woof: PREBID_RESPONSE(ad HTML, width, height) Woof-->>Worker: PREBID_RESPONSE Worker->>Worker: doc.write(ad) into friendly iframe Worker->>Worker: resizeAdSlot(elementId, width, height) PM->>Timer: schedule next refresh Note over PM,Worker: No PUC, no iframe.html fetch, no postMessage relay

Key Differences: Network & Rendering Path

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.

AspectwithDFP=1withDFP=0
GAM requestYesNo
Creative deliverySafeFrameFriendly iframe, srcdoc
TargetingSET_TARGETING before GAMNot needed
PUC involvedYesNo (1 fewer HTTP round-trip)
07 / 13

Viewability — IntersectionObserver Pipeline

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().

Full Viewability Flow

sequenceDiagram participant IO as IntersectionObserver (Worker) participant Worker as workerEntry.js (Worker) participant Woof as woof channel participant Core as crossFrameCore.js (Core) participant Emitter as wrapperEmitter (Core) participant Stats as Statistics (Core) participant WTH as winnerTargetingHook listener (Core) participant Prebid as $pbjs$ (Core) participant Adapter as Bidder Adapter (Core) Note over IO,Worker: Ad slot becomes 50%+ visible (IO threshold 0.5) IO->>Worker: intersection ratio >= 0.5 Worker->>Worker: Start 1-second timer (IAB compliance) Note over Worker: viewTimers Map tracks per-element timers Worker->>Worker: Timer fires after 1s continuous visibility Worker->>Woof: IMPRESSION_VIEWABLE { elementId } Woof->>Core: IMPRESSION_VIEWABLE Core->>Emitter: emit visibility:{elementId}:visible Core->>Emitter: emit visibility:{elementId}:visible1sec Emitter->>Stats: multitracking event 18 (PLACEMENT_IS_VISIBLE) Emitter->>Stats: multitracking event 20 (PLACEMENT_IS_VISIBLE_IAB) Emitter->>WTH: visibility:{code}:visible1sec fires Note over WTH: latestAdIdByElement Map provides current adId WTH->>Prebid: $pbjs$.markBidViewable({ adId }) Prebid->>Adapter: onBidViewable(bid) Note over IO,Adapter: --- Refresh Cycle --- Core->>Woof: RENDER_AD (new creative) Woof->>Worker: RENDER_AD Worker->>Worker: resetViewability() - unobserve container Worker->>Worker: observeViewability() - re-arm IO for new creative Note over IO,Worker: IO fires again when new ad is 50%+ visible IO->>Worker: intersection ratio >= 0.5 Worker->>Worker: 1-second timer (same flow repeats)

Key Implementation Details

Viewability Mechanics
  • IO threshold 0.5: The IntersectionObserver is configured with a threshold of 0.5, meaning the callback fires when 50% of the ad slot is visible.
  • 1-second timer (IAB): A per-element timer in the viewTimers Map ensures the slot remains continuously visible for 1 full second.
  • latestAdIdByElement Map: Tracks the current winning adId per element ID non-destructively.
  • Works for BOTH placement types: The winnerTargetingHook registers a permanent listener on visibility:{code}:visible1sec for every placement.
  • Refresh lifecycle: On refresh, resetViewability() clears the existing timer and unobserves the container. After the new creative renders, observeViewability() re-arms the IO.
workerEntry.js — IO viewability setup
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
}
08 / 13

Secure Creative Relay (PUC)

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.

PUC (in SafeFrame creative) | Finds __pb_locator__ in worker frame | postMessage("Prebid Request", adId) ▼ Worker (workerEntry.js) | Intercepts message, maps adId → elementId | Sends PREBID_REQUEST via woof ▼ Core (crossFrameCore.js) | Dispatches to prebid's secureCreatives via MessageChannel | Receives Prebid Response on channel port | Sends PREBID_RESPONSE via woof ▼ Worker (workerEntry.js) | Forwards response to PUC via postMessage | Resizes ad slot locally (resizeAdSlot) ▼ PUC (in SafeFrame creative) Renders the actual ad creative

Why __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.

workerEntry.js — __pb_locator__ insertion
// 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 }
        });
    }
});

Dual Rendering Path: PUC and FIF

The worker supports both SafeFrame (PUC) and Friendly Iframe (FIF) rendering paths. This is a deliberate architectural choice providing publishers with maximum flexibility:

🔒

SafeFrame (PUC) Path

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.

📄

Friendly Iframe (FIF) 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.

09 / 13

GDPR / TCF Cross-Frame Consent

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.

How Consent Flows

TCF Proxy Chain
  • CMP loads in the host/core frame and creates the __tcfapiLocator iframe
  • Worker's tcfapi.js walks up the frame hierarchy and finds the locator
  • Creates a postMessage proxy with the correct (cmd, version, callback, arg) signature
  • Prebid in the core frame calls __tcfapi normally — the CMP responds directly
  • Worker's GAM calls include consent data via the proxied TCF API

9 Tested Scenarios

GroupScenarioExpected Behavior
AFull consent, host same-originAll bidders active, GAM request sent
BFull consent, host cross-originTCF proxy via postMessage, all bidders active
CNo consentGDPR-blocked bidders filtered, GAM consent mode
DNo CMP presentBidders run without GDPR flags (non-EU assumption)
EGDPR not applicableAll bidders active, no consent string sent
FDelayed CMP loadWrapper waits for CMP, then proceeds
GAwaiting user actionWrapper waits for user consent decision
HPartial consent (vendor 410)Specific vendor blocked, others proceed
IPartial 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.

10 / 13

Test Coverage Matrix

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 FileTestsCoverage 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

Test Infrastructure

Playwright E2E Setup
  • Config injection via page.addInitScript() — no filesystem writes
  • Each spec creates its own test environment with isolated config
  • Shared utilities in e2e/common/: tools.js, configFeatures.js, configAds.js, testConfig.js
  • Cross-frame-specific utils in crossFrame.utils.js
  • All specs run with randomized ports to support parallel execution
11 / 13

Live Demos

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/.

TCF / GDPR Demos

12 / 13

File Map

The entire cross-frame system is 1,423 lines across 7 source files. No external dependencies beyond @hbdev/woof for the postMessage transport.

FileLinesRolePurpose
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
13 / 13