Realtime rooms and WebSockets
github.com/angvp/tango/realtime is a transport-neutral, single-process room-coordination primitive: chat rooms, turn-based games, presence — anywhere multiple connected actors need ordered, shared state. github.com/angvp/tango/realtime/websocket is the optional HTTP WebSocket adapter for it. Neither package is coupled to auth, auth/jwt, or accounts; a host supplies its own authentication callback.
The runnable reference is examples/realtime-chat. It demonstrates a Hub, a chat Logic, auth/jwt-based authentication over a WebSocket handshake, join/leave notices, and an idle timer.
Constructing a Hub
One Hub serves one room type — one Logic implementation, constructed per room ID by a Factory:
hub, err := realtime.NewHub(func(roomID string) realtime.Logic {
return chatLogic{}
}, realtime.Options{})
A host running more than one kind of room (chat and a game, say) constructs more than one Hub. Options{ReconnectWindow, PeerQueue, RoomQueue} all default to sane values (DefaultReconnectWindow 30s, DefaultPeerQueue 16, DefaultRoomQueue 64) when left zero; a negative value is rejected by NewHub itself, at construction, not at first use. Set Options.Recorder to record bounded join/leave/dispatch success/error metrics; both Dispatch and the WebSocket adapter's DispatchPeer path count as dispatch. It defaults to no-op and must be safe for concurrent calls.
The Logic contract
type Logic interface {
Handle(*RoomContext, Event) error
Snapshot(*RoomContext, Principal) ([]byte, error)
}
Handle processes one Event at a time — EventJoin, EventLeave, EventAction (a dispatched client/bot action), or EventTimer (a fired ResetTimer). Snapshot returns the payload a Principal should receive immediately upon joining or reconnecting; realtime delivers it before any other room event reaches that peer.
*RoomContext is only valid inside one Handle/Snapshot call — never store it. Its methods:
func (r *RoomContext) Broadcast(payload []byte) error
func (r *RoomContext) BroadcastExcept(userID string, payload []byte) error
func (r *RoomContext) SendUser(userID string, payload []byte) error
func (r *RoomContext) ResetTimer(name string, duration time.Duration) error
Payload []byte is opaque throughout realtime — never parsed or inspected by the framework, only by Logic. There is no built-in wire envelope; encoding is entirely the host's concern, the same split db.Store/admin.Widget already draw between mechanism and encoding elsewhere in tanGO.
One ordering detail worth knowing up front: EventJoin fires after membership is already installed, so a plain Broadcast on join notifies the joiner about their own join. Use BroadcastExcept there if that's not wanted. EventLeave doesn't have the same issue — membership is removed before Handle runs for it, so Broadcast already excludes the leaver.
The single-owner room loop
Each active room is owned by exactly one goroutine, which alone applies Join/Leave/Dispatch, mutates membership, runs timers, and calls into Logic. In practice this means:
Hub.Join/Leave/Dispatch/DispatchPeerblock the caller until the room loop has actually applied the operation — not until any network I/O completes, just until room state reflects it.Join/Leavealso wait forLogic.Handle's correspondingEventJoin/EventLeavecall and return its error (membership and any snapshot delivery already applied are never rolled back on that error — same no-rollback rule asDispatch, below). A staleLeave(aPeerno longer current) is an idempotent no-op that never reachesLogic.Handleat all.Dispatch/DispatchPeerreturnLogic.Handle's own error too, so a caller (including a bot's own scheduler) can observe domain rejection, not merely "enqueued OK."- Peer identity is validated once, at
Join. A nilPeer— including a typed-nil pointer hiding inside a non-nilPeerinterface value, which is comparable and!= nilas an interface but can still panic if its methods are called — or one whose dynamic type is not comparable, is rejected there withErrInvalidPeerbefore it can ever be admitted into membership. This is what makes every later identity comparison (a staleLeave,DispatchPeer's generation check) safe regardless of what concretePeertype a host passes. - Peer replacement is generation-safe. Joining with a new
Peerfor a(RoomID, Principal)that already has a live one replaces it; the oldPeer's eventualLeave— its read loop finally noticing the close — is recognized as stale and is a no-op, never removing the replacement. The same generation check gatesDispatchPeer(see below): a replaced or overflow-removedPeergetsErrStalePeer, never reachingLogic.Handle. - A slow peer gets closed, not the room loop stalled. Delivery goes through a bounded per-peer queue (
PeerQueue); if it overflows, the Hub closes that one connection. The room and every other peer are unaffected. A failedPeer.Send(a broken connection, not just a full queue) is reported back to the room loop the same way and removes exactly that connection — never a peer that has since replaced it. - Outbound payloads are copied before queueing. Delivery happens later, on a separate per-peer writer goroutine, so
Broadcast/SendUser/Dispatch/DispatchPeernever retain a caller's own byte slice — mutating it right after a call returns is safe. ResetTimeris generation-safe too. Every call bumps that timer name's generation; the room loop discards an expiry whose generation is no longer current, so a timer callback that was blocked waiting for queue capacity can never fire afterLogicalready reset it.- There is no transactional rollback. If
HandlecallsBroadcast/SendUser/etc. and then returns an error, those deliveries already happened. Validate before emitting, not emit-then-validate. This also applies toJoinitself: anEventJoinhandler error leaves the membership and snapshot deliveryJoinalready applied in place — a transport adapter that gives up on a failedJoinmust still callLeaveto clean that up, or a deadPeerstays registered indefinitely (see the WebSocket adapter section below). - Rooms are created and evicted implicitly. A room springs into existence on a
Principal's firstJoinand is evicted once it has had zero live peers for longer thanReconnectWindow— eviction cancels pending timers and discards all in-memory state. A botPrincipal(one that only ever callsDispatch, neverJoin) never counts as a live peer and never keeps a room alive.
Bots and transports: Dispatch vs DispatchPeer
A Principal can call Hub.Dispatch (as EventAction) against an already-existing room without ever calling Join — no Peer, no Snapshot, no membership entry. This is the bot/host-side path: unrestricted, membership-free, and deliberately not part of the Coordinator interface adapters depend on. Dispatch against a room that has never existed or has already been evicted returns ErrRoomNotFound.
A transport connection must instead use Hub.DispatchPeer(ctx, event, peer) — the method Coordinator actually exposes, and what realtime/websocket.View calls. It requires peer to be exactly the currently installed connection for event.Principal in that room; a stale, replaced, overflow-removed, or never-joined peer gets ErrStalePeer before Logic.Handle ever runs. This is what stops a connection that has already been superseded — by a reconnect, or by the Hub closing it after an outbound-queue overflow — from continuing to act as if it were still current.
tanGO does not add a Principal.Bot/Kind field: the Hub orders and delivers identically regardless of actor, so an app that needs to distinguish bot from human owns that convention itself.
Shutting down
Hub.Close(ctx) is idempotent: it rejects further Join/Dispatch/DispatchPeer with ErrClosed, stops every room's pending timers, closes every live peer, and waits for every room loop to actually exit (or for ctx to be canceled, in which case it returns promptly with ctx.Err() while shutdown continues best-effort in the background). An operation already queued, or blocked trying to enqueue, at the moment Close began also resolves to ErrClosed — ErrRoomNotFound stays reserved for an actual room eviction, never Hub-wide shutdown.
Hub.Close integrates with tango.ServeContext by registering it as a Lifecycle's Stop:
registry.RegisterLifecycle(tango.Lifecycle{
Name: "chat-hub",
Stop: hub.Close,
})
then calling tango.ServeContext (not tango.Serve) with a caller-cancelable context, typically built with signal.NotifyContext. See application lifecycle and examples/realtime-chat, which wires this exact pattern.
The WebSocket adapter
func View(coordinator realtime.Coordinator, authenticate Authenticate, roomID func(*tango.Context) (string, error), opts ...ViewOption) tango.View
Mount it as an ordinary route:
view := realtimews.View(hub, authenticate, func(ctx *tango.Context) (string, error) {
return ctx.Param("id"), nil
})
registry.Routes().Include("/", tango.URLs{
tango.Path(http.MethodGet, "/rooms/{id}/ws", view),
})
authenticate (func(*http.Request) (realtime.Principal, error)) runs on the plain HTTP request before any upgrade is attempted — a failure responds with an ordinary JSON 401, no partially-established socket. It may call auth/jwt.Service.Verify directly (not Service.Middleware, which targets ordinary HTTP routes), extracting the token with jwt.BearerToken or, since a browser WebSocket handshake cannot set a custom header, jwt.QueryToken. It may just as easily use a cookie session or anything else — realtime/realtime/websocket never import auth, auth/jwt, or accounts.
View calls coordinator.Join and blocks until it returns successfully before starting the read loop — a client message sent immediately after the socket opens can never race ahead of the room loop's own membership-apply, which is exactly why Hub.Join is synchronous in the first place. Every inbound message is then dispatched through coordinator.DispatchPeer, carrying the same Peer View registered at Join — never the unrestricted Hub.Dispatch — so a connection the Hub has already replaced or closed for a queue overflow can never keep dispatching as if it were still current. Inbound messages are capped at DefaultMaxMessageSize (32 KiB); an oversized frame closes the connection rather than being buffered.
Origins
By default the upgrade accepts only a request whose Origin header matches the route's own host, and answers 403 to any other, even when authenticate would have accepted it. When the page that opens the socket is served from somewhere else (a frontend dev server on another port, another subdomain in production), say so:
view := realtimews.View(hub, authenticate, roomID,
realtimews.WithOriginPatterns("app.example.com", "https://*.example.com"))
A pattern is a host, optionally with a scheme, and * may stand in for part of a segment. Never pass * on its own. realtimews.WithInsecureSkipVerify() turns the origin check off entirely; it exists for deployments with no real origin to check, such as native clients, or infrastructure that already enforces one. A browser page can open a WebSocket to any host, and the Origin check is what stops a hostile page from using a visitor's cookies against yours, so prefer patterns and treat the insecure option as a deliberate decision.
Leave is called exactly once, with a fresh bounded background context, in both places the connection can end: after the read loop returns normally, and after a failed Join. The latter exists because Join has no rollback (see above) — an EventJoin handler error can leave membership installed even though View is about to close the socket, so View always calls Leave before closing it. If Join failed before membership existed (e.g. a Snapshot error), that Leave is simply a safe, idempotent no-op.
Deliberate limits
- Single-process, in-memory only — no distributed pub/sub, no cross-instance presence; running more than one instance covers routing a room to one process.
- No persistence or replay of room state across a restart or past a room's eviction.
- No automatic game rules, bot AI, or matchmaking —
Logicis entirely host-written. - No configurable backpressure policy beyond "close the slow peer."
See limitations for the full list and the numeric defaults.