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 boto3REQUEST_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_HANDLEDdef 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_ws_message(connection_id, body)return REQUEST_HANDLEDdef 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_HANDLEDdef 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: falsecustom:
pythonRequirements:
noDeploy: [] # do not ignore botocoreprovider:
name: aws
[..]
region: us-east-1 # replace by the region you want to deploy to
websocketsApiName: ${self:service}-${opt:stage}-websocketApi
websocketsApiRouteSelectionExpression: $request.body.actioniamRoleStatements:
- 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: sendMessageenvironment:
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_ENDPOINT
variable. 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.