@mearl/client 2.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 +119 -0
- package/dist/actionTimeouts.d.ts +2 -0
- package/dist/actionTimeouts.js +49 -0
- package/dist/browserRegistry.d.ts +38 -0
- package/dist/browserRegistry.js +80 -0
- package/dist/check.d.ts +1 -0
- package/dist/check.js +146 -0
- package/dist/cli.d.ts +14 -0
- package/dist/cli.js +324 -0
- package/dist/commands.d.ts +18 -0
- package/dist/commands.js +666 -0
- package/dist/fileRefs.d.ts +20 -0
- package/dist/fileRefs.js +52 -0
- package/dist/index.d.ts +402 -0
- package/dist/index.js +125 -0
- package/dist/socket.d.ts +35 -0
- package/dist/socket.js +306 -0
- package/package.json +44 -0
package/dist/socket.js
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 底层 Socket 通信模块
|
|
3
|
+
*
|
|
4
|
+
* 通过 Unix Domain Socket (macOS/Linux) 或 Named Pipe (Windows) 与 native host 进程通信。
|
|
5
|
+
* 每次调用新建连接,收到响应后关闭,简单可靠。
|
|
6
|
+
*
|
|
7
|
+
* 当 Socket 文件不存在(ENOENT)时,自动尝试拉起 native host,稍后重试连接。
|
|
8
|
+
*/
|
|
9
|
+
import net from 'node:net';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
import { resolveFileRefs } from './fileRefs.js';
|
|
14
|
+
import { listLiveBrowsers, findBrowser, describeBrowser } from './browserRegistry.js';
|
|
15
|
+
import { resolveActionTimeoutSec } from './actionTimeouts.js';
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
export const CLIENT_VERSION = require('../package.json').version;
|
|
18
|
+
/**
|
|
19
|
+
* Legacy well-known socket. New native-hosts publish per-browser sockets in the
|
|
20
|
+
* registry and we address those; this path is a fallback for pre-registry hosts
|
|
21
|
+
* (or the pre-ping startup window) and is what auto-spawn targets.
|
|
22
|
+
*/
|
|
23
|
+
export const SOCKET_PATH = process.platform === 'win32'
|
|
24
|
+
? '\\\\.\\pipe\\mearl-agent'
|
|
25
|
+
: path.join(os.tmpdir(), 'mearl-agent.sock');
|
|
26
|
+
/**
|
|
27
|
+
* Process-wide default browser selector. Lets a long-lived host (e.g. the MCP
|
|
28
|
+
* server) pin a target once via `select_browser` without threading it through
|
|
29
|
+
* every call. Explicit `options.browser` still wins per-call.
|
|
30
|
+
*/
|
|
31
|
+
let defaultBrowser;
|
|
32
|
+
/** Pin the default target browser for subsequent invokes (undefined clears it). */
|
|
33
|
+
export function setDefaultBrowser(selector) {
|
|
34
|
+
defaultBrowser = selector && selector.trim() ? selector.trim() : undefined;
|
|
35
|
+
}
|
|
36
|
+
export function getDefaultBrowser() {
|
|
37
|
+
return defaultBrowser;
|
|
38
|
+
}
|
|
39
|
+
function findConnectedBrowser(items, selector) {
|
|
40
|
+
const normalized = selector.trim().toLowerCase();
|
|
41
|
+
const connected = items.filter(item => item.status === 'connected');
|
|
42
|
+
return (connected.find(item => String(item.browserId).toLowerCase() === normalized) ??
|
|
43
|
+
connected.find(item => String(item.name).toLowerCase() === normalized) ??
|
|
44
|
+
connected.find(item => String(item.browserId).toLowerCase().includes(normalized) ||
|
|
45
|
+
String(item.name).toLowerCase().includes(normalized)) ??
|
|
46
|
+
null);
|
|
47
|
+
}
|
|
48
|
+
export function decorateBrowserListResult(result) {
|
|
49
|
+
const browsers = Array.isArray(result?.browsers) ? result.browsers : [];
|
|
50
|
+
const selector = getDefaultBrowser() ?? process.env.MEARL_BROWSER;
|
|
51
|
+
const selected = selector
|
|
52
|
+
? findConnectedBrowser(browsers, selector)
|
|
53
|
+
: (browsers.find(browser => browser.status === 'connected') ?? null);
|
|
54
|
+
return {
|
|
55
|
+
...result,
|
|
56
|
+
defaultBrowserId: selected?.browserId ?? null,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function selectBrowserResult(data) {
|
|
60
|
+
const selector = typeof data.browser === 'string' ? data.browser.trim() : '';
|
|
61
|
+
if (!selector) {
|
|
62
|
+
setDefaultBrowser(undefined);
|
|
63
|
+
return {
|
|
64
|
+
selected: null,
|
|
65
|
+
note: 'Cleared: default target is now the most-recently focused browser.',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const browsers = listLiveBrowsers();
|
|
69
|
+
const match = findBrowser(browsers, selector);
|
|
70
|
+
if (!match)
|
|
71
|
+
throw new Error(formatNoMatchError(selector, browsers));
|
|
72
|
+
setDefaultBrowser(match.browserId);
|
|
73
|
+
return { selected: { browserId: match.browserId, name: match.browserName } };
|
|
74
|
+
}
|
|
75
|
+
/** Whether this process has already attempted to auto-spawn a persistent native-host. */
|
|
76
|
+
let spawnAttempted = false;
|
|
77
|
+
/**
|
|
78
|
+
* Attempt to spawn `mearl-native-host` in the background (detached).
|
|
79
|
+
* Inventory-only hosts serve one browser_list request without publishing a fake CDP target.
|
|
80
|
+
* Returns true if a spawn was attempted (regardless of success).
|
|
81
|
+
*/
|
|
82
|
+
async function trySpawnNativeHost(registerBrowser = true) {
|
|
83
|
+
if (registerBrowser && spawnAttempted)
|
|
84
|
+
return false;
|
|
85
|
+
if (registerBrowser)
|
|
86
|
+
spawnAttempted = true;
|
|
87
|
+
try {
|
|
88
|
+
const { spawn } = await import('node:child_process');
|
|
89
|
+
return await new Promise(resolve => {
|
|
90
|
+
const args = registerBrowser ? ['--daemon'] : ['--daemon', '--skip-browser-registration'];
|
|
91
|
+
const child = spawn('mearl-native-host', args, {
|
|
92
|
+
detached: true,
|
|
93
|
+
stdio: 'ignore',
|
|
94
|
+
windowsHide: true,
|
|
95
|
+
});
|
|
96
|
+
let settled = false;
|
|
97
|
+
const finish = (ok) => {
|
|
98
|
+
if (settled)
|
|
99
|
+
return;
|
|
100
|
+
settled = true;
|
|
101
|
+
resolve(ok);
|
|
102
|
+
};
|
|
103
|
+
// `spawn ENOENT` 等错误会通过事件异步抛出;必须显式监听避免进程崩溃。
|
|
104
|
+
child.once('error', () => finish(false));
|
|
105
|
+
child.once('spawn', () => {
|
|
106
|
+
child.unref();
|
|
107
|
+
finish(true);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* 向 native host 发送一个 action 请求并等待响应。
|
|
117
|
+
*
|
|
118
|
+
* 目标解析优先级:显式 options.browser → setDefaultBrowser → 环境变量
|
|
119
|
+
* MEARL_BROWSER → 自动(注册表中最近聚焦的浏览器)。注册表为空时回退到
|
|
120
|
+
* legacy well-known socket,并在 socket 不存在(ENOENT)时自动拉起 native host。
|
|
121
|
+
*/
|
|
122
|
+
export function invoke(action, data = {}, options = {}) {
|
|
123
|
+
if (action === 'browser_select_browser') {
|
|
124
|
+
try {
|
|
125
|
+
return Promise.resolve(selectBrowserResult(data));
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
return Promise.reject(error);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const { timeoutSec = resolveActionTimeoutSec(action, data), transport, browser } = options;
|
|
132
|
+
// 统一解析 payload 顶层字段中的 `@<path>` 文件引用(所有 action 通用)
|
|
133
|
+
const resolvedData = resolveFileRefs(data);
|
|
134
|
+
if (action === 'browser_list') {
|
|
135
|
+
return dispatchInvoke(action, resolvedData, timeoutSec, undefined, undefined).then(decorateBrowserListResult);
|
|
136
|
+
}
|
|
137
|
+
const selector = browser ?? defaultBrowser ?? process.env.MEARL_BROWSER ?? undefined;
|
|
138
|
+
return dispatchInvoke(action, resolvedData, timeoutSec, transport, selector);
|
|
139
|
+
}
|
|
140
|
+
function isConnError(error) {
|
|
141
|
+
const code = error?.code;
|
|
142
|
+
return code === 'ENOENT' || code === 'ECONNREFUSED';
|
|
143
|
+
}
|
|
144
|
+
async function dispatchInvoke(action, data, timeoutSec, transport, selector) {
|
|
145
|
+
const browsers = listLiveBrowsers();
|
|
146
|
+
// Explicit target: must match a live browser; do not silently fall back.
|
|
147
|
+
if (selector) {
|
|
148
|
+
const match = findBrowser(browsers, selector);
|
|
149
|
+
if (!match) {
|
|
150
|
+
throw new Error(formatNoMatchError(selector, browsers));
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
return await invokeOnce(action, data, timeoutSec, transport, match.socketPath);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
if (isConnError(error)) {
|
|
157
|
+
throw new Error(formatConnectError(error, match.socketPath), {
|
|
158
|
+
cause: error,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// No explicit target: prefer the most-recently-focused live browser; on a
|
|
165
|
+
// stale socket, fall through to the legacy path + auto-spawn.
|
|
166
|
+
if (browsers.length > 0) {
|
|
167
|
+
try {
|
|
168
|
+
return await invokeOnce(action, data, timeoutSec, transport, browsers[0].socketPath);
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
if (!isConnError(error))
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return invokeViaLegacy(action, data, timeoutSec, transport, /* isRetry */ false);
|
|
176
|
+
}
|
|
177
|
+
/** Legacy well-known socket, auto-spawning a native-host on ENOENT and retrying once. */
|
|
178
|
+
async function invokeViaLegacy(action, data, timeoutSec, transport, isRetry) {
|
|
179
|
+
try {
|
|
180
|
+
return await invokeOnce(action, data, timeoutSec, transport, SOCKET_PATH);
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
const err = error;
|
|
184
|
+
if (err.code === 'ENOENT' && !isRetry) {
|
|
185
|
+
const spawned = await trySpawnNativeHost(action !== 'browser_list');
|
|
186
|
+
if (spawned) {
|
|
187
|
+
// Wait for the native host to start listening; persistent hosts also publish their registry record.
|
|
188
|
+
await new Promise(r => setTimeout(r, 1200));
|
|
189
|
+
const browsers = listLiveBrowsers();
|
|
190
|
+
if (browsers.length > 0) {
|
|
191
|
+
return invokeOnce(action, data, timeoutSec, transport, browsers[0].socketPath);
|
|
192
|
+
}
|
|
193
|
+
return invokeViaLegacy(action, data, timeoutSec, transport, /* isRetry */ true);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (err instanceof Error && !isConnError(err))
|
|
197
|
+
throw err;
|
|
198
|
+
throw new Error(formatConnectError(err, SOCKET_PATH), { cause: error });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function invokeOnce(action, data, timeoutSec, transport, socketPath) {
|
|
202
|
+
const id = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
203
|
+
return new Promise((resolve, reject) => {
|
|
204
|
+
const socket = net.createConnection(socketPath);
|
|
205
|
+
let settled = false;
|
|
206
|
+
let buffer = '';
|
|
207
|
+
const settle = (error, result) => {
|
|
208
|
+
if (settled)
|
|
209
|
+
return;
|
|
210
|
+
settled = true;
|
|
211
|
+
clearTimeout(timer);
|
|
212
|
+
socket.removeAllListeners();
|
|
213
|
+
socket.end();
|
|
214
|
+
if (error)
|
|
215
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
216
|
+
else
|
|
217
|
+
resolve(result);
|
|
218
|
+
};
|
|
219
|
+
const timer = setTimeout(() => {
|
|
220
|
+
settle(new Error(`请求超时,已等待 ${timeoutSec} 秒`));
|
|
221
|
+
}, timeoutSec * 1000);
|
|
222
|
+
socket.on('connect', () => {
|
|
223
|
+
const message = { id, action, data, version: CLIENT_VERSION };
|
|
224
|
+
if (transport)
|
|
225
|
+
message.transport = transport;
|
|
226
|
+
socket.write(JSON.stringify(message) + '\n');
|
|
227
|
+
});
|
|
228
|
+
socket.on('data', chunk => {
|
|
229
|
+
buffer += chunk.toString('utf-8');
|
|
230
|
+
let newlineIndex;
|
|
231
|
+
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
|
|
232
|
+
const line = buffer.slice(0, newlineIndex);
|
|
233
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
234
|
+
if (!line.trim())
|
|
235
|
+
continue;
|
|
236
|
+
try {
|
|
237
|
+
const response = JSON.parse(line);
|
|
238
|
+
if (response.id !== id)
|
|
239
|
+
continue;
|
|
240
|
+
if (response.versionWarning) {
|
|
241
|
+
console.warn(response.versionWarning);
|
|
242
|
+
}
|
|
243
|
+
if (response.success)
|
|
244
|
+
settle(null, response.data);
|
|
245
|
+
else
|
|
246
|
+
settle(new Error(response.error || 'Socket 服务端返回未知错误'));
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
settle(new Error(`无效的 JSON 响应: ${line}`));
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
// Preserve the raw error (with .code) so the caller can branch on
|
|
256
|
+
// ENOENT / ECONNREFUSED to prune stale sockets or auto-spawn.
|
|
257
|
+
socket.on('error', (error) => settle(error));
|
|
258
|
+
socket.on('end', () => {
|
|
259
|
+
if (!settled)
|
|
260
|
+
settle(new Error('在收到响应前 Socket 已关闭'));
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
const EXTENSION_INSTALL_URL = 'https://chromewebstore.google.com/detail/mearl/aoehhjnofngknnjefamjbplchbolghkm';
|
|
265
|
+
function formatConnectError(error, socketPath) {
|
|
266
|
+
const installHint = `如果尚未安装浏览器插件,请先前往: ${EXTENSION_INSTALL_URL}`;
|
|
267
|
+
if (error.code === 'ENOENT') {
|
|
268
|
+
return [
|
|
269
|
+
`未找到 Socket: ${socketPath}`,
|
|
270
|
+
'native host 自动拉起失败,请手动排查:',
|
|
271
|
+
'',
|
|
272
|
+
'【方式一:浏览器插件模式】',
|
|
273
|
+
`1. 确认已安装 Mearl 浏览器插件(${EXTENSION_INSTALL_URL})`,
|
|
274
|
+
'2. 确认已执行 Native Messaging 初始化(安装 @mearl/native-host 时会自动完成)',
|
|
275
|
+
'3. 确认 Chrome 浏览器正在运行,如仍无法连接可在 chrome://extensions 重新加载插件',
|
|
276
|
+
'',
|
|
277
|
+
'【方式二:CDP 远程调试模式(Chrome 145+,无需插件)】',
|
|
278
|
+
' 访问 chrome://inspect/#remote-debugging,勾选 "Discover network targets"',
|
|
279
|
+
' Chrome 会弹出授权提示,点击允许,native host 自动读取 DevToolsActivePort 文件连接',
|
|
280
|
+
' 确认有普通网页已打开(非 chrome:// 页面)',
|
|
281
|
+
].join('\n');
|
|
282
|
+
}
|
|
283
|
+
if (error.code === 'ECONNREFUSED') {
|
|
284
|
+
return [
|
|
285
|
+
`Socket 连接被拒绝: ${socketPath}`,
|
|
286
|
+
'native host 可能已退出。',
|
|
287
|
+
'请确认 Chrome 浏览器正在运行,可尝试在 chrome://extensions 重新加载插件。',
|
|
288
|
+
'',
|
|
289
|
+
installHint,
|
|
290
|
+
].join('\n');
|
|
291
|
+
}
|
|
292
|
+
return [`Socket 连接失败: ${error.message}`, '', installHint].join('\n');
|
|
293
|
+
}
|
|
294
|
+
/** Error message when an explicit --browser selector matches no live browser. */
|
|
295
|
+
function formatNoMatchError(selector, browsers) {
|
|
296
|
+
const available = browsers.length
|
|
297
|
+
? browsers.map(b => ` - ${describeBrowser(b)}`).join('\n')
|
|
298
|
+
: ' (当前没有已连接的浏览器)';
|
|
299
|
+
return [
|
|
300
|
+
`未找到匹配 "${selector}" 的浏览器。`,
|
|
301
|
+
'当前已连接的浏览器:',
|
|
302
|
+
available,
|
|
303
|
+
'',
|
|
304
|
+
'用 `mearl browser_list` 查看完整列表;可用 browserId 或名称匹配。',
|
|
305
|
+
].join('\n');
|
|
306
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mearl/client",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Client SDK & CLI for Mearl — communicate with Chrome Extension via Unix Socket",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./action-timeouts": {
|
|
14
|
+
"types": "./dist/actionTimeouts.d.ts",
|
|
15
|
+
"import": "./dist/actionTimeouts.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"bin": {
|
|
19
|
+
"mearl": "./dist/cli.js"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
24
|
+
"keywords": [
|
|
25
|
+
"mearl",
|
|
26
|
+
"chrome-extension",
|
|
27
|
+
"devtools",
|
|
28
|
+
"socket-client"
|
|
29
|
+
],
|
|
30
|
+
"license": "ISC",
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"registry": "https://registry.npmjs.org"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/node": "^24.9.1",
|
|
36
|
+
"typescript": "^5.8.3"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "rm -rf dist/ && tsc && chmod +x dist/cli.js",
|
|
40
|
+
"dev": "tsc --watch",
|
|
41
|
+
"typecheck": "tsc --noEmit",
|
|
42
|
+
"test": "vitest run"
|
|
43
|
+
}
|
|
44
|
+
}
|