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:
180
packages/web-extension/src/utils/channel.ts
Normal file
180
packages/web-extension/src/utils/channel.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import mitt from 'mitt';
|
||||
import Browser, { Runtime } from 'webextension-polyfill';
|
||||
|
||||
export type Message = EventType | ServiceType;
|
||||
export type EventType = {
|
||||
type: 'event';
|
||||
event: string;
|
||||
detail: unknown;
|
||||
};
|
||||
export type ServiceType = {
|
||||
type: 'service';
|
||||
service: string;
|
||||
params: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Channel for inter-context communication.
|
||||
*
|
||||
* A chrome extension typically contains 4 types of context: background, popup, options and content scripts.
|
||||
* Communication between these contexts relies on
|
||||
* chrome.runtime.sendMessage and chrome.tabs.sendMessage.
|
||||
*
|
||||
* This Class provides two communication model:
|
||||
* * request/response
|
||||
* * event trigger/listen
|
||||
* based on chrome.runtime.sendMessage and chrome.tabs.sendMessage.
|
||||
*/
|
||||
class Channel {
|
||||
private services = new Map<
|
||||
string,
|
||||
(params: unknown, sender: Runtime.MessageSender) => Promise<unknown>
|
||||
>();
|
||||
private emitter = mitt();
|
||||
constructor() {
|
||||
/**
|
||||
* Register massage listener.
|
||||
*/
|
||||
Browser.runtime.onMessage.addListener(
|
||||
((message: string, sender: Runtime.MessageSender) => {
|
||||
const parsed = JSON.parse(message) as Message | null | undefined;
|
||||
if (!parsed || !parsed.type) {
|
||||
console.error(`Bad message: ${message}`);
|
||||
return;
|
||||
}
|
||||
switch (parsed.type) {
|
||||
case 'event':
|
||||
this.emitter.emit(parsed.event, { detail: parsed.detail, sender });
|
||||
break;
|
||||
case 'service': {
|
||||
const server = this.services.get(parsed.service);
|
||||
if (!server) break;
|
||||
return server(parsed.params, sender);
|
||||
}
|
||||
default:
|
||||
console.error(
|
||||
`Unknown message type: ${(parsed as { type: string }).type}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}).bind(this),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide a service.
|
||||
*
|
||||
* @param serviceName - the name of the service, acts like a URL
|
||||
* @param serveFunction - a function to provide the service when a consumer request this service.
|
||||
* @returns a function to remove the service
|
||||
*/
|
||||
public provide(
|
||||
serviceName: string,
|
||||
serveFunction: (
|
||||
params: unknown,
|
||||
sender: Runtime.MessageSender,
|
||||
) => Promise<unknown>,
|
||||
): () => void {
|
||||
this.services.set(serviceName, serveFunction);
|
||||
return () => {
|
||||
this.services.delete(serviceName);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request and get a response.
|
||||
*
|
||||
* @param service - service name to request
|
||||
* @param params - request parameters
|
||||
* @returns service data
|
||||
*/
|
||||
public request(
|
||||
serviceName: string,
|
||||
params: Record<string, unknown> | unknown,
|
||||
) {
|
||||
const message = JSON.stringify({
|
||||
type: 'service',
|
||||
service: serviceName,
|
||||
params,
|
||||
});
|
||||
return Browser.runtime.sendMessage(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request to the specified tab and get a response.
|
||||
*
|
||||
* @param tabId - tab id
|
||||
* @param service - service name to request
|
||||
* @param params - request parameters
|
||||
* @returns service data
|
||||
*/
|
||||
public requestToTab(
|
||||
tabId: number,
|
||||
serviceName: string,
|
||||
params: Record<string, unknown> | unknown,
|
||||
) {
|
||||
if (!Browser.tabs || !Browser.tabs.sendMessage)
|
||||
return Promise.reject('Can not send message to tabs in current context!');
|
||||
const message = JSON.stringify({
|
||||
type: 'service',
|
||||
service: serviceName,
|
||||
params,
|
||||
});
|
||||
return Browser.tabs.sendMessage(tabId, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an event handler.
|
||||
*
|
||||
* @param eventName - event name
|
||||
* @param handler - event handler, accepts two arguments:
|
||||
* detail: event detail
|
||||
* source: source of the event, chrome.runtime.MessageSender object
|
||||
* @returns a function to remove the handler
|
||||
*/
|
||||
public on(event: string, handler: (detail: unknown) => unknown) {
|
||||
return this.emitter.on(event, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event.
|
||||
*
|
||||
* @param event - event name
|
||||
* @param detail - event detail
|
||||
*/
|
||||
public emit(event: string, detail: unknown) {
|
||||
const message = JSON.stringify({ type: 'event', event, detail });
|
||||
void Browser.runtime.sendMessage(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event to specified tabs.
|
||||
*
|
||||
* @param tabIds - tab ids
|
||||
* @param event - event name
|
||||
* @param detail - event detail
|
||||
*/
|
||||
public emitToTabs(tabIds: number | number[], event: string, detail: unknown) {
|
||||
if (!Browser.tabs || !Browser.tabs.sendMessage)
|
||||
return Promise.reject('Can not send message to tabs in current context!');
|
||||
|
||||
// If tabIds is a number, wrap it up with an array.
|
||||
if (typeof tabIds === 'number') {
|
||||
tabIds = [tabIds];
|
||||
}
|
||||
|
||||
const message = JSON.stringify({ type: 'event', event, detail });
|
||||
tabIds.forEach((tabId) => void Browser.tabs.sendMessage(tabId, message));
|
||||
}
|
||||
|
||||
public async getCurrentTabId() {
|
||||
const tabs = await Browser.tabs.query({
|
||||
active: true,
|
||||
currentWindow: true,
|
||||
});
|
||||
return tabs[0].id || -1;
|
||||
}
|
||||
}
|
||||
|
||||
export default Channel;
|
||||
44
packages/web-extension/src/utils/index.ts
Normal file
44
packages/web-extension/src/utils/index.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export function isFirefox(): boolean {
|
||||
return window.navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
|
||||
}
|
||||
|
||||
export function isInCrossOriginIFrame(): boolean {
|
||||
if (window.parent !== window) {
|
||||
try {
|
||||
void window.parent.location.origin;
|
||||
} catch (error) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const SECOND = 1000;
|
||||
const MINUTE = 60 * SECOND;
|
||||
const HOUR = 60 * MINUTE;
|
||||
|
||||
export function formatTime(ms: number): string {
|
||||
if (ms <= 0) {
|
||||
return '00:00';
|
||||
}
|
||||
const hour = Math.floor(ms / HOUR);
|
||||
ms = ms % HOUR;
|
||||
const minute = Math.floor(ms / MINUTE);
|
||||
ms = ms % MINUTE;
|
||||
const second = Math.floor(ms / SECOND);
|
||||
if (hour) {
|
||||
return `${padZero(hour)}:${padZero(minute)}:${padZero(second)}`;
|
||||
}
|
||||
return `${padZero(minute)}:${padZero(second)}`;
|
||||
}
|
||||
|
||||
function padZero(num: number, len = 2): string {
|
||||
let str = String(num);
|
||||
const threshold = Math.pow(10, len - 1);
|
||||
if (num < threshold) {
|
||||
while (String(threshold).length > str.length) {
|
||||
str = `0${num}`;
|
||||
}
|
||||
}
|
||||
return str;
|
||||
}
|
||||
90
packages/web-extension/src/utils/recording.ts
Normal file
90
packages/web-extension/src/utils/recording.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import Browser from 'webextension-polyfill';
|
||||
import type { eventWithTime } from '@rrweb/types';
|
||||
|
||||
import {
|
||||
LocalData,
|
||||
LocalDataKey,
|
||||
RecorderStatus,
|
||||
RecordStartedMessage,
|
||||
RecordStoppedMessage,
|
||||
ServiceName,
|
||||
} from '~/types';
|
||||
import type Channel from './channel';
|
||||
import { isFirefox } from '.';
|
||||
|
||||
/**
|
||||
* Some commonly used functions for session recording.
|
||||
*/
|
||||
|
||||
// Pause recording.
|
||||
export async function pauseRecording(
|
||||
channel: Channel,
|
||||
newStatus: RecorderStatus,
|
||||
status?: LocalData[LocalDataKey.recorderStatus],
|
||||
) {
|
||||
if (!status)
|
||||
status = (await Browser.storage.local.get(LocalDataKey.recorderStatus))[
|
||||
LocalDataKey.recorderStatus
|
||||
] as LocalData[LocalDataKey.recorderStatus];
|
||||
const { startTimestamp, activeTabId } = status;
|
||||
const stopResponse = (await channel.requestToTab(
|
||||
activeTabId,
|
||||
ServiceName.PauseRecord,
|
||||
{},
|
||||
)) as RecordStoppedMessage;
|
||||
if (!stopResponse) return;
|
||||
const statusData: LocalData[LocalDataKey.recorderStatus] = {
|
||||
status: newStatus,
|
||||
activeTabId,
|
||||
startTimestamp,
|
||||
pausedTimestamp: stopResponse.endTimestamp,
|
||||
};
|
||||
await Browser.storage.local.set({
|
||||
[LocalDataKey.recorderStatus]: statusData,
|
||||
});
|
||||
return {
|
||||
status: statusData,
|
||||
bufferedEvents: stopResponse.events,
|
||||
};
|
||||
}
|
||||
|
||||
// Resume recording after change to a new tab.
|
||||
export async function resumeRecording(
|
||||
channel: Channel,
|
||||
newTabId: number,
|
||||
status?: LocalData[LocalDataKey.recorderStatus],
|
||||
bufferedEvents?: eventWithTime[],
|
||||
) {
|
||||
if (!status)
|
||||
status = (await Browser.storage.local.get(LocalDataKey.recorderStatus))[
|
||||
LocalDataKey.recorderStatus
|
||||
] as LocalData[LocalDataKey.recorderStatus];
|
||||
if (!bufferedEvents)
|
||||
bufferedEvents = (
|
||||
(await Browser.storage.local.get(
|
||||
LocalDataKey.bufferedEvents,
|
||||
)) as LocalData
|
||||
)[LocalDataKey.bufferedEvents];
|
||||
const { startTimestamp, pausedTimestamp } = status;
|
||||
// On Firefox, the new tab is not communicable immediately after it is created.
|
||||
if (isFirefox()) await new Promise((r) => setTimeout(r, 50));
|
||||
const startResponse = (await channel.requestToTab(
|
||||
newTabId,
|
||||
ServiceName.ResumeRecord,
|
||||
{ events: bufferedEvents, pausedTimestamp },
|
||||
)) as RecordStartedMessage;
|
||||
if (!startResponse) return;
|
||||
const pausedTime = pausedTimestamp
|
||||
? startResponse.startTimestamp - pausedTimestamp
|
||||
: 0;
|
||||
const statusData: LocalData[LocalDataKey.recorderStatus] = {
|
||||
status: RecorderStatus.RECORDING,
|
||||
activeTabId: newTabId,
|
||||
startTimestamp:
|
||||
(startTimestamp || bufferedEvents[0].timestamp) + pausedTime,
|
||||
};
|
||||
await Browser.storage.local.set({
|
||||
[LocalDataKey.recorderStatus]: statusData,
|
||||
});
|
||||
return statusData;
|
||||
}
|
||||
90
packages/web-extension/src/utils/storage.ts
Normal file
90
packages/web-extension/src/utils/storage.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { openDB } from 'idb';
|
||||
import { eventWithTime } from '@rrweb/types';
|
||||
import { Session } from '~/types';
|
||||
|
||||
/**
|
||||
* Storage related functions with indexedDB.
|
||||
*/
|
||||
|
||||
const EventStoreName = 'events';
|
||||
type EventData = {
|
||||
id: string;
|
||||
events: eventWithTime[];
|
||||
};
|
||||
|
||||
export async function getEventStore() {
|
||||
return openDB<EventData>(EventStoreName, 1, {
|
||||
upgrade(db) {
|
||||
db.createObjectStore(EventStoreName, {
|
||||
keyPath: 'id',
|
||||
autoIncrement: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getEvents(id: string) {
|
||||
const db = await getEventStore();
|
||||
const data = (await db.get(EventStoreName, id)) as EventData;
|
||||
return data.events;
|
||||
}
|
||||
|
||||
const SessionStoreName = 'sessions';
|
||||
export async function getSessionStore() {
|
||||
return openDB<Session>(SessionStoreName, 1, {
|
||||
upgrade(db) {
|
||||
// Create a store of objects
|
||||
db.createObjectStore(SessionStoreName, {
|
||||
// The 'id' property of the object will be the key.
|
||||
keyPath: 'id',
|
||||
// If it isn't explicitly set, create a value by auto incrementing.
|
||||
autoIncrement: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveSession(session: Session, events: eventWithTime[]) {
|
||||
const eventStore = await getEventStore();
|
||||
await eventStore.put(EventStoreName, { id: session.id, events });
|
||||
const store = await getSessionStore();
|
||||
await store.add(SessionStoreName, session);
|
||||
}
|
||||
|
||||
export async function getSession(id: string) {
|
||||
const store = await getSessionStore();
|
||||
return store.get(SessionStoreName, id) as Promise<Session>;
|
||||
}
|
||||
|
||||
export async function getAllSessions() {
|
||||
const store = await getSessionStore();
|
||||
const sessions = (await store.getAll(SessionStoreName)) as Session[];
|
||||
return sessions.sort((a, b) => b.createTimestamp - a.createTimestamp);
|
||||
}
|
||||
|
||||
export async function deleteSession(id: string) {
|
||||
const eventStore = await getEventStore();
|
||||
const sessionStore = await getSessionStore();
|
||||
await Promise.all([
|
||||
eventStore.delete(EventStoreName, id),
|
||||
sessionStore.delete(SessionStoreName, id),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function deleteSessions(ids: string[]) {
|
||||
const eventStore = await getEventStore();
|
||||
const sessionStore = await getSessionStore();
|
||||
const eventTransition = eventStore.transaction(EventStoreName, 'readwrite');
|
||||
const sessionTransition = sessionStore.transaction(
|
||||
SessionStoreName,
|
||||
'readwrite',
|
||||
);
|
||||
const promises = [];
|
||||
for (const id of ids) {
|
||||
promises.push(eventTransition.store.delete(id));
|
||||
promises.push(sessionTransition.store.delete(id));
|
||||
}
|
||||
await Promise.all(promises).then(() => {
|
||||
return Promise.all([eventTransition.done, sessionTransition.done]);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user