@mearl/client 2.0.2 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -2
- package/dist/actionTimeouts.js +9 -11
- package/dist/cli.js +5 -2
- package/dist/commands.d.ts +890 -4
- package/dist/commands.js +79 -4
- package/dist/generated-browser-action-protocol.d.ts +677 -0
- package/dist/generated-browser-action-protocol.js +144 -0
- package/dist/index.d.ts +41 -425
- package/dist/index.js +10 -12
- package/dist/socket.d.ts +2 -0
- package/dist/socket.js +70 -19
- package/package.json +3 -3
package/dist/socket.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* 当 Socket 文件不存在(ENOENT)时,自动尝试拉起 native host,稍后重试连接。
|
|
8
8
|
*/
|
|
9
9
|
import net from 'node:net';
|
|
10
|
+
import fs from 'node:fs';
|
|
10
11
|
import os from 'node:os';
|
|
11
12
|
import path from 'node:path';
|
|
12
13
|
import { createRequire } from 'node:module';
|
|
@@ -15,6 +16,7 @@ import { listLiveBrowsers, findBrowser, describeBrowser } from './browserRegistr
|
|
|
15
16
|
import { resolveActionTimeoutSec } from './actionTimeouts.js';
|
|
16
17
|
const require = createRequire(import.meta.url);
|
|
17
18
|
export const CLIENT_VERSION = require('../package.json').version;
|
|
19
|
+
const MAX_SOCKET_BUFFER_SIZE = 64 * 1024 * 1024;
|
|
18
20
|
/**
|
|
19
21
|
* Legacy well-known socket. New native-hosts publish per-browser sockets in the
|
|
20
22
|
* registry and we address those; this path is a fallback for pre-registry hosts
|
|
@@ -79,8 +81,8 @@ let spawnAttempted = false;
|
|
|
79
81
|
* Inventory-only hosts serve one browser_list request without publishing a fake CDP target.
|
|
80
82
|
* Returns true if a spawn was attempted (regardless of success).
|
|
81
83
|
*/
|
|
82
|
-
async function trySpawnNativeHost(registerBrowser = true) {
|
|
83
|
-
if (registerBrowser && spawnAttempted)
|
|
84
|
+
async function trySpawnNativeHost(registerBrowser = true, retryPreviousAttempt = false) {
|
|
85
|
+
if (registerBrowser && spawnAttempted && !retryPreviousAttempt)
|
|
84
86
|
return false;
|
|
85
87
|
if (registerBrowser)
|
|
86
88
|
spawnAttempted = true;
|
|
@@ -128,20 +130,37 @@ export function invoke(action, data = {}, options = {}) {
|
|
|
128
130
|
return Promise.reject(error);
|
|
129
131
|
}
|
|
130
132
|
}
|
|
131
|
-
const { timeoutSec = resolveActionTimeoutSec(action, data), transport, browser } = options;
|
|
133
|
+
const { timeoutSec = resolveActionTimeoutSec(action, data), transport, browser, controlSource = 'local', } = options;
|
|
132
134
|
// 统一解析 payload 顶层字段中的 `@<path>` 文件引用(所有 action 通用)
|
|
133
135
|
const resolvedData = resolveFileRefs(data);
|
|
134
136
|
if (action === 'browser_list') {
|
|
135
|
-
return dispatchInvoke(action, resolvedData, timeoutSec, undefined, undefined).then(decorateBrowserListResult);
|
|
137
|
+
return dispatchInvoke(action, resolvedData, timeoutSec, undefined, undefined, controlSource).then(decorateBrowserListResult);
|
|
136
138
|
}
|
|
137
139
|
const selector = browser ?? defaultBrowser ?? process.env.MEARL_BROWSER ?? undefined;
|
|
138
|
-
return dispatchInvoke(action, resolvedData, timeoutSec, transport, selector);
|
|
140
|
+
return dispatchInvoke(action, resolvedData, timeoutSec, transport, selector, controlSource);
|
|
139
141
|
}
|
|
140
142
|
function isConnError(error) {
|
|
141
143
|
const code = error?.code;
|
|
142
144
|
return code === 'ENOENT' || code === 'ECONNREFUSED';
|
|
143
145
|
}
|
|
144
|
-
|
|
146
|
+
function isSocketSandboxError(error) {
|
|
147
|
+
return error?.code === 'EPERM';
|
|
148
|
+
}
|
|
149
|
+
async function dispatchInvoke(action, data, timeoutSec, transport, selector, controlSource) {
|
|
150
|
+
const configuredSocketPath = process.env.MEARL_SOCKET_PATH?.trim();
|
|
151
|
+
if (!selector && configuredSocketPath) {
|
|
152
|
+
try {
|
|
153
|
+
return await invokeOnce(action, data, timeoutSec, transport, configuredSocketPath, controlSource);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
if (isConnError(error) || isSocketSandboxError(error)) {
|
|
157
|
+
throw new Error(formatConnectError(error, configuredSocketPath), {
|
|
158
|
+
cause: error,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
145
164
|
const browsers = listLiveBrowsers();
|
|
146
165
|
// Explicit target: must match a live browser; do not silently fall back.
|
|
147
166
|
if (selector) {
|
|
@@ -150,10 +169,10 @@ async function dispatchInvoke(action, data, timeoutSec, transport, selector) {
|
|
|
150
169
|
throw new Error(formatNoMatchError(selector, browsers));
|
|
151
170
|
}
|
|
152
171
|
try {
|
|
153
|
-
return await invokeOnce(action, data, timeoutSec, transport, match.socketPath);
|
|
172
|
+
return await invokeOnce(action, data, timeoutSec, transport, match.socketPath, controlSource);
|
|
154
173
|
}
|
|
155
174
|
catch (error) {
|
|
156
|
-
if (isConnError(error)) {
|
|
175
|
+
if (isConnError(error) || isSocketSandboxError(error)) {
|
|
157
176
|
throw new Error(formatConnectError(error, match.socketPath), {
|
|
158
177
|
cause: error,
|
|
159
178
|
});
|
|
@@ -165,42 +184,56 @@ async function dispatchInvoke(action, data, timeoutSec, transport, selector) {
|
|
|
165
184
|
// stale socket, fall through to the legacy path + auto-spawn.
|
|
166
185
|
if (browsers.length > 0) {
|
|
167
186
|
try {
|
|
168
|
-
return await invokeOnce(action, data, timeoutSec, transport, browsers[0].socketPath);
|
|
187
|
+
return await invokeOnce(action, data, timeoutSec, transport, browsers[0].socketPath, controlSource);
|
|
169
188
|
}
|
|
170
189
|
catch (error) {
|
|
190
|
+
if (isSocketSandboxError(error)) {
|
|
191
|
+
throw new Error(formatConnectError(error, browsers[0].socketPath), { cause: error });
|
|
192
|
+
}
|
|
171
193
|
if (!isConnError(error))
|
|
172
194
|
throw error;
|
|
173
195
|
}
|
|
174
196
|
}
|
|
175
|
-
return invokeViaLegacy(action, data, timeoutSec, transport, /* isRetry */ false);
|
|
197
|
+
return invokeViaLegacy(action, data, timeoutSec, transport, controlSource, /* isRetry */ false);
|
|
176
198
|
}
|
|
177
199
|
/** Legacy well-known socket, auto-spawning a native-host on ENOENT and retrying once. */
|
|
178
|
-
async function invokeViaLegacy(action, data, timeoutSec, transport, isRetry) {
|
|
200
|
+
async function invokeViaLegacy(action, data, timeoutSec, transport, controlSource, isRetry) {
|
|
179
201
|
try {
|
|
180
|
-
return await invokeOnce(action, data, timeoutSec, transport, SOCKET_PATH);
|
|
202
|
+
return await invokeOnce(action, data, timeoutSec, transport, SOCKET_PATH, controlSource);
|
|
181
203
|
}
|
|
182
204
|
catch (error) {
|
|
183
205
|
const err = error;
|
|
184
|
-
|
|
185
|
-
|
|
206
|
+
const shouldAttemptSpawn = err.code === 'ENOENT' || (action === 'browser_release' && err.code === 'ECONNREFUSED');
|
|
207
|
+
if (shouldAttemptSpawn && !isRetry) {
|
|
208
|
+
const spawned = await trySpawnNativeHost(action !== 'browser_list', action === 'browser_release');
|
|
186
209
|
if (spawned) {
|
|
187
210
|
// Wait for the native host to start listening; persistent hosts also publish their registry record.
|
|
188
211
|
await new Promise(r => setTimeout(r, 1200));
|
|
189
212
|
const browsers = listLiveBrowsers();
|
|
190
213
|
if (browsers.length > 0) {
|
|
191
|
-
return invokeOnce(action, data, timeoutSec, transport, browsers[0].socketPath);
|
|
214
|
+
return invokeOnce(action, data, timeoutSec, transport, browsers[0].socketPath, controlSource);
|
|
192
215
|
}
|
|
193
|
-
return invokeViaLegacy(action, data, timeoutSec, transport,
|
|
216
|
+
return invokeViaLegacy(action, data, timeoutSec, transport, controlSource,
|
|
217
|
+
/* isRetry */ true);
|
|
194
218
|
}
|
|
195
219
|
}
|
|
196
|
-
if (err instanceof Error && !isConnError(err))
|
|
220
|
+
if (err instanceof Error && !isConnError(err) && !isSocketSandboxError(err))
|
|
197
221
|
throw err;
|
|
198
222
|
throw new Error(formatConnectError(err, SOCKET_PATH), { cause: error });
|
|
199
223
|
}
|
|
200
224
|
}
|
|
201
|
-
function invokeOnce(action, data, timeoutSec, transport, socketPath) {
|
|
225
|
+
function invokeOnce(action, data, timeoutSec, transport, socketPath, controlSource) {
|
|
202
226
|
const id = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
203
227
|
return new Promise((resolve, reject) => {
|
|
228
|
+
if (process.platform !== 'win32' && fs.existsSync(socketPath) && process.getuid) {
|
|
229
|
+
const ownerUid = fs.statSync(socketPath).uid;
|
|
230
|
+
if (ownerUid !== process.getuid()) {
|
|
231
|
+
const error = new Error(`拒绝连接不属于当前用户的 Socket: ${socketPath}`);
|
|
232
|
+
Object.assign(error, { code: 'EACCES' });
|
|
233
|
+
reject(error);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
204
237
|
const socket = net.createConnection(socketPath);
|
|
205
238
|
let settled = false;
|
|
206
239
|
let buffer = '';
|
|
@@ -220,13 +253,23 @@ function invokeOnce(action, data, timeoutSec, transport, socketPath) {
|
|
|
220
253
|
settle(new Error(`请求超时,已等待 ${timeoutSec} 秒`));
|
|
221
254
|
}, timeoutSec * 1000);
|
|
222
255
|
socket.on('connect', () => {
|
|
223
|
-
const message = {
|
|
256
|
+
const message = {
|
|
257
|
+
id,
|
|
258
|
+
action,
|
|
259
|
+
data,
|
|
260
|
+
version: CLIENT_VERSION,
|
|
261
|
+
controlSource,
|
|
262
|
+
};
|
|
224
263
|
if (transport)
|
|
225
264
|
message.transport = transport;
|
|
226
265
|
socket.write(JSON.stringify(message) + '\n');
|
|
227
266
|
});
|
|
228
267
|
socket.on('data', chunk => {
|
|
229
268
|
buffer += chunk.toString('utf-8');
|
|
269
|
+
if (Buffer.byteLength(buffer, 'utf-8') > MAX_SOCKET_BUFFER_SIZE) {
|
|
270
|
+
settle(new Error(`Socket 响应超过 ${MAX_SOCKET_BUFFER_SIZE} 字节限制`));
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
230
273
|
let newlineIndex;
|
|
231
274
|
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
|
|
232
275
|
const line = buffer.slice(0, newlineIndex);
|
|
@@ -289,6 +332,14 @@ function formatConnectError(error, socketPath) {
|
|
|
289
332
|
installHint,
|
|
290
333
|
].join('\n');
|
|
291
334
|
}
|
|
335
|
+
if (error.code === 'EPERM') {
|
|
336
|
+
return [
|
|
337
|
+
`Socket 连接被执行环境拦截: ${socketPath}`,
|
|
338
|
+
'当前进程没有访问该 Mearl Unix Socket 的权限(EPERM)。',
|
|
339
|
+
'如果命令运行在 Agent 沙箱中,请为这个 Socket 配置精确的 Unix Socket 白名单后重试。',
|
|
340
|
+
'无需重新安装浏览器插件,也不要使用 sudo 或改写 Socket 权限。',
|
|
341
|
+
].join('\n');
|
|
342
|
+
}
|
|
292
343
|
return [`Socket 连接失败: ${error.message}`, '', installHint].join('\n');
|
|
293
344
|
}
|
|
294
345
|
/** Error message when an explicit --browser selector matches no live browser. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mearl/client",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Client SDK & CLI for Mearl — communicate with Chrome Extension via Unix Socket",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
"typescript": "^5.8.3"
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|
|
39
|
-
"build": "rm -rf dist/ && tsc && chmod +x dist/cli.js",
|
|
39
|
+
"build": "node ../../scripts/generate-client-action-protocol.mjs --check && rm -rf dist/ && tsc && chmod +x dist/cli.js",
|
|
40
40
|
"dev": "tsc --watch",
|
|
41
|
-
"typecheck": "tsc --noEmit",
|
|
41
|
+
"typecheck": "node ../../scripts/generate-client-action-protocol.mjs --check && tsc --noEmit",
|
|
42
42
|
"test": "vitest run"
|
|
43
43
|
}
|
|
44
44
|
}
|