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.
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.
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.
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.
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.
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.
Line
Why 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.OnDispose
Neither 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.
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.
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
packagedemosimport("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.typeChatMessagestruct{Who,Textstring}// 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.typeChatRoomstruct{bus*topic.Topic[ChatMessage]presence*topic.Topic[int64]musync.Mutexonlineint64}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(nint64){r.mu.Lock()deferr.mu.Unlock()r.online+=nr.presence.Publish(r.online)}func(r*ChatRoom)count()int64{r.mu.Lock()deferr.mu.Unlock()returnr.online}vartutorialRoom=&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.typeTutorialChatstruct{room*ChatRoom// Limiter, keepRows and trim: see shared_contract.go.LimLimiterWhovia.Signal[string]Draftvia.Signal[string]Logvia.List[ChatMessage]Onlinevia.State[int64]Noticevia.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.funcNewTutorialChat(limLimiter)TutorialChat{iflim==nil{panic("demos: NewTutorialChat: TutorialChat.Lim must not be nil")}returnTutorialChat{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)returnnil}func(c*TutorialChat)onMessage(ctx*via.Ctx,mChatMessage){c.Log.Append(m)trim(&c.Log,keepRows)}func(c*TutorialChat)Send(ctx*via.Ctx){text:=strings.TrimSpace(c.Draft.Get())iftext==""{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)ifwho==""{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.funcfirstRunes(sstring,nint)string{ifr:=[]rune(s);len(r)>n{returnstring(r[:n])}returns}func(c*TutorialChat)row(mChatMessage)h.H{returnh.Li(h.B(h.Str(m.Who+": ")),h.Str(m.Text))}func(c*TutorialChat)View()h.H{returnh.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()),)}
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.