Setup Serverless Websockets with API Gateway on AWS
TL;DR : View the GitHub Repo
This is a fast guide on how to quickly get yourself started with web sockets using the serverless framework, API Gateway and DynamoDB.
There's a lot of other things you can do with WebSockets on API Gateway such as adding an Authorizer, etc. We're just covering the basics
Introduction
Sometimes we need a little more than what HTTP has to offer, being able to have real-time communication between a client/server would involve some kind of polling with HTTP. The WebSocket protocol supports real-time bidirectional communication between the client and server over a TCP socket connection.
Traditionally a websocket server is stateful which allows the server to keep track of connected clients, however we're using serverless and state is not persistent. To address this we will use DynamoDB to keep track of our clients.
Getting started
There's a few things you'll need on your machine before we get going.
Please make sure you have the serverless
and aws-cli
tools installed and configured on your machine.
npm install aws-cli -g
npm install serverless -g
Setting up the server
With the serverless framework deploying is made super simple, almost too simple so pay attention to the details here!
These are the following steps required:
1. Setup a serverless project
Setup the boilerplate code required for a NodeJS project on AWS by creating a new project with the following command:
serverless create —template aws-nodejs —name YOUR_PROJECT_NAME
2. Modify serverless.yml
In the serverless.yml file we will:
- Create a DynamoDB resource
- Set an environment variable for the table name
- Set the handlers for the WebSocket events
Note: The environment variable CONNECTION_DB_TABLE
(defined on Line 6) recursively references the TableName
value provided in the DynamoDB Table resource (Line 56).
service: aws-serverless-websockets | |
provider: | |
name: aws | |
runtime: nodejs12.x | |
environment: | |
CONNECTION_DB_TABLE: ${self:resources.Resources.MyAppTable.Properties.TableName} | |
iamRoleStatements: | |
- Effect: Allow | |
Action: | |
- "dynamodb:PutItem" | |
- "dynamodb:GetItem" | |
- "dynamodb:DeleteItem" | |
- "dynamodb:Scan" | |
Resource: | |
- Fn::GetAtt: [MyAppTable, Arn] | |
- Effect: Allow | |
Action: | |
- "execute-api:ManageConnections" | |
Resource: | |
- "arn:aws:execute-api:*:*:**/@connections/*" | |
functions: | |
connectHandler: | |
handler: handler.connectHandler | |
events: | |
- websocket: | |
route: $connect | |
disconnectHandler: | |
handler: handler.disconnectHandler | |
events: | |
- websocket: | |
route: $disconnect | |
defaultHandler: | |
handler: handler.defaultHandler | |
events: | |
- websocket: | |
route: $default | |
broadcastHandler: | |
handler: handler.broadcastHandler | |
events: | |
- websocket: | |
route: broadcastMessage | |
resources: | |
Resources: | |
MyAppTable: | |
Type: "AWS::DynamoDB::Table" | |
Properties: | |
AttributeDefinitions: | |
- AttributeName: "connectionId" | |
AttributeType: "S" | |
KeySchema: | |
- AttributeName: "connectionId" | |
KeyType: "HASH" | |
BillingMode: PAY_PER_REQUEST | |
TableName: myAppConnectionTable |
3. Implement handler.js
In handler.js, we’re going to:
- Import the
aws-sdk
library - Initialise the DynamoDB client and access our environment variable
-
Setup the 3 basic routes
- connect - when a new client connects
- disconnect - when a client disconnects
- default - catch-all events that don't have a custom route
-
Setup our custom route:
- broadcastMessage - will send a message to all connected clients
-
Write the functions to handle the events
- connectHandler - add the connectionId to the database
- disconnectHandler - remove the connectionId from the database
- defaultHandler - return an error that we couldn't match the event
- broadcastHandler - get all connected clients from the database then send them a message
"use strict"; | |
const AWS = require("aws-sdk"); | |
const dynamo = new AWS.DynamoDB.DocumentClient(); | |
const CONNECTION_DB_TABLE = process.env.CONNECTION_DB_TABLE; | |
const successfullResponse = { | |
statusCode: 200, | |
body: "Success", | |
}; | |
const failedResponse = (statusCode, error) => ({ | |
statusCode, | |
body: error, | |
}); | |
module.exports.connectHandler = (event, context, callback) => { | |
addConnection(event.requestContext.connectionId) | |
.then(() => { | |
callback(null, successfullResponse); | |
}) | |
.catch((err) => { | |
callback(failedResponse(500, JSON.stringify(err))); | |
}); | |
}; | |
module.exports.disconnectHandler = (event, context, callback) => { | |
deleteConnection(event.requestContext.connectionId) | |
.then(() => { | |
callback(null, successfullResponse); | |
}) | |
.catch((err) => { | |
console.log(err); | |
callback(failedResponse(500, JSON.stringify(err))); | |
}); | |
}; | |
module.exports.defaultHandler = (event, context, callback) => { | |
callback(null, failedResponse(404, "No event found")); | |
}; | |
module.exports.broadcastHandler = (event, context, callback) => { | |
sendMessageToAllConnected(event) | |
.then(() => { | |
callback(null, successfullResponse); | |
}) | |
.catch((err) => { | |
callback(failedResponse(500, JSON.stringify(err))); | |
}); | |
}; | |
const sendMessageToAllConnected = (event) => { | |
return getAllConnections().then((connectionData) => { | |
return connectionData.Items.map((connectionId) => { | |
return send(event, connectionId.connectionId); | |
}); | |
}); | |
}; | |
const getAllConnections = () => { | |
const params = { | |
TableName: CONNECTION_DB_TABLE, | |
ProjectionExpression: "connectionId", | |
}; | |
return dynamo.scan(params).promise(); | |
}; | |
const send = (event, connectionId) => { | |
const body = JSON.parse(event.body); | |
let postData = body.data; | |
console.log("Sending....."); | |
if (typeof postData === Object) { | |
console.log("It was an object"); | |
postData = JSON.stringify(postData); | |
} else if (typeof postData === String) { | |
console.log("It was a string"); | |
} | |
const endpoint = | |
event.requestContext.domainName + "/" + event.requestContext.stage; | |
const apigwManagementApi = new AWS.ApiGatewayManagementApi({ | |
apiVersion: "2018-11-29", | |
endpoint: endpoint, | |
}); | |
const params = { | |
ConnectionId: connectionId, | |
Data: postData, | |
}; | |
return apigwManagementApi.postToConnection(params).promise(); | |
}; | |
const addConnection = (connectionId) => { | |
const params = { | |
TableName: CONNECTION_DB_TABLE, | |
Item: { | |
connectionId: connectionId, | |
}, | |
}; | |
return dynamo.put(params).promise(); | |
}; | |
const deleteConnection = (connectionId) => { | |
const params = { | |
TableName: CONNECTION_DB_TABLE, | |
Key: { | |
connectionId: connectionId, | |
}, | |
}; | |
return dynamo.delete(params).promise(); | |
}; |
4. Deploy
In your terminal, run sls deploy
or serverless deploy
(sls is shorthand for serverless).
serverless deploy
5. Test that it's working!
Connect to the server
Grab the endpoint URL from the output of the deployment and use wscat
to connect.
wscat -c wss://XXXXXXXXX.execute-api.eu-central-1.amazonaws.com/dev
Trigger the custom event
Once connected, send this JSON string to test the custom broadcastMessage handler.
{ "action": "broadcastMessage", "data": "Hello World!" }
Expected output
If everything went well, you should receive the Hello World!
message back in your WebSocket session. 🎉
Voila! What's next?
Hopefully you made it through this guide and have a functioning WebSocket server, if you're ready to do more with it i'll be publishing a post about the client implementation really soon!