@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 +214 -0
- package/dist/__tests__/routing.test.d.ts +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +609 -0
- package/dist/daemon.d.ts +22 -0
- package/dist/index.d.ts +65 -0
- package/dist/index.js +424 -0
- package/dist/paths.d.ts +14 -0
- package/package.json +48 -0
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
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
|
+
});
|
package/dist/daemon.d.ts
ADDED
|
@@ -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>;
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
};
|
package/dist/paths.d.ts
ADDED
|
@@ -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
|
+
}
|