Web sockets
Learning objectives
- Knows what web sockets are and what they are used for.
- Knows how to manage bidirectional communication between the client and the server using web sockets.
Server-sent events discussed in the previous part are a push technology, where the server sends information to the client. If there is a need for bidirectional communication, server-sent events are not a good fit. For bidirectional communication, one would use web sockets. Web sockets use the WebSocket Protocol instead of HTTP, building on top of TCP.
Web socket on a server
The way how a web socket server is implemented depends naturally heavily on the chosen technology. A popular choice across a variety of platforms is Socket.io, which provides options for both a web server and the client.
In Deno, we would use the built-in upgradeWebSocket function, which allows upgrading an HTTP request into a WebSocket. The function is given a request, and it returns a WebSocket and a Response.
A WebSocket server that would act similarly to server-sent events server would be implemented as follows. The server below listens to requests, updates the requests to a WebSocket, and begins sending the message ping
to the client, once per second. The method send is used for sending data.
import { serve } from "https://deno.land/std@0.222.1/http/server.ts";
const handleRequest = async (request) => {
const { socket, response } = Deno.upgradeWebSocket(request);
let interval = setInterval(() => {
socket.send("ping");
}, 1000);
socket.onclose = () => {
clearInterval(interval);
};
return response;
};
serve(handleRequest, { port: 7777 });
WebSockets have a handful of events that one can set properties to. In the above, we the onclose
property is set -- the function is called when the WebSocket connection is closed, which leads to the interval being cleared.
Note that NGINX configuration must be adjusted to allow upgrading to WebSocket connection when used. Some discussion on this is available on the NGINX Blog.
Web socket on a client
WebSockets are handled on the client using the WebSocket interface. A websocket connection is prefixed with ws
instead of http
(and wss
instead of https
). The following example outlines a simple WebSocket client that connects to a server accepting WebSocket connections running on port 7777
on a host identified using window.location.hostname
. The path is set to /api/ws
.
The property onmessage
is used to set functionality that is called on each message sent over the web socket. In the following example, each message sent over the websocket would be logged to the console.
const host = window.location.hostname;
const webSocket = new WebSocket("ws://" + host + ":7777/api/ws");
webSocket.onmessage = (event) => {
console.log(event.data);
};
Similar to server-sent events, one could also create basic functionality that could be used for opening and closing a web socket connection, as shown below.
let webSocket;
const openConnection = () => {
const host = window.location.hostname;
webSocket = new WebSocket("ws://" + host + ":7777/api/ws");
webSocket.onmessage = (event) => {
console.log(event.data);
};
};
const closeConnection = () => {
webSocket.close();
};
When we consider using WebSockets in Svelte, the behavior of our application would be very similar to the application we previously worked on when looking into server-sent events. The main difference is that we utilize WebSocket
instead of EventSource
. The following example outlines a Svelte component that would, when rendered, open up a WebSocket connection and start listening to events. Each event would be added to the events
list, and shown in the application.
<script>
import { onMount } from "svelte";
let events = [];
let ws;
onMount(() => {
const host = window.location.hostname;
ws = new WebSocket("ws://" + host + ":7777/api/ws");
ws.onmessage = (event) => {
events = [...events, event.data];
};
return () => {
if (ws.readyState === 1) {
ws.close();
}
};
});
const closeConnection = () => {
ws.close();
};
</script>
<h2>WebSocket events ({events.length})</h2>
<button on:click={closeConnection}>Close connection</button>
<ul>
{#each events as event}
<li>{event}</li>
{/each}
</ul>
The above component would offer two options for closing the connection. First, the connection would be closed when the component is dismantled -- for this, WebSocket's readyState is used. Second, the connection could also be used by calling the closeConnection
function.
WebSocket support
Most web clients support web sockets, but not all. Popular libraries such as Socket.io provide options for falling back to long polling in the case the client do not support web sockets.
Based on data from caniuse.com, over 98% of all web users have web socket support.
Bidirectional communication
Bidirectional communication means that both the server and the client can send messages over the open web socket connection. As, in our case, both the server and the client conform to the WebSocket interface, the same methods can be used on both ends. The method send
is used for sending data, while the property onmessage
is overridden to define what should happen when a message is received.
A simple ping-pong functionality could be implemented as follows. On the client, the application would provide a button that would send the message "Ping" to the server, which could be done by adding a function for sending the message and a corresponding button.
<script>
// ...
const sendMessage = () => {
ws.send("Ping");
};
// ...
</script>
<h2>WebSocket events ({events.length})</h2>
<button on:click={sendMessage}>Send message</button>
<!-- etc -->
On the backend, the application would be adjusted so that the message sent from the client would be logged, and the server would send the message "Pong" as a response after a small delay. Below, the method setTimeout is used to define a one second delay for sending the message.
import { serve } from "https://deno.land/std@0.222.1/http/server.ts";
const handleRequest = async (request) => {
const { socket, response } = Deno.upgradeWebSocket(request);
socket.onmessage = (event) => {
console.log(event.data);
setTimeout(() => {
socket.send("Pong");
}, 1000);
};
return response;
};
serve(handleRequest, { port: 7777 });
Now, every time the client would send a message to the server, the server would respond after a small delay. Note that in this case, the connection is continuously open.
WebSocket and connection persistence
Unlike EventSource, WebSocket does not by default try to re-establish a connection that has been closed.
Web sockets and multiple clients
Having multiple clients connect to a single server follows the same principle as observed in server-sent events. We store the opened connections in a set and whenever we receive a message, we send the same message to all of the open connections. Similarly, when a connection is closed, we remove it from the set of sockets. On the server side, this would look as follows.
import { serve } from "https://deno.land/std@0.222.1/http/server.ts";
const sockets = new Set();
const sendMessage = (event) => {
sockets.forEach((socket) => socket.send(event.data));
};
const handleRequest = async (request) => {
const { socket, response } = Deno.upgradeWebSocket(request);
sockets.add(socket);
socket.onclose = () => {
sockets.delete(socket);
};
socket.onmessage = sendMessage;
return response;
};
serve(handleRequest, { port: 7777 });
The functionality on the client side would not change considerably, although some renaming of variables and adding an input box would be meaningful. The following outlines basic functionality needed for typing in text and sending the text to the server over a web socket connection. In addition, the functionality shows the received messages in a list.
<script>
import { onMount } from "svelte";
let input = "";
let messages = [];
let ws;
onMount(() => {
const host = window.location.hostname;
ws = new WebSocket("ws://" + host + ":7777/api/ws");
ws.onmessage = (event) => {
messages = [...messages, event.data];
};
return () => {
if (ws.readyState === 1) {
ws.close();
}
};
});
const sendMessage = () => {
if (input.trim() == "") {
return;
}
ws.send(input.trim());
input = "";
};
</script>
<h2>Messages</h2>
<input bind:value={input} />
<button on:click={sendMessage}>Send message</button>
<ul>
{#each messages as message}
<li>{message}</li>
{/each}
</ul>
With the two above pieces, we would have a simple anonymous chat server that anyone (with a client that supports web sockets) could join in.
Next steps for Web Sockets
One of the challenges with web sockets is that the amount of data or messages that one can send to a web socket (or receive from a web socket) is not limited. This means that one could, in principle, overload a web socket. Web sockets have a property bufferedAmount which provides information on the amount of data queued to send but not yet transmitted, which could be monitored to maintain a stable pace of messages.
One potential solution for this is the WebSocketStream API, which is currently under development.