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

Adding Web Push notifications

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

This is the final 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 4

Now that the latest score updates are being published to an SNS topic I want to push notifications to any devices that have subscribed to receive them.

No one wants to receive push notifications twice a minute so I decided to only send notifications for the following:

  • Every 10 overs

  • Every wicket

  • Every player milestone

  • The result

I also needed a way for users to register/unregister to receive these notifications.

Subscription API

To create and delete subscriptions I use an API Gateway HTTP API with a single route with POST, PUT and DELETE methods which expect a body which is validated using this Zod schema:

import { z } from 'zod';

const SubscriptionSchema = z.object({
  endpoint: z.string(),
  keys: z.object({
    p256dh: z.string(),
    auth: z.string(),
  }),
});

I created a new Dynamo table to store the subscriptions with a partition key of the endpoint URL and the POST and PUT methods make a PUT request to the table passing the validated body.

The DELETE method makes a request to the table to delete the endpoint URL passed as a parameter.

The methods are all integrated with a single Lambda which uses the httpMethod property of the event:

export const handler = async ({ body, httpMethod }) => {
  const validateResult = validateSubscription(JSON.parse(body));
  if (!validateResult.success) {
    return { statusCode: 400, body: JSON.stringify(validateResult.error) };
  }

  const { data: subscription } = validateResult;
  switch (httpMethod) {
    case 'POST':
      await subscribe(subscription);
      break;
    case 'DELETE':
      await unsubscribe(subscription.endpoint);
      break;
    case 'PUT':
      await update(subscription);
      break;
  }

  return { statusCode: 200 };
};

Finally, I wanted some security on this API and decided a simple API key would suffice. Each route is secured using the same simple authoriser Lambda which tests the header against the expected key:

export const handler = async ({ headers: { authorization } }) => 
    ({ isAuthorized: authorization === process.env.API_KEY });

Creating notifications

As previously mentioned I didn't want to send every single update so I needed to create a buffer that received the updates from the SNS and only sent a web push notification when needed.

I created a Lambda that subscribes to the SNS topic, compares the data with what was sent for the previous notification and, if a new notification is due, creates that notification and adds it to an SQS. The code that checks whether a notification is required is here.

Once I had created the SQS for sending notifications I decided it would be useful to send a confirmation notification whenever a new subscription is received so I updated the Subscription API to put a notification onto the queue. This way a user can confirm everything is working as it should when they subscribe, rather than realising no updates are coming through during a game.

Sending notifications

To use web push I need to create a Vapid key-pair. To do this I used this tool.

To send the notifications I used the web-push library and get the Vapid details from the environment:

const vapidSubject = `${process.env.VAPID_SUBJECT}`;
const vapidPublicKey = `${process.env.VAPID_PUBLIC_KEY}`;
const vapidPrivateKey = `${process.env.VAPID_PRIVATE_KEY}`;

webpush.setVapidDetails(vapidSubject, vapidPublicKey, vapidPrivateKey);

Then I needed to get the subscriptions from the Dynamo table, I did this using a Scan which should be fine for the foreseeable future but for a large amount of subscriptions may not scale very well - will cross this bridge if I get there.

Once I have all of the subscriptions I can send them using sendNotification:

await webpush.sendNotification(
    subscription, 
    JSON.stringify({ title: 'Hello', body: 'Hello from live-scores' })
);

Expired subscriptions

Although there is an API call that can be made to delete a subscription sometimes that might not be called when a subscription is disabled or the subscription may expire.

When the sendNotification is called for an expired subscription an exception is thrown. As these subscriptions cannot become active again (as far as I know) then it makes sense to delete them from the table so as not to waste resources trying to send to them every time.

As I already had the code written to delete a subscription from the API it made sense to try and re-use it. Rather than call the API for each expired subscription I decided to create an SQS queue with a new Lambda to read from it that deletes the subscription from the table. Both the API DELETE method and the code that handles expired subscriptions then put a message on the queue:

import webpush, { PushSubscription, WebPushError } from 'web-push';

const send = (removeSubscription: RemoveSubscription, notification: string) => async (subscription: PushSubscription) => {
  try {
    await webpush.sendNotification(subscription, notification);
  } catch (e: unknown) {
    if (e instanceof WebPushError && 
        (e.body.includes('unsubscribed') || e.body.includes('expired'))) {
      removeSubscription(subscription.endpoint);
    } else {
      console.error(e);
    }
  }
};

The architecture looks like this:

architecture.drawio.png

Subscribing/unsubscribing from a web client

To use web push from the client I first needed to check that it is supported in the browser which I did using this expression 'serviceWorker' in navigator && 'PushManager' in window && 'showNotification' in ServiceWorkerRegistration.prototype

Once I've established that it is supported I use PushManager.subscribe passing in the public Vapid key which will create a new subscription object for the website. To retrieve an existing subscription I use PushManager.getSubscription. This object matches the Zod schema that it used to validate the subscription API calls.

Then it's a question of either making a POST or DELETE request to the API depending on whether the subscription is being created or deleted. The code I use to subscribe from a Remix website is here.

This also does some extra checks when running in IOS as if the os version is 16.4 or over and web push is not supported then if the user adds the site to their home screen then it should be available.

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

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.