Keeping supporters up to date using AWS Events and Web Push (part 3)

Adding WebSocket push notifications

Keeping supporters up to date using AWS Events and Web Push (part 3)

This is part 3 of a series of articles showing how I built a solution to keep supporters of a sports club up to date using an event-driven architecture in AWS and Web Push notifications.

Previous articles:

Goals

  • Keep supporters up to date as much as possible during the games

  • Use an event-driven architecture in AWS

  • Require no manual intervention (I’ve better things to do at the weekend)

  • Minimal cost as it’s running in my AWS account

Part 3

Now that the latest score updates are being published to an SNS topic I needed to update the club website through a WebSocket to keep the score page up-to-date. Before being able to send the updates though there needed to be a way for the page to connect to the WebSocket.

Creating a WebSocket API

First I needed to use API Gateway to create a WebSocket API. In this project the infrastructure is created using Terraform and the code to create the API is here but, as always, a WebSocket API can also be created through the AWS console or CLI.

In this instance, the socket connection was only going to be used to send messages, not receive anything, so I only needed the $connect and $disconnect routes.

Connecting

I added the $connect route and integration with a Lambda, socket-connect, when the $connect route is selected in the console the detail looks like this:

The socket-connect Lambda receives an event as a parameter that includes a requestContext.connectionId value which is the unique id that can be used to send a message on the WebSocket. I created a new Dynamo table to store the connection ids as its partition key along with a TTL of 24 hours to keep the table clean.

The code for this Lambda is fairly straightforward, it gets the connectionId and performs a PUT on the Dynamo table of the value along with the expiry field which is configured as the TTL:

import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb';

const client = new DynamoDBClient({});
const documentClient = DynamoDBDocumentClient.from(client);

const TableName = 'cleckheaton-cc-live-score-connections';

export const handler = async event => {
  const { connectionId } = event.requestContext;
  await documentClient.send(
    new PutCommand({
      TableName,
      Item: {
        connectionId,
        expiry: Math.floor(Date.now() / 1000) + 24 * 60 * 60,
      },
    }),
  );

  return { statusCode: 200 };
};

Disconnecting

Similarly, I added a $disconnect route and integration with a Lambda called socket-disconnect:

The socket-disconnect Lambda also receives an event with requestContext.connectionId as a parameter. This function needs to delete the record from the Dynamo table:

import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, DeleteCommand } from '@aws-sdk/lib-dynamodb';

const client = new DynamoDBClient({});
const documentClient = DynamoDBDocumentClient.from(client);

const TableName = 'cleckheaton-cc-live-score-connections';

export const handler = async event => {
  const { connectionId } = event.requestContext;
  await documentClient.send(new DeleteCommand({ TableName, Key: { connectionId } }));
  return { statusCode: 200 };
};

Sending

When the updated scorecard JSON is received from the SNS topic it needs to be sent to every connection stored in the Dynamo table. For the foreseeable future, a single Scan should be sufficient to get all the data although if traffic increases significantly then this may not scale too well and will have to be looked at again.

Once all the connections have been retrieved then the JSON is sent to each of them:

import { ApiGatewayManagementApiClient, PostToConnectionCommand } from '@aws-sdk/client-apigatewaymanagementapi';
import { Scorecard } from '@cleckheaton-ccc-live-scores/schema';

const apiGatewayClient = new ApiGatewayManagementApiClient({ region: 'eu-west-2', endpoint: `${process.env.SOCKET_ENDPOINT}` });

const sendScorecard = (scorecard: Scorecard) => async (connectionId: string) => {
  const command = new PostToConnectionCommand({
    ConnectionId: connectionId,
    Data: Buffer.from(JSON.stringify(scorecard)),
  });

  return apiGatewayClient.send(command);
};

The architecture looks like this:

Connecting from the web client

To connect to the socket from the web client I create a new instance of the WebSocket class passing the URL of the API to the constructor.

To receive events from the socket use the addEventListener method passing the name of the event and a listener function. The message event is the important one for receiving the latest scorecard and in the handler for that event I dispatch a custom event - this event has the name of the team the update refers to and the detail property is the JSON received in the message. These events can then be handled on any page that needs an up-to-date score:

const teamEventName = {
  firstTeam: 'firstTeamScoreUpdate',
  secondTeam: 'secondTeamScoreUpdate',
};

const registerForScorecardUpdates = () => {
  const socket = new WebSocket(`${window.ENV.UPDATES_WEB_SOCKET_URL}`);
  socket.addEventListener('open', () => {
    console.log('Connected to updates web socket');
  });

  socket.addEventListener('message', event => {
    console.log('received', event.data);

    const scorecard = JSON.parse(event.data);
    window.dispatchEvent(new CustomEvent(teamEventName[scorecard.teamName as 'firstTeam' | 'secondTeam'], { detail: scorecard }));
  });
};

export { registerForScorecardUpdates };

The code for this article can be found on this branch and the full service is here.

Coming next

Part 4 will show how the updates are pushed to Web Push.

If you would like to test out the service and get updates through the summer go here and hit Subscribe. Note that on IOS Web Push is only available on v16.4 and above.