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
2026-04-01 12:00:00 +08:00
committed by GitHub
parent e5c0eae352
commit b837600e80
40 changed files with 5935 additions and 493 deletions

View 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;

View 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;
}

View 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;
}

View 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]);
});
}