server/browser-live.ts
Rama main · réplica de lectura del repositorio original.
import { userError, userErrorFields } from "./user-errors.ts";
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import type { ServerResponse } from "node:http";
import { promisify } from "node:util";
import type { PageSize } from "./browser-page-size.ts";
import { attachesToChrome, browserRuntimeEnv, type BrowserRuntime } from "./browser-runtime.ts";
import { requestedSize } from "./browser/viewers/viewport.ts";
import { closeBrowserSession } from "./browser-engine.ts";
import { DialogWatch, localDevToolsUrl } from "./browser-dialogs.ts";
import type { Cdp, CdpListeners } from "./browser-sites.ts";
import { relay } from "./browser-relay.ts";
import { HOLD_LIMITS, HoldClock, type HoldLimits } from "./browser/hold.ts";
import { BROWSER_HELD_ELSEWHERE } from "../shared/browser-held.ts";
const execute = promisify(execFile);
/** A frame of the largest page the view sizes (MAX_PAGE_PIXELS) is up to about 2.8 MB of base64 for a page of noise, and two
* may be on their way to a view at once. */
const MAX_FRAME = 4 * 1024 * 1024;
const MAX_BUFFER = 8 * 1024 * 1024;
const HEARTBEAT_MS = 10_000;
const COMMAND_TIMEOUT_MS = 30_000;
const STATUS_PROBE_MS = 10_000;
const BUSY_RETRY_MS = [1_000, 2_000, 5_000];
const BUSY_LIMIT_MS = 10 * 60_000;
// agent-browser's native press resolver supplies the virtual key codes and Enter/Tab text its raw input_keyboard relay omits.
const DISCRETE_KEYS = new Set(["Backspace", "Enter", "Tab", "Escape", "Delete", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown"]);
const RESTART_NEEDED = "A browser action was interrupted. Choose Restart browser… in the browser menu before continuing.";
const restartedNote = (keepsSignIns: boolean) => `The page stopped responding, so the browser was restarted. ${keepsSignIns ? "Your sign-ins are kept; open" : "It is a temporary browser, so its sign-ins are gone; open"} the page again.`;
const INPUT_RELAY = { timeoutMs: 30_000, maxBytes: 65_536 };
/** Frames a view may have on its way without acknowledging them: over a slow link one acknowledgement round trip per frame
* held a Cloud's panel to a few frames a second. */
const FRAME_WINDOW = 2;
/** Inputs one request may carry, so a remote panel's typing does not wait a round trip per key. */
export const INPUT_BATCH_LIMIT = 64;
const ENDED_PROBE = { timeoutMs: 5_000, maxBytes: 65_536 };
/** What a view hears when its browser closed unused: it stops, where reconnecting would start the browser again. */
export const BROWSER_ASLEEP_ERROR = { type: "error", retryable: false, asleep: true, message: "This browser closed after it sat unused, to save memory. Reconnect to open it again." };
/** What a view hears while its browser cannot show it yet; it keeps waiting. */
export const BROWSER_WAITING = { type: "waiting", reason: "busy" };
export class BrowserLiveError extends Error {
readonly status: number;
constructor(message: string, status = 400) { super(message); this.name = "BrowserLiveError"; this.status = status; }
}
export class BrowserActionFailedError extends BrowserLiveError {
readonly settled = true;
constructor(args: readonly string[]) {
const opening = args[0] === "open" || (args[0] === "tab" && args[1] === "new" && args.length > 2);
super(opening ? "This page could not be opened. Check the address and try again." : "The browser could not do that. Try again.", 422);
this.name = "BrowserActionFailedError";
}
}
/** Another command, usually the bot's own, holds the session; not a missing engine. */
export class BrowserBusyError extends BrowserLiveError {
constructor(message = "The browser is busy with another action and did not answer in time. Try again shortly.") {
super(message, 503); this.name = "BrowserBusyError";
}
}
/** The engine left a status read unanswered with no bot or person working in it: it hung, and its session is being reset. */
class BrowserUnresponsiveError extends BrowserLiveError {
constructor() { super("The browser stopped responding, so it is being restarted. The live view reconnects by itself.", 503); this.name = "BrowserUnresponsiveError"; Object.assign(this, userErrorFields(userError("serverError.browser.restartingUnresponsive"))); }
}
/** A person's input or command went unanswered, but the engine answered a read queued behind it, so it has ended. */
export class BrowserSlowActionError extends BrowserLiveError {
readonly settled = true;
constructor() { super("The browser was slow to take that. Check the page before you repeat it.", 409); this.name = "BrowserSlowActionError"; }
}
/** The browser answered that it did not take an input, which therefore had no effect. */
class BrowserInputRefusedError extends Error {}
const INPUT_REFUSED = "The page did not take some of your input. Check the page, then try again.";
/** An input, or the release of a key or button, the browser never confirmed: its state in the page is unknown. */
class BrowserInputUnconfirmedError extends BrowserLiveError {
constructor() { super("The browser could not confirm this input. Choose Restart browser… in the browser menu before continuing.", 503); this.name = "BrowserInputUnconfirmedError"; }
}
/** execFile's own timeout killed a command that had not exited (not a missing binary, a failure, an abort or an overflow). */
function timedOut(error: unknown): boolean {
const failure = error as { killed?: unknown; signal?: unknown; code?: unknown } | null;
return failure?.killed === true && failure.signal === "SIGKILL" && failure.code == null;
}
type ObjectValue = Record<string, unknown>;
function object(value: unknown): ObjectValue | null {
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as ObjectValue : null;
}
function number(value: unknown, min: number, max: number): value is number {
return typeof value === "number" && Number.isFinite(value) && value >= min && value <= max;
}
function integer(value: unknown, min: number, max: number): value is number {
return number(value, min, max) && Number.isSafeInteger(value);
}
function text(value: unknown, max: number): value is string { return typeof value === "string" && value.length <= max; }
function displayUrl(value: unknown): string {
if (!text(value, 8192)) return "";
try {
const url = new URL(value);
if (!['http:', 'https:', 'about:'].includes(url.protocol)) return "";
url.username = ""; url.password = "";
return url.href;
} catch { return ""; }
}
/** The engine's JSON answer says the command failed, so it ran to the end; a timeout, abort or crash stays an interrupted action. */
function engineAnsweredFailure(error: unknown): boolean {
const stdout = (error as { stdout?: unknown } | null)?.stdout;
if (typeof stdout !== "string" || !stdout.trim()) return false;
try { return object(JSON.parse(stdout))?.success === false; } catch { return false; }
}
function browserLaunchFailure(error: unknown): boolean {
const detail = [
(error as { stderr?: unknown })?.stderr,
(error as { stdout?: unknown })?.stdout,
error instanceof Error ? error.message : String(error),
].filter((part): part is string => typeof part === "string").join("\n");
return /missing x server|\$DISPLAY|devtoolsactiveport|chrome exited|could not launch|failed to launch|browser launch failed/iu.test(detail);
}
/** Project only the viewer protocol. Never relay engine commands, results or console output. */
export function normalizeBrowserLiveMessage(value: unknown): ObjectValue | null {
const message = object(value);
if (!message) return null;
if (message.type === "error") return { type: "error", retryable: true, message: "The browser stream was interrupted." };
if (message.type === "url") return { type: "url", url: displayUrl(message.url) };
if (message.type === "tabs" && Array.isArray(message.tabs) && message.tabs.length <= 100) {
return { type: "tabs", tabs: message.tabs.flatMap((value) => {
const tab = object(value);
if (!tab || typeof tab.tabId !== "string" || !/^t[1-9]\d{0,8}$/.test(tab.tabId)) return [];
return [{ tabId: tab.tabId, title: text(tab.title, 512) ? tab.title : "", url: displayUrl(tab.url), active: tab.active === true }];
}) };
}
if (message.type === "status" && typeof message.connected === "boolean") {
return { type: "status", connected: message.connected, screencasting: message.screencasting === true,
viewportWidth: integer(message.viewportWidth, 1, 8192) ? message.viewportWidth : 1280,
viewportHeight: integer(message.viewportHeight, 1, 8192) ? message.viewportHeight : 720 };
}
if (message.type !== "frame" || !integer(message.seq, 1, Number.MAX_SAFE_INTEGER)
|| !text(message.data, MAX_FRAME) || !/^(?:\/9j\/|iVBORw0KGgo)/.test(message.data)
|| !/^[A-Za-z0-9+/]*={0,2}$/.test(message.data)) return null;
const meta = object(message.metadata);
if (!meta || !integer(meta.deviceWidth, 1, 8192) || !integer(meta.deviceHeight, 1, 8192)
|| meta.deviceWidth * meta.deviceHeight > 16_777_216) return null;
return { type: "frame", seq: message.seq, data: message.data, format: message.data.startsWith("/9j/") ? "jpeg" : "png",
metadata: { deviceWidth: meta.deviceWidth, deviceHeight: meta.deviceHeight,
offsetTop: number(meta.offsetTop, 0, 8192) ? meta.offsetTop : 0,
pageScaleFactor: number(meta.pageScaleFactor, 0.01, 100) ? meta.pageScaleFactor : 1,
scrollOffsetX: number(meta.scrollOffsetX, -1e8, 1e8) ? meta.scrollOffsetX : 0,
scrollOffsetY: number(meta.scrollOffsetY, -1e8, 1e8) ? meta.scrollOffsetY : 0,
timestamp: number(meta.timestamp, 0, Number.MAX_SAFE_INTEGER) ? meta.timestamp : 0 } };
}
export function browserStreamPort(value: unknown): number {
const data = object(value);
if (!data || data.enabled !== true || !integer(data.port, 1, 65535)) {
throw new BrowserLiveError("The browser did not provide a valid local stream. Update or reinstall the browser engine.", 503);
}
return data.port;
}
/** A restart closes the tabs of every bot on the profile, so a busy one blocks it, unless the browser no longer answers it anyway
* or the bot is waiting on the person in this browser (an open sign-in card). */
export function browserRestartRefusal(interrupted: boolean, busyBots: ReadonlyArray<{ waitingOnBrowser: boolean }>): string | null {
return !interrupted && busyBots.some((bot) => !bot.waitingOnBrowser) ? "Stop every bot using this profile before restarting its browser." : null;
}
type Action = { type: "take" | "release" | "restart" } | { type: "ack"; seq: number }
| { type: "command"; args: string[] } | { type: "input"; message: ObjectValue } | { type: "inputs"; messages: ObjectValue[] }
| { type: "dialog"; accept: boolean; promptText?: string }
/** The panel's size in CSS pixels, which the page fills; null for a collapsed panel, which leaves the page as it is. */
| { type: "resize"; size: PageSize | null };
/** A move the next one replaces changes nothing the page keeps. */
function withoutReplacedMoves(messages: ObjectValue[]): ObjectValue[] {
return messages.filter((message, at) => {
const next = messages[at + 1];
return !(message.eventType === "mouseMoved" && next?.eventType === "mouseMoved" && next.button === message.button && next.modifiers === message.modifiers);
});
}
/** HTTP bodies can select a fixed action, never a CLI command, flag, port or CDP method. */
export function parseBrowserLiveAction(value: unknown): Action {
const body = object(value);
const invalid = () => new BrowserLiveError("Invalid browser action.");
if (!body) throw invalid();
if (body.type === "take" || body.type === "release" || body.type === "restart") return { type: body.type };
if (body.type === "ack" && integer(body.seq, 1, Number.MAX_SAFE_INTEGER)) return { type: "ack", seq: body.seq };
if (body.type === "resize") {
if (!number(body.width, 0, 100_000) || !number(body.height, 0, 100_000)) throw invalid();
return { type: "resize", size: requestedSize(body.width, body.height) };
}
if (body.type === "dialog") {
if (typeof body.accept !== "boolean" || (body.promptText !== undefined && !text(body.promptText, 4096))) throw invalid();
return { type: "dialog", accept: body.accept, ...(body.promptText === undefined ? {} : { promptText: body.promptText }) };
}
if (body.type === "input_batch") {
if (!Array.isArray(body.events) || !body.events.length || body.events.length > INPUT_BATCH_LIMIT) throw invalid();
const messages = body.events.map((event: unknown) => {
const parsed = parseBrowserLiveAction(event);
if (parsed.type !== "input") throw invalid();
return parsed.message;
});
return { type: "inputs", messages: withoutReplacedMoves(messages) };
}
if (body.type === "back" || body.type === "forward" || body.type === "reload") return { type: "command", args: [body.type] };
if (body.type === "navigate" || body.type === "tab-new") {
if (body.type === "tab-new" && body.url === undefined) return { type: "command", args: ["tab", "new"] };
// eslint-disable-next-line no-control-regex -- Reject control characters before URL parsing normalizes them.
if (!text(body.url, 8192) || !body.url || /[\u0000-\u0020\u007f\\]/u.test(body.url)) throw invalid();
let url: URL;
try { url = new URL(body.url); } catch { throw invalid(); }
if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password) throw invalid();
return { type: "command", args: body.type === "navigate" ? ["open", url.href] : ["tab", "new", url.href] };
}
if (body.type === "tab-select" || body.type === "tab-close") {
if (typeof body.tabId !== "string" || !/^t[1-9]\d{0,8}$/.test(body.tabId)) throw invalid();
return { type: "command", args: body.type === "tab-select" ? ["tab", body.tabId] : ["tab", "close", body.tabId] };
}
if (body.type === "input_mouse") {
if (!["mouseMoved", "mousePressed", "mouseReleased", "mouseWheel"].includes(String(body.eventType))
|| !number(body.x, 0, 8192) || !number(body.y, 0, 8192)) throw invalid();
const button = body.button ?? "none";
const clickCount = body.clickCount ?? 0, modifiers = body.modifiers ?? 0;
const deltaX = body.deltaX ?? 0, deltaY = body.deltaY ?? 0;
if (!["none", "left", "middle", "right"].includes(String(button)) || !integer(clickCount, 0, 3)
|| !integer(modifiers, 0, 15) || !number(deltaX, -10000, 10000) || !number(deltaY, -10000, 10000)) throw invalid();
return { type: "input", message: { type: "input_mouse", eventType: body.eventType, x: body.x, y: body.y, button, clickCount, modifiers, deltaX, deltaY } };
}
if (body.type === "input_keyboard") {
if (body.eventType === "char") {
if (!text(body.text, 4096) || !body.text) throw invalid();
return { type: "input", message: { type: "input_keyboard", eventType: "char", text: body.text } };
}
if (!["keyDown", "keyUp"].includes(String(body.eventType)) || !text(body.key, 64) || !body.key) throw invalid();
const modifiers = body.modifiers ?? 0, keyCode = body.windowsVirtualKeyCode ?? 0;
if (!integer(modifiers, 0, 15) || !integer(keyCode, 0, 255)
|| (body.code !== undefined && !text(body.code, 64)) || (body.text !== undefined && !text(body.text, 4096))) throw invalid();
return { type: "input", message: { type: "input_keyboard", eventType: body.eventType, key: body.key,
modifiers, windowsVirtualKeyCode: keyCode, ...(body.code === undefined ? {} : { code: body.code }), ...(body.text === undefined ? {} : { text: body.text }) } };
}
throw invalid();
}
interface OpenOptions {
botId: string;
session: string;
spec: { command: string; args?: readonly string[]; env?: NodeJS.ProcessEnv };
/** The session the view was opened with, or local-owner. */
owner: string;
/** The person watching: whose hold this view shares. Their account's key; the owner when there is none. */
person?: string;
isCurrent: () => boolean;
res: ServerResponse;
/** The id of the view this panel had before its stream dropped: that view, if the server still has it open, is replaced. */
resume?: string | null;
/** The panel's size in CSS pixels, which the page fills from the start. */
size?: PageSize | null;
}
interface Viewer extends OpenOptions {
id: string;
person: string;
socket?: WebSocket;
closed: boolean;
blocked: boolean;
heartbeat?: NodeJS.Timeout;
drainTimer?: NodeJS.Timeout;
/** A frame upstream still waits on: acknowledged once the view has fewer than FRAME_WINDOW frames on their way. */
frameSeq?: number;
/** Frames sent to the view that it has not acknowledged yet, by sequence, with when each was sent. */
unacked: Map<number, number>;
hiddenFrame?: ObjectValue;
pendingFrame?: ObjectValue;
pendingActions: number;
/** Raw keys down in the page, by code (or key) to the key name a key-up needs. */
pressedKeys: Map<string, string>;
pressedButtons: Set<string>;
pointer: { x: number; y: number };
port?: number;
restarting: boolean;
waiting: boolean;
abort: AbortController;
unwatch: () => void;
/** Set once its stream dropped: runs when its last in-flight action has finished. */
idle?: () => void;
/** The panel size this view asked for last, and when, among all views. */
requested?: PageSize & { order: number };
}
/** The view a person's hold was used from, gone with its stream dropped. Its late hand-back still counts, and the view of
* theirs that carries on with the hold waits until what it pressed is let go. */
interface Left {
viewer: Viewer;
/** Resolves once its in-flight input finished and the keys and buttons it held are let go. */
settled: Promise<void>;
done: boolean;
}
/** How a view reaches its browser's DevTools, to watch the page's own dialogs: without it, they are not watched. */
export interface BrowserLiveDevTools {
/** The browser's DevTools address, from its daemon's stream port. */
url(port: number): Promise<unknown>;
connect?: (url: string, timeoutMs: number, listeners: CdpListeners) => Promise<Cdp>;
}
export class BrowserLive {
private readonly runtime: BrowserRuntime;
private readonly viewers = new Map<string, Viewer>();
/** By browser session: the person's view their hold is used from. Their other views watch it; any may take it over. */
private readonly drivers = new Map<string, string>();
private readonly left = new Map<string, Left>();
private readonly clocks = new Map<string, HoldClock>();
private readonly holdLimits: HoldLimits;
private readonly sessionResets = new Map<string, Promise<boolean>>();
/** Each watched browser's page dialogs, and the views that watch them. */
private readonly dialogs = new Map<string, { watch: DialogWatch; views: Set<string> }>();
private readonly devTools?: BrowserLiveDevTools;
private order = 0;
constructor({ runtime, devTools, holdLimits = HOLD_LIMITS }: { runtime: BrowserRuntime; devTools?: BrowserLiveDevTools; holdLimits?: HoldLimits }) {
this.runtime = runtime;
this.devTools = devTools;
this.holdLimits = holdLimits;
runtime.onIdleClose((session) => this.asleep(session));
}
private current(viewer: Viewer): boolean {
try { return !viewer.closed && !viewer.res.destroyed && !viewer.res.writableEnded && viewer.isCurrent(); } catch { return false; }
}
private send(viewer: Viewer, message: ObjectValue): void {
if (!this.current(viewer)) { this.close(viewer, true, true); return; }
if (!viewer.res.headersSent) return;
if (viewer.waiting && message.type !== "waiting" && message.type !== "error") return;
if (viewer.res.writableLength > MAX_BUFFER) { this.close(viewer, true, true); return; }
if (viewer.blocked) return;
const { type, ...data } = message;
let written: boolean;
try { written = viewer.res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`); }
catch { this.close(viewer, true, true); return; }
if (!written) {
viewer.blocked = true;
viewer.drainTimer = setTimeout(() => this.close(viewer, true, true), HEARTBEAT_MS);
viewer.drainTimer.unref();
viewer.res.once("drain", () => {
viewer.blocked = false; clearTimeout(viewer.drainTimer);
this.control(viewer.session);
const pending = viewer.pendingFrame; viewer.pendingFrame = undefined;
if (pending) this.frame(viewer, pending);
});
}
}
/** Every view hears who holds the browser: `owned` when its person does, from this view or another of theirs, and
* `controlling` when it is the view their hold is used from and their input runs now. */
private control(session: string): void {
const holder = this.runtime.heldBy(session) ?? null;
// A hold that ended, whichever way, leaves nothing behind for the next one.
if (holder === null) this.forgetHold(session);
for (const viewer of this.viewers.values()) {
if (viewer.session !== session) continue;
const hidden = holder !== null && holder !== viewer.person;
if (hidden && (viewer.frameSeq || viewer.unacked.size)) {
viewer.unacked.clear();
try { this.release(viewer); } catch { this.close(viewer); continue; }
}
if (hidden && viewer.pendingFrame) {
const pending = viewer.pendingFrame; viewer.pendingFrame = undefined;
this.frame(viewer, pending);
}
// Like its frames, the page's question is withheld from views while another person holds the browser.
const dialog = hidden ? null : this.dialogs.get(session)?.watch.dialog ?? null;
this.send(viewer, { type: "control", held: holder !== null, owned: holder !== null && !hidden, controlling: this.controlling(viewer), ...(dialog ? { dialog } : {}) });
if (!hidden && viewer.hiddenFrame) {
const frame = viewer.hiddenFrame; viewer.hiddenFrame = undefined;
this.frame(viewer, frame);
}
}
}
private frame(viewer: Viewer, frame: ObjectValue): void {
if (!this.current(viewer)) { this.close(viewer, true, true); return; }
const holder = this.runtime.heldBy(viewer.session);
if (holder && holder !== viewer.person) {
viewer.hiddenFrame = frame;
try { this.sendSocket(viewer, { type: "ack", seq: frame.seq }); } catch { this.close(viewer); }
return;
}
// ACKs can arrive before the SSE transport drains: keep the newest frame rather than wait for an ACK the viewer cannot send.
if (viewer.blocked) { viewer.pendingFrame = frame; return; }
viewer.pendingFrame = undefined;
viewer.unacked.set(frame.seq as number, Date.now());
viewer.frameSeq = frame.seq as number;
this.send(viewer, frame);
if (viewer.closed) return;
try { this.release(viewer); } catch { this.close(viewer); }
}
/** Lets upstream send its next frame while the view has room for it. */
private release(viewer: Viewer): void {
if (viewer.frameSeq === undefined || viewer.unacked.size >= FRAME_WINDOW) return;
this.sendSocket(viewer, { type: "ack", seq: viewer.frameSeq });
viewer.frameSeq = undefined;
}
/** The view rendered `seq`, and every frame sent before it. */
private rendered(viewer: Viewer, seq: number): void {
if (!viewer.unacked.has(seq)) return;
for (const sent of viewer.unacked.keys()) {
viewer.unacked.delete(sent);
if (sent === seq) break;
}
this.release(viewer);
}
private sendSocket(viewer: Viewer, message: ObjectValue): void {
if (viewer.socket?.readyState !== WebSocket.OPEN || viewer.socket.bufferedAmount > 64 * 1024) {
throw new BrowserLiveError("The browser connection closed. Reopen the browser panel.", 409);
}
viewer.socket.send(JSON.stringify(message));
}
/** `dropped`: the person's stream was lost, not closed on purpose, so a hold they have waits for their panel to reconnect. */
private close(viewer: Viewer, endResponse = true, dropped = false): void {
if (viewer.closed) return;
viewer.closed = true;
viewer.abort.abort();
viewer.unwatch();
this.viewers.delete(viewer.id);
if (viewer.requested) this.fit(viewer.session);
clearTimeout(viewer.heartbeat); clearTimeout(viewer.drainTimer);
viewer.socket?.close();
viewer.hiddenFrame = undefined; viewer.pendingFrame = undefined; viewer.unacked.clear();
this.unwatchDialogs(viewer);
if (!viewer.restarting) this.leave(viewer, dropped);
if (endResponse && !viewer.res.writableEnded && !viewer.res.destroyed) {
if (viewer.blocked || viewer.res.writableLength > MAX_BUFFER) viewer.res.destroy();
else viewer.res.end();
}
this.control(viewer.session);
}
/** The page fills the panel of the view that asked last, once the bot's running call ends; with no view left, the bot's next call gives it the standard size. */
private fit(session: string, fresh = false): void {
const latest = [...this.viewers.values()].filter((viewer) => viewer.session === session && viewer.requested)
.sort((a, b) => b.requested!.order - a.requested!.order)[0]?.requested;
void this.runtime.pageSizes.resize(session, latest ? { width: latest.width, height: latest.height } : null, fresh).catch(() => undefined);
}
/** Its person holds the browser, from this view or another of theirs. */
private ownedBy(viewer: Viewer): boolean {
return this.runtime.heldBy(viewer.session) === viewer.person;
}
/** It is the view its person's hold is used from, and their input runs now. */
private controlling(viewer: Viewer): boolean {
return this.drivers.get(viewer.session) === viewer.id && this.runtime.canControl(viewer.session, viewer.person);
}
private viewsOf(session: string, person: string): Viewer[] {
return [...this.viewers.values()].filter((viewer) => viewer.session === session && viewer.person === person);
}
private clock(session: string): HoldClock {
let clock = this.clocks.get(session);
if (!clock) {
clock = new HoldClock(() => {
const person = this.runtime.heldBy(session);
if (person) this.end(session, person);
}, this.holdLimits);
this.clocks.set(session, clock);
}
return clock;
}
/**
* A view of the person holding the browser went. What it pressed is let go once its input on the way has run. If its
* stream dropped while it was still theirs, the hold carries on in their other views, or waits the grace for their next
* one. A view closed on purpose, or no longer theirs (signed out, switched away), ends a hold no view of theirs has.
*/
private leave(viewer: Viewer, dropped: boolean): void {
const session = viewer.session;
const used = this.drivers.get(session) === viewer.id;
if (used) this.drivers.delete(session);
if (!this.ownedBy(viewer)) return;
const open = this.viewsOf(session, viewer.person).length > 0;
let theirs = false;
try { theirs = dropped && viewer.isCurrent(); } catch {}
if (!theirs) {
this.abandonPressedInput(viewer);
if (!open) this.end(session, viewer.person);
return;
}
let finish!: () => void;
const left: Left = { viewer, settled: new Promise<void>((resolve) => { finish = resolve; }), done: false };
if (used) this.left.set(session, left);
viewer.idle = () => {
viewer.idle = undefined;
void this.releaseHeld(viewer).finally(() => { left.done = true; finish(); });
};
if (viewer.pendingActions === 0) viewer.idle();
this.clock(session).watched(open);
}
/** The person's hold ends as a hand-back does, once what their view that dropped pressed is let go. */
private end(session: string, person: string): void {
const left = this.left.get(session);
const finish = () => {
if (this.runtime.heldBy(session) !== person) return;
for (const viewer of this.viewsOf(session, person)) this.abandonPressedInput(viewer);
this.runtime.release(session, person);
this.control(session);
};
if (left && !left.done) void left.settled.then(finish); else finish();
}
private forgetHold(session: string): void {
this.drivers.delete(session);
this.left.delete(session);
this.clocks.get(session)?.stop();
}
/** Its person holds the browser and none of their views uses it: this one carries on with their hold, but never before
* what the view they used pressed is let go, nor replays that view's input. */
private async carryOn(viewer: Viewer): Promise<void> {
const session = viewer.session;
const free = () => this.ownedBy(viewer) && !this.viewers.has(this.drivers.get(session) ?? "");
if (!free()) return;
await this.left.get(session)?.settled;
if (!this.current(viewer) || !free()) return;
this.drivers.set(session, viewer.id);
this.left.delete(session);
}
/** Lets go in the page of keys and buttons a dropped view still held, as the panel does when its window loses focus.
* Only a release the browser does not confirm keeps the restart barrier. */
private async releaseHeld(viewer: Viewer): Promise<void> {
if (!viewer.pressedKeys.size && !viewer.pressedButtons.size) return;
const port = viewer.port;
if (!port || !this.runtime.canControl(viewer.session, viewer.person)) { this.abandonPressedInput(viewer); return; }
await this.runtime.withHumanAction(viewer.session, viewer.person, async () => {
const unconfirmed = () => new BrowserInputUnconfirmedError();
for (const [code, key] of viewer.pressedKeys) {
if (!await this.sendInput(viewer, port, { action: "input_keyboard", type: "keyUp", key, ...(code === key ? {} : { code }) }, true)) throw unconfirmed();
viewer.pressedKeys.delete(code);
}
for (const button of viewer.pressedButtons) {
const release = { action: "input_mouse", type: "mouseReleased", ...viewer.pointer, button, clickCount: 1, modifiers: 0, deltaX: 0, deltaY: 0 };
if (!await this.sendInput(viewer, port, release, true)) throw unconfirmed();
viewer.pressedButtons.delete(button);
}
}).catch(() => undefined);
}
private abandonPressedInput(viewer: Viewer): void {
// A disconnect may lose the matching key-up/mouse-up; an agent must never inherit held input.
if (viewer.pressedKeys.size || viewer.pressedButtons.size) this.runtime.abandonHumanInput(viewer.session, viewer.person);
}
private async command(viewer: Viewer, args: string[], timeout = COMMAND_TIMEOUT_MS, person = false): Promise<ObjectValue> {
if (!this.current(viewer)) throw new BrowserLiveError("This browser view is no longer available.", 409);
const env = browserRuntimeEnv({ ...viewer.spec.env, AGENT_BROWSER_SESSION: viewer.session });
try {
const { stdout } = await execute(viewer.spec.command, [...args, "--json", "--no-webmcp"], {
env, timeout, killSignal: "SIGKILL", maxBuffer: 1024 * 1024, encoding: "utf8", windowsHide: true,
signal: viewer.abort.signal,
});
if (!this.current(viewer)) throw new Error("stale viewer");
const result = object(JSON.parse(stdout));
const data = object(result?.data);
if (result?.success !== true || !data) {
// The engine's own error stays internal (logged, never sent).
const detail = [result?.error, result?.message].filter((part): part is string => typeof part === "string").join(" ");
throw Object.assign(new Error(detail ? `browser command failed: ${detail}` : "browser command failed"), { stdout });
}
return data;
} catch (error) {
if (timedOut(error)) throw new BrowserBusyError();
console.warn("browser-live:", error);
if (browserLaunchFailure(error)) {
// A daemon holding a launch config Chrome cannot satisfy here (headed, no display) fails every command until killed, so discard the session once.
this.resetSession(viewer, "a failed browser launch");
throw new BrowserLiveError("The browser could not start on this server. Reconnect to retry with a fresh browser session.", 503);
}
if (person && engineAnsweredFailure(error)) throw new BrowserActionFailedError(args);
throw new BrowserLiveError("The browser could not complete this action. Check that the browser engine is installed, then reconnect.", 503);
}
}
/** Discards the session's daemon once; connecting waits for it, then starts a fresh one. */
private resetSession(viewer: Viewer, why: string): void {
if (this.sessionResets.has(viewer.session)) return;
const env = browserRuntimeEnv({ ...viewer.spec.env, AGENT_BROWSER_SESSION: viewer.session });
const reset = closeBrowserSession(viewer.spec.command, env).then((closed) => {
if (closed) console.warn(`browser-live: reset ${viewer.session} after ${why}; reconnecting starts a fresh session`);
else console.warn(`browser-live: reset of ${viewer.session} after ${why} did not finish; the next command retries it`);
return closed;
}, () => false);
this.sessionResets.set(viewer.session, reset);
void reset.finally(() => { if (this.sessionResets.get(viewer.session) === reset) this.sessionResets.delete(viewer.session); });
}
private async connect(viewer: Viewer): Promise<number> {
// Wait out a reset or idle close in progress, so the probe meets a fresh daemon; checked on every attempt.
const resetting = this.sessionResets.get(viewer.session);
if (resetting) await resetting;
await this.runtime.settled(viewer.session);
await this.runtime.launchReady(viewer.spec).catch((error: unknown) => {
throw new BrowserLiveError(error instanceof Error ? error.message : "The browser is not ready yet.", 503);
});
let status: ObjectValue;
try { status = await this.command(viewer, ["stream", "status"], STATUS_PROBE_MS); }
catch (error) {
// Only a bot call or a person's action can hold the engine past this read; with neither, it has hung.
if (!(error instanceof BrowserBusyError) || this.runtime.working(viewer.session)) throw error;
this.resetSession(viewer, "a status read the browser never answered");
throw new BrowserUnresponsiveError();
}
if (status.enabled !== true) {
try { status = await this.command(viewer, ["stream", "enable"]); }
catch (error) {
if (error instanceof BrowserBusyError) throw error;
status = await this.command(viewer, ["stream", "status"]);
}
}
const port = browserStreamPort(status);
// `open` without a URL is an idempotent launch, never navigation away from the bot's page.
if (status.connected !== true) await this.command(viewer, ["open"]);
await this.runtime.launched(viewer.session, viewer.spec);
return port;
}
private async connectWhenFree(viewer: Viewer): Promise<number> {
const deadline = Date.now() + BUSY_LIMIT_MS;
for (let attempt = 0; ; attempt++) {
try { return await this.connect(viewer); }
catch (error) {
if (!(error instanceof BrowserBusyError) || !this.current(viewer)) throw error;
if (Date.now() >= deadline) throw new BrowserBusyError("The bot is still using this browser. Reconnect when it has finished.");
}
viewer.waiting = true;
this.startEvents(viewer);
this.send(viewer, BROWSER_WAITING);
await new Promise<void>((resolve) => {
const done = () => { clearTimeout(timer); viewer.abort.signal.removeEventListener("abort", done); resolve(); };
const timer = setTimeout(done, BUSY_RETRY_MS[Math.min(attempt, BUSY_RETRY_MS.length - 1)]);
timer.unref?.();
viewer.abort.signal.addEventListener("abort", done, { once: true });
});
if (!this.current(viewer)) throw new BrowserLiveError("This browser view is no longer available.", 409);
}
}
private startEvents(viewer: Viewer): void {
if (viewer.res.headersSent) return;
viewer.res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-store", "X-Accel-Buffering": "no", Connection: "keep-alive" });
viewer.res.flushHeaders();
}
private async input(viewer: Viewer, message: ObjectValue): Promise<void> {
if (!viewer.port) throw new BrowserLiveError("The browser stream is not connected.", 409);
let command: ObjectValue;
const { type, eventType, ...fields } = message;
const key = String(fields.code || fields.key);
const shortcut = Number(fields.modifiers) > 0 && !["Alt", "Control", "Meta", "Shift"].includes(String(fields.key))
&& !(fields.modifiers === 8 && text(fields.key, 64) && [...fields.key].length === 1);
// press already released the key: match raw held keys, not keyUp modifiers, which may have changed or be zero after blur.
if (type === "input_keyboard" && eventType === "keyUp" && !viewer.pressedKeys.has(key)) return;
if (type === "input_mouse") command = { action: "input_mouse", type: eventType, ...fields };
else if (eventType === "char") command = { action: "keyboard", subaction: "insertText", text: fields.text };
else if (eventType === "keyDown" && (shortcut || DISCRETE_KEYS.has(String(fields.key)))) {
// press resolves virtual key codes, text and modifiers, and acknowledges the whole key-down/up pair before hand-back.
const modifiers = fields.modifiers as number;
const chord = [[1, "Alt"], [2, "Control"], [4, "Meta"], [8, "Shift"]] as const;
command = { action: "press", key: [...chord.filter(([bit]) => modifiers & bit).map(([, key]) => key), fields.key].join("+") };
} else command = { action: "input_keyboard", type: eventType, key: fields.key,
...(fields.code === undefined ? {} : { code: fields.code }), ...(fields.text === undefined ? {} : { text: fields.text }) };
if (command.action === "input_keyboard" && eventType === "keyDown") {
if (viewer.pressedKeys.size >= 64 && !viewer.pressedKeys.has(key)) throw new BrowserLiveError("Too many keys are held. Restart the browser before continuing.", 409);
viewer.pressedKeys.set(key, String(fields.key));
}
if (type === "input_mouse") viewer.pointer = { x: fields.x as number, y: fields.y as number };
if (type === "input_mouse" && eventType === "mousePressed") viewer.pressedButtons.add(String(fields.button));
const releases = (command.action === "input_keyboard" && eventType === "keyUp") || (type === "input_mouse" && eventType === "mouseReleased");
let confirmed: boolean;
try { confirmed = await this.sendInput(viewer, viewer.port, command, releases); }
catch (error) {
if (error instanceof BrowserInputRefusedError) {
if (command.action === "input_keyboard" && eventType === "keyDown") viewer.pressedKeys.delete(key);
if (type === "input_mouse" && eventType === "mousePressed") viewer.pressedButtons.delete(String(fields.button));
}
throw error;
}
if (!confirmed) throw new BrowserInputUnconfirmedError();
if (command.action === "press" || (command.action === "input_keyboard" && eventType === "keyUp")) viewer.pressedKeys.delete(key);
if (type === "input_mouse" && eventType === "mouseReleased") viewer.pressedButtons.delete(String(fields.button));
}
/** The relay awaits the daemon's CDP reply; a WS send is only an enqueue and cannot serve as the hand-back barrier. A single CDP
* event the daemon refused had no effect, and a release may repeat, so each is sent once more; a press is two events. Chrome
* holds an input that opened the page's own question until it is answered, then delivers it. */
private async sendInput(viewer: Viewer, port: number, command: ObjectValue, releases: boolean): Promise<boolean> {
const send = () => relay(port, command, INPUT_RELAY).then((answer) => answer.success);
const watch = this.dialogs.get(viewer.session)?.watch;
const before = watch?.opened ?? 0;
const asked = () => !!watch && (watch.dialog !== null || watch.opened !== before);
const answered = async () => { await watch!.settled(viewer.abort.signal); return !viewer.abort.signal.aborted; };
try {
if (await send()) return true;
if (asked()) return await answered();
if (command.action !== "press" && await send()) return true;
} catch {
if (asked()) return await answered();
if (!await this.ended(port)) return false;
if (!releases) throw new BrowserSlowActionError();
return send().catch(() => false);
}
// A press is a key-down and key-up in one command: refused, its key may still be down.
if (releases || command.action === "press") return false;
throw new BrowserInputRefusedError();
}
private async watchDialogs(viewer: Viewer): Promise<void> {
if (!this.devTools || !viewer.port || attachesToChrome(viewer.spec.env ?? {})) return;
const devTools = this.devTools;
const port = viewer.port;
const join = () => {
const entry = this.dialogs.get(viewer.session);
if (!entry || entry.watch.closed) return false;
if (this.current(viewer)) entry.views.add(viewer.id);
return true;
};
if (join()) return;
const url = localDevToolsUrl(await devTools.url(port).catch(() => null));
if (join() || !url || !this.current(viewer)) return;
const session = viewer.session;
const watch = new DialogWatch(() => this.control(session));
this.dialogs.set(session, { watch, views: new Set([viewer.id]) });
await watch.start(url, devTools.connect).catch(() => watch.close());
}
private unwatchDialogs(viewer: Viewer): void {
const entry = this.dialogs.get(viewer.session);
if (!entry?.views.delete(viewer.id) || entry.views.size) return;
entry.watch.close();
this.dialogs.delete(viewer.session);
}
/** The person's browser stopped answering an input: restarted for them, on the same profile, and every view reconnects. */
private async restartUnresponsive(viewer: Viewer): Promise<boolean> {
if (viewer.restarting) return false;
viewer.restarting = true;
try {
await this.runtime.restart(viewer.session, viewer.person, async () => {
const env = browserRuntimeEnv({ ...viewer.spec.env, AGENT_BROWSER_SESSION: viewer.session });
if (!await closeBrowserSession(viewer.spec.command, env)) throw new Error("browser close failed");
});
const note = { type: "error", retryable: true, message: restartedNote(viewer.spec.env?.AGENT_BROWSER_RESTORE_SAVE !== "never") };
for (const other of this.viewers.values()) if (other.session === viewer.session) this.send(other, note);
this.closeForSession(viewer.session);
return true;
} catch { return false; }
finally { viewer.restarting = false; if (viewer.closed) this.runtime.release(viewer.session, viewer.person); this.control(viewer.session); }
}
/** The daemon runs one command per session at a time, so an answer to a read sent after an unanswered command proves it ended. */
private ended(port: number): Promise<boolean> {
return relay(port, { action: "stream_status" }, ENDED_PROBE).then((answer) => answer.success, () => false);
}
async open(options: OpenOptions): Promise<void> {
// The view this panel had before its stream dropped, if the server still has it open, never comes back.
const replaced = options.resume ? this.viewers.get(options.resume) : undefined;
if (replaced && replaced.owner === options.owner && replaced.botId === options.botId) this.close(replaced, true, true);
if (this.viewers.size >= 8 || [...this.viewers.values()].filter((v) => v.session === options.session).length >= 2) {
throw new BrowserLiveError("Too many browser views are open. Close another browser panel first.", 429);
}
const viewer: Viewer = { ...options, id: randomUUID(), person: options.person ?? options.owner, closed: false, blocked: false, pendingActions: 0, restarting: false, pressedKeys: new Map(), pressedButtons: new Set(), pointer: { x: 0, y: 0 }, unacked: new Map(), waiting: false, abort: new AbortController(), unwatch: () => {} };
if (!this.current(viewer)) throw new BrowserLiveError("This browser view is no longer available.", 409);
// A view that opens while another holds the browser leaves the holder's page as it is.
if (options.size && !this.runtime.heldBy(options.session)) viewer.requested = { ...options.size, order: ++this.order };
viewer.unwatch = this.runtime.watch(viewer.session, viewer.spec);
this.viewers.set(viewer.id, viewer);
if (this.ownedBy(viewer)) this.clock(viewer.session).watched(true);
options.res.once("close", () => this.close(viewer, true, true));
options.res.once("error", () => this.close(viewer, true, true));
try {
const port = await this.connectWhenFree(viewer);
if (!this.current(viewer)) throw new BrowserLiveError("This browser view is no longer available.", 409);
// Before its frames flow: the person's next view carries on with their hold, after a dropped stream or a reopened app.
await this.carryOn(viewer);
if (!this.current(viewer)) throw new BrowserLiveError("This browser view is no longer available.", 409);
viewer.port = port;
this.fit(viewer.session, true);
await this.watchDialogs(viewer);
if (!this.current(viewer)) throw new BrowserLiveError("This browser view is no longer available.", 409);
const socket = new WebSocket(`ws://127.0.0.1:${port}/?pacing=ack&maxFps=15`);
viewer.socket = socket;
this.startEvents(viewer);
viewer.waiting = false;
this.send(viewer, { type: "ready", viewerId: viewer.id, inputBatch: INPUT_BATCH_LIMIT });
this.control(viewer.session);
socket.addEventListener("message", (event) => {
if (!this.current(viewer)) { this.close(viewer, true, true); return; }
if (typeof event.data !== "string" || event.data.length > MAX_BUFFER) { this.close(viewer); return; }
let message: ObjectValue | null;
let raw: ObjectValue | null;
try { raw = object(JSON.parse(event.data)); message = normalizeBrowserLiveMessage(raw); } catch { this.close(viewer); return; }
if (!message) { if (raw?.type === "frame") this.close(viewer); return; }
const heldBy = this.runtime.heldBy(viewer.session);
if (heldBy && heldBy !== viewer.person && ["tabs", "url"].includes(String(message.type))) return;
if (message.type === "frame") this.frame(viewer, message);
else this.send(viewer, message);
});
socket.addEventListener("error", () => { if (!viewer.restarting) { this.send(viewer, { type: "error", retryable: true, message: "The browser stream disconnected." }); this.close(viewer); } });
socket.addEventListener("close", () => { if (!viewer.restarting) this.close(viewer); });
// Handlers go on before awaiting the upgrade: upstream sends its cached status, tabs and first frame right after opening.
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new BrowserLiveError("The browser stream did not connect. Reopen the browser panel.", 503)), 10_000);
const failed = () => { clearTimeout(timer); reject(new BrowserLiveError("The browser stream did not connect. Reopen the browser panel.", 503)); };
socket.addEventListener("error", failed, { once: true });
socket.addEventListener("close", failed, { once: true });
socket.addEventListener("open", () => { clearTimeout(timer); socket.removeEventListener("error", failed); socket.removeEventListener("close", failed); resolve(); }, { once: true });
});
if (!this.current(viewer)) { this.close(viewer); return; }
viewer.heartbeat = setInterval(() => {
const oldest = viewer.unacked.values().next().value;
if (!this.current(viewer) || (oldest !== undefined && Date.now() - oldest > 2 * HEARTBEAT_MS)) { this.close(viewer, true, true); return; }
this.control(viewer.session);
this.send(viewer, { type: "heartbeat" });
}, HEARTBEAT_MS);
viewer.heartbeat.unref();
} catch (error) {
console.warn("browser-live:", error);
if (error instanceof BrowserUnresponsiveError) this.startEvents(viewer);
if (options.res.headersSent) {
const busy = error instanceof BrowserBusyError;
const explained = busy || error instanceof BrowserUnresponsiveError;
this.send(viewer, { type: "error", retryable: !busy, message: explained ? error.message : "The browser stream could not start.", ...userErrorFields(error) });
this.close(viewer); return;
}
this.close(viewer, options.res.headersSent);
throw error instanceof BrowserLiveError ? error : new BrowserLiveError("The browser stream could not start. Reopen the browser panel.", 503);
}
}
async action({ viewerId, botId, owner, body }: { viewerId: string; botId: string; owner: string; body: unknown }): Promise<unknown> {
const viewer = this.viewers.get(viewerId);
if (!viewer || viewer.botId !== botId || viewer.owner !== owner || !this.current(viewer)) {
if (viewer && viewer.botId === botId && viewer.owner === owner) this.close(viewer, true, true);
// A hand-back sent as its own stream dropped still hands back.
const left = [...this.left.values()].find((entry) => entry.viewer.id === viewerId);
if (left && left.viewer.botId === botId && left.viewer.owner === owner && object(body)?.type === "release") {
this.end(left.viewer.session, left.viewer.person);
return { ok: true };
}
throw new BrowserLiveError("This browser view is no longer available. Reopen the browser panel.", 404);
}
const action = parseBrowserLiveAction(body);
if (action.type === "restart") {
if (viewer.restarting) throw new BrowserLiveError("The browser is already restarting.", 409);
viewer.restarting = true;
try {
const restarting = this.runtime.restart(viewer.session, viewer.person, async () => {
if (!this.current(viewer)) throw new Error("stale viewer");
const env = browserRuntimeEnv({ ...viewer.spec.env, AGENT_BROWSER_SESSION: viewer.session });
if (!await closeBrowserSession(viewer.spec.command, env)) throw new Error("browser close failed");
});
this.control(viewer.session);
await restarting;
this.closeForSession(viewer.session);
return { ok: true };
} catch { throw new BrowserLiveError("The browser could not restart. Wait for active work to finish and try again.", 409); }
finally { viewer.restarting = false; if (viewer.closed) this.runtime.release(viewer.session, viewer.person); this.control(viewer.session); }
}
if (action.type === "ack") {
this.rendered(viewer, action.seq);
return { ok: true };
}
if (action.type === "resize") {
const holder = this.runtime.heldBy(viewer.session);
if (action.size && (!holder || (holder === viewer.person && this.drivers.get(viewer.session) === viewer.id))) {
viewer.requested = { ...action.size, order: ++this.order };
this.fit(viewer.session);
}
return { ok: true };
}
// Any view of the person hands their hold back, whichever of their views it is used from.
if (action.type === "release") {
this.end(viewer.session, viewer.person);
return { ok: true };
}
if (action.type === "take") {
try {
const taking = this.runtime.take(viewer.session, viewer.person);
this.control(viewer.session);
// The bot's own action finished first, so input aimed before the grant may no longer fit the page.
const waited = await taking;
// Taken over from another view of theirs: what that view pressed is let go first, and never replayed here.
const before = this.viewers.get(this.drivers.get(viewer.session) ?? "");
if (before && before !== viewer) await this.releaseHeld(before);
await this.left.get(viewer.session)?.settled;
if (!this.current(viewer)) { this.close(viewer, true, true); throw new BrowserLiveError("This browser view closed.", 409); }
if (this.ownedBy(viewer)) {
this.drivers.set(viewer.session, viewer.id);
this.left.delete(viewer.session);
this.clock(viewer.session).used();
this.clock(viewer.session).watched(true);
}
return { ok: true, waited: waited === true };
} catch { throw this.refusal(viewer, "Another browser view or bot action is using this browser. Try again shortly."); }
finally { this.control(viewer.session); }
}
if (!this.controlling(viewer)) throw this.refusal(viewer, "Browser control changed. Try again.");
this.clock(viewer.session).used();
if (action.type === "dialog") {
const watch = this.dialogs.get(viewer.session)?.watch;
if (!watch?.dialog) throw new BrowserLiveError("The page's question is no longer open.", 409);
if (!await watch.answer(action.accept, action.promptText)) throw new BrowserLiveError("The page did not take that answer. Try again.", 503);
return { ok: true };
}
if (viewer.pendingActions >= 32) throw new BrowserLiveError("Too many browser actions are pending. Try again shortly.", 429);
viewer.pendingActions += 1;
let refused = false;
try {
await this.runtime.withHumanAction(viewer.session, viewer.person, async () => {
if (!this.current(viewer)) throw new BrowserLiveError("This browser view closed.", 409);
const take = async (message: ObjectValue) => {
try { await this.input(viewer, message); }
catch (error) { if (!(error instanceof BrowserInputRefusedError)) throw error; refused = true; }
};
if (action.type === "input") await take(action.message);
else if (action.type === "inputs") { for (const message of action.messages) await take(message); }
else if (action.type === "command") await this.command(viewer, action.args, COMMAND_TIMEOUT_MS, true).catch(async (error: unknown) => {
throw error instanceof BrowserBusyError && viewer.port && await this.ended(viewer.port) ? new BrowserSlowActionError() : error;
});
});
return refused ? { ok: true, notice: INPUT_REFUSED } : { ok: true };
} catch (error) {
if (error instanceof BrowserInputUnconfirmedError && viewer.pendingActions === 1 && await this.restartUnresponsive(viewer)) {
throw new BrowserLiveError(restartedNote(viewer.spec.env?.AGENT_BROWSER_RESTORE_SAVE !== "never"), 503);
}
throw error instanceof BrowserLiveError ? error : this.refusal(viewer, "Browser control changed. Try again.");
}
finally {
viewer.pendingActions -= 1;
if (viewer.closed && viewer.pendingActions === 0) viewer.idle?.();
this.control(viewer.session);
}
}
private refusal(viewer: Viewer, otherwise: string): BrowserLiveError {
const holder = this.runtime.heldBy(viewer.session);
if (holder && holder !== viewer.person) return new BrowserLiveError(BROWSER_HELD_ELSEWHERE, 409);
return new BrowserLiveError(this.runtime.interrupted(viewer.session) ? RESTART_NEEDED : otherwise, 409);
}
/** Done on a sign-in card, from any window or device: whoever holds this browser hands it back. */
handBack(session: string): void {
const person = this.runtime.heldBy(session);
if (person) this.end(session, person);
}
/** Every view of a browser about to close unused stops, while its stream still answers. */
private asleep(session: string): void {
for (const viewer of this.viewers.values()) {
if (viewer.session !== session) continue;
this.send(viewer, BROWSER_ASLEEP_ERROR);
this.close(viewer);
}
}
closeForSession(session: string): void { this.closeWhere((viewer) => viewer.session === session); }
closeForOwner(owner: string): void { this.closeWhere((viewer) => viewer.owner === owner); }
closeForBot(botId: string): void { this.closeWhere((viewer) => viewer.botId === botId); }
closeAll(): void { this.closeWhere(() => true); }
/** Closed on purpose: a hold none of its person's views still has ends now, a dropped view's included. */
private closeWhere(match: (viewer: Viewer) => boolean): void {
for (const viewer of this.viewers.values()) if (match(viewer)) this.close(viewer);
for (const { viewer } of this.left.values()) {
if (match(viewer) && !this.viewsOf(viewer.session, viewer.person).length) this.end(viewer.session, viewer.person);
}
}
}