History

[codex] Structure browser recording failures (#3330)

Julius Marminge committed 4 months ago

Commit 3e5f9f21 parent 68882761 file · +193 −23Browse files

Unified · Split

apps/web/src/browser/browserRecording.ts+193−23
@@ -3,12 +3,71 @@ import type { · 2 unmodified lines
33 DesktopPreviewRecordingFrame,
44 } from "@t3tools/contracts";
55 import { useAtomValue } from "@effect/atom-react";
6+import * as Schema from "effect/Schema";
67 import { Atom } from "effect/unstable/reactivity";
78
89 import { previewBridge } from "~/components/preview/previewBridge";
910 import { appAtomRegistry } from "~/rpc/atomRegistry";
1011 import { useBrowserSurfaceStore } from "./browserSurfaceStore";
1112
13+export 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+
24+export 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+
36+export 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+
49+export 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+
1271 interface ActiveRecording {
1372 readonly tabId: string;
1473 readonly canvas: HTMLCanvasElement;
@@ -70,22 +129,41 @@ const clearActiveRecording = (recording: ActiveRecording): void => { · 55 unmodified lines
70129
71130 export 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> { · 1 unmodified line
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
109222 export async function stopBrowserRecording(
@@ -112,17 +225,74 @@ export async function stopBrowserRecording( · 2 unmodified lines
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 }