Skip to content

Commit 24f5fd9

Browse files
authored
refactor: improved tab recording to improve stability (#1632)
* refactor: improved tab recording to improve stability * feat: enable to import session * improve stability * feat: enable to edit session name * prevent duplicate rrweb player in the dev mode
1 parent 79837ac commit 24f5fd9

File tree

12 files changed

+433
-382
lines changed

12 files changed

+433
-382
lines changed

.changeset/four-panthers-fly.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@rrweb/web-extension": patch
3+
---
4+
5+
web-extension: improve recording stability across tabs and enable session import
+222-92
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,25 @@
11
import Browser from 'webextension-polyfill';
2+
import { nanoid } from 'nanoid';
23
import type { eventWithTime } from '@rrweb/types';
34
import Channel from '~/utils/channel';
45
import {
5-
type LocalData,
6+
EventName,
67
LocalDataKey,
8+
MessageName,
79
RecorderStatus,
8-
type Settings,
9-
type SyncData,
10+
ServiceName,
1011
SyncDataKey,
1112
} from '~/types';
12-
import { pauseRecording, resumeRecording } from '~/utils/recording';
13-
14-
const channel = new Channel();
13+
import type {
14+
LocalData,
15+
RecordStartedMessage,
16+
RecordStoppedMessage,
17+
Session,
18+
Settings,
19+
SyncData,
20+
} from '~/types';
21+
import { isFirefox } from '~/utils';
22+
import { addSession } from '~/utils/storage';
1523

1624
void (async () => {
1725
// assign default value to settings of this extension
@@ -28,105 +36,215 @@ void (async () => {
2836
settings,
2937
} as SyncData);
3038

31-
// When tab is changed during the recording process, pause recording in the old tab and start a new one in the new tab.
32-
Browser.tabs.onActivated.addListener((activeInfo) => {
33-
Browser.storage.local
34-
.get(LocalDataKey.recorderStatus)
35-
.then(async (data) => {
36-
const localData = data as LocalData;
37-
if (!localData || !localData[LocalDataKey.recorderStatus]) return;
38-
let statusData = localData[LocalDataKey.recorderStatus];
39-
let { status } = statusData;
40-
let bufferedEvents: eventWithTime[] | undefined;
41-
42-
if (status === RecorderStatus.RECORDING) {
43-
const result = await pauseRecording(
44-
channel,
45-
RecorderStatus.PausedSwitch,
46-
statusData,
47-
).catch(async () => {
48-
/**
49-
* This error happen when the old tab is closed.
50-
* In this case, the recording process would be stopped through Browser.tabs.onRemoved API.
51-
* So we just read the new status here.
52-
*/
53-
const localData = (await Browser.storage.local.get(
54-
LocalDataKey.recorderStatus,
55-
)) as LocalData;
56-
return {
57-
status: localData[LocalDataKey.recorderStatus],
58-
bufferedEvents,
59-
};
60-
});
61-
if (!result) return;
62-
statusData = result.status;
63-
status = statusData.status;
64-
bufferedEvents = result.bufferedEvents;
65-
}
66-
if (status === RecorderStatus.PausedSwitch)
67-
await resumeRecording(
68-
channel,
69-
activeInfo.tabId,
70-
statusData,
71-
bufferedEvents,
72-
);
73-
})
39+
const events: eventWithTime[] = [];
40+
const channel = new Channel();
41+
let recorderStatus: LocalData[LocalDataKey.recorderStatus] = {
42+
status: RecorderStatus.IDLE,
43+
activeTabId: -1,
44+
};
45+
// Reset recorder status when the extension is reloaded.
46+
await Browser.storage.local.set({
47+
[LocalDataKey.recorderStatus]: recorderStatus,
48+
});
49+
50+
channel.on(EventName.StartButtonClicked, async () => {
51+
if (recorderStatus.status !== RecorderStatus.IDLE) return;
52+
recorderStatus = {
53+
status: RecorderStatus.IDLE,
54+
activeTabId: -1,
55+
};
56+
await Browser.storage.local.set({
57+
[LocalDataKey.recorderStatus]: recorderStatus,
58+
});
59+
60+
events.length = 0; // clear events before recording
61+
const tabId = await channel.getCurrentTabId();
62+
if (tabId === -1) return;
63+
64+
const res = (await channel
65+
.requestToTab(tabId, ServiceName.StartRecord, {})
66+
.catch(async (error: Error) => {
67+
recorderStatus.errorMessage = error.message;
68+
await Browser.storage.local.set({
69+
[LocalDataKey.recorderStatus]: recorderStatus,
70+
});
71+
})) as RecordStartedMessage;
72+
if (!res) return;
73+
Object.assign(recorderStatus, {
74+
status: RecorderStatus.RECORDING,
75+
activeTabId: tabId,
76+
startTimestamp: res.startTimestamp,
77+
});
78+
await Browser.storage.local.set({
79+
[LocalDataKey.recorderStatus]: recorderStatus,
80+
});
81+
});
82+
83+
channel.on(EventName.StopButtonClicked, async () => {
84+
if (recorderStatus.status === RecorderStatus.IDLE) return;
85+
86+
if (recorderStatus.status === RecorderStatus.RECORDING)
87+
(await channel
88+
.requestToTab(recorderStatus.activeTabId, ServiceName.StopRecord, {})
89+
.catch(() => ({
90+
message: MessageName.RecordStopped,
91+
endTimestamp: Date.now(),
92+
}))) as RecordStoppedMessage;
93+
recorderStatus = {
94+
status: RecorderStatus.IDLE,
95+
activeTabId: -1,
96+
};
97+
await Browser.storage.local.set({
98+
[LocalDataKey.recorderStatus]: recorderStatus,
99+
});
100+
const title =
101+
(await Browser.tabs
102+
.query({ active: true, currentWindow: true })
103+
.then((tabs) => tabs[0]?.title)
104+
.catch(() => {
105+
// ignore error
106+
})) ?? 'new session';
107+
const newSession = generateSession(title);
108+
await addSession(newSession, events).catch((e) => {
109+
recorderStatus.errorMessage = (e as { message: string }).message;
110+
void Browser.storage.local.set({
111+
[LocalDataKey.recorderStatus]: recorderStatus,
112+
});
113+
});
114+
channel.emit(EventName.SessionUpdated, {
115+
session: newSession,
116+
});
117+
events.length = 0;
118+
});
119+
120+
async function pauseRecording(newStatus: RecorderStatus) {
121+
if (
122+
recorderStatus.status !== RecorderStatus.RECORDING ||
123+
recorderStatus.activeTabId === -1
124+
)
125+
return;
126+
127+
const stopResponse = (await channel
128+
.requestToTab(recorderStatus.activeTabId, ServiceName.StopRecord, {})
74129
.catch(() => {
75-
// the extension can't access to the tab
130+
// ignore error
131+
})) as RecordStoppedMessage | undefined;
132+
Object.assign(recorderStatus, {
133+
status: newStatus,
134+
activeTabId: -1,
135+
pausedTimestamp: stopResponse?.endTimestamp,
136+
});
137+
await Browser.storage.local.set({
138+
[LocalDataKey.recorderStatus]: recorderStatus,
139+
});
140+
}
141+
channel.on(EventName.PauseButtonClicked, async () => {
142+
if (recorderStatus.status !== RecorderStatus.RECORDING) return;
143+
await pauseRecording(RecorderStatus.PAUSED);
144+
});
145+
146+
async function resumeRecording(newTabId: number) {
147+
if (
148+
![RecorderStatus.PAUSED, RecorderStatus.PausedSwitch].includes(
149+
recorderStatus.status,
150+
)
151+
)
152+
return;
153+
const { startTimestamp, pausedTimestamp } = recorderStatus;
154+
// On Firefox, the new tab is not communicable immediately after it is created.
155+
if (isFirefox()) await new Promise((r) => setTimeout(r, 50));
156+
const pausedTime = pausedTimestamp ? Date.now() - pausedTimestamp : 0;
157+
// Decrease the time spent in the pause state and make them look like a continuous recording.
158+
events.forEach((event) => {
159+
event.timestamp += pausedTime;
160+
});
161+
const startResponse = (await channel
162+
.requestToTab(newTabId, ServiceName.StartRecord, {})
163+
.catch((e: { message: string }) => {
164+
recorderStatus.errorMessage = e.message;
165+
void Browser.storage.local.set({
166+
[LocalDataKey.recorderStatus]: recorderStatus,
167+
});
168+
})) as RecordStartedMessage | undefined;
169+
if (!startResponse) {
170+
// Restore the events data when the recording fails to start.
171+
events.forEach((event) => {
172+
event.timestamp -= pausedTime;
76173
});
174+
return;
175+
}
176+
recorderStatus = {
177+
status: RecorderStatus.RECORDING,
178+
activeTabId: newTabId,
179+
startTimestamp: (startTimestamp || Date.now()) + pausedTime,
180+
};
181+
await Browser.storage.local.set({
182+
[LocalDataKey.recorderStatus]: recorderStatus,
183+
});
184+
}
185+
channel.on(EventName.ResumeButtonClicked, async () => {
186+
if (recorderStatus.status !== RecorderStatus.PAUSED) return;
187+
recorderStatus.errorMessage = undefined;
188+
await Browser.storage.local.set({
189+
[LocalDataKey.recorderStatus]: recorderStatus,
190+
});
191+
const tabId = await channel.getCurrentTabId();
192+
await resumeRecording(tabId);
193+
});
194+
195+
channel.on(EventName.ContentScriptEmitEvent, (data) => {
196+
events.push(data as eventWithTime);
197+
});
198+
199+
// When tab is changed during the recording process, pause recording in the old tab and start a new one in the new tab.
200+
Browser.tabs.onActivated.addListener((activeInfo) => {
201+
void (async () => {
202+
if (
203+
recorderStatus.status !== RecorderStatus.RECORDING &&
204+
recorderStatus.status !== RecorderStatus.PausedSwitch
205+
)
206+
return;
207+
if (activeInfo.tabId === recorderStatus.activeTabId) return;
208+
if (recorderStatus.status === RecorderStatus.RECORDING)
209+
await pauseRecording(RecorderStatus.PausedSwitch);
210+
if (recorderStatus.status === RecorderStatus.PausedSwitch)
211+
await resumeRecording(activeInfo.tabId);
212+
})();
213+
return;
77214
});
78215

79216
// If the recording can't start on an invalid tab, resume it when the tab content is updated.
80217
Browser.tabs.onUpdated.addListener(function (tabId, info) {
81218
if (info.status !== 'complete') return;
82-
Browser.storage.local
83-
.get(LocalDataKey.recorderStatus)
84-
.then(async (data) => {
85-
const localData = data as LocalData;
86-
if (!localData || !localData[LocalDataKey.recorderStatus]) return;
87-
const { status, activeTabId } = localData[LocalDataKey.recorderStatus];
88-
if (status !== RecorderStatus.PausedSwitch || activeTabId === tabId)
89-
return;
90-
await resumeRecording(
91-
channel,
92-
tabId,
93-
localData[LocalDataKey.recorderStatus],
94-
);
95-
})
96-
.catch(() => {
97-
// the extension can't access to the tab
98-
});
219+
if (
220+
recorderStatus.status !== RecorderStatus.PausedSwitch ||
221+
recorderStatus.activeTabId === tabId
222+
)
223+
return;
224+
void resumeRecording(tabId);
99225
});
100226

101227
/**
102-
* When the current tab is closed, the recording events will be lost because this event is fired after it is closed.
103-
* This event listener is just used to make sure the recording status is updated.
228+
* When the current tab is closed, and there's no other tab to resume recording, make sure the recording status is updated to SwitchPaused.
104229
*/
105230
Browser.tabs.onRemoved.addListener((tabId) => {
106-
Browser.storage.local
107-
.get(LocalDataKey.recorderStatus)
108-
.then(async (data) => {
109-
const localData = data as LocalData;
110-
if (!localData || !localData[LocalDataKey.recorderStatus]) return;
111-
const { status, activeTabId, startTimestamp } =
112-
localData[LocalDataKey.recorderStatus];
113-
if (activeTabId !== tabId || status !== RecorderStatus.RECORDING)
114-
return;
115-
116-
// Update the recording status to make it resumable after users switch to other tabs.
117-
const statusData: LocalData[LocalDataKey.recorderStatus] = {
118-
status: RecorderStatus.PausedSwitch,
119-
activeTabId,
120-
startTimestamp,
121-
pausedTimestamp: Date.now(),
122-
};
123-
await Browser.storage.local.set({
124-
[LocalDataKey.recorderStatus]: statusData,
125-
});
126-
})
127-
.catch((err) => {
128-
console.error(err);
231+
void (async () => {
232+
if (
233+
recorderStatus.activeTabId !== tabId ||
234+
recorderStatus.status !== RecorderStatus.RECORDING
235+
)
236+
return;
237+
// Update the recording status to make it resumable after users switch to other tabs.
238+
Object.assign(recorderStatus, {
239+
status: RecorderStatus.PausedSwitch,
240+
activeTabId: -1,
241+
pausedTimestamp: Date.now(),
242+
});
243+
244+
await Browser.storage.local.set({
245+
[LocalDataKey.recorderStatus]: recorderStatus,
129246
});
247+
})();
130248
});
131249
})();
132250

@@ -160,3 +278,15 @@ function setDefaultSettings(
160278
}
161279
}
162280
}
281+
282+
function generateSession(title: string) {
283+
const newSession: Session = {
284+
id: nanoid(),
285+
name: title,
286+
tags: [],
287+
createTimestamp: Date.now(),
288+
modifyTimestamp: Date.now(),
289+
recorderVersion: Browser.runtime.getManifest().version_name || 'unknown',
290+
};
291+
return newSession;
292+
}

0 commit comments

Comments
 (0)