“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:
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.
Let’s follow through the following steps to configure Serverless framework:
Install serverless
npm install -g serverless
Log in into your AWS account or set up one
Create serverless-deployer
IAM user
serverless-deployer
Configure serverless from the .csv file:
serverless config credentials --provider aws \
--key <Access key ID> \
--secret <Secret access key> \
--profile serverless-demo
Create iot-connector
IAM user
Add User
iot-connector
Programmatic access
and click “Next”Attach existing policies directly
AWSIoTDataAccess
from the list and click “Next”Create user
Download .csv
Make a directory for our project
mkdir serverless-aws-iot
cd serverless-aws-iot
Create a boilerplate serverless project in the above folder, name it backend
serverless create --template aws-nodejs --path backend
cd backend
Create an empty package.json
as serverless didn’t do it for us (version 1.14.0)
echo "{}" > package.json
Install serverless-offline for easy localhost development
npm install serverless-offline --save-dev
Next, let’s test the serverless setup. Edit serverless.yml
. After service: backend
add
plugins:
- serverless-offline
After handler: handler.hello
add:
events:
- http: GET /
Run the app:
serverless offline --port 8080
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.
Now, the Serverless Framework is configured to deploy code to our AWS account. Let’s create some useful Lambda functions to deploy.
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.
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.
Make sure the right dependencies are installed
npm i aws-signature-v4 --save; npm i crypto --save
Now, let’s test the function:
serverless offline --port 8080 start
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.
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: "*"
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.
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.
Make sure dependencies are installed:
npm i aws-sdk --save
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.
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.
Install the create-react-app:
npm install -g create-react-app
Navigate to the serverless-aws-iot
forder we initially created:
cd ..
Create a react project:
create-react-app frontend
cd frontend
We will be using react-bootstrap to quickly make some nice-looking UI.
npm i react-bootstrap --save; npm i bootstrap@3 --save
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';
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;
};
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.
Install dependencies:
npm i superagent --save; npm i mqtt --save; npm i guid --save
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.
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>
);
}
}
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%;
}
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'));
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"
Start the front-end:
npm start
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.
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.
Send some messages from either of the tabs and observe them appear in the messages window.
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
.