@remotelinker/reverse-ws-tunnel 1.0.7 → 1.0.9

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.
package/README.md CHANGED
@@ -30,6 +30,20 @@ Reverse WebSocket Tunnel is a library that enables you to expose local services
30
30
 
31
31
  ---
32
32
 
33
+ ## ✨ v1.0.9 - What's New
34
+
35
+ ### 🐛 Bug Fixes
36
+ - **Message Format Standardization**: Fixed inconsistent message formats between server components
37
+ - **Ping/Pong Reliability**: Resolved issues with application-level heartbeat failing during data transfer
38
+ - **Connection Stability**: Improved connection handling and reduced timeout issues
39
+
40
+ ### 🔧 Technical Improvements
41
+ - **Unified Message Protocol**: All server messages now use consistent `buildMessageBuffer` format
42
+ - **Simplified Client Architecture**: Removed hybrid parsing logic for better maintainability
43
+ - **Enhanced Buffer Management**: Improved message buffering and parsing reliability
44
+
45
+ ---
46
+
33
47
  ## 📦 Installation
34
48
 
35
49
  ```bash
@@ -9,12 +9,17 @@ const packageJson = require('../package.json');
9
9
  const RECONNECT_INTERVAL = 5000;
10
10
  const MESSAGE_TYPE_CONFIG = 0x01;
11
11
  const MESSAGE_TYPE_DATA = 0x02;
12
+ const MESSAGE_TYPE_APP_PING = 0x03;
13
+ const MESSAGE_TYPE_APP_PONG = 0x04;
12
14
  const clients = {};
13
15
  const PING_INTERVAL = 30 * 1000; //30s
14
16
  const PONG_WAIT = 5 * 1000; //5s
17
+ const APP_PING_INTERVAL = 20 * 1000; // 20 secondi
18
+ const HEALTH_TIMEOUT = 45 * 1000; // 45 secondi sliding window
19
+ const RECONNECT_BACKOFF = [1000, 2000, 5000, 10000, 30000]; // Backoff progressivo
15
20
 
16
21
  /**
17
- * Starts the WebSocket tunnel client.
22
+ * Starts WebSocket tunnel client.
18
23
  * @param {Object} config - Configuration for tunnel.
19
24
  */
20
25
  function connectWebSocket(config) {
@@ -23,7 +28,12 @@ function connectWebSocket(config) {
23
28
  const eventEmitter = new EventEmitter();
24
29
  let ws;
25
30
  let pingInterval;
31
+ let appPingInterval;
32
+ let healthMonitor;
26
33
  let isClosed = false;
34
+ let reconnectAttempt = 0;
35
+ let pingSeq = 0;
36
+ let lastPongTs = Date.now();
27
37
 
28
38
  if (!tunnelId) {
29
39
  throw new Error(`Missing mandatory tunnelId`);
@@ -49,6 +59,10 @@ function connectWebSocket(config) {
49
59
  eventEmitter.emit('connected');
50
60
  ({ pingInterval } = heartBeat(ws));
51
61
 
62
+ // Avviare heartbeat applicativo
63
+ appPingInterval = startAppHeartbeat(ws, tunnelId, { pingSeq: () => pingSeq, incPingSeq: () => pingSeq++ });
64
+ healthMonitor = startHealthMonitor(ws, tunnelId, { lastPongTs: () => lastPongTs, setLastPongTs: (ts) => lastPongTs = ts });
65
+
52
66
  const uuid = uuidv4();
53
67
  const payload = {
54
68
  TARGET_URL: targetUrl,
@@ -64,29 +78,62 @@ function connectWebSocket(config) {
64
78
  ws.send(message);
65
79
  });
66
80
 
81
+ let messageBuffer = Buffer.alloc(0);
82
+
67
83
  ws.on('message', (data) => {
68
- const uuid = data.slice(0, 36).toString();
69
- const type = data.readUInt8(36);
70
- const payload = data.slice(37);
84
+ logger.trace(`Received message chunk: ${data.length} bytes`);
85
+ messageBuffer = Buffer.concat([messageBuffer, data]);
86
+
87
+ while (messageBuffer.length >= 4) {
88
+ const length = messageBuffer.readUInt32BE(0);
89
+ if (messageBuffer.length < 4 + length) {
90
+ logger.trace(`Waiting for more data: need ${4 + length} bytes, have ${messageBuffer.length}`);
91
+ break;
92
+ }
93
+
94
+ const message = messageBuffer.slice(4, 4 + length);
95
+ messageBuffer = messageBuffer.slice(4 + length);
96
+
97
+ const messageTunnelId = message.slice(0, 36).toString();
98
+ const uuid = message.slice(36, 72).toString();
99
+ const type = message.readUInt8(72);
100
+ const payload = message.slice(73);
71
101
 
72
- logger.trace(`Received WS message for uuid=${uuid}, type=${type}, length=${payload.length}`);
102
+ logger.trace(`Received WS message for uuid=${uuid}, type=${type}, length=${payload.length}`);
73
103
 
74
- if (type === MESSAGE_TYPE_DATA) {
75
- if (payload.toString() === 'CLOSE') {
76
- logger.debug(`Received CLOSE for uuid=${uuid}`);
77
- if (clients[uuid]) {
78
- clients[uuid].end();
104
+ if (type === MESSAGE_TYPE_DATA) {
105
+ if (payload.toString() === 'CLOSE') {
106
+ logger.debug(`Received CLOSE for uuid=${uuid}`);
107
+ if (clients[uuid]) {
108
+ clients[uuid].end();
109
+ }
110
+ return;
79
111
  }
80
- return;
81
- }
82
112
 
83
- const client = clients[uuid] || createTcpClient(targetUrl, targetPort, ws, tunnelId, uuid);
113
+ const client = clients[uuid] || createTcpClient(targetUrl, targetPort, ws, tunnelId, uuid);
114
+
115
+ if (!client.write(payload)) {
116
+ logger.debug(`Backpressure on TCP socket for uuid=${uuid}`);
117
+ client.once('drain', () => {
118
+ logger.info(`TCP socket drained for uuid=${uuid}`);
119
+ });
120
+ }
121
+ return;
84
122
 
85
- if (!client.write(payload)) {
86
- logger.debug(`Backpressure on TCP socket for uuid=${uuid}`);
87
- client.once('drain', () => {
88
- logger.info(`TCP socket drained for uuid=${uuid}`);
89
- });
123
+ } else if (type === MESSAGE_TYPE_APP_PONG) {
124
+ try {
125
+ const pongData = JSON.parse(payload.toString());
126
+ // Accetta solo pong con seq >= pingSeq - 10 (finestra di 10 ping)
127
+ if (pongData.seq >= (pingSeq - 10)) {
128
+ lastPongTs = Date.now();
129
+ logger.trace(`App pong received: seq=${pongData.seq}`);
130
+ } else {
131
+ logger.debug(`Ignoring old pong: seq=${pongData.seq}`);
132
+ }
133
+ } catch (err) {
134
+ logger.error(`Invalid app pong format: ${err.message}`);
135
+ }
136
+ return;
90
137
  }
91
138
  }
92
139
  });
@@ -95,6 +142,8 @@ function connectWebSocket(config) {
95
142
  logger.warn('WebSocket connection closed. Cleaning up clients.');
96
143
  eventEmitter.emit('disconnected');
97
144
  clearInterval(pingInterval);
145
+ clearInterval(appPingInterval);
146
+ clearInterval(healthMonitor);
98
147
 
99
148
  for (const uuid in clients) {
100
149
  logger.debug(`Closing TCP connection for uuid=${uuid}`);
@@ -104,8 +153,12 @@ function connectWebSocket(config) {
104
153
  }
105
154
 
106
155
  if (!isClosed && autoReconnect) {
107
- logger.info(`Reconnecting in ${RECONNECT_INTERVAL / 1000}s...`);
108
- setTimeout(connect, RECONNECT_INTERVAL);
156
+ const delay = RECONNECT_BACKOFF[reconnectAttempt] || RECONNECT_BACKOFF[RECONNECT_BACKOFF.length - 1];
157
+ logger.info(`Reconnecting in ${delay / 1000}s (attempt ${reconnectAttempt + 1})`);
158
+ setTimeout(() => {
159
+ reconnectAttempt = Math.min(reconnectAttempt + 1, RECONNECT_BACKOFF.length);
160
+ connect();
161
+ }, delay);
109
162
  }
110
163
  });
111
164
 
@@ -151,7 +204,7 @@ function heartBeat(ws) {
151
204
  }
152
205
 
153
206
  /**
154
- * Creates a TCP connection to the target service.
207
+ * Creates a TCP connection to target service.
155
208
  */
156
209
  function createTcpClient(targetUrl, targetPort, ws, tunnelId, uuid) {
157
210
  const hostname = new URL(targetUrl).hostname;
@@ -184,6 +237,42 @@ function createTcpClient(targetUrl, targetPort, ws, tunnelId, uuid) {
184
237
  return client;
185
238
  }
186
239
 
240
+ /**
241
+ * Starts the application-level heartbeat (ping every 20 seconds)
242
+ */
243
+ function startAppHeartbeat(ws, tunnelId, pingState) {
244
+ return setInterval(() => {
245
+ if (ws.readyState === WebSocket.OPEN) {
246
+ pingState.incPingSeq();
247
+ const currentPingSeq = pingState.pingSeq();
248
+ const pingData = JSON.stringify({
249
+ type: 'ping',
250
+ seq: currentPingSeq,
251
+ ts: Date.now()
252
+ });
253
+
254
+ const message = buildMessageBuffer(tunnelId, uuidv4(), MESSAGE_TYPE_APP_PING, pingData);
255
+ ws.send(message);
256
+
257
+ logger.trace(`App ping sent: seq=${currentPingSeq}`);
258
+ }
259
+ }, APP_PING_INTERVAL);
260
+ }
261
+
262
+ /**
263
+ * Starts health monitoring with sliding window timeout
264
+ */
265
+ function startHealthMonitor(ws, tunnelId, pongState) {
266
+ return setInterval(() => {
267
+ const now = Date.now();
268
+ const currentLastPongTs = pongState.lastPongTs();
269
+ if (now - currentLastPongTs > HEALTH_TIMEOUT) {
270
+ logger.warn(`Health timeout exceeded (${HEALTH_TIMEOUT}ms) - terminating connection`);
271
+ ws.terminate();
272
+ }
273
+ }, 5000); // Check every 5 seconds
274
+ }
275
+
187
276
  function resetClients() { // for testing
188
277
  for (const key in clients) {
189
278
  delete clients[key];
@@ -193,4 +282,4 @@ function resetClients() { // for testing
193
282
  module.exports = {
194
283
  connectWebSocket,
195
284
  resetClients, // for testing
196
- };
285
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remotelinker/reverse-ws-tunnel",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "description": "A Node.js library for creating secure reverse tunnels over WebSocket connections",
5
5
  "main": "index.cjs",
6
6
  "types": "types/index.d.ts",
@@ -4,4 +4,6 @@ module.exports = {
4
4
  RECONNECT_INTERVAL: 1000 * 5,
5
5
  MESSAGE_TYPE_CONFIG: 0x01,
6
6
  MESSAGE_TYPE_DATA: 0x02,
7
+ MESSAGE_TYPE_APP_PING: 0x03,
8
+ MESSAGE_TYPE_APP_PONG: 0x04,
7
9
  };
@@ -1,7 +1,8 @@
1
1
  const state = require('./state');
2
- const { MESSAGE_TYPE_CONFIG, MESSAGE_TYPE_DATA } = require('./constants');
2
+ const { MESSAGE_TYPE_CONFIG, MESSAGE_TYPE_DATA, MESSAGE_TYPE_APP_PING, MESSAGE_TYPE_APP_PONG } = require('./constants');
3
3
  const { startTCPServer } = require('./tcpServer');
4
4
  const { logger } = require('../utils/logger');
5
+ const { buildMessageBuffer } = require('../client/utils');
5
6
 
6
7
  /**
7
8
  * Handles a parsed WebSocket message.
@@ -65,6 +66,25 @@ function handleParsedMessage(ws, tunnelId, uuid, type, payload, tunnelIdHeaderNa
65
66
  return;
66
67
  }
67
68
 
69
+ // Handle MESSAGE_TYPE_APP_PING
70
+ if (type === MESSAGE_TYPE_APP_PING) {
71
+ try {
72
+ const pingData = JSON.parse(payload.toString());
73
+ const pongData = JSON.stringify({
74
+ type: 'pong',
75
+ seq: pingData.seq
76
+ });
77
+
78
+ const pongMessage = buildMessageBuffer(tunnelId, uuid, MESSAGE_TYPE_APP_PONG, pongData);
79
+ ws.send(pongMessage);
80
+
81
+ logger.trace(`App pong sent: seq=${pingData.seq} for tunnel ${tunnelId}`);
82
+ } catch (err) {
83
+ logger.error(`Invalid app ping format for tunnel ${tunnelId}: ${err.message}`);
84
+ }
85
+ return;
86
+ }
87
+
68
88
  // Handle MESSAGE_TYPE_DATA
69
89
  const tunnel = state[port]?.websocketTunnels?.[tunnelId];
70
90
 
@@ -5,6 +5,7 @@ const { HTTPParser, methods } = require('http-parser-js');
5
5
  const state = require('./state');
6
6
  const { MESSAGE_TYPE_DATA } = require('./constants');
7
7
  const { logger } = require('../utils/logger');
8
+ const { buildMessageBuffer } = require('../client/utils');
8
9
 
9
10
  function startTCPServer(port, tunnelIdHeaderName, websocketPort) {
10
11
  const wsPortKey = String(websocketPort);
@@ -60,7 +61,8 @@ function startTCPServer(port, tunnelIdHeaderName, websocketPort) {
60
61
  isWebSocket = headers['upgrade']?.toLowerCase() === 'websocket';
61
62
 
62
63
  logger.trace(`Sending initial headers (${rawHeaders.length} bytes) to tunnel [${currentTunnelId}]`);
63
- tunnel.ws.send(Buffer.concat([uuidBuffer, Buffer.from([MESSAGE_TYPE_DATA]), Buffer.from(rawHeaders)]));
64
+ const message = buildMessageBuffer(currentTunnelId, uuid, MESSAGE_TYPE_DATA, rawHeaders);
65
+ tunnel.ws.send(message);
64
66
 
65
67
  if (isWebSocket) parser.close();
66
68
  };
@@ -70,7 +72,8 @@ function startTCPServer(port, tunnelIdHeaderName, websocketPort) {
70
72
  if (tunnel?.ws && !isWebSocket) {
71
73
  const body = chunk.slice(offset, offset + length);
72
74
  logger.trace(`Forwarding body (${body.length} bytes) to tunnel [${currentTunnelId}]`);
73
- tunnel.ws.send(Buffer.concat([uuidBuffer, Buffer.from([MESSAGE_TYPE_DATA]), body]));
75
+ const message = buildMessageBuffer(currentTunnelId, uuid, MESSAGE_TYPE_DATA, body);
76
+ tunnel.ws.send(message);
74
77
  }
75
78
  };
76
79
 
@@ -91,7 +94,8 @@ function startTCPServer(port, tunnelIdHeaderName, websocketPort) {
91
94
  if (isWebSocket) {
92
95
  if (tunnel?.ws) {
93
96
  logger.trace(`Forwarding WebSocket TCP data (${chunk.length} bytes) for tunnel [${currentTunnelId}]`);
94
- tunnel.ws.send(Buffer.concat([uuidBuffer, Buffer.from([MESSAGE_TYPE_DATA]), chunk]));
97
+ const message = buildMessageBuffer(currentTunnelId, uuid, MESSAGE_TYPE_DATA, chunk);
98
+ tunnel.ws.send(message);
95
99
  }
96
100
  } else {
97
101
  try {
@@ -107,7 +111,8 @@ function startTCPServer(port, tunnelIdHeaderName, websocketPort) {
107
111
  const tunnel = state[wsPortKey]?.websocketTunnels?.[currentTunnelId];
108
112
  if (tunnel?.ws) {
109
113
  logger.debug(`TCP socket end for tunnel [${currentTunnelId}] (uuid: ${uuid})`);
110
- tunnel.ws.send(Buffer.concat([uuidBuffer, Buffer.from([MESSAGE_TYPE_DATA]), Buffer.from('CLOSE')]));
114
+ const message = buildMessageBuffer(currentTunnelId, uuid, MESSAGE_TYPE_DATA, 'CLOSE');
115
+ tunnel.ws.send(message);
111
116
  }
112
117
  });
113
118