Phase 4: Web Push notifications (pywebpush + VAPID)

Backend: PushSubscription model/migration, VAPID config + `cli.py
generate-vapid-keys`, push_service.send_push_to_user (upsert-by-endpoint
subscribe/unsubscribe, auto-cleanup of expired 404/410 subscriptions),
/api/push/* router, and ConnectionManager now tracks connected user IDs
per room so chat.py can push only to offline members after broadcasting
to online ones.

Two test-infra bugs found and fixed along the way: send_push_to_user
takes the caller's AsyncSession and is awaited inline rather than fired
via asyncio.create_task with its own session (background tasks were
outliving the test event loop); and the ws_client fixture now uses
NullPool to eliminate a connection-pool checkout race that was failing
WS tests intermittently.

Frontend: service worker rebuilt with vite-plugin-pwa's injectManifest
strategy (custom src/sw.ts) so it can add push/notificationclick
handlers alongside the existing precaching and StaleWhileRevalidate
routes ported over from generateSW. New subscribe/unsubscribe flow
(lib/push.ts, api/push.ts) with a toggle in the account menu.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 06:53:07 -06:00
co-authored by Claude Sonnet 5
parent aeb2f3f6a5
commit d09bf4a30a
27 changed files with 876 additions and 95 deletions
+6 -1
View File
@@ -20,7 +20,12 @@
"@vitejs/plugin-react": "^6.0.4",
"oxlint": "^1.75.0",
"typescript": "~6.0.2",
"vite": "^8.2.0"
"vite": "^8.2.0",
"workbox-cacheable-response": "^7.4.1",
"workbox-expiration": "^7.4.1",
"workbox-precaching": "^7.4.1",
"workbox-routing": "^7.4.1",
"workbox-strategies": "^7.4.1"
}
},
"node_modules/@apideck/better-ajv-errors": {
+6 -1
View File
@@ -22,6 +22,11 @@
"@vitejs/plugin-react": "^6.0.4",
"oxlint": "^1.75.0",
"typescript": "~6.0.2",
"vite": "^8.2.0"
"vite": "^8.2.0",
"workbox-cacheable-response": "^7.4.1",
"workbox-expiration": "^7.4.1",
"workbox-precaching": "^7.4.1",
"workbox-routing": "^7.4.1",
"workbox-strategies": "^7.4.1"
}
}
+28
View File
@@ -0,0 +1,28 @@
import { apiFetch } from './client'
export interface PushSubscriptionPayload {
endpoint: string
keys: { p256dh: string; auth: string }
}
interface VapidPublicKeyResponse {
public_key: string | null
}
export function getVapidPublicKey(): Promise<VapidPublicKeyResponse> {
return apiFetch<VapidPublicKeyResponse>('/api/push/vapid-public-key')
}
export function subscribePush(subscription: PushSubscriptionPayload): Promise<void> {
return apiFetch<void>('/api/push/subscribe', {
method: 'POST',
body: JSON.stringify(subscription),
})
}
export function unsubscribePush(endpoint: string): Promise<void> {
return apiFetch<void>('/api/push/subscribe', {
method: 'DELETE',
body: JSON.stringify({ endpoint }),
})
}
+12
View File
@@ -94,3 +94,15 @@
.top-bar-menu button[role='menuitem']:hover {
background: var(--ds-surface-2);
}
.top-bar-menu button[role='menuitem']:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.top-bar-menu-error {
color: var(--ds-danger);
font-size: 0.74rem;
padding: 2px 8px 6px;
max-width: 220px;
}
+38 -1
View File
@@ -1,12 +1,38 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import logo from '../assets/logo.png'
import { useAuth } from '../context/AuthContext'
import { initials } from '../lib/avatar'
import { getPushSubscriptionStatus, isPushSupported, subscribeToPush, unsubscribeFromPush } from '../lib/push'
import './TopBar.css'
export function TopBar() {
const { user, logout } = useAuth()
const [menuOpen, setMenuOpen] = useState(false)
const [pushSubscribed, setPushSubscribed] = useState(false)
const [pushBusy, setPushBusy] = useState(false)
const [pushError, setPushError] = useState<string | null>(null)
useEffect(() => {
getPushSubscriptionStatus().then(setPushSubscribed)
}, [])
async function handleTogglePush() {
setPushBusy(true)
setPushError(null)
try {
if (pushSubscribed) {
await unsubscribeFromPush()
setPushSubscribed(false)
} else {
await subscribeToPush()
setPushSubscribed(true)
}
} catch (err) {
setPushError(err instanceof Error ? err.message : String(err))
} finally {
setPushBusy(false)
}
}
if (!user) return null
@@ -32,6 +58,17 @@ export function TopBar() {
<div className="top-bar-menu-scrim" onClick={() => setMenuOpen(false)} />
<div className="top-bar-menu" role="menu">
<div className="top-bar-menu-username">{user.username}</div>
{isPushSupported() && (
<button
type="button"
role="menuitem"
onClick={handleTogglePush}
disabled={pushBusy}
>
{pushSubscribed ? 'Disable notifications' : 'Enable notifications'}
</button>
)}
{pushError && <div className="top-bar-menu-error">{pushError}</div>}
<button type="button" role="menuitem" onClick={() => logout()}>
Log out
</button>
+64
View File
@@ -0,0 +1,64 @@
import { getVapidPublicKey, subscribePush, unsubscribePush } from '../api/push'
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = atob(base64)
const outputArray = new Uint8Array(new ArrayBuffer(rawData.length))
for (let i = 0; i < rawData.length; i++) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
}
export function isPushSupported(): boolean {
return 'serviceWorker' in navigator && 'PushManager' in window
}
export async function getPushSubscriptionStatus(): Promise<boolean> {
if (!isPushSupported()) return false
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
return subscription !== null
}
export async function subscribeToPush(): Promise<void> {
if (!isPushSupported()) {
throw new Error('Push notifications are not supported in this browser')
}
const permission = await Notification.requestPermission()
if (permission !== 'granted') {
throw new Error('Notification permission was not granted')
}
const { public_key } = await getVapidPublicKey()
if (!public_key) {
throw new Error('Push notifications are not configured on the server')
}
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(public_key),
})
const json = subscription.toJSON()
if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) {
throw new Error('Push subscription is missing required fields')
}
await subscribePush({
endpoint: json.endpoint,
keys: { p256dh: json.keys.p256dh, auth: json.keys.auth },
})
}
export async function unsubscribeFromPush(): Promise<void> {
if (!isPushSupported()) return
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
if (!subscription) return
await unsubscribePush(subscription.endpoint)
await subscription.unsubscribe()
}
+111
View File
@@ -0,0 +1,111 @@
/// <reference lib="webworker" />
import { CacheableResponsePlugin } from 'workbox-cacheable-response'
import { ExpirationPlugin } from 'workbox-expiration'
import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from 'workbox-precaching'
import { NavigationRoute, registerRoute } from 'workbox-routing'
import { NetworkOnly, StaleWhileRevalidate } from 'workbox-strategies'
declare let self: ServiceWorkerGlobalScope
self.skipWaiting()
cleanupOutdatedCaches()
// The app shell -- same effect generateSW gave us automatically in Phase 3.
precacheAndRoute(self.__WB_MANIFEST)
registerRoute(
new NavigationRoute(createHandlerBoundToURL('index.html'), {
denylist: [/^\/api/, /^\/ws/],
}),
)
const READ_CACHE_EXPIRATION = { maxEntries: 50, maxAgeSeconds: 7 * 24 * 60 * 60 }
const cacheableResponse = new CacheableResponsePlugin({ statuses: [0, 200] })
// Ported from Phase 3's vite.config.ts `workbox.runtimeCaching` -- that
// option only applies to the generateSW strategy, so with a hand-written
// service worker (required below for the push/notificationclick handlers)
// these routes have to be registered explicitly instead.
registerRoute(({ url }) => url.pathname.startsWith('/api/auth/'), new NetworkOnly())
registerRoute(
({ url }) => url.pathname === '/api/rooms/mine',
new StaleWhileRevalidate({
cacheName: 'api-rooms-mine',
plugins: [cacheableResponse, new ExpirationPlugin(READ_CACHE_EXPIRATION)],
}),
)
registerRoute(
({ url }) => url.pathname === '/api/rooms',
new StaleWhileRevalidate({
cacheName: 'api-rooms-open',
plugins: [cacheableResponse, new ExpirationPlugin(READ_CACHE_EXPIRATION)],
}),
)
registerRoute(
({ url }) => /^\/api\/rooms\/[^/]+\/messages$/.test(url.pathname),
new StaleWhileRevalidate({
cacheName: 'api-room-messages',
plugins: [cacheableResponse, new ExpirationPlugin(READ_CACHE_EXPIRATION)],
}),
)
registerRoute(
({ url }) => /^\/api\/rooms\/[^/]+\/members$/.test(url.pathname),
new StaleWhileRevalidate({
cacheName: 'api-room-members',
plugins: [cacheableResponse, new ExpirationPlugin(READ_CACHE_EXPIRATION)],
}),
)
registerRoute(
({ url }) => url.pathname === '/api/invites/mine',
new StaleWhileRevalidate({
cacheName: 'api-invites-mine',
plugins: [cacheableResponse, new ExpirationPlugin(READ_CACHE_EXPIRATION)],
}),
)
// Defensive default: anything else under /api/ stays network-only until
// explicitly opted in above.
registerRoute(({ url }) => url.pathname.startsWith('/api/'), new NetworkOnly())
interface PushPayload {
title: string
body: string
room_id: string
}
self.addEventListener('push', (event) => {
if (!event.data) return
let payload: PushPayload
try {
payload = event.data.json()
} catch {
return
}
event.waitUntil(
self.registration.showNotification(payload.title, {
body: payload.body,
icon: '/icons/icon-192.png',
badge: '/icons/icon-192.png',
data: { room_id: payload.room_id },
}),
)
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
const roomId = (event.notification.data as { room_id?: string } | undefined)?.room_id
const targetUrl = roomId ? `/rooms/${roomId}` : '/rooms'
event.waitUntil(
(async () => {
const clientsList = await self.clients.matchAll({ type: 'window', includeUncontrolled: true })
for (const client of clientsList) {
if ('focus' in client) {
await client.navigate(targetUrl)
return client.focus()
}
}
return self.clients.openWindow(targetUrl)
})(),
)
})
+2 -1
View File
@@ -22,5 +22,6 @@
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/sw.ts"]
}
+2 -1
View File
@@ -2,6 +2,7 @@
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.sw.json" }
]
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.sw.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "WebWorker"],
"module": "esnext",
"moduleResolution": "bundler",
"types": ["vite/client"],
"skipLibCheck": true,
"noEmit": true,
"moduleDetection": "force",
"erasableSyntaxOnly": true
},
"include": ["src/sw.ts"]
}
+14 -76
View File
@@ -2,16 +2,25 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
const READ_CACHE_EXPIRATION = {
maxEntries: 50,
maxAgeSeconds: 7 * 24 * 60 * 60, // 7 days
}
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(),
VitePWA({
// generateSW (Phase 3) can't add custom event listeners, and push /
// notificationclick need exactly that -- injectManifest means we hand-
// write the service worker (src/sw.ts); its runtime-caching routes are
// registered there directly instead of via the `workbox` option below
// (which only applies to generateSW).
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.ts',
injectManifest: {
// Workbox's default globPatterns exclude the manifest's own output
// dir, which is fine, but be explicit about what the app shell
// precache should contain.
globPatterns: ['**/*.{js,css,html,ico,png,svg,webmanifest}'],
},
registerType: 'autoUpdate',
manifest: {
name: 'KeepItTalking',
@@ -31,77 +40,6 @@ export default defineConfig({
},
],
},
workbox: {
navigateFallbackDenylist: [/^\/api/, /^\/ws/],
// urlPattern uses function matchers against url.pathname rather than
// RegExp (which Workbox tests against the *full href*, origin
// included -- a `^/api/` anchor would silently never match).
runtimeCaching: [
// Never serve a stale cached "who am I" response.
{
urlPattern: ({ url }) => url.pathname.startsWith('/api/auth/'),
handler: 'NetworkOnly',
},
// Cache-and-refresh: show the last known list/history immediately,
// update from the network in the background. Routes default to
// matching GET only, so mutations to these same paths are
// untouched and still go straight to network.
{
urlPattern: ({ url }) => url.pathname === '/api/rooms/mine',
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'api-rooms-mine',
expiration: READ_CACHE_EXPIRATION,
cacheableResponse: { statuses: [0, 200] },
},
},
{
urlPattern: ({ url }) => url.pathname === '/api/rooms',
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'api-rooms-open',
expiration: READ_CACHE_EXPIRATION,
cacheableResponse: { statuses: [0, 200] },
},
},
{
urlPattern: ({ url }) =>
/^\/api\/rooms\/[^/]+\/messages$/.test(url.pathname),
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'api-room-messages',
expiration: READ_CACHE_EXPIRATION,
cacheableResponse: { statuses: [0, 200] },
},
},
{
urlPattern: ({ url }) =>
/^\/api\/rooms\/[^/]+\/members$/.test(url.pathname),
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'api-room-members',
expiration: READ_CACHE_EXPIRATION,
cacheableResponse: { statuses: [0, 200] },
},
},
{
urlPattern: ({ url }) => url.pathname === '/api/invites/mine',
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'api-invites-mine',
expiration: READ_CACHE_EXPIRATION,
cacheableResponse: { statuses: [0, 200] },
},
},
// Defensive default: anything else under /api/ (including any
// future GET endpoint) stays network-only until explicitly opted
// in above.
{
urlPattern: ({ url }) => url.pathname.startsWith('/api/'),
handler: 'NetworkOnly',
},
],
},
}),
],
server: {