From 65135a72327e83d1c3efa12eeea20d80de3ffc47 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 5 Feb 2026 10:11:32 -0800 Subject: [PATCH] Add GAM interceptor integration - frontend - Add client-side GAM interceptor that intercepts GPT slotRenderEnded events - Render Prebid creatives when GAM doesn't have matching line items - Support multiple rendering methods: iframe src replacement, doc.write, pbjs.renderAd - Add GamConfig interface for configuration via tsjs.setConfig({ gam: {...} }) - Forward GAM config from core config to GAM integration via lazy loader - Add comprehensive tests for iframe attribute extraction and interceptor behavior The GAM interceptor supports: - Filtering by specific bidders (bidders option) - Force rendering even when GAM has a line item (forceRender option) - Auto-initialization on module load - Stats tracking for debugging --- crates/js/lib/src/core/config.ts | 29 +- crates/js/lib/src/core/types.ts | 12 + crates/js/lib/src/integrations/gam/index.ts | 396 ++++++++++++++++++ .../lib/test/integrations/gam/index.test.ts | 121 ++++++ 4 files changed, 557 insertions(+), 1 deletion(-) create mode 100644 crates/js/lib/src/integrations/gam/index.ts create mode 100644 crates/js/lib/test/integrations/gam/index.test.ts diff --git a/crates/js/lib/src/core/config.ts b/crates/js/lib/src/core/config.ts index c06f0817..fa8e013d 100644 --- a/crates/js/lib/src/core/config.ts +++ b/crates/js/lib/src/core/config.ts @@ -1,10 +1,28 @@ // Global configuration storage for the tsjs runtime (mode, logging, etc.). import { log, LogLevel } from './log'; -import type { Config } from './types'; +import type { Config, GamConfig } from './types'; import { RequestMode } from './types'; let CONFIG: Config = { mode: RequestMode.FirstParty }; +// Lazy import to avoid circular dependencies - GAM integration may not be present +let setGamConfigFn: ((cfg: GamConfig) => void) | null | undefined = undefined; + +function getSetGamConfig(): ((cfg: GamConfig) => void) | null { + if (setGamConfigFn === undefined) { + try { + // Dynamic import path - bundler will include if gam integration is present + // eslint-disable-next-line @typescript-eslint/no-require-imports + const gam = require('../integrations/gam/index'); + setGamConfigFn = gam.setGamConfig || null; + } catch { + // GAM integration not available + setGamConfigFn = null; + } + } + return setGamConfigFn ?? null; +} + // Merge publisher-provided config and adjust the log level accordingly. export function setConfig(cfg: Config): void { CONFIG = { ...CONFIG, ...cfg }; @@ -12,6 +30,15 @@ export function setConfig(cfg: Config): void { const l = cfg.logLevel as LogLevel | undefined; if (typeof l === 'string') log.setLevel(l); else if (debugFlag === true) log.setLevel('debug'); + + // Forward GAM config to the GAM integration if present + if (cfg.gam) { + const setGam = getSetGamConfig(); + if (setGam) { + setGam(cfg.gam); + } + } + log.info('setConfig:', cfg); } diff --git a/crates/js/lib/src/core/types.ts b/crates/js/lib/src/core/types.ts index 72365fb9..5999fabc 100644 --- a/crates/js/lib/src/core/types.ts +++ b/crates/js/lib/src/core/types.ts @@ -47,11 +47,23 @@ export enum RequestMode { ThirdParty = 'thirdParty', } +/** GAM interceptor configuration. */ +export interface GamConfig { + /** Enable the GAM interceptor. Defaults to false. */ + enabled?: boolean; + /** Only intercept bids from these bidders. Empty array = all bidders. */ + bidders?: string[]; + /** Force render Prebid creative even if GAM returned a line item. Defaults to false. */ + forceRender?: boolean; +} + export interface Config { debug?: boolean; logLevel?: 'silent' | 'error' | 'warn' | 'info' | 'debug'; /** Select ad serving mode. Default is RequestMode.FirstParty. */ mode?: RequestMode; + /** GAM interceptor configuration. */ + gam?: GamConfig; // Extendable for future fields [key: string]: unknown; } diff --git a/crates/js/lib/src/integrations/gam/index.ts b/crates/js/lib/src/integrations/gam/index.ts new file mode 100644 index 00000000..2832067c --- /dev/null +++ b/crates/js/lib/src/integrations/gam/index.ts @@ -0,0 +1,396 @@ +// GAM (Google Ad Manager) Interceptor - forces Prebid creatives to render when +// GAM doesn't have matching line items configured. +// +// This integration intercepts GPT's slotRenderEnded event and replaces GAM's +// creative with the Prebid winning bid when: +// 1. A Prebid bid exists for the slot (hb_adid targeting is set) +// 2. The bid meets the configured criteria (specific bidder or any bidder) +// +// Configuration options: +// - enabled: boolean (default: false) - Master switch for the interceptor +// - bidders: string[] (default: []) - Only intercept for these bidders. Empty = all bidders +// - forceRender: boolean (default: false) - Render even if GAM has a line item +// +// Usage: +// window.tsGamConfig = { enabled: true, bidders: ['mocktioneer'] }; +// // or via tsjs.setConfig({ gam: { enabled: true, bidders: ['mocktioneer'] } }) + +import { log } from '../../core/log'; + +export interface TsGamConfig { + /** Enable the GAM interceptor. Defaults to false. */ + enabled?: boolean; + /** Only intercept bids from these bidders. Empty array = all bidders. */ + bidders?: string[]; + /** Force render Prebid creative even if GAM returned a line item. Defaults to false. */ + forceRender?: boolean; +} + +export interface TsGamApi { + setConfig(cfg: TsGamConfig): void; + getConfig(): TsGamConfig; + getStats(): GamInterceptStats; +} + +interface GamInterceptStats { + intercepted: number; + rendered: Array<{ + slotId: string; + adId: string; + bidder: string; + method: string; + timestamp: number; + }>; +} + +type GamWindow = Window & { + googletag?: { + pubads?: () => { + addEventListener: (event: string, callback: (e: SlotRenderEndedEvent) => void) => void; + getSlots?: () => GptSlot[]; + }; + }; + pbjs?: { + getBidResponsesForAdUnitCode?: (code: string) => { bids?: PrebidBid[] }; + renderAd?: (doc: Document, adId: string) => void; + }; + tsGamConfig?: TsGamConfig; + __tsGamInstalled?: boolean; +}; + +interface SlotRenderEndedEvent { + slot: GptSlot; + isEmpty: boolean; + lineItemId: number | null; +} + +interface GptSlot { + getSlotElementId(): string; + getTargeting(key: string): string[]; + getTargetingKeys(): string[]; +} + +interface PrebidBid { + adId?: string; + ad?: string; + adUrl?: string; + bidder?: string; + cpm?: number; +} + +interface IframeAttrs { + src: string; + width?: string; + height?: string; +} + +/** + * Extract iframe attributes from a creative that is just an iframe wrapper. + * Returns null if the creative is not a simple iframe tag. + * Exported for testing. + */ +export function extractIframeAttrs(html: string): IframeAttrs | null { + const trimmed = html.trim(); + // Check if it's a simple iframe tag (possibly with whitespace/newline after) + if (!trimmed.toLowerCase().startsWith(''; + expect(extractIframeSrc(html)).toBe('/first-party/proxy?tsurl=https://example.com'); + }); + + it('handles trailing newline (mocktioneer style)', async () => { + const { extractIframeSrc } = await import('../../../src/integrations/gam/index'); + + const html = + '\n'; + expect(extractIframeSrc(html)).toBe( + '/first-party/proxy?tsurl=https%3A%2F%2Flocal.mocktioneer.com' + ); + }); + + it('returns null for non-iframe content', async () => { + const { extractIframeSrc } = await import('../../../src/integrations/gam/index'); + + expect(extractIframeSrc('
not an iframe
')).toBeNull(); + expect(extractIframeSrc('')).toBeNull(); + }); + + it('returns null for iframe without src', async () => { + const { extractIframeSrc } = await import('../../../src/integrations/gam/index'); + + expect(extractIframeSrc('')).toBeNull(); + }); + + it('returns null for complex content with iframe', async () => { + const { extractIframeSrc } = await import('../../../src/integrations/gam/index'); + + expect(extractIframeSrc('
')).toBeNull(); + }); + }); +});