Introducing Architect 5.0: fully serverless WebSockets

Brian Leroux’s avatar

by Brian Leroux
@brianleroux
on

Switchboard Photo by John Barkiple

We’re thrilled to announce Architect 5.0 (Catalope), with full API Gateway WebSocket integration. We can’t wait to share what we learned, current limitations, and our excitement for future serverless WebSockets support unlocks.

This article will:

Serverless WebSocket FAQ 🔌

So what is a serverless WebSocket?

API Gateway, which marshals client connections for Lambda cloud functions, can creating endpoints with two protocols: https, and as of a few weeks ago, wss. This means WebSockets can now be an event source for Lambda. In other words, you can now add WebSockets to your application without running, maintaining, and operating servers/containers/VMs.

Architect provides endpoints pre-configured with Lambda handler functions deployed and ready to iterate; complete with local development and isolated staging and production environments.

When and why would someone want to use a serverless WebSocket?

  • Your app needs real time push of data; this includes (but is not limited to) web browsers. (Many things speak the wss protocol!)
  • You desire a stateless runtime execution model for your app layer; your app receives WebSocket events, processes them, possibly communicates back to socket clients by posting through an HTTP API, and then terminates execution
  • You want nothing to do with maintaining, patching, or running WebSocket server resources
  • You desire usage-based billing and want to only pay for resources in use; horizontal scaling should be transparent with no pre-provisioning of nodes/clusters/instances/VMs/containers in order to operate WebSocket-enabled infra

What are the current limitations of serverless WebSockets? (As of January, 2019)

  • 10,000 requests per second (can be increased)
  • 500 new connections per second
  • 128 KB message payload size
  • 2 hour connection duration (of course, one can reconnect)
  • 10 minute idle timeout

How much does an API Gateway WebSocket API cost anyhow? 💸

The AWS free tier is very generous:

  • 1M API calls
  • 1M messages
  • 750,000 connection minutes

After that its $3.50 per million calls. It’s worth noting that DynamoDB and Lambda have additional cost thresholds (and similarly generous free tiers).

Building serverless WebSockets with Architect 5.0 (Catalope)

Get started

Good news, getting started with serverless WebSockets on Architect doesn’t require an AWS account! First, let’s install Architect and touch a .arc file:

mkdir wsproj
cd wsproj
npm init --yes
npm i @architect/architect
touch .arc

Add the sections below to your app.arc file:

@app
mywsapp
@ws
# no further config required!
@http
get /

Work locally

Architect apps work locally, and that includes full WebSocket support. Being able to preview locally, test headlessly, and deploy to identical staging and production environments is critical to both stable systems and well-rested developers.

Below we walk through a reduced-case app to demo setting up a socket and the stateless posting/receiving of messages.

To initiate the connection, we will render HTML on the get / route. It is probably worth mentioning this is demo code. How you arrive at an HTML string is totally up to you. Templating libraries are available to the Node runtime, many transpilers, or even just be lazy and fs.readFile a file straight out. Don’t forget the type! 🙇🏽‍♂️

let arc = require("@architect/functions");
let static = arc.http.helpers.static;
let getURL = require("./get-web-socket-url");

/**
 * renders the html app chrome
 */
exports.handler = async function http(req) {
  return {
    statusCode: 200,
    headers: { "content-type": "text/html; charset=utf8" },
    body: `<!doctype html>
<html>
<body>
<h1>Web sockets echo server demo</h1>
<main>Loading...</main>
<input id=message type=text placeholder="Enter message" autofocus>
<script>
window.WS_URL = '${getURL()}'
</script>
<script type=module src=${static("/index.mjs")}></script>
</body>
</html>`,
  };
};

View source on GitHub

Notice above that we hardcode the WebSocket URL in the global window.WS_URL. The getURL function encapsulates the logic for returning the appropriate local, staging, and production URLs. Web Socket (wss://) endpoints are not the same as HTTP (https://) endpoints!

Start the sandbox by running npx sandbox and open http://localhost:3333.

The browser JavaScript lives in /public/index.mjs. Again, you can arrive at web browser code however you want. For this example wrote boring old web browser JavaScript.

// get the web socket url from the backend
let url = window.WS_URL

// all the DOM nodes this script will mutate
let main = document.getElementsByTagName('main')[0]
let msg = document.getElementById('message')

// setup the web socket
let ws = new WebSocket(url)
ws.onopen = open
ws.onclose = close
ws.onmessage = message
ws.onerror = console.log

// connect to the web socket
function open() {
  let ts = new Date(Date.now()).toISOString()
  main.innerHTML = `<p><b><code>${ts} - opened</code></b></p>`
}

// report a closed web socket connection
function close() {
  main.innerHTML = 'Closed <a href=/>reload</a>'
}

// write a message into main
function message(e) {
  let msg = JSON.parse(e.data)
  main.innerHTML += `<p><code>${msg.text}</code></p>`
}

// sends messages to the lambda
msg.addEventListener('keyup', function(e) {
  if (e.key == 'Enter') {
    let text = e.target.value // get the text
    e.target.value = ''       // clear the text
    ws.send(JSON.stringify({text}))
  }
})

View source on GitHub

When the web socket is opened, the main element is updated with a timestamp and the string opened. Subsequent messages received are appended to main. That’s it for receiving messages.

To send messages, the msg text input element listens for the enter key and sends a JSON encoded string to the src/ws/ws-default Lambda:

let arc = require('@architect/functions')

/**
 * append a timestamp and echo the message back to the connectionId
 */
exports.handler = async function ws(event) {

  console.log('ws-default called with', event)

  let timestamp = new Date().toISOString()
  let connectionId = event.requestContext.connectionId
  let message = JSON.parse(event.body)
  let text = `${timestamp} - Echoing ${message.text}`

  await arc.ws.send({
    id: connectionId,
    payload: {text}
  })

  return {statusCode: 200}
}

View source on GitHub

The message variable holds the parsed value of the string sent from the web browser client. The text variable appends a timestamp, and then the Lambda immediately calls arc.ws(event)send to send the message back to the original connectionId.

Running npx sandbox also kicks up ws://localhost:3333 and ensures src/ws/* Lambda functions are invoked appropriately. This is a completely stateless execution model for a long lived real time app. (Weird!)

Deploying to AWS

  • Generate corresponding AWS infra by running npx create.
  • You’ll want to make note of the generated URLs for staging and production and modify src/http/get-index/get-web-socket-url.js
  • Add a means to serve the index.mjs file, by either creating S3 buckets with @static, or create an @http function to serve it…serverlessly ✨
  • Deploy local changes to staging by running npx deploy
  • When you’re ready, ship to production: npx deploy --production

Notes

  • WebSocket security is its own art and we will be publishing further ideas and guidance on how to Web Socket safely 🧨
  • Begin Data is very helpful for managing clients’ connectionId 🎉

Next Steps

Catalope

The fearsome Catalope!