authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 12:06:31-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 12:06:31-07:00
log3e5f9f2cdd73c3afb1ab73634ca91c42098fbaba
treedabba413249ababb9be7650dd63de257c6c38524
parent6888276f19d47b9a73546e92721a56abbc8ab6c8
signature Signed by PGP key B5690EEEBB952194

[codex] Structure browser recording failures (#3330)

Co-authored-by: codex <codex@users.noreply.github.com>

1 files changed, 193 insertions(+), 23 deletions(-)

apps/web/src/browser/browserRecording.ts+193-23
......@@ -3,12 +3,71 @@ import type {
33 DesktopPreviewRecordingFrame,
44} from "@t3tools/contracts";
55import { useAtomValue } from "@effect/atom-react";
6import * as Schema from "effect/Schema";
67import { Atom } from "effect/unstable/reactivity";
78
89import { previewBridge } from "~/components/preview/previewBridge";
910import { appAtomRegistry } from "~/rpc/atomRegistry";
1011import { useBrowserSurfaceStore } from "./browserSurfaceStore";
1112
13export class BrowserRecordingUnavailableError extends Schema.TaggedErrorClass<BrowserRecordingUnavailableError>()(
14 "BrowserRecordingUnavailableError",
15 {
16 tabId: Schema.String,
17 },
18) {
19 override get message(): string {
20 return `Browser recording is unavailable for tab ${this.tabId}.`;
21 }
22}
23
24export class BrowserRecordingConflictError extends Schema.TaggedErrorClass<BrowserRecordingConflictError>()(
25 "BrowserRecordingConflictError",
26 {
27 requestedTabId: Schema.String,
28 activeTabId: Schema.String,
29 },
30) {
31 override get message(): string {
32 return `Cannot record tab ${this.requestedTabId} while tab ${this.activeTabId} is already being recorded.`;
33 }
34}
35
36export class BrowserRecordingCanvasUnavailableError extends Schema.TaggedErrorClass<BrowserRecordingCanvasUnavailableError>()(
37 "BrowserRecordingCanvasUnavailableError",
38 {
39 tabId: Schema.String,
40 width: Schema.Number,
41 height: Schema.Number,
42 },
43) {
44 override get message(): string {
45 return `Browser recording canvas ${this.width}x${this.height} is unavailable for tab ${this.tabId}.`;
46 }
47}
48
49export class BrowserRecordingOperationError extends Schema.TaggedErrorClass<BrowserRecordingOperationError>()(
50 "BrowserRecordingOperationError",
51 {
52 operation: Schema.Literals([
53 "initialize-media-recorder",
54 "subscribe-frames",
55 "start-media-recorder",
56 "start-screencast",
57 "stop-screencast",
58 "stop-media-recorder",
59 "save-artifact",
60 "cleanup",
61 ]),
62 tabId: Schema.String,
63 cause: Schema.Defect(),
64 },
65) {
66 override get message(): string {
67 return `Browser recording operation ${this.operation} failed for tab ${this.tabId}.`;
68 }
69}
70
1271interface ActiveRecording {
1372 readonly tabId: string;
1473 readonly canvas: HTMLCanvasElement;
......@@ -70,22 +129,41 @@ const clearActiveRecording = (recording: ActiveRecording): void => {
70129
71130export async function startBrowserRecording(tabId: string): Promise<string> {
72131 const bridge = previewBridge;
73 if (!bridge) throw new Error("Browser recording is unavailable.");
132 if (!bridge) throw new BrowserRecordingUnavailableError({ tabId });
74133 if (active) {
75134 if (active.tabId === tabId) return active.startedAt;
76 throw new Error("Another preview tab is already being recorded.");
135 throw new BrowserRecordingConflictError({
136 requestedTabId: tabId,
137 activeTabId: active.tabId,
138 });
77139 }
78140 const rect = useBrowserSurfaceStore.getState().byTabId[tabId]?.rect;
79141 const canvas = document.createElement("canvas");
80142 canvas.width = Math.max(1, rect?.width ?? 1280);
81143 canvas.height = Math.max(1, rect?.height ?? 800);
82144 const context = canvas.getContext("2d", { alpha: false });
83 if (!context) throw new Error("Browser recording canvas is unavailable.");
84 const mimeType = preferredMimeType();
85 const recorder = new MediaRecorder(canvas.captureStream(12), {
86 mimeType,
87 videoBitsPerSecond: 4_000_000,
88 });
145 if (!context) {
146 throw new BrowserRecordingCanvasUnavailableError({
147 tabId,
148 width: canvas.width,
149 height: canvas.height,
150 });
151 }
152 let mimeType: string;
153 let recorder: MediaRecorder;
154 try {
155 mimeType = preferredMimeType();
156 recorder = new MediaRecorder(canvas.captureStream(12), {
157 mimeType,
158 videoBitsPerSecond: 4_000_000,
159 });
160 } catch (cause) {
161 throw new BrowserRecordingOperationError({
162 operation: "initialize-media-recorder",
163 tabId,
164 cause,
165 });
166 }
89167 const startedAt = new Date().toISOString();
90168 const chunks: Blob[] = [];
91169 recorder.addEventListener("dataavailable", (event) => {
......@@ -93,17 +171,52 @@ export async function startBrowserRecording(tabId: string): Promise<string> {
93171 });
94172 const recording = { tabId, canvas, context, recorder, chunks, mimeType, startedAt };
95173 active = recording;
96 unsubscribeFrames ??= bridge.recording.onFrame(drawFrame);
97 recorder.start(1_000);
98174 try {
99 await bridge.recording.startScreencast(tabId);
100 appAtomRegistry.set(activeBrowserRecordingTabIdAtom, tabId);
101 return startedAt;
102 } catch (error) {
103 await stopMediaRecorder(recorder);
175 unsubscribeFrames ??= bridge.recording.onFrame(drawFrame);
176 } catch (cause) {
104177 clearActiveRecording(recording);
105 throw error;
178 throw new BrowserRecordingOperationError({
179 operation: "subscribe-frames",
180 tabId,
181 cause,
182 });
183 }
184 try {
185 recorder.start(1_000);
186 } catch (cause) {
187 clearActiveRecording(recording);
188 throw new BrowserRecordingOperationError({
189 operation: "start-media-recorder",
190 tabId,
191 cause,
192 });
193 }
194 try {
195 await bridge.recording.startScreencast(tabId);
196 } catch (cause) {
197 let cleanupCause: unknown;
198 try {
199 await stopMediaRecorder(recorder);
200 } catch (error) {
201 cleanupCause = error;
202 } finally {
203 clearActiveRecording(recording);
204 }
205 throw new BrowserRecordingOperationError({
206 operation: "start-screencast",
207 tabId,
208 cause:
209 cleanupCause === undefined
210 ? cause
211 : new AggregateError(
212 [cause, cleanupCause],
213 `Browser recording start and cleanup failed for tab ${tabId}.`,
214 { cause },
215 ),
216 });
106217 }
218 appAtomRegistry.set(activeBrowserRecordingTabIdAtom, tabId);
219 return startedAt;
107220}
108221
109222export async function stopBrowserRecording(
......@@ -112,17 +225,74 @@ export async function stopBrowserRecording(
112225 const bridge = previewBridge;
113226 const recording = active;
114227 if (!bridge || !recording || recording.tabId !== tabId) return null;
228 let result:
229 | { readonly _tag: "Success"; readonly artifact: DesktopPreviewRecordingArtifact }
230 | { readonly _tag: "Failure"; readonly error: unknown };
231 try {
232 try {
233 await bridge.recording.stopScreencast(tabId);
234 } catch (cause) {
235 throw new BrowserRecordingOperationError({
236 operation: "stop-screencast",
237 tabId,
238 cause,
239 });
240 }
241 try {
242 await stopMediaRecorder(recording.recorder);
243 } catch (cause) {
244 throw new BrowserRecordingOperationError({
245 operation: "stop-media-recorder",
246 tabId,
247 cause,
248 });
249 }
250 try {
251 const blob = new Blob(recording.chunks, { type: recording.mimeType });
252 const artifact = await bridge.recording.save(
253 tabId,
254 recording.mimeType,
255 new Uint8Array(await blob.arrayBuffer()),
256 );
257 result = { _tag: "Success", artifact };
258 } catch (cause) {
259 throw new BrowserRecordingOperationError({
260 operation: "save-artifact",
261 tabId,
262 cause,
263 });
264 }
265 } catch (error) {
266 result = { _tag: "Failure", error };
267 }
268
269 let cleanupError: BrowserRecordingOperationError | undefined;
115270 try {
116 await bridge.recording.stopScreencast(tabId);
117271 await stopMediaRecorder(recording.recorder);
118 const blob = new Blob(recording.chunks, { type: recording.mimeType });
119 return await bridge.recording.save(
272 } catch (cause) {
273 cleanupError = new BrowserRecordingOperationError({
274 operation: "stop-media-recorder",
120275 tabId,
121 recording.mimeType,
122 new Uint8Array(await blob.arrayBuffer()),
123 );
276 cause,
277 });
124278 } finally {
125 await stopMediaRecorder(recording.recorder);
126279 clearActiveRecording(recording);
127280 }
281
282 if (result._tag === "Failure") {
283 if (cleanupError) {
284 throw new BrowserRecordingOperationError({
285 operation: "cleanup",
286 tabId,
287 cause: new AggregateError(
288 [result.error, cleanupError],
289 `Browser recording stop and cleanup failed for tab ${tabId}.`,
290 { cause: result.error },
291 ),
292 });
293 }
294 throw result.error;
295 }
296 if (cleanupError) throw cleanupError;
297 return result.artifact;
128298}