feat(experiment-tag): add RTBT relay protocol and RelayClient#333
feat(experiment-tag): add RTBT relay protocol and RelayClient#333tyiuhc wants to merge 21 commits into
Conversation
Introduce cross-subdomain RTBT relay messaging types and an iframe-based
RelayClient that talks to cdn.amplitude.com/script/{API_KEY}.relay.html.
Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 4 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for all 4 issues found in the latest run.
- ✅ Fixed: Flush resends already-sent writes
- Fixed by only pushing events to pendingWrites when the relay is unavailable, preventing duplicate sends when writeEvent is called while relay is up and flush is called later.
- ✅ Fixed: Destroy races init timeout
- Fixed by storing initTimeoutId as a class property and clearing it in destroy() to prevent the stale timeout callback from setting ready=true after teardown.
- ✅ Fixed: Concurrent init duplicates iframes
- Fixed by storing initPromise and returning it for concurrent init() calls, ensuring only one iframe is created regardless of how many times init() is called concurrently.
- ✅ Fixed: Late ready ignored after timeout
- Fixed by changing the ready message handler condition from !this.ready to !this.available, allowing late ready messages to still set up the relay after timeout.
Or push these changes by commenting:
@cursor push ffa1552f6a
Preview (ffa1552f6a)
diff --git a/packages/experiment-tag/src/behavioral-targeting/relay-client.ts b/packages/experiment-tag/src/behavioral-targeting/relay-client.ts
--- a/packages/experiment-tag/src/behavioral-targeting/relay-client.ts
+++ b/packages/experiment-tag/src/behavioral-targeting/relay-client.ts
@@ -45,6 +45,8 @@
>();
private messageListener: ((event: MessageEvent) => void) | null = null;
+ private initPromise: Promise<void> | null = null;
+ private initTimeoutId: number | null = null;
constructor(
private readonly apiKey: string,
@@ -62,7 +64,11 @@
return;
}
- return new Promise((resolve) => {
+ if (this.initPromise) {
+ return this.initPromise;
+ }
+
+ this.initPromise = new Promise((resolve) => {
const iframe = document.createElement('iframe');
iframe.src = this.relayUrl;
iframe.style.display = 'none';
@@ -75,11 +81,14 @@
return;
}
- if (!this.ready && isRelayReadyMessage(event.data)) {
+ if (!this.available && isRelayReadyMessage(event.data)) {
this.iframeWindow = iframe.contentWindow;
this.available = true;
this.ready = true;
- window.clearTimeout(timeoutId);
+ if (this.initTimeoutId !== null) {
+ window.clearTimeout(this.initTimeoutId);
+ this.initTimeoutId = null;
+ }
resolve();
return;
}
@@ -96,8 +105,8 @@
pending.resolve(response);
};
- const timeoutId = window.setTimeout(() => {
- this.available = false;
+ this.initTimeoutId = window.setTimeout(() => {
+ this.initTimeoutId = null;
this.ready = true;
resolve();
}, RELAY_RPC_TIMEOUT_MS);
@@ -105,6 +114,8 @@
window.addEventListener('message', onMessage);
this.messageListener = onMessage;
});
+
+ return this.initPromise;
}
private sendRequest(request: RelayRequest): Promise<RelayResponse> {
@@ -140,8 +151,8 @@
}
writeEvent(event: RelayEventRecord): void {
- this.pendingWrites.push(event);
if (!this.available || !this.iframeWindow) {
+ this.pendingWrites.push(event);
return;
}
void this.sendRequest({
@@ -199,6 +210,10 @@
}
destroy(): void {
+ if (this.initTimeoutId !== null) {
+ window.clearTimeout(this.initTimeoutId);
+ this.initTimeoutId = null;
+ }
if (this.messageListener) {
window.removeEventListener('message', this.messageListener);
this.messageListener = null;
@@ -208,5 +223,6 @@
this.iframeWindow = null;
this.available = false;
this.ready = false;
+ this.initPromise = null;
}
}You can send follow-ups to the cloud agent here.
Address Bugbot findings: dedupe concurrent init, accept late ready after timeout, clear init timer on destroy, and avoid double-sending flushed writes. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Init appends iframe before body
- Wrapped iframe creation and appendChild call in whenBodyReady callback to defer DOM mutation until document.body exists, matching the pattern used elsewhere in the package.
- ✅ Fixed: Queued writes not flushed on ready
- Added this.flush() call immediately after setting available=true in the ready handler to automatically drain pendingWrites when the relay becomes available.
Or push these changes by commenting:
@cursor push 3913f319a9
Preview (3913f319a9)
diff --git a/packages/experiment-tag/src/behavioral-targeting/relay-client.ts b/packages/experiment-tag/src/behavioral-targeting/relay-client.ts
--- a/packages/experiment-tag/src/behavioral-targeting/relay-client.ts
+++ b/packages/experiment-tag/src/behavioral-targeting/relay-client.ts
@@ -6,6 +6,7 @@
RelayRequest,
RelayResponse,
} from './relay-protocol';
+import { whenBodyReady } from '../util/when-body-ready';
export function getRelayUrl(apiKey: string, dev = false): string {
if (dev) {
@@ -67,52 +68,56 @@
this.initPromise = new Promise((resolve) => {
this.initResolve = resolve;
- const iframe = document.createElement('iframe');
- iframe.src = this.relayUrl;
- iframe.style.display = 'none';
- iframe.setAttribute('aria-hidden', 'true');
- document.body.appendChild(iframe);
- this.iframe = iframe;
- const onMessage = (event: MessageEvent) => {
- if (event.origin !== this.relayOrigin) {
- return;
- }
+ whenBodyReady(() => {
+ const iframe = document.createElement('iframe');
+ iframe.src = this.relayUrl;
+ iframe.style.display = 'none';
+ iframe.setAttribute('aria-hidden', 'true');
+ document.body.appendChild(iframe);
+ this.iframe = iframe;
- if (!this.available && isRelayReadyMessage(event.data)) {
- this.iframeWindow = iframe.contentWindow;
- this.available = true;
+ const onMessage = (event: MessageEvent) => {
+ if (event.origin !== this.relayOrigin) {
+ return;
+ }
+
+ if (!this.available && isRelayReadyMessage(event.data)) {
+ this.iframeWindow = iframe.contentWindow;
+ this.available = true;
+ this.ready = true;
+ if (this.initTimeoutId !== null) {
+ window.clearTimeout(this.initTimeoutId);
+ this.initTimeoutId = null;
+ }
+ this.initResolve = null;
+ this.flush();
+ resolve();
+ return;
+ }
+
+ const response = event.data as RelayResponse;
+ if (!response?.requestId) {
+ return;
+ }
+ const pending = this.pendingRequests.get(response.requestId);
+ if (!pending) {
+ return;
+ }
+ this.pendingRequests.delete(response.requestId);
+ pending.resolve(response);
+ };
+
+ this.initTimeoutId = window.setTimeout(() => {
+ this.initTimeoutId = null;
this.ready = true;
- if (this.initTimeoutId !== null) {
- window.clearTimeout(this.initTimeoutId);
- this.initTimeoutId = null;
- }
this.initResolve = null;
resolve();
- return;
- }
+ }, RELAY_RPC_TIMEOUT_MS);
- const response = event.data as RelayResponse;
- if (!response?.requestId) {
- return;
- }
- const pending = this.pendingRequests.get(response.requestId);
- if (!pending) {
- return;
- }
- this.pendingRequests.delete(response.requestId);
- pending.resolve(response);
- };
-
- this.initTimeoutId = window.setTimeout(() => {
- this.initTimeoutId = null;
- this.ready = true;
- this.initResolve = null;
- resolve();
- }, RELAY_RPC_TIMEOUT_MS);
-
- window.addEventListener('message', onMessage);
- this.messageListener = onMessage;
+ window.addEventListener('message', onMessage);
+ this.messageListener = onMessage;
+ });
});
return this.initPromise;You can send follow-ups to the cloud agent here.
Use whenBodyReady before iframe injection and auto-flush pendingWrites when the relay becomes available. Co-authored-by: Cursor <cursoragent@cursor.com>
Start init timeout when init() is called, not after body is ready, and skip iframe injection when document.body is still unavailable. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Init timeout skips late iframe
- Moved the timeout inside the whenBodyReady callback so it only starts after the iframe is created, ensuring the full timeout window is available for the relay to respond.
- ✅ Fixed: Ready messages ignore event source
- Added event.source === iframe.contentWindow check to the ready message handler to ensure the message comes from this client's own iframe, preventing cross-frame ready spoofing.
Or push these changes by commenting:
@cursor push 1a584f99f6
Preview (1a584f99f6)
diff --git a/packages/experiment-tag/src/behavioral-targeting/relay-client.ts b/packages/experiment-tag/src/behavioral-targeting/relay-client.ts
--- a/packages/experiment-tag/src/behavioral-targeting/relay-client.ts
+++ b/packages/experiment-tag/src/behavioral-targeting/relay-client.ts
@@ -83,11 +83,6 @@
resolve();
};
- this.initTimeoutId = window.setTimeout(() => {
- this.initTimeoutId = null;
- finishInit();
- }, RELAY_RPC_TIMEOUT_MS);
-
whenBodyReady(() => {
if (!this.initResolve) {
return;
@@ -103,12 +98,21 @@
document.body.appendChild(iframe);
this.iframe = iframe;
+ this.initTimeoutId = window.setTimeout(() => {
+ this.initTimeoutId = null;
+ finishInit();
+ }, RELAY_RPC_TIMEOUT_MS);
+
const onMessage = (event: MessageEvent) => {
if (event.origin !== this.relayOrigin) {
return;
}
- if (!this.available && isRelayReadyMessage(event.data)) {
+ if (
+ !this.available &&
+ isRelayReadyMessage(event.data) &&
+ event.source === iframe.contentWindow
+ ) {
this.iframeWindow = iframe.contentWindow;
this.available = true;
this.flush();You can send follow-ups to the cloud agent here.
Allow whenBodyReady to create the iframe after init resolves via timeout instead of bailing on cleared initResolve. Ignore postMessage events whose source is not the relay iframe's contentWindow. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: writeEvent queues after destroy
- Added a destroyed check at the start of writeEvent() to silently drop events after destroy() has been called.
- ✅ Fixed: Re-init blocked by destroyed flag
- Reset destroyed flag to false at the start of init() to allow re-initialization on the same instance.
Or push these changes by commenting:
@cursor push fd3536a8f9
Preview (fd3536a8f9)
diff --git a/packages/experiment-tag/src/behavioral-targeting/relay-client.ts b/packages/experiment-tag/src/behavioral-targeting/relay-client.ts
--- a/packages/experiment-tag/src/behavioral-targeting/relay-client.ts
+++ b/packages/experiment-tag/src/behavioral-targeting/relay-client.ts
@@ -68,6 +68,8 @@
return this.initPromise;
}
+ this.destroyed = false;
+
this.initPromise = new Promise((resolve) => {
this.initResolve = resolve;
@@ -173,6 +175,9 @@
}
writeEvent(event: RelayEventRecord): void {
+ if (this.destroyed) {
+ return;
+ }
if (!this.available || !this.iframeWindow) {
this.pendingWrites.push(event);
return;You can send follow-ups to the cloud agent here.
Clear destroyed at init() start so the same RelayClient can be reused. Silently ignore writeEvent after destroy instead of queueing unflushable events. Update re-init test to use the same instance. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Queued writes survive destroy
- Added
this.pendingWrites = [];to the destroy() method to clear the pending events queue, preventing stale events from being flushed when the client is re-initialized.
- Added
Or push these changes by commenting:
@cursor push 5280a23b07
Preview (5280a23b07)
diff --git a/packages/experiment-tag/src/behavioral-targeting/relay-client.ts b/packages/experiment-tag/src/behavioral-targeting/relay-client.ts
--- a/packages/experiment-tag/src/behavioral-targeting/relay-client.ts
+++ b/packages/experiment-tag/src/behavioral-targeting/relay-client.ts
@@ -256,5 +256,6 @@
this.available = false;
this.ready = false;
this.initPromise = null;
+ this.pendingWrites = [];
}
}You can send follow-ups to the cloud agent here.
Teardown resets the ephemeral write queue so stale events are not flushed after re-init on the same instance. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Destroy leaves body polls running
- Modified whenBodyReady to return a cancel function and updated RelayClient to store and call this cancel function in destroy(), stopping any in-flight rAF polling.
Or push these changes by commenting:
@cursor push 0f85a07877
Preview (0f85a07877)
diff --git a/packages/experiment-tag/src/behavioral-targeting/relay-client.ts b/packages/experiment-tag/src/behavioral-targeting/relay-client.ts
--- a/packages/experiment-tag/src/behavioral-targeting/relay-client.ts
+++ b/packages/experiment-tag/src/behavioral-targeting/relay-client.ts
@@ -51,6 +51,7 @@
private initTimeoutId: number | null = null;
private initResolve: (() => void) | null = null;
private destroyed = false;
+ private cancelBodyPoll: (() => void) | null = null;
constructor(
private readonly apiKey: string,
@@ -91,7 +92,7 @@
finishInit();
}, RELAY_RPC_TIMEOUT_MS);
- whenBodyReady(() => {
+ this.cancelBodyPoll = whenBodyReady(() => {
if (this.destroyed || this.iframe) {
return;
}
@@ -238,6 +239,10 @@
destroy(): void {
this.destroyed = true;
+ if (this.cancelBodyPoll) {
+ this.cancelBodyPoll();
+ this.cancelBodyPoll = null;
+ }
if (this.initTimeoutId !== null) {
window.clearTimeout(this.initTimeoutId);
this.initTimeoutId = null;
diff --git a/packages/experiment-tag/src/util/when-body-ready.ts b/packages/experiment-tag/src/util/when-body-ready.ts
--- a/packages/experiment-tag/src/util/when-body-ready.ts
+++ b/packages/experiment-tag/src/util/when-body-ready.ts
@@ -10,18 +10,29 @@
* In the (effectively impossible) case where `requestAnimationFrame` is
* unavailable, the callback is invoked immediately rather than never —
* callers that would crash on a null body should guard inside.
+ *
+ * Returns a cancel function that stops the polling. If the body was already
+ * available (callback ran synchronously), the cancel function is a no-op.
*/
-export const whenBodyReady = (callback: () => void): void => {
+const noop = (): void => {
+ /* no-op cancel when body was already ready */
+};
+
+export const whenBodyReady = (callback: () => void): (() => void) => {
if (document.body) {
callback();
- return;
+ return noop;
}
const globalScope = getGlobalScope();
if (!globalScope?.requestAnimationFrame) {
callback();
- return;
+ return noop;
}
+ let cancelled = false;
const poll = () => {
+ if (cancelled) {
+ return;
+ }
if (document.body) {
callback();
} else {
@@ -29,4 +40,7 @@
}
};
globalScope.requestAnimationFrame(poll);
+ return () => {
+ cancelled = true;
+ };
};You can send follow-ups to the cloud agent here.
Return a disposer from whenBodyReady and call it in destroy() to stop stacked rAF polls when init runs before document.body exists. Consolidate relay-client tests with shared helpers. Co-authored-by: Cursor <cursoragent@cursor.com>
experiment-tag has no local-dev CDN detection; use RelayClient's relayUrl constructor param for manual testing against local dynamic-script. Co-authored-by: Cursor <cursoragent@cursor.com>
RelayClient now requires the cross-subdomain visitor ID and includes it on every postMessage request so CDN localStorage is isolated per visitor. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Destroy leaves pending RPC map
- Added code in destroy() to reject all pending RPC requests with an error and clear the pendingRequests map, ensuring callers get immediate teardown signal and preventing stale requestId cross-talk after re-init.
Or push these changes by commenting:
@cursor push 208bb91324
Preview (208bb91324)
diff --git a/packages/experiment-tag/src/behavioral-targeting/relay-client.ts b/packages/experiment-tag/src/behavioral-targeting/relay-client.ts
--- a/packages/experiment-tag/src/behavioral-targeting/relay-client.ts
+++ b/packages/experiment-tag/src/behavioral-targeting/relay-client.ts
@@ -252,6 +252,10 @@
window.removeEventListener('message', this.messageListener);
this.messageListener = null;
}
+ for (const pending of this.pendingRequests.values()) {
+ pending.reject(new Error('relay destroyed'));
+ }
+ this.pendingRequests.clear();
this.iframe?.remove();
this.iframe = null;
this.iframeWindow = null;You can send follow-ups to the cloud agent here.
Bugbot: destroy() left in-flight readEvents/checkMigrated/migrateEvents promises hanging until RPC timeout. Reject and clear pendingRequests on teardown. Co-authored-by: Cursor <cursoragent@cursor.com>
EventStorageManager writes to relay on addEvent, merges relay store on syncFromRelay (migrate if needed, relay wins on dedup), and flushes relay on unload/visibility. BehavioralTargetingManager exposes setRelayClient and syncFromRelay for Pass 2 evaluation. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…tests" This reverts commit 4d3eff1.
This reverts commit a8a88ef.
SDK does not validate web_exp_id_v2 shape before relay RPC; relay page owns any ID sanitization for storage keys. Co-authored-by: Cursor <cursoragent@cursor.com>
Wait for late RELAY_READY after init timeout and queue writeEvent until RPC confirms so unload flush can resend in-flight events. Co-authored-by: Cursor <cursoragent@cursor.com>
flush() retries without clearing the queue early so failed writes can resend. Co-authored-by: Cursor <cursoragent@cursor.com>
Avoid duplicate queue entries when flush resends in-flight writes. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Pending write cleared by reference
- Changed removeConfirmedWrite to use findIndex with logical comparison (id, event_type, timestamp) instead of indexOf reference comparison, matching the deduplication logic in writeEvent.
- ✅ Fixed: Destroy leaves waiters until timeout
- Added callback invocation for all waiters in destroy() before clearing the availableWaiters array, so pending waitForAvailable() promises resolve immediately.
Or push these changes by commenting:
@cursor push 74905c4c15
Preview (74905c4c15)
diff --git a/packages/experiment-tag/src/behavioral-targeting/relay-client.ts b/packages/experiment-tag/src/behavioral-targeting/relay-client.ts
--- a/packages/experiment-tag/src/behavioral-targeting/relay-client.ts
+++ b/packages/experiment-tag/src/behavioral-targeting/relay-client.ts
@@ -245,7 +245,12 @@
}
private removeConfirmedWrite(event: RelayEventRecord): void {
- const idx = this.pendingWrites.indexOf(event);
+ const idx = this.pendingWrites.findIndex(
+ (queued) =>
+ queued.id === event.id &&
+ queued.event_type === event.event_type &&
+ queued.timestamp === event.timestamp,
+ );
if (idx !== -1) {
this.pendingWrites.splice(idx, 1);
}
@@ -318,7 +323,11 @@
pending.reject(new Error('relay destroyed'));
}
this.pendingRequests.clear();
+ const waiters = [...this.availableWaiters];
this.availableWaiters.length = 0;
+ for (const waiter of waiters) {
+ waiter();
+ }
this.iframe?.remove();
this.iframe = null;
this.iframeWindow = null;You can send follow-ups to the cloud agent here.
Remove queue entries by id/type/timestamp on confirm, settle waitForAvailable on destroy. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Destroy races iframe listener setup
- Added destroyed check in onMessage handler, reset available/iframeWindow/listener in init(), and cleanup check after addEventListener to prevent race between destroy() and listener setup.
Or push these changes by commenting:
@cursor push 44d21768a5
Preview (44d21768a5)
diff --git a/packages/experiment-tag/src/behavioral-targeting/relay-client.ts b/packages/experiment-tag/src/behavioral-targeting/relay-client.ts
--- a/packages/experiment-tag/src/behavioral-targeting/relay-client.ts
+++ b/packages/experiment-tag/src/behavioral-targeting/relay-client.ts
@@ -130,6 +130,12 @@
}
this.destroyed = false;
+ if (this.messageListener) {
+ window.removeEventListener('message', this.messageListener);
+ this.messageListener = null;
+ }
+ this.available = false;
+ this.iframeWindow = null;
this.initPromise = new Promise((resolve) => {
this.initResolve = resolve;
@@ -169,6 +175,9 @@
this.iframe = iframe;
const onMessage = (event: MessageEvent) => {
+ if (this.destroyed) {
+ return;
+ }
if (event.origin !== this.relayOrigin) {
return;
}
@@ -199,6 +208,11 @@
window.addEventListener('message', onMessage);
this.messageListener = onMessage;
+
+ if (this.destroyed) {
+ window.removeEventListener('message', onMessage);
+ this.messageListener = null;
+ }
});
});You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit bd419e3. Configure here.
…oy messages Reset available/iframeWindow/listener at the start of init() and guard the message handler with the destroyed flag so a torn-down client can never be marked available against a detached iframe window. Co-authored-by: Cursor <cursoragent@cursor.com>


Summary
relay-protocol.tswith cross-subdomain RTBT message types andRELAY_RPC_TIMEOUT_MS(2000ms)RelayClient— iframe postMessage RPC tohttps://cdn.amplitude.com/script/{API_KEY}.relay.htmlweb_exp_id_v2; every RPC includes it so CDNlocalStorageisEXP_{apiKeySlice}_{web_exp_id_v2}_*(not one store per deployment key per browser)whenBodyReadywith a cancel disposer;RelayClient.destroy()stops body pollsFoundation for cross-subdomain RTBT syncing — not yet integrated into
experiment.ts/ event storage. Follow-ups:RelayClient.init()+ Pass 2 inexperiment.ts(depends on feat(experiment-tag): add cross-subdomain web_exp_id_v2 and first_seen cookies #332 forweb_exp_id_v2)Test plan
npx jest test/behavioral-targeting/relay-client.test.ts(experiment-tag)relayUrltoRelayClientconstructor)Merge order
web/rtbt-use-relay→ this branch)experiment.tsNote
Medium Risk
New cross-origin iframe and postMessage path for behavioral event storage; mitigated by origin/source checks and no production integration in this PR, but future wiring will touch experiment data flow.
Overview
Adds the cross-subdomain RTBT relay foundation: shared
relay-protocoltypes and aRelayClientthat loads a hidden CDN iframe and talks to it viapostMessageRPC (READ_EVENTS,WRITE_EVENT,MIGRATE_EVENTS,CHECK_MIGRATED). Every request carriesweb_exp_id_v2so relay storage is namespaced per visitor, not only per API key.RelayClientcovers init (deduped concurrent init, 2s timeout, late ready), origin/source checks, queued writes with flush/retry, migration helpers,waitForAvailable, and destroy/re-init cleanup.whenBodyReadynow returns a cancel disposer so relay init can stop body polling on destroy. Relay modules are exported from behavioral-targeting; nothing inexperiment.tsuses this yet (follow-up wiring).Tests cover relay lifecycle, writes, deferred body, and cancel behavior.
Reviewed by Cursor Bugbot for commit 45057fe. Bugbot is set up for automated code reviews on this repo. Configure here.