Building a HTTP Tunnel with WebSocket and Node.JS Stream
Tags: node, webdev, tunnel, javascript
Published: 2022-03-09 12:09:56

When developing apps or bots that integrate with third-party services, it is common to need to expose the local development server to the internet to receive Webhook messages. To achieve this, an HTTP tunnel is required for the local server. This article demonstrates how to use WebSocket and Node.js streams to build an HTTP tunnel tool and transfer big data.

## Why Deploy Your Own HTTP Tunnel Service

Many online services provide HTTP tunnels, such as ngrok, which offer paid fixed public domains to connect the local server. It also has a free package, but it only provides a random domain that changes each time the client restarts, making it inconvenient to save the domain in third-party services.

To get a fixed domain, you can deploy your own HTTP tunnel on your server. ngrok also provides an open-source version for server-side deployment, but it is an old 1.x version with some serious reliability issues and not recommended for production.

In addition, with your own server, you can ensure data security.

## Introduction about Lite HTTP Tunnel project

[Lite HTTP Tunnel](https://github.com/web-tunnel/lite-http-tunnel) is a recently developed HTTP tunnel service that can be self-hosted. You can use the Deploy button in the Github repository to deploy it and obtain a fixed domain for free.

It is built based on Express.js and Socket.io with just a few lines of code. It uses WebSocket to stream HTTP/HTTPS requests from the public server to your local server.

## Implementation

### Step 1: Build a WebSocket Connection Between Server and Client

To support WebSocket connections at the server-side, we use socket.io:

```js
const http = require('http');
const express = require('express');
const { Server } = require('socket.io');

const app = express();
const httpServer = http.createServer(app);
const io = new Server(httpServer);

let connectedSocket = null;

io.on('connection', (socket) => {
  console.log('client connected');
  connectedSocket = socket;
  const onMessage = (message) => {
    if (message === 'ping') {
      socket.send('pong');
    }
  }
  const onDisconnect = (reason) => {
    console.log('client disconnected: ', reason);
    connectedSocket = null;
    socket.off('message', onMessage);
    socket.off('error', onError);
  };
  const onError = (e) => {
    connectedSocket = null;
    socket.off('message', onMessage);
    socket.off('disconnect', onDisconnect);
  };
  socket.on('message', onMessage);
  socket.once('disconnect', onDisconnect);
  socket.once('error', onError);
});

httpServer.listen(process.env.PORT);
```

To connect the WebSocket at the client-side:

```js
const { io } = require('socket.io-client');

let socket = null;

function initClient(options) {
  socket = io(options.server, {
    transports: ["websocket"],
    auth: {
      token: options.jwtToken,
    },
  });

  socket.on('connect', () => {
    if (socket.connected) {
      console.log('client connect to server successfully');
    }
  });

  socket.on('connect_error', (e) => {
    console.log('connect error', e && e.message);
  });

  socket.on('disconnect', () => {
    console.log('client disconnected');
  });
}
```

### Step2: Use JWT Token to Protect the WebSocket Connection

At the server-side, we use `socket.io` middleware to reject invalid connections:

```js
const jwt = require('jsonwebtoken');

io.use((socket, next) => {
  if (connectedSocket) {
    return next(new Error('Connected error'));
  }
  if (!socket.handshake.auth || !socket.handshake.auth.token){
    next(new Error('Authentication error'));
  }
  jwt.verify(socket.handshake.auth.token, process.env.SECRET_KEY, function(err, decoded) {
    if (err) {
      return next(new Error('Authentication error'));
    }
    if (decoded.token !== process.env.VERIFY_TOKEN) {
      return next(new Error('Authentication error'));
    }
    next();
  });  
});
```

### Step 3: Data Stream Transmission

In Node.js, both HTTP Request and Response are streams. On the server side, Request is a Readable stream, while Response is a Writable stream.
The normal data stream transmission in a node.js web server is shown in the following diagram:

![Normal web server](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1cnxd0hq3wz4al4sstiq.png)

And now that our Web Server is inside the local firewall, we use a public server to forward the Request and Response through. Therefore, the user's HTTP data first goes to the Tunnel server, which sends the Request to the Tunnel client, and then the Tunnel client sends the Request to the Local web server to get the Response, which is finally returned to the Tunnel server for transmission to the Client side.

![User and Tunnel server](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xjei2g8ls3zt0ms4vl0h.png)

![Tunnel server to Tunnel Client](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1by7qm4wg93gy5cy4kbo.png)

![Tunnel client to local server](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xjxq6z5itligznaqq19h.png)

To transmit the Request and Response streams between the Tunnel server and Tunnel client, we implement a TunnelRequest writable stream class and TunnelResponse readable stream class on the Tunnel server side, based on WebSocket, and a TunnelRequest readable stream class and TunnelResponse writable stream class on the Tunnel client side.

Tunnel server side：

```js
const { Writable, Readable } = require('stream');

class TunnelRequest extends Writable {
   // ...
}

class TunnelResponse extends Readable {
  // ...
}
```
Tunel client side:

```js
const { Writable, Readable } = require('stream');

class TunnelRequest extends Readable {
   // ...
}

class TunnelResponse extends Writable {
   // ...
}
```

To learn more about Node.js stream, you can refer to the official [documentation](https://nodejs.org/api/stream.html). For the implementation of TunnelRequest and TunnelResponse, you can visit [https://github.com/web-tunnel/lite-http-tunnel/blob/main/lib.js](https://github.com/web-tunnel/lite-http-tunnel/blob/main/lib.js). 

After completing all of the above steps, we now support streaming HTTP requests to a local computer and sending responses from the local server back to the original request. This is a lightweight solution, but it is highly stable and easy to deploy in any Node.js environment.


### Step 4: Deploy HTTP Tunnel service

We can deploy the HTTP tunnel service to a cloud provider such as Heroku/Render. The project [Lite HTTP Tunnel](https://github.com/web-tunnel/lite-http-tunnel) contains a Heroku/Render button in the Github repository, which allows you to deploy the service to Heroku/Render quickly.

## More

So we have introduced about how to transfer HTTP requests based on WebSocket and Node.js Writable and Readable stream. In latest version of Lite HTTP Tunnel, we refactor the project with Duplex stream to support requests from WebSocket. You can check that from [source code](https://github.com/web-tunnel/lite-http-tunnel).