You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Unless given in full, JS source paths below are relative to crates/trusted-server-js/lib/src/, JS test paths to crates/trusted-server-js/lib/, and Rust paths to crates/trusted-server-core/src/.
build-all.mjs builds core and each integration as a separate, self-contained Vite IIFE (crates/trusted-server-js/lib/build-all.mjs:46-84). Rollup inlines every core module that an integration imports into that integration's bundle. The server then joins the bundles with ;\n (crates/trusted-server-js/src/bundle.rs:29-34, :91-109), but joining does not merge module instances. Any module-level state in a shared core module therefore exists once per bundle.
Two stateful core modules are affected in ways users can see:
Context providers.core/context.ts:11 holds the registry in module scope:
constproviders=newMap<string,ContextProvider>();
The Permutive IIFE registers its provider into its own copy (integrations/permutive/index.ts:108-111). Core's requestAds() reads the copy inside tsjs-core.js (core/request.ts:44, const config = collectContext();). The provider is never seen, and every /auction request from tsjs.requestAds() carries config: {}.
Log level.core/log.ts:5 holds the level in module scope:
letcurrentLevel: LogLevel='warn';
Core and 11 of the 12 integration bundles (all but didomi) carry their own copy. tsjs.setConfig({ logLevel }), tsjs.setConfig({ debug: true }) (core/config.ts:13-20) and tsjs.log.setLevel (core/index.ts:33) change only core's copy. Integration logging ignores them. Most integration bundles stay at warn; creative raises its own copy to info in its click guard (integrations/creative/click.ts:491-494).
The codebase already states the rule these modules break. core/types.ts:323: "Separate bundles can only reach each other through window.tsjs". The DOM insertion dispatcher, first-impression state and the APS Prebid renderer registry follow it. core/context.ts and core/log.ts do not.
History:
Per-module IIFE builds arrived in Add Prebid.js NPM integration with per-module JS builds #242 (c06ae9cbd, 2026-02-19). Before that, one vite build of one entry produced tsjs-unified.js from a single module graph (git show c06ae9cbd^:crates/js/lib/vite.config.ts).
cd crates/trusted-server-js/lib
npm ci
npm run build
Save this as crates/trusted-server-js/lib/repro-iife-state.mjs. It loads the built bundles the way bundle.rs serves them (core first, joined with ;\n, one script):
/auction config: {}
Permutive guard info lines after setConfig({ logLevel: "info" }): 0
Control: the same three entry points built as one IIFE (one module graph, same Vite options) print {"permutive_segments":["111","222"]} and 1.
Unit tests cannot catch this, because Vitest loads one module graph. This test, saved as crates/trusted-server-js/lib/test/integrations/permutive/context.test.ts, passes on a4e01eb55 even though production drops the segments:
Today no test covers the Permutive entry reaching requestAds() at all. test/core/context.test.ts tests the registry alone and test/integrations/permutive/segments.test.ts tests the storage reader alone.
Expected behavior
With the Permutive integration enabled and cohorts in permutive-app, tsjs.requestAds() sends config: { "permutive_segments": [...] }.
tsjs.setConfig({ logLevel }), tsjs.setConfig({ debug: true }) and tsjs.log.setLevel control logging for core and every integration module.
Actual behavior
tsjs.requestAds() sends config: {}.
Integration modules ignore the configured level. Most stay at warn, and creative stays at info.
Root cause
Rollup builds each entry with its own module graph, and build-all.mjs:51-74 sets no externals. Module scope is per bundle, so new Map() in core/context.ts:11 and let currentLevel in core/log.ts:5 exist once in tsjs-core.js and again in each integration bundle that imports them.
Rollup's module graph at a4e01eb55 (same options as build-all.mjs) shows every module that is inlined into more than one bundle:
Module
Bundles
Module-level mutable state
Effect today
core/context.ts
core, permutive
providers map
Observable: Permutive context dropped
core/log.ts
core and 11 integrations
currentLevel
Observable: integration logging ignores the configured level
Latent. In-flight frame supersession and native dispatch tokens do not span core's requestAds() path and GPT's path. This matters only if both render APS into the same slot. Prebid imports only registerApsPrebidRenderer and validateApsRenderer (integrations/prebid/index.ts:27), and its registry already lives on window.tsjs.apsPrebidRenderers (integrations/aps/render.ts:346). The per-copy validation cache only costs cache misses.
core/first_impression.ts
gpt, prebid
none; state lives on window.tsjs.firstImpression (:52-56)
none (shared/origin.ts computes a load-time constant from the same window)
Code size only
core/config.ts and core/registry.ts are bundled only into core, so getConfig() and the ad unit registry are not affected.
Impact
Permutive segments, for publishers who enable Permutive and call tsjs.requestAds():
No other path in this repository delivers Permutive segments to demand today:
The Prebid shim sends /auction only adUnits and eids: buildAdRequest(validBidRequests, { eids: auctionEids }) (integrations/prebid/index.ts:2283). buildAdRequest never sets config and does not forward ortb2 (core/auction.ts:108-112).
Server-side auctions for ad templates build context: std::collections::HashMap::new() (publisher.rs:5401). The Rust Permutive integration only proxies the SDK and API and reads no cookie or storage (integrations/permutive.rs).
Generated Prebid bundles accept only bidder, User ID and analytics modules (crates/trusted-server-js/lib/build-prebid-external.mjs:42-61, enforced against Prebid metadata at :340-355). permutiveRtdProvider is an rtd module in prebid.js 10.26.0, so it is rejected. permutiveIdentityManagerIdSystem is a User ID module and carries identity, not cohorts.
Outside this repository, the Permutive SDK may pass cohorts to ad servers by itself. I did not verify that.
Even with the browser fixed, the server keeps permutive_segments only when auction.allowed_context_keys lists it (auction/formats.rs:227-251). The default is empty (auction_config_types.rs:74-79, trusted-server.example.toml:284). The only reader of AuctionRequest.context is the adserver_mock development mediator, through context_query_params (integrations/adserver_mock.rs:151-157). The Prebid Server and APS providers do not read it.
Nothing in Trusted Server calls tsjs.requestAds(). Only publisher page code and the Playwright APS tests do.
So the demand impact today is small: the feature from #263 has never run in production. Operators who configured allowed_context_keys = ["permutive_segments"] and context_query_params never received segments.
Logging affects every deployment. Integration logs cannot be raised for debugging. For example, script guard rewrites are logged at info inside integration bundles (shared/script_guard.ts:91) and stay hidden after tsjs.setConfig({ logLevel: 'debug' }).
Proposed fix
Keep the two registries in one global slot that every bundle copy shares, the pattern the DOM insertion dispatcher already uses:
core/context.ts: store the providers map under Symbol.for('trusted-server.contextProviders') and look it up on each call.
core/log.ts: store the level under Symbol.for('trusted-server.logLevel') and read it on each call.
integrations/creative/click.ts:491-494: remove the warn to info bump in installClickGuard, or move it behind the existing tsdebug flag. Creative is always in the unified bundle (JS_ALWAYS, crates/trusted-server-core/src/integrations/registry.rs:1184) and installs on every page (integrations/creative/index.ts:77-97). With a shared level, this bump would switch every module on every page to info. In a prototype without this change, loading core;creative;permutive printed 4 info lines at load instead of 1.
test/core/context.test.ts: reset the shared map between tests. Five tests there rely on vi.resetModules() to get a fresh map and fail once the map is global.
I prototyped these four changes in a scratch copy of a4e01eb55. The artifact test described below passed, the default level after load stayed warn, and all existing Vitest tests passed.
Alternative: core publishes a runtime object on window.tsjs, and integration builds reach it through a small shim or through Rollup external plus output.globals. This is the direction of the shared browser runtime proposed in #1194. It is a larger change and needs defined behavior for an IIFE that runs without core.
Behavior that changes when this is fixed (worth a release note):
tsjs.requestAds() starts sending config.permutive_segments to the publisher's edge whenever Permutive is enabled and has cohorts. The server drops the key with a debug log unless it is allowlisted. Where it is allowlisted and adserver_mock maps it, segments start reaching the mediation URL. Neither the provider (integrations/permutive/segments.ts) nor the server filter (auction/formats.rs:227-251) checks consent, so review consent handling before relying on this flow.
Integration console output follows the configured level.
The creative installing click guard info line no longer prints by default. It prints today only because creative's private logger is bumped.
An artifact test in Node (like test/prebid-artifact-integration.test.mjs) builds core, creative and permutive with the production options, joins them with ;\n, loads them in JSDOM, and asserts that tsjs.requestAds() sends permutive_segments. It fails on a4e01eb55 with expected {} to deeply equal { Object (permutive_segments) } (checked) and passes with the fix.
The same test asserts that after tsjs.setConfig({ logLevel: 'info' }) an integration bundle's info line appears, and that the level right after load is still warn.
build-all.mjs and the artifact test share one definition of the per-module build options, so the test cannot drift from production.
test/core/context.test.ts resets the shared registry between tests.
Release notes describe the new /auctionconfig content and point to auction.allowed_context_keys.
Optional: a build check lists core modules inlined into more than one IIFE and fails when one keeps module-level mutable state outside a shared global key.
Description
Unless given in full, JS source paths below are relative to
crates/trusted-server-js/lib/src/, JS test paths tocrates/trusted-server-js/lib/, and Rust paths tocrates/trusted-server-core/src/.build-all.mjsbuilds core and each integration as a separate, self-contained Vite IIFE (crates/trusted-server-js/lib/build-all.mjs:46-84). Rollup inlines every core module that an integration imports into that integration's bundle. The server then joins the bundles with;\n(crates/trusted-server-js/src/bundle.rs:29-34,:91-109), but joining does not merge module instances. Any module-level state in a shared core module therefore exists once per bundle.Two stateful core modules are affected in ways users can see:
Context providers.
core/context.ts:11holds the registry in module scope:The Permutive IIFE registers its provider into its own copy (
integrations/permutive/index.ts:108-111). Core'srequestAds()reads the copy insidetsjs-core.js(core/request.ts:44,const config = collectContext();). The provider is never seen, and every/auctionrequest fromtsjs.requestAds()carriesconfig: {}.Log level.
core/log.ts:5holds the level in module scope:Core and 11 of the 12 integration bundles (all but
didomi) carry their own copy.tsjs.setConfig({ logLevel }),tsjs.setConfig({ debug: true })(core/config.ts:13-20) andtsjs.log.setLevel(core/index.ts:33) change only core's copy. Integration logging ignores them. Most integration bundles stay atwarn; creative raises its own copy toinfoin its click guard (integrations/creative/click.ts:491-494).The codebase already states the rule these modules break.
core/types.ts:323: "Separate bundles can only reach each other throughwindow.tsjs". The DOM insertion dispatcher, first-impression state and the APS Prebid renderer registry follow it.core/context.tsandcore/log.tsdo not.History:
c06ae9cbd, 2026-02-19). Before that, onevite buildof one entry producedtsjs-unified.jsfrom a single module graph (git show c06ae9cbd^:crates/js/lib/vite.config.ts).7f342eae6, 2026-03-02), which closed As publisher I want to see Permutive segment propagate to ad server #132. Bundles built from the7f342eae6sources with that commit'sbuild-all.mjsalready sendconfig: {}(I built them with the dependencies installed ata4e01eb55). The Permutive path has never worked in a production build.tsjs-unified.jsbuilt the same way fromc06ae9cbd^prints the Permutive guard's info line aftersetConfig({ logLevel: 'info' }). It has not worked since Add Prebid.js NPM integration with per-module JS builds #242.Steps to reproduce
Build the production bundles:
cd crates/trusted-server-js/lib npm ci npm run buildSave this as
crates/trusted-server-js/lib/repro-iife-state.mjs. It loads the built bundles the waybundle.rsserves them (core first, joined with;\n, one script):Run it:
Observed at
a4e01eb55:Control: the same three entry points built as one IIFE (one module graph, same Vite options) print
{"permutive_segments":["111","222"]}and1.Unit tests cannot catch this, because Vitest loads one module graph. This test, saved as
crates/trusted-server-js/lib/test/integrations/permutive/context.test.ts, passes ona4e01eb55even though production drops the segments:Today no test covers the Permutive entry reaching
requestAds()at all.test/core/context.test.tstests the registry alone andtest/integrations/permutive/segments.test.tstests the storage reader alone.Expected behavior
permutive-app,tsjs.requestAds()sendsconfig: { "permutive_segments": [...] }.tsjs.setConfig({ logLevel }),tsjs.setConfig({ debug: true })andtsjs.log.setLevelcontrol logging for core and every integration module.Actual behavior
tsjs.requestAds()sendsconfig: {}.warn, and creative stays atinfo.Root cause
Rollup builds each entry with its own module graph, and
build-all.mjs:51-74sets no externals. Module scope is per bundle, sonew Map()incore/context.ts:11andlet currentLevelincore/log.ts:5exist once intsjs-core.jsand again in each integration bundle that imports them.Rollup's module graph at
a4e01eb55(same options asbuild-all.mjs) shows every module that is inlined into more than one bundle:core/context.tsprovidersmapcore/log.tscurrentLevelintegrations/aps/render.tsactiveFrames,pendingFrameCancels,nativeDispatches,validatedRendererCache,publisherNativeRendering(:31-32,:45-49)requestAds()path and GPT's path. This matters only if both render APS into the same slot. Prebid imports onlyregisterApsPrebidRendererandvalidateApsRenderer(integrations/prebid/index.ts:27), and its registry already lives onwindow.tsjs.apsPrebidRenderers(integrations/aps/render.ts:346). The per-copy validation cache only costs cache misses.core/first_impression.tswindow.tsjs.firstImpression(:52-56)shared/dom_insertion_dispatcher.tsSymbol.for('trusted-server.domInsertionDispatcher')(:6-7,:150-177)core/auction.ts,core/queue.ts,core/render.ts,core/slot_element.ts,shared/globals.ts,shared/origin.ts,shared/script_guard.tsshared/origin.tscomputes a load-time constant from the same window)core/config.tsandcore/registry.tsare bundled only into core, sogetConfig()and the ad unit registry are not affected.Impact
Permutive segments, for publishers who enable Permutive and call
tsjs.requestAds():/auctiononlyadUnitsandeids:buildAdRequest(validBidRequests, { eids: auctionEids })(integrations/prebid/index.ts:2283).buildAdRequestnever setsconfigand does not forwardortb2(core/auction.ts:108-112).context: std::collections::HashMap::new()(publisher.rs:5401). The Rust Permutive integration only proxies the SDK and API and reads no cookie or storage (integrations/permutive.rs).crates/trusted-server-js/lib/build-prebid-external.mjs:42-61, enforced against Prebid metadata at:340-355).permutiveRtdProvideris anrtdmodule in prebid.js 10.26.0, so it is rejected.permutiveIdentityManagerIdSystemis a User ID module and carries identity, not cohorts.permutive_segmentsonly whenauction.allowed_context_keyslists it (auction/formats.rs:227-251). The default is empty (auction_config_types.rs:74-79,trusted-server.example.toml:284). The only reader ofAuctionRequest.contextis theadserver_mockdevelopment mediator, throughcontext_query_params(integrations/adserver_mock.rs:151-157). The Prebid Server and APS providers do not read it.tsjs.requestAds(). Only publisher page code and the Playwright APS tests do.So the demand impact today is small: the feature from #263 has never run in production. Operators who configured
allowed_context_keys = ["permutive_segments"]andcontext_query_paramsnever received segments.Logging affects every deployment. Integration logs cannot be raised for debugging. For example, script guard rewrites are logged at
infoinside integration bundles (shared/script_guard.ts:91) and stay hidden aftertsjs.setConfig({ logLevel: 'debug' }).Proposed fix
Keep the two registries in one global slot that every bundle copy shares, the pattern the DOM insertion dispatcher already uses:
core/context.ts: store the providers map underSymbol.for('trusted-server.contextProviders')and look it up on each call.core/log.ts: store the level underSymbol.for('trusted-server.logLevel')and read it on each call.integrations/creative/click.ts:491-494: remove thewarntoinfobump ininstallClickGuard, or move it behind the existingtsdebugflag. Creative is always in the unified bundle (JS_ALWAYS,crates/trusted-server-core/src/integrations/registry.rs:1184) and installs on every page (integrations/creative/index.ts:77-97). With a shared level, this bump would switch every module on every page toinfo. In a prototype without this change, loadingcore;creative;permutiveprinted 4 info lines at load instead of 1.test/core/context.test.ts: reset the shared map between tests. Five tests there rely onvi.resetModules()to get a fresh map and fail once the map is global.I prototyped these four changes in a scratch copy of
a4e01eb55. The artifact test described below passed, the default level after load stayedwarn, and all existing Vitest tests passed.Alternative: core publishes a runtime object on
window.tsjs, and integration builds reach it through a small shim or through Rollupexternalplusoutput.globals. This is the direction of the shared browser runtime proposed in #1194. It is a larger change and needs defined behavior for an IIFE that runs without core.Behavior that changes when this is fixed (worth a release note):
tsjs.requestAds()starts sendingconfig.permutive_segmentsto the publisher's edge whenever Permutive is enabled and has cohorts. The server drops the key with a debug log unless it is allowlisted. Where it is allowlisted andadserver_mockmaps it, segments start reaching the mediation URL. Neither the provider (integrations/permutive/segments.ts) nor the server filter (auction/formats.rs:227-251) checks consent, so review consent handling before relying on this flow.installing click guardinfo line no longer prints by default. It prints today only because creative's private logger is bumped.Done when
test/prebid-artifact-integration.test.mjs) buildscore,creativeandpermutivewith the production options, joins them with;\n, loads them in JSDOM, and asserts thattsjs.requestAds()sendspermutive_segments. It fails ona4e01eb55withexpected {} to deeply equal { Object (permutive_segments) }(checked) and passes with the fix.tsjs.setConfig({ logLevel: 'info' })an integration bundle's info line appears, and that the level right after load is stillwarn.build-all.mjsand the artifact test share one definition of the per-module build options, so the test cannot drift from production.test/core/context.test.tsresets the shared registry between tests./auctionconfigcontent and point toauction.allowed_context_keys.Affected area
JS build pipeline
Version
main at a4e01eb
Related