Deno WebSocketServer with HTTP API and EventSource Client

Deno WebSocketServer with HTTP API and EventSource Client
Photo by Maria Oswalt / Unsplash

Deno is an anagram of Node and is written by the same person, Ryan Dahl, not exactly a successor but a fresh approach.

What we'll build.  Essentially it's a WebSocket proxy, it will take existing WebSocket connections and pass events to the connected client if requested.  It consists of three main pieces.

  1. A WebSocketServer
  2. An HTTP API
  3. A client that connects using an EventSource.

The WebSockerServer will store client connections.  The EventSource client connects to the server and receives events from connected WebSockets using Server Sent Events.

The WebSockerServer

Lets start by creating a simple WebSocketServer.

import { WebSocketServer } from "https://deno.land/x/websocket@v0.1.3/mod.ts";

const WS_PORT = 8080;
const wss = new WebSocketServer(WS_PORT);

wss.on("connection", function (ws: any) {
  console.log("client connected");
  ws.on("message", async function (message: any) {
    const data = JSON.parse(message);
    console.log("message:", data);
  });

  ws.on("close", function () {
    console.log("connection closed");
  })
});

console.log("WebSocket: listening on port", WS_PORT );

To run this we can use deno run --allow-net server.ts .  Deno requires explicit permissions to be provided for various operations like network access, file reading and writing etc, hence the --allow-net server.ts.

Now we should see our console message WebSocket: listening on port 8080 .

We'll use a teeny index.html file to test out connection.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>DenoWebsocketServer</title>
  <base href="/">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
  <script>
    let ws = new WebSocket("ws://localhost:8080");
    ws.onopen = () => {
      console.log("WebSocket Open");
    }
  </script>
</body>
</html>

We'll serve our index.html via serve ( npm i -g serve ).  We just need to run serve in the directory with our index.html and open the browser to http://localhost:3000/index.html, we should see our WebSocket Open message in the console.

The HTTP API

Let's add a simple HTTP operation.

import { WebSocketServer } from "https://deno.land/x/websocket@v0.1.3/mod.ts";
import { Application, Router } from "https://deno.land/x/oak/mod.ts";
import { oakCors } from "https://deno.land/x/cors/mod.ts";

const WS_PORT = 8080;
const wss = new WebSocketServer(WS_PORT);

wss.on("connection", function (ws: any) {

  console.log("client connected:", ws);
  ws.on("message", async function (message: any) {
    const data = JSON.parse(message);
    console.log("message:", data);
  });

  ws.on("close", function () {
    console.log("connection closed");
  })
});

console.log("listening on port", WS_PORT );

const HTTP_PORT = 8082;
const app = new Application();
const router = new Router();

const hello = async({request, response}: {request: any, response: any}) => {
  response.body = {response: "world"};
};

router.get("/hello", hello);

app.use(oakCors({origin: "*"}))
app.use(router.routes());
app.listen({ port: HTTP_PORT });

Update client to call our new HTTP operation.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>DenoWebsocketServer</title>
  <base href="/">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
  <script>
    let ws = new WebSocket("ws://localhost:8080");
    ws.onopen = () => {
      console.log("WebSocket Open");
    }

    fetch("http://localhost:8082/hello")
      .then(response => response.json())
      .then(response =>  {
        console.log("http resonse", response);
      });
  </script>
</body>
</html>

Great, now we have a WebSocketServer and a basic HTTP API.  Let's hook up the client to receive WebSocket events from the various connections.

First we need a way to track the WebSocket connections, so we'll need each connection to provide a unique id (we'll use a poor mans unique id 😪 ) which we'll store in a Map.  We'll also update our HTTP endpoint to handle Server Sent Events and remove hello.

import { WebSocketServer } from "https://deno.land/x/websocket@v0.1.3/mod.ts";
import { Application, Context, Router, ServerSentEventTarget } from "https://deno.land/x/oak/mod.ts";
import { oakCors } from "https://deno.land/x/cors/mod.ts";

const WS_PORT = 8080;
const wss = new WebSocketServer(WS_PORT);

let eventClient: ServerSentEventTarget;

// we want to ensure there is a connectionId also
type WebSocketServerId = WebSocketServer & {connectionId: string};
// map of websocket connections
const connections = new Map<string, WebSocketServerId>();

wss.on("connection", function (ws: WebSocketServerId) {
    
   ws.on("message", async function (event) {
   const data = JSON.parse(event);
   // websocket clients must send a connectionId
   if (!data.connectionId) {
     throw new Error("No connectionId");
   }

   ws.connectionId = data.connectionId;

   // store the connection
   connections.set(ws.connectionId, ws);
   try {
      if (eventClient) {
        // dispatch message to EventSource client if exists
        eventClient?.dispatchMessage(data);
      }
    } catch(err) {
      console.log(err);
    }
    
  });
  
  ws.on("close", function () {
    // remove the connection from map
    connections.delete(ws.connectionId);
    const msg = JSON.stringify({ close: { connectionId: ws.connectionId } }); 
    // update client with close event
    if (eventClient) {
      eventClient.dispatchMessage(msg);
    }
  });
    
});

console.log("listening on port", WS_PORT );

const HTTP_PORT = 8082;
const app = new Application();
const router = new Router();

// provide an http endpoint for our EventSource client to connect on
const events = (context: Context) => {
  eventClient = context.sendEvents();
};
router.get("/events", events);

app.use(oakCors({origin: "*"}))
app.use(router.routes());
app.listen({ port: HTTP_PORT });    

Now we need to test this using some WebSocket clients that will register connections with the WebSocketServer and then create an EvenSource client that will get pushed messages on any open WebSocket connections.

The WebSocket Client

Lets update our client to just open a WebSocket connection and send a message after 5 seconds.

<!-- ws-client.html -->
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>DenoWebsocketServer</title>
  <base href="/">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
  <h1>WebSocket Client</h1>
  <script>
    const connectionId = Date.now().toString();
    let ws = new WebSocket("ws://localhost:8080");
    ws.onopen = () => {
      console.log("WebSocket Open");
      ws.send( JSON.stringify( {connectionId} ) );
    }

    // send a message to the server
    setTimeout(() => {
      ws.send( JSON.stringify( {connectionId, message: 'hello from WebSocket client!!'} ) );
    }, 5000);

  </script>
</body>
</html>

The EventSource Client

We create a separate file for our EventSource client that will get pushed WebSocket messages from the WebSocketServer.

<!-- es-client.html -->
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>DenoWebsocketServer</title>
  <base href="/">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
  <h1>Event Source Client</h1>
  <script>

    let eventSource = new EventSource("http://localhost:8082/events");
    eventSource.onmessage = (event) => {
      const {connectionId, message, close} = JSON.parse(event.data);
      if (close) {
        console.log(`connectionId ${close.connectionId}: closed`);
        return;
      }
      if (connectionId && !message) {
        console.log(`connectionId ${connectionId}: connected`);
      } else {
        console.log(`connectionId ${connectionId}: ${message}`);
      }
    }
  </script>
</body>
</html>

Now restart the WebSocket server, open the EventSource client http://localhost:3000/es-client .  Once our EventSource client is connected open a couple of WebSocket clients http://localhost:3000/ws-client.  

You should now be able to see messages from the WebSocket clients coming into the EventSource client.

This has been a simple introduction to Deno that may serve as a foundation for more complex use cases where you need a way to tap into WebSocket data.