Build a serverless realtime presence counter with Node.js
To follow this tutorial you will need an account with AWS. You need to have installed Node.js on your local machine.
When building web apps, we typically divide our time between coding our app logic and maintaining servers to host our app. Serverless architecture allows us to focus on building our app’s logic, leaving all the server management to a cloud provider such as AWS. Serverless apps are also passive, in the sense that they use no resources when idle, so cost is saved as well.
In this tutorial, we’ll build a small web app to show how serverless and realtime can work together. Our app will have one page, where it displays the number of people currently viewing that page and updates it in realtime. We’ll run our app on AWS Lambda. Here’s a preview of our site in action:
You can check out the source code of the complete application on GitHub.
Prerequisites
- Node.js v6.5.0 or greater
- An AWS account. You can sign up for a free account here
- A Pusher account. Sign up here (it’s free).
Setting up the project
First, we’ll install the serverless framework, a CLI tool for building serverless apps:
npm install -g serverless
Next, we’ll create a new service using the AWS Node.js template. Create a folder to hold your service (I’m calling mine “tvass”, short for That Very Awesome Serverless Site) and run the following command in it:
serverless create --template aws-nodejs
This will populate the current directory with a few files. Your directory should have the following structure:
tvass
|- .gitignore
|- handler.js
|- serverless.yml
Building the serverless component
The serverless.yml
file describes our service so the serverless CLI can configure and deploy it to our provider. Let’s write our serverless.yml
. Replace the contents of the file with the following:
service: tvass
provider:
name: aws
runtime: nodejs6.10
functions:
home:
handler: handler.home
events:
- http:
path: /
method: get
cors: true
The format is easy to understand:
- In the service key, we state the name of our service.
- In the provider key, we specify the name of our provider and the runtime environment we wish to use.
- In the functions key, we list out the functions our app provides. Functions are the building blocks of our service. They’re used as entry points to the service to perform a specific action. For our service, our functions correspond to the routes in our app, which means we’ll have just one function, the one which renders the home page. The function is described by:
- a handler, which is the JavaScript function exported from our
handler.js
that will be executed when this function is triggered. - events which trigger the function. In this case, our desired event is a GET request to the root URL of our app.
- a handler, which is the JavaScript function exported from our
We defined handler.home
as the handler for the home
function. This means we need to write a home
function and export it from handler.js
. Let’s do that now.
First, we’ll install handlebars, which is what we’ll use as our template engine. We’ll also install the Pusher SDK. Create a package.json
file in your project root with the following content:
{
"dependencies": {
"handlebars": "^4.0.11",
"pusher": "^1.5.1"
}
}
Then run npm install
.
Next up, let’s create the home page view (a handlebars template). Create a file named home.hbs
with the following content:
<body>
<h2 align="center" id="visitorCount">{{ visitorCount }}</h2>
<p align="center">person(s) currently viewing this page</p>
</body>
Lastly, the handler itself. Replace the code in handler.js
with the following:
'use strict';
const hbs = require('handlebars');
const fs = require('fs');
let visitorCount = 0;
module.exports.home = (event, context, callback) => {
let template = fs.readFileSync(__dirname + '/home.hbs', 'utf8');
template = hbs.compile(template);
const response = {
statusCode: 200,
headers: { 'Content-type': 'text/html' },
body: template({ visitorCount })
};
callback(null, response);
};
In this function, we grab the template file, pass its contents to handlebars and render the result as a web page in the caller’s browser.
Building the realtime component
We’ve got the serverless part figured out. Time to solve the realtime part. How do we:
- get the number of people viewing the page?
- update this number when someone opens the page or leaves it?
Here’s how we’ll do this with Pusher:
- Our backend will record the current count of visitors and pass this to the view before rendering. We could store this count in a cache like Redis, but we’ll just store it in memory to keep this demo simple.
- Whenever the page is rendered on a browser, it subscribes to two public Pusher channels:
- An existing channel (let’s call this
visitor-updates
). This is the channel where it will receive updates on the number of visitors. - A new channel with a randomly generated name. The purpose of this channel is to trigger a Pusher event called
channel_occupied
, which will be sent via a webhook to our backend. Also, when the user leaves the page, the Pusher connection will be terminated, resulting in achannel_vacated
notification. - When the backend receives the
channel_occupied
orchannel_vacated
notifications, it re-calculates the visitor count and broadcasts the new value on thevisitor-updates
channel. Pages subscribed to this channel can then update their UI to reflect the new value.
We’ve already got the code for (1) in our handler.js
(the visitorCount
variable). Let’s update the home.hbs
view to behave as we set out in (2):
<body>
<h2 align="center" id="visitorCount">{{ visitorCount }}</h2>
<p align="center">person(s) currently viewing this page</p>
<script src="https://js.pusher.com/4.2/pusher.min.js"></script>
<script>
var pusher = new Pusher("{{ appKey }}", {
cluster: "{{ appCluster }}",
});
pusher.subscribe("{{ updatesChannel }}")
.bind('pusher:subscription_succeeded', function () {
pusher.subscribe(Date.now() + Math.random().toString(36).replace(/\W+/g, ''));
})
.bind('update', function (data) {
document.getElementById('visitorCount').textContent = data.newCount;
});
</script>
</body>
A few notes on the code snippet above:
appKey
,appCluster
andupdatesChannel
are variables that will be passed by our backend to the view when compiling with handlebars.- We first subscribe to our
updatesChannel
and wait for the Pusher eventsubscription_succeeded
before creating the new, random channel. This is so anupdate
event is triggered immediately (since a new channel is created)
Now, to the backend. First, we’ll update our home
handler to pass the variables mentioned above to the view. Then we’ll add a second handler, to serve as our webhook that will get notified by Pusher of the channel_occupied
and channel_vacated
events.
'use strict';
const hbs = require('handlebars');
const fs = require('fs');
const Pusher = require('pusher');
let visitorCount = 0;
const updatesChannel = 'visitor-updates';
module.exports.home = (event, context, callback) => {
let template = fs.readFileSync(__dirname + '/home.hbs', 'utf8');
template = hbs.compile(template);
const response = {
statusCode: 200,
headers: {
'Content-type': 'text/html'
},
body: template({
visitorCount,
updatesChannel,
appKey: process.env.PUSHER_APP_KEY,
appCluster: process.env.PUSHER_APP_CLUSTER,
})
};
callback(null, response);
};
module.exports.webhook = (event, context, callback) => {
let body = JSON.parse(event.body);
body.events.forEach((event) => {
// ignore any events from our public channel -- it's only for broadcasting
if (event.channel === updatesChannel) {
return;
}
visitorCount += event.name === 'channel_occupied' ? 1 : -1;
});
// notify all clients of new figure
const pusher = new Pusher({
appId: process.env.PUSHER_APP_ID,
key: process.env.PUSHER_APP_KEY,
secret: process.env.PUSHER_APP_SECRET,
cluster: process.env.PUSHER_APP_CLUSTER,
});
pusher.trigger(updatesChannel, 'update', {newCount: visitorCount});
// let Pusher know everything went well
callback(null, { statusCode: 200 });
};
Lastly, we need to declare this new endpoint (our webhook) as a function in our serverless.yml
. We’ll also add environment variables to hold our Pusher credentials:
service: tvass
provider:
name: aws
runtime: nodejs6.10
environment:
PUSHER_APP_ID: your-app-id
PUSHER_APP_KEY: your-app-key
PUSHER_APP_SECRET: your-app-secret
PUSHER_APP_CLUSTER: your-app-cluster
functions:
home:
handler: handler.home
events:
- http:
path: /
method: get
cors: true
webhook:
handler: handler.webhook
events:
- http:
path: /webhook
method: post
cors: true
Note the environment
section we added under the provider
. It’s used for specifying environment variables that all our functions will have access to. You’ll need to log in to your Pusher dashboard and create a new app if you haven’t already done so. Obtain your app credentials from your dashboard and replace the stubs above with the actual values.
Deploying
First, you’ll need to configure the serverless CLI to use your AWS credentials. Serverless has published a guide on that (in video and text formats).
Now run serverless deploy
to deploy your service.
We’ll need the URLs of our two routes. Look at the output after serverless deploy
is done. Towards the bottom, you should see the two URLs listed, something like this:
GET - https://xxxxxxxxx.execute-api.yyyyyyy.amazonaws.com/dev/
POST - https://xxxxxxxxx.execute-api.yyyyyyy.amazonaws.com/dev/webhook
Take note of those two—we’ll need them in a bit.
One last thing: you’ll need to enable Channel existence webhooks for our Pusher app. On your Pusher app dashboard, click on the “Webhooks” tab and select the “channel existence” radio button. In the text box, paste the URL of the webhook you obtained above, and click “Add”. Good to go!
Now visit the URL of the home page (the GET route) in a browser. Open it in multiple tabs and you should see the number of visitors go up or down as you open and close tabs.
Note: you might observe a small bug in our application: the visitors’ count always shows up as 0 when the page is loaded, before getting updated. This is because you can’t actually persist variables in memory across Lambda Functions, which is what we’re trying to do with our visitorsCount
variable. We could fix it by using an external data store like Redis or AWS S3, but that would add unnecessary complexity to this demo.
Conclusion
In this article, we’ve built a simple demo showing how we can integrate realtime capabilities in a serverless app. We could go on to display the number of actual users by filtering by IP address. If our app involved signing in, we could use presence channels to know who exactly was viewing the page. I hope you’ve gotten an idea of the possibilities available with serverless and realtime. Have fun trying out new implementations.
13 March 2018
by Shalvah Adebayo