Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions crates/trusted-server-core/src/integrations/prebid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -911,7 +911,7 @@ pub fn register(
.with_proxy(integration.clone())
.with_attribute_rewriter(integration.clone())
.with_head_injector(integration)
.without_js()
.with_deferred_js()
.build(),
))
}
Expand Down Expand Up @@ -3021,8 +3021,8 @@ passphrase = "test-secret-key-32-bytes-minimum"
"External prebid bundle route should be injected"
);
assert!(
!processed.contains("tsjs-prebid.min.js"),
"Embedded deferred prebid bundle should not be injected"
processed.contains("tsjs-prebid.min.js"),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ refactor — Injection order is now a correctness invariant, and it is untested.

Both scripts are defer, so they execute in document order: the external bundle tag (emitted by head_inserts) must precede the tsjs-prebid deferred tag (emitted afterwards by html_processor). If that order ever reverses, the shim runs first, finds no registerBidAdapter, and disables the integration outright — a hard failure with no test to catch it.

This test asserts both tags are present but not their relative position. Suggest tightening it:

let bundle_index = processed
    .find(PREBID_BUNDLE_ROUTE)
    .expect("should inject external prebid bundle route");
let shim_index = processed
    .find("tsjs-prebid.min.js")
    .expect("should inject deferred tsjs prebid shim");
assert!(
    bundle_index < shim_index,
    "external prebid bundle must execute before the deferred tsjs shim"
);

"Deferred tsjs prebid shim should be injected"
);
}

Expand Down
22 changes: 11 additions & 11 deletions crates/trusted-server-core/src/integrations/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1949,7 +1949,7 @@ mod tests {
}

#[test]
fn js_module_ids_exclude_prebid_and_include_core_js_only_modules() {
fn js_module_ids_defer_prebid_and_include_core_js_only_modules() {
let settings = crate::test_support::tests::create_test_settings();
let mut settings_with_prebid = settings;
settings_with_prebid
Expand All @@ -1975,8 +1975,8 @@ mod tests {
let deferred = registry.js_module_ids_deferred();

assert!(
!all.contains(&"prebid"),
"should not include prebid in embedded TSJS module IDs"
all.contains(&"prebid"),
"should include the prebid shim in embedded TSJS module IDs"
);
assert!(
immediate.contains(&"creative"),
Expand All @@ -1991,8 +1991,8 @@ mod tests {
"should not include prebid in immediate IDs"
);
assert!(
!deferred.contains(&"prebid"),
"should not include prebid in deferred IDs"
deferred.contains(&"prebid"),
"should serve the prebid shim as a deferred module"
);
}

Expand Down Expand Up @@ -2077,7 +2077,7 @@ mod tests {
}

#[test]
fn js_module_ids_exclude_prebid_when_external_bundle_is_configured() {
fn js_module_ids_defer_prebid_shim_when_external_bundle_is_configured() {
let mut settings = crate::test_support::tests::create_test_settings();
settings
.integrations
Expand All @@ -2094,16 +2094,16 @@ mod tests {
let registry = IntegrationRegistry::new(&settings).expect("should create registry");

assert!(
!registry.js_module_ids().contains(&"prebid"),
"external bundle mode should not include prebid in embedded TSJS modules"
registry.js_module_ids().contains(&"prebid"),
"external bundle mode should include the prebid shim in embedded TSJS modules"
);
assert!(
!registry.js_module_ids_immediate().contains(&"prebid"),
"external bundle mode should not include prebid in immediate TSJS modules"
"the prebid shim should not load in the immediate TSJS bundle"
);
assert!(
!registry.js_module_ids_deferred().contains(&"prebid"),
"external bundle mode should not include prebid in deferred TSJS modules"
registry.js_module_ids_deferred().contains(&"prebid"),
"the prebid shim should load as a deferred TSJS module"
);
assert!(
registry.has_route(&Method::GET, "/integrations/prebid/bundle.js"),
Expand Down
6 changes: 3 additions & 3 deletions crates/trusted-server-core/src/publisher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5276,7 +5276,7 @@ mod tests {
}

#[test]
fn tsjs_dynamic_does_not_serve_embedded_prebid() {
fn tsjs_dynamic_serves_prebid_shim_when_enabled() {
let settings = create_test_settings();
let registry =
IntegrationRegistry::new(&settings).expect("should create integration registry");
Expand All @@ -5288,8 +5288,8 @@ mod tests {
let response = handle_tsjs_dynamic(&req, &registry).expect("should handle tsjs request");
assert_eq!(
response.status(),
StatusCode::NOT_FOUND,
"should not serve embedded prebid module"
StatusCode::OK,
"should serve the deferred prebid shim module when prebid is enabled"
);
}

Expand Down
11 changes: 6 additions & 5 deletions crates/trusted-server-core/src/tsjs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,12 +192,13 @@ mod tests {
}

#[test]
fn tsjs_deferred_script_src_uses_empty_hash_for_external_or_unknown_module() {
assert_eq!(
tsjs_deferred_script_src("prebid"),
"/static/tsjs=tsjs-prebid.min.js?v=",
"prebid now ships as an external bundle and has no local hash"
fn tsjs_deferred_script_src_hashes_prebid_shim_and_empties_unknown_module() {
let prebid_src = tsjs_deferred_script_src("prebid");
assert!(
prebid_src.starts_with("/static/tsjs=tsjs-prebid.min.js?v="),
"prebid shim should be served from the deferred tsjs route"
);
assert_sha256_hex_hash(hash_query_value(&prebid_src));
assert_eq!(
tsjs_deferred_script_src("unknown-module"),
"/static/tsjs=tsjs-unknown-module.min.js?v=",
Expand Down
11 changes: 5 additions & 6 deletions crates/trusted-server-js/lib/build-all.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@
* tsjs-core.js — core API (always included)
* tsjs-<integration>.js — one per discovered integration
*
* Prebid is intentionally excluded from this embedded build. Use
* build-prebid-external.mjs to generate publisher-specific Prebid bundles
* outside the Cargo build.
* The prebid integration builds here as the tsjs shim only — Prebid.js itself
* is never bundled into tsjs. Use build-prebid-external.mjs to generate the
* pure Prebid.js external bundle (core + adapters + user ID modules) that the
* shim requires at runtime via integrations.prebid.external_bundle_url.
*/

import fs from 'node:fs';
Expand All @@ -34,9 +35,7 @@ const integrationModules = fs.existsSync(integrationsDir)
.filter((name) => {
const fullPath = path.join(integrationsDir, name);
return (
name !== 'prebid' &&
fs.statSync(fullPath).isDirectory() &&
fs.existsSync(path.join(fullPath, 'index.ts'))
fs.statSync(fullPath).isDirectory() && fs.existsSync(path.join(fullPath, 'index.ts'))
);
})
.sort()
Expand Down
40 changes: 39 additions & 1 deletion crates/trusted-server-js/lib/build-prebid-external.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -182,9 +182,39 @@ function createTemporaryModulePaths() {
temporaryDir,
adaptersFile: path.join(temporaryDir, '_adapters.generated.ts'),
userIdsFile: path.join(temporaryDir, '_user_ids.generated.ts'),
entryFile: path.join(temporaryDir, '_external_entry.generated.ts'),
};
}

function generateExternalEntry(entryFile, adapters) {
const content = [
'// Auto-generated by build-prebid-external.mjs.',
'//',
'// Pure Prebid.js external bundle: core, consent modules, user ID modules,',
'// and client-side bid adapters. The Trusted Server prebid shim',
'// (tsjs-prebid, served by the server) installs the trustedServer adapter',
'// onto the `window.pbjs` global this bundle populates and drives queue',
'// processing — this bundle intentionally does NOT call processQueue().',
"import 'prebid.js';",
"import 'prebid.js/modules/consentManagementTcf.js';",
"import 'prebid.js/modules/consentManagementGpp.js';",
"import 'prebid.js/modules/consentManagementUsp.js';",
"import 'prebid.js/modules/userId.js';",
"import './_adapters.generated';",
"import { INCLUDED_PREBID_USER_ID_MODULES } from './_user_ids.generated';",
'',
'// Manifest consumed by the tsjs prebid shim to validate that every',
'// configured client_side_bidder has its adapter compiled in.',
'(window as unknown as Record<string, unknown>).__tsjs_prebid_bundle = Object.freeze({',
` adapters: ${JSON.stringify(adapters)},`,
' userIdModules: INCLUDED_PREBID_USER_ID_MODULES,',
'});',
'',
].join('\n');

fs.writeFileSync(entryFile, content);
}

export function deriveBundleMetadata(bundleBytes) {
const sha256 = crypto.createHash('sha256').update(bundleBytes).digest('hex');
const sri = `sha384-${crypto.createHash('sha384').update(bundleBytes).digest('base64')}`;
Expand Down Expand Up @@ -224,6 +254,13 @@ async function buildExternalBundle(outDir, generatedModules) {
'node_modules/prebid.js/dist/src/src/adapterManager.js'
),
},
{
find: 'prebid.js/src/adRendering.js',
replacement: path.resolve(
__dirname,
'node_modules/prebid.js/dist/src/src/adRendering.js'
),
},
],
},
build: {
Expand All @@ -233,7 +270,7 @@ async function buildExternalBundle(outDir, generatedModules) {
sourcemap: false,
minify: 'esbuild',
rollupOptions: {
input: path.join(prebidDir, 'index.ts'),
input: generatedModules.entryFile,
output: {
format: 'iife',
dir: outDir,
Expand Down Expand Up @@ -270,6 +307,7 @@ export async function main(argv = process.argv.slice(2)) {
try {
const adapters = generateAdapterImports(args.adapters, generatedModules.adaptersFile);
const userIdModules = generateUserIdImports(args.userIdModules, generatedModules.userIdsFile);
generateExternalEntry(generatedModules.entryFile, adapters);
const bundle = await buildExternalBundle(args.outDir, generatedModules);
const manifest = {
prebidVersion: prebidPackageVersion(),
Expand Down
4 changes: 2 additions & 2 deletions crates/trusted-server-js/lib/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
"dev": "vite build --watch",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint \"src/**/*.{ts,tsx}\"",
"lint:fix": "eslint --fix \"src/**/*.{ts,tsx}\"",
"lint": "eslint \"src/**/*.{ts,tsx}\" \"test/**/*.{ts,tsx}\"",
"lint:fix": "eslint --fix \"src/**/*.{ts,tsx}\" \"test/**/*.{ts,tsx}\"",
"format": "prettier --check \"**/*.{ts,tsx,js,json,css,md}\"",
"format:write": "prettier --write \"**/*.{ts,tsx,js,json,css,md}\""
},
Expand Down
107 changes: 75 additions & 32 deletions crates/trusted-server-js/lib/src/integrations/prebid/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,29 +11,56 @@
// The shim on requestBids injects "trustedServer" into every ad unit so all
// 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';
import 'prebid.js/modules/userId.js';

// Client-side bid adapters — self-register with prebid.js on import.
// The external bundle generator aliases these placeholder modules to temporary
// modules built from its --adapters and --user-id-modules options. 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 type _pbjsDefault from 'prebid.js';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 praise — This is the right seam. A type-only import plus capturing the head-injected window.pbjs stub keeps Prebid.js entirely out of the tsjs artifact while preserving the typing, and replacing the internal adapterManager.getBidAdapter import with the stamped manifest drops the last dependency on Prebid internals. Validation quality is unchanged, the coupling is gone.


import { log } from '../../core/log';
import { buildAdRequest, parseAuctionResponse } from '../../core/auction';
import type { AuctionBid, AuctionEid } from '../../core/auction';
import type { AuctionSlot } from '../../core/types';

import { INCLUDED_PREBID_USER_ID_MODULES } from './_user_ids.generated';
import { PREBID_USER_ID_MODULE_REGISTRY } from './user_id_modules';

/**
* Prebid.js public API surface (type-only; erased at build time).
*
* `getUserIdsAsEids` is added by the userId module at runtime, which the base
* package typing does not model.
*/
type PbjsGlobal = typeof _pbjsDefault & {
getUserIdsAsEids?: () => unknown[];
};

// Prebid.js itself is NOT bundled into this module. It is served as the
// external bundle configured via `integrations.prebid.external_bundle_url`
// (required whenever the prebid integration is enabled) and owns the
// `window.pbjs` global. The Rust head injector emits a stub
// (`window.pbjs = window.pbjs || {que:[],cmd:[]}`) before any script runs and
// Prebid.js installs its API onto that same object, so capturing the reference
// at module scope is safe regardless of evaluation order.
const pbjs: PbjsGlobal = (
typeof window !== 'undefined'
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
((window as any).pbjs ??= { que: [], cmd: [] })
: { que: [], cmd: [] }
) as PbjsGlobal;

/**
* Manifest stamped on `window.__tsjs_prebid_bundle` by the external Prebid.js
* bundle (see build-prebid-external.mjs): which client-side bid adapters and
* user ID modules were compiled into it.
*/
interface ExternalPrebidBundleManifest {
adapters?: string[];
userIdModules?: string[];
}

function getExternalBundleManifest(): ExternalPrebidBundleManifest | undefined {
if (typeof window === 'undefined') {
return undefined;
}
return (window as { __tsjs_prebid_bundle?: ExternalPrebidBundleManifest }).__tsjs_prebid_bundle;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 thinking — The manifest is page-controlled state read without shape validation.

__tsjs_prebid_bundle is a plain window global that any script on the page can set. The interface types adapters and userIdModules as string[], but nothing checks at runtime: if either is present and non-array, bundledAdapters.includes(bidder) (line 1088) or includedUserIdModules.includes(entry.moduleName) (line 176) throws a TypeError, which escapes installPrebidNpm and, on the self-init path, becomes an uncaught error at module scope.

A pair of Array.isArray guards inside getExternalBundleManifest keeps a malformed or hostile global to a diagnostics-only degradation.

}

const ADAPTER_CODE = 'trustedServer';
// OpenRTB permits vendor-specific agent types; PAIR uses 571187.
// Keep this range aligned with the signed 32-bit Rust/OpenRTB representation.
Expand Down Expand Up @@ -142,18 +169,19 @@ function readConfiguredUserIdNames(): string[] {
}

function recordUserIdModuleDiagnostics(): PrebidUserIdDiagnostics {
const includedUserIdModules = getExternalBundleManifest()?.userIdModules ?? [];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ refactor — User ID diagnostics do not distinguish "no manifest" from "module missing".

The adapters check at line 1078 explicitly handles manifest === undefined with a single "did not stamp an adapter manifest" warning. This path instead falls back to [], so an older or unstamped bundle makes every configured User ID module look absent and emits one warning per module on the first auction.

Mirroring the adapters handling keeps the two diagnostics consistent and avoids misleading operators during the bundle rollout.

const configuredUserIdNames = [...new Set(readConfiguredUserIdNames())].sort();
const coveredConfigNames = new Set(
PREBID_USER_ID_MODULE_REGISTRY.filter((entry) =>
INCLUDED_PREBID_USER_ID_MODULES.includes(entry.moduleName)
includedUserIdModules.includes(entry.moduleName)
).flatMap((entry) => entry.configNames)
);
const missingConfiguredUserIdNames = configuredUserIdNames.filter(
(name) => !coveredConfigNames.has(name)
);

const diagnostics: PrebidUserIdDiagnostics = {
includedModules: [...INCLUDED_PREBID_USER_ID_MODULES],
includedModules: [...includedUserIdModules],
configuredUserIdNames,
missingConfiguredUserIdNames,
};
Expand Down Expand Up @@ -804,6 +832,18 @@ function collectAuctionEids(): AuctionEid[] | undefined {
* 2. `config` argument — explicit overrides from the publisher's JS
*/
export function installPrebidNpm(config?: Partial<PrebidNpmConfig>): typeof pbjs {
// The prebid integration requires the external Prebid.js bundle

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 thinking — No idempotency guard for the rollout window.

The deployment note says the currently deployed bundle still carries the old baked-in shim, so running the new server against it installs the adapter twice and wraps requestBids twice. A sentinel here makes either deploy order safe for about three lines, and __tsRemoveAdUnitWrapped already sets the precedent in this file:

const installedFlag = window as { __tsjsPrebidShimInstalled?: boolean };
if (installedFlag.__tsjsPrebidShimInstalled) return pbjs;
installedFlag.__tsjsPrebidShimInstalled = true;

That removes the ordering constraint from the deploy runbook entirely.

// (integrations.prebid.external_bundle_url). When it failed to load (network
// error, SRI mismatch) window.pbjs is still the head-injected stub with no
// API — installing the adapter is impossible, so bail out loudly.
if (typeof (pbjs as { registerBidAdapter?: unknown }).registerBidAdapter !== 'function') {
log.error(
'[tsjs-prebid] window.pbjs has no Prebid.js API — the external Prebid bundle ' +
'failed to load. Prebid integration disabled.'
);
return pbjs;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench — Bail-out is partial: the refresh handler still installs when the external bundle is missing.

installPrebidNpm() returns early here, but self-init calls installRefreshHandler() unconditionally right after it (line 1355). That wrapper is then live on googletag.pubads().refresh(), and on every publisher refresh it runs clearRefreshTargeting() on each independent slot — clearing ts_initial, hb_pb, hb_bidder, hb_adid, hb_cache_host, hb_cache_pathbefore calling pbjs.requestBids(...), which is undefined on the head-injected stub. The TypeError is caught by the try/catch at line 1243 and completeRefresh(false) skips setTargetingForGPTAsync, so nothing refills the targeting.

Net effect when the bundle fails to load (network error, SRI mismatch, blocked request): server-side SSAT targeting applied by adInit is wiped on the first publisher refresh and no auction replaces it.

This failure mode is new to this PR. On main the shim lived inside the external bundle, so a failed bundle meant no shim at all and nothing wrapped pubads.refresh. Splitting the artifacts makes "shim present, Prebid absent" reachable.

Fix — gate the rest of self-init on a successful install:

const prebidApiAvailable =
  typeof (pbjs as { registerBidAdapter?: unknown }).registerBidAdapter === 'function';

if (typeof window !== 'undefined') {
  installPrebidNpm();
  if (prebidApiAvailable) {
    installRefreshHandler();
    // ...user ID module setup
  }
}

}

publisherAdUnitSnapshots = new Map();
pendingPublisherBids = new Map();
pendingPublisherCodes = new Map();
Expand Down Expand Up @@ -1030,24 +1070,27 @@ export function installPrebidNpm(config?: Partial<PrebidNpmConfig>): typeof pbjs
pbjs.processQueue();
recordUserIdModuleDiagnostics();

// 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 included in the generated
// external Prebid bundle. 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)) {
// Validate that every client-side bidder has its adapter compiled into the

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 thinkingprocessQueue() ownership now lives in the second artifact.

The external bundle deliberately does not call processQueue(); the shim does (line 1070). That makes the reverse partial-load a silent failure: if the bundle loads but the shim does not (adblock filters matching tsjs-prebid.min.js, a /static/tsjs= error, CSP), pbjs.que never drains. Publisher code that pushed into pbjs.que never runs, while window.pbjs looks fully loaded, so there is no signal anywhere on the page.

Before this PR a single artifact made this atomic. Worth considering a watchdog in the generated bundle entry that calls processQueue() itself if the shim has not installed within N ms, so the queue drains in a degraded but functional state.

// external Prebid.js bundle. The bundle stamps its adapter list on
// window.__tsjs_prebid_bundle; a missing adapter means the bidder was listed
// in client_side_bidders but not included in the generated bundle, so it is
// silently dropped from both server-side and client-side auctions.
const bundledAdapters = getExternalBundleManifest()?.adapters;
if (bundledAdapters === undefined) {
if (clientSideBidders.size > 0) {
log.warn(
'[tsjs-prebid] external Prebid bundle did not stamp an adapter manifest; ' +
'cannot verify client_side_bidders adapters'
);
}
} else {
for (const bidder of clientSideBidders) {
if (!bundledAdapters.includes(bidder)) {
log.error(
`[tsjs-prebid] client-side bidder "${bidder}" has no adapter loaded. ` +
`Add it to build-prebid-external.mjs --adapters.`
`[tsjs-prebid] client-side bidder "${bidder}" has no adapter in the external ` +

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick — This message names the wrong operator surface. The documented flow is ts prebid bundle driven by [integrations.prebid.bundle].adapters in trusted-server.toml; build-prebid-external.mjs --adapters is the internal script the CLI shells out to. Pointing at the CLI config key matches what an operator can actually change.

`Prebid bundle. Add it to build-prebid-external.mjs --adapters.`
);
}
} catch {
log.error(
`[tsjs-prebid] client-side bidder "${bidder}" has no adapter loaded. ` +
`Add it to build-prebid-external.mjs --adapters.`
);
}
}

Expand Down
Loading
Loading