aboutsummaryrefslogtreecommitdiff
path: root/src/client.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/client.ts')
-rw-r--r--src/client.ts54
1 files changed, 2 insertions, 52 deletions
diff --git a/src/client.ts b/src/client.ts
index 4937447..84a98ee 100644
--- a/src/client.ts
+++ b/src/client.ts
@@ -24,7 +24,6 @@ export type BrowserClientOptions = {
readonly harnessWindowTargetId?: string;
readonly harnessWindowId?: number;
};
- /** Profile to pin this session to, loaded from the durable pin store. */
readonly profilePin?: ProfilePin | null;
readonly onOwnershipChange?: (snapshot: {
readonly ownedTargetIds: ReadonlyArray<string>;
@@ -39,11 +38,7 @@ export type BrowserClient = {
status(): DaemonStatus;
start(): Promise<Result<void, CdpError>>;
stop(): Promise<void>;
- /** 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>>;
@@ -57,21 +52,12 @@ export type BrowserClient = {
current(): { readonly sessionId: string; readonly targetId: string } | null;
session(): CdpSession;
transport(): CdpTransport;
- /** User-data-dir of the connected browser, when discovery could determine it.
- * Undefined for BU_CDP_WS / bare-port connections, where profile selection
- * is unavailable. */
userDataDir(): string | undefined;
- /** The profile this session is pinned to, or null when unpinned. */
profilePin(): ProfilePin | null;
setProfilePin(pin: ProfilePin | null): void;
- /** browserContextId of the pinned profile, once a window has identified it.
- * Session-scoped: Chrome re-mints context ids on every browser run. */
profileContextId(): string | undefined;
setProfileContextId(contextId: string | undefined): void;
- /** Open and adopt a window inside the pinned profile. Returns its seed tab. */
seedPinnedProfileWindow(): Promise<Result<string, CdpError>>;
- /** Returns the shared async mutex that serialized browser tools must acquire
- * before performing mutations. Observation tools should not use this. */
mutationMutex(): Mutex;
};
@@ -132,9 +118,6 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
});
});
}
- // The seed provider routes attachFirstPage's fallback through the target
- // factory, so a pinned profile is honoured on every path that can create the
- // harness window.
const session = createCdpSession(transport, ownership, async () => {
const window = await ensureHarnessWindow(api);
return window.success ? ok(window.data.targetId) : window;
@@ -147,12 +130,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
let profilePin: ProfilePin | null = opts.profilePin ?? null;
let profileContextId: string | undefined;
- /**
- * With a profile pinned, the harness window must exist BEFORE anything
- * attaches: session.attachFirstPage() would otherwise fall back to a bare
- * Target.createTarget, which lands in the focus-derived default profile.
- * Seeding first means attachFirstPage always finds an owned tab to attach to.
- */
+ // With a pin set the window must exist BEFORE anything attaches, or attachFirstPage's bare Target.createTarget lands in the focus-derived default profile.
const seedIfPinned = async (): Promise<Result<void, CdpError>> => {
if (!profilePin) return ok(undefined);
const window = await ensureHarnessWindow(api);
@@ -215,14 +193,10 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
return start();
}
lastHealth = Date.now();
- // Verify the page session is still responsive (handles the case where the
- // browser transport is alive but the page target crashed, e.g. localhost died).
const jsProbe = await session.call("Runtime.evaluate", {
expression: "1", returnByValue: true,
}, { timeoutMs: 2_000 });
if (!jsProbe.success && jsProbe.error.kind === "session_not_found") {
- // Re-seed first when pinned: if the user closed every harness tab,
- // attachFirstPage would mint a window in the focus-derived profile.
const seeded = await seedIfPinned();
if (!seeded.success) return seeded;
const reattached = await session.attachFirstPage();
@@ -237,12 +211,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
};
const evaluateJs = async (expression: string, sessionId?: string): Promise<Result<unknown, CdpError>> => {
- // Heuristic IIFE wrap: legacy convenience that lets agents write
- // `return foo` instead of `(() => foo)()`. Only wrap when the trimmed
- // source *starts with* a return statement — a bare expression, or one that
- // merely mentions `return` inside a string literal or comment, is passed
- // through untouched (previously any substring `"return "` triggered a wrap,
- // silently turning such expressions into `undefined`).
+ // Wrap only when the trimmed source *starts with* `return` — matching the substring anywhere silently turned expressions mentioning it into `undefined`.
const trimmed = expression.trim();
const isReturnStatement = /^return[\s(]/.test(trimmed);
const wrapped = isReturnStatement ? `(function(){${expression}})()` : expression;
@@ -289,14 +258,12 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
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);
- // Prune per-tab page caches for tabs that no longer exist
for (const tid of pageCaches.keys()) {
if (!live.has(tid)) pageCaches.delete(tid);
}
@@ -306,10 +273,6 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
const switchTab = async (targetId: string): Promise<Result<void, CdpError>> => {
const r = await session.switchTo(targetId);
if (!r.success) return r;
- // pageCache per-tab: no longer cleared — each tab retains its cache
- // Best-effort: mark the tab title with a green circle so the user can
- // see which tab the agent attached to. CSP or detached frames may
- // block the eval; we don't surface that as a switchTab failure.
await session.call("Runtime.evaluate", {
expression: safeJs`if(!document.title.startsWith('🟢'))document.title='🟢 '+document.title`,
});
@@ -317,17 +280,11 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
};
const newTab = async (url?: string): Promise<Result<string, CdpError>> => {
- // Reconcile ownership against live targets before deciding where to open.
const tabsResult = await listTabs(true);
if (!tabsResult.success) return tabsResult;
- // ensureHarnessWindow reuses the seed tab, falls back to any surviving
- // owned tab, and only then creates a window — which, when a profile is
- // pinned, means launching one inside that profile.
const window = await ensureHarnessWindow(api);
if (!window.success) return window;
- // A freshly created window IS the new tab; opening another would leave a
- // stray blank tab behind.
let tabId: string;
if (window.data.freshlyCreated) {
tabId = window.data.targetId;
@@ -354,9 +311,6 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
};
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);
@@ -375,10 +329,6 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
const detach = async (): Promise<void> => {
const cur = session.current();
if (!cur) return;
- // Target.detachFromTarget removes the "Chrome is being controlled" banner
- // and releases the page session. The transport (WebSocket/Unix socket)
- // stays alive for reuse by the next session.
- // Reference: https://chromedevtools.github.io/devtools-protocol/tot/Target/#method-detachFromTarget
await transport.request("Target.detachFromTarget", { sessionId: cur.sessionId }, { sessionId: null });
};