Skip to content

Fix APS PUC rendering and collapsed GAM shells - #998

Open
ChristianPavilonis wants to merge 16 commits into
fix/aps-renderer-requestid-registrationfrom
aps-renderer-handshake-fix
Open

Fix APS PUC rendering and collapsed GAM shells#998
ChristianPavilonis wants to merge 16 commits into
fix/aps-renderer-requestid-registrationfrom
aps-renderer-handshake-fix

Conversation

@ChristianPavilonis

Copy link
Copy Markdown
Collaborator

Summary

  • establish nonce-bound APS renderer communication over a private MessageChannel for PUC sibling-frame rendering while retaining the legacy direct renderer path
  • resize only the authenticated, ordinary collapsed 1x1 GAM shell after APS rendering
  • add PUC 1.17.2 browser coverage and focused transport/geometry regressions

Validation

  • cargo test-fastly
  • cargo clippy-fastly
  • cargo fmt --all -- --check
  • cd crates/trusted-server-js/lib && npx vitest run
  • TEST_FRAMEWORK=nextjs npx playwright test tests/shared/aps-renderer.spec.ts
  • git diff --check

ChristianPavilonis and others added 14 commits July 29, 2026 12:19
APS bids are bid-by-reference: interpretResponse sets the renderer descriptor on
the Prebid bid as the custom trustedServerRenderer field, and a bidResponse
listener registers it in window.tsjs.apsPrebidRenderers keyed by Prebid's
generated adId so the Universal Creative can later request it.

Prebid normalizes each bid into its own object during addBidResponse and drops
unknown top-level fields, so the custom field can be gone before the bidResponse
listener runs (observed in production: absent as early as bidAccepted). The
listener then saw renderer === undefined and returned without registering,
leaving the registry empty; the Universal Creative's request found nothing and
Prebid's default renderer threw "Missing ad markup or URL" (reason noAd) for
every APS bid.

Carry the descriptor in bid.meta as well. meta is a first-class field Prebid
preserves through normalization (bidderFactory assigns bid.meta onto the
normalized bid), and it is per bid, so multiple APS bids on the same imp each
keep their own descriptor. The bidResponse listener falls back to the meta copy
when the top-level field is absent and scrubs both copies unconditionally after
the registration attempt, keeping the executable capability only in the bounded
one-time registry.

Adds unit tests for registration via meta with the top-level field stripped,
distinct renderer registration for multiple APS bids on one imp, and that a
stripped bid carrying no descriptor registers nothing.
- Register and scrub the APS renderer descriptor on bidAccepted, the first
  event after Prebid assigns adId, so bidResponse and analytics consumers of
  later events never observe the meta copy; the bidResponse pass remains as
  a fallback that no-ops once the bidAccepted pass has scrubbed.
- Guard the meta read with a narrow object check so a module overwriting
  meta with a non-object value cannot break registration or scrubbing.
- Consolidate the dual-carrier rationale onto APS_RENDERER_FIELD, stating
  the belt-and-braces intent of keeping the custom top-level copy; both
  call sites now reference it in one line.
The APS adapter spec loads the external Prebid bundle and assumed it
carries the tsjs shim (and the trustedServer adapter) inside it. Once
the shim is decoupled (PR #967) the external bundle is pure Prebid.js,
no adapter ever registers, and the in-page auction times out.

Detect the decoupled world through the window.__tsjs_prebid_bundle
manifest stamp and load dist/tsjs-prebid.js after the external bundle,
matching the script order the server actually serves. On a coupled
bundle the stamp is absent and the spec behaves exactly as before.
@ChristianPavilonis
ChristianPavilonis force-pushed the aps-renderer-handshake-fix branch from f65164d to 78d72e6 Compare August 4, 2026 20:06
@ChristianPavilonis
ChristianPavilonis changed the base branch from rc/july to fix/aps-renderer-spec-decoupled-shim August 4, 2026 20:06
@ChristianPavilonis
ChristianPavilonis marked this pull request as ready for review August 4, 2026 20:51

@prk-Jr prk-Jr left a comment

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.

Summary

Moves APS PUC delivery onto a nonce-bound private MessageChannel while keeping the legacy direct-postMessage path in the renderer document, and adds a narrowly-guarded resize for collapsed 1x1 GAM shells. The transport rework is backward-compatible by construction and the new PUC 1.17.2 browser test is a genuine regression net. One blocking item: the JS format gate fails on a stray blank line. One question on renderer versioning.

Blocking

🔧 wrench

  • JS format gate fails: stray double blank line left by the moved APS branch — prettier --check rejects src/integrations/gpt/index.ts, failing CI gate 6 (crates/trusted-server-js/lib/src/integrations/gpt/index.ts:1206, inline).

❓ question

  • Renderer version not bumped despite a wire-protocol change (crates/trusted-server-js/lib/src/integrations/aps/render.ts:7): APS_UNIVERSAL_CREATIVE_RENDERER_VERSION stays 4 while APS_UNIVERSAL_CREATIVE_RENDERER switches from a direct postMessage({nonce, renderer}) to a transferred MessageChannel. PUC keys its cached dynamic renderer on rendererVersion. Is holding at 4 deliberate — i.e. the renderer document retains the legacy direct branch precisely so a PUC-cached v4 source keeps working against the new document? If so, a comment on that constant saying the version is intentionally pinned would make the coupling explicit; if not, it needs a bump. (Not inline: line 7 is outside the diff.)

Non-blocking

🤔 thinking

  • Inline sizing is permanent across GPT refresh (.../gpt/index.ts:185, inline)
  • Port branch drops the parent-source check the direct branch keeps (crates/trusted-server-core/src/integrations/aps.rs:120, inline)
  • Capability consumed before the response is known to be delivered (.../gpt/index.ts:1158, inline)
  • var b declared twice in the renderer source (.../aps/render.ts:421, inline)

⛏ nitpick

  • Inconsistent try scope between the two response paths (.../gpt/index.ts:1238, inline)
  • usesFixedPositioning also matches sticky (.../gpt/index.ts:145, inline)

📝 note

  • Resize applies to more than the APS path: the description says "resize only the authenticated, ordinary collapsed 1x1 GAM shell after APS rendering", but resizeCollapsedCreativeFrame is also called on the inline-adm path and the cached-adm path. The behaviour change is wider than the summary states — worth reflecting in the PR description so the blast radius is clear to anyone bisecting a sizing regression later.
  • New browser-test dependency: prebid-universal-creative@1.17.2 is pinned exactly and lands as "dev": true (93 packages in that lockfile, all dev). Reasonable price for real PUC coverage; noting the added CI install surface (gulp-cli, babel-runtime, core-js@2.6.12) for awareness.

👍 praise

  • Conservative resize guard set (.../gpt/index.ts:151, inline)
  • Real-PUC topology coverage (.../browser/tests/shared/aps-renderer.spec.ts:208, inline)

CI Status

GitHub checks on this head: 4/4 pass (browser integration tests, integration tests, integration tests (Fastly EC lifecycle), prepare integration artifacts). Because the base branch is fix/aps-renderer-spec-decoupled-shim rather than main, the fmt / clippy / unit-test workflows did not run on this PR — the results below are from running them locally at 5f29a74.

  • cargo fmt --all -- --check: PASS
  • cargo test-fastly aps: PASS (60 passed)
  • npx vitest run: PASS (528 passed, 31 files)
  • npm run lint (eslint): PASS
  • npm run format (prettier --check): FAILsrc/integrations/gpt/index.ts
  • clippy: not run locally

(npx tsc --noEmit reports errors in test/integrations/prebid/index.test.ts and test/shared/beacon_guard.test.ts; those are pre-existing and unrelated to this PR.)

);
return;
}

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 — CI format gate fails here.

Moving the matchedBid.renderer branch below the width/height computation left a second consecutive blank line, so prettier --check rejects this file and CI gate 6 (cd crates/trusted-server-js/lib && npm run format) fails:

$ npx prettier --check src/integrations/gpt/index.ts
[warn] src/integrations/gpt/index.ts
[warn] Code style issues found in the above file.

$ npx prettier src/integrations/gpt/index.ts | diff - src/integrations/gpt/index.ts
1205a1206
>

Fix: delete the blank line. Note this PR's base is not main, so the fmt/clippy/unit-test workflows never ran on this head — only the four integration-test checks did.

var n=w.btoa(s).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/g,"");
var f=w.document.createElement("iframe"),done=false,t;
function clean(){w.removeEventListener("message",receive);if(t)w.clearTimeout(t);}
var x=w.document,b=x.body,f=x.createElement("iframe"),q=new MessageChannel(),done=false,sent=false,t;

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.

🤔 thinkingb is declared twice in this renderer source.

Two lines above, var b=new Uint8Array(16) holds the nonce bytes; here var b=x.body rebinds the same name. It is correct today only because the byte buffer is dead by the time this line runs. Any future edit that moves the nonce derivation below the body capture would silently base64-encode the body element instead of the random bytes, and the resulting nonce would still match /^[A-Za-z0-9_-]{22,128}$/ in the renderer document — so it would fail as a silent loss of entropy, not a thrown error.

Fix: rename one of them, e.g.

var by=new Uint8Array(16);c.getRandomValues(by);var s="";for(var i=0;i<by.length;i++)s+=String.fromCharCode(by[i]);

return;
}

frame.iframe.style.width = `${width}px`;

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 inline sizing written here is permanent and never cleared.

frame.iframe.style.width/height (and the wrapper's, below) are inline styles, which beat GAM's presentational width/height attributes. Two consequences on a GPT slot refresh that reuses the same iframe element:

  1. GAM re-sizes the shell via attributes for the next creative — the stale inline 300px × 250px wins and the slot renders at the previous creative's box.
  2. GAM collapses the slot back to 1x1 via attributes — the computed size is still 300px, so hasOnePixelDimension now returns false and this function can never re-arm for that slot.

Suggestion: record what was set and clear it when the frame is observed re-collapsing (attributes back to 1), or scope the resize to the APS renderer path where the collapsed-shell topology is the actual problem being fixed.

start(message.renderer,function(result){parent.postMessage({message:result,nonce:nonce},'*');});
return;
}
if(!keys(message,['nonce'])||event.ports.length!==1||message.nonce!==expected)return;

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 MessagePort branch drops the parent-source check the direct branch keeps.

Three lines above, the direct branch guards with event.source!==parent||event.ports.length!==0. This port branch checks only key shape, port count and nonce. The 128-bit fragment nonce is unreadable cross-origin, so this is not exploitable — but the asymmetry reads as a deliberate relaxation, and the accompanying test now asserts only event.ports.length!==1 for this branch.

Fix: keep the two branches identical in intent for one extra comparison.

if(!keys(message,['nonce'])||event.source!==parent||event.ports.length!==1||message.nonce!==expected)return;

// an unrelated iframe when this slot-bound capability rejects it.
e.stopImmediatePropagation();
if (!messageSourceBelongsToAdUnit(e.source, prebidRendererEntry.adUnitCode)) return;
const sourceFrame = sourceFrameForAdUnit(e.source, prebidRendererEntry.adUnitCode);

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 capability is consumed before the response is known to be delivered.

In this branch the order is stopImmediatePropagation()consumeApsPrebidRenderer()markWinner()port.postMessage(...). The new catch below logs and returns, which leaves the slot in a terminal bad state: the one-shot renderer entry is already consumed, the bid is marked winner, Prebid's own handler was already suppressed, and markRendered() never runs. No retry path exists — the slot stays blank.

The same shape applies to the sibling branch further down, which also consumes before posting.

Suggestion: post first and consume on success, or restore the entry in the catch before returning. A MessagePort.postMessage of a string realistically only throws on a detached/closed port, but that is exactly the case where the fallback matters.

height: renderer.height,
})
);
resizeCollapsedCreativeFrame(e.source, sourceSlotFrame, renderer.width, renderer.height);

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 — inconsistent try scope between the two response paths.

Here resizeCollapsedCreativeFrame and log.debug sit inside the try, so a throw from the resize logs pbRender bridge: APS response failed even though the response was delivered successfully — misleading during triage. The matchedBid.adm branch below keeps its resize outside the try.

Fix: match the adm branch — keep only port.postMessage inside the try, and run the resize and the debug log after it.

return match !== null && Number(match[1]) <= 1;
}

function usesFixedPositioning(element: HTMLElement): boolean {

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.

nitpickusesFixedPositioning also matches sticky.

The name says fixed but the predicate covers both, which is the right behaviour for anchor/rail ads. Renaming to usesPinnedPositioning (or adding a one-line doc comment) keeps the call sites at lines 172 and 182 self-explanatory.

}

/** Resize only an authenticated, still-collapsed ordinary display-creative shell. */
function resizeCollapsedCreativeFrame(

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 — the guard set here fails closed at every step.

Requiring both the width/height attributes and the computed dimensions to be ≤1px, re-checking isConnected and contentWindow === source after the async hop, rejecting fixed/sticky frames, and carving out ins[data-anchor-status] means a hostile or merely unusual frame gets ignored rather than resized. The wrapper pass repeats the same 1x1 test before touching the parent. Good defensive design for code that mutates publisher DOM.

})();`;

test.describe("APS opaque renderer", () => {
test("renders APS through the real PUC dynamic-renderer topology and resizes only its 1x1 shell", async ({

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 shape of test for this bug class.

Driving the real PUC 1.17.2 banner.js through its dynamic-renderer branch (by deliberately leaving pbjs absent) reproduces the actual sibling-frame topology instead of a hand-rolled approximation, and the assertions cover geometry at all three frame levels plus the outer shell. The #div-other sibling slot that must stay 1x1 is what turns this from a happy-path test into a real regression net for the resize guard.

@aram356
aram356 force-pushed the fix/aps-renderer-spec-decoupled-shim branch from 417f5e0 to 2b4b17f Compare August 6, 2026 06:39

@aram356 aram356 left a comment

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.

Summary

The new work here is sound: the nonce-bound MessageChannel handshake correctly fixes PUC sibling-frame rendering (where the sender is the hidden dynamic-renderer frame, so the old event.source !== parent check could never pass), the collapsed 1x1 GAM shell resize is tightly guarded, and the real PUC 1.17.2 end-to-end spec is strong coverage. Two blocking items: a JS format failure and the branch/base divergence below.

Blocking

🔧 wrench

  • PR does not merge into its base: git merge-tree against fix/aps-renderer-spec-decoupled-shim reports content conflicts in 15 files (aps.rs, orchestrator.rs, formats.rs, prebid.rs, publisher.rs, creative_opportunities.rs, render.ts, gpt/index.ts, types.ts, several tests/docs, plus add/add conflicts on the new files). This branch was rebuilt on a stale merge-base (bf61fa189) while the base chain advanced, so the two sides now hold divergent copies of the same logical commits: this branch carries the markWinner/markRendered split from the #963 feedback round while the base tip still has markUsed, and the base carries ~16k lines of unrelated newer work this branch lacks. As a result the PR is unmergeable as-is, and the 48-file diff attributes ~9.5k lines of already-reviewed stacked content (#918/#963/#988) to this PR. Fix: update the base branch to the rebased chain, or rebase this branch onto the current base tip, so the PR diff reduces to the actual new work (9 files, +1,628/−98).
  • JS format gate fails on crates/trusted-server-js/lib/src/integrations/gpt/index.ts (see inline comment): CI gate 6 (npm run format) rejects the double blank line left where the old renderer branch was removed.

CI Status

  • GitHub checks: integration tests (4 jobs) PASS
  • Local vitest: 528/528 PASS
  • Local cargo test (wasm32-wasip1, aps-filtered): 60/60 PASS
  • JS format: FAIL (gpt/index.ts)
  • fmt/clippy: not run locally

// pulling slot B's creative and firing slot B's win/billing beacons.
if (!matchedBid || matchedBid.hb_adid !== adId) return;


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: removing the old renderer branch left a double blank line here. npx prettier --check fails on exactly this (verified locally), so the npm run format CI gate will reject the PR.

Fix: delete one of the two blank lines.

start(message.renderer,function(result){parent.postMessage({message:result,nonce:nonce},'*');});
return;
}
if(!keys(message,['nonce'])||event.ports.length!==1||message.nonce!==expected)return;

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 port path deliberately omits the event.source===parent check that the legacy path above keeps. That omission is load-bearing: in PUC's sibling-frame topology the handshake sender is the hidden dynamic-renderer frame, not this frame's parent, so a source check would re-break exactly the bug this PR fixes. Nothing in the code records that, and a future hardening pass could plausibly "restore" the check.

Suggestion: add a short comment above this branch, e.g. // Port path is nonce-only by design: PUC's dynamic renderer posts from a hidden sibling frame, not this frame's parent.

if(accepted||portEvent.ports.length!==0||!keys(descriptor,['renderer'])){port.close();return;}
var nonce=expected;
if(!start(descriptor.renderer,function(result){try{port.postMessage({message:result,nonce:nonce});}finally{port.close();}}))port.close();
};

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: when the descriptor fails validRenderer, the frame closes the port without replying. Port closure is not observable cross-browser, so the parent waits the full 10s RENDERER_READY_TIMEOUT_MS before rejecting, and PUC reports adRenderSucceeded/adRenderFailed 10 seconds late.

Suggestion: post trusted-server/aps/renderer-failed on the port before closing it (and likewise return a failure reply in the legacy path), so invalid descriptors fail fast instead of timing out.

if(typeof MessageChannel!=="function")throw new Error("APS renderer channel unavailable");
var b=new Uint8Array(16);c.getRandomValues(b);var s="";for(var i=0;i<b.length;i++)s+=String.fromCharCode(b[i]);
var n=w.btoa(s).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/g,"");
var x=w.document,b=x.body,f=x.createElement("iframe"),q=new MessageChannel(),done=false,sent=false,t;

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: var b is declared twice in this renderer source: first as the nonce byte array on line 419, then reassigned to x.body here. It only works because the reuse happens after the nonce is derived; a future reorder would silently corrupt the nonce.

Suggestion: use a different variable name for the body reference.

"devDependencies": {
"@playwright/test": "^1.49.0"
"@playwright/test": "^1.49.0",
"prebid-universal-creative": "1.17.2"

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.

📝 note: prebid-universal-creative@1.17.2 pulls 93 packages into the browser-test harness (including gulp-cli and core-js, which has an install script) for the single dist/banner.js file the spec reads. Pinned, dev-only, and integrity-hashed, so this is acceptable; if the footprint becomes a concern, consider npm ci --ignore-scripts in CI or vendoring just the dist file.

const rendererUrl = apsRendererUrl();
if (!renderer || !rendererUrl) return;

// Ownership and the complete validated envelope are valid before this

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 comment does not parse ("Ownership and the complete validated envelope are valid before this handler claims the message..."); please reword, e.g. "Validate ownership and the complete envelope before this handler claims the message or suppresses another legitimate handler."

Relatedly, the PBS Cache path below is now the only response path whose port.postMessage has no dedicated try/catch: a throw there is swallowed by the fetch .catch and logged as "PBS Cache fetch failed", which is misleading.

Base automatically changed from fix/aps-renderer-spec-decoupled-shim to fix/aps-renderer-requestid-registration August 6, 2026 07:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants