diff --git a/.eslintrc.js b/.eslintrc.js index 511e78048e4..f17c7a0063d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -42,6 +42,7 @@ module.exports = { sourceType: 'module', ecmaVersion: 2018, }, + ignorePatterns: ['libraries/creative-renderer*'], rules: { 'comma-dangle': 'off', diff --git a/allowedModules.js b/allowedModules.js index 3e6e3039fa2..75ad4141a6c 100644 --- a/allowedModules.js +++ b/allowedModules.js @@ -1,22 +1,18 @@ -const sharedWhiteList = [ -]; - module.exports = { 'modules': [ - ...sharedWhiteList, 'criteo-direct-rsa-validate', 'crypto-js', 'live-connect' // Maintained by LiveIntent : https://github.com/liveintent-berlin/live-connect/ ], 'src': [ - ...sharedWhiteList, 'fun-hooks/no-eval', 'just-clone', 'dlv', 'dset' ], 'libraries': [ - ...sharedWhiteList // empty for now, but keep it to enable linting + ], + 'creative': [ ] }; diff --git a/creative/README.md b/creative/README.md new file mode 100644 index 00000000000..76f0be833e3 --- /dev/null +++ b/creative/README.md @@ -0,0 +1,44 @@ +## Dynamic creative renderers + +The contents of this directory are compiled separately from the rest of Prebid, and intended to be dynamically injected +into creative frames: + +- `crossDomain.js` (compiled into `build/creative/creative.js`, also exposed in `integrationExamples/gpt/x-domain/creative.html`) + is the logic that should be statically set up in the creative. +- At build time, each folder under 'renderers' is compiled into a source string made available from a corresponding +`creative-renderer-*` library. These libraries are committed in source so that they are available to NPM consumers. +- At render time, Prebid passes the appropriate renderer's source string to the remote creative, which then runs it. + +The goal is to have a creative script that is as simple, lightweight, and unchanging as possible, but still allow the possibility +of complex or frequently updated rendering logic. Compared to the approach taken by [PUC](https://github.com/prebid/prebid-universal-creative), this: + +- should perform marginally better: the creative only runs logic that is pertinent (for example, it sees native logic only on native bids); +- avoids the problem of synchronizing deployments when the rendering logic is updated (see https://github.com/prebid/prebid-universal-creative/issues/187), since it's bundled together with the rest of Prebid; +- is easier to embed directly in the creative (saving a network call), since the static "shell" is designed to change as infrequently as possible; +- allows the same rendering logic to be used both in remote (cross-domain) and local (`pbjs.renderAd`) frames, since it's directly available to Prebid; +- requires Prebid.js - meaning it does not support AMP/App/Mobile (but it's still possible for something like PUC to run the same dynamic renderers + when it receives them from Prebid, and fall back to separate AMP/App/Mobile logic otherwise). + +### Renderer interface + +A creative renderer (not related to other types of renderers in the codebase) is a script that exposes a global `window.render` function: + +```javascript +window.render = function(data, {mkFrame, sendMessage}, win) { ... } +``` + +where: + + - `data` is rendering data about the winning bid, and varies depending on the bid type - see `getRenderingData` in `adRendering.js`; + - `mkFrame(document, attributes)` is a utility that creates a frame with the given attributes and convenient defaults (no border, margin, and scrolling); + - `sendMessage(messageType, payload)` is the mechanism by which the renderer/creative can communicate back with Prebid - see `creativeMessageHandler` in `adRendering.js`; + - `win` is the window to render into; note that this is not the same window that runs the renderer. + +The function may return a promise; if it does and the promise rejects, or if the function throws, an AD_RENDER_FAILED event is emitted in Prebid. Otherwise an AD_RENDER_SUCCEEDED is fired +when the promise resolves (or when `render` returns anything other than a promise). + +### Renderer development + +Since renderers are compiled into source, they use production settings even during development builds. You can toggle this with +the `--creative-dev` CLI option (e.g., `gulp serve-fast --creative-dev`), which disables the minifier and generates source maps; if you do, take care +to not commit the resulting `creative-renderer-*` libraries (or run a normal build before you do). diff --git a/creative/constants.js b/creative/constants.js new file mode 100644 index 00000000000..6bb92cfe3c2 --- /dev/null +++ b/creative/constants.js @@ -0,0 +1,9 @@ +// eslint-disable-next-line prebid/validate-imports +import CONSTANTS from '../src/constants.json'; + +export const MESSAGE_REQUEST = CONSTANTS.MESSAGES.REQUEST; +export const MESSAGE_RESPONSE = CONSTANTS.MESSAGES.RESPONSE; +export const MESSAGE_EVENT = CONSTANTS.MESSAGES.EVENT; +export const EVENT_AD_RENDER_FAILED = CONSTANTS.EVENTS.AD_RENDER_FAILED; +export const EVENT_AD_RENDER_SUCCEEDED = CONSTANTS.EVENTS.AD_RENDER_SUCCEEDED; +export const ERROR_EXCEPTION = CONSTANTS.AD_RENDER_FAILED_REASON.EXCEPTION; diff --git a/creative/crossDomain.js b/creative/crossDomain.js new file mode 100644 index 00000000000..a851885bfc0 --- /dev/null +++ b/creative/crossDomain.js @@ -0,0 +1,92 @@ +import { + ERROR_EXCEPTION, + EVENT_AD_RENDER_FAILED, EVENT_AD_RENDER_SUCCEEDED, + MESSAGE_EVENT, + MESSAGE_REQUEST, + MESSAGE_RESPONSE +} from './constants.js'; + +const mkFrame = (() => { + const DEFAULTS = { + frameBorder: 0, + scrolling: 'no', + marginHeight: 0, + marginWidth: 0, + topMargin: 0, + leftMargin: 0, + allowTransparency: 'true', + }; + return (doc, attrs) => { + const frame = doc.createElement('iframe'); + Object.entries(Object.assign({}, attrs, DEFAULTS)) + .forEach(([k, v]) => frame.setAttribute(k, v)); + return frame; + }; +})(); + +export function renderer(win) { + return function ({adId, pubUrl, clickUrl}) { + const pubDomain = new URL(pubUrl, window.location).origin; + + function sendMessage(type, payload, responseListener) { + const channel = new MessageChannel(); + channel.port1.onmessage = guard(responseListener); + win.parent.postMessage(JSON.stringify(Object.assign({message: type, adId}, payload)), pubDomain, [channel.port2]); + } + + function onError(e) { + sendMessage(MESSAGE_EVENT, { + event: EVENT_AD_RENDER_FAILED, + info: { + reason: e?.reason || ERROR_EXCEPTION, + message: e?.message + } + }); + // eslint-disable-next-line no-console + e?.stack && console.error(e); + } + + function guard(fn) { + return function () { + try { + return fn.apply(this, arguments); + } catch (e) { + onError(e); + } + }; + } + + function onMessage(ev) { + let data; + try { + data = JSON.parse(ev.data); + } catch (e) { + return; + } + if (data.message === MESSAGE_RESPONSE && data.adId === adId) { + const renderer = mkFrame(win.document, { + width: 0, + height: 0, + style: 'display: none', + srcdoc: `` + }); + renderer.onload = guard(function () { + const W = renderer.contentWindow; + // NOTE: on Firefox, `Promise.resolve(P)` or `new Promise((resolve) => resolve(P))` + // does not appear to work if P comes from another frame + W.Promise.resolve(W.render(data, {sendMessage, mkFrame}, win)).then( + () => sendMessage(MESSAGE_EVENT, {event: EVENT_AD_RENDER_SUCCEEDED}), + onError + ) + }); + win.document.body.appendChild(renderer); + } + } + + sendMessage(MESSAGE_REQUEST, { + options: {clickUrl} + }, onMessage); + }; +} + +window.pbRender = renderer(window); diff --git a/creative/renderers/display/constants.js b/creative/renderers/display/constants.js new file mode 100644 index 00000000000..d291c79bb34 --- /dev/null +++ b/creative/renderers/display/constants.js @@ -0,0 +1,4 @@ +// eslint-disable-next-line prebid/validate-imports +import CONSTANTS from '../../../src/constants.json'; + +export const ERROR_NO_AD = CONSTANTS.AD_RENDER_FAILED_REASON.NO_AD; diff --git a/creative/renderers/display/renderer.js b/creative/renderers/display/renderer.js new file mode 100644 index 00000000000..e031679b116 --- /dev/null +++ b/creative/renderers/display/renderer.js @@ -0,0 +1,21 @@ +import {ERROR_NO_AD} from './constants.js'; + +export function render({ad, adUrl, width, height}, {mkFrame}, win) { + if (!ad && !adUrl) { + throw { + reason: ERROR_NO_AD, + message: 'Missing ad markup or URL' + }; + } else { + const doc = win.document; + const attrs = {width, height}; + if (adUrl && !ad) { + attrs.src = adUrl; + } else { + attrs.srcdoc = ad; + } + doc.body.appendChild(mkFrame(doc, attrs)); + } +} + +window.render = render; diff --git a/creative/renderers/native/constants.js b/creative/renderers/native/constants.js new file mode 100644 index 00000000000..ac20275fca8 --- /dev/null +++ b/creative/renderers/native/constants.js @@ -0,0 +1,14 @@ +// eslint-disable-next-line prebid/validate-imports +import CONSTANTS from '../../../src/constants.json'; + +export const MESSAGE_NATIVE = CONSTANTS.MESSAGES.NATIVE; +export const ACTION_RESIZE = 'resizeNativeHeight'; +export const ACTION_CLICK = 'click'; +export const ACTION_IMP = 'fireNativeImpressionTrackers'; + +export const ORTB_ASSETS = { + title: 'text', + data: 'value', + img: 'url', + video: 'vasttag' +} diff --git a/creative/renderers/native/renderer.js b/creative/renderers/native/renderer.js new file mode 100644 index 00000000000..5cc8f100108 --- /dev/null +++ b/creative/renderers/native/renderer.js @@ -0,0 +1,88 @@ +import {ACTION_CLICK, ACTION_IMP, ACTION_RESIZE, MESSAGE_NATIVE, ORTB_ASSETS} from './constants.js'; + +export function getReplacer(adId, {assets = [], ortb, nativeKeys = {}}) { + const assetValues = Object.fromEntries((assets).map(({key, value}) => [key, value])); + let repl = Object.fromEntries( + Object.entries(nativeKeys).flatMap(([name, key]) => { + const value = assetValues.hasOwnProperty(name) ? assetValues[name] : undefined; + return [ + [`##${key}##`, value], + [`${key}:${adId}`, value] + ]; + }) + ); + if (ortb) { + Object.assign(repl, + { + '##hb_native_linkurl##': ortb.link?.url, + '##hb_native_privacy##': ortb.privacy + }, + Object.fromEntries( + (ortb.assets || []).flatMap(asset => { + const type = Object.keys(ORTB_ASSETS).find(type => asset[type]); + return [ + type && [`##hb_native_asset_id_${asset.id}##`, asset[type][ORTB_ASSETS[type]]], + asset.link?.url && [`##hb_native_asset_link_id_${asset.id}##`, asset.link.url] + ].filter(e => e); + }) + ) + ); + } + repl = Object.entries(repl).concat([[/##hb_native_asset_(link_)?id_\d+##/g]]); + + return function (template) { + return repl.reduce((text, [pattern, value]) => text.replaceAll(pattern, value || ''), template); + }; +} + +function loadScript(url, doc) { + return new Promise((resolve, reject) => { + const script = doc.createElement('script'); + script.onload = resolve; + script.onerror = reject; + script.src = url; + doc.body.appendChild(script); + }); +} + +export function getAdMarkup(adId, nativeData, replacer, win, load = loadScript) { + const {rendererUrl, assets, ortb, adTemplate} = nativeData; + const doc = win.document; + if (rendererUrl) { + return load(rendererUrl, doc).then(() => { + if (typeof win.renderAd !== 'function') { + throw new Error(`Renderer from '${rendererUrl}' does not define renderAd()`); + } + const payload = assets || []; + payload.ortb = ortb; + return win.renderAd(payload); + }); + } else { + return Promise.resolve(replacer(adTemplate ?? doc.body.innerHTML)); + } +} + +export function render({adId, native}, {sendMessage}, win, getMarkup = getAdMarkup) { + const {head, body} = win.document; + const resize = () => sendMessage(MESSAGE_NATIVE, { + action: ACTION_RESIZE, + height: body.offsetHeight, + width: body.offsetWidth + }); + const replacer = getReplacer(adId, native); + head && (head.innerHTML = replacer(head.innerHTML)); + return getMarkup(adId, native, replacer, win).then(markup => { + body.innerHTML = markup; + if (typeof win.postRenderAd === 'function') { + win.postRenderAd({adId, ...native}); + } + win.document.querySelectorAll('.pb-click').forEach(el => { + const assetId = el.getAttribute('hb_native_asset_id'); + el.addEventListener('click', () => sendMessage(MESSAGE_NATIVE, {action: ACTION_CLICK, assetId})); + }); + sendMessage(MESSAGE_NATIVE, {action: ACTION_IMP}); + win.document.readyState === 'complete' ? resize() : win.onload = resize; + }); +} + +window.render = render; diff --git a/gulpfile.js b/gulpfile.js index d035da4b0fc..f3d44243ef8 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -25,6 +25,8 @@ const path = require('path'); const execa = require('execa'); const {minify} = require('terser'); const Vinyl = require('vinyl'); +const wrap = require('gulp-wrap'); +const rename = require('gulp-rename'); var prebid = require('./package.json'); var port = 9999; @@ -83,6 +85,7 @@ function lint(done) { 'src/**/*.js', 'modules/**/*.js', 'libraries/**/*.js', + 'creative/**/*.js', 'test/**/*.js', 'plugins/**/*.js', '!plugins/**/node_modules/**', @@ -170,10 +173,29 @@ function makeWebpackPkg(extraConfig = {}) { } } -function buildCreative() { - return gulp.src(['**/*']) - .pipe(webpackStream(require('./webpack.creative.js'))) - .pipe(gulp.dest('build/creative')) +function buildCreative(mode = 'production') { + const opts = {mode}; + if (mode === 'development') { + opts.devtool = 'inline-source-map' + } + return function() { + return gulp.src(['**/*']) + .pipe(webpackStream(Object.assign(require('./webpack.creative.js'), opts))) + .pipe(gulp.dest('build/creative')) + } +} + +function updateCreativeRenderers() { + return gulp.src(['build/creative/renderers/**/*']) + .pipe(wrap('// this file is autogenerated, see creative/README.md\nexport const RENDERER = <%= JSON.stringify(contents.toString()) %>')) + .pipe(rename(function (path) { + return { + dirname: `creative-renderer-${path.basename}`, + basename: 'renderer', + extname: '.js' + } + })) + .pipe(gulp.dest('libraries')) } function updateCreativeExample(cb) { @@ -440,6 +462,8 @@ function watchTaskMaker(options = {}) { var mainWatcher = gulp.watch([ 'src/**/*.js', 'libraries/**/*.js', + '!libraries/creative-renderer-*/**/*.js', + 'creative/**/*.js', 'modules/**/*.js', ].concat(options.alsoWatch)); @@ -450,8 +474,8 @@ function watchTaskMaker(options = {}) { } } -const watch = watchTaskMaker({alsoWatch: ['test/**/*.js'], task: () => gulp.series(clean, gulp.parallel(lint, 'build-bundle-dev', test, buildCreative))}); -const watchFast = watchTaskMaker({livereload: false, task: () => gulp.parallel('build-bundle-dev', buildCreative)}); +const watch = watchTaskMaker({alsoWatch: ['test/**/*.js'], task: () => gulp.series(clean, gulp.parallel(lint, 'build-bundle-dev', test))}); +const watchFast = watchTaskMaker({livereload: false, task: () => gulp.series('build-bundle-dev')}); // support tasks gulp.task(lint); @@ -461,8 +485,11 @@ gulp.task(clean); gulp.task(escapePostbidConfig); -gulp.task('build-bundle-dev', gulp.series(makeDevpackPkg, gulpBundle.bind(null, true))); -gulp.task('build-bundle-prod', gulp.series(makeWebpackPkg(), gulpBundle.bind(null, false))); +gulp.task('build-creative-dev', gulp.series(buildCreative(argv.creativeDev ? 'development' : 'production'), updateCreativeRenderers)); +gulp.task('build-creative-prod', gulp.series(buildCreative(), updateCreativeRenderers)); + +gulp.task('build-bundle-dev', gulp.series('build-creative-dev', makeDevpackPkg, gulpBundle.bind(null, true))); +gulp.task('build-bundle-prod', gulp.series('build-creative-prod', makeWebpackPkg(), gulpBundle.bind(null, false))); // build-bundle-verbose - prod bundle except names and comments are preserved. Use this to see the effects // of dead code elimination. gulp.task('build-bundle-verbose', gulp.series(makeWebpackPkg({ @@ -482,23 +509,21 @@ gulp.task('build-bundle-verbose', gulp.series(makeWebpackPkg({ } }), gulpBundle.bind(null, false))); -gulp.task('build-creative', gulp.series(buildCreative, updateCreativeExample)); - // public tasks (dependencies are needed for each task since they can be ran on their own) gulp.task('test-only', test); gulp.task('test-all-features-disabled', testTaskMaker({disableFeatures: require('./features.json'), oneBrowser: 'chrome', watch: false})); -gulp.task('test', gulp.series(clean, lint, gulp.parallel('build-creative', gulp.series('test-all-features-disabled', 'test-only')))); +gulp.task('test', gulp.series(clean, lint, 'test-all-features-disabled', 'test-only')); gulp.task('test-coverage', gulp.series(clean, testCoverage)); gulp.task(viewCoverage); gulp.task('coveralls', gulp.series('test-coverage', coveralls)); -gulp.task('build', gulp.series(clean, 'build-bundle-prod', 'build-creative')); +gulp.task('build', gulp.series(clean, 'build-bundle-prod', updateCreativeExample)); gulp.task('build-postbid', gulp.series(escapePostbidConfig, buildPostbid)); gulp.task('serve', gulp.series(clean, lint, gulp.parallel('build-bundle-dev', watch, test))); -gulp.task('serve-fast', gulp.series(clean, gulp.parallel('build-bundle-dev', buildCreative, watchFast))); +gulp.task('serve-fast', gulp.series(clean, gulp.parallel('build-bundle-dev', watchFast))); gulp.task('serve-prod', gulp.series(clean, gulp.parallel('build-bundle-prod', startLocalServer))); gulp.task('serve-and-test', gulp.series(clean, gulp.parallel('build-bundle-dev', watchFast, testTaskMaker({watch: true})))); gulp.task('serve-e2e', gulp.series(clean, 'build-bundle-prod', gulp.parallel(() => startIntegServer(), startLocalServer))); diff --git a/integrationExamples/gpt/x-domain/creative.html b/integrationExamples/gpt/x-domain/creative.html index f1c0c647e72..bf2bd5f3fad 100644 --- a/integrationExamples/gpt/x-domain/creative.html +++ b/integrationExamples/gpt/x-domain/creative.html @@ -2,10 +2,10 @@ // creative will be rendered, e.g. GAM delivering a SafeFrame // this code is autogenerated, also available in 'build/creative/creative.js' - + + + + + + + + + +

Prebid Native w/custom renderer

+
+
+ +
+
+ + + + diff --git a/integrationExamples/noadserver/native_renderer/renderer.js b/integrationExamples/noadserver/native_renderer/renderer.js new file mode 100644 index 00000000000..d1c754f20b7 --- /dev/null +++ b/integrationExamples/noadserver/native_renderer/renderer.js @@ -0,0 +1,69 @@ +window.renderAd = function (data) { + data = Object.fromEntries(data.map(({key, value}) => [key, value])); + return ` + +
+
+
+

+ ${data.title} +

+
+
+ ${data.sponsoredBy} +
+
+
`; +}; diff --git a/libraries/creative-renderer-display/renderer.js b/libraries/creative-renderer-display/renderer.js new file mode 100644 index 00000000000..72f3658fe79 --- /dev/null +++ b/libraries/creative-renderer-display/renderer.js @@ -0,0 +1,2 @@ +// this file is autogenerated, see creative/README.md +export const RENDERER = "!function(){\"use strict\";window.render=function({ad:d,adUrl:i,width:n,height:e},{mkFrame:o},r){if(!d&&!i)throw{reason:\"noAd\",message:\"Missing ad markup or URL\"};{const t=r.document,s={width:n,height:e};i&&!d?s.src=i:s.srcdoc=d,t.body.appendChild(o(t,s))}}}();" \ No newline at end of file diff --git a/libraries/creative-renderer-native/renderer.js b/libraries/creative-renderer-native/renderer.js new file mode 100644 index 00000000000..509f7943af4 --- /dev/null +++ b/libraries/creative-renderer-native/renderer.js @@ -0,0 +1,2 @@ +// this file is autogenerated, see creative/README.md +export const RENDERER = "!function(){\"use strict\";const e=JSON.parse('{\"X3\":{\"B5\":\"Prebid Native\"}}').X3.B5,t={title:\"text\",data:\"value\",img:\"url\",video:\"vasttag\"};function n(e,t){return new Promise(((n,r)=>{const i=t.createElement(\"script\");i.onload=n,i.onerror=r,i.src=e,t.body.appendChild(i)}))}function r(e,t,r,i,o=n){const{rendererUrl:s,assets:a,ortb:d,adTemplate:c}=t,l=i.document;return s?o(s,l).then((()=>{if(\"function\"!=typeof i.renderAd)throw new Error(`Renderer from '${s}' does not define renderAd()`);const e=a||[];return e.ortb=d,i.renderAd(e)})):Promise.resolve(r(c??l.body.innerHTML))}window.render=function({adId:n,native:i},{sendMessage:o},s,a=r){const{head:d,body:c}=s.document,l=()=>o(e,{action:\"resizeNativeHeight\",height:c.offsetHeight,width:c.offsetWidth}),u=function(e,{assets:n=[],ortb:r,nativeKeys:i={}}){const o=Object.fromEntries(n.map((({key:e,value:t})=>[e,t])));let s=Object.fromEntries(Object.entries(i).flatMap((([t,n])=>{const r=o.hasOwnProperty(t)?o[t]:void 0;return[[`##${n}##`,r],[`${n}:${e}`,r]]})));return r&&Object.assign(s,{\"##hb_native_linkurl##\":r.link?.url,\"##hb_native_privacy##\":r.privacy},Object.fromEntries((r.assets||[]).flatMap((e=>{const n=Object.keys(t).find((t=>e[t]));return[n&&[`##hb_native_asset_id_${e.id}##`,e[n][t[n]]],e.link?.url&&[`##hb_native_asset_link_id_${e.id}##`,e.link.url]].filter((e=>e))})))),s=Object.entries(s).concat([[/##hb_native_asset_(link_)?id_\\d+##/g]]),function(e){return s.reduce(((e,[t,n])=>e.replaceAll(t,n||\"\")),e)}}(n,i);return d&&(d.innerHTML=u(d.innerHTML)),a(n,i,u,s).then((t=>{c.innerHTML=t,\"function\"==typeof s.postRenderAd&&s.postRenderAd({adId:n,...i}),s.document.querySelectorAll(\".pb-click\").forEach((t=>{const n=t.getAttribute(\"hb_native_asset_id\");t.addEventListener(\"click\",(()=>o(e,{action:\"click\",assetId:n})))})),o(e,{action:\"fireNativeImpressionTrackers\"}),\"complete\"===s.document.readyState?l():s.onload=l}))}}();" \ No newline at end of file diff --git a/libraries/creativeRender/constants.js b/libraries/creativeRender/constants.js deleted file mode 100644 index 7b67f8ed5cd..00000000000 --- a/libraries/creativeRender/constants.js +++ /dev/null @@ -1,10 +0,0 @@ -import events from '../../src/constants.json'; - -export const PREBID_NATIVE = 'Prebid Native'; -export const PREBID_REQUEST = 'Prebid Request'; -export const PREBID_RESPONSE = 'Prebid Response'; -export const PREBID_EVENT = 'Prebid Event'; -export const AD_RENDER_SUCCEEDED = events.EVENTS.AD_RENDER_SUCCEEDED; -export const AD_RENDER_FAILED = events.EVENTS.AD_RENDER_FAILED; -export const NO_AD = events.AD_RENDER_FAILED_REASON.NO_AD; -export const EXCEPTION = events.AD_RENDER_FAILED_REASON.EXCEPTION; diff --git a/libraries/creativeRender/crossDomain.js b/libraries/creativeRender/crossDomain.js deleted file mode 100644 index ffa8b468f12..00000000000 --- a/libraries/creativeRender/crossDomain.js +++ /dev/null @@ -1,57 +0,0 @@ -import {mkFrame, writeAd} from './writer.js'; -import { - AD_RENDER_FAILED, - AD_RENDER_SUCCEEDED, - PREBID_EVENT, - PREBID_RESPONSE, - PREBID_REQUEST, - EXCEPTION -} from './constants.js'; - -export function renderer(win = window) { - return function ({adId, pubUrl, clickUrl}) { - const pubDomain = (function() { - const a = win.document.createElement('a'); - a.href = pubUrl; - return a.protocol + '//' + a.host; - })(); - function sendMessage(type, payload, transfer) { - win.parent.postMessage(JSON.stringify(Object.assign({message: type, adId}, payload)), pubDomain, transfer); - } - function cb(err) { - sendMessage(PREBID_EVENT, { - event: err == null ? AD_RENDER_SUCCEEDED : AD_RENDER_FAILED, - info: err - }); - } - function onMessage(ev) { - let data = {}; - try { - data = JSON.parse(ev[ev.message ? 'message' : 'data']); - } catch (e) { - return; - } - if (data.message === PREBID_RESPONSE && data.adId === adId) { - try { - let doc = win.document - if (data.ad) { - doc = mkFrame(doc, {width: data.width, height: data.height}).contentDocument; - doc.open(); - } - writeAd(data, cb, doc); - } catch (e) { - // eslint-disable-next-line standard/no-callback-literal - cb({ reason: EXCEPTION, message: e.message }) - } - } - } - - const channel = new MessageChannel(); - channel.port1.onmessage = onMessage; - sendMessage(PREBID_REQUEST, { - options: {clickUrl} - }, [channel.port2]); - win.addEventListener('message', onMessage, false); - } -} -window.renderAd = renderer(); diff --git a/libraries/creativeRender/direct.js b/libraries/creativeRender/direct.js deleted file mode 100644 index 19d34e16844..00000000000 --- a/libraries/creativeRender/direct.js +++ /dev/null @@ -1,62 +0,0 @@ -import {emitAdRenderFail, emitAdRenderSucceeded, handleRender} from '../../src/adRendering.js'; -import {writeAd} from './writer.js'; -import {auctionManager} from '../../src/auctionManager.js'; -import CONSTANTS from '../../src/constants.json'; -import {inIframe, insertElement} from '../../src/utils.js'; -import {getGlobal} from '../../src/prebidGlobal.js'; -import {EXCEPTION} from './constants.js'; - -export function renderAdDirect(doc, adId, options) { - let bid; - function cb(err) { - if (err != null) { - emitAdRenderFail(Object.assign({id: adId, bid}, err)); - } else { - emitAdRenderSucceeded({doc, bid, adId}) - } - } - function renderFn(adData) { - writeAd(adData, cb, doc); - if (doc.defaultView && doc.defaultView.frameElement) { - doc.defaultView.frameElement.width = adData.width; - doc.defaultView.frameElement.height = adData.height; - } - // TODO: this is almost certainly the wrong way to do this - const creativeComment = document.createComment(`Creative ${bid.creativeId} served by ${bid.bidder} Prebid.js Header Bidding`); - insertElement(creativeComment, doc, 'html'); - } - try { - if (!adId || !doc) { - // eslint-disable-next-line standard/no-callback-literal - cb({ - reason: CONSTANTS.AD_RENDER_FAILED_REASON.MISSING_DOC_OR_ADID, - message: `missing ${adId ? 'doc' : 'adId'}` - }); - } else { - bid = auctionManager.findBidByAdId(adId); - - if (FEATURES.VIDEO) { - // TODO: could the video module implement this as a custom renderer, rather than a special case in here? - const adUnit = bid && auctionManager.index.getAdUnit(bid); - const videoModule = getGlobal().videoModule; - if (adUnit?.video && videoModule) { - videoModule.renderBid(adUnit.video.divId, bid); - return; - } - } - - if ((doc === document && !inIframe())) { - // eslint-disable-next-line standard/no-callback-literal - cb({ - reason: CONSTANTS.AD_RENDER_FAILED_REASON.PREVENT_WRITING_ON_MAIN_DOCUMENT, - message: `renderAd was prevented from writing to the main document.` - }) - } else { - handleRender(renderFn, {adId, options: {clickUrl: options?.clickThrough}, bidResponse: bid, doc}); - } - } - } catch (e) { - // eslint-disable-next-line standard/no-callback-literal - cb({reason: EXCEPTION, message: e.message}) - } -} diff --git a/libraries/creativeRender/writer.js b/libraries/creativeRender/writer.js deleted file mode 100644 index 80bb0592a1f..00000000000 --- a/libraries/creativeRender/writer.js +++ /dev/null @@ -1,34 +0,0 @@ -import {NO_AD} from './constants.js'; - -const IFRAME_ATTRS = { - frameBorder: 0, - scrolling: 'no', - marginHeight: 0, - marginWidth: 0, - topMargin: 0, - leftMargin: 0, - allowTransparency: 'true', -}; - -export function mkFrame(doc, attrs) { - const frame = doc.createElement('iframe'); - attrs = Object.assign({}, attrs, IFRAME_ATTRS); - Object.entries(attrs).forEach(([k, v]) => frame.setAttribute(k, v)); - doc.body.appendChild(frame); - return frame; -} - -export function writeAd({ad, adUrl, width, height}, cb, doc = document) { - if (!ad && !adUrl) { - // eslint-disable-next-line standard/no-callback-literal - cb({reason: NO_AD, message: 'Missing ad markup or URL'}); - } else { - if (adUrl && !ad) { - mkFrame(doc, {width, height, src: adUrl}) - } else { - doc.write(ad); - doc.close(); - } - cb(); - } -} diff --git a/modules/nativeRendering.js b/modules/nativeRendering.js new file mode 100644 index 00000000000..8e6b6baab55 --- /dev/null +++ b/modules/nativeRendering.js @@ -0,0 +1,27 @@ +import {getRenderingData} from '../src/adRendering.js'; +import {getNativeRenderingData, isNativeResponse} from '../src/native.js'; +import {auctionManager} from '../src/auctionManager.js'; +import {RENDERER} from '../libraries/creative-renderer-native/renderer.js'; +import {getCreativeRendererSource} from '../src/creativeRenderers.js'; + +function getRenderingDataHook(next, bidResponse, options) { + if (isNativeResponse(bidResponse)) { + next.bail({ + native: getNativeRenderingData(bidResponse, auctionManager.index.getAdUnit(bidResponse)) + }) + } else { + next(bidResponse, options) + } +} +function getRendererSourceHook(next, bidResponse) { + if (isNativeResponse(bidResponse)) { + next.bail(RENDERER); + } else { + next(bidResponse); + } +} + +if (FEATURES.NATIVE) { + getRenderingData.before(getRenderingDataHook) + getCreativeRendererSource.before(getRendererSourceHook); +} diff --git a/modules/videoModule/index.js b/modules/videoModule/index.js index bfb239210db..28f5c90d326 100644 --- a/modules/videoModule/index.js +++ b/modules/videoModule/index.js @@ -21,6 +21,9 @@ import { gamSubmoduleFactory } from './gamAdServerSubmodule.js'; import { videoImpressionVerifierFactory } from './videoImpressionVerifier.js'; import { AdQueueCoordinator } from './adQueue.js'; import { getExternalVideoEventName, getExternalVideoEventPayload } from '../../libraries/video/shared/helpers.js' +import {VIDEO} from '../../src/mediaTypes.js'; +import {auctionManager} from '../../src/auctionManager.js'; +import {doRender} from '../../src/adRendering.js'; const allVideoEvents = Object.keys(videoEvents).map(eventKey => videoEvents[eventKey]); events.addEvents(allVideoEvents.concat([AUCTION_AD_LOAD_ATTEMPT, AUCTION_AD_LOAD_QUEUED, AUCTION_AD_LOAD_ABORT, BID_IMPRESSION, BID_ERROR]).map(getExternalVideoEventName)); @@ -261,6 +264,18 @@ export function PbVideo(videoCore_, getConfig_, pbGlobal_, pbEvents_, videoEvent } } +function videoRenderHook(next, args) { + if (args.bidResponse.mediaType === VIDEO) { + const adUnit = auctionManager.index.getAdUnit(args.bidResponse); + if (adUnit?.video) { + getGlobal().videoModule.renderBid(adUnit.video.divId, args.bidResponse); + next.bail(); + return; + } + } + next(args); +} + export function pbVideoFactory() { const videoCore = videoCoreFactory(); const adQueueCoordinator = AdQueueCoordinator(videoCore, events); @@ -268,6 +283,7 @@ export function pbVideoFactory() { const pbVideo = PbVideo(videoCore, config.getConfig, pbGlobal, events, allVideoEvents, gamSubmoduleFactory, videoImpressionVerifierFactory, adQueueCoordinator); pbVideo.init(); pbGlobal.videoModule = pbVideo; + doRender.before(videoRenderHook); return pbVideo; } diff --git a/package-lock.json b/package-lock.json index 544d0bdae5b..4965e554935 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "dset": "3.1.2", "express": "^4.15.4", "fun-hooks": "^0.9.9", + "gulp-wrap": "^0.15.0", "just-clone": "^1.0.2", "live-connect-js": "^6.3.4" }, @@ -61,6 +62,7 @@ "gulp-eslint": "^6.0.0", "gulp-if": "^3.0.0", "gulp-js-escape": "^1.0.1", + "gulp-rename": "^2.0.0", "gulp-replace": "^1.0.0", "gulp-shell": "^0.8.0", "gulp-sourcemaps": "^3.0.0", @@ -7194,7 +7196,6 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/ansi-wrap/-/ansi-wrap-0.1.0.tgz", "integrity": "sha512-ZyznvL8k/FZeQHr2T6LzcJ/+vBApDnMNZvfVFy3At0knswWd6rJ3/0Hhmpu8oqa6C92npmozs890sX9Dl6q+Qw==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -7293,7 +7294,6 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, "dependencies": { "sprintf-js": "~1.0.2" } @@ -7564,7 +7564,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", "integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -8312,8 +8311,7 @@ "node_modules/bluebird": { "version": "3.4.7", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", - "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", - "dev": true + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==" }, "node_modules/body": { "version": "5.1.0", @@ -8621,6 +8619,38 @@ "node": ">=0.2.0" } }, + "node_modules/bufferstreams": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/bufferstreams/-/bufferstreams-1.0.1.tgz", + "integrity": "sha512-LZmiIfQprMLS6/k42w/PTc7awhU8AdNNcUerxTgr01WlP9agR2SgMv0wjlYYFD6eDOi8WvofrTX8RayjR/AeUQ==", + "dependencies": { + "readable-stream": "^1.0.33" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/bufferstreams/node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==" + }, + "node_modules/bufferstreams/node_modules/readable-stream": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "node_modules/bufferstreams/node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -9408,6 +9438,18 @@ "node": ">= 0.6" } }, + "node_modules/consolidate": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/consolidate/-/consolidate-0.15.1.tgz", + "integrity": "sha512-DW46nrsMJgy9kqAbPt5rKaCr7uFtpo4mSUvLHIUbJEjm0vo+aY5QLwBUq3FK4tRnJr/X0Psc0C4jf/h+HtXSMw==", + "deprecated": "Please upgrade to consolidate v1.0.0+ as it has been modernized with several long-awaited fixes implemented. Maintenance is supported by Forward Email at https://forwardemail.net ; follow/watch https://github.com/ladjs/consolidate for updates and release changelog", + "dependencies": { + "bluebird": "^3.1.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -9505,8 +9547,7 @@ "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" }, "node_modules/cors": { "version": "2.8.5", @@ -11234,8 +11275,7 @@ "node_modules/es6-promise": { "version": "4.2.8", "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", - "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", - "dev": true + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==" }, "node_modules/es6-promisify": { "version": "5.0.0", @@ -13992,6 +14032,14 @@ "xtend": "~4.0.1" } }, + "node_modules/fs-readfile-promise": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/fs-readfile-promise/-/fs-readfile-promise-3.0.1.tgz", + "integrity": "sha512-LsSxMeaJdYH27XrW7Dmq0Gx63mioULCRel63B5VeELYLavi1wF5s0XfsIdKDFdCL9hsfQ2qBvXJszQtQJ9h17A==", + "dependencies": { + "graceful-fs": "^4.1.11" + } + }, "node_modules/fs.extra": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fs.extra/-/fs.extra-1.3.2.tgz", @@ -15090,8 +15138,7 @@ "node_modules/graceful-fs": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", - "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", - "dev": true + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==" }, "node_modules/grapheme-splitter": { "version": "1.0.4", @@ -16218,6 +16265,15 @@ "minimatch": "^3.0.3" } }, + "node_modules/gulp-rename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/gulp-rename/-/gulp-rename-2.0.0.tgz", + "integrity": "sha512-97Vba4KBzbYmR5VBs9mWmK+HwIf5mj+/zioxfZhOKeXtx5ZjBk57KFlePf5nxq9QsTtFl0ejnHE3zTC9MHXqyQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/gulp-replace": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/gulp-replace/-/gulp-replace-1.1.3.tgz", @@ -16678,6 +16734,89 @@ "node": ">= 0.9" } }, + "node_modules/gulp-wrap": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/gulp-wrap/-/gulp-wrap-0.15.0.tgz", + "integrity": "sha512-f17zkGObA+hE/FThlg55gfA0nsXbdmHK1WqzjjB2Ytq1TuhLR7JiCBJ3K4AlMzCyoFaCjfowos+VkToUNE0WTQ==", + "dependencies": { + "consolidate": "^0.15.1", + "es6-promise": "^4.2.6", + "fs-readfile-promise": "^3.0.1", + "js-yaml": "^3.13.0", + "lodash": "^4.17.11", + "node.extend": "2.0.2", + "plugin-error": "^1.0.1", + "through2": "^3.0.1", + "tryit": "^1.0.1", + "vinyl-bufferstream": "^1.0.1" + }, + "engines": { + "node": ">=6.14", + "npm": ">=1.4.3" + } + }, + "node_modules/gulp-wrap/node_modules/ansi-colors": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-1.1.0.tgz", + "integrity": "sha512-SFKX67auSNoVR38N3L+nvsPjOE0bybKTYbkf5tRvushrAPQ9V75huw0ZxBkKVeRU9kqH3d6HA4xTckbwZ4ixmA==", + "dependencies": { + "ansi-wrap": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gulp-wrap/node_modules/arr-diff": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", + "integrity": "sha512-YVIQ82gZPGBebQV/a8dar4AitzCQs0jjXwMPZllpXMaGjXPYVUawSxQrRsjhjupyVxEvbHgUmIhKVlND+j02kA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gulp-wrap/node_modules/arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gulp-wrap/node_modules/extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", + "dependencies": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gulp-wrap/node_modules/plugin-error": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/plugin-error/-/plugin-error-1.0.1.tgz", + "integrity": "sha512-L1zP0dk7vGweZME2i+EeakvUNqSrdiI3F91TwEoYiGrAfUXmVv6fJIq4g82PAXxNsWOp0J7ZqQy/3Szz0ajTxA==", + "dependencies": { + "ansi-colors": "^1.0.1", + "arr-diff": "^4.0.0", + "arr-union": "^3.1.0", + "extend-shallow": "^3.0.2" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/gulp-wrap/node_modules/through2": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-3.0.2.tgz", + "integrity": "sha512-enaDQ4MUyP2W6ZyT6EsMzqBPZaM/avg8iuo+l2d3QCs0J+6RaqkHV/2/lOwDTueBHeJ/2LG9lrLW3d5rWPucuQ==", + "dependencies": { + "inherits": "^2.0.4", + "readable-stream": "2 || 3" + } + }, "node_modules/gulplog": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/gulplog/-/gulplog-1.0.0.tgz", @@ -17535,6 +17674,14 @@ "node": ">= 0.10" } }, + "node_modules/is": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/is/-/is-3.3.0.tgz", + "integrity": "sha512-nW24QBoPcFGGHJGUwnfpI7Yc5CdqWNdsyHQszVE/z2pKHXzh7FZ5GWhJqSyaQ9wMkQnsTx+kAI8bHlCX4tKdbg==", + "engines": { + "node": "*" + } + }, "node_modules/is-absolute": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz", @@ -17755,7 +17902,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", - "dev": true, "dependencies": { "is-plain-object": "^2.0.4" }, @@ -17767,7 +17913,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, "dependencies": { "isobject": "^3.0.1" }, @@ -18194,7 +18339,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -18934,7 +19078,6 @@ "version": "3.14.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -18947,7 +19090,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" @@ -19832,8 +19974,7 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "node_modules/lodash._basecopy": { "version": "3.0.1", @@ -22281,6 +22422,18 @@ "strict-event-emitter": "^0.1.0" } }, + "node_modules/node.extend": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/node.extend/-/node.extend-2.0.2.tgz", + "integrity": "sha512-pDT4Dchl94/+kkgdwyS2PauDFjZG0Hk0IcHIB+LkW27HLDtdoeMxHTxZh39DYbPP8UflWXWj9JcdDozF+YDOpQ==", + "dependencies": { + "has": "^1.0.3", + "is": "^3.2.1" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/nopt": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz", @@ -23471,8 +23624,7 @@ "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, "node_modules/progress": { "version": "2.0.3", @@ -23954,7 +24106,6 @@ "version": "2.3.7", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", - "dev": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -23968,14 +24119,12 @@ "node_modules/readable-stream/node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" }, "node_modules/readable-stream/node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, "node_modules/readdir-glob": { "version": "1.1.2", @@ -25636,8 +25785,7 @@ "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" }, "node_modules/sshpk": { "version": "1.17.0", @@ -25891,7 +26039,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -25899,8 +26046,7 @@ "node_modules/string_decoder/node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, "node_modules/string-template": { "version": "0.2.1", @@ -26675,6 +26821,11 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/tryit": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tryit/-/tryit-1.0.3.tgz", + "integrity": "sha512-6C5h3CE+0qjGp+YKYTs74xR0k/Nw/ePtl/Lp6CCf44hqBQ66qnH1sDFR5mV/Gc48EsrHLB53lCFSffQCkka3kg==" + }, "node_modules/tsconfig-paths": { "version": "3.14.1", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz", @@ -27324,8 +27475,7 @@ "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, "node_modules/utils-merge": { "version": "1.0.1", @@ -27673,6 +27823,14 @@ "node": ">= 0.10" } }, + "node_modules/vinyl-bufferstream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/vinyl-bufferstream/-/vinyl-bufferstream-1.0.1.tgz", + "integrity": "sha512-yCCIoTf26Q9SQ0L9cDSavSL7Nt6wgQw8TU1B/bb9b9Z4A3XTypXCGdc5BvXl4ObQvVY8JrDkFnWa/UqBqwM2IA==", + "dependencies": { + "bufferstreams": "1.0.1" + } + }, "node_modules/vinyl-fs": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/vinyl-fs/-/vinyl-fs-3.0.3.tgz", @@ -34489,8 +34647,7 @@ "ansi-wrap": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/ansi-wrap/-/ansi-wrap-0.1.0.tgz", - "integrity": "sha512-ZyznvL8k/FZeQHr2T6LzcJ/+vBApDnMNZvfVFy3At0knswWd6rJ3/0Hhmpu8oqa6C92npmozs890sX9Dl6q+Qw==", - "dev": true + "integrity": "sha512-ZyznvL8k/FZeQHr2T6LzcJ/+vBApDnMNZvfVFy3At0knswWd6rJ3/0Hhmpu8oqa6C92npmozs890sX9Dl6q+Qw==" }, "anymatch": { "version": "3.1.2", @@ -34573,7 +34730,6 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, "requires": { "sprintf-js": "~1.0.2" } @@ -34783,8 +34939,7 @@ "assign-symbols": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", - "integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==", - "dev": true + "integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==" }, "ast-types": { "version": "0.13.4", @@ -35405,8 +35560,7 @@ "bluebird": { "version": "3.4.7", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", - "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", - "dev": true + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==" }, "body": { "version": "5.1.0", @@ -35652,6 +35806,37 @@ "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", "dev": true }, + "bufferstreams": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/bufferstreams/-/bufferstreams-1.0.1.tgz", + "integrity": "sha512-LZmiIfQprMLS6/k42w/PTc7awhU8AdNNcUerxTgr01WlP9agR2SgMv0wjlYYFD6eDOi8WvofrTX8RayjR/AeUQ==", + "requires": { + "readable-stream": "^1.0.33" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==" + }, + "readable-stream": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==" + } + } + }, "bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -36252,6 +36437,14 @@ "integrity": "sha512-3R0kMOdL7CjJpU66fzAkCe6HNtd3AavCS4m+uW4KtJjrdGPT0SQEZieAYd+cm+lJoBznNQ4lqipYWkhBMgk00g==", "dev": true }, + "consolidate": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/consolidate/-/consolidate-0.15.1.tgz", + "integrity": "sha512-DW46nrsMJgy9kqAbPt5rKaCr7uFtpo4mSUvLHIUbJEjm0vo+aY5QLwBUq3FK4tRnJr/X0Psc0C4jf/h+HtXSMw==", + "requires": { + "bluebird": "^3.1.1" + } + }, "content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -36323,8 +36516,7 @@ "core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" }, "cors": { "version": "2.8.5", @@ -37668,8 +37860,7 @@ "es6-promise": { "version": "4.2.8", "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", - "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", - "dev": true + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==" }, "es6-promisify": { "version": "5.0.0", @@ -39813,6 +40004,14 @@ } } }, + "fs-readfile-promise": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/fs-readfile-promise/-/fs-readfile-promise-3.0.1.tgz", + "integrity": "sha512-LsSxMeaJdYH27XrW7Dmq0Gx63mioULCRel63B5VeELYLavi1wF5s0XfsIdKDFdCL9hsfQ2qBvXJszQtQJ9h17A==", + "requires": { + "graceful-fs": "^4.1.11" + } + }, "fs.extra": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fs.extra/-/fs.extra-1.3.2.tgz", @@ -40695,8 +40894,7 @@ "graceful-fs": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", - "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", - "dev": true + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==" }, "grapheme-splitter": { "version": "1.0.4", @@ -41620,6 +41818,12 @@ "minimatch": "^3.0.3" } }, + "gulp-rename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/gulp-rename/-/gulp-rename-2.0.0.tgz", + "integrity": "sha512-97Vba4KBzbYmR5VBs9mWmK+HwIf5mj+/zioxfZhOKeXtx5ZjBk57KFlePf5nxq9QsTtFl0ejnHE3zTC9MHXqyQ==", + "dev": true + }, "gulp-replace": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/gulp-replace/-/gulp-replace-1.1.3.tgz", @@ -41993,6 +42197,72 @@ } } }, + "gulp-wrap": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/gulp-wrap/-/gulp-wrap-0.15.0.tgz", + "integrity": "sha512-f17zkGObA+hE/FThlg55gfA0nsXbdmHK1WqzjjB2Ytq1TuhLR7JiCBJ3K4AlMzCyoFaCjfowos+VkToUNE0WTQ==", + "requires": { + "consolidate": "^0.15.1", + "es6-promise": "^4.2.6", + "fs-readfile-promise": "^3.0.1", + "js-yaml": "^3.13.0", + "lodash": "^4.17.11", + "node.extend": "2.0.2", + "plugin-error": "^1.0.1", + "through2": "^3.0.1", + "tryit": "^1.0.1", + "vinyl-bufferstream": "^1.0.1" + }, + "dependencies": { + "ansi-colors": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-1.1.0.tgz", + "integrity": "sha512-SFKX67auSNoVR38N3L+nvsPjOE0bybKTYbkf5tRvushrAPQ9V75huw0ZxBkKVeRU9kqH3d6HA4xTckbwZ4ixmA==", + "requires": { + "ansi-wrap": "^0.1.0" + } + }, + "arr-diff": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", + "integrity": "sha512-YVIQ82gZPGBebQV/a8dar4AitzCQs0jjXwMPZllpXMaGjXPYVUawSxQrRsjhjupyVxEvbHgUmIhKVlND+j02kA==" + }, + "arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==" + }, + "extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", + "requires": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + } + }, + "plugin-error": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/plugin-error/-/plugin-error-1.0.1.tgz", + "integrity": "sha512-L1zP0dk7vGweZME2i+EeakvUNqSrdiI3F91TwEoYiGrAfUXmVv6fJIq4g82PAXxNsWOp0J7ZqQy/3Szz0ajTxA==", + "requires": { + "ansi-colors": "^1.0.1", + "arr-diff": "^4.0.0", + "arr-union": "^3.1.0", + "extend-shallow": "^3.0.2" + } + }, + "through2": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-3.0.2.tgz", + "integrity": "sha512-enaDQ4MUyP2W6ZyT6EsMzqBPZaM/avg8iuo+l2d3QCs0J+6RaqkHV/2/lOwDTueBHeJ/2LG9lrLW3d5rWPucuQ==", + "requires": { + "inherits": "^2.0.4", + "readable-stream": "2 || 3" + } + } + } + }, "gulplog": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/gulplog/-/gulplog-1.0.0.tgz", @@ -42621,6 +42891,11 @@ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" }, + "is": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/is/-/is-3.3.0.tgz", + "integrity": "sha512-nW24QBoPcFGGHJGUwnfpI7Yc5CdqWNdsyHQszVE/z2pKHXzh7FZ5GWhJqSyaQ9wMkQnsTx+kAI8bHlCX4tKdbg==" + }, "is-absolute": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz", @@ -42767,7 +43042,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", - "dev": true, "requires": { "is-plain-object": "^2.0.4" }, @@ -42776,7 +43050,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, "requires": { "isobject": "^3.0.1" } @@ -43072,8 +43345,7 @@ "isobject": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "dev": true + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==" }, "isstream": { "version": "0.1.2", @@ -43630,7 +43902,6 @@ "version": "3.14.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, "requires": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -43639,8 +43910,7 @@ "esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" } } }, @@ -44354,8 +44624,7 @@ "lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "lodash._basecopy": { "version": "3.0.1", @@ -46191,6 +46460,15 @@ "strict-event-emitter": "^0.1.0" } }, + "node.extend": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/node.extend/-/node.extend-2.0.2.tgz", + "integrity": "sha512-pDT4Dchl94/+kkgdwyS2PauDFjZG0Hk0IcHIB+LkW27HLDtdoeMxHTxZh39DYbPP8UflWXWj9JcdDozF+YDOpQ==", + "requires": { + "has": "^1.0.3", + "is": "^3.2.1" + } + }, "nopt": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz", @@ -47066,8 +47344,7 @@ "process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, "progress": { "version": "2.0.3", @@ -47425,7 +47702,6 @@ "version": "2.3.7", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", - "dev": true, "requires": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -47439,14 +47715,12 @@ "isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" }, "safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" } } }, @@ -48798,8 +49072,7 @@ "sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" }, "sshpk": { "version": "1.17.0", @@ -49010,7 +49283,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, "requires": { "safe-buffer": "~5.1.0" }, @@ -49018,8 +49290,7 @@ "safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" } } }, @@ -49629,6 +49900,11 @@ "integrity": "sha512-AqTiAOLcj85xS7vQ8QkAV41hPDIJ71XJB4RCUrzo/1GM2CQwhkJGaf9Hgr7BOugMRpgGUrqRg/DrBDl4H40+8g==", "dev": true }, + "tryit": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tryit/-/tryit-1.0.3.tgz", + "integrity": "sha512-6C5h3CE+0qjGp+YKYTs74xR0k/Nw/ePtl/Lp6CCf44hqBQ66qnH1sDFR5mV/Gc48EsrHLB53lCFSffQCkka3kg==" + }, "tsconfig-paths": { "version": "3.14.1", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz", @@ -50135,8 +50411,7 @@ "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, "utils-merge": { "version": "1.0.1", @@ -50413,6 +50688,14 @@ } } }, + "vinyl-bufferstream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/vinyl-bufferstream/-/vinyl-bufferstream-1.0.1.tgz", + "integrity": "sha512-yCCIoTf26Q9SQ0L9cDSavSL7Nt6wgQw8TU1B/bb9b9Z4A3XTypXCGdc5BvXl4ObQvVY8JrDkFnWa/UqBqwM2IA==", + "requires": { + "bufferstreams": "1.0.1" + } + }, "vinyl-fs": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/vinyl-fs/-/vinyl-fs-3.0.3.tgz", diff --git a/package.json b/package.json index 60a42e6afd6..3e20acbeab9 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "gulp-eslint": "^6.0.0", "gulp-if": "^3.0.0", "gulp-js-escape": "^1.0.1", + "gulp-rename": "^2.0.0", "gulp-replace": "^1.0.0", "gulp-shell": "^0.8.0", "gulp-sourcemaps": "^3.0.0", @@ -132,6 +133,7 @@ "dset": "3.1.2", "express": "^4.15.4", "fun-hooks": "^0.9.9", + "gulp-wrap": "^0.15.0", "just-clone": "^1.0.2", "live-connect-js": "^6.3.4" }, diff --git a/plugins/eslint/validateImports.js b/plugins/eslint/validateImports.js index 324b75c4ce7..b936f44aee7 100644 --- a/plugins/eslint/validateImports.js +++ b/plugins/eslint/validateImports.js @@ -3,6 +3,7 @@ const path = require('path'); const _ = require('lodash'); const resolveFrom = require('resolve-from'); const MODULES_PATH = path.resolve(__dirname, '../../modules'); +const CREATIVE_PATH = path.resolve(__dirname, '../../creative'); function isInDirectory(filename, dir) { const rel = path.relative(dir, filename); @@ -31,6 +32,16 @@ function flagErrors(context, node, importPath) { context.report(node, `import "${importPath}": importing from modules is not allowed`); } + // do not allow imports into `creative` + if (isInDirectory(absImportPath, CREATIVE_PATH) && !isInDirectory(absFileDir, CREATIVE_PATH) && absFileDir !== CREATIVE_PATH) { + context.report(node, `import "${importPath}": importing from creative is not allowed`); + } + + // do not allow imports outside `creative` + if (isInDirectory(absFileDir, CREATIVE_PATH) && !isInDirectory(absImportPath, CREATIVE_PATH) && absImportPath !== CREATIVE_PATH) { + context.report(node, `import "${importPath}": importing from outside creative is not allowed`); + } + // don't allow extension-less local imports if ( !importPath.match(/^\w+/) && diff --git a/src/adRendering.js b/src/adRendering.js index 37bbf20d432..a6d509bea77 100644 --- a/src/adRendering.js +++ b/src/adRendering.js @@ -1,12 +1,16 @@ -import {deepAccess, logError, logWarn, replaceMacros} from './utils.js'; +import {createIframe, deepAccess, inIframe, insertElement, logError, logWarn, replaceMacros} from './utils.js'; import * as events from './events.js'; -import constants from './constants.json'; +import CONSTANTS from './constants.json'; import {config} from './config.js'; import {executeRenderer, isRendererRequired} from './Renderer.js'; import {VIDEO} from './mediaTypes.js'; import {auctionManager} from './auctionManager.js'; +import {getCreativeRenderer} from './creativeRenderers.js'; +import {hook} from './hook.js'; +import {fireNativeTrackers} from './native.js'; -const {AD_RENDER_FAILED, AD_RENDER_SUCCEEDED, STALE_RENDER, BID_WON} = constants.EVENTS; +const {AD_RENDER_FAILED, AD_RENDER_SUCCEEDED, STALE_RENDER, BID_WON} = CONSTANTS.EVENTS; +const {EXCEPTION} = CONSTANTS.AD_RENDER_FAILED_REASON; /** * Emit the AD_RENDER_FAILED event. @@ -46,16 +50,108 @@ export function emitAdRenderSucceeded({ doc, bid, id }) { events.emit(AD_RENDER_SUCCEEDED, data); } -export function handleRender(renderFn, {adId, options, bidResponse, doc}) { +export function handleCreativeEvent(data, bidResponse) { + switch (data.event) { + case CONSTANTS.EVENTS.AD_RENDER_FAILED: + emitAdRenderFail({ + bid: bidResponse, + id: bidResponse.adId, + reason: data.info.reason, + message: data.info.message + }); + break; + case CONSTANTS.EVENTS.AD_RENDER_SUCCEEDED: + emitAdRenderSucceeded({ + doc: null, + bid: bidResponse, + id: bidResponse.adId + }); + break; + default: + logError(`Received event request for unsupported event: '${data.event}' (adId: '${bidResponse.adId}')`); + } +} + +export function handleNativeMessage(data, bidResponse, {resizeFn, fireTrackers = fireNativeTrackers}) { + switch (data.action) { + case 'resizeNativeHeight': + resizeFn(data.width, data.height); + break; + default: + fireTrackers(data, bidResponse); + } +} + +const HANDLERS = { + [CONSTANTS.MESSAGES.EVENT]: handleCreativeEvent +} + +if (FEATURES.NATIVE) { + HANDLERS[CONSTANTS.MESSAGES.NATIVE] = handleNativeMessage; +} + +function creativeMessageHandler(deps) { + return function (type, data, bidResponse) { + if (HANDLERS.hasOwnProperty(type)) { + HANDLERS[type](data, bidResponse, deps); + } + } +} + +export const getRenderingData = hook('sync', function (bidResponse, options) { + const {ad, adUrl, cpm, originalCpm, width, height} = bidResponse + const repl = { + AUCTION_PRICE: originalCpm || cpm, + CLICKTHROUGH: options?.clickUrl || '' + } + return { + ad: replaceMacros(ad, repl), + adUrl: replaceMacros(adUrl, repl), + width, + height + }; +}) + +export const doRender = hook('sync', function({renderFn, resizeFn, bidResponse, options}) { + if (FEATURES.VIDEO && bidResponse.mediaType === VIDEO) { + emitAdRenderFail({ + reason: CONSTANTS.AD_RENDER_FAILED_REASON.PREVENT_WRITING_ON_MAIN_DOCUMENT, + message: 'Cannot render video ad', + bid: bidResponse, + id: bidResponse.adId + }); + return; + } + const data = getRenderingData(bidResponse, options); + renderFn(Object.assign({adId: bidResponse.adId}, data)); + const {width, height} = data; + if ((width ?? height) != null) { + resizeFn(width, height); + } +}); + +doRender.before(function (next, args) { + // run renderers from a high priority hook to allow the video module to insert itself between this and "normal" rendering. + const {bidResponse, doc} = args; + if (isRendererRequired(bidResponse.renderer)) { + executeRenderer(bidResponse.renderer, bidResponse, doc); + emitAdRenderSucceeded({doc, bid: bidResponse, id: bidResponse.adId}) + next.bail(); + } else { + next(args); + } +}, 100) + +export function handleRender({renderFn, resizeFn, adId, options, bidResponse, doc}) { if (bidResponse == null) { emitAdRenderFail({ - reason: constants.AD_RENDER_FAILED_REASON.CANNOT_FIND_AD, + reason: CONSTANTS.AD_RENDER_FAILED_REASON.CANNOT_FIND_AD, message: `Cannot find ad '${adId}'`, id: adId }); return; } - if (bidResponse.status === constants.BID_STATUS.RENDERED) { + if (bidResponse.status === CONSTANTS.BID_STATUS.RENDERED) { logWarn(`Ad id ${adId} has been rendered before`); events.emit(STALE_RENDER, bidResponse); if (deepAccess(config.getConfig('auctionOptions'), 'suppressStaleRender')) { @@ -63,44 +159,67 @@ export function handleRender(renderFn, {adId, options, bidResponse, doc}) { } } try { - const {adId, ad, adUrl, width, height, renderer, cpm, originalCpm, mediaType} = bidResponse; - // rendering for outstream safeframe - if (isRendererRequired(renderer)) { - executeRenderer(renderer, bidResponse, doc); - emitAdRenderSucceeded({doc, bid: bidResponse, id: adId}) - } else if (adId) { - if (mediaType === VIDEO) { - emitAdRenderFail({ - reason: constants.AD_RENDER_FAILED_REASON.PREVENT_WRITING_ON_MAIN_DOCUMENT, - message: 'Cannot render video ad', - bid: bidResponse, - id: adId - }); - return; - } - const repl = { - AUCTION_PRICE: originalCpm || cpm, - CLICKTHROUGH: options?.clickUrl || '' - }; - renderFn({ - ad: replaceMacros(ad, repl), - adUrl: replaceMacros(adUrl, repl), - adId, - width, - height - }); - } + doRender({renderFn, resizeFn, bidResponse, options, doc}); } catch (e) { emitAdRenderFail({ - reason: constants.AD_RENDER_FAILED_REASON.EXCEPTION, + reason: CONSTANTS.AD_RENDER_FAILED_REASON.EXCEPTION, message: e.message, id: adId, bid: bidResponse }); - return; } - // save winning bids auctionManager.addWinningBid(bidResponse); - events.emit(BID_WON, bidResponse); } + +export function renderAdDirect(doc, adId, options) { + let bid; + function fail(reason, message) { + emitAdRenderFail(Object.assign({id: adId, bid}, {reason, message})); + } + function resizeFn(width, height) { + if (doc.defaultView && doc.defaultView.frameElement) { + width && (doc.defaultView.frameElement.width = width); + height && (doc.defaultView.frameElement.height = height); + } + } + const messageHandler = creativeMessageHandler({resizeFn}); + function renderFn(adData) { + if (adData.ad) { + doc.write(adData.ad); + doc.close(); + emitAdRenderSucceeded({doc, bid, adId: bid.adId}); + } else { + getCreativeRenderer(bid) + .then(render => render(adData, { + sendMessage: (type, data) => messageHandler(type, data, bid), + mkFrame: createIframe, + }, doc.defaultView)) + .then( + () => emitAdRenderSucceeded({doc, bid, adId: bid.adId}), + (e) => { + fail(e?.reason || CONSTANTS.AD_RENDER_FAILED_REASON.EXCEPTION, e?.message) + e?.stack && logError(e); + } + ); + } + // TODO: this is almost certainly the wrong way to do this + const creativeComment = document.createComment(`Creative ${bid.creativeId} served by ${bid.bidder} Prebid.js Header Bidding`); + insertElement(creativeComment, doc, 'html'); + } + try { + if (!adId || !doc) { + fail(CONSTANTS.AD_RENDER_FAILED_REASON.MISSING_DOC_OR_ADID, `missing ${adId ? 'doc' : 'adId'}`); + } else { + bid = auctionManager.findBidByAdId(adId); + + if ((doc === document && !inIframe())) { + fail(CONSTANTS.AD_RENDER_FAILED_REASON.PREVENT_WRITING_ON_MAIN_DOCUMENT, `renderAd was prevented from writing to the main document.`); + } else { + handleRender({renderFn, resizeFn, adId, options: {clickUrl: options?.clickThrough}, bidResponse: bid, doc}); + } + } + } catch (e) { + fail(EXCEPTION, e.message); + } +} diff --git a/src/auction.js b/src/auction.js index cc42e54a2b1..c2758fdd571 100644 --- a/src/auction.js +++ b/src/auction.js @@ -80,7 +80,7 @@ import { timestamp } from './utils.js'; import {getPriceBucketString} from './cpmBucketManager.js'; -import {getNativeTargeting, toLegacyResponse} from './native.js'; +import {getNativeTargeting, isNativeResponse, setNativeResponseProperties} from './native.js'; import {getCacheUrl, store} from './videoCache.js'; import {Renderer} from './Renderer.js'; import {config} from './config.js'; @@ -468,10 +468,8 @@ export function auctionCallbacks(auctionDone, auctionInstance, {index = auctionM if (FEATURES.VIDEO && bidResponse.mediaType === VIDEO) { tryAddVideoBid(auctionInstance, bidResponse, done); } else { - if (FEATURES.NATIVE && bidResponse.native != null && typeof bidResponse.native === 'object') { - // NOTE: augment bidResponse.native even if bidResponse.mediaType !== NATIVE; it's possible - // to treat banner responses as native - addLegacyFieldsIfNeeded(bidResponse); + if (FEATURES.NATIVE && isNativeResponse(bidResponse)) { + setNativeResponseProperties(bidResponse, index.getAdUnit(bidResponse)); } addBidToAuction(auctionInstance, bidResponse); done(); @@ -577,17 +575,6 @@ function tryAddVideoBid(auctionInstance, bidResponse, afterBidAdded, {index = au } } -// Native bid response might be in ortb2 format - adds legacy field for backward compatibility -const addLegacyFieldsIfNeeded = (bidResponse) => { - const nativeOrtbRequest = auctionManager.index.getAdUnit(bidResponse)?.nativeOrtbRequest; - const nativeOrtbResponse = bidResponse.native?.ortb - - if (nativeOrtbRequest && nativeOrtbResponse) { - const legacyResponse = toLegacyResponse(nativeOrtbResponse, nativeOrtbRequest); - Object.assign(bidResponse.native, legacyResponse); - } -} - const _storeInCache = (batch) => { store(batch.map(entry => entry.bidResponse), function (error, cacheIds) { cacheIds.forEach((cacheId, i) => { diff --git a/src/constants.json b/src/constants.json index af7e80ec0b4..ceac779a508 100644 --- a/src/constants.json +++ b/src/constants.json @@ -186,5 +186,11 @@ "SUCCESS": "success", "ERROR": "error", "TIMEOUT": "timeout" + }, + "MESSAGES": { + "REQUEST": "Prebid Request", + "RESPONSE": "Prebid Response", + "NATIVE": "Prebid Native", + "EVENT": "Prebid Event" } } diff --git a/src/creativeRenderers.js b/src/creativeRenderers.js new file mode 100644 index 00000000000..8331c23c8de --- /dev/null +++ b/src/creativeRenderers.js @@ -0,0 +1,24 @@ +import {GreedyPromise} from './utils/promise.js'; +import {createInvisibleIframe} from './utils.js'; +import {RENDERER} from '../libraries/creative-renderer-display/renderer.js'; +import {hook} from './hook.js'; + +export const getCreativeRendererSource = hook('sync', function (bidResponse) { + return RENDERER; +}) + +export const getCreativeRenderer = (function() { + const renderers = {}; + return function (bidResponse) { + const src = getCreativeRendererSource(bidResponse); + if (!renderers.hasOwnProperty(src)) { + renderers[src] = new GreedyPromise((resolve) => { + const iframe = createInvisibleIframe(); + iframe.srcdoc = ``; + iframe.onload = () => resolve(iframe.contentWindow.render); + document.body.appendChild(iframe); + }) + } + return renderers[src]; + } +})(); diff --git a/src/native.js b/src/native.js index affdc855353..1b6e13c77fc 100644 --- a/src/native.js +++ b/src/native.js @@ -1,6 +1,6 @@ import { deepAccess, - deepClone, + deepClone, getDefinedParams, insertHtmlIntoIframe, isArray, isBoolean, @@ -104,6 +104,12 @@ const TRACKER_EVENTS = { 'viewable-video50': 4, } +export function isNativeResponse(bidResponse) { + // check for native data and not mediaType; it's possible + // to treat banner responses as native + return bidResponse.native && typeof bidResponse.native === 'object'; +} + /** * Recieves nativeParams from an adUnit. If the params were not of type 'type', * passes them on directly. If they were of type 'type', translate @@ -332,6 +338,23 @@ export function fireClickTrackers(nativeResponse, assetId = null, {fetchURL = tr } } +export function setNativeResponseProperties(bid, adUnit) { + const nativeOrtbRequest = adUnit?.nativeOrtbRequest; + const nativeOrtbResponse = bid.native?.ortb; + + if (nativeOrtbRequest && nativeOrtbResponse) { + const legacyResponse = toLegacyResponse(nativeOrtbResponse, nativeOrtbRequest); + Object.assign(bid.native, legacyResponse); + } + + ['rendererUrl', 'adTemplate'].forEach(prop => { + const val = adUnit?.nativeParams?.[prop]; + if (val) { + bid.native[prop] = getAssetValue(val); + } + }); +} + /** * Gets native targeting key-value pairs * @param {Object} bid @@ -340,11 +363,6 @@ export function fireClickTrackers(nativeResponse, assetId = null, {fetchURL = tr export function getNativeTargeting(bid, {index = auctionManager.index} = {}) { let keyValues = {}; const adUnit = index.getAdUnit(bid); - if (deepAccess(adUnit, 'nativeParams.rendererUrl')) { - bid['native']['rendererUrl'] = getAssetValue(adUnit.nativeParams['rendererUrl']); - } else if (deepAccess(adUnit, 'nativeParams.adTemplate')) { - bid['native']['adTemplate'] = getAssetValue(adUnit.nativeParams['adTemplate']); - } const globalSendTargetingKeys = deepAccess( adUnit, @@ -389,41 +407,40 @@ export function getNativeTargeting(bid, {index = auctionManager.index} = {}) { return keyValues; } -function assetsMessage(data, adObject, keys, {index = auctionManager.index} = {}) { - const message = { - message: 'assetResponse', - adId: data.adId, - }; - - const adUnit = index.getAdUnit(adObject); - let nativeResp = adObject.native; +function getNativeAssets(nativeProps, keys, ext = false) { + let assets = []; + Object.entries(nativeProps) + .filter(([k, v]) => v && ((ext === false && k === 'ext') || keys == null || keys.includes(k))) + .forEach(([key, value]) => { + if (ext === false && key === 'ext') { + assets.push(...getNativeAssets(value, keys, true)); + } else if (ext || NATIVE_KEYS.hasOwnProperty(key)) { + assets.push({key, value: getAssetValue(value)}); + } + }); + return assets; +} - if (adObject.native.ortb) { - message.ortb = adObject.native.ortb; +export function getNativeRenderingData(bid, adUnit, keys) { + const data = { + ...getDefinedParams(bid.native, ['rendererUrl', 'adTemplate']), + assets: getNativeAssets(bid.native, keys), + nativeKeys: CONSTANTS.NATIVE_KEYS + }; + if (bid.native.ortb) { + data.ortb = bid.native.ortb; } else if (adUnit.mediaTypes?.native?.ortb) { - message.ortb = toOrtbNativeResponse(adObject.native, adUnit.nativeOrtbRequest); + data.ortb = toOrtbNativeResponse(bid.native, adUnit.nativeOrtbRequest); } - message.assets = []; - - (keys == null ? Object.keys(nativeResp) : keys).forEach(function(key) { - if (key === 'adTemplate' && nativeResp[key]) { - message.adTemplate = getAssetValue(nativeResp[key]); - } else if (key === 'rendererUrl' && nativeResp[key]) { - message.rendererUrl = getAssetValue(nativeResp[key]); - } else if (key === 'ext') { - Object.keys(nativeResp[key]).forEach(extKey => { - if (nativeResp[key][extKey]) { - const value = getAssetValue(nativeResp[key][extKey]); - message.assets.push({ key: extKey, value }); - } - }) - } else if (nativeResp[key] && CONSTANTS.NATIVE_KEYS.hasOwnProperty(key)) { - const value = getAssetValue(nativeResp[key]); + return data; +} - message.assets.push({ key, value }); - } - }); - return message; +function assetsMessage(data, adObject, keys, {index = auctionManager.index} = {}) { + return { + message: 'assetResponse', + adId: data.adId, + ...getNativeRenderingData(adObject, index.getAdUnit(adObject), keys) + }; } const NATIVE_KEYS_INVERTED = Object.fromEntries(Object.entries(CONSTANTS.NATIVE_KEYS).map(([k, v]) => [v, k])); diff --git a/src/prebid.js b/src/prebid.js index c06fb6c1979..750a4bdee1a 100644 --- a/src/prebid.js +++ b/src/prebid.js @@ -40,7 +40,7 @@ import {newMetrics, useMetrics} from './utils/perfMetrics.js'; import {defer, GreedyPromise} from './utils/promise.js'; import {enrichFPD} from './fpd/enrichment.js'; import {allConsent} from './consentHandler.js'; -import {renderAdDirect} from '../libraries/creativeRender/direct.js'; +import {renderAdDirect} from './adRendering.js'; import {getHighestCpm} from './utils/reducers.js'; import {fillVideoDefaults} from './video.js'; diff --git a/src/secureCreatives.js b/src/secureCreatives.js index 0ea93e7e4fb..1880f56f474 100644 --- a/src/secureCreatives.js +++ b/src/secureCreatives.js @@ -4,25 +4,26 @@ */ import * as events from './events.js'; -import {fireNativeTrackers, getAllAssetsMessage, getAssetMessage} from './native.js'; -import constants from './constants.json'; +import {getAllAssetsMessage, getAssetMessage} from './native.js'; +import CONSTANTS from './constants.json'; import {isApnGetTagDefined, isGptPubadsDefined, logError, logWarn} from './utils.js'; import {auctionManager} from './auctionManager.js'; import {find, includes} from './polyfill.js'; -import {emitAdRenderFail, emitAdRenderSucceeded, handleRender} from './adRendering.js'; -import {PREBID_EVENT, PREBID_NATIVE, PREBID_REQUEST, PREBID_RESPONSE} from '../libraries/creativeRender/constants.js'; +import {handleCreativeEvent, handleNativeMessage, handleRender} from './adRendering.js'; +import {getCreativeRendererSource} from './creativeRenderers.js'; -const BID_WON = constants.EVENTS.BID_WON; -const WON_AD_IDS = new WeakSet(); +const {REQUEST, RESPONSE, NATIVE, EVENT} = CONSTANTS.MESSAGES; + +const BID_WON = CONSTANTS.EVENTS.BID_WON; const HANDLER_MAP = { - [PREBID_REQUEST]: handleRenderRequest, - [PREBID_EVENT]: handleEventRequest, + [REQUEST]: handleRenderRequest, + [EVENT]: handleEventRequest, }; if (FEATURES.NATIVE) { Object.assign(HANDLER_MAP, { - [PREBID_NATIVE]: handleNativeRequest, + [NATIVE]: handleNativeRequest, }); } @@ -67,13 +68,24 @@ export function receiveMessage(ev) { } } +function getResizer(bidResponse) { + return function (width, height) { + resizeRemoteCreative({...bidResponse, width, height}); + } +} function handleRenderRequest(reply, message, bidResponse) { - handleRender(function (adData) { - resizeRemoteCreative(bidResponse); - reply(Object.assign({ - message: PREBID_RESPONSE, - }, adData)); - }, {options: message.options, adId: message.adId, bidResponse}); + handleRender({ + renderFn(adData) { + reply(Object.assign({ + message: RESPONSE, + renderer: getCreativeRendererSource(bidResponse) + }, adData)); + }, + resizeFn: getResizer(bidResponse), + options: message.options, + adId: message.adId, + bidResponse + }); } function handleNativeRequest(reply, data, adObject) { @@ -87,8 +99,7 @@ function handleNativeRequest(reply, data, adObject) { return; } - if (!WON_AD_IDS.has(adObject)) { - WON_AD_IDS.add(adObject); + if (adObject.status !== CONSTANTS.BID_STATUS.RENDERED) { auctionManager.addWinningBid(adObject); events.emit(BID_WON, adObject); } @@ -100,13 +111,8 @@ function handleNativeRequest(reply, data, adObject) { case 'allAssetRequest': reply(getAllAssetsMessage(data, adObject)); break; - case 'resizeNativeHeight': - adObject.height = data.height; - adObject.width = data.width; - resizeRemoteCreative(adObject); - break; default: - fireNativeTrackers(data, adObject); + handleNativeMessage(data, adObject, {resizeFn: getResizer(adObject)}) } } @@ -115,40 +121,25 @@ function handleEventRequest(reply, data, adObject) { logError(`Cannot find ad '${data.adId}' for x-origin event request`); return; } - if (adObject.status !== constants.BID_STATUS.RENDERED) { - logWarn(`Received x-origin event request without corresponding render request for ad '${data.adId}'`); + if (adObject.status !== CONSTANTS.BID_STATUS.RENDERED) { + logWarn(`Received x-origin event request without corresponding render request for ad '${adObject.adId}'`); return; } - switch (data.event) { - case constants.EVENTS.AD_RENDER_FAILED: - emitAdRenderFail({ - bid: adObject, - id: data.adId, - reason: data.info.reason, - message: data.info.message - }); - break; - case constants.EVENTS.AD_RENDER_SUCCEEDED: - emitAdRenderSucceeded({ - doc: null, - bid: adObject, - id: data.adId - }); - break; - default: - logError(`Received x-origin event request for unsupported event: '${data.event}' (adId: '${data.adId}')`); - } + return handleCreativeEvent(data, adObject); } export function resizeRemoteCreative({adId, adUnitCode, width, height}) { + function getDimension(value) { + return value ? value + 'px' : '100%'; + } // resize both container div + iframe ['div', 'iframe'].forEach(elmType => { // not select element that gets removed after dfp render let element = getElementByAdUnit(elmType + ':not([style*="display: none"])'); if (element) { let elementStyle = element.style; - elementStyle.width = width ? width + 'px' : '100%'; - elementStyle.height = height + 'px'; + elementStyle.width = getDimension(width) + elementStyle.height = getDimension(height); } else { logWarn(`Unable to locate matching page element for adUnitCode ${adUnitCode}. Can't resize it to ad's dimensions. Please review setup.`); } diff --git a/src/utils.js b/src/utils.js index 7f71ff299fb..c7ce5f22f9a 100644 --- a/src/utils.js +++ b/src/utils.js @@ -252,25 +252,37 @@ export function debugTurnedOn() { return !!config.getConfig('debug'); } +export const createIframe = (() => { + const DEFAULTS = { + border: '0px', + hspace: '0', + vspace: '0', + marginWidth: '0', + marginHeight: '0', + scrolling: 'no', + frameBorder: '0', + allowtransparency: 'true' + } + return (doc, attrs, style = {}) => { + const f = doc.createElement('iframe'); + Object.assign(f, Object.assign({}, DEFAULTS, attrs)); + Object.assign(f.style, style); + return f; + } +})(); + export function createInvisibleIframe() { - var f = document.createElement('iframe'); - f.id = getUniqueIdentifierStr(); - f.height = 0; - f.width = 0; - f.border = '0px'; - f.hspace = '0'; - f.vspace = '0'; - f.marginWidth = '0'; - f.marginHeight = '0'; - f.style.border = '0'; - f.scrolling = 'no'; - f.frameBorder = '0'; - f.src = 'about:blank'; - f.style.display = 'none'; - f.style.height = '0px'; - f.style.width = '0px'; - f.allowtransparency = 'true'; - return f; + return createIframe(document, { + id: getUniqueIdentifierStr(), + width: 0, + height: 0, + src: 'about:blank' + }, { + display: 'none', + height: '0px', + width: '0px', + border: '0px' + }); } /* diff --git a/test/spec/creative/crossDomainCreative_spec.js b/test/spec/creative/crossDomainCreative_spec.js index 765d5e5311a..f4c98aa7b50 100644 --- a/test/spec/creative/crossDomainCreative_spec.js +++ b/test/spec/creative/crossDomainCreative_spec.js @@ -1,17 +1,18 @@ -import {renderer} from '../../../libraries/creativeRender/crossDomain.js'; +import {renderer} from '../../../creative/crossDomain.js'; import { - AD_RENDER_FAILED, AD_RENDER_SUCCEEDED, EXCEPTION, NO_AD, - PREBID_EVENT, - PREBID_REQUEST, - PREBID_RESPONSE -} from '../../../libraries/creativeRender/constants.js'; + ERROR_EXCEPTION, + EVENT_AD_RENDER_FAILED, EVENT_AD_RENDER_SUCCEEDED, + MESSAGE_EVENT, + MESSAGE_REQUEST, + MESSAGE_RESPONSE +} from '../../../creative/constants.js'; describe('cross-domain creative', () => { - let win, renderAd, messages, mkIframe, listeners; + const ORIGIN = 'https://example.com'; + let win, renderAd, messages, mkIframe; beforeEach(() => { messages = []; - listeners = []; mkIframe = sinon.stub(); win = { document: { @@ -28,7 +29,6 @@ describe('cross-domain creative', () => { } }) }, - addEventListener: sinon.stub().callsFake((_, listener) => listeners.push(listener)), parent: { postMessage: sinon.stub().callsFake((payload, targetOrigin, transfer) => { messages.push({payload: JSON.parse(payload), targetOrigin, transfer}); @@ -44,138 +44,150 @@ describe('cross-domain creative', () => { }); it('generates request message with adId and clickUrl', () => { - renderAd({adId: '123', clickUrl: 'https://click-url.com'}); + renderAd({adId: '123', clickUrl: 'https://click-url.com', pubUrl: ORIGIN}); expect(messages[0].payload).to.eql({ - message: PREBID_REQUEST, + message: MESSAGE_REQUEST, adId: '123', options: { clickUrl: 'https://click-url.com' } }) + }); + + it('runs scripts inserted through iframe srcdoc', (done) => { + const iframe = document.createElement('iframe'); + iframe.setAttribute('srcdoc', ''); + iframe.onload = function () { + expect(iframe.contentWindow.ran).to.be.true; + done(); + } + document.body.appendChild(iframe); }) - Object.entries({ - 'MessageChannel': (msg) => messages[0].transfer[0].postMessage(msg), - 'message listener': (msg) => listeners.forEach((fn) => fn({data: msg})) - }).forEach(([t, transport]) => { - describe(`when using ${t}`, () => { - function reply(msg) { - transport(JSON.stringify(msg)) - }; + describe('listens and', () => { + function reply(msg, index = 0) { + messages[index].transfer[0].postMessage(JSON.stringify(msg)); + } - it('ignores messages that are not a prebid response message', () => { - renderAd({adId: '123'}); - reply({adId: '123', ad: 'markup'}); - sinon.assert.notCalled(mkIframe); - }) + it('ignores messages that are not a prebid response message', () => { + renderAd({adId: '123', pubUrl: ORIGIN}); + reply({adId: '123', ad: 'markup'}); + sinon.assert.notCalled(mkIframe); + }) - describe('signals AD_RENDER_FAILED', () => { - it('on exception', (done) => { - mkIframe.callsFake(() => { throw new Error('error message') }); - renderAd({adId: '123'}); - reply({message: PREBID_RESPONSE, adId: '123', ad: 'markup'}); - setTimeout(() => { - expect(messages[1].payload).to.eql({ - message: PREBID_EVENT, - adId: '123', - event: AD_RENDER_FAILED, - info: { - reason: EXCEPTION, - message: 'error message' - } - }) - done(); - }, 100) - }); + it('signals AD_RENDER_FAILED on exceptions', (done) => { + mkIframe.callsFake(() => { throw new Error('error message') }); + renderAd({adId: '123', pubUrl: ORIGIN}); + reply({message: MESSAGE_RESPONSE, adId: '123', ad: 'markup'}); + setTimeout(() => { + expect(messages[1].payload).to.eql({ + message: MESSAGE_EVENT, + adId: '123', + event: EVENT_AD_RENDER_FAILED, + info: { + reason: ERROR_EXCEPTION, + message: 'error message' + } + }) + done(); + }, 100) + }); + + describe('renderer', () => { + beforeEach(() => { + win.document.createElement.callsFake(document.createElement.bind(document)); + win.document.body.appendChild.callsFake(document.body.appendChild.bind(document.body)); + }); - it('on missing ad', (done) => { - renderAd({adId: '123'}); - reply({message: PREBID_RESPONSE, adId: '123'}); + it('sets up and runs renderer', (done) => { + window._render = sinon.stub(); + const data = { + message: MESSAGE_RESPONSE, + adId: '123', + renderer: 'window.render = window.parent._render' + } + renderAd({adId: '123', pubUrl: ORIGIN}); + reply(data); + setTimeout(() => { + try { + sinon.assert.calledWith(window._render, data, sinon.match.any, win); + done() + } finally { + delete window._render; + } + }, 100) + }); + + Object.entries({ + 'throws (w/error)': ['window.render = function() { throw new Error("msg") }'], + 'throws (w/reason)': ['window.render = function() { throw {reason: "other", message: "msg"}}', 'other'], + 'is missing': [null, ERROR_EXCEPTION, null], + 'rejects (w/error)': ['window.render = function() { return Promise.reject(new Error("msg")) }'], + 'rejects (w/reason)': ['window.render = function() { return Promise.reject({reason: "other", message: "msg"}) }', 'other'], + }).forEach(([t, [renderer, reason = ERROR_EXCEPTION, message = 'msg']]) => { + it(`signals AD_RENDER_FAILED on renderer that ${t}`, (done) => { + renderAd({adId: '123', pubUrl: ORIGIN}); + reply({ + message: MESSAGE_RESPONSE, + adId: '123', + renderer + }); setTimeout(() => { sinon.assert.match(messages[1].payload, { - message: PREBID_EVENT, adId: '123', - event: AD_RENDER_FAILED, + message: MESSAGE_EVENT, + event: EVENT_AD_RENDER_FAILED, info: { - reason: NO_AD, + reason, + message: sinon.match(val => message == null || message === val) } - }) + }); done(); }, 100) }) }); - describe('rendering', () => { - let iframe; - - beforeEach(() => { - iframe = { - attrs: {}, - setAttribute: sinon.stub().callsFake((k, v) => iframe.attrs[k.toLowerCase()] = v), - contentDocument: { - open: sinon.stub(), - write: sinon.stub(), - close: sinon.stub(), - } - } - mkIframe.callsFake(() => iframe); + it('signals AD_RENDER_SUCCEEDED when renderer resolves', (done) => { + renderAd({adId: '123', pubUrl: ORIGIN}); + reply({ + message: MESSAGE_RESPONSE, + adId: '123', + renderer: 'window.render = function() { return new Promise((resolve) => { window.parent._resolve = resolve })}' }); - - it('renders adUrl as iframe src', (done) => { - renderAd({adId: '123'}); - reply({message: PREBID_RESPONSE, adId: '123', adUrl: 'some-url'}); + setTimeout(() => { + expect(messages[1]).to.not.exist; + window._resolve(); setTimeout(() => { - sinon.assert.calledWith(win.document.body.appendChild, iframe); - expect(iframe.attrs.src).to.eql('some-url'); + sinon.assert.match(messages[1].payload, { + adId: '123', + message: MESSAGE_EVENT, + event: EVENT_AD_RENDER_SUCCEEDED + }) + delete window._resolve; done(); }, 100) - }); + }, 100) + }) - it('renders ad through document.write', (done) => { - renderAd({adId: '123'}); - reply({message: PREBID_RESPONSE, adId: '123', ad: 'some-markup'}); + it('is provided a sendMessage that accepts replies', (done) => { + renderAd({adId: '123', pubUrl: ORIGIN}); + window._reply = sinon.stub(); + reply({ + adId: '123', + message: MESSAGE_RESPONSE, + renderer: 'window.render = function(_, {sendMessage}) { sendMessage("test", "data", function(reply) { window.parent._reply(reply) }) }' + }); + setTimeout(() => { + reply('response', 1); setTimeout(() => { - sinon.assert.calledWith(win.document.body.appendChild, iframe); - sinon.assert.called(iframe.contentDocument.open); - sinon.assert.calledWith(iframe.contentDocument.write, 'some-markup'); - sinon.assert.called(iframe.contentDocument.close); - done(); + try { + sinon.assert.calledWith(window._reply, sinon.match({data: JSON.stringify('response')})); + done(); + } finally { + delete window._reply; + } }, 100) - }); - - Object.entries({ - adUrl: 'mock-ad-url', - ad: 'mock-ad-markup' - }).forEach(([prop, propValue]) => { - describe(`when message has ${prop}`, () => { - beforeEach((done) => { - renderAd({adId: '123'}); - reply({ - message: PREBID_RESPONSE, - adId: '123', - [prop]: propValue, - width: 100, - height: 200 - }); - setTimeout(done, 100); - }); - - it('emits AD_RENDER_SUCCEEDED', () => { - expect(messages[1].payload).to.eql({ - message: PREBID_EVENT, - adId: '123', - event: AD_RENDER_SUCCEEDED - }) - }); - - it('sets iframe height / width to ad height / width', () => { - sinon.assert.match(iframe.attrs, { - width: 100, - height: 200 - }) - }) - }) - }) + }, 100) }); }); }); diff --git a/test/spec/creative/displayRenderer_spec.js b/test/spec/creative/displayRenderer_spec.js new file mode 100644 index 00000000000..6be6e90813a --- /dev/null +++ b/test/spec/creative/displayRenderer_spec.js @@ -0,0 +1,55 @@ +import {render} from 'creative/renderers/display/renderer.js'; +import {ERROR_NO_AD} from '../../../creative/renderers/display/constants.js'; + +describe('Creative renderer - display', () => { + let doc, mkFrame, sendMessage; + beforeEach(() => { + mkFrame = sinon.stub().callsFake((doc, attrs) => Object.assign({doc}, attrs)); + sendMessage = sinon.stub(); + doc = { + body: { + appendChild: sinon.stub() + } + }; + }); + + function runRenderer(data) { + return render(data, {sendMessage, mkFrame}, {document: doc}); + } + + it('throws when both ad and adUrl are missing', () => { + expect(() => { + try { + runRenderer({}) + } catch (e) { + expect(e.reason).to.eql(ERROR_NO_AD); + throw e; + } + }).to.throw(); + }) + + Object.entries({ + ad: 'srcdoc', + adUrl: 'src' + }).forEach(([adProp, frameProp]) => { + describe(`when ad has ${adProp}`, () => { + let data; + beforeEach(() => { + data = { + [adProp]: 'ad', + width: 123, + height: 321 + } + }) + it(`drops iframe with ${frameProp} = ${adProp}`, () => { + runRenderer(data); + sinon.assert.calledWith(doc.body.appendChild, { + doc, + [frameProp]: 'ad', + width: data.width, + height: data.height + }) + }) + }) + }) +}) diff --git a/test/spec/creative/nativeRenderer_spec.js b/test/spec/creative/nativeRenderer_spec.js new file mode 100644 index 00000000000..66e81a517c7 --- /dev/null +++ b/test/spec/creative/nativeRenderer_spec.js @@ -0,0 +1,298 @@ +import {getAdMarkup, getReplacements, getReplacer} from '../../../creative/renderers/native/renderer.js'; +import {ACTION_CLICK, ACTION_IMP, ACTION_RESIZE, MESSAGE_NATIVE} from '../../../creative/renderers/native/constants.js'; + +describe('Native creative renderer', () => { + let win; + beforeEach(() => { + win = {}; + }); + + describe('getAdMarkup', () => { + let loadScript; + beforeEach(() => { + loadScript = sinon.stub(); + }); + it('uses rendererUrl if present', () => { + win.document = {} + const data = { + assets: ['1', '2'], + ortb: 'ortb', + rendererUrl: 'renderer' + }; + const renderAd = sinon.stub().returns('markup'); + loadScript.returns(Promise.resolve().then(() => { + win.renderAd = renderAd; + })); + return getAdMarkup('123', data, null, win, loadScript).then((markup) => { + expect(markup).to.eql('markup'); + sinon.assert.calledWith(loadScript, data.rendererUrl, sinon.match(arg => arg === win.document)); + sinon.assert.calledWith(renderAd, sinon.match(arg => { + expect(arg).to.have.members(data.assets); + expect(arg.ortb).to.eql(data.ortb); + return true; + })); + }); + }); + describe('otherwise, calls replacer', () => { + let replacer; + beforeEach(() => { + replacer = sinon.stub().returns('markup'); + }); + it('with adTemplate, if present', () => { + return getAdMarkup('123', {adTemplate: 'tpl'}, replacer, win).then((result) => { + expect(result).to.eql('markup'); + sinon.assert.calledWith(replacer, 'tpl'); + }); + }); + it('with document body otherwise', () => { + win.document = {body: {innerHTML: 'body'}}; + return getAdMarkup('123', {}, replacer, win).then((result) => { + expect(result).to.eql('markup'); + sinon.assert.calledWith(replacer, 'body'); + }) + }) + }) + }); + + describe('getReplacer', () => { + function expectReplacements(replacer, replacements) { + Object.entries(replacements).forEach(([placeholder, repl]) => { + expect(replacer(`.${placeholder}.${placeholder}.`)).to.eql(`.${repl}.${repl}.`); + }) + } + it('uses empty strings for missing legacy assets', () => { + const repl = getReplacer('123', { + nativeKeys: { + 'k': 'hb_native_k' + } + }); + expectReplacements(repl, { + '##hb_native_k##': '', + 'hb_native_k:123': '' + }) + }); + + it('uses empty string for missing ORTB assets', () => { + const repl = getReplacer('', { + ortb: { + assets: [{ + id: 1, + link: {url: 'l1'}, + data: {value: 'v1'} + }] + } + }); + expectReplacements(repl, { + '##hb_native_asset_id_1##': 'v1', + '##hb_native_asset_id_2##': '', + '##hb_native_asset_link_id_1##': 'l1', + '##hb_native_asset_link_id_2##': '' + }); + }); + + it('replaces placeholders for for legacy assets', () => { + const repl = getReplacer('123', { + assets: [ + {key: 'k1', value: 'v1'}, {key: 'k2', value: 'v2'} + ], + nativeKeys: { + k1: 'hb_native_k1', + k2: 'hb_native_k2' + } + }); + expectReplacements(repl, { + '##hb_native_k1##': 'v1', + 'hb_native_k1:123': 'v1', + '##hb_native_k2##': 'v2', + 'hb_native_k2:123': 'v2' + }) + }); + + describe('ORTB response top-level (non-asset) fields', () => { + const ortb = { + link: { + url: 'link.url' + }, + privacy: 'privacy.url' + }; + const expected = { + '##hb_native_linkurl##': 'link.url', + '##hb_native_privacy##': 'privacy.url' + }; + it('replaces placeholders', () => { + const repl = getReplacer('123', { + ortb + }); + expectReplacements(repl, expected); + }); + it('gives them precedence over legacy counterparts', () => { + const repl = getReplacer('123', { + ortb, + assets: [ + {key: 'clickUrl', value: 'overridden'}, + {key: 'privacyLink', value: 'overridden'} + ], + nativeKeys: { + clickUrl: 'hb_native_linkurl', + privacyLink: 'hb_native_privacy' + } + }); + expectReplacements(repl, expected); + }); + it('uses empty string for missing assets', () => { + const repl = getReplacer('123', { + ortb: {} + }); + expectReplacements(repl, { + '##hb_native_linkurl##': '', + '##hb_native_privacy##': '', + }) + }); + }); + + Object.entries({ + title: {text: 'val'}, + data: {value: 'val'}, + img: {url: 'val'}, + video: {vasttag: 'val'} + }).forEach(([type, contents]) => { + describe(`for ortb ${type} asset`, () => { + let ortb; + beforeEach(() => { + ortb = { + assets: [ + { + id: 123, + [type]: contents + } + ] + }; + }); + it('replaces placeholder', () => { + const repl = getReplacer('', {ortb}); + expectReplacements(repl, { + '##hb_native_asset_id_123##': 'val' + }) + }); + it('replaces link placeholders', () => { + ortb.assets[0].link = {url: 'link'}; + const repl = getReplacer('', {ortb}); + expectReplacements(repl, { + '##hb_native_asset_link_id_123##': 'link' + }) + }); + }); + }); + }); + + describe('render', () => { + let getMarkup, sendMessage, adId, nativeData, exc; + beforeEach(() => { + adId = '123'; + nativeData = {} + getMarkup = sinon.stub(); + sendMessage = sinon.stub() + exc = sinon.stub(); + win.document = { + querySelectorAll() { return [] }, + body: {} + } + }); + + function runRender() { + return render({adId, native: nativeData}, {sendMessage, exc}, win, getMarkup) + } + + it('replaces placeholders in head, if present', () => { + getMarkup.returns(Promise.resolve('')) + win.document.head = {innerHTML: '##hb_native_asset_id_1##'}; + nativeData.ortb = { + assets: [ + {id: 1, data: {value: 'repl'}} + ] + }; + return runRender().then(() => { + expect(win.document.head.innerHTML).to.eql('repl'); + }) + }); + + it('drops markup on body, and fires imp trackers', () => { + getMarkup.returns(Promise.resolve('markup')); + return runRender().then(() => { + expect(win.document.body.innerHTML).to.eql('markup'); + sinon.assert.calledWith(sendMessage, MESSAGE_NATIVE, {action: ACTION_IMP}); + }) + }); + + it('runs postRenderAd if defined', () => { + win.postRenderAd = sinon.stub(); + getMarkup.returns(Promise.resolve('markup')); + return runRender().then(() => { + sinon.assert.calledWith(win.postRenderAd, sinon.match({ + adId, + ...nativeData + })) + }) + }) + + it('rejects on error', (done) => { + const err = new Error('failure'); + getMarkup.returns(Promise.reject(err)); + runRender().catch((e) => { + expect(e).to.eql(err); + done(); + }) + }); + + describe('requests resize', () => { + beforeEach(() => { + getMarkup.returns(Promise.resolve('markup')); + win.document.body.offsetHeight = 123; + win.document.body.offsetWidth = 321; + }); + + it('immediately, if document is loaded', () => { + win.document.readyState = 'complete'; + return runRender().then(() => { + sinon.assert.calledWith(sendMessage, MESSAGE_NATIVE, {action: ACTION_RESIZE, height: 123, width: 321}) + }) + }); + + it('on document load otherwise', () => { + return runRender().then(() => { + sinon.assert.neverCalledWith(sendMessage, MESSAGE_NATIVE, sinon.match({action: ACTION_RESIZE})); + win.onload(); + sinon.assert.calledWith(sendMessage, MESSAGE_NATIVE, {action: ACTION_RESIZE, height: 123, width: 321}); + }) + }) + }) + + describe('click trackers', () => { + let iframe; + beforeEach(() => { + iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + win.document = iframe.contentDocument; + }) + afterEach(() => { + document.body.removeChild(iframe); + }) + + it('are fired on click', () => { + getMarkup.returns(Promise.resolve('
')); + return runRender().then(() => { + win.document.querySelector('#target').click(); + sinon.assert.calledWith(sendMessage, MESSAGE_NATIVE, sinon.match({action: ACTION_CLICK})); + }) + }); + + it('pass assetId if provided', () => { + getMarkup.returns(Promise.resolve('
')); + return runRender().then(() => { + win.document.querySelector('#target').click(); + sinon.assert.calledWith(sendMessage, MESSAGE_NATIVE, {action: ACTION_CLICK, assetId: '123'}) + }); + }); + }); + }); +}); diff --git a/test/spec/native_spec.js b/test/spec/native_spec.js index bf1c745d8cc..9184601a76d 100644 --- a/test/spec/native_spec.js +++ b/test/spec/native_spec.js @@ -9,7 +9,12 @@ import { decorateAdUnitsWithNativeParams, isOpenRTBBidRequestValid, isNativeOpenRTBBidValid, - toOrtbNativeRequest, toOrtbNativeResponse, legacyPropertiesToOrtbNative, fireImpressionTrackers, fireClickTrackers, + toOrtbNativeRequest, + toOrtbNativeResponse, + legacyPropertiesToOrtbNative, + fireImpressionTrackers, + fireClickTrackers, + setNativeResponseProperties, } from 'src/native.js'; import CONSTANTS from 'src/constants.json'; import { stubAuctionIndex } from '../helpers/indexStub.js'; @@ -345,73 +350,10 @@ describe('native.js', function () { ]); }); - it('should copy over rendererUrl to bid object and include it in targeting', function () { - const adUnit = { - adUnitId: 'au', - nativeParams: { - image: { - required: true, - sizes: [150, 50], - }, - title: { - required: true, - len: 80, - }, - rendererUrl: { - url: 'https://www.renderer.com/', - }, - }, - }; - const targeting = getNativeTargeting(bid, deps(adUnit)); - - expect(Object.keys(targeting)).to.deep.equal([ - CONSTANTS.NATIVE_KEYS.title, - CONSTANTS.NATIVE_KEYS.body, - CONSTANTS.NATIVE_KEYS.cta, - CONSTANTS.NATIVE_KEYS.image, - CONSTANTS.NATIVE_KEYS.icon, - CONSTANTS.NATIVE_KEYS.sponsoredBy, - CONSTANTS.NATIVE_KEYS.clickUrl, - CONSTANTS.NATIVE_KEYS.privacyLink, - CONSTANTS.NATIVE_KEYS.rendererUrl, - ]); - - expect(bid.native.rendererUrl).to.deep.equal('https://www.renderer.com/'); - delete bid.native.rendererUrl; - }); - - it('should copy over adTemplate to bid object and include it in targeting', function () { - const adUnit = { - adUnitId: 'au', - nativeParams: { - image: { - required: true, - sizes: [150, 50], - }, - title: { - required: true, - len: 80, - }, - adTemplate: '

##hb_native_body##

', - }, - }; - const targeting = getNativeTargeting(bid, deps(adUnit)); - - expect(Object.keys(targeting)).to.deep.equal([ - CONSTANTS.NATIVE_KEYS.title, - CONSTANTS.NATIVE_KEYS.body, - CONSTANTS.NATIVE_KEYS.cta, - CONSTANTS.NATIVE_KEYS.image, - CONSTANTS.NATIVE_KEYS.icon, - CONSTANTS.NATIVE_KEYS.sponsoredBy, - CONSTANTS.NATIVE_KEYS.clickUrl, - CONSTANTS.NATIVE_KEYS.privacyLink, - ]); - - expect(bid.native.adTemplate).to.deep.equal( - '

##hb_native_body##

' - ); - delete bid.native.adTemplate; + it('should include rendererUrl in targeting', function () { + const rendererUrl = 'https://www.renderer.com/'; + const targeting = getNativeTargeting({...bid, native: {...bid.native, rendererUrl: {url: rendererUrl}}}, deps({})); + expect(targeting[CONSTANTS.NATIVE_KEYS.rendererUrl]).to.eql(rendererUrl); }); it('fires impression trackers', function () { @@ -646,6 +588,58 @@ describe('native.js', function () { expect(actual.impressionTrackers).to.contain('https://sample-imp.com'); }); }); + + describe('setNativeResponseProperties', () => { + let adUnit; + beforeEach(() => { + adUnit = { + mediaTypes: { + native: {}, + }, + nativeParams: {} + }; + }); + it('sets legacy response', () => { + adUnit.nativeOrtbRequest = { + assets: [{ + id: 1, + data: { + type: 2 + } + }] + }; + const ortbBid = { + ...bid, + native: { + ortb: { + link: { + url: 'clickurl' + }, + assets: [{ + id: 1, + data: { + value: 'body' + } + }] + } + } + }; + setNativeResponseProperties(ortbBid, adUnit); + expect(ortbBid.native.clickUrl).to.eql('clickurl'); + expect(ortbBid.native.body).to.eql('body'); + }); + + it('sets rendererUrl', () => { + adUnit.nativeParams.rendererUrl = {url: 'renderer'}; + setNativeResponseProperties(bid, adUnit); + expect(bid.native.rendererUrl).to.eql('renderer'); + }); + it('sets adTemplate', () => { + adUnit.nativeParams.adTemplate = 'template'; + setNativeResponseProperties(bid, adUnit); + expect(bid.native.adTemplate).to.eql('template'); + }); + }); }); describe('validate native openRTB', function () { diff --git a/test/spec/unit/adRendering_spec.js b/test/spec/unit/adRendering_spec.js new file mode 100644 index 00000000000..c2f62842c7e --- /dev/null +++ b/test/spec/unit/adRendering_spec.js @@ -0,0 +1,248 @@ +import * as events from 'src/events.js'; +import * as utils from 'src/utils.js'; +import { + doRender, + getRenderingData, + handleCreativeEvent, + handleNativeMessage, + handleRender +} from '../../../src/adRendering.js'; +import CONSTANTS from 'src/constants.json'; +import {expect} from 'chai/index.mjs'; +import {config} from 'src/config.js'; +import {VIDEO} from '../../../src/mediaTypes.js'; +import {auctionManager} from '../../../src/auctionManager.js'; + +describe('adRendering', () => { + let sandbox; + beforeEach(() => { + sandbox = sinon.sandbox.create(); + sandbox.stub(utils, 'logWarn'); + sandbox.stub(utils, 'logError'); + }) + afterEach(() => { + sandbox.restore(); + }) + + describe('getRenderingData', () => { + let bidResponse; + beforeEach(() => { + bidResponse = {}; + }); + + ['ad', 'adUrl'].forEach((prop) => { + describe(`on ${prop}`, () => { + it('replaces AUCTION_PRICE macro', () => { + bidResponse[prop] = 'pre${AUCTION_PRICE}post'; + bidResponse.cpm = 123; + const result = getRenderingData(bidResponse); + expect(result[prop]).to.eql('pre123post'); + }); + it('replaces CLICKTHROUGH macro', () => { + bidResponse[prop] = 'pre${CLICKTHROUGH}post'; + const result = getRenderingData(bidResponse, {clickUrl: 'clk'}); + expect(result[prop]).to.eql('preclkpost'); + }); + it('defaults CLICKTHROUGH to empty string', () => { + bidResponse[prop] = 'pre${CLICKTHROUGH}post'; + const result = getRenderingData(bidResponse); + expect(result[prop]).to.eql('prepost'); + }); + }); + }); + }) + + describe('rendering logic', () => { + let bidResponse, renderFn, resizeFn, adId; + beforeEach(() => { + sandbox.stub(events, 'emit'); + renderFn = sinon.stub(); + resizeFn = sinon.stub(); + adId = 123; + bidResponse = { + adId + } + }); + + function expectAdRenderFailedEvent(reason) { + sinon.assert.calledWith(events.emit, CONSTANTS.EVENTS.AD_RENDER_FAILED, sinon.match({adId, reason})); + } + + describe('doRender', () => { + let getRenderingDataStub; + function getRenderingDataHook(next, ...args) { + next.bail(getRenderingDataStub(...args)); + } + before(() => { + getRenderingData.before(getRenderingDataHook, 999); + }) + after(() => { + getRenderingData.getHooks({hook: getRenderingDataHook}).remove(); + }); + beforeEach(() => { + getRenderingDataStub = sinon.stub(); + }) + + describe('when the ad has a renderer', () => { + let bidResponse; + beforeEach(() => { + bidResponse = { + adId: 'mock-ad-id', + renderer: { + url: 'some-custom-renderer', + render: sinon.stub() + } + } + }); + + it('does not invoke renderFn, but the renderer instead', () => { + doRender({renderFn, bidResponse}); + sinon.assert.notCalled(renderFn); + sinon.assert.called(bidResponse.renderer.render); + }); + + it('emits AD_RENDER_SUCCEDED', () => { + doRender({renderFn, bidResponse}); + sinon.assert.calledWith(events.emit, CONSTANTS.EVENTS.AD_RENDER_SUCCEEDED, sinon.match({ + bid: bidResponse, + adId: bidResponse.adId + })); + }); + }); + + if (FEATURES.VIDEO) { + it('should emit AD_RENDER_FAILED on video bids', () => { + bidResponse.mediaType = VIDEO; + doRender({renderFn, bidResponse}); + expectAdRenderFailedEvent(CONSTANTS.AD_RENDER_FAILED_REASON.PREVENT_WRITING_ON_MAIN_DOCUMENT) + }); + } + + it('invokes renderFn with rendering data', () => { + const data = {ad: 'creative'}; + getRenderingDataStub.returns(data); + doRender({renderFn, resizeFn, bidResponse}); + sinon.assert.calledWith(renderFn, sinon.match({ + adId: bidResponse.adId, + ...data + })) + }); + + it('invokes resizeFn with w/h from rendering data', () => { + getRenderingDataStub.returns({width: 123, height: 321}); + doRender({renderFn, resizeFn, bidResponse}); + sinon.assert.calledWith(resizeFn, 123, 321); + }); + + it('does not invoke resizeFn if rendering data has no w/h', () => { + getRenderingDataStub.returns({}); + doRender({renderFn, resizeFn, bidResponse}); + sinon.assert.notCalled(resizeFn); + }) + }); + + describe('handleRender', () => { + let doRenderStub + function doRenderHook(next, ...args) { + next.bail(doRenderStub(...args)); + } + before(() => { + doRender.before(doRenderHook, 999); + }) + after(() => { + doRender.getHooks({hook: doRenderHook}).remove(); + }) + beforeEach(() => { + sandbox.stub(auctionManager, 'addWinningBid'); + doRenderStub = sinon.stub(); + }) + describe('should emit AD_RENDER_FAILED', () => { + it('when bidResponse is missing', () => { + handleRender({adId}); + expectAdRenderFailedEvent(CONSTANTS.AD_RENDER_FAILED_REASON.CANNOT_FIND_AD); + sinon.assert.notCalled(doRenderStub); + }); + it('on exceptions', () => { + doRenderStub.throws(new Error()); + handleRender({adId, bidResponse}); + expectAdRenderFailedEvent(CONSTANTS.AD_RENDER_FAILED_REASON.EXCEPTION); + }); + }) + + describe('when bid was already rendered', () => { + beforeEach(() => { + bidResponse.status = CONSTANTS.BID_STATUS.RENDERED; + }); + afterEach(() => { + config.resetConfig(); + }) + it('should emit STALE_RENDER', () => { + handleRender({adId, bidResponse}); + sinon.assert.calledWith(events.emit, CONSTANTS.EVENTS.STALE_RENDER, bidResponse); + sinon.assert.called(doRenderStub); + }); + it('should skip rendering if suppressStaleRender', () => { + config.setConfig({auctionOptions: {suppressStaleRender: true}}); + handleRender({adId, bidResponse}); + sinon.assert.notCalled(doRenderStub); + }) + }); + + it('should mark bid as won and emit BID_WON', () => { + handleRender({renderFn, bidResponse}); + sinon.assert.calledWith(events.emit, CONSTANTS.EVENTS.BID_WON, bidResponse); + sinon.assert.calledWith(auctionManager.addWinningBid, bidResponse); + }) + }) + }) + + describe('handleCreativeEvent', () => { + let bid; + beforeEach(() => { + sandbox.stub(events, 'emit'); + bid = { + status: CONSTANTS.BID_STATUS.RENDERED + } + }); + it('emits AD_RENDER_FAILED with given reason', () => { + handleCreativeEvent({event: CONSTANTS.EVENTS.AD_RENDER_FAILED, info: {reason: 'reason', message: 'message'}}, bid); + sinon.assert.calledWith(events.emit, CONSTANTS.EVENTS.AD_RENDER_FAILED, sinon.match({bid, reason: 'reason', message: 'message'})); + }); + + it('emits AD_RENDER_SUCCEEDED', () => { + handleCreativeEvent({event: CONSTANTS.EVENTS.AD_RENDER_SUCCEEDED}, bid); + sinon.assert.calledWith(events.emit, CONSTANTS.EVENTS.AD_RENDER_SUCCEEDED, sinon.match({bid})); + }); + + it('logs an error on other events', () => { + handleCreativeEvent({event: 'unsupported'}, bid); + sinon.assert.called(utils.logError); + sinon.assert.notCalled(events.emit); + }); + }); + + describe('handleNativeMessage', () => { + if (!FEATURES.NATIVE) return; + let bid; + beforeEach(() => { + bid = { + adId: '123' + }; + }) + + it('should resize', () => { + const resizeFn = sinon.stub(); + handleNativeMessage({action: 'resizeNativeHeight', height: 100}, bid, {resizeFn}); + sinon.assert.calledWith(resizeFn, undefined, 100); + }); + + it('should fire trackers', () => { + const data = { + action: 'click' + }; + const fireTrackers = sinon.stub(); + handleNativeMessage(data, bid, {fireTrackers}); + sinon.assert.calledWith(fireTrackers, data, bid); + }) + }) +}); diff --git a/test/spec/unit/pbjs_api_spec.js b/test/spec/unit/pbjs_api_spec.js index e61171c3a22..7f55a2cddf0 100644 --- a/test/spec/unit/pbjs_api_spec.js +++ b/test/spec/unit/pbjs_api_spec.js @@ -26,6 +26,8 @@ import {createBid} from '../../../src/bidfactory.js'; import {enrichFPD} from '../../../src/fpd/enrichment.js'; import {mockFpdEnrichments} from '../../helpers/fpd.js'; import {generateUUID} from '../../../src/utils.js'; +import {getCreativeRenderer} from '../../../src/creativeRenderers.js'; + var assert = require('chai').assert; var expect = require('chai').expect; @@ -200,11 +202,13 @@ window.apntag = { describe('Unit: Prebid Module', function () { let bidExpiryStub, sandbox; - before(() => { + before((done) => { hook.ready(); $$PREBID_GLOBAL$$.requestBids.getHooks().remove(); resetDebugging(); sinon.stub(filters, 'isActualBid').returns(true); // stub this out so that we can use vanilla objects as bids + // preload creative renderer + getCreativeRenderer({}).then(() => done()); }); beforeEach(function () { @@ -1086,35 +1090,6 @@ describe('Unit: Prebid Module', function () { expect(slots[0].spySetTargeting.args).to.deep.contain.members(expected); }); - it('should find correct gpt slot based on ad id rather than ad unit code when resizing secure creative', function () { - var slots = [ - new Slot('div-not-matching-adunit-code-1', config.adUnitCodes[0]), - new Slot('div-not-matching-adunit-code-2', config.adUnitCodes[0]), - new Slot('div-not-matching-adunit-code-3', config.adUnitCodes[0]) - ]; - - slots[1].setTargeting('hb_adid', ['someAdId']); - slots[1].spyGetSlotElementId.resetHistory(); - window.googletag.pubads().setSlots(slots); - - const mockAdObject = { - adId: 'someAdId', - ad: '', - adUrl: 'http://creative.prebid.org/${AUCTION_PRICE}', - width: 300, - height: 250, - renderer: null, - cpm: '1.00', - adUnitCode: config.adUnitCodes[0], - }; - - resizeRemoteCreative(mockAdObject); - - expect(slots[0].spyGetSlotElementId.called).to.equal(false); - expect(slots[1].spyGetSlotElementId.called).to.equal(true); - expect(slots[2].spyGetSlotElementId.called).to.equal(false); - }); - it('Calling enableSendAllBids should set targeting to include standard keys with bidder' + ' append to key name', function () { var slots = createSlotArray(); @@ -1236,10 +1211,14 @@ describe('Unit: Prebid Module', function () { height: 0 } }, + body: { + appendChild: sinon.stub() + }, getElementsByTagName: sinon.stub(), querySelector: sinon.stub(), createElement: sinon.stub(), }; + doc.defaultView.document = doc; elStub = { insertBefore: sinon.stub() diff --git a/test/spec/unit/secureCreatives_spec.js b/test/spec/unit/secureCreatives_spec.js index 75813245298..a7be4e327f0 100644 --- a/test/spec/unit/secureCreatives_spec.js +++ b/test/spec/unit/secureCreatives_spec.js @@ -1,4 +1,4 @@ -import {getReplier, receiveMessage} from 'src/secureCreatives.js'; +import {getReplier, receiveMessage, resizeRemoteCreative} from 'src/secureCreatives.js'; import * as utils from 'src/utils.js'; import {getAdUnits, getBidRequests, getBidResponses} from 'test/fixtures/fixtures.js'; import {auctionManager} from 'src/auctionManager.js'; @@ -7,10 +7,11 @@ import * as native from 'src/native.js'; import {fireNativeTrackers, getAllAssetsMessage} from 'src/native.js'; import * as events from 'src/events.js'; import {config as configObj} from 'src/config.js'; +import * as creativeRenderers from 'src/creativeRenderers.js'; import 'src/prebid.js'; +import 'modules/nativeRendering.js'; import {expect} from 'chai'; -import {handleRender} from '../../../src/adRendering.js'; var CONSTANTS = require('src/constants.json'); @@ -63,78 +64,6 @@ describe('secureCreatives', () => { }); }); - describe('handleRender', () => { - let bidResponse, renderFn, result; - beforeEach(() => { - result = null; - renderFn = sinon.stub().callsFake((r) => { result = r; }); - bidResponse = { - adId: 123 - } - }); - - describe('when the ad has a renderer', () => { - let bidResponse; - beforeEach(() => { - sandbox.stub(events, 'emit'); - bidResponse = { - adId: 'mock-ad-id', - renderer: { - url: 'some-custom-renderer', - render: sinon.stub() - } - } - }); - - it('does not invoke renderFn, but the renderer instead', () => { - handleRender(renderFn, {bidResponse}); - sinon.assert.notCalled(renderFn); - sinon.assert.called(bidResponse.renderer.render); - }); - - it('emits AD_RENDER_SUCCEDED', () => { - handleRender(renderFn, {bidResponse}); - sinon.assert.calledWith(events.emit, CONSTANTS.EVENTS.AD_RENDER_SUCCEEDED, sinon.match({ - bid: bidResponse, - adId: bidResponse.adId - })); - }); - - it('emits AD_RENDER_FAILED', () => { - const err = new Error('error message'); - bidResponse.renderer.render.throws(err); - handleRender(renderFn, {bidResponse}); - sinon.assert.calledWith(events.emit, CONSTANTS.EVENTS.AD_RENDER_FAILED, sinon.match({ - bid: bidResponse, - adId: bidResponse.adId, - reason: CONSTANTS.AD_RENDER_FAILED_REASON.EXCEPTION, - message: err.message - })); - }) - }); - - ['ad', 'adUrl'].forEach((prop) => { - describe(`on ${prop}`, () => { - it('replaces AUCTION_PRICE macro', () => { - bidResponse[prop] = 'pre${AUCTION_PRICE}post'; - bidResponse.cpm = 123; - handleRender(renderFn, {adId: 123, bidResponse}); - expect(result[prop]).to.eql('pre123post'); - }); - it('replaces CLICKTHROUGH macro', () => { - bidResponse[prop] = 'pre${CLICKTHROUGH}post'; - handleRender(renderFn, {adId: 123, bidResponse, options: {clickUrl: 'clk'}}); - expect(result[prop]).to.eql('preclkpost'); - }); - it('defaults CLICKTHROUGH to empty string', () => { - bidResponse[prop] = 'pre${CLICKTHROUGH}post'; - handleRender(renderFn, {adId: 123, bidResponse}); - expect(result[prop]).to.eql('prepost'); - }); - }); - }); - }); - describe('receiveMessage', function() { const bidId = 1; const warning = `Ad id ${bidId} has been rendered before`; @@ -196,19 +125,15 @@ describe('secureCreatives', () => { }); beforeEach(function() { - spyAddWinningBid = sinon.spy(auctionManager, 'addWinningBid'); - spyLogWarn = sinon.spy(utils, 'logWarn'); - stubFireNativeTrackers = sinon.stub(native, 'fireNativeTrackers').callsFake(message => { return message.action; }); - stubGetAllAssetsMessage = sinon.stub(native, 'getAllAssetsMessage'); - stubEmit = sinon.stub(events, 'emit'); + spyAddWinningBid = sandbox.spy(auctionManager, 'addWinningBid'); + spyLogWarn = sandbox.spy(utils, 'logWarn'); + stubFireNativeTrackers = sandbox.stub(native, 'fireNativeTrackers').callsFake(message => { return message.action; }); + stubGetAllAssetsMessage = sandbox.stub(native, 'getAllAssetsMessage'); + stubEmit = sandbox.stub(events, 'emit'); }); afterEach(function() { - spyAddWinningBid.restore(); - spyLogWarn.restore(); - stubFireNativeTrackers.restore(); - stubGetAllAssetsMessage.restore(); - stubEmit.restore(); + sandbox.restore(); resetAuction(); adResponse.adId = bidId; }); @@ -352,6 +277,66 @@ describe('secureCreatives', () => { adId: bidId })); }); + + it('should include renderers in responses', () => { + sandbox.stub(creativeRenderers, 'getCreativeRendererSource').returns('mock-renderer'); + pushBidResponseToAuction({}); + const ev = makeEvent({ + source: { + postMessage: sinon.stub() + }, + data: JSON.stringify({adId: bidId, message: 'Prebid Request'}) + }); + receiveMessage(ev); + sinon.assert.calledWith(ev.source.postMessage, sinon.match(ob => JSON.parse(ob).renderer === 'mock-renderer')); + }); + + if (FEATURES.NATIVE) { + it('should include native rendering data in responses', () => { + const bid = { + native: { + ortb: { + assets: [ + { + id: 1, + data: { + type: 2, + value: 'vbody' + } + } + ] + }, + body: 'vbody', + adTemplate: 'tpl', + rendererUrl: 'rurl' + } + } + pushBidResponseToAuction(bid); + const ev = makeEvent({ + source: { + postMessage: sinon.stub() + }, + data: JSON.stringify({adId: bidId, message: 'Prebid Request'}) + }) + receiveMessage(ev); + sinon.assert.calledWith(ev.source.postMessage, sinon.match(ob => { + const data = JSON.parse(ob); + ['width', 'height'].forEach(prop => expect(data[prop]).to.not.exist); + const native = data.native; + sinon.assert.match(native, { + ortb: bid.native.ortb, + adTemplate: bid.native.adTemplate, + rendererUrl: bid.native.rendererUrl, + }) + expect(Object.fromEntries(native.assets.map(({key, value}) => [key, value]))).to.eql({ + adTemplate: bid.native.adTemplate, + rendererUrl: bid.native.rendererUrl, + body: 'vbody' + }); + return true; + })) + }) + } }); describe('Prebid Native', function() { @@ -412,45 +397,6 @@ describe('secureCreatives', () => { receiveMessage(ev); stubEmit.withArgs(CONSTANTS.EVENTS.BID_WON, adResponse).calledOnce; }); - - it('Prebid native should fire trackers', function () { - let adId = 2; - pushBidResponseToAuction({adId}); - - const data = { - adId: adId, - message: 'Prebid Native', - action: 'click', - }; - - const ev = makeEvent({ - data: JSON.stringify(data), - source: { - postMessage: sinon.stub() - }, - origin: 'any origin' - }); - - receiveMessage(ev); - - sinon.assert.neverCalledWith(spyLogWarn, warning); - sinon.assert.calledOnce(stubFireNativeTrackers); - sinon.assert.calledWith(stubEmit, CONSTANTS.EVENTS.BID_WON, adResponse); - sinon.assert.calledOnce(spyAddWinningBid); - - resetHistories(ev.source.postMessage); - - delete data.action; - ev.data = JSON.stringify(data); - receiveMessage(ev); - - sinon.assert.neverCalledWith(spyLogWarn, warning); - sinon.assert.calledOnce(stubFireNativeTrackers); - sinon.assert.neverCalledWith(stubEmit, CONSTANTS.EVENTS.BID_WON); - sinon.assert.notCalled(spyAddWinningBid); - - expect(adResponse).to.have.property('status', CONSTANTS.BID_STATUS.RENDERED); - }); }); describe('Prebid Event', () => { @@ -504,4 +450,53 @@ describe('secureCreatives', () => { }); }); }); + + describe('resizeRemoteCreative', () => { + let origGpt; + before(() => { + origGpt = window.googletag; + }); + after(() => { + window.googletag = origGpt; + }); + function mockSlot(elementId, pathId) { + let targeting = {}; + return { + getSlotElementId: sinon.stub().callsFake(() => elementId), + getAdUnitPath: sinon.stub().callsFake(() => pathId), + setTargeting: sinon.stub().callsFake((key, value) => { + value = Array.isArray(value) ? value : [value]; + targeting[key] = value; + }), + getTargetingKeys: sinon.stub().callsFake(() => Object.keys(targeting)), + getTargeting: sinon.stub().callsFake((key) => targeting[key] || []) + } + } + let slots; + beforeEach(() => { + slots = [ + mockSlot('div1', 'au1'), + mockSlot('div2', 'au2'), + mockSlot('div3', 'au3') + ] + window.googletag = { + pubads: sinon.stub().returns({ + getSlots: sinon.stub().returns(slots) + }) + }; + sandbox.stub(document, 'getElementById'); + }) + + it('should find correct gpt slot based on ad id rather than ad unit code when resizing secure creative', function () { + slots[1].setTargeting('hb_adid', ['adId']); + resizeRemoteCreative({ + adId: 'adId', + width: 300, + height: 250, + }); + [0, 2].forEach((i) => sinon.assert.notCalled(slots[i].getSlotElementId)) + sinon.assert.called(slots[1].getSlotElementId); + sinon.assert.calledWith(document.getElementById, 'div2'); + }); + }) }); diff --git a/webpack.creative.js b/webpack.creative.js index 7279455e155..86f5f24d580 100644 --- a/webpack.creative.js +++ b/webpack.creative.js @@ -10,8 +10,14 @@ module.exports = { }, entry: { 'creative': { - import: './libraries/creativeRender/crossDomain.js', + import: './creative/crossDomain.js', }, + 'renderers/display': { + import: './creative/renderers/display/renderer.js' + }, + 'renderers/native': { + import: './creative/renderers/native/renderer.js' + } }, output: { path: path.resolve('./build/creative'),