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; 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; 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; pressedButtons: Set; 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; 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; connect?: (url: string, timeoutMs: number, listeners: CdpListeners) => Promise; } export class BrowserLive { private readonly runtime: BrowserRuntime; private readonly viewers = new Map(); /** 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(); private readonly left = new Map(); private readonly clocks = new Map(); private readonly holdLimits: HoldLimits; private readonly sessionResets = new Map>(); /** Each watched browser's page dialogs, and the views that watch them. */ private readonly dialogs = new Map }>(); 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((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 { 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 { 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 { 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 { // 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 { 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((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 { 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 { 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 { 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 { 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 { return relay(port, { action: "stream_status" }, ENDED_PROBE).then((answer) => answer.success, () => false); } async open(options: OpenOptions): Promise { // 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((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 { 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); } } }