rrweb extension implementation (#1044)

* feat: add rrweb web-extension package

* refactor: make the extension suitable for manifest v3

* update tsconfig.json

* use version_name rather than recorder_version in manifest.json

* update manifest.json

* enable to keep recording after changing tabs

* enable to record between tabs and urls

* fix CI error

* try to fix CI error

* feat: add pause and resume buttons

* feat: add a link to new session after recording

* improve session list

* refactor: migrate session storage from chrome local storage to indexedDB

* feat: add pagination to session list

* fix: multiple recorders are started after pausing and resuming process

* fix: can't stop recording on firefox browser

* update type import of 'eventWithTime'

* fix CI error

* doc: add readme

* Apply suggestions from Justin's code review

Co-authored-by: Justin Halsall <Juice10@users.noreply.github.com>

* refactor: make use of webNavigation API to implement recording consistent during page navigation

* fix firefox compatibility issue and add title to pages

* add mouseleave listener to enhance the recording liability

* fix firefox compatibility issue and improve the experience of recording resume after closing tabs

* update tsconfig

* upgrade vite-plugin-web-extension config to fix some bugs on facebook web page

* update import links

* refactor: cross tab recording mechanism

apply Justin's suggestion

* refactor: slipt util/index.ts into multiple files

* implement cross-origin iframe recording

* fix: regression of issue: ShadowHost can't be a string (issue 941)

* refactor shadow dom recording to make tests cover key code

* Apply formatting changes

* increase the node memory limitation to avoid CI failure

* Create lovely-pears-cross.md

* Apply formatting changes

* Update packages/web-extension/package.json

* Update .changeset/lovely-pears-cross.md

* update change logs

* delete duplicated property

---------

Co-authored-by: Justin Halsall <Juice10@users.noreply.github.com>
This commit is contained in:
Yun Feng
2023-02-14 10:15:34 +11:00
committed by GitHub
parent 227d43abb9
commit 282c8fa415
40 changed files with 5935 additions and 493 deletions

View File

@@ -0,0 +1,207 @@
import Browser, { Storage } from 'webextension-polyfill';
import { nanoid } from 'nanoid';
import type { eventWithTime } from '@rrweb/types';
import {
LocalData,
LocalDataKey,
RecorderStatus,
ServiceName,
Session,
RecordStartedMessage,
RecordStoppedMessage,
MessageName,
EmitEventMessage,
} from '~/types';
import Channel from '~/utils/channel';
import { isInCrossOriginIFrame } from '~/utils';
const channel = new Channel();
void (() => {
window.addEventListener(
'message',
(
event: MessageEvent<{
message: MessageName;
}>,
) => {
if (event.source !== window) return;
if (event.data.message === MessageName.RecordScriptReady)
window.postMessage(
{
message: MessageName.StartRecord,
config: {
recordCrossOriginIframes: true,
},
},
location.origin,
);
},
);
if (isInCrossOriginIFrame()) {
void initCrossOriginIframe();
} else {
void initMainPage();
}
})();
async function initMainPage() {
let bufferedEvents: eventWithTime[] = [];
let newEvents: eventWithTime[] = [];
let startResponseCb: ((response: RecordStartedMessage) => void) | undefined =
undefined;
channel.provide(ServiceName.StartRecord, async () => {
startRecord();
return new Promise((resolve) => {
startResponseCb = (response) => {
resolve(response);
};
});
});
channel.provide(ServiceName.ResumeRecord, async (params) => {
const { events, pausedTimestamp } = params as {
events: eventWithTime[];
pausedTimestamp: number;
};
bufferedEvents = events;
startRecord();
return new Promise((resolve) => {
startResponseCb = (response) => {
const pausedTime = response.startTimestamp - pausedTimestamp;
// Decrease the time spent in the pause state and make them look like a continuous recording.
bufferedEvents.forEach((event) => {
event.timestamp += pausedTime;
});
resolve(response);
};
});
});
let stopResponseCb: ((response: RecordStoppedMessage) => void) | undefined =
undefined;
channel.provide(ServiceName.StopRecord, () => {
window.postMessage({ message: MessageName.StopRecord });
return new Promise((resolve) => {
stopResponseCb = (response: RecordStoppedMessage) => {
stopResponseCb = undefined;
const newSession = generateSession();
response.session = newSession;
bufferedEvents = [];
newEvents = [];
resolve(response);
// clear cache
void Browser.storage.local.set({
[LocalDataKey.bufferedEvents]: [],
});
};
});
});
channel.provide(ServiceName.PauseRecord, () => {
window.postMessage({ message: MessageName.StopRecord });
return new Promise((resolve) => {
stopResponseCb = (response: RecordStoppedMessage) => {
stopResponseCb = undefined;
bufferedEvents = [];
newEvents = [];
resolve(response);
void Browser.storage.local.set({
[LocalDataKey.bufferedEvents]: response.events,
});
};
});
});
window.addEventListener(
'message',
(
event: MessageEvent<
| RecordStartedMessage
| RecordStoppedMessage
| EmitEventMessage
| {
message: MessageName;
}
>,
) => {
if (event.source !== window) return;
else if (
event.data.message === MessageName.RecordStarted &&
startResponseCb
)
startResponseCb(event.data as RecordStartedMessage);
else if (
event.data.message === MessageName.RecordStopped &&
stopResponseCb
) {
const data = event.data as RecordStoppedMessage;
// On firefox, the event.data is immutable, so we need to clone it to avoid errors.
const newData = {
...data,
};
newData.events = bufferedEvents.concat(data.events);
stopResponseCb(newData);
} else if (event.data.message === MessageName.EmitEvent)
newEvents.push((event.data as EmitEventMessage).event);
},
);
const localData = (await Browser.storage.local.get()) as LocalData;
if (
localData?.[LocalDataKey.recorderStatus]?.status ===
RecorderStatus.RECORDING
) {
startRecord();
bufferedEvents = localData[LocalDataKey.bufferedEvents] || [];
}
// Before unload pages, cache the new events in the local storage.
window.addEventListener('beforeunload', () => {
void Browser.storage.local.set({
[LocalDataKey.bufferedEvents]: bufferedEvents.concat(newEvents),
});
});
}
async function initCrossOriginIframe() {
Browser.storage.local.onChanged.addListener((change) => {
if (change[LocalDataKey.recorderStatus]) {
const statusChange = change[
LocalDataKey.recorderStatus
] as Storage.StorageChange;
const newStatus =
statusChange.newValue as LocalData[LocalDataKey.recorderStatus];
if (newStatus.status === RecorderStatus.RECORDING) startRecord();
else
window.postMessage(
{ message: MessageName.StopRecord },
location.origin,
);
}
});
const localData = (await Browser.storage.local.get()) as LocalData;
if (
localData?.[LocalDataKey.recorderStatus]?.status ===
RecorderStatus.RECORDING
)
startRecord();
}
function startRecord() {
const scriptEl = document.createElement('script');
scriptEl.src = Browser.runtime.getURL('content/inject.js');
document.documentElement.appendChild(scriptEl);
scriptEl.onload = () => {
document.documentElement.removeChild(scriptEl);
};
}
function generateSession() {
const newSession: Session = {
id: nanoid(),
name: document.title,
tags: [],
createTimestamp: Date.now(),
modifyTimestamp: Date.now(),
recorderVersion: Browser.runtime.getManifest().version_name || 'unknown',
};
return newSession;
}

View File

@@ -0,0 +1,72 @@
import { record } from 'rrweb';
import type { recordOptions } from 'rrweb/typings/types';
import type { eventWithTime } from '@rrweb/types';
import { MessageName, RecordStartedMessage } from '~/types';
import { isInCrossOriginIFrame } from '~/utils';
/**
* This script is injected into both main page and cross-origin IFrames through <script> tags.
*/
const events: eventWithTime[] = [];
let stopFn: (() => void) | null = null;
function startRecord(config: recordOptions<eventWithTime>) {
events.length = 0;
stopFn =
record({
emit: (event) => {
events.push(event);
postMessage({
message: MessageName.EmitEvent,
event,
});
},
...config,
}) || null;
postMessage({
message: MessageName.RecordStarted,
startTimestamp: Date.now(),
} as RecordStartedMessage);
}
const messageHandler = (
event: MessageEvent<{
message: MessageName;
config?: recordOptions<eventWithTime>;
}>,
) => {
if (event.source !== window) return;
const data = event.data;
const eventHandler = {
[MessageName.StartRecord]: () => {
startRecord(data.config || {});
},
[MessageName.StopRecord]: () => {
if (stopFn) stopFn();
postMessage({
message: MessageName.RecordStopped,
events,
endTimestamp: Date.now(),
});
window.removeEventListener('message', messageHandler);
},
} as Record<MessageName, () => void>;
if (eventHandler[data.message]) eventHandler[data.message]();
};
/**
* Only post message in the main page.
*/
function postMessage(message: unknown) {
if (!isInCrossOriginIFrame()) window.postMessage(message, location.origin);
}
window.addEventListener('message', messageHandler);
window.postMessage(
{
message: MessageName.RecordScriptReady,
},
location.origin,
);