/** * CDP façade over `chrome.debugger`. * * Puppeteer clients (the omp browser tool: one supervisor connection plus one * per tab worker) connect to this bridge as if it were Chrome's browser * debugging endpoint. Chrome only allows a single debugger attachment per tab, * so the bridge owns ONE `chrome.debugger` attachment per tab (via the * extension) and multiplexes every downstream connection over it with minted * per-connection session ids. * * Emulated surface (everything else is forwarded to `chrome.debugger`): * - the browser target (`/json/version` handshake, `Browser.getVersion`) * - the `Target.*` domain, including puppeteer's tab → page auto-attach * hierarchy (see puppeteer-core `cdp/ExtensionTransport.ts`, the reference * implementation for this emulation) * * Session id namespaces seen by a downstream connection: * - minted tab pseudo-sessions (`ST..`) — Target emulation only * - minted page pseudo-sessions (`SP..`) — forwarded to the * tab's root debugger session * - real child session ids (OOPIFs, workers) — created by Chrome under the * shared root session and passed through verbatim */ import { createHash } from "node:crypto"; import { VERSION } from "@oh-my-pi/pi-utils/dirs"; import { BROWSER_PROTOCOL_TIMEOUT_MS } from "../launch"; import { DISCARDED_TABS_PROTOCOL_VERSION, DOWNLOADS_PROTOCOL_VERSION, EXTENSION_RELOAD_PROTOCOL_VERSION, FOREIGN_FRAME_REFUSAL, isOlderOmpVersion, isOmpVersion, } from "./protocol"; import type { ExtToRelayMessage, RelayRpcRequest, RelayToExtMessage, TabSnapshot } from "./protocol"; /** Transport-agnostic websocket surface the bridge writes to. */ export interface RelaySocket { send(text: string): void; close(): void; } interface CdpCommand { id: number; method: string; params?: Record; sessionId?: string; } /** * Per-pseudo-session Runtime domain state. * - `default`: never toggled Runtime — still receives the relay's legacy * root-event fan-out, so omp's own patched-puppeteer client (which * pull-acquires contexts and never sends `Runtime.enable`) keeps getting * `Runtime.executionContextCreated`. * - `enabled`: ran `Runtime.enable`; gets the existing-context replay. * - `disabled`: explicitly ran `Runtime.disable`; silenced until it re-enables. */ type RuntimeState = "default" | "enabled" | "disabled"; /** * CDP `{ enabled }` switches Chrome keeps per debugger session. Every relay session of a tab shares one attachment, * which outlives them, so the bridge keeps a switch on while any session asks for it and turns it off once none does. */ const TAB_WIDE_SWITCHES = ["Page.setInterceptFileChooserDialog", "Emulation.setFocusEmulationEnabled"] as const; type TabWideSwitch = (typeof TAB_WIDE_SWITCHES)[number]; function isTabWideSwitch(method: string): method is TabWideSwitch { return (TAB_WIDE_SWITCHES as readonly string[]).includes(method); } interface SessionRef { kind: "tab" | "page"; /** Registry key of the owning tab (`:`). */ tabKey: string; tabId: number; runtimeState: RuntimeState; /** Context ids already announced to this pseudo-session. */ readonly runtimeContexts: Set; /** In-flight `Runtime.enable` for this session; duplicates await it. */ runtimeEnabling: Promise | null; /** Monotonic ownership token for enable rollback and replay. */ runtimeEpoch: number; /** Sent `Target.setAutoAttach`; only such a session is told about real child targets. */ autoAttach: boolean; /** Tab-wide switches ({@link TAB_WIDE_SWITCHES}) this session turned on. */ readonly switches: Set; } interface TargetInfo { targetId: string; type: "tab" | "page" | "browser"; title: string; url: string; attached: boolean; canAccessOpener: boolean; } class CdpConnection { discover = false; autoAttach = false; /** Minted pseudo-sessions owned by this connection. */ readonly sessions = new Map(); /** Tabs this connection claimed as drive targets (`/cdp?claim=` / `Target.createTarget`). */ readonly claims = new Set(); /** Real child session id → the session it was announced on; its events and detach go there. */ readonly childParents = new Map(); constructor( readonly id: number, readonly socket: RelaySocket, ) {} sessionsForTab(tabKey: string, kind?: "tab" | "page"): string[] { const out: string[] = []; for (const [sessionId, ref] of this.sessions) { if (ref.tabKey === tabKey && (!kind || ref.kind === kind)) out.push(sessionId); } return out; } /** This tab's first page session that armed auto-attach; one per connection so no child is announced twice. */ childTargetSessionForTab(tabKey: string): string | undefined { for (const [sessionId, ref] of this.sessions) { if (ref.tabKey === tabKey && ref.kind === "page" && ref.autoAttach) return sessionId; } return undefined; } } /** Transport replacement is retryable and must not permanently ban a tab. */ class ExtensionReplacedError extends Error {} /** * Rewrites the files of the extension Chrome knows as `extensionId` to this omp's, when that is the copy this relay * may update; resolves whether the files on disk are now this omp's (then the bridge asks the extension to reload). */ export type RelayBridgeExtensionUpdater = (extensionId: string) => Promise; /** A connected extension browser instance; one per browser/profile. */ interface ExtInstance { instanceId: string; /** Short code derived from the instance id and browser run; names target ids (`TAB.`). */ code: string; socket: RelaySocket | null; info: { userAgent: string; browserVersion: string; discardedTabsProtocol?: number; ompVersion?: string; reloadProtocol?: number; downloadsProtocol?: number; } | null; /** Chrome extension id from the socket's `Origin`; null when the upgrade carried none. */ extensionId: string | null; /** The relay is rewriting this extension's files to reload it: not ready meanwhile (its socket closes after). */ updating: boolean; /** `Date.now()` when its socket closed; null while connected. */ goneSince: number | null; /** Settles at its next hello; created by the first caller that waits for one. */ reconnected: PromiseWithResolvers | null; } /** * Deterministic per-instance code for target ids: stable across relay restarts and service-worker restarts, new * after a browser restart (when Chrome reuses tab ids). Older extensions send no browser session. */ function instanceCode(instanceId: string, browserSession: string): string { const identity = browserSession ? `${instanceId}/${browserSession}` : instanceId; return createHash("sha256").update(identity).digest("base64url").slice(0, 8); } function tabKeyOf(extCode: string, tabId: number): string { return `${extCode}:${tabId}`; } function tabTargetIdFromKey(key: string): string { return `TAB${key.replace(":", ".")}`; } function pageTargetIdFromKey(key: string): string { return `PAGE${key.replace(":", ".")}`; } function parseTargetId(targetId: string): { key: string; kind: "tab" | "page" } | null { const match = /^(TAB|PAGE)([^.]+)\.(\d+)$/.exec(targetId); if (!match) return null; const kind: "tab" | "page" = match[1] === "TAB" ? "tab" : "page"; return { key: `${match[2]}:${match[3]}`, kind }; } class TabState { /** Assigned in the constructor body from the owning instance. */ readonly tabKey: string; url: string; title: string; active: boolean; /** Discarded tabs cannot answer debugger calls. */ discarded: boolean; windowId: number; pinned: boolean; /** Chrome tab group id from the last snapshot; -1 when ungrouped. */ groupId: number; /** Whether `chrome.debugger` is currently attached to this tab. */ attached = false; /** * Why Chrome refused omp this tab (its attach error, or the user cancelling the debugger bar); while set the * tab is not adoptable. Cleared on navigation. */ refusal: string | undefined; /** Whether targets for this tab were announced to discovering connections. */ announced = false; attaching: Promise | null = null; /** Discard cancels the waiter but keeps the extension RPC alive for late cleanup. */ attachCancelled = false; attachGeneration = 0; cancelAttach: (() => void) | null = null; /** Retry when a revived tab's canceled attach request finally settles. */ retryAttachAfterDiscard = false; /** Relay-initiated detach in flight; reattach serializes behind it. */ detaching: Promise | null = null; /** A successful attach completed after the most recently requested relay detach. */ reattachedAfterDetach = false; /** The relay asked the extension to close this tab (`Target.closeTarget`); its coming detach is not Chrome's own. */ closing = false; /** Tab-wide switches whose last change never reached the extension (its socket was down or the send failed). */ readonly unsettledSwitches = new Set(); /** True after the relay put this tab in the omp group; `ompGroupId` holds that group. */ grouped = false; /** Group RPC in flight — suppresses duplicate requests from load-time tabUpdated bursts. */ grouping = false; ompGroupId: number | undefined; /** User pulled the tab out of the omp group — never re-group it. */ groupOptOut = false; /** The app in front of the user when omp's input last reached this tab, and when; a window the tab opens uses it once. */ frontAtInput: { app: FrontApp; at: number } | null = null; /** * Real Chrome sessions (OOPIF/worker children) under this tab's attachment: the child session * that reported each (none for the root) and its `Target.attachedToTarget` params. */ readonly realSessions = new Map }>(); /** Live execution contexts from the shared root debugger session. */ readonly runtimeContexts = new Map>(); /** Whether the shared root Runtime domain has been enabled by the bridge. */ rootRuntimeEnabled = false; rootRuntimeEnabling: Promise | null = null; /** Invalidates an in-flight Runtime enable when the debugger detaches. */ runtimeGeneration = 0; /** Settled by the next detach Chrome reports on its own (not one the relay requested). */ chromeDetach = Promise.withResolvers(); constructor( readonly instanceId: string, readonly extCode: string, readonly tabId: number, snap: TabSnapshot, ) { this.tabKey = tabKeyOf(extCode, tabId); this.url = snap.url; this.title = snap.title; this.active = snap.active; this.discarded = snap.discarded; this.windowId = snap.windowId; this.pinned = snap.pinned; this.groupId = snap.groupId; } update(snap: TabSnapshot): void { this.url = snap.url; this.title = snap.title; this.active = snap.active; this.discarded = snap.discarded; this.windowId = snap.windowId; this.pinned = snap.pinned; this.groupId = snap.groupId; } } /** URLs `chrome.debugger` cannot attach to; hidden from downstream discovery entirely. */ const INELIGIBLE_URL = /^(chrome|devtools|edge|view-source|chrome-extension|chrome-untrusted|chrome-search):/i; const RPC_TIMEOUT_MS = 20_000; /** How long after its timeout a late RPC result is still undone; a wedged extension must not grow the map forever. */ const LATE_RPC_KEEP_MS = 60_000; const CDP_ERROR_METHOD_NOT_FOUND = -32601; const CDP_ERROR_SERVER = -32000; /** * How long a new tab waits for the browser it belongs in (that of `nearTargetId` or of a tab the client drives) to * reconnect after its socket closed: the extension's longest reconnect backoff (10 s) plus its hello. */ const AFFINE_RECONNECT_WAIT_MS = 12_000; /** Chrome's answer to a command in flight when it ends the debugger attachment. */ const DETACHED_COMMAND_ERROR = /Detached while handling command/; /** How long such a failure waits for the extension to report the detach itself. */ const CHROME_DETACH_REPORT_MS = 1_000; /** {@link RelayBridge.#rpc}'s failure when the extension sent no answer within the deadline. */ class RpcTimeoutError extends Error {} /** * Deadline for a command whose answer waits on the page itself (a promise it settles, a navigation it commits), * where {@link RPC_TIMEOUT_MS} is for one Chrome answers at once. omp's own clients give up at * `BROWSER_PROTOCOL_TIMEOUT_MS`, as on a launched Chrome; the relay waits a little longer so their error, not the * relay's, reaches the call. */ const PAGE_BOUND_RPC_TIMEOUT_MS = BROWSER_PROTOCOL_TIMEOUT_MS + 5_000; /** A window a tab opens this long after omp's input reached it is still taken to be that input's doing. */ const POPUP_AFTER_INPUT_MS = 1_000; /** How long the user's app is held in front after omp's input opened a window; Chrome activates within milliseconds. */ const KEEP_FRONT_MS = 500; /** * omp's CDP inputs a page can open a window on, by method and event `type`: those that give the page user activation * (a button or key going down, a release, a tap). Moves, wheels, key-ups and inserted text never do, so they read no * front app. `true`: every event of that method. */ const WINDOW_OPENING_INPUT: Record | true> = { "Input.dispatchMouseEvent": { mousePressed: true, mouseReleased: true }, "Input.emulateTouchFromMouseEvent": { mousePressed: true, mouseReleased: true }, "Input.dispatchKeyEvent": { keyDown: true, rawKeyDown: true }, "Input.dispatchTouchEvent": { touchStart: true, touchEnd: true }, "Input.synthesizeTapGesture": true, }; function canOpenWindow(method: string, params: Record | undefined): boolean { if (!Object.hasOwn(WINDOW_OPENING_INPUT, method)) return false; const types = WINDOW_OPENING_INPUT[method]; const type = params?.type; return types === true || (typeof type === "string" && Object.hasOwn(types, type)); } /** The app in front of the user, as `macOSFrontApp` from `@oh-my-pi/pi-natives` reads it. */ export interface FrontApp { /** Puts the app back in front each time the app that took the front takes it again within `durationMs`, until the user picks an app; resolves how many times. */ keepInFront(durationMs: number): Promise; } /** * Multiplexing CDP bridge between downstream puppeteer connections and the * relay extension. One instance per relay server; all state lives here so an * extension service-worker restart only has to re-handshake. */ export class RelayBridge { /** Tab registries keyed by `:`; one namespace per browser instance. */ #tabs = new Map(); #conns = new Map(); #connSeq = 0; #sessionSeq = 0; #rpcSeq = 0; /** Connected extension browser instances, keyed by stable instance id. */ #instances = new Map(); #socketInstance = new Map(); /** Chrome extension id each extension socket's upgrade named in its `Origin`. */ #socketExtensionId = new Map(); /** Rewrites the files of the extension with this id to this omp's; resolves whether it did (see {@link RelayBridgeExtensionUpdater}). */ #updateExtension: RelayBridgeExtensionUpdater | null; /** Per instance, the extension versions the relay already reloaded it from: never twice from the same one. */ #updateTried = new Map>(); /** Instance whose hello ran last: answers browser-wide requests and owns created tabs. */ #lastHelloInstance: string | null = null; #extensionSeen = false; /** * Tabs omp opened whose close failed while their browser was offline (see {@link #closeTab}), by tab key, with * the URL the tab showed: closed at that browser's next hello, unless the tab is gone or shows another page. */ #closeOnHello = new Map(); /** `Date.now()` when the last ready extension socket closed; null while one is ready or none ever was. */ #extensionGoneSince: number | null = null; #pendingRpc = new Map< string, { resolve: (value: unknown) => void; reject: (err: Error) => void; timer: NodeJS.Timeout } >(); /** * Timed-out RPCs whose late success still needs undoing (a tab Chrome created after omp gave up), by * `instance:id`. */ #lateRpc = new Map void; timer: NodeJS.Timeout }>(); /** Real child session id → owning tab key, learned from `Target.attachedToTarget` events. */ #realSessionTabs = new Map(); #log: (message: string, data?: Record) => void; /** Tab-group appearance for driven tabs; null disables grouping. */ #group: { title: string; color: string } | null; /** Tabs awaiting the next group RPC; drained one batch at a time. */ #groupQueue: TabState[] = []; /** True while {@link #drainGroupQueue} runs — group RPCs must never overlap. */ #groupDraining = false; /** Each tab switch's last transition, by `tabKey method`; see {@link #serializeSwitches}. */ #switchTransitions = new Map>(); /** Reads the app in front of the user; set by {@link keepFrontAppWith} where the relay can put it back (macOS). */ #readFrontApp: (() => FrontApp | null) | undefined; /** * When the running hold on the user's app ends (`Date.now()` ms), at its deadline or once it settles, whichever is * first; 0 with none. A window opened meanwhile shares it, and omp's input reads no front app then: the browser may * be in front, and a later hold would keep it there. */ #keepingFrontUntil = 0; constructor( opts: { log?: (message: string, data?: Record) => void; /** Group tabs the agent actively drives under one per-window Chrome tab group. */ group?: { title: string; color: string } | null; /** Enables extension self-update; absent, the relay never rewrites or reloads an extension. */ updateExtension?: RelayBridgeExtensionUpdater; } = {}, ) { this.#log = opts.log ?? (() => {}); this.#group = opts.group ?? null; this.#updateExtension = opts.updateExtension ?? null; } /** True once an extension has completed its hello handshake, and the relay is not rewriting its files. */ get ready(): boolean { for (const inst of this.#instances.values()) { if (inst.socket && inst.info && !inst.updating) return true; } return false; } /** The instance whose hello ran last (browser-wide requests route there). */ #lastHello(): ExtInstance | undefined { if (this.#lastHelloInstance) { const inst = this.#instances.get(this.#lastHelloInstance); if (inst?.socket) return inst; } for (const inst of this.#instances.values()) { if (inst.socket) return inst; } return undefined; } /** * The browser the caller's tabs live in: the one named by `nearTargetId` (a tab the caller drives on another * connection; omp gives every tab its own), else one holding a tab this connection drives. Offline or not. */ #affineInstance(conn: CdpConnection, nearTargetId: unknown): ExtInstance | undefined { const near = typeof nearTargetId === "string" ? parseTargetId(nearTargetId) : null; if (near) { // The target id names its browser even after the user closed that tab. const code = near.key.slice(0, near.key.indexOf(":")); const inst = [...this.#instances.values()].find(inst => inst.code === code); if (inst) return inst; } const own = [...conn.claims].map(key => this.#tabs.get(key)).find(tab => tab !== undefined); return own && this.#instances.get(own.instanceId); } /** Wait for an offline browser's hello, up to {@link AFFINE_RECONNECT_WAIT_MS} after its socket closed. */ async #awaitReconnect(inst: ExtInstance): Promise { if (inst.socket || inst.goneSince === null) return; const left = inst.goneSince + AFFINE_RECONNECT_WAIT_MS - Date.now(); if (left <= 0) return; inst.reconnected ??= Promise.withResolvers(); const timeout = Promise.withResolvers(); const timer = setTimeout(timeout.resolve, left); try { await Promise.race([inst.reconnected.promise, timeout.promise]); } finally { clearTimeout(timer); } } /** * The browser a new tab opens in: the caller's ({@link #affineInstance}), else the one showing the first selected * tab (the tab an open without a target used to adopt), else the last hello, so a reconnecting extension's hello * does not move new tabs to another browser. */ #instanceForNewTab(affine: ExtInstance | undefined): ExtInstance | undefined { if (affine?.socket) return affine; for (const tab of this.#tabs.values()) { if (!tab.active || tab.discarded || !this.#eligible(tab)) continue; const inst = this.#instances.get(tab.instanceId); if (inst?.socket) return inst; } return this.#lastHello(); } /** True after the first hello, and stays true: separates a reaped service worker from an absent extension. */ get extensionSeen(): boolean { return this.#extensionSeen; } /** Milliseconds since the last ready extension disconnected, or null while one is connected or none ever was. */ get extensionGoneForMs(): number | null { return this.#extensionGoneSince === null ? null : Date.now() - this.#extensionGoneSince; } /** Downstream CDP clients (omp browser sessions, puppeteer) connected right now. */ get cdpClientCount(): number { return this.#conns.size; } /** Payload for `GET /json/version`. */ versionInfo(wsUrl: string): Record { const info = this.#lastHello()?.info; let hasCompatibleExtension = false; // The oldest connected extension's installing omp, so a current browser cannot hide a stale one. // A stamp of unknown age yields to any other, so it cannot hide one either. let oldestExtensionOmp: string | undefined; for (const instance of this.#instances.values()) { if (!instance.socket || instance.info?.discardedTabsProtocol !== DISCARDED_TABS_PROTOCOL_VERSION) continue; // Its files are being rewritten; it says which version it runs once it is back. if (instance.updating) continue; hasCompatibleExtension = true; const ompVersion = instance.info.ompVersion ?? ""; if ( oldestExtensionOmp === undefined || isOlderOmpVersion(ompVersion, oldestExtensionOmp) || (oldestExtensionOmp !== "" && !isOmpVersion(oldestExtensionOmp)) ) { oldestExtensionOmp = ompVersion; } } const ua = info?.userAgent ?? ""; return { Browser: info?.browserVersion ?? "Chrome/unknown", "Protocol-Version": "1.3", "User-Agent": ua, "V8-Version": "", "WebKit-Version": "", webSocketDebuggerUrl: wsUrl, ompRelayVersion: VERSION, ompRelayDiscardedTabsProtocol: String(DISCARDED_TABS_PROTOCOL_VERSION), ompExtensionDiscardedTabsProtocol: String(hasCompatibleExtension ? DISCARDED_TABS_PROTOCOL_VERSION : 0), ompRelayCdpClients: this.#conns.size, ompExtensionVersion: oldestExtensionOmp ?? "", }; } /** Payload for `GET /json/list` (debugging aid; per-target endpoints are not served). */ listTargets(): Array> { const out: Array> = []; for (const tab of this.#tabs.values()) { if (!this.#eligible(tab)) continue; out.push({ id: pageTargetIdFromKey(tab.tabKey), type: "page", title: tab.title, url: tab.url, active: String(tab.active), discarded: String(tab.discarded), }); } return out; } // ---- extension lifecycle ------------------------------------------------- #rejectPendingExtensionRpcs(instanceId: string, error: Error): void { const prefix = `${instanceId}:`; for (const [key, pending] of this.#pendingRpc) { if (!key.startsWith(prefix)) continue; clearTimeout(pending.timer); pending.reject(error); this.#pendingRpc.delete(key); } } /** * A new extension socket connected; its hello assigns (or reuses) a browser instance. * `extensionId` is the Chrome extension id its upgrade's `Origin` named, if any. */ extConnected(socket: RelaySocket, extensionId?: string): void { if (extensionId) this.#socketExtensionId.set(socket, extensionId); this.#log("extension socket connected"); } extClosed(socket: RelaySocket): void { this.#socketExtensionId.delete(socket); const instanceId = this.#socketInstance.get(socket); if (instanceId === undefined) return; this.#socketInstance.delete(socket); const inst = this.#instances.get(instanceId); if (!inst || inst.socket !== socket) return; inst.socket = null; inst.goneSince = Date.now(); inst.updating = false; this.#rejectPendingExtensionRpcs(instanceId, new Error("relay extension disconnected")); // Keep the instance's tabs listed (the browser may reconnect), but drop // debugger state: the attachment died with the service worker. for (const tab of this.#tabs.values()) { if (tab.instanceId !== instanceId) continue; tab.attached = false; tab.attaching = null; this.#resetRuntime(tab); // The extension dissolves omp groups on disconnect (or died along // with them); grouping state is unknowable until the next hello. tab.grouped = false; tab.grouping = false; tab.ompGroupId = undefined; } this.#groupQueue = this.#groupQueue.filter(tab => tab.instanceId !== instanceId); if (!this.ready) this.#extensionGoneSince ??= Date.now(); } extMessage(socket: RelaySocket, raw: string): void { let msg: ExtToRelayMessage; try { msg = JSON.parse(raw) as ExtToRelayMessage; } catch { this.#log("dropping malformed extension message"); return; } if (msg.t === "hello") { this.#onHello(socket, msg); return; } const instanceId = this.#socketInstance.get(socket); if (instanceId === undefined) return; const inst = this.#instances.get(instanceId); if (!inst || inst.socket !== socket) return; switch (msg.t) { case "rpcResult": { const key = `${instanceId}:${msg.id}`; const pending = this.#pendingRpc.get(key); if (!pending) { const late = this.#lateRpc.get(key); if (!late) return; this.#lateRpc.delete(key); clearTimeout(late.timer); if (msg.ok) late.onResult(msg.result); if (this.#conns.size === 0) void this.#updateIdleExtensions(); return; } this.#pendingRpc.delete(key); clearTimeout(pending.timer); if (msg.ok) pending.resolve(msg.result); else pending.reject(new Error(msg.error ?? "extension rpc failed")); return; } case "cdpEvent": this.#onCdpEvent(tabKeyOf(inst.code, msg.tabId), msg.sessionId, msg.method, msg.params); return; case "detached": this.#onTabDetached(tabKeyOf(inst.code, msg.tabId), msg.reason, msg.relayInitiated === true); return; case "tabCreated": this.#onTabUpsert(msg.tab, instanceId); return; case "tabUpdated": this.#onTabUpsert(msg.tab, instanceId); return; case "tabRemoved": this.#onTabRemoved(tabKeyOf(inst.code, msg.tabId)); return; case "ping": socket.send(JSON.stringify({ t: "pong" } satisfies RelayToExtMessage)); return; } } #onHello(socket: RelaySocket, msg: Extract): void { // Hellos without an instance id (legacy extension builds) share one anon // instance and keep the historical latest-wins replacement semantics. const instanceId = typeof msg.instanceId === "string" && msg.instanceId.length > 0 ? msg.instanceId : "anon"; const code = instanceCode(instanceId, typeof msg.browserSession === "string" ? msg.browserSession : ""); let inst = this.#instances.get(instanceId); if (!inst) { inst = { instanceId, code, socket: null, info: null, goneSince: null, reconnected: null, extensionId: null, updating: false, }; this.#instances.set(instanceId, inst); } else if (inst.socket && inst.socket !== socket) { // Same browser reconnected (service-worker restart): retire the old // socket. Debugger-derived state dies with the old attachment, so // reset runtime state exactly like the old single-slot replace did; // the hello's attachedTabIds reconciliation runs right after. for (const tab of this.#tabs.values()) { if (tab.instanceId === instanceId) this.#resetRuntime(tab); } this.#rejectPendingExtensionRpcs(instanceId, new ExtensionReplacedError()); this.#socketInstance.delete(inst.socket); inst.socket.close(); } if (inst.code !== code) { // The browser restarted, and Chrome reuses tab ids: retire the previous run's targets, and the closes // queued for them, so an old target id cannot name a tab the user opened since. for (const [key, tab] of this.#tabs) { if (tab.instanceId === instanceId) this.#onTabRemoved(key); } for (const key of this.#closeOnHello.keys()) { if (key.startsWith(`${inst.code}:`)) this.#closeOnHello.delete(key); } inst.code = code; } inst.socket = socket; inst.goneSince = null; inst.reconnected?.resolve(); inst.reconnected = null; this.#socketInstance.set(socket, instanceId); inst.extensionId = this.#socketExtensionId.get(socket) ?? null; inst.updating = false; inst.info = { userAgent: msg.userAgent, browserVersion: msg.browserVersion, discardedTabsProtocol: msg.discardedTabsProtocol, ompVersion: typeof msg.ompVersion === "string" ? msg.ompVersion : undefined, reloadProtocol: msg.reloadProtocol, downloadsProtocol: msg.downloadsProtocol, }; this.#lastHelloInstance = instanceId; this.#extensionSeen = true; this.#extensionGoneSince = null; // The hello GC is scoped to this instance: another browser's tabs are // untouched, which is what lets Chrome and Edge share one relay. const seen = new Set(); const attachedNow = new Set(msg.attachedTabIds); const known = new Set(); for (const tab of this.#tabs.values()) if (tab.instanceId === instanceId) known.add(tab.tabKey); for (const snap of msg.tabs) { seen.add(snap.tabId); this.#onTabUpsert(snap, instanceId, { silent: true }); } for (const [key, tab] of this.#tabs) { if (tab.instanceId !== instanceId || seen.has(tab.tabId)) continue; this.#onTabRemoved(key); } // Close the tabs whose close failed while this browser was offline, unless the user closed them meanwhile or // the id now names another page. for (const [key, url] of this.#closeOnHello) { if (!key.startsWith(`${inst.code}:`)) continue; this.#closeOnHello.delete(key); const closed = this.#tabs.get(key); if (closed?.url === url) void this.#closeTab(key, closed.tabId, inst).catch(() => undefined); } for (const tab of this.#tabs.values()) { if (tab.instanceId !== instanceId) continue; const wasAttached = tab.attached; tab.attached = attachedNow.has(tab.tabId); tab.attaching = null; tab.cancelAttach = null; tab.attachCancelled = false; if (!tab.attached) this.#forgetChildren(tab); if (tab.discarded) { // Retire held sessions now; revival will reannounce and attach the tab. this.#retractTab(tab); this.#detachIfUnheld(tab.tabKey); continue; } // An attachment that outlived the extension's socket kept its switches, including any a release could not // turn off while the socket was down, and one this bridge first hears of (the relay restarted under a live // extension) may hold any switch a vanished client left on: bring those to what live sessions want now. if (!known.has(tab.tabKey)) for (const method of TAB_WIDE_SWITCHES) tab.unsettledSwitches.add(method); const unsettled = [...tab.unsettledSwitches]; tab.unsettledSwitches.clear(); if (tab.attached) { for (const method of unsettled) this.#sendSwitch(tab.tabKey, method, this.#switchWanted(tab.tabKey, method)); } // A service-worker restart can drop attachments while downstream // connections still hold sessions: restore them best-effort. if (wasAttached && !tab.attached && this.#sessionHolders(tab.tabKey).length > 0) { void this.#ensureAttached(tab).then(ok => { if (!ok) this.#onTabDetached(tab.tabKey, "reattach_failed", false); // The lost attachment took its switches with it. else { for (const method of TAB_WIDE_SWITCHES) { if (this.#switchWanted(tab.tabKey, method)) this.#sendSwitch(tab.tabKey, method, true); } } }); } // A tab the hello brings that no client was told about, such as one a browser opened before its // extension connected, is announced now: no later change has to happen for Puppeteer to see it. if (!tab.announced && this.#eligible(tab)) this.#announceTab(tab); } this.#syncGrouping(); this.#log("extension connected", { instanceId, tabs: msg.tabs.length, version: msg.browserVersion, omp: inst.info.ompVersion, }); this.#maybeUpdateExtension(inst); } // ---- extension self-update ------------------------------------------------ /** * The older omp version this extension runs, when the relay may replace it: the extension reloads on request, * its script carries an omp version older than this relay's, its socket named its Chrome id, and the relay has * not already reloaded it from that version (a reload that brings the same version back must not loop). */ #outdatedReloadable(inst: ExtInstance): string | null { const ompVersion = inst.info?.ompVersion ?? ""; if (inst.info?.reloadProtocol !== EXTENSION_RELOAD_PROTOCOL_VERSION || !inst.extensionId) return null; if (!isOmpVersion(ompVersion) || !isOlderOmpVersion(ompVersion, VERSION)) return null; return this.#updateTried.get(inst.instanceId)?.has(ompVersion) ? null : ompVersion; } /** * Why reloading this extension now would cost someone something, or null. A reload drops the extension's * socket and every debugger attachment it holds, so it waits for no CDP client to be connected (no claimed * tab, no held session) and none of the extension's tabs to be attached, attaching or detaching. It also waits * out the extension's calls in flight and the tab closes queued for its next hello: the reloaded extension * reports a new browser session, which retires the target ids they name, so a tab omp opened would stay open. */ #reloadBlocker(inst: ExtInstance): string | null { if (!inst.socket) return "disconnected"; if (this.#conns.size > 0) return "CDP clients connected"; for (const tab of this.#tabs.values()) { if (tab.instanceId === inst.instanceId && (tab.attached || tab.attaching || tab.detaching)) { return "tabs attached"; } } // A call past its timeout whose late result still closes what it made (a tab created after omp gave up) counts. for (const key of [...this.#pendingRpc.keys(), ...this.#lateRpc.keys()]) { if (key.startsWith(`${inst.instanceId}:`)) return "extension calls pending"; } for (const key of this.#closeOnHello.keys()) { if (key.startsWith(`${inst.code}:`)) return "tab closes queued"; } return null; } /** Rewrite an outdated extension's files to this omp's and ask it to reload, when that costs nothing. */ #maybeUpdateExtension(inst: ExtInstance): void { const updateExtension = this.#updateExtension; if (!updateExtension || inst.updating) return; const from = this.#outdatedReloadable(inst); if (from === null) return; const busy = this.#reloadBlocker(inst); if (busy !== null) { this.#log("extension update deferred", { instanceId: inst.instanceId, from, reason: busy }); return; } const socket = inst.socket!; // Not ready while the files change: a client waits for the reloaded extension instead of connecting. inst.updating = true; void updateExtension(inst.extensionId!) .catch(err => { this.#log("extension update failed", { error: err instanceof Error ? err.message : String(err) }); return false; }) .then(updated => { if (inst.socket !== socket || !inst.updating) return; // A client may have connected or a tab attached while the files were written. const busy = updated ? this.#reloadBlocker(inst) : "files not rewritten"; if (busy !== null) { inst.updating = false; this.#log("extension update stopped", { instanceId: inst.instanceId, from, reason: busy }); return; } const tried = this.#updateTried.get(inst.instanceId) ?? new Set(); tried.add(from); this.#updateTried.set(inst.instanceId, tried); socket.send(JSON.stringify({ t: "reload" } satisfies RelayToExtMessage)); this.#log("extension reloading into update", { instanceId: inst.instanceId, from, to: VERSION }); // Retire the socket now, as if the reload had closed it: nothing new may reach a browser that is // about to drop its socket and attachments. An extension that ignored the request redials. socket.close(); this.extClosed(socket); }); } /** * With no CDP client left, once every tab's attach and detach has settled (their cleanup included, which can start * another detach): update the extensions that were kept busy. An attach left running after its tab was discarded * is not awaited twice; its own RPC retries the update when it settles. */ async #updateIdleExtensions(): Promise { const awaited = new Set>(); for (;;) { const busy = Array.from(this.#tabs.values(), tab => tab.attaching ?? tab.detaching).filter( (p): p is Promise | Promise => p !== null && !awaited.has(p), ); if (busy.length === 0) break; for (const p of busy) awaited.add(p); await Promise.all(busy); } if (this.#conns.size > 0) return; for (const inst of this.#instances.values()) this.#maybeUpdateExtension(inst); } // ---- downstream (puppeteer) lifecycle ------------------------------------- /** * Register a downstream CDP websocket; returns the connection id. `claim` is the target the connecting omp tab * worker drives (`/cdp?claim=`), claimed before puppeteer sends anything to it. */ cdpConnected(socket: RelaySocket, claim?: string): number { const conn = new CdpConnection(++this.#connSeq, socket); this.#conns.set(conn.id, conn); this.#log("cdp client connected", { conn: conn.id }); const parsed = claim ? parseTargetId(claim) : null; const tab = parsed ? this.#tabs.get(parsed.key) : undefined; if (tab) { // Chrome refused the tab over another extension's frame; claimed, the extension may now empty it. Cleared // before the claim, which groups only an eligible tab. const foreignFrameRefusal = tab.refusal !== undefined && FOREIGN_FRAME_REFUSAL.test(tab.refusal); if (foreignFrameRefusal) tab.refusal = undefined; this.#claimTab(conn, tab.tabKey); if (foreignFrameRefusal && !tab.announced && this.#eligible(tab) && !tab.discarded) this.#announceTab(tab); } return conn.id; } cdpClosed(connId: number): void { const conn = this.#conns.get(connId); if (!conn) return; this.#conns.delete(connId); const touched = new Set(); for (const ref of conn.sessions.values()) touched.add(ref.tabKey); const departed = [...conn.sessions.values()]; conn.sessions.clear(); for (const ref of departed) this.#releaseSwitches(ref); // Tabs this client claimed leave the omp group unless another claimant // remains — session holders don't count: the long-lived registry // connection holds sessions on every tab without driving any of them. for (const tabId of conn.claims) { const tab = this.#tabs.get(tabId); if (tab) this.#syncTabGrouping(tab); } conn.claims.clear(); // Drop the debugger (and its infobar) from tabs nobody drives anymore. for (const tabKey of touched) this.#detachIfUnheld(tabKey); this.#log("cdp client closed", { conn: connId }); if (this.#conns.size === 0) void this.#updateIdleExtensions(); } cdpMessage(connId: number, raw: string): void { const conn = this.#conns.get(connId); if (!conn) return; let msg: CdpCommand; try { msg = JSON.parse(raw) as CdpCommand; } catch { return; } if (typeof msg.id !== "number" || typeof msg.method !== "string") return; void this.#handleCdpCommand(conn, msg).catch(err => { this.#replyError(conn, msg, err instanceof Error ? err.message : String(err)); }); } // ---- command routing ------------------------------------------------------- async #handleCdpCommand(conn: CdpConnection, msg: CdpCommand): Promise { const sessionId = msg.sessionId; if (!sessionId) { await this.#handleBrowserCommand(conn, msg); return; } const ref = conn.sessions.get(sessionId); if (ref?.kind === "tab") { this.#handleTabSessionCommand(conn, msg, ref); return; } if (ref?.kind === "page") { await this.#handlePageSessionCommand(conn, msg, sessionId, ref); return; } const realTabKey = this.#realSessionTabs.get(sessionId); if (realTabKey !== undefined) { const announce = msg.method === "Target.setAutoAttach" && msg.params?.autoAttach === true && conn.childParents.has(sessionId) ? () => this.#announceChildren(conn, realTabKey, sessionId, sessionId) : undefined; await this.#forwardToTab(conn, msg, realTabKey, sessionId, announce); return; } this.#replyError(conn, msg, `Unknown session id ${sessionId}`); } async #handlePageSessionCommand( conn: CdpConnection, msg: CdpCommand, sessionId: string, ref: SessionRef, ): Promise { if (msg.method === "Runtime.disable") { ref.runtimeState = "disabled"; ref.runtimeEpoch++; ref.runtimeContexts.clear(); // Abandon any in-flight enable's ownership: a later enable starts fresh // rather than joining a cycle that predates this disable. ref.runtimeEnabling = null; this.#reply(conn, msg, {}); return; } if (msg.method !== "Runtime.enable") { if (msg.method === "Target.setAutoAttach") { // Armed while in flight (Chrome reports existing children before it // answers); a failed request restores the previous state. const previous = ref.autoAttach; ref.autoAttach = msg.params?.autoAttach === true; const announce = () => { const parent = conn.childTargetSessionForTab(ref.tabKey); if (parent && conn.sessions.get(sessionId) === ref) this.#announceChildren(conn, ref.tabKey, undefined, parent); }; if (!(await this.#forwardToTab(conn, msg, ref.tabKey, undefined, ref.autoAttach ? announce : undefined))) { ref.autoAttach = previous; } return; } if (isTabWideSwitch(msg.method)) { // Every session of a tab shares one debugger attachment, so the switch is tab-wide here: one session // switching it off must not undo another's (a pending chooser wait, a background tab's input). const method = msg.method; await this.#serializeSwitches(ref.tabKey, method, async () => { const previous = ref.switches.has(method); if (msg.params?.enabled === true) ref.switches.add(method); else ref.switches.delete(method); const params = { ...msg.params, enabled: this.#switchWanted(ref.tabKey, method) }; if (!(await this.#forwardToTab(conn, { ...msg, params }, ref.tabKey, undefined))) { if (previous) ref.switches.add(method); else ref.switches.delete(method); // Chrome may have applied it before the reply was lost with the socket. this.#tabs.get(ref.tabKey)?.unsettledSwitches.add(method); } }); return; } await this.#forwardToTab(conn, msg, ref.tabKey, undefined); return; } // A pipelined duplicate must await the in-flight enable, never ack early: // the root cycle may still fail, and success must trail the context replay. if (ref.runtimeEnabling) { await this.#awaitEnable(conn, msg, ref.runtimeEnabling); return; } if (ref.runtimeState === "enabled") { this.#reply(conn, msg, {}); return; } const enabling = this.#enableSessionRuntime(conn, sessionId, ref); ref.runtimeEnabling = enabling; try { await this.#awaitEnable(conn, msg, enabling); } finally { if (ref.runtimeEnabling === enabling) ref.runtimeEnabling = null; } } /** Reply to one `Runtime.enable` command with the shared enable's outcome. */ async #awaitEnable(conn: CdpConnection, msg: CdpCommand, enabling: Promise): Promise { try { await enabling; this.#reply(conn, msg, {}); } catch (err) { this.#replyError(conn, msg, err instanceof Error ? err.message : String(err)); } } /** * Drive the shared root `Runtime.enable` for a session and replay the live * contexts to it. Rejects if the root cycle fails so every joined caller * observes the failure instead of a spurious success. */ async #enableSessionRuntime(conn: CdpConnection, sessionId: string, ref: SessionRef): Promise { const prev = ref.runtimeState; const epoch = ++ref.runtimeEpoch; ref.runtimeState = "enabled"; const tab = this.#tabs.get(ref.tabKey); if (!tab) { ref.runtimeState = prev; throw new Error(`No tab ${ref.tabKey}`); } try { await this.#ensureRuntimeEnabled(tab); // A disable or newer enable may have taken ownership while the root // RPC was in flight; only the latest enable may replay or roll back. if (conn.sessions.get(sessionId) === ref && ref.runtimeEpoch === epoch && ref.runtimeState === "enabled") { this.#replayRuntimeContexts(conn, sessionId, ref, tab); } } catch (err) { if (ref.runtimeEpoch === epoch) { ref.runtimeState = prev; ref.runtimeContexts.clear(); } throw err; } } async #ensureRuntimeEnabled(tab: TabState): Promise { if (tab.rootRuntimeEnabled) return; if (tab.rootRuntimeEnabling) return await tab.rootRuntimeEnabling; const enabling = this.#cycleRuntime(tab); tab.rootRuntimeEnabling = enabling; const generation = tab.runtimeGeneration; try { await enabling; if (tab.runtimeGeneration === generation) tab.rootRuntimeEnabled = true; } finally { if (tab.rootRuntimeEnabling === enabling) tab.rootRuntimeEnabling = null; } } async #cycleRuntime(tab: TabState): Promise { const inst = this.#instanceFor(tab); await this.#send(tab, inst, "Runtime.disable"); await this.#send(tab, inst, "Runtime.enable"); } /** * Run one command on the tab's debugger attachment. One whose answer waits on the page itself (a promise it * settles, a navigation it commits) gets {@link PAGE_BOUND_RPC_TIMEOUT_MS}; any other gets {@link RPC_TIMEOUT_MS}. */ async #send( tab: TabState, inst: ExtInstance, method: string, params?: Record, sessionId?: string, ): Promise { const pageBound = params?.awaitPromise === true || method === "Runtime.awaitPromise" || method === "Page.navigate"; const timeoutMs = pageBound ? PAGE_BOUND_RPC_TIMEOUT_MS : RPC_TIMEOUT_MS; try { return await this.#rpc({ op: "send", tabId: tab.tabId, sessionId, method, params }, inst, timeoutMs); } catch (err) { if (!(err instanceof RpcTimeoutError)) throw err; throw new Error(`${method} on tab ${tab.tabId}: Chrome sent no answer within ${timeoutMs}ms`, { cause: err }); } } #replayRuntimeContexts(conn: CdpConnection, sessionId: string, ref: SessionRef, tab: TabState): void { for (const [contextId, params] of tab.runtimeContexts) { if (ref.runtimeContexts.has(contextId)) continue; ref.runtimeContexts.add(contextId); conn.socket.send(JSON.stringify({ sessionId, method: "Runtime.executionContextCreated", params })); } } /** * Chrome reports a child target once, to the first auto-attach armed over it, so a connection * arming later (a tab worker after the registry connection) is told here of the children already * reported under `parent` (the tab's root when undefined) that it has not heard of. */ #announceChildren(conn: CdpConnection, tabKey: string, parent: string | undefined, sessionId: string): void { const tab = this.#tabs.get(tabKey); if (!tab) return; for (const [child, known] of tab.realSessions) { if (known.parent !== parent || conn.childParents.has(child)) continue; conn.childParents.set(child, sessionId); conn.socket.send(JSON.stringify({ sessionId, method: "Target.attachedToTarget", params: known.event })); } } /** * Forward a command to the tab and reply to it; resolves whether the command succeeded. * `beforeReply` runs on success ahead of the reply. Target lifecycle commands are answered by the * relay's browser handlers instead, which reply (or throw to `cdpMessage`) themselves: they resolve `true`. */ async #forwardToTab( conn: CdpConnection, msg: CdpCommand, tabKey: string, realSessionId: string | undefined, beforeReply?: () => void, ): Promise { // Guard rail: a page session must never take the whole browser down. if (msg.method === "Browser.close") { this.#reply(conn, msg, {}); return true; } // Relay-private claim the omp tab worker sends on the page it adopted. A worker of this version claims at // connect already (`/cdp?claim=`); one of an older version, sharing this relay, claims only here. Never // forwarded — real Chrome rejects the unknown method. if (msg.method === "OMP.claimTarget") { this.#claimTab(conn, tabKey); this.#reply(conn, msg, {}); return true; } const tab = this.#tabs.get(tabKey); if (!tab) { this.#replyError(conn, msg, `No tab with key ${tabKey}`); return false; } const inst = this.#instances.get(tab.instanceId); if (!inst || !inst.socket) { this.#replyError(conn, msg, "relay extension is not connected"); return false; } // Relay-private: where Chrome saved a download the page reported. A page session's download events carry no // path, and Chrome refuses an extension's `setDownloadBehavior`, so the extension looks the file up. if (msg.method === "OMP.downloadPath") return await this.#replyDownloadPath(conn, msg, inst); // Sent raw, createTarget honors `newWindow` and answers with Chrome's id, which no relay command // resolves; close and activate are refused ("Not allowed"). Run them in this tab's browser, where a // new tab lands like every relay-created tab: in that browser's current window. if ( msg.method === "Target.createTarget" || msg.method === "Target.closeTarget" || msg.method === "Target.activateTarget" ) { await this.#handleBrowserCommand(conn, msg, tab); return true; } if (this.#readFrontApp && canOpenWindow(msg.method, msg.params)) { tab.frontAtInput = Date.now() < this.#keepingFrontUntil ? null : this.#readFrontAtInput(this.#readFrontApp); } try { const result = await this.#send(tab, inst, msg.method, msg.params, realSessionId); beforeReply?.(); this.#reply(conn, msg, (result as Record | undefined) ?? {}); return true; } catch (err) { const message = err instanceof Error ? err.message : String(err); // Chrome fails the root session's command in flight before it reports the detach. Hold the failure // until the detach is reported, so the client gets `OMP.debuggerDetached` first and knows why. if (realSessionId === undefined && tab.attached && DETACHED_COMMAND_ERROR.test(message)) { await Promise.race([tab.chromeDetach.promise, Bun.sleep(CHROME_DETACH_REPORT_MS)]); } this.#replyError(conn, msg, message); return false; } } /** * Read the app in front of the user with `read` as omp's input reaches a tab, to put it back when that input opens a * window and Chrome brings itself forward to show it. Only where the relay can put it back (macOS). */ keepFrontAppWith(read: () => FrontApp | null): void { this.#readFrontApp = read; } /** * The app in front of the user now, for omp's input to a tab. A read that throws (a stale native addon without * `macOSFrontApp`) turns the reads off for good, so the input itself still goes out. */ #readFrontAtInput(read: () => FrontApp | null): { app: FrontApp; at: number } | null { try { const app = read(); return app && { app, at: Date.now() }; } catch (err) { this.#readFrontApp = undefined; this.#log("could not read the app in front; not holding it in front", { error: err instanceof Error ? err.message : String(err), }); return null; } } /** * Hold the app that was in front at omp's last input to `tab`, if that input can have opened the window the tab's * page is opening: Chrome makes itself the active app to show it. */ #keepFrontApp(tab: TabState): void { const front = tab.frontAtInput; tab.frontAtInput = null; if (!front || Date.now() < this.#keepingFrontUntil || Date.now() - front.at > POPUP_AFTER_INPUT_MS) return; const until = Date.now() + KEEP_FRONT_MS; this.#keepingFrontUntil = until; front.app.keepInFront(KEEP_FRONT_MS).then( times => { if (times > 0) this.#log("put the user's app back in front", { times }); if (this.#keepingFrontUntil === until) this.#keepingFrontUntil = 0; }, err => { this.#log("could not keep the user's app in front", { error: err instanceof Error ? err.message : String(err), }); }, ); } /** * Record `conn` as a driver of the tab and reconcile grouping. Claims are * explicit (worker adoption or tab creation) rather than inferred from * command traffic: target discovery scans every page with the same * commands a driver sends, so inference would sweep all tabs. */ #claimTab(conn: CdpConnection, tabKey: string): void { const tab = this.#tabs.get(tabKey); if (!tab) return; if (!conn.claims.has(tabKey)) { conn.claims.add(tabKey); this.#log("tab claimed", { conn: conn.id, tabKey }); } this.#syncTabGrouping(tab); } /** True while any downstream connection claims the tab as its drive target. */ #claimed(tabKey: string): boolean { for (const conn of this.#conns.values()) { if (conn.claims.has(tabKey)) return true; } return false; } /** Tab pseudo-sessions only exist to satisfy puppeteer's Target hierarchy. */ #handleTabSessionCommand(conn: CdpConnection, msg: CdpCommand, ref: SessionRef): void { switch (msg.method) { case "Target.setAutoAttach": { const tab = this.#tabs.get(ref.tabKey); if (!tab) { this.#replyError(conn, msg, `Tab ${ref.tabKey} is gone`); return; } // Emit before replying: puppeteer's TargetManager counts page // children attached before the setAutoAttach response resolves. const pageSession = this.#mintSession(conn, "page", tab); this.#emit( conn, "Target.attachedToTarget", { sessionId: pageSession, targetInfo: this.#pageInfo(tab, true), waitingForDebugger: false, }, msg.sessionId, ); this.#reply(conn, msg, {}); return; } case "Runtime.runIfWaitingForDebugger": this.#reply(conn, msg, {}); return; case "Target.detachFromTarget": { const child = typeof msg.params?.sessionId === "string" ? msg.params.sessionId : undefined; if (child) this.#releaseSession(conn, child, msg.sessionId); this.#reply(conn, msg, {}); return; } default: this.#replyError(conn, msg, `'${msg.method}' is not supported on a tab target`, CDP_ERROR_METHOD_NOT_FOUND); } } /** `near`: the tab whose session sent the command; a tab it creates opens in that tab's browser. */ async #handleBrowserCommand(conn: CdpConnection, msg: CdpCommand, near?: TabState): Promise { switch (msg.method) { case "Browser.getVersion": { this.#reply(conn, msg, { protocolVersion: "1.3", product: this.#lastHello()?.info?.browserVersion ?? "Chrome/unknown", revision: "", userAgent: this.#lastHello()?.info?.userAgent ?? "", jsVersion: "", }); return; } case "Target.getBrowserContexts": this.#reply(conn, msg, { browserContextIds: [] }); return; case "Target.getTargets": { const targetInfos: TargetInfo[] = []; for (const tab of this.#tabs.values()) { if (this.#eligible(tab)) targetInfos.push(this.#pageInfo(tab, tab.attached)); } this.#reply(conn, msg, { targetInfos }); return; } case "Target.setDiscoverTargets": { conn.discover = true; for (const tab of this.#tabs.values()) { if (!this.#eligible(tab)) continue; tab.announced = true; this.#emit(conn, "Target.targetCreated", { targetInfo: this.#tabInfo(tab, tab.attached) }); this.#emit(conn, "Target.targetCreated", { targetInfo: this.#pageInfo(tab, tab.attached) }); } this.#reply(conn, msg, {}); return; } case "Target.setAutoAttach": { conn.autoAttach = true; // Discarded tabs can stall debugger calls. Retract targets already // announced by Target.setDiscoverTargets so Puppeteer can connect(). const tabs = [...this.#tabs.values()].filter(tab => this.#eligible(tab) && !tab.discarded); await Promise.all(tabs.map(tab => this.#ensureAttached(tab, { retryAfterDiscard: true }))); for (const tab of tabs) { if (!tab.attached || tab.discarded || !this.#eligible(tab)) { // Failed or newly discarded tabs must not hold up connect(). this.#retractTab(tab); this.#detachIfUnheld(tab.tabKey); continue; } this.#emitTabAttached(conn, tab); } for (const tab of this.#tabs.values()) { if (tab.announced && tab.discarded && this.#eligible(tab)) { this.#retractTab(tab); this.#detachIfUnheld(tab.tabKey); } } this.#reply(conn, msg, {}); return; } case "Target.attachToTarget": { const parsed = typeof msg.params?.targetId === "string" ? parseTargetId(msg.params.targetId) : null; const tab = parsed ? this.#tabs.get(parsed.key) : undefined; if (!parsed || !tab) { this.#replyError(conn, msg, `No target with id ${String(msg.params?.targetId)}`); return; } if (tab.discarded) { this.#replyError(conn, msg, `Cannot attach to discarded tab ${tab.tabKey}`); return; } if (!(await this.#ensureAttached(tab))) { const reason = tab.refusal ? `: ${tab.refusal}` : ""; this.#replyError(conn, msg, `Cannot attach to tab ${tab.tabKey} (${tab.url})${reason}`); return; } const sessionId = this.#mintSession(conn, parsed.kind, tab); const info = parsed.kind === "tab" ? this.#tabInfo(tab, true) : this.#pageInfo(tab, true); this.#emit(conn, "Target.attachedToTarget", { sessionId, targetInfo: info, waitingForDebugger: false }); this.#reply(conn, msg, { sessionId }); return; } case "Target.detachFromTarget": { const sessionId = typeof msg.params?.sessionId === "string" ? msg.params.sessionId : undefined; if (sessionId) this.#releaseSession(conn, sessionId, undefined); this.#reply(conn, msg, {}); return; } case "Target.createTarget": { const url = typeof msg.params?.url === "string" && msg.params.url.length > 0 ? msg.params.url : "about:blank"; // `nearTargetId` is the relay's own parameter: open in the browser of that tab. const affine = near ? undefined : this.#affineInstance(conn, msg.params?.nearTargetId); // Its extension may be reconnecting: another browser can be another profile, logged in elsewhere. if (affine && !affine.socket) { await this.#awaitReconnect(affine); if (this.#conns.get(conn.id) !== conn) return; } const inst = near ? this.#instances.get(near.instanceId) : this.#instanceForNewTab(affine); if (!inst || !inst.socket) { this.#replyError(conn, msg, "relay extension is not connected"); return; } // CDP `background: true` creates the tab without selecting it; an extension that predates `active` ignores it. const active = msg.params?.background === true ? false : undefined; // Chrome can still create the tab after the RPC timed out: close it then, since nobody learns its id. const result = (await this.#rpc({ op: "createTab", url, active }, inst, RPC_TIMEOUT_MS, late => { const tabId = (late as { tab: TabSnapshot }).tab.tabId; this.#closeTab(tabKeyOf(inst.code, tabId), tabId, inst).catch(err => { this.#log("late relay tab close waits for the next hello", { tabId, error: err instanceof Error ? err.message : String(err), }); }); })) as { tab: TabSnapshot }; this.#onTabUpsert(result.tab, inst.instanceId); // The client left while Chrome created the tab (omp gave up on a slow open), so nobody // can learn its id to close it later: close it now instead of leaving it in the user's browser. if (this.#conns.get(conn.id) !== conn) { await this.#closeTab(tabKeyOf(inst.code, result.tab.tabId), result.tab.tabId, inst); return; } // Creating a tab is an explicit act of driving it. const createdKey = tabKeyOf(inst.code, result.tab.tabId); this.#claimTab(conn, createdKey); // Puppeteer waits for the new page's auto-attach. Answer once it settled: a tab Chrome refuses (an // extension debugger policy refuses every tab) would otherwise hold the open for its whole timeout. const created = this.#tabs.get(createdKey); if (conn.autoAttach && created && !(await this.#ensureAttached(created)) && created.refusal) { const refusal = created.refusal; await this.#closeTab(createdKey, created.tabId, inst).catch(() => undefined); this.#replyError(conn, msg, `Chrome refused to attach to the new tab: ${refusal}`); return; } this.#reply(conn, msg, { targetId: pageTargetIdFromKey(createdKey) }); return; } case "Target.closeTarget": { const parsed = typeof msg.params?.targetId === "string" ? parseTargetId(msg.params.targetId) : null; if (!parsed) { this.#replyError(conn, msg, `No target with id ${String(msg.params?.targetId)}`); return; } const removedTab = this.#tabs.get(parsed.key); if (!removedTab) { this.#replyError(conn, msg, `No target with id ${String(msg.params?.targetId)}`); return; } await this.#closeTab(parsed.key, removedTab.tabId, this.#instanceFor(removedTab)); this.#reply(conn, msg, { success: true }); return; } case "Target.activateTarget": { const parsed = typeof msg.params?.targetId === "string" ? parseTargetId(msg.params.targetId) : null; const activatedTab = parsed ? this.#tabs.get(parsed.key) : undefined; if (parsed && activatedTab) { await this.#rpc({ op: "activateTab", tabId: activatedTab.tabId }, this.#instanceFor(activatedTab)); } else if (near) { // Raw, a page session's activate failed; don't turn an unknown id into success there. this.#replyError(conn, msg, `No target with id ${String(msg.params?.targetId)}`); return; } this.#reply(conn, msg, {}); return; } case "Target.getTargetInfo": { const raw = typeof msg.params?.targetId === "string" ? msg.params.targetId : undefined; const parsed = raw ? parseTargetId(raw) : null; const tab = parsed ? this.#tabs.get(parsed.key) : undefined; if (parsed && tab) { const info = parsed.kind === "tab" ? this.#tabInfo(tab, tab.attached) : this.#pageInfo(tab, tab.attached); this.#reply(conn, msg, { targetInfo: info }); return; } this.#reply(conn, msg, { targetInfo: { targetId: "relay-browser", type: "browser", title: "", url: "", attached: true, canAccessOpener: false, } satisfies TargetInfo, }); return; } case "Browser.close": // Never close the user's browser; acknowledge and ignore. this.#log("refusing Browser.close from downstream client", { conn: conn.id }); this.#reply(conn, msg, {}); return; case "Browser.setDownloadBehavior": this.#reply(conn, msg, {}); return; case "Target.createBrowserContext": this.#replyError(conn, msg, "Browser contexts are not supported by the omp browser relay"); return; default: this.#replyError(conn, msg, `'${msg.method}' wasn't found`, CDP_ERROR_METHOD_NOT_FOUND); } } // ---- extension events ------------------------------------------------------- #onCdpEvent( tabKey: string, sourceSessionId: string | undefined, method: string, params?: Record, ): void { const tab = this.#tabs.get(tabKey); if (!tab) return; // The page (or a frame in it, on a child session) is opening a tab or window; Chrome brings itself forward // to show it. if (method === "Page.windowOpen") this.#keepFrontApp(tab); // Track real child sessions so downstream commands can route back. if (method === "Target.attachedToTarget") { const child = params?.sessionId; if (typeof child === "string" && params) { tab.realSessions.set(child, { parent: sourceSessionId, event: params }); this.#realSessionTabs.set(child, tabKey); } } else if (method === "Target.detachedFromTarget") { const child = params?.sessionId; if (typeof child === "string") { tab.realSessions.delete(child); this.#realSessionTabs.delete(child); } } if (sourceSessionId) { // Event from a real child session: pass through verbatim to every // connection that was told about the child, tracking nested children. const nested = typeof params?.sessionId === "string" ? params.sessionId : undefined; const payload = JSON.stringify({ sessionId: sourceSessionId, method, params }); for (const conn of this.#conns.values()) { if (!conn.childParents.has(sourceSessionId)) continue; conn.socket.send(payload); if (nested && method === "Target.attachedToTarget") conn.childParents.set(nested, sourceSessionId); else if (nested && method === "Target.detachedFromTarget") conn.childParents.delete(nested); } return; } if (method.startsWith("Runtime.")) { const createdContext = method === "Runtime.executionContextCreated" ? params?.context : undefined; const createdContextId = createdContext && typeof createdContext === "object" && "id" in createdContext && typeof createdContext.id === "number" ? createdContext.id : undefined; const destroyedContextId = method === "Runtime.executionContextDestroyed" && typeof params?.executionContextId === "number" ? params.executionContextId : undefined; if (createdContextId !== undefined && params) tab.runtimeContexts.set(createdContextId, params); if (destroyedContextId !== undefined) tab.runtimeContexts.delete(destroyedContextId); if (method === "Runtime.executionContextsCleared") tab.runtimeContexts.clear(); for (const conn of this.#conns.values()) { for (const [pageSession, ref] of conn.sessions) { if (ref.kind !== "page" || ref.tabKey !== tabKey) continue; if (destroyedContextId !== undefined) ref.runtimeContexts.delete(destroyedContextId); if (method === "Runtime.executionContextsCleared") ref.runtimeContexts.clear(); // `default` sessions never enabled Runtime but still get the // legacy fan-out; only an explicit `Runtime.disable` silences one. if (ref.runtimeState === "disabled") continue; if (createdContextId !== undefined) { if (ref.runtimeContexts.has(createdContextId)) continue; ref.runtimeContexts.add(createdContextId); } conn.socket.send(JSON.stringify({ sessionId: pageSession, method, params })); } } return; } // Other root-session events fan out once per minted page session. A real // child is announced once per connection, on a session that armed // auto-attach, and its detach goes to that same session. if (method === "Target.attachedToTarget" || method === "Target.detachedFromTarget") { const child = params?.sessionId; if (typeof child !== "string") return; for (const conn of this.#conns.values()) { let parent: string | undefined; if (method === "Target.attachedToTarget") { parent = conn.childTargetSessionForTab(tabKey); if (parent) conn.childParents.set(child, parent); } else { parent = conn.childParents.get(child); conn.childParents.delete(child); } if (parent) conn.socket.send(JSON.stringify({ sessionId: parent, method, params })); } return; } for (const conn of this.#conns.values()) { for (const pageSession of conn.sessionsForTab(tabKey, "page")) { conn.socket.send(JSON.stringify({ sessionId: pageSession, method, params })); } } } #onTabDetached(tabKey: string, reason: string, relayInitiated: boolean): void { const tab = this.#tabs.get(tabKey); if (!tab) return; // Explicit source attribution comes from the extension that executed // chrome.debugger.detach, so socket replacement cannot confuse this // with a user cancellation or mutate an unrelated attach promise. if (relayInitiated) { // A replacement hello can observe the old attachment before the // pending detach completes. Reconcile that stale snapshot unless a // later attach has already superseded this detach. if (!tab.reattachedAfterDetach) tab.attached = false; return; } this.#log("tab detached", { tabKey, reason }); tab.attached = false; tab.attaching = null; this.#resetRuntime(tab); // Only the user pressing Cancel on Chrome's debugging bar is a refusal. Chrome also detaches when // another extension's frame (a password manager's inline menu) starts loading in the tab: that tab // stays adoptable and is announced again at once; an attach Chrome refuses still bans it (#ensureAttached). const cancelledByUser = reason === "canceled_by_user"; if (cancelledByUser) tab.refusal = "the user cancelled Chrome's debugging bar"; // A user cancel releases the tab's omp-group membership; any other detach keeps it. this.#syncTabGrouping(tab); // Relay-private event, ahead of the sessions' detach: a client driving the tab learns that Chrome // ended the attachment, not the page, and can re-adopt it instead of waiting on a dead session. A tab // the relay is closing detaches on its way out (Chrome may report that before the removal): not re-adoptable. if (!tab.closing) { for (const conn of this.#conns.values()) { for (const pageSession of conn.sessionsForTab(tabKey, "page")) { this.#emit(conn, "OMP.debuggerDetached", { reason }, pageSession); } } } // Failures of commands that were in flight are released behind the event (#forwardToTab). tab.chromeDetach.resolve(); tab.chromeDetach = Promise.withResolvers(); this.#retractTab(tab); if (!cancelledByUser && !tab.closing && this.#eligible(tab) && !tab.discarded) this.#announceTab(tab); } #onTabRemoved(tabKey: string): void { const tab = this.#tabs.get(tabKey); if (!tab) return; this.#retractTab(tab); this.#tabs.delete(tabKey); for (const conn of this.#conns.values()) conn.claims.delete(tabKey); } #onTabUpsert(snap: TabSnapshot, instanceId: string, opts: { silent?: boolean } = {}): void { const inst = this.#instances.get(instanceId); if (!inst) return; const key = tabKeyOf(inst.code, snap.tabId); let tab = this.#tabs.get(key); const becameActive = snap.active && (!tab || !tab.active || tab.windowId !== snap.windowId); if (!tab) { tab = new TabState(instanceId, inst.code, snap.tabId, snap); this.#tabs.set(key, tab); } else { if (tab.url !== snap.url) tab.refusal = undefined; // The user dragging a tab out of the omp group is an opt-out; the // relay never fights the user over grouping. if (tab.grouped && tab.ompGroupId !== undefined && snap.groupId !== tab.ompGroupId) { tab.grouped = false; tab.groupOptOut = true; } tab.update(snap); if (tab.discarded && tab.attaching && !tab.attachCancelled) { tab.attachGeneration++; tab.attachCancelled = true; tab.cancelAttach?.(); } } if (becameActive) { for (const other of this.#tabs.values()) { if (other !== tab && other.instanceId === instanceId && other.windowId === snap.windowId) other.active = false; } } if (opts.silent) return; const eligible = this.#eligible(tab); this.#syncTabGrouping(tab); if (eligible && !tab.announced && !tab.discarded) { this.#announceTab(tab); return; } if ((!eligible || tab.discarded) && tab.announced) { // Puppeteer waits for attachments to announced targets. this.#retractTab(tab); this.#detachIfUnheld(tab.tabKey); return; } if (eligible && tab.announced) { for (const conn of this.#conns.values()) { if (!conn.discover) continue; this.#emit(conn, "Target.targetInfoChanged", { targetInfo: this.#tabInfo(tab, tab.attached) }); this.#emit(conn, "Target.targetInfoChanged", { targetInfo: this.#pageInfo(tab, tab.attached) }); } } } /** Announce an unannounced tab to discovering connections and attach it for auto-attaching ones. */ #announceTab(tab: TabState): void { tab.announced = true; for (const conn of this.#conns.values()) { if (!conn.discover) continue; this.#emit(conn, "Target.targetCreated", { targetInfo: this.#tabInfo(tab, tab.attached) }); this.#emit(conn, "Target.targetCreated", { targetInfo: this.#pageInfo(tab, tab.attached) }); } this.#attachForAutoAttach(tab); } #attachForAutoAttach(tab: TabState): void { let hasAutoAttach = false; for (const conn of this.#conns.values()) { if (conn.autoAttach) { hasAutoAttach = true; break; } } if (!hasAutoAttach) return; void this.#ensureAttached(tab, { retryAfterDiscard: true }).then(ok => { if (!ok) { // A refused attach bars the tab until its URL changes. Withdraw the announcement too, or the URL change // finds the tab already announced and neither announces nor attaches it again. if (tab.refusal && tab.announced && this.#tabs.get(tab.tabKey) === tab) this.#retractTab(tab); return; } if (tab.discarded || !this.#eligible(tab) || this.#tabs.get(tab.tabKey) !== tab) { this.#retractTab(tab); this.#detachIfUnheld(tab.tabKey); return; } for (const conn of this.#conns.values()) { if (conn.autoAttach) this.#emitTabAttached(conn, tab); } }); } // ---- tab grouping ----------------------------------------------------------- /** A tab belongs in the omp group when claimed by a client, controllable, unpinned, not user-opted-out, and not already in a user group. */ #groupWorthy(tab: TabState): boolean { if (!this.#claimed(tab.tabKey) || !this.#eligible(tab) || tab.pinned || tab.groupOptOut) return false; return tab.grouped || tab.groupId === -1; } /** Re-group every claimed tab (extension hello / reconnect). */ #syncGrouping(): void { if (!this.#group) return; const worthy = [...this.#tabs.values()].filter(tab => this.#groupWorthy(tab) && !tab.grouped && !tab.grouping); if (worthy.length > 0) this.#requestGroup(worthy); } /** Reconcile one tab's group membership after a lifecycle event. */ #syncTabGrouping(tab: TabState): void { if (!this.#group) return; if (this.#groupWorthy(tab)) { if (!tab.grouped && !tab.grouping) this.#requestGroup([tab]); return; } if (tab.grouped) { tab.grouped = false; tab.ompGroupId = undefined; void this.#rpc({ op: "ungroup", tabIds: [tab.tabId] }, this.#instanceFor(tab)).catch(() => {}); } } /** * Queue tabs for grouping and drain serially. Overlapping group RPCs race * the extension's non-atomic query→create→set-title sequence and mint * duplicate omp groups, so at most one group RPC is ever in flight. */ #requestGroup(tabs: TabState[]): void { if (!this.#group) return; for (const tab of tabs) { tab.grouping = true; this.#groupQueue.push(tab); } if (!this.#groupDraining) void this.#drainGroupQueue(); } async #drainGroupQueue(): Promise { const group = this.#group; if (!group) return; this.#groupDraining = true; try { while (this.#groupQueue.length > 0) { const batch = this.#groupQueue.splice(0); // Group RPCs address tabs by chrome tabId within one browser // instance, so a mixed batch is split per instance. const byInstance = new Map(); for (const tab of batch) { const tabs = byInstance.get(tab.instanceId); if (tabs) tabs.push(tab); else byInstance.set(tab.instanceId, [tab]); } for (const [instanceId, tabs] of byInstance) { const inst = this.#instances.get(instanceId); if (!inst?.socket) { for (const tab of tabs) tab.grouping = false; continue; } const tabIds = tabs.map(tab => tab.tabId); try { const result = await this.#rpc({ op: "group", tabIds, title: group.title, color: group.color }, inst); // Extension replies { grouped: { [tabId]: groupId } }; validate per entry. const grouped: Record = result && typeof result === "object" && "grouped" in result && result.grouped && typeof result.grouped === "object" ? (result.grouped as Record) : {}; for (const tab of tabs) { const groupId = grouped[String(tab.tabId)]; if (typeof groupId !== "number") continue; tab.grouped = true; tab.ompGroupId = groupId; } this.#log("grouped tabs", { instanceId, tabIds, grouped }); } catch (err) { this.#log("tab grouping failed", { error: err instanceof Error ? err.message : String(err) }); } finally { for (const tab of tabs) { tab.grouping = false; // The claim may have gone while the RPC was in flight (its client closed): undo the grouping. if (tab.grouped && !this.#groupWorthy(tab)) this.#syncTabGrouping(tab); } } } } } finally { this.#groupDraining = false; } } /** Tear a tab out of every downstream connection (closed, detached, or now ineligible). */ #retractTab(tab: TabState): void { this.#forgetChildren(tab); for (const conn of this.#conns.values()) { const tabSessions = conn.sessionsForTab(tab.tabKey, "tab"); for (const pageSession of conn.sessionsForTab(tab.tabKey, "page")) { conn.sessions.delete(pageSession); this.#emit( conn, "Target.detachedFromTarget", { sessionId: pageSession, targetId: pageTargetIdFromKey(tab.tabKey) }, tabSessions[0], ); } for (const tabSession of tabSessions) { conn.sessions.delete(tabSession); this.#emit(conn, "Target.detachedFromTarget", { sessionId: tabSession, targetId: tabTargetIdFromKey(tab.tabKey), }); } if (conn.discover && tab.announced) { this.#emit(conn, "Target.targetDestroyed", { targetId: pageTargetIdFromKey(tab.tabKey) }); this.#emit(conn, "Target.targetDestroyed", { targetId: tabTargetIdFromKey(tab.tabKey) }); } } tab.announced = false; } // ---- session + attach bookkeeping -------------------------------------------- #mintSession(conn: CdpConnection, kind: "tab" | "page", tab: TabState): string { const sessionId = `S${kind === "tab" ? "T" : "P"}${tab.tabId}.${conn.id}.${++this.#sessionSeq}`; conn.sessions.set(sessionId, { kind, tabKey: tab.tabKey, tabId: tab.tabId, runtimeState: "default", runtimeContexts: new Set(), runtimeEnabling: null, runtimeEpoch: 0, autoAttach: false, switches: new Set(), }); return sessionId; } #releaseSession(conn: CdpConnection, sessionId: string, parentSessionId: string | undefined): void { const ref = conn.sessions.get(sessionId); if (!ref) return; conn.sessions.delete(sessionId); this.#releaseSwitches(ref); const targetId = ref.kind === "tab" ? tabTargetIdFromKey(ref.tabKey) : pageTargetIdFromKey(ref.tabKey); this.#emit(conn, "Target.detachedFromTarget", { sessionId, targetId }, parentSessionId); // An explicit release of the last session must drop the attachment too, // or it outlives every downstream session: the infobar stays up, and // dismissing it bans the tab for the rest of the epoch. this.#detachIfUnheld(ref.tabKey); } /** * Release the tab's chrome.debugger attachment once no downstream session * holds it. Inert while the long-lived registry connection still holds one. */ #detachIfUnheld(tabKey: string): void { if (this.#sessionHolders(tabKey).length > 0) return; const tab = this.#tabs.get(tabKey); if (!tab?.attached) return; tab.attached = false; this.#resetRuntime(tab); this.#forgetChildren(tab); tab.reattachedAfterDetach = false; // In a real Chrome CDP session, detaching during a focus-emulation reset can strand the next session. // Wait for in-flight tab-wide switches (bounded by their RPC timeout) before ending the attachment. const transitions = TAB_WIDE_SWITCHES.map(method => this.#switchTransitions.get(`${tabKey} ${method}`)).filter( pending => pending !== undefined, ); const done = (async () => { if (transitions.length > 0) await Promise.all(transitions); await this.#rpc({ op: "detach", tabId: tab.tabId }, this.#instanceFor(tab)); })() .catch(() => {}) .finally(() => { if (tab.detaching === done) tab.detaching = null; }); tab.detaching = done; } #resetRuntime(tab: TabState): void { tab.runtimeContexts.clear(); tab.rootRuntimeEnabled = false; tab.rootRuntimeEnabling = null; tab.runtimeGeneration++; } /** * Drop the tab's real child sessions, which end with its debugger attachment, and send each * connection told of one its detach; nested children go first, while their parent is still known. */ #forgetChildren(tab: TabState): void { for (const child of [...tab.realSessions.keys()].reverse()) { this.#realSessionTabs.delete(child); for (const conn of this.#conns.values()) { const parent = conn.childParents.get(child); if (parent === undefined) continue; conn.childParents.delete(child); this.#emit(conn, "Target.detachedFromTarget", { sessionId: child }, parent); } } tab.realSessions.clear(); } /** Connections currently holding any session on a tab. */ #sessionHolders(tabKey: string): CdpConnection[] { const out: CdpConnection[] = []; for (const conn of this.#conns.values()) { if (conn.sessionsForTab(tabKey).length > 0) out.push(conn); } return out; } /** Whether any live session on a tab, across connections, has `method` switched on. */ #switchWanted(tabKey: string, method: TabWideSwitch): boolean { for (const conn of this.#conns.values()) { for (const ref of conn.sessions.values()) { if (ref.tabKey === tabKey && ref.switches.has(method)) return true; } } return false; } /** * Switch off what a departed session left on, unless another live session of the tab still wants it: a session * that went away without turning it off (a killed worker) would otherwise leave it on for as long as the attachment * lasts, swallowing every chooser or keeping a background tab visible. */ #releaseSwitches(ref: SessionRef): void { for (const method of ref.switches) this.#sendSwitch(ref.tabKey, method, false); } /** * Send `method` as `enabled` once the tab's earlier transitions settle, unless live sessions want otherwise by * then: a release must not switch off what another session has since asked for. */ #sendSwitch(tabKey: string, method: TabWideSwitch, enabled: boolean): void { void this.#serializeSwitches(tabKey, method, async () => { if (this.#switchWanted(tabKey, method) !== enabled) return; const tab = this.#tabs.get(tabKey); if (!tab) return; const inst = tab.attached ? this.#instances.get(tab.instanceId) : undefined; if (!inst?.socket) { tab.unsettledSwitches.add(method); return; } await this.#rpc({ op: "send", tabId: tab.tabId, method, params: { enabled } }, inst).catch(() => { tab.unsettledSwitches.add(method); }); }); } /** * Run one tab switch's transitions one at a time, each sending the tab-wide state as it stands when it runs. * The extension starts RPCs independently, so overlapping transitions could settle out of order; a failed one's * rollback would then restore a state another transition had already changed on the tab. */ #serializeSwitches(tabKey: string, method: TabWideSwitch, run: () => Promise): Promise { const key = `${tabKey} ${method}`; const pending = this.#switchTransitions.get(key); const result = pending ? pending.then(run) : run(); const tail = result.catch(() => {}); this.#switchTransitions.set(key, tail); void tail.then(() => { if (this.#switchTransitions.get(key) === tail) this.#switchTransitions.delete(key); }); return result; } #emitTabAttached(conn: CdpConnection, tab: TabState): void { if (conn.sessionsForTab(tab.tabKey, "tab").length > 0) return; const sessionId = this.#mintSession(conn, "tab", tab); this.#emit(conn, "Target.attachedToTarget", { sessionId, targetInfo: this.#tabInfo(tab, true), waitingForDebugger: false, }); } async #ensureAttached(tab: TabState, opts: { retryAfterDiscard?: boolean } = {}): Promise { if (tab.discarded) return false; // The extension emits the detach echo before resolving the RPC. Awaiting // prevents a replacement attach racing either operation. while (tab.detaching) await tab.detaching; if (tab.discarded) return false; if (tab.attached) return true; if (!this.#eligible(tab)) return false; const inst = this.#instances.get(tab.instanceId); if (!inst?.socket) return false; if (tab.attaching) { if (tab.attachCancelled && opts.retryAfterDiscard) tab.retryAttachAfterDiscard = true; return await tab.attaching; } const generation = tab.attachGeneration; const cancelled = Promise.withResolvers(); const request = this.#rpc({ op: "attach", tabId: tab.tabId }, inst) .then(() => { tab.attached = true; tab.reattachedAfterDetach = true; if (tab.discarded) this.#detachIfUnheld(tab.tabKey); return !tab.discarded; }) .catch(err => { const error = err instanceof Error ? err.message : String(err); this.#log("attach failed", { tabKey: tab.tabKey, url: tab.url, error }); if (!(err instanceof ExtensionReplacedError) && generation === tab.attachGeneration) { tab.refusal = error || "Chrome refused the attach"; } return false; }) .finally(() => { if (tab.attaching === attempt) { tab.attaching = null; tab.cancelAttach = null; tab.attachCancelled = false; } const retry = tab.retryAttachAfterDiscard; tab.retryAttachAfterDiscard = false; if (retry && !tab.discarded) this.#attachForAutoAttach(tab); // Every client left while Chrome attached: nobody can take a session on the tab, so release it. if (this.#conns.size === 0) this.#detachIfUnheld(tab.tabKey); }); // Stop waiting for discarded tabs without dropping the extension RPC's late result. const attempt = Promise.race([request, cancelled.promise]); tab.attaching = attempt; tab.cancelAttach = () => cancelled.resolve(false); return await attempt; } #eligible(tab: TabState): boolean { if (tab.refusal) return false; const instance = this.#instances.get(tab.instanceId); if (!instance?.socket || instance.info?.discardedTabsProtocol !== DISCARDED_TABS_PROTOCOL_VERSION) return false; if (!tab.url) return true; return !INELIGIBLE_URL.test(tab.url); } #tabInfo(tab: TabState, attached: boolean): TargetInfo { return { targetId: tabTargetIdFromKey(tab.tabKey), type: "tab", title: tab.title, url: tab.url || "about:blank", attached, canAccessOpener: false, }; } #pageInfo(tab: TabState, attached: boolean): TargetInfo { return { targetId: pageTargetIdFromKey(tab.tabKey), type: "page", title: tab.title, url: tab.url || "about:blank", attached, canAccessOpener: false, }; } // ---- plumbing --------------------------------------------------------------- #reply(conn: CdpConnection, msg: CdpCommand, result: Record): void { conn.socket.send(JSON.stringify({ id: msg.id, sessionId: msg.sessionId, result })); } #replyError(conn: CdpConnection, msg: CdpCommand, message: string, code = CDP_ERROR_SERVER): void { conn.socket.send(JSON.stringify({ id: msg.id, sessionId: msg.sessionId, error: { code, message } })); } #emit(conn: CdpConnection, method: string, params: Record, sessionId?: string): void { conn.socket.send(JSON.stringify({ sessionId, method, params })); } /** The extension instance a tab belongs to; throws if the browser vanished. */ #instanceFor(tab: TabState): ExtInstance { const inst = this.#instances.get(tab.instanceId); if (!inst) throw new Error("relay extension is not connected"); return inst; } /** Answer `OMP.downloadPath` with the file Chrome saved for the completed download the page reported as `params.guid`. */ async #replyDownloadPath(conn: CdpConnection, msg: CdpCommand, inst: ExtInstance): Promise { const guid = msg.params?.guid; if (typeof guid !== "string") { this.#replyError(conn, msg, "OMP.downloadPath requires the download's guid"); return false; } if (inst.info?.downloadsProtocol !== DOWNLOADS_PROTOCOL_VERSION) { this.#replyError( conn, msg, "the omp relay extension in this browser predates download tracking; run `omp browser-relay install` and reload the extension in chrome://extensions", ); return false; } let found: unknown; try { found = await this.#rpc({ op: "findDownload", guid }, inst); } catch (err) { this.#replyError(conn, msg, err instanceof Error ? err.message : String(err)); return false; } if ( !found || typeof found !== "object" || !("filename" in found) || typeof found.filename !== "string" || !found.filename || !("state" in found) || found.state !== "complete" ) { this.#replyError(conn, msg, "Chrome has no completed download for this report"); return false; } this.#reply(conn, msg, { filePath: found.filename }); return true; } #rpc( req: RelayRpcRequest, inst: ExtInstance, timeoutMs = RPC_TIMEOUT_MS, onLateResult?: (result: unknown) => void, ): Promise { if (!inst.socket) return Promise.reject(new Error("relay extension is not connected")); const id = ++this.#rpcSeq; const { promise, resolve, reject } = Promise.withResolvers(); const timer = setTimeout(() => { const key = `${inst.instanceId}:${id}`; this.#pendingRpc.delete(key); if (onLateResult) { const expiry = setTimeout(() => { this.#lateRpc.delete(key); if (this.#conns.size === 0) void this.#updateIdleExtensions(); }, LATE_RPC_KEEP_MS); expiry.unref?.(); this.#lateRpc.set(key, { onResult: onLateResult, timer: expiry }); } reject(new RpcTimeoutError(`extension rpc '${req.op}' timed out after ${timeoutMs}ms`)); }, timeoutMs); this.#pendingRpc.set(`${inst.instanceId}:${id}`, { resolve, reject, timer }); // Only in a tab omp drives may the extension empty another extension's frames to get past Chrome's refusal. const sent = req.op === "attach" || req.op === "send" ? { ...req, clearForeignFrames: this.#claimed(tabKeyOf(inst.code, req.tabId)) } : req; inst.socket.send(JSON.stringify({ t: "rpc", id, ...sent } satisfies RelayToExtMessage)); // A pending call defers an extension update (see #reloadBlocker): retry it once the call and its cleanup settle. void promise .catch(() => undefined) .then(() => { if (this.#conns.size === 0) void this.#updateIdleExtensions(); }); return promise; } /** * Close a tab omp opened. The extension retries a close Chrome refuses before answering, so the caller holds * its relay lease through the retry. One whose close still fails (its browser offline, the socket dropping * mid-call, or Chrome refusing every try) is closed at that browser's next hello, unless the user closed it. */ async #closeTab(key: string, tabId: number, inst: ExtInstance): Promise { const tab = this.#tabs.get(key); if (tab) tab.closing = true; try { await this.#rpc({ op: "removeTab", tabId }, inst); } catch (err) { // The tab stays open, so a later detach is Chrome's own again. if (tab) tab.closing = false; this.#closeOnHello.set(key, tab?.url ?? ""); throw err; } } }