realtime

package
v0.8.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 31, 2026 License: MIT Imports: 9 Imported by: 0

Documentation

Overview

Package realtime provides transport-neutral, single-process room coordination. Each active room is owned by exactly one goroutine (its room loop), which is the sole mutator of that room's membership, timers, and every call into host-supplied Logic. There is no distributed pub/sub and no locking-based shared state.

Index

Constants

View Source
const (
	DefaultReconnectWindow = 30 * time.Second
	DefaultPeerQueue       = 16
	DefaultRoomQueue       = 64
)

Variables

View Source
var (
	// ErrRoomNotFound is returned against a room that has never existed or
	// has already been evicted.
	ErrRoomNotFound = errors.New("tango realtime: room not found")
	// ErrClosed is returned by Join/Dispatch/DispatchPeer once Hub.Close
	// has begun — including for operations that were already queued, or
	// blocked trying to enqueue, at the moment it began.
	ErrClosed = errors.New("tango realtime: hub is closed")
	// ErrStalePeer is returned by DispatchPeer when peer is not exactly
	// the currently installed connection for the event's Principal in that
	// room — stale, replaced, overflow-removed, or never a member.
	ErrStalePeer = errors.New("tango realtime: peer is not the current connection")
	// ErrInvalidPeer is returned by Join for a nil Peer, or one whose
	// dynamic type is not comparable.
	ErrInvalidPeer = errors.New("tango realtime: peer must be non-nil and comparable")
	// ErrEmptyFrame is returned by Join when the Framer returns a nil or
	// empty result for the join snapshot. The snapshot is sequence 1 and
	// cannot be skipped, so the join fails. The failure does not close the
	// existing member or admit the new peer; sends Snapshot made through
	// RoomContext are not rolled back (see Framer).
	ErrEmptyFrame = errors.New("tango realtime: framed join snapshot is empty")
)

Functions

This section is empty.

Types

type Coordinator

type Coordinator interface {
	Join(context.Context, string, Principal, Peer) error
	Leave(context.Context, string, Principal, Peer) error
	DispatchPeer(context.Context, Event, Peer) error
}

Coordinator is the surface realtime/websocket (and any other adapter) depends on. It is kept intentionally narrow: its purpose is adapter testability against a fake implementation, not general swappability. It deliberately excludes Hub.Dispatch — a transport must always act through DispatchPeer, which is bound to its specific live connection, not the unrestricted membership-free path bots and other trusted host-side producers use.

type Event

type Event struct {
	Kind      EventKind
	RoomID    string
	Principal Principal
	Timer     string // populated only for EventTimer
	Payload   []byte
}

Event is one unit of room activity. Payload is opaque to realtime — it is never parsed or inspected by the framework, only by Logic.

type EventKind

type EventKind uint8

EventKind discriminates the shapes of activity a room's event loop processes. It carries no application meaning beyond routing.

const (
	EventAction EventKind = iota
	EventTimer
	EventJoin
	EventLeave
)

type Factory

type Factory func(roomID string) Logic

Factory constructs the Logic for one room ID. One Hub always uses the same Factory, so one Hub corresponds to one room type; a host running several kinds of room constructs several Hubs.

type Framer added in v0.7.0

type Framer interface {
	Frame(to Principal, seq uint64, payload []byte) ([]byte, error)
}

Framer is an optional interface a Logic implements to opt its room in to sequenced framing. Frame runs on the room goroutine, like every Logic callback, and a panic in Frame is not recovered: it crashes the process, as a panic in Handle or Snapshot does.

payload is the callee's own per-member copy: Frame may inspect or modify it without affecting other members or the caller. The returned slice becomes the queued buffer and is owned by the room from the moment Frame returns; the implementation must not retain or modify it afterwards. A nil or empty result sends nothing to that member. A non-nil error closes that member exactly as queue overflow does. For the join snapshot, a nil or empty result, or an error, fails Join instead. That failure does not itself close, rewind or skip the existing member, and never admits the new peer. Sends Snapshot made through RoomContext are not rolled back. A send is admitted to the existing member's queue only if Frame returns a non-empty result and the queue accepts it; an admitted send keeps its next sequence. An empty result is skipped and consumes no sequence. A Frame error or queue overflow closes the existing member, the rejected payload consumes no sequence, and the member stays closed. RoomContext reports no error to Snapshot.

On a connection whose Logic implements Framer, every payload admitted to the member's queue is passed through Frame with a sequence that starts at 1 for the join snapshot and increases by 1 for each later admitted payload, in queue order, with no gaps. The sequence is per member (per connection; reconnecting restarts at 1), not per room, and records admission to the outgoing queue, not delivery or acknowledgement by the peer. A payload that is not admitted (excluded or unknown member, empty encoder result, encoder error, queue overflow) uses no sequence; a socket write failure ends the member.

type Hub

type Hub struct {
	// contains filtered or unexported fields
}

Hub owns every active room for one Factory. Rooms are not pre-declared: they are created implicitly on a Principal's first Join and evicted implicitly once empty past Options.ReconnectWindow.

func NewHub

func NewHub(factory Factory, opts Options) (*Hub, error)

NewHub constructs a Hub. A zero Options field is replaced with this package's Default* constant; a negative field is rejected here, at construction, rather than surfacing later as a confusing runtime failure.

func (*Hub) Close

func (h *Hub) Close(ctx context.Context) error

Close is idempotent: the first call rejects further Join/Dispatch with ErrClosed, stops every room's pending timers, closes every live peer, and waits for every room loop to actually exit. Every call (the first and any subsequent one) blocks until that has happened or ctx is done — a canceled ctx returns promptly with ctx.Err() while shutdown continues best-effort in the background; it is not a guarantee rooms have finished.

func (*Hub) Dispatch

func (h *Hub) Dispatch(ctx context.Context, ev Event) (err error)

Dispatch delivers ev.Payload into ev.RoomID's Logic.Handle. ev.Kind must be EventAction — EventJoin/EventLeave/EventTimer are synthesized internally by the room loop itself, never accepted from a caller. Dispatch does not require the caller to have joined; this is how a bot Principal acts without ever holding a Peer.

func (*Hub) DispatchPeer

func (h *Hub) DispatchPeer(ctx context.Context, ev Event, peer Peer) (err error)

DispatchPeer delivers ev.Payload into ev.RoomID's Logic.Handle on behalf of peer, which must currently be the live connection for ev.Principal in that room. Unlike Dispatch, DispatchPeer is membership- and generation-bound: it is what realtime/websocket (and any other transport adapter) must use instead of Dispatch, so a replaced or overflow-removed connection can never keep acting as if it were still current. It returns ErrStalePeer for a peer that is stale, replaced, overflow-removed, or was never a member — checked inside the room loop, before Logic.Handle runs.

func (*Hub) Join

func (h *Hub) Join(ctx context.Context, roomID string, principal Principal, peer Peer) (err error)

Join installs peer as principal's live connection in roomID, creating the room on first join. It blocks until the room's own event loop has applied the join (installed membership and enqueued Logic.Snapshot for delivery) or ctx is done.

func (*Hub) Leave

func (h *Hub) Leave(ctx context.Context, roomID string, principal Principal, peer Peer) (err error)

Leave removes peer as principal's live connection in roomID, if it is still the current one. A Leave for a room that no longer exists, or from a peer already superseded by a later Join, is a no-op — never an error.

func (*Hub) SendUser

func (h *Hub) SendUser(ctx context.Context, userID string, payload []byte) error

SendUser delivers payload to userID's live peer in every room the Hub currently tracks where that user has one — a no-op in any room where they don't.

type Logic

type Logic interface {
	// Handle processes one Event. Deliveries made via RoomContext before
	// returning a non-nil error are not rolled back — validate before
	// emitting, not emit-then-validate.
	Handle(*RoomContext, Event) error
	// Snapshot returns the current-state payload a Principal should
	// receive immediately upon joining (or rejoining) the room.
	Snapshot(*RoomContext, Principal) ([]byte, error)
}

Logic is the host-supplied domain behavior for one room.

type Options

type Options struct {
	// ReconnectWindow is how long an empty room's in-memory state is
	// retained before implicit eviction.
	ReconnectWindow time.Duration
	// PeerQueue is the bounded outbound-delivery queue depth per peer.
	PeerQueue int
	// RoomQueue is the bounded inbound event-queue depth per room.
	RoomQueue int
	// Recorder receives bounded Hub operation metrics. Implementations must
	// be safe for concurrent use. Nil uses observability.NopRecorder{}.
	Recorder observability.Recorder
}

Options configures a Hub. A zero field is replaced with this package's corresponding Default* constant; a negative field is a construction error.

type Peer

type Peer interface {
	Send(context.Context, []byte) error
	Close() error
}

Peer is one live, replaceable transport connection for one Principal in one room. Implementations must be comparable (a pointer-receiver type is the natural choice) so the Hub can recognize a stale Leave or DispatchPeer call from an already-replaced connection by identity. Join validates this at the boundary — a nil Peer, or one whose dynamic type is not comparable, is rejected with ErrInvalidPeer rather than ever being admitted into membership, so no later comparison against it can panic.

type Principal

type Principal struct {
	UserID string
}

Principal is the stable, transport-independent actor identity a host supplies to Join/Dispatch. It is deliberately just an opaque UserID: realtime does not distinguish a human actor from a bot at this layer — an application needing that distinction owns its own convention.

type RoomContext

type RoomContext struct {
	// contains filtered or unexported fields
}

RoomContext is the handle Logic uses to affect its own room. Its methods are only ever called from that room's own event-loop goroutine, while handling one Event or computing one Snapshot — never concurrently, and never valid to retain past that call.

func (*RoomContext) Broadcast

func (r *RoomContext) Broadcast(payload []byte) error

Broadcast delivers payload to every currently live peer in the room.

func (*RoomContext) BroadcastExcept

func (r *RoomContext) BroadcastExcept(userID string, payload []byte) error

BroadcastExcept delivers payload to every live peer except userID.

func (*RoomContext) ResetTimer

func (r *RoomContext) ResetTimer(name string, duration time.Duration) error

ResetTimer (re)schedules a named timer for the room this RoomContext belongs to. Only valid while handling an Event or computing a Snapshot — see RoomContext's doc comment.

func (*RoomContext) SendUser

func (r *RoomContext) SendUser(userID string, payload []byte) error

SendUser delivers payload to userID's live peer in this room, if any. It is a no-op if userID has no live peer here.

Directories

Path Synopsis
Package websocket adapts realtime.Coordinator to an HTTP WebSocket upgrade, wrapping github.com/coder/websocket.
Package websocket adapts realtime.Coordinator to an HTTP WebSocket upgrade, wrapping github.com/coder/websocket.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL