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

@@ -1,2 +0,0 @@
---
---

View File

@@ -0,0 +1,5 @@
---
'@rrweb/web-extension': patch
---
Add rrweb browser extension

View File

@@ -6,4 +6,4 @@
'rrweb-snapshot': patch
---
- [`fe69bd6`](https://github.com/rrweb-io/rrweb/commit/fe69bd6456cead304bfc77cf72c9db0f8c030842) [#1087](https://github.com/rrweb-io/rrweb/pull/1087) Thanks [@YunFeng0817](https://github.com/YunFeng0817)! - Refactor all suffix of bundled scripts with commonjs module from 'js' to cjs.
Refactor all suffix of bundled scripts with commonjs module from 'js' to cjs [#1087](https://github.com/rrweb-io/rrweb/pull/1087).

View File

@@ -1,2 +0,0 @@
---
---

View File

@@ -3,4 +3,4 @@
'rrweb': patch
---
- [`4ee86fe`](https://github.com/rrweb-io/rrweb/commit/4ee86fe66d3e1fe7071f9c8764d82a6fa5c71d57) [#1091](https://github.com/rrweb-io/rrweb/pull/1091) Thanks [@YunFeng0817](https://github.com/YunFeng0817)! - Fix: improve rrdom robustness.
Fix: improve rrdom robustness [#1091](https://github.com/rrweb-io/rrweb/pull/1091).

View File

@@ -24,7 +24,7 @@ jobs:
run: yarn
- name: Build Project
run: yarn build:all
run: NODE_OPTIONS='--max-old-space-size=4096' yarn build:all
- name: Check types
run: yarn turbo run check-types

View File

@@ -27,7 +27,7 @@ jobs:
id: changesets
uses: changesets/action@v1
with:
publish: yarn run release
publish: NODE_OPTIONS='--max-old-space-size=4096' yarn run release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -20,7 +20,7 @@ jobs:
- name: Install Dependencies
run: yarn
- name: Build Packages
run: yarn build:all
run: NODE_OPTIONS='--max-old-space-size=4096' yarn build:all
- name: Eslint Check
run: yarn turbo run lint
- name: Save Code Linting Report JSON

View File

@@ -25,9 +25,10 @@
"path": "../packages/rrweb-snapshot"
},
{
"name": "@rrweb/types",
"path": "../packages/types"
}
"name": "web-extension (package)",
"path": "../packages/web-extension"
},
{ "name": "@rrweb/types", "path": "../packages/types" }
],
"settings": {
"jest.disabledWorkspaceFolders": [

View File

@@ -0,0 +1,32 @@
<p align="center">
<img width="100px" height="100px" src="https://www.rrweb.io/favicon.png">
</p>
# rrweb extension
The package web-extension provides a browser extension for recording and replaying web pages.
## Installation
```
yarn install
```
## Build
```bash
# build for chrome
yarn build:chrome
# build for firefox
yarn build:firefox
```
## Development
```bash
# start a development chrome browser
yarn dev:chrome
# start a development firefox browser
yarn dev:firefox
```

View File

@@ -0,0 +1,47 @@
{
"name": "@rrweb/web-extension",
"private": true,
"version": "2.0.0",
"description": "The web extension of rrweb which helps to run rrweb on any website out of box",
"author": "rrweb-io",
"license": "MIT",
"scripts": {
"dev:chrome": "cross-env TARGET_BROWSER=chrome vite dev",
"dev:firefox": "cross-env TARGET_BROWSER=firefox vite dev",
"build:chrome": "cross-env TARGET_BROWSER=chrome vite build",
"build:firefox": "cross-env TARGET_BROWSER=firefox vite build",
"pack:chrome": "cross-env TARGET_BROWSER=chrome ZIP=true vite build",
"pack:firefox": "cross-env TARGET_BROWSER=firefox ZIP=true vite build",
"check-types": "tsc -noEmit",
"prepublish": "npm run pack:chrome && npm run pack:firefox"
},
"devDependencies": {
"@rrweb/types": "^2.0.0-alpha.4",
"@types/react-dom": "^18.0.6",
"@types/webextension-polyfill": "^0.9.1",
"@vitejs/plugin-react": "^2.1.0",
"cross-env": "^7.0.3",
"type-fest": "^2.19.0",
"typescript": "^4.7.3",
"vite": "^3.1.8",
"vite-plugin-web-extension": "^1.4.5",
"vite-plugin-zip": "^1.0.1",
"webextension-polyfill": "^0.10.0"
},
"dependencies": {
"@chakra-ui/react": "^2.3.4",
"@emotion/react": "^11.10.4",
"@emotion/styled": "^11.10.4",
"@tanstack/react-table": "^8.5.22",
"framer-motion": "^7.3.6",
"idb": "^7.1.1",
"mitt": "^3.0.0",
"nanoid": "^4.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^4.4.0",
"react-router-dom": "^6.4.1",
"rrweb": "^2.0.0-alpha.4",
"rrweb-player": "^1.0.0-alpha.4"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@@ -0,0 +1,162 @@
import Browser from 'webextension-polyfill';
import type { eventWithTime } from '@rrweb/types';
import Channel from '~/utils/channel';
import {
LocalData,
LocalDataKey,
RecorderStatus,
Settings,
SyncData,
SyncDataKey,
} from '~/types';
import { pauseRecording, resumeRecording } from '~/utils/recording';
const channel = new Channel();
void (async () => {
// assign default value to settings of this extension
const result =
((await Browser.storage.sync.get(SyncDataKey.settings)) as SyncData) ||
undefined;
const defaultSettings: Settings = {};
let settings = defaultSettings;
if (result && result.settings) {
setDefaultSettings(result.settings, defaultSettings);
settings = result.settings;
}
await Browser.storage.sync.set({
settings,
} as SyncData);
// When tab is changed during the recording process, pause recording in the old tab and start a new one in the new tab.
Browser.tabs.onActivated.addListener((activeInfo) => {
Browser.storage.local
.get(LocalDataKey.recorderStatus)
.then(async (data) => {
const localData = data as LocalData;
if (!localData || !localData[LocalDataKey.recorderStatus]) return;
let statusData = localData[LocalDataKey.recorderStatus];
let { status } = statusData;
let bufferedEvents: eventWithTime[] | undefined;
if (status === RecorderStatus.RECORDING) {
const result = await pauseRecording(
channel,
RecorderStatus.PausedSwitch,
statusData,
).catch(async () => {
/**
* This error happen when the old tab is closed.
* In this case, the recording process would be stopped through Browser.tabs.onRemoved API.
* So we just read the new status here.
*/
const localData = (await Browser.storage.local.get(
LocalDataKey.recorderStatus,
)) as LocalData;
return {
status: localData[LocalDataKey.recorderStatus],
bufferedEvents,
};
});
if (!result) return;
statusData = result.status;
status = statusData.status;
bufferedEvents = result.bufferedEvents;
}
if (status === RecorderStatus.PausedSwitch)
await resumeRecording(
channel,
activeInfo.tabId,
statusData,
bufferedEvents,
);
})
.catch(() => {
// the extension can't access to the tab
});
});
// If the recording can't start on an invalid tab, resume it when the tab content is updated.
Browser.tabs.onUpdated.addListener(function (tabId, info) {
if (info.status !== 'complete') return;
Browser.storage.local
.get(LocalDataKey.recorderStatus)
.then(async (data) => {
const localData = data as LocalData;
if (!localData || !localData[LocalDataKey.recorderStatus]) return;
const { status, activeTabId } = localData[LocalDataKey.recorderStatus];
if (status !== RecorderStatus.PausedSwitch || activeTabId === tabId)
return;
await resumeRecording(
channel,
tabId,
localData[LocalDataKey.recorderStatus],
);
})
.catch(() => {
// the extension can't access to the tab
});
});
/**
* When the current tab is closed, the recording events will be lost because this event is fired after it is closed.
* This event listener is just used to make sure the recording status is updated.
*/
Browser.tabs.onRemoved.addListener((tabId) => {
Browser.storage.local
.get(LocalDataKey.recorderStatus)
.then(async (data) => {
const localData = data as LocalData;
if (!localData || !localData[LocalDataKey.recorderStatus]) return;
const { status, activeTabId, startTimestamp } =
localData[LocalDataKey.recorderStatus];
if (activeTabId !== tabId || status !== RecorderStatus.RECORDING)
return;
// Update the recording status to make it resumable after users switch to other tabs.
const statusData: LocalData[LocalDataKey.recorderStatus] = {
status: RecorderStatus.PausedSwitch,
activeTabId,
startTimestamp,
pausedTimestamp: Date.now(),
};
await Browser.storage.local.set({
[LocalDataKey.recorderStatus]: statusData,
});
})
.catch((err) => {
console.error(err);
});
});
})();
/**
* Update existed settings with new settings.
* Set new setting values if these properties don't exist in older versions.
*/
function setDefaultSettings(
existedSettings: Record<string, unknown>,
newSettings: Record<string, unknown>,
) {
for (const i in newSettings) {
// settings[i] contains key-value settings
if (
typeof newSettings[i] === 'object' &&
!(newSettings[i] instanceof Array) &&
Object.keys(newSettings[i] as Record<string, unknown>).length > 0
) {
if (existedSettings[i]) {
setDefaultSettings(
existedSettings[i] as Record<string, unknown>,
newSettings[i] as Record<string, unknown>,
);
} else {
// settings[i] contains several setting items but these have not been set before
existedSettings[i] = newSettings[i];
}
} else if (existedSettings[i] === undefined) {
// settings[i] is a single setting item and it has not been set before
existedSettings[i] = newSettings[i];
}
}
}

View File

@@ -0,0 +1,33 @@
import { Button, ButtonProps } from '@chakra-ui/react';
interface CircleButtonProps extends ButtonProps {
diameter: number;
onClick?: () => void;
children?: React.ReactNode;
title?: string;
}
export function CircleButton({
diameter,
onClick,
children,
title,
...rest
}: CircleButtonProps) {
return (
<Button
w={`${diameter}rem`}
h={`${diameter}rem`}
padding={`${diameter / 2}rem`}
borderRadius={9999}
textAlign="center"
bgColor="gray.100"
boxSizing="content-box"
onClick={onClick}
title={title}
{...rest}
>
{children}
</Button>
);
}

View File

@@ -0,0 +1,290 @@
import { ReactNode } from 'react';
import {
IconButton,
Box,
CloseButton,
Flex,
HStack,
Icon,
Image,
useColorModeValue,
Link,
Drawer,
DrawerContent,
useDisclosure,
BoxProps,
FlexProps,
Heading,
Stack,
Text,
Popover,
PopoverTrigger,
PopoverContent,
} from '@chakra-ui/react';
import { FiChevronRight, FiMenu } from 'react-icons/fi';
import type { IconType } from 'react-icons';
import Browser from 'webextension-polyfill';
export interface SideBarItem {
label: string;
icon: IconType;
href: string;
}
export interface HeadBarItem {
label: string;
subLabel?: string;
children?: Array<HeadBarItem>;
href?: string;
}
export default function SidebarWithHeader({
children,
title,
headBarItems,
sideBarItems,
}: {
title?: string;
sideBarItems: SideBarItem[];
headBarItems: SideBarItem[];
children: ReactNode;
}) {
const { isOpen, onOpen, onClose } = useDisclosure();
return (
<Box minH="100vh">
<SidebarContent
sideBarItems={sideBarItems}
onClose={() => onClose}
display={{ base: 'none', md: 'block' }}
title={title}
/>
<Drawer
autoFocus={false}
isOpen={isOpen}
placement="left"
onClose={onClose}
returnFocusOnClose={false}
onOverlayClick={onClose}
size="full"
>
<DrawerContent>
<SidebarContent
onClose={onClose}
sideBarItems={sideBarItems}
title={title}
/>
</DrawerContent>
</Drawer>
<MobileNav onOpen={onOpen}>
<Flex
alignItems={'center'}
display={{ base: 'none', md: 'flex' }}
ml={10}
>
<DesktopNav headBarItems={headBarItems} />
</Flex>
</MobileNav>
<Box ml={{ base: 0, md: 60 }}>{children}</Box>
</Box>
);
}
interface SidebarProps extends BoxProps {
onClose: () => void;
title?: string;
sideBarItems: SideBarItem[];
}
const SidebarContent = ({
onClose,
sideBarItems,
title,
...rest
}: SidebarProps) => {
return (
<Box
transition="3s ease"
bg={useColorModeValue('white', 'gray.900')}
borderRight="1px"
borderRightColor={useColorModeValue('gray.200', 'gray.700')}
w={{ base: 'full', md: 60 }}
pos="fixed"
h="full"
{...rest}
>
<Flex h="20" alignItems="center" mx="8" justify="flex-start" gap="3">
<Link href="https://github.com/rrweb-io/rrweb" target="_blank">
<Image
borderRadius="md"
boxSize="2rem"
src={Browser.runtime.getURL('assets/icon128.png')}
alt="RRWeb Logo"
/>
</Link>
{title && (
<Heading as="h4" size="md">
{title}
</Heading>
)}
<CloseButton display={{ base: 'flex', md: 'none' }} onClick={onClose} />
</Flex>
{sideBarItems.map((link) => (
<NavItem key={link.label} icon={link.icon} href={link.href}>
{link.label}
</NavItem>
))}
</Box>
);
};
interface NavItemProps extends FlexProps {
icon: IconType;
href: string;
children: string;
}
const NavItem = ({ icon, href, children, ...rest }: NavItemProps) => {
return (
<Link
href={href}
style={{ textDecoration: 'none' }}
_focus={{ boxShadow: 'none' }}
fontSize="lg"
fontWeight={500}
>
<Flex
align="center"
p="4"
mx="4"
borderRadius="lg"
role="group"
cursor="pointer"
_hover={{
bg: 'gray.200',
}}
{...rest}
>
<>
{icon && <Icon mr="4" fontSize="16" as={icon} />}
{children}
</>
</Flex>
</Link>
);
};
interface MobileProps extends FlexProps {
onOpen: () => void;
}
const MobileNav = ({ onOpen, ...rest }: MobileProps) => {
return (
<Flex
ml={{ base: 0, md: 60 }}
px={{ base: 4, md: 4 }}
height="20"
alignItems="center"
bg={useColorModeValue('white', 'gray.900')}
borderBottomWidth="1px"
borderBottomColor={useColorModeValue('gray.200', 'gray.700')}
justifyContent={{ base: 'space-between', md: 'flex-start' }}
{...rest}
>
<IconButton
display={{ base: 'flex', md: 'none' }}
onClick={onOpen}
variant="outline"
aria-label="open menu"
icon={<FiMenu />}
/>
<HStack spacing={{ base: '0', md: '6' }}>
{rest.children && rest.children}
</HStack>
</Flex>
);
};
const DesktopNav = ({ headBarItems }: { headBarItems: HeadBarItem[] }) => {
const linkColor = useColorModeValue('gray.600', 'gray.200');
const linkHoverColor = useColorModeValue('gray.800', 'white');
const popoverContentBgColor = useColorModeValue('white', 'gray.800');
return (
<Stack direction={'row'} spacing={4}>
{headBarItems.map((navItem) => (
<Box key={navItem.label}>
<Popover trigger={'hover'} placement={'bottom-start'}>
<PopoverTrigger>
<Link
p={2}
href={navItem.href ?? '#'}
fontSize={'sm'}
fontWeight={500}
color={linkColor}
_hover={{
textDecoration: 'none',
color: linkHoverColor,
}}
>
{navItem.label}
</Link>
</PopoverTrigger>
{navItem.children && (
<PopoverContent
border={0}
boxShadow={'xl'}
bg={popoverContentBgColor}
p={4}
rounded={'xl'}
minW={'sm'}
>
<Stack>
{navItem.children.map((child) => (
<DesktopSubNav key={child.label} {...child} />
))}
</Stack>
</PopoverContent>
)}
</Popover>
</Box>
))}
</Stack>
);
};
const DesktopSubNav = ({ label, href, subLabel }: HeadBarItem) => {
return (
<Link
href={href}
role={'group'}
display={'block'}
p={2}
rounded={'md'}
_hover={{ bg: useColorModeValue('pink.50', 'gray.900') }}
>
<Stack direction={'row'} align={'center'}>
<Box>
<Text
transition={'all .3s ease'}
_groupHover={{ color: 'pink.400' }}
fontWeight={500}
>
{label}
</Text>
<Text fontSize={'sm'}>{subLabel}</Text>
</Box>
<Flex
transition={'all .3s ease'}
transform={'translateX(-10px)'}
opacity={0}
_groupHover={{ opacity: '100%', transform: 'translateX(0)' }}
justify={'flex-end'}
align={'center'}
flex={1}
>
<Icon color={'pink.400'} w={5} h={5} as={FiChevronRight} />
</Flex>
</Stack>
</Link>
);
};

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,
);

View File

@@ -0,0 +1,74 @@
{
"common": {
"name": "rrweb",
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content/index.ts"],
"all_frames": true,
"run_at": "document_idle"
}
],
"icons": {
"16": "assets/icon16.png",
"48": "assets/icon48.png",
"128": "assets/icon128.png"
},
"permissions": ["activeTab", "tabs", "storage", "unlimitedStorage"]
},
"v2": {
"common": {
"manifest_version": 2,
"background": {
"persistent": false,
"scripts": ["background/index.ts"]
},
"browser_action": {
"default_title": "rrweb extension",
"default_popup": "popup/popup.html",
"browser_style": false
},
"options_ui": {
"page": "options/index.html",
"open_in_tab": true,
"chrome_style": false
},
"web_accessible_resources": ["**/*.js"]
},
"chrome": {},
"firefox": {
"browser_specific_settings": {
"gecko": {
"id": "rrweb@rrweb.io",
"strict_min_version": "42.0"
}
}
}
},
"v3": {
"common": {
"manifest_version": 3,
"background": {
"service_worker": "background/index.ts",
"type": "module"
},
"action": {
"default_title": "rrweb extension",
"default_popup": "popup/popup.html",
"browser_style": false
},
"options_ui": {
"page": "options/index.html",
"open_in_tab": true
},
"web_accessible_resources": [
{
"resources": ["**/*.js"],
"matches": ["<all_urls>"]
}
]
},
"chrome": {},
"firefox": {}
}
}

View File

@@ -0,0 +1,31 @@
import { Route, Routes } from 'react-router-dom';
import SidebarWithHeader from '~/components/SidebarWithHeader';
import { FiList, FiSettings } from 'react-icons/fi';
import { Box } from '@chakra-ui/react';
export default function App() {
return (
<SidebarWithHeader
title="Settings"
headBarItems={[
{
label: 'Sessions',
icon: FiList,
href: '/pages/index.html#',
},
{
label: 'Settings',
icon: FiSettings,
href: '#',
},
]}
sideBarItems={[]}
>
<Box p="10">
<Routes>
<Route path="/" element={<></>} />
</Routes>
</Box>
</SidebarWithHeader>
);
}

View File

@@ -0,0 +1,9 @@
<!DOCTYPE html>
<meta charset="utf-8" />
<title>rrweb settings</title>
<html>
<body>
<div id="root"></div>
</body>
<script type="module" src="./index.tsx"></script>
</html>

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { ChakraProvider } from '@chakra-ui/react';
import * as ReactDOM from 'react-dom/client';
import { createHashRouter, RouterProvider } from 'react-router-dom';
import App from './App';
const rootElement = document.getElementById('root');
const router = createHashRouter([
{
path: '/*',
element: <App />,
},
]);
rootElement &&
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<ChakraProvider>
<RouterProvider router={router} />
</ChakraProvider>
</React.StrictMode>,
);

View File

@@ -0,0 +1,37 @@
import { Route, Routes } from 'react-router-dom';
import SidebarWithHeader from '~/components/SidebarWithHeader';
import { SessionList } from './SessionList';
import { FiList, FiSettings } from 'react-icons/fi';
import Player from './Player';
export default function App() {
return (
<SidebarWithHeader
title="Sessions"
headBarItems={[
{
label: 'Settings',
icon: FiSettings,
href: '/options/index.html#',
},
{
label: 'Sessions',
icon: FiList,
href: '#',
},
]}
sideBarItems={[
{
label: 'List',
icon: FiList,
href: `#`,
},
]}
>
<Routes>
<Route path="/" element={<SessionList />} />
<Route path="session/:sessionId" element={<Player />} />
</Routes>
</SidebarWithHeader>
);
}

View File

@@ -0,0 +1,68 @@
import { useRef, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import Replayer from 'rrweb-player';
import {
Box,
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
Center,
} from '@chakra-ui/react';
import { getEvents, getSession } from '~/utils/storage';
export default function Player() {
const playerElRef = useRef<HTMLDivElement>(null);
const playerRef = useRef<Replayer | null>(null);
const { sessionId } = useParams();
const [sessionName, setSessionName] = useState('');
useEffect(() => {
if (!sessionId) return;
getSession(sessionId)
.then((session) => {
setSessionName(session.name);
})
.catch((err) => {
console.error(err);
});
getEvents(sessionId)
.then((events) => {
if (!playerElRef.current || !sessionId) return;
const linkEl = document.createElement('link');
linkEl.href =
'https://cdn.jsdelivr.net/npm/rrweb-player@latest/dist/style.css';
linkEl.rel = 'stylesheet';
document.head.appendChild(linkEl);
playerRef.current = new Replayer({
target: playerElRef.current as HTMLElement,
props: {
events,
autoPlay: true,
},
});
})
.catch((err) => {
console.error(err);
});
return () => {
playerRef.current?.pause();
};
}, [sessionId]);
return (
<>
<Breadcrumb mb={5} fontSize="md">
<BreadcrumbItem>
<BreadcrumbLink href="#">Sessions</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbItem>
<BreadcrumbLink>{sessionName}</BreadcrumbLink>
</BreadcrumbItem>
</Breadcrumb>
<Center>
<Box ref={playerElRef}></Box>
</Center>
</>
);
}

View File

@@ -0,0 +1,318 @@
import { useEffect, useMemo, useState } from 'react';
import {
chakra,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Text,
TableContainer,
Flex,
Checkbox,
Button,
Spacer,
IconButton,
Select,
Input,
Divider,
} from '@chakra-ui/react';
import {
createColumnHelper,
useReactTable,
flexRender,
getCoreRowModel,
SortingState,
getSortedRowModel,
PaginationState,
} from '@tanstack/react-table';
import { VscTriangleDown, VscTriangleUp } from 'react-icons/vsc';
import { useNavigate } from 'react-router-dom';
import { Session, EventName } from '~/types';
import Channel from '~/utils/channel';
import { deleteSessions, getAllSessions } from '~/utils/storage';
import {
FiChevronLeft,
FiChevronRight,
FiChevronsLeft,
FiChevronsRight,
} from 'react-icons/fi';
const columnHelper = createColumnHelper<Session>();
const channel = new Channel();
export function SessionList() {
const [sessions, setSessions] = useState<Session[]>([]);
const navigate = useNavigate();
const [sorting, setSorting] = useState<SortingState>([
{
id: 'createTimestamp',
desc: true,
},
]);
const [rowSelection, setRowSelection] = useState({});
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const fetchDataOptions = {
pageIndex,
pageSize,
};
const fetchData = (options: { pageIndex: number; pageSize: number }) => {
return {
rows: sessions.slice(
options.pageIndex * options.pageSize,
(options.pageIndex + 1) * options.pageSize,
),
pageCount: Math.ceil(sessions.length / options.pageSize),
};
};
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize],
);
const columns = useMemo(
() => [
columnHelper.display({
id: 'select',
header: ({ table }) => (
<Checkbox
isChecked={table.getIsAllRowsSelected()}
isIndeterminate={table.getIsSomeRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<Checkbox
isChecked={row.getIsSelected()}
isIndeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
}),
columnHelper.accessor((row) => row.name, {
cell: (info) => info.getValue(),
header: 'Name',
}),
columnHelper.accessor((row) => row.createTimestamp, {
id: 'createTimestamp',
cell: (info) => new Date(info.getValue()).toLocaleString(),
header: 'Created Time',
sortDescFirst: true,
}),
columnHelper.accessor((row) => row.recorderVersion, {
cell: (info) => info.getValue(),
header: 'RRWEB Version',
}),
],
[],
);
const table = useReactTable<Session>({
columns,
data: fetchData(fetchDataOptions).rows,
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
state: {
pagination,
sorting,
rowSelection,
},
manualPagination: true,
pageCount: fetchData(fetchDataOptions).pageCount,
});
const updateSessions = async () => {
const sessions = await getAllSessions();
setSessions(sessions);
};
useEffect(() => {
void updateSessions();
channel.on(EventName.SessionUpdated, () => {
void updateSessions();
});
}, []);
return (
<>
<TableContainer fontSize="md">
<Table variant="simple">
<Thead>
{table.getHeaderGroups().map((headerGroup) => (
<Tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const meta = header.column.columnDef.meta as
| {
isNumeric: boolean;
}
| undefined;
return (
<Th
key={header.id}
onClick={header.column.getToggleSortingHandler()}
isNumeric={meta?.isNumeric}
verticalAlign="center"
userSelect="none"
>
<Flex align="center">
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
<chakra.span pl={4}>
{{
asc: (
<VscTriangleUp aria-label="sorted ascending" />
),
desc: (
<VscTriangleDown aria-label="sorted descending" />
),
}[header.column.getIsSorted() as string] ?? null}
</chakra.span>
</Flex>
</Th>
);
})}
</Tr>
))}
</Thead>
<Tbody>
{table.getRowModel().rows.map((row) => (
<Tr key={row.id} _hover={{ cursor: 'pointer' }}>
{row.getVisibleCells().map((cell, index) => {
const meta = cell.column.columnDef.meta as
| {
isNumeric: boolean;
}
| undefined;
return (
<Td
key={cell.id}
isNumeric={meta?.isNumeric}
onClick={() => {
if (index !== 0)
navigate(`/session/${row.original.id}`);
}}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</Td>
);
})}
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
<Flex mt={4}>
<Flex gap={16} align="center" ml={4}>
<Flex gap={1}>
<IconButton
aria-label={'Goto 1st Page'}
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<FiChevronsLeft />
</IconButton>
<IconButton
aria-label={'Goto Previous Page'}
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<FiChevronLeft />
</IconButton>
</Flex>
<Flex gap={1} fontSize="md">
<Text>Page</Text>
<Text as="b" w={12}>
{`${
table.getState().pagination.pageIndex + 1
} of ${table.getPageCount()}`}
</Text>
</Flex>
<Divider orientation="vertical" />
<Flex gap={1} justify="center" align="center" fontSize="md">
<Text w={28}>Go to page:</Text>
<Input
w={20}
size="md"
type="number"
defaultValue={table.getState().pagination.pageIndex + 1}
onChange={(e) => {
const page = e.target.value ? Number(e.target.value) - 1 : 0;
table.setPageIndex(page);
}}
/>
</Flex>
<Flex gap={1}>
<IconButton
aria-label={'Goto Next Page'}
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<FiChevronRight />
</IconButton>
<IconButton
aria-label={'Goto last Page'}
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<FiChevronsRight />
</IconButton>
</Flex>
</Flex>
<Spacer />
<Flex gap={8} align="center" mr={4}>
<Select
variant="outline"
size="md"
value={table.getState().pagination.pageSize}
onChange={(e) => {
table.setPageSize(Number(e.target.value));
}}
>
{[10, 20, 30, 40, 50].map((pageSize) => (
<option key={pageSize} value={pageSize}>
Show {pageSize} items
</option>
))}
</Select>
{Object.keys(rowSelection).length > 0 && (
<Button
mr={4}
size="md"
colorScheme="red"
onClick={() => {
if (table.getSelectedRowModel().flatRows.length === 0) return;
const ids = table
.getSelectedRowModel()
.flatRows.map((row) => row.original.id);
void deleteSessions(ids).then(() => {
setRowSelection({});
void updateSessions();
channel.emit(EventName.SessionUpdated, {});
});
}}
>
Delete
</Button>
)}
</Flex>
</Flex>
</>
);
}

View File

@@ -0,0 +1,9 @@
<!DOCTYPE html>
<meta charset="utf-8" />
<title>rrweb</title>
<html>
<body>
<div id="root"></div>
</body>
<script type="module" src="./index.tsx"></script>
</html>

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { ChakraProvider } from '@chakra-ui/react';
import * as ReactDOM from 'react-dom/client';
import { createHashRouter, RouterProvider } from 'react-router-dom';
import App from './App';
const rootElement = document.getElementById('root');
const router = createHashRouter([
{
path: '/*',
element: <App />,
},
]);
rootElement &&
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<ChakraProvider>
<RouterProvider router={router} />
</ChakraProvider>
</React.StrictMode>,
);

View File

@@ -0,0 +1,249 @@
import { useState, useEffect } from 'react';
import {
Box,
Flex,
IconButton,
Link,
Spacer,
Stack,
Text,
} from '@chakra-ui/react';
import { FiSettings, FiList, FiPause, FiPlay } from 'react-icons/fi';
import Channel from '~/utils/channel';
import {
LocalData,
LocalDataKey,
RecorderStatus,
ServiceName,
RecordStartedMessage,
RecordStoppedMessage,
Session,
EventName,
} from '~/types';
import Browser from 'webextension-polyfill';
import { CircleButton } from '~/components/CircleButton';
import { Timer } from './Timer';
import { pauseRecording, resumeRecording } from '~/utils/recording';
import { saveSession } from '~/utils/storage';
const RECORD_BUTTON_SIZE = 3;
const channel = new Channel();
export function App() {
const [status, setStatus] = useState<RecorderStatus>(RecorderStatus.IDLE);
const [errorMessage, setErrorMessage] = useState('');
const [startTime, setStartTime] = useState(0);
const [newSession, setNewSession] = useState<Session | null>(null);
useEffect(() => {
void Browser.storage.local.get(LocalDataKey.recorderStatus).then((data) => {
const localData = data as LocalData;
if (!localData || !localData[LocalDataKey.recorderStatus]) return;
const { status, startTimestamp, pausedTimestamp } = localData[
LocalDataKey.recorderStatus
];
setStatus(status);
if (startTimestamp && pausedTimestamp)
setStartTime(Date.now() - pausedTimestamp + startTimestamp || 0);
else if (startTimestamp) setStartTime(startTimestamp);
});
}, []);
return (
<Flex direction="column" w={300} padding="5%">
<Flex>
<Text fontSize="md" fontWeight="bold">
RRWeb Recorder
</Text>
<Spacer />
<Stack direction="row">
<IconButton
onClick={() => {
void Browser.tabs.create({ url: '/pages/index.html#/' });
}}
size="xs"
icon={<FiList />}
aria-label={'Session List'}
title="Session List"
></IconButton>
<IconButton
onClick={() => {
void Browser.runtime.openOptionsPage();
}}
size="xs"
icon={<FiSettings />}
aria-label={'Settings button'}
title="Settings"
></IconButton>
</Stack>
</Flex>
{status !== RecorderStatus.IDLE && startTime && (
<Timer
startTime={startTime}
ticking={status === RecorderStatus.RECORDING}
/>
)}
<Flex justify="center" gap="10" mt="5" mb="5">
{[RecorderStatus.IDLE, RecorderStatus.RECORDING].includes(status) && (
<CircleButton
diameter={RECORD_BUTTON_SIZE}
title={
status === RecorderStatus.IDLE
? 'Start Recording'
: 'Stop Recording'
}
onClick={() => {
if (status === RecorderStatus.RECORDING) {
// stop recording
setErrorMessage('');
void channel.getCurrentTabId().then((tabId) => {
if (tabId === -1) return;
void channel
.requestToTab(tabId, ServiceName.StopRecord, {})
.then(async (res: RecordStoppedMessage) => {
if (!res) return;
setStatus(RecorderStatus.IDLE);
const status: LocalData[LocalDataKey.recorderStatus] = {
status: RecorderStatus.IDLE,
activeTabId: tabId,
};
await Browser.storage.local.set({
[LocalDataKey.recorderStatus]: status,
});
if (res.session) {
setNewSession(res.session);
await saveSession(res.session, res.events).catch(
(e) => {
setErrorMessage((e as { message: string }).message);
},
);
channel.emit(EventName.SessionUpdated, {});
}
})
.catch((error: Error) => {
setErrorMessage(error.message);
});
});
} else {
// start recording
void channel.getCurrentTabId().then((tabId) => {
if (tabId === -1) return;
void channel
.requestToTab(tabId, ServiceName.StartRecord, {})
.then(async (res: RecordStartedMessage | undefined) => {
if (res) {
setStatus(RecorderStatus.RECORDING);
setStartTime(res.startTimestamp);
const status: LocalData[LocalDataKey.recorderStatus] = {
status: RecorderStatus.RECORDING,
activeTabId: tabId,
startTimestamp: res.startTimestamp,
};
await Browser.storage.local.set({
[LocalDataKey.recorderStatus]: status,
});
}
})
.catch((error: Error) => {
setErrorMessage(error.message);
});
});
}
}}
>
<Box
w={`${RECORD_BUTTON_SIZE}rem`}
h={`${RECORD_BUTTON_SIZE}rem`}
borderRadius={status === RecorderStatus.IDLE ? 9999 : 6}
margin="0"
bgColor="red.500"
/>
</CircleButton>
)}
{status !== RecorderStatus.IDLE && (
<CircleButton
diameter={RECORD_BUTTON_SIZE}
title={
status === RecorderStatus.RECORDING
? 'Pause Recording'
: 'Resume Recording'
}
onClick={() => {
if (status === RecorderStatus.RECORDING) {
void pauseRecording(channel, RecorderStatus.PAUSED).then(
(result) => {
if (!result) return;
setStatus(result?.status.status);
},
);
} else {
void channel.getCurrentTabId().then((tabId) => {
if (tabId === -1) return;
resumeRecording(channel, tabId)
.then((statusData) => {
if (!statusData) return;
setStatus(statusData.status);
if (statusData.startTimestamp)
setStartTime(statusData.startTimestamp);
})
.catch((error: Error) => {
setErrorMessage(error.message);
});
});
}
}}
>
<Box
w={`${RECORD_BUTTON_SIZE}rem`}
h={`${RECORD_BUTTON_SIZE}rem`}
borderRadius={9999}
margin="0"
color="gray.600"
>
{[RecorderStatus.PAUSED, RecorderStatus.PausedSwitch].includes(
status,
) && (
<FiPlay
style={{
paddingLeft: '0.5rem',
width: '100%',
height: '100%',
}}
/>
)}
{status === RecorderStatus.RECORDING && (
<FiPause
style={{
width: '100%',
height: '100%',
}}
/>
)}
</Box>
</CircleButton>
)}
</Flex>
{newSession && (
<Text>
<Text as="b">New Session: </Text>
<Link
href={Browser.runtime.getURL(
`pages/index.html#/session/${newSession.id}`,
)}
isExternal
>
{newSession.name}
</Link>
</Text>
)}
{errorMessage !== '' && (
<Text color="red.500" fontSize="md">
{errorMessage}
<br />
Maybe refresh your current tab.
</Text>
)}
</Flex>
);
}

View File

@@ -0,0 +1,25 @@
import { useEffect, useState } from 'react';
import { Stat, StatNumber } from '@chakra-ui/react';
import { formatTime } from '~/utils';
export function Timer({
startTime,
ticking,
}: {
startTime: number;
ticking: boolean;
}) {
const [time, setTime] = useState(Date.now() - startTime);
useEffect(() => {
if (!ticking) return;
const interval = setInterval(() => {
setTime(Date.now() - startTime);
}, 100);
return () => clearInterval(interval);
}, [startTime, ticking]);
return (
<Stat textAlign="center" mt={4}>
<StatNumber fontSize="3xl">{formatTime(time)}</StatNumber>
</Stat>
);
}

View File

@@ -0,0 +1,15 @@
import React from 'react';
import { ChakraProvider } from '@chakra-ui/react';
import * as ReactDOM from 'react-dom/client';
import { App } from './App';
const rootElement = document.getElementById('root');
rootElement &&
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<ChakraProvider>
<App />
</ChakraProvider>
</React.StrictMode>,
);

View File

@@ -0,0 +1,8 @@
<!DOCTYPE html>
<meta charset="utf-8" />
<html>
<body>
<div id="root"></div>
</body>
<script type="module" src="./index.tsx"></script>
</html>

View File

@@ -0,0 +1,86 @@
import type { eventWithTime } from '@rrweb/types';
export enum SyncDataKey {
settings = 'settings',
}
export type SyncData = {
[SyncDataKey.settings]: Settings;
};
export type Settings = {
//
};
export enum LocalDataKey {
recorderStatus = 'recorder_status',
bufferedEvents = 'buffered_events',
}
export type LocalData = {
[LocalDataKey.recorderStatus]: {
status: RecorderStatus;
activeTabId: number;
startTimestamp?: number;
// the timestamp when the recording is paused
pausedTimestamp?: number;
};
[LocalDataKey.bufferedEvents]: eventWithTime[];
};
export enum RecorderStatus {
IDLE = 'IDLE',
RECORDING = 'RECORDING',
PAUSED = 'PAUSED',
// when user change the tab, the recorder will be paused during the tab change
PausedSwitch = 'PAUSED_SWITCH',
}
export type Session = {
id: string;
name: string;
tags: string[];
createTimestamp: number;
modifyTimestamp: number;
recorderVersion: string;
};
// all service names for channel
export enum ServiceName {
StartRecord = 'start-record',
StopRecord = 'stop-record',
PauseRecord = 'pause-record',
ResumeRecord = 'resume-record',
}
// all event names for channel
export enum EventName {
SessionUpdated = 'session-updated',
}
// all message names for postMessage API
export enum MessageName {
RecordScriptReady = 'rrweb-extension-record-script-ready',
StartRecord = 'rrweb-extension-start-record',
RecordStarted = 'rrweb-extension-record-started',
StopRecord = 'rrweb-extension-stop-record',
RecordStopped = 'rrweb-extension-record-stopped',
EmitEvent = 'rrweb-extension-emit-event',
}
export type RecordStartedMessage = {
message: MessageName.RecordStarted;
startTimestamp: number;
};
export type RecordStoppedMessage = {
message: MessageName.RecordStopped;
events: eventWithTime[];
endTimestamp: number;
session?: Session;
};
export type EmitEventMessage = {
message: MessageName.EmitEvent;
event: eventWithTime;
};

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

View File

@@ -0,0 +1,41 @@
{
"compilerOptions": {
"baseUrl": ".",
"module": "ESNext",
"target": "es2016",
"lib": [
"DOM",
"ESNext"
],
"strict": true,
"esModuleInterop": true,
"incremental": false,
"skipLibCheck": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"noUnusedLocals": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"paths": {
"~/*": [
"src/*"
]
},
"jsx": "react-jsx"
},
"exclude": [
"dist",
"node_modules"
],
"references": [
{
"path": "../rrweb"
},
{
"path": "../rrweb-player"
},
{
"path": "../types"
}
]
}

View File

@@ -0,0 +1,106 @@
import {
defineConfig,
LibraryFormats,
LibraryOptions,
PluginOption,
} from 'vite';
import webExtension, { readJsonFile } from 'vite-plugin-web-extension';
import zip from 'vite-plugin-zip';
import * as path from 'path';
import type { PackageJson } from 'type-fest';
import react from '@vitejs/plugin-react';
function useSpecialFormat(
entriesToUse: string[],
format: LibraryFormats,
): PluginOption {
return {
name: 'use-special-format',
config(config) {
const shouldUse = entriesToUse.includes(
(config.build?.lib as LibraryOptions)?.entry,
);
if (shouldUse) {
config.build ??= {};
// @ts-expect-error: lib needs to be an object, forcing it.
config.build.lib ||= {};
// @ts-expect-error: lib is an object
config.build.lib.formats = [format];
}
},
};
}
export default defineConfig({
root: 'src',
// Configure our outputs - nothing special, this is normal vite config
build: {
outDir: path.resolve(
__dirname,
'dist',
process.env.TARGET_BROWSER as string,
),
emptyOutDir: true,
},
// Add the webExtension plugin
plugins: [
react(),
webExtension({
// A function to generate manifest file dynamically.
manifest: () => {
const packageJson = readJsonFile('package.json') as PackageJson;
const isProduction = process.env.NODE_ENV === 'production';
type ManifestBase = {
common: Record<string, unknown>;
chrome: Record<string, unknown>;
firefox: Record<string, unknown>;
};
const originalManifest = readJsonFile('./src/manifest.json') as {
common: Record<string, unknown>;
v2: ManifestBase;
v3: ManifestBase;
};
const ManifestVersion =
process.env.TARGET_BROWSER === 'chrome' && isProduction ? 'v3' : 'v2';
const BrowserName =
process.env.TARGET_BROWSER === 'chrome' ? 'chrome' : 'firefox';
const commonManifest = originalManifest.common;
const manifest = {
version: packageJson.version,
author: packageJson.author,
version_name: packageJson.dependencies?.rrweb?.replace('^', ''),
...commonManifest,
};
Object.assign(
manifest,
originalManifest[ManifestVersion].common,
originalManifest[ManifestVersion][BrowserName],
);
return manifest;
},
assets: 'assets',
browser: process.env.TARGET_BROWSER,
webExtConfig: {
startUrl: ['github.com/rrweb-io/rrweb'],
watchIgnored: ['*.md', '*.log'],
},
additionalInputs: ['pages/index.html', 'content/inject.ts'],
}),
// https://github.com/aklinker1/vite-plugin-web-extension/issues/50#issuecomment-1317922947
// transfer inject.ts to iife format to avoid error
useSpecialFormat(
[path.resolve(__dirname, 'src/content/inject.ts')],
'iife',
),
process.env.ZIP === 'true' &&
zip({
dir: 'dist',
outputName: process.env.TARGET_BROWSER,
}),
],
resolve: {
alias: {
'~': path.resolve(__dirname, './src'),
},
},
});

4035
yarn.lock

File diff suppressed because it is too large Load Diff