The same, for a source that depends on the request, such as a path param.
Without a trigger, a page is a request and a response. With one, via opens one SSE connection for the tab and pushes element patches down it. One stream per tab, not per unit: every live unit on the page shares one connection. A tab is that one connection, so "per tab" in these docs means per connection: a reload is a new tab.
Actions do not change transport. A click still POSTs; with a live unit on the page the answer arrives as a frame on the stream instead of as the POST's own body.
Liveness is decided once, by the render that served the page. An action cannot turn a plain page into a live one.
via.State is server-held and per tab. via.State.Display renders it and marks its unit live; via.State.Get does not. Here the State is rendered on every request and only hidden, so this unit is live from the GET, and the reveal comes back as a frame on the stream.
held since 06:54:22
Try this
Click reveal: the Wire pane shows the POST, then the patch arriving on the SSE stream.
Reload and reveal again: the time moves, because each connection runs OnInit afresh.
Wire
Use the demo to see its requests and patches here.
Source
packagedemosimport("time""github.com/go-via/via""github.com/go-via/via/h""github.com/go-via/via/on")// LiveReveal hides a State without leaving it out of the render: Display runs// on every render, hidden or not, so the unit is live from the GET and the// reveal has a stream to arrive on.typeLiveRevealstruct{Openvia.State[bool]Sincevia.State[string]}func(r*LiveReveal)OnInit(ctx*via.Ctx)error{r.Since.Set("held since "+time.Now().Format("15:04:05"))returnnil}func(r*LiveReveal)Toggle(ctx*via.Ctx){r.Open.Set(!r.Open.Get())}func(r*LiveReveal)View()h.H{label:="reveal"ifr.Open.Get(){label="hide"}returnh.Div(h.Button(on.Click(r.Toggle),h.Str(label)),h.P(h.Class("metric"),h.Hidden(!r.Open.Get()),r.Since.Display()),)}
ctx.Tick(time.Second, p.beat) is the whole subscription. Every beat runs the handler on the unit's goroutine, re-renders this unit and patches it; the header, the sidebar and the other demos are not touched. Tick is valid only inside OnInit; a later call registers nothing and logs.
00:00
beat 0 since this tab connected
Try this
Open Wire: one patch a second, carrying only this card's markup.
Wire
Use the demo to see its requests and patches here.
Source
packagedemosimport("fmt""time""github.com/go-via/via""github.com/go-via/via/h")// Pulse is the smallest thing that streams: OnInit asks for a tick, so via// opens this tab's SSE connection and pushes a re-render of this region — and// only this region — every second.typePulsestruct{Beatsvia.State[int]Uptimevia.State[string]}func(p*Pulse)OnInit(ctx*via.Ctx)error{p.Uptime.Set(pulseClock(0))ctx.Tick(time.Second,p.beat)returnnil}func(p*Pulse)beat(ctx*via.Ctx){n:=p.Beats.Get()+1p.Beats.Set(n)p.Uptime.Set(pulseClock(n))}funcpulseClock(secsint)string{returnfmt.Sprintf("%02d:%02d",secs/60,secs%60)}func(p*Pulse)View()h.H{returnh.Div(h.P(h.Class("metric"),p.Uptime.Display()),h.P(h.Class("note"),h.Str("beat "),p.Beats.Display(),h.Str(" since this tab connected")),)}
A topic.Topic is built with topic.New and held for the life of the process; the zero Topic is not usable. via.Ctx.Listen subscribes this unit, runs the handler on the unit's own goroutine for every value, and unsubscribes when the tab goes away. The list is this tab's own and keeps the newest 50; posting is capped at 20 a minute per client IP.
Use the demo to see its requests and patches here.
Source
packagedemosimport("strconv""sync/atomic""time""github.com/go-via/via""github.com/go-via/via/h""github.com/go-via/via/on""github.com/go-via/via/topic")// feedTopic is a plain package-level value. Nothing about it is via's: any// goroutine may Publish to it, and a unit's ctx.Listen is what turns a publish// into a frame on that unit's stream.var(feedTopic=topic.New[FeedPost]()feedSeqatomic.Int64)// FeedPost is one broadcast line. Topics carry whatever type you give them.typeFeedPoststruct{Attime.TimeTextstring}// Feed shows every post made while this tab is open — it mirrors no store, so// a tab that arrives late starts empty. A value every tab must agree on wants// State.Track instead.typeFeedstruct{// Limiter, keepRows and trim: see shared_contract.go.LimLimiterPostsvia.List[FeedPost]Noticevia.State[string]}// NewFeed takes the limiter the page owns; a nil one is a wiring mistake, so// it fails here rather than on the first render.funcNewFeed(limLimiter)Feed{iflim==nil{panic("demos: NewFeed: Feed.Lim must not be nil")}returnFeed{Lim:lim}}func(f*Feed)OnInit(ctx*via.Ctx)error{ctx.Listen(feedTopic,f.recv)returnnil}// recv runs on this unit's own goroutine, serialized with its ticks, so it may// touch unit state without a lock.func(f*Feed)recv(ctx*via.Ctx,pFeedPost){f.Posts.Append(p)trim(&f.Posts,keepRows)}func(f*Feed)Post(ctx*via.Ctx){if!f.Lim.Allow(ctx){f.Notice.Set("slow down — 20 posts a minute")return}f.Notice.Set("")feedTopic.Publish(FeedPost{At:time.Now(),Text:"event #"+strconv.FormatInt(feedSeq.Add(1),10)})}func(f*Feed)View()h.H{returnh.Div(h.Div(h.Class("row"),h.Button(on.Click(f.Post),h.Str("post an event")),h.Span(h.Class("note"),f.Notice.Display()),),// A scroll container is only keyboard-scrollable while it can hold// focus, and nothing inside this one is focusable.h.Ul(h.Class("loglist"),h.TabIndex(0),h.Role("log"),h.Aria("label","Posted events"),f.Posts.Each(func(pFeedPost)h.H{returnh.Li(h.Str(p.At.Format("15:04:05")+" "+p.Text))})),)}
State is per tab, so a number every visitor must agree on lives in a store you own, here an int64 behind a mutex held across the change and the publish, so tabs never settle on an older value. The topic only announces that the store moved. via.StateTrack keeps this tab's State equal to it: seed at init, seed again once the stream has subscribed, then follow every publish.
The literal form is for a source fixed at mount, as here, and needs no OnInit. via.State.Track in OnInit is for a source that depends on the request, such as the room named by a path param. The counter resets to zero every 15 minutes, through the topic, so open tabs redraw.
Use the demo to see its requests and patches here.
Source
packagedemosimport("sync""github.com/go-via/via""github.com/go-via/via/h""github.com/go-via/via/on""github.com/go-via/via/topic")// sharedHits is the store and the topic announces that it moved. sharedMu// covers each change and its publish together, so publishes leave in the order// the count moved and the last one a tab sees is the current count.var(sharedMusync.MutexsharedHitsint64sharedTopic=topic.New[int64]())funcsharedAdd(nint64){sharedMu.Lock()defersharedMu.Unlock()sharedHits+=nsharedTopic.Publish(sharedHits)}funcsharedLoad()int64{sharedMu.Lock()defersharedMu.Unlock()returnsharedHits}funcresetShared(){sharedMu.Lock()defersharedMu.Unlock()sharedHits=0sharedTopic.Publish(0)}// Shared follows the counter through the via.StateTrack literal NewShared// builds; the topic re-seeds it on every publish.typeSharedstruct{// Limiter: see shared_contract.go.LimLimiterHitsvia.State[int64]Noticevia.State[string]}// NewShared takes the limiter the page owns, and seeds Hits from the store the// topic announces. A nil limiter is a wiring mistake, so it fails here rather// than on the first render.funcNewShared(limLimiter)Shared{iflim==nil{panic("demos: NewShared: Shared.Lim must not be nil")}returnShared{Lim:lim,Hits:via.StateTrack(sharedTopic,sharedLoad)}}func(s*Shared)Inc(ctx*via.Ctx){if!s.Lim.Allow(ctx){s.Notice.Set("slow down — 20 clicks a minute")return}s.Notice.Set("")sharedAdd(1)}func(s*Shared)View()h.H{returnh.Div(h.P(h.Class("metric"),s.Hits.Display()),h.Div(h.Class("row"),h.Button(on.Click(s.Inc),h.Str("+1 for everyone")),h.Span(h.Class("note"),s.Notice.Display()),),)}
One topic carries every visitor's pings. Each event names the session it is for, and the Listen handler drops the rest. ctx.Session().ID() is the routing key: it is stable, it is not the cookie, and it grants nothing. The session is the unit of fan-out, not the tab.
Try this
Ping once here. The first ping mints the session. A tab opened before then joins once it pings or reloads; pings from other tabs cannot reach it until it sends the cookie.
Ping from the second tab: both tabs of this browser light up.
Open this page in a different browser and ping: only the browser that pinged lights up.
Wire
Use the demo to see its requests and patches here.
Source
packagedemosimport("time""github.com/go-via/via""github.com/go-via/via/h""github.com/go-via/via/on""github.com/go-via/via/topic")// pingEvent is addressed: one topic carries every visitor's pings and each// unit drops the ones that are not its own. A topic per session would be a// map of topics nobody ever cleans up.typepingEventstruct{TostringMsgstring}varpingTopic=topic.New[pingEvent]()// Ping is per-user fan-out over a shared topic, keyed by the session id.typePingstruct{// Limiter, keepRows and trim: see shared_contract.go.LimLimitersidstringgonechanstruct{}Msgsvia.List[string]Noticevia.State[string]}// NewPing takes the limiter the page owns; a nil one is a wiring mistake, so// it fails here rather than on the first render.funcNewPing(limLimiter)Ping{iflim==nil{panic("demos: NewPing: Ping.Lim must not be nil")}returnPing{Lim:lim}}func(p*Ping)OnInit(ctx*via.Ctx)error{p.sid=ctx.Session().ID()p.gone=make(chanstruct{})ctx.Listen(pingTopic,p.recv)ctx.OnDispose(func(){close(p.gone)})returnnil}func(p*Ping)recv(ctx*via.Ctx,epingEvent){ife.To!=p.sid{return}p.Msgs.Append(e.Msg)trim(&p.Msgs,keepRows)}func(p*Ping)Send(ctx*via.Ctx){if!p.Lim.Allow(ctx){p.Notice.Set("slow down — 20 pings a minute")return}// Minting the session here, on a rate-limited action, keeps a crawler's// GETs out of the session store.p.sid=ctx.Session().Ensure()p.Notice.Set("scheduled")// A goroutine may not touch unit state; publishing is how it reaches one.// gone is closed by OnDispose on the live unit; a request-scoped instance// never closes it, and its ping is delivered to nobody.to,gone:=p.sid,p.gonetime.AfterFunc(3*time.Second,func(){select{case<-gone:returndefault:}pingTopic.Publish(pingEvent{To:to,Msg:"ping at "+time.Now().Format("15:04:05")})})}func(p*Ping)View()h.H{returnh.Div(h.Div(h.Class("row"),h.Button(on.Click(p.Send),h.Str("ping me in 3s")),h.Span(h.Class("note"),p.Notice.Display()),),h.Ul(h.Class("loglist"),h.TabIndex(0),h.Role("log"),h.Aria("label","Pings received"),p.Msgs.Each(func(mstring)h.H{returnh.Li(h.Str(m))})),)}
via.Ctx.OnConnect runs once when a unit's stream opens and via.Ctx.OnDispose when it closes, so the pair brackets a real connection: a GET that never connects counts for nothing. They move a count and publish it under one lock; via.StateTrack does the rest. Neither hook makes a unit live on its own; the tracked State does.
Close that tab: the count drops as soon as its stream ends.
Wire
Use the demo to see its requests and patches here.
Source
packagedemosimport("sync""github.com/go-via/via""github.com/go-via/via/h""github.com/go-via/via/topic")// presenceTabs is the head count and the topic tells every tab it moved.// presenceMu covers each change and its publish together, as in shared.go, so// the last count a tab sees is the current one.var(presenceMusync.MutexpresenceTabsint64presenceTopic=topic.New[int64]())funcpresenceAdd(nint64){presenceMu.Lock()deferpresenceMu.Unlock()presenceTabs+=npresenceTopic.Publish(presenceTabs)}funcpresenceLoad()int64{presenceMu.Lock()deferpresenceMu.Unlock()returnpresenceTabs}// LivePresence counts the tabs holding a stream open on this page. OnConnect// and OnDispose run only on a real connection, so a crawler's GET, which// renders the page but never connects, counts for nothing.typeLivePresencestruct{Tabsvia.State[int64]}funcNewLivePresence()LivePresence{returnLivePresence{Tabs:via.StateTrack(presenceTopic,presenceLoad)}}func(p*LivePresence)OnInit(ctx*via.Ctx)error{ctx.OnConnect(p.join)ctx.OnDispose(p.leave)returnnil}func(p*LivePresence)join(){presenceAdd(1)}func(p*LivePresence)leave(){presenceAdd(-1)}func(p*LivePresence)View()h.H{returnh.Div(h.P(h.Class("metric"),p.Tabs.Display()),h.P(h.Class("note"),h.Str("tabs connected to this page, yours included")),)}
A keepalive frame every 25 seconds, fixed. A failed write is how the server notices a peer that vanished without closing, and the stream then runs its disposers.
Stalled peer
A frame write gives up after 10 seconds and the stream closes.
Network drop
The browser retries the stream and shows a Reconnecting banner.
Stream ends
A clean close, from a deploy or r.Close(), shows a Disconnected banner, then probes the page URL with backoff from 0.5 to 8 seconds and reloads once the server answers.
A reconnect is a new connection: OnInit runs again and State starts from what it sets. After a server restart nothing of the old process remains, so a tab gets back whatever your stores and the session hold. Automatic reloads are capped per episode, and probing stops after about two and a half minutes; either way the banner then offers a Reconnect button.
The banner's state is also on <html data-via-connection> as online, connecting or offline, for styling your own connection UI.