aboutsummaryrefslogtreecommitdiff
path: root/src/client.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/client.ts')
-rw-r--r--src/client.ts79
1 files changed, 75 insertions, 4 deletions
diff --git a/src/client.ts b/src/client.ts
index 94b6ac0..2e451e8 100644
--- a/src/client.ts
+++ b/src/client.ts
@@ -6,11 +6,20 @@ import { type CdpError, cdpError } from "./cdp/errors";
import type { CdpTransport } from "./cdp/transport";
import { createCdpTransport } from "./cdp/transport";
import { type CdpSession, createCdpSession } from "./cdp/session";
+import { type OwnershipRegistry, createOwnershipRegistry } from "./cdp/ownership";
import type { DaemonStatus, DialogInfo, PageInfo, TabInfo } from "./cdp/types";
export type BrowserClientOptions = {
readonly namespace: string;
readonly remote?: { readonly cdpUrl: string; readonly browserId: string };
+ readonly initialOwnership?: {
+ readonly ownedTargetIds?: ReadonlyArray<string>;
+ readonly harnessWindowTargetId?: string;
+ };
+ readonly onOwnershipChange?: (snapshot: {
+ readonly ownedTargetIds: ReadonlyArray<string>;
+ readonly harnessWindowTargetId: string | undefined;
+ }) => void;
};
export type BrowserClient = {
@@ -25,6 +34,9 @@ export type BrowserClient = {
listTabs(includeInternal?: boolean): Promise<Result<ReadonlyArray<TabInfo>, CdpError>>;
switchTab(targetId: string): Promise<Result<void, CdpError>>;
newTab(url?: string): Promise<Result<string, CdpError>>;
+ closeTab(targetId: string): Promise<Result<void, CdpError>>;
+ owns(targetId: string): boolean;
+ ownership(): OwnershipRegistry;
current(): { readonly sessionId: string; readonly targetId: string } | null;
session(): CdpSession;
transport(): CdpTransport;
@@ -66,7 +78,24 @@ const parsePageInfoPayload = (v: unknown): Result<PageInfo, CdpError> => {
export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient => {
const transport = createCdpTransport();
- const session = createCdpSession(transport);
+ const ownershipInit: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string } = {};
+ if (opts.initialOwnership?.ownedTargetIds !== undefined) {
+ ownershipInit.ownedTargetIds = opts.initialOwnership.ownedTargetIds;
+ }
+ if (opts.initialOwnership?.harnessWindowTargetId !== undefined) {
+ ownershipInit.harnessWindowTargetId = opts.initialOwnership.harnessWindowTargetId;
+ }
+ const ownership = createOwnershipRegistry(ownershipInit);
+ if (opts.onOwnershipChange) {
+ const cb = opts.onOwnershipChange;
+ ownership.onChange(() => {
+ cb({
+ ownedTargetIds: ownership.list(),
+ harnessWindowTargetId: ownership.harnessWindow(),
+ });
+ });
+ }
+ const session = createCdpSession(transport, ownership);
const mutationMutex = createMutex();
let lastHealth = 0;
let pageCache: { readonly info: PageInfo; readonly at: number } | null = null;
@@ -186,7 +215,19 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
const tabs = data.targetInfos
.filter((t) => t.type === "page")
.filter((t) => includeInternal || !t.url.startsWith("chrome://"))
- .map((t): TabInfo => ({ targetId: t.targetId, title: t.title, url: t.url }));
+ .map((t): TabInfo => ({
+ targetId: t.targetId,
+ title: t.title,
+ url: t.url,
+ owned: ownership.has(t.targetId),
+ }));
+ // Reconcile any persisted owned ids that no longer exist as live page targets.
+ const live = new Set(tabs.map((t) => t.targetId));
+ const owned = ownership.list();
+ const survivors = owned.filter((id) => live.has(id));
+ if (survivors.length !== owned.length) ownership.replaceAll(survivors);
+ const hw = ownership.harnessWindow();
+ if (hw && !live.has(hw)) ownership.setHarnessWindow(undefined);
return ok(tabs);
};
@@ -204,9 +245,30 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
};
const newTab = async (url?: string): Promise<Result<string, CdpError>> => {
- const created = await session.callBrowser("Target.createTarget", { url: "about:blank" });
+ // Verify the recorded harness window still exists. Querying the live
+ // target list also gives listTabs's reconciliation a chance to run.
+ const tabsResult = await listTabs(true);
+ if (!tabsResult.success) return tabsResult;
+ const live = new Set(tabsResult.data.map((t) => t.targetId));
+ const hw = ownership.harnessWindow();
+
+ const params: Record<string, unknown> = { url: "about:blank" };
+ if (hw && live.has(hw)) {
+ // Open as a child of the harness window's seed tab — Chrome places
+ // it in the same window, giving us visual grouping for free.
+ params["openerId"] = hw;
+ } else {
+ // Either fresh session or the harness window was closed by the user.
+ // Spawn a new dedicated window.
+ params["newWindow"] = true;
+ }
+ const created = await session.callBrowser("Target.createTarget", params);
if (!created.success) return created;
const c = created.data as { targetId: string };
+
+ if (params["newWindow"]) ownership.setHarnessWindow(c.targetId);
+ ownership.add(c.targetId);
+
const switched = await switchTab(c.targetId);
if (!switched.success) return switched;
if (url && url !== "about:blank") {
@@ -217,6 +279,13 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
return ok(c.targetId);
};
+ const closeTab = async (targetId: string): Promise<Result<void, CdpError>> => {
+ const r = await session.callBrowser("Target.closeTarget", { targetId });
+ if (!r.success) return r;
+ ownership.remove(targetId);
+ return ok(undefined);
+ };
+
const status = (): DaemonStatus => ({
alive: transport.state() === "open" && session.current() !== null,
sessionId: session.current()?.sessionId ?? null,
@@ -229,7 +298,9 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
ensureAlive, status, start, stop,
evaluateJs, pageInfo,
takeDialog: () => session.takeDialog(),
- listTabs, switchTab, newTab,
+ listTabs, switchTab, newTab, closeTab,
+ owns: (id: string) => ownership.has(id),
+ ownership: () => ownership,
current: () => session.current(),
session: () => session,
transport: () => transport,