🔔

何もわからないところからプッシュ通知(web-push)が実装できるまで

2024/08/07に公開

背景

プッシュ通知のプの字も分からないところからドキュメント等を調べ尽くし、なんとか下記のような(ネイティブアプリ向けではなく)ブラウザ向けのプッシュ通知を実装できました。

iOS Safari MaxOS Chrome


送られてきた通知をタップすると Web アプリを開くことができる

その時に学んだことや実装方法をまとめてみます。

登場人物

ブラウザ向けプッシュ通知に関わる登場人物たちとそれぞれの役割をまとめます。

  • ユーザー
    • プッシュ通知を受け取る人。受信した通知を表示することをブラウザに許可する
  • ブラウザ
    • ユーザーにプッシュ通知表示の許可を求める
    • プッシュサービスから PushSubscription を得て App サーバへ送る(Service Worker)
    • プッシュ通知を受信して表示する(Service Worker)
  • App サーバ
    • ブラウザから受け取った PushSubscription を DB に保存する
    • プッシュサービスに対して web push protocol [RFC8030] のリクエストを送る
  • プッシュサービス(開発者はノータッチな部分
    • App サーバからのリクエストを受け、ブラウザへプッシュ通知を送ってくれるもの

全体像は下記の通りです( プッシュ通知通知 と省略しています)。

プッシュ通知の購読

まずは、プッシュ通知を受け取るために、ユーザーがプッシュ通知を購読し、ユーザーごと・ブラウザごとの PushSubscriptionというものをプッシュサービスから受け取り、DB に保存する必要があります。このオブジェクトには、プッシュ通知を送信するために必要なエンドポイントとデータ送信時に使う暗号キーが含まれています。
プッシュ通知を表示する許可をユーザーから得るために Notification API を利用し、
https://developer.mozilla.org/ja/docs/Web/API/Notification
プッシュ通知を購読するために Push API を利用します。
https://developer.mozilla.org/ja/docs/Web/API/Push_API

フロントエンド
export const PushNotificationSetting: FC<AuthRequiredPageProps> = ({
  authUser,
}) => {
  const { t } = useTranslation();

  const encodeToBase64 = (arrayBuffer: ArrayBuffer | null) => {
    if (!arrayBuffer) return '';
    return btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
  };

  const [pushSubscription, setPushSubscription] =
    useState<PushSubscription | null>(null);

  const { gqlClient } = useGqlClient();

  const { data, isLoading } = useGetPushSubscriptionsQuery(
    gqlClient,
    {
      where: { user_id: { _eq: authUser?.id } },
    },
    {
      enabled: !!authUser,
    },
  );

  const { show } = useToast();
  const queryClient = useQueryClient();
  const { mutate } =
    useSubscribeToPushNotificationMutation(gqlClient, {
      onSuccess: async () => {
        show(t('閲覧中のブラウザをプッシュ通知先として登録しました'));
        queryClient.invalidateQueries({
          queryKey: ['GetPushSubscriptions'],
          type: 'active',
        });
      },
    });
  useEffect(() => {
    if (pushSubscription) {
      mutate({
        endpoint: pushSubscription.endpoint,
        userAgent: navigator.userAgent,
        os: parseUserAgent(navigator.userAgent).os,
        browser: parseUserAgent(navigator.userAgent).browser,
        p256dh: encodeToBase64(pushSubscription.getKey('p256dh')) ?? '',
        auth: encodeToBase64(pushSubscription.getKey('auth')) ?? '',
      });
    }
  }, [mutate, pushSubscription]);

  const handleClick = async () => {
    const getPushSubscription = async () => {
      if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
        return show(t('このブラウザはプッシュ通知に対応していません'));
      }

      const permission = await Notification.requestPermission();
      if (permission === 'denied' || permission === 'default') {
        return show(
          t(
            'プッシュ通知が許可されていません。ブラウザの設定を変更してください',
          ),
        );
      }

      const registration = await navigator.serviceWorker.ready;
      const subscription = await registration.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: process.env.NEXT_PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY,
      });

      setPushSubscription(subscription);
    };

    await getPushSubscription();
  };

  return (
    <>
      <Button
        disabled={alreadySubscribed}
        onClick={handleClick}
      >
        閲覧中のブラウザを登録
      </Button>
    </>
  );
};

ブラウザがプッシュ通知をサポートしていなかったり、ユーザーがプッシュ通知の表示を拒否したりした場合は、プッシュ通知の購読処理が走らないようにしています。
applicationServerKey については、ライブラリを使うなどして簡単に作成可能です。
https://github.com/web-push-libs/web-push

npm i web-push
npx web-push generate-vapid-keys

生成された公開鍵と秘密鍵は環境変数等に保存しておきます。

バックエンド
import { Response } from 'express';
import { prisma } from '~/lib/prisma';

import { subscribeToPushNotificationReqBodySchema } from './interface';
type SubscribeToPushNotificationResponse = {
  success: boolean;
};

export const subscribeToPushNotification = async (
  req: typeof subscribeToPushNotificationReqBodySchema,
  res: Response<SubscribeToPushNotificationResponse>,
) => {
  if (!req.user) {
    throw new Error('User is not authenticated.');
  }

  const userId = req.user.id;
  const { endpoint, userAgent, os, browser, p256dh, auth } = req.body.input;

  if (!endpoint || !userAgent || !os || !browser || !p256dh || !auth) {
    res.status(400).json({ message: 'Invalid request body' });
    return;
  }

  const pushSubscription = await prisma.pushSubscription.findUnique({
    where: { userId_os_browser: { userId, os, browser } },
  });
  if (pushSubscription) {
    return res
      .status(204)
      .json({ message: 'Already subscribed to push notification' });
  }

  await prisma.pushSubscription.create({
    data: {
      userId,
      endpoint,
      userAgent,
      os,
      browser,
      p256dh,
      auth,
    },
  });

  return res.status(200).json({
    success: true,
  });
};

プッシュ通知の送信

ブラウザ向けにプッシュ通知を送るためには、Web アプリの App サーバが web push protocol に沿ってプッシュサービスに POST リクエストをする必要があります。また、送信するメッセージは暗号化し、受信したブラウザが正しく復号できるようにヘッダーを追加する必要もあります。ここらへんは複雑なので、ライブラリ[1] に任せるのが賢明だと思われます。

今回は質問箱系サービスでのプッシュ通知なので、新しい質問が送られた際にプッシュ通知を送信するようにします(実際には Cloud Tasks のタスクを作成してプッシュ通知の送信処理は非同期的に行われるようにしていますが、ここでは簡略化のため省略します)。

sendPushNotification.ts
import webPush, { PushSubscription } from 'web-push';

export type PushNotificationMessage = {
  title: string; // 通知のタイトル
  body: string; // 通知の本文
  url?: string; // 通知をクリックしたときに開く URL
};

export const sendPushNotification = async (
  userId: string, // プッシュ通知送信先のユーザーID
  message: PushNotificationMessage,
): Promise<void> => {
  try {
    webPush.setVapidDetails(
      `mailto:info@test.com`, // プッシュサービスが送信者と通信する必要がある場合にそれを可能にする情報
      'WEB_PUSH_VAPID_PUBLIC_KEY', // VAPID 公開鍵
      'WEB_PUSH_VAPID_PRIVATE_KEY', // VAPID 秘密鍵
    );

    const subscriptions = await prisma.pushSubscription.findMany({
      where: {
        userId,
      },
      select: {
        endpoint: true,
        auth: true,
        p256dh: true,
      },
    });
    if (subscriptions.length === 0) {
      return;
    }

    const pushSubscriptions: PushSubscription[] = subscriptions.map((sub) => {
      return {
        endpoint: sub.endpoint,
        keys: {
          auth: sub.auth,
          p256dh: sub.p256dh,
        },
      };
    });

    const message = { title, body, url };

    await Promise.all(
      pushSubscriptions.map((sub) =>
        webPush.sendNotification(sub, JSON.stringify(message)),
      ),
    );

    logger.info(`successfully sent web push to ${userId}.`, message);
  } catch (error) {
    logger.error('Error sending web push to ${userId}.', error);
  }
};

上述のプッシュ通知の購読(await registration.pushManager.subscribe(...))時に引数に渡していた applicationServerKey(公開鍵) を、今度は webPush.setVapidDetails(...) の第二引数に渡しています。
あとは、DB に保存していた pushSubscription を使って webpush.sendNotification(...)を呼び出すだけです。これが呼び出されると Promise が返され、プッシュ通知が正常に送信されるとPromise が解決されます。

handleCreateNewQuestion.ts
const handleCreateNewQuestion = async () => {
    await sendPushNotification(user.id, {
        title: '新しいレターが届きました',
        body: question.content,
        url: `${APP_BASE_URL}/questions/${question.id}`,
    });
}

プッシュ通知の受信 -> 表示

iOS Safari でプッシュ通知を受け取るには、Web アプリを PWA に対応させ、ユーザーに Web アプリを「ホーム画面に追加」してもらう必要があります。

PWA への対応方法は この記事 が参考になります。
また、next-pwaで自動生成されるサービスワーカーと、プッシュ通知を受信するために自分で作成するサービスワーカーを統合させる方法の詳細については、下記の記事をご覧ください。
https://qiita.com/chima91/items/3d81dc21c7506bbbe906

next.config.js
const withPWA = require('next-pwa')({
  dest: 'public',
  register: true,
  skipWaiting: true,
});
const withTM = require('next-transpile-modules')([
  'hogehoge',
  'fugafuga',
]);

const { i18n } = require('./next-i18next.config');

module.exports = withTM(
  withPWA({
    reactStrictMode: true,
    i18n,
    experimental: {
      scrollRestoration: true,
      esmExternals: false,
    },
    eslint: {
      ignoreDuringBuilds: true,
    },
  }),
);
worker/index.js
// プッシュ通知(web-push)受信時の処理
self.addEventListener('push', function (event) {
  const data = event.data.json();
  const title = data.title || '新しいアクションがありました';
  const options = {
    body: data.body || '詳細はクリックして確認してください',
    icon: '/icon-192x192.png',
    badge: '/icon-192x192.png',
    data: {
      url: data.url,
    },
  };
  event.waitUntil(self.registration.showNotification(title, options));
});

// プッシュ通知(web-push)クリック時の処理
self.addEventListener('notificationclick', function (event) {
  event.notification.close();
  event.waitUntil(
    clients.matchAll({ type: 'window', includeUncontrolled: true }).then(() => {
      if (clients.openWindow) {
        return clients.openWindow(event.notification.data.url);
      }
    }),
  );
});

最後に

プッシュ通知の購読解除など実装を省略している箇所もありますが、

  1. ユーザーがプッシュ通知の表示を許可してプッシュ通知を購読し、
  2. App サーバがプッシュサービスに web push protocol のリクエストを送り、
  3. プッシュサービスがブラウザにプッシュ通知を送信し、
  4. ブラウザ(Service Worker)が受信したプッシュ通知を表示する

という一連の実装をまとめられたかと思います。
どなたかの参考になれば幸いです。

脚注
  1. 先ほどの web-push のようなライブラリ ↩︎

Discussion