From d167d9bbd469028c6398e8eb213698e707e04cb8 Mon Sep 17 00:00:00 2001 From: erani-n Date: Thu, 30 Jul 2026 02:12:22 +0300 Subject: [PATCH 1/4] Geoedge RTD Provider: outstream video monitoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Video creatives are VAST rather than HTML, so there is no markup to wrap. With the new `outstream` param the module wraps the bid's own renderer instead and asks the client whether the creative may run. If the client does not load in time the creative renders anyway — an ad is never lost because monitoring was unavailable. Bids carrying a safeRenderer are skipped, since prebid loads that renderer's own script and never calls bid.renderer. getInitialParams also carries `pbjs: getGlobal()` into the preloaded frame, so the client gets a direct handle instead of resolving the global by name via `_pbjsGlobals`. The frame is same-origin, so this grants no new capability — it only removes the lookup. Restores the `export` keywords the spec relies on, adds JSDoc, and extends the spec from 17 to 46 tests covering detection, wrapping, render-time enforcement, parked renders and the fail-open deadline. --- modules/geoedgeRtdProvider.js | 319 ++++++++++++++----- modules/geoedgeRtdProvider.md | 32 ++ test/spec/modules/geoedgeRtdProvider_spec.js | 280 +++++++++++++++- 3 files changed, 552 insertions(+), 79 deletions(-) diff --git a/modules/geoedgeRtdProvider.js b/modules/geoedgeRtdProvider.js index 14df281098f..7a38aa313f5 100644 --- a/modules/geoedgeRtdProvider.js +++ b/modules/geoedgeRtdProvider.js @@ -3,6 +3,7 @@ * The {@link module:modules/realTimeData} module is required * The module will fetch creative wrapper from geoedge server * The module will place geoedge RUM client on bid responses markup + * For outstream video the module holds the bid's own renderer until the client clears the creative * @module modules/geoedgeProvider * @requires module:modules/realTimeData */ @@ -12,48 +13,48 @@ * @property {string} key * @property {?Object} bidders * @property {?boolean} wap + * @property {?boolean} gpt + * @property {?boolean} outstream publisher opt-in to outstream video monitoring * @property {?string} keyName */ import { submodule } from '../src/hook.js'; +import { getGlobal } from '../src/prebidGlobal.js'; import { ajax } from '../src/ajax.js'; import { generateUUID, createInvisibleIframe, insertElement, isEmpty, logError } from '../src/utils.js'; import * as events from '../src/events.js'; import { EVENTS } from '../src/constants.js'; import { loadExternalScript } from '../src/adloader.js'; +import { isRendererRequired } from '../src/Renderer.js'; import { auctionManager } from '../src/auctionManager.js'; import { getRefererInfo } from '../src/refererDetection.js'; import { MODULE_TYPE_RTD } from '../src/activities/modules.js'; -/** - * @typedef {import('../modules/rtdModule/index.js').RtdSubmodule} RtdSubmodule - */ - -/** @type {string} */ const SUBMODULE_NAME = 'geoedge'; -/** @type {string} */ -export const WRAPPER_URL = 'https://wrappers.geoedge.be/wrapper.html'; -/** @type {string} */ /* eslint-disable no-template-curly-in-string */ +export const WRAPPER_URL = 'https://wrappers.geoedge.be/wrapper.html'; export const HTML_PLACEHOLDER = '${creative}'; -/** @type {string} */ const PV_ID = generateUUID(); -/** @type {string} */ const HOST_NAME = 'https://rumcdn.geoedge.be'; -/** @type {string} */ const FILE_NAME_CLIENT = 'grumi.js'; -/** @type {string} */ const FILE_NAME_INPAGE = 'grumi-ip.js'; -/** @type {function} */ export const getClientUrl = (key) => `${HOST_NAME}/${key}/${FILE_NAME_CLIENT}`; -/** @type {function} */ export const getInPageUrl = (key) => `${HOST_NAME}/${key}/${FILE_NAME_INPAGE}`; -/** @type {string} */ +const OUTSTREAM_API = 'grumiOutstreamApi'; // exposed by the client inside the preloaded frame +const OUTSTREAM_GATED = '__geOutstreamGated'; // stamped on a renderer we wrapped; the client reads it +export const OUTSTREAM_GATE_TIMEOUT = 1500; // give up waiting for the client and render unprotected +const VAST_HEAD_CHARS = 300; // how far into bid.ad to look for the marker + export let wrapper; -/** @type {boolean} */ let wrapperReady; -/** @type {boolean} */ -let preloaded; +let hasClientLoaded = false; +let hasClientTimedOut = false; +let clientTimeoutId; +/** @type {HTMLIFrameElement} the preloaded client frame; the video gate delegates into it */ +let clientFrame; +/** @type {Array} renders parked until the client script has executed; flushed by onClientLoad */ +let videoWaiters = []; + /** @type {object} */ const refererInfo = getRefererInfo(); /** @type {object} */ @@ -67,6 +68,7 @@ export function fetchWrapper(success) { if (wrapperReady) { return success(wrapper); } + ajax(WRAPPER_URL, success); } @@ -79,37 +81,96 @@ export function setWrapper(responseText) { wrapper = responseText; } -export function getInitialParams(key) { +/** + * builds the params object handed to the client inside the preloaded frame + * @param {string} key + * @param {?boolean} outstream publisher opt-in to outstream video monitoring + * @return {Object} + */ +export function getInitialParams(key, outstream) { const params = { - wver: '1.1.1', + wver: '1.1.2', wtype: 'pbjs-module', key, - meta: { - topUrl: refererInfo.page - }, + meta: { topUrl: refererInfo.page }, site: refererInfo.domain, pimp: PV_ID, fsRan: true, - frameApi: true + frameApi: true, + outstream, // the publisher's outstream opt-in, carried into the frame as session.outstream + // a direct handle on this instance, so the client need not resolve the global by name + pbjs: getGlobal() }; + return params; } -export function markAsLoaded() { - preloaded = true; +/** + * the client script's onload. Releases any render parked waiting for it. + */ +export function onClientLoad() { + hasClientLoaded = true; + + if (hasClientTimedOut) { + return; + } + + stopClientLoadTimer(); + handleOutstreamPendingBids(); +} + +function onClientTimeout() { + hasClientTimedOut = true; + + flushOutstreamPendingBids(); +} + +function handleOutstreamPendingBids() { + videoWaiters.forEach((waiter) => { + const [renderInvoker, bid] = waiter; + + if (shouldRenderOutstream(bid)) { + renderInvoker(); + } + }); + + videoWaiters = []; +} + +function flushOutstreamPendingBids() { + videoWaiters.forEach((waiter) => { + const [renderInvoker] = waiter; + + renderInvoker(); + }); + + videoWaiters = []; +} + +function startClientLoadTimer() { + clientTimeoutId = setTimeout(onClientTimeout, OUTSTREAM_GATE_TIMEOUT); +} + +function stopClientLoadTimer() { + clearTimeout(clientTimeoutId); } /** * preloads the client * @param {string} key + * @param {?boolean} outstream publisher opt-in to outstream video monitoring */ -export function preloadClient(key) { +export function preloadClient(key, outstream) { const iframe = createInvisibleIframe(); + const url = getClientUrl(key); + iframe.id = 'grumiFrame'; insertElement(iframe); - iframe.contentWindow.grumi = getInitialParams(key); - const url = getClientUrl(key); - loadExternalScript(url, MODULE_TYPE_RTD, SUBMODULE_NAME, markAsLoaded, iframe.contentDocument); + iframe.contentWindow.grumi = getInitialParams(key, outstream); + clientFrame = iframe; + + loadExternalScript(url, MODULE_TYPE_RTD, SUBMODULE_NAME, onClientLoad, iframe.contentDocument); + startClientLoadTimer(); } /** @@ -123,6 +184,12 @@ function replacer(str) { }; } +/** + * places the creative inside the wrapper + * @param {string} wrapper + * @param {string} html + * @return {string} + */ export function wrapHtml(wrapper, html) { return wrapper.replace(HTML_PLACEHOLDER, replacer(html)); } @@ -152,95 +219,198 @@ export function getMacros(bid, key) { }; } -/** - * replace macro placeholders in a string with values from a dictionary - * @param {string} wrapper - * @param {Object} macros - * @return {string} - */ function replaceMacros(wrapper, macros) { var re = new RegExp('\\' + Object.keys(macros).join('|'), 'gi'); - return wrapper.replace(re, function(matched) { + return wrapper.replace(re, function (matched) { return macros[matched]; }); } -/** - * build final creative html with creative wrapper - * @param {Object} bid - * @param {string} wrapper - * @param {string} html - * @return {string} - */ function buildHtml(bid, wrapper, html, key) { const macros = getMacros(bid, key); wrapper = replaceMacros(wrapper, macros); + return wrapHtml(wrapper, html); } -/** - * muatates the bid ad property - * @param {Object} bid - * @param {string} ad - */ function mutateBid(bid, ad) { bid.ad = ad; } /** - * wraps a bid object with the creative wrapper + * wraps the bid's markup with the creative wrapper * @param {Object} bid * @param {string} key */ export function wrapBidResponse(bid, key) { const wrapped = buildHtml(bid, wrapper, bid.ad, key); + mutateBid(bid, wrapped); } -/** - * checks if bidder's bids should be monitored - * @param {string} bidder - * @return {boolean} - */ function isSupportedBidder(bidder, paramsBidders) { return isEmpty(paramsBidders) || paramsBidders[bidder] === true; } -/** - * checks if bid should be monitored - * @param {Object} bid - * @return {boolean} - */ function shouldWrap(bid, params) { const supportedBidder = isSupportedBidder(bid.bidderCode, params.bidders); - const donePreload = params.wap ? preloaded : true; + const donePreload = params.wap ? hasClientLoaded : true; const isGPT = params.gpt; + return wrapperReady && supportedBidder && donePreload && !isGPT; } function conditionallyWrap(bidResponse, config, userConsent) { const params = config.params; + if (shouldWrap(bidResponse, params)) { wrapBidResponse(bidResponse, params.key); } } -function isBillingMessage(data, params) { - return data.key === params.key && data.impression; +// --------------------------------------------------------------------------- +// Outstream video gate +// +// Video creatives are VAST, not HTML, so there is no markup to wrap. The bid's own renderer is +// wrapped instead: on render, the client is asked whether the creative may run. If the client does +// not load in time the creative renders anyway — a publisher's ad is never lost because monitoring +// was unavailable. +// --------------------------------------------------------------------------- + +function getOutstreamAPI() { + try { + return clientFrame && clientFrame.contentWindow && clientFrame.contentWindow[OUTSTREAM_API]; + } catch (e) { + return null; // frame torn out of the DOM + } +} + +// Deliberately broad, because mediaType alone is not reliable: an adapter that omits it leaves a +// video bid labeled 'banner', and instream/outstream context lives on the adUnit, not the response. +// The bid.ad leg covers outstream, the one video context prebid does not require a VAST field for — +// checkVideoBidSetup accepts it on hasRenderer alone, so the VAST may arrive in `ad`. +// No vastUrl leg: handleVideoBidCaching backfills a bare vastUrl into vastXml before BID_RESPONSE, +// so a correctly labeled video bid always has vastXml by the time this runs. +/** + * whether this bid could carry a VAST document through its renderer + * @param {Object} bid + * @return {boolean} + */ +export function isVastBid(bid) { + return Boolean(bid.mediaType === 'video' || bid.vastXml || hasVastXmlInBidAd(bid)); +} + +function hasVastXmlInBidAd(bid) { + // head only — display bids reach this leg too, and their `ad` is a full creative + return typeof bid.ad === 'string' && / originalRender.apply(self, args); + const videoWaiter = [renderInvoker, bid]; + + videoWaiters.push(videoWaiter); + }; + + setRendererAsGated(renderer); +} + +/** + * Test-only: clears the client-load flags so a spec can reach the parked and failed-open branches. + * The module is a singleton and prebid's adloader mock fires the load callback synchronously, so the + * flags latch true on the first init(). clientFrame and the load timer are left intact. + */ +export function resetOutstreamGateStateForTesting() { + hasClientLoaded = false; + hasClientTimedOut = false; + videoWaiters = []; +} + +function onBidResponse(bidResponse, config, userConsent) { + if (shouldGateOutstreamRender(bidResponse, config.params)) { + gateOutstreamRender(bidResponse); + + return; + } + + conditionallyWrap(bidResponse, config, userConsent); +} + +function isBillingMessage(data, params) { + return data.key === params.key && data.impression; +} + +// Fire billable events when our client posts an impression message function fireBillableEventsForApplicableBids(params) { window.addEventListener('message', function (message) { const data = message.data; + if (isBillingMessage(data, params)) { const winningBid = auctionManager.findBidByAdId(data.adId); + events.emit(EVENTS.BILLABLE_EVENT, { vendor: SUBMODULE_NAME, billingId: data.impressionId, @@ -253,10 +423,7 @@ function fireBillableEventsForApplicableBids(params) { }); } -/** - * Loads Geoedge in page script that monitors all ad slots created by GPT - * @param {Object} params - */ +// Loads the geoedge in-page script that monitors all ad slots created by GPT function setupInPage(params) { window.grumi = params; window.grumi.fromPrebid = true; @@ -273,21 +440,17 @@ function init(config, userConsent) { setupInPage(params); } else { fetchWrapper(setWrapper); - preloadClient(params.key); + preloadClient(params.key, params.outstream); } fireBillableEventsForApplicableBids(params); + return true; } -/** @type {RtdSubmodule} */ export const geoedgeSubmodule = { - /** - * used to link submodule with realTimeData - * @type {string} - */ name: SUBMODULE_NAME, init, - onBidResponseEvent: conditionallyWrap + onBidResponseEvent: onBidResponse }; submodule('realTimeData', geoedgeSubmodule); diff --git a/modules/geoedgeRtdProvider.md b/modules/geoedgeRtdProvider.md index cdf913b8893..9c84b14ebb8 100644 --- a/modules/geoedgeRtdProvider.md +++ b/modules/geoedgeRtdProvider.md @@ -50,6 +50,38 @@ Parameters details: |params.bidders | Object | Bidders to monitor |Optional, list of bidder to include / exclude from monitoring. Omitting this will monitor bids from all bidders. | |params.wap |Boolean |Wrap after preload |Optional, defaults to `false`. Set to `true` if you want to monitor only after the module has preloaded the monitoring client. | |params.gpt |Boolean |Wrap all GPT ad slots |Optional, defaults to `false`. Set to `true` if you want to monitor all Google Publisher Tag ad slots, regaedless if the winning bid comes from Prebid or Google Ad Manager (Direct, Adx, Adesnse, Open Bidding, etc). | +|params.outstream |Boolean |Monitor outstream video |Optional, defaults to `false`. Set to `true` to extend monitoring to outstream video bids. See "Outstream video" below — this is the one option that can delay a render. | + +## Outstream video + +Video creatives are VAST rather than HTML, so they cannot be wrapped the way display creatives are. +With `outstream: true` the module instead wraps the bid's own `renderer.render` and asks the +monitoring client whether the creative may run: + +```javascript +pbjs.setConfig({ + realTimeData: { + dataProviders: [{ + name: 'geoedge', + params: { + key: '123123', + outstream: true + } + }] + } +}); +``` + +Behavior worth knowing before enabling it: + +- **It can delay a render.** If Prebid calls `render()` before the monitoring client has loaded, the + render is held until the client answers. This is the only option in this module that affects when a + creative is displayed. +- **It fails open.** If the client does not load within its deadline, or loads without a verdict for + the bid, the creative renders unmonitored. An ad is never lost because monitoring was unavailable. +- **It only affects bids Prebid renders through the bid's renderer.** Bids carrying a `safeRenderer`, + and bids whose VAST reaches a player straight from targeting or Prebid Cache, are left untouched. +- **Display monitoring is unchanged.** A bid handled by the outstream path is not also HTML-wrapped. ## Example diff --git a/test/spec/modules/geoedgeRtdProvider_spec.js b/test/spec/modules/geoedgeRtdProvider_spec.js index c2b1c3a0b5b..53ae7c4966c 100644 --- a/test/spec/modules/geoedgeRtdProvider_spec.js +++ b/test/spec/modules/geoedgeRtdProvider_spec.js @@ -4,6 +4,7 @@ import * as geoedgeRtdModule from '../../../modules/geoedgeRtdProvider.js'; import { server } from '../../../test/mocks/xhr.js'; import * as events from '../../../src/events.js'; import { EVENTS } from '../../../src/constants.js'; +import { getGlobal } from '../../../src/prebidGlobal.js'; const { geoedgeSubmodule, @@ -13,10 +14,17 @@ const { setWrapper, getMacros, WRAPPER_URL, - preloadClient + preloadClient, + isVastBid, + onClientLoad, + resetOutstreamGateStateForTesting, + OUTSTREAM_GATE_TIMEOUT } = geoedgeRtdModule; const key = '123123123'; +// The client publishes its gate on the preloaded frame's window under this name. +const OUTSTREAM_API = 'grumiOutstreamApi'; + function makeConfig(gpt) { return { name: 'geoedge', @@ -127,6 +135,17 @@ describe('Geoedge RTD module', function () { const isClientUrl = arg => arg === getClientUrl(key); expect(loadExternalScriptCall.calledWithMatch(isClientUrl)).to.equal(true); }); + it('should carry the publisher outstream opt-in into the frame', function () { + preloadClient(key, true); + // insertElement prepends into , so the newest frame is the FIRST match, not the last + const grumi = document.querySelector('#grumiFrame').contentWindow.grumi; + expect(grumi.outstream).to.equal(true); + }); + it('should hand the frame a reference to this prebid instance', function () { + preloadClient(key); + const grumi = document.querySelector('#grumiFrame').contentWindow.grumi; + expect(grumi.pbjs).to.equal(getGlobal()); + }); }); describe('setWrapper', function () { it('should set the wrapper', function () { @@ -169,5 +188,264 @@ describe('Geoedge RTD module', function () { expect(equalsOriginal).to.equal(true); }); }); + + // ----------------------------------------------------------------------- + // Outstream video gate + // ----------------------------------------------------------------------- + + describe('isVastBid', function () { + it('should accept a bid labeled as video', function () { + expect(isVastBid({ mediaType: 'video' })).to.equal(true); + }); + it('should accept a bid carrying vastXml', function () { + expect(isVastBid({ vastXml: '' })).to.equal(true); + }); + it('should accept a mislabeled bid whose ad starts with a VAST tag, case-insensitively', function () { + expect(isVastBid({ ad: '' })).to.equal(true); + expect(isVastBid({ ad: '' })).to.equal(true); + }); + it('should reject a vastUrl-only bid — a correctly labeled video bid always arrives with vastXml backfilled', function () { + expect(isVastBid({ vastUrl: 'https://example.com/vast.xml' })).to.equal(false); + }); + it('should not scan past the head of bid.ad for a VAST marker', function () { + const buried = `${'x'.repeat(400)}`; + expect(isVastBid({ ad: buried })).to.equal(false); + }); + it('should reject a display bid', function () { + expect(isVastBid(mockBid('bidderA'))).to.equal(false); + }); + it('should reject a bid with no ad and no video fields', function () { + expect(isVastBid({})).to.equal(false); + }); + }); + + describe('outstream gate', function () { + let frame; + let originalRender; + + function makeOutstreamConfig(outstream) { + return { + name: 'geoedge', + params: { key, outstream, bidders: { bidderA: true } } + }; + } + + // isRendererRequired() needs url (or renderNow); the gate additionally needs render itself. + function mockRenderer() { + return { url: 'https://example.com/outstream.js', render: sinon.spy() }; + } + + function mockVideoBid(extra) { + return Object.assign(mockBid('bidderA'), { + mediaType: 'video', + vastXml: '', + renderer: mockRenderer() + }, extra); + } + + function gate(bid, outstream = true) { + geoedgeSubmodule.onBidResponseEvent(bid, makeOutstreamConfig(outstream)); + return bid; + } + + function isWrapped(bid, original) { + return bid.renderer.render !== original; + } + + function publishGate(shouldRender) { + frame.contentWindow[OUTSTREAM_API] = { shouldRender: sinon.stub().returns(shouldRender) }; + } + + beforeEach(function () { + document.querySelectorAll('#grumiFrame').forEach(el => el.remove()); + // establishes clientFrame; the adloader stub fires the load callback synchronously + preloadClient(key, true); + frame = document.querySelector('#grumiFrame'); + delete frame.contentWindow[OUTSTREAM_API]; + resetOutstreamGateStateForTesting(); + }); + + describe('deciding what to wrap', function () { + it('should wrap the renderer of an outstream video bid', function () { + const bid = mockVideoBid(); + originalRender = bid.renderer.render; + gate(bid); + expect(isWrapped(bid, originalRender)).to.equal(true); + }); + it('should not wrap when the publisher did not opt in to outstream', function () { + const bid = mockVideoBid(); + originalRender = bid.renderer.render; + gate(bid, false); + expect(isWrapped(bid, originalRender)).to.equal(false); + }); + it('should not wrap a bid carrying a safeRenderer — prebid never calls bid.renderer for those', function () { + const bid = mockVideoBid({ safeRenderer: true }); + originalRender = bid.renderer.render; + gate(bid); + expect(isWrapped(bid, originalRender)).to.equal(false); + }); + it('should not wrap when the renderer is not required by prebid', function () { + const bid = mockVideoBid({ renderer: { render: sinon.spy() } }); // no url / renderNow + originalRender = bid.renderer.render; + gate(bid); + expect(isWrapped(bid, originalRender)).to.equal(false); + }); + it('should not wrap when the renderer has no render method', function () { + const bid = mockVideoBid({ renderer: { url: 'https://example.com/outstream.js' } }); + gate(bid); + expect(bid.renderer.render).to.equal(undefined); + }); + it('should wrap a renderer only once across repeated bidResponse events', function () { + const bid = mockVideoBid(); + gate(bid); + const wrappedOnce = bid.renderer.render; + gate(bid); + expect(bid.renderer.render).to.equal(wrappedOnce); + }); + it('should fall through to html wrapping for a display bid when outstream is on', function () { + const bid = mockBid('bidderA'); + gate(bid); + expect(bid.ad.indexOf('')).to.equal(0); + }); + it('should not wrap the html of a gated video bid', function () { + const bid = mockVideoBid({ ad: '' }); + gate(bid); + expect(bid.ad.indexOf('')).to.equal(-1); + }); + }); + + describe('enforcing at render time', function () { + it('should render when the client allows the bid', function () { + const bid = mockVideoBid(); + originalRender = bid.renderer.render; + gate(bid); + onClientLoad(); + publishGate(true); + bid.renderer.render(); + expect(originalRender.calledOnce).to.equal(true); + }); + it('should not render when the client blocks the bid', function () { + const bid = mockVideoBid(); + originalRender = bid.renderer.render; + gate(bid); + onClientLoad(); + publishGate(false); + bid.renderer.render(); + expect(originalRender.called).to.equal(false); + }); + it('should render unprotected when the client published no gate', function () { + const bid = mockVideoBid(); + originalRender = bid.renderer.render; + gate(bid); + onClientLoad(); + bid.renderer.render(); + expect(originalRender.calledOnce).to.equal(true); + }); + it('should preserve the renderer receiver and arguments', function () { + const bid = mockVideoBid(); + originalRender = bid.renderer.render; + gate(bid); + onClientLoad(); + publishGate(true); + bid.renderer.render('a', 'b'); + expect(originalRender.calledOn(bid.renderer)).to.equal(true); + expect(originalRender.calledWithExactly('a', 'b')).to.equal(true); + }); + }); + + describe('parking a render until the client loads', function () { + it('should not render while the client has neither loaded nor timed out', function () { + const bid = mockVideoBid(); + originalRender = bid.renderer.render; + gate(bid); + bid.renderer.render(); + expect(originalRender.called).to.equal(false); + }); + it('should release a parked render once the client loads and allows it', function () { + const bid = mockVideoBid(); + originalRender = bid.renderer.render; + gate(bid); + bid.renderer.render(); + publishGate(true); + onClientLoad(); + expect(originalRender.calledOnce).to.equal(true); + }); + it('should drop a parked render when the loaded client blocks it', function () { + const bid = mockVideoBid(); + originalRender = bid.renderer.render; + gate(bid); + bid.renderer.render(); + publishGate(false); + onClientLoad(); + expect(originalRender.called).to.equal(false); + }); + it('should release each parked render exactly once', function () { + const first = mockVideoBid(); + const second = mockVideoBid(); + const firstRender = first.renderer.render; + const secondRender = second.renderer.render; + gate(first); + gate(second); + first.renderer.render(); + second.renderer.render(); + publishGate(true); + onClientLoad(); + onClientLoad(); + expect(firstRender.calledOnce).to.equal(true); + expect(secondRender.calledOnce).to.equal(true); + }); + }); + + describe('failing open on the client load deadline', function () { + let clock; + + beforeEach(function () { + clock = sinon.useFakeTimers(); + // re-arm the deadline against the fake clock + preloadClient(key, true); + frame = document.querySelector('#grumiFrame'); + delete frame.contentWindow[OUTSTREAM_API]; + resetOutstreamGateStateForTesting(); + }); + afterEach(function () { + clock.restore(); + }); + + it('should release a parked render when the deadline passes', function () { + const bid = mockVideoBid(); + originalRender = bid.renderer.render; + gate(bid); + bid.renderer.render(); + expect(originalRender.called).to.equal(false); + clock.tick(OUTSTREAM_GATE_TIMEOUT); + expect(originalRender.calledOnce).to.equal(true); + }); + it('should release a parked render even when a gate would have blocked it', function () { + const bid = mockVideoBid(); + originalRender = bid.renderer.render; + gate(bid); + bid.renderer.render(); + publishGate(false); + clock.tick(OUTSTREAM_GATE_TIMEOUT); + expect(originalRender.calledOnce).to.equal(true); + }); + it('should render immediately once the deadline has already passed', function () { + const bid = mockVideoBid(); + originalRender = bid.renderer.render; + gate(bid); + clock.tick(OUTSTREAM_GATE_TIMEOUT); + bid.renderer.render(); + expect(originalRender.calledOnce).to.equal(true); + }); + it('should not release a parked render before the deadline', function () { + const bid = mockVideoBid(); + originalRender = bid.renderer.render; + gate(bid); + bid.renderer.render(); + clock.tick(OUTSTREAM_GATE_TIMEOUT - 1); + expect(originalRender.called).to.equal(false); + }); + }); + }); }); }); From 709ae9363e1b0579428184fda8ce322c0ff4688c Mon Sep 17 00:00:00 2001 From: erani-n Date: Thu, 30 Jul 2026 02:45:15 +0300 Subject: [PATCH 2/4] Honor params.bidders on the outstream gate path --- modules/geoedgeRtdProvider.js | 3 ++- test/spec/modules/geoedgeRtdProvider_spec.js | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/modules/geoedgeRtdProvider.js b/modules/geoedgeRtdProvider.js index 7a38aa313f5..79a86b2ea33 100644 --- a/modules/geoedgeRtdProvider.js +++ b/modules/geoedgeRtdProvider.js @@ -315,8 +315,9 @@ function hasVastXmlInBidAd(bid) { */ function shouldGateOutstreamRender(bid, params) { const { renderer } = bid; + const supportedBidder = isSupportedBidder(bid.bidderCode, params.bidders); - if (!params.outstream || !isVastBid(bid) || bid.safeRenderer || !clientFrame) { + if (!params.outstream || !supportedBidder || !isVastBid(bid) || bid.safeRenderer || !clientFrame) { return false; } diff --git a/test/spec/modules/geoedgeRtdProvider_spec.js b/test/spec/modules/geoedgeRtdProvider_spec.js index 53ae7c4966c..9c358317a61 100644 --- a/test/spec/modules/geoedgeRtdProvider_spec.js +++ b/test/spec/modules/geoedgeRtdProvider_spec.js @@ -278,6 +278,12 @@ describe('Geoedge RTD module', function () { gate(bid, false); expect(isWrapped(bid, originalRender)).to.equal(false); }); + it('should not wrap a bid from a bidder params.bidders excludes', function () { + const bid = mockVideoBid({ bidderCode: 'bidderB' }); + originalRender = bid.renderer.render; + gate(bid); + expect(isWrapped(bid, originalRender)).to.equal(false); + }); it('should not wrap a bid carrying a safeRenderer — prebid never calls bid.renderer for those', function () { const bid = mockVideoBid({ safeRenderer: true }); originalRender = bid.renderer.render; From e0a42f2f48377f3da7c4101b8c5a8af0f3d927e2 Mon Sep 17 00:00:00 2001 From: erani-n Date: Sun, 2 Aug 2026 14:48:18 +0300 Subject: [PATCH 3/4] Adopt the naming from #15262 --- modules/geoedgeRtdProvider.js | 48 ++++++++++---------- modules/geoedgeRtdProvider.md | 13 +++--- test/spec/modules/geoedgeRtdProvider_spec.js | 48 ++++++++++---------- 3 files changed, 55 insertions(+), 54 deletions(-) diff --git a/modules/geoedgeRtdProvider.js b/modules/geoedgeRtdProvider.js index 79a86b2ea33..2442d19a87c 100644 --- a/modules/geoedgeRtdProvider.js +++ b/modules/geoedgeRtdProvider.js @@ -40,19 +40,19 @@ const FILE_NAME_CLIENT = 'grumi.js'; const FILE_NAME_INPAGE = 'grumi-ip.js'; export const getClientUrl = (key) => `${HOST_NAME}/${key}/${FILE_NAME_CLIENT}`; export const getInPageUrl = (key) => `${HOST_NAME}/${key}/${FILE_NAME_INPAGE}`; -const OUTSTREAM_API = 'grumiOutstreamApi'; // exposed by the client inside the preloaded frame +const OUTSTREAM_API = 'grumiOutstreamApi'; // exposed by the client inside the client frame const OUTSTREAM_GATED = '__geOutstreamGated'; // stamped on a renderer we wrapped; the client reads it export const OUTSTREAM_GATE_TIMEOUT = 1500; // give up waiting for the client and render unprotected const VAST_HEAD_CHARS = 300; // how far into bid.ad to look for the marker export let wrapper; let wrapperReady; -let hasClientLoaded = false; -let hasClientTimedOut = false; +let clientLoaded = false; +let clientTimedOut = false; let clientTimeoutId; -/** @type {HTMLIFrameElement} the preloaded client frame; the video gate delegates into it */ +/** @type {HTMLIFrameElement} the client frame; the video gate delegates into it */ let clientFrame; -/** @type {Array} renders parked until the client script has executed; flushed by onClientLoad */ +/** @type {Array} renders parked until the client script has executed; flushed by markClientAsLoaded */ let videoWaiters = []; /** @type {object} */ @@ -73,7 +73,7 @@ export function fetchWrapper(success) { } /** - * sets the wrapper and calls preload client + * sets the wrapper response * @param {string} responseText */ export function setWrapper(responseText) { @@ -82,7 +82,7 @@ export function setWrapper(responseText) { } /** - * builds the params object handed to the client inside the preloaded frame + * builds the params object handed to the client inside the frame * @param {string} key * @param {?boolean} outstream publisher opt-in to outstream video monitoring * @return {Object} @@ -108,10 +108,10 @@ export function getInitialParams(key, outstream) { /** * the client script's onload. Releases any render parked waiting for it. */ -export function onClientLoad() { - hasClientLoaded = true; +export function markClientAsLoaded() { + clientLoaded = true; - if (hasClientTimedOut) { + if (clientTimedOut) { return; } @@ -120,7 +120,7 @@ export function onClientLoad() { } function onClientTimeout() { - hasClientTimedOut = true; + clientTimedOut = true; flushOutstreamPendingBids(); } @@ -156,11 +156,11 @@ function stopClientLoadTimer() { } /** - * preloads the client + * loads the monitoring client in an invisible iframe * @param {string} key * @param {?boolean} outstream publisher opt-in to outstream video monitoring */ -export function preloadClient(key, outstream) { +export function loadClientInIframe(key, outstream) { const iframe = createInvisibleIframe(); const url = getClientUrl(key); @@ -169,7 +169,7 @@ export function preloadClient(key, outstream) { iframe.contentWindow.grumi = getInitialParams(key, outstream); clientFrame = iframe; - loadExternalScript(url, MODULE_TYPE_RTD, SUBMODULE_NAME, onClientLoad, iframe.contentDocument); + loadExternalScript(url, MODULE_TYPE_RTD, SUBMODULE_NAME, markClientAsLoaded, iframe.contentDocument); startClientLoadTimer(); } @@ -255,10 +255,10 @@ function isSupportedBidder(bidder, paramsBidders) { function shouldWrap(bid, params) { const supportedBidder = isSupportedBidder(bid.bidderCode, params.bidders); - const donePreload = params.wap ? hasClientLoaded : true; + const clientReady = params.wap ? clientLoaded : true; const isGPT = params.gpt; - return wrapperReady && supportedBidder && donePreload && !isGPT; + return wrapperReady && supportedBidder && clientReady && !isGPT; } function conditionallyWrap(bidResponse, config, userConsent) { @@ -274,7 +274,7 @@ function conditionallyWrap(bidResponse, config, userConsent) { // // Video creatives are VAST, not HTML, so there is no markup to wrap. The bid's own renderer is // wrapped instead: on render, the client is asked whether the creative may run. If the client does -// not load in time the creative renders anyway — a publisher's ad is never lost because monitoring +// not load in time the creative renders anyway. A publisher's ad is never lost because monitoring // was unavailable. // --------------------------------------------------------------------------- @@ -288,7 +288,7 @@ function getOutstreamAPI() { // Deliberately broad, because mediaType alone is not reliable: an adapter that omits it leaves a // video bid labeled 'banner', and instream/outstream context lives on the adUnit, not the response. -// The bid.ad leg covers outstream, the one video context prebid does not require a VAST field for — +// The bid.ad leg covers outstream, the one video context prebid does not require a VAST field for: // checkVideoBidSetup accepts it on hasRenderer alone, so the VAST may arrive in `ad`. // No vastUrl leg: handleVideoBidCaching backfills a bare vastUrl into vastXml before BID_RESPONSE, // so a correctly labeled video bid always has vastXml by the time this runs. @@ -302,7 +302,7 @@ export function isVastBid(bid) { } function hasVastXmlInBidAd(bid) { - // head only — display bids reach this leg too, and their `ad` is a full creative + // head only, since display bids reach this leg too, and their `ad` is a full creative return typeof bid.ad === 'string' && / arg === getClientUrl(key); expect(loadExternalScriptCall.calledWithMatch(isClientUrl)).to.equal(true); }); it('should carry the publisher outstream opt-in into the frame', function () { - preloadClient(key, true); + loadClientInIframe(key, true); // insertElement prepends into , so the newest frame is the FIRST match, not the last const grumi = document.querySelector('#grumiFrame').contentWindow.grumi; expect(grumi.outstream).to.equal(true); }); it('should hand the frame a reference to this prebid instance', function () { - preloadClient(key); + loadClientInIframe(key); const grumi = document.querySelector('#grumiFrame').contentWindow.grumi; expect(grumi.pbjs).to.equal(getGlobal()); }); @@ -204,7 +204,7 @@ describe('Geoedge RTD module', function () { expect(isVastBid({ ad: '' })).to.equal(true); expect(isVastBid({ ad: '' })).to.equal(true); }); - it('should reject a vastUrl-only bid — a correctly labeled video bid always arrives with vastXml backfilled', function () { + it('should reject a vastUrl-only bid, since a correctly labeled video bid always arrives with vastXml backfilled', function () { expect(isVastBid({ vastUrl: 'https://example.com/vast.xml' })).to.equal(false); }); it('should not scan past the head of bid.ad for a VAST marker', function () { @@ -259,7 +259,7 @@ describe('Geoedge RTD module', function () { beforeEach(function () { document.querySelectorAll('#grumiFrame').forEach(el => el.remove()); // establishes clientFrame; the adloader stub fires the load callback synchronously - preloadClient(key, true); + loadClientInIframe(key, true); frame = document.querySelector('#grumiFrame'); delete frame.contentWindow[OUTSTREAM_API]; resetOutstreamGateStateForTesting(); @@ -284,7 +284,7 @@ describe('Geoedge RTD module', function () { gate(bid); expect(isWrapped(bid, originalRender)).to.equal(false); }); - it('should not wrap a bid carrying a safeRenderer — prebid never calls bid.renderer for those', function () { + it('should not wrap a bid carrying a safeRenderer, since prebid never calls bid.renderer for those', function () { const bid = mockVideoBid({ safeRenderer: true }); originalRender = bid.renderer.render; gate(bid); @@ -325,7 +325,7 @@ describe('Geoedge RTD module', function () { const bid = mockVideoBid(); originalRender = bid.renderer.render; gate(bid); - onClientLoad(); + markClientAsLoaded(); publishGate(true); bid.renderer.render(); expect(originalRender.calledOnce).to.equal(true); @@ -334,7 +334,7 @@ describe('Geoedge RTD module', function () { const bid = mockVideoBid(); originalRender = bid.renderer.render; gate(bid); - onClientLoad(); + markClientAsLoaded(); publishGate(false); bid.renderer.render(); expect(originalRender.called).to.equal(false); @@ -343,7 +343,7 @@ describe('Geoedge RTD module', function () { const bid = mockVideoBid(); originalRender = bid.renderer.render; gate(bid); - onClientLoad(); + markClientAsLoaded(); bid.renderer.render(); expect(originalRender.calledOnce).to.equal(true); }); @@ -351,7 +351,7 @@ describe('Geoedge RTD module', function () { const bid = mockVideoBid(); originalRender = bid.renderer.render; gate(bid); - onClientLoad(); + markClientAsLoaded(); publishGate(true); bid.renderer.render('a', 'b'); expect(originalRender.calledOn(bid.renderer)).to.equal(true); @@ -373,7 +373,7 @@ describe('Geoedge RTD module', function () { gate(bid); bid.renderer.render(); publishGate(true); - onClientLoad(); + markClientAsLoaded(); expect(originalRender.calledOnce).to.equal(true); }); it('should drop a parked render when the loaded client blocks it', function () { @@ -382,7 +382,7 @@ describe('Geoedge RTD module', function () { gate(bid); bid.renderer.render(); publishGate(false); - onClientLoad(); + markClientAsLoaded(); expect(originalRender.called).to.equal(false); }); it('should release each parked render exactly once', function () { @@ -395,8 +395,8 @@ describe('Geoedge RTD module', function () { first.renderer.render(); second.renderer.render(); publishGate(true); - onClientLoad(); - onClientLoad(); + markClientAsLoaded(); + markClientAsLoaded(); expect(firstRender.calledOnce).to.equal(true); expect(secondRender.calledOnce).to.equal(true); }); @@ -408,7 +408,7 @@ describe('Geoedge RTD module', function () { beforeEach(function () { clock = sinon.useFakeTimers(); // re-arm the deadline against the fake clock - preloadClient(key, true); + loadClientInIframe(key, true); frame = document.querySelector('#grumiFrame'); delete frame.contentWindow[OUTSTREAM_API]; resetOutstreamGateStateForTesting(); From 01808390d892ecd8e7eea811e2bd9574039cc671 Mon Sep 17 00:00:00 2001 From: erani-n Date: Sun, 2 Aug 2026 15:25:39 +0300 Subject: [PATCH 4/4] Only pass the prebid instance when outstream is enabled --- modules/geoedgeRtdProvider.js | 8 +++++--- test/spec/modules/geoedgeRtdProvider_spec.js | 9 +++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/modules/geoedgeRtdProvider.js b/modules/geoedgeRtdProvider.js index 2442d19a87c..b2e69bf63e0 100644 --- a/modules/geoedgeRtdProvider.js +++ b/modules/geoedgeRtdProvider.js @@ -97,11 +97,13 @@ export function getInitialParams(key, outstream) { pimp: PV_ID, fsRan: true, frameApi: true, - outstream, // the publisher's outstream opt-in, carried into the frame as session.outstream - // a direct handle on this instance, so the client need not resolve the global by name - pbjs: getGlobal() + outstream }; + if (outstream) { + params.pbjs = getGlobal(); + } + return params; } diff --git a/test/spec/modules/geoedgeRtdProvider_spec.js b/test/spec/modules/geoedgeRtdProvider_spec.js index bea6d6fcb68..1a2950279c6 100644 --- a/test/spec/modules/geoedgeRtdProvider_spec.js +++ b/test/spec/modules/geoedgeRtdProvider_spec.js @@ -141,11 +141,16 @@ describe('Geoedge RTD module', function () { const grumi = document.querySelector('#grumiFrame').contentWindow.grumi; expect(grumi.outstream).to.equal(true); }); - it('should hand the frame a reference to this prebid instance', function () { - loadClientInIframe(key); + it('should hand the frame a reference to this prebid instance when outstream is on', function () { + loadClientInIframe(key, true); const grumi = document.querySelector('#grumiFrame').contentWindow.grumi; expect(grumi.pbjs).to.equal(getGlobal()); }); + it('should not put the prebid instance in the frame without the outstream opt-in', function () { + loadClientInIframe(key); + const grumi = document.querySelector('#grumiFrame').contentWindow.grumi; + expect(grumi.pbjs).to.equal(undefined); + }); }); describe('setWrapper', function () { it('should set the wrapper', function () {