何を作ったか — 「思い出せないと使われない」復習機能

このブログには 「今日の復習」 というページがあります。全記事の末尾にあるクイズを1つの問題バンクに集約し、 SM-2 簡易版の間隔反復(spaced repetition)で「忘れかけた頃」に再出題する仕組みです。 正解するたびに出題間隔が 1日 → 3日 → 1週間と広がり、間違えた問題は翌日また出てきます。

ロジック自体は素直に動きます。ただ、作ってすぐに致命的な弱点に気づきました。間隔反復は「その日に開く」ことが前提のアルゴリズムなのに、開くきっかけがどこにもないのです。 ブラウザのブックマークから復習ページを開くこと自体を忘れれば、期限切れの問題が積み上がっていくだけです。 忘却曲線に基づいて出題するツールが、ツールそのものを忘却される——これは容易に想像できる未来でした。

解決策は単純で、「開くきっかけ」をアプリ側から届けること。つまり PWA(Progressive Web App)化してホーム画面に置き、毎朝 Web Push で呼び戻すことにしました。 今回追加したのは次の6点です。

  • Web App Manifeststart_url/review/ にした「復習アプリ」としてインストール可能にする
  • Service Worker — 復習ページと問題バンクの控えめなキャッシュ + Push受信 + 通知タップ処理
  • 購読クライアント — 通知許可 → VAPID鍵で購読 → サーバ登録を行う push.ts
  • 購読APIapi/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.jsoncrons から毎日 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本で「開く」を仕組みにする。効果は今後の継続率で検証する。

参考リンク

理解度チェック

問題 0 / 50%
Q1

2026年時点の Chrome において、PWA を「インストール可能」にするために必須ではないものはどれですか?

キーボード: 1〜4 で選択、Enter で回答