@taole/dd-playwright 0.0.0-stage → 0.1.1
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 +98 -2
- package/SKILL.md +28 -0
- package/bin/cli.mjs +173 -0
- package/bin/mcp-server.mjs +416 -0
- package/package.json +30 -4
- package/src/account-store.mjs +234 -0
- package/src/auth/diandian-auth.mjs +279 -0
- package/src/browser-manager.mjs +516 -0
- package/src/config.mjs +61 -0
- package/src/server/daemon.mjs +183 -0
- package/src/server/web-server.mjs +930 -0
- package/src/whistle-proxy.mjs +232 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whistle 隔离代理管理器(进程内启动)。
|
|
3
|
+
*
|
|
4
|
+
* 与 CLI `run` 不同(CLI 内部还会 spawn 二级 bootstrap 子进程,kill 后易留孤儿),
|
|
5
|
+
* 这里直接调用 whistle 的 programmatic API,在当前 Node 进程内监听端口:
|
|
6
|
+
* - 无子进程,MCP 进程退出即代理消失,无孤儿
|
|
7
|
+
* - WHISTLE_PATH 指向独立临时目录,绝不污染用户本机 ~/.WhistleAppData
|
|
8
|
+
* - 仅监听 127.0.0.1,不对外暴露
|
|
9
|
+
* - 域名后缀限制可声明规则;启用后浏览器流量均经代理转发
|
|
10
|
+
*/
|
|
11
|
+
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { createRequire } from "node:module";
|
|
15
|
+
|
|
16
|
+
let startWhistleFn = null;
|
|
17
|
+
function getStartWhistle() {
|
|
18
|
+
if (!startWhistleFn) {
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
startWhistleFn = require("whistle");
|
|
21
|
+
}
|
|
22
|
+
return startWhistleFn;
|
|
23
|
+
}
|
|
24
|
+
async function waitReady(port, timeoutMs = 25_000) {
|
|
25
|
+
const deadline = Date.now() + timeoutMs;
|
|
26
|
+
while (Date.now() < deadline) {
|
|
27
|
+
try {
|
|
28
|
+
const res = await fetch(`http://127.0.0.1:${port}/cgi-bin/status`);
|
|
29
|
+
if (res.ok) return true;
|
|
30
|
+
} catch {}
|
|
31
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class WhistleProxyManager {
|
|
37
|
+
/**
|
|
38
|
+
* @param {{domainRegex: RegExp}} options 域名白名单正则,不匹配的规则域名会被拒绝
|
|
39
|
+
*/
|
|
40
|
+
constructor({ domainRegex } = {}) {
|
|
41
|
+
if (!(domainRegex instanceof RegExp)) {
|
|
42
|
+
throw new Error("WhistleProxyManager 需要 domainRegex (RegExp)");
|
|
43
|
+
}
|
|
44
|
+
this.domainRegex = domainRegex;
|
|
45
|
+
this.port = null;
|
|
46
|
+
this.proxyEvents = null; // whistle 内部 EventEmitter,含 .server
|
|
47
|
+
this.workDir = null;
|
|
48
|
+
this.started = false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 启动隔离 whistle 实例。
|
|
53
|
+
* @param {number} [preferredPort] 代理监听端口,默认自动分配
|
|
54
|
+
* @returns {Promise<number>} 实际监听的代理端口
|
|
55
|
+
*/
|
|
56
|
+
async start(preferredPort) {
|
|
57
|
+
if (this.started) return this.port;
|
|
58
|
+
|
|
59
|
+
const port = preferredPort ?? (await this._findFreePort());
|
|
60
|
+
|
|
61
|
+
// 创建隔离数据目录(必须在 require 配置生效前设置环境变量)
|
|
62
|
+
this.workDir = mkdtempSync(join(tmpdir(), "whistle-proxy-"));
|
|
63
|
+
process.env.WHISTLE_PATH = this.workDir;
|
|
64
|
+
|
|
65
|
+
const rulesDir = join(this.workDir, ".whistle", "rules");
|
|
66
|
+
const filesDir = join(rulesDir, "files");
|
|
67
|
+
mkdirSync(filesDir, { recursive: true });
|
|
68
|
+
|
|
69
|
+
// 写入空规则文件(selectedList 必须存在,否则规则不生效)
|
|
70
|
+
writeFileSync(join(filesDir, "0.default"), "");
|
|
71
|
+
writeFileSync(
|
|
72
|
+
join(rulesDir, "properties"),
|
|
73
|
+
JSON.stringify({ filesOrder: ["default"], selectedList: ["default"] }),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
// 进程内启动(callback this 指向 server,参数为 proxyEvents)
|
|
77
|
+
const startWhistle = getStartWhistle();
|
|
78
|
+
this.proxyEvents = await new Promise((resolve, reject) => {
|
|
79
|
+
try {
|
|
80
|
+
startWhistle(
|
|
81
|
+
{
|
|
82
|
+
host: "127.0.0.1",
|
|
83
|
+
port,
|
|
84
|
+
mode: "capture",
|
|
85
|
+
noGlobalPlugins: true,
|
|
86
|
+
},
|
|
87
|
+
function (proxyEvents) {
|
|
88
|
+
resolve(proxyEvents);
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
reject(err);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// 等待就绪
|
|
97
|
+
const ready = await waitReady(port);
|
|
98
|
+
if (!ready) {
|
|
99
|
+
await this._closeServer();
|
|
100
|
+
rmSync(this.workDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
101
|
+
this.workDir = null;
|
|
102
|
+
throw new Error(
|
|
103
|
+
`Whistle proxy failed to start on port ${port} within 25s`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
this.port = port;
|
|
108
|
+
this.started = true;
|
|
109
|
+
return port;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 声明一条域名映射规则。
|
|
114
|
+
* @param {string} domain 目标域名,必须匹配 domainRegex
|
|
115
|
+
* @param {number} port 本地 dev server 端口
|
|
116
|
+
* @param {"http"|"https"} protocol 本地 dev server 协议,默认 http
|
|
117
|
+
* @returns {Promise<{url: string, rule: string}>} 浏览器应访问的干净 URL
|
|
118
|
+
*/
|
|
119
|
+
async setRule(domain, port, protocol = "http") {
|
|
120
|
+
if (!this.started) {
|
|
121
|
+
throw new Error("Whistle proxy not started. Call start() first.");
|
|
122
|
+
}
|
|
123
|
+
if (!this.domainRegex.test(domain)) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`Domain ${domain} not allowed; must match ${this.domainRegex}`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
129
|
+
throw new Error(`Invalid port: ${port}`);
|
|
130
|
+
}
|
|
131
|
+
if (protocol !== "http" && protocol !== "https") {
|
|
132
|
+
throw new Error(`Protocol must be http or https, got: ${protocol}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const ruleText = `${domain} ${protocol}://127.0.0.1:${port}`;
|
|
136
|
+
|
|
137
|
+
// 通过 whistle WebUI CGI 运行期写入并启用规则(比写文件可靠,立即生效)
|
|
138
|
+
const res = await fetch(
|
|
139
|
+
`http://127.0.0.1:${this.port}/cgi-bin/rules/select`,
|
|
140
|
+
{
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
143
|
+
body: new URLSearchParams({
|
|
144
|
+
name: "default",
|
|
145
|
+
value: ruleText,
|
|
146
|
+
}).toString(),
|
|
147
|
+
},
|
|
148
|
+
);
|
|
149
|
+
if (!res.ok) {
|
|
150
|
+
throw new Error(`Failed to set whistle rule: HTTP ${res.status}`);
|
|
151
|
+
}
|
|
152
|
+
const result = await res.json();
|
|
153
|
+
if (result.ec !== 0) {
|
|
154
|
+
throw new Error(`Failed to set whistle rule: ${JSON.stringify(result)}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 验证规则已生效
|
|
158
|
+
const listRes = await fetch(
|
|
159
|
+
`http://127.0.0.1:${this.port}/cgi-bin/rules/list`,
|
|
160
|
+
);
|
|
161
|
+
const listData = await listRes.json();
|
|
162
|
+
const active = listData.list?.find((r) => r.name === "default");
|
|
163
|
+
if (!active?.selected || !active.data?.includes(domain)) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
"Whistle rule set but not active. Verify via /cgi-bin/rules/list.",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const url = protocol === "https" ? `https://${domain}/` : `http://${domain}/`;
|
|
170
|
+
return { url, rule: ruleText };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 浏览器应使用的 Chromium 启动参数。
|
|
175
|
+
* @returns {string[]}
|
|
176
|
+
*/
|
|
177
|
+
getBrowserArgs() {
|
|
178
|
+
if (!this.started) return [];
|
|
179
|
+
return [
|
|
180
|
+
`--proxy-server=http://127.0.0.1:${this.port}`,
|
|
181
|
+
"--ignore-certificate-errors",
|
|
182
|
+
];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 浏览器 context 选项(HTTPS 场景需要忽略证书错误)。
|
|
187
|
+
* @returns {{ignoreHTTPSErrors: boolean}}
|
|
188
|
+
*/
|
|
189
|
+
getContextOptions() {
|
|
190
|
+
return { ignoreHTTPSErrors: true };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async _closeServer() {
|
|
194
|
+
const server = this.proxyEvents?.server;
|
|
195
|
+
if (server && typeof server.close === "function") {
|
|
196
|
+
await new Promise((resolve) => {
|
|
197
|
+
// 强制断开已建立连接,避免 close 挂起
|
|
198
|
+
if (typeof server.closeAllConnections === "function") {
|
|
199
|
+
try {
|
|
200
|
+
server.closeAllConnections();
|
|
201
|
+
} catch {}
|
|
202
|
+
}
|
|
203
|
+
server.close(() => resolve());
|
|
204
|
+
// 兜底:server 未在监听时 close 回调可能不触发
|
|
205
|
+
setTimeout(resolve, 3000).unref?.();
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
this.proxyEvents = null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async close() {
|
|
212
|
+
await this._closeServer();
|
|
213
|
+
if (this.workDir) {
|
|
214
|
+
rmSync(this.workDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
215
|
+
this.workDir = null;
|
|
216
|
+
}
|
|
217
|
+
this.started = false;
|
|
218
|
+
this.port = null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async _findFreePort() {
|
|
222
|
+
const net = await import("node:net");
|
|
223
|
+
return new Promise((resolve, reject) => {
|
|
224
|
+
const server = net.createServer();
|
|
225
|
+
server.listen(0, "127.0.0.1", () => {
|
|
226
|
+
const { port } = server.address();
|
|
227
|
+
server.close(() => resolve(port));
|
|
228
|
+
});
|
|
229
|
+
server.on("error", reject);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|