Log

いろいろ

Next.jsのWebアプリをPWA化してWeb Pushでユーザーに通知を送りつける

Distortion.fmをPWA (Progressive Web Apps) 化しました。

Distortion.fm

ホーム画面へインストールすることで独立したアプリのように利用できます。最新エピソード配信のお知らせをスマホの通知で受信することもできます。イメージはこんな感じ。

Web Push!

ここまでがユーザー向けのお話。ここからは実装についてのお話。リポジトリも添えておきます。

github.com

今さら?

PWAというと今さら感があるかもしれませんが、iOSでWeb Pushが利用できるようになったのが今さらなんですもの。

2023年3月27日にリリースされたiOS 16.4からWeb Pushが利用できるようになりました。

WebKit Features in Safari 16.4 | WebKit

iOS and iPadOS 16.4 add support for Web Push to web apps added to the Home Screen. Web Push makes it possible for web developers to send push notifications to their users through the use of Push API, Notifications API, and Service Workers.

2023年7月12日にリリースされたiOSChromeではPWAをインストールできます。これにより普段Chromeを利用しているユーザーに、PWAをインストールするために一時的にSafariを使ってもらう必要がなくなりました。

‎「Google Chrome - ウェブブラウザ」をApp Storeで *1

115.0.5790.84 2023年7月12日
Chrome をご利用いただきありがとうございます。このバージョンの新機能は以下のとおりです。
ホーム画面に URL やプログレッシブ ウェブアプリを追加できるようになりました。ウェブページまたはプログレッシブ ウェブアプリを開き、アドレスバーの共有アイコンをタップして [ホーム画面に追加] をタップします。

ということ。今さら時代が来たのだ。

さて、引用したiOS 16.4のリリースノートにキーワードが出揃っていますね。

  • web apps added to the Home Screen
  • Push API
  • Notifications API
  • Service Workers

これらを組み合わせて今回の目的を実現します。

ゴール

今回のゴールは以下の2点です。

  • 単独のアプリとしてWebアプリを利用できる
  • 最新エピソードの通知を受信できる

オフラインでの利用やパフォーマンスを考慮したキャッシュ戦略はスコープ外です。キャッシュは利用しません。*2

前提としてWebアプリはNext.jsで作られています。また、next-pwaは利用しません。

そしてこの記事はPWAおよびWeb Pushの動作原理を述べるものではありません。淡々と実装した内容について書いていきます。ブログのタイトル通りログです。

Add to Home Screen

まずはWebアプリをホーム画面に追加して単独のアプリとして利用できるようにします。

ホーム画面に追加 - プログレッシブウェブアプリ (PWA) | MDN

マニフェストファイルを作成して/public/manifest.jsonに配置します。

{
  "short_name": "Dfm",
  "name": "Distortion.fm",
  "icons": [
    {
      "src": "icon-192x192.png",
      "type": "image/png",
      "sizes": "192x192"
    },
    {
      "src": "icon-512x512.png",
      "type": "image/png",
      "sizes": "512x512"
    }
  ],
  "start_url": "/",
  "display": "standalone",
  "theme_color": "#ffffff",
  "background_color": "#ffffff"
}

/pages/_document.tsxマニフェストファイルを読み込みます。コードは必要な部分を抜粋。

<Head>
  <link rel="manifest" href="/manifest.json" />
</HEAD>

ホーム画面にインストールできるようにするための対応はこれだけです。

Web Push

最新エピソードの通知を受信できるようにするためにNotification APIとPush APIを利用します。ここを読みましょう。

通知とプッシュを利用して PWA を再エンゲージ可能にするには - プログレッシブウェブアプリ (PWA) | MDN

Notification API、Push APIという順序が理解しやすいです。以降はそんなもの無視して話を進めます。

構成図です。

構成図

Service Workerの登録

Service Workerの登録

/pages/_app.tsxでマウント時にService Workerを登録します。

useEffect(() => {
  try {
    regsterServiceWorkerOrThowError()
  } catch (error) {
    // マウント時はエラーメッセージを表示しない
  }
}, []);
/**
 * Service Workerを登録する
 * Service Workerが利用できない場合は例外を投げる
 */
export const regsterServiceWorkerOrThowError = (): void => {
  try {
    navigator.serviceWorker.register("/service-worker.js");
  } catch {
    throw new Error(MESSAGE_NOT_SUPPORT_WEB_PUSH);
  }
}

Service Workerは/public/service-wokrer.jsに配置しています。登録に関連するinstallイベントとactivateイベントを示します。

self.addEventListener("install", (event) => {
  // Service Workerの更新があった場合に即座にactivateする
  event.waitUntil(self.skipWaiting());
});

self.addEventListener("activate", (event) => {
  // 再読み込みを待たずにService Workerを有効にする
  // https://developer.mozilla.org/ja/docs/Web/API/Clients/claim
  event.waitUntil(clients.claim());
});

ここではService Workerの更新を即時反映するための処理をおこなっています。Service Workerはそのライフサイクルにより更新がすぐに反映されません。self.skipWaiting()clients.claim()を利用することで即時反映します。

ただし、これはライフサイクルが持つ目的とメリットから逸脱することに注意が必要です。ライフサイクルについては以下の記事に詳しい説明があります。ちなみに私は全部読んでいません。このことが原因でいつか痛い目にあうのかも。

Service Worker のライフサイクル  |  Articles  |  web.dev

Notification API

Notification API

ユーザーへ通知許可をリクエストします。

/**
 * プッシュ通知を購読する
 */
const subscribeNotifications = async () => {
  try {
    // ユーザーから通知の許可を得る
    const permission = await getNotificationPermissionOrThrowError();
    setNotificationIsGranted(permission === "granted");

    if (permission === "granted") {
      // 略
    }
  } catch (error) {
    alert(error);
  }
};
/**
 * ユーザーに通知許可をリクエストする
 * 通知が利用できない場合に例外を投げる
 */
export const getNotificationPermissionOrThrowError = async (): Promise<NotificationPermission> => {
  let permission: NotificationPermission;

  try {
    permission = await Notification.requestPermission();
  } catch {
    throw new Error(MESSAGE_NOT_SUPPORT_WEB_PUSH);
  }

  if (permission === "denied") {
    throw new Error(MESSAGE_DENIED_NOTIFICATIONS);
  }

  return permission;
}

Notification.requestPermission()を実行することでユーザーに通知許可を求めるダイアログが表示されます。おそらく印象がよくないであろうあのダイアログです。

PC

スマホ

人類はこれを許可してくれるのでしょうか。反射で拒否されそうです。拒否などのユーザーアクションの結果により以下の3つの値のいずれかに解決されます。

  • granted: 許可
  • denied: 拒否
  • default: 不明

ユーザーから許可を勝ち取ることができればデバイスの通知機能を利用できるようになります。

Push API

Push API

複雑になってきました。構成図のPush Serverは各ブラウザベンダが用意しているものです。

ここではweb-pushライブラリを利用します。

事前準備として秘密鍵と公開鍵を生成します。これはアプリケーションのコードではありません。ローカルPCで一度だけ実行してその結果を環境変数に保存します。

GitHub - web-push-libs/web-push: Web Push library for Node.js

const vapidKeys = webpush.generateVAPIDKeys();

// Prints 2 URL Safe Base64 Encoded Strings
console.log(vapidKeys.publicKey, vapidKeys.privateKey);

アプリ側の実装です。Notification APIで通知許可を得た場合にプッシュ通知を購読します。

/**
 * プッシュ通知を購読する
 */
const subscribeNotifications = async () => {
  try {
    // ユーザーから通知の許可を得る
    const permission = await getNotificationPermissionOrThrowError();
    setNotificationIsGranted(permission === "granted");

    if (permission === "granted") {
      // プッシュ通知をSubscribe
      const subscription = await subscribeWebPushOrThrowError();
      setSubscribing(true);

      // アイコンを揺らす
      setShakeIcon(true)

      // APIにSubscribe情報を渡してDBへ保存する
      await fetch("/api/subscription", {
        method: "POST",
        headers: {
          "Content-type": "application/json"
        },
        body: JSON.stringify({ subscription })
      });
    }
  } catch (error) {
    alert(error);
  }
};
/**
 * PushSubscriptionを購読する
 * WebPushが利用できない場合は例外を投げる
 */
export const subscribeWebPushOrThrowError = async (): Promise<PushSubscription> => {
  const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
  if (!vapidPublicKey) {
    throw new Error(MESSAGE_VAPID_PUBLIC_KEY_IS_NOT_FOUND);
  }

  try {
    const registration = await navigator.serviceWorker.ready;
    const subscription = await registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: vapidPublicKey
    });
    return subscription;
  } catch {
    throw new Error(MESSAGE_NOT_SUPPORT_WEB_PUSH);
  }
}

メインとなる処理はここです。

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

登録されているService Workerを取得してプッシュ通知を購読します。事前準備で生成した公開鍵を引数として渡します。

MDNのサンプルではこの公開鍵をUint8Arrayに変換する処理が記載されていました。

通知とプッシュを利用して PWA を再エンゲージ可能にするには - プログレッシブウェブアプリ (PWA) | MDN

これはChromeのための対応ですが当該issueは既にクローズされています。よって特別な対応は不要と考えてUint8Arrayへの変換はおこなっていません。動作も問題なし。

802280 - chromium - An open-source project to help move the web forward. - Monorail

以上でプッシュ通知を購読することができました。

なお、購読の有効期限が切れたタイミングなのか以下のエラーが発生することがありました。

Web Push Error 410: the push subscription has expired or the user has unsubscribed

その場合は再度購読します。

ServiceWorkerGlobalScope: pushsubscriptionchange イベント - Web API | MDN

self.addEventListener("pushsubscriptionchange", (event) => {
  event.waitUntil(
    self.registration.pushManager
      .subscribe(event.oldSubscription.options)
      .then((subscription) =>
        fetch("/api/subscription", {
          method: "PUT",
          headers: {
            "Content-type": "application/json",
          },
          body: JSON.stringify({
            oldEndpoint: event.oldSubscription.endpoint,
            subscription,
          }),
        })
      )
  );
});

プッシュ通知を送る

プッシュ通知を送る

プッシュ通知の購読後はAPIをコールします。購読情報をDBに保存し、実際にプッシュ通知を利用してユーザーに処理の成功をお知らせします。

/pages/api/sunscription.tsから必要な部分だけ抜き出します。

const webPush = require("web-push");

webPush.setVapidDetails(
  "https://distortion.fm/",
  process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
)

const postMethod = async (req: NextApiRequest, res: NextApiResponse) => {
  const subscription = {
    endpoint: req.body.subscription.endpoint,
    keys: req.body.subscription.keys
  }

  await webPush.sendNotification(
    subscription,
    JSON.stringify({
      title: "通知設定ON",
      body: "こんな感じで最新エピソードの追加をお知らせするよ。"
    })
  );

  // INSERT
  await sql`
    INSERT INTO subscription(endpoint, keys_p256dh, keys_auth)
    VALUES(${subscription.endpoint}, ${subscription.keys.p256dh}, ${subscription.keys.auth})
  `;

  res.status(201).end();
}

「Push API」で作成した秘密鍵と公開鍵をセットします。あとはsendNotification()メソッドを呼び出せばOK。

webPush.setVapidDetails(
  "https://distortion.fm/",
  process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
)

最後はService Workerでプッシュ通知を受け取った際の処理を書きます。といってもユーザーに通知を表示するだけです。

self.addEventListener("push", (event) => {
  if (!event.data) return;
  const data = JSON.parse(event.data.text());

  event.waitUntil(
    self.registration.showNotification(
      data.title,
      {
        body: data.body,
        icon: "/favicon.ico",
        data: {
          url: data.url
        }
      }
    )
  );
});

self.addEventListener("notificationclick", (event) => {
  event.notification.close();
  event.waitUntil(
    clients.openWindow(event.notification.data?.url || "/")
  )
});

こんな感じで通知を受け取ることができます。

通知設定完了の通知を通知

最新エピソードをお知らせする

通知を飛ばせるようになったので最後に実際のユースケースを試します。最新エピソードの追加をユーザーにお知らせしましょう。

最新エピソードの追加を通知する

前回の記事で書いたとおりGoogle Colabで文字起こしをしています。

ポッドキャストを文字起こしして再生している文章をハイライト表示する - Log

文字起こし完了後にユーザーへの通知処理を追加します。前回よりJupyter Notebookの使い方が洗練されました。

セクションに分けてみた

前段の処理はリファクタしただけなので割愛して「プッシュ通知」のセクションだけを載せます。

!pip install git+https://github.com/web-push-libs/pywebpush.git
!pip install beautifulsoup4 psycopg2-binary requests
import json
from bs4 import BeautifulSoup
import psycopg2
from psycopg2.extras import DictCursor
from pywebpush import webpush, WebPushException
import requests


def get_episode_title(episode_id):
  """
  Webサイトからエピソードのタイトルを取得する
  """
  url = f"https://distortion.fm/episode/{episode_id}"
  html = requests.get(url)
  soup = BeautifulSoup(html.content, "html.parser")
  return soup.find("title").text


# 通知するdataを構築
data={
  "title": "新しいエピソードが視聴できます。",
  "body": get_episode_title(EPISODE_ID),
  "url": f"/episode/{EPISODE_ID}"
}


# 通知対象をDBから取得
with psycopg2.connect(DATABASE_URL) as conn:
  with conn.cursor(cursor_factory=DictCursor) as cur:
    cur.execute("SELECT endpoint, keys_p256dh, keys_auth FROM subscription")
    rows = cur.fetchall()


# 通知
for row in rows:
  print(row)

  subscription_info={
    "endpoint": row["endpoint"],
    "keys": {
      "p256dh": row["keys_p256dh"],
      "auth": row["keys_auth"]
    }
  }

  try:
    webpush(
      subscription_info=subscription_info,
      data=json.dumps(data),
      vapid_private_key=VAPID_PRIVATE_KEY,
      vapid_claims={
        "sub": EMAIL,
      }
    )
  except WebPushException as ex:
    print("I'm sorry, Dave, but I can't do that: {}", repr(ex))

「Push API」と同様にweb-pushライブラリを利用したいところですが、わざわざNode.jsを利用するのもあれかなと思ってPythonで書きました。pywebpushライブラリを利用します。毎度のことですが俺はふいんきPythonを書いている。そして俺はDaveではない。READEMEのコードを参考にしただけです。

GitHub - web-push-libs/pywebpush: Python Webpush Data encryption library

無事に通知が届きました。

500: Internal Server Error

は?*3

参考

*1:2023年11月13日時点の閲覧情報を引用しています。時間経過により過去のアップデート履歴は閲覧できなくなる可能性があります。

*2:Service Workerを利用することによりデフォルトでキャッシュされる仕組みなどがあるかもしれませんが、そういった仕組みがあるかも調べていません。能動的にキャッシュの設定をしていないというだけです。

*3:このエラーはおそらくSSGに起因するもので今回の主題とは関係ありません。