@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.
@@ -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
+ }