@mearl/cloud-server
Advanced tools
| // src/index.ts | ||
| import WebSocket, { WebSocketServer } from "ws"; | ||
| import { createServer as createHttpServer } from "node:http"; | ||
| import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; | ||
| import { randomBytes } from "node:crypto"; | ||
| import { networkInterfaces } from "node:os"; | ||
| // src/paths.ts | ||
| import { join } from "node:path"; | ||
| import { DAEMON_HOME } from "@mearl/daemon-core"; | ||
| var CONFIG_DIR = DAEMON_HOME; | ||
| var CONFIG_FILE = join(CONFIG_DIR, "cloud-server.json"); | ||
| var MODE_FILE = join(CONFIG_DIR, "cloud-server-mode.json"); | ||
| var LOG_FILE = join(CONFIG_DIR, "cloud-server.log"); | ||
| // src/index.ts | ||
| import { | ||
| isAgentHelloMessage, | ||
| isConnectorHelloMessage, | ||
| isHeartbeatMessage, | ||
| isCloudMessage, | ||
| isCloudResponse, | ||
| resolveCloudConnectorSelector, | ||
| MAX_BUFFER_SIZE, | ||
| DEFAULT_HEARTBEAT_TIMEOUT, | ||
| DEFAULT_REQUEST_TIMEOUT | ||
| } from "@mearl/cloud-types"; | ||
| import { resolveActionTimeoutSec } from "@mearl/client/action-timeouts"; | ||
| var DEFAULT_PORT = 8080; | ||
| var DEFAULT_PATH = "/ws"; | ||
| var DEFAULT_MAX_CONNECTIONS = 100; | ||
| var DEFAULT_MAX_MESSAGE_SIZE = 10 * 1024 * 1024; | ||
| var RESPONSE_GRACE_MS = 5e3; | ||
| var LEGACY_CONNECTOR_HELLO_GRACE_MS = 1e3; | ||
| var CloudServer = class { | ||
| options; | ||
| connectorWss = null; | ||
| agentWss = null; | ||
| connectorHttpServer = null; | ||
| agentHttpServer = null; | ||
| connections = /* @__PURE__ */ new Map(); | ||
| pendingRequests = /* @__PURE__ */ new Map(); | ||
| heartbeatTimer = null; | ||
| constructor(options = {}) { | ||
| const port = options.port ?? DEFAULT_PORT; | ||
| this.options = { | ||
| port, | ||
| agentPort: options.agentPort ?? port + 1, | ||
| path: options.path ?? DEFAULT_PATH, | ||
| token: options.token, | ||
| heartbeatTimeout: options.heartbeatTimeout ?? DEFAULT_HEARTBEAT_TIMEOUT, | ||
| maxConnections: options.maxConnections ?? DEFAULT_MAX_CONNECTIONS, | ||
| maxMessageSize: options.maxMessageSize ?? DEFAULT_MAX_MESSAGE_SIZE, | ||
| requestTimeout: options.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT, | ||
| allowRemoteAgent: options.allowRemoteAgent ?? false | ||
| }; | ||
| } | ||
| getToken() { | ||
| if (!this.options.token) { | ||
| this.options.token = this.generateRandomToken(); | ||
| } | ||
| return this.options.token; | ||
| } | ||
| generateRandomToken() { | ||
| 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 localWsUrl = `ws://localhost:${this.options.agentPort}${this.options.path}?token=${token}`; | ||
| let publicWsUrl; | ||
| const sandboxUrl = await this.getSandboxPublicUrl(this.options.port); | ||
| if (sandboxUrl) { | ||
| try { | ||
| const parsedUrl = new URL(sandboxUrl); | ||
| parsedUrl.protocol = "wss:"; | ||
| parsedUrl.pathname = this.options.path; | ||
| parsedUrl.search = `?token=${token}`; | ||
| publicWsUrl = parsedUrl.toString(); | ||
| } catch { | ||
| const httpsUrl = sandboxUrl.replace("https://", "wss://"); | ||
| publicWsUrl = `${httpsUrl}${this.options.path}?token=${token}`; | ||
| } | ||
| } else { | ||
| const publicHost = process.env.CLOUD_SERVER_HOST || this.getPublicIP(); | ||
| publicWsUrl = `ws://${publicHost}:${this.options.port}${this.options.path}?token=${token}`; | ||
| } | ||
| 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 start "${publicWsUrl}"`; | ||
| console.log( | ||
| `[CloudServer] Connector listener on 0.0.0.0:${this.options.port} (network); agent listener on 127.0.0.1:${this.options.agentPort} (local only)` | ||
| ); | ||
| console.log(`[CloudServer] Connect from local machine:`); | ||
| console.log(` ${connectorCommand}`); | ||
| this.startHeartbeatCheck(); | ||
| const info = { | ||
| pid: process.pid, | ||
| port: this.options.port, | ||
| path: this.options.path, | ||
| token, | ||
| localUrl: localWsUrl, | ||
| publicUrl: publicWsUrl, | ||
| connectorCommand, | ||
| startedAt: (/* @__PURE__ */ new Date()).toISOString() | ||
| }; | ||
| this.writeConfigFile(info); | ||
| return info; | ||
| } | ||
| /** | ||
| * Stand up one HTTP+WebSocket listener. `fromLocal` tags every connection | ||
| * accepted here so handleRequest can pin command-issuing authority to the | ||
| * loopback listener. | ||
| */ | ||
| createListener(port, host, fromLocal) { | ||
| return new Promise((resolve, reject) => { | ||
| const httpServer = createHttpServer((req, res) => { | ||
| console.log(`[CloudServer] HTTP request: ${req.method} ${req.url}`); | ||
| res.writeHead(404, { "Content-Type": "text/plain" }); | ||
| res.end("Not Found - This is a WebSocket server"); | ||
| }); | ||
| const wss = new WebSocketServer({ | ||
| server: httpServer, | ||
| path: this.options.path, | ||
| maxPayload: this.options.maxMessageSize | ||
| }); | ||
| wss.on("connection", (ws, req) => { | ||
| console.log( | ||
| `[CloudServer] New ${fromLocal ? "agent" : "connector"} connection from ${req.socket.remoteAddress}, path: ${req.url}` | ||
| ); | ||
| this.handleConnection(ws, req, fromLocal); | ||
| }); | ||
| wss.on("error", (error) => { | ||
| console.error("[CloudServer] WebSocket server error:", error.message); | ||
| }); | ||
| httpServer.on("error", (error) => { | ||
| console.error("[CloudServer] HTTP server error:", error.message); | ||
| reject(error); | ||
| }); | ||
| if (fromLocal) { | ||
| this.agentWss = wss; | ||
| this.agentHttpServer = httpServer; | ||
| } else { | ||
| this.connectorWss = wss; | ||
| this.connectorHttpServer = httpServer; | ||
| } | ||
| httpServer.listen(port, host, () => { | ||
| resolve(); | ||
| }); | ||
| }); | ||
| } | ||
| stop() { | ||
| return new Promise((resolve) => { | ||
| if (this.heartbeatTimer) { | ||
| clearInterval(this.heartbeatTimer); | ||
| this.heartbeatTimer = null; | ||
| } | ||
| for (const [ws] of this.connections) { | ||
| ws.close(); | ||
| } | ||
| this.connections.clear(); | ||
| for (const pending of this.pendingRequests.values()) { | ||
| clearTimeout(pending.timer); | ||
| } | ||
| this.pendingRequests.clear(); | ||
| this.agentWss?.close(); | ||
| this.connectorWss?.close(); | ||
| this.agentWss = null; | ||
| this.connectorWss = null; | ||
| const servers = [this.agentHttpServer, this.connectorHttpServer].filter( | ||
| (s) => s !== null | ||
| ); | ||
| this.agentHttpServer = null; | ||
| this.connectorHttpServer = null; | ||
| if (servers.length === 0) { | ||
| resolve(); | ||
| return; | ||
| } | ||
| let remaining = servers.length; | ||
| for (const s of servers) { | ||
| s.close(() => { | ||
| if (--remaining <= 0) resolve(); | ||
| }); | ||
| } | ||
| }); | ||
| } | ||
| handleConnection(ws, req, fromLocal) { | ||
| if (this.connections.size >= this.options.maxConnections) { | ||
| ws.close(1008, "Server is full"); | ||
| return; | ||
| } | ||
| if (this.options.token) { | ||
| const url = new URL(req.url || "", `http://${req.headers.host}`); | ||
| const token = url.searchParams.get("token"); | ||
| if (token !== this.options.token) { | ||
| ws.close(1008, "Unauthorized"); | ||
| return; | ||
| } | ||
| } | ||
| const connId = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; | ||
| const connection = { | ||
| ws, | ||
| id: connId, | ||
| lastHeartbeat: Date.now(), | ||
| connectedAt: Date.now(), | ||
| role: fromLocal ? "agent" : "pending", | ||
| fromLocal, | ||
| messageBuffer: "" | ||
| }; | ||
| this.connections.set(ws, connection); | ||
| console.log(`[CloudServer] Client connected: ${connId} (total: ${this.connections.size})`); | ||
| ws.on("message", (data) => { | ||
| const conn = this.connections.get(ws); | ||
| if (!conn) return; | ||
| conn.messageBuffer += data.toString("utf-8"); | ||
| if (conn.messageBuffer.length > MAX_BUFFER_SIZE) { | ||
| console.error( | ||
| `[CloudServer] Message buffer exceeds limit for ${connId}, closing connection` | ||
| ); | ||
| ws.close(1008, "Message too large"); | ||
| return; | ||
| } | ||
| let newlineIndex; | ||
| while ((newlineIndex = conn.messageBuffer.indexOf("\n")) !== -1) { | ||
| const line = conn.messageBuffer.slice(0, newlineIndex); | ||
| conn.messageBuffer = conn.messageBuffer.slice(newlineIndex + 1); | ||
| if (!line.trim()) continue; | ||
| try { | ||
| const message = JSON.parse(line); | ||
| this.handleMessage(ws, message); | ||
| } catch (error) { | ||
| console.error(`[CloudServer] Failed to parse message from ${connId}:`, line, error); | ||
| } | ||
| } | ||
| }); | ||
| ws.on("close", () => { | ||
| this.connections.delete(ws); | ||
| this.rejectPendingForWs(ws); | ||
| console.log(`[CloudServer] Client disconnected: ${connId} (total: ${this.connections.size})`); | ||
| }); | ||
| ws.on("error", (error) => { | ||
| console.error(`[CloudServer] Connection error for ${connId}:`, error.message); | ||
| }); | ||
| } | ||
| handleMessage(ws, message) { | ||
| const conn = this.connections.get(ws); | ||
| if (!conn) return; | ||
| if (isHeartbeatMessage(message)) { | ||
| if (message.type === "ping") { | ||
| ws.send( | ||
| JSON.stringify({ type: "pong", timestamp: Date.now() }) + "\n" | ||
| ); | ||
| conn.lastHeartbeat = Date.now(); | ||
| } else if (message.type === "pong") { | ||
| conn.lastHeartbeat = Date.now(); | ||
| } | ||
| return; | ||
| } | ||
| if (isAgentHelloMessage(message)) { | ||
| this.handleAgentHello(ws); | ||
| return; | ||
| } | ||
| if (isConnectorHelloMessage(message)) { | ||
| this.handleConnectorHello(ws, message); | ||
| return; | ||
| } | ||
| if (isCloudResponse(message)) { | ||
| this.handleResponse(ws, message); | ||
| return; | ||
| } | ||
| if (isCloudMessage(message)) { | ||
| this.handleRequest(ws, message); | ||
| return; | ||
| } | ||
| } | ||
| 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.role !== "agent") { | ||
| console.warn( | ||
| `[CloudServer] Rejected agent request from non-local connection ${conn.id} (action: ${action})` | ||
| ); | ||
| ws.send( | ||
| JSON.stringify({ | ||
| id, | ||
| success: false, | ||
| 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 @mearl/client on the same machine as cloud-server, or start the server with --allow-remote-agent to override." | ||
| }) + "\n" | ||
| ); | ||
| return; | ||
| } | ||
| if (action === "connector_list") { | ||
| const response = { | ||
| id, | ||
| success: true, | ||
| data: this.getConnectorList() | ||
| }; | ||
| ws.send(JSON.stringify(response) + "\n"); | ||
| return; | ||
| } | ||
| 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); | ||
| const timeoutSec = Number.isFinite(requestedTimeoutSec) && requestedTimeoutSec > 0 ? Math.min(requestedTimeoutSec, 3600) : resolveActionTimeoutSec(action, data, this.options.requestTimeout); | ||
| const timer = setTimeout( | ||
| () => { | ||
| this.pendingRequests.delete(routeId); | ||
| if (ws.readyState === WebSocket.OPEN) { | ||
| ws.send(JSON.stringify({ id, success: false, error: "Request timeout" }) + "\n"); | ||
| } | ||
| }, | ||
| timeoutSec * 1e3 + RESPONSE_GRACE_MS | ||
| ); | ||
| 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) { | ||
| const conn = this.connections.get(ws); | ||
| if (!conn) return; | ||
| if (conn.role !== "connector") return; | ||
| const pending = this.pendingRequests.get(response.id); | ||
| if (!pending || pending.connectorWs !== ws) return; | ||
| this.pendingRequests.delete(response.id); | ||
| clearTimeout(pending.timer); | ||
| if (pending.agentWs.readyState === WebSocket.OPEN) { | ||
| pending.agentWs.send( | ||
| JSON.stringify({ ...response, id: pending.agentRequestId }) + "\n" | ||
| ); | ||
| } | ||
| } | ||
| 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.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 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" }; | ||
| const available = connectors.map(({ info }) => `${info.name} [${info.connectorId}]`).join(", "); | ||
| if (!selector) { | ||
| if (connectors.length === 1) { | ||
| return { ws: connectors[0].ws, info: connectors[0].info }; | ||
| } | ||
| return { | ||
| error: `Multiple cloud connectors are connected (${connectors.length}). Pass --connector <id|name>. Available: ${available}` | ||
| }; | ||
| } | ||
| 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 }; | ||
| } | ||
| 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) { | ||
| this.pendingRequests.forEach((pending, 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: pending.agentRequestId, | ||
| success: false, | ||
| error: "Connector connection closed" | ||
| }) + "\n" | ||
| ); | ||
| } | ||
| }); | ||
| } | ||
| startHeartbeatCheck() { | ||
| if (this.heartbeatTimer) return; | ||
| this.heartbeatTimer = setInterval(() => { | ||
| const now = Date.now(); | ||
| const timeoutMs = this.options.heartbeatTimeout * 1e3; | ||
| for (const [ws, conn] of this.connections) { | ||
| if (now - conn.lastHeartbeat > timeoutMs) { | ||
| console.log(`[CloudServer] Client ${conn.id} heartbeat timeout, closing connection`); | ||
| ws.close(1e3, "Heartbeat timeout"); | ||
| } | ||
| } | ||
| }, 3e4); | ||
| } | ||
| getPublicIP() { | ||
| const interfaces = networkInterfaces(); | ||
| for (const name of Object.keys(interfaces)) { | ||
| const iface = interfaces[name]; | ||
| if (!iface) continue; | ||
| for (const addr of iface) { | ||
| if (addr.family === "IPv4" && !addr.internal) { | ||
| return addr.address; | ||
| } | ||
| } | ||
| } | ||
| return "localhost"; | ||
| } | ||
| async getSandboxPublicUrl(port) { | ||
| if (!process.env.AONE_SANDBOX_ID) { | ||
| return null; | ||
| } | ||
| const mappingApiUrl = "http://localhost:58596/api/port-mapping"; | ||
| try { | ||
| const response = await fetch(`${mappingApiUrl}?port=${port}`); | ||
| if (!response.ok) { | ||
| return null; | ||
| } | ||
| const data = await response.json(); | ||
| if (data.success && data.url) { | ||
| return data.url; | ||
| } | ||
| } catch (error) { | ||
| console.error( | ||
| `[CloudServer] Failed to query sandbox port mapping:`, | ||
| error instanceof Error ? error.message : String(error) | ||
| ); | ||
| } | ||
| return null; | ||
| } | ||
| writeConfigFile(info) { | ||
| try { | ||
| if (!existsSync(CONFIG_DIR)) { | ||
| mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 }); | ||
| } | ||
| const config = { server: info.localUrl, ...info }; | ||
| 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}`); | ||
| } catch (error) { | ||
| console.error("[CloudServer] Failed to write config file:", error); | ||
| } | ||
| } | ||
| getStats() { | ||
| let agents = 0; | ||
| for (const conn of this.connections.values()) { | ||
| if (conn.role === "agent") agents++; | ||
| } | ||
| return { | ||
| connections: this.connections.size, | ||
| agents, | ||
| connectors: this.getConnectorConnections().length, | ||
| pendingRequests: this.pendingRequests.size | ||
| }; | ||
| } | ||
| }; | ||
| export { | ||
| CONFIG_FILE, | ||
| MODE_FILE, | ||
| LOG_FILE, | ||
| CloudServer | ||
| }; |
+12
-562
| #!/usr/bin/env node | ||
| import { | ||
| CONFIG_FILE, | ||
| CloudServer, | ||
| LOG_FILE, | ||
| MODE_FILE | ||
| } from "./chunks/chunk-DCTZIDJ7.js"; | ||
@@ -6,558 +12,2 @@ // src/daemon.ts | ||
| import { createDaemon, readRecord, removeRecord } from "@mearl/daemon-core"; | ||
| // src/index.ts | ||
| import WebSocket, { WebSocketServer } from "ws"; | ||
| import { createServer as createHttpServer } from "node:http"; | ||
| import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; | ||
| import { randomBytes } from "node:crypto"; | ||
| import { networkInterfaces } from "node:os"; | ||
| // src/paths.ts | ||
| import { join } from "node:path"; | ||
| import { DAEMON_HOME } from "@mearl/daemon-core"; | ||
| var CONFIG_DIR = DAEMON_HOME; | ||
| var CONFIG_FILE = join(CONFIG_DIR, "cloud-server.json"); | ||
| var MODE_FILE = join(CONFIG_DIR, "cloud-server-mode.json"); | ||
| var LOG_FILE = join(CONFIG_DIR, "cloud-server.log"); | ||
| // src/index.ts | ||
| import { | ||
| isAgentHelloMessage, | ||
| isConnectorHelloMessage, | ||
| isHeartbeatMessage, | ||
| isCloudMessage, | ||
| isCloudResponse, | ||
| resolveCloudConnectorSelector, | ||
| MAX_BUFFER_SIZE, | ||
| DEFAULT_HEARTBEAT_TIMEOUT, | ||
| DEFAULT_REQUEST_TIMEOUT | ||
| } from "@mearl/cloud-types"; | ||
| import { resolveActionTimeoutSec } from "@mearl/client/action-timeouts"; | ||
| var fetchInstance = null; | ||
| async function getFetch() { | ||
| if (fetchInstance) { | ||
| return fetchInstance; | ||
| } | ||
| if (typeof globalThis.fetch !== "undefined") { | ||
| fetchInstance = globalThis.fetch; | ||
| } else { | ||
| const { default: fetch } = await import("node-fetch"); | ||
| fetchInstance = fetch; | ||
| } | ||
| return fetchInstance; | ||
| } | ||
| var DEFAULT_PORT = 8080; | ||
| var DEFAULT_PATH = "/ws"; | ||
| var DEFAULT_MAX_CONNECTIONS = 100; | ||
| var DEFAULT_MAX_MESSAGE_SIZE = 10 * 1024 * 1024; | ||
| var RESPONSE_GRACE_MS = 5e3; | ||
| var LEGACY_CONNECTOR_HELLO_GRACE_MS = 1e3; | ||
| var CloudServer = class { | ||
| options; | ||
| connectorWss = null; | ||
| agentWss = null; | ||
| connectorHttpServer = null; | ||
| agentHttpServer = null; | ||
| connections = /* @__PURE__ */ new Map(); | ||
| pendingRequests = /* @__PURE__ */ new Map(); | ||
| heartbeatTimer = null; | ||
| constructor(options = {}) { | ||
| const port = options.port ?? DEFAULT_PORT; | ||
| this.options = { | ||
| port, | ||
| agentPort: options.agentPort ?? port + 1, | ||
| path: options.path ?? DEFAULT_PATH, | ||
| token: options.token, | ||
| heartbeatTimeout: options.heartbeatTimeout ?? DEFAULT_HEARTBEAT_TIMEOUT, | ||
| maxConnections: options.maxConnections ?? DEFAULT_MAX_CONNECTIONS, | ||
| maxMessageSize: options.maxMessageSize ?? DEFAULT_MAX_MESSAGE_SIZE, | ||
| requestTimeout: options.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT, | ||
| allowRemoteAgent: options.allowRemoteAgent ?? false | ||
| }; | ||
| } | ||
| getToken() { | ||
| if (!this.options.token) { | ||
| this.options.token = this.generateRandomToken(); | ||
| } | ||
| return this.options.token; | ||
| } | ||
| generateRandomToken() { | ||
| 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 localWsUrl = `ws://localhost:${this.options.agentPort}${this.options.path}?token=${token}`; | ||
| let publicWsUrl; | ||
| const sandboxUrl = await this.getSandboxPublicUrl(this.options.port); | ||
| if (sandboxUrl) { | ||
| try { | ||
| const parsedUrl = new URL(sandboxUrl); | ||
| parsedUrl.protocol = "wss:"; | ||
| parsedUrl.pathname = this.options.path; | ||
| parsedUrl.search = `?token=${token}`; | ||
| publicWsUrl = parsedUrl.toString(); | ||
| } catch { | ||
| const httpsUrl = sandboxUrl.replace("https://", "wss://"); | ||
| publicWsUrl = `${httpsUrl}${this.options.path}?token=${token}`; | ||
| } | ||
| } else { | ||
| const publicHost = process.env.CLOUD_SERVER_HOST || this.getPublicIP(); | ||
| publicWsUrl = `ws://${publicHost}:${this.options.port}${this.options.path}?token=${token}`; | ||
| } | ||
| 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 start "${publicWsUrl}"`; | ||
| console.log( | ||
| `[CloudServer] Connector listener on 0.0.0.0:${this.options.port} (network); agent listener on 127.0.0.1:${this.options.agentPort} (local only)` | ||
| ); | ||
| console.log(`[CloudServer] Connect from local machine:`); | ||
| console.log(` ${connectorCommand}`); | ||
| this.startHeartbeatCheck(); | ||
| const info = { | ||
| pid: process.pid, | ||
| port: this.options.port, | ||
| path: this.options.path, | ||
| token, | ||
| localUrl: localWsUrl, | ||
| publicUrl: publicWsUrl, | ||
| connectorCommand, | ||
| startedAt: (/* @__PURE__ */ new Date()).toISOString() | ||
| }; | ||
| this.writeConfigFile(info); | ||
| return info; | ||
| } | ||
| /** | ||
| * Stand up one HTTP+WebSocket listener. `fromLocal` tags every connection | ||
| * accepted here so handleRequest can pin command-issuing authority to the | ||
| * loopback listener. | ||
| */ | ||
| createListener(port, host, fromLocal) { | ||
| return new Promise((resolve, reject) => { | ||
| const httpServer = createHttpServer((req, res) => { | ||
| console.log(`[CloudServer] HTTP request: ${req.method} ${req.url}`); | ||
| res.writeHead(404, { "Content-Type": "text/plain" }); | ||
| res.end("Not Found - This is a WebSocket server"); | ||
| }); | ||
| const wss = new WebSocketServer({ | ||
| server: httpServer, | ||
| path: this.options.path, | ||
| maxPayload: this.options.maxMessageSize | ||
| }); | ||
| wss.on("connection", (ws, req) => { | ||
| console.log( | ||
| `[CloudServer] New ${fromLocal ? "agent" : "connector"} connection from ${req.socket.remoteAddress}, path: ${req.url}` | ||
| ); | ||
| this.handleConnection(ws, req, fromLocal); | ||
| }); | ||
| wss.on("error", (error) => { | ||
| console.error("[CloudServer] WebSocket server error:", error.message); | ||
| }); | ||
| httpServer.on("error", (error) => { | ||
| console.error("[CloudServer] HTTP server error:", error.message); | ||
| reject(error); | ||
| }); | ||
| if (fromLocal) { | ||
| this.agentWss = wss; | ||
| this.agentHttpServer = httpServer; | ||
| } else { | ||
| this.connectorWss = wss; | ||
| this.connectorHttpServer = httpServer; | ||
| } | ||
| httpServer.listen(port, host, () => { | ||
| resolve(); | ||
| }); | ||
| }); | ||
| } | ||
| stop() { | ||
| return new Promise((resolve) => { | ||
| if (this.heartbeatTimer) { | ||
| clearInterval(this.heartbeatTimer); | ||
| this.heartbeatTimer = null; | ||
| } | ||
| for (const [ws] of this.connections) { | ||
| ws.close(); | ||
| } | ||
| this.connections.clear(); | ||
| for (const pending of this.pendingRequests.values()) { | ||
| clearTimeout(pending.timer); | ||
| } | ||
| this.pendingRequests.clear(); | ||
| this.agentWss?.close(); | ||
| this.connectorWss?.close(); | ||
| this.agentWss = null; | ||
| this.connectorWss = null; | ||
| const servers = [this.agentHttpServer, this.connectorHttpServer].filter( | ||
| (s) => s !== null | ||
| ); | ||
| this.agentHttpServer = null; | ||
| this.connectorHttpServer = null; | ||
| if (servers.length === 0) { | ||
| resolve(); | ||
| return; | ||
| } | ||
| let remaining = servers.length; | ||
| for (const s of servers) { | ||
| s.close(() => { | ||
| if (--remaining <= 0) resolve(); | ||
| }); | ||
| } | ||
| }); | ||
| } | ||
| handleConnection(ws, req, fromLocal) { | ||
| if (this.connections.size >= this.options.maxConnections) { | ||
| ws.close(1008, "Server is full"); | ||
| return; | ||
| } | ||
| if (this.options.token) { | ||
| const url = new URL(req.url || "", `http://${req.headers.host}`); | ||
| const token = url.searchParams.get("token"); | ||
| if (token !== this.options.token) { | ||
| ws.close(1008, "Unauthorized"); | ||
| return; | ||
| } | ||
| } | ||
| const connId = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; | ||
| const connection = { | ||
| ws, | ||
| id: connId, | ||
| lastHeartbeat: Date.now(), | ||
| connectedAt: Date.now(), | ||
| role: fromLocal ? "agent" : "pending", | ||
| fromLocal, | ||
| messageBuffer: "" | ||
| }; | ||
| this.connections.set(ws, connection); | ||
| console.log(`[CloudServer] Client connected: ${connId} (total: ${this.connections.size})`); | ||
| ws.on("message", (data) => { | ||
| const conn = this.connections.get(ws); | ||
| if (!conn) return; | ||
| conn.messageBuffer += data.toString("utf-8"); | ||
| if (conn.messageBuffer.length > MAX_BUFFER_SIZE) { | ||
| console.error( | ||
| `[CloudServer] Message buffer exceeds limit for ${connId}, closing connection` | ||
| ); | ||
| ws.close(1008, "Message too large"); | ||
| return; | ||
| } | ||
| let newlineIndex; | ||
| while ((newlineIndex = conn.messageBuffer.indexOf("\n")) !== -1) { | ||
| const line = conn.messageBuffer.slice(0, newlineIndex); | ||
| conn.messageBuffer = conn.messageBuffer.slice(newlineIndex + 1); | ||
| if (!line.trim()) continue; | ||
| try { | ||
| const message = JSON.parse(line); | ||
| this.handleMessage(ws, message); | ||
| } catch (error) { | ||
| console.error(`[CloudServer] Failed to parse message from ${connId}:`, line, error); | ||
| } | ||
| } | ||
| }); | ||
| ws.on("close", () => { | ||
| this.connections.delete(ws); | ||
| this.rejectPendingForWs(ws); | ||
| console.log(`[CloudServer] Client disconnected: ${connId} (total: ${this.connections.size})`); | ||
| }); | ||
| ws.on("error", (error) => { | ||
| console.error(`[CloudServer] Connection error for ${connId}:`, error.message); | ||
| }); | ||
| } | ||
| handleMessage(ws, message) { | ||
| const conn = this.connections.get(ws); | ||
| if (!conn) return; | ||
| if (isHeartbeatMessage(message)) { | ||
| if (message.type === "ping") { | ||
| ws.send( | ||
| JSON.stringify({ type: "pong", timestamp: Date.now() }) + "\n" | ||
| ); | ||
| conn.lastHeartbeat = Date.now(); | ||
| } else if (message.type === "pong") { | ||
| conn.lastHeartbeat = Date.now(); | ||
| } | ||
| return; | ||
| } | ||
| if (isAgentHelloMessage(message)) { | ||
| this.handleAgentHello(ws); | ||
| return; | ||
| } | ||
| if (isConnectorHelloMessage(message)) { | ||
| this.handleConnectorHello(ws, message); | ||
| return; | ||
| } | ||
| if (isCloudResponse(message)) { | ||
| this.handleResponse(ws, message); | ||
| return; | ||
| } | ||
| if (isCloudMessage(message)) { | ||
| this.handleRequest(ws, message); | ||
| return; | ||
| } | ||
| } | ||
| 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.role !== "agent") { | ||
| console.warn( | ||
| `[CloudServer] Rejected agent request from non-local connection ${conn.id} (action: ${action})` | ||
| ); | ||
| ws.send( | ||
| JSON.stringify({ | ||
| id, | ||
| success: false, | ||
| 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 @mearl/client on the same machine as cloud-server, or start the server with --allow-remote-agent to override." | ||
| }) + "\n" | ||
| ); | ||
| return; | ||
| } | ||
| if (action === "connector_list") { | ||
| const response = { | ||
| id, | ||
| success: true, | ||
| data: this.getConnectorList() | ||
| }; | ||
| ws.send(JSON.stringify(response) + "\n"); | ||
| return; | ||
| } | ||
| 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); | ||
| const timeoutSec = Number.isFinite(requestedTimeoutSec) && requestedTimeoutSec > 0 ? Math.min(requestedTimeoutSec, 3600) : resolveActionTimeoutSec(action, data, this.options.requestTimeout); | ||
| const timer = setTimeout( | ||
| () => { | ||
| this.pendingRequests.delete(routeId); | ||
| if (ws.readyState === WebSocket.OPEN) { | ||
| ws.send(JSON.stringify({ id, success: false, error: "Request timeout" }) + "\n"); | ||
| } | ||
| }, | ||
| timeoutSec * 1e3 + RESPONSE_GRACE_MS | ||
| ); | ||
| 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) { | ||
| const conn = this.connections.get(ws); | ||
| if (!conn) return; | ||
| if (conn.role !== "connector") return; | ||
| const pending = this.pendingRequests.get(response.id); | ||
| if (!pending || pending.connectorWs !== ws) return; | ||
| this.pendingRequests.delete(response.id); | ||
| clearTimeout(pending.timer); | ||
| if (pending.agentWs.readyState === WebSocket.OPEN) { | ||
| pending.agentWs.send( | ||
| JSON.stringify({ ...response, id: pending.agentRequestId }) + "\n" | ||
| ); | ||
| } | ||
| } | ||
| 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.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 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" }; | ||
| const available = connectors.map(({ info }) => `${info.name} [${info.connectorId}]`).join(", "); | ||
| if (!selector) { | ||
| if (connectors.length === 1) { | ||
| return { ws: connectors[0].ws, info: connectors[0].info }; | ||
| } | ||
| return { | ||
| error: `Multiple cloud connectors are connected (${connectors.length}). Pass --connector <id|name>. Available: ${available}` | ||
| }; | ||
| } | ||
| 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 }; | ||
| } | ||
| 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) { | ||
| this.pendingRequests.forEach((pending, 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: pending.agentRequestId, | ||
| success: false, | ||
| error: "Connector connection closed" | ||
| }) + "\n" | ||
| ); | ||
| } | ||
| }); | ||
| } | ||
| startHeartbeatCheck() { | ||
| if (this.heartbeatTimer) return; | ||
| this.heartbeatTimer = setInterval(() => { | ||
| const now = Date.now(); | ||
| const timeoutMs = this.options.heartbeatTimeout * 1e3; | ||
| for (const [ws, conn] of this.connections) { | ||
| if (now - conn.lastHeartbeat > timeoutMs) { | ||
| console.log(`[CloudServer] Client ${conn.id} heartbeat timeout, closing connection`); | ||
| ws.close(1e3, "Heartbeat timeout"); | ||
| } | ||
| } | ||
| }, 3e4); | ||
| } | ||
| getPublicIP() { | ||
| const interfaces = networkInterfaces(); | ||
| for (const name of Object.keys(interfaces)) { | ||
| const iface = interfaces[name]; | ||
| if (!iface) continue; | ||
| for (const addr of iface) { | ||
| if (addr.family === "IPv4" && !addr.internal) { | ||
| return addr.address; | ||
| } | ||
| } | ||
| } | ||
| return "localhost"; | ||
| } | ||
| async getSandboxPublicUrl(port) { | ||
| if (!process.env.AONE_SANDBOX_ID) { | ||
| return null; | ||
| } | ||
| const mappingApiUrl = "http://localhost:58596/api/port-mapping"; | ||
| try { | ||
| const fetch = await getFetch(); | ||
| const response = await fetch(`${mappingApiUrl}?port=${port}`); | ||
| if (!response.ok) { | ||
| return null; | ||
| } | ||
| const data = await response.json(); | ||
| if (data.success && data.url) { | ||
| return data.url; | ||
| } | ||
| } catch (error) { | ||
| console.error( | ||
| `[CloudServer] Failed to query sandbox port mapping:`, | ||
| error instanceof Error ? error.message : String(error) | ||
| ); | ||
| } | ||
| return null; | ||
| } | ||
| writeConfigFile(info) { | ||
| try { | ||
| if (!existsSync(CONFIG_DIR)) { | ||
| mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 }); | ||
| } | ||
| const config = { server: info.localUrl, ...info }; | ||
| 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}`); | ||
| } catch (error) { | ||
| console.error("[CloudServer] Failed to write config file:", error); | ||
| } | ||
| } | ||
| getStats() { | ||
| let agents = 0; | ||
| for (const conn of this.connections.values()) { | ||
| if (conn.role === "agent") agents++; | ||
| } | ||
| return { | ||
| connections: this.connections.size, | ||
| agents, | ||
| connectors: this.getConnectorConnections().length, | ||
| pendingRequests: this.pendingRequests.size | ||
| }; | ||
| } | ||
| }; | ||
| // src/daemon.ts | ||
| var SCRIPT_PATH = fileURLToPath(import.meta.url); | ||
@@ -628,3 +78,3 @@ function formatConnectInfo(record) { | ||
| // src/mode.ts | ||
| import { mkdirSync as mkdirSync2, readFileSync, rmSync, writeFileSync as writeFileSync2 } from "node:fs"; | ||
| import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | ||
| import { dirname } from "node:path"; | ||
@@ -648,4 +98,4 @@ var CLOUD_SERVER_MODE_ENV = "MEARL_CLOUD_MODE"; | ||
| const writeText = storage.writeText ?? ((filePath, value) => { | ||
| mkdirSync2(dirname(filePath), { recursive: true }); | ||
| writeFileSync2(filePath, value, { encoding: "utf8", mode: 384 }); | ||
| mkdirSync(dirname(filePath), { recursive: true }); | ||
| writeFileSync(filePath, value, { encoding: "utf8", mode: 384 }); | ||
| }); | ||
@@ -674,5 +124,5 @@ writeText(modeFile, `${JSON.stringify({ mode })} | ||
| // src/qoder.ts | ||
| import { randomBytes as randomBytes2 } from "node:crypto"; | ||
| import { randomBytes } from "node:crypto"; | ||
| function createQoderPairingInfo(options = {}) { | ||
| const pairingCode = (options.createCode ?? (() => randomBytes2(24).toString("base64url")))(); | ||
| const pairingCode = (options.createCode ?? (() => randomBytes(24).toString("base64url")))(); | ||
| if (!/^[A-Za-z0-9_-]{16,128}$/.test(pairingCode)) { | ||
@@ -730,3 +180,3 @@ throw new Error("Qoder pairing code must be 16-128 URL-safe characters"); | ||
| // src/cli.ts | ||
| var CLOUD_SERVER_VERSION = true ? "2.7.2" : "unknown"; | ||
| var CLOUD_SERVER_VERSION = true ? "2.7.3" : "unknown"; | ||
| var argv = process.argv.slice(2); | ||
@@ -733,0 +183,0 @@ if (argv.includes("--version") || argv.includes("-v")) { |
+2
-552
@@ -1,556 +0,6 @@ | ||
| // src/index.ts | ||
| import WebSocket, { WebSocketServer } from "ws"; | ||
| import { createServer as createHttpServer } from "node:http"; | ||
| import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; | ||
| import { randomBytes } from "node:crypto"; | ||
| import { networkInterfaces } from "node:os"; | ||
| // src/paths.ts | ||
| import { join } from "node:path"; | ||
| import { DAEMON_HOME } from "@mearl/daemon-core"; | ||
| var CONFIG_DIR = DAEMON_HOME; | ||
| var CONFIG_FILE = join(CONFIG_DIR, "cloud-server.json"); | ||
| var MODE_FILE = join(CONFIG_DIR, "cloud-server-mode.json"); | ||
| var LOG_FILE = join(CONFIG_DIR, "cloud-server.log"); | ||
| // src/index.ts | ||
| import { | ||
| isAgentHelloMessage, | ||
| isConnectorHelloMessage, | ||
| isHeartbeatMessage, | ||
| isCloudMessage, | ||
| isCloudResponse, | ||
| resolveCloudConnectorSelector, | ||
| MAX_BUFFER_SIZE, | ||
| DEFAULT_HEARTBEAT_TIMEOUT, | ||
| DEFAULT_REQUEST_TIMEOUT | ||
| } from "@mearl/cloud-types"; | ||
| import { resolveActionTimeoutSec } from "@mearl/client/action-timeouts"; | ||
| var fetchInstance = null; | ||
| async function getFetch() { | ||
| if (fetchInstance) { | ||
| return fetchInstance; | ||
| } | ||
| if (typeof globalThis.fetch !== "undefined") { | ||
| fetchInstance = globalThis.fetch; | ||
| } else { | ||
| const { default: fetch } = await import("node-fetch"); | ||
| fetchInstance = fetch; | ||
| } | ||
| return fetchInstance; | ||
| } | ||
| var DEFAULT_PORT = 8080; | ||
| var DEFAULT_PATH = "/ws"; | ||
| var DEFAULT_MAX_CONNECTIONS = 100; | ||
| var DEFAULT_MAX_MESSAGE_SIZE = 10 * 1024 * 1024; | ||
| var RESPONSE_GRACE_MS = 5e3; | ||
| var LEGACY_CONNECTOR_HELLO_GRACE_MS = 1e3; | ||
| var CloudServer = class { | ||
| options; | ||
| connectorWss = null; | ||
| agentWss = null; | ||
| connectorHttpServer = null; | ||
| agentHttpServer = null; | ||
| connections = /* @__PURE__ */ new Map(); | ||
| pendingRequests = /* @__PURE__ */ new Map(); | ||
| heartbeatTimer = null; | ||
| constructor(options = {}) { | ||
| const port = options.port ?? DEFAULT_PORT; | ||
| this.options = { | ||
| port, | ||
| agentPort: options.agentPort ?? port + 1, | ||
| path: options.path ?? DEFAULT_PATH, | ||
| token: options.token, | ||
| heartbeatTimeout: options.heartbeatTimeout ?? DEFAULT_HEARTBEAT_TIMEOUT, | ||
| maxConnections: options.maxConnections ?? DEFAULT_MAX_CONNECTIONS, | ||
| maxMessageSize: options.maxMessageSize ?? DEFAULT_MAX_MESSAGE_SIZE, | ||
| requestTimeout: options.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT, | ||
| allowRemoteAgent: options.allowRemoteAgent ?? false | ||
| }; | ||
| } | ||
| getToken() { | ||
| if (!this.options.token) { | ||
| this.options.token = this.generateRandomToken(); | ||
| } | ||
| return this.options.token; | ||
| } | ||
| generateRandomToken() { | ||
| 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 localWsUrl = `ws://localhost:${this.options.agentPort}${this.options.path}?token=${token}`; | ||
| let publicWsUrl; | ||
| const sandboxUrl = await this.getSandboxPublicUrl(this.options.port); | ||
| if (sandboxUrl) { | ||
| try { | ||
| const parsedUrl = new URL(sandboxUrl); | ||
| parsedUrl.protocol = "wss:"; | ||
| parsedUrl.pathname = this.options.path; | ||
| parsedUrl.search = `?token=${token}`; | ||
| publicWsUrl = parsedUrl.toString(); | ||
| } catch { | ||
| const httpsUrl = sandboxUrl.replace("https://", "wss://"); | ||
| publicWsUrl = `${httpsUrl}${this.options.path}?token=${token}`; | ||
| } | ||
| } else { | ||
| const publicHost = process.env.CLOUD_SERVER_HOST || this.getPublicIP(); | ||
| publicWsUrl = `ws://${publicHost}:${this.options.port}${this.options.path}?token=${token}`; | ||
| } | ||
| 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 start "${publicWsUrl}"`; | ||
| console.log( | ||
| `[CloudServer] Connector listener on 0.0.0.0:${this.options.port} (network); agent listener on 127.0.0.1:${this.options.agentPort} (local only)` | ||
| ); | ||
| console.log(`[CloudServer] Connect from local machine:`); | ||
| console.log(` ${connectorCommand}`); | ||
| this.startHeartbeatCheck(); | ||
| const info = { | ||
| pid: process.pid, | ||
| port: this.options.port, | ||
| path: this.options.path, | ||
| token, | ||
| localUrl: localWsUrl, | ||
| publicUrl: publicWsUrl, | ||
| connectorCommand, | ||
| startedAt: (/* @__PURE__ */ new Date()).toISOString() | ||
| }; | ||
| this.writeConfigFile(info); | ||
| return info; | ||
| } | ||
| /** | ||
| * Stand up one HTTP+WebSocket listener. `fromLocal` tags every connection | ||
| * accepted here so handleRequest can pin command-issuing authority to the | ||
| * loopback listener. | ||
| */ | ||
| createListener(port, host, fromLocal) { | ||
| return new Promise((resolve, reject) => { | ||
| const httpServer = createHttpServer((req, res) => { | ||
| console.log(`[CloudServer] HTTP request: ${req.method} ${req.url}`); | ||
| res.writeHead(404, { "Content-Type": "text/plain" }); | ||
| res.end("Not Found - This is a WebSocket server"); | ||
| }); | ||
| const wss = new WebSocketServer({ | ||
| server: httpServer, | ||
| path: this.options.path, | ||
| maxPayload: this.options.maxMessageSize | ||
| }); | ||
| wss.on("connection", (ws, req) => { | ||
| console.log( | ||
| `[CloudServer] New ${fromLocal ? "agent" : "connector"} connection from ${req.socket.remoteAddress}, path: ${req.url}` | ||
| ); | ||
| this.handleConnection(ws, req, fromLocal); | ||
| }); | ||
| wss.on("error", (error) => { | ||
| console.error("[CloudServer] WebSocket server error:", error.message); | ||
| }); | ||
| httpServer.on("error", (error) => { | ||
| console.error("[CloudServer] HTTP server error:", error.message); | ||
| reject(error); | ||
| }); | ||
| if (fromLocal) { | ||
| this.agentWss = wss; | ||
| this.agentHttpServer = httpServer; | ||
| } else { | ||
| this.connectorWss = wss; | ||
| this.connectorHttpServer = httpServer; | ||
| } | ||
| httpServer.listen(port, host, () => { | ||
| resolve(); | ||
| }); | ||
| }); | ||
| } | ||
| stop() { | ||
| return new Promise((resolve) => { | ||
| if (this.heartbeatTimer) { | ||
| clearInterval(this.heartbeatTimer); | ||
| this.heartbeatTimer = null; | ||
| } | ||
| for (const [ws] of this.connections) { | ||
| ws.close(); | ||
| } | ||
| this.connections.clear(); | ||
| for (const pending of this.pendingRequests.values()) { | ||
| clearTimeout(pending.timer); | ||
| } | ||
| this.pendingRequests.clear(); | ||
| this.agentWss?.close(); | ||
| this.connectorWss?.close(); | ||
| this.agentWss = null; | ||
| this.connectorWss = null; | ||
| const servers = [this.agentHttpServer, this.connectorHttpServer].filter( | ||
| (s) => s !== null | ||
| ); | ||
| this.agentHttpServer = null; | ||
| this.connectorHttpServer = null; | ||
| if (servers.length === 0) { | ||
| resolve(); | ||
| return; | ||
| } | ||
| let remaining = servers.length; | ||
| for (const s of servers) { | ||
| s.close(() => { | ||
| if (--remaining <= 0) resolve(); | ||
| }); | ||
| } | ||
| }); | ||
| } | ||
| handleConnection(ws, req, fromLocal) { | ||
| if (this.connections.size >= this.options.maxConnections) { | ||
| ws.close(1008, "Server is full"); | ||
| return; | ||
| } | ||
| if (this.options.token) { | ||
| const url = new URL(req.url || "", `http://${req.headers.host}`); | ||
| const token = url.searchParams.get("token"); | ||
| if (token !== this.options.token) { | ||
| ws.close(1008, "Unauthorized"); | ||
| return; | ||
| } | ||
| } | ||
| const connId = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; | ||
| const connection = { | ||
| ws, | ||
| id: connId, | ||
| lastHeartbeat: Date.now(), | ||
| connectedAt: Date.now(), | ||
| role: fromLocal ? "agent" : "pending", | ||
| fromLocal, | ||
| messageBuffer: "" | ||
| }; | ||
| this.connections.set(ws, connection); | ||
| console.log(`[CloudServer] Client connected: ${connId} (total: ${this.connections.size})`); | ||
| ws.on("message", (data) => { | ||
| const conn = this.connections.get(ws); | ||
| if (!conn) return; | ||
| conn.messageBuffer += data.toString("utf-8"); | ||
| if (conn.messageBuffer.length > MAX_BUFFER_SIZE) { | ||
| console.error( | ||
| `[CloudServer] Message buffer exceeds limit for ${connId}, closing connection` | ||
| ); | ||
| ws.close(1008, "Message too large"); | ||
| return; | ||
| } | ||
| let newlineIndex; | ||
| while ((newlineIndex = conn.messageBuffer.indexOf("\n")) !== -1) { | ||
| const line = conn.messageBuffer.slice(0, newlineIndex); | ||
| conn.messageBuffer = conn.messageBuffer.slice(newlineIndex + 1); | ||
| if (!line.trim()) continue; | ||
| try { | ||
| const message = JSON.parse(line); | ||
| this.handleMessage(ws, message); | ||
| } catch (error) { | ||
| console.error(`[CloudServer] Failed to parse message from ${connId}:`, line, error); | ||
| } | ||
| } | ||
| }); | ||
| ws.on("close", () => { | ||
| this.connections.delete(ws); | ||
| this.rejectPendingForWs(ws); | ||
| console.log(`[CloudServer] Client disconnected: ${connId} (total: ${this.connections.size})`); | ||
| }); | ||
| ws.on("error", (error) => { | ||
| console.error(`[CloudServer] Connection error for ${connId}:`, error.message); | ||
| }); | ||
| } | ||
| handleMessage(ws, message) { | ||
| const conn = this.connections.get(ws); | ||
| if (!conn) return; | ||
| if (isHeartbeatMessage(message)) { | ||
| if (message.type === "ping") { | ||
| ws.send( | ||
| JSON.stringify({ type: "pong", timestamp: Date.now() }) + "\n" | ||
| ); | ||
| conn.lastHeartbeat = Date.now(); | ||
| } else if (message.type === "pong") { | ||
| conn.lastHeartbeat = Date.now(); | ||
| } | ||
| return; | ||
| } | ||
| if (isAgentHelloMessage(message)) { | ||
| this.handleAgentHello(ws); | ||
| return; | ||
| } | ||
| if (isConnectorHelloMessage(message)) { | ||
| this.handleConnectorHello(ws, message); | ||
| return; | ||
| } | ||
| if (isCloudResponse(message)) { | ||
| this.handleResponse(ws, message); | ||
| return; | ||
| } | ||
| if (isCloudMessage(message)) { | ||
| this.handleRequest(ws, message); | ||
| return; | ||
| } | ||
| } | ||
| 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.role !== "agent") { | ||
| console.warn( | ||
| `[CloudServer] Rejected agent request from non-local connection ${conn.id} (action: ${action})` | ||
| ); | ||
| ws.send( | ||
| JSON.stringify({ | ||
| id, | ||
| success: false, | ||
| 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 @mearl/client on the same machine as cloud-server, or start the server with --allow-remote-agent to override." | ||
| }) + "\n" | ||
| ); | ||
| return; | ||
| } | ||
| if (action === "connector_list") { | ||
| const response = { | ||
| id, | ||
| success: true, | ||
| data: this.getConnectorList() | ||
| }; | ||
| ws.send(JSON.stringify(response) + "\n"); | ||
| return; | ||
| } | ||
| 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); | ||
| const timeoutSec = Number.isFinite(requestedTimeoutSec) && requestedTimeoutSec > 0 ? Math.min(requestedTimeoutSec, 3600) : resolveActionTimeoutSec(action, data, this.options.requestTimeout); | ||
| const timer = setTimeout( | ||
| () => { | ||
| this.pendingRequests.delete(routeId); | ||
| if (ws.readyState === WebSocket.OPEN) { | ||
| ws.send(JSON.stringify({ id, success: false, error: "Request timeout" }) + "\n"); | ||
| } | ||
| }, | ||
| timeoutSec * 1e3 + RESPONSE_GRACE_MS | ||
| ); | ||
| 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) { | ||
| const conn = this.connections.get(ws); | ||
| if (!conn) return; | ||
| if (conn.role !== "connector") return; | ||
| const pending = this.pendingRequests.get(response.id); | ||
| if (!pending || pending.connectorWs !== ws) return; | ||
| this.pendingRequests.delete(response.id); | ||
| clearTimeout(pending.timer); | ||
| if (pending.agentWs.readyState === WebSocket.OPEN) { | ||
| pending.agentWs.send( | ||
| JSON.stringify({ ...response, id: pending.agentRequestId }) + "\n" | ||
| ); | ||
| } | ||
| } | ||
| 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.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 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" }; | ||
| const available = connectors.map(({ info }) => `${info.name} [${info.connectorId}]`).join(", "); | ||
| if (!selector) { | ||
| if (connectors.length === 1) { | ||
| return { ws: connectors[0].ws, info: connectors[0].info }; | ||
| } | ||
| return { | ||
| error: `Multiple cloud connectors are connected (${connectors.length}). Pass --connector <id|name>. Available: ${available}` | ||
| }; | ||
| } | ||
| 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 }; | ||
| } | ||
| 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) { | ||
| this.pendingRequests.forEach((pending, 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: pending.agentRequestId, | ||
| success: false, | ||
| error: "Connector connection closed" | ||
| }) + "\n" | ||
| ); | ||
| } | ||
| }); | ||
| } | ||
| startHeartbeatCheck() { | ||
| if (this.heartbeatTimer) return; | ||
| this.heartbeatTimer = setInterval(() => { | ||
| const now = Date.now(); | ||
| const timeoutMs = this.options.heartbeatTimeout * 1e3; | ||
| for (const [ws, conn] of this.connections) { | ||
| if (now - conn.lastHeartbeat > timeoutMs) { | ||
| console.log(`[CloudServer] Client ${conn.id} heartbeat timeout, closing connection`); | ||
| ws.close(1e3, "Heartbeat timeout"); | ||
| } | ||
| } | ||
| }, 3e4); | ||
| } | ||
| getPublicIP() { | ||
| const interfaces = networkInterfaces(); | ||
| for (const name of Object.keys(interfaces)) { | ||
| const iface = interfaces[name]; | ||
| if (!iface) continue; | ||
| for (const addr of iface) { | ||
| if (addr.family === "IPv4" && !addr.internal) { | ||
| return addr.address; | ||
| } | ||
| } | ||
| } | ||
| return "localhost"; | ||
| } | ||
| async getSandboxPublicUrl(port) { | ||
| if (!process.env.AONE_SANDBOX_ID) { | ||
| return null; | ||
| } | ||
| const mappingApiUrl = "http://localhost:58596/api/port-mapping"; | ||
| try { | ||
| const fetch = await getFetch(); | ||
| const response = await fetch(`${mappingApiUrl}?port=${port}`); | ||
| if (!response.ok) { | ||
| return null; | ||
| } | ||
| const data = await response.json(); | ||
| if (data.success && data.url) { | ||
| return data.url; | ||
| } | ||
| } catch (error) { | ||
| console.error( | ||
| `[CloudServer] Failed to query sandbox port mapping:`, | ||
| error instanceof Error ? error.message : String(error) | ||
| ); | ||
| } | ||
| return null; | ||
| } | ||
| writeConfigFile(info) { | ||
| try { | ||
| if (!existsSync(CONFIG_DIR)) { | ||
| mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 }); | ||
| } | ||
| const config = { server: info.localUrl, ...info }; | ||
| 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}`); | ||
| } catch (error) { | ||
| console.error("[CloudServer] Failed to write config file:", error); | ||
| } | ||
| } | ||
| getStats() { | ||
| let agents = 0; | ||
| for (const conn of this.connections.values()) { | ||
| if (conn.role === "agent") agents++; | ||
| } | ||
| return { | ||
| connections: this.connections.size, | ||
| agents, | ||
| connectors: this.getConnectorConnections().length, | ||
| pendingRequests: this.pendingRequests.size | ||
| }; | ||
| } | ||
| }; | ||
| CloudServer | ||
| } from "./chunks/chunk-DCTZIDJ7.js"; | ||
| export { | ||
| CloudServer | ||
| }; |
+11
-6
| { | ||
| "name": "@mearl/cloud-server", | ||
| "version": "2.7.2", | ||
| "version": "2.7.3", | ||
| "description": "Cloud WebSocket server for Mearl — bridges cloud agents to local connectors", | ||
| "type": "module", | ||
| "engines": { | ||
| "node": ">=18" | ||
| }, | ||
| "main": "dist/index.js", | ||
@@ -18,3 +21,6 @@ "types": "dist/index.d.ts", | ||
| "files": [ | ||
| "dist" | ||
| "dist", | ||
| "!dist/**/*.map", | ||
| "!dist/**/__tests__/**", | ||
| "!dist/**/*.test.d.ts" | ||
| ], | ||
@@ -31,7 +37,6 @@ "keywords": [ | ||
| "dependencies": { | ||
| "node-fetch": "^3.3.2", | ||
| "ws": "^8.18.0", | ||
| "@mearl/daemon-core": "2.7.2", | ||
| "@mearl/client": "2.7.2", | ||
| "@mearl/cloud-types": "2.7.2" | ||
| "@mearl/client": "2.7.3", | ||
| "@mearl/cloud-types": "2.7.3", | ||
| "@mearl/daemon-core": "2.7.3" | ||
| }, | ||
@@ -38,0 +43,0 @@ "devDependencies": { |
| export {}; |
| export {}; |
| export {}; |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
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.
4
-20%10
-23.08%3
-78.57%42633
-31.14%11
-15.38%988
-35.97%+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
Updated
Updated
Updated