@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 +61 -2
- package/dist/chrome.d.ts +62 -0
- package/dist/chrome.js +244 -0
- package/dist/connection.d.ts +61 -0
- package/dist/connection.js +181 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/tab.d.ts +132 -0
- package/dist/tab.js +329 -0
- package/package.json +29 -4
package/README.md
CHANGED
|
@@ -1,3 +1,62 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @aivi/chrome
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
package/dist/chrome.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
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.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
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
|
+
}
|