@mearl/cloud-server
Advanced tools
+147
-34
@@ -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")) { |
+6
-1
@@ -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; |
+146
-33
@@ -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": { |
+21
-2
@@ -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 地址 |
55219
18.24%1390
20.14%234
9.35%+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
Updated
Updated
Updated