Back

adamham.dev

Setup Serverless Websockets with API Gateway on AWS

March 07, 2020

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
view raw serverless.yml hosted with ❤ by GitHub

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();
};
view raw handler.js hosted with ❤ by GitHub

4. Deploy

In your terminal, run sls deploy or serverless deploy (sls is shorthand for serverless).

serverless deploy

photo

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. 🎉

photo

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!