Distortion.fmをPWA (Progressive Web Apps) 化しました。
ホーム画面へインストールすることで独立したアプリのように利用できます。最新エピソード配信のお知らせをスマホの通知で受信することもできます。イメージはこんな感じ。
ここまでがユーザー向けのお話。ここからは実装についてのお話。リポジトリも添えておきます。
- 今さら?
- ゴール
- Add to Home Screen
- Web Push
- Service Workerの登録
- Notification API
- Push API
- プッシュ通知を送る
- 最新エピソードをお知らせする
- 参考
今さら?
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日にリリースされたiOS版ChromeではPWAをインストールできます。これにより普段Chromeを利用しているユーザーに、PWAをインストールするために一時的にSafariを使ってもらう必要がなくなりました。
「Google Chrome - ウェブブラウザ」をApp Storeで *1
115.0.5790.84 2023年7月12日
Chrome をご利用いただきありがとうございます。このバージョンの新機能は以下のとおりです。
• ホーム画面に URL やプログレッシブ ウェブアプリを追加できるようになりました。ウェブページまたはプログレッシブ ウェブアプリを開き、アドレスバーの共有アイコンをタップして [ホーム画面に追加] をタップします。
ということ。今さら時代が来たのだ。
さて、引用したiOS 16.4のリリースノートにキーワードが出揃っていますね。
これらを組み合わせて今回の目的を実現します。
ゴール
今回のゴールは以下の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の登録
/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
ユーザーへ通知許可をリクエストします。
/** * プッシュ通知を購読する */ 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()
を実行することでユーザーに通知許可を求めるダイアログが表示されます。おそらく印象がよくないであろうあのダイアログです。
人類はこれを許可してくれるのでしょうか。反射で拒否されそうです。拒否などのユーザーアクションの結果により以下の3つの値のいずれかに解決されます。
- granted: 許可
- denied: 拒否
- default: 不明
ユーザーから許可を勝ち取ることができればデバイスの通知機能を利用できるようになります。
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
無事に通知が届きました。
は?*3
参考
- 通知とプッシュを利用して PWA を再エンゲージ可能にするには - プログレッシブウェブアプリ (PWA) | MDN
実装はMDNを参考にしています。 - 5行でわかるプッシュ通知【VAPID】 #JavaScript - Qiita
とにかく動かしたい場合におすすめの記事です。小さくシンプルにまとまっています。