Sign In

@mearl/cloud-server

Package Overview
Dependencies
Maintainers
1
Versions
20
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@mearl/cloud-server - npm Package Compare versions

Comparing version
2.1.0
to
2.2.0
+147
-34
dist/cli.js

@@ -23,5 +23,8 @@ #!/usr/bin/env node

import {
isAgentHelloMessage,
isConnectorHelloMessage,
isHeartbeatMessage,
isCloudMessage,
isCloudResponse,
resolveCloudConnectorSelector,
MAX_BUFFER_SIZE,

@@ -50,2 +53,3 @@ DEFAULT_HEARTBEAT_TIMEOUT,

var RESPONSE_GRACE_MS = 5e3;
var LEGACY_CONNECTOR_HELLO_GRACE_MS = 1e3;
var CloudServer = class {

@@ -225,5 +229,4 @@ options;

lastHeartbeat: Date.now(),
// The loopback listener is agent-only. Marking it immediately prevents an
// idle local client from being mistaken for a connector during reconnects.
isAgent: fromLocal,
connectedAt: Date.now(),
role: fromLocal ? "agent" : "pending",
fromLocal,

@@ -252,3 +255,3 @@ messageBuffer: ""

const message = JSON.parse(line);
this.handleMessage(ws, message, connId);
this.handleMessage(ws, message);
} catch (error) {

@@ -268,3 +271,3 @@ console.error(`[CloudServer] Failed to parse message from ${connId}:`, line, error);

}
handleMessage(ws, message, connId) {
handleMessage(ws, message) {
const conn = this.connections.get(ws);

@@ -283,16 +286,57 @@ if (!conn) return;

}
if (isAgentHelloMessage(message)) {
this.handleAgentHello(ws);
return;
}
if (isConnectorHelloMessage(message)) {
this.handleConnectorHello(ws, message);
return;
}
if (isCloudResponse(message)) {
this.handleResponse(ws, message, connId);
this.handleResponse(ws, message);
return;
}
if (isCloudMessage(message)) {
this.handleRequest(ws, message, connId);
this.handleRequest(ws, message);
return;
}
}
handleRequest(ws, message, _connId) {
handleAgentHello(ws) {
const conn = this.connections.get(ws);
if (!conn) return;
if (!conn.fromLocal && !this.options.allowRemoteAgent) {
ws.close(1008, "Agent must be local");
return;
}
conn.role = "agent";
delete conn.connector;
}
handleConnectorHello(ws, message) {
const conn = this.connections.get(ws);
if (!conn || conn.fromLocal) {
ws.close(1008, "Connector must use the connector listener");
return;
}
for (const [otherWs, other] of this.connections) {
if (otherWs !== ws && other.role === "connector" && other.connector?.connectorId === message.connectorId) {
this.connections.delete(otherWs);
this.rejectPendingForWs(otherWs);
otherWs.close(1e3, "Connector replaced by a newer connection");
}
}
conn.role = "connector";
conn.connector = {
connectorId: message.connectorId,
name: message.name,
...message.version ? { version: message.version } : {}
};
console.log(
`[CloudServer] Connector registered: ${message.name} (${message.connectorId}, v${message.version ?? "unknown"})`
);
}
handleRequest(ws, message) {
const { id, action, data } = message;
const conn = this.connections.get(ws);
if (!conn) return;
if (!conn.fromLocal && !this.options.allowRemoteAgent) {
if (conn.role !== "agent") {
console.warn(

@@ -305,18 +349,22 @@ `[CloudServer] Rejected agent request from non-local connection ${conn.id} (action: ${action})`

success: false,
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."
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."
}) + "\n"
);
ws.close(1008, "Agent must be local");
return;
}
conn.isAgent = true;
const targetWs = this.findConnector();
if (!targetWs) {
ws.send(JSON.stringify({ id, success: false, error: "No local connector available" }) + "\n");
if (action === "connector_list") {
const response = {
id,
success: true,
data: this.getConnectorList()
};
ws.send(JSON.stringify(response) + "\n");
return;
}
if (this.pendingRequests.has(id)) {
ws.send(JSON.stringify({ id, success: false, error: "Duplicate request id" }) + "\n");
const target = this.resolveConnector(message.connector);
if ("error" in target) {
ws.send(JSON.stringify({ id, success: false, error: target.error }) + "\n");
return;
}
const routeId = randomBytes(12).toString("base64url");
const requestedTimeoutSec = Number(message.timeoutSec);

@@ -326,3 +374,3 @@ const timeoutSec = Number.isFinite(requestedTimeoutSec) && requestedTimeoutSec > 0 ? Math.min(requestedTimeoutSec, 3600) : resolveActionTimeoutSec(action, data, this.options.requestTimeout);

() => {
this.pendingRequests.delete(id);
this.pendingRequests.delete(routeId);
if (ws.readyState === WebSocket.OPEN) {

@@ -334,11 +382,22 @@ ws.send(JSON.stringify({ id, success: false, error: "Request timeout" }) + "\n");

);
this.pendingRequests.set(id, { agentWs: ws, connectorWs: targetWs, timer });
targetWs.send(
JSON.stringify({ id, action, data, browser: message.browser, timeoutSec }) + "\n"
this.pendingRequests.set(routeId, {
agentRequestId: id,
agentWs: ws,
connectorWs: target.ws,
timer
});
target.ws.send(
JSON.stringify({
id: routeId,
action,
data,
browser: message.browser,
timeoutSec
}) + "\n"
);
}
handleResponse(ws, response, _connId) {
handleResponse(ws, response) {
const conn = this.connections.get(ws);
if (!conn) return;
if (conn.fromLocal || conn.isAgent) return;
if (conn.role !== "connector") return;
const pending = this.pendingRequests.get(response.id);

@@ -349,13 +408,65 @@ if (!pending || pending.connectorWs !== ws) return;

if (pending.agentWs.readyState === WebSocket.OPEN) {
pending.agentWs.send(JSON.stringify(response) + "\n");
pending.agentWs.send(
JSON.stringify({ ...response, id: pending.agentRequestId }) + "\n"
);
}
}
findConnector() {
connectorInfo(ws, conn) {
const pendingRequests = Array.from(this.pendingRequests.values()).filter(
(pending) => pending.connectorWs === ws
).length;
return {
connectorId: conn.connector?.connectorId ?? conn.id,
name: conn.connector?.name ?? `legacy-${conn.id.slice(-8)}`,
...conn.connector?.version ? { version: conn.connector.version } : {},
connectedAt: new Date(conn.connectedAt).toISOString(),
lastHeartbeatAt: new Date(conn.lastHeartbeat).toISOString(),
pendingRequests,
...!conn.connector ? { legacy: true } : {}
};
}
getConnectorConnections() {
const connectors = [];
for (const [ws, conn] of this.connections) {
if (!conn.fromLocal && !conn.isAgent && ws.readyState === WebSocket.OPEN) {
return ws;
if (conn.role === "pending" && Date.now() - conn.connectedAt >= LEGACY_CONNECTOR_HELLO_GRACE_MS) {
conn.role = "connector";
}
if (conn.role !== "connector" || ws.readyState !== WebSocket.OPEN) continue;
connectors.push({ ws, conn, info: this.connectorInfo(ws, conn) });
}
return null;
return connectors.sort((a, b) => a.info.name.localeCompare(b.info.name));
}
getConnectorList() {
const connectors = this.getConnectorConnections().map(({ info }) => info);
return { count: connectors.length, connectors };
}
resolveConnector(selector) {
const connectors = this.getConnectorConnections();
if (connectors.length === 0) return { error: "No local connector available" };
if (!selector) {
if (connectors.length === 1) {
return { ws: connectors[0].ws, info: connectors[0].info };
}
return {
error: `Multiple cloud connectors are connected (${connectors.length}). Run \`mearl connector_list\` and pass --connector <id|name>.`
};
}
const selection = resolveCloudConnectorSelector(
connectors.map(({ info }) => info),
selector
);
if (selection.status === "matched") {
const matched = connectors.find(({ info }) => info === selection.connector);
return { ws: matched.ws, info: matched.info };
}
const available = connectors.map(({ info }) => `${info.name} [${info.connectorId}]`).join(", ");
if (selection.status === "ambiguous") {
return {
error: `Cloud connector selector "${selector}" is ambiguous. Available: ${available}`
};
}
return {
error: `No cloud connector matches "${selector}". Available: ${available}`
};
}
rejectPendingForWs(ws) {

@@ -368,3 +479,7 @@ this.pendingRequests.forEach((pending, id) => {

pending.agentWs.send(
JSON.stringify({ id, success: false, error: "Connector connection closed" }) + "\n"
JSON.stringify({
id: pending.agentRequestId,
success: false,
error: "Connector connection closed"
}) + "\n"
);

@@ -444,6 +559,4 @@ }

let agents = 0;
let connectors = 0;
for (const conn of this.connections.values()) {
if (conn.isAgent) agents++;
else connectors++;
if (conn.role === "agent") agents++;
}

@@ -453,3 +566,3 @@ return {

agents,
connectors,
connectors: this.getConnectorConnections().length,
pendingRequests: this.pendingRequests.size

@@ -523,3 +636,3 @@ };

// src/cli.ts
var CLOUD_SERVER_VERSION = true ? "2.1.0" : "unknown";
var CLOUD_SERVER_VERSION = true ? "2.2.0" : "unknown";
var argv = process.argv.slice(2);

@@ -526,0 +639,0 @@ if (argv.includes("--version") || argv.includes("-v")) {

@@ -51,5 +51,10 @@ export interface CloudServerOptions {

private handleMessage;
private handleAgentHello;
private handleConnectorHello;
private handleRequest;
private handleResponse;
private findConnector;
private connectorInfo;
private getConnectorConnections;
private getConnectorList;
private resolveConnector;
private rejectPendingForWs;

@@ -56,0 +61,0 @@ private startHeartbeatCheck;

@@ -17,5 +17,8 @@ // src/index.ts

import {
isAgentHelloMessage,
isConnectorHelloMessage,
isHeartbeatMessage,
isCloudMessage,
isCloudResponse,
resolveCloudConnectorSelector,
MAX_BUFFER_SIZE,

@@ -44,2 +47,3 @@ DEFAULT_HEARTBEAT_TIMEOUT,

var RESPONSE_GRACE_MS = 5e3;
var LEGACY_CONNECTOR_HELLO_GRACE_MS = 1e3;
var CloudServer = class {

@@ -219,5 +223,4 @@ options;

lastHeartbeat: Date.now(),
// The loopback listener is agent-only. Marking it immediately prevents an
// idle local client from being mistaken for a connector during reconnects.
isAgent: fromLocal,
connectedAt: Date.now(),
role: fromLocal ? "agent" : "pending",
fromLocal,

@@ -246,3 +249,3 @@ messageBuffer: ""

const message = JSON.parse(line);
this.handleMessage(ws, message, connId);
this.handleMessage(ws, message);
} catch (error) {

@@ -262,3 +265,3 @@ console.error(`[CloudServer] Failed to parse message from ${connId}:`, line, error);

}
handleMessage(ws, message, connId) {
handleMessage(ws, message) {
const conn = this.connections.get(ws);

@@ -277,16 +280,57 @@ if (!conn) return;

}
if (isAgentHelloMessage(message)) {
this.handleAgentHello(ws);
return;
}
if (isConnectorHelloMessage(message)) {
this.handleConnectorHello(ws, message);
return;
}
if (isCloudResponse(message)) {
this.handleResponse(ws, message, connId);
this.handleResponse(ws, message);
return;
}
if (isCloudMessage(message)) {
this.handleRequest(ws, message, connId);
this.handleRequest(ws, message);
return;
}
}
handleRequest(ws, message, _connId) {
handleAgentHello(ws) {
const conn = this.connections.get(ws);
if (!conn) return;
if (!conn.fromLocal && !this.options.allowRemoteAgent) {
ws.close(1008, "Agent must be local");
return;
}
conn.role = "agent";
delete conn.connector;
}
handleConnectorHello(ws, message) {
const conn = this.connections.get(ws);
if (!conn || conn.fromLocal) {
ws.close(1008, "Connector must use the connector listener");
return;
}
for (const [otherWs, other] of this.connections) {
if (otherWs !== ws && other.role === "connector" && other.connector?.connectorId === message.connectorId) {
this.connections.delete(otherWs);
this.rejectPendingForWs(otherWs);
otherWs.close(1e3, "Connector replaced by a newer connection");
}
}
conn.role = "connector";
conn.connector = {
connectorId: message.connectorId,
name: message.name,
...message.version ? { version: message.version } : {}
};
console.log(
`[CloudServer] Connector registered: ${message.name} (${message.connectorId}, v${message.version ?? "unknown"})`
);
}
handleRequest(ws, message) {
const { id, action, data } = message;
const conn = this.connections.get(ws);
if (!conn) return;
if (!conn.fromLocal && !this.options.allowRemoteAgent) {
if (conn.role !== "agent") {
console.warn(

@@ -299,18 +343,22 @@ `[CloudServer] Rejected agent request from non-local connection ${conn.id} (action: ${action})`

success: false,
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."
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."
}) + "\n"
);
ws.close(1008, "Agent must be local");
return;
}
conn.isAgent = true;
const targetWs = this.findConnector();
if (!targetWs) {
ws.send(JSON.stringify({ id, success: false, error: "No local connector available" }) + "\n");
if (action === "connector_list") {
const response = {
id,
success: true,
data: this.getConnectorList()
};
ws.send(JSON.stringify(response) + "\n");
return;
}
if (this.pendingRequests.has(id)) {
ws.send(JSON.stringify({ id, success: false, error: "Duplicate request id" }) + "\n");
const target = this.resolveConnector(message.connector);
if ("error" in target) {
ws.send(JSON.stringify({ id, success: false, error: target.error }) + "\n");
return;
}
const routeId = randomBytes(12).toString("base64url");
const requestedTimeoutSec = Number(message.timeoutSec);

@@ -320,3 +368,3 @@ const timeoutSec = Number.isFinite(requestedTimeoutSec) && requestedTimeoutSec > 0 ? Math.min(requestedTimeoutSec, 3600) : resolveActionTimeoutSec(action, data, this.options.requestTimeout);

() => {
this.pendingRequests.delete(id);
this.pendingRequests.delete(routeId);
if (ws.readyState === WebSocket.OPEN) {

@@ -328,11 +376,22 @@ ws.send(JSON.stringify({ id, success: false, error: "Request timeout" }) + "\n");

);
this.pendingRequests.set(id, { agentWs: ws, connectorWs: targetWs, timer });
targetWs.send(
JSON.stringify({ id, action, data, browser: message.browser, timeoutSec }) + "\n"
this.pendingRequests.set(routeId, {
agentRequestId: id,
agentWs: ws,
connectorWs: target.ws,
timer
});
target.ws.send(
JSON.stringify({
id: routeId,
action,
data,
browser: message.browser,
timeoutSec
}) + "\n"
);
}
handleResponse(ws, response, _connId) {
handleResponse(ws, response) {
const conn = this.connections.get(ws);
if (!conn) return;
if (conn.fromLocal || conn.isAgent) return;
if (conn.role !== "connector") return;
const pending = this.pendingRequests.get(response.id);

@@ -343,13 +402,65 @@ if (!pending || pending.connectorWs !== ws) return;

if (pending.agentWs.readyState === WebSocket.OPEN) {
pending.agentWs.send(JSON.stringify(response) + "\n");
pending.agentWs.send(
JSON.stringify({ ...response, id: pending.agentRequestId }) + "\n"
);
}
}
findConnector() {
connectorInfo(ws, conn) {
const pendingRequests = Array.from(this.pendingRequests.values()).filter(
(pending) => pending.connectorWs === ws
).length;
return {
connectorId: conn.connector?.connectorId ?? conn.id,
name: conn.connector?.name ?? `legacy-${conn.id.slice(-8)}`,
...conn.connector?.version ? { version: conn.connector.version } : {},
connectedAt: new Date(conn.connectedAt).toISOString(),
lastHeartbeatAt: new Date(conn.lastHeartbeat).toISOString(),
pendingRequests,
...!conn.connector ? { legacy: true } : {}
};
}
getConnectorConnections() {
const connectors = [];
for (const [ws, conn] of this.connections) {
if (!conn.fromLocal && !conn.isAgent && ws.readyState === WebSocket.OPEN) {
return ws;
if (conn.role === "pending" && Date.now() - conn.connectedAt >= LEGACY_CONNECTOR_HELLO_GRACE_MS) {
conn.role = "connector";
}
if (conn.role !== "connector" || ws.readyState !== WebSocket.OPEN) continue;
connectors.push({ ws, conn, info: this.connectorInfo(ws, conn) });
}
return null;
return connectors.sort((a, b) => a.info.name.localeCompare(b.info.name));
}
getConnectorList() {
const connectors = this.getConnectorConnections().map(({ info }) => info);
return { count: connectors.length, connectors };
}
resolveConnector(selector) {
const connectors = this.getConnectorConnections();
if (connectors.length === 0) return { error: "No local connector available" };
if (!selector) {
if (connectors.length === 1) {
return { ws: connectors[0].ws, info: connectors[0].info };
}
return {
error: `Multiple cloud connectors are connected (${connectors.length}). Run \`mearl connector_list\` and pass --connector <id|name>.`
};
}
const selection = resolveCloudConnectorSelector(
connectors.map(({ info }) => info),
selector
);
if (selection.status === "matched") {
const matched = connectors.find(({ info }) => info === selection.connector);
return { ws: matched.ws, info: matched.info };
}
const available = connectors.map(({ info }) => `${info.name} [${info.connectorId}]`).join(", ");
if (selection.status === "ambiguous") {
return {
error: `Cloud connector selector "${selector}" is ambiguous. Available: ${available}`
};
}
return {
error: `No cloud connector matches "${selector}". Available: ${available}`
};
}
rejectPendingForWs(ws) {

@@ -362,3 +473,7 @@ this.pendingRequests.forEach((pending, id) => {

pending.agentWs.send(
JSON.stringify({ id, success: false, error: "Connector connection closed" }) + "\n"
JSON.stringify({
id: pending.agentRequestId,
success: false,
error: "Connector connection closed"
}) + "\n"
);

@@ -438,6 +553,4 @@ }

let agents = 0;
let connectors = 0;
for (const conn of this.connections.values()) {
if (conn.isAgent) agents++;
else connectors++;
if (conn.role === "agent") agents++;
}

@@ -447,3 +560,3 @@ return {

agents,
connectors,
connectors: this.getConnectorConnections().length,
pendingRequests: this.pendingRequests.size

@@ -450,0 +563,0 @@ };

+4
-4
{
"name": "@mearl/cloud-server",
"version": "2.1.0",
"version": "2.2.0",
"description": "Cloud WebSocket server for Mearl — bridges cloud agents to local connectors",

@@ -32,5 +32,5 @@ "type": "module",

"ws": "^8.18.0",
"@mearl/client": "2.1.0",
"@mearl/cloud-types": "2.1.0",
"@mearl/daemon-core": "2.1.0"
"@mearl/cloud-types": "2.2.0",
"@mearl/client": "2.2.0",
"@mearl/daemon-core": "2.2.0"
},

@@ -37,0 +37,0 @@ "devDependencies": {

@@ -24,2 +24,3 @@ # @mearl/cloud-server

**子命令:**
- `start` - 后台启动 server(默认,不写也等价于 `start`)

@@ -32,2 +33,3 @@ - `stop` - 停止后台 server

**选项:**
- `--foreground` / `-f` - 前台运行(不守护化,适合 systemd / Docker / PM2)

@@ -41,2 +43,3 @@ - `--port <port>` - 服务器端口(默认 8080)

**示例:**
```bash

@@ -61,2 +64,3 @@ # 后台启动(自动生成 token),打印连接命令后立即返回

后台启动后会打印供本地机器使用的连接命令,例如:
```

@@ -71,2 +75,3 @@ [CloudServer] Started in background (pid 12345)

运行时文件位于 `~/.mearl/`:
- `cloud-server.json` - 连接配置(含 server URL、pid、端口等),cloud-client 自动读取

@@ -78,2 +83,3 @@ - `cloud-server.log` - 后台进程日志(`logs` 子命令读取,或 `tail -f` 跟踪);超过 5MB 会在下次启动时滚动为 `cloud-server.log.1`

**关于 token:**
- 未指定 `--token` 时会自动生成。`restart` 会**沿用当前 token**,因此之前打印的连接命令重启后仍然有效。

@@ -83,2 +89,3 @@ - `stop` 后再 `start` 属于全新启动,会重新生成 token;若希望连接命令长期固定,请显式传入 `--token` 或设置 `MEARL_TOKEN`。

**环境变量:**
- `MEARL_TOKEN` - 鉴权密钥(覆盖 --token 选项)

@@ -135,7 +142,13 @@ - `CLOUD_SERVER_HOST` - 公网主机名(默认自动获取)

Server 会自动识别连接类型,并将 Agent 的请求转发到可用的 Connector。
两类客户端建连后会分别发送 `agent_hello` 和 `connector_hello`。Connector 会注册
稳定 ID、名称和版本;同一 ID 重连时,新连接会替换旧连接。
只有一个 Connector 在线时,Server 自动选择它。多个 Connector 同时在线时,Agent
先调用 `connector_list` 获取清单,再在请求中传 `connector`;未指定目标时
Server 会拒绝请求,避免把浏览器操作发到错误机器。
## 消息协议
### 请求格式(Agent → Server)
```json

@@ -146,2 +159,3 @@ {

"data": { "count": 5 },
"connector": "work-mac",
"version": "1.30.0"

@@ -151,3 +165,7 @@ }

`connector` 接受 connector ID 或可唯一匹配的名称。`connector_list` 是
Server 内置命令,不会转发到浏览器侧。
### 响应格式(Server → Agent)
```json

@@ -163,2 +181,3 @@ {

### 心跳格式
```json

@@ -225,2 +244,2 @@ { "type": "ping", "timestamp": 1234567890 }

- 在沙箱环境中启动时,会自动检测并使用平台分配的公网地址
- 如果沙箱端口映射 API 不可用,会回退到使用本地 IP 地址
- 如果沙箱端口映射 API 不可用,会回退到使用本地 IP 地址