Adding Websockets to your AWS Serverless application

The quickest way to add Websockets to your AWS Serverless application

This guide aims to be the quickest way to adding a websockets endpoint to your existing Serverless application. We will be using Lamda functions written in Python.

I based this guide on a very complete but verbose article by Lance Goodridge
If you are looking for a full example of a full-stack chat application, Lance’s article has a lot of additional hints.

functions/websocket.py

Let’s create a handler for connecting, disconnecting, receiving messages and sending messages:

import os
import json
import boto3
REQUEST_HANDLED = {"statusCode": 200}
def connection_manager(event, context):
connection_id = event["requestContext"].get("connectionId")
if event["requestContext"]["eventType"] == "CONNECT":
print("Connect requested")
# you might want to store the connection_id in a database of some sort
return REQUEST_HANDLED
elif event["requestContext"]["eventType"] == "DISCONNECT":
print("Disconnect requested")
return REQUEST_HANDLED
def handle_incoming_ws_message(event, context):
"""
When a message comes in, just echo it back to the sender
"""
body = _get_event_body(event)
body['type'] = 'echoReply'
connection_id = event["requestContext"].get("connectionId")
_send_to_connection(connection_id, body, event)
return REQUEST_HANDLED
def default_message(event, context):
"""
Send back error when unrecognized WebSocket action is received.
"""
print("Unrecognized WebSocket action received.")
connection_id = event["requestContext"].get("connectionId")
send_ws_message(connection_id, {'type':'invalidRequest', 'error':'Unrecognized WebSocket action received.'})
return REQUEST_HANDLED
def send_ws_message(connection_id, body):
if not isinstance(body, str):
body = json.dumps(body)
_send_to_connection(connection_id, body)
def _get_event_body(event):
try:
return json.loads(event.get("body", ""))
except ValueError:
print("event body could not be JSON decoded.")
return {}
def _send_to_connection(connection_id, data):
endpoint = os.environ['WEBSOCKET_API_ENDPOINT']
gatewayapi = boto3.client("apigatewaymanagementapi",
endpoint_url=endpoint)
return gatewayapi.post_to_connection(ConnectionId=connection_id,
Data=data.encode('utf-8'))

Note that we always return REQUEST_HANDLED from a websocket handler function. Although websocket clients don’t need a response like in the case of HTTP, the Api Gateway that calls our handler does need one. The Api Gateway will generate a 502 error if you don’t return a dict containing the statusCode key.

requirements.txt

# replace by latest as listed on https://pypi.org/project/botocore/
botocore==1.12.133

As of may 2019, the bundled version of botocore is missing websocket features. That’s why we have to bundle it with our app.

serverless.yml

service: myapp # replace by your app name
package:
excludeDevDependencies: false
custom:
pythonRequirements:
noDeploy: [] # do not ignore botocore
provider:
name: aws
[..]
region: us-east-1 # replace by the region you want to deploy to
websocketsApiName: ${self:service}-${opt:stage}-websocketApi
websocketsApiRouteSelectionExpression: $request.body.action
iamRoleStatements:
- Effect: Allow
Action:
- "execute-api:ManageConnections"
Resource:
- "arn:aws:execute-api:*:*:**/@connections/*"
functions:
# allows clients to connect and disconnect
connectionManager:
handler: functions/websocket.connection_manager
events:
- websocket:
route: $connect
- websocket:
route: $disconnect
  # Catch-all route for unsupported actions, like HTML 404 
defaultMessage:
handler: functions/websocket.default_message
events:
- websocket:
route: $default
  # Handle incoming websocket messages
incomingMessage:
handler: functions/websocket.handle_incoming_ws_message
events:
- websocket:
route: sendMessage
environment:
WEBSOCKET_API_ENDPOINT: !Join ['', ['https://', !Ref WebsocketsApi, '.execute-api.', "${self:region}", '.amazonaws.com/', "${opt:stage}/"]]
plugins:
- serverless-python-requirements

The script above assumes you provide the stage variable through a deploy command line option like serverless deploy --stage mystage

If you haven’t installed the serverless-python-requirements plugin yet:

serverless plugin install -n serverless-python-requirements

When deploying these changes, you’ll discover that your websockets api gateway url differs from the REST url of any other endpoints. Compare the endpoints in this example output of serverless deploy:

serverless deploy --stage mystage
[..]
Serverless: Stack update finished...
Service Information
[..]
endpoints:
GET - https://chjpngb558.execute-api.us-east-1.amazonaws.com/mystage/functions/some_rest_handler/handle
wss://rs1bcx8hn2.execute-api.us-east-1.amazonaws.com/mystage

To send a message to a websocket client, we send it to an Api Gateway endpoint which in turn forwards it to the websocket for us. That’s why we defined the WEBSOCKET_API_ENDPOINTvariable. Clients connect to an actual websocket, so they use the wss:// url.

Note that your lambda function needs access to the public internet. See the section on troubleshooting below if you run into timeout exceptions when trying to send websocket messages to clients.

Testing

Install wscat:

npm install -g wscat

Connect to your endpoint using the wss:// url from your deploy output:

wscat -c <YOUR_WEBSOCKET_ENDPOINT>
connected (press CTRL+C to quit)
>

Send a message. Note that the action key in the message is used for the route selection, all other keys can be changed to your liking:

> {"action": "sendMessage", "favoriteColor": "green"}
< {"action": "sendMessage", "favoriteColor": "green", "type": "echoReply"}
> {"action": "haltAndCatchFire"}
< {"type":"invalidRequest", "error":"Unrecognized WebSocket action received."}

When you get a Internal Server Error Response, look in the cloudwatch logs of the incomingMessage function for errors or timeouts. See Troubleshooting for tips.

I recommend to always include a type key in messages sent to clients. Messages might be received over a single long-living connection, out of order and not necessarily as a response to a request, so it’s a good idea to let the client know what information to expect in the body of the message.

Implementing a real client

Implementing a javascript client is outside the scope of this article. I would recommend to use a library to handle websocket connections though, or a framework integration. For our VueJS app, we used vue-native-websocket which provided automatic reconnect and VueX integration. For an example of a full project including a client, see this article

Access control

Note that your websocket endpoint is currently public to the world and probably needs some access control. Because you probably already have some kind of authorizer set up for your REST endpoints, you might try to adapt / reuse it for websockets. Alternatively, refer to the Api Gateway Docs for possible solutions.

Troubleshooting

wscat shows a 502 error when connecting

See the cloudwatch logs for the connectionManager function

After sending a message, you get an Internal Server Error Response

The Internal Server Error is generating by the Api Gateway and hides the actual error encountered by your lambda function. See the cloudwatch logs for your lambda function (eg incomingMessage or defaultMessage) and read for solutions to more specific problems

An error response without any sign of trouble in cloudwatch

Probably means you did not return a response from your lambda function. The function completed successfully (hence no error), but the Api gateway got no response and thus throws an error. Check whether your lambda functions end with return REQUEST_HANDLED

Unknown service: ‘apigatewaymanagementapi’

Botocore is probably too old. Make sure you added it to requirements.txt, added the serverless-python-requirements plugin, and configured it with noDeploy: []

Task timed out after 6.01 seconds

A lambda function probably tried to access the WEBSOCKET_API_ENDPOINT but failed because you run your lambda in a VPC which does not have internet access.

Add a vpc to every function which sends messages to websockets:

functions:
defaultMessage:
handler: functions/websocket.default_message
events:
- websocket:
route: $default
vpc:
subnetIds:
- !Ref NatSubnet
# Handle incoming websocket messages
incomingMessage:
handler: functions/websocket.handle_incoming_ws_message
events:
- websocket:
route: sendMessage
vpc:
subnetIds:
- !Ref NatSubnet
resources:
Resources:
# Use the NatSubnet for Lambda functions which require outbound internet access
# See https://aws.amazon.com/premiumsupport/knowledge-center/internet-access-lambda-function/
NatSubnet:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
CidrBlock: 10.0.3.0/24
Tags:
- Key: Name
Value: '${self:service}-${self:provider.stage}-nat-subnet'
    NatGatewayEIP:
Type: AWS::EC2::EIP
    NatGateway:
Type: AWS::EC2::NatGateway
Properties:
AllocationId: !GetAtt NatGatewayEIP.AllocationId
SubnetId: !Ref Subnet1
Tags:
- Key: Name
Value: '${self:service}-${self:provider.stage}-nat-gateway'
    NatRouteTable:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref VPC
Tags:
- Key: Name
Value: '${self:service}-${self:provider.stage}-nat-rtb'
    NatInternetRoute:
Type: AWS::EC2::Route
DependsOn: NatGateway
Properties:
DestinationCidrBlock: 0.0.0.0/0
RouteTableId: !Ref NatRouteTable
NatGatewayId: !Ref NatGateway
    NatSubnetRouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref NatSubnet
RouteTableId: !Ref NatRouteTable

About Artificial Industry: We help entrepreneurs to change the world by transforming their ideas fast and efficiently into successful online businesses. We do this by creating (data) prototypes and MVP’s for our clients.