@dainprotocol/tunnel 1.1.33 → 2.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/dist/client/index.d.ts +6 -6
- package/dist/client/index.js +206 -175
- package/dist/server/index.d.ts +8 -5
- package/dist/server/index.js +471 -326
- package/dist/server/start.js +4 -13
- package/package.json +14 -29
package/dist/client/index.d.ts
CHANGED
|
@@ -9,9 +9,11 @@ declare class DainTunnel extends EventEmitter {
|
|
|
9
9
|
private tunnelId;
|
|
10
10
|
private secret;
|
|
11
11
|
private webSocketClients;
|
|
12
|
-
private
|
|
13
|
-
private
|
|
12
|
+
private pendingWebSocketMessages;
|
|
13
|
+
private sseAbortControllers;
|
|
14
14
|
private heartbeatInterval;
|
|
15
|
+
private reconnectTimer;
|
|
16
|
+
private isStopping;
|
|
15
17
|
constructor(serverUrl: string, apiKey: string);
|
|
16
18
|
private signChallenge;
|
|
17
19
|
private safeSend;
|
|
@@ -25,14 +27,12 @@ declare class DainTunnel extends EventEmitter {
|
|
|
25
27
|
private handleRequest;
|
|
26
28
|
private handleWebSocketConnection;
|
|
27
29
|
private handleWebSocketMessage;
|
|
30
|
+
private flushPendingWebSocketMessages;
|
|
31
|
+
private cleanupWebSocketConnection;
|
|
28
32
|
private handleSSEConnection;
|
|
29
33
|
private handleSSEClose;
|
|
30
34
|
private forwardRequest;
|
|
31
35
|
private attemptReconnect;
|
|
32
|
-
/**
|
|
33
|
-
* Reset reconnection attempts counter.
|
|
34
|
-
* Call this to allow reconnection after max_reconnect_attempts was reached.
|
|
35
|
-
*/
|
|
36
36
|
resetReconnection(): void;
|
|
37
37
|
stop(): Promise<void>;
|
|
38
38
|
}
|
package/dist/client/index.js
CHANGED
|
@@ -1,14 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
};
|
|
5
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.DainTunnel = void 0;
|
|
7
|
-
const ws_1 = __importDefault(require("ws"));
|
|
8
|
-
const http_1 = __importDefault(require("http"));
|
|
9
|
-
const events_1 = require("events");
|
|
10
|
-
const crypto_1 = require("crypto");
|
|
11
|
-
const auth_1 = require("@dainprotocol/service-sdk/service/auth");
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import { EventEmitter } from "events";
|
|
3
|
+
import { createHmac } from "crypto";
|
|
4
|
+
import { parseAPIKey } from "@dainprotocol/service-sdk/service/auth";
|
|
12
5
|
const TIMEOUTS = {
|
|
13
6
|
HEARTBEAT: 25000,
|
|
14
7
|
REQUEST: 25000,
|
|
@@ -17,12 +10,33 @@ const TIMEOUTS = {
|
|
|
17
10
|
SHUTDOWN_GRACE: 500,
|
|
18
11
|
};
|
|
19
12
|
const RECONNECT = {
|
|
20
|
-
MAX_ATTEMPTS: 10,
|
|
21
|
-
BASE_DELAY: 1000,
|
|
22
|
-
MAX_DELAY: 30000,
|
|
23
|
-
JITTER: 0.3,
|
|
13
|
+
MAX_ATTEMPTS: 10,
|
|
14
|
+
BASE_DELAY: 1000,
|
|
15
|
+
MAX_DELAY: 30000,
|
|
16
|
+
JITTER: 0.3,
|
|
17
|
+
};
|
|
18
|
+
const LIMITS = {
|
|
19
|
+
MAX_PENDING_WS_MESSAGES_PER_CONNECTION: 256,
|
|
24
20
|
};
|
|
25
|
-
|
|
21
|
+
function rawDataToString(message) {
|
|
22
|
+
if (typeof message === "string")
|
|
23
|
+
return message;
|
|
24
|
+
if (Buffer.isBuffer(message))
|
|
25
|
+
return message.toString("utf8");
|
|
26
|
+
if (Array.isArray(message))
|
|
27
|
+
return Buffer.concat(message).toString("utf8");
|
|
28
|
+
return Buffer.from(message).toString("utf8");
|
|
29
|
+
}
|
|
30
|
+
function rawDataToBuffer(data) {
|
|
31
|
+
if (Buffer.isBuffer(data))
|
|
32
|
+
return data;
|
|
33
|
+
if (Array.isArray(data))
|
|
34
|
+
return Buffer.concat(data);
|
|
35
|
+
if (typeof data === "string")
|
|
36
|
+
return Buffer.from(data, "utf8");
|
|
37
|
+
return Buffer.from(data);
|
|
38
|
+
}
|
|
39
|
+
class DainTunnel extends EventEmitter {
|
|
26
40
|
constructor(serverUrl, apiKey) {
|
|
27
41
|
super();
|
|
28
42
|
this.serverUrl = serverUrl;
|
|
@@ -31,34 +45,30 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
31
45
|
this.port = null;
|
|
32
46
|
this.reconnectAttempts = 0;
|
|
33
47
|
this.webSocketClients = new Map();
|
|
34
|
-
this.
|
|
48
|
+
this.pendingWebSocketMessages = new Map();
|
|
49
|
+
this.sseAbortControllers = new Map();
|
|
35
50
|
this.heartbeatInterval = null;
|
|
36
|
-
|
|
51
|
+
this.reconnectTimer = null;
|
|
52
|
+
this.isStopping = false;
|
|
53
|
+
const parsed = parseAPIKey(apiKey);
|
|
37
54
|
if (!parsed) {
|
|
38
55
|
throw new Error('Invalid API key format. Expected: sk_agent_{agentId}_{orgId}_{secret}');
|
|
39
56
|
}
|
|
40
57
|
this.apiKey = apiKey;
|
|
41
58
|
this.tunnelId = `${parsed.orgId}_${parsed.agentId}`;
|
|
42
59
|
this.secret = parsed.secret;
|
|
43
|
-
this.httpAgent = new http_1.default.Agent({
|
|
44
|
-
keepAlive: true,
|
|
45
|
-
keepAliveMsecs: 30000,
|
|
46
|
-
maxSockets: 100,
|
|
47
|
-
maxFreeSockets: 20,
|
|
48
|
-
});
|
|
49
60
|
}
|
|
50
61
|
signChallenge(challenge) {
|
|
51
|
-
return
|
|
62
|
+
return createHmac('sha256', this.secret).update(challenge).digest('hex');
|
|
52
63
|
}
|
|
53
64
|
safeSend(data) {
|
|
54
|
-
var _a;
|
|
55
65
|
try {
|
|
56
|
-
if (
|
|
66
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
57
67
|
this.ws.send(JSON.stringify(data));
|
|
58
68
|
return true;
|
|
59
69
|
}
|
|
60
70
|
}
|
|
61
|
-
catch
|
|
71
|
+
catch {
|
|
62
72
|
// Connection lost during send
|
|
63
73
|
}
|
|
64
74
|
return false;
|
|
@@ -66,12 +76,11 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
66
76
|
startHeartbeat() {
|
|
67
77
|
this.stopHeartbeat();
|
|
68
78
|
this.heartbeatInterval = setInterval(() => {
|
|
69
|
-
|
|
70
|
-
if (((_a = this.ws) === null || _a === void 0 ? void 0 : _a.readyState) === ws_1.default.OPEN) {
|
|
79
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
71
80
|
try {
|
|
72
81
|
this.ws.ping();
|
|
73
82
|
}
|
|
74
|
-
catch
|
|
83
|
+
catch {
|
|
75
84
|
this.emit("error", new Error("Heartbeat ping failed"));
|
|
76
85
|
}
|
|
77
86
|
}
|
|
@@ -84,6 +93,7 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
84
93
|
}
|
|
85
94
|
}
|
|
86
95
|
async start(port) {
|
|
96
|
+
this.isStopping = false;
|
|
87
97
|
this.port = port;
|
|
88
98
|
return this.connect();
|
|
89
99
|
}
|
|
@@ -107,7 +117,7 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
107
117
|
}
|
|
108
118
|
};
|
|
109
119
|
try {
|
|
110
|
-
this.ws = new
|
|
120
|
+
this.ws = new WebSocket(this.serverUrl);
|
|
111
121
|
this.ws.on("open", async () => {
|
|
112
122
|
this.reconnectAttempts = 0;
|
|
113
123
|
try {
|
|
@@ -130,7 +140,7 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
130
140
|
});
|
|
131
141
|
this.ws.on("message", (data) => {
|
|
132
142
|
try {
|
|
133
|
-
this.handleMessage(JSON.parse(data), (url) => finish(url));
|
|
143
|
+
this.handleMessage(JSON.parse(rawDataToString(data)), (url) => finish(url));
|
|
134
144
|
}
|
|
135
145
|
catch (err) {
|
|
136
146
|
finish(undefined, err);
|
|
@@ -139,6 +149,9 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
139
149
|
this.ws.on("close", () => {
|
|
140
150
|
this.stopHeartbeat();
|
|
141
151
|
this.cleanupAllClients();
|
|
152
|
+
this.ws = null;
|
|
153
|
+
if (this.isStopping)
|
|
154
|
+
return;
|
|
142
155
|
if (this.tunnelUrl) {
|
|
143
156
|
this.emit("disconnected");
|
|
144
157
|
this.attemptReconnect();
|
|
@@ -149,13 +162,12 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
149
162
|
});
|
|
150
163
|
this.ws.on("error", (error) => this.emit("error", error));
|
|
151
164
|
connectionTimeoutId = setTimeout(() => {
|
|
152
|
-
|
|
153
|
-
if (!resolved && (!this.ws || this.ws.readyState !== ws_1.default.OPEN)) {
|
|
165
|
+
if (!resolved && (!this.ws || this.ws.readyState !== WebSocket.OPEN)) {
|
|
154
166
|
finish(undefined, new Error("Connection timeout"));
|
|
155
167
|
try {
|
|
156
|
-
|
|
168
|
+
this.ws?.terminate();
|
|
157
169
|
}
|
|
158
|
-
catch
|
|
170
|
+
catch { }
|
|
159
171
|
}
|
|
160
172
|
}, TIMEOUTS.CONNECTION);
|
|
161
173
|
}
|
|
@@ -165,21 +177,22 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
165
177
|
});
|
|
166
178
|
}
|
|
167
179
|
cleanupAllClients() {
|
|
168
|
-
for (const
|
|
180
|
+
for (const controller of this.sseAbortControllers.values()) {
|
|
169
181
|
try {
|
|
170
|
-
|
|
182
|
+
controller.abort();
|
|
171
183
|
}
|
|
172
|
-
catch
|
|
184
|
+
catch { }
|
|
173
185
|
}
|
|
174
|
-
this.
|
|
186
|
+
this.sseAbortControllers.clear();
|
|
175
187
|
for (const client of this.webSocketClients.values()) {
|
|
176
188
|
try {
|
|
177
|
-
if (client.readyState ===
|
|
189
|
+
if (client.readyState === WebSocket.OPEN)
|
|
178
190
|
client.close(1001);
|
|
179
191
|
}
|
|
180
|
-
catch
|
|
192
|
+
catch { }
|
|
181
193
|
}
|
|
182
194
|
this.webSocketClients.clear();
|
|
195
|
+
this.pendingWebSocketMessages.clear();
|
|
183
196
|
}
|
|
184
197
|
async requestChallenge() {
|
|
185
198
|
return new Promise((resolve, reject) => {
|
|
@@ -190,11 +203,10 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
190
203
|
let resolved = false;
|
|
191
204
|
let timeoutId = null;
|
|
192
205
|
const finish = (challenge, error) => {
|
|
193
|
-
var _a;
|
|
194
206
|
if (resolved)
|
|
195
207
|
return;
|
|
196
208
|
resolved = true;
|
|
197
|
-
|
|
209
|
+
this.ws?.removeListener("message", challengeHandler);
|
|
198
210
|
if (timeoutId) {
|
|
199
211
|
clearTimeout(timeoutId);
|
|
200
212
|
timeoutId = null;
|
|
@@ -208,12 +220,12 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
208
220
|
};
|
|
209
221
|
const challengeHandler = (message) => {
|
|
210
222
|
try {
|
|
211
|
-
const data = JSON.parse(message);
|
|
223
|
+
const data = JSON.parse(rawDataToString(message));
|
|
212
224
|
if (data.type === "challenge") {
|
|
213
225
|
finish(data.challenge);
|
|
214
226
|
}
|
|
215
227
|
}
|
|
216
|
-
catch
|
|
228
|
+
catch { }
|
|
217
229
|
};
|
|
218
230
|
this.ws.on("message", challengeHandler);
|
|
219
231
|
this.ws.send(JSON.stringify({ type: "challenge_request" }));
|
|
@@ -272,28 +284,31 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
272
284
|
this.safeSend({ type: 'websocket', id: message.id, event, data });
|
|
273
285
|
};
|
|
274
286
|
try {
|
|
275
|
-
// Preserve original host for JWT audience validation
|
|
276
287
|
const headers = { ...message.headers };
|
|
277
288
|
const originalHost = message.headers.host;
|
|
278
289
|
if (originalHost && !headers['x-forwarded-host']) {
|
|
279
290
|
headers['x-forwarded-host'] = originalHost;
|
|
280
291
|
headers['x-forwarded-proto'] = 'https';
|
|
281
292
|
}
|
|
282
|
-
delete headers.host;
|
|
283
|
-
const client = new
|
|
293
|
+
delete headers.host;
|
|
294
|
+
const client = new WebSocket(`ws://localhost:${this.port}${message.path}`, {
|
|
284
295
|
headers
|
|
285
296
|
});
|
|
297
|
+
this.pendingWebSocketMessages.set(message.id, []);
|
|
286
298
|
this.webSocketClients.set(message.id, client);
|
|
299
|
+
client.on('open', () => {
|
|
300
|
+
this.flushPendingWebSocketMessages(message.id, sendWsEvent);
|
|
301
|
+
});
|
|
287
302
|
client.on('message', (data) => {
|
|
288
|
-
sendWsEvent('message', data.toString('base64'));
|
|
303
|
+
sendWsEvent('message', rawDataToBuffer(data).toString('base64'));
|
|
289
304
|
});
|
|
290
305
|
client.on('close', () => {
|
|
291
306
|
sendWsEvent('close');
|
|
292
|
-
this.
|
|
307
|
+
this.cleanupWebSocketConnection(message.id);
|
|
293
308
|
});
|
|
294
309
|
client.on('error', (error) => {
|
|
295
310
|
sendWsEvent('error', error.message);
|
|
296
|
-
this.
|
|
311
|
+
this.cleanupWebSocketConnection(message.id);
|
|
297
312
|
});
|
|
298
313
|
this.emit("websocket_connection", { id: message.id, path: message.path });
|
|
299
314
|
}
|
|
@@ -307,25 +322,63 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
307
322
|
return;
|
|
308
323
|
switch (message.event) {
|
|
309
324
|
case 'message':
|
|
310
|
-
if (message.data
|
|
325
|
+
if (!message.data)
|
|
326
|
+
return;
|
|
327
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
311
328
|
client.send(Buffer.from(message.data, 'base64'));
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (client.readyState !== WebSocket.CONNECTING)
|
|
332
|
+
return;
|
|
333
|
+
const pending = this.pendingWebSocketMessages.get(message.id);
|
|
334
|
+
if (!pending)
|
|
335
|
+
return;
|
|
336
|
+
if (pending.length >= LIMITS.MAX_PENDING_WS_MESSAGES_PER_CONNECTION) {
|
|
337
|
+
client.close(1013, "WebSocket upstream overloaded");
|
|
338
|
+
this.cleanupWebSocketConnection(message.id);
|
|
339
|
+
return;
|
|
312
340
|
}
|
|
341
|
+
pending.push(Buffer.from(message.data, 'base64'));
|
|
313
342
|
break;
|
|
314
343
|
case 'close':
|
|
315
|
-
if (client.readyState ===
|
|
344
|
+
if (client.readyState === WebSocket.OPEN || client.readyState === WebSocket.CONNECTING) {
|
|
316
345
|
client.close();
|
|
317
346
|
}
|
|
318
|
-
this.
|
|
347
|
+
this.cleanupWebSocketConnection(message.id);
|
|
348
|
+
break;
|
|
349
|
+
case 'error':
|
|
350
|
+
if (client.readyState === WebSocket.OPEN || client.readyState === WebSocket.CONNECTING) {
|
|
351
|
+
client.close(1011, message.data);
|
|
352
|
+
}
|
|
353
|
+
this.cleanupWebSocketConnection(message.id);
|
|
319
354
|
break;
|
|
320
355
|
}
|
|
321
356
|
}
|
|
357
|
+
flushPendingWebSocketMessages(id, sendWsEvent) {
|
|
358
|
+
const client = this.webSocketClients.get(id);
|
|
359
|
+
const pending = this.pendingWebSocketMessages.get(id);
|
|
360
|
+
if (!client || !pending || pending.length === 0)
|
|
361
|
+
return;
|
|
362
|
+
try {
|
|
363
|
+
while (pending.length > 0 && client.readyState === WebSocket.OPEN) {
|
|
364
|
+
client.send(pending.shift());
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
sendWsEvent("error", error.message);
|
|
369
|
+
this.cleanupWebSocketConnection(id);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
cleanupWebSocketConnection(id) {
|
|
373
|
+
this.pendingWebSocketMessages.delete(id);
|
|
374
|
+
this.webSocketClients.delete(id);
|
|
375
|
+
}
|
|
322
376
|
handleSSEConnection(message) {
|
|
323
377
|
const sendSseEvent = (event, data = '') => {
|
|
324
378
|
this.safeSend({ type: 'sse', id: message.id, event, data });
|
|
325
379
|
};
|
|
326
380
|
try {
|
|
327
381
|
const headers = { ...message.headers };
|
|
328
|
-
// Preserve original host for JWT audience validation
|
|
329
382
|
const originalHost = message.headers.host;
|
|
330
383
|
if (originalHost && !headers['x-forwarded-host']) {
|
|
331
384
|
headers['x-forwarded-host'] = originalHost;
|
|
@@ -333,29 +386,35 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
333
386
|
}
|
|
334
387
|
delete headers.host;
|
|
335
388
|
headers['accept-encoding'] = 'identity';
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
389
|
+
const abortController = new AbortController();
|
|
390
|
+
this.sseAbortControllers.set(message.id, abortController);
|
|
391
|
+
const url = `http://localhost:${this.port}${message.path}`;
|
|
392
|
+
const fetchOptions = {
|
|
340
393
|
method: message.method || 'GET',
|
|
341
394
|
headers,
|
|
342
|
-
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
395
|
+
signal: abortController.signal,
|
|
396
|
+
};
|
|
397
|
+
if (message.body && message.method !== 'GET') {
|
|
398
|
+
fetchOptions.body = Buffer.from(message.body, 'base64');
|
|
399
|
+
}
|
|
400
|
+
fetch(url, fetchOptions)
|
|
401
|
+
.then(async (res) => {
|
|
402
|
+
if (res.status !== 200) {
|
|
403
|
+
const errorBody = await res.text();
|
|
404
|
+
sendSseEvent('error', `Status ${res.status}: ${errorBody.substring(0, 200)}`);
|
|
405
|
+
this.sseAbortControllers.delete(message.id);
|
|
350
406
|
return;
|
|
351
407
|
}
|
|
352
|
-
const socket = res.socket || res.connection;
|
|
353
|
-
if (socket === null || socket === void 0 ? void 0 : socket.setNoDelay)
|
|
354
|
-
socket.setNoDelay(true);
|
|
355
408
|
sendSseEvent('connected');
|
|
409
|
+
if (!res.body) {
|
|
410
|
+
sendSseEvent('close');
|
|
411
|
+
this.sseAbortControllers.delete(message.id);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const reader = res.body.getReader();
|
|
415
|
+
const decoder = new TextDecoder();
|
|
356
416
|
let buffer = '';
|
|
357
417
|
const processBuffer = () => {
|
|
358
|
-
// Normalize line endings once
|
|
359
418
|
if (buffer.indexOf('\r') >= 0) {
|
|
360
419
|
buffer = buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
361
420
|
}
|
|
@@ -363,7 +422,6 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
363
422
|
while ((idx = buffer.indexOf('\n\n')) >= 0) {
|
|
364
423
|
const msgData = buffer.slice(0, idx);
|
|
365
424
|
buffer = buffer.slice(idx + 2);
|
|
366
|
-
// Skip empty messages and comments
|
|
367
425
|
if (!msgData || msgData[0] === ':')
|
|
368
426
|
continue;
|
|
369
427
|
let event = 'message';
|
|
@@ -382,30 +440,36 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
382
440
|
sendSseEvent(event, data);
|
|
383
441
|
}
|
|
384
442
|
};
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
443
|
+
try {
|
|
444
|
+
while (true) {
|
|
445
|
+
const { done, value } = await reader.read();
|
|
446
|
+
if (done)
|
|
447
|
+
break;
|
|
448
|
+
buffer += decoder.decode(value, { stream: true });
|
|
449
|
+
processBuffer();
|
|
450
|
+
}
|
|
451
|
+
// Process remaining buffer
|
|
390
452
|
if (buffer.trim()) {
|
|
391
453
|
buffer += '\n\n';
|
|
392
454
|
processBuffer();
|
|
393
455
|
}
|
|
394
456
|
sendSseEvent('close');
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
457
|
+
}
|
|
458
|
+
catch (err) {
|
|
459
|
+
if (err.name !== 'AbortError') {
|
|
460
|
+
sendSseEvent('error', err.message);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
finally {
|
|
464
|
+
this.sseAbortControllers.delete(message.id);
|
|
465
|
+
}
|
|
466
|
+
})
|
|
467
|
+
.catch((error) => {
|
|
468
|
+
if (error.name !== 'AbortError') {
|
|
469
|
+
sendSseEvent('error', error.message);
|
|
470
|
+
}
|
|
471
|
+
this.sseAbortControllers.delete(message.id);
|
|
403
472
|
});
|
|
404
|
-
if (message.body && message.method !== 'GET') {
|
|
405
|
-
req.write(Buffer.from(message.body, 'base64'));
|
|
406
|
-
}
|
|
407
|
-
req.end();
|
|
408
|
-
this.sseClients.set(message.id, req);
|
|
409
473
|
this.emit("sse_connection", { id: message.id, path: message.path });
|
|
410
474
|
}
|
|
411
475
|
catch (error) {
|
|
@@ -413,125 +477,92 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
413
477
|
}
|
|
414
478
|
}
|
|
415
479
|
handleSSEClose(message) {
|
|
416
|
-
const
|
|
417
|
-
if (
|
|
418
|
-
|
|
419
|
-
this.
|
|
480
|
+
const controller = this.sseAbortControllers.get(message.id);
|
|
481
|
+
if (controller) {
|
|
482
|
+
controller.abort();
|
|
483
|
+
this.sseAbortControllers.delete(message.id);
|
|
420
484
|
}
|
|
421
485
|
}
|
|
422
|
-
forwardRequest(request) {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
if (originalHost && !headers['x-forwarded-host']) {
|
|
445
|
-
headers['x-forwarded-host'] = originalHost;
|
|
446
|
-
headers['x-forwarded-proto'] = 'https';
|
|
447
|
-
}
|
|
448
|
-
delete headers.host;
|
|
449
|
-
const options = {
|
|
450
|
-
hostname: 'localhost',
|
|
451
|
-
port: this.port,
|
|
452
|
-
path: request.path,
|
|
453
|
-
method: request.method,
|
|
454
|
-
headers,
|
|
455
|
-
agent: this.httpAgent,
|
|
456
|
-
timeout: TIMEOUTS.REQUEST,
|
|
457
|
-
};
|
|
458
|
-
const req = http_1.default.request(options, (res) => {
|
|
459
|
-
const chunks = [];
|
|
460
|
-
res.on('data', (chunk) => chunks.push(chunk));
|
|
461
|
-
res.on('end', () => {
|
|
462
|
-
const headers = { ...res.headers };
|
|
463
|
-
delete headers['transfer-encoding'];
|
|
464
|
-
delete headers['content-length'];
|
|
465
|
-
finish({
|
|
466
|
-
type: 'response',
|
|
467
|
-
requestId: request.id,
|
|
468
|
-
status: res.statusCode,
|
|
469
|
-
headers,
|
|
470
|
-
body: Buffer.concat(chunks).toString('base64'),
|
|
471
|
-
});
|
|
472
|
-
});
|
|
473
|
-
res.on('error', (err) => finish(undefined, err));
|
|
474
|
-
});
|
|
475
|
-
req.on('timeout', () => {
|
|
476
|
-
req.destroy();
|
|
477
|
-
finish(undefined, new Error(`Request timeout after ${TIMEOUTS.REQUEST}ms`));
|
|
478
|
-
});
|
|
479
|
-
req.on('error', (err) => finish(undefined, err));
|
|
480
|
-
timeoutId = setTimeout(() => {
|
|
481
|
-
req.destroy();
|
|
482
|
-
finish(undefined, new Error(`Request timeout after ${TIMEOUTS.REQUEST}ms`));
|
|
483
|
-
}, TIMEOUTS.REQUEST);
|
|
484
|
-
if (request.body && request.method !== 'GET') {
|
|
485
|
-
req.write(Buffer.from(request.body, 'base64'));
|
|
486
|
-
}
|
|
487
|
-
req.end();
|
|
486
|
+
async forwardRequest(request) {
|
|
487
|
+
const headers = { ...request.headers };
|
|
488
|
+
const originalHost = request.headers.host;
|
|
489
|
+
if (originalHost && !headers['x-forwarded-host']) {
|
|
490
|
+
headers['x-forwarded-host'] = originalHost;
|
|
491
|
+
headers['x-forwarded-proto'] = 'https';
|
|
492
|
+
}
|
|
493
|
+
delete headers.host;
|
|
494
|
+
const url = `http://localhost:${this.port}${request.path}`;
|
|
495
|
+
const fetchOptions = {
|
|
496
|
+
method: request.method,
|
|
497
|
+
headers,
|
|
498
|
+
signal: AbortSignal.timeout(TIMEOUTS.REQUEST),
|
|
499
|
+
};
|
|
500
|
+
if (request.body && request.method !== 'GET') {
|
|
501
|
+
fetchOptions.body = Buffer.from(request.body, 'base64');
|
|
502
|
+
}
|
|
503
|
+
const res = await fetch(url, fetchOptions);
|
|
504
|
+
const responseBody = await res.arrayBuffer();
|
|
505
|
+
const responseHeaders = {};
|
|
506
|
+
res.headers.forEach((value, key) => {
|
|
507
|
+
responseHeaders[key] = value;
|
|
488
508
|
});
|
|
509
|
+
delete responseHeaders['transfer-encoding'];
|
|
510
|
+
delete responseHeaders['content-length'];
|
|
511
|
+
return {
|
|
512
|
+
type: 'response',
|
|
513
|
+
requestId: request.id,
|
|
514
|
+
status: res.status,
|
|
515
|
+
headers: responseHeaders,
|
|
516
|
+
body: Buffer.from(responseBody).toString('base64'),
|
|
517
|
+
};
|
|
489
518
|
}
|
|
490
519
|
attemptReconnect() {
|
|
520
|
+
if (this.isStopping)
|
|
521
|
+
return;
|
|
491
522
|
if (this.reconnectAttempts >= RECONNECT.MAX_ATTEMPTS) {
|
|
492
523
|
this.emit("max_reconnect_attempts");
|
|
493
524
|
return;
|
|
494
525
|
}
|
|
495
526
|
this.reconnectAttempts++;
|
|
496
|
-
// Exponential backoff with jitter
|
|
497
527
|
const baseDelay = Math.min(RECONNECT.BASE_DELAY * Math.pow(2, this.reconnectAttempts - 1), RECONNECT.MAX_DELAY);
|
|
498
528
|
const jitter = baseDelay * RECONNECT.JITTER * (Math.random() - 0.5);
|
|
499
529
|
const delay = Math.round(baseDelay + jitter);
|
|
500
530
|
this.emit("reconnecting", { attempt: this.reconnectAttempts, delay });
|
|
501
|
-
setTimeout(async () => {
|
|
531
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
502
532
|
try {
|
|
503
533
|
await this.connect();
|
|
504
534
|
this.emit("reconnected");
|
|
505
535
|
}
|
|
506
|
-
catch
|
|
507
|
-
|
|
536
|
+
catch {
|
|
537
|
+
if (!this.isStopping)
|
|
538
|
+
this.attemptReconnect();
|
|
508
539
|
}
|
|
509
540
|
}, delay);
|
|
510
541
|
}
|
|
511
|
-
/**
|
|
512
|
-
* Reset reconnection attempts counter.
|
|
513
|
-
* Call this to allow reconnection after max_reconnect_attempts was reached.
|
|
514
|
-
*/
|
|
515
542
|
resetReconnection() {
|
|
516
543
|
this.reconnectAttempts = 0;
|
|
517
544
|
}
|
|
518
545
|
async stop() {
|
|
546
|
+
this.isStopping = true;
|
|
519
547
|
this.stopHeartbeat();
|
|
520
548
|
this.cleanupAllClients();
|
|
549
|
+
if (this.reconnectTimer) {
|
|
550
|
+
clearTimeout(this.reconnectTimer);
|
|
551
|
+
this.reconnectTimer = null;
|
|
552
|
+
}
|
|
521
553
|
if (this.ws) {
|
|
522
554
|
try {
|
|
523
|
-
if (this.ws.readyState ===
|
|
555
|
+
if (this.ws.readyState === WebSocket.OPEN)
|
|
524
556
|
this.ws.close();
|
|
525
557
|
}
|
|
526
|
-
catch
|
|
558
|
+
catch { }
|
|
527
559
|
this.ws = null;
|
|
528
560
|
}
|
|
529
561
|
this.tunnelUrl = null;
|
|
530
|
-
this.httpAgent.destroy();
|
|
531
562
|
await new Promise(resolve => setTimeout(resolve, TIMEOUTS.SHUTDOWN_GRACE));
|
|
532
563
|
}
|
|
533
564
|
}
|
|
534
|
-
|
|
535
|
-
|
|
565
|
+
export { DainTunnel };
|
|
566
|
+
export default {
|
|
536
567
|
createTunnel: (serverUrl, apiKey) => new DainTunnel(serverUrl, apiKey),
|
|
537
568
|
};
|
package/dist/server/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ declare class DainTunnelServer {
|
|
|
3
3
|
private port;
|
|
4
4
|
private app;
|
|
5
5
|
private server;
|
|
6
|
-
private
|
|
6
|
+
private allowedCorsOrigins;
|
|
7
7
|
private tunnels;
|
|
8
8
|
private pendingRequests;
|
|
9
9
|
private challenges;
|
|
@@ -12,11 +12,14 @@ declare class DainTunnelServer {
|
|
|
12
12
|
private tunnelRequestCount;
|
|
13
13
|
private safeSend;
|
|
14
14
|
private decrementRequestCount;
|
|
15
|
+
private buildForwardedHeaders;
|
|
16
|
+
private buildTunnelUrl;
|
|
15
17
|
constructor(hostname: string, port: number);
|
|
16
|
-
private
|
|
17
|
-
private
|
|
18
|
-
private
|
|
19
|
-
private
|
|
18
|
+
private setupRoutes;
|
|
19
|
+
private handleWsOpen;
|
|
20
|
+
private handleWsMessage;
|
|
21
|
+
private handleWsClose;
|
|
22
|
+
private handleProxiedWebSocketClientMessage;
|
|
20
23
|
private handleChallengeRequest;
|
|
21
24
|
private handleStartMessage;
|
|
22
25
|
private handleResponseMessage;
|