diff --git a/crates/common/src/integrations/prebid.rs b/crates/common/src/integrations/prebid.rs index 89a8826c..574a3c3a 100644 --- a/crates/common/src/integrations/prebid.rs +++ b/crates/common/src/integrations/prebid.rs @@ -69,6 +69,18 @@ pub struct PrebidIntegrationConfig { deserialize_with = "crate::settings::vec_from_seq_or_map" )] pub script_patterns: Vec, + /// Bidders that should run client-side in the browser via native Prebid.js + /// adapters instead of being routed through the server-side auction. + /// + /// These bidders are **not** absorbed into the `trustedServer` adapter and + /// remain as standalone bids in each ad unit. The corresponding Prebid.js + /// adapter modules must be statically imported in the JS bundle so they are + /// available at runtime. + /// + /// This list is independent of [`bidders`](Self::bidders) — the operator + /// manages both lists explicitly. + #[serde(default, deserialize_with = "crate::settings::vec_from_seq_or_map")] + pub client_side_bidders: Vec, /// Per-bidder, per-zone param overrides. The outer key is a bidder name, the /// inner key is a zone name (sent by the JS adapter from `mediaTypes.banner.name` /// — a non-standard Prebid.js field used as a temporary workaround), @@ -218,6 +230,20 @@ fn build(settings: &Settings) -> Option> { log::warn!("Prebid integration disabled: prebid.server_url missing"); return None; } + + // Warn about bidders that appear in both lists — this is likely a config + // mistake. A bidder should be in either `bidders` (server-side) or + // `client_side_bidders` (browser-side), not both. + for bidder in &config.client_side_bidders { + if config.bidders.iter().any(|b| b == bidder) { + log::warn!( + "prebid: bidder \"{}\" is in both bidders and client_side_bidders — \ + it will run server-side AND be left for client-side, which is likely unintended", + bidder + ); + } + } + Some(PrebidIntegration::new(config)) } @@ -306,6 +332,8 @@ impl IntegrationHeadInjector for PrebidIntegration { timeout: u32, debug: bool, bidders: &'a [String], + #[serde(skip_serializing_if = "<[String]>::is_empty")] + client_side_bidders: &'a [String], } let payload = InjectedPrebidClientConfig { @@ -313,6 +341,7 @@ impl IntegrationHeadInjector for PrebidIntegration { timeout: self.config.timeout_ms, debug: self.config.debug, bidders: &self.config.bidders, + client_side_bidders: &self.config.client_side_bidders, }; // Escape `.js — one per discovered integration + * + * Environment variables: + * TSJS_PREBID_ADAPTERS — Comma-separated list of Prebid.js bid adapter + * names to include in the bundle (e.g. "rubicon,appnexus,openx"). + * Each name must have a corresponding {name}BidAdapter.js module in + * the prebid.js package. Default: "rubicon". */ import fs from 'node:fs'; @@ -19,6 +25,90 @@ const srcDir = path.resolve(__dirname, 'src'); const distDir = path.resolve(__dirname, '..', 'dist'); const integrationsDir = path.join(srcDir, 'integrations'); +// --------------------------------------------------------------------------- +// Prebid adapter generation +// --------------------------------------------------------------------------- + +const DEFAULT_PREBID_ADAPTERS = 'rubicon'; +const ADAPTERS_FILE = path.join( + integrationsDir, + 'prebid', + '_adapters.generated.ts', +); + +/** + * Generate `_adapters.generated.ts` with import statements for each adapter + * listed in the TSJS_PREBID_ADAPTERS environment variable. + * + * Invalid adapter names (those without a matching module in prebid.js) are + * logged and skipped. + */ +function generatePrebidAdapters() { + const raw = process.env.TSJS_PREBID_ADAPTERS || DEFAULT_PREBID_ADAPTERS; + const names = raw + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + + if (names.length === 0) { + console.warn( + '[build-all] TSJS_PREBID_ADAPTERS is empty, falling back to default:', + DEFAULT_PREBID_ADAPTERS, + ); + names.push(DEFAULT_PREBID_ADAPTERS); + } + + const modulesDir = path.join( + __dirname, + 'node_modules', + 'prebid.js', + 'modules', + ); + + // Validate each adapter and build import lines + const imports = []; + for (const name of names) { + const moduleFile = `${name}BidAdapter.js`; + const modulePath = path.join(modulesDir, moduleFile); + if (!fs.existsSync(modulePath)) { + console.error( + `[build-all] WARNING: Prebid adapter "${name}" not found (expected ${moduleFile}), skipping`, + ); + continue; + } + imports.push(`import 'prebid.js/modules/${moduleFile}';`); + } + + if (imports.length === 0) { + console.error( + '[build-all] WARNING: No valid Prebid adapters found, bundle will have no client-side adapters', + ); + } + + const content = [ + '// Auto-generated by build-all.mjs — manual edits will be overwritten at build time.', + '//', + '// Controls which Prebid.js bid adapters are included in the bundle.', + '// Set the TSJS_PREBID_ADAPTERS environment variable to a comma-separated list', + '// of adapter names (e.g. "rubicon,appnexus,openx") before building.', + `// Default: "${DEFAULT_PREBID_ADAPTERS}"`, + '', + ...imports, + '', + ].join('\n'); + + fs.writeFileSync(ADAPTERS_FILE, content); + + const adapterNames = names.filter((name) => + fs.existsSync(path.join(modulesDir, `${name}BidAdapter.js`)), + ); + console.log('[build-all] Prebid adapters:', adapterNames); +} + +generatePrebidAdapters(); + +// --------------------------------------------------------------------------- + // Clean dist directory fs.rmSync(distDir, { recursive: true, force: true }); fs.mkdirSync(distDir, { recursive: true }); @@ -47,6 +137,16 @@ async function buildModule(name, entryPath) { await build({ configFile: false, root: __dirname, + resolve: { + alias: { + // prebid.js doesn't expose src/adapterManager.js via its package + // "exports" map, but we need it for client-side bidder validation. + 'prebid.js/src/adapterManager.js': path.resolve( + __dirname, + 'node_modules/prebid.js/dist/src/src/adapterManager.js', + ), + }, + }, build: { emptyOutDir: false, outDir: distDir, diff --git a/crates/js/lib/package-lock.json b/crates/js/lib/package-lock.json index edc731e2..ee30a477 100644 --- a/crates/js/lib/package-lock.json +++ b/crates/js/lib/package-lock.json @@ -99,6 +99,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1728,6 +1729,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -1768,6 +1770,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -3024,6 +3027,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -3352,6 +3356,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3401,7 +3406,6 @@ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "license": "MIT", - "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -3419,7 +3423,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3435,8 +3438,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/ansi-colors": { "version": "1.1.0", @@ -3847,6 +3849,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4707,6 +4710,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5317,8 +5321,7 @@ "url": "https://opencollective.com/fastify" } ], - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/fdir": { "version": "6.5.0", @@ -7205,6 +7208,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7743,7 +7747,6 @@ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "license": "MIT", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -7780,7 +7783,6 @@ "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -7792,8 +7794,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/semver": { "version": "7.7.4", @@ -8535,6 +8536,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8763,6 +8765,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/crates/js/lib/src/integrations/prebid/_adapters.generated.ts b/crates/js/lib/src/integrations/prebid/_adapters.generated.ts new file mode 100644 index 00000000..baf65ce9 --- /dev/null +++ b/crates/js/lib/src/integrations/prebid/_adapters.generated.ts @@ -0,0 +1,8 @@ +// Auto-generated by build-all.mjs — manual edits will be overwritten at build time. +// +// Controls which Prebid.js bid adapters are included in the bundle. +// Set the TSJS_PREBID_ADAPTERS environment variable to a comma-separated list +// of adapter names (e.g. "rubicon,appnexus,openx") before building. +// Default: "rubicon" + +import 'prebid.js/modules/rubiconBidAdapter.js'; diff --git a/crates/js/lib/src/integrations/prebid/index.ts b/crates/js/lib/src/integrations/prebid/index.ts index a4a37003..63282513 100644 --- a/crates/js/lib/src/integrations/prebid/index.ts +++ b/crates/js/lib/src/integrations/prebid/index.ts @@ -12,10 +12,19 @@ // bids flow through the orchestrator. import pbjs from 'prebid.js'; +import adapterManager from 'prebid.js/src/adapterManager.js'; import 'prebid.js/modules/consentManagementTcf.js'; import 'prebid.js/modules/consentManagementGpp.js'; import 'prebid.js/modules/consentManagementUsp.js'; +// Client-side bid adapters — self-register with prebid.js on import. +// The set of adapters is controlled by the TSJS_PREBID_ADAPTERS env var at +// build time. See _adapters.generated.ts (written by build-all.mjs). +// When a bidder is listed in `client_side_bidders` in trusted-server.toml, +// the requestBids shim leaves its bids untouched and the corresponding +// adapter handles them natively in the browser. +import './_adapters.generated'; + import { log } from '../../core/log'; import { buildAdRequest, parseAuctionResponse } from '../../core/auction'; import type { AuctionBid } from '../../core/auction'; @@ -43,6 +52,8 @@ interface InjectedPrebidConfig { timeout?: number; debug?: boolean; bidders?: string[]; + /** Bidders that run client-side via native Prebid.js adapters. */ + clientSideBidders?: string[]; } /** Read server-injected config from window.__tsjs_prebid, if present. */ @@ -195,8 +206,16 @@ export function installPrebidNpm(config?: Partial): typeof pbjs const originalRequestBids = pbjs.requestBids.bind(pbjs); + // Bidders that should run client-side via their native Prebid.js adapters. + // Read once from the server-injected config. + const clientSideBidders = new Set(injected?.clientSideBidders ?? []); + if (clientSideBidders.size > 0) { + log.info('[tsjs-prebid] client-side bidders:', [...clientSideBidders]); + } + // Shim requestBids to inject the trustedServer bidder into every ad unit - // so all bids flow through the /auction orchestrator. + // so server-side bids flow through the /auction orchestrator while + // client-side bidders are left untouched. pbjs.requestBids = function (requestObj?: Parameters[0]) { log.debug('[tsjs-prebid] requestBids called'); @@ -211,11 +230,16 @@ export function installPrebidNpm(config?: Partial): typeof pbjs } // Preserve per-bidder params for server-side expansion. + // Skip client-side bidders — they remain as standalone bids and run + // via their native Prebid.js adapters in the browser. const bidderParams: Record> = {}; for (const bid of unit.bids) { if (!bid?.bidder || bid.bidder === ADAPTER_CODE) { continue; } + if (clientSideBidders.has(bid.bidder)) { + continue; + } bidderParams[bid.bidder] = bid.params ?? {}; } @@ -276,6 +300,27 @@ export function installPrebidNpm(config?: Partial): typeof pbjs // prebid.js via NPM. pbjs.processQueue(); + // Validate that every client-side bidder has its adapter registered. + // Adapters self-register on import, so a missing adapter means the bidder + // was listed in client_side_bidders but not in TSJS_PREBID_ADAPTERS at + // build time. Without the adapter the bidder is silently dropped from both + // server-side and client-side auctions. + for (const bidder of clientSideBidders) { + try { + if (!adapterManager.getBidAdapter(bidder)) { + log.error( + `[tsjs-prebid] client-side bidder "${bidder}" has no adapter loaded. ` + + `Add it to TSJS_PREBID_ADAPTERS at build time.` + ); + } + } catch { + log.error( + `[tsjs-prebid] client-side bidder "${bidder}" has no adapter loaded. ` + + `Add it to TSJS_PREBID_ADAPTERS at build time.` + ); + } + } + log.info('[tsjs-prebid] prebid initialized with trustedServer adapter'); return pbjs; diff --git a/crates/js/lib/test/integrations/prebid/index.test.ts b/crates/js/lib/test/integrations/prebid/index.test.ts index 4806e257..961b7abc 100644 --- a/crates/js/lib/test/integrations/prebid/index.test.ts +++ b/crates/js/lib/test/integrations/prebid/index.test.ts @@ -1,37 +1,54 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; // Define mocks using vi.hoisted so they're available inside vi.mock factories -const { mockSetConfig, mockProcessQueue, mockRequestBids, mockRegisterBidAdapter, mockPbjs } = - vi.hoisted(() => { - const mockSetConfig = vi.fn(); - const mockProcessQueue = vi.fn(); - const mockRequestBids = vi.fn(); - const mockRegisterBidAdapter = vi.fn(); - const mockPbjs = { - setConfig: mockSetConfig, - processQueue: mockProcessQueue, - requestBids: mockRequestBids, - registerBidAdapter: mockRegisterBidAdapter, - adUnits: [] as any[], - }; - return { - mockSetConfig, - mockProcessQueue, - mockRequestBids, - mockRegisterBidAdapter, - mockPbjs, - }; - }); +const { + mockSetConfig, + mockProcessQueue, + mockRequestBids, + mockRegisterBidAdapter, + mockPbjs, + mockGetBidAdapter, + mockAdapterManager, +} = vi.hoisted(() => { + const mockSetConfig = vi.fn(); + const mockProcessQueue = vi.fn(); + const mockRequestBids = vi.fn(); + const mockRegisterBidAdapter = vi.fn(); + const mockGetBidAdapter = vi.fn(); + const mockPbjs = { + setConfig: mockSetConfig, + processQueue: mockProcessQueue, + requestBids: mockRequestBids, + registerBidAdapter: mockRegisterBidAdapter, + adUnits: [] as any[], + }; + const mockAdapterManager = { + getBidAdapter: mockGetBidAdapter, + }; + return { + mockSetConfig, + mockProcessQueue, + mockRequestBids, + mockRegisterBidAdapter, + mockPbjs, + mockGetBidAdapter, + mockAdapterManager, + }; +}); // Mock prebid.js before importing the module under test. // The real prebid.js cannot run in jsdom, so we provide a minimal stub. vi.mock('prebid.js', () => ({ default: mockPbjs })); +vi.mock('prebid.js/src/adapterManager.js', () => ({ default: mockAdapterManager })); // Side-effect imports are no-ops in tests vi.mock('prebid.js/modules/consentManagementTcf.js', () => ({})); vi.mock('prebid.js/modules/consentManagementGpp.js', () => ({})); vi.mock('prebid.js/modules/consentManagementUsp.js', () => ({})); +// Mock the build-generated adapter imports (no-op in tests) +vi.mock('../../../src/integrations/prebid/_adapters.generated', () => ({})); + import { collectBidders, getInjectedConfig, @@ -579,3 +596,207 @@ describe('prebid/installPrebidNpm with server-injected config', () => { expect(mockProcessQueue).toHaveBeenCalled(); }); }); + +describe('prebid/client-side bidders', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockPbjs.requestBids = mockRequestBids; + mockPbjs.adUnits = []; + // By default, pretend all adapters are registered + mockGetBidAdapter.mockReturnValue({}); + delete (window as any).__tsjs_prebid; + }); + + afterEach(() => { + delete (window as any).__tsjs_prebid; + }); + + it('excludes client-side bidders from trustedServer bidderParams', () => { + (window as any).__tsjs_prebid = { clientSideBidders: ['rubicon'] }; + + const pbjs = installPrebidNpm(); + + const adUnits = [ + { + bids: [ + { bidder: 'appnexus', params: { placementId: 123 } }, + { bidder: 'rubicon', params: { accountId: 'abc' } }, + { bidder: 'kargo', params: { placementId: 'k1' } }, + ], + }, + ]; + pbjs.requestBids({ adUnits } as any); + + const tsBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer') as any; + expect(tsBid).toBeDefined(); + // rubicon should NOT be in bidderParams — it runs client-side + expect(tsBid.params.bidderParams).toEqual({ + appnexus: { placementId: 123 }, + kargo: { placementId: 'k1' }, + }); + }); + + it('preserves client-side bidder bids as standalone entries', () => { + (window as any).__tsjs_prebid = { clientSideBidders: ['rubicon'] }; + + const pbjs = installPrebidNpm(); + + const adUnits = [ + { + bids: [ + { bidder: 'appnexus', params: { placementId: 123 } }, + { bidder: 'rubicon', params: { accountId: 'abc' } }, + ], + }, + ]; + pbjs.requestBids({ adUnits } as any); + + // rubicon bid should remain untouched as a standalone entry + const rubiconBid = adUnits[0].bids.find((b: any) => b.bidder === 'rubicon') as any; + expect(rubiconBid).toBeDefined(); + expect(rubiconBid.params).toEqual({ accountId: 'abc' }); + }); + + it('handles multiple client-side bidders', () => { + (window as any).__tsjs_prebid = { clientSideBidders: ['rubicon', 'openx'] }; + + const pbjs = installPrebidNpm(); + + const adUnits = [ + { + bids: [ + { bidder: 'appnexus', params: { placementId: 123 } }, + { bidder: 'rubicon', params: { accountId: 'abc' } }, + { bidder: 'openx', params: { unit: '456' } }, + ], + }, + ]; + pbjs.requestBids({ adUnits } as any); + + const tsBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer') as any; + // Only appnexus should be in bidderParams + expect(tsBid.params.bidderParams).toEqual({ + appnexus: { placementId: 123 }, + }); + + // Both client-side bidders should remain + expect(adUnits[0].bids.find((b: any) => b.bidder === 'rubicon')).toBeDefined(); + expect(adUnits[0].bids.find((b: any) => b.bidder === 'openx')).toBeDefined(); + }); + + it('behaves normally when no client-side bidders are configured', () => { + // No __tsjs_prebid at all — all bidders go server-side + const pbjs = installPrebidNpm(); + + const adUnits = [ + { + bids: [ + { bidder: 'appnexus', params: { placementId: 123 } }, + { bidder: 'rubicon', params: { accountId: 'abc' } }, + ], + }, + ]; + pbjs.requestBids({ adUnits } as any); + + const tsBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer') as any; + expect(tsBid.params.bidderParams).toEqual({ + appnexus: { placementId: 123 }, + rubicon: { accountId: 'abc' }, + }); + }); + + it('behaves normally when client-side bidders list is empty', () => { + (window as any).__tsjs_prebid = { clientSideBidders: [] }; + + const pbjs = installPrebidNpm(); + + const adUnits = [ + { + bids: [ + { bidder: 'appnexus', params: { placementId: 123 } }, + { bidder: 'rubicon', params: { accountId: 'abc' } }, + ], + }, + ]; + pbjs.requestBids({ adUnits } as any); + + const tsBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer') as any; + expect(tsBid.params.bidderParams).toEqual({ + appnexus: { placementId: 123 }, + rubicon: { accountId: 'abc' }, + }); + }); + + it('still injects trustedServer when all bidders are client-side', () => { + (window as any).__tsjs_prebid = { clientSideBidders: ['rubicon', 'appnexus'] }; + + const pbjs = installPrebidNpm(); + + const adUnits = [ + { + bids: [ + { bidder: 'rubicon', params: { accountId: 'abc' } }, + { bidder: 'appnexus', params: { placementId: 123 } }, + ], + }, + ]; + pbjs.requestBids({ adUnits } as any); + + // trustedServer should still be present (even with empty bidderParams) + const tsBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer') as any; + expect(tsBid).toBeDefined(); + expect(tsBid.params.bidderParams).toEqual({}); + }); + + it('logs error when a client-side bidder has no adapter loaded', () => { + // rubicon is registered, but openx is not + mockGetBidAdapter.mockImplementation((bidder: string) => + bidder === 'rubicon' ? {} : undefined + ); + (window as any).__tsjs_prebid = { clientSideBidders: ['rubicon', 'openx'] }; + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + installPrebidNpm(); + + // Should have been called to check both bidders + expect(mockGetBidAdapter).toHaveBeenCalledWith('rubicon'); + expect(mockGetBidAdapter).toHaveBeenCalledWith('openx'); + + // Should log an error for the missing adapter. + // log.error() uses styled console output: console.error('%c[tsjs]%c ...:', style, reset, ...args) + // so the actual message is the 4th argument. + const errorCalls = errorSpy.mock.calls; + const hasOpenxError = errorCalls.some((args) => + args.some( + (a) => + typeof a === 'string' && a.includes('client-side bidder "openx" has no adapter loaded') + ) + ); + expect(hasOpenxError).toBe(true); + + // Should NOT log an error for the registered adapter + const hasRubiconError = errorCalls.some((args) => + args.some((a) => typeof a === 'string' && a.includes('client-side bidder "rubicon"')) + ); + expect(hasRubiconError).toBe(false); + + errorSpy.mockRestore(); + }); + + it('does not log errors when all client-side bidders have adapters', () => { + mockGetBidAdapter.mockReturnValue({}); + (window as any).__tsjs_prebid = { clientSideBidders: ['rubicon'] }; + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + installPrebidNpm(); + + const hasAdapterError = errorSpy.mock.calls.some((args) => + args.some((a) => typeof a === 'string' && a.includes('has no adapter loaded')) + ); + expect(hasAdapterError).toBe(false); + + errorSpy.mockRestore(); + }); +}); diff --git a/crates/js/lib/vitest.config.ts b/crates/js/lib/vitest.config.ts index 8f48d91a..97d9d84c 100644 --- a/crates/js/lib/vitest.config.ts +++ b/crates/js/lib/vitest.config.ts @@ -1,6 +1,18 @@ +import path from 'node:path'; import { defineConfig } from 'vitest/config'; export default defineConfig({ + resolve: { + alias: { + // prebid.js doesn't expose src/adapterManager.js via its package + // "exports" map, but we need it for client-side bidder validation. + // Map the specifier to the actual dist file. + 'prebid.js/src/adapterManager.js': path.resolve( + __dirname, + 'node_modules/prebid.js/dist/src/src/adapterManager.js' + ), + }, + }, test: { environment: 'jsdom', globals: true, diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 0984f2cd..534fb9cc 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -92,7 +92,8 @@ secret_store_id = "01GYYY" enabled = true server_url = "https://prebid-server.com/openrtb2/auction" timeout_ms = 1200 -bidders = ["kargo", "rubicon", "appnexus"] +bidders = ["kargo", "appnexus", "openx"] +client_side_bidders = ["rubicon"] ``` ## Detailed Reference @@ -744,6 +745,7 @@ apply when the integration section exists in `trusted-server.toml`. | `debug` | Boolean | `false` | Enable debug mode (sets `ext.prebid.debug` and `returnallbidstatus`; surfaces debug metadata in responses) | | `test_mode` | Boolean | `false` | Set OpenRTB `test: 1` flag for non-billable test traffic (independent of `debug`) | | `debug_query_params` | String | `None` | Extra query params appended for debugging | +| `client_side_bidders`| Array[String] | `[]` | Bidders that run client-side via native Prebid.js adapters instead of server-side (see [Prebid docs](/guide/integrations/prebid#client-side-bidders)) | | `script_patterns` | Array[String] | `["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"]` | URL patterns for Prebid script interception | **Example**: @@ -753,10 +755,13 @@ apply when the integration section exists in `trusted-server.toml`. enabled = true server_url = "https://prebid-server.example/openrtb2/auction" timeout_ms = 1200 -bidders = ["kargo", "rubicon", "appnexus", "openx"] +bidders = ["kargo", "appnexus", "openx"] debug = false # test_mode = false +# Bidders that run client-side via native Prebid.js adapters +client_side_bidders = ["rubicon"] + # Customize script interception (optional) script_patterns = ["/prebid.js", "/prebid.min.js"] ``` @@ -767,7 +772,8 @@ script_patterns = ["/prebid.js", "/prebid.min.js"] TRUSTED_SERVER__INTEGRATIONS__PREBID__ENABLED=true TRUSTED_SERVER__INTEGRATIONS__PREBID__SERVER_URL=https://prebid.example/auction TRUSTED_SERVER__INTEGRATIONS__PREBID__TIMEOUT_MS=1200 -TRUSTED_SERVER__INTEGRATIONS__PREBID__BIDDERS=kargo,rubicon,appnexus +TRUSTED_SERVER__INTEGRATIONS__PREBID__BIDDERS=kargo,appnexus,openx +TRUSTED_SERVER__INTEGRATIONS__PREBID__CLIENT_SIDE_BIDDERS=rubicon TRUSTED_SERVER__INTEGRATIONS__PREBID__DEBUG=false TRUSTED_SERVER__INTEGRATIONS__PREBID__TEST_MODE=false TRUSTED_SERVER__INTEGRATIONS__PREBID__DEBUG_QUERY_PARAMS=debug=1 diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index 5dddc1ff..6268fe1e 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -19,10 +19,14 @@ Prebid is the leading open-source header bidding solution that allows publishers enabled = true server_url = "https://prebid-server.example.com/openrtb2/auction" timeout_ms = 1200 -bidders = ["kargo", "rubicon", "appnexus"] +bidders = ["kargo", "appnexus", "openx"] debug = false # test_mode = false +# Bidders that run client-side via native Prebid.js adapters instead of +# being routed through the server-side auction. +client_side_bidders = ["rubicon"] + # Script interception patterns (optional - defaults shown below) script_patterns = ["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"] @@ -44,7 +48,8 @@ in_content = {placementId = "_s2sContentPlacement"} | `debug` | Boolean | `false` | Enable Prebid debug mode (sets `ext.prebid.debug` and `ext.prebid.returnallbidstatus`; surfaces debug metadata in auction responses) | | `test_mode` | Boolean | `false` | Set the OpenRTB `test: 1` flag so bidders treat the auction as non-billable test traffic. Separate from `debug` to avoid suppressing real demand | | `debug_query_params` | String | `None` | Extra query params appended for debugging | -| `script_patterns` | Array[String] | `["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"]` | URL patterns for Prebid script interception | +| `client_side_bidders` | Array[String] | `[]` | Bidders that run client-side via native Prebid.js adapters instead of server-side. See [Client-Side Bidders](#client-side-bidders) | +| `script_patterns` | Array[String] | `["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"]` | URL patterns for Prebid script interception | ## Debug Mode @@ -178,6 +183,46 @@ the outgoing bidder params become: For an unrecognised zone (e.g., `sidebar`), the incoming params are left unchanged. +## Client-Side Bidders + +Some Prebid.js bid adapters do not work well through Prebid Server (e.g. Magnite/Rubicon). The `client_side_bidders` config field lets you keep these bidders running natively in the browser while routing all other bidders through the server-side auction. + +### How it works + +1. The server injects the `clientSideBidders` list into the page via `window.__tsjs_prebid`. +2. When `pbjs.requestBids()` is called, the TSJS shim checks each bid against the list. +3. **Client-side bidders** are left as standalone bids — their native Prebid.js adapters handle them in the browser. +4. **All other bidders** are absorbed into the `trustedServer` adapter and routed through the `/auction` orchestrator to Prebid Server. +5. Both sets of bids compete in the same Prebid.js auction. + +### Configuration + +```toml +[integrations.prebid] +bidders = ["kargo", "appnexus", "openx"] # server-side via PBS +client_side_bidders = ["rubicon"] # native browser adapters +``` + +The two lists are independent — the operator manages both explicitly. If a bidder appears in both lists, a warning is logged at startup (the bidder will run in both paths, which is likely unintended). + +### Build-time adapter selection + +Client-side bidders need their Prebid.js adapter modules bundled in the JS output. This is controlled by the `TSJS_PREBID_ADAPTERS` environment variable at build time: + +```bash +# Default: only rubicon +TSJS_PREBID_ADAPTERS=rubicon + +# Multiple adapters +TSJS_PREBID_ADAPTERS=rubicon,appnexus,openx +``` + +The build script (`build-all.mjs`) validates that each adapter exists in `prebid.js/modules/{name}BidAdapter.js` and generates `_adapters.generated.ts` with the appropriate imports. At runtime, TSJS also validates that every bidder in `client_side_bidders` has a registered adapter and logs an error if one is missing. + +::: warning +Adding a new client-side bidder requires both a config change (`client_side_bidders`) **and** a rebuild with the adapter included in `TSJS_PREBID_ADAPTERS`. Without the adapter in the bundle, the bidder is silently dropped from both server-side and client-side auctions. +::: + ## Endpoints ### GET /first-party/ad @@ -220,7 +265,7 @@ Replace client-side Prebid.js entirely with server-side auctions for maximum per ### Hybrid Client + Server -Use server-side for primary demand, client-side for niche bidders. +Use server-side for primary demand and `client_side_bidders` for adapters that don't work well with Prebid Server (e.g. Magnite/Rubicon). See [Client-Side Bidders](#client-side-bidders) for configuration details. ### Mobile-First Monetization diff --git a/trusted-server.toml b/trusted-server.toml index 0c0a6f7e..151fad97 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -40,12 +40,17 @@ secret_store_id = "" enabled = true server_url = "http://68.183.113.79:8000" timeout_ms = 1000 -bidders = ["kargo", "rubicon", "appnexus", "openx"] +bidders = ["kargo", "appnexus", "openx"] debug = false # test_mode = false # debug_query_params = "" # script_patterns = ["/prebid.js"] +# Bidders that run client-side via native Prebid.js adapters instead of +# being routed through the server-side auction. Their adapter modules must +# be statically imported in the JS bundle. +client_side_bidders = ["rubicon"] + # Zone-specific bid param overrides for Kargo s2s placement IDs. # The JS adapter reads the zone from mediaTypes.banner.name on each ad unit # and includes it in the request. The server maps zone → s2s placementId here.