diff --git a/crates/js/lib/src/core/config.ts b/crates/js/lib/src/core/config.ts index c0bbe742..733dcfc8 100644 --- a/crates/js/lib/src/core/config.ts +++ b/crates/js/lib/src/core/config.ts @@ -1,5 +1,7 @@ -// Global configuration storage for the tsjs runtime (logging, debug, etc.). +// Global configuration storage for the tsjs runtime (mode, logging, etc.). import { log, LogLevel } from './log'; +import type { Config, GamConfig } from './types'; +import { RequestMode } from './types'; export interface Config { debug?: boolean; @@ -9,6 +11,25 @@ export interface Config { let CONFIG: Config = {}; + +// 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 }; @@ -16,6 +37,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 9f726bb9..b6ccc56a 100644 --- a/crates/js/lib/src/core/types.ts +++ b/crates/js/lib/src/core/types.ts @@ -26,8 +26,8 @@ export interface TsjsApi { addAdUnits(units: AdUnit | AdUnit[]): void; renderAdUnit(codeOrUnit: string | AdUnit): void; renderAllAdUnits(): void; - setConfig?(cfg: Record): void; - getConfig?(): Record; + setConfig?(cfg: Config): void; + getConfig?(): Config; requestAds?(opts?: { bidsBackHandler?: () => void; timeout?: number }): void; requestAds?( callback: () => void, @@ -42,3 +42,48 @@ export interface TsjsApi { debug(...args: unknown[]): void; }; } + +/** 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; +} + +// Core-neutral request types +export type RequestAdsCallback = () => void; +export interface RequestAdsOptions { + bidsBackHandler?: RequestAdsCallback; + timeout?: number; +} + +// Back-compat aliases for Prebid-style naming (used by the extension shim) +export type RequestBidsCallback = RequestAdsCallback; + +export interface HighestCpmBid { + adUnitCode: string; + width: number; + height: number; + cpm: number; + currency: string; + bidderCode: string; + creativeId: string; + adserverTargeting: Record; +} + +// Minimal OpenRTB response typing +// OpenRTB response typing is specific to the Prebid extension and lives in src/ext/types.ts 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(); + }); + }); +});