diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d410ea4f..6a468062b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Browser integration bundles now share context providers and the configured log level with TSJS core (#1196). With Permutive enabled and cohorts available, `tsjs.requestAds()` now sends `config.permutive_segments` to `/auction`; the server retains that key only when `auction.allowed_context_keys` includes `"permutive_segments"`. This restores a previously inactive data flow, not a consent gate: review publisher consent handling before relying on it. Integration logs now follow `tsjs.setConfig` and `tsjs.log.setLevel`, and creative no longer raises the default `warn` level to `info`. The `?tsdebug=1` query parameter or `localStorage['tsdebug'] = '1'` intentionally enables `debug` logging across all bundles when creative installs, overriding any log level set earlier through `tsjs.setConfig` or `tsjs.log.setLevel`. Later calls to either API can change the level again. - `[auction].allowed_context_keys` now serializes in sorted, deduplicated order, so ESI template-cache fingerprints and `ts config diff`/`push` envelope hashes are stable across loads. Template fingerprints also sort object keys independently of `serde_json/preserve_order`. Existing envelopes may show a one-time allowlist reorder after upgrading; push once to settle it. The updated fingerprint format causes one template-cache miss per cached page after deployment. - TSJS-generated envelopes now send `trustedServer.params.storedRequest: false`, preventing accidental PBS stored lookups without suppressing eligible non-PBS demand. PBS filters unusable impressions after overrides; explicit `true` and omission in valid envelopes retain inline-first stored fallback. A malformed envelope disables stored fallback for the entire slot, including independent direct demand left unusable after overrides. Publisher intent survives repeated and refresh auctions. Deploy compatible server admission everywhere before serving the new JS, and retain it during rollback while cached clients remain. See the Prebid deployment guide. - Protocol-relative creative URLs now honor `rewrite.exclude_domains`, so excluded creative assets stay direct and excluded absolute or protocol-relative URLs submitted to `/first-party/sign` are rejected. diff --git a/crates/trusted-server-js/lib/build-all.mjs b/crates/trusted-server-js/lib/build-all.mjs index 2bfee01b1..8d0ddd683 100644 --- a/crates/trusted-server-js/lib/build-all.mjs +++ b/crates/trusted-server-js/lib/build-all.mjs @@ -19,6 +19,8 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { build } from 'vite'; +import { moduleBuildOptions } from './build-module-options.mjs'; + const __dirname = path.dirname(fileURLToPath(import.meta.url)); const srcDir = path.resolve(__dirname, 'src'); const distDir = path.resolve(__dirname, '..', 'dist'); @@ -39,7 +41,7 @@ const integrationModules = fs.existsSync(integrationsDir) ); }) .sort() - : []; + : []; console.log('[build-all] Discovered integrations:', integrationModules); @@ -48,30 +50,7 @@ async function buildModule(name, entryPath) { const outFile = `tsjs-${name}.js`; console.log(`[build-all] Building ${outFile} from ${path.relative(__dirname, entryPath)}`); - await build({ - configFile: false, - root: __dirname, - build: { - emptyOutDir: false, - outDir: distDir, - assetsDir: '.', - sourcemap: false, - minify: 'esbuild', - rollupOptions: { - input: entryPath, - output: { - format: 'iife', - dir: distDir, - entryFileNames: outFile, - inlineDynamicImports: true, - extend: false, - // Use a unique IIFE name per module to avoid conflicts - name: name === 'core' ? 'tsjs' : `tsjs_${name}`, - }, - }, - }, - logLevel: 'warn', - }); + await build(moduleBuildOptions({ name, entryPath, outDir: distDir })); console.log(`[build-all] Built ${outFile}`); } diff --git a/crates/trusted-server-js/lib/build-module-options.mjs b/crates/trusted-server-js/lib/build-module-options.mjs new file mode 100644 index 000000000..64b78a3fb --- /dev/null +++ b/crates/trusted-server-js/lib/build-module-options.mjs @@ -0,0 +1,32 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const libDir = path.dirname(fileURLToPath(import.meta.url)); + +/** Production Vite options for one independently bundled tsjs IIFE. */ +export function moduleBuildOptions({ name, entryPath, outDir }) { + return { + configFile: false, + root: libDir, + build: { + emptyOutDir: false, + outDir, + assetsDir: '.', + sourcemap: false, + minify: 'esbuild', + rollupOptions: { + input: entryPath, + output: { + format: 'iife', + dir: outDir, + entryFileNames: `tsjs-${name}.js`, + inlineDynamicImports: true, + extend: false, + // Use a unique IIFE name per module to avoid conflicts. + name: name === 'core' ? 'tsjs' : `tsjs_${name}`, + }, + }, + }, + logLevel: 'warn', + }; +} diff --git a/crates/trusted-server-js/lib/src/core/context.ts b/crates/trusted-server-js/lib/src/core/context.ts index 9ee4ddff5..23ee0895d 100644 --- a/crates/trusted-server-js/lib/src/core/context.ts +++ b/crates/trusted-server-js/lib/src/core/context.ts @@ -8,7 +8,16 @@ import { log } from './log'; */ export type ContextProvider = () => Record | undefined; -const providers = new Map(); +// Independently built IIFEs must register and collect from the same map. +// Resolve it on each call rather than capturing a bundle-local registry. +const CONTEXT_PROVIDERS_KEY = Symbol.for('trusted-server.contextProviders'); + +function getProviders(): Map { + const sharedGlobal = globalThis as typeof globalThis & { + [CONTEXT_PROVIDERS_KEY]?: Map; + }; + return (sharedGlobal[CONTEXT_PROVIDERS_KEY] ??= new Map()); +} /** * Register a context provider that will be called before every auction request. @@ -19,6 +28,7 @@ const providers = new Map(); * duplicate accumulation in SPA environments. */ export function registerContextProvider(id: string, provider: ContextProvider): void { + const providers = getProviders(); providers.set(id, provider); log.debug('context: registered provider', { id, total: providers.size }); } @@ -32,7 +42,7 @@ export function registerContextProvider(id: string, provider: ContextProvider): */ export function collectContext(): Record { const context: Record = {}; - for (const provider of providers.values()) { + for (const provider of getProviders().values()) { try { const data = provider(); if (data) Object.assign(context, data); diff --git a/crates/trusted-server-js/lib/src/core/log.ts b/crates/trusted-server-js/lib/src/core/log.ts index b750430c6..336cdf571 100644 --- a/crates/trusted-server-js/lib/src/core/log.ts +++ b/crates/trusted-server-js/lib/src/core/log.ts @@ -2,7 +2,14 @@ export type LogLevel = 'silent' | 'error' | 'warn' | 'info' | 'debug'; const LEVELS: Record = { silent: -1, error: 0, warn: 1, info: 2, debug: 3 }; -let currentLevel: LogLevel = 'warn'; +// Each IIFE carries its own logger code, but all copies share one level. +const LOG_LEVEL_KEY = Symbol.for('trusted-server.logLevel'); + +type LogGlobal = typeof globalThis & { [LOG_LEVEL_KEY]?: LogLevel }; + +function getLevel(): LogLevel { + return ((globalThis as LogGlobal)[LOG_LEVEL_KEY] ??= 'warn'); +} function levelNum(l: LogLevel) { return LEVELS[l] ?? 1; @@ -50,21 +57,19 @@ function print(method: 'log' | 'info' | 'warn' | 'error', ...args: unknown[]) { // Thin wrapper around console that keeps timestamped output and honours a runtime log level. export const log = { setLevel(l: LogLevel) { - currentLevel = l; - }, - getLevel(): LogLevel { - return currentLevel; + (globalThis as LogGlobal)[LOG_LEVEL_KEY] = l; }, + getLevel, info: (...a: unknown[]) => { - if (levelNum(currentLevel) >= LEVELS.info) print('info', ...a); + if (levelNum(getLevel()) >= LEVELS.info) print('info', ...a); }, warn: (...a: unknown[]) => { - if (levelNum(currentLevel) >= LEVELS.warn) print('warn', ...a); + if (levelNum(getLevel()) >= LEVELS.warn) print('warn', ...a); }, error: (...a: unknown[]) => { - if (levelNum(currentLevel) >= LEVELS.error) print('error', ...a); + if (levelNum(getLevel()) >= LEVELS.error) print('error', ...a); }, debug: (...a: unknown[]) => { - if (levelNum(currentLevel) >= LEVELS.debug) print('log', ...a); + if (levelNum(getLevel()) >= LEVELS.debug) print('log', ...a); }, }; diff --git a/crates/trusted-server-js/lib/src/integrations/creative/click.ts b/crates/trusted-server-js/lib/src/integrations/creative/click.ts index 38d6b982e..8a9cbee0f 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/click.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/click.ts @@ -489,9 +489,6 @@ function monitorAnchorMutations(): void { // Wire up capture-phase click handlers + mutation observers to protect clicks. export function installClickGuard(): void { - if (log.getLevel && log.getLevel() === 'warn') { - log.setLevel('info'); - } enableDebugFromEnv(); log.info('tsjs-creative:click: installing click guard'); diff --git a/crates/trusted-server-js/lib/test/core/config.test.ts b/crates/trusted-server-js/lib/test/core/config.test.ts index 6abacdf54..3d303ac2f 100644 --- a/crates/trusted-server-js/lib/test/core/config.test.ts +++ b/crates/trusted-server-js/lib/test/core/config.test.ts @@ -1,9 +1,16 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const LOG_LEVEL_KEY = Symbol.for('trusted-server.logLevel'); +const sharedGlobal = globalThis as typeof globalThis & { [LOG_LEVEL_KEY]?: unknown }; describe('config', () => { - beforeEach(async () => { - // reset module state between tests - await vi.resetModules(); + beforeEach(() => { + delete sharedGlobal[LOG_LEVEL_KEY]; + vi.resetModules(); + }); + + afterEach(() => { + delete sharedGlobal[LOG_LEVEL_KEY]; }); it('sets and gets config, controls log level', async () => { diff --git a/crates/trusted-server-js/lib/test/core/context.test.ts b/crates/trusted-server-js/lib/test/core/context.test.ts index 74854837e..97e4f34e5 100644 --- a/crates/trusted-server-js/lib/test/core/context.test.ts +++ b/crates/trusted-server-js/lib/test/core/context.test.ts @@ -1,8 +1,18 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const CONTEXT_PROVIDERS_KEY = Symbol.for('trusted-server.contextProviders'); +const sharedGlobal = globalThis as typeof globalThis & { + [CONTEXT_PROVIDERS_KEY]?: unknown; +}; describe('context provider registry', () => { - beforeEach(async () => { - await vi.resetModules(); + beforeEach(() => { + delete sharedGlobal[CONTEXT_PROVIDERS_KEY]; + vi.resetModules(); + }); + + afterEach(() => { + delete sharedGlobal[CONTEXT_PROVIDERS_KEY]; }); it('returns empty context when no providers registered', async () => { @@ -46,6 +56,17 @@ describe('context provider registry', () => { expect(collectContext()).toEqual({ survived: true }); }); + it('shares providers between separately loaded module instances', async () => { + const first = await import('../../src/core/context'); + first.registerContextProvider('first', () => ({ a: 1 })); + + vi.resetModules(); + const second = await import('../../src/core/context'); + expect(second.collectContext()).toEqual({ a: 1 }); + second.registerContextProvider('second', () => ({ b: 2 })); + expect(first.collectContext()).toEqual({ a: 1, b: 2 }); + }); + it('re-registration with same id replaces previous provider', async () => { const { registerContextProvider, collectContext } = await import('../../src/core/context'); registerContextProvider('dup', () => ({ v: 1 })); diff --git a/crates/trusted-server-js/lib/test/core/log.test.ts b/crates/trusted-server-js/lib/test/core/log.test.ts new file mode 100644 index 000000000..64f11ae66 --- /dev/null +++ b/crates/trusted-server-js/lib/test/core/log.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const LOG_LEVEL_KEY = Symbol.for('trusted-server.logLevel'); +const sharedGlobal = globalThis as typeof globalThis & { + [LOG_LEVEL_KEY]?: unknown; +}; + +describe('shared logger', () => { + beforeEach(() => { + delete sharedGlobal[LOG_LEVEL_KEY]; + vi.resetModules(); + }); + + afterEach(() => { + delete sharedGlobal[LOG_LEVEL_KEY]; + vi.restoreAllMocks(); + }); + + it('defaults to warn', async () => { + const { log } = await import('../../src/core/log'); + expect(log.getLevel()).toBe('warn'); + }); + + it('shares level changes between separately loaded module instances', async () => { + const first = await import('../../src/core/log'); + first.log.setLevel('debug'); + + vi.resetModules(); + const second = await import('../../src/core/log'); + expect(second.log.getLevel()).toBe('debug'); + second.log.setLevel('silent'); + expect(first.log.getLevel()).toBe('silent'); + }); + + it.each([ + ['silent', []], + ['error', ['error']], + ['warn', ['error', 'warn']], + ['info', ['error', 'warn', 'info']], + ['debug', ['error', 'warn', 'info', 'debug']], + ] as const)('gates all console methods at %s', async (level, enabled) => { + const spies = { + error: vi.spyOn(console, 'error').mockImplementation(() => {}), + warn: vi.spyOn(console, 'warn').mockImplementation(() => {}), + info: vi.spyOn(console, 'info').mockImplementation(() => {}), + debug: vi.spyOn(console, 'log').mockImplementation(() => {}), + }; + const { log } = await import('../../src/core/log'); + log.setLevel(level); + for (const method of ['error', 'warn', 'info', 'debug'] as const) { + log[method]('example'); + expect(spies[method]).toHaveBeenCalledTimes( + enabled.some((value) => value === method) ? 1 : 0 + ); + } + }); +}); diff --git a/crates/trusted-server-js/lib/test/iife-state-artifact-integration.test.mjs b/crates/trusted-server-js/lib/test/iife-state-artifact-integration.test.mjs new file mode 100644 index 000000000..127b122de --- /dev/null +++ b/crates/trusted-server-js/lib/test/iife-state-artifact-integration.test.mjs @@ -0,0 +1,211 @@ +// @vitest-environment node + +// Unit tests share one module graph. These production IIFEs must share state +// even though each bundle contains its own copy of the context and log modules. +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { JSDOM } from 'jsdom'; +import { build } from 'vite'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { moduleBuildOptions } from '../build-module-options.mjs'; + +const libDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const bundles = new Map(); +let outputDirectory; +let dom; + +beforeAll(async () => { + outputDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'trusted-server-iife-state-')); + for (const name of ['core', 'creative', 'permutive']) { + const entryPath = + name === 'core' + ? path.join(libDir, 'src', 'core', 'index.ts') + : path.join(libDir, 'src', 'integrations', name, 'index.ts'); + await build(moduleBuildOptions({ name, entryPath, outDir: outputDirectory })); + bundles.set(name, fs.readFileSync(path.join(outputDirectory, `tsjs-${name}.js`), 'utf8')); + } +}, 60_000); + +afterEach(() => { + dom?.window.close(); +}); + +afterAll(() => { + if (outputDirectory) fs.rmSync(outputDirectory, { recursive: true, force: true }); +}); + +function createPage({ + modules = ['core', 'creative', 'permutive'], + logLevel, + debug = false, + storedDebug = false, +} = {}) { + dom = new JSDOM('
', { + url: `https://publisher.example.com/${debug ? '?tsdebug=1' : ''}`, + runScripts: 'outside-only', + }); + const { window } = dom; + if (storedDebug) window.localStorage.setItem('tsdebug', '1'); + // Avoid SDK polling and external requests. Script elements are not fetched + // with outside-only execution and no resource loader. + window.permutive = { config: {} }; + window.localStorage.setItem( + 'permutive-app', + JSON.stringify({ core: { cohorts: { all: ['111', '222'] } } }) + ); + const info = vi.fn(); + const debugLog = vi.fn(); + window.console.info = info; + window.console.log = debugLog; + window.fetch = vi.fn(async () => ({ + ok: true, + headers: { get: () => 'application/json' }, + json: async () => ({ seatbid: [] }), + })); + if (logLevel) { + window.tsjs = { que: [() => window.tsjs.setConfig({ logLevel })] }; + } + // Match bundle.rs: core first and independent IIFEs joined into one script. + window.eval(modules.map((name) => bundles.get(name)).join(';\n')); + return { window, info, debugLog }; +} + +function insertPermutiveScript(window) { + const script = window.document.createElement('script'); + script.src = 'https://cdn.permutive.com/example-web.js'; + window.document.head.appendChild(script); + expect(script.src).toBe('https://publisher.example.com/integrations/permutive/sdk'); +} + +function expectPermutiveInfo(window, info, enabled) { + info.mockClear(); + insertPermutiveScript(window); + const lines = info.mock.calls.filter((args) => + args.some((arg) => String(arg).includes('Permutive guard: rewriting')) + ); + expect(lines).toHaveLength(enabled ? 1 : 0); +} + +function requestContext(window) { + window.tsjs.addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } }); + window.tsjs.requestAds(); + expect(window.fetch).toHaveBeenCalledTimes(1); + const [url, init] = window.fetch.mock.calls[0]; + expect(url).toBe('/auction'); + expect(init.method).toBe('POST'); + return JSON.parse(init.body).config; +} + +describe('shared state across production IIFEs', () => { + it('sends Permutive context from an integration bundle through core requestAds', () => { + const { window } = createPage(); + expect(requestContext(window)).toEqual({ permutive_segments: ['111', '222'] }); + }); + + it('keeps context empty when Permutive has no cohorts', () => { + const { window } = createPage(); + window.localStorage.removeItem('permutive-app'); + expect(requestContext(window)).toEqual({}); + }); + + it('keeps context empty when the Permutive integration is not loaded', () => { + const { window } = createPage({ modules: ['core', 'creative'] }); + expect(requestContext(window)).toEqual({}); + }); + + it('keeps default logging at warn, including creative installation', () => { + const { window, info } = createPage(); + expect(window.tsjs.log.getLevel()).toBe('warn'); + expect(info).not.toHaveBeenCalled(); + expectPermutiveInfo(window, info, false); + }); + + it('shares setConfig logLevel changes with integration bundles', () => { + const { window, info } = createPage(); + window.tsjs.setConfig({ logLevel: 'info' }); + expectPermutiveInfo(window, info, true); + window.tsjs.setConfig({ logLevel: 'warn' }); + expectPermutiveInfo(window, info, false); + }); + + it('shares setConfig debug changes with integration bundles', () => { + const { window, info, debugLog } = createPage(); + window.tsjs.setConfig({ debug: true }); + expectPermutiveInfo(window, info, true); + debugLog.mockClear(); + window.tsjs.requestAds(); + expect( + debugLog.mock.calls.some((args) => + args.some((arg) => String(arg).includes('getPermutiveSegments: found segments')) + ) + ).toBe(true); + }); + + it('shares direct log.setLevel changes with integration bundles', () => { + const { window, info } = createPage(); + window.tsjs.log.setLevel('info'); + expectPermutiveInfo(window, info, true); + window.tsjs.log.setLevel('silent'); + expectPermutiveInfo(window, info, false); + }); + + it('preserves publisher logging configured before integration bundles load', () => { + const { window, info } = createPage({ logLevel: 'info' }); + expect(window.tsjs.log.getLevel()).toBe('info'); + expectPermutiveInfo(window, info, true); + }); + + it('does not let creative overwrite an explicit publisher warn level', () => { + const { window, info } = createPage({ logLevel: 'warn' }); + expect(window.tsjs.log.getLevel()).toBe('warn'); + expect(info).not.toHaveBeenCalled(); + }); + + it('enables shared logging with the explicit tsdebug flag', () => { + const { window, info } = createPage({ debug: true }); + expect(window.tsjs.log.getLevel()).toBe('debug'); + expectPermutiveInfo(window, info, true); + }); + + it.each([ + ['query', 'warn'], + ['query', 'silent'], + ['localStorage', 'warn'], + ['localStorage', 'silent'], + ])('lets %s tsdebug override an earlier publisher %s level', (source, logLevel) => { + const { window, info, debugLog } = createPage({ + logLevel, + debug: source === 'query', + storedDebug: source === 'localStorage', + }); + expect(window.tsjs.log.getLevel()).toBe('debug'); + expectPermutiveInfo(window, info, true); + debugLog.mockClear(); + expect(requestContext(window)).toEqual({ permutive_segments: ['111', '222'] }); + expect( + debugLog.mock.calls.some((args) => + args.some((arg) => String(arg).includes('getPermutiveSegments: found segments')) + ) + ).toBe(true); + + window.tsjs.setConfig({ logLevel }); + expect(window.tsjs.log.getLevel()).toBe(logLevel); + expectPermutiveInfo(window, info, false); + + window.tsjs.log.setLevel('debug'); + expectPermutiveInfo(window, info, true); + window.tsjs.log.setLevel(logLevel); + expect(window.tsjs.log.getLevel()).toBe(logLevel); + expectPermutiveInfo(window, info, false); + }); + + it('adopts context registered by an integration loaded before core', () => { + const { window } = createPage({ modules: ['permutive'] }); + window.eval(bundles.get('core')); + expect(requestContext(window)).toEqual({ permutive_segments: ['111', '222'] }); + }); +}); diff --git a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs index f0283f0a7..24db647d3 100644 --- a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs +++ b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs @@ -13,6 +13,7 @@ import { fileURLToPath } from 'node:url'; import { JSDOM, requestInterceptor } from 'jsdom'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { moduleBuildOptions } from '../build-module-options.mjs'; import { main, verifyPrebidPackageVersion } from '../build-prebid-external.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -54,29 +55,13 @@ beforeAll(async () => { }); const { build } = await import('vite'); - await build({ - configFile: false, - root: libDir, - build: { - emptyOutDir: false, + await build( + moduleBuildOptions({ + name: 'prebid', + entryPath: path.join(libDir, 'src', 'integrations', 'prebid', 'index.ts'), outDir: outputDirectory, - assetsDir: '.', - sourcemap: false, - minify: 'esbuild', - rollupOptions: { - input: path.join(libDir, 'src', 'integrations', 'prebid', 'index.ts'), - output: { - format: 'iife', - dir: outputDirectory, - entryFileNames: 'tsjs-prebid.js', - inlineDynamicImports: true, - extend: false, - name: 'tsjs_prebid', - }, - }, - }, - logLevel: 'warn', - }); + }) + ); shimCode = fs.readFileSync(path.join(outputDirectory, 'tsjs-prebid.js'), 'utf8'); }, 240_000);