The web was originally designed to store and retrieve static resources, and HTTP still kind of reflects that. It isn’t great for interactive games and other applications requiring server notifications.
WebSockets were created to provide:
The websocket protocol runs on top of TCP and exposes a nice API. We can build highly communicative applications on top of the protocol when writing clients in a browser. We’ll cover the details of the websocket handshake and all the other implementation details later. For now, let’s just build apps with the basic WebSocket API.
All server side languages (JavaScript, Python, Ruby, Java, C#, Go, etc.) provide libraries to help you write websocket servers. To use web sockets on a Node-based server, npm install ws
(Read the docs).
Here’s a simple server, with a little bit of logging:
const WebSocket = require('ws'); const server = new WebSocket.Server({ port: 59898 }); server.on('connection', (socket, req) => { console.log('Connection from', req.connection.remoteAddress); socket.on('message', (message) => { socket.send(message.toUpperCase()); }); }); console.log('The capitalization server is running...');
Here’s a client, all shoved into a single HTML page (for now):
<html> <head> <meta charset="utf-8"> <title>Capitalizer Web Client</title> </head> <body> <p>Hello, this is your first websocket example. It is a painfully simple hack. It is such a hack that the host is hardcoded. Please don't do this in real life. Subsequent example will show you the right way to specify a host.</p> <p><input type="text"><button>Capitalize</button></p> <p id="response"></p> <script> addEventListener('load', () => { const socket = new WebSocket('ws://localhost:59898'); document.querySelector('button').addEventListener('click', () => { socket.send(document.querySelector('input').value); }); socket.addEventListener('message', (event) => { document.querySelector('#response').textContent = event.data; }); }); </script> </body> </html>
Run the server on a command line, and open the client in a browser.
Notice that the programming styles differ a lot between the client and server:
Server Side | Client Side |
---|---|
const WebSocket = require('ws'); | WebSockets built in |
const server = new WebSocket.Server({ port: p }) |
const socket = new Websocket('ws://xxx.example.com:p') |
server.on('connection', (socket, req) => { ... }) | Connection established in WebSocket constructor |
socket.on('message', (data) => { ... data ... }) |
socket.addEventListener('message', (event) => { ... event.data ... }) |
socket.send(data) |
socket.send(data) |
Runs as separate process | Runs in the browser |
You should really write secure servers.
When secure, the client will specifywss
in the url scheme instead ofws
.
Here’s a more complex example. Notice how similar it is to classic TCP server we covered earlier.
// A server for a multi-player tic tac toe game. Loosely based on an example in // Deitel and Deitel’s “Java How to Program” book. For this project I created a // new application-level protocol called TTTP (for Tic Tac Toe Protocol), which // is entirely plain text. The messages of TTTP are: // // Client -> Server // MOVE <n> // QUIT // // Server -> Client // WELCOME <char> // VALID_MOVE // OTHER_PLAYER_MOVED <n> // OTHER_PLAYER_LEFT // VICTORY // DEFEAT // TIE // MESSAGE <text> const WebSocket = require("ws") const server = new WebSocket.Server({ port: 58901 }) ;(() => { // When null, we are waiting for the first player to connect, after which we will // create a new game. After the second player connects, the game can be fully set // up and played, and this variable immediately set back to null so the future // connections make new games. let game = null server.on("connection", (ws, req) => { console.log("Connection from", req.connection.remoteAddress) if (game === null) { game = new Game() game.playerX = new Player(game, ws, "X") } else { game.playerO = new Player(game, ws, "O") game = null } }) console.log("The Tic Tac Toe server is running...") })() class Game { // A board has nine squares. Each square is either unowned or it is owned by a // player. So we use a simple array of player references. If null, the corresponding // square is unowned, otherwise the array cell stores a reference to the player that // owns it. constructor() { this.board = Array(9).fill(null) } hasWinner() { const b = this.board const wins = [ [0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6], ] return wins.some(([x, y, z]) => b[x] !== null && b[x] === b[y] && b[y] === b[z]) } boardFilledUp() { return this.board.every((square) => square !== null) } move(location, player) { if (player !== this.currentPlayer) { throw new Error("Not your turn") } else if (!player.opponent) { throw new Error("You don’t have an opponent yet") } else if (this.board[location] !== null) { throw new Error("Cell already occupied") } this.board[location] = this.currentPlayer this.currentPlayer = this.currentPlayer.opponent } } class Player { constructor(game, socket, mark) { Object.assign(this, { game, socket, mark }) this.send(`WELCOME ${mark}`) if (mark === "X") { game.currentPlayer = this this.send("MESSAGE Waiting for opponent to connect") } else { this.opponent = game.playerX this.opponent.opponent = this this.send("MESSAGE Your opponent will move first") this.opponent.send("MESSAGE Your move") } socket.on("message", (buffer) => { const command = buffer.toString("utf-8").trim() console.log(`Received ${command}`) if (command === "QUIT") { socket.close() } else if (/^MOVE \d+$/.test(command)) { const location = Number(command.substring(5)) try { game.move(location, this) this.send(`VALID_MOVE ${location}`) this.opponent.send(`OPPONENT_MOVED ${location}`) if (this.game.hasWinner()) { this.send("VICTORY") this.opponent.send("DEFEAT") } else if (this.game.boardFilledUp()) { ;[this, this.opponent].forEach((p) => p.send("TIE")) } } catch (e) { console.trace(e) this.send(`MESSAGE ${e.message}`) } } }) socket.on("close", () => { try { this.opponent.send("OTHER_PLAYER_LEFT") } catch (e) {} }) } send(message) { try { this.socket.send(`${message}\n`) } catch (e) { console.error(e) } } }
Here’s the client, put together in CodePen:
See the Pen Tic Tac Toe WebSocket Client by Ray Toal (@rtoal) on CodePen.
In practice, CodePen is wonderful for developing, but you will eventually need to deploy a single webapp which has endpoints for getting your HTML pages as well as web sockets for your bi-directional, full-duplex, communication needs. Once you are ready for this, you might as well start using an HTTP framework.
Let's build an app where all users move around a canvas. Every time they move, they notify the server, which broadcasts the “state” to all participants. We’ll have an HTTP server in which the endpoint GET /
returns the HTML page. The server application, at startup, sets up a web socket listener. We’ll build this with the Express framework so create a new folder and:
$ npm init $ npm install --save ws express
The app should have the following structure (not including the README, the git-related files, and the linter-related files, package.json, node_modules, etc., which should all be present, but for simplicity we’re focusing only on the app itself):
├── app.js └── public ├── party.html ├── party.css └── party.js
Here is the main application:
const path = require('path'); const express = require('express'); const WebSocket = require('ws'); const app = express(); app.use(express.static('public')); app.get('/', (req, res) => res.sendFile(path.join(__dirname, 'public', 'party.html'))); app.listen(50000, () => console.log('Party server is running')); const state = new Map(); function randomColor() { const [r, g, b] = Array(3).fill(0).map(() => Math.floor(Math.random() * 200)); return `rgb(${r}, ${g}, ${b})`; } new WebSocket.Server({ port: 50001 }).on('connection', (socket) => { state.set(socket, { location: [0, 0], color: randomColor() }); socket.on('message', (data) => { state.get(socket).location = JSON.parse(data); const renderData = JSON.stringify(Array.from(state.values())); Array.from(state.keys()).forEach(sock => sock.send(renderData)); }); });
And the HTML file:
<html> <head> <meta charset="utf-8"> <link rel="stylesheet" href="party.css"> </head> <body> <h1>Party</h1> <canvas width="500" height="500"></canvas> <script src="party.js"></script> </body> </html>
And the css:
body { margin: 20px; } canvas { background-color: #dfd; }
And finally, the JavaScript client:
const canvas = document.querySelector('canvas'); const ctx = canvas.getContext('2d'); const socket = new WebSocket(`ws://${location.hostname}:50001`); canvas.addEventListener('mousemove', (e) => { const rect = canvas.getBoundingClientRect(); const [x, y] = [e.clientX - rect.left, e.clientY - rect.top]; socket.send(JSON.stringify([x, y])); }); socket.addEventListener('message', (event) => { ctx.clearRect(0, 0, canvas.width, canvas.height); JSON.parse(event.data).forEach(({ location, color }) => { ctx.fillStyle = color; ctx.fillRect(...location, 10, 10); }); });
To use the app, just visit the main page in your browser. Everyone that joins can see each other. Enjoy.
There’s this really popular library called socket.io which is built on top of WebSockets, providing a bunch of features.
A little game I wrote with Socket.io and Express
We’ve covered: