From 7406ed7d8e4e12e16802db244ed6ccb2beaf995b Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:11:42 -0700 Subject: [PATCH] Register APS renderer via requestId so it survives Prebid field stripping APS bids are bid-by-reference: interpretResponse sets the renderer descriptor on the Prebid bid as the custom `trustedServerRenderer` field, and a bidResponse listener registers it in window.tsjs.apsPrebidRenderers keyed by Prebid's generated adId so the Universal Creative can later request it. Prebid normalizes each bid into its own object during addBidResponse and drops unknown top-level fields, so the custom field can be gone before the bidResponse listener runs (observed in production: absent as early as bidAccepted). The listener then saw renderer === undefined and returned without registering, leaving the registry empty; the Universal Creative's request found nothing and Prebid's default renderer threw "Missing ad markup or URL" (reason noAd) for every APS bid. Also stash the descriptor keyed by `requestId` (a first-class field Prebid preserves) when the bids are built, and have the bidResponse listener fall back to that stash when the custom field is absent. Additive: the existing field path is unchanged, so cases where the field survives behave exactly as before; the fallback engages only when Prebid has stripped it. Bounded map. Adds a unit test that registers via requestId with the custom field removed. --- .../lib/src/integrations/prebid/index.ts | 47 ++++++++++++++++--- .../test/integrations/prebid/index.test.ts | 43 +++++++++++++++++ 2 files changed, 83 insertions(+), 7 deletions(-) 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 826fec4b..d12ca614 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -193,6 +193,35 @@ function recordUserIdModuleDiagnostics(): PrebidUserIdDiagnostics { /** Resolved endpoint — set by installPrebidNpm, read by the adapter. */ let auctionEndpoint = '/auction'; +// Prebid normalizes each bid into its own internal object during `addBidResponse`, +// which can drop unknown top-level fields — so the custom `trustedServerRenderer` +// descriptor set in `interpretResponse` may be gone by the time the `bidResponse` +// listener runs (observed in production: the field is absent as early as `bidAccepted`). +// To make registration independent of custom-field survival, the descriptor is also +// stashed here keyed by `requestId` (a first-class field Prebid preserves) at +// `interpretResponse` time, and the `bidResponse` listener falls back to it. Bounded. +const MAX_PENDING_APS_RENDERERS = 256; +const pendingApsRenderersByRequestId = new Map(); + +function stashPendingApsRenderer(requestId: unknown, renderer: unknown): void { + if (typeof requestId !== 'string' || requestId.length === 0) return; + if ( + !pendingApsRenderersByRequestId.has(requestId) && + pendingApsRenderersByRequestId.size >= MAX_PENDING_APS_RENDERERS + ) { + const oldest = pendingApsRenderersByRequestId.keys().next().value; + if (oldest !== undefined) pendingApsRenderersByRequestId.delete(oldest); + } + pendingApsRenderersByRequestId.set(requestId, renderer); +} + +function takePendingApsRenderer(requestId: unknown): unknown { + if (typeof requestId !== 'string') return undefined; + const renderer = pendingApsRenderersByRequestId.get(requestId); + if (renderer !== undefined) pendingApsRenderersByRequestId.delete(requestId); + return renderer; +} + /** * Convert parsed {@link AuctionBid}s into Prebid bid response objects, * linking each bid back to the original BidRequest via `requestId`. @@ -220,9 +249,12 @@ export function auctionBidsToPrebidBids(auctionBids: AuctionBid[], bidRequests: } const origReq = requestsByCode.get(bid.impid); + const requestId = origReq?.bidId ?? bid.impid; + // Stash by requestId so registration survives Prebid stripping the custom field. + if (renderer) stashPendingApsRenderer(requestId, renderer); return [ { - requestId: origReq?.bidId ?? bid.impid, + requestId, cpm: bid.price, width: bid.width, height: bid.height, @@ -524,12 +556,13 @@ function installApsBidResponseRegistry(): void { pbjs.onEvent('bidResponse', (rawBid) => { const bid = rawBid as unknown as Record; - const renderer = bid[APS_RENDERER_FIELD]; - if ( - bid['adapterCode'] !== ADAPTER_CODE || - bid['bidderCode'] !== APS_BIDDER_CODE || - renderer === undefined - ) { + if (bid['adapterCode'] !== ADAPTER_CODE || bid['bidderCode'] !== APS_BIDDER_CODE) { + return; + } + // Prefer the custom field; fall back to the requestId stash when Prebid has stripped + // it during bid normalization (the field is often gone before this listener runs). + const renderer = bid[APS_RENDERER_FIELD] ?? takePendingApsRenderer(bid['requestId']); + if (renderer === undefined) { return; } 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 68928785..a27be5f4 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 @@ -365,6 +365,49 @@ describe('prebid/installPrebidNpm', () => { ); }); + it('registers APS renderer via requestId when Prebid strips the custom field', () => { + installPrebidNpm(); + + const bidResponseListener = mockOnEvent.mock.calls.find( + ([eventName]) => eventName === 'bidResponse' + )?.[1] as ((bid: Record) => void) | undefined; + expect(bidResponseListener).toBeTypeOf('function'); + + const renderer = apsRenderer(); + // interpretResponse output: stashes the descriptor keyed by requestId (survives + // Prebid's bid normalization, which strips the custom trustedServerRenderer field). + auctionBidsToPrebidBids( + [ + { + impid: 'div-aps', + renderer, + price: 1.0, + width: 300, + height: 250, + seat: 'aps', + creativeId: 'cr-aps', + adomain: [], + }, + ], + [{ adUnitCode: 'div-aps', bidId: 'req-strip' }] + ); + + // Prebid delivered the bid with the custom field REMOVED — only requestId survives. + bidResponseListener!({ + adapterCode: 'trustedServer', + bidderCode: 'aps', + adId: 'stripped-field-ad-id', + adUnitCode: 'div-aps', + ttl: 300, + requestId: 'req-strip', + }); + + const entry = (window as any).tsjs.apsPrebidRenderers['stripped-field-ad-id']; + expect(entry).toEqual( + expect.objectContaining({ adUnitCode: 'div-aps', renderer, markWinner: expect.any(Function) }) + ); + }); + it('does not register malformed or non-trusted APS renderer capabilities', () => { const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); installPrebidNpm();