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();