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:
207
packages/web-extension/src/content/index.ts
Normal file
207
packages/web-extension/src/content/index.ts
Normal 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;
|
||||
}
|
||||
72
packages/web-extension/src/content/inject.ts
Normal file
72
packages/web-extension/src/content/inject.ts
Normal 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,
|
||||
);
|
||||
Reference in New Issue
Block a user