Skip to content
921 changes: 921 additions & 0 deletions crates/common/src/integrations/gpt.rs

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions crates/common/src/integrations/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ pub mod adserver_mock;
pub mod aps;
pub mod datadome;
pub mod didomi;
pub mod gpt;
pub mod lockr;
pub mod nextjs;
pub mod permutive;
Expand All @@ -32,5 +33,6 @@ pub(crate) fn builders() -> &'static [IntegrationBuilder] {
lockr::register,
didomi::register,
datadome::register,
gpt::register,
]
}
170 changes: 170 additions & 0 deletions crates/js/lib/src/integrations/gpt/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { log } from '../../core/log';

import { installGptGuard } from './script_guard';

/**
* Google Publisher Tags (GPT) Integration Shim
*
* Hooks into the googletag.cmd command queue so the Trusted Server can
* observe and augment ad-slot definitions before GPT processes them.
* The shim ensures the googletag stub exists early (matching GPT's own
* bootstrap pattern) and patches `cmd.push` to wrap queued callbacks.
*
* Current capabilities:
* - Installs a script guard that rewrites dynamically inserted GPT
* `<script>` elements to the first-party proxy endpoint.
* - Takes over the `googletag.cmd` array so that every callback runs
* through a wrapper that can inject targeting, logging, or consent
* signals before the real GPT processes the command.
*
* Future enhancements (driven by config or tsjs API):
* - Inject synthetic ID as page-level key-value targeting.
* - Gate ad requests on consent status.
* - Rewrite ad-unit paths for A/B testing.
*/

// ------------------------------------------------------------------
// googletag type stubs (minimal surface needed by the shim)
// ------------------------------------------------------------------

interface GoogleTagSlot {
getAdUnitPath(): string;
getSlotElementId(): string;
setTargeting(key: string, value: string | string[]): GoogleTagSlot;
}

interface GoogleTagPubAdsService {
setTargeting(key: string, value: string | string[]): GoogleTagPubAdsService;
getTargeting(key: string): string[];
enableSingleRequest(): void;
}

interface GoogleTag {
cmd: Array<() => void>;
pubads(): GoogleTagPubAdsService;
defineSlot(
adUnitPath: string,
size: Array<number | number[]>,
elementId: string
): GoogleTagSlot | null;
enableServices(): void;
display(elementId: string): void;
_loaded_?: boolean;
}

type GptWindow = Window & {
googletag?: Partial<GoogleTag>;
};

// ------------------------------------------------------------------
// Shim implementation
// ------------------------------------------------------------------

/**
* Ensure the `googletag` stub exists on `window`.
*
* This mirrors the official GPT bootstrap snippet:
* ```js
* window.googletag = window.googletag || {};
* googletag.cmd = googletag.cmd || [];
* ```
* By running before the publisher's own snippet we can patch `cmd` early.
*/
function ensureGoogleTagStub(win: GptWindow): Partial<GoogleTag> {
const tag = (win.googletag = win.googletag ?? {});
tag.cmd = tag.cmd ?? [];
return tag;
}

/**
* Wrap a queued GPT callback to add instrumentation and future hook points.
*
* Today the wrapper only logs; as the integration matures it will inject
* synthetic ID targeting and consent gates.
*/
function wrapCommand(fn: () => void): () => void {
return () => {
try {
fn();
} catch (err) {
log.error('GPT shim: queued command threw', err);
}
};
}

/**
* Patch `googletag.cmd` so every pushed callback runs through [`wrapCommand`].
*
* Preserves the existing `tag.cmd` array identity so that GPT's own custom
* `cmd.push` behaviour (immediate execution when the library is already
* loaded) is not lost. The original `push` is saved and delegated to after
* wrapping each callback.
*
* Already-queued callbacks are re-wrapped in place so GPT processes them
* through our wrapper when it drains the queue.
*/
function patchCommandQueue(tag: Partial<GoogleTag>): void {
// Ensure the queue exists.
if (!Array.isArray(tag.cmd)) {
tag.cmd = [];
}

const queue = tag.cmd;

// Guard against double-patching (idempotent install).
if ((queue as { __tsPushed?: boolean }).__tsPushed) {
log.debug('GPT shim: command queue already patched, skipping');
return;
}

const originalPush = queue.push.bind(queue);

// Override push on the *existing* array — preserves object identity so
// GPT (if already loaded) keeps its reference.
queue.push = function (...callbacks: Array<() => void>): number {
const wrapped = callbacks.map(wrapCommand);
return originalPush(...wrapped);
};

// Mark as patched to prevent double-wrapping.
(queue as { __tsPushed?: boolean }).__tsPushed = true;

// Re-wrap any callbacks that were queued before we patched.
for (let i = 0; i < queue.length; i++) {
queue[i] = wrapCommand(queue[i]);
}

log.debug('GPT shim: command queue patched', { pendingCommands: queue.length });
}

/**
* Install the GPT integration shim.
*
* Sets up the script guard for dynamic script interception and patches the
* `googletag.cmd` command queue.
*/
export function installGptShim(): boolean {
if (typeof window === 'undefined') {
return false;
}

const win = window as GptWindow;

// Install DOM interception guard first so any dynamic GPT script insertions
// are rewritten before the browser fetches them.
installGptGuard();

const tag = ensureGoogleTagStub(win);
patchCommandQueue(tag);

log.info('GPT shim installed');
return true;
}

// Self-initialise on import when the server-side GPT integration is enabled.
// The trusted server injects `window.__tsjs_gpt_enabled = true` via an inline
// script (IntegrationHeadInjector) so the shim stays dormant when the GPT proxy
// routes are not registered.
if (typeof window !== 'undefined' && (window as Record<string, unknown>).__tsjs_gpt_enabled) {
installGptShim();
}
Loading