Build a serverless realtime presence counter with Node.js

Introduction

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
  • Pusher acccount. Create a free sandbox Pusher account or sign in.

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:

1tvass
2    |- .gitignore
3    |- handler.js
4    |- 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:

1service: tvass
2    
3    provider:
4      name: aws
5      runtime: nodejs6.10
6    
7    functions:
8      home:
9        handler: handler.home
10        events:
11          - http:
12              path: /
13              method: get
14              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.

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:

1{
2      "dependencies": {
3        "handlebars": "^4.0.11",
4        "pusher": "^1.5.1"
5      }
6    }

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:

1<body>
2    <h2 align="center" id="visitorCount">{{ visitorCount }}</h2>
3    <p align="center">person(s) currently viewing this page</p>
4    </body>

Lastly, the handler itself. Replace the code in handler.js with the following:

1'use strict';
2    
3    const hbs = require('handlebars');
4    const fs = require('fs');
5    
6    let visitorCount = 0;
7    
8    module.exports.home = (event, context, callback) => {
9        let template = fs.readFileSync(__dirname + '/home.hbs', 'utf8');
10        template = hbs.compile(template);
11    
12        const response = {
13            statusCode: 200,
14            headers: { 'Content-type': 'text/html' },
15            body: template({ visitorCount })
16        };
17    
18        callback(null, response);
19    };

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 a channel_vacated notification.
  • When the backend receives the channel_occupied or channel_vacated notifications, it re-calculates the visitor count and broadcasts the new value on the visitor-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):

1<body>
2    <h2 align="center" id="visitorCount">{{ visitorCount }}</h2>
3    <p align="center">person(s) currently viewing this page</p>
4    
5    <script src="https://js.pusher.com/4.2/pusher.min.js"></script>
6    <script>
7        var pusher = new Pusher("{{ appKey }}", {
8            cluster: "{{ appCluster }}",
9        });
10        pusher.subscribe("{{ updatesChannel }}")
11                .bind('pusher:subscription_succeeded', function () {
12                    pusher.subscribe(Date.now() + Math.random().toString(36).replace(/\W+/g, ''));
13                })
14                .bind('update', function (data) {
15                    document.getElementById('visitorCount').textContent = data.newCount;
16                });
17    </script>
18    
19    </body>

A few notes on the code snippet above:

  • appKey, appCluster and updatesChannel 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 event subscription_succeeded before creating the new, random channel. This is so an update 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.

1'use strict';
2    
3    const hbs = require('handlebars');
4    const fs = require('fs');
5    const Pusher = require('pusher');
6    
7    let visitorCount = 0;
8    const updatesChannel = 'visitor-updates';
9    
10    module.exports.home = (event, context, callback) => {
11        let template = fs.readFileSync(__dirname + '/home.hbs', 'utf8');
12        template = hbs.compile(template);
13    
14        const response = {
15            statusCode: 200,
16            headers: {
17                'Content-type': 'text/html'
18            },
19            body: template({
20                visitorCount,
21                updatesChannel,
22                appKey: process.env.PUSHER_APP_KEY,
23                appCluster: process.env.PUSHER_APP_CLUSTER,
24            })
25        };
26    
27        callback(null, response);
28    };
29    
30    module.exports.webhook = (event, context, callback) => {
31        let body = JSON.parse(event.body);
32        body.events.forEach((event) => {
33            // ignore any events from our public channel -- it's only for broadcasting
34            if (event.channel === updatesChannel) {
35                return;
36            }
37            visitorCount += event.name === 'channel_occupied' ? 1 : -1;
38        });
39    
40        // notify all clients of new figure
41        const pusher = new Pusher({
42            appId: process.env.PUSHER_APP_ID,
43            key: process.env.PUSHER_APP_KEY,
44            secret: process.env.PUSHER_APP_SECRET,
45            cluster: process.env.PUSHER_APP_CLUSTER,
46        });
47        pusher.trigger(updatesChannel, 'update', {newCount: visitorCount});
48    
49        // let Pusher know everything went well
50        callback(null, { statusCode: 200 });
51    };

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:

1service: tvass
2    
3    provider:
4      name: aws
5      runtime: nodejs6.10
6      environment:
7        PUSHER_APP_ID: your-app-id
8        PUSHER_APP_KEY: your-app-key
9        PUSHER_APP_SECRET: your-app-secret
10        PUSHER_APP_CLUSTER: your-app-cluster
11    
12    functions:
13      home:
14        handler: handler.home
15        events:
16        - http:
17            path: /
18            method: get
19            cors: true
20      webhook:
21        handler: handler.webhook
22        events:
23        - http:
24            path: /webhook
25            method: post
26            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:

1GET - https://xxxxxxxxx.execute-api.yyyyyyy.amazonaws.com/dev/
2     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 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.