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', {