@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 CHANGED
@@ -1,15 +1,6 @@
1
- # Mirror - HTTP 隧道工具
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
- ### 规则已添加但服务端仍返回 "No matching rule"
40
+ ## 规则匹配
258
41
 
259
- - 若在「先 link、后 add」的另一终端添加规则,后台 link 会在约 2 秒内自动同步;也可执行 `mirror sync` 手动触发同步。
260
- - 确认本机目标服务已启动:例如规则指向 `http://127.0.0.1:8081` 时,本地应能访问 `curl http://127.0.0.1:8081/`。
42
+ - 规则按**路径长度**从长到短匹配,路径越长优先级越高。
43
+ - `domain/path` 支持:`example.com/api`、`example.com`(等价于根路径 `/`)、或 `https://example.com/path`(协议会在解析时去掉)。
44
+ - 规则存于 `~/.mirror/rules.json`,增删后下次请求即生效。
261
45
 
262
- ## 注意事项
46
+ ## 常见问题
263
47
 
264
- - 服务端和客户端必须使用相同的 token(如果配置了)
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>', '请求超时时间(毫秒)', '30000')
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
- * 处理 HTTP 请求
36
- * 若消息无 target 且配置了 getTarget,则由客户端根据 Host/path 匹配规则得到目标。
37
- * 目标未写协议时默认 http(本地服务通常为 HTTP)。
36
+ * 流式:处理 HTTP 请求开始(创建本地请求,不写 body,等 CHUNK/END)
37
+ */
38
+ private handleHttpRequestStart;
39
+ /**
40
+ * 创建流式 HTTP 请求:收到 response 后发送 START,再流式发送 body 帧与 END
38
41
  */
39
- private handleHttpRequest;
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;AAW/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;AAED;;GAEG;AACH,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;gBAE1C,OAAO,EAAE,uBAAuB;IAwC5C;;OAEG;IACH,OAAO,CAAC,mBAAmB;IAwC3B,+DAA+D;IAC/D,OAAO,CAAC,kBAAkB;IAK1B;;;;OAIG;YACW,iBAAiB;IA0D/B;;OAEG;YACW,eAAe;IAiF7B;;OAEG;IACH,OAAO,CAAC,YAAY;IAWpB;;OAEG;IACH,OAAO,CAAC,aAAa;IASrB;;OAEG;IACH,OAAO,CAAC,eAAe;IA+DvB;;OAEG;IACH,OAAO,CAAC,cAAc;IAmCtB;;OAEG;IACH,OAAO,CAAC,gBAAgB;IAYxB;;OAEG;IACH,OAAO,CAAC,MAAM;IASd;;OAEG;IACH,KAAK,IAAI,IAAI;CASd"}
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 'HTTP_REQUEST':
70
- this.handleHttpRequest(message);
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
- * 处理 HTTP 请求
97
- * 若消息无 target 且配置了 getTarget,则由客户端根据 Host/path 匹配规则得到目标。
98
- * 目标未写协议时默认 http(本地服务通常为 HTTP)。
124
+ * 流式:处理 HTTP 请求开始(创建本地请求,不写 body,等 CHUNK/END)
99
125
  */
100
- async handleHttpRequest(message) {
101
- try {
102
- const { requestId, method, url: requestUrl, headers, body, target } = message;
103
- let targetUrlStr;
104
- if (target) {
105
- targetUrlStr = target.startsWith('http') ? target : `http://${target}`;
106
- }
107
- else if (this.getTarget) {
108
- const host = headers?.host ?? '';
109
- const domain = host.split(':')[0];
110
- const pathname = new URL(requestUrl || '/', `http://${host}`).pathname;
111
- const resolved = this.getTarget(domain, pathname);
112
- if (resolved == null) {
113
- this.tunnelWs.send(JSON.stringify({
114
- type: 'HTTP_RESPONSE',
115
- requestId,
116
- statusCode: 404,
117
- statusMessage: 'Not Found',
118
- headers: { 'Content-Type': 'text/plain' },
119
- body: Buffer.from('No matching rule for this request').toString('base64'),
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
- // 目标仅含 origin,需拼上原始请求路径与查询,再转发
129
- const fullTargetUrl = new URL(requestUrl || '/', targetUrlStr).href;
130
- const bodyBuffer = body ? Buffer.from(body, 'base64') : undefined;
131
- const response = await this.sendHttpRequest(method, fullTargetUrl, headers, bodyBuffer);
132
- const responseMessage = {
133
- type: 'HTTP_RESPONSE',
134
- requestId,
135
- statusCode: response.statusCode,
136
- statusMessage: response.statusMessage || 'OK',
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 handling HTTP request:', 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: message.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
- // 关闭所有 WebSocket 客户端
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
  }