@dainprotocol/tunnel 1.1.25 → 1.1.29
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 +5 -7
- package/dist/client/index.js +239 -186
- package/dist/server/index.d.ts +7 -1
- package/dist/server/index.js +365 -331
- package/dist/server/start.js +19 -0
- package/package.json +3 -2
package/dist/server/index.js
CHANGED
|
@@ -10,40 +10,45 @@ const body_parser_1 = __importDefault(require("body-parser"));
|
|
|
10
10
|
const auth_1 = require("@dainprotocol/service-sdk/service/auth");
|
|
11
11
|
const crypto_1 = require("crypto");
|
|
12
12
|
const url_1 = require("url");
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
const TIMEOUTS = {
|
|
14
|
+
CHALLENGE_TTL: 30000,
|
|
15
|
+
PING_INTERVAL: 30000,
|
|
16
|
+
REQUEST_TIMEOUT: 30000,
|
|
17
|
+
SSE_KEEPALIVE: 5000,
|
|
18
|
+
TUNNEL_RETRY_DELAY: 100,
|
|
19
|
+
SERVER_KEEPALIVE: 65000,
|
|
20
|
+
SERVER_HEADERS: 66000,
|
|
21
|
+
};
|
|
22
|
+
const LIMITS = {
|
|
23
|
+
MAX_CONCURRENT_REQUESTS_PER_TUNNEL: 100,
|
|
24
|
+
MAX_MISSED_PONGS: 2,
|
|
25
|
+
MAX_PAYLOAD_BYTES: 100 * 1024 * 1024,
|
|
26
|
+
BACKPRESSURE_THRESHOLD: 1024 * 1024,
|
|
27
|
+
SERVER_MAX_HEADERS: 100,
|
|
28
|
+
WS_BACKLOG: 100,
|
|
29
|
+
TUNNEL_RETRY_COUNT: 3,
|
|
30
|
+
};
|
|
15
31
|
let idCounter = 0;
|
|
16
32
|
function fastId() {
|
|
17
33
|
return `${Date.now().toString(36)}-${(idCounter++).toString(36)}-${(0, crypto_1.randomBytes)(4).toString('hex')}`;
|
|
18
34
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
function flushSSE(res) {
|
|
26
|
-
try {
|
|
27
|
-
// Node.js TCP sockets use cork/uncork for buffering control
|
|
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);
|
|
35
|
+
class DainTunnelServer {
|
|
36
|
+
safeSend(ws, data) {
|
|
37
|
+
try {
|
|
38
|
+
if (ws.readyState === ws_1.default.OPEN) {
|
|
39
|
+
ws.send(JSON.stringify(data));
|
|
40
|
+
return true;
|
|
38
41
|
}
|
|
39
42
|
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
console.error("[Tunnel] SafeSend error:", error);
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
40
47
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
48
|
+
decrementRequestCount(tunnelId) {
|
|
49
|
+
const currentCount = this.tunnelRequestCount.get(tunnelId) || 1;
|
|
50
|
+
this.tunnelRequestCount.set(tunnelId, Math.max(0, currentCount - 1));
|
|
44
51
|
}
|
|
45
|
-
}
|
|
46
|
-
class DainTunnelServer {
|
|
47
52
|
constructor(hostname, port) {
|
|
48
53
|
this.hostname = hostname;
|
|
49
54
|
this.port = port;
|
|
@@ -52,34 +57,35 @@ class DainTunnelServer {
|
|
|
52
57
|
this.challenges = new Map();
|
|
53
58
|
this.sseConnections = new Map();
|
|
54
59
|
this.wsConnections = new Map();
|
|
55
|
-
// High-frequency optimization: Track active request count per tunnel for backpressure
|
|
56
60
|
this.tunnelRequestCount = new Map();
|
|
57
|
-
this.MAX_CONCURRENT_REQUESTS_PER_TUNNEL = 100;
|
|
58
61
|
this.app = (0, express_1.default)();
|
|
59
62
|
this.server = http_1.default.createServer(this.app);
|
|
60
|
-
|
|
61
|
-
this.server.
|
|
62
|
-
this.server.
|
|
63
|
-
this.server.maxHeadersCount = 100; // Limit header count for security
|
|
63
|
+
this.server.keepAliveTimeout = TIMEOUTS.SERVER_KEEPALIVE;
|
|
64
|
+
this.server.headersTimeout = TIMEOUTS.SERVER_HEADERS;
|
|
65
|
+
this.server.maxHeadersCount = LIMITS.SERVER_MAX_HEADERS;
|
|
64
66
|
this.wss = new ws_1.default.Server({
|
|
65
67
|
server: this.server,
|
|
66
|
-
path: undefined,
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
maxPayload: 100 * 1024 * 1024, // 100MB max payload
|
|
68
|
+
path: undefined,
|
|
69
|
+
backlog: LIMITS.WS_BACKLOG,
|
|
70
|
+
perMessageDeflate: false,
|
|
71
|
+
maxPayload: LIMITS.MAX_PAYLOAD_BYTES,
|
|
71
72
|
});
|
|
72
|
-
// Single optimized CORS middleware (avoid duplicate cors() + manual headers)
|
|
73
73
|
this.app.use((req, res, next) => {
|
|
74
|
-
|
|
74
|
+
const origin = req.headers.origin;
|
|
75
|
+
if (origin) {
|
|
76
|
+
res.header("Access-Control-Allow-Origin", origin);
|
|
77
|
+
res.header("Vary", "Origin");
|
|
78
|
+
res.header("Access-Control-Allow-Credentials", "true");
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
res.header("Access-Control-Allow-Origin", "*");
|
|
82
|
+
}
|
|
75
83
|
res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS");
|
|
76
84
|
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
85
|
if (req.method === "OPTIONS")
|
|
79
86
|
return res.sendStatus(204);
|
|
80
87
|
next();
|
|
81
88
|
});
|
|
82
|
-
// Add body-parser middleware (skip for GET/HEAD/OPTIONS which don't have bodies)
|
|
83
89
|
this.app.use((req, res, next) => {
|
|
84
90
|
if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') {
|
|
85
91
|
return next();
|
|
@@ -90,18 +96,37 @@ class DainTunnelServer {
|
|
|
90
96
|
this.setupWebSocketServer();
|
|
91
97
|
}
|
|
92
98
|
setupExpressRoutes() {
|
|
93
|
-
|
|
99
|
+
this.app.get("/", (req, res, next) => {
|
|
100
|
+
var _a;
|
|
101
|
+
if (((_a = req.headers.upgrade) === null || _a === void 0 ? void 0 : _a.toLowerCase()) === 'websocket')
|
|
102
|
+
return next();
|
|
103
|
+
res.status(200).json({
|
|
104
|
+
status: "healthy",
|
|
105
|
+
tunnels: this.tunnels.size,
|
|
106
|
+
pendingRequests: this.pendingRequests.size,
|
|
107
|
+
sseConnections: this.sseConnections.size,
|
|
108
|
+
wsConnections: this.wsConnections.size,
|
|
109
|
+
uptime: process.uptime()
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
this.app.get("/health", (req, res) => {
|
|
113
|
+
res.status(200).json({
|
|
114
|
+
status: "ok",
|
|
115
|
+
tunnels: this.tunnels.size,
|
|
116
|
+
pendingRequests: this.pendingRequests.size,
|
|
117
|
+
uptime: process.uptime()
|
|
118
|
+
});
|
|
119
|
+
});
|
|
94
120
|
this.app.use("/:tunnelId", this.handleRequest.bind(this));
|
|
95
121
|
}
|
|
96
122
|
setupWebSocketServer() {
|
|
97
|
-
// Handle WebSocket connections from tunnel clients
|
|
98
123
|
this.wss.on("connection", (ws, req) => {
|
|
99
124
|
var _a;
|
|
100
125
|
try {
|
|
101
126
|
const url = (0, url_1.parse)(req.url || '', true);
|
|
102
127
|
const pathParts = ((_a = url.pathname) === null || _a === void 0 ? void 0 : _a.split('/')) || [];
|
|
103
|
-
|
|
104
|
-
if (
|
|
128
|
+
const isRootConnection = !url.pathname || url.pathname === '/' || pathParts.length <= 1 || !pathParts[1];
|
|
129
|
+
if (isRootConnection) {
|
|
105
130
|
this.handleTunnelClientConnection(ws, req);
|
|
106
131
|
}
|
|
107
132
|
else {
|
|
@@ -114,25 +139,26 @@ class DainTunnelServer {
|
|
|
114
139
|
}
|
|
115
140
|
});
|
|
116
141
|
}
|
|
117
|
-
// Handle WebSocket connections from tunnel clients
|
|
118
142
|
handleTunnelClientConnection(ws, req) {
|
|
119
143
|
ws.on("message", (message) => {
|
|
120
144
|
try {
|
|
121
145
|
const data = JSON.parse(message);
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
146
|
+
switch (data.type) {
|
|
147
|
+
case "challenge_request":
|
|
148
|
+
this.handleChallengeRequest(ws);
|
|
149
|
+
break;
|
|
150
|
+
case "start":
|
|
151
|
+
this.handleStartMessage(ws, data);
|
|
152
|
+
break;
|
|
153
|
+
case "response":
|
|
154
|
+
this.handleResponseMessage(data);
|
|
155
|
+
break;
|
|
156
|
+
case "sse":
|
|
157
|
+
this.handleSSEMessage(data);
|
|
158
|
+
break;
|
|
159
|
+
case "websocket":
|
|
160
|
+
this.handleWebSocketMessage(data);
|
|
161
|
+
break;
|
|
136
162
|
}
|
|
137
163
|
}
|
|
138
164
|
catch (error) {
|
|
@@ -143,7 +169,6 @@ class DainTunnelServer {
|
|
|
143
169
|
ws.on("close", () => this.removeTunnel(ws));
|
|
144
170
|
ws.on("error", (error) => console.error("[Tunnel] WS error:", error));
|
|
145
171
|
}
|
|
146
|
-
// Handle incoming WebSocket connections to be proxied through the tunnel
|
|
147
172
|
handleProxiedWebSocketConnection(ws, req) {
|
|
148
173
|
var _a;
|
|
149
174
|
const url = (0, url_1.parse)(req.url || '', true);
|
|
@@ -159,57 +184,35 @@ class DainTunnelServer {
|
|
|
159
184
|
ws.close(1008, "Tunnel not found");
|
|
160
185
|
return;
|
|
161
186
|
}
|
|
162
|
-
// Create a WebSocket connection ID
|
|
163
187
|
const wsConnectionId = fastId();
|
|
164
|
-
// Store this connection
|
|
165
188
|
this.wsConnections.set(wsConnectionId, {
|
|
166
189
|
clientSocket: ws,
|
|
167
190
|
id: wsConnectionId,
|
|
168
191
|
path: remainingPath,
|
|
169
|
-
headers: req.headers
|
|
192
|
+
headers: req.headers,
|
|
193
|
+
tunnelId
|
|
170
194
|
});
|
|
171
|
-
|
|
172
|
-
tunnel.ws.send(JSON.stringify({
|
|
195
|
+
this.safeSend(tunnel.ws, {
|
|
173
196
|
type: "websocket_connection",
|
|
174
197
|
id: wsConnectionId,
|
|
175
198
|
path: remainingPath,
|
|
176
199
|
headers: req.headers
|
|
177
|
-
}));
|
|
178
|
-
// Handle messages from the client
|
|
179
|
-
ws.on("message", (data) => {
|
|
180
|
-
tunnel.ws.send(JSON.stringify({
|
|
181
|
-
type: "websocket",
|
|
182
|
-
id: wsConnectionId,
|
|
183
|
-
event: "message",
|
|
184
|
-
data: data.toString('base64')
|
|
185
|
-
}));
|
|
186
|
-
});
|
|
187
|
-
// Handle client disconnection
|
|
188
|
-
ws.on("close", () => {
|
|
189
|
-
tunnel.ws.send(JSON.stringify({
|
|
190
|
-
type: "websocket",
|
|
191
|
-
id: wsConnectionId,
|
|
192
|
-
event: "close"
|
|
193
|
-
}));
|
|
194
|
-
this.wsConnections.delete(wsConnectionId);
|
|
195
|
-
});
|
|
196
|
-
// Handle errors
|
|
197
|
-
ws.on("error", (error) => {
|
|
198
|
-
tunnel.ws.send(JSON.stringify({
|
|
199
|
-
type: "websocket",
|
|
200
|
-
id: wsConnectionId,
|
|
201
|
-
event: "error",
|
|
202
|
-
data: error.message
|
|
203
|
-
}));
|
|
204
200
|
});
|
|
201
|
+
const sendToTunnel = (event, data) => {
|
|
202
|
+
const currentTunnel = this.tunnels.get(tunnelId);
|
|
203
|
+
if (currentTunnel) {
|
|
204
|
+
this.safeSend(currentTunnel.ws, { type: "websocket", id: wsConnectionId, event, data });
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
ws.on("message", (data) => sendToTunnel("message", data.toString('base64')));
|
|
208
|
+
ws.on("close", () => { sendToTunnel("close"); this.wsConnections.delete(wsConnectionId); });
|
|
209
|
+
ws.on("error", (error) => sendToTunnel("error", error.message));
|
|
205
210
|
}
|
|
206
211
|
handleChallengeRequest(ws) {
|
|
207
212
|
const challenge = fastId();
|
|
208
|
-
|
|
209
|
-
this.challenges.set(challenge, challengeObj);
|
|
213
|
+
this.challenges.set(challenge, { ws, challenge, timestamp: Date.now() });
|
|
210
214
|
ws.send(JSON.stringify({ type: "challenge", challenge }));
|
|
211
|
-
|
|
212
|
-
setTimeout(() => this.challenges.delete(challenge), 30000);
|
|
215
|
+
setTimeout(() => this.challenges.delete(challenge), TIMEOUTS.CHALLENGE_TTL);
|
|
213
216
|
}
|
|
214
217
|
handleStartMessage(ws, data) {
|
|
215
218
|
const { challenge, signature, tunnelId, apiKey } = data;
|
|
@@ -220,7 +223,6 @@ class DainTunnelServer {
|
|
|
220
223
|
}
|
|
221
224
|
this.challenges.delete(challenge);
|
|
222
225
|
try {
|
|
223
|
-
// Parse API key to get the secret for HMAC validation
|
|
224
226
|
if (!apiKey) {
|
|
225
227
|
ws.close(1008, "API key required");
|
|
226
228
|
return;
|
|
@@ -230,65 +232,63 @@ class DainTunnelServer {
|
|
|
230
232
|
ws.close(1008, "Invalid API key format");
|
|
231
233
|
return;
|
|
232
234
|
}
|
|
233
|
-
|
|
234
|
-
const expectedSignature = (0, crypto_1.createHmac)('sha256', parsed.secret)
|
|
235
|
-
.update(challenge)
|
|
236
|
-
.digest('hex');
|
|
237
|
-
// Convert to buffers for timing-safe comparison
|
|
235
|
+
const expectedSignature = (0, crypto_1.createHmac)('sha256', parsed.secret).update(challenge).digest('hex');
|
|
238
236
|
const expectedSigBuffer = Buffer.from(expectedSignature, 'hex');
|
|
239
237
|
const receivedSigBuffer = Buffer.from(signature, 'hex');
|
|
240
|
-
// Constant-time comparison to prevent timing attacks
|
|
241
238
|
if (expectedSigBuffer.length !== receivedSigBuffer.length ||
|
|
242
239
|
!(0, crypto_1.timingSafeEqual)(expectedSigBuffer, receivedSigBuffer)) {
|
|
243
240
|
ws.close(1008, "Invalid signature");
|
|
244
241
|
return;
|
|
245
242
|
}
|
|
246
|
-
// Verify that tunnelId matches orgId_agentId from the API key
|
|
247
243
|
const expectedTunnelId = `${parsed.orgId}_${parsed.agentId}`;
|
|
248
244
|
if (tunnelId !== expectedTunnelId) {
|
|
249
245
|
ws.close(1008, `Tunnel ID does not match API key. Expected: ${expectedTunnelId}, Got: ${tunnelId}`);
|
|
250
246
|
return;
|
|
251
247
|
}
|
|
252
|
-
|
|
253
|
-
if (
|
|
254
|
-
|
|
255
|
-
if (oldTunnel && oldTunnel.ws !== ws) {
|
|
256
|
-
oldTunnel.ws.close(1000, "Replaced by new connection");
|
|
257
|
-
}
|
|
248
|
+
const existingTunnel = this.tunnels.get(tunnelId);
|
|
249
|
+
if (existingTunnel && existingTunnel.ws !== ws) {
|
|
250
|
+
existingTunnel.ws.close(1000, "Replaced by new connection");
|
|
258
251
|
}
|
|
259
252
|
this.tunnels.set(tunnelId, { id: tunnelId, ws });
|
|
260
|
-
// Save tunnel ID on the WebSocket object for easy lookup on close
|
|
261
253
|
ws.tunnelId = tunnelId;
|
|
262
|
-
|
|
254
|
+
let isAlive = true;
|
|
255
|
+
let missedPongs = 0;
|
|
256
|
+
ws.on("pong", () => { isAlive = true; missedPongs = 0; });
|
|
263
257
|
const intervalId = setInterval(() => {
|
|
264
|
-
if (this.tunnels.has(tunnelId)) {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
258
|
+
if (!this.tunnels.has(tunnelId)) {
|
|
259
|
+
clearInterval(intervalId);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const tunnel = this.tunnels.get(tunnelId);
|
|
263
|
+
if (!tunnel || tunnel.ws !== ws || ws.readyState !== ws_1.default.OPEN) {
|
|
264
|
+
clearInterval(intervalId);
|
|
265
|
+
if ((tunnel === null || tunnel === void 0 ? void 0 : tunnel.ws) === ws)
|
|
266
|
+
this.tunnels.delete(tunnelId);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (!isAlive) {
|
|
270
|
+
missedPongs++;
|
|
271
|
+
if (missedPongs >= LIMITS.MAX_MISSED_PONGS) {
|
|
272
|
+
console.log(`[Tunnel] ${tunnelId} failed liveness check (${missedPongs} missed pongs), terminating`);
|
|
275
273
|
clearInterval(intervalId);
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
}
|
|
274
|
+
ws.terminate();
|
|
275
|
+
return;
|
|
279
276
|
}
|
|
280
277
|
}
|
|
281
|
-
|
|
278
|
+
isAlive = false;
|
|
279
|
+
try {
|
|
280
|
+
ws.ping();
|
|
281
|
+
}
|
|
282
|
+
catch (_a) {
|
|
282
283
|
clearInterval(intervalId);
|
|
284
|
+
ws.terminate();
|
|
283
285
|
}
|
|
284
|
-
},
|
|
285
|
-
// Store the interval ID on the WebSocket object so we can clean it up
|
|
286
|
+
}, TIMEOUTS.PING_INTERVAL);
|
|
286
287
|
ws.keepAliveInterval = intervalId;
|
|
287
288
|
ws.on("close", () => clearInterval(intervalId));
|
|
288
|
-
let tunnelUrl =
|
|
289
|
-
if (process.env.SKIP_PORT !== "true")
|
|
289
|
+
let tunnelUrl = this.hostname;
|
|
290
|
+
if (process.env.SKIP_PORT !== "true")
|
|
290
291
|
tunnelUrl += `:${this.port}`;
|
|
291
|
-
}
|
|
292
292
|
tunnelUrl += `/${tunnelId}`;
|
|
293
293
|
ws.send(JSON.stringify({ type: "tunnelUrl", url: tunnelUrl }));
|
|
294
294
|
console.log(`[Tunnel] Created: ${tunnelUrl}`);
|
|
@@ -299,84 +299,63 @@ class DainTunnelServer {
|
|
|
299
299
|
}
|
|
300
300
|
}
|
|
301
301
|
handleResponseMessage(data) {
|
|
302
|
+
console.log(`[Response] Received for: ${data.requestId}, status: ${data.status}`);
|
|
302
303
|
const pendingRequest = this.pendingRequests.get(data.requestId);
|
|
303
|
-
if (pendingRequest)
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
const bodyBuffer = Buffer.from(data.body, "base64");
|
|
317
|
-
res
|
|
318
|
-
.status(data.status)
|
|
319
|
-
.set(headers)
|
|
320
|
-
.set("Content-Length", bodyBuffer.length.toString())
|
|
321
|
-
.send(bodyBuffer);
|
|
322
|
-
this.pendingRequests.delete(data.requestId);
|
|
323
|
-
}
|
|
304
|
+
if (!pendingRequest)
|
|
305
|
+
return;
|
|
306
|
+
const { res, startTime, tunnelId, timeoutId } = pendingRequest;
|
|
307
|
+
console.log(`[Response] Completed in ${Date.now() - startTime}ms`);
|
|
308
|
+
if (timeoutId)
|
|
309
|
+
clearTimeout(timeoutId);
|
|
310
|
+
this.decrementRequestCount(tunnelId);
|
|
311
|
+
const headers = { ...data.headers };
|
|
312
|
+
delete headers["transfer-encoding"];
|
|
313
|
+
delete headers["content-length"];
|
|
314
|
+
const bodyBuffer = Buffer.from(data.body, "base64");
|
|
315
|
+
res.status(data.status).set(headers).set("Content-Length", bodyBuffer.length.toString()).send(bodyBuffer);
|
|
316
|
+
this.pendingRequests.delete(data.requestId);
|
|
324
317
|
}
|
|
325
318
|
handleSSEMessage(data) {
|
|
326
319
|
const connection = this.sseConnections.get(data.id);
|
|
327
320
|
if (!connection)
|
|
328
321
|
return;
|
|
329
322
|
const { res, tunnelId } = connection;
|
|
330
|
-
const conn = connection;
|
|
331
|
-
// Skip 'connected' event - we already wrote headers
|
|
332
323
|
if (data.event === 'connected')
|
|
333
324
|
return;
|
|
334
|
-
// Handle close event
|
|
335
325
|
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) { }
|
|
326
|
+
try {
|
|
327
|
+
res.end();
|
|
344
328
|
}
|
|
329
|
+
catch (_a) { }
|
|
345
330
|
this.cleanupSSEConnection(data.id, tunnelId);
|
|
346
331
|
return;
|
|
347
332
|
}
|
|
348
|
-
// Handle error - send as SSE error event (headers already written)
|
|
349
333
|
if (data.event === 'error') {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
flushSSE(res);
|
|
354
|
-
res.end();
|
|
355
|
-
}
|
|
356
|
-
catch (_b) { }
|
|
334
|
+
try {
|
|
335
|
+
res.write(`event: error\ndata: ${data.data}\n\n`);
|
|
336
|
+
res.end();
|
|
357
337
|
}
|
|
338
|
+
catch (_b) { }
|
|
358
339
|
this.cleanupSSEConnection(data.id, tunnelId);
|
|
359
340
|
return;
|
|
360
341
|
}
|
|
361
|
-
// Skip if client disconnected
|
|
362
|
-
if (conn.clientDisconnected || !res.writable) {
|
|
363
|
-
conn.clientDisconnected = true;
|
|
364
|
-
return;
|
|
365
|
-
}
|
|
366
|
-
// Forward SSE event
|
|
367
342
|
try {
|
|
368
343
|
if (data.event)
|
|
369
344
|
res.write(`event: ${data.event}\n`);
|
|
370
|
-
|
|
371
|
-
|
|
345
|
+
for (const line of data.data.split('\n')) {
|
|
346
|
+
res.write(`data: ${line}\n`);
|
|
347
|
+
}
|
|
348
|
+
res.write('\n');
|
|
372
349
|
}
|
|
373
350
|
catch (_c) {
|
|
374
|
-
|
|
351
|
+
this.cleanupSSEConnection(data.id, tunnelId);
|
|
375
352
|
}
|
|
376
353
|
}
|
|
377
354
|
cleanupSSEConnection(id, tunnelId) {
|
|
378
|
-
const
|
|
379
|
-
|
|
355
|
+
const connection = this.sseConnections.get(id);
|
|
356
|
+
if (connection === null || connection === void 0 ? void 0 : connection.keepAliveInterval)
|
|
357
|
+
clearInterval(connection.keepAliveInterval);
|
|
358
|
+
this.decrementRequestCount(tunnelId);
|
|
380
359
|
this.sseConnections.delete(id);
|
|
381
360
|
}
|
|
382
361
|
handleWebSocketMessage(data) {
|
|
@@ -384,199 +363,260 @@ class DainTunnelServer {
|
|
|
384
363
|
if (!connection)
|
|
385
364
|
return;
|
|
386
365
|
const { clientSocket } = connection;
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
this.wsConnections.delete(data.id);
|
|
366
|
+
const isOpen = clientSocket.readyState === ws_1.default.OPEN;
|
|
367
|
+
switch (data.event) {
|
|
368
|
+
case 'message':
|
|
369
|
+
if (data.data && isOpen)
|
|
370
|
+
clientSocket.send(Buffer.from(data.data, 'base64'));
|
|
371
|
+
break;
|
|
372
|
+
case 'close':
|
|
373
|
+
if (isOpen)
|
|
374
|
+
clientSocket.close();
|
|
375
|
+
this.wsConnections.delete(data.id);
|
|
376
|
+
break;
|
|
377
|
+
case 'error':
|
|
378
|
+
if (isOpen)
|
|
379
|
+
clientSocket.close(1011, data.data);
|
|
380
|
+
this.wsConnections.delete(data.id);
|
|
381
|
+
break;
|
|
404
382
|
}
|
|
405
383
|
}
|
|
406
384
|
async handleRequest(req, res) {
|
|
385
|
+
var _a, _b, _c;
|
|
407
386
|
const tunnelId = req.params.tunnelId;
|
|
408
|
-
|
|
409
|
-
if (req.headers.upgrade
|
|
410
|
-
// This is handled by the WebSocket server now
|
|
387
|
+
console.log(`[Request] ${req.method} /${tunnelId}${req.url}, Accept: ${(_a = req.headers.accept) === null || _a === void 0 ? void 0 : _a.substring(0, 30)}`);
|
|
388
|
+
if (((_b = req.headers.upgrade) === null || _b === void 0 ? void 0 : _b.toLowerCase()) === 'websocket')
|
|
411
389
|
return;
|
|
412
|
-
}
|
|
413
390
|
let tunnel;
|
|
414
|
-
let retries =
|
|
391
|
+
let retries = LIMITS.TUNNEL_RETRY_COUNT;
|
|
415
392
|
while (retries > 0 && !tunnel) {
|
|
416
393
|
tunnel = this.tunnels.get(tunnelId);
|
|
417
394
|
if (!tunnel) {
|
|
418
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
395
|
+
await new Promise(resolve => setTimeout(resolve, TIMEOUTS.TUNNEL_RETRY_DELAY));
|
|
419
396
|
retries--;
|
|
420
397
|
}
|
|
421
398
|
}
|
|
422
399
|
if (!tunnel) {
|
|
423
|
-
|
|
400
|
+
const available = Array.from(this.tunnels.keys());
|
|
401
|
+
console.log(`[Request] Tunnel not found: ${tunnelId}, available tunnels: [${available.join(', ')}], count: ${available.length}`);
|
|
402
|
+
this.decrementRequestCount(tunnelId);
|
|
403
|
+
res.status(502).json({
|
|
404
|
+
error: "Bad Gateway",
|
|
405
|
+
message: `Tunnel "${tunnelId}" not connected. The service may be offline or reconnecting.`,
|
|
406
|
+
availableTunnels: available.length
|
|
407
|
+
});
|
|
408
|
+
return;
|
|
424
409
|
}
|
|
425
|
-
|
|
426
|
-
if (tunnel.ws.bufferedAmount >
|
|
427
|
-
|
|
410
|
+
console.log(`[Request] Tunnel found: ${tunnelId}, wsState: ${tunnel.ws.readyState}`);
|
|
411
|
+
if (tunnel.ws.bufferedAmount > LIMITS.BACKPRESSURE_THRESHOLD) {
|
|
412
|
+
res.status(503).json({ error: "Service Unavailable", message: "Tunnel high load" });
|
|
413
|
+
return;
|
|
428
414
|
}
|
|
429
|
-
// Check concurrent request limit per tunnel
|
|
430
415
|
const currentCount = this.tunnelRequestCount.get(tunnelId) || 0;
|
|
431
|
-
if (currentCount >=
|
|
432
|
-
|
|
416
|
+
if (currentCount >= LIMITS.MAX_CONCURRENT_REQUESTS_PER_TUNNEL) {
|
|
417
|
+
res.status(503).json({ error: "Service Unavailable", message: "Too many concurrent requests" });
|
|
418
|
+
return;
|
|
433
419
|
}
|
|
434
420
|
this.tunnelRequestCount.set(tunnelId, currentCount + 1);
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
return
|
|
421
|
+
if ((_c = req.headers.accept) === null || _c === void 0 ? void 0 : _c.includes('text/event-stream')) {
|
|
422
|
+
this.handleSSERequest(req, res, tunnelId, tunnel);
|
|
423
|
+
return;
|
|
438
424
|
}
|
|
439
|
-
// Handle regular HTTP request
|
|
440
425
|
const requestId = fastId();
|
|
441
426
|
const startTime = Date.now();
|
|
442
|
-
// Set a timeout for the request (30 seconds)
|
|
443
|
-
const REQUEST_TIMEOUT = 30000;
|
|
444
427
|
const timeoutId = setTimeout(() => {
|
|
445
428
|
const pendingRequest = this.pendingRequests.get(requestId);
|
|
446
429
|
if (pendingRequest) {
|
|
447
|
-
|
|
448
|
-
this.tunnelRequestCount.set(tunnelId, Math.max(0, currentCount - 1));
|
|
430
|
+
this.decrementRequestCount(tunnelId);
|
|
449
431
|
this.pendingRequests.delete(requestId);
|
|
450
432
|
if (!res.headersSent) {
|
|
451
433
|
res.status(504).json({ error: "Gateway Timeout", message: "Request timed out" });
|
|
452
434
|
}
|
|
453
435
|
}
|
|
454
|
-
}, REQUEST_TIMEOUT);
|
|
436
|
+
}, TIMEOUTS.REQUEST_TIMEOUT);
|
|
455
437
|
this.pendingRequests.set(requestId, { res, startTime, tunnelId, timeoutId });
|
|
456
|
-
tunnel.ws.
|
|
438
|
+
console.log(`[Request] Sending to tunnel client: ${requestId}, wsState: ${tunnel.ws.readyState}, buffered: ${tunnel.ws.bufferedAmount}`);
|
|
439
|
+
const hasBody = req.method !== "GET" && req.method !== "HEAD" &&
|
|
440
|
+
req.body && Buffer.isBuffer(req.body) && req.body.length > 0;
|
|
441
|
+
const sent = this.safeSend(tunnel.ws, {
|
|
457
442
|
type: "request",
|
|
458
443
|
id: requestId,
|
|
459
444
|
method: req.method,
|
|
460
445
|
path: req.url,
|
|
461
446
|
headers: req.headers,
|
|
462
|
-
body:
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
447
|
+
body: hasBody ? req.body.toString("base64") : undefined,
|
|
448
|
+
});
|
|
449
|
+
console.log(`[Request] Sent to tunnel client: ${sent}`);
|
|
450
|
+
if (!sent) {
|
|
451
|
+
clearTimeout(timeoutId);
|
|
452
|
+
this.pendingRequests.delete(requestId);
|
|
453
|
+
this.decrementRequestCount(tunnelId);
|
|
454
|
+
if (!res.headersSent) {
|
|
455
|
+
res.status(502).json({ error: "Bad Gateway", message: "Tunnel connection lost" });
|
|
456
|
+
}
|
|
457
|
+
}
|
|
466
458
|
}
|
|
467
459
|
handleSSERequest(req, res, tunnelId, tunnel) {
|
|
460
|
+
var _a, _b;
|
|
468
461
|
const sseId = fastId();
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
if (socket
|
|
472
|
-
socket.setNoDelay(true);
|
|
473
|
-
|
|
474
|
-
|
|
462
|
+
const startTime = Date.now();
|
|
463
|
+
console.log(`[SSE] ${sseId} started: ${req.url}`);
|
|
464
|
+
if (req.socket) {
|
|
465
|
+
req.socket.setNoDelay(true);
|
|
466
|
+
req.socket.setTimeout(0);
|
|
467
|
+
}
|
|
468
|
+
const origin = req.headers.origin;
|
|
469
|
+
const sseHeaders = {
|
|
475
470
|
'Content-Type': 'text/event-stream',
|
|
476
|
-
'Cache-Control': 'no-cache',
|
|
471
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
472
|
+
'Connection': 'keep-alive',
|
|
477
473
|
'X-Accel-Buffering': 'no',
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
474
|
+
'X-Content-Type-Options': 'nosniff',
|
|
475
|
+
'Content-Encoding': 'identity',
|
|
476
|
+
'Transfer-Encoding': 'chunked',
|
|
477
|
+
};
|
|
478
|
+
if (typeof origin === 'string') {
|
|
479
|
+
sseHeaders['Access-Control-Allow-Origin'] = origin;
|
|
480
|
+
sseHeaders['Access-Control-Allow-Credentials'] = 'true';
|
|
481
|
+
sseHeaders['Vary'] = 'Origin';
|
|
482
|
+
}
|
|
483
|
+
else {
|
|
484
|
+
sseHeaders['Access-Control-Allow-Origin'] = '*';
|
|
485
|
+
}
|
|
486
|
+
res.writeHead(200, sseHeaders);
|
|
487
|
+
const flush = () => {
|
|
488
|
+
if (typeof res.flush === 'function')
|
|
489
|
+
res.flush();
|
|
490
|
+
};
|
|
491
|
+
(_a = res.flushHeaders) === null || _a === void 0 ? void 0 : _a.call(res);
|
|
492
|
+
res.write(':keepalive\n\n');
|
|
493
|
+
flush();
|
|
494
|
+
const keepAliveInterval = setInterval(() => {
|
|
495
|
+
try {
|
|
496
|
+
if (!res.writableEnded) {
|
|
497
|
+
res.write(':keepalive\n\n');
|
|
498
|
+
flush();
|
|
499
|
+
}
|
|
500
|
+
else
|
|
501
|
+
clearInterval(keepAliveInterval);
|
|
502
|
+
}
|
|
503
|
+
catch (_a) {
|
|
504
|
+
clearInterval(keepAliveInterval);
|
|
505
|
+
}
|
|
506
|
+
}, TIMEOUTS.SSE_KEEPALIVE);
|
|
507
|
+
this.sseConnections.set(sseId, { req, res, id: sseId, tunnelId, keepAliveInterval });
|
|
508
|
+
const hasBody = req.method !== "GET" && req.body &&
|
|
509
|
+
Buffer.isBuffer(req.body) && req.body.length > 0;
|
|
510
|
+
const sent = this.safeSend(tunnel.ws, {
|
|
488
511
|
type: "sse_connection",
|
|
489
512
|
id: sseId,
|
|
490
513
|
path: req.url,
|
|
491
514
|
method: req.method,
|
|
492
515
|
headers: req.headers,
|
|
493
|
-
body:
|
|
494
|
-
})
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
516
|
+
body: hasBody ? req.body.toString("base64") : undefined
|
|
517
|
+
});
|
|
518
|
+
if (!sent) {
|
|
519
|
+
this.cleanupSSEConnection(sseId, tunnelId);
|
|
520
|
+
res.end();
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
let cleanedUp = false;
|
|
524
|
+
const doCleanup = () => {
|
|
525
|
+
if (cleanedUp)
|
|
526
|
+
return;
|
|
527
|
+
cleanedUp = true;
|
|
528
|
+
console.log(`[SSE] ${sseId} completed in ${Date.now() - startTime}ms`);
|
|
529
|
+
this.safeSend(tunnel.ws, { type: "sse_close", id: sseId });
|
|
530
|
+
this.cleanupSSEConnection(sseId, tunnelId);
|
|
531
|
+
};
|
|
532
|
+
req.on('close', () => {
|
|
533
|
+
var _a;
|
|
534
|
+
if (((_a = req.socket) === null || _a === void 0 ? void 0 : _a.destroyed) || res.writableEnded)
|
|
535
|
+
doCleanup();
|
|
510
536
|
});
|
|
537
|
+
(_b = req.socket) === null || _b === void 0 ? void 0 : _b.on('close', doCleanup);
|
|
538
|
+
req.on('error', doCleanup);
|
|
539
|
+
res.on('error', doCleanup);
|
|
511
540
|
}
|
|
512
541
|
removeTunnel(ws) {
|
|
513
542
|
try {
|
|
514
543
|
if (ws.keepAliveInterval)
|
|
515
544
|
clearInterval(ws.keepAliveInterval);
|
|
516
|
-
const
|
|
517
|
-
let removedTunnelId = tunnelId;
|
|
518
|
-
if (tunnelId && this.tunnels.has(tunnelId)) {
|
|
519
|
-
const tunnel = this.tunnels.get(tunnelId);
|
|
520
|
-
if (tunnel && tunnel.ws === ws)
|
|
521
|
-
this.tunnels.delete(tunnelId);
|
|
522
|
-
}
|
|
523
|
-
else {
|
|
524
|
-
removedTunnelId = undefined;
|
|
525
|
-
for (const [id, tunnel] of this.tunnels.entries()) {
|
|
526
|
-
if (tunnel.ws === ws) {
|
|
527
|
-
this.tunnels.delete(id);
|
|
528
|
-
removedTunnelId = id;
|
|
529
|
-
break;
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
}
|
|
545
|
+
const removedTunnelId = this.findAndRemoveTunnel(ws);
|
|
533
546
|
if (removedTunnelId) {
|
|
534
547
|
this.tunnelRequestCount.delete(removedTunnelId);
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
548
|
+
this.cleanupPendingRequests(removedTunnelId);
|
|
549
|
+
this.cleanupTunnelSSEConnections(removedTunnelId);
|
|
550
|
+
this.cleanupTunnelWSConnections(removedTunnelId);
|
|
551
|
+
}
|
|
552
|
+
this.cleanupChallengesForSocket(ws);
|
|
553
|
+
}
|
|
554
|
+
catch (error) {
|
|
555
|
+
console.error("[Tunnel] Remove error:", error);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
findAndRemoveTunnel(ws) {
|
|
559
|
+
const tunnelId = ws.tunnelId;
|
|
560
|
+
if (tunnelId && this.tunnels.has(tunnelId)) {
|
|
561
|
+
const tunnel = this.tunnels.get(tunnelId);
|
|
562
|
+
if ((tunnel === null || tunnel === void 0 ? void 0 : tunnel.ws) === ws) {
|
|
563
|
+
this.tunnels.delete(tunnelId);
|
|
564
|
+
return tunnelId;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
for (const [id, tunnel] of this.tunnels.entries()) {
|
|
568
|
+
if (tunnel.ws === ws) {
|
|
569
|
+
this.tunnels.delete(id);
|
|
570
|
+
return id;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return undefined;
|
|
574
|
+
}
|
|
575
|
+
cleanupPendingRequests(tunnelId) {
|
|
576
|
+
for (const [requestId, pending] of this.pendingRequests.entries()) {
|
|
577
|
+
if (pending.tunnelId === tunnelId) {
|
|
578
|
+
try {
|
|
579
|
+
if (pending.timeoutId)
|
|
580
|
+
clearTimeout(pending.timeoutId);
|
|
581
|
+
if (!pending.res.headersSent) {
|
|
582
|
+
pending.res.status(502).json({ error: "Bad Gateway", message: "Tunnel closed" });
|
|
557
583
|
}
|
|
558
584
|
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
585
|
+
catch (_a) { }
|
|
586
|
+
this.pendingRequests.delete(requestId);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
cleanupTunnelSSEConnections(tunnelId) {
|
|
591
|
+
for (const [sseId, conn] of this.sseConnections.entries()) {
|
|
592
|
+
if (conn.tunnelId === tunnelId) {
|
|
593
|
+
if (conn.keepAliveInterval)
|
|
594
|
+
clearInterval(conn.keepAliveInterval);
|
|
595
|
+
try {
|
|
596
|
+
conn.res.end();
|
|
568
597
|
}
|
|
598
|
+
catch (_a) { }
|
|
599
|
+
this.sseConnections.delete(sseId);
|
|
569
600
|
}
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
cleanupTunnelWSConnections(tunnelId) {
|
|
604
|
+
for (const [wsId, conn] of this.wsConnections.entries()) {
|
|
605
|
+
if (conn.tunnelId === tunnelId) {
|
|
606
|
+
try {
|
|
607
|
+
conn.clientSocket.close(1001, "Tunnel closed");
|
|
575
608
|
}
|
|
609
|
+
catch (_a) { }
|
|
610
|
+
this.wsConnections.delete(wsId);
|
|
576
611
|
}
|
|
577
612
|
}
|
|
578
|
-
|
|
579
|
-
|
|
613
|
+
}
|
|
614
|
+
cleanupChallengesForSocket(ws) {
|
|
615
|
+
for (const [challenge, obj] of this.challenges.entries()) {
|
|
616
|
+
if (obj.ws === ws) {
|
|
617
|
+
this.challenges.delete(challenge);
|
|
618
|
+
break;
|
|
619
|
+
}
|
|
580
620
|
}
|
|
581
621
|
}
|
|
582
622
|
async start() {
|
|
@@ -590,37 +630,31 @@ class DainTunnelServer {
|
|
|
590
630
|
async stop() {
|
|
591
631
|
return new Promise((resolve) => {
|
|
592
632
|
try {
|
|
593
|
-
|
|
594
|
-
for (const [sseId, sseConnection] of this.sseConnections.entries()) {
|
|
633
|
+
for (const [sseId, conn] of this.sseConnections.entries()) {
|
|
595
634
|
try {
|
|
596
|
-
|
|
635
|
+
conn.res.end();
|
|
597
636
|
}
|
|
598
637
|
catch (error) {
|
|
599
|
-
console.error(`Error closing SSE
|
|
638
|
+
console.error(`Error closing SSE ${sseId}:`, error);
|
|
600
639
|
}
|
|
601
640
|
this.sseConnections.delete(sseId);
|
|
602
641
|
}
|
|
603
|
-
|
|
604
|
-
for (const [wsId, wsConnection] of this.wsConnections.entries()) {
|
|
642
|
+
for (const [wsId, conn] of this.wsConnections.entries()) {
|
|
605
643
|
try {
|
|
606
|
-
|
|
644
|
+
conn.clientSocket.close(1001, "Server shutting down");
|
|
607
645
|
}
|
|
608
646
|
catch (error) {
|
|
609
|
-
console.error(`Error closing
|
|
647
|
+
console.error(`Error closing WS ${wsId}:`, error);
|
|
610
648
|
}
|
|
611
649
|
this.wsConnections.delete(wsId);
|
|
612
650
|
}
|
|
613
|
-
// Close the WebSocket server
|
|
614
651
|
this.wss.close(() => {
|
|
615
|
-
|
|
616
|
-
this.server.close(() => {
|
|
617
|
-
resolve();
|
|
618
|
-
});
|
|
652
|
+
this.server.close(() => resolve());
|
|
619
653
|
});
|
|
620
654
|
}
|
|
621
655
|
catch (error) {
|
|
622
656
|
console.error('Error during server shutdown:', error);
|
|
623
|
-
resolve();
|
|
657
|
+
resolve();
|
|
624
658
|
}
|
|
625
659
|
});
|
|
626
660
|
}
|