Websocket : how to forward traffic in Node.js

Many solutions exist to serve remote content on proxies : webpack-dev-server, next.js, http-proxy, or just my own local-traffic.
While these solutions offer an efficient way to route http traffic, they sometimes have support for websocket, which correspond to a different protocol (tcp messages but not exactly http)

The client only sends a http request once, then the client and server exchange messages without handshake (the initial message to initiate a connection).

Node.js does offer a way to intercept these messages, but only via the EventEmitter interface.
So you won’t be able to “listen” for websocket messages in a server.
I have written a wrapper to allow a server to intercept websocket messages and to forward them to a real websocket server. Using this tool, I am able to build a proxy server that is also able to proxy websockets.

This will be very useful to proxy hot reload functionalities on server and thus to keep a nice level of interactivity even behind the proxy server.

import { createServer, request as httpRequest, RequestOptions } from ‘http’;
import type { IncomingMessage } from ‘http’;
import type { Duplex } from ‘stream’;
const activateWebsocket = true;
const determineURL: (request: IncomingMessage) => URL = (request) => {
if (!request.url) throw new Error(“expected request url”)
return new URL(
`http://${request.headers.host}${
request.url.endsWith(“/_next/webpack-hmr”)
? request.url
: request.url
.replace(new RegExp(`^${request.url}`, “g”), “”)
.replace(/^\/*/, “/”)
}`)
}
createServer(() => {
})
.on(“upgrade”, (request: IncomingMessage, upstreamSocket: Duplex) => {
if (activateWebsocket) {
upstreamSocket.end(`HTTP/1.1 503 Service Unavailable\r\n\r\n`)
return;
}
const target = determineURL(request);
const downstreamRequestOptions: RequestOptions = {
hostname: target.hostname,
path: target.pathname,
port: target.port,
protocol: target.protocol,
method: request.method,
headers: request.headers,
host: target.hostname,
};
const downstreamRequest = httpRequest(downstreamRequestOptions);
downstreamRequest.end();
downstreamRequest.on(‘error’, (error) => {
console.log(`websocket request has errored ${
(error as NodeJS.ErrnoException).errno ?
`(${(error as NodeJS.ErrnoException).errno})` : }`)
});
downstreamRequest.on(‘upgrade’, (response, downstreamSocket) => {
const upgradeResponse = `HTTP/${response.httpVersion} ${response.statusCode} ${
response.statusMessage}\r\n${Object.entries(response.headers)
.flatMap(([key, value]) => (!Array.isArray(value) ? [value] : value)
.map(oneValue => [key, oneValue]))
.map(([key, value]) =>
`${key}: ${value}\r\n`).join()}\r\n`;
upstreamSocket.write(upgradeResponse);
upstreamSocket.allowHalfOpen = true;
downstreamSocket.allowHalfOpen = true;
downstreamSocket.on(‘data’, (data) => upstreamSocket.write(data));
upstreamSocket.on(‘data’, (data) => downstreamSocket.write(data));
downstreamSocket.on(‘error’, (error) => {
console.log(`downstream socket has errored ${
(error as NodeJS.ErrnoException).errno ?
`(${(error as NodeJS.ErrnoException).errno})` : }`)
})
upstreamSocket.on(‘error’, (error) => {
console.log(`upstream socket has errored ${
(error as NodeJS.ErrnoException).errno ?
`(${(error as NodeJS.ErrnoException).errno})` : }`)
})
});
})

It is quite straightforward to explain the “upgrade” event callback :
When we get a request of type “upgrade”, we forward the request to the target, and we reply to the client once we get a response (which should be of status code 101).
We keep a reference to the upstream socket and the downstream socket, and we make sure to forward any message coming upstream or downstream to mirror the traffic transparently.

The upgrade callback should only be called once, and all the subsequent messages are supposed to be intercepted in either downstreamSocket on data, or in upstreamSocket on data.

To see a working example, just run local-traffic >= 0.0.39 with node.js

Leave a Reply

Your email address will not be published. Required fields are marked *

*

This site uses Akismet to reduce spam. Learn how your comment data is processed.