chrome-control-proxy 1.0.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 +141 -0
- package/bin/ccp.js +266 -0
- package/index.js +198 -0
- package/lib/browser-controller.js +182 -0
- package/lib/logger.js +192 -0
- package/lib/playwright-controller.js +609 -0
- package/package.json +44 -0
package/README.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# chrome-control-proxy
|
|
2
|
+
|
|
3
|
+
在宿主机上提供 **HTTP 代理服务**,用于:
|
|
4
|
+
|
|
5
|
+
- 启动 / 停止 / 重启带 **远程调试端口(默认 9222)** 的 Chrome
|
|
6
|
+
- 通过 **Playwright(CDP 连接)** 提供 `page-dom`、脚本执行等能力,供本机或 Docker 内 OpenClaw 等客户端调用
|
|
7
|
+
|
|
8
|
+
**要求:Node.js ≥ 18**(Playwright 与 Express 5 需要)。
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## 安装
|
|
13
|
+
|
|
14
|
+
全局安装(推荐,可在任意目录执行 `ccp`):
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install -g chrome-control-proxy
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
验证:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
ccp help
|
|
24
|
+
ccp status # 需先启动服务,否则显示 unreachable
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## CLI:`ccp`
|
|
30
|
+
|
|
31
|
+
管理 **HTTP 服务进程**(`node index.js`),不是替代 `curl` 调接口。
|
|
32
|
+
|
|
33
|
+
| 命令 | 说明 |
|
|
34
|
+
|------|------|
|
|
35
|
+
| `ccp start` | 后台启动服务,PID 写入 `CCP_PID_FILE`(默认系统临时目录下 `chrome-control-proxy.pid`),日志默认 `chrome-control-proxy.log` |
|
|
36
|
+
| `ccp stop` | 按 PID 结束进程;若无 PID 或失败,会 `pkill -f` 匹配本包 `index.js` 路径 |
|
|
37
|
+
| `ccp restart` | `stop` 后 `start` |
|
|
38
|
+
| `ccp status` | 请求 `GET /health`、`/browser/status`、`/playwright/status`(服务不可达时退出码 1) |
|
|
39
|
+
|
|
40
|
+
环境变量:
|
|
41
|
+
|
|
42
|
+
| 变量 | 含义 |
|
|
43
|
+
|------|------|
|
|
44
|
+
| `PORT` | HTTP 端口,默认 `3333` |
|
|
45
|
+
| `HOST` | 绑定地址,默认 `127.0.0.1` |
|
|
46
|
+
| `CHROME_PORT` | Chrome 远程调试端口,默认 `9222` |
|
|
47
|
+
| `CHROME_PROFILE_DIR` | Chrome 用户数据目录 |
|
|
48
|
+
| `CHROME_BINARY` | Chrome 可执行文件路径 |
|
|
49
|
+
| `CDP_URL` | Playwright 连接地址,默认 `http://127.0.0.1:${CHROME_PORT}` |
|
|
50
|
+
| `PLAYWRIGHT_PAGE_DEFAULT_TIMEOUT_MS` | `locator`/`click` 等未写 `timeout` 时的默认上限,默认 **60000**(避免 CDP 页面上仍为 10s 导致 `waitFor` 易超时) |
|
|
51
|
+
| `PLAYWRIGHT_NAVIGATION_DEFAULT_TIMEOUT_MS` | `goto` 等导航默认上限,未设时取 **max(动作超时, 90000)** |
|
|
52
|
+
| `JSON_BODY_LIMIT` | 请求体大小上限,默认 `16mb` |
|
|
53
|
+
| `LOG_LEVEL` | 日志级别:`error` / `warn` / `info` / `debug`,默认 `info` |
|
|
54
|
+
| `LOG_DIR` | 若设置,除控制台外**按日期写入目录**:`ccp-YYYY-MM-DD.log` |
|
|
55
|
+
| `LOG_MAX_FILE_MB` | 单日单文件最大体积(MB),超出则同日内分片为 `…-YYYY-MM-DD.1.log`、`.2.log`…;不设置则不按大小切分 |
|
|
56
|
+
| `LOG_CONSOLE` | 是否仍输出到 stdout/stderr,默认 `true`;仅写文件时可设 `false` |
|
|
57
|
+
| `CCP_PID_FILE` | 覆盖 PID 文件路径 |
|
|
58
|
+
| `CCP_LOG_FILE` | `ccp start` 子进程 stdout/stderr 重定向文件(与 `LOG_DIR` 应用内日志不同) |
|
|
59
|
+
|
|
60
|
+
前台直接运行(不用 `ccp`):
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
npm start
|
|
64
|
+
# 或
|
|
65
|
+
node index.js
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
日志默认输出到 **stdout**,前缀为 `[chrome-control-proxy]`。设置 **`LOG_DIR`** 后,会额外写入按日期的文件(可选 **`LOG_MAX_FILE_MB`** 按大小分片)。`ccp start` 后台进程仍可将输出重定向到 **`CCP_LOG_FILE`**,与应用内 `LOG_DIR` 可分开配置。生产环境也可用系统 **logrotate** 管理 `LOG_DIR` 下文件。
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## HTTP 接口一览
|
|
73
|
+
|
|
74
|
+
### 健康与 Chrome
|
|
75
|
+
|
|
76
|
+
| 路径 | 方法 | 说明 |
|
|
77
|
+
|------|------|------|
|
|
78
|
+
| `/health` | GET | 服务与 Chrome 端口探测 |
|
|
79
|
+
| `/browser/status` | GET | Chrome 是否在监听 |
|
|
80
|
+
| `/browser/start` | POST | 启动 Chrome(带 `--remote-debugging-port`) |
|
|
81
|
+
| `/browser/stop` | POST | 结束 Chrome |
|
|
82
|
+
| `/browser/restart` | POST | 重启 Chrome |
|
|
83
|
+
|
|
84
|
+
### Playwright(与 `/browser/start` 启动的 Chrome 同一 CDP)
|
|
85
|
+
|
|
86
|
+
| 路径 | 方法 | 说明 |
|
|
87
|
+
|------|------|------|
|
|
88
|
+
| `/playwright/status` | GET | CDP 是否可连 |
|
|
89
|
+
| `/playwright/page-dom` | POST | 页面快照:HTML / innerText / a11y / **Playwright 专用可交互列表** |
|
|
90
|
+
| `/playwright/run` | POST | 在 VM 中执行用户脚本(注入 `page`、`context`、`browser`) |
|
|
91
|
+
|
|
92
|
+
`POST /playwright/page-dom` 与 `POST /playwright/run` 在服务端 **串行排队**,避免多请求抢同一浏览器。
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## 调用示例(宿主机)
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
curl -s http://127.0.0.1:3333/health
|
|
100
|
+
curl -s -X POST http://127.0.0.1:3333/browser/start
|
|
101
|
+
curl -s http://127.0.0.1:3333/playwright/status
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## 调用示例(Docker 内访问宿主机)
|
|
105
|
+
|
|
106
|
+
将 `127.0.0.1` 换成 `host.docker.internal`(Mac/Win Docker Desktop 常见;Linux 可能需 `extra_hosts`)。
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
curl -s http://host.docker.internal:3333/health
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## `POST /playwright/page-dom` 要点
|
|
115
|
+
|
|
116
|
+
- **`includeHtml: false`**:不返回整页 HTML,利于降 token。
|
|
117
|
+
- **`includePlaywrightSnapshot: true`**:返回 `playwright.targets[]`(含 **`suggestedLocator`**),便于生成脚本。
|
|
118
|
+
- **`selector`**:只截取子树。
|
|
119
|
+
- **`includeInnerText` / `includeAccessibility`**:按需打开。
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## `POST /playwright/run` 要点
|
|
124
|
+
|
|
125
|
+
- 脚本为 **async 函数体**,可 `await`、`return`;返回值会尽量 JSON 序列化后返回。
|
|
126
|
+
- **不要在请求体里同时滥用外层 `url` 与脚本内 `goto`**:若外层传 `url`,会先导航再执行脚本;**退出登录再登录** 等流程宜 **外层不传 `url`**,只在脚本里 `clearCookies` + `goto`。
|
|
127
|
+
- 默认 **`scriptTimeout`** 见环境变量 `PLAYWRIGHT_RUN_DEFAULT_MS`。
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## 安全与边界
|
|
132
|
+
|
|
133
|
+
- 默认只监听 `127.0.0.1`;若改为 `0.0.0.0` 暴露局域网,需自行鉴权。
|
|
134
|
+
- `/playwright/run` 执行用户脚本,仅适用于可信环境。
|
|
135
|
+
- 测试账号与密码勿写入仓库。
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## OpenClaw 集成说明
|
|
140
|
+
|
|
141
|
+
更完整的调用顺序、踩坑说明见仓库内 **`host-chrome-control/SKILL.md`**(随代码维护;**不会**打进 `npm pack` 的默认包文件,clone 仓库即可见)。
|
package/bin/ccp.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const http = require('http');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const { spawn, execSync } = require('child_process');
|
|
8
|
+
const log = require(path.join(__dirname, '..', 'lib', 'logger.js'));
|
|
9
|
+
|
|
10
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
11
|
+
const INDEX_JS = path.join(ROOT, 'index.js');
|
|
12
|
+
const PID_FILE = process.env.CCP_PID_FILE || path.join(os.tmpdir(), 'chrome-control-proxy.pid');
|
|
13
|
+
const LOG_FILE = process.env.CCP_LOG_FILE || path.join(os.tmpdir(), 'chrome-control-proxy.log');
|
|
14
|
+
const HOST = process.env.HOST || '127.0.0.1';
|
|
15
|
+
const PORT = Number(process.env.PORT) || 3333;
|
|
16
|
+
|
|
17
|
+
function sleepSync(ms) {
|
|
18
|
+
const end = Date.now() + ms;
|
|
19
|
+
while (Date.now() < end) {
|
|
20
|
+
/* spin */
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function httpJson(method, p) {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const opts = {
|
|
27
|
+
hostname: HOST,
|
|
28
|
+
port: PORT,
|
|
29
|
+
path: p,
|
|
30
|
+
method,
|
|
31
|
+
timeout: 8000,
|
|
32
|
+
};
|
|
33
|
+
const req = http.request(opts, (res) => {
|
|
34
|
+
let d = '';
|
|
35
|
+
res.on('data', (c) => {
|
|
36
|
+
d += c;
|
|
37
|
+
});
|
|
38
|
+
res.on('end', () => {
|
|
39
|
+
try {
|
|
40
|
+
resolve(d ? JSON.parse(d) : {});
|
|
41
|
+
} catch {
|
|
42
|
+
resolve({ raw: d });
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
req.on('error', reject);
|
|
47
|
+
req.on('timeout', () => {
|
|
48
|
+
req.destroy();
|
|
49
|
+
reject(new Error('timeout'));
|
|
50
|
+
});
|
|
51
|
+
req.end();
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readPid() {
|
|
56
|
+
try {
|
|
57
|
+
const n = Number(fs.readFileSync(PID_FILE, 'utf8').trim());
|
|
58
|
+
return Number.isFinite(n) ? n : null;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isAlive(pid) {
|
|
65
|
+
if (!pid) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
process.kill(pid, 0);
|
|
70
|
+
return true;
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function serviceReachable() {
|
|
77
|
+
try {
|
|
78
|
+
const h = await httpJson('GET', '/health');
|
|
79
|
+
return Boolean(h && h.ok);
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function cmdStatus() {
|
|
86
|
+
const pid = readPid();
|
|
87
|
+
console.log(`ccp: pid file ${PID_FILE}`);
|
|
88
|
+
if (pid) {
|
|
89
|
+
console.log(`ccp: recorded pid ${pid} ${isAlive(pid) ? '(running)' : '(not running)'}`);
|
|
90
|
+
}
|
|
91
|
+
const up = await serviceReachable();
|
|
92
|
+
console.log(`ccp: http://${HOST}:${PORT} ${up ? 'reachable' : 'unreachable'}`);
|
|
93
|
+
if (!up) {
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const h = await httpJson('GET', '/health');
|
|
98
|
+
console.log('health:', JSON.stringify(h, null, 2));
|
|
99
|
+
} catch (e) {
|
|
100
|
+
console.error('health:', e.message);
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const b = await httpJson('GET', '/browser/status');
|
|
104
|
+
console.log('browser:', JSON.stringify(b, null, 2));
|
|
105
|
+
} catch (e) {
|
|
106
|
+
console.error('browser:', e.message);
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const p = await httpJson('GET', '/playwright/status');
|
|
110
|
+
console.log('playwright:', JSON.stringify(p, null, 2));
|
|
111
|
+
} catch (e) {
|
|
112
|
+
console.error('playwright:', e.message);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function cmdStart() {
|
|
117
|
+
log.info('ccp', 'start requested', { port: PORT, host: HOST });
|
|
118
|
+
if (await serviceReachable()) {
|
|
119
|
+
log.warn('ccp', 'service already responding', { port: PORT });
|
|
120
|
+
console.error('ccp: service already responding on port', PORT);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
const oldPid = readPid();
|
|
124
|
+
if (oldPid && isAlive(oldPid)) {
|
|
125
|
+
log.warn('ccp', 'stale pid file', { pid: oldPid });
|
|
126
|
+
console.error('ccp: stale pid file points to running process', oldPid);
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
if (oldPid && !isAlive(oldPid)) {
|
|
130
|
+
try {
|
|
131
|
+
fs.unlinkSync(PID_FILE);
|
|
132
|
+
} catch {
|
|
133
|
+
/* */
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let logFd;
|
|
138
|
+
try {
|
|
139
|
+
logFd = fs.openSync(LOG_FILE, 'a');
|
|
140
|
+
} catch {
|
|
141
|
+
logFd = 'ignore';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const child = spawn(process.execPath, [INDEX_JS], {
|
|
145
|
+
detached: true,
|
|
146
|
+
stdio: ['ignore', logFd, logFd],
|
|
147
|
+
cwd: ROOT,
|
|
148
|
+
env: { ...process.env },
|
|
149
|
+
});
|
|
150
|
+
child.unref();
|
|
151
|
+
fs.writeFileSync(PID_FILE, String(child.pid));
|
|
152
|
+
log.info('ccp', 'spawned server process', { pid: child.pid, logFile: LOG_FILE, index: INDEX_JS });
|
|
153
|
+
console.log('ccp: started pid', child.pid);
|
|
154
|
+
console.log('ccp: log', LOG_FILE);
|
|
155
|
+
|
|
156
|
+
sleepSync(400);
|
|
157
|
+
if (await serviceReachable()) {
|
|
158
|
+
log.info('ccp', 'service reachable');
|
|
159
|
+
console.log('ccp: service is up');
|
|
160
|
+
} else {
|
|
161
|
+
log.error('ccp', 'service not reachable after start', new Error(`check ${LOG_FILE}`));
|
|
162
|
+
console.error('ccp: service not yet reachable; check log:', LOG_FILE);
|
|
163
|
+
process.exit(1);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function killProcessTree(pid) {
|
|
168
|
+
if (!isAlive(pid)) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
process.kill(pid, 'SIGTERM');
|
|
173
|
+
} catch {
|
|
174
|
+
/* */
|
|
175
|
+
}
|
|
176
|
+
for (let i = 0; i < 40; i++) {
|
|
177
|
+
if (!isAlive(pid)) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
sleepSync(100);
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
process.kill(pid, 'SIGKILL');
|
|
184
|
+
} catch {
|
|
185
|
+
/* */
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function cmdStop() {
|
|
190
|
+
log.info('ccp', 'stop requested', { pidFile: PID_FILE });
|
|
191
|
+
const pid = readPid();
|
|
192
|
+
if (pid && isAlive(pid)) {
|
|
193
|
+
log.info('ccp', 'killing pid', { pid });
|
|
194
|
+
killProcessTree(pid);
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
fs.unlinkSync(PID_FILE);
|
|
198
|
+
} catch {
|
|
199
|
+
/* */
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
const esc = INDEX_JS.replace(/'/g, "'\\''");
|
|
203
|
+
execSync(`pkill -f '${esc}'`, { stdio: 'ignore' });
|
|
204
|
+
} catch {
|
|
205
|
+
/* */
|
|
206
|
+
}
|
|
207
|
+
log.info('ccp', 'stop finished');
|
|
208
|
+
console.log('ccp: stopped');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function cmdRestart() {
|
|
212
|
+
log.info('ccp', 'restart requested');
|
|
213
|
+
cmdStop();
|
|
214
|
+
sleepSync(500);
|
|
215
|
+
await cmdStart();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function printHelp() {
|
|
219
|
+
console.log(`Usage: ccp <command>
|
|
220
|
+
|
|
221
|
+
Commands:
|
|
222
|
+
start Start HTTP proxy (node index.js), write pid to ${PID_FILE}
|
|
223
|
+
stop Stop process from pid file, fallback pkill by index.js path
|
|
224
|
+
restart stop then start
|
|
225
|
+
status GET /health, /browser/status, /playwright/status
|
|
226
|
+
|
|
227
|
+
Environment:
|
|
228
|
+
PORT HTTP port (default 3333)
|
|
229
|
+
HOST Bind host (default 127.0.0.1)
|
|
230
|
+
CCP_PID_FILE Override pid file path
|
|
231
|
+
CCP_LOG_FILE Override log file path
|
|
232
|
+
`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function main() {
|
|
236
|
+
const sub = process.argv[2] || 'help';
|
|
237
|
+
switch (sub) {
|
|
238
|
+
case 'start':
|
|
239
|
+
await cmdStart();
|
|
240
|
+
break;
|
|
241
|
+
case 'stop':
|
|
242
|
+
cmdStop();
|
|
243
|
+
break;
|
|
244
|
+
case 'restart':
|
|
245
|
+
await cmdRestart();
|
|
246
|
+
break;
|
|
247
|
+
case 'status':
|
|
248
|
+
await cmdStatus();
|
|
249
|
+
break;
|
|
250
|
+
case 'help':
|
|
251
|
+
case '-h':
|
|
252
|
+
case '--help':
|
|
253
|
+
printHelp();
|
|
254
|
+
break;
|
|
255
|
+
default:
|
|
256
|
+
console.error('ccp: unknown command:', sub);
|
|
257
|
+
printHelp();
|
|
258
|
+
process.exit(1);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
main().catch((e) => {
|
|
263
|
+
log.error('ccp', 'cli fatal', e);
|
|
264
|
+
console.error(e);
|
|
265
|
+
process.exit(1);
|
|
266
|
+
});
|
package/index.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const log = require('./lib/logger');
|
|
3
|
+
const {
|
|
4
|
+
getChromeStatus,
|
|
5
|
+
startChrome,
|
|
6
|
+
stopChrome,
|
|
7
|
+
restartChrome,
|
|
8
|
+
} = require('./lib/browser-controller');
|
|
9
|
+
const {
|
|
10
|
+
CDP_URL,
|
|
11
|
+
PLAYWRIGHT_RUN_DEFAULT_MS,
|
|
12
|
+
getPlaywrightBrowser,
|
|
13
|
+
enqueuePlaywright,
|
|
14
|
+
getPageDomPayload,
|
|
15
|
+
runPlaywrightUserScript,
|
|
16
|
+
packScriptReturnValue,
|
|
17
|
+
} = require('./lib/playwright-controller');
|
|
18
|
+
|
|
19
|
+
process.on('uncaughtException', (e) => {
|
|
20
|
+
log.error('process', 'uncaughtException', e);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
});
|
|
23
|
+
process.on('unhandledRejection', (r) => {
|
|
24
|
+
log.error('process', 'unhandledRejection', r);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function shutdown(signal) {
|
|
28
|
+
log.info('server', `shutdown ${signal}`);
|
|
29
|
+
const { closeFileSink } = require('./lib/logger');
|
|
30
|
+
closeFileSink()
|
|
31
|
+
.catch(() => {})
|
|
32
|
+
.finally(() => {
|
|
33
|
+
process.exit(0);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
37
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
38
|
+
|
|
39
|
+
const app = express();
|
|
40
|
+
const JSON_BODY_LIMIT = process.env.JSON_BODY_LIMIT || '16mb';
|
|
41
|
+
app.use(express.json({ limit: JSON_BODY_LIMIT }));
|
|
42
|
+
|
|
43
|
+
app.use((req, res, next) => {
|
|
44
|
+
const t0 = Date.now();
|
|
45
|
+
res.on('finish', () => {
|
|
46
|
+
log.info('http', `${req.method} ${req.path} -> ${res.statusCode} ${Date.now() - t0}ms`);
|
|
47
|
+
});
|
|
48
|
+
next();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const HOST = process.env.HOST || '127.0.0.1';
|
|
52
|
+
const PORT = process.env.PORT || 3333;
|
|
53
|
+
|
|
54
|
+
function sendPlaywrightError(res, err) {
|
|
55
|
+
log.error('http', `response error ${err.code || 500}`, err);
|
|
56
|
+
const currentUrl = err.currentUrl;
|
|
57
|
+
const base = {
|
|
58
|
+
ok: false,
|
|
59
|
+
error: err.message || String(err),
|
|
60
|
+
...(currentUrl && { currentUrl }),
|
|
61
|
+
...(err.name && err.name !== 'Error' && { errorName: err.name }),
|
|
62
|
+
};
|
|
63
|
+
if (err.code === 'CHROME_DOWN') {
|
|
64
|
+
return res.status(503).json({ ...base, code: err.code });
|
|
65
|
+
}
|
|
66
|
+
return res.status(500).json(base);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
app.get('/health', async (_req, res) => {
|
|
70
|
+
const status = await getChromeStatus();
|
|
71
|
+
res.json({
|
|
72
|
+
ok: true,
|
|
73
|
+
service: 'chrome-control',
|
|
74
|
+
browser: status,
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
app.get('/browser/status', async (_req, res) => {
|
|
79
|
+
const status = await getChromeStatus();
|
|
80
|
+
res.json({
|
|
81
|
+
ok: true,
|
|
82
|
+
...status,
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
app.post('/browser/start', async (_req, res) => {
|
|
87
|
+
log.info('http', 'POST /browser/start');
|
|
88
|
+
const result = await startChrome();
|
|
89
|
+
res.json({
|
|
90
|
+
ok: true,
|
|
91
|
+
...result,
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
app.post('/browser/stop', async (_req, res) => {
|
|
96
|
+
log.info('http', 'POST /browser/stop');
|
|
97
|
+
const result = await stopChrome();
|
|
98
|
+
res.json({
|
|
99
|
+
ok: true,
|
|
100
|
+
...result,
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
app.post('/browser/restart', async (_req, res) => {
|
|
105
|
+
log.info('http', 'POST /browser/restart');
|
|
106
|
+
const result = await restartChrome();
|
|
107
|
+
res.json({
|
|
108
|
+
ok: true,
|
|
109
|
+
...result,
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
app.get('/playwright/status', async (_req, res) => {
|
|
114
|
+
try {
|
|
115
|
+
const browser = await getPlaywrightBrowser();
|
|
116
|
+
res.json({
|
|
117
|
+
ok: true,
|
|
118
|
+
connected: browser.isConnected(),
|
|
119
|
+
cdpUrl: CDP_URL,
|
|
120
|
+
contextCount: browser.contexts().length,
|
|
121
|
+
});
|
|
122
|
+
} catch (err) {
|
|
123
|
+
sendPlaywrightError(res, err);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
app.post('/playwright/page-dom', async (req, res) => {
|
|
128
|
+
try {
|
|
129
|
+
const b = req.body || {};
|
|
130
|
+
log.info('http', 'POST /playwright/page-dom');
|
|
131
|
+
const payload = await enqueuePlaywright(() =>
|
|
132
|
+
getPageDomPayload({
|
|
133
|
+
url: b.url,
|
|
134
|
+
waitUntil: b.waitUntil || 'load',
|
|
135
|
+
timeout: b.timeout ?? 30000,
|
|
136
|
+
target: b.target || 'first',
|
|
137
|
+
maxHtmlChars: b.maxHtmlChars,
|
|
138
|
+
maxTextChars: b.maxTextChars,
|
|
139
|
+
maxA11yChars: b.maxA11yChars,
|
|
140
|
+
maxPlaywrightJsonChars: b.maxPlaywrightJsonChars,
|
|
141
|
+
selector: b.selector,
|
|
142
|
+
includeHtml: b.includeHtml !== false,
|
|
143
|
+
includeInnerText: Boolean(b.includeInnerText),
|
|
144
|
+
includeAccessibility: Boolean(b.includeAccessibility),
|
|
145
|
+
includePlaywrightSnapshot: Boolean(b.includePlaywrightSnapshot),
|
|
146
|
+
maxPlaywrightTargets: b.maxPlaywrightTargets,
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
res.json({
|
|
150
|
+
ok: true,
|
|
151
|
+
step: 'page-dom',
|
|
152
|
+
...payload,
|
|
153
|
+
});
|
|
154
|
+
} catch (err) {
|
|
155
|
+
sendPlaywrightError(res, err);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
app.post('/playwright/run', async (req, res) => {
|
|
160
|
+
try {
|
|
161
|
+
const { script, url, waitUntil, timeout, target, scriptTimeout } = req.body || {};
|
|
162
|
+
if (script === undefined || script === null || script === '') {
|
|
163
|
+
log.warn('http', 'POST /playwright/run rejected: empty script');
|
|
164
|
+
return res.status(400).json({ ok: false, error: 'script is required' });
|
|
165
|
+
}
|
|
166
|
+
log.info('http', 'POST /playwright/run', { scriptLen: String(script).length });
|
|
167
|
+
const { page, result } = await enqueuePlaywright(() =>
|
|
168
|
+
runPlaywrightUserScript(String(script), {
|
|
169
|
+
url,
|
|
170
|
+
waitUntil,
|
|
171
|
+
timeout,
|
|
172
|
+
target,
|
|
173
|
+
scriptTimeout: scriptTimeout ?? PLAYWRIGHT_RUN_DEFAULT_MS,
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
176
|
+
res.json({
|
|
177
|
+
ok: true,
|
|
178
|
+
step: 'run',
|
|
179
|
+
currentUrl: page.url(),
|
|
180
|
+
...packScriptReturnValue(result),
|
|
181
|
+
});
|
|
182
|
+
} catch (err) {
|
|
183
|
+
if (err.code === 'BAD_SCRIPT' || err.code === 'SCRIPT_TOO_LARGE') {
|
|
184
|
+
log.warn('http', `playwright/run client error ${err.code}`, err.message);
|
|
185
|
+
return res.status(400).json({
|
|
186
|
+
ok: false,
|
|
187
|
+
error: err.message,
|
|
188
|
+
code: err.code,
|
|
189
|
+
...(err.currentUrl && { currentUrl: err.currentUrl }),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
sendPlaywrightError(res, err);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
app.listen(PORT, HOST, () => {
|
|
197
|
+
log.info('server', `listening http://${HOST}:${PORT}`, { cdpUrl: CDP_URL });
|
|
198
|
+
});
|