@dainprotocol/tunnel 1.1.14 → 1.1.17

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.
@@ -12,6 +12,7 @@ declare class DainTunnel extends EventEmitter {
12
12
  private secret;
13
13
  private webSocketClients;
14
14
  private sseClients;
15
+ private httpAgent;
15
16
  constructor(serverUrl: string, apiKey: string);
16
17
  /**
17
18
  * Sign a challenge using HMAC-SHA256
@@ -29,6 +29,13 @@ class DainTunnel extends events_1.EventEmitter {
29
29
  this.apiKey = apiKey;
30
30
  this.tunnelId = `${parsed.orgId}_${parsed.agentId}`; // orgId_agentId to prevent collisions
31
31
  this.secret = parsed.secret; // secret for HMAC signatures
32
+ // High-frequency optimization: Create reusable HTTP agent with connection pooling
33
+ this.httpAgent = new http_1.default.Agent({
34
+ keepAlive: true,
35
+ keepAliveMsecs: 30000, // Keep connections alive for 30s
36
+ maxSockets: 50, // Allow up to 50 concurrent connections to local service
37
+ maxFreeSockets: 10, // Keep up to 10 idle connections
38
+ });
32
39
  }
33
40
  /**
34
41
  * Sign a challenge using HMAC-SHA256
@@ -46,10 +53,8 @@ class DainTunnel extends events_1.EventEmitter {
46
53
  async connect() {
47
54
  return new Promise((resolve, reject) => {
48
55
  try {
49
- console.log(`Connecting to WebSocket server: ${this.serverUrl}`);
50
56
  this.ws = new ws_1.default(this.serverUrl);
51
57
  this.ws.on("open", async () => {
52
- console.log('WebSocket connection opened');
53
58
  this.reconnectAttempts = 0;
54
59
  try {
55
60
  const challenge = await this.requestChallenge();
@@ -65,46 +70,51 @@ class DainTunnel extends events_1.EventEmitter {
65
70
  this.emit("connected");
66
71
  }
67
72
  catch (err) {
68
- console.error('Error during challenge-response:', err);
69
73
  reject(err);
70
74
  }
71
75
  });
72
76
  this.ws.on("message", (data) => {
73
77
  try {
74
- const message = JSON.parse(data);
75
- this.handleMessage(message, resolve);
78
+ this.handleMessage(JSON.parse(data), resolve);
76
79
  }
77
80
  catch (err) {
78
- console.error('Error handling message:', err);
79
81
  reject(err);
80
82
  }
81
83
  });
82
84
  this.ws.on("close", () => {
83
- console.log('WebSocket connection closed');
85
+ // Clean up all active SSE connections
86
+ for (const [, client] of this.sseClients) {
87
+ try {
88
+ client.destroy();
89
+ }
90
+ catch (e) { /* ignore */ }
91
+ }
92
+ this.sseClients.clear();
93
+ // Clean up all WebSocket clients
94
+ for (const [, client] of this.webSocketClients) {
95
+ try {
96
+ if (client.readyState === ws_1.default.OPEN)
97
+ client.close(1001);
98
+ }
99
+ catch (e) { /* ignore */ }
100
+ }
101
+ this.webSocketClients.clear();
84
102
  if (this.tunnelUrl) {
85
103
  this.emit("disconnected");
86
104
  this.attemptReconnect();
87
105
  }
88
106
  else {
89
- // If we haven't received a tunnelUrl yet, just reject
90
- reject(new Error("Connection closed before tunnel was established"));
107
+ reject(new Error("Connection closed before tunnel established"));
91
108
  }
92
109
  });
93
- this.ws.on("error", (error) => {
94
- // Prevent unhandled error events
95
- // Don't reject here, the close handler will be called after an error
96
- this.emit("error", error);
97
- });
98
- // Add a timeout to reject the promise if connection takes too long
110
+ this.ws.on("error", (error) => this.emit("error", error));
99
111
  setTimeout(() => {
100
112
  if (!this.ws || this.ws.readyState !== ws_1.default.OPEN) {
101
- const timeoutError = new Error("Connection timeout");
102
- reject(timeoutError);
113
+ reject(new Error("Connection timeout"));
103
114
  }
104
115
  }, 10000);
105
116
  }
106
117
  catch (err) {
107
- console.error('Error creating WebSocket:', err);
108
118
  reject(err);
109
119
  }
110
120
  });
@@ -170,65 +180,37 @@ class DainTunnel extends events_1.EventEmitter {
170
180
  }
171
181
  }
172
182
  handleWebSocketConnection(message) {
183
+ var _a;
173
184
  try {
174
- console.log(`Creating local WebSocket connection to: ws://localhost:${this.port}${message.path}`);
175
- // Create a WebSocket connection to the local server
176
185
  const client = new ws_1.default(`ws://localhost:${this.port}${message.path}`, {
177
186
  headers: message.headers
178
187
  });
179
- // Store the client
180
188
  this.webSocketClients.set(message.id, client);
181
- // Handle connection open
182
- client.on('open', () => {
183
- console.log(`Local WebSocket connection opened: ${message.id}`);
184
- });
185
- // Handle messages from the local server
186
189
  client.on('message', (data) => {
187
- if (this.ws && this.ws.readyState === ws_1.default.OPEN) {
188
- this.ws.send(JSON.stringify({
189
- type: 'websocket',
190
- id: message.id,
191
- event: 'message',
192
- data: data.toString('base64')
193
- }));
190
+ var _a;
191
+ if (((_a = this.ws) === null || _a === void 0 ? void 0 : _a.readyState) === ws_1.default.OPEN) {
192
+ this.ws.send(JSON.stringify({ type: 'websocket', id: message.id, event: 'message', data: data.toString('base64') }));
194
193
  }
195
194
  });
196
- // Handle close
197
195
  client.on('close', () => {
198
- console.log(`Local WebSocket connection closed: ${message.id}`);
199
- if (this.ws && this.ws.readyState === ws_1.default.OPEN) {
200
- this.ws.send(JSON.stringify({
201
- type: 'websocket',
202
- id: message.id,
203
- event: 'close'
204
- }));
196
+ var _a;
197
+ if (((_a = this.ws) === null || _a === void 0 ? void 0 : _a.readyState) === ws_1.default.OPEN) {
198
+ this.ws.send(JSON.stringify({ type: 'websocket', id: message.id, event: 'close' }));
205
199
  }
206
200
  this.webSocketClients.delete(message.id);
207
201
  });
208
- // Handle errors
209
202
  client.on('error', (error) => {
210
- console.error(`Local WebSocket connection error: ${message.id}`, error.message);
211
- if (this.ws && this.ws.readyState === ws_1.default.OPEN) {
212
- this.ws.send(JSON.stringify({
213
- type: 'websocket',
214
- id: message.id,
215
- event: 'error',
216
- data: error.message
217
- }));
203
+ var _a;
204
+ if (((_a = this.ws) === null || _a === void 0 ? void 0 : _a.readyState) === ws_1.default.OPEN) {
205
+ this.ws.send(JSON.stringify({ type: 'websocket', id: message.id, event: 'error', data: error.message }));
218
206
  }
219
207
  this.webSocketClients.delete(message.id);
220
208
  });
221
209
  this.emit("websocket_connection", { id: message.id, path: message.path });
222
210
  }
223
211
  catch (error) {
224
- console.error('Error establishing WebSocket connection:', error);
225
- if (this.ws && this.ws.readyState === ws_1.default.OPEN) {
226
- this.ws.send(JSON.stringify({
227
- type: 'websocket',
228
- id: message.id,
229
- event: 'error',
230
- data: error.message
231
- }));
212
+ if (((_a = this.ws) === null || _a === void 0 ? void 0 : _a.readyState) === ws_1.default.OPEN) {
213
+ this.ws.send(JSON.stringify({ type: 'websocket', id: message.id, event: 'error', data: error.message }));
232
214
  }
233
215
  }
234
216
  }
@@ -250,134 +232,80 @@ class DainTunnel extends events_1.EventEmitter {
250
232
  }
251
233
  }
252
234
  handleSSEConnection(message) {
235
+ var _a;
253
236
  try {
254
- console.log(`[SSE E2E] Client: Establishing SSE connection ${message.id} to localhost:${this.port}${message.path}`);
255
- // Create an EventSource-like stream to the local server
256
- // Since Node.js doesn't have a built-in EventSource, we'll use HTTP
257
237
  const options = {
258
238
  hostname: 'localhost',
259
239
  port: this.port,
260
240
  path: message.path,
261
- method: message.method || 'GET', // Use provided method (supports POST tool calls)
241
+ method: message.method || 'GET',
262
242
  headers: message.headers,
243
+ agent: this.httpAgent,
263
244
  };
264
245
  const req = http_1.default.request(options, (res) => {
246
+ var _a;
265
247
  if (res.statusCode !== 200) {
266
- console.log(`[SSE E2E] Client: Service returned status ${res.statusCode} for ${message.id}`);
267
- if (this.ws && this.ws.readyState === ws_1.default.OPEN) {
268
- this.ws.send(JSON.stringify({
269
- type: 'sse',
270
- id: message.id,
271
- event: 'error',
272
- data: `Server responded with status code ${res.statusCode}`
273
- }));
248
+ res.resume();
249
+ if (((_a = this.ws) === null || _a === void 0 ? void 0 : _a.readyState) === ws_1.default.OPEN) {
250
+ this.ws.send(JSON.stringify({ type: 'sse', id: message.id, event: 'error', data: `Status ${res.statusCode}` }));
274
251
  }
275
252
  return;
276
253
  }
277
- // Optimize socket for low-latency streaming
278
254
  const socket = res.socket || res.connection;
279
- if (socket && socket.setNoDelay) {
280
- socket.setNoDelay(true); // Disable Nagle's algorithm
281
- }
282
- console.log(`[SSE E2E] Client: Service accepted SSE connection ${message.id}, streaming started`);
283
- // Process SSE stream
255
+ if (socket === null || socket === void 0 ? void 0 : socket.setNoDelay)
256
+ socket.setNoDelay(true);
284
257
  let buffer = '';
285
- let eventCount = 0;
286
258
  res.on('data', (chunk) => {
287
- const chunkStr = chunk.toString();
288
- buffer += chunkStr;
289
- // Process complete SSE messages
259
+ var _a;
260
+ buffer += chunk.toString();
290
261
  while (buffer.includes('\n\n')) {
291
- const messageEndIndex = buffer.indexOf('\n\n');
292
- const messageData = buffer.substring(0, messageEndIndex);
293
- buffer = buffer.substring(messageEndIndex + 2);
294
- // Parse the SSE message
295
- const lines = messageData.split('\n');
296
- let event = 'message';
297
- let data = '';
298
- for (const line of lines) {
299
- if (line.startsWith('event:')) {
262
+ const idx = buffer.indexOf('\n\n');
263
+ const msgData = buffer.substring(0, idx);
264
+ buffer = buffer.substring(idx + 2);
265
+ let event = 'message', data = '';
266
+ for (const line of msgData.split('\n')) {
267
+ if (line.startsWith('event:'))
300
268
  event = line.substring(6).trim();
301
- }
302
- else if (line.startsWith('data:')) {
269
+ else if (line.startsWith('data:'))
303
270
  data += line.substring(5).trim() + '\n';
304
- }
305
- }
306
- // Remove trailing newline
307
- if (data.endsWith('\n')) {
308
- data = data.substring(0, data.length - 1);
309
271
  }
310
- eventCount++;
311
- // Only log key events (not every progress update)
312
- if (event === 'error' || event === 'close' || event === 'end' || eventCount === 1) {
313
- console.log(`[SSE E2E] Client→Server: Forwarding event="${event}" for ${message.id} (total events: ${eventCount})`);
314
- }
315
- // Forward to server
316
- if (this.ws && this.ws.readyState === ws_1.default.OPEN) {
317
- this.ws.send(JSON.stringify({
318
- type: 'sse',
319
- id: message.id,
320
- event,
321
- data
322
- }));
272
+ if (data.endsWith('\n'))
273
+ data = data.slice(0, -1);
274
+ if (((_a = this.ws) === null || _a === void 0 ? void 0 : _a.readyState) === ws_1.default.OPEN) {
275
+ this.ws.send(JSON.stringify({ type: 'sse', id: message.id, event, data }));
323
276
  }
324
277
  }
325
278
  });
326
279
  res.on('end', () => {
327
- console.log(`[SSE E2E] Client: Service stream ended for ${message.id} (total events sent: ${eventCount})`);
328
- if (this.ws && this.ws.readyState === ws_1.default.OPEN) {
329
- this.ws.send(JSON.stringify({
330
- type: 'sse',
331
- id: message.id,
332
- event: 'close',
333
- data: 'Connection closed'
334
- }));
280
+ var _a;
281
+ if (((_a = this.ws) === null || _a === void 0 ? void 0 : _a.readyState) === ws_1.default.OPEN) {
282
+ this.ws.send(JSON.stringify({ type: 'sse', id: message.id, event: 'close', data: 'Connection closed' }));
335
283
  }
336
284
  });
337
285
  });
338
286
  req.on('error', (error) => {
339
- console.log(`[SSE E2E] Client: ❌ Request error for ${message.id}: ${error.message}`);
340
- if (this.ws && this.ws.readyState === ws_1.default.OPEN) {
341
- this.ws.send(JSON.stringify({
342
- type: 'sse',
343
- id: message.id,
344
- event: 'error',
345
- data: error.message
346
- }));
287
+ var _a;
288
+ if (((_a = this.ws) === null || _a === void 0 ? void 0 : _a.readyState) === ws_1.default.OPEN) {
289
+ this.ws.send(JSON.stringify({ type: 'sse', id: message.id, event: 'error', data: error.message }));
347
290
  }
348
291
  });
349
- // Write request body for POST requests
350
- if (message.body && message.method !== 'GET') {
292
+ if (message.body && message.method !== 'GET')
351
293
  req.write(Buffer.from(message.body, 'base64'));
352
- }
353
294
  req.end();
354
- // Store a reference to abort the connection later if needed
355
295
  this.sseClients.set(message.id, req);
356
296
  this.emit("sse_connection", { id: message.id, path: message.path });
357
297
  }
358
298
  catch (error) {
359
- console.error(`[SSE E2E] Client: Error establishing SSE connection ${message.id}:`, error);
360
- if (this.ws && this.ws.readyState === ws_1.default.OPEN) {
361
- this.ws.send(JSON.stringify({
362
- type: 'sse',
363
- id: message.id,
364
- event: 'error',
365
- data: error.message
366
- }));
299
+ if (((_a = this.ws) === null || _a === void 0 ? void 0 : _a.readyState) === ws_1.default.OPEN) {
300
+ this.ws.send(JSON.stringify({ type: 'sse', id: message.id, event: 'error', data: error.message }));
367
301
  }
368
302
  }
369
303
  }
370
304
  handleSSEClose(message) {
371
- console.log(`[SSE E2E] Client: Received close command from server for ${message.id}`);
372
305
  const client = this.sseClients.get(message.id);
373
306
  if (client) {
374
- // Abort the request if it's still active
375
307
  client.destroy();
376
308
  this.sseClients.delete(message.id);
377
- console.log(`[SSE E2E] Client: Destroyed local service connection for ${message.id}`);
378
- }
379
- else {
380
- console.log(`[SSE E2E] Client: No active connection found for ${message.id} (already cleaned up)`);
381
309
  }
382
310
  }
383
311
  forwardRequest(request) {
@@ -388,6 +316,7 @@ class DainTunnel extends events_1.EventEmitter {
388
316
  path: request.path,
389
317
  method: request.method,
390
318
  headers: request.headers,
319
+ agent: this.httpAgent, // Use connection pooling
391
320
  };
392
321
  const req = http_1.default.request(options, (res) => {
393
322
  let body = Buffer.from([]);
@@ -422,25 +351,14 @@ class DainTunnel extends events_1.EventEmitter {
422
351
  attemptReconnect() {
423
352
  if (this.reconnectAttempts < this.maxReconnectAttempts) {
424
353
  this.reconnectAttempts++;
425
- console.log(`Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
426
354
  setTimeout(async () => {
427
355
  try {
428
356
  await this.connect();
429
- // If the connect succeeds but we don't have a tunnelUrl yet, we're still initializing
430
- if (!this.tunnelUrl) {
431
- console.log('Reconnected but tunnelUrl not set. Waiting for tunnel URL...');
432
- }
433
- else {
434
- console.log(`Reconnected successfully. Tunnel URL: ${this.tunnelUrl}`);
435
- }
436
- }
437
- catch (error) {
438
- console.error(`Reconnection attempt ${this.reconnectAttempts} failed:`, error);
439
357
  }
358
+ catch (error) { /* ignore */ }
440
359
  }, this.reconnectDelay);
441
360
  }
442
361
  else {
443
- console.log('Maximum reconnection attempts reached');
444
362
  this.emit("max_reconnect_attempts");
445
363
  }
446
364
  }
@@ -481,6 +399,10 @@ class DainTunnel extends events_1.EventEmitter {
481
399
  }
482
400
  this.ws = null;
483
401
  }
402
+ // Destroy the HTTP agent to close pooled connections
403
+ if (this.httpAgent) {
404
+ this.httpAgent.destroy();
405
+ }
484
406
  // Wait for all connections to close properly
485
407
  await new Promise(resolve => setTimeout(resolve, 500));
486
408
  resolve();
@@ -9,6 +9,8 @@ declare class DainTunnelServer {
9
9
  private challenges;
10
10
  private sseConnections;
11
11
  private wsConnections;
12
+ private tunnelRequestCount;
13
+ private readonly MAX_CONCURRENT_REQUESTS_PER_TUNNEL;
12
14
  constructor(hostname: string, port: number);
13
15
  private setupExpressRoutes;
14
16
  private setupWebSocketServer;
@@ -6,12 +6,16 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const express_1 = __importDefault(require("express"));
7
7
  const http_1 = __importDefault(require("http"));
8
8
  const ws_1 = __importDefault(require("ws"));
9
- const uuid_1 = require("uuid");
10
9
  const body_parser_1 = __importDefault(require("body-parser"));
11
- const cors_1 = __importDefault(require("cors"));
12
10
  const auth_1 = require("@dainprotocol/service-sdk/service/auth");
13
11
  const crypto_1 = require("crypto");
14
12
  const url_1 = require("url");
13
+ // High-frequency optimization: Fast ID generation using crypto.randomBytes
14
+ // ~10x faster than uuid v4 for high-frequency scenarios
15
+ let idCounter = 0;
16
+ function fastId() {
17
+ return `${Date.now().toString(36)}-${(idCounter++).toString(36)}-${(0, crypto_1.randomBytes)(4).toString('hex')}`;
18
+ }
15
19
  /**
16
20
  * Force immediate flush of SSE data to prevent buffering
17
21
  * SSE requires immediate delivery; Node.js res.write() doesn't guarantee this
@@ -48,30 +52,40 @@ class DainTunnelServer {
48
52
  this.challenges = new Map();
49
53
  this.sseConnections = new Map();
50
54
  this.wsConnections = new Map();
55
+ // High-frequency optimization: Track active request count per tunnel for backpressure
56
+ this.tunnelRequestCount = new Map();
57
+ this.MAX_CONCURRENT_REQUESTS_PER_TUNNEL = 100;
51
58
  this.app = (0, express_1.default)();
52
59
  this.server = http_1.default.createServer(this.app);
60
+ // High-frequency optimization: Configure server for better throughput
61
+ this.server.keepAliveTimeout = 65000; // Keep connections alive longer (default 5s)
62
+ this.server.headersTimeout = 66000; // Must be higher than keepAliveTimeout
63
+ this.server.maxHeadersCount = 100; // Limit header count for security
53
64
  this.wss = new ws_1.default.Server({
54
65
  server: this.server,
55
- path: undefined // Allow connections on any path
66
+ path: undefined, // Allow connections on any path
67
+ // High-frequency optimization: Increase backlog and disable per-message deflate
68
+ backlog: 100,
69
+ perMessageDeflate: false, // Disable compression for lower latency
70
+ maxPayload: 100 * 1024 * 1024, // 100MB max payload
56
71
  });
57
- // Use cors middleware
58
- this.app.use((0, cors_1.default)({
59
- origin: '*',
60
- methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
61
- credentials: true
62
- }));
63
- // Update CORS middleware
72
+ // Single optimized CORS middleware (avoid duplicate cors() + manual headers)
64
73
  this.app.use((req, res, next) => {
65
74
  res.header("Access-Control-Allow-Origin", "*");
66
75
  res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS");
67
- 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, Content-Type, Authorization, Accept, Origin, X-Requested-With');
68
- if (req.method === "OPTIONS") {
69
- return res.sendStatus(200);
70
- }
76
+ res.header("Access-Control-Allow-Headers", "X-DAIN-SIGNATURE, X-DAIN-SMART-ACCOUNT-PDA, X-DAIN-AGENT-ID, X-DAIN-ORG-ID, X-DAIN-ADDRESS, X-DAIN-TIMESTAMP, X-DAIN-WEBHOOK-URL, Content-Type, Authorization, Accept, Origin, X-Requested-With");
77
+ res.header("Access-Control-Allow-Credentials", "true");
78
+ if (req.method === "OPTIONS")
79
+ return res.sendStatus(204);
71
80
  next();
72
81
  });
73
- // Add body-parser middleware
74
- this.app.use(body_parser_1.default.raw({ type: "*/*", limit: "100mb" }));
82
+ // Add body-parser middleware (skip for GET/HEAD/OPTIONS which don't have bodies)
83
+ this.app.use((req, res, next) => {
84
+ if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') {
85
+ return next();
86
+ }
87
+ body_parser_1.default.raw({ type: "*/*", limit: "100mb" })(req, res, next);
88
+ });
75
89
  this.setupExpressRoutes();
76
90
  this.setupWebSocketServer();
77
91
  }
@@ -83,25 +97,19 @@ class DainTunnelServer {
83
97
  // Handle WebSocket connections from tunnel clients
84
98
  this.wss.on("connection", (ws, req) => {
85
99
  var _a;
86
- console.log("New WebSocket connection");
87
100
  try {
88
- // Check if this is a tunnel client connection or a user connection to be proxied
89
101
  const url = (0, url_1.parse)(req.url || '', true);
90
102
  const pathParts = ((_a = url.pathname) === null || _a === void 0 ? void 0 : _a.split('/')) || [];
91
- console.log(`WebSocket connection path: ${url.pathname}`);
92
103
  // Client tunnel connection has no tunnelId in the path (or is at root)
93
104
  if (!url.pathname || url.pathname === '/' || pathParts.length <= 1 || !pathParts[1]) {
94
- console.log("Handling as tunnel client connection");
95
105
  this.handleTunnelClientConnection(ws, req);
96
106
  }
97
107
  else {
98
- // This is a WebSocket connection to be proxied through the tunnel
99
- console.log(`Handling as proxied WebSocket connection for path: ${url.pathname}`);
100
108
  this.handleProxiedWebSocketConnection(ws, req);
101
109
  }
102
110
  }
103
111
  catch (error) {
104
- console.error("Error in WebSocket connection setup:", error);
112
+ console.error("[Tunnel] WebSocket setup error:", error);
105
113
  ws.close(1011, "Internal server error");
106
114
  }
107
115
  });
@@ -111,7 +119,6 @@ class DainTunnelServer {
111
119
  ws.on("message", (message) => {
112
120
  try {
113
121
  const data = JSON.parse(message);
114
- console.log(`Received WebSocket message: ${data.type}`);
115
122
  if (data.type === "challenge_request") {
116
123
  this.handleChallengeRequest(ws);
117
124
  }
@@ -129,17 +136,12 @@ class DainTunnelServer {
129
136
  }
130
137
  }
131
138
  catch (error) {
132
- console.error("Error processing message:", error);
139
+ console.error("[Tunnel] Message error:", error);
133
140
  ws.close(1008, "Invalid message");
134
141
  }
135
142
  });
136
- ws.on("close", () => {
137
- console.log("WebSocket connection closed");
138
- this.removeTunnel(ws);
139
- });
140
- ws.on("error", (error) => {
141
- console.error("WebSocket error:", error);
142
- });
143
+ ws.on("close", () => this.removeTunnel(ws));
144
+ ws.on("error", (error) => console.error("[Tunnel] WS error:", error));
143
145
  }
144
146
  // Handle incoming WebSocket connections to be proxied through the tunnel
145
147
  handleProxiedWebSocketConnection(ws, req) {
@@ -153,15 +155,12 @@ class DainTunnelServer {
153
155
  const tunnelId = pathParts[1];
154
156
  const remainingPath = '/' + pathParts.slice(2).join('/');
155
157
  const tunnel = this.tunnels.get(tunnelId);
156
- console.log(`Handling WebSocket connection for tunnel: ${tunnelId}, path: ${remainingPath}`);
157
- console.log(`Available tunnels: ${Array.from(this.tunnels.keys()).join(', ')}`);
158
158
  if (!tunnel) {
159
- console.log(`Tunnel not found for WebSocket connection: ${tunnelId}`);
160
159
  ws.close(1008, "Tunnel not found");
161
160
  return;
162
161
  }
163
162
  // Create a WebSocket connection ID
164
- const wsConnectionId = (0, uuid_1.v4)();
163
+ const wsConnectionId = fastId();
165
164
  // Store this connection
166
165
  this.wsConnections.set(wsConnectionId, {
167
166
  clientSocket: ws,
@@ -205,10 +204,12 @@ class DainTunnelServer {
205
204
  });
206
205
  }
207
206
  handleChallengeRequest(ws) {
208
- const challenge = (0, uuid_1.v4)();
209
- const challengeObj = { ws, challenge };
207
+ const challenge = fastId();
208
+ const challengeObj = { ws, challenge, timestamp: Date.now() };
210
209
  this.challenges.set(challenge, challengeObj);
211
210
  ws.send(JSON.stringify({ type: "challenge", challenge }));
211
+ // Auto-cleanup expired challenges (30 second TTL)
212
+ setTimeout(() => this.challenges.delete(challenge), 30000);
212
213
  }
213
214
  handleStartMessage(ws, data) {
214
215
  const { challenge, signature, tunnelId, apiKey } = data;
@@ -250,34 +251,27 @@ class DainTunnelServer {
250
251
  }
251
252
  // If tunnel already exists, remove old one
252
253
  if (this.tunnels.has(tunnelId)) {
253
- console.log(`Tunnel ${tunnelId} already exists, replacing it`);
254
254
  const oldTunnel = this.tunnels.get(tunnelId);
255
255
  if (oldTunnel && oldTunnel.ws !== ws) {
256
256
  oldTunnel.ws.close(1000, "Replaced by new connection");
257
257
  }
258
258
  }
259
259
  this.tunnels.set(tunnelId, { id: tunnelId, ws });
260
- console.log(`Tunnel added: ${tunnelId}`);
261
- console.log(`Current tunnels: ${Array.from(this.tunnels.keys()).join(', ')}`);
262
260
  // Save tunnel ID on the WebSocket object for easy lookup on close
263
261
  ws.tunnelId = tunnelId;
264
- // Add a periodic check to ensure the tunnel is still in the map
262
+ // Add a periodic ping to keep the connection alive (silent - no logging)
265
263
  const intervalId = setInterval(() => {
266
264
  if (this.tunnels.has(tunnelId)) {
267
265
  const tunnel = this.tunnels.get(tunnelId);
268
266
  if (tunnel && tunnel.ws === ws && ws.readyState === ws_1.default.OPEN) {
269
- console.log(`Tunnel ${tunnelId} still active`);
270
- // Send a ping to keep the connection alive
271
267
  try {
272
268
  ws.ping();
273
269
  }
274
270
  catch (error) {
275
- console.error(`Error sending ping to tunnel ${tunnelId}:`, error);
276
271
  clearInterval(intervalId);
277
272
  }
278
273
  }
279
274
  else {
280
- console.log(`Tunnel ${tunnelId} exists but WebSocket state is invalid, cleaning up`);
281
275
  clearInterval(intervalId);
282
276
  if (tunnel && tunnel.ws === ws) {
283
277
  this.tunnels.delete(tunnelId);
@@ -285,23 +279,19 @@ class DainTunnelServer {
285
279
  }
286
280
  }
287
281
  else {
288
- console.log(`Tunnel ${tunnelId} not found in periodic check`);
289
282
  clearInterval(intervalId);
290
283
  }
291
- }, 5000); // Check every 5 seconds
284
+ }, 30000); // Ping every 30 seconds (reduced frequency)
292
285
  // Store the interval ID on the WebSocket object so we can clean it up
293
286
  ws.keepAliveInterval = intervalId;
294
- ws.on("close", () => {
295
- console.log(`WebSocket for tunnel ${tunnelId} closed, clearing interval`);
296
- clearInterval(intervalId);
297
- });
287
+ ws.on("close", () => clearInterval(intervalId));
298
288
  let tunnelUrl = `${this.hostname}`;
299
289
  if (process.env.SKIP_PORT !== "true") {
300
290
  tunnelUrl += `:${this.port}`;
301
291
  }
302
292
  tunnelUrl += `/${tunnelId}`;
303
293
  ws.send(JSON.stringify({ type: "tunnelUrl", url: tunnelUrl }));
304
- console.log(`New tunnel created: ${tunnelUrl}`);
294
+ console.log(`[Tunnel] Created: ${tunnelUrl}`);
305
295
  }
306
296
  catch (error) {
307
297
  console.error(`Error in handleStartMessage for tunnel ${tunnelId}:`, error);
@@ -311,8 +301,15 @@ class DainTunnelServer {
311
301
  handleResponseMessage(data) {
312
302
  const pendingRequest = this.pendingRequests.get(data.requestId);
313
303
  if (pendingRequest) {
314
- const { res, startTime } = pendingRequest;
304
+ const { res, startTime, tunnelId, timeoutId } = pendingRequest;
315
305
  const endTime = Date.now();
306
+ // Clear the timeout since we received a response
307
+ if (timeoutId) {
308
+ clearTimeout(timeoutId);
309
+ }
310
+ // Decrement request counter for backpressure tracking
311
+ const currentCount = this.tunnelRequestCount.get(tunnelId) || 1;
312
+ this.tunnelRequestCount.set(tunnelId, Math.max(0, currentCount - 1));
316
313
  const headers = { ...data.headers };
317
314
  delete headers["transfer-encoding"];
318
315
  delete headers["content-length"];
@@ -322,99 +319,57 @@ class DainTunnelServer {
322
319
  .set(headers)
323
320
  .set("Content-Length", bodyBuffer.length.toString())
324
321
  .send(bodyBuffer);
325
- console.log(`Request handled: ${data.requestId}, Duration: ${endTime - startTime}ms`);
326
322
  this.pendingRequests.delete(data.requestId);
327
323
  }
328
324
  }
329
325
  handleSSEMessage(data) {
330
326
  const connection = this.sseConnections.get(data.id);
331
327
  if (!connection) {
332
- console.log(`[SSE E2E] Server: Connection not found for ${data.id} (already cleaned up)`);
328
+ console.log(`[SSE Debug] handleSSEMessage: No connection found for ${data.id}, event=${data.event}`);
333
329
  return;
334
330
  }
335
331
  const { res, tunnelId } = connection;
336
332
  const clientDisconnected = connection.clientDisconnected;
337
- // Check if this is a "close" event - this is when we should clean up
338
333
  const isCloseEvent = data.event === 'close';
334
+ console.log(`[SSE Debug] handleSSEMessage: id=${data.id}, event=${data.event}, clientDisconnected=${clientDisconnected}, isClose=${isCloseEvent}`);
339
335
  if (isCloseEvent) {
340
- console.log(`[SSE E2E] Server: Received close event for ${data.id}, cleaning up`);
341
- // Now we can safely send sse_close to tunnel client
342
336
  const tunnel = this.tunnels.get(tunnelId);
343
337
  if (tunnel) {
344
- tunnel.ws.send(JSON.stringify({
345
- type: "sse_close",
346
- id: data.id
347
- }));
348
- console.log(`[SSE E2E] Server→Client: Sent sse_close command for ${data.id}`);
338
+ tunnel.ws.send(JSON.stringify({ type: "sse_close", id: data.id }));
349
339
  }
350
- // End the response if client is still connected
351
340
  if (!clientDisconnected && res.writable) {
352
341
  try {
353
- res.write(`event: ${data.event}\n`);
354
- res.write(`data: ${data.data}\n`);
355
- res.write('\n');
342
+ res.write(`event: ${data.event}\ndata: ${data.data}\n\n`);
356
343
  flushSSE(res);
357
344
  res.end();
358
- console.log(`[SSE E2E] Server→Browser: Sent close event and ended response for ${data.id}`);
359
- }
360
- catch (error) {
361
- console.error(`[SSE E2E] Server: ❌ Error writing close event for ${data.id}:`, error);
362
345
  }
346
+ catch (error) { /* ignore */ }
363
347
  }
364
- else {
365
- console.log(`[SSE E2E] Server: Browser already disconnected for ${data.id}, skipping close event`);
366
- }
348
+ // Decrement request counter for backpressure tracking
349
+ const currentCount = this.tunnelRequestCount.get(tunnelId) || 1;
350
+ this.tunnelRequestCount.set(tunnelId, Math.max(0, currentCount - 1));
367
351
  this.sseConnections.delete(data.id);
368
352
  return;
369
353
  }
370
- // If client disconnected, don't try to write (but don't fail either - let service finish)
371
- if (clientDisconnected) {
372
- // Only log first discard and key events to avoid spam
373
- if (!(connection.discardCount)) {
374
- connection.discardCount = 0;
375
- }
376
- connection.discardCount++;
377
- if (connection.discardCount === 1 || data.event === 'error' || data.event === 'end') {
378
- console.log(`[SSE E2E] Server: Discarding event="${data.event}" for ${data.id} - browser disconnected (total discarded: ${connection.discardCount})`);
379
- }
380
- return;
381
- }
382
- // Check if response is still writable before attempting to write
383
- if (!res.writable) {
384
- console.log(`[SSE E2E] Server: Response no longer writable for ${data.id}, marking as disconnected`);
354
+ if (clientDisconnected || !res.writable) {
385
355
  connection.clientDisconnected = true;
386
356
  return;
387
357
  }
388
358
  try {
389
- if (data.event) {
359
+ if (data.event)
390
360
  res.write(`event: ${data.event}\n`);
391
- }
392
- // Write data as-is (don't split - data is already properly formatted)
393
- res.write(`data: ${data.data}\n`);
394
- res.write('\n');
395
- // Force immediate flush to prevent buffering
396
- // This is critical for SSE - without it, events may be delayed or lost
361
+ const escapedData = data.data.replace(/\n/g, '\ndata: ');
362
+ res.write(`data: ${escapedData}\n\n`);
397
363
  flushSSE(res);
398
- // Only log key events to avoid spam
399
- if (!(connection.eventCount)) {
400
- connection.eventCount = 0;
401
- }
402
- connection.eventCount++;
403
- if (connection.eventCount === 1 || data.event === 'error' || data.event === 'end') {
404
- console.log(`[SSE E2E] Server→Browser: Forwarded event="${data.event}" for ${data.id} (total events: ${connection.eventCount})`);
405
- }
406
364
  }
407
365
  catch (error) {
408
- console.error(`[SSE E2E] Server: ❌ Error writing SSE message for ${data.id}:`, error);
409
366
  connection.clientDisconnected = true;
410
367
  }
411
368
  }
412
369
  handleWebSocketMessage(data) {
413
370
  const connection = this.wsConnections.get(data.id);
414
- if (!connection) {
415
- console.log(`WebSocket connection not found: ${data.id}`);
371
+ if (!connection)
416
372
  return;
417
- }
418
373
  const { clientSocket } = connection;
419
374
  if (data.event === 'message' && data.data) {
420
375
  const messageData = Buffer.from(data.data, 'base64');
@@ -447,41 +402,57 @@ class DainTunnelServer {
447
402
  while (retries > 0 && !tunnel) {
448
403
  tunnel = this.tunnels.get(tunnelId);
449
404
  if (!tunnel) {
450
- console.log(`Tunnel not found: ${tunnelId}, retrying... (${retries} attempts left)`);
451
- console.log(`Current tunnels: ${Array.from(this.tunnels.keys()).join(', ')}`);
452
- await new Promise(resolve => setTimeout(resolve, 100)); // Wait 100ms before retrying
405
+ await new Promise(resolve => setTimeout(resolve, 100));
453
406
  retries--;
454
407
  }
455
408
  }
456
409
  if (!tunnel) {
457
- console.log(`Tunnel not found after retries: ${tunnelId}`);
458
410
  return res.status(404).send("Tunnel not found");
459
411
  }
412
+ // High-frequency optimization: Check WebSocket buffer and apply backpressure
413
+ if (tunnel.ws.bufferedAmount > 1024 * 1024) {
414
+ return res.status(503).json({ error: "Service Unavailable", message: "Tunnel high load" });
415
+ }
416
+ // Check concurrent request limit per tunnel
417
+ const currentCount = this.tunnelRequestCount.get(tunnelId) || 0;
418
+ if (currentCount >= this.MAX_CONCURRENT_REQUESTS_PER_TUNNEL) {
419
+ return res.status(503).json({ error: "Service Unavailable", message: "Too many concurrent requests" });
420
+ }
421
+ this.tunnelRequestCount.set(tunnelId, currentCount + 1);
460
422
  // Check for SSE request
461
423
  if (req.headers.accept && req.headers.accept.includes('text/event-stream')) {
462
424
  return this.handleSSERequest(req, res, tunnelId, tunnel);
463
425
  }
464
426
  // Handle regular HTTP request
465
- const requestId = (0, uuid_1.v4)();
427
+ const requestId = fastId();
466
428
  const startTime = Date.now();
467
- this.pendingRequests.set(requestId, { res, startTime });
468
- const requestMessage = {
429
+ // Set a timeout for the request (30 seconds)
430
+ const REQUEST_TIMEOUT = 30000;
431
+ const timeoutId = setTimeout(() => {
432
+ const pendingRequest = this.pendingRequests.get(requestId);
433
+ if (pendingRequest) {
434
+ const currentCount = this.tunnelRequestCount.get(tunnelId) || 1;
435
+ this.tunnelRequestCount.set(tunnelId, Math.max(0, currentCount - 1));
436
+ this.pendingRequests.delete(requestId);
437
+ if (!res.headersSent) {
438
+ res.status(504).json({ error: "Gateway Timeout", message: "Request timed out" });
439
+ }
440
+ }
441
+ }, REQUEST_TIMEOUT);
442
+ this.pendingRequests.set(requestId, { res, startTime, tunnelId, timeoutId });
443
+ tunnel.ws.send(JSON.stringify({
469
444
  type: "request",
470
445
  id: requestId,
471
446
  method: req.method,
472
447
  path: req.url,
473
448
  headers: req.headers,
474
- body: req.method !== "GET" && req.body
449
+ body: req.method !== "GET" && req.method !== "HEAD" && req.body
475
450
  ? req.body.toString("base64")
476
451
  : undefined,
477
- };
478
- tunnel.ws.send(JSON.stringify(requestMessage));
479
- console.log(`Request forwarded: ${requestId}, Method: ${req.method}, Path: ${req.url}`);
452
+ }));
480
453
  }
481
454
  handleSSERequest(req, res, tunnelId, tunnel) {
482
- // Setup SSE connection
483
- const sseId = (0, uuid_1.v4)();
484
- console.log(`[SSE E2E] Server: Establishing SSE connection ${sseId} from browser to ${req.url}`);
455
+ const sseId = fastId();
485
456
  // Optimize TCP socket for low-latency streaming
486
457
  const socket = req.socket || req.connection;
487
458
  if (socket && socket.setNoDelay) {
@@ -497,113 +468,114 @@ class DainTunnelServer {
497
468
  // Flush headers immediately to establish SSE connection
498
469
  // Without this, headers may be buffered, delaying connection setup
499
470
  res.flushHeaders();
500
- // Send initial connection event
501
- res.write('\n');
471
+ // Send SSE comment to keep connection alive and verify it's working
472
+ // Comments start with : and are ignored by EventSource but flush the buffer
473
+ res.write(': connected\n\n');
502
474
  // Flush the initial write
503
475
  flushSSE(res);
504
- // Store the SSE connection
505
- this.sseConnections.set(sseId, {
506
- req,
507
- res,
508
- id: sseId,
509
- tunnelId
510
- });
511
- console.log(`[SSE E2E] Server→Client: Forwarding SSE connection request ${sseId} to tunnel client`);
512
- // Notify the tunnel client about the new SSE connection
476
+ this.sseConnections.set(sseId, { req, res, id: sseId, tunnelId });
513
477
  tunnel.ws.send(JSON.stringify({
514
478
  type: "sse_connection",
515
479
  id: sseId,
516
480
  path: req.url,
517
- method: req.method, // Include HTTP method for POST tool calls
481
+ method: req.method,
518
482
  headers: req.headers,
519
483
  body: req.method !== "GET" && req.body ? req.body.toString("base64") : undefined
520
484
  }));
521
- // Handle client disconnect
522
485
  req.on('close', () => {
523
- console.log(`[SSE E2E] Server: Browser disconnected from SSE ${sseId}`);
524
- // DO NOT immediately send sse_close to tunnel client!
525
- // The local service may still be processing and sending SSE events.
526
- // Instead, mark this connection as "client disconnected" and let the
527
- // local service finish naturally. We'll clean up when we receive the
528
- // final "close" event from the service, or when the connection errors.
486
+ console.log(`[SSE Debug] req.close fired for ${sseId} - client disconnected`);
529
487
  const connection = this.sseConnections.get(sseId);
530
488
  if (connection) {
531
- // Mark as client disconnected, but keep the tunnel client connection alive
532
489
  connection.clientDisconnected = true;
533
- console.log(`[SSE E2E] Server: Marked ${sseId} as browser-disconnected (service can still finish)`);
490
+ // Clean up after a short delay to allow any pending events to be handled
491
+ setTimeout(() => {
492
+ if (this.sseConnections.has(sseId)) {
493
+ console.log(`[SSE Debug] Cleaning up SSE connection ${sseId} after client disconnect`);
494
+ // Decrement request counter for backpressure tracking
495
+ const currentCount = this.tunnelRequestCount.get(tunnelId) || 1;
496
+ this.tunnelRequestCount.set(tunnelId, Math.max(0, currentCount - 1));
497
+ this.sseConnections.delete(sseId);
498
+ // Notify tunnel client to close the connection
499
+ const tunnel = this.tunnels.get(tunnelId);
500
+ if (tunnel && tunnel.ws.readyState === ws_1.default.OPEN) {
501
+ tunnel.ws.send(JSON.stringify({ type: "sse_close", id: sseId }));
502
+ }
503
+ }
504
+ }, 100);
534
505
  }
535
506
  });
507
+ req.on('error', (err) => {
508
+ console.log(`[SSE Debug] req.error fired for ${sseId}:`, err.message);
509
+ });
536
510
  }
537
511
  removeTunnel(ws) {
538
512
  try {
539
- // Clear any interval timer
540
- if (ws.keepAliveInterval) {
513
+ if (ws.keepAliveInterval)
541
514
  clearInterval(ws.keepAliveInterval);
542
- }
543
- // If we saved tunnelId on the WebSocket object, use it for faster lookup
544
515
  const tunnelId = ws.tunnelId;
545
516
  let removedTunnelId = tunnelId;
546
517
  if (tunnelId && this.tunnels.has(tunnelId)) {
547
518
  const tunnel = this.tunnels.get(tunnelId);
548
- if (tunnel && tunnel.ws === ws) {
519
+ if (tunnel && tunnel.ws === ws)
549
520
  this.tunnels.delete(tunnelId);
550
- console.log(`Tunnel removed using stored ID: ${tunnelId}`);
551
- }
552
521
  }
553
522
  else {
554
- // Fall back to iterating through all tunnels
555
523
  removedTunnelId = undefined;
556
524
  for (const [id, tunnel] of this.tunnels.entries()) {
557
525
  if (tunnel.ws === ws) {
558
526
  this.tunnels.delete(id);
559
527
  removedTunnelId = id;
560
- console.log(`Tunnel removed: ${id}`);
561
528
  break;
562
529
  }
563
530
  }
564
531
  }
565
532
  if (removedTunnelId) {
566
- console.log(`Tunnel ${removedTunnelId} removed. Current tunnels: ${Array.from(this.tunnels.keys()).join(', ')}`);
533
+ this.tunnelRequestCount.delete(removedTunnelId);
534
+ // Close all pending HTTP requests associated with this tunnel
535
+ for (const [requestId, pendingRequest] of this.pendingRequests.entries()) {
536
+ if (pendingRequest.tunnelId === removedTunnelId) {
537
+ try {
538
+ if (pendingRequest.timeoutId)
539
+ clearTimeout(pendingRequest.timeoutId);
540
+ if (!pendingRequest.res.headersSent) {
541
+ pendingRequest.res.status(502).json({ error: "Bad Gateway", message: "Tunnel closed" });
542
+ }
543
+ }
544
+ catch (error) { /* ignore */ }
545
+ this.pendingRequests.delete(requestId);
546
+ }
547
+ }
567
548
  // Close all SSE connections associated with this tunnel
568
549
  for (const [sseId, sseConnection] of this.sseConnections.entries()) {
569
550
  if (sseConnection.tunnelId === removedTunnelId) {
570
551
  try {
571
552
  sseConnection.res.end();
572
553
  }
573
- catch (error) {
574
- console.error(`Error closing SSE connection ${sseId}:`, error);
575
- }
554
+ catch (error) { /* ignore */ }
576
555
  this.sseConnections.delete(sseId);
577
556
  }
578
557
  }
579
558
  // Close all WebSocket connections associated with this tunnel
580
559
  for (const [wsId, wsConnection] of this.wsConnections.entries()) {
581
- const wsPath = wsConnection.path;
582
- if (wsPath.startsWith(`/${removedTunnelId}/`)) {
560
+ if (wsConnection.path.startsWith(`/${removedTunnelId}/`)) {
583
561
  try {
584
562
  wsConnection.clientSocket.close(1001, "Tunnel closed");
585
563
  }
586
- catch (error) {
587
- console.error(`Error closing WebSocket connection ${wsId}:`, error);
588
- }
564
+ catch (error) { /* ignore */ }
589
565
  this.wsConnections.delete(wsId);
590
566
  }
591
567
  }
592
568
  }
593
- else {
594
- console.log(`No tunnel found to remove for the closed WebSocket connection`);
595
- }
596
- // Also remove any pending challenges for this WebSocket
569
+ // Remove any pending challenges for this WebSocket
597
570
  for (const [challenge, challengeObj] of this.challenges.entries()) {
598
571
  if (challengeObj.ws === ws) {
599
572
  this.challenges.delete(challenge);
600
- console.log(`Challenge removed for closed WebSocket`);
601
573
  break;
602
574
  }
603
575
  }
604
576
  }
605
577
  catch (error) {
606
- console.error("Error in removeTunnel:", error);
578
+ console.error("[Tunnel] Remove error:", error);
607
579
  }
608
580
  }
609
581
  async start() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dainprotocol/tunnel",
3
- "version": "1.1.14",
3
+ "version": "1.1.17",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "private": false,
@@ -19,7 +19,7 @@
19
19
  "author": "Ryan",
20
20
  "license": "ISC",
21
21
  "dependencies": {
22
- "@dainprotocol/service-sdk": "^2.0.30",
22
+ "@dainprotocol/service-sdk": "2.0.56",
23
23
  "@types/body-parser": "^1.19.5",
24
24
  "@types/cors": "^2.8.17",
25
25
  "@types/eventsource": "^3.0.0",
@@ -77,4 +77,4 @@
77
77
  ]
78
78
  }
79
79
  }
80
- }
80
+ }