@dainprotocol/tunnel 1.1.25 → 1.1.26
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 +13 -0
- package/dist/client/index.js +168 -23
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.js +216 -129
- package/dist/server/start.js +19 -0
- package/package.json +3 -2
package/dist/client/index.d.ts
CHANGED
|
@@ -13,12 +13,24 @@ declare class DainTunnel extends EventEmitter {
|
|
|
13
13
|
private webSocketClients;
|
|
14
14
|
private sseClients;
|
|
15
15
|
private httpAgent;
|
|
16
|
+
private heartbeatInterval;
|
|
17
|
+
private readonly HEARTBEAT_INTERVAL;
|
|
16
18
|
constructor(serverUrl: string, apiKey: string);
|
|
17
19
|
/**
|
|
18
20
|
* Sign a challenge using HMAC-SHA256
|
|
19
21
|
* @private
|
|
20
22
|
*/
|
|
21
23
|
private signChallenge;
|
|
24
|
+
/**
|
|
25
|
+
* Start client-side heartbeat to detect connection issues early
|
|
26
|
+
* @private
|
|
27
|
+
*/
|
|
28
|
+
private startHeartbeat;
|
|
29
|
+
/**
|
|
30
|
+
* Stop the client-side heartbeat
|
|
31
|
+
* @private
|
|
32
|
+
*/
|
|
33
|
+
private stopHeartbeat;
|
|
22
34
|
start(port: number): Promise<string>;
|
|
23
35
|
private connect;
|
|
24
36
|
private requestChallenge;
|
|
@@ -28,6 +40,7 @@ declare class DainTunnel extends EventEmitter {
|
|
|
28
40
|
private handleWebSocketMessage;
|
|
29
41
|
private handleSSEConnection;
|
|
30
42
|
private handleSSEClose;
|
|
43
|
+
private readonly REQUEST_TIMEOUT;
|
|
31
44
|
private forwardRequest;
|
|
32
45
|
private sendMessage;
|
|
33
46
|
private attemptReconnect;
|
package/dist/client/index.js
CHANGED
|
@@ -21,6 +21,11 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
21
21
|
this.reconnectDelay = 5000;
|
|
22
22
|
this.webSocketClients = new Map();
|
|
23
23
|
this.sseClients = new Map();
|
|
24
|
+
// Heartbeat interval for client-side liveness
|
|
25
|
+
this.heartbeatInterval = null;
|
|
26
|
+
this.HEARTBEAT_INTERVAL = 25000; // 25 seconds (less than server's 30s)
|
|
27
|
+
// Client-side timeout (less than server's 30s to respond before server times out)
|
|
28
|
+
this.REQUEST_TIMEOUT = 25000;
|
|
24
29
|
// Parse API key to extract agentId and secret
|
|
25
30
|
const parsed = (0, auth_1.parseAPIKey)(apiKey);
|
|
26
31
|
if (!parsed) {
|
|
@@ -30,11 +35,12 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
30
35
|
this.tunnelId = `${parsed.orgId}_${parsed.agentId}`; // orgId_agentId to prevent collisions
|
|
31
36
|
this.secret = parsed.secret; // secret for HMAC signatures
|
|
32
37
|
// High-frequency optimization: Create reusable HTTP agent with connection pooling
|
|
38
|
+
// Aligned with server's MAX_CONCURRENT_REQUESTS_PER_TUNNEL = 100
|
|
33
39
|
this.httpAgent = new http_1.default.Agent({
|
|
34
40
|
keepAlive: true,
|
|
35
41
|
keepAliveMsecs: 30000, // Keep connections alive for 30s
|
|
36
|
-
maxSockets:
|
|
37
|
-
maxFreeSockets:
|
|
42
|
+
maxSockets: 100, // Match server's concurrent request limit
|
|
43
|
+
maxFreeSockets: 20, // Keep more idle connections for burst traffic
|
|
38
44
|
});
|
|
39
45
|
}
|
|
40
46
|
/**
|
|
@@ -46,12 +52,62 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
46
52
|
.update(challenge)
|
|
47
53
|
.digest('hex');
|
|
48
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Start client-side heartbeat to detect connection issues early
|
|
57
|
+
* @private
|
|
58
|
+
*/
|
|
59
|
+
startHeartbeat() {
|
|
60
|
+
this.stopHeartbeat(); // Clear any existing heartbeat
|
|
61
|
+
this.heartbeatInterval = setInterval(() => {
|
|
62
|
+
if (this.ws && this.ws.readyState === ws_1.default.OPEN) {
|
|
63
|
+
try {
|
|
64
|
+
this.ws.ping();
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
// Ping failed, connection might be dead
|
|
68
|
+
this.emit("error", new Error("Heartbeat ping failed"));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}, this.HEARTBEAT_INTERVAL);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Stop the client-side heartbeat
|
|
75
|
+
* @private
|
|
76
|
+
*/
|
|
77
|
+
stopHeartbeat() {
|
|
78
|
+
if (this.heartbeatInterval) {
|
|
79
|
+
clearInterval(this.heartbeatInterval);
|
|
80
|
+
this.heartbeatInterval = null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
49
83
|
async start(port) {
|
|
50
84
|
this.port = port;
|
|
51
85
|
return this.connect();
|
|
52
86
|
}
|
|
53
87
|
async connect() {
|
|
54
88
|
return new Promise((resolve, reject) => {
|
|
89
|
+
let resolved = false;
|
|
90
|
+
let connectionTimeoutId = null;
|
|
91
|
+
const cleanup = () => {
|
|
92
|
+
if (connectionTimeoutId) {
|
|
93
|
+
clearTimeout(connectionTimeoutId);
|
|
94
|
+
connectionTimeoutId = null;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
const safeResolve = (value) => {
|
|
98
|
+
if (!resolved) {
|
|
99
|
+
resolved = true;
|
|
100
|
+
cleanup();
|
|
101
|
+
resolve(value);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
const safeReject = (error) => {
|
|
105
|
+
if (!resolved) {
|
|
106
|
+
resolved = true;
|
|
107
|
+
cleanup();
|
|
108
|
+
reject(error);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
55
111
|
try {
|
|
56
112
|
this.ws = new ws_1.default(this.serverUrl);
|
|
57
113
|
this.ws.on("open", async () => {
|
|
@@ -67,21 +123,25 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
67
123
|
tunnelId: this.tunnelId,
|
|
68
124
|
apiKey: this.apiKey // Send API key for server validation
|
|
69
125
|
});
|
|
126
|
+
// Start heartbeat after successful authentication
|
|
127
|
+
this.startHeartbeat();
|
|
70
128
|
this.emit("connected");
|
|
71
129
|
}
|
|
72
130
|
catch (err) {
|
|
73
|
-
|
|
131
|
+
safeReject(err);
|
|
74
132
|
}
|
|
75
133
|
});
|
|
76
134
|
this.ws.on("message", (data) => {
|
|
77
135
|
try {
|
|
78
|
-
this.handleMessage(JSON.parse(data),
|
|
136
|
+
this.handleMessage(JSON.parse(data), safeResolve);
|
|
79
137
|
}
|
|
80
138
|
catch (err) {
|
|
81
|
-
|
|
139
|
+
safeReject(err);
|
|
82
140
|
}
|
|
83
141
|
});
|
|
84
142
|
this.ws.on("close", () => {
|
|
143
|
+
// Stop heartbeat on disconnect
|
|
144
|
+
this.stopHeartbeat();
|
|
85
145
|
// Clean up all active SSE connections
|
|
86
146
|
for (const [, client] of this.sseClients) {
|
|
87
147
|
try {
|
|
@@ -104,18 +164,26 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
104
164
|
this.attemptReconnect();
|
|
105
165
|
}
|
|
106
166
|
else {
|
|
107
|
-
|
|
167
|
+
safeReject(new Error("Connection closed before tunnel established"));
|
|
108
168
|
}
|
|
109
169
|
});
|
|
110
170
|
this.ws.on("error", (error) => this.emit("error", error));
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
171
|
+
// Connection timeout with proper cleanup
|
|
172
|
+
connectionTimeoutId = setTimeout(() => {
|
|
173
|
+
if (!resolved && (!this.ws || this.ws.readyState !== ws_1.default.OPEN)) {
|
|
174
|
+
safeReject(new Error("Connection timeout"));
|
|
175
|
+
// Close the WebSocket if it's still trying to connect
|
|
176
|
+
if (this.ws) {
|
|
177
|
+
try {
|
|
178
|
+
this.ws.terminate();
|
|
179
|
+
}
|
|
180
|
+
catch (e) { /* ignore */ }
|
|
181
|
+
}
|
|
114
182
|
}
|
|
115
183
|
}, 10000);
|
|
116
184
|
}
|
|
117
185
|
catch (err) {
|
|
118
|
-
|
|
186
|
+
safeReject(err);
|
|
119
187
|
}
|
|
120
188
|
});
|
|
121
189
|
}
|
|
@@ -125,23 +193,39 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
125
193
|
reject(new Error("WebSocket is not connected"));
|
|
126
194
|
return;
|
|
127
195
|
}
|
|
128
|
-
|
|
196
|
+
let resolved = false;
|
|
197
|
+
let timeoutId = null;
|
|
198
|
+
const cleanup = () => {
|
|
199
|
+
if (this.ws) {
|
|
200
|
+
this.ws.removeListener("message", challengeHandler);
|
|
201
|
+
}
|
|
202
|
+
if (timeoutId) {
|
|
203
|
+
clearTimeout(timeoutId);
|
|
204
|
+
timeoutId = null;
|
|
205
|
+
}
|
|
206
|
+
};
|
|
129
207
|
const challengeHandler = (message) => {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (
|
|
133
|
-
|
|
208
|
+
try {
|
|
209
|
+
const data = JSON.parse(message);
|
|
210
|
+
if (data.type === "challenge" && !resolved) {
|
|
211
|
+
resolved = true;
|
|
212
|
+
cleanup();
|
|
213
|
+
resolve(data.challenge);
|
|
134
214
|
}
|
|
135
|
-
|
|
215
|
+
}
|
|
216
|
+
catch (e) {
|
|
217
|
+
// Ignore parse errors for non-challenge messages
|
|
136
218
|
}
|
|
137
219
|
};
|
|
138
220
|
this.ws.on("message", challengeHandler);
|
|
221
|
+
this.ws.send(JSON.stringify({ type: "challenge_request" }));
|
|
139
222
|
// Add a timeout for the challenge request
|
|
140
|
-
setTimeout(() => {
|
|
141
|
-
if (
|
|
142
|
-
|
|
223
|
+
timeoutId = setTimeout(() => {
|
|
224
|
+
if (!resolved) {
|
|
225
|
+
resolved = true;
|
|
226
|
+
cleanup();
|
|
227
|
+
reject(new Error("Challenge request timeout"));
|
|
143
228
|
}
|
|
144
|
-
reject(new Error("Challenge request timeout"));
|
|
145
229
|
}, 5000);
|
|
146
230
|
});
|
|
147
231
|
}
|
|
@@ -176,6 +260,20 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
176
260
|
this.emit("request_handled", { request, response });
|
|
177
261
|
}
|
|
178
262
|
catch (error) {
|
|
263
|
+
// Send error response back to server instead of just emitting an event
|
|
264
|
+
// This prevents 30-second timeouts when the local service is unreachable
|
|
265
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
266
|
+
const errorResponse = {
|
|
267
|
+
type: "response",
|
|
268
|
+
requestId: request.id,
|
|
269
|
+
status: 502, // Bad Gateway - indicates upstream error
|
|
270
|
+
headers: { "content-type": "application/json" },
|
|
271
|
+
body: Buffer.from(JSON.stringify({
|
|
272
|
+
error: "Bad Gateway",
|
|
273
|
+
message: `Failed to forward request to local service: ${errorMessage}`
|
|
274
|
+
})).toString("base64")
|
|
275
|
+
};
|
|
276
|
+
this.sendMessage(errorResponse);
|
|
179
277
|
this.emit("request_error", { request, error });
|
|
180
278
|
}
|
|
181
279
|
}
|
|
@@ -271,10 +369,16 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
271
369
|
// Helper to process SSE events from buffer
|
|
272
370
|
const processBuffer = () => {
|
|
273
371
|
var _a;
|
|
372
|
+
// FIX: Normalize CRLF to LF for cross-platform SSE compatibility
|
|
373
|
+
// Some SSE implementations use \r\n\r\n instead of \n\n
|
|
374
|
+
buffer = buffer.replace(/\r\n/g, '\n');
|
|
274
375
|
while (buffer.includes('\n\n')) {
|
|
275
376
|
const idx = buffer.indexOf('\n\n');
|
|
276
377
|
const msgData = buffer.substring(0, idx);
|
|
277
378
|
buffer = buffer.substring(idx + 2);
|
|
379
|
+
// Skip empty messages and keepalive comments
|
|
380
|
+
if (!msgData.trim() || msgData.startsWith(':'))
|
|
381
|
+
continue;
|
|
278
382
|
let event = 'message', data = '';
|
|
279
383
|
for (const line of msgData.split('\n')) {
|
|
280
384
|
if (line.startsWith('event:'))
|
|
@@ -284,7 +388,8 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
284
388
|
}
|
|
285
389
|
if (data.endsWith('\n'))
|
|
286
390
|
data = data.slice(0, -1);
|
|
287
|
-
|
|
391
|
+
// Only send if we have actual data
|
|
392
|
+
if (data && ((_a = this.ws) === null || _a === void 0 ? void 0 : _a.readyState) === ws_1.default.OPEN) {
|
|
288
393
|
this.ws.send(JSON.stringify({ type: 'sse', id: message.id, event, data }));
|
|
289
394
|
}
|
|
290
395
|
}
|
|
@@ -333,6 +438,28 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
333
438
|
}
|
|
334
439
|
forwardRequest(request) {
|
|
335
440
|
return new Promise((resolve, reject) => {
|
|
441
|
+
let resolved = false;
|
|
442
|
+
let timeoutId = null;
|
|
443
|
+
const cleanup = () => {
|
|
444
|
+
if (timeoutId) {
|
|
445
|
+
clearTimeout(timeoutId);
|
|
446
|
+
timeoutId = null;
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
const safeResolve = (response) => {
|
|
450
|
+
if (!resolved) {
|
|
451
|
+
resolved = true;
|
|
452
|
+
cleanup();
|
|
453
|
+
resolve(response);
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
const safeReject = (error) => {
|
|
457
|
+
if (!resolved) {
|
|
458
|
+
resolved = true;
|
|
459
|
+
cleanup();
|
|
460
|
+
reject(error);
|
|
461
|
+
}
|
|
462
|
+
};
|
|
336
463
|
const options = {
|
|
337
464
|
hostname: 'localhost',
|
|
338
465
|
port: this.port,
|
|
@@ -340,6 +467,7 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
340
467
|
method: request.method,
|
|
341
468
|
headers: request.headers,
|
|
342
469
|
agent: this.httpAgent, // Use connection pooling
|
|
470
|
+
timeout: this.REQUEST_TIMEOUT, // Socket timeout
|
|
343
471
|
};
|
|
344
472
|
const req = http_1.default.request(options, (res) => {
|
|
345
473
|
let body = Buffer.from([]);
|
|
@@ -350,7 +478,7 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
350
478
|
const headers = { ...res.headers };
|
|
351
479
|
delete headers['transfer-encoding'];
|
|
352
480
|
delete headers['content-length'];
|
|
353
|
-
|
|
481
|
+
safeResolve({
|
|
354
482
|
type: 'response',
|
|
355
483
|
requestId: request.id,
|
|
356
484
|
status: res.statusCode,
|
|
@@ -358,8 +486,21 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
358
486
|
body: body.toString('base64'),
|
|
359
487
|
});
|
|
360
488
|
});
|
|
489
|
+
res.on('error', safeReject);
|
|
361
490
|
});
|
|
362
|
-
|
|
491
|
+
// Handle request timeout
|
|
492
|
+
req.on('timeout', () => {
|
|
493
|
+
req.destroy();
|
|
494
|
+
safeReject(new Error(`Request timeout after ${this.REQUEST_TIMEOUT}ms`));
|
|
495
|
+
});
|
|
496
|
+
req.on('error', safeReject);
|
|
497
|
+
// Additional safety timeout
|
|
498
|
+
timeoutId = setTimeout(() => {
|
|
499
|
+
if (!resolved) {
|
|
500
|
+
req.destroy();
|
|
501
|
+
safeReject(new Error(`Request timeout after ${this.REQUEST_TIMEOUT}ms`));
|
|
502
|
+
}
|
|
503
|
+
}, this.REQUEST_TIMEOUT);
|
|
363
504
|
if (request.body && request.method !== 'GET') {
|
|
364
505
|
req.write(Buffer.from(request.body, 'base64'));
|
|
365
506
|
}
|
|
@@ -388,6 +529,8 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
388
529
|
async stop() {
|
|
389
530
|
return new Promise(async (resolve) => {
|
|
390
531
|
try {
|
|
532
|
+
// Stop heartbeat first
|
|
533
|
+
this.stopHeartbeat();
|
|
391
534
|
// Close all WebSocket clients
|
|
392
535
|
for (const [id, client] of this.webSocketClients.entries()) {
|
|
393
536
|
try {
|
|
@@ -422,6 +565,8 @@ class DainTunnel extends events_1.EventEmitter {
|
|
|
422
565
|
}
|
|
423
566
|
this.ws = null;
|
|
424
567
|
}
|
|
568
|
+
// Reset tunnel URL so reconnect doesn't happen
|
|
569
|
+
this.tunnelUrl = null;
|
|
425
570
|
// Destroy the HTTP agent to close pooled connections
|
|
426
571
|
if (this.httpAgent) {
|
|
427
572
|
this.httpAgent.destroy();
|
package/dist/server/index.d.ts
CHANGED
|
@@ -11,6 +11,10 @@ declare class DainTunnelServer {
|
|
|
11
11
|
private wsConnections;
|
|
12
12
|
private tunnelRequestCount;
|
|
13
13
|
private readonly MAX_CONCURRENT_REQUESTS_PER_TUNNEL;
|
|
14
|
+
/**
|
|
15
|
+
* Safely send a message to a WebSocket, handling errors gracefully
|
|
16
|
+
*/
|
|
17
|
+
private safeSend;
|
|
14
18
|
constructor(hostname: string, port: number);
|
|
15
19
|
private setupExpressRoutes;
|
|
16
20
|
private setupWebSocketServer;
|
package/dist/server/index.js
CHANGED
|
@@ -16,34 +16,22 @@ let idCounter = 0;
|
|
|
16
16
|
function fastId() {
|
|
17
17
|
return `${Date.now().toString(36)}-${(idCounter++).toString(36)}-${(0, crypto_1.randomBytes)(4).toString('hex')}`;
|
|
18
18
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
// We uncork to force immediate transmission of buffered data
|
|
29
|
-
const socket = res.socket;
|
|
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);
|
|
19
|
+
class DainTunnelServer {
|
|
20
|
+
/**
|
|
21
|
+
* Safely send a message to a WebSocket, handling errors gracefully
|
|
22
|
+
*/
|
|
23
|
+
safeSend(ws, data) {
|
|
24
|
+
try {
|
|
25
|
+
if (ws.readyState === ws_1.default.OPEN) {
|
|
26
|
+
ws.send(JSON.stringify(data));
|
|
27
|
+
return true;
|
|
38
28
|
}
|
|
39
29
|
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
console.error("[Tunnel] SafeSend error:", error);
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
40
34
|
}
|
|
41
|
-
catch (error) {
|
|
42
|
-
// Flush is best-effort; log but don't fail the request
|
|
43
|
-
console.error('[SSE Flush] Error flushing socket:', error);
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
class DainTunnelServer {
|
|
47
35
|
constructor(hostname, port) {
|
|
48
36
|
this.hostname = hostname;
|
|
49
37
|
this.port = port;
|
|
@@ -90,6 +78,29 @@ class DainTunnelServer {
|
|
|
90
78
|
this.setupWebSocketServer();
|
|
91
79
|
}
|
|
92
80
|
setupExpressRoutes() {
|
|
81
|
+
// Health check endpoint for App Platform / load balancers
|
|
82
|
+
// Skip WebSocket upgrade requests - let WebSocket server handle them
|
|
83
|
+
this.app.get("/", (req, res, next) => {
|
|
84
|
+
if (req.headers.upgrade && req.headers.upgrade.toLowerCase() === 'websocket') {
|
|
85
|
+
return next();
|
|
86
|
+
}
|
|
87
|
+
res.status(200).json({
|
|
88
|
+
status: "healthy",
|
|
89
|
+
tunnels: this.tunnels.size,
|
|
90
|
+
pendingRequests: this.pendingRequests.size,
|
|
91
|
+
sseConnections: this.sseConnections.size,
|
|
92
|
+
wsConnections: this.wsConnections.size,
|
|
93
|
+
uptime: process.uptime()
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
this.app.get("/health", (req, res) => {
|
|
97
|
+
res.status(200).json({
|
|
98
|
+
status: "ok",
|
|
99
|
+
tunnels: this.tunnels.size,
|
|
100
|
+
pendingRequests: this.pendingRequests.size,
|
|
101
|
+
uptime: process.uptime()
|
|
102
|
+
});
|
|
103
|
+
});
|
|
93
104
|
// Generic route handler for all tunnel requests
|
|
94
105
|
this.app.use("/:tunnelId", this.handleRequest.bind(this));
|
|
95
106
|
}
|
|
@@ -161,46 +172,56 @@ class DainTunnelServer {
|
|
|
161
172
|
}
|
|
162
173
|
// Create a WebSocket connection ID
|
|
163
174
|
const wsConnectionId = fastId();
|
|
164
|
-
// Store this connection
|
|
175
|
+
// Store this connection with tunnelId for proper cleanup
|
|
165
176
|
this.wsConnections.set(wsConnectionId, {
|
|
166
177
|
clientSocket: ws,
|
|
167
178
|
id: wsConnectionId,
|
|
168
179
|
path: remainingPath,
|
|
169
|
-
headers: req.headers
|
|
180
|
+
headers: req.headers,
|
|
181
|
+
tunnelId: tunnelId
|
|
170
182
|
});
|
|
171
183
|
// Notify tunnel client about the new WebSocket connection
|
|
172
|
-
tunnel.ws
|
|
184
|
+
this.safeSend(tunnel.ws, {
|
|
173
185
|
type: "websocket_connection",
|
|
174
186
|
id: wsConnectionId,
|
|
175
187
|
path: remainingPath,
|
|
176
188
|
headers: req.headers
|
|
177
|
-
})
|
|
189
|
+
});
|
|
178
190
|
// Handle messages from the client
|
|
179
191
|
ws.on("message", (data) => {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
192
|
+
const currentTunnel = this.tunnels.get(tunnelId);
|
|
193
|
+
if (currentTunnel) {
|
|
194
|
+
this.safeSend(currentTunnel.ws, {
|
|
195
|
+
type: "websocket",
|
|
196
|
+
id: wsConnectionId,
|
|
197
|
+
event: "message",
|
|
198
|
+
data: data.toString('base64')
|
|
199
|
+
});
|
|
200
|
+
}
|
|
186
201
|
});
|
|
187
202
|
// Handle client disconnection
|
|
188
203
|
ws.on("close", () => {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
204
|
+
const currentTunnel = this.tunnels.get(tunnelId);
|
|
205
|
+
if (currentTunnel) {
|
|
206
|
+
this.safeSend(currentTunnel.ws, {
|
|
207
|
+
type: "websocket",
|
|
208
|
+
id: wsConnectionId,
|
|
209
|
+
event: "close"
|
|
210
|
+
});
|
|
211
|
+
}
|
|
194
212
|
this.wsConnections.delete(wsConnectionId);
|
|
195
213
|
});
|
|
196
214
|
// Handle errors
|
|
197
215
|
ws.on("error", (error) => {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
216
|
+
const currentTunnel = this.tunnels.get(tunnelId);
|
|
217
|
+
if (currentTunnel) {
|
|
218
|
+
this.safeSend(currentTunnel.ws, {
|
|
219
|
+
type: "websocket",
|
|
220
|
+
id: wsConnectionId,
|
|
221
|
+
event: "error",
|
|
222
|
+
data: error.message
|
|
223
|
+
});
|
|
224
|
+
}
|
|
204
225
|
});
|
|
205
226
|
}
|
|
206
227
|
handleChallengeRequest(ws) {
|
|
@@ -259,29 +280,49 @@ class DainTunnelServer {
|
|
|
259
280
|
this.tunnels.set(tunnelId, { id: tunnelId, ws });
|
|
260
281
|
// Save tunnel ID on the WebSocket object for easy lookup on close
|
|
261
282
|
ws.tunnelId = tunnelId;
|
|
262
|
-
//
|
|
283
|
+
// Liveness detection: track pong responses
|
|
284
|
+
let isAlive = true;
|
|
285
|
+
let missedPongs = 0;
|
|
286
|
+
const MAX_MISSED_PONGS = 2; // Allow 2 missed pongs before considering dead
|
|
287
|
+
// Handle pong responses to verify connection is alive
|
|
288
|
+
ws.on("pong", () => {
|
|
289
|
+
isAlive = true;
|
|
290
|
+
missedPongs = 0;
|
|
291
|
+
});
|
|
292
|
+
// Add a periodic ping to keep the connection alive and detect dead connections
|
|
263
293
|
const intervalId = setInterval(() => {
|
|
264
|
-
if (this.tunnels.has(tunnelId)) {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
294
|
+
if (!this.tunnels.has(tunnelId)) {
|
|
295
|
+
clearInterval(intervalId);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const tunnel = this.tunnels.get(tunnelId);
|
|
299
|
+
if (!tunnel || tunnel.ws !== ws || ws.readyState !== ws_1.default.OPEN) {
|
|
300
|
+
clearInterval(intervalId);
|
|
301
|
+
if (tunnel && tunnel.ws === ws) {
|
|
302
|
+
this.tunnels.delete(tunnelId);
|
|
273
303
|
}
|
|
274
|
-
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
// Check if previous ping was acknowledged
|
|
307
|
+
if (!isAlive) {
|
|
308
|
+
missedPongs++;
|
|
309
|
+
if (missedPongs >= MAX_MISSED_PONGS) {
|
|
310
|
+
console.log(`[Tunnel] ${tunnelId} failed liveness check (${missedPongs} missed pongs), terminating`);
|
|
275
311
|
clearInterval(intervalId);
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
}
|
|
312
|
+
ws.terminate(); // Force close without waiting for close handshake
|
|
313
|
+
return;
|
|
279
314
|
}
|
|
280
315
|
}
|
|
281
|
-
|
|
316
|
+
// Send new ping and mark as waiting for pong
|
|
317
|
+
isAlive = false;
|
|
318
|
+
try {
|
|
319
|
+
ws.ping();
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
282
322
|
clearInterval(intervalId);
|
|
323
|
+
ws.terminate();
|
|
283
324
|
}
|
|
284
|
-
}, 30000); // Ping every 30 seconds
|
|
325
|
+
}, 30000); // Ping every 30 seconds
|
|
285
326
|
// Store the interval ID on the WebSocket object so we can clean it up
|
|
286
327
|
ws.keepAliveInterval = intervalId;
|
|
287
328
|
ws.on("close", () => clearInterval(intervalId));
|
|
@@ -299,10 +340,12 @@ class DainTunnelServer {
|
|
|
299
340
|
}
|
|
300
341
|
}
|
|
301
342
|
handleResponseMessage(data) {
|
|
343
|
+
console.log(`[Response] Received for: ${data.requestId}, status: ${data.status}`);
|
|
302
344
|
const pendingRequest = this.pendingRequests.get(data.requestId);
|
|
303
345
|
if (pendingRequest) {
|
|
304
346
|
const { res, startTime, tunnelId, timeoutId } = pendingRequest;
|
|
305
347
|
const endTime = Date.now();
|
|
348
|
+
console.log(`[Response] Completed in ${endTime - startTime}ms`);
|
|
306
349
|
// Clear the timeout since we received a response
|
|
307
350
|
if (timeoutId) {
|
|
308
351
|
clearTimeout(timeoutId);
|
|
@@ -323,58 +366,59 @@ class DainTunnelServer {
|
|
|
323
366
|
}
|
|
324
367
|
}
|
|
325
368
|
handleSSEMessage(data) {
|
|
369
|
+
console.log(`[SSE] Received message: id=${data.id}, event=${data.event}`);
|
|
326
370
|
const connection = this.sseConnections.get(data.id);
|
|
327
|
-
if (!connection)
|
|
371
|
+
if (!connection) {
|
|
372
|
+
console.log(`[SSE] Connection not found for id: ${data.id}`);
|
|
328
373
|
return;
|
|
374
|
+
}
|
|
329
375
|
const { res, tunnelId } = connection;
|
|
330
|
-
const conn = connection;
|
|
331
376
|
// Skip 'connected' event - we already wrote headers
|
|
332
|
-
if (data.event === 'connected')
|
|
377
|
+
if (data.event === 'connected') {
|
|
378
|
+
console.log(`[SSE] Received 'connected' event, skipping`);
|
|
333
379
|
return;
|
|
334
|
-
|
|
380
|
+
}
|
|
381
|
+
// Handle close event - end the response and cleanup
|
|
335
382
|
if (data.event === 'close') {
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
tunnel.ws.send(JSON.stringify({ type: "sse_close", id: data.id }));
|
|
339
|
-
if (!conn.clientDisconnected && res.writable) {
|
|
340
|
-
try {
|
|
341
|
-
res.end();
|
|
342
|
-
}
|
|
343
|
-
catch (_a) { }
|
|
383
|
+
try {
|
|
384
|
+
res.end();
|
|
344
385
|
}
|
|
386
|
+
catch (_a) { }
|
|
345
387
|
this.cleanupSSEConnection(data.id, tunnelId);
|
|
346
388
|
return;
|
|
347
389
|
}
|
|
348
|
-
// Handle error
|
|
390
|
+
// Handle error event
|
|
349
391
|
if (data.event === 'error') {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
flushSSE(res);
|
|
354
|
-
res.end();
|
|
355
|
-
}
|
|
356
|
-
catch (_b) { }
|
|
392
|
+
try {
|
|
393
|
+
res.write(`event: error\ndata: ${data.data}\n\n`);
|
|
394
|
+
res.end();
|
|
357
395
|
}
|
|
396
|
+
catch (_b) { }
|
|
358
397
|
this.cleanupSSEConnection(data.id, tunnelId);
|
|
359
398
|
return;
|
|
360
399
|
}
|
|
361
|
-
//
|
|
362
|
-
if (conn.clientDisconnected || !res.writable) {
|
|
363
|
-
conn.clientDisconnected = true;
|
|
364
|
-
return;
|
|
365
|
-
}
|
|
366
|
-
// Forward SSE event
|
|
400
|
+
// Forward SSE event to client
|
|
367
401
|
try {
|
|
368
|
-
if (data.event)
|
|
402
|
+
if (data.event) {
|
|
369
403
|
res.write(`event: ${data.event}\n`);
|
|
370
|
-
|
|
371
|
-
|
|
404
|
+
}
|
|
405
|
+
// Split data by newlines and send each line with data: prefix
|
|
406
|
+
const dataLines = data.data.split('\n');
|
|
407
|
+
for (const line of dataLines) {
|
|
408
|
+
res.write(`data: ${line}\n`);
|
|
409
|
+
}
|
|
410
|
+
res.write('\n');
|
|
372
411
|
}
|
|
373
412
|
catch (_c) {
|
|
374
|
-
|
|
413
|
+
// Client disconnected
|
|
414
|
+
this.cleanupSSEConnection(data.id, tunnelId);
|
|
375
415
|
}
|
|
376
416
|
}
|
|
377
417
|
cleanupSSEConnection(id, tunnelId) {
|
|
418
|
+
const connection = this.sseConnections.get(id);
|
|
419
|
+
if (connection === null || connection === void 0 ? void 0 : connection.keepAliveInterval) {
|
|
420
|
+
clearInterval(connection.keepAliveInterval);
|
|
421
|
+
}
|
|
378
422
|
const currentCount = this.tunnelRequestCount.get(tunnelId) || 1;
|
|
379
423
|
this.tunnelRequestCount.set(tunnelId, Math.max(0, currentCount - 1));
|
|
380
424
|
this.sseConnections.delete(id);
|
|
@@ -404,7 +448,9 @@ class DainTunnelServer {
|
|
|
404
448
|
}
|
|
405
449
|
}
|
|
406
450
|
async handleRequest(req, res) {
|
|
451
|
+
var _a;
|
|
407
452
|
const tunnelId = req.params.tunnelId;
|
|
453
|
+
console.log(`[Request] ${req.method} /${tunnelId}${req.url}, Accept: ${(_a = req.headers.accept) === null || _a === void 0 ? void 0 : _a.substring(0, 30)}`);
|
|
408
454
|
// Check for upgraded connections (WebSockets) - these are handled by the WebSocket server
|
|
409
455
|
if (req.headers.upgrade && req.headers.upgrade.toLowerCase() === 'websocket') {
|
|
410
456
|
// This is handled by the WebSocket server now
|
|
@@ -420,8 +466,18 @@ class DainTunnelServer {
|
|
|
420
466
|
}
|
|
421
467
|
}
|
|
422
468
|
if (!tunnel) {
|
|
423
|
-
|
|
469
|
+
const available = Array.from(this.tunnels.keys());
|
|
470
|
+
console.log(`[Request] Tunnel not found: ${tunnelId}, available tunnels: [${available.join(', ')}], count: ${available.length}`);
|
|
471
|
+
// Decrement counter since we incremented speculatively
|
|
472
|
+
const count = this.tunnelRequestCount.get(tunnelId) || 1;
|
|
473
|
+
this.tunnelRequestCount.set(tunnelId, Math.max(0, count - 1));
|
|
474
|
+
return res.status(502).json({
|
|
475
|
+
error: "Bad Gateway",
|
|
476
|
+
message: `Tunnel "${tunnelId}" not connected. The service may be offline or reconnecting.`,
|
|
477
|
+
availableTunnels: available.length
|
|
478
|
+
});
|
|
424
479
|
}
|
|
480
|
+
console.log(`[Request] Tunnel found: ${tunnelId}, wsState: ${tunnel.ws.readyState}`);
|
|
425
481
|
// High-frequency optimization: Check WebSocket buffer and apply backpressure
|
|
426
482
|
if (tunnel.ws.bufferedAmount > 1024 * 1024) {
|
|
427
483
|
return res.status(503).json({ error: "Service Unavailable", message: "Tunnel high load" });
|
|
@@ -453,60 +509,87 @@ class DainTunnelServer {
|
|
|
453
509
|
}
|
|
454
510
|
}, REQUEST_TIMEOUT);
|
|
455
511
|
this.pendingRequests.set(requestId, { res, startTime, tunnelId, timeoutId });
|
|
456
|
-
tunnel.ws.
|
|
512
|
+
console.log(`[Request] Sending to tunnel client: ${requestId}, wsState: ${tunnel.ws.readyState}, buffered: ${tunnel.ws.bufferedAmount}`);
|
|
513
|
+
// FIX: Only include body if it has actual content (not empty buffer)
|
|
514
|
+
// Empty buffer is truthy but should be treated as no body
|
|
515
|
+
const hasBody = req.method !== "GET" && req.method !== "HEAD" &&
|
|
516
|
+
req.body && Buffer.isBuffer(req.body) && req.body.length > 0;
|
|
517
|
+
const sent = this.safeSend(tunnel.ws, {
|
|
457
518
|
type: "request",
|
|
458
519
|
id: requestId,
|
|
459
520
|
method: req.method,
|
|
460
521
|
path: req.url,
|
|
461
522
|
headers: req.headers,
|
|
462
|
-
body:
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
523
|
+
body: hasBody ? req.body.toString("base64") : undefined,
|
|
524
|
+
});
|
|
525
|
+
console.log(`[Request] Sent to tunnel client: ${sent}`);
|
|
526
|
+
// If send failed, clean up immediately
|
|
527
|
+
if (!sent) {
|
|
528
|
+
clearTimeout(timeoutId);
|
|
529
|
+
this.pendingRequests.delete(requestId);
|
|
530
|
+
const count = this.tunnelRequestCount.get(tunnelId) || 1;
|
|
531
|
+
this.tunnelRequestCount.set(tunnelId, Math.max(0, count - 1));
|
|
532
|
+
if (!res.headersSent) {
|
|
533
|
+
res.status(502).json({ error: "Bad Gateway", message: "Tunnel connection lost" });
|
|
534
|
+
}
|
|
535
|
+
}
|
|
466
536
|
}
|
|
467
537
|
handleSSERequest(req, res, tunnelId, tunnel) {
|
|
538
|
+
var _a;
|
|
468
539
|
const sseId = fastId();
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
if (socket === null || socket === void 0 ? void 0 : socket.setNoDelay)
|
|
472
|
-
socket.setNoDelay(true);
|
|
473
|
-
// Write SSE headers immediately to keep browser connection alive
|
|
540
|
+
console.log(`[SSE] New request: ${sseId}, tunnel: ${tunnelId}, path: ${req.url}`);
|
|
541
|
+
// Setup SSE headers
|
|
474
542
|
res.writeHead(200, {
|
|
475
543
|
'Content-Type': 'text/event-stream',
|
|
476
544
|
'Cache-Control': 'no-cache',
|
|
477
|
-
'
|
|
545
|
+
'Connection': 'keep-alive'
|
|
478
546
|
});
|
|
479
|
-
|
|
480
|
-
res.
|
|
481
|
-
|
|
547
|
+
// Flush headers and send immediate keepalive to establish the stream
|
|
548
|
+
(_a = res.flushHeaders) === null || _a === void 0 ? void 0 : _a.call(res);
|
|
549
|
+
res.write(':keepalive\n\n');
|
|
550
|
+
// Send periodic keep-alive comments to prevent proxy timeouts
|
|
551
|
+
const keepAliveIntervalMs = 5000;
|
|
552
|
+
const keepAliveInterval = setInterval(() => {
|
|
553
|
+
try {
|
|
554
|
+
if (!res.writableEnded) {
|
|
555
|
+
res.write(':keepalive\n\n');
|
|
556
|
+
}
|
|
557
|
+
else {
|
|
558
|
+
clearInterval(keepAliveInterval);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
catch (_a) {
|
|
562
|
+
clearInterval(keepAliveInterval);
|
|
563
|
+
}
|
|
564
|
+
}, keepAliveIntervalMs);
|
|
565
|
+
// Store the SSE connection
|
|
482
566
|
this.sseConnections.set(sseId, {
|
|
483
|
-
req, res, id: sseId, tunnelId,
|
|
484
|
-
clientDisconnected: false
|
|
567
|
+
req, res, id: sseId, tunnelId, keepAliveInterval
|
|
485
568
|
});
|
|
486
|
-
//
|
|
487
|
-
|
|
569
|
+
// FIX: Only include body if it has actual content (not empty buffer)
|
|
570
|
+
const hasBody = req.method !== "GET" && req.body &&
|
|
571
|
+
Buffer.isBuffer(req.body) && req.body.length > 0;
|
|
572
|
+
// Notify the tunnel client about the new SSE connection
|
|
573
|
+
const sent = this.safeSend(tunnel.ws, {
|
|
488
574
|
type: "sse_connection",
|
|
489
575
|
id: sseId,
|
|
490
576
|
path: req.url,
|
|
491
577
|
method: req.method,
|
|
492
578
|
headers: req.headers,
|
|
493
|
-
body:
|
|
494
|
-
})
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
}
|
|
508
|
-
}, 100);
|
|
509
|
-
}
|
|
579
|
+
body: hasBody ? req.body.toString("base64") : undefined
|
|
580
|
+
});
|
|
581
|
+
console.log(`[SSE] Sent sse_connection to tunnel client: ${sent}`);
|
|
582
|
+
if (!sent) {
|
|
583
|
+
console.log(`[SSE] Failed to send sse_connection, cleaning up`);
|
|
584
|
+
this.cleanupSSEConnection(sseId, tunnelId);
|
|
585
|
+
res.end();
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
// Handle client disconnect
|
|
589
|
+
req.on('close', () => {
|
|
590
|
+
console.log(`[SSE] Client disconnected: ${sseId}`);
|
|
591
|
+
this.safeSend(tunnel.ws, { type: "sse_close", id: sseId });
|
|
592
|
+
this.cleanupSSEConnection(sseId, tunnelId);
|
|
510
593
|
});
|
|
511
594
|
}
|
|
512
595
|
removeTunnel(ws) {
|
|
@@ -549,6 +632,10 @@ class DainTunnelServer {
|
|
|
549
632
|
// Close all SSE connections associated with this tunnel
|
|
550
633
|
for (const [sseId, sseConnection] of this.sseConnections.entries()) {
|
|
551
634
|
if (sseConnection.tunnelId === removedTunnelId) {
|
|
635
|
+
// Clear keepalive interval to prevent memory leak
|
|
636
|
+
if (sseConnection.keepAliveInterval) {
|
|
637
|
+
clearInterval(sseConnection.keepAliveInterval);
|
|
638
|
+
}
|
|
552
639
|
try {
|
|
553
640
|
sseConnection.res.end();
|
|
554
641
|
}
|
|
@@ -558,7 +645,7 @@ class DainTunnelServer {
|
|
|
558
645
|
}
|
|
559
646
|
// Close all WebSocket connections associated with this tunnel
|
|
560
647
|
for (const [wsId, wsConnection] of this.wsConnections.entries()) {
|
|
561
|
-
if (wsConnection.
|
|
648
|
+
if (wsConnection.tunnelId === removedTunnelId) {
|
|
562
649
|
try {
|
|
563
650
|
wsConnection.clientSocket.close(1001, "Tunnel closed");
|
|
564
651
|
}
|
package/dist/server/start.js
CHANGED
|
@@ -7,6 +7,15 @@ const index_1 = __importDefault(require("./index"));
|
|
|
7
7
|
const dotenv_1 = __importDefault(require("dotenv"));
|
|
8
8
|
// Load environment variables from .env file
|
|
9
9
|
dotenv_1.default.config();
|
|
10
|
+
// Handle uncaught exceptions to prevent server crashes
|
|
11
|
+
process.on('uncaughtException', (error) => {
|
|
12
|
+
console.error('[Fatal] Uncaught exception:', error);
|
|
13
|
+
// Don't exit - try to keep serving
|
|
14
|
+
});
|
|
15
|
+
process.on('unhandledRejection', (reason, promise) => {
|
|
16
|
+
console.error('[Fatal] Unhandled rejection at:', promise, 'reason:', reason);
|
|
17
|
+
// Don't exit - try to keep serving
|
|
18
|
+
});
|
|
10
19
|
const port = parseInt(process.env.PORT || '3000', 10);
|
|
11
20
|
const hostname = process.env.HOSTNAME || 'localhost';
|
|
12
21
|
const server = new index_1.default(hostname, port);
|
|
@@ -19,3 +28,13 @@ server.start()
|
|
|
19
28
|
console.error('Failed to start DainTunnel Server:', error);
|
|
20
29
|
process.exit(1);
|
|
21
30
|
});
|
|
31
|
+
// Graceful shutdown on SIGTERM (sent by App Platform)
|
|
32
|
+
process.on('SIGTERM', () => {
|
|
33
|
+
console.log('[Server] SIGTERM received, shutting down gracefully...');
|
|
34
|
+
server.stop().then(() => {
|
|
35
|
+
console.log('[Server] Graceful shutdown complete');
|
|
36
|
+
process.exit(0);
|
|
37
|
+
}).catch(() => {
|
|
38
|
+
process.exit(1);
|
|
39
|
+
});
|
|
40
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dainprotocol/tunnel",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.26",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"private": false,
|
|
@@ -13,13 +13,14 @@
|
|
|
13
13
|
"test": "jest",
|
|
14
14
|
"test:watch": "jest --watch",
|
|
15
15
|
"prepublishOnly": "npm run build && npm run build:types",
|
|
16
|
+
"start": "node dist/server/start.js",
|
|
16
17
|
"start-server": "ts-node src/server/start.ts"
|
|
17
18
|
},
|
|
18
19
|
"keywords": [],
|
|
19
20
|
"author": "Ryan",
|
|
20
21
|
"license": "ISC",
|
|
21
22
|
"dependencies": {
|
|
22
|
-
"@dainprotocol/service-sdk": "2.0.
|
|
23
|
+
"@dainprotocol/service-sdk": "2.0.75",
|
|
23
24
|
"@types/body-parser": "^1.19.5",
|
|
24
25
|
"@types/cors": "^2.8.17",
|
|
25
26
|
"@types/eventsource": "^3.0.0",
|