via

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

Troubleshooting

Each section is a symptom as you would see it: a status, a body, a log line or a panic. The quoted text is what via prints.

On this page

#Every action answers 403 forbidden origin

response
403 forbidden origin

via.WithTrustedOrigin turned the origin check on and the browser's request fails it. The allowlist compares scheme, host and port, so the usual misses are:

  • A different spelling: http:// for https://, www., a non-default port.
  • A sibling subdomain: its Sec-Fetch-Site is same-site, which fails. List it.
  • A proxy that strips Sec-Fetch-* or rewrites Host to the upstream address. nginx needs proxy_set_header Host $host;.
  • An empty origin read from the environment, like the VIA_ORIGIN the Deploy page's main.go reads, so the listed value is not the site's.
main.go
func newRouter() *via.Router {
	return via.NewRouter(
		// Exactly as the browser sends it: scheme, host, port if not default.
		via.WithTrustedOrigin("https://example.com"),
		via.WithTrustedOrigin("https://app.example.com"),
	)
}

A 403 with the body session mismatch is a different check: a live action carried a different session than the one its tab connected with. The cookie expired or was cleared, or another app on the same host overwrote it; give each app its own via.WithSessionCookieName. A bare forbidden is your OnInit or OnReload returning via.ErrForbidden.

See Sessions & security: Origin checks and CSRF.

#The page never updates live

Clicks POST and answer 200, or a Tick runs on the server, and the page does not change. Check in this order:

  • No live unit. A page streams only if a unit renders a via.State or via.List, or registers via.Ctx.Tick or via.Ctx.Listen in OnInit. A page of Signals is plain: an action's response patches it, and nothing else can.
  • The proxy buffers the stream. Caddy needs flush_interval -1 on the reverse_proxy; nginx needs proxy_buffering off;. Compression is not the cause with Caddy's encode or Traefik's compress: both flush each frame.
  • An idle timeout under 25 s. via writes a keepalive every 25 seconds, fixed. A proxy or balancer that closes a silent connection sooner drops every quiet stream; 60 s is enough.
  • The State sits behind a closed branch. The GET rendered no State, so the page was served plain, and the action that opens the branch fails:
response and log
500 action failed
level=ERROR msg="via: action panic" err="via: this action made a unit live on a page that was served plain — the tab has no stream. Every action after this one would 410. Liveness must not depend on a branch an action can open: render the State unconditionally, or register a Tick/Listen in OnInit so the page is live from the first render"

Render the State outside the via.When, or register the Tick or Listen in OnInit so the unit is live from the first render.

See Deploy.

#Clicks answer 410 Gone

response
410 no such action cGAmF8Xg; this render does not bind it
410 this render does not bind that action for that argument
410 no such child
410 this tab id has no open stream for this page: the connection closed, or the id belongs to a page that is gone — reload
410 this action needs the page's tab id and the request carried no viatab signal: the page never opened its stream, or the id was filtered out
log
level=WARN msg="via: no such action; this render binds others" act=cGAmF8Xg binds="Of-LgGbQ (main.(*Todos).Delete-fm)"
level=WARN msg="via: the action is bound, but not for this arg" act=main.(*Todos).Delete-fm arg="\"2\"" bound=1

An action is dispatchable only if the render before it bound that handler, with that argument, in that child. A 410 means the render the server last ran no longer contains what the client clicked: the button sits inside a via.When that is now closed, the on.Bind argument changed since the page was drawn, or the via.Child moved. The two stream messages are a live unit's action with no open stream behind it.

  • Bind a stable key as the argument, a row's primary key, never a position, count or cursor.
  • Gate via.When on session or database state, and keep any When around a via.Child fixed for the life of the page: it shifts later siblings' keys.
  • On a plain page, a handler that exists only inside a branch opened by a bound Signal always 410s: the render that authorises runs before the posted signals apply. Move the handler outside the branch.

The browser reloads the tab on its own after a 410, with a "Page is out of date - reloading…" banner. An action for a live tab whose stream is still connecting, or reconnecting, waits up to 2 seconds (less if via.WithPinnedDeadline is shorter) for it, and answers 410 only if the stream has not come by then. When via ended the stream itself, the 410 comes at once.

See Actions: Arguments.

#A Disconnected banner, or the tab keeps reloading

via's reconnect script shows a banner at the top of the page. What each state means:

BannerCauseWhat happens next
Reconnecting…The network dropped the stream.The browser retries; the banner clears on the next patch.
Disconnected.The stream ended cleanly: a deploy, a restart, Router.Close.Probes the page URL with backoff (0.5 s to 8 s) and reloads once it answers.
Page is out of date - reloading…A request answered 410.Probes, then reloads.
Disconnected., and it staysThe stream's connect answered 403; or two automatic reloads already; or 20 probes failed.Nothing until you press Reconnect, which clears the reload count.

Every banner but Reconnecting… carries a Reconnect button that probes at once. An action that fails with the stream still up (a 500 from a handler panic, a 503, a 403) shows no banner and leaves data-via-connection alone; Datastar fires a datastar-fetch error event on the element that made the request, for a message of your own.

A banner right after every deploy is expected; state that lives only in a via.State starts over, since a reconnect is a new connection. A Disconnected banner that stays on first load is a stream connect answering 403, or a 5xx on every reload: read the Network tab for the status and find it on this page. The reload count clears 5 seconds after a load, so a server that fails straight after every reload stops at two.

See Live state: Connection lifecycle.

#OnConnect or OnDispose never runs

room.go
func (r *Room) OnInit(ctx *via.Ctx) error {
	ctx.OnConnect(r.join)
	ctx.OnDispose(r.leave)
	return nil
}

func (r *Room) join()  { online.Add(1) }
func (r *Room) leave() { online.Add(-1) }

Besides View and PageMeta, v0.8 calls two methods: OnInit and OnReload. A v0.7 OnConnect(ctx *via.Ctx) error or OnDispose(ctx *via.Ctx) method still compiles and is never called. Mount warns once about the OnConnect; the OnDispose has an action's shape, so via says nothing. Register both from OnInit with via.Ctx.OnConnect and via.Ctx.OnDispose. They run only on a unit something else made live; on a plain unit there is no connection to open or close.

log
level=WARN msg="via: main.Room.OnConnect is shaped like the v0.7 OnConnect hook, which v0.8 never calls. Move its work into OnInit: ctx.OnConnect(fn) runs fn once when the stream opens, and ctx.Tick or ctx.Listen makes the unit live."

A method named like a hook with the hook's exact signature, on a type that lacks the hook, warns once. That covers aliases (Init, Reload, Refresh, Metadata) and names one typo from a hook (OnRelaod, Oninit); OnRelay and OnInput are left alone.

log
level=WARN msg="via: main.Room.Oninit looks like a mis-named OnInit — it has the hook's exact signature but main.Room has no OnInit func(*via.Ctx) error, so nothing will ever call it. Rename it to OnInit."

See Migrating from v0.7.

#Mount panics at startup

panic
panic: via: main.Room.OnInit has signature func(*via.Ctx), not func(*via.Ctx) error — so the hook will never run
panic: via: main.Search.View has a VALUE receiver and the composition holds Signals — View must take a POINTER receiver (func (p *Search) View() h.H), or every rendered Signal binds against a discarded copy
panic: via: Mount path "/files/{path...}": a {name...} wildcard is not supported — a page's action and stream routes live under its path

Registration mistakes panic when the router is built, before it serves: a hook with the wrong signature, a View with a value receiver on a type holding Signals, a {name...} wildcard or a reserved path segment, a session key under 16 bytes, two mounts on one path. The message names the type or the pattern; fix that and restart.

via.Mount also renders the mounted value once, without running OnInit, and panics on a wiring mistake that render reaches: a func literal bound once per row, a method taken through an interface field or an ambiguous value receiver, h.El("script"), a Signal with no slot, a child without a View. Those messages start found at Mount. Any other panic in that render is ignored, since the page has no data yet. A mistake behind a branch the empty value does not take panics at the first render that takes it.

#via.Child given a pointer

panic
panic: via: found at Mount, rendering *main.Page as mounted: via: via.Child(child) requires child to have a View() method

via.Child takes the child by value and calls View on its own copy, so via.Child(&p.Chat) hands it a **Chat. Pass the field: via.Child(p.Chat).

See Compositions.

#A page answers 500 render failed

A composition mistake that only a render with data can see, or that Mount's render did not reach. The body is always render failed; the log line says which.

#A live unit inside another live unit

response and log
500 render failed
level=ERROR msg="via: render panic" err="via: via.Child: a live unit cannot sit inside another live unit — embed it directly from a plain ancestor instead"

A live unit may not contain another live unit, and a live child's View may not call via.Child at all. Keep the live units as siblings under a plain parent:

dashboard.go
type Dashboard struct {
	Chat  Chat
	Clock Clock
	Feed  Feed
}

func (d *Dashboard) View() h.H {
	return h.Main(via.Child(d.Chat), via.Child(d.Clock), via.Child(d.Feed))
}

See Compositions.

#Rows keep the wrong state after a delete or reorder

todos.go
func (l *Todos) row(t Todo) h.H {
	return h.Li(h.ID("todo-"+strconv.Itoa(t.ID)),
		h.Input(h.Type("checkbox")),
		h.Str(t.Title),
		h.Button(on.Click(on.Bind(l.Delete, t.ID)), h.Str("delete")),
	)
}

func (l *Todos) View() h.H { return h.Ul(via.Each(l.Items, l.row)) }

Datastar morphs a re-rendered list by position. Delete the second of three rows without ids and the browser keeps the second element and rewrites its text, so a ticked checkbox, typed input or open <details> now belongs to the row below. A stable id per row makes the morph match by id. The click itself is not affected: on.Bind carries the row's key.

See Actions: Arguments.

#An upload answers 413 request body too large

response
413 request body too large

Two caps: via.WithMaxBody (1 MiB) for a JSON action body, and via.WithMaxUpload (8 MiB) for the whole multipart body of a via.PostForm submit. Raise the one that matches; a proxy in front may have its own limit, such as nginx's client_max_body_size.

main.go
func newUploadRouter() *via.Router {
	return via.NewRouter(
		via.WithMaxUpload(64<<20), // a PostForm submit's whole body
		via.WithMaxBody(4<<20),    // a JSON action body
	)
}

See Sessions & security › Request limits.

#Connects or actions answer 503

response and log
503 stream capacity reached
level=WARN msg="via: refusing an SSE connect with 503: the router is at its live-stream cap. There is no per-IP share of that cap, so a single client can hold all of it; every tab is refused until one closes. The limit is WithMaxSSEConn — raise it only with the memory to back it." open=10000 cap=10000

503 stream busy
level=WARN msg="via: tab pinned — its stream goroutine is inside a Tick, Listen, OnConnect or action handler, so its keepalives have stopped too and every action on this tab answers 503 until it returns. Move blocking work off the handler."

stream capacity reached is the router at via.WithMaxSSEConn (10,000 open streams). The cap is router-wide with no per-client share; raise it only with the memory for one goroutine and one composition tree per stream.

stream busy is one tab's goroutine stuck in a handler for longer than via.WithPinnedDeadline (5 s). Tick, Listen and actions on a tab run one at a time, so a blocking call in any of them stalls the rest. Do slow work in a goroutine and publish the result back through a topic. When the line says blocked writing a frame to a client that is not reading instead, no handler is at fault: the browser stopped reading, and the stream closes once the 10 s write deadline passes. A 503 with session store unavailable is a via.SessionStore that failed to load.

See Sessions & security › Request limits.

#Error: Blocked by CSP

console
Executing inline script violates the following Content Security Policy directive 'script-src 'self''. Either the 'unsafe-inline' keyword, a hash ('sha256-…'), or a nonce ('nonce-...') is required to enable inline execution.
Uncaught Error: Blocked by CSP.

Datastar compiles each expression by adding a script that carries the page's nonce, and via's own policy admits it. The error means a second policy reached the browser, from middleware or the proxy (nginx add_header, Caddy header), and browsers enforce every policy they receive. Remove the extra script-src, or build it from via's header so it carries the same nonce; 'unsafe-eval' does not help. Asset origins belong in PageMeta().Assets, which via folds into its own policy.

#EvalError: Refused to evaluate a string as JavaScript

console
EvalError: Refused to evaluate a string as JavaScript because 'unsafe-eval' is not an allowed source of script

A script of yours, or a library it loads, calls eval, new Function or setTimeout with a string. via's policy forbids it. Use a build of the library that does not evaluate strings, or, if there is none, set via.WithUnsafeEval, which allows it on every page.

See Sessions & security › Content-Security-Policy.

#Copy to clipboard does nothing

expr.CopyToClipboard and expr.CopyTextOf call navigator.clipboard.writeText and swallow a refused write, so it fails with nothing in the console. Browsers allow it in a secure context (HTTPS, or localhost), from a user gesture, in a focused document. Over plain HTTP on a LAN address navigator.clipboard is undefined and the call throws; from on.LoadCS or a background tab the write is refused. Bind it to on.ClickCS and serve over HTTPS.

See Signals: Expressions.