1// @effect-diagnostics nodeBuiltinImport:off globalDate:off globalTimers:off - Playwright callbacks run outside the Effect runtime.
2// Screencasts ignore emulated device scale; real 2x keeps captures sharp.
3// --disable-gpu uses cheaper software compositing while preserving SwiftShader WebGL.
4import {
5 BUILT_IN_BROWSER_PROFILES,
6 DEFAULT_BROWSER_PROFILE_ID,
7 FILL_PREVIEW_VIEWPORT,
8 findBrowserProfile,
9 INCOGNITO_BROWSER_PROFILE_ID,
10 resolveBrowserProfiles,
11 type BrowserProfile,
12 type PreviewReportProfilesInput,
13 PREVIEW_AUTOMATION_SERVER_OPERATIONS,
14 PreviewViewportSetting as PreviewViewportSettingSchema,
15 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
16 type PreviewAutomationActionEvent,
17 type PreviewAutomationClickInput,
18 type PreviewAutomationDialogInput,
19 type PreviewAutomationConsoleEntry,
20 type PreviewAutomationDragInput,
21 type PreviewAutomationEvaluateInput,
22 type PreviewAutomationHoverInput,
23 type PreviewAutomationNavigateInput,
24 type PreviewAutomationNetworkEntry,
25 type PreviewAutomationOpenInput,
26 type PreviewAutomationPressInput,
27 type PreviewAutomationRequest,
28 type PreviewAutomationResizeInput,
29 type PreviewAutomationScrollInput,
30 type PreviewAutomationSelectInput,
31 type PreviewAutomationSetColorSchemeInput,
32 type PreviewAutomationStatus,
33 type PreviewAutomationTypeInput,
34 type PreviewAutomationUploadInput,
35 type PreviewAutomationWaitForInput,
36 PreviewClearProfileError,
37 type PreviewEvent,
38 type PreviewNavStatus,
39 type PreviewSessionSnapshot,
40 type PreviewViewportSetting,
41 ThreadId,
42 SERVER_BROWSER_AUTOMATION_CLIENT_ID,
43 type PreviewAppearancePreference,
44} from "@t3tools/contracts";
45import * as HostProcess from "@t3tools/shared/HostProcess";
46import { normalizePreviewUrl } from "@t3tools/shared/preview";
47import { resolvePreviewViewport } from "@t3tools/shared/previewViewport";
48import * as NodeCrypto from "node:crypto";
49import * as NodeFSP from "node:fs/promises";
50import * as NodePath from "node:path";
51import * as Context from "effect/Context";
52import * as Effect from "effect/Effect";
53import { constVoid } from "effect/Function";
54import * as Layer from "effect/Layer";
55import * as Option from "effect/Option";
56import * as Queue from "effect/Queue";
57import * as Schedule from "effect/Schedule";
58import * as Schema from "effect/Schema";
59import * as Exit from "effect/Exit";
60import * as Scope from "effect/Scope";
61import * as Stream from "effect/Stream";
62import type * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
63import type {
64 BrowserContext,
65 CDPSession,
66 Dialog,
67 Download,
68 FileChooser,
69 Frame,
70 Page,
71} from "playwright-core";
72
73import { PENDING_ATTACHMENT_THREAD_SEGMENT } from "../attachmentStore.ts";
74import * as ServerConfig from "../config.ts";
75import { resolveRootCliCommand } from "../cli/invocation.ts";
76import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
77import * as PreviewAutomationBroker from "../mcp/PreviewAutomationBroker.ts";
78import * as DesktopBrowserChannel from "./DesktopBrowserChannel.ts";
79import * as PreviewManager from "./Manager.ts";
80import * as ServerBrowserPage from "./ServerBrowserPage.ts";
81import * as PreviewBrowser from "./PreviewBrowser.ts";
82import * as PreviewBrowserHost from "./PreviewBrowserHost.ts";
83import { presentAsChrome, ServerBrowserContexts } from "./ServerBrowserContexts.ts";
84import { BrowserControlInterrupted, SessionControl } from "./SessionControl.ts";
85
86const SERVER_HOST_CLIENT_ID = SERVER_BROWSER_AUTOMATION_CLIENT_ID;
87const RENDER_SCALE = 2;
88// Chromium allows three unacked frames, so 100 ms pacing caps viewers near 30 fps.
89const SCREENCAST_ACK_PACE_MS = 100;
90const SCREENCAST_SETTLE_MS = 200;
91// Only scrolling triggers reduced-quality motion frames; animations stay sharp.
92const SCREENCAST_MOTION_FRAMES = 4;
93const SCREENCAST_MOTION_WINDOW_MS = 300;
94const SCREENCAST_MOTION_QUALITY = 50;
95const HOST_RECONNECT_DELAY = "1 second";
96/** How long a new tab waits for the desktop app to mount it before running headless. */
97const DESKTOP_ATTACH_TIMEOUT = "10 seconds";
98const VIEWER_OUTPUT_LIMIT = 64;
99const RECORDING_SCREENCAST = { format: "jpeg", quality: 90, everyNthFrame: 1 } as const;
100const decodeViewportSetting = Schema.decodeUnknownSync(PreviewViewportSettingSchema);
101/** The agent cursor glides to its target, then pulses just before the press, like desktop tabs. */
102const AGENT_CURSOR_MOVE_MS = 160;
103const AGENT_CURSOR_CLICK_LEAD_MS = 40;
104
105const sleepUntil = (deadline: number) =>
106 new Promise<void>((resolve) => setTimeout(resolve, Math.max(0, deadline - Date.now())));
107
108// Touch viewers raise the keyboard for editable targets, including opaque frames.
109const EDITABLE_AT_POINT_SCRIPT = `(x, y) => {
110 let element = document.elementFromPoint(x, y);
111 while (element && element.shadowRoot) {
112 const inner = element.shadowRoot.elementFromPoint(x, y);
113 if (!inner || inner === element) break;
114 element = inner;
115 }
116 if (element && element.tagName === "LABEL" && element.control) element = element.control;
117 if (!element) return false;
118 if (element.tagName === "IFRAME" || element.tagName === "FRAME") return true;
119 if (element.isContentEditable) return true;
120 if (element.tagName === "TEXTAREA") return !element.disabled && !element.readOnly;
121 if (element.tagName !== "INPUT") return false;
122 const nonText = ["button", "checkbox", "color", "file", "hidden", "image", "radio", "range", "reset", "submit"];
123 return !nonText.includes(element.type) && !element.disabled && !element.readOnly;
124}`;
125const UNATTACHED_FILL_VIEWPORT = { width: 1280, height: 800 } as const;
126const NAVIGATION_TIMEOUT_MS = 15_000;
127const VIEWER_NAVIGATION_OPTIONS = { waitUntil: "commit", timeout: NAVIGATION_TIMEOUT_MS } as const;
128const ACTION_TIMELINE_LIMIT = 50;
129
130export class ServerBrowserTabNotFoundError extends Schema.TaggedError<ServerBrowserTabNotFoundError>()(
131 "ServerBrowserTabNotFoundError",
132 { threadId: Schema.String, tabId: Schema.String },
133) {
134 override get message(): string {
135 return "The server preview tab does not exist.";
136 }
137}
138
139const isTabNotFound = Schema.is(ServerBrowserTabNotFoundError);
140const isHostSetupError = Schema.is(
141 Schema.Union([
142 PreviewBrowserHost.PreviewBrowserSandboxError,
143 PreviewBrowserHost.PreviewBrowserLibrariesError,
144 ]),
145);
146
147export class ServerBrowserLaunchError extends Schema.TaggedError<ServerBrowserLaunchError>()(
148 "ServerBrowserLaunchError",
149 { cause: Schema.Defect() },
150) {
151 override get message(): string {
152 return "The server preview browser could not start.";
153 }
154}
155
156export type ServerBrowserViewerOutput =
157 | {
158 readonly _tag: "frame";
159 readonly data: Uint8Array;
160 readonly ack: Effect.Effect<void>;
161 }
162 | { readonly _tag: "viewport"; readonly width: number; readonly height: number }
163 | {
164 readonly _tag: "control";
165 readonly canOperate: boolean;
166 readonly controller: "agent" | "you" | "another-viewer" | "unclaimed";
167 readonly generation: number;
168 readonly dialog: {
169 readonly type: string;
170 readonly message: string;
171 readonly defaultValue: string;
172 } | null;
173 }
174 | {
175 readonly _tag: "probe";
176 readonly x: number;
177 readonly y: number;
178 readonly editable: boolean;
179 }
180 | { readonly _tag: "clipboard"; readonly text: string }
181 | {
182 readonly _tag: "pointer";
183 readonly phase: "move" | "click";
184 readonly x: number;
185 readonly y: number;
186 readonly sequence: number;
187 }
188 | {
189 readonly _tag: "fileChooser";
190 readonly id: string;
191 readonly multiple: boolean;
192 readonly accept: string;
193 }
194 | { readonly _tag: "fileChooserClosed"; readonly id: string }
195 /** The page this viewer controls opened a new tab; the viewer shows it, as a browser would. */
196 | { readonly _tag: "popup"; readonly tabId: string }
197 | {
198 readonly _tag: "download";
199 readonly id: string;
200 readonly fileName: string;
201 readonly sizeBytes: number;
202 }
203 | { readonly _tag: "gone" }
204 /** The page is still open, but this connection to it ended; the viewer reconnects. */
205 | { readonly _tag: "reconnect" };
206
207export interface ServerBrowserViewer {
208 readonly output: Queue.Dequeue<ServerBrowserViewerOutput>;
209 readonly input: (message: unknown) => Effect.Effect<void>;
210}
211
212export class ServerBrowser extends Context.Service<
213 ServerBrowser,
214 {
215 readonly attachViewer: (input: {
216 readonly threadId: string;
217 readonly tabId: string;
218 readonly maxWidth: number;
219 readonly maxHeight: number;
220 readonly quality: number;
221 readonly canOperate: boolean;
222 }) => Effect.Effect<
223 ServerBrowserViewer,
224 ServerBrowserTabNotFoundError | ServerBrowserLaunchError,
225 Scope.Scope
226 >;
227 /**
228 * Gives a page's open file picker the files a viewer uploaded, by path, and
229 * closes it. No files cancels the pick. False when that picker is gone.
230 */
231 readonly answerFileChooser: (input: {
232 readonly threadId: string;
233 readonly tabId: string;
234 readonly chooserId: string;
235 readonly files: ReadonlyArray<{
236 readonly name: string;
237 readonly mimeType: string;
238 readonly buffer: Buffer;
239 }>;
240 }) => Effect.Effect<boolean>;
241 /** A finished download of a live tab, for the authenticated download route. */
242 readonly openDownload: (input: {
243 readonly threadId: string;
244 readonly tabId: string;
245 readonly downloadId: string;
246 }) => Effect.Effect<Option.Option<{ readonly path: string; readonly fileName: string }>>;
247 /** Deletes a human profile's server-side storage, closing its open tabs first. */
248 readonly clearProfile: (profileId: string) => Effect.Effect<void, PreviewClearProfileError>;
249 /**
250 * Records a client's browser profiles for agents' preview_open. In memory:
251 * clients report again on connect and whenever the list changes.
252 */
253 readonly reportProfiles: (input: PreviewReportProfilesInput) => Effect.Effect<void>;
254 }
255>()("t3/preview/ServerBrowser") {}
256
257interface ViewerState {
258 readonly id: string;
259 readonly canOperate: boolean;
260 readonly pressedKeys: Map<string, { key: string; code: string }>;
261 readonly pressedButtons: Map<"left" | "middle" | "right", { x: number; y: number }>;
262 readonly push: (output: ServerBrowserViewerOutput) => void;
263 readonly pause: () => Promise<void>;
264 readonly resume: () => Promise<void>;
265 scrolledAt: number;
266 /** Panel bounds, retained in fixed mode; passive viewers never request a size. */
267 requestedSize: { width: number; height: number; order: number } | null;
268}
269
270interface EncoderWindow {
271 __t3Recorder: {
272 cursor(x: number, y: number, click: boolean): void;
273 frame(data: string, width: number): Promise<boolean>;
274 stop(): Promise<{ mimeType: string | null; count: number; bytes: number }>;
275 chunk(index: number): Promise<string>;
276 };
277}
278
279interface Recording {
280 readonly encoder: Page;
281 readonly session: CDPSession;
282 readonly startedAt: string;
283 /** Frames still being handed to the encoder; stopping waits for them. */
284 readonly framesInFlight: Set<Promise<void>>;
285}
286
287interface ServerTab {
288 readonly threadId: ThreadId;
289 readonly tabId: string;
290 readonly page: Page;
291 readonly cdp: CDPSession;
292 /** Sessions of the page's frames that run in their own process. */
293 readonly frameSessions: Map<Frame, CDPSession>;
294 readonly createdAt: number;
295 /** The agent's latest request on this tab; idle agent tabs close. */
296 usedAt: number;
297 readonly viewers: Set<ViewerState>;
298 readonly consoleEntries: Array<PreviewAutomationConsoleEntry>;
299 readonly networkEntries: Array<PreviewAutomationNetworkEntry>;
300 readonly actionTimeline: Array<PreviewAutomationActionEvent>;
301 readonly control: SessionControl;
302 /** The tab's own storage context, closed with it. Popups share their opener's. */
303 readonly isolatedContext: boolean;
304 /**
305 * Set when the desktop app renders this tab. The server drives the desktop's
306 * page; the desktop owns its size, storage, and window.
307 */
308 readonly desktop: { readonly close: () => Promise<void> } | null;
309 readonly profileId: string | undefined;
310 /** Set when a page in another tab opened this one with `window.open` or a link. */
311 readonly openerTabId: string | undefined;
312 /** Finished downloads, newest last; files live until the tab closes. */
313 readonly downloads: Array<ServerDownload>;
314 /**
315 * A page's open file picker, waiting for the controlling viewer's files. Its
316 * id is reissued for each viewer it is offered to, so only that viewer can answer.
317 */
318 fileChooser: {
319 readonly id: string;
320 readonly chooser: FileChooser;
321 readonly accept: string;
322 readonly offeredTo: string | null;
323 } | null;
324 dialog: Dialog | null;
325 setting: PreviewViewportSetting;
326 colorScheme: PreviewAppearancePreference;
327 zoomFactor: number;
328 loading: boolean;
329 closing: boolean;
330 recording: Recording | null;
331 initialNavigation: Promise<void> | null;
332 /** Counts main-frame navigation requests, so late events can tell they were superseded. */
333 navigationGeneration: number;
334 /**
335 * The latest main-frame navigation, when it aborted, to tell a navigation's
336 * download from a page's. The timer settles it if no download follows.
337 */
338 abortedNavigation: {
339 readonly url: string;
340 readonly generation: number;
341 readonly timer: ReturnType<typeof setTimeout>;
342 } | null;
343 /** The latest queued start, so a stop can find a recording still starting. */
344 recordingStart: Promise<Recording> | null;
345 /** Serializes captures and recording start/stop. */
346 captureLock: Promise<void>;
347 /** Scaled captures rendering now; screencasts stay stopped meanwhile. */
348 capturing: number;
349}
350
351interface ServerDownload {
352 readonly id: string;
353 readonly fileName: string;
354 readonly path: string;
355 readonly sizeBytes: number;
356 readonly url: string;
357 readonly completedAt: string;
358}
359
360/** One agent session and the whole server; each tab holds a renderer process. */
361/**
362 * Operations an agent may run on any tab in its thread, even while a human
363 * drives it. Evaluate is not one: page script can change anything, so it needs
364 * the same control as clicking.
365 */
366const READ_OPERATIONS: ReadonlySet<PreviewAutomationRequest["operation"]> = new Set([
367 "status",
368 "snapshot",
369 "waitFor",
370]);
371
372const AGENT_TAB_LIMIT = 8;
373const SERVER_TAB_LIMIT = 32;
374/** Agent tabs nobody watches close after this long without an agent request. */
375const AGENT_TAB_IDLE_MS = 30 * 60 * 1000;
376const IDLE_SWEEP_INTERVAL = "1 minute";
377
378const DOWNLOAD_LIMIT = 20;
379/** How long an aborted navigation waits for the download that usually explains it. */
380const ABORTED_NAVIGATION_DOWNLOAD_WAIT_MS = 5_000;
381const DOWNLOAD_MAX_BYTES = 1024 * 1024 * 1024;
382
383const tabKey = (threadId: string, tabId: string) => `${threadId}\u0000${tabId}`;
384
385const pushBounded = <A>(
386 buffer: Array<A>,
387 entry: A,
388 limit = ServerBrowserPage.DIAGNOSTIC_BUFFER_LIMIT,
389) => {
390 buffer.push(entry);
391 if (buffer.length > limit) buffer.splice(0, buffer.length - limit);
392};
393
394const viewportSettingsEqual = (left: PreviewViewportSetting, right: PreviewViewportSetting) =>
395 left._tag === right._tag &&
396 (left._tag === "fill" ||
397 (right._tag !== "fill" && left.width === right.width && left.height === right.height));
398
399const fixedViewportSize = (setting: PreviewViewportSetting) =>
400 setting._tag === "fill" ? null : { width: setting.width, height: setting.height };
401
402const asRecord = (value: unknown): Record<string, unknown> | null =>
403 typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
404
405const num = (value: unknown, fallback = 0) =>
406 typeof value === "number" && Number.isFinite(value) ? value : fallback;
407
408const modifiersOf = (message: Record<string, unknown>) => {
409 const value = num(message.modifiers);
410 return Number.isInteger(value) && value >= 0 && value < 16 ? value : 0;
411};
412
413// Cmd shortcuts from Apple viewers are no editing shortcut for Linux or headless
414// Chromium, so they carry the command. Ctrl already works natively on Linux,
415// except copy and cut, which headless Chromium only runs as commands.
416const editingCommand = (key: string, modifiers: number) => {
417 const modifier = modifiers & 0b0111;
418 if (modifier !== 2 && modifier !== 4) return null;
419 const lower = key.toLowerCase();
420 const command =
421 lower === "c"
422 ? "copy"
423 : lower === "x"
424 ? "cut"
425 : modifier !== 4
426 ? null
427 : lower === "a"
428 ? "selectAll"
429 : lower === "z"
430 ? modifiers & 8
431 ? "redo"
432 : "undo"
433 : null;
434 return command ? { commands: [command] } : null;
435};
436
437const CLIPBOARD_TEXT_LIMIT = 1024 * 1024;
438const CLIPBOARD_WORLD = "t3-preview-clipboard";
439// Headless Chromium shares one clipboard between every context, so the copy
440// a viewer's copy or cut key makes is read from this world instead of the
441// clipboard. Page scripts cannot see the world, and only a copy made while a
442// real key is handled counts.
443const CLIPBOARD_SCRIPT = `(() => {
444 let keying = false;
445 let copied = "";
446 // The same Ctrl or Cmd + C or X that editingCommand sends as copy or cut.
447 addEventListener("keydown", (event) => {
448 if (!event.isTrusted || event.altKey || event.ctrlKey === event.metaKey) return;
449 const key = event.key.toLowerCase();
450 if (key !== "c" && key !== "x") return;
451 keying = true;
452 copied = "";
453 setTimeout(() => { keying = false; });
454 }, true);
455 const selection = () => {
456 let field = document.activeElement;
457 while (field?.shadowRoot?.activeElement) field = field.shadowRoot.activeElement;
458 if ((field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement) && field.selectionStart !== null)
459 return field.value.slice(field.selectionStart, field.selectionEnd ?? field.selectionStart);
460 return String(document.getSelection() ?? "");
461 };
462 const record = (text) => {
463 copied = String(text ?? "").slice(0, ${CLIPBOARD_TEXT_LIMIT});
464 };
465 // Capturing at window runs before page handlers can stop the event, and
466 // before cut removes the selection.
467 const snapshot = (event) => {
468 if (keying && event.isTrusted) record(selection());
469 };
470 // Bubbling back to window picks up text a page handler set instead.
471 const replace = (event) => {
472 if (keying && event.isTrusted && event.defaultPrevented) record(event.clipboardData?.getData("text/plain"));
473 };
474 for (const type of ["copy", "cut"]) {
475 addEventListener(type, snapshot, true);
476 addEventListener(type, replace);
477 }
478 globalThis.takeCopy = () => {
479 const text = copied;
480 copied = "";
481 return text;
482 };
483})();`;
484
485const make = Effect.gen(function* () {
486 const config = yield* ServerConfig.ServerConfig;
487 const host = { platform: yield* HostProcess.Platform, arch: yield* HostProcess.Architecture };
488 const manager = yield* PreviewManager.PreviewManager;
489 const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
490 const environment = yield* ServerEnvironment.ServerEnvironment;
491 const previewBrowser = yield* PreviewBrowser.PreviewBrowser;
492 const desktopChannel = yield* DesktopBrowserChannel.DesktopBrowserChannel;
493 const runFork = Effect.runForkWith(yield* Effect.context<never>());
494 const launchServices = yield* Effect.context<ChildProcessSpawner.ChildProcessSpawner>();
495 // The fix every host error names, rendered for how this server was launched.
496 const setupCommand = yield* resolveRootCliCommand(PreviewBrowserHost.SETUP_SUBCOMMAND);
497
498 const tabs = new Map<string, ServerTab>();
499 const pendingTabs = new Map<string, Promise<ServerTab>>();
500 /** Sessions closed while their tab was still opening; the open discards its page. */
501 const closedPendingTabs = new Set<string>();
502 /** Popup pages waiting for the tab their `opened` event creates. */
503 const adoptedPages = new Map<string, { readonly page: Page; readonly openerTabId: string }>();
504 /** Sessions the manager closed, so their tabs end for good. Pruned once dropped. */
505 const closedSessions = new Set<string>();
506 let hostConnectionId: string | null = null;
507 let viewerResizeOrder = 0;
508
509 const contexts = new ServerBrowserContexts({
510 profilesDir: NodePath.join(config.stateDir, "server-browser", "profiles"),
511 executable: () => Effect.runPromise(previewBrowser.executable),
512 // Playwright's launch error drops Chrome's own output; its sandbox note survives.
513 diagnose: (executable, cause) =>
514 Effect.runPromiseWith(launchServices)(
515 PreviewBrowserHost.diagnoseLaunchFailure({
516 executable,
517 setupCommand,
518 output: /sandboxing failed/i.test(String(cause))
519 ? PreviewBrowserHost.NO_SANDBOX_SIGNATURE
520 : "",
521 }),
522 ),
523 onContextClose: (context) => {
524 for (const tab of tabs.values()) {
525 if (tab.page.context() === context) dropTab(tab, true);
526 }
527 },
528 });
529
530 const dialogStatus = (tab: ServerTab) =>
531 tab.dialog
532 ? {
533 type: tab.dialog.type(),
534 message: tab.dialog.message().slice(0, 4000),
535 defaultValue: tab.dialog.defaultValue().slice(0, 4000),
536 }
537 : null;
538
539 const broadcastControl = (tab: ServerTab) => {
540 for (const viewer of tab.viewers)
541 viewer.push({
542 _tag: "control",
543 canOperate: viewer.canOperate,
544 controller:
545 tab.control.controller === viewer.id
546 ? "you"
547 : tab.control.controller !== null
548 ? "another-viewer"
549 : tab.control.agentId !== null
550 ? "agent"
551 : "unclaimed",
552 generation: tab.control.generation,
553 dialog: dialogStatus(tab),
554 });
555 };
556
557 const resolveDialog = async (tab: ServerTab, input: PreviewAutomationDialogInput) => {
558 const dialog = tab.dialog;
559 if (!dialog) throw new Error("No dialog is pending.");
560 if (input.accept) await dialog.accept(input.promptText);
561 else await dialog.dismiss();
562 if (tab.dialog === dialog) tab.dialog = null;
563 ServerBrowserPage.invalidateRefs(tab.page);
564 broadcastControl(tab);
565 };
566
567 const report = (tab: ServerTab, navStatus: PreviewNavStatus) => {
568 void tab.cdp
569 .send("Page.getNavigationHistory")
570 .catch(() => null)
571 .then((history) => {
572 const index = history?.currentIndex ?? 0;
573 const count = history?.entries.length ?? 0;
574 runFork(
575 manager
576 .reportStatus({
577 threadId: tab.threadId,
578 tabId: tab.tabId,
579 serverControlled: true,
580 navStatus,
581 canGoBack: index > 0,
582 canGoForward: index < count - 1,
583 })
584 .pipe(Effect.ignore),
585 );
586 });
587 };
588
589 const reportLoaded = async (tab: ServerTab) => {
590 const url = tab.page.url();
591 // Chromium's error page loads after `requestfailed` and must not clear LoadFailed.
592 if (url === "about:blank" || url.startsWith("chrome-error://")) return;
593 const title = (await tab.page.title().catch(() => "")).slice(0, 512);
594 // A navigation that started while reading the title owns the status now.
595 if (tab.loading || tab.page.url() !== url) return;
596 report(tab, { _tag: "Success", url: url.slice(0, 2048), title });
597 };
598
599 const reportLiveTabs = () => {
600 const connectionId = hostConnectionId;
601 if (connectionId === null) return;
602 runFork(
603 environment.getEnvironmentId.pipe(
604 Effect.flatMap((environmentId) =>
605 broker.focusHost({
606 clientId: SERVER_HOST_CLIENT_ID,
607 environmentId,
608 connectionId,
609 focused: true,
610 liveTabs: [...tabs.values()].map((tab) => ({
611 threadId: tab.threadId,
612 tabId: tab.tabId,
613 visible: tab.viewers.size > 0,
614 })),
615 }),
616 ),
617 ),
618 );
619 };
620
621 /** The CSS size the page lays out in: the viewport shrunk by the tab's zoom, as Chrome zooms. */
622 const layoutSize = (tab: ServerTab) => {
623 const size = tab.page.viewportSize();
624 if (!size || tab.desktop) return size;
625 return {
626 width: Math.max(1, Math.round(size.width / tab.zoomFactor)),
627 height: Math.max(1, Math.round(size.height / tab.zoomFactor)),
628 };
629 };
630
631 /**
632 * Zooms a headless page as Chrome's zoom does: it lays out in fewer CSS
633 * pixels and draws each one larger, so frames keep their size. The desktop
634 * zooms the pages it renders itself.
635 */
636 const applyZoom = async (tab: ServerTab) => {
637 if (tab.desktop) return;
638 const size = tab.page.viewportSize();
639 if (!size) return;
640 if (tab.zoomFactor === 1) {
641 // Playwright's own override carries the plain viewport.
642 await tab.page.setViewportSize(size);
643 return;
644 }
645 const layout = layoutSize(tab)!;
646 await tab.cdp.send("Emulation.setDeviceMetricsOverride", {
647 width: layout.width,
648 height: layout.height,
649 deviceScaleFactor: RENDER_SCALE * tab.zoomFactor,
650 mobile: false,
651 });
652 };
653
654 const broadcastViewport = (tab: ServerTab) => {
655 const size = layoutSize(tab);
656 if (!size) return;
657 for (const viewer of tab.viewers) viewer.push({ _tag: "viewport", ...size });
658 };
659
660 const applySetting = async (tab: ServerTab, setting: PreviewViewportSetting) => {
661 tab.setting = setting;
662 // The desktop lays its webview out at the published setting itself.
663 if (tab.desktop) return;
664 const size =
665 fixedViewportSize(setting) ??
666 [...tab.viewers]
667 .map((viewer) => viewer.requestedSize)
668 .filter((requested) => requested !== null)
669 .sort((left, right) => right.order - left.order)[0] ??
670 UNATTACHED_FILL_VIEWPORT;
671 await tab.page.setViewportSize({ width: size.width, height: size.height });
672 await applyZoom(tab);
673 broadcastViewport(tab);
674 };
675
676 /** Applies a tab's published appearance and, for headless tabs, zoom. */
677 const applyRendering = async (tab: ServerTab, snapshot: PreviewSessionSnapshot) => {
678 const colorScheme = snapshot.colorScheme ?? "system";
679 if (colorScheme !== tab.colorScheme) {
680 tab.colorScheme = colorScheme;
681 await tab.page.emulateMedia({ colorScheme: colorScheme === "system" ? null : colorScheme });
682 }
683 const zoomFactor = snapshot.zoomFactor ?? 1;
684 if (zoomFactor !== tab.zoomFactor && !tab.desktop) {
685 tab.zoomFactor = zoomFactor;
686 await applyZoom(tab);
687 broadcastViewport(tab);
688 }
689 };
690
691 /** Whether the preview session for a tab still exists. */
692 const sessionOpen = (tab: ServerTab) => !closedSessions.has(tabKey(tab.threadId, tab.tabId));
693
694 const dropTab = (tab: ServerTab, closeSession: boolean) => {
695 const key = tabKey(tab.threadId, tab.tabId);
696 if (tabs.get(key) !== tab) return;
697 tabs.delete(key);
698 tab.closing = true;
699 clearAbortedNavigation(tab);
700 // A desktop page outlives the connection unless its session closed with it.
701 const end = tab.desktop && !closeSession && sessionOpen(tab) ? "reconnect" : "gone";
702 for (const viewer of tab.viewers) viewer.push({ _tag: end });
703 void tab.control.close().catch(constVoid);
704 // The desktop owns its page; letting go only ends this connection.
705 if (tab.desktop) void tab.desktop.close().catch(constVoid);
706 else void tab.page.close().catch(constVoid);
707 if (tab.isolatedContext) void tab.page.context().close().catch(constVoid);
708 void tab.recording?.encoder.close().catch(constVoid);
709 void NodeFSP.rm(downloadDir(tab), { recursive: true, force: true }).catch(constVoid);
710 reportLiveTabs();
711 if (closeSession) {
712 runFork(manager.close({ threadId: tab.threadId, tabId: tab.tabId }).pipe(Effect.ignore));
713 }
714 };
715
716 /**
717 * With a desktop app attached, every tab of this server renders there, so a
718 * new tab waits for its `<webview>` instead of launching headless.
719 */
720 const desktopRenders = (snapshot: PreviewSessionSnapshot) =>
721 desktopChannel.available
722 ? Effect.runPromise(
723 desktopChannel.awaitAttached(
724 { threadId: snapshot.threadId, tabId: snapshot.tabId },
725 DESKTOP_ATTACH_TIMEOUT,
726 ),
727 )
728 : Promise.resolve(false);
729
730 /** Connects to the desktop's page for a tab, which it renders and the server drives. */
731 const connectDesktop = async (snapshot: PreviewSessionSnapshot) => {
732 const scope = await Effect.runPromise(Scope.make());
733 try {
734 const endpoint = await Effect.runPromise(
735 desktopChannel
736 .endpoint({ threadId: snapshot.threadId, tabId: snapshot.tabId })
737 .pipe(Scope.provide(scope)),
738 );
739 const connected = await contexts.connectDesktopPage(endpoint);
740 return {
741 page: connected.page,
742 close: async () => {
743 await connected.browser.close().catch(constVoid);
744 await Effect.runPromise(Scope.close(scope, Exit.void));
745 },
746 };
747 } catch (cause) {
748 await Effect.runPromise(Scope.close(scope, Exit.void));
749 throw cause;
750 }
751 };
752
753 const createTab = async (snapshot: PreviewSessionSnapshot): Promise<ServerTab> => {
754 const adopted = adoptedPages.get(tabKey(snapshot.threadId, snapshot.tabId));
755 adoptedPages.delete(tabKey(snapshot.threadId, snapshot.tabId));
756 const desktop =
757 adopted === undefined && (await desktopRenders(snapshot))
758 ? await connectDesktop(snapshot)
759 : null;
760 // An agent tab without a profile (no client reported one) keeps throwaway storage.
761 const isolatedContext =
762 adopted === undefined &&
763 desktop === null &&
764 ((snapshot.automationOwner !== undefined && snapshot.profileId === undefined) ||
765 snapshot.profileId === INCOGNITO_BROWSER_PROFILE_ID);
766 const context =
767 adopted?.page.context() ??
768 desktop?.page.context() ??
769 (await contexts.contextFor(
770 snapshot.profileId ?? "default",
771 isolatedContext ? tabKey(snapshot.threadId, snapshot.tabId) : undefined,
772 ));
773 if (adopted?.page.isClosed()) throw new Error("The popup closed before it opened.");
774 // The desktop page already has its own clipboard.
775 if (!desktop) await prepareContext(context);
776 const page = adopted?.page ?? desktop?.page ?? (await context.newPage());
777 const cdp = await context.newCDPSession(page);
778 // The desktop's page is already a normal browser. A popup's first request
779 // went out before it became a tab; everything after presents as Chrome.
780 if (!desktop) await presentAsChrome(cdp, host);
781 if (!desktop) await recordCopies(cdp);
782 page.setDefaultTimeout(NAVIGATION_TIMEOUT_MS);
783 page.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
784 const control = new SessionControl(snapshot.automationOwner ?? null, () =>
785 ServerBrowserPage.invalidateRefs(page),
786 );
787 const tab: ServerTab = {
788 threadId: ThreadId.make(snapshot.threadId),
789 tabId: snapshot.tabId,
790 page,
791 cdp,
792 frameSessions: new Map(),
793 createdAt: Date.now(),
794 usedAt: Date.now(),
795 viewers: new Set(),
796 consoleEntries: [],
797 networkEntries: [],
798 actionTimeline: [],
799 control,
800 isolatedContext,
801 desktop: desktop === null ? null : { close: desktop.close },
802 profileId: snapshot.profileId,
803 openerTabId: adopted?.openerTabId,
804 downloads: [],
805 fileChooser: null,
806 dialog: null,
807 setting: snapshot.viewport ?? FILL_PREVIEW_VIEWPORT,
808 colorScheme: "system",
809 zoomFactor: 1,
810 loading: false,
811 closing: false,
812 recording: null,
813 recordingStart: null,
814 initialNavigation: null,
815 navigationGeneration: 0,
816 abortedNavigation: null,
817 captureLock: Promise.resolve(),
818 capturing: 0,
819 };
820 if (!desktop) {
821 await page.setViewportSize(fixedViewportSize(tab.setting) ?? UNATTACHED_FILL_VIEWPORT);
822 }
823 await applyRendering(tab, snapshot);
824 const isMainNavigation = (request: { isNavigationRequest(): boolean; frame(): unknown }) =>
825 request.isNavigationRequest() && request.frame() === page.mainFrame();
826 const navigationGenerations = new WeakMap<object, number>();
827 page.on("request", (request) => {
828 if (!isMainNavigation(request)) return;
829 navigationGenerations.set(request, ++tab.navigationGeneration);
830 clearAbortedNavigation(tab);
831 tab.loading = true;
832 report(tab, { _tag: "Loading", url: request.url().slice(0, 2048), title: "" });
833 });
834 page.on("load", () => {
835 tab.loading = false;
836 void reportLoaded(tab);
837 });
838 page.on("framenavigated", (frame) => {
839 // Same-document navigations (SPA routes) fire no load event.
840 if (frame === page.mainFrame() && !tab.loading) void reportLoaded(tab);
841 if (!desktop) void recordFrameCopies(tab, frame);
842 });
843 // An adopted popup's frames may have loaded before the tab listened.
844 if (adopted) for (const frame of page.frames()) void recordFrameCopies(tab, frame);
845 page.on("requestfailed", (request) => {
846 const errorText = request.failure()?.errorText ?? "";
847 pushBounded(tab.networkEntries, {
848 url: request.url(),
849 method: request.method(),
850 status: null,
851 failed: true,
852 errorText,
853 timestamp: new Date().toISOString(),
854 });
855 if (!isMainNavigation(request)) return;
856 // A navigation a newer one replaced leaves the status to the newer one. A
857 // popup's first request can predate the listener, so it is untracked and
858 // superseded by any tracked one.
859 const generation = navigationGenerations.get(request) ?? 0;
860 if (generation !== tab.navigationGeneration) return;
861 // An aborted navigation is usually a download; its `download` event settles the status.
862 if (errorText.includes("ERR_ABORTED")) {
863 const url = request.url();
864 const navigationGeneration = tab.navigationGeneration;
865 clearAbortedNavigation(tab);
866 tab.abortedNavigation = {
867 url,
868 generation: navigationGeneration,
869 timer: setTimeout(() => {
870 if (tab.abortedNavigation?.generation !== navigationGeneration) return;
871 tab.abortedNavigation = null;
872 if (!tab.closing) settleAbortedNavigation(tab, navigationGeneration, url, undefined);
873 }, ABORTED_NAVIGATION_DOWNLOAD_WAIT_MS),
874 };
875 return;
876 }
877 tab.loading = false;
878 const { code, description } = ServerBrowserPage.parseNetError(errorText);
879 report(tab, {
880 _tag: "LoadFailed",
881 url: request.url().slice(0, 2048),
882 title: "",
883 code,
884 description,
885 });
886 });
887 page.on("response", (response) => {
888 pushBounded(tab.networkEntries, {
889 url: response.url(),
890 method: response.request().method(),
891 status: response.status(),
892 failed: false,
893 timestamp: new Date().toISOString(),
894 });
895 });
896 page.on("console", (message) => {
897 pushBounded(tab.consoleEntries, {
898 level: message.type(),
899 text: message.text().slice(0, 2_000),
900 timestamp: new Date().toISOString(),
901 });
902 });
903 page.on("dialog", (dialog) => {
904 tab.dialog = dialog;
905 broadcastControl(tab);
906 });
907 page.on("download", (download) => void saveDownload(tab, download));
908 page.on("filechooser", (chooser) => void offerFileChooser(tab, chooser));
909 // Popups become tabs and keep `window.opener`, so sign-in popups can report back.
910 page.on("popup", (popup) => void adoptPopup(tab, popup));
911 // Playwright cannot reload a crashed page, so it must leave the tab list.
912 const key = tabKey(tab.threadId, tab.tabId);
913 if (closedPendingTabs.delete(key)) {
914 await control.close().catch(constVoid);
915 if (desktop) await desktop.close().catch(constVoid);
916 else await page.close().catch(constVoid);
917 if (isolatedContext) await context.close().catch(constVoid);
918 throw new ServerBrowserTabNotFoundError({ threadId: tab.threadId, tabId: tab.tabId });
919 }
920 page.on("close", () => dropTab(tab, true));
921 page.on("crash", () => dropTab(tab, true));
922 tabs.set(key, tab);
923 reportLiveTabs();
924 // A popup is already loading its own URL, and the desktop loads its tab's.
925 if (!adopted && !desktop && snapshot.navStatus._tag === "Loading") {
926 tab.initialNavigation = page
927 .goto(snapshot.navStatus.url, { waitUntil: "commit", timeout: NAVIGATION_TIMEOUT_MS })
928 .then(constVoid);
929 // Background creation keeps the failed tab; automation awaits the original error.
930 void tab.initialNavigation.catch(constVoid);
931 }
932 return tab;
933 };
934
935 const downloadsRoot = NodePath.join(config.stateDir, "server-browser", "downloads");
936 // Tab ids are opaque server strings; hashing keeps them out of the path.
937 const downloadDir = (tab: ServerTab) =>
938 NodePath.join(
939 downloadsRoot,
940 NodeCrypto.createHash("sha256").update(tabKey(tab.threadId, tab.tabId)).digest("hex"),
941 );
942
943 const clearAbortedNavigation = (tab: ServerTab) => {
944 if (tab.abortedNavigation !== null) clearTimeout(tab.abortedNavigation.timer);
945 tab.abortedNavigation = null;
946 };
947
948 /**
949 * Settles the status of a navigation that aborted, usually into a download.
950 * A tab opened straight to the file has no page to show, so it reports the
951 * file with its download; a page that linked to it keeps showing itself.
952 */
953 const settleAbortedNavigation = (
954 tab: ServerTab,
955 generation: number,
956 url: string,
957 offered: { readonly id: string; readonly fileName: string } | undefined,
958 ) => {
959 // A newer navigation owns the status.
960 if (tab.navigationGeneration !== generation) return;
961 tab.loading = false;
962 if (tab.page.url() !== "about:blank") {
963 void reportLoaded(tab);
964 return;
965 }
966 report(tab, {
967 _tag: "LoadFailed",
968 url: url.slice(0, 2048),
969 title: "",
970 code: -3,
971 description: "ERR_ABORTED",
972 ...(offered === undefined ? {} : { download: offered }),
973 });
974 };
975
976 const saveDownload = async (tab: ServerTab, download: Download) => {
977 const id = NodeCrypto.randomUUID();
978 const path = NodePath.join(downloadDir(tab), id);
979 const navigation =
980 download.url() === tab.abortedNavigation?.url ? tab.abortedNavigation.generation : null;
981 if (navigation !== null) clearAbortedNavigation(tab);
982 let offered: { readonly id: string; readonly fileName: string } | undefined;
983 try {
984 if (await download.failure()) return;
985 await NodeFSP.mkdir(downloadDir(tab), { recursive: true });
986 await download.saveAs(path);
987 const { size } = await NodeFSP.stat(path);
988 if (tab.closing || size > DOWNLOAD_MAX_BYTES) {
989 await NodeFSP.rm(path, { force: true });
990 return;
991 }
992 const saved: ServerDownload = {
993 id,
994 fileName: download.suggestedFilename().slice(0, 255) || "download",
995 path,
996 sizeBytes: size,
997 url: download.url().slice(0, 2048),
998 completedAt: new Date().toISOString(),
999 };
1000 tab.downloads.push(saved);
1001 for (const evicted of tab.downloads.splice(0, tab.downloads.length - DOWNLOAD_LIMIT)) {
1002 await NodeFSP.rm(evicted.path, { force: true }).catch(constVoid);
1003 }
1004 offered = { id, fileName: saved.fileName };
1005 // A tab whose own address was the file shows it in place (see
1006 // settleAbortedNavigation), unless it navigated on while the file saved.
1007 const shownInTab =
1008 navigation === tab.navigationGeneration && tab.page.url() === "about:blank";
1009 if (shownInTab) return;
1010 // Only the person driving the page gets the file offered; agents read it from status.
1011 const controller = [...tab.viewers].find((viewer) => viewer.id === tab.control.controller);
1012 controller?.push({
1013 _tag: "download",
1014 id,
1015 fileName: saved.fileName,
1016 sizeBytes: saved.sizeBytes,
1017 });
1018 } catch (cause) {
1019 await NodeFSP.rm(path, { force: true }).catch(constVoid);
1020 runFork(Effect.logWarning("server preview download failed", { cause }));
1021 } finally {
1022 if (navigation !== null && !tab.closing) {
1023 settleAbortedNavigation(tab, navigation, download.url(), offered);
1024 }
1025 }
1026 };
1027
1028 const fileChooserMessage = (tab: ServerTab): ServerBrowserViewerOutput | null =>
1029 tab.fileChooser
1030 ? {
1031 _tag: "fileChooser",
1032 id: tab.fileChooser.id,
1033 multiple: tab.fileChooser.chooser.isMultiple(),
1034 accept: tab.fileChooser.accept,
1035 }
1036 : null;
1037
1038 const offerFileChooser = async (tab: ServerTab, chooser: FileChooser) => {
1039 const previous = tab.fileChooser;
1040 const accept =
1041 (await chooser
1042 .element()
1043 .getAttribute("accept")
1044 .catch(() => null)) ?? "";
1045 // A newer picker replaces an unanswered one, as a real browser allows only one.
1046 if (previous) closeFileChooser(tab);
1047 tab.fileChooser = {
1048 id: NodeCrypto.randomUUID(),
1049 chooser,
1050 accept: accept.slice(0, 1024),
1051 offeredTo: null,
1052 };
1053 pushFileChooser(tab);
1054 };
1055
1056 const pushFileChooser = (tab: ServerTab) => {
1057 const open = tab.fileChooser;
1058 const controller = [...tab.viewers].find((viewer) => viewer.id === tab.control.controller);
1059 if (!open || !controller) return;
1060 if (open.offeredTo !== controller.id) {
1061 // A new controller gets a fresh id; the previous one can no longer answer.
1062 tab.fileChooser = { ...open, id: NodeCrypto.randomUUID(), offeredTo: controller.id };
1063 if (open.offeredTo !== null)
1064 for (const viewer of tab.viewers) viewer.push({ _tag: "fileChooserClosed", id: open.id });
1065 }
1066 const message = fileChooserMessage(tab);
1067 if (message) controller.push(message);
1068 };
1069
1070 const closeFileChooser = (tab: ServerTab) => {
1071 const open = tab.fileChooser;
1072 if (!open) return;
1073 tab.fileChooser = null;
1074 for (const viewer of tab.viewers) viewer.push({ _tag: "fileChooserClosed", id: open.id });
1075 };
1076
1077 /** Hands uploaded files to the page's open picker; an empty list cancels it. */
1078 const setChooserFiles = async (
1079 input: Parameters<ServerBrowser["Service"]["answerFileChooser"]>[0],
1080 ) => {
1081 const tab = tabs.get(tabKey(input.threadId, input.tabId));
1082 const open = tab?.fileChooser;
1083 if (
1084 !tab ||
1085 !open ||
1086 open.id !== input.chooserId ||
1087 open.offeredTo === null ||
1088 open.offeredTo !== tab.control.controller
1089 )
1090 return false;
1091 if (input.files.length > 0) {
1092 await open.chooser.setFiles(
1093 open.chooser.isMultiple() ? [...input.files] : input.files.slice(0, 1),
1094 );
1095 }
1096 if (tab.fileChooser === open) closeFileChooser(tab);
1097 return true;
1098 };
1099 /** The agent's files go to a named file input, or else to the page's open picker. */
1100 const uploadFiles = async (tab: ServerTab, input: PreviewAutomationUploadInput) => {
1101 const relative = input.paths.find((path) => !NodePath.isAbsolute(path));
1102 if (relative !== undefined)
1103 throw new ServerBrowserPage.ServerBrowserOperationError(
1104 "PreviewAutomationExecutionError",
1105 `Upload paths must be absolute: ${relative}`,
1106 );
1107 if (await ServerBrowserPage.setInputFiles(tab.page, input)) return undefined;
1108 const open = tab.fileChooser;
1109 if (!open)
1110 throw new ServerBrowserPage.ServerBrowserOperationError(
1111 "PreviewAutomationExecutionError",
1112 "No file picker is open. Click the page's upload control first, or pass the file input's locator.",
1113 );
1114 if (input.paths.length > 1 && !open.chooser.isMultiple())
1115 throw new ServerBrowserPage.ServerBrowserOperationError(
1116 "PreviewAutomationExecutionError",
1117 "This file picker accepts one file.",
1118 );
1119 if (input.paths.length > 0)
1120 await open.chooser.setFiles([...input.paths], {
1121 timeout: input.timeoutMs ?? NAVIGATION_TIMEOUT_MS,
1122 });
1123 if (tab.fileChooser === open) closeFileChooser(tab);
1124 return undefined;
1125 };
1126
1127 const answerFileChooser: ServerBrowser["Service"]["answerFileChooser"] = (input) =>
1128 Effect.promise(() => setChooserFiles(input).catch(() => false));
1129
1130 const openDownload: ServerBrowser["Service"]["openDownload"] = (input) =>
1131 Effect.sync(() => {
1132 const download = tabs
1133 .get(tabKey(input.threadId, input.tabId))
1134 ?.downloads.find((candidate) => candidate.id === input.downloadId);
1135 return download === undefined
1136 ? Option.none()
1137 : Option.some({ path: download.path, fileName: download.fileName });
1138 });
1139
1140 const preparedContexts = new WeakSet<BrowserContext>();
1141 const prepareContext = async (context: BrowserContext) => {
1142 if (preparedContexts.has(context)) return;
1143 preparedContexts.add(context);
1144 await context.grantPermissions(["clipboard-write"]);
1145 };
1146
1147 /** Records the copies made in each frame a session reaches. */
1148 const recordCopies = async (cdp: CDPSession) => {
1149 // Scripts for new documents run only while this session's Page domain is on.
1150 await cdp.send("Page.enable");
1151 await cdp.send("Page.addScriptToEvaluateOnNewDocument", {
1152 source: CLIPBOARD_SCRIPT,
1153 worldName: CLIPBOARD_WORLD,
1154 runImmediately: true,
1155 });
1156 };
1157
1158 /**
1159 * A frame in its own process, such as one with an `Origin-Agent-Cluster`
1160 * header, is out of the page session's reach, so it records copies through
1161 * its own session until that target closes.
1162 */
1163 const recordFrameCopies = async (tab: ServerTab, frame: Frame) => {
1164 if (frame === tab.page.mainFrame() || tab.frameSessions.has(frame)) return;
1165 // A frame in its parent's process has no session of its own.
1166 const cdp = await tab.page
1167 .context()
1168 .newCDPSession(frame)
1169 .catch(() => null);
1170 if (!cdp) return;
1171 if (tab.frameSessions.has(frame) || frame.isDetached()) {
1172 await cdp.detach().catch(constVoid);
1173 return;
1174 }
1175 tab.frameSessions.set(frame, cdp);
1176 cdp.on("close", () => {
1177 if (tab.frameSessions.get(frame) !== cdp) return;
1178 tab.frameSessions.delete(frame);
1179 // A frame that moved to another process records through its new target.
1180 if (!frame.isDetached()) void recordFrameCopies(tab, frame);
1181 });
1182 await recordCopies(cdp).catch(constVoid);
1183 };
1184
1185 /** Clears and returns the copy of each frame a session reaches. */
1186 const takeSessionCopies = async (cdp: CDPSession) => {
1187 // An out-of-process frame that closed meanwhile copied nothing.
1188 const tree = await cdp.send("Page.getFrameTree").catch(() => null);
1189 if (!tree) return [];
1190 const frameIds = (node: typeof tree.frameTree): Array<string> => [
1191 node.frame.id,
1192 ...(node.childFrames ?? []).flatMap(frameIds),
1193 ];
1194 return Promise.all(
1195 frameIds(tree.frameTree).map((frameId) =>
1196 cdp
1197 .send("Page.createIsolatedWorld", { frameId, worldName: CLIPBOARD_WORLD })
1198 .then(({ executionContextId }) =>
1199 cdp.send("Runtime.evaluate", {
1200 contextId: executionContextId,
1201 expression: "globalThis.takeCopy?.()",
1202 returnByValue: true,
1203 }),
1204 )
1205 .then(
1206 ({ result }) => (typeof result.value === "string" ? result.value : ""),
1207 // A frame that detached meanwhile copied nothing.
1208 () => "",
1209 ),
1210 ),
1211 );
1212 };
1213
1214 /** Clears every frame's copy and returns what the key just handled copied or cut. */
1215 const takeCopiedText = async (tab: ServerTab) => {
1216 const copies = await Promise.all(
1217 [tab.cdp, ...tab.frameSessions.values()].map(takeSessionCopies),
1218 );
1219 return copies.flat().find((text) => text.length > 0);
1220 };
1221
1222 const adoptPopup = async (opener: ServerTab, popup: Page) => {
1223 const agentId = opener.control.agentId;
1224 // A popup past an agent's limit closes; its page sees window.open return a closed window.
1225 if (opener.closing || (agentId !== null && atTabLimit(agentId))) {
1226 await popup.close().catch(constVoid);
1227 return;
1228 }
1229 const url = popup.url();
1230 // Captured now: the person whose click opened the popup gets switched to it.
1231 const controller = [...opener.viewers].find(
1232 (viewer) => viewer.id === opener.control.controller,
1233 );
1234 await Effect.runPromise(
1235 manager.open({
1236 threadId: opener.threadId,
1237 ...(/^https?:/i.test(url) ? { url } : {}),
1238 runtime: "server",
1239 ...(opener.profileId === undefined ? {} : { profileId: opener.profileId }),
1240 // Agent popups stay with the agent and only float when it asks, like its own opens.
1241 ...(opener.control.agentId === null
1242 ? {}
1243 : { automationOwner: opener.control.agentId, reveal: false }),
1244 beforePublish: (snapshot) =>
1245 adoptedPages.set(tabKey(snapshot.threadId, snapshot.tabId), {
1246 page: popup,
1247 openerTabId: opener.tabId,
1248 }),
1249 }),
1250 ).then(
1251 (snapshot) => {
1252 // The opener stays open behind it, so `window.opener` can still report back.
1253 if (opener.control.agentId === null)
1254 controller?.push({ _tag: "popup", tabId: snapshot.tabId });
1255 },
1256 async () => {
1257 await popup.close().catch(constVoid);
1258 },
1259 );
1260 };
1261
1262 const ensureTab = (snapshot: PreviewSessionSnapshot): Promise<ServerTab> => {
1263 const key = tabKey(snapshot.threadId, snapshot.tabId);
1264 const pending = pendingTabs.get(key);
1265 if (pending) return pending;
1266 const existing = tabs.get(key);
1267 if (existing) return Promise.resolve(existing);
1268 const opening = createTab(snapshot)
1269 .catch((cause: unknown) => {
1270 runFork(
1271 Effect.logWarning(
1272 isHostSetupError(cause) ? cause.message : "server preview tab failed to start",
1273 { cause },
1274 ),
1275 );
1276 throw cause;
1277 })
1278 .finally(() => {
1279 pendingTabs.delete(key);
1280 closedPendingTabs.delete(key);
1281 });
1282 pendingTabs.set(key, opening);
1283 return opening;
1284 };
1285
1286 const findTab = (threadId: string, tabId: string) =>
1287 Effect.gen(function* () {
1288 const existing = tabs.get(tabKey(threadId, tabId));
1289 if (existing) return existing;
1290 const { sessions } = yield* manager.list({ threadId: ThreadId.make(threadId) });
1291 const snapshot = sessions.find(
1292 (session) => session.tabId === tabId && session.runtime === "server",
1293 );
1294 if (!snapshot) return yield* new ServerBrowserTabNotFoundError({ threadId, tabId });
1295 return yield* Effect.tryPromise({
1296 try: () => ensureTab(snapshot),
1297 catch: (cause) => (isTabNotFound(cause) ? cause : new ServerBrowserLaunchError({ cause })),
1298 });
1299 });
1300
1301 const atTabLimit = (agentSessionId: string) =>
1302 tabs.size + pendingTabs.size >= SERVER_TAB_LIMIT ||
1303 [...tabs.values()].filter((tab) => tab.control.agentId === agentSessionId).length >=
1304 AGENT_TAB_LIMIT;
1305
1306 const assertTabCapacity = (agentSessionId: string) => {
1307 if (atTabLimit(agentSessionId))
1308 throw new BrowserControlInterrupted(
1309 `Too many server browser tabs are open (${AGENT_TAB_LIMIT} per agent session, ${SERVER_TAB_LIMIT} per server). Close one with t3_preview_close or reuse one from preview_status tabs.`,
1310 "tabLimit",
1311 );
1312 };
1313
1314 /** Closes agent tabs that no one watches and the agent stopped using. */
1315 const closeIdleAgentTabs = () => {
1316 const cutoff = Date.now() - AGENT_TAB_IDLE_MS;
1317 for (const tab of tabs.values()) {
1318 if (tab.control.agentId !== null && tab.viewers.size === 0 && tab.usedAt < cutoff)
1319 dropTab(tab, true);
1320 }
1321 };
1322
1323 /**
1324 * The tab a request without a tabId means: the caller's newest tab, else the
1325 * thread's tab the user is looking at or used last, so "this page" works.
1326 */
1327 const latestThreadTab = (threadId: string, agentSessionId?: string) => {
1328 const threadTabs = [...tabs.values()].filter((tab) => tab.threadId === threadId);
1329 return (
1330 threadTabs
1331 .filter((tab) => tab.control.agentId === agentSessionId)
1332 .sort((left, right) => right.createdAt - left.createdAt)[0] ??
1333 threadTabs.sort(
1334 (left, right) =>
1335 Number(right.viewers.size > 0) - Number(left.viewers.size > 0) ||
1336 right.usedAt - left.usedAt,
1337 )[0]
1338 );
1339 };
1340
1341 const tabOwner = (tab: ServerTab) =>
1342 tab.control.controller !== null
1343 ? ("human" as const)
1344 : tab.control.agentId !== null
1345 ? ("agent" as const)
1346 : ("unclaimed" as const);
1347
1348 /** The latest profile list a client reported; agents choose from it. */
1349 let reportedProfiles: {
1350 readonly profiles: ReadonlyArray<BrowserProfile>;
1351 readonly defaultProfileId: string;
1352 } | null = null;
1353
1354 const reportProfiles: ServerBrowser["Service"]["reportProfiles"] = (input) =>
1355 Effect.sync(() => {
1356 const profiles = resolveBrowserProfiles(input.profiles);
1357 reportedProfiles = {
1358 profiles,
1359 defaultProfileId:
1360 findBrowserProfile(profiles, input.defaultProfileId)?.id ?? DEFAULT_BROWSER_PROFILE_ID,
1361 };
1362 });
1363
1364 const profileStatus = () => {
1365 const profiles = reportedProfiles?.profiles ?? BUILT_IN_BROWSER_PROFILES;
1366 return {
1367 profiles: profiles.map((profile) => ({
1368 id: profile.id,
1369 name: profile.name,
1370 ...(profile.kind === "incognito" ? { incognito: true } : {}),
1371 })),
1372 ...(reportedProfiles ? { defaultProfileId: reportedProfiles.defaultProfileId } : {}),
1373 };
1374 };
1375
1376 /** Matches an id, then a case-insensitive name. Omitted means the reported default. */
1377 const resolveOpenProfile = (requested: string | undefined): string | undefined => {
1378 if (requested === undefined) return reportedProfiles?.defaultProfileId;
1379 const profiles = reportedProfiles?.profiles ?? BUILT_IN_BROWSER_PROFILES;
1380 const wanted = requested.toLowerCase();
1381 const match =
1382 findBrowserProfile(profiles, requested) ??
1383 profiles.find((profile) => profile.name.toLowerCase() === wanted);
1384 if (match) return match.id;
1385 throw new ServerBrowserPage.ServerBrowserOperationError(
1386 "PreviewAutomationUnknownProfileError",
1387 `No browser profile is named "${requested}". Use one of: ${profiles
1388 .map((profile) => `${profile.name} (id ${profile.id})`)
1389 .join(", ")}.`,
1390 );
1391 };
1392
1393 const statusWithTitle = async (
1394 tab: ServerTab | undefined,
1395 agentSessionId?: string,
1396 ): Promise<PreviewAutomationStatus> => {
1397 if (!tab) {
1398 return {
1399 available: false,
1400 visible: false,
1401 tabId: null,
1402 url: null,
1403 title: null,
1404 loading: false,
1405 ...profileStatus(),
1406 };
1407 }
1408 const url = tab.page.url();
1409 const viewport = tab.page.viewportSize();
1410 const status = {
1411 available: true,
1412 visible: tab.viewers.size > 0,
1413 tabId: tab.tabId,
1414 url: url === "about:blank" ? null : url,
1415 title: null,
1416 loading: tab.loading,
1417 control: {
1418 owner: tabOwner(tab),
1419 ownedByCaller: tab.control.agentId === agentSessionId,
1420 generation: tab.control.generation,
1421 },
1422 dialog: dialogStatus(tab),
1423 fileChooser: tab.fileChooser
1424 ? { multiple: tab.fileChooser.chooser.isMultiple(), accept: tab.fileChooser.accept }
1425 : null,
1426 viewportSetting: tab.setting,
1427 ...(viewport ? { viewport } : {}),
1428 tabs: [...tabs.values()]
1429 .filter((candidate) => candidate.threadId === tab.threadId)
1430 .map((candidate) => ({
1431 tabId: candidate.tabId,
1432 url: candidate.page.url() === "about:blank" ? null : candidate.page.url(),
1433 ...(candidate.openerTabId === undefined ? {} : { openerTabId: candidate.openerTabId }),
1434 owner: tabOwner(candidate),
1435 ownedByCaller: candidate.control.agentId === agentSessionId,
1436 visible: candidate.viewers.size > 0,
1437 ...(candidate.profileId === undefined ? {} : { profileId: candidate.profileId }),
1438 })),
1439 ...profileStatus(),
1440 downloads: tab.downloads.map(({ fileName, path, sizeBytes, url, completedAt }) => ({
1441 fileName,
1442 path,
1443 sizeBytes,
1444 url,
1445 completedAt,
1446 })),
1447 };
1448 if (status.url === null || tab.dialog) return status;
1449 return { ...status, title: (await tab.page.title().catch(() => "")) || null };
1450 };
1451
1452 const resolveNavigationUrl = (input: PreviewAutomationNavigateInput) => {
1453 if (input.url !== undefined) return normalizePreviewUrl(input.url);
1454 const target = input.target!;
1455 if (target.kind === "url") return normalizePreviewUrl(target.url);
1456 // The browser runs inside the environment, so its ports are loopback.
1457 const path = target.path ?? "";
1458 return `${target.protocol ?? "http"}://localhost:${target.port}${path.startsWith("/") || path === "" ? path : `/${path}`}`;
1459 };
1460
1461 const navigate = async (
1462 tab: ServerTab,
1463 url: string,
1464 readiness: "load" | "domContentLoaded" | "none",
1465 timeout: number,
1466 ) => {
1467 const navigation = tab.page.goto(url, {
1468 timeout,
1469 waitUntil:
1470 readiness === "domContentLoaded"
1471 ? "domcontentloaded"
1472 : readiness === "none"
1473 ? "commit"
1474 : "load",
1475 });
1476 if (readiness === "none") {
1477 tab.control.track(navigation);
1478 return;
1479 }
1480 await navigation.catch((cause: unknown) => {
1481 const message = cause instanceof Error ? cause.message : String(cause);
1482 if (message.includes("Download is starting")) {
1483 throw new ServerBrowserPage.ServerBrowserOperationError(
1484 "PreviewAutomationExecutionError",
1485 `${url} is a file this browser cannot show, so it was downloaded instead. preview_status lists it under downloads.`,
1486 );
1487 }
1488 if (/ERR_[A-Z_]+/.test(message)) {
1489 throw new ServerBrowserPage.ServerBrowserOperationError(
1490 "PreviewAutomationExecutionError",
1491 `Navigation to ${url} failed: ${ServerBrowserPage.parseNetError(message).description}`,
1492 );
1493 }
1494 throw cause;
1495 });
1496 };
1497
1498 const withCaptureLock = <A>(tab: ServerTab, operation: () => Promise<A>): Promise<A> => {
1499 const run = tab.captureLock.then(() => {
1500 if (tab.closing) throw new Error("The preview tab closed.");
1501 return operation();
1502 });
1503 tab.captureLock = run.then(constVoid, constVoid);
1504 return run;
1505 };
1506
1507 const startRecording = (tab: ServerTab): Promise<Recording> => {
1508 const started = withCaptureLock(tab, async () => {
1509 if (tab.recording) return tab.recording;
1510 const encoder = await contexts.scratchPage();
1511 let session: CDPSession | null = null;
1512 try {
1513 await encoder.evaluate(ServerBrowserPage.RECORDING_ENCODER_SCRIPT);
1514 // Seed idle pages before returning so an immediate stop has a frame to encode.
1515 const firstFrame = await ServerBrowserPage.captureViewport(tab.page, tab.cdp, {
1516 format: "jpeg",
1517 quality: RECORDING_SCREENCAST.quality,
1518 scale: 1,
1519 });
1520 await encoder.evaluate(
1521 ([data, width]) =>
1522 (globalThis as unknown as EncoderWindow).__t3Recorder.frame(data, width),
1523 [firstFrame, tab.page.viewportSize()?.width ?? UNATTACHED_FILL_VIEWPORT.width] as const,
1524 );
1525 const opened = await tab.page.context().newCDPSession(tab.page);
1526 session = opened;
1527 const framesInFlight = new Set<Promise<void>>();
1528 opened.on("Page.screencastFrame", (frame) => {
1529 const cssWidth = tab.page.viewportSize()?.width ?? frame.metadata.deviceWidth;
1530 const delivered: Promise<void> = encoder
1531 .evaluate(
1532 ([data, width]) =>
1533 (globalThis as unknown as EncoderWindow).__t3Recorder.frame(data, width),
1534 [frame.data, cssWidth] as const,
1535 )
1536 .then(async (accepted) => {
1537 if (!accepted) await opened.send("Page.stopScreencast");
1538 })
1539 .catch(constVoid)
1540 .finally(() => {
1541 framesInFlight.delete(delivered);
1542 void opened
1543 .send("Page.screencastFrameAck", { sessionId: frame.sessionId })
1544 .catch(constVoid);
1545 });
1546 framesInFlight.add(delivered);
1547 });
1548 await opened.send("Page.startScreencast", RECORDING_SCREENCAST);
1549 if (tab.closing) throw new Error("The tab closed while the recording started.");
1550 const recording: Recording = {
1551 encoder,
1552 session: opened,
1553 startedAt: new Date().toISOString(),
1554 framesInFlight,
1555 };
1556 tab.recording = recording;
1557 return recording;
1558 } catch (cause) {
1559 // A start that fails partway must not leave its encoder page behind.
1560 await encoder.close().catch(constVoid);
1561 await session?.detach().catch(constVoid);
1562 throw cause;
1563 }
1564 }).finally(() => {
1565 if (tab.recordingStart === started) tab.recordingStart = null;
1566 });
1567 tab.recordingStart = started;
1568 return started;
1569 };
1570
1571 // Scaled captures repaint every screencast; pause them to avoid leaking that frame.
1572 const withScreencastsPaused = <A>(tab: ServerTab, capture: () => Promise<A>): Promise<A> =>
1573 withCaptureLock(tab, async () => {
1574 tab.capturing += 1;
1575 const recording = tab.recording;
1576 try {
1577 await Promise.all([
1578 ...[...tab.viewers].map((viewer) => viewer.pause()),
1579 recording?.session.send("Page.stopScreencast").catch(constVoid),
1580 ]);
1581 return await capture();
1582 } finally {
1583 tab.capturing -= 1;
1584 // Viewers that attached during the capture start here too.
1585 await Promise.all([
1586 ...[...tab.viewers].map((viewer) => viewer.resume()),
1587 recording && tab.recording === recording
1588 ? recording.session.send("Page.startScreencast", RECORDING_SCREENCAST).catch(constVoid)
1589 : undefined,
1590 ]);
1591 }
1592 });
1593
1594 const stopRecording = (tab: ServerTab) =>
1595 withCaptureLock(tab, async () => {
1596 const recording = tab.recording;
1597 if (!recording) {
1598 throw new ServerBrowserPage.ServerBrowserOperationError(
1599 "PreviewAutomationRecordingNotActiveError",
1600 "No recording is active for this tab.",
1601 );
1602 }
1603 let mimeType: string | null;
1604 const chunks: Array<Buffer> = [];
1605 try {
1606 await recording.session.send("Page.stopScreencast").catch(constVoid);
1607 // The last frames may still be on their way into the encoder.
1608 await Promise.all(recording.framesInFlight);
1609 await recording.session.detach().catch(constVoid);
1610 const stopped = await recording.encoder.evaluate(() =>
1611 (globalThis as unknown as EncoderWindow).__t3Recorder.stop(),
1612 );
1613 mimeType = stopped.mimeType;
1614 // Checked before the transfer so an oversized video never lands in this process.
1615 if (stopped.bytes > PROVIDER_SEND_TURN_MAX_FILE_BYTES) {
1616 throw new ServerBrowserPage.ServerBrowserOperationError(
1617 "PreviewAutomationRecordingTooLargeError",
1618 "The recording is larger than the attachment limit.",
1619 );
1620 }
1621 for (let index = 0; index < stopped.count; index += 1) {
1622 const chunk = await recording.encoder.evaluate(
1623 (chunkIndex) => (globalThis as unknown as EncoderWindow).__t3Recorder.chunk(chunkIndex),
1624 index,
1625 );
1626 chunks.push(Buffer.from(chunk, "base64"));
1627 }
1628 } finally {
1629 await recording.encoder.close().catch(constVoid);
1630 tab.recording = null;
1631 }
1632 const data = Buffer.concat(chunks);
1633 if (!mimeType || data.byteLength === 0) {
1634 throw new ServerBrowserPage.ServerBrowserOperationError(
1635 "PreviewAutomationExecutionError",
1636 "The recording captured no frames.",
1637 );
1638 }
1639 // Use the desktop upload location so the MCP handler can claim the recording.
1640 const extension = mimeType.startsWith("video/mp4") ? "mp4" : "webm";
1641 const pendingId = `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${NodeCrypto.randomUUID()}-${extension}`;
1642 const path = NodePath.join(config.attachmentsDir, `${pendingId}.${extension}`);
1643 await NodeFSP.mkdir(config.attachmentsDir, { recursive: true });
1644 await NodeFSP.writeFile(path, data);
1645 return {
1646 id: pendingId,
1647 tabId: tab.tabId,
1648 path,
1649 mimeType: mimeType.split(";")[0]!,
1650 sizeBytes: data.byteLength,
1651 createdAt: new Date().toISOString(),
1652 uploadedAttachmentId: pendingId,
1653 };
1654 });
1655
1656 let pointerSequence = 0;
1657 /**
1658 * Shows viewers and recordings where the agent is about to act. Nobody is
1659 * watching a headless agent tab, so it pays no glide delay.
1660 */
1661 const pointerFor =
1662 (tab: ServerTab): ServerBrowserPage.PointerReporter =>
1663 async ({ x, y }, phase) => {
1664 const encoder = tab.recording?.encoder;
1665 // A desktop-rendered tab is always on screen in the desktop app.
1666 if (tab.viewers.size === 0 && !encoder && !tab.desktop) return;
1667 const show = (next: "move" | "click") => {
1668 const sequence = ++pointerSequence;
1669 for (const viewer of tab.viewers)
1670 viewer.push({ _tag: "pointer", phase: next, x, y, sequence });
1671 if (tab.desktop)
1672 runFork(
1673 desktopChannel.pointer(
1674 { threadId: tab.threadId, tabId: tab.tabId },
1675 { phase: next, x, y },
1676 ),
1677 );
1678 void encoder
1679 ?.evaluate(
1680 ([px, py, click]) =>
1681 (globalThis as unknown as EncoderWindow).__t3Recorder?.cursor(px, py, click),
1682 [x, y, next === "click"] as const,
1683 )
1684 .catch(constVoid);
1685 };
1686 show("move");
1687 await sleepUntil(Date.now() + AGENT_CURSOR_MOVE_MS);
1688 if (phase !== "click") return;
1689 show("click");
1690 await sleepUntil(Date.now() + AGENT_CURSOR_CLICK_LEAD_MS);
1691 };
1692
1693 const recordAction = <A>(tab: ServerTab, action: string, run: () => Promise<A>): Promise<A> => {
1694 const event: {
1695 -readonly [K in keyof PreviewAutomationActionEvent]: PreviewAutomationActionEvent[K];
1696 } = {
1697 id: NodeCrypto.randomUUID(),
1698 action,
1699 status: "running",
1700 startedAt: new Date().toISOString(),
1701 };
1702 pushBounded(tab.actionTimeline, event, ACTION_TIMELINE_LIMIT);
1703 return run().then(
1704 (result) => {
1705 event.status = "succeeded";
1706 event.completedAt = new Date().toISOString();
1707 return result;
1708 },
1709 (cause: unknown) => {
1710 event.status = "failed";
1711 event.completedAt = new Date().toISOString();
1712 event.error = cause instanceof Error ? cause.message.split("\n")[0] : String(cause);
1713 throw cause;
1714 },
1715 );
1716 };
1717
1718 const requireTab = async (request: PreviewAutomationRequest) => {
1719 if (!request.agentSessionId)
1720 throw new BrowserControlInterrupted("The agent session is missing. Reconnect the provider.");
1721 const owned = [...tabs.values()].filter(
1722 (tab) => tab.threadId === request.threadId && tab.control.agentId === request.agentSessionId,
1723 );
1724 if (!request.tabIdExplicit && owned.length > 1)
1725 throw new BrowserControlInterrupted(
1726 "Multiple tabs belong to this agent session. Pass a tabId from preview_open or preview_status tabs.",
1727 "tabRequired",
1728 );
1729 const tab =
1730 request.tabId === undefined
1731 ? latestThreadTab(request.threadId, request.agentSessionId)
1732 : await Effect.runPromise(
1733 findTab(request.threadId, request.tabId).pipe(Effect.orElseSucceed(constVoid)),
1734 );
1735 if (!tab) {
1736 throw new ServerBrowserPage.ServerBrowserOperationError(
1737 "PreviewAutomationTabNotFoundError",
1738 "No server preview tab is open for this thread. Call preview_open first.",
1739 );
1740 }
1741 // Any tab in the thread can be read; acting is checked by its control.
1742 if (!READ_OPERATIONS.has(request.operation) && !tab.control.agentMayAct(request.agentSessionId))
1743 throw new BrowserControlInterrupted(
1744 "This tab belongs to another agent session. You can read it, but open your own tab to act.",
1745 "agentMismatch",
1746 );
1747 return tab;
1748 };
1749
1750 /**
1751 * Any request, even a failed one, keeps the agent's tabs on the thread from
1752 * idling out. The tab it targeted becomes the thread's most recently used.
1753 */
1754 const markUsed = (request: PreviewAutomationRequest) => {
1755 const now = Date.now();
1756 for (const tab of tabs.values()) {
1757 if (
1758 tab.threadId === request.threadId &&
1759 (tab.control.agentId === request.agentSessionId || tab.tabId === request.tabId)
1760 )
1761 tab.usedAt = now;
1762 }
1763 };
1764
1765 const runOperation = async (request: PreviewAutomationRequest): Promise<unknown> => {
1766 const input = request.input;
1767 switch (request.operation) {
1768 case "status":
1769 return statusWithTitle(
1770 request.tabId === undefined
1771 ? latestThreadTab(request.threadId, request.agentSessionId)
1772 : tabs.get(tabKey(request.threadId, request.tabId)),
1773 request.agentSessionId,
1774 );
1775 case "open": {
1776 if (!request.agentSessionId)
1777 throw new BrowserControlInterrupted(
1778 "The agent session is missing. Reconnect the provider.",
1779 );
1780 const open = input as PreviewAutomationOpenInput;
1781 const url = open.url === undefined ? undefined : normalizePreviewUrl(open.url);
1782 const reuse = open.reuseExistingTab ?? true;
1783 if (
1784 reuse &&
1785 !request.tabIdExplicit &&
1786 [...tabs.values()].filter(
1787 (tab) =>
1788 tab.threadId === request.threadId && tab.control.agentId === request.agentSessionId,
1789 ).length > 1
1790 )
1791 throw new BrowserControlInterrupted(
1792 "Multiple tabs are open. Pass tabId or reuseExistingTab=false.",
1793 "tabRequired",
1794 );
1795 // A tab still launching exists only as a session, so resolve it like a viewer would.
1796 const found =
1797 reuse && request.tabId !== undefined
1798 ? await Effect.runPromise(
1799 findTab(request.threadId, request.tabId).pipe(
1800 Effect.catchTags({
1801 ServerBrowserTabNotFoundError: () => Effect.succeed(undefined),
1802 }),
1803 ),
1804 )
1805 : undefined;
1806 // Only an explicit tabId reuses a tab this session did not open; a tab
1807 // it last read (such as the user's) is not taken over implicitly.
1808 const existing =
1809 found && (request.tabIdExplicit || found.control.agentId === request.agentSessionId)
1810 ? found
1811 : undefined;
1812 const navigationTimeout = Math.min(request.timeoutMs, NAVIGATION_TIMEOUT_MS);
1813 const profileId = existing ? undefined : resolveOpenProfile(open.profileId);
1814 if (!existing) {
1815 closeIdleAgentTabs();
1816 assertTabCapacity(request.agentSessionId);
1817 }
1818 const tab =
1819 existing ??
1820 (await ensureTab(
1821 await Effect.runPromise(
1822 manager.open({
1823 threadId: request.threadId,
1824 ...(url ? { url } : {}),
1825 ...(profileId === undefined ? {} : { profileId }),
1826 runtime: "server",
1827 reveal: false,
1828 automationOwner: request.agentSessionId,
1829 }),
1830 ),
1831 ));
1832 if (existing?.dialog)
1833 throw new BrowserControlInterrupted(
1834 "A browser dialog is pending. Read preview_status and use preview_dialog first.",
1835 "dialogPending",
1836 );
1837 return tab.control.agent(request.agentSessionId, async () => {
1838 if (tab.dialog)
1839 throw new BrowserControlInterrupted(
1840 "A browser dialog is pending. Read preview_status and use preview_dialog first.",
1841 "dialogPending",
1842 );
1843 if (existing) {
1844 if (url) await navigate(tab, url, "load", navigationTimeout);
1845 } else {
1846 // Await the original navigation failure even though background creation keeps the tab.
1847 await tab.initialNavigation;
1848 }
1849 const reveal = open.open ?? open.show;
1850 if (reveal !== false) {
1851 await Effect.runPromise(
1852 manager.requestReveal({
1853 threadId: tab.threadId,
1854 tabId: tab.tabId,
1855 force: reveal === true,
1856 }),
1857 );
1858 }
1859 if (!existing && url) {
1860 await tab.page
1861 .waitForLoadState("load", { timeout: navigationTimeout })
1862 .catch(constVoid);
1863 }
1864 return statusWithTitle(tab, request.agentSessionId);
1865 });
1866 }
1867 case "recordingStop": {
1868 if (
1869 !request.tabIdExplicit &&
1870 [...tabs.values()].filter(
1871 (candidate) =>
1872 candidate.threadId === request.threadId &&
1873 candidate.control.agentId === request.agentSessionId,
1874 ).length > 1
1875 )
1876 throw new BrowserControlInterrupted(
1877 "Multiple tabs belong to this agent session. Pass a tabId from preview_open or preview_status tabs.",
1878 "tabRequired",
1879 );
1880 const recordings = [...tabs.values()].filter(
1881 (candidate) =>
1882 candidate.threadId === request.threadId &&
1883 candidate.control.agentMayAct(request.agentSessionId ?? "") &&
1884 (candidate.recording || candidate.recordingStart),
1885 );
1886 const targetTabId =
1887 request.tabId ?? latestThreadTab(request.threadId, request.agentSessionId)?.tabId;
1888 const tab =
1889 recordings.find((candidate) => candidate.tabId === targetTabId) ??
1890 (!request.tabIdExplicit && recordings.length === 1 ? recordings[0] : undefined);
1891 if (!tab) {
1892 throw new ServerBrowserPage.ServerBrowserOperationError(
1893 "PreviewAutomationRecordingNotActiveError",
1894 "No recording is active for this thread.",
1895 );
1896 }
1897 return tab.control.agent(request.agentSessionId ?? "", () => stopRecording(tab));
1898 }
1899 }
1900 const tab = await requireTab(request);
1901 // Closing must unblock an action waiting on a dialog, without queueing behind it.
1902 if (request.operation === "close") {
1903 if (tab.control.agentId !== request.agentSessionId)
1904 throw new BrowserControlInterrupted(
1905 "Only the agent session that opened this tab can close it.",
1906 "agentMismatch",
1907 );
1908 if (tab.control.controller !== null)
1909 throw new BrowserControlInterrupted("A human controls this tab.", "humanControl");
1910 void tab.control.close().catch(constVoid);
1911 await Effect.runPromise(manager.close({ threadId: tab.threadId, tabId: tab.tabId }));
1912 dropTab(tab, false);
1913 return {};
1914 }
1915 // A click can be waiting for its dialog. Resolve it outside the serial queue,
1916 // with the same owner check, so the operation can finish and control can drain.
1917 if (request.operation === "dialog") {
1918 if (tab.control.controller !== null)
1919 throw new BrowserControlInterrupted("A human controls this tab.", "humanControl");
1920 await resolveDialog(tab, input as PreviewAutomationDialogInput);
1921 return statusWithTitle(tab, request.agentSessionId);
1922 }
1923 const agentSessionId = request.agentSessionId!;
1924 // A tab this session did not open, while it cannot act there (another
1925 // agent's, or the user's while they drive): read it in place, without
1926 // queueing behind the human's input. Its own tabs keep the takeover
1927 // contract: a human taking control interrupts every agent call.
1928 if (
1929 READ_OPERATIONS.has(request.operation) &&
1930 tab.control.agentId !== agentSessionId &&
1931 !tab.control.agentCanActNow(agentSessionId)
1932 ) {
1933 return tab.control.observe(async () => {
1934 if (tab.dialog)
1935 throw new BrowserControlInterrupted(
1936 "A browser dialog is pending. Read preview_status.",
1937 "dialogPending",
1938 );
1939 return executeTabOperation(tab, request);
1940 });
1941 }
1942 return tab.control.agent(agentSessionId, async () => {
1943 if (tab.dialog)
1944 throw new BrowserControlInterrupted(
1945 "A browser dialog is pending. Read preview_status and use preview_dialog first.",
1946 "dialogPending",
1947 );
1948 const generation = tab.control.generation;
1949 try {
1950 return await executeTabOperation(tab, request);
1951 } finally {
1952 if (generation !== tab.control.generation) ServerBrowserPage.invalidateRefs(tab.page);
1953 }
1954 });
1955 };
1956
1957 const executeTabOperation = async (tab: ServerTab, request: PreviewAutomationRequest) => {
1958 const input = request.input;
1959 switch (request.operation) {
1960 case "navigate": {
1961 const navigateInput = input as PreviewAutomationNavigateInput;
1962 await recordAction(tab, "navigate", () =>
1963 navigate(
1964 tab,
1965 resolveNavigationUrl(navigateInput),
1966 navigateInput.readiness ?? "load",
1967 navigateInput.timeoutMs ?? request.timeoutMs,
1968 ),
1969 );
1970 return statusWithTitle(tab, request.agentSessionId);
1971 }
1972 case "resize": {
1973 const setting = resolvePreviewViewport(input as PreviewAutomationResizeInput);
1974 await Effect.runPromise(
1975 manager.resize({ threadId: tab.threadId, tabId: tab.tabId, viewport: setting }),
1976 );
1977 await applySetting(tab, setting);
1978 return {
1979 tabId: tab.tabId,
1980 setting,
1981 viewport: tab.page.viewportSize() ?? UNATTACHED_FILL_VIEWPORT,
1982 };
1983 }
1984 case "setColorScheme": {
1985 const { colorScheme } = input as PreviewAutomationSetColorSchemeInput;
1986 const snapshot = await Effect.runPromise(
1987 manager.adjust({ threadId: tab.threadId, tabId: tab.tabId, colorScheme }),
1988 );
1989 await applyRendering(tab, snapshot);
1990 return { tabId: tab.tabId, colorScheme };
1991 }
1992 case "snapshot": {
1993 return withScreencastsPaused(tab, () =>
1994 ServerBrowserPage.snapshot({ ...tab, renderScale: RENDER_SCALE }),
1995 );
1996 }
1997 case "click": {
1998 const clickInput = input as PreviewAutomationClickInput;
1999 await recordAction(tab, "click", () =>
2000 ServerBrowserPage.click(tab.page, clickInput, pointerFor(tab)),
2001 );
2002 return undefined;
2003 }
2004 case "hover":
2005 return recordAction(tab, "hover", () =>
2006 ServerBrowserPage.hover(tab.page, input as PreviewAutomationHoverInput, pointerFor(tab)),
2007 );
2008 case "select":
2009 return recordAction(tab, "select", () =>
2010 ServerBrowserPage.select(tab.page, input as PreviewAutomationSelectInput),
2011 );
2012 case "drag":
2013 return recordAction(tab, "drag", () =>
2014 ServerBrowserPage.drag(tab.page, input as PreviewAutomationDragInput, pointerFor(tab)),
2015 );
2016 case "upload":
2017 return recordAction(tab, "upload", () =>
2018 uploadFiles(tab, input as PreviewAutomationUploadInput),
2019 );
2020 case "type":
2021 return recordAction(tab, "type", () =>
2022 ServerBrowserPage.type(tab.page, input as PreviewAutomationTypeInput),
2023 );
2024 case "press":
2025 return recordAction(tab, "press", () =>
2026 ServerBrowserPage.press(tab.page, input as PreviewAutomationPressInput),
2027 );
2028 case "scroll":
2029 return recordAction(tab, "scroll", () =>
2030 ServerBrowserPage.scroll(tab.page, input as PreviewAutomationScrollInput),
2031 );
2032 case "evaluate":
2033 return ServerBrowserPage.evaluate(
2034 tab.cdp,
2035 input as PreviewAutomationEvaluateInput,
2036 request.timeoutMs,
2037 );
2038 case "waitFor":
2039 return ServerBrowserPage.waitFor(tab.page, input as PreviewAutomationWaitForInput);
2040 case "recordingStart": {
2041 const recording = await startRecording(tab);
2042 return { tabId: tab.tabId, recording: true, startedAt: recording.startedAt };
2043 }
2044 }
2045 };
2046
2047 const handleRequest = (connectionId: string, request: PreviewAutomationRequest) =>
2048 Effect.tryPromise({
2049 try: () => runOperation(request).finally(() => markUsed(request)),
2050 catch: ServerBrowserPage.toOperationError,
2051 }).pipe(
2052 Effect.match({
2053 onSuccess: (result) => ({ ok: true as const, result }),
2054 onFailure: (error) => ({
2055 ok: false as const,
2056 error: {
2057 _tag: error.tag,
2058 message: error.message,
2059 ...(error.detail === undefined ? {} : { detail: error.detail }),
2060 },
2061 }),
2062 }),
2063 Effect.flatMap((outcome) =>
2064 broker.respond({
2065 clientId: SERVER_HOST_CLIENT_ID,
2066 connectionId,
2067 requestId: request.requestId,
2068 ...outcome,
2069 }),
2070 ),
2071 Effect.ignore,
2072 );
2073
2074 const mirrorManagerEvent = (event: PreviewEvent) =>
2075 Effect.promise(async () => {
2076 if (event.type === "opened" && event.snapshot.runtime === "server") {
2077 await ensureTab(event.snapshot).catch(constVoid);
2078 return;
2079 }
2080 const key = tabKey(event.threadId, event.tabId);
2081 const tab = tabs.get(key);
2082 if (event.type === "closed" && !tab && pendingTabs.has(key)) closedPendingTabs.add(key);
2083 if (!tab) return;
2084 if (event.type === "closed") {
2085 closedSessions.add(key);
2086 dropTab(tab, false);
2087 closedSessions.delete(key);
2088 return;
2089 }
2090 // Any client or agent may change these; the page follows what was published.
2091 if (event.type === "resized") {
2092 await tab.control
2093 .system(async () => {
2094 if (
2095 !viewportSettingsEqual(event.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, tab.setting)
2096 ) {
2097 await applySetting(tab, event.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT);
2098 }
2099 await applyRendering(tab, event.snapshot);
2100 const request = event.request;
2101 if (request?.clear === "cookies") await tab.cdp.send("Network.clearBrowserCookies");
2102 if (request?.clear === "cache") await tab.cdp.send("Network.clearBrowserCache");
2103 if (request?.hardReload) await tab.cdp.send("Page.reload", { ignoreCache: true });
2104 })
2105 .catch((cause: unknown) =>
2106 runFork(Effect.logWarning("server preview could not apply a tab setting", { cause })),
2107 );
2108 }
2109 });
2110
2111 const releaseViewerInput = async (viewer: ViewerState, session: CDPSession) => {
2112 for (const { key, code } of viewer.pressedKeys.values()) {
2113 await session.send("Input.dispatchKeyEvent", { type: "keyUp", key, code }).catch(constVoid);
2114 }
2115 viewer.pressedKeys.clear();
2116 for (const [button, point] of viewer.pressedButtons) {
2117 await session
2118 .send("Input.dispatchMouseEvent", {
2119 type: "mouseReleased",
2120 button,
2121 ...point,
2122 buttons: 0,
2123 clickCount: 1,
2124 })
2125 .catch(constVoid);
2126 }
2127 viewer.pressedButtons.clear();
2128 viewer.requestedSize = null;
2129 };
2130
2131 const dispatchViewerInput = async (
2132 tab: ServerTab,
2133 session: CDPSession,
2134 viewer: ViewerState,
2135 raw: unknown,
2136 ) => {
2137 const message = asRecord(raw);
2138 if (!message) return;
2139 const modifiers = modifiersOf(message);
2140 switch (message.type) {
2141 case "mouse": {
2142 const action = message.action;
2143 const type =
2144 action === "down" ? "mousePressed" : action === "up" ? "mouseReleased" : "mouseMoved";
2145 const button = ["none", "left", "middle", "right"].includes(String(message.button))
2146 ? (message.button as "none" | "left" | "middle" | "right")
2147 : "none";
2148 await session.send("Input.dispatchMouseEvent", {
2149 type,
2150 x: num(message.x),
2151 y: num(message.y),
2152 button,
2153 buttons: num(message.buttons),
2154 clickCount: num(message.clickCount, type === "mouseMoved" ? 0 : 1),
2155 modifiers,
2156 });
2157 if (button !== "none") {
2158 if (action === "down")
2159 viewer.pressedButtons.set(button, { x: num(message.x), y: num(message.y) });
2160 else if (action === "up") viewer.pressedButtons.delete(button);
2161 }
2162 return;
2163 }
2164 case "wheel":
2165 viewer.scrolledAt = Date.now();
2166 await session.send("Input.dispatchMouseEvent", {
2167 type: "mouseWheel",
2168 x: num(message.x),
2169 y: num(message.y),
2170 deltaX: num(message.deltaX),
2171 deltaY: num(message.deltaY),
2172 modifiers,
2173 });
2174 return;
2175 case "key": {
2176 const key = typeof message.key === "string" ? message.key : "";
2177 const code = typeof message.code === "string" ? message.code : "";
2178 const text = typeof message.text === "string" ? message.text : undefined;
2179 if (message.action === "up") {
2180 await session.send("Input.dispatchKeyEvent", { type: "keyUp", key, code, modifiers });
2181 viewer.pressedKeys.delete(code || key);
2182 return;
2183 }
2184 const command = editingCommand(key, modifiers);
2185 // Only the controlling viewer's own copy and cut reach its clipboard.
2186 const copying =
2187 !tab.desktop && (command?.commands[0] === "copy" || command?.commands[0] === "cut");
2188 // Clear what any frame kept from an earlier key, so only this key's copy is read.
2189 if (copying) await takeCopiedText(tab);
2190 await session.send("Input.dispatchKeyEvent", {
2191 type: text ? "keyDown" : "rawKeyDown",
2192 key,
2193 code,
2194 modifiers,
2195 ...(text ? { text, unmodifiedText: text } : {}),
2196 ...command,
2197 windowsVirtualKeyCode: num(
2198 message.keyCode,
2199 key.length === 1 ? key.toUpperCase().charCodeAt(0) : 0,
2200 ),
2201 });
2202 viewer.pressedKeys.set(code || key, { key, code });
2203 if (copying) {
2204 const copied = await takeCopiedText(tab);
2205 if (copied) viewer.push({ _tag: "clipboard", text: copied });
2206 }
2207 return;
2208 }
2209 case "text":
2210 if (typeof message.text === "string" && message.text.length > 0) {
2211 await session.send("Input.insertText", { text: message.text.slice(0, 10_000) });
2212 }
2213 return;
2214 case "resize": {
2215 const width = Math.min(Math.round(num(message.width)), 3840);
2216 const height = Math.min(Math.round(num(message.height)), 2160);
2217 if (width < 100 || height < 100) return;
2218 viewer.requestedSize = { width, height, order: ++viewerResizeOrder };
2219 if (tab.setting._tag !== "fill" || tab.desktop) return;
2220 const current = tab.page.viewportSize();
2221 if (current?.width === width && current.height === height) return;
2222 await tab.page.setViewportSize({ width, height });
2223 broadcastViewport(tab);
2224 return;
2225 }
2226 case "viewport": {
2227 const setting = decodeViewportSetting(message.setting);
2228 await Effect.runPromise(
2229 manager.resize({ threadId: tab.threadId, tabId: tab.tabId, viewport: setting }),
2230 );
2231 await applySetting(tab, setting);
2232 return;
2233 }
2234 case "navigate":
2235 if (typeof message.url === "string") {
2236 const url = normalizePreviewUrl(message.url);
2237 await tab.page.goto(url, VIEWER_NAVIGATION_OPTIONS);
2238 }
2239 return;
2240 case "history":
2241 await (num(message.delta) < 0
2242 ? tab.page.goBack(VIEWER_NAVIGATION_OPTIONS)
2243 : tab.page.goForward(VIEWER_NAVIGATION_OPTIONS));
2244 return;
2245 case "reload":
2246 // A hard reload fetches everything again, as Chrome's Shift+Reload does.
2247 if (message.ignoreCache === true) {
2248 await session.send("Page.reload", { ignoreCache: true });
2249 return;
2250 }
2251 await tab.page.reload(VIEWER_NAVIGATION_OPTIONS);
2252 return;
2253 case "probe": {
2254 const x = num(message.x);
2255 const y = num(message.y);
2256 const result = await session.send("Runtime.evaluate", {
2257 expression: `(${EDITABLE_AT_POINT_SCRIPT})(${x}, ${y})`,
2258 returnByValue: true,
2259 });
2260 viewer.push({ _tag: "probe", x, y, editable: result.result.value === true });
2261 return;
2262 }
2263 }
2264 };
2265
2266 const attachViewer: ServerBrowser["Service"]["attachViewer"] = (input) =>
2267 Effect.gen(function* () {
2268 const tab = yield* findTab(input.threadId, input.tabId);
2269 const output = yield* Queue.make<ServerBrowserViewerOutput>({
2270 capacity: VIEWER_OUTPUT_LIMIT,
2271 strategy: "dropping",
2272 });
2273 const session = yield* Effect.acquireRelease(
2274 Effect.tryPromise({
2275 try: () => tab.page.context().newCDPSession(tab.page),
2276 catch: (cause) => new ServerBrowserLaunchError({ cause }),
2277 }),
2278 (session) =>
2279 Effect.promise(() =>
2280 session
2281 .send("Page.stopScreencast")
2282 .catch(constVoid)
2283 .then(() => session.detach())
2284 .catch(constVoid),
2285 ),
2286 );
2287 const quality = Math.min(100, Math.max(1, Math.round(input.quality)));
2288 // Chromium may drop the final frame during a burst; send a still when it settles.
2289 let framesInFlight = 0;
2290 let mayHaveDropped = false;
2291 let motion = false;
2292 const recentFrames: Array<number> = [];
2293 let screencastParams = Promise.resolve();
2294 let screencastScale = 1;
2295 const startScreencast = (scale: number) => {
2296 screencastScale = scale;
2297 screencastParams = screencastParams.then(async () => {
2298 // A scaled capture is rendering; its resume starts the stream.
2299 if (tab.capturing > 0) return;
2300 await session.send("Page.stopScreencast").catch(constVoid);
2301 await session
2302 .send("Page.startScreencast", {
2303 format: "jpeg",
2304 quality: screencastScale < 1 ? Math.min(quality, SCREENCAST_MOTION_QUALITY) : quality,
2305 maxWidth: Math.max(1, Math.round(input.maxWidth * screencastScale)),
2306 maxHeight: Math.max(1, Math.round(input.maxHeight * screencastScale)),
2307 })
2308 .catch(constVoid);
2309 });
2310 return screencastParams;
2311 };
2312 const viewer: ViewerState = {
2313 id: NodeCrypto.randomUUID(),
2314 canOperate: input.canOperate,
2315 pressedKeys: new Map(),
2316 pressedButtons: new Map(),
2317 push: (next) => {
2318 // Dropped frames must still release Chromium.
2319 if (Queue.offerUnsafe(output, next)) return;
2320 if (next._tag === "frame") runFork(next.ack);
2321 // State a stalled viewer cannot miss replaces its backlog. It runs
2322 // synchronously so an older replacement can never land after a newer one.
2323 else if (next._tag === "gone" || next._tag === "control" || next._tag === "fileChooser") {
2324 const dropped = Effect.runSyncExit(Queue.clear(output));
2325 if (dropped._tag === "Failure") return;
2326 Queue.offerUnsafe(output, next);
2327 // The controller's open picker may have been in the dropped backlog.
2328 const chooser = next._tag === "control" ? fileChooserMessage(tab) : null;
2329 if (chooser && tab.control.controller === viewer.id) Queue.offerUnsafe(output, chooser);
2330 for (const item of dropped.value) if (item._tag === "frame") runFork(item.ack);
2331 }
2332 },
2333 pause: () => {
2334 screencastParams = screencastParams.then(() =>
2335 session.send("Page.stopScreencast").then(constVoid, constVoid),
2336 );
2337 return screencastParams;
2338 },
2339 resume: () => startScreencast(screencastScale),
2340 scrolledAt: 0,
2341 requestedSize: null,
2342 };
2343 yield* Effect.acquireRelease(
2344 Effect.sync(() => {
2345 tab.viewers.add(viewer);
2346 reportLiveTabs();
2347 }),
2348 () =>
2349 Effect.promise(async () => {
2350 await tab.control.disconnect(viewer.id, () => releaseViewerInput(viewer, session));
2351 tab.viewers.delete(viewer);
2352 broadcastControl(tab);
2353 reportLiveTabs();
2354 }),
2355 );
2356 if (input.canOperate && tab.control.agentId === null && tab.control.controller === null) {
2357 yield* Effect.promise(() => tab.control.take(viewer.id));
2358 }
2359 broadcastControl(tab);
2360 pushFileChooser(tab);
2361 // Full scale: a scaled capture would flash in every other viewer.
2362 const pushStill = async () => {
2363 const data = await withCaptureLock(tab, () =>
2364 ServerBrowserPage.captureViewport(tab.page, session, {
2365 format: "jpeg",
2366 quality,
2367 scale: 1,
2368 }),
2369 ).catch(() => null);
2370 if (data)
2371 viewer.push({ _tag: "frame", data: Buffer.from(data, "base64"), ack: Effect.void });
2372 };
2373 let settleTimer: ReturnType<typeof setTimeout> | null = null;
2374 yield* Effect.addFinalizer(() =>
2375 Effect.sync(() => {
2376 if (settleTimer !== null) clearTimeout(settleTimer);
2377 }),
2378 );
2379 let screencastStarted = false;
2380 session.on("Page.screencastFrame", (frame) => {
2381 screencastStarted = true;
2382 if (framesInFlight > 0) mayHaveDropped = true;
2383 framesInFlight += 1;
2384 const arrivedAt = Date.now();
2385 recentFrames.push(arrivedAt);
2386 while (recentFrames[0]! < arrivedAt - SCREENCAST_MOTION_WINDOW_MS) recentFrames.shift();
2387 if (
2388 !motion &&
2389 recentFrames.length >= SCREENCAST_MOTION_FRAMES &&
2390 arrivedAt - viewer.scrolledAt < SCREENCAST_MOTION_WINDOW_MS
2391 ) {
2392 motion = true;
2393 void startScreencast(0.5);
2394 } else if (motion && arrivedAt - viewer.scrolledAt > SCREENCAST_SETTLE_MS) {
2395 // The page keeps animating after the scroll; its own frames are sharp again.
2396 motion = false;
2397 recentFrames.length = 0;
2398 void startScreencast(1);
2399 }
2400 if (settleTimer !== null) clearTimeout(settleTimer);
2401 settleTimer = setTimeout(() => {
2402 settleTimer = null;
2403 if (!motion && !mayHaveDropped) return;
2404 mayHaveDropped = false;
2405 if (motion) {
2406 motion = false;
2407 recentFrames.length = 0;
2408 void startScreencast(1);
2409 }
2410 void pushStill();
2411 }, SCREENCAST_SETTLE_MS);
2412 viewer.push({
2413 _tag: "frame",
2414 data: Buffer.from(frame.data, "base64"),
2415 ack: Effect.promise(() =>
2416 sleepUntil(arrivedAt + SCREENCAST_ACK_PACE_MS).then(() => {
2417 framesInFlight -= 1;
2418 return session
2419 .send("Page.screencastFrameAck", { sessionId: frame.sessionId })
2420 .catch(constVoid);
2421 }),
2422 ),
2423 });
2424 });
2425 broadcastViewport(tab);
2426 yield* Effect.promise(() => startScreencast(1));
2427 // An idle page does not repaint for a new screencast, so the viewer
2428 // starts from a still unless a live frame beat it.
2429 if (!screencastStarted) yield* Effect.promise(pushStill);
2430 return {
2431 output,
2432 input: (raw: unknown) =>
2433 Effect.promise(async () => {
2434 if (!viewer.canOperate) return;
2435 const message = asRecord(raw);
2436 if (!message) return;
2437 try {
2438 if (message.type === "takeControl") {
2439 const taking = tab.control.take(viewer.id);
2440 broadcastControl(tab);
2441 await taking;
2442 pushFileChooser(tab);
2443 } else if (message.type === "releaseControl") {
2444 const releasing = tab.control.release(viewer.id, () =>
2445 releaseViewerInput(viewer, session),
2446 );
2447 broadcastControl(tab);
2448 await releasing;
2449 } else if (
2450 message.type === "dialog" &&
2451 tab.control.controller === viewer.id &&
2452 typeof message.accept === "boolean"
2453 ) {
2454 await resolveDialog(tab, {
2455 accept: message.accept,
2456 ...(typeof message.promptText === "string"
2457 ? { promptText: message.promptText }
2458 : {}),
2459 });
2460 } else {
2461 await tab.control.human(viewer.id, () =>
2462 dispatchViewerInput(tab, session, viewer, message),
2463 );
2464 }
2465 } catch {
2466 // Rejected ownership cannot mutate the page; refresh the viewer's controls.
2467 broadcastControl(tab);
2468 }
2469 }),
2470 } satisfies ServerBrowserViewer;
2471 });
2472
2473 yield* manager.events.pipe(Stream.runForEach(mirrorManagerEvent), Effect.forkScoped);
2474 // Whoever runs the server learns the fix before anyone opens a tab.
2475 if (
2476 !PreviewBrowserHost.sandboxDisabled(yield* HostProcess.Environment) &&
2477 (yield* PreviewBrowserHost.sandboxBlocked)
2478 ) {
2479 yield* Effect.logWarning(
2480 `This host blocks the sandbox T3's browser runs in, so browser tabs and HTML previews will not start. Run \`${setupCommand}\` once to allow it.`,
2481 );
2482 }
2483 // The desktop took its page back (closed, swapped, crashed, or devtools opened).
2484 // The session stays; the next viewer or agent reconnects when it re-attaches.
2485 yield* desktopChannel.detached.pipe(
2486 Stream.runForEach((key) =>
2487 Effect.sync(() => {
2488 const tab = tabs.get(tabKey(key.threadId, key.tabId));
2489 if (tab?.desktop) dropTab(tab, false);
2490 }),
2491 ),
2492 Effect.forkScoped,
2493 );
2494 // The page came back, for example after its DevTools closed. Reconnect now
2495 // rather than on the next viewer or agent, or the tab's URL and title stop
2496 // reaching every client in the meantime.
2497 yield* desktopChannel.attached.pipe(
2498 Stream.runForEach((key) =>
2499 Effect.gen(function* () {
2500 if (tabs.has(tabKey(key.threadId, key.tabId))) return;
2501 const { sessions } = yield* manager.list({ threadId: ThreadId.make(key.threadId) });
2502 const snapshot = sessions.find(
2503 (session) => session.tabId === key.tabId && session.runtime === "server",
2504 );
2505 if (snapshot) yield* Effect.promise(() => ensureTab(snapshot).catch(constVoid));
2506 }).pipe(Effect.ignore),
2507 ),
2508 Effect.forkScoped,
2509 );
2510 yield* Effect.sync(closeIdleAgentTabs).pipe(
2511 Effect.repeat(Schedule.spaced(IDLE_SWEEP_INTERVAL)),
2512 Effect.forkScoped,
2513 );
2514 const environmentId = yield* environment.getEnvironmentId;
2515 const hostSession = broker
2516 .connect(
2517 {
2518 clientId: SERVER_HOST_CLIENT_ID,
2519 environmentId,
2520 supportedOperations: [...PREVIEW_AUTOMATION_SERVER_OPERATIONS],
2521 },
2522 { preferred: true },
2523 )
2524 .pipe(
2525 Effect.flatMap((events) =>
2526 events.pipe(
2527 Stream.runForEach((event) => {
2528 if (event.type === "connected") {
2529 hostConnectionId = event.connectionId;
2530 return Effect.sync(reportLiveTabs);
2531 }
2532 return handleRequest(event.connectionId, event.request).pipe(
2533 Effect.forkScoped,
2534 Effect.asVoid,
2535 );
2536 }),
2537 ),
2538 ),
2539 );
2540 // The broker disconnects timed-out hosts, including slow first installs. Reconnect.
2541 yield* hostSession.pipe(
2542 Effect.exit,
2543 Effect.andThen(Effect.sleep(HOST_RECONNECT_DELAY)),
2544 Effect.forever,
2545 Effect.forkScoped,
2546 );
2547 yield* Effect.addFinalizer(() =>
2548 Effect.promise(async () => {
2549 await contexts.close();
2550 }),
2551 );
2552
2553 const clearProfile = (profileId: string) =>
2554 Effect.tryPromise({
2555 try: () => contexts.clearProfile(profileId),
2556 catch: (cause) => new PreviewClearProfileError({ profileId, cause }),
2557 });
2558
2559 return ServerBrowser.of({
2560 attachViewer,
2561 clearProfile,
2562 reportProfiles,
2563 openDownload,
2564 answerFileChooser,
2565 });
2566});
2567
2568export const layer = Layer.effect(ServerBrowser, make);