Presence
Ephemeral realtime presence — who's online in a channel, with heartbeat, live roster, and per-member metadata (status, cursor). Works cross-instance.
@voltro/plugin-presence answers "who's here right now". A client heartbeats into a channel; the roster lists everyone whose heartbeat is fresh. Backed by a swept _voltro_presence table, so it works across instances with no extra infra (Redis not required).
Wiring
// app.config.ts
import { presencePlugin } from '@voltro/plugin-presence'
export default {
type: 'api' as const, name: 'api',
plugins: [presencePlugin({ timeoutMs: 30_000 })], // online window after the last heartbeat
}Contributes the _voltro_presence table + three routes: presence.heartbeat, presence.list, presence.leave. A background sweep drops stale rows.
Client hook
import { usePresence } from '@voltro/plugin-presence/web'
const Room = ({ channel }: { channel: string }) => {
// Heartbeats while mounted, leaves on unmount, returns the live roster.
const members = usePresence(channel, { meta: { status: 'typing' } })
return <div>{members.length} online: {members.map((m) => m.key).join(', ')}</div>
}
usePresence(channel, opts) heartbeats on an interval (heartbeatMs, default 15s) and publishes per-member meta (status, cursor position, …). The roster is push-driven — presence.list is a reactive plugin query (source: '_voltro_presence'), so the framework pushes a fresh roster over the subscription transport on every presence-table change (heartbeat / leave / sweep) with NO client polling. key defaults to the subject id; pass an explicit key for anonymous members.
Typing indicator
useTyping is a typing indicator built on the SAME presence primitive — no new transport. While isTyping, the client heartbeats into a short-TTL lane typing:<channel>; stop() / unmount leaves it, so a typer drops off within the heartbeat window.
import { useTyping } from '@voltro/plugin-presence/web'
const Composer = ({ channel, myKey }: { channel: string; myKey: string }) => {
const typing = useTyping(channel, { selfKey: myKey })
return (
<>
<textarea onFocus={typing.start} onBlur={typing.stop} />
{typing.active.length > 0 && <em>{typing.active.length} typing…</em>}
</>
)
}useTyping(channel, opts) → { active, isTyping, start, stop }: active is the other members currently typing (self excluded via selfKey), start()/stop() toggle broadcasting. A shorter heartbeat than the roster (heartbeatMs default 3s) so a typer clears quickly; the lane roster is push-driven like usePresence (no poll). activeTypers(members, selfKey) is the pure self-exclusion helper it uses.
Notes
- The roster is push-driven:
presence.listis a reactive plugin query (source: '_voltro_presence'), so the framework re-runs it and pushes deltas over the subscription transport on every change to the.reactive()presence table — no client polling. Any plugin query route that names asourcetable becomes push-driven the same way. - A member counts as online for
timeoutMsafter its last heartbeat; the sweep removes rows older than2 × timeoutMs. - The stale-row sweep is cluster-coordinated: on a multi-replica SQL deployment it runs on only ONE replica per tick (via the same claim gate the cron scheduler uses), not once per replica. On a single-process / memory / SQLite deployment it simply runs every tick locally. No configuration — it's automatic.
Permissions
store:write (heartbeat upserts the presence table).