Introducing Architect 5.0: fully serverless WebSockets

avatar
Brian Leroux
January 24, 2019

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?

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

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

The AWS free tier is very generous:

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

Notes

Next Steps

Catalope

The fearsome Catalope!