@aivi/chrome 0.0.0-stage → 0.1.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/README.md CHANGED
@@ -1,3 +1,62 @@
1
- # Temporary Holding Version
1
+ # @aivi/chrome
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A puppeteer-shaped driver for the Chrome that is already installed. One
4
+ launch, one websocket, a dozen page verbs — and the full DevTools Protocol
5
+ still reachable underneath, because a wrapper that hides the protocol is a
6
+ leash.
7
+
8
+ Why not puppeteer: its npm tree is 32 MB (`chromium-bidi`, its own `zod`,
9
+ `devtools-protocol`, more) for helpers we can write in a page function. This
10
+ package has **no dependencies**: Node 26 ships `fetch`, `WebSocket`, and
11
+ `EventTarget` itself.
12
+
13
+ ## Use
14
+
15
+ ```ts
16
+ import { Chrome } from '@aivi/chrome'
17
+
18
+ const chrome = await Chrome.launch({ profileDir: '/tmp/aivi-browser' })
19
+ const tab = await chrome.newTab()
20
+
21
+ // the face trick: install before the page's own scripts run
22
+ await tab.send('Page.addScriptToEvaluateOnNewDocument', { source: faceSource })
23
+ await tab.goto('https://meet.google.com/abc-defg-hij')
24
+
25
+ await tab.waitForText('Ask to join')
26
+ await tab.fill('input[aria-label="Your name"]', 'aivi')
27
+ await tab.click('button[aria-label="Ask to join"]')
28
+ await tab.waitForText('Leave call')
29
+
30
+ await chrome.close() // ends the browser it launched
31
+ ```
32
+
33
+ `Chrome.attach({ debugUrl })` takes over a browser somebody else started;
34
+ its `close()` disconnects and leaves that browser running.
35
+
36
+ ## The verbs
37
+
38
+ | | |
39
+ | --- | --- |
40
+ | `evaluate(expr)` / `call(fn, ...args)` | run code in the page, plain values back |
41
+ | `waitFor(fn)` / `waitForSelector` / `waitForText` | poll until truthy, bounded |
42
+ | `exists` / `text` / `attr` / `url` | read the DOM |
43
+ | `click` / `fill` | act on it (`fill` does the native-setter dance frameworks need) |
44
+ | `screenshot` / `goto` / `close` | the rest of the day |
45
+ | `send(method, params)` / `on(event)` | the escape hatch: any command, any event, this tab |
46
+
47
+ Page functions travel as source text: they close over nothing from your
48
+ file, take data as arguments, and return plain values.
49
+
50
+ ## Honest edges
51
+
52
+ - Clicks are synthetic (`element.click()`). Proven on real Meet screens; an
53
+ app that demands trusted input gets `Input.dispatchMouseEvent` through
54
+ `send`.
55
+ - Timers are bounded one-shot waits for the two things the protocol has no
56
+ event for — "an element finally exists" and "the booting browser wrote its
57
+ port file". Nothing here repeats on a schedule.
58
+ - No silent reconnect: the socket to a browser you launched dies with it, on
59
+ purpose.
60
+ - Chrome 136+ ignores the debug port on the default profile, so a launch
61
+ always carries its own `--user-data-dir`.
62
+ - macOS and Linux. Windows: not yet, say so if you need it.
@@ -0,0 +1,62 @@
1
+ import { CdpConnection } from './connection.ts';
2
+ import { Tab } from './tab.ts';
3
+ /** Find the browser binary: `AIVI_CHROME_PATH` if set (a set-but-wrong value
4
+ * is a loud error, not a silent miss), then the usual paths, then PATH.
5
+ * Undefined means no browser found; the caller decides what that means. */
6
+ export declare function findChrome(): string | undefined;
7
+ export interface ChromeLaunchOptions {
8
+ /** A directory for this browser alone. Chrome refuses its debug port on
9
+ * the default profile, so this is not optional; a throwaway one is fine
10
+ * and `rm` then cleans up. */
11
+ profileDir: string;
12
+ /** Skip the search and say which binary to run. */
13
+ executablePath?: string;
14
+ /** Default true: `--headless=new`, the mode proven on the face route. */
15
+ headless?: boolean;
16
+ /** Appended after the default flags. */
17
+ args?: string[];
18
+ /** How long the browser gets to boot and answer. Default 20000. */
19
+ startupTimeoutMs?: number;
20
+ commandMaxTime?: number;
21
+ }
22
+ export interface ChromeAttachOptions {
23
+ /** Base URL of a browser already started with `--remote-debugging-port`.
24
+ * Default `http://127.0.0.1:9222`. */
25
+ debugUrl?: string;
26
+ /** The websocket URL straight from `/json/version`, when discovery is
27
+ * already done. */
28
+ wsUrl?: string;
29
+ /** How long to wait for the answer. Default 20000. */
30
+ timeoutMs?: number;
31
+ commandMaxTime?: number;
32
+ }
33
+ export declare class Chrome {
34
+ #private;
35
+ readonly connection: CdpConnection;
36
+ /** The browser's own version string, e.g. `Chrome/141.0.7390.107`. */
37
+ readonly browser: string;
38
+ /** The debug port, so an attach or a status line can name it. */
39
+ readonly port: number;
40
+ private constructor();
41
+ /** Launch the installed Chrome as a child of this process. */
42
+ static launch(options: ChromeLaunchOptions): Promise<Chrome>;
43
+ /** Take over a browser somebody else started with
44
+ * `--remote-debugging-port`. `close()` disconnects and leaves it running —
45
+ * it is not ours to kill. */
46
+ static attach(options?: ChromeAttachOptions): Promise<Chrome>;
47
+ /** True when this handle launched the browser and owns ending it. */
48
+ get launched(): boolean;
49
+ /** Notice the browser going away on its own — a crash, or a hand at the
50
+ * Activity Monitor. The listener fires once when the connection dies;
51
+ * our own `close()` does not count. A caller holding live state in the
52
+ * page (a meeting) uses this to say the state is over instead of
53
+ * knocking on a dead browser forever. */
54
+ onGone(listener: () => void): void;
55
+ /** Open a tab, blank on purpose: the caller installs its scripts before
56
+ * the first navigation — `tab.send('Page.addScriptToEvaluateOnNewDocument',
57
+ * ...)`, then `tab.goto(url)` — which is the whole face trick. */
58
+ newTab(): Promise<Tab>;
59
+ /** Disconnect, and end the browser when we launched it. An attached browser
60
+ * keeps running. */
61
+ close(): Promise<void>;
62
+ }
package/dist/chrome.js ADDED
@@ -0,0 +1,244 @@
1
+ /**
2
+ * The browser itself: launch the installed Chrome as our child, or attach to
3
+ * one somebody else started, and hand out tabs.
4
+ *
5
+ * Chrome 136 and later ignore `--remote-debugging-port` on the default
6
+ * profile, so a launch always carries its own `--user-data-dir` — which is
7
+ * also what lets several aivi browsers sit side by side, and keeps the
8
+ * operator's own browser out of it. We ask for port 0 and read the real port
9
+ * from the `DevToolsActivePort` file Chrome writes in the profile, so two
10
+ * launches never fight over a number.
11
+ *
12
+ * The default flags are the ones proven on real Meet screens: camera and mic
13
+ * prompts answer themselves, WebGL paints without a GPU, nothing waits for a
14
+ * human gesture. Further flags append after them.
15
+ */
16
+ import { spawn } from 'node:child_process';
17
+ import { existsSync } from 'node:fs';
18
+ import { mkdir, readFile, rm } from 'node:fs/promises';
19
+ import { delimiter, join } from 'node:path';
20
+ import { CdpConnection, sleep } from './connection.js';
21
+ import { Tab } from './tab.js';
22
+ /** Usual install paths per platform, after the `AIVI_CHROME_PATH`
23
+ * environment variable. No Windows: aivi's hosts are mac and linux. */
24
+ const CANDIDATES = {
25
+ darwin: [
26
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
27
+ '/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta',
28
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
29
+ ],
30
+ linux: [
31
+ '/usr/bin/google-chrome-stable',
32
+ '/usr/bin/google-chrome',
33
+ '/usr/bin/chromium',
34
+ '/usr/bin/chromium-browser',
35
+ '/snap/bin/chromium',
36
+ ],
37
+ };
38
+ const PATH_NAMES = ['google-chrome-stable', 'google-chrome', 'chromium', 'chromium-browser'];
39
+ const DEFAULT_ARGS = [
40
+ '--use-fake-ui-for-media-stream', // the camera and mic prompts answer themselves
41
+ '--autoplay-policy=no-user-gesture-required',
42
+ '--enable-unsafe-swiftshader', // WebGL without a GPU: the face is a WebGL canvas
43
+ '--disable-blink-features=AutomationControlled', // navigator.webdriver reads false, like a normal browser (CDP otherwise sets it true, and conferencing pages read it)
44
+ // the face animates on requestAnimationFrame, and Chrome throttles that
45
+ // clock for windows sitting behind others — the meeting tile froze on
46
+ // the last frame while our window was occluded (real meeting,
47
+ // 2026-10-14). These two flags keep our own window's clock honest.
48
+ '--disable-backgrounding-occluded-windows',
49
+ '--disable-renderer-backgrounding',
50
+ '--window-size=1280,720',
51
+ '--lang=en-US',
52
+ ];
53
+ const DEFAULT_STARTUP_TIMEOUT_MS = 20_000;
54
+ /** Find the browser binary: `AIVI_CHROME_PATH` if set (a set-but-wrong value
55
+ * is a loud error, not a silent miss), then the usual paths, then PATH.
56
+ * Undefined means no browser found; the caller decides what that means. */
57
+ export function findChrome() {
58
+ const fromEnv = process.env.AIVI_CHROME_PATH;
59
+ if (fromEnv !== undefined && fromEnv !== '') {
60
+ if (!existsSync(fromEnv))
61
+ throw new Error(`AIVI_CHROME_PATH points at nothing: ${fromEnv}`);
62
+ return fromEnv;
63
+ }
64
+ const known = [...(CANDIDATES[process.platform] ?? [])];
65
+ for (const name of PATH_NAMES) {
66
+ for (const dir of (process.env.PATH ?? '').split(delimiter)) {
67
+ if (dir !== '' && existsSync(join(dir, name)))
68
+ known.push(join(dir, name));
69
+ }
70
+ }
71
+ return known.find(path => existsSync(path));
72
+ }
73
+ function exited(child) {
74
+ return child.exitCode !== null || child.signalCode !== null;
75
+ }
76
+ function exitCodeOf(child) {
77
+ return child.exitCode !== null ? String(child.exitCode) : (child.signalCode ?? '?');
78
+ }
79
+ /** Wait for the port file Chrome writes in the profile, watching the process
80
+ * so a browser that dies on a bad flag fails here with its code, not with a
81
+ * timeout much later. */
82
+ async function readActivePort(portFile, child, timeoutMs) {
83
+ const deadline = Date.now() + timeoutMs;
84
+ do {
85
+ try {
86
+ const port = Number((await readFile(portFile, 'utf8')).split('\n')[0]?.trim());
87
+ if (Number.isInteger(port) && port > 0 && port < 65536)
88
+ return port;
89
+ }
90
+ catch {
91
+ // Chrome has not written the file yet
92
+ }
93
+ if (exited(child)) {
94
+ throw new Error(`chrome exited before reporting a port (code ${exitCodeOf(child)}) — check the flags`);
95
+ }
96
+ await sleep(50);
97
+ } while (Date.now() < deadline);
98
+ throw new Error(`no DevToolsActivePort in the profile within ${timeoutMs} ms`);
99
+ }
100
+ /** Ask `/json/version` until it answers, watching the process when there is
101
+ * one. The websocket URL is the browser's own — we never build it. */
102
+ async function discoverVersion(baseUrl, deadline, child) {
103
+ do {
104
+ try {
105
+ const response = await fetch(`${baseUrl}/json/version`);
106
+ if (response.ok) {
107
+ const info = (await response.json());
108
+ if (info.webSocketDebuggerUrl !== undefined)
109
+ return info;
110
+ }
111
+ }
112
+ catch {
113
+ // nothing listening yet
114
+ }
115
+ if (child !== undefined && exited(child)) {
116
+ throw new Error(`chrome exited before answering /json/version (code ${exitCodeOf(child)})`);
117
+ }
118
+ await sleep(100);
119
+ } while (Date.now() < deadline);
120
+ throw new Error(`the browser at ${baseUrl} never answered /json/version`);
121
+ }
122
+ /** SIGTERM so Chrome can flush the profile, SIGKILL only if it ignores
123
+ * that. One bounded wait either way. */
124
+ async function killChild(child) {
125
+ if (exited(child))
126
+ return;
127
+ child.kill('SIGTERM');
128
+ await new Promise(resolve => {
129
+ const killer = setTimeout(() => child.kill('SIGKILL'), 5_000);
130
+ child.once('exit', () => {
131
+ clearTimeout(killer);
132
+ resolve();
133
+ });
134
+ });
135
+ }
136
+ export class Chrome {
137
+ connection;
138
+ /** The browser's own version string, e.g. `Chrome/141.0.7390.107`. */
139
+ browser;
140
+ /** The debug port, so an attach or a status line can name it. */
141
+ port;
142
+ #child;
143
+ #closed = false;
144
+ constructor(connection, info, port, child) {
145
+ this.connection = connection;
146
+ this.browser = info.Browser ?? 'unknown';
147
+ this.port = port;
148
+ this.#child = child;
149
+ }
150
+ /** Launch the installed Chrome as a child of this process. */
151
+ static async launch(options) {
152
+ const bin = options.executablePath ?? findChrome();
153
+ if (bin === undefined)
154
+ throw new Error('no Chrome binary found — install Chrome or set AIVI_CHROME_PATH');
155
+ if (options.profileDir === '')
156
+ throw new Error('launch needs its own profileDir (Chrome ignores the debug port on the default profile)');
157
+ const startupTimeoutMs = options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS;
158
+ await mkdir(options.profileDir, { recursive: true });
159
+ const portFile = join(options.profileDir, 'DevToolsActivePort');
160
+ // a file from an earlier run would advertise a dead port
161
+ await rm(portFile, { force: true });
162
+ const flags = [
163
+ ...((options.headless ?? true) ? ['--headless=new'] : []),
164
+ `--user-data-dir=${options.profileDir}`,
165
+ '--remote-debugging-port=0',
166
+ '--no-first-run',
167
+ '--no-default-browser-check',
168
+ ...DEFAULT_ARGS,
169
+ ...(options.args ?? []),
170
+ // headless Chrome exits when its last tab closes, so one stays open
171
+ 'about:blank',
172
+ ];
173
+ const child = spawn(bin, flags, { stdio: 'ignore' });
174
+ try {
175
+ const port = await readActivePort(portFile, child, startupTimeoutMs);
176
+ const info = await discoverVersion(`http://127.0.0.1:${port}`, Date.now() + startupTimeoutMs, child);
177
+ const connection = await CdpConnection.open(info.webSocketDebuggerUrl ?? '', {
178
+ ...(options.commandMaxTime === undefined ? {} : { commandMaxTime: options.commandMaxTime }),
179
+ });
180
+ return new Chrome(connection, info, port, child);
181
+ }
182
+ catch (error) {
183
+ await killChild(child);
184
+ throw error;
185
+ }
186
+ }
187
+ /** Take over a browser somebody else started with
188
+ * `--remote-debugging-port`. `close()` disconnects and leaves it running —
189
+ * it is not ours to kill. */
190
+ static async attach(options = {}) {
191
+ const timeoutMs = options.timeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS;
192
+ let info = {};
193
+ let wsUrl = options.wsUrl;
194
+ if (wsUrl === undefined) {
195
+ const baseUrl = options.debugUrl ?? 'http://127.0.0.1:9222';
196
+ info = await discoverVersion(baseUrl, Date.now() + timeoutMs, undefined);
197
+ wsUrl = info.webSocketDebuggerUrl ?? '';
198
+ }
199
+ // the answer carries the real host and port, so trust it over the question
200
+ const port = Number(new URL(wsUrl).port);
201
+ const connection = await CdpConnection.open(wsUrl, {
202
+ ...(options.commandMaxTime === undefined ? {} : { commandMaxTime: options.commandMaxTime }),
203
+ });
204
+ return new Chrome(connection, info, port, undefined);
205
+ }
206
+ /** True when this handle launched the browser and owns ending it. */
207
+ get launched() {
208
+ return this.#child !== undefined;
209
+ }
210
+ /** Notice the browser going away on its own — a crash, or a hand at the
211
+ * Activity Monitor. The listener fires once when the connection dies;
212
+ * our own `close()` does not count. A caller holding live state in the
213
+ * page (a meeting) uses this to say the state is over instead of
214
+ * knocking on a dead browser forever. */
215
+ onGone(listener) {
216
+ this.connection.addEventListener('close', () => {
217
+ if (!this.#closed)
218
+ listener();
219
+ }, { once: true });
220
+ }
221
+ /** Open a tab, blank on purpose: the caller installs its scripts before
222
+ * the first navigation — `tab.send('Page.addScriptToEvaluateOnNewDocument',
223
+ * ...)`, then `tab.goto(url)` — which is the whole face trick. */
224
+ async newTab() {
225
+ const { targetId } = await this.connection.send('Target.createTarget', {
226
+ url: 'about:blank',
227
+ });
228
+ const { sessionId } = await this.connection.send('Target.attachToTarget', {
229
+ targetId,
230
+ flatten: true,
231
+ });
232
+ return new Tab(this.connection, targetId, sessionId);
233
+ }
234
+ /** Disconnect, and end the browser when we launched it. An attached browser
235
+ * keeps running. */
236
+ async close() {
237
+ if (this.#closed)
238
+ return;
239
+ this.#closed = true;
240
+ this.connection.close();
241
+ if (this.#child !== undefined)
242
+ await killChild(this.#child);
243
+ }
244
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * One websocket to one Chrome browser: the bottom of the protocol. Commands
3
+ * are matched to their answers by id. A command carrying a session id
4
+ * addresses one tab; without one it addresses the browser itself. Events
5
+ * arrive named `Domain.event` and carry the session they belong to.
6
+ *
7
+ * There is no silent reconnection. A socket to a browser we launched dies
8
+ * with it, and hiding that would turn a dead meeting into a hung process:
9
+ * calls still waiting reject as closed, and the `close` event says it
10
+ * happened.
11
+ */
12
+ /** A protocol answer or a connection trouble, either way with a `code`.
13
+ * Answers from the browser carry the protocol's numeric codes (e.g. -32601
14
+ * for a method that does not exist); our own troubles use the string
15
+ * constants exported below. */
16
+ export declare class CdpError extends Error {
17
+ readonly code: number | string;
18
+ constructor(message: string, code: number | string);
19
+ }
20
+ export declare const CONNECTION_CLOSED = "connectionClosed";
21
+ export declare const CONNECTION_REFUSED = "connectionRefused";
22
+ export declare const CONNECTION_TIMED_OUT = "connectionTimedOut";
23
+ export declare const COMMAND_TIMED_OUT = "commandTimedOut";
24
+ /** A protocol event (`Page.loadEventFired` and friends). `sessionId` is the
25
+ * tab the event came from, undefined for browser-level events. */
26
+ export declare class CdpEvent extends Event {
27
+ readonly params: Record<string, unknown> | undefined;
28
+ readonly sessionId: string | undefined;
29
+ constructor(type: string, init?: {
30
+ params?: Record<string, unknown> | undefined;
31
+ sessionId?: string | undefined;
32
+ });
33
+ }
34
+ /** A one-shot wait. This package's only timer: polling is honest when the
35
+ * protocol reports events like "the page loaded" but not "an element
36
+ * finally exists", and puppeteer and playwright poll the same way. Nothing
37
+ * here repeats on a schedule; every sleep belongs to a bounded wait that a
38
+ * caller started and can end. */
39
+ export declare function sleep(ms: number): Promise<void>;
40
+ export interface CdpConnectionOptions {
41
+ /** Reject a command whose answer never arrives, after this many
42
+ * milliseconds. The connection stays usable: the protocol cannot cancel a
43
+ * command once it is sent, so the browser may still have acted on it.
44
+ * Default 30000, 0 to disable. */
45
+ commandMaxTime?: number;
46
+ /** Bound the websocket handshake itself. Default 10000. */
47
+ connectMaxTime?: number;
48
+ }
49
+ export declare class CdpConnection extends EventTarget {
50
+ #private;
51
+ private constructor();
52
+ /** Open the socket and fail fast if nothing answers. A caller waiting for
53
+ * a booting browser retries at its own level, where it also watches that
54
+ * browser's process. */
55
+ static open(wsUrl: string, options?: CdpConnectionOptions): Promise<CdpConnection>;
56
+ get closed(): boolean;
57
+ send<T = unknown>(method: string, params?: object, sessionId?: string): Promise<T>;
58
+ /** Close the socket. Waiting calls reject; listeners are not kept, so a
59
+ * consumer that wants to reconnect builds it with its own knowledge. */
60
+ close(): void;
61
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * One websocket to one Chrome browser: the bottom of the protocol. Commands
3
+ * are matched to their answers by id. A command carrying a session id
4
+ * addresses one tab; without one it addresses the browser itself. Events
5
+ * arrive named `Domain.event` and carry the session they belong to.
6
+ *
7
+ * There is no silent reconnection. A socket to a browser we launched dies
8
+ * with it, and hiding that would turn a dead meeting into a hung process:
9
+ * calls still waiting reject as closed, and the `close` event says it
10
+ * happened.
11
+ */
12
+ /** A protocol answer or a connection trouble, either way with a `code`.
13
+ * Answers from the browser carry the protocol's numeric codes (e.g. -32601
14
+ * for a method that does not exist); our own troubles use the string
15
+ * constants exported below. */
16
+ export class CdpError extends Error {
17
+ code;
18
+ constructor(message, code) {
19
+ super(message);
20
+ this.name = 'CdpError';
21
+ this.code = code;
22
+ }
23
+ }
24
+ export const CONNECTION_CLOSED = 'connectionClosed';
25
+ export const CONNECTION_REFUSED = 'connectionRefused';
26
+ export const CONNECTION_TIMED_OUT = 'connectionTimedOut';
27
+ export const COMMAND_TIMED_OUT = 'commandTimedOut';
28
+ /** A protocol event (`Page.loadEventFired` and friends). `sessionId` is the
29
+ * tab the event came from, undefined for browser-level events. */
30
+ export class CdpEvent extends Event {
31
+ params;
32
+ sessionId;
33
+ constructor(type, init = {}) {
34
+ super(type);
35
+ this.params = init.params;
36
+ this.sessionId = init.sessionId;
37
+ }
38
+ }
39
+ /** A one-shot wait. This package's only timer: polling is honest when the
40
+ * protocol reports events like "the page loaded" but not "an element
41
+ * finally exists", and puppeteer and playwright poll the same way. Nothing
42
+ * here repeats on a schedule; every sleep belongs to a bounded wait that a
43
+ * caller started and can end. */
44
+ export function sleep(ms) {
45
+ return new Promise(resolve => setTimeout(resolve, ms));
46
+ }
47
+ const DEFAULT_COMMAND_MAX_TIME = 30_000;
48
+ const DEFAULT_CONNECT_MAX_TIME = 10_000;
49
+ export class CdpConnection extends EventTarget {
50
+ #wsUrl;
51
+ #commandMaxTime;
52
+ #connectMaxTime;
53
+ #pending = new Map();
54
+ #ws;
55
+ #nextId = 0;
56
+ #closed = false;
57
+ constructor(wsUrl, options) {
58
+ super();
59
+ this.#wsUrl = wsUrl;
60
+ this.#commandMaxTime = options.commandMaxTime ?? DEFAULT_COMMAND_MAX_TIME;
61
+ this.#connectMaxTime = options.connectMaxTime ?? DEFAULT_CONNECT_MAX_TIME;
62
+ }
63
+ /** Open the socket and fail fast if nothing answers. A caller waiting for
64
+ * a booting browser retries at its own level, where it also watches that
65
+ * browser's process. */
66
+ static async open(wsUrl, options = {}) {
67
+ const connection = new CdpConnection(wsUrl, options);
68
+ await connection.#connect();
69
+ return connection;
70
+ }
71
+ get closed() {
72
+ return this.#closed;
73
+ }
74
+ #connect() {
75
+ const ws = new WebSocket(this.#wsUrl);
76
+ this.#ws = ws;
77
+ ws.addEventListener('message', event => this.#onMessage(event.data));
78
+ ws.addEventListener('close', event => {
79
+ this.#ws = undefined;
80
+ this.#drain(event.reason === '' ? undefined : ` (${event.reason})`);
81
+ if (!this.#closed) {
82
+ this.#closed = true;
83
+ this.dispatchEvent(new Event('close'));
84
+ }
85
+ });
86
+ return new Promise((resolve, reject) => {
87
+ let settled = false;
88
+ let timer;
89
+ const fail = (error) => {
90
+ if (settled)
91
+ return;
92
+ settled = true;
93
+ clearTimeout(timer);
94
+ ws.close();
95
+ reject(error);
96
+ };
97
+ timer = setTimeout(() => fail(new CdpError(`socket not opened within ${this.#connectMaxTime} ms`, CONNECTION_TIMED_OUT)), this.#connectMaxTime);
98
+ ws.addEventListener('open', () => {
99
+ if (settled)
100
+ return;
101
+ settled = true;
102
+ clearTimeout(timer);
103
+ resolve();
104
+ }, { once: true });
105
+ ws.addEventListener('error', () => fail(new CdpError(`could not reach the browser at ${this.#wsUrl}`, CONNECTION_REFUSED)), { once: true });
106
+ });
107
+ }
108
+ send(method, params = {}, sessionId) {
109
+ const ws = this.#ws;
110
+ if (this.#closed || ws === undefined || ws.readyState !== WebSocket.OPEN) {
111
+ return Promise.reject(new CdpError(`connection closed when calling ${method}`, CONNECTION_CLOSED));
112
+ }
113
+ const id = this.#nextId;
114
+ this.#nextId = (this.#nextId + 1) % Number.MAX_SAFE_INTEGER;
115
+ const message = { id, method, params };
116
+ if (sessionId !== undefined)
117
+ message.sessionId = sessionId;
118
+ return new Promise((resolve, reject) => {
119
+ const timer = this.#commandMaxTime === 0
120
+ ? undefined
121
+ : setTimeout(() => {
122
+ this.#pending.delete(id);
123
+ reject(new CdpError(`no answer for ${method} within ${this.#commandMaxTime} ms`, COMMAND_TIMED_OUT));
124
+ }, this.#commandMaxTime);
125
+ this.#pending.set(id, { method, resolve: resolve, reject, timer });
126
+ try {
127
+ ws.send(JSON.stringify(message));
128
+ }
129
+ catch (error) {
130
+ if (timer !== undefined)
131
+ clearTimeout(timer);
132
+ this.#pending.delete(id);
133
+ reject(new CdpError(`send failed when calling ${method}: ${String(error)}`, CONNECTION_CLOSED));
134
+ }
135
+ });
136
+ }
137
+ /** Close the socket. Waiting calls reject; listeners are not kept, so a
138
+ * consumer that wants to reconnect builds it with its own knowledge. */
139
+ close() {
140
+ if (this.#closed)
141
+ return;
142
+ this.#closed = true;
143
+ this.#drain(' by us');
144
+ this.#ws?.close();
145
+ this.dispatchEvent(new Event('close'));
146
+ }
147
+ #onMessage(raw) {
148
+ let message;
149
+ try {
150
+ message = JSON.parse(raw);
151
+ }
152
+ catch {
153
+ return;
154
+ }
155
+ if (message.id !== undefined) {
156
+ const pending = this.#pending.get(message.id);
157
+ if (pending !== undefined) {
158
+ this.#pending.delete(message.id);
159
+ if (pending.timer !== undefined)
160
+ clearTimeout(pending.timer);
161
+ if (message.error === undefined) {
162
+ pending.resolve(message.result);
163
+ }
164
+ else {
165
+ pending.reject(new CdpError(`${message.error.message ?? 'protocol error'} when calling ${pending.method}`, message.error.code ?? -32000));
166
+ }
167
+ }
168
+ }
169
+ if (message.method !== undefined) {
170
+ this.dispatchEvent(new CdpEvent(message.method, { params: message.params, sessionId: message.sessionId }));
171
+ }
172
+ }
173
+ #drain(reasonSuffix) {
174
+ for (const pending of this.#pending.values()) {
175
+ if (pending.timer !== undefined)
176
+ clearTimeout(pending.timer);
177
+ pending.reject(new CdpError(`connection closed${reasonSuffix ?? ''} when calling ${pending.method}`, CONNECTION_CLOSED));
178
+ }
179
+ this.#pending.clear();
180
+ }
181
+ }
@@ -0,0 +1,3 @@
1
+ export { Chrome, type ChromeAttachOptions, type ChromeLaunchOptions, findChrome } from './chrome.ts';
2
+ export { CdpConnection, type CdpConnectionOptions, CdpError, CdpEvent, COMMAND_TIMED_OUT, CONNECTION_CLOSED, CONNECTION_REFUSED, CONNECTION_TIMED_OUT, sleep, } from './connection.ts';
3
+ export { type Locator, type PageFunction, Tab, type WaitOptions } from './tab.ts';
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { Chrome, findChrome } from './chrome.js';
2
+ export { CdpConnection, CdpError, CdpEvent, COMMAND_TIMED_OUT, CONNECTION_CLOSED, CONNECTION_REFUSED, CONNECTION_TIMED_OUT, sleep, } from './connection.js';
3
+ export { Tab } from './tab.js';
package/dist/tab.d.ts ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * One tab, addressed by the session id the browser handed us.
3
+ *
4
+ * The helpers are the everyday verbs — read, wait, click, type — and every
5
+ * one of them is the same trick: a small function, sent as source text and
6
+ * run inside the page by `Runtime.evaluate`, whose return value comes back
7
+ * as a plain value. A page function must not close over anything from this
8
+ * file; data goes in as arguments, and only plain values come back. That
9
+ * is why the locators below carry their matcher as an argument and their
10
+ * finder as a self-contained function, not as shared code.
11
+ *
12
+ * Waiting is the package's job, never the caller's. `tab.locator(matcher)`
13
+ * hands back a handle whose verbs wait for the page to be ready for them:
14
+ * `click()` does not fire until the element exists AND is enabled, the way
15
+ * playwright's actionability works
16
+ * (https://playwright.dev/docs/actionability). A framework keeps its own
17
+ * submit buttons disabled until it has accepted what was typed — so the
18
+ * wait for enabled is the verification that the page will listen, and no
19
+ * caller ever clicks in a loop hoping one lands.
20
+ *
21
+ * Whatever the helpers do not cover is one `send` away: the full protocol
22
+ * stays reachable per tab, because the face trick itself — installing a
23
+ * script before the page's own scripts run — is exactly such a raw call.
24
+ */
25
+ import type { CdpConnection, CdpEvent } from './connection.ts';
26
+ /** A function that runs inside the page. It travels as source text, so it
27
+ * closes over nothing from here. */
28
+ export type PageFunction<T = unknown> = (...args: never[]) => T;
29
+ export interface WaitOptions {
30
+ /** Give up after this many milliseconds. Default 10000. */
31
+ timeoutMs?: number;
32
+ /** Pause between looks. Default 120. */
33
+ intervalMs?: number;
34
+ }
35
+ /** A waiting handle on an element, in the shape playwright made popular:
36
+ * get it once from `tab.locator(matcher)`, and every verb on it waits
37
+ * for the page to be ready instead of the caller guessing in a loop. */
38
+ export interface Locator {
39
+ /** The matcher it was built from, for logs and errors. */
40
+ readonly matcher: string;
41
+ /** There right now — disabled counts. No waiting. */
42
+ exists(): Promise<boolean>;
43
+ /** There right now AND able to take a click. No waiting. */
44
+ enabled(): Promise<boolean>;
45
+ /** One attribute's value, right now; undefined when the element or the
46
+ * attribute is absent. The page's own state often lives in attributes
47
+ * (`data-is-muted` on Meet's mic button) where names cannot see it. */
48
+ attr(name: string): Promise<string | undefined>;
49
+ /** Wait until it is there. */
50
+ waitFor(options?: WaitOptions): Promise<void>;
51
+ /** Wait until it is there and enabled, then click it ONCE, and answer
52
+ * the words it carries (aria-label, text, or tag). A framework keeps
53
+ * its submit buttons disabled until it accepts what was typed, so this
54
+ * wait is the proof the page will listen — the old failure was clicking
55
+ * a still-hydrating screen, reporting success, and then waiting on a
56
+ * human for an action the page never received (real Meet, 2026-10-14). */
57
+ click(options?: WaitOptions): Promise<string>;
58
+ /** Wait until it is there, then type into it the way its framework
59
+ * expects. */
60
+ fill(value: string, options?: WaitOptions): Promise<void>;
61
+ }
62
+ export declare class Tab {
63
+ #private;
64
+ constructor(connection: CdpConnection, targetId: string, sessionId: string);
65
+ get targetId(): string;
66
+ get sessionId(): string;
67
+ /** The escape hatch: any protocol command, addressed to this tab. */
68
+ send<T = unknown>(method: string, params?: object): Promise<T>;
69
+ /** Listen for a protocol event of this tab. The listener sees only events
70
+ * carrying our session id. */
71
+ on(event: string, listener: (event: CdpEvent) => void): this;
72
+ off(event: string, listener: (event: CdpEvent) => void): this;
73
+ /** Navigate and wait for the load event. Blank on arrival is normal for a
74
+ * fresh tab: the caller installs its scripts first, then drives here. */
75
+ goto(url: string, options?: {
76
+ timeoutMs?: number;
77
+ }): Promise<void>;
78
+ /** Run an expression in the page and take its value back. */
79
+ evaluate<T = unknown>(expression: string): Promise<T>;
80
+ /** Run a function in the page and take its value back. A string is sent as
81
+ * source, for text that was built elsewhere. Arguments travel as JSON —
82
+ * the same rule as return values, so a page function truly cannot close
83
+ * over anything or carry anything but plain data. `Runtime.evaluate` in a
84
+ * wrapped expression is used instead of `callFunctionOn` because it works
85
+ * from the first breath of a fresh tab, before any execution context is
86
+ * announced. */
87
+ call<T = unknown>(fn: PageFunction<T> | string, ...args: unknown[]): Promise<T>;
88
+ /** Poll until the condition answers truthy, and take that answer. CDP
89
+ * reports "the page loaded", never "this element finally exists", so
90
+ * polling is the honest mechanism — bounded, one-shot sleeps. Extra
91
+ * arguments travel to the page function as plain JSON, the same rule
92
+ * as `call`. */
93
+ waitFor<T>(condition: PageFunction<T> | string, options?: WaitOptions, ...args: unknown[]): Promise<NonNullable<T>>;
94
+ waitForSelector(selector: string, options?: WaitOptions): Promise<void>;
95
+ waitForText(needle: string, options?: WaitOptions): Promise<void>;
96
+ /** A waiting handle on the first element the matcher describes: a CSS
97
+ * selector, or `name:` followed by a case-insensitive regex the
98
+ * accessible name of a button must match (`aria-label` first, then its
99
+ * text). Buttons that live behind accessible names and inputs addressed
100
+ * by CSS hand back the same kind of handle, and every verb on it waits
101
+ * before it acts. */
102
+ locator(matcher: string): Locator;
103
+ /** Whether anything matches, right now. */
104
+ exists(matcher: string): Promise<boolean>;
105
+ /** The words on the page, as a person reads them. */
106
+ words(): Promise<string>;
107
+ /** Wait until the element is there and enabled, click it once, answer
108
+ * its words. For more than one verb on the same element, keep the
109
+ * handle: `tab.locator(matcher)`. */
110
+ click(matcher: string, options?: WaitOptions): Promise<string>;
111
+ /** Type into a box the way its framework expects, after waiting for it
112
+ * to be there. */
113
+ fill(matcher: string, value: string, options?: WaitOptions): Promise<void>;
114
+ /** The visible text of the first match, or undefined when there is none. */
115
+ text(selector: string): Promise<string | undefined>;
116
+ attr(selector: string, name: string): Promise<string | undefined>;
117
+ url(): Promise<string>;
118
+ screenshot(): Promise<Buffer>;
119
+ /** Make the page unable to tell headless from headed: `HeadlessChrome/`
120
+ * in the user agent is a fingerprint conferencing apps read. The launch
121
+ * flag keeps `navigator.webdriver` false NATIVELY — patching it with a
122
+ * script is itself the tell (measured 2026-10-14: a defineProperty
123
+ * getter on Navigator.prototype alone got us refused by Meet; the page
124
+ * inspects the getter's source). The override carries the browser's
125
+ * own client-hint metadata from `Browser.getVersion`, so the story the
126
+ * page reads is self-consistent and none of it is invented. Answers
127
+ * false when there was nothing to hide. */
128
+ hideHeadless(): Promise<boolean>;
129
+ /** Close the tab. The browser itself outlives it, so another tab can open
130
+ * in a moment. */
131
+ close(): Promise<void>;
132
+ }
package/dist/tab.js ADDED
@@ -0,0 +1,329 @@
1
+ import { sleep } from './connection.js';
2
+ const DEFAULT_WAIT_TIMEOUT_MS = 10_000;
3
+ const DEFAULT_WAIT_INTERVAL_MS = 120;
4
+ function unwrap(answer) {
5
+ const details = answer.exceptionDetails;
6
+ if (details !== undefined) {
7
+ throw new Error(details.exception?.description ?? details.text ?? 'the page function threw');
8
+ }
9
+ return answer.result?.value;
10
+ }
11
+ function labelOf(condition) {
12
+ const source = typeof condition === 'string' ? condition : condition.toString();
13
+ return source.length > 120 ? `${source.slice(0, 120)}…` : source;
14
+ }
15
+ /** The package's one way to wait: look, and if not yet, look again after a
16
+ * pause. CDP announces "the page loaded", never "this element finally
17
+ * exists", so bounded re-looking is the honest mechanism. */
18
+ async function poll(look, what, options) {
19
+ const timeoutMs = options.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS;
20
+ const intervalMs = options.intervalMs ?? DEFAULT_WAIT_INTERVAL_MS;
21
+ const deadline = Date.now() + timeoutMs;
22
+ do {
23
+ if (await look())
24
+ return;
25
+ await sleep(intervalMs);
26
+ } while (Date.now() < deadline);
27
+ throw new Error(`no ${what} within ${timeoutMs} ms`);
28
+ }
29
+ /** One page-side look: the first element the matcher describes. `name:`
30
+ * matches a button's accessible name — `aria-label` first, then its text,
31
+ * what real apps like Meet put on their buttons — as a case-insensitive
32
+ * regex; anything else is a CSS selector. Answers the element's own words,
33
+ * whether it can take a click, and (when asked for) one attribute. */
34
+ const locate = (kind, matcher, attribute) => {
35
+ const words = (el) => (el.getAttribute('aria-label') || el.textContent || '').trim();
36
+ const live = (el) => !el.disabled && el.getAttribute('aria-disabled') !== 'true';
37
+ let el;
38
+ if (kind === 'name') {
39
+ const rx = new RegExp(matcher, 'i');
40
+ el = Array.from(document.querySelectorAll('button, [role="button"]')).find(e => rx.test(words(e)));
41
+ }
42
+ else {
43
+ el = document.querySelector(matcher) ?? undefined;
44
+ }
45
+ if (el === undefined)
46
+ return null;
47
+ return {
48
+ label: words(el) || el.tagName.toLowerCase(),
49
+ enabled: live(el),
50
+ attr: attribute === '' ? undefined : (el.getAttribute(attribute) ?? undefined),
51
+ };
52
+ };
53
+ /** Find the first element the matcher describes that is ENABLED, and click
54
+ * it — one page-side move, no window between looking and touching where
55
+ * the element could change its mind. Null when there was nothing to click
56
+ * (absent, or there but disabled). */
57
+ const clickLocated = (kind, matcher) => {
58
+ const words = (el) => (el.getAttribute('aria-label') || el.textContent || '').trim();
59
+ const live = (el) => !el.disabled && el.getAttribute('aria-disabled') !== 'true';
60
+ let el;
61
+ if (kind === 'name') {
62
+ const rx = new RegExp(matcher, 'i');
63
+ el = Array.from(document.querySelectorAll('button, [role="button"]')).find(e => rx.test(words(e)) && live(e));
64
+ }
65
+ else {
66
+ const found = document.querySelector(matcher);
67
+ if (found !== null && live(found))
68
+ el = found;
69
+ }
70
+ if (el === undefined)
71
+ return null;
72
+ el.click();
73
+ return words(el) || el.tagName.toLowerCase();
74
+ };
75
+ /** Type into a box the way its framework expects: set the value through the
76
+ * native setter, then dispatch `input`. A plain `.value =` leaves
77
+ * frameworks like Meet's convinced the box is still empty — proven on the
78
+ * real waiting-room name box. */
79
+ const fillLocated = (kind, matcher, value) => {
80
+ const el = kind === 'name'
81
+ ? Array.from(document.querySelectorAll('button, [role="button"]')).find(e => new RegExp(matcher, 'i').test((e.getAttribute('aria-label') || e.textContent || '').trim()))
82
+ : document.querySelector(matcher);
83
+ if (el === null || el === undefined)
84
+ return 'absent';
85
+ if (!(el instanceof HTMLInputElement))
86
+ return 'not-an-input';
87
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
88
+ if (setter === undefined)
89
+ return 'not-an-input';
90
+ setter.call(el, value);
91
+ el.dispatchEvent(new Event('input', { bubbles: true }));
92
+ return 'filled';
93
+ };
94
+ class LocatorHandle {
95
+ #tab;
96
+ matcher;
97
+ #kind;
98
+ #body;
99
+ constructor(tab, matcher) {
100
+ this.#tab = tab;
101
+ this.matcher = matcher;
102
+ this.#kind = matcher.startsWith('name:') ? 'name' : 'css';
103
+ this.#body = this.#kind === 'name' ? matcher.slice('name:'.length) : matcher;
104
+ }
105
+ async #probe(attribute = '') {
106
+ return this.#tab.call(locate, this.#kind, this.#body, attribute);
107
+ }
108
+ async exists() {
109
+ return (await this.#probe()) !== null;
110
+ }
111
+ async enabled() {
112
+ return (await this.#probe())?.enabled === true;
113
+ }
114
+ async attr(name) {
115
+ return (await this.#probe(name))?.attr;
116
+ }
117
+ async waitFor(options = {}) {
118
+ await poll(async () => (await this.#probe()) !== null, `element ${this.matcher}`, options);
119
+ }
120
+ async click(options = {}) {
121
+ const timeoutMs = options.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS;
122
+ const intervalMs = options.intervalMs ?? DEFAULT_WAIT_INTERVAL_MS;
123
+ const deadline = Date.now() + timeoutMs;
124
+ do {
125
+ const hit = await this.#tab.call(clickLocated, this.#kind, this.#body);
126
+ if (hit !== null)
127
+ return hit;
128
+ await sleep(intervalMs);
129
+ } while (Date.now() < deadline);
130
+ const seen = await this.#probe();
131
+ throw new Error(seen === null
132
+ ? `no element matching ${this.matcher} within ${timeoutMs} ms`
133
+ : `${this.matcher} stayed disabled for ${timeoutMs} ms — the page never took it`);
134
+ }
135
+ async fill(value, options = {}) {
136
+ await this.waitFor(options);
137
+ const answer = await this.#tab.call(fillLocated, this.#kind, this.#body, value);
138
+ if (answer === 'absent')
139
+ throw new Error(`${this.matcher} vanished before the fill`);
140
+ if (answer === 'not-an-input')
141
+ throw new Error(`cannot fill ${this.matcher}: it is not an <input>`);
142
+ }
143
+ }
144
+ export class Tab {
145
+ #connection;
146
+ #targetId;
147
+ #sessionId;
148
+ #listeners = new Map();
149
+ #pageEnabled = false;
150
+ constructor(connection, targetId, sessionId) {
151
+ this.#connection = connection;
152
+ this.#targetId = targetId;
153
+ this.#sessionId = sessionId;
154
+ }
155
+ get targetId() {
156
+ return this.#targetId;
157
+ }
158
+ get sessionId() {
159
+ return this.#sessionId;
160
+ }
161
+ /** The escape hatch: any protocol command, addressed to this tab. */
162
+ send(method, params = {}) {
163
+ return this.#connection.send(method, params, this.#sessionId);
164
+ }
165
+ /** Listen for a protocol event of this tab. The listener sees only events
166
+ * carrying our session id. */
167
+ on(event, listener) {
168
+ const wrapper = raw => {
169
+ const event = raw;
170
+ if (event.sessionId === this.#sessionId)
171
+ listener(event);
172
+ };
173
+ this.#listeners.set(listener, wrapper);
174
+ this.#connection.addEventListener(event, wrapper);
175
+ return this;
176
+ }
177
+ off(event, listener) {
178
+ const wrapper = this.#listeners.get(listener);
179
+ if (wrapper !== undefined) {
180
+ this.#connection.removeEventListener(event, wrapper);
181
+ this.#listeners.delete(listener);
182
+ }
183
+ return this;
184
+ }
185
+ #once(event, timeoutMs) {
186
+ return new Promise((resolve, reject) => {
187
+ const listener = (arrived) => {
188
+ this.off(event, listener);
189
+ clearTimeout(timer);
190
+ resolve(arrived);
191
+ };
192
+ const timer = setTimeout(() => {
193
+ this.off(event, listener);
194
+ reject(new Error(`no ${event} within ${timeoutMs} ms`));
195
+ }, timeoutMs);
196
+ this.on(event, listener);
197
+ });
198
+ }
199
+ /** Navigate and wait for the load event. Blank on arrival is normal for a
200
+ * fresh tab: the caller installs its scripts first, then drives here. */
201
+ async goto(url, options = {}) {
202
+ const timeoutMs = options.timeoutMs ?? 30_000;
203
+ if (!this.#pageEnabled) {
204
+ await this.send('Page.enable');
205
+ this.#pageEnabled = true;
206
+ }
207
+ const loaded = this.#once('Page.loadEventFired', timeoutMs);
208
+ const answer = await this.send('Page.navigate', { url });
209
+ if (answer.errorText !== undefined) {
210
+ throw new Error(`navigation to ${url} failed: ${answer.errorText}`);
211
+ }
212
+ await loaded;
213
+ }
214
+ /** Run an expression in the page and take its value back. */
215
+ async evaluate(expression) {
216
+ return unwrap(await this.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }));
217
+ }
218
+ /** Run a function in the page and take its value back. A string is sent as
219
+ * source, for text that was built elsewhere. Arguments travel as JSON —
220
+ * the same rule as return values, so a page function truly cannot close
221
+ * over anything or carry anything but plain data. `Runtime.evaluate` in a
222
+ * wrapped expression is used instead of `callFunctionOn` because it works
223
+ * from the first breath of a fresh tab, before any execution context is
224
+ * announced. */
225
+ async call(fn, ...args) {
226
+ const source = typeof fn === 'string' ? fn : fn.toString();
227
+ const given = args.map(value => JSON.stringify(value) ?? 'undefined');
228
+ return unwrap(await this.send('Runtime.evaluate', {
229
+ expression: `(${source})(${given.join(', ')})`,
230
+ awaitPromise: true,
231
+ returnByValue: true,
232
+ }));
233
+ }
234
+ /** Poll until the condition answers truthy, and take that answer. CDP
235
+ * reports "the page loaded", never "this element finally exists", so
236
+ * polling is the honest mechanism — bounded, one-shot sleeps. Extra
237
+ * arguments travel to the page function as plain JSON, the same rule
238
+ * as `call`. */
239
+ async waitFor(condition, options = {}, ...args) {
240
+ return this.#wait(condition, args, options);
241
+ }
242
+ async waitForSelector(selector, options = {}) {
243
+ await this.#wait((sel) => document.querySelector(sel) !== null, [selector], options);
244
+ }
245
+ async waitForText(needle, options = {}) {
246
+ await this.#wait((text) => (document.body?.innerText ?? '').includes(text), [needle], options);
247
+ }
248
+ async #wait(condition, args, options) {
249
+ let answer;
250
+ await poll(async () => {
251
+ answer = await this.call(condition, ...args);
252
+ return Boolean(answer);
253
+ }, labelOf(condition), options);
254
+ return answer;
255
+ }
256
+ /** A waiting handle on the first element the matcher describes: a CSS
257
+ * selector, or `name:` followed by a case-insensitive regex the
258
+ * accessible name of a button must match (`aria-label` first, then its
259
+ * text). Buttons that live behind accessible names and inputs addressed
260
+ * by CSS hand back the same kind of handle, and every verb on it waits
261
+ * before it acts. */
262
+ locator(matcher) {
263
+ return new LocatorHandle(this, matcher);
264
+ }
265
+ /** Whether anything matches, right now. */
266
+ async exists(matcher) {
267
+ return this.locator(matcher).exists();
268
+ }
269
+ /** The words on the page, as a person reads them. */
270
+ async words() {
271
+ return this.call(() => {
272
+ const body = document.body;
273
+ return body === null ? '' : body.innerText || body.textContent || '';
274
+ });
275
+ }
276
+ /** Wait until the element is there and enabled, click it once, answer
277
+ * its words. For more than one verb on the same element, keep the
278
+ * handle: `tab.locator(matcher)`. */
279
+ click(matcher, options = {}) {
280
+ return this.locator(matcher).click(options);
281
+ }
282
+ /** Type into a box the way its framework expects, after waiting for it
283
+ * to be there. */
284
+ async fill(matcher, value, options = {}) {
285
+ await this.locator(matcher).fill(value, options);
286
+ }
287
+ /** The visible text of the first match, or undefined when there is none. */
288
+ async text(selector) {
289
+ return this.call((sel) => {
290
+ const el = document.querySelector(sel);
291
+ return el === null ? undefined : el.innerText;
292
+ }, selector);
293
+ }
294
+ async attr(selector, name) {
295
+ return this.call((sel, attribute) => document.querySelector(sel)?.getAttribute(attribute) ?? undefined, selector, name);
296
+ }
297
+ async url() {
298
+ return this.evaluate('location.href');
299
+ }
300
+ async screenshot() {
301
+ const { data } = await this.send('Page.captureScreenshot', { format: 'png' });
302
+ return Buffer.from(data, 'base64');
303
+ }
304
+ /** Make the page unable to tell headless from headed: `HeadlessChrome/`
305
+ * in the user agent is a fingerprint conferencing apps read. The launch
306
+ * flag keeps `navigator.webdriver` false NATIVELY — patching it with a
307
+ * script is itself the tell (measured 2026-10-14: a defineProperty
308
+ * getter on Navigator.prototype alone got us refused by Meet; the page
309
+ * inspects the getter's source). The override carries the browser's
310
+ * own client-hint metadata from `Browser.getVersion`, so the story the
311
+ * page reads is self-consistent and none of it is invented. Answers
312
+ * false when there was nothing to hide. */
313
+ async hideHeadless() {
314
+ const ua = await this.call(() => navigator.userAgent);
315
+ if (!ua.includes('HeadlessChrome/'))
316
+ return false;
317
+ const { userAgentMetadata } = (await this.send('Browser.getVersion'));
318
+ await this.send('Network.setUserAgentOverride', {
319
+ userAgent: ua.replace('HeadlessChrome/', 'Chrome/'),
320
+ ...(userAgentMetadata === undefined ? {} : { userAgentMetadata }),
321
+ });
322
+ return true;
323
+ }
324
+ /** Close the tab. The browser itself outlives it, so another tab can open
325
+ * in a moment. */
326
+ async close() {
327
+ await this.#connection.send('Target.closeTarget', { targetId: this.#targetId });
328
+ }
329
+ }
package/package.json CHANGED
@@ -1,6 +1,31 @@
1
1
  {
2
2
  "name": "@aivi/chrome",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "a puppeteer-shaped driver for an installed Chrome: a dozen page verbs over the DevTools Protocol, with the full protocol left reachable",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "development": "./src/index.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "engines": {
17
+ "node": ">=26.1.0 <27"
18
+ },
19
+ "license": "MIT",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/aivi-hq/aivi.git",
23
+ "directory": "packages/chrome"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "scripts": {
29
+ "prepack": "tsc -b tsconfig.build.json"
30
+ }
31
+ }