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日にリリースされたiOS 版Chrome では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) {
}
} , [] );
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 ) => {
event .waitUntil(self .skipWaiting());
} );
self .addEventListener("activate" , (event ) => {
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 Serverは各ブラウザベンダが用意しているものです。
ここではweb-push
ライブラリを利用します。
事前準備として秘密鍵 と公開鍵を生成します。これはアプリケーションのコードではありません。ローカルPCで一度だけ実行してその結果を環境変数 に保存します。
GitHub - web-push-libs/web-push: Web Push library for Node.js
const vapidKeys = webpush.generateVAPIDKeys();
console.log(vapidKeys.publicKey, vapidKeys.privateKey);
アプリ側の実装です。Notification API で通知許可を得た場合にプッシュ通知を購読します。
const subscribeNotifications = async () => {
try {
const permission = await getNotificationPermissionOrThrowError();
setNotificationIsGranted( permission === "granted" );
if ( permission === "granted" ) {
const subscription = await subscribeWebPushOrThrowError();
setSubscribing( true );
setShakeIcon( true )
await fetch ( "/api/subscription" , {
method: "POST" ,
headers: {
"Content-type" : "application/json"
} ,
body: JSON .stringify( { subscription } )
} );
}
} catch ( error) {
alert( error);
}
} ;
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: "こんな感じで最新エピソードの追加をお知らせするよ。"
} )
);
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={
"title" : "新しいエピソードが視聴できます。" ,
"body" : get_episode_title(EPISODE_ID),
"url" : f"/episode/{EPISODE_ID}"
}
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
参考