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