From 67af9f766114782181c03fa017f773406d012e61 Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 26 Aug 2026 17:58:52 -0500 Subject: [PATCH] Arbitrate GPT first impressions and resize PUC shells --- .../src/integrations/gpt.rs | 10 +- .../src/integrations/gpt_bootstrap.js | 288 ++++- .../browser/package-lock.json | 1065 ++++++++++++++++- .../browser/package.json | 3 +- .../browser/tests/shared/aps-renderer.spec.ts | 146 +++ .../lib/src/core/first_impression.ts | 358 ++++++ .../trusted-server-js/lib/src/core/types.ts | 38 + .../lib/src/integrations/gpt/index.ts | 415 ++++++- .../lib/src/integrations/prebid/index.ts | 251 +++- .../lib/test/integrations/gpt/ad_init.test.ts | 269 ++++- .../integrations/gpt/gpt_bootstrap.test.ts | 59 + .../test/integrations/prebid/index.test.ts | 56 + docs/guide/integrations/aps.md | 4 +- ...6-04-15-server-side-ad-templates-design.md | 10 +- ...vent-duplicate-gpt-slot-requests-design.md | 35 +- 15 files changed, 2843 insertions(+), 164 deletions(-) create mode 100644 crates/trusted-server-js/lib/src/core/first_impression.ts diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index 84158c27e..9a0905455 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -1246,12 +1246,16 @@ mod tests { "should set ts_initial sentinel" ); assert!( - !combined.contains("addEventListener(\"slotRenderEnded\""), - "inline bootstrap cannot prove TS creative rendering from GPT slotRenderEnded" + combined.contains("addEventListener(\"slotRequested\""), + "should observe publisher GPT requests before delayed adInit" + ); + assert!( + combined.contains("addEventListener(\"slotRenderEnded\""), + "should observe publisher GPT renders before delayed adInit" ); assert!( !combined.contains("sendBeacon"), - "inline bootstrap must not fire win/billing beacons from GPT slotRenderEnded" + "inline bootstrap lifecycle ownership must not fire win/billing beacons" ); assert!( !combined.contains("getTargeting(\"hb_adid\")"), diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index 2475c5082..883848509 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -102,6 +102,137 @@ pubads.__tsInitialLoadHooked = true; }); + var FIRST_IMPRESSION_LEASE_MS = 5000; + + function firstImpressionState(now) { + var generation = ts.navGeneration || 0; + if ( + !ts.firstImpression || + ts.firstImpression.generation !== generation + ) { + ts.firstImpression = { + generation: generation, + nextToken: 0, + slots: {}, + fallbackSlots: {}, + }; + } + var state = ts.firstImpression; + state.slots = state.slots || {}; + state.fallbackSlots = state.fallbackSlots || {}; + Object.keys(state.slots).forEach(function (elementId) { + var claim = state.slots[elementId]; + if ( + claim.generation !== generation || + claim.slotElementId !== elementId || + claim.element !== document.getElementById(elementId) || + !claim.element.isConnected + ) { + delete state.slots[elementId]; + return; + } + Object.keys(claim.publisherAuctions || {}).forEach(function (token) { + if (claim.publisherAuctions[token].expiresAt <= now) { + delete claim.publisherAuctions[token]; + } + }); + if ( + claim.owner === "publisher" && + (claim.phase === "auctioning" || claim.phase === "delivery_pending") && + Object.keys(claim.publisherAuctions || {}).length === 0 && + claim.expiresAt <= now + ) { + delete state.slots[elementId]; + } + }); + Object.keys(state.fallbackSlots).forEach(function (elementId) { + var element = state.fallbackSlots[elementId]; + if ( + !element.isConnected || + element.id !== elementId || + document.getElementById(elementId) !== element + ) { + delete state.fallbackSlots[elementId]; + } + }); + return state; + } + + function firstImpressionClaim(element) { + return firstImpressionState(Date.now()).slots[element.id]; + } + + function claimFirstImpressionForTrustedServer(element) { + var now = Date.now(); + var state = firstImpressionState(now); + if (state.slots[element.id]) return null; + var claim = { + generation: state.generation, + slotElementId: element.id, + element: element, + owner: "trusted_server", + phase: "delivery_pending", + expiresAt: now + FIRST_IMPRESSION_LEASE_MS, + publisherAuctions: {}, + }; + state.slots[element.id] = claim; + return claim; + } + + function releaseTrustedServerFirstImpressionClaim(element, claim) { + var state = firstImpressionState(Date.now()); + if ( + state.slots[element.id] === claim && + claim.owner === "trusted_server" && + claim.phase === "delivery_pending" && + Object.keys(claim.publisherAuctions || {}).length === 0 + ) { + delete state.slots[element.id]; + } + } + + function installFirstImpressionListeners() { + if (ts.firstImpressionListenersInstalled) return; + tag.cmd.push(function () { + if (ts.firstImpressionListenersInstalled) return; + var pubads = window.googletag.pubads(); + if (!pubads || typeof pubads.addEventListener !== "function") return; + var observe = function (phase) { + return function (event) { + var elementId = + event.slot && event.slot.getSlotElementId + ? event.slot.getSlotElementId() + : ""; + var element = elementId && document.getElementById(elementId); + if (!element) return; + var state = firstImpressionState(Date.now()); + var claim = state.slots[elementId]; + if (!claim) { + claim = state.slots[elementId] = { + generation: state.generation, + slotElementId: elementId, + element: element, + owner: "publisher", + phase: phase, + expiresAt: Number.POSITIVE_INFINITY, + publisherAuctions: {}, + }; + } else { + claim.phase = phase; + if (claim.owner === "publisher") { + claim.expiresAt = Number.POSITIVE_INFINITY; + } + } + }; + }; + pubads.addEventListener("slotRequested", observe("requested")); + pubads.addEventListener("slotRenderEnded", observe("rendered")); + ts.firstImpressionListenersInstalled = true; + }); + } + + installFirstImpressionListeners(); + // Minimal fallback for tsjs.scheduleInitialAdInit, mirroring the bundle's // hydration-safe scheduler in // crates/trusted-server-js/lib/src/integrations/gpt/index.ts: the @@ -412,6 +543,129 @@ installSlotHandoff(); + function bootstrapTargeting(slot, bid) { + var targeting = Object.assign({}, slot.targeting || {}); + ["hb_pb", "hb_bidder", "hb_adid", "hb_cache_host", "hb_cache_path"].forEach( + function (key) { + if (bid[key]) targeting[key] = String(bid[key]); + }, + ); + targeting.ts_initial = "1"; + return targeting; + } + + function scheduleFirstImpressionFallback(slot, bid, element, generation) { + var state = firstImpressionState(Date.now()); + if (state.fallbackSlots[element.id]) return; + state.fallbackSlots[element.id] = element; + + var retry = function () { + if ( + (ts.navGeneration || 0) !== generation || + !element.isConnected || + document.getElementById(element.id) !== element + ) { + return; + } + var claim = firstImpressionClaim(element); + if (claim) { + if ( + claim.owner !== "publisher" || + claim.phase === "requested" || + claim.phase === "rendered" + ) { + return; + } + var delay = Math.max(0, claim.expiresAt - Date.now()); + if (delay > 0) { + window.setTimeout(retry, delay + 1); + return; + } + } + + tag.cmd.push(function () { + if ( + (ts.navGeneration || 0) !== generation || + !element.isConnected || + document.getElementById(element.id) !== element + ) { + return; + } + var fallbackClaim = claimFirstImpressionForTrustedServer(element); + if (!fallbackClaim) return; + var pubads = window.googletag.pubads(); + var existingSlots = pubads.getSlots ? pubads.getSlots() : []; + var gptSlot = + existingSlots.find(function (candidate) { + return candidate.getSlotElementId() === element.id; + }) || null; + var tsOwned = false; + if (!gptSlot) { + gptSlot = runHandoffInternal(function () { + return window.googletag.defineSlot( + slot.gam_unit_path, + slot.formats, + element.id, + ); + }); + if (!gptSlot) { + releaseTrustedServerFirstImpressionClaim(element, fallbackClaim); + return; + } + gptSlot.addService(pubads); + tsOwned = true; + ts.gptSlotHandoffs = ts.gptSlotHandoffs || {}; + ts.gptSlotHandoffs[element.id] = { + gamUnitPath: slot.gam_unit_path, + formats: slot.formats, + divIdPrefix: slot.div_id, + slotElementId: element.id, + publisherClaimed: false, + suppressPublisherDisplay: false, + suppressPublisherRefresh: false, + }; + } + + var targeting = bootstrapTargeting(slot, bid); + Object.entries(targeting).forEach(function (entry) { + gptSlot.setTargeting(entry[0], entry[1]); + }); + fallbackClaim.targeting = targeting; + var slotElementId = gptSlot.getSlotElementId() || element.id; + ts.divToSlotId = ts.divToSlotId || {}; + ts.divToSlotId[element.id] = slot.id; + ts.divToSlotId[slotElementId] = slot.id; + if (tsOwned) { + ts.prevGptSlots = ts.prevGptSlots || []; + ts.prevGptSlots.push(gptSlot); + } + if (!ts.servicesEnabled) { + pubads.enableSingleRequest(); + window.googletag.enableServices(); + ts.servicesEnabled = true; + } + if (tsOwned) { + runHandoffInternal(function () { + window.googletag.display(slotElementId); + }); + } + syncInitialLoadDisabled(window.googletag); + if (!tsOwned || ts.gptInitialLoadDisabled) { + ts.adInitRefreshInProgress = true; + try { + runHandoffInternal(function () { + pubads.refresh([gptSlot]); + }); + } finally { + ts.adInitRefreshInProgress = false; + } + } + }); + }; + + retry(); + } + ts.adInit = function () { var slots = ts.adSlots || []; var bids = ts.bids || {}; @@ -476,6 +730,14 @@ } var actualDivId = el.id; var b = bids[slot.id] || {}; + var tsClaim = claimFirstImpressionForTrustedServer(el); + if (!tsClaim) { + var currentClaim = firstImpressionClaim(el); + if (currentClaim && currentClaim.owner === "publisher") { + scheduleFirstImpressionFallback(slot, b, el, generation); + } + return; + } var existingSlots = googletag.pubads().getSlots(); var s = @@ -493,7 +755,10 @@ actualDivId, ); }); - if (!s) return; + if (!s) { + releaseTrustedServerFirstImpressionClaim(el, tsClaim); + return; + } s.addService(googletag.pubads()); tsOwned = true; ts.gptSlotHandoffs = ts.gptSlotHandoffs || {}; @@ -508,20 +773,11 @@ }; } - Object.entries(slot.targeting || {}).forEach(function (e) { - s.setTargeting(e[0], e[1]); - }); - [ - "hb_pb", - "hb_bidder", - "hb_adid", - "hb_cache_host", - "hb_cache_path", - ].forEach(function (k) { - if (b[k]) s.setTargeting(k, b[k]); + var targeting = bootstrapTargeting(slot, b); + Object.entries(targeting).forEach(function (entry) { + s.setTargeting(entry[0], entry[1]); }); - // Keep in sync with TS_INITIAL_TARGETING_KEY in index.ts - s.setTargeting("ts_initial", "1"); + tsClaim.targeting = targeting; // Map the resolved inner div to the slot ID. This bootstrap fires no // beacons and registers no slotRenderEnded listener; the map is consumed // by the bundle's render bridge (index.ts) once it loads. @@ -540,7 +796,9 @@ }); ts.prevGptSlots = newSlots; ts.divToSlotId = divToSlotId; - if (!ts.servicesEnabled) { + var hasRenderableWork = + slotsToDisplay.length > 0 || slotsToRefresh.length > 0; + if (!ts.servicesEnabled && hasRenderableWork) { googletag.pubads().enableSingleRequest(); googletag.enableServices(); ts.servicesEnabled = true; diff --git a/crates/trusted-server-integration-tests/browser/package-lock.json b/crates/trusted-server-integration-tests/browser/package-lock.json index 39b512a1d..00f5a6d07 100644 --- a/crates/trusted-server-integration-tests/browser/package-lock.json +++ b/crates/trusted-server-integration-tests/browser/package-lock.json @@ -8,7 +8,18 @@ "name": "integration-tests-browser", "version": "1.0.0", "devDependencies": { - "@playwright/test": "^1.49.0" + "@playwright/test": "^1.49.0", + "prebid-universal-creative": "1.17.2" + } + }, + "node_modules/@gulpjs/messages": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@gulpjs/messages/-/messages-1.1.0.tgz", + "integrity": "sha512-Ys9sazDatyTgZVb4xPlDufLweJ/Os2uHWOv+Caxvy2O85JcnT4M3vc73bi8pdLWlv3fdWQz3pdI9tVwo8rQQSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" } }, "node_modules/@playwright/test": { @@ -27,6 +38,308 @@ "node": ">=18" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/array-each": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-each/-/array-each-1.0.1.tgz", + "integrity": "sha512-zHjL5SZa68hkKHBFBK6DJCTtr9sfTCPCaph/L7tMSLcTFgy+zX7E+6q5UArbtOtMBCtxdICpfTCspRse+ywyXA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-slice": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/array-slice/-/array-slice-1.1.0.tgz", + "integrity": "sha512-B1qMD3RBP7O8o0H2KbrXDyB0IccejMF15+87Lvlor12ONPRHP6gTjXMNkt/d3ZuOGbAe66hFmaCfECI24Ufp6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/babel-plugin-transform-object-assign": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-object-assign/-/babel-plugin-transform-object-assign-6.22.0.tgz", + "integrity": "sha512-N6Pddn/0vgLjnGr+mS7ttlFkQthqcnINE9EMOxB0CF8F4t6kuJXz6NUeLfSoRbLmkGh0mgDs9i2isdaZj0Ghtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-runtime": "^6.22.0" + } + }, + "node_modules/babel-runtime": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", + "integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-js": "^2.4.0", + "regenerator-runtime": "^0.11.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/copy-props": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/copy-props/-/copy-props-4.0.0.tgz", + "integrity": "sha512-bVWtw1wQLzzKiYROtvNlbJgxgBYt2bMJpkCbKmXM3xyijvcjjWXEk5nyrrT3bgJ7ODb19ZohE2T0Y3FgNPyoTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "each-props": "^3.0.0", + "is-plain-object": "^5.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/core-js": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", + "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==", + "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.", + "dev": true, + "hasInstallScript": true, + "license": "MIT" + }, + "node_modules/detect-file": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz", + "integrity": "sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/each-props": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/each-props/-/each-props-3.0.0.tgz", + "integrity": "sha512-IYf1hpuWrdzse/s/YJOrFmU15lyhSzxelNVAHTEG3DtP4QsLTWZUzcUL3HMXmKQxXpa4EIrBPpwRgj0aehdvAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^5.0.0", + "object.defaults": "^1.1.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/expand-tilde": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz", + "integrity": "sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "homedir-polyfill": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/findup-sync": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-5.0.0.tgz", + "integrity": "sha512-MzwXju70AuyflbgeOhzvQWAvvQdo1XL0A9bVvlXsYcFEBM87WR4OakL4OfZq+QRmr+duJubio+UtNQCPsVESzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-file": "^1.0.0", + "is-glob": "^4.0.3", + "micromatch": "^4.0.4", + "resolve-dir": "^1.0.1" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/fined": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fined/-/fined-2.0.0.tgz", + "integrity": "sha512-OFRzsL6ZMHz5s0JrsEr+TpdGNCtrVtnuG3x1yzGNiQHT0yaDnXAj8V/lWcpJVrnoDpcwXcASxAZYbuXda2Y82A==", + "dev": true, + "license": "MIT", + "dependencies": { + "expand-tilde": "^2.0.2", + "is-plain-object": "^5.0.0", + "object.defaults": "^1.1.0", + "object.pick": "^1.3.0", + "parse-filepath": "^1.0.2" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/flagged-respawn": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/flagged-respawn/-/flagged-respawn-2.0.0.tgz", + "integrity": "sha512-Gq/a6YCi8zexmGHMuJwahTGzXlAZAOsbCVKduWXC6TlLCjjFRlExMJc4GC2NYPYZ0r/brw9P7CpRgQmlPVeOoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/for-own": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/for-own/-/for-own-1.0.0.tgz", + "integrity": "sha512-0OABksIGrxKK8K4kynWkQ7y1zounQxP+CWnyclVwj81KW3vlLlGUx57DKGcP/LH216GzqnstnPocF16Nxs0Ycg==", + "dev": true, + "license": "MIT", + "dependencies": { + "for-in": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -42,6 +355,450 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/global-modules": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz", + "integrity": "sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "global-prefix": "^1.0.1", + "is-windows": "^1.0.1", + "resolve-dir": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/global-prefix": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-1.0.2.tgz", + "integrity": "sha512-5lsx1NUDHtSjfg0eHlmYvZKv8/nVqX4ckFbM+FrGcQ+04KWcWFo9P5MxPZYSzUvyzmdTbI7Eix8Q4IbELDqzKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "expand-tilde": "^2.0.2", + "homedir-polyfill": "^1.0.1", + "ini": "^1.3.4", + "is-windows": "^1.0.1", + "which": "^1.2.14" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/glogg": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/glogg/-/glogg-2.2.0.tgz", + "integrity": "sha512-eWv1ds/zAlz+M1ioHsyKJomfY7jbDDPpwSkv14KQj89bycx1nvK5/2Cj/T9g7kzJcX5Bc7Yv22FjfBZS/jl94A==", + "dev": true, + "license": "MIT", + "dependencies": { + "sparkles": "^2.1.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/gulp-cli": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/gulp-cli/-/gulp-cli-3.1.0.tgz", + "integrity": "sha512-zZzwlmEsTfXcxRKiCHsdyjZZnFvXWM4v1NqBJSYbuApkvVKivjcmOS2qruAJ+PkEHLFavcDKH40DPc1+t12a9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@gulpjs/messages": "^1.1.0", + "chalk": "^4.1.2", + "copy-props": "^4.0.0", + "gulplog": "^2.2.0", + "interpret": "^3.1.1", + "liftoff": "^5.0.1", + "mute-stdout": "^2.0.0", + "replace-homedir": "^2.0.0", + "semver-greatest-satisfied-range": "^2.0.0", + "string-width": "^4.2.3", + "v8flags": "^4.0.0", + "yargs": "^16.2.0" + }, + "bin": { + "gulp": "bin/gulp.js" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gulplog": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/gulplog/-/gulplog-2.2.0.tgz", + "integrity": "sha512-V2FaKiOhpR3DRXZuYdRLn/qiY0yI5XmqbTKrYbdemJ+xOh2d2MOweI/XFgMzd/9+1twdvMwllnZbWZNJ+BOm4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "glogg": "^2.2.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/homedir-polyfill": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", + "integrity": "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-passwd": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/interpret": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/is-absolute": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz", + "integrity": "sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-relative": "^1.0.0", + "is-windows": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-object": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz", + "integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-relative": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz", + "integrity": "sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unc-path": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-unc-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz", + "integrity": "sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "unc-path-regex": "^0.1.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/liftoff": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/liftoff/-/liftoff-5.0.1.tgz", + "integrity": "sha512-wwLXMbuxSF8gMvubFcFRp56lkFV69twvbU5vDPbaw+Q+/rF8j0HKjGbIdlSi+LuJm9jf7k9PB+nTxnsLMPcv2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "extend": "^3.0.2", + "findup-sync": "^5.0.0", + "fined": "^2.0.0", + "flagged-respawn": "^2.0.0", + "is-plain-object": "^5.0.0", + "rechoir": "^0.8.0", + "resolve": "^1.20.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/map-cache": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", + "integrity": "sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mute-stdout": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stdout/-/mute-stdout-2.0.0.tgz", + "integrity": "sha512-32GSKM3Wyc8dg/p39lWPKYu8zci9mJFzV1Np9Of0ZEpe6Fhssn/FbI7ywAMd40uX+p3ZKh3T5EeCFv81qS3HmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/object.defaults": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/object.defaults/-/object.defaults-1.1.0.tgz", + "integrity": "sha512-c/K0mw/F11k4dEUBMW8naXUuBuhxRCfG7W+yFy8EcijU/rSmazOUd1XAEEe6bC0OuXY4HUKjTJv7xbxIMqdxrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-each": "^1.0.1", + "array-slice": "^1.0.0", + "for-own": "^1.0.0", + "isobject": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object.pick": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", + "integrity": "sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/parse-filepath": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz", + "integrity": "sha512-FwdRXKCohSVeXqwtYonZTXtbGJKrn+HNyWDYVcp5yuJlesTwNH4rsmRZ+GrKAPJ5bLpRxESMeS+Rl0VCHRvB2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-absolute": "^1.0.0", + "map-cache": "^0.2.0", + "path-root": "^0.1.1" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/parse-passwd": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz", + "integrity": "sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-root": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/path-root/-/path-root-0.1.1.tgz", + "integrity": "sha512-QLcPegTHF11axjfojBIoDygmS2E3Lf+8+jI6wOVmNVenrKSo3mFdSGiIgdSHenczw3wPtlVMQaFVwGmM7BJdtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-root-regex": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-root-regex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/path-root-regex/-/path-root-regex-0.1.2.tgz", + "integrity": "sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/playwright": { "version": "1.58.2", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", @@ -73,6 +830,312 @@ "engines": { "node": ">=18" } + }, + "node_modules/postscribe": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/postscribe/-/postscribe-2.0.8.tgz", + "integrity": "sha512-Sxt6pek38NKX85Vb/PbcritqVxsgPZQFLcuf4o0f7lXRb76jM0XP79SGwCBPRTuv+U2zqByQan8EzRjqquD73A==", + "dev": true, + "license": "MIT", + "dependencies": { + "prescribe": ">=1.1.2" + } + }, + "node_modules/prebid-universal-creative": { + "version": "1.17.2", + "resolved": "https://registry.npmjs.org/prebid-universal-creative/-/prebid-universal-creative-1.17.2.tgz", + "integrity": "sha512-+1fB/eD3eXF+m8T0S4GL/wrXatx/tpeTtZ6ptFQnjQxtejiM0GuoFWdJvx5xlqkP1Z14WEE6/eRc1zWcxvg/Dg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "babel-plugin-transform-object-assign": "^6.22.0", + "gulp-cli": "^3.0.0", + "postscribe": "^2.0.8" + } + }, + "node_modules/prescribe": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/prescribe/-/prescribe-1.1.3.tgz", + "integrity": "sha512-HEg0ElY5tmmCshST4tzl47+SirJO2cVo6j/+O4d6xIz+80ixNcN0GgPQsn76AgeTTIAQOrwq1rfoptubQuZ1Uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", + "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/replace-homedir": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/replace-homedir/-/replace-homedir-2.0.0.tgz", + "integrity": "sha512-bgEuQQ/BHW0XkkJtawzrfzHFSN70f/3cNOiHa2QsYxqrjaC30X1k74FJ6xswVBP0sr0SpGIdVFuPwfrYziVeyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-dir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz", + "integrity": "sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "expand-tilde": "^2.0.0", + "global-modules": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/semver-greatest-satisfied-range": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/semver-greatest-satisfied-range/-/semver-greatest-satisfied-range-2.0.0.tgz", + "integrity": "sha512-lH3f6kMbwyANB7HuOWRMlLCa2itaCrZJ+SAqqkSZrZKO/cAsk2EOyaKHUtNkVLFyFW9pct22SFesFp3Z7zpA0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "sver": "^1.8.3" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/sparkles": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/sparkles/-/sparkles-2.1.0.tgz", + "integrity": "sha512-r7iW1bDw8R/cFifrD3JnQJX0K1jqT0kprL48BiBpLZLJPmAm34zsVBsK5lc7HirZYZqMW65dOXZgbAGt/I6frg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sver": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/sver/-/sver-1.8.4.tgz", + "integrity": "sha512-71o1zfzyawLfIWBOmw8brleKyvnbn73oVHNCsu51uPMz/HWiKkkXsI31JjHW5zqXEqnPYkIiHd8ZmL7FCimLEA==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "semver": "^6.3.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/unc-path-regex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz", + "integrity": "sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/v8flags": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/v8flags/-/v8flags-4.0.1.tgz", + "integrity": "sha512-fcRLaS4H/hrZk9hYwbdRM35D0U8IYMfEClhXxCivOojl+yTRAZH3Zy2sSy6qVCiGbV9YAtPssP6jaChqC9vPCg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz", + "integrity": "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } } } } diff --git a/crates/trusted-server-integration-tests/browser/package.json b/crates/trusted-server-integration-tests/browser/package.json index 13282f289..42855b5b2 100644 --- a/crates/trusted-server-integration-tests/browser/package.json +++ b/crates/trusted-server-integration-tests/browser/package.json @@ -8,6 +8,7 @@ "test:wordpress": "TEST_FRAMEWORK=wordpress npx playwright test" }, "devDependencies": { - "@playwright/test": "^1.49.0" + "@playwright/test": "^1.49.0", + "prebid-universal-creative": "1.17.2" } } diff --git a/crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts b/crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts index fce505d42..927b89a45 100644 --- a/crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts +++ b/crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts @@ -10,6 +10,13 @@ const SCRIPT_CREATIVE_URL = "https://creative.example/script.js"; const SANDBOX = "allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation"; const TSJS_CRATE = resolve(__dirname, "../../../../trusted-server-js"); +const PUC_BANNER = readFileSync( + resolve( + __dirname, + "../../node_modules/prebid-universal-creative/dist/banner.js", + ), + "utf8", +); function clientAuctionBundlePaths() { const manifestPath = resolve(TSJS_CRATE, "dist/prebid/manifest.json"); @@ -197,6 +204,145 @@ const SCRIPT_CREATIVE = `(function(){ })();`; test.describe("APS rendering", () => { + test("renders through real PUC and expands only its authenticated 1x1 shell", async ({ + page, + }) => { + const adId = "fictional-inline-ad-id"; + const publisherOrigin = new URL(runtimeUrl("/")).origin; + const outerCreativeUrl = runtimeUrl("/fictional-puc-shell"); + let creativeRequests = 0; + + await page.route(runtimeUrl("/aps-puc-topology-test"), (route) => + route.fulfill({ + status: 200, + contentType: "text/html", + body: '
', + }), + ); + await page.route(outerCreativeUrl, (route) => + route.fulfill({ + status: 200, + contentType: "text/html", + body: ``, + }), + ); + await page.route(IFRAME_CREATIVE_URL, (route) => { + creativeRequests += 1; + return route.fulfill({ + status: 200, + contentType: "text/html", + body: IFRAME_CREATIVE, + }); + }); + + await page.goto(runtimeUrl("/aps-puc-topology-test")); + await page.addScriptTag({ path: clientAuctionBundlePaths().gpt }); + await page.evaluate( + ({ creativeUrl, outerUrl, selectedAdId }) => { + const typedWindow = window as unknown as { + tsjs: Record; + pucEvents: Array>; + }; + typedWindow.tsjs = { + bids: { + "aps-slot": { + hb_adid: selectedAdId, + hb_bidder: "fictional", + hb_pb: "1.23", + adm: ``, + w: 300, + h: 250, + }, + }, + adSlots: [ + { + id: "aps-slot", + div_id: "div-aps", + gam_unit_path: "/fictional/aps", + formats: [[300, 250]], + }, + ], + }; + typedWindow.pucEvents = []; + const locator = document.createElement("iframe"); + locator.name = "__pb_locator__"; + document.body.appendChild(locator); + window.addEventListener("message", (event) => { + try { + const message = JSON.parse( + String(event.data), + ) as Record; + if (message.message === "Prebid Event") { + typedWindow.pucEvents.push(message); + } + } catch { + // Ignore unrelated publisher messages. + } + }); + + const slot = document.getElementById("div-aps")!; + slot.style.width = "1px"; + slot.style.height = "1px"; + const frame = document.createElement("iframe"); + frame.id = "google_ads_iframe_fictional_0"; + frame.width = "1"; + frame.height = "1"; + frame.style.width = "1px"; + frame.style.height = "1px"; + frame.src = outerUrl; + slot.appendChild(frame); + + const other = document.getElementById("div-other")!; + const otherFrame = document.createElement("iframe"); + otherFrame.width = "1"; + otherFrame.height = "1"; + otherFrame.style.width = "1px"; + otherFrame.style.height = "1px"; + other.appendChild(otherFrame); + }, + { + creativeUrl: IFRAME_CREATIVE_URL, + outerUrl: outerCreativeUrl, + selectedAdId: adId, + }, + ); + + await expect.poll(() => creativeRequests).toBe(1); + await expect + .poll(() => + page.evaluate(() => + ( + window as unknown as { + pucEvents: Array>; + } + ).pucEvents.some( + (event) => event.event === "adRenderSucceeded", + ), + ), + ) + .toBe(true); + await expect(page.locator("#google_ads_iframe_fictional_0")).toHaveCSS( + "width", + "300px", + ); + await expect(page.locator("#google_ads_iframe_fictional_0")).toHaveCSS( + "height", + "250px", + ); + await expect(page.locator("#div-aps")).toHaveCSS("width", "300px"); + await expect(page.locator("#div-aps")).toHaveCSS("height", "250px"); + await expect(page.locator("#div-other iframe")).toHaveCSS( + "width", + "1px", + ); + await expect(page.locator("#div-other iframe")).toHaveCSS( + "height", + "1px", + ); + }); + test("renders a trustedServer adapter bid using Prebid's generated GAM ad ID", async ({ page, }) => { diff --git a/crates/trusted-server-js/lib/src/core/first_impression.ts b/crates/trusted-server-js/lib/src/core/first_impression.ts new file mode 100644 index 000000000..fc53dad36 --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/first_impression.ts @@ -0,0 +1,358 @@ +import type { + FirstImpressionPhase, + FirstImpressionPublisherAuction, + FirstImpressionSlotClaim, + FirstImpressionState, + TsjsApi, +} from './types'; + +/** Time allowed for one navigation's losing first-impression delivery. */ +export const FIRST_IMPRESSION_LEASE_MS = 5000; + +const MAX_FIRST_IMPRESSION_SLOTS = 256; +const MAX_PUBLISHER_AUCTIONS_PER_SLOT = 16; + +function currentGeneration(ts: TsjsApi): number { + return ts.navGeneration ?? 0; +} + +function claimMatchesElement( + claim: FirstImpressionSlotClaim, + element: HTMLElement, + generation: number +): boolean { + return ( + claim.generation === generation && + claim.slotElementId === element.id && + claim.element === element && + element.isConnected + ); +} + +function removePublisherAuction( + state: FirstImpressionState, + claim: FirstImpressionSlotClaim, + token: string, + now: number +): void { + delete claim.publisherAuctions[token]; + if ( + claim.owner === 'publisher' && + (claim.phase === 'auctioning' || claim.phase === 'delivery_pending') && + Object.keys(claim.publisherAuctions).length === 0 && + claim.expiresAt <= now + ) { + delete state.slots[claim.slotElementId]; + } +} + +function pruneFirstImpressionState(ts: TsjsApi, now = Date.now()): FirstImpressionState { + const generation = currentGeneration(ts); + if (ts.firstImpression?.generation !== generation) { + ts.firstImpression = { generation, nextToken: 0, slots: {}, fallbackSlots: {} }; + } + + const state = ts.firstImpression; + state.slots ??= {}; + state.fallbackSlots ??= {}; + for (const [elementId, claim] of Object.entries(state.slots)) { + if (!claimMatchesElement(claim, claim.element, generation)) { + delete state.slots[elementId]; + continue; + } + for (const [token, auction] of Object.entries(claim.publisherAuctions)) { + if (auction.expiresAt <= now) removePublisherAuction(state, claim, token, now); + } + if ( + claim.owner === 'publisher' && + (claim.phase === 'auctioning' || claim.phase === 'delivery_pending') && + Object.keys(claim.publisherAuctions).length === 0 && + claim.expiresAt <= now + ) { + delete state.slots[elementId]; + } + } + for (const [elementId, element] of Object.entries(state.fallbackSlots)) { + if ( + !element.isConnected || + element.id !== elementId || + document.getElementById(elementId) !== element + ) { + delete state.fallbackSlots[elementId]; + } + } + return state; +} + +function activePhysicalElement(element: HTMLElement | null): HTMLElement | undefined { + return element?.isConnected && element.id ? element : undefined; +} + +function visibleThroughAncestors(element: HTMLElement): boolean { + for (let current: HTMLElement | null = element; current; current = current.parentElement) { + const style = window.getComputedStyle(current); + if (style.display === 'none' || style.visibility === 'hidden') return false; + } + return true; +} + +/** Resolve a publisher ad-unit code to one exact active physical slot element. */ +export function resolveFirstImpressionElement(adUnitCode: string): HTMLElement | undefined { + if (!adUnitCode) return undefined; + const exact = activePhysicalElement(document.getElementById(adUnitCode)); + if (exact) return exact; + + const matches = Array.from(document.querySelectorAll('[id]')).filter( + (element) => + element.id.startsWith(adUnitCode) && + !element.id.endsWith('-container') && + visibleThroughAncestors(element) + ); + return matches.length === 1 ? matches[0] : undefined; +} + +/** Return the live ownership claim for an exact slot element. */ +export function firstImpressionClaim( + ts: TsjsApi, + element: HTMLElement +): FirstImpressionSlotClaim | undefined { + const state = pruneFirstImpressionState(ts); + const claim = state.slots[element.id]; + return claim && claimMatchesElement(claim, element, state.generation) ? claim : undefined; +} + +function storeClaim(state: FirstImpressionState, claim: FirstImpressionSlotClaim): boolean { + if ( + !state.slots[claim.slotElementId] && + Object.keys(state.slots).length >= MAX_FIRST_IMPRESSION_SLOTS + ) { + return false; + } + state.slots[claim.slotElementId] = claim; + return true; +} + +/** Atomically claim an untouched slot for Trusted Server. */ +export function claimFirstImpressionForTrustedServer( + ts: TsjsApi, + element: HTMLElement, + now = Date.now() +): FirstImpressionSlotClaim | undefined { + const state = pruneFirstImpressionState(ts, now); + const existing = state.slots[element.id]; + if (existing && claimMatchesElement(existing, element, state.generation)) return undefined; + + const claim: FirstImpressionSlotClaim = { + generation: state.generation, + slotElementId: element.id, + element, + owner: 'trusted_server', + phase: 'delivery_pending', + expiresAt: now + FIRST_IMPRESSION_LEASE_MS, + publisherAuctions: {}, + }; + return storeClaim(state, claim) ? claim : undefined; +} + +function schedulePublisherAuctionExpiry(ts: TsjsApi, token: string): void { + window.setTimeout( + () => releasePublisherFirstImpressionAuction(ts, token), + FIRST_IMPRESSION_LEASE_MS + ); +} + +/** Release a TS claim when slot setup failed before any request could start. */ +export function releaseTrustedServerFirstImpressionClaim( + ts: TsjsApi, + element: HTMLElement, + claim: FirstImpressionSlotClaim +): void { + const state = pruneFirstImpressionState(ts); + if ( + state.slots[element.id] === claim && + claim.owner === 'trusted_server' && + claim.phase === 'delivery_pending' && + Object.keys(claim.publisherAuctions).length === 0 + ) { + delete state.slots[element.id]; + } +} + +/** Register real publisher auctions before native `requestBids()` starts. */ +export function registerPublisherFirstImpressionAuctions( + ts: TsjsApi, + adUnitCodes: Iterable, + now = Date.now() +): Map { + const state = pruneFirstImpressionState(ts, now); + const registrations = new Map(); + + for (const adUnitCode of adUnitCodes) { + const element = resolveFirstImpressionElement(adUnitCode); + if (!element) continue; + + let claim = state.slots[element.id]; + if (!claim || !claimMatchesElement(claim, element, state.generation)) { + claim = { + generation: state.generation, + slotElementId: element.id, + element, + owner: 'publisher', + phase: 'auctioning', + expiresAt: now + FIRST_IMPRESSION_LEASE_MS, + publisherAuctions: {}, + }; + if (!storeClaim(state, claim)) continue; + } + + if ( + claim.owner === 'publisher' && + (claim.phase === 'requested' || claim.phase === 'rendered') + ) { + continue; + } + if (claim.owner === 'trusted_server' && (claim.suppressionConsumed || claim.expiresAt <= now)) { + continue; + } + if (Object.keys(claim.publisherAuctions).length >= MAX_PUBLISHER_AUCTIONS_PER_SLOT) continue; + + const token = `${state.generation}:${++state.nextToken}`; + const auction: FirstImpressionPublisherAuction = { + token, + adUnitCode, + phase: 'auctioning', + expiresAt: now + FIRST_IMPRESSION_LEASE_MS, + adIds: [], + suppressDelivery: claim.owner === 'trusted_server', + }; + claim.publisherAuctions[token] = auction; + if (claim.owner === 'publisher') claim.expiresAt = Math.max(claim.expiresAt, auction.expiresAt); + registrations.set(adUnitCode, token); + schedulePublisherAuctionExpiry(ts, token); + } + + return registrations; +} + +function findPublisherAuction( + ts: TsjsApi, + token: string, + now = Date.now() +): + | { + state: FirstImpressionState; + claim: FirstImpressionSlotClaim; + auction: FirstImpressionPublisherAuction; + } + | undefined { + const state = pruneFirstImpressionState(ts, now); + for (const claim of Object.values(state.slots)) { + const auction = claim.publisherAuctions[token]; + if (auction) return { state, claim, auction }; + } + return undefined; +} + +/** Move one publisher auction to delivery-pending without disturbing overlaps. */ +export function markPublisherFirstImpressionDeliveryPending( + ts: TsjsApi, + token: string, + adIds: string[], + now = Date.now() +): void { + const found = findPublisherAuction(ts, token, now); + if (!found) return; + found.auction.phase = 'delivery_pending'; + found.auction.adIds = [...new Set(adIds)]; + if (found.claim.owner === 'publisher') found.claim.phase = 'delivery_pending'; +} + +/** Release exactly one publisher auction token after failure, timeout, or removal. */ +export function releasePublisherFirstImpressionAuction( + ts: TsjsApi, + token: string, + now = Date.now() +): void { + const found = findPublisherAuction(ts, token, now); + if (!found) return; + found.auction.expiresAt = Math.min(found.auction.expiresAt, now); + if ( + found.claim.owner === 'publisher' && + Object.keys(found.claim.publisherAuctions).length === 1 + ) { + found.claim.expiresAt = now; + } + removePublisherAuction(found.state, found.claim, token, now); +} + +/** Consume one correlated publisher delivery and report whether TS owns it. */ +export function consumePublisherFirstImpressionDelivery( + ts: TsjsApi, + token: string | undefined, + now = Date.now() +): boolean { + if (!token) return false; + const found = findPublisherAuction(ts, token, now); + if (!found) return false; + + const suppress = + found.claim.owner === 'trusted_server' && + found.auction.suppressDelivery && + !found.claim.suppressionConsumed && + found.claim.expiresAt > now; + delete found.claim.publisherAuctions[token]; + if (suppress) found.claim.suppressionConsumed = true; + return suppress; +} + +/** Record a GPT request or render, using publisher ownership when no claimant exists. */ +export function observeFirstImpressionGptLifecycle( + ts: TsjsApi, + element: HTMLElement, + phase: Extract, + now = Date.now() +): void { + const state = pruneFirstImpressionState(ts, now); + let claim = state.slots[element.id]; + if (!claim || !claimMatchesElement(claim, element, state.generation)) { + claim = { + generation: state.generation, + slotElementId: element.id, + element, + owner: 'publisher', + phase, + expiresAt: Number.POSITIVE_INFINITY, + publisherAuctions: {}, + }; + storeClaim(state, claim); + return; + } + + claim.phase = phase; + if (claim.owner === 'publisher') claim.expiresAt = Number.POSITIVE_INFINITY; +} + +/** Reserve the only Trusted Server fallback allowed for this physical slot and generation. */ +export function reservePublisherFirstImpressionFallback( + ts: TsjsApi, + element: HTMLElement +): boolean { + const state = pruneFirstImpressionState(ts); + const reservedElement = state.fallbackSlots[element.id]; + if (reservedElement) return false; + state.fallbackSlots[element.id] = element; + return true; +} + +/** Delay before an abandoned publisher claim can receive one per-slot TS fallback. */ +export function publisherFirstImpressionRetryDelay( + ts: TsjsApi, + element: HTMLElement, + now = Date.now() +): number | undefined { + const claim = firstImpressionClaim(ts, element); + if (!claim) return 0; + if (claim.owner !== 'publisher') return undefined; + if (claim.phase === 'requested' || claim.phase === 'rendered') return undefined; + return Math.max(0, claim.expiresAt - now); +} diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 03ff0aca2..9caaf5b35 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -365,6 +365,40 @@ export interface GptSlotHandoff { suppressPublisherRefresh: boolean; } +export type FirstImpressionOwner = 'publisher' | 'trusted_server'; +export type FirstImpressionPhase = 'auctioning' | 'delivery_pending' | 'requested' | 'rendered'; + +/** One publisher auction participating in the current navigation's first impression. */ +export interface FirstImpressionPublisherAuction { + token: string; + adUnitCode: string; + phase: 'auctioning' | 'delivery_pending'; + expiresAt: number; + adIds: string[]; + suppressDelivery: boolean; +} + +/** First-impression ownership for one exact physical slot element. */ +export interface FirstImpressionSlotClaim { + generation: number; + slotElementId: string; + element: HTMLElement; + owner: FirstImpressionOwner; + phase: FirstImpressionPhase; + expiresAt: number; + publisherAuctions: Record; + suppressionConsumed?: boolean; + targeting?: Record; +} + +/** Bounded first-impression state shared by the GPT bootstrap, GPT, and Prebid bundles. */ +export interface FirstImpressionState { + generation: number; + nextToken: number; + slots: Record; + fallbackSlots: Record; +} + export interface TsjsApi { version: string; que: Array<() => void>; @@ -436,6 +470,10 @@ export interface TsjsApi { gptSlotHandoffs?: Record; /** True only while TS calls a GPT function that the handoff wrappers observe. */ gptSlotHandoffInternal?: boolean; + /** Per-navigation first-impression ownership shared by GPT and Prebid. */ + firstImpression?: FirstImpressionState; + /** Guards the shared production GPT lifecycle listener installation. */ + firstImpressionListenersInstalled?: boolean; /** Guards SPA pushState hook installation. */ spaHookInstalled?: boolean; /** Internal one-shot state shared by bootstrap and bundle scheduler installs. */ diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index 89b480c6f..701f928b2 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -1,3 +1,11 @@ +import { + claimFirstImpressionForTrustedServer, + firstImpressionClaim, + observeFirstImpressionGptLifecycle, + publisherFirstImpressionRetryDelay, + releaseTrustedServerFirstImpressionClaim, + reservePublisherFirstImpressionFallback, +} from '../../core/first_impression'; import { log } from '../../core/log'; import type { AuctionSlot, @@ -191,48 +199,140 @@ function candidateSlotRoots(elementId: string): HTMLElement[] { return roots; } -function candidateSlotRootsForConfiguredDivId(divId: string): HTMLElement[] { - const roots = candidateSlotRoots(divId); - const dynamicElements = Array.from(document.querySelectorAll('[id]')).filter( - (element) => element.id.startsWith(divId) && !element.id.endsWith('-container') - ); - for (const element of dynamicElements) { - if (!roots.includes(element)) roots.push(element); - const container = document.getElementById(`${element.id}-container`); - if (container && !roots.includes(container)) roots.push(container); - } - return roots; +interface MessageSourceFrame { + iframe: HTMLIFrameElement; + root: HTMLElement; } -function sourceIsInSlotRoots(source: MessageEventSource, roots: HTMLElement[]): boolean { - return roots.some((root) => - Array.from(root.querySelectorAll('iframe')).some((iframe) => iframe.contentWindow === source) - ); +function sourceFrameInRoots( + source: MessageEventSource | null, + roots: readonly HTMLElement[] +): MessageSourceFrame | undefined { + if (!source) return undefined; + const matches = new Map(); + for (const root of roots) { + for (const iframe of root.querySelectorAll('iframe')) { + if (iframe.contentWindow === source && !matches.has(iframe)) matches.set(iframe, root); + } + } + if (matches.size !== 1) return undefined; + const [iframe, root] = matches.entries().next().value as [HTMLIFrameElement, HTMLElement]; + return { iframe, root }; } -function slotIdForMessageSource(source: MessageEventSource | null): string | undefined { - if (!source) return undefined; +function sourceFrameForSlotId( + source: MessageEventSource | null, + slotId: string +): MessageSourceFrame | undefined { + const mappedRoots = Object.entries(window.tsjs?.divToSlotId ?? {}) + .filter(([, mappedSlotId]) => mappedSlotId === slotId) + .flatMap(([elementId]) => candidateSlotRoots(elementId)); + const configuredRoots = (window.tsjs?.adSlots ?? []) + .filter((slot) => slot.id === slotId) + .flatMap((slot) => { + const element = resolveSlotElementByDivId(slot.div_id).element; + return element ? candidateSlotRoots(element.id) : []; + }); + return sourceFrameInRoots(source, [...new Set([...mappedRoots, ...configuredRoots])]); +} - const divToSlotId = window.tsjs?.divToSlotId ?? {}; - const resolvedSlotId = Object.entries(divToSlotId).find(([elementId]) => - sourceIsInSlotRoots(source, candidateSlotRoots(elementId)) - )?.[1]; - if (resolvedSlotId) return resolvedSlotId; +interface MessageSourceSlotFrame extends MessageSourceFrame { + slotId: string; +} - const slots = window.tsjs?.adSlots ?? []; - return [...slots] - .sort((left, right) => right.div_id.length - left.div_id.length) - .find((slot) => sourceIsInSlotRoots(source, candidateSlotRootsForConfiguredDivId(slot.div_id))) - ?.id; +function slotFrameForMessageSource( + source: MessageEventSource | null +): MessageSourceSlotFrame | undefined { + const slotIds = new Set(); + for (const [elementId, slotId] of Object.entries(window.tsjs?.divToSlotId ?? {})) { + if (sourceFrameInRoots(source, candidateSlotRoots(elementId))) slotIds.add(slotId); + } + for (const slot of window.tsjs?.adSlots ?? []) { + const element = resolveSlotElementByDivId(slot.div_id).element; + if (element && sourceFrameInRoots(source, candidateSlotRoots(element.id))) { + slotIds.add(slot.id); + } + } + if (slotIds.size !== 1) return undefined; + const slotId = slotIds.values().next().value as string; + const frame = sourceFrameForSlotId(source, slotId); + return frame ? { ...frame, slotId } : undefined; } -function messageSourceBelongsToAdUnit( +function sourceFrameForAdUnit( source: MessageEventSource | null, adUnitCode: string -): boolean { - return source - ? sourceIsInSlotRoots(source, candidateSlotRootsForConfiguredDivId(adUnitCode)) - : false; +): MessageSourceFrame | undefined { + const element = resolveSlotElementByDivId(adUnitCode).element; + return element ? sourceFrameInRoots(source, candidateSlotRoots(element.id)) : undefined; +} + +function hasCollapsedDimension(element: HTMLElement, dimension: 'width' | 'height'): boolean { + const value = window.getComputedStyle(element)[dimension]; + const match = /^(\d+(?:\.\d+)?)px$/.exec(value); + return match !== null && Number(match[1]) <= 1; +} + +function usesFixedPositioning(element: HTMLElement): boolean { + const position = window.getComputedStyle(element).position; + return position === 'fixed' || position === 'sticky'; +} + +const MAX_CREATIVE_SHELL_DIMENSION = 10_000; + +/** Resize only the authenticated source iframe for a still-current collapsed display shell. */ +function resizeCollapsedCreativeFrame( + source: MessageEventSource | null, + frame: MessageSourceFrame, + width: number, + height: number, + generation: number, + stillOwnsCreative: () => boolean +): void { + if ( + (window.tsjs?.navGeneration ?? 0) !== generation || + !stillOwnsCreative() || + !Number.isFinite(width) || + !Number.isFinite(height) || + width <= 0 || + height <= 0 || + width > MAX_CREATIVE_SHELL_DIMENSION || + height > MAX_CREATIVE_SHELL_DIMENSION || + !frame.iframe.isConnected || + !frame.root.isConnected || + !frame.root.contains(frame.iframe) || + frame.iframe.contentWindow !== source || + frame.iframe.getAttribute('width') !== '1' || + frame.iframe.getAttribute('height') !== '1' || + !hasCollapsedDimension(frame.iframe, 'width') || + !hasCollapsedDimension(frame.iframe, 'height') || + usesFixedPositioning(frame.iframe) || + frame.iframe.closest( + 'ins[data-anchor-status], [data-google-interstitial], [data-vignette-loaded]' + ) + ) { + return; + } + + const wrapper = frame.iframe.parentElement; + if ( + !wrapper || + wrapper === document.body || + wrapper === document.documentElement || + !frame.root.contains(wrapper) || + usesFixedPositioning(wrapper) + ) { + return; + } + + frame.iframe.width = String(width); + frame.iframe.height = String(height); + frame.iframe.style.width = `${width}px`; + frame.iframe.style.height = `${height}px`; + if (hasCollapsedDimension(wrapper, 'width') && hasCollapsedDimension(wrapper, 'height')) { + wrapper.style.width = `${width}px`; + wrapper.style.height = `${height}px`; + } } function clearTargetingKeys(slot: GoogleTagSlot, keys: Iterable): void { @@ -930,11 +1030,176 @@ function installLatePublisherSlotHandoff(ts: TsjsApi): void { }); } +function installFirstImpressionLifecycleObservers(ts: TsjsApi, g: Partial): void { + if (ts.firstImpressionListenersInstalled) return; + g.cmd?.push(() => { + if (ts.firstImpressionListenersInstalled) return; + const pubads = g.pubads?.(); + if (!pubads?.addEventListener) return; + + const observe = + (phase: 'requested' | 'rendered') => + (event: SlotRenderEndedEvent): void => { + const elementId = event.slot?.getSlotElementId?.(); + const element = elementId ? document.getElementById(elementId) : null; + if (element) observeFirstImpressionGptLifecycle(ts, element, phase); + }; + pubads.addEventListener('slotRequested', observe('requested')); + pubads.addEventListener('slotRenderEnded', observe('rendered')); + ts.firstImpressionListenersInstalled = true; + }); +} + +function trustedServerTargeting( + slot: AuctionSlot, + bid: AuctionBidData +): Record { + const targeting: Record = { ...(slot.targeting ?? {}) }; + for (const key of TS_BID_TARGETING_KEYS) { + if (bid[key]) targeting[key] = String(bid[key]); + } + targeting[TS_INITIAL_TARGETING_KEY] = '1'; + return targeting; +} + +function applyTrustedServerTargeting( + ts: TsjsApi, + gptSlot: GoogleTagSlot, + slot: AuctionSlot, + bid: AuctionBidData, + elementIds: readonly string[] +): string[] { + const previousKeys = ts.prevSlotTargetingKeys ?? {}; + clearTargetingKeys(gptSlot, [ + ...TS_BASE_TARGETING_KEYS, + ...elementIds.flatMap((elementId) => previousKeys[elementId] ?? []), + ]); + const targeting = trustedServerTargeting(slot, bid); + for (const [key, value] of Object.entries(targeting)) gptSlot.setTargeting(key, value); + const element = document.getElementById(elementIds[0]!); + const claim = element ? firstImpressionClaim(ts, element) : undefined; + if (claim?.owner === 'trusted_server') claim.targeting = targeting; + return Object.keys(slot.targeting ?? {}); +} + +function schedulePublisherFirstImpressionFallback( + ts: TsjsApi, + g: Partial, + slot: AuctionSlot, + bid: AuctionBidData, + element: HTMLElement, + generation: number +): void { + if (!reservePublisherFirstImpressionFallback(ts, element)) return; + + const retry = (): void => { + if ( + (ts.navGeneration ?? 0) !== generation || + !element.isConnected || + document.getElementById(element.id) !== element + ) { + return; + } + const delay = publisherFirstImpressionRetryDelay(ts, element); + if (delay === undefined) return; + if (delay > 0) { + window.setTimeout(retry, delay + 1); + return; + } + + g.cmd?.push(() => { + if ( + (ts.navGeneration ?? 0) !== generation || + !element.isConnected || + document.getElementById(element.id) !== element + ) { + return; + } + const claim = claimFirstImpressionForTrustedServer(ts, element); + if (!claim) return; + + const pubads = g.pubads?.(); + if (!pubads) { + releaseTrustedServerFirstImpressionClaim(ts, element, claim); + return; + } + let gptSlot = pubads + .getSlots?.() + .find((candidate) => candidate.getSlotElementId() === element.id); + let tsOwned = false; + if (!gptSlot) { + gptSlot = + withGptSlotHandoffInternal(ts, () => + g.defineSlot?.(slot.gam_unit_path, slot.formats, element.id) + ) ?? undefined; + if (!gptSlot) { + releaseTrustedServerFirstImpressionClaim(ts, element, claim); + return; + } + gptSlot.addService(pubads); + tsOwned = true; + (ts.gptSlotHandoffs ??= {})[element.id] = { + gamUnitPath: slot.gam_unit_path, + formats: slot.formats, + divIdPrefix: slot.div_id, + slotElementId: element.id, + publisherClaimed: false, + suppressPublisherDisplay: false, + suppressPublisherRefresh: false, + }; + } + + const slotElementId = gptSlot.getSlotElementId?.() ?? element.id; + const targetingKeys = applyTrustedServerTargeting(ts, gptSlot, slot, bid, [ + element.id, + slotElementId, + ]); + (ts.divToSlotId ??= {})[element.id] = slot.id; + if (slotElementId !== element.id) ts.divToSlotId[slotElementId] = slot.id; + (ts.prevSlotTargetingKeys ??= {})[element.id] = targetingKeys; + if (slotElementId !== element.id) ts.prevSlotTargetingKeys[slotElementId] = targetingKeys; + if (tsOwned) (ts.prevGptSlots ??= []).push(gptSlot); + + try { + ts.gptDiagnosticsRecorder?.recordTrustedServerOpportunity( + gptSlot, + slot.id, + trustedServerOpportunity(bid), + bid.hb_auction_id, + slot.formats + ); + } catch { + // Diagnostics must not alter fallback delivery. + } + + if (!ts.servicesEnabled) { + pubads.enableSingleRequest(); + g.enableServices?.(); + ts.servicesEnabled = true; + } + if (tsOwned) withGptSlotHandoffInternal(ts, () => g.display?.(slotElementId)); + syncInitialLoadDisabled(g, ts); + if (!tsOwned || ts.gptInitialLoadDisabled) { + ts.adInitRefreshInProgress = true; + try { + withGptSlotHandoffInternal(ts, () => pubads.refresh([gptSlot!])); + } finally { + ts.adInitRefreshInProgress = false; + } + } + }); + }; + + retry(); +} + export function installTsAdInit(): void { const ts = (window.tsjs ??= {} as TsjsApi); installInitialLoadDetector(ts); installScheduleInitialAdInit(ts); + const g = (window as GptWindow).googletag; + if (g) installFirstImpressionLifecycleObservers(ts, g); installLatePublisherSlotHandoff(ts); ts.adInit = function () { const slots = ts.adSlots ?? []; @@ -951,6 +1216,7 @@ export function installTsAdInit(): void { const generation = ts.navGeneration ?? 0; const g = (window as GptWindow).googletag; if (!g) return; + installFirstImpressionLifecycleObservers(ts, g); const warnedResolutionFailures = new Set(); g.cmd?.push(() => { @@ -1000,6 +1266,8 @@ export function installTsAdInit(): void { (g.pubads!().getSlots?.() ?? []).forEach((gptSlot: GoogleTagSlot) => { const elementId = gptSlot.getSlotElementId(); if (!prevTouchedDivIds.has(elementId)) return; + const element = document.getElementById(elementId); + if (element && firstImpressionClaim(ts, element)) return; clearTargetingKeys(gptSlot, [ ...TS_BASE_TARGETING_KEYS, ...(prevSlotTargetingKeys[elementId] ?? []), @@ -1037,6 +1305,14 @@ export function installTsAdInit(): void { } const actualDivId = el.id; const bid = bids[slot.id] ?? {}; + const firstImpression = claimFirstImpressionForTrustedServer(ts, el); + if (!firstImpression) { + const claim = firstImpressionClaim(ts, el); + if (claim?.owner === 'publisher') { + schedulePublisherFirstImpressionFallback(ts, g, slot, bid, el, generation); + } + return; + } const existingSlot = g.pubads!() .getSlots?.() @@ -1052,7 +1328,10 @@ export function installTsAdInit(): void { const defined = withGptSlotHandoffInternal(ts, () => g.defineSlot?.(slot.gam_unit_path, slot.formats, actualDivId) ); - if (!defined) return; + if (!defined) { + releaseTrustedServerFirstImpressionClaim(ts, el, firstImpression); + return; + } defined.addService(g.pubads!()); gptSlot = defined; tsOwned = true; @@ -1068,17 +1347,10 @@ export function installTsAdInit(): void { } const slotDivId2 = gptSlot.getSlotElementId?.() ?? actualDivId; - clearTargetingKeys(gptSlot, [ - ...TS_BASE_TARGETING_KEYS, - ...(prevSlotTargetingKeys[actualDivId] ?? []), - ...(prevSlotTargetingKeys[slotDivId2] ?? []), + const slotTargetingKeys = applyTrustedServerTargeting(ts, gptSlot, slot, bid, [ + actualDivId, + slotDivId2, ]); - - Object.entries(slot.targeting ?? {}).forEach(([k, v]) => gptSlot.setTargeting(k, v)); - TS_BID_TARGETING_KEYS.forEach((key) => { - if (bid[key]) gptSlot.setTargeting(key, String(bid[key]!)); - }); - gptSlot.setTargeting(TS_INITIAL_TARGETING_KEY, '1'); // Diagnostics are observational only. A missing or malformed debug // implementation must never interrupt slot mapping or delivery. try { @@ -1098,7 +1370,6 @@ export function installTsAdInit(): void { // injection address the same, single GPT slot. divToSlotId[actualDivId] = slot.id; if (slotDivId2 !== actualDivId) divToSlotId[slotDivId2] = slot.id; - const slotTargetingKeys = Object.keys(slot.targeting ?? {}); nextSlotTargetingKeys[actualDivId] = slotTargetingKeys; if (slotDivId2 !== actualDivId) nextSlotTargetingKeys[slotDivId2] = slotTargetingKeys; if (tsOwned) { @@ -1398,6 +1669,7 @@ export function installSpaAuctionHook(): void { if (path === currentPath) return; currentPath = path; ts.navGeneration = (ts.navGeneration ?? 0) + 1; + delete ts.firstImpression; // A route change invalidates hydration aliases before the new route's // publisher can define a same-prefix slot while page-bids is in flight. for (const [elementId, handoff] of Object.entries(ts.gptSlotHandoffs ?? {})) { @@ -1682,6 +1954,7 @@ export function installTsRenderBridge(): void { if (!port) return; const now = Date.now(); + const generation = window.tsjs?.navGeneration ?? 0; pruneConsumedPrebidApsIds(consumedPrebidApsIds, now); const consumedPrebidAps = consumedPrebidApsIds.get(adId); if (consumedPrebidAps) { @@ -1698,7 +1971,8 @@ export function installTsRenderBridge(): void { // Prebid handles ad IDs globally and would otherwise answer a request from // an unrelated iframe when this slot-bound capability rejects it. e.stopImmediatePropagation(); - if (!messageSourceBelongsToAdUnit(e.source, prebidRendererEntry.adUnitCode)) return; + const sourceFrame = sourceFrameForAdUnit(e.source, prebidRendererEntry.adUnitCode); + if (!sourceFrame) return; const renderer = validateApsRenderer(prebidRendererEntry.renderer); if (!renderer || !hasConsumedPrebidApsIdCapacity(consumedPrebidApsIds, adId)) return; if (!consumeApsPrebidRenderer(adId, prebidRendererEntry)) return; @@ -1731,6 +2005,16 @@ export function installTsRenderBridge(): void { height: validatedRenderer.height, }) ); + resizeCollapsedCreativeFrame( + e.source, + sourceFrame, + validatedRenderer.width, + validatedRenderer.height, + generation, + () => + sourceFrameForAdUnit(e.source, prebidRendererEntry.adUnitCode)?.iframe === + sourceFrame.iframe + ); return true; } catch (err) { log.warn(`[tsjs-gpt] APS Prebid response post failed for '${adId}'`, err); @@ -1748,8 +2032,8 @@ export function installTsRenderBridge(): void { return; } - const sourceSlotId = slotIdForMessageSource(e.source); - if (!sourceSlotId) return; + const sourceSlotFrame = slotFrameForMessageSource(e.source); + if (!sourceSlotFrame) return; // Resolve the bid by the requesting slot, not by the first bid whose hb_adid // matches. hb_adid is not unique per bid: absent PBS Cache it falls back to a @@ -1758,7 +2042,7 @@ export function installTsRenderBridge(): void { // first-match-by-adId lookup would resolve every duplicate to one slot, so all // but that slot render blank. const bids = window.tsjs?.bids ?? {}; - const slotId = sourceSlotId; + const slotId = sourceSlotFrame.slotId; const matchedBid = bids[slotId]; // Not a TS bid, or the requesting slot's bid does not own this adId โ€” let @@ -1795,6 +2079,17 @@ export function installTsRenderBridge(): void { height: validatedRenderer.height, }) ); + resizeCollapsedCreativeFrame( + e.source, + sourceSlotFrame, + validatedRenderer.width, + validatedRenderer.height, + generation, + () => + window.tsjs?.bids?.[slotId] === matchedBid && + matchedBid.hb_adid === adId && + sourceFrameForSlotId(e.source, slotId)?.iframe === sourceSlotFrame.iframe + ); return true; } catch (err) { log.warn(`[tsjs-gpt] APS server response post failed for '${slotId}'`, err); @@ -1841,6 +2136,13 @@ export function installTsRenderBridge(): void { log.warn(`[tsjs-gpt] pbRender bridge: response post failed for '${slotId}'`, err); return; } + resizeCollapsedCreativeFrame(e.source, sourceSlotFrame, width, height, generation, () => + Boolean( + window.tsjs?.bids?.[slotId] === matchedBid && + matchedBid.hb_adid === adId && + sourceFrameForSlotId(e.source, slotId)?.iframe === sourceSlotFrame.iframe + ) + ); safelyRecordCreativeResponse(attemptId); fireWinBillingBeacons(slotId, matchedBid); log.debug(`[tsjs-gpt] pbRender bridge served '${slotId}' from inline adm`); @@ -1890,6 +2192,8 @@ export function installTsRenderBridge(): void { cached.price !== undefined ? expandAuctionPriceMacro(cached.adm, cached.price) : cached.adm; + const cachedWidth = cached.width ?? width; + const cachedHeight = cached.height ?? height; try { port.postMessage( JSON.stringify({ @@ -1897,10 +2201,21 @@ export function installTsRenderBridge(): void { adId, ad, renderer: TS_DISPLAY_RENDERER, - width: cached.width ?? width, - height: cached.height ?? height, + width: cachedWidth, + height: cachedHeight, }) ); + resizeCollapsedCreativeFrame( + e.source, + sourceSlotFrame, + cachedWidth, + cachedHeight, + generation, + () => + window.tsjs?.bids?.[slotId] === matchedBid && + matchedBid.hb_adid === adId && + sourceFrameForSlotId(e.source, slotId)?.iframe === sourceSlotFrame.iframe + ); } catch (err) { safelyRecordCreativeFailure(attemptId, 'response_post_failed'); log.warn(`[tsjs-gpt] pbRender bridge: response post failed for '${slotId}'`, err); diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index 44b47f2da..65cbb0697 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -13,6 +13,13 @@ import type _pbjsDefault from 'prebid.js'; +import { + consumePublisherFirstImpressionDelivery, + firstImpressionClaim, + markPublisherFirstImpressionDeliveryPending, + registerPublisherFirstImpressionAuctions, + releasePublisherFirstImpressionAuction, +} from '../../core/first_impression'; import { log } from '../../core/log'; import { buildAdRequest, parseAuctionResponse } from '../../core/auction'; import { registerApsPrebidRenderer, validateApsRenderer } from '../aps/render'; @@ -375,10 +382,13 @@ type PendingPublisherBid = { adUnitCode: string; expiresAt: number; registrationId: number; + firstImpressionToken?: string; }; type PendingPublisherCode = { + adUnitCode: string; expiresAt: number; registrationId: number; + firstImpressionToken?: string; }; type RemoveAdUnit = (adUnitCode?: string | string[]) => unknown; type PrebidWithRemoveAdUnit = { @@ -388,8 +398,9 @@ type PrebidWithRemoveAdUnit = { let publisherAdUnitSnapshots = new Map(); let pendingPublisherBids = new Map(); -let pendingPublisherCodes = new Map(); +let pendingPublisherCodes = new Map>(); let pendingPublisherRegistrationId = 0; +let publisherFirstImpressionTokens = new Map>(); let syntheticRefreshAdUnits = new WeakSet(); type TrustedServerBidRequest = { adUnitCode?: string; @@ -414,6 +425,7 @@ type RefreshGptSlot = { getSlotElementId?: () => string; getAdUnitPath?: () => string; getTargeting?: (key: string) => string[]; + setTargeting?: (key: string, value: string | string[]) => RefreshGptSlot; clearTargeting?: (key?: string) => RefreshGptSlot; getSizes?: () => unknown[]; }; @@ -819,26 +831,74 @@ function clearRefreshTargeting(slot: RefreshGptSlot): void { } } +function restoreTrustedServerFirstImpressionTargeting(slot: RefreshGptSlot): void { + const ts = window.tsjs; + const injectedSlot = findInjectedSlotForRefresh(slot); + const element = [refreshSlotElementId(slot), injectedSlot?.div_id] + .filter((elementId): elementId is string => Boolean(elementId)) + .map((elementId) => document.getElementById(elementId)) + .find((candidate): candidate is HTMLElement => + Boolean(candidate && ts && firstImpressionClaim(ts, candidate)?.owner === 'trusted_server') + ); + const claim = ts && element ? firstImpressionClaim(ts, element) : undefined; + if (claim?.owner !== 'trusted_server' || !claim.targeting || !slot.setTargeting) return; + clearRefreshTargeting(slot); + for (const [key, value] of Object.entries(claim.targeting)) slot.setTargeting(key, value); +} + +/** Track a first-impression token until its exact auction is consumed or abandoned. */ +function trackPublisherFirstImpressionToken(adUnitCode: string, token: string): void { + const tokens = publisherFirstImpressionTokens.get(adUnitCode) ?? new Set(); + tokens.add(token); + publisherFirstImpressionTokens.set(adUnitCode, tokens); +} + +function forgetPublisherFirstImpressionToken(adUnitCode: string, token?: string): void { + const tokens = publisherFirstImpressionTokens.get(adUnitCode); + if (!tokens) return; + if (token === undefined) { + if (window.tsjs) { + for (const current of tokens) releasePublisherFirstImpressionAuction(window.tsjs, current); + } + publisherFirstImpressionTokens.delete(adUnitCode); + return; + } + tokens.delete(token); + if (tokens.size === 0) publisherFirstImpressionTokens.delete(adUnitCode); +} + /** Remove pending delivery state for an ad unit, optionally from one registration only. */ function removePendingPublisherBidsForCode(adUnitCode: string, registrationId?: number): void { - const pendingCode = pendingPublisherCodes.get(adUnitCode); - if (registrationId !== undefined && pendingCode?.registrationId !== registrationId) return; + const registrations = pendingPublisherCodes.get(adUnitCode); + if (registrations) { + if (registrationId === undefined) { + pendingPublisherCodes.delete(adUnitCode); + } else { + registrations.delete(registrationId); + if (registrations.size === 0) pendingPublisherCodes.delete(adUnitCode); + } + } - pendingPublisherCodes.delete(adUnitCode); for (const [adId, pendingBid] of pendingPublisherBids) { if ( pendingBid.adUnitCode === adUnitCode && (registrationId === undefined || pendingBid.registrationId === registrationId) ) { pendingPublisherBids.delete(adId); + if (pendingBid.firstImpressionToken) { + forgetPublisherFirstImpressionToken(adUnitCode, pendingBid.firstImpressionToken); + } } } } /** Discard delivery state that outlived the publisher auction which created it. */ function prunePendingPublisherBids(now = Date.now()): void { - for (const [adUnitCode, pendingCode] of pendingPublisherCodes) { - if (pendingCode.expiresAt <= now) pendingPublisherCodes.delete(adUnitCode); + for (const [adUnitCode, registrations] of pendingPublisherCodes) { + for (const [registrationId, pendingCode] of registrations) { + if (pendingCode.expiresAt <= now) registrations.delete(registrationId); + } + if (registrations.size === 0) pendingPublisherCodes.delete(adUnitCode); } for (const [adId, pendingBid] of pendingPublisherBids) { @@ -846,12 +906,15 @@ function prunePendingPublisherBids(now = Date.now()): void { } } -/** Store a short-lived pending publisher ad-unit code for delivery correlation. */ -function storePendingPublisherCode(adUnitCode: string, pendingCode: PendingPublisherCode): void { - pendingPublisherCodes.delete(adUnitCode); - pendingPublisherCodes.set(adUnitCode, pendingCode); +/** Store a short-lived pending publisher ad-unit code without erasing overlaps. */ +function storePendingPublisherCode(pendingCode: PendingPublisherCode): void { + const registrations = pendingPublisherCodes.get(pendingCode.adUnitCode) ?? new Map(); + registrations.set(pendingCode.registrationId, pendingCode); + pendingPublisherCodes.set(pendingCode.adUnitCode, registrations); - if (pendingPublisherCodes.size > MAX_PENDING_PUBLISHER_BIDS) { + let registrationCount = 0; + for (const pending of pendingPublisherCodes.values()) registrationCount += pending.size; + if (registrationCount > MAX_PENDING_PUBLISHER_BIDS) { const oldestCode = pendingPublisherCodes.keys().next().value; if (oldestCode !== undefined) removePendingPublisherBidsForCode(oldestCode); } @@ -868,29 +931,18 @@ function storePendingPublisherBid(adId: string, pendingBid: PendingPublisherBid) } } -/** Register every requested publisher code and any bid IDs returned for that auction. */ -function registerPendingPublisherBids( +function publisherResponseAdIds( publisherAdUnitCodes: Set, bidResponses: unknown -): number { - prunePendingPublisherBids(); - const registrationId = ++pendingPublisherRegistrationId; - const expiresAt = Date.now() + PENDING_PUBLISHER_DELIVERY_TTL_MS; - - for (const adUnitCode of publisherAdUnitCodes) { - removePendingPublisherBidsForCode(adUnitCode); - storePendingPublisherCode(adUnitCode, { expiresAt, registrationId }); - } - - if (!bidResponses || typeof bidResponses !== 'object' || Array.isArray(bidResponses)) { - return registrationId; - } +): Map { + const adIds = new Map(); + if (!bidResponses || typeof bidResponses !== 'object' || Array.isArray(bidResponses)) + return adIds; for (const [responseCode, responseGroup] of Object.entries(bidResponses)) { if (!responseGroup || typeof responseGroup !== 'object') continue; const bids = (responseGroup as { bids?: unknown }).bids; if (!Array.isArray(bids)) continue; - for (const bid of bids) { if (!bid || typeof bid !== 'object') continue; const response = bid as { adId?: unknown; adUnitCode?: unknown }; @@ -898,29 +950,65 @@ function registerPendingPublisherBids( const adUnitCode = typeof response.adUnitCode === 'string' ? response.adUnitCode : responseCode; if (!adId || !adUnitCode || !publisherAdUnitCodes.has(adUnitCode)) continue; + adIds.set(adUnitCode, [...(adIds.get(adUnitCode) ?? []), adId]); + } + } + return adIds; +} - storePendingPublisherBid(adId, { adUnitCode, expiresAt, registrationId }); +/** Register every requested publisher code and any bid IDs returned for that auction. */ +function registerPendingPublisherBids( + publisherAdUnitCodes: Set, + bidResponses: unknown, + firstImpressionTokens: Map +): number { + prunePendingPublisherBids(); + const registrationId = ++pendingPublisherRegistrationId; + const expiresAt = Date.now() + PENDING_PUBLISHER_DELIVERY_TTL_MS; + const responseAdIds = publisherResponseAdIds(publisherAdUnitCodes, bidResponses); + + for (const adUnitCode of publisherAdUnitCodes) { + const firstImpressionToken = firstImpressionTokens.get(adUnitCode); + storePendingPublisherCode({ + adUnitCode, + expiresAt, + registrationId, + firstImpressionToken, + }); + if (firstImpressionToken && window.tsjs) { + markPublisherFirstImpressionDeliveryPending( + window.tsjs, + firstImpressionToken, + responseAdIds.get(adUnitCode) ?? [] + ); + } + } + + for (const [adUnitCode, adIds] of responseAdIds) { + const firstImpressionToken = firstImpressionTokens.get(adUnitCode); + for (const adId of adIds) { + storePendingPublisherBid(adId, { + adUnitCode, + expiresAt, + registrationId, + firstImpressionToken, + }); } } return registrationId; } -/** - * Partition slots by whether they belong to a pending publisher auction. - * - * A current `hb_adid` is the precise signal. When publishers intentionally - * omit that targeting, a short-lived requested-code match preserves delivery - * for no-bid and custom-targeting auctions. Without an ID, that fallback cannot - * distinguish a delayed delivery from the first independent refresh, so it may - * conservatively suppress one auction before its one-shot state is consumed. - * A non-empty unmatched ID remains independent so stale targeting cannot - * suppress a fresh auction. Every match is consumed once. - */ -function publisherDeliverySlots(targetSlots: RefreshGptSlot[]): Set { +interface PublisherDeliveryPartition { + deliverySlots: Set; + suppressedSlots: Set; +} + +/** Partition correlated publisher deliveries from one losing first-impression delivery. */ +function publisherDeliverySlots(targetSlots: RefreshGptSlot[]): PublisherDeliveryPartition { prunePendingPublisherBids(); const deliverySlots = new Set(); - const deliveredCodes = new Set(); + const suppressedSlots = new Set(); for (const slot of targetSlots) { const adIds = slot.getTargeting?.('hb_adid'); @@ -937,16 +1025,23 @@ function publisherDeliverySlots(targetSlots: RefreshGptSlot[]): Set typeof code === 'string' && code.length > 0) - .find((code) => pendingPublisherCodes.has(code)); - const adUnitCode = pendingBid?.adUnitCode ?? pendingCode; - if (!adUnitCode) continue; - - deliverySlots.add(slot); - deliveredCodes.add(adUnitCode); + .flatMap((code) => [...(pendingPublisherCodes.get(code)?.values() ?? [])]) + .sort((left, right) => left.registrationId - right.registrationId)[0]; + const pending = pendingBid ?? pendingCode; + if (!pending) continue; + + const suppress = + pending.firstImpressionToken && window.tsjs + ? consumePublisherFirstImpressionDelivery(window.tsjs, pending.firstImpressionToken) + : false; + if (pending.firstImpressionToken) { + forgetPublisherFirstImpressionToken(pending.adUnitCode, pending.firstImpressionToken); + } + removePendingPublisherBidsForCode(pending.adUnitCode); + (suppress ? suppressedSlots : deliverySlots).add(slot); } - deliveredCodes.forEach((adUnitCode) => removePendingPublisherBidsForCode(adUnitCode)); - return deliverySlots; + return { deliverySlots, suppressedSlots }; } /** Evict publisher state after Prebid removes one or more ad units. */ @@ -955,6 +1050,9 @@ function removePublisherState(adUnitCode?: string | string[]): void { publisherAdUnitSnapshots.clear(); pendingPublisherBids.clear(); pendingPublisherCodes.clear(); + for (const code of publisherFirstImpressionTokens.keys()) { + forgetPublisherFirstImpressionToken(code); + } return; } @@ -962,6 +1060,7 @@ function removePublisherState(adUnitCode?: string | string[]): void { for (const code of adUnitCodes) { publisherAdUnitSnapshots.delete(code); removePendingPublisherBidsForCode(code); + forgetPublisherFirstImpressionToken(code); } } @@ -1084,6 +1183,7 @@ export function installPrebidNpm(config?: Partial): typeof pbjs pendingPublisherBids = new Map(); pendingPublisherCodes = new Map(); pendingPublisherRegistrationId = 0; + publisherFirstImpressionTokens = new Map(); syntheticRefreshAdUnits = new WeakSet(); const prebidWithRemoveAdUnit = pbjs as unknown as PrebidWithRemoveAdUnit; @@ -1185,6 +1285,20 @@ export function installPrebidNpm(config?: Partial): typeof pbjs .map((unit) => unit.code) .filter((code): code is string => typeof code === 'string' && code.length > 0) ); + const firstImpressionTokens = + !isSyntheticRefresh && !window.tsjs?.adInitRefreshInProgress + ? registerPublisherFirstImpressionAuctions( + (window.tsjs ??= {} as TsjsApi), + publisherAdUnitCodes + ) + : new Map(); + for (const [adUnitCode, token] of firstImpressionTokens) { + trackPublisherFirstImpressionToken(adUnitCode, token); + window.setTimeout( + () => forgetPublisherFirstImpressionToken(adUnitCode, token), + PENDING_PUBLISHER_DELIVERY_TTL_MS + ); + } // Ensure every ad unit has a trustedServer bid entry for (const unit of adUnits) { @@ -1280,7 +1394,7 @@ export function installPrebidNpm(config?: Partial): typeof pbjs syncPrebidEidsCookie(); const registrationId = isSyntheticRefresh ? undefined - : registerPendingPublisherBids(publisherAdUnitCodes, args[0]); + : registerPendingPublisherBids(publisherAdUnitCodes, args[0], firstImpressionTokens); if (typeof originalBidsBack !== 'function') return; try { @@ -1291,11 +1405,23 @@ export function installPrebidNpm(config?: Partial): typeof pbjs removePendingPublisherBidsForCode(code, registrationId) ); } + for (const [adUnitCode, token] of firstImpressionTokens) { + releasePublisherFirstImpressionAuction(window.tsjs!, token); + forgetPublisherFirstImpressionToken(adUnitCode, token); + } throw error; } }; - return originalRequestBids(opts); + try { + return originalRequestBids(opts); + } catch (error) { + for (const [adUnitCode, token] of firstImpressionTokens) { + releasePublisherFirstImpressionAuction(window.tsjs!, token); + forgetPublisherFirstImpressionToken(adUnitCode, token); + } + throw error; + } }; // Apply initial configuration @@ -1403,11 +1529,15 @@ export function installRefreshHandler(timeoutMs = 1500): void { return originalRefresh(slots, opts); } - const deliverySlots = publisherDeliverySlots(targetSlots); - const independentSlots = targetSlots.filter((slot) => !deliverySlots.has(slot)); + const { deliverySlots, suppressedSlots } = publisherDeliverySlots(targetSlots); + suppressedSlots.forEach(restoreTrustedServerFirstImpressionTargeting); + const remainingSlots = targetSlots.filter((slot) => !suppressedSlots.has(slot)); + if (remainingSlots.length === 0) return; + const forwardedSlots = suppressedSlots.size > 0 ? remainingSlots : slots; + const independentSlots = remainingSlots.filter((slot) => !deliverySlots.has(slot)); if (independentSlots.length === 0) { - recordPrebidRefreshForDiagnostics(targetSlots); - return dispatchPrebidRefresh(originalRefresh, slots, opts); + recordPrebidRefreshForDiagnostics(remainingSlots); + return dispatchPrebidRefresh(originalRefresh, forwardedSlots, opts); } // Clear stale Trusted Server/Prebid targeting from independent slots before @@ -1484,12 +1614,11 @@ export function installRefreshHandler(timeoutMs = 1500): void { log.error('[tsjs-prebid] refresh targeting failed', error); } } - recordPrebidRefreshForDiagnostics(targetSlots); - // Preserve the publisher's original refresh form. In particular, a bare - // GPT refresh remains bare so GPT resolves its registered slot set when - // the auction completes; the dispatch wrapper only scopes the shared - // diagnostics context around the delegated call. - dispatchPrebidRefresh(originalRefresh, slots, opts); + recordPrebidRefreshForDiagnostics(remainingSlots); + // Preserve the publisher's original refresh form unless one losing + // first-impression slot was filtered. A bare call must become explicit + // in that case so GPT cannot re-add the suppressed slot. + dispatchPrebidRefresh(originalRefresh, forwardedSlots, opts); } try { diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index b7186518b..70a75140e 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -6,6 +6,7 @@ import { resolve } from 'node:path'; import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest'; import envelope from '../../fixtures/aps-renderer-v1.json'; +import { registerPublisherFirstImpressionAuctions } from '../../../src/core/first_impression'; import type { AuctionBidData, TsjsApi } from '../../../src/core/types'; import { APS_PREBID_CREATIVE_RUNNER_URL, @@ -248,6 +249,7 @@ describe('installTsAdInit', () => { const mockSlot = { addService: vi.fn().mockReturnThis(), setTargeting: vi.fn().mockReturnThis(), + clearTargeting: vi.fn().mockReturnThis(), getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), getTargeting: vi.fn().mockReturnValue([]), }; @@ -282,6 +284,105 @@ describe('installTsAdInit', () => { return { mockPubads, mockSlot }; } + it('leaves a publisher-auctioned slot untouched when delayed adInit receives no candidate', async () => { + const recordTrustedServerOpportunity = vi.fn(); + const { mockPubads, mockSlot } = configureOpportunityDiagnostics( + undefined, + recordTrustedServerOpportunity + ); + const ts = (window as TestWindow).tsjs as TsjsApi; + registerPublisherFirstImpressionAuctions(ts, ['div-atf-sidebar']); + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + ts.adInit!(); + + expect(mockSlot.setTargeting).not.toHaveBeenCalled(); + expect(mockPubads.refresh).not.toHaveBeenCalled(); + expect(recordTrustedServerOpportunity).not.toHaveBeenCalled(); + expect(ts.divToSlotId).toEqual({}); + expect(ts.prevSlotTargetingKeys).toEqual({}); + }); + + it('falls back once when a publisher auction abandons its first-impression claim', async () => { + vi.useFakeTimers(); + try { + const recordTrustedServerOpportunity = vi.fn(); + const { mockPubads, mockSlot } = configureOpportunityDiagnostics( + { hb_pb: '1.10', hb_adid: 'example-fallback-ad', adm: '
Fallback
' }, + recordTrustedServerOpportunity + ); + const ts = (window as TestWindow).tsjs as TsjsApi; + registerPublisherFirstImpressionAuctions(ts, ['div-atf-sidebar']); + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + ts.adInit!(); + + expect(mockPubads.refresh).not.toHaveBeenCalled(); + vi.advanceTimersByTime(5001); + + expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); + expect(mockPubads.refresh).toHaveBeenCalledOnce(); + expect(mockPubads.refresh).toHaveBeenCalledWith([mockSlot]); + expect(recordTrustedServerOpportunity).toHaveBeenCalledOnce(); + + vi.advanceTimersByTime(10_000); + expect(mockPubads.refresh).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + + it('does not clear targeting or request again after TS claims an existing slot', async () => { + const recordTrustedServerOpportunity = vi.fn(); + const { mockPubads, mockSlot } = configureOpportunityDiagnostics( + undefined, + recordTrustedServerOpportunity + ); + const ts = (window as TestWindow).tsjs as TsjsApi; + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + + ts.adInit!(); + const clearCalls = mockSlot.clearTargeting.mock.calls.length; + const targetingCalls = mockSlot.setTargeting.mock.calls.length; + ts.adInit!(); + + expect(mockSlot.clearTargeting).toHaveBeenCalledTimes(clearCalls); + expect(mockSlot.setTargeting).toHaveBeenCalledTimes(targetingCalls); + expect(mockPubads.refresh).toHaveBeenCalledOnce(); + expect(recordTrustedServerOpportunity).toHaveBeenCalledOnce(); + }); + + it.each(['slotRequested', 'slotRenderEnded'] as const)( + 'leaves a publisher slot untouched after an earlier %s event', + async (eventName) => { + const recordTrustedServerOpportunity = vi.fn(); + const { mockPubads, mockSlot } = configureOpportunityDiagnostics( + { hb_pb: '2.00', hb_adid: 'late-page-bid', adm: '
Late
' }, + recordTrustedServerOpportunity + ); + const ts = (window as TestWindow).tsjs as TsjsApi; + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + const lifecycleListener = mockPubads.addEventListener.mock.calls.find( + ([registeredEvent]) => registeredEvent === eventName + )?.[1] as ((event: SlotRenderEvent) => void) | undefined; + expect(lifecycleListener).toBeDefined(); + lifecycleListener!({ isEmpty: false, slot: mockSlot }); + + ts.adInit!(); + + expect(mockSlot.setTargeting).not.toHaveBeenCalled(); + expect(mockPubads.refresh).not.toHaveBeenCalled(); + expect(recordTrustedServerOpportunity).not.toHaveBeenCalled(); + expect(ts.firstImpression?.slots['div-atf-sidebar']?.owner).toBe('publisher'); + expect(ts.firstImpression?.slots['div-atf-sidebar']?.phase).toBe( + eventName === 'slotRequested' ? 'requested' : 'rendered' + ); + } + ); + it.each([ [ 'inline markup', @@ -1858,7 +1959,9 @@ describe('installTsAdInit', () => { (window as TestWindow).tsjs!.adInit!(); - expect(nativeRefresh).toHaveBeenCalledWith([mockSlot]); + // The slot already spent its first impression above. Changing GPT's + // initial-load mode must not make a repeated adInit request it again. + expect(nativeRefresh).not.toHaveBeenCalled(); nativeRefresh.mockClear(); gpt.setConfig({ disableInitialLoad: false }); @@ -1877,7 +1980,7 @@ describe('installTsAdInit', () => { (window as TestWindow).tsjs!.adInit!(); - expect(nativeRefresh).toHaveBeenCalledWith([mockSlot]); + expect(nativeRefresh).not.toHaveBeenCalled(); // A later modern call can re-enable initial load after the legacy API. nativeRefresh.mockClear(); @@ -3034,6 +3137,23 @@ describe('installTsRenderBridge', () => { return iframe.contentWindow!; } + function createCollapsedTrustedSlotIframe(divId = 'div-header') { + const slot = document.createElement('div'); + slot.id = divId; + const wrapper = document.createElement('div'); + wrapper.style.width = '1px'; + wrapper.style.height = '1px'; + const iframe = document.createElement('iframe'); + iframe.width = '1'; + iframe.height = '1'; + iframe.style.width = '1px'; + iframe.style.height = '1px'; + wrapper.appendChild(iframe); + slot.appendChild(wrapper); + document.body.appendChild(slot); + return { iframe, slot, source: iframe.contentWindow!, wrapper }; + } + async function captureBridgeListener(): Promise<(e: MessageEvent) => unknown> { let bridgeListener: ((e: MessageEvent) => unknown) | undefined; const origAdd = window.addEventListener.bind(window); @@ -3095,6 +3215,69 @@ describe('installTsRenderBridge', () => { expect(recordTrustedServerCreativeFailure).not.toHaveBeenCalled(); }); + it('expands an authenticated collapsed inline creative shell after response delivery', async () => { + const tsjs = (window as TestWindow).tsjs!; + tsjs.bids.homepage_header.adm = '
Fictional creative
'; + tsjs.bids.homepage_header.w = 728; + tsjs.bids.homepage_header.h = 90; + delete tsjs.bids.homepage_header.nurl; + delete tsjs.bids.homepage_header.burl; + const bridgeListener = await captureBridgeListener(); + const collapsed = createCollapsedTrustedSlotIframe(); + const postMessage = vi.fn(); + + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), + ports: [{ postMessage }], + source: collapsed.source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + + expect(postMessage).toHaveBeenCalledOnce(); + expect(collapsed.iframe.width).toBe('728'); + expect(collapsed.iframe.height).toBe('90'); + expect(collapsed.wrapper.style.width).toBe('728px'); + expect(collapsed.wrapper.style.height).toBe('90px'); + }); + + it.each(['fixed', 'anchor', 'expanded', 'oversized'] as const)( + 'does not resize a %s Universal Creative shell', + async (guard) => { + const tsjs = (window as TestWindow).tsjs!; + tsjs.bids.homepage_header.adm = '
Fictional creative
'; + tsjs.bids.homepage_header.w = guard === 'oversized' ? 10_001 : 300; + tsjs.bids.homepage_header.h = 250; + delete tsjs.bids.homepage_header.nurl; + delete tsjs.bids.homepage_header.burl; + const bridgeListener = await captureBridgeListener(); + const collapsed = createCollapsedTrustedSlotIframe(); + if (guard === 'fixed') collapsed.iframe.style.position = 'fixed'; + if (guard === 'expanded') collapsed.iframe.style.width = '300px'; + if (guard === 'anchor') { + const anchor = document.createElement('ins'); + anchor.dataset.anchorStatus = 'displayed'; + collapsed.slot.insertBefore(anchor, collapsed.wrapper); + anchor.appendChild(collapsed.wrapper); + } + + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), + ports: [{ postMessage: vi.fn() }], + source: collapsed.source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + + expect(collapsed.iframe.width).toBe('1'); + expect(collapsed.iframe.height).toBe('1'); + expect(collapsed.wrapper.style.width).toBe('1px'); + expect(collapsed.wrapper.style.height).toBe('1px'); + } + ); + it('records no creative evidence for an ad ID the requesting slot does not own', async () => { const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(42); const recordTrustedServerCreativeResponse = vi.fn(); @@ -3195,7 +3378,7 @@ describe('installTsRenderBridge', () => { expect(fetchStub).not.toHaveBeenCalled(); }); - it('records response_post_failed when posting inline markup throws', async () => { + it('records response_post_failed without resizing when posting inline markup throws', async () => { const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(46); const recordTrustedServerCreativeResponse = vi.fn(); @@ -3209,7 +3392,7 @@ describe('installTsRenderBridge', () => { tsjs.bids.homepage_header.adm = '
Creative
'; const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); + const collapsed = createCollapsedTrustedSlotIframe(); const stopImmediatePropagation = vi.fn(); expect(() => bridgeListener( @@ -3222,7 +3405,7 @@ describe('installTsRenderBridge', () => { }), }, ], - source, + source: collapsed.source, stopImmediatePropagation, }) as unknown as MessageEvent ) @@ -3232,6 +3415,8 @@ describe('installTsRenderBridge', () => { expect(recordTrustedServerCreativeFailure).toHaveBeenCalledTimes(1); expect(recordTrustedServerCreativeFailure).toHaveBeenCalledWith(46, 'response_post_failed'); expect(recordTrustedServerCreativeResponse).not.toHaveBeenCalled(); + expect(collapsed.iframe.width).toBe('1'); + expect(collapsed.iframe.height).toBe('1'); expect(beaconSpy).not.toHaveBeenCalled(); beaconSpy.mockRestore(); }); @@ -3252,7 +3437,8 @@ describe('installTsRenderBridge', () => { const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); + const collapsed = createCollapsedTrustedSlotIframe(); + const source = collapsed.source; const stopSpy = vi.fn(); const portMessages: string[] = []; const fakePort = { postMessage: (message: string) => portMessages.push(message) }; @@ -3297,6 +3483,10 @@ describe('installTsRenderBridge', () => { }); expect(String(response.renderer)).not.toContain(renderer.accountId); expect(String(response.renderer)).not.toContain(renderer.aaxResponse); + expect(collapsed.iframe.width).toBe('300'); + expect(collapsed.iframe.height).toBe('250'); + expect(collapsed.wrapper.style.width).toBe('300px'); + expect(collapsed.wrapper.style.height).toBe('250px'); // Universal Creative's dynamic-renderer path evaluates the returned static // source and calls window.render(response, helper, targetWindow). Consume @@ -3417,7 +3607,8 @@ describe('installTsRenderBridge', () => { }; const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); + const collapsed = createCollapsedTrustedSlotIframe(); + const source = collapsed.source; const stopSpy = vi.fn(); const portMessages: string[] = []; const event = Object.assign(new Event('message'), { @@ -3453,6 +3644,10 @@ describe('installTsRenderBridge', () => { ); expect(renderer.bidId).not.toBe(prebidAdId); expect((window as TestWindow).tsjs.apsPrebidRenderers[prebidAdId]).toBeUndefined(); + expect(collapsed.iframe.width).toBe('300'); + expect(collapsed.iframe.height).toBe('250'); + expect(collapsed.wrapper.style.width).toBe('300px'); + expect(collapsed.wrapper.style.height).toBe('250px'); expect(fetchStub).not.toHaveBeenCalled(); foreignIframe.remove(); }); @@ -3542,7 +3737,7 @@ describe('installTsRenderBridge', () => { } }); - it('uses the requesting frame to resolve a registered APS dynamic slot prefix', async () => { + it('does not use the requesting frame to disambiguate a registered APS slot prefix', async () => { const renderer = apsRenderer(); const prebidAdId = 'native-dynamic-prebid-ad-id'; const markUsed = vi.fn(); @@ -3556,7 +3751,7 @@ describe('installTsRenderBridge', () => { }, }; const marker = enablePublisherNativeMode(); - const firstSource = createTrustedSlotIframe('div-native-first'); + createTrustedSlotIframe('div-native-first'); const source = createTrustedSlotIframe('div-native-second'); try { @@ -3569,18 +3764,10 @@ describe('installTsRenderBridge', () => { stopImmediatePropagation: vi.fn(), }) as unknown as MessageEvent ); - const native = nativeRunnerIn('div-native-second'); - native.runner.dispatchEvent(new Event('load')); - await Promise.resolve(); - await Promise.resolve(); - expect(native.frame.style.display).toBe(''); - expect(markUsed).toHaveBeenCalledOnce(); - expect( - Array.from(document.querySelectorAll('#div-native-first iframe')).some( - (frame) => frame.contentWindow === firstSource - ) - ).toBe(true); + expect(document.querySelector('iframe[title="Ad content"]')).toBeNull(); + expect(markUsed).not.toHaveBeenCalled(); + expect((window as TestWindow).tsjs.apsPrebidRenderers[prebidAdId]).toBeDefined(); } finally { marker.remove(); document.getElementById('div-native-first')?.remove(); @@ -4409,7 +4596,7 @@ describe('installTsRenderBridge', () => { beaconSpy.mockRestore(); }); - it('sizes a PBS Cache render from the cached bid dimensions', async () => { + it('sizes a PBS Cache render and its collapsed shell from cached bid dimensions', async () => { const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); // Cached bid is 300x250 while the slot's first format is 728x90 (from the // default setup). The response must use the cached dimensions. @@ -4421,13 +4608,13 @@ describe('installTsRenderBridge', () => { const bridgeListener = await captureBridgeListener(); const portMessages: string[] = []; const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - const source = createTrustedSlotIframe(); + const collapsed = createCollapsedTrustedSlotIframe(); bridgeListener( Object.assign(new Event('message'), { data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), ports: [fakePort], - source, + source: collapsed.source, stopImmediatePropagation: vi.fn(), }) as unknown as MessageEvent ); @@ -4438,9 +4625,45 @@ describe('installTsRenderBridge', () => { const parsed = JSON.parse(portMessages[0]) as PrebidResponseMessage; expect(parsed.width).toBe(300); expect(parsed.height).toBe(250); + expect(collapsed.iframe.width).toBe('300'); + expect(collapsed.iframe.height).toBe('250'); + expect(collapsed.wrapper.style.width).toBe('300px'); + expect(collapsed.wrapper.style.height).toBe('250px'); beaconSpy.mockRestore(); }); + it('does not resize a stale cache response after navigation', async () => { + let resolveText: ((body: string) => void) | undefined; + fetchStub.mockResolvedValue({ + ok: true, + text: () => + new Promise((resolve) => { + resolveText = resolve; + }), + } as Response); + const bridgeListener = await captureBridgeListener(); + const collapsed = createCollapsedTrustedSlotIframe(); + const postMessage = vi.fn(); + + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), + ports: [{ postMessage }], + source: collapsed.source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + await Promise.resolve(); + expect(resolveText).toBeDefined(); + (window as TestWindow).tsjs!.navGeneration = 1; + resolveText?.(JSON.stringify({ adm: '
cached
', w: 300, h: 250 })); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(postMessage).toHaveBeenCalledOnce(); + expect(collapsed.iframe.width).toBe('1'); + expect(collapsed.iframe.height).toBe('1'); + }); + it('expands ${AUCTION_PRICE} from the cached bid price before responding', async () => { const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); fetchStub.mockResolvedValue({ diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts index ab6d646f2..a9c84cc61 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts @@ -432,6 +432,65 @@ describe('gpt_bootstrap.js fallback', () => { expect(ts.servicesEnabled).toBe(true); }); + it('fallback adInit leaves a publisher-rendered slot untouched', () => { + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + }; + const mockPubads = { + addEventListener: vi.fn(), + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot]), + refresh: vi.fn(), + }; + const nativeRefresh = mockPubads.refresh; + const defineSlot = vi.fn(); + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot, + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + display: vi.fn(), + }; + document.body.innerHTML = '
'; + runBootstrap(); + const ts = (window as TestWindow).tsjs!; + const element = document.getElementById('div-atf-sidebar')!; + ts.firstImpression = { + generation: 0, + nextToken: 0, + fallbackSlots: {}, + slots: { + 'div-atf-sidebar': { + generation: 0, + slotElementId: 'div-atf-sidebar', + element, + owner: 'publisher', + phase: 'rendered', + expiresAt: Number.POSITIVE_INFINITY, + publisherAuctions: {}, + }, + }, + }; + ts.adSlots = [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + }, + ]; + ts.bids = { atf_sidebar_ad: { hb_pb: '1.00' } }; + + ts.adInit!(); + + expect(mockSlot.setTargeting).not.toHaveBeenCalled(); + expect(nativeRefresh).not.toHaveBeenCalled(); + expect(defineSlot).not.toHaveBeenCalled(); + expect(ts.servicesEnabled).not.toBe(true); + }); + it('fallback adInit cancels queued work when the generation advances before the queue drains', () => { const commandQueue: Array<() => void> = []; const nativeRefresh = vi.fn(); diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index 8ead01aa8..7b115c925 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -201,7 +201,9 @@ import { installRefreshHandler, } from '../../../src/integrations/prebid/index'; import type { AuctionBid } from '../../../src/core/auction'; +import { claimFirstImpressionForTrustedServer } from '../../../src/core/first_impression'; import { log } from '../../../src/core/log'; +import type { TsjsApi } from '../../../src/core/types'; import { GptDiagnosticsObserver } from '../../../src/integrations/gpt_diagnostics/observer'; import { GptDiagnosticsStore } from '../../../src/integrations/gpt_diagnostics/store'; import envelope from '../../fixtures/aps-renderer-v1.json'; @@ -2560,6 +2562,60 @@ describe('prebid publisher snapshots and delivery refreshes', () => { return recordPrebidRefresh; } + it('suppresses one publisher delivery after TS claims first and allows a later refresh', () => { + const code = 'example-ts-first-slot'; + const element = document.createElement('div'); + element.id = code; + document.body.appendChild(element); + try { + const targeting = new Map([ + ['ts_initial', '1'], + ['hb_adid', 'example-ts-ad-id'], + ['hb_pb', '1.25'], + ]); + const slot = { + getSlotElementId: () => code, + getTargeting: (key: string) => { + const value = targeting.get(key); + return value === undefined ? [] : Array.isArray(value) ? value : [value]; + }, + setTargeting: vi.fn((key: string, value: string | string[]) => { + targeting.set(key, value); + return slot; + }), + clearTargeting: vi.fn((key: string) => { + targeting.delete(key); + return slot; + }), + getSizes: () => [[300, 250]], + }; + const ts = (testWindow.tsjs = {} as TsjsApi) as TsjsApi; + const claim = claimFirstImpressionForTrustedServer(ts, element)!; + claim.targeting = Object.fromEntries(targeting); + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => pubads.refresh([slot], { changeCorrelator: false }), + } as unknown as RequestBidsArg); + + expect(originalRefresh).not.toHaveBeenCalled(); + expect(slot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); + expect(slot.setTargeting).toHaveBeenCalledWith('hb_adid', 'example-ts-ad-id'); + expect(ts.firstImpression?.slots[code]?.suppressionConsumed).toBe(true); + + pubads.refresh([slot], { changeCorrelator: false }); + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(originalRefresh).toHaveBeenCalledOnce(); + expect(originalRefresh).toHaveBeenCalledWith([slot], { changeCorrelator: false }); + } finally { + element.remove(); + } + }); + it('records a publisher delivery refresh immediately before its GPT request', () => { const slot = { getSlotElementId: () => 'example-delivery-marker', diff --git a/docs/guide/integrations/aps.md b/docs/guide/integrations/aps.md index bde759c45..cb1e8eee6 100644 --- a/docs/guide/integrations/aps.md +++ b/docs/guide/integrations/aps.md @@ -195,7 +195,9 @@ In `trusted_server` mode, the TSJS auction client validates the typed renderer d ### GAM and Universal Creative -For initial navigation and page-bids, Trusted Server publishes the same descriptor in `window.tsjs.bids`. The source-checked Prebid Universal Creative bridge accepts requests only from the iframe that owns the matching `hb_adid` and validates the complete envelope. In `trusted_server` mode it returns a static dynamic-renderer program that creates the same opaque renderer iframe. In `publisher_native` mode it instead resolves the publisher div and starts the friendly-frame runner without sending a Universal Creative renderer response. +For initial navigation and page-bids, Trusted Server publishes the same descriptor in `window.tsjs.bids`. The source-checked Prebid Universal Creative bridge accepts requests only from the iframe that owns the matching `hb_adid` and validates the complete envelope. In `trusted_server` mode it returns a static dynamic-renderer program that creates the same opaque renderer iframe. After the response is delivered, the bridge expands an authenticated ordinary display iframe only when its width and height attributes and computed geometry are still 1x1. It resizes that source iframe and its immediate collapsed shell parent to the validated winning dimensions. Ambiguous sources, stale navigation or refresh completions, anchors, interstitials, fixed or sticky frames, invalid dimensions, and already-expanded frames remain unchanged. The same guard applies to APS capabilities, inline `adm`, and PBS Cache responses. + +In `publisher_native` mode the bridge instead resolves the publisher div and starts the friendly-frame runner without sending a Universal Creative renderer response. That renderer replaces the slot through a different owner and does not run the collapsed-shell helper. After the native runner loads, Trusted Server replaces the existing children of the resolved publisher div with the friendly frame. This removes the GAM or Universal Creative iframe when it is inside that div. If the runner fails, the existing iframe remains, but its Universal Creative request receives no response because Trusted Server has already claimed the selected bid. This one-owner behavior avoids a second render path, but GAM impression and viewability reporting must be validated with the APS account team for the controlled cohort. diff --git a/docs/superpowers/specs/2026-04-15-server-side-ad-templates-design.md b/docs/superpowers/specs/2026-04-15-server-side-ad-templates-design.md index 8617ef877..147323675 100644 --- a/docs/superpowers/specs/2026-04-15-server-side-ad-templates-design.md +++ b/docs/superpowers/specs/2026-04-15-server-side-ad-templates-design.md @@ -68,10 +68,14 @@ across every navigation in the user's clickstream rather than once per session. pipeline. The GAM call (`securepubads.g.doubleclick.net`) moving server-side is aspirational, contingent on Google agreement, and is not committed for any phase (see ยง9.6). -- Eliminating Prebid entirely โ€” a stripped-down Prebid bundle (_slim-Prebid_) is +- Eliminating Prebid entirely. A stripped-down Prebid bundle (_slim-Prebid_) is lazy-loaded post-`window.load` to handle scroll/refresh auctions and userID - enrichment. **TS owns the first impression; Prebid owns subsequent refresh - auctions.** + enrichment. **The first valid claimant owns each navigation's first impression.** + A publisher auction, GPT request, or GPT render consumes the claim before late + page-bids data can target or refresh that slot. If TS claims first, it suppresses + one correlated losing publisher delivery during a bounded lease. Later publisher + refresh auctions proceed normally. Strict TS-first delivery would require holding + publisher delivery and remains a separate design choice. - Dynamic slot discovery (reading the DOM) โ€” this design commits to pre-defined, URL-matched slot templates. Smart Slots' dynamic injection behavior is replaced by server knowledge. diff --git a/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md b/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md index 68e1cf75e..ff5dd3a8b 100644 --- a/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md +++ b/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md @@ -21,9 +21,10 @@ A fix must keep both implementations in sync. 1. A configured placement has at most one initial GPT slot and ad request when TS runs before a publisher defines its inner div. -2. Apply TS targeting and the `ts_initial=1` marker before that single initial - request. -3. Continue reusing a slot that the publisher has already defined. +2. Apply TS targeting and the `ts_initial=1` marker only when TS owns that single + initial request. +3. Continue reusing a slot that the publisher has already defined without changing + its targeting after a publisher auction, GPT request, or GPT render claims it. 4. Keep the TS-only fallback: if the publisher never defines the placement, TS still displays it and makes exactly one initial request. 5. Preserve `disableInitialLoad()`, SPA targeting cleanup, and the rule that TS does @@ -35,11 +36,33 @@ A fix must keep both implementations in sync. - Deduplicating by GAM ad-unit path. Multiple visible placements may validly share a path. - Changing publisher GAM configuration, line items, or refresh policy. -- Delaying the initial TS request while waiting an arbitrary amount of time for - framework hydration. A time-based grace period cannot distinguish a slow - publisher-owned slot from a placement that the publisher will never define. +- Delaying the initial TS request while waiting for a publisher that has not made a + concrete claim. A time-based grace period cannot distinguish a slow publisher-owned + slot from a placement that the publisher will never define. An actual publisher + `requestBids()` call receives a bounded lease instead. - General interception of unrelated GPT slots. +## Decision: first claimant owns delivery + +The first valid claimant owns each physical slot's first impression for the current +navigation. A real publisher `requestBids()` call claims before native Prebid starts. +A GPT `slotRequested` or `slotRenderEnded` event also claims for the publisher when TS +has not claimed first. `adInit()` may write `ts_initial=1`, apply `hb_*` targeting, and +request an existing slot only after it atomically claims an untouched slot. + +Publisher auction claims use unique, expiring registration tokens. The matching +callback moves only its token to delivery-pending and attaches returned ad IDs. +Overlapping auctions cannot clear each other's tokens. If TS claimed first, the GPT +refresh wrapper filters one correlated losing publisher delivery and restores the TS +targeting snapshot. It forwards every unaffected slot and the original refresh options +exactly once. The one-shot state is then consumed, so later publisher refresh auctions +remain eligible. + +If a publisher claim expires without a GPT request, `adInit()` retries only that slot +after checking the navigation generation, DOM element identity, and ownership again. +It never reruns whole-page initialization. Strict TS-first delivery is outside this +design because it would require holding publisher delivery while page-bids settles. + ## Decision: one inner-div slot with late-definition handoff TS will define its fallback slot on the **actual inner div**, never on its outer