chrome-cli-bridge 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/bin/chrome-bridge.js +152 -0
- package/package.json +51 -0
- package/src/client.js +96 -0
- package/src/commands/click.js +22 -0
- package/src/commands/devtools.js +57 -0
- package/src/commands/exec.js +22 -0
- package/src/commands/hover.js +22 -0
- package/src/commands/inject.js +31 -0
- package/src/commands/logs.js +47 -0
- package/src/commands/navigate.js +21 -0
- package/src/commands/query.js +29 -0
- package/src/commands/repl.js +63 -0
- package/src/commands/screenshot.js +29 -0
- package/src/commands/snapshot.js +21 -0
- package/src/commands/start.js +42 -0
- package/src/commands/status.js +43 -0
- package/src/commands/stop.js +28 -0
- package/src/commands/storage.js +26 -0
- package/src/commands/tabs.js +29 -0
- package/src/commands/trigger.js +22 -0
- package/src/commands/type.js +23 -0
- package/src/commands/wait.js +23 -0
- package/src/index.js +83 -0
- package/src/output.js +56 -0
- package/src/relay.js +200 -0
- package/src/rpc.js +36 -0
- package/src/token.js +24 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createInterface } from 'node:readline'
|
|
3
|
+
import { getClient } from '../src/client.js'
|
|
4
|
+
import { outLine } from '../src/output.js'
|
|
5
|
+
|
|
6
|
+
// ── Pipe mode ───────────────────────────────────────────────────────────────
|
|
7
|
+
// Activated when stdin is not a TTY or --pipe flag is present
|
|
8
|
+
|
|
9
|
+
const forcePipe = process.argv.includes('--pipe')
|
|
10
|
+
const isPipeMode = forcePipe || !process.stdin.isTTY
|
|
11
|
+
|
|
12
|
+
if (isPipeMode) {
|
|
13
|
+
// Remove --pipe flag from args so yargs doesn't complain
|
|
14
|
+
const idx = process.argv.indexOf('--pipe')
|
|
15
|
+
if (idx !== -1) process.argv.splice(idx, 1)
|
|
16
|
+
|
|
17
|
+
runPipeMode()
|
|
18
|
+
} else {
|
|
19
|
+
runCLI()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function runPipeMode() {
|
|
23
|
+
const portArg = process.argv.indexOf('--port')
|
|
24
|
+
const port = portArg !== -1 ? parseInt(process.argv[portArg + 1], 10) : 9876
|
|
25
|
+
|
|
26
|
+
let client
|
|
27
|
+
try {
|
|
28
|
+
client = await getClient(port)
|
|
29
|
+
} catch (e) {
|
|
30
|
+
process.stderr.write(JSON.stringify({ error: 'connection_failed', message: e.message }) + '\n')
|
|
31
|
+
process.exit(1)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity })
|
|
35
|
+
|
|
36
|
+
rl.on('line', async (line) => {
|
|
37
|
+
const trimmed = line.trim()
|
|
38
|
+
if (!trimmed) return
|
|
39
|
+
|
|
40
|
+
let msg
|
|
41
|
+
try {
|
|
42
|
+
msg = JSON.parse(trimmed)
|
|
43
|
+
} catch {
|
|
44
|
+
outLine({ error: 'invalid_input', message: `Invalid JSON: ${trimmed.slice(0, 80)}` })
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const { command, ...params } = msg
|
|
49
|
+
if (!command) {
|
|
50
|
+
outLine({ error: 'invalid_input', message: 'Missing "command" field' })
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const methodMap = {
|
|
55
|
+
tabs: 'tabs.list',
|
|
56
|
+
select: 'tabs.select',
|
|
57
|
+
query: 'page.query',
|
|
58
|
+
exec: 'page.exec',
|
|
59
|
+
logs: 'page.logs',
|
|
60
|
+
network: 'page.network',
|
|
61
|
+
trigger: 'page.trigger',
|
|
62
|
+
screenshot: 'page.screenshot',
|
|
63
|
+
navigate: 'page.navigate',
|
|
64
|
+
storage: 'page.storage',
|
|
65
|
+
wait: 'page.wait',
|
|
66
|
+
inject: 'page.inject',
|
|
67
|
+
snapshot: 'page.snapshot',
|
|
68
|
+
type: 'page.type',
|
|
69
|
+
click: 'page.click',
|
|
70
|
+
hover: 'page.hover',
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const method = methodMap[command]
|
|
74
|
+
if (!method) {
|
|
75
|
+
outLine({ error: 'unknown_command', message: `Unknown command: ${command}` })
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const result = await client.call(method, params)
|
|
81
|
+
outLine({ result })
|
|
82
|
+
} catch (e) {
|
|
83
|
+
outLine({ error: e.message, code: e.code, ...(e.data && { data: e.data }) })
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
rl.on('close', () => {
|
|
88
|
+
client.close()
|
|
89
|
+
process.exit(0)
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function runCLI() {
|
|
94
|
+
const yargs = (await import('yargs')).default
|
|
95
|
+
const { hideBin } = await import('yargs/helpers')
|
|
96
|
+
|
|
97
|
+
// commandDir() is not supported in ESM — import commands explicitly
|
|
98
|
+
const [start, stop, status, tabs, query, exec, logs, trigger,
|
|
99
|
+
screenshot, navigate, storage, wait, inject, snapshot,
|
|
100
|
+
type, click, hover, repl, devtools] = await Promise.all([
|
|
101
|
+
import('../src/commands/start.js'),
|
|
102
|
+
import('../src/commands/stop.js'),
|
|
103
|
+
import('../src/commands/status.js'),
|
|
104
|
+
import('../src/commands/tabs.js'),
|
|
105
|
+
import('../src/commands/query.js'),
|
|
106
|
+
import('../src/commands/exec.js'),
|
|
107
|
+
import('../src/commands/logs.js'),
|
|
108
|
+
import('../src/commands/trigger.js'),
|
|
109
|
+
import('../src/commands/screenshot.js'),
|
|
110
|
+
import('../src/commands/navigate.js'),
|
|
111
|
+
import('../src/commands/storage.js'),
|
|
112
|
+
import('../src/commands/wait.js'),
|
|
113
|
+
import('../src/commands/inject.js'),
|
|
114
|
+
import('../src/commands/snapshot.js'),
|
|
115
|
+
import('../src/commands/type.js'),
|
|
116
|
+
import('../src/commands/click.js'),
|
|
117
|
+
import('../src/commands/hover.js'),
|
|
118
|
+
import('../src/commands/repl.js'),
|
|
119
|
+
import('../src/commands/devtools.js'),
|
|
120
|
+
])
|
|
121
|
+
|
|
122
|
+
yargs(hideBin(process.argv))
|
|
123
|
+
.scriptName('chrome-bridge')
|
|
124
|
+
.usage('$0 <command> [options]')
|
|
125
|
+
.command(start)
|
|
126
|
+
.command(stop)
|
|
127
|
+
.command(status)
|
|
128
|
+
.command(tabs)
|
|
129
|
+
.command(query)
|
|
130
|
+
.command(exec)
|
|
131
|
+
.command(logs)
|
|
132
|
+
.command(trigger)
|
|
133
|
+
.command(screenshot)
|
|
134
|
+
.command(navigate)
|
|
135
|
+
.command(storage)
|
|
136
|
+
.command(wait)
|
|
137
|
+
.command(inject)
|
|
138
|
+
.command(snapshot)
|
|
139
|
+
.command(type)
|
|
140
|
+
.command(click)
|
|
141
|
+
.command(hover)
|
|
142
|
+
.command(repl)
|
|
143
|
+
.command(devtools)
|
|
144
|
+
.option('port', { type: 'number', default: 9876, global: true, describe: 'Relay port' })
|
|
145
|
+
.option('pipe', { type: 'boolean', describe: 'Force stdin pipe mode (NDJSON in, NDJSON out)' })
|
|
146
|
+
.demandCommand(1, 'Specify a command. Run --help for available commands.')
|
|
147
|
+
.strict()
|
|
148
|
+
.help()
|
|
149
|
+
.alias('h', 'help')
|
|
150
|
+
.wrap(null)
|
|
151
|
+
.parse()
|
|
152
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "chrome-cli-bridge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Connect CLI tools and AI agents to a live Chrome tab via WebSocket bridge",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"chrome-bridge": "./bin/chrome-bridge.js"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.js"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=18.0.0"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"start": "node bin/chrome-bridge.js start",
|
|
18
|
+
"bridge": "node bin/chrome-bridge.js",
|
|
19
|
+
"test": "node --test tests/unit/**/*.test.js",
|
|
20
|
+
"test:integration": "node --test tests/integration/**/*.test.js"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"chrome",
|
|
24
|
+
"browser",
|
|
25
|
+
"automation",
|
|
26
|
+
"cli",
|
|
27
|
+
"ai",
|
|
28
|
+
"websocket",
|
|
29
|
+
"devtools",
|
|
30
|
+
"scraping",
|
|
31
|
+
"bridge",
|
|
32
|
+
"agent"
|
|
33
|
+
],
|
|
34
|
+
"author": "Nestor Mata <nestor.mata@gmail.com>",
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "https://github.com/nestormata/chrome-bridge.git"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/nestormata/chrome-bridge#readme",
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/nestormata/chrome-bridge/issues"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"ws": "^8.17.0",
|
|
46
|
+
"yargs": "^17.7.2"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"puppeteer": "^22.0.0"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { readToken } from './token.js'
|
|
2
|
+
import { Relay } from './relay.js'
|
|
3
|
+
|
|
4
|
+
let _client = null
|
|
5
|
+
|
|
6
|
+
export async function getClient(port = 9876) {
|
|
7
|
+
if (_client) return _client
|
|
8
|
+
const token = await readToken()
|
|
9
|
+
if (!token) throw new Error('No session token found. Run: chrome-bridge start')
|
|
10
|
+
|
|
11
|
+
_client = new RelayClient(port, token)
|
|
12
|
+
await _client.connect()
|
|
13
|
+
return _client
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class RelayClient {
|
|
17
|
+
#ws = null
|
|
18
|
+
#token
|
|
19
|
+
#port
|
|
20
|
+
#pendingCalls = new Map()
|
|
21
|
+
#eventHandlers = new Map()
|
|
22
|
+
#nextId = (() => { let i = 0; return () => ++i })()
|
|
23
|
+
|
|
24
|
+
constructor(port, token) {
|
|
25
|
+
this.#port = port
|
|
26
|
+
this.#token = token
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async connect() {
|
|
30
|
+
const { WebSocket } = await import('ws')
|
|
31
|
+
await new Promise((resolve, reject) => {
|
|
32
|
+
this.#ws = new WebSocket(`ws://127.0.0.1:${this.#port}`)
|
|
33
|
+
this.#ws.once('error', reject)
|
|
34
|
+
this.#ws.once('open', () => {
|
|
35
|
+
this.#ws.off('error', reject)
|
|
36
|
+
this.#ws.on('error', () => {})
|
|
37
|
+
this.#ws.on('message', (data) => this.#onMessage(data))
|
|
38
|
+
// send handshake with cli role
|
|
39
|
+
this.#ws.send(JSON.stringify({
|
|
40
|
+
jsonrpc: '2.0', method: 'handshake', params: { token: this.#token, role: 'cli' }, id: 0,
|
|
41
|
+
}))
|
|
42
|
+
resolve()
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
close() {
|
|
48
|
+
this.#ws?.terminate()
|
|
49
|
+
this.#ws = null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async call(method, params = {}, timeoutMs = 15000) {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const id = this.#nextId()
|
|
55
|
+
const timer = setTimeout(() => {
|
|
56
|
+
this.#pendingCalls.delete(id)
|
|
57
|
+
reject(new Error(`Timeout waiting for ${method}`))
|
|
58
|
+
}, timeoutMs)
|
|
59
|
+
this.#pendingCalls.set(id, { resolve, reject, timer })
|
|
60
|
+
this.#ws.send(JSON.stringify({ jsonrpc: '2.0', method, params, id }))
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
on(event, handler) {
|
|
65
|
+
if (!this.#eventHandlers.has(event)) this.#eventHandlers.set(event, new Set())
|
|
66
|
+
this.#eventHandlers.get(event).add(handler)
|
|
67
|
+
return () => this.#eventHandlers.get(event)?.delete(handler)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
#onMessage(data) {
|
|
71
|
+
let msg
|
|
72
|
+
try { msg = JSON.parse(data.toString()) } catch { return }
|
|
73
|
+
|
|
74
|
+
if (msg.id !== undefined && (msg.result !== undefined || msg.error)) {
|
|
75
|
+
const pending = this.#pendingCalls.get(msg.id)
|
|
76
|
+
if (pending) {
|
|
77
|
+
clearTimeout(pending.timer)
|
|
78
|
+
this.#pendingCalls.delete(msg.id)
|
|
79
|
+
if (msg.error) {
|
|
80
|
+
const e = new Error(msg.error.message)
|
|
81
|
+
e.code = msg.error.code
|
|
82
|
+
e.data = msg.error.data
|
|
83
|
+
pending.reject(e)
|
|
84
|
+
} else {
|
|
85
|
+
pending.resolve(msg.result)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (msg.method && msg.id === undefined) {
|
|
92
|
+
const handlers = this.#eventHandlers.get(msg.method)
|
|
93
|
+
if (handlers) handlers.forEach((fn) => fn(msg.params))
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { getClient } from '../client.js'
|
|
2
|
+
import { out, err } from '../output.js'
|
|
3
|
+
|
|
4
|
+
export const command = 'click'
|
|
5
|
+
export const describe = 'Simulate a mouse click on a DOM element'
|
|
6
|
+
export const builder = {
|
|
7
|
+
selector: { type: 'string', demandOption: true, describe: 'CSS selector for target element' },
|
|
8
|
+
port: { type: 'number', default: 9876, describe: 'Relay port' },
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function handler(argv) {
|
|
12
|
+
try {
|
|
13
|
+
const client = await getClient(argv.port)
|
|
14
|
+
const result = await client.call('page.click', { selector: argv.selector })
|
|
15
|
+
out(result)
|
|
16
|
+
client.close()
|
|
17
|
+
if (result?.error) process.exit(1)
|
|
18
|
+
} catch (e) {
|
|
19
|
+
err(e.message)
|
|
20
|
+
process.exit(1)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { getClient } from '../client.js'
|
|
2
|
+
import { out, err } from '../output.js'
|
|
3
|
+
|
|
4
|
+
export const command = 'devtools <subcommand>'
|
|
5
|
+
export const describe = 'Access Chrome DevTools data (performance, memory, coverage)'
|
|
6
|
+
export const builder = (yargs) => {
|
|
7
|
+
yargs
|
|
8
|
+
.command({
|
|
9
|
+
command: 'performance',
|
|
10
|
+
describe: 'Collect runtime performance metrics',
|
|
11
|
+
builder: { port: { type: 'number', default: 9876 } },
|
|
12
|
+
async handler(argv) {
|
|
13
|
+
try {
|
|
14
|
+
const client = await getClient(argv.port)
|
|
15
|
+
const result = await client.call('devtools.performance', {})
|
|
16
|
+
out(result)
|
|
17
|
+
client.close()
|
|
18
|
+
} catch (e) { err(e.message); process.exit(1) }
|
|
19
|
+
},
|
|
20
|
+
})
|
|
21
|
+
.command({
|
|
22
|
+
command: 'memory',
|
|
23
|
+
describe: 'Take a heap memory snapshot',
|
|
24
|
+
builder: {
|
|
25
|
+
output: { type: 'string', alias: 'o', describe: 'Output file path (default: ./heap-<ts>.heapsnapshot)' },
|
|
26
|
+
port: { type: 'number', default: 9876 },
|
|
27
|
+
},
|
|
28
|
+
async handler(argv) {
|
|
29
|
+
try {
|
|
30
|
+
const client = await getClient(argv.port)
|
|
31
|
+
const output = argv.output || `./heap-${Date.now()}.heapsnapshot`
|
|
32
|
+
const result = await client.call('devtools.memory', { output })
|
|
33
|
+
out(result)
|
|
34
|
+
client.close()
|
|
35
|
+
} catch (e) { err(e.message); process.exit(1) }
|
|
36
|
+
},
|
|
37
|
+
})
|
|
38
|
+
.command({
|
|
39
|
+
command: 'coverage',
|
|
40
|
+
describe: 'Capture JavaScript code coverage',
|
|
41
|
+
builder: {
|
|
42
|
+
duration: { type: 'number', default: 5000, describe: 'Recording duration in ms' },
|
|
43
|
+
port: { type: 'number', default: 9876 },
|
|
44
|
+
},
|
|
45
|
+
async handler(argv) {
|
|
46
|
+
try {
|
|
47
|
+
const client = await getClient(argv.port)
|
|
48
|
+
const result = await client.call('devtools.coverage', { duration: argv.duration })
|
|
49
|
+
out(result)
|
|
50
|
+
client.close()
|
|
51
|
+
} catch (e) { err(e.message); process.exit(1) }
|
|
52
|
+
},
|
|
53
|
+
})
|
|
54
|
+
.demandCommand(1, 'Specify a devtools subcommand: performance | memory | coverage')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function handler() {}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { getClient } from '../client.js'
|
|
2
|
+
import { out, err } from '../output.js'
|
|
3
|
+
|
|
4
|
+
export const command = 'exec'
|
|
5
|
+
export const describe = 'Execute JavaScript in the selected tab'
|
|
6
|
+
export const builder = {
|
|
7
|
+
code: { type: 'string', demandOption: true, describe: 'JavaScript expression or statement' },
|
|
8
|
+
port: { type: 'number', default: 9876, describe: 'Relay port' },
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function handler(argv) {
|
|
12
|
+
try {
|
|
13
|
+
const client = await getClient(argv.port)
|
|
14
|
+
const result = await client.call('page.exec', { code: argv.code })
|
|
15
|
+
out(result)
|
|
16
|
+
client.close()
|
|
17
|
+
if (result?.error) process.exit(1)
|
|
18
|
+
} catch (e) {
|
|
19
|
+
err(e.message, e.data)
|
|
20
|
+
process.exit(1)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { getClient } from '../client.js'
|
|
2
|
+
import { out, err } from '../output.js'
|
|
3
|
+
|
|
4
|
+
export const command = 'hover'
|
|
5
|
+
export const describe = 'Simulate a mouse hover over a DOM element'
|
|
6
|
+
export const builder = {
|
|
7
|
+
selector: { type: 'string', demandOption: true, describe: 'CSS selector for target element' },
|
|
8
|
+
port: { type: 'number', default: 9876, describe: 'Relay port' },
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function handler(argv) {
|
|
12
|
+
try {
|
|
13
|
+
const client = await getClient(argv.port)
|
|
14
|
+
const result = await client.call('page.hover', { selector: argv.selector })
|
|
15
|
+
out(result)
|
|
16
|
+
client.close()
|
|
17
|
+
if (result?.error) process.exit(1)
|
|
18
|
+
} catch (e) {
|
|
19
|
+
err(e.message)
|
|
20
|
+
process.exit(1)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { getClient } from '../client.js'
|
|
2
|
+
import { out, err } from '../output.js'
|
|
3
|
+
import { readFile } from 'node:fs/promises'
|
|
4
|
+
|
|
5
|
+
export const command = 'inject'
|
|
6
|
+
export const describe = 'Inject a local JavaScript file into the selected tab'
|
|
7
|
+
export const builder = {
|
|
8
|
+
file: { type: 'string', demandOption: true, describe: 'Path to JS file to inject' },
|
|
9
|
+
port: { type: 'number', default: 9876, describe: 'Relay port' },
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function handler(argv) {
|
|
13
|
+
let code
|
|
14
|
+
try {
|
|
15
|
+
code = await readFile(argv.file, 'utf8')
|
|
16
|
+
} catch {
|
|
17
|
+
err(`file_not_found: ${argv.file}`)
|
|
18
|
+
process.exit(1)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const client = await getClient(argv.port)
|
|
23
|
+
const result = await client.call('page.inject', { code })
|
|
24
|
+
out(result)
|
|
25
|
+
client.close()
|
|
26
|
+
if (result?.error) process.exit(1)
|
|
27
|
+
} catch (e) {
|
|
28
|
+
err(e.message)
|
|
29
|
+
process.exit(1)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { getClient } from '../client.js'
|
|
2
|
+
import { out, outLine, err } from '../output.js'
|
|
3
|
+
|
|
4
|
+
export const command = 'logs'
|
|
5
|
+
export const describe = 'Read console or network logs from the selected tab'
|
|
6
|
+
export const builder = {
|
|
7
|
+
follow: { type: 'boolean', alias: 'f', describe: 'Stream logs in real time (legacy alias for --watch)' },
|
|
8
|
+
watch: { type: 'boolean', alias: 'w', describe: 'Stream logs in real time via push events' },
|
|
9
|
+
level: { type: 'string', describe: 'Filter by level: log|warn|error|info|debug' },
|
|
10
|
+
network: { type: 'boolean', describe: 'Show network requests instead of console logs' },
|
|
11
|
+
port: { type: 'number', default: 9876, describe: 'Relay port' },
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function handler(argv) {
|
|
15
|
+
try {
|
|
16
|
+
const client = await getClient(argv.port)
|
|
17
|
+
|
|
18
|
+
if (argv.network) {
|
|
19
|
+
const result = await client.call('page.network', {})
|
|
20
|
+
out(result)
|
|
21
|
+
client.close()
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (argv.watch || argv.follow) {
|
|
26
|
+
// Print buffered logs first, then stream push events
|
|
27
|
+
const buffered = await client.call('page.logs', { level: argv.level })
|
|
28
|
+
buffered.forEach((entry) => outLine(entry))
|
|
29
|
+
|
|
30
|
+
// Subscribe to push events (stream.log is canonical; page:log is legacy)
|
|
31
|
+
client.on('stream.log', (entry) => {
|
|
32
|
+
if (!argv.level || entry.level === argv.level) outLine(entry)
|
|
33
|
+
})
|
|
34
|
+
client.on('page:log', () => {}) // drain legacy events silently
|
|
35
|
+
|
|
36
|
+
process.on('SIGINT', () => { client.close(); process.exit(0) })
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const result = await client.call('page.logs', { level: argv.level })
|
|
41
|
+
out(result)
|
|
42
|
+
client.close()
|
|
43
|
+
} catch (e) {
|
|
44
|
+
err(e.message)
|
|
45
|
+
process.exit(1)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { getClient } from '../client.js'
|
|
2
|
+
import { out, err } from '../output.js'
|
|
3
|
+
|
|
4
|
+
export const command = 'navigate'
|
|
5
|
+
export const describe = 'Navigate the selected tab to a URL'
|
|
6
|
+
export const builder = {
|
|
7
|
+
url: { type: 'string', demandOption: true, describe: 'URL to navigate to' },
|
|
8
|
+
port: { type: 'number', default: 9876, describe: 'Relay port' },
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function handler(argv) {
|
|
12
|
+
try {
|
|
13
|
+
const client = await getClient(argv.port)
|
|
14
|
+
const result = await client.call('page.navigate', { url: argv.url })
|
|
15
|
+
out(result)
|
|
16
|
+
client.close()
|
|
17
|
+
} catch (e) {
|
|
18
|
+
err(e.message)
|
|
19
|
+
process.exit(1)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { getClient } from '../client.js'
|
|
2
|
+
import { out, err } from '../output.js'
|
|
3
|
+
|
|
4
|
+
export const command = 'query'
|
|
5
|
+
export const describe = 'Query the DOM of the selected tab'
|
|
6
|
+
export const builder = {
|
|
7
|
+
selector: { type: 'string', describe: 'CSS selector to query' },
|
|
8
|
+
html: { type: 'boolean', describe: 'Return full page HTML' },
|
|
9
|
+
port: { type: 'number', default: 9876, describe: 'Relay port' },
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function handler(argv) {
|
|
13
|
+
if (!argv.selector && !argv.html) {
|
|
14
|
+
err('Provide --selector <css> or --html')
|
|
15
|
+
process.exit(1)
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
const client = await getClient(argv.port)
|
|
19
|
+
const result = await client.call('page.query', {
|
|
20
|
+
selector: argv.selector,
|
|
21
|
+
full: argv.html ?? false,
|
|
22
|
+
})
|
|
23
|
+
out(result)
|
|
24
|
+
client.close()
|
|
25
|
+
} catch (e) {
|
|
26
|
+
err(e.message)
|
|
27
|
+
process.exit(1)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline'
|
|
2
|
+
import { getClient } from '../client.js'
|
|
3
|
+
import { err } from '../output.js'
|
|
4
|
+
|
|
5
|
+
export const command = 'repl'
|
|
6
|
+
export const describe = 'Start an interactive JavaScript REPL in the selected tab'
|
|
7
|
+
export const builder = {
|
|
8
|
+
port: { type: 'number', default: 9876, describe: 'Relay port' },
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function handler(argv) {
|
|
12
|
+
let client
|
|
13
|
+
try {
|
|
14
|
+
client = await getClient(argv.port)
|
|
15
|
+
} catch (e) {
|
|
16
|
+
err(e.message)
|
|
17
|
+
process.exit(1)
|
|
18
|
+
}
|
|
19
|
+
await runRepl(process.stdin, process.stdout, client)
|
|
20
|
+
client.close()
|
|
21
|
+
process.exit(0)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Run the REPL loop.
|
|
26
|
+
* Accepts injectable input/output streams and a client for testability.
|
|
27
|
+
* @param {import('node:stream').Readable} input
|
|
28
|
+
* @param {import('node:stream').Writable} output
|
|
29
|
+
* @param {{ call: (method: string, params: object) => Promise<any> }} client
|
|
30
|
+
* @returns {Promise<void>} resolves when the REPL exits
|
|
31
|
+
*/
|
|
32
|
+
export async function runRepl(input, output, client) {
|
|
33
|
+
const isTTY = output.isTTY ?? false
|
|
34
|
+
|
|
35
|
+
if (isTTY) {
|
|
36
|
+
output.write('chrome-bridge REPL — JavaScript in the selected tab\n')
|
|
37
|
+
output.write('Type .exit or press Ctrl+C/Ctrl+D to quit.\n')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const rl = createInterface({ input, output: isTTY ? output : undefined, terminal: false, crlfDelay: Infinity })
|
|
41
|
+
|
|
42
|
+
if (isTTY) rl.setPrompt('> ')
|
|
43
|
+
|
|
44
|
+
return new Promise((resolve) => {
|
|
45
|
+
rl.on('line', async (line) => {
|
|
46
|
+
const code = line.trim()
|
|
47
|
+
if (!code || code === '.exit') { rl.close(); return }
|
|
48
|
+
rl.pause()
|
|
49
|
+
try {
|
|
50
|
+
const res = await client.call('page.exec', { code })
|
|
51
|
+
output.write(JSON.stringify(res.result) + '\n')
|
|
52
|
+
} catch (e) {
|
|
53
|
+
output.write(`Error: ${e.message}\n`)
|
|
54
|
+
}
|
|
55
|
+
rl.resume()
|
|
56
|
+
if (isTTY) rl.prompt()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
rl.on('close', resolve)
|
|
60
|
+
|
|
61
|
+
if (isTTY) rl.prompt()
|
|
62
|
+
})
|
|
63
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { getClient } from '../client.js'
|
|
2
|
+
import { out, err } from '../output.js'
|
|
3
|
+
import { writeFile } from 'node:fs/promises'
|
|
4
|
+
|
|
5
|
+
export const command = 'screenshot'
|
|
6
|
+
export const describe = 'Capture a screenshot of the selected tab'
|
|
7
|
+
export const builder = {
|
|
8
|
+
output: { type: 'string', alias: 'o', describe: 'Save PNG to file path instead of printing base64' },
|
|
9
|
+
port: { type: 'number', default: 9876, describe: 'Relay port' },
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function handler(argv) {
|
|
13
|
+
try {
|
|
14
|
+
const client = await getClient(argv.port)
|
|
15
|
+
const result = await client.call('page.screenshot', {})
|
|
16
|
+
client.close()
|
|
17
|
+
|
|
18
|
+
if (argv.output) {
|
|
19
|
+
const base64 = result.dataUrl.replace(/^data:image\/png;base64,/, '')
|
|
20
|
+
await writeFile(argv.output, Buffer.from(base64, 'base64'))
|
|
21
|
+
out({ saved: argv.output })
|
|
22
|
+
} else {
|
|
23
|
+
out(result)
|
|
24
|
+
}
|
|
25
|
+
} catch (e) {
|
|
26
|
+
err(e.message)
|
|
27
|
+
process.exit(1)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { getClient } from '../client.js'
|
|
2
|
+
import { out, err } from '../output.js'
|
|
3
|
+
|
|
4
|
+
export const command = 'snapshot'
|
|
5
|
+
export const describe = 'Capture the full HTML of the current page'
|
|
6
|
+
export const builder = {
|
|
7
|
+
styles: { type: 'boolean', default: false, describe: 'Include computed styles inline' },
|
|
8
|
+
port: { type: 'number', default: 9876, describe: 'Relay port' },
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function handler(argv) {
|
|
12
|
+
try {
|
|
13
|
+
const client = await getClient(argv.port)
|
|
14
|
+
const result = await client.call('page.snapshot', { styles: argv.styles })
|
|
15
|
+
out(result)
|
|
16
|
+
client.close()
|
|
17
|
+
} catch (e) {
|
|
18
|
+
err(e.message)
|
|
19
|
+
process.exit(1)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Relay } from '../relay.js'
|
|
2
|
+
import { readToken, TOKEN_PATH } from '../token.js'
|
|
3
|
+
import { writeFile, readFile, unlink } from 'node:fs/promises'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { homedir } from 'node:os'
|
|
6
|
+
|
|
7
|
+
const PID_FILE = join(homedir(), '.chrome-cli-bridge.pid')
|
|
8
|
+
|
|
9
|
+
export const command = 'start'
|
|
10
|
+
export const describe = 'Start the relay server'
|
|
11
|
+
export const builder = {
|
|
12
|
+
port: { type: 'number', default: 9876, describe: 'Port to listen on' },
|
|
13
|
+
detach: { type: 'boolean', default: false, describe: 'Run in background (daemon mode)' },
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function handler(argv) {
|
|
17
|
+
const relay = new Relay(argv.port)
|
|
18
|
+
try {
|
|
19
|
+
const token = await relay.start()
|
|
20
|
+
await writeFile(PID_FILE, String(process.pid), 'utf8')
|
|
21
|
+
console.log(`Relay started on ws://127.0.0.1:${argv.port}`)
|
|
22
|
+
console.log(`Session token: ${token}`)
|
|
23
|
+
console.log(`Token saved to: ${TOKEN_PATH}`)
|
|
24
|
+
console.log('')
|
|
25
|
+
console.log('Open the chrome-cli-bridge extension popup in Chrome, paste the token, and click Connect.')
|
|
26
|
+
console.log('Press Ctrl+C to stop.')
|
|
27
|
+
|
|
28
|
+
process.on('SIGINT', async () => {
|
|
29
|
+
await relay.stop()
|
|
30
|
+
await unlink(PID_FILE).catch(() => {})
|
|
31
|
+
process.exit(0)
|
|
32
|
+
})
|
|
33
|
+
process.on('SIGTERM', async () => {
|
|
34
|
+
await relay.stop()
|
|
35
|
+
await unlink(PID_FILE).catch(() => {})
|
|
36
|
+
process.exit(0)
|
|
37
|
+
})
|
|
38
|
+
} catch (e) {
|
|
39
|
+
console.error(e.message)
|
|
40
|
+
process.exit(1)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { out } from '../output.js'
|
|
5
|
+
import { readToken } from '../token.js'
|
|
6
|
+
import { RelayClient } from '../client.js'
|
|
7
|
+
|
|
8
|
+
const PID_FILE = join(homedir(), '.chrome-cli-bridge.pid')
|
|
9
|
+
|
|
10
|
+
export const command = 'status'
|
|
11
|
+
export const describe = 'Check relay and extension connection status'
|
|
12
|
+
export const builder = {
|
|
13
|
+
port: { type: 'number', default: 9876, describe: 'Relay port' },
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function handler(argv) {
|
|
17
|
+
const result = { relay: 'stopped', extension: 'disconnected', pid: null, token: null }
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const pid = parseInt(await readFile(PID_FILE, 'utf8'), 10)
|
|
21
|
+
process.kill(pid, 0) // check if alive
|
|
22
|
+
result.relay = 'running'
|
|
23
|
+
result.pid = pid
|
|
24
|
+
} catch { /* not running */ }
|
|
25
|
+
|
|
26
|
+
result.token = (await readToken()) ? '(set)' : '(not set)'
|
|
27
|
+
|
|
28
|
+
if (result.relay === 'running') {
|
|
29
|
+
try {
|
|
30
|
+
const token = await readToken()
|
|
31
|
+
const client = new RelayClient(argv.port, token)
|
|
32
|
+
await client.connect()
|
|
33
|
+
// If connect succeeded, relay responded; check if extension is attached
|
|
34
|
+
const tabs = await client.call('tabs.list', {}).catch(() => null)
|
|
35
|
+
result.extension = tabs !== null ? 'connected' : 'not connected'
|
|
36
|
+
client.close()
|
|
37
|
+
} catch {
|
|
38
|
+
result.extension = 'not connected'
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
out(result)
|
|
43
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { readFile, unlink } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
|
|
5
|
+
const PID_FILE = join(homedir(), '.chrome-cli-bridge.pid')
|
|
6
|
+
|
|
7
|
+
export const command = 'stop'
|
|
8
|
+
export const describe = 'Stop the relay server'
|
|
9
|
+
export const builder = {}
|
|
10
|
+
|
|
11
|
+
export async function handler() {
|
|
12
|
+
try {
|
|
13
|
+
const pid = parseInt(await readFile(PID_FILE, 'utf8'), 10)
|
|
14
|
+
process.kill(pid, 'SIGTERM')
|
|
15
|
+
await unlink(PID_FILE).catch(() => {})
|
|
16
|
+
console.log(`Stopped relay (pid ${pid})`)
|
|
17
|
+
} catch (e) {
|
|
18
|
+
if (e.code === 'ENOENT') {
|
|
19
|
+
console.error('No relay is running (no PID file found)')
|
|
20
|
+
} else if (e.code === 'ESRCH') {
|
|
21
|
+
console.error('Relay process not found — cleaning up stale PID file')
|
|
22
|
+
await unlink(PID_FILE).catch(() => {})
|
|
23
|
+
} else {
|
|
24
|
+
console.error(e.message)
|
|
25
|
+
}
|
|
26
|
+
process.exit(1)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { getClient } from '../client.js'
|
|
2
|
+
import { out, err } from '../output.js'
|
|
3
|
+
|
|
4
|
+
export const command = 'storage'
|
|
5
|
+
export const describe = 'Read or write cookies, localStorage, or sessionStorage'
|
|
6
|
+
export const builder = {
|
|
7
|
+
type: { type: 'string', choices: ['local', 'session', 'cookies'], demandOption: true, describe: 'Storage type' },
|
|
8
|
+
key: { type: 'string', describe: 'Key to read or write' },
|
|
9
|
+
set: { type: 'string', describe: 'Value to write (requires --key)' },
|
|
10
|
+
port: { type: 'number', default: 9876, describe: 'Relay port' },
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function handler(argv) {
|
|
14
|
+
try {
|
|
15
|
+
const client = await getClient(argv.port)
|
|
16
|
+
const params = { type: argv.type }
|
|
17
|
+
if (argv.key !== undefined) params.key = argv.key
|
|
18
|
+
if (argv.set !== undefined) params.set = argv.set
|
|
19
|
+
const result = await client.call('page.storage', params)
|
|
20
|
+
out(result)
|
|
21
|
+
client.close()
|
|
22
|
+
} catch (e) {
|
|
23
|
+
err(e.message)
|
|
24
|
+
process.exit(1)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { getClient } from '../client.js'
|
|
2
|
+
import { out, err } from '../output.js'
|
|
3
|
+
|
|
4
|
+
export const command = 'tabs'
|
|
5
|
+
export const describe = 'List open tabs or select a target tab'
|
|
6
|
+
export const builder = {
|
|
7
|
+
select: { type: 'string', describe: 'Select a tab by ID or "active"' },
|
|
8
|
+
port: { type: 'number', default: 9876, describe: 'Relay port' },
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function handler(argv) {
|
|
12
|
+
try {
|
|
13
|
+
const client = await getClient(argv.port)
|
|
14
|
+
|
|
15
|
+
if (argv.select !== undefined) {
|
|
16
|
+
const tabId = argv.select === 'active' ? 'active' : parseInt(argv.select, 10)
|
|
17
|
+
const result = await client.call('tabs.select', { tabId })
|
|
18
|
+
out(result)
|
|
19
|
+
} else {
|
|
20
|
+
const result = await client.call('tabs.list', {})
|
|
21
|
+
out(result)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
client.close()
|
|
25
|
+
} catch (e) {
|
|
26
|
+
err(e.message)
|
|
27
|
+
process.exit(1)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { getClient } from '../client.js'
|
|
2
|
+
import { out, err } from '../output.js'
|
|
3
|
+
|
|
4
|
+
export const command = 'trigger'
|
|
5
|
+
export const describe = 'Dispatch a DOM event on an element in the selected tab'
|
|
6
|
+
export const builder = {
|
|
7
|
+
selector: { type: 'string', demandOption: true, describe: 'CSS selector for the target element' },
|
|
8
|
+
event: { type: 'string', demandOption: true, describe: 'Event type: click|input|change|submit|keydown|keyup' },
|
|
9
|
+
port: { type: 'number', default: 9876, describe: 'Relay port' },
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function handler(argv) {
|
|
13
|
+
try {
|
|
14
|
+
const client = await getClient(argv.port)
|
|
15
|
+
const result = await client.call('page.trigger', { selector: argv.selector, event: argv.event })
|
|
16
|
+
out(result)
|
|
17
|
+
client.close()
|
|
18
|
+
} catch (e) {
|
|
19
|
+
err(e.message)
|
|
20
|
+
process.exit(e.code === -32001 ? 1 : 1)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { getClient } from '../client.js'
|
|
2
|
+
import { out, err } from '../output.js'
|
|
3
|
+
|
|
4
|
+
export const command = 'type'
|
|
5
|
+
export const describe = 'Simulate keyboard typing into a DOM element'
|
|
6
|
+
export const builder = {
|
|
7
|
+
selector: { type: 'string', demandOption: true, describe: 'CSS selector for target element' },
|
|
8
|
+
text: { type: 'string', demandOption: true, describe: 'Text to type' },
|
|
9
|
+
port: { type: 'number', default: 9876, describe: 'Relay port' },
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function handler(argv) {
|
|
13
|
+
try {
|
|
14
|
+
const client = await getClient(argv.port)
|
|
15
|
+
const result = await client.call('page.type', { selector: argv.selector, text: argv.text })
|
|
16
|
+
out(result)
|
|
17
|
+
client.close()
|
|
18
|
+
if (result?.error) process.exit(1)
|
|
19
|
+
} catch (e) {
|
|
20
|
+
err(e.message)
|
|
21
|
+
process.exit(1)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { getClient } from '../client.js'
|
|
2
|
+
import { out, err } from '../output.js'
|
|
3
|
+
|
|
4
|
+
export const command = 'wait'
|
|
5
|
+
export const describe = 'Wait until a CSS selector appears in the DOM'
|
|
6
|
+
export const builder = {
|
|
7
|
+
selector: { type: 'string', demandOption: true, describe: 'CSS selector to wait for' },
|
|
8
|
+
timeout: { type: 'number', default: 5000, describe: 'Timeout in ms (default 5000)' },
|
|
9
|
+
port: { type: 'number', default: 9876, describe: 'Relay port' },
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function handler(argv) {
|
|
13
|
+
try {
|
|
14
|
+
const client = await getClient(argv.port)
|
|
15
|
+
const result = await client.call('page.wait', { selector: argv.selector, timeout: argv.timeout })
|
|
16
|
+
out(result)
|
|
17
|
+
client.close()
|
|
18
|
+
if (!result.found) process.exit(1)
|
|
19
|
+
} catch (e) {
|
|
20
|
+
err(e.message)
|
|
21
|
+
process.exit(1)
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Relay } from './relay.js'
|
|
2
|
+
import { RelayClient } from './client.js'
|
|
3
|
+
import { readToken } from './token.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* ChromeBridge SDK — programmatic interface for Node.js consumers.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* const bridge = new ChromeBridge()
|
|
10
|
+
* await bridge.connect()
|
|
11
|
+
* const tabs = await bridge.tabs()
|
|
12
|
+
* await bridge.selectTab(tabs[0].id)
|
|
13
|
+
* const { result } = await bridge.exec({ code: 'document.title' })
|
|
14
|
+
* await bridge.disconnect()
|
|
15
|
+
*/
|
|
16
|
+
export class ChromeBridge {
|
|
17
|
+
#client = null
|
|
18
|
+
#port
|
|
19
|
+
|
|
20
|
+
constructor({ port = 9876 } = {}) {
|
|
21
|
+
this.#port = port
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async connect() {
|
|
25
|
+
const token = await readToken()
|
|
26
|
+
if (!token) throw new Error('No session token found. Run: chrome-bridge start')
|
|
27
|
+
this.#client = new RelayClient(this.#port, token)
|
|
28
|
+
await this.#client.connect()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async disconnect() {
|
|
32
|
+
this.#client?.close()
|
|
33
|
+
this.#client = null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#call(method, params) {
|
|
37
|
+
if (!this.#client) throw new Error('Not connected. Call bridge.connect() first.')
|
|
38
|
+
return this.#client.call(method, params)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Existing commands ────────────────────────────────────────────────────
|
|
42
|
+
tabs() { return this.#call('tabs.list', {}) }
|
|
43
|
+
selectTab(tabId) { return this.#call('tabs.select', { tabId }) }
|
|
44
|
+
query(params) { return this.#call('page.query', params) }
|
|
45
|
+
exec(params) { return this.#call('page.exec', params) }
|
|
46
|
+
logs(params = {}) { return this.#call('page.logs', params) }
|
|
47
|
+
network() { return this.#call('page.network', {}) }
|
|
48
|
+
trigger(params) { return this.#call('page.trigger', params) }
|
|
49
|
+
|
|
50
|
+
// ── New commands ─────────────────────────────────────────────────────────
|
|
51
|
+
screenshot(params = {}) { return this.#call('page.screenshot', params) }
|
|
52
|
+
navigate(params) { return this.#call('page.navigate', params) }
|
|
53
|
+
storage(params) { return this.#call('page.storage', params) }
|
|
54
|
+
wait(params) { return this.#call('page.wait', params) }
|
|
55
|
+
inject(params) { return this.#call('page.inject', params) }
|
|
56
|
+
snapshot(params = {}) { return this.#call('page.snapshot', params) }
|
|
57
|
+
type(params) { return this.#call('page.type', params) }
|
|
58
|
+
click(params) { return this.#call('page.click', params) }
|
|
59
|
+
hover(params) { return this.#call('page.hover', params) }
|
|
60
|
+
|
|
61
|
+
/** Subscribe to real-time console log push events.
|
|
62
|
+
* @returns {() => void} unsubscribe function */
|
|
63
|
+
streamLogs(handler) {
|
|
64
|
+
if (!this.#client) throw new Error('Not connected.')
|
|
65
|
+
return this.#client.on('stream.log', handler)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** DevTools namespace */
|
|
69
|
+
get devtools() {
|
|
70
|
+
return {
|
|
71
|
+
performance: () => this.#call('devtools.performance', {}),
|
|
72
|
+
memory: (params = {}) => this.#call('devtools.memory', params),
|
|
73
|
+
coverage: (params = {}) => this.#call('devtools.coverage', params),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
on(event, handler) {
|
|
78
|
+
if (!this.#client) throw new Error('Not connected.')
|
|
79
|
+
return this.#client.on(event, handler)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export { Relay, RelayClient, readToken }
|
package/src/output.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const isTTY = process.stdout.isTTY
|
|
2
|
+
|
|
3
|
+
export function out(data) {
|
|
4
|
+
if (isTTY) {
|
|
5
|
+
prettyPrint(data)
|
|
6
|
+
} else {
|
|
7
|
+
process.stdout.write(JSON.stringify(data) + '\n')
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function outLine(data) {
|
|
12
|
+
process.stdout.write(JSON.stringify(data) + '\n')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function err(msg, data) {
|
|
16
|
+
const obj = { error: msg, ...(data && { data }) }
|
|
17
|
+
if (isTTY) {
|
|
18
|
+
process.stderr.write(`\x1b[31mError:\x1b[0m ${msg}${data ? '\n' + JSON.stringify(data, null, 2) : ''}\n`)
|
|
19
|
+
} else {
|
|
20
|
+
process.stderr.write(JSON.stringify(obj) + '\n')
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function prettyPrint(data) {
|
|
25
|
+
if (Array.isArray(data)) {
|
|
26
|
+
if (data.length === 0) { console.log('(empty)'); return }
|
|
27
|
+
// Detect tab list shape
|
|
28
|
+
if (data[0]?.id !== undefined && data[0]?.url !== undefined) {
|
|
29
|
+
const rows = data.map((t) =>
|
|
30
|
+
` [${t.id}] ${t.active ? '●' : '○'} ${t.title?.slice(0, 50).padEnd(50)} ${t.url}`)
|
|
31
|
+
console.log(rows.join('\n'))
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
// Detect element list shape
|
|
35
|
+
if (data[0]?.tag !== undefined) {
|
|
36
|
+
data.forEach((el, i) => {
|
|
37
|
+
console.log(` [${i}] <${el.tag}${el.id ? '#' + el.id : ''}> ${el.textContent?.slice(0, 80)}`)
|
|
38
|
+
})
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
console.log(JSON.stringify(data, null, 2))
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
if (typeof data === 'object' && data !== null) {
|
|
45
|
+
// Log entry shape
|
|
46
|
+
if (data.level && data.message && data.timestamp) {
|
|
47
|
+
const d = new Date(data.timestamp).toISOString()
|
|
48
|
+
const lvl = data.level.toUpperCase().padEnd(5)
|
|
49
|
+
console.log(` ${d} [${lvl}] ${data.message}`)
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
console.log(JSON.stringify(data, null, 2))
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
console.log(data)
|
|
56
|
+
}
|
package/src/relay.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { WebSocketServer } from 'ws'
|
|
2
|
+
import { generateToken, readToken } from './token.js'
|
|
3
|
+
import { isRequest, isResponse, isEvent, error, ERR } from './rpc.js'
|
|
4
|
+
|
|
5
|
+
const HANDSHAKE_TIMEOUT_MS = 5000
|
|
6
|
+
const KEEPALIVE_INTERVAL_MS = 20000
|
|
7
|
+
|
|
8
|
+
export class Relay {
|
|
9
|
+
#port
|
|
10
|
+
#wss = null
|
|
11
|
+
#extension = null // the authenticated extension WS
|
|
12
|
+
#cliClients = new Set() // all authenticated CLI client sockets
|
|
13
|
+
#token = null
|
|
14
|
+
#pendingCalls = new Map() // id → { resolve, reject, timer }
|
|
15
|
+
#eventHandlers = new Map() // method → Set<fn>
|
|
16
|
+
#keepaliveTimer = null
|
|
17
|
+
|
|
18
|
+
constructor(port = 9876) {
|
|
19
|
+
this.#port = port
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
get port() { return this.#port }
|
|
23
|
+
get token() { return this.#token }
|
|
24
|
+
get connected() { return this.#extension !== null }
|
|
25
|
+
|
|
26
|
+
async start() {
|
|
27
|
+
this.#token = await generateToken()
|
|
28
|
+
|
|
29
|
+
await new Promise((resolve, reject) => {
|
|
30
|
+
this.#wss = new WebSocketServer({ host: '127.0.0.1', port: this.#port })
|
|
31
|
+
|
|
32
|
+
this.#wss.once('error', (err) => {
|
|
33
|
+
if (err.code === 'EADDRINUSE') {
|
|
34
|
+
reject(new Error(`Port ${this.#port} is already in use. Is chrome-bridge already running?\nRun: chrome-bridge stop`))
|
|
35
|
+
} else {
|
|
36
|
+
reject(err)
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
this.#wss.once('listening', resolve)
|
|
41
|
+
this.#wss.on('connection', (ws) => this.#onConnection(ws))
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
return this.#token
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async stop() {
|
|
48
|
+
clearInterval(this.#keepaliveTimer)
|
|
49
|
+
// Terminate ALL connected sockets (extension + any CLI clients) so wss.close()
|
|
50
|
+
// completes immediately instead of waiting for them to close on their own.
|
|
51
|
+
if (this.#wss) {
|
|
52
|
+
for (const ws of this.#wss.clients) ws.terminate()
|
|
53
|
+
}
|
|
54
|
+
this.#extension = null
|
|
55
|
+
await new Promise((resolve) => this.#wss ? this.#wss.close(resolve) : resolve())
|
|
56
|
+
this.#wss = null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Send a JSON-RPC request to the extension and await the response
|
|
60
|
+
async call(method, params = {}, timeoutMs = 10000) {
|
|
61
|
+
if (!this.#extension) throw new Error('Extension not connected')
|
|
62
|
+
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
const msg = { jsonrpc: '2.0', method, params, id: this.#nextId() }
|
|
65
|
+
const timer = setTimeout(() => {
|
|
66
|
+
this.#pendingCalls.delete(msg.id)
|
|
67
|
+
reject(new Error(`Timeout waiting for response to ${method}`))
|
|
68
|
+
}, timeoutMs)
|
|
69
|
+
|
|
70
|
+
this.#pendingCalls.set(msg.id, { resolve, reject, timer })
|
|
71
|
+
this.#extension.send(JSON.stringify(msg))
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
on(event, handler) {
|
|
76
|
+
if (!this.#eventHandlers.has(event)) this.#eventHandlers.set(event, new Set())
|
|
77
|
+
this.#eventHandlers.get(event).add(handler)
|
|
78
|
+
return () => this.#eventHandlers.get(event)?.delete(handler)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#nextId = (() => { let i = 0; return () => ++i })()
|
|
82
|
+
|
|
83
|
+
#onConnection(ws) {
|
|
84
|
+
let role = null // 'extension' | 'cli'
|
|
85
|
+
|
|
86
|
+
const handshakeTimer = setTimeout(() => {
|
|
87
|
+
if (!role) ws.close(4001, 'Unauthorized: handshake timeout')
|
|
88
|
+
}, HANDSHAKE_TIMEOUT_MS)
|
|
89
|
+
|
|
90
|
+
ws.on('message', (data) => {
|
|
91
|
+
let msg
|
|
92
|
+
try { msg = JSON.parse(data.toString()) } catch {
|
|
93
|
+
ws.send(JSON.stringify(error(null, ERR.PARSE_ERROR, 'Parse error')))
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!role) {
|
|
98
|
+
if (msg.method === 'handshake' && msg.params?.token === this.#token) {
|
|
99
|
+
clearTimeout(handshakeTimer)
|
|
100
|
+
role = msg.params?.role === 'cli' ? 'cli' : 'extension'
|
|
101
|
+
|
|
102
|
+
if (role === 'extension') {
|
|
103
|
+
this.#extension = ws
|
|
104
|
+
this.#startKeepalive()
|
|
105
|
+
} else if (role === 'cli') {
|
|
106
|
+
this.#cliClients.add(ws)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
ws.send(JSON.stringify({ jsonrpc: '2.0', result: { status: 'ok', role }, id: msg.id }))
|
|
110
|
+
} else {
|
|
111
|
+
ws.close(4001, 'Unauthorized')
|
|
112
|
+
}
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (role === 'cli') {
|
|
117
|
+
// Forward request to extension, send response back to this CLI client
|
|
118
|
+
if (!this.#extension) {
|
|
119
|
+
ws.send(JSON.stringify(error(msg.id, -32000, 'Extension not connected')))
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
const id = this.#nextId()
|
|
123
|
+
const forwarded = { ...msg, id }
|
|
124
|
+
const timer = setTimeout(() => {
|
|
125
|
+
this.#pendingCalls.delete(id)
|
|
126
|
+
ws.send(JSON.stringify(error(msg.id, -32000, 'Timeout')))
|
|
127
|
+
}, 10000)
|
|
128
|
+
this.#pendingCalls.set(id, {
|
|
129
|
+
resolve: (result) => ws.send(JSON.stringify({ jsonrpc: '2.0', result, id: msg.id })),
|
|
130
|
+
reject: (err) => ws.send(JSON.stringify(error(msg.id, err.code ?? -32000, err.message))),
|
|
131
|
+
timer,
|
|
132
|
+
})
|
|
133
|
+
this.#extension.send(JSON.stringify(forwarded))
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// role === 'extension': handle responses to our outgoing calls
|
|
138
|
+
if (isResponse(msg)) {
|
|
139
|
+
const pending = this.#pendingCalls.get(msg.id)
|
|
140
|
+
if (pending) {
|
|
141
|
+
clearTimeout(pending.timer)
|
|
142
|
+
this.#pendingCalls.delete(msg.id)
|
|
143
|
+
if (msg.error) {
|
|
144
|
+
const err = new Error(msg.error.message)
|
|
145
|
+
err.code = msg.error.code
|
|
146
|
+
err.data = msg.error.data
|
|
147
|
+
pending.reject(err)
|
|
148
|
+
} else {
|
|
149
|
+
pending.resolve(msg.result)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Handle push events from the extension — forward to local handlers AND all CLI clients
|
|
156
|
+
if (isEvent(msg)) {
|
|
157
|
+
const handlers = this.#eventHandlers.get(msg.method)
|
|
158
|
+
if (handlers) handlers.forEach((fn) => fn(msg.params))
|
|
159
|
+
// Forward the raw event to all connected CLI WebSocket clients
|
|
160
|
+
const raw = data.toString()
|
|
161
|
+
for (const cliWs of this.#cliClients) {
|
|
162
|
+
if (cliWs.readyState === 1 /* OPEN */) cliWs.send(raw)
|
|
163
|
+
}
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
ws.on('close', () => {
|
|
169
|
+
this.#cliClients.delete(ws)
|
|
170
|
+
if (this.#extension === ws) {
|
|
171
|
+
this.#extension = null
|
|
172
|
+
clearInterval(this.#keepaliveTimer)
|
|
173
|
+
this.#keepaliveTimer = null
|
|
174
|
+
// reject all pending calls
|
|
175
|
+
for (const [, pending] of this.#pendingCalls) {
|
|
176
|
+
clearTimeout(pending.timer)
|
|
177
|
+
pending.reject(new Error('Extension disconnected'))
|
|
178
|
+
}
|
|
179
|
+
this.#pendingCalls.clear()
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
#startKeepalive() {
|
|
185
|
+
clearInterval(this.#keepaliveTimer)
|
|
186
|
+
this.#keepaliveTimer = setInterval(() => {
|
|
187
|
+
if (this.#extension?.readyState === 1 /* OPEN */) {
|
|
188
|
+
this.#extension.ping()
|
|
189
|
+
}
|
|
190
|
+
}, KEEPALIVE_INTERVAL_MS)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Singleton for CLI commands
|
|
195
|
+
let _relay = null
|
|
196
|
+
|
|
197
|
+
export function getRelay(port) {
|
|
198
|
+
if (!_relay) _relay = new Relay(port)
|
|
199
|
+
return _relay
|
|
200
|
+
}
|
package/src/rpc.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
let _id = 0
|
|
2
|
+
|
|
3
|
+
export function request(method, params = {}) {
|
|
4
|
+
return { jsonrpc: '2.0', method, params, id: ++_id }
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function response(id, result) {
|
|
8
|
+
return { jsonrpc: '2.0', result, id }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function error(id, code, message, data) {
|
|
12
|
+
return { jsonrpc: '2.0', error: { code, message, ...(data && { data }) }, id }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isRequest(msg) {
|
|
16
|
+
return msg.jsonrpc === '2.0' && typeof msg.method === 'string' && msg.id !== undefined
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isResponse(msg) {
|
|
20
|
+
return msg.jsonrpc === '2.0' && (msg.result !== undefined || msg.error !== undefined) && msg.id !== undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isEvent(msg) {
|
|
24
|
+
return msg.jsonrpc === '2.0' && typeof msg.method === 'string' && msg.id === undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const ERR = {
|
|
28
|
+
PARSE_ERROR: -32700,
|
|
29
|
+
INVALID_REQUEST: -32600,
|
|
30
|
+
METHOD_NOT_FOUND: -32601,
|
|
31
|
+
INVALID_PARAMS: -32602,
|
|
32
|
+
TAB_NOT_FOUND: -32000,
|
|
33
|
+
ELEMENT_NOT_FOUND: -32001,
|
|
34
|
+
EXEC_ERROR: -32002,
|
|
35
|
+
DEBUGGER_ATTACH_FAILED: -32003,
|
|
36
|
+
}
|
package/src/token.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { writeFile, readFile } from 'node:fs/promises'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { chmod } from 'node:fs/promises'
|
|
6
|
+
|
|
7
|
+
const TOKEN_PATH = join(homedir(), '.chrome-cli-bridge.token')
|
|
8
|
+
|
|
9
|
+
export async function generateToken() {
|
|
10
|
+
const token = randomUUID()
|
|
11
|
+
await writeFile(TOKEN_PATH, token, { encoding: 'utf8', mode: 0o600 })
|
|
12
|
+
await chmod(TOKEN_PATH, 0o600)
|
|
13
|
+
return token
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function readToken() {
|
|
17
|
+
try {
|
|
18
|
+
return (await readFile(TOKEN_PATH, 'utf8')).trim()
|
|
19
|
+
} catch {
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export { TOKEN_PATH }
|