@wenbin_wb/dsh-bridge 1.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/LICENSE +21 -0
- package/README.md +88 -0
- package/client/build.mjs +41 -0
- package/client/client.js +484 -0
- package/client/index.js +410 -0
- package/cordis.patch.yml +4 -0
- package/lib/bridge-rpc.js +113 -0
- package/lib/cloudflared-manager.mjs +236 -0
- package/lib/index.js +499 -0
- package/lib/tunnel-client.mjs +291 -0
- package/package.json +73 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// DSH Bridge - Custom Tunnel Client
|
|
2
|
+
// WebSocket-based reverse tunnel with automatic reconnection and health monitoring
|
|
3
|
+
|
|
4
|
+
import { WebSocket } from 'ws';
|
|
5
|
+
import { request as httpRequest } from 'node:http';
|
|
6
|
+
|
|
7
|
+
const HEARTBEAT_INTERVAL = 30000; // 30 seconds
|
|
8
|
+
const RECONNECT_DELAY = 5000; // 5 seconds
|
|
9
|
+
const MAX_RECONNECT_ATTEMPTS = 5;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Custom tunnel client with production-grade features:
|
|
13
|
+
* - Automatic reconnection with exponential backoff
|
|
14
|
+
* - Heartbeat monitoring
|
|
15
|
+
* - Request multiplexing
|
|
16
|
+
* - Graceful shutdown
|
|
17
|
+
*/
|
|
18
|
+
export class CustomTunnelClient {
|
|
19
|
+
constructor({ serverUrl, accessToken, localPort, signal, onStateChange, logger }) {
|
|
20
|
+
this.serverUrl = serverUrl;
|
|
21
|
+
this.accessToken = accessToken;
|
|
22
|
+
this.localPort = localPort;
|
|
23
|
+
this.signal = signal;
|
|
24
|
+
this.onStateChange = onStateChange;
|
|
25
|
+
this.logger = logger;
|
|
26
|
+
|
|
27
|
+
this.ws = null;
|
|
28
|
+
this.publicUrl = null;
|
|
29
|
+
this.connected = false;
|
|
30
|
+
this.disconnecting = false;
|
|
31
|
+
this.reconnectAttempts = 0;
|
|
32
|
+
this.reconnectTimer = null;
|
|
33
|
+
this.heartbeatTimer = null;
|
|
34
|
+
this.pendingRequests = new Map();
|
|
35
|
+
this.requestIdCounter = 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async connect() {
|
|
39
|
+
if (this.connected) return;
|
|
40
|
+
|
|
41
|
+
this._setState('connecting', 'Connecting to tunnel server...');
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
await this._connectWebSocket();
|
|
45
|
+
this._startHeartbeat();
|
|
46
|
+
this.reconnectAttempts = 0;
|
|
47
|
+
this._setState('ready', 'Tunnel established');
|
|
48
|
+
} catch (err) {
|
|
49
|
+
this._setState('error', err.message);
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
_connectWebSocket() {
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
if (this.signal?.aborted) {
|
|
57
|
+
return reject(new Error('Aborted'));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const url = new URL(this.serverUrl);
|
|
61
|
+
url.searchParams.set('token', this.accessToken);
|
|
62
|
+
|
|
63
|
+
this.ws = new WebSocket(url.toString(), {
|
|
64
|
+
handshakeTimeout: 10000,
|
|
65
|
+
perMessageDeflate: false,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const onAbort = () => {
|
|
69
|
+
this.ws?.terminate();
|
|
70
|
+
reject(new Error('Aborted'));
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
this.signal?.addEventListener('abort', onAbort);
|
|
74
|
+
|
|
75
|
+
this.ws.on('open', () => {
|
|
76
|
+
this.signal?.removeEventListener('abort', onAbort);
|
|
77
|
+
this.logger?.info('Tunnel WebSocket connected');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
this.ws.on('message', (data) => {
|
|
81
|
+
this._handleMessage(data);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
this.ws.on('close', (code, reason) => {
|
|
85
|
+
this.connected = false;
|
|
86
|
+
this._stopHeartbeat();
|
|
87
|
+
|
|
88
|
+
if (!this.signal?.aborted) {
|
|
89
|
+
this.logger?.warn('Tunnel disconnected: code=%d, reason=%s', code, reason.toString());
|
|
90
|
+
this._scheduleReconnect();
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
this.ws.on('error', (err) => {
|
|
95
|
+
this.logger?.error('Tunnel WebSocket error: %s', err.message);
|
|
96
|
+
|
|
97
|
+
if (!this.connected) {
|
|
98
|
+
this.signal?.removeEventListener('abort', onAbort);
|
|
99
|
+
reject(err);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Listen for 'ready' message from server
|
|
104
|
+
const readyHandler = (data) => {
|
|
105
|
+
try {
|
|
106
|
+
const msg = JSON.parse(data.toString());
|
|
107
|
+
if (msg.type === 'ready' && msg.publicUrl) {
|
|
108
|
+
this.publicUrl = msg.publicUrl;
|
|
109
|
+
this.connected = true;
|
|
110
|
+
this.ws.off('message', readyHandler);
|
|
111
|
+
this.signal?.removeEventListener('abort', onAbort);
|
|
112
|
+
this.logger?.info('Tunnel ready: %s', this.publicUrl);
|
|
113
|
+
resolve();
|
|
114
|
+
}
|
|
115
|
+
} catch {}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
this.ws.on('message', readyHandler);
|
|
119
|
+
|
|
120
|
+
// Timeout
|
|
121
|
+
setTimeout(() => {
|
|
122
|
+
if (!this.connected) {
|
|
123
|
+
this.signal?.removeEventListener('abort', onAbort);
|
|
124
|
+
this.ws?.terminate();
|
|
125
|
+
reject(new Error('Connection timeout'));
|
|
126
|
+
}
|
|
127
|
+
}, 15000);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
_handleMessage(data) {
|
|
132
|
+
try {
|
|
133
|
+
const msg = JSON.parse(data.toString());
|
|
134
|
+
|
|
135
|
+
// HTTP request from server
|
|
136
|
+
if (msg.type === 'request') {
|
|
137
|
+
this._handleHttpRequest(msg);
|
|
138
|
+
}
|
|
139
|
+
// Response for our request
|
|
140
|
+
else if (msg.type === 'response') {
|
|
141
|
+
this._handleHttpResponse(msg);
|
|
142
|
+
}
|
|
143
|
+
// Heartbeat pong
|
|
144
|
+
else if (msg.type === 'pong') {
|
|
145
|
+
// Server is alive
|
|
146
|
+
}
|
|
147
|
+
} catch (err) {
|
|
148
|
+
this.logger?.error('Failed to parse tunnel message: %s', err.message);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
_handleHttpRequest(msg) {
|
|
153
|
+
const { requestId, method, path, headers } = msg;
|
|
154
|
+
|
|
155
|
+
// Proxy to local DSH
|
|
156
|
+
const req = httpRequest({
|
|
157
|
+
host: '127.0.0.1',
|
|
158
|
+
port: this.localPort,
|
|
159
|
+
method,
|
|
160
|
+
path,
|
|
161
|
+
headers: {
|
|
162
|
+
...headers,
|
|
163
|
+
host: `127.0.0.1:${this.localPort}`,
|
|
164
|
+
},
|
|
165
|
+
}, (res) => {
|
|
166
|
+
const chunks = [];
|
|
167
|
+
|
|
168
|
+
res.on('data', (chunk) => {
|
|
169
|
+
chunks.push(chunk);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
res.on('end', () => {
|
|
173
|
+
const body = Buffer.concat(chunks).toString('base64');
|
|
174
|
+
|
|
175
|
+
this._sendMessage({
|
|
176
|
+
type: 'response',
|
|
177
|
+
requestId,
|
|
178
|
+
statusCode: res.statusCode,
|
|
179
|
+
headers: res.headers,
|
|
180
|
+
body,
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
req.on('error', (err) => {
|
|
186
|
+
this.logger?.error('Local request failed: %s', err.message);
|
|
187
|
+
|
|
188
|
+
this._sendMessage({
|
|
189
|
+
type: 'response',
|
|
190
|
+
requestId,
|
|
191
|
+
statusCode: 502,
|
|
192
|
+
headers: { 'content-type': 'text/plain' },
|
|
193
|
+
body: Buffer.from('Bad Gateway').toString('base64'),
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Send request body if present
|
|
198
|
+
if (msg.body) {
|
|
199
|
+
req.write(Buffer.from(msg.body, 'base64'));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
req.end();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
_handleHttpResponse(msg) {
|
|
206
|
+
const { requestId, statusCode, headers, body } = msg;
|
|
207
|
+
const pending = this.pendingRequests.get(requestId);
|
|
208
|
+
|
|
209
|
+
if (pending) {
|
|
210
|
+
pending.resolve({ statusCode, headers, body });
|
|
211
|
+
this.pendingRequests.delete(requestId);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
_sendMessage(msg) {
|
|
216
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
217
|
+
this.ws.send(JSON.stringify(msg));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
_startHeartbeat() {
|
|
222
|
+
this._stopHeartbeat();
|
|
223
|
+
|
|
224
|
+
this.heartbeatTimer = setInterval(() => {
|
|
225
|
+
if (this.connected) {
|
|
226
|
+
this._sendMessage({ type: 'ping' });
|
|
227
|
+
}
|
|
228
|
+
}, HEARTBEAT_INTERVAL);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
_stopHeartbeat() {
|
|
232
|
+
if (this.heartbeatTimer) {
|
|
233
|
+
clearInterval(this.heartbeatTimer);
|
|
234
|
+
this.heartbeatTimer = null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
_scheduleReconnect() {
|
|
239
|
+
if (this.signal?.aborted || this.disconnecting) return;
|
|
240
|
+
if (this.reconnectTimer) return;
|
|
241
|
+
if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
242
|
+
this._setState('error', `Failed to reconnect after ${MAX_RECONNECT_ATTEMPTS} attempts`);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
this.reconnectAttempts++;
|
|
247
|
+
const delay = RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts - 1);
|
|
248
|
+
|
|
249
|
+
this._setState('reconnecting', `Reconnecting in ${Math.round(delay / 1000)}s (attempt ${this.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`);
|
|
250
|
+
|
|
251
|
+
this.reconnectTimer = setTimeout(() => {
|
|
252
|
+
this.reconnectTimer = null;
|
|
253
|
+
this.connect().catch(() => {
|
|
254
|
+
// Will schedule another reconnect
|
|
255
|
+
});
|
|
256
|
+
}, delay);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
_setState(phase, detail) {
|
|
260
|
+
if (this.onStateChange) {
|
|
261
|
+
this.onStateChange({ phase, detail });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
disconnect() {
|
|
266
|
+
// Mark as disconnected (don't modify external signal)
|
|
267
|
+
this.disconnecting = true;
|
|
268
|
+
this._stopHeartbeat();
|
|
269
|
+
|
|
270
|
+
if (this.reconnectTimer) {
|
|
271
|
+
clearTimeout(this.reconnectTimer);
|
|
272
|
+
this.reconnectTimer = null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (this.ws) {
|
|
276
|
+
this.ws.close();
|
|
277
|
+
this.ws = null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
this.connected = false;
|
|
281
|
+
this.publicUrl = null;
|
|
282
|
+
|
|
283
|
+
// Reject all pending requests
|
|
284
|
+
for (const [id, pending] of this.pendingRequests) {
|
|
285
|
+
pending.reject(new Error('Disconnected'));
|
|
286
|
+
this.pendingRequests.delete(id);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
this.logger?.info('Tunnel disconnected');
|
|
290
|
+
}
|
|
291
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wenbin_wb/dsh-bridge",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "DSH 多渠道接入桥:局域网二维码、自建隧道、机器人集成",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"default": "./lib/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./client": "./client/client.js",
|
|
12
|
+
"./package.json": "./package.json"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"lib",
|
|
16
|
+
"client",
|
|
17
|
+
"cordis.patch.yml",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build:client": "node client/build.mjs"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"qrcode": "^1.5.3",
|
|
26
|
+
"ws": "^8.18.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"esbuild": "^0.25.9"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"@deepseek-ai/cordis": "^4.0.1"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=22"
|
|
36
|
+
},
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"keywords": [
|
|
39
|
+
"deepseek-harness",
|
|
40
|
+
"dsh",
|
|
41
|
+
"dsh-plugin",
|
|
42
|
+
"remote",
|
|
43
|
+
"tunnel",
|
|
44
|
+
"qr",
|
|
45
|
+
"bot"
|
|
46
|
+
],
|
|
47
|
+
"dsh": {
|
|
48
|
+
"bundle": {
|
|
49
|
+
"patch": "./cordis.patch.yml"
|
|
50
|
+
},
|
|
51
|
+
"client": {
|
|
52
|
+
"inject": [
|
|
53
|
+
"@deepseek-ai/dsh-client-connection",
|
|
54
|
+
"@deepseek-ai/dsh-client-ui-slots",
|
|
55
|
+
"@deepseek-ai/dsh-client-ui-layout",
|
|
56
|
+
"@deepseek-ai/dsh-client-locale"
|
|
57
|
+
],
|
|
58
|
+
"platform": "web"
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"repository": {
|
|
62
|
+
"type": "git",
|
|
63
|
+
"url": "git+https://github.com/wenbin-wb/dsh-bridge.git"
|
|
64
|
+
},
|
|
65
|
+
"bugs": {
|
|
66
|
+
"url": "https://github.com/wenbin-wb/dsh-bridge/issues"
|
|
67
|
+
},
|
|
68
|
+
"homepage": "https://github.com/wenbin-wb/dsh-bridge",
|
|
69
|
+
"publishConfig": {
|
|
70
|
+
"access": "public",
|
|
71
|
+
"registry": "https://registry.npmjs.org/"
|
|
72
|
+
}
|
|
73
|
+
}
|