Files
rrweb/packages/rrweb/scripts/stream.js
Justin Halsall 2a80949948 Cross origin iframe support (#1035)
* Add `recordCrossOriginIframe` setting

* Set up messaging between iframes

* should emit full snapshot event from iframe as mutation event

* this.mirror was dropped on attachIframe

* should use unique id for child of iframe

* Cross origin iframe recording in `yarn live-stream`

* Root iframe check thats supported by firefox

* Live stream: Inject script in all frames

* Record same origin and cross origin iframes differently

* Should map Input events correctly

* Turn on other tests

* Fix compatibility with newer puppeteer

* puppeteer vs 12 seems stable without to many changes needed

* normalize port numbers in snapshots

* Handle scroll and ViewportResize events in cross origin iframe

* Correctly map cross origin mutations

* Map selection events for cross origin iframes

* Map canvas mutations for cross origin iframes

* Update snapshot to include canvas events

* Skip all meta events

* Support custom events as best we can in cross origin iframes

* Use earliest version of puppeteer that works with cross origin live-stream

* Map mouse/touch interaction events

* Update snapshots for correctly mapped click events

* Tweak tests for new puppeteer version

* Map MediaInteraction correctly for cross origin iframes

* Make tests consistent between high and low dpi devices

* Make test less flaky

* Make test less flaky

* Make test less flaky

* Make test less flaky

* Add support for styles in cross origin iframes

* Map traditional stylesheet mutations on cross origin iframes

* Add todo

* Add iframe mirror

* Get iframe manager to use iframe mirrors internally

* Rename `IframeMirror` to `CrossOriginIframeMirror`

* Setup basic cross origin canvas webrtc streaming

* Clean up removed canvas elements

* reset style mirror on new full snapshot

* Fix cross origin canvas webrtc streaming

* Make emit optional

* Run tests on github actions

* Upload image artifacts from failed tests

* Use newer github actions

* Test: hopefully adding more wait will fix it

* add extra wait

* Fix image snapshot tests

* Make tests run with new puppeteer version

* upgrade eslint-plugin-jest

* Chore: Remove travis ci as ci's running on github actions

* Chore: Support recording cross origin iframe in repl

* Force developers to update the cross origin iframe mapping when adding new events

https://github.com/rrweb-io/rrweb/pull/1035#discussion_r1012516277

* Document cross origin iframe recording

* Docs: cross origin iframes recording methods

* Docs: AI translated, cross origin iframe recording

* rename getParentId to getId

* Migrate to @rrweb/types

* Run on pull request

* doc: improve Chinese doc

* Rename `parentId` to `Id`

Co-authored-by: Mark-Fenng <f18846188605@gmail.com>
2022-11-16 13:11:11 +08:00

271 lines
7.3 KiB
JavaScript

/* eslint:disable: no-console */
import * as path from 'path';
import * as fs from 'fs';
import { EventEmitter } from 'node:events';
import inquirer from 'inquirer';
import puppeteer from 'puppeteer';
import { fileURLToPath } from 'url';
import { startServer, getServerURL } from './utils.js';
// Turn on devtools for debugging:
const devtools = false;
const defaultURL = 'https://webglsamples.org/';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const emitter = new EventEmitter();
const code = fs.readFileSync(path.join(__dirname, '../dist/rrweb.js'), 'utf8');
const pluginCode = fs.readFileSync(
path.join(__dirname, '../dist/plugins/canvas-webrtc-record.js'),
'utf8',
);
async function injectRecording(frame) {
await frame.evaluate(
(rrwebCode, pluginCode) => {
const win = window;
if (win.__IS_RECORDING__) return;
win.__IS_RECORDING__ = true;
(async () => {
function loadScript(code) {
const s = document.createElement('script');
s.type = 'text/javascript';
s.innerHTML = code;
if (document.head) {
document.head.append(s);
} else {
requestAnimationFrame(() => {
document.head.append(s);
});
}
}
loadScript(rrwebCode);
loadScript(pluginCode);
win.events = [];
window.record = win.rrweb.record;
window.plugin = new rrwebCanvasWebRTCRecord.RRWebPluginCanvasWebRTCRecord(
{
signalSendCallback: (msg) => {
// [record#callback] provides canvas id, stream, and webrtc sdpOffer signal & connect message
_signal(msg);
},
},
);
window.record({
emit: (event) => {
win.events.push(event);
win._captureEvent(event);
},
plugins: [window.plugin.initPlugin()],
recordCanvas: false,
recordCrossOriginIframes: true,
collectFonts: true,
inlineImages: true,
});
})();
},
code,
pluginCode,
);
}
async function startReplay(page, serverURL, recordedPage) {
await recordedPage.exposeFunction('_signal', async (signal) => {
await page.evaluate((signal) => {
// [replay#signalReceive] setups up peer and starts creating counter offer
window.plugin.signalReceive(signal);
}, signal);
});
await page.exposeFunction('_signal', async (signal) => {
await recordedPage.evaluate((signal) => {
// [record#signalReceive] setups up webrtc connection
window.plugin.signalReceive(signal);
}, signal);
});
await page.exposeFunction('_canvas', async (id) => {
await recordedPage.evaluate((id) => {
// [record#setupStream] sets up the canvas stream for a given id.
const stream = window.plugin.setupStream(id);
console.log('stream for', id, '=>', stream);
}, id);
});
await page.addScriptTag({ url: `${serverURL}/rrweb.js` });
await page.addScriptTag({
url: `${serverURL}/plugins/canvas-webrtc-replay.js`,
});
return page.evaluate(() => {
window.plugin = new rrwebCanvasWebRTCReplay.RRWebPluginCanvasWebRTCReplay({
canvasFoundCallback(canvas, context) {
console.log('canvas', canvas, context);
// [replay#onBuild] gets id of canvas element and sends to recorded page
_canvas(context.id);
},
signalSendCallback(data) {
_signal(JSON.stringify(data));
},
});
window.replayer = new rrweb.Replayer([], {
UNSAFE_replayCanvas: true,
liveMode: true,
plugins: [window.plugin.initPlugin()],
});
window.replayer.startLive();
const style = new CSSStyleSheet();
style.replaceSync('body {margin: 0;} iframe {border: none;}');
document.adoptedStyleSheets = [style];
});
}
async function resizeWindow(page, top, left, width, height) {
const session = await page.target().createCDPSession();
await page.setViewport({ height, width });
const { windowId } = await session.send('Browser.getWindowForTarget');
await session.send('Browser.setWindowBounds', {
bounds: { top, left, height, width },
windowId,
});
}
void (async () => {
let server;
let serverURL;
await start();
async function start() {
server = await startServer();
serverURL = getServerURL(server);
const { url } = await inquirer.prompt([
{
type: 'input',
name: 'url',
message: `Enter the url you want to record, e.g ${defaultURL}: `,
},
]);
await record(url);
const { shouldRecordAnother } = await inquirer.prompt([
{
type: 'confirm',
name: 'shouldRecordAnother',
message: 'Record another one?',
},
]);
emitter.emit('done');
if (shouldRecordAnother) {
await start();
} else {
process.exit();
}
}
async function record(url) {
if (url === '') url = defaultURL;
const replayingBrowser = await puppeteer.launch({
headless: false,
devtools,
defaultViewport: {
width: 1600,
height: 900,
},
args: [
'--start-maximized',
'--ignore-certificate-errors',
'--no-sandbox',
],
});
const replayerPage = (await replayingBrowser.pages())[0];
await replayerPage.goto('about:blank');
await replayerPage.addStyleTag({
path: path.resolve(__dirname, '../dist/rrweb.css'),
});
const recordingBrowser = await puppeteer.launch({
headless: false,
devtools,
defaultViewport: {
width: 1600,
height: 900,
},
args: [
'--start-maximized',
'--ignore-certificate-errors',
'--no-sandbox',
],
});
const recordedPage = (await recordingBrowser.pages())[0];
if (!recordedPage) {
throw new Error('No recorded page found');
}
// disables content security policy which enables us to insert rrweb as a script tag
await recordedPage.setBypassCSP(true);
replayerPage.on('console', (msg) =>
console.log('REPLAY PAGE LOG:', msg.text()),
);
recordedPage.on('console', (msg) =>
console.log('RECORD PAGE LOG:', msg.text()),
);
await startReplay(replayerPage, serverURL, recordedPage);
await Promise.all([
resizeWindow(recordedPage, 0, 0, 800, 800),
resizeWindow(replayerPage, 0, 800, 800, 800),
]);
await recordedPage.exposeFunction('_captureEvent', (event) => {
replayerPage.evaluate((event) => {
window.replayer.addEvent(event);
}, event);
});
recordedPage.on('framenavigated', async (frame) => {
console.log('framenavigated');
await injectRecording(frame, serverURL);
});
await recordedPage.goto(url, {
waitUntil: 'domcontentloaded',
timeout: 300000,
});
if (!replayerPage) throw new Error('No replayer page found');
emitter.once('done', async () => {
const pages = [
...(await recordingBrowser.pages()),
...(await replayingBrowser.pages()),
];
await server.close();
await Promise.all(pages.map((page) => page.close()));
await recordingBrowser.close();
await replayingBrowser.close();
});
}
process
.on('uncaughtException', (error) => {
console.error(error);
})
.on('unhandledRejection', (error) => {
console.error(error);
});
})();