diff --git a/.changeset/beige-numbers-enjoy.md b/.changeset/beige-numbers-enjoy.md
deleted file mode 100644
index a845151cc8..0000000000
--- a/.changeset/beige-numbers-enjoy.md
+++ /dev/null
@@ -1,2 +0,0 @@
----
----
diff --git a/.changeset/lovely-pears-cross.md b/.changeset/lovely-pears-cross.md
new file mode 100644
index 0000000000..edf88c50fd
--- /dev/null
+++ b/.changeset/lovely-pears-cross.md
@@ -0,0 +1,5 @@
+---
+'@rrweb/web-extension': patch
+---
+
+Add rrweb browser extension
diff --git a/.changeset/nervous-poets-grin.md b/.changeset/nervous-poets-grin.md
index eb53dd7f53..084e1c5524 100644
--- a/.changeset/nervous-poets-grin.md
+++ b/.changeset/nervous-poets-grin.md
@@ -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).
diff --git a/.changeset/olive-worms-pump.md b/.changeset/olive-worms-pump.md
deleted file mode 100644
index a845151cc8..0000000000
--- a/.changeset/olive-worms-pump.md
+++ /dev/null
@@ -1,2 +0,0 @@
----
----
diff --git a/.changeset/real-trains-switch.md b/.changeset/real-trains-switch.md
index d102959fdf..71de446836 100644
--- a/.changeset/real-trains-switch.md
+++ b/.changeset/real-trains-switch.md
@@ -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).
diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml
index b94c259fcb..a1ad8e81cf 100644
--- a/.github/workflows/ci-cd.yml
+++ b/.github/workflows/ci-cd.yml
@@ -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
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index f5f4afccce..0f9688d18a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -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 }}
diff --git a/.github/workflows/style-check.yml b/.github/workflows/style-check.yml
index 50498ee960..79320836dc 100644
--- a/.github/workflows/style-check.yml
+++ b/.github/workflows/style-check.yml
@@ -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
diff --git a/.vscode/rrweb-monorepo.code-workspace b/.vscode/rrweb-monorepo.code-workspace
index 6a1be41ead..43fd69fb85 100644
--- a/.vscode/rrweb-monorepo.code-workspace
+++ b/.vscode/rrweb-monorepo.code-workspace
@@ -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": [
diff --git a/packages/web-extension/README.md b/packages/web-extension/README.md
new file mode 100644
index 0000000000..5acb7b6cc9
--- /dev/null
+++ b/packages/web-extension/README.md
@@ -0,0 +1,32 @@
+
+
+
+
+# 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
+```
diff --git a/packages/web-extension/package.json b/packages/web-extension/package.json
new file mode 100644
index 0000000000..54679add8a
--- /dev/null
+++ b/packages/web-extension/package.json
@@ -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"
+ }
+}
diff --git a/packages/web-extension/src/assets/icon128.png b/packages/web-extension/src/assets/icon128.png
new file mode 100644
index 0000000000..c053fb110d
Binary files /dev/null and b/packages/web-extension/src/assets/icon128.png differ
diff --git a/packages/web-extension/src/assets/icon16.png b/packages/web-extension/src/assets/icon16.png
new file mode 100644
index 0000000000..26a6bdd018
Binary files /dev/null and b/packages/web-extension/src/assets/icon16.png differ
diff --git a/packages/web-extension/src/assets/icon48.png b/packages/web-extension/src/assets/icon48.png
new file mode 100644
index 0000000000..31d04fa38a
Binary files /dev/null and b/packages/web-extension/src/assets/icon48.png differ
diff --git a/packages/web-extension/src/background/index.ts b/packages/web-extension/src/background/index.ts
new file mode 100644
index 0000000000..901669907b
--- /dev/null
+++ b/packages/web-extension/src/background/index.ts
@@ -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,
+ newSettings: Record,
+) {
+ 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).length > 0
+ ) {
+ if (existedSettings[i]) {
+ setDefaultSettings(
+ existedSettings[i] as Record,
+ newSettings[i] as Record,
+ );
+ } 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];
+ }
+ }
+}
diff --git a/packages/web-extension/src/components/CircleButton.tsx b/packages/web-extension/src/components/CircleButton.tsx
new file mode 100644
index 0000000000..abbb68e576
--- /dev/null
+++ b/packages/web-extension/src/components/CircleButton.tsx
@@ -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 (
+
+ );
+}
diff --git a/packages/web-extension/src/components/SidebarWithHeader.tsx b/packages/web-extension/src/components/SidebarWithHeader.tsx
new file mode 100644
index 0000000000..ba8470a5b6
--- /dev/null
+++ b/packages/web-extension/src/components/SidebarWithHeader.tsx
@@ -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;
+ href?: string;
+}
+
+export default function SidebarWithHeader({
+ children,
+ title,
+ headBarItems,
+ sideBarItems,
+}: {
+ title?: string;
+ sideBarItems: SideBarItem[];
+ headBarItems: SideBarItem[];
+ children: ReactNode;
+}) {
+ const { isOpen, onOpen, onClose } = useDisclosure();
+ return (
+
+ onClose}
+ display={{ base: 'none', md: 'block' }}
+ title={title}
+ />
+
+
+
+
+
+
+
+
+
+
+ {children}
+
+ );
+}
+
+interface SidebarProps extends BoxProps {
+ onClose: () => void;
+ title?: string;
+ sideBarItems: SideBarItem[];
+}
+
+const SidebarContent = ({
+ onClose,
+ sideBarItems,
+ title,
+ ...rest
+}: SidebarProps) => {
+ return (
+
+
+
+
+
+ {title && (
+
+ {title}
+
+ )}
+
+
+ {sideBarItems.map((link) => (
+
+ {link.label}
+
+ ))}
+
+ );
+};
+
+interface NavItemProps extends FlexProps {
+ icon: IconType;
+ href: string;
+ children: string;
+}
+const NavItem = ({ icon, href, children, ...rest }: NavItemProps) => {
+ return (
+
+
+ <>
+ {icon && }
+ {children}
+ >
+
+
+ );
+};
+
+interface MobileProps extends FlexProps {
+ onOpen: () => void;
+}
+const MobileNav = ({ onOpen, ...rest }: MobileProps) => {
+ return (
+
+ }
+ />
+
+
+ {rest.children && rest.children}
+
+
+ );
+};
+
+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 (
+
+ {headBarItems.map((navItem) => (
+
+
+
+
+ {navItem.label}
+
+
+
+ {navItem.children && (
+
+
+ {navItem.children.map((child) => (
+
+ ))}
+
+
+ )}
+
+
+ ))}
+
+ );
+};
+
+const DesktopSubNav = ({ label, href, subLabel }: HeadBarItem) => {
+ return (
+
+
+
+
+ {label}
+
+ {subLabel}
+
+
+
+
+
+
+ );
+};
diff --git a/packages/web-extension/src/content/index.ts b/packages/web-extension/src/content/index.ts
new file mode 100644
index 0000000000..0fec24b154
--- /dev/null
+++ b/packages/web-extension/src/content/index.ts
@@ -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;
+}
diff --git a/packages/web-extension/src/content/inject.ts b/packages/web-extension/src/content/inject.ts
new file mode 100644
index 0000000000..0f2d7d3f67
--- /dev/null
+++ b/packages/web-extension/src/content/inject.ts
@@ -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
+