@mearl/cloud-server 2.1.0 → 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 +21 -2
- package/dist/cli.js +147 -34
- package/dist/index.d.ts +6 -1
- package/dist/index.js +146 -33
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -21,6 +21,7 @@ mearl-cloud-server [command] [options]
|
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
**子命令:**
|
|
24
|
+
|
|
24
25
|
- `start` - 后台启动 server(默认,不写也等价于 `start`)
|
|
25
26
|
- `stop` - 停止后台 server
|
|
26
27
|
- `restart` - 重启后台 server
|
|
@@ -28,6 +29,7 @@ mearl-cloud-server [command] [options]
|
|
|
28
29
|
- `logs` - 打印 server 日志末尾
|
|
29
30
|
|
|
30
31
|
**选项:**
|
|
32
|
+
|
|
31
33
|
- `--foreground` / `-f` - 前台运行(不守护化,适合 systemd / Docker / PM2)
|
|
32
34
|
- `--port <port>` - 服务器端口(默认 8080)
|
|
33
35
|
- `--path <path>` - WebSocket 路径(默认 /ws)
|
|
@@ -36,6 +38,7 @@ mearl-cloud-server [command] [options]
|
|
|
36
38
|
- `--max-connections <n>` - 最大连接数(默认 100)
|
|
37
39
|
|
|
38
40
|
**示例:**
|
|
41
|
+
|
|
39
42
|
```bash
|
|
40
43
|
# 后台启动(自动生成 token),打印连接命令后立即返回
|
|
41
44
|
mearl-cloud-server
|
|
@@ -55,6 +58,7 @@ mearl-cloud-server --foreground
|
|
|
55
58
|
```
|
|
56
59
|
|
|
57
60
|
后台启动后会打印供本地机器使用的连接命令,例如:
|
|
61
|
+
|
|
58
62
|
```
|
|
59
63
|
[CloudServer] Started in background (pid 12345)
|
|
60
64
|
[CloudServer] Logs: ~/.mearl/cloud-server.log
|
|
@@ -64,16 +68,19 @@ mearl-cloud-server --foreground
|
|
|
64
68
|
```
|
|
65
69
|
|
|
66
70
|
运行时文件位于 `~/.mearl/`:
|
|
71
|
+
|
|
67
72
|
- `cloud-server.json` - 连接配置(含 server URL、pid、端口等),cloud-client 自动读取
|
|
68
73
|
- `cloud-server.log` - 后台进程日志(`logs` 子命令读取,或 `tail -f` 跟踪);超过 5MB 会在下次启动时滚动为 `cloud-server.log.1`
|
|
69
74
|
|
|
70
75
|
> 守护进程的启停、状态、日志滚动等生命周期逻辑由共享包 [`@mearl/daemon-core`](../../daemon-core) 提供(与 cloud-connector 复用同一套实现)。
|
|
71
76
|
|
|
72
77
|
**关于 token:**
|
|
78
|
+
|
|
73
79
|
- 未指定 `--token` 时会自动生成。`restart` 会**沿用当前 token**,因此之前打印的连接命令重启后仍然有效。
|
|
74
80
|
- `stop` 后再 `start` 属于全新启动,会重新生成 token;若希望连接命令长期固定,请显式传入 `--token` 或设置 `MEARL_TOKEN`。
|
|
75
81
|
|
|
76
82
|
**环境变量:**
|
|
83
|
+
|
|
77
84
|
- `MEARL_TOKEN` - 鉴权密钥(覆盖 --token 选项)
|
|
78
85
|
- `CLOUD_SERVER_HOST` - 公网主机名(默认自动获取)
|
|
79
86
|
|
|
@@ -125,21 +132,32 @@ Server 接受两种类型的连接:
|
|
|
125
132
|
1. **Agent 连接** - 发起请求的云端客户端
|
|
126
133
|
2. **Connector 连接** - 转发请求到 native-host 的本地连接器
|
|
127
134
|
|
|
128
|
-
|
|
135
|
+
两类客户端建连后会分别发送 `agent_hello` 和 `connector_hello`。Connector 会注册
|
|
136
|
+
稳定 ID、名称和版本;同一 ID 重连时,新连接会替换旧连接。
|
|
137
|
+
|
|
138
|
+
只有一个 Connector 在线时,Server 自动选择它。多个 Connector 同时在线时,Agent
|
|
139
|
+
先调用 `connector_list` 获取清单,再在请求中传 `connector`;未指定目标时
|
|
140
|
+
Server 会拒绝请求,避免把浏览器操作发到错误机器。
|
|
129
141
|
|
|
130
142
|
## 消息协议
|
|
131
143
|
|
|
132
144
|
### 请求格式(Agent → Server)
|
|
145
|
+
|
|
133
146
|
```json
|
|
134
147
|
{
|
|
135
148
|
"id": "unique-id",
|
|
136
149
|
"action": "get_requests",
|
|
137
150
|
"data": { "count": 5 },
|
|
151
|
+
"connector": "work-mac",
|
|
138
152
|
"version": "1.30.0"
|
|
139
153
|
}
|
|
140
154
|
```
|
|
141
155
|
|
|
156
|
+
`connector` 接受 connector ID 或可唯一匹配的名称。`connector_list` 是
|
|
157
|
+
Server 内置命令,不会转发到浏览器侧。
|
|
158
|
+
|
|
142
159
|
### 响应格式(Server → Agent)
|
|
160
|
+
|
|
143
161
|
```json
|
|
144
162
|
{
|
|
145
163
|
"id": "unique-id",
|
|
@@ -150,6 +168,7 @@ Server 会自动识别连接类型,并将 Agent 的请求转发到可用的 Co
|
|
|
150
168
|
```
|
|
151
169
|
|
|
152
170
|
### 心跳格式
|
|
171
|
+
|
|
153
172
|
```json
|
|
154
173
|
{ "type": "ping", "timestamp": 1234567890 }
|
|
155
174
|
{ "type": "pong", "timestamp": 1234567890 }
|
|
@@ -211,4 +230,4 @@ pm2 start mearl-cloud-server --name mtop-cloud-server -- --foreground --port 808
|
|
|
211
230
|
- 设置合理的 `maxConnections` 限制资源占用
|
|
212
231
|
- 启用 `token` 鉴权保护服务安全
|
|
213
232
|
- 在沙箱环境中启动时,会自动检测并使用平台分配的公网地址
|
|
214
|
-
- 如果沙箱端口映射 API 不可用,会回退到使用本地 IP 地址
|
|
233
|
+
- 如果沙箱端口映射 API 不可用,会回退到使用本地 IP 地址
|
package/dist/cli.js
CHANGED
|
@@ -20,9 +20,12 @@ var LOG_FILE = join(CONFIG_DIR, "cloud-server.log");
|
|
|
20
20
|
|
|
21
21
|
// src/index.ts
|
|
22
22
|
import {
|
|
23
|
+
isAgentHelloMessage,
|
|
24
|
+
isConnectorHelloMessage,
|
|
23
25
|
isHeartbeatMessage,
|
|
24
26
|
isCloudMessage,
|
|
25
27
|
isCloudResponse,
|
|
28
|
+
resolveCloudConnectorSelector,
|
|
26
29
|
MAX_BUFFER_SIZE,
|
|
27
30
|
DEFAULT_HEARTBEAT_TIMEOUT,
|
|
28
31
|
DEFAULT_REQUEST_TIMEOUT
|
|
@@ -46,6 +49,7 @@ var DEFAULT_PATH = "/ws";
|
|
|
46
49
|
var DEFAULT_MAX_CONNECTIONS = 100;
|
|
47
50
|
var DEFAULT_MAX_MESSAGE_SIZE = 10 * 1024 * 1024;
|
|
48
51
|
var RESPONSE_GRACE_MS = 5e3;
|
|
52
|
+
var LEGACY_CONNECTOR_HELLO_GRACE_MS = 1e3;
|
|
49
53
|
var CloudServer = class {
|
|
50
54
|
options;
|
|
51
55
|
connectorWss = null;
|
|
@@ -220,9 +224,8 @@ var CloudServer = class {
|
|
|
220
224
|
ws,
|
|
221
225
|
id: connId,
|
|
222
226
|
lastHeartbeat: Date.now(),
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
isAgent: fromLocal,
|
|
227
|
+
connectedAt: Date.now(),
|
|
228
|
+
role: fromLocal ? "agent" : "pending",
|
|
226
229
|
fromLocal,
|
|
227
230
|
messageBuffer: ""
|
|
228
231
|
};
|
|
@@ -246,7 +249,7 @@ var CloudServer = class {
|
|
|
246
249
|
if (!line.trim()) continue;
|
|
247
250
|
try {
|
|
248
251
|
const message = JSON.parse(line);
|
|
249
|
-
this.handleMessage(ws, message
|
|
252
|
+
this.handleMessage(ws, message);
|
|
250
253
|
} catch (error) {
|
|
251
254
|
console.error(`[CloudServer] Failed to parse message from ${connId}:`, line, error);
|
|
252
255
|
}
|
|
@@ -261,7 +264,7 @@ var CloudServer = class {
|
|
|
261
264
|
console.error(`[CloudServer] Connection error for ${connId}:`, error.message);
|
|
262
265
|
});
|
|
263
266
|
}
|
|
264
|
-
handleMessage(ws, message
|
|
267
|
+
handleMessage(ws, message) {
|
|
265
268
|
const conn = this.connections.get(ws);
|
|
266
269
|
if (!conn) return;
|
|
267
270
|
if (isHeartbeatMessage(message)) {
|
|
@@ -275,20 +278,61 @@ var CloudServer = class {
|
|
|
275
278
|
}
|
|
276
279
|
return;
|
|
277
280
|
}
|
|
281
|
+
if (isAgentHelloMessage(message)) {
|
|
282
|
+
this.handleAgentHello(ws);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (isConnectorHelloMessage(message)) {
|
|
286
|
+
this.handleConnectorHello(ws, message);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
278
289
|
if (isCloudResponse(message)) {
|
|
279
|
-
this.handleResponse(ws, message
|
|
290
|
+
this.handleResponse(ws, message);
|
|
280
291
|
return;
|
|
281
292
|
}
|
|
282
293
|
if (isCloudMessage(message)) {
|
|
283
|
-
this.handleRequest(ws, message
|
|
294
|
+
this.handleRequest(ws, message);
|
|
284
295
|
return;
|
|
285
296
|
}
|
|
286
297
|
}
|
|
287
|
-
|
|
288
|
-
const { id, action, data } = message;
|
|
298
|
+
handleAgentHello(ws) {
|
|
289
299
|
const conn = this.connections.get(ws);
|
|
290
300
|
if (!conn) return;
|
|
291
301
|
if (!conn.fromLocal && !this.options.allowRemoteAgent) {
|
|
302
|
+
ws.close(1008, "Agent must be local");
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
conn.role = "agent";
|
|
306
|
+
delete conn.connector;
|
|
307
|
+
}
|
|
308
|
+
handleConnectorHello(ws, message) {
|
|
309
|
+
const conn = this.connections.get(ws);
|
|
310
|
+
if (!conn || conn.fromLocal) {
|
|
311
|
+
ws.close(1008, "Connector must use the connector listener");
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
for (const [otherWs, other] of this.connections) {
|
|
315
|
+
if (otherWs !== ws && other.role === "connector" && other.connector?.connectorId === message.connectorId) {
|
|
316
|
+
this.connections.delete(otherWs);
|
|
317
|
+
this.rejectPendingForWs(otherWs);
|
|
318
|
+
otherWs.close(1e3, "Connector replaced by a newer connection");
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
conn.role = "connector";
|
|
322
|
+
conn.connector = {
|
|
323
|
+
connectorId: message.connectorId,
|
|
324
|
+
name: message.name,
|
|
325
|
+
...message.version ? { version: message.version } : {}
|
|
326
|
+
};
|
|
327
|
+
console.log(
|
|
328
|
+
`[CloudServer] Connector registered: ${message.name} (${message.connectorId}, v${message.version ?? "unknown"})`
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
handleRequest(ws, message) {
|
|
332
|
+
const { id, action, data } = message;
|
|
333
|
+
const conn = this.connections.get(ws);
|
|
334
|
+
if (!conn) return;
|
|
335
|
+
if (conn.role !== "agent") {
|
|
292
336
|
console.warn(
|
|
293
337
|
`[CloudServer] Rejected agent request from non-local connection ${conn.id} (action: ${action})`
|
|
294
338
|
);
|
|
@@ -296,57 +340,124 @@ var CloudServer = class {
|
|
|
296
340
|
JSON.stringify({
|
|
297
341
|
id,
|
|
298
342
|
success: false,
|
|
299
|
-
error: "Agent requests are only accepted on the local interface (127.0.0.1). Run cloud-client on the same machine as cloud-server, or start the server with --allow-remote-agent to override."
|
|
343
|
+
error: this.options.allowRemoteAgent && !conn.fromLocal ? "Remote agents must identify themselves with an agent_hello message before issuing commands." : "Agent requests are only accepted on the local interface (127.0.0.1). Run cloud-client on the same machine as cloud-server, or start the server with --allow-remote-agent to override."
|
|
300
344
|
}) + "\n"
|
|
301
345
|
);
|
|
302
|
-
ws.close(1008, "Agent must be local");
|
|
303
346
|
return;
|
|
304
347
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
348
|
+
if (action === "connector_list") {
|
|
349
|
+
const response = {
|
|
350
|
+
id,
|
|
351
|
+
success: true,
|
|
352
|
+
data: this.getConnectorList()
|
|
353
|
+
};
|
|
354
|
+
ws.send(JSON.stringify(response) + "\n");
|
|
309
355
|
return;
|
|
310
356
|
}
|
|
311
|
-
|
|
312
|
-
|
|
357
|
+
const target = this.resolveConnector(message.connector);
|
|
358
|
+
if ("error" in target) {
|
|
359
|
+
ws.send(JSON.stringify({ id, success: false, error: target.error }) + "\n");
|
|
313
360
|
return;
|
|
314
361
|
}
|
|
362
|
+
const routeId = randomBytes(12).toString("base64url");
|
|
315
363
|
const requestedTimeoutSec = Number(message.timeoutSec);
|
|
316
364
|
const timeoutSec = Number.isFinite(requestedTimeoutSec) && requestedTimeoutSec > 0 ? Math.min(requestedTimeoutSec, 3600) : resolveActionTimeoutSec(action, data, this.options.requestTimeout);
|
|
317
365
|
const timer = setTimeout(
|
|
318
366
|
() => {
|
|
319
|
-
this.pendingRequests.delete(
|
|
367
|
+
this.pendingRequests.delete(routeId);
|
|
320
368
|
if (ws.readyState === WebSocket.OPEN) {
|
|
321
369
|
ws.send(JSON.stringify({ id, success: false, error: "Request timeout" }) + "\n");
|
|
322
370
|
}
|
|
323
371
|
},
|
|
324
372
|
timeoutSec * 1e3 + RESPONSE_GRACE_MS
|
|
325
373
|
);
|
|
326
|
-
this.pendingRequests.set(
|
|
327
|
-
|
|
328
|
-
|
|
374
|
+
this.pendingRequests.set(routeId, {
|
|
375
|
+
agentRequestId: id,
|
|
376
|
+
agentWs: ws,
|
|
377
|
+
connectorWs: target.ws,
|
|
378
|
+
timer
|
|
379
|
+
});
|
|
380
|
+
target.ws.send(
|
|
381
|
+
JSON.stringify({
|
|
382
|
+
id: routeId,
|
|
383
|
+
action,
|
|
384
|
+
data,
|
|
385
|
+
browser: message.browser,
|
|
386
|
+
timeoutSec
|
|
387
|
+
}) + "\n"
|
|
329
388
|
);
|
|
330
389
|
}
|
|
331
|
-
handleResponse(ws, response
|
|
390
|
+
handleResponse(ws, response) {
|
|
332
391
|
const conn = this.connections.get(ws);
|
|
333
392
|
if (!conn) return;
|
|
334
|
-
if (conn.
|
|
393
|
+
if (conn.role !== "connector") return;
|
|
335
394
|
const pending = this.pendingRequests.get(response.id);
|
|
336
395
|
if (!pending || pending.connectorWs !== ws) return;
|
|
337
396
|
this.pendingRequests.delete(response.id);
|
|
338
397
|
clearTimeout(pending.timer);
|
|
339
398
|
if (pending.agentWs.readyState === WebSocket.OPEN) {
|
|
340
|
-
pending.agentWs.send(
|
|
399
|
+
pending.agentWs.send(
|
|
400
|
+
JSON.stringify({ ...response, id: pending.agentRequestId }) + "\n"
|
|
401
|
+
);
|
|
341
402
|
}
|
|
342
403
|
}
|
|
343
|
-
|
|
404
|
+
connectorInfo(ws, conn) {
|
|
405
|
+
const pendingRequests = Array.from(this.pendingRequests.values()).filter(
|
|
406
|
+
(pending) => pending.connectorWs === ws
|
|
407
|
+
).length;
|
|
408
|
+
return {
|
|
409
|
+
connectorId: conn.connector?.connectorId ?? conn.id,
|
|
410
|
+
name: conn.connector?.name ?? `legacy-${conn.id.slice(-8)}`,
|
|
411
|
+
...conn.connector?.version ? { version: conn.connector.version } : {},
|
|
412
|
+
connectedAt: new Date(conn.connectedAt).toISOString(),
|
|
413
|
+
lastHeartbeatAt: new Date(conn.lastHeartbeat).toISOString(),
|
|
414
|
+
pendingRequests,
|
|
415
|
+
...!conn.connector ? { legacy: true } : {}
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
getConnectorConnections() {
|
|
419
|
+
const connectors = [];
|
|
344
420
|
for (const [ws, conn] of this.connections) {
|
|
345
|
-
if (
|
|
346
|
-
|
|
421
|
+
if (conn.role === "pending" && Date.now() - conn.connectedAt >= LEGACY_CONNECTOR_HELLO_GRACE_MS) {
|
|
422
|
+
conn.role = "connector";
|
|
347
423
|
}
|
|
424
|
+
if (conn.role !== "connector" || ws.readyState !== WebSocket.OPEN) continue;
|
|
425
|
+
connectors.push({ ws, conn, info: this.connectorInfo(ws, conn) });
|
|
348
426
|
}
|
|
349
|
-
return
|
|
427
|
+
return connectors.sort((a, b) => a.info.name.localeCompare(b.info.name));
|
|
428
|
+
}
|
|
429
|
+
getConnectorList() {
|
|
430
|
+
const connectors = this.getConnectorConnections().map(({ info }) => info);
|
|
431
|
+
return { count: connectors.length, connectors };
|
|
432
|
+
}
|
|
433
|
+
resolveConnector(selector) {
|
|
434
|
+
const connectors = this.getConnectorConnections();
|
|
435
|
+
if (connectors.length === 0) return { error: "No local connector available" };
|
|
436
|
+
if (!selector) {
|
|
437
|
+
if (connectors.length === 1) {
|
|
438
|
+
return { ws: connectors[0].ws, info: connectors[0].info };
|
|
439
|
+
}
|
|
440
|
+
return {
|
|
441
|
+
error: `Multiple cloud connectors are connected (${connectors.length}). Run \`mearl connector_list\` and pass --connector <id|name>.`
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
const selection = resolveCloudConnectorSelector(
|
|
445
|
+
connectors.map(({ info }) => info),
|
|
446
|
+
selector
|
|
447
|
+
);
|
|
448
|
+
if (selection.status === "matched") {
|
|
449
|
+
const matched = connectors.find(({ info }) => info === selection.connector);
|
|
450
|
+
return { ws: matched.ws, info: matched.info };
|
|
451
|
+
}
|
|
452
|
+
const available = connectors.map(({ info }) => `${info.name} [${info.connectorId}]`).join(", ");
|
|
453
|
+
if (selection.status === "ambiguous") {
|
|
454
|
+
return {
|
|
455
|
+
error: `Cloud connector selector "${selector}" is ambiguous. Available: ${available}`
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
return {
|
|
459
|
+
error: `No cloud connector matches "${selector}". Available: ${available}`
|
|
460
|
+
};
|
|
350
461
|
}
|
|
351
462
|
rejectPendingForWs(ws) {
|
|
352
463
|
this.pendingRequests.forEach((pending, id) => {
|
|
@@ -355,7 +466,11 @@ var CloudServer = class {
|
|
|
355
466
|
this.pendingRequests.delete(id);
|
|
356
467
|
if (pending.connectorWs === ws && pending.agentWs !== ws && pending.agentWs.readyState === WebSocket.OPEN) {
|
|
357
468
|
pending.agentWs.send(
|
|
358
|
-
JSON.stringify({
|
|
469
|
+
JSON.stringify({
|
|
470
|
+
id: pending.agentRequestId,
|
|
471
|
+
success: false,
|
|
472
|
+
error: "Connector connection closed"
|
|
473
|
+
}) + "\n"
|
|
359
474
|
);
|
|
360
475
|
}
|
|
361
476
|
});
|
|
@@ -430,15 +545,13 @@ var CloudServer = class {
|
|
|
430
545
|
}
|
|
431
546
|
getStats() {
|
|
432
547
|
let agents = 0;
|
|
433
|
-
let connectors = 0;
|
|
434
548
|
for (const conn of this.connections.values()) {
|
|
435
|
-
if (conn.
|
|
436
|
-
else connectors++;
|
|
549
|
+
if (conn.role === "agent") agents++;
|
|
437
550
|
}
|
|
438
551
|
return {
|
|
439
552
|
connections: this.connections.size,
|
|
440
553
|
agents,
|
|
441
|
-
connectors,
|
|
554
|
+
connectors: this.getConnectorConnections().length,
|
|
442
555
|
pendingRequests: this.pendingRequests.size
|
|
443
556
|
};
|
|
444
557
|
}
|
|
@@ -507,7 +620,7 @@ async function runForeground(options) {
|
|
|
507
620
|
}
|
|
508
621
|
|
|
509
622
|
// src/cli.ts
|
|
510
|
-
var CLOUD_SERVER_VERSION = true ? "2.
|
|
623
|
+
var CLOUD_SERVER_VERSION = true ? "2.2.0" : "unknown";
|
|
511
624
|
var argv = process.argv.slice(2);
|
|
512
625
|
if (argv.includes("--version") || argv.includes("-v")) {
|
|
513
626
|
console.log(CLOUD_SERVER_VERSION);
|
package/dist/index.d.ts
CHANGED
|
@@ -48,9 +48,14 @@ export declare class CloudServer {
|
|
|
48
48
|
stop(): Promise<void>;
|
|
49
49
|
private handleConnection;
|
|
50
50
|
private handleMessage;
|
|
51
|
+
private handleAgentHello;
|
|
52
|
+
private handleConnectorHello;
|
|
51
53
|
private handleRequest;
|
|
52
54
|
private handleResponse;
|
|
53
|
-
private
|
|
55
|
+
private connectorInfo;
|
|
56
|
+
private getConnectorConnections;
|
|
57
|
+
private getConnectorList;
|
|
58
|
+
private resolveConnector;
|
|
54
59
|
private rejectPendingForWs;
|
|
55
60
|
private startHeartbeatCheck;
|
|
56
61
|
private getPublicIP;
|
package/dist/index.js
CHANGED
|
@@ -14,9 +14,12 @@ var LOG_FILE = join(CONFIG_DIR, "cloud-server.log");
|
|
|
14
14
|
|
|
15
15
|
// src/index.ts
|
|
16
16
|
import {
|
|
17
|
+
isAgentHelloMessage,
|
|
18
|
+
isConnectorHelloMessage,
|
|
17
19
|
isHeartbeatMessage,
|
|
18
20
|
isCloudMessage,
|
|
19
21
|
isCloudResponse,
|
|
22
|
+
resolveCloudConnectorSelector,
|
|
20
23
|
MAX_BUFFER_SIZE,
|
|
21
24
|
DEFAULT_HEARTBEAT_TIMEOUT,
|
|
22
25
|
DEFAULT_REQUEST_TIMEOUT
|
|
@@ -40,6 +43,7 @@ var DEFAULT_PATH = "/ws";
|
|
|
40
43
|
var DEFAULT_MAX_CONNECTIONS = 100;
|
|
41
44
|
var DEFAULT_MAX_MESSAGE_SIZE = 10 * 1024 * 1024;
|
|
42
45
|
var RESPONSE_GRACE_MS = 5e3;
|
|
46
|
+
var LEGACY_CONNECTOR_HELLO_GRACE_MS = 1e3;
|
|
43
47
|
var CloudServer = class {
|
|
44
48
|
options;
|
|
45
49
|
connectorWss = null;
|
|
@@ -214,9 +218,8 @@ var CloudServer = class {
|
|
|
214
218
|
ws,
|
|
215
219
|
id: connId,
|
|
216
220
|
lastHeartbeat: Date.now(),
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
isAgent: fromLocal,
|
|
221
|
+
connectedAt: Date.now(),
|
|
222
|
+
role: fromLocal ? "agent" : "pending",
|
|
220
223
|
fromLocal,
|
|
221
224
|
messageBuffer: ""
|
|
222
225
|
};
|
|
@@ -240,7 +243,7 @@ var CloudServer = class {
|
|
|
240
243
|
if (!line.trim()) continue;
|
|
241
244
|
try {
|
|
242
245
|
const message = JSON.parse(line);
|
|
243
|
-
this.handleMessage(ws, message
|
|
246
|
+
this.handleMessage(ws, message);
|
|
244
247
|
} catch (error) {
|
|
245
248
|
console.error(`[CloudServer] Failed to parse message from ${connId}:`, line, error);
|
|
246
249
|
}
|
|
@@ -255,7 +258,7 @@ var CloudServer = class {
|
|
|
255
258
|
console.error(`[CloudServer] Connection error for ${connId}:`, error.message);
|
|
256
259
|
});
|
|
257
260
|
}
|
|
258
|
-
handleMessage(ws, message
|
|
261
|
+
handleMessage(ws, message) {
|
|
259
262
|
const conn = this.connections.get(ws);
|
|
260
263
|
if (!conn) return;
|
|
261
264
|
if (isHeartbeatMessage(message)) {
|
|
@@ -269,20 +272,61 @@ var CloudServer = class {
|
|
|
269
272
|
}
|
|
270
273
|
return;
|
|
271
274
|
}
|
|
275
|
+
if (isAgentHelloMessage(message)) {
|
|
276
|
+
this.handleAgentHello(ws);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (isConnectorHelloMessage(message)) {
|
|
280
|
+
this.handleConnectorHello(ws, message);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
272
283
|
if (isCloudResponse(message)) {
|
|
273
|
-
this.handleResponse(ws, message
|
|
284
|
+
this.handleResponse(ws, message);
|
|
274
285
|
return;
|
|
275
286
|
}
|
|
276
287
|
if (isCloudMessage(message)) {
|
|
277
|
-
this.handleRequest(ws, message
|
|
288
|
+
this.handleRequest(ws, message);
|
|
278
289
|
return;
|
|
279
290
|
}
|
|
280
291
|
}
|
|
281
|
-
|
|
282
|
-
const { id, action, data } = message;
|
|
292
|
+
handleAgentHello(ws) {
|
|
283
293
|
const conn = this.connections.get(ws);
|
|
284
294
|
if (!conn) return;
|
|
285
295
|
if (!conn.fromLocal && !this.options.allowRemoteAgent) {
|
|
296
|
+
ws.close(1008, "Agent must be local");
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
conn.role = "agent";
|
|
300
|
+
delete conn.connector;
|
|
301
|
+
}
|
|
302
|
+
handleConnectorHello(ws, message) {
|
|
303
|
+
const conn = this.connections.get(ws);
|
|
304
|
+
if (!conn || conn.fromLocal) {
|
|
305
|
+
ws.close(1008, "Connector must use the connector listener");
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
for (const [otherWs, other] of this.connections) {
|
|
309
|
+
if (otherWs !== ws && other.role === "connector" && other.connector?.connectorId === message.connectorId) {
|
|
310
|
+
this.connections.delete(otherWs);
|
|
311
|
+
this.rejectPendingForWs(otherWs);
|
|
312
|
+
otherWs.close(1e3, "Connector replaced by a newer connection");
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
conn.role = "connector";
|
|
316
|
+
conn.connector = {
|
|
317
|
+
connectorId: message.connectorId,
|
|
318
|
+
name: message.name,
|
|
319
|
+
...message.version ? { version: message.version } : {}
|
|
320
|
+
};
|
|
321
|
+
console.log(
|
|
322
|
+
`[CloudServer] Connector registered: ${message.name} (${message.connectorId}, v${message.version ?? "unknown"})`
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
handleRequest(ws, message) {
|
|
326
|
+
const { id, action, data } = message;
|
|
327
|
+
const conn = this.connections.get(ws);
|
|
328
|
+
if (!conn) return;
|
|
329
|
+
if (conn.role !== "agent") {
|
|
286
330
|
console.warn(
|
|
287
331
|
`[CloudServer] Rejected agent request from non-local connection ${conn.id} (action: ${action})`
|
|
288
332
|
);
|
|
@@ -290,57 +334,124 @@ var CloudServer = class {
|
|
|
290
334
|
JSON.stringify({
|
|
291
335
|
id,
|
|
292
336
|
success: false,
|
|
293
|
-
error: "Agent requests are only accepted on the local interface (127.0.0.1). Run cloud-client on the same machine as cloud-server, or start the server with --allow-remote-agent to override."
|
|
337
|
+
error: this.options.allowRemoteAgent && !conn.fromLocal ? "Remote agents must identify themselves with an agent_hello message before issuing commands." : "Agent requests are only accepted on the local interface (127.0.0.1). Run cloud-client on the same machine as cloud-server, or start the server with --allow-remote-agent to override."
|
|
294
338
|
}) + "\n"
|
|
295
339
|
);
|
|
296
|
-
ws.close(1008, "Agent must be local");
|
|
297
340
|
return;
|
|
298
341
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
342
|
+
if (action === "connector_list") {
|
|
343
|
+
const response = {
|
|
344
|
+
id,
|
|
345
|
+
success: true,
|
|
346
|
+
data: this.getConnectorList()
|
|
347
|
+
};
|
|
348
|
+
ws.send(JSON.stringify(response) + "\n");
|
|
303
349
|
return;
|
|
304
350
|
}
|
|
305
|
-
|
|
306
|
-
|
|
351
|
+
const target = this.resolveConnector(message.connector);
|
|
352
|
+
if ("error" in target) {
|
|
353
|
+
ws.send(JSON.stringify({ id, success: false, error: target.error }) + "\n");
|
|
307
354
|
return;
|
|
308
355
|
}
|
|
356
|
+
const routeId = randomBytes(12).toString("base64url");
|
|
309
357
|
const requestedTimeoutSec = Number(message.timeoutSec);
|
|
310
358
|
const timeoutSec = Number.isFinite(requestedTimeoutSec) && requestedTimeoutSec > 0 ? Math.min(requestedTimeoutSec, 3600) : resolveActionTimeoutSec(action, data, this.options.requestTimeout);
|
|
311
359
|
const timer = setTimeout(
|
|
312
360
|
() => {
|
|
313
|
-
this.pendingRequests.delete(
|
|
361
|
+
this.pendingRequests.delete(routeId);
|
|
314
362
|
if (ws.readyState === WebSocket.OPEN) {
|
|
315
363
|
ws.send(JSON.stringify({ id, success: false, error: "Request timeout" }) + "\n");
|
|
316
364
|
}
|
|
317
365
|
},
|
|
318
366
|
timeoutSec * 1e3 + RESPONSE_GRACE_MS
|
|
319
367
|
);
|
|
320
|
-
this.pendingRequests.set(
|
|
321
|
-
|
|
322
|
-
|
|
368
|
+
this.pendingRequests.set(routeId, {
|
|
369
|
+
agentRequestId: id,
|
|
370
|
+
agentWs: ws,
|
|
371
|
+
connectorWs: target.ws,
|
|
372
|
+
timer
|
|
373
|
+
});
|
|
374
|
+
target.ws.send(
|
|
375
|
+
JSON.stringify({
|
|
376
|
+
id: routeId,
|
|
377
|
+
action,
|
|
378
|
+
data,
|
|
379
|
+
browser: message.browser,
|
|
380
|
+
timeoutSec
|
|
381
|
+
}) + "\n"
|
|
323
382
|
);
|
|
324
383
|
}
|
|
325
|
-
handleResponse(ws, response
|
|
384
|
+
handleResponse(ws, response) {
|
|
326
385
|
const conn = this.connections.get(ws);
|
|
327
386
|
if (!conn) return;
|
|
328
|
-
if (conn.
|
|
387
|
+
if (conn.role !== "connector") return;
|
|
329
388
|
const pending = this.pendingRequests.get(response.id);
|
|
330
389
|
if (!pending || pending.connectorWs !== ws) return;
|
|
331
390
|
this.pendingRequests.delete(response.id);
|
|
332
391
|
clearTimeout(pending.timer);
|
|
333
392
|
if (pending.agentWs.readyState === WebSocket.OPEN) {
|
|
334
|
-
pending.agentWs.send(
|
|
393
|
+
pending.agentWs.send(
|
|
394
|
+
JSON.stringify({ ...response, id: pending.agentRequestId }) + "\n"
|
|
395
|
+
);
|
|
335
396
|
}
|
|
336
397
|
}
|
|
337
|
-
|
|
398
|
+
connectorInfo(ws, conn) {
|
|
399
|
+
const pendingRequests = Array.from(this.pendingRequests.values()).filter(
|
|
400
|
+
(pending) => pending.connectorWs === ws
|
|
401
|
+
).length;
|
|
402
|
+
return {
|
|
403
|
+
connectorId: conn.connector?.connectorId ?? conn.id,
|
|
404
|
+
name: conn.connector?.name ?? `legacy-${conn.id.slice(-8)}`,
|
|
405
|
+
...conn.connector?.version ? { version: conn.connector.version } : {},
|
|
406
|
+
connectedAt: new Date(conn.connectedAt).toISOString(),
|
|
407
|
+
lastHeartbeatAt: new Date(conn.lastHeartbeat).toISOString(),
|
|
408
|
+
pendingRequests,
|
|
409
|
+
...!conn.connector ? { legacy: true } : {}
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
getConnectorConnections() {
|
|
413
|
+
const connectors = [];
|
|
338
414
|
for (const [ws, conn] of this.connections) {
|
|
339
|
-
if (
|
|
340
|
-
|
|
415
|
+
if (conn.role === "pending" && Date.now() - conn.connectedAt >= LEGACY_CONNECTOR_HELLO_GRACE_MS) {
|
|
416
|
+
conn.role = "connector";
|
|
341
417
|
}
|
|
418
|
+
if (conn.role !== "connector" || ws.readyState !== WebSocket.OPEN) continue;
|
|
419
|
+
connectors.push({ ws, conn, info: this.connectorInfo(ws, conn) });
|
|
342
420
|
}
|
|
343
|
-
return
|
|
421
|
+
return connectors.sort((a, b) => a.info.name.localeCompare(b.info.name));
|
|
422
|
+
}
|
|
423
|
+
getConnectorList() {
|
|
424
|
+
const connectors = this.getConnectorConnections().map(({ info }) => info);
|
|
425
|
+
return { count: connectors.length, connectors };
|
|
426
|
+
}
|
|
427
|
+
resolveConnector(selector) {
|
|
428
|
+
const connectors = this.getConnectorConnections();
|
|
429
|
+
if (connectors.length === 0) return { error: "No local connector available" };
|
|
430
|
+
if (!selector) {
|
|
431
|
+
if (connectors.length === 1) {
|
|
432
|
+
return { ws: connectors[0].ws, info: connectors[0].info };
|
|
433
|
+
}
|
|
434
|
+
return {
|
|
435
|
+
error: `Multiple cloud connectors are connected (${connectors.length}). Run \`mearl connector_list\` and pass --connector <id|name>.`
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
const selection = resolveCloudConnectorSelector(
|
|
439
|
+
connectors.map(({ info }) => info),
|
|
440
|
+
selector
|
|
441
|
+
);
|
|
442
|
+
if (selection.status === "matched") {
|
|
443
|
+
const matched = connectors.find(({ info }) => info === selection.connector);
|
|
444
|
+
return { ws: matched.ws, info: matched.info };
|
|
445
|
+
}
|
|
446
|
+
const available = connectors.map(({ info }) => `${info.name} [${info.connectorId}]`).join(", ");
|
|
447
|
+
if (selection.status === "ambiguous") {
|
|
448
|
+
return {
|
|
449
|
+
error: `Cloud connector selector "${selector}" is ambiguous. Available: ${available}`
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
return {
|
|
453
|
+
error: `No cloud connector matches "${selector}". Available: ${available}`
|
|
454
|
+
};
|
|
344
455
|
}
|
|
345
456
|
rejectPendingForWs(ws) {
|
|
346
457
|
this.pendingRequests.forEach((pending, id) => {
|
|
@@ -349,7 +460,11 @@ var CloudServer = class {
|
|
|
349
460
|
this.pendingRequests.delete(id);
|
|
350
461
|
if (pending.connectorWs === ws && pending.agentWs !== ws && pending.agentWs.readyState === WebSocket.OPEN) {
|
|
351
462
|
pending.agentWs.send(
|
|
352
|
-
JSON.stringify({
|
|
463
|
+
JSON.stringify({
|
|
464
|
+
id: pending.agentRequestId,
|
|
465
|
+
success: false,
|
|
466
|
+
error: "Connector connection closed"
|
|
467
|
+
}) + "\n"
|
|
353
468
|
);
|
|
354
469
|
}
|
|
355
470
|
});
|
|
@@ -424,15 +539,13 @@ var CloudServer = class {
|
|
|
424
539
|
}
|
|
425
540
|
getStats() {
|
|
426
541
|
let agents = 0;
|
|
427
|
-
let connectors = 0;
|
|
428
542
|
for (const conn of this.connections.values()) {
|
|
429
|
-
if (conn.
|
|
430
|
-
else connectors++;
|
|
543
|
+
if (conn.role === "agent") agents++;
|
|
431
544
|
}
|
|
432
545
|
return {
|
|
433
546
|
connections: this.connections.size,
|
|
434
547
|
agents,
|
|
435
|
-
connectors,
|
|
548
|
+
connectors: this.getConnectorConnections().length,
|
|
436
549
|
pendingRequests: this.pendingRequests.size
|
|
437
550
|
};
|
|
438
551
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mearl/cloud-server",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Cloud WebSocket server for Mearl — bridges cloud agents to local connectors",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -29,9 +29,9 @@
|
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"node-fetch": "^3.3.2",
|
|
31
31
|
"ws": "^8.18.0",
|
|
32
|
-
"@mearl/
|
|
33
|
-
"@mearl/
|
|
34
|
-
"@mearl/daemon-core": "2.
|
|
32
|
+
"@mearl/cloud-types": "2.2.0",
|
|
33
|
+
"@mearl/client": "2.2.0",
|
|
34
|
+
"@mearl/daemon-core": "2.2.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@types/node": "^24.9.1",
|