/** * This module interacts with the server used to cache video ad content to be restored later. * At a high level, the expected workflow goes like this: * * - Request video ads from Bidders * - Generate IDs for each valid bid, and cache the key/value pair on the server. * - Return these IDs so that publishers can use them to fetch the bids later. * * This trickery helps integrate with ad servers, which set character limits on request params. */ import {ajaxBuilder} from './ajax.js'; import {config} from './config.js'; import {auctionManager} from './auctionManager.js'; import {generateUUID, logError, logWarn} from './utils.js'; import {addBidToAuction} from './auction.js'; import type {VideoBid} from "./bidfactory.ts"; /** * Might be useful to be configurable in the future * Depending on publisher needs */ // TODO: we have a `ttlBuffer` setting const ttlBufferInSeconds = 15; export const vastLocalCache = new Map(); /** * Function which wraps a URI that serves VAST XML, so that it can be loaded. * * @param uri The URI where the VAST content can be found. * @param impTrackerURLs An impression tracker URL for the delivery of the video ad * @return A VAST URL which loads XML from the given URI. */ function wrapURI(uri: string, impTrackerURLs: string | string[]) { impTrackerURLs = impTrackerURLs && (Array.isArray(impTrackerURLs) ? impTrackerURLs : [impTrackerURLs]); // Technically, this is vulnerable to cross-script injection by sketchy vastUrl bids. // We could make sure it's a valid URI... but since we're loading VAST XML from the // URL they provide anyway, that's probably not a big deal. const impressions = impTrackerURLs ? impTrackerURLs.map(trk => ``).join('') : ''; return ` prebid.org wrapper ${impressions} `; } declare module './bidfactory' { interface VideoBidResponseProperties { /** * VAST impression trackers to attach to this bid. */ vastImpUrl?: string | string [] /** * Cache key to use for caching this bid's VAST. */ customCacheKey?: string } interface VideoBidProperties { /** * The cache key that was used for this bid. */ videoCacheKey?: string; } } export interface CacheConfig { /** * The URL of the Prebid Cache server endpoint where VAST creatives will be sent. */ url: string; /** * Flag determining whether to locally save VAST XML as a blob */ useLocal?: boolean; /** * Timeout (in milliseconds) for network requests to the cache */ timeout?: number; /** * Passes additional data to the url, used for additional event tracking data. Defaults to false. */ vasttrack?: boolean; /** * If the bidder supplied their own cache key, setting this value to true adds a VAST wrapper around that URL, * stores it in the cache defined by the url parameter, and replaces the original video cache key with the new one. * This can dramatically simplify ad server setup because it means all VAST creatives reside behind a single URL. * The tradeoff: this approach requires the video player to unwrap one extra level of VAST. Defaults to false. */ ignoreBidderCacheKey?: boolean; /** * Enables video cache requests to be batched by a specified amount (defaults to 1) instead of making a single request per each video. */ batchSize?: number; /** * Used in conjunction with batchSize, batchTimeout specifies how long to wait in milliseconds before sending * a batch video cache request based on the value for batchSize (if present). A batch request will be made whether * the batchSize amount was reached or the batchTimeout timer runs out. batchTimeout defaults to 0. */ batchTimeout?: number; } declare module './config' { interface Config { cache?: CacheConfig; } } /** * Wraps a bid in the format expected by the prebid-server endpoints, or returns null if * the bid can't be converted cleanly. * * @return {Object|null} - The payload to be sent to the prebid-server endpoints, or null if the bid can't be converted cleanly. */ function toStorageRequest(bid, {index = auctionManager.index} = {}) { const vastValue = getVastXml(bid); const auction = index.getAuction(bid); const ttlWithBuffer = Number(bid.ttl) + ttlBufferInSeconds; const payload: any = { type: 'xml', value: vastValue, ttlseconds: ttlWithBuffer }; if (config.getConfig('cache.vasttrack')) { payload.bidder = bid.bidder; payload.bidid = bid.requestId; payload.aid = bid.auctionId; } if (auction != null) { payload.timestamp = auction.getAuctionStart(); } if (typeof bid.customCacheKey === 'string' && bid.customCacheKey !== '') { payload.key = bid.customCacheKey; } return payload; } interface VideoCacheStoreCallback { /** * A function which should be called with the results of the storage operation. * * @param error The error, if one occurred. * @param uuids An array of unique IDs. The array will have one element for each bid we were asked * to store. It may include null elements if some of the bids were malformed, or an error occurred. * Each non-null element in this array is a valid input into the retrieve function, which will fetch * some VAST XML which can be used to render this bid's ad. */ (error: Error | null, uuids: { uuid: string }[]) } /** * A function which bridges the APIs between the videoCacheStoreCallback and our ajax function's API. * * @param done A callback to the "store" function. */ function shimStorageCallback(done: VideoCacheStoreCallback) { return { success: function (responseBody) { let ids; try { ids = JSON.parse(responseBody).responses } catch (e) { done(e, []); return; } if (ids) { done(null, ids); } else { done(new Error("The cache server didn't respond with a responses property."), []); } }, error: function (statusText, responseBody) { done(new Error(`Error storing video ad in the cache: ${statusText}: ${JSON.stringify(responseBody)}`), []); } } } function getVastXml(bid) { return bid.vastXml ? bid.vastXml : wrapURI(bid.vastUrl, bid.vastImpUrl); }; /** * If the given bid is for a Video ad, generate a unique ID and cache it somewhere server-side. * * @param bids A list of bid objects which should be cached. * @param done An optional callback which should be executed after * @param getAjax * the data has been stored in the cache. */ export function store(bids: VideoBid[], done?: VideoCacheStoreCallback, getAjax = ajaxBuilder) { const requestData = { puts: bids.map(bid => toStorageRequest(bid)) }; const ajax = getAjax(config.getConfig('cache.timeout')); ajax(config.getConfig('cache.url'), shimStorageCallback(done), JSON.stringify(requestData), { contentType: 'text/plain', withCredentials: true }); } export function getCacheUrl(id) { return `${config.getConfig('cache.url')}?uuid=${id}`; } export const storeLocally = (bid) => { const vastXml = getVastXml(bid); const bidVastUrl = URL.createObjectURL(new Blob([vastXml], { type: 'text/xml' })); assignVastUrlAndCacheId(bid, bidVastUrl); vastLocalCache.set(bid.videoCacheKey, bidVastUrl); }; const assignVastUrlAndCacheId = (bid, vastUrl, videoCacheKey?) => { bid.videoCacheKey = videoCacheKey || generateUUID(); if (!bid.vastUrl) { bid.vastUrl = vastUrl; } } export const _internal = { store } export function storeBatch(batch) { const bids = batch.map(entry => entry.bidResponse) function err(msg) { logError(`Failed to save to the video cache: ${msg}. Video bids will be discarded:`, bids) } _internal.store(bids, function (error, cacheIds) { if (error) { err(error) } else if (batch.length !== cacheIds.length) { logError(`expected ${batch.length} cache IDs, got ${cacheIds.length} instead`) } else { cacheIds.forEach((cacheId, i) => { const {auctionInstance, bidResponse, afterBidAdded} = batch[i]; if (cacheId.uuid === '') { logWarn(`Supplied video cache key was already in use by Prebid Cache; caching attempt was rejected. Video bid must be discarded.`); } else { assignVastUrlAndCacheId(bidResponse, getCacheUrl(cacheId.uuid), cacheId.uuid); addBidToAuction(auctionInstance, bidResponse); afterBidAdded(); } }); } }); }; let batchSize, batchTimeout, cleanupHandler; if (FEATURES.VIDEO || FEATURES.AUDIO) { config.getConfig('cache', ({cache}) => { batchSize = typeof cache.batchSize === 'number' && cache.batchSize > 0 ? cache.batchSize : 1; batchTimeout = typeof cache.batchTimeout === 'number' && cache.batchTimeout > 0 ? cache.batchTimeout : 0; // removing blobs that are not going to be used if (cache.useLocal && !cleanupHandler) { cleanupHandler = auctionManager.onExpiry((auction) => { auction.getBidsReceived() .forEach((bid) => { const vastUrl = vastLocalCache.get(bid.videoCacheKey) if (vastUrl && vastUrl.startsWith('blob')) { URL.revokeObjectURL(vastUrl); } vastLocalCache.delete(bid.videoCacheKey); }) }); } }); } export const batchingCache = (timeout = setTimeout, cache = storeBatch) => { let batches = [[]]; let debouncing = false; const noTimeout = cb => cb(); return function (auctionInstance, bidResponse, afterBidAdded) { const batchFunc = batchTimeout > 0 ? timeout : noTimeout; if (batches[batches.length - 1].length >= batchSize) { batches.push([]); } batches[batches.length - 1].push({auctionInstance, bidResponse, afterBidAdded}); if (!debouncing) { debouncing = true; batchFunc(() => { batches.forEach(cache); batches = [[]]; debouncing = false; }, batchTimeout); } }; }; export const batchAndStore = batchingCache();