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' - + + + + + + +
+ + +