pi-browser-harness 0.10.2 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.md +5 -9
- package/package.json +5 -2
- package/skills/pi-browser-harness/SKILL.md +1 -1
- package/src/cdp/commands.ts +256 -0
- package/src/cdp/console-buffer.ts +10 -85
- package/src/cdp/discovery.ts +28 -71
- package/src/cdp/errors.ts +4 -0
- package/src/cdp/events.ts +170 -0
- package/src/cdp/network-buffer.ts +42 -109
- package/src/cdp/ownership.ts +0 -17
- package/src/cdp/record-store.ts +61 -0
- package/src/cdp/session.ts +205 -193
- package/src/cdp/target-factory.ts +10 -80
- package/src/cdp/types.ts +21 -12
- package/src/client.ts +39 -95
- package/src/daemon/bridge.ts +150 -203
- package/src/daemon/index.ts +0 -28
- package/src/daemon/protocol.ts +6 -68
- package/src/daemon/queue.ts +85 -0
- package/src/daemon/server.ts +24 -99
- package/src/daemon/spawn.ts +18 -68
- package/src/{cdp/daemon-transport.ts → daemon/transport.ts} +18 -51
- package/src/domains/ax-tree.ts +179 -0
- package/src/domains/box.ts +51 -0
- package/src/domains/cdp-call.ts +55 -0
- package/src/domains/click.ts +19 -32
- package/src/domains/console.ts +48 -2
- package/src/domains/dialog.ts +8 -5
- package/src/domains/drag.ts +8 -7
- package/src/domains/element-call.ts +117 -0
- package/src/domains/files.ts +22 -26
- package/src/domains/fill-engine.ts +80 -0
- package/src/domains/form-batch.ts +65 -0
- package/src/domains/form.ts +116 -105
- package/src/domains/history.ts +13 -9
- package/src/domains/isolated-tab.ts +26 -55
- package/src/domains/js.ts +29 -42
- package/src/domains/keyboard.ts +36 -27
- package/src/domains/navigate.ts +16 -23
- package/src/domains/network.ts +60 -10
- package/src/domains/page.ts +24 -3
- package/src/domains/readpage/read-page.ts +74 -19
- package/src/domains/readpage/readability.ts +0 -24
- package/src/domains/ref-resolve.ts +24 -58
- package/src/domains/render.ts +6 -27
- package/src/domains/screenshot-capture.ts +80 -0
- package/src/domains/screenshot.ts +9 -86
- package/src/domains/scroll.ts +10 -11
- package/src/domains/search/google-serp.ts +0 -29
- package/src/domains/search/web-search.ts +48 -20
- package/src/domains/setup.ts +1 -11
- package/src/domains/snapshot.ts +35 -276
- package/src/domains/tabs.ts +14 -7
- package/src/domains/target.ts +35 -0
- package/src/domains/viewport.ts +5 -3
- package/src/index.ts +8 -58
- package/src/profile/bind.ts +8 -57
- package/src/profile/browser-process.ts +75 -126
- package/src/profile/command.ts +50 -29
- package/src/profile/launch.ts +22 -40
- package/src/profile/list.ts +14 -54
- package/src/profile/paths.ts +2 -65
- package/src/profile/store.ts +35 -56
- package/src/registry.ts +30 -33
- package/src/schemas/parse.ts +23 -0
- package/src/setup.ts +0 -25
- package/src/state.ts +16 -16
- package/src/util/debug.ts +8 -0
- package/src/util/guards.ts +30 -0
- package/src/util/js-template.ts +1 -16
- package/src/util/mutex.ts +0 -5
- package/src/util/result.ts +0 -6
- package/src/util/sharp-shim.ts +12 -19
- package/src/util/tool.ts +17 -42
- package/src/util/truncate.ts +6 -0
- package/tsconfig.json +1 -16
- package/skills/deep-research/SKILL.md +0 -66
- package/src/cdp/event-queue.ts +0 -128
- package/src/cdp/transport.ts +0 -156
- package/src/cdp/window-binding.ts +0 -15
- package/src/deep-research.ts +0 -41
- package/src/domains/forms.ts +0 -355
- package/src/domains/readpage/capture.ts +0 -46
- package/src/domains/search/extract.ts +0 -44
- package/src/profile/picker.ts +0 -73
- package/src/util/keycodes.ts +0 -10
- package/src/util/time.ts +0 -29
|
@@ -1,48 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
* The single place harness tabs and windows are created.
|
|
3
|
-
*
|
|
4
|
-
* Every tab the harness opens must land in the pinned profile, so all creation
|
|
5
|
-
* paths (client.newTab, browser_open_urls, isolated tabs for web_search /
|
|
6
|
-
* read_page) funnel through here. A missed call site would silently act in
|
|
7
|
-
* another profile — with that profile's cookies and logins.
|
|
8
|
-
*
|
|
9
|
-
* Two mechanisms, chosen by whether a profile is pinned:
|
|
10
|
-
*
|
|
11
|
-
* unpinned — `Target.createTarget`, exactly as before: `newWindow: true` for
|
|
12
|
-
* a fresh dedicated window, `openerId` for siblings inside it.
|
|
13
|
-
*
|
|
14
|
-
* pinned — the window comes from a command-line launch into the profile
|
|
15
|
-
* (see profile/launch.ts), and further tabs come from
|
|
16
|
-
* `window.open(..., '_blank')` evaluated in an owned page with
|
|
17
|
-
* `userGesture: true`. That is the only CDP-reachable way to place
|
|
18
|
-
* a tab in a non-default browser context: createTarget rejects a
|
|
19
|
-
* foreign browserContextId outright, and `openerId` does NOT
|
|
20
|
-
* inherit the opener's context (verified — it lands in the
|
|
21
|
-
* focus-derived default context, in a different window).
|
|
22
|
-
* window.open keeps both the context and the window, recursively.
|
|
23
|
-
*
|
|
24
|
-
* Correlation: window.open returns no target id, so each spawn carries a unique
|
|
25
|
-
* `about:blank#pi-<uuid>` token and the new target is found by that token.
|
|
26
|
-
* Set-difference polling would be wrong here — isolated tabs run concurrently
|
|
27
|
-
* (browser_read_page is registered unserialized).
|
|
28
|
-
*/
|
|
1
|
+
// `window.open` with userGesture is the only CDP-reachable way into a non-default browser context: createTarget rejects a foreign browserContextId and `openerId` does not inherit the opener's context.
|
|
29
2
|
|
|
30
3
|
import { randomUUID } from "node:crypto";
|
|
31
4
|
import type { BrowserClient } from "../client";
|
|
32
5
|
import { type Result, err, ok } from "../util/result";
|
|
33
6
|
import { type CdpError, cdpError } from "./errors";
|
|
34
7
|
|
|
35
|
-
/** How long to wait for a window.open-spawned target to appear. */
|
|
36
8
|
const SPAWN_DEADLINE_MS = 8_000;
|
|
37
9
|
const SPAWN_POLL_MS = 100;
|
|
38
10
|
|
|
39
11
|
export type HarnessWindow = {
|
|
40
|
-
/** Target id of the window's seed tab. */
|
|
41
12
|
readonly targetId: string;
|
|
42
|
-
/**
|
|
43
|
-
* True when this call created the window. The seed tab is then a blank tab
|
|
44
|
-
* the caller may use directly, instead of opening a second one.
|
|
45
|
-
*/
|
|
46
13
|
readonly freshlyCreated: boolean;
|
|
47
14
|
};
|
|
48
15
|
|
|
@@ -56,23 +23,15 @@ type PageTarget = {
|
|
|
56
23
|
const listPageTargets = async (client: BrowserClient): Promise<Result<ReadonlyArray<PageTarget>, CdpError>> => {
|
|
57
24
|
const r = await client.session().callBrowser("Target.getTargets");
|
|
58
25
|
if (!r.success) return r;
|
|
59
|
-
|
|
60
|
-
return ok(data.targetInfos.filter((t) => t.type === "page"));
|
|
26
|
+
return ok(r.data.targetInfos.filter((t) => t.type === "page"));
|
|
61
27
|
};
|
|
62
28
|
|
|
63
|
-
/** A CDP session id for driving `targetId`, reusing the active one when possible. */
|
|
64
29
|
const sessionForTarget = async (client: BrowserClient, targetId: string): Promise<Result<string, CdpError>> => {
|
|
65
30
|
const current = client.current();
|
|
66
31
|
if (current?.targetId === targetId) return ok(current.sessionId);
|
|
67
|
-
|
|
68
|
-
if (!attached.success) return attached;
|
|
69
|
-
return ok((attached.data as { sessionId: string }).sessionId);
|
|
32
|
+
return client.session().attach(targetId);
|
|
70
33
|
};
|
|
71
34
|
|
|
72
|
-
/**
|
|
73
|
-
* Spawn a tab from `openerTargetId` via window.open, so it inherits the
|
|
74
|
-
* opener's profile and window, and return its target id.
|
|
75
|
-
*/
|
|
76
35
|
const spawnTabViaOpener = async (
|
|
77
36
|
client: BrowserClient,
|
|
78
37
|
openerTargetId: string,
|
|
@@ -92,8 +51,7 @@ const spawnTabViaOpener = async (
|
|
|
92
51
|
session.data,
|
|
93
52
|
);
|
|
94
53
|
if (!opened.success) return opened;
|
|
95
|
-
|
|
96
|
-
if (result.result?.value !== true) {
|
|
54
|
+
if (opened.data.result.value !== true) {
|
|
97
55
|
return err(cdpError(
|
|
98
56
|
"invalid_response",
|
|
99
57
|
"the browser blocked window.open in the pinned profile's window — the page may have navigated away; retry, or run /browser-profile to re-seed",
|
|
@@ -113,29 +71,13 @@ const spawnTabViaOpener = async (
|
|
|
113
71
|
return err(cdpError("timeout", `the tab opened in the pinned profile did not report back within ${SPAWN_DEADLINE_MS}ms`));
|
|
114
72
|
};
|
|
115
73
|
|
|
116
|
-
/**
|
|
117
|
-
* In-flight window creation, per client. Isolated tabs run concurrently
|
|
118
|
-
* (browser_read_page is registered unserialized), so two of them starting at
|
|
119
|
-
* once would otherwise each find "no harness window" and create one — two blank
|
|
120
|
-
* windows before, and two launched profile windows now. Callers share the first
|
|
121
|
-
* call's result instead.
|
|
122
|
-
*/
|
|
123
74
|
const inFlight = new WeakMap<BrowserClient, Promise<Result<HarnessWindow, CdpError>>>();
|
|
124
75
|
|
|
125
|
-
/**
|
|
126
|
-
* The harness window for this session, creating it when absent.
|
|
127
|
-
*
|
|
128
|
-
* Reuses a live owned tab when one exists — including after the user closed the
|
|
129
|
-
* original seed — so reconnects don't scatter windows.
|
|
130
|
-
*/
|
|
131
76
|
export const ensureHarnessWindow = async (client: BrowserClient): Promise<Result<HarnessWindow, CdpError>> => {
|
|
132
77
|
const pending = inFlight.get(client);
|
|
133
78
|
if (pending) {
|
|
134
79
|
const shared = await pending;
|
|
135
|
-
// `freshlyCreated` is a claim on the seed tab
|
|
136
|
-
// started the creation may hold it. Everyone waiting on the same window
|
|
137
|
-
// must open a tab of their own beside it, or two callers would drive the
|
|
138
|
-
// very same tab.
|
|
80
|
+
// `freshlyCreated` is a claim on the seed tab that only the caller which started the creation may hold, or two callers would drive the same tab.
|
|
139
81
|
return shared.success ? ok({ targetId: shared.data.targetId, freshlyCreated: false }) : shared;
|
|
140
82
|
}
|
|
141
83
|
const attempt = ensureHarnessWindowUncoordinated(client).finally(() => inFlight.delete(client));
|
|
@@ -152,11 +94,7 @@ const ensureHarnessWindowUncoordinated = async (
|
|
|
152
94
|
|
|
153
95
|
const ownership = client.ownership();
|
|
154
96
|
const pinnedCtx = client.profileContextId();
|
|
155
|
-
//
|
|
156
|
-
// pin set but no context resolved yet — a fresh connection, or a browser that
|
|
157
|
-
// restarted — the recorded tabs cannot be verified, so we seed a new window
|
|
158
|
-
// rather than risk adopting one from the wrong profile. (Sessions close their
|
|
159
|
-
// owned tabs on shutdown, so this is the normal, no-cost case.)
|
|
97
|
+
// With a pin set but no context resolved yet, a recorded tab's profile cannot be verified, so seed a new window rather than risk adopting one from the wrong profile.
|
|
160
98
|
const canReuse = client.profilePin() === null || pinnedCtx !== undefined;
|
|
161
99
|
const inPinnedProfile = (targetId: string): boolean => {
|
|
162
100
|
if (!pinnedCtx) return true;
|
|
@@ -183,26 +121,18 @@ const ensureHarnessWindowUncoordinated = async (
|
|
|
183
121
|
return ok({ targetId: seeded.data, freshlyCreated: true });
|
|
184
122
|
};
|
|
185
123
|
|
|
186
|
-
/** Unpinned path: a fresh dedicated window, exactly as the harness always did. */
|
|
187
124
|
const createDedicatedWindow = async (client: BrowserClient): Promise<Result<string, CdpError>> => {
|
|
188
125
|
const created = await client.session().callBrowser("Target.createTarget", { url: "about:blank", newWindow: true });
|
|
189
126
|
if (!created.success) return created;
|
|
190
|
-
const { targetId } = created.data
|
|
127
|
+
const { targetId } = created.data;
|
|
191
128
|
const ownership = client.ownership();
|
|
192
129
|
ownership.setHarnessWindow(targetId);
|
|
193
130
|
ownership.add(targetId);
|
|
194
|
-
const win = await client.session().
|
|
195
|
-
if (win.success)
|
|
196
|
-
const windowId = (win.data as { windowId?: number }).windowId;
|
|
197
|
-
if (typeof windowId === "number") ownership.setHarnessWindowId(windowId);
|
|
198
|
-
}
|
|
131
|
+
const win = await client.session().windowId(targetId);
|
|
132
|
+
if (win.success) ownership.setHarnessWindowId(win.data);
|
|
199
133
|
return ok(targetId);
|
|
200
134
|
};
|
|
201
135
|
|
|
202
|
-
/**
|
|
203
|
-
* Open a harness-owned tab beside `openerTargetId`, in the same window and —
|
|
204
|
-
* when a profile is pinned — the same profile. The tab is registered as owned.
|
|
205
|
-
*/
|
|
206
136
|
export const openHarnessTab = async (
|
|
207
137
|
client: BrowserClient,
|
|
208
138
|
openerTargetId: string,
|
|
@@ -215,7 +145,7 @@ export const openHarnessTab = async (
|
|
|
215
145
|
openerId: openerTargetId,
|
|
216
146
|
});
|
|
217
147
|
if (!created.success) return created;
|
|
218
|
-
return ok(
|
|
148
|
+
return ok(created.data.targetId);
|
|
219
149
|
})();
|
|
220
150
|
if (!spawned.success) return spawned;
|
|
221
151
|
client.ownership().add(spawned.data);
|
package/src/cdp/types.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import type { Result } from "../util/result";
|
|
2
|
+
import { isRecord } from "../util/guards";
|
|
3
|
+
import type { CdpError } from "./errors";
|
|
4
|
+
|
|
1
5
|
export type CdpEvent = {
|
|
2
6
|
readonly method: string;
|
|
3
7
|
readonly params: unknown;
|
|
@@ -44,18 +48,8 @@ export type DaemonStatus = {
|
|
|
44
48
|
readonly remoteBrowserId?: string;
|
|
45
49
|
};
|
|
46
50
|
|
|
47
|
-
const INTERNAL_PREFIXES: ReadonlyArray<string> = [
|
|
48
|
-
"chrome://", "chrome-untrusted://", "devtools://", "chrome-extension://", "about:",
|
|
49
|
-
];
|
|
50
|
-
|
|
51
|
-
export const isInternalUrl = (url: string): boolean =>
|
|
52
|
-
INTERNAL_PREFIXES.some((p) => url.startsWith(p));
|
|
53
|
-
|
|
54
|
-
const isObject = (v: unknown): v is Readonly<Record<string, unknown>> =>
|
|
55
|
-
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
56
|
-
|
|
57
51
|
export const isCdpRawMessage = (v: unknown): v is CdpRawMessage => {
|
|
58
|
-
if (!
|
|
52
|
+
if (!isRecord(v)) return false;
|
|
59
53
|
const id = v["id"];
|
|
60
54
|
if (id !== undefined && typeof id !== "number") return false;
|
|
61
55
|
const method = v["method"];
|
|
@@ -64,10 +58,25 @@ export const isCdpRawMessage = (v: unknown): v is CdpRawMessage => {
|
|
|
64
58
|
if (sessionId !== undefined && typeof sessionId !== "string") return false;
|
|
65
59
|
const errVal = v["error"];
|
|
66
60
|
if (errVal !== undefined) {
|
|
67
|
-
if (!
|
|
61
|
+
if (!isRecord(errVal)) return false;
|
|
68
62
|
if (typeof errVal["message"] !== "string") return false;
|
|
69
63
|
const code = errVal["code"];
|
|
70
64
|
if (code !== undefined && typeof code !== "number") return false;
|
|
71
65
|
}
|
|
72
66
|
return true;
|
|
73
67
|
};
|
|
68
|
+
|
|
69
|
+
export const DEFAULT_TIMEOUT_MS = 15_000;
|
|
70
|
+
|
|
71
|
+
export type CdpTransport = {
|
|
72
|
+
connect(url: string, opts?: { timeoutMs?: number }): Promise<Result<void, CdpError>>;
|
|
73
|
+
close(): Promise<void>;
|
|
74
|
+
request(
|
|
75
|
+
method: string,
|
|
76
|
+
params: Record<string, unknown>,
|
|
77
|
+
opts?: { sessionId?: string | null; timeoutMs?: number },
|
|
78
|
+
): Promise<Result<unknown, CdpError>>;
|
|
79
|
+
events(): AsyncIterable<CdpEvent>;
|
|
80
|
+
state(): "open" | "closed" | "connecting";
|
|
81
|
+
onClose(cb: () => void): () => void;
|
|
82
|
+
};
|
package/src/client.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { Compile } from "typebox/compile";
|
|
3
|
+
import { parseJson } from "./schemas/parse";
|
|
1
4
|
import { type Result, err, ok } from "./util/result";
|
|
2
5
|
import { safeJs } from "./util/js-template";
|
|
3
6
|
import { type Mutex, createMutex } from "./util/mutex";
|
|
4
7
|
import { discoverEndpoint } from "./cdp/discovery";
|
|
5
8
|
import { type CdpError, cdpError } from "./cdp/errors";
|
|
6
|
-
import type { CdpTransport } from "./cdp/
|
|
7
|
-
import { createCdpTransport } from "./cdp/transport";
|
|
9
|
+
import type { CdpTransport } from "./cdp/types";
|
|
8
10
|
import { type CdpSession, createCdpSession } from "./cdp/session";
|
|
9
11
|
import { type OwnershipRegistry, createOwnershipRegistry } from "./cdp/ownership";
|
|
10
12
|
import { ensureHarnessWindow, openHarnessTab } from "./cdp/target-factory";
|
|
@@ -14,14 +16,13 @@ import type { DaemonStatus, DialogInfo, PageInfo, TabInfo } from "./cdp/types";
|
|
|
14
16
|
|
|
15
17
|
export type BrowserClientOptions = {
|
|
16
18
|
readonly namespace: string;
|
|
17
|
-
readonly transport
|
|
19
|
+
readonly transport: CdpTransport;
|
|
18
20
|
readonly remote?: { readonly cdpUrl: string; readonly browserId: string };
|
|
19
21
|
readonly initialOwnership?: {
|
|
20
22
|
readonly ownedTargetIds?: ReadonlyArray<string>;
|
|
21
23
|
readonly harnessWindowTargetId?: string;
|
|
22
24
|
readonly harnessWindowId?: number;
|
|
23
25
|
};
|
|
24
|
-
/** Profile to pin this session to, loaded from the durable pin store. */
|
|
25
26
|
readonly profilePin?: ProfilePin | null;
|
|
26
27
|
readonly onOwnershipChange?: (snapshot: {
|
|
27
28
|
readonly ownedTargetIds: ReadonlyArray<string>;
|
|
@@ -36,11 +37,7 @@ export type BrowserClient = {
|
|
|
36
37
|
status(): DaemonStatus;
|
|
37
38
|
start(): Promise<Result<void, CdpError>>;
|
|
38
39
|
stop(): Promise<void>;
|
|
39
|
-
/** Detach from the current page target (removes the "Chrome is being controlled" banner)
|
|
40
|
-
* while keeping the transport connection alive. Call on session shutdown. */
|
|
41
40
|
detach(): Promise<void>;
|
|
42
|
-
/** Close every tab this session owns and clear the window binding. Best-effort —
|
|
43
|
-
* used on session shutdown so no stale harness tabs survive. */
|
|
44
41
|
closeOwnedTabs(): Promise<void>;
|
|
45
42
|
evaluateJs(expression: string, sessionId?: string): Promise<Result<unknown, CdpError>>;
|
|
46
43
|
pageInfo(): Promise<Result<PageInfo | { readonly dialog: DialogInfo }, CdpError>>;
|
|
@@ -54,57 +51,51 @@ export type BrowserClient = {
|
|
|
54
51
|
current(): { readonly sessionId: string; readonly targetId: string } | null;
|
|
55
52
|
session(): CdpSession;
|
|
56
53
|
transport(): CdpTransport;
|
|
57
|
-
/** User-data-dir of the connected browser, when discovery could determine it.
|
|
58
|
-
* Undefined for BU_CDP_WS / bare-port connections, where profile selection
|
|
59
|
-
* is unavailable. */
|
|
60
54
|
userDataDir(): string | undefined;
|
|
61
|
-
/** The profile this session is pinned to, or null when unpinned. */
|
|
62
55
|
profilePin(): ProfilePin | null;
|
|
63
56
|
setProfilePin(pin: ProfilePin | null): void;
|
|
64
|
-
/** browserContextId of the pinned profile, once a window has identified it.
|
|
65
|
-
* Session-scoped: Chrome re-mints context ids on every browser run. */
|
|
66
57
|
profileContextId(): string | undefined;
|
|
67
58
|
setProfileContextId(contextId: string | undefined): void;
|
|
68
|
-
/** Open and adopt a window inside the pinned profile. Returns its seed tab. */
|
|
69
59
|
seedPinnedProfileWindow(): Promise<Result<string, CdpError>>;
|
|
70
|
-
/** Returns the shared async mutex that serialized browser tools must acquire
|
|
71
|
-
* before performing mutations. Observation tools should not use this. */
|
|
72
60
|
mutationMutex(): Mutex;
|
|
73
61
|
};
|
|
74
62
|
|
|
75
63
|
const HEALTH_TTL_MS = 30_000;
|
|
76
64
|
const PAGE_INFO_TTL_MS = 1_000;
|
|
77
65
|
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
66
|
+
const PageInfoPayload = Type.Object({
|
|
67
|
+
url: Type.String(),
|
|
68
|
+
title: Type.String(),
|
|
69
|
+
w: Type.Number(),
|
|
70
|
+
h: Type.Number(),
|
|
71
|
+
sx: Type.Number(),
|
|
72
|
+
sy: Type.Number(),
|
|
73
|
+
pw: Type.Number(),
|
|
74
|
+
ph: Type.Number(),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const pageInfoValidator = Compile(PageInfoPayload);
|
|
78
|
+
|
|
79
|
+
const parsePageInfoPayload = (raw: string): Result<PageInfo, CdpError> => {
|
|
80
|
+
const parsed = parseJson(raw, pageInfoValidator);
|
|
81
|
+
if (!parsed.success) {
|
|
82
|
+
return err(cdpError("invalid_response", `page info payload is invalid: ${parsed.error}`));
|
|
93
83
|
}
|
|
84
|
+
const o = parsed.data;
|
|
94
85
|
return ok({
|
|
95
|
-
url: o
|
|
96
|
-
title: o
|
|
97
|
-
width: o
|
|
98
|
-
height: o
|
|
99
|
-
scrollX: o
|
|
100
|
-
scrollY: o
|
|
101
|
-
pageWidth: o
|
|
102
|
-
pageHeight: o
|
|
86
|
+
url: o.url,
|
|
87
|
+
title: o.title,
|
|
88
|
+
width: o.w,
|
|
89
|
+
height: o.h,
|
|
90
|
+
scrollX: o.sx,
|
|
91
|
+
scrollY: o.sy,
|
|
92
|
+
pageWidth: o.pw,
|
|
93
|
+
pageHeight: o.ph,
|
|
103
94
|
});
|
|
104
95
|
};
|
|
105
96
|
|
|
106
97
|
export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient => {
|
|
107
|
-
const transport = opts.transport
|
|
98
|
+
const transport = opts.transport;
|
|
108
99
|
const ownershipInit: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string; harnessWindowId?: number } = {};
|
|
109
100
|
if (opts.initialOwnership?.ownedTargetIds !== undefined) {
|
|
110
101
|
ownershipInit.ownedTargetIds = opts.initialOwnership.ownedTargetIds;
|
|
@@ -126,9 +117,6 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
126
117
|
});
|
|
127
118
|
});
|
|
128
119
|
}
|
|
129
|
-
// The seed provider routes attachFirstPage's fallback through the target
|
|
130
|
-
// factory, so a pinned profile is honoured on every path that can create the
|
|
131
|
-
// harness window.
|
|
132
120
|
const session = createCdpSession(transport, ownership, async () => {
|
|
133
121
|
const window = await ensureHarnessWindow(api);
|
|
134
122
|
return window.success ? ok(window.data.targetId) : window;
|
|
@@ -141,12 +129,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
141
129
|
let profilePin: ProfilePin | null = opts.profilePin ?? null;
|
|
142
130
|
let profileContextId: string | undefined;
|
|
143
131
|
|
|
144
|
-
|
|
145
|
-
* With a profile pinned, the harness window must exist BEFORE anything
|
|
146
|
-
* attaches: session.attachFirstPage() would otherwise fall back to a bare
|
|
147
|
-
* Target.createTarget, which lands in the focus-derived default profile.
|
|
148
|
-
* Seeding first means attachFirstPage always finds an owned tab to attach to.
|
|
149
|
-
*/
|
|
132
|
+
// With a pin set the window must exist BEFORE anything attaches, or attachFirstPage's bare Target.createTarget lands in the focus-derived default profile.
|
|
150
133
|
const seedIfPinned = async (): Promise<Result<void, CdpError>> => {
|
|
151
134
|
if (!profilePin) return ok(undefined);
|
|
152
135
|
const window = await ensureHarnessWindow(api);
|
|
@@ -156,12 +139,9 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
156
139
|
|
|
157
140
|
const start = async (): Promise<Result<void, CdpError>> => {
|
|
158
141
|
if (transport.state() === "open" && session.current()) return ok(undefined);
|
|
159
|
-
const envUrl = process.env["BU_CDP_WS"];
|
|
160
142
|
let wsUrl: string;
|
|
161
143
|
if (remote?.cdpUrl) {
|
|
162
144
|
wsUrl = remote.cdpUrl;
|
|
163
|
-
} else if (envUrl) {
|
|
164
|
-
wsUrl = envUrl;
|
|
165
145
|
} else {
|
|
166
146
|
const discovered = await discoverEndpoint();
|
|
167
147
|
if (!discovered.success) return discovered;
|
|
@@ -209,14 +189,10 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
209
189
|
return start();
|
|
210
190
|
}
|
|
211
191
|
lastHealth = Date.now();
|
|
212
|
-
// Verify the page session is still responsive (handles the case where the
|
|
213
|
-
// browser transport is alive but the page target crashed, e.g. localhost died).
|
|
214
192
|
const jsProbe = await session.call("Runtime.evaluate", {
|
|
215
193
|
expression: "1", returnByValue: true,
|
|
216
194
|
}, { timeoutMs: 2_000 });
|
|
217
195
|
if (!jsProbe.success && jsProbe.error.kind === "session_not_found") {
|
|
218
|
-
// Re-seed first when pinned: if the user closed every harness tab,
|
|
219
|
-
// attachFirstPage would mint a window in the focus-derived profile.
|
|
220
196
|
const seeded = await seedIfPinned();
|
|
221
197
|
if (!seeded.success) return seeded;
|
|
222
198
|
const reattached = await session.attachFirstPage();
|
|
@@ -231,12 +207,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
231
207
|
};
|
|
232
208
|
|
|
233
209
|
const evaluateJs = async (expression: string, sessionId?: string): Promise<Result<unknown, CdpError>> => {
|
|
234
|
-
//
|
|
235
|
-
// `return foo` instead of `(() => foo)()`. Only wrap when the trimmed
|
|
236
|
-
// source *starts with* a return statement — a bare expression, or one that
|
|
237
|
-
// merely mentions `return` inside a string literal or comment, is passed
|
|
238
|
-
// through untouched (previously any substring `"return "` triggered a wrap,
|
|
239
|
-
// silently turning such expressions into `undefined`).
|
|
210
|
+
// Wrap only when the trimmed source *starts with* `return` — matching the substring anywhere silently turned expressions mentioning it into `undefined`.
|
|
240
211
|
const trimmed = expression.trim();
|
|
241
212
|
const isReturnStatement = /^return[\s(]/.test(trimmed);
|
|
242
213
|
const wrapped = isReturnStatement ? `(function(){${expression}})()` : expression;
|
|
@@ -244,11 +215,10 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
244
215
|
? await session.callOnTarget("Runtime.evaluate", { expression: wrapped, returnByValue: true, awaitPromise: true }, sessionId)
|
|
245
216
|
: await session.call("Runtime.evaluate", { expression: wrapped, returnByValue: true, awaitPromise: true });
|
|
246
217
|
if (!r.success) return r;
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
return err(cdpError("remote_error", `JS evaluation failed: ${JSON.stringify(data.exceptionDetails)}`, "Runtime.evaluate"));
|
|
218
|
+
if (r.data.exceptionDetails !== undefined) {
|
|
219
|
+
return err(cdpError("remote_error", `JS evaluation failed: ${JSON.stringify(r.data.exceptionDetails)}`, "Runtime.evaluate"));
|
|
250
220
|
}
|
|
251
|
-
return ok(data.result
|
|
221
|
+
return ok(r.data.result.value);
|
|
252
222
|
};
|
|
253
223
|
|
|
254
224
|
const readPageInfo = async (): Promise<Result<PageInfo, CdpError>> => {
|
|
@@ -260,13 +230,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
260
230
|
const raw = await evaluateJs(expr);
|
|
261
231
|
if (!raw.success) return raw;
|
|
262
232
|
if (typeof raw.data !== "string") return err(cdpError("invalid_response", "page info evaluation did not return a string"));
|
|
263
|
-
|
|
264
|
-
try {
|
|
265
|
-
parsedRaw = JSON.parse(raw.data);
|
|
266
|
-
} catch (e) {
|
|
267
|
-
return err(cdpError("invalid_response", `page info JSON.parse failed: ${e instanceof Error ? e.message : String(e)}`));
|
|
268
|
-
}
|
|
269
|
-
const info = parsePageInfoPayload(parsedRaw);
|
|
233
|
+
const info = parsePageInfoPayload(raw.data);
|
|
270
234
|
if (!info.success) return info;
|
|
271
235
|
if (currentTid) pageCaches.set(currentTid, { info: info.data, at: Date.now() });
|
|
272
236
|
return ok(info.data);
|
|
@@ -281,8 +245,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
281
245
|
const listTabs = async (includeInternal = true): Promise<Result<ReadonlyArray<TabInfo>, CdpError>> => {
|
|
282
246
|
const r = await session.callBrowser("Target.getTargets");
|
|
283
247
|
if (!r.success) return r;
|
|
284
|
-
const
|
|
285
|
-
const tabs = data.targetInfos
|
|
248
|
+
const tabs = r.data.targetInfos
|
|
286
249
|
.filter((t) => t.type === "page")
|
|
287
250
|
.filter((t) => includeInternal || !t.url.startsWith("chrome://"))
|
|
288
251
|
.map((t): TabInfo => ({
|
|
@@ -291,14 +254,12 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
291
254
|
url: t.url,
|
|
292
255
|
owned: ownership.has(t.targetId),
|
|
293
256
|
}));
|
|
294
|
-
// Reconcile any persisted owned ids that no longer exist as live page targets.
|
|
295
257
|
const live = new Set(tabs.map((t) => t.targetId));
|
|
296
258
|
const owned = ownership.list();
|
|
297
259
|
const survivors = owned.filter((id) => live.has(id));
|
|
298
260
|
if (survivors.length !== owned.length) ownership.replaceAll(survivors);
|
|
299
261
|
const hw = ownership.harnessWindow();
|
|
300
262
|
if (hw && !live.has(hw)) ownership.setHarnessWindow(undefined);
|
|
301
|
-
// Prune per-tab page caches for tabs that no longer exist
|
|
302
263
|
for (const tid of pageCaches.keys()) {
|
|
303
264
|
if (!live.has(tid)) pageCaches.delete(tid);
|
|
304
265
|
}
|
|
@@ -308,10 +269,6 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
308
269
|
const switchTab = async (targetId: string): Promise<Result<void, CdpError>> => {
|
|
309
270
|
const r = await session.switchTo(targetId);
|
|
310
271
|
if (!r.success) return r;
|
|
311
|
-
// pageCache per-tab: no longer cleared — each tab retains its cache
|
|
312
|
-
// Best-effort: mark the tab title with a green circle so the user can
|
|
313
|
-
// see which tab the agent attached to. CSP or detached frames may
|
|
314
|
-
// block the eval; we don't surface that as a switchTab failure.
|
|
315
272
|
await session.call("Runtime.evaluate", {
|
|
316
273
|
expression: safeJs`if(!document.title.startsWith('🟢'))document.title='🟢 '+document.title`,
|
|
317
274
|
});
|
|
@@ -319,17 +276,11 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
319
276
|
};
|
|
320
277
|
|
|
321
278
|
const newTab = async (url?: string): Promise<Result<string, CdpError>> => {
|
|
322
|
-
// Reconcile ownership against live targets before deciding where to open.
|
|
323
279
|
const tabsResult = await listTabs(true);
|
|
324
280
|
if (!tabsResult.success) return tabsResult;
|
|
325
281
|
|
|
326
|
-
// ensureHarnessWindow reuses the seed tab, falls back to any surviving
|
|
327
|
-
// owned tab, and only then creates a window — which, when a profile is
|
|
328
|
-
// pinned, means launching one inside that profile.
|
|
329
282
|
const window = await ensureHarnessWindow(api);
|
|
330
283
|
if (!window.success) return window;
|
|
331
|
-
// A freshly created window IS the new tab; opening another would leave a
|
|
332
|
-
// stray blank tab behind.
|
|
333
284
|
let tabId: string;
|
|
334
285
|
if (window.data.freshlyCreated) {
|
|
335
286
|
tabId = window.data.targetId;
|
|
@@ -356,9 +307,6 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
356
307
|
};
|
|
357
308
|
|
|
358
309
|
const closeOwnedTabs = async (): Promise<void> => {
|
|
359
|
-
// Snapshot first — remove() mutates the list, and targetDestroyed events
|
|
360
|
-
// may also prune concurrently. Failures are swallowed: a tab the user
|
|
361
|
-
// already closed, or a dead transport, must not block shutdown.
|
|
362
310
|
for (const id of ownership.list()) {
|
|
363
311
|
await session.callBrowser("Target.closeTarget", { targetId: id }).catch(() => {});
|
|
364
312
|
ownership.remove(id);
|
|
@@ -377,10 +325,6 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
377
325
|
const detach = async (): Promise<void> => {
|
|
378
326
|
const cur = session.current();
|
|
379
327
|
if (!cur) return;
|
|
380
|
-
// Target.detachFromTarget removes the "Chrome is being controlled" banner
|
|
381
|
-
// and releases the page session. The transport (WebSocket/Unix socket)
|
|
382
|
-
// stays alive for reuse by the next session.
|
|
383
|
-
// Reference: https://chromedevtools.github.io/devtools-protocol/tot/Target/#method-detachFromTarget
|
|
384
328
|
await transport.request("Target.detachFromTarget", { sessionId: cur.sessionId }, { sessionId: null });
|
|
385
329
|
};
|
|
386
330
|
|