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; an action on a live unit answers as a frame on the stream instead of in 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 01:34:01
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(f.row)),)}func(f*Feed)row(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()),),)}
Track works on a via.List too. The store publishes a copy of the whole slice on every change, and each tab's list follows it:
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(p.closeGone)returnnil}func(p*Ping)closeGone(){close(p.gone)}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.// OnDispose closes gone when this tab's stream ends, so a ping from a tab// closed within 3s is dropped.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(p.row)),)}func(p*Ping)row(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 push sends the unit's whole HTML: via re-renders the unit that changed, with no diff, and Datastar morphs it into the page. A render identical to the last sends nothing. A change costs the size of its unit, once for every tab that shows it.
Measured on a table of 1000 rows, four cells each, about 168 bytes a row:
Layout
Sent per tab, per change
The rows in a live via.List; one row's status changes
168 KB
The rows plain, in a root that also renders a via.State counter; the counter changes
168 KB
The rows plain in the root; the counter in its own live via.Child; the counter changes
about 100 bytes
A live State anywhere in a unit makes the whole unit the patch. At 1000 open tabs, one change is 168 MB against 100 KB. When a small part changes often, keep the large part plain and move the small part into its own live Child. A live List sends every row on every change, so cap or page one that can grow.
Patches are not compressed. The Caddyfile on the deploy page leaves text/event-stream out of encode, because a compressing proxy holds patches back. Size the unit instead.
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. Past via.WithPinnedDeadline (5 s) the tab is logged as pinned, blocked writing to a client that is not reading, and its actions answer 503.
Blocked handler
A Tick, Listen or action handler running past via.WithPinnedDeadline is logged with its tab and unit type. Everything else on the tab waits behind it, keepalives included, and its actions answer 503.
Click before the stream connects
The action waits up to 2 seconds for the stream, then runs; several run in click order. Past 2 seconds it answers 410 and the tab reloads.
Session written elsewhere
A Put or Delete from any request reaches the Tick and Listen handlers of every open tab on the session: at once in this process, on the next keepalive from another process.
Session rotated
Every other open tab on the session ends its stream and reloads under the new cookie. The tab whose action rotated keeps its stream.
Session gone
On the keepalive, a stream whose session another process rotated away, deleted or let expire closes, and the tab reloads.
Network drop
The browser retries the stream under the same tab id and shows a Reconnecting banner; a click in the gap waits up to 2 seconds for it, then answers 410 and the tab reloads. If the session cookie changed since the page loaded (a Rotate, or a session minted after the load), the retry gets a fresh id and a click in the gap answers 410.
Stream ends
A clean close, from a deploy or r.Shutdown(ctx), shows a Disconnected banner, then probes the page URL with backoff from 0.5 to 8 seconds and reloads once the server answers.
Action on a gone tab
Waits up to 2 seconds, or via.WithPinnedDeadline if that is shorter, for the stream to come back, then answers 410 and the tab reloads once. When via ended the stream itself (Rotate, a session gone, an aborted push), the 410 comes at once.
Failed action
A 500 from a handler panic, a 503 or a 403 leaves the banner alone: the stream is fine. Datastar fires a datastar-fetch error event on the element that made the request.
Too many streams
A connect past via.WithMaxSSEConn (10,000 by default) answers 503. So does an action once as many actions, at most 1024, are already waiting for their streams.
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.