@dainprotocol/tunnel 1.1.31 → 1.1.35

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.
@@ -9,9 +9,12 @@ declare class DainTunnel extends EventEmitter {
9
9
  private tunnelId;
10
10
  private secret;
11
11
  private webSocketClients;
12
+ private pendingWebSocketMessages;
12
13
  private sseClients;
13
14
  private httpAgent;
14
15
  private heartbeatInterval;
16
+ private reconnectTimer;
17
+ private isStopping;
15
18
  constructor(serverUrl: string, apiKey: string);
16
19
  private signChallenge;
17
20
  private safeSend;
@@ -25,10 +28,17 @@ declare class DainTunnel extends EventEmitter {
25
28
  private handleRequest;
26
29
  private handleWebSocketConnection;
27
30
  private handleWebSocketMessage;
31
+ private flushPendingWebSocketMessages;
32
+ private cleanupWebSocketConnection;
28
33
  private handleSSEConnection;
29
34
  private handleSSEClose;
30
35
  private forwardRequest;
31
36
  private attemptReconnect;
37
+ /**
38
+ * Reset reconnection attempts counter.
39
+ * Call this to allow reconnection after max_reconnect_attempts was reached.
40
+ */
41
+ resetReconnection(): void;
32
42
  stop(): Promise<void>;
33
43
  }
34
44
  export { DainTunnel };
@@ -14,12 +14,35 @@ const TIMEOUTS = {
14
14
  REQUEST: 25000,
15
15
  CONNECTION: 10000,
16
16
  CHALLENGE: 5000,
17
- RECONNECT_DELAY: 5000,
18
17
  SHUTDOWN_GRACE: 500,
19
18
  };
20
19
  const RECONNECT = {
21
- MAX_ATTEMPTS: 5,
20
+ MAX_ATTEMPTS: 10, // Increase from 5
21
+ BASE_DELAY: 1000, // Start at 1s
22
+ MAX_DELAY: 30000, // Cap at 30s
23
+ JITTER: 0.3, // 30% randomization
22
24
  };
25
+ const LIMITS = {
26
+ MAX_PENDING_WS_MESSAGES_PER_CONNECTION: 256,
27
+ };
28
+ function rawDataToString(message) {
29
+ if (typeof message === "string")
30
+ return message;
31
+ if (Buffer.isBuffer(message))
32
+ return message.toString("utf8");
33
+ if (Array.isArray(message))
34
+ return Buffer.concat(message).toString("utf8");
35
+ return Buffer.from(message).toString("utf8");
36
+ }
37
+ function rawDataToBuffer(data) {
38
+ if (Buffer.isBuffer(data))
39
+ return data;
40
+ if (Array.isArray(data))
41
+ return Buffer.concat(data);
42
+ if (typeof data === "string")
43
+ return Buffer.from(data, "utf8");
44
+ return Buffer.from(data);
45
+ }
23
46
  class DainTunnel extends events_1.EventEmitter {
24
47
  constructor(serverUrl, apiKey) {
25
48
  super();
@@ -29,8 +52,11 @@ class DainTunnel extends events_1.EventEmitter {
29
52
  this.port = null;
30
53
  this.reconnectAttempts = 0;
31
54
  this.webSocketClients = new Map();
55
+ this.pendingWebSocketMessages = new Map();
32
56
  this.sseClients = new Map();
33
57
  this.heartbeatInterval = null;
58
+ this.reconnectTimer = null;
59
+ this.isStopping = false;
34
60
  const parsed = (0, auth_1.parseAPIKey)(apiKey);
35
61
  if (!parsed) {
36
62
  throw new Error('Invalid API key format. Expected: sk_agent_{agentId}_{orgId}_{secret}');
@@ -82,6 +108,7 @@ class DainTunnel extends events_1.EventEmitter {
82
108
  }
83
109
  }
84
110
  async start(port) {
111
+ this.isStopping = false;
85
112
  this.port = port;
86
113
  return this.connect();
87
114
  }
@@ -128,7 +155,7 @@ class DainTunnel extends events_1.EventEmitter {
128
155
  });
129
156
  this.ws.on("message", (data) => {
130
157
  try {
131
- this.handleMessage(JSON.parse(data), (url) => finish(url));
158
+ this.handleMessage(JSON.parse(rawDataToString(data)), (url) => finish(url));
132
159
  }
133
160
  catch (err) {
134
161
  finish(undefined, err);
@@ -137,6 +164,9 @@ class DainTunnel extends events_1.EventEmitter {
137
164
  this.ws.on("close", () => {
138
165
  this.stopHeartbeat();
139
166
  this.cleanupAllClients();
167
+ this.ws = null;
168
+ if (this.isStopping)
169
+ return;
140
170
  if (this.tunnelUrl) {
141
171
  this.emit("disconnected");
142
172
  this.attemptReconnect();
@@ -178,6 +208,7 @@ class DainTunnel extends events_1.EventEmitter {
178
208
  catch (_b) { }
179
209
  }
180
210
  this.webSocketClients.clear();
211
+ this.pendingWebSocketMessages.clear();
181
212
  }
182
213
  async requestChallenge() {
183
214
  return new Promise((resolve, reject) => {
@@ -206,7 +237,7 @@ class DainTunnel extends events_1.EventEmitter {
206
237
  };
207
238
  const challengeHandler = (message) => {
208
239
  try {
209
- const data = JSON.parse(message);
240
+ const data = JSON.parse(rawDataToString(message));
210
241
  if (data.type === "challenge") {
211
242
  finish(data.challenge);
212
243
  }
@@ -281,17 +312,21 @@ class DainTunnel extends events_1.EventEmitter {
281
312
  const client = new ws_1.default(`ws://localhost:${this.port}${message.path}`, {
282
313
  headers
283
314
  });
315
+ this.pendingWebSocketMessages.set(message.id, []);
284
316
  this.webSocketClients.set(message.id, client);
317
+ client.on('open', () => {
318
+ this.flushPendingWebSocketMessages(message.id, sendWsEvent);
319
+ });
285
320
  client.on('message', (data) => {
286
- sendWsEvent('message', data.toString('base64'));
321
+ sendWsEvent('message', rawDataToBuffer(data).toString('base64'));
287
322
  });
288
323
  client.on('close', () => {
289
324
  sendWsEvent('close');
290
- this.webSocketClients.delete(message.id);
325
+ this.cleanupWebSocketConnection(message.id);
291
326
  });
292
327
  client.on('error', (error) => {
293
328
  sendWsEvent('error', error.message);
294
- this.webSocketClients.delete(message.id);
329
+ this.cleanupWebSocketConnection(message.id);
295
330
  });
296
331
  this.emit("websocket_connection", { id: message.id, path: message.path });
297
332
  }
@@ -305,18 +340,57 @@ class DainTunnel extends events_1.EventEmitter {
305
340
  return;
306
341
  switch (message.event) {
307
342
  case 'message':
308
- if (message.data && client.readyState === ws_1.default.OPEN) {
343
+ if (!message.data)
344
+ return;
345
+ if (client.readyState === ws_1.default.OPEN) {
309
346
  client.send(Buffer.from(message.data, 'base64'));
347
+ return;
348
+ }
349
+ if (client.readyState !== ws_1.default.CONNECTING)
350
+ return;
351
+ const pending = this.pendingWebSocketMessages.get(message.id);
352
+ if (!pending)
353
+ return;
354
+ if (pending.length >= LIMITS.MAX_PENDING_WS_MESSAGES_PER_CONNECTION) {
355
+ client.close(1013, "WebSocket upstream overloaded");
356
+ this.cleanupWebSocketConnection(message.id);
357
+ return;
310
358
  }
359
+ pending.push(Buffer.from(message.data, 'base64'));
311
360
  break;
312
361
  case 'close':
313
- if (client.readyState === ws_1.default.OPEN) {
362
+ if (client.readyState === ws_1.default.OPEN || client.readyState === ws_1.default.CONNECTING) {
314
363
  client.close();
315
364
  }
316
- this.webSocketClients.delete(message.id);
365
+ this.cleanupWebSocketConnection(message.id);
366
+ break;
367
+ case 'error':
368
+ if (client.readyState === ws_1.default.OPEN || client.readyState === ws_1.default.CONNECTING) {
369
+ client.close(1011, message.data);
370
+ }
371
+ this.cleanupWebSocketConnection(message.id);
317
372
  break;
318
373
  }
319
374
  }
375
+ flushPendingWebSocketMessages(id, sendWsEvent) {
376
+ const client = this.webSocketClients.get(id);
377
+ const pending = this.pendingWebSocketMessages.get(id);
378
+ if (!client || !pending || pending.length === 0)
379
+ return;
380
+ try {
381
+ while (pending.length > 0 && client.readyState === ws_1.default.OPEN) {
382
+ client.send(pending.shift());
383
+ }
384
+ }
385
+ catch (error) {
386
+ sendWsEvent("error", error.message);
387
+ this.cleanupWebSocketConnection(id);
388
+ }
389
+ }
390
+ cleanupWebSocketConnection(id) {
391
+ this.pendingWebSocketMessages.delete(id);
392
+ this.webSocketClients.delete(id);
393
+ }
320
394
  handleSSEConnection(message) {
321
395
  const sendSseEvent = (event, data = '') => {
322
396
  this.safeSend({ type: 'sse', id: message.id, event, data });
@@ -339,12 +413,17 @@ class DainTunnel extends events_1.EventEmitter {
339
413
  headers,
340
414
  agent: this.httpAgent,
341
415
  }, (res) => {
416
+ const cleanup = () => {
417
+ this.sseClients.delete(message.id);
418
+ };
342
419
  if (res.statusCode !== 200) {
343
420
  let errorBody = '';
344
421
  res.on('data', (chunk) => { errorBody += chunk.toString(); });
345
422
  res.on('end', () => {
346
423
  sendSseEvent('error', `Status ${res.statusCode}: ${errorBody.substring(0, 200)}`);
424
+ cleanup();
347
425
  });
426
+ res.on('close', cleanup);
348
427
  return;
349
428
  }
350
429
  const socket = res.socket || res.connection;
@@ -390,6 +469,12 @@ class DainTunnel extends events_1.EventEmitter {
390
469
  processBuffer();
391
470
  }
392
471
  sendSseEvent('close');
472
+ cleanup();
473
+ });
474
+ res.on('close', cleanup);
475
+ res.on('error', (error) => {
476
+ sendSseEvent('error', error.message);
477
+ cleanup();
393
478
  });
394
479
  });
395
480
  req.on('socket', (socket) => {
@@ -398,12 +483,16 @@ class DainTunnel extends events_1.EventEmitter {
398
483
  });
399
484
  req.on('error', (error) => {
400
485
  sendSseEvent('error', error.message);
486
+ this.sseClients.delete(message.id);
487
+ });
488
+ req.on('close', () => {
489
+ this.sseClients.delete(message.id);
401
490
  });
491
+ this.sseClients.set(message.id, req);
402
492
  if (message.body && message.method !== 'GET') {
403
493
  req.write(Buffer.from(message.body, 'base64'));
404
494
  }
405
495
  req.end();
406
- this.sseClients.set(message.id, req);
407
496
  this.emit("sse_connection", { id: message.id, path: message.path });
408
497
  }
409
498
  catch (error) {
@@ -486,22 +575,44 @@ class DainTunnel extends events_1.EventEmitter {
486
575
  });
487
576
  }
488
577
  attemptReconnect() {
489
- if (this.reconnectAttempts < RECONNECT.MAX_ATTEMPTS) {
490
- this.reconnectAttempts++;
491
- setTimeout(async () => {
492
- try {
493
- await this.connect();
494
- }
495
- catch (_a) { }
496
- }, TIMEOUTS.RECONNECT_DELAY);
497
- }
498
- else {
578
+ if (this.isStopping)
579
+ return;
580
+ if (this.reconnectAttempts >= RECONNECT.MAX_ATTEMPTS) {
499
581
  this.emit("max_reconnect_attempts");
582
+ return;
500
583
  }
584
+ this.reconnectAttempts++;
585
+ // Exponential backoff with jitter
586
+ const baseDelay = Math.min(RECONNECT.BASE_DELAY * Math.pow(2, this.reconnectAttempts - 1), RECONNECT.MAX_DELAY);
587
+ const jitter = baseDelay * RECONNECT.JITTER * (Math.random() - 0.5);
588
+ const delay = Math.round(baseDelay + jitter);
589
+ this.emit("reconnecting", { attempt: this.reconnectAttempts, delay });
590
+ this.reconnectTimer = setTimeout(async () => {
591
+ try {
592
+ await this.connect();
593
+ this.emit("reconnected");
594
+ }
595
+ catch (_a) {
596
+ if (!this.isStopping)
597
+ this.attemptReconnect();
598
+ }
599
+ }, delay);
600
+ }
601
+ /**
602
+ * Reset reconnection attempts counter.
603
+ * Call this to allow reconnection after max_reconnect_attempts was reached.
604
+ */
605
+ resetReconnection() {
606
+ this.reconnectAttempts = 0;
501
607
  }
502
608
  async stop() {
609
+ this.isStopping = true;
503
610
  this.stopHeartbeat();
504
611
  this.cleanupAllClients();
612
+ if (this.reconnectTimer) {
613
+ clearTimeout(this.reconnectTimer);
614
+ this.reconnectTimer = null;
615
+ }
505
616
  if (this.ws) {
506
617
  try {
507
618
  if (this.ws.readyState === ws_1.default.OPEN)
@@ -12,6 +12,8 @@ declare class DainTunnelServer {
12
12
  private tunnelRequestCount;
13
13
  private safeSend;
14
14
  private decrementRequestCount;
15
+ private buildForwardedHeaders;
16
+ private buildTunnelUrl;
15
17
  constructor(hostname: string, port: number);
16
18
  private setupExpressRoutes;
17
19
  private setupWebSocketServer;
@@ -41,6 +41,27 @@ function extractPathParts(url) {
41
41
  const path = qIdx >= 0 ? url.slice(0, qIdx) : url;
42
42
  return path.split('/');
43
43
  }
44
+ function rawDataToString(message) {
45
+ if (typeof message === "string")
46
+ return message;
47
+ if (Buffer.isBuffer(message))
48
+ return message.toString("utf8");
49
+ if (Array.isArray(message))
50
+ return Buffer.concat(message).toString("utf8");
51
+ return Buffer.from(message).toString("utf8");
52
+ }
53
+ function rawDataToBase64(data) {
54
+ if (typeof data === "string")
55
+ return Buffer.from(data, "utf8").toString("base64");
56
+ if (Buffer.isBuffer(data))
57
+ return data.toString("base64");
58
+ if (Array.isArray(data))
59
+ return Buffer.concat(data).toString("base64");
60
+ return Buffer.from(data).toString("base64");
61
+ }
62
+ function normalizeHostToBaseUrl(hostname) {
63
+ return /^https?:\/\//i.test(hostname) ? hostname : `http://${hostname}`;
64
+ }
44
65
  class DainTunnelServer {
45
66
  safeSend(ws, data) {
46
67
  try {
@@ -55,8 +76,29 @@ class DainTunnelServer {
55
76
  return false;
56
77
  }
57
78
  decrementRequestCount(tunnelId) {
58
- const currentCount = this.tunnelRequestCount.get(tunnelId) || 1;
59
- this.tunnelRequestCount.set(tunnelId, Math.max(0, currentCount - 1));
79
+ const currentCount = this.tunnelRequestCount.get(tunnelId);
80
+ if (!currentCount)
81
+ return;
82
+ if (currentCount <= 1) {
83
+ this.tunnelRequestCount.delete(tunnelId);
84
+ return;
85
+ }
86
+ this.tunnelRequestCount.set(tunnelId, currentCount - 1);
87
+ }
88
+ buildForwardedHeaders(headers, tunnelId) {
89
+ const forwardedHeaders = { ...headers };
90
+ if (!forwardedHeaders["x-forwarded-prefix"]) {
91
+ forwardedHeaders["x-forwarded-prefix"] = `/${tunnelId}`;
92
+ }
93
+ return forwardedHeaders;
94
+ }
95
+ buildTunnelUrl(tunnelId) {
96
+ const baseUrl = normalizeHostToBaseUrl(this.hostname);
97
+ const url = new URL(baseUrl);
98
+ if (process.env.SKIP_PORT !== "true" && !url.port) {
99
+ url.port = String(this.port);
100
+ }
101
+ return `${url.toString().replace(/\/$/, "")}/${tunnelId}`;
60
102
  }
61
103
  constructor(hostname, port) {
62
104
  this.hostname = hostname;
@@ -149,7 +191,7 @@ class DainTunnelServer {
149
191
  handleTunnelClientConnection(ws, req) {
150
192
  ws.on("message", (message) => {
151
193
  try {
152
- const data = JSON.parse(message);
194
+ const data = JSON.parse(rawDataToString(message));
153
195
  switch (data.type) {
154
196
  case "challenge_request":
155
197
  this.handleChallengeRequest(ws);
@@ -190,18 +232,19 @@ class DainTunnelServer {
190
232
  return;
191
233
  }
192
234
  const wsConnectionId = fastId();
235
+ const forwardedHeaders = this.buildForwardedHeaders(req.headers, tunnelId);
193
236
  this.wsConnections.set(wsConnectionId, {
194
237
  clientSocket: ws,
195
238
  id: wsConnectionId,
196
239
  path: remainingPath,
197
- headers: req.headers,
240
+ headers: forwardedHeaders,
198
241
  tunnelId
199
242
  });
200
243
  this.safeSend(tunnel.ws, {
201
244
  type: "websocket_connection",
202
245
  id: wsConnectionId,
203
246
  path: remainingPath,
204
- headers: req.headers
247
+ headers: forwardedHeaders
205
248
  });
206
249
  const sendToTunnel = (event, data) => {
207
250
  const currentTunnel = this.tunnels.get(tunnelId);
@@ -209,7 +252,7 @@ class DainTunnelServer {
209
252
  this.safeSend(currentTunnel.ws, { type: "websocket", id: wsConnectionId, event, data });
210
253
  }
211
254
  };
212
- ws.on("message", (data) => sendToTunnel("message", data.toString('base64')));
255
+ ws.on("message", (data) => sendToTunnel("message", rawDataToBase64(data)));
213
256
  ws.on("close", () => { sendToTunnel("close"); this.wsConnections.delete(wsConnectionId); });
214
257
  ws.on("error", (error) => sendToTunnel("error", error.message));
215
258
  }
@@ -291,10 +334,7 @@ class DainTunnelServer {
291
334
  }, TIMEOUTS.PING_INTERVAL);
292
335
  ws.keepAliveInterval = intervalId;
293
336
  ws.on("close", () => clearInterval(intervalId));
294
- let tunnelUrl = this.hostname;
295
- if (process.env.SKIP_PORT !== "true")
296
- tunnelUrl += `:${this.port}`;
297
- tunnelUrl += `/${tunnelId}`;
337
+ const tunnelUrl = this.buildTunnelUrl(tunnelId);
298
338
  ws.send(JSON.stringify({ type: "tunnelUrl", url: tunnelUrl }));
299
339
  console.log(`[Tunnel] Created: ${tunnelUrl}`);
300
340
  }
@@ -389,6 +429,13 @@ class DainTunnelServer {
389
429
  const tunnelId = req.params.tunnelId;
390
430
  if (((_a = req.headers.upgrade) === null || _a === void 0 ? void 0 : _a.toLowerCase()) === 'websocket')
391
431
  return;
432
+ if (!tunnelId || !tunnelId.includes("_")) {
433
+ res.status(404).json({
434
+ error: "Not Found",
435
+ message: `Tunnel "${tunnelId}" does not exist.`,
436
+ });
437
+ return;
438
+ }
392
439
  let tunnel;
393
440
  let retries = LIMITS.TUNNEL_RETRY_COUNT;
394
441
  while (retries > 0 && !tunnel) {
@@ -399,7 +446,6 @@ class DainTunnelServer {
399
446
  }
400
447
  }
401
448
  if (!tunnel) {
402
- this.decrementRequestCount(tunnelId);
403
449
  res.status(502).json({
404
450
  error: "Bad Gateway",
405
451
  message: `Tunnel "${tunnelId}" not connected. The service may be offline or reconnecting.`,
@@ -441,7 +487,7 @@ class DainTunnelServer {
441
487
  id: requestId,
442
488
  method: req.method,
443
489
  path: req.url,
444
- headers: req.headers,
490
+ headers: this.buildForwardedHeaders(req.headers, tunnelId),
445
491
  body: hasBody ? req.body.toString("base64") : undefined,
446
492
  });
447
493
  if (!sent) {
@@ -507,7 +553,7 @@ class DainTunnelServer {
507
553
  id: sseId,
508
554
  path: req.url,
509
555
  method: req.method,
510
- headers: req.headers,
556
+ headers: this.buildForwardedHeaders(req.headers, tunnelId),
511
557
  body: hasBody ? req.body.toString("base64") : undefined
512
558
  });
513
559
  if (!sent) {
@@ -609,7 +655,6 @@ class DainTunnelServer {
609
655
  for (const [challenge, obj] of this.challenges.entries()) {
610
656
  if (obj.ws === ws) {
611
657
  this.challenges.delete(challenge);
612
- break;
613
658
  }
614
659
  }
615
660
  }
@@ -624,7 +669,33 @@ class DainTunnelServer {
624
669
  async stop() {
625
670
  return new Promise((resolve) => {
626
671
  try {
672
+ for (const tunnel of this.tunnels.values()) {
673
+ if (tunnel.ws.keepAliveInterval)
674
+ clearInterval(tunnel.ws.keepAliveInterval);
675
+ try {
676
+ tunnel.ws.terminate();
677
+ }
678
+ catch (error) {
679
+ console.error(`Error closing tunnel ${tunnel.id}:`, error);
680
+ }
681
+ }
682
+ this.tunnels.clear();
683
+ this.tunnelRequestCount.clear();
684
+ for (const [requestId, pending] of this.pendingRequests.entries()) {
685
+ if (pending.timeoutId)
686
+ clearTimeout(pending.timeoutId);
687
+ if (!pending.res.headersSent) {
688
+ try {
689
+ pending.res.status(503).json({ error: "Service Unavailable", message: "Server shutting down" });
690
+ }
691
+ catch (_a) { }
692
+ }
693
+ this.pendingRequests.delete(requestId);
694
+ }
695
+ this.challenges.clear();
627
696
  for (const [sseId, conn] of this.sseConnections.entries()) {
697
+ if (conn.keepAliveInterval)
698
+ clearInterval(conn.keepAliveInterval);
628
699
  try {
629
700
  conn.res.end();
630
701
  }
@@ -642,7 +713,16 @@ class DainTunnelServer {
642
713
  }
643
714
  this.wsConnections.delete(wsId);
644
715
  }
716
+ for (const socket of this.wss.clients) {
717
+ try {
718
+ socket.terminate();
719
+ }
720
+ catch (_b) { }
721
+ }
645
722
  this.wss.close(() => {
723
+ var _a, _b, _c, _d;
724
+ (_b = (_a = this.server).closeIdleConnections) === null || _b === void 0 ? void 0 : _b.call(_a);
725
+ (_d = (_c = this.server).closeAllConnections) === null || _d === void 0 ? void 0 : _d.call(_c);
646
726
  this.server.close(() => resolve());
647
727
  });
648
728
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dainprotocol/tunnel",
3
- "version": "1.1.31",
3
+ "version": "1.1.35",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "private": false,
@@ -20,7 +20,7 @@
20
20
  "author": "Ryan",
21
21
  "license": "ISC",
22
22
  "dependencies": {
23
- "@dainprotocol/service-sdk": "2.0.75",
23
+ "@dainprotocol/service-sdk": "2.0.82",
24
24
  "@types/body-parser": "^1.19.5",
25
25
  "@types/cors": "^2.8.17",
26
26
  "@types/eventsource": "^3.0.0",