チャットを作る:第4章 — 管理画面とプッシュ通知

Tony Duong

Tony Duong

5月 31, 20263

他の言語:🇫🇷🇬🇧
#rails#web-push#vapid#service-worker#react
チャットを作る:第4章 — 管理画面とプッシュ通知

💬 これは、このサイトの右下に表示されているライブチャットそのものの実装です。 このシリーズでは、それをどう作ったかを順を追って解説します。チャットの吹き出しを開いて試してから、その仕組みを読み進めてみてください。

第2章と第3章の訪問者側チャットは物語の半分です。この章ではもう半分を扱います:管理者がどう認証するか、AdminChat.tsxが1つのWebSocketで多数の会話をどう多重化するか、そしてWeb Push通知がブラウザタブを閉じた後も管理者にどう届くかです。

管理者認証フロー

管理ページはsrc/app/admin/に配置されています — [locale]ルートツリーの外です。Next.js App Routerはすべてのルートツリーが<html>タグと<body>タグを提供することを要求するため、独自のルートレイアウト(src/app/admin/layout.tsx)が必要です。ブログはsrc/app/[locale]/layout.tsxからそれらを取得するため、/admin/*は別ツリーになります。

ログインシーケンス:

1. 管理者が /admin/login にアクセス
2. AdminLoginPage がフォームをレンダリング            (src/app/admin/login/page.tsx)
3. フォーム送信 -> adminLogin(user, pass)             (src/lib/chat-client.ts)
4. fetch POST /auth/login                             (credentials: "include")
5. AuthController が ENV変数をチェック                 (chat-server/app/controllers/auth_controller.rb)
6. 一致 -> session[:admin] = true、{ ok: true }を返す
7. ブラウザが _chat_server_session Cookie を保存
8. ルーターが /admin/chats に遷移
9. AdminChat がWebSocket接続 -- Connection#connect がsession[:admin]を読む

コントローラー(chat-server/app/controllers/auth_controller.rb)は2つの環境変数と比較します:

if username == ENV["ADMIN_USER"] && password == ENV["ADMIN_PASSWORD"]
  session[:admin] = true
  render json: { ok: true }
end

ユーザーテーブルなし、パスワードハッシュなし、リカバリーフローなし。単一管理者の個人ブログ向け:柔軟性ゼロですが、環境変数以外の攻撃面もゼロです。WebSocket接続は同じセッションを読み取ってself.admin = trueを設定します(第2章)。

AdminChat.tsx — 1つのWebSocketで多数の会話を管理

第3章の訪問者ChatWidgetは1つの会話を処理します。AdminChatは単一のAdminChannelサブスクリプションですべての会話を処理し、2段階のステートを持ちます:

// src/components/AdminChat.tsx
const [conversations, setConversations] = useState<ChatConversation[]>([]);  // 一覧
const [selectedId, setSelectedId] = useState<number | null>(null);           // 詳細
const [messages, setMessages] = useState<ChatMessage[]>([]);                 // 詳細

receivedコールバックは4つのイベントタイプで分岐します:

イベントタイプ 効果
conversations 会話リスト全体を置換(接続時に送信)
new_message last_message / unread_countを更新;選択中ならメッセージを追加
history 選択された会話のメッセージリストを置換
conversation_deleted リストから削除;削除されたものが選択中なら選択をクリア

new_messageハンドラーには微妙なエッジケースがあります。まだリストにない会話(新しい訪問者)のメッセージが到着した場合、部分的なオブジェクトを構築するのではなく、サーバーにリフレッシュを要求します:

const exists = prev.some((c) => c.id === data.conversation_id);
if (!exists) {
  subscriptionRef.current?.perform("list_conversations");
  return prev;
}

第3章のselectedIdRefパターンがここにも現れます — コールバックはselectedIdRef.currentを読んでメッセージを追加するか判断しますが、会話を切り替えるたびにサブスクリプションを再作成することはありません。

会話を選択すると、同じWebSocketを通じて2つのサーバーアクションが発火します:

subscriptionRef.current?.perform("get_history", { conversation_id: id });
subscriptionRef.current?.perform("mark_read", { conversation_id: id });

未読カウントはクライアント側で楽観的にゼロにされ、サーバーが変更を永続化します。

Web Pushパイプライン

プッシュ通知には4者が関わります:管理者のブラウザ、Next.jsフロントエンド、Railsバックエンド、プッシュサービス(Google FCM、Mozilla autopush)。これらのサービスへの登録は不要です — VAPIDキーが認証を処理します。

登録

AdminChatが接続すると、プッシュに登録します。src/components/AdminChat.tsxregisterPushNotificationsは4つのことを行います:

  1. Service Worker(/sw-chat.js)を登録
  2. 通知の許可をリクエスト
  3. VAPIDパブリックキーでプッシュサービスにサブスクライブ
  4. { endpoint, p256dh, auth }をRailsにPOST
// src/components/AdminChat.tsx
const registration = await navigator.serviceWorker.register("/sw-chat.js");
const permission = await Notification.requestPermission();
if (permission !== "granted") return;

const subscription = await registration.pushManager.subscribe({
  userVisibleOnly: true,
  applicationServerKey: urlBase64ToUint8Array(vapidKey) as BufferSource,
});

const json = subscription.toJSON();
await fetch(`${getChatHttpUrl()}/push_subscriptions`, {
  method: "POST",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    endpoint: json.endpoint,
    p256dh: json.keys?.p256dh,
    auth: json.keys?.auth,
  }),
});

Railsはこの3つのフィールドをpush_subscriptionsテーブルに保存します — 後で暗号化ペイロードを送信するために必要なすべてです。

通知の送信

トリガーはVisitorChannel#send_message(第2章)にあります。両方のストリームにブロードキャスト後、アクティブな管理者WebSocketを確認します:

# chat-server/app/channels/visitor_channel.rb
unless admin_connected?
  PushNotificationService.notify(conversation.visitor_name, content) rescue nil
end

PushNotificationServicechat-server/app/services/push_notification_service.rb)は保存されたすべてのサブスクリプションを反復し、VAPID認証情報を使ってWebPush.payload_sendを呼びます。サブスクリプションが410 Goneを返した場合、WebPush::ExpiredSubscriptionをキャッチして古いレコードを削除します。

VAPIDキー

プッシュサービスに対してサーバーを識別する公開鍵/秘密鍵ペアです。以下で生成します:

cd chat-server
bundle exec rails runner "k = WebPush.generate_key; puts k.public_key; puts k.private_key"

パブリックキーは.env.localNEXT_PUBLIC_VAPID_PUBLIC_KEY)とchat-server/.envVAPID_PUBLIC_KEY)に配置します。プライベートキーはバックエンド側のみに保持します。

Service Worker

public/sw-chat.jsは2つのイベントリスナーを持つ30行のファイルです。pushは暗号化ペイロードをパースしてshowNotificationを呼びます。notificationclickは通知を閉じ、既存の/admin/chatsタブにフォーカスを試み、なければ新しいタブを開きます。

重要な特性:Service Workerは別スレッドで動作し、タブを閉じた後も永続します。プッシュ通知が管理者がページにいないときでも機能するのは、まさにこれが理由です。

次のステップ

機能はローカルで動作しています。第5章ではデプロイを扱います — Kamal 2がRailsアプリをDockerコンテナにパッケージし、Hetzner VPSに配送し、SSL、環境シークレット、GitHub Actions CIを接続する方法です。

やってみよう

1. プッシュサブスクリプションの確認

DevToolsのApplicationタブで/admin/chatsを開いてください。Service Workersでsw-chat.jsを、Push Messagingでサブスクリプションの詳細を確認してください。

Chromeの場合:DevTools > Application > Service Workersにsw-chat.jsがステータス「activated and is running」で表示されます。Application > Storage > Push Messagingで、サブスクリプションのendpoint URL(Chromeではfcm.googleapis.comを指す)と、/push_subscriptionsにPOSTされた内容と一致するp256dhおよびauthキーが表示されます。

2. VAPIDキーのローテーション

Rails runnerコマンドで新しいキーを生成してください。.env.localchat-server/.envの両方で置き換えてください。両方のサーバーを再起動し、プッシュが引き続き動作することを確認してください。(片方だけ更新すると、キーの不一致でサブスクリプションが失敗します。)

cd chat-server
bundle exec rails runner "k = WebPush.generate_key; puts k.public_key; puts k.private_key"

1行目をNEXT_PUBLIC_VAPID_PUBLIC_KEY.env.local)とVAPID_PUBLIC_KEYchat-server/.env)にコピーします。2行目をVAPID_PRIVATE_KEYchat-server/.env)にコピーします。両方のサーバーを再起動します。applicationServerKeyが変わったため、ブラウザは接続時に新しいプッシュサブスクリプションを作成します。

3. 通知にメッセージ数を含める

通知の本文に会話のメッセージ数を表示するよう、PushNotificationServiceを修正してください。

chat-server/app/channels/visitor_channel.rbで、conversationオブジェクトを渡します:

PushNotificationService.notify(conversation, content) rescue nil

chat-server/app/services/push_notification_service.rbで:

def self.notify(conversation, message_content)
  payload = {
    title: "New message from #{conversation.visitor_name}",
    body: "#{message_content.truncate(100)} (#{conversation.messages.count} messages)",
    url: "/admin/chats"
  }.to_json
  # ... rest unchanged
end

4. 通知サウンドの追加

プッシュ通知がシステムサウンドを再生するよう、public/sw-chat.jsを修正してください。

pushリスナーの通知オプションにsilent: falseを追加します:

self.registration.showNotification(data.title || "New message", {
  body: data.body || "",
  icon: "/icon-192.png",
  badge: "/icon-192.png",
  data: { url: data.url || "/admin/chats" },
  silent: false,
})

silent: falseはほとんどのプラットフォームでデフォルトです — silent: trueが設定されていない限り、通知は既にシステムサウンドを再生します。soundプロパティによるカスタムサウンドはブラウザのサポートが一貫していません。


🌐 Claudeによる翻訳

Tony Duong

著者: Tony Duong

デジタル日記。思考、経験、そして人生についての考え。