@cjwddz/mirror 2.0.9 → 2.0.11
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 +24 -242
- package/dist/cli/index.js +1 -1
- package/dist/core/http-tunnel-client.d.ts +11 -12
- package/dist/core/http-tunnel-client.d.ts.map +1 -1
- package/dist/core/http-tunnel-client.js +128 -103
- package/dist/core/http-tunnel-client.js.map +1 -1
- package/dist/core/http-tunnel-protocol.d.ts +69 -2
- package/dist/core/http-tunnel-protocol.d.ts.map +1 -1
- package/dist/core/http-tunnel-protocol.js +38 -2
- package/dist/core/http-tunnel-protocol.js.map +1 -1
- package/dist/core/http-tunnel-server.d.ts +9 -11
- package/dist/core/http-tunnel-server.d.ts.map +1 -1
- package/dist/core/http-tunnel-server.js +103 -97
- package/dist/core/http-tunnel-server.js.map +1 -1
- package/dist/test/index.test.js +17 -1
- package/dist/test/index.test.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,15 +1,6 @@
|
|
|
1
|
-
# Mirror
|
|
1
|
+
# Mirror
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
## 特性
|
|
6
|
-
|
|
7
|
-
- ✅ 支持多个本地服务同时代理
|
|
8
|
-
- ✅ 基于路径长度的优先级匹配(路径越长优先级越高)
|
|
9
|
-
- ✅ 规则持久化存储
|
|
10
|
-
- ✅ Token 认证
|
|
11
|
-
- ✅ 单客户端连接(新连接自动踢出旧连接)
|
|
12
|
-
- ✅ 支持 WebSocket 协议
|
|
3
|
+
HTTP 隧道:把本地服务通过公网服务端暴露出去,支持多域名/多路径规则。
|
|
13
4
|
|
|
14
5
|
## 安装
|
|
15
6
|
|
|
@@ -17,255 +8,46 @@
|
|
|
17
8
|
npm install -g @cjwddz/mirror
|
|
18
9
|
```
|
|
19
10
|
|
|
20
|
-
##
|
|
21
|
-
|
|
22
|
-
### 1. 启动服务端
|
|
11
|
+
## 使用
|
|
23
12
|
|
|
24
|
-
|
|
13
|
+
**服务端**(公网机器):
|
|
25
14
|
|
|
26
15
|
```bash
|
|
27
16
|
mirror server -p 80 -t 3000 --token your-token
|
|
28
17
|
```
|
|
29
18
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
- `-p, --port`: HTTP 服务端口(默认 80)
|
|
33
|
-
- `-t, --tunnel-port`: WebSocket 隧道端口(默认 3000,通常由 Nginx 反向代理到 443)
|
|
34
|
-
- `--host`: 监听地址(默认 0.0.0.0)
|
|
35
|
-
- `--token`: 认证令牌(可选)
|
|
36
|
-
|
|
37
|
-
### 2. 添加代理规则
|
|
38
|
-
|
|
39
|
-
在本地机器上添加规则:
|
|
40
|
-
|
|
41
|
-
```bash
|
|
42
|
-
# 添加 API 规则(路径较长,优先级高)
|
|
43
|
-
mirror add example.com/api localhost:3001
|
|
44
|
-
|
|
45
|
-
# 添加用户 API 规则(路径最长,优先级最高)
|
|
46
|
-
mirror add example.com/api/users localhost:3002
|
|
47
|
-
|
|
48
|
-
# 添加前端规则(根路径,优先级低)
|
|
49
|
-
mirror add example.com localhost:5173
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
### 3. 连接服务端
|
|
19
|
+
**客户端**(本机):
|
|
53
20
|
|
|
54
21
|
```bash
|
|
22
|
+
# 连接
|
|
55
23
|
mirror link mirror.tri-bank.online --token your-token
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
连接后,规则会自动同步到服务端。
|
|
59
|
-
|
|
60
|
-
### 4. 查看状态
|
|
61
|
-
|
|
62
|
-
```bash
|
|
63
|
-
mirror status
|
|
64
|
-
```
|
|
65
24
|
|
|
66
|
-
|
|
25
|
+
# 添加规则(域名+路径 -> 本地地址)
|
|
26
|
+
mirror add example.com/api localhost:3001
|
|
27
|
+
mirror add example.com localhost:5173
|
|
28
|
+
# 支持写 https://domain/path,协议会被忽略
|
|
29
|
+
mirror add https://test1.example.com/foo/ 127.0.0.1:8082
|
|
67
30
|
|
|
68
|
-
|
|
69
|
-
Mirror - Connection Status
|
|
70
|
-
==========================
|
|
71
|
-
|
|
72
|
-
✓ Mirror client is running
|
|
73
|
-
PID: 12345
|
|
74
|
-
|
|
75
|
-
Server Configuration:
|
|
76
|
-
Server: mirror.tri-bank.online
|
|
77
|
-
Token: your-toke...
|
|
78
|
-
|
|
79
|
-
Proxy Rules (3 configured):
|
|
80
|
-
[3] example.com/api/users -> localhost:3002 (高)
|
|
81
|
-
[1] example.com/api -> localhost:3001 (高)
|
|
82
|
-
[2] example.com/ -> localhost:5173 (低)
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
### 5. 查看规则
|
|
86
|
-
|
|
87
|
-
```bash
|
|
31
|
+
# 查看 / 删除规则
|
|
88
32
|
mirror list
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
输出示例:
|
|
92
|
-
|
|
93
|
-
```
|
|
94
|
-
Mirror - Proxy Rules
|
|
95
|
-
====================
|
|
96
|
-
|
|
97
|
-
Total: 3 rule(s)
|
|
98
|
-
|
|
99
|
-
[3] example.com/api/users -> localhost:3002
|
|
100
|
-
Priority: 高(路径规则)
|
|
101
|
-
Created: 2/18/2026, 11:57:20 AM
|
|
102
|
-
|
|
103
|
-
[1] example.com/api -> localhost:3001
|
|
104
|
-
Priority: 高(路径规则)
|
|
105
|
-
Created: 2/18/2026, 11:57:14 AM
|
|
106
|
-
|
|
107
|
-
[2] example.com/ -> localhost:5173
|
|
108
|
-
Priority: 低(域名规则)
|
|
109
|
-
Created: 2/18/2026, 11:57:17 AM
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
### 6. 删除规则
|
|
113
|
-
|
|
114
|
-
```bash
|
|
115
|
-
# 删除指定规则
|
|
116
|
-
mirror remove 1
|
|
117
|
-
|
|
118
|
-
# 删除所有规则
|
|
119
|
-
mirror remove
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
### 7. 断开连接
|
|
123
|
-
|
|
124
|
-
```bash
|
|
125
|
-
mirror stop
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
## 命令参考
|
|
129
|
-
|
|
130
|
-
### `mirror link <server> [options]`
|
|
131
|
-
|
|
132
|
-
连接到 Mirror 服务端。
|
|
133
|
-
|
|
134
|
-
- `server`: 服务端地址(如 `mirror.tri-bank.online`)
|
|
135
|
-
- `-t, --token`: 认证令牌
|
|
136
|
-
|
|
137
|
-
### `mirror add <domain/path> <target>`
|
|
138
|
-
|
|
139
|
-
添加代理规则。
|
|
140
|
-
|
|
141
|
-
- `domain/path`: 域名和路径组合(如 `example.com/api`)
|
|
142
|
-
- `target`: 目标服务地址(如 `localhost:3001`)
|
|
143
|
-
|
|
144
|
-
**规则格式说明:**
|
|
145
|
-
|
|
146
|
-
- 域名和路径写在一起,使用 `/` 分隔
|
|
147
|
-
- 路径可以省略,省略时默认为 `/`
|
|
148
|
-
- 示例:
|
|
149
|
-
- `example.com/api` - 匹配 `example.com/api` 开头的请求
|
|
150
|
-
- `example.com` 或 `example.com/` - 匹配 `example.com` 域名的所有请求
|
|
151
|
-
|
|
152
|
-
### `mirror remove [ruleId]`
|
|
153
|
-
|
|
154
|
-
删除代理规则。
|
|
155
|
-
|
|
156
|
-
- `ruleId`: 规则 ID(可选,不指定则删除所有规则)
|
|
157
|
-
|
|
158
|
-
### `mirror list`
|
|
159
|
-
|
|
160
|
-
查看所有代理规则。
|
|
161
|
-
|
|
162
|
-
### `mirror status`
|
|
163
|
-
|
|
164
|
-
查看当前连接状态和配置。
|
|
165
|
-
|
|
166
|
-
### `mirror stop`
|
|
167
|
-
|
|
168
|
-
断开与服务端的连接。
|
|
169
|
-
|
|
170
|
-
### `mirror sync`
|
|
171
|
-
|
|
172
|
-
请求后台 link 进程将当前规则同步到服务端。若先执行了 `mirror add` / `mirror remove` 再在另一终端执行本命令,可手动触发一次同步(通常 add/remove 会自动写同步标记)。
|
|
173
|
-
|
|
174
|
-
### `mirror server [options]`
|
|
175
|
-
|
|
176
|
-
启动 HTTP 隧道服务端。
|
|
177
|
-
|
|
178
|
-
- `-p, --port`: HTTP 服务端口(默认 80)
|
|
179
|
-
- `-t, --tunnel-port`: WebSocket 隧道端口(默认 3000)
|
|
180
|
-
- `--host`: 监听地址(默认 0.0.0.0)
|
|
181
|
-
- `--token`: 认证令牌(可选)
|
|
182
|
-
- `--timeout`: 请求超时时间(毫秒,默认 30000)
|
|
183
|
-
|
|
184
|
-
## 规则匹配逻辑
|
|
185
|
-
|
|
186
|
-
1. 规则按路径长度降序排序(路径越长优先级越高)
|
|
187
|
-
2. 同长度的规则,后添加的优先级更高
|
|
188
|
-
3. 路径规则:请求路径以规则路径开头即匹配
|
|
189
|
-
4. 根路径规则(`/`):匹配该域名的所有路径
|
|
190
|
-
|
|
191
|
-
### 示例
|
|
192
|
-
|
|
193
|
-
假设有以下规则:
|
|
33
|
+
mirror remove 1 # 删单条
|
|
34
|
+
mirror remove # 删全部
|
|
194
35
|
|
|
36
|
+
mirror status # 连接状态
|
|
37
|
+
mirror stop # 断开
|
|
195
38
|
```
|
|
196
|
-
[1] example.com/api -> localhost:3001 (路径长度: 4)
|
|
197
|
-
[2] example.com/ -> localhost:5173 (路径长度: 1)
|
|
198
|
-
[3] example.com/api/users -> localhost:3002 (路径长度: 10)
|
|
199
|
-
```
|
|
200
|
-
|
|
201
|
-
请求匹配结果(按优先级排序):
|
|
202
|
-
|
|
203
|
-
1. `example.com/api/users/v1` → `localhost:3002`(匹配规则 3,路径长度 10)
|
|
204
|
-
2. `example.com/api/products` → `localhost:3001`(匹配规则 1,路径长度 4)
|
|
205
|
-
3. `example.com/` → `localhost:5173`(匹配规则 2,路径长度 1)
|
|
206
|
-
4. `example.com/about` → `localhost:5173`(匹配规则 2,路径长度 1)
|
|
207
|
-
|
|
208
|
-
## 配置文件
|
|
209
|
-
|
|
210
|
-
规则存储在 `~/.mirror/rules.json` 中:
|
|
211
|
-
|
|
212
|
-
```json
|
|
213
|
-
{
|
|
214
|
-
"server": "mirror.tri-bank.online",
|
|
215
|
-
"token": "your-token",
|
|
216
|
-
"rules": [
|
|
217
|
-
{
|
|
218
|
-
"id": 1,
|
|
219
|
-
"domain": "example.com",
|
|
220
|
-
"path": "/api",
|
|
221
|
-
"target": "localhost:3001",
|
|
222
|
-
"priority": "high",
|
|
223
|
-
"createdAt": "2026-02-18T03:50:18.627Z"
|
|
224
|
-
}
|
|
225
|
-
]
|
|
226
|
-
}
|
|
227
|
-
```
|
|
228
|
-
|
|
229
|
-
断开连接后规则会保留,下次连接时自动同步。
|
|
230
|
-
|
|
231
|
-
## 进程管理
|
|
232
|
-
|
|
233
|
-
Mirror 客户端使用锁文件机制确保同一时间只有一个实例在运行。锁文件位于 `~/.mirror/.lock`。
|
|
234
|
-
|
|
235
|
-
- 如果尝试启动第二个实例,会提示已有进程在运行
|
|
236
|
-
- 如果进程异常退出,锁文件会被自动清理
|
|
237
|
-
- 使用 `mirror stop` 命令可以安全停止运行中的客户端
|
|
238
|
-
|
|
239
|
-
## 故障排查
|
|
240
|
-
|
|
241
|
-
### 配置了规则但通过 https://test1.xxx.com/ 无法访问
|
|
242
|
-
|
|
243
|
-
常见原因:**访问代理域名(如 test1.tri-bank.online)的请求没有到达 Mirror 服务端的 HTTP 端口**。
|
|
244
|
-
|
|
245
|
-
服务端有两个端口:
|
|
246
|
-
|
|
247
|
-
- **HTTP 端口(默认 80)**:接收浏览器/客户端对代理域名的请求(如 `https://test1.tri-bank.online/`),按 Host 匹配规则并转发到你的本地服务。
|
|
248
|
-
- **WebSocket 端口(默认 3000)**:仅用于客户端 `mirror link` 的隧道连接(如 `wss://mirror.tri-bank.online`)。
|
|
249
|
-
|
|
250
|
-
因此 Nginx 需要区分配置:
|
|
251
|
-
|
|
252
|
-
1. **mirror.tri-bank.online**(或你的隧道域名)→ 反向代理到服务端的 **3000** 端口(WebSocket)。
|
|
253
|
-
2. **test1.tri-bank.online**(及所有要代理的子域名)→ 反向代理到服务端的 **80** 端口(HTTP)。
|
|
254
|
-
|
|
255
|
-
若只配置了隧道域名指向 3000,而 test1 等域名未指向 80,则访问 test1 的请求不会进入 Mirror,就会出现“无法访问”。请在服务端 Nginx 中为 test1.tri-bank.online 增加 `server_name` 并 `proxy_pass http://127.0.0.1:80`(或你实际使用的 HTTP 端口)。参见仓库内 `nginx.example.conf` 中的「代理域名 → HTTP 80」示例。
|
|
256
39
|
|
|
257
|
-
|
|
40
|
+
## 规则匹配
|
|
258
41
|
|
|
259
|
-
-
|
|
260
|
-
-
|
|
42
|
+
- 规则按**路径长度**从长到短匹配,路径越长优先级越高。
|
|
43
|
+
- `domain/path` 支持:`example.com/api`、`example.com`(等价于根路径 `/`)、或 `https://example.com/path`(协议会在解析时去掉)。
|
|
44
|
+
- 规则存于 `~/.mirror/rules.json`,增删后下次请求即生效。
|
|
261
45
|
|
|
262
|
-
##
|
|
46
|
+
## 常见问题
|
|
263
47
|
|
|
264
|
-
-
|
|
265
|
-
-
|
|
266
|
-
- 建议在生产环境使用 HTTPS
|
|
267
|
-
- WebSocket 隧道使用 wss:// 协议,需要 SSL 证书
|
|
48
|
+
- **访问代理域名 404**:确认 Nginx 里**代理域名**(如 test1.xxx.com)反代到服务端 **HTTP 端口(默认 80)**;**隧道域名**(mirror.xxx.com)反代到 **WebSocket 端口(默认 3000)**。
|
|
49
|
+
- **匹配不到规则**:确认本机目标服务已启动,且 `mirror status` 中规则与预期一致。
|
|
268
50
|
|
|
269
|
-
##
|
|
51
|
+
## License
|
|
270
52
|
|
|
271
53
|
MIT
|
package/dist/cli/index.js
CHANGED
|
@@ -89,7 +89,7 @@ program
|
|
|
89
89
|
.option('-t, --tunnel-port <port>', 'WebSocket 隧道端口(默认 3000)', '3000')
|
|
90
90
|
.option('--host <host>', '监听地址(默认 0.0.0.0)', '0.0.0.0')
|
|
91
91
|
.option('--token <token>', '认证令牌(可选)')
|
|
92
|
-
.option('--timeout <ms>', '请求超时时间(毫秒)', '
|
|
92
|
+
.option('--timeout <ms>', '请求超时时间(毫秒)', '60000')
|
|
93
93
|
.action((options) => serverCommand({
|
|
94
94
|
port: parseInt(options.port, 10),
|
|
95
95
|
tunnelPort: parseInt(options.tunnelPort, 10),
|
|
@@ -14,9 +14,6 @@ export interface HttpTunnelClientOptions {
|
|
|
14
14
|
/** 可选:未提供 getTarget 时或 WS 升级时的默认目标 base URL */
|
|
15
15
|
targetUrl?: string;
|
|
16
16
|
}
|
|
17
|
-
/**
|
|
18
|
-
* HTTP 隧道客户端类
|
|
19
|
-
*/
|
|
20
17
|
export declare class HttpTunnelClient {
|
|
21
18
|
private targetUrl;
|
|
22
19
|
private tunnelWs;
|
|
@@ -24,19 +21,25 @@ export declare class HttpTunnelClient {
|
|
|
24
21
|
private httpAgent;
|
|
25
22
|
private httpsAgent;
|
|
26
23
|
private wsClients;
|
|
24
|
+
private pendingStreamRequests;
|
|
27
25
|
constructor(options: HttpTunnelClientOptions);
|
|
28
26
|
/**
|
|
29
|
-
*
|
|
27
|
+
* 处理隧道消息(支持二进制 body 帧与 JSON 控制消息)
|
|
30
28
|
*/
|
|
31
29
|
private handleTunnelMessage;
|
|
30
|
+
private handleRequestBodyChunk;
|
|
31
|
+
private handleHttpRequestChunk;
|
|
32
|
+
private handleHttpRequestEnd;
|
|
32
33
|
/** 目标未写协议时拼成完整 URL;默认 http(本机服务多为 HTTP,入口 HTTPS 由 Nginx 终结) */
|
|
33
34
|
private normalizeTargetUrl;
|
|
34
35
|
/**
|
|
35
|
-
*
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
* 流式:处理 HTTP 请求开始(创建本地请求,不写 body,等 CHUNK/END)
|
|
37
|
+
*/
|
|
38
|
+
private handleHttpRequestStart;
|
|
39
|
+
/**
|
|
40
|
+
* 创建流式 HTTP 请求:收到 response 后发送 START,再流式发送 body 帧与 END
|
|
38
41
|
*/
|
|
39
|
-
private
|
|
42
|
+
private createStreamingHttpRequest;
|
|
40
43
|
/**
|
|
41
44
|
* 处理 WebSocket 升级请求;若有 getTarget 则按 Host/path 解析目标(与 HTTP 规则一致)
|
|
42
45
|
*/
|
|
@@ -49,10 +52,6 @@ export declare class HttpTunnelClient {
|
|
|
49
52
|
* 处理 WebSocket 关闭消息
|
|
50
53
|
*/
|
|
51
54
|
private handleWsClose;
|
|
52
|
-
/**
|
|
53
|
-
* 发送 HTTP 请求到本地服务
|
|
54
|
-
*/
|
|
55
|
-
private sendHttpRequest;
|
|
56
55
|
/**
|
|
57
56
|
* 构建目标 URL
|
|
58
57
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"http-tunnel-client.d.ts","sourceRoot":"","sources":["../../src/core/http-tunnel-client.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAIH,OAAO,EAAE,SAAS,EAAE,MAAM,IAAI,CAAC;
|
|
1
|
+
{"version":3,"file":"http-tunnel-client.d.ts","sourceRoot":"","sources":["../../src/core/http-tunnel-client.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAIH,OAAO,EAAE,SAAS,EAAE,MAAM,IAAI,CAAC;AAoB/B;;;GAGG;AACH,MAAM,WAAW,uBAAuB;IACtC,QAAQ,EAAE,SAAS,CAAC;IACpB,yCAAyC;IACzC,SAAS,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;IAChE,+CAA+C;IAC/C,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAUD,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,SAAS,CAAM;IACvB,OAAO,CAAC,QAAQ,CAAY;IAC5B,OAAO,CAAC,SAAS,CAA+D;IAChF,OAAO,CAAC,SAAS,CAAa;IAC9B,OAAO,CAAC,UAAU,CAAc;IAChC,OAAO,CAAC,SAAS,CAAqC;IACtD,OAAO,CAAC,qBAAqB,CAAgD;gBAEjE,OAAO,EAAE,uBAAuB;IAwC5C;;OAEG;IACH,OAAO,CAAC,mBAAmB;IAoD3B,OAAO,CAAC,sBAAsB;IAO9B,OAAO,CAAC,sBAAsB;IAO9B,OAAO,CAAC,oBAAoB;IAQ5B,+DAA+D;IAC/D,OAAO,CAAC,kBAAkB;IAK1B;;OAEG;IACH,OAAO,CAAC,sBAAsB;IA+C9B;;OAEG;IACH,OAAO,CAAC,0BAA0B;IAyElC;;OAEG;YACW,eAAe;IAiF7B;;OAEG;IACH,OAAO,CAAC,YAAY;IAWpB;;OAEG;IACH,OAAO,CAAC,aAAa;IASrB;;OAEG;IACH,OAAO,CAAC,cAAc;IAmCtB;;OAEG;IACH,OAAO,CAAC,gBAAgB;IAYxB;;OAEG;IACH,OAAO,CAAC,MAAM;IASd;;OAEG;IACH,KAAK,IAAI,IAAI;CAQd"}
|
|
@@ -5,10 +5,7 @@
|
|
|
5
5
|
import http from 'http';
|
|
6
6
|
import https from 'https';
|
|
7
7
|
import { WebSocket } from 'ws';
|
|
8
|
-
import { isHttpTunnelMessage, } from './http-tunnel-protocol.js';
|
|
9
|
-
/**
|
|
10
|
-
* HTTP 隧道客户端类
|
|
11
|
-
*/
|
|
8
|
+
import { isHttpTunnelMessage, decodeBodyChunkFrame, encodeBodyChunkFrame, isBodyChunkFrame, BODY_CHUNK_REQUEST, BODY_CHUNK_RESPONSE, } from './http-tunnel-protocol.js';
|
|
12
9
|
export class HttpTunnelClient {
|
|
13
10
|
targetUrl;
|
|
14
11
|
tunnelWs;
|
|
@@ -16,6 +13,7 @@ export class HttpTunnelClient {
|
|
|
16
13
|
httpAgent;
|
|
17
14
|
httpsAgent;
|
|
18
15
|
wsClients = new Map(); // requestId -> WebSocket client
|
|
16
|
+
pendingStreamRequests = new Map();
|
|
19
17
|
constructor(options) {
|
|
20
18
|
this.targetUrl = new URL(options.targetUrl ?? 'http://localhost');
|
|
21
19
|
this.tunnelWs = options.tunnelWs;
|
|
@@ -51,11 +49,17 @@ export class HttpTunnelClient {
|
|
|
51
49
|
});
|
|
52
50
|
}
|
|
53
51
|
/**
|
|
54
|
-
*
|
|
52
|
+
* 处理隧道消息(支持二进制 body 帧与 JSON 控制消息)
|
|
55
53
|
*/
|
|
56
54
|
handleTunnelMessage(data) {
|
|
55
|
+
const buf = typeof data === 'string' ? Buffer.from(data, 'utf8') : data;
|
|
56
|
+
if (Buffer.isBuffer(buf) && buf.length >= 1 && isBodyChunkFrame(buf)) {
|
|
57
|
+
const decoded = decodeBodyChunkFrame(buf, BODY_CHUNK_REQUEST);
|
|
58
|
+
if (decoded)
|
|
59
|
+
this.handleRequestBodyChunk(decoded.requestId, decoded.payload);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
57
62
|
try {
|
|
58
|
-
// 正确处理 Buffer 和 string 类型的数据
|
|
59
63
|
const messageStr = typeof data === 'string' ? data : data.toString('utf8');
|
|
60
64
|
const message = JSON.parse(messageStr);
|
|
61
65
|
if (!isHttpTunnelMessage(message)) {
|
|
@@ -64,10 +68,15 @@ export class HttpTunnelClient {
|
|
|
64
68
|
}
|
|
65
69
|
switch (message.type) {
|
|
66
70
|
case 'WELCOME':
|
|
67
|
-
// 忽略欢迎消息
|
|
68
71
|
break;
|
|
69
|
-
case '
|
|
70
|
-
this.
|
|
72
|
+
case 'HTTP_REQUEST_START':
|
|
73
|
+
this.handleHttpRequestStart(message);
|
|
74
|
+
break;
|
|
75
|
+
case 'HTTP_REQUEST_CHUNK':
|
|
76
|
+
this.handleHttpRequestChunk(message);
|
|
77
|
+
break;
|
|
78
|
+
case 'HTTP_REQUEST_END':
|
|
79
|
+
this.handleHttpRequestEnd(message);
|
|
71
80
|
break;
|
|
72
81
|
case 'WS_UPGRADE':
|
|
73
82
|
this.handleWsUpgrade(message);
|
|
@@ -86,6 +95,25 @@ export class HttpTunnelClient {
|
|
|
86
95
|
console.error('[HTTP Tunnel Client] Error handling tunnel message:', error);
|
|
87
96
|
}
|
|
88
97
|
}
|
|
98
|
+
handleRequestBodyChunk(requestId, payload) {
|
|
99
|
+
const pending = this.pendingStreamRequests.get(requestId);
|
|
100
|
+
if (pending?.req && !pending.req.destroyed) {
|
|
101
|
+
pending.req.write(payload);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
handleHttpRequestChunk(message) {
|
|
105
|
+
const pending = this.pendingStreamRequests.get(message.requestId);
|
|
106
|
+
if (pending?.req && !pending.req.destroyed && message.data) {
|
|
107
|
+
pending.req.write(Buffer.from(message.data, 'base64'));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
handleHttpRequestEnd(message) {
|
|
111
|
+
const pending = this.pendingStreamRequests.get(message.requestId);
|
|
112
|
+
if (pending?.req && !pending.req.destroyed) {
|
|
113
|
+
this.pendingStreamRequests.delete(message.requestId);
|
|
114
|
+
pending.req.end();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
89
117
|
/** 目标未写协议时拼成完整 URL;默认 http(本机服务多为 HTTP,入口 HTTPS 由 Nginx 终结) */
|
|
90
118
|
normalizeTargetUrl(resolved) {
|
|
91
119
|
if (resolved.startsWith('http://') || resolved.startsWith('https://'))
|
|
@@ -93,62 +121,106 @@ export class HttpTunnelClient {
|
|
|
93
121
|
return `http://${resolved}`;
|
|
94
122
|
}
|
|
95
123
|
/**
|
|
96
|
-
*
|
|
97
|
-
* 若消息无 target 且配置了 getTarget,则由客户端根据 Host/path 匹配规则得到目标。
|
|
98
|
-
* 目标未写协议时默认 http(本地服务通常为 HTTP)。
|
|
124
|
+
* 流式:处理 HTTP 请求开始(创建本地请求,不写 body,等 CHUNK/END)
|
|
99
125
|
*/
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
targetUrlStr = this.normalizeTargetUrl(resolved);
|
|
124
|
-
}
|
|
125
|
-
else {
|
|
126
|
-
targetUrlStr = this.buildTargetUrl(requestUrl);
|
|
126
|
+
handleHttpRequestStart(message) {
|
|
127
|
+
const { requestId, method, url: requestUrl, headers, target } = message;
|
|
128
|
+
let targetUrlStr;
|
|
129
|
+
if (target) {
|
|
130
|
+
targetUrlStr = target.startsWith('http') ? target : `http://${target}`;
|
|
131
|
+
}
|
|
132
|
+
else if (this.getTarget) {
|
|
133
|
+
const host = headers?.host ?? '';
|
|
134
|
+
const domain = host.split(':')[0];
|
|
135
|
+
const pathname = new URL(requestUrl || '/', `http://${host}`).pathname;
|
|
136
|
+
const resolved = this.getTarget(domain, pathname);
|
|
137
|
+
if (resolved == null) {
|
|
138
|
+
this.tunnelWs.send(JSON.stringify({
|
|
139
|
+
type: 'HTTP_RESPONSE_START',
|
|
140
|
+
requestId,
|
|
141
|
+
statusCode: 404,
|
|
142
|
+
statusMessage: 'Not Found',
|
|
143
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
144
|
+
}));
|
|
145
|
+
this.tunnelWs.send(JSON.stringify({ type: 'HTTP_RESPONSE_END', requestId }));
|
|
146
|
+
return;
|
|
127
147
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
headers: this.normalizeHeaders(response.headers),
|
|
138
|
-
body: response.body ? response.body.toString('base64') : undefined,
|
|
139
|
-
};
|
|
140
|
-
this.tunnelWs.send(JSON.stringify(responseMessage));
|
|
148
|
+
targetUrlStr = this.normalizeTargetUrl(resolved);
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
targetUrlStr = this.buildTargetUrl(requestUrl);
|
|
152
|
+
}
|
|
153
|
+
const fullTargetUrl = new URL(requestUrl || '/', targetUrlStr).href;
|
|
154
|
+
try {
|
|
155
|
+
const req = this.createStreamingHttpRequest(requestId, method, fullTargetUrl, headers);
|
|
156
|
+
this.pendingStreamRequests.set(requestId, { req });
|
|
141
157
|
}
|
|
142
158
|
catch (error) {
|
|
143
|
-
console.error('[HTTP Tunnel Client] Error
|
|
159
|
+
console.error('[HTTP Tunnel Client] Error creating HTTP request:', error);
|
|
144
160
|
this.tunnelWs.send(JSON.stringify({
|
|
145
161
|
type: 'TUNNEL_ERROR',
|
|
146
|
-
requestId
|
|
162
|
+
requestId,
|
|
147
163
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
148
164
|
code: 'REQUEST_ERROR',
|
|
149
165
|
}));
|
|
150
166
|
}
|
|
151
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* 创建流式 HTTP 请求:收到 response 后发送 START,再流式发送 body 帧与 END
|
|
170
|
+
*/
|
|
171
|
+
createStreamingHttpRequest(requestId, method, targetUrl, headers) {
|
|
172
|
+
const urlObj = new URL(targetUrl);
|
|
173
|
+
const isHttps = urlObj.protocol === 'https:';
|
|
174
|
+
const httpModule = isHttps ? https : http;
|
|
175
|
+
const forwardHeaders = { ...headers };
|
|
176
|
+
const forwardedProto = isHttps ? 'https' : 'http';
|
|
177
|
+
forwardHeaders['x-forwarded-proto'] = forwardedProto;
|
|
178
|
+
forwardHeaders['x-forwarded-scheme'] = forwardedProto;
|
|
179
|
+
const options = {
|
|
180
|
+
hostname: urlObj.hostname,
|
|
181
|
+
port: urlObj.port || (isHttps ? 443 : 80),
|
|
182
|
+
path: urlObj.pathname + urlObj.search,
|
|
183
|
+
method,
|
|
184
|
+
headers: forwardHeaders,
|
|
185
|
+
agent: isHttps ? this.httpsAgent : this.httpAgent,
|
|
186
|
+
rejectUnauthorized: false,
|
|
187
|
+
};
|
|
188
|
+
const req = httpModule.request(options, (res) => {
|
|
189
|
+
this.tunnelWs.send(JSON.stringify({
|
|
190
|
+
type: 'HTTP_RESPONSE_START',
|
|
191
|
+
requestId,
|
|
192
|
+
statusCode: res.statusCode || 200,
|
|
193
|
+
statusMessage: res.statusMessage || 'OK',
|
|
194
|
+
headers: this.normalizeHeaders(res.headers),
|
|
195
|
+
}));
|
|
196
|
+
res.on('data', (chunk) => {
|
|
197
|
+
const frame = encodeBodyChunkFrame(BODY_CHUNK_RESPONSE, requestId, chunk);
|
|
198
|
+
this.tunnelWs.send(frame);
|
|
199
|
+
});
|
|
200
|
+
res.on('end', () => {
|
|
201
|
+
this.tunnelWs.send(JSON.stringify({ type: 'HTTP_RESPONSE_END', requestId }));
|
|
202
|
+
});
|
|
203
|
+
res.on('error', (err) => {
|
|
204
|
+
console.error('[HTTP Tunnel Client] Response stream error:', err);
|
|
205
|
+
this.tunnelWs.send(JSON.stringify({
|
|
206
|
+
type: 'TUNNEL_ERROR',
|
|
207
|
+
requestId,
|
|
208
|
+
error: err.message,
|
|
209
|
+
code: 'RESPONSE_ERROR',
|
|
210
|
+
}));
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
req.on('error', (error) => {
|
|
214
|
+
this.pendingStreamRequests.delete(requestId);
|
|
215
|
+
this.tunnelWs.send(JSON.stringify({
|
|
216
|
+
type: 'TUNNEL_ERROR',
|
|
217
|
+
requestId,
|
|
218
|
+
error: error.message,
|
|
219
|
+
code: 'REQUEST_ERROR',
|
|
220
|
+
}));
|
|
221
|
+
});
|
|
222
|
+
return req;
|
|
223
|
+
}
|
|
152
224
|
/**
|
|
153
225
|
* 处理 WebSocket 升级请求;若有 getTarget 则按 Host/path 解析目标(与 HTTP 规则一致)
|
|
154
226
|
*/
|
|
@@ -247,53 +319,6 @@ export class HttpTunnelClient {
|
|
|
247
319
|
this.wsClients.delete(message.requestId);
|
|
248
320
|
}
|
|
249
321
|
}
|
|
250
|
-
/**
|
|
251
|
-
* 发送 HTTP 请求到本地服务
|
|
252
|
-
*/
|
|
253
|
-
sendHttpRequest(method, targetUrl, headers, body) {
|
|
254
|
-
return new Promise((resolve, reject) => {
|
|
255
|
-
const urlObj = new URL(targetUrl);
|
|
256
|
-
const isHttps = urlObj.protocol === 'https:';
|
|
257
|
-
const httpModule = isHttps ? https : http;
|
|
258
|
-
// 移除可能导致问题的头部;转发到本机时协议统一为实际连接协议(http/https),避免本机误用 https
|
|
259
|
-
const safeHeaders = { ...headers };
|
|
260
|
-
delete safeHeaders['host'];
|
|
261
|
-
delete safeHeaders['content-length'];
|
|
262
|
-
const forwardedProto = isHttps ? 'https' : 'http';
|
|
263
|
-
safeHeaders['x-forwarded-proto'] = forwardedProto;
|
|
264
|
-
safeHeaders['x-forwarded-scheme'] = forwardedProto;
|
|
265
|
-
const options = {
|
|
266
|
-
hostname: urlObj.hostname,
|
|
267
|
-
port: urlObj.port || (isHttps ? 443 : 80),
|
|
268
|
-
path: urlObj.pathname + urlObj.search,
|
|
269
|
-
method,
|
|
270
|
-
headers: safeHeaders,
|
|
271
|
-
agent: isHttps ? this.httpsAgent : this.httpAgent,
|
|
272
|
-
rejectUnauthorized: false,
|
|
273
|
-
};
|
|
274
|
-
const req = httpModule.request(options, (res) => {
|
|
275
|
-
const chunks = [];
|
|
276
|
-
res.on('data', (chunk) => {
|
|
277
|
-
chunks.push(chunk);
|
|
278
|
-
});
|
|
279
|
-
res.on('end', () => {
|
|
280
|
-
resolve({
|
|
281
|
-
statusCode: res.statusCode || 200,
|
|
282
|
-
statusMessage: res.statusMessage,
|
|
283
|
-
headers: res.headers,
|
|
284
|
-
body: chunks.length > 0 ? Buffer.concat(chunks) : undefined,
|
|
285
|
-
});
|
|
286
|
-
});
|
|
287
|
-
});
|
|
288
|
-
req.on('error', (error) => {
|
|
289
|
-
reject(error);
|
|
290
|
-
});
|
|
291
|
-
if (body) {
|
|
292
|
-
req.write(body);
|
|
293
|
-
}
|
|
294
|
-
req.end();
|
|
295
|
-
});
|
|
296
|
-
}
|
|
297
322
|
/**
|
|
298
323
|
* 构建目标 URL
|
|
299
324
|
*/
|
|
@@ -356,10 +381,10 @@ export class HttpTunnelClient {
|
|
|
356
381
|
* 关闭客户端
|
|
357
382
|
*/
|
|
358
383
|
close() {
|
|
359
|
-
|
|
384
|
+
this.pendingStreamRequests.forEach((p) => p.req.destroy());
|
|
385
|
+
this.pendingStreamRequests.clear();
|
|
360
386
|
this.wsClients.forEach((ws) => ws.close());
|
|
361
387
|
this.wsClients.clear();
|
|
362
|
-
// 关闭代理
|
|
363
388
|
this.httpAgent.destroy();
|
|
364
389
|
this.httpsAgent.destroy();
|
|
365
390
|
}
|