diff --git a/modules/prebidServerBidAdapter/index.js b/modules/prebidServerBidAdapter/index.js index b5cd0232187..532a222bf85 100644 --- a/modules/prebidServerBidAdapter/index.js +++ b/modules/prebidServerBidAdapter/index.js @@ -56,6 +56,7 @@ let eidPermissions; * @property {string} [adapter='prebidServer'] adapter code to use for S2S * @property {boolean} [enabled=false] enables S2S bidding * @property {number} [timeout=1000] timeout for S2S bidders - should be lower than `pbjs.requestBids({timeout})` + * @property {number} [syncTimeout=1000] timeout for cookie sync iframe / image rendering * @property {number} [maxBids=1] * @property {AdapterOptions} [adapterOptions] adds arguments to resulting OpenRTB payload to Prebid Server * @property {Object} [syncUrlModifier] @@ -77,6 +78,7 @@ let eidPermissions; */ const s2sDefaultConfig = { timeout: 1000, + syncTimeout: 1000, maxBids: 1, adapter: 'prebidServer', adapterOptions: {}, @@ -274,11 +276,9 @@ function doAllSyncs(bidders, s2sConfig) { */ function doPreBidderSync(type, url, bidder, done, s2sConfig) { if (s2sConfig.syncUrlModifier && typeof s2sConfig.syncUrlModifier[bidder] === 'function') { - const newSyncUrl = s2sConfig.syncUrlModifier[bidder](type, url, bidder); - doBidderSync(type, newSyncUrl, bidder, done) - } else { - doBidderSync(type, url, bidder, done) + url = s2sConfig.syncUrlModifier[bidder](type, url, bidder); } + doBidderSync(type, url, bidder, done, s2sConfig.syncTimeout) } /** @@ -288,17 +288,18 @@ function doPreBidderSync(type, url, bidder, done, s2sConfig) { * @param {string} url the url to sync * @param {string} bidder name of bidder doing sync for * @param {function} done an exit callback; to signify this pixel has either: finished rendering or something went wrong + * @param {number} timeout: maximum time to wait for rendering in milliseconds */ -function doBidderSync(type, url, bidder, done) { +function doBidderSync(type, url, bidder, done, timeout) { if (!url) { logError(`No sync url for bidder "${bidder}": ${url}`); done(); } else if (type === 'image' || type === 'redirect') { logMessage(`Invoking image pixel user sync for bidder: "${bidder}"`); - triggerPixel(url, done); - } else if (type == 'iframe') { + triggerPixel(url, done, timeout); + } else if (type === 'iframe') { logMessage(`Invoking iframe user sync for bidder: "${bidder}"`); - insertUserSyncIframe(url, done); + insertUserSyncIframe(url, done, timeout); } else { logError(`User sync type "${type}" not supported for bidder: "${bidder}"`); done(); diff --git a/src/utils.js b/src/utils.js index 2e433ef4351..a0983da3bd8 100644 --- a/src/utils.js +++ b/src/utils.js @@ -482,16 +482,43 @@ export function insertElement(elm, doc, target, asLastChildChild) { } catch (e) {} } +/** + * Returns a promise that completes when the given element triggers a 'load' or 'error' DOM event, or when + * `timeout` milliseconds have elapsed. + * + * @param {HTMLElement} element + * @param {Number} [timeout] + * @returns {Promise} + */ +export function waitForElementToLoad(element, timeout) { + let timer = null; + return new Promise((resolve) => { + const onLoad = function() { + element.removeEventListener('load', onLoad); + element.removeEventListener('error', onLoad); + if (timer != null) { + window.clearTimeout(timer); + } + resolve(); + }; + element.addEventListener('load', onLoad); + element.addEventListener('error', onLoad); + if (timeout != null) { + timer = window.setTimeout(onLoad, timeout); + } + }); +} + /** * Inserts an image pixel with the specified `url` for cookie sync * @param {string} url URL string of the image pixel to load * @param {function} [done] an optional exit callback, used when this usersync pixel is added during an async process + * @param {Number} [timeout] an optional timeout in milliseconds for the image to load before calling `done` */ -export function triggerPixel(url, done) { +export function triggerPixel(url, done, timeout) { const img = new Image(); if (done && internal.isFn(done)) { - img.addEventListener('load', done); - img.addEventListener('error', done); + waitForElementToLoad(img, timeout).then(done); } img.src = url; } @@ -539,18 +566,18 @@ export function insertHtmlIntoIframe(htmlCode) { * @param {string} url URL to be requested * @param {string} encodeUri boolean if URL should be encoded before inserted. Defaults to true * @param {function} [done] an optional exit callback, used when this usersync pixel is added during an async process + * @param {Number} [timeout] an optional timeout in milliseconds for the iframe to load before calling `done` */ -export function insertUserSyncIframe(url, done) { +export function insertUserSyncIframe(url, done, timeout) { let iframeHtml = internal.createTrackPixelIframeHtml(url, false, 'allow-scripts allow-same-origin'); let div = document.createElement('div'); div.innerHTML = iframeHtml; let iframe = div.firstChild; if (done && internal.isFn(done)) { - iframe.addEventListener('load', done); - iframe.addEventListener('error', done); + waitForElementToLoad(iframe, timeout).then(done); } internal.insertElement(iframe, document, 'html', true); -}; +} /** * Creates a snippet of HTML that retrieves the specified `url` diff --git a/test/spec/modules/prebidServerBidAdapter_spec.js b/test/spec/modules/prebidServerBidAdapter_spec.js index be07e1dcc93..4be8c462144 100644 --- a/test/spec/modules/prebidServerBidAdapter_spec.js +++ b/test/spec/modules/prebidServerBidAdapter_spec.js @@ -2420,6 +2420,36 @@ describe('S2S Adapter', function () { utils.getBidRequest.restore(); }); + + describe('on sync requested with no cookie', () => { + let cfg, req, csRes; + + beforeEach(() => { + cfg = utils.deepClone(CONFIG); + req = utils.deepClone(REQUEST); + cfg.syncEndpoint = { p1Consent: 'https://prebid.adnxs.com/pbs/v1/cookie_sync' }; + req.s2sConfig = cfg; + config.setConfig({ s2sConfig: cfg }); + csRes = utils.deepClone(RESPONSE_NO_COOKIE); + }); + + afterEach(() => { + resetSyncedStatus(); + }) + + Object.entries({ + iframe: () => utils.insertUserSyncIframe, + image: () => utils.triggerPixel, + }).forEach(([type, syncer]) => { + it(`passes timeout to ${type} syncs`, () => { + cfg.syncTimeout = 123; + csRes.bidder_status[0].usersync.type = type; + adapter.callBids(req, BID_REQUESTS, addBidResponse, done, ajax); + server.requests[0].respond(200, {}, JSON.stringify(csRes)); + expect(syncer().args[0]).to.include.members([123]); + }); + }); + }); }); describe('bid won events', function () { diff --git a/test/spec/utils_spec.js b/test/spec/utils_spec.js index 6494ead78e7..898b79cdcb5 100644 --- a/test/spec/utils_spec.js +++ b/test/spec/utils_spec.js @@ -2,6 +2,7 @@ import { getAdServerTargeting } from 'test/fixtures/fixtures.js'; import { expect } from 'chai'; import CONSTANTS from 'src/constants.json'; import * as utils from 'src/utils.js'; +import {waitForElementToLoad} from 'src/utils.js'; var assert = require('assert'); @@ -1198,4 +1199,41 @@ describe('Utils', function () { }); }); }); + + describe('waitForElementToLoad', () => { + let element; + let callbacks; + + function callback() { + callbacks++; + } + + function delay(delay = 0) { + return new Promise((resolve) => { + window.setTimeout(resolve, delay); + }) + } + + beforeEach(() => { + callbacks = 0; + element = window.document.createElement('div'); + }); + + it('should respect timeout if set', () => { + waitForElementToLoad(element, 50).then(callback); + return delay(60).then(() => { + expect(callbacks).to.equal(1); + }); + }); + + ['load', 'error'].forEach((event) => { + it(`should complete on '${event} event'`, () => { + waitForElementToLoad(element).then(callback); + element.dispatchEvent(new Event(event)); + return delay().then(() => { + expect(callbacks).to.equal(1); + }) + }); + }); + }); });