Tutorial: a chat server
You will build a chat with rooms: users sign in with a token, see who comes and goes, and post messages everyone in the room receives. Then you will test it, run it on several processes over Redis, and generate a typed client.
It builds on the quick start; reading that first helps.
1. The project
mkdir rooms && cd rooms
go mod init example.com/rooms
go get go.chanx.dev go.chanx.dev/layers/redisEverything below goes in main.go, and the tests in main_test.go.
2. Messages
Clients send send; the room receives posted, stamped with the author and the time. The client never sends its own name: the server knows who it is.
type SendPayload struct {
Text string `json:"text"`
}
// SendMessage posts to the room the connection is in.
type SendMessage struct {
chanx.BaseMessage `action:"send" description:"Post a message to the room."`
Payload SendPayload `json:"payload"`
}
type PostedPayload struct {
Author string `json:"author"`
Text string `json:"text"`
At time.Time `json:"at"`
}
// PostedMessage is what everyone in the room receives.
type PostedMessage struct {
chanx.BaseMessage `action:"posted" description:"A message posted to the room."`
Payload PostedPayload `json:"payload"`
}A second message tells the room when someone joins or leaves:
type PresencePayload struct {
User string `json:"user"`
Online bool `json:"online"`
}
// PresenceMessage tells the room someone joined or left.
type PresenceMessage struct {
chanx.BaseMessage `action:"presence"`
Payload PresencePayload `json:"payload"`
}3. The room consumer
type RoomConsumer struct {
Users map[string]string // token -> user name; your user store in real life
}
// Groups puts the connection in the room named by the URL.
func (RoomConsumer) Groups(c *chanx.Conn) []string {
return []string{"room." + c.Param("room")}
}
// HandleSend stamps the message with its author and broadcasts it to the room.
func (RoomConsumer) HandleSend(ctx *chanx.Context, m SendMessage) error {
text := strings.TrimSpace(m.Payload.Text)
if text == "" {
return nil
}
return ctx.Broadcast(PostedMessage{Payload: PostedPayload{
Author: ctx.Conn.User.(string),
Text: text,
At: time.Now().UTC(),
}})
}ctx.Conn.User is what authentication returned, set in step 5. RoomConsumer holds a map of users for now: a plain struct field, like any dependency, your database or user service in real code. One consumer value serves every connection, so it holds no per-connection state.
4. Presence
// OnConnect announces the newcomer to everyone already in the room.
func (RoomConsumer) OnConnect(ctx *chanx.Context) error {
return ctx.Broadcast(PresenceMessage{Payload: PresencePayload{User: ctx.Conn.User.(string), Online: true}}, chanx.ExcludeSelf())
}
// OnDisconnect runs while the room is still joined, so the goodbye reaches it.
func (RoomConsumer) OnDisconnect(ctx *chanx.Context) {
_ = ctx.Broadcast(PresenceMessage{Payload: PresencePayload{User: ctx.Conn.User.(string), Online: false}}, chanx.ExcludeSelf())
}
// Operations and Emits document what the handlers broadcast, for the AsyncAPI
// document and the typed clients generated from it.
func (RoomConsumer) Operations() map[string]chanx.Operation {
return map[string]chanx.Operation{
"HandleSend": {Summary: "Post a message", Replies: []chanx.Message{PostedMessage{}}},
}
}
func (RoomConsumer) Emits() []chanx.Message { return []chanx.Message{PresenceMessage{}} }OnConnectruns once the connection is in its room;ExcludeSelfkeeps the newcomer out of their own announcement.OnDisconnectruns before the connection leaves its groups, so the room still hears the goodbye.OperationsandEmitsdeclare what the handlers broadcast. The AsyncAPI document lists them, and so do the clients generated from it.
5. Authentication
// Authenticate accepts "Authorization: Bearer <token>" or "?token=<token>".
func (c RoomConsumer) Authenticate(ctx *chanx.Context, r *chanx.Request) (any, error) {
return auth.Bearer(r, func(token string) (string, error) {
if name, ok := c.Users[token]; ok {
return name, nil
}
return "", chanx.Unauthenticated("Invalid token.")
})
}
func (RoomConsumer) Credentials() []chanx.Credential { return auth.BearerCredentials() }Authenticate runs once per connection, before any handler. auth.Bearer reads the token from the Authorization header or a token query parameter (browsers cannot set headers on a WebSocket). What it returns becomes ctx.Conn.User.
On failure the client receives the authentication message, then the socket closes with 4401, so a client knows to stop reconnecting:
{"action": "authentication", "payload": {"status_code": 401, "status_text": "Invalid token.", "data": null}}Credentials documents where the token goes, as AsyncAPI security schemes.
6. Mount and run
func newEngine(set layers.Set) *chanx.Engine {
engine := chanx.New(chanx.Config{Layers: set})
engine.Handle("/ws/rooms/{room}", RoomConsumer{Users: map[string]string{"t-ann": "ann", "t-bob": "bob"}},
chanx.Name("rooms"), chanx.Description("Chat rooms"))
return engine
}
func main() {
redisURL := flag.String("redis", os.Getenv("REDIS_URL"), "Redis URL; empty: in memory")
addr := flag.String("addr", ":8000", "listen address")
flag.Parse()
var set layers.Set // nil: one in-memory layer
if *redisURL != "" {
layer, err := redis.NewPubSub(redis.PubSubOptions{Hosts: []string{*redisURL}})
if err != nil {
log.Fatal(err)
}
set = layers.Set{"default": layer}
}
engine := newEngine(set)
mux := http.NewServeMux()
chanxhttp.Mount(mux, engine)
chanxhttp.MountDocs(mux, engine, "/asyncapi", asyncapi.Info{Title: "Rooms"})
log.Printf("listening on %s", *addr)
log.Fatal(http.ListenAndServe(*addr, mux))
}go run .
npx wscat -c "ws://localhost:8000/ws/rooms/lobby?token=t-ann"
npx wscat -c "ws://localhost:8000/ws/rooms/lobby?token=t-bob"Ann sees Bob arrive. Send from Bob:
> {"action": "send", "payload": {"text": "hi ann"}}
< {"action": "posted", "payload": {"author": "bob", "text": "hi ann", "at": "2026-10-25T09:12:03.512Z"}}7. Test it
chanxtest runs connections in memory, with no network and no ports:
func TestRoom(t *testing.T) {
engine := newEngine(nil)
ann := chanxtest.Connect(t, engine, "/ws/rooms/lobby?token=t-ann")
bob := chanxtest.Connect(t, engine, "/ws/rooms/lobby", chanxtest.Header("Authorization", "Bearer t-bob"))
ann.Expect(`{"action": "presence", "payload": {"user": "bob", "online": true}}`)
bob.Send(SendMessage{Payload: SendPayload{Text: "hi ann"}})
for _, c := range []*chanxtest.Client{ann, bob} {
posted, _ := chanxtest.ReceiveUntilMessage[PostedMessage](c)
if posted.Payload.Author != "bob" || posted.Payload.Text != "hi ann" {
t.Fatalf("got %+v", posted.Payload)
}
}
bob.Close()
ann.Expect(`{"action": "presence", "payload": {"user": "bob", "online": false}}`)
}
func TestBadToken(t *testing.T) {
c := chanxtest.Connect(t, newEngine(nil), "/ws/rooms/lobby?token=nope")
if code := c.WaitClosed(); code != 4401 {
t.Fatalf("closed with %d", code)
}
}go test ./...Connect returns once the connection has joined its groups, so a broadcast sent right after reaches it. By default the client is strict: a test fails if the server sends an action nobody declared, which is how a missing Emits shows up. More in Testing.
8. More than one process
One process keeps its groups in memory. Start Redis and point every process at it:
docker run -d -p 6379:6379 redis:7
go run . -redis redis://localhost:6379
go run . -redis redis://localhost:6379 -addr :8001Connect Ann to :8000 and Bob to :8001: they chat as before. The Redis layer is the same format as Python's channels_redis, so a Django or FastAPI process on that Redis joins the same rooms. See Redis and Python.
9. A typed client
The server documents itself at /asyncapi.json. Generate a TypeScript client from it:
npm install @chanx-js/client
npx @chanx-js/codegen http://localhost:8000/asyncapi.json -o src/generatedimport { createClient } from '@chanx-js/client';
import { rooms } from './src/generated';
const client = createClient({ baseUrl: 'ws://localhost:8000' });
const room = client.connect(rooms, { params: { room: 'lobby' }, queryParams: { token: 't-ann' } });
room.on('posted', (m) => console.log(`${m.payload.author}: ${m.payload.text}`));
room.send({ action: 'send', payload: { text: 'hello' } });Sending an action the server does not accept is a compile error. See Clients for Python and other options.
Where to go next
- Topics: let one socket follow many rooms.
- Groups and events: push from an HTTP handler or a worker.
- Frameworks: the same consumer on gin, echo or Fiber.