@dainprotocol/tunnel 1.1.14 → 1.1.16
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 +1 -0
- package/dist/client/index.js +77 -155
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.js +134 -170
- package/package.json +3 -3
package/dist/client/index.d.ts
CHANGED
package/dist/client/index.js
CHANGED
|
@@ -29,6 +29,13 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
29
29
|
this.apiKey = apiKey;
|
|
30
30
|
this.tunnelId = `${parsed.orgId}_${parsed.agentId}`; // orgId_agentId to prevent collisions
|
|
31
31
|
this.secret = parsed.secret; // secret for HMAC signatures
|
|
32
|
+
// High-frequency optimization: Create reusable HTTP agent with connection pooling
|
|
33
|
+
this.httpAgent = new http_1.default.Agent({
|
|
34
|
+
keepAlive: true,
|
|
35
|
+
keepAliveMsecs: 30000, // Keep connections alive for 30s
|
|
36
|
+
maxSockets: 50, // Allow up to 50 concurrent connections to local service
|
|
37
|
+
maxFreeSockets: 10, // Keep up to 10 idle connections
|
|
38
|
+
});
|
|
32
39
|
}
|
|
33
40
|
/**
|
|
34
41
|
* Sign a challenge using HMAC-SHA256
|
|
@@ -46,10 +53,8 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
46
53
|
async connect() {
|
|
47
54
|
return new Promise((resolve, reject) => {
|
|
48
55
|
try {
|
|
49
|
-
console.log(`Connecting to WebSocket server: ${this.serverUrl}`);
|
|
50
56
|
this.ws = new ws_1.default(this.serverUrl);
|
|
51
57
|
this.ws.on("open", async () => {
|
|
52
|
-
console.log('WebSocket connection opened');
|
|
53
58
|
this.reconnectAttempts = 0;
|
|
54
59
|
try {
|
|
55
60
|
const challenge = await this.requestChallenge();
|
|
@@ -65,46 +70,51 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
65
70
|
this.emit("connected");
|
|
66
71
|
}
|
|
67
72
|
catch (err) {
|
|
68
|
-
console.error('Error during challenge-response:', err);
|
|
69
73
|
reject(err);
|
|
70
74
|
}
|
|
71
75
|
});
|
|
72
76
|
this.ws.on("message", (data) => {
|
|
73
77
|
try {
|
|
74
|
-
|
|
75
|
-
this.handleMessage(message, resolve);
|
|
78
|
+
this.handleMessage(JSON.parse(data), resolve);
|
|
76
79
|
}
|
|
77
80
|
catch (err) {
|
|
78
|
-
console.error('Error handling message:', err);
|
|
79
81
|
reject(err);
|
|
80
82
|
}
|
|
81
83
|
});
|
|
82
84
|
this.ws.on("close", () => {
|
|
83
|
-
|
|
85
|
+
// Clean up all active SSE connections
|
|
86
|
+
for (const [, client] of this.sseClients) {
|
|
87
|
+
try {
|
|
88
|
+
client.destroy();
|
|
89
|
+
}
|
|
90
|
+
catch (e) { /* ignore */ }
|
|
91
|
+
}
|
|
92
|
+
this.sseClients.clear();
|
|
93
|
+
// Clean up all WebSocket clients
|
|
94
|
+
for (const [, client] of this.webSocketClients) {
|
|
95
|
+
try {
|
|
96
|
+
if (client.readyState === ws_1.default.OPEN)
|
|
97
|
+
client.close(1001);
|
|
98
|
+
}
|
|
99
|
+
catch (e) { /* ignore */ }
|
|
100
|
+
}
|
|
101
|
+
this.webSocketClients.clear();
|
|
84
102
|
if (this.tunnelUrl) {
|
|
85
103
|
this.emit("disconnected");
|
|
86
104
|
this.attemptReconnect();
|
|
87
105
|
}
|
|
88
106
|
else {
|
|
89
|
-
|
|
90
|
-
reject(new Error("Connection closed before tunnel was established"));
|
|
107
|
+
reject(new Error("Connection closed before tunnel established"));
|
|
91
108
|
}
|
|
92
109
|
});
|
|
93
|
-
this.ws.on("error", (error) =>
|
|
94
|
-
// Prevent unhandled error events
|
|
95
|
-
// Don't reject here, the close handler will be called after an error
|
|
96
|
-
this.emit("error", error);
|
|
97
|
-
});
|
|
98
|
-
// Add a timeout to reject the promise if connection takes too long
|
|
110
|
+
this.ws.on("error", (error) => this.emit("error", error));
|
|
99
111
|
setTimeout(() => {
|
|
100
112
|
if (!this.ws || this.ws.readyState !== ws_1.default.OPEN) {
|
|
101
|
-
|
|
102
|
-
reject(timeoutError);
|
|
113
|
+
reject(new Error("Connection timeout"));
|
|
103
114
|
}
|
|
104
115
|
}, 10000);
|
|
105
116
|
}
|
|
106
117
|
catch (err) {
|
|
107
|
-
console.error('Error creating WebSocket:', err);
|
|
108
118
|
reject(err);
|
|
109
119
|
}
|
|
110
120
|
});
|
|
@@ -170,65 +180,37 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
170
180
|
}
|
|
171
181
|
}
|
|
172
182
|
handleWebSocketConnection(message) {
|
|
183
|
+
var _a;
|
|
173
184
|
try {
|
|
174
|
-
console.log(`Creating local WebSocket connection to: ws://localhost:${this.port}${message.path}`);
|
|
175
|
-
// Create a WebSocket connection to the local server
|
|
176
185
|
const client = new ws_1.default(`ws://localhost:${this.port}${message.path}`, {
|
|
177
186
|
headers: message.headers
|
|
178
187
|
});
|
|
179
|
-
// Store the client
|
|
180
188
|
this.webSocketClients.set(message.id, client);
|
|
181
|
-
// Handle connection open
|
|
182
|
-
client.on('open', () => {
|
|
183
|
-
console.log(`Local WebSocket connection opened: ${message.id}`);
|
|
184
|
-
});
|
|
185
|
-
// Handle messages from the local server
|
|
186
189
|
client.on('message', (data) => {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
id: message.id,
|
|
191
|
-
event: 'message',
|
|
192
|
-
data: data.toString('base64')
|
|
193
|
-
}));
|
|
190
|
+
var _a;
|
|
191
|
+
if (((_a = this.ws) === null || _a === void 0 ? void 0 : _a.readyState) === ws_1.default.OPEN) {
|
|
192
|
+
this.ws.send(JSON.stringify({ type: 'websocket', id: message.id, event: 'message', data: data.toString('base64') }));
|
|
194
193
|
}
|
|
195
194
|
});
|
|
196
|
-
// Handle close
|
|
197
195
|
client.on('close', () => {
|
|
198
|
-
|
|
199
|
-
if (this.ws
|
|
200
|
-
this.ws.send(JSON.stringify({
|
|
201
|
-
type: 'websocket',
|
|
202
|
-
id: message.id,
|
|
203
|
-
event: 'close'
|
|
204
|
-
}));
|
|
196
|
+
var _a;
|
|
197
|
+
if (((_a = this.ws) === null || _a === void 0 ? void 0 : _a.readyState) === ws_1.default.OPEN) {
|
|
198
|
+
this.ws.send(JSON.stringify({ type: 'websocket', id: message.id, event: 'close' }));
|
|
205
199
|
}
|
|
206
200
|
this.webSocketClients.delete(message.id);
|
|
207
201
|
});
|
|
208
|
-
// Handle errors
|
|
209
202
|
client.on('error', (error) => {
|
|
210
|
-
|
|
211
|
-
if (this.ws
|
|
212
|
-
this.ws.send(JSON.stringify({
|
|
213
|
-
type: 'websocket',
|
|
214
|
-
id: message.id,
|
|
215
|
-
event: 'error',
|
|
216
|
-
data: error.message
|
|
217
|
-
}));
|
|
203
|
+
var _a;
|
|
204
|
+
if (((_a = this.ws) === null || _a === void 0 ? void 0 : _a.readyState) === ws_1.default.OPEN) {
|
|
205
|
+
this.ws.send(JSON.stringify({ type: 'websocket', id: message.id, event: 'error', data: error.message }));
|
|
218
206
|
}
|
|
219
207
|
this.webSocketClients.delete(message.id);
|
|
220
208
|
});
|
|
221
209
|
this.emit("websocket_connection", { id: message.id, path: message.path });
|
|
222
210
|
}
|
|
223
211
|
catch (error) {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
this.ws.send(JSON.stringify({
|
|
227
|
-
type: 'websocket',
|
|
228
|
-
id: message.id,
|
|
229
|
-
event: 'error',
|
|
230
|
-
data: error.message
|
|
231
|
-
}));
|
|
212
|
+
if (((_a = this.ws) === null || _a === void 0 ? void 0 : _a.readyState) === ws_1.default.OPEN) {
|
|
213
|
+
this.ws.send(JSON.stringify({ type: 'websocket', id: message.id, event: 'error', data: error.message }));
|
|
232
214
|
}
|
|
233
215
|
}
|
|
234
216
|
}
|
|
@@ -250,134 +232,80 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
250
232
|
}
|
|
251
233
|
}
|
|
252
234
|
handleSSEConnection(message) {
|
|
235
|
+
var _a;
|
|
253
236
|
try {
|
|
254
|
-
console.log(`[SSE E2E] Client: Establishing SSE connection ${message.id} to localhost:${this.port}${message.path}`);
|
|
255
|
-
// Create an EventSource-like stream to the local server
|
|
256
|
-
// Since Node.js doesn't have a built-in EventSource, we'll use HTTP
|
|
257
237
|
const options = {
|
|
258
238
|
hostname: 'localhost',
|
|
259
239
|
port: this.port,
|
|
260
240
|
path: message.path,
|
|
261
|
-
method: message.method || 'GET',
|
|
241
|
+
method: message.method || 'GET',
|
|
262
242
|
headers: message.headers,
|
|
243
|
+
agent: this.httpAgent,
|
|
263
244
|
};
|
|
264
245
|
const req = http_1.default.request(options, (res) => {
|
|
246
|
+
var _a;
|
|
265
247
|
if (res.statusCode !== 200) {
|
|
266
|
-
|
|
267
|
-
if (this.ws
|
|
268
|
-
this.ws.send(JSON.stringify({
|
|
269
|
-
type: 'sse',
|
|
270
|
-
id: message.id,
|
|
271
|
-
event: 'error',
|
|
272
|
-
data: `Server responded with status code ${res.statusCode}`
|
|
273
|
-
}));
|
|
248
|
+
res.resume();
|
|
249
|
+
if (((_a = this.ws) === null || _a === void 0 ? void 0 : _a.readyState) === ws_1.default.OPEN) {
|
|
250
|
+
this.ws.send(JSON.stringify({ type: 'sse', id: message.id, event: 'error', data: `Status ${res.statusCode}` }));
|
|
274
251
|
}
|
|
275
252
|
return;
|
|
276
253
|
}
|
|
277
|
-
// Optimize socket for low-latency streaming
|
|
278
254
|
const socket = res.socket || res.connection;
|
|
279
|
-
if (socket
|
|
280
|
-
socket.setNoDelay(true);
|
|
281
|
-
}
|
|
282
|
-
console.log(`[SSE E2E] Client: Service accepted SSE connection ${message.id}, streaming started`);
|
|
283
|
-
// Process SSE stream
|
|
255
|
+
if (socket === null || socket === void 0 ? void 0 : socket.setNoDelay)
|
|
256
|
+
socket.setNoDelay(true);
|
|
284
257
|
let buffer = '';
|
|
285
|
-
let eventCount = 0;
|
|
286
258
|
res.on('data', (chunk) => {
|
|
287
|
-
|
|
288
|
-
buffer +=
|
|
289
|
-
// Process complete SSE messages
|
|
259
|
+
var _a;
|
|
260
|
+
buffer += chunk.toString();
|
|
290
261
|
while (buffer.includes('\n\n')) {
|
|
291
|
-
const
|
|
292
|
-
const
|
|
293
|
-
buffer = buffer.substring(
|
|
294
|
-
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
let data = '';
|
|
298
|
-
for (const line of lines) {
|
|
299
|
-
if (line.startsWith('event:')) {
|
|
262
|
+
const idx = buffer.indexOf('\n\n');
|
|
263
|
+
const msgData = buffer.substring(0, idx);
|
|
264
|
+
buffer = buffer.substring(idx + 2);
|
|
265
|
+
let event = 'message', data = '';
|
|
266
|
+
for (const line of msgData.split('\n')) {
|
|
267
|
+
if (line.startsWith('event:'))
|
|
300
268
|
event = line.substring(6).trim();
|
|
301
|
-
|
|
302
|
-
else if (line.startsWith('data:')) {
|
|
269
|
+
else if (line.startsWith('data:'))
|
|
303
270
|
data += line.substring(5).trim() + '\n';
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
// Remove trailing newline
|
|
307
|
-
if (data.endsWith('\n')) {
|
|
308
|
-
data = data.substring(0, data.length - 1);
|
|
309
271
|
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
if (
|
|
313
|
-
|
|
314
|
-
}
|
|
315
|
-
// Forward to server
|
|
316
|
-
if (this.ws && this.ws.readyState === ws_1.default.OPEN) {
|
|
317
|
-
this.ws.send(JSON.stringify({
|
|
318
|
-
type: 'sse',
|
|
319
|
-
id: message.id,
|
|
320
|
-
event,
|
|
321
|
-
data
|
|
322
|
-
}));
|
|
272
|
+
if (data.endsWith('\n'))
|
|
273
|
+
data = data.slice(0, -1);
|
|
274
|
+
if (((_a = this.ws) === null || _a === void 0 ? void 0 : _a.readyState) === ws_1.default.OPEN) {
|
|
275
|
+
this.ws.send(JSON.stringify({ type: 'sse', id: message.id, event, data }));
|
|
323
276
|
}
|
|
324
277
|
}
|
|
325
278
|
});
|
|
326
279
|
res.on('end', () => {
|
|
327
|
-
|
|
328
|
-
if (this.ws
|
|
329
|
-
this.ws.send(JSON.stringify({
|
|
330
|
-
type: 'sse',
|
|
331
|
-
id: message.id,
|
|
332
|
-
event: 'close',
|
|
333
|
-
data: 'Connection closed'
|
|
334
|
-
}));
|
|
280
|
+
var _a;
|
|
281
|
+
if (((_a = this.ws) === null || _a === void 0 ? void 0 : _a.readyState) === ws_1.default.OPEN) {
|
|
282
|
+
this.ws.send(JSON.stringify({ type: 'sse', id: message.id, event: 'close', data: 'Connection closed' }));
|
|
335
283
|
}
|
|
336
284
|
});
|
|
337
285
|
});
|
|
338
286
|
req.on('error', (error) => {
|
|
339
|
-
|
|
340
|
-
if (this.ws
|
|
341
|
-
this.ws.send(JSON.stringify({
|
|
342
|
-
type: 'sse',
|
|
343
|
-
id: message.id,
|
|
344
|
-
event: 'error',
|
|
345
|
-
data: error.message
|
|
346
|
-
}));
|
|
287
|
+
var _a;
|
|
288
|
+
if (((_a = this.ws) === null || _a === void 0 ? void 0 : _a.readyState) === ws_1.default.OPEN) {
|
|
289
|
+
this.ws.send(JSON.stringify({ type: 'sse', id: message.id, event: 'error', data: error.message }));
|
|
347
290
|
}
|
|
348
291
|
});
|
|
349
|
-
|
|
350
|
-
if (message.body && message.method !== 'GET') {
|
|
292
|
+
if (message.body && message.method !== 'GET')
|
|
351
293
|
req.write(Buffer.from(message.body, 'base64'));
|
|
352
|
-
}
|
|
353
294
|
req.end();
|
|
354
|
-
// Store a reference to abort the connection later if needed
|
|
355
295
|
this.sseClients.set(message.id, req);
|
|
356
296
|
this.emit("sse_connection", { id: message.id, path: message.path });
|
|
357
297
|
}
|
|
358
298
|
catch (error) {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
this.ws.send(JSON.stringify({
|
|
362
|
-
type: 'sse',
|
|
363
|
-
id: message.id,
|
|
364
|
-
event: 'error',
|
|
365
|
-
data: error.message
|
|
366
|
-
}));
|
|
299
|
+
if (((_a = this.ws) === null || _a === void 0 ? void 0 : _a.readyState) === ws_1.default.OPEN) {
|
|
300
|
+
this.ws.send(JSON.stringify({ type: 'sse', id: message.id, event: 'error', data: error.message }));
|
|
367
301
|
}
|
|
368
302
|
}
|
|
369
303
|
}
|
|
370
304
|
handleSSEClose(message) {
|
|
371
|
-
console.log(`[SSE E2E] Client: Received close command from server for ${message.id}`);
|
|
372
305
|
const client = this.sseClients.get(message.id);
|
|
373
306
|
if (client) {
|
|
374
|
-
// Abort the request if it's still active
|
|
375
307
|
client.destroy();
|
|
376
308
|
this.sseClients.delete(message.id);
|
|
377
|
-
console.log(`[SSE E2E] Client: Destroyed local service connection for ${message.id}`);
|
|
378
|
-
}
|
|
379
|
-
else {
|
|
380
|
-
console.log(`[SSE E2E] Client: No active connection found for ${message.id} (already cleaned up)`);
|
|
381
309
|
}
|
|
382
310
|
}
|
|
383
311
|
forwardRequest(request) {
|
|
@@ -388,6 +316,7 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
388
316
|
path: request.path,
|
|
389
317
|
method: request.method,
|
|
390
318
|
headers: request.headers,
|
|
319
|
+
agent: this.httpAgent, // Use connection pooling
|
|
391
320
|
};
|
|
392
321
|
const req = http_1.default.request(options, (res) => {
|
|
393
322
|
let body = Buffer.from([]);
|
|
@@ -422,25 +351,14 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
422
351
|
attemptReconnect() {
|
|
423
352
|
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
424
353
|
this.reconnectAttempts++;
|
|
425
|
-
console.log(`Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
|
426
354
|
setTimeout(async () => {
|
|
427
355
|
try {
|
|
428
356
|
await this.connect();
|
|
429
|
-
// If the connect succeeds but we don't have a tunnelUrl yet, we're still initializing
|
|
430
|
-
if (!this.tunnelUrl) {
|
|
431
|
-
console.log('Reconnected but tunnelUrl not set. Waiting for tunnel URL...');
|
|
432
|
-
}
|
|
433
|
-
else {
|
|
434
|
-
console.log(`Reconnected successfully. Tunnel URL: ${this.tunnelUrl}`);
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
catch (error) {
|
|
438
|
-
console.error(`Reconnection attempt ${this.reconnectAttempts} failed:`, error);
|
|
439
357
|
}
|
|
358
|
+
catch (error) { /* ignore */ }
|
|
440
359
|
}, this.reconnectDelay);
|
|
441
360
|
}
|
|
442
361
|
else {
|
|
443
|
-
console.log('Maximum reconnection attempts reached');
|
|
444
362
|
this.emit("max_reconnect_attempts");
|
|
445
363
|
}
|
|
446
364
|
}
|
|
@@ -481,6 +399,10 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
481
399
|
}
|
|
482
400
|
this.ws = null;
|
|
483
401
|
}
|
|
402
|
+
// Destroy the HTTP agent to close pooled connections
|
|
403
|
+
if (this.httpAgent) {
|
|
404
|
+
this.httpAgent.destroy();
|
|
405
|
+
}
|
|
484
406
|
// Wait for all connections to close properly
|
|
485
407
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
486
408
|
resolve();
|
package/dist/server/index.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ declare class DainTunnelServer {
|
|
|
9
9
|
private challenges;
|
|
10
10
|
private sseConnections;
|
|
11
11
|
private wsConnections;
|
|
12
|
+
private tunnelRequestCount;
|
|
13
|
+
private readonly MAX_CONCURRENT_REQUESTS_PER_TUNNEL;
|
|
12
14
|
constructor(hostname: string, port: number);
|
|
13
15
|
private setupExpressRoutes;
|
|
14
16
|
private setupWebSocketServer;
|
package/dist/server/index.js
CHANGED
|
@@ -6,12 +6,16 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
const express_1 = __importDefault(require("express"));
|
|
7
7
|
const http_1 = __importDefault(require("http"));
|
|
8
8
|
const ws_1 = __importDefault(require("ws"));
|
|
9
|
-
const uuid_1 = require("uuid");
|
|
10
9
|
const body_parser_1 = __importDefault(require("body-parser"));
|
|
11
|
-
const cors_1 = __importDefault(require("cors"));
|
|
12
10
|
const auth_1 = require("@dainprotocol/service-sdk/service/auth");
|
|
13
11
|
const crypto_1 = require("crypto");
|
|
14
12
|
const url_1 = require("url");
|
|
13
|
+
// High-frequency optimization: Fast ID generation using crypto.randomBytes
|
|
14
|
+
// ~10x faster than uuid v4 for high-frequency scenarios
|
|
15
|
+
let idCounter = 0;
|
|
16
|
+
function fastId() {
|
|
17
|
+
return `${Date.now().toString(36)}-${(idCounter++).toString(36)}-${(0, crypto_1.randomBytes)(4).toString('hex')}`;
|
|
18
|
+
}
|
|
15
19
|
/**
|
|
16
20
|
* Force immediate flush of SSE data to prevent buffering
|
|
17
21
|
* SSE requires immediate delivery; Node.js res.write() doesn't guarantee this
|
|
@@ -48,30 +52,40 @@ class DainTunnelServer {
|
|
|
48
52
|
this.challenges = new Map();
|
|
49
53
|
this.sseConnections = new Map();
|
|
50
54
|
this.wsConnections = new Map();
|
|
55
|
+
// High-frequency optimization: Track active request count per tunnel for backpressure
|
|
56
|
+
this.tunnelRequestCount = new Map();
|
|
57
|
+
this.MAX_CONCURRENT_REQUESTS_PER_TUNNEL = 100;
|
|
51
58
|
this.app = (0, express_1.default)();
|
|
52
59
|
this.server = http_1.default.createServer(this.app);
|
|
60
|
+
// High-frequency optimization: Configure server for better throughput
|
|
61
|
+
this.server.keepAliveTimeout = 65000; // Keep connections alive longer (default 5s)
|
|
62
|
+
this.server.headersTimeout = 66000; // Must be higher than keepAliveTimeout
|
|
63
|
+
this.server.maxHeadersCount = 100; // Limit header count for security
|
|
53
64
|
this.wss = new ws_1.default.Server({
|
|
54
65
|
server: this.server,
|
|
55
|
-
path: undefined // Allow connections on any path
|
|
66
|
+
path: undefined, // Allow connections on any path
|
|
67
|
+
// High-frequency optimization: Increase backlog and disable per-message deflate
|
|
68
|
+
backlog: 100,
|
|
69
|
+
perMessageDeflate: false, // Disable compression for lower latency
|
|
70
|
+
maxPayload: 100 * 1024 * 1024, // 100MB max payload
|
|
56
71
|
});
|
|
57
|
-
//
|
|
58
|
-
this.app.use((0, cors_1.default)({
|
|
59
|
-
origin: '*',
|
|
60
|
-
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
|
61
|
-
credentials: true
|
|
62
|
-
}));
|
|
63
|
-
// Update CORS middleware
|
|
72
|
+
// Single optimized CORS middleware (avoid duplicate cors() + manual headers)
|
|
64
73
|
this.app.use((req, res, next) => {
|
|
65
74
|
res.header("Access-Control-Allow-Origin", "*");
|
|
66
75
|
res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS");
|
|
67
|
-
res.header("Access-Control-Allow-Headers",
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
76
|
+
res.header("Access-Control-Allow-Headers", "X-DAIN-SIGNATURE, X-DAIN-SMART-ACCOUNT-PDA, X-DAIN-AGENT-ID, X-DAIN-ORG-ID, X-DAIN-ADDRESS, X-DAIN-TIMESTAMP, X-DAIN-WEBHOOK-URL, Content-Type, Authorization, Accept, Origin, X-Requested-With");
|
|
77
|
+
res.header("Access-Control-Allow-Credentials", "true");
|
|
78
|
+
if (req.method === "OPTIONS")
|
|
79
|
+
return res.sendStatus(204);
|
|
71
80
|
next();
|
|
72
81
|
});
|
|
73
|
-
// Add body-parser middleware
|
|
74
|
-
this.app.use(
|
|
82
|
+
// Add body-parser middleware (skip for GET/HEAD/OPTIONS which don't have bodies)
|
|
83
|
+
this.app.use((req, res, next) => {
|
|
84
|
+
if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') {
|
|
85
|
+
return next();
|
|
86
|
+
}
|
|
87
|
+
body_parser_1.default.raw({ type: "*/*", limit: "100mb" })(req, res, next);
|
|
88
|
+
});
|
|
75
89
|
this.setupExpressRoutes();
|
|
76
90
|
this.setupWebSocketServer();
|
|
77
91
|
}
|
|
@@ -83,25 +97,19 @@ class DainTunnelServer {
|
|
|
83
97
|
// Handle WebSocket connections from tunnel clients
|
|
84
98
|
this.wss.on("connection", (ws, req) => {
|
|
85
99
|
var _a;
|
|
86
|
-
console.log("New WebSocket connection");
|
|
87
100
|
try {
|
|
88
|
-
// Check if this is a tunnel client connection or a user connection to be proxied
|
|
89
101
|
const url = (0, url_1.parse)(req.url || '', true);
|
|
90
102
|
const pathParts = ((_a = url.pathname) === null || _a === void 0 ? void 0 : _a.split('/')) || [];
|
|
91
|
-
console.log(`WebSocket connection path: ${url.pathname}`);
|
|
92
103
|
// Client tunnel connection has no tunnelId in the path (or is at root)
|
|
93
104
|
if (!url.pathname || url.pathname === '/' || pathParts.length <= 1 || !pathParts[1]) {
|
|
94
|
-
console.log("Handling as tunnel client connection");
|
|
95
105
|
this.handleTunnelClientConnection(ws, req);
|
|
96
106
|
}
|
|
97
107
|
else {
|
|
98
|
-
// This is a WebSocket connection to be proxied through the tunnel
|
|
99
|
-
console.log(`Handling as proxied WebSocket connection for path: ${url.pathname}`);
|
|
100
108
|
this.handleProxiedWebSocketConnection(ws, req);
|
|
101
109
|
}
|
|
102
110
|
}
|
|
103
111
|
catch (error) {
|
|
104
|
-
console.error("
|
|
112
|
+
console.error("[Tunnel] WebSocket setup error:", error);
|
|
105
113
|
ws.close(1011, "Internal server error");
|
|
106
114
|
}
|
|
107
115
|
});
|
|
@@ -111,7 +119,6 @@ class DainTunnelServer {
|
|
|
111
119
|
ws.on("message", (message) => {
|
|
112
120
|
try {
|
|
113
121
|
const data = JSON.parse(message);
|
|
114
|
-
console.log(`Received WebSocket message: ${data.type}`);
|
|
115
122
|
if (data.type === "challenge_request") {
|
|
116
123
|
this.handleChallengeRequest(ws);
|
|
117
124
|
}
|
|
@@ -129,17 +136,12 @@ class DainTunnelServer {
|
|
|
129
136
|
}
|
|
130
137
|
}
|
|
131
138
|
catch (error) {
|
|
132
|
-
console.error("
|
|
139
|
+
console.error("[Tunnel] Message error:", error);
|
|
133
140
|
ws.close(1008, "Invalid message");
|
|
134
141
|
}
|
|
135
142
|
});
|
|
136
|
-
ws.on("close", () =>
|
|
137
|
-
|
|
138
|
-
this.removeTunnel(ws);
|
|
139
|
-
});
|
|
140
|
-
ws.on("error", (error) => {
|
|
141
|
-
console.error("WebSocket error:", error);
|
|
142
|
-
});
|
|
143
|
+
ws.on("close", () => this.removeTunnel(ws));
|
|
144
|
+
ws.on("error", (error) => console.error("[Tunnel] WS error:", error));
|
|
143
145
|
}
|
|
144
146
|
// Handle incoming WebSocket connections to be proxied through the tunnel
|
|
145
147
|
handleProxiedWebSocketConnection(ws, req) {
|
|
@@ -153,15 +155,12 @@ class DainTunnelServer {
|
|
|
153
155
|
const tunnelId = pathParts[1];
|
|
154
156
|
const remainingPath = '/' + pathParts.slice(2).join('/');
|
|
155
157
|
const tunnel = this.tunnels.get(tunnelId);
|
|
156
|
-
console.log(`Handling WebSocket connection for tunnel: ${tunnelId}, path: ${remainingPath}`);
|
|
157
|
-
console.log(`Available tunnels: ${Array.from(this.tunnels.keys()).join(', ')}`);
|
|
158
158
|
if (!tunnel) {
|
|
159
|
-
console.log(`Tunnel not found for WebSocket connection: ${tunnelId}`);
|
|
160
159
|
ws.close(1008, "Tunnel not found");
|
|
161
160
|
return;
|
|
162
161
|
}
|
|
163
162
|
// Create a WebSocket connection ID
|
|
164
|
-
const wsConnectionId = (
|
|
163
|
+
const wsConnectionId = fastId();
|
|
165
164
|
// Store this connection
|
|
166
165
|
this.wsConnections.set(wsConnectionId, {
|
|
167
166
|
clientSocket: ws,
|
|
@@ -205,10 +204,12 @@ class DainTunnelServer {
|
|
|
205
204
|
});
|
|
206
205
|
}
|
|
207
206
|
handleChallengeRequest(ws) {
|
|
208
|
-
const challenge = (
|
|
209
|
-
const challengeObj = { ws, challenge };
|
|
207
|
+
const challenge = fastId();
|
|
208
|
+
const challengeObj = { ws, challenge, timestamp: Date.now() };
|
|
210
209
|
this.challenges.set(challenge, challengeObj);
|
|
211
210
|
ws.send(JSON.stringify({ type: "challenge", challenge }));
|
|
211
|
+
// Auto-cleanup expired challenges (30 second TTL)
|
|
212
|
+
setTimeout(() => this.challenges.delete(challenge), 30000);
|
|
212
213
|
}
|
|
213
214
|
handleStartMessage(ws, data) {
|
|
214
215
|
const { challenge, signature, tunnelId, apiKey } = data;
|
|
@@ -250,34 +251,27 @@ class DainTunnelServer {
|
|
|
250
251
|
}
|
|
251
252
|
// If tunnel already exists, remove old one
|
|
252
253
|
if (this.tunnels.has(tunnelId)) {
|
|
253
|
-
console.log(`Tunnel ${tunnelId} already exists, replacing it`);
|
|
254
254
|
const oldTunnel = this.tunnels.get(tunnelId);
|
|
255
255
|
if (oldTunnel && oldTunnel.ws !== ws) {
|
|
256
256
|
oldTunnel.ws.close(1000, "Replaced by new connection");
|
|
257
257
|
}
|
|
258
258
|
}
|
|
259
259
|
this.tunnels.set(tunnelId, { id: tunnelId, ws });
|
|
260
|
-
console.log(`Tunnel added: ${tunnelId}`);
|
|
261
|
-
console.log(`Current tunnels: ${Array.from(this.tunnels.keys()).join(', ')}`);
|
|
262
260
|
// Save tunnel ID on the WebSocket object for easy lookup on close
|
|
263
261
|
ws.tunnelId = tunnelId;
|
|
264
|
-
// Add a periodic
|
|
262
|
+
// Add a periodic ping to keep the connection alive (silent - no logging)
|
|
265
263
|
const intervalId = setInterval(() => {
|
|
266
264
|
if (this.tunnels.has(tunnelId)) {
|
|
267
265
|
const tunnel = this.tunnels.get(tunnelId);
|
|
268
266
|
if (tunnel && tunnel.ws === ws && ws.readyState === ws_1.default.OPEN) {
|
|
269
|
-
console.log(`Tunnel ${tunnelId} still active`);
|
|
270
|
-
// Send a ping to keep the connection alive
|
|
271
267
|
try {
|
|
272
268
|
ws.ping();
|
|
273
269
|
}
|
|
274
270
|
catch (error) {
|
|
275
|
-
console.error(`Error sending ping to tunnel ${tunnelId}:`, error);
|
|
276
271
|
clearInterval(intervalId);
|
|
277
272
|
}
|
|
278
273
|
}
|
|
279
274
|
else {
|
|
280
|
-
console.log(`Tunnel ${tunnelId} exists but WebSocket state is invalid, cleaning up`);
|
|
281
275
|
clearInterval(intervalId);
|
|
282
276
|
if (tunnel && tunnel.ws === ws) {
|
|
283
277
|
this.tunnels.delete(tunnelId);
|
|
@@ -285,23 +279,19 @@ class DainTunnelServer {
|
|
|
285
279
|
}
|
|
286
280
|
}
|
|
287
281
|
else {
|
|
288
|
-
console.log(`Tunnel ${tunnelId} not found in periodic check`);
|
|
289
282
|
clearInterval(intervalId);
|
|
290
283
|
}
|
|
291
|
-
},
|
|
284
|
+
}, 30000); // Ping every 30 seconds (reduced frequency)
|
|
292
285
|
// Store the interval ID on the WebSocket object so we can clean it up
|
|
293
286
|
ws.keepAliveInterval = intervalId;
|
|
294
|
-
ws.on("close", () =>
|
|
295
|
-
console.log(`WebSocket for tunnel ${tunnelId} closed, clearing interval`);
|
|
296
|
-
clearInterval(intervalId);
|
|
297
|
-
});
|
|
287
|
+
ws.on("close", () => clearInterval(intervalId));
|
|
298
288
|
let tunnelUrl = `${this.hostname}`;
|
|
299
289
|
if (process.env.SKIP_PORT !== "true") {
|
|
300
290
|
tunnelUrl += `:${this.port}`;
|
|
301
291
|
}
|
|
302
292
|
tunnelUrl += `/${tunnelId}`;
|
|
303
293
|
ws.send(JSON.stringify({ type: "tunnelUrl", url: tunnelUrl }));
|
|
304
|
-
console.log(`
|
|
294
|
+
console.log(`[Tunnel] Created: ${tunnelUrl}`);
|
|
305
295
|
}
|
|
306
296
|
catch (error) {
|
|
307
297
|
console.error(`Error in handleStartMessage for tunnel ${tunnelId}:`, error);
|
|
@@ -311,8 +301,15 @@ class DainTunnelServer {
|
|
|
311
301
|
handleResponseMessage(data) {
|
|
312
302
|
const pendingRequest = this.pendingRequests.get(data.requestId);
|
|
313
303
|
if (pendingRequest) {
|
|
314
|
-
const { res, startTime } = pendingRequest;
|
|
304
|
+
const { res, startTime, tunnelId, timeoutId } = pendingRequest;
|
|
315
305
|
const endTime = Date.now();
|
|
306
|
+
// Clear the timeout since we received a response
|
|
307
|
+
if (timeoutId) {
|
|
308
|
+
clearTimeout(timeoutId);
|
|
309
|
+
}
|
|
310
|
+
// Decrement request counter for backpressure tracking
|
|
311
|
+
const currentCount = this.tunnelRequestCount.get(tunnelId) || 1;
|
|
312
|
+
this.tunnelRequestCount.set(tunnelId, Math.max(0, currentCount - 1));
|
|
316
313
|
const headers = { ...data.headers };
|
|
317
314
|
delete headers["transfer-encoding"];
|
|
318
315
|
delete headers["content-length"];
|
|
@@ -322,99 +319,54 @@ class DainTunnelServer {
|
|
|
322
319
|
.set(headers)
|
|
323
320
|
.set("Content-Length", bodyBuffer.length.toString())
|
|
324
321
|
.send(bodyBuffer);
|
|
325
|
-
console.log(`Request handled: ${data.requestId}, Duration: ${endTime - startTime}ms`);
|
|
326
322
|
this.pendingRequests.delete(data.requestId);
|
|
327
323
|
}
|
|
328
324
|
}
|
|
329
325
|
handleSSEMessage(data) {
|
|
330
326
|
const connection = this.sseConnections.get(data.id);
|
|
331
|
-
if (!connection)
|
|
332
|
-
console.log(`[SSE E2E] Server: Connection not found for ${data.id} (already cleaned up)`);
|
|
327
|
+
if (!connection)
|
|
333
328
|
return;
|
|
334
|
-
}
|
|
335
329
|
const { res, tunnelId } = connection;
|
|
336
330
|
const clientDisconnected = connection.clientDisconnected;
|
|
337
|
-
// Check if this is a "close" event - this is when we should clean up
|
|
338
331
|
const isCloseEvent = data.event === 'close';
|
|
339
332
|
if (isCloseEvent) {
|
|
340
|
-
console.log(`[SSE E2E] Server: Received close event for ${data.id}, cleaning up`);
|
|
341
|
-
// Now we can safely send sse_close to tunnel client
|
|
342
333
|
const tunnel = this.tunnels.get(tunnelId);
|
|
343
334
|
if (tunnel) {
|
|
344
|
-
tunnel.ws.send(JSON.stringify({
|
|
345
|
-
type: "sse_close",
|
|
346
|
-
id: data.id
|
|
347
|
-
}));
|
|
348
|
-
console.log(`[SSE E2E] Server→Client: Sent sse_close command for ${data.id}`);
|
|
335
|
+
tunnel.ws.send(JSON.stringify({ type: "sse_close", id: data.id }));
|
|
349
336
|
}
|
|
350
|
-
// End the response if client is still connected
|
|
351
337
|
if (!clientDisconnected && res.writable) {
|
|
352
338
|
try {
|
|
353
|
-
res.write(`event: ${data.event}\n`);
|
|
354
|
-
res.write(`data: ${data.data}\n`);
|
|
355
|
-
res.write('\n');
|
|
339
|
+
res.write(`event: ${data.event}\ndata: ${data.data}\n\n`);
|
|
356
340
|
flushSSE(res);
|
|
357
341
|
res.end();
|
|
358
|
-
console.log(`[SSE E2E] Server→Browser: Sent close event and ended response for ${data.id}`);
|
|
359
|
-
}
|
|
360
|
-
catch (error) {
|
|
361
|
-
console.error(`[SSE E2E] Server: ❌ Error writing close event for ${data.id}:`, error);
|
|
362
342
|
}
|
|
343
|
+
catch (error) { /* ignore */ }
|
|
363
344
|
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
345
|
+
// Decrement request counter for backpressure tracking
|
|
346
|
+
const currentCount = this.tunnelRequestCount.get(tunnelId) || 1;
|
|
347
|
+
this.tunnelRequestCount.set(tunnelId, Math.max(0, currentCount - 1));
|
|
367
348
|
this.sseConnections.delete(data.id);
|
|
368
349
|
return;
|
|
369
350
|
}
|
|
370
|
-
|
|
371
|
-
if (clientDisconnected) {
|
|
372
|
-
// Only log first discard and key events to avoid spam
|
|
373
|
-
if (!(connection.discardCount)) {
|
|
374
|
-
connection.discardCount = 0;
|
|
375
|
-
}
|
|
376
|
-
connection.discardCount++;
|
|
377
|
-
if (connection.discardCount === 1 || data.event === 'error' || data.event === 'end') {
|
|
378
|
-
console.log(`[SSE E2E] Server: Discarding event="${data.event}" for ${data.id} - browser disconnected (total discarded: ${connection.discardCount})`);
|
|
379
|
-
}
|
|
380
|
-
return;
|
|
381
|
-
}
|
|
382
|
-
// Check if response is still writable before attempting to write
|
|
383
|
-
if (!res.writable) {
|
|
384
|
-
console.log(`[SSE E2E] Server: Response no longer writable for ${data.id}, marking as disconnected`);
|
|
351
|
+
if (clientDisconnected || !res.writable) {
|
|
385
352
|
connection.clientDisconnected = true;
|
|
386
353
|
return;
|
|
387
354
|
}
|
|
388
355
|
try {
|
|
389
|
-
if (data.event)
|
|
356
|
+
if (data.event)
|
|
390
357
|
res.write(`event: ${data.event}\n`);
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
res.write(`data: ${data.data}\n`);
|
|
394
|
-
res.write('\n');
|
|
395
|
-
// Force immediate flush to prevent buffering
|
|
396
|
-
// This is critical for SSE - without it, events may be delayed or lost
|
|
358
|
+
const escapedData = data.data.replace(/\n/g, '\ndata: ');
|
|
359
|
+
res.write(`data: ${escapedData}\n\n`);
|
|
397
360
|
flushSSE(res);
|
|
398
|
-
// Only log key events to avoid spam
|
|
399
|
-
if (!(connection.eventCount)) {
|
|
400
|
-
connection.eventCount = 0;
|
|
401
|
-
}
|
|
402
|
-
connection.eventCount++;
|
|
403
|
-
if (connection.eventCount === 1 || data.event === 'error' || data.event === 'end') {
|
|
404
|
-
console.log(`[SSE E2E] Server→Browser: Forwarded event="${data.event}" for ${data.id} (total events: ${connection.eventCount})`);
|
|
405
|
-
}
|
|
406
361
|
}
|
|
407
362
|
catch (error) {
|
|
408
|
-
console.error(`[SSE E2E] Server: ❌ Error writing SSE message for ${data.id}:`, error);
|
|
409
363
|
connection.clientDisconnected = true;
|
|
410
364
|
}
|
|
411
365
|
}
|
|
412
366
|
handleWebSocketMessage(data) {
|
|
413
367
|
const connection = this.wsConnections.get(data.id);
|
|
414
|
-
if (!connection)
|
|
415
|
-
console.log(`WebSocket connection not found: ${data.id}`);
|
|
368
|
+
if (!connection)
|
|
416
369
|
return;
|
|
417
|
-
}
|
|
418
370
|
const { clientSocket } = connection;
|
|
419
371
|
if (data.event === 'message' && data.data) {
|
|
420
372
|
const messageData = Buffer.from(data.data, 'base64');
|
|
@@ -447,41 +399,57 @@ class DainTunnelServer {
|
|
|
447
399
|
while (retries > 0 && !tunnel) {
|
|
448
400
|
tunnel = this.tunnels.get(tunnelId);
|
|
449
401
|
if (!tunnel) {
|
|
450
|
-
|
|
451
|
-
console.log(`Current tunnels: ${Array.from(this.tunnels.keys()).join(', ')}`);
|
|
452
|
-
await new Promise(resolve => setTimeout(resolve, 100)); // Wait 100ms before retrying
|
|
402
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
453
403
|
retries--;
|
|
454
404
|
}
|
|
455
405
|
}
|
|
456
406
|
if (!tunnel) {
|
|
457
|
-
console.log(`Tunnel not found after retries: ${tunnelId}`);
|
|
458
407
|
return res.status(404).send("Tunnel not found");
|
|
459
408
|
}
|
|
409
|
+
// High-frequency optimization: Check WebSocket buffer and apply backpressure
|
|
410
|
+
if (tunnel.ws.bufferedAmount > 1024 * 1024) {
|
|
411
|
+
return res.status(503).json({ error: "Service Unavailable", message: "Tunnel high load" });
|
|
412
|
+
}
|
|
413
|
+
// Check concurrent request limit per tunnel
|
|
414
|
+
const currentCount = this.tunnelRequestCount.get(tunnelId) || 0;
|
|
415
|
+
if (currentCount >= this.MAX_CONCURRENT_REQUESTS_PER_TUNNEL) {
|
|
416
|
+
return res.status(503).json({ error: "Service Unavailable", message: "Too many concurrent requests" });
|
|
417
|
+
}
|
|
418
|
+
this.tunnelRequestCount.set(tunnelId, currentCount + 1);
|
|
460
419
|
// Check for SSE request
|
|
461
420
|
if (req.headers.accept && req.headers.accept.includes('text/event-stream')) {
|
|
462
421
|
return this.handleSSERequest(req, res, tunnelId, tunnel);
|
|
463
422
|
}
|
|
464
423
|
// Handle regular HTTP request
|
|
465
|
-
const requestId = (
|
|
424
|
+
const requestId = fastId();
|
|
466
425
|
const startTime = Date.now();
|
|
467
|
-
|
|
468
|
-
const
|
|
426
|
+
// Set a timeout for the request (30 seconds)
|
|
427
|
+
const REQUEST_TIMEOUT = 30000;
|
|
428
|
+
const timeoutId = setTimeout(() => {
|
|
429
|
+
const pendingRequest = this.pendingRequests.get(requestId);
|
|
430
|
+
if (pendingRequest) {
|
|
431
|
+
const currentCount = this.tunnelRequestCount.get(tunnelId) || 1;
|
|
432
|
+
this.tunnelRequestCount.set(tunnelId, Math.max(0, currentCount - 1));
|
|
433
|
+
this.pendingRequests.delete(requestId);
|
|
434
|
+
if (!res.headersSent) {
|
|
435
|
+
res.status(504).json({ error: "Gateway Timeout", message: "Request timed out" });
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}, REQUEST_TIMEOUT);
|
|
439
|
+
this.pendingRequests.set(requestId, { res, startTime, tunnelId, timeoutId });
|
|
440
|
+
tunnel.ws.send(JSON.stringify({
|
|
469
441
|
type: "request",
|
|
470
442
|
id: requestId,
|
|
471
443
|
method: req.method,
|
|
472
444
|
path: req.url,
|
|
473
445
|
headers: req.headers,
|
|
474
|
-
body: req.method !== "GET" && req.body
|
|
446
|
+
body: req.method !== "GET" && req.method !== "HEAD" && req.body
|
|
475
447
|
? req.body.toString("base64")
|
|
476
448
|
: undefined,
|
|
477
|
-
};
|
|
478
|
-
tunnel.ws.send(JSON.stringify(requestMessage));
|
|
479
|
-
console.log(`Request forwarded: ${requestId}, Method: ${req.method}, Path: ${req.url}`);
|
|
449
|
+
}));
|
|
480
450
|
}
|
|
481
451
|
handleSSERequest(req, res, tunnelId, tunnel) {
|
|
482
|
-
|
|
483
|
-
const sseId = (0, uuid_1.v4)();
|
|
484
|
-
console.log(`[SSE E2E] Server: Establishing SSE connection ${sseId} from browser to ${req.url}`);
|
|
452
|
+
const sseId = fastId();
|
|
485
453
|
// Optimize TCP socket for low-latency streaming
|
|
486
454
|
const socket = req.socket || req.connection;
|
|
487
455
|
if (socket && socket.setNoDelay) {
|
|
@@ -497,113 +465,109 @@ class DainTunnelServer {
|
|
|
497
465
|
// Flush headers immediately to establish SSE connection
|
|
498
466
|
// Without this, headers may be buffered, delaying connection setup
|
|
499
467
|
res.flushHeaders();
|
|
500
|
-
// Send
|
|
501
|
-
|
|
468
|
+
// Send SSE comment to keep connection alive and verify it's working
|
|
469
|
+
// Comments start with : and are ignored by EventSource but flush the buffer
|
|
470
|
+
res.write(': connected\n\n');
|
|
502
471
|
// Flush the initial write
|
|
503
472
|
flushSSE(res);
|
|
504
|
-
|
|
505
|
-
this.sseConnections.set(sseId, {
|
|
506
|
-
req,
|
|
507
|
-
res,
|
|
508
|
-
id: sseId,
|
|
509
|
-
tunnelId
|
|
510
|
-
});
|
|
511
|
-
console.log(`[SSE E2E] Server→Client: Forwarding SSE connection request ${sseId} to tunnel client`);
|
|
512
|
-
// Notify the tunnel client about the new SSE connection
|
|
473
|
+
this.sseConnections.set(sseId, { req, res, id: sseId, tunnelId });
|
|
513
474
|
tunnel.ws.send(JSON.stringify({
|
|
514
475
|
type: "sse_connection",
|
|
515
476
|
id: sseId,
|
|
516
477
|
path: req.url,
|
|
517
|
-
method: req.method,
|
|
478
|
+
method: req.method,
|
|
518
479
|
headers: req.headers,
|
|
519
480
|
body: req.method !== "GET" && req.body ? req.body.toString("base64") : undefined
|
|
520
481
|
}));
|
|
521
|
-
// Handle client disconnect
|
|
522
482
|
req.on('close', () => {
|
|
523
|
-
console.log(`[SSE E2E] Server: Browser disconnected from SSE ${sseId}`);
|
|
524
|
-
// DO NOT immediately send sse_close to tunnel client!
|
|
525
|
-
// The local service may still be processing and sending SSE events.
|
|
526
|
-
// Instead, mark this connection as "client disconnected" and let the
|
|
527
|
-
// local service finish naturally. We'll clean up when we receive the
|
|
528
|
-
// final "close" event from the service, or when the connection errors.
|
|
529
483
|
const connection = this.sseConnections.get(sseId);
|
|
530
484
|
if (connection) {
|
|
531
|
-
// Mark as client disconnected, but keep the tunnel client connection alive
|
|
532
485
|
connection.clientDisconnected = true;
|
|
533
|
-
|
|
486
|
+
// Clean up after a short delay to allow any pending events to be handled
|
|
487
|
+
setTimeout(() => {
|
|
488
|
+
if (this.sseConnections.has(sseId)) {
|
|
489
|
+
// Decrement request counter for backpressure tracking
|
|
490
|
+
const currentCount = this.tunnelRequestCount.get(tunnelId) || 1;
|
|
491
|
+
this.tunnelRequestCount.set(tunnelId, Math.max(0, currentCount - 1));
|
|
492
|
+
this.sseConnections.delete(sseId);
|
|
493
|
+
// Notify tunnel client to close the connection
|
|
494
|
+
const tunnel = this.tunnels.get(tunnelId);
|
|
495
|
+
if (tunnel && tunnel.ws.readyState === ws_1.default.OPEN) {
|
|
496
|
+
tunnel.ws.send(JSON.stringify({ type: "sse_close", id: sseId }));
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}, 100);
|
|
534
500
|
}
|
|
535
501
|
});
|
|
536
502
|
}
|
|
537
503
|
removeTunnel(ws) {
|
|
538
504
|
try {
|
|
539
|
-
|
|
540
|
-
if (ws.keepAliveInterval) {
|
|
505
|
+
if (ws.keepAliveInterval)
|
|
541
506
|
clearInterval(ws.keepAliveInterval);
|
|
542
|
-
}
|
|
543
|
-
// If we saved tunnelId on the WebSocket object, use it for faster lookup
|
|
544
507
|
const tunnelId = ws.tunnelId;
|
|
545
508
|
let removedTunnelId = tunnelId;
|
|
546
509
|
if (tunnelId && this.tunnels.has(tunnelId)) {
|
|
547
510
|
const tunnel = this.tunnels.get(tunnelId);
|
|
548
|
-
if (tunnel && tunnel.ws === ws)
|
|
511
|
+
if (tunnel && tunnel.ws === ws)
|
|
549
512
|
this.tunnels.delete(tunnelId);
|
|
550
|
-
console.log(`Tunnel removed using stored ID: ${tunnelId}`);
|
|
551
|
-
}
|
|
552
513
|
}
|
|
553
514
|
else {
|
|
554
|
-
// Fall back to iterating through all tunnels
|
|
555
515
|
removedTunnelId = undefined;
|
|
556
516
|
for (const [id, tunnel] of this.tunnels.entries()) {
|
|
557
517
|
if (tunnel.ws === ws) {
|
|
558
518
|
this.tunnels.delete(id);
|
|
559
519
|
removedTunnelId = id;
|
|
560
|
-
console.log(`Tunnel removed: ${id}`);
|
|
561
520
|
break;
|
|
562
521
|
}
|
|
563
522
|
}
|
|
564
523
|
}
|
|
565
524
|
if (removedTunnelId) {
|
|
566
|
-
|
|
525
|
+
this.tunnelRequestCount.delete(removedTunnelId);
|
|
526
|
+
// Close all pending HTTP requests associated with this tunnel
|
|
527
|
+
for (const [requestId, pendingRequest] of this.pendingRequests.entries()) {
|
|
528
|
+
if (pendingRequest.tunnelId === removedTunnelId) {
|
|
529
|
+
try {
|
|
530
|
+
if (pendingRequest.timeoutId)
|
|
531
|
+
clearTimeout(pendingRequest.timeoutId);
|
|
532
|
+
if (!pendingRequest.res.headersSent) {
|
|
533
|
+
pendingRequest.res.status(502).json({ error: "Bad Gateway", message: "Tunnel closed" });
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
catch (error) { /* ignore */ }
|
|
537
|
+
this.pendingRequests.delete(requestId);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
567
540
|
// Close all SSE connections associated with this tunnel
|
|
568
541
|
for (const [sseId, sseConnection] of this.sseConnections.entries()) {
|
|
569
542
|
if (sseConnection.tunnelId === removedTunnelId) {
|
|
570
543
|
try {
|
|
571
544
|
sseConnection.res.end();
|
|
572
545
|
}
|
|
573
|
-
catch (error) {
|
|
574
|
-
console.error(`Error closing SSE connection ${sseId}:`, error);
|
|
575
|
-
}
|
|
546
|
+
catch (error) { /* ignore */ }
|
|
576
547
|
this.sseConnections.delete(sseId);
|
|
577
548
|
}
|
|
578
549
|
}
|
|
579
550
|
// Close all WebSocket connections associated with this tunnel
|
|
580
551
|
for (const [wsId, wsConnection] of this.wsConnections.entries()) {
|
|
581
|
-
|
|
582
|
-
if (wsPath.startsWith(`/${removedTunnelId}/`)) {
|
|
552
|
+
if (wsConnection.path.startsWith(`/${removedTunnelId}/`)) {
|
|
583
553
|
try {
|
|
584
554
|
wsConnection.clientSocket.close(1001, "Tunnel closed");
|
|
585
555
|
}
|
|
586
|
-
catch (error) {
|
|
587
|
-
console.error(`Error closing WebSocket connection ${wsId}:`, error);
|
|
588
|
-
}
|
|
556
|
+
catch (error) { /* ignore */ }
|
|
589
557
|
this.wsConnections.delete(wsId);
|
|
590
558
|
}
|
|
591
559
|
}
|
|
592
560
|
}
|
|
593
|
-
|
|
594
|
-
console.log(`No tunnel found to remove for the closed WebSocket connection`);
|
|
595
|
-
}
|
|
596
|
-
// Also remove any pending challenges for this WebSocket
|
|
561
|
+
// Remove any pending challenges for this WebSocket
|
|
597
562
|
for (const [challenge, challengeObj] of this.challenges.entries()) {
|
|
598
563
|
if (challengeObj.ws === ws) {
|
|
599
564
|
this.challenges.delete(challenge);
|
|
600
|
-
console.log(`Challenge removed for closed WebSocket`);
|
|
601
565
|
break;
|
|
602
566
|
}
|
|
603
567
|
}
|
|
604
568
|
}
|
|
605
569
|
catch (error) {
|
|
606
|
-
console.error("
|
|
570
|
+
console.error("[Tunnel] Remove error:", error);
|
|
607
571
|
}
|
|
608
572
|
}
|
|
609
573
|
async start() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dainprotocol/tunnel",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.16",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"private": false,
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"author": "Ryan",
|
|
20
20
|
"license": "ISC",
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@dainprotocol/service-sdk": "
|
|
22
|
+
"@dainprotocol/service-sdk": "2.0.56",
|
|
23
23
|
"@types/body-parser": "^1.19.5",
|
|
24
24
|
"@types/cors": "^2.8.17",
|
|
25
25
|
"@types/eventsource": "^3.0.0",
|
|
@@ -77,4 +77,4 @@
|
|
|
77
77
|
]
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
|
-
}
|
|
80
|
+
}
|