WebSockets (client & server) in Deno

Mayank Choubey
Tech Tonic
6 min readApr 26, 2021

--

An update of this article has been published here.

Purpose

WebSocket is an advanced protocol for two-way interactive communication over a single TCP connection. Prior to WebSocket, this was possible only through HTTP polling, which was cumbersome. Using WebSockets, a real-time session can be established between the user’s browser and the server. Using WebSocket APIs, messages can be exchanged anytime (no polling required).

WebSocket is related to HTTP, but is very distinct. Both WebSocket and HTTP are layer 7 protocols, relying on an underlying TCP connection. Although they are different, WebSocket is intentionally designed to work over HTTP ports 80 and 443 to support intermediate proxies, routers, etc. WebSocket is fully compatible with HTTP. In fact, the WebSocket connection is created as an upgrade over the HTTP connection.

Like HTTP and HTTPS, WebSocket comes with ws:// (unencrypted) and wss:// (encrypted) URI schemes.

WebSocket is very popular for real-time chat applications. A lot of leading names use WebSocket to provide real-time customer service through chat.

In this article, we’ll learn how to write a WebSocket server & client in Deno. At the end, we’ll go over a complete chat app.

Server

Deno’s standard library comes with a ws module to easily write WebSocket servers. For clients, Deno provides standard WebSocket web API (we’ll see it later).

There are two steps in creating a WebSocket server:

  • Step 1 —Accept a standard HTTP connection
  • Step 2 — Upgrade to WebSocket

Step 1

The first step is to accept a standard HTTP connection. This is required because WebSocket connection is created as an upgrade over the standard HTTP connection. As mentioned above, this ensures fully compatibility with HTTP.

import { serve } from "https://deno.land/std/http/mod.ts"for await(const req of serve(':5000')) {
//do something
}

Step 2

The second step is to upgrade the just connected HTTP connection to WebSocket connection. The ws module comes with a function acceptWebSocket to make the upgrade. The acceptWebSocket function needs four inputs to make the upgrade:

  • conn: The underlying TCP connection object
  • writer: The writer from the HTTP request (To send data to the client)
  • reader: The reader from the HTTP request (To get data from the client)
  • headers: The headers from the HTTP request (To check validity of upgrade)
import * as ws from "https://deno.land/std/ws/mod.ts";
import { serve } from "https://deno.land/std/http/mod.ts"
for await(const req of serve(':5000')) {
const wconn={conn: req.conn, bufReader: req.r, bufWriter: req.w, headers: req.headers};
await ws.acceptWebSocket(wconn);
}

The function acceptWebSocket can throw an error if the HTTP connection is not upgradable. Let’s see what must be there to upgrade a connection.

Upgrade

Certain conditions must be met to upgrade an HTTP connection to a WebSocket connection. Here are the conditions:

  • Request must contain a header named upgrade containing websocket as the value
  • Request must contain a header named Sec-WebSocket-Key containing a value of non-zero length

If above conditions fail, an error is thrown: request is not acceptable .

Once the headers are checked, a response code 101 is sent to the client with the following headers:

  • Upgrade: websocket
  • Connection: Upgrade
  • Sec-WebSocket-Accept: A key derived from Sec-WebSocket-Key
  • Sec-WebSocket-Protocol: Copy from request if it came
  • Sec-WebSocket-Version: Copy from request if it came

The app writers don’t have to worry about the upgrade procedure. This is taken care by the library. Usually, servers would announce a different port for WebSocket communication. Any request on this port would be expected to contain the required WebSocket headers. If not present, the server would reject the request.

Once the upgrade is complete, a WebSocket object is returned that can be asynchronously iterated for incoming WebSocket messages. Before we go over messages, let’s briefly touch security.

Security

As WebSocket servers mostly handle external connections, the end point needs to be secured so that only authorized users should be able to upgrade. As the ws module doesn’t come with security, this needs to be checked by the application before accepting the WebSocket i.e. before calling acceptWebSocket .

The most common way to enable security is to check Authorization header or some other custom header like X-WS-AUTH-KEY . Based on the implementation, these headers could carry an API key, temporary secret, jwt, etc that can be used to ensure that only authorized clients can upgrade.

const auth=req.headers.get('x-ws-auth-key');
if(!auth || auth !== WS_AUTH_KEY) {
req.respond({status: 403});
continue;
}
//client authorized, try to accept

In some cases, WebSocket clients don’t allow sending HTTP headers (like WebSocket web API). In those cases, a token can be present in the URL.

Events

Once connected, WebSocket produces events of the following types:

  • Ping: This is the keep-alive event. Note that pings are responded by ws module. They’re notified to the application just in case.
  • Pong: This is the response to the keep-alive or ping event. This is also notified to the application just in case.
  • Close: This is raised when the other party has closed the connection.
  • Text: This is raised when a text (string) has been received from the other party.
  • Binary: This is raised when binary data has been received from the other party

Any application must process text, binary, and close events. ws module comes with useful functions to find out the type of event.

for await (const e of whdl) {
if(ws.isWebSocketCloseEvent(e))
break;
else if(typeof e === 'string')
e; //process as string
else if(e instanceof Uint8Array)
e; //process as binary
}

Client

Deno implements WebSocket clients through web API https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_client_applications. We’ll quickly see a small piece of code:

const ws=new WebSocket('ws://localhost:5000');
ws.onopen=()=>ws.send("SAMPLE DATA");
ws.onmessage=(m)=>ws.close();
ws.onclose=()=>console.log('Exited');

That’s all required for the client. First, create a WebSocket connection to a server. Then, defined functions for events like onopen, onmessage, onclose .

If the client doesn’t support sending authorization headers, the secure token can be sent in URL:

const ws=new WebSocket('ws://localhost:5000?accessToken=TOKEN-1');

A sample chat app

Now that we’ve gone through the basics of the WebSocket client and server, let’s write a complete chat app. The server would receive messages from the client and would respond based on it’s preconfigured data. The client would take input from the user and send it to the server. When user types exit , the client would close the connection.

Here is the complete server code:

import * as ws from "https://deno.land/std/ws/mod.ts";
import { serve } from "https://deno.land/std/http/mod.ts";
const db:Record<string, string>={
'help': 'Sure, I am here to help. Can you briefly describe the problem.',
'hi': 'Hi, how can I help you today?',
'hello': 'Hi, how can I help you today?',
'unknown': 'Sorry, I am unable to understand you.'
};
console.log('Waiting for clients ...');
for await(const req of serve(':5000')) {
console.log('Incoming connection from client ...');
const wdata={conn: req.conn, bufReader: req.r, bufWriter: req.w, headers: req.headers};
try {
const whdl=await ws.acceptWebSocket(wdata);
console.log('Connection established with client ...');
whdl.send(db['hi']);
console.log('SERVER >> '+db['hi']);
for await (const e of whdl) {
if(ws.isWebSocketCloseEvent(e)) {
console.log('Connection closed by client ...');
break;
}
else if(typeof e === 'string') {
console.log('CLIENT >> '+e);
let message:string="";
for(const k in db)
if(e.includes(k))
message=db[k];
if(!message)
message=db['unknown'];
whdl.send(message);
console.log('SERVER >> '+message);
}
}
} catch(err) {
req.respond({status: 400});
}
}

Here is the complete client code:

console.log("Connecting to server ...");
let ws:WebSocket;
try {
ws=new WebSocket('ws://localhost:5000');
} catch(err) {
console.log('Failed to connect to server ... exiting');
Deno.exit(1);
}
ws.onopen=connected;
ws.onmessage=m=>processMessage(ws, m);
ws.onclose=disconnected;
function connected() {
console.log('Connected to server ...');
}
function disconnected() {
console.log('Disconnected from server ...');
}
function processMessage(ws:WebSocket, m:MessageEvent) {
console.log('SERVER >> '+m.data);
const reply=prompt('Client >> ') || 'No reply';
if(reply === 'exit')
return ws.close();
ws.send(reply as string);
}

The client and server code is simple and straightforward. Here is the output from client and server:

// SERVER SIDE ------------------------Waiting for clients ...
Incoming connection from client ...
Connection established with client ...
SERVER >> Hi, how can I help you today?
CLIENT >> hello
SERVER >> Hi, how can I help you today?
CLIENT >> i need help
SERVER >> Sure, I am here to help. Can you briefly describe the problem.
Connection closed by client ...

// CLIENT SIDE ------------------------
Connecting to server ...
Connected to server ...
SERVER >> Hi, how can I help you today?
Client >> hello
SERVER >> Hi, how can I help you today?
Client >> i need help
SERVER >> Sure, I am here to help. Can you briefly describe the problem.
Client >> exit
Disconnected from server ...

--

--