From 98986fdf58d79eee64f4d6b109835824491c1797 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 5 Feb 2026 11:07:43 -0800 Subject: [PATCH] Simplify TSJS core library - remove rendering/auction logic This is a breaking change that removes the ad rendering and auction logic from the core library, keeping only configuration and logging. ## Removed APIs - `tsjs.addAdUnits()` - ad unit registration - `tsjs.renderAdUnit()` - single ad unit rendering - `tsjs.renderAllAdUnits()` - batch ad unit rendering - `tsjs.requestAds()` - auction request - `tsjs.getHighestCpmBids()` - bid retrieval - `tsjs.que` - command queue ## Kept APIs - `tsjs.version` - version string - `tsjs.setConfig()` - configuration - `tsjs.getConfig()` - get current config - `tsjs.log` - logging utilities ## Removed Files - core/queue.ts, registry.ts, render.ts, request.ts, util.ts - core/styles/normalize.css, core/templates/iframe.html - integrations/ext/ (Prebid shim - replaced by prebid integration) - integrations/testlight/ (obsolete test integration) ## Migration Use the GAM interceptor integration for rendering Prebid creatives, or the Prebid NPM integration for server-side bidding. Bundle size reduced from ~32KB to ~23KB (gzipped: ~7.5KB). --- crates/js/lib/src/core/config.ts | 3 +- crates/js/lib/src/core/global.d.ts | 4 +- crates/js/lib/src/core/index.ts | 43 +---- crates/js/lib/src/core/queue.ts | 23 --- crates/js/lib/src/core/registry.ts | 36 ---- crates/js/lib/src/core/render.ts | 181 ------------------ crates/js/lib/src/core/request.ts | 176 ----------------- crates/js/lib/src/core/styles/normalize.css | 169 ---------------- crates/js/lib/src/core/templates/iframe.html | 8 - crates/js/lib/src/core/types.ts | 31 +-- crates/js/lib/src/core/util.ts | 9 - crates/js/lib/src/integrations/ext/index.ts | 4 - .../js/lib/src/integrations/ext/prebidjs.ts | 121 ------------ crates/js/lib/src/integrations/ext/types.ts | 12 -- .../lib/src/integrations/testlight/index.ts | 81 -------- crates/js/lib/test/core/index.test.ts | 79 ++------ crates/js/lib/test/core/registry.test.ts | 27 --- crates/js/lib/test/core/render.test.ts | 18 -- crates/js/lib/test/core/request.test.ts | 162 ---------------- .../test/integrations/ext/prebidjs.test.ts | 58 ------ 20 files changed, 31 insertions(+), 1214 deletions(-) delete mode 100644 crates/js/lib/src/core/queue.ts delete mode 100644 crates/js/lib/src/core/registry.ts delete mode 100644 crates/js/lib/src/core/render.ts delete mode 100644 crates/js/lib/src/core/request.ts delete mode 100644 crates/js/lib/src/core/styles/normalize.css delete mode 100644 crates/js/lib/src/core/templates/iframe.html delete mode 100644 crates/js/lib/src/core/util.ts delete mode 100644 crates/js/lib/src/integrations/ext/index.ts delete mode 100644 crates/js/lib/src/integrations/ext/prebidjs.ts delete mode 100644 crates/js/lib/src/integrations/ext/types.ts delete mode 100644 crates/js/lib/src/integrations/testlight/index.ts delete mode 100644 crates/js/lib/test/core/registry.test.ts delete mode 100644 crates/js/lib/test/core/render.test.ts delete mode 100644 crates/js/lib/test/core/request.test.ts delete mode 100644 crates/js/lib/test/integrations/ext/prebidjs.test.ts diff --git a/crates/js/lib/src/core/config.ts b/crates/js/lib/src/core/config.ts index fa8e013d..b0fdbf91 100644 --- a/crates/js/lib/src/core/config.ts +++ b/crates/js/lib/src/core/config.ts @@ -1,9 +1,8 @@ // 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'; -let CONFIG: Config = { mode: RequestMode.FirstParty }; +let CONFIG: Config = { mode: 'render' }; // Lazy import to avoid circular dependencies - GAM integration may not be present let setGamConfigFn: ((cfg: GamConfig) => void) | null | undefined = undefined; diff --git a/crates/js/lib/src/core/global.d.ts b/crates/js/lib/src/core/global.d.ts index c7c8b08f..ea91f97b 100644 --- a/crates/js/lib/src/core/global.d.ts +++ b/crates/js/lib/src/core/global.d.ts @@ -3,7 +3,9 @@ import type { TsjsApi } from './types'; declare global { interface Window { tsjs?: TsjsApi; - pbjs?: TsjsApi; + // pbjs is Prebid.js which has its own types + // eslint-disable-next-line @typescript-eslint/no-explicit-any + pbjs?: any; } } diff --git a/crates/js/lib/src/core/index.ts b/crates/js/lib/src/core/index.ts index 227b1a35..41c8270e 100644 --- a/crates/js/lib/src/core/index.ts +++ b/crates/js/lib/src/core/index.ts @@ -1,12 +1,8 @@ -// Public tsjs core bundle: sets up the global API, queue, and default methods. -export type { AdUnit, TsjsApi } from './types'; +// Public tsjs core bundle: sets up the global API. +export type { TsjsApi } from './types'; import type { TsjsApi } from './types'; -import { addAdUnits } from './registry'; -import { renderAdUnit, renderAllAdUnits } from './render'; import { log } from './log'; import { setConfig, getConfig } from './config'; -import { requestAds } from './request'; -import { installQueue } from './queue'; const VERSION = '0.1.0'; @@ -15,45 +11,12 @@ const w: Window & { tsjs?: TsjsApi } = tsjs?: TsjsApi; }) || ({} as Window & { tsjs?: TsjsApi }); -// Collect existing tsjs queued fns before we overwrite -const pending: Array<() => void> = Array.isArray(w.tsjs?.que) ? [...w.tsjs.que] : []; - // Create API and attach methods const api: TsjsApi = (w.tsjs ??= {} as TsjsApi); api.version = VERSION; -api.addAdUnits = addAdUnits; -api.renderAdUnit = renderAdUnit; -api.renderAllAdUnits = () => renderAllAdUnits(); api.log = log; api.setConfig = setConfig; api.getConfig = getConfig; -// Provide core requestAds API -api.requestAds = requestAds; -// Point global tsjs w.tsjs = api; -// Single shared queue -installQueue(api, w); - -// Flush prior queued callbacks -for (const fn of pending) { - try { - if (typeof fn === 'function') { - fn.call(api); - log.debug('queue: flushed callback'); - } - } catch { - /* ignore queued callback error */ - } -} - -log.info('tsjs initialized', { - methods: [ - 'setConfig', - 'getConfig', - 'requestAds', - 'addAdUnits', - 'renderAdUnit', - 'renderAllAdUnits', - ], -}); +log.info('tsjs initialized', { version: VERSION }); diff --git a/crates/js/lib/src/core/queue.ts b/crates/js/lib/src/core/queue.ts deleted file mode 100644 index 73c2741b..00000000 --- a/crates/js/lib/src/core/queue.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Minimal Prebid-style queue shim that executes callbacks immediately. -import { log } from './log'; - -// Replace the legacy Prebid-style queue with an immediate executor so queued work runs in order. -export function installQueue void> }>( - target: T, - w: Window & { tsjs?: T } -) { - const q: Array<() => void> = []; - q.push = ((fn: () => void) => { - if (typeof fn === 'function') { - try { - fn.call(target); - log.debug('queue: push executed immediately'); - } catch { - /* ignore queued fn error */ - } - } - return q.length; - }) as typeof q.push; - target.que = q; - if (w.tsjs) w.tsjs.que = q; -} diff --git a/crates/js/lib/src/core/registry.ts b/crates/js/lib/src/core/registry.ts deleted file mode 100644 index ea77937a..00000000 --- a/crates/js/lib/src/core/registry.ts +++ /dev/null @@ -1,36 +0,0 @@ -// In-memory registry for ad units registered via tsjs (used by core + extensions). -import type { AdUnit, Size } from './types'; -import { toArray } from './util'; -import { log } from './log'; - -const registry = new Map(); - -// Merge ad unit definitions into the in-memory registry (supports array or single unit). -export function addAdUnits(units: AdUnit | AdUnit[]): void { - for (const u of toArray(units)) { - if (!u || !u.code) continue; - registry.set(u.code, { ...registry.get(u.code), ...u }); - } - log.info('addAdUnits:', { count: toArray(units).length }); -} - -// Convenience helper to grab the first banner size off an ad unit. -export function firstSize(unit: AdUnit): Size | null { - const sizes = unit.mediaTypes?.banner?.sizes; - return sizes && sizes.length ? sizes[0] : null; -} - -// Return a snapshot array of all registered ad units. -export function getAllUnits(): AdUnit[] { - return Array.from(registry.values()); -} - -// Look up a unit by its code. -export function getUnit(code: string): AdUnit | undefined { - return registry.get(code); -} - -// Extract just the ad unit codes for quick iteration. -export function getAllCodes(): string[] { - return Array.from(registry.keys()); -} diff --git a/crates/js/lib/src/core/render.ts b/crates/js/lib/src/core/render.ts deleted file mode 100644 index 46851855..00000000 --- a/crates/js/lib/src/core/render.ts +++ /dev/null @@ -1,181 +0,0 @@ -// Rendering utilities for Trusted Server demo placements: find slots, seed placeholders, -// and inject creatives into sandboxed iframes. -import { log } from './log'; -import type { AdUnit } from './types'; -import { getUnit, getAllUnits, firstSize } from './registry'; -import NORMALIZE_CSS from './styles/normalize.css?inline'; -import IFRAME_TEMPLATE from './templates/iframe.html?raw'; - -function normalizeId(raw: string): string { - const s = String(raw ?? '').trim(); - return s.startsWith('#') ? s.slice(1) : s; -} - -// Locate an ad slot element by id, tolerating funky selectors provided by tag managers. -export function findSlot(id: string): HTMLElement | null { - const nid = normalizeId(id); - // Fast path - const byId = document.getElementById(nid) as HTMLElement | null; - if (byId) return byId; - // Fallback for odd IDs (special chars) or if provided with quotes/etc. - try { - const selector = `[id="${nid.replace(/"/g, '\\"')}"]`; - const byAttr = document.querySelector(selector) as HTMLElement | null; - if (byAttr) return byAttr; - } catch { - // Ignore selector errors (e.g., invalid characters) - } - return null; -} - -function ensureSlot(id: string): HTMLElement { - const nid = normalizeId(id); - let el = document.getElementById(nid) as HTMLElement | null; - if (el) return el; - el = document.createElement('div'); - el.id = nid; - const body: HTMLElement | null = typeof document !== 'undefined' ? document.body : null; - if (body && typeof body.appendChild === 'function') { - body.appendChild(el); - } else { - // DOM not ready — attach once available - const element = el; - const onReady = () => { - const readyBody = document.body; - if (readyBody && !document.getElementById(nid) && element) readyBody.appendChild(element); - }; - document.addEventListener('DOMContentLoaded', onReady, { once: true }); - } - return el; -} - -// Drop a placeholder message into the slot so pages don't sit empty pre-render. -export function renderAdUnit(codeOrUnit: string | AdUnit): void { - const code = typeof codeOrUnit === 'string' ? codeOrUnit : codeOrUnit?.code; - if (!code) return; - const unit = typeof codeOrUnit === 'string' ? getUnit(code) : codeOrUnit; - const size = (unit && firstSize(unit)) || [300, 250]; - const el = ensureSlot(code); - try { - el.textContent = `Trusted Server — ${size[0]}x${size[1]}`; - log.info('renderAdUnit: rendered placeholder', { code, size }); - } catch { - log.warn('renderAdUnit: failed', { code }); - } -} - -// Render placeholders for every registered ad unit (used in simple publisher demos). -export function renderAllAdUnits(): void { - try { - const parentReady = - typeof document !== 'undefined' && (document.body || document.documentElement); - if (!parentReady) { - log.warn('renderAllAdUnits: DOM not ready; skipping'); - return; - } - const units = getAllUnits(); - for (const u of units) { - renderAdUnit(u); - } - log.info('renderAllAdUnits: rendered all placeholders', { count: units.length }); - } catch (e) { - log.warn('renderAllAdUnits: failed', e as unknown); - } -} - -// Swap the slot contents for a creative iframe and write HTML into it safely. -export function renderCreativeIntoSlot(slotId: string, html: string): void { - const el = findSlot(slotId); - if (!el) { - log.warn('renderCreativeIntoSlot: slot not found; skipping render', { slotId }); - return; - } - try { - // Clear previous content - el.innerHTML = ''; - // Determine size if available - const unit = getUnit(slotId); - const sz = (unit && firstSize(unit)) || [300, 250]; - const iframe = createAdIframe(el, { - name: `tsjs_iframe_${slotId}`, - title: 'Ad content', - width: sz[0], - height: sz[1], - }); - writeHtmlToIframe(iframe, html); - log.info('renderCreativeIntoSlot: rendered', { slotId, width: sz[0], height: sz[1] }); - } catch (err) { - log.warn('renderCreativeIntoSlot: failed', { slotId, err }); - } -} - -type IframeOptions = { name?: string; title?: string; width?: number; height?: number }; - -// Construct a sandboxed iframe sized for the ad so we can render arbitrary HTML. -export function createAdIframe( - container: HTMLElement, - opts: IframeOptions = {} -): HTMLIFrameElement { - const iframe = document.createElement('iframe'); - // Attributes - iframe.scrolling = 'no'; - iframe.frameBorder = '0'; - iframe.setAttribute('marginwidth', '0'); - iframe.setAttribute('marginheight', '0'); - if (opts.name) iframe.name = String(opts.name); - iframe.title = opts.title || 'Ad content'; - iframe.setAttribute('aria-label', 'Advertisement'); - // Sandbox permissions for creatives - try { - iframe.sandbox.add( - 'allow-forms', - 'allow-popups', - 'allow-popups-to-escape-sandbox', - 'allow-same-origin', - 'allow-scripts', - 'allow-top-navigation-by-user-activation' - ); - } catch (err) { - log.debug('createAdIframe: sandbox add failed', err); - } - // Sizing + style - const w = Math.max(0, Number(opts.width ?? 0) | 0); - const h = Math.max(0, Number(opts.height ?? 0) | 0); - if (w > 0) iframe.width = String(w); - if (h > 0) iframe.height = String(h); - const s = iframe.style; - s.setProperty('border', '0'); - s.setProperty('margin', '0'); - s.setProperty('overflow', 'hidden'); - s.setProperty('display', 'block'); - if (w > 0) s.setProperty('width', `${w}px`); - if (h > 0) s.setProperty('height', `${h}px`); - // Insert into container - container.appendChild(iframe); - return iframe; -} - -function writeHtmlToIframe(iframe: HTMLIFrameElement, creativeHtml: string): void { - try { - const doc = (iframe.contentDocument || iframe.contentWindow?.document) as Document | undefined; - if (!doc) return; - const html = buildIframeDocument(creativeHtml); - doc.open(); - doc.write(html); - doc.close(); - } catch (err) { - log.warn('renderCreativeIntoSlot: iframe write failed', { err }); - } -} - -function buildIframeDocument(creativeHtml: string): string { - return IFRAME_TEMPLATE.replace('%NORMALIZE_CSS%', NORMALIZE_CSS).replace( - '%CREATIVE_HTML%', - creativeHtml - ); -} - -// Build a complete HTML document for a creative, suitable for use with iframe.srcdoc -export function buildCreativeDocument(creativeHtml: string): string { - return buildIframeDocument(creativeHtml); -} diff --git a/crates/js/lib/src/core/request.ts b/crates/js/lib/src/core/request.ts deleted file mode 100644 index a84af082..00000000 --- a/crates/js/lib/src/core/request.ts +++ /dev/null @@ -1,176 +0,0 @@ -// Request orchestration for tsjs: unified auction endpoint with iframe-based creative rendering. -import { log } from './log'; -import { getAllUnits, firstSize } from './registry'; -import { createAdIframe, findSlot, buildCreativeDocument } from './render'; -import type { RequestAdsCallback, RequestAdsOptions } from './types'; - -// getHighestCpmBids is provided by the Prebid extension (shim) to mirror Prebid's API - -// Entry point matching Prebid's requestBids signature; uses unified /auction endpoint. -export function requestAds( - callbackOrOpts?: RequestAdsCallback | RequestAdsOptions, - maybeOpts?: RequestAdsOptions -): void { - let callback: RequestAdsCallback | undefined; - let opts: RequestAdsOptions | undefined; - if (typeof callbackOrOpts === 'function') { - callback = callbackOrOpts as RequestAdsCallback; - opts = maybeOpts; - } else { - opts = callbackOrOpts as RequestAdsOptions | undefined; - callback = opts?.bidsBackHandler; - } - - log.info('requestAds: called', { hasCallback: typeof callback === 'function' }); - try { - const adUnits = getAllUnits(); - const payload = { adUnits, config: {} }; - log.debug('requestAds: payload', { units: adUnits.length }); - - // Use unified auction endpoint - void requestAdsUnified(payload); - - // Synchronously invoke callback to match test expectations - try { - if (callback) callback(); - } catch { - /* ignore callback errors */ - } - } catch { - log.warn('requestAds: failed to initiate'); - } -} - -// Fire a JSON POST to the unified /auction endpoint and render creatives via iframes. -function requestAdsUnified(payload: { adUnits: unknown[]; config: unknown }) { - if (typeof fetch !== 'function') { - log.warn('requestAds: fetch not available; nothing to render'); - return; - } - - log.info('requestAds: sending request to /auction', { - units: (payload.adUnits || []).length, - }); - - void fetch('/auction', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - credentials: 'same-origin', - body: JSON.stringify(payload), - keepalive: true, - }) - .then(async (res) => { - log.debug('requestAds: received response'); - try { - const ct = res.headers.get('content-type') || ''; - if (res.ok && ct.includes('application/json')) { - const data: unknown = await res.json(); - const bids = parseSeatBids(data); - - log.info('requestAds: got bids', { count: bids.length }); - - for (const bid of bids) { - if (bid.impid && bid.adm) { - // adm contains the creative HTML directly (already rewritten with proxy URLs) - renderCreativeInline(String(bid.impid), String(bid.adm), bid.w, bid.h); - } - } - - log.info('requestAds: rendered creatives from response'); - return; - } - log.warn('requestAds: unexpected response', { ok: res.ok, status: res.status, ct }); - } catch (err) { - log.warn('requestAds: failed to process response', err); - } - }) - .catch((e) => { - log.warn('requestAds: failed', e); - }); -} - -// Render a creative by writing HTML directly into a sandboxed iframe. -function renderCreativeInline( - slotId: string, - creativeHtml: string, - creativeWidth?: number, - creativeHeight?: number -): void { - const container = findSlot(slotId) as HTMLElement | null; - if (!container) { - log.warn('renderCreativeInline: slot not found; skipping render', { slotId }); - return; - } - - try { - // Clear previous content - container.innerHTML = ''; - - // Determine size with fallback chain: creative size → ad unit size → 300x250 - let width: number; - let height: number; - - if (creativeWidth && creativeHeight && creativeWidth > 0 && creativeHeight > 0) { - // Use actual creative dimensions from bid response - width = creativeWidth; - height = creativeHeight; - log.debug('renderCreativeInline: using creative dimensions', { width, height }); - } else { - // Fallback to ad unit's first size, then to 300x250 - const unit = getAllUnits().find((u) => u.code === slotId); - const size = (unit && firstSize(unit)) || [300, 250]; - width = size[0]; - height = size[1]; - log.debug('renderCreativeInline: using ad unit dimensions', { width, height }); - } - - // Create iframe sized for the ad - const iframe = createAdIframe(container, { - name: `tsjs_iframe_${slotId}`, - title: 'Ad content', - width, - height, - }); - - // Write creative HTML directly into iframe using srcdoc - iframe.srcdoc = buildCreativeDocument(creativeHtml); - - log.info('renderCreativeInline: rendered', { - slotId, - width, - height, - htmlLength: creativeHtml.length, - }); - } catch (err) { - log.warn('renderCreativeInline: failed', { slotId, err }); - } -} - -// Local minimal OpenRTB typing to keep core decoupled from Prebid extension types -type RtBid = { impid?: string; adm?: string; w?: number; h?: number }; -type RtSeatBid = { bid?: RtBid[] | null }; -type RtResponse = { seatbid?: RtSeatBid[] | null }; - -function isSeatBidArray(x: unknown): x is RtSeatBid[] { - return Array.isArray(x); -} - -// Minimal OpenRTB seatbid parser—just enough to render adm by impid. -function parseSeatBids(data: unknown): RtBid[] { - const out: RtBid[] = []; - const resp = data as Partial; - const seatbids = resp && resp.seatbid; - if (!seatbids || !isSeatBidArray(seatbids)) return out; - for (const sb of seatbids) { - const bids = sb && sb.bid; - if (!Array.isArray(bids)) continue; - for (const b of bids) { - const impid = typeof b?.impid === 'string' ? b!.impid : undefined; - const adm = typeof b?.adm === 'string' ? b!.adm : undefined; - const w = typeof b?.w === 'number' ? b!.w : undefined; - const h = typeof b?.h === 'number' ? b!.h : undefined; - out.push({ impid, adm, w, h }); - } - } - return out; -} diff --git a/crates/js/lib/src/core/styles/normalize.css b/crates/js/lib/src/core/styles/normalize.css deleted file mode 100644 index da77bad7..00000000 --- a/crates/js/lib/src/core/styles/normalize.css +++ /dev/null @@ -1,169 +0,0 @@ -/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ -button, -hr, -input { - overflow: visible; -} - -progress, -sub, -sup { - vertical-align: baseline; -} - -[type='checkbox'], -[type='radio'], -legend { - box-sizing: border-box; - padding: 0; -} - -html { - line-height: 1.15; - -webkit-text-size-adjust: 100%; -} - -body { - margin: 0; -} - -details, -main { - display: block; -} - -h1 { - font-size: 2em; - margin: 0.67em 0; -} - -hr { - box-sizing: content-box; - height: 0; -} - -code, -kbd, -pre, -samp { - font-family: monospace, monospace; - font-size: 1em; -} - -a { - background-color: transparent; -} - -abbr[title] { - border-bottom: none; - text-decoration: underline dotted; -} - -b, -strong { - font-weight: bolder; -} - -small { - font-size: 80%; -} - -sub, -sup { - font-size: 75%; - line-height: 0; - position: relative; -} - -sub { - bottom: -0.25em; -} - -sup { - top: -0.5em; -} - -img { - border-style: none; -} - -button, -input, -optgroup, -select, -textarea { - font-family: inherit; - font-size: 100%; - line-height: 1.15; - margin: 0; -} - -button, -select { - text-transform: none; -} - -[type='button'], -[type='reset'], -[type='submit'], -button { - -webkit-appearance: button; -} - -[type='button']::-moz-focus-inner, -[type='reset']::-moz-focus-inner, -[type='submit']::-moz-focus-inner, -button::-moz-focus-inner { - border-style: none; - padding: 0; -} - -[type='button']:-moz-focusring, -[type='reset']:-moz-focusring, -[type='submit']:-moz-focusring, -button:-moz-focusring { - outline: 1px dotted ButtonText; -} - -fieldset { - padding: 0.35em 0.75em 0.625em; -} - -legend { - color: inherit; - display: table; - max-width: 100%; - white-space: normal; -} - -textarea { - overflow: auto; -} - -[type='number']::-webkit-inner-spin-button, -[type='number']::-webkit-outer-spin-button { - height: auto; -} - -[type='search'] { - -webkit-appearance: textfield; - outline-offset: -2px; -} - -[type='search']::-webkit-search-decoration { - -webkit-appearance: none; -} - -::-webkit-file-upload-button { - -webkit-appearance: button; - font: inherit; -} - -summary { - display: list-item; -} - -[hidden], -template { - display: none; -} diff --git a/crates/js/lib/src/core/templates/iframe.html b/crates/js/lib/src/core/templates/iframe.html deleted file mode 100644 index 55af0d5f..00000000 --- a/crates/js/lib/src/core/templates/iframe.html +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - %CREATIVE_HTML% - diff --git a/crates/js/lib/src/core/types.ts b/crates/js/lib/src/core/types.ts index 5999fabc..c32cea42 100644 --- a/crates/js/lib/src/core/types.ts +++ b/crates/js/lib/src/core/types.ts @@ -1,6 +1,8 @@ // Shared TypeScript types for the tsjs core API and extensions. export type Size = readonly [number, number]; +export type LogLevel = 'silent' | 'error' | 'warn' | 'info' | 'debug'; + export interface Banner { sizes: ReadonlyArray; } @@ -22,19 +24,11 @@ export interface AdUnit { export interface TsjsApi { version: string; - que: Array<() => void>; - addAdUnits(units: AdUnit | AdUnit[]): void; - renderAdUnit(codeOrUnit: string | AdUnit): void; - renderAllAdUnits(): void; - setConfig?(cfg: Config): void; - getConfig?(): Config; - // Core API: requestAds; accepts same signatures as Prebid's requestBids - requestAds?(opts?: RequestAdsOptions): void; - requestAds?(callback: RequestAdsCallback, opts?: RequestAdsOptions): void; - getHighestCpmBids?(adUnitCodes?: string | string[]): ReadonlyArray; - log?: { - setLevel(l: 'silent' | 'error' | 'warn' | 'info' | 'debug'): void; - getLevel(): 'silent' | 'error' | 'warn' | 'info' | 'debug'; + setConfig(cfg: Config): void; + getConfig(): Config; + log: { + setLevel(l: LogLevel): void; + getLevel(): LogLevel; info(...args: unknown[]): void; warn(...args: unknown[]): void; error(...args: unknown[]): void; @@ -42,11 +36,6 @@ export interface TsjsApi { }; } -export enum RequestMode { - FirstParty = 'firstParty', - ThirdParty = 'thirdParty', -} - /** GAM interceptor configuration. */ export interface GamConfig { /** Enable the GAM interceptor. Defaults to false. */ @@ -59,9 +48,9 @@ export interface GamConfig { export interface Config { debug?: boolean; - logLevel?: 'silent' | 'error' | 'warn' | 'info' | 'debug'; - /** Select ad serving mode. Default is RequestMode.FirstParty. */ - mode?: RequestMode; + logLevel?: LogLevel; + /** Select ad serving mode: 'render' or 'auction'. */ + mode?: 'render' | 'auction'; /** GAM interceptor configuration. */ gam?: GamConfig; // Extendable for future fields diff --git a/crates/js/lib/src/core/util.ts b/crates/js/lib/src/core/util.ts deleted file mode 100644 index be9a8551..00000000 --- a/crates/js/lib/src/core/util.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Tiny shared helpers used across core modules. -export function isArray(v: unknown): v is T[] { - return Array.isArray(v); -} - -// Normalise a single value into an array for simple iteration helpers. -export function toArray(v: T | T[]): T[] { - return isArray(v) ? v : [v]; -} diff --git a/crates/js/lib/src/integrations/ext/index.ts b/crates/js/lib/src/integrations/ext/index.ts deleted file mode 100644 index 2cf79719..00000000 --- a/crates/js/lib/src/integrations/ext/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { installPrebidJsShim } from './prebidjs'; - -// Execute immediately on import; safe no-op if pbjs is not present. -void installPrebidJsShim(); diff --git a/crates/js/lib/src/integrations/ext/prebidjs.ts b/crates/js/lib/src/integrations/ext/prebidjs.ts deleted file mode 100644 index 242ebc02..00000000 --- a/crates/js/lib/src/integrations/ext/prebidjs.ts +++ /dev/null @@ -1,121 +0,0 @@ -// Prebid.js compatibility shim: exposes tsjs API through the legacy pbjs global. -import type { - TsjsApi, - HighestCpmBid, - RequestAdsCallback, - RequestAdsOptions, -} from '../../core/types'; -import { log } from '../../core/log'; -import { installQueue } from '../../core/queue'; -import { getAllCodes, getAllUnits, firstSize } from '../../core/registry'; -import { resolvePrebidWindow, PrebidWindow } from '../../shared/globals'; -type RequestBidsFunction = ( - callbackOrOpts?: RequestAdsCallback | RequestAdsOptions, - opts?: RequestAdsOptions -) => void; - -/** - * Shim implementation for pbjs.getHighestCpmBids that returns synthetic - * placeholder bids derived from the registered core ad units. - */ -function getHighestCpmBidsShim(adUnitCodes?: string | string[]): ReadonlyArray { - const codes: string[] = - typeof adUnitCodes === 'string' ? [adUnitCodes] : (adUnitCodes ?? getAllCodes()); - const results: HighestCpmBid[] = []; - for (const code of codes) { - const unit = getAllUnits().find((u) => u.code === code); - if (!unit) continue; - const size = (firstSize(unit) ?? [300, 250]) as readonly [number, number]; - results.push({ - adUnitCode: code, - width: size[0], - height: size[1], - cpm: 0, - currency: 'USD', - bidderCode: 'tsjs', - creativeId: 'tsjs-placeholder', - adserverTargeting: {}, - }); - } - return results; -} - -/** - * Shim implementation for pbjs.requestBids that forwards to core requestAds. - */ -function requestBidsShim(api: TsjsApi): RequestBidsFunction { - return (callbackOrOpts?: RequestAdsCallback | RequestAdsOptions, opts?: RequestAdsOptions) => { - const requestAds = api.requestAds as - | ((options?: RequestAdsOptions) => void) - | ((callback: RequestAdsCallback, options?: RequestAdsOptions) => void) - | undefined; - if (!requestAds) return; - if (typeof callbackOrOpts === 'function') { - requestAds(callbackOrOpts, opts); - } else { - requestAds(callbackOrOpts); - } - }; -} - -// Guarantee a tsjs API stub exists so we can alias pbjs onto it. -function ensureTsjsApi(win: PrebidWindow): TsjsApi { - if (win.tsjs) return win.tsjs; - const stub: TsjsApi = { - version: '0.0.0', - que: [], - addAdUnits: () => undefined, - renderAdUnit: () => undefined, - renderAllAdUnits: () => undefined, - }; - win.tsjs = stub; - return stub; -} - -// Bridge the minimal tsjs API onto the legacy pbjs global so existing tags keep working. -export function installPrebidJsShim(): boolean { - const w = resolvePrebidWindow(); - - // Ensure core exists - const api = ensureTsjsApi(w); - - // Capture any queued pbjs callbacks before aliasing - const pending: Array<() => void> = Array.isArray(w.pbjs?.que) ? [...(w.pbjs?.que ?? [])] : []; - - // Core provides requestAds/getHighestCpmBids; extension aliases pbjs and shims requestBids → requestAds - - // Alias pbjs to tsjs and ensure a single shared queue - w.pbjs = api; - if (!Array.isArray(api.que)) { - installQueue(api, w); - } - const pbjsApi = w.pbjs as TsjsApi & { requestBids?: RequestBidsFunction }; - // Make sure both globals share the same queue - if (Array.isArray(api.que)) { - pbjsApi.que = api.que; - } - // Shim Prebid-style API surface - pbjsApi.requestBids = requestBidsShim(api); - pbjsApi.getHighestCpmBids = getHighestCpmBidsShim; - - // Flush previously queued pbjs callbacks - for (const fn of pending) { - try { - if (typeof fn === 'function') { - fn.call(api); - log.debug('prebidjs extension: flushed callback'); - } - } catch (err) { - log.debug('prebidjs extension: queued callback failed', err); - } - } - - log.info('prebidjs extension installed', { - hasRequestBids: typeof pbjsApi.requestBids === 'function', - hasGetHighestCpmBids: typeof pbjsApi.getHighestCpmBids === 'function', - }); - - return true; -} - -export default installPrebidJsShim; diff --git a/crates/js/lib/src/integrations/ext/types.ts b/crates/js/lib/src/integrations/ext/types.ts deleted file mode 100644 index a83a499f..00000000 --- a/crates/js/lib/src/integrations/ext/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Minimal OpenRTB response typing (used by the Prebid extension) -export interface OpenRtbBid { - impid?: string; - adm?: string; - [key: string]: unknown; -} -export interface OpenRtbSeatBid { - bid?: OpenRtbBid[] | null; -} -export interface OpenRtbBidResponse { - seatbid?: OpenRtbSeatBid[] | null; -} diff --git a/crates/js/lib/src/integrations/testlight/index.ts b/crates/js/lib/src/integrations/testlight/index.ts deleted file mode 100644 index 3db00ce0..00000000 --- a/crates/js/lib/src/integrations/testlight/index.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { TsjsApi } from '../../core/types'; -import { installQueue } from '../../core/queue'; -import { log } from '../../core/log'; -import { resolvePrebidWindow, PrebidWindow } from '../../shared/globals'; - -type TestlightCallback = () => void; - -type TestlightGlobal = { - que?: TestlightCallback[]; -}; - -type TestlightWindow = PrebidWindow & { - testlight?: TestlightGlobal; -}; - -function ensureTsjsApi(win: TestlightWindow): TsjsApi { - if (win.tsjs) return win.tsjs; - const stub: TsjsApi = { - version: '0.0.0', - que: [], - addAdUnits: () => undefined, - renderAdUnit: () => undefined, - renderAllAdUnits: () => undefined, - }; - win.tsjs = stub; - return stub; -} - -function installTestlightQueue(api: TsjsApi, win: TestlightWindow): void { - if (!Array.isArray(api.que)) { - installQueue(api, win); - } -} - -function flushCallbacks(queue: TestlightCallback[], api: TsjsApi): void { - while (queue.length > 0) { - const fn = queue.shift(); - if (typeof fn !== 'function') { - continue; - } - try { - if (Array.isArray(api.que)) { - api.que.push(fn); - } else { - fn.call(api); - } - log.debug('testlight shim: flushed callback'); - } catch (err) { - log.debug('testlight shim: queued callback threw', err); - } - } -} - -export function installTestlightShim(): boolean { - const win = resolvePrebidWindow() as TestlightWindow; - const api = ensureTsjsApi(win); - installTestlightQueue(api, win); - - const testlight = (win.testlight = win.testlight ?? {}); - const pending: TestlightCallback[] = Array.isArray(testlight.que) ? [...testlight.que] : []; - const queue: TestlightCallback[] = []; - testlight.que = queue; - - const originalPush = queue.push.bind(queue); - queue.push = function (...callbacks: TestlightCallback[]): number { - const len = originalPush(...callbacks); - flushCallbacks(queue, api); - return len; - }; - - if (pending.length > 0) { - queue.push(...pending); - } - - log.info('testlight shim installed', { queuedCallbacks: queue.length }); - return true; -} - -if (typeof window !== 'undefined') { - installTestlightShim(); -} diff --git a/crates/js/lib/test/core/index.test.ts b/crates/js/lib/test/core/index.test.ts index 38fefbe2..5754a8a8 100644 --- a/crates/js/lib/test/core/index.test.ts +++ b/crates/js/lib/test/core/index.test.ts @@ -1,80 +1,29 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; - -declare global { - interface Window { - tsjs?: any; - } -} - -const ORIGINAL_FETCH = global.fetch; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { TsjsApi } from '../../src/core/types'; describe('core/index', () => { beforeEach(async () => { await vi.resetModules(); - document.body.innerHTML = ''; - delete (window as any).tsjs; - }); - - afterEach(() => { - global.fetch = ORIGINAL_FETCH; + delete (window as typeof window & { tsjs?: TsjsApi }).tsjs; }); it('initializes tsjs API with expected surface', async () => { await import('../../src/core/index'); - const api = window.tsjs; + const api = (window as typeof window & { tsjs?: TsjsApi }).tsjs; expect(api).toBeDefined(); - expect(typeof api.version).toBe('string'); - expect(Array.isArray(api.que)).toBe(true); - expect(typeof api.addAdUnits).toBe('function'); - expect(typeof api.renderAdUnit).toBe('function'); - expect(typeof api.renderAllAdUnits).toBe('function'); - expect(typeof api.setConfig).toBe('function'); - expect(typeof api.getConfig).toBe('function'); - expect(typeof api.requestAds).toBe('function'); - }); - - it('flushes queued callbacks that existed before initialization', async () => { - const callback = vi.fn(function () { - expect(this).toBe(window.tsjs); - }); - (window as any).tsjs = { que: [callback] }; - - await import('../../src/core/index'); - - expect(callback).toHaveBeenCalledTimes(1); - }); - - it('installs queue that executes callbacks immediately with api context', async () => { - await import('../../src/core/index'); - const api = window.tsjs; - const fn = vi.fn(); - - api.que.push(fn); - - expect(fn).toHaveBeenCalledTimes(1); - expect(fn.mock.instances[0]).toBe(api); - }); - - it('renders registered ad units using core rendering helpers', async () => { - await import('../../src/core/index'); - const api = window.tsjs; - - api.addAdUnits([ - { code: 'slot-1', mediaTypes: { banner: { sizes: [[300, 250]] } } }, - { code: 'slot-2', mediaTypes: { banner: { sizes: [[320, 50]] } } }, - ]); - - api.renderAllAdUnits(); - - expect(document.getElementById('slot-1')?.textContent).toContain('300x250'); - expect(document.getElementById('slot-2')?.textContent).toContain('320x50'); + expect(typeof api!.version).toBe('string'); + expect(typeof api!.setConfig).toBe('function'); + expect(typeof api!.getConfig).toBe('function'); + expect(api!.log).toBeDefined(); }); - it('exposes requestAds from the core request module', async () => { - const { requestAds } = await import('../../src/core/request'); + it('setConfig updates config', async () => { await import('../../src/core/index'); - const api = window.tsjs; + const api = (window as typeof window & { tsjs?: TsjsApi }).tsjs!; - expect(api.requestAds).toBe(requestAds); + api.setConfig({ mode: 'auction', debug: true }); + const cfg = api.getConfig(); + expect(cfg.mode).toBe('auction'); + expect(cfg.debug).toBe(true); }); }); diff --git a/crates/js/lib/test/core/registry.test.ts b/crates/js/lib/test/core/registry.test.ts deleted file mode 100644 index 726f6779..00000000 --- a/crates/js/lib/test/core/registry.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; - -describe('registry', () => { - beforeEach(async () => { - await vi.resetModules(); - }); - - it('adds ad units and returns size', async () => { - const { addAdUnits, firstSize, getAllUnits } = await import('../../src/core/registry'); - const unit = { - code: 'u1', - mediaTypes: { - banner: { - sizes: [ - [320, 50], - [300, 250], - ], - }, - }, - } as any; - addAdUnits(unit); - - const all = getAllUnits(); - expect(all.length).toBe(1); - expect(firstSize(all[0])!.join('x')).toBe('320x50'); - }); -}); diff --git a/crates/js/lib/test/core/render.test.ts b/crates/js/lib/test/core/render.test.ts deleted file mode 100644 index 53fe40f9..00000000 --- a/crates/js/lib/test/core/render.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; - -describe('render', () => { - beforeEach(async () => { - await vi.resetModules(); - document.body.innerHTML = ''; - }); - - it('injects creative into existing slot (via iframe)', async () => { - const { renderCreativeIntoSlot } = await import('../../src/core/render'); - const div = document.createElement('div'); - div.id = 'slotA'; - document.body.appendChild(div); - renderCreativeIntoSlot('slotA', 'ad'); - const iframe = document.getElementById('slotA')!.querySelector('iframe'); - expect(iframe).toBeTruthy(); - }); -}); diff --git a/crates/js/lib/test/core/request.test.ts b/crates/js/lib/test/core/request.test.ts deleted file mode 100644 index 8002413f..00000000 --- a/crates/js/lib/test/core/request.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; - -// Ensure mocks referenced inside vi.mock factory are hoisted -const { renderMock } = vi.hoisted(() => ({ renderMock: vi.fn() })); - -describe('request.requestAds', () => { - beforeEach(async () => { - await vi.resetModules(); - document.body.innerHTML = ''; - renderMock.mockReset(); - }); - - it('sends fetch and renders creatives via iframe from response', async () => { - // mock fetch - returns creative HTML inline in adm field - const creativeHtml = '
Test Creative
'; - (globalThis as any).fetch = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - headers: { get: () => 'application/json' }, - json: async () => ({ - seatbid: [{ bid: [{ impid: 'slot1', adm: creativeHtml }] }], - }), - }); - - const { addAdUnits } = await import('../../src/core/registry'); - const { requestAds } = await import('../../src/core/request'); - - document.body.innerHTML = '
'; - addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any); - - requestAds(); - // wait microtasks - await Promise.resolve(); - await Promise.resolve(); - - expect((globalThis as any).fetch).toHaveBeenCalled(); - - // Verify iframe was created with creative HTML in srcdoc - const iframe = document.querySelector('#slot1 iframe') as HTMLIFrameElement | null; - expect(iframe).toBeTruthy(); - expect(iframe!.srcdoc).toContain(creativeHtml); - }); - - it('handles unexpected third-party response without rendering', async () => { - vi.mock('../../src/core/render', async () => { - const actual = await vi.importActual('../../src/core/render'); - return { - ...actual, - renderCreativeIntoSlot: (slotId: string, html: string) => renderMock(slotId, html), - }; - }); - - (globalThis as any).fetch = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - headers: { get: () => 'text/plain' }, - json: async () => ({}), - }); - - const { addAdUnits } = await import('../../src/core/registry'); - const { setConfig } = await import('../../src/core/config'); - const { requestAds } = await import('../../src/core/request'); - - addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any); - setConfig({ mode: 'thirdParty' } as any); - - requestAds(); - await Promise.resolve(); - await Promise.resolve(); - - expect((globalThis as any).fetch).toHaveBeenCalled(); - expect(renderMock).not.toHaveBeenCalled(); - }); - - it('ignores fetch rejection gracefully', async () => { - vi.mock('../../src/core/render', async () => { - const actual = await vi.importActual('../../src/core/render'); - return { - ...actual, - renderCreativeIntoSlot: (slotId: string, html: string) => renderMock(slotId, html), - }; - }); - - (globalThis as any).fetch = vi.fn().mockRejectedValue(new Error('network-error')); - - const { addAdUnits } = await import('../../src/core/registry'); - const { setConfig } = await import('../../src/core/config'); - const { requestAds } = await import('../../src/core/request'); - - addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any); - setConfig({ mode: 'thirdParty' } as any); - - requestAds(); - await Promise.resolve(); - await Promise.resolve(); - - expect((globalThis as any).fetch).toHaveBeenCalled(); - expect(renderMock).not.toHaveBeenCalled(); - }); - - it('inserts an iframe with creative HTML from unified auction', async () => { - // mock fetch for unified auction endpoint - returns inline HTML - const creativeHtml = 'Ad'; - (globalThis as any).fetch = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - headers: { get: () => 'application/json' }, - json: async () => ({ - seatbid: [{ bid: [{ impid: 'slot1', adm: creativeHtml }] }], - }), - }); - - const { addAdUnits } = await import('../../src/core/registry'); - const { requestAds } = await import('../../src/core/request'); - - // Prepare slot in DOM - const div = document.createElement('div'); - div.id = 'slot1'; - document.body.appendChild(div); - - // Add an ad unit and request - addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any); - requestAds(); - - await Promise.resolve(); - await Promise.resolve(); - - // Verify iframe was inserted with creative HTML in srcdoc - const iframe = document.querySelector('#slot1 iframe') as HTMLIFrameElement | null; - expect(iframe).toBeTruthy(); - expect(iframe!.srcdoc).toContain(creativeHtml); - }); - - it('skips iframe insertion when slot is missing', async () => { - // mock fetch for unified auction endpoint - returns inline HTML - (globalThis as any).fetch = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - headers: { get: () => 'application/json' }, - json: async () => ({ - seatbid: [ - { - bid: [{ impid: 'missing-slot', adm: '
Creative for missing slot
' }], - }, - ], - }), - }); - - const { addAdUnits } = await import('../../src/core/registry'); - const { requestAds } = await import('../../src/core/request'); - - addAdUnits({ code: 'missing-slot', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any); - requestAds(); - - await Promise.resolve(); - await Promise.resolve(); - - // No iframe should be inserted because the slot isn't present in DOM - const iframe = document.querySelector('iframe'); - expect(iframe).toBeNull(); - }); -}); diff --git a/crates/js/lib/test/integrations/ext/prebidjs.test.ts b/crates/js/lib/test/integrations/ext/prebidjs.test.ts deleted file mode 100644 index c28fdc38..00000000 --- a/crates/js/lib/test/integrations/ext/prebidjs.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -import installPrebidJsShim from '../../../src/integrations/ext/prebidjs'; - -const ORIGINAL_WINDOW = global.window; - -function createWindow() { - return { - tsjs: undefined as any, - pbjs: undefined as any, - } as Window & { tsjs?: any; pbjs?: any }; -} - -describe('ext/prebidjs', () => { - let testWindow: ReturnType; - - beforeEach(() => { - testWindow = createWindow(); - Object.assign(globalThis as any, { window: testWindow }); - }); - - afterEach(() => { - Object.assign(globalThis as any, { window: ORIGINAL_WINDOW }); - vi.restoreAllMocks(); - }); - - it('installs shim, aliases pbjs to tsjs, and shares queue', () => { - const result = installPrebidJsShim(); - expect(result).toBe(true); - expect(testWindow.pbjs).toBe(testWindow.tsjs); - expect(Array.isArray(testWindow.tsjs!.que)).toBe(true); - }); - - it('flushes queued pbjs callbacks', () => { - const callback = vi.fn(); - testWindow.pbjs = { que: [callback] } as any; - - installPrebidJsShim(); - - expect(callback).toHaveBeenCalled(); - expect(testWindow.pbjs).toBe(testWindow.tsjs); - }); - - it('ensures shared queue and requestBids shim delegates to requestAds', () => { - installPrebidJsShim(); - - const api = testWindow.tsjs!; - api.requestAds = vi.fn(); - const requestBids = testWindow.pbjs!.requestBids.bind(testWindow.pbjs); - - const callback = vi.fn(); - requestBids(callback); - expect(api.requestAds).toHaveBeenCalledWith(callback, undefined); - - requestBids({ timeout: 100 } as any); - expect(api.requestAds).toHaveBeenCalledWith({ timeout: 100 }); - }); -});