From ec01c13e9d7e7d7acced298c666500ec842bf24e Mon Sep 17 00:00:00 2001
From: mayberryzane <86132398+mayberryzane@users.noreply.github.com>
Date: Fri, 24 Jul 2026 13:56:00 -0700
Subject: [PATCH 01/11] feat(replay): origin-isolated embedded replayer over
postMessage (SEC-8885)
Add an embedded-replay bridge so the Replayer can run inside a sandboxed,
cookieless, cross-origin iframe and be driven from the parent page, instead of
running in the embedding app's privileged origin.
- protocol.ts: typed, versioned, namespaced message contract. Data-only
(no functions/DOM cross the boundary) and both sides authenticate their peer
(host by event.origin, client by event.source identity). pickSerializableConfig
allowlists the data-only playerConfig subset.
- host.ts: EmbeddedReplayerHost runs inside the iframe, constructs the Replayer
in that realm, forwards ReplayerEvents back, applies control commands, answers
getter RPCs, and pushes current-time telemetry while playing.
- client.ts: EmbeddedReplayerClient mirrors the Replayer control/event/getter
surface the viewer needs, over the bridge.
- host-document.ts: buildHostDocument emits the sandbox HTML shell with a
CSP whose script-src omits unsafe-eval (kills the canvas Function() path even
inside the sandbox).
Purely additive; existing in-parent Replayer usage is unchanged. Unit tests
cover the protocol guards, config sanitization, and the client bridge behavior.
Co-Authored-By: Claude Opus 4.8
---
.../origin-isolated-embedded-replayer.md | 13 +
packages/rrweb/src/index.ts | 18 ++
packages/rrweb/src/replay/embedded/client.ts | 217 ++++++++++++++
.../src/replay/embedded/host-document.ts | 83 ++++++
packages/rrweb/src/replay/embedded/host.ts | 277 ++++++++++++++++++
packages/rrweb/src/replay/embedded/index.ts | 35 +++
.../rrweb/src/replay/embedded/protocol.ts | 174 +++++++++++
packages/rrweb/test/replay/embedded.test.ts | 157 ++++++++++
8 files changed, 974 insertions(+)
create mode 100644 .changeset/origin-isolated-embedded-replayer.md
create mode 100644 packages/rrweb/src/replay/embedded/client.ts
create mode 100644 packages/rrweb/src/replay/embedded/host-document.ts
create mode 100644 packages/rrweb/src/replay/embedded/host.ts
create mode 100644 packages/rrweb/src/replay/embedded/index.ts
create mode 100644 packages/rrweb/src/replay/embedded/protocol.ts
create mode 100644 packages/rrweb/test/replay/embedded.test.ts
diff --git a/.changeset/origin-isolated-embedded-replayer.md b/.changeset/origin-isolated-embedded-replayer.md
new file mode 100644
index 00000000..481eff0e
--- /dev/null
+++ b/.changeset/origin-isolated-embedded-replayer.md
@@ -0,0 +1,13 @@
+---
+"rrweb": minor
+---
+
+Add origin-isolated embedded replay (SEC-8885). New `EmbeddedReplayerHost` /
+`startEmbeddedReplayerHost` run the `Replayer` inside a sandboxed, cookieless,
+cross-origin iframe; `EmbeddedReplayerClient` drives it from the parent over a
+typed, origin-authenticated `postMessage` bridge that only ever transfers data
+(never functions/DOM, never `eval`). `buildHostDocument` emits the sandbox HTML
+shell with a `` CSP whose `script-src` omits `unsafe-eval`. This relocates
+all untrusted replay-data handling (including the canvas-argument deserializer)
+out of the embedding app's privileged origin. Purely additive; existing
+in-parent `Replayer` usage is unchanged.
diff --git a/packages/rrweb/src/index.ts b/packages/rrweb/src/index.ts
index 7851c6e0..f7a8f865 100644
--- a/packages/rrweb/src/index.ts
+++ b/packages/rrweb/src/index.ts
@@ -22,6 +22,24 @@ import './replay/styles/style.css';
export type { recordOptions, ReplayPlugin } from './types';
+// Origin-isolated replay: run the Replayer inside a cookieless, cross-origin
+// iframe and drive it over postMessage (SEC-8885). See ./replay/embedded.
+export {
+ EmbeddedReplayerHost,
+ startEmbeddedReplayerHost,
+ EmbeddedReplayerClient,
+ buildHostDocument,
+ pickSerializableConfig,
+ RRWEB_EMBEDDED_CHANNEL,
+ RRWEB_EMBEDDED_PROTOCOL_VERSION,
+ type EmbeddedReplayerHostOptions,
+ type EmbeddedReplayerClientOptions,
+ type HostDocumentOptions,
+ type SerializableReplayerConfig,
+ type HostCommand,
+ type HostMessage,
+} from './replay/embedded';
+
const { addCustomEvent } = record;
const { freezePage } = record;
const { takeFullSnapshot } = record;
diff --git a/packages/rrweb/src/replay/embedded/client.ts b/packages/rrweb/src/replay/embedded/client.ts
new file mode 100644
index 00000000..cde30e39
--- /dev/null
+++ b/packages/rrweb/src/replay/embedded/client.ts
@@ -0,0 +1,217 @@
+/**
+ * `EmbeddedReplayerClient` — runs in the PARENT page and drives an
+ * `EmbeddedReplayerHost` living in an isolated iframe, over `postMessage`.
+ *
+ * It mirrors the subset of the `Replayer` API the viewer actually needs
+ * (playback control + an `on(...)` event surface + async getters), so callers
+ * can treat it much like a local `Replayer` without any same-origin access to
+ * the replay document.
+ *
+ * Peer authentication: the client identifies host messages by `event.source`
+ * identity against the iframe's `contentWindow`. A sandboxed opaque-origin
+ * iframe reports `event.origin === "null"`, so origin-string matching is not
+ * usable here — source identity is the reliable check.
+ */
+
+import {
+ isEnvelope,
+ wrap,
+ type HostCommand,
+ type HostMessage,
+ type HostRequestMethod,
+ type PlayerMetaDataLike,
+ type SerializableReplayerConfig,
+} from './protocol';
+import type { eventWithTime } from '@rrweb/types';
+
+export interface EmbeddedReplayerClientOptions {
+ /**
+ * Target origin used when posting to the iframe. For a sandboxed
+ * opaque-origin host this must be "*" (an opaque frame's origin is "null",
+ * which cannot be named as a targetOrigin). Only replay data — never app
+ * secrets — is ever posted, so "*" is acceptable. When the host is served
+ * from a dedicated named origin, pass that origin for a tighter target.
+ */
+ hostOrigin?: string;
+}
+
+type Listener = (payload?: unknown) => void;
+
+export class EmbeddedReplayerClient {
+ private readonly iframe: HTMLIFrameElement;
+ private readonly hostOrigin: string;
+
+ private readonly listeners = new Map>();
+ private readonly pending = new Map<
+ number,
+ { resolve: (v: unknown) => void; reject: (e: Error) => void }
+ >();
+ private nextRequestId = 1;
+
+ private metadata: PlayerMetaDataLike | null = null;
+ private lastCurrentTime = 0;
+ private ready = false;
+ private readyResolvers: Array<() => void> = [];
+ private disposed = false;
+
+ constructor(iframe: HTMLIFrameElement, options: EmbeddedReplayerClientOptions = {}) {
+ this.iframe = iframe;
+ this.hostOrigin = options.hostOrigin ?? '*';
+ window.addEventListener('message', this.onMessage);
+ }
+
+ /** Resolves once the host iframe has loaded and announced readiness. */
+ whenReady(): Promise {
+ if (this.ready) return Promise.resolve();
+ return new Promise((resolve) => this.readyResolvers.push(resolve));
+ }
+
+ /* --- commands --------------------------------------------------------- */
+
+ init(
+ events: eventWithTime[],
+ config?: SerializableReplayerConfig,
+ autoplay = false,
+ ): void {
+ this.send({ type: 'init', events, config, autoplay });
+ }
+
+ play(timeOffset?: number): void {
+ this.send({ type: 'play', timeOffset });
+ }
+
+ pause(timeOffset?: number): void {
+ this.send({ type: 'pause', timeOffset });
+ }
+
+ resume(timeOffset?: number): void {
+ this.send({ type: 'resume', timeOffset });
+ }
+
+ setConfig(config: SerializableReplayerConfig): void {
+ this.send({ type: 'setConfig', config });
+ }
+
+ replaceEvents(events: eventWithTime[]): void {
+ this.send({ type: 'replaceEvents', events });
+ }
+
+ addEvent(event: eventWithTime): void {
+ this.send({ type: 'addEvent', event });
+ }
+
+ enableInteract(): void {
+ this.send({ type: 'enableInteract' });
+ }
+
+ disableInteract(): void {
+ this.send({ type: 'disableInteract' });
+ }
+
+ /** Tear down: destroys the host replayer and detaches the listener. */
+ destroy(): void {
+ if (this.disposed) return;
+ this.disposed = true;
+ try {
+ this.send({ type: 'destroy' });
+ } finally {
+ window.removeEventListener('message', this.onMessage);
+ this.listeners.clear();
+ this.pending.forEach((p) => p.reject(new Error('client destroyed')));
+ this.pending.clear();
+ }
+ }
+
+ /* --- event surface ---------------------------------------------------- */
+
+ on(event: string, listener: Listener): void {
+ let set = this.listeners.get(event);
+ if (!set) {
+ set = new Set();
+ this.listeners.set(event, set);
+ }
+ set.add(listener);
+ }
+
+ off(event: string, listener: Listener): void {
+ this.listeners.get(event)?.delete(listener);
+ }
+
+ /* --- getters ---------------------------------------------------------- */
+
+ /** Best-effort last-known current time (pushed by the host while playing). */
+ getCurrentTime(): number {
+ return this.lastCurrentTime;
+ }
+
+ getMetaData(): Promise {
+ if (this.metadata) return Promise.resolve(this.metadata);
+ return this.request('getMetaData') as Promise;
+ }
+
+ getActivityIntervals(): Promise {
+ return this.request('getActivityIntervals');
+ }
+
+ requestCurrentTime(): Promise {
+ return this.request('getCurrentTime') as Promise;
+ }
+
+ /* --- internals -------------------------------------------------------- */
+
+ private request(method: HostRequestMethod): Promise {
+ const id = this.nextRequestId++;
+ return new Promise((resolve, reject) => {
+ this.pending.set(id, { resolve, reject });
+ this.send({ type: 'request', id, method });
+ });
+ }
+
+ private send(command: HostCommand): void {
+ const target = this.iframe.contentWindow;
+ if (!target) return;
+ target.postMessage(wrap(command), this.hostOrigin);
+ }
+
+ private onMessage = (event: MessageEvent): void => {
+ if (event.source !== this.iframe.contentWindow) return;
+ if (!isEnvelope(event.data)) return;
+ this.handle(event.data.message);
+ };
+
+ private handle(message: HostMessage): void {
+ switch (message.type) {
+ case 'ready':
+ this.ready = true;
+ this.readyResolvers.forEach((r) => r());
+ this.readyResolvers = [];
+ return;
+ case 'initialized':
+ this.metadata = message.metadata;
+ this.emit('initialized', message.metadata);
+ return;
+ case 'replayer-event':
+ this.emit(message.event, message.payload);
+ return;
+ case 'time':
+ this.lastCurrentTime = message.currentTime;
+ this.emit('time', message.currentTime);
+ return;
+ case 'response': {
+ const pending = this.pending.get(message.id);
+ if (!pending) return;
+ this.pending.delete(message.id);
+ if (message.ok) pending.resolve(message.data);
+ else pending.reject(new Error(message.error));
+ return;
+ }
+ case 'error':
+ this.emit('error', message.message);
+ return;
+ }
+ }
+
+ private emit(event: string, payload?: unknown): void {
+ this.listeners.get(event)?.forEach((listener) => listener(payload));
+ }
+}
diff --git a/packages/rrweb/src/replay/embedded/host-document.ts b/packages/rrweb/src/replay/embedded/host-document.ts
new file mode 100644
index 00000000..86a9ae65
--- /dev/null
+++ b/packages/rrweb/src/replay/embedded/host-document.ts
@@ -0,0 +1,83 @@
+/**
+ * Builds the minimal HTML shell that hosts the embedded replayer inside the
+ * sandboxed iframe.
+ *
+ * Intended usage (served-URL mode): serve this document at a static URL and
+ * point an iframe at it with `sandbox="allow-scripts"` (NOTE: no
+ * `allow-same-origin`). The sandbox attribute forces the document into an
+ * opaque origin regardless of the URL it is served from, which is what makes it
+ * cookieless and cross-site to the app — so escaped replay JS has no app
+ * cookies, no same-origin API access, and no reach into the parent window. The
+ * parent origin to trust is passed at runtime via the iframe's `parentOrigin`
+ * query parameter, which the host bootstrap reads.
+ *
+ * The `` CSP below governs the realm the replayer ACTUALLY runs in (unlike
+ * today's policy, which only covered the rebuilt-DOM child). `script-src` omits
+ * `unsafe-eval`, which kills the `new Function(...)` canvas-deserializer path
+ * (SEC-8885) even inside the sandbox — defense in depth on top of the origin
+ * isolation itself.
+ */
+
+export interface HostDocumentOptions {
+ /** Absolute URL of the built host bundle (the module that calls `startEmbeddedReplayerHost`). */
+ scriptUrl: string;
+ /** Optional absolute URL of the rrweb replay stylesheet. */
+ styleUrl?: string;
+ /** Extra values appended to `style-src` (e.g. proxied stylesheet origins). */
+ extraStyleSources?: string[];
+}
+
+function originOf(url: string): string {
+ try {
+ return new URL(url).origin;
+ } catch {
+ return "'self'";
+ }
+}
+
+export function buildHostDocument(options: HostDocumentOptions): string {
+ const scriptOrigin = originOf(options.scriptUrl);
+ const styleSources = [
+ "'unsafe-inline'",
+ scriptOrigin,
+ ...(options.extraStyleSources ?? []),
+ ].join(' ');
+
+ // Strict where it counts (no script beyond our bundle, no eval, no workers,
+ // no outbound connections), permissive for visual assets (fidelity).
+ const csp = [
+ `default-src 'none'`,
+ `script-src ${scriptOrigin}`,
+ `style-src ${styleSources}`,
+ `img-src * data: blob:`,
+ `font-src * data:`,
+ `media-src * data: blob:`,
+ `frame-src *`,
+ `connect-src 'none'`,
+ `worker-src 'none'`,
+ ].join('; ');
+
+ const styleTag = options.styleUrl
+ ? ``
+ : '';
+
+ return `
+
+
+
+
+
+${styleTag}
+
+
+
+`;
+}
+
+function escapeAttr(value: string): string {
+ return value
+ .replace(/&/g, '&')
+ .replace(/"/g, '"')
+ .replace(//g, '>');
+}
diff --git a/packages/rrweb/src/replay/embedded/host.ts b/packages/rrweb/src/replay/embedded/host.ts
new file mode 100644
index 00000000..bfd8b39f
--- /dev/null
+++ b/packages/rrweb/src/replay/embedded/host.ts
@@ -0,0 +1,277 @@
+/**
+ * `EmbeddedReplayerHost` — runs INSIDE the isolated replay iframe.
+ *
+ * It receives the recorded events + a data-only config from the parent over
+ * `postMessage`, constructs a normal rrweb `Replayer` in *this* realm (so all
+ * untrusted-data handling — including canvas-argument deserialization — happens
+ * here, not in the parent's privileged origin), forwards `Replayer` events back
+ * to the parent, and applies playback commands.
+ *
+ * Security invariants (see ./protocol.ts):
+ * - Only messages whose `event.origin` equals the trusted parent origin AND
+ * whose `event.source` is our parent window are acted on.
+ * - Message content is only ever treated as data. Config is passed through
+ * `pickSerializableConfig`, so no function/DOM value from a message reaches
+ * the `Replayer`. Nothing is `eval`'d or constructed by name from a message.
+ */
+
+import { Replayer } from '../';
+import { ReplayerEvents } from '@rrweb/types';
+import type { eventWithTime } from '@rrweb/types';
+import type { playerConfig } from '../../types';
+import {
+ isEnvelope,
+ pickSerializableConfig,
+ wrap,
+ type HostCommand,
+ type HostMessage,
+ type HostRequestMethod,
+} from './protocol';
+
+export interface EmbeddedReplayerHostOptions {
+ /**
+ * The exact origin the parent page is served from (e.g.
+ * "https://app.launchdarkly.com"). Messages from any other origin are
+ * ignored. If omitted, it is read from the `parentOrigin` query parameter of
+ * this iframe's URL; if that is also absent, the host pins the origin of the
+ * first well-formed message it receives and warns.
+ */
+ expectedParentOrigin?: string;
+ /** Element the `Replayer` renders into. Defaults to `document.body`. */
+ root?: HTMLElement;
+ /** Parent window to post back to. Defaults to `window.parent`. */
+ parentWindow?: Window;
+ /** How often (ms) to push current-time telemetry while playing. */
+ timeUpdateIntervalMs?: number;
+}
+
+/** Events whose payload is small, plain data and useful to the parent UI. */
+const PAYLOAD_EVENTS = new Set([
+ ReplayerEvents.Resize,
+ ReplayerEvents.EventCast,
+ ReplayerEvents.CustomEvent,
+]);
+
+export class EmbeddedReplayerHost {
+ private readonly root: HTMLElement;
+ private readonly parentWindow: Window;
+ private readonly timeUpdateIntervalMs: number;
+ private expectedParentOrigin: string | null;
+
+ private replayer: Replayer | null = null;
+ private playing = false;
+ private rafHandle: number | null = null;
+ private started = false;
+
+ constructor(options: EmbeddedReplayerHostOptions = {}) {
+ this.root = options.root ?? document.body;
+ this.parentWindow = options.parentWindow ?? window.parent;
+ this.timeUpdateIntervalMs = options.timeUpdateIntervalMs ?? 50;
+ this.expectedParentOrigin =
+ options.expectedParentOrigin ??
+ new URLSearchParams(location.search).get('parentOrigin') ??
+ null;
+ }
+
+ /** Install the message listener and announce readiness to the parent. */
+ start(): void {
+ if (this.started) return;
+ this.started = true;
+ window.addEventListener('message', this.onMessage);
+ this.post({ type: 'ready' });
+ }
+
+ /** Tear everything down (also invoked on a `destroy` command). */
+ stop(): void {
+ window.removeEventListener('message', this.onMessage);
+ this.stopTimePump();
+ this.replayer?.destroy();
+ this.replayer = null;
+ this.started = false;
+ }
+
+ private onMessage = (event: MessageEvent): void => {
+ // Authenticate the peer before looking at anything else.
+ if (event.source !== this.parentWindow) return;
+ if (!isEnvelope(event.data)) return;
+ if (this.expectedParentOrigin === null) {
+ // No pre-declared parent: trust-on-first-message, then pin.
+ this.expectedParentOrigin = event.origin;
+ console.warn(
+ `[rrweb-embedded] pinning parent origin to "${event.origin}" (no parentOrigin provided)`,
+ );
+ } else if (event.origin !== this.expectedParentOrigin) {
+ return;
+ }
+
+ try {
+ this.handle(event.data.message);
+ } catch (err) {
+ this.post({
+ type: 'error',
+ message: err instanceof Error ? err.message : String(err),
+ });
+ }
+ };
+
+ private handle(command: HostCommand): void {
+ switch (command.type) {
+ case 'init':
+ this.init(command.events, command.config, command.autoplay);
+ return;
+ case 'play':
+ this.replayer?.play(command.timeOffset ?? 0);
+ this.setPlaying(true);
+ return;
+ case 'pause':
+ this.replayer?.pause(command.timeOffset);
+ this.setPlaying(false);
+ return;
+ case 'resume':
+ this.replayer?.resume(command.timeOffset ?? 0);
+ this.setPlaying(true);
+ return;
+ case 'setConfig':
+ this.replayer?.setConfig(pickSerializableConfig(command.config));
+ return;
+ case 'replaceEvents':
+ this.replayer?.replaceEvents(command.events);
+ return;
+ case 'addEvent':
+ this.replayer?.addEvent(command.event);
+ return;
+ case 'enableInteract':
+ this.replayer?.enableInteract();
+ return;
+ case 'disableInteract':
+ this.replayer?.disableInteract();
+ return;
+ case 'destroy':
+ this.stop();
+ return;
+ case 'request':
+ this.reply(command.id, command.method);
+ return;
+ }
+ }
+
+ private init(
+ events: eventWithTime[],
+ config: unknown,
+ autoplay?: boolean,
+ ): void {
+ if (this.replayer) {
+ this.replayer.destroy();
+ this.stopTimePump();
+ }
+
+ // Data-only config from the parent, plus locally-owned, non-transferable
+ // options (the render root and a logger) that never come off the wire.
+ const replayerConfig: Partial = {
+ ...pickSerializableConfig(config),
+ root: this.root,
+ };
+
+ this.replayer = new Replayer(events, replayerConfig);
+ this.wireEvents(this.replayer);
+
+ this.post({ type: 'initialized', metadata: this.replayer.getMetaData() });
+
+ if (autoplay) {
+ this.replayer.play(0);
+ this.setPlaying(true);
+ }
+ }
+
+ private wireEvents(replayer: Replayer): void {
+ // Forward every Replayer event by name; attach a payload only for the
+ // curated, plainly-serializable ones. A DataCloneError (non-cloneable
+ // payload) degrades to a name-only forward rather than dropping the event.
+ for (const name of Object.values(ReplayerEvents)) {
+ replayer.on(name, (payload?: unknown) => {
+ if (name === ReplayerEvents.Finish) this.setPlaying(false);
+ const withPayload =
+ PAYLOAD_EVENTS.has(name) && payload !== undefined
+ ? { type: 'replayer-event' as const, event: name, payload }
+ : { type: 'replayer-event' as const, event: name };
+ try {
+ this.post(withPayload);
+ } catch {
+ this.post({ type: 'replayer-event', event: name });
+ }
+ });
+ }
+ }
+
+ private reply(id: number, method: HostRequestMethod): void {
+ if (!this.replayer) {
+ this.post({ type: 'response', id, ok: false, error: 'no replayer' });
+ return;
+ }
+ try {
+ let data: unknown;
+ if (method === 'getMetaData') data = this.replayer.getMetaData();
+ else if (method === 'getCurrentTime') data = this.replayer.getCurrentTime();
+ else data = this.replayer.getActivityIntervals();
+ this.post({ type: 'response', id, ok: true, data });
+ } catch (err) {
+ this.post({
+ type: 'response',
+ id,
+ ok: false,
+ error: err instanceof Error ? err.message : String(err),
+ });
+ }
+ }
+
+ /* --- current-time telemetry ------------------------------------------- */
+
+ private setPlaying(playing: boolean): void {
+ if (this.playing === playing) return;
+ this.playing = playing;
+ if (playing) this.startTimePump();
+ else this.stopTimePump();
+ }
+
+ private startTimePump(): void {
+ if (this.rafHandle !== null) return;
+ let last = 0;
+ const tick = (now: number): void => {
+ if (!this.playing || !this.replayer) return;
+ if (now - last >= this.timeUpdateIntervalMs) {
+ last = now;
+ this.post({ type: 'time', currentTime: this.replayer.getCurrentTime() });
+ }
+ this.rafHandle = requestAnimationFrame(tick);
+ };
+ this.rafHandle = requestAnimationFrame(tick);
+ }
+
+ private stopTimePump(): void {
+ if (this.rafHandle !== null) {
+ cancelAnimationFrame(this.rafHandle);
+ this.rafHandle = null;
+ }
+ }
+
+ private post(message: HostMessage): void {
+ // Target the known parent origin when we have it; fall back to "*" only
+ // before the origin is pinned (the `ready` handshake). We never send app
+ // secrets over this channel — only replay telemetry — so "*" is acceptable
+ // for that single pre-pinning message.
+ this.parentWindow.postMessage(wrap(message), this.expectedParentOrigin ?? '*');
+ }
+}
+
+/**
+ * Convenience bootstrap for the iframe entry module: construct a host and start
+ * it. The host reads its trusted parent origin from the `parentOrigin` query
+ * parameter unless one is passed explicitly.
+ */
+export function startEmbeddedReplayerHost(
+ options?: EmbeddedReplayerHostOptions,
+): EmbeddedReplayerHost {
+ const host = new EmbeddedReplayerHost(options);
+ host.start();
+ return host;
+}
diff --git a/packages/rrweb/src/replay/embedded/index.ts b/packages/rrweb/src/replay/embedded/index.ts
new file mode 100644
index 00000000..49818d44
--- /dev/null
+++ b/packages/rrweb/src/replay/embedded/index.ts
@@ -0,0 +1,35 @@
+/**
+ * Origin-isolated replay: run the rrweb `Replayer` inside a cookieless,
+ * cross-origin (sandboxed opaque-origin) iframe and drive it from the parent
+ * over `postMessage`. See ./protocol.ts for the rationale (SEC-8885).
+ *
+ * - `EmbeddedReplayerHost` / `startEmbeddedReplayerHost`: run inside the iframe.
+ * - `EmbeddedReplayerClient`: run in the parent to control the host.
+ * - `buildHostDocument`: HTML shell for the sandboxed iframe.
+ */
+
+export {
+ EmbeddedReplayerHost,
+ startEmbeddedReplayerHost,
+ type EmbeddedReplayerHostOptions,
+} from './host';
+export {
+ EmbeddedReplayerClient,
+ type EmbeddedReplayerClientOptions,
+} from './client';
+export { buildHostDocument, type HostDocumentOptions } from './host-document';
+export {
+ RRWEB_EMBEDDED_CHANNEL,
+ RRWEB_EMBEDDED_PROTOCOL_VERSION,
+ SERIALIZABLE_CONFIG_KEYS,
+ pickSerializableConfig,
+ isEnvelope,
+ wrap,
+ type SerializableReplayerConfig,
+ type SerializableConfigKey,
+ type HostCommand,
+ type HostMessage,
+ type HostRequestMethod,
+ type PlayerMetaDataLike,
+ type Envelope,
+} from './protocol';
diff --git a/packages/rrweb/src/replay/embedded/protocol.ts b/packages/rrweb/src/replay/embedded/protocol.ts
new file mode 100644
index 00000000..cf5d6092
--- /dev/null
+++ b/packages/rrweb/src/replay/embedded/protocol.ts
@@ -0,0 +1,174 @@
+/**
+ * Wire protocol for running the rrweb `Replayer` inside an isolated iframe and
+ * driving it from a parent page over `postMessage`.
+ *
+ * Motivation (SEC-8885): today the `Replayer` runs in the host page and rebuilds
+ * the recorded DOM downward into a child iframe. Canvas replay reconstructs
+ * command arguments via `new window[rr_type](...)` in that host realm, so a
+ * poisoned `Promise(Function("code"))` executes as first-party JS on the app
+ * origin. Relocating the `Replayer` *into* a cookieless, cross-origin iframe
+ * moves every bit of untrusted-data handling out of the privileged origin: even
+ * if attacker JS runs, it runs somewhere with no app cookies, no same-origin API
+ * access, and no reach into the parent window.
+ *
+ * This module defines the messages exchanged across that boundary. Two rules are
+ * load-bearing and MUST hold for the isolation to be worth anything:
+ *
+ * 1. Every payload is plain, structured-cloneable DATA. Neither side ever sends
+ * a function, and neither side ever `eval`s / constructs anything from a
+ * message. The host constructs a `Replayer` from event data; it never turns
+ * message content into code.
+ * 2. Both sides authenticate their peer before acting on a message. The host
+ * checks `event.origin` against the parent origin it was told to trust; the
+ * client checks `event.source` identity against its iframe's `contentWindow`
+ * (a sandboxed opaque-origin iframe reports `event.origin === "null"`, so
+ * origin-string matching is not usable on the client side).
+ */
+
+import type { playerConfig } from '../../types';
+import type { eventWithTime } from '@rrweb/types';
+
+/** Namespaces our traffic so unrelated `postMessage` chatter is ignored. */
+export const RRWEB_EMBEDDED_CHANNEL = 'rrweb-embedded' as const;
+
+/** Bump on any breaking change to the message shapes below. */
+export const RRWEB_EMBEDDED_PROTOCOL_VERSION = 1 as const;
+
+/**
+ * The subset of `playerConfig` that is safe to send across the boundary: plain
+ * data only. `root` (a DOM node), `unpackFn`/`logger` (functions) and `plugins`
+ * (functions) are deliberately excluded — the host supplies those locally and
+ * never accepts them from a message. Keep this list in sync with the type below.
+ */
+export const SERIALIZABLE_CONFIG_KEYS = [
+ 'speed',
+ 'maxSpeed',
+ 'loadTimeout',
+ 'skipInactive',
+ 'inactivePeriodThreshold',
+ 'showWarning',
+ 'showDebug',
+ 'blockClass',
+ 'liveMode',
+ 'insertStyleRules',
+ 'triggerFocus',
+ 'UNSAFE_replayCanvas',
+ 'cspContent',
+ 'pauseAnimation',
+ 'mouseTail',
+ 'useVirtualDom',
+ 'inactiveThreshold',
+ 'inactiveSkipTime',
+] as const;
+
+export type SerializableConfigKey = (typeof SERIALIZABLE_CONFIG_KEYS)[number];
+
+/** Data-only config the parent may hand the host. */
+export type SerializableReplayerConfig = Partial<
+ Pick
+>;
+
+/**
+ * Copy only allowlisted, data-only keys off an untrusted config-shaped value.
+ * Anything else (including functions and DOM nodes) is dropped.
+ */
+export function pickSerializableConfig(
+ config: unknown,
+): SerializableReplayerConfig {
+ const out: Record = {};
+ if (config && typeof config === 'object') {
+ for (const key of SERIALIZABLE_CONFIG_KEYS) {
+ const value = (config as Record)[key];
+ if (value !== undefined && typeof value !== 'function') {
+ out[key] = value;
+ }
+ }
+ }
+ return out as SerializableReplayerConfig;
+}
+
+/* -------------------------------------------------------------------------- */
+/* Parent -> Host (commands) */
+/* -------------------------------------------------------------------------- */
+
+export type HostCommand =
+ | { type: 'init'; events: eventWithTime[]; config?: SerializableReplayerConfig; autoplay?: boolean }
+ | { type: 'play'; timeOffset?: number }
+ | { type: 'pause'; timeOffset?: number }
+ | { type: 'resume'; timeOffset?: number }
+ | { type: 'setConfig'; config: SerializableReplayerConfig }
+ | { type: 'replaceEvents'; events: eventWithTime[] }
+ | { type: 'addEvent'; event: eventWithTime }
+ | { type: 'enableInteract' }
+ | { type: 'disableInteract' }
+ | { type: 'destroy' }
+ | { type: 'request'; id: number; method: HostRequestMethod };
+
+/** Getter RPCs the parent can await a reply to. */
+export type HostRequestMethod =
+ | 'getMetaData'
+ | 'getCurrentTime'
+ | 'getActivityIntervals';
+
+/* -------------------------------------------------------------------------- */
+/* Host -> Parent (events / telemetry / replies) */
+/* -------------------------------------------------------------------------- */
+
+export type HostMessage =
+ /** Host script booted and the message listener is installed (pre-init). */
+ | { type: 'ready' }
+ /** `Replayer` constructed; carries its (serializable) metadata. */
+ | { type: 'initialized'; metadata: PlayerMetaDataLike }
+ /** A forwarded `Replayer` event (`replayer.on(...)`). Payload is optional and always plain data. */
+ | { type: 'replayer-event'; event: string; payload?: SerializablePayload }
+ /** Periodic current-time push while playing, so the parent scrubber can track without sync getters. */
+ | { type: 'time'; currentTime: number }
+ /** Reply to a `request` command. */
+ | { type: 'response'; id: number; ok: true; data: SerializablePayload }
+ | { type: 'response'; id: number; ok: false; error: string }
+ /** Host-side failure surfaced for logging. */
+ | { type: 'error'; message: string };
+
+export type PlayerMetaDataLike = {
+ startTime: number;
+ endTime: number;
+ totalTime: number;
+};
+
+/** Anything that survives structured clone; kept loose on purpose. */
+export type SerializablePayload = unknown;
+
+/* -------------------------------------------------------------------------- */
+/* Envelope + guards */
+/* -------------------------------------------------------------------------- */
+
+export interface Envelope {
+ channel: typeof RRWEB_EMBEDDED_CHANNEL;
+ version: typeof RRWEB_EMBEDDED_PROTOCOL_VERSION;
+ message: T;
+}
+
+export function wrap(message: T): Envelope {
+ return {
+ channel: RRWEB_EMBEDDED_CHANNEL,
+ version: RRWEB_EMBEDDED_PROTOCOL_VERSION,
+ message,
+ };
+}
+
+/**
+ * Validate that an arbitrary `postMessage` datum is one of our envelopes on the
+ * expected protocol version. Does not trust the sender — callers must still
+ * authenticate origin/source before acting.
+ */
+export function isEnvelope(data: unknown): data is Envelope {
+ if (!data || typeof data !== 'object') return false;
+ const e = data as Partial>;
+ return (
+ e.channel === RRWEB_EMBEDDED_CHANNEL &&
+ e.version === RRWEB_EMBEDDED_PROTOCOL_VERSION &&
+ !!e.message &&
+ typeof e.message === 'object' &&
+ typeof (e.message as { type?: unknown }).type === 'string'
+ );
+}
diff --git a/packages/rrweb/test/replay/embedded.test.ts b/packages/rrweb/test/replay/embedded.test.ts
new file mode 100644
index 00000000..83629ab0
--- /dev/null
+++ b/packages/rrweb/test/replay/embedded.test.ts
@@ -0,0 +1,157 @@
+// @vitest-environment jsdom
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import {
+ isEnvelope,
+ pickSerializableConfig,
+ wrap,
+ RRWEB_EMBEDDED_CHANNEL,
+ RRWEB_EMBEDDED_PROTOCOL_VERSION,
+ type HostMessage,
+} from '../../src/replay/embedded/protocol';
+import { EmbeddedReplayerClient } from '../../src/replay/embedded/client';
+
+describe('embedded replay protocol', () => {
+ describe('isEnvelope', () => {
+ it('accepts a well-formed envelope', () => {
+ expect(isEnvelope(wrap({ type: 'ready' }))).toBe(true);
+ });
+
+ it('rejects wrong channel / version / shape', () => {
+ expect(isEnvelope(null)).toBe(false);
+ expect(isEnvelope({})).toBe(false);
+ expect(
+ isEnvelope({ channel: 'other', version: 1, message: { type: 'ready' } }),
+ ).toBe(false);
+ expect(
+ isEnvelope({
+ channel: RRWEB_EMBEDDED_CHANNEL,
+ version: 999,
+ message: { type: 'ready' },
+ }),
+ ).toBe(false);
+ // message without a string `type`
+ expect(
+ isEnvelope({
+ channel: RRWEB_EMBEDDED_CHANNEL,
+ version: RRWEB_EMBEDDED_PROTOCOL_VERSION,
+ message: {},
+ }),
+ ).toBe(false);
+ });
+ });
+
+ describe('pickSerializableConfig', () => {
+ it('keeps allowlisted data keys', () => {
+ const out = pickSerializableConfig({
+ speed: 2,
+ skipInactive: true,
+ UNSAFE_replayCanvas: false,
+ cspContent: "script-src 'none'",
+ });
+ expect(out).toEqual({
+ speed: 2,
+ skipInactive: true,
+ UNSAFE_replayCanvas: false,
+ cspContent: "script-src 'none'",
+ });
+ });
+
+ it('drops functions, DOM-ish values, and unknown keys', () => {
+ const out = pickSerializableConfig({
+ speed: 1,
+ root: { nodeType: 1 }, // not allowlisted -> dropped
+ unpackFn: () => 'x', // function -> dropped
+ logger: { log: () => undefined }, // not allowlisted -> dropped
+ plugins: [() => undefined], // not allowlisted -> dropped
+ somethingElse: 'nope', // unknown -> dropped
+ });
+ expect(out).toEqual({ speed: 1 });
+ expect('root' in out).toBe(false);
+ expect('unpackFn' in out).toBe(false);
+ });
+
+ it('returns {} for non-objects', () => {
+ expect(pickSerializableConfig(undefined)).toEqual({});
+ expect(pickSerializableConfig('nope')).toEqual({});
+ });
+ });
+});
+
+describe('EmbeddedReplayerClient', () => {
+ afterEach(() => vi.restoreAllMocks());
+
+ // iframe stand-in whose contentWindow is the real window, so the client's
+ // `event.source === iframe.contentWindow` check passes for our synthetic events.
+ function makeClient() {
+ const iframe = { contentWindow: window } as unknown as HTMLIFrameElement;
+ const posted: unknown[] = [];
+ vi.spyOn(window, 'postMessage').mockImplementation((msg: unknown) => {
+ posted.push(msg);
+ });
+ const client = new EmbeddedReplayerClient(iframe, { hostOrigin: '*' });
+ return { client, posted };
+ }
+
+ function deliver(message: HostMessage, source: unknown = window) {
+ const event = new MessageEvent('message', { data: wrap(message) });
+ Object.defineProperty(event, 'source', { value: source });
+ window.dispatchEvent(event);
+ }
+
+ it('posts namespaced command envelopes', () => {
+ const { client, posted } = makeClient();
+ client.play(1234);
+ expect(posted).toHaveLength(1);
+ expect(posted[0]).toMatchObject({
+ channel: RRWEB_EMBEDDED_CHANNEL,
+ version: RRWEB_EMBEDDED_PROTOCOL_VERSION,
+ message: { type: 'play', timeOffset: 1234 },
+ });
+ });
+
+ it('resolves whenReady on the ready handshake', async () => {
+ const { client } = makeClient();
+ let resolved = false;
+ const ready = client.whenReady().then(() => (resolved = true));
+ expect(resolved).toBe(false);
+ deliver({ type: 'ready' });
+ await ready;
+ expect(resolved).toBe(true);
+ });
+
+ it('forwards replayer events and time updates to on() listeners', () => {
+ const { client } = makeClient();
+ const onResize = vi.fn();
+ const onTime = vi.fn();
+ client.on('resize', onResize);
+ client.on('time', onTime);
+
+ deliver({
+ type: 'replayer-event',
+ event: 'resize',
+ payload: { width: 800, height: 600 },
+ });
+ deliver({ type: 'time', currentTime: 4200 });
+
+ expect(onResize).toHaveBeenCalledWith({ width: 800, height: 600 });
+ expect(onTime).toHaveBeenCalledWith(4200);
+ expect(client.getCurrentTime()).toBe(4200);
+ });
+
+ it('resolves RPC responses by id', async () => {
+ const { client } = makeClient();
+ const p = client.getActivityIntervals();
+ deliver({ type: 'response', id: 1, ok: true, data: [{ start: 0 }] });
+ await expect(p).resolves.toEqual([{ start: 0 }]);
+ });
+
+ it('ignores messages from an untrusted source', () => {
+ const { client } = makeClient();
+ const onTime = vi.fn();
+ client.on('time', onTime);
+ // A message whose source is NOT the iframe's contentWindow must be ignored.
+ deliver({ type: 'time', currentTime: 999 }, { fake: true });
+ expect(onTime).not.toHaveBeenCalled();
+ expect(client.getCurrentTime()).toBe(0);
+ });
+});
From a2287ac073ebf09d7de438e34f0b4d39d14f6512 Mon Sep 17 00:00:00 2001
From: mayberryzane <86132398+mayberryzane@users.noreply.github.com>
Date: Fri, 24 Jul 2026 16:06:08 -0700
Subject: [PATCH 02/11] refactor(replay): simplify embedded bridge (review
feedback)
- Drop the request/response RPC layer. The viewer's stable getters
(getMetaData, getActivityIntervals) now ride the `initialized` message
(re-sent on replaceEvents) and current time streams via `time`, so the
client's getters are synchronous cached reads. Removes pending-map/request-id
bookkeeping on the client and reply() on the host.
- Replace the rAF + manual-throttle time pump with a plain setInterval.
- Reuse the package's playerMetaData / SessionInterval types instead of the
parallel PlayerMetaDataLike; drop the SerializablePayload alias.
- Require a configured parentOrigin (option or query param) instead of the
trust-on-first-message fallback; host ignores messages when none is set.
Command surface (resume/addEvent/enable/disableInteract) intentionally kept for
now; it'll be trimmed to actual callers when the gonfalon integration lands.
Typecheck + lint clean; 10 unit tests pass.
Co-Authored-By: Claude Opus 4.8
---
packages/rrweb/src/replay/embedded/client.ts | 56 +++-------
packages/rrweb/src/replay/embedded/host.ts | 103 +++++++-----------
packages/rrweb/src/replay/embedded/index.ts | 2 -
.../rrweb/src/replay/embedded/protocol.ts | 41 +++----
packages/rrweb/test/replay/embedded.test.ts | 22 +++-
5 files changed, 92 insertions(+), 132 deletions(-)
diff --git a/packages/rrweb/src/replay/embedded/client.ts b/packages/rrweb/src/replay/embedded/client.ts
index cde30e39..1639e2c9 100644
--- a/packages/rrweb/src/replay/embedded/client.ts
+++ b/packages/rrweb/src/replay/embedded/client.ts
@@ -3,7 +3,7 @@
* `EmbeddedReplayerHost` living in an isolated iframe, over `postMessage`.
*
* It mirrors the subset of the `Replayer` API the viewer actually needs
- * (playback control + an `on(...)` event surface + async getters), so callers
+ * (playback control + an `on(...)` event surface + cached getters), so callers
* can treat it much like a local `Replayer` without any same-origin access to
* the replay document.
*
@@ -18,11 +18,13 @@ import {
wrap,
type HostCommand,
type HostMessage,
- type HostRequestMethod,
- type PlayerMetaDataLike,
type SerializableReplayerConfig,
} from './protocol';
-import type { eventWithTime } from '@rrweb/types';
+import type {
+ eventWithTime,
+ playerMetaData,
+ SessionInterval,
+} from '@rrweb/types';
export interface EmbeddedReplayerClientOptions {
/**
@@ -42,13 +44,9 @@ export class EmbeddedReplayerClient {
private readonly hostOrigin: string;
private readonly listeners = new Map>();
- private readonly pending = new Map<
- number,
- { resolve: (v: unknown) => void; reject: (e: Error) => void }
- >();
- private nextRequestId = 1;
- private metadata: PlayerMetaDataLike | null = null;
+ private metadata: playerMetaData | null = null;
+ private activityIntervals: SessionInterval[] = [];
private lastCurrentTime = 0;
private ready = false;
private readyResolvers: Array<() => void> = [];
@@ -117,8 +115,6 @@ export class EmbeddedReplayerClient {
} finally {
window.removeEventListener('message', this.onMessage);
this.listeners.clear();
- this.pending.forEach((p) => p.reject(new Error('client destroyed')));
- this.pending.clear();
}
}
@@ -139,34 +135,25 @@ export class EmbeddedReplayerClient {
/* --- getters ---------------------------------------------------------- */
- /** Best-effort last-known current time (pushed by the host while playing). */
+ // Cached getters mirroring the local Replayer's synchronous ones. Metadata
+ // and activity intervals arrive on `initialized` (re-sent on replaceEvents);
+ // current time is streamed by the host while playing.
+
+ /** Last-known current time (pushed by the host while playing). */
getCurrentTime(): number {
return this.lastCurrentTime;
}
- getMetaData(): Promise {
- if (this.metadata) return Promise.resolve(this.metadata);
- return this.request('getMetaData') as Promise;
- }
-
- getActivityIntervals(): Promise {
- return this.request('getActivityIntervals');
+ getMetaData(): playerMetaData | null {
+ return this.metadata;
}
- requestCurrentTime(): Promise {
- return this.request('getCurrentTime') as Promise;
+ getActivityIntervals(): SessionInterval[] {
+ return this.activityIntervals;
}
/* --- internals -------------------------------------------------------- */
- private request(method: HostRequestMethod): Promise {
- const id = this.nextRequestId++;
- return new Promise((resolve, reject) => {
- this.pending.set(id, { resolve, reject });
- this.send({ type: 'request', id, method });
- });
- }
-
private send(command: HostCommand): void {
const target = this.iframe.contentWindow;
if (!target) return;
@@ -188,6 +175,7 @@ export class EmbeddedReplayerClient {
return;
case 'initialized':
this.metadata = message.metadata;
+ this.activityIntervals = message.activityIntervals;
this.emit('initialized', message.metadata);
return;
case 'replayer-event':
@@ -197,14 +185,6 @@ export class EmbeddedReplayerClient {
this.lastCurrentTime = message.currentTime;
this.emit('time', message.currentTime);
return;
- case 'response': {
- const pending = this.pending.get(message.id);
- if (!pending) return;
- this.pending.delete(message.id);
- if (message.ok) pending.resolve(message.data);
- else pending.reject(new Error(message.error));
- return;
- }
case 'error':
this.emit('error', message.message);
return;
diff --git a/packages/rrweb/src/replay/embedded/host.ts b/packages/rrweb/src/replay/embedded/host.ts
index bfd8b39f..58938718 100644
--- a/packages/rrweb/src/replay/embedded/host.ts
+++ b/packages/rrweb/src/replay/embedded/host.ts
@@ -25,7 +25,6 @@ import {
wrap,
type HostCommand,
type HostMessage,
- type HostRequestMethod,
} from './protocol';
export interface EmbeddedReplayerHostOptions {
@@ -33,8 +32,8 @@ export interface EmbeddedReplayerHostOptions {
* The exact origin the parent page is served from (e.g.
* "https://app.launchdarkly.com"). Messages from any other origin are
* ignored. If omitted, it is read from the `parentOrigin` query parameter of
- * this iframe's URL; if that is also absent, the host pins the origin of the
- * first well-formed message it receives and warns.
+ * this iframe's URL; if that is also absent, the host ignores all messages
+ * (and warns once on start).
*/
expectedParentOrigin?: string;
/** Element the `Replayer` renders into. Defaults to `document.body`. */
@@ -56,11 +55,11 @@ export class EmbeddedReplayerHost {
private readonly root: HTMLElement;
private readonly parentWindow: Window;
private readonly timeUpdateIntervalMs: number;
- private expectedParentOrigin: string | null;
+ private readonly expectedParentOrigin: string | null;
private replayer: Replayer | null = null;
private playing = false;
- private rafHandle: number | null = null;
+ private timer: ReturnType | null = null;
private started = false;
constructor(options: EmbeddedReplayerHostOptions = {}) {
@@ -77,6 +76,11 @@ export class EmbeddedReplayerHost {
start(): void {
if (this.started) return;
this.started = true;
+ if (this.expectedParentOrigin === null) {
+ console.warn(
+ '[rrweb-embedded] no parentOrigin configured; all incoming messages will be ignored',
+ );
+ }
window.addEventListener('message', this.onMessage);
this.post({ type: 'ready' });
}
@@ -91,18 +95,11 @@ export class EmbeddedReplayerHost {
}
private onMessage = (event: MessageEvent): void => {
- // Authenticate the peer before looking at anything else.
+ // Authenticate the peer before acting on anything.
if (event.source !== this.parentWindow) return;
+ if (this.expectedParentOrigin === null) return;
+ if (event.origin !== this.expectedParentOrigin) return;
if (!isEnvelope(event.data)) return;
- if (this.expectedParentOrigin === null) {
- // No pre-declared parent: trust-on-first-message, then pin.
- this.expectedParentOrigin = event.origin;
- console.warn(
- `[rrweb-embedded] pinning parent origin to "${event.origin}" (no parentOrigin provided)`,
- );
- } else if (event.origin !== this.expectedParentOrigin) {
- return;
- }
try {
this.handle(event.data.message);
@@ -135,7 +132,10 @@ export class EmbeddedReplayerHost {
this.replayer?.setConfig(pickSerializableConfig(command.config));
return;
case 'replaceEvents':
- this.replayer?.replaceEvents(command.events);
+ if (this.replayer) {
+ this.replayer.replaceEvents(command.events);
+ this.postInitialized(); // metadata/intervals may have changed
+ }
return;
case 'addEvent':
this.replayer?.addEvent(command.event);
@@ -149,9 +149,6 @@ export class EmbeddedReplayerHost {
case 'destroy':
this.stop();
return;
- case 'request':
- this.reply(command.id, command.method);
- return;
}
}
@@ -174,8 +171,7 @@ export class EmbeddedReplayerHost {
this.replayer = new Replayer(events, replayerConfig);
this.wireEvents(this.replayer);
-
- this.post({ type: 'initialized', metadata: this.replayer.getMetaData() });
+ this.postInitialized();
if (autoplay) {
this.replayer.play(0);
@@ -183,43 +179,27 @@ export class EmbeddedReplayerHost {
}
}
+ /** Push the stable getters (metadata, activity intervals) to the parent. */
+ private postInitialized(): void {
+ if (!this.replayer) return;
+ this.post({
+ type: 'initialized',
+ metadata: this.replayer.getMetaData(),
+ activityIntervals: this.replayer.getActivityIntervals(),
+ });
+ }
+
private wireEvents(replayer: Replayer): void {
// Forward every Replayer event by name; attach a payload only for the
- // curated, plainly-serializable ones. A DataCloneError (non-cloneable
- // payload) degrades to a name-only forward rather than dropping the event.
+ // curated events whose payloads are plain, structured-cloneable data.
for (const name of Object.values(ReplayerEvents)) {
replayer.on(name, (payload?: unknown) => {
if (name === ReplayerEvents.Finish) this.setPlaying(false);
- const withPayload =
+ this.post(
PAYLOAD_EVENTS.has(name) && payload !== undefined
- ? { type: 'replayer-event' as const, event: name, payload }
- : { type: 'replayer-event' as const, event: name };
- try {
- this.post(withPayload);
- } catch {
- this.post({ type: 'replayer-event', event: name });
- }
- });
- }
- }
-
- private reply(id: number, method: HostRequestMethod): void {
- if (!this.replayer) {
- this.post({ type: 'response', id, ok: false, error: 'no replayer' });
- return;
- }
- try {
- let data: unknown;
- if (method === 'getMetaData') data = this.replayer.getMetaData();
- else if (method === 'getCurrentTime') data = this.replayer.getCurrentTime();
- else data = this.replayer.getActivityIntervals();
- this.post({ type: 'response', id, ok: true, data });
- } catch (err) {
- this.post({
- type: 'response',
- id,
- ok: false,
- error: err instanceof Error ? err.message : String(err),
+ ? { type: 'replayer-event', event: name, payload }
+ : { type: 'replayer-event', event: name },
+ );
});
}
}
@@ -234,23 +214,18 @@ export class EmbeddedReplayerHost {
}
private startTimePump(): void {
- if (this.rafHandle !== null) return;
- let last = 0;
- const tick = (now: number): void => {
- if (!this.playing || !this.replayer) return;
- if (now - last >= this.timeUpdateIntervalMs) {
- last = now;
+ if (this.timer !== null) return;
+ this.timer = setInterval(() => {
+ if (this.replayer) {
this.post({ type: 'time', currentTime: this.replayer.getCurrentTime() });
}
- this.rafHandle = requestAnimationFrame(tick);
- };
- this.rafHandle = requestAnimationFrame(tick);
+ }, this.timeUpdateIntervalMs);
}
private stopTimePump(): void {
- if (this.rafHandle !== null) {
- cancelAnimationFrame(this.rafHandle);
- this.rafHandle = null;
+ if (this.timer !== null) {
+ clearInterval(this.timer);
+ this.timer = null;
}
}
diff --git a/packages/rrweb/src/replay/embedded/index.ts b/packages/rrweb/src/replay/embedded/index.ts
index 49818d44..d33562fd 100644
--- a/packages/rrweb/src/replay/embedded/index.ts
+++ b/packages/rrweb/src/replay/embedded/index.ts
@@ -29,7 +29,5 @@ export {
type SerializableConfigKey,
type HostCommand,
type HostMessage,
- type HostRequestMethod,
- type PlayerMetaDataLike,
type Envelope,
} from './protocol';
diff --git a/packages/rrweb/src/replay/embedded/protocol.ts b/packages/rrweb/src/replay/embedded/protocol.ts
index cf5d6092..7240bf37 100644
--- a/packages/rrweb/src/replay/embedded/protocol.ts
+++ b/packages/rrweb/src/replay/embedded/protocol.ts
@@ -26,7 +26,11 @@
*/
import type { playerConfig } from '../../types';
-import type { eventWithTime } from '@rrweb/types';
+import type {
+ eventWithTime,
+ playerMetaData,
+ SessionInterval,
+} from '@rrweb/types';
/** Namespaces our traffic so unrelated `postMessage` chatter is ignored. */
export const RRWEB_EMBEDDED_CHANNEL = 'rrweb-embedded' as const;
@@ -101,14 +105,7 @@ export type HostCommand =
| { type: 'addEvent'; event: eventWithTime }
| { type: 'enableInteract' }
| { type: 'disableInteract' }
- | { type: 'destroy' }
- | { type: 'request'; id: number; method: HostRequestMethod };
-
-/** Getter RPCs the parent can await a reply to. */
-export type HostRequestMethod =
- | 'getMetaData'
- | 'getCurrentTime'
- | 'getActivityIntervals';
+ | { type: 'destroy' };
/* -------------------------------------------------------------------------- */
/* Host -> Parent (events / telemetry / replies) */
@@ -117,27 +114,23 @@ export type HostRequestMethod =
export type HostMessage =
/** Host script booted and the message listener is installed (pre-init). */
| { type: 'ready' }
- /** `Replayer` constructed; carries its (serializable) metadata. */
- | { type: 'initialized'; metadata: PlayerMetaDataLike }
+ /**
+ * `Replayer` constructed (or its events replaced). Carries the stable getters
+ * the viewer needs — metadata and activity intervals — so the parent reads
+ * them from cache instead of pulling them synchronously across the boundary.
+ */
+ | {
+ type: 'initialized';
+ metadata: playerMetaData;
+ activityIntervals: SessionInterval[];
+ }
/** A forwarded `Replayer` event (`replayer.on(...)`). Payload is optional and always plain data. */
- | { type: 'replayer-event'; event: string; payload?: SerializablePayload }
+ | { type: 'replayer-event'; event: string; payload?: unknown }
/** Periodic current-time push while playing, so the parent scrubber can track without sync getters. */
| { type: 'time'; currentTime: number }
- /** Reply to a `request` command. */
- | { type: 'response'; id: number; ok: true; data: SerializablePayload }
- | { type: 'response'; id: number; ok: false; error: string }
/** Host-side failure surfaced for logging. */
| { type: 'error'; message: string };
-export type PlayerMetaDataLike = {
- startTime: number;
- endTime: number;
- totalTime: number;
-};
-
-/** Anything that survives structured clone; kept loose on purpose. */
-export type SerializablePayload = unknown;
-
/* -------------------------------------------------------------------------- */
/* Envelope + guards */
/* -------------------------------------------------------------------------- */
diff --git a/packages/rrweb/test/replay/embedded.test.ts b/packages/rrweb/test/replay/embedded.test.ts
index 83629ab0..4d9b08f4 100644
--- a/packages/rrweb/test/replay/embedded.test.ts
+++ b/packages/rrweb/test/replay/embedded.test.ts
@@ -138,11 +138,25 @@ describe('EmbeddedReplayerClient', () => {
expect(client.getCurrentTime()).toBe(4200);
});
- it('resolves RPC responses by id', async () => {
+ it('caches metadata and activity intervals from the initialized message', () => {
const { client } = makeClient();
- const p = client.getActivityIntervals();
- deliver({ type: 'response', id: 1, ok: true, data: [{ start: 0 }] });
- await expect(p).resolves.toEqual([{ start: 0 }]);
+ expect(client.getMetaData()).toBeNull();
+ expect(client.getActivityIntervals()).toEqual([]);
+
+ deliver({
+ type: 'initialized',
+ metadata: { startTime: 0, endTime: 5000, totalTime: 5000 },
+ activityIntervals: [
+ { startTime: 0, endTime: 1000, duration: 1000, active: true },
+ ],
+ });
+
+ expect(client.getMetaData()).toEqual({
+ startTime: 0,
+ endTime: 5000,
+ totalTime: 5000,
+ });
+ expect(client.getActivityIntervals()).toHaveLength(1);
});
it('ignores messages from an untrusted source', () => {
From d72e43f3fb056927bfd9c4f690a36e6eacd58233 Mon Sep 17 00:00:00 2001
From: mayberryzane <86132398+mayberryzane@users.noreply.github.com>
Date: Fri, 24 Jul 2026 16:34:48 -0700
Subject: [PATCH 03/11] style: prettier-format embedded bridge files
Co-Authored-By: Claude Opus 4.8
---
packages/rrweb/src/replay/embedded/client.ts | 5 ++++-
packages/rrweb/src/replay/embedded/host.ts | 10 ++++++++--
packages/rrweb/src/replay/embedded/protocol.ts | 7 ++++++-
packages/rrweb/test/replay/embedded.test.ts | 6 +++++-
4 files changed, 23 insertions(+), 5 deletions(-)
diff --git a/packages/rrweb/src/replay/embedded/client.ts b/packages/rrweb/src/replay/embedded/client.ts
index 1639e2c9..ca660387 100644
--- a/packages/rrweb/src/replay/embedded/client.ts
+++ b/packages/rrweb/src/replay/embedded/client.ts
@@ -52,7 +52,10 @@ export class EmbeddedReplayerClient {
private readyResolvers: Array<() => void> = [];
private disposed = false;
- constructor(iframe: HTMLIFrameElement, options: EmbeddedReplayerClientOptions = {}) {
+ constructor(
+ iframe: HTMLIFrameElement,
+ options: EmbeddedReplayerClientOptions = {},
+ ) {
this.iframe = iframe;
this.hostOrigin = options.hostOrigin ?? '*';
window.addEventListener('message', this.onMessage);
diff --git a/packages/rrweb/src/replay/embedded/host.ts b/packages/rrweb/src/replay/embedded/host.ts
index 58938718..a5b62e0f 100644
--- a/packages/rrweb/src/replay/embedded/host.ts
+++ b/packages/rrweb/src/replay/embedded/host.ts
@@ -217,7 +217,10 @@ export class EmbeddedReplayerHost {
if (this.timer !== null) return;
this.timer = setInterval(() => {
if (this.replayer) {
- this.post({ type: 'time', currentTime: this.replayer.getCurrentTime() });
+ this.post({
+ type: 'time',
+ currentTime: this.replayer.getCurrentTime(),
+ });
}
}, this.timeUpdateIntervalMs);
}
@@ -234,7 +237,10 @@ export class EmbeddedReplayerHost {
// before the origin is pinned (the `ready` handshake). We never send app
// secrets over this channel — only replay telemetry — so "*" is acceptable
// for that single pre-pinning message.
- this.parentWindow.postMessage(wrap(message), this.expectedParentOrigin ?? '*');
+ this.parentWindow.postMessage(
+ wrap(message),
+ this.expectedParentOrigin ?? '*',
+ );
}
}
diff --git a/packages/rrweb/src/replay/embedded/protocol.ts b/packages/rrweb/src/replay/embedded/protocol.ts
index 7240bf37..e693a725 100644
--- a/packages/rrweb/src/replay/embedded/protocol.ts
+++ b/packages/rrweb/src/replay/embedded/protocol.ts
@@ -96,7 +96,12 @@ export function pickSerializableConfig(
/* -------------------------------------------------------------------------- */
export type HostCommand =
- | { type: 'init'; events: eventWithTime[]; config?: SerializableReplayerConfig; autoplay?: boolean }
+ | {
+ type: 'init';
+ events: eventWithTime[];
+ config?: SerializableReplayerConfig;
+ autoplay?: boolean;
+ }
| { type: 'play'; timeOffset?: number }
| { type: 'pause'; timeOffset?: number }
| { type: 'resume'; timeOffset?: number }
diff --git a/packages/rrweb/test/replay/embedded.test.ts b/packages/rrweb/test/replay/embedded.test.ts
index 4d9b08f4..f3ba937b 100644
--- a/packages/rrweb/test/replay/embedded.test.ts
+++ b/packages/rrweb/test/replay/embedded.test.ts
@@ -20,7 +20,11 @@ describe('embedded replay protocol', () => {
expect(isEnvelope(null)).toBe(false);
expect(isEnvelope({})).toBe(false);
expect(
- isEnvelope({ channel: 'other', version: 1, message: { type: 'ready' } }),
+ isEnvelope({
+ channel: 'other',
+ version: 1,
+ message: { type: 'ready' },
+ }),
).toBe(false);
expect(
isEnvelope({
From 7705569889fc3bd42884df9dd6bae363777a14ed Mon Sep 17 00:00:00 2001
From: mayberryzane <86132398+mayberryzane@users.noreply.github.com>
Date: Thu, 30 Jul 2026 13:52:41 -0700
Subject: [PATCH 04/11] fix(test): anchor polyfill script injection on the
document's first
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The two shadow-DOM polyfill tests splice their polyfill ) get injected mid-bundle,
the HTML parser terminates the inline script there, rrweb never loads, and
window.snapshots stays undefined.
Anchor on the first '' — the document's real head — instead.
Co-Authored-By: Claude Fable 5
---
packages/rrweb/test/integration.test.ts | 11 +++++++----
packages/rrweb/test/utils.ts | 11 +++++++++++
2 files changed, 18 insertions(+), 4 deletions(-)
diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts
index b70a662a..f4d699c8 100644
--- a/packages/rrweb/test/integration.test.ts
+++ b/packages/rrweb/test/integration.test.ts
@@ -9,6 +9,7 @@ import {
launchPuppeteer,
waitForRAF,
waitForIFrameLoad,
+ replaceFirst,
replaceLast,
generateRecordSnippet,
ISuite,
@@ -1221,8 +1222,9 @@ describe('record integration tests', function (this: ISuite) {
const page: puppeteer.Page = await browser.newPage();
await page.goto('about:blank');
await page.setContent(
- // insert shadydom script
- replaceLast(
+ // insert shadydom script (first '' = the document's real head; the
+ // inlined rrweb bundle also contains that text in a string literal)
+ replaceFirst(
getHtml.call(this, 'polyfilled-shadowdom-mutation.html'),
'',
`
@@ -1255,8 +1257,9 @@ describe('record integration tests', function (this: ISuite) {
const page: puppeteer.Page = await browser.newPage();
await page.goto('about:blank');
await page.setContent(
- // insert lwc's synthetic-shadow script
- replaceLast(
+ // insert lwc's synthetic-shadow script (first '' = the document's
+ // real head; the inlined rrweb bundle also contains that text)
+ replaceFirst(
getHtml.call(this, 'polyfilled-shadowdom-mutation.html'),
'',
`
diff --git a/packages/rrweb/test/utils.ts b/packages/rrweb/test/utils.ts
index ffc2a13a..fe06edcb 100644
--- a/packages/rrweb/test/utils.ts
+++ b/packages/rrweb/test/utils.ts
@@ -335,6 +335,17 @@ export function replaceLast(str: string, find: string, replace: string) {
return str.substring(0, index) + replace + str.substring(index + find.length);
}
+// For anchors like '' that must target the document's real head: the
+// inlined rrweb bundle can contain the same text in a string literal (e.g.
+// buildHostDocument's HTML template), so the last occurrence is not safe.
+export function replaceFirst(str: string, find: string, replace: string) {
+ const index = str.indexOf(find);
+ if (index === -1) {
+ return str;
+ }
+ return str.substring(0, index) + replace + str.substring(index + find.length);
+}
+
export async function assertDomSnapshot(page: puppeteer.Page) {
const cdp = await page.target().createCDPSession();
const { data } = await cdp.send('Page.captureSnapshot', {
From b995466ba0362980c6ac0fa358893ddebd569bc0 Mon Sep 17 00:00:00 2001
From: mayberryzane <86132398+mayberryzane@users.noreply.github.com>
Date: Thu, 30 Jul 2026 14:34:19 -0700
Subject: [PATCH 05/11] fix(replay): correct embedded host lifecycle and
document the deployment shape
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three fixes plus e2e coverage for the embedded replayer:
- Reset the host's `playing` flag when a re-init or stop tears down the
previous Replayer. It previously called stopTimePump() directly, so the
flag stayed true and the next setPlaying(true) no-opped — current-time
telemetry never resumed after re-initializing while playing.
- Add a `ping` command: the host answers with `ready`. A client attached to
an already-booted host (e.g. re-created against a long-lived iframe)
missed the one-shot `ready` announcement and its whenReady() never
resolved. The client now pings on construction.
- Document the required deployment shape. The host needs
sandbox="allow-scripts allow-same-origin" on a dedicated cross-site
origin: nested browsing contexts inherit sandbox flags, so in an
opaque-origin document the Replayer's rebuild iframe is unreachable
(contentDocument is null) and replay cannot work at all. Isolation comes
from the origin being cross-site to the embedding app, not from opacity.
Also note that this package ships no prebuilt host bundle and that a
cross-origin scriptUrl must be CORS-enabled.
New test/replay/embedded-e2e.test.ts boots a real host (and Replayer) in a
sandboxed iframe served from a second origin and drives it with a real
client: handshake, cross-origin build + metadata, time streaming across a
re-init, and late-client handshake. The last two fail without the fixes
above. Host-side unit tests now cover the origin/source authentication
checks. test/utils.ts: point the served-bundle route at the real dist path.
Co-Authored-By: Claude Fable 5
---
.../origin-isolated-embedded-replayer.md | 20 +-
packages/rrweb/src/index.ts | 5 +-
packages/rrweb/src/replay/embedded/client.ts | 21 +-
.../src/replay/embedded/host-document.ts | 41 ++--
packages/rrweb/src/replay/embedded/host.ts | 18 +-
packages/rrweb/src/replay/embedded/index.ts | 7 +-
.../rrweb/src/replay/embedded/protocol.ts | 27 ++-
packages/rrweb/test/html/embedded-host.html | 17 ++
.../rrweb/test/replay/embedded-e2e.test.ts | 198 ++++++++++++++++++
packages/rrweb/test/replay/embedded.test.ts | 85 +++++++-
packages/rrweb/test/utils.ts | 2 +-
11 files changed, 386 insertions(+), 55 deletions(-)
create mode 100644 packages/rrweb/test/html/embedded-host.html
create mode 100644 packages/rrweb/test/replay/embedded-e2e.test.ts
diff --git a/.changeset/origin-isolated-embedded-replayer.md b/.changeset/origin-isolated-embedded-replayer.md
index 481eff0e..3e2fd25c 100644
--- a/.changeset/origin-isolated-embedded-replayer.md
+++ b/.changeset/origin-isolated-embedded-replayer.md
@@ -2,12 +2,14 @@
"rrweb": minor
---
-Add origin-isolated embedded replay (SEC-8885). New `EmbeddedReplayerHost` /
-`startEmbeddedReplayerHost` run the `Replayer` inside a sandboxed, cookieless,
-cross-origin iframe; `EmbeddedReplayerClient` drives it from the parent over a
-typed, origin-authenticated `postMessage` bridge that only ever transfers data
-(never functions/DOM, never `eval`). `buildHostDocument` emits the sandbox HTML
-shell with a `` CSP whose `script-src` omits `unsafe-eval`. This relocates
-all untrusted replay-data handling (including the canvas-argument deserializer)
-out of the embedding app's privileged origin. Purely additive; existing
-in-parent `Replayer` usage is unchanged.
+Add optional origin-isolated embedded replay. New `EmbeddedReplayerHost` /
+`startEmbeddedReplayerHost` run the `Replayer` inside a sandboxed iframe served
+from a separate, cross-site origin, and `EmbeddedReplayerClient` drives it from
+the parent page over a typed, origin-authenticated `postMessage` bridge that
+only ever transfers plain data (never functions or DOM nodes).
+`buildHostDocument` emits the HTML shell for that iframe, including a ``
+CSP that names only the host bundle in `script-src` and omits `unsafe-eval`.
+Embedders who adopt this run replay outside their own origin, so the replay path
+has no app cookies, no same-origin access to app APIs, and no reach into the
+embedding page. Opt-in and purely additive: existing in-parent `Replayer` usage
+is unchanged.
diff --git a/packages/rrweb/src/index.ts b/packages/rrweb/src/index.ts
index f7a8f865..2178f66d 100644
--- a/packages/rrweb/src/index.ts
+++ b/packages/rrweb/src/index.ts
@@ -22,8 +22,9 @@ import './replay/styles/style.css';
export type { recordOptions, ReplayPlugin } from './types';
-// Origin-isolated replay: run the Replayer inside a cookieless, cross-origin
-// iframe and drive it over postMessage (SEC-8885). See ./replay/embedded.
+// Optional origin-isolated replay: run the Replayer inside a sandboxed iframe
+// on a separate, cross-site origin and drive it over postMessage.
+// See ./replay/embedded.
export {
EmbeddedReplayerHost,
startEmbeddedReplayerHost,
diff --git a/packages/rrweb/src/replay/embedded/client.ts b/packages/rrweb/src/replay/embedded/client.ts
index ca660387..7ee9448a 100644
--- a/packages/rrweb/src/replay/embedded/client.ts
+++ b/packages/rrweb/src/replay/embedded/client.ts
@@ -8,9 +8,9 @@
* the replay document.
*
* Peer authentication: the client identifies host messages by `event.source`
- * identity against the iframe's `contentWindow`. A sandboxed opaque-origin
- * iframe reports `event.origin === "null"`, so origin-string matching is not
- * usable here — source identity is the reliable check.
+ * identity against the iframe's `contentWindow`. Source identity holds no
+ * matter what origin the host document ends up on (it even survives the frame
+ * navigating), so it is the robust check on this side of the boundary.
*/
import {
@@ -28,11 +28,11 @@ import type {
export interface EmbeddedReplayerClientOptions {
/**
- * Target origin used when posting to the iframe. For a sandboxed
- * opaque-origin host this must be "*" (an opaque frame's origin is "null",
- * which cannot be named as a targetOrigin). Only replay data — never app
- * secrets — is ever posted, so "*" is acceptable. When the host is served
- * from a dedicated named origin, pass that origin for a tighter target.
+ * Target origin used when posting to the iframe. Pass the dedicated host
+ * origin (e.g. the origin `buildHostDocument`'s shell is served from) so
+ * replay data can only be delivered to the document it is meant for. The
+ * default is "*", which works before the host origin is known; only replay
+ * data — never app secrets — is ever posted over this channel.
*/
hostOrigin?: string;
}
@@ -59,6 +59,11 @@ export class EmbeddedReplayerClient {
this.iframe = iframe;
this.hostOrigin = options.hostOrigin ?? '*';
window.addEventListener('message', this.onMessage);
+ // If the host booted before this client attached (e.g. a client re-created
+ // on a long-lived iframe), its one-shot `ready` announcement is gone —
+ // probe so it re-announces. Harmless when the iframe hasn't loaded yet:
+ // the message is dropped and the host's own boot-time `ready` arrives.
+ this.send({ type: 'ping' });
}
/** Resolves once the host iframe has loaded and announced readiness. */
diff --git a/packages/rrweb/src/replay/embedded/host-document.ts b/packages/rrweb/src/replay/embedded/host-document.ts
index 86a9ae65..2db745e9 100644
--- a/packages/rrweb/src/replay/embedded/host-document.ts
+++ b/packages/rrweb/src/replay/embedded/host-document.ts
@@ -2,20 +2,35 @@
* Builds the minimal HTML shell that hosts the embedded replayer inside the
* sandboxed iframe.
*
- * Intended usage (served-URL mode): serve this document at a static URL and
- * point an iframe at it with `sandbox="allow-scripts"` (NOTE: no
- * `allow-same-origin`). The sandbox attribute forces the document into an
- * opaque origin regardless of the URL it is served from, which is what makes it
- * cookieless and cross-site to the app — so escaped replay JS has no app
- * cookies, no same-origin API access, and no reach into the parent window. The
- * parent origin to trust is passed at runtime via the iframe's `parentOrigin`
- * query parameter, which the host bootstrap reads.
+ * Intended usage: serve this document from a DEDICATED origin that is
+ * cross-site to the embedding app (so it never receives app cookies), and
+ * point an iframe at it with `sandbox="allow-scripts allow-same-origin"`. The
+ * isolation comes from the origin boundary: everything the replayer does runs
+ * with no app cookies, no same-origin access to app APIs, and no reach into the
+ * parent window. The parent origin to trust is passed at runtime via the
+ * iframe's `parentOrigin` query parameter, which the host bootstrap reads.
*
- * The `` CSP below governs the realm the replayer ACTUALLY runs in (unlike
- * today's policy, which only covered the rebuilt-DOM child). `script-src` omits
- * `unsafe-eval`, which kills the `new Function(...)` canvas-deserializer path
- * (SEC-8885) even inside the sandbox — defense in depth on top of the origin
- * isolation itself.
+ * `allow-same-origin` is REQUIRED, which is why an opaque-origin
+ * (`allow-scripts`-only) deployment is not possible: nested browsing contexts
+ * inherit the sandbox flags, so inside an opaque-origin document every child
+ * iframe lands in a fresh opaque origin of its own and its `contentDocument`
+ * is unreachable — and the `Replayer` rebuilds the recorded DOM into exactly
+ * such a child iframe (see `createSandboxedIframe` in rrweb-snapshot).
+ * Corollary: NEVER serve this document from the app's own origin — with
+ * `allow-same-origin` the frame would then be same-origin with the app, which
+ * removes the boundary this mode exists to create.
+ *
+ * The `` CSP below governs the realm the replayer itself runs in, not just
+ * the rebuilt-DOM child document. `script-src` names only the host bundle and
+ * omits `unsafe-eval`, so the replay realm loads no other script and compiles
+ * no code from strings — defense in depth on top of the origin isolation.
+ *
+ * Integration notes:
+ * - This package ships no prebuilt host bundle: `scriptUrl` must point at a
+ * consumer-built module that calls `startEmbeddedReplayerHost()`.
+ * - Module scripts are CORS-gated: if `scriptUrl` is not same-origin with the
+ * host document, its server must send `Access-Control-Allow-Origin` for the
+ * host origin or the host silently never boots.
*/
export interface HostDocumentOptions {
diff --git a/packages/rrweb/src/replay/embedded/host.ts b/packages/rrweb/src/replay/embedded/host.ts
index a5b62e0f..082a4ca6 100644
--- a/packages/rrweb/src/replay/embedded/host.ts
+++ b/packages/rrweb/src/replay/embedded/host.ts
@@ -2,10 +2,10 @@
* `EmbeddedReplayerHost` — runs INSIDE the isolated replay iframe.
*
* It receives the recorded events + a data-only config from the parent over
- * `postMessage`, constructs a normal rrweb `Replayer` in *this* realm (so all
- * untrusted-data handling — including canvas-argument deserialization — happens
- * here, not in the parent's privileged origin), forwards `Replayer` events back
- * to the parent, and applies playback commands.
+ * `postMessage`, constructs a normal rrweb `Replayer` in *this* realm (so the
+ * replay of untrusted recording data happens here rather than in the parent's
+ * privileged origin), forwards `Replayer` events back to the parent, and
+ * applies playback commands.
*
* Security invariants (see ./protocol.ts):
* - Only messages whose `event.origin` equals the trusted parent origin AND
@@ -88,7 +88,7 @@ export class EmbeddedReplayerHost {
/** Tear everything down (also invoked on a `destroy` command). */
stop(): void {
window.removeEventListener('message', this.onMessage);
- this.stopTimePump();
+ this.setPlaying(false);
this.replayer?.destroy();
this.replayer = null;
this.started = false;
@@ -113,6 +113,10 @@ export class EmbeddedReplayerHost {
private handle(command: HostCommand): void {
switch (command.type) {
+ case 'ping':
+ // Re-announce readiness for clients that attached after start().
+ this.post({ type: 'ready' });
+ return;
case 'init':
this.init(command.events, command.config, command.autoplay);
return;
@@ -158,8 +162,10 @@ export class EmbeddedReplayerHost {
autoplay?: boolean,
): void {
if (this.replayer) {
+ // setPlaying (not a bare stopTimePump) so the `playing` flag resets too;
+ // otherwise the next setPlaying(true) no-ops and the pump never restarts.
+ this.setPlaying(false);
this.replayer.destroy();
- this.stopTimePump();
}
// Data-only config from the parent, plus locally-owned, non-transferable
diff --git a/packages/rrweb/src/replay/embedded/index.ts b/packages/rrweb/src/replay/embedded/index.ts
index d33562fd..fa448e95 100644
--- a/packages/rrweb/src/replay/embedded/index.ts
+++ b/packages/rrweb/src/replay/embedded/index.ts
@@ -1,7 +1,8 @@
/**
- * Origin-isolated replay: run the rrweb `Replayer` inside a cookieless,
- * cross-origin (sandboxed opaque-origin) iframe and drive it from the parent
- * over `postMessage`. See ./protocol.ts for the rationale (SEC-8885).
+ * Origin-isolated replay (opt-in): run the rrweb `Replayer` inside a sandboxed
+ * iframe on a separate, cross-site origin and drive it from the parent over
+ * `postMessage`. See ./protocol.ts for the rationale and the deployment
+ * requirements.
*
* - `EmbeddedReplayerHost` / `startEmbeddedReplayerHost`: run inside the iframe.
* - `EmbeddedReplayerClient`: run in the parent to control the host.
diff --git a/packages/rrweb/src/replay/embedded/protocol.ts b/packages/rrweb/src/replay/embedded/protocol.ts
index e693a725..ef458308 100644
--- a/packages/rrweb/src/replay/embedded/protocol.ts
+++ b/packages/rrweb/src/replay/embedded/protocol.ts
@@ -2,14 +2,13 @@
* Wire protocol for running the rrweb `Replayer` inside an isolated iframe and
* driving it from a parent page over `postMessage`.
*
- * Motivation (SEC-8885): today the `Replayer` runs in the host page and rebuilds
- * the recorded DOM downward into a child iframe. Canvas replay reconstructs
- * command arguments via `new window[rr_type](...)` in that host realm, so a
- * poisoned `Promise(Function("code"))` executes as first-party JS on the app
- * origin. Relocating the `Replayer` *into* a cookieless, cross-origin iframe
- * moves every bit of untrusted-data handling out of the privileged origin: even
- * if attacker JS runs, it runs somewhere with no app cookies, no same-origin API
- * access, and no reach into the parent window.
+ * Motivation: a recorded session is untrusted input, and by default the
+ * `Replayer` runs in the embedding page's own realm — so the whole replay path
+ * operates with the embedding origin's privileges. This opt-in mode lets an
+ * embedder relocate the `Replayer` into a separate, cross-site origin instead,
+ * where it has no app cookies, no same-origin access to app APIs, and no reach
+ * into the parent window. Purely additive: in-parent `Replayer` usage is
+ * unchanged, and embedders who do not opt in are unaffected.
*
* This module defines the messages exchanged across that boundary. Two rules are
* load-bearing and MUST hold for the isolation to be worth anything:
@@ -20,9 +19,9 @@
* message content into code.
* 2. Both sides authenticate their peer before acting on a message. The host
* checks `event.origin` against the parent origin it was told to trust; the
- * client checks `event.source` identity against its iframe's `contentWindow`
- * (a sandboxed opaque-origin iframe reports `event.origin === "null"`, so
- * origin-string matching is not usable on the client side).
+ * client checks `event.source` identity against its iframe's
+ * `contentWindow`, which holds regardless of the origin the host document
+ * is served from.
*/
import type { playerConfig } from '../../types';
@@ -96,6 +95,12 @@ export function pickSerializableConfig(
/* -------------------------------------------------------------------------- */
export type HostCommand =
+ /**
+ * Handshake probe. The host answers with `ready`. Sent by the client on
+ * construction so that a client attached to an already-booted host (whose
+ * one-shot `ready` announcement it missed) still resolves `whenReady()`.
+ */
+ | { type: 'ping' }
| {
type: 'init';
events: eventWithTime[];
diff --git a/packages/rrweb/test/html/embedded-host.html b/packages/rrweb/test/html/embedded-host.html
new file mode 100644
index 00000000..956cd252
--- /dev/null
+++ b/packages/rrweb/test/html/embedded-host.html
@@ -0,0 +1,17 @@
+
+
+
+
+ embedded replayer host
+
+
+
+
+
+
+
diff --git a/packages/rrweb/test/replay/embedded-e2e.test.ts b/packages/rrweb/test/replay/embedded-e2e.test.ts
new file mode 100644
index 00000000..87f8edbd
--- /dev/null
+++ b/packages/rrweb/test/replay/embedded-e2e.test.ts
@@ -0,0 +1,198 @@
+/**
+ * End-to-end coverage for the optional origin-isolated embedded replayer: boots
+ * a real `EmbeddedReplayerHost` (and `Replayer`) inside a sandboxed cross-origin
+ * iframe and drives it from a parent page on a different origin with a real
+ * `EmbeddedReplayerClient` over `postMessage`.
+ *
+ * Deployment-shape note: the host runs with
+ * `sandbox="allow-scripts allow-same-origin"` on a DEDICATED origin (here: a
+ * second localhost port). `allow-same-origin` is required — without it the
+ * host document gets an opaque origin, its nested browsing contexts inherit
+ * the sandbox flags and land in fresh opaque origins of their own, and the
+ * `Replayer`'s rebuild iframe becomes unreachable (`contentDocument` is null),
+ * so replay cannot work at all. Isolation comes from the host origin being
+ * cross-site to the app, not from origin opacity.
+ */
+import * as fs from 'fs';
+import * as path from 'path';
+import type * as puppeteer from 'puppeteer';
+import { vi } from 'vitest';
+import { startServer, getServerURL, launchPuppeteer, ISuite } from '../utils';
+import rawEvents from '../events/hover';
+
+describe('embedded replayer e2e', function (this: ISuite) {
+ vi.setConfig({ testTimeout: 30_000 });
+
+ let code: ISuite['code'];
+ let browser: ISuite['browser'];
+ let parentServer: ISuite['server'];
+ let hostServer: ISuite['server'];
+ let parentServerURL: string;
+ let hostServerURL: string;
+ let page: puppeteer.Page;
+
+ // Stretch the fixture (~155ms) so playback runs long enough to observe the
+ // current-time telemetry stream across several pump ticks.
+ const events = rawEvents.map((e) => ({ ...e, timestamp: e.timestamp * 30 }));
+ const eventsJson = JSON.stringify(events);
+
+ beforeAll(async () => {
+ // Two servers on different ports = two origins, so the host iframe is
+ // genuinely cross-origin to the parent page, like the real deployment.
+ parentServer = await startServer();
+ hostServer = await startServer(3031);
+ parentServerURL = getServerURL(parentServer);
+ hostServerURL = getServerURL(hostServer);
+ browser = await launchPuppeteer();
+ const bundlePath = path.resolve(__dirname, '../../dist/rrweb.umd.cjs');
+ code = fs.readFileSync(bundlePath, 'utf8');
+ });
+
+ afterEach(async () => {
+ await page?.close();
+ });
+
+ afterAll(async () => {
+ await browser.close();
+ parentServer.close();
+ hostServer.close();
+ });
+
+ /**
+ * Load a parent page on origin A, embed the served host page from origin B
+ * in a sandboxed iframe, and attach a client. Leaves `__client`, `__iframe`,
+ * `__received`, and `__eventsJson` on the parent window for tests to drive.
+ */
+ async function bootEmbedded(): Promise {
+ page = await browser.newPage();
+ await page.goto(`${parentServerURL}/html/`);
+ await page.setContent(
+ ``,
+ );
+ await page.evaluate(
+ (hostPageURL, evJson) => {
+ /* eslint-disable @typescript-eslint/no-explicit-any */
+ const w = window as any;
+ w.__eventsJson = evJson;
+ const iframe = document.createElement('iframe');
+ iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin');
+ iframe.src =
+ hostPageURL + '?parentOrigin=' + encodeURIComponent(location.origin);
+ document.body.appendChild(iframe);
+ w.__iframe = iframe;
+
+ const received = {
+ initializedCount: 0,
+ metadata: null as unknown,
+ times: [] as number[],
+ replayerEvents: [] as string[],
+ errors: [] as string[],
+ };
+ w.__received = received;
+
+ const client = new w.rrweb.EmbeddedReplayerClient(iframe, {
+ hostOrigin: new URL(hostPageURL).origin,
+ });
+ w.__client = client;
+ client.on('initialized', (m: unknown) => {
+ received.initializedCount += 1;
+ received.metadata = m;
+ });
+ client.on('time', (t: number) => received.times.push(t));
+ client.on('fullsnapshot-rebuilded', () =>
+ received.replayerEvents.push('fullsnapshot-rebuilded'),
+ );
+ client.on('error', (message: string) => received.errors.push(message));
+ /* eslint-enable @typescript-eslint/no-explicit-any */
+ },
+ `${hostServerURL}/html/embedded-host.html`,
+ eventsJson,
+ );
+ // The host announces `ready` once the served host page boots.
+ const ready = await page.evaluate(`
+ Promise.race([
+ window.__client.whenReady().then(() => 'ready'),
+ new Promise((resolve) => setTimeout(() => resolve('timed out'), 15000)),
+ ])
+ `);
+ if (ready !== 'ready') {
+ throw new Error('embedded host never announced ready');
+ }
+ }
+
+ it('completes the ready handshake, builds the replay cross-origin, and reports metadata', async () => {
+ await bootEmbedded();
+
+ const crossOriginBlocked = await page.evaluate(
+ 'window.__iframe.contentDocument === null',
+ );
+ // The parent must NOT be able to reach into the host document — removing
+ // that reachability is the point of the isolation.
+ expect(crossOriginBlocked).toBe(true);
+
+ await page.evaluate(
+ 'window.__client.init(JSON.parse(window.__eventsJson), {}, false)',
+ );
+ await page.waitForFunction('window.__received.initializedCount > 0');
+ await page.waitForFunction(
+ 'window.__received.replayerEvents.includes("fullsnapshot-rebuilded")',
+ );
+
+ const errors = (await page.evaluate(
+ 'window.__received.errors',
+ )) as string[];
+ expect(errors).toEqual([]);
+
+ const metadata = (await page.evaluate('window.__received.metadata')) as {
+ totalTime: number;
+ };
+ expect(metadata.totalTime).toBeGreaterThan(0);
+ const cachedMetadata = await page.evaluate('window.__client.getMetaData()');
+ expect(cachedMetadata).toEqual(metadata);
+ });
+
+ it('streams time updates while playing, including after a re-init while already playing', async () => {
+ await bootEmbedded();
+
+ await page.evaluate(
+ 'window.__client.init(JSON.parse(window.__eventsJson), {}, false)',
+ );
+ await page.waitForFunction('window.__received.initializedCount > 0');
+
+ await page.evaluate('window.__client.play(0)');
+ await page.waitForFunction('window.__received.times.length >= 3');
+
+ // Re-init while the previous replayer is still playing. The host must
+ // reset its playing state so the autoplay below restarts the time pump —
+ // regression test for the pump dying after re-init.
+ const before = (await page.evaluate(
+ 'window.__received.times.length',
+ )) as number;
+ await page.evaluate(
+ 'window.__client.init(JSON.parse(window.__eventsJson), {}, true)',
+ );
+ await page.waitForFunction('window.__received.initializedCount >= 2');
+ await page.waitForFunction(
+ `window.__received.times.length >= ${before + 3}`,
+ { timeout: 10_000 },
+ );
+ });
+
+ it('resolves whenReady for a client attached after the host booted', async () => {
+ await bootEmbedded();
+
+ // The first client consumed the boot-time `ready`. A second client on the
+ // same long-lived iframe must still hand-shake (via its construction ping)
+ // instead of hanging forever — regression test for the missed-`ready` race.
+ const result = await page.evaluate(`
+ (() => {
+ const c2 = new window.rrweb.EmbeddedReplayerClient(window.__iframe);
+ return Promise.race([
+ c2.whenReady().then(() => 'ready'),
+ new Promise((resolve) => setTimeout(() => resolve('timed out'), 5000)),
+ ]);
+ })()
+ `);
+ expect(result).toBe('ready');
+ });
+});
diff --git a/packages/rrweb/test/replay/embedded.test.ts b/packages/rrweb/test/replay/embedded.test.ts
index f3ba937b..4bfb9e81 100644
--- a/packages/rrweb/test/replay/embedded.test.ts
+++ b/packages/rrweb/test/replay/embedded.test.ts
@@ -9,6 +9,7 @@ import {
type HostMessage,
} from '../../src/replay/embedded/protocol';
import { EmbeddedReplayerClient } from '../../src/replay/embedded/client';
+import { EmbeddedReplayerHost } from '../../src/replay/embedded/host';
describe('embedded replay protocol', () => {
describe('isEnvelope', () => {
@@ -102,11 +103,23 @@ describe('EmbeddedReplayerClient', () => {
window.dispatchEvent(event);
}
+ it('probes the host with a ping on construction', () => {
+ const { posted } = makeClient();
+ // A client attached to an already-booted host missed the one-shot `ready`
+ // announcement; the construction-time ping makes the host re-announce.
+ expect(posted).toHaveLength(1);
+ expect(posted[0]).toMatchObject({
+ channel: RRWEB_EMBEDDED_CHANNEL,
+ version: RRWEB_EMBEDDED_PROTOCOL_VERSION,
+ message: { type: 'ping' },
+ });
+ });
+
it('posts namespaced command envelopes', () => {
const { client, posted } = makeClient();
client.play(1234);
- expect(posted).toHaveLength(1);
- expect(posted[0]).toMatchObject({
+ expect(posted).toHaveLength(2); // [0] is the construction ping
+ expect(posted[1]).toMatchObject({
channel: RRWEB_EMBEDDED_CHANNEL,
version: RRWEB_EMBEDDED_PROTOCOL_VERSION,
message: { type: 'play', timeOffset: 1234 },
@@ -173,3 +186,71 @@ describe('EmbeddedReplayerClient', () => {
expect(client.getCurrentTime()).toBe(0);
});
});
+
+describe('EmbeddedReplayerHost', () => {
+ const PARENT_ORIGIN = 'https://parent.example';
+
+ // Stand-in for window.parent: captures what the host posts back.
+ function makeHost() {
+ const parentWindow = {
+ postMessage: vi.fn(),
+ } as unknown as Window;
+ const host = new EmbeddedReplayerHost({
+ expectedParentOrigin: PARENT_ORIGIN,
+ parentWindow,
+ });
+ return {
+ host,
+ posted: parentWindow.postMessage as ReturnType,
+ parentWindow,
+ };
+ }
+
+ function deliverCommand(
+ message: unknown,
+ { origin = PARENT_ORIGIN, source }: { origin?: string; source: unknown },
+ ) {
+ const event = new MessageEvent('message', { data: wrap(message), origin });
+ Object.defineProperty(event, 'source', { value: source });
+ window.dispatchEvent(event);
+ }
+
+ it('announces ready on start and re-announces on ping', () => {
+ const { host, posted, parentWindow } = makeHost();
+ host.start();
+ expect(posted).toHaveBeenCalledTimes(1);
+ expect(posted.mock.calls[0][0]).toMatchObject({
+ message: { type: 'ready' },
+ });
+ expect(posted.mock.calls[0][1]).toBe(PARENT_ORIGIN);
+
+ // A late-attaching client probes with ping; the host must re-announce.
+ deliverCommand({ type: 'ping' }, { source: parentWindow });
+ expect(posted).toHaveBeenCalledTimes(2);
+ expect(posted.mock.calls[1][0]).toMatchObject({
+ message: { type: 'ready' },
+ });
+ host.stop();
+ });
+
+ it('ignores commands from the wrong origin', () => {
+ const { host, posted, parentWindow } = makeHost();
+ host.start();
+ posted.mockClear();
+ deliverCommand(
+ { type: 'ping' },
+ { origin: 'https://evil.example', source: parentWindow },
+ );
+ expect(posted).not.toHaveBeenCalled();
+ host.stop();
+ });
+
+ it('ignores commands whose source is not the parent window', () => {
+ const { host, posted } = makeHost();
+ host.start();
+ posted.mockClear();
+ deliverCommand({ type: 'ping' }, { source: { fake: true } });
+ expect(posted).not.toHaveBeenCalled();
+ host.stop();
+ });
+});
diff --git a/packages/rrweb/test/utils.ts b/packages/rrweb/test/utils.ts
index fe06edcb..13477489 100644
--- a/packages/rrweb/test/utils.ts
+++ b/packages/rrweb/test/utils.ts
@@ -60,7 +60,7 @@ export const startServer = (defaultPort = 3030) =>
let pathname = path.join(__dirname, sanitizePath);
if (/^\/rrweb.*\.c?js.*/.test(sanitizePath)) {
- pathname = path.join(__dirname, `../dist/main`, sanitizePath);
+ pathname = path.join(__dirname, `../dist`, sanitizePath);
}
try {
From a850efd177baa721fa7fec5f2495120b09bfeb83 Mon Sep 17 00:00:00 2001
From: mayberryzane <86132398+mayberryzane@users.noreply.github.com>
Date: Thu, 30 Jul 2026 14:48:41 -0700
Subject: [PATCH 06/11] fix(replay): send the embedded handshake probe with an
open target origin
A freshly created iframe is still on its initial about:blank document, which
carries the PARENT's origin. Posting the construction-time handshake probe at
the configured host origin was therefore refused by the browser and logged as
a console error on every attach, in the common case where the client is
constructed before the frame navigates.
The probe carries no data beyond its own message type, so target it at "*" and
keep every real command pinned to the host origin. Verified against a live
two-origin setup: the console is clean and both handshake paths (boot-time
ready, and a client attached after the host booted) still resolve.
Co-Authored-By: Claude Fable 5
---
packages/rrweb/src/replay/embedded/client.ts | 16 ++++++++++++++--
1 file changed, 14 insertions(+), 2 deletions(-)
diff --git a/packages/rrweb/src/replay/embedded/client.ts b/packages/rrweb/src/replay/embedded/client.ts
index 7ee9448a..34469825 100644
--- a/packages/rrweb/src/replay/embedded/client.ts
+++ b/packages/rrweb/src/replay/embedded/client.ts
@@ -62,8 +62,15 @@ export class EmbeddedReplayerClient {
// If the host booted before this client attached (e.g. a client re-created
// on a long-lived iframe), its one-shot `ready` announcement is gone —
// probe so it re-announces. Harmless when the iframe hasn't loaded yet:
- // the message is dropped and the host's own boot-time `ready` arrives.
- this.send({ type: 'ping' });
+ // the message lands on the placeholder document, which has no listener, and
+ // the host's own boot-time `ready` arrives later.
+ //
+ // Targeted at "*" rather than hostOrigin on purpose: a freshly created
+ // iframe is still on its initial about:blank document, which carries the
+ // PARENT's origin, so a hostOrigin-targeted post would be refused and
+ // logged as a console error on every attach. The probe carries no data
+ // beyond its own type, so it is safe to leave the target open.
+ this.probe();
}
/** Resolves once the host iframe has loaded and announced readiness. */
@@ -168,6 +175,11 @@ export class EmbeddedReplayerClient {
target.postMessage(wrap(command), this.hostOrigin);
}
+ /** Data-free handshake probe; see the note in the constructor. */
+ private probe(): void {
+ this.iframe.contentWindow?.postMessage(wrap({ type: 'ping' }), '*');
+ }
+
private onMessage = (event: MessageEvent): void => {
if (event.source !== this.iframe.contentWindow) return;
if (!isEnvelope(event.data)) return;
From 70c642df3935d8479195e7d5cd2700df33cf7685 Mon Sep 17 00:00:00 2001
From: mayberryzane <86132398+mayberryzane@users.noreply.github.com>
Date: Thu, 30 Jul 2026 15:41:19 -0700
Subject: [PATCH 07/11] fix(replay): push a settled playback position when the
embedded host stops
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The parent cannot read the playback position synchronously — it lives behind
an origin boundary — so the client's getCurrentTime() is only ever as fresh as
the last pushed `time` message. The pump that produces those runs only while
playing, and pausing/finishing stopped it without sending a final value, so the
cached position was left up to one interval (50ms default) stale.
That matters because Replayer.play(timeOffset) always SEEKS: play() means "play
from 0", not "unpause" (resume() is deprecated and identical). A viewer that
resumes with play(getCurrentTime()) would therefore rewind slightly on every
pause/play cycle, and the drift accumulates.
Pausing and finishing now push one final, settled position after the pump has
stopped, so getCurrentTime() is exact whenever playback is at rest. Extracted
the pump body into postTime() rather than duplicating the post.
Covered by a new e2e case: seek-and-pause well ahead of the current position
(so an in-flight pump tick cannot produce a false pass), assert the parent
converges on the seek target, assert it then holds still while paused, and
assert resuming from the cached value continues forward instead of restarting.
Fails without this change.
Co-Authored-By: Claude Fable 5
---
packages/rrweb/src/replay/embedded/host.ts | 31 +++++++++----
.../rrweb/test/replay/embedded-e2e.test.ts | 44 +++++++++++++++++++
2 files changed, 66 insertions(+), 9 deletions(-)
diff --git a/packages/rrweb/src/replay/embedded/host.ts b/packages/rrweb/src/replay/embedded/host.ts
index 082a4ca6..77e84f65 100644
--- a/packages/rrweb/src/replay/embedded/host.ts
+++ b/packages/rrweb/src/replay/embedded/host.ts
@@ -127,6 +127,7 @@ export class EmbeddedReplayerHost {
case 'pause':
this.replayer?.pause(command.timeOffset);
this.setPlaying(false);
+ this.postTime(); // settled position, after the pump has stopped
return;
case 'resume':
this.replayer?.resume(command.timeOffset ?? 0);
@@ -200,7 +201,10 @@ export class EmbeddedReplayerHost {
// curated events whose payloads are plain, structured-cloneable data.
for (const name of Object.values(ReplayerEvents)) {
replayer.on(name, (payload?: unknown) => {
- if (name === ReplayerEvents.Finish) this.setPlaying(false);
+ if (name === ReplayerEvents.Finish) {
+ this.setPlaying(false);
+ this.postTime(); // settled end position, after the pump has stopped
+ }
this.post(
PAYLOAD_EVENTS.has(name) && payload !== undefined
? { type: 'replayer-event', event: name, payload }
@@ -221,14 +225,23 @@ export class EmbeddedReplayerHost {
private startTimePump(): void {
if (this.timer !== null) return;
- this.timer = setInterval(() => {
- if (this.replayer) {
- this.post({
- type: 'time',
- currentTime: this.replayer.getCurrentTime(),
- });
- }
- }, this.timeUpdateIntervalMs);
+ this.timer = setInterval(() => this.postTime(), this.timeUpdateIntervalMs);
+ }
+
+ /**
+ * Push the current playback position across the bridge.
+ *
+ * The parent cannot read the position synchronously — it lives in this realm
+ * behind an origin boundary — so the client's `getCurrentTime()` is only ever
+ * as fresh as the last push. While playing, the pump keeps it current. When
+ * playback stops the pump stops too, so the last pumped value would be up to
+ * one interval stale; callers that seek from it (`play(getCurrentTime())` to
+ * resume) would drift backwards. Pausing and finishing therefore push one
+ * final, settled value.
+ */
+ private postTime(): void {
+ if (!this.replayer) return;
+ this.post({ type: 'time', currentTime: this.replayer.getCurrentTime() });
}
private stopTimePump(): void {
diff --git a/packages/rrweb/test/replay/embedded-e2e.test.ts b/packages/rrweb/test/replay/embedded-e2e.test.ts
index 87f8edbd..47d82618 100644
--- a/packages/rrweb/test/replay/embedded-e2e.test.ts
+++ b/packages/rrweb/test/replay/embedded-e2e.test.ts
@@ -178,6 +178,50 @@ describe('embedded replayer e2e', function (this: ISuite) {
);
});
+ it('pushes a settled position on pause, so the parent can resume without rewinding', async () => {
+ await bootEmbedded();
+
+ await page.evaluate(
+ 'window.__client.init(JSON.parse(window.__eventsJson), {}, false)',
+ );
+ await page.waitForFunction('window.__received.initializedCount > 0');
+
+ await page.evaluate('window.__client.play(0)');
+ await page.waitForFunction('window.__received.times.length >= 3');
+
+ // Seek-and-pause far ahead of where playback currently is (a few hundred ms
+ // in). Any pump tick still in flight reports the old position, so only a
+ // final push made *after* the pause can land near the seek target.
+ const SEEK_TO = 3000;
+ await page.evaluate(`window.__client.pause(${SEEK_TO})`);
+ await page.waitForFunction(
+ `Math.abs(window.__client.getCurrentTime() - ${SEEK_TO}) < 250`,
+ { timeout: 10_000 },
+ );
+
+ // The position must also hold still while paused — no further pushes.
+ const atRest = (await page.evaluate(
+ 'window.__client.getCurrentTime()',
+ )) as number;
+ await new Promise((resolve) => setTimeout(resolve, 300));
+ expect(await page.evaluate('window.__client.getCurrentTime()')).toBe(
+ atRest,
+ );
+
+ // Resuming from the cached position must continue forward from there rather
+ // than restart, which is what a viewer's play button has to do given that
+ // Replayer.play(timeOffset) always seeks.
+ await page.evaluate(
+ 'window.__client.play(window.__client.getCurrentTime())',
+ );
+ await page.waitForFunction(`window.__client.getCurrentTime() > ${atRest}`, {
+ timeout: 10_000,
+ });
+ expect(
+ (await page.evaluate('window.__received.times')) as number[],
+ ).not.toContain(0);
+ });
+
it('resolves whenReady for a client attached after the host booted', async () => {
await bootEmbedded();
From fc6c0b051acf702a36f793ab30ab055aceeba9c3 Mon Sep 17 00:00:00 2001
From: mayberryzane <86132398+mayberryzane@users.noreply.github.com>
Date: Thu, 30 Jul 2026 16:06:30 -0700
Subject: [PATCH 08/11] feat(replay): let the parent drive embedded replay
layout over the bridge
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Scaling the replay is the one thing an embedding viewer cannot do through the
bridge as it stood. The embedder computes the scale itself — it knows its own
container size and the recorded viewport — but the wrapper element it would
apply that scale to lives in the host realm, and the rect it would measure is
not readable across an origin boundary. Without this, adopting isolated replay
means reimplementing sizing, so the bridge carries it instead:
- setLayout(scale, anchor) sends INTENT, not styles. A CSS class from the
embedder's stylesheet means nothing inside the host document and would
silently do nothing, so the host translates scale + anchor ('top' | 'center')
into transform/position itself: transform-origin top left with left: 50%, and
either top: 50% with a -50% Y translate or top: 0 with none.
- The host retains the last layout and re-applies it after a re-init. Each
Replayer builds its own wrapper, so without this a session switch would snap
the replay back to 1:1 in the corner.
- The host reports the wrapper's rendered geometry (width/height/top/left, in
host-document coordinates) after init, after a layout change, and on Resize.
getDimensions() exposes it and a `dimensions` event announces it — the
cross-origin stand-in for wrapper.getBoundingClientRect().
Covered by an e2e case asserting the only observable available: halving the
scale halves the reported geometry, a top anchor moves the wrapper to the top
edge without resizing it, and the layout survives a re-init. Fails without the
host implementation. Unit tests assert the client sends intent and leaves the
anchor default to the host, so the two sides cannot disagree about it.
Co-Authored-By: Claude Fable 5
---
.../origin-isolated-embedded-replayer.md | 4 ++
packages/rrweb/src/index.ts | 2 +
packages/rrweb/src/replay/embedded/client.ts | 38 ++++++++++++
packages/rrweb/src/replay/embedded/host.ts | 57 +++++++++++++++++
packages/rrweb/src/replay/embedded/index.ts | 2 +
.../rrweb/src/replay/embedded/protocol.ts | 30 +++++++++
.../rrweb/test/replay/embedded-e2e.test.ts | 62 +++++++++++++++++++
packages/rrweb/test/replay/embedded.test.ts | 46 ++++++++++++++
8 files changed, 241 insertions(+)
diff --git a/.changeset/origin-isolated-embedded-replayer.md b/.changeset/origin-isolated-embedded-replayer.md
index 3e2fd25c..b1af1581 100644
--- a/.changeset/origin-isolated-embedded-replayer.md
+++ b/.changeset/origin-isolated-embedded-replayer.md
@@ -9,6 +9,10 @@ the parent page over a typed, origin-authenticated `postMessage` bridge that
only ever transfers plain data (never functions or DOM nodes).
`buildHostDocument` emits the HTML shell for that iframe, including a ``
CSP that names only the host bundle in `script-src` and omits `unsafe-eval`.
+Because the replay wrapper is not reachable from the embedding page, the client
+also carries layout: `setLayout(scale, anchor)` sends scaling intent for the host
+to apply (retained across re-inits), and the host reports the rendered geometry
+back, which `getDimensions()` exposes and a `dimensions` event announces.
Embedders who adopt this run replay outside their own origin, so the replay path
has no app cookies, no same-origin access to app APIs, and no reach into the
embedding page. Opt-in and purely additive: existing in-parent `Replayer` usage
diff --git a/packages/rrweb/src/index.ts b/packages/rrweb/src/index.ts
index 2178f66d..b393b511 100644
--- a/packages/rrweb/src/index.ts
+++ b/packages/rrweb/src/index.ts
@@ -37,6 +37,8 @@ export {
type EmbeddedReplayerClientOptions,
type HostDocumentOptions,
type SerializableReplayerConfig,
+ type ReplayAnchor,
+ type ReplayDimensions,
type HostCommand,
type HostMessage,
} from './replay/embedded';
diff --git a/packages/rrweb/src/replay/embedded/client.ts b/packages/rrweb/src/replay/embedded/client.ts
index 34469825..ccf7cbde 100644
--- a/packages/rrweb/src/replay/embedded/client.ts
+++ b/packages/rrweb/src/replay/embedded/client.ts
@@ -18,6 +18,7 @@ import {
wrap,
type HostCommand,
type HostMessage,
+ type ReplayAnchor,
type SerializableReplayerConfig,
} from './protocol';
import type {
@@ -39,6 +40,14 @@ export interface EmbeddedReplayerClientOptions {
type Listener = (payload?: unknown) => void;
+/** Rendered geometry of the replay wrapper, as measured in the host realm. */
+export interface ReplayDimensions {
+ width: number;
+ height: number;
+ top: number;
+ left: number;
+}
+
export class EmbeddedReplayerClient {
private readonly iframe: HTMLIFrameElement;
private readonly hostOrigin: string;
@@ -47,6 +56,7 @@ export class EmbeddedReplayerClient {
private metadata: playerMetaData | null = null;
private activityIntervals: SessionInterval[] = [];
+ private dimensions: ReplayDimensions | null = null;
private lastCurrentTime = 0;
private ready = false;
private readyResolvers: Array<() => void> = [];
@@ -105,6 +115,16 @@ export class EmbeddedReplayerClient {
this.send({ type: 'setConfig', config });
}
+ /**
+ * Scale and position the replay. Compute `scale` from your own container size
+ * and the recorded viewport, as you would when driving a local `Replayer`;
+ * the host applies it, since the wrapper element is not reachable from here.
+ * The layout is retained across re-inits.
+ */
+ setLayout(scale: number, anchor?: ReplayAnchor): void {
+ this.send({ type: 'setLayout', scale, anchor });
+ }
+
replaceEvents(events: eventWithTime[]): void {
this.send({ type: 'replaceEvents', events });
}
@@ -167,6 +187,15 @@ export class EmbeddedReplayerClient {
return this.activityIntervals;
}
+ /**
+ * Rendered geometry of the replay, in the host document's coordinate space —
+ * the cross-origin stand-in for `replayer.wrapper.getBoundingClientRect()`.
+ * Null until the host has reported it (listen for `'dimensions'`).
+ */
+ getDimensions(): ReplayDimensions | null {
+ return this.dimensions;
+ }
+
/* --- internals -------------------------------------------------------- */
private send(command: HostCommand): void {
@@ -205,6 +234,15 @@ export class EmbeddedReplayerClient {
this.lastCurrentTime = message.currentTime;
this.emit('time', message.currentTime);
return;
+ case 'dimensions':
+ this.dimensions = {
+ width: message.width,
+ height: message.height,
+ top: message.top,
+ left: message.left,
+ };
+ this.emit('dimensions', this.dimensions);
+ return;
case 'error':
this.emit('error', message.message);
return;
diff --git a/packages/rrweb/src/replay/embedded/host.ts b/packages/rrweb/src/replay/embedded/host.ts
index 77e84f65..f0ae83bc 100644
--- a/packages/rrweb/src/replay/embedded/host.ts
+++ b/packages/rrweb/src/replay/embedded/host.ts
@@ -25,6 +25,7 @@ import {
wrap,
type HostCommand,
type HostMessage,
+ type ReplayAnchor,
} from './protocol';
export interface EmbeddedReplayerHostOptions {
@@ -61,6 +62,12 @@ export class EmbeddedReplayerHost {
private playing = false;
private timer: ReturnType | null = null;
private started = false;
+ /**
+ * Last layout the parent asked for. Retained because each `Replayer` builds
+ * its own wrapper element, so a re-init would otherwise drop the scaling and
+ * render the replay at 1:1 in the corner.
+ */
+ private layout: { scale: number; anchor: ReplayAnchor } | null = null;
constructor(options: EmbeddedReplayerHostOptions = {}) {
this.root = options.root ?? document.body;
@@ -133,6 +140,14 @@ export class EmbeddedReplayerHost {
this.replayer?.resume(command.timeOffset ?? 0);
this.setPlaying(true);
return;
+ case 'setLayout':
+ this.layout = {
+ scale: command.scale,
+ anchor: command.anchor ?? 'center',
+ };
+ this.applyLayout();
+ this.postDimensions();
+ return;
case 'setConfig':
this.replayer?.setConfig(pickSerializableConfig(command.config));
return;
@@ -178,7 +193,9 @@ export class EmbeddedReplayerHost {
this.replayer = new Replayer(events, replayerConfig);
this.wireEvents(this.replayer);
+ this.applyLayout(); // new Replayer == new wrapper, so re-apply
this.postInitialized();
+ this.postDimensions();
if (autoplay) {
this.replayer.play(0);
@@ -186,6 +203,42 @@ export class EmbeddedReplayerHost {
}
}
+ /**
+ * Translate the parent's layout intent into styles on the replay wrapper.
+ *
+ * Mirrors what an embedder would otherwise apply from its own stylesheet:
+ * `transform-origin: top left` with `left: 50%` to center horizontally, and
+ * either `top: 50%` + a -50% Y translate to center vertically, or `top: 0`
+ * with no Y translate to anchor at the top edge.
+ */
+ private applyLayout(): void {
+ const wrapper = this.replayer?.wrapper;
+ if (!wrapper || !this.layout) return;
+ const { scale, anchor } = this.layout;
+ const top = anchor === 'top';
+ wrapper.style.position = 'absolute';
+ wrapper.style.transformOrigin = 'top left';
+ wrapper.style.left = '50%';
+ wrapper.style.top = top ? '0' : '50%';
+ wrapper.style.transform = top
+ ? `scale(${scale}) translate(-50%, 0)`
+ : `scale(${scale}) translate(-50%, -50%)`;
+ }
+
+ /** Report the wrapper's rendered geometry; the parent cannot measure it. */
+ private postDimensions(): void {
+ const wrapper = this.replayer?.wrapper;
+ if (!wrapper) return;
+ const rect = wrapper.getBoundingClientRect();
+ this.post({
+ type: 'dimensions',
+ width: rect.width,
+ height: rect.height,
+ top: rect.top,
+ left: rect.left,
+ });
+ }
+
/** Push the stable getters (metadata, activity intervals) to the parent. */
private postInitialized(): void {
if (!this.replayer) return;
@@ -205,6 +258,10 @@ export class EmbeddedReplayerHost {
this.setPlaying(false);
this.postTime(); // settled end position, after the pump has stopped
}
+ if (name === ReplayerEvents.Resize) {
+ // The recorded viewport changed, so the rendered size did too.
+ this.postDimensions();
+ }
this.post(
PAYLOAD_EVENTS.has(name) && payload !== undefined
? { type: 'replayer-event', event: name, payload }
diff --git a/packages/rrweb/src/replay/embedded/index.ts b/packages/rrweb/src/replay/embedded/index.ts
index fa448e95..72306c92 100644
--- a/packages/rrweb/src/replay/embedded/index.ts
+++ b/packages/rrweb/src/replay/embedded/index.ts
@@ -17,6 +17,7 @@ export {
export {
EmbeddedReplayerClient,
type EmbeddedReplayerClientOptions,
+ type ReplayDimensions,
} from './client';
export { buildHostDocument, type HostDocumentOptions } from './host-document';
export {
@@ -28,6 +29,7 @@ export {
wrap,
type SerializableReplayerConfig,
type SerializableConfigKey,
+ type ReplayAnchor,
type HostCommand,
type HostMessage,
type Envelope,
diff --git a/packages/rrweb/src/replay/embedded/protocol.ts b/packages/rrweb/src/replay/embedded/protocol.ts
index ef458308..a19cffd3 100644
--- a/packages/rrweb/src/replay/embedded/protocol.ts
+++ b/packages/rrweb/src/replay/embedded/protocol.ts
@@ -94,6 +94,13 @@ export function pickSerializableConfig(
/* Parent -> Host (commands) */
/* -------------------------------------------------------------------------- */
+/**
+ * Where the scaled replay sits in the host viewport. `center` centers it on
+ * both axes; `top` centers horizontally and anchors to the top edge, so the
+ * replay grows downward as the frame gets taller.
+ */
+export type ReplayAnchor = 'top' | 'center';
+
export type HostCommand =
/**
* Handshake probe. The host answers with `ready`. Sent by the client on
@@ -107,6 +114,17 @@ export type HostCommand =
config?: SerializableReplayerConfig;
autoplay?: boolean;
}
+ /**
+ * Scale and position the replay inside the host viewport.
+ *
+ * The embedder computes `scale` itself — it knows its own container size and
+ * the recorded viewport — but cannot apply it, because the replay wrapper
+ * lives in the host realm. So intent crosses the boundary and the host
+ * translates it into styles. Deliberately NOT a CSS string or class name: a
+ * class from the embedder's stylesheet means nothing in the host document and
+ * would silently do nothing.
+ */
+ | { type: 'setLayout'; scale: number; anchor?: ReplayAnchor }
| { type: 'play'; timeOffset?: number }
| { type: 'pause'; timeOffset?: number }
| { type: 'resume'; timeOffset?: number }
@@ -138,6 +156,18 @@ export type HostMessage =
| { type: 'replayer-event'; event: string; payload?: unknown }
/** Periodic current-time push while playing, so the parent scrubber can track without sync getters. */
| { type: 'time'; currentTime: number }
+ /**
+ * Rendered geometry of the replay wrapper, in the host document's coordinate
+ * space. The parent cannot measure across the boundary, so the host reports
+ * it after init, after a layout change, and whenever the replay resizes.
+ */
+ | {
+ type: 'dimensions';
+ width: number;
+ height: number;
+ top: number;
+ left: number;
+ }
/** Host-side failure surfaced for logging. */
| { type: 'error'; message: string };
diff --git a/packages/rrweb/test/replay/embedded-e2e.test.ts b/packages/rrweb/test/replay/embedded-e2e.test.ts
index 47d82618..05d76481 100644
--- a/packages/rrweb/test/replay/embedded-e2e.test.ts
+++ b/packages/rrweb/test/replay/embedded-e2e.test.ts
@@ -87,6 +87,7 @@ describe('embedded replayer e2e', function (this: ISuite) {
times: [] as number[],
replayerEvents: [] as string[],
errors: [] as string[],
+ dimensionsCount: 0,
};
w.__received = received;
@@ -103,6 +104,9 @@ describe('embedded replayer e2e', function (this: ISuite) {
received.replayerEvents.push('fullsnapshot-rebuilded'),
);
client.on('error', (message: string) => received.errors.push(message));
+ client.on('dimensions', () => {
+ received.dimensionsCount += 1;
+ });
/* eslint-enable @typescript-eslint/no-explicit-any */
},
`${hostServerURL}/html/embedded-host.html`,
@@ -222,6 +226,64 @@ describe('embedded replayer e2e', function (this: ISuite) {
).not.toContain(0);
});
+ it('applies parent-driven scaling in the host realm and reports the geometry back', async () => {
+ await bootEmbedded();
+
+ await page.evaluate(
+ 'window.__client.init(JSON.parse(window.__eventsJson), {}, false)',
+ );
+ await page.waitForFunction('window.__client.getDimensions() !== null');
+
+ const dims = () =>
+ page.evaluate('window.__client.getDimensions()') as Promise<{
+ width: number;
+ height: number;
+ top: number;
+ left: number;
+ }>;
+
+ // Scaling is applied to a wrapper the parent cannot touch or measure, so
+ // the reported geometry is the only observable. Halving the scale must
+ // halve the reported size.
+ await page.evaluate('window.__client.setLayout(1, "center")');
+ await page.waitForFunction('window.__client.getDimensions().width > 0');
+ const full = await dims();
+
+ await page.evaluate('window.__client.setLayout(0.5, "center")');
+ await page.waitForFunction(
+ `Math.abs(window.__client.getDimensions().width - ${full.width / 2}) < 2`,
+ { timeout: 10_000 },
+ );
+ const half = await dims();
+ expect(half.height).toBeCloseTo(full.height / 2, 0);
+
+ // Anchoring must move the replay without resizing it: a top anchor puts the
+ // wrapper's top edge at the top of the host viewport.
+ await page.evaluate('window.__client.setLayout(0.5, "top")');
+ await page.waitForFunction(
+ 'Math.abs(window.__client.getDimensions().top) < 2',
+ { timeout: 10_000 },
+ );
+ const topAnchored = await dims();
+ expect(topAnchored.width).toBeCloseTo(half.width, 0);
+ expect(Math.abs(topAnchored.top - half.top)).toBeGreaterThan(1);
+
+ // A re-init builds a new Replayer, and therefore a new wrapper. The host
+ // must re-apply the retained layout or the replay would snap back to 1:1.
+ await page.evaluate(
+ 'window.__client.init(JSON.parse(window.__eventsJson), {}, false)',
+ );
+ await page.waitForFunction('window.__received.initializedCount >= 2');
+ await page.waitForFunction(
+ `Math.abs(window.__client.getDimensions().width - ${half.width}) < 2`,
+ { timeout: 10_000 },
+ );
+
+ expect(
+ (await page.evaluate('window.__received.errors')) as string[],
+ ).toEqual([]);
+ });
+
it('resolves whenReady for a client attached after the host booted', async () => {
await bootEmbedded();
diff --git a/packages/rrweb/test/replay/embedded.test.ts b/packages/rrweb/test/replay/embedded.test.ts
index 4bfb9e81..6f66c713 100644
--- a/packages/rrweb/test/replay/embedded.test.ts
+++ b/packages/rrweb/test/replay/embedded.test.ts
@@ -126,6 +126,52 @@ describe('EmbeddedReplayerClient', () => {
});
});
+ it('sends layout intent, not styles, and defaults the anchor host-side', () => {
+ const { client, posted } = makeClient();
+ client.setLayout(0.5, 'top');
+ expect(posted[1]).toMatchObject({
+ message: { type: 'setLayout', scale: 0.5, anchor: 'top' },
+ });
+
+ // Anchor omitted: the host applies its own default rather than the client
+ // inventing one, so both sides agree on exactly one default.
+ client.setLayout(0.25);
+ expect(posted[2]).toMatchObject({
+ message: { type: 'setLayout', scale: 0.25 },
+ });
+ expect(
+ (posted[2] as { message: Record }).message.anchor,
+ ).toBeUndefined();
+ });
+
+ it('caches reported dimensions and emits them', () => {
+ const { client } = makeClient();
+ expect(client.getDimensions()).toBeNull();
+ const onDimensions = vi.fn();
+ client.on('dimensions', onDimensions);
+
+ deliver({
+ type: 'dimensions',
+ width: 452,
+ height: 242,
+ top: 119,
+ left: 224,
+ });
+
+ expect(client.getDimensions()).toEqual({
+ width: 452,
+ height: 242,
+ top: 119,
+ left: 224,
+ });
+ expect(onDimensions).toHaveBeenCalledWith({
+ width: 452,
+ height: 242,
+ top: 119,
+ left: 224,
+ });
+ });
+
it('resolves whenReady on the ready handshake', async () => {
const { client } = makeClient();
let resolved = false;
From 91f5f8cf20bb45975d012e99a6a2f3462dd158d0 Mon Sep 17 00:00:00 2001
From: mayberryzane <86132398+mayberryzane@users.noreply.github.com>
Date: Mon, 3 Aug 2026 16:46:21 -0700
Subject: [PATCH 09/11] fix(replay): queue embedded client commands until the
host handshakes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A command posted while the iframe is still on its initial about:blank (which
carries the parent's origin) is rejected as an origin mismatch and dropped —
the viewer calls setLayout on mount, well before the host boots, so the replay
never got scaled. Buffer commands issued before 'ready' and flush them once the
frame is on the host origin.
Co-Authored-By: Claude Fable 5
---
packages/rrweb/src/replay/embedded/client.ts | 25 ++++++++++++++++++++
1 file changed, 25 insertions(+)
diff --git a/packages/rrweb/src/replay/embedded/client.ts b/packages/rrweb/src/replay/embedded/client.ts
index ccf7cbde..1cec707c 100644
--- a/packages/rrweb/src/replay/embedded/client.ts
+++ b/packages/rrweb/src/replay/embedded/client.ts
@@ -61,6 +61,14 @@ export class EmbeddedReplayerClient {
private ready = false;
private readyResolvers: Array<() => void> = [];
private disposed = false;
+ /**
+ * Commands issued before the host handshakes. A command posted while the
+ * iframe is still on its initial `about:blank` (which carries the PARENT's
+ * origin) is rejected by the browser as an origin mismatch and lost — the
+ * viewer calls `setLayout` on mount, well before the host boots. Buffer until
+ * `ready`, then flush; by then the frame is on the host origin.
+ */
+ private pending: HostCommand[] = [];
constructor(
iframe: HTMLIFrameElement,
@@ -199,11 +207,27 @@ export class EmbeddedReplayerClient {
/* --- internals -------------------------------------------------------- */
private send(command: HostCommand): void {
+ // Hold commands until the host is ready (see `pending`), so they aren't
+ // posted at the host origin while the frame is still on about:blank.
+ if (!this.ready) {
+ this.pending.push(command);
+ return;
+ }
+ this.postToHost(command);
+ }
+
+ private postToHost(command: HostCommand): void {
const target = this.iframe.contentWindow;
if (!target) return;
target.postMessage(wrap(command), this.hostOrigin);
}
+ private flushPending(): void {
+ const queued = this.pending;
+ this.pending = [];
+ queued.forEach((command) => this.postToHost(command));
+ }
+
/** Data-free handshake probe; see the note in the constructor. */
private probe(): void {
this.iframe.contentWindow?.postMessage(wrap({ type: 'ping' }), '*');
@@ -219,6 +243,7 @@ export class EmbeddedReplayerClient {
switch (message.type) {
case 'ready':
this.ready = true;
+ this.flushPending(); // deliver commands buffered before the handshake
this.readyResolvers.forEach((r) => r());
this.readyResolvers = [];
return;
From 3537c0ac0db5f1baa6878deeb2dffd96f604ed53 Mon Sep 17 00:00:00 2001
From: mayberryzane <86132398+mayberryzane@users.noreply.github.com>
Date: Tue, 4 Aug 2026 09:41:15 -0700
Subject: [PATCH 10/11] fix(replay): transparent host-document background so
the letterbox isn't white
The replay is aspect-ratio-fit, so there are letterbox bars around it. A white
host body painted them white; make it transparent so they reveal the embedder's
player background (matching the in-parent replayer).
Co-Authored-By: Claude Fable 5
---
packages/rrweb/src/replay/embedded/host-document.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/rrweb/src/replay/embedded/host-document.ts b/packages/rrweb/src/replay/embedded/host-document.ts
index 2db745e9..4d6deee2 100644
--- a/packages/rrweb/src/replay/embedded/host-document.ts
+++ b/packages/rrweb/src/replay/embedded/host-document.ts
@@ -81,7 +81,7 @@ export function buildHostDocument(options: HostDocumentOptions): string {
-
+
${styleTag}
From e40592f69082899c193110fcf4d137925f46f9be Mon Sep 17 00:00:00 2001
From: mayberryzane <86132398+mayberryzane@users.noreply.github.com>
Date: Wed, 5 Aug 2026 11:21:03 -0700
Subject: [PATCH 11/11] fix(test): handshake before asserting embedded commands
post
The command-queue change buffers client commands until the host 'ready'
handshake, so the two unit tests that issued a command immediately after
construction saw it queued (only the construction ping posted). Complete the
handshake first in those tests, and add coverage that a pre-handshake command
is buffered and flushed on 'ready'.
---
packages/rrweb/test/replay/embedded.test.ts | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/packages/rrweb/test/replay/embedded.test.ts b/packages/rrweb/test/replay/embedded.test.ts
index 6f66c713..d988fe56 100644
--- a/packages/rrweb/test/replay/embedded.test.ts
+++ b/packages/rrweb/test/replay/embedded.test.ts
@@ -117,6 +117,7 @@ describe('EmbeddedReplayerClient', () => {
it('posts namespaced command envelopes', () => {
const { client, posted } = makeClient();
+ deliver({ type: 'ready' }); // handshake first, so commands post instead of queueing
client.play(1234);
expect(posted).toHaveLength(2); // [0] is the construction ping
expect(posted[1]).toMatchObject({
@@ -126,8 +127,22 @@ describe('EmbeddedReplayerClient', () => {
});
});
+ it('buffers commands issued before the handshake and flushes them on ready', () => {
+ const { client, posted } = makeClient();
+ // Before `ready`, a command is held back — only the construction ping posted.
+ client.play(1234);
+ expect(posted).toHaveLength(1);
+ // The handshake flushes the queue, in order.
+ deliver({ type: 'ready' });
+ expect(posted).toHaveLength(2);
+ expect(posted[1]).toMatchObject({
+ message: { type: 'play', timeOffset: 1234 },
+ });
+ });
+
it('sends layout intent, not styles, and defaults the anchor host-side', () => {
const { client, posted } = makeClient();
+ deliver({ type: 'ready' }); // handshake first, so commands post instead of queueing
client.setLayout(0.5, 'top');
expect(posted[1]).toMatchObject({
message: { type: 'setLayout', scale: 0.5, anchor: 'top' },