@dainprotocol/tunnel 1.1.26 → 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.
@@ -10,16 +10,29 @@ 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
- // High-frequency optimization: Fast ID generation using crypto.randomBytes
14
- // ~10x faster than uuid v4 for high-frequency scenarios
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
35
  class DainTunnelServer {
20
- /**
21
- * Safely send a message to a WebSocket, handling errors gracefully
22
- */
23
36
  safeSend(ws, data) {
24
37
  try {
25
38
  if (ws.readyState === ws_1.default.OPEN) {
@@ -32,6 +45,10 @@ class DainTunnelServer {
32
45
  }
33
46
  return false;
34
47
  }
48
+ decrementRequestCount(tunnelId) {
49
+ const currentCount = this.tunnelRequestCount.get(tunnelId) || 1;
50
+ this.tunnelRequestCount.set(tunnelId, Math.max(0, currentCount - 1));
51
+ }
35
52
  constructor(hostname, port) {
36
53
  this.hostname = hostname;
37
54
  this.port = port;
@@ -40,34 +57,35 @@ class DainTunnelServer {
40
57
  this.challenges = new Map();
41
58
  this.sseConnections = new Map();
42
59
  this.wsConnections = new Map();
43
- // High-frequency optimization: Track active request count per tunnel for backpressure
44
60
  this.tunnelRequestCount = new Map();
45
- this.MAX_CONCURRENT_REQUESTS_PER_TUNNEL = 100;
46
61
  this.app = (0, express_1.default)();
47
62
  this.server = http_1.default.createServer(this.app);
48
- // High-frequency optimization: Configure server for better throughput
49
- this.server.keepAliveTimeout = 65000; // Keep connections alive longer (default 5s)
50
- this.server.headersTimeout = 66000; // Must be higher than keepAliveTimeout
51
- 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;
52
66
  this.wss = new ws_1.default.Server({
53
67
  server: this.server,
54
- path: undefined, // Allow connections on any path
55
- // High-frequency optimization: Increase backlog and disable per-message deflate
56
- backlog: 100,
57
- perMessageDeflate: false, // Disable compression for lower latency
58
- maxPayload: 100 * 1024 * 1024, // 100MB max payload
68
+ path: undefined,
69
+ backlog: LIMITS.WS_BACKLOG,
70
+ perMessageDeflate: false,
71
+ maxPayload: LIMITS.MAX_PAYLOAD_BYTES,
59
72
  });
60
- // Single optimized CORS middleware (avoid duplicate cors() + manual headers)
61
73
  this.app.use((req, res, next) => {
62
- res.header("Access-Control-Allow-Origin", "*");
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
+ }
63
83
  res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS");
64
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");
65
- res.header("Access-Control-Allow-Credentials", "true");
66
85
  if (req.method === "OPTIONS")
67
86
  return res.sendStatus(204);
68
87
  next();
69
88
  });
70
- // Add body-parser middleware (skip for GET/HEAD/OPTIONS which don't have bodies)
71
89
  this.app.use((req, res, next) => {
72
90
  if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') {
73
91
  return next();
@@ -78,12 +96,10 @@ class DainTunnelServer {
78
96
  this.setupWebSocketServer();
79
97
  }
80
98
  setupExpressRoutes() {
81
- // Health check endpoint for App Platform / load balancers
82
- // Skip WebSocket upgrade requests - let WebSocket server handle them
83
99
  this.app.get("/", (req, res, next) => {
84
- if (req.headers.upgrade && req.headers.upgrade.toLowerCase() === 'websocket') {
100
+ var _a;
101
+ if (((_a = req.headers.upgrade) === null || _a === void 0 ? void 0 : _a.toLowerCase()) === 'websocket')
85
102
  return next();
86
- }
87
103
  res.status(200).json({
88
104
  status: "healthy",
89
105
  tunnels: this.tunnels.size,
@@ -101,18 +117,16 @@ class DainTunnelServer {
101
117
  uptime: process.uptime()
102
118
  });
103
119
  });
104
- // Generic route handler for all tunnel requests
105
120
  this.app.use("/:tunnelId", this.handleRequest.bind(this));
106
121
  }
107
122
  setupWebSocketServer() {
108
- // Handle WebSocket connections from tunnel clients
109
123
  this.wss.on("connection", (ws, req) => {
110
124
  var _a;
111
125
  try {
112
126
  const url = (0, url_1.parse)(req.url || '', true);
113
127
  const pathParts = ((_a = url.pathname) === null || _a === void 0 ? void 0 : _a.split('/')) || [];
114
- // Client tunnel connection has no tunnelId in the path (or is at root)
115
- if (!url.pathname || url.pathname === '/' || pathParts.length <= 1 || !pathParts[1]) {
128
+ const isRootConnection = !url.pathname || url.pathname === '/' || pathParts.length <= 1 || !pathParts[1];
129
+ if (isRootConnection) {
116
130
  this.handleTunnelClientConnection(ws, req);
117
131
  }
118
132
  else {
@@ -125,25 +139,26 @@ class DainTunnelServer {
125
139
  }
126
140
  });
127
141
  }
128
- // Handle WebSocket connections from tunnel clients
129
142
  handleTunnelClientConnection(ws, req) {
130
143
  ws.on("message", (message) => {
131
144
  try {
132
145
  const data = JSON.parse(message);
133
- if (data.type === "challenge_request") {
134
- this.handleChallengeRequest(ws);
135
- }
136
- else if (data.type === "start") {
137
- this.handleStartMessage(ws, data);
138
- }
139
- else if (data.type === "response") {
140
- this.handleResponseMessage(data);
141
- }
142
- else if (data.type === "sse") {
143
- this.handleSSEMessage(data);
144
- }
145
- else if (data.type === "websocket") {
146
- this.handleWebSocketMessage(data);
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;
147
162
  }
148
163
  }
149
164
  catch (error) {
@@ -154,7 +169,6 @@ class DainTunnelServer {
154
169
  ws.on("close", () => this.removeTunnel(ws));
155
170
  ws.on("error", (error) => console.error("[Tunnel] WS error:", error));
156
171
  }
157
- // Handle incoming WebSocket connections to be proxied through the tunnel
158
172
  handleProxiedWebSocketConnection(ws, req) {
159
173
  var _a;
160
174
  const url = (0, url_1.parse)(req.url || '', true);
@@ -170,67 +184,35 @@ class DainTunnelServer {
170
184
  ws.close(1008, "Tunnel not found");
171
185
  return;
172
186
  }
173
- // Create a WebSocket connection ID
174
187
  const wsConnectionId = fastId();
175
- // Store this connection with tunnelId for proper cleanup
176
188
  this.wsConnections.set(wsConnectionId, {
177
189
  clientSocket: ws,
178
190
  id: wsConnectionId,
179
191
  path: remainingPath,
180
192
  headers: req.headers,
181
- tunnelId: tunnelId
193
+ tunnelId
182
194
  });
183
- // Notify tunnel client about the new WebSocket connection
184
195
  this.safeSend(tunnel.ws, {
185
196
  type: "websocket_connection",
186
197
  id: wsConnectionId,
187
198
  path: remainingPath,
188
199
  headers: req.headers
189
200
  });
190
- // Handle messages from the client
191
- ws.on("message", (data) => {
201
+ const sendToTunnel = (event, data) => {
192
202
  const currentTunnel = this.tunnels.get(tunnelId);
193
203
  if (currentTunnel) {
194
- this.safeSend(currentTunnel.ws, {
195
- type: "websocket",
196
- id: wsConnectionId,
197
- event: "message",
198
- data: data.toString('base64')
199
- });
204
+ this.safeSend(currentTunnel.ws, { type: "websocket", id: wsConnectionId, event, data });
200
205
  }
201
- });
202
- // Handle client disconnection
203
- ws.on("close", () => {
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
- }
212
- this.wsConnections.delete(wsConnectionId);
213
- });
214
- // Handle errors
215
- ws.on("error", (error) => {
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
- }
225
- });
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));
226
210
  }
227
211
  handleChallengeRequest(ws) {
228
212
  const challenge = fastId();
229
- const challengeObj = { ws, challenge, timestamp: Date.now() };
230
- this.challenges.set(challenge, challengeObj);
213
+ this.challenges.set(challenge, { ws, challenge, timestamp: Date.now() });
231
214
  ws.send(JSON.stringify({ type: "challenge", challenge }));
232
- // Auto-cleanup expired challenges (30 second TTL)
233
- setTimeout(() => this.challenges.delete(challenge), 30000);
215
+ setTimeout(() => this.challenges.delete(challenge), TIMEOUTS.CHALLENGE_TTL);
234
216
  }
235
217
  handleStartMessage(ws, data) {
236
218
  const { challenge, signature, tunnelId, apiKey } = data;
@@ -241,7 +223,6 @@ class DainTunnelServer {
241
223
  }
242
224
  this.challenges.delete(challenge);
243
225
  try {
244
- // Parse API key to get the secret for HMAC validation
245
226
  if (!apiKey) {
246
227
  ws.close(1008, "API key required");
247
228
  return;
@@ -251,45 +232,28 @@ class DainTunnelServer {
251
232
  ws.close(1008, "Invalid API key format");
252
233
  return;
253
234
  }
254
- // Validate HMAC signature using constant-time comparison
255
- const expectedSignature = (0, crypto_1.createHmac)('sha256', parsed.secret)
256
- .update(challenge)
257
- .digest('hex');
258
- // Convert to buffers for timing-safe comparison
235
+ const expectedSignature = (0, crypto_1.createHmac)('sha256', parsed.secret).update(challenge).digest('hex');
259
236
  const expectedSigBuffer = Buffer.from(expectedSignature, 'hex');
260
237
  const receivedSigBuffer = Buffer.from(signature, 'hex');
261
- // Constant-time comparison to prevent timing attacks
262
238
  if (expectedSigBuffer.length !== receivedSigBuffer.length ||
263
239
  !(0, crypto_1.timingSafeEqual)(expectedSigBuffer, receivedSigBuffer)) {
264
240
  ws.close(1008, "Invalid signature");
265
241
  return;
266
242
  }
267
- // Verify that tunnelId matches orgId_agentId from the API key
268
243
  const expectedTunnelId = `${parsed.orgId}_${parsed.agentId}`;
269
244
  if (tunnelId !== expectedTunnelId) {
270
245
  ws.close(1008, `Tunnel ID does not match API key. Expected: ${expectedTunnelId}, Got: ${tunnelId}`);
271
246
  return;
272
247
  }
273
- // If tunnel already exists, remove old one
274
- if (this.tunnels.has(tunnelId)) {
275
- const oldTunnel = this.tunnels.get(tunnelId);
276
- if (oldTunnel && oldTunnel.ws !== ws) {
277
- oldTunnel.ws.close(1000, "Replaced by new connection");
278
- }
248
+ const existingTunnel = this.tunnels.get(tunnelId);
249
+ if (existingTunnel && existingTunnel.ws !== ws) {
250
+ existingTunnel.ws.close(1000, "Replaced by new connection");
279
251
  }
280
252
  this.tunnels.set(tunnelId, { id: tunnelId, ws });
281
- // Save tunnel ID on the WebSocket object for easy lookup on close
282
253
  ws.tunnelId = tunnelId;
283
- // Liveness detection: track pong responses
284
254
  let isAlive = true;
285
255
  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
256
+ ws.on("pong", () => { isAlive = true; missedPongs = 0; });
293
257
  const intervalId = setInterval(() => {
294
258
  if (!this.tunnels.has(tunnelId)) {
295
259
  clearInterval(intervalId);
@@ -298,38 +262,33 @@ class DainTunnelServer {
298
262
  const tunnel = this.tunnels.get(tunnelId);
299
263
  if (!tunnel || tunnel.ws !== ws || ws.readyState !== ws_1.default.OPEN) {
300
264
  clearInterval(intervalId);
301
- if (tunnel && tunnel.ws === ws) {
265
+ if ((tunnel === null || tunnel === void 0 ? void 0 : tunnel.ws) === ws)
302
266
  this.tunnels.delete(tunnelId);
303
- }
304
267
  return;
305
268
  }
306
- // Check if previous ping was acknowledged
307
269
  if (!isAlive) {
308
270
  missedPongs++;
309
- if (missedPongs >= MAX_MISSED_PONGS) {
271
+ if (missedPongs >= LIMITS.MAX_MISSED_PONGS) {
310
272
  console.log(`[Tunnel] ${tunnelId} failed liveness check (${missedPongs} missed pongs), terminating`);
311
273
  clearInterval(intervalId);
312
- ws.terminate(); // Force close without waiting for close handshake
274
+ ws.terminate();
313
275
  return;
314
276
  }
315
277
  }
316
- // Send new ping and mark as waiting for pong
317
278
  isAlive = false;
318
279
  try {
319
280
  ws.ping();
320
281
  }
321
- catch (error) {
282
+ catch (_a) {
322
283
  clearInterval(intervalId);
323
284
  ws.terminate();
324
285
  }
325
- }, 30000); // Ping every 30 seconds
326
- // Store the interval ID on the WebSocket object so we can clean it up
286
+ }, TIMEOUTS.PING_INTERVAL);
327
287
  ws.keepAliveInterval = intervalId;
328
288
  ws.on("close", () => clearInterval(intervalId));
329
- let tunnelUrl = `${this.hostname}`;
330
- if (process.env.SKIP_PORT !== "true") {
289
+ let tunnelUrl = this.hostname;
290
+ if (process.env.SKIP_PORT !== "true")
331
291
  tunnelUrl += `:${this.port}`;
332
- }
333
292
  tunnelUrl += `/${tunnelId}`;
334
293
  ws.send(JSON.stringify({ type: "tunnelUrl", url: tunnelUrl }));
335
294
  console.log(`[Tunnel] Created: ${tunnelUrl}`);
@@ -342,43 +301,27 @@ class DainTunnelServer {
342
301
  handleResponseMessage(data) {
343
302
  console.log(`[Response] Received for: ${data.requestId}, status: ${data.status}`);
344
303
  const pendingRequest = this.pendingRequests.get(data.requestId);
345
- if (pendingRequest) {
346
- const { res, startTime, tunnelId, timeoutId } = pendingRequest;
347
- const endTime = Date.now();
348
- console.log(`[Response] Completed in ${endTime - startTime}ms`);
349
- // Clear the timeout since we received a response
350
- if (timeoutId) {
351
- clearTimeout(timeoutId);
352
- }
353
- // Decrement request counter for backpressure tracking
354
- const currentCount = this.tunnelRequestCount.get(tunnelId) || 1;
355
- this.tunnelRequestCount.set(tunnelId, Math.max(0, currentCount - 1));
356
- const headers = { ...data.headers };
357
- delete headers["transfer-encoding"];
358
- delete headers["content-length"];
359
- const bodyBuffer = Buffer.from(data.body, "base64");
360
- res
361
- .status(data.status)
362
- .set(headers)
363
- .set("Content-Length", bodyBuffer.length.toString())
364
- .send(bodyBuffer);
365
- this.pendingRequests.delete(data.requestId);
366
- }
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);
367
317
  }
368
318
  handleSSEMessage(data) {
369
- console.log(`[SSE] Received message: id=${data.id}, event=${data.event}`);
370
319
  const connection = this.sseConnections.get(data.id);
371
- if (!connection) {
372
- console.log(`[SSE] Connection not found for id: ${data.id}`);
320
+ if (!connection)
373
321
  return;
374
- }
375
322
  const { res, tunnelId } = connection;
376
- // Skip 'connected' event - we already wrote headers
377
- if (data.event === 'connected') {
378
- console.log(`[SSE] Received 'connected' event, skipping`);
323
+ if (data.event === 'connected')
379
324
  return;
380
- }
381
- // Handle close event - end the response and cleanup
382
325
  if (data.event === 'close') {
383
326
  try {
384
327
  res.end();
@@ -387,7 +330,6 @@ class DainTunnelServer {
387
330
  this.cleanupSSEConnection(data.id, tunnelId);
388
331
  return;
389
332
  }
390
- // Handle error event
391
333
  if (data.event === 'error') {
392
334
  try {
393
335
  res.write(`event: error\ndata: ${data.data}\n\n`);
@@ -397,30 +339,23 @@ class DainTunnelServer {
397
339
  this.cleanupSSEConnection(data.id, tunnelId);
398
340
  return;
399
341
  }
400
- // Forward SSE event to client
401
342
  try {
402
- if (data.event) {
343
+ if (data.event)
403
344
  res.write(`event: ${data.event}\n`);
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) {
345
+ for (const line of data.data.split('\n')) {
408
346
  res.write(`data: ${line}\n`);
409
347
  }
410
348
  res.write('\n');
411
349
  }
412
350
  catch (_c) {
413
- // Client disconnected
414
351
  this.cleanupSSEConnection(data.id, tunnelId);
415
352
  }
416
353
  }
417
354
  cleanupSSEConnection(id, tunnelId) {
418
355
  const connection = this.sseConnections.get(id);
419
- if (connection === null || connection === void 0 ? void 0 : connection.keepAliveInterval) {
356
+ if (connection === null || connection === void 0 ? void 0 : connection.keepAliveInterval)
420
357
  clearInterval(connection.keepAliveInterval);
421
- }
422
- const currentCount = this.tunnelRequestCount.get(tunnelId) || 1;
423
- this.tunnelRequestCount.set(tunnelId, Math.max(0, currentCount - 1));
358
+ this.decrementRequestCount(tunnelId);
424
359
  this.sseConnections.delete(id);
425
360
  }
426
361
  handleWebSocketMessage(data) {
@@ -428,90 +363,79 @@ class DainTunnelServer {
428
363
  if (!connection)
429
364
  return;
430
365
  const { clientSocket } = connection;
431
- if (data.event === 'message' && data.data) {
432
- const messageData = Buffer.from(data.data, 'base64');
433
- if (clientSocket.readyState === ws_1.default.OPEN) {
434
- clientSocket.send(messageData);
435
- }
436
- }
437
- else if (data.event === 'close') {
438
- if (clientSocket.readyState === ws_1.default.OPEN) {
439
- clientSocket.close();
440
- }
441
- this.wsConnections.delete(data.id);
442
- }
443
- else if (data.event === 'error' && data.data) {
444
- if (clientSocket.readyState === ws_1.default.OPEN) {
445
- clientSocket.close(1011, data.data);
446
- }
447
- 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;
448
382
  }
449
383
  }
450
384
  async handleRequest(req, res) {
451
- var _a;
385
+ var _a, _b, _c;
452
386
  const tunnelId = req.params.tunnelId;
453
387
  console.log(`[Request] ${req.method} /${tunnelId}${req.url}, Accept: ${(_a = req.headers.accept) === null || _a === void 0 ? void 0 : _a.substring(0, 30)}`);
454
- // Check for upgraded connections (WebSockets) - these are handled by the WebSocket server
455
- if (req.headers.upgrade && req.headers.upgrade.toLowerCase() === 'websocket') {
456
- // This is handled by the WebSocket server now
388
+ if (((_b = req.headers.upgrade) === null || _b === void 0 ? void 0 : _b.toLowerCase()) === 'websocket')
457
389
  return;
458
- }
459
390
  let tunnel;
460
- let retries = 3;
391
+ let retries = LIMITS.TUNNEL_RETRY_COUNT;
461
392
  while (retries > 0 && !tunnel) {
462
393
  tunnel = this.tunnels.get(tunnelId);
463
394
  if (!tunnel) {
464
- await new Promise(resolve => setTimeout(resolve, 100));
395
+ await new Promise(resolve => setTimeout(resolve, TIMEOUTS.TUNNEL_RETRY_DELAY));
465
396
  retries--;
466
397
  }
467
398
  }
468
399
  if (!tunnel) {
469
400
  const available = Array.from(this.tunnels.keys());
470
401
  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({
402
+ this.decrementRequestCount(tunnelId);
403
+ res.status(502).json({
475
404
  error: "Bad Gateway",
476
405
  message: `Tunnel "${tunnelId}" not connected. The service may be offline or reconnecting.`,
477
406
  availableTunnels: available.length
478
407
  });
408
+ return;
479
409
  }
480
410
  console.log(`[Request] Tunnel found: ${tunnelId}, wsState: ${tunnel.ws.readyState}`);
481
- // High-frequency optimization: Check WebSocket buffer and apply backpressure
482
- if (tunnel.ws.bufferedAmount > 1024 * 1024) {
483
- return res.status(503).json({ error: "Service Unavailable", message: "Tunnel high load" });
411
+ if (tunnel.ws.bufferedAmount > LIMITS.BACKPRESSURE_THRESHOLD) {
412
+ res.status(503).json({ error: "Service Unavailable", message: "Tunnel high load" });
413
+ return;
484
414
  }
485
- // Check concurrent request limit per tunnel
486
415
  const currentCount = this.tunnelRequestCount.get(tunnelId) || 0;
487
- if (currentCount >= this.MAX_CONCURRENT_REQUESTS_PER_TUNNEL) {
488
- return res.status(503).json({ error: "Service Unavailable", message: "Too many concurrent requests" });
416
+ if (currentCount >= LIMITS.MAX_CONCURRENT_REQUESTS_PER_TUNNEL) {
417
+ res.status(503).json({ error: "Service Unavailable", message: "Too many concurrent requests" });
418
+ return;
489
419
  }
490
420
  this.tunnelRequestCount.set(tunnelId, currentCount + 1);
491
- // Check for SSE request
492
- if (req.headers.accept && req.headers.accept.includes('text/event-stream')) {
493
- return this.handleSSERequest(req, res, tunnelId, tunnel);
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;
494
424
  }
495
- // Handle regular HTTP request
496
425
  const requestId = fastId();
497
426
  const startTime = Date.now();
498
- // Set a timeout for the request (30 seconds)
499
- const REQUEST_TIMEOUT = 30000;
500
427
  const timeoutId = setTimeout(() => {
501
428
  const pendingRequest = this.pendingRequests.get(requestId);
502
429
  if (pendingRequest) {
503
- const currentCount = this.tunnelRequestCount.get(tunnelId) || 1;
504
- this.tunnelRequestCount.set(tunnelId, Math.max(0, currentCount - 1));
430
+ this.decrementRequestCount(tunnelId);
505
431
  this.pendingRequests.delete(requestId);
506
432
  if (!res.headersSent) {
507
433
  res.status(504).json({ error: "Gateway Timeout", message: "Request timed out" });
508
434
  }
509
435
  }
510
- }, REQUEST_TIMEOUT);
436
+ }, TIMEOUTS.REQUEST_TIMEOUT);
511
437
  this.pendingRequests.set(requestId, { res, startTime, tunnelId, timeoutId });
512
438
  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
439
  const hasBody = req.method !== "GET" && req.method !== "HEAD" &&
516
440
  req.body && Buffer.isBuffer(req.body) && req.body.length > 0;
517
441
  const sent = this.safeSend(tunnel.ws, {
@@ -523,53 +447,66 @@ class DainTunnelServer {
523
447
  body: hasBody ? req.body.toString("base64") : undefined,
524
448
  });
525
449
  console.log(`[Request] Sent to tunnel client: ${sent}`);
526
- // If send failed, clean up immediately
527
450
  if (!sent) {
528
451
  clearTimeout(timeoutId);
529
452
  this.pendingRequests.delete(requestId);
530
- const count = this.tunnelRequestCount.get(tunnelId) || 1;
531
- this.tunnelRequestCount.set(tunnelId, Math.max(0, count - 1));
453
+ this.decrementRequestCount(tunnelId);
532
454
  if (!res.headersSent) {
533
455
  res.status(502).json({ error: "Bad Gateway", message: "Tunnel connection lost" });
534
456
  }
535
457
  }
536
458
  }
537
459
  handleSSERequest(req, res, tunnelId, tunnel) {
538
- var _a;
460
+ var _a, _b;
539
461
  const sseId = fastId();
540
- console.log(`[SSE] New request: ${sseId}, tunnel: ${tunnelId}, path: ${req.url}`);
541
- // Setup SSE headers
542
- res.writeHead(200, {
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 = {
543
470
  'Content-Type': 'text/event-stream',
544
- 'Cache-Control': 'no-cache',
545
- 'Connection': 'keep-alive'
546
- });
547
- // Flush headers and send immediate keepalive to establish the stream
471
+ 'Cache-Control': 'no-cache, no-transform',
472
+ 'Connection': 'keep-alive',
473
+ 'X-Accel-Buffering': 'no',
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
+ };
548
491
  (_a = res.flushHeaders) === null || _a === void 0 ? void 0 : _a.call(res);
549
492
  res.write(':keepalive\n\n');
550
- // Send periodic keep-alive comments to prevent proxy timeouts
551
- const keepAliveIntervalMs = 5000;
493
+ flush();
552
494
  const keepAliveInterval = setInterval(() => {
553
495
  try {
554
496
  if (!res.writableEnded) {
555
497
  res.write(':keepalive\n\n');
498
+ flush();
556
499
  }
557
- else {
500
+ else
558
501
  clearInterval(keepAliveInterval);
559
- }
560
502
  }
561
503
  catch (_a) {
562
504
  clearInterval(keepAliveInterval);
563
505
  }
564
- }, keepAliveIntervalMs);
565
- // Store the SSE connection
566
- this.sseConnections.set(sseId, {
567
- req, res, id: sseId, tunnelId, keepAliveInterval
568
- });
569
- // FIX: Only include body if it has actual content (not empty buffer)
506
+ }, TIMEOUTS.SSE_KEEPALIVE);
507
+ this.sseConnections.set(sseId, { req, res, id: sseId, tunnelId, keepAliveInterval });
570
508
  const hasBody = req.method !== "GET" && req.body &&
571
509
  Buffer.isBuffer(req.body) && req.body.length > 0;
572
- // Notify the tunnel client about the new SSE connection
573
510
  const sent = this.safeSend(tunnel.ws, {
574
511
  type: "sse_connection",
575
512
  id: sseId,
@@ -578,92 +515,108 @@ class DainTunnelServer {
578
515
  headers: req.headers,
579
516
  body: hasBody ? req.body.toString("base64") : undefined
580
517
  });
581
- console.log(`[SSE] Sent sse_connection to tunnel client: ${sent}`);
582
518
  if (!sent) {
583
- console.log(`[SSE] Failed to send sse_connection, cleaning up`);
584
519
  this.cleanupSSEConnection(sseId, tunnelId);
585
520
  res.end();
586
521
  return;
587
522
  }
588
- // Handle client disconnect
589
- req.on('close', () => {
590
- console.log(`[SSE] Client disconnected: ${sseId}`);
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`);
591
529
  this.safeSend(tunnel.ws, { type: "sse_close", id: sseId });
592
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();
593
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);
594
540
  }
595
541
  removeTunnel(ws) {
596
542
  try {
597
543
  if (ws.keepAliveInterval)
598
544
  clearInterval(ws.keepAliveInterval);
599
- const tunnelId = ws.tunnelId;
600
- let removedTunnelId = tunnelId;
601
- if (tunnelId && this.tunnels.has(tunnelId)) {
602
- const tunnel = this.tunnels.get(tunnelId);
603
- if (tunnel && tunnel.ws === ws)
604
- this.tunnels.delete(tunnelId);
605
- }
606
- else {
607
- removedTunnelId = undefined;
608
- for (const [id, tunnel] of this.tunnels.entries()) {
609
- if (tunnel.ws === ws) {
610
- this.tunnels.delete(id);
611
- removedTunnelId = id;
612
- break;
613
- }
614
- }
615
- }
545
+ const removedTunnelId = this.findAndRemoveTunnel(ws);
616
546
  if (removedTunnelId) {
617
547
  this.tunnelRequestCount.delete(removedTunnelId);
618
- // Close all pending HTTP requests associated with this tunnel
619
- for (const [requestId, pendingRequest] of this.pendingRequests.entries()) {
620
- if (pendingRequest.tunnelId === removedTunnelId) {
621
- try {
622
- if (pendingRequest.timeoutId)
623
- clearTimeout(pendingRequest.timeoutId);
624
- if (!pendingRequest.res.headersSent) {
625
- pendingRequest.res.status(502).json({ error: "Bad Gateway", message: "Tunnel closed" });
626
- }
627
- }
628
- catch (error) { /* ignore */ }
629
- this.pendingRequests.delete(requestId);
630
- }
631
- }
632
- // Close all SSE connections associated with this tunnel
633
- for (const [sseId, sseConnection] of this.sseConnections.entries()) {
634
- if (sseConnection.tunnelId === removedTunnelId) {
635
- // Clear keepalive interval to prevent memory leak
636
- if (sseConnection.keepAliveInterval) {
637
- clearInterval(sseConnection.keepAliveInterval);
638
- }
639
- try {
640
- sseConnection.res.end();
641
- }
642
- catch (error) { /* ignore */ }
643
- this.sseConnections.delete(sseId);
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" });
644
583
  }
645
584
  }
646
- // Close all WebSocket connections associated with this tunnel
647
- for (const [wsId, wsConnection] of this.wsConnections.entries()) {
648
- if (wsConnection.tunnelId === removedTunnelId) {
649
- try {
650
- wsConnection.clientSocket.close(1001, "Tunnel closed");
651
- }
652
- catch (error) { /* ignore */ }
653
- this.wsConnections.delete(wsId);
654
- }
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();
655
597
  }
598
+ catch (_a) { }
599
+ this.sseConnections.delete(sseId);
656
600
  }
657
- // Remove any pending challenges for this WebSocket
658
- for (const [challenge, challengeObj] of this.challenges.entries()) {
659
- if (challengeObj.ws === ws) {
660
- this.challenges.delete(challenge);
661
- break;
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");
662
608
  }
609
+ catch (_a) { }
610
+ this.wsConnections.delete(wsId);
663
611
  }
664
612
  }
665
- catch (error) {
666
- console.error("[Tunnel] Remove error:", error);
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
+ }
667
620
  }
668
621
  }
669
622
  async start() {
@@ -677,37 +630,31 @@ class DainTunnelServer {
677
630
  async stop() {
678
631
  return new Promise((resolve) => {
679
632
  try {
680
- // Close all SSE connections
681
- for (const [sseId, sseConnection] of this.sseConnections.entries()) {
633
+ for (const [sseId, conn] of this.sseConnections.entries()) {
682
634
  try {
683
- sseConnection.res.end();
635
+ conn.res.end();
684
636
  }
685
637
  catch (error) {
686
- console.error(`Error closing SSE connection ${sseId}:`, error);
638
+ console.error(`Error closing SSE ${sseId}:`, error);
687
639
  }
688
640
  this.sseConnections.delete(sseId);
689
641
  }
690
- // Close all WebSocket connections
691
- for (const [wsId, wsConnection] of this.wsConnections.entries()) {
642
+ for (const [wsId, conn] of this.wsConnections.entries()) {
692
643
  try {
693
- wsConnection.clientSocket.close(1001, "Server shutting down");
644
+ conn.clientSocket.close(1001, "Server shutting down");
694
645
  }
695
646
  catch (error) {
696
- console.error(`Error closing WebSocket connection ${wsId}:`, error);
647
+ console.error(`Error closing WS ${wsId}:`, error);
697
648
  }
698
649
  this.wsConnections.delete(wsId);
699
650
  }
700
- // Close the WebSocket server
701
651
  this.wss.close(() => {
702
- // Close the HTTP server
703
- this.server.close(() => {
704
- resolve();
705
- });
652
+ this.server.close(() => resolve());
706
653
  });
707
654
  }
708
655
  catch (error) {
709
656
  console.error('Error during server shutdown:', error);
710
- resolve(); // Resolve anyway to prevent hanging
657
+ resolve();
711
658
  }
712
659
  });
713
660
  }