@mearl/cloud-server 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,214 @@
1
+ # @mearl/cloud-server
2
+
3
+ 云端 WebSocket Server,用于中转云端 Agent 请求到本地连接器。
4
+
5
+ ## 安装
6
+
7
+ ```bash
8
+ npm install -g @mearl/cloud-server
9
+ # 或
10
+ pnpm add -g @mearl/cloud-server
11
+ ```
12
+
13
+ ## 使用
14
+
15
+ ### 命令行方式
16
+
17
+ Server **默认以后台守护进程方式运行**,启动后立即返回,并打印本地机器需要执行的连接命令。
18
+
19
+ ```bash
20
+ mearl-cloud-server [command] [options]
21
+ ```
22
+
23
+ **子命令:**
24
+ - `start` - 后台启动 server(默认,不写也等价于 `start`)
25
+ - `stop` - 停止后台 server
26
+ - `restart` - 重启后台 server
27
+ - `status` - 查看运行状态及连接信息
28
+ - `logs` - 打印 server 日志末尾
29
+
30
+ **选项:**
31
+ - `--foreground` / `-f` - 前台运行(不守护化,适合 systemd / Docker / PM2)
32
+ - `--port <port>` - 服务器端口(默认 8080)
33
+ - `--path <path>` - WebSocket 路径(默认 /ws)
34
+ - `--token <token>` - 鉴权密钥(未指定时自动生成)
35
+ - `--heartbeat <seconds>` - 心跳超时时间(默认 90 秒)
36
+ - `--max-connections <n>` - 最大连接数(默认 100)
37
+
38
+ **示例:**
39
+ ```bash
40
+ # 后台启动(自动生成 token),打印连接命令后立即返回
41
+ mearl-cloud-server
42
+
43
+ # 查看状态与连接信息
44
+ mearl-cloud-server status
45
+
46
+ # 停止 / 重启
47
+ mearl-cloud-server stop
48
+ mearl-cloud-server restart
49
+
50
+ # 自定义端口和鉴权
51
+ mearl-cloud-server start --port 9000 --token your-secret-token
52
+
53
+ # 前台运行(交给外部进程管理器托管)
54
+ mearl-cloud-server --foreground
55
+ ```
56
+
57
+ 后台启动后会打印供本地机器使用的连接命令,例如:
58
+ ```
59
+ [CloudServer] Started in background (pid 12345)
60
+ [CloudServer] Logs: ~/.mearl/cloud-server.log
61
+
62
+ Connect from your local machine:
63
+ npx @mearl/cloud-connector "ws://10.0.1.100:8080/ws?token=xxx"
64
+ ```
65
+
66
+ 运行时文件位于 `~/.mearl/`:
67
+ - `cloud-server.json` - 连接配置(含 server URL、pid、端口等),cloud-client 自动读取
68
+ - `cloud-server.log` - 后台进程日志(`logs` 子命令读取,或 `tail -f` 跟踪);超过 5MB 会在下次启动时滚动为 `cloud-server.log.1`
69
+
70
+ > 守护进程的启停、状态、日志滚动等生命周期逻辑由共享包 [`@mearl/daemon-core`](../../daemon-core) 提供(与 cloud-connector 复用同一套实现)。
71
+
72
+ **关于 token:**
73
+ - 未指定 `--token` 时会自动生成。`restart` 会**沿用当前 token**,因此之前打印的连接命令重启后仍然有效。
74
+ - `stop` 后再 `start` 属于全新启动,会重新生成 token;若希望连接命令长期固定,请显式传入 `--token` 或设置 `MEARL_TOKEN`。
75
+
76
+ **环境变量:**
77
+ - `MEARL_TOKEN` - 鉴权密钥(覆盖 --token 选项)
78
+ - `CLOUD_SERVER_HOST` - 公网主机名(默认自动获取)
79
+
80
+ ### 编程方式
81
+
82
+ ```typescript
83
+ import { CloudServer } from '@mearl/cloud-server';
84
+
85
+ const server = new CloudServer({
86
+ port: 8080,
87
+ path: '/ws',
88
+ token: 'your-secret-token',
89
+ heartbeatTimeout: 90,
90
+ maxConnections: 100,
91
+ maxMessageSize: 10 * 1024 * 1024,
92
+ });
93
+
94
+ await server.start();
95
+
96
+ // 获取统计信息
97
+ const stats = server.getStats();
98
+ console.log(stats);
99
+ // { connections: 2, agents: 1, connectors: 1, pendingRequests: 0 }
100
+
101
+ // 优雅关闭
102
+ await server.stop();
103
+ ```
104
+
105
+ ## 架构
106
+
107
+ ```
108
+ 云端 Agent
109
+ ↓
110
+ @mearl/cloud-client
111
+ ↓ (WebSocket)
112
+ @mearl/cloud-server (本进程)
113
+ ↓ (WebSocket)
114
+ @mearl/cloud-connector (本地)
115
+ ↓ (Unix Socket)
116
+ @mearl/native-host
117
+ ↓
118
+ Chrome Extension / CDP
119
+ ```
120
+
121
+ ## 连接类型
122
+
123
+ Server 接受两种类型的连接:
124
+
125
+ 1. **Agent 连接** - 发起请求的云端客户端
126
+ 2. **Connector 连接** - 转发请求到 native-host 的本地连接器
127
+
128
+ Server 会自动识别连接类型,并将 Agent 的请求转发到可用的 Connector。
129
+
130
+ ## 消息协议
131
+
132
+ ### 请求格式(Agent → Server)
133
+ ```json
134
+ {
135
+ "id": "unique-id",
136
+ "action": "get_requests",
137
+ "data": { "count": 5 },
138
+ "version": "1.30.0"
139
+ }
140
+ ```
141
+
142
+ ### 响应格式(Server → Agent)
143
+ ```json
144
+ {
145
+ "id": "unique-id",
146
+ "success": true,
147
+ "data": { ... },
148
+ "versionWarning": "..."
149
+ }
150
+ ```
151
+
152
+ ### 心跳格式
153
+ ```json
154
+ { "type": "ping", "timestamp": 1234567890 }
155
+ { "type": "pong", "timestamp": 1234567890 }
156
+ ```
157
+
158
+ ## 鉴权
159
+
160
+ 设置 `token` 后,所有连接必须携带正确的 token:
161
+
162
+ ```bash
163
+ # Agent 连接
164
+ ws://server.com/ws?token=your-secret-token
165
+
166
+ # Connector 连接
167
+ ws://server.com/ws?token=your-secret-token
168
+ ```
169
+
170
+ ## 部署建议
171
+
172
+ > 交给外部进程管理器(systemd / Docker / PM2)托管时,请加 `--foreground`,让 server 在前台运行,由管理器负责守护与重启。
173
+
174
+ ### 使用 systemd(Linux)
175
+
176
+ ```ini
177
+ [Unit]
178
+ Description=Mearl Cloud Server
179
+ After=network.target
180
+
181
+ [Service]
182
+ Type=simple
183
+ User=your-user
184
+ ExecStart=/usr/bin/mearl-cloud-server --foreground --port 8080 --token your-token
185
+ Restart=always
186
+ RestartSec=5
187
+
188
+ [Install]
189
+ WantedBy=multi-user.target
190
+ ```
191
+
192
+ ### 使用 Docker
193
+
194
+ ```dockerfile
195
+ FROM node:20-alpine
196
+ RUN npm install -g @mearl/cloud-server
197
+ EXPOSE 8080
198
+ CMD ["mearl-cloud-server", "--foreground", "--token", "your-token"]
199
+ ```
200
+
201
+ ### 使用 PM2
202
+
203
+ ```bash
204
+ pm2 start mearl-cloud-server --name mtop-cloud-server -- --foreground --port 8080 --token your-token
205
+ ```
206
+
207
+ ## 注意事项
208
+
209
+ - 建议在生产环境使用 WSS(WebSocket Secure)加密传输
210
+ - 使用反向代理(如 Nginx)处理 WSS 升级
211
+ - 设置合理的 `maxConnections` 限制资源占用
212
+ - 启用 `token` 鉴权保护服务安全
213
+ - 在沙箱环境中启动时,会自动检测并使用平台分配的公网地址
214
+ - 如果沙箱端口映射 API 不可用,会回退到使用本地 IP 地址
@@ -0,0 +1 @@
1
+ export {};
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,609 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/daemon.ts
4
+ import { fileURLToPath } from "node:url";
5
+ import { createDaemon, readRecord, removeRecord } from "@mearl/daemon-core";
6
+
7
+ // src/index.ts
8
+ import WebSocket, { WebSocketServer } from "ws";
9
+ import { createServer as createHttpServer } from "node:http";
10
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
11
+ import { networkInterfaces } from "node:os";
12
+
13
+ // src/paths.ts
14
+ import { join } from "node:path";
15
+ import { DAEMON_HOME } from "@mearl/daemon-core";
16
+ var CONFIG_DIR = DAEMON_HOME;
17
+ var CONFIG_FILE = join(CONFIG_DIR, "cloud-server.json");
18
+ var LOG_FILE = join(CONFIG_DIR, "cloud-server.log");
19
+
20
+ // src/index.ts
21
+ import {
22
+ isHeartbeatMessage,
23
+ isCloudMessage,
24
+ isCloudResponse,
25
+ MAX_BUFFER_SIZE,
26
+ DEFAULT_HEARTBEAT_TIMEOUT,
27
+ DEFAULT_REQUEST_TIMEOUT
28
+ } from "@mearl/cloud-types";
29
+ import { resolveActionTimeoutSec } from "@mearl/client/action-timeouts";
30
+ var fetchInstance = null;
31
+ async function getFetch() {
32
+ if (fetchInstance) {
33
+ return fetchInstance;
34
+ }
35
+ if (typeof globalThis.fetch !== "undefined") {
36
+ fetchInstance = globalThis.fetch;
37
+ } else {
38
+ const { default: fetch } = await import("node-fetch");
39
+ fetchInstance = fetch;
40
+ }
41
+ return fetchInstance;
42
+ }
43
+ var DEFAULT_PORT = 8080;
44
+ var DEFAULT_PATH = "/ws";
45
+ var DEFAULT_MAX_CONNECTIONS = 100;
46
+ var DEFAULT_MAX_MESSAGE_SIZE = 10 * 1024 * 1024;
47
+ var RESPONSE_GRACE_MS = 5e3;
48
+ var CloudServer = class {
49
+ options;
50
+ connectorWss = null;
51
+ agentWss = null;
52
+ connectorHttpServer = null;
53
+ agentHttpServer = null;
54
+ connections = /* @__PURE__ */ new Map();
55
+ pendingRequests = /* @__PURE__ */ new Map();
56
+ heartbeatTimer = null;
57
+ constructor(options = {}) {
58
+ const port = options.port ?? DEFAULT_PORT;
59
+ this.options = {
60
+ port,
61
+ agentPort: options.agentPort ?? port + 1,
62
+ path: options.path ?? DEFAULT_PATH,
63
+ token: options.token,
64
+ heartbeatTimeout: options.heartbeatTimeout ?? DEFAULT_HEARTBEAT_TIMEOUT,
65
+ maxConnections: options.maxConnections ?? DEFAULT_MAX_CONNECTIONS,
66
+ maxMessageSize: options.maxMessageSize ?? DEFAULT_MAX_MESSAGE_SIZE,
67
+ requestTimeout: options.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT,
68
+ allowRemoteAgent: options.allowRemoteAgent ?? false
69
+ };
70
+ }
71
+ getToken() {
72
+ if (!this.options.token) {
73
+ this.options.token = this.generateRandomToken();
74
+ }
75
+ return this.options.token;
76
+ }
77
+ generateRandomToken() {
78
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
79
+ let token = "";
80
+ for (let i = 0; i < 32; i++) {
81
+ token += chars.charAt(Math.floor(Math.random() * chars.length));
82
+ }
83
+ return token;
84
+ }
85
+ async start() {
86
+ await this.createListener(this.options.agentPort, "127.0.0.1", true);
87
+ await this.createListener(this.options.port, "0.0.0.0", false);
88
+ const token = this.getToken();
89
+ const localWsUrl = `ws://localhost:${this.options.agentPort}${this.options.path}?token=${token}`;
90
+ let publicWsUrl;
91
+ const sandboxUrl = await this.getSandboxPublicUrl(this.options.port);
92
+ if (sandboxUrl) {
93
+ try {
94
+ const parsedUrl = new URL(sandboxUrl);
95
+ parsedUrl.protocol = "wss:";
96
+ parsedUrl.pathname = this.options.path;
97
+ parsedUrl.search = `?token=${token}`;
98
+ publicWsUrl = parsedUrl.toString();
99
+ } catch {
100
+ const httpsUrl = sandboxUrl.replace("https://", "wss://");
101
+ publicWsUrl = `${httpsUrl}${this.options.path}?token=${token}`;
102
+ }
103
+ } else {
104
+ const publicHost = process.env.CLOUD_SERVER_HOST || this.getPublicIP();
105
+ publicWsUrl = `ws://${publicHost}:${this.options.port}${this.options.path}?token=${token}`;
106
+ }
107
+ const connectorCommand = `npx @mearl/cloud-connector "${publicWsUrl}"`;
108
+ console.log(
109
+ `[CloudServer] Connector listener on 0.0.0.0:${this.options.port} (network); agent listener on 127.0.0.1:${this.options.agentPort} (local only)`
110
+ );
111
+ console.log(`[CloudServer] Connect from local machine:`);
112
+ console.log(` ${connectorCommand}`);
113
+ this.startHeartbeatCheck();
114
+ const info = {
115
+ pid: process.pid,
116
+ port: this.options.port,
117
+ path: this.options.path,
118
+ token,
119
+ localUrl: localWsUrl,
120
+ publicUrl: publicWsUrl,
121
+ connectorCommand,
122
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
123
+ };
124
+ this.writeConfigFile(info);
125
+ return info;
126
+ }
127
+ /**
128
+ * Stand up one HTTP+WebSocket listener. `fromLocal` tags every connection
129
+ * accepted here so handleRequest can pin command-issuing authority to the
130
+ * loopback listener.
131
+ */
132
+ createListener(port, host, fromLocal) {
133
+ return new Promise((resolve, reject) => {
134
+ const httpServer = createHttpServer((req, res) => {
135
+ console.log(`[CloudServer] HTTP request: ${req.method} ${req.url}`);
136
+ res.writeHead(404, { "Content-Type": "text/plain" });
137
+ res.end("Not Found - This is a WebSocket server");
138
+ });
139
+ const wss = new WebSocketServer({
140
+ server: httpServer,
141
+ path: this.options.path,
142
+ maxPayload: this.options.maxMessageSize
143
+ });
144
+ wss.on("connection", (ws, req) => {
145
+ console.log(
146
+ `[CloudServer] New ${fromLocal ? "agent" : "connector"} connection from ${req.socket.remoteAddress}, path: ${req.url}`
147
+ );
148
+ this.handleConnection(ws, req, fromLocal);
149
+ });
150
+ wss.on("error", (error) => {
151
+ console.error("[CloudServer] WebSocket server error:", error.message);
152
+ });
153
+ httpServer.on("error", (error) => {
154
+ console.error("[CloudServer] HTTP server error:", error.message);
155
+ reject(error);
156
+ });
157
+ if (fromLocal) {
158
+ this.agentWss = wss;
159
+ this.agentHttpServer = httpServer;
160
+ } else {
161
+ this.connectorWss = wss;
162
+ this.connectorHttpServer = httpServer;
163
+ }
164
+ httpServer.listen(port, host, () => {
165
+ resolve();
166
+ });
167
+ });
168
+ }
169
+ stop() {
170
+ return new Promise((resolve) => {
171
+ if (this.heartbeatTimer) {
172
+ clearInterval(this.heartbeatTimer);
173
+ this.heartbeatTimer = null;
174
+ }
175
+ for (const [ws] of this.connections) {
176
+ ws.close();
177
+ }
178
+ this.connections.clear();
179
+ this.pendingRequests.clear();
180
+ this.agentWss?.close();
181
+ this.connectorWss?.close();
182
+ this.agentWss = null;
183
+ this.connectorWss = null;
184
+ const servers = [this.agentHttpServer, this.connectorHttpServer].filter(
185
+ (s) => s !== null
186
+ );
187
+ this.agentHttpServer = null;
188
+ this.connectorHttpServer = null;
189
+ if (servers.length === 0) {
190
+ resolve();
191
+ return;
192
+ }
193
+ let remaining = servers.length;
194
+ for (const s of servers) {
195
+ s.close(() => {
196
+ if (--remaining <= 0) resolve();
197
+ });
198
+ }
199
+ });
200
+ }
201
+ handleConnection(ws, req, fromLocal) {
202
+ if (this.connections.size >= this.options.maxConnections) {
203
+ ws.close(1008, "Server is full");
204
+ return;
205
+ }
206
+ if (this.options.token) {
207
+ const url = new URL(req.url || "", `http://${req.headers.host}`);
208
+ const token = url.searchParams.get("token");
209
+ if (token !== this.options.token) {
210
+ ws.close(1008, "Unauthorized");
211
+ return;
212
+ }
213
+ }
214
+ const connId = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
215
+ const connection = {
216
+ ws,
217
+ id: connId,
218
+ lastHeartbeat: Date.now(),
219
+ isAgent: false,
220
+ fromLocal,
221
+ messageBuffer: ""
222
+ };
223
+ this.connections.set(ws, connection);
224
+ console.log(`[CloudServer] Client connected: ${connId} (total: ${this.connections.size})`);
225
+ ws.on("message", (data) => {
226
+ const conn = this.connections.get(ws);
227
+ if (!conn) return;
228
+ conn.messageBuffer += data.toString("utf-8");
229
+ if (conn.messageBuffer.length > MAX_BUFFER_SIZE) {
230
+ console.error(
231
+ `[CloudServer] Message buffer exceeds limit for ${connId}, closing connection`
232
+ );
233
+ ws.close(1008, "Message too large");
234
+ return;
235
+ }
236
+ let newlineIndex;
237
+ while ((newlineIndex = conn.messageBuffer.indexOf("\n")) !== -1) {
238
+ const line = conn.messageBuffer.slice(0, newlineIndex);
239
+ conn.messageBuffer = conn.messageBuffer.slice(newlineIndex + 1);
240
+ if (!line.trim()) continue;
241
+ try {
242
+ const message = JSON.parse(line);
243
+ this.handleMessage(ws, message, connId);
244
+ } catch (error) {
245
+ console.error(`[CloudServer] Failed to parse message from ${connId}:`, line, error);
246
+ }
247
+ }
248
+ });
249
+ ws.on("close", () => {
250
+ this.connections.delete(ws);
251
+ this.rejectPendingForWs(ws);
252
+ console.log(`[CloudServer] Client disconnected: ${connId} (total: ${this.connections.size})`);
253
+ });
254
+ ws.on("error", (error) => {
255
+ console.error(`[CloudServer] Connection error for ${connId}:`, error.message);
256
+ });
257
+ }
258
+ handleMessage(ws, message, connId) {
259
+ const conn = this.connections.get(ws);
260
+ if (!conn) return;
261
+ if (isHeartbeatMessage(message)) {
262
+ if (message.type === "ping") {
263
+ ws.send(
264
+ JSON.stringify({ type: "pong", timestamp: Date.now() }) + "\n"
265
+ );
266
+ conn.lastHeartbeat = Date.now();
267
+ } else if (message.type === "pong") {
268
+ conn.lastHeartbeat = Date.now();
269
+ }
270
+ return;
271
+ }
272
+ if (isCloudResponse(message)) {
273
+ this.handleResponse(ws, message, connId);
274
+ return;
275
+ }
276
+ if (isCloudMessage(message)) {
277
+ this.handleRequest(ws, message, connId);
278
+ return;
279
+ }
280
+ }
281
+ handleRequest(ws, message, _connId) {
282
+ const { id, action, data } = message;
283
+ const conn = this.connections.get(ws);
284
+ if (!conn) return;
285
+ if (!conn.fromLocal && !this.options.allowRemoteAgent) {
286
+ console.warn(
287
+ `[CloudServer] Rejected agent request from non-local connection ${conn.id} (action: ${action})`
288
+ );
289
+ ws.send(
290
+ JSON.stringify({
291
+ id,
292
+ success: false,
293
+ error: "Agent requests are only accepted on the local interface (127.0.0.1). Run cloud-client on the same machine as cloud-server, or start the server with --allow-remote-agent to override."
294
+ }) + "\n"
295
+ );
296
+ ws.close(1008, "Agent must be local");
297
+ return;
298
+ }
299
+ conn.isAgent = true;
300
+ const targetWs = this.findConnector();
301
+ if (!targetWs) {
302
+ ws.send(JSON.stringify({ id, success: false, error: "No local connector available" }) + "\n");
303
+ return;
304
+ }
305
+ const requestedTimeoutSec = Number(message.timeoutSec);
306
+ const timeoutSec = Number.isFinite(requestedTimeoutSec) && requestedTimeoutSec > 0 ? Math.min(requestedTimeoutSec, 3600) : resolveActionTimeoutSec(action, data, this.options.requestTimeout);
307
+ const timer = setTimeout(
308
+ () => {
309
+ this.pendingRequests.delete(id);
310
+ if (ws.readyState === WebSocket.OPEN) {
311
+ ws.send(JSON.stringify({ id, success: false, error: "Request timeout" }) + "\n");
312
+ }
313
+ },
314
+ timeoutSec * 1e3 + RESPONSE_GRACE_MS
315
+ );
316
+ this.pendingRequests.set(id, { ws, timer });
317
+ targetWs.send(
318
+ JSON.stringify({ id, action, data, browser: message.browser, timeoutSec }) + "\n"
319
+ );
320
+ }
321
+ handleResponse(ws, response, _connId) {
322
+ const conn = this.connections.get(ws);
323
+ if (!conn) return;
324
+ if (conn.isAgent) return;
325
+ const pending = this.pendingRequests.get(response.id);
326
+ if (!pending) return;
327
+ this.pendingRequests.delete(response.id);
328
+ clearTimeout(pending.timer);
329
+ if (pending.ws.readyState === WebSocket.OPEN) {
330
+ pending.ws.send(JSON.stringify(response) + "\n");
331
+ }
332
+ }
333
+ findConnector() {
334
+ for (const [ws, conn] of this.connections) {
335
+ if (!conn.isAgent && ws.readyState === WebSocket.OPEN) {
336
+ return ws;
337
+ }
338
+ }
339
+ return null;
340
+ }
341
+ rejectPendingForWs(ws) {
342
+ this.pendingRequests.forEach((pending, id) => {
343
+ if (pending.ws === ws) {
344
+ clearTimeout(pending.timer);
345
+ if (ws.readyState === WebSocket.OPEN) {
346
+ ws.send(JSON.stringify({ id, success: false, error: "Connection closed" }) + "\n");
347
+ }
348
+ this.pendingRequests.delete(id);
349
+ }
350
+ });
351
+ }
352
+ startHeartbeatCheck() {
353
+ if (this.heartbeatTimer) return;
354
+ this.heartbeatTimer = setInterval(() => {
355
+ const now = Date.now();
356
+ const timeoutMs = this.options.heartbeatTimeout * 1e3;
357
+ for (const [ws, conn] of this.connections) {
358
+ if (now - conn.lastHeartbeat > timeoutMs) {
359
+ console.log(`[CloudServer] Client ${conn.id} heartbeat timeout, closing connection`);
360
+ ws.close(1e3, "Heartbeat timeout");
361
+ }
362
+ }
363
+ }, 3e4);
364
+ }
365
+ getPublicIP() {
366
+ const interfaces = networkInterfaces();
367
+ for (const name of Object.keys(interfaces)) {
368
+ const iface = interfaces[name];
369
+ if (!iface) continue;
370
+ for (const addr of iface) {
371
+ if (addr.family === "IPv4" && !addr.internal) {
372
+ return addr.address;
373
+ }
374
+ }
375
+ }
376
+ return "localhost";
377
+ }
378
+ async getSandboxPublicUrl(port) {
379
+ if (!process.env.AONE_SANDBOX_ID) {
380
+ return null;
381
+ }
382
+ const mappingApiUrl = "http://localhost:58596/api/port-mapping";
383
+ try {
384
+ const fetch = await getFetch();
385
+ const response = await fetch(`${mappingApiUrl}?port=${port}`);
386
+ if (!response.ok) {
387
+ return null;
388
+ }
389
+ const data = await response.json();
390
+ if (data.success && data.url) {
391
+ return data.url;
392
+ }
393
+ } catch (error) {
394
+ console.error(
395
+ `[CloudServer] Failed to query sandbox port mapping:`,
396
+ error instanceof Error ? error.message : String(error)
397
+ );
398
+ }
399
+ return null;
400
+ }
401
+ writeConfigFile(info) {
402
+ try {
403
+ if (!existsSync(CONFIG_DIR)) {
404
+ mkdirSync(CONFIG_DIR, { recursive: true });
405
+ }
406
+ const config = { server: info.localUrl, ...info };
407
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
408
+ console.log(`[CloudServer] Config written to: ${CONFIG_FILE}`);
409
+ } catch (error) {
410
+ console.error("[CloudServer] Failed to write config file:", error);
411
+ }
412
+ }
413
+ getStats() {
414
+ let agents = 0;
415
+ let connectors = 0;
416
+ for (const conn of this.connections.values()) {
417
+ if (conn.isAgent) agents++;
418
+ else connectors++;
419
+ }
420
+ return {
421
+ connections: this.connections.size,
422
+ agents,
423
+ connectors,
424
+ pendingRequests: this.pendingRequests.size
425
+ };
426
+ }
427
+ };
428
+
429
+ // src/daemon.ts
430
+ var SCRIPT_PATH = fileURLToPath(import.meta.url);
431
+ function formatConnectInfo(record) {
432
+ const config = record;
433
+ const lines = [];
434
+ if (config.port) lines.push(` port: ${config.port}`);
435
+ const command2 = config.connectorCommand || (config.publicUrl ? `npx @mearl/cloud-connector "${config.publicUrl}"` : null);
436
+ if (command2) {
437
+ lines.push("");
438
+ lines.push(" Connect from your local machine:");
439
+ lines.push(` ${command2}`);
440
+ }
441
+ return lines.length ? lines.join("\n") : null;
442
+ }
443
+ function daemon(foregroundArgs) {
444
+ return createDaemon({
445
+ name: "CloudServer",
446
+ scriptPath: SCRIPT_PATH,
447
+ configFile: CONFIG_FILE,
448
+ logFile: LOG_FILE,
449
+ foregroundArgs,
450
+ formatInfo: formatConnectInfo
451
+ });
452
+ }
453
+ function startDaemon(passthroughArgs) {
454
+ return daemon(passthroughArgs).start();
455
+ }
456
+ function stopDaemon() {
457
+ return daemon([]).stop();
458
+ }
459
+ function restartDaemon(passthroughArgs) {
460
+ let args = passthroughArgs;
461
+ if (!passthroughArgs.includes("--token") && !process.env.MEARL_TOKEN) {
462
+ const token = readRecord(CONFIG_FILE)?.token;
463
+ if (token) args = [...passthroughArgs, "--token", token];
464
+ }
465
+ return daemon(args).restart();
466
+ }
467
+ function statusDaemon() {
468
+ daemon([]).status();
469
+ }
470
+ function printLogs() {
471
+ daemon([]).logs();
472
+ }
473
+ async function runForeground(options) {
474
+ const server = new CloudServer(options);
475
+ await server.start();
476
+ console.log("[CloudServer] Press Ctrl+C to stop");
477
+ let shuttingDown = false;
478
+ const shutdown = async (signal) => {
479
+ if (shuttingDown) return;
480
+ shuttingDown = true;
481
+ console.log(`
482
+ [CloudServer] Received ${signal}, shutting down...`);
483
+ await server.stop();
484
+ removeRecord(CONFIG_FILE);
485
+ process.exit(0);
486
+ };
487
+ process.on("SIGINT", () => void shutdown("SIGINT"));
488
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
489
+ }
490
+
491
+ // src/cli.ts
492
+ var CLOUD_SERVER_VERSION = true ? "2.0.0" : "unknown";
493
+ var argv = process.argv.slice(2);
494
+ if (argv.includes("--version") || argv.includes("-v")) {
495
+ console.log(CLOUD_SERVER_VERSION);
496
+ process.exit(0);
497
+ }
498
+ if (argv.includes("--help") || argv.includes("-h")) {
499
+ showHelp();
500
+ process.exit(0);
501
+ }
502
+ var COMMANDS = ["start", "stop", "restart", "status", "logs"];
503
+ var command = "start";
504
+ var rest = argv;
505
+ if (argv[0] && COMMANDS.includes(argv[0])) {
506
+ command = argv[0];
507
+ rest = argv.slice(1);
508
+ }
509
+ var foreground = rest.includes("--foreground") || rest.includes("-f");
510
+ function showHelp() {
511
+ console.log(`mearl-cloud-server v${CLOUD_SERVER_VERSION}
512
+
513
+ Usage:
514
+ mearl-cloud-server [command] [options]
515
+
516
+ Commands:
517
+ start Start the server in the background (default)
518
+ stop Stop the background server
519
+ restart Restart the background server
520
+ status Show whether the server is running + connect info
521
+ logs Print the tail of the server log
522
+
523
+ Options:
524
+ --foreground, -f Run in the foreground instead of daemonizing
525
+ --port <port> Connector (network) port (default: 8080)
526
+ --agent-port <port> Agent port, bound to 127.0.0.1 only (default: port + 1)
527
+ --path <path> WebSocket path (default: /ws)
528
+ --token <token> Authentication token (auto-generated if not specified)
529
+ --heartbeat <seconds> Heartbeat timeout in seconds (default: 90)
530
+ --max-connections <n> Max concurrent connections (default: 100)
531
+ --allow-remote-agent Allow command-issuing agents to connect from the
532
+ network (default: off; agents must be local)
533
+ --version, -v Show version
534
+ --help, -h Show this help message
535
+
536
+ Environment Variables:
537
+ MEARL_TOKEN Authentication token (overrides --token if set)
538
+ CLOUD_SERVER_HOST Host name for WebSocket URL (default: localhost)
539
+
540
+ Examples:
541
+ # Start in the background (default) and print the connector command
542
+ mearl-cloud-server
543
+
544
+ # Check status / connect info, then stop
545
+ mearl-cloud-server status
546
+ mearl-cloud-server stop
547
+
548
+ # Start with a custom port and token
549
+ mearl-cloud-server start --port 9000 --token your-secret-token
550
+
551
+ # Run in the foreground (e.g. under systemd / Docker / PM2)
552
+ mearl-cloud-server --foreground
553
+ `);
554
+ }
555
+ function parseOptions(args) {
556
+ const options = {};
557
+ for (let i = 0; i < args.length; i++) {
558
+ const arg = args[i];
559
+ if (arg === "--port" && i + 1 < args.length) {
560
+ options.port = parseInt(args[++i], 10);
561
+ } else if (arg === "--agent-port" && i + 1 < args.length) {
562
+ options.agentPort = parseInt(args[++i], 10);
563
+ } else if (arg === "--path" && i + 1 < args.length) {
564
+ options.path = args[++i];
565
+ } else if (arg === "--token" && i + 1 < args.length) {
566
+ options.token = args[++i];
567
+ } else if (arg === "--heartbeat" && i + 1 < args.length) {
568
+ options.heartbeatTimeout = parseInt(args[++i], 10);
569
+ } else if (arg === "--max-connections" && i + 1 < args.length) {
570
+ options.maxConnections = parseInt(args[++i], 10);
571
+ } else if (arg === "--allow-remote-agent") {
572
+ options.allowRemoteAgent = true;
573
+ }
574
+ }
575
+ if (!options.token) {
576
+ const envToken = process.env.MEARL_TOKEN;
577
+ if (envToken) {
578
+ options.token = envToken;
579
+ }
580
+ }
581
+ return options;
582
+ }
583
+ async function main() {
584
+ if (foreground) {
585
+ await runForeground(parseOptions(rest));
586
+ return;
587
+ }
588
+ switch (command) {
589
+ case "start":
590
+ await startDaemon(rest);
591
+ break;
592
+ case "stop":
593
+ await stopDaemon();
594
+ break;
595
+ case "restart":
596
+ await restartDaemon(rest);
597
+ break;
598
+ case "status":
599
+ statusDaemon();
600
+ break;
601
+ case "logs":
602
+ printLogs();
603
+ break;
604
+ }
605
+ }
606
+ main().catch((error) => {
607
+ console.error("[CloudServer] Fatal error:", error);
608
+ process.exit(1);
609
+ });
@@ -0,0 +1,22 @@
1
+ import { CloudServerOptions } from './index.js';
2
+ /** Spawn the server as a detached background process. */
3
+ export declare function startDaemon(passthroughArgs: string[]): Promise<void>;
4
+ /** Stop a running background server. */
5
+ export declare function stopDaemon(): Promise<void>;
6
+ /**
7
+ * Restart the background server, preserving the existing (auto-generated) token
8
+ * unless the caller overrides it, so the previously-shared connector command
9
+ * keeps working across a restart.
10
+ */
11
+ export declare function restartDaemon(passthroughArgs: string[]): Promise<void>;
12
+ /** Print whether a background server is running and how to connect to it. */
13
+ export declare function statusDaemon(): void;
14
+ /** Print the tail of the daemon log file. */
15
+ export declare function printLogs(): void;
16
+ /**
17
+ * Run the server in the current process (no daemonizing). Used internally by
18
+ * the detached child, and available to users via `--foreground`. The server
19
+ * writes its own `ServerInfo` (including `pid`) into the config file once it is
20
+ * listening — that pid is how the daemon manager confirms the child came up.
21
+ */
22
+ export declare function runForeground(options: CloudServerOptions): Promise<void>;
@@ -0,0 +1,65 @@
1
+ export interface CloudServerOptions {
2
+ port?: number;
3
+ /** Loopback-only port for the co-located agent (cloud-client). Defaults to port + 1. */
4
+ agentPort?: number;
5
+ path?: string;
6
+ token?: string;
7
+ heartbeatTimeout?: number;
8
+ maxConnections?: number;
9
+ maxMessageSize?: number;
10
+ requestTimeout?: number;
11
+ /** Escape hatch: allow agents (command issuers) to connect from the network. Default false. */
12
+ allowRemoteAgent?: boolean;
13
+ }
14
+ /** Runtime info about a started server, also persisted to the config file. */
15
+ export interface ServerInfo {
16
+ pid: number;
17
+ port: number;
18
+ path: string;
19
+ token: string;
20
+ /** URL for a cloud-client running on the same machine (localhost). */
21
+ localUrl: string;
22
+ /** URL for the local connector to dial back into this server. */
23
+ publicUrl: string;
24
+ /** Ready-to-paste command for the local machine. */
25
+ connectorCommand: string;
26
+ /** ISO timestamp of when the server started listening. */
27
+ startedAt: string;
28
+ }
29
+ export declare class CloudServer {
30
+ private options;
31
+ private connectorWss;
32
+ private agentWss;
33
+ private connectorHttpServer;
34
+ private agentHttpServer;
35
+ private connections;
36
+ private pendingRequests;
37
+ private heartbeatTimer;
38
+ constructor(options?: CloudServerOptions);
39
+ getToken(): string;
40
+ private generateRandomToken;
41
+ start(): Promise<ServerInfo>;
42
+ /**
43
+ * Stand up one HTTP+WebSocket listener. `fromLocal` tags every connection
44
+ * accepted here so handleRequest can pin command-issuing authority to the
45
+ * loopback listener.
46
+ */
47
+ private createListener;
48
+ stop(): Promise<void>;
49
+ private handleConnection;
50
+ private handleMessage;
51
+ private handleRequest;
52
+ private handleResponse;
53
+ private findConnector;
54
+ private rejectPendingForWs;
55
+ private startHeartbeatCheck;
56
+ private getPublicIP;
57
+ private getSandboxPublicUrl;
58
+ private writeConfigFile;
59
+ getStats(): {
60
+ connections: number;
61
+ agents: number;
62
+ connectors: number;
63
+ pendingRequests: number;
64
+ };
65
+ }
package/dist/index.js ADDED
@@ -0,0 +1,424 @@
1
+ // src/index.ts
2
+ import WebSocket, { WebSocketServer } from "ws";
3
+ import { createServer as createHttpServer } from "node:http";
4
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
5
+ import { networkInterfaces } from "node:os";
6
+
7
+ // src/paths.ts
8
+ import { join } from "node:path";
9
+ import { DAEMON_HOME } from "@mearl/daemon-core";
10
+ var CONFIG_DIR = DAEMON_HOME;
11
+ var CONFIG_FILE = join(CONFIG_DIR, "cloud-server.json");
12
+ var LOG_FILE = join(CONFIG_DIR, "cloud-server.log");
13
+
14
+ // src/index.ts
15
+ import {
16
+ isHeartbeatMessage,
17
+ isCloudMessage,
18
+ isCloudResponse,
19
+ MAX_BUFFER_SIZE,
20
+ DEFAULT_HEARTBEAT_TIMEOUT,
21
+ DEFAULT_REQUEST_TIMEOUT
22
+ } from "@mearl/cloud-types";
23
+ import { resolveActionTimeoutSec } from "@mearl/client/action-timeouts";
24
+ var fetchInstance = null;
25
+ async function getFetch() {
26
+ if (fetchInstance) {
27
+ return fetchInstance;
28
+ }
29
+ if (typeof globalThis.fetch !== "undefined") {
30
+ fetchInstance = globalThis.fetch;
31
+ } else {
32
+ const { default: fetch } = await import("node-fetch");
33
+ fetchInstance = fetch;
34
+ }
35
+ return fetchInstance;
36
+ }
37
+ var DEFAULT_PORT = 8080;
38
+ var DEFAULT_PATH = "/ws";
39
+ var DEFAULT_MAX_CONNECTIONS = 100;
40
+ var DEFAULT_MAX_MESSAGE_SIZE = 10 * 1024 * 1024;
41
+ var RESPONSE_GRACE_MS = 5e3;
42
+ var CloudServer = class {
43
+ options;
44
+ connectorWss = null;
45
+ agentWss = null;
46
+ connectorHttpServer = null;
47
+ agentHttpServer = null;
48
+ connections = /* @__PURE__ */ new Map();
49
+ pendingRequests = /* @__PURE__ */ new Map();
50
+ heartbeatTimer = null;
51
+ constructor(options = {}) {
52
+ const port = options.port ?? DEFAULT_PORT;
53
+ this.options = {
54
+ port,
55
+ agentPort: options.agentPort ?? port + 1,
56
+ path: options.path ?? DEFAULT_PATH,
57
+ token: options.token,
58
+ heartbeatTimeout: options.heartbeatTimeout ?? DEFAULT_HEARTBEAT_TIMEOUT,
59
+ maxConnections: options.maxConnections ?? DEFAULT_MAX_CONNECTIONS,
60
+ maxMessageSize: options.maxMessageSize ?? DEFAULT_MAX_MESSAGE_SIZE,
61
+ requestTimeout: options.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT,
62
+ allowRemoteAgent: options.allowRemoteAgent ?? false
63
+ };
64
+ }
65
+ getToken() {
66
+ if (!this.options.token) {
67
+ this.options.token = this.generateRandomToken();
68
+ }
69
+ return this.options.token;
70
+ }
71
+ generateRandomToken() {
72
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
73
+ let token = "";
74
+ for (let i = 0; i < 32; i++) {
75
+ token += chars.charAt(Math.floor(Math.random() * chars.length));
76
+ }
77
+ return token;
78
+ }
79
+ async start() {
80
+ await this.createListener(this.options.agentPort, "127.0.0.1", true);
81
+ await this.createListener(this.options.port, "0.0.0.0", false);
82
+ const token = this.getToken();
83
+ const localWsUrl = `ws://localhost:${this.options.agentPort}${this.options.path}?token=${token}`;
84
+ let publicWsUrl;
85
+ const sandboxUrl = await this.getSandboxPublicUrl(this.options.port);
86
+ if (sandboxUrl) {
87
+ try {
88
+ const parsedUrl = new URL(sandboxUrl);
89
+ parsedUrl.protocol = "wss:";
90
+ parsedUrl.pathname = this.options.path;
91
+ parsedUrl.search = `?token=${token}`;
92
+ publicWsUrl = parsedUrl.toString();
93
+ } catch {
94
+ const httpsUrl = sandboxUrl.replace("https://", "wss://");
95
+ publicWsUrl = `${httpsUrl}${this.options.path}?token=${token}`;
96
+ }
97
+ } else {
98
+ const publicHost = process.env.CLOUD_SERVER_HOST || this.getPublicIP();
99
+ publicWsUrl = `ws://${publicHost}:${this.options.port}${this.options.path}?token=${token}`;
100
+ }
101
+ const connectorCommand = `npx @mearl/cloud-connector "${publicWsUrl}"`;
102
+ console.log(
103
+ `[CloudServer] Connector listener on 0.0.0.0:${this.options.port} (network); agent listener on 127.0.0.1:${this.options.agentPort} (local only)`
104
+ );
105
+ console.log(`[CloudServer] Connect from local machine:`);
106
+ console.log(` ${connectorCommand}`);
107
+ this.startHeartbeatCheck();
108
+ const info = {
109
+ pid: process.pid,
110
+ port: this.options.port,
111
+ path: this.options.path,
112
+ token,
113
+ localUrl: localWsUrl,
114
+ publicUrl: publicWsUrl,
115
+ connectorCommand,
116
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
117
+ };
118
+ this.writeConfigFile(info);
119
+ return info;
120
+ }
121
+ /**
122
+ * Stand up one HTTP+WebSocket listener. `fromLocal` tags every connection
123
+ * accepted here so handleRequest can pin command-issuing authority to the
124
+ * loopback listener.
125
+ */
126
+ createListener(port, host, fromLocal) {
127
+ return new Promise((resolve, reject) => {
128
+ const httpServer = createHttpServer((req, res) => {
129
+ console.log(`[CloudServer] HTTP request: ${req.method} ${req.url}`);
130
+ res.writeHead(404, { "Content-Type": "text/plain" });
131
+ res.end("Not Found - This is a WebSocket server");
132
+ });
133
+ const wss = new WebSocketServer({
134
+ server: httpServer,
135
+ path: this.options.path,
136
+ maxPayload: this.options.maxMessageSize
137
+ });
138
+ wss.on("connection", (ws, req) => {
139
+ console.log(
140
+ `[CloudServer] New ${fromLocal ? "agent" : "connector"} connection from ${req.socket.remoteAddress}, path: ${req.url}`
141
+ );
142
+ this.handleConnection(ws, req, fromLocal);
143
+ });
144
+ wss.on("error", (error) => {
145
+ console.error("[CloudServer] WebSocket server error:", error.message);
146
+ });
147
+ httpServer.on("error", (error) => {
148
+ console.error("[CloudServer] HTTP server error:", error.message);
149
+ reject(error);
150
+ });
151
+ if (fromLocal) {
152
+ this.agentWss = wss;
153
+ this.agentHttpServer = httpServer;
154
+ } else {
155
+ this.connectorWss = wss;
156
+ this.connectorHttpServer = httpServer;
157
+ }
158
+ httpServer.listen(port, host, () => {
159
+ resolve();
160
+ });
161
+ });
162
+ }
163
+ stop() {
164
+ return new Promise((resolve) => {
165
+ if (this.heartbeatTimer) {
166
+ clearInterval(this.heartbeatTimer);
167
+ this.heartbeatTimer = null;
168
+ }
169
+ for (const [ws] of this.connections) {
170
+ ws.close();
171
+ }
172
+ this.connections.clear();
173
+ this.pendingRequests.clear();
174
+ this.agentWss?.close();
175
+ this.connectorWss?.close();
176
+ this.agentWss = null;
177
+ this.connectorWss = null;
178
+ const servers = [this.agentHttpServer, this.connectorHttpServer].filter(
179
+ (s) => s !== null
180
+ );
181
+ this.agentHttpServer = null;
182
+ this.connectorHttpServer = null;
183
+ if (servers.length === 0) {
184
+ resolve();
185
+ return;
186
+ }
187
+ let remaining = servers.length;
188
+ for (const s of servers) {
189
+ s.close(() => {
190
+ if (--remaining <= 0) resolve();
191
+ });
192
+ }
193
+ });
194
+ }
195
+ handleConnection(ws, req, fromLocal) {
196
+ if (this.connections.size >= this.options.maxConnections) {
197
+ ws.close(1008, "Server is full");
198
+ return;
199
+ }
200
+ if (this.options.token) {
201
+ const url = new URL(req.url || "", `http://${req.headers.host}`);
202
+ const token = url.searchParams.get("token");
203
+ if (token !== this.options.token) {
204
+ ws.close(1008, "Unauthorized");
205
+ return;
206
+ }
207
+ }
208
+ const connId = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
209
+ const connection = {
210
+ ws,
211
+ id: connId,
212
+ lastHeartbeat: Date.now(),
213
+ isAgent: false,
214
+ fromLocal,
215
+ messageBuffer: ""
216
+ };
217
+ this.connections.set(ws, connection);
218
+ console.log(`[CloudServer] Client connected: ${connId} (total: ${this.connections.size})`);
219
+ ws.on("message", (data) => {
220
+ const conn = this.connections.get(ws);
221
+ if (!conn) return;
222
+ conn.messageBuffer += data.toString("utf-8");
223
+ if (conn.messageBuffer.length > MAX_BUFFER_SIZE) {
224
+ console.error(
225
+ `[CloudServer] Message buffer exceeds limit for ${connId}, closing connection`
226
+ );
227
+ ws.close(1008, "Message too large");
228
+ return;
229
+ }
230
+ let newlineIndex;
231
+ while ((newlineIndex = conn.messageBuffer.indexOf("\n")) !== -1) {
232
+ const line = conn.messageBuffer.slice(0, newlineIndex);
233
+ conn.messageBuffer = conn.messageBuffer.slice(newlineIndex + 1);
234
+ if (!line.trim()) continue;
235
+ try {
236
+ const message = JSON.parse(line);
237
+ this.handleMessage(ws, message, connId);
238
+ } catch (error) {
239
+ console.error(`[CloudServer] Failed to parse message from ${connId}:`, line, error);
240
+ }
241
+ }
242
+ });
243
+ ws.on("close", () => {
244
+ this.connections.delete(ws);
245
+ this.rejectPendingForWs(ws);
246
+ console.log(`[CloudServer] Client disconnected: ${connId} (total: ${this.connections.size})`);
247
+ });
248
+ ws.on("error", (error) => {
249
+ console.error(`[CloudServer] Connection error for ${connId}:`, error.message);
250
+ });
251
+ }
252
+ handleMessage(ws, message, connId) {
253
+ const conn = this.connections.get(ws);
254
+ if (!conn) return;
255
+ if (isHeartbeatMessage(message)) {
256
+ if (message.type === "ping") {
257
+ ws.send(
258
+ JSON.stringify({ type: "pong", timestamp: Date.now() }) + "\n"
259
+ );
260
+ conn.lastHeartbeat = Date.now();
261
+ } else if (message.type === "pong") {
262
+ conn.lastHeartbeat = Date.now();
263
+ }
264
+ return;
265
+ }
266
+ if (isCloudResponse(message)) {
267
+ this.handleResponse(ws, message, connId);
268
+ return;
269
+ }
270
+ if (isCloudMessage(message)) {
271
+ this.handleRequest(ws, message, connId);
272
+ return;
273
+ }
274
+ }
275
+ handleRequest(ws, message, _connId) {
276
+ const { id, action, data } = message;
277
+ const conn = this.connections.get(ws);
278
+ if (!conn) return;
279
+ if (!conn.fromLocal && !this.options.allowRemoteAgent) {
280
+ console.warn(
281
+ `[CloudServer] Rejected agent request from non-local connection ${conn.id} (action: ${action})`
282
+ );
283
+ ws.send(
284
+ JSON.stringify({
285
+ id,
286
+ success: false,
287
+ error: "Agent requests are only accepted on the local interface (127.0.0.1). Run cloud-client on the same machine as cloud-server, or start the server with --allow-remote-agent to override."
288
+ }) + "\n"
289
+ );
290
+ ws.close(1008, "Agent must be local");
291
+ return;
292
+ }
293
+ conn.isAgent = true;
294
+ const targetWs = this.findConnector();
295
+ if (!targetWs) {
296
+ ws.send(JSON.stringify({ id, success: false, error: "No local connector available" }) + "\n");
297
+ return;
298
+ }
299
+ const requestedTimeoutSec = Number(message.timeoutSec);
300
+ const timeoutSec = Number.isFinite(requestedTimeoutSec) && requestedTimeoutSec > 0 ? Math.min(requestedTimeoutSec, 3600) : resolveActionTimeoutSec(action, data, this.options.requestTimeout);
301
+ const timer = setTimeout(
302
+ () => {
303
+ this.pendingRequests.delete(id);
304
+ if (ws.readyState === WebSocket.OPEN) {
305
+ ws.send(JSON.stringify({ id, success: false, error: "Request timeout" }) + "\n");
306
+ }
307
+ },
308
+ timeoutSec * 1e3 + RESPONSE_GRACE_MS
309
+ );
310
+ this.pendingRequests.set(id, { ws, timer });
311
+ targetWs.send(
312
+ JSON.stringify({ id, action, data, browser: message.browser, timeoutSec }) + "\n"
313
+ );
314
+ }
315
+ handleResponse(ws, response, _connId) {
316
+ const conn = this.connections.get(ws);
317
+ if (!conn) return;
318
+ if (conn.isAgent) return;
319
+ const pending = this.pendingRequests.get(response.id);
320
+ if (!pending) return;
321
+ this.pendingRequests.delete(response.id);
322
+ clearTimeout(pending.timer);
323
+ if (pending.ws.readyState === WebSocket.OPEN) {
324
+ pending.ws.send(JSON.stringify(response) + "\n");
325
+ }
326
+ }
327
+ findConnector() {
328
+ for (const [ws, conn] of this.connections) {
329
+ if (!conn.isAgent && ws.readyState === WebSocket.OPEN) {
330
+ return ws;
331
+ }
332
+ }
333
+ return null;
334
+ }
335
+ rejectPendingForWs(ws) {
336
+ this.pendingRequests.forEach((pending, id) => {
337
+ if (pending.ws === ws) {
338
+ clearTimeout(pending.timer);
339
+ if (ws.readyState === WebSocket.OPEN) {
340
+ ws.send(JSON.stringify({ id, success: false, error: "Connection closed" }) + "\n");
341
+ }
342
+ this.pendingRequests.delete(id);
343
+ }
344
+ });
345
+ }
346
+ startHeartbeatCheck() {
347
+ if (this.heartbeatTimer) return;
348
+ this.heartbeatTimer = setInterval(() => {
349
+ const now = Date.now();
350
+ const timeoutMs = this.options.heartbeatTimeout * 1e3;
351
+ for (const [ws, conn] of this.connections) {
352
+ if (now - conn.lastHeartbeat > timeoutMs) {
353
+ console.log(`[CloudServer] Client ${conn.id} heartbeat timeout, closing connection`);
354
+ ws.close(1e3, "Heartbeat timeout");
355
+ }
356
+ }
357
+ }, 3e4);
358
+ }
359
+ getPublicIP() {
360
+ const interfaces = networkInterfaces();
361
+ for (const name of Object.keys(interfaces)) {
362
+ const iface = interfaces[name];
363
+ if (!iface) continue;
364
+ for (const addr of iface) {
365
+ if (addr.family === "IPv4" && !addr.internal) {
366
+ return addr.address;
367
+ }
368
+ }
369
+ }
370
+ return "localhost";
371
+ }
372
+ async getSandboxPublicUrl(port) {
373
+ if (!process.env.AONE_SANDBOX_ID) {
374
+ return null;
375
+ }
376
+ const mappingApiUrl = "http://localhost:58596/api/port-mapping";
377
+ try {
378
+ const fetch = await getFetch();
379
+ const response = await fetch(`${mappingApiUrl}?port=${port}`);
380
+ if (!response.ok) {
381
+ return null;
382
+ }
383
+ const data = await response.json();
384
+ if (data.success && data.url) {
385
+ return data.url;
386
+ }
387
+ } catch (error) {
388
+ console.error(
389
+ `[CloudServer] Failed to query sandbox port mapping:`,
390
+ error instanceof Error ? error.message : String(error)
391
+ );
392
+ }
393
+ return null;
394
+ }
395
+ writeConfigFile(info) {
396
+ try {
397
+ if (!existsSync(CONFIG_DIR)) {
398
+ mkdirSync(CONFIG_DIR, { recursive: true });
399
+ }
400
+ const config = { server: info.localUrl, ...info };
401
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
402
+ console.log(`[CloudServer] Config written to: ${CONFIG_FILE}`);
403
+ } catch (error) {
404
+ console.error("[CloudServer] Failed to write config file:", error);
405
+ }
406
+ }
407
+ getStats() {
408
+ let agents = 0;
409
+ let connectors = 0;
410
+ for (const conn of this.connections.values()) {
411
+ if (conn.isAgent) agents++;
412
+ else connectors++;
413
+ }
414
+ return {
415
+ connections: this.connections.size,
416
+ agents,
417
+ connectors,
418
+ pendingRequests: this.pendingRequests.size
419
+ };
420
+ }
421
+ };
422
+ export {
423
+ CloudServer
424
+ };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Directory holding cloud-server runtime files (config / log). Shares the single
3
+ * `~/.mearl` root (resolved cross-platform via `homedir()`) that
4
+ * daemon-core derives every other daemon path from.
5
+ */
6
+ export declare const CONFIG_DIR: string;
7
+ /**
8
+ * Connection config written by the running server and read by cloud-client.
9
+ * Keeps the `server` field for backward compatibility; daemon lifecycle
10
+ * commands additionally read `pid` / `port` / `startedAt` from here.
11
+ */
12
+ export declare const CONFIG_FILE: string;
13
+ /** Daemon stdout/stderr log file. */
14
+ export declare const LOG_FILE: string;
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@mearl/cloud-server",
3
+ "version": "2.0.0",
4
+ "description": "Cloud WebSocket server for Mearl — bridges cloud agents to local connectors",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "bin": {
15
+ "mearl-cloud-server": "./dist/cli.js"
16
+ },
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "keywords": [
21
+ "mearl",
22
+ "cloud-server",
23
+ "websocket"
24
+ ],
25
+ "license": "ISC",
26
+ "publishConfig": {
27
+ "registry": "https://registry.npmjs.org"
28
+ },
29
+ "dependencies": {
30
+ "node-fetch": "^3.3.2",
31
+ "ws": "^8.18.0",
32
+ "@mearl/client": "2.0.0",
33
+ "@mearl/cloud-types": "2.0.0",
34
+ "@mearl/daemon-core": "2.0.0"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^24.9.1",
38
+ "@types/ws": "^8.5.13",
39
+ "esbuild": "^0.25.2",
40
+ "typescript": "^5.8.3"
41
+ },
42
+ "scripts": {
43
+ "build": "rm -rf dist/ && node build.mjs",
44
+ "dev": "tsc --watch",
45
+ "typecheck": "tsc --noEmit",
46
+ "test": "vitest run"
47
+ }
48
+ }