via

Tutorial: live chat

Build the repo's internal/example/chat from an empty main.go: a room every tab shares, a composer, a send action, a clean shutdown and a live head-count. Each step is the whole file and it builds; highlighted lines are new or changed since the step before. Start in a module that has via, as in Getting started. Stop the previous run with Ctrl+C before each new one.

On this page

#1. The room

One topic every tab subscribes to, and a list that shows what arrives on it.

main.go
package main

import (
	"log"
	"net/http"

	"github.com/go-via/via"
	"github.com/go-via/via/h"
	"github.com/go-via/via/topic"
)

type Message struct{ Who, Text string }

type Room struct {
	bus *topic.Topic[Message]
}

func NewRoom() *Room {
	return &Room{bus: topic.New[Message]()}
}

type Chat struct {
	room *Room

	Log via.List[Message]
}

func (c *Chat) OnInit(ctx *via.Ctx) error {
	ctx.Listen(c.room.bus, c.onMessage)
	return nil
}

func (c *Chat) onMessage(ctx *via.Ctx, m Message) { c.Log.Append(m) }

func (c *Chat) row(m Message) h.H {
	return h.Li(h.B(h.Str(m.Who+": ")), h.Str(m.Text))
}

func (c *Chat) View() h.H {
	return h.Div(
		h.H1(h.Str("Room")),
		h.Ul(c.Log.Each(c.row)),
	)
}

func main() {
	room := NewRoom()
	r := via.Handler(Chat{room: room})
	err := http.ListenAndServe(":8080", r)
	r.Close()
	log.Fatal(err)
}
  1. Run it

    go run .
  2. Open http://localhost:8080

    A heading and nothing else yet. The browser's network panel shows one request that stays open: the tab's SSE stream. The warning about via.WithTrustedOrigin is expected on localhost; see Sessions & security before you deploy.

A topic.Topic, made with topic.New and kept for the life of the process, fans each published value out to every subscriber. via.Ctx.Listen in OnInit subscribes this tab once its stream opens and runs onMessage on the unit's own goroutine for every publish, then pushes the re-render. Chat holds a pointer to the room, so the copy each request gets shares it. Log is a via.List: server state, one per tab, rendered by via.List.Each.

#2. The view

Two inputs the browser owns: who you are and what you are typing.

main.go
package main

import (
	"log"
	"net/http"

	"github.com/go-via/via"
	"github.com/go-via/via/h"
	"github.com/go-via/via/topic"
)

type Message struct{ Who, Text string }

type Room struct {
	bus *topic.Topic[Message]
}

func NewRoom() *Room {
	return &Room{bus: topic.New[Message]()}
}

type Chat struct {
	room *Room

	Who   via.Signal[string]
	Draft via.Signal[string]
	Log   via.List[Message]
}

func (c *Chat) OnInit(ctx *via.Ctx) error {
	ctx.Listen(c.room.bus, c.onMessage)
	return nil
}

func (c *Chat) onMessage(ctx *via.Ctx, m Message) { c.Log.Append(m) }

func (c *Chat) row(m Message) h.H {
	return h.Li(h.B(h.Str(m.Who+": ")), h.Str(m.Text))
}

func (c *Chat) View() h.H {
	return h.Div(
		h.H1(h.Str("Room")),
		h.Ul(c.Log.Each(c.row)),
		h.Label(h.Str("you "), h.Input(c.Who.Bind())),
		h.Input(c.Draft.Bind(), h.Placeholder("message")),
	)
}

func main() {
	room := NewRoom()
	r := via.Handler(Chat{room: room})
	err := http.ListenAndServe(":8080", r)
	r.Close()
	log.Fatal(err)
}
  1. Run it

    go run .
  2. Open http://localhost:8080

    Type in both inputs. The network panel stays quiet: nothing reaches the server until an action runs.

A via.Signal is a value both sides hold. via.Signal.Bind hands it to the browser: the input writes it, and the next action posts it with the request, where via.Signal.Get reads it.

#3. Sending

Enter publishes the draft to every tab and empties the sender's input.

main.go
package main

import (
	"log"
	"net/http"

	"github.com/go-via/via"
	"github.com/go-via/via/h"
	"github.com/go-via/via/on"
	"github.com/go-via/via/topic"
)

type Message struct{ Who, Text string }

type Room struct {
	bus *topic.Topic[Message]
}

func NewRoom() *Room {
	return &Room{bus: topic.New[Message]()}
}

type Chat struct {
	room *Room

	Who   via.Signal[string]
	Draft via.Signal[string]
	Log   via.List[Message]
}

func (c *Chat) OnInit(ctx *via.Ctx) error {
	ctx.Listen(c.room.bus, c.onMessage)
	return nil
}

func (c *Chat) onMessage(ctx *via.Ctx, m Message) { c.Log.Append(m) }

func (c *Chat) Send(ctx *via.Ctx) {
	if c.Draft.Get() == "" {
		return
	}
	c.room.bus.Publish(Message{Who: c.Who.Get(), Text: c.Draft.Get()})
	c.Draft.Set("")
}

func (c *Chat) row(m Message) h.H {
	return h.Li(h.B(h.Str(m.Who+": ")), h.Str(m.Text))
}

func (c *Chat) View() h.H {
	return h.Div(
		h.H1(h.Str("Room")),
		h.Ul(c.Log.Each(c.row)),
		h.Form(on.Submit(c.Send),
			h.Label(h.Str("you "), h.Input(c.Who.Bind())),
			h.Input(c.Draft.Bind(), h.Placeholder("message")),
			h.Button(h.Str("send")),
		),
	)
}

func main() {
	room := NewRoom()
	r := via.Handler(Chat{room: room})
	err := http.ListenAndServe(":8080", r)
	r.Close()
	log.Fatal(err)
}
  1. Run it

    go run .
  2. Open http://localhost:8080 in two tabs

    Type a line in one and press Enter. It shows in both, and the sender's input empties.

on.Submit posts Send when the form submits, by Enter or the button, and the page does not navigate. Send publishes, and Draft.Set("") clears the composer: a signal the server writes goes back to that tab, and only the signals the action wrote are sent, so a field still being edited is left alone. The sender's own line comes back through Listen, like everyone else's.

#4. Running it

Stop the server without waiting on the tabs that are still open.

main.go
package main

import (
	"cmp"
	"context"
	"errors"
	"log"
	"net/http"
	"os"
	"os/signal"
	"time"

	"github.com/go-via/via"
	"github.com/go-via/via/h"
	"github.com/go-via/via/on"
	"github.com/go-via/via/topic"
)

type Message struct{ Who, Text string }

type Room struct {
	bus *topic.Topic[Message]
}

func NewRoom() *Room {
	return &Room{bus: topic.New[Message]()}
}

type Chat struct {
	room *Room

	Who   via.Signal[string]
	Draft via.Signal[string]
	Log   via.List[Message]
}

func (c *Chat) OnInit(ctx *via.Ctx) error {
	ctx.Listen(c.room.bus, c.onMessage)
	return nil
}

func (c *Chat) onMessage(ctx *via.Ctx, m Message) { c.Log.Append(m) }

func (c *Chat) Send(ctx *via.Ctx) {
	if c.Draft.Get() == "" {
		return
	}
	c.room.bus.Publish(Message{Who: c.Who.Get(), Text: c.Draft.Get()})
	c.Draft.Set("")
}

func (c *Chat) row(m Message) h.H {
	return h.Li(h.B(h.Str(m.Who+": ")), h.Str(m.Text))
}

func (c *Chat) View() h.H {
	return h.Div(
		h.H1(h.Str("Room")),
		h.Ul(c.Log.Each(c.row)),
		h.Form(on.Submit(c.Send),
			h.Label(h.Str("you "), h.Input(c.Who.Bind())),
			h.Input(c.Draft.Bind(), h.Placeholder("message")),
			h.Button(h.Str("send")),
		),
	)
}

func main() {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
	defer stop()

	room := NewRoom()
	r := via.Handler(Chat{room: room})
	srv := &http.Server{Addr: cmp.Or(os.Getenv("VIA_ADDR"), ":8080"), Handler: r}

	go func() {
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Fatal(err)
		}
	}()
	<-ctx.Done()

	r.Close()
	shut, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	if err := srv.Shutdown(shut); err != nil {
		log.Print(err)
	}
}
  1. Run it with a tab open

    go run .
  2. Press Ctrl-C

    The process exits at once. Delete the r.Close() line and repeat: Shutdown waits out its 5-second deadline, then logs context deadline exceeded.

Every open tab holds a streaming response. r.Close() ends each one the way a closed tab would, running its OnDispose; http.Server.Shutdown waits for in-flight responses and never ends a stream itself, so Close goes first. via.Handler returns the *Router so main can reach it. Steps 1 to 3 call Close only after ListenAndServe returns, which Ctrl-C never lets happen. VIA_ADDR overrides the listen address.

#5. Why it's live

Show how many tabs are connected, which needs a connection to count.

main.go
package main

import (
	"cmp"
	"context"
	"errors"
	"log"
	"net/http"
	"os"
	"os/signal"
	"sync"
	"time"

	"github.com/go-via/via"
	"github.com/go-via/via/h"
	"github.com/go-via/via/on"
	"github.com/go-via/via/topic"
)

type Message struct{ Who, Text string }

type Room struct {
	bus      *topic.Topic[Message]
	presence *topic.Topic[int64]
	mu       sync.Mutex
	online   int64
}

func NewRoom() *Room {
	return &Room{bus: topic.New[Message](), presence: topic.New[int64]()}
}
func (r *Room) join() { r.add(1) }
func (r *Room) part() { r.add(-1) }

func (r *Room) add(n int64) {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.online += n
	r.presence.Publish(r.online)
}

func (r *Room) count() int64 {
	r.mu.Lock()
	defer r.mu.Unlock()
	return r.online
}

type Chat struct {
	room *Room

	Who    via.Signal[string]
	Draft  via.Signal[string]
	Log    via.List[Message]
	Online via.State[int64]
}

func (c *Chat) OnInit(ctx *via.Ctx) error {
	ctx.Listen(c.room.bus, c.onMessage)
	c.Online.Track(ctx, c.room.presence, c.room.count)

	ctx.OnConnect(c.room.join)
	ctx.OnDispose(c.room.part)
	return nil
}

func (c *Chat) onMessage(ctx *via.Ctx, m Message) { c.Log.Append(m) }

func (c *Chat) Send(ctx *via.Ctx) {
	if c.Draft.Get() == "" {
		return
	}
	c.room.bus.Publish(Message{Who: c.Who.Get(), Text: c.Draft.Get()})
	c.Draft.Set("")
}

func (c *Chat) row(m Message) h.H {
	return h.Li(h.B(h.Str(m.Who+": ")), h.Str(m.Text))
}

func (c *Chat) View() h.H {
	return h.Div(
		h.H1(h.Str("Room — "), c.Online.Display(), h.Str(" online")),
		h.Ul(c.Log.Each(c.row)),
		h.Form(on.Submit(c.Send),
			h.Label(h.Str("you "), h.Input(c.Who.Bind())),
			h.Input(c.Draft.Bind(), h.Placeholder("message")),
			h.Button(h.Str("send")),
		),
	)
}

func main() {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
	defer stop()

	room := NewRoom()
	r := via.Handler(Chat{room: room})
	srv := &http.Server{Addr: cmp.Or(os.Getenv("VIA_ADDR"), ":8080"), Handler: r}

	go func() {
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Fatal(err)
		}
	}()
	<-ctx.Done()

	r.Close()
	shut, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	if err := srv.Shutdown(shut); err != nil {
		log.Print(err)
	}
}
  1. Run it

    go run .
  2. Open http://localhost:8080

    The heading reads 1 online. Open a second tab: both read 2. Close it: back to 1.

Online.Track keeps a via.State equal to a store: it seeds from count, seeds again once the stream has subscribed, then applies every value on presence. OnInit runs on the plain GET as well as on the stream, so joining there would count requests. via.Ctx.OnConnect and via.Ctx.OnDispose run once each, when the stream opens and when it closes. add holds the mutex across the change and the publish, so two tabs joining at once cannot publish their counts out of order.

LineWhy this page streams
ctx.Listen(c.room.bus, …)A subscription needs somewhere to push, so the unit is live.
c.Log.Each, c.Online.Display()Rendering a List or a State marks the unit live: a later change must reach the tab.
ctx.OnConnect, ctx.OnDisposeNeither makes a unit live; they run only on a unit something else made live.

Every live unit on a page shares the tab's one SSE stream; actions still POST. Live state covers the rest.

This file is internal/example/chat/main.go without its comments, and a test in this site's build fails if the two drift. A diff shows comment lines only.

#Try it here

#Live chat

The finished chat, running on this page. It adds what a public page needs: 20 sends a minute per client IP, names capped at 24 characters and messages at 200, a blank name posts as anon, a whitespace-only draft is dropped, and each tab keeps the newest 50 lines. The count is tabs open on this page. Nothing stores a message, so a reload starts empty.

0 online

    Try this

    • Open in a second tab ↗
    • Send a line from one tab: Wire shows the POST, then the patch arriving on this tab's stream.
    • Close the second tab: the count drops as soon as its stream ends.
    Wire

    Use the demo to see its requests and patches here.

    Source
    package demos
    
    import (
    	"strings"
    	"sync"
    
    	"github.com/go-via/via"
    	"github.com/go-via/via/h"
    	"github.com/go-via/via/on"
    	"github.com/go-via/via/topic"
    )
    
    // ChatMessage is one chat line.
    type ChatMessage struct{ Who, Text string }
    
    // ChatRoom is the tutorial's Room. Nothing here stores a message: the topic
    // hands each one to the tabs open now, so a tab that arrives later, or
    // reloads, starts empty and there is no history to reset.
    type ChatRoom struct {
    	bus      *topic.Topic[ChatMessage]
    	presence *topic.Topic[int64]
    	mu       sync.Mutex
    	online   int64
    }
    
    func (r *ChatRoom) join() { r.add(1) }
    func (r *ChatRoom) part() { r.add(-1) }
    
    // add holds mu across the change and the publish, so counts leave in the
    // order they moved and the last one a tab sees is current.
    func (r *ChatRoom) add(n int64) {
    	r.mu.Lock()
    	defer r.mu.Unlock()
    	r.online += n
    	r.presence.Publish(r.online)
    }
    
    func (r *ChatRoom) count() int64 {
    	r.mu.Lock()
    	defer r.mu.Unlock()
    	return r.online
    }
    
    var tutorialRoom = &ChatRoom{bus: topic.New[ChatMessage](), presence: topic.New[int64]()}
    
    // TutorialChat is the tutorial's Chat plus what a public page needs: a
    // per-IP send budget, length caps, and a log trimmed to the newest rows.
    type TutorialChat struct {
    	room *ChatRoom
    	// Limiter, keepRows and trim: see shared_contract.go.
    	Lim Limiter
    
    	Who    via.Signal[string]
    	Draft  via.Signal[string]
    	Log    via.List[ChatMessage]
    	Online via.State[int64]
    	Notice via.State[string]
    }
    
    // NewTutorialChat takes the limiter the page owns; a nil one is a wiring
    // mistake, so it fails here rather than on the first send.
    func NewTutorialChat(lim Limiter) TutorialChat {
    	if lim == nil {
    		panic("demos: NewTutorialChat: TutorialChat.Lim must not be nil")
    	}
    	return TutorialChat{room: tutorialRoom, Lim: lim}
    }
    
    func (c *TutorialChat) OnInit(ctx *via.Ctx) error {
    	ctx.Listen(c.room.bus, c.onMessage)
    	c.Online.Track(ctx, c.room.presence, c.room.count)
    	ctx.OnConnect(c.room.join)
    	ctx.OnDispose(c.room.part)
    	return nil
    }
    
    func (c *TutorialChat) onMessage(ctx *via.Ctx, m ChatMessage) {
    	c.Log.Append(m)
    	trim(&c.Log, keepRows)
    }
    
    func (c *TutorialChat) Send(ctx *via.Ctx) {
    	text := strings.TrimSpace(c.Draft.Get())
    	if text == "" {
    		c.Draft.Set("")
    		return
    	}
    	if !c.Lim.Allow(ctx) {
    		c.Notice.Set("slow down — 20 messages a minute")
    		return
    	}
    	c.Notice.Set("")
    	who := firstRunes(strings.TrimSpace(c.Who.Get()), 24)
    	if who == "" {
    		who = "anon"
    	}
    	c.room.bus.Publish(ChatMessage{Who: who, Text: firstRunes(text, 200)})
    	c.Draft.Set("")
    }
    
    // firstRunes is the server's cap; maxlength on the input is only the polite one.
    func firstRunes(s string, n int) string {
    	if r := []rune(s); len(r) > n {
    		return string(r[:n])
    	}
    	return s
    }
    
    func (c *TutorialChat) row(m ChatMessage) h.H {
    	return h.Li(h.B(h.Str(m.Who+": ")), h.Str(m.Text))
    }
    
    func (c *TutorialChat) View() h.H {
    	return h.Div(
    		h.P(h.Class("note"), c.Online.Display(), h.Str(" online")),
    		h.Ul(h.Class("loglist"), h.TabIndex(0), h.Role("log"), h.Aria("label", "Chat messages"), c.Log.Each(c.row)),
    		h.Form(on.Submit(c.Send),
    			h.Label(h.Str("you "), h.Input(c.Who.Bind(), h.Placeholder("anon"), h.MaxLength(24), h.AutoComplete("off"))),
    			h.Div(h.Class("row"),
    				h.Input(c.Draft.Bind(), h.Placeholder("message"), h.MaxLength(200), h.AutoComplete("off"),
    					h.Aria("label", "Message")),
    				h.Button(h.Type("submit"), h.Str("send")),
    			),
    		),
    		h.Small(h.Class("notice"), c.Notice.Display()),
    	)
    }
    

    #6. Next steps

    • Rooms by name: mount on /room/{name}, look the room up with via.Ctx.Param in OnInit, and follow its count with via.State.Track, the form for a source that depends on the request.
    • History: a tab starts empty. Keep the newest lines in the Room behind a mutex and copy them into Log in via.Ctx.OnConnect, which runs after Listen has subscribed, so a line published between the two is not lost.
    • Test it: vt.Serve runs the router in-process, vt.App.Connect opens a tab's stream and vt.Conn.Await waits for a line on it. See Testing.