via

package module
v0.9.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 29, 2026 License: MIT Imports: 36 Imported by: 1

README

via

via

Go Reference CI CodeQL

Live web UI in Go. A page is a struct, a click is a method call, and the server can push the change to every open tab. No JavaScript to write, no build step.

A counter every tab shares

via needs Go 1.27 or newer.

go mod init example.com/counter
go get github.com/go-via/via

main.go:

package main

import (
	"log"
	"net/http"
	"sync"

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

var (
	mu    sync.Mutex
	count int64
	moved = topic.New[int64]()
)

func add(n int64) {
	mu.Lock()
	defer mu.Unlock()
	count += n
	moved.Publish(count)
}

func load() int64 {
	mu.Lock()
	defer mu.Unlock()
	return count
}

type Counter struct{ N via.State[int64] }

func (c *Counter) Inc(ctx *via.Ctx) { add(1) }
func (c *Counter) Dec(ctx *via.Ctx) { add(-1) }

func (c *Counter) View() h.H {
	return h.Div(
		h.Button(on.Click(c.Dec), h.Str("-")),
		h.H1(c.N.Display()),
		h.Button(on.Click(c.Inc), h.Str("+")),
	)
}

func main() {
	r := via.Handler(Counter{N: via.StateTrack(moved, load)})
	log.Fatal(http.ListenAndServe(":8080", r))
}
go run .

Open http://localhost:8080 in two tabs and click + in one. Both update. At startup via warns that no WithTrustedOrigin is set; Security covers what to set before you deploy. Getting started builds up to this program.

Why via

  • Actions are methods. on.Click(c.Inc) takes a method value, so a misspelled action is a compile error, not a dead button.
  • Live only where it needs to be. A page is plain HTTP until something on it goes live; then its tab holds one stream. Live state
  • Nothing to build. The browser client ships inside the module, and the markup is Go.
  • Testable from go test. Package vt drives pages, actions and live streams without a browser. Testing

What it costs

Live state is held on the server. What it costs lists the tradeoffs.

Documentation

Status

Pre-1.0: the API can change between minor versions. CHANGELOG.md records each release.

Contributing

./ci.sh

It runs gofmt, vet, staticcheck and the race tests for via and the site module (internal/site), then the browser tier in vtbrowser, which needs Chromium or Chrome; --no-browser skips it. It reports, never rewrites.

CONVENTIONS.md has the code and test conventions, including the bar for a new duck-typed method.

License

MIT, see LICENSE.

Documentation

Overview

Package via is a server-driven reactive UI toolkit built on the h DSL and the Datastar client: plain request/response pages, SSE-backed live children, server-authoritative State/List/Signal, and always-on sessions.

Hard guarantees (the point of the design): no '&' at any user call site, no reflection in the public API surface (inside, reflect walks each composition type once and memoizes; a render does no walk, only the reads that key those memos: a bound handler's code pointer and a Child's type), no closures in it either, no any in element/child signatures. The library is stdlib-only. Identifier strings do appear at the edges the caller controls directly — ctx.Param[T]("id"), FormFile("avatar"), Mount("/thread/{id}") — but never as an internal wire-name a caller could desync (see the Field-Embeddable Types convention).

Lifecycle hooks

The hooks are duck-typed: opt in by having the method, so a rename or signature change silently opts a unit back out. Mount and Child catch two slips — a hook-named method with the wrong signature panics at boot, and a near-miss name carrying a hook's exact signature is logged once. A near miss is a known alias ("Reload", "Init") or a hook name one typing slip away ("OnRelaod", "Oninit"). A leftover v0.7 OnConnect(*via.Ctx) error is logged too, even next to an OnInit.

OnInit(*via.Ctx) error   // before the ctx-free View, on a page or any
                         // embedded child
OnReload(*via.Ctx) error // after one of the unit's actions, before the
                         // render that answers it
PageMeta() via.Meta      // the mounted root's own document

OnInit loads request or session data into a unit's fields and registers the timers and subscriptions that make it live — Ctx.Tick and Ctx.Listen are valid only there. It runs on every transport but a live action over an already-open stream: it ran once, at connect, so a session whose authorization changes after that keeps acting on the stream until the tab next acts and is denied, the stream closes, or the router shuts down.

OnReload is the fix for the commonest week-one defect — OnInit loads, the handler mutates the store, and the render answering the action still shows what OnInit loaded. It is a second hook rather than a second OnInit run because OnInit is an initializer, not a loader: it mints and defaults the session, registers Tick/Listen, and may Redirect or return ErrNotFound, all wrong to repeat once a handler has committed a mutation. It runs on the plain path and the live path alike, once per action, and is skipped when the handler queued a Redirect. Tick, Listen, OnConnect and Track called inside it register nothing and log a warning: liveness is the GET/connect verdict. A non-nil error is answered like OnInit's — ErrNotFound is 404, ErrForbidden 403, anything else 500.

PageMeta names the document — title, description, social cards, assets — and is read after OnInit, so the data is already loaded. Only the mounted root's is read, and it takes effect on a render that writes a document (the GET, and the full-page answer to a native form submit), never on an SSE push. See Meta.

Goroutine model

A composition instance is never shared between goroutines by via, and none of its handles take a lock. That is safe because every callback via runs against one instance runs on one goroutine:

  • A plain request (a GET page, an action POST on a page with no live unit) gets its own instance, copied from the value passed to Mount. OnInit, the action handler, OnReload and View all run on that request's net/http goroutine, and the instance is discarded with the response.
  • A live connection (one opened by Ctx.Tick, Ctx.Listen, or by rendering a State or List) keeps its instance for the life of the stream, and everything that touches it runs on that stream's single goroutine: every Tick handler, every Listen handler, every re-render and every SSE write. A Ctx.Tick timer does own a goroutine, but it only posts work onto the stream goroutine; it never calls your fn itself. An action POST against a live tab is likewise marshalled onto the stream goroutine and its HTTP handler waits for the result, so a handler never runs concurrently with a tick.

The rule that follows: Signal, State, List, Ctx and Session are Not safe for concurrent use. Call them only from a via callback. To reach a unit from a goroutine of your own — a background worker, a message consumer, an http.Handler outside via — publish to a topic.Topic and have the unit Ctx.Listen to it; the value is then delivered on the unit's own goroutine.

go func() { prices.Publish(tick) }() // fine: Topic is concurrency-safe
go func() { p.Price.Set(tick) }()    // race: Set is not

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrStoreDown means the session store could not be read for this request.
	// The request itself was fine; a dependency is not. Answer it like an
	// outage — a status page, not a retry prompt.
	ErrStoreDown = errors.New("via: session store unavailable")

	// ErrStaleTab means the tab that would have bound this action is gone: its
	// stream closed, or the id belongs to a render that no longer exists. The
	// one failure a reload actually fixes.
	ErrStaleTab = errors.New("via: stale tab")

	// ErrForbidden denies with a 403. Return it from OnInit for "you may not
	// do this"; queue a Ctx.Redirect instead for "please sign in".
	ErrForbidden = errors.New("via: forbidden")
)

The errors via reports through PageError.Err for the failures an app can reasonably answer differently from the rest of their status class. Match with errors.Is; Err is nil for every other failure, so a switch must have a default.

There is deliberately no sentinel for the 503s the SSE connect raises (at capacity, shutting down): that route is client-consumed and never renders an error page, so a sentinel for it would be unreachable API. A native form submit to a live tab can also answer 503 (too many actions parked, the stream pinned, the server shutting down); that one is error-paged, with Reason unavailable and Err nil. Nor is there a sentinel for an unknown action or a cross-mount child id — both are programming mistakes with the same answer, "something went wrong", not a distinct page.

View Source
var ErrNotFound = errors.New("via: not found")

ErrNotFound is the sentinel an OnInit returns when the data the page needs no longer exists — the request is honest, so the answer is 404, not 500. Wrap it freely; errors.Is matches.

Functions

func Child added in v0.8.0

func Child[C any](child C) h.H

Child renders a child composition — a plain struct field of the parent, seeded at the parent's literal — into its own positional container. The child gets its own OnInit (before its View, on every request-scoped render) and may be plain or live; liveness is what the child does, never a separate type. One live child anywhere makes the page stream.

type Page struct{ Chat ChatRoom; Ticker Clock }
func (p *Page) View() h.H { return h.Div(via.Child(p.Chat), via.Child(p.Ticker)) }

The child is taken by value: value state stays isolated per connection while pointer deps (a shared room) are intentionally shared. The argument must be a field selector (p.Chat) — a composite literal would re-seed on every render. An optional region is via.When, not an empty child; a generic layout (type Shell[C any] struct{ Body C }) composes for free.

The liveness rule:

A page streams iff it contains a live unit; a live child may sit under any
plain ancestor; a live unit may not contain another live unit.

So nesting is open — nest live children as deep as you like, as long as every ancestor on the path is plain. Only the last clause limits: a live child's own View may not call Child either. Violations panic at render.

trap: a Child's key is its position among its parent's Child calls, composed with the parent's ("0", "0-0"), and the container id, signal prefix and dispatch address all read it. A child's key must be identical on every render for the life of a connection, and a When wrapped around a Child shifts its later siblings' ordinals — so such a When may depend only on data fixed by OnInit or the field literal, never on time, a client signal, or shared state that changes while the page is open. A stale action URL whose key now holds a child of another type answers 410. One of the same type answers 410 when the two were reached through different calls during the render: separate via.Child expressions in a View or a When branch, or one helper called from separate places there. Otherwise it still runs on the child now at the key: one expression that renders either copy (a loop, a closure built per copy, c := p.A; if swap { c = p.B }; via.Child(c)), and any Child built outside the render, such as markup OnInit or OnReload stores in a field. A row is markup with on.Bind, not a Child.

It panics if the child has no View() method.

func Each added in v0.8.0

func Each[T any](items []T, row func(T) h.H) h.H

Each renders row(item) for every item, in order, in place — a row method returning <li> lands directly inside the surrounding <ul>. row is a named method value, never a closure at the call site.

trap: Datastar morphs the re-rendered list by position, which is right for an append-only list but wrong for reorder or delete — give each row a stable id (h.RawAttr("id", …)) so the morph matches by id instead. Per-row actions use on.Bind, carrying the row's own key with the click. Per-row signals are not supported: a Signal inside a slice element is outside the composition struct, so it has no field offset to name itself by and rendering it panics.

func Mount added in v0.4.0

func Mount[T any, PT ptrViewer[T]](r *Router, path string, root T, opts ...MountOption)

Mount registers a page composition at path, in http.ServeMux pattern syntax. Its actions post to {path}/_via/a/{child}/{act}. root is taken by value; the PT constraint makes a missing or mistyped View() a compile error, like Handler.

A page serves its own path only, never a subtree: "/" serves "/", and "/docs/" serves "/docs/" but not "/docs/intro", and ServeMux redirects a GET of "/docs" to it. "/docs" and "/docs/" share one action route, so only one of the two can be mounted. {name} wildcards are allowed; {name...} and {$} are not, since the page's action and stream routes live under its path, and {child} and {act} are reserved for the action route. Mount panics on either.

Mount renders root's View once, as mounted and without OnInit, and panics on a wiring mistake that render reaches (two actions sharing an id, an interface or ambiguous value-receiver method, h.El("script"), a Signal with no slot, a child without a View), rather than answering 500 on the first request. Any other panic in that render is ignored. The check is best-effort: a mistake behind a branch the zero value skips still panics on the first render that takes it.

func PostForm added in v0.8.0

func PostForm(handler func(*Ctx), children ...h.H) h.H

PostForm renders a native <form method="post"> whose submit runs handler on the server — the flow for sign-up/in, file uploads, and anything ending in a Redirect. Unlike on.Submit, which element-patches in place, this is a real browser navigation: handler reads fields via ctx.Request().FormValue and may Redirect. The form is always multipart, so ctx.Request().FormFile works. handler is a named method value; children are the form contents.

A native submit posts form fields, not the signal store, so in handler a Signal's Get returns its initial value, not what the input shows. To keep Signals on the form's inputs (for an on.Change that reshapes it, say), give each bound input an h.Name, read it with FormValue, and Set the Signal from it; the page the submit answers with renders those values. That page is rendered from the acted-on instance only for a plain unit: in a live unit the submit answers with a fresh page, and what handler set is gone.

It carries a hidden _viatab field synced to the $viatab signal: a native submit carries neither Datastar's signal store nor its headers, so dispatch routes on this field instead — under the same per-mount ownership check.

func When added in v0.8.0

func When(cond bool, build func() h.H) h.H

When renders build()'s result when cond is true, and does not call build otherwise — lazy, so a branch only valid when the condition holds (it reads a value present only when logged in) is never evaluated on the false path. build is a named method value, never a closure at the call site.

cond decides what is dispatchable, not just what is drawn: a handler or on.Bind arg inside a closed branch is not bound, so a POST for it answers 410 before the handler runs. So gate on session or database state — a Bind()ed Signal is whatever the client last set it to, which makes it a fine switch for a disclosure the user controls and never an authorization check.

A Bind()ed signal as cond has one limit on a plain (streamless) page: the render that decides dispatchability runs before the POST body is applied, so signals and content inside the branch round-trip normally but a handler that only exists inside it answers 410. Put it outside the branch, or go live.

Types

type Assets added in v0.8.0

type Assets struct {
	Scripts []Script
	Styles  []Style
	Preload []Preload

	// FontOrigins are origins font files may be fetched from. A remote
	// stylesheet's own origin does not cover this: the origin its @font-face
	// rules point at lives inside a file via never sees.
	FontOrigins []string
}

Assets is the CSP-governed half of the document head: everything that loads or executes. Declaring it here is what lets via derive a policy that admits it — markup smuggled through Head.Raw would be blocked by the browser with no via-side signal, which is why Raw refuses scripts and styles.

type Ctx added in v0.2.0

type Ctx struct {
	// contains filtered or unexported fields
}

Ctx is the per-request binder: it names signal slots by field offset and actions by handler identity during a render pass, hydrates signals from the request, and records per-slot initial values.

Not safe for concurrent use. Call it only from the via callback it was handed to; to reach a live unit from a goroutine of your own, publish to a topic.Topic the unit Listens to. See the package doc for the goroutine model.

func (*Ctx) Context added in v0.8.0

func (c *Ctx) Context() context.Context

Context returns the context that bounds this unit's work. On a live unit it is the stream context — cancelled when the tab disconnects or Router.Shutdown runs — which is the one a Tick or Listen handler can watch to abandon a slow call instead of finishing it against a dead socket:

func (p *Page) tick(ctx *via.Ctx) {
	rows, err := p.db.QueryContext(ctx.Context(), …)
	…
}

Outside a live unit it is the request's own context, and context.Background when there is no request in scope (a bare render). It is never nil.

Note the asymmetry with Ctx.Request: a live action's req.Context may already be done by the time the handler runs on the stream goroutine, so prefer this for anything that outlives the POST.

func (*Ctx) Listen added in v0.8.0

func (c *Ctx) Listen[T any](t *topic.Topic[T], handler func(*Ctx, T))

Listen wires a unit to a Topic: it subscribes, pumps every published value into handler on the unit's own goroutine (serialized with Tick, so unit state is mutated race-free), pushes this unit's re-render, and stops on disconnect. Like Tick it makes the unit live and is valid only inside OnInit.

Every published value reaches handler exactly once, in publish order, up to the subscription's queue limit (see topic.Publish for the one drop case, which logs; Listen owns the Sub, so hold your own Subscribe to read Sub.Dropped). Renders are not one per value — a whole backlog runs its handler calls, then one re-render and one SSE frame — so a burst costs frames proportional to how fast the client drains, not how fast the topic publishes.

The Subscribe is deferred to the SSE handler: OnInit also runs on a plain GET and every plain action, and subscribing there would hand out a Sub nothing will ever Stop. It happens before any OnConnect fn runs, so a unit that publishes on connect observes its own publish.

handler's ctx.Session() is the session the stream connected with, and a write to it from any request reaches handler: a logout in another tab is seen at once in this process, and on the next keepalive when it happened on another process (see Session). A tab that connected before its session existed sees none until an action posted with its tab id carries a session cookie: that binds the tab to the session, and handler sees it from then on (a reconnect with the cookie does the same). A session minted by another tab does not reach it by itself, because only a request naming this tab can bind it. Read the id from ctx.Session() in handler rather than caching it in OnInit, and mint the session in OnInit if every tab must be addressable by session from its first frame.

func (*Ctx) OnConnect added in v0.8.0

func (c *Ctx) OnConnect(fn func())

OnConnect registers fn to run once when this unit's stream opens — the acquire half of OnDispose (join a room, claim a slot). OnInit itself runs on every request that renders the unit, so an acquire there would fire on requests that never become a connection. Valid only inside OnInit, and it does not itself make a unit live: on a unit nothing else made live, fn never runs. Like Tick, a call after OnInit returned registers nothing and logs.

A Set inside fn reaches the client: one push follows the connect. fn runs after every Listen subscribes, so it's where to re-read a store a Listen mirrors — a publish before the subscribe else reaches no handler.

func (*Ctx) OnDispose added in v0.8.0

func (c *Ctx) OnDispose(fn func())

OnDispose registers a teardown function run when the unit's connection closes — stop subscriptions, release producers. fn is a named method value (e.g. sub.Stop). Valid only inside OnInit, and only meaningful on a unit something else has already made live: a unit that never ticks, listens, or renders State opens no connection to tear down. Like Tick, a call after OnInit returned registers nothing and logs.

func (*Ctx) Param added in v0.8.0

func (c *Ctx) Param[T any](name string) T

Param reads the mount pattern's named {name} segment, in http.ServeMux syntax (via.Mount(r, "/thread/{id}", …) → ctx.Param[int]("id")). Callable from OnInit, actions, and a live unit's Tick/Listen handlers; View is ctx-free, so load params in OnInit into a field instead.

A segment that cannot decode into T answers 404 — never a silent zero value. Naming a segment the mount pattern doesn't have panics.

In a WithErrorPage handler it returns the zero value instead, for every name: that Ctx is answering a failure that may have happened before any route matched, so there is no pattern to read a segment from — and an error page is the one render that must not be able to fail. Read Ctx.Request if the raw path matters there.

Path params survive an action; query params do not. An action POSTs to {mount}/_via/a/{child}/{act}, built from the mount pattern with its {name} segments filled in — and nothing else. The page's "?q=urgent&sort=age" is not on that URL, so the discovery render that decides what is dispatchable runs against the unfiltered page: a row that only exists under the filter binds no action in that render, and clicking it answers 410. Ctx.Request().URL.Query() is therefore readable on the GET and empty on every action.

So a page's list state — filter, page number, sort, tab — belongs in path params or in the session, never in the query string:

via.Mount(r, "/tickets/{status}/{page}", TicketList{}) // survives an action
// /tickets?status=open&page=2                    // does not

func (*Ctx) Redirect added in v0.2.3

func (c *Ctx) Redirect(path string)

Redirect navigates the browser to path after the current handler returns, from anywhere: OnInit (before the View ever renders), OnReload, a PostForm submit, and a Datastar @post action — plain, embedded, or live. path must be relative or an absolute http(s) URL on this site: the request's own host, or an origin passed to WithTrustedOrigin. Anything else (another host, a javascript: or any other scheme) is dropped and logged, never followed, so a target read from the request cannot send the user off-site. Leave the site with RedirectExternal.

The transport differs, the meaning does not: a full-page request answers 303, a @post answers the one-line navigation script Datastar executes (see redirectInit). A Redirect skips the unit's OnReload and the response render — nothing from this render is going to be shown.

func (*Ctx) RedirectExternal added in v0.9.0

func (c *Ctx) RedirectExternal(target string)

RedirectExternal is Redirect to any http(s) URL, for a hand-off that has to leave the site (an OAuth provider, a payment page). The scheme gate still applies. Passing it a target read from the request unchecked is the open redirect Redirect refuses.

func (*Ctx) Request added in v0.3.0

func (c *Ctx) Request() *http.Request

Request returns the HTTP request that triggered this handler — headers, cookies, URL, RemoteAddr, TLS. Nil when no request is in scope (a bare render).

Read-only: the body is already consumed into the request's signals, and a live action runs on the stream goroutine after the POST has acked, so the request's Context may already be done.

There is no matching writer. Ctx cannot set a header, a status or a response body — Ctx.Redirect is the only response shaping a handler gets — because a live action's "response" is an SSE frame on a connection the POST does not own. So anything that streams bytes to the browser (a file download, a CSV export, an image) is a sibling net/http handler, registered next to the via one and linked to like any other URL:

mux.Handle("/app/", viaRouter)
mux.HandleFunc("/files/{id}", serveAttachment) // http.ServeContent, not via

func (*Ctx) Session added in v0.4.0

func (c *Ctx) Session() *Session

Session resolves the browser session for this Ctx, always returning a usable handle so callers never need a nil check. The cookie is read here but issued only on the first write (see Session.ensure).

func (*Ctx) Tick added in v0.8.0

func (c *Ctx) Tick(d time.Duration, fn func(*Ctx))

Tick schedules fn to run every d for the life of the unit's connection, and is one of three things that make a unit live (Listen and rendering a State or List are the others). After each run via re-renders the unit and pushes an element-patch. A d of 0 or less runs every second instead, and the Router logs one warning. Valid only inside OnInit: ticks and subs are snapshotted there, so a later call registers nothing and logs loudly.

type Head struct {
	// Lang is the <html lang> value (e.g. "en", "pt-PT"). Empty omits it.
	Lang string

	// Raw is head markup emitted verbatim after <meta charset> — icons,
	// viewport meta, whatever the app needs. Scripts and styles are refused:
	// declare them in Assets.
	Raw string

	// Assets are the scripts, styles and preloads every page carries. They
	// widen the CSP of every mount.
	Assets Assets
}

Head describes the router-wide document shell: the <html lang>, raw head markup, and the assets every page of the app carries. Per-page slots — the title and the rest of Meta — belong to the mounted page, not here.

Raw is emitted verbatim and via never parses it, so it may not carry a <script> or <style>: those are CSP-governed and must be declared in Assets, where buildCSP can see them.

The zero Head means "inherit": an empty field is not rendered and does not widen the policy, so a partially-filled Head is always valid.

type List added in v0.8.0

type List[E any] struct{ State[[]E] }

List is server-authoritative slice state — a chat log, a feed, a todo list. It embeds State[[]E], so Get and Set remain the general door (l.Set(slices.Insert(...))) and Append/Remove/Each spell the common cases. Track is promoted too: l.Track(ctx, t, load) in OnInit keeps the list equal to a store whose topic carries the whole []E. Rows morph by position unless each carries a stable id, so give the row an h.ID(…) when the order can change. Like State, rendering one makes its unit live, and like State it reads a `via:"init=<json>"` field tag — `via:"init=[\"a\",\"b\"]"` — that a ListOf literal wins over.

Not safe for concurrent use — same rule as State.

func ListOf added in v0.8.0

func ListOf[E any](v ...E) List[E]

ListOf seeds a List with the given elements — the StateOf of lists, and the same choice against a zero List filled in OnInit.

func (*List[E]) Append added in v0.8.0

func (l *List[E]) Append(v E)

Append adds v to the end of this connection's list and schedules the push, like any Set. It re-slices in place when there is capacity, so appending in a tick loop does not reallocate every frame — but the whole list re-renders on each push, so a list that grows without bound grows the frame without bound too. Cap it, or page it.

func (*List[E]) Each added in v0.8.0

func (l *List[E]) Each(row func(E) h.H) h.H

Each renders row(item) for every element, in order — sugar over via.Each(l.Get(), row), and like State.Display it marks the unit live. Same by-position morph trap as via.Each.

func (*List[E]) Remove added in v0.8.0

func (l *List[E]) Remove(i int)

Remove deletes the element at i, shifting the rest left, and panics if i is out of range. The freed slot is zeroed, so a pointer element does not stay reachable through the backing array.

type MemorySessionStore added in v0.8.0

type MemorySessionStore struct {
	// contains filtered or unexported fields
}

MemorySessionStore is the process-local default store — a map guarded by a mutex, with CAS support (VersionedSessionStore). Build one with NewMemorySessionStore; the zero value is not usable.

func NewMemorySessionStore added in v0.8.0

func NewMemorySessionStore() *MemorySessionStore

NewMemorySessionStore returns the default process-local store: a map that is lost on restart and invisible to every other pod. Use it explicitly only to make that choice visible at the call site.

It returns the concrete type, not the SessionStore interface: the store also implements VersionedSessionStore, and a wrapper built around the interface would silently drop the CAS path and take the lossy merge instead.

func (*MemorySessionStore) Delete added in v0.8.0

func (s *MemorySessionStore) Delete(_ context.Context, id string) error

Delete implements SessionStore. Deleting an absent id is not an error.

func (*MemorySessionStore) Load added in v0.8.0

func (s *MemorySessionStore) Load(_ context.Context, id string) ([]byte, bool, error)

Load implements SessionStore. An entry past its TTL is deleted on sight and reported absent.

func (*MemorySessionStore) LoadVersion added in v0.8.0

func (s *MemorySessionStore) LoadVersion(_ context.Context, id string) ([]byte, uint64, bool, error)

LoadVersion implements VersionedSessionStore. The revision is a process-wide counter, so it changes on any write, not only this id's.

func (*MemorySessionStore) Save added in v0.8.0

func (s *MemorySessionStore) Save(_ context.Context, id string, data []byte, ttl time.Duration) error

Save implements SessionStore. A ttl of 0 or less stores the blob without an expiry.

func (*MemorySessionStore) SaveIf added in v0.8.0

func (s *MemorySessionStore) SaveIf(_ context.Context, id string, data []byte, ttl time.Duration, version uint64) (bool, error)

SaveIf implements VersionedSessionStore. An absent or expired entry reads as version 0, so a first write must pass 0.

type Meta added in v0.8.0

type Meta struct {
	// Title is the document title. Empty means no <title> element.
	Title string

	// Description is the <meta name="description"> content. Empty omits it.
	Description string

	// Canonical is the <link rel="canonical"> href. Empty omits it.
	Canonical string

	// Robots is the <meta name="robots"> content ("noindex", …). Empty omits it.
	Robots string

	// OG are Open Graph properties without their prefix — "title" becomes
	// <meta property="og:title">. Rendered in sorted key order. A non-nil map
	// defaults "title", "description" and "url" from Title, Description and
	// Canonical where it does not set them itself; a nil map renders nothing.
	OG map[string]string

	// Twitter are Twitter card names without their prefix — "card" becomes
	// <meta name="twitter:card">. Rendered in sorted key order.
	Twitter map[string]string

	// Assets are this page's own scripts, styles and preloads. Must be a
	// constant of the type: see the type doc.
	Assets Assets
}

Meta is what a mounted page declares about its own document, via the PageMeta() Meta hook on the mounted root. Everything but Assets is inert: escaped text written into the head, free to depend on data OnInit loaded.

Assets is not inert — it decides the page's Content-Security-Policy, which is built once at Mount. It must therefore be a constant of the type: via reads it at Mount from the mounted literal, again at Mount from a probe copy whose zero fields are filled in, and again on every document render. It panics if any two disagree — at Mount for a page the probe can see through, on the first render for one it cannot.

type MountOption added in v0.8.0

type MountOption func(*mountConfig)

MountOption configures a single Mount call, as opposed to Option which configures the whole Router. None exist yet; the type keeps Mount's signature stable for when one does.

type Option added in v0.2.0

type Option func(*config)

Option configures a Handler or a NewRouter. On a Router the options apply to the whole app, not to an individual Mount: there is one session cookie and one origin policy across every page mounted on it.

func WithErrorPage added in v0.8.0

func WithErrorPage(fn func(*Ctx, PageError) h.H) Option

WithErrorPage renders via's failures as HTML documents instead of plain text.

It applies only to document responses. It applies to a response the browser will render as a page: a GET of a mounted page, a route that matches no mount, and a native <form> submit (which navigates). It deliberately does not apply to a Datastar @post or to the SSE connect — those bodies are consumed by the client, which already surfaces a failure itself, so an HTML document there is dead weight that would only be logged to a console. Those keep their plain-text bodies.

The handler gets a Ctx carrying the request and the session — enough for a "you are signed in as …" header — but no composition: it runs for failures that happen before any page resolves, so there is nothing to embed and ctx.Redirect, ctx.Tick and ctx.Listen are ignored.

Styling an action failure is a client-side job, deliberately. A @post that answers 400/403/410/503 surfaces its status through Datastar, which fires a datastar-fetch error event on the element that made the request — listen for that and render the banner you want (see reconnect.go for the shape via's own reconnect notice uses). There is no server-rendered equivalent, because an action's response patches elements rather than replacing the document.

Cost: a failed request that reaches the handler resolves the session a second time — once on the way in, once for the Ctx the handler gets — so an error page is one extra store read per failure. It is off the happy path, but a 404-flooded endpoint pays it per request.

Head.Raw is emitted verbatim into the error document's <head>, the same as on a normal page. Anything unsafe there is unsafe here too, on a response the app did not choose to serve.

It may not make things worse. Returning nil, or panicking, falls back to the exact plain-text response via would have sent and logs once.

CSP: an error page may render before a mount resolves, or for a different mount than the one in hand, so it gets the router-wide floor — the policy built from Head.Assets alone. It cannot widen a mount's policy and it carries no per-page assets; declare anything it needs in the router's Head.

via.NewRouter(via.WithErrorPage(func(ctx *via.Ctx, e via.PageError) h.H {
	if e.Reason == via.ReasonNotFound {
		return h.Div(h.H1(h.Str("No such page")), h.A(h.Href("/"), h.Str("Home")))
	}
	return h.Div(h.H1(h.Str("Something broke")))
}))

func WithHead added in v0.8.0

func WithHead(head Head) Option

WithHead sets the router-wide document shell: lang, raw head markup, and the assets every page carries.

Invalid heads panic at startup rather than serving a broken document: a Lang that isn't a language tag, a Raw carrying a script or style, or an asset that fails Assets validation.

func WithLogger added in v0.4.0

func WithLogger(l *slog.Logger) Option

WithLogger routes via's own diagnostics to l. Default is slog.Default(). It panics on nil.

The h package and via/topic have no Router to reach and keep writing to slog.Default().

func WithMaxBody added in v0.8.0

func WithMaxBody(bytes int64) Option

WithMaxBody caps an action POST body in bytes, and how much of a native form submit stays in RAM before the rest spills to a temp file (default 1 MiB). Over the cap the request answers 413. It panics on a value of 0 or less.

func WithMaxSSEConn added in v0.8.0

func WithMaxSSEConn(n int) Option

WithMaxSSEConn caps how many live SSE streams this Router serves at once (default 10000); past the cap a connect is refused 503. Each stream is a goroutine plus a composition tree held for the tab's life, so the right value is the one the box has memory for — raise it with the memory to back it, and lower it when a single pod should shed load to its siblings rather than swap. The same number, up to 1024, caps the actions parked router-wide waiting for a tab's stream to connect; past it an action answers 503 at once. It panics on a value of 0 or less.

func WithMaxUpload added in v0.8.0

func WithMaxUpload(bytes int64) Option

WithMaxUpload caps a native PostForm submit's whole multipart body in bytes (default 8 MiB). Over the cap the request answers 413. It panics on a value of 0 or less.

func WithPinnedDeadline added in v0.8.0

func WithPinnedDeadline(d time.Duration) Option

WithPinnedDeadline sets how long an action POST waits for the tab's stream goroutine to pick it up before answering 503 and logging the tab as pinned (default 5s). The goroutine is serialized across every Tick, Listen and action on that tab, so one handler that blocks stalls the rest; this deadline is what turns that into a diagnosable 503 instead of a hang. It bounds only the wait to be picked up: an action that has started running is waited for, because it writes the POST's own response.

It also bounds how long an action carrying a tab id this process issued waits for that tab's stream to connect; one deadline covers both waits. A click made before the stream opens, or during a reconnect, runs once it does, in arrival order. It answers 410 if the stream has not come by 2s or d, whichever is sooner, and 503 if it came but did not pick the action up by d. When via itself ended the stream (a Rotate, a revoked session, an aborted push, Router.Close), the id answers 410 at once for d instead of waiting. Set it below the load balancer's own timeout so via answers first. It panics on a value of 0 or less.

The tab is logged as pinned too when any one handler, or one frame write to a client that stopped reading, runs past d with no action waiting, so a tab nobody clicks on is still reported. The line says which of the two it is.

func WithSecureCookies added in v0.3.0

func WithSecureCookies() Option

WithSecureCookies forces Secure on the session cookie even when via can't tell the browser is on https. By default Secure is set when the request came over TLS or a proxy says https in X-Forwarded-Proto or Forwarded (proto=), which keeps plain http://localhost dev working. Set this behind a TLS-terminating proxy that sends neither header.

func WithSessionCookieName added in v0.6.0

func WithSessionCookieName(name string) Option

WithSessionCookieName overrides the session cookie name (default "via_session"). Set a distinct name per app when two via apps share a host, or their session cookies clobber each other. It panics on a name net/http would drop from Set-Cookie: empty, or with a byte outside an HTTP token.

func WithSessionKey added in v0.8.0

func WithSessionKey(key []byte) Option

WithSessionKey sets the HMAC key signing the session cookie id. The key resolves WithSessionKey → VIA_SESSION_KEY → a random per-process key (warned on first use) — fine for dev, but those cookies survive neither a restart nor a second process, so set a stable key in production. It keeps the cookie valid only; the data behind it lives in the SessionStore, so a stable key without WithSessionStore still logs everyone out on restart.

It panics on an empty key (an unset env var read into it fails at boot instead of falling back to a random key), on one shorter than 16 bytes, and on a second WithSessionKey.

func WithSessionStore added in v0.8.0

func WithSessionStore(s SessionStore) Option

WithSessionStore points sessions at a shared, durable store instead of the default process-local map — the difference between a deploy logging every user out and a deploy nobody notices, and what makes a second pod see the first pod's sessions. Pair it with WithSessionKey: the key keeps the cookie valid, the store keeps the data behind it.

type redisSessions struct{ c *redis.Client }

func (r redisSessions) Load(ctx context.Context, id string) ([]byte, bool, error) {
	b, err := r.c.Get(ctx, "via:"+id).Bytes()
	if errors.Is(err, redis.Nil) {
		return nil, false, nil
	}
	return b, err == nil, err
}

func (r redisSessions) Save(ctx context.Context, id string, data []byte, ttl time.Duration) error {
	return r.c.Set(ctx, "via:"+id, data, ttl).Err()
}

func (r redisSessions) Delete(ctx context.Context, id string) error {
	return r.c.Del(ctx, "via:"+id).Err()
}

via.NewRouter(via.WithSessionKey(key), via.WithSessionStore(redisSessions{c}))

It panics on nil and on a second WithSessionStore.

func WithSessionStoreTimeout added in v0.8.0

func WithSessionStoreTimeout(d time.Duration) Option

WithSessionStoreTimeout caps how long one session store round-trip may take (default 5s). It panics on a value of 0 or less. Without it a hung backend pins the request goroutine for as long as it hangs — session calls deliberately survive client cancellation, so the request's own context is no escape. A read-modify-write with retries (see Session) is bounded as a whole, not per attempt.

func WithSessionTTL added in v0.2.3

func WithSessionTTL(d time.Duration) Option

WithSessionTTL sets how long a session may sit idle before it expires (default 24h). The window slides at one write per half-TTL: once less than half of it is left, an access re-saves the session and re-sends the cookie so the browser's expiry moves with it. A session can therefore expire as little as half the TTL after its last request. It panics on a value of 0 or less.

func WithTrustedOrigin added in v0.8.0

func WithTrustedOrigin(origin string) Option

WithTrustedOrigin turns on origin enforcement for the action endpoint and the SSE connect, and allowlists an origin (scheme://host[:port]). With at least one set, only same-origin requests and listed origins are admitted, and a request that carries no origin signal is refused. WITHOUT ANY, EVERY ORIGIN IS ACCEPTED, including requests that carry no origin signal, and the Router logs a warning at startup — fine for development, set this in production. Ctx.Redirect also follows absolute URLs on a listed origin.

The origin is compared the way the browser serializes it: the host is lower-cased and a default port dropped, so "https://Auth.example:443" matches an Origin of "https://auth.example", and a trailing "/" is dropped. It panics on a value that is not a bare http(s) origin: one with a path, a query, a fragment or userinfo.

func WithUnsafeEval added in v0.9.0

func WithUnsafeEval() Option

WithUnsafeEval adds 'unsafe-eval' to every mount's script-src, for a library that compiles code from strings (eval, Function, setTimeout with a string). The nonce and hashes stay. Without it the policy forbids eval, which Datastar does not need. While it is set, the Router logs a warning at startup.

type PageError added in v0.8.0

type PageError struct {
	// Status is the HTTP status via will send. An error page never changes it.
	Status int

	// Reason is the stable code to switch on.
	Reason Reason

	// Detail is the plain-text body via would have sent on its own. Safe to
	// show — it names no user data — but it is prose, not an API.
	Detail string

	// Err is the underlying error when one exists (a failed OnInit/OnReload,
	// a recovered panic), nil otherwise.
	Err error
}

PageError is everything via knows about a failure it is about to answer. Status and Reason are the contract; Detail and Err are for logging and for dev builds, and either may be empty.

type Preload added in v0.8.0

type Preload struct {
	Href string
	As   string
}

Preload is one <link rel="preload">. As is the destination — "script", "style", "font" or "image" — and names the directive the href widens.

type Reason added in v0.8.0

type Reason string

Reason is the stable code a WithErrorPage handler switches on, so an app never has to parse via's plain-text bodies. One reason per status class: the status is the contract, the body text is not.

const (
	ReasonBadRequest       Reason = "bad_request"        // 400 — unusable action arg, malformed form or body
	ReasonForbidden        Reason = "forbidden"          // 403 — untrusted origin, session mismatch, ErrForbidden
	ReasonNotFound         Reason = "not_found"          // 404 — no such route, ErrNotFound, undecodable Param
	ReasonMethodNotAllowed Reason = "method_not_allowed" // 405 — the route exists, this method does not (a GET of an action URL)
	ReasonGone             Reason = "gone"               // 410 — the render that would bind this action is gone
	ReasonTooLarge         Reason = "too_large"          // 413 — body over the cap
	ReasonInternal         Reason = "internal"           // 500 — a hook or render failed
	ReasonUnavailable      Reason = "unavailable"        // 503 — the session store could not answer, or a live tab could not take the action
)

The reasons via reports. A status via does not currently emit maps to ReasonInternal (5xx) or ReasonBadRequest (4xx), so a switch with a default is always exhaustive.

type Router added in v0.8.0

type Router struct {
	// contains filtered or unexported fields
}

Router serves several via pages, each Mounted at its own path, behind one http.Handler. Sessions are configured on the router (one cookie for the whole app) and shared across mounts; each page's actions are namespaced under its mount path, so two pages can declare the same action without colliding.

A Router owns goroutines (one per live tab, plus its tickers), so shut it down with Router.Shutdown — http.Server.Shutdown alone will not, and will block on every open SSE stream until its own deadline.

The zero Router is usable and configures itself on first use, like http.ServeMux; reach for NewRouter whenever you have options to pass.

func Handler added in v0.8.0

func Handler[T any, PT ptrViewer[T]](root T, opts ...Option) *Router

Handler builds a single-page app: a Router with root mounted at "/". root is taken by value; per request via copies it into an addressable local and operates on the pointer, so pointer-receiver methods work without '&' at the call site. The PT constraint makes a missing or mistyped View() a compile error rather than a first-request 500; Handler(Counter{}) still infers both parameters.

Like Mount, it renders root once and panics on a wiring mistake that render reaches.

It returns the *Router rather than an http.Handler so the live half is reachable: a via app owns goroutines, and Router.Shutdown (or Router.Close, with no deadline) is what drains them.

r := via.Handler(Counter{})
defer r.Close()
http.ListenAndServe(":8080", r)

func NewRouter added in v0.8.0

func NewRouter(opts ...Option) *Router

NewRouter builds an empty router. Mount pages onto it, then serve it, and Router.Shutdown it when the server is shutting down. Options (WithSessionKey, WithTrustedOrigin, …) configure the whole app.

func (*Router) Close added in v0.8.0

func (r *Router) Close()

Close is Router.Shutdown with no deadline: it returns only once every stream has ended, however long a blocked handler takes.

func (*Router) ServeHTTP added in v0.8.0

func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request)

ServeHTTP lazily runs r.init on first use, so a zero Router (var r via.Router) is ready to Mount and serve without an explicit constructor call.

func (*Router) Shutdown added in v0.9.0

func (r *Router) Shutdown(ctx context.Context) error

Shutdown shuts the router's live half down the way http.Server.Shutdown shuts its listeners: it refuses new streams (a connect answers 503), ends every open one and waits for their goroutines to return. Call it before srv.Shutdown, with the same deadline: a stream's goroutine, its Tick timers and its Listen subscriptions hang off a context of the router's own, which http.Server.Shutdown does not cancel, so without this srv.Shutdown blocks on every open tab until its deadline and then kills them mid-frame.

<-stop
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
r.Shutdown(ctx)
srv.Shutdown(ctx)

Every open stream ends the way a closed tab ends: the handler returns normally, so the response terminates cleanly rather than truncating, and each unit's OnDispose runs. An action POST in flight against a closing tab resolves either as its normal response or as 410 Gone, the same answer it gets against a tab that has just disconnected — never silently dropped.

A goroutine cannot be stopped from outside, so a stream whose Tick, Listen or action handler is blocked ends only when that handler returns. If ctx ends first, Shutdown logs such tabs with their unit type and what each is blocked on (the first 20 by name, then one line with the total), and returns ctx.Err(); the goroutines finish on their own. Handlers that watch Ctx.Context return as soon as Shutdown starts.

Shutdown does not stop serving plain pages; that is http.Server.Shutdown's job. It is safe to call more than once and from any goroutine.

type Script added in v0.8.0

type Script struct {
	Src    string
	Inline string
	Module bool
	Defer  bool
}

Script is one <script>. Exactly one of Src and Inline is set: Src is a URL (relative, or an absolute http(s) one whose origin joins script-src), Inline is source admitted by the sha256 of its exact bytes.

type Session added in v0.4.0

type Session struct {
	// contains filtered or unexported fields
}

Session is a browser session's value bag, resolved from the signed cookie, created lazily on the first write — an app that never stores anything stays cookieless. The value is stored as JSON: T must round-trip through encoding/json, because the bytes may be read back by a different process. A session holds one value; nest what you need in a struct.

SECURITY: sessions do NOT rotate their id on their own. Call Session.Rotate at every auth-state change (login, logout, privilege elevation) to invalidate an id an attacker may have planted before it — fixation defense.

Where the data lives is SessionStore's business: the default is process-local, so a restart logs everyone out and a second pod sees nothing — WithSessionStore is the fix. Expiry is an idle window (default 24h) that slides as the session is used.

Each request decodes its own copy, so a write is visible to the next request, not to a request already in flight. A live unit's Tick and Listen handlers are the exception: every write to the session, from any request, reaches every open tab's handlers — at once in this process, and on the tab's next keepalive (every 25s) when it lands on another process sharing the store.

A Rotate ends the stream of every other open tab on the session, so each reloads under the new cookie instead of keeping what it rendered under the old auth state; the tab whose live action rotated keeps its stream. On the same keepalive a tab whose session another process rotated away, deleted or let expire also ends its stream and reloads.

Writes from two in-flight requests on one session resolve last-writer-wins: each write re-reads the stored blob and overlays its own value onto it.

The merge is a read-modify-write. Against a store that implements VersionedSessionStore — the default one does — it re-merges and retries until its write applies to the revision it merged against, so a concurrent write is not clobbered; under contention that will not settle it gives up after a bounded number of attempts and drops its own write, with a log. Against a store that does not, the window between the read and the write is real: a write landing inside another's round-trip is dropped. Either way a session is a value bag, not a counter and not a lock.

A write through a handle whose id has been rotated away or has expired is also dropped, with a log: reviving that id would undo Session.Rotate.

The handle itself is not safe for concurrent use — only the stored data is merged across requests. Call it from the via callback that handed it to you; see the package doc for the goroutine model.

func (*Session) Delete added in v0.4.0

func (s *Session) Delete()

Delete clears the stored value; the session id and cookie survive, so a later Put on this same session starts from nothing rather than minting a new id. Any other handle sharing this request's session sees the value gone too — they share the same underlying data — and so do the Tick and Listen handlers of the session's open tabs (see Session). A unit that copied the value into a field in OnInit keeps it until it reloads; logging out should also Session.Rotate, which reloads those tabs.

func (*Session) Ensure added in v0.8.0

func (s *Session) Ensure() string

Ensure mints the session and issues its cookie if there is none yet, storing no value, and returns Session.ID. Use it to key per-user state before the app has anything to Put. Returns "" without minting where no cookie can reach the browser — a Tick or Listen Ctx, a WithErrorPage render — or when the store could not be read; Put in those places warns and stores anyway, Ensure has nothing worth storing.

func (*Session) Get added in v0.8.0

func (s *Session) Get[T any]() (T, bool)

Get reads the session's value as T, returning the zero value and false when nothing is stored or the stored bytes no longer decode into T.

func (*Session) ID added in v0.8.0

func (s *Session) ID() string

ID is the session's stable identity: minted once, unchanged by Session.Rotate, "" when there is no session yet. Key per-user state by it; it is not the cookie and grants nothing.

func (*Session) Put added in v0.8.0

func (s *Session) Put(v any)

Put stores v as the session's value. The first Put issues the cookie, and only where a response is open: a plain action, OnInit, or a live action. It panics if v does not marshal to JSON.

SECURITY: Put does NOT rotate the session id. Call Session.Rotate right after a Put that changes auth state, so a pre-auth id an attacker planted doesn't survive the login.

func (*Session) Rotate added in v0.4.0

func (s *Session) Rotate() string

Rotate issues a fresh session id, carries the existing data to it, and re-sets the cookie — call it after every auth-state change (login, privilege elevation) so a fixed pre-auth id is invalidated. Returns the new id, or "" when no response is open to carry the cookie; rotate from a plain action or OnInit.

Every other open tab on the session reloads: its stream ends, since what it rendered was decided under the old auth state. The tab a live action rotated from keeps its stream; an action on a plain child of a live page is not a live action, so that tab reloads too. Rotate on auth changes, not on every request, or every tab of the session reloads each time.

type SessionStore added in v0.8.0

type SessionStore interface {
	// Load returns the blob stored under id. ok is false when id is unknown or
	// expired. err is for backend failures only.
	Load(ctx context.Context, id string) (data []byte, ok bool, err error)
	// Save writes data under id and arms its expiry ttl from now, replacing any
	// blob already there.
	Save(ctx context.Context, id string, data []byte, ttl time.Duration) error
	// Delete removes id. Deleting an id that is not there is not an error.
	Delete(ctx context.Context, id string) error
}

SessionStore is where session state lives between requests. The default is a process-local map, which is why a restart logs everyone out and a second pod sees none of the first pod's sessions; point WithSessionStore at Redis, a SQL table, or anything else that outlives the process and both stop being true.

A store is a blob map and nothing more. via hands each session over already-serialized and never asks a store to understand it, so there is no session type to implement against:

  • Rotation is Save under the new id then Delete of the old one; a store implements neither.
  • Expiry is the ttl handed to Save. Honour it if the backend does it for free (Redis SETEX, a SQL expires_at column); via stamps the blob with the same deadline and refuses an expired Load regardless, so a store that ignores ttl is still correct — it just keeps dead rows around.
  • The idle window slides: via re-Saves a session once less than half its TTL is left, so a store never has to touch expiry on Load.

Every method may be called concurrently, and from a request goroutine — honour ctx. A failed Load answers every transport with 503 and ErrStoreDown rather than serving the request as anonymous. A failed Save or Delete is logged and the write dropped. Session.Rotate is the exception: if the session cannot be written under its new id, or the old id can be neither deleted nor overwritten with an expired blob, via panics and the request answers 500 rather than reporting a rotation that did not happen.

Implement VersionedSessionStore as well if the backend can do a conditional write; without it, two requests writing the same session at the same instant can still lose one.

Every open stream bound to a session calls Load once per keepalive (every 25s), so a Rotate, logout or expiry on another process reaches its tabs: open streams / 25 Loads a second. Against a database each is a query, so size the pool for them or put a cache in front. The Load runs on the tab's own goroutine, so a slow one delays that tab's next push; a failed Load keeps the stream up and asks again on the next beat.

type Signal added in v0.2.0

type Signal[T any] struct {
	// contains filtered or unexported fields
}

Signal is a client-resident value that round-trips per request and renders as a Datastar text-bound span. T must be JSON-round-trippable.

It starts at T's zero value, or at the JSON in a `via:"init=<json>"` field tag, read once when via walks the composition type at Mount; a tagged signal reaches the client at first paint whether the View renders it or not.

Not safe for concurrent use. Call it only from via callbacks (OnInit, an action handler, a Tick or Listen handler); to reach a unit from a goroutine of your own, publish to a topic.Topic the unit Listens to. See the package doc for the goroutine model.

func (*Signal[T]) Bind added in v0.4.0

func (s *Signal[T]) Bind() h.Attr

Bind returns a two-way data-bind="<slot>" attribute for an input, sharing the signal's name with Display regardless of source order. Bind is what puts the slot under client control: its value is thereafter whatever the client last set, so never gate an authorization decision on a Bind()ed signal.

func (*Signal[T]) Display added in v0.8.0

func (s *Signal[T]) Display() h.H

Display renders the signal as a Datastar text-bound span. Displaying the same signal in several places reuses its name, so they all update together. Unlike Bind it does not make the slot client-writable — an inbound value for a Display-only signal is ignored (see Signal.bind).

func (*Signal[T]) Get added in v0.8.0

func (s *Signal[T]) Get() T

Get returns the server-side value: what the last Set wrote, or what the client posted back for a Bind()ed signal on this request. A native PostForm submit posts form fields, not signals, so there Get returns the initial value; read the field with ctx.Request().FormValue instead.

func (*Signal[T]) Ref added in v0.8.0

func (s *Signal[T]) Ref() expr.Expr

Ref returns the signal's Datastar expression — "$count" for a field Count, "$chat__draft" for a Draft inside an embedded Chat (the double underscore marks the child boundary; a plain nested struct joins with a single one) — for hand-written Datastar attributes the typed API does not cover:

h.Div(h.DataShow(p.Open.Ref()), ...)

A Signal must be a plain field of the composition, through plain nested structs if you like; that is what names it before the View runs, so Ref reads the same name wherever it is called. Mount already panics on a type holding one through a pointer, slice, array or map field. Ref panics on any other signal with no name (one behind an interface, or a local variable) — the same verdict rendering it gives, moved to the call that would otherwise have produced a bare "$" and a silently dead Datastar expression.

func (*Signal[T]) Set added in v0.8.0

func (s *Signal[T]) Set(v T)

Set assigns the value and declares the slot, which is what carries the change to the client: a live action pushes a patch-signals frame, a plain action's element patch carries a data-signals restricted to the slots it wrote. Only the signals an action actually wrote are declared, so a signal the user is mid-edit is never overwritten behind them.

The View need not render the signal: Set declares it, so a Set in OnInit seeds an island the View never binds or displays. Declaring is not hydrating; only Bind makes a slot client-writable.

A nil slice or map marshals as JSON null, which a client-side forEach or index faults on. Start such a signal at an empty one with a field tag: `via:"init=[]"`.

type SignalCS added in v0.8.0

type SignalCS[T any] struct {
	// contains filtered or unexported fields
}

SignalCS is a client-only signal: the server never reads or writes it. Its wire name is "_"-prefixed, which Datastar's fetch filter drops from every POST. It starts at T's zero value, or at the JSON in a `via:"init=<json>"` field tag, read once when via walks the composition type at Mount — there is still no Set and no Get; a value the server needs to know is a Signal.

Details via.SignalCS[bool] `via:"init=true"`

A string seed is written as JSON, not as a bare string: `via:"init=\"north-1\""`.

func (*SignalCS[T]) Bind added in v0.8.0

func (s *SignalCS[T]) Bind() h.Attr

Bind returns a two-way data-bind="<slot>" attribute for an input. The value stays in the browser: unlike Signal.Bind it makes nothing server-readable.

func (*SignalCS[T]) Display added in v0.8.0

func (s *SignalCS[T]) Display() h.H

Display renders the signal as a Datastar text-bound span, showing the seed value until the client changes it.

func (*SignalCS[T]) Ref added in v0.8.0

func (s *SignalCS[T]) Ref() expr.Expr

Ref returns the signal's Datastar expression — "$_open" for a field Open. Like Signal.Ref it panics on a signal reached through a pointer, slice, array or map field, which has no field name to be named by.

type State added in v0.2.0

type State[T any] struct {
	// contains filtered or unexported fields
}

State is server-authoritative, per-connection unit state. Unlike Signal it never reaches the client as a signal: it is server-rendered as literal text and morphed into the live DOM when it changes, with no client-side hook to write it at all. Rendering one makes its unit live.

The zero State is ready to use, so a page normally leaves it zero and writes its starting value in OnInit with State.Set. Use StateOf instead when the starting value comes from outside the unit — a parent seeding an embedded child from a composite literal, where there is no OnInit of the child's to run. The two are the same job from opposite ends; pick by who owns the value.

A constant start value can also be a field tag, `via:"init=<json>"`, read once when via walks the composition type at Mount. A StateOf literal wins over the tag, and a State.Set in OnInit wins over both.

Not safe for concurrent use. Call it only from via callbacks (OnInit, an action handler, a Tick or Listen handler); to reach a unit from a goroutine of your own, publish to a topic.Topic the unit Listens to. See the package doc for the goroutine model.

State has no Ref and reaches no client expression. When markup must react to it client-side, mirror it into a Signal with a Set in the same callback and use that Signal's Ref instead.

func StateOf added in v0.8.0

func StateOf[T any](v T) State[T]

StateOf seeds a State with v, so a parent can hand an embedded child its starting value from a composite literal. A unit seeding its own state wants the zero State plus a State.Set in OnInit instead — that one can read the request, the path params and the session; a literal cannot:

type Page struct{ Chat Chat }
p := Page{Chat: Chat{Room: via.StateOf("lobby")}}

The stored value is unexported (see the Field-Embeddable Types convention), so this constructor is the only way to write one outside a callback.

func StateTrack added in v0.8.0

func StateTrack[T any](t *topic.Topic[T], load func() T) State[T]

StateTrack is the literal form of State.Track: a State that seeds from load at every init of its unit, re-seeds once the stream has subscribed, and follows t.

via.Handler(Counter{Hits: via.StateTrack(room, n.Load)})

load runs at each init, not here, so a request renders whatever the store holds then; the unit needs no OnInit of its own, and a literal on one that has an OnInit runs first so it reads the seeded value. Use it when the source is fixed at mount time; use Track in OnInit when it depends on the request.

func (*State[T]) Display added in v0.8.0

func (s *State[T]) Display() h.H

Display renders the current value as literal, escaped server text and marks its unit live, so server-held state alone earns a connection.

trap: liveness must be render-invariant. Only the GET's verdict bootstraps an SSE stream, so a Display reached through a branch closed at GET wires the page plain, and an action that later opens the branch leaves the tab demanding a connection it never opened — via fails that action loudly rather than letting every action after it 410. Render the State unconditionally (put via.When inside the row, not around the Display), or register a Tick/Listen in OnInit.

func (*State[T]) Get added in v0.8.0

func (s *State[T]) Get() T

Get returns this connection's value. It is server-authoritative: nothing the client sends can change it.

Get does not make the unit live — only State.Display and List.Each do, because only a rendered State has anything to push. A unit that holds a State and merely Gets it (say, to build a string its View writes with h.Str) is a plain unit: it opens no stream, and a Set on it reaches no browser. Render the State with Display, or register a Tick/Listen in OnInit.

func (*State[T]) Set added in v0.8.0

func (s *State[T]) Set(v T)

Set assigns the value on this unit instance. The change reaches the browser on the next push — a Tick re-render, an action response, or a stream flush.

func (*State[T]) Track added in v0.8.0

func (s *State[T]) Track(ctx *Ctx, t *topic.Topic[T], load func() T)

Track keeps s equal to a store announced on t: it seeds s from load now, seeds it again once the stream has subscribed, and then applies every publish. Valid only inside OnInit; a later call is ignored and logged. load may return one field of a larger store, in which case t carries that field's type.

type Style added in v0.8.0

type Style struct {
	Href   string
	Inline string
}

Style is one stylesheet — Href for a <link rel="stylesheet">, Inline for a <style> admitted by the sha256 of its exact bytes. Exactly one is set.

type VersionedSessionStore added in v0.8.0

type VersionedSessionStore interface {
	SessionStore
	// LoadVersion is Load, plus the revision token of the blob returned.
	LoadVersion(ctx context.Context, id string) (data []byte, version uint64, ok bool, err error)
	// SaveIf is Save, applied only while the stored revision is still version.
	// ok is false — with a nil error — when it moved on and the caller must
	// re-read and retry.
	SaveIf(ctx context.Context, id string, data []byte, ttl time.Duration, version uint64) (ok bool, err error)
}

VersionedSessionStore is the optional half of SessionStore, for a backend that can make a write conditional on the revision it read. via writes a session by re-reading the stored blob and overlaying the keys this request touched; with a plain store that read-modify-write is not atomic, so two requests writing at the same instant can still lose one of them. Implement this and via retries the merge until its write applies to the revision it merged against, which closes the window entirely.

version is opaque and store-defined; 0 means "no blob stored". Redis does this with WATCH/MULTI or a Lua script, SQL with an UPDATE ... WHERE version = $n. The default memory store implements it.

Directories

Path Synopsis
Package expr composes the small JavaScript expressions Datastar evaluates in the browser: a signal reference like "$count", a comparison, an assignment, a call.
Package expr composes the small JavaScript expressions Datastar evaluates in the browser: a signal reference like "$count", a comparison, an assignment, a call.
h
Package h is the via HTML DSL: markup as ordinary Go function calls.
Package h is the via HTML DSL: markup as ordinary Go function calls.
internal
example/chat command
Command chat is a live multi-user chat room with a presence count: messages typed in one tab appear in every connected tab, over one per-tab SSE stream.
Command chat is a live multi-user chat room with a presence count: messages typed in one tab appear in every connected tab, over one per-tab SSE stream.
example/counter command
Command counter is a server-rendered counter: a click POSTs an action that mutates server state, and via re-renders the fragment into the live DOM.
Command counter is a server-rendered counter: a click POSTs an action that mutates server state, and via re-renders the fragment into the live DOM.
example/dashboard command
Command dashboard shows live-child multiplexing: one page, one SSE stream, several independent regions that re-render and patch only themselves.
Command dashboard shows live-child multiplexing: one page, one SSE stream, several independent regions that re-render and patch only themselves.
example/feed command
Command feed is a multi-user broadcast: one server-side publisher sends to a Topic and every connected browser shows the newest messages.
Command feed is a multi-user broadcast: one server-side publisher sends to a Topic and every connected browser shows the newest messages.
example/forum command
Command forum is a multi-page app - sign-up, sign-in, a profile with avatar upload, threads and posts - exercising the router features together: via.NewRouter + Mount, OnInit page data, PostForm + Redirect, ctx.Param[int], and an OnInit session check that redirects anonymous visitors.
Command forum is a multi-page app - sign-up, sign-in, a profile with avatar upload, threads and posts - exercising the router features together: via.NewRouter + Mount, OnInit page data, PostForm + Redirect, ctx.Param[int], and an OnInit session check that redirects anonymous visitors.
example/greeting command
Command greeting is a client-side reactive form: a text input two-way bound to a Signal, displayed live next to it.
Command greeting is a client-side reactive form: a text input two-way bound to a Signal, displayed live next to it.
example/poll command
Command poll is a CRUD list with per-row actions.
Command poll is a CRUD list with per-row actions.
example/pulse command
Command pulse is a live child: OnInit registers a tick, so via opens a per-tab SSE stream and pushes a re-rendered fragment on every beat.
Command pulse is a live child: OnInit registers a tick, so via opens a per-tab SSE stream and pushes a re-rendered fragment on every beat.
example/shared command
Command shared is a counter every tab shares, tracked by a State literal on the field, so the page needs no OnInit at all.
Command shared is a counter every tab shares, tracked by a State literal on the field, so the page needs no OnInit at all.
render
Package render is the render core shared by h, on and via.
Package render is the render core shared by h, on and via.
Package on binds DOM events with the event name and its modifiers checked by the compiler: on.Event("clik", …) compiles and never fires, on.Click cannot be misspelled.
Package on binds DOM events with the event name and its modifiers checked by the compiler: on.Event("clik", …) compiles and never fires, on.Click cannot be misspelled.
Package topic is an in-process fan-out broker for live children: one Publish reaches every Subscriber.
Package topic is an in-process fan-out broker for live children: one Publish reaches every Subscriber.
Package vt (via test) is a black-box test harness for via compositions.
Package vt (via test) is a black-box test harness for via compositions.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL