WebSockets

Real time web apps with stateless cloud functions

The @ws primitive creates a WebSocket endpoint and stateless handler functions: connect, disconnect and default.



🚜 Work Locally

An example .arc file:

@app
testapp

@ws
# no further config required

@http
get /

Architect generates the following functions:

  • src/ws/connect invoked when the WebSocket is connected
  • src/ws/default invoked whenever a message is sent
  • src/ws/disconnect invoked when disconnected

connect

The connect Lambda is primarily intended to verify event.header.Origin.

default

The default Lambda will be the main event bus for WebSocket events. The event.requestContext.connectionId variable is used for determining the current WebSocket.

disconnect

The disconnect Lambda is used to cleanup any records of event.requestContext.connectionId.


🌾 Provision

WebSocket functions generate many supporting AWS resources. Some highlights:

  • AWS::ApiGatewayV2::Route
  • AWS::ApiGatewayV2::Integration
  • AWS::ApiGatewayV2::Api
  • AWS::ApiGatewayV2::Deployment
  • AWS::ApiGatewayV2::Stage

Additionally these AWS::SSM::Parameter resources are created which can be inspected at runtime:

  • /[StackName]/ws/https with a value like https://xxx.execute-api.us-west-1.amazonaws.com/production/@connections
  • /[StackName]/ws/wss with a value like wss://xxx.execute-api.us-west-1.amazonaws.com/production

All runtime functions have the environment variable AWS_CLOUDFORMATION which is the currently deployed CloudFormation stack name; this combined w the runtime aws-sdk or @architect/functions can be used to lookup these values in SSM


⛵️ Deploy

  • arc deploy to deploy with CloudFormation to staging
  • arc deploy dirty to overwrite deployed staging lambda functions
  • arc deploy production to run a full CloudFormation production deployment

🔭 Find the example repo on GitHub.


🎉 Event Payload

WebSocket functions are always invoked with an event payload that contains useful information:

  • event.requestContext.connectionId the currently executing WebSocket connection
  • event.requestContext.apiId the currently executing WebSocket apiId
  • event.body the message payload
exports.handler = async function ws(event) {
  // event.requestContext.connectionId
  // event.requestContext.apiId
  // event.body
  return {statusCode: 200}
}

🧭 Browser Implementation

Render the app HTML shell and embed the current WebSocket URL in a global WS_URL.

// src/http/get-index/index.js
let getURL = require('./get-web-socket-url')

/**
 * renders the html app chrome
 */
exports.handler = async function http(req) {
  return {
    type: 'text/html; charset=utf8',
    body: `<!doctype html>
<html>
<body>
<h1>WebSockets</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>`
  }
}

We'll put the browser JavaScript in public/index.mjs:

// public/index.mjs

// get the WebSocket 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 WebSocket
let ws = new WebSocket(url)
ws.onopen = open
ws.onclose = close
ws.onmessage = message
ws.onerror = console.log

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

// report a closed WebSocket 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}))
  }
})

🧁 Send Events from Lambda

Send a JSON payload to any connectionId from runtime function code.

// src/ws/connected
let arc = require('@architect/functions')

exports.handler = async function connected(event) {
  let id = event.requestContext.connectionId
  let payload = {ok: true, ts: Date.now()}
  await arc.ws.send({id, payload})
  return {statusCode: 200}
}

🚙 Custom Routes

Specify custom WebSocket route keys:

@app
testapp

@ws
action
status
join
default
connected
disconnected