diff --git a/.changeset/origin-isolated-embedded-replayer.md b/.changeset/origin-isolated-embedded-replayer.md
new file mode 100644
index 00000000..b1af1581
--- /dev/null
+++ b/.changeset/origin-isolated-embedded-replayer.md
@@ -0,0 +1,19 @@
+---
+"rrweb": minor
+---
+
+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`.
+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
+is unchanged.
diff --git a/packages/rrweb/src/index.ts b/packages/rrweb/src/index.ts
index 7851c6e0..b393b511 100644
--- a/packages/rrweb/src/index.ts
+++ b/packages/rrweb/src/index.ts
@@ -22,6 +22,27 @@ import './replay/styles/style.css';
export type { recordOptions, ReplayPlugin } from './types';
+// 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,
+ EmbeddedReplayerClient,
+ buildHostDocument,
+ pickSerializableConfig,
+ RRWEB_EMBEDDED_CHANNEL,
+ RRWEB_EMBEDDED_PROTOCOL_VERSION,
+ type EmbeddedReplayerHostOptions,
+ type EmbeddedReplayerClientOptions,
+ type HostDocumentOptions,
+ type SerializableReplayerConfig,
+ type ReplayAnchor,
+ type ReplayDimensions,
+ 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..1cec707c
--- /dev/null
+++ b/packages/rrweb/src/replay/embedded/client.ts
@@ -0,0 +1,280 @@
+/**
+ * `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 + cached 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`. 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 {
+ isEnvelope,
+ wrap,
+ type HostCommand,
+ type HostMessage,
+ type ReplayAnchor,
+ type SerializableReplayerConfig,
+} from './protocol';
+import type {
+ eventWithTime,
+ playerMetaData,
+ SessionInterval,
+} from '@rrweb/types';
+
+export interface EmbeddedReplayerClientOptions {
+ /**
+ * 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;
+}
+
+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;
+
+ private readonly listeners = new Map>();
+
+ private metadata: playerMetaData | null = null;
+ private activityIntervals: SessionInterval[] = [];
+ private dimensions: ReplayDimensions | null = null;
+ private lastCurrentTime = 0;
+ 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,
+ options: EmbeddedReplayerClientOptions = {},
+ ) {
+ 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 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. */
+ 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 });
+ }
+
+ /**
+ * 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 });
+ }
+
+ 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();
+ }
+ }
+
+ /* --- 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 ---------------------------------------------------------- */
+
+ // 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(): playerMetaData | null {
+ return this.metadata;
+ }
+
+ getActivityIntervals(): SessionInterval[] {
+ 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 {
+ // 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' }), '*');
+ }
+
+ 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.flushPending(); // deliver commands buffered before the handshake
+ this.readyResolvers.forEach((r) => r());
+ this.readyResolvers = [];
+ return;
+ case 'initialized':
+ this.metadata = message.metadata;
+ this.activityIntervals = message.activityIntervals;
+ 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 '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;
+ }
+ }
+
+ 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..4d6deee2
--- /dev/null
+++ b/packages/rrweb/src/replay/embedded/host-document.ts
@@ -0,0 +1,98 @@
+/**
+ * Builds the minimal HTML shell that hosts the embedded replayer inside the
+ * sandboxed iframe.
+ *
+ * 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.
+ *
+ * `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 {
+ /** 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..f0ae83bc
--- /dev/null
+++ b/packages/rrweb/src/replay/embedded/host.ts
@@ -0,0 +1,334 @@
+/**
+ * `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 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
+ * 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 ReplayAnchor,
+} 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 ignores all messages
+ * (and warns once on start).
+ */
+ 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 readonly expectedParentOrigin: string | null;
+
+ private replayer: Replayer | null = null;
+ 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;
+ 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;
+ 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' });
+ }
+
+ /** Tear everything down (also invoked on a `destroy` command). */
+ stop(): void {
+ window.removeEventListener('message', this.onMessage);
+ this.setPlaying(false);
+ this.replayer?.destroy();
+ this.replayer = null;
+ this.started = false;
+ }
+
+ private onMessage = (event: MessageEvent): void => {
+ // 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;
+
+ 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 '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;
+ case 'play':
+ this.replayer?.play(command.timeOffset ?? 0);
+ this.setPlaying(true);
+ return;
+ 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);
+ 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;
+ case 'replaceEvents':
+ if (this.replayer) {
+ this.replayer.replaceEvents(command.events);
+ this.postInitialized(); // metadata/intervals may have changed
+ }
+ 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;
+ }
+ }
+
+ private init(
+ events: eventWithTime[],
+ config: unknown,
+ 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();
+ }
+
+ // 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.applyLayout(); // new Replayer == new wrapper, so re-apply
+ this.postInitialized();
+ this.postDimensions();
+
+ if (autoplay) {
+ this.replayer.play(0);
+ this.setPlaying(true);
+ }
+ }
+
+ /**
+ * 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;
+ 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 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);
+ 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 }
+ : { type: 'replayer-event', event: name },
+ );
+ });
+ }
+ }
+
+ /* --- 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.timer !== null) return;
+ 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 {
+ if (this.timer !== null) {
+ clearInterval(this.timer);
+ this.timer = 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..72306c92
--- /dev/null
+++ b/packages/rrweb/src/replay/embedded/index.ts
@@ -0,0 +1,36 @@
+/**
+ * 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.
+ * - `buildHostDocument`: HTML shell for the sandboxed iframe.
+ */
+
+export {
+ EmbeddedReplayerHost,
+ startEmbeddedReplayerHost,
+ type EmbeddedReplayerHostOptions,
+} from './host';
+export {
+ EmbeddedReplayerClient,
+ type EmbeddedReplayerClientOptions,
+ type ReplayDimensions,
+} 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 ReplayAnchor,
+ type HostCommand,
+ type HostMessage,
+ 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..a19cffd3
--- /dev/null
+++ b/packages/rrweb/src/replay/embedded/protocol.ts
@@ -0,0 +1,207 @@
+/**
+ * Wire protocol for running the rrweb `Replayer` inside an isolated iframe and
+ * driving it from a parent page over `postMessage`.
+ *
+ * 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:
+ *
+ * 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`, which holds regardless of the origin the host document
+ * is served from.
+ */
+
+import type { playerConfig } from '../../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;
+
+/** 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) */
+/* -------------------------------------------------------------------------- */
+
+/**
+ * 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
+ * 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[];
+ 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 }
+ | { type: 'setConfig'; config: SerializableReplayerConfig }
+ | { type: 'replaceEvents'; events: eventWithTime[] }
+ | { type: 'addEvent'; event: eventWithTime }
+ | { type: 'enableInteract' }
+ | { type: 'disableInteract' }
+ | { type: 'destroy' };
+
+/* -------------------------------------------------------------------------- */
+/* Host -> Parent (events / telemetry / replies) */
+/* -------------------------------------------------------------------------- */
+
+export type HostMessage =
+ /** Host script booted and the message listener is installed (pre-init). */
+ | { type: 'ready' }
+ /**
+ * `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?: 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 };
+
+/* -------------------------------------------------------------------------- */
+/* 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/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/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/replay/embedded-e2e.test.ts b/packages/rrweb/test/replay/embedded-e2e.test.ts
new file mode 100644
index 00000000..05d76481
--- /dev/null
+++ b/packages/rrweb/test/replay/embedded-e2e.test.ts
@@ -0,0 +1,304 @@
+/**
+ * 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[],
+ dimensionsCount: 0,
+ };
+ 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));
+ client.on('dimensions', () => {
+ received.dimensionsCount += 1;
+ });
+ /* 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('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('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();
+
+ // 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
new file mode 100644
index 00000000..d988fe56
--- /dev/null
+++ b/packages/rrweb/test/replay/embedded.test.ts
@@ -0,0 +1,317 @@
+// @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';
+import { EmbeddedReplayerHost } from '../../src/replay/embedded/host';
+
+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('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();
+ 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({
+ channel: RRWEB_EMBEDDED_CHANNEL,
+ version: RRWEB_EMBEDDED_PROTOCOL_VERSION,
+ message: { type: 'play', timeOffset: 1234 },
+ });
+ });
+
+ 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' },
+ });
+
+ // 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;
+ 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('caches metadata and activity intervals from the initialized message', () => {
+ const { client } = makeClient();
+ 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', () => {
+ 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);
+ });
+});
+
+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 ffc2a13a..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 {
@@ -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', {