Subscribing from the Hub
Join a channel with useRealtimeChannel — grant a token, listen with on(event), and release on unmount.
You have a channel kind and an event on the API. An Education Hub screen needs to hear it. You do not create a second Centrifuge client. You ask the shared store for that channel, listen while the screen is mounted, and let the hook release it.
This page is the ticket "when exam-status fires, close the student attempt UI."
What sits above you
RealtimeProvider (near the app root) connects once, using GET /api/v1/realtime/token as getToken. It also subscribes to the personal channel. Feature code should not call new Centrifuge().
useRealtimeChannel refcounts: ten components asking for campus-staff + the same campusId share one Centrifugo subscription.
Step 1: Grant and subscribe
import { useRealtimeChannel } from '@/hooks/use-realtime-channel'
import { RealtimeChannel } from '@/lib/realtime/realtime-channels'
const { on, presence, channelName } = useRealtimeChannel(
RealtimeChannel.CBT_EXAM, // generated enum — not a hand-written string
{ examId },
{ mode: 'participant', enabled: !!examId },
)kind comes from RealtimeChannel, which is a re-export of the API DTO enum. After you add a kind on the API and regenerate, the const appears. Params match the pattern: campusId and/or examId today.
enabled: false skips the grant (useful while an id is still loading). mode defaults to 'participant'. Use 'observer' only when the API allows it for that kind — see Using presence.
The hook:
POST /api/v1/realtime/subscription-tokenwith{ kind, campusId?, examId?, mode }.store.acquire(channel, { initialToken, getToken, mode })—getTokenmust resendmodeon every refresh.- On unmount,
store.release. Last release unsubscribes.
A 401/403 on the grant becomes Centrifuge UnauthorizedError and stops retries. That is expected when the user is not allowed on the channel — do not toast it as a generic failure.
Step 2: Listen
useEffect(() => {
return on('exam-status', (data) => {
// data is RealtimeEvents['exam-status']
if (data.type === 'exam-closed') closeAttemptUi()
})
}, [on, closeAttemptUi])on is typed from RealtimeEventMap via the type bus. Unknown types never reach you — the store only dispatches names it was given.
Return the unsubscribe from useEffect. The hook's own cleanup releases the channel; your listener cleanup only removes the handler.
For personal-channel events (none shipped yet except whatever you add), useRealtimeEvent in use-realtime.tsx listens on the provider's personal subscription. Invalidation does not go there.
Step 3: Invalidation is already wired
You should not subscribe to invalidate in a random page. useRealtimeInvalidation joins school-staff and feeds a debounce buffer. Your job on that path is a registerInvalidation row — Invalidating Hub queries.
School-admin observer batch
Watching every campus without appearing in any of them:
const { byCampus } = useObserverChannels({ enabled: isSchoolAdmin })
// Map<campusId, Set<userId>>That hook calls POST /api/v1/realtime/observer-tokens once, acquires each grant as observer, and keys presence by the campusId the API returned — never by parsing staff:campus.….
Gotchas
Do not parse channel names. Treat channelName as an opaque handle. Campus maps use campusId from the grant DTO.
mode on refresh. Forgetting it on getToken turns an observer into a participant after five minutes. Copy the existing hooks.
One provider. A second Centrifuge instance is a second connection quota and a split presence picture.
Types come from the bus. If on('exam-status', …) does not type-check, the Hub client is stale — bun api:watch with the API serving /.well-known/chowbea.json. Do not locally extend RealtimeEvents.