Enhancing Real-Time Notifications in Cachepot with AWS AppSync Events

technologies

Users nowadays want immediate updates and smooth real-time experiences in their digital interactions. As a company we aim to provide web applications that work effectively for users who want immediate updates. During AWS re: At AWS re:Invent 2024 I learned about AWS AppSync Events which is a serverless tool that enables real-time data broadcasting through WebSockets. Our team at Cachepot focuses on building web applications that deliver exceptional performance to users. Our team chose AWS AppSync Events to update the way Cachepot sends real-time notifications because we liked its features. Through this blog I will explain our process to add AWS AppSync Events into Cachepot to deliver real-time updates for our end users.

Why Real-Time Notifications Matter

Users need live updates about their activities through real-time notifications to stay informed and connected. Setting up real-time notifications needs significant WebSocket building and requires skillful handling of scalability and secure connection maintenance. AWS AppSync Events eases our workload so we can concentrate on creating an outstanding user experience.

Our Use Case: Real-Time Notifications from Admin

In Cachepot, one of our features is allowing users to receive the admin’s notifications in real-time. Previously, this was managed through periodic API polling, which increased latency and added unnecessary server load. With AWS AppSync Events, we replaced polling with real-time WebSocket notifications that broadcast updates as soon as they occur.

Setting Up AWS AppSync Events

Step 1: Create the Event API 

We built a new Event API using the AWS AppSync Console. The API setup was straightforward:

  1. Our team went to the Create API page where we selected Event API as the service
  2. Named the API as notificationEvents
  3. We established the notifications channel namespace to handle receive notifications.

The /notifications namespace helps us deliver notifications to channels at the /notifications/<userSub> endpoint with specific notification control.

Step 2: Implementing Channels and Handlers

export function onSubscribe(ctx) {

    if (ctx.identity.groups.includes('ADMIN')) {
        console.log(`Admin ${ctx.identity.sub} cannot subscribed to ${ctx.channel}`)
        // strict only reseller receive notifications
        util.unauthorized()
    }

}

or

export function onSubscribe(ctx) {

    if (ctx.info.channel.path !== `/notifications/${ctx.identity.sub}`) {
        console.log(`User ${ctx.identity.sub} cannot subscribed to ${ctx.channel}`)
        // strict user can only receive notifications about their own
        util.unauthorized()
    }

}

Step 3: Integrating with Cachepot

WebSocket protocol overview

We leveraged the browser’s native WebSocket API for real-time communication:

const WebSocketContext = createContext({})
interface IProps {
  children: ReactElement,
  channel: string
}
const APPSYNC_HOST = import.meta.env.VITE_APPSYNC_HOST
const APPSYNC_REALTIME_HOST = import.meta.env.VITE_APPSYNC_REALTIME_HOST

export const WebSocketProvider = (props: IProps) => {
  const { token } = useContext(authContext);
  const { handleList: handleListBar } = useContext(NotificationBarContext)

  const authorization = useMemo(() => ({
    host: APPSYNC_HOST,
    Authorization: `Bearer ${token?.toString()}`
  }), [token?.toString()])

  function getAuthProtocol() {
    const header = btoa(JSON.stringify(authorization))
      .replace(/\+/g, '-') // Convert '+' to '-'
      .replace(/\//g, '_') // Convert '/' to '_'
      .replace(/=+$/, '') // Remove padding `=`
    return `header-${header}`
  }

  useEffect(() => {
    const websocket = new WebSocket(
      `wss://${APPSYNC_REALTIME_HOST}/event/realtime`,
      ["aws-appsync-event-ws", getAuthProtocol()]
      );

    websocket.onopen = () => {
      console.log('WebSocket connection opened');
      websocket.send(JSON.stringify({ type: 'connection_init' }));
    };

    websocket.onmessage = (event) => {
      const message = JSON.parse(event.data);

      switch (message.type) {
        case "connection_ack":
          console.log("connection_ack");
          const subscribeMessage = {
            id: window.crypto.randomUUID(),
            type: "subscribe",
            channel: props.channel,
            authorization
          };
          websocket.send(JSON.stringify(subscribeMessage));
          break;
        case "start_ack":
          console.log("start_ack");
          break;
        case "error":
          console.log('error');
          break;
        case "data":
          handleMessage(message);
          break;
        default:
          console.log('default');
      }
    };

    websocket.onerror = (error) => {
      console.error('WebSocket error:', error);
    };

    websocket.onclose = () => {
      console.log('WebSocket connection closed');
    };

    return () => {
      websocket.close();
    };
  }, [token?.toString()]);

  const handleMessage = async (message: any) => {
    if (!!message.event) {
      const payload = JSON.parse(message.event);
      if (payload.tags.includes(NOTIFICATION_TAG.BUG)) {
        toast.warning(payload.title, { toastId: payload.id })
      } else if (payload.tags.includes(NOTIFICATION_TAG.INFO)) {
        toast.info(payload.title, { toastId: payload.id })
      } else {
        toast.success(payload.title, { toastId: payload.id })
      }
      await Promise.all([
        handleListBar({
          type: ACTION.add,
          payload: {
            id: payload.id,
            tags: payload.tags,
            title: payload.title,
            publishDate: dayjs().utc().format(),
            message: payload.message
          }
        }),
        handleListBar({
          type: ACTION.delete,
          payload: null,
        })
      ])
    }
  }

  return (
    <WebSocketContext.Provider value={{}}>
      {props.children}
    </WebSocketContext.Provider>
  )
}

To publish updates, our backend sends HTTP POST requests to the Event API: 

const AWS_REGION = process.env.REGION;
const EVENT_API_ENDPOINT = process.env.EVENT_API_ENDPOINT;

module.exports = async (input) => {
  const endpoint = new URL(`https://${EVENT_API_ENDPOINT}/event`);

  const signer = new SignatureV4({
    credentials: defaultProvider(),
    region: AWS_REGION,
    service: 'appsync',
    sha256: Sha256
  });

  const requestToBeSigned = new HttpRequest({
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      host: endpoint.host
    },
    hostname: endpoint.host,
    body: JSON.stringify(input),
    path: endpoint.pathname
  });
  const signed = await signer.sign(requestToBeSigned);
  const request = new Request(endpoint, signed);

  try {
    const response =  await fetch(request);
    const body = await response.json();

    if (body.errors) {
      const messages = body.errors.map((item) => item.message).join('. ')
      console.log('messages', messages);
      throw new Error(messages);
    } else {
      console.log('body', body.data);
      return body.data;
    }
  } catch (error) {
    throw new Error(error.message);
  }
}

Step 4: Demo

Benefits Observed

  • Instant Updates: Admin sents notifications instantly for a smooth user experience.
  • Scalability: AWS AppSync Events manages millions of connections without any issues.
  • Reduced Load: Our new system cuts server overhead through polling elimination.

Conclusion 

AWS AppSync Events helps Cachepot deliver instant notifications to its users. Our development team benefits greatly from this service because it runs without servers and sets up effortlessly while handling many users. I recommend AWS AppSync Events for applications that need real-time features. Click now to access the AWS AppSync Console‘s features.

Related posts