chromux 0.2.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 +152 -0
- package/chrome-tabs.mjs +501 -0
- package/chromux.mjs +847 -0
- package/package.json +13 -0
- package/test.sh +141 -0
package/README.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# chromux
|
|
2
|
+
|
|
3
|
+
tmux for Chrome tabs — zero-dependency parallel Chrome tab controller via raw CDP.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
AI agents need to browse the web in parallel using the user's **real Chrome** (with logins preserved, no bot detection). Existing tools either bundle their own Chromium (Playwright/Puppeteer) or can't isolate tabs properly (agent-browser `--cdp --session`).
|
|
8
|
+
|
|
9
|
+
chromux solves this by talking to Chrome's DevTools Protocol directly using only Node.js built-ins — no Playwright, no Puppeteer, no npm dependencies.
|
|
10
|
+
|
|
11
|
+
| | Playwright/Puppeteer | agent-browser `--cdp` | chromux |
|
|
12
|
+
|---|---|---|---|
|
|
13
|
+
| Browser | Bundled Chromium | Real Chrome | Real Chrome |
|
|
14
|
+
| Bot detection | Often caught | Avoided | Avoided |
|
|
15
|
+
| Tab isolation | Yes | **No** (sessions share tab) | **Yes** |
|
|
16
|
+
| Parallel agents | Yes | **Broken** | **Yes** |
|
|
17
|
+
| Dependencies | 100s of MB | playwright-core | **None** |
|
|
18
|
+
| Profile management | No | No | **Yes** |
|
|
19
|
+
|
|
20
|
+
## Prerequisites
|
|
21
|
+
|
|
22
|
+
- **Node.js >= 22** (for built-in `WebSocket`)
|
|
23
|
+
- **Google Chrome** installed
|
|
24
|
+
|
|
25
|
+
## Quick Start
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# Launch Chrome with an isolated profile (auto-finds Chrome, auto-assigns port)
|
|
29
|
+
chromux launch
|
|
30
|
+
|
|
31
|
+
# Open tabs for two agents
|
|
32
|
+
chromux open agent-a https://news.ycombinator.com
|
|
33
|
+
chromux open agent-b https://reddit.com/r/programming
|
|
34
|
+
|
|
35
|
+
# Each operates independently
|
|
36
|
+
chromux snapshot agent-a
|
|
37
|
+
chromux click agent-a @3
|
|
38
|
+
chromux eval agent-b "document.title"
|
|
39
|
+
chromux screenshot agent-a /tmp/hn.png
|
|
40
|
+
|
|
41
|
+
# Clean up
|
|
42
|
+
chromux close agent-a
|
|
43
|
+
chromux close agent-b
|
|
44
|
+
chromux kill default
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Profile Management
|
|
48
|
+
|
|
49
|
+
Each profile is an isolated Chrome instance with its own user-data-dir, logins, cookies, and extensions.
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
# Launch named profiles
|
|
53
|
+
chromux launch work
|
|
54
|
+
chromux launch personal
|
|
55
|
+
|
|
56
|
+
# See what's running
|
|
57
|
+
chromux ps
|
|
58
|
+
# PROFILE PORT PID STATUS TABS
|
|
59
|
+
# work 9300 12345 running 3
|
|
60
|
+
# personal 9301 12346 running 1
|
|
61
|
+
|
|
62
|
+
# Use a specific profile for tab commands
|
|
63
|
+
chromux --profile work open my-tab https://...
|
|
64
|
+
CHROMUX_PROFILE=personal chromux open other-tab https://...
|
|
65
|
+
|
|
66
|
+
# Default profile is "default" — used when no --profile specified
|
|
67
|
+
chromux open my-tab https://... # → uses "default" profile (auto-launches if needed)
|
|
68
|
+
|
|
69
|
+
# Stop a profile
|
|
70
|
+
chromux kill work
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Commands
|
|
74
|
+
|
|
75
|
+
### Profile
|
|
76
|
+
|
|
77
|
+
| Command | Description |
|
|
78
|
+
|---------|-------------|
|
|
79
|
+
| `launch [name]` | Launch Chrome with isolated profile (default: "default") |
|
|
80
|
+
| `launch <name> --port N` | Launch with specific port |
|
|
81
|
+
| `ps` | List running profiles |
|
|
82
|
+
| `kill <name>` | Stop profile (Chrome + daemon) |
|
|
83
|
+
|
|
84
|
+
### Tab Operations
|
|
85
|
+
|
|
86
|
+
| Command | Description |
|
|
87
|
+
|---------|-------------|
|
|
88
|
+
| `open <session> <url>` | Navigate (auto-creates tab) |
|
|
89
|
+
| `snapshot <session>` | Accessibility tree with `@ref` numbers |
|
|
90
|
+
| `click <session> @<ref>` | Click element by ref |
|
|
91
|
+
| `click <session> "selector"` | Click by CSS selector |
|
|
92
|
+
| `fill <session> @<ref> "text"` | Fill input field |
|
|
93
|
+
| `type <session> "text"` | Keyboard input (Enter, Tab, etc.) |
|
|
94
|
+
| `eval <session> "js"` | Run JavaScript expression |
|
|
95
|
+
| `screenshot <session> [path]` | Take PNG screenshot |
|
|
96
|
+
| `scroll <session> up\|down` | Scroll page |
|
|
97
|
+
| `wait <session> <ms>` | Wait milliseconds |
|
|
98
|
+
| `close <session>` | Close tab |
|
|
99
|
+
| `list` | List active sessions in current profile |
|
|
100
|
+
| `stop` | Stop daemon (keeps Chrome running) |
|
|
101
|
+
|
|
102
|
+
## Architecture
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
~/.chromux/
|
|
106
|
+
config.json Global config (optional)
|
|
107
|
+
profiles/
|
|
108
|
+
default/ Chrome user-data-dir
|
|
109
|
+
.state PID, port, socket path
|
|
110
|
+
work/
|
|
111
|
+
.state
|
|
112
|
+
|
|
113
|
+
Chrome instance A (port 9300, ~/.chromux/profiles/default/)
|
|
114
|
+
↑ CDP WebSocket per tab
|
|
115
|
+
chromux daemon (Unix socket /tmp/chromux-default.sock)
|
|
116
|
+
↑ HTTP
|
|
117
|
+
CLI / AI agents
|
|
118
|
+
|
|
119
|
+
Chrome instance B (port 9301, ~/.chromux/profiles/work/)
|
|
120
|
+
↑ CDP WebSocket per tab
|
|
121
|
+
chromux daemon (Unix socket /tmp/chromux-work.sock)
|
|
122
|
+
↑ HTTP
|
|
123
|
+
CLI / AI agents
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
- **No Playwright/Puppeteer** — raw `WebSocket` + `http` from Node.js stdlib
|
|
127
|
+
- **Tab CRUD** via Chrome's `/json/*` HTTP endpoints
|
|
128
|
+
- **Page ops** via CDP WebSocket JSON-RPC
|
|
129
|
+
- **Daemon per profile** keeps WebSocket connections alive across CLI invocations
|
|
130
|
+
- **Auto-launch** — `chromux open` auto-launches default profile if needed
|
|
131
|
+
|
|
132
|
+
## Configuration
|
|
133
|
+
|
|
134
|
+
Optional `~/.chromux/config.json`:
|
|
135
|
+
|
|
136
|
+
```json
|
|
137
|
+
{
|
|
138
|
+
"chromePath": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
139
|
+
"portRangeStart": 9300,
|
|
140
|
+
"portRangeEnd": 9399
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Environment
|
|
145
|
+
|
|
146
|
+
| Variable | Default | Description |
|
|
147
|
+
|----------|---------|-------------|
|
|
148
|
+
| `CHROMUX_PROFILE` | `default` | Active profile name |
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
MIT
|
package/chrome-tabs.mjs
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* chrome-tabs — Zero-dependency parallel Chrome tab controller via raw CDP.
|
|
5
|
+
*
|
|
6
|
+
* Connects to a real Chrome instance (e.g. chrome-agent) over the Chrome
|
|
7
|
+
* DevTools Protocol. Each "session" is an independent browser tab that can
|
|
8
|
+
* be operated in parallel by different AI agents.
|
|
9
|
+
*
|
|
10
|
+
* Dependencies: NONE — uses only Node.js ≥22 built-ins (http, WebSocket, fs).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import http from 'node:http';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import { spawn, execSync } from 'node:child_process';
|
|
16
|
+
|
|
17
|
+
const SOCK = '/tmp/chrome-tabs.sock';
|
|
18
|
+
const CDP_URL = process.env.CHROME_CDP || 'http://localhost:9222';
|
|
19
|
+
|
|
20
|
+
// ============================================================
|
|
21
|
+
// CDP Client — thin wrapper over Chrome DevTools Protocol
|
|
22
|
+
// ============================================================
|
|
23
|
+
|
|
24
|
+
class CDPClient {
|
|
25
|
+
#ws;
|
|
26
|
+
#seq = 0;
|
|
27
|
+
#pending = new Map(); // id → { resolve }
|
|
28
|
+
#waiters = []; // [{ method, resolve, timer }]
|
|
29
|
+
|
|
30
|
+
async connect(wsUrl) {
|
|
31
|
+
this.#ws = new WebSocket(wsUrl);
|
|
32
|
+
await new Promise((res, rej) => {
|
|
33
|
+
this.#ws.addEventListener('open', res, { once: true });
|
|
34
|
+
this.#ws.addEventListener('error', rej, { once: true });
|
|
35
|
+
});
|
|
36
|
+
this.#ws.addEventListener('message', (evt) => {
|
|
37
|
+
const msg = JSON.parse(evt.data);
|
|
38
|
+
// command response
|
|
39
|
+
if ('id' in msg) {
|
|
40
|
+
const p = this.#pending.get(msg.id);
|
|
41
|
+
if (p) { this.#pending.delete(msg.id); p.resolve(msg); }
|
|
42
|
+
}
|
|
43
|
+
// domain event
|
|
44
|
+
if ('method' in msg) {
|
|
45
|
+
for (let i = this.#waiters.length - 1; i >= 0; i--) {
|
|
46
|
+
if (this.#waiters[i].method === msg.method) {
|
|
47
|
+
clearTimeout(this.#waiters[i].timer);
|
|
48
|
+
this.#waiters[i].resolve(msg.params);
|
|
49
|
+
this.#waiters.splice(i, 1);
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Send a CDP command, return result (throws on CDP error). */
|
|
58
|
+
async send(method, params = {}) {
|
|
59
|
+
const id = ++this.#seq;
|
|
60
|
+
const msg = await new Promise((resolve) => {
|
|
61
|
+
this.#pending.set(id, { resolve });
|
|
62
|
+
this.#ws.send(JSON.stringify({ id, method, params }));
|
|
63
|
+
});
|
|
64
|
+
if (msg.error) throw new Error(`CDP ${method}: ${msg.error.message}`);
|
|
65
|
+
return msg.result;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Wait for a domain event (e.g. Page.loadEventFired). */
|
|
69
|
+
waitForEvent(method, timeout = 30000) {
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
const timer = setTimeout(() => {
|
|
72
|
+
const i = this.#waiters.findIndex(w => w === entry);
|
|
73
|
+
if (i >= 0) this.#waiters.splice(i, 1);
|
|
74
|
+
reject(new Error(`Timeout waiting for ${method}`));
|
|
75
|
+
}, timeout);
|
|
76
|
+
const entry = { method, resolve, timer };
|
|
77
|
+
this.#waiters.push(entry);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
close() { this.#ws?.close(); }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ============================================================
|
|
85
|
+
// Chrome HTTP helpers — tab CRUD via /json/* endpoints
|
|
86
|
+
// ============================================================
|
|
87
|
+
|
|
88
|
+
function cdpFetch(path, method = 'GET') {
|
|
89
|
+
const base = new URL(CDP_URL);
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
const req = http.request({ hostname: base.hostname, port: base.port, path, method }, (res) => {
|
|
92
|
+
let d = '';
|
|
93
|
+
res.on('data', c => d += c);
|
|
94
|
+
res.on('end', () => {
|
|
95
|
+
try { resolve(JSON.parse(d)); }
|
|
96
|
+
catch { resolve(d); }
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
req.on('error', reject);
|
|
100
|
+
req.end();
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function createTab(url = 'about:blank') {
|
|
105
|
+
return cdpFetch(`/json/new?${url}`, 'PUT');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function closeTab(targetId) {
|
|
109
|
+
return cdpFetch(`/json/close/${targetId}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function listTabs() {
|
|
113
|
+
const all = await cdpFetch('/json');
|
|
114
|
+
return all.filter(t => t.type === 'page');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ============================================================
|
|
118
|
+
// Snapshot — accessibility tree with @ref numbers
|
|
119
|
+
// ============================================================
|
|
120
|
+
|
|
121
|
+
const SNAPSHOT_JS = `(() => {
|
|
122
|
+
let refId = 0;
|
|
123
|
+
const ROLES = {
|
|
124
|
+
a:'link', button:'button', input:'textbox', select:'combobox',
|
|
125
|
+
textarea:'textbox', img:'img', nav:'navigation', main:'main',
|
|
126
|
+
header:'banner', footer:'contentinfo', form:'form',
|
|
127
|
+
h1:'heading', h2:'heading', h3:'heading',
|
|
128
|
+
h4:'heading', h5:'heading', h6:'heading',
|
|
129
|
+
ul:'list', ol:'list', li:'listitem',
|
|
130
|
+
table:'table', tr:'row', td:'cell', th:'columnheader',
|
|
131
|
+
dialog:'dialog', section:'region', aside:'complementary',
|
|
132
|
+
};
|
|
133
|
+
const INTERACTIVE = new Set(['a','button','input','select','textarea']);
|
|
134
|
+
function getRole(el) {
|
|
135
|
+
return el.getAttribute('role') || ROLES[el.tagName.toLowerCase()] || null;
|
|
136
|
+
}
|
|
137
|
+
function isInteractive(el) {
|
|
138
|
+
const tag = el.tagName.toLowerCase();
|
|
139
|
+
if (INTERACTIVE.has(tag)) return true;
|
|
140
|
+
const role = el.getAttribute('role');
|
|
141
|
+
if (role === 'button' || role === 'link' || role === 'tab' || role === 'menuitem') return true;
|
|
142
|
+
if (el.getAttribute('tabindex') !== null && el.getAttribute('tabindex') !== '-1') return true;
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
function getLabel(el) {
|
|
146
|
+
const tag = el.tagName.toLowerCase();
|
|
147
|
+
const aria = el.getAttribute('aria-label');
|
|
148
|
+
if (aria) return aria;
|
|
149
|
+
if (tag === 'input' || tag === 'textarea') return el.value || el.placeholder || '';
|
|
150
|
+
if (tag === 'img') return el.alt || '';
|
|
151
|
+
let text = '';
|
|
152
|
+
for (const n of el.childNodes) { if (n.nodeType === 3) text += n.textContent; }
|
|
153
|
+
return text.trim().substring(0, 100);
|
|
154
|
+
}
|
|
155
|
+
function walk(el, depth) {
|
|
156
|
+
if (!el || el.nodeType !== 1) return '';
|
|
157
|
+
try {
|
|
158
|
+
const s = getComputedStyle(el);
|
|
159
|
+
if (s.display === 'none' || s.visibility === 'hidden' || el.hidden) return '';
|
|
160
|
+
} catch { return ''; }
|
|
161
|
+
if (el.getAttribute('aria-hidden') === 'true') return '';
|
|
162
|
+
const tag = el.tagName.toLowerCase();
|
|
163
|
+
if (['script','style','noscript','br','hr','svg','path'].includes(tag)) return '';
|
|
164
|
+
const role = getRole(el);
|
|
165
|
+
const interactive = isInteractive(el);
|
|
166
|
+
const label = getLabel(el);
|
|
167
|
+
const has = role || interactive || label;
|
|
168
|
+
const cd = has ? depth + 1 : depth;
|
|
169
|
+
let children = '';
|
|
170
|
+
for (const c of el.children) children += walk(c, cd);
|
|
171
|
+
if (!has && !children) return '';
|
|
172
|
+
if (!has) return children;
|
|
173
|
+
const indent = ' '.repeat(depth);
|
|
174
|
+
let line = indent;
|
|
175
|
+
if (interactive) {
|
|
176
|
+
const ref = ++refId;
|
|
177
|
+
el.setAttribute('data-ct-ref', String(ref));
|
|
178
|
+
line += '@' + ref + ' ';
|
|
179
|
+
}
|
|
180
|
+
line += role || tag;
|
|
181
|
+
if (label) line += ' "' + label.replace(/"/g, '\\\\"') + '"';
|
|
182
|
+
if (tag === 'input') line += ' [' + (el.type || 'text') + ']';
|
|
183
|
+
if (tag === 'a' && el.href) {
|
|
184
|
+
const href = el.getAttribute('href');
|
|
185
|
+
if (href && !href.startsWith('javascript:') && !href.startsWith('#'))
|
|
186
|
+
line += ' -> ' + href.substring(0, 80);
|
|
187
|
+
}
|
|
188
|
+
return line + '\\n' + children;
|
|
189
|
+
}
|
|
190
|
+
return '# ' + document.title + '\\n# ' + location.href + '\\n\\n' + walk(document.body, 0);
|
|
191
|
+
})()`;
|
|
192
|
+
|
|
193
|
+
// ============================================================
|
|
194
|
+
// Daemon server
|
|
195
|
+
// ============================================================
|
|
196
|
+
|
|
197
|
+
async function startDaemon() {
|
|
198
|
+
try { fs.unlinkSync(SOCK); } catch {}
|
|
199
|
+
|
|
200
|
+
// Verify Chrome is reachable
|
|
201
|
+
try { await cdpFetch('/json/version'); }
|
|
202
|
+
catch { console.error(`Cannot reach Chrome at ${CDP_URL}`); process.exit(1); }
|
|
203
|
+
|
|
204
|
+
/** @type {Map<string, {targetId: string, cdp: CDPClient}>} */
|
|
205
|
+
const sessions = new Map();
|
|
206
|
+
|
|
207
|
+
const server = http.createServer(async (req, res) => {
|
|
208
|
+
const url = new URL(req.url, 'http://x');
|
|
209
|
+
const body = ['POST', 'PUT'].includes(req.method) ? await readBody(req) : null;
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
const result = await route(req.method, url.pathname, body, sessions);
|
|
213
|
+
const isText = typeof result === 'string';
|
|
214
|
+
res.writeHead(200, { 'Content-Type': isText ? 'text/plain; charset=utf-8' : 'application/json' });
|
|
215
|
+
res.end(isText ? result : JSON.stringify(result));
|
|
216
|
+
} catch (err) {
|
|
217
|
+
res.writeHead(err.status || 500, { 'Content-Type': 'application/json' });
|
|
218
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
server.listen(SOCK, () => console.log(`chrome-tabs daemon on ${SOCK}`));
|
|
223
|
+
|
|
224
|
+
const cleanup = () => {
|
|
225
|
+
for (const s of sessions.values()) s.cdp.close();
|
|
226
|
+
try { fs.unlinkSync(SOCK); } catch {}
|
|
227
|
+
};
|
|
228
|
+
process.on('exit', cleanup);
|
|
229
|
+
process.on('SIGTERM', () => process.exit(0));
|
|
230
|
+
process.on('SIGINT', () => process.exit(0));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function route(method, path, body, sessions) {
|
|
234
|
+
|
|
235
|
+
// --- health ---
|
|
236
|
+
if (path === '/health')
|
|
237
|
+
return { ok: true, sessions: sessions.size };
|
|
238
|
+
|
|
239
|
+
// --- list ---
|
|
240
|
+
if (path === '/list') {
|
|
241
|
+
const out = {};
|
|
242
|
+
for (const [id, s] of sessions) {
|
|
243
|
+
try {
|
|
244
|
+
const r = await s.cdp.send('Runtime.evaluate', { expression: 'JSON.stringify({url:location.href,title:document.title})', returnByValue: true });
|
|
245
|
+
out[id] = JSON.parse(r.result.value);
|
|
246
|
+
} catch { out[id] = { url: '(closed)', title: '' }; sessions.delete(id); }
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// --- open ---
|
|
252
|
+
if (path === '/open' && method === 'POST') {
|
|
253
|
+
const { session, url } = body;
|
|
254
|
+
if (!session || !url) throw httpErr(400, 'session and url required');
|
|
255
|
+
|
|
256
|
+
let s = sessions.get(session);
|
|
257
|
+
if (!s) {
|
|
258
|
+
const tab = await createTab('about:blank');
|
|
259
|
+
const cdp = new CDPClient();
|
|
260
|
+
await cdp.connect(tab.webSocketDebuggerUrl);
|
|
261
|
+
await cdp.send('Page.enable');
|
|
262
|
+
await cdp.send('Runtime.enable');
|
|
263
|
+
s = { targetId: tab.id, cdp };
|
|
264
|
+
sessions.set(session, s);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const loaded = s.cdp.waitForEvent('Page.loadEventFired', 30000);
|
|
268
|
+
await s.cdp.send('Page.navigate', { url });
|
|
269
|
+
await loaded;
|
|
270
|
+
|
|
271
|
+
const r = await s.cdp.send('Runtime.evaluate', { expression: 'JSON.stringify({url:location.href,title:document.title})', returnByValue: true });
|
|
272
|
+
return { session, ...JSON.parse(r.result.value) };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// --- snapshot ---
|
|
276
|
+
if (path.startsWith('/snapshot/')) {
|
|
277
|
+
const session = decodeURIComponent(path.split('/')[2]);
|
|
278
|
+
const s = getSession(sessions, session);
|
|
279
|
+
const r = await s.cdp.send('Runtime.evaluate', { expression: SNAPSHOT_JS, returnByValue: true });
|
|
280
|
+
return r.result.value; // plain text
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// --- click ---
|
|
284
|
+
if (path === '/click' && method === 'POST') {
|
|
285
|
+
const { session, selector } = body;
|
|
286
|
+
const s = getSession(sessions, session);
|
|
287
|
+
const sel = selector.startsWith('@')
|
|
288
|
+
? `[data-ct-ref="${selector.slice(1)}"]`
|
|
289
|
+
: selector;
|
|
290
|
+
await s.cdp.send('Runtime.evaluate', {
|
|
291
|
+
expression: `document.querySelector('${sel.replace(/'/g, "\\'")}').click()`,
|
|
292
|
+
returnByValue: true,
|
|
293
|
+
});
|
|
294
|
+
await sleep(500);
|
|
295
|
+
return { clicked: selector };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// --- fill ---
|
|
299
|
+
if (path === '/fill' && method === 'POST') {
|
|
300
|
+
const { session, selector, text } = body;
|
|
301
|
+
const s = getSession(sessions, session);
|
|
302
|
+
const sel = selector.startsWith('@')
|
|
303
|
+
? `[data-ct-ref="${selector.slice(1)}"]`
|
|
304
|
+
: selector;
|
|
305
|
+
const escaped = text.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
306
|
+
await s.cdp.send('Runtime.evaluate', {
|
|
307
|
+
expression: `(() => { const el = document.querySelector('${sel.replace(/'/g, "\\'")}'); el.focus(); el.value = '${escaped}'; el.dispatchEvent(new Event('input', {bubbles:true})); })()`,
|
|
308
|
+
returnByValue: true,
|
|
309
|
+
});
|
|
310
|
+
return { filled: selector, text };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// --- type ---
|
|
314
|
+
if (path === '/type' && method === 'POST') {
|
|
315
|
+
const { session, text } = body;
|
|
316
|
+
const s = getSession(sessions, session);
|
|
317
|
+
const KEY_MAP = { Enter: '\r', Tab: '\t', Escape: '\u001B', Backspace: '\b' };
|
|
318
|
+
if (KEY_MAP[text] || text.length === 1) {
|
|
319
|
+
await s.cdp.send('Input.dispatchKeyEvent', {
|
|
320
|
+
type: 'keyDown', key: text, text: KEY_MAP[text] || text,
|
|
321
|
+
});
|
|
322
|
+
await s.cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: text });
|
|
323
|
+
} else {
|
|
324
|
+
for (const ch of text) {
|
|
325
|
+
await s.cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: ch, text: ch });
|
|
326
|
+
await s.cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: ch });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return { typed: text };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// --- eval ---
|
|
333
|
+
if (path === '/eval' && method === 'POST') {
|
|
334
|
+
const { session, code } = body;
|
|
335
|
+
const s = getSession(sessions, session);
|
|
336
|
+
const r = await s.cdp.send('Runtime.evaluate', {
|
|
337
|
+
expression: code, returnByValue: true, awaitPromise: true,
|
|
338
|
+
});
|
|
339
|
+
if (r.exceptionDetails) throw httpErr(400, r.exceptionDetails.text || 'eval error');
|
|
340
|
+
return r.result.value;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// --- screenshot ---
|
|
344
|
+
if (path === '/screenshot' && method === 'POST') {
|
|
345
|
+
const { session, path: savePath } = body;
|
|
346
|
+
const s = getSession(sessions, session);
|
|
347
|
+
const r = await s.cdp.send('Page.captureScreenshot', { format: 'png' });
|
|
348
|
+
const p = savePath || `/tmp/chrome-tabs-${session}-${Date.now()}.png`;
|
|
349
|
+
fs.writeFileSync(p, Buffer.from(r.data, 'base64'));
|
|
350
|
+
return { path: p };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// --- scroll ---
|
|
354
|
+
if (path === '/scroll' && method === 'POST') {
|
|
355
|
+
const { session, direction } = body;
|
|
356
|
+
const s = getSession(sessions, session);
|
|
357
|
+
const delta = direction === 'up' ? -500 : 500;
|
|
358
|
+
await s.cdp.send('Input.dispatchMouseEvent', { type: 'mouseWheel', x: 300, y: 300, deltaX: 0, deltaY: delta });
|
|
359
|
+
await sleep(300);
|
|
360
|
+
return { scrolled: direction };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// --- wait ---
|
|
364
|
+
if (path === '/wait' && method === 'POST') {
|
|
365
|
+
getSession(sessions, body.session);
|
|
366
|
+
await sleep(body.ms || 1000);
|
|
367
|
+
return { waited: body.ms || 1000 };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// --- close session ---
|
|
371
|
+
if (path.startsWith('/session/') && method === 'DELETE') {
|
|
372
|
+
const session = decodeURIComponent(path.split('/')[2]);
|
|
373
|
+
const s = sessions.get(session);
|
|
374
|
+
if (s) {
|
|
375
|
+
s.cdp.close();
|
|
376
|
+
await closeTab(s.targetId).catch(() => {});
|
|
377
|
+
sessions.delete(session);
|
|
378
|
+
}
|
|
379
|
+
return { closed: session };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// --- stop ---
|
|
383
|
+
if (path === '/stop') {
|
|
384
|
+
setTimeout(() => process.exit(0), 100);
|
|
385
|
+
return { stopping: true };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
throw httpErr(404, `Not found: ${method} ${path}`);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ============================================================
|
|
392
|
+
// CLI client
|
|
393
|
+
// ============================================================
|
|
394
|
+
|
|
395
|
+
function cliReq(method, path, body) {
|
|
396
|
+
return new Promise((resolve, reject) => {
|
|
397
|
+
const opts = {
|
|
398
|
+
socketPath: SOCK, path, method,
|
|
399
|
+
headers: body ? { 'Content-Type': 'application/json' } : {},
|
|
400
|
+
};
|
|
401
|
+
const req = http.request(opts, (res) => {
|
|
402
|
+
let d = '';
|
|
403
|
+
res.on('data', c => d += c);
|
|
404
|
+
res.on('end', () => {
|
|
405
|
+
if (res.statusCode >= 400) {
|
|
406
|
+
try { reject(new Error(JSON.parse(d).error)); }
|
|
407
|
+
catch { reject(new Error(d)); }
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if (res.headers['content-type']?.includes('text/plain')) resolve(d);
|
|
411
|
+
else { try { resolve(JSON.parse(d)); } catch { resolve(d); } }
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
req.on('error', reject);
|
|
415
|
+
if (body) req.write(JSON.stringify(body));
|
|
416
|
+
req.end();
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function ensureDaemon() {
|
|
421
|
+
try { await cliReq('GET', '/health'); return; } catch {}
|
|
422
|
+
process.stderr.write('Starting chrome-tabs daemon...');
|
|
423
|
+
const child = spawn(process.execPath, [process.argv[1], '--daemon'], {
|
|
424
|
+
detached: true, stdio: 'ignore', env: { ...process.env, CHROME_CDP: CDP_URL },
|
|
425
|
+
});
|
|
426
|
+
child.unref();
|
|
427
|
+
for (let i = 0; i < 50; i++) {
|
|
428
|
+
await sleep(200);
|
|
429
|
+
try { await cliReq('GET', '/health'); process.stderr.write(' ready.\n'); return; } catch {}
|
|
430
|
+
}
|
|
431
|
+
console.error(' failed. Is Chrome running with --remote-debugging-port=9222?');
|
|
432
|
+
process.exit(1);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function runCli(cmd, args) {
|
|
436
|
+
await ensureDaemon();
|
|
437
|
+
const routes = {
|
|
438
|
+
open: () => cliReq('POST', '/open', { session: args[0], url: args[1] }),
|
|
439
|
+
snapshot: () => cliReq('GET', `/snapshot/${args[0]}`),
|
|
440
|
+
click: () => cliReq('POST', '/click', { session: args[0], selector: args[1] }),
|
|
441
|
+
fill: () => cliReq('POST', '/fill', { session: args[0], selector: args[1], text: args[2] }),
|
|
442
|
+
type: () => cliReq('POST', '/type', { session: args[0], text: args[1] }),
|
|
443
|
+
eval: () => cliReq('POST', '/eval', { session: args[0], code: args[1] }),
|
|
444
|
+
screenshot: () => cliReq('POST', '/screenshot', { session: args[0], path: args[1] }),
|
|
445
|
+
scroll: () => cliReq('POST', '/scroll', { session: args[0], direction: args[1] || 'down' }),
|
|
446
|
+
wait: () => cliReq('POST', '/wait', { session: args[0], ms: parseInt(args[1]) || 1000 }),
|
|
447
|
+
close: () => cliReq('DELETE', `/session/${args[0]}`),
|
|
448
|
+
list: () => cliReq('GET', '/list'),
|
|
449
|
+
stop: () => cliReq('POST', '/stop', {}),
|
|
450
|
+
};
|
|
451
|
+
if (!routes[cmd]) { console.error(`Unknown: ${cmd}. Run: chrome-tabs help`); process.exit(1); }
|
|
452
|
+
try {
|
|
453
|
+
const r = await routes[cmd]();
|
|
454
|
+
console.log(typeof r === 'string' ? r : JSON.stringify(r, null, 2));
|
|
455
|
+
} catch (e) { console.error(`Error: ${e.message}`); process.exit(1); }
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ============================================================
|
|
459
|
+
// Helpers
|
|
460
|
+
// ============================================================
|
|
461
|
+
|
|
462
|
+
function getSession(sessions, id) {
|
|
463
|
+
const s = sessions.get(id);
|
|
464
|
+
if (!s) throw httpErr(404, `Session "${id}" not found`);
|
|
465
|
+
return s;
|
|
466
|
+
}
|
|
467
|
+
function httpErr(status, message) { const e = new Error(message); e.status = status; return e; }
|
|
468
|
+
function readBody(req) {
|
|
469
|
+
return new Promise(r => { let d = ''; req.on('data', c => d += c); req.on('end', () => { try { r(JSON.parse(d)); } catch { r(d); } }); });
|
|
470
|
+
}
|
|
471
|
+
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
472
|
+
|
|
473
|
+
// ============================================================
|
|
474
|
+
// Entry
|
|
475
|
+
// ============================================================
|
|
476
|
+
|
|
477
|
+
const [,, cmd, ...args] = process.argv;
|
|
478
|
+
|
|
479
|
+
if (cmd === '--daemon') {
|
|
480
|
+
await startDaemon();
|
|
481
|
+
} else if (!cmd || cmd === 'help' || cmd === '--help') {
|
|
482
|
+
console.log(`chrome-tabs — Zero-dep parallel Chrome tab controller via raw CDP
|
|
483
|
+
|
|
484
|
+
Usage:
|
|
485
|
+
chrome-tabs open <session> <url> Navigate (auto-creates tab)
|
|
486
|
+
chrome-tabs snapshot <session> Accessibility tree with @ref
|
|
487
|
+
chrome-tabs click <session> @<ref> Click by ref number
|
|
488
|
+
chrome-tabs fill <session> @<ref> "text" Fill input
|
|
489
|
+
chrome-tabs type <session> "text" Keyboard input
|
|
490
|
+
chrome-tabs eval <session> "js" Run JavaScript
|
|
491
|
+
chrome-tabs screenshot <session> [path] Take screenshot
|
|
492
|
+
chrome-tabs scroll <session> up|down Scroll
|
|
493
|
+
chrome-tabs wait <session> <ms> Wait
|
|
494
|
+
chrome-tabs close <session> Close tab
|
|
495
|
+
chrome-tabs list List sessions
|
|
496
|
+
chrome-tabs stop Stop daemon
|
|
497
|
+
|
|
498
|
+
Env: CHROME_CDP (default http://localhost:9222)`);
|
|
499
|
+
} else {
|
|
500
|
+
await runCli(cmd, args);
|
|
501
|
+
}
|