@dainprotocol/tunnel 1.1.23 → 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.
@@ -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;
@@ -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: 50, // Allow up to 50 concurrent connections to local service
37
- maxFreeSockets: 10, // Keep up to 10 idle connections
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
- reject(err);
131
+ safeReject(err);
74
132
  }
75
133
  });
76
134
  this.ws.on("message", (data) => {
77
135
  try {
78
- this.handleMessage(JSON.parse(data), resolve);
136
+ this.handleMessage(JSON.parse(data), safeResolve);
79
137
  }
80
138
  catch (err) {
81
- reject(err);
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
- reject(new Error("Connection closed before tunnel established"));
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
- setTimeout(() => {
112
- if (!this.ws || this.ws.readyState !== ws_1.default.OPEN) {
113
- reject(new Error("Connection timeout"));
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
- reject(err);
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
- this.ws.send(JSON.stringify({ type: "challenge_request" }));
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
- const data = JSON.parse(message);
131
- if (data.type === "challenge") {
132
- if (this.ws) {
133
- this.ws.removeListener("message", challengeHandler);
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
- resolve(data.challenge);
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 (this.ws) {
142
- this.ws.removeListener("message", challengeHandler);
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
  }
@@ -268,13 +366,19 @@ class DainTunnel extends events_1.EventEmitter {
268
366
  }
269
367
  // Stream SSE events to tunnel server
270
368
  let buffer = '';
271
- res.on('data', (chunk) => {
369
+ // Helper to process SSE events from buffer
370
+ const processBuffer = () => {
272
371
  var _a;
273
- buffer += chunk.toString();
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,13 +388,24 @@ class DainTunnel extends events_1.EventEmitter {
284
388
  }
285
389
  if (data.endsWith('\n'))
286
390
  data = data.slice(0, -1);
287
- if (((_a = this.ws) === null || _a === void 0 ? void 0 : _a.readyState) === ws_1.default.OPEN) {
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
  }
396
+ };
397
+ res.on('data', (chunk) => {
398
+ buffer += chunk.toString();
399
+ processBuffer();
291
400
  });
292
401
  res.on('end', () => {
293
402
  var _a;
403
+ // Process any remaining data in buffer (might be missing final \n\n)
404
+ if (buffer.trim()) {
405
+ // Add missing newlines to ensure processing
406
+ buffer += '\n\n';
407
+ processBuffer();
408
+ }
294
409
  if (((_a = this.ws) === null || _a === void 0 ? void 0 : _a.readyState) === ws_1.default.OPEN) {
295
410
  this.ws.send(JSON.stringify({ type: 'sse', id: message.id, event: 'close', data: '' }));
296
411
  }
@@ -323,6 +438,28 @@ class DainTunnel extends events_1.EventEmitter {
323
438
  }
324
439
  forwardRequest(request) {
325
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
+ };
326
463
  const options = {
327
464
  hostname: 'localhost',
328
465
  port: this.port,
@@ -330,6 +467,7 @@ class DainTunnel extends events_1.EventEmitter {
330
467
  method: request.method,
331
468
  headers: request.headers,
332
469
  agent: this.httpAgent, // Use connection pooling
470
+ timeout: this.REQUEST_TIMEOUT, // Socket timeout
333
471
  };
334
472
  const req = http_1.default.request(options, (res) => {
335
473
  let body = Buffer.from([]);
@@ -340,7 +478,7 @@ class DainTunnel extends events_1.EventEmitter {
340
478
  const headers = { ...res.headers };
341
479
  delete headers['transfer-encoding'];
342
480
  delete headers['content-length'];
343
- resolve({
481
+ safeResolve({
344
482
  type: 'response',
345
483
  requestId: request.id,
346
484
  status: res.statusCode,
@@ -348,8 +486,21 @@ class DainTunnel extends events_1.EventEmitter {
348
486
  body: body.toString('base64'),
349
487
  });
350
488
  });
489
+ res.on('error', safeReject);
351
490
  });
352
- req.on('error', reject);
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);
353
504
  if (request.body && request.method !== 'GET') {
354
505
  req.write(Buffer.from(request.body, 'base64'));
355
506
  }
@@ -378,6 +529,8 @@ class DainTunnel extends events_1.EventEmitter {
378
529
  async stop() {
379
530
  return new Promise(async (resolve) => {
380
531
  try {
532
+ // Stop heartbeat first
533
+ this.stopHeartbeat();
381
534
  // Close all WebSocket clients
382
535
  for (const [id, client] of this.webSocketClients.entries()) {
383
536
  try {
@@ -412,6 +565,8 @@ class DainTunnel extends events_1.EventEmitter {
412
565
  }
413
566
  this.ws = null;
414
567
  }
568
+ // Reset tunnel URL so reconnect doesn't happen
569
+ this.tunnelUrl = null;
415
570
  // Destroy the HTTP agent to close pooled connections
416
571
  if (this.httpAgent) {
417
572
  this.httpAgent.destroy();
@@ -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;
@@ -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
- * Force immediate flush of SSE data to prevent buffering
21
- * SSE requires immediate delivery; Node.js res.write() doesn't guarantee this
22
- *
23
- * @param res Express response object
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);
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.send(JSON.stringify({
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
- tunnel.ws.send(JSON.stringify({
181
- type: "websocket",
182
- id: wsConnectionId,
183
- event: "message",
184
- data: data.toString('base64')
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
- tunnel.ws.send(JSON.stringify({
190
- type: "websocket",
191
- id: wsConnectionId,
192
- event: "close"
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
- tunnel.ws.send(JSON.stringify({
199
- type: "websocket",
200
- id: wsConnectionId,
201
- event: "error",
202
- data: error.message
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
- // Add a periodic ping to keep the connection alive (silent - no logging)
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
- const tunnel = this.tunnels.get(tunnelId);
266
- if (tunnel && tunnel.ws === ws && ws.readyState === ws_1.default.OPEN) {
267
- try {
268
- ws.ping();
269
- }
270
- catch (error) {
271
- clearInterval(intervalId);
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
- else {
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
- if (tunnel && tunnel.ws === ws) {
277
- this.tunnels.delete(tunnelId);
278
- }
312
+ ws.terminate(); // Force close without waiting for close handshake
313
+ return;
279
314
  }
280
315
  }
281
- else {
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 (reduced frequency)
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
- // Handle close event
380
+ }
381
+ // Handle close event - end the response and cleanup
335
382
  if (data.event === 'close') {
336
- const tunnel = this.tunnels.get(tunnelId);
337
- if (tunnel)
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 - send as SSE error event (headers already written)
390
+ // Handle error event
349
391
  if (data.event === 'error') {
350
- if (!conn.clientDisconnected && res.writable) {
351
- try {
352
- res.write(`event: error\ndata: ${data.data}\n\n`);
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
- // Skip if client disconnected
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
- res.write(`data: ${data.data.replace(/\n/g, '\ndata: ')}\n\n`);
371
- flushSSE(res);
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
- conn.clientDisconnected = true;
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
- return res.status(404).send("Tunnel not found");
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.send(JSON.stringify({
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: req.method !== "GET" && req.method !== "HEAD" && req.body
463
- ? req.body.toString("base64")
464
- : undefined,
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
- // Optimize TCP for streaming
470
- const socket = req.socket || req.connection;
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
- 'X-Accel-Buffering': 'no',
545
+ 'Connection': 'keep-alive'
478
546
  });
479
- res.flushHeaders();
480
- res.write(': connected\n\n');
481
- flushSSE(res);
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
- // Forward to tunnel client
487
- tunnel.ws.send(JSON.stringify({
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: req.method !== "GET" && req.body ? req.body.toString("base64") : undefined
494
- }));
495
- // Track response close (not request close) - for POST SSE, req closes immediately
496
- res.on('close', () => {
497
- const connection = this.sseConnections.get(sseId);
498
- if (connection) {
499
- connection.clientDisconnected = true;
500
- setTimeout(() => {
501
- if (this.sseConnections.has(sseId)) {
502
- this.cleanupSSEConnection(sseId, tunnelId);
503
- const t = this.tunnels.get(tunnelId);
504
- if ((t === null || t === void 0 ? void 0 : t.ws.readyState) === ws_1.default.OPEN) {
505
- t.ws.send(JSON.stringify({ type: "sse_close", id: sseId }));
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.path.startsWith(`/${removedTunnelId}/`)) {
648
+ if (wsConnection.tunnelId === removedTunnelId) {
562
649
  try {
563
650
  wsConnection.clientSocket.close(1001, "Tunnel closed");
564
651
  }
@@ -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.23",
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.59",
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",