何を作ったか — 「思い出せないと使われない」復習機能
このブログには 「今日の復習」 というページがあります。全記事の末尾にあるクイズを1つの問題バンクに集約し、 SM-2 簡易版の間隔反復(spaced repetition)で「忘れかけた頃」に再出題する仕組みです。 正解するたびに出題間隔が 1日 → 3日 → 1週間と広がり、間違えた問題は翌日また出てきます。
ロジック自体は素直に動きます。ただ、作ってすぐに致命的な弱点に気づきました。間隔反復は「その日に開く」ことが前提のアルゴリズムなのに、開くきっかけがどこにもないのです。 ブラウザのブックマークから復習ページを開くこと自体を忘れれば、期限切れの問題が積み上がっていくだけです。 忘却曲線に基づいて出題するツールが、ツールそのものを忘却される——これは容易に想像できる未来でした。
解決策は単純で、「開くきっかけ」をアプリ側から届けること。つまり PWA(Progressive Web App)化してホーム画面に置き、毎朝 Web Push で呼び戻すことにしました。 今回追加したのは次の6点です。
- Web App Manifest —
start_urlを/review/にした「復習アプリ」としてインストール可能にする - Service Worker — 復習ページと問題バンクの控えめなキャッシュ + Push受信 + 通知タップ処理
- 購読クライアント — 通知許可 → VAPID鍵で購読 → サーバ登録を行う
push.ts - 購読API —
api/push/subscribe.ts(Vercel Functions + Upstash Redis) - 送信ジョブ —
api/push/send.tsを Vercel Cron が毎朝起動 - アプリバッジ — 今日の残り復習数をアイコン上に表示(Badging API)
PWA の3要素と2026年時点の現在地
PWA は長らく「manifest + Service Worker + HTTPS の三点セット」と説明されてきました。ただ、この説明は少し古くなっています。Chrome はモバイル 108・デスクトップ 112 以降、インストール条件から Service Worker(fetch ハンドラ)の要件を外しました。 manifest と HTTPS だけでインストールプロンプトは出ます。
一方で、今回やりたいことのうち「Push を受信する」「オフラインでも復習ページを開く」は Service Worker なしには実現できません。 つまり、要素ごとに「何のために必要か」が分離した、と理解するのが正確です。
| 要素 | インストール可能にするため | Push通知のため | オフライン表示のため |
|---|---|---|---|
| Web App Manifest | 必須(name / icons / start_url / display) | 不要(ただしiOSはホーム画面追加が前提なので実質必須) | 不要 |
| Service Worker | 不要(Chrome 108/112以降) | 必須(push イベントの受け口) | 必須(fetch を横取りしてキャッシュ応答) |
| HTTPS | 必須 | 必須 | 必須(SW登録の前提) |
| サーバ側API | 不要 | 必須(購読保存 + 送信) | 不要 |
全体アーキテクチャ
完成形の流れを先に示します。特徴は、Astro の静的出力(記事・復習ページ)と、api/ ディレクトリに置いた Vercel Functions が同じデプロイに同居している点です。 ブラウザは自分で Push サーバに繋ぐわけではなく、ブラウザベンダーの Push Service(Chrome なら FCM、Safari なら APNs)を経由します。
graph TD
subgraph browser["ブラウザ / ホーム画面アプリ"]
P["復習ページ"]
SW["Service Worker"]
end
subgraph vercel["Vercel(同一デプロイ)"]
SUB["api/push/subscribe"]
CRON["Cron 毎日 UTC 1:00"]
SEND["api/push/send"]
end
R[("Upstash Redis<br/>push:subs")]
PS["Push Service<br/>FCM / APNs / Mozilla"]
P -->|"1. 購読登録"| SUB
SUB -->|"2. 保存"| R
CRON -->|"3. 起動"| SEND
R -->|"4. 購読一覧"| SEND
SEND -->|"5. 暗号化送信"| PS
PS -->|"6. push"| SW
SW -->|"7. 通知表示"| P
ここで押さえておくべき登場人物は「Push Service」です。Web Push は RFC 8030(プロトコル)・RFC 8291(メッセージ暗号化)・RFC 8292(VAPID) で標準化されており、 どのブラウザでも「アプリサーバ → Push Service → ブラウザの Service Worker」という同じ形で届きます。 自前のサーバが知っているのは endpoint(Push Service 上の購読URL)と暗号鍵だけで、ユーザーの端末を直接は知りません。
実装1: Manifest — 「ブログ」ではなく「復習アプリ」としてインストールさせる
最初の設計判断は start_url です。ブログ全体を PWA にするのではなく、ホーム画面のアイコンをタップしたら復習ページが開くようにしました。 記事を読むのはブラウザで十分で、アプリとして毎日開いてほしいのは復習だけだからです。scope は / のままにして、復習から記事へのリンクもアプリ内で開けるようにしています。
{
"name": "saka2jp's blog — 今日の復習",
"short_name": "復習",
"start_url": "/review/",
"scope": "/",
"display": "standalone",
"background_color": "#0c0a12",
"theme_color": "#0c0a12",
"lang": "ja",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}アイコンは 192px と 512px の2枚が Chrome の要件で、加えて purpose: "maskable" の1枚を用意しました。 Android はアイコンを円や角丸にマスクするため、余白なしの画像だと端が欠けます。maskable 版は中央 80% に収まるよう余白を持たせたものです。 iOS はこの manifest の icons を見ず apple-touch-icon を参照するので、レイアウト側に別途リンクを置いています。
<!-- BaseLayout.astro の head -->
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="復習" />
<script is:inline>
if ('serviceWorker' in navigator) {
window.addEventListener('load', function () {
navigator.serviceWorker.register('/sw.js');
});
}
</script>実装2: Service Worker — 「控えめなキャッシュ」と Push 受信
キャッシュは3パスだけ、network-first
PWA の解説記事では「全アセットを precache してオフラインファーストに」という構成をよく見ますが、今回は意図的に採用しませんでした。 このブログは記事の追加・修正が頻繁で、Service Worker のキャッシュが古い記事を返し続ける事故のほうが、オフラインで読めない不便より痛いと判断したからです。
| 戦略 | 動き | 向いている対象 | 今回の採用 |
|---|---|---|---|
| Cache First | キャッシュがあれば即返す。無ければネットワーク | ハッシュ付きの不変アセット(/_astro/*.js 等) | 不採用(Astro側のHTTPキャッシュで十分) |
| Network First | まずネットワーク。失敗時にキャッシュへフォールバック | 更新頻度が高いが、オフラインでも「何か」を見せたいページ | 採用(/review/ と問題バンク) |
| Stale While Revalidate | キャッシュを即返しつつ裏で更新 | 多少古くても構わない一覧・アバター等 | 不採用 |
| キャッシュしない | SW は関与せず、通常のHTTPキャッシュに任せる | 記事本体・API | 採用(上記3パス以外すべて) |
const CACHE_NAME = 'saka2blog-review-v1';
const CACHED_PATHS = ['/review/', '/data/review-bank.json', '/manifest.webmanifest'];
self.addEventListener('install', (event) => {
self.skipWaiting();
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(CACHED_PATHS).catch(() => {})),
);
});
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
// 旧バージョンのキャッシュを掃除してから制御権を奪う
const keys = await caches.keys();
await Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)));
await self.clients.claim();
})());
});
// network-first + キャッシュフォールバック(復習関連パスのみ)
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (event.request.method !== 'GET' || url.origin !== self.location.origin) return;
if (!CACHED_PATHS.includes(url.pathname)) return; // ← 対象外は SW が一切触らない
event.respondWith(
fetch(event.request)
.then((res) => {
if (res.ok) {
const copy = res.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, copy));
}
return res;
})
.catch(() => caches.match(event.request)),
);
});ポイントは fetch ハンドラの早期 return です。respondWith を呼ばずに抜けると、そのリクエストは Service Worker が存在しないときと同じ経路で処理されます。 「SW を入れたら記事が古くなった」という典型的な事故は、この一行で構造的に防げます。CACHE_NAME のバージョン番号は、復習ページの構造を変えたときに上げれば activate で旧キャッシュが消える、という運用にしています。
push イベントと notificationclick
self.addEventListener('push', (event) => {
let payload = {
title: '🔁 今日の復習の時間です',
body: 'クイズが出題を待っています。5分だけ解きましょう。',
url: '/review/',
};
try {
if (event.data) payload = { ...payload, ...event.data.json() };
} catch {
// JSONでないペイロードはデフォルト文言で表示
}
event.waitUntil(
self.registration.showNotification(payload.title, {
body: payload.body,
icon: '/icons/icon-192.png',
badge: '/icons/badge-96.png', // Androidの小アイコン。モノクロ透過PNGでないとChromeロゴになる
tag: 'daily-review', // 同じtagの通知は上書き(毎朝1件だけ残る)
data: { url: payload.url },
}),
);
});
// 通知タップ → 復習ページを開く(既に開いていればフォーカス)
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const targetUrl = event.notification.data?.url || '/review/';
event.waitUntil((async () => {
const clientList = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
for (const client of clientList) {
if (new URL(client.url).pathname === targetUrl && 'focus' in client) return client.focus();
}
return self.clients.openWindow(targetUrl);
})());
});2つ細かい設計があります。1つは tag: 'daily-review' で、数日開かなかったときに通知が積み上がらず最新1件に置き換わるようにしたこと。 もう1つは userVisibleOnly: true で購読する以上、push を受けたら必ず通知を表示しなければならないことです。 ペイロードの JSON パースに失敗しても、デフォルト文言で showNotification を呼ぶようにしているのはそのためです。
実装3: 購読フロー — VAPID 公開鍵から Redis 保存まで
クライアント側の「通知を有効にする」ボタンから購読が保存されるまでの流れです。 権限リクエストは必ずユーザー操作の中で呼びます。ページ読み込み直後に requestPermission() を出すと、ブラウザによっては自動でブロックされ、しかも一度 denied になると JavaScript からは二度と復帰できません。
sequenceDiagram
participant U as ユーザー
participant App as 復習ページ
participant SW as Service Worker
participant API as api/push/subscribe
participant PS as Push Service
U->>App: 「通知を有効にする」をタップ
App->>U: Notification.requestPermission()
U-->>App: granted
App->>API: GET(VAPID公開鍵を要求)
API-->>App: publicKey
App->>SW: pushManager.subscribe(applicationServerKey)
SW->>PS: 購読を作成
PS-->>SW: endpoint + p256dh + auth
SW-->>App: PushSubscription
App->>API: POST subscription JSON
API->>API: Redis HSET push:subs endpoint → JSON
API-->>App: 200 OK
// src/components/review/push.ts(抜粋)
export async function enablePush(): Promise<PushStatus> {
if (!isPushSupported()) return 'unsupported';
const permission = await Notification.requestPermission();
if (permission !== 'granted') return permission === 'denied' ? 'denied' : 'off';
// サーバから VAPID 公開鍵を取得(鍵をバンドルに焼き込まない)
const keyRes = await fetch('/api/push/subscribe');
if (!keyRes.ok) throw new Error('サーバ側のVAPID鍵が未設定です');
const { publicKey } = await keyRes.json();
const reg = await navigator.serviceWorker.ready;
const subscription =
(await reg.pushManager.getSubscription()) ??
(await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey) as BufferSource,
}));
const saveRes = await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subscription: subscription.toJSON() }),
});
if (!saveRes.ok) throw new Error('購読情報の保存に失敗しました');
return 'on';
}VAPID(Voluntary Application Server Identification)は「この Push はこのアプリサーバが送っている」と Push Service に証明する仕組みです。 鍵ペアは npx web-push generate-vapid-keys で1回生成し、公開鍵・秘密鍵を Vercel の環境変数に入れます。 公開鍵はクライアントに渡す前提のものなので GET で返して構いませんが、ビルドに焼き込まず API から取る形にしておくと、鍵をローテーションしても再デプロイだけで済みます。
静的サイトに API を同居させる
Astro は静的出力(output: 'static')のまま、リポジトリ直下の api/ ディレクトリにファイルを置けば Vercel がそれを Functions としてデプロイします。 フレームワークのアダプタを切り替える必要はありません。購読情報は Upstash Redis の hash 1本に、field = endpoint、value = 購読 JSON で保存しています。 endpoint は Push Service が発行する一意な URL なので、重複登録が自然に上書きになるのが便利です。
// api/push/subscribe.ts(抜粋)
import type { VercelRequest, VercelResponse } from '@vercel/node';
import { Redis } from '@upstash/redis';
const SUBS_KEY = 'push:subs'; // Redis hash: field = endpoint, value = subscription JSON
export default async function handler(req: VercelRequest, res: VercelResponse) {
if (req.method === 'GET') {
return res.status(200).json({ publicKey: process.env.VAPID_PUBLIC_KEY });
}
if (req.method === 'POST') {
const subscription = req.body?.subscription;
if (!subscription?.endpoint || !subscription?.keys) {
return res.status(400).json({ error: 'invalid subscription' });
}
await redis.hset(SUBS_KEY, { [subscription.endpoint]: JSON.stringify(subscription) });
return res.status(200).json({ ok: true });
}
if (req.method === 'DELETE') {
await redis.hdel(SUBS_KEY, req.body?.endpoint);
return res.status(200).json({ ok: true });
}
res.setHeader('Allow', 'GET, POST, DELETE');
return res.status(405).json({ error: 'method not allowed' });
}実装4: 毎朝10時に送る — Vercel Cron と購読の掃除
送信側は api/push/send.ts で、vercel.json の crons から毎日 UTC 1:00(JST 10:00)に叩かれます。 ライブラリは web-push を使い、RFC 8291 の暗号化と VAPID の JWT 生成を任せています。
// vercel.json
{
"crons": [
{ "path": "/api/push/send", "schedule": "0 1 * * *" }
]
}// api/push/send.ts(抜粋)
import webpush from 'web-push';
export default async function handler(req: VercelRequest, res: VercelResponse) {
// Vercel Cron は Authorization: Bearer <CRON_SECRET> を付けて呼ぶ。外部からの叩かれ防止
const secret = process.env.CRON_SECRET;
if (secret && req.headers.authorization !== 'Bearer ' + secret) {
return res.status(401).json({ error: 'unauthorized' });
}
webpush.setVapidDetails(
process.env.VAPID_SUBJECT ?? 'https://saka2jp.vercel.app',
process.env.VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!,
);
const subs = await redis.hgetall<Record<string, unknown>>(SUBS_KEY);
const payload = JSON.stringify({
title: '🔁 今日の復習の時間です',
body: 'クイズが出題を待っています。5分だけ解きましょう。',
url: '/review/',
});
let sent = 0, pruned = 0;
await Promise.all(
Object.entries(subs ?? {}).map(async ([endpoint, value]) => {
try {
const subscription = typeof value === 'string' ? JSON.parse(value) : value;
await webpush.sendNotification(subscription, payload);
sent++;
} catch (err: unknown) {
const status = (err as { statusCode?: number }).statusCode;
// 404 / 410 は「その購読はもう存在しない」。次回以降送らないよう削除する
if (status === 404 || status === 410) {
await redis.hdel(SUBS_KEY, endpoint);
pruned++;
}
}
}),
);
return res.status(200).json({ sent, pruned });
}地味に重要なのが 404 / 410 での購読削除です。ユーザーがブラウザ側で通知をオフにしたり、サイトデータを消したりすると、 Push Service は該当 endpoint への送信に 410 Gone を返します。これを放置すると死んだ購読に毎朝送り続けることになり、 Push Service 側からスパム扱いされるリスクもあります。送信ジョブが自分で掃除する形にして、クライアント側の DELETE は「できたら呼ぶ」程度の扱いにしました。
実装5: アプリバッジで「今日の残り数」を見せる
最後に小さな仕上げとして、Badging API で今日の未消化問題数をアイコンに表示するようにしました。 復習セッションの統計(stats.dueToday)が変わるたびに setAppBadge を呼ぶだけです。 対応していない環境では単に無視されるよう、オプショナルチェーンと try/catch で包んでいます。
export function updateAppBadge(count: number): void {
try {
const nav = navigator as Navigator & {
setAppBadge?: (n: number) => Promise<void>;
clearAppBadge?: () => Promise<void>;
};
if (count > 0) nav.setAppBadge?.(count);
else nav.clearAppBadge?.();
} catch {
// 非対応環境では無視
}
}
// ReviewSession.tsx 側
useEffect(() => {
if (stats) updateAppBadge(stats.dueToday);
}, [stats]);iOS ではバッジの権限が「通知の許可」に紐づいて自動付与されるので、通知を有効にした人だけバッジも出ます。 「全部解いたらバッジが消える」という小さな達成感を、「開く動機」の一つとして狙っています。
ハマったところ
1. iOS の Safari では「通知ボタンが出ない」のが正しい
iPhone の Safari で確認すると pushStatus === 'unsupported' になり、一瞬バグを疑う挙動です。 前述のとおり iOS はホーム画面に追加した Web アプリでしか PushManager を公開しないので、これは仕様です。 UI 側では unsupported のときボタンを非表示にしていますが、iOS 判定をして「ホーム画面に追加すると通知が使えます」と案内するほうが親切だと感じています。
2. Service Worker の更新が反映されない
sw.js を書き換えても古い SW が動き続ける問題。ブラウザは SW スクリプトをバイト単位で比較して更新を検出しますが、 新しい SW は既存のタブがすべて閉じるまで waiting 状態で待ちます。 開発中は self.skipWaiting() と clients.claim() を入れておく、DevTools の Application タブで「Update on reload」を有効にする、の2点で解消できます。 本番でも即時切り替えを許容できる(状態を持たない)SW なので、そのまま skipWaiting を残しています。
3. applicationServerKey の型エラー
VAPID 公開鍵は base64url 文字列で配布されますが、subscribe() には Uint8Array で渡す必要があります。- → +、_ → / の置換とパディング補完をして atob するヘルパーが定番ですが、 TypeScript 5.7 で型付き配列がジェネリック化された影響で、新しい DOM 型定義との組み合わせでは Uint8Array<ArrayBufferLike> が BufferSource に直接代入できずコンパイルエラーになります。as BufferSource のキャストで逃げています。
4. ローカルで Push を試せない
Vercel Functions は astro dev では動かないため、購読 API の動作確認はプレビューデプロイで行うことになります。 Push そのものは HTTPS が必須なので、いずれにせよ本番相当の環境が要ります。CRON_SECRET を付けて curl で /api/push/send を手動で叩けるようにしておくと、朝を待たずに送信を検証できます。
学び・振り返り
- PWA は「全部やる」必要がない。 インストール性・Push・オフラインは別々の要件で、必要なものだけ選べる。 今回はオフラインを最小限にして、事故リスクの高い記事キャッシュを避けた。
- Service Worker で最も重要なコードは「何もしない分岐」。 触らないリクエストを明示的に
returnすることで、SW 導入の副作用を局所化できる。 - 静的サイトでも「少しのサーバ」は簡単に足せる。
api/ディレクトリ + マーケットプレイスの Redis で、フレームワーク構成を変えずに購読管理と Cron が動いた。 - 通知は届けたら終わりではなく、届かなくなった購読を消すまでが仕事。 410 の掃除を最初から入れておくと、後で「なぜか送信数が増え続ける」を追わなくてよい。
- 学習ツールの成否は UX の「開くきっかけ」で決まる。 アルゴリズムを磨くより先に、毎朝の通知1本で「開く」を仕組みにする。効果は今後の継続率で検証する。
参考リンク
- Revisiting Chrome's installability criteria — Chrome for Developers(Service Worker 要件の撤廃と、現在のインストール条件)
- Making PWAs installable — MDN(manifest の必須メンバー、HTTPS 要件)
- Web Push for Web Apps on iOS and iPadOS — WebKit(iOS 16.4 での Web Push 対応。ホーム画面追加が前提である旨の一次記載)
- Badging for Home Screen Web Apps — WebKit(Badging API の対応範囲と、通知許可に権限が連動する仕組み)
- RFC 8030 — Generic Event Delivery Using HTTP Push / RFC 8291 — Message Encryption for Web Push / RFC 8292 — VAPID
- web-push-libs/web-push(Node.js 送信ライブラリ。
generate-vapid-keysCLI を同梱) - Usage & Pricing for Cron Jobs — Vercel(Hobby プランの1日1回制約と実行時刻の精度)
- Cron jobs now support 100 per project on every plan — Vercel Changelog
- ブラウザに値を保存する選択肢を整理する — 本ブログ(Cache API と localStorage の位置づけ。復習の学習記録は localStorage に置いています)
理解度チェック
2026年時点の Chrome において、PWA を「インストール可能」にするために必須ではないものはどれですか?
キーボード: 1〜4 で選択、Enter で回答