diff --git a/ads/_a4a-config.js b/ads/_a4a-config.js index 9d7aab2fadea..84bbffbe029c 100644 --- a/ads/_a4a-config.js +++ b/ads/_a4a-config.js @@ -30,6 +30,7 @@ export function getA4ARegistry() { 'mgid': (win, adTag) => !adTag.hasAttribute('data-container') && !adTag.hasAttribute('data-website'), + 'insurads': () => true, 'nws': () => true, 'smartadserver': () => true, 'valueimpression': () => true, diff --git a/build-system/compile/bundles.config.extensions.json b/build-system/compile/bundles.config.extensions.json index fe6527cef57f..382369466483 100644 --- a/build-system/compile/bundles.config.extensions.json +++ b/build-system/compile/bundles.config.extensions.json @@ -108,6 +108,11 @@ "version": "0.1", "latestVersion": "0.1" }, + { + "name": "amp-ad-network-insurads-impl", + "version": "0.1", + "latestVersion": "0.1" + }, { "name": "amp-ad-network-mgid-impl", "version": "0.1", diff --git a/build-system/compile/bundles.legacy-latest-versions.jsonc b/build-system/compile/bundles.legacy-latest-versions.jsonc index 5a67246d25f0..c106f487f972 100644 --- a/build-system/compile/bundles.legacy-latest-versions.jsonc +++ b/build-system/compile/bundles.legacy-latest-versions.jsonc @@ -20,6 +20,7 @@ "amp-ad-network-dianomi-impl": "0.1", "amp-ad-network-doubleclick-impl": "0.1", "amp-ad-network-fake-impl": "0.1", + "amp-ad-network-insurads-impl": "0.1", "amp-ad-network-nws-impl": "0.1", "amp-ad-network-valueimpression-impl": "0.1", "amp-ad": "0.1", diff --git a/build-system/test-configs/dep-check-config.js b/build-system/test-configs/dep-check-config.js index e961ec1f23ed..cd9e635ec64a 100644 --- a/build-system/test-configs/dep-check-config.js +++ b/build-system/test-configs/dep-check-config.js @@ -141,6 +141,7 @@ exports.rules = [ // a4a ads depend on a4a. 'extensions/amp-ad-network-nws-impl/0.1/amp-ad-network-nws-impl.js->extensions/amp-a4a/0.1/amp-a4a.js', 'extensions/amp-ad-network-fake-impl/0.1/amp-ad-network-fake-impl.js->extensions/amp-a4a/0.1/amp-a4a.js', + 'extensions/amp-ad-network-insurads-impl/0.1/amp-ad-network-insurads-impl.js->extensions/amp-a4a/0.1/amp-a4a.js', 'extensions/amp-ad-network-adzerk-impl/0.1/amp-ad-network-adzerk-impl.js->extensions/amp-a4a/0.1/amp-a4a.js', 'extensions/amp-ad-network-smartads-impl/0.1/amp-ad-network-smartads-impl.js->extensions/amp-a4a/0.1/amp-a4a.js', 'extensions/amp-ad-network-doubleclick-impl/0.1/sra-utils.js->extensions/amp-a4a/0.1/amp-a4a.js', @@ -163,6 +164,9 @@ exports.rules = [ 'extensions/amp-ad-network-doubleclick-impl/0.1/amp-ad-network-doubleclick-impl.js->extensions/amp-a4a/0.1/refresh-manager.js', 'extensions/amp-ad-network-valueimpression-impl/0.1/amp-ad-network-valueimpression-impl.js->extensions/amp-a4a/0.1/refresh-manager.js', + // Depends on DoubleClick + 'extensions/amp-ad-network-insurads-impl/0.1/doubleclick-helper.js->extensions/amp-ad-network-doubleclick-impl/0.1/amp-ad-network-doubleclick-impl.js', + // AMP access depends on AMP access 'extensions/amp-access-scroll/0.1/scroll-impl.js->extensions/amp-access/0.1/amp-access-client.js', @@ -536,6 +540,10 @@ exports.rules = [ 'extensions/amp-ad-network-doubleclick-impl/0.1/amp-ad-network-doubleclick-impl.js', 'extensions/amp-ad-network-adsense-impl/0.1/amp-ad-network-adsense-impl.js', ], + allowlist: [ + // Depends on DoubleClick + 'extensions/amp-ad-network-insurads-impl/0.1/doubleclick-helper.js->extensions/amp-ad-network-doubleclick-impl/0.1/amp-ad-network-doubleclick-impl.js', + ], }, // Delayed fetch for Doubleclick will be deprecated on March 29, 2018. diff --git a/examples/amp-ad/a4a.amp.html b/examples/amp-ad/a4a.amp.html index 30bef5344703..4faad77f71da 100644 --- a/examples/amp-ad/a4a.amp.html +++ b/examples/amp-ad/a4a.amp.html @@ -42,6 +42,14 @@

A4A Examples

+

Insurads

+ +

Fake ad network

+ + + + + InsurAds example - AMP News Example + + + + + + + + + + +
+

InsurAds example

+

Today's Headlines

+ +
+

Breaking News

+

Stay updated with the latest news from around the world. Fast, reliable, and always up to date.

+ +
+ + + + +
+
+

World

+

Global leaders meet to discuss climate change initiatives and future cooperation.

+
+
+

Technology

+

New smartphone release sets the bar for innovation and user experience.

+
+
+

Sports

+

Local team wins championship in a thrilling final match.

+
+
+
+ + + diff --git a/extensions/amp-ad-network-insurads-impl/0.1/amp-ad-network-insurads-impl.js b/extensions/amp-ad-network-insurads-impl/0.1/amp-ad-network-insurads-impl.js new file mode 100644 index 000000000000..865376d4870b --- /dev/null +++ b/extensions/amp-ad-network-insurads-impl/0.1/amp-ad-network-insurads-impl.js @@ -0,0 +1,752 @@ +import {CONSENT_POLICY_STATE} from '#core/constants/consent-state'; +import {Deferred} from '#core/data-structures/promise'; +import {tryParseJson} from '#core/types/object/json'; + +import {Services} from '#service'; + +import {user} from '#utils/log'; + +import { + getConsentMetadata, + getConsentPolicyInfo, + getConsentPolicySharedData, + getConsentPolicyState, +} from 'src/consent'; + +import {Core} from './core'; +import {DoubleClickHelper} from './doubleclick-helper'; +import {ExtensionCommunication} from './extension'; +import {CryptoUtils} from './utilities'; +import {VisibilityTracker} from './visibility-tracking'; +import {Waterfall} from './waterfall'; + +import {AmpA4A, hasStorageConsent} from '../../amp-a4a/0.1/amp-a4a'; + +/** @type {string} */ +const TAG = 'amp-ad-network-insurads-impl'; + +export class AmpAdNetworkInsuradsImpl extends AmpA4A { + /** + * @param {!Element} element + */ + constructor(element) { + super(element); + + this.element.setAttribute('data-enable-refresh', 'false'); + + /** @private {number} */ + this.unitId_ = 0; + + /** @private {?Object} */ + this.adResponseData_ = null; + /** @private {?Array>} */ + this.sizes_ = null; + + /** @private {number} */ + this.parentMawId_ = 0; + + /** @private {string} */ + this.unitCode_ = CryptoUtils.generateCode(); + /** @private {string} */ + this.path_ = this.element.getAttribute('data-slot'); + /** @private {!Object} */ + this.requiredKeyValues_ = {}; + /** @private {?Object} */ + this.originalRtcConfig_ = tryParseJson( + this.element.getAttribute('rtc-config') + ); + + /** @private {boolean} */ + this.isViewable_ = false; + + /** @private {?Object} */ + this.iabTaxonomy_ = {}; + + /** @private {boolean} */ + this.appEnabled_ = false; + /** @private @const {!Deferred} */ + this.appReadyDeferred_ = new Deferred(); + + /** @private {?ExtensionCommunication} */ + this.extension_ = null; + /** @private @const {!Deferred} */ + this.extensionReadyDeferred_ = new Deferred(); + + /** @private {?Waterfall} */ + this.waterfall_ = null; + + /** @public {?DoubleClickHelper} */ + this.dCHelper = new DoubleClickHelper(this); + this.dCHelper.callMethod('constructor', element); + + const publicId = this.element.getAttribute('data-public-id'); + const {canonicalUrl} = Services.documentInfoForDoc(this.element); + + this.getConsent_().then((consent) => { + this.initializeWithConsent_(consent, canonicalUrl, publicId); + }); + } + + /** @override */ + buildCallback() { + this.dCHelper.callMethod('buildCallback'); + } + + /** @override */ + onCreativeRender(creativeMetaData, opt_onLoadPromise) { + this.isRefreshing = false; + this.dCHelper.callMethod( + 'onCreativeRender', + creativeMetaData, + opt_onLoadPromise + ); + } + + /** @override */ + refresh(refreshEndCallback) { + if (this.isRefreshing) { + return; + } + this.refreshCount_++; + + return super.refresh(refreshEndCallback); + } + + /** @override */ + extractSize(responseHeaders) { + this.adResponseData_ = { + lineItemId: responseHeaders.get('google-lineitem-id') || '-1', + creativeId: responseHeaders.get('google-creative-id') || '-1', + servedSize: responseHeaders.get('google-size') || '', + }; + + this.appReadyDeferred_.promise.then(() => { + this.sendUnitInit_(); + }); + + this.extensionReadyDeferred_.promise.then(() => { + if (this.extension_) { + const entry = this.waterfall_ + ? this.waterfall_.getCurrentEntry() + : null; + + this.extension_.bannerChanged({ + unitId: this.getUnitId_(), + shortId: this.unitId_, + impressionId: CryptoUtils.generateImpressionId(), + provider: entry ? entry.provider : '', + width: this.adResponseData_.servedSize.width, + height: this.adResponseData_.servedSize.height, + }); + } + }); + + return this.dCHelper.callMethod('extractSize', responseHeaders); + } + + /** @override */ + getAdUrl(opt_consentTuple, opt_rtcResponsesPromise, opt_serveNpaSignal) { + this.getAdUrlDeferred = new Deferred(); + this.getAdUrlInsurAdsDeferred = new Deferred(); + const self = this; + this.dCHelper.callMethod( + 'getAdUrl', + opt_consentTuple, + opt_rtcResponsesPromise, + opt_serveNpaSignal + ); + this.getAdUrlDeferred.promise.then((doubleClickUrl) => { + const augmentedAdUrl = this.augmentAdUrl_(doubleClickUrl); + self.getAdUrlInsurAdsDeferred.resolve(augmentedAdUrl); + }); + return this.getAdUrlInsurAdsDeferred.promise; + } + + /** @override */ + tearDownSlot() { + this.dCHelper.callMethod('tearDownSlot'); + } + + /** @override */ + forceCollapse() { + if (this.refreshCount_ === 0) { + super.forceCollapse(); + this.destroy_(); + } else { + this.triggerImmediateRefresh_(); + } + } + + /** + * Initializes the InsurAds instance with consent data. + * @param {string=} consent - The consent data string + * @param {string} canonicalUrl - The canonical URL of the document + * @param {string} publicId - The public ID for the ad + * @private + */ + initializeWithConsent_(consent, canonicalUrl, publicId) { + const consentTuple = consent ? this.parseConsent_(consent) : null; + const storageConsent = hasStorageConsent(consentTuple); + + this.core_ = Core.start(this.win, canonicalUrl, publicId, storageConsent); + this.core_.registerUnit(this.unitCode_, this.handleReconnect_.bind(this), { + appInitHandler: (message) => this.handleAppInit_(message), + unitInitHandler: (message) => this.handleUnitInit_(message), + waterfallHandler: (message) => this.handleWaterfall_(message), + }); + } + + /** + * Appends InsurAds URL parameters for ad requests. + * @param {string} adUrl + * @return {string} The augmented URL with InsurAds parameters + * @private + */ + augmentAdUrl_(adUrl) { + if ( + !this.appEnabled_ || + !this.waterfall_ || + this.api_.getRefreshCount() === 0 + ) { + // If app is not enabled or no waterfall, return the original ad URL + console /*OK*/ + .log( + 'InsurAds: App not enabled or no waterfall, returning original ad URL' + ); + const url = new URL(adUrl); // TODO: return adUrl original + const params = url.searchParams; + params.set('iat', 'not-enabled'); + return url.toString(); + } + + const url = new URL(adUrl); + const params = url.searchParams; + const entry = this.waterfall_.getCurrentEntry(); + + if (entry.path) { + params.set('iu', entry.path); + } + + this.mergeKeyValuesWithParams_(params, entry); + this.addUserSignalsToParams_(params, entry); + this.parseSizesFromParams_(params); + + return url.toString(); + } + + /** + * Merges entry key values with existing URL parameters + * @param {!URLSearchParams} params - The URL parameters + * @param {!Object} entry - The waterfall entry + * @private + */ + mergeKeyValuesWithParams_(params, entry) { + if (!params || !entry) { + return; + } + const existingKeyValues = params.get('scp'); + const allKeyValues = []; + + if (entry.keyValues) { + allKeyValues.push(...entry.keyValues); + } + if (entry.commonKeyValues) { + allKeyValues.push(...entry.commonKeyValues); + } + + if (allKeyValues.length === 0) { + return; + } + + const serializedKeyValues = this.serializeKeyValueArray_(allKeyValues); + const mergedKeyValues = existingKeyValues + ? `${existingKeyValues}&${serializedKeyValues}` + : serializedKeyValues; + + params.set('scp', mergedKeyValues); + } + + /** + * Adds IAB taxonomy user signals to URL parameters if conditions are met + * @param {!URLSearchParams} params - The URL parameters to modify + * @param {!Object} entry - The waterfall entry + * @private + */ + addUserSignalsToParams_(params, entry) { + if (!params || !entry) { + return; + } + + if (!this.iabTaxonomy_ || !entry.isHouseDemand) { + return; + } + + try { + const userSignals = this.convertToUserSignals_(this.iabTaxonomy_); + const encodedSignals = this.encodeUserSignals_(userSignals); + params.set('ppsj', encodedSignals); + } catch (error) { + console /*Ok*/ + .error('Failed to encode user signals:', error); + } + } + + /** + * Encodes user signals for URL transmission + * @param {!Object} userSignals - The user signals object + * @return {string} Base64 encoded and URI encoded signals + * @private + */ + encodeUserSignals_(userSignals) { + const jsonString = JSON.stringify(userSignals); + const base64Encoded = btoa(jsonString); + return encodeURIComponent(base64Encoded); + } + + /** + * Parses and stores ad sizes from URL parameters + * @param {!URLSearchParams} params - The URL parameters + * @private + */ + parseSizesFromParams_(params) { + const sizesString = params.get('sz'); + + if (!sizesString) { + this.sizes_ = []; + return; + } + + try { + this.sizes_ = this.parseSizeString_(sizesString); + } catch (error) { + this.sizes_ = []; + } + } + + /** + * refreshEndCallback + * @private + */ + refreshEndCallback_() { + console /*OK*/ + .log('Refresh End Callback'); + } + + /** + * Triggers an immediate refresh of the ad. + * This can be called when receiving realtime messages that require a refresh + * or an Extension Refresh message. + * @return {boolean} + * @private + */ + triggerImmediateRefresh_() { + if (!this.appEnabled_) { + this.destroy_(); + return false; + } + + if (this.isRefreshing) { + return false; + } + + if (!this.iframe) { + return false; + } + + const nextEntry = this.waterfall_.getNextEntry(); + + if (!nextEntry) { + return false; + } + + this.updateRtcConfig_(nextEntry); + + this.refresh(this.refreshEndCallback_); + } + + /** + * Handles reconnection to InsurAds + * This is called when the WebSocket connection is lost and needs to be re-established. + * @private + * */ + handleReconnect_() { + this.sendUnitInit_(true); + } + + /** + * Handles app initialization messages + * @param {!Object} message - The app initialization message + * @private + */ + handleAppInit_(message) { + if (message.status !== undefined) { + this.appEnabled_ = message.status > 0 ? true : false; + + if (!this.appEnabled_) { + this.destroy_(); + return; + } + + if (!this.appReadyDeferred_.isDone()) { + this.appReadyDeferred_.resolve(); + } + + this.populateRequiredKeysAndValues_(message.requiredKeys); + } + + if (message.iabTaxonomy !== undefined) { + this.iabTaxonomy_ = message.iabTaxonomy; + } + + if (!this.visibilityTracker) { + this.visibilityTracker = new VisibilityTracker( + this.win, + this.element, + this.onVisibilityChange_.bind(this) + ); + } + } + + /** + * Handles unit initialization messages + * @param {!Object} message - The unit initialization message + * @private + */ + handleUnitInit_(message) { + this.unitId_ = message.unitId; + this.element.setAttribute('tg-zone', this.getUnitId_()); + + if (window.frames['TG-listener'] && !this.extension_) { + this.extension_ = ExtensionCommunication.start( + this.getUnitId_(), + this.handlerExtensionMessages_.bind(this) + ); + } + + const {height, width} = this.creativeSize_ || this.initialSize_; + + if (!this.extensionReadyDeferred_.isDone()) { + if (this.extension_) { + this.extension_.unitCreated({ + unitId: this.getUnitId_(), + shortId: message.unitId, + sizes: this.sizes_, + rotation: message.rotation ? message.rotation : false, + visible: this.isViewable_, + width, + height, + }); + } + + this.extensionReadyDeferred_.resolve(); + } + } + + /** + * Handles unit waterfall messages + * @param {!Object} message - The app initialization message + * @private + */ + handleWaterfall_(message) { + if (message.unitCode !== this.unitCode_) { + return; + } + + this.waterfall_ = Waterfall.fromWaterfallMessage(message); + this.triggerImmediateRefresh_(); + } + + /** + * Handle incoming messages from the extension + * @param {MessageEvent} msg - The message event + * @private + */ + handlerExtensionMessages_(msg) { + if (msg.data.unitId !== this.getUnitId_()) { + return; + } + + switch (msg.data.action) { + case 'changeBanner': + this.sendUnitInit_(false, true); + break; + } + } + + /** + * Handles visibility changes + * @param {!Object} visibilityData - Visibility data object + * @private + */ + onVisibilityChange_(visibilityData) { + if (this.isViewable_ !== visibilityData.isViewable && this.appEnabled_) { + this.core_.sendUnitSnapshot(this.unitCode_, visibilityData.isViewable); + } + this.isViewable_ = visibilityData.isViewable; + } + + /** + * Sends the unit initialization message + * @param {boolean=} reconnect - Whether this is a reconnect + * @param {boolean=} passback - Whether this is a passback + * @private + */ + sendUnitInit_(reconnect = false, passback = false) { + if (this.appEnabled_) { + const entry = this.waterfall_ ? this.waterfall_.getCurrentEntry() : null; + const unitInit = { + unitCode: this.unitCode_, + keyValues: this.requiredKeyValues_, + path: entry ? entry.path : this.path_, + lineItemId: this.adResponseData_.lineItemId, + creativeId: this.adResponseData_.creativeId, + servedSize: this.adResponseData_.servedSize, + isHouseDemand: entry ? entry.isHouseDemand : false, + position: entry ? entry.position : undefined, + parentMawId: this.parentMawId_, + sizes: this.sizes_, + }; + + this.core_.sendUnitInit(unitInit, reconnect, passback); + } + } + + /** + * Return the Full Unit Id with the slot index + * @return {string} + * @private + */ + getUnitId_() { + const unitId = + this.unitId_ + '.' + this.element.getAttribute('data-amp-slot-index'); + return unitId; + } + + /** + * Destroy implementation + * This is called when the ad is removed from the DOM or refreshed + * @private + */ + destroy_() { + if (this.visibilityTracker) { + this.visibilityTracker.destroy(); + this.visibilityTracker = null; + } + + if (this.extension_) { + this.extension_.unitRemoved(this.getUnitId_()); + this.extension_ = null; + } + } + + /** + * Serializes an array of [key, value] pairs into a query string. + * @param {!Array|string)>>} pairs + * @return {string} + * @private + */ + serializeKeyValueArray_(pairs) { + return pairs + .map(([key, value]) => this.serializeItem_(key, value)) + .join('&'); + } + + /** + * @param {string} key + * @param {(!Array|string)} value + * @return {string} + * @private + */ + serializeItem_(key, value) { + const serializedValue = (Array.isArray(value) ? value : [value]) + .map(encodeURIComponent) + .join(); + return `${encodeURIComponent(key)}=${serializedValue}`; + } + + /** + * Convert the IabTaxonomy to Google required format + * @param {object} data - IabTaxonomy data. + * @return {object} - Formatted signals + * @private + */ + convertToUserSignals_(data) { + const taxonomyMap = { + content: 'IAB_CONTENT', + audience: 'IAB_AUDIENCE', + }; + + const PublisherProvidedTaxonomySignals = []; + + for (const [type, taxonomies] of Object.entries(data)) { + const prefix = taxonomyMap[type]; + if (!prefix) { + continue; + } + + for (const [version, items] of Object.entries(taxonomies)) { + const values = items + .map((item) => item.id) + .filter((id) => id !== undefined); + + if (values.length > 0) { + PublisherProvidedTaxonomySignals.push({ + taxonomy: `${prefix}_${version.replace(/\./g, '_')}`, + values, + }); + } + } + } + + return {PublisherProvidedTaxonomySignals}; + } + + /** + * Updates the rtc-config attribute based on the next entry / original rtc config. + * @param {!Object} entry + * @private + */ + updateRtcConfig_(entry) { + let rtcConfigStr = null; + + if ( + entry.isHouseDemand && + entry.vendors && + Object.keys(entry.vendors).length > 0 + ) { + rtcConfigStr = JSON.stringify({ + vendors: entry.vendors, + timeoutMillis: 750, + }); + } else if ( + this.originalRtcConfig_ && + Object.keys(this.originalRtcConfig_).length > 0 + ) { + rtcConfigStr = JSON.stringify(this.originalRtcConfig_); + } + + if (rtcConfigStr) { + const currentRtcConfig = this.element.getAttribute('rtc-config'); + if (currentRtcConfig !== rtcConfigStr) { + this.element.setAttribute('rtc-config', rtcConfigStr); + } + } else { + this.element.removeAttribute('rtc-config'); + } + } + + /** + * Populates requiredKeys_ and requiredKeyValues_ from requiredKeys and element targeting. + * @param {!Array} requiredKeys + * @private + */ + populateRequiredKeysAndValues_(requiredKeys) { + if (!Array.isArray(requiredKeys) || requiredKeys.length === 0) { + return; + } + + const jsonTargeting = tryParseJson(this.element.getAttribute('json')) || {}; + const {targeting} = jsonTargeting; + requiredKeys.forEach((key) => { + if (targeting && targeting[key]) { + this.requiredKeyValues_[key] = targeting[key]; + } + }); + } + + /** + * Get Consent + * @return {!Promise>} - Resolves with consent state, string, metadata, and shared data, or undefined if no policy ID + * @private + */ + getConsent_() { + const consentPolicyId = super.getConsentPolicy(); + + if (consentPolicyId) { + const consentStatePromise = getConsentPolicyState( + this.element, + consentPolicyId + ).catch((err) => { + user().error(TAG, 'Error determining consent state', err); + return CONSENT_POLICY_STATE.UNKNOWN; + }); + + const consentStringPromise = getConsentPolicyInfo( + this.element, + consentPolicyId + ).catch((err) => { + user().error(TAG, 'Error determining consent string', err); + return null; + }); + + const consentMetadataPromise = getConsentMetadata( + this.element, + consentPolicyId + ).catch((err) => { + user().error(TAG, 'Error determining consent metadata', err); + return null; + }); + + const consentSharedDataPromise = getConsentPolicySharedData( + this.element, + consentPolicyId + ).catch((err) => { + user().error(TAG, 'Error determining consent shared data', err); + return null; + }); + + return Promise.all([ + consentStatePromise, + consentStringPromise, + consentMetadataPromise, + consentSharedDataPromise, + ]); + } + + return Promise.resolve(null); + } + + /** + * Parses the consent tuple into a structured object + * @param {Array} consentResponse - The consent response array + * @return {?ConsentTupleDef} The parsed consent object + * @private + */ + parseConsent_(consentResponse) { + const consentState = consentResponse[0]; + const consentString = consentResponse[1]; + const consentMetadata = consentResponse[2]; + const consentSharedData = consentResponse[3]; + + const gdprApplies = consentMetadata + ? consentMetadata['gdprApplies'] + : consentMetadata; + const additionalConsent = consentMetadata + ? consentMetadata['additionalConsent'] + : consentMetadata; + const consentStringType = consentMetadata + ? consentMetadata['consentStringType'] + : consentMetadata; + const purposeOne = consentMetadata + ? consentMetadata['purposeOne'] + : consentMetadata; + const gppSectionId = consentMetadata + ? consentMetadata['gppSectionId'] + : consentMetadata; + + return { + consentState, + consentString, + consentStringType, + gdprApplies, + additionalConsent, + consentSharedData, + purposeOne, + gppSectionId, + }; + } +} + +AMP.extension(TAG, '0.1', (AMP) => { + AMP.registerElement(TAG, AmpAdNetworkInsuradsImpl); +}); diff --git a/extensions/amp-ad-network-insurads-impl/0.1/cookie.js b/extensions/amp-ad-network-insurads-impl/0.1/cookie.js new file mode 100644 index 000000000000..08a4e09096db --- /dev/null +++ b/extensions/amp-ad-network-insurads-impl/0.1/cookie.js @@ -0,0 +1,175 @@ +import {getCookie, setCookie} from 'src/cookies'; + +import {CryptoUtils} from './utilities'; + +/** @type {string} */ +const SCN = '___iat_ses'; +/** @type {string} */ +const VCN = '___iat_vis'; +/** @type {number} */ +const SCD = 30 * 60; // 30 minutes +/** @type {number} */ +const VCD = 6 * 30 * 24 * 60 * 60; // 6 months + +export class Cookie { + /** + * Cookie constructor` + * @param {Window} win + * @param {boolean} allowStorage + */ + constructor(win, allowStorage) { + /** @private {!Document} */ + this.win_ = win; + + /** @private {boolean} */ + this.cookies_ = true; + /** @private {boolean} */ + this.consent_ = allowStorage; + + // Generate a random session ID + /** @private {string|null} */ + this.sessionId_ = CryptoUtils.generateSessionId(); + + // Read and update session cookie + /** @private {string|null} */ + this.sessionCookie_ = this.getCookie_(SCN); + /** @private {boolean} */ + this.newVisitor_ = this.sessionCookie_ ? false : true; + /** @private {boolean|null} */ + this.cookiesEnabled_ = this.writeAndTestCookie_(SCN, SCD, this.sessionId_); + + // Read visitor cookie + /** @private {string|null} */ + this.visitCookie_ = this.getCookie_(VCN); + } + + /** + * Get Session Cookie + * @return {string|null} + */ + getSessionCookie() { + return this.sessionCookie_; + } + + /** + * Get Visit Cookie + * @return {string|null} + */ + getVisitCookie() { + return this.visitCookie_; + } + + /** + * Get Last Time Stamp + * @return {number} - The last timestamp from the visit cookie or the current timestamp + */ + getLastTimeStamp() { + if (this.visitCookie_) { + const parts = this.visitCookie_.split('.'); + return parts.length > 2 ? parseInt(parts[2], 10) : 0; + } + return 0; + } + + /** + * Get Cookies Enabled + * @return {boolean} + */ + isCookiesEnabled() { + return this.cookiesEnabled_; + } + + /** + * Get New Visitor + * @return {boolean} + */ + isNewVisitor() { + return this.newVisitor_; + } + + /** + * Update Visitor Cookie + * @param {string} lockedId + * @param {number} ts - The server timestamp + */ + updateVisitCookie(lockedId, ts) { + // Update visitor cookie with current server timestamp, plus all IatId stuff + this.writeCookie_(VCN, VCD, this.prepareVisitorCookie_(lockedId, ts)); + } + + /** + * Get Cookie + * @param {string} cookieName + * @return {string|undefined} - The value of the cookie or undefined if not found + */ + getCookie_(cookieName) { + return getCookie(this.win_, cookieName); + } + + /** + * Write Cookie + * @param {string} cookieName + * @param {number} cookieDuration + * @param {string} cookieValue + */ + writeCookie_(cookieName, cookieDuration, cookieValue) { + const expires = Date.now() + (this.cookies_ ? cookieDuration : -1) * 1000; + const options = { + highestAvailableDomain: true, + }; + + setCookie(this.win_, cookieName, cookieValue, expires, options); + } + + /** + * Write and test Cookie + * @param {string} cookieName + * @param {number} cookieDuration + * @param {string} cookieValue + * @return {boolean} - True if the cookie was successfully written and tested + */ + writeAndTestCookie_(cookieName, cookieDuration, cookieValue) { + // Write cookie as usual and test it + this.writeCookie_(cookieName, cookieDuration, cookieValue); + const content = this.getCookie_(cookieName); + + // If consent was not granted, set cookies as not supported from now on + // and delete the cookie previously created + if (!this.consent_) { + this.cookies_ = false; + this.writeCookie_(cookieName, cookieDuration, cookieValue); + } + + // True if domain cookies are supported + return !!content; + } + + /** + * Prepare visitor cookie + * @param {string} lockedId + * @param {number} ts - The server timestamp + * @return {string} - The formatted visitor cookie string + */ + prepareVisitorCookie_(lockedId, ts) { + const visitCookieParts = (this.visitCookie_ || '').split('.'); + + let retVal = + this.sessionId_ + + '.' + + lockedId + + '.' + + ts + + '.' + + (visitCookieParts[3] || '') + + '.' + + (visitCookieParts[4] || '') + + '.' + + (visitCookieParts[5] || '') + + '.' + + (visitCookieParts[6] || ''); + + // If available, store the new server choice for later decisions, otherwise keep current value + retVal += '.' + (visitCookieParts[7] || ''); // (c.lockedId || locked.lLockedId) - not available at the moment, use existing + return retVal; + } +} diff --git a/extensions/amp-ad-network-insurads-impl/0.1/core.js b/extensions/amp-ad-network-insurads-impl/0.1/core.js new file mode 100644 index 000000000000..7d2965fe4e1d --- /dev/null +++ b/extensions/amp-ad-network-insurads-impl/0.1/core.js @@ -0,0 +1,401 @@ +import {Cookie} from './cookie'; +import {EngagementTracker} from './engagement-tracking'; +import {ExtensionCommunication} from './extension'; +import { + AppInitMessage, + HandshakeMessage, + MessageFactory, + MessageHandler, + PageStatusMessage, + UnitInitMessage, + UnitSnapshotMessage, +} from './messages'; +import {RealtimeManager} from './realtime-manager'; +import {LockedId} from './utilities'; +/** + * Insurads Core + */ +export class Core { + /** @private {?Core} */ + static instance_ = null; + + /** @private {!Object} */ + unitHandlerMap = {}; + + /** @private {!EngagementTracker} */ + engagement_ = null; + + /** @private {?AppInitResponseMessage} */ + appInitResponse_ = null; + + /** + * Constructs the Core instance. + * @param {Window} win + * @param {string} canonicalUrl - Canonical URL + * @param {string} publicId - The public ID + * @param {boolean} hasStorageConsent - If Consent was given or does not apply + */ + constructor(win, canonicalUrl, publicId, hasStorageConsent) { + this.win_ = win; + this.canonicalUrl_ = canonicalUrl; + this.publicId_ = publicId; + this.consent_ = hasStorageConsent; + + /** @private {!LockedId} */ + this.lockedData_ = new LockedId().getLockedIdData(this.consent_); + /** @private {!ExtensionCommunication} */ + this.extension_ = win.frames['TG-listener'] + ? new ExtensionCommunication() + : null; + /** @private {!Cookie} */ + this.cookies_ = new Cookie(this.win_, this.consent_); + } + + /** + * Returns the singleton instance of Core. + * @param {Window} win - The window object + * @param {string} canonicalUrl - The canonical URL + * @param {string} publicId - The public ID + * @param {boolean} hasStorageConsent - If Consent was given or does not apply + * @return {!Core} + * @public + */ + static start(win, canonicalUrl, publicId, hasStorageConsent) { + if (!Core.instance_) { + Core.instance_ = new Core(win, canonicalUrl, publicId, hasStorageConsent); + Core.instance_.setupRealtimeConnection_(); + } + + return Core.instance_; + } + + /** + * Registers a new ad unit with the Core service. + * Each ad unit instance on the page should call this. + * @param {string} unitCode - The unique code for the ad unit. + * @param {function()} reconnectHandler - Handler for reconnection logic. + * @param {Object=} handlers - Message handlers for this specific ad unit. + */ + registerUnit(unitCode, reconnectHandler, handlers = {}) { + this.unitHandlerMap[unitCode] = new UnitHandlers( + reconnectHandler, + new MessageHandler(handlers) + ); + + // If the app is already initialized, immediately send the config to the new ad unit. + if (this.appInitResponse_) { + this.unitHandlerMap[unitCode].messageHandlers.processMessage( + this.appInitResponse_ + ); + } + } + + /** + * Sets up the realtime connection and event handlers + * @param {boolean} reconnect + * @private + */ + setupRealtimeConnection_(reconnect = false) { + /** @private {!RealtimeManager} */ + this.realtimeManager_ = RealtimeManager.start( + this.publicId_, + this.canonicalUrl_ + ); + + if (this.realtimeManager_) { + this.realtimeManager_.onReceiveMessage = this.dispatchMessage_.bind(this); + this.realtimeManager_.onConnect = this.onRealtimeConnect_.bind( + this, + reconnect + ); + this.realtimeManager_.onDisconnect = (event) => { + if (event.code !== 1000) { + this.destroy(); + } + }; + } + } + + /** + * Sends a handshake message + * @private + */ + sendHandshake_() { + const handshake = new HandshakeMessage(); + this.realtimeManager_.sendHandshake(handshake.serialize()); + } + + /** + * Sends an app initialization message + * @param {boolean=} reconnect - Reconnect flag + * @private + */ + sendAppInit_(reconnect = false) { + const appInit = new AppInitMessage({ + lockedId: this.lockedData_, + newVisitor: this.cookies_.isNewVisitor(), + lastTimestamp: this.cookies_.getLastTimeStamp(), + extension: !!this.extension_, + reconnect, + }); + this.realtimeManager_.send(appInit.serialize()); + } + + /** + * Sends an ad unit initialization message + * @param {{ + * unitCode: string, + * creativeId: (string|undefined), + * isHouseDemand: (boolean|undefined), + * keyValues: (Array|undefined), + * lineItemId: (string|undefined), + * parentMawId: (number|undefined), + * path: (string|undefined), + * position: (number|undefined), + * servedSize: (string|undefined) + * sizes: (Array|undefined) + * }} unitInfo - Ad unit information object. + * @param {boolean=} reconnect - Reconnect flag + * @param {boolean=} passback - Passback flag + */ + sendUnitInit(unitInfo, reconnect = false, passback = false) { + const info = { + ...unitInfo, + reconnect, + passback, + }; + const unitInit = new UnitInitMessage(info); + this.realtimeManager_.send(unitInit.serialize()); + } + + /** + * Sends an ad unit visibility snapshot + * @param {string} unitCode - Ad unit code + * @param {boolean} visible - Visibility + */ + sendUnitSnapshot(unitCode, visible) { + const snapshot = new UnitSnapshotMessage(unitCode, visible); + this.realtimeManager_.send(snapshot.serialize()); + } + + /** + * Sends a page status update + * @param {!Object} state - Engagement state object + * @private + */ + sendPageStatus_(state) { + if ( + state.isEngaged && + this.realtimeManager_ && + !this.realtimeManager_.isConnected() + ) { + this.setupRealtimeConnection_(true); + } + + const status = new PageStatusMessage(state.isEngaged, state.isVisible); + this.realtimeManager_.send(status.serialize()); + } + + /** + * Disconnects the WebSocket connection + * @param {boolean} clearQueue - Whether to clear the message queue on disconnect (default: true) + * @param {number=} code - Optional close code (default: 1000 - normal closure) + * @param {string=} reason - Optional reason for closing + * @return {boolean} Whether disconnection was successful + * @public + */ + disconnect(clearQueue = true, code = 1000, reason = 'AMP is going away') { + if (!this.realtimeManager_) { + return false; + } + + try { + this.realtimeManager_.disconnect(clearQueue, code, reason); + return true; + } catch (e) { + return false; + } + } + + /** + * Handles logic to run when the realtime connection is established. + * @param {boolean} reconnect + * @private + */ + onRealtimeConnect_(reconnect) { + this.sendHandshake_(); + this.sendAppInit_(reconnect); + if (reconnect) { + for (const unitCode in this.unitHandlerMap) { + this.unitHandlerMap[unitCode].reconnectHandler(); + } + } + } + + /** + * Central dispatcher for all incoming WebSocket messages. + * Routes messages to the correct ad unit handler. + * Broadcasts global messages + * @param {string} raw The raw message string from the WebSocket. + * @private + */ + dispatchMessage_(raw) { + const messages = raw.split('\u001e').filter(Boolean); + + messages.forEach((rawMessage) => { + try { + // TODO: Investigate the need for the double parsing, + // maybe this can be improved on server/client + const messageData = JSON.parse(rawMessage); + const action = messageData.arguments[0]; + const data = JSON.parse(messageData.arguments[1]); + + const parsedMessage = MessageFactory.createMessage(action, data); + if (!parsedMessage) { + return; + } + + if (action === 'app-init-response') { + // Global message, should broadcast to all units + for (const unitCode in this.unitHandlerMap) { + this.unitHandlerMap[unitCode].messageHandlers.processMessage( + parsedMessage + ); + } + + this.processAppInitResponse_(parsedMessage); + return; + } + + const {unitCode} = parsedMessage.message; + + if (unitCode && this.unitHandlerMap[unitCode]) { + const unitHandlers = this.unitHandlerMap[unitCode]; + unitHandlers.messageHandlers.processMessage(parsedMessage); + } + } catch (e) {} + }); + } + + /** + * Handles app initialization messages + * @param {!AppInitResponseMessage} appInitMessage - The app initialization message + * @private + */ + processAppInitResponse_(appInitMessage) { + const {message} = appInitMessage; + + // Merge the app init response with the existing one + // This allows us to accumulate configuration data + // and avoid overwriting previous responses. + if (!this.appInitResponse_) { + this.appInitResponse_ = appInitMessage; + } else { + const existingPayload = this.appInitResponse_.message; + const newPayload = appInitMessage.message; + const mergedPayload = {...existingPayload, ...newPayload}; + this.appInitResponse_.message = mergedPayload; + } + + // It is an app init response with general configuration, + // Set app status, start engagement and extension if exists. + if (message.status !== undefined) { + this.status = message.status; + this.appEnabled = message.status > 0 ? true : false; + + if (!this.appEnabled) { + this.destroy(); + return; + } + + if (!this.engagement_) { + const config = { + ivm: message.ivm, + }; + this.engagement_ = new EngagementTracker(this.win_); + this.engagement_.init(config); + this.unlistenEngagement_ = this.engagement_.registerListener( + this.updateEngagementStatus_.bind(this) + ); + } + + if (this.extension_) { + this.extension_.setup({ + applicationId: message.applicationId, + country: message.countryCode, + section: message.sectionId, + sessionId: this.cookies_.getSessionCookie(), + ivm: message.ivm, + state: this.engagement_.isEngaged() ? 1 : 0, + }); + } + + this.cookies_.updateVisitCookie( + message.lockedId, + message.serverTimestamp + ); + } + } + + /** + * Handles user engagement changes + * @param {!Object} state - Engagement state object + * @private + */ + updateEngagementStatus_(state) { + this.sendPageStatus_(state); + + if (this.extension_) { + this.extension_.engagementStatus({ + index: state.isEngaged ? 1 : 0, + name: state.isEngaged ? 'Active' : 'Inactive', + }); + } + } + + /** + * Destroy implementation + * This is called when the ad is removed from the DOM or refreshed + * @public + */ + destroy() { + if (this.unlistenEngagement_) { + this.unlistenEngagement_(); + this.unlistenEngagement_ = null; + } + + if (this.realtimeManager_) { + this.realtimeManager_.disconnect(true, 1000, 'Core is being destroyed'); + this.realtimeManager_.destroy(); + this.realtimeManager_ = null; + } + + if (this.engagement_) { + this.engagement_.destroy(); + this.engagement_ = null; + } + + if (this.extension_) { + this.extension_.destroy(); + this.extension_ = null; + } + + this.unitHandlerMap = {}; + } +} + +class UnitHandlers { + /** @public {?function()} */ + reconnectHandler = null; + /** @public {?MessageHandler} */ + messageHandlers = null; + + /** + * @param {function()} reconnectHandler - Handler for reconnection logic + * @param {Object=} handlers - Message handlers + */ + constructor(reconnectHandler, handlers = {}) { + this.reconnectHandler = reconnectHandler; + this.messageHandlers = new MessageHandler(handlers); + } +} diff --git a/extensions/amp-ad-network-insurads-impl/0.1/doubleclick-helper.js b/extensions/amp-ad-network-insurads-impl/0.1/doubleclick-helper.js new file mode 100644 index 000000000000..642b4f51a10c --- /dev/null +++ b/extensions/amp-ad-network-insurads-impl/0.1/doubleclick-helper.js @@ -0,0 +1,80 @@ +import {AmpAdNetworkDoubleclickImpl} from '../../amp-ad-network-doubleclick-impl/0.1/amp-ad-network-doubleclick-impl'; + +/** + * Helper class to manage DoubleClick implementation integration + */ +export class DoubleClickHelper { + /** + * @param {!Object} impl - The AmpAdNetworkInsuradsImpl instance + */ + constructor(impl) { + /** @private {!Object} */ + this.impl_ = impl; + + /** @private {string} */ + this.prefix_ = 'doubleClick'; + + /** @private {Array} */ + this.exceptions_ = [ + 'constructor', + 'buildCallback', + 'onCreativeRender', + 'refresh', + 'extractSize', + 'getAdUrl', + 'tearDownSlot', + ]; + + this.initializeDoubleClickMethods_(); + } + + /** + * Initializes the DoubleClick methods on the implementation + * @private + */ + initializeDoubleClickMethods_() { + const implProto = this.impl_.constructor.prototype; + const dblProto = AmpAdNetworkDoubleclickImpl.prototype; + + this.exceptions_.forEach((methodName) => { + implProto[this.getCapitalizedMethodWithPrefix_(methodName)] = + dblProto[methodName]; + }); + + for (const methodName in dblProto) { + if (!this.exceptions_.includes(methodName)) { + implProto[methodName] = dblProto[methodName]; + } + } + } + + /** + * Returns capitalized method name with prefix + * @param {string} methodName - The method name + * @return {string} The prefixed method name + * @private + */ + getCapitalizedMethodWithPrefix_(methodName) { + return ( + this.prefix_ + methodName.charAt(0).toUpperCase() + methodName.slice(1) + ); + } + + /** + * Calls a DoubleClick implementation method + * @param {string} methodName - The name of the method to call + * @param {...*} args - Arguments to pass to the method + * @return {*} Result of the method call + */ + callMethod(methodName, ...args) { + const prefixedName = this.getCapitalizedMethodWithPrefix_(methodName); + + if (typeof this.impl_[prefixedName] === 'function') { + try { + return this.impl_[prefixedName](...args); + } catch (error) {} + } + + return null; + } +} diff --git a/extensions/amp-ad-network-insurads-impl/0.1/engagement-tracking.js b/extensions/amp-ad-network-insurads-impl/0.1/engagement-tracking.js new file mode 100644 index 000000000000..c84c09c2b638 --- /dev/null +++ b/extensions/amp-ad-network-insurads-impl/0.1/engagement-tracking.js @@ -0,0 +1,283 @@ +import {isDocumentHidden} from '#core/document/visibility'; + +import {listen} from '#utils/event-helper'; + +/** + * Enum for browser/user activity states + * @enum {number} + */ +export const BrowserState = { + UNKNOWN: -1, + INACTIVE: 0, + ACTIVE: 1, + IDLE: 2, +}; + +/** + * @typedef {{ + * idleTimer: (number|undefined), + * ivm: (boolean|undefined) + * }} EngagementConfig + */ + +/** + * Array of event types which will be listened for on the document to indicate + * activity. Other activities are also observed on the AmpDoc and Viewport + * objects. + * @private @const {Array} + */ +const ACTIVE_EVENT_TYPES = [ + 'mousedown', + 'mouseup', + 'mousemove', + 'keydown', + 'keyup', +]; + +/** + * A singleton tracker for user engagement across all ad units. + * This tracks focus, visibility, and page state to determine if the user is engaged. + */ +export class EngagementTracker { + /** + * @param {!Window} win - Window object + */ + constructor(win) { + /** @private {!Window} */ + this.win_ = win; + + /** @private {Array} */ + this.listeners_ = []; + + /** @private {Array} */ + this.unlisteners_ = []; + + /** @private {number} */ + this.idleTimeout_ = 21000; // Default to 21 seconds + + /** @private {?number} */ + this.idleTimer_ = null; + + /** @private {boolean} */ + this.ivm_ = false; + + /** @private {BrowserState} */ + this.currentState_ = BrowserState.UNKNOWN; + + /** @private {boolean} */ + this.isFocused_ = false; + + /** @private {boolean} */ + this.isVisible_ = false; + + /** @private {boolean} */ + this.isOpen_ = false; + + /** @private {boolean} */ + this.isEngaged_ = false; + + /** @private {boolean} */ + this.isIdle_ = false; + + /** @private {boolean} */ + this.initialized_ = false; + } + + /** + * Initialize event listeners + * @param {EngagementConfig=} config + */ + init(config = {}) { + if (this.initialized_) { + return; + } + this.initialized_ = true; + + if (config.idleTimer !== undefined) { + this.idleTimeout_ = config.idleTimer * 1000; + } + + if (config.ivm !== undefined) { + this.ivm_ = config.ivm; + } + + this.isFocused_ = this.win_.document.hasFocus(); + this.isVisible_ = !isDocumentHidden(this.win_.document); + this.isOpen_ = true; + this.isEngaged_ = this.calculateEngaged_(); + this.isIdle_ = false; + + const onFocus = () => { + this.isFocused_ = true; + this.updateEngagement_(); + }; + const onBlur = () => { + this.isFocused_ = false; + this.updateEngagement_(); + }; + const onPageShow = () => { + this.isOpen_ = true; + this.updateEngagement_(); + }; + const onPageHide = () => { + this.isOpen_ = false; + this.updateEngagement_(); + }; + const onVisibilityChange = () => { + this.isVisible_ = !isDocumentHidden(this.win_.document); + this.updateEngagement_(); + }; + + this.unlisteners_.push( + listen(this.win_, 'focus', onFocus), + listen(this.win_, 'blur', onBlur), + listen(this.win_, 'pageshow', onPageShow), + listen(this.win_, 'pagehide', onPageHide), + listen(this.win_.document, 'visibilitychange', onVisibilityChange) + ); + + this.setUpListenersFromArray_( + this.win_.document, + ACTIVE_EVENT_TYPES, + () => { + this.isFocused_ = true; + this.isVisible_ = true; + this.isOpen_ = true; + this.isIdle_ = false; + this.updateEngagement_(); + } + ); + } + + /** + * Calculate the current engagement status + * @return {boolean} Whether the user is currently engaged + * @private + */ + calculateEngaged_() { + return this.isOpen_ && this.isVisible_ && this.isFocused_; + } + + /** + * Update engagement status and notify listeners if changed + * @private + */ + updateEngagement_() { + const isEngaged = this.calculateEngaged_(); + + if (isEngaged !== this.isEngaged_) { + this.isEngaged_ = isEngaged; + this.currentState_ = isEngaged + ? BrowserState.ACTIVE + : BrowserState.INACTIVE; + this.notifyListeners_(); + } + + if (this.currentState_ === BrowserState.ACTIVE) { + this.restartIdleTimer_(); + } else if (this.currentState_ === BrowserState.INACTIVE) { + clearTimeout(this.idleTimer_); + } + } + + /** + * Notify all listeners of the current page state + * @private + */ + notifyListeners_() { + this.listeners_.forEach((listener) => { + try { + listener(this.getState()); + } catch (e) {} + }); + } + + /** + * Resets the time until the user state changes to idle + * @private + */ + restartIdleTimer_() { + if (this.ivm_) { + return; + } + + clearTimeout(this.idleTimer_); + + this.idleTimer_ = setTimeout(() => { + this.isIdle_ = true; + this.currentState_ = BrowserState.IDLE; + }, this.idleTimeout_); + } + + /** + * @private + * @param {!EventTarget} target + * @param {Array} events + * @param {function()} listener + */ + setUpListenersFromArray_(target, events, listener) { + for (let i = 0; i < events.length; i++) { + this.unlisteners_.push(listen(target, events[i], listener)); + } + } + + /** + * Add a listener for engagement changes + * @param {function(!Object)} listener - Function called when engagement changes + * @return {function()} Function to remove the listener + */ + registerListener(listener) { + this.listeners_.push(listener); + + try { + const state = this.getState(); + listener(state); + } catch (e) {} + + return () => { + const index = this.listeners_.indexOf(listener); + if (index !== -1) { + this.listeners_.splice(index, 1); + } + }; + } + + /** + * Get current engagement state + * @return {boolean} + */ + isEngaged() { + return this.isEngaged_; + } + + /** + * Get detailed engagement state + * @return {!Object} + */ + getState() { + return { + currentState: this.currentState_, + isEngaged: this.isEngaged_, + isFocused: this.isFocused_, + isVisible: this.isVisible_, + isOpen: this.isOpen_, + isIdle: this.isIdle_, + }; + } + + /** + * Clean up resources and reset the singleton + */ + destroy() { + this.unlisteners_.forEach((unlisten) => { + try { + unlisten(); + } catch (e) {} + }); + this.unlisteners_ = []; + this.listeners_ = []; + clearTimeout(this.idleTimer_); + this.idleTimer_ = null; + this.initialized_ = false; + } +} diff --git a/extensions/amp-ad-network-insurads-impl/0.1/extension.js b/extensions/amp-ad-network-insurads-impl/0.1/extension.js new file mode 100644 index 000000000000..14f678bc3e95 --- /dev/null +++ b/extensions/amp-ad-network-insurads-impl/0.1/extension.js @@ -0,0 +1,200 @@ +import {BrowserState} from './engagement-tracking'; + +/** + * Handles communication between the AMP ad and the extension iframe + */ +export class ExtensionCommunication { + /** @private {?ExtensionCommunication} */ + static instance_ = null; + + /** @type {Object} */ + unitHandlerMap_ = {}; + + /** @type {Array} */ + queue_ = []; + + /** + * Returns the singleton instance of ExtensionCommunication. + * @param {string} unitId + * @param {function()} handler message handler + * @return {!ExtensionCommunication} + * @public + */ + static start(unitId, handler = {}) { + if (!ExtensionCommunication.instance_) { + ExtensionCommunication.instance_ = new ExtensionCommunication(); + } + + ExtensionCommunication.instance_.unitHandlerMap_[unitId] = handler; + + return ExtensionCommunication.instance_; + } + + /** + * Create Extension Communication Channel + */ + initExtensionCommunication_() { + if (!this.listener) { + !this.listenerAttacher && + (this.listenerAttacher = setInterval( + () => this.initExtensionCommunication_(), + 2000 + )); + this.listener = window.frames['TG-listener']; + } + if (this.listener) { + this.listener.addEventListener('message', this.handlerExtensionMessages_); + while (this.queue_.length !== 0) { + this.listener./*OK*/ postMessage(this.queue_.shift(), '*'); + } + if (this.listenerAttacher) { + clearInterval(this.listenerAttacher); + } + } + } + + /** + * Send message back to unit handler + * @param {object} message - The message to send + * @private + */ + handlerExtensionMessages_(message) { + this.unitHandlerMap_[message.data.unitId] && + this.unitHandlerMap_[message.data.unitId](message); + } + + /** + * Send a message to the extension iframe + * @param {string} type - The message type + * @param {object} data - The message data + * @private + */ + sendIframeMessage_(type, data) { + const msg = { + type, + data, + }; + + this.queue_.push(msg); + this.initExtensionCommunication_(); + } + + /** + * Cleanup event listeners and intervals + */ + destroy() { + if (this.listener) { + this.listener.removeEventListener( + 'message', + this.handlerExtensionMessages_ + ); + } + + if (this.listenerAttacher) { + clearInterval(this.listenerAttacher); + this.listenerAttacher = null; + } + + this.listener = null; + this.queue = []; + } + + /** + * Setup the extension communication channel + * @param {{ + * applicationId: number, + * country: string, + * section: number, + * sessionId: string, + * ivm: boolean, + * state: BrowserState + * }} params + */ + setup(params) { + const conf = { + sessionId: params.sessionId, + appId: params.applicationId, + section: params.section, + // eslint-disable-next-line local/camelcase + g_country: params.country, + ivm: params.ivm, + }; + + this.sendIframeMessage_('cfg', conf); + this.engagementStatus(params.state); + } + + /** + * Send a message when an ad unit is added + * @param {string} unitId + * */ + unitRemoved(unitId) { + this.sendIframeMessage_('adUnitRemoved', {id: unitId}); + } + + /** + * Send a message when a banner is changed. + * @param {{ + * unitId: (string), + * shortId: (string), + * impressionId: (string), + * provider: (string), + * width: number, + * height: number + * }} params + */ + bannerChanged(params) { + const msg = { + id: params.unitId, + shortId: params.shortId, + creative: null, + order: null, + orderLine: null, + impressionId: params.impressionId, + market: params.provider || '', + creativeWidth: params.width, + creativeHeight: params.height, + }; + this.sendIframeMessage_('bannerChanged', msg); + } + + /** + * Send a message when an ad unit is created. + * @param {{ + * unitId: (string), + * shortId: (string), + * sizes: (!Array>), + * rotation: (string|boolean), + * visible: (boolean), + * width: (number), + * height: (number), + * }} params + */ + unitCreated(params) { + const msg = { + id: params.unitId, + shortId: params.shortId, + sizes: params.sizes, + configuration: null, + customTargeting: null, + rotation: params.rotation, + isFirstPrint: false, + isTracking: false, + visible: params.visible, + width: params.width, + height: params.height, + }; + this.sendIframeMessage_('adUnitChanged', msg); + } + + /** + * Send a message to change the banner + * @param {BrowserState} state + */ + engagementStatus(state) { + this.sendIframeMessage_('engagementStatusChanged', { + index: state, + name: BrowserState[state], + }); + } +} diff --git a/extensions/amp-ad-network-insurads-impl/0.1/messages.js b/extensions/amp-ad-network-insurads-impl/0.1/messages.js new file mode 100644 index 000000000000..74829c42d251 --- /dev/null +++ b/extensions/amp-ad-network-insurads-impl/0.1/messages.js @@ -0,0 +1,415 @@ +/** + * Base class for all message types + */ +export class BaseMessage { + /** + * @param {string} action - Message action + * @param {Object=} message - Message content + */ + constructor(action, message = {}) { + /** @public {string} */ + this.action = action; + + /** @public {Object} */ + this.message = message; + } + + /** + * Serializes the message to JSON + * @return {string} + */ + serialize() { + return [this.action, JSON.stringify(this.message)]; + } + + /** + * Creates a message from JSON + * @param {string} json - JSON string + * @return {?BaseMessage} + * @static + */ + static fromJson(json) { + try { + const parsed = JSON.parse(json); + return new BaseMessage(parsed.action, parsed.message); + } catch (e) { + return null; + } + } +} + +/** + * Handshake message for initial communication setup + */ +export class HandshakeMessage { + /** + * + */ + constructor() { + /** @public {Object} */ + this.message = {protocol: 'json', version: 1}; + } + + /** + * Serializes the message to JSON + * @return {string} + */ + serialize() { + return JSON.stringify(this.message) + '\u001e'; + } +} + +/** + * Application initialization message + */ +export class AppInitMessage extends BaseMessage { + /** + * @param {object} params + * @param {string} params.lockedId - Locked ID message + * @param {boolean} params.newVisitor - New Visitor + * @param {number} params.lastTimestamp - Last Time Stamp + * @param {boolean} params.extension - Extension Status + * @param {boolean} params.reconnect - Reconnect flag + */ + constructor({ + extension, + lastTimestamp, + lockedId, + newVisitor, + reconnect = false, + }) { + super('app-init', { + lockedId, + newVisitor, + lastTimestamp, + extension, + reconnect, + }); + } +} + +/** + * Ad unit initialization message + */ +export class UnitInitMessage extends BaseMessage { + /** + * @param {object} params + * @param {string} params.unitCode - Generated Ad Unit ID + * @param {string} params.creativeId - Creative ID + * @param {Array} params.keyValues - Key values for targeting + * @param {string} params.lineItemId - Line Item ID + * @param {number} params.parentMawId - Parent MAW ID + * @param {boolean} params.passback - Passback + * @param {string} params.path - Path of the Ad Unit + * @param {number} params.position - Waterfall Position + * @param {boolean} params.reconnect - reconnect + * @param {string} params.servedSize - Served Size + * @param {Array} params.sizes - Available sizes + * @param {boolean} params.isHouseDemand - Is House Demand + */ + constructor({ + creativeId, + isHouseDemand, + keyValues, + lineItemId, + parentMawId = 0, + passback = false, + path, + position, + reconnect = false, + servedSize, + sizes, + unitCode, + }) { + super('unit-init', { + unitCode, + path, + lineItemId, + creativeId, + servedSize, + sizes, + keyValues, + position, + parentMawId, + passback, + reconnect, + isHouseDemand, + }); + } +} + +/** + * Ad unit snapshot message for reporting current state + */ +export class UnitSnapshotMessage extends BaseMessage { + /** + * @param {string} unitCode - Ad unit ID + * @param {boolean} visible - Whether ad is considered visible + */ + constructor(unitCode, visible = {}) { + super('unit-snapshot', { + unitCode, + visible, + }); + } +} + +/** + * Application status update message + */ +export class PageStatusMessage extends BaseMessage { + /** + * @param {boolean} engagement - Engagement status + * @param {boolean} documentVisible - Whether the page is visible + */ + constructor(engagement, documentVisible) { + super('page-status', { + engagement, + documentVisible, + }); + } +} + +/** + * Application initialization response message + */ +export class AppInitResponseMessage extends BaseMessage { + /** + * @param {object} params - The parameters for the message. + * @param {number=} params.accountId - Account ID. + * @param {number=} params.applicationId - Application ID. + * @param {string=} params.countryCode - Country code. + * @param {boolean=} params.ivm - IntelliSense Viewability Mode. + * @param {object=} params.requiredKeys - Required Key values for targeting. + * @param {object=} params.iabTaxonomy - IAB Taxonomy. + * @param {string=} params.reason - Reason for the status. + * @param {number=} params.sectionId - Section ID for the app. + * @param {string=} params.serverTimestamp - The Server Timestamp + * @param {number=} params.status - Status of the app. + */ + constructor({ + accountId, + applicationId, + countryCode, + iabTaxonomy, + ivm, + reason, + requiredKeys, + sectionId, + serverTimestamp, + status, + }) { + const messagePayload = {}; + + if (accountId !== undefined) { + messagePayload.accountId = accountId; + } + if (applicationId !== undefined) { + messagePayload.applicationId = applicationId; + } + if (countryCode !== undefined) { + messagePayload.countryCode = countryCode; + } + if (ivm !== undefined) { + messagePayload.ivm = ivm; + } + if (requiredKeys !== undefined) { + messagePayload.requiredKeys = requiredKeys; + } + if (iabTaxonomy !== undefined) { + messagePayload.iabTaxonomy = iabTaxonomy; + } + if (status !== undefined) { + messagePayload.status = status; + } + if (reason !== undefined) { + messagePayload.reason = reason; + } + if (sectionId !== undefined) { + messagePayload.sectionId = sectionId; + } + if (serverTimestamp !== undefined) { + messagePayload.serverTimestamp = serverTimestamp; + } + + super('app-init-response', messagePayload); + } +} + +/** + * Unit initialization response message + */ +export class UnitInitResponseMessage extends BaseMessage { + /** + * @param {string} unitCode - Ad unit code identifier + * @param {string} unitId - Ad unit ID + */ + constructor(unitCode, unitId) { + super('unit-init-response', { + unitCode, + unitId, + }); + } +} + +/** + * Unit Waterfall message + */ +export class WaterfallMessage extends BaseMessage { + /** + * @param {object} params The parameters for the message. + * @param {string} params.unitCode The unique code for the ad unit. + * @param {!Array} params.entries The waterfall entries for different providers. + * @param {{[key: string]: string}=} params.commonKeyValues Key-values to be applied to all entries. + */ + constructor({commonKeyValues = {}, entries = [], unitCode}) { + super('unit-waterfall', { + unitCode, + entries, + commonKeyValues, + }); + } +} + +/** + * Represents a single entry in an ad unit's waterfall. + */ +export class WaterfallEntry { + /** + * @param {object} params The parameters for the entry. + * @param {number=} params.position The position of this entry in the waterfall. + * @param {string=} params.provider The ad provider for this entry (e.g., 'pgam'). + * @param {string=} params.path The ad unit path for this provider. + * @param {!Array>=} params.sizes The ad sizes for this entry. + * @param {{[key: string]: (string|Array)}=} params.keyValues Specific key-values for this entry. + * @param {{[key: string]: Object}=} params.vendors Vendor-specific data (e.g., for prebid). + * @param {boolean=} params.isHouseDemand - Is House Demand + */ + constructor({ + isHouseDemand = false, + keyValues = {}, + path = '', + position = 0, + provider = '', + sizes = [], + vendors = {}, + } = {}) { + /** @public {number} */ + this.position = position; + + /** @public {string} */ + this.provider = provider; + + /** @public {string} */ + this.path = path; + + /** @public {!Array>} */ + this.sizes = sizes; + + /** @public {!Object>} */ + this.keyValues = keyValues; + + /** @public {!Object} */ + this.vendors = vendors; + + /** @public {boolean} */ + this.isHouseDemand = isHouseDemand; + } +} + +/** + * Factory class to create appropriate message instances + */ +export class MessageFactory { + /** + * Creates a message of the appropriate action from raw message + * @param {string} action - Message action + * @param {object} message - Message message + * @return {BaseMessage} + */ + static createMessage(action, message) { + message = JSON.parse(message); + switch (action) { + case 'app-init-response': + return new AppInitResponseMessage(message); + + case 'unit-init-response': + return new UnitInitResponseMessage( + message.unitCode || 'unknown', + message.unitId || 'unknown' + ); + + case 'unit-waterfall': + const entries = (message.entries || []).map( + (entryData) => new WaterfallEntry(entryData) + ); + return new WaterfallMessage({ + unitCode: message.unitCode || 'unknown', + entries, + commonKeyValues: message.commonKeyValues || {}, + }); + default: + return new BaseMessage(action, message); + } + } + + /** + * Creates a message from a JSON string + * @param {string} json - JSON string + * @return {?BaseMessage} + */ + static fromJson(json) { + try { + const parsed = JSON.parse(json); + return this.createMessage(parsed.action, parsed.message); + } catch (e) { + return null; + } + } +} + +/** + * Message handler class to process incoming messages + */ +export class MessageHandler { + /** + * @param {Object=} options - Options object + * @param {Function=} options.appInitHandler - Handler for app init messages + * @param {Function=} options.unitInitHandler - Handler for unit init messages + * @param {Function=} options.waterfallHandler - Handler for unit waterfall messages + * @param {Function=} options.disconnectHandler - Handler for disconnect messages + */ + constructor({ + appInitHandler = null, + disconnectHandler = null, + unitInitHandler = null, + waterfallHandler = null, + } = {}) { + /** @private {Object} */ + this.handlers_ = { + 'app-init-response': appInitHandler, + 'unit-init-response': unitInitHandler, + 'waterfall': waterfallHandler, + 'disconnect': disconnectHandler, + }; + } + + /** + * Processes a pre-parsed message object. + * @param {?BaseMessage} messageObj The message object to handle. + * @return {boolean} Whether the message was handled. + */ + processMessage(messageObj) { + if (!messageObj) { + return false; + } + + const handler = this.handlers_[messageObj.action]; + if (handler && typeof handler === 'function') { + handler(messageObj.message); + return true; + } + + return false; + } +} diff --git a/extensions/amp-ad-network-insurads-impl/0.1/realtime-manager.js b/extensions/amp-ad-network-insurads-impl/0.1/realtime-manager.js new file mode 100644 index 000000000000..5bfd269535e8 --- /dev/null +++ b/extensions/amp-ad-network-insurads-impl/0.1/realtime-manager.js @@ -0,0 +1,349 @@ +/** @type {string} */ +const HUB_URL_TEMPLATE = + 'wss://amp-messaging.insurads.com/rt-pub/node2/hub?pid=$PUBLICID$&ht=$HT$&v=$V$&url=$URL$'; +/** @type {number} */ +const HUB_TYPE_AMP = 2; +/** @type {number} */ +const MSG_VERSION = 1.0; + +/** @type {number} */ +const MAX_RETRIES = 3; +/** @type {number} */ +const RETRY_DELAY = 1000; + +export class RealtimeManager { + /** @private {?RealtimeManager} */ + static instance_ = null; + + /** @private {?WebSocket} */ + ws = null; + + /** @private {string} */ + publicId_ = ''; + + /** @private {string} */ + canonicalUrl_ = ''; + + /** @private {boolean} */ + handshakeComplete_ = false; + + /** @private {Array} */ + messageQueue_ = []; + + // Bound event handlers + /** @private {?function} */ + boundOnOpen_ = null; + /** @private {?function} */ + boundOnDisconnect_ = null; + /** @private {?function} */ + boundOnError_ = null; + /** @private {?function} */ + boundOnReceiveMessage_ = null; + + // Events and callbacks + /** @private {?function():void} */ + onConnect = null; + /** @private {?function():void} */ + onFailedConnect = null; + /** @private {?function():void} */ + onDisconnect = null; + /** @private {?function(string):void} */ + onReceiveMessage = null; + + // Retry logic + /** @private {number} */ + retryCount_ = 0; + /** @private {?number} */ + retryTimer_ = null; + + /** + * Creates a new RealtimeManager + * @param {string=} publicId - Optional public ID + * @param {string=} canonicalUrl - Optional canonical URL + */ + constructor(publicId = '', canonicalUrl = '') { + if (publicId) { + this.publicId_ = publicId; + } + if (canonicalUrl) { + this.canonicalUrl_ = canonicalUrl; + } + + this.boundOnOpen_ = this.onOpen_.bind(this); + this.boundOnDisconnect_ = this.onDisconnect_.bind(this); + this.boundOnError_ = this.onError_.bind(this); + this.boundOnReceiveMessage_ = this.onReceiveMessage_.bind(this); + } + + /** + * Returns the singleton instance of RealtimeManager. + * @param {string} publicId - The public ID + * @param {string} canonicalUrl - The canonical URL + * @return {!RealtimeManager} + * @public + */ + static start(publicId, canonicalUrl) { + if (!RealtimeManager.instance_) { + RealtimeManager.instance_ = new RealtimeManager(publicId, canonicalUrl); + } + + if (!RealtimeManager.instance_.isConnected()) { + RealtimeManager.instance_.connect(); + } + + return RealtimeManager.instance_; + } + + /** + * Checks if the WebSocket is connected. + * @return {boolean} + * @public + * */ + isConnected() { + return this.ws && this.ws.readyState === WebSocket.OPEN; + } + + /** + * Initializes event handlers for WebSocket events + * @private + * */ + onOpen_() { + this.clearRetryTimer_(); + + if (this.onConnect) { + this.onConnect(); + } + } + + /** + * Handles connection closed event + * @param {CloseEvent} event - The close event + * @private + */ + onDisconnect_(event) { + if ( + !this.retryTimer_ && + this.retryCount_ < MAX_RETRIES && + event.code !== 1000 + ) { + this.retryCount_++; + this.retryTimer_ = setTimeout(() => { + this.connect(); + }, RETRY_DELAY); + return; + } + + if (this.retryTimer_) { + clearTimeout(this.retryTimer_); + this.retryTimer_ = null; + } + + if (this.onDisconnect) { + this.onDisconnect(event); + } + + this.handshakeComplete_ = false; + this.ws = null; + } + + /** + * Handles WebSocket error events + * @param {Event} event - The error event + * @private + * */ + onError_(event) { + if (this.onFailedConnect) { + this.onFailedConnect(event); + } + } + + /** + * Handles incoming messages from the WebSocket + * @param {MessageEvent} event - The message event + * @private + * */ + onReceiveMessage_(event) { + if (this.onReceiveMessage) { + this.onReceiveMessage(event.data); + } + } + + /** + * Handles the completion of the handshake + * @private + * */ + onHandshakeComplete_() { + this.handshakeComplete_ = true; + + // Process any queued messages now that handshake is complete + this.processQueuedMessages_(); + } + + /** + * Process all queued messages after handshake completes + * @private + */ + processQueuedMessages_() { + while (this.messageQueue_.length > 0) { + const queuedMessage = this.messageQueue_.shift(); + this.sendImmediately_(queuedMessage); + } + } + + /** + * Connect to the WebSocket + * @return {boolean} - True if connection was initiated, false otherwise + * @public + */ + connect() { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + return true; + } + + try { + if (!this.publicId_ || !this.canonicalUrl_) { + return false; + } + + const url = HUB_URL_TEMPLATE.replace('$PUBLICID$', this.publicId_) + .replace('$HT$', HUB_TYPE_AMP) + .replace('$V$', MSG_VERSION) + .replace('$URL$', encodeURIComponent(this.canonicalUrl_)); + + this.ws = new WebSocket(url); + + this.ws.addEventListener('open', this.boundOnOpen_); + this.ws.addEventListener('close', this.boundOnDisconnect_); + this.ws.addEventListener('error', this.boundOnError_); + this.ws.addEventListener('message', this.boundOnReceiveMessage_); + + this.clearRetryTimer_(); + + return true; + } catch (e) { + this.clearRetryTimer_(); + return false; + } + } + + /** + * Disconnect the WebSocket + * @param {boolean=} clearQueue - Whether to clear the message queue (default: true) + * @param {number=} code - Optional close code (default: 1000 - normal closure) + * @param {string=} reason - Optional reason for closing + * @public + */ + disconnect(clearQueue = true, code = 1000, reason = 'AMP is going away') { + if (!this.ws) { + this.handshakeComplete_ = false; + return; + } + + try { + this.handshakeComplete_ = false; + + if (clearQueue) { + this.messageQueue_ = []; + } + + if ( + this.ws.readyState === WebSocket.OPEN || + this.ws.readyState === WebSocket.CONNECTING + ) { + this.ws.close(code, reason); + } + + this.ws.removeEventListener('open', this.boundOnOpen_); + this.ws.removeEventListener('close', this.boundOnDisconnect_); + this.ws.removeEventListener('error', this.boundOnError_); + this.ws.removeEventListener('message', this.boundOnReceiveMessage_); + + this.onConnect = null; + this.onFailedConnect = null; + this.onDisconnect = null; + this.onReceiveMessage = null; + + this.ws = null; + } catch (e) { + return; + } + } + + /** + * Sends a message through the WebSocket connection + * @param {!Object} message - The message to send + * @return {boolean} - True if message was sent or queued, false otherwise + */ + send(message) { + if (!this.handshakeComplete_) { + this.messageQueue_.push(message); + + return true; + } + + return this.sendImmediately_(message); + } + + /** + * Sends a handshake message to initialize the connection + * @param {string=} message - handshake + * @return {boolean} - True if handshake was sent, false otherwise + */ + sendHandshake(message) { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + return false; + } + + try { + this.ws.send(message); + + this.onHandshakeComplete_(); + + return true; + } catch (e) { + return false; + } + } + + /** + * Immediately sends a message without checking handshake status + * @param {!Object} message - The message to send + * @return {boolean} - True if message was sent, false otherwise + * @private + */ + sendImmediately_(message) { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + return false; + } + + try { + const payload = { + arguments: message, + target: 'SendMessage', + type: 1, + }; + this.ws.send(JSON.stringify(payload) + '\u001e'); + return true; + } catch (e) { + return false; + } + } + + /** + * Clears the retry timer and resets retry count + * @private + */ + clearRetryTimer_() { + if (this.retryTimer_) { + clearTimeout(this.retryTimer_); + this.retryTimer_ = null; + } + } + + /** + * Clean up resources and reset the singleton + */ + destroy() { + RealtimeManager.instance_ = null; + } +} diff --git a/extensions/amp-ad-network-insurads-impl/0.1/utilities.js b/extensions/amp-ad-network-insurads-impl/0.1/utilities.js new file mode 100644 index 000000000000..40fddeeeeecf --- /dev/null +++ b/extensions/amp-ad-network-insurads-impl/0.1/utilities.js @@ -0,0 +1,146 @@ +import {stringHash32} from '#core/types/string'; + +export class LockedId { + /** + * Get Hash + * controlHash is the hash returned from server that contains the public ip address of the client + * @param {boolean} allowStorage + * @return {!Array<(string|number)>} + */ + getLockedIdData(allowStorage = false) { + const keyParts = [ + this.getTimeZone_(), + this.getCanvasPrint_(allowStorage), + this.getGPUInfo_(allowStorage), + navigator.language, + navigator.languages.join(','), + navigator.systemLanguage || window.navigator.language, + navigator.hardwareConcurrency, + ]; + return keyParts; + } + + /** + * Get Time Zone. Return a string containing the time zone. + * @return {string} + * @private + */ + getTimeZone_() { + const rightNow = new Date(); + let myNumber, formattedNumber, result; + myNumber = String(-(rightNow.getTimezoneOffset() / 60)); + if (myNumber < 0) { + myNumber = myNumber * -1; + formattedNumber = ('0' + myNumber).slice(-2); + result = '-' + formattedNumber; + } else { + formattedNumber = ('0' + myNumber).slice(-2); + result = '+' + formattedNumber; + } + return result; + } + + /** + * Get Canvas Print. Return a string containing the canvas URI data. + * @param {boolean} allowStorage + * @return {string} + * @private + * */ + getCanvasPrint_(allowStorage = false) { + if (!allowStorage) { + return ''; + } + const canvas = document.createElement('canvas'); + let ctx; + + try { + ctx = canvas.getContext('2d'); + } catch (e) { + return ''; + } + + const txt = 'IaID,org 1.0'; + ctx.textBaseline = 'top'; + ctx.font = "14px 'Arial'"; + ctx.textBaseline = 'alphabetic'; + ctx.fillStyle = '#f60'; + ctx.fillRect(125, 1, 62, 20); + ctx.fillStyle = '#069'; + ctx.fillText(txt, 2, 15); + ctx.fillStyle = 'rgba(102, 204, 0, 0.7)'; + ctx.fillText(txt, 4, 17); + return stringHash32(canvas.toDataURL()); + } + + /** + * Get GPU Info. Return a string containing the GPU vendor and renderer. + * @param {boolean} allowStorage + * @return {string} + * @private + */ + getGPUInfo_(allowStorage = false) { + if (!allowStorage) { + return ''; + } + + const canvas = document.createElement('canvas'); + let gl; + let debugInfo; + let vendor; + let renderer; + + try { + gl = + canvas.getContext('webgl') || canvas.getContext('experimental-webgl'); + } catch (e) {} + + if (gl) { + debugInfo = gl.getExtension('WEBGL_debug_renderer_info'); + vendor = gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL); + renderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL); + } + return vendor + '~' + renderer; + } +} + +export class CryptoUtils { + /** + * Generates a random code. + * @return {string} + */ + static generateCode() { + return this.generate_(11).toLowerCase(); + } + + /** + * Generates an impression id. + * @return {string} + */ + static generateImpressionId() { + return this.generate_(43).toLowerCase(); + } + + /** + * Generates a session id. + * @return {string} + */ + static generateSessionId() { + return this.generate_(16).toUpperCase(); + } + + /** + * Generates a random string. + * @param {number} length + * @return {string} + * @private + */ + static generate_(length) { + let text = ''; + const charSet = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + for (let i = 0; i < length; i++) { + text += charSet.charAt(Math.floor(Math.random() * charSet.length)); + } + return text; + } +} diff --git a/extensions/amp-ad-network-insurads-impl/0.1/visibility-tracking.js b/extensions/amp-ad-network-insurads-impl/0.1/visibility-tracking.js new file mode 100644 index 000000000000..1adaded32653 --- /dev/null +++ b/extensions/amp-ad-network-insurads-impl/0.1/visibility-tracking.js @@ -0,0 +1,86 @@ +/** + * Handles visibility tracking for ad elements using IntersectionObserver + */ +export class VisibilityTracker { + /** + * @param {!Window} win - Window object + * @param {!Element} element - Element to track + * @param {Function=} onVisibilityChange - Optional callback for visibility changes + */ + constructor(win, element, onVisibilityChange = null) { + /** @private {!Window} */ + this.win_ = win; + + /** @private {!Element} */ + this.element_ = element; + + /** @private {Function|null} */ + this.onVisibilityChange_ = onVisibilityChange; + + /** @private {number} */ + this.visibilityPercentage_ = 0; + + /** @private {?IntersectionObserver} */ + this.visibilityObserver_ = null; + + this.setupVisibilityTracking_(); + } + + /** + * Sets up visibility tracking using IntersectionObserver + * @private + */ + setupVisibilityTracking_() { + const thresholds = [0, 0.5, 1]; + + this.visibilityObserver_ = new this.win_.IntersectionObserver( + (entries) => this.handleVisibilityChange_(entries), + { + threshold: thresholds, + } + ); + + this.visibilityObserver_.observe(this.element_); + } + + /** + * Handles intersection changes reported by the IntersectionObserver + * @param {!Array} entries + * @private + */ + handleVisibilityChange_(entries) { + entries.forEach((entry) => { + const previousVisibility = this.visibilityPercentage_; + this.visibilityPercentage_ = entry.intersectionRatio; + + const visibilityChanged = + Math.abs(this.visibilityPercentage_ - previousVisibility) >= 0.1; + if (visibilityChanged) { + const visibilityData = { + visibilityPercentage: this.visibilityPercentage_, + isVisible: this.visibilityPercentage_ > 0, + isViewable: this.visibilityPercentage_ >= 0.5, + isFullyVisible: this.visibilityPercentage_ >= 0.9, + visibilityChange: this.visibilityPercentage_ - previousVisibility, + boundingClientRect: entry.boundingClientRect, + intersectionRect: entry.intersectionRect, + timestamp: Date.now(), + }; + + if (this.onVisibilityChange_) { + this.onVisibilityChange_(visibilityData); + } + } + }); + } + + /** + * Clean up the observer when no longer needed + */ + destroy() { + if (this.visibilityObserver_) { + this.visibilityObserver_.disconnect(); + this.visibilityObserver_ = null; + } + } +} diff --git a/extensions/amp-ad-network-insurads-impl/0.1/waterfall.js b/extensions/amp-ad-network-insurads-impl/0.1/waterfall.js new file mode 100644 index 000000000000..f6b96f3b69aa --- /dev/null +++ b/extensions/amp-ad-network-insurads-impl/0.1/waterfall.js @@ -0,0 +1,87 @@ +/** + * Represents a single entry in the ad waterfall. + */ +class WaterfallEntry { + /** + * @param {object} params The parameters for the entry. + * @param {number} params.position + * @param {string} params.provider + * @param {string} params.path + * @param {!Array>} params.sizes + * @param {{[key: string]: (string|!Array)}} params.keyValues + * @param {{[key: string]: !Object}} params.vendors + * @param {boolean} params.isHouseDemand + */ + constructor({ + isHouseDemand = false, + keyValues = {}, + path = '', + position = 0, + provider = '', + sizes = [], + vendors = {}, + }) { + this.position = position; + this.provider = provider; + this.path = path; + this.sizes = sizes; + this.keyValues = keyValues; + this.vendors = vendors; + this.isHouseDemand = isHouseDemand; + } +} + +/** + * Waterfall model that keeps data for the next ad refresh. + */ +export class Waterfall { + /** + * @param {string} unitCode + * @param {!Array} entries + * @param {{[key: string]: string}} commonKeyValues + */ + constructor(unitCode, entries, commonKeyValues) { + /** @public {string} */ + this.unitCode = unitCode; + + /** @private {!Array} */ + this.entries_ = entries.sort((a, b) => a.position - b.position); + + /** @public {{[key: string]: string}} */ + this.commonKeyValues = commonKeyValues; + + /** @private {number} The index of the next entry to use. */ + this.currentIndex_ = 0; + } + + /** + * Gets the currently active waterfall entry. + * @return {?WaterfallEntry} + */ + getCurrentEntry() { + return this.entries_[this.currentIndex_] || null; + } + + /** + * Advances the waterfall to the next entry. + * @return {?WaterfallEntry} The entry at the current index before incrementing, or null if exhausted. + */ + getNextEntry() { + return this.entries_[this.currentIndex_++] || null; + } + + /** + * Creates a Waterfall instance from a WaterfallMessage. + * @param {!WaterfallMessage} waterfallMessage The message to process. + * @return {!Waterfall} + */ + static fromWaterfallMessage(waterfallMessage) { + const {commonKeyValues, entries, unitCode} = waterfallMessage.message; + + const waterfallEntries = (entries || []).map( + (entryData) => new WaterfallEntry(entryData) + ); + + return new Waterfall(unitCode, waterfallEntries, commonKeyValues); + } +} diff --git a/extensions/amp-ad-network-insurads-impl/OWNERS b/extensions/amp-ad-network-insurads-impl/OWNERS new file mode 100644 index 000000000000..08283ce0b794 --- /dev/null +++ b/extensions/amp-ad-network-insurads-impl/OWNERS @@ -0,0 +1,14 @@ +// For an explanation of the OWNERS rules and syntax, see: +// https://github.com/ampproject/amp-github-apps/blob/main/owners/OWNERS.example + +{ + rules: [ + { + owners: [ + {name: 'ampproject/wg-ads-reviewers'}, + {name: 'joseclimaco'}, + {name: 'aribeiro-insurads'}, + ], + }, + ], +} diff --git a/extensions/amp-ad-network-insurads-impl/amp-ad-network-insurads-impl-internal.md b/extensions/amp-ad-network-insurads-impl/amp-ad-network-insurads-impl-internal.md new file mode 100644 index 000000000000..6defc20aed12 --- /dev/null +++ b/extensions/amp-ad-network-insurads-impl/amp-ad-network-insurads-impl-internal.md @@ -0,0 +1,61 @@ +# InsurAds _Temporary_ + +## Example + +### Basic sample + +```html + + +``` + +### Sample with multisize + +```html + + +``` + +### Sample with targeting + +```html + + +``` + +## Configuration + +For details on the configuration semantics, please contact the ad network or refer to their documentation. + +## Supported parameters + +| Parameter name | Description | Required | +| --------------- | ----------------------------------- | -------- | +| width | Primary size width | Yes | +| height | Primary size height | Yes | +| data-public-id | Application public id | Yes | +| data-slot | Ad unit code | Yes | +| data-multi-size | Comma separated list of other sizes | No | +| json | Custom targeting map | No | + +Note: if any of the required parameters is not present, the ad slot will not be filled. diff --git a/extensions/amp-ad/0.1/amp-ad.css b/extensions/amp-ad/0.1/amp-ad.css index 7a0cb1791c8c..3682300393d4 100644 --- a/extensions/amp-ad/0.1/amp-ad.css +++ b/extensions/amp-ad/0.1/amp-ad.css @@ -41,24 +41,28 @@ amp-ad[sticky] { } amp-ad[type='adsense'], -amp-ad[type='doubleclick'] { +amp-ad[type='doubleclick'], +amp-ad[type='insurads'] { + direction: ltr; } amp-ad[data-a4a-upgrade-type='amp-ad-network-doubleclick-impl'] > iframe, -amp-ad[data-a4a-upgrade-type='amp-ad-network-adsense-impl'] > iframe { +amp-ad[data-a4a-upgrade-type='amp-ad-network-adsense-impl'] > iframe, +amp-ad[data-a4a-upgrade-type='amp-ad-network-insurads-impl'] > iframe { min-height: 0; min-width: 0; } -amp-ad[data-a4a-upgrade-type='amp-ad-network-doubleclick-impl'][height='fluid'] - > iframe { +amp-ad[data-a4a-upgrade-type='amp-ad-network-doubleclick-impl'][height='fluid'] > iframe, +amp-ad[data-a4a-upgrade-type='amp-ad-network-insurads-impl'][height='fluid'] > iframe { height: 100% !important; width: 100% !important; position: relative; } -amp-ad[data-a4a-upgrade-type='amp-ad-network-doubleclick-impl'][height='fluid'] { +amp-ad[data-a4a-upgrade-type='amp-ad-network-doubleclick-impl'][height='fluid'], +amp-ad[data-a4a-upgrade-type='amp-ad-network-insurads-impl'][height='fluid'] { width: 100% !important; } diff --git a/extensions/amp-story-auto-ads/0.1/story-ad-config.js b/extensions/amp-story-auto-ads/0.1/story-ad-config.js index b97a3c0b6142..210f70203b1f 100644 --- a/extensions/amp-story-auto-ads/0.1/story-ad-config.js +++ b/extensions/amp-story-auto-ads/0.1/story-ad-config.js @@ -21,6 +21,7 @@ const AllowedAdTypes = { 'adsense': true, 'custom': true, 'doubleclick': true, + 'insurads': true, 'fake': true, 'nws': true, 'mgid': true,