One event, three channels: live toasts over Server-Sent Events, push to phones through FCM and APNs, e-mail at the pace each person chose — and why SSE rather than WebSocket, and why no separate realtime service yet.
🔔 Notifications & Realtime
Something happens — a follower, a tip, an unlock, a bid, a pledge, a message — and the person it concerns hears of it wherever they are: on an open page (in the second), on their phone (even with the app closed), by e-mail (at the pace they chose). One event, one row, three channels.
1. The channels
Every event goes through notify() (apps/web/lib/notifications.ts): it writes the notification (the source of
truth), publishes it live, pushes it to the phones, then e-mails it. Each event can be turned off per channel in
Settings → Notifications (the "in the app" switch covers the bell, the toasts and the phone).
2. Why SSE and not WebSocket
The traffic is one-way, server → client: the client acts through ordinary HTTP requests (follow, tip, bid, send a message), and only has to hear what follows. That is what Server-Sent Events are for:
- Plain HTTP: through every proxy and CDN (checked on DigitalOcean App Platform — the stream is not buffered, heartbeats every 15 s), the same cookie or bearer token as the rest of the API, no protocol upgrade, no extra port.
- Reconnection is built in (
EventSourceretries by itself); a reconnection reloads the state, so nothing missed while offline stays missing. - No sticky sessions: any instance serves any stream, because the fan-out happens in PostgreSQL (below).
WebSocket would pay for a full-duplex channel nobody uses, and ask for sticky routing or a pub/sub tier to fan out between instances. It becomes the right tool only for high-frequency client → server traffic (live video chat, cursors, games) — none of which Orochia has.
3. One bus, no broker
apps/web/lib/realtime.ts is the only door: publish(topic, event) → PostgreSQL NOTIFY orochia_events → every
instance's LISTEN connection → the SSE streams it holds. Topics: user:<id> (notifications, messages),
auction:<id>, challenge:<id>. Payloads are small facts (≤ 8 KB), never documents.
A separate "realtime" service? Not now. A dedicated service (or Redis / NATS) would add a deployment, a hop and a
monthly bill to carry what the database the app already pays for carries well below its limits. The day it is needed
— several products publishing to the same people, ~10 k concurrent streams, durable replay — realtime.ts is the one
file whose body changes; its callers do not. The push client is already a package of its own (packages/push), ready
to move to a shared Krizaka repository when a second product needs it.
4. Mobile push
- Registration: after sign-in the app asks for permission, gets its Expo push token and registers it
(
POST /api/me/devices,{ token, platform }). A token belongs to one account at a time — signing in with another account on the same phone moves it. Sign-out forgets it (DELETE /api/me/devices). - Sending:
pushToUsersends the notification's subject and text, withdata.path— tapping the notification opens that screen in the app. Batches of 100, never throws; tokens reported dead are deleted. - Gate: pushes leave only in production, or with
PUSH_DELIVERY=on(local runs and CI never call the service);PUSH_DELIVERY=offstops them in production too. - Why Expo Push and not Firebase directly: one HTTP call and one kind of token for both platforms, no Admin SDK
and no service-account key on the server. Firebase is still underneath for Android — its credentials live in the
app's EAS project (below). Switching to FCM HTTP v1 directly later only changes
packages/push.
To turn it on (once)
- Expo / EAS —
npx eas-cli@latest initinorochia-mobile(creates the project and itsprojectId). - Android — Firebase: create a Firebase project, add an Android app
com.krizaka.orochia, downloadgoogle-services.json(EAS file secretGOOGLE_SERVICES_JSON), and upload an FCM v1 service-account key witheas credentials→ Android → Push Notifications (FCM V1). - iOS — APNs:
eas credentials→ iOS → Push Notifications creates (or uploads) the APNs key from the Apple Developer account. - Server (optional): turn on Enhanced push security in the Expo project and set
EXPO_ACCESS_TOKEN.