Sign In

@mearl/cloud-server

Package Overview
Dependencies
Maintainers
2
Versions
17
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@mearl/cloud-server - npm Package Compare versions

Comparing version
2.7.2
to
2.7.3
+546
dist/chunks/chunk-DCTZIDJ7.js
// 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")) {

@@ -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
};
{
"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": {