Skip to content

feat(experiment-tag): add RTBT relay protocol and RelayClient#333

Open
tyiuhc wants to merge 21 commits into
mainfrom
web/rtbt-relay-client
Open

feat(experiment-tag): add RTBT relay protocol and RelayClient#333
tyiuhc wants to merge 21 commits into
mainfrom
web/rtbt-relay-client

Conversation

@tyiuhc

@tyiuhc tyiuhc commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Add relay-protocol.ts with cross-subdomain RTBT message types and RELAY_RPC_TIMEOUT_MS (2000ms)
  • Add RelayClient — iframe postMessage RPC to https://cdn.amplitude.com/script/{API_KEY}.relay.html
  • Per-visitor relay namespace — constructor takes web_exp_id_v2; every RPC includes it so CDN localStorage is EXP_{apiKeySlice}_{web_exp_id_v2}_* (not one store per deployment key per browser)
  • Harden init lifecycle: concurrent init dedupe, outer timeout, late ready/body, origin + source validation, destroy/re-init
  • Extend whenBodyReady with a cancel disposer; RelayClient.destroy() stops body polls
  • Export relay modules from behavioral-targeting index

Foundation for cross-subdomain RTBT syncing — not yet integrated into experiment.ts / event storage. Follow-ups:

Test plan

  • npx jest test/behavioral-targeting/relay-client.test.ts (experiment-tag)
  • Manual: verify relay iframe loads against local dynamic-script (relay hosting in nova#31184; pass custom relayUrl to RelayClient constructor)

Merge order

  1. feat(experiment-tag): add cross-subdomain web_exp_id_v2 and first_seen cookies #332 (identity cookies) + nova#31098 (new-experiment bucketing)
  2. This PR (feat(experiment-tag): add RTBT relay protocol and RelayClient #333)
  3. feat(experiment-tag): Dual-write RTBT events to relay #334 stacked PR (web/rtbt-use-relay → this branch)
  4. feat(experiment-tag): Wire relay iframe on start #335 wiring in experiment.ts
  5. nova#31184 (relay hosting)

Note

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-protocol types and a RelayClient that loads a hidden CDN iframe and talks to it via postMessage RPC (READ_EVENTS, WRITE_EVENT, MIGRATE_EVENTS, CHECK_MIGRATED). Every request carries web_exp_id_v2 so relay storage is namespaced per visitor, not only per API key.

RelayClient covers init (deduped concurrent init, 2s timeout, late ready), origin/source checks, queued writes with flush/retry, migration helpers, waitForAvailable, and destroy/re-init cleanup. whenBodyReady now returns a cancel disposer so relay init can stop body polling on destroy. Relay modules are exported from behavioral-targeting; nothing in experiment.ts uses 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.

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>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Create PR

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.

Comment thread packages/experiment-tag/src/behavioral-targeting/relay-client.ts
Comment thread packages/experiment-tag/src/behavioral-targeting/relay-client.ts
Comment thread packages/experiment-tag/src/behavioral-targeting/relay-client.ts
Comment thread packages/experiment-tag/src/behavioral-targeting/relay-client.ts Outdated
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>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Create PR

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.

Comment thread packages/experiment-tag/src/behavioral-targeting/relay-client.ts Outdated
Comment thread packages/experiment-tag/src/behavioral-targeting/relay-client.ts
Use whenBodyReady before iframe injection and auto-flush pendingWrites
when the relay becomes available.

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread packages/experiment-tag/src/behavioral-targeting/relay-client.ts Outdated
Comment thread packages/experiment-tag/src/behavioral-targeting/relay-client.ts
@linear-code

linear-code Bot commented Jun 16, 2026

Copy link
Copy Markdown

WEB-130

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>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Create PR

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.

Comment thread packages/experiment-tag/src/behavioral-targeting/relay-client.ts
Comment thread packages/experiment-tag/src/behavioral-targeting/relay-client.ts
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>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Create PR

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.

Comment thread packages/experiment-tag/src/behavioral-targeting/relay-client.ts
Comment thread packages/experiment-tag/src/behavioral-targeting/relay-client.ts
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>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Create PR

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.

Comment thread packages/experiment-tag/src/behavioral-targeting/relay-client.ts
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>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Create PR

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.

Comment thread packages/experiment-tag/src/behavioral-targeting/relay-client.ts
tyiuhc and others added 3 commits June 16, 2026 10:04
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>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Create PR

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.

Comment thread packages/experiment-tag/src/behavioral-targeting/relay-client.ts
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>
tyiuhc and others added 4 commits June 16, 2026 14:52
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>
tyiuhc and others added 3 commits June 16, 2026 15:47
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>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Create PR

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.

Comment thread packages/experiment-tag/src/behavioral-targeting/relay-client.ts
Comment thread packages/experiment-tag/src/behavioral-targeting/relay-client.ts Outdated
Remove queue entries by id/type/timestamp on confirm, settle
waitForAvailable on destroy.

Co-authored-by: Cursor <cursoragent@cursor.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

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.

Create PR

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.

Comment thread packages/experiment-tag/src/behavioral-targeting/relay-client.ts
…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>
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.

1 participant