Skip to content

Integration IIFEs keep private core state, so Permutive segments never reach /auction #1196

Description

@aram356

Description

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:

  1. Context providers. core/context.ts:11 holds the registry in module scope:

    const providers = new Map<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: {}.

  2. Log level. core/log.ts:5 holds the level in module scope:

    let currentLevel: 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:

Steps to reproduce

  1. Build the production bundles:

    cd crates/trusted-server-js/lib
    npm ci
    npm run build
  2. 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):

    import fs from 'node:fs';
    import { JSDOM, VirtualConsole } from 'jsdom';
    
    const bundle = ['core', 'creative', 'permutive']
      .map((id) => fs.readFileSync(`../dist/tsjs-${id}.js`, 'utf8'))
      .join(';\n');
    
    const infoLines = [];
    const virtualConsole = new VirtualConsole();
    virtualConsole.on('info', (...args) => infoLines.push(args.join(' ')));
    
    let auctionBody;
    const { window } = new JSDOM(
      `<!doctype html><head><script>${bundle}</script></head><body><div id="slot1"></div></body>`,
      {
        url: 'https://publisher.example.com/',
        runScripts: 'dangerously',
        virtualConsole,
        beforeParse(w) {
          w.localStorage.setItem('permutive-app', JSON.stringify({ core: { cohorts: { all: ['111', '222'] } } }));
          w.fetch = async (_url, init) => {
            auctionBody = JSON.parse(init.body);
            return { ok: true, headers: { get: () => 'application/json' }, json: async () => ({ seatbid: [] }) };
          };
        },
      }
    );
    
    window.tsjs.addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } });
    window.tsjs.requestAds();
    console.log('/auction config:', JSON.stringify(auctionBody.config));
    
    window.tsjs.setConfig({ logLevel: 'info' });
    infoLines.length = 0;
    const script = window.document.createElement('script');
    script.src = 'https://cdn.permutive.com/example-web.js';
    window.document.head.appendChild(script);
    console.log('Permutive guard info lines after setConfig({ logLevel: "info" }):',
      infoLines.filter((line) => line.includes('Permutive guard')).length);
  3. Run it:

    node repro-iife-state.mjs

    Observed at a4e01eb55:

    /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:

import { expect, it, vi } from 'vitest';

it('sends permutive_segments to /auction', async () => {
  localStorage.setItem('permutive-app', JSON.stringify({ core: { cohorts: { all: ['111', '222'] } } }));
  let body: { config?: unknown } | undefined;
  vi.stubGlobal('fetch', vi.fn(async (_url: string, init: RequestInit) => {
    body = JSON.parse(String(init.body));
    return new Response('{"seatbid":[]}', { headers: { 'content-type': 'application/json' } });
  }));
  await import('../../../src/integrations/permutive/index');
  const { addAdUnits } = await import('../../../src/core/registry');
  const { requestAds } = await import('../../../src/core/request');
  addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } });
  requestAds();
  expect(body?.config).toEqual({ permutive_segments: ['111', '222'] });
});

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
integrations/aps/render.ts core, gpt, prebid activeFrames, pendingFrameCancels, nativeDispatches, validatedRendererCache, publisherNativeRendering (:31-32, :45-49) 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) Shared by design
shared/dom_insertion_dispatcher.ts datadome, google_tag_manager, gpt, lockr, permutive, sourcepoint none; versioned state on Symbol.for('trusted-server.domInsertionDispatcher') (:6-7, :150-177) Shared by design
core/auction.ts, core/queue.ts, core/render.ts, core/slot_element.ts, shared/globals.ts, shared/origin.ts, shared/script_guard.ts 2 to 5 bundles each 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.
  • If APS render state is later moved to shared state too, frame supersession starts spanning core and GPT paths. That belongs with TSJS core imports the APS renderer directly, so a second bid-renderer vendor cannot be added without changing core #1111.

Done when

  • 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 /auction config 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.

Affected area

JS build pipeline

Version

main at a4e01eb

Related

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

No labels
No labels

Type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions