Skip to main content

Live updates with events

Claude
Authored by Claude · Last updated AI generated documentation written by reading the source code
self-hosted

The Media Server pushes changes to every open app over one server-sent event stream per app. Cardinal's own apps keep that stream open for as long as the user is signed in, reconnect with a growing delay when it drops, and turn each event into a refresh of exactly the data it affects. The server does not store events, so anything sent while an app is disconnected is gone, and an app that does not refetch after a gap shows stale data until the user reloads. Events is the catalogue of event types and the message format; this recipe is the client loop and the refresh map.

Rules​

  1. Open the stream only while signed in: GET /api/v1/events/subscribe, with the credential in the Authorization header. Close it when the user signs out. Use the token query parameter only where the platform's SSE client cannot send headers, such as the browser's built-in EventSource.
  2. Read every message as JSON from its data: line, with a type and a payload. Messages carry no event: name. Skip a message that is not valid JSON; never let one stop the stream.
  3. Reconnect with exponential backoff. Wait 1 second after the first failure, then double the wait after each failure in a row, up to 30 seconds. Reset the wait to 1 second as soon as a connection opens.
  4. Renew the credential on a 401, then reconnect. A credential that is about to expire is renewed before connecting, not after the refusal.
  5. Stop when the credential is dead: on a 410, or on a 401 that a renewed credential also gets. The credential has expired or been revoked, or its user was deleted. Sign the user out, as Authentication describes, instead of retrying.
  6. Refetch after every reconnection. Events sent while the stream was down are lost. Once the stream opens again, refetch whatever the app is showing, and do not wait for events to bring it up to date.
  7. Treat a stream that ends as a dropped connection. The server can close it, and so can a proxy in between. Reconnect, then refetch.
  8. Follow the base URL. When the connection moves, close the stream and open it on the new address straight away, without the backoff delay.
  9. Refresh only what an event affects, using the map below. Most events say that something changed; refetch it rather than patching the app's data from the payload. Payloads follow the server's data model and can change between versions, so read only the fields the map names.
  10. Expect events from other people's actions. Indexing, job, waveform, photo, and photo album events go to every connected user. Queue and notification events go to every app and device the user they belong to has open.

What to refresh for each event​

typeRefresh
music.waveform_readyNothing to fetch: payload.waveform is the finished waveform for payload.musicTrackId. Store it where the app reads waveforms.
playback_queue.extendedThe upcoming items of the queue named by payload.queueId, if the app shows it. See Playback and the queue.
notification.createdThe user's notifications, GET /api/v1/notifications.
indexing.started, indexing.current_progress, indexing.paused, indexing.resumed, indexing.stopped, indexing.completedAn indexing indicator, from the payload or from GET /api/v1/index/state.
indexing.completed, indexing.music_release_added, indexing.music_artist_addedBrowse lists the app shows, such as releases and artists.
job.preparing, job.started, job.resume, job.completedA list of running jobs, GET /api/v1/jobs, if the app shows one.
photo.updatedThe photo, and the album lists it appears in.
photo_album.created, photo_album.updated, photo_album.deleted, photo_album.photos_added, photo_album.photos_removedThe album list, and any album the app has open. Refetch rather than reading an album ID from the payload.
factory_resetEverything. The server was reset: its accounts are gone and its instance ID changed. Discard the app's data, credential, and stored instance ID, and sign the user out.

Refreshing data the user cannot read returns 403. Skip refreshes the user lacks the capability for, as Permissions in the UI describes; for example, jobs need Jobs.Read and notifications need Notifications.Read.

Reference implementation​

getToken(), getOrigin(), and onOriginChange() come from Connecting and staying connected. This reads the stream with fetch, which can send the Authorization header; the browser's EventSource cannot.

declare function getToken(options?: { refresh?: boolean }): Promise<string>
declare function getOrigin(): string | null
declare function onOriginChange(listener: (origin: string) => void): () => void

type ServerEvent = { type: string, payload: any }

const BASE_DELAY_MS = 1_000
const MAX_DELAY_MS = 30_000

export function subscribe(
onEvent: (event: ServerEvent) => void,
onReconnect: () => void, // refetch what the app shows
onSignedOut: () => void,
): () => void {
let stopped = false
let failures = 0
let opened = false
let connection: AbortController | null = null
let wake: (() => void) | null = null

const sleep = (ms: number) => new Promise<void>((resolve) => {
const timer = setTimeout(resolve, ms)
wake = () => { clearTimeout(timer); resolve() }
})

const open = async (refresh: boolean, signal: AbortSignal) => fetch(`${getOrigin()}/api/v1/events/subscribe`, {
headers: { Authorization: `Bearer ${await getToken({ refresh })}`, Accept: 'text/event-stream' },
signal,
})

const run = async () => {
while (!stopped) {
connection = new AbortController()
try {
let res = await open(false, connection.signal)
if (res.status === 401) res = await open(true, connection.signal)
if (res.status === 401 || res.status === 410) {
stopped = true
onSignedOut()
return
}
if (!res.ok || !res.body) throw new Error(`Events refused with ${res.status}`)

failures = 0
if (opened) onReconnect()
opened = true
await read(res.body, onEvent)
} catch {
// dropped, refused, or aborted by a move: all handled by the loop
}
if (stopped) return
if (connection.signal.aborted) continue
await sleep(Math.min(BASE_DELAY_MS * 2 ** failures++, MAX_DELAY_MS))
}
}

const unsubscribeOrigin = onOriginChange(() => {
failures = 0
connection?.abort()
wake?.()
})

void run()

return () => {
stopped = true
unsubscribeOrigin()
connection?.abort()
wake?.()
}
}

async function read(body: ReadableStream<Uint8Array>, onEvent: (event: ServerEvent) => void): Promise<void> {
const reader = body.pipeThrough(new TextDecoderStream()).getReader()
let buffer = ''
let data: string[] = []

for (;;) {
const { value, done } = await reader.read()
if (done) return
buffer += value

let newline: number
while ((newline = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, newline).replace(/\r$/, '')
buffer = buffer.slice(newline + 1)

if (line.startsWith('data:')) {
data.push(line.slice(5).trimStart())
} else if (line === '' && data.length) {
try { onEvent(JSON.parse(data.join('\n'))) } catch {}
data = []
}
}
}
}

Wire the map to the app's own data layer:

const refresh: Record<string, (payload: any) => void> = {
'music.waveform_ready': (p) => storeWaveform(p.musicTrackId, p.waveform),
'playback_queue.extended': (p) => { if (p.queueId === currentQueueId) refetchUpcoming() },
'notification.created': () => refetchNotifications(),
'indexing.completed': () => refetchBrowseLists(),
'factory_reset': () => signOutAndClear(),
// ...the rest of the table
}

const unsubscribe = subscribe(
(event) => refresh[event.type]?.(event.payload),
() => refetchEverythingVisible(),
() => signOutAndClear(),
)

Edge cases​

  • The browser's EventSource. It cannot send headers, so it needs ?token= in the URL, and that URL then carries a credential: never log it. EventSource retries a dropped connection on its own, but stops for good after an error status such as 401. Open a new one with a fresh credential when that happens.
  • One user, many apps. Every app the user has open holds its own stream and receives the user's events, so a queue extended on a phone also refreshes the queue on a desktop.
  • A burst of events. Indexing and jobs send progress events often. Throttle refetches that progress events cause, or update from the payload.
  • The same event twice. A refetch is idempotent, which is why the map refetches rather than applying payloads as changes.
  • Unknown event types. The server adds events over time. Ignore types the app does not handle.

How to verify it works​

  1. Sign in and confirm one request to /api/v1/events/subscribe stays open, with an Authorization header and no token in the URL.
  2. Start a job in the Admin app and confirm job.started and job.completed arrive.
  3. Restart the Media Server. Confirm reconnection attempts about 1, 2, 4, and 8 seconds apart, and one refetch of the visible data once the stream opens again.
  4. Change something while the app is disconnected, such as adding a photo to an album, and confirm the app shows the change after it reconnects, without an event for it.
  5. Move the app to another base URL and confirm the old stream closes and a new one opens on the new address immediately.
  6. Revoke the app's credential and confirm the app stops reconnecting and asks the user to sign in.

Was this article helpful?