@dainprotocol/tunnel 1.1.13 → 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 +78 -151
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.js +150 -179
- 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,129 +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
|
-
|
|
278
|
-
|
|
254
|
+
const socket = res.socket || res.connection;
|
|
255
|
+
if (socket === null || socket === void 0 ? void 0 : socket.setNoDelay)
|
|
256
|
+
socket.setNoDelay(true);
|
|
279
257
|
let buffer = '';
|
|
280
|
-
let eventCount = 0;
|
|
281
258
|
res.on('data', (chunk) => {
|
|
282
|
-
|
|
283
|
-
buffer +=
|
|
284
|
-
// Process complete SSE messages
|
|
259
|
+
var _a;
|
|
260
|
+
buffer += chunk.toString();
|
|
285
261
|
while (buffer.includes('\n\n')) {
|
|
286
|
-
const
|
|
287
|
-
const
|
|
288
|
-
buffer = buffer.substring(
|
|
289
|
-
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
let data = '';
|
|
293
|
-
for (const line of lines) {
|
|
294
|
-
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:'))
|
|
295
268
|
event = line.substring(6).trim();
|
|
296
|
-
|
|
297
|
-
else if (line.startsWith('data:')) {
|
|
269
|
+
else if (line.startsWith('data:'))
|
|
298
270
|
data += line.substring(5).trim() + '\n';
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
// Remove trailing newline
|
|
302
|
-
if (data.endsWith('\n')) {
|
|
303
|
-
data = data.substring(0, data.length - 1);
|
|
304
|
-
}
|
|
305
|
-
eventCount++;
|
|
306
|
-
// Only log key events (not every progress update)
|
|
307
|
-
if (event === 'error' || event === 'close' || event === 'end' || eventCount === 1) {
|
|
308
|
-
console.log(`[SSE E2E] Client→Server: Forwarding event="${event}" for ${message.id} (total events: ${eventCount})`);
|
|
309
271
|
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
id: message.id,
|
|
315
|
-
event,
|
|
316
|
-
data
|
|
317
|
-
}));
|
|
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 }));
|
|
318
276
|
}
|
|
319
277
|
}
|
|
320
278
|
});
|
|
321
279
|
res.on('end', () => {
|
|
322
|
-
|
|
323
|
-
if (this.ws
|
|
324
|
-
this.ws.send(JSON.stringify({
|
|
325
|
-
type: 'sse',
|
|
326
|
-
id: message.id,
|
|
327
|
-
event: 'close',
|
|
328
|
-
data: 'Connection closed'
|
|
329
|
-
}));
|
|
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' }));
|
|
330
283
|
}
|
|
331
284
|
});
|
|
332
285
|
});
|
|
333
286
|
req.on('error', (error) => {
|
|
334
|
-
|
|
335
|
-
if (this.ws
|
|
336
|
-
this.ws.send(JSON.stringify({
|
|
337
|
-
type: 'sse',
|
|
338
|
-
id: message.id,
|
|
339
|
-
event: 'error',
|
|
340
|
-
data: error.message
|
|
341
|
-
}));
|
|
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 }));
|
|
342
290
|
}
|
|
343
291
|
});
|
|
344
|
-
|
|
345
|
-
if (message.body && message.method !== 'GET') {
|
|
292
|
+
if (message.body && message.method !== 'GET')
|
|
346
293
|
req.write(Buffer.from(message.body, 'base64'));
|
|
347
|
-
}
|
|
348
294
|
req.end();
|
|
349
|
-
// Store a reference to abort the connection later if needed
|
|
350
295
|
this.sseClients.set(message.id, req);
|
|
351
296
|
this.emit("sse_connection", { id: message.id, path: message.path });
|
|
352
297
|
}
|
|
353
298
|
catch (error) {
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
this.ws.send(JSON.stringify({
|
|
357
|
-
type: 'sse',
|
|
358
|
-
id: message.id,
|
|
359
|
-
event: 'error',
|
|
360
|
-
data: error.message
|
|
361
|
-
}));
|
|
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 }));
|
|
362
301
|
}
|
|
363
302
|
}
|
|
364
303
|
}
|
|
365
304
|
handleSSEClose(message) {
|
|
366
|
-
console.log(`[SSE E2E] Client: Received close command from server for ${message.id}`);
|
|
367
305
|
const client = this.sseClients.get(message.id);
|
|
368
306
|
if (client) {
|
|
369
|
-
// Abort the request if it's still active
|
|
370
307
|
client.destroy();
|
|
371
308
|
this.sseClients.delete(message.id);
|
|
372
|
-
console.log(`[SSE E2E] Client: Destroyed local service connection for ${message.id}`);
|
|
373
|
-
}
|
|
374
|
-
else {
|
|
375
|
-
console.log(`[SSE E2E] Client: No active connection found for ${message.id} (already cleaned up)`);
|
|
376
309
|
}
|
|
377
310
|
}
|
|
378
311
|
forwardRequest(request) {
|
|
@@ -383,6 +316,7 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
383
316
|
path: request.path,
|
|
384
317
|
method: request.method,
|
|
385
318
|
headers: request.headers,
|
|
319
|
+
agent: this.httpAgent, // Use connection pooling
|
|
386
320
|
};
|
|
387
321
|
const req = http_1.default.request(options, (res) => {
|
|
388
322
|
let body = Buffer.from([]);
|
|
@@ -417,25 +351,14 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
417
351
|
attemptReconnect() {
|
|
418
352
|
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
419
353
|
this.reconnectAttempts++;
|
|
420
|
-
console.log(`Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
|
421
354
|
setTimeout(async () => {
|
|
422
355
|
try {
|
|
423
356
|
await this.connect();
|
|
424
|
-
// If the connect succeeds but we don't have a tunnelUrl yet, we're still initializing
|
|
425
|
-
if (!this.tunnelUrl) {
|
|
426
|
-
console.log('Reconnected but tunnelUrl not set. Waiting for tunnel URL...');
|
|
427
|
-
}
|
|
428
|
-
else {
|
|
429
|
-
console.log(`Reconnected successfully. Tunnel URL: ${this.tunnelUrl}`);
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
catch (error) {
|
|
433
|
-
console.error(`Reconnection attempt ${this.reconnectAttempts} failed:`, error);
|
|
434
357
|
}
|
|
358
|
+
catch (error) { /* ignore */ }
|
|
435
359
|
}, this.reconnectDelay);
|
|
436
360
|
}
|
|
437
361
|
else {
|
|
438
|
-
console.log('Maximum reconnection attempts reached');
|
|
439
362
|
this.emit("max_reconnect_attempts");
|
|
440
363
|
}
|
|
441
364
|
}
|
|
@@ -476,6 +399,10 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
476
399
|
}
|
|
477
400
|
this.ws = null;
|
|
478
401
|
}
|
|
402
|
+
// Destroy the HTTP agent to close pooled connections
|
|
403
|
+
if (this.httpAgent) {
|
|
404
|
+
this.httpAgent.destroy();
|
|
405
|
+
}
|
|
479
406
|
// Wait for all connections to close properly
|
|
480
407
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
481
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
|
|
@@ -20,16 +24,18 @@ const url_1 = require("url");
|
|
|
20
24
|
*/
|
|
21
25
|
function flushSSE(res) {
|
|
22
26
|
try {
|
|
23
|
-
//
|
|
24
|
-
//
|
|
27
|
+
// Node.js TCP sockets use cork/uncork for buffering control
|
|
28
|
+
// We uncork to force immediate transmission of buffered data
|
|
25
29
|
const socket = res.socket;
|
|
26
|
-
if (socket
|
|
27
|
-
socket
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
if (socket) {
|
|
31
|
+
// If socket is corked, uncork it to flush immediately
|
|
32
|
+
if (socket.uncork) {
|
|
33
|
+
socket.uncork();
|
|
34
|
+
}
|
|
35
|
+
// Disable Nagle's algorithm for low-latency streaming
|
|
36
|
+
if (socket.setNoDelay && !socket._noDelay) {
|
|
37
|
+
socket.setNoDelay(true);
|
|
38
|
+
}
|
|
33
39
|
}
|
|
34
40
|
}
|
|
35
41
|
catch (error) {
|
|
@@ -46,30 +52,40 @@ class DainTunnelServer {
|
|
|
46
52
|
this.challenges = new Map();
|
|
47
53
|
this.sseConnections = new Map();
|
|
48
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;
|
|
49
58
|
this.app = (0, express_1.default)();
|
|
50
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
|
|
51
64
|
this.wss = new ws_1.default.Server({
|
|
52
65
|
server: this.server,
|
|
53
|
-
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
|
|
54
71
|
});
|
|
55
|
-
//
|
|
56
|
-
this.app.use((0, cors_1.default)({
|
|
57
|
-
origin: '*',
|
|
58
|
-
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
|
59
|
-
credentials: true
|
|
60
|
-
}));
|
|
61
|
-
// Update CORS middleware
|
|
72
|
+
// Single optimized CORS middleware (avoid duplicate cors() + manual headers)
|
|
62
73
|
this.app.use((req, res, next) => {
|
|
63
74
|
res.header("Access-Control-Allow-Origin", "*");
|
|
64
75
|
res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS");
|
|
65
|
-
res.header("Access-Control-Allow-Headers",
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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);
|
|
69
80
|
next();
|
|
70
81
|
});
|
|
71
|
-
// Add body-parser middleware
|
|
72
|
-
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
|
+
});
|
|
73
89
|
this.setupExpressRoutes();
|
|
74
90
|
this.setupWebSocketServer();
|
|
75
91
|
}
|
|
@@ -81,25 +97,19 @@ class DainTunnelServer {
|
|
|
81
97
|
// Handle WebSocket connections from tunnel clients
|
|
82
98
|
this.wss.on("connection", (ws, req) => {
|
|
83
99
|
var _a;
|
|
84
|
-
console.log("New WebSocket connection");
|
|
85
100
|
try {
|
|
86
|
-
// Check if this is a tunnel client connection or a user connection to be proxied
|
|
87
101
|
const url = (0, url_1.parse)(req.url || '', true);
|
|
88
102
|
const pathParts = ((_a = url.pathname) === null || _a === void 0 ? void 0 : _a.split('/')) || [];
|
|
89
|
-
console.log(`WebSocket connection path: ${url.pathname}`);
|
|
90
103
|
// Client tunnel connection has no tunnelId in the path (or is at root)
|
|
91
104
|
if (!url.pathname || url.pathname === '/' || pathParts.length <= 1 || !pathParts[1]) {
|
|
92
|
-
console.log("Handling as tunnel client connection");
|
|
93
105
|
this.handleTunnelClientConnection(ws, req);
|
|
94
106
|
}
|
|
95
107
|
else {
|
|
96
|
-
// This is a WebSocket connection to be proxied through the tunnel
|
|
97
|
-
console.log(`Handling as proxied WebSocket connection for path: ${url.pathname}`);
|
|
98
108
|
this.handleProxiedWebSocketConnection(ws, req);
|
|
99
109
|
}
|
|
100
110
|
}
|
|
101
111
|
catch (error) {
|
|
102
|
-
console.error("
|
|
112
|
+
console.error("[Tunnel] WebSocket setup error:", error);
|
|
103
113
|
ws.close(1011, "Internal server error");
|
|
104
114
|
}
|
|
105
115
|
});
|
|
@@ -109,7 +119,6 @@ class DainTunnelServer {
|
|
|
109
119
|
ws.on("message", (message) => {
|
|
110
120
|
try {
|
|
111
121
|
const data = JSON.parse(message);
|
|
112
|
-
console.log(`Received WebSocket message: ${data.type}`);
|
|
113
122
|
if (data.type === "challenge_request") {
|
|
114
123
|
this.handleChallengeRequest(ws);
|
|
115
124
|
}
|
|
@@ -127,17 +136,12 @@ class DainTunnelServer {
|
|
|
127
136
|
}
|
|
128
137
|
}
|
|
129
138
|
catch (error) {
|
|
130
|
-
console.error("
|
|
139
|
+
console.error("[Tunnel] Message error:", error);
|
|
131
140
|
ws.close(1008, "Invalid message");
|
|
132
141
|
}
|
|
133
142
|
});
|
|
134
|
-
ws.on("close", () =>
|
|
135
|
-
|
|
136
|
-
this.removeTunnel(ws);
|
|
137
|
-
});
|
|
138
|
-
ws.on("error", (error) => {
|
|
139
|
-
console.error("WebSocket error:", error);
|
|
140
|
-
});
|
|
143
|
+
ws.on("close", () => this.removeTunnel(ws));
|
|
144
|
+
ws.on("error", (error) => console.error("[Tunnel] WS error:", error));
|
|
141
145
|
}
|
|
142
146
|
// Handle incoming WebSocket connections to be proxied through the tunnel
|
|
143
147
|
handleProxiedWebSocketConnection(ws, req) {
|
|
@@ -151,15 +155,12 @@ class DainTunnelServer {
|
|
|
151
155
|
const tunnelId = pathParts[1];
|
|
152
156
|
const remainingPath = '/' + pathParts.slice(2).join('/');
|
|
153
157
|
const tunnel = this.tunnels.get(tunnelId);
|
|
154
|
-
console.log(`Handling WebSocket connection for tunnel: ${tunnelId}, path: ${remainingPath}`);
|
|
155
|
-
console.log(`Available tunnels: ${Array.from(this.tunnels.keys()).join(', ')}`);
|
|
156
158
|
if (!tunnel) {
|
|
157
|
-
console.log(`Tunnel not found for WebSocket connection: ${tunnelId}`);
|
|
158
159
|
ws.close(1008, "Tunnel not found");
|
|
159
160
|
return;
|
|
160
161
|
}
|
|
161
162
|
// Create a WebSocket connection ID
|
|
162
|
-
const wsConnectionId = (
|
|
163
|
+
const wsConnectionId = fastId();
|
|
163
164
|
// Store this connection
|
|
164
165
|
this.wsConnections.set(wsConnectionId, {
|
|
165
166
|
clientSocket: ws,
|
|
@@ -203,10 +204,12 @@ class DainTunnelServer {
|
|
|
203
204
|
});
|
|
204
205
|
}
|
|
205
206
|
handleChallengeRequest(ws) {
|
|
206
|
-
const challenge = (
|
|
207
|
-
const challengeObj = { ws, challenge };
|
|
207
|
+
const challenge = fastId();
|
|
208
|
+
const challengeObj = { ws, challenge, timestamp: Date.now() };
|
|
208
209
|
this.challenges.set(challenge, challengeObj);
|
|
209
210
|
ws.send(JSON.stringify({ type: "challenge", challenge }));
|
|
211
|
+
// Auto-cleanup expired challenges (30 second TTL)
|
|
212
|
+
setTimeout(() => this.challenges.delete(challenge), 30000);
|
|
210
213
|
}
|
|
211
214
|
handleStartMessage(ws, data) {
|
|
212
215
|
const { challenge, signature, tunnelId, apiKey } = data;
|
|
@@ -248,34 +251,27 @@ class DainTunnelServer {
|
|
|
248
251
|
}
|
|
249
252
|
// If tunnel already exists, remove old one
|
|
250
253
|
if (this.tunnels.has(tunnelId)) {
|
|
251
|
-
console.log(`Tunnel ${tunnelId} already exists, replacing it`);
|
|
252
254
|
const oldTunnel = this.tunnels.get(tunnelId);
|
|
253
255
|
if (oldTunnel && oldTunnel.ws !== ws) {
|
|
254
256
|
oldTunnel.ws.close(1000, "Replaced by new connection");
|
|
255
257
|
}
|
|
256
258
|
}
|
|
257
259
|
this.tunnels.set(tunnelId, { id: tunnelId, ws });
|
|
258
|
-
console.log(`Tunnel added: ${tunnelId}`);
|
|
259
|
-
console.log(`Current tunnels: ${Array.from(this.tunnels.keys()).join(', ')}`);
|
|
260
260
|
// Save tunnel ID on the WebSocket object for easy lookup on close
|
|
261
261
|
ws.tunnelId = tunnelId;
|
|
262
|
-
// Add a periodic
|
|
262
|
+
// Add a periodic ping to keep the connection alive (silent - no logging)
|
|
263
263
|
const intervalId = setInterval(() => {
|
|
264
264
|
if (this.tunnels.has(tunnelId)) {
|
|
265
265
|
const tunnel = this.tunnels.get(tunnelId);
|
|
266
266
|
if (tunnel && tunnel.ws === ws && ws.readyState === ws_1.default.OPEN) {
|
|
267
|
-
console.log(`Tunnel ${tunnelId} still active`);
|
|
268
|
-
// Send a ping to keep the connection alive
|
|
269
267
|
try {
|
|
270
268
|
ws.ping();
|
|
271
269
|
}
|
|
272
270
|
catch (error) {
|
|
273
|
-
console.error(`Error sending ping to tunnel ${tunnelId}:`, error);
|
|
274
271
|
clearInterval(intervalId);
|
|
275
272
|
}
|
|
276
273
|
}
|
|
277
274
|
else {
|
|
278
|
-
console.log(`Tunnel ${tunnelId} exists but WebSocket state is invalid, cleaning up`);
|
|
279
275
|
clearInterval(intervalId);
|
|
280
276
|
if (tunnel && tunnel.ws === ws) {
|
|
281
277
|
this.tunnels.delete(tunnelId);
|
|
@@ -283,23 +279,19 @@ class DainTunnelServer {
|
|
|
283
279
|
}
|
|
284
280
|
}
|
|
285
281
|
else {
|
|
286
|
-
console.log(`Tunnel ${tunnelId} not found in periodic check`);
|
|
287
282
|
clearInterval(intervalId);
|
|
288
283
|
}
|
|
289
|
-
},
|
|
284
|
+
}, 30000); // Ping every 30 seconds (reduced frequency)
|
|
290
285
|
// Store the interval ID on the WebSocket object so we can clean it up
|
|
291
286
|
ws.keepAliveInterval = intervalId;
|
|
292
|
-
ws.on("close", () =>
|
|
293
|
-
console.log(`WebSocket for tunnel ${tunnelId} closed, clearing interval`);
|
|
294
|
-
clearInterval(intervalId);
|
|
295
|
-
});
|
|
287
|
+
ws.on("close", () => clearInterval(intervalId));
|
|
296
288
|
let tunnelUrl = `${this.hostname}`;
|
|
297
289
|
if (process.env.SKIP_PORT !== "true") {
|
|
298
290
|
tunnelUrl += `:${this.port}`;
|
|
299
291
|
}
|
|
300
292
|
tunnelUrl += `/${tunnelId}`;
|
|
301
293
|
ws.send(JSON.stringify({ type: "tunnelUrl", url: tunnelUrl }));
|
|
302
|
-
console.log(`
|
|
294
|
+
console.log(`[Tunnel] Created: ${tunnelUrl}`);
|
|
303
295
|
}
|
|
304
296
|
catch (error) {
|
|
305
297
|
console.error(`Error in handleStartMessage for tunnel ${tunnelId}:`, error);
|
|
@@ -309,8 +301,15 @@ class DainTunnelServer {
|
|
|
309
301
|
handleResponseMessage(data) {
|
|
310
302
|
const pendingRequest = this.pendingRequests.get(data.requestId);
|
|
311
303
|
if (pendingRequest) {
|
|
312
|
-
const { res, startTime } = pendingRequest;
|
|
304
|
+
const { res, startTime, tunnelId, timeoutId } = pendingRequest;
|
|
313
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));
|
|
314
313
|
const headers = { ...data.headers };
|
|
315
314
|
delete headers["transfer-encoding"];
|
|
316
315
|
delete headers["content-length"];
|
|
@@ -320,99 +319,54 @@ class DainTunnelServer {
|
|
|
320
319
|
.set(headers)
|
|
321
320
|
.set("Content-Length", bodyBuffer.length.toString())
|
|
322
321
|
.send(bodyBuffer);
|
|
323
|
-
console.log(`Request handled: ${data.requestId}, Duration: ${endTime - startTime}ms`);
|
|
324
322
|
this.pendingRequests.delete(data.requestId);
|
|
325
323
|
}
|
|
326
324
|
}
|
|
327
325
|
handleSSEMessage(data) {
|
|
328
326
|
const connection = this.sseConnections.get(data.id);
|
|
329
|
-
if (!connection)
|
|
330
|
-
console.log(`[SSE E2E] Server: Connection not found for ${data.id} (already cleaned up)`);
|
|
327
|
+
if (!connection)
|
|
331
328
|
return;
|
|
332
|
-
}
|
|
333
329
|
const { res, tunnelId } = connection;
|
|
334
330
|
const clientDisconnected = connection.clientDisconnected;
|
|
335
|
-
// Check if this is a "close" event - this is when we should clean up
|
|
336
331
|
const isCloseEvent = data.event === 'close';
|
|
337
332
|
if (isCloseEvent) {
|
|
338
|
-
console.log(`[SSE E2E] Server: Received close event for ${data.id}, cleaning up`);
|
|
339
|
-
// Now we can safely send sse_close to tunnel client
|
|
340
333
|
const tunnel = this.tunnels.get(tunnelId);
|
|
341
334
|
if (tunnel) {
|
|
342
|
-
tunnel.ws.send(JSON.stringify({
|
|
343
|
-
type: "sse_close",
|
|
344
|
-
id: data.id
|
|
345
|
-
}));
|
|
346
|
-
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 }));
|
|
347
336
|
}
|
|
348
|
-
// End the response if client is still connected
|
|
349
337
|
if (!clientDisconnected && res.writable) {
|
|
350
338
|
try {
|
|
351
|
-
res.write(`event: ${data.event}\n`);
|
|
352
|
-
res.write(`data: ${data.data}\n`);
|
|
353
|
-
res.write('\n');
|
|
339
|
+
res.write(`event: ${data.event}\ndata: ${data.data}\n\n`);
|
|
354
340
|
flushSSE(res);
|
|
355
341
|
res.end();
|
|
356
|
-
console.log(`[SSE E2E] Server→Browser: Sent close event and ended response for ${data.id}`);
|
|
357
|
-
}
|
|
358
|
-
catch (error) {
|
|
359
|
-
console.error(`[SSE E2E] Server: ❌ Error writing close event for ${data.id}:`, error);
|
|
360
342
|
}
|
|
343
|
+
catch (error) { /* ignore */ }
|
|
361
344
|
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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));
|
|
365
348
|
this.sseConnections.delete(data.id);
|
|
366
349
|
return;
|
|
367
350
|
}
|
|
368
|
-
|
|
369
|
-
if (clientDisconnected) {
|
|
370
|
-
// Only log first discard and key events to avoid spam
|
|
371
|
-
if (!(connection.discardCount)) {
|
|
372
|
-
connection.discardCount = 0;
|
|
373
|
-
}
|
|
374
|
-
connection.discardCount++;
|
|
375
|
-
if (connection.discardCount === 1 || data.event === 'error' || data.event === 'end') {
|
|
376
|
-
console.log(`[SSE E2E] Server: Discarding event="${data.event}" for ${data.id} - browser disconnected (total discarded: ${connection.discardCount})`);
|
|
377
|
-
}
|
|
378
|
-
return;
|
|
379
|
-
}
|
|
380
|
-
// Check if response is still writable before attempting to write
|
|
381
|
-
if (!res.writable) {
|
|
382
|
-
console.log(`[SSE E2E] Server: Response no longer writable for ${data.id}, marking as disconnected`);
|
|
351
|
+
if (clientDisconnected || !res.writable) {
|
|
383
352
|
connection.clientDisconnected = true;
|
|
384
353
|
return;
|
|
385
354
|
}
|
|
386
355
|
try {
|
|
387
|
-
if (data.event)
|
|
356
|
+
if (data.event)
|
|
388
357
|
res.write(`event: ${data.event}\n`);
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
res.write(`data: ${data.data}\n`);
|
|
392
|
-
res.write('\n');
|
|
393
|
-
// Force immediate flush to prevent buffering
|
|
394
|
-
// 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`);
|
|
395
360
|
flushSSE(res);
|
|
396
|
-
// Only log key events to avoid spam
|
|
397
|
-
if (!(connection.eventCount)) {
|
|
398
|
-
connection.eventCount = 0;
|
|
399
|
-
}
|
|
400
|
-
connection.eventCount++;
|
|
401
|
-
if (connection.eventCount === 1 || data.event === 'error' || data.event === 'end') {
|
|
402
|
-
console.log(`[SSE E2E] Server→Browser: Forwarded event="${data.event}" for ${data.id} (total events: ${connection.eventCount})`);
|
|
403
|
-
}
|
|
404
361
|
}
|
|
405
362
|
catch (error) {
|
|
406
|
-
console.error(`[SSE E2E] Server: ❌ Error writing SSE message for ${data.id}:`, error);
|
|
407
363
|
connection.clientDisconnected = true;
|
|
408
364
|
}
|
|
409
365
|
}
|
|
410
366
|
handleWebSocketMessage(data) {
|
|
411
367
|
const connection = this.wsConnections.get(data.id);
|
|
412
|
-
if (!connection)
|
|
413
|
-
console.log(`WebSocket connection not found: ${data.id}`);
|
|
368
|
+
if (!connection)
|
|
414
369
|
return;
|
|
415
|
-
}
|
|
416
370
|
const { clientSocket } = connection;
|
|
417
371
|
if (data.event === 'message' && data.data) {
|
|
418
372
|
const messageData = Buffer.from(data.data, 'base64');
|
|
@@ -445,41 +399,62 @@ class DainTunnelServer {
|
|
|
445
399
|
while (retries > 0 && !tunnel) {
|
|
446
400
|
tunnel = this.tunnels.get(tunnelId);
|
|
447
401
|
if (!tunnel) {
|
|
448
|
-
|
|
449
|
-
console.log(`Current tunnels: ${Array.from(this.tunnels.keys()).join(', ')}`);
|
|
450
|
-
await new Promise(resolve => setTimeout(resolve, 100)); // Wait 100ms before retrying
|
|
402
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
451
403
|
retries--;
|
|
452
404
|
}
|
|
453
405
|
}
|
|
454
406
|
if (!tunnel) {
|
|
455
|
-
console.log(`Tunnel not found after retries: ${tunnelId}`);
|
|
456
407
|
return res.status(404).send("Tunnel not found");
|
|
457
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);
|
|
458
419
|
// Check for SSE request
|
|
459
420
|
if (req.headers.accept && req.headers.accept.includes('text/event-stream')) {
|
|
460
421
|
return this.handleSSERequest(req, res, tunnelId, tunnel);
|
|
461
422
|
}
|
|
462
423
|
// Handle regular HTTP request
|
|
463
|
-
const requestId = (
|
|
424
|
+
const requestId = fastId();
|
|
464
425
|
const startTime = Date.now();
|
|
465
|
-
|
|
466
|
-
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({
|
|
467
441
|
type: "request",
|
|
468
442
|
id: requestId,
|
|
469
443
|
method: req.method,
|
|
470
444
|
path: req.url,
|
|
471
445
|
headers: req.headers,
|
|
472
|
-
body: req.method !== "GET" && req.body
|
|
446
|
+
body: req.method !== "GET" && req.method !== "HEAD" && req.body
|
|
473
447
|
? req.body.toString("base64")
|
|
474
448
|
: undefined,
|
|
475
|
-
};
|
|
476
|
-
tunnel.ws.send(JSON.stringify(requestMessage));
|
|
477
|
-
console.log(`Request forwarded: ${requestId}, Method: ${req.method}, Path: ${req.url}`);
|
|
449
|
+
}));
|
|
478
450
|
}
|
|
479
451
|
handleSSERequest(req, res, tunnelId, tunnel) {
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
452
|
+
const sseId = fastId();
|
|
453
|
+
// Optimize TCP socket for low-latency streaming
|
|
454
|
+
const socket = req.socket || req.connection;
|
|
455
|
+
if (socket && socket.setNoDelay) {
|
|
456
|
+
socket.setNoDelay(true); // Disable Nagle's algorithm for immediate transmission
|
|
457
|
+
}
|
|
483
458
|
// Set SSE headers with anti-buffering directives
|
|
484
459
|
// Note: Do NOT set 'Connection: keep-alive' - it's forbidden in HTTP/2 and will cause immediate disconnect
|
|
485
460
|
res.writeHead(200, {
|
|
@@ -490,113 +465,109 @@ class DainTunnelServer {
|
|
|
490
465
|
// Flush headers immediately to establish SSE connection
|
|
491
466
|
// Without this, headers may be buffered, delaying connection setup
|
|
492
467
|
res.flushHeaders();
|
|
493
|
-
// Send
|
|
494
|
-
|
|
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');
|
|
495
471
|
// Flush the initial write
|
|
496
472
|
flushSSE(res);
|
|
497
|
-
|
|
498
|
-
this.sseConnections.set(sseId, {
|
|
499
|
-
req,
|
|
500
|
-
res,
|
|
501
|
-
id: sseId,
|
|
502
|
-
tunnelId
|
|
503
|
-
});
|
|
504
|
-
console.log(`[SSE E2E] Server→Client: Forwarding SSE connection request ${sseId} to tunnel client`);
|
|
505
|
-
// Notify the tunnel client about the new SSE connection
|
|
473
|
+
this.sseConnections.set(sseId, { req, res, id: sseId, tunnelId });
|
|
506
474
|
tunnel.ws.send(JSON.stringify({
|
|
507
475
|
type: "sse_connection",
|
|
508
476
|
id: sseId,
|
|
509
477
|
path: req.url,
|
|
510
|
-
method: req.method,
|
|
478
|
+
method: req.method,
|
|
511
479
|
headers: req.headers,
|
|
512
480
|
body: req.method !== "GET" && req.body ? req.body.toString("base64") : undefined
|
|
513
481
|
}));
|
|
514
|
-
// Handle client disconnect
|
|
515
482
|
req.on('close', () => {
|
|
516
|
-
console.log(`[SSE E2E] Server: Browser disconnected from SSE ${sseId}`);
|
|
517
|
-
// DO NOT immediately send sse_close to tunnel client!
|
|
518
|
-
// The local service may still be processing and sending SSE events.
|
|
519
|
-
// Instead, mark this connection as "client disconnected" and let the
|
|
520
|
-
// local service finish naturally. We'll clean up when we receive the
|
|
521
|
-
// final "close" event from the service, or when the connection errors.
|
|
522
483
|
const connection = this.sseConnections.get(sseId);
|
|
523
484
|
if (connection) {
|
|
524
|
-
// Mark as client disconnected, but keep the tunnel client connection alive
|
|
525
485
|
connection.clientDisconnected = true;
|
|
526
|
-
|
|
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);
|
|
527
500
|
}
|
|
528
501
|
});
|
|
529
502
|
}
|
|
530
503
|
removeTunnel(ws) {
|
|
531
504
|
try {
|
|
532
|
-
|
|
533
|
-
if (ws.keepAliveInterval) {
|
|
505
|
+
if (ws.keepAliveInterval)
|
|
534
506
|
clearInterval(ws.keepAliveInterval);
|
|
535
|
-
}
|
|
536
|
-
// If we saved tunnelId on the WebSocket object, use it for faster lookup
|
|
537
507
|
const tunnelId = ws.tunnelId;
|
|
538
508
|
let removedTunnelId = tunnelId;
|
|
539
509
|
if (tunnelId && this.tunnels.has(tunnelId)) {
|
|
540
510
|
const tunnel = this.tunnels.get(tunnelId);
|
|
541
|
-
if (tunnel && tunnel.ws === ws)
|
|
511
|
+
if (tunnel && tunnel.ws === ws)
|
|
542
512
|
this.tunnels.delete(tunnelId);
|
|
543
|
-
console.log(`Tunnel removed using stored ID: ${tunnelId}`);
|
|
544
|
-
}
|
|
545
513
|
}
|
|
546
514
|
else {
|
|
547
|
-
// Fall back to iterating through all tunnels
|
|
548
515
|
removedTunnelId = undefined;
|
|
549
516
|
for (const [id, tunnel] of this.tunnels.entries()) {
|
|
550
517
|
if (tunnel.ws === ws) {
|
|
551
518
|
this.tunnels.delete(id);
|
|
552
519
|
removedTunnelId = id;
|
|
553
|
-
console.log(`Tunnel removed: ${id}`);
|
|
554
520
|
break;
|
|
555
521
|
}
|
|
556
522
|
}
|
|
557
523
|
}
|
|
558
524
|
if (removedTunnelId) {
|
|
559
|
-
|
|
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
|
+
}
|
|
560
540
|
// Close all SSE connections associated with this tunnel
|
|
561
541
|
for (const [sseId, sseConnection] of this.sseConnections.entries()) {
|
|
562
542
|
if (sseConnection.tunnelId === removedTunnelId) {
|
|
563
543
|
try {
|
|
564
544
|
sseConnection.res.end();
|
|
565
545
|
}
|
|
566
|
-
catch (error) {
|
|
567
|
-
console.error(`Error closing SSE connection ${sseId}:`, error);
|
|
568
|
-
}
|
|
546
|
+
catch (error) { /* ignore */ }
|
|
569
547
|
this.sseConnections.delete(sseId);
|
|
570
548
|
}
|
|
571
549
|
}
|
|
572
550
|
// Close all WebSocket connections associated with this tunnel
|
|
573
551
|
for (const [wsId, wsConnection] of this.wsConnections.entries()) {
|
|
574
|
-
|
|
575
|
-
if (wsPath.startsWith(`/${removedTunnelId}/`)) {
|
|
552
|
+
if (wsConnection.path.startsWith(`/${removedTunnelId}/`)) {
|
|
576
553
|
try {
|
|
577
554
|
wsConnection.clientSocket.close(1001, "Tunnel closed");
|
|
578
555
|
}
|
|
579
|
-
catch (error) {
|
|
580
|
-
console.error(`Error closing WebSocket connection ${wsId}:`, error);
|
|
581
|
-
}
|
|
556
|
+
catch (error) { /* ignore */ }
|
|
582
557
|
this.wsConnections.delete(wsId);
|
|
583
558
|
}
|
|
584
559
|
}
|
|
585
560
|
}
|
|
586
|
-
|
|
587
|
-
console.log(`No tunnel found to remove for the closed WebSocket connection`);
|
|
588
|
-
}
|
|
589
|
-
// Also remove any pending challenges for this WebSocket
|
|
561
|
+
// Remove any pending challenges for this WebSocket
|
|
590
562
|
for (const [challenge, challengeObj] of this.challenges.entries()) {
|
|
591
563
|
if (challengeObj.ws === ws) {
|
|
592
564
|
this.challenges.delete(challenge);
|
|
593
|
-
console.log(`Challenge removed for closed WebSocket`);
|
|
594
565
|
break;
|
|
595
566
|
}
|
|
596
567
|
}
|
|
597
568
|
}
|
|
598
569
|
catch (error) {
|
|
599
|
-
console.error("
|
|
570
|
+
console.error("[Tunnel] Remove error:", error);
|
|
600
571
|
}
|
|
601
572
|
}
|
|
602
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
|
+
}
|