Skip to content

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 ​

bash
mkdir rooms && cd rooms
go mod init example.com/rooms
go get go.chanx.dev go.chanx.dev/layers/redis

Everything 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.

go
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:

go
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 ​

go
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 ​

go
// 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{}} }
  • OnConnect runs once the connection is in its room; ExcludeSelf keeps the newcomer out of their own announcement.
  • OnDisconnect runs before the connection leaves its groups, so the room still hears the goodbye.
  • Operations and Emits declare what the handlers broadcast. The AsyncAPI document lists them, and so do the clients generated from it.

5. Authentication ​

go
// 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:

json
{"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 ​

go
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))
}
bash
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:

json
> {"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:

go
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)
	}
}
bash
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:

bash
docker run -d -p 6379:6379 redis:7
go run . -redis redis://localhost:6379
go run . -redis redis://localhost:6379 -addr :8001

Connect 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:

bash
npm install @chanx-js/client
npx @chanx-js/codegen http://localhost:8000/asyncapi.json -o src/generated
ts
import { 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 ​

MIT licensed