aboutsummaryrefslogtreecommitdiff
path: root/src/client.ts
diff options
context:
space:
mode:
authorAman Kumar <145339995+amankumarsingh77@users.noreply.github.com>2026-07-22 00:59:58 +0530
committerGitHub <noreply@github.com>2026-07-22 00:59:58 +0530
commitfe00d3361dece3d35f788d3eeb1c750eedbf6294 (patch)
treea51b8ffe3cddb1aa8d457784f71cc1b2b0b61d56 /src/client.ts
parent7de0beaf2cf713afc50c573e941fa279ec6df9d9 (diff)
feat: add forms domain and durable window binding (v0.9.0) (#14)
- Add forms domain with a universal field setter that auto-detects element type and fires framework-compatible input events (React native-setter trick) - Add window-binding helper to capture and bind the real Chrome windowId as the session's durable window identity on every new-window code path - Wire supporting changes across CDP session/ownership, client, navigate, isolated-tab, prompt, and state - Add manual tests for forms and window binding - Bump version 0.8.3 -> 0.9.0 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src/client.ts')
-rw-r--r--src/client.ts61
1 files changed, 52 insertions, 9 deletions
diff --git a/src/client.ts b/src/client.ts
index 724a711..9a97369 100644
--- a/src/client.ts
+++ b/src/client.ts
@@ -16,10 +16,12 @@ export type BrowserClientOptions = {
readonly initialOwnership?: {
readonly ownedTargetIds?: ReadonlyArray<string>;
readonly harnessWindowTargetId?: string;
+ readonly harnessWindowId?: number;
};
readonly onOwnershipChange?: (snapshot: {
readonly ownedTargetIds: ReadonlyArray<string>;
readonly harnessWindowTargetId: string | undefined;
+ readonly harnessWindowId: number | undefined;
}) => void;
};
@@ -32,6 +34,9 @@ export type BrowserClient = {
/** Detach from the current page target (removes the "Chrome is being controlled" banner)
* while keeping the transport connection alive. Call on session shutdown. */
detach(): Promise<void>;
+ /** Close every tab this session owns and clear the window binding. Best-effort —
+ * used on session shutdown so no stale harness tabs survive. */
+ closeOwnedTabs(): Promise<void>;
evaluateJs(expression: string, sessionId?: string): Promise<Result<unknown, CdpError>>;
pageInfo(): Promise<Result<PageInfo | { readonly dialog: DialogInfo }, CdpError>>;
takeDialog(): DialogInfo | null;
@@ -82,13 +87,16 @@ const parsePageInfoPayload = (v: unknown): Result<PageInfo, CdpError> => {
export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient => {
const transport = opts.transport ?? createCdpTransport();
- const ownershipInit: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string } = {};
+ const ownershipInit: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string; harnessWindowId?: number } = {};
if (opts.initialOwnership?.ownedTargetIds !== undefined) {
ownershipInit.ownedTargetIds = opts.initialOwnership.ownedTargetIds;
}
if (opts.initialOwnership?.harnessWindowTargetId !== undefined) {
ownershipInit.harnessWindowTargetId = opts.initialOwnership.harnessWindowTargetId;
}
+ if (opts.initialOwnership?.harnessWindowId !== undefined) {
+ ownershipInit.harnessWindowId = opts.initialOwnership.harnessWindowId;
+ }
const ownership = createOwnershipRegistry(ownershipInit);
if (opts.onOwnershipChange) {
const cb = opts.onOwnershipChange;
@@ -96,6 +104,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
cb({
ownedTargetIds: ownership.list(),
harnessWindowTargetId: ownership.harnessWindow(),
+ harnessWindowId: ownership.harnessWindowId(),
});
});
}
@@ -256,6 +265,15 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
return ok(undefined);
};
+ // Ask Chrome for the real windowId hosting a target. Undefined on failure —
+ // callers treat the window binding as best-effort.
+ const getWindowId = async (targetId: string): Promise<number | undefined> => {
+ const r = await session.callBrowser("Browser.getWindowForTarget", { targetId });
+ if (!r.success) return undefined;
+ const wid = (r.data as { windowId?: number }).windowId;
+ return typeof wid === "number" ? wid : undefined;
+ };
+
const newTab = async (url?: string): Promise<Result<string, CdpError>> => {
// Verify the recorded harness window still exists. Querying the live
// target list also gives listTabs's reconciliation a chance to run.
@@ -263,22 +281,35 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
if (!tabsResult.success) return tabsResult;
const live = new Set(tabsResult.data.map((t) => t.targetId));
const hw = ownership.harnessWindow();
+ // Pick an opener that keeps us inside the harness window. Prefer the seed
+ // tab; if the user closed it but other owned tabs survive, reuse one of
+ // those instead of spawning a second window (avoids window scatter).
+ const opener = hw && live.has(hw)
+ ? hw
+ : ownership.list().find((id) => live.has(id));
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;
+ if (opener) {
+ // Open as a child of an owned tab — Chrome places it in the same window,
+ // giving us visual grouping for free and keeping the session single-window.
+ params["openerId"] = opener;
} else {
- // Either fresh session or the harness window was closed by the user.
- // Spawn a new dedicated window.
+ // Fresh session, or the whole harness window was closed by the user.
+ // Spawn a new dedicated window and rebind to it.
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);
+ if (params["newWindow"]) {
+ ownership.setHarnessWindow(c.targetId);
+ const wid = await getWindowId(c.targetId);
+ if (wid !== undefined) ownership.setHarnessWindowId(wid);
+ } else if (opener && opener !== hw) {
+ // Re-seeded onto a surviving owned tab; keep the seed pointer live.
+ ownership.setHarnessWindow(opener);
+ }
ownership.add(c.targetId);
const switched = await switchTab(c.targetId);
@@ -297,6 +328,18 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
return ok(undefined);
};
+ const closeOwnedTabs = async (): Promise<void> => {
+ // Snapshot first — remove() mutates the list, and targetDestroyed events
+ // may also prune concurrently. Failures are swallowed: a tab the user
+ // already closed, or a dead transport, must not block shutdown.
+ for (const id of ownership.list()) {
+ await session.callBrowser("Target.closeTarget", { targetId: id }).catch(() => {});
+ ownership.remove(id);
+ }
+ ownership.setHarnessWindow(undefined);
+ ownership.setHarnessWindowId(undefined);
+ };
+
const status = (): DaemonStatus => ({
alive: transport.state() === "open" && session.current() !== null,
sessionId: session.current()?.sessionId ?? null,
@@ -316,7 +359,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
return {
namespace: opts.namespace,
- ensureAlive, status, start, stop, detach,
+ ensureAlive, status, start, stop, detach, closeOwnedTabs,
evaluateJs, pageInfo,
takeDialog: () => session.takeDialog(),
listTabs, switchTab, newTab, closeTab,