AJAX, events, WebSockets
In this tutorial we will learn how to use AJAX and WebSockets to code a single page app, a client-server application that runs from a single view.
We will take the client-only Connect Four application we coded in this tutorial, and we will transform it in a multi-user game app.
The references for this tutorial are:
- The MDN page on JavaScript classes,
- The MDN page on XMLHttpRequest,
- The MDN page on the Fetch API,
- The MDN pages on AJAX,
- The
ws
module docs for WebSockets. - This MDN tutorial on WebSockets.
Prepare your workspace
Create a new Node app on Glitch. Install the following packages through package.json
:
body-parser
for handling POST requests,ws
for handling WebSockets.
Our application will use no templates, and for the moment we do not need a database either.
The main page
The only view for the user will be served from the /
URL. This will
be a very simple static HTML page that loads a style sheet and the
client script. Something along these lines:
<!Doctype html>
<html>
<head>
...
<link rel="stylesheet" href="..." />
</head>
<body>
<div id="main">
Please, enable JavaScript.
</div>
<script src="..."></script>
</body>
</html>
-
Create a static HTML page as above (do not forget compulsory tags, such as
<meta charset>
and<title>
), and serve it at the/
URL, usingexpress.static()
(preferred) orapp.sendFile()
(see the reflector tutorial). -
Create the style sheet and the script (both empty, for the moment). Serve them using
express.static()
orapp.sendFile()
.Test that everything works correctly by, e.g., changing the text “Please, enable JavaScript” to “Welcome to Connect Four!” upon page load.
Getting the user id
For the moment we will let the client choose its user name at page load, without creating a user account with a password.
-
Upon loading the main page, show a popup asking for the user name. You can use the (synchronous, blocking, oldie but still goodie) function
prompt
to create the popup. -
When the user gives its name, save it in
sessionStorage
, so that the value is kept in memory as long as the browser stays open. -
Modify your code so that the popup is only shown if a username is not available in
sessionStorage
yet.Note: You can use several browser tabs and/or private navigation (
Shift+Ctrl+P
in Firefox,Shift+Ctrl+N
in Chrome) to do your tests. You can also empty thesessionStorage
through the developer tools, or by issuingsessionStorage.clear()
in the console. -
Change the text shown in the main page to “Hello …” when the user name is known (replace the dots by the user name).
Communicating the user id to the server
We will now use AJAX to send the user name to the server, so that it can create a list of all connected users.
We need a way to store the list of connected users on the server. Good practice would suggest that we store this list in a database (either a SQL database, or an in-memory key-value store, such as Redis). However, we will make it simple and store this list in a global variable in the server code. This technique is a bit fragile and prone to memory leaks, but works nevertheless.
-
On the server side, create a global variable
connected_users
, initialized to the empty object:var connected_users = {};
-
To conveniently represent the users, we will create a JavaScript class. Create a class named
User
with a very simple constructor:constructor(name) { this.name = name; }
-
Create a GET handler for the
/connect/:user
URL. When a request is received:- the username is recovered from the URL (use
req.params
); - a new
User
is created with the given name; -
the user is added to
connected_users
, using the username as key:connected_users[name] = new User(name);
This will allow us to lookup users in
connected_users
quickly.
After the user is inserted in
connected_users
, send an acknowledgment message usingres.json
.Test your handler by manually visiting
/connect/:user
in your browser. - the username is recovered from the URL (use
-
On the client-side, now, use
XMLHttpRequest
orfetch()
to send your username to/connect/:user
. Wait for the acknowledgment message and log it to the console. -
GET method is handy for debugging, however we are changing the state of the server with the route
connect/:user
and a GET request should not change the state of the server. Turn GET into POST for this route.
Getting the user list
-
Create a new handler for the
/userlist
URL. It must take the contents ofconnected_users
, transform them to a list, and send them viares.json()
.Hint: To transform a JavaScript object to a list, you can either write a for loop, or use
Object.values
. -
On the client side, after having successfully connected, use
XMLHttpRequest
orfetch()
to get the user list. Using the DOM API, dynamically present the user list in the browser as a<table>
containing one row per user.Test using several tabs. You can also ask a friend to connect to your app.
Hint: for convenience, it is best to write a function, say
createUserList
, that takes the list of users as input and returns a DOM element representing the<table>
. Then you can add this DOM element to the document by replacing the children of the div#main
. -
Using
setInterval()
, update the list of users every 2 seconds.
Handling disconnections
-
Create a POST handler for
/disconnect/:user
. When a request is received, the corresponding user is removed fromconnected_users
. You can remove an element from an object by usingdelete connected_users[...]
-
On the client side, listen for the
beforeunload
event that is fired when the browser window is closed. React by sending a beacon to/disconnect/:user
(a beacon is like a POST AJAX request, but it is guaranteed to be sent even if the window is closing).Test your application using several tabs.
Note:
beforeunload
is also fired when you reload the page, so expect a lot of disconnection events everytime you restart the server (as Glitch reloads the page automatically).
Enter WebSockets
We have reached the limits of the AJAX technique. We could keep using AJAX and short polling to write the full application, however this would quickly become messy, slow and error prone. Instead, we will replace our AJAX calls with WebSockets.
To handle WebSockets on the server side, we will use the ws
module. On the client side, we will
use the native WebSocket
API. These two have a very similar interface.
Let’s start from the server. First, don’t forget to add add the ws
module to your package.json
file (it is not installed by default). Just use the normal “Add Package” button in Glitch that is displayed at the top of the package.json
file. Then, to load and configure the ws
module,
modify your application thusly:
var http = require('http');
var ws = require('ws');
var express = require('express');
var app = express();
// etc...
// We attach express and ws to the same HTTP server
var server = http.createServer(app);
var wsserver = new ws.Server({
server: server,
});
// We define the WebSocket logic
wsserver.on('connection', function(wsconn) {
console.log('Received new WS connection');
wsconn.send('Hello world!');
wsconn.on('message', function(data) {
console.log(data);
});
// etc...
});
// Watch out for this: app.listen would break ws!
server.listen(process.env.PORT);
On the client side, it is very easy to talk to this WebSocket server
var ws = new WebSocket('wss://' + window.location.host)
ws.addEventListener('open', function(e) {
ws.send('Hi world!');
ws.addEventListener('message', function(e) {
console.log(e.data);
});
});
Warning: Take note of the minor differences:
- The event is named
connection
on the server andopen
on the client (as these are subtly different: it is always the client who opens the connction); - It is
.addEventListener()
in the client and.on()
in the server; - Callback parameters are subtly different.
Warning: The wss://
scheme is for WebSockets encrypted over
TLS. This is only compatible with an HTTPS server. If you are
developping locally, you must use ws://
instead.
-
Remove the AJAX call to
/connect/:user
from the client. Replace it with a WebSocket message sending the user name to the server. -
On the server side, wait for this message and insert the user into
connected_users
. -
We will need to use the WebSocket channel to transfer many other messages, and the server and client must be able to distinguish them. Thus, it is not acceptable to send the username as plaintext data. Instead, we will serialize data using JSON.
-
On the client side, create a message with this structure:
{ type: 'new_connection', username: '...' }
serialize it using
JSON.stringify()
and send it via the WebSocket. -
On the server side, parse the message with
JSON.parse()
, check that the message type isnew_connection
, then create the new user.
-
-
We need to keep track of which user is associated to which WebSocket connection. Store a reference to the current user inside the connection callback. Something like this:
function(wsconn) { var myuser = null; wsconn.on('message', function(data) { myuser = new User(...); ... }); }
-
Remove the beacon to
/disconnect/:user
. -
On the server side, detect a disconnection by listening to the
close
event onwsconn
. Ifmyuser
is notnull
, remove the user fromconnected_users
.
Test your application using several tabs. Verify that connections and disconnections happen consistently.
Ditching short polling
We still haven’t removed the short polling to /userlist
. We must now
notify all WebSocket clients whenever a connection/disconnection
happens.
-
Remove the AJAX calls to
/userlist
from the client. -
By looking at this example, modify your server so that it broadcasts to all connected clients the list of users whenever a connection or disconnetion happens.
Note: You must now use
JSON.stringify()
on the server, andJSON.parse()
on the client. Like you did before, choose a meaningfultype
for this message. -
Modify your client so that it updates the list of users whenever it receives a new message.
Test your application using several browsers tabs.
Challenging
We are ready to initiate a game. We need to encode the logic that allows one user to challenge another. Instead of broadcast messages to all users, we now need to send specific messages to each user.
-
Modify your
User
class to hold three parameters:constructor(name, wsconn) { this.name = name; // The user name this.wsconn = wsconn; // The WS connection to the user browser this.state = 'AVAILABLE'; // An internal state }
-
In the server, modify the WebSocket message handler to pass the current connection to
new User(...)
. -
The
wsconn
object is not serializable byJSON.stringify()
; we must adapt our code. Add a.toJSON()
method to the classUser
, like thustoJSON() { return { name: this.name, state: this.state, } }
Now when a
User
object is serialized to JSON byJSON.stringify()
, it will only include thename
andstate
attributes of theUser
, but not thewsconn
attribute, which is not serializable. -
Modify the client code producing the list of users, so that it puts next to the user names a column with “Challenge” buttons, like thus
foo bar baz The buttons must be disabled if the user state is not
AVAILABLE
(you can enable/disable a buttonb
via JavaScript by settingb.disabled
tofalse/true
).Add an event listener to the “Challenge” buttons: whenever a button is clicked, a WebSocket message is sent to the server, containing the name of the challenged user.
-
In the server, add a
.invite(opponent)
method to theUser
class. This method takes another user (opponent
) as parameter and does the following:- Checks that
opponent
is notthis
(a user can’t challenge itself); - Checks that
opponent
andthis
are bothAVAILABLE
; - Switches the state of both
opponent
andthis
toPLAYING
; - Returns
true
;
Modify the WebSocket
message
handler to handle challenge message types. When a new challenge request is received, it calls the.invite()
method on the challenger user, then- Sends a WebSocket message to
opponent
to notify it of the new game; - If everything has gone well:
- it sends a WebSocket message to
this
to notify it of the start of the game; - it broadcasts a message to all users with the updated list of users;
- it sends a WebSocket message to
- If there has been an error:
- it sends a WebSocket message to
this
, explaining the error.
- it sends a WebSocket message to
- Checks that
-
Modify the client code to replace the user list with a message “You are playing with…” whenever a user is in
PLAYING
state.
Test your code using several browsers tabs.
Quitting the game
Before coding the game, let’s jump straight to the end of it.
-
In the server, create a class
Game
representing a game between two users. For the moment, this class only has two fields:player1
andplayer2
. By default,player1
is the challenged. -
Modify
User.invite()
so that it creates a newGame
instance whenever a user challenges another. TheGame
instance will point to the two users, and each of the users will point to it. -
Add a “Quit” button under the “You are playing with…” message. When the user clicks it, send a quit message to the server.
-
Add a
.quit()
method to theGame
class. It resets the user states toAVAILABLE
, and removes the references to itself. -
Add a
.quit()
method to theUser
class. It must:- Check that the user is
PLAYING
, - Call the
.quit()
method of the associatedGame
, - Return
true
if everything went well.
Modify the WebSocket
message
handler to handle quit message types. When a new quit request is received, it calls the.quit()
method, then it must- Notify the opponent that the game is over,
- If everything has gone well:
- send a WebSocket message to
this
to notify it of the end of the game; - broadcast a message to all users with the updated list of users;
- send a WebSocket message to
- If there has been an error:
- send a WebSocket message to
this
, explaining the error.
- send a WebSocket message to
- Check that the user is
-
Modify the client so that it reverts to showing the list of users when a game has ended.
Let’s play
And we finally come to the real game. Using the code you wrote for the Connect Four tutorial, or this solution, you will make a full multi-user Connect Four app.
Instructions are going to become more approximate from now on.
-
Below the “You are playing with…” message, show the game board.
-
Store the state of the game in the
Game
class: a 6×7 grid, and a variable tracking which player plays next (player1
takes red and starts). -
Modify the click handler on the board so that:
- If the move is valid (it’s the player’s turn, and the column is not full), a message is sent to the server through the WebSocket;
- If the move is invalid, an error message is shown.
-
Write the game logic in the server. When a move is received from the client, it must:
- Check that the move is valid (checking on the client side is not enough);
- Update the state;
- Check if the move is a winning one.
After doing the checks and updating the state, the server notifies both players of the new state.
-
When the game is over, both clients show a message indicating the winner, and go back to the
AVAILABLE
state.
Test your application with several games simultaneously.
To go further (optional)
-
Integrate the database from the previous tutorial:
- The users authenticate with their username and password,
- The colors of the tokens are chosen from the user preferences (the challenged user always gets it preferred color).
- Keep track of the number of wins/losses in the database.
- Show all this information in the user list.
Warning: You will need to get values from the session in the WebSocket code.
express-session
does not work automatically with WebSockets. You will need this hack, as described here.var sess_storage = session({ secret: "12345", resave: false, saveUninitialized: false, }); app.use(sess_storage); var wsserver = new ws.Server({ server: server, verifyClient: function(info, next) { sess_storage(info.req, {}, function(err) { if (err) { next(false, 500, "Error: " + err); } else { // Pass false if you want to refuse the connection next(true); } }); }, });
Then the session will be available in
wsconn.upgradeReq.session
. -
Use a more elaborate challenging system, based upon four states:
AVAILABLE
,INVITATION SENT
,INVITATION RECEIVED
andPLAYING
. Allow a user to decline an invitation. -
Allow a user to quit a game by forfeiting (the game is considered lost for the user).
-
Handle unexpected disconnections (forfeit the game if the user is playing).
-
Allow playing many games simultaneously.
-
We already mentioned that using global variables is fragile. In a real-world scenario, where Node.js is distributed on many cores (see the Cluster API), global memory is not even an option.
Instead of using global state, you can use a database, for example an SQL one. However this is not the most efficient and practical solution.
An in-memory NoSQL system, such as Redis, is more apt to the task. Its pub/sub system works perfectly with Node.js and server push.
Replace global variables with a Redis database. You will have to go through the Redis manuals and the docs of the
node_redis
module.Redis is not available on Glitch. The simplest way is to develop locally.