@dainprotocol/tunnel 1.1.13 → 1.1.16

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,129 +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
- console.log(`[SSE E2E] Client: Service accepted SSE connection ${message.id}, streaming started`);
278
- // Process SSE stream
254
+ const socket = res.socket || res.connection;
255
+ if (socket === null || socket === void 0 ? void 0 : socket.setNoDelay)
256
+ socket.setNoDelay(true);
279
257
  let buffer = '';
280
- let eventCount = 0;
281
258
  res.on('data', (chunk) => {
282
- const chunkStr = chunk.toString();
283
- buffer += chunkStr;
284
- // Process complete SSE messages
259
+ var _a;
260
+ buffer += chunk.toString();
285
261
  while (buffer.includes('\n\n')) {
286
- const messageEndIndex = buffer.indexOf('\n\n');
287
- const messageData = buffer.substring(0, messageEndIndex);
288
- buffer = buffer.substring(messageEndIndex + 2);
289
- // Parse the SSE message
290
- const lines = messageData.split('\n');
291
- let event = 'message';
292
- let data = '';
293
- for (const line of lines) {
294
- 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:'))
295
268
  event = line.substring(6).trim();
296
- }
297
- else if (line.startsWith('data:')) {
269
+ else if (line.startsWith('data:'))
298
270
  data += line.substring(5).trim() + '\n';
299
- }
300
- }
301
- // Remove trailing newline
302
- if (data.endsWith('\n')) {
303
- data = data.substring(0, data.length - 1);
304
- }
305
- eventCount++;
306
- // Only log key events (not every progress update)
307
- if (event === 'error' || event === 'close' || event === 'end' || eventCount === 1) {
308
- console.log(`[SSE E2E] Client→Server: Forwarding event="${event}" for ${message.id} (total events: ${eventCount})`);
309
271
  }
310
- // Forward to server
311
- if (this.ws && this.ws.readyState === ws_1.default.OPEN) {
312
- this.ws.send(JSON.stringify({
313
- type: 'sse',
314
- id: message.id,
315
- event,
316
- data
317
- }));
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 }));
318
276
  }
319
277
  }
320
278
  });
321
279
  res.on('end', () => {
322
- console.log(`[SSE E2E] Client: Service stream ended for ${message.id} (total events sent: ${eventCount})`);
323
- if (this.ws && this.ws.readyState === ws_1.default.OPEN) {
324
- this.ws.send(JSON.stringify({
325
- type: 'sse',
326
- id: message.id,
327
- event: 'close',
328
- data: 'Connection closed'
329
- }));
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' }));
330
283
  }
331
284
  });
332
285
  });
333
286
  req.on('error', (error) => {
334
- console.log(`[SSE E2E] Client: ❌ Request error for ${message.id}: ${error.message}`);
335
- if (this.ws && this.ws.readyState === ws_1.default.OPEN) {
336
- this.ws.send(JSON.stringify({
337
- type: 'sse',
338
- id: message.id,
339
- event: 'error',
340
- data: error.message
341
- }));
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 }));
342
290
  }
343
291
  });
344
- // Write request body for POST requests
345
- if (message.body && message.method !== 'GET') {
292
+ if (message.body && message.method !== 'GET')
346
293
  req.write(Buffer.from(message.body, 'base64'));
347
- }
348
294
  req.end();
349
- // Store a reference to abort the connection later if needed
350
295
  this.sseClients.set(message.id, req);
351
296
  this.emit("sse_connection", { id: message.id, path: message.path });
352
297
  }
353
298
  catch (error) {
354
- console.error(`[SSE E2E] Client: Error establishing SSE connection ${message.id}:`, error);
355
- if (this.ws && this.ws.readyState === ws_1.default.OPEN) {
356
- this.ws.send(JSON.stringify({
357
- type: 'sse',
358
- id: message.id,
359
- event: 'error',
360
- data: error.message
361
- }));
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 }));
362
301
  }
363
302
  }
364
303
  }
365
304
  handleSSEClose(message) {
366
- console.log(`[SSE E2E] Client: Received close command from server for ${message.id}`);
367
305
  const client = this.sseClients.get(message.id);
368
306
  if (client) {
369
- // Abort the request if it's still active
370
307
  client.destroy();
371
308
  this.sseClients.delete(message.id);
372
- console.log(`[SSE E2E] Client: Destroyed local service connection for ${message.id}`);
373
- }
374
- else {
375
- console.log(`[SSE E2E] Client: No active connection found for ${message.id} (already cleaned up)`);
376
309
  }
377
310
  }
378
311
  forwardRequest(request) {
@@ -383,6 +316,7 @@ class DainTunnel extends events_1.EventEmitter {
383
316
  path: request.path,
384
317
  method: request.method,
385
318
  headers: request.headers,
319
+ agent: this.httpAgent, // Use connection pooling
386
320
  };
387
321
  const req = http_1.default.request(options, (res) => {
388
322
  let body = Buffer.from([]);
@@ -417,25 +351,14 @@ class DainTunnel extends events_1.EventEmitter {
417
351
  attemptReconnect() {
418
352
  if (this.reconnectAttempts < this.maxReconnectAttempts) {
419
353
  this.reconnectAttempts++;
420
- console.log(`Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
421
354
  setTimeout(async () => {
422
355
  try {
423
356
  await this.connect();
424
- // If the connect succeeds but we don't have a tunnelUrl yet, we're still initializing
425
- if (!this.tunnelUrl) {
426
- console.log('Reconnected but tunnelUrl not set. Waiting for tunnel URL...');
427
- }
428
- else {
429
- console.log(`Reconnected successfully. Tunnel URL: ${this.tunnelUrl}`);
430
- }
431
- }
432
- catch (error) {
433
- console.error(`Reconnection attempt ${this.reconnectAttempts} failed:`, error);
434
357
  }
358
+ catch (error) { /* ignore */ }
435
359
  }, this.reconnectDelay);
436
360
  }
437
361
  else {
438
- console.log('Maximum reconnection attempts reached');
439
362
  this.emit("max_reconnect_attempts");
440
363
  }
441
364
  }
@@ -476,6 +399,10 @@ class DainTunnel extends events_1.EventEmitter {
476
399
  }
477
400
  this.ws = null;
478
401
  }
402
+ // Destroy the HTTP agent to close pooled connections
403
+ if (this.httpAgent) {
404
+ this.httpAgent.destroy();
405
+ }
479
406
  // Wait for all connections to close properly
480
407
  await new Promise(resolve => setTimeout(resolve, 500));
481
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
@@ -20,16 +24,18 @@ const url_1 = require("url");
20
24
  */
21
25
  function flushSSE(res) {
22
26
  try {
23
- // Access underlying socket to force flush
24
- // Type assertion needed as Express types don't expose socket methods
27
+ // Node.js TCP sockets use cork/uncork for buffering control
28
+ // We uncork to force immediate transmission of buffered data
25
29
  const socket = res.socket;
26
- if (socket && typeof socket.flush === 'function') {
27
- socket.flush();
28
- }
29
- // Fallback: try connection property (older Node.js versions)
30
- const connection = res.connection;
31
- if (connection && typeof connection.flush === 'function') {
32
- connection.flush();
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);
38
+ }
33
39
  }
34
40
  }
35
41
  catch (error) {
@@ -46,30 +52,40 @@ class DainTunnelServer {
46
52
  this.challenges = new Map();
47
53
  this.sseConnections = new Map();
48
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;
49
58
  this.app = (0, express_1.default)();
50
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
51
64
  this.wss = new ws_1.default.Server({
52
65
  server: this.server,
53
- 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
54
71
  });
55
- // Use cors middleware
56
- this.app.use((0, cors_1.default)({
57
- origin: '*',
58
- methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
59
- credentials: true
60
- }));
61
- // Update CORS middleware
72
+ // Single optimized CORS middleware (avoid duplicate cors() + manual headers)
62
73
  this.app.use((req, res, next) => {
63
74
  res.header("Access-Control-Allow-Origin", "*");
64
75
  res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS");
65
- 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');
66
- if (req.method === "OPTIONS") {
67
- return res.sendStatus(200);
68
- }
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);
69
80
  next();
70
81
  });
71
- // Add body-parser middleware
72
- 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
+ });
73
89
  this.setupExpressRoutes();
74
90
  this.setupWebSocketServer();
75
91
  }
@@ -81,25 +97,19 @@ class DainTunnelServer {
81
97
  // Handle WebSocket connections from tunnel clients
82
98
  this.wss.on("connection", (ws, req) => {
83
99
  var _a;
84
- console.log("New WebSocket connection");
85
100
  try {
86
- // Check if this is a tunnel client connection or a user connection to be proxied
87
101
  const url = (0, url_1.parse)(req.url || '', true);
88
102
  const pathParts = ((_a = url.pathname) === null || _a === void 0 ? void 0 : _a.split('/')) || [];
89
- console.log(`WebSocket connection path: ${url.pathname}`);
90
103
  // Client tunnel connection has no tunnelId in the path (or is at root)
91
104
  if (!url.pathname || url.pathname === '/' || pathParts.length <= 1 || !pathParts[1]) {
92
- console.log("Handling as tunnel client connection");
93
105
  this.handleTunnelClientConnection(ws, req);
94
106
  }
95
107
  else {
96
- // This is a WebSocket connection to be proxied through the tunnel
97
- console.log(`Handling as proxied WebSocket connection for path: ${url.pathname}`);
98
108
  this.handleProxiedWebSocketConnection(ws, req);
99
109
  }
100
110
  }
101
111
  catch (error) {
102
- console.error("Error in WebSocket connection setup:", error);
112
+ console.error("[Tunnel] WebSocket setup error:", error);
103
113
  ws.close(1011, "Internal server error");
104
114
  }
105
115
  });
@@ -109,7 +119,6 @@ class DainTunnelServer {
109
119
  ws.on("message", (message) => {
110
120
  try {
111
121
  const data = JSON.parse(message);
112
- console.log(`Received WebSocket message: ${data.type}`);
113
122
  if (data.type === "challenge_request") {
114
123
  this.handleChallengeRequest(ws);
115
124
  }
@@ -127,17 +136,12 @@ class DainTunnelServer {
127
136
  }
128
137
  }
129
138
  catch (error) {
130
- console.error("Error processing message:", error);
139
+ console.error("[Tunnel] Message error:", error);
131
140
  ws.close(1008, "Invalid message");
132
141
  }
133
142
  });
134
- ws.on("close", () => {
135
- console.log("WebSocket connection closed");
136
- this.removeTunnel(ws);
137
- });
138
- ws.on("error", (error) => {
139
- console.error("WebSocket error:", error);
140
- });
143
+ ws.on("close", () => this.removeTunnel(ws));
144
+ ws.on("error", (error) => console.error("[Tunnel] WS error:", error));
141
145
  }
142
146
  // Handle incoming WebSocket connections to be proxied through the tunnel
143
147
  handleProxiedWebSocketConnection(ws, req) {
@@ -151,15 +155,12 @@ class DainTunnelServer {
151
155
  const tunnelId = pathParts[1];
152
156
  const remainingPath = '/' + pathParts.slice(2).join('/');
153
157
  const tunnel = this.tunnels.get(tunnelId);
154
- console.log(`Handling WebSocket connection for tunnel: ${tunnelId}, path: ${remainingPath}`);
155
- console.log(`Available tunnels: ${Array.from(this.tunnels.keys()).join(', ')}`);
156
158
  if (!tunnel) {
157
- console.log(`Tunnel not found for WebSocket connection: ${tunnelId}`);
158
159
  ws.close(1008, "Tunnel not found");
159
160
  return;
160
161
  }
161
162
  // Create a WebSocket connection ID
162
- const wsConnectionId = (0, uuid_1.v4)();
163
+ const wsConnectionId = fastId();
163
164
  // Store this connection
164
165
  this.wsConnections.set(wsConnectionId, {
165
166
  clientSocket: ws,
@@ -203,10 +204,12 @@ class DainTunnelServer {
203
204
  });
204
205
  }
205
206
  handleChallengeRequest(ws) {
206
- const challenge = (0, uuid_1.v4)();
207
- const challengeObj = { ws, challenge };
207
+ const challenge = fastId();
208
+ const challengeObj = { ws, challenge, timestamp: Date.now() };
208
209
  this.challenges.set(challenge, challengeObj);
209
210
  ws.send(JSON.stringify({ type: "challenge", challenge }));
211
+ // Auto-cleanup expired challenges (30 second TTL)
212
+ setTimeout(() => this.challenges.delete(challenge), 30000);
210
213
  }
211
214
  handleStartMessage(ws, data) {
212
215
  const { challenge, signature, tunnelId, apiKey } = data;
@@ -248,34 +251,27 @@ class DainTunnelServer {
248
251
  }
249
252
  // If tunnel already exists, remove old one
250
253
  if (this.tunnels.has(tunnelId)) {
251
- console.log(`Tunnel ${tunnelId} already exists, replacing it`);
252
254
  const oldTunnel = this.tunnels.get(tunnelId);
253
255
  if (oldTunnel && oldTunnel.ws !== ws) {
254
256
  oldTunnel.ws.close(1000, "Replaced by new connection");
255
257
  }
256
258
  }
257
259
  this.tunnels.set(tunnelId, { id: tunnelId, ws });
258
- console.log(`Tunnel added: ${tunnelId}`);
259
- console.log(`Current tunnels: ${Array.from(this.tunnels.keys()).join(', ')}`);
260
260
  // Save tunnel ID on the WebSocket object for easy lookup on close
261
261
  ws.tunnelId = tunnelId;
262
- // 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)
263
263
  const intervalId = setInterval(() => {
264
264
  if (this.tunnels.has(tunnelId)) {
265
265
  const tunnel = this.tunnels.get(tunnelId);
266
266
  if (tunnel && tunnel.ws === ws && ws.readyState === ws_1.default.OPEN) {
267
- console.log(`Tunnel ${tunnelId} still active`);
268
- // Send a ping to keep the connection alive
269
267
  try {
270
268
  ws.ping();
271
269
  }
272
270
  catch (error) {
273
- console.error(`Error sending ping to tunnel ${tunnelId}:`, error);
274
271
  clearInterval(intervalId);
275
272
  }
276
273
  }
277
274
  else {
278
- console.log(`Tunnel ${tunnelId} exists but WebSocket state is invalid, cleaning up`);
279
275
  clearInterval(intervalId);
280
276
  if (tunnel && tunnel.ws === ws) {
281
277
  this.tunnels.delete(tunnelId);
@@ -283,23 +279,19 @@ class DainTunnelServer {
283
279
  }
284
280
  }
285
281
  else {
286
- console.log(`Tunnel ${tunnelId} not found in periodic check`);
287
282
  clearInterval(intervalId);
288
283
  }
289
- }, 5000); // Check every 5 seconds
284
+ }, 30000); // Ping every 30 seconds (reduced frequency)
290
285
  // Store the interval ID on the WebSocket object so we can clean it up
291
286
  ws.keepAliveInterval = intervalId;
292
- ws.on("close", () => {
293
- console.log(`WebSocket for tunnel ${tunnelId} closed, clearing interval`);
294
- clearInterval(intervalId);
295
- });
287
+ ws.on("close", () => clearInterval(intervalId));
296
288
  let tunnelUrl = `${this.hostname}`;
297
289
  if (process.env.SKIP_PORT !== "true") {
298
290
  tunnelUrl += `:${this.port}`;
299
291
  }
300
292
  tunnelUrl += `/${tunnelId}`;
301
293
  ws.send(JSON.stringify({ type: "tunnelUrl", url: tunnelUrl }));
302
- console.log(`New tunnel created: ${tunnelUrl}`);
294
+ console.log(`[Tunnel] Created: ${tunnelUrl}`);
303
295
  }
304
296
  catch (error) {
305
297
  console.error(`Error in handleStartMessage for tunnel ${tunnelId}:`, error);
@@ -309,8 +301,15 @@ class DainTunnelServer {
309
301
  handleResponseMessage(data) {
310
302
  const pendingRequest = this.pendingRequests.get(data.requestId);
311
303
  if (pendingRequest) {
312
- const { res, startTime } = pendingRequest;
304
+ const { res, startTime, tunnelId, timeoutId } = pendingRequest;
313
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));
314
313
  const headers = { ...data.headers };
315
314
  delete headers["transfer-encoding"];
316
315
  delete headers["content-length"];
@@ -320,99 +319,54 @@ class DainTunnelServer {
320
319
  .set(headers)
321
320
  .set("Content-Length", bodyBuffer.length.toString())
322
321
  .send(bodyBuffer);
323
- console.log(`Request handled: ${data.requestId}, Duration: ${endTime - startTime}ms`);
324
322
  this.pendingRequests.delete(data.requestId);
325
323
  }
326
324
  }
327
325
  handleSSEMessage(data) {
328
326
  const connection = this.sseConnections.get(data.id);
329
- if (!connection) {
330
- console.log(`[SSE E2E] Server: Connection not found for ${data.id} (already cleaned up)`);
327
+ if (!connection)
331
328
  return;
332
- }
333
329
  const { res, tunnelId } = connection;
334
330
  const clientDisconnected = connection.clientDisconnected;
335
- // Check if this is a "close" event - this is when we should clean up
336
331
  const isCloseEvent = data.event === 'close';
337
332
  if (isCloseEvent) {
338
- console.log(`[SSE E2E] Server: Received close event for ${data.id}, cleaning up`);
339
- // Now we can safely send sse_close to tunnel client
340
333
  const tunnel = this.tunnels.get(tunnelId);
341
334
  if (tunnel) {
342
- tunnel.ws.send(JSON.stringify({
343
- type: "sse_close",
344
- id: data.id
345
- }));
346
- console.log(`[SSE E2E] Server→Client: Sent sse_close command for ${data.id}`);
335
+ tunnel.ws.send(JSON.stringify({ type: "sse_close", id: data.id }));
347
336
  }
348
- // End the response if client is still connected
349
337
  if (!clientDisconnected && res.writable) {
350
338
  try {
351
- res.write(`event: ${data.event}\n`);
352
- res.write(`data: ${data.data}\n`);
353
- res.write('\n');
339
+ res.write(`event: ${data.event}\ndata: ${data.data}\n\n`);
354
340
  flushSSE(res);
355
341
  res.end();
356
- console.log(`[SSE E2E] Server→Browser: Sent close event and ended response for ${data.id}`);
357
- }
358
- catch (error) {
359
- console.error(`[SSE E2E] Server: ❌ Error writing close event for ${data.id}:`, error);
360
342
  }
343
+ catch (error) { /* ignore */ }
361
344
  }
362
- else {
363
- console.log(`[SSE E2E] Server: Browser already disconnected for ${data.id}, skipping close event`);
364
- }
345
+ // Decrement request counter for backpressure tracking
346
+ const currentCount = this.tunnelRequestCount.get(tunnelId) || 1;
347
+ this.tunnelRequestCount.set(tunnelId, Math.max(0, currentCount - 1));
365
348
  this.sseConnections.delete(data.id);
366
349
  return;
367
350
  }
368
- // If client disconnected, don't try to write (but don't fail either - let service finish)
369
- if (clientDisconnected) {
370
- // Only log first discard and key events to avoid spam
371
- if (!(connection.discardCount)) {
372
- connection.discardCount = 0;
373
- }
374
- connection.discardCount++;
375
- if (connection.discardCount === 1 || data.event === 'error' || data.event === 'end') {
376
- console.log(`[SSE E2E] Server: Discarding event="${data.event}" for ${data.id} - browser disconnected (total discarded: ${connection.discardCount})`);
377
- }
378
- return;
379
- }
380
- // Check if response is still writable before attempting to write
381
- if (!res.writable) {
382
- console.log(`[SSE E2E] Server: Response no longer writable for ${data.id}, marking as disconnected`);
351
+ if (clientDisconnected || !res.writable) {
383
352
  connection.clientDisconnected = true;
384
353
  return;
385
354
  }
386
355
  try {
387
- if (data.event) {
356
+ if (data.event)
388
357
  res.write(`event: ${data.event}\n`);
389
- }
390
- // Write data as-is (don't split - data is already properly formatted)
391
- res.write(`data: ${data.data}\n`);
392
- res.write('\n');
393
- // Force immediate flush to prevent buffering
394
- // This is critical for SSE - without it, events may be delayed or lost
358
+ const escapedData = data.data.replace(/\n/g, '\ndata: ');
359
+ res.write(`data: ${escapedData}\n\n`);
395
360
  flushSSE(res);
396
- // Only log key events to avoid spam
397
- if (!(connection.eventCount)) {
398
- connection.eventCount = 0;
399
- }
400
- connection.eventCount++;
401
- if (connection.eventCount === 1 || data.event === 'error' || data.event === 'end') {
402
- console.log(`[SSE E2E] Server→Browser: Forwarded event="${data.event}" for ${data.id} (total events: ${connection.eventCount})`);
403
- }
404
361
  }
405
362
  catch (error) {
406
- console.error(`[SSE E2E] Server: ❌ Error writing SSE message for ${data.id}:`, error);
407
363
  connection.clientDisconnected = true;
408
364
  }
409
365
  }
410
366
  handleWebSocketMessage(data) {
411
367
  const connection = this.wsConnections.get(data.id);
412
- if (!connection) {
413
- console.log(`WebSocket connection not found: ${data.id}`);
368
+ if (!connection)
414
369
  return;
415
- }
416
370
  const { clientSocket } = connection;
417
371
  if (data.event === 'message' && data.data) {
418
372
  const messageData = Buffer.from(data.data, 'base64');
@@ -445,41 +399,62 @@ class DainTunnelServer {
445
399
  while (retries > 0 && !tunnel) {
446
400
  tunnel = this.tunnels.get(tunnelId);
447
401
  if (!tunnel) {
448
- console.log(`Tunnel not found: ${tunnelId}, retrying... (${retries} attempts left)`);
449
- console.log(`Current tunnels: ${Array.from(this.tunnels.keys()).join(', ')}`);
450
- await new Promise(resolve => setTimeout(resolve, 100)); // Wait 100ms before retrying
402
+ await new Promise(resolve => setTimeout(resolve, 100));
451
403
  retries--;
452
404
  }
453
405
  }
454
406
  if (!tunnel) {
455
- console.log(`Tunnel not found after retries: ${tunnelId}`);
456
407
  return res.status(404).send("Tunnel not found");
457
408
  }
409
+ // High-frequency optimization: Check WebSocket buffer and apply backpressure
410
+ if (tunnel.ws.bufferedAmount > 1024 * 1024) {
411
+ return res.status(503).json({ error: "Service Unavailable", message: "Tunnel high load" });
412
+ }
413
+ // Check concurrent request limit per tunnel
414
+ const currentCount = this.tunnelRequestCount.get(tunnelId) || 0;
415
+ if (currentCount >= this.MAX_CONCURRENT_REQUESTS_PER_TUNNEL) {
416
+ return res.status(503).json({ error: "Service Unavailable", message: "Too many concurrent requests" });
417
+ }
418
+ this.tunnelRequestCount.set(tunnelId, currentCount + 1);
458
419
  // Check for SSE request
459
420
  if (req.headers.accept && req.headers.accept.includes('text/event-stream')) {
460
421
  return this.handleSSERequest(req, res, tunnelId, tunnel);
461
422
  }
462
423
  // Handle regular HTTP request
463
- const requestId = (0, uuid_1.v4)();
424
+ const requestId = fastId();
464
425
  const startTime = Date.now();
465
- this.pendingRequests.set(requestId, { res, startTime });
466
- const requestMessage = {
426
+ // Set a timeout for the request (30 seconds)
427
+ const REQUEST_TIMEOUT = 30000;
428
+ const timeoutId = setTimeout(() => {
429
+ const pendingRequest = this.pendingRequests.get(requestId);
430
+ if (pendingRequest) {
431
+ const currentCount = this.tunnelRequestCount.get(tunnelId) || 1;
432
+ this.tunnelRequestCount.set(tunnelId, Math.max(0, currentCount - 1));
433
+ this.pendingRequests.delete(requestId);
434
+ if (!res.headersSent) {
435
+ res.status(504).json({ error: "Gateway Timeout", message: "Request timed out" });
436
+ }
437
+ }
438
+ }, REQUEST_TIMEOUT);
439
+ this.pendingRequests.set(requestId, { res, startTime, tunnelId, timeoutId });
440
+ tunnel.ws.send(JSON.stringify({
467
441
  type: "request",
468
442
  id: requestId,
469
443
  method: req.method,
470
444
  path: req.url,
471
445
  headers: req.headers,
472
- body: req.method !== "GET" && req.body
446
+ body: req.method !== "GET" && req.method !== "HEAD" && req.body
473
447
  ? req.body.toString("base64")
474
448
  : undefined,
475
- };
476
- tunnel.ws.send(JSON.stringify(requestMessage));
477
- console.log(`Request forwarded: ${requestId}, Method: ${req.method}, Path: ${req.url}`);
449
+ }));
478
450
  }
479
451
  handleSSERequest(req, res, tunnelId, tunnel) {
480
- // Setup SSE connection
481
- const sseId = (0, uuid_1.v4)();
482
- console.log(`[SSE E2E] Server: Establishing SSE connection ${sseId} from browser to ${req.url}`);
452
+ const sseId = fastId();
453
+ // Optimize TCP socket for low-latency streaming
454
+ const socket = req.socket || req.connection;
455
+ if (socket && socket.setNoDelay) {
456
+ socket.setNoDelay(true); // Disable Nagle's algorithm for immediate transmission
457
+ }
483
458
  // Set SSE headers with anti-buffering directives
484
459
  // Note: Do NOT set 'Connection: keep-alive' - it's forbidden in HTTP/2 and will cause immediate disconnect
485
460
  res.writeHead(200, {
@@ -490,113 +465,109 @@ class DainTunnelServer {
490
465
  // Flush headers immediately to establish SSE connection
491
466
  // Without this, headers may be buffered, delaying connection setup
492
467
  res.flushHeaders();
493
- // Send initial connection event
494
- res.write('\n');
468
+ // Send SSE comment to keep connection alive and verify it's working
469
+ // Comments start with : and are ignored by EventSource but flush the buffer
470
+ res.write(': connected\n\n');
495
471
  // Flush the initial write
496
472
  flushSSE(res);
497
- // Store the SSE connection
498
- this.sseConnections.set(sseId, {
499
- req,
500
- res,
501
- id: sseId,
502
- tunnelId
503
- });
504
- console.log(`[SSE E2E] Server→Client: Forwarding SSE connection request ${sseId} to tunnel client`);
505
- // Notify the tunnel client about the new SSE connection
473
+ this.sseConnections.set(sseId, { req, res, id: sseId, tunnelId });
506
474
  tunnel.ws.send(JSON.stringify({
507
475
  type: "sse_connection",
508
476
  id: sseId,
509
477
  path: req.url,
510
- method: req.method, // Include HTTP method for POST tool calls
478
+ method: req.method,
511
479
  headers: req.headers,
512
480
  body: req.method !== "GET" && req.body ? req.body.toString("base64") : undefined
513
481
  }));
514
- // Handle client disconnect
515
482
  req.on('close', () => {
516
- console.log(`[SSE E2E] Server: Browser disconnected from SSE ${sseId}`);
517
- // DO NOT immediately send sse_close to tunnel client!
518
- // The local service may still be processing and sending SSE events.
519
- // Instead, mark this connection as "client disconnected" and let the
520
- // local service finish naturally. We'll clean up when we receive the
521
- // final "close" event from the service, or when the connection errors.
522
483
  const connection = this.sseConnections.get(sseId);
523
484
  if (connection) {
524
- // Mark as client disconnected, but keep the tunnel client connection alive
525
485
  connection.clientDisconnected = true;
526
- console.log(`[SSE E2E] Server: Marked ${sseId} as browser-disconnected (service can still finish)`);
486
+ // Clean up after a short delay to allow any pending events to be handled
487
+ setTimeout(() => {
488
+ if (this.sseConnections.has(sseId)) {
489
+ // Decrement request counter for backpressure tracking
490
+ const currentCount = this.tunnelRequestCount.get(tunnelId) || 1;
491
+ this.tunnelRequestCount.set(tunnelId, Math.max(0, currentCount - 1));
492
+ this.sseConnections.delete(sseId);
493
+ // Notify tunnel client to close the connection
494
+ const tunnel = this.tunnels.get(tunnelId);
495
+ if (tunnel && tunnel.ws.readyState === ws_1.default.OPEN) {
496
+ tunnel.ws.send(JSON.stringify({ type: "sse_close", id: sseId }));
497
+ }
498
+ }
499
+ }, 100);
527
500
  }
528
501
  });
529
502
  }
530
503
  removeTunnel(ws) {
531
504
  try {
532
- // Clear any interval timer
533
- if (ws.keepAliveInterval) {
505
+ if (ws.keepAliveInterval)
534
506
  clearInterval(ws.keepAliveInterval);
535
- }
536
- // If we saved tunnelId on the WebSocket object, use it for faster lookup
537
507
  const tunnelId = ws.tunnelId;
538
508
  let removedTunnelId = tunnelId;
539
509
  if (tunnelId && this.tunnels.has(tunnelId)) {
540
510
  const tunnel = this.tunnels.get(tunnelId);
541
- if (tunnel && tunnel.ws === ws) {
511
+ if (tunnel && tunnel.ws === ws)
542
512
  this.tunnels.delete(tunnelId);
543
- console.log(`Tunnel removed using stored ID: ${tunnelId}`);
544
- }
545
513
  }
546
514
  else {
547
- // Fall back to iterating through all tunnels
548
515
  removedTunnelId = undefined;
549
516
  for (const [id, tunnel] of this.tunnels.entries()) {
550
517
  if (tunnel.ws === ws) {
551
518
  this.tunnels.delete(id);
552
519
  removedTunnelId = id;
553
- console.log(`Tunnel removed: ${id}`);
554
520
  break;
555
521
  }
556
522
  }
557
523
  }
558
524
  if (removedTunnelId) {
559
- console.log(`Tunnel ${removedTunnelId} removed. Current tunnels: ${Array.from(this.tunnels.keys()).join(', ')}`);
525
+ this.tunnelRequestCount.delete(removedTunnelId);
526
+ // Close all pending HTTP requests associated with this tunnel
527
+ for (const [requestId, pendingRequest] of this.pendingRequests.entries()) {
528
+ if (pendingRequest.tunnelId === removedTunnelId) {
529
+ try {
530
+ if (pendingRequest.timeoutId)
531
+ clearTimeout(pendingRequest.timeoutId);
532
+ if (!pendingRequest.res.headersSent) {
533
+ pendingRequest.res.status(502).json({ error: "Bad Gateway", message: "Tunnel closed" });
534
+ }
535
+ }
536
+ catch (error) { /* ignore */ }
537
+ this.pendingRequests.delete(requestId);
538
+ }
539
+ }
560
540
  // Close all SSE connections associated with this tunnel
561
541
  for (const [sseId, sseConnection] of this.sseConnections.entries()) {
562
542
  if (sseConnection.tunnelId === removedTunnelId) {
563
543
  try {
564
544
  sseConnection.res.end();
565
545
  }
566
- catch (error) {
567
- console.error(`Error closing SSE connection ${sseId}:`, error);
568
- }
546
+ catch (error) { /* ignore */ }
569
547
  this.sseConnections.delete(sseId);
570
548
  }
571
549
  }
572
550
  // Close all WebSocket connections associated with this tunnel
573
551
  for (const [wsId, wsConnection] of this.wsConnections.entries()) {
574
- const wsPath = wsConnection.path;
575
- if (wsPath.startsWith(`/${removedTunnelId}/`)) {
552
+ if (wsConnection.path.startsWith(`/${removedTunnelId}/`)) {
576
553
  try {
577
554
  wsConnection.clientSocket.close(1001, "Tunnel closed");
578
555
  }
579
- catch (error) {
580
- console.error(`Error closing WebSocket connection ${wsId}:`, error);
581
- }
556
+ catch (error) { /* ignore */ }
582
557
  this.wsConnections.delete(wsId);
583
558
  }
584
559
  }
585
560
  }
586
- else {
587
- console.log(`No tunnel found to remove for the closed WebSocket connection`);
588
- }
589
- // Also remove any pending challenges for this WebSocket
561
+ // Remove any pending challenges for this WebSocket
590
562
  for (const [challenge, challengeObj] of this.challenges.entries()) {
591
563
  if (challengeObj.ws === ws) {
592
564
  this.challenges.delete(challenge);
593
- console.log(`Challenge removed for closed WebSocket`);
594
565
  break;
595
566
  }
596
567
  }
597
568
  }
598
569
  catch (error) {
599
- console.error("Error in removeTunnel:", error);
570
+ console.error("[Tunnel] Remove error:", error);
600
571
  }
601
572
  }
602
573
  async start() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dainprotocol/tunnel",
3
- "version": "1.1.13",
3
+ "version": "1.1.16",
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
+ }