via

You are reading the docs for v0.8. Read the latest (v0.10)

Live state

The server pushes to a tab when a unit on the page asks for it.

On this page

#A page is plain until a unit goes live

Any one of these makes its unit live, and the page streams:

TriggerWhereEffect
ctx.Tick(d, fn)OnInitRuns fn every d, then re-renders the unit and pushes the patch.
ctx.Listen(t, fn)OnInitRuns fn for every value published on t, then re-renders and pushes. Unsubscribes when the tab goes away.
State.Display(), List.Each(row)ViewRenders server state; a change pushes a patch on the tab's stream.
via.StateTrack(t, load)field literalSeeds from load, seeds again once the stream has subscribed, then follows t. For a source fixed at mount.
State.Track(ctx, t, load)OnInitThe 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.

#Server state

#Reveal

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.

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
package demos

import (
	"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.
type LiveReveal struct {
	Open  via.State[bool]
	Since via.State[string]
}

func (r *LiveReveal) OnInit(ctx *via.Ctx) error {
	r.Since.Set("held since " + time.Now().Format("15:04:05"))
	return nil
}

func (r *LiveReveal) Toggle(ctx *via.Ctx) { r.Open.Set(!r.Open.Get()) }

func (r *LiveReveal) View() h.H {
	label := "reveal"
	if r.Open.Get() {
		label = "hide"
	}
	return h.Div(
		h.Button(on.Click(r.Toggle), h.Str(label)),
		h.P(h.Class("metric"), h.Hidden(!r.Open.Get()), r.Since.Display()),
	)
}

#Timers

#Pulse

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
package demos

import (
	"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.
type Pulse struct {
	Beats  via.State[int]
	Uptime via.State[string]
}

func (p *Pulse) OnInit(ctx *via.Ctx) error {
	p.Uptime.Set(pulseClock(0))
	ctx.Tick(time.Second, p.beat)
	return nil
}

func (p *Pulse) beat(ctx *via.Ctx) {
	n := p.Beats.Get() + 1
	p.Beats.Set(n)
	p.Uptime.Set(pulseClock(n))
}

func pulseClock(secs int) string { return fmt.Sprintf("%02d:%02d", secs/60, secs%60) }

func (p *Pulse) View() h.H {
	return h.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")),
	)
}

#Topics

#Broadcast feed

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.

    Try this

    Wire

    Use the demo to see its requests and patches here.

    Source
    package demos
    
    import (
    	"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]()
    	feedSeq   atomic.Int64
    )
    
    // FeedPost is one broadcast line. Topics carry whatever type you give them.
    type FeedPost struct {
    	At   time.Time
    	Text string
    }
    
    // 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.
    type Feed struct {
    	// Limiter, keepRows and trim: see shared_contract.go.
    	Lim    Limiter
    	Posts  via.List[FeedPost]
    	Notice via.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.
    func NewFeed(lim Limiter) Feed {
    	if lim == nil {
    		panic("demos: NewFeed: Feed.Lim must not be nil")
    	}
    	return Feed{Lim: lim}
    }
    
    func (f *Feed) OnInit(ctx *via.Ctx) error {
    	ctx.Listen(feedTopic, f.recv)
    	return nil
    }
    
    // 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, p FeedPost) {
    	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 {
    	return h.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(p FeedPost) h.H {
    			return h.Li(h.Str(p.At.Format("15:04:05") + "  " + p.Text))
    		})),
    	)
    }
    

    #Shared state across tabs

    #Shared counter

    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.

    0

    Try this

    Wire

    Use the demo to see its requests and patches here.

    Source
    package demos
    
    import (
    	"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 (
    	sharedMu    sync.Mutex
    	sharedHits  int64
    	sharedTopic = topic.New[int64]()
    )
    
    func sharedAdd(n int64) {
    	sharedMu.Lock()
    	defer sharedMu.Unlock()
    	sharedHits += n
    	sharedTopic.Publish(sharedHits)
    }
    
    func sharedLoad() int64 {
    	sharedMu.Lock()
    	defer sharedMu.Unlock()
    	return sharedHits
    }
    
    func resetShared() {
    	sharedMu.Lock()
    	defer sharedMu.Unlock()
    	sharedHits = 0
    	sharedTopic.Publish(0)
    }
    
    // Shared follows the counter through the via.StateTrack literal NewShared
    // builds; the topic re-seeds it on every publish.
    type Shared struct {
    	// Limiter: see shared_contract.go.
    	Lim    Limiter
    	Hits   via.State[int64]
    	Notice via.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.
    func NewShared(lim Limiter) Shared {
    	if lim == nil {
    		panic("demos: NewShared: Shared.Lim must not be nil")
    	}
    	return Shared{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 {
    	return h.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()),
    		),
    	)
    }
    

    #Per-session delivery

    #Ping me

    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.
      • Open in a second tab ↗
      • 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
      package demos
      
      import (
      	"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.
      type pingEvent struct {
      	To  string
      	Msg string
      }
      
      var pingTopic = topic.New[pingEvent]()
      
      // Ping is per-user fan-out over a shared topic, keyed by the session id.
      type Ping struct {
      	// Limiter, keepRows and trim: see shared_contract.go.
      	Lim    Limiter
      	sid    string
      	gone   chan struct{}
      	Msgs   via.List[string]
      	Notice via.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.
      func NewPing(lim Limiter) Ping {
      	if lim == nil {
      		panic("demos: NewPing: Ping.Lim must not be nil")
      	}
      	return Ping{Lim: lim}
      }
      
      func (p *Ping) OnInit(ctx *via.Ctx) error {
      	p.sid = ctx.Session().ID()
      	p.gone = make(chan struct{})
      	ctx.Listen(pingTopic, p.recv)
      	ctx.OnDispose(func() { close(p.gone) })
      	return nil
      }
      
      func (p *Ping) recv(ctx *via.Ctx, e pingEvent) {
      	if e.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.gone
      	time.AfterFunc(3*time.Second, func() {
      		select {
      		case <-gone:
      			return
      		default:
      		}
      		pingTopic.Publish(pingEvent{To: to, Msg: "ping at " + time.Now().Format("15:04:05")})
      	})
      }
      
      func (p *Ping) View() h.H {
      	return h.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(m string) h.H { return h.Li(h.Str(m)) })),
      	)
      }
      

      #Presence

      #Tabs connected

      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.

      0

      tabs connected to this page, yours included

      Try this

      Wire

      Use the demo to see its requests and patches here.

      Source
      package demos
      
      import (
      	"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 (
      	presenceMu    sync.Mutex
      	presenceTabs  int64
      	presenceTopic = topic.New[int64]()
      )
      
      func presenceAdd(n int64) {
      	presenceMu.Lock()
      	defer presenceMu.Unlock()
      	presenceTabs += n
      	presenceTopic.Publish(presenceTabs)
      }
      
      func presenceLoad() int64 {
      	presenceMu.Lock()
      	defer presenceMu.Unlock()
      	return presenceTabs
      }
      
      // 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.
      type LivePresence struct {
      	Tabs via.State[int64]
      }
      
      func NewLivePresence() LivePresence {
      	return LivePresence{Tabs: via.StateTrack(presenceTopic, presenceLoad)}
      }
      
      func (p *LivePresence) OnInit(ctx *via.Ctx) error {
      	ctx.OnConnect(p.join)
      	ctx.OnDispose(p.leave)
      	return nil
      }
      
      func (p *LivePresence) join()  { presenceAdd(1) }
      func (p *LivePresence) leave() { presenceAdd(-1) }
      
      func (p *LivePresence) View() h.H {
      	return h.Div(
      		h.P(h.Class("metric"), p.Tabs.Display()),
      		h.P(h.Class("note"), h.Str("tabs connected to this page, yours included")),
      	)
      }
      

      #Connection lifecycle

      EventWhat happens
      Idle streamA 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 peerA frame write gives up after 10 seconds and the stream closes.
      Network dropThe browser retries the stream and shows a Reconnecting banner.
      Stream endsA 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.
      Action on a gone tabAnswers 410 and the tab reloads.
      Too many streamsA connect past via.WithMaxSSEConn (10,000 by default) answers 503.

      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.