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.
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
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:
handler.js
that will be executed when this function is triggered.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.
We’ve got the serverless part figured out. Time to solve the realtime part. How do we:
Here’s how we’ll do this with Pusher:
visitor-updates
). This is the channel where it will receive updates on the number of visitors.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.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.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 theprovider
. 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.
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.
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.