diff --git a/integrationExamples/gpt/wurflRtdProvider_example.html b/integrationExamples/gpt/wurflRtdProvider_example.html
new file mode 100644
index 00000000000..f2bfe7f76b7
--- /dev/null
+++ b/integrationExamples/gpt/wurflRtdProvider_example.html
@@ -0,0 +1,106 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Prebid.js Test
+ Div-1
+
+
+
+
+
+
\ No newline at end of file
diff --git a/modules/.submodules.json b/modules/.submodules.json
index ea128e91579..6b4b0d21d54 100644
--- a/modules/.submodules.json
+++ b/modules/.submodules.json
@@ -100,7 +100,8 @@
"sirdataRtdProvider",
"symitriDapRtdProvider",
"timeoutRtdProvider",
- "weboramaRtdProvider"
+ "weboramaRtdProvider",
+ "wurflRtdProvider"
],
"fpdModule": [
"validationFpdModule",
diff --git a/modules/wurflRtdProvider.js b/modules/wurflRtdProvider.js
new file mode 100644
index 00000000000..94bb8c6440a
--- /dev/null
+++ b/modules/wurflRtdProvider.js
@@ -0,0 +1,213 @@
+import { submodule } from '../src/hook.js';
+import { fetch, sendBeacon } from '../src/ajax.js';
+import { loadExternalScript } from '../src/adloader.js';
+import {
+ mergeDeep,
+ prefixLog,
+} from '../src/utils.js';
+
+// Constants
+const REAL_TIME_MODULE = 'realTimeData';
+const MODULE_NAME = 'wurfl';
+
+// WURFL_JS_HOST is the host for the WURFL service endpoints
+const WURFL_JS_HOST = 'https://prebid.wurflcloud.com';
+// WURFL_JS_ENDPOINT_PATH is the path for the WURFL.js endpoint used to load WURFL data
+const WURFL_JS_ENDPOINT_PATH = '/wurfl.js';
+// STATS_ENDPOINT_PATH is the path for the stats endpoint used to send analytics data
+const STATS_ENDPOINT_PATH = '/v1/prebid/stats';
+
+const logger = prefixLog('[WURFL RTD Submodule]');
+
+// enrichedBidders holds a list of prebid bidder names, of bidders which have been
+// injected with WURFL data
+const enrichedBidders = new Set();
+
+/**
+ * init initializes the WURFL RTD submodule
+ * @param {Object} config Configuration for WURFL RTD submodule
+ * @param {Object} userConsent User consent data
+ */
+const init = (config, userConsent) => {
+ logger.logMessage('initialized');
+ return true;
+}
+
+/**
+ * getBidRequestData enriches the OpenRTB 2.0 device data with WURFL data
+ * @param {Object} reqBidsConfigObj Bid request configuration object
+ * @param {Function} callback Called on completion
+ * @param {Object} config Configuration for WURFL RTD submodule
+ * @param {Object} userConsent User consent data
+ */
+const getBidRequestData = (reqBidsConfigObj, callback, config, userConsent) => {
+ const altHost = config.params?.altHost ?? null;
+ const isDebug = config.params?.debug ?? false;
+
+ const bidders = new Set();
+ reqBidsConfigObj.adUnits.forEach(adUnit => {
+ adUnit.bids.forEach(bid => {
+ bidders.add(bid.bidder);
+ });
+ });
+
+ let host = WURFL_JS_HOST;
+ if (altHost) {
+ host = altHost;
+ }
+
+ const url = new URL(host);
+ url.pathname = WURFL_JS_ENDPOINT_PATH;
+
+ if (isDebug) {
+ url.searchParams.set('debug', 'true')
+ }
+
+ url.searchParams.set('mode', 'prebid')
+ logger.logMessage('url', url.toString());
+
+ try {
+ loadExternalScript(url.toString(), MODULE_NAME, () => {
+ logger.logMessage('script injected');
+ window.WURFLPromises.complete.then((res) => {
+ logger.logMessage('received data', res);
+ if (!res.wurfl_pbjs) {
+ logger.logError('invalid WURFL.js for Prebid response');
+ } else {
+ enrichBidderRequests(reqBidsConfigObj, bidders, res);
+ }
+ callback();
+ });
+ });
+ } catch (err) {
+ logger.logError(err);
+ callback();
+ }
+}
+
+/**
+ * enrichBidderRequests enriches the OpenRTB 2.0 device data with WURFL data for Business Edition
+ * @param {Object} reqBidsConfigObj Bid request configuration object
+ * @param {Array} bidders List of bidders
+ * @param {Object} wjsResponse WURFL.js response
+ */
+function enrichBidderRequests(reqBidsConfigObj, bidders, wjsResponse) {
+ const authBidders = wjsResponse.wurfl_pbjs?.authorized_bidders ?? {};
+ const caps = wjsResponse.wurfl_pbjs?.caps ?? [];
+
+ bidders.forEach((bidderCode) => {
+ if (bidderCode in authBidders) {
+ // inject WURFL data
+ enrichedBidders.add(bidderCode);
+ const data = bidderData(wjsResponse.WURFL, caps, authBidders[bidderCode]);
+ logger.logMessage(`injecting data for ${bidderCode}: `, data);
+ enrichBidderRequest(reqBidsConfigObj, bidderCode, data);
+ return;
+ }
+ // inject WURFL low entropy data
+ const data = lowEntropyData(wjsResponse.WURFL, wjsResponse.wurfl_pbjs?.low_entropy_caps);
+ logger.logMessage(`injecting low entropy data for ${bidderCode}: `, data);
+ enrichBidderRequest(reqBidsConfigObj, bidderCode, data);
+ });
+}
+
+/**
+ * bidderData returns the WURFL data for a bidder
+ * @param {Object} wurflData WURFL data
+ * @param {Array} caps Capability list
+ * @param {Array} filter Filter list
+ * @returns {Object} Bidder data
+ */
+export const bidderData = (wurflData, caps, filter) => {
+ const data = {};
+ caps.forEach((cap, index) => {
+ if (!filter.includes(index)) {
+ return;
+ }
+ if (cap in wurflData) {
+ data[cap] = wurflData[cap];
+ }
+ });
+ return data;
+}
+
+/**
+ * lowEntropyData returns the WURFL low entropy data
+ * @param {Object} wurflData WURFL data
+ * @param {Array} lowEntropyCaps Low entropy capability list
+ * @returns {Object} Bidder data
+ */
+export const lowEntropyData = (wurflData, lowEntropyCaps) => {
+ const data = {};
+ lowEntropyCaps.forEach((cap, _) => {
+ let value = wurflData[cap];
+ if (cap == 'complete_device_name') {
+ value = value.replace(/Apple (iP(hone|ad|od)).*/, 'Apple iP$2');
+ }
+ data[cap] = value;
+ });
+ return data;
+}
+
+/**
+ * enrichBidderRequest enriches the bidder request with WURFL data
+ * @param {Object} reqBidsConfigObj Bid request configuration object
+ * @param {String} bidderCode Bidder code
+ * @param {Object} wurflData WURFL data
+ */
+export const enrichBidderRequest = (reqBidsConfigObj, bidderCode, wurflData) => {
+ const ortb2data = {
+ 'device': {
+ 'ext': {
+ 'wurfl': wurflData,
+ }
+ },
+ };
+ mergeDeep(reqBidsConfigObj.ortb2Fragments.bidder, { [bidderCode]: ortb2data });
+}
+
+/**
+ * onAuctionEndEvent is called when the auction ends
+ * @param {Object} auctionDetails Auction details
+ * @param {Object} config Configuration for WURFL RTD submodule
+ * @param {Object} userConsent User consent data
+ */
+function onAuctionEndEvent(auctionDetails, config, userConsent) {
+ const altHost = config.params?.altHost ?? null;
+
+ let host = WURFL_JS_HOST;
+ if (altHost) {
+ host = altHost;
+ }
+
+ const url = new URL(host);
+ url.pathname = STATS_ENDPOINT_PATH;
+
+ if (enrichedBidders.size === 0) {
+ return;
+ }
+
+ var payload = JSON.stringify({ bidders: [...enrichedBidders] });
+ const sentBeacon = sendBeacon(url.toString(), payload);
+ if (sentBeacon) {
+ return;
+ }
+
+ fetch(url.toString(), {
+ method: 'POST',
+ body: payload,
+ mode: 'no-cors',
+ keepalive: true
+ });
+}
+
+// The WURFL submodule
+export const wurflSubmodule = {
+ name: MODULE_NAME,
+ init,
+ getBidRequestData,
+ onAuctionEndEvent,
+}
+
+// Register the WURFL submodule as submodule of realTimeData
+submodule(REAL_TIME_MODULE, wurflSubmodule);
diff --git a/modules/wurflRtdProvider.md b/modules/wurflRtdProvider.md
new file mode 100644
index 00000000000..d656add3543
--- /dev/null
+++ b/modules/wurflRtdProvider.md
@@ -0,0 +1,67 @@
+# WURFL Real-time Data Submodule
+
+## Overview
+
+ Module Name: WURFL Rtd Provider
+ Module Type: Rtd Provider
+ Maintainer: prebid@scientiamobile.com
+
+## Description
+
+The WURFL RTD module enriches the OpenRTB 2.0 device data with [WURFL data](https://www.scientiamobile.com/wurfl-js-business-edition-at-the-intersection-of-javascript-and-enterprise/).
+The module sets the WURFL data in `device.ext.wurfl` and all the bidder adapters will always receive the low entry capabilites like `is_mobile`, `complete_device_name` and `form_factor`.
+
+For a more detailed analysis bidders can subscribe to detect iPhone and iPad models and receive additional [WURFL device capabilities](https://www.scientiamobile.com/capabilities/?products%5B%5D=wurfl-js).
+
+## User-Agent Client Hints
+
+WURFL.js is fully compatible with Chromium's User-Agent Client Hints (UA-CH) initiative. If User-Agent Client Hints are absent in the HTTP headers that WURFL.js receives, the service will automatically fall back to using the User-Agent Client Hints' JS API to fetch [high entropy client hint values](https://wicg.github.io/ua-client-hints/#getHighEntropyValues) from the client device. However, we recommend that you explicitly opt-in/advertise support for User-Agent Client Hints on your website and delegate them to the WURFL.js service for the fastest detection experience. Our documentation regarding implementing User-Agent Client Hint support [is available here](https://docs.scientiamobile.com/guides/implementing-useragent-clienthints).
+
+## Usage
+
+### Build
+```
+gulp build --modules="wurflRtdProvider,appnexusBidAdapter,..."
+```
+
+### Configuration
+
+Use `setConfig` to instruct Prebid.js to initilize the WURFL RTD module, as specified below.
+
+This module is configured as part of the `realTimeData.dataProviders`
+
+```javascript
+var TIMEOUT = 1000;
+pbjs.setConfig({
+ realTimeData: {
+ auctionDelay: TIMEOUT,
+ dataProviders: [{
+ name: 'wurfl',
+ waitForIt: true,
+ params: {
+ debug: false
+ }
+ }]
+ }
+});
+```
+
+### Parameters
+
+| Name | Type | Description | Default |
+| :------------------------ | :------------ | :--------------------------------------------------------------- |:----------------- |
+| name | String | Real time data module name | Always 'wurfl' |
+| waitForIt | Boolean | Should be `true` if there's an `auctionDelay` defined (optional) | `false` |
+| params | Object | | |
+| params.altHost | String | Alternate host to connect to WURFL.js | |
+| params.debug | Boolean | Enable debug | `false` |
+
+## Testing
+
+To view an example of how the WURFL RTD module works :
+
+`gulp serve --modules=wurflRtdProvider,appnexusBidAdapter`
+
+and then point your browser at:
+
+`http://localhost:9999/integrationExamples/gpt/wurflRtdProvider_example.html`
diff --git a/src/adloader.js b/src/adloader.js
index 79ea6e017bb..d1bc881adb5 100644
--- a/src/adloader.js
+++ b/src/adloader.js
@@ -32,6 +32,7 @@ const _approvedLoadExternalJSList = [
'dynamicAdBoost',
'51Degrees',
'symitridap',
+ 'wurfl',
// UserId Submodules
'justtag',
'tncId',
diff --git a/test/spec/modules/wurflRtdProvider_spec.js b/test/spec/modules/wurflRtdProvider_spec.js
new file mode 100644
index 00000000000..5b1cc5b751f
--- /dev/null
+++ b/test/spec/modules/wurflRtdProvider_spec.js
@@ -0,0 +1,324 @@
+import {
+ bidderData,
+ enrichBidderRequest,
+ lowEntropyData,
+ wurflSubmodule,
+} from 'modules/wurflRtdProvider';
+import * as ajaxModule from 'src/ajax';
+import { loadExternalScriptStub } from 'test/mocks/adloaderStub.js';
+
+describe('wurflRtdProvider', function () {
+ describe('wurflSubmodule', function () {
+ const altHost = 'http://example.local/wurfl.js';
+ const wurfl_pbjs = {
+ low_entropy_caps: ['complete_device_name', 'form_factor', 'is_mobile'],
+ caps: [
+ 'advertised_browser',
+ 'advertised_browser_version',
+ 'advertised_device_os',
+ 'advertised_device_os_version',
+ 'brand_name',
+ 'complete_device_name',
+ 'form_factor',
+ 'is_app_webview',
+ 'is_full_desktop',
+ 'is_mobile',
+ 'is_robot',
+ 'is_smartphone',
+ 'is_smarttv',
+ 'is_tablet',
+ 'manufacturer_name',
+ 'marketing_name'
+ ],
+ authorized_bidders: {
+ 'bidder1': [0, 1, 2, 3, 4, 5, 6, 7, 10, 13, 15],
+ 'bidder2': [5, 6, 7, 8, 9, 10, 11, 12, 13, 14],
+ }
+ }
+
+ const WURFL = {
+ advertised_browser: 'Chrome',
+ advertised_browser_version: '125.0.6422.76',
+ advertised_device_os: 'Linux',
+ advertised_device_os_version: '6.5.0',
+ brand_name: 'Google',
+ complete_device_name: 'Google Chrome',
+ form_factor: 'Desktop',
+ is_app_webview: !1,
+ is_full_desktop: !0,
+ is_mobile: !1,
+ is_robot: !1,
+ is_smartphone: !1,
+ is_smarttv: !1,
+ is_tablet: !1,
+ manufacturer_name: '',
+ marketing_name: '',
+ }
+
+ // expected analytics values
+ const expectedStatsURL = 'https://prebid.wurflcloud.com/v1/prebid/stats';
+ const expectedData = JSON.stringify({ bidders: ['bidder1', 'bidder2'] });
+
+ let sandbox;
+
+ beforeEach(function() {
+ sandbox = sinon.createSandbox();
+ window.WURFLPromises = {
+ init: new Promise(function(resolve, reject) { resolve({ WURFL, wurfl_pbjs }) }),
+ complete: new Promise(function(resolve, reject) { resolve({ WURFL, wurfl_pbjs }) }),
+ };
+ });
+
+ afterEach(() => {
+ // Restore the original functions
+ sandbox.restore();
+ window.WURFLPromises = undefined;
+ });
+
+ // Bid request config
+ const reqBidsConfigObj = {
+ adUnits: [{
+ bids: [
+ { bidder: 'bidder1' },
+ { bidder: 'bidder2' },
+ { bidder: 'bidder3' },
+ ]
+ }],
+ ortb2Fragments: {
+ bidder: {},
+ }
+ };
+
+ it('initialises the WURFL RTD provider', function () {
+ expect(wurflSubmodule.init()).to.be.true;
+ });
+
+ it('should enrich the bid request data', (done) => {
+ const expectedURL = new URL(altHost);
+ expectedURL.searchParams.set('debug', true);
+ expectedURL.searchParams.set('mode', 'prebid');
+
+ const callback = () => {
+ expect(reqBidsConfigObj.ortb2Fragments.bidder).to.deep.equal({
+ bidder1: {
+ device: {
+ ext: {
+ wurfl: {
+ advertised_browser: 'Chrome',
+ advertised_browser_version: '125.0.6422.76',
+ advertised_device_os: 'Linux',
+ advertised_device_os_version: '6.5.0',
+ brand_name: 'Google',
+ complete_device_name: 'Google Chrome',
+ form_factor: 'Desktop',
+ is_app_webview: !1,
+ is_robot: !1,
+ is_tablet: !1,
+ marketing_name: '',
+ },
+ },
+ },
+ },
+ bidder2: {
+ device: {
+ ext: {
+ wurfl: {
+ complete_device_name: 'Google Chrome',
+ form_factor: 'Desktop',
+ is_app_webview: !1,
+ is_full_desktop: !0,
+ is_mobile: !1,
+ is_robot: !1,
+ is_smartphone: !1,
+ is_smarttv: !1,
+ is_tablet: !1,
+ manufacturer_name: '',
+ },
+ },
+ },
+ },
+ bidder3: {
+ device: {
+ ext: {
+ wurfl: {
+ complete_device_name: 'Google Chrome',
+ form_factor: 'Desktop',
+ is_mobile: !1,
+ },
+ },
+ },
+ },
+ });
+ done();
+ };
+
+ const config = {
+ params: {
+ altHost: altHost,
+ debug: true,
+ }
+ };
+ const userConsent = {};
+
+ wurflSubmodule.getBidRequestData(reqBidsConfigObj, callback, config, userConsent);
+ expect(loadExternalScriptStub.calledOnce).to.be.true;
+ const loadExternalScriptCall = loadExternalScriptStub.getCall(0);
+ expect(loadExternalScriptCall.args[0]).to.equal(expectedURL.toString());
+ expect(loadExternalScriptCall.args[1]).to.equal('wurfl');
+ });
+
+ it('onAuctionEndEvent: should send analytics data using navigator.sendBeacon, if available', () => {
+ const auctionDetails = {};
+ const config = {};
+ const userConsent = {};
+
+ const sendBeaconStub = sandbox.stub(navigator, 'sendBeacon');
+
+ // Call the function
+ wurflSubmodule.onAuctionEndEvent(auctionDetails, config, userConsent);
+
+ // Assertions
+ expect(sendBeaconStub.calledOnce).to.be.true;
+ expect(sendBeaconStub.calledWithExactly(expectedStatsURL, expectedData)).to.be.true;
+ });
+
+ it('onAuctionEndEvent: should send analytics data using fetch as fallback, if navigator.sendBeacon is not available', () => {
+ const auctionDetails = {};
+ const config = {};
+ const userConsent = {};
+
+ const sendBeaconStub = sandbox.stub(navigator, 'sendBeacon').value(undefined);
+ const windowFetchStub = sandbox.stub(window, 'fetch');
+ const fetchAjaxStub = sandbox.stub(ajaxModule, 'fetch');
+
+ // Call the function
+ wurflSubmodule.onAuctionEndEvent(auctionDetails, config, userConsent);
+
+ // Assertions
+ expect(sendBeaconStub.called).to.be.false;
+
+ expect(fetchAjaxStub.calledOnce).to.be.true;
+ const fetchAjaxCall = fetchAjaxStub.getCall(0);
+ expect(fetchAjaxCall.args[0]).to.equal(expectedStatsURL);
+ expect(fetchAjaxCall.args[1].method).to.equal('POST');
+ expect(fetchAjaxCall.args[1].body).to.equal(expectedData);
+ expect(fetchAjaxCall.args[1].mode).to.equal('no-cors');
+ });
+ });
+
+ describe('bidderData', () => {
+ it('should return the WURFL data for a bidder', () => {
+ const wjsData = {
+ capability1: 'value1',
+ capability2: 'value2',
+ capability3: 'value3',
+ };
+ const caps = ['capability1', 'capability2', 'capability3'];
+ const filter = [0, 2];
+
+ const result = bidderData(wjsData, caps, filter);
+
+ expect(result).to.deep.equal({
+ capability1: 'value1',
+ capability3: 'value3',
+ });
+ });
+
+ it('should return an empty object if the filter is empty', () => {
+ const wjsData = {
+ capability1: 'value1',
+ capability2: 'value2',
+ capability3: 'value3',
+ };
+ const caps = ['capability1', 'capability3'];
+ const filter = [];
+
+ const result = bidderData(wjsData, caps, filter);
+
+ expect(result).to.deep.equal({});
+ });
+ });
+
+ describe('lowEntropyData', () => {
+ it('should return the correct low entropy data for Apple devices', () => {
+ const wjsData = {
+ complete_device_name: 'Apple iPhone X',
+ form_factor: 'Smartphone',
+ is_mobile: !0,
+ };
+ const lowEntropyCaps = ['complete_device_name', 'form_factor', 'is_mobile'];
+ const expectedData = {
+ complete_device_name: 'Apple iPhone',
+ form_factor: 'Smartphone',
+ is_mobile: !0,
+ };
+ const result = lowEntropyData(wjsData, lowEntropyCaps);
+ expect(result).to.deep.equal(expectedData);
+ });
+
+ it('should return the correct low entropy data for Android devices', () => {
+ const wjsData = {
+ complete_device_name: 'Samsung SM-G981B (Galaxy S20 5G)',
+ form_factor: 'Smartphone',
+ is_mobile: !0,
+ };
+ const lowEntropyCaps = ['complete_device_name', 'form_factor', 'is_mobile'];
+ const expectedData = {
+ complete_device_name: 'Samsung SM-G981B (Galaxy S20 5G)',
+ form_factor: 'Smartphone',
+ is_mobile: !0,
+ };
+ const result = lowEntropyData(wjsData, lowEntropyCaps);
+ expect(result).to.deep.equal(expectedData);
+ });
+
+ it('should return an empty object if the lowEntropyCaps array is empty', () => {
+ const wjsData = {
+ complete_device_name: 'Samsung SM-G981B (Galaxy S20 5G)',
+ form_factor: 'Smartphone',
+ is_mobile: !0,
+ };
+ const lowEntropyCaps = [];
+ const expectedData = {};
+ const result = lowEntropyData(wjsData, lowEntropyCaps);
+ expect(result).to.deep.equal(expectedData);
+ });
+ });
+
+ describe('enrichBidderRequest', () => {
+ it('should enrich the bidder request with WURFL data', () => {
+ const reqBidsConfigObj = {
+ ortb2Fragments: {
+ bidder: {
+ exampleBidder: {
+ device: {
+ ua: 'user-agent',
+ }
+ }
+ }
+ }
+ };
+ const bidderCode = 'exampleBidder';
+ const wjsData = {
+ capability1: 'value1',
+ capability2: 'value2'
+ };
+
+ enrichBidderRequest(reqBidsConfigObj, bidderCode, wjsData);
+
+ expect(reqBidsConfigObj.ortb2Fragments.bidder).to.deep.equal({
+ exampleBidder: {
+ device: {
+ ua: 'user-agent',
+ ext: {
+ wurfl: {
+ capability1: 'value1',
+ capability2: 'value2'
+ }
+ }
+ }
+ }
+ });
+ });
+ });
+});