@mearl/cloud-server
Advanced tools
+42
-24
@@ -10,3 +10,4 @@ #!/usr/bin/env node | ||
| import { createServer as createHttpServer } from "node:http"; | ||
| import { existsSync, mkdirSync, writeFileSync } from "node:fs"; | ||
| import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; | ||
| import { randomBytes } from "node:crypto"; | ||
| import { networkInterfaces } from "node:os"; | ||
@@ -79,13 +80,8 @@ | ||
| generateRandomToken() { | ||
| const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; | ||
| let token = ""; | ||
| for (let i = 0; i < 32; i++) { | ||
| token += chars.charAt(Math.floor(Math.random() * chars.length)); | ||
| } | ||
| return token; | ||
| return randomBytes(24).toString("base64url"); | ||
| } | ||
| async start() { | ||
| const token = this.getToken(); | ||
| await this.createListener(this.options.agentPort, "127.0.0.1", true); | ||
| await this.createListener(this.options.port, "0.0.0.0", false); | ||
| const token = this.getToken(); | ||
| const localWsUrl = `ws://localhost:${this.options.agentPort}${this.options.path}?token=${token}`; | ||
@@ -109,2 +105,7 @@ let publicWsUrl; | ||
| } | ||
| if (publicWsUrl.startsWith("ws://") && !publicWsUrl.startsWith("ws://localhost")) { | ||
| console.warn( | ||
| "[CloudServer] Connector traffic is using unencrypted ws://. Use a TLS-terminating proxy and wss:// when the connection crosses a trusted network." | ||
| ); | ||
| } | ||
| const connectorCommand = `npx @mearl/cloud-connector "${publicWsUrl}"`; | ||
@@ -182,2 +183,5 @@ console.log( | ||
| this.connections.clear(); | ||
| for (const pending of this.pendingRequests.values()) { | ||
| clearTimeout(pending.timer); | ||
| } | ||
| this.pendingRequests.clear(); | ||
@@ -223,3 +227,5 @@ this.agentWss?.close(); | ||
| lastHeartbeat: Date.now(), | ||
| isAgent: false, | ||
| // The loopback listener is agent-only. Marking it immediately prevents an | ||
| // idle local client from being mistaken for a connector during reconnects. | ||
| isAgent: fromLocal, | ||
| fromLocal, | ||
@@ -310,2 +316,6 @@ messageBuffer: "" | ||
| } | ||
| if (this.pendingRequests.has(id)) { | ||
| ws.send(JSON.stringify({ id, success: false, error: "Duplicate request id" }) + "\n"); | ||
| return; | ||
| } | ||
| const requestedTimeoutSec = Number(message.timeoutSec); | ||
@@ -322,3 +332,3 @@ const timeoutSec = Number.isFinite(requestedTimeoutSec) && requestedTimeoutSec > 0 ? Math.min(requestedTimeoutSec, 3600) : resolveActionTimeoutSec(action, data, this.options.requestTimeout); | ||
| ); | ||
| this.pendingRequests.set(id, { ws, timer }); | ||
| this.pendingRequests.set(id, { agentWs: ws, connectorWs: targetWs, timer }); | ||
| targetWs.send( | ||
@@ -331,9 +341,9 @@ JSON.stringify({ id, action, data, browser: message.browser, timeoutSec }) + "\n" | ||
| if (!conn) return; | ||
| if (conn.isAgent) return; | ||
| if (conn.fromLocal || conn.isAgent) return; | ||
| const pending = this.pendingRequests.get(response.id); | ||
| if (!pending) return; | ||
| if (!pending || pending.connectorWs !== ws) return; | ||
| this.pendingRequests.delete(response.id); | ||
| clearTimeout(pending.timer); | ||
| if (pending.ws.readyState === WebSocket.OPEN) { | ||
| pending.ws.send(JSON.stringify(response) + "\n"); | ||
| if (pending.agentWs.readyState === WebSocket.OPEN) { | ||
| pending.agentWs.send(JSON.stringify(response) + "\n"); | ||
| } | ||
@@ -343,3 +353,3 @@ } | ||
| for (const [ws, conn] of this.connections) { | ||
| if (!conn.isAgent && ws.readyState === WebSocket.OPEN) { | ||
| if (!conn.fromLocal && !conn.isAgent && ws.readyState === WebSocket.OPEN) { | ||
| return ws; | ||
@@ -352,8 +362,9 @@ } | ||
| this.pendingRequests.forEach((pending, id) => { | ||
| if (pending.ws === ws) { | ||
| clearTimeout(pending.timer); | ||
| if (ws.readyState === WebSocket.OPEN) { | ||
| ws.send(JSON.stringify({ id, success: false, error: "Connection closed" }) + "\n"); | ||
| } | ||
| this.pendingRequests.delete(id); | ||
| if (pending.agentWs !== ws && pending.connectorWs !== ws) return; | ||
| clearTimeout(pending.timer); | ||
| this.pendingRequests.delete(id); | ||
| if (pending.connectorWs === ws && pending.agentWs !== ws && pending.agentWs.readyState === WebSocket.OPEN) { | ||
| pending.agentWs.send( | ||
| JSON.stringify({ id, success: false, error: "Connector connection closed" }) + "\n" | ||
| ); | ||
| } | ||
@@ -414,6 +425,13 @@ }); | ||
| if (!existsSync(CONFIG_DIR)) { | ||
| mkdirSync(CONFIG_DIR, { recursive: true }); | ||
| mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 }); | ||
| } | ||
| const config = { server: info.localUrl, ...info }; | ||
| writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8"); | ||
| writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { | ||
| encoding: "utf-8", | ||
| mode: 384 | ||
| }); | ||
| if (process.platform !== "win32") { | ||
| chmodSync(CONFIG_DIR, 448); | ||
| chmodSync(CONFIG_FILE, 384); | ||
| } | ||
| console.log(`[CloudServer] Config written to: ${CONFIG_FILE}`); | ||
@@ -503,3 +521,3 @@ } catch (error) { | ||
| // src/cli.ts | ||
| var CLOUD_SERVER_VERSION = true ? "2.0.2" : "unknown"; | ||
| var CLOUD_SERVER_VERSION = true ? "2.1.0" : "unknown"; | ||
| var argv = process.argv.slice(2); | ||
@@ -506,0 +524,0 @@ if (argv.includes("--version") || argv.includes("-v")) { |
+41
-23
| // src/index.ts | ||
| import WebSocket, { WebSocketServer } from "ws"; | ||
| import { createServer as createHttpServer } from "node:http"; | ||
| import { existsSync, mkdirSync, writeFileSync } from "node:fs"; | ||
| import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; | ||
| import { randomBytes } from "node:crypto"; | ||
| import { networkInterfaces } from "node:os"; | ||
@@ -72,13 +73,8 @@ | ||
| generateRandomToken() { | ||
| const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; | ||
| let token = ""; | ||
| for (let i = 0; i < 32; i++) { | ||
| token += chars.charAt(Math.floor(Math.random() * chars.length)); | ||
| } | ||
| return token; | ||
| return randomBytes(24).toString("base64url"); | ||
| } | ||
| async start() { | ||
| const token = this.getToken(); | ||
| await this.createListener(this.options.agentPort, "127.0.0.1", true); | ||
| await this.createListener(this.options.port, "0.0.0.0", false); | ||
| const token = this.getToken(); | ||
| const localWsUrl = `ws://localhost:${this.options.agentPort}${this.options.path}?token=${token}`; | ||
@@ -102,2 +98,7 @@ let publicWsUrl; | ||
| } | ||
| if (publicWsUrl.startsWith("ws://") && !publicWsUrl.startsWith("ws://localhost")) { | ||
| console.warn( | ||
| "[CloudServer] Connector traffic is using unencrypted ws://. Use a TLS-terminating proxy and wss:// when the connection crosses a trusted network." | ||
| ); | ||
| } | ||
| const connectorCommand = `npx @mearl/cloud-connector "${publicWsUrl}"`; | ||
@@ -175,2 +176,5 @@ console.log( | ||
| this.connections.clear(); | ||
| for (const pending of this.pendingRequests.values()) { | ||
| clearTimeout(pending.timer); | ||
| } | ||
| this.pendingRequests.clear(); | ||
@@ -216,3 +220,5 @@ this.agentWss?.close(); | ||
| lastHeartbeat: Date.now(), | ||
| isAgent: false, | ||
| // The loopback listener is agent-only. Marking it immediately prevents an | ||
| // idle local client from being mistaken for a connector during reconnects. | ||
| isAgent: fromLocal, | ||
| fromLocal, | ||
@@ -303,2 +309,6 @@ messageBuffer: "" | ||
| } | ||
| if (this.pendingRequests.has(id)) { | ||
| ws.send(JSON.stringify({ id, success: false, error: "Duplicate request id" }) + "\n"); | ||
| return; | ||
| } | ||
| const requestedTimeoutSec = Number(message.timeoutSec); | ||
@@ -315,3 +325,3 @@ const timeoutSec = Number.isFinite(requestedTimeoutSec) && requestedTimeoutSec > 0 ? Math.min(requestedTimeoutSec, 3600) : resolveActionTimeoutSec(action, data, this.options.requestTimeout); | ||
| ); | ||
| this.pendingRequests.set(id, { ws, timer }); | ||
| this.pendingRequests.set(id, { agentWs: ws, connectorWs: targetWs, timer }); | ||
| targetWs.send( | ||
@@ -324,9 +334,9 @@ JSON.stringify({ id, action, data, browser: message.browser, timeoutSec }) + "\n" | ||
| if (!conn) return; | ||
| if (conn.isAgent) return; | ||
| if (conn.fromLocal || conn.isAgent) return; | ||
| const pending = this.pendingRequests.get(response.id); | ||
| if (!pending) return; | ||
| if (!pending || pending.connectorWs !== ws) return; | ||
| this.pendingRequests.delete(response.id); | ||
| clearTimeout(pending.timer); | ||
| if (pending.ws.readyState === WebSocket.OPEN) { | ||
| pending.ws.send(JSON.stringify(response) + "\n"); | ||
| if (pending.agentWs.readyState === WebSocket.OPEN) { | ||
| pending.agentWs.send(JSON.stringify(response) + "\n"); | ||
| } | ||
@@ -336,3 +346,3 @@ } | ||
| for (const [ws, conn] of this.connections) { | ||
| if (!conn.isAgent && ws.readyState === WebSocket.OPEN) { | ||
| if (!conn.fromLocal && !conn.isAgent && ws.readyState === WebSocket.OPEN) { | ||
| return ws; | ||
@@ -345,8 +355,9 @@ } | ||
| this.pendingRequests.forEach((pending, id) => { | ||
| if (pending.ws === ws) { | ||
| clearTimeout(pending.timer); | ||
| if (ws.readyState === WebSocket.OPEN) { | ||
| ws.send(JSON.stringify({ id, success: false, error: "Connection closed" }) + "\n"); | ||
| } | ||
| this.pendingRequests.delete(id); | ||
| if (pending.agentWs !== ws && pending.connectorWs !== ws) return; | ||
| clearTimeout(pending.timer); | ||
| this.pendingRequests.delete(id); | ||
| if (pending.connectorWs === ws && pending.agentWs !== ws && pending.agentWs.readyState === WebSocket.OPEN) { | ||
| pending.agentWs.send( | ||
| JSON.stringify({ id, success: false, error: "Connector connection closed" }) + "\n" | ||
| ); | ||
| } | ||
@@ -407,6 +418,13 @@ }); | ||
| if (!existsSync(CONFIG_DIR)) { | ||
| mkdirSync(CONFIG_DIR, { recursive: true }); | ||
| mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 }); | ||
| } | ||
| const config = { server: info.localUrl, ...info }; | ||
| writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8"); | ||
| writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { | ||
| encoding: "utf-8", | ||
| mode: 384 | ||
| }); | ||
| if (process.platform !== "win32") { | ||
| chmodSync(CONFIG_DIR, 448); | ||
| chmodSync(CONFIG_FILE, 384); | ||
| } | ||
| console.log(`[CloudServer] Config written to: ${CONFIG_FILE}`); | ||
@@ -413,0 +431,0 @@ } catch (error) { |
+4
-4
| { | ||
| "name": "@mearl/cloud-server", | ||
| "version": "2.0.2", | ||
| "version": "2.1.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.0.2", | ||
| "@mearl/cloud-types": "2.0.2", | ||
| "@mearl/daemon-core": "2.0.2" | ||
| "@mearl/client": "2.1.0", | ||
| "@mearl/cloud-types": "2.1.0", | ||
| "@mearl/daemon-core": "2.1.0" | ||
| }, | ||
@@ -37,0 +37,0 @@ "devDependencies": { |
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
46700
4.47%1157
3.21%+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
Updated
Updated
Updated