Using API Gateway WebSockets with the Serverless Framework

As we approach the end of 2018, I’m incredibly excited to announce that we at Serverless have a small gift for you: You can work with Amazon API Gateway WebSockets in your Serverless Framework applications starting right now.

But before we dive into the how-to, there are some interesting caveats that I want you to be aware of.

First, this is not supported in AWS CloudFormation just yet, though AWS has publicly stated it will be early next year! As such, we decided to implement our initial support as a plugin and keep it out of core until the official AWS CloudFormation support is added.

Second, the configuration syntax should be pretty close, but we make no promises that anything implemented with this will carry forward after core support. And once core support is added with AWS CloudFormation, you will need to recreate your API Gateway resources managed by CloudFormation. This means that any clients using your WebSocket application would need to be repointed, or other DNS would have needed to be in place, to facilitate the cutover.

I recommend you check out my original post for a basic understanding of how WebSockets works at a technical level via connections and callbacks to the Amazon API Gateway connections management API.

With all that out of the way, play with our new presents!

How it works—we kept it familiar

Integrating a WebSocket API in your serverless app will feel like second nature if you’re already using our http events.

A simple application might look something like the following serverless.yml:

Chat app architecture

As users connect and disconnect, we store their connection Id in the DynamoDB table, as well as register them into the "General" chat channel.

Users can then:

Subscribe to a channel (the first subscription creates the channel)

Unsubscribe from a channel

Send a message to all users in a channel

Each time any of these things occurs, we send out a broadcast to all subscribers of a channel what has happened. If someone joined the channel, left (or disconnected and left all channels), or a message was sent.

When a user disconnects, we use the "disconnect" message from API Gateway to delete all the connection subscriptions so we don't waste cycles trying to send messages to dead connections.

When a user sends a message via the WebSockets, we look up all the subscriptions and their connection Id's from the DynamoDB table, and send them a message over their corresponding WebSocket with the content and other information—straightforward behavior, and similar to what you would expect for WebSockets.

Why DynamoDB streams?

So, what are we leveraging DynamoDB streams for you ask?

We decided to think about things a bit differently to demonstrate the power of this architecture. When a user unsubscribes or subscribes to a channel, we don't immediately notify everyone in the same Lambda invocation. Rather, we have AWS Lambda receive that stream and process it asynchronously.

It still happens extremely fast, and to all WebSocket clients, it appears no different.

The real power of this approach is: say you have sub-services or systems running that want to send messages, or ban users. Those subsystems don't need to care about the implementation of the WebSocket system; they simply work with the DynamoDB table and can create, update and delete subscriptions, send bot messages, etc. Those changes flow through the exact same pattern as if they were issued via WebSocket clients themselves.

I think this is a pretty neat concept, and I am curious to see what folks build with it!

A couple notes about WebSockets and the ApiGatewayManagementApi

You cannot send messages back the typical way as an HTTP response payload you may be used to with API Gateway HTTP. Just return back a statusCode (ex: 200) property in your payload to tell API Gateway everything is good, but it will not send that to the client. If there are errors like a 500, those will go to the client.

You cannot send a WebSocket message via the Management API in the $connect route, that needs to succeed before the socket connection will allow messages to flow. You will get a 410 code meaning the connection is "Gone" (or doesn't exist yet.).

For some psuedo-code, it would look something like this:

const success = {
statusCode: 200
}
asyncfunctionconnectionHandler(event, context){
await saveConnectionInfoToDyanmoDB(event.requestContext.connectionId);
return success;
// if we would try to post to the websocket management api here, we would get a 410// we must first "succesfully" execute this connection handler to establish the WebSocket
}
// assume there is other logic and processes that save "channel" subscriptions for each// subscriber, along with their connectionId informationasyncfunctionmessageHandler(event, context){
const payload = JSON.parse(event.body);
// fetch anyone subscribed to a channel defined in payload for a datastoreconst subscribers = await fetchSubscribersToChannel(payload.channelId);
// for each subscriber to the channel, we have to send a message per connection// (no batch, one call to Api Gateway Management API per message)const messages = subscribers.map(async (subscriber) => {
return sendMessageToSubscriber(subscriber.connectionId, payload)
})
// make sure they all sendawaitPromise.all(messages)
// still have to let api gateway know we were succesful!return success;
}

410 error codes mean the connection is gone (or isn't established yet). Depending on your use case, you may want to clean those up in your data store so you don't keep trying to send messages!

You can close connections from the "server" side via the ApiGatewayManagementApi. In addition, the $disconnect route invoke is a best attempt, and not a guarantee. (That said, I haven't seen it fail yet, so it seems like a small edge case.)

If you are using the AWS CLI to send messages, be sure to use the --endpoint parameter to override the default api used to your actual wss api endpoint. The docs mention this in the top level description of the command, but not in the post-to-connection description.