dsh-mobile-plugin 0.0.0-stage → 0.1.57
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 +128 -2
- package/config/agent-presets/standard/agent.cordis.yml +170 -0
- package/config/agent-presets/standard/preset.yml +3 -0
- package/cordis.mobile-host.yml +48 -0
- package/cordis.patch.yml +21 -0
- package/dsh-mobile-credentials/lib/credentials.js +135 -0
- package/dsh-mobile-credentials/package.json +13 -0
- package/host.js +128 -0
- package/lib/auto-archive.cjs +98 -0
- package/lib/client.js +690 -0
- package/lib/desktop-half-stats.cjs +101 -0
- package/lib/desktop-view.cjs +109 -0
- package/lib/host-guard.cjs +230 -0
- package/lib/host-patches.cjs +46 -0
- package/lib/http/package.json +3 -0
- package/lib/http/routing.js +84 -0
- package/lib/impl.js +406 -0
- package/lib/mirror/pusher.cjs +706 -0
- package/lib/mirror-snapshot.cjs +39 -0
- package/lib/mobile/agent-presets.js +182 -0
- package/lib/mobile/attachments.js +116 -0
- package/lib/mobile/auth.js +78 -0
- package/lib/mobile/completion.js +114 -0
- package/lib/mobile/credentials.js +150 -0
- package/lib/mobile/directory-browse.js +295 -0
- package/lib/mobile/envelope.js +51 -0
- package/lib/mobile/events.js +293 -0
- package/lib/mobile/history-derive.js +608 -0
- package/lib/mobile/host-bridge/endpoints.js +189 -0
- package/lib/mobile/host-bridge/event-synthesis.js +232 -0
- package/lib/mobile/host-bridge/shims.js +218 -0
- package/lib/mobile/host-bridge/transport.js +168 -0
- package/lib/mobile/host-bridge.js +71 -0
- package/lib/mobile/host-settings.js +282 -0
- package/lib/mobile/idempotency.js +78 -0
- package/lib/mobile/jobs.js +97 -0
- package/lib/mobile/local-images.js +153 -0
- package/lib/mobile/package.json +3 -0
- package/lib/mobile/plugins.js +149 -0
- package/lib/mobile/questions.js +71 -0
- package/lib/mobile/references.js +209 -0
- package/lib/mobile/rpc.js +76 -0
- package/lib/mobile/session-cache.js +263 -0
- package/lib/mobile/session-goal.js +44 -0
- package/lib/mobile/session-log.js +94 -0
- package/lib/mobile/session-mirror.js +177 -0
- package/lib/mobile/session-models.js +153 -0
- package/lib/mobile/session-presets.js +52 -0
- package/lib/mobile/session-search.js +65 -0
- package/lib/mobile/session-send.js +278 -0
- package/lib/mobile/session-snapshot.js +142 -0
- package/lib/mobile/sessions.js +462 -0
- package/lib/mobile/skills.js +80 -0
- package/lib/mobile/stream.js +268 -0
- package/lib/mobile/subagents.js +265 -0
- package/lib/mobile/workspaces.js +214 -0
- package/lib/panel/index.html +33 -0
- package/lib/read-receipt.cjs +124 -0
- package/lib/reconnect-scheduler.cjs +37 -0
- package/lib/relay/core.js +77 -0
- package/lib/relay/lan-network.cjs +85 -0
- package/lib/relay/lan-proxy.js +593 -0
- package/lib/relay/package.json +3 -0
- package/lib/relay/pairing-policy.js +45 -0
- package/lib/relay/port-allocator.cjs +60 -0
- package/lib/relay/preview.js +374 -0
- package/lib/relay/protocol.js +177 -0
- package/lib/relay/status.js +145 -0
- package/lib/relay/tunnel.js +721 -0
- package/lib/routes/control-plane.js +311 -0
- package/lib/routes/mobile-api.js +208 -0
- package/lib/routes/package.json +3 -0
- package/lib/session-cleanup.cjs +153 -0
- package/lib/shared/credentials-entry.js +48 -0
- package/lib/shared/dshtoken.js +62 -0
- package/lib/shared/fresh-request.js +35 -0
- package/lib/shared/http-util.js +71 -0
- package/lib/shared/list-revision.js +51 -0
- package/lib/shared/list-signal.js +87 -0
- package/lib/shared/package.json +3 -0
- package/lib/shared/paths.js +27 -0
- package/lib/shared/session-signal.js +110 -0
- package/lib/shared/workspace-guard.js +127 -0
- package/lib/sidecar-facade-worker.cjs +353 -0
- package/lib/sidecar-facade.cjs +141 -0
- package/lib/sidecar-lifecycle.cjs +246 -0
- package/package.json +74 -4
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 出站隧道客户端 v2:连接 dsh-mobile-server 中转,把中转发来的 HTTP/WS 请求转发到
|
|
5
|
+
* 本机 dsh web(127.0.0.1:<port>)。
|
|
6
|
+
*
|
|
7
|
+
* 协议 v2 相对 v1 的变更(见 ../../relay/PROTOCOL.md):
|
|
8
|
+
* - 二进制帧取代 JSON/base64 文本帧(数据通道不再 base64);
|
|
9
|
+
* - 手机请求 body 流式转发(httpRequest + httpBody*),不再整包缓冲;
|
|
10
|
+
* - 应用层 ping/pong 心跳(对 NAT/代理更可靠)。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const http = require('node:http');
|
|
14
|
+
const { WebSocket } = require('ws');
|
|
15
|
+
const { T, jsonFrame, encodeFrame, createFrameDecoder, parseJson, encodeClosePayload, decodeClosePayload } = require('./protocol.js');
|
|
16
|
+
const { isFreshRequest } = require('../shared/fresh-request');
|
|
17
|
+
|
|
18
|
+
const HEARTBEAT_INTERVAL_MS = 15_000;
|
|
19
|
+
// Relay currently expires an idle tunnel after 60s. Keep a wider client-side
|
|
20
|
+
// grace period so scheduling jitter or a delayed pong cannot make the client
|
|
21
|
+
// race the relay's timeout.
|
|
22
|
+
const PONG_TIMEOUT_MS = 90_000;
|
|
23
|
+
const MAX_TUNNEL_BUFFERED_BYTES = 16 * 1024 * 1024;
|
|
24
|
+
const TUNNEL_BUFFER_LOW_WATER_BYTES = MAX_TUNNEL_BUFFERED_BYTES / 2;
|
|
25
|
+
const HTTP_RESPONSE_CHUNK_BYTES = 256 * 1024;
|
|
26
|
+
const MAX_HTTP_RESPONSE_PENDING_BYTES = 8 * 1024 * 1024;
|
|
27
|
+
const MAX_TUNNEL_HTTP_RESPONSE_PENDING_BYTES = 32 * 1024 * 1024;
|
|
28
|
+
const HTTP_RESPONSE_PUMP_MS = 25;
|
|
29
|
+
const HTTP_RESPONSE_BACKPRESSURE_TIMEOUT_MS = 30_000;
|
|
30
|
+
const HTTP_RESPONSE_RELAY_PAUSE_TIMEOUT_MS = 30_000;
|
|
31
|
+
const UPSTREAM_HEADER_TIMEOUT_MS = 7_000;
|
|
32
|
+
const UPSTREAM_RESPONSE_IDLE_TIMEOUT_MS = 30_000;
|
|
33
|
+
// Keep reconnect storms from monopolizing the local Host HTTP server.
|
|
34
|
+
const MAX_UPSTREAM_SSE_CONNECTIONS = 16;
|
|
35
|
+
class RelayTunnel {
|
|
36
|
+
/**
|
|
37
|
+
* @param {object} opts
|
|
38
|
+
* @param {string} opts.relayUrl 隧道 WSS 地址(含 /tunnel),如 wss://relay.example.com/tunnel
|
|
39
|
+
* @param {string} opts.instanceId
|
|
40
|
+
* @param {string} opts.pairingCode
|
|
41
|
+
* @param {string} [opts.name]
|
|
42
|
+
* @param {string[]} [opts.lanUrls] LAN sidecar candidates (metadata only)
|
|
43
|
+
* @param {object[]} [opts.lanCandidates] LAN candidate metadata (metadata only)
|
|
44
|
+
* @param {string} opts.upstream 本地上游,如 http://127.0.0.1:52323
|
|
45
|
+
* @param {string} [opts.registerSecret] 中转侧 TUNNEL_REGISTER_SECRET 的对偶值。
|
|
46
|
+
* 优先于环境变量 DSH_RELAY_REGISTER_SECRET:桌面版 DSH 是从图标/启动台
|
|
47
|
+
* 拉起的,拿不到 shell 环境变量,密钥只能走 relay.json(0600)。
|
|
48
|
+
* @param {() => void} [opts.onRegistered] 注册成功回调(用于状态展示)
|
|
49
|
+
* @param {(code: number, reason: string) => void} [opts.onClose] 连接断开回调(用于断线重连)
|
|
50
|
+
* @param {(msg: {requestId: string, device?: {name?: string, ip?: string, platform?: string, osVersion?: string, version?: string, appVersion?: string, id?: string, deviceId?: string}}) => void} [opts.onPairRequest]
|
|
51
|
+
* 中转转发来的配对授权请求(手机配对码已通过校验,需桌面用户确认)
|
|
52
|
+
* @param {(msg: string) => void} [opts.onLog]
|
|
53
|
+
*/
|
|
54
|
+
constructor(opts) {
|
|
55
|
+
this.relayUrl = opts.relayUrl;
|
|
56
|
+
this.instanceId = opts.instanceId;
|
|
57
|
+
this.pairingCode = opts.pairingCode;
|
|
58
|
+
this.name = opts.name || '';
|
|
59
|
+
this.lanUrls = Array.isArray(opts.lanUrls) ? opts.lanUrls.slice() : [];
|
|
60
|
+
this.lanCandidates = Array.isArray(opts.lanCandidates) ? opts.lanCandidates.slice() : [];
|
|
61
|
+
this.upstream = new URL(opts.upstream);
|
|
62
|
+
// 注册密钥:relay.json 里的显式值优先,环境变量作为无头/命令行场景的兜底。
|
|
63
|
+
this.registerSecret = typeof opts.registerSecret === 'string' && opts.registerSecret
|
|
64
|
+
? opts.registerSecret
|
|
65
|
+
: (process.env.DSH_RELAY_REGISTER_SECRET || '');
|
|
66
|
+
// 读取隔离:可选的本地快照读取回调,命中时直接回(不再连主进程)。
|
|
67
|
+
this.readSnapshot = typeof opts.readSnapshot === 'function' ? opts.readSnapshot : null;
|
|
68
|
+
this.onRegistered = opts.onRegistered || (() => {});
|
|
69
|
+
this.onClose = opts.onClose || (() => {});
|
|
70
|
+
this.onPairRequest = opts.onPairRequest || (() => {});
|
|
71
|
+
this.onLog = opts.onLog || (() => {});
|
|
72
|
+
this.ws = null;
|
|
73
|
+
this.wsClients = new Map(); // relay 侧 ws id -> 上游 ws client
|
|
74
|
+
this.reqClients = new Map(); // relay 侧 http id -> { req, res }
|
|
75
|
+
this.sseClients = new Set();
|
|
76
|
+
// Tombstones close races where an upstream callback is already queued when
|
|
77
|
+
// Relay cancels the request. They are bounded because ids are monotonic.
|
|
78
|
+
this.cancelledHttp = new Set();
|
|
79
|
+
this.cancelledWs = new Set();
|
|
80
|
+
this.hbTimer = null;
|
|
81
|
+
this.httpPumpTimer = null;
|
|
82
|
+
this.httpPumpCursor = 0;
|
|
83
|
+
this.pendingHttpResponseBytes = 0;
|
|
84
|
+
this.lastPong = 0;
|
|
85
|
+
// 清除所有授权的等待回调(一次一个);由 requestRevokeAll 注入,revokeAllAck 触发。
|
|
86
|
+
this._revokeAllAck = null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
log(...args) {
|
|
90
|
+
this.onLog(`[relay ${this.instanceId}]`, ...args);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
get connected() {
|
|
94
|
+
return !!this.ws && this.ws.readyState === WebSocket.OPEN;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 发送队列积压到上限时**只丢这一帧,不断开隧道**。
|
|
99
|
+
*
|
|
100
|
+
* 旧实现在两个分支里都 `ws.terminate()`:积压超限时把整条隧道掐掉。可积压本身是
|
|
101
|
+
* 「手机/中转读得慢」,不是「隧道坏了」——掐掉它,所有设备(包括正在正常转发的
|
|
102
|
+
* 那些)一起掉线,还要等 5s 重连。中转侧的同名判定(dsh-mobile-server/lib/proxy-http.js
|
|
103
|
+
* 的 sendFrame)已经改成只 `return false`,两侧口径必须一致:丢一帧可以恢复,
|
|
104
|
+
* 关一条隧道是全网断流。
|
|
105
|
+
*
|
|
106
|
+
* 真正的「隧道已死」由心跳兜底:PONG_TIMEOUT_MS 收不到 pong 时才会 terminate。
|
|
107
|
+
*/
|
|
108
|
+
dropOverBudget(bytes, frameLength) {
|
|
109
|
+
const total = bytes + frameLength;
|
|
110
|
+
this.log(`发送队列积压 ${total} 字节,超过上限 ${MAX_TUNNEL_BUFFERED_BYTES},丢弃该帧(隧道保持连接)`);
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
trySendEncodedFrame(frame) {
|
|
114
|
+
if (!this.connected) return 'unavailable';
|
|
115
|
+
const buffered = Number(this.ws.bufferedAmount || 0);
|
|
116
|
+
if (buffered > MAX_TUNNEL_BUFFERED_BYTES || buffered + frame.length > MAX_TUNNEL_BUFFERED_BYTES) {
|
|
117
|
+
this.dropOverBudget(buffered, frame.length);
|
|
118
|
+
return 'backpressure';
|
|
119
|
+
}
|
|
120
|
+
try { this.ws.send(frame); return 'sent'; } catch { return 'error'; }
|
|
121
|
+
}
|
|
122
|
+
trySendFrame(type, id, payload) {
|
|
123
|
+
try { return this.trySendEncodedFrame(encodeFrame(type, id, payload)); } catch { return 'error'; }
|
|
124
|
+
}
|
|
125
|
+
trySendJson(type, id, obj) {
|
|
126
|
+
try { return this.trySendEncodedFrame(jsonFrame(type, id, obj)); } catch { return 'error'; }
|
|
127
|
+
}
|
|
128
|
+
sendFrame(type, id, payload) { return this.trySendFrame(type, id, payload) === 'sent'; }
|
|
129
|
+
sendJson(type, id, obj) { return this.trySendJson(type, id, obj) === 'sent'; }
|
|
130
|
+
createHttpResponseEntry(req = null, res = null, isSse = false) {
|
|
131
|
+
return {
|
|
132
|
+
req, res, isSse, relayPaused: false, bufferPaused: false, pendingFrames: [], pendingResponseBytes: 0,
|
|
133
|
+
pendingHttpPayloadBytes: 0, headerTimer: null, responseIdleTimer: null, backpressureTimer: null,
|
|
134
|
+
relayPauseTimer: null, terminalQueued: false, done: false,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
armUpstreamHeaderTimeout(id, entry) {
|
|
138
|
+
if (entry.headerTimer) clearTimeout(entry.headerTimer);
|
|
139
|
+
entry.headerTimer = setTimeout(() => {
|
|
140
|
+
entry.headerTimer = null;
|
|
141
|
+
if (entry.done || entry.res || this.cancelledHttp.has(id)) return;
|
|
142
|
+
this.log(`upstream response header timeout: id=${id}`);
|
|
143
|
+
try { entry.req?.destroy(new Error('upstream response headers timeout')); } catch { /* ignore */ }
|
|
144
|
+
}, UPSTREAM_HEADER_TIMEOUT_MS);
|
|
145
|
+
}
|
|
146
|
+
clearUpstreamResponseIdleTimeout(entry) {
|
|
147
|
+
if (entry.responseIdleTimer) clearTimeout(entry.responseIdleTimer);
|
|
148
|
+
entry.responseIdleTimer = null;
|
|
149
|
+
}
|
|
150
|
+
clearHttpRelayPauseTimeout(entry) {
|
|
151
|
+
if (entry.relayPauseTimer) clearTimeout(entry.relayPauseTimer);
|
|
152
|
+
entry.relayPauseTimer = null;
|
|
153
|
+
}
|
|
154
|
+
armHttpRelayPauseTimeout(id, entry) {
|
|
155
|
+
if (!entry || entry.done || entry.relayPauseTimer) return;
|
|
156
|
+
entry.relayPauseTimer = setTimeout(() => {
|
|
157
|
+
entry.relayPauseTimer = null;
|
|
158
|
+
if (entry.done || !entry.relayPaused) return;
|
|
159
|
+
this.log(`HTTP relay pause timeout: id=${id}`);
|
|
160
|
+
if (!this.queueHttpTerminalError(id, entry, 'client response backpressure timeout')) {
|
|
161
|
+
this.finishHttpRequest(id, entry, true);
|
|
162
|
+
}
|
|
163
|
+
}, HTTP_RESPONSE_RELAY_PAUSE_TIMEOUT_MS);
|
|
164
|
+
}
|
|
165
|
+
armUpstreamResponseIdleTimeout(id, entry) {
|
|
166
|
+
this.clearUpstreamResponseIdleTimeout(entry);
|
|
167
|
+
if (entry.done || entry.terminalQueued || entry.isSse || !entry.res || entry.relayPaused || entry.bufferPaused) return;
|
|
168
|
+
entry.responseIdleTimer = setTimeout(() => {
|
|
169
|
+
entry.responseIdleTimer = null;
|
|
170
|
+
if (entry.done || entry.relayPaused || entry.bufferPaused) return;
|
|
171
|
+
this.log(`upstream response idle timeout: id=${id}`);
|
|
172
|
+
this.queueHttpTerminalError(id, entry, 'upstream response idle timeout');
|
|
173
|
+
}, UPSTREAM_RESPONSE_IDLE_TIMEOUT_MS);
|
|
174
|
+
}
|
|
175
|
+
finishHttpRequest(id, entry, destroy = false) {
|
|
176
|
+
if (!entry || entry.done) return;
|
|
177
|
+
entry.done = true;
|
|
178
|
+
if (entry.headerTimer) clearTimeout(entry.headerTimer);
|
|
179
|
+
entry.headerTimer = null;
|
|
180
|
+
this.clearUpstreamResponseIdleTimeout(entry);
|
|
181
|
+
this.clearHttpRelayPauseTimeout(entry);
|
|
182
|
+
if (entry.backpressureTimer) clearTimeout(entry.backpressureTimer);
|
|
183
|
+
entry.backpressureTimer = null;
|
|
184
|
+
this.pendingHttpResponseBytes = Math.max(0, this.pendingHttpResponseBytes - (entry.pendingHttpPayloadBytes || 0));
|
|
185
|
+
entry.pendingHttpPayloadBytes = 0;
|
|
186
|
+
entry.pendingFrames = [];
|
|
187
|
+
entry.pendingResponseBytes = 0;
|
|
188
|
+
entry.bufferPaused = false;
|
|
189
|
+
entry.relayPaused = false;
|
|
190
|
+
this.sseClients.delete(id);
|
|
191
|
+
if (this.reqClients.get(id) === entry) this.reqClients.delete(id);
|
|
192
|
+
if (destroy) {
|
|
193
|
+
try { entry.req?.destroy(); } catch { /* ignore */ }
|
|
194
|
+
try { entry.res?.destroy(); } catch { /* ignore */ }
|
|
195
|
+
}
|
|
196
|
+
if (![...this.reqClients.values()].some((item) => item.pendingFrames?.length)) this.clearHttpPumpTimer();
|
|
197
|
+
}
|
|
198
|
+
clearHttpPumpTimer() {
|
|
199
|
+
if (this.httpPumpTimer) clearTimeout(this.httpPumpTimer);
|
|
200
|
+
this.httpPumpTimer = null;
|
|
201
|
+
}
|
|
202
|
+
queueHttpTerminalError(id, entry, message) {
|
|
203
|
+
if (!entry || entry.done) return false;
|
|
204
|
+
const queuedError = entry.pendingFrames.find((item) => item.type === T.httpError);
|
|
205
|
+
if (entry.terminalQueued && !queuedError) return false;
|
|
206
|
+
entry.terminalQueued = true;
|
|
207
|
+
if (entry.headerTimer) clearTimeout(entry.headerTimer);
|
|
208
|
+
entry.headerTimer = null;
|
|
209
|
+
this.clearUpstreamResponseIdleTimeout(entry);
|
|
210
|
+
this.clearHttpRelayPauseTimeout(entry);
|
|
211
|
+
if (entry.backpressureTimer) clearTimeout(entry.backpressureTimer);
|
|
212
|
+
entry.backpressureTimer = null;
|
|
213
|
+
entry.relayPaused = false;
|
|
214
|
+
entry.bufferPaused = false;
|
|
215
|
+
this.pendingHttpResponseBytes = Math.max(0, this.pendingHttpResponseBytes - (entry.pendingHttpPayloadBytes || 0));
|
|
216
|
+
entry.pendingHttpPayloadBytes = 0;
|
|
217
|
+
try { entry.req?.destroy(); } catch { /* ignore */ }
|
|
218
|
+
try { entry.res?.destroy(); } catch { /* ignore */ }
|
|
219
|
+
if (queuedError) {
|
|
220
|
+
entry.pendingFrames = [queuedError];
|
|
221
|
+
entry.pendingResponseBytes = queuedError.size;
|
|
222
|
+
} else {
|
|
223
|
+
const frame = jsonFrame(T.httpError, id, { message });
|
|
224
|
+
entry.pendingFrames = [{ type: T.httpError, frame, payload: null, offset: 0, size: frame.length, terminal: true }];
|
|
225
|
+
entry.pendingResponseBytes = frame.length;
|
|
226
|
+
}
|
|
227
|
+
entry.backpressureTimer = setTimeout(() => {
|
|
228
|
+
if (!entry.done && entry.pendingFrames.length) {
|
|
229
|
+
this.log(`HTTP terminal error delivery timeout: id=${id}`);
|
|
230
|
+
this.finishHttpRequest(id, entry, true);
|
|
231
|
+
}
|
|
232
|
+
}, HTTP_RESPONSE_BACKPRESSURE_TIMEOUT_MS);
|
|
233
|
+
this.scheduleHttpResponsePump();
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
queueHttpResponseFrame(id, entry, type, payload, asJson = false) {
|
|
237
|
+
if (!entry || entry.done || entry.terminalQueued) return false;
|
|
238
|
+
const terminal = type === T.httpEnd || type === T.httpError;
|
|
239
|
+
const isChunk = type === T.httpChunk;
|
|
240
|
+
let frame = null;
|
|
241
|
+
let body = null;
|
|
242
|
+
try {
|
|
243
|
+
if (isChunk) body = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
|
|
244
|
+
else frame = asJson ? jsonFrame(type, id, payload) : encodeFrame(type, id, payload);
|
|
245
|
+
} catch {
|
|
246
|
+
this.finishHttpRequest(id, entry, true);
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
const size = isChunk ? body.length : frame.length;
|
|
250
|
+
const mustQueue = isChunk && body.length > HTTP_RESPONSE_CHUNK_BYTES;
|
|
251
|
+
if (!entry.pendingFrames.length && !mustQueue && !entry.relayPaused) {
|
|
252
|
+
const result = isChunk ? this.trySendFrame(type, id, body) : this.trySendEncodedFrame(frame);
|
|
253
|
+
if (result === 'sent') {
|
|
254
|
+
if (type === T.httpEnd || type === T.httpError) this.finishHttpRequest(id, entry);
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
if (result === 'error') {
|
|
258
|
+
return this.queueHttpTerminalError(id, entry, 'relay failed to accept upstream response');
|
|
259
|
+
}
|
|
260
|
+
if (result !== 'backpressure') {
|
|
261
|
+
this.finishHttpRequest(id, entry, true);
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const nextRequestBytes = entry.pendingResponseBytes + size;
|
|
266
|
+
const nextTunnelPayloadBytes = this.pendingHttpResponseBytes + (isChunk ? size : 0);
|
|
267
|
+
if (nextRequestBytes > MAX_HTTP_RESPONSE_PENDING_BYTES) {
|
|
268
|
+
this.log(`HTTP response buffer limit exceeded: id=${id} bytes=${nextRequestBytes}`);
|
|
269
|
+
return this.queueHttpTerminalError(id, entry, 'upstream response backpressure limit exceeded');
|
|
270
|
+
}
|
|
271
|
+
if (nextTunnelPayloadBytes > MAX_TUNNEL_HTTP_RESPONSE_PENDING_BYTES) {
|
|
272
|
+
this.log(`Tunnel HTTP response buffer limit exceeded: id=${id} bytes=${nextTunnelPayloadBytes}`);
|
|
273
|
+
return this.queueHttpTerminalError(id, entry, 'tunnel response backpressure limit exceeded');
|
|
274
|
+
}
|
|
275
|
+
entry.pendingFrames.push({ type, frame, payload: body, offset: 0, size, terminal });
|
|
276
|
+
if (terminal) entry.terminalQueued = true;
|
|
277
|
+
entry.pendingResponseBytes += size;
|
|
278
|
+
if (isChunk) {
|
|
279
|
+
entry.pendingHttpPayloadBytes += size;
|
|
280
|
+
this.pendingHttpResponseBytes += size;
|
|
281
|
+
}
|
|
282
|
+
entry.bufferPaused = true;
|
|
283
|
+
this.clearUpstreamResponseIdleTimeout(entry);
|
|
284
|
+
entry.res?.pause();
|
|
285
|
+
if (!entry.backpressureTimer) {
|
|
286
|
+
entry.backpressureTimer = setTimeout(() => {
|
|
287
|
+
if (entry.done || !entry.pendingFrames.length) return;
|
|
288
|
+
this.log(`HTTP response backpressure timeout: id=${id}`);
|
|
289
|
+
if (!this.queueHttpTerminalError(id, entry, 'upstream response backpressure timeout')) {
|
|
290
|
+
this.finishHttpRequest(id, entry, true);
|
|
291
|
+
}
|
|
292
|
+
}, HTTP_RESPONSE_BACKPRESSURE_TIMEOUT_MS);
|
|
293
|
+
}
|
|
294
|
+
this.scheduleHttpResponsePump();
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
scheduleHttpResponsePump() {
|
|
298
|
+
if (this.httpPumpTimer) return;
|
|
299
|
+
this.httpPumpTimer = setTimeout(() => {
|
|
300
|
+
this.httpPumpTimer = null;
|
|
301
|
+
this.pumpHttpResponses();
|
|
302
|
+
}, HTTP_RESPONSE_PUMP_MS);
|
|
303
|
+
}
|
|
304
|
+
pumpHttpResponses() {
|
|
305
|
+
if (!this.connected) return this.clearHttpPumpTimer();
|
|
306
|
+
const all = [...this.reqClients.entries()];
|
|
307
|
+
const count = all.length;
|
|
308
|
+
if (!count) return this.clearHttpPumpTimer();
|
|
309
|
+
const start = this.httpPumpCursor % count;
|
|
310
|
+
if (!all.some(([, entry]) => !entry.done && !entry.relayPaused && entry.pendingFrames?.length)) {
|
|
311
|
+
return this.clearHttpPumpTimer();
|
|
312
|
+
}
|
|
313
|
+
if (Number(this.ws.bufferedAmount || 0) > TUNNEL_BUFFER_LOW_WATER_BYTES) return this.scheduleHttpResponsePump();
|
|
314
|
+
for (let step = 0; step < count; step += 1) {
|
|
315
|
+
const index = (start + step) % count;
|
|
316
|
+
const [id, entry] = all[index];
|
|
317
|
+
if (entry.done || entry.relayPaused || !entry.pendingFrames?.length) continue;
|
|
318
|
+
if (Number(this.ws.bufferedAmount || 0) + HTTP_RESPONSE_CHUNK_BYTES + 16 > MAX_TUNNEL_BUFFERED_BYTES) break;
|
|
319
|
+
const item = entry.pendingFrames[0];
|
|
320
|
+
let result;
|
|
321
|
+
if (item.type === T.httpChunk) {
|
|
322
|
+
const end = Math.min(item.offset + HTTP_RESPONSE_CHUNK_BYTES, item.payload.length);
|
|
323
|
+
const chunk = item.payload.subarray(item.offset, end);
|
|
324
|
+
result = this.trySendFrame(item.type, id, chunk);
|
|
325
|
+
if (result === 'sent') {
|
|
326
|
+
item.offset = end;
|
|
327
|
+
entry.pendingResponseBytes -= chunk.length;
|
|
328
|
+
entry.pendingHttpPayloadBytes -= chunk.length;
|
|
329
|
+
this.pendingHttpResponseBytes = Math.max(0, this.pendingHttpResponseBytes - chunk.length);
|
|
330
|
+
if (item.offset >= item.payload.length) entry.pendingFrames.shift();
|
|
331
|
+
}
|
|
332
|
+
} else {
|
|
333
|
+
result = this.trySendEncodedFrame(item.frame);
|
|
334
|
+
if (result === 'sent') {
|
|
335
|
+
entry.pendingResponseBytes -= item.size;
|
|
336
|
+
entry.pendingFrames.shift();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (result === 'backpressure') break;
|
|
340
|
+
if (result === 'error' && item.type !== T.httpError) {
|
|
341
|
+
this.queueHttpTerminalError(id, entry, 'relay failed to accept upstream response');
|
|
342
|
+
this.httpPumpCursor = (index + 1) % count;
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
if (result !== 'sent') {
|
|
346
|
+
this.finishHttpRequest(id, entry, true);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
this.httpPumpCursor = (index + 1) % count;
|
|
350
|
+
if (item.terminal && !entry.pendingFrames.length) {
|
|
351
|
+
this.finishHttpRequest(id, entry);
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
if (!entry.pendingFrames.length) {
|
|
355
|
+
entry.bufferPaused = false;
|
|
356
|
+
if (entry.backpressureTimer) clearTimeout(entry.backpressureTimer);
|
|
357
|
+
entry.backpressureTimer = null;
|
|
358
|
+
if (!entry.relayPaused) {
|
|
359
|
+
entry.res?.resume();
|
|
360
|
+
this.armUpstreamResponseIdleTimeout(id, entry);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if ([...this.reqClients.values()].some((entry) => !entry.done && !entry.relayPaused && entry.pendingFrames?.length)) {
|
|
365
|
+
this.scheduleHttpResponsePump();
|
|
366
|
+
} else {
|
|
367
|
+
this.clearHttpPumpTimer();
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
rememberCancelled(set, id) {
|
|
371
|
+
set.add(id);
|
|
372
|
+
while (set.size > 4096) set.delete(set.values().next().value);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* 更新配对码:先更新本地,再经已建立的隧道通知中转(旧码即刻失效)。
|
|
377
|
+
* 若隧道尚未连接,register 首条消息会携带新码(register 读取 this.pairingCode)。
|
|
378
|
+
*/
|
|
379
|
+
updatePairingCode(code) {
|
|
380
|
+
this.pairingCode = code;
|
|
381
|
+
if (this.connected) this.sendJson(T.updatePairing, 0, { pairingCode: code });
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** 回传桌面端弹窗授权结果:approved + 桌面签发的移动 token(拒绝时无 token)。 */
|
|
385
|
+
sendPairConfirm(requestId, approved, token) {
|
|
386
|
+
this.sendJson(T.pairConfirm, 0, {
|
|
387
|
+
requestId,
|
|
388
|
+
approved,
|
|
389
|
+
...(approved && token ? { token } : {}),
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* 请求中转清空本实例全部设备授权(清除所有授权)。
|
|
395
|
+
* resolve 于收到 revokeAllAck,reject 于未连接 / 超时。
|
|
396
|
+
* @param {number} [timeoutMs] 等待回执的毫秒数,默认 10s
|
|
397
|
+
*/
|
|
398
|
+
requestRevokeAll(timeoutMs = 10_000) {
|
|
399
|
+
if (!this.connected) return Promise.reject(new Error('tunnel not connected'));
|
|
400
|
+
if (this._revokeAllAck) return Promise.reject(new Error('revoke-all already in flight'));
|
|
401
|
+
return new Promise((resolve, reject) => {
|
|
402
|
+
const timer = setTimeout(() => {
|
|
403
|
+
this._revokeAllAck = null;
|
|
404
|
+
reject(new Error('revoke-all timeout'));
|
|
405
|
+
}, timeoutMs);
|
|
406
|
+
this._revokeAllAck = (result) => {
|
|
407
|
+
clearTimeout(timer);
|
|
408
|
+
this._revokeAllAck = null;
|
|
409
|
+
resolve(result || {});
|
|
410
|
+
};
|
|
411
|
+
this.sendJson(T.revokeAll, 0, { at: Date.now() });
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** 建立连接并注册;resolve 于收到 registered,reject 于连接/注册失败 */
|
|
416
|
+
start() {
|
|
417
|
+
return new Promise((resolve, reject) => {
|
|
418
|
+
let settled = false;
|
|
419
|
+
const done = (fn, arg) => {
|
|
420
|
+
if (!settled) {
|
|
421
|
+
settled = true;
|
|
422
|
+
fn(arg);
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
const ws = new WebSocket(this.relayUrl, { perMessageDeflate: true });
|
|
426
|
+
this.ws = ws;
|
|
427
|
+
const timer = setTimeout(() => {
|
|
428
|
+
done(reject, new Error('注册超时'));
|
|
429
|
+
try { ws.close(); } catch { /* ignore */ }
|
|
430
|
+
}, 8000);
|
|
431
|
+
|
|
432
|
+
const push = createFrameDecoder((frame) => {
|
|
433
|
+
if (frame.type === T.registered) {
|
|
434
|
+
clearTimeout(timer);
|
|
435
|
+
done(resolve);
|
|
436
|
+
this.onRegistered();
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
this.handleFrame(frame);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
ws.on('open', () => {
|
|
443
|
+
this.sendJson(T.register, 0, {
|
|
444
|
+
instanceId: this.instanceId,
|
|
445
|
+
pairingCode: this.pairingCode,
|
|
446
|
+
name: this.name,
|
|
447
|
+
// 可选:与中转端 TUNNEL_REGISTER_SECRET 对应,防止他人抢注 instanceId。
|
|
448
|
+
...(this.registerSecret ? { secret: this.registerSecret } : {}),
|
|
449
|
+
...(this.lanUrls.length ? { lanUrls: this.lanUrls } : {}),
|
|
450
|
+
...(this.lanCandidates.length ? { lanCandidates: this.lanCandidates } : {}),
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
ws.on('message', (data, isBinary) => push(Buffer.isBuffer(data) ? data : Buffer.from(data)));
|
|
454
|
+
ws.on('close', (code, reason) => {
|
|
455
|
+
clearTimeout(timer);
|
|
456
|
+
this.stopHeartbeat();
|
|
457
|
+
this.log(`tunnel closed: ${code} ${reason}`);
|
|
458
|
+
this.ws = null;
|
|
459
|
+
this.clearHttpPumpTimer();
|
|
460
|
+
for (const [, client] of this.wsClients) {
|
|
461
|
+
try { client.terminate(); } catch { /* ignore */ }
|
|
462
|
+
}
|
|
463
|
+
this.wsClients.clear();
|
|
464
|
+
for (const [id, entry] of this.reqClients) this.finishHttpRequest(id, entry, true);
|
|
465
|
+
this.reqClients.clear();
|
|
466
|
+
this.sseClients.clear();
|
|
467
|
+
this.cancelledHttp.clear();
|
|
468
|
+
this.cancelledWs.clear();
|
|
469
|
+
this.onClose(code, reason);
|
|
470
|
+
done(reject, new Error(`隧道关闭: ${code} ${reason}`));
|
|
471
|
+
});
|
|
472
|
+
ws.on('error', (err) => {
|
|
473
|
+
clearTimeout(timer);
|
|
474
|
+
done(reject, err);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
this.startHeartbeat();
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
stop() {
|
|
482
|
+
this.stopHeartbeat();
|
|
483
|
+
this.clearHttpPumpTimer();
|
|
484
|
+
for (const [, client] of this.wsClients) {
|
|
485
|
+
try { client.terminate(); } catch { /* ignore */ }
|
|
486
|
+
}
|
|
487
|
+
this.wsClients.clear();
|
|
488
|
+
for (const [id, entry] of this.reqClients) this.finishHttpRequest(id, entry, true);
|
|
489
|
+
this.reqClients.clear();
|
|
490
|
+
this.sseClients.clear();
|
|
491
|
+
this.cancelledHttp.clear();
|
|
492
|
+
this.cancelledWs.clear();
|
|
493
|
+
if (this.ws) {
|
|
494
|
+
try { this.ws.close(); } catch { /* ignore */ }
|
|
495
|
+
this.ws = null;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ── 心跳 ──────────────────────────────────────────────────────────
|
|
500
|
+
startHeartbeat() {
|
|
501
|
+
this.stopHeartbeat();
|
|
502
|
+
this.lastPong = Date.now();
|
|
503
|
+
this.hbTimer = setInterval(() => {
|
|
504
|
+
if (!this.connected) return;
|
|
505
|
+
if (Date.now() - this.lastPong > PONG_TIMEOUT_MS) {
|
|
506
|
+
this.log('心跳超时,断开隧道');
|
|
507
|
+
try { this.ws.terminate(); } catch { /* ignore */ }
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
this.sendFrame(T.ping, 0, Buffer.alloc(0));
|
|
511
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
512
|
+
}
|
|
513
|
+
stopHeartbeat() {
|
|
514
|
+
if (this.hbTimer) {
|
|
515
|
+
clearInterval(this.hbTimer);
|
|
516
|
+
this.hbTimer = null;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// ── 帧分发 ────────────────────────────────────────────────────────
|
|
521
|
+
handleFrame(frame) {
|
|
522
|
+
const { type, id, payload } = frame;
|
|
523
|
+
switch (type) {
|
|
524
|
+
case T.httpRequest: {
|
|
525
|
+
const msg = parseJson(payload);
|
|
526
|
+
this.cancelledHttp.delete(id);
|
|
527
|
+
const isSse = String(msg.headers?.accept || '').toLowerCase().includes('text/event-stream');
|
|
528
|
+
const entry = this.createHttpResponseEntry(null, null, isSse);
|
|
529
|
+
this.reqClients.set(id, entry);
|
|
530
|
+
if (isSse && this.sseClients.size >= MAX_UPSTREAM_SSE_CONNECTIONS) {
|
|
531
|
+
this.queueHttpResponseFrame(id, entry, T.httpHead, { status: 429, headers: { 'content-type': 'text/plain; charset=utf-8', 'retry-after': '2' } }, true);
|
|
532
|
+
this.queueHttpResponseFrame(id, entry, T.httpEnd, Buffer.alloc(0));
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
if (isSse) this.sseClients.add(id);
|
|
536
|
+
// 读取隔离:命中本地快照时也走有界发送队列,避免快照响应在buffer高水位时丢帧。
|
|
537
|
+
// 例外:带 ?fresh=1 的写后刷新必须回源(判定与实测见 lib/shared/fresh-request.js)。
|
|
538
|
+
if (this.readSnapshot && !isFreshRequest(msg.path) && (msg.method === 'GET' || msg.method === undefined)) {
|
|
539
|
+
const body = this.readSnapshot({ method: msg.method, path: msg.path });
|
|
540
|
+
if (body && body.ok) {
|
|
541
|
+
const data = Buffer.from(JSON.stringify(body));
|
|
542
|
+
this.queueHttpResponseFrame(id, entry, T.httpHead, { status: 200, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } }, true);
|
|
543
|
+
this.queueHttpResponseFrame(id, entry, T.httpChunk, data);
|
|
544
|
+
this.queueHttpResponseFrame(id, entry, T.httpEnd, Buffer.alloc(0));
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
const req = http.request(
|
|
549
|
+
{
|
|
550
|
+
host: this.upstream.hostname,
|
|
551
|
+
port: this.upstream.port,
|
|
552
|
+
method: msg.method,
|
|
553
|
+
path: msg.path,
|
|
554
|
+
headers: {
|
|
555
|
+
...(msg.headers || {}),
|
|
556
|
+
// 保留手机端原始 Host(信任围栏按 Host 判定);缺省回退本地上游
|
|
557
|
+
host: (msg.headers && msg.headers.host) || this.upstream.host,
|
|
558
|
+
},
|
|
559
|
+
},
|
|
560
|
+
(res) => {
|
|
561
|
+
entry.res = res;
|
|
562
|
+
if (entry.headerTimer) clearTimeout(entry.headerTimer);
|
|
563
|
+
entry.headerTimer = null;
|
|
564
|
+
if (this.cancelledHttp.has(id)) return this.finishHttpRequest(id, entry, true);
|
|
565
|
+
this.queueHttpResponseFrame(id, entry, T.httpHead, { status: res.statusCode, headers: res.headers }, true);
|
|
566
|
+
if (entry.relayPaused || entry.bufferPaused) res.pause();
|
|
567
|
+
else this.armUpstreamResponseIdleTimeout(id, entry);
|
|
568
|
+
res.on('data', (chunk) => {
|
|
569
|
+
if (!this.cancelledHttp.has(id)) {
|
|
570
|
+
this.armUpstreamResponseIdleTimeout(id, entry);
|
|
571
|
+
this.queueHttpResponseFrame(id, entry, T.httpChunk, chunk);
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
res.on('end', () => {
|
|
575
|
+
this.clearUpstreamResponseIdleTimeout(entry);
|
|
576
|
+
const cancelled = this.cancelledHttp.has(id);
|
|
577
|
+
if (!cancelled) this.queueHttpResponseFrame(id, entry, T.httpEnd, Buffer.alloc(0));
|
|
578
|
+
else this.finishHttpRequest(id, entry);
|
|
579
|
+
if (cancelled) this.cancelledHttp.delete(id);
|
|
580
|
+
});
|
|
581
|
+
res.on('error', (err) => {
|
|
582
|
+
this.clearUpstreamResponseIdleTimeout(entry);
|
|
583
|
+
const cancelled = this.cancelledHttp.has(id);
|
|
584
|
+
if (!cancelled) this.queueHttpResponseFrame(id, entry, T.httpError, { message: err.message }, true);
|
|
585
|
+
else this.finishHttpRequest(id, entry);
|
|
586
|
+
if (cancelled) this.cancelledHttp.delete(id);
|
|
587
|
+
});
|
|
588
|
+
},
|
|
589
|
+
);
|
|
590
|
+
entry.req = req;
|
|
591
|
+
this.reqClients.set(id, entry);
|
|
592
|
+
req.on('error', (err) => {
|
|
593
|
+
if (entry.headerTimer) clearTimeout(entry.headerTimer);
|
|
594
|
+
entry.headerTimer = null;
|
|
595
|
+
const cancelled = this.cancelledHttp.has(id);
|
|
596
|
+
if (!cancelled) this.queueHttpResponseFrame(id, entry, T.httpError, { message: err.message }, true);
|
|
597
|
+
else this.finishHttpRequest(id, entry);
|
|
598
|
+
if (cancelled && !entry.res) this.cancelledHttp.delete(id);
|
|
599
|
+
});
|
|
600
|
+
break;
|
|
601
|
+
}
|
|
602
|
+
case T.httpBody: {
|
|
603
|
+
const entry = this.reqClients.get(id);
|
|
604
|
+
if (!entry || this.cancelledHttp.has(id)) break;
|
|
605
|
+
if (payload.length === 0) {
|
|
606
|
+
try {
|
|
607
|
+
entry.req.end();
|
|
608
|
+
this.armUpstreamHeaderTimeout(id, entry);
|
|
609
|
+
} catch { /* ignore */ }
|
|
610
|
+
} else {
|
|
611
|
+
try { entry.req.write(payload); } catch { /* ignore */ }
|
|
612
|
+
}
|
|
613
|
+
break;
|
|
614
|
+
}
|
|
615
|
+
case T.httpCancel: {
|
|
616
|
+
const entry = this.reqClients.get(id);
|
|
617
|
+
this.rememberCancelled(this.cancelledHttp, id);
|
|
618
|
+
if (entry) this.finishHttpRequest(id, entry, true);
|
|
619
|
+
else this.sseClients.delete(id);
|
|
620
|
+
break;
|
|
621
|
+
}
|
|
622
|
+
case T.httpPause: {
|
|
623
|
+
const entry = this.reqClients.get(id);
|
|
624
|
+
if (entry) {
|
|
625
|
+
if (!entry.relayPaused) {
|
|
626
|
+
entry.relayPaused = true;
|
|
627
|
+
this.armHttpRelayPauseTimeout(id, entry);
|
|
628
|
+
}
|
|
629
|
+
this.clearUpstreamResponseIdleTimeout(entry);
|
|
630
|
+
entry.res?.pause();
|
|
631
|
+
}
|
|
632
|
+
break;
|
|
633
|
+
}
|
|
634
|
+
case T.httpResume: {
|
|
635
|
+
const entry = this.reqClients.get(id);
|
|
636
|
+
if (entry) {
|
|
637
|
+
entry.relayPaused = false;
|
|
638
|
+
this.clearHttpRelayPauseTimeout(entry);
|
|
639
|
+
if (!entry.bufferPaused) {
|
|
640
|
+
entry.res?.resume();
|
|
641
|
+
this.armUpstreamResponseIdleTimeout(id, entry);
|
|
642
|
+
}
|
|
643
|
+
if (entry.pendingFrames?.length) this.scheduleHttpResponsePump();
|
|
644
|
+
}
|
|
645
|
+
break;
|
|
646
|
+
}
|
|
647
|
+
case T.wsUpgrade: {
|
|
648
|
+
const msg = parseJson(payload);
|
|
649
|
+
this.cancelledWs.delete(id);
|
|
650
|
+
this.forwardWs(id, msg.path, msg.headers);
|
|
651
|
+
break;
|
|
652
|
+
}
|
|
653
|
+
case T.wsText:
|
|
654
|
+
case T.wsBinary: {
|
|
655
|
+
const client = this.wsClients.get(id);
|
|
656
|
+
if (!this.cancelledWs.has(id) && client && client.readyState === WebSocket.OPEN) {
|
|
657
|
+
client.send(payload, { binary: type === T.wsBinary });
|
|
658
|
+
}
|
|
659
|
+
break;
|
|
660
|
+
}
|
|
661
|
+
case T.wsClose: {
|
|
662
|
+
const client = this.wsClients.get(id);
|
|
663
|
+
this.rememberCancelled(this.cancelledWs, id);
|
|
664
|
+
this.wsClients.delete(id);
|
|
665
|
+
if (client) {
|
|
666
|
+
const { code, reason } = decodeClosePayload(payload);
|
|
667
|
+
try { client.close(code, reason); } catch { /* ignore */ }
|
|
668
|
+
}
|
|
669
|
+
break;
|
|
670
|
+
}
|
|
671
|
+
case T.pairRequest: {
|
|
672
|
+
const msg = parseJson(payload);
|
|
673
|
+
this.onPairRequest(msg);
|
|
674
|
+
break;
|
|
675
|
+
}
|
|
676
|
+
case T.ping:
|
|
677
|
+
this.sendFrame(T.pong, 0, Buffer.alloc(0));
|
|
678
|
+
break;
|
|
679
|
+
case T.pong:
|
|
680
|
+
this.lastPong = Date.now();
|
|
681
|
+
break;
|
|
682
|
+
case T.revokeAllAck: {
|
|
683
|
+
const msg = parseJson(payload);
|
|
684
|
+
if (this._revokeAllAck) this._revokeAllAck(msg);
|
|
685
|
+
break;
|
|
686
|
+
}
|
|
687
|
+
default:
|
|
688
|
+
break;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** 把手机发来的 WS 升级请求桥接到本地上游 WS */
|
|
693
|
+
forwardWs(id, path, headers) {
|
|
694
|
+
const url = `ws://${this.upstream.host}${path}`;
|
|
695
|
+
let client;
|
|
696
|
+
try {
|
|
697
|
+
client = new WebSocket(url, { headers });
|
|
698
|
+
} catch (err) {
|
|
699
|
+
if (!this.cancelledWs.has(id)) this.sendJson(T.wsError, id, { message: err.message });
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
this.wsClients.set(id, client);
|
|
703
|
+
client.on('open', () => {
|
|
704
|
+
if (this.cancelledWs.has(id)) return client.close();
|
|
705
|
+
this.sendFrame(T.wsOpen, id, Buffer.alloc(0));
|
|
706
|
+
});
|
|
707
|
+
client.on('message', (data, isBinary) => {
|
|
708
|
+
if (!this.cancelledWs.has(id)) this.sendFrame(isBinary ? T.wsBinary : T.wsText, id, data);
|
|
709
|
+
});
|
|
710
|
+
client.on('close', (code, reason) => {
|
|
711
|
+
this.wsClients.delete(id);
|
|
712
|
+
if (!this.cancelledWs.has(id)) this.sendFrame(T.wsClose, id, encodeClosePayload(code, reason));
|
|
713
|
+
});
|
|
714
|
+
client.on('error', (err) => {
|
|
715
|
+
if (!this.cancelledWs.has(id)) this.sendJson(T.wsError, id, { message: err.message });
|
|
716
|
+
this.wsClients.delete(id);
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
module.exports = { RelayTunnel, HEARTBEAT_INTERVAL_MS, PONG_TIMEOUT_MS };
|