Tutorial: Real-time front-end updates with React, Serverless, and WebSockets on AWS IoT

By Georgii Oleinikov
June 09, 2017

“Serverless” architectures are getting more popular nowadays, replacing server-based stacks. Serverless doesn’t mean that you don’t have a server though. It only means that you don’t own your servers and even don’t manage them. For instance, if you use JavaScript, you don’t have a server, instance or container with a NodeJS running waiting for the request. Instead, each request goes to an API Gateway that spawns a new NodeJS instance if necessary, and has it process the request on demand.

The advantages are obvious:

  • You don’t have to manage and maintain (virtual) servers
  • You don’t have to worry about the health of your instances
  • You don’t have to worry about scaling
  • A lot of “glue” code that you’d have to write in other frameworks (e.g. Django or Rails) becomes a simple configuration exercise
  • You pay only for what you are using

So, if serverless is so awesome, why is it not a go-to choice for a back-end architecture? I’d argue that it should be. It’s like turning to the cloud in the old-ish days: you should always go for the cloud unless you have reasons not to. So what can be the reasons that may force you out of serverless? One problem that we encountered is that we had a serverless app, but we wanted to have real-time updates that most of the cool websites have these days. This requires some sort of persistent connection with a server, but how can you have a persistent connection with a server if you don’t have a persistent server?

It turns out that AWS has a nice solution for this: AWS IoT. It supports real-time communication using MQTT protocol over WebSockets, so we can use that for our web app. Microsoft Azure offers similar features with its IoT Hub while Google Cloud Platform uses Compute Engine for a WebSocket server.

Surprisingly, there are not so many articles on the web on how to do integrate a WebSockets communication via IoT into a serverless app, so we decided to write this blog post about it. We wanted to make this post friendly for people who haven’t worked with serverless, and so I will cover how to:

We will be using a simple chat app to demonstrate the above functionalities. The source code is available on GitHub. Alright, enough of this intro, let’s get our hands dirty.


Part I: Serverless back-end

A) Setup project and configure Serverless

Let’s follow through the following steps to configure Serverless framework:

  1. Install serverless

     npm install -g serverless
    
  2. Log in into your AWS account or set up one

  3. Create serverless-deployer IAM user

    • Go to AWS console
    • Navigate IAM -> Users and click “Add User”
    • Set “User name” to serverless-deployer
    • Set “Access type” to “Programmatic access” and click “Next”
    • Click “Attach existing policies directly”
    • Select “AdministratorAccess” from the list and click “Next”
    • Review information and click “Create user”
    • Click “Download .csv”
  4. Configure serverless from the .csv file:

     serverless config credentials --provider aws \
         --key <Access key ID> \
         --secret <Secret access key> \
         --profile serverless-demo
    
  5. Create iot-connector IAM user

    • Navigate IAM -> Users and click Add User
    • Set “User name” to iot-connector
    • Set “Access type” to Programmatic access and click “Next”
    • Click Attach existing policies directly
    • Select AWSIoTDataAccess from the list and click “Next”
    • Review information and click Create user
    • Click Download .csv
  6. Make a directory for our project

     mkdir serverless-aws-iot
     cd serverless-aws-iot
    
  7. Create a boilerplate serverless project in the above folder, name it backend

     serverless create --template aws-nodejs --path backend
     cd backend
    
  8. Create an empty package.json as serverless didn’t do it for us (version 1.14.0)

     echo "{}" > package.json
    
  9. Install serverless-offline for easy localhost development

     npm install serverless-offline --save-dev
    
  10. Next, let’s test the serverless setup. Edit serverless.yml. After service: backend add

    plugins:
      - serverless-offline
    
  11. After handler: handler.hello add:

    events:
      - http: GET /
    
  12. Run the app:

    serverless offline --port 8080
    
  13. Finally, navigate in the browser to localhost:8080 and observe the output that includes the message Go Serverless v1.0! Your function executed successfully!. You can inspect handler.js function to see how the response is generated.


B) Create Lambdas

Now, the Serverless Framework is configured to deploy code to our AWS account. Let’s create some useful Lambda functions to deploy.

  1. Edit the existing serverless.yml file. Remove its contents and add:

     service: serverless-aws-iot
    
     plugins:
       - serverless-offline
    
     provider:
       name: aws
       runtime: nodejs6.10
       stage: dev
       region: eu-west-1
    
     functions:
       iotPresignedUrl:
         handler: src/iotPresignedUrl.handler
         timeout: 30
         events:
           - http: OPTIONS /iot-presigned-url
           - http:
               method: GET
               path: /iot-presigned-url
         environment:
           IOT_AWS_REGION: '<Your AWS region>'
           IOT_ENDPOINT_HOST: '<Pick from AWS console IoT -> Settings -> Endpoint>'
           IOT_ACCESS_KEY: '<Access key ID from iot-connector>'
           IOT_SECRET_KEY: '<Secret access key from iot-connector>'
    

    This configures Serverless to use the correct AWS region and project stage as well as defines our first Lambda function: iotPresignedUrl. The function will vend a signed URL that web clients will use to connect to AWS IoT. Make sure to replace IOT_AWS_REGION, IOT_ENDPOINT_HOST, IOT_ACCESS_KEY and IOT_SECRET_KEY in the above config with the corresponding values from AWS console.

  2. Next, let’s add some Lambda code. Create the file for it

     mkdir src
     mkdir src/iotPresignedUrl
     touch src/iotPresignedUrl/index.js
    

    Now, open the file src/iotPresignedUrl/index.js and add the following code:

     'use strict';
    
     const v4 = require('aws-signature-v4');
     const crypto = require('crypto');
    
     exports.handler = (event, context, callback) => {
         const url = v4.createPresignedURL(
             'GET',
             process.env.IOT_ENDPOINT_HOST.toLowerCase(),
             '/mqtt',
             'iotdevicegateway',
             crypto.createHash('sha256').update('', 'utf8').digest('hex'),
             {
                 'key': process.env.IOT_ACCESS_KEY,
                 'secret': process.env.IOT_SECRET_KEY,
                 'protocol': 'wss',
                 'region': process.env.IOT_AWS_REGION,
             }
         );
    
         const response = {
             statusCode: 200,
             body: JSON.stringify({ url: url }),
         };
    
         callback(null, response);
     }
    

    This is a NodeJS Lambda function. When it’s executed, the exports.handler method is called, and when callback is called, the result is returned to the caller. Pretty easy, huh? In this particular lamba we use our AWS keys in order to sign the IoT URL and return to the caller. The signed URL will be used as a way to authenticate the front-end clients against AWS IoT. There are other ways to do that and we will mention them briefly in the end.

  3. Make sure the right dependencies are installed

     npm i aws-signature-v4 --save; npm i crypto --save
    
  4. Now, let’s test the function:

     serverless offline --port 8080 start
    
  5. Navigate in the browser to localhost:8080/iot-presigned-url. You should see something like this:

     { "url": "wss://<endpoint>.iot.eu-west-1.amazonaws.com/mqtt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=<credential>%2Feu-west-1%2Fiotdevicegateway%2Faws4_request&X-Amz-Date=<date>&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&X-Amz-Signature=<signature>" }
    

    We will now use this URL on the front-end to connect to AWS IoT. But before we do that, let’s add another Lambda function to demonstrate how we can set up interaction between IoT and Lambda.

  6. Edit serverless.yml again. Add the following to the provider section:

      iamRoleStatements:
        - Effect: "Allow"
          Action:
            - "iot:Connect"
            - "iot:Publish"
            - "iot:Subscribe"
            - "iot:Receive"
            - "iot:GetThingShadow"
            - "iot:UpdateThingShadow"
          Resource: "*"
    
  7. Add the following to the functions section:

      notifyDisconnect:
        handler: src/notifyDisconnect.handler
        timeout: 30
        events:
          - iot:
              sql: "SELECT * FROM 'last-will'"
        environment:
          IOT_AWS_REGION: '<Your AWS region>'
          IOT_ENDPOINT_HOST: '<Pick from AWS console IoT -> Settings -> Endpoint>'
    

    Here we are telling AWS to invoke the notifyDisconnect Lambda function when any message (*) arrives to the last-will IoT topic.

  8. Next, create file src/notifyDisconnect/index.js with the following content:

     'use strict';
    
     const AWS = require('aws-sdk');
    
     AWS.config.region = process.env.IOT_AWS_REGION;
     const iotData = new AWS.IotData({ endpoint: process.env.IOT_ENDPOINT_HOST });
    
     exports.handler = (message) => {
         let params = {
             topic: 'client-disconnected',
             payload: JSON.stringify(message),
             qos: 0
         };
    
         iotData.publish(params, function(err, data){
             if(err){
                 console.log('Unable to notify IoT of stories update: ${err}');
             }
             else{
                 console.log('Successfully notified IoT of stories update');
             }
         });
     };
    

    The notifyDisconnect function is simply redirecting a message from the last-will topic to the client-disconnected topic. Reason being, when we connect front-end clients to the AWS IoT, we will be setting a so-called “last will and testament” message, which will be sent to a given topic (last-will in our case) in a case when the client disconnects.

    The front-end will listen to the client-disconnected topic to update the user statuses. This is of course an unnecessary complexity as we could have sent the last will message to the client-disconnected topic in the first place, but I want to demonstrate how Lambda functions can interact with IoT.

  9. Make sure dependencies are installed:

     npm i aws-sdk --save
    
  10. Finally, deploy the functions:

    serverless deploy --stage dev --region eu-west-1
    

The deployment will upload the functions to AWS as well as set up CloudWatch logging. We don’t really need to deploy the iotPresignedUrl function just to test it out as it can be invoked locally. However, the notifyDisconnect function is deployed together with an IoT rule invoking it, so it has to be deployed to take effect. Now that we have the basic back-end set up, let’s get going with some front-end.


Part II: React front-end


A) Create a React project

The create-react-app tool is a nice way to quickly start a react project without doing any configuration. We will use it for the purposes of this demo.

  1. Install the create-react-app:

     npm install -g create-react-app
    
  2. Navigate to the serverless-aws-iot forder we initially created:

    cd ..
    
  3. Create a react project:

     create-react-app frontend
     cd frontend
    
  4. We will be using react-bootstrap to quickly make some nice-looking UI.

     npm i react-bootstrap --save; npm i bootstrap@3 --save
    
  5. Edit src/App.js that was added by create-react-app and add to the beginning:

     import 'bootstrap/dist/css/bootstrap.css';
     import 'bootstrap/dist/css/bootstrap-theme.css';
    
  6. Finally, let’s add some code to interact with AWS IoT. Create a new src/RealtimeClient.js file with the following content:

     import request from 'superagent';
     import mqtt from 'mqtt';
    
     const LAST_WILL_TOPIC = 'last-will';
     const MESSAGE_TOPIC = 'message';
     const CLIENT_CONNECTED = 'client-connected';
     const CLIENT_DISCONNECTED = 'client-disconnected';
    
     const getNotification = (clientId, username) => JSON.stringify({ clientId, username });
    
     const validateClientConnected = (client) => {
         if (!client) {
             throw new Error("Client is not connected yet. Call client.connect() first!");
         }
     };
    
     export default (clientId, username) => {
         const options = {
             will: {
                 topic: LAST_WILL_TOPIC,
                 payload: getNotification(clientId, username),
             }
         };
         let client = null;
    
         const clientWrapper = {};
         clientWrapper.connect = () => {
             return request('/iot-presigned-url')
                 .then(response => {
                     client = mqtt.connect(response.body.url, options);
                     client.on('connect', () => {
                         console.log('Connected to AWS IoT Broker');
                         client.subscribe(MESSAGE_TOPIC);
                         client.subscribe(CLIENT_CONNECTED);
                         client.subscribe(CLIENT_DISCONNECTED);
                         const connectNotification = getNotification(clientId, username);
                         client.publish(CLIENT_CONNECTED, connectNotification);
                         console.log('Sent message: ${CLIENT_CONNECTED} - ${connectNotification}');
                     });
                     client.on('close', () => {
                         console.log('Connection to AWS IoT Broker closed');
                         client.end();
                     });
                 })
         }
         clientWrapper.onConnect = (callback) => {
             validateClientConnected(client)
             client.on('connect', callback);
             return clientWrapper;
         };
         clientWrapper.onDisconnect = (callback) => {
             validateClientConnected(client)
             client.on('close', callback);
             return clientWrapper;
         };
         clientWrapper.onMessageReceived = (callback) => {
             validateClientConnected(client)
             client.on('message', (topic, message) => {
                 console.log('Received message: ${topic} - ${message}');
                 callback(topic, JSON.parse(message.toString('utf8')));
             });
             return clientWrapper;
         };
         clientWrapper.sendMessage = (message) => {
             validateClientConnected(client)
             client.publish(MESSAGE_TOPIC, JSON.stringify(message));
             console.log('Sent message: ${MESSAGE_TOPIC} - ${JSON.stringify(message)}');
             return clientWrapper;
         };
         return clientWrapper;
     };
    

B) Connect to IoT

We will be using this client as our gateway for AWS IoT. The client uses the superagent ajax library to fetch the presigned URL, and then mqtt library to connect to IoT. The guid library will be used in order to generate clientId that will be provided to IoT for every browser window.

  1. Install dependencies:

     npm i superagent --save; npm i mqtt --save; npm i guid --save
    
  2. Now let’s use it in our main component. Open App.js and replace the existing class App with the following:

     class App extends Component {
         constructor(props) {
             super(props);
    
             this.onSend = this.onSend.bind(this);
             this.connect = this.connect.bind(this);
    
             this.state = {
                 users: [],
                 messages: [],
                 clientId: getClientId(),
                 isConnected: false,
             };
         }
    
         connect(username) {
             this.setState({ username });
    
             this.client = new RealtimeClient(this.state.clientId, username);
    
             this.client.connect()
                 .then(() => {
                     this.setState({ isConnected: true });
                     this.client.onMessageReceived((topic, message) => {
                         if (topic === "client-connected") {
                             this.setState({ users: [...this.state.users, message] })
                         } else if (topic === "client-disconnected") {
                             this.setState({ users: this.state.users.filter(user => user.clientId !== message.clientId) })
                         } else {
                             this.setState({ messages: [...this.state.messages, message] });
                         }
                     })
                 })
         }
    
         onSend(message) {
             this.client.sendMessage({
                 username: this.state.username,
                 message: message,
                 id: getMessageId(),
             });
         };
    
         render() {
             return (
                 <div>
                     <ChatHeader
                         isConnected={ this.state.isConnected }
                     />
                     <ChatWindow
                         users={ this.state.users }
                         messages={ this.state.messages }
                         onSend={ this.onSend }
                     />
                     <UserNamePrompt
                         onPickUsername={ this.connect }
                     />
                 </div>
             );
         }
     }
    

    The App component will be the “container” component with the app state. There will be three “presentational“ components: UserNamePrompt, ChatHeader and ChatWindow. UserNamePrompt will ask for the username before the user connects to the chat, ChatHeader will display the connectivity status and ChatWindow will display the current list of users and messages as well as a functionality to send a message.

  3. Now, to make this work, let’s add the presentational components we used above. Nothing fancy, just some bootstrap components. Normally we would add them into separate files, but in this case we will add them to App.js. After the imports and before the class App in App.js add the following:

     import Guid from 'guid';
     import {
         Grid,
         Row,
         Col,
         Form,
         FormControl,
         Button,
         ListGroup,
         ListGroupItem,
         Nav,
         Navbar,
         NavItem,
         InputGroup,
         Modal,
     } from 'react-bootstrap';
     import RealtimeClient from './RealtimeClient';
    
     const getClientId = () => 'web-client:' + Guid.raw();
     const getMessageId = () => 'message-id:' + Guid.raw();
    
     const User = (user) => (
         <ListGroupItem key={user.clientId}>{ user.username }</ListGroupItem>
     );
    
     const Users = ({ users }) => (
         <div id="sidebar-wrapper">
             <div id="sidebar">
                 <ListGroup>
                     <ListGroupItem key='title'><i>Connected users</i></ListGroupItem>
                     { users.map(User) }
                 </ListGroup>
             </div>
         </div>
     );
    
     const Message = (message) => (
         <ListGroupItem key={message.id}><b>{message.username}</b> : {message.message}</ListGroupItem>
     );
    
     const ChatMessages = ({ messages }) => (
         <div id="messages">
             <ListGroup>
                 <ListGroupItem key='title'><i>Messages</i></ListGroupItem>
                 { messages.map(Message) }
             </ListGroup>
         </div>
     );
    
     const ChatHeader = ({ isConnected }) => (
         <Navbar fixedTop>
             <Navbar.Header>
                 <Navbar.Brand>
                    Serverless IoT chat demo
                 </Navbar.Brand>
             </Navbar.Header>
             <Nav>
                 <NavItem>{ isConnected ? 'Connected' : 'Not connected'}</NavItem>
             </Nav>
         </Navbar>
     );
    
     const ChatInput = ({ onSend }) => {
         const onSubmit = (event) => {
             onSend(this.input.value);
             this.input.value = '';
             event.preventDefault();
         }
         return (
             <Navbar fixedBottom fluid>
                 <Col xs={9} xsOffset={3}>
                     <Form inline onSubmit={ onSubmit }>
                         <InputGroup>
                             <FormControl
                                 type="text"
                                 placeholder="Type your message"
                                 inputRef={ref => { this.input = ref; }}
                             />
                             <InputGroup.Button>
                                 <Button type="submit" >Send</Button>
                             </InputGroup.Button>
                         </InputGroup>
                     </Form>
                 </Col>
             </Navbar>
         );
     };
    
     const ChatWindow = ({ users, messages, onSend }) => (
         <div>
             <Grid fluid>
                 <Row>
                     <Col xs={3}>
                         <Users
                             users={ users }
                         />
                     </Col>
                     <Col xs={9}>
                         <ChatMessages
                             messages={ messages }
                         />
                     </Col>
                 </Row>
             </Grid>
             <ChatInput onSend={ onSend }/>
         </div>
     );
    
     class UserNamePrompt extends Component {
         constructor(props) {
             super(props);
    
             this.state = { showModal: true }
         }
    
         render() {
             const onSubmit = (event) => {
                 if (this.input.value) {
                     this.props.onPickUsername(this.input.value);
                     this.setState({ showModal: false });
                 }
                 event.preventDefault();
             }
             return (
                 <Modal show={this.state.showModal} bsSize="sm">
                     <Form inline onSubmit={ onSubmit }>
                         <Modal.Header closeButton>
                             <Modal.Title>Pick your username</Modal.Title>
                         </Modal.Header>
                         <Modal.Body>
                             <FormControl
                                 type="text"
                                 placeholder="Type your username"
                                 inputRef={ref => {
                                     this.input = ref;
                                 }}
                             />
                         </Modal.Body>
                         <Modal.Footer>
                             <Button type="submit">Ok</Button>
                         </Modal.Footer>
                     </Form>
                 </Modal>
             );
         }
     }
    
  4. React bootstrap does a lot of styling for us, but we need a bit more to make it pretty, feel free to skip it. Replace the contents of the App.css with the following:

     body {
       padding-top: 50px !important;
     }
    
     @media (max-width: 614px) {
       body {
         padding-top: 260px !important;
       }
     }
    
     .navbar-fixed-bottom .container-fluid {
       padding-top: 7px;
       margin-right: 0px;
     }
    
     .navbar .container {
       margin: 0px !important;
     }
    
     #sidebar-wrapper {
       height: 100%;
       padding: 0px;
       position: fixed;
       border-right: 1px solid lightgray;
       width: 23%;
     }
    
     #sidebar {
       position: relative;
       height: 100%;
       overflow-x: hidden;
       overflow-y: scroll;
     }
    
     #sidebar .list-group-item {
       border-radius: 0;
       border-style: solid;
       border-color: lightgray;
       border-width: 0 0 1px 0;
       margin-top: 10px;
     }
    
     #messages .list-group-item {
       border: 0;
       margin: 10px 0 15px 0;
     }
    
     .modal-dialog {
       position: absolute;
       top: 35%;
       left: 40%;
       width: 20%;
     }
    
  5. Remove things that we don’t need from index.js that were created by create-react-app. Replace its contents with:

     import React from 'react';
     import ReactDOM from 'react-dom';
     import App from './App';
    
     ReactDOM.render(<App />, document.getElementById('root'));
    
  6. We need to do one more thing. We won’t be able to do local development because of CORS: our react app is vended on port 3000, and serverless Lambdas live on 8080. Instead of enabling CORS we can enable proxying only on dev by adding the following at the end of package.json:

     "proxy": "http://localhost:8080"
    
  7. Start the front-end:

     npm start
    
  8. Now that we’ve built and configured the front-end, let’s test it out! Navigate to localhost:3000, enter the username ninja and observe our beautiful chat app.

  9. Open another tab with localhost:3000, enter the username coder. Open the old window and see that coder appeared on the left. That’s because each client sent a message with the username to client-connected topic upon establishing a connection. Note that ninja won’t appear in the second tab because the message was sent before the second tab was opened. In order to fix this we would have to maintain a list of connected users in a DB, but I’m sure it won’t be a big challenge for you to implement now that you know how front-end, IoT and Lambda interact together.

  10. Send some messages from either of the tabs and observe them appear in the messages window.


Part III: Future work

What can we improve in our current set up? There are several things:

  • First and foremost, the iotPresignedUrl Lambda is accessed without any authentication. This cannot be used in production. One way to fix this is to add custom authorizer for the API Gateway.

  • Another way is to have authentication via Cognito for API Gateway and IoT. In that case you may not need the iotPresignedUrl Lambda, as IoT supports authentication with Cognito.

  • The fact that we have our AWS secrets in serverless config is obviously not great, as developers can accidentally push the secrets to the git repository, potentially exposing them. One of the ways around this is to use the serverless-secrets-plugin.

Author: Georgii Oleinikov, Full-stack JavaScript Developer.

Georgii is a full-stack developer with 5 years experience. He can go all the way: from machine learning to frontend development, using technologies such as nodeJS, ReactJs, Angular, and Java.