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-drivenpresence.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.list is 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 a source table becomes push-driven the same way.
  • A member counts as online for timeoutMs after its last heartbeat; the sweep removes rows older than 2 × 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).