32 Commits

Author SHA1 Message Date
Ryan Hamamura
539a2ad504 feat: three-tier context lifecycle (grace → suspended → reaped)
All checks were successful
CI / Build and Test (push) Successful in 1m22s
Contexts that lose their SSE connection now pass through a suspended
state before being fully reaped. Suspended contexts keep their shell
(ID, route, CSRF token) but free page resources. On reconnect, the
page init function is re-run for a seamless resume. Contexts past
the TTL trigger a client-side reload instead of a silent dead page.

Configurable via ContextSuspendAfter (default 15m) and ContextTTL
(default 1h).
2026-02-13 15:22:08 -10:00
Ryan Hamamura
11c6354da0 docs: add guide covering routing, state, HTML DSL, pubsub, and project structure 2026-02-13 10:55:07 -10:00
Ryan Hamamura
719b389be6 refactor: split nats-chatroom into modules with profile, layout, and auth
Extract chat, profile, types, and user data into separate files.
Embed CSS via go:embed. Add a layout with nav, session-based profile
persistence, and a middleware guard that redirects to /profile when
no identity is set.
2026-02-13 10:55:04 -10:00
Ryan Hamamura
1384e49e14 fix: preserve context across SSE reconnects on tab visibility change
Datastar aborts SSE on visibilitychange (tab hidden) and reconnects
when visible. The previous cleanup-on-disconnect destroyed the context
before the client could reconnect. Now SSE disconnect does a soft
teardown (mark disconnected, keep context alive) and reconnect drains
stale patches before resuming. The reaper uses disconnect time instead
of creation time so recently-disconnected contexts aren't prematurely
reaped.
2026-02-13 10:52:46 -10:00
Ryan Hamamura
785f11e52d fix: harden SPA navigation with race protection and correctness fixes
- Add navMu to serialize concurrent navigations on the same context
- Replace url.PathEscape with targeted JS string escaper (PathEscape
  mangles full paths and doesn't escape single quotes)
- Collapse syncWithViewTransition into syncView(bool) to remove duplication
- Simplify popstate ready guard in navigate.js
- Preserve URL hash during SPA navigation
2026-02-12 14:41:50 -10:00
Ryan Hamamura
2f19874c17 feat: add PubSub() accessor to V struct 2026-02-12 14:32:05 -10:00
Ryan Hamamura
27b8540b71 feat: add SPA navigation with view transitions
Swap page content over the existing SSE connection without full page
loads. A persistent Context resets its page-specific state (signals,
actions, intervals, subscriptions) on navigate while preserving the
SSE stream, CSRF token, and session.

- c.Navigate(path) for programmatic SPA navigation from actions
- Injected JS intercepts same-origin <a> clicks (opt out with
  data-via-no-boost) and handles popstate for back/forward
- v.Layout() wraps pages in a shared shell for DRY nav/chrome
- View Transition API integration via WithViewTransitions() on
  PatchElements and h.DataViewTransition() helper
- POST /_navigate endpoint with CSRF validation and rate limiting
- pageStopChan cancels page-level OnInterval goroutines on navigate
- Includes SPA example with layout, counter, and live clock pages
2026-02-12 13:52:47 -10:00
Ryan Hamamura
532651552a refactor: simplify OnInterval API to auto-start and return stop func
Replace the exported OnIntervalRoutine struct (Start/Stop/UpdateInterval)
with a single function that auto-starts the goroutine and returns an
idempotent stop closure. Uses close(channel) instead of send-on-channel,
fixing a potential deadlock when the goroutine exits via context disposal.

Closes #5 item 4.
2026-02-12 12:27:50 -10:00
Ryan Hamamura
2310e45d35 feat: auto-start embedded NATS server in New()
Pub/sub now works out of the box — New() starts a process-scoped
embedded NATS server with JetStream. The PubSub interface remains
for custom backends via Config(Options{PubSub: ...}).

- Move vianats functionality into nats.go (eliminates circular import)
- Add NATSConn(), JetStream(), EnsureStream(), ReplayHistory[T]() to via
- Delete vianats/ package
- Simplify nats-chatroom and pubsub-crud examples
- Rewrite pubsub tests to use real embedded NATS
2026-02-12 08:54:44 -10:00
Ryan Hamamura
10b4838f8d feat: auto-track fields on context for zero-arg ValidateAll/ResetFields
Fields created via Context.Field are now tracked on the page context,
so ValidateAll() and ResetFields() with no arguments operate on all
fields by default. Explicit field args still work for selective use.

Also switches MinLen/MaxLen to utf8.RuneCountInString for correct
unicode handling and replaces fmt.Errorf with errors.New where
format strings are unnecessary.
2026-02-11 19:57:13 -10:00
Ryan Hamamura
5362614c3e feat: add field validation API with signup form example
Introduces Field, Rule, ValidateAll, ResetFields, and AddError for
declarative input validation. Includes built-in rules (Required,
MinLen, MaxLen, Min, Max, Email, Pattern, Custom) and a signup
example exercising the full API surface.
2026-02-11 14:42:44 -10:00
ryanhamamura
e636970f7b feat: add middleware, route groups, and codebase cleanup
* feat: add middleware example demonstrating route groups

Self-contained example covering v.Use(), v.Group(), nested groups,
Group.Use(), and middleware chaining with role-based access control.

* feat: add per-action middleware via WithMiddleware ActionOption

Reuses the existing Middleware type so the same auth/logging functions
work at both page and action level. Middleware runs after CSRF and
rate-limit checks, with full access to session and signals.

* feat: add RedirectView helper and refactor session example to use middleware

RedirectView lets middleware abort and redirect in one step. The session
example now uses an authRequired middleware on a route group instead of
an inline check inside the view.

* fix: remove dead code, fix double Load and extractParams mismatch

- Remove componentRegistry (written, never read)
- Remove unused signal methods: Bytes, Int64, Float
- Remove unreachable nil check in registerCtx
- Simplify injectRouteParams (extractParams already returns fresh map)
- Fix double sync.Map.Load in injectSignals
- Merge Shutdown/shutdown into single method
- Inline currSessionNum
- Fix extractParams: mismatched literal segment now returns nil
- Minor: new(bytes.Buffer), go c.Sync(), genRandID reads 4 bytes
2026-02-11 13:50:02 -10:00
Ryan Hamamura
f5158b866c feat: add Static and StaticFS helpers for serving static files
One-liner static file serving: v.Static("/assets/", "./public") for
filesystem directories and v.StaticFS("/assets/", fsys) for embed.FS.
Both auto-normalize the URL prefix and disable directory listings.
2026-02-06 13:22:00 -10:00
Ryan Hamamura
2f6c5916ce docs: rewrite README with correct import paths and current feature set 2026-02-06 12:56:31 -10:00
Ryan Hamamura
0762ddbbc2 feat: add token-bucket rate limiting for action endpoints
Add per-context and per-action rate limiting using golang.org/x/time/rate.
Configure globally via Options.ActionRateLimit or per-action with
WithRateLimit(). Defaults to 10 req/s with burst of 20.
2026-02-06 11:52:07 -10:00
Ryan Hamamura
b7acfa6302 feat: add automatic CSRF protection for action calls
Generate a per-context CSRF token (128-bit, crypto/rand) and inject it
as a Datastar signal (via-csrf) alongside via-ctx. Validate with
constant-time comparison on /_action/{id} before executing, returning
403 on mismatch. Transparent to users since Datastar sends all signals
automatically.

Closes #9
2026-02-06 11:17:41 -10:00
Ryan Hamamura
8aa91c577c feat: add event types OnSubmit, OnInput, OnFocus, OnBlur, OnMouseEnter, OnMouseLeave, OnScroll, OnDblClick 2026-02-06 10:54:27 -10:00
Ryan Hamamura
6dcd54c88b fix: clean up leaked contexts on SSE disconnect and add orphan reaper
When clients disconnect without beforeunload firing (network drops,
mobile kills, crashes), contexts leaked in the registry permanently.

- Extract cleanupCtx helper for dispose/unregister sequence
- Call cleanupCtx on SSE disconnect (sse.Context().Done())
- Add background reaper for contexts where SSE never connected
- Add ContextTTL config option (default 30s, negative disables)
- Fix inverted condition in devModeRemovePersisted
2026-02-06 10:34:28 -10:00
Ryan Hamamura
2c44671d0e feat: add generic pub/sub helpers and pubsub-crud example
Add typed Publish[T] and Subscribe[T] generic helpers that handle
JSON marshaling, along with vianats.EnsureStream and ReplayHistory
helpers. Refactor nats-chatroom to use the new APIs.

Add pubsub-crud example demonstrating CRUD operations with DaisyUI
toast notifications broadcast to all connected clients via NATS.
2026-02-06 09:47:39 -10:00
Ryan Hamamura
53e5733100 feat: add keyboard grid example
8x8 grid game demonstrating OnKeyDownMap with WASD and arrow key
bindings. Arrow keys use WithPreventDefault to avoid page scrolling.
2026-02-02 08:58:03 -10:00
Ryan Hamamura
11543947bd feat: add OnKeyDownMap and WithWindow for combined key bindings
Add window-scoped keydown dispatching with per-key signal and
preventDefault options. Use comma operator instead of semicolons
in generated ternary expressions to produce valid JavaScript.
2026-02-02 08:57:59 -10:00
Ryan Hamamura
e79bb0e1b0 Revert "feat: add OnKeyDownMap and WithWindow for combined key bindings"
This reverts commit d1e8e3a2ed.
2026-02-02 08:27:07 -10:00
Ryan Hamamura
d1e8e3a2ed feat: add OnKeyDownMap and WithWindow for combined key bindings
OnKeyDownMap merges multiple key bindings into a single
data-on:keydown__window attribute via a JS ternary chain,
avoiding HTML attribute deduplication. WithWindow scopes
any keydown listener to the window object.
2026-02-02 08:23:06 -10:00
Ryan Hamamura
4a7acbb630 feat: add graceful shutdown with OS signal handling
Handle SIGINT/SIGTERM in Start() to cleanly drain all contexts,
stop goroutines, close SSE connections, and tear down PubSub.

Fix stopAllRoutines() to close() the channel instead of sending a
single value, so all listening goroutines are notified.
2026-01-31 09:22:43 -10:00
Ryan Hamamura
a7ace9099f feat: replace log with rs/zerolog for structured logging
Switch from the standard library log package to rs/zerolog with
ConsoleWriter for colorful terminal output in dev mode and JSON
output in production. Users can now provide their own logger via
Options.Logger or set the level via Options.LogLevel.
2026-01-31 08:18:24 -10:00
Ryan Hamamura
d8318af9c4 feat: add JetStream message replay to chatroom example
Replay stored messages from JetStream when joining or switching rooms
so users see chat history immediately.
2026-01-26 08:10:30 -10:00
Ryan Hamamura
30cc6d88e6 feat: add embedded NATS pub/sub support on Context
Define PubSub and Subscription interfaces in the core via package with
a vianats sub-package providing the embedded NATS + JetStream
implementation. Expose c.Publish() and c.Subscribe() on Context with
automatic subscription cleanup on session close. Refactor the NATS
chatroom example to use the built-in methods instead of manual
subscription tracking.
2026-01-26 08:06:50 -10:00
Ryan Hamamura
88bd0f31df feat: add NATS chatroom example with embedded server
Demonstrates pub/sub messaging as an alternative to custom Rooms
implementation. Uses delaneyj/toolbelt/embeddednats to run NATS
with JetStream inside the binary - no external server required.
2026-01-16 00:50:05 -10:00
Ryan Hamamura
82a3314089 feat: add SQLite session store support
Add NewSQLiteSessionManager helper that creates an SCS session manager
backed by SQLite, allowing sessions to persist across server restarts.
The function handles table creation automatically.
2026-01-15 08:44:27 -10:00
Ryan Hamamura
73f4e4009b Always sync full state when SSE connects
Previously only called Sync() on SSE reconnect (detected via last-event-id
header). This caused issues when application code registered contexts for
updates before the SSE connection was established - patches sent to
patchChan could be dropped.

Now always call Sync() when SSE connects, ensuring clients receive the
full current state regardless of what happened before the connection
was established.

Fixes #2
2026-01-14 19:02:44 -10:00
Ryan Hamamura
c77ccc0796 chore: rename module to github.com/ryanhamamura/via
Update module path and all internal imports to use the new repository location.
2026-01-14 10:47:11 -10:00
Ryan Hamamura
d4b831492e refactor: simplify Datastar configuration API
Flatten DatastarConfig struct into Options (DatastarContent, DatastarPath)
and replace datastarHandlerRegistered bool with sync.Once for thread safety.
2026-01-14 02:01:18 -10:00
58 changed files with 5717 additions and 368 deletions

5
.gitignore vendored
View File

@@ -47,3 +47,8 @@ internal/examples/picocss/picocss
internal/examples/plugins/plugins
internal/examples/realtimechart/realtimechart
internal/examples/shakespeare/shakespeare
internal/examples/nats-chatroom/nats-chatroom
/nats-chatroom
# NATS data directory
data/

View File

@@ -1,30 +1,33 @@
# Via
# Via
Real-time engine for building reactive web applications in pure Go.
## Why Via?
Somewhere along the way, the web became tangled in layers of JavaScript, build chains, and frameworks stacked on frameworks.
Via takes a radical stance:
The web became tangled in layers of JavaScript, build chains, and frameworks stacked on frameworks. Via takes a different path.
- No templates.
- No JavaScript.
- No transpilation.
- No hydration.
- No front-end fatigue.
- Single SSE stream.
- Full reactivity.
- Built-in Brotli compression.
- Pure Go.
**Philosophy**
- No templates. No JavaScript. No transpilation. No hydration.
- Views are pure Go functions. HTML is composed with a type-safe DSL.
- A single SSE stream carries all reactivity — no WebSocket juggling, no polling.
**Batteries included**
- Automatic CSRF protection on every action call
- Token-bucket rate limiting (global defaults + per-action overrides)
- Cookie-based sessions backed by SQLite
- Pub/sub messaging with an embedded NATS backend
- Structured logging via zerolog
- Graceful shutdown with context draining
- Brotli compression out of the box
## Example
```go
package main
import (
"github.com/go-via/via"
"github.com/go-via/via/h"
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
)
type Counter struct{ Count int }
@@ -57,25 +60,43 @@ func main() {
}
```
## What's built in
## 🚧 Experimental
<s>Via is still a newborn.</s> Via is taking its first steps!
- Version `0.1.0` released.
- Expect a little less chaos.
- **Reactive views + signals** — bind state to the DOM; changes push over SSE automatically
- **Components** — self-contained subcontexts with their own data, actions, and signals
- **Sessions** — cookie-based, backed by SQLite via `scs`
- **Pub/sub** — embedded NATS server with JetStream; generic `Publish[T]` / `Subscribe[T]` helpers
- **CSRF protection** — automatic token generation and validation on every action
- **Rate limiting** — token-bucket algorithm, configurable globally and per-action
- **Event handling** — `OnClick`, `OnChange`, `OnSubmit`, `OnInput`, `OnFocus`, `OnBlur`, `OnMouseEnter`, `OnMouseLeave`, `OnScroll`, `OnDblClick`, `OnKeyDown`, and `OnKeyDownMap` for multi-key bindings
- **Timed routines** — `OnInterval` auto-starts a ticker goroutine, returns a stop function, tied to context lifecycle
- **Redirects** — `Redirect`, `ReplaceURL`, and format-string variants
- **Plugin system** — `func(v *V)` hooks for integrating CSS/JS libraries
- **Structured logging** — zerolog with configurable levels; console output in dev, JSON in production
- **Graceful shutdown** — listens for SIGINT/SIGTERM, drains contexts, closes pub/sub
- **Context lifecycle** — background reaper cleans up disconnected contexts; configurable TTL
- **HTML DSL** — the `h` package provides type-safe Go-native HTML composition
## Examples
The `internal/examples/` directory contains 14 runnable examples:
`chatroom` · `counter` · `countercomp` · `greeter` · `keyboard` · `livereload` · `nats-chatroom` · `pathparams` · `picocss` · `plugins` · `pubsub-crud` · `realtimechart` · `session` · `shakespeare`
## Experimental
Via is maturing — sessions, CSRF, rate limiting, pub/sub, and graceful shutdown are in place — but the API is still evolving. Expect breaking changes before `v1`.
## Contributing
- Via is intentionally minimal and opinionated — and so is contributing.
- If you love Go, simplicity, and meaningful abstractions — Come along for the ride!
- Fork, branch, build, tinker with things, submit a pull request.
- Fork, branch, build, tinker, submit a pull request.
- Keep every line purposeful.
- Share feedback: open an issue or start a discussion.
## Credits
Via builds upon the work of these amazing projects:
Via builds upon the work of these projects:
- 🚀 [Datastar](https://data-star.dev) - The hypermedia powerhouse at the core of Via. It powers browser reactivity through Signals and enables real-time HTML/Signal patches over an always-on SSE event stream.
- 🧩 [Gomponents](https://maragu.dev/gomponents) - The awesome project that gifts Via with Go-native HTML composition superpowers through the `via/h` package.
> Thank you for building something that doesnt just function — it inspires. 🫶
- [Datastar](https://data-star.dev) — the hypermedia framework powering browser reactivity through signals and real-time HTML patches over SSE.
- [Gomponents](https://maragu.dev/gomponents) Go-native HTML composition that powers the `via/h` package.

View File

@@ -4,7 +4,7 @@ import (
"fmt"
"strconv"
"github.com/go-via/via/h"
"github.com/ryanhamamura/via/h"
)
// actionTrigger represents a trigger to an event handler fn
@@ -18,9 +18,11 @@ type ActionTriggerOption interface {
}
type triggerOpts struct {
hasSignal bool
signalID string
value string
hasSignal bool
signalID string
value string
window bool
preventDefault bool
}
type withSignalOpt struct {
@@ -34,6 +36,28 @@ func (o withSignalOpt) apply(opts *triggerOpts) {
opts.value = o.value
}
type withWindowOpt struct{}
func (o withWindowOpt) apply(opts *triggerOpts) {
opts.window = true
}
// WithWindow makes the event listener attach to the window instead of the element.
func WithWindow() ActionTriggerOption {
return withWindowOpt{}
}
type withPreventDefaultOpt struct{}
func (o withPreventDefaultOpt) apply(opts *triggerOpts) {
opts.preventDefault = true
}
// WithPreventDefault calls evt.preventDefault() for matched keys.
func WithPreventDefault() ActionTriggerOption {
return withPreventDefaultOpt{}
}
// WithSignal sets a signal value before triggering the action.
func WithSignal(sig *signal, value string) ActionTriggerOption {
return withSignalOpt{
@@ -54,7 +78,7 @@ func buildOnExpr(base string, opts *triggerOpts) string {
if !opts.hasSignal {
return base
}
return fmt.Sprintf("$%s=%s;%s", opts.signalID, opts.value, base)
return fmt.Sprintf("$%s=%s,%s", opts.signalID, opts.value, base)
}
func applyOptions(options ...ActionTriggerOption) triggerOpts {
@@ -83,6 +107,54 @@ func (a *actionTrigger) OnChange(options ...ActionTriggerOption) h.H {
return h.Data("on:change__debounce.200ms", buildOnExpr(actionURL(a.id), &opts))
}
// OnSubmit returns a via.h DOM attribute that triggers on form submit.
func (a *actionTrigger) OnSubmit(options ...ActionTriggerOption) h.H {
opts := applyOptions(options...)
return h.Data("on:submit", buildOnExpr(actionURL(a.id), &opts))
}
// OnInput returns a via.h DOM attribute that triggers on input (without debounce).
func (a *actionTrigger) OnInput(options ...ActionTriggerOption) h.H {
opts := applyOptions(options...)
return h.Data("on:input", buildOnExpr(actionURL(a.id), &opts))
}
// OnFocus returns a via.h DOM attribute that triggers when the element gains focus.
func (a *actionTrigger) OnFocus(options ...ActionTriggerOption) h.H {
opts := applyOptions(options...)
return h.Data("on:focus", buildOnExpr(actionURL(a.id), &opts))
}
// OnBlur returns a via.h DOM attribute that triggers when the element loses focus.
func (a *actionTrigger) OnBlur(options ...ActionTriggerOption) h.H {
opts := applyOptions(options...)
return h.Data("on:blur", buildOnExpr(actionURL(a.id), &opts))
}
// OnMouseEnter returns a via.h DOM attribute that triggers when the mouse enters the element.
func (a *actionTrigger) OnMouseEnter(options ...ActionTriggerOption) h.H {
opts := applyOptions(options...)
return h.Data("on:mouseenter", buildOnExpr(actionURL(a.id), &opts))
}
// OnMouseLeave returns a via.h DOM attribute that triggers when the mouse leaves the element.
func (a *actionTrigger) OnMouseLeave(options ...ActionTriggerOption) h.H {
opts := applyOptions(options...)
return h.Data("on:mouseleave", buildOnExpr(actionURL(a.id), &opts))
}
// OnScroll returns a via.h DOM attribute that triggers on scroll.
func (a *actionTrigger) OnScroll(options ...ActionTriggerOption) h.H {
opts := applyOptions(options...)
return h.Data("on:scroll", buildOnExpr(actionURL(a.id), &opts))
}
// OnDblClick returns a via.h DOM attribute that triggers on double click.
func (a *actionTrigger) OnDblClick(options ...ActionTriggerOption) h.H {
opts := applyOptions(options...)
return h.Data("on:dblclick", buildOnExpr(actionURL(a.id), &opts))
}
// OnKeyDown returns a via.h DOM attribute that triggers when a key is pressed.
// key: optional, see https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key
// Example: OnKeyDown("Enter")
@@ -92,5 +164,49 @@ func (a *actionTrigger) OnKeyDown(key string, options ...ActionTriggerOption) h.
if key != "" {
condition = fmt.Sprintf("evt.key==='%s' &&", key)
}
return h.Data("on:keydown", fmt.Sprintf("%s%s", condition, buildOnExpr(actionURL(a.id), &opts)))
attrName := "on:keydown"
if opts.window {
attrName = "on:keydown__window"
}
return h.Data(attrName, fmt.Sprintf("%s%s", condition, buildOnExpr(actionURL(a.id), &opts)))
}
// KeyBinding pairs a key with an action and per-binding options.
type KeyBinding struct {
Key string
Action *actionTrigger
Options []ActionTriggerOption
}
// KeyBind creates a KeyBinding for use with OnKeyDownMap.
func KeyBind(key string, action *actionTrigger, options ...ActionTriggerOption) KeyBinding {
return KeyBinding{Key: key, Action: action, Options: options}
}
// OnKeyDownMap produces a single window-scoped keydown attribute that dispatches
// to different actions based on the pressed key. Each binding can reference a
// different action and carry its own signal/preventDefault options.
func OnKeyDownMap(bindings ...KeyBinding) h.H {
if len(bindings) == 0 {
return nil
}
expr := ""
for i, b := range bindings {
opts := applyOptions(b.Options...)
branch := ""
if opts.preventDefault {
branch = "evt.preventDefault(),"
}
branch += buildOnExpr(actionURL(b.Action.id), &opts)
if i > 0 {
expr += " : "
}
expr += fmt.Sprintf("evt.key==='%s' ? (%s)", b.Key, branch)
}
expr += " : void 0"
return h.Data("on:keydown__window", expr)
}

View File

@@ -1,26 +1,19 @@
package via
import "github.com/alexedwards/scs/v2"
import (
"time"
// DatastarConfig configures a custom Datastar.js script.
type DatastarConfig struct {
// Content is the Datastar.js script content.
// If nil, the embedded default is used.
Content []byte
"github.com/alexedwards/scs/v2"
"github.com/rs/zerolog"
)
// Path is the URL path where the script is served.
// Defaults to "/_datastar.js" if empty.
Path string
}
func ptr(l zerolog.Level) *zerolog.Level { return &l }
type LogLevel int
const (
undefined LogLevel = iota
LogLevelError
LogLevelWarn
LogLevelInfo
LogLevelDebug
var (
LogLevelDebug = ptr(zerolog.DebugLevel)
LogLevelInfo = ptr(zerolog.InfoLevel)
LogLevelWarn = ptr(zerolog.WarnLevel)
LogLevelError = ptr(zerolog.ErrorLevel)
)
// Plugin is a func that can mutate the given *via.V app runtime. It is useful to integrate popular JS/CSS UI libraries or tools.
@@ -34,9 +27,12 @@ type Options struct {
// The http server address. e.g. ':3000'
ServerAddress string
// Level of the logs to write to stdout.
// Options: Error, Warn, Info, Debug.
LogLvl LogLevel
// LogLevel sets the minimum log level. nil keeps the default (Info).
LogLevel *zerolog.Level
// Logger overrides the default logger entirely. When set, LogLevel and
// DevMode have no effect on logging.
Logger *zerolog.Logger
// The title of the HTML document.
DocumentTitle string
@@ -49,7 +45,30 @@ type Options struct {
// passing it (lifetime, cookie settings, store, etc).
SessionManager *scs.SessionManager
// Datastar configures a custom Datastar.js script.
// If nil, Via uses its embedded default.
Datastar *DatastarConfig
// DatastarContent is the Datastar.js script content.
// If nil, the embedded default is used.
DatastarContent []byte
// DatastarPath is the URL path where the script is served.
// Defaults to "/_datastar.js" if empty.
DatastarPath string
// PubSub enables publish/subscribe messaging. Use vianats.New() for an
// embedded NATS backend, or supply any PubSub implementation.
PubSub PubSub
// ContextSuspendAfter is the time a context may be disconnected before
// the reaper suspends it (frees page resources but keeps the context
// shell for seamless re-init on reconnect). Default: 15m.
ContextSuspendAfter time.Duration
// ContextTTL is the maximum time a context may exist without an SSE
// connection before the background reaper fully disposes it.
// Default: 1h. Negative value disables the reaper.
ContextTTL time.Duration
// ActionRateLimit configures the default token-bucket rate limiter for
// action endpoints. Zero values use built-in defaults (10 req/s, burst 20).
// Set Rate to -1 to disable rate limiting entirely.
ActionRateLimit RateLimitConfig
}

View File

@@ -5,13 +5,14 @@ import (
"context"
"encoding/json"
"fmt"
"log"
"maps"
"reflect"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/go-via/via/h"
"github.com/ryanhamamura/via/h"
"golang.org/x/time/rate"
)
// Context is the living bridge between Go and the browser.
@@ -20,17 +21,28 @@ import (
type Context struct {
id string
route string
csrfToken string
app *V
view func() h.H
routeParams map[string]string
componentRegistry map[string]*Context
parentPageCtx *Context
parentPageCtx *Context
patchChan chan patch
actionRegistry map[string]func()
actionLimiter *rate.Limiter
actionRegistry map[string]actionEntry
signals *sync.Map
mu sync.RWMutex
navMu sync.Mutex
ctxDisposedChan chan struct{}
pageStopChan chan struct{}
reqCtx context.Context
fields []*Field
subscriptions []Subscription
subsMu sync.Mutex
disposeOnce sync.Once
createdAt time.Time
sseConnected atomic.Bool
sseDisconnectedAt atomic.Pointer[time.Time]
suspended atomic.Bool
}
// View defines the UI rendered by this context.
@@ -41,7 +53,11 @@ func (c *Context) View(f func() h.H) {
if f == nil {
panic("nil viewfn")
}
c.view = func() h.H { return h.Div(h.ID(c.id), f()) }
if c.app.layout != nil {
c.view = func() h.H { return h.Div(h.ID(c.id), c.app.layout(f)) }
} else {
c.view = func() h.H { return h.Div(h.ID(c.id), f()) }
}
}
// Component registers a subcontext that has self contained data, actions and signals.
@@ -73,7 +89,6 @@ func (c *Context) Component(initCtx func(c *Context)) func() h.H {
compCtx.parentPageCtx = c
}
initCtx(compCtx)
c.componentRegistry[id] = compCtx
return compCtx.view
}
@@ -98,39 +113,46 @@ func (c *Context) isComponent() bool {
// h.Button(h.Text("Increment n"), increment.OnClick()),
// )
// })
func (c *Context) Action(f func()) *actionTrigger {
func (c *Context) Action(f func(), opts ...ActionOption) *actionTrigger {
id := genRandID()
if f == nil {
c.app.logErr(c, "failed to bind action '%s' to context: nil func", id)
return nil
}
entry := actionEntry{fn: f}
for _, opt := range opts {
opt(&entry)
}
if c.isComponent() {
c.parentPageCtx.actionRegistry[id] = f
c.parentPageCtx.actionRegistry[id] = entry
} else {
c.actionRegistry[id] = f
c.actionRegistry[id] = entry
}
return &actionTrigger{id}
}
func (c *Context) getActionFn(id string) (func(), error) {
if f, ok := c.actionRegistry[id]; ok {
return f, nil
func (c *Context) getAction(id string) (actionEntry, error) {
if e, ok := c.actionRegistry[id]; ok {
return e, nil
}
return nil, fmt.Errorf("action '%s' not found", id)
return actionEntry{}, fmt.Errorf("action '%s' not found", id)
}
// OnInterval starts a go routine that sets a time.Ticker with the given duration and executes
// the given handler func() on every tick. Use *Routine.UpdateInterval to update the interval.
func (c *Context) OnInterval(duration time.Duration, handler func()) *OnIntervalRoutine {
var cn chan struct{}
if c.isComponent() { // components use the chan on the parent page ctx
cn = c.parentPageCtx.ctxDisposedChan
// OnInterval starts a goroutine that executes handler on every tick of the given duration.
// The goroutine is tied to the context lifecycle and will stop when the context is disposed.
// Returns a func() that stops the interval when called.
func (c *Context) OnInterval(duration time.Duration, handler func()) func() {
var disposeCh, pageCh chan struct{}
if c.isComponent() {
disposeCh = c.parentPageCtx.ctxDisposedChan
pageCh = c.parentPageCtx.pageStopChan
} else {
cn = c.ctxDisposedChan
disposeCh = c.ctxDisposedChan
pageCh = c.pageStopChan
}
r := newOnIntervalRoutine(cn, duration, handler)
return r
return newOnInterval(disposeCh, pageCh, duration, handler)
}
// Signal creates a reactive signal and initializes it with the given value.
@@ -195,14 +217,14 @@ func (c *Context) injectSignals(sigs map[string]any) {
defer c.mu.Unlock()
for sigID, val := range sigs {
if _, ok := c.signals.Load(sigID); !ok {
item, ok := c.signals.Load(sigID)
if !ok {
c.signals.Store(sigID, &signal{
id: sigID,
val: val,
})
continue
}
item, _ := c.signals.Load(sigID)
if sig, ok := item.(*signal); ok {
sig.val = val
sig.changed = false
@@ -253,15 +275,22 @@ func (c *Context) sendPatch(p patch) {
// Sync pushes the current view state and signal changes to the browser immediately
// over the live SSE event stream.
func (c *Context) Sync() {
elemsPatch := bytes.NewBuffer(make([]byte, 0))
c.syncView(false)
}
func (c *Context) syncView(viewTransition bool) {
elemsPatch := new(bytes.Buffer)
if err := c.view().Render(elemsPatch); err != nil {
c.app.logErr(c, "sync view failed: %v", err)
return
}
c.sendPatch(patch{patchTypeElements, elemsPatch.String()})
typ := patchType(patchTypeElements)
if viewTransition {
typ = patchTypeElementsWithVT
}
c.sendPatch(patch{typ, elemsPatch.String()})
updatedSigs := c.prepareSignalsForPatch()
if len(updatedSigs) != 0 {
outgoingSigs, _ := json.Marshal(updatedSigs)
c.sendPatch(patch{patchTypeSignals, string(outgoingSigs)})
@@ -318,6 +347,15 @@ func (c *Context) ExecScript(s string) {
c.sendPatch(patch{patchTypeScript, s})
}
// RedirectView sets a view that redirects the browser to the given URL.
// Use this in middleware to abort the chain and redirect in one step.
func (c *Context) RedirectView(url string) {
c.View(func() h.H {
c.Redirect(url)
return h.Div()
})
}
// Redirect navigates the browser to the given URL.
// This triggers a full page navigation - the current context will be disposed
// and a new context created at the destination URL.
@@ -349,11 +387,70 @@ func (c *Context) ReplaceURLf(format string, a ...any) {
c.ReplaceURL(fmt.Sprintf(format, a...))
}
// stopAllRoutines stops all go routines tied to this Context preventing goroutine leaks.
// resetPageState tears down page-specific state (intervals, subscriptions,
// actions, signals, fields) without disposing the context itself. The SSE
// connection and context lifetime are unaffected.
func (c *Context) resetPageState() {
close(c.pageStopChan)
c.unsubscribeAll()
c.mu.Lock()
c.actionRegistry = make(map[string]actionEntry)
c.signals = new(sync.Map)
c.fields = nil
c.pageStopChan = make(chan struct{})
c.mu.Unlock()
}
// suspend frees page-scoped resources while keeping the context shell alive
// in the registry for seamless re-init on reconnect.
func (c *Context) suspend() {
c.resetPageState()
c.suspended.Store(true)
}
// Navigate performs an SPA navigation to the given path. It resets page state,
// runs the target page's init function (with middleware), and pushes the new
// view over the existing SSE connection with a view transition animation.
// If popstate is true, replaceState is used instead of pushState.
func (c *Context) Navigate(path string, popstate bool) {
c.navMu.Lock()
defer c.navMu.Unlock()
route, initFn, params := c.app.matchRoute(path)
if initFn == nil {
c.Redirect(path)
return
}
c.resetPageState()
c.route = route
c.injectRouteParams(params)
initFn(c)
c.syncView(true)
safe := strings.NewReplacer(`\`, `\\`, `'`, `\'`).Replace(path)
if popstate {
c.ExecScript(fmt.Sprintf("history.replaceState({},'','%s')", safe))
} else {
c.ExecScript(fmt.Sprintf("history.pushState({},'','%s')", safe))
}
}
// dispose idempotently tears down this context: unsubscribes all pubsub
// subscriptions and closes ctxDisposedChan to stop routines and exit the SSE loop.
func (c *Context) dispose() {
c.disposeOnce.Do(func() {
c.unsubscribeAll()
c.stopAllRoutines()
})
}
// stopAllRoutines closes ctxDisposedChan, broadcasting to all listening
// goroutines (OnInterval, SSE loop) that this context is done.
func (c *Context) stopAllRoutines() {
select {
case c.ctxDisposedChan <- struct{}{}:
case <-c.ctxDisposedChan:
// already closed
default:
close(c.ctxDisposedChan)
}
}
@@ -361,12 +458,9 @@ func (c *Context) injectRouteParams(params map[string]string) {
if params == nil {
return
}
m := make(map[string]string)
c.mu.Lock()
defer c.mu.Unlock()
maps.Copy(m, params)
c.routeParams = m
c.routeParams = params
}
// GetPathParam retrieves the value from the page request URL for the given parameter name
@@ -403,20 +497,116 @@ func (c *Context) Session() *Session {
}
}
// Publish sends data to the given subject via the configured PubSub backend.
// Returns an error if no PubSub is configured. No-ops during panic-check init.
func (c *Context) Publish(subject string, data []byte) error {
if c.id == "" {
return nil
}
if c.app.pubsub == nil {
return fmt.Errorf("pubsub not configured")
}
return c.app.pubsub.Publish(subject, data)
}
// Subscribe creates a subscription on the configured PubSub backend.
// The subscription is tracked for automatic cleanup when the context is disposed.
// Returns an error if no PubSub is configured. No-ops during panic-check init.
func (c *Context) Subscribe(subject string, handler func(data []byte)) (Subscription, error) {
if c.id == "" {
return nil, nil
}
if c.app.pubsub == nil {
return nil, fmt.Errorf("pubsub not configured")
}
sub, err := c.app.pubsub.Subscribe(subject, handler)
if err != nil {
return nil, err
}
// Track on page context for cleanup (components use parent, like signals/actions)
target := c
if c.isComponent() {
target = c.parentPageCtx
}
target.subsMu.Lock()
target.subscriptions = append(target.subscriptions, sub)
target.subsMu.Unlock()
return sub, nil
}
// unsubscribeAll cleans up all tracked subscriptions for this context and its components.
func (c *Context) unsubscribeAll() {
c.subsMu.Lock()
subs := c.subscriptions
c.subscriptions = nil
c.subsMu.Unlock()
for _, sub := range subs {
sub.Unsubscribe()
}
}
// Field creates a signal with validation rules attached.
// The initial value seeds both the signal and the reset target.
// The field is tracked on the context so ValidateAll/ResetFields
// can operate on all fields by default.
func (c *Context) Field(initial any, rules ...Rule) *Field {
f := &Field{
signal: c.Signal(initial),
rules: rules,
initialVal: initial,
}
target := c
if c.isComponent() {
target = c.parentPageCtx
}
target.fields = append(target.fields, f)
return f
}
// ValidateAll runs Validate on each field, returning true only if all pass.
// With no arguments it validates every field tracked on this context.
func (c *Context) ValidateAll(fields ...*Field) bool {
if len(fields) == 0 {
fields = c.fields
}
ok := true
for _, f := range fields {
if !f.Validate() {
ok = false
}
}
return ok
}
// ResetFields resets each field to its initial value and clears errors.
// With no arguments it resets every field tracked on this context.
func (c *Context) ResetFields(fields ...*Field) {
if len(fields) == 0 {
fields = c.fields
}
for _, f := range fields {
f.Reset()
}
}
func newContext(id string, route string, v *V) *Context {
if v == nil {
log.Fatal("create context failed: app pointer is nil")
panic("create context failed: app pointer is nil")
}
return &Context{
id: id,
route: route,
csrfToken: genCSRFToken(),
routeParams: make(map[string]string),
app: v,
componentRegistry: make(map[string]*Context),
actionRegistry: make(map[string]func()),
actionLimiter: newLimiter(v.actionRateLimit, defaultActionRate, defaultActionBurst),
actionRegistry: make(map[string]actionEntry),
signals: new(sync.Map),
patchChan: make(chan patch, 1),
patchChan: make(chan patch, 8),
ctxDisposedChan: make(chan struct{}, 1),
pageStopChan: make(chan struct{}),
createdAt: time.Now(),
}
}

179
docs/getting-started.md Normal file
View File

@@ -0,0 +1,179 @@
# Getting Started
Via is a server-side reactive web framework for Go. The browser connects over SSE (Server-Sent Events), and all state lives on the server — signals, actions, and view rendering happen in Go. The browser is a thin display layer that Datastar keeps in sync via DOM morphing.
## Core Loop
Every Via app follows the same pattern:
```go
package main
import (
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
)
func main() {
v := via.New()
v.Config(via.Options{
DocumentTitle: "My App",
})
v.Page("/", func(c *via.Context) {
count := 0
step := c.Signal(1)
increment := c.Action(func() {
count += step.Int()
c.Sync()
})
c.View(func() h.H {
return h.Div(
h.P(h.Textf("Count: %d", count)),
h.Label(
h.Text("Step: "),
h.Input(h.Type("number"), step.Bind()),
),
h.Button(h.Text("+"), increment.OnClick()),
)
})
})
v.Start()
}
```
What happens:
1. `via.New()` creates the app, starts an embedded NATS server, and registers internal routes (`/_sse`, `/_action/{id}`, `/_navigate`, `/_session/close`).
2. `v.Config()` applies settings.
3. `v.Page()` registers a route. The init function receives a `*Context` where you define signals, actions, and the view.
4. `v.Start()` starts the HTTP server and blocks until SIGINT/SIGTERM.
When a browser hits the page, Via creates a new `Context`, runs the init function, renders the full HTML document, and opens an SSE connection. From that point, every `c.Sync()` re-renders the view and pushes a DOM patch to the browser.
## Configuration
```go
v.Config(via.Options{
DevMode: true,
ServerAddress: ":8080",
LogLevel: via.LogLevelDebug,
DocumentTitle: "My App",
Plugins: []via.Plugin{MyPlugin},
SessionManager: sm,
PubSub: customBackend,
ContextTTL: 60 * time.Second,
ActionRateLimit: via.RateLimitConfig{Rate: 20, Burst: 40},
})
```
| Field | Default | Description |
|-------|---------|-------------|
| `DevMode` | `false` | Enables context persistence across restarts, console logger, and Datastar inspector widget |
| `ServerAddress` | `":3000"` | HTTP listen address |
| `LogLevel` | `InfoLevel` | Minimum log level. Use `via.LogLevelDebug`, `LogLevelInfo`, `LogLevelWarn`, `LogLevelError` |
| `Logger` | (auto) | Replace the default logger entirely. When set, `LogLevel` and `DevMode` have no effect on logging |
| `DocumentTitle` | `"⚡ Via"` | The `<title>` of the HTML document |
| `Plugins` | `nil` | Slice of plugin functions executed during `Config()` |
| `SessionManager` | in-memory | Cookie-based session manager. See [PubSub and Sessions](pubsub-and-sessions.md) |
| `DatastarContent` | (embedded) | Custom Datastar JS bytes |
| `DatastarPath` | `"/_datastar.js"` | URL path for the Datastar script |
| `PubSub` | embedded NATS | Custom PubSub backend. Replaces the default NATS. See [PubSub and Sessions](pubsub-and-sessions.md) |
| `ContextTTL` | `30s` | Max time a context survives without an SSE connection before cleanup. Negative value disables the reaper |
| `ActionRateLimit` | `10 req/s, burst 20` | Default token-bucket rate limiter for action endpoints. Rate of `-1` disables limiting |
## Static Files
Serve files from a directory:
```go
v.Static("/assets/", "./static")
```
Or from an embedded filesystem:
```go
//go:embed static
var staticFS embed.FS
v.StaticFS("/assets/", staticFS)
```
Both disable directory listing and return 404 for directory paths.
## Head and Foot Injection
Add elements to every page's `<head>` or end of `<body>`:
```go
v.AppendToHead(
h.Link(h.Rel("stylesheet"), h.Href("/assets/style.css")),
h.Meta(h.Attr("name", "viewport"), h.Attr("content", "width=device-width, initial-scale=1")),
)
v.AppendToFoot(
h.Script(h.Src("/assets/app.js")),
)
```
These are additive and affect all pages globally.
## Plugins
A plugin is a `func(v *via.V)` that mutates the app during configuration — registering routes, injecting assets, or applying middleware.
```go
func PicoCSSPlugin(v *via.V) {
v.HTTPServeMux().HandleFunc("GET /css/pico.css", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css")
w.Write(picoCSSBytes)
})
v.AppendToHead(h.Link(h.Rel("stylesheet"), h.Href("/css/pico.css")))
}
// Usage:
v.Config(via.Options{
Plugins: []via.Plugin{PicoCSSPlugin},
})
```
Plugins have full access to the `*V` public API: `HTTPServeMux()`, `AppendToHead()`, `AppendToFoot()`, `Config()`, etc.
## DevMode
Enable during development for a better feedback loop:
```go
v.Config(via.Options{DevMode: true})
```
What it does:
- **Console logger** — Human-readable log output with timestamps.
- **Context persistence** — Saves context-to-route mappings to `.via/devmode/ctx.json`. On server restart, reconnecting browsers restore their state instead of getting a blank page. Pair with [Air](https://github.com/air-verse/air) for hot-reloading.
- **Datastar inspector** — Injects a widget showing live signal values and SSE activity.
## Custom HTTP Handlers
Access the underlying `*http.ServeMux` for custom routes:
```go
mux := v.HTTPServeMux()
mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
```
Register custom handlers before calling `v.Start()`.
## Next Steps
- [State and Interactivity](state-and-interactivity.md) — Signals, actions, components, validation
- [Routing and Navigation](routing-and-navigation.md) — Multi-page apps, middleware, SPA navigation
- [PubSub and Sessions](pubsub-and-sessions.md) — Real-time messaging, persistent sessions
- [HTML DSL](html-dsl.md) — The `h` package reference
- [Project Structure](project-structure.md) — Organizing files as your app grows

164
docs/html-dsl.md Normal file
View File

@@ -0,0 +1,164 @@
# HTML DSL
Reference for the `h` package — Via's HTML builder.
## Overview
The `h` package wraps [gomponents](https://github.com/maragudk/gomponents) with a single interface:
```go
type H interface {
Render(w io.Writer) error
}
```
Every element, attribute, and text node implements `H`. Build HTML by nesting function calls:
```go
import "github.com/ryanhamamura/via/h"
h.Div(h.Class("card"),
h.H2(h.Text("Title")),
h.P(h.Textf("Count: %d", count)),
h.Button(h.Text("Click"), action.OnClick()),
)
```
For cleaner templates, use a dot import:
```go
import . "github.com/ryanhamamura/via/h"
Div(Class("card"),
H2(Text("Title")),
P(Textf("Count: %d", count)),
Button(Text("Click"), action.OnClick()),
)
```
## Text Nodes
| Function | Description |
|----------|-------------|
| `Text(s)` | Escaped text node |
| `Textf(fmt, args...)` | Escaped text with `fmt.Sprintf` |
| `Raw(s)` | Unescaped raw HTML — use for trusted content like SVG |
| `Rawf(fmt, args...)` | Unescaped raw HTML with `fmt.Sprintf` |
## Elements
Every element function takes `...H` children (elements, attributes, and text nodes mixed together) except `Style(v string)` and `Title(v string)` which take a single string.
### Document structure
`HTML`, `Head`, `Body`, `Main`, `Header`, `Footer`, `Section`, `Article`, `Aside`, `Nav`, `Div`, `Span`
### Headings
`H1`, `H2`, `H3`, `H4`, `H5`, `H6`
### Text
`P`, `A`, `Strong`, `Em`, `B`, `I`, `U`, `S`, `Small`, `Mark`, `Del`, `Ins`, `Sub`, `Sup`, `Abbr`, `Cite`, `Code`, `Pre`, `Samp`, `Kbd`, `Var`, `Q`, `BlockQuote`, `Dfn`, `Wbr`, `Br`, `Hr`
### Forms
`Form`, `Input`, `Textarea`, `Select`, `Option`, `OptGroup`, `Button`, `Label`, `FieldSet`, `Legend`, `DataList`, `Meter`, `Progress`
### Tables
`Table`, `THead`, `TBody`, `TFoot`, `Tr`, `Th`, `Td`, `Caption`, `Col`, `ColGroup`
### Lists
`Ul`, `Ol`, `Li`, `Dl`, `Dt`, `Dd`
### Media
`Img`, `Audio`, `Video`, `Source`, `Picture`, `Canvas`, `IFrame`, `Embed`, `Object`
### Other
`Details`, `Summary`, `Dialog`, `Template`, `NoScript`, `Figure`, `FigCaption`, `Address`, `Time`, `Base`, `Link`, `Meta`, `Script`, `Area`
### Special signatures
| Function | Signature | Notes |
|----------|-----------|-------|
| `Style(v)` | `func Style(v string) H` | Inline `style` attribute, not a container element |
| `StyleEl(children...)` | `func StyleEl(children ...H) H` | The `<style>` element as a container |
| `Title(v)` | `func Title(v string) H` | Sets `<title>` text |
## Attributes
### Generic
```go
Attr("name", "value") // name="value"
Attr("disabled") // boolean attribute (no value)
```
`Attr` with no value produces a boolean attribute. With one value, it produces a name-value pair. More than one value panics.
### Named helpers
| Function | HTML output |
|----------|-------------|
| `ID(v)` | `id="v"` |
| `Class(v)` | `class="v"` |
| `Href(v)` | `href="v"` |
| `Src(v)` | `src="v"` |
| `Type(v)` | `type="v"` |
| `Value(v)` | `value="v"` |
| `Placeholder(v)` | `placeholder="v"` |
| `Rel(v)` | `rel="v"` |
| `Role(v)` | `role="v"` |
| `Data(name, v)` | `data-name="v"` (auto-prefixes `data-`) |
## Conditional Rendering
```go
h.If(showError, h.P(h.Class("error"), h.Text("Something went wrong")))
```
Returns the node when `true`, `nil` (renders nothing) when `false`.
## Datastar Helpers
These produce attributes used by Datastar for client-side reactivity.
| Function | Output | Description |
|----------|--------|-------------|
| `DataInit(expr)` | `data-init="expr"` | Initialize client-side state |
| `DataEffect(expr)` | `data-effect="expr"` | Reactive side effect expression |
| `DataIgnoreMorph()` | `data-ignore-morph` | Skip this element during DOM morph. See [SPA Navigation](routing-and-navigation.md#dataignoremorph) |
| `DataViewTransition(name)` | `style="view-transition-name: name"` | Animate element across SPA navigations. See [View Transitions](routing-and-navigation.md#view-transitions) |
> `DataViewTransition` sets the entire `style` attribute. If you also need other inline styles, include `view-transition-name` directly in a `Style()` call.
## Utilities
### HTML5
Full HTML5 document template:
```go
h.HTML5(h.HTML5Props{
Title: "My Page",
Description: "Page description",
Language: "en",
Head: []h.H{h.Link(h.Rel("stylesheet"), h.Href("/style.css"))},
Body: []h.H{h.Div(h.Text("Hello"))},
})
```
Via uses this internally to render the initial page document. You typically don't need it directly.
### JoinAttrs
Joins attribute values from child nodes by spaces:
```go
h.JoinAttrs("class", h.Class("card"), h.Class("active"))
// → class="card active"
```

164
docs/project-structure.md Normal file
View File

@@ -0,0 +1,164 @@
# Project Structure
Via's closure-based page model pulls signals, actions, and views into a single scope — similar to Svelte's single-file components. This works well at every scale, but the way you organize files should evolve as your app grows.
## Stage 1: Everything in main.go
For small apps and prototypes, keep everything in `main.go`. This is the right choice when your app is under ~150 lines or has a single page.
Within the file, follow this ordering convention inside each page:
```go
v.Page("/", func(c *via.Context) {
// State — plain Go variables and signals
count := 0
step := c.Signal(1)
// Actions — event handlers that mutate state
increment := c.Action(func() {
count += step.Int()
c.Sync()
})
// View — returns the HTML tree
c.View(func() h.H {
return h.Div(
h.P(h.Textf("Count: %d", count)),
h.Button(h.Text("+"), increment.OnClick()),
)
})
})
```
State → signals → actions → view. This reads top-to-bottom and matches the data flow: state is declared, actions mutate it, the view renders it.
The [counter](../internal/examples/counter/main.go) and [greeter](../internal/examples/greeter/main.go) examples use this layout.
## Stage 2: Page per file
When `main.go` has multiple pages or exceeds ~150 lines, extract each page into its own file as a package-level function.
`main.go` becomes the app skeleton — setup, configuration, routes, and start:
```go
package main
import (
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
)
func main() {
v := via.New()
v.Config(via.Options{
DocumentTitle: "My App",
})
v.AppendToHead(
h.Link(h.Rel("stylesheet"), h.Href("/css/pico.css")),
)
v.Page("/", HomePage)
v.Page("/chat", ChatPage)
v.Start()
}
```
Each page lives in its own file with a descriptive name:
```go
// home.go
package main
import (
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
)
func HomePage(c *via.Context) {
greeting := c.Signal("Hello")
c.View(func() h.H {
return h.Div(h.P(h.Text(greeting.String())))
})
}
```
Components follow the same pattern — keep them in the page file if single-use, or extract to their own file if reused across pages. Middleware goes in the same file as the route group it protects, or in `middleware.go` if shared.
```
myapp/
├── main.go # skeleton + routes
├── home.go # func HomePage(c *via.Context)
├── chat.go # func ChatPage(c *via.Context)
└── middleware.go # shared middleware
```
## Stage 3: Co-located CSS and shared types
As pages accumulate custom styling, CSS strings in Go become hard to maintain — no syntax highlighting, no linting. Extract them to `.css` files alongside the pages they belong to and use `//go:embed` to load them.
```go
// main.go
package main
import "embed"
//go:embed chat.css
var chatCSS string
func main() {
v := via.New()
v.AppendToHead(
h.Link(h.Rel("stylesheet"), h.Href("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css")),
h.StyleEl(h.Raw(chatCSS)),
)
// ...
}
```
When multiple pages share the same structs, extract them to `types.go`. Framework-agnostic domain logic (helpers, dummy data, business rules) gets its own file too.
```
myapp/
├── main.go # skeleton + routes + global styles
├── home.go
├── chat.go
├── chat.css # //go:embed in main.go
├── types.go # shared types
└── userdata.go # helpers, dummy data
```
The [nats-chatroom](../internal/examples/nats-chatroom/) example demonstrates this layout.
## CSS Approaches
Via doesn't prescribe a CSS strategy. Two approaches work well:
**CSS framework classes in Go code** — Use Pico, Tailwind, or similar. Classes go directly in the view via `h.Class()`. Good for rapid prototyping since there's nothing to extract.
```go
h.Div(h.Class("container"),
h.Button(h.Class("primary"), h.Text("Save")),
)
```
**Co-located `.css` files with `//go:embed`** — Write plain CSS in a separate file, embed it, and inject via `AppendToHead`. You get syntax highlighting, linting, and clean separation.
```go
//go:embed chat.css
var chatCSS string
// in main():
v.AppendToHead(h.StyleEl(h.Raw(chatCSS)))
```
Use a framework for quick prototypes and dashboards. Switch to co-located CSS files when you have significant custom styling or want tooling support.
## Next Steps
- [Getting Started](getting-started.md) — The core loop and configuration
- [State and Interactivity](state-and-interactivity.md) — Signals, actions, components, validation

251
docs/pubsub-and-sessions.md Normal file
View File

@@ -0,0 +1,251 @@
# PubSub and Sessions
Infrastructure for multi-user real-time communication and persistent state.
## PubSub
Via includes an embedded NATS server that starts automatically with `via.New()`. No external services required — pub/sub works out of the box.
### Interface
```go
type PubSub interface {
Publish(subject string, data []byte) error
Subscribe(subject string, handler func(data []byte)) (Subscription, error)
Close() error
}
type Subscription interface {
Unsubscribe() error
}
```
You can replace the default NATS with any backend implementing this interface via `Options.PubSub`.
### Basic pub/sub
```go
// Subscribe to messages
via.Subscribe(c, "chat.room.general", func(msg ChatMessage) {
messages = append(messages, msg)
c.Sync()
})
// Publish a message
via.Publish(c, "chat.room.general", ChatMessage{
User: username,
Message: text,
Time: time.Now().UnixMilli(),
})
```
The generic helpers `via.Publish[T]` and `via.Subscribe[T]` handle JSON marshaling/unmarshaling automatically. They are package-level functions (not methods) because Go doesn't support generic methods.
Raw byte-level access is also available on the context:
```go
c.Publish("subject", []byte("raw data"))
c.Subscribe("subject", func(data []byte) { /* ... */ })
```
### Auto-cleanup
Subscriptions created via `c.Subscribe()` or `via.Subscribe()` are tracked on the context and automatically unsubscribed when:
- The context is disposed (browser disconnects, tab closes)
- SPA navigation moves to a different page
You don't need to manually unsubscribe in normal usage.
### Custom backend
Replace the embedded NATS with your own PubSub implementation:
```go
v.Config(via.Options{
PubSub: myRedisBackend,
})
```
This disables the embedded NATS server. The `NATSConn()` and `JetStream()` accessors will return nil.
## JetStream
NATS JetStream provides persistent, replayable message streams. Useful for chat history, event logs, or any scenario where new subscribers need to catch up on past messages.
### Ensure a stream exists
```go
err := via.EnsureStream(v, via.StreamConfig{
Name: "CHAT",
Subjects: []string{"chat.>"},
MaxMsgs: 1000,
MaxAge: 24 * time.Hour,
})
```
| Field | Description |
|-------|-------------|
| `Name` | Stream name |
| `Subjects` | NATS subjects to capture (supports wildcards: `>` matches all sub-levels) |
| `MaxMsgs` | Maximum number of messages to retain |
| `MaxAge` | Maximum age before messages are discarded |
Call `EnsureStream` during app initialization, before `v.Start()`.
### Replay history
Retrieve recent messages from a stream:
```go
messages, err := via.ReplayHistory[ChatMessage](v, "chat.room.general", 50)
```
Returns up to the last `limit` messages on the subject, deserialized as `T`. Use this when a new user joins and needs to see recent history.
### Direct NATS access
For advanced use cases, access the NATS connection and JetStream context directly:
```go
nc := v.NATSConn() // *nats.Conn, nil if custom PubSub
js := v.JetStream() // nats.JetStreamContext, nil if custom PubSub
```
### PubSub accessor
Access the configured PubSub backend from the `V` instance:
```go
ps := v.PubSub() // via.PubSub interface, nil if none configured
```
## Sessions
Via uses [SCS](https://github.com/alexedwards/scs) for cookie-based session management.
### Setup with SQLite
```go
db, _ := sql.Open("sqlite3", "app.db")
sm, _ := via.NewSQLiteSessionManager(db)
sm.Lifetime = 24 * time.Hour
sm.Cookie.SameSite = http.SameSiteLaxMode
v.Config(via.Options{SessionManager: sm})
```
`NewSQLiteSessionManager` creates the `sessions` table and index if they don't exist. The returned `*scs.SessionManager` can be configured further (lifetime, cookie settings) before passing to `Config`.
A default in-memory session manager is always available, even without explicit configuration. Use `NewSQLiteSessionManager` when you need sessions to survive server restarts.
### Session API
Access the session from any context:
```go
s := c.Session()
```
**Getters:**
| Method | Return type |
|--------|-------------|
| `s.Get(key)` | `any` |
| `s.GetString(key)` | `string` |
| `s.GetInt(key)` | `int` |
| `s.GetBool(key)` | `bool` |
| `s.GetFloat64(key)` | `float64` |
| `s.GetTime(key)` | `time.Time` |
| `s.GetBytes(key)` | `[]byte` |
**Pop** (get and delete — useful for flash messages):
| Method | Return type |
|--------|-------------|
| `s.Pop(key)` | `any` |
| `s.PopString(key)` | `string` |
| `s.PopInt(key)` | `int` |
| `s.PopBool(key)` | `bool` |
| `s.PopFloat64(key)` | `float64` |
| `s.PopTime(key)` | `time.Time` |
| `s.PopBytes(key)` | `[]byte` |
**Mutators:**
| Method | Description |
|--------|-------------|
| `s.Set(key, val)` | Store a value |
| `s.Delete(key)` | Remove a single key |
| `s.Clear()` | Remove all session data |
| `s.Destroy()` | Destroy the entire session (for logout) |
| `s.RenewToken()` | Regenerate session ID (prevents session fixation — call after login) |
**Introspection:**
| Method | Description |
|--------|-------------|
| `s.Exists(key)` | True if key exists |
| `s.Keys()` | All keys in the session |
| `s.ID()` | Session token (cookie value) |
All getters return zero values if the key doesn't exist or the session manager is nil.
### Auth pattern
A common login/logout flow using sessions and middleware:
```go
// Middleware
func authRequired(c *via.Context, next func()) {
if c.Session().GetString("username") == "" {
c.Session().Set("flash", "Please log in first")
c.RedirectView("/login")
return
}
next()
}
// Login page
v.Page("/login", func(c *via.Context) {
user := c.Signal("")
pass := c.Signal("")
flash := c.Session().PopString("flash")
login := c.Action(func() {
if authenticate(user.String(), pass.String()) {
c.Session().RenewToken()
c.Session().Set("username", user.String())
c.Redirect("/dashboard")
} else {
flash = "Invalid credentials"
c.Sync()
}
})
c.View(func() h.H {
return h.Form(login.OnSubmit(),
h.If(flash != "", h.P(h.Text(flash))),
h.Input(h.Type("text"), user.Bind(), h.Placeholder("Username")),
h.Input(h.Type("password"), pass.Bind(), h.Placeholder("Password")),
h.Button(h.Type("submit"), h.Text("Log In")),
)
})
})
// Protected pages
protected := v.Group("", authRequired)
protected.Page("/dashboard", dashboardHandler)
// Logout action (inside a protected page)
logout := c.Action(func() {
c.Session().Destroy()
c.Redirect("/login")
})
```
Key points:
- Call `RenewToken()` after login to prevent session fixation.
- Use `PopString` for flash messages — they're read once then removed.
- Use `RedirectView` in middleware, `Redirect` in actions. See the [gotcha in routing](routing-and-navigation.md#middleware).

View File

@@ -0,0 +1,222 @@
# Routing and Navigation
Multi-page app structure, middleware, and Via's SPA navigation system.
## Pages
Register a page with a route pattern and an init function:
```go
v.Page("/", func(c *via.Context) {
c.View(func() h.H {
return h.H1(h.Text("Home"))
})
})
```
Routes use Go's standard `net/http.ServeMux` patterns. Via registers each page as a `GET` handler.
> **Gotcha:** Via runs every page init function at registration time (in a `defer/recover` block) to catch panics early. If your init function panics — e.g. by forgetting `c.View()` — the app crashes at startup, not at request time.
## Path Parameters
Use `{param}` syntax in route patterns:
```go
v.Page("/users/{id}/posts/{post_id}", func(c *via.Context) {
userID := c.GetPathParam("id")
postID := c.GetPathParam("post_id")
c.View(func() h.H {
return h.P(h.Textf("User %s, Post %s", userID, postID))
})
})
```
`GetPathParam` returns an empty string if the parameter doesn't exist.
## Route Groups
Group pages under a shared prefix with shared middleware:
```go
admin := v.Group("/admin", authRequired)
admin.Page("/dashboard", dashboardHandler) // route: /admin/dashboard
admin.Page("/settings", settingsHandler) // route: /admin/settings
```
### Nesting
Groups nest — the child inherits the parent's prefix and middleware:
```go
admin := v.Group("/admin", authRequired)
admin.Use(auditLog) // add middleware after creation
superAdmin := admin.Group("/super", superAdminOnly)
superAdmin.Page("/nuke", nukeHandler) // route: /admin/super/nuke
// middleware order: global → authRequired → auditLog → superAdminOnly → handler
```
### Empty prefix
Use an empty prefix when you need shared middleware without a path prefix:
```go
protected := v.Group("", authRequired)
protected.Page("/dashboard", dashboardHandler) // route: /dashboard
protected.Page("/profile", profileHandler) // route: /profile
```
## Middleware
```go
type Middleware func(c *Context, next func())
```
Call `next()` to continue the chain. Return without calling `next()` to abort — but set a view first.
```go
func authRequired(c *via.Context, next func()) {
if c.Session().GetString("username") == "" {
c.Session().Set("flash", "Please log in")
c.RedirectView("/login")
return // don't call next — chain is aborted
}
next()
}
```
> **Gotcha:** Use `c.RedirectView()` in middleware, not `c.Redirect()`. The SSE connection isn't open yet during the initial page load, so `Redirect()` (which sends a patch over SSE) won't work. `RedirectView()` sets the view to one that triggers a redirect once SSE connects.
### Three levels
| Level | Registration | Scope |
|-------|-------------|-------|
| Global | `v.Use(mw...)` | Every page |
| Group | `v.Group(prefix, mw...)` or `g.Use(mw...)` | Pages in the group |
| Action | `c.Action(fn, via.WithMiddleware(mw...))` | A single action endpoint |
### Execution order
Middleware runs in registration order: global first, then group, then the handler.
```go
v.Use(logger) // 1st
admin := v.Group("/admin", auth) // 2nd
admin.Use(audit) // 3rd
admin.Page("/x", handler) // 4th
// execution: logger → auth → audit → handler
```
Action-level middleware runs after CSRF validation and rate limiting, when the action endpoint is invoked.
## SPA Navigation
Via intercepts same-origin link clicks and navigates without a full page reload. The SSE connection persists, and the new page's view is morphed into the DOM with a view transition.
### How it works
1. `navigate.js` (embedded in every page) intercepts clicks on `<a>` elements.
2. For same-origin links, it POSTs to `/_navigate` with the context ID, CSRF token, and target URL.
3. The server calls `c.Navigate()`, which:
- Resets page state (stops intervals, unsubscribes PubSub, clears signals/actions/fields)
- Runs the target page's init function (with middleware) on the **same context**
- Pushes the new view via SSE with a view transition
- Updates the browser URL via `history.pushState()`
### What gets cleaned up on navigate
- Intervals stop (via `pageStopChan`)
- PubSub subscriptions are unsubscribed
- Signals, actions, and fields are cleared
- The new page starts completely fresh
The SSE connection and the context itself survive. This is what makes it an SPA — the existing stream is reused.
### Layouts
Define a layout to provide persistent chrome (nav bars, sidebars) that wraps every page:
```go
v.Layout(func(content func() h.H) h.H {
return h.Div(
h.Nav(
h.A(h.Href("/"), h.Text("Home")),
h.A(h.Href("/counter"), h.Text("Counter")),
h.A(h.Href("/clock"), h.Text("Clock")),
),
h.Main(content()),
)
})
```
The `content` parameter is the page's view function. During SPA navigation, the entire layout + content is re-rendered and morphed — Datastar's morph algorithm (idiomorph) efficiently updates only the changed parts, so the nav bar stays visually stable while the main content transitions.
> **Gotcha:** Layout state does not persist across navigations in the way page state doesn't — the layout is re-rendered from scratch each time. If you need state that survives navigation (like a selected nav item), derive it from the current route rather than storing it in a variable.
### View transitions
Animate elements across page navigations using the browser View Transitions API:
```go
// On the home page:
h.H1(h.Text("Home"), h.DataViewTransition("page-title"))
// On the counter page:
h.H1(h.Text("Counter"), h.DataViewTransition("page-title"))
```
Elements with matching `view-transition-name` values animate smoothly during SPA navigation. `DataViewTransition` sets the CSS `view-transition-name` as an inline `style` attribute. If the element also needs other inline styles, set `view-transition-name` directly in a `Style()` call instead.
Via automatically includes the `<meta name="view-transition" content="same-origin">` tag to enable the API.
### Opting out
Add `data-via-no-boost` to links that should trigger a full page reload:
```go
h.A(h.Href("/"), h.Text("Full Reload"), h.Attr("data-via-no-boost"))
```
Links are also auto-ignored when:
- They have a `target` attribute (e.g. `target="_blank"`)
- Modifier keys are held (Ctrl, Meta, Shift, Alt)
- The `href` starts with `#` or is cross-origin
- The `href` is missing
### Programmatic navigation
Trigger SPA navigation from an action handler:
```go
goCounter := c.Action(func() {
c.Navigate("/counter", false)
})
```
The second parameter controls history behavior: `false` for `pushState` (normal navigation), `true` for `replaceState` (back/forward).
If the path doesn't match any registered route, `Navigate` falls back to `c.Redirect()` (full page navigation).
### DataIgnoreMorph
Prevent Datastar from overwriting an element during morph:
```go
h.Div(h.ID("toast-container"), h.DataIgnoreMorph())
```
The element and its subtree are skipped during DOM patches. Useful for elements with client-side state: a focused input, an animation, a third-party widget, or a toast notification container.
## Custom HTTP Handlers
Access the underlying mux for non-Via routes (APIs, webhooks, health checks):
```go
mux := v.HTTPServeMux()
mux.HandleFunc("GET /api/health", healthHandler)
mux.HandleFunc("POST /api/webhook", webhookHandler)
```
Register before `v.Start()`. These routes bypass Via's context/SSE system entirely.

View File

@@ -0,0 +1,313 @@
# State and Interactivity
This is the core reactive model — signals, actions, views, components, and validation.
## Context Lifecycle
A `*Context` is created per browser visit. It holds all page state: signals, actions, fields, subscriptions, and the view function.
```
Browser hits page → new Context created → init function runs → HTML rendered
SSE connection opens ← browser loads page
action fires → signals injected from browser → handler runs → Sync() → DOM patched
```
The context is disposed when the SSE connection closes (tab close, navigation away, network loss). A background reaper also cleans up contexts that never establish an SSE connection within `ContextTTL` (default 30s).
During [SPA navigation](routing-and-navigation.md#spa-navigation), the context itself survives — only page-level state (signals, actions, fields, intervals, subscriptions) is reset. The SSE connection persists.
## Signals
Signals are reactive values synchronized between server and browser. Create one with an initial value:
```go
name := c.Signal("world")
count := c.Signal(0)
items := c.Signal([]string{"a", "b"})
```
### Reading values
```go
name.String() // "world"
count.Int() // 0
count.Bool() // false (parses "true", "1", "yes", "on")
```
Signal values come from the browser. Before every action call, the browser sends all current signal values to the server. You always read the latest browser state inside action handlers.
### Writing values
```go
name.SetValue("Via")
c.SyncSignals() // push only changed signals to browser
// or
c.Sync() // re-render view AND push changed signals
```
`SetValue` marks the signal as changed. The change is not sent to the browser until you call `Sync()` or `SyncSignals()`.
### Rendering in the view
```go
// Two-way binding on an input — browser edits update the signal
h.Input(h.Type("text"), name.Bind())
// Reactive text display — updates when the signal changes
h.Span(name.Text())
// Read value at render time — static until next Sync()
h.P(h.Textf("Count: %d", count.Int()))
```
`Bind()` outputs a `data-bind` attribute for two-way binding. `Text()` outputs a `<span data-text="$signalID">` for reactive display.
## Actions
Actions are server-side event handlers. They run on the server when triggered by a browser event.
```go
submit := c.Action(func() {
// signals are already injected — read them here
fmt.Println(name.String())
count.SetValue(count.Int() + 1)
c.Sync()
})
```
### Trigger methods
Attach an action to a DOM event by calling a trigger method in the view:
```go
h.Button(h.Text("Submit"), submit.OnClick())
h.Input(name.Bind(), submit.OnKeyDown("Enter"))
h.Select(category.Bind(), filter.OnChange())
h.Form(submit.OnSubmit())
```
Available triggers:
| Method | Event | Notes |
|--------|-------|-------|
| `OnClick()` | `click` | |
| `OnDblClick()` | `dblclick` | |
| `OnChange()` | `change` | 200ms debounce |
| `OnInput()` | `input` | No debounce |
| `OnSubmit()` | `submit` | |
| `OnKeyDown(key)` | `keydown` | Filtered by key name (e.g. `"Enter"`, `"Escape"`) |
| `OnFocus()` | `focus` | |
| `OnBlur()` | `blur` | |
| `OnMouseEnter()` | `mouseenter` | |
| `OnMouseLeave()` | `mouseleave` | |
| `OnScroll()` | `scroll` | |
### Trigger options
Every trigger method accepts `ActionTriggerOption` values:
```go
// Set a signal value before the action fires
submit.OnClick(via.WithSignal(mode, "delete"))
submit.OnClick(via.WithSignalInt(page, 3))
// Listen on window instead of the element
submit.OnKeyDown("Escape", via.WithWindow())
// Prevent browser default behavior
submit.OnKeyDown("ArrowDown", via.WithPreventDefault())
```
### Multi-key dispatch
`OnKeyDownMap` binds multiple keys to different actions in a single attribute:
```go
via.OnKeyDownMap(
via.KeyBind("w", move, via.WithSignal(dir, "up")),
via.KeyBind("s", move, via.WithSignal(dir, "down")),
via.KeyBind("ArrowUp", move, via.WithSignal(dir, "up"), via.WithPreventDefault()),
via.KeyBind("ArrowDown", move, via.WithSignal(dir, "down"), via.WithPreventDefault()),
)
```
This produces a single `data-on:keydown__window` attribute. Place it on any element in the view.
### Action options
```go
// Per-action rate limiting (overrides the context-level default)
c.Action(handler, via.WithRateLimit(5, 10))
// Per-action middleware (runs after CSRF and rate-limit checks)
c.Action(handler, via.WithMiddleware(requireAdmin))
```
## Views and Sync
Every page handler must call `c.View()` to define the UI:
```go
c.View(func() h.H {
return h.Div(
h.P(h.Textf("Hello, %s!", name.String())),
)
})
```
> **Gotcha:** If you forget `c.View()`, the app panics at startup during route registration — not at request time.
The view function is re-evaluated on every `c.Sync()`. The resulting HTML is pushed to the browser via SSE, where Datastar morphs the DOM.
### Sync variants
| Method | What it sends |
|--------|---------------|
| `c.Sync()` | Re-renders the view HTML **and** pushes changed signals |
| `c.SyncSignals()` | Pushes only changed signals, no view re-render |
| `c.SyncElements(elem...)` | Pushes specific HTML elements to merge into the DOM. Each element **must have an ID** matching an existing DOM element |
| `c.ExecScript(js)` | Sends JavaScript for the browser to execute (auto-removed after execution) |
Use `SyncSignals()` when only signal values changed and the view structure is the same. Use `SyncElements()` for targeted updates without re-rendering the entire view. Use `ExecScript()` to interact with client-side libraries (e.g. pushing data to a chart).
## Components
Extract reusable UI with `c.Component()`:
```go
func counterFn(c *via.Context) {
count := 0
step := c.Signal(1)
increment := c.Action(func() {
count += step.Int()
c.Sync()
})
c.View(func() h.H {
return h.Div(
h.P(h.Textf("Count: %d", count)),
h.Input(h.Type("number"), step.Bind()),
h.Button(h.Text("+"), increment.OnClick()),
)
})
}
// In a page:
v.Page("/", func(c *via.Context) {
counter1 := c.Component(counterFn)
counter2 := c.Component(counterFn)
c.View(func() h.H {
return h.Div(
h.H2(h.Text("Counter 1")), counter1(),
h.H2(h.Text("Counter 2")), counter2(),
)
})
})
```
Each component instance gets its own closure state, but signals, actions, and fields are registered on the parent page context. Components share the parent's SSE stream — `c.Sync()` from a component re-renders the entire page view.
## Fields and Validation
Fields are signals with validation rules. Use them for form inputs:
```go
username := c.Field("", via.Required(), via.MinLen(3), via.MaxLen(20))
email := c.Field("", via.Required(), via.Email())
age := c.Field("", via.Required(), via.Min(13), via.Max(120))
website := c.Field("", via.Pattern(`^https?://`, "Must start with http:// or https://"))
```
### Built-in rules
| Rule | Description |
|------|-------------|
| `Required(msg...)` | Rejects empty/whitespace-only values |
| `MinLen(n, msg...)` | Minimum character count (Unicode-aware) |
| `MaxLen(n, msg...)` | Maximum character count (Unicode-aware) |
| `Min(n, msg...)` | Minimum numeric value (parsed as int) |
| `Max(n, msg...)` | Maximum numeric value (parsed as int) |
| `Email(msg...)` | Email format regex |
| `Pattern(re, msg...)` | Custom regex |
| `Custom(fn)` | `func(string) error` — return non-nil to fail |
All rules accept an optional custom error message as the last argument.
### Using fields in views and actions
```go
submit := c.Action(func() {
if !c.ValidateAll() {
c.Sync()
return
}
// Server-side validation
if userExists(username.String()) {
username.AddError("Username taken")
c.Sync()
return
}
createUser(username.String(), email.String())
c.ResetFields()
c.Sync()
})
c.View(func() h.H {
return h.Form(submit.OnSubmit(),
h.Input(h.Type("text"), username.Bind(), h.Placeholder("Username")),
h.If(username.HasError(), h.Small(h.Text(username.FirstError()))),
h.Input(h.Type("email"), email.Bind(), h.Placeholder("Email")),
h.If(email.HasError(), h.Small(h.Text(email.FirstError()))),
h.Button(h.Type("submit"), h.Text("Sign Up")),
)
})
```
| Method | Description |
|--------|-------------|
| `field.Validate()` | Run rules, return true if all pass |
| `field.HasError()` | True if any validation errors exist |
| `field.FirstError()` | First error message, or `""` |
| `field.Errors()` | All error messages |
| `field.AddError(msg)` | Add a custom server-side error |
| `field.ClearErrors()` | Remove all errors |
| `field.Reset()` | Restore initial value and clear errors |
| `c.ValidateAll(fields...)` | Validate given fields (or all if none specified). Does not short-circuit — all fields get validated so all errors are populated |
| `c.ResetFields(fields...)` | Reset given fields (or all if none specified) |
Fields embed `*signal`, so `Bind()`, `Text()`, `String()`, `Int()`, `Bool()`, `SetValue()`, and `ID()` all work.
## OnInterval
Run a function at regular intervals, tied to the page lifecycle:
```go
stop := c.OnInterval(time.Second, func() {
now = time.Now()
c.Sync()
})
```
- Starts immediately — no separate start call needed.
- Returns a `func()` that stops the interval (idempotent).
- Automatically stops on context disposal (tab close) or SPA navigation away.
- Call `c.Sync()` inside the handler to push updates to the browser.
## Navigation Helpers
| Method | Effect |
|--------|--------|
| `c.Redirect(url)` | Full page navigation. Disposes the context, browser loads a new page |
| `c.Redirectf(fmt, args...)` | `Redirect` with `fmt.Sprintf` |
| `c.RedirectView(url)` | Sets the view to trigger a redirect on SSE connect. Use in [middleware](routing-and-navigation.md#middleware) to abort the chain and redirect |
| `c.ReplaceURL(url)` | Updates the browser URL bar without navigation. Useful for reflecting state in query params |
| `c.ReplaceURLf(fmt, args...)` | `ReplaceURL` with `fmt.Sprintf` |
| `c.Navigate(path, popstate)` | [SPA navigation](routing-and-navigation.md#spa-navigation). Resets page state, runs the target page handler on the same context, pushes the new view with a view transition |
> **Gotcha:** In middleware, use `c.RedirectView()`, not `c.Redirect()`. `Redirect` sends a patch over SSE, but the SSE connection isn't established yet during the initial page load.

58
field.go Normal file
View File

@@ -0,0 +1,58 @@
package via
// Field is a signal with built-in validation rules and error state.
// It embeds *signal, so all signal methods (Bind, String, Int, Bool, SetValue, Text, ID)
// work transparently.
type Field struct {
*signal
rules []Rule
errors []string
initialVal any
}
// Validate runs all rules against the current value.
// Clears previous errors, populates new ones, returns true if all rules pass.
func (f *Field) Validate() bool {
f.errors = nil
val := f.String()
for _, r := range f.rules {
if err := r.validate(val); err != nil {
f.errors = append(f.errors, err.Error())
}
}
return len(f.errors) == 0
}
// HasError returns true if this field has any validation errors.
func (f *Field) HasError() bool {
return len(f.errors) > 0
}
// FirstError returns the first validation error message, or "" if valid.
func (f *Field) FirstError() string {
if len(f.errors) > 0 {
return f.errors[0]
}
return ""
}
// Errors returns all current validation error messages.
func (f *Field) Errors() []string {
return f.errors
}
// AddError manually adds an error message (useful for server-side or cross-field validation).
func (f *Field) AddError(msg string) {
f.errors = append(f.errors, msg)
}
// ClearErrors removes all validation errors from this field.
func (f *Field) ClearErrors() {
f.errors = nil
}
// Reset restores the field value to its initial value and clears all errors.
func (f *Field) Reset() {
f.SetValue(f.initialVal)
f.errors = nil
}

206
field_test.go Normal file
View File

@@ -0,0 +1,206 @@
package via
import (
"fmt"
"testing"
"github.com/ryanhamamura/via/h"
"github.com/stretchr/testify/assert"
)
func newTestField(initial any, rules ...Rule) *Field {
v := New()
var f *Field
v.Page("/", func(c *Context) {
f = c.Field(initial, rules...)
c.View(func() h.H { return h.Div() })
})
return f
}
func TestFieldCreation(t *testing.T) {
f := newTestField("hello", Required())
assert.Equal(t, "hello", f.String())
assert.NotEmpty(t, f.ID())
}
func TestFieldSignalDelegation(t *testing.T) {
f := newTestField(42)
assert.Equal(t, "42", f.String())
assert.Equal(t, 42, f.Int())
f.SetValue("new")
assert.Equal(t, "new", f.String())
// Bind returns an h.H element
assert.NotNil(t, f.Bind())
}
func TestFieldValidateSingleRule(t *testing.T) {
f := newTestField("", Required())
assert.False(t, f.Validate())
assert.True(t, f.HasError())
assert.Equal(t, "This field is required", f.FirstError())
f.SetValue("ok")
assert.True(t, f.Validate())
assert.False(t, f.HasError())
assert.Equal(t, "", f.FirstError())
}
func TestFieldValidateMultipleRules(t *testing.T) {
f := newTestField("ab", Required(), MinLen(3))
assert.False(t, f.Validate())
errs := f.Errors()
assert.Len(t, errs, 1)
assert.Equal(t, "Must be at least 3 characters", errs[0])
f.SetValue("")
assert.False(t, f.Validate())
errs = f.Errors()
assert.Len(t, errs, 2)
}
func TestFieldErrors(t *testing.T) {
f := newTestField("")
assert.Nil(t, f.Errors())
assert.False(t, f.HasError())
assert.Equal(t, "", f.FirstError())
}
func TestFieldAddError(t *testing.T) {
f := newTestField("ok")
f.AddError("username taken")
assert.True(t, f.HasError())
assert.Equal(t, "username taken", f.FirstError())
assert.Len(t, f.Errors(), 1)
}
func TestFieldClearErrors(t *testing.T) {
f := newTestField("", Required())
f.Validate()
assert.True(t, f.HasError())
f.ClearErrors()
assert.False(t, f.HasError())
}
func TestFieldReset(t *testing.T) {
f := newTestField("initial", Required(), MinLen(3))
f.SetValue("changed")
f.AddError("some error")
f.Reset()
assert.Equal(t, "initial", f.String())
assert.False(t, f.HasError())
}
func TestValidateAll(t *testing.T) {
v := New()
v.Page("/", func(c *Context) {
c.Field("", Required(), MinLen(3))
c.Field("", Required(), Email())
c.View(func() h.H { return h.Div() })
// both empty → both fail
assert.False(t, c.ValidateAll())
})
v2 := New()
v2.Page("/", func(c *Context) {
c.Field("joe", Required(), MinLen(3))
c.Field("joe@x.com", Required(), Email())
c.View(func() h.H { return h.Div() })
assert.True(t, c.ValidateAll())
})
}
func TestValidateAllPartialFailure(t *testing.T) {
v := New()
v.Page("/", func(c *Context) {
good := c.Field("valid", Required())
bad := c.Field("", Required())
c.View(func() h.H { return h.Div() })
ok := c.ValidateAll()
assert.False(t, ok)
assert.False(t, good.HasError())
assert.True(t, bad.HasError())
})
}
func TestValidateAllSelectiveArgs(t *testing.T) {
v := New()
v.Page("/", func(c *Context) {
a := c.Field("", Required())
b := c.Field("ok", Required())
cField := c.Field("", Required())
c.View(func() h.H { return h.Div() })
// only validate a and b — cField should be untouched
ok := c.ValidateAll(a, b)
assert.False(t, ok)
assert.True(t, a.HasError())
assert.False(t, b.HasError())
assert.False(t, cField.HasError(), "unselected field should not be validated")
})
}
func TestResetFields(t *testing.T) {
v := New()
v.Page("/", func(c *Context) {
a := c.Field("a", Required())
b := c.Field("b", Required())
c.View(func() h.H { return h.Div() })
a.SetValue("changed-a")
b.SetValue("changed-b")
a.AddError("err")
c.ResetFields()
assert.Equal(t, "a", a.String())
assert.Equal(t, "b", b.String())
assert.False(t, a.HasError())
})
}
func TestResetFieldsSelectiveArgs(t *testing.T) {
v := New()
v.Page("/", func(c *Context) {
a := c.Field("a")
b := c.Field("b")
c.View(func() h.H { return h.Div() })
a.SetValue("changed-a")
b.SetValue("changed-b")
// only reset a
c.ResetFields(a)
assert.Equal(t, "a", a.String())
assert.Equal(t, "changed-b", b.String(), "unselected field should not be reset")
})
}
func TestFieldValidateClearsPreviousErrors(t *testing.T) {
f := newTestField("", Required())
f.Validate()
assert.True(t, f.HasError())
f.SetValue("ok")
f.Validate()
assert.False(t, f.HasError())
}
func TestFieldCustomValidator(t *testing.T) {
f := newTestField("bad", Custom(func(val string) error {
if val == "bad" {
return fmt.Errorf("no bad words")
}
return nil
}))
assert.False(t, f.Validate())
assert.Equal(t, "no bad words", f.FirstError())
f.SetValue("good")
assert.True(t, f.Validate())
}

33
go.mod
View File

@@ -1,4 +1,4 @@
module github.com/go-via/via
module github.com/ryanhamamura/via
go 1.25.4
@@ -6,20 +6,37 @@ require maragu.dev/gomponents v1.2.0
require (
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de
github.com/alexedwards/scs/v2 v2.9.0
github.com/delaneyj/toolbelt v0.9.1
github.com/mattn/go-sqlite3 v1.14.32
github.com/nats-io/nats.go v1.48.0
github.com/rs/zerolog v1.34.0
github.com/starfederation/datastar-go v1.0.3
github.com/stretchr/testify v1.10.0
github.com/stretchr/testify v1.11.1
golang.org/x/time v0.14.0
)
require (
github.com/CAFxX/httpcompression v0.0.9 // indirect
github.com/alexedwards/scs/v2 v2.9.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/kr/pretty v0.1.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/antithesishq/antithesis-sdk-go v0.5.0 // indirect
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/google/go-tpm v0.9.7 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 // indirect
github.com/nats-io/jwt/v2 v2.8.0 // indirect
github.com/nats-io/nats-server/v2 v2.12.2 // indirect
github.com/nats-io/nkeys v0.4.12 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/sys v0.38.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

75
go.sum
View File

@@ -2,32 +2,71 @@ github.com/CAFxX/httpcompression v0.0.9 h1:0ue2X8dOLEpxTm8tt+OdHcgA+gbDge0OqFQWG
github.com/CAFxX/httpcompression v0.0.9/go.mod h1:XX8oPZA+4IDcfZ0A71Hz0mZsv/YJOgYygkFhizVPilM=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de h1:c72K9HLu6K442et0j3BUL/9HEYaUJouLkkVANdmqTOo=
github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de/go.mod h1:Iyk7S76cxGaiEX/mSYmTZzYehp4KfyylcLaV3OnToss=
github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90=
github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/antithesishq/antithesis-sdk-go v0.5.0 h1:cudCFF83pDDANcXFzkQPUHHedfnnIbUO3JMr9fqwFJs=
github.com/antithesishq/antithesis-sdk-go v0.5.0/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E=
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/delaneyj/toolbelt v0.9.1 h1:QJComn2qoaQ4azl5uRkGpdHSO9e+JtoxDTXCiQHvH8o=
github.com/delaneyj/toolbelt v0.9.1/go.mod h1:eNXpPuThjTD4tpRNCBl4JEz9jdg9LpyzNuyG+stnIbs=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f h1:jopqB+UTSdJGEJT8tEqYyE29zN91fi2827oLET8tl7k=
github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f/go.mod h1:nOPhAkwVliJdNTkj3gXpljmWhjc4wCaVqbMJcPKWP4s=
github.com/google/go-tpm v0.9.7 h1:u89J4tUUeDTlH8xxC3CTW7OHZjbjKoHdQ9W7gCUhtxA=
github.com/google/go-tpm v0.9.7/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 h1:KGuD/pM2JpL9FAYvBrnBBeENKZNh6eNtjqytV6TYjnk=
github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
github.com/nats-io/jwt/v2 v2.8.0 h1:K7uzyz50+yGZDO5o772eRE7atlcSEENpL7P+b74JV1g=
github.com/nats-io/jwt/v2 v2.8.0/go.mod h1:me11pOkwObtcBNR8AiMrUbtVOUGkqYjMQZ6jnSdVUIA=
github.com/nats-io/nats-server/v2 v2.12.2 h1:4TEQd0Y4zvcW0IsVxjlXnRso1hBkQl3TS0BI+SxgPhE=
github.com/nats-io/nats-server/v2 v2.12.2/go.mod h1:j1AAttYeu7WnvD8HLJ+WWKNMSyxsqmZ160pNtCQRMyE=
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ=
github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/starfederation/datastar-go v1.0.3 h1:DnzgsJ6tDHDM6y5Nxsk0AGW/m8SyKch2vQg3P1xGTcU=
github.com/starfederation/datastar-go v1.0.3/go.mod h1:stm83LQkhZkwa5GzzdPEN6dLuu8FVwxIv0w1DYkbD3w=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -36,8 +75,8 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
@@ -45,9 +84,19 @@ github.com/valyala/gozstd v1.20.1 h1:xPnnnvjmaDDitMFfDxmQ4vpx0+3CdTg2o3lALvXTU/g
github.com/valyala/gozstd v1.20.1/go.mod h1:y5Ew47GLlP37EkTB+B4s7r6A5rdaeB7ftbl9zoYiIPQ=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -11,3 +11,11 @@ func DataEffect(expression string) H {
func DataIgnoreMorph() H {
return Attr("data-ignore-morph")
}
// DataViewTransition sets the view-transition-name CSS property on an element
// via an inline style. Elements with matching names animate between pages
// during SPA navigation. If the element also needs other inline styles,
// include view-transition-name directly in the Style() call instead.
func DataViewTransition(name string) H {
return Attr("style", "view-transition-name: "+name)
}

View File

@@ -3,8 +3,8 @@ package main
import (
"math/rand"
"github.com/go-via/via"
"github.com/go-via/via/h"
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
)
var (
@@ -22,7 +22,7 @@ func main() {
v.Config(via.Options{
DevMode: true,
DocumentTitle: "ViaChat",
LogLvl: via.LogLevelInfo,
LogLevel: via.LogLevelInfo,
})
v.AppendToHead(

View File

@@ -1,8 +1,8 @@
package main
import (
"github.com/go-via/via"
"github.com/go-via/via/h"
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
)
type Counter struct{ Count int }

View File

@@ -1,8 +1,8 @@
package main
import (
"github.com/go-via/via"
"github.com/go-via/via/h"
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
)
func main() {

View File

@@ -1,8 +1,8 @@
package main
import (
"github.com/go-via/via"
"github.com/go-via/via/h"
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
)
func main() {

View File

@@ -0,0 +1,74 @@
package main
import (
"fmt"
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
)
const gridSize = 8
func main() {
v := via.New()
v.Config(via.Options{DocumentTitle: "Keyboard", ServerAddress: ":7331"})
v.Page("/", func(c *via.Context) {
x, y := 0, 0
dir := c.Signal("")
move := c.Action(func() {
switch dir.String() {
case "up":
y = max(0, y-1)
case "down":
y = min(gridSize-1, y+1)
case "left":
x = max(0, x-1)
case "right":
x = min(gridSize-1, x+1)
}
c.Sync()
})
c.View(func() h.H {
var rows []h.H
for row := range gridSize {
var cells []h.H
for col := range gridSize {
bg := "#e0e0e0"
if col == x && row == y {
bg = "#4a90d9"
}
cells = append(cells, h.Div(
h.Attr("style", fmt.Sprintf(
"width:48px;height:48px;background:%s;border:1px solid #ccc;",
bg,
)),
))
}
rows = append(rows, h.Div(
append([]h.H{h.Attr("style", "display:flex;")}, cells...)...,
))
}
return h.Div(
h.H1(h.Text("Keyboard Grid")),
h.P(h.Text("Move with WASD or arrow keys")),
h.Div(rows...),
via.OnKeyDownMap(
via.KeyBind("w", move, via.WithSignal(dir, "up")),
via.KeyBind("a", move, via.WithSignal(dir, "left")),
via.KeyBind("s", move, via.WithSignal(dir, "down")),
via.KeyBind("d", move, via.WithSignal(dir, "right")),
via.KeyBind("ArrowUp", move, via.WithSignal(dir, "up"), via.WithPreventDefault()),
via.KeyBind("ArrowLeft", move, via.WithSignal(dir, "left"), via.WithPreventDefault()),
via.KeyBind("ArrowDown", move, via.WithSignal(dir, "down"), via.WithPreventDefault()),
via.KeyBind("ArrowRight", move, via.WithSignal(dir, "right"), via.WithPreventDefault()),
),
)
})
})
v.Start()
}

View File

@@ -1,9 +1,9 @@
package main
import (
"github.com/go-via/via"
"github.com/ryanhamamura/via"
// "github.com/go-via/via-plugin-picocss/picocss"
"github.com/go-via/via/h"
"github.com/ryanhamamura/via/h"
)
type Counter struct{ Count int }
@@ -14,7 +14,7 @@ func main() {
v.Config(via.Options{
DocumentTitle: "Live Reload Demo",
DevMode: true,
LogLvl: via.LogLevelDebug,
LogLevel: via.LogLevelDebug,
Plugins: []via.Plugin{
// picocss.Default
},

View File

@@ -0,0 +1,151 @@
package main
import (
"fmt"
"time"
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
)
func main() {
v := via.New()
v.Config(via.Options{
ServerAddress: ":8080",
DocumentTitle: "Middleware Example",
})
// --- Middleware definitions ---
// requestLogger logs every page request to stdout.
requestLogger := func(c *via.Context, next func()) {
fmt.Printf("[%s] request\n", time.Now().Format("15:04:05"))
next()
}
// authRequired redirects unauthenticated users to /login.
authRequired := func(c *via.Context, next func()) {
if c.Session().GetString("role") == "" {
c.RedirectView("/login")
return
}
next()
}
// auditLog prints the authenticated username to stdout.
auditLog := func(c *via.Context, next func()) {
fmt.Printf("[audit] user=%s\n", c.Session().GetString("username"))
next()
}
// superAdminOnly rejects non-superadmin users with a forbidden view.
superAdminOnly := func(c *via.Context, next func()) {
if c.Session().GetString("role") != "superadmin" {
c.View(func() h.H {
return h.Div(
h.H1(h.Text("Forbidden")),
h.P(h.Text("Super-admin access required.")),
h.A(h.Href("/admin/dashboard"), h.Text("Back to dashboard")),
)
})
return
}
next()
}
// --- Route registration ---
v.Use(requestLogger) // global middleware
admin := v.Group("/admin", authRequired) // prefixed group
admin.Use(auditLog) // Group.Use()
superAdmin := admin.Group("/super", superAdminOnly) // nested group
// Public: redirect root to login
v.Page("/", func(c *via.Context) {
c.View(func() h.H {
c.Redirect("/login")
return h.Div()
})
})
// Public: login page with role-selection buttons
v.Page("/login", func(c *via.Context) {
loginAdmin := c.Action(func() {
c.Session().Set("role", "admin")
c.Session().Set("username", "alice")
c.Session().RenewToken()
c.Redirect("/admin/dashboard")
})
loginSuper := c.Action(func() {
c.Session().Set("role", "superadmin")
c.Session().Set("username", "bob")
c.Session().RenewToken()
c.Redirect("/admin/dashboard")
})
c.View(func() h.H {
return h.Div(
h.H1(h.Text("Login")),
h.P(h.Text("Choose a role:")),
h.Button(h.Text("Login as Admin"), loginAdmin.OnClick()),
h.Raw(" "),
h.Button(h.Text("Login as Super Admin"), loginSuper.OnClick()),
)
})
})
// Per-action middleware: only superadmins can invoke this action.
requireSuperAdmin := func(c *via.Context, next func()) {
if c.Session().GetString("role") != "superadmin" {
return
}
next()
}
// Admin: dashboard (requires authRequired + auditLog)
admin.Page("/dashboard", func(c *via.Context) {
logout := c.Action(func() {
c.Session().Delete("role")
c.Session().Delete("username")
c.Redirect("/login")
})
dangerAction := c.Action(func() {
fmt.Printf("[danger] executed by %s\n", c.Session().GetString("username"))
c.Sync()
}, via.WithMiddleware(requireSuperAdmin))
c.View(func() h.H {
username := c.Session().GetString("username")
role := c.Session().GetString("role")
return h.Div(
h.H1(h.Textf("Dashboard — %s (%s)", username, role)),
h.Ul(
h.Li(h.A(h.Href("/admin/super/settings"), h.Text("Super Admin Settings"))),
),
h.H2(h.Text("Danger Zone")),
h.P(h.Text("This action is protected by per-action middleware (superadmin only):")),
h.Button(h.Text("Delete Everything"), dangerAction.OnClick()),
h.Br(),
h.Br(),
h.Button(h.Text("Logout"), logout.OnClick()),
)
})
})
// Super-admin: settings (requires authRequired + auditLog + superAdminOnly)
superAdmin.Page("/settings", func(c *via.Context) {
c.View(func() h.H {
username := c.Session().GetString("username")
return h.Div(
h.H1(h.Textf("Super Admin Settings — %s", username)),
h.P(h.Text("Only super-admins can see this page.")),
h.A(h.Href("/admin/dashboard"), h.Text("Back to dashboard")),
)
})
})
v.Start()
}

View File

@@ -0,0 +1,109 @@
# NATS Chatroom Example (Embedded)
A chatroom built with Via and an **embedded NATS server**, demonstrating pub/sub messaging as an alternative to the custom `Rooms` implementation in `../chatroom`.
Uses `delaneyj/toolbelt/embeddednats` to run NATS inside the same binary - no external server required.
## Key Differences from Original Chatroom
| Aspect | Original (`../chatroom`) | This Example |
|--------|-------------------------|--------------|
| Pub/sub | Custom `Rooms` struct (~160 lines) | NATS subjects |
| Member tracking | Manual `map[TU]Syncable` | NATS handles subscribers |
| Publish timing | Ticker every 100ms + dirty flag | Instant delivery |
| Durability | None (in-memory) | JetStream persists to disk |
| Multi-instance | Not supported | Works across server instances |
| External deps | None | **None** (NATS embedded in binary) |
## Run the Example
```bash
go run ./internal/examples/nats-chatroom
```
That's it. No separate NATS server needed.
Open multiple browser tabs at http://localhost:7331 to see messages broadcast across all clients.
## How Embedded NATS Works
```go
// Start embedded NATS server (JetStream enabled by default)
ns, err := embeddednats.New(ctx,
embeddednats.WithDirectory("./data/nats"),
)
ns.WaitForServer()
// Get client connection to embedded server
nc, err := ns.Client()
```
Data is persisted to `./data/nats/` for JetStream durability.
## Architecture
```
┌─────────────────────────────────────────────────────────┐
│ Single Binary │
│ │
│ Browser A Embedded NATS Browser B │
│ │ │ │ │
│ │-- Via Action ---> │ │ │
│ │ (Send msg) │ │ │
│ │ │ │ │
│ │ nc.Publish() │ │
│ │ "chat.room.Go" │ │
│ │ │ │ │
│ │<-- Subscribe -----|---- Subscribe --->│ │
│ │ callback │ callback │ │
│ │ │ │ │
│ │-- c.Sync() ------>│<--- c.Sync() -----| │
│ │ (SSE) │ (SSE) │ │
│ │
└─────────────────────────────────────────────────────────┘
```
## JetStream Durability
Messages persist to disk via JetStream:
```go
js.AddStream(&nats.StreamConfig{
Name: "CHAT",
Subjects: []string{"chat.>"},
MaxMsgs: 1000, // Keep last 1000 messages
MaxAge: 24 * time.Hour,
})
```
Stop and restart the app - chat history survives.
## Code Comparison
**Original chatroom - 160+ lines of custom pub/sub:**
- `Rooms` struct with named rooms
- `Room` with member tracking, mutex, dirty flag
- Ticker-based publish loop
- Manual join/leave channels
**This example - ~60 lines of NATS integration:**
- `embeddednats.New()` starts the server
- `nc.Subscribe(subject, handler)` for receiving
- `nc.Publish(subject, data)` for sending
- NATS handles delivery, no polling
## Next Steps
If this pattern proves useful, it could be promoted to a Via plugin:
```go
// Hypothetical future API
v.Config(via.WithEmbeddedNATS("./data/nats"))
// In page init
c.Subscribe("events.user.*", func(data []byte) {
c.Sync()
})
c.Publish("events.user.login", userData)
```

View File

@@ -0,0 +1,153 @@
body { margin: 0; }
/* Layout navbar */
.app-nav {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem 1rem;
background: var(--pico-card-background-color);
border-bottom: 1px solid var(--pico-muted-border-color);
}
.app-nav .brand {
font-weight: 700;
text-decoration: none;
margin-right: auto;
}
.nav-links {
display: flex;
gap: 1rem;
}
.nav-links a {
text-decoration: none;
font-size: 0.875rem;
}
/* Chat page */
.chat-page {
display: flex;
flex-direction: column;
height: calc(100vh - 53px);
}
nav[role="tab-control"] ul li a[aria-current="page"] {
background-color: var(--pico-primary-background);
color: var(--pico-primary-inverse);
border-bottom: 2px solid var(--pico-primary);
}
.chat-message { display: flex; gap: 0.75rem; margin-bottom: 0.5rem; }
.avatar {
width: 2rem;
height: 2rem;
border-radius: 50%;
background: var(--pico-muted-border-color);
display: grid;
place-items: center;
font-size: 1.5rem;
flex-shrink: 0;
}
.avatar-lg {
width: 3rem;
height: 3rem;
font-size: 2rem;
}
.bubble { flex: 1; }
.bubble p { margin: 0; }
.chat-history {
flex: 1;
overflow-y: auto;
padding: 1rem;
padding-bottom: calc(88px + env(safe-area-inset-bottom));
}
.chat-input {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: var(--pico-background-color);
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom));
border-top: 1px solid var(--pico-muted-border-color);
}
.chat-input fieldset {
flex: 1;
margin: 0;
}
/* NATS badge with status dot */
.nats-badge {
background: #27AAE1;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
margin-left: auto;
display: flex;
align-items: center;
gap: 0.375rem;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #4ade80;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* Profile page */
.profile-page {
max-width: 480px;
margin: 0 auto;
padding: 2rem 1rem;
}
.profile-preview {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
padding: 1rem;
background: var(--pico-card-background-color);
border-radius: 8px;
}
.preview-name {
font-size: 1.125rem;
font-weight: 600;
}
.profile-form label {
display: block;
margin-bottom: 0.5rem;
}
.emoji-grid {
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: 0.25rem;
margin-bottom: 1.5rem;
}
.emoji-option {
padding: 0.375rem;
font-size: 1.25rem;
border: 2px solid transparent;
border-radius: 8px;
background: none;
cursor: pointer;
text-align: center;
}
.emoji-option:hover {
background: var(--pico-muted-border-color);
}
.emoji-selected {
border-color: var(--pico-primary);
background: var(--pico-primary-focus);
}
.profile-actions {
display: flex;
gap: 0.75rem;
}
.field-error {
color: var(--pico-del-color);
}

View File

@@ -0,0 +1,148 @@
package main
import (
"sync"
"time"
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
)
var (
WithSignal = via.WithSignal
)
func ChatPage(c *via.Context) {
currentUser := UserInfo{
Name: c.Session().GetString(SessionKeyUsername),
Emoji: c.Session().GetString(SessionKeyEmoji),
}
roomSignal := c.Signal("Go")
statement := c.Signal("")
var messages []ChatMessage
var messagesMu sync.Mutex
currentRoom := "Go"
var currentSub via.Subscription
subscribeToRoom := func(room string) {
if currentSub != nil {
currentSub.Unsubscribe()
}
subject := "chat.room." + room
if hist, err := via.ReplayHistory[ChatMessage](v, subject, 50); err == nil {
messages = hist
}
sub, _ := via.Subscribe(c, subject, func(msg ChatMessage) {
messagesMu.Lock()
messages = append(messages, msg)
if len(messages) > 50 {
messages = messages[len(messages)-50:]
}
messagesMu.Unlock()
c.Sync()
})
currentSub = sub
currentRoom = room
}
subscribeToRoom("Go")
// Heartbeat — keeps connected indicator alive
connected := true
c.OnInterval(30*time.Second, func() {
connected = true
c.Sync()
})
switchRoom := c.Action(func() {
newRoom := roomSignal.String()
if newRoom != currentRoom {
messagesMu.Lock()
messages = nil
messagesMu.Unlock()
subscribeToRoom(newRoom)
c.Sync()
}
})
say := c.Action(func() {
msg := statement.String()
if msg == "" {
msg = randomDevQuote()
}
statement.SetValue("")
via.Publish(c, "chat.room."+currentRoom, ChatMessage{
User: currentUser,
Message: msg,
Time: time.Now().UnixMilli(),
})
})
c.View(func() h.H {
var tabs []h.H
for _, name := range roomNames {
isCurrent := name == currentRoom
tabs = append(tabs, h.Li(
h.A(
h.If(isCurrent, h.Attr("aria-current", "page")),
h.Text(name),
switchRoom.OnClick(WithSignal(roomSignal, name)),
),
))
}
messagesMu.Lock()
chatHistoryChildren := []h.H{
h.Class("chat-history"),
h.Script(h.Raw(`new MutationObserver(()=>scrollChatToBottom()).observe(document.querySelector('.chat-history'), {childList:true})`)),
}
for _, msg := range messages {
chatHistoryChildren = append(chatHistoryChildren,
h.Div(h.Class("chat-message"),
h.Div(h.Class("avatar"), h.Attr("title", msg.User.Name), h.Text(msg.User.Emoji)),
h.Div(h.Class("bubble"),
h.P(h.Text(msg.Message)),
),
),
)
}
messagesMu.Unlock()
_ = connected
return h.Div(h.Class("chat-page"),
h.Nav(
h.Attr("role", "tab-control"),
h.Ul(tabs...),
h.Span(h.Class("nats-badge"),
h.Span(h.Class("status-dot")),
h.Text("NATS"),
),
),
h.Div(chatHistoryChildren...),
h.Div(
h.Class("chat-input"),
h.DataIgnoreMorph(),
currentUser.Avatar(),
h.FieldSet(
h.Attr("role", "group"),
h.Input(
h.Type("text"),
h.Placeholder(currentUser.Name+" says..."),
statement.Bind(),
h.Attr("autofocus"),
say.OnKeyDown("Enter"),
),
h.Button(h.Text("Send"), say.OnClick()),
),
),
)
})
}

View File

@@ -0,0 +1,81 @@
package main
import (
_ "embed"
"log"
"time"
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
)
//go:embed chat.css
var chatCSS string
var v *via.V
func main() {
v = via.New()
v.Config(via.Options{
DevMode: true,
DocumentTitle: "NATS Chat",
LogLevel: via.LogLevelInfo,
ServerAddress: ":7331",
})
err := via.EnsureStream(v, via.StreamConfig{
Name: "CHAT",
Subjects: []string{"chat.>"},
MaxMsgs: 1000,
MaxAge: 24 * time.Hour,
})
if err != nil {
log.Fatalf("Failed to ensure stream: %v", err)
}
v.AppendToHead(
h.Link(h.Rel("stylesheet"), h.Href("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css")),
h.StyleEl(h.Raw(chatCSS)),
h.Script(h.Raw(`
function scrollChatToBottom() {
const chatHistory = document.querySelector('.chat-history');
if (chatHistory) chatHistory.scrollTop = chatHistory.scrollHeight;
}
`)),
)
v.Layout(func(content func() h.H) h.H {
return h.Div(
h.Nav(h.Class("app-nav"),
h.A(h.Href("/"), h.Class("brand"), h.Text("NATS Chat")),
h.Div(h.Class("nav-links"),
h.A(h.Href("/"), h.Text("Chat")),
h.A(h.Href("/profile"), h.Text("Profile")),
),
),
h.Main(h.Class("container"),
h.DataViewTransition("page-content"),
content(),
),
)
})
// Profile page — public, no auth required
v.Page("/profile", ProfilePage)
// Auth middleware — redirects to profile if no identity set
requireProfile := func(c *via.Context, next func()) {
if c.Session().GetString(SessionKeyUsername) == "" {
c.RedirectView("/profile")
return
}
next()
}
// Chat page — protected by profile middleware
protected := v.Group("", requireProfile)
protected.Page("/", ChatPage)
log.Println("Starting NATS chatroom on :7331 (embedded NATS server)")
v.Start()
}

View File

@@ -0,0 +1,110 @@
package main
import (
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
)
func ProfilePage(c *via.Context) {
existingName := c.Session().GetString(SessionKeyUsername)
existingEmoji := c.Session().GetString(SessionKeyEmoji)
if existingEmoji == "" {
existingEmoji = emojiChoices[0]
}
nameField := c.Field(existingName,
via.Required("Display name is required"),
via.MinLen(2, "Must be at least 2 characters"),
via.MaxLen(20, "Must be at most 20 characters"),
)
selectedEmoji := c.Signal(existingEmoji)
saveToSession := func() bool {
if !c.ValidateAll() {
c.Sync()
return false
}
c.Session().Set(SessionKeyUsername, nameField.String())
c.Session().Set(SessionKeyEmoji, selectedEmoji.String())
return true
}
save := c.Action(func() {
saveToSession()
})
saveAndChat := c.Action(func() {
if saveToSession() {
c.Navigate("/", false)
}
})
c.View(func() h.H {
// Emoji grid
emojiGrid := []h.H{h.Class("emoji-grid")}
for _, emoji := range emojiChoices {
cls := "emoji-option"
if emoji == selectedEmoji.String() {
cls += " emoji-selected"
}
emojiGrid = append(emojiGrid,
h.Button(
h.Class(cls),
h.Type("button"),
h.Text(emoji),
save.OnClick(WithSignal(selectedEmoji, emoji)),
),
)
}
// Action buttons — "Start Chatting" only if editing is meaningful
actionButtons := []h.H{h.Class("profile-actions")}
if existingName != "" {
actionButtons = append(actionButtons,
h.Button(h.Text("Save"), save.OnClick(), h.Class("secondary")),
)
}
actionButtons = append(actionButtons,
h.Button(h.Text("Start Chatting"), saveAndChat.OnClick()),
)
previewName := nameField.String()
if previewName == "" {
previewName = "Your Name"
}
return h.Div(h.Class("profile-page"),
h.H2(h.Text("Your Profile"), h.DataViewTransition("page-title")),
// Live preview
h.Div(h.Class("profile-preview"),
h.Div(h.Class("avatar avatar-lg"), h.Text(selectedEmoji.String())),
h.Span(h.Class("preview-name"), h.Text(previewName)),
),
h.Div(h.Class("profile-form"),
// Name field
h.Label(h.Text("Display Name"),
h.Input(
h.Type("text"),
h.Placeholder("Enter a display name"),
nameField.Bind(),
h.Attr("autofocus"),
saveAndChat.OnKeyDown("Enter"),
h.If(nameField.HasError(), h.Attr("aria-invalid", "true")),
),
h.If(nameField.HasError(),
h.Small(h.Class("field-error"), h.Text(nameField.FirstError())),
),
),
// Emoji picker
h.Label(h.Text("Choose an Avatar")),
h.Div(emojiGrid...),
// Actions
h.Div(actionButtons...),
),
)
})
}

View File

@@ -0,0 +1,30 @@
package main
import "github.com/ryanhamamura/via/h"
const (
SessionKeyUsername = "username"
SessionKeyEmoji = "emoji"
)
type ChatMessage struct {
User UserInfo `json:"user"`
Message string `json:"message"`
Time int64 `json:"time"`
}
type UserInfo struct {
Name string `json:"name"`
Emoji string `json:"emoji"`
}
func (u *UserInfo) Avatar() h.H {
return h.Div(h.Class("avatar"), h.Attr("title", u.Name), h.Text(u.Emoji))
}
var roomNames = []string{"Go", "Rust", "Python", "JavaScript", "Clojure"}
var emojiChoices = []string{
"🐼", "🐯", "🦅", "🐬", "🦊", "🐺", "🐻", "🦦", "🦁", "🐸",
"🦄", "🐙", "🦀", "🐝", "🦋", "🐢", "🦉", "🐳", "🦈", "🐧",
}

View File

@@ -0,0 +1,22 @@
package main
import "math/rand"
var quoteIdx = rand.Intn(len(devQuotes))
var devQuotes = []string{
"Just use NATS.",
"Pub/sub all the things!",
"Messages are the new API.",
"JetStream for durability.",
"No more polling.",
"Event-driven architecture FTW.",
"Decouple everything.",
"NATS is fast.",
"Subjects are like topics.",
"Request-reply is cool.",
}
func randomDevQuote() string {
quoteIdx = (quoteIdx + 1) % len(devQuotes)
return devQuotes[quoteIdx]
}

View File

@@ -3,9 +3,9 @@ package main
import (
"strconv"
"github.com/go-via/via"
"github.com/ryanhamamura/via"
// "github.com/go-via/via-plugin-picocss/picocss"
. "github.com/go-via/via/h"
. "github.com/ryanhamamura/via/h"
)
func main() {

View File

@@ -1,9 +1,9 @@
package main
import (
"github.com/go-via/via"
"github.com/ryanhamamura/via"
// "github.com/go-via/via-plugin-picocss/picocss"
"github.com/go-via/via/h"
"github.com/ryanhamamura/via/h"
)
type Counter struct{ Count int }

View File

@@ -4,8 +4,8 @@ import (
_ "embed"
"net/http"
"github.com/go-via/via"
"github.com/go-via/via/h"
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
)
// Example of a Via application with a plugin that adds PicoCSS. The plugin

View File

@@ -0,0 +1,273 @@
package main
import (
"crypto/rand"
"fmt"
"html"
"log"
"sync"
"time"
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
)
var WithSignal = via.WithSignal
type Bookmark struct {
ID string
Title string
URL string
}
type CRUDEvent struct {
Action string `json:"action"`
Title string `json:"title"`
UserID string `json:"user_id"`
}
var (
bookmarks []Bookmark
bookmarksMu sync.RWMutex
)
func randomHex(n int) string {
b := make([]byte, n)
rand.Read(b)
return fmt.Sprintf("%x", b)
}
func findBookmark(id string) (Bookmark, int) {
for i, bm := range bookmarks {
if bm.ID == id {
return bm, i
}
}
return Bookmark{}, -1
}
func main() {
v := via.New()
v.Config(via.Options{
DevMode: true,
DocumentTitle: "Bookmarks",
LogLevel: via.LogLevelInfo,
ServerAddress: ":7331",
})
err := via.EnsureStream(v, via.StreamConfig{
Name: "BOOKMARKS",
Subjects: []string{"bookmarks.>"},
MaxMsgs: 1000,
MaxAge: 24 * time.Hour,
})
if err != nil {
log.Fatalf("Failed to ensure stream: %v", err)
}
v.AppendToHead(
h.Link(h.Rel("stylesheet"), h.Href("https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css")),
h.Script(h.Src("https://cdn.tailwindcss.com")),
)
v.Page("/", func(c *via.Context) {
userID := randomHex(8)
titleSignal := c.Signal("")
urlSignal := c.Signal("")
targetIDSignal := c.Signal("")
via.Subscribe(c, "bookmarks.events", func(evt CRUDEvent) {
if evt.UserID == userID {
return
}
safeTitle := html.EscapeString(evt.Title)
var alertClass string
switch evt.Action {
case "created":
alertClass = "alert-success"
case "updated":
alertClass = "alert-info"
case "deleted":
alertClass = "alert-error"
}
c.ExecScript(fmt.Sprintf(`(function(){
var tc = document.getElementById('toast-container');
if (!tc) return;
var d = document.createElement('div');
d.className = 'alert %s';
d.innerHTML = '<span>Bookmark "%s" %s</span>';
tc.appendChild(d);
setTimeout(function(){ d.remove(); }, 3000);
})()`, alertClass, safeTitle, evt.Action))
c.Sync()
})
save := c.Action(func() {
title := titleSignal.String()
url := urlSignal.String()
if title == "" || url == "" {
return
}
targetID := targetIDSignal.String()
action := "created"
bookmarksMu.Lock()
if targetID != "" {
if _, idx := findBookmark(targetID); idx >= 0 {
bookmarks[idx].Title = title
bookmarks[idx].URL = url
action = "updated"
}
} else {
bookmarks = append(bookmarks, Bookmark{
ID: randomHex(8),
Title: title,
URL: url,
})
}
bookmarksMu.Unlock()
titleSignal.SetValue("")
urlSignal.SetValue("")
targetIDSignal.SetValue("")
via.Publish(c, "bookmarks.events", CRUDEvent{
Action: action,
Title: title,
UserID: userID,
})
c.Sync()
})
edit := c.Action(func() {
id := targetIDSignal.String()
bookmarksMu.RLock()
bm, idx := findBookmark(id)
bookmarksMu.RUnlock()
if idx < 0 {
return
}
titleSignal.SetValue(bm.Title)
urlSignal.SetValue(bm.URL)
})
del := c.Action(func() {
id := targetIDSignal.String()
bookmarksMu.Lock()
bm, idx := findBookmark(id)
if idx >= 0 {
bookmarks = append(bookmarks[:idx], bookmarks[idx+1:]...)
}
bookmarksMu.Unlock()
if idx < 0 {
return
}
targetIDSignal.SetValue("")
via.Publish(c, "bookmarks.events", CRUDEvent{
Action: "deleted",
Title: bm.Title,
UserID: userID,
})
c.Sync()
})
cancelEdit := c.Action(func() {
titleSignal.SetValue("")
urlSignal.SetValue("")
targetIDSignal.SetValue("")
})
c.View(func() h.H {
isEditing := targetIDSignal.String() != ""
// Build table rows
bookmarksMu.RLock()
var rows []h.H
for _, bm := range bookmarks {
rows = append(rows, h.Tr(
h.Td(h.Text(bm.Title)),
h.Td(h.A(h.Href(bm.URL), h.Attr("target", "_blank"), h.Class("link link-primary"), h.Text(bm.URL))),
h.Td(
h.Div(h.Class("flex gap-1"),
h.Button(h.Class("btn btn-xs btn-ghost"), h.Text("Edit"),
edit.OnClick(WithSignal(targetIDSignal, bm.ID)),
),
h.Button(h.Class("btn btn-xs btn-ghost text-error"), h.Text("Delete"),
del.OnClick(WithSignal(targetIDSignal, bm.ID)),
),
),
),
))
}
bookmarksMu.RUnlock()
saveLabel := "Add Bookmark"
if isEditing {
saveLabel = "Update Bookmark"
}
return h.Div(h.Class("min-h-screen bg-base-200"),
// Navbar
h.Div(h.Class("navbar bg-base-100 shadow-sm"),
h.Div(h.Class("flex-1"),
h.A(h.Class("btn btn-ghost text-xl"), h.Text("Bookmarks")),
),
h.Div(h.Class("flex-none"),
h.Div(h.Class("badge badge-outline"), h.Text(userID[:8])),
),
),
h.Div(h.Class("container mx-auto p-4 max-w-3xl flex flex-col gap-4"),
// Form card
h.Div(h.Class("card bg-base-100 shadow"),
h.Div(h.Class("card-body"),
h.H2(h.Class("card-title"), h.Text(saveLabel)),
h.Div(h.Class("flex flex-col gap-2"),
h.Input(h.Class("input input-bordered w-full"), h.Type("text"), h.Placeholder("Title"), titleSignal.Bind()),
h.Input(h.Class("input input-bordered w-full"), h.Type("text"), h.Placeholder("https://example.com"), urlSignal.Bind()),
h.Div(h.Class("card-actions justify-end"),
h.If(isEditing,
h.Button(h.Class("btn btn-ghost"), h.Text("Cancel"), cancelEdit.OnClick()),
),
h.Button(h.Class("btn btn-primary"), h.Text(saveLabel), save.OnClick()),
),
),
),
),
// Table card
h.Div(h.Class("card bg-base-100 shadow"),
h.Div(h.Class("card-body"),
h.H2(h.Class("card-title"), h.Text("All Bookmarks")),
h.If(len(rows) == 0,
h.P(h.Class("text-base-content/60"), h.Text("No bookmarks yet. Add one above!")),
),
h.If(len(rows) > 0,
h.Div(h.Class("overflow-x-auto"),
h.Table(h.Class("table"),
h.THead(h.Tr(
h.Th(h.Text("Title")),
h.Th(h.Text("URL")),
h.Th(h.Text("Actions")),
)),
h.TBody(rows...),
),
),
),
),
),
),
// Toast container — ignored by morph so Sync() doesn't wipe active toasts
h.Div(h.ID("toast-container"), h.Class("toast toast-end toast-top"), h.DataIgnoreMorph()),
)
})
})
log.Println("Starting pubsub-crud example on :7331")
v.Start()
}

View File

@@ -5,16 +5,16 @@ import (
"math/rand"
"time"
"github.com/go-via/via"
"github.com/ryanhamamura/via"
// "github.com/go-via/via-plugin-picocss/picocss"
"github.com/go-via/via/h"
"github.com/ryanhamamura/via/h"
)
func main() {
v := via.New()
v.Config(via.Options{
LogLvl: via.LogLevelDebug,
LogLevel: via.LogLevelDebug,
DevMode: true,
Plugins: []via.Plugin{
// picocss.Default,
@@ -37,29 +37,33 @@ func main() {
return 1000 / time.Duration(refreshRate.Int()) * time.Millisecond
}
updateData := c.OnInterval(computedTickDuration(), func() {
ts := time.Now().UnixMilli()
val := rand.ExpFloat64() * 10
var stopUpdate func()
startInterval := func() {
stopUpdate = c.OnInterval(computedTickDuration(), func() {
ts := time.Now().UnixMilli()
val := rand.ExpFloat64() * 10
c.ExecScript(fmt.Sprintf(`
if (myChart) {
myChart.appendData({seriesIndex: 0, data: [[%d, %f]]});
myChart.setOption({},{notMerge:false,lazyUpdate:true});
};
`, ts, val))
})
updateData.Start()
c.ExecScript(fmt.Sprintf(`
if (myChart) {
myChart.appendData({seriesIndex: 0, data: [[%d, %f]]});
myChart.setOption({},{notMerge:false,lazyUpdate:true});
};
`, ts, val))
})
}
startInterval()
updateRefreshRate := c.Action(func() {
updateData.UpdateInterval(computedTickDuration())
stopUpdate()
startInterval()
})
toggleIsLive := c.Action(func() {
isLive = isLiveSig.Bool()
if isLive {
updateData.Start()
startInterval()
} else {
updateData.Stop()
stopUpdate()
}
})
c.View(func() h.H {

View File

@@ -1,15 +1,45 @@
package main
import (
"github.com/go-via/via"
"github.com/go-via/via/h"
"database/sql"
"log"
_ "github.com/mattn/go-sqlite3"
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
)
func main() {
v := via.New()
v.Config(via.Options{ServerAddress: ":7331"})
// Open SQLite database for persistent sessions
db, err := sql.Open("sqlite3", "sessions.db")
if err != nil {
log.Fatalf("failed to open database: %v", err)
}
defer db.Close()
// Login page
// Create session manager with SQLite store
sm, err := via.NewSQLiteSessionManager(db)
if err != nil {
log.Fatalf("failed to create session manager: %v", err)
}
v := via.New()
v.Config(via.Options{
ServerAddress: ":7331",
SessionManager: sm,
})
// Auth middleware — redirects unauthenticated users to /login
authRequired := func(c *via.Context, next func()) {
if c.Session().GetString("username") == "" {
c.Session().Set("flash", "Please log in first")
c.RedirectView("/login")
return
}
next()
}
// Login page (public)
v.Page("/login", func(c *via.Context) {
flash := c.Session().PopString("flash")
usernameInput := c.Signal("")
@@ -44,8 +74,10 @@ func main() {
})
})
// Dashboard page (protected)
v.Page("/dashboard", func(c *via.Context) {
// Protected pages
protected := v.Group("", authRequired)
protected.Page("/dashboard", func(c *via.Context) {
logout := c.Action(func() {
c.Session().Set("flash", "Goodbye!")
c.Session().Delete("username")
@@ -54,14 +86,6 @@ func main() {
c.View(func() h.H {
username := c.Session().GetString("username")
// Not logged in? Redirect to login
if username == "" {
c.Session().Set("flash", "Please log in first")
c.Redirect("/login")
return h.Div()
}
flash := c.Session().PopString("flash")
var flashMsg h.H
if flash != "" {

View File

@@ -6,8 +6,8 @@ import (
"log"
"time"
"github.com/go-via/via"
"github.com/go-via/via/h"
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
_ "github.com/mattn/go-sqlite3"
)
@@ -54,7 +54,7 @@ func main() {
v.Config(via.Options{
DevMode: true,
DocumentTitle: "Search",
LogLvl: via.LogLevelWarn,
LogLevel: via.LogLevelWarn,
})
v.AppendToHead(

View File

@@ -0,0 +1,87 @@
package main
import (
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
)
func main() {
v := via.New()
v.Config(via.Options{
DocumentTitle: "Signup",
ServerAddress: ":8080",
})
v.AppendToHead(h.StyleEl(h.Raw(`
body { font-family: system-ui, sans-serif; max-width: 420px; margin: 2rem auto; padding: 0 1rem; }
label { display: block; font-weight: 600; margin-top: 1rem; }
input { display: block; width: 100%; padding: 0.4rem; margin-top: 0.25rem; box-sizing: border-box; }
.error { color: #c00; font-size: 0.85rem; margin-top: 0.2rem; }
.success { color: #080; margin-top: 1rem; }
.actions { margin-top: 1.5rem; display: flex; gap: 0.5rem; }
`)))
v.Page("/", func(c *via.Context) {
username := c.Field("", via.Required(), via.MinLen(3), via.MaxLen(20))
email := c.Field("", via.Required(), via.Email())
age := c.Field("", via.Required(), via.Min(13), via.Max(120))
// Optional field — only validated when non-empty
website := c.Field("", via.Pattern(`^$|^https?://\S+$`, "Must be a valid URL"))
var success string
signup := c.Action(func() {
success = ""
if !c.ValidateAll() {
c.Sync()
return
}
// Server-side check
if username.String() == "admin" {
username.AddError("Username is already taken")
c.Sync()
return
}
success = "Account created for " + username.String() + "!"
c.ResetFields()
c.Sync()
})
reset := c.Action(func() {
success = ""
c.ResetFields()
c.Sync()
})
c.View(func() h.H {
return h.Div(
h.H1(h.Text("Sign Up")),
h.Label(h.Text("Username")),
h.Input(h.Type("text"), h.Placeholder("pick a username"), username.Bind()),
h.If(username.HasError(), h.Div(h.Class("error"), h.Text(username.FirstError()))),
h.Label(h.Text("Email")),
h.Input(h.Type("email"), h.Placeholder("you@example.com"), email.Bind()),
h.If(email.HasError(), h.Div(h.Class("error"), h.Text(email.FirstError()))),
h.Label(h.Text("Age")),
h.Input(h.Type("number"), h.Placeholder("your age"), age.Bind()),
h.If(age.HasError(), h.Div(h.Class("error"), h.Text(age.FirstError()))),
h.Label(h.Text("Website (optional)")),
h.Input(h.Type("url"), h.Placeholder("https://example.com"), website.Bind()),
h.If(website.HasError(), h.Div(h.Class("error"), h.Text(website.FirstError()))),
h.Div(h.Class("actions"),
h.Button(h.Text("Sign Up"), signup.OnClick()),
h.Button(h.Text("Reset"), reset.OnClick()),
),
h.If(success != "", h.P(h.Class("success"), h.Text(success))),
)
})
})
v.Start()
}

View File

@@ -0,0 +1,91 @@
package main
import (
"fmt"
"time"
"github.com/ryanhamamura/via"
. "github.com/ryanhamamura/via/h"
)
func main() {
v := via.New()
v.Config(via.Options{
DocumentTitle: "SPA Navigation",
ServerAddress: ":7331",
})
v.AppendToHead(
Raw(`<link rel="preconnect" href="https://fonts.googleapis.com">`),
Raw(`<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>`),
Raw(`<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">`),
Raw(`<style>body{font-family:'Inter',sans-serif;margin:0;background:#111;color:#eee}</style>`),
)
v.Layout(func(content func() H) H {
return Div(
Nav(
Style("display:flex;gap:1rem;padding:1rem;background:#222;"),
A(Href("/"), Text("Home"), Style("color:#fff")),
A(Href("/counter"), Text("Counter"), Style("color:#fff")),
A(Href("/clock"), Text("Clock"), Style("color:#fff")),
A(Href("https://github.com"), Text("GitHub (external)"), Style("color:#888")),
A(Href("/"), Text("Full Reload"), Attr("data-via-no-boost"), Style("color:#f88")),
),
Main(Style("padding:1rem"), content()),
)
})
// Home page
v.Page("/", func(c *via.Context) {
goCounter := c.Action(func() { c.Navigate("/counter", false) })
c.View(func() H {
return Div(
H1(Text("Home"), DataViewTransition("page-title")),
P(Text("Click the nav links above — no page reload, no white flash.")),
P(Text("Or navigate programmatically:")),
Button(Text("Go to Counter"), goCounter.OnClick()),
)
})
})
// Counter page — demonstrates signals and actions survive within a page,
// but reset on navigate away and back.
v.Page("/counter", func(c *via.Context) {
count := 0
increment := c.Action(func() { count++; c.Sync() })
goHome := c.Action(func() { c.Navigate("/", false) })
c.View(func() H {
return Div(
H1(Text("Counter"), DataViewTransition("page-title")),
P(Textf("Count: %d", count)),
Button(Text("+1"), increment.OnClick()),
Button(Text("Go Home"), goHome.OnClick(), Style("margin-left:0.5rem")),
)
})
})
// Clock page — demonstrates OnInterval cleanup on navigate.
v.Page("/clock", func(c *via.Context) {
now := time.Now().Format("15:04:05")
c.OnInterval(time.Second, func() {
now = time.Now().Format("15:04:05")
c.Sync()
})
c.View(func() H {
return Div(
H1(Text("Clock"), DataViewTransition("page-title")),
P(Text("This page has an OnInterval that ticks every second.")),
P(Textf("Current time: %s", now)),
P(Text("Navigate away and back — the old interval stops, a new one starts.")),
P(Textf("Proof this is a fresh page init: random = %d", time.Now().UnixNano()%1000)),
)
})
})
fmt.Println("SPA example running at http://localhost:7331")
v.Start()
}

82
middleware.go Normal file
View File

@@ -0,0 +1,82 @@
package via
// Middleware wraps a page init function. Call next to continue the chain;
// return without calling next to abort (set a view first, e.g. RedirectView).
type Middleware func(c *Context, next func())
// Group is a route group with a shared prefix and middleware stack.
type Group struct {
v *V
prefix string
middleware []Middleware
}
// Use appends middleware to the global stack.
// Global middleware runs before every page handler.
func (v *V) Use(mw ...Middleware) {
v.middleware = append(v.middleware, mw...)
}
// Group creates a route group with the given path prefix and middleware.
// Routes registered on the group are prefixed and run the group's middleware
// after any global middleware.
func (v *V) Group(prefix string, mw ...Middleware) *Group {
return &Group{
v: v,
prefix: prefix,
middleware: mw,
}
}
// Page registers a route on this group. The full route is the group prefix
// concatenated with route.
func (g *Group) Page(route string, initContextFn func(c *Context)) {
fullRoute := g.prefix + route
allMw := make([]Middleware, 0, len(g.v.middleware)+len(g.middleware))
allMw = append(allMw, g.v.middleware...)
allMw = append(allMw, g.middleware...)
wrapped := chainMiddleware(allMw, initContextFn)
g.v.page(fullRoute, initContextFn, wrapped)
}
// Group creates a nested sub-group that inherits this group's prefix and
// middleware, then adds its own.
func (g *Group) Group(prefix string, mw ...Middleware) *Group {
combined := make([]Middleware, len(g.middleware), len(g.middleware)+len(mw))
copy(combined, g.middleware)
combined = append(combined, mw...)
return &Group{
v: g.v,
prefix: g.prefix + prefix,
middleware: combined,
}
}
// Use appends middleware to this group's stack.
func (g *Group) Use(mw ...Middleware) {
g.middleware = append(g.middleware, mw...)
}
// WithMiddleware returns an ActionOption that attaches middleware to an action.
// Action middleware runs after CSRF/rate-limit checks and signal injection.
func WithMiddleware(mw ...Middleware) ActionOption {
return func(e *actionEntry) {
e.middleware = append(e.middleware, mw...)
}
}
// chainMiddleware wraps handler with the given middleware, outer-first.
func chainMiddleware(mws []Middleware, handler func(*Context)) func(*Context) {
if len(mws) == 0 {
return handler
}
chained := handler
for i := len(mws) - 1; i >= 0; i-- {
mw := mws[i]
next := chained
chained = func(c *Context) {
mw(c, func() { next(c) })
}
}
return chained
}

340
middleware_test.go Normal file
View File

@@ -0,0 +1,340 @@
package via
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/ryanhamamura/via/h"
"github.com/stretchr/testify/assert"
)
func TestMiddlewareRunsBeforeHandler(t *testing.T) {
var order []string
v := New()
v.Use(func(c *Context, next func()) {
order = append(order, "mw")
next()
})
v.Page("/", func(c *Context) {
order = append(order, "handler")
c.View(func() h.H { return h.Div() })
})
// Reset after registration (panic-check runs the raw handler)
order = nil
w := httptest.NewRecorder()
v.mux.ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, []string{"mw", "handler"}, order)
}
func TestMiddlewareAbortSkipsHandler(t *testing.T) {
handlerCalled := false
v := New()
v.Use(func(c *Context, next func()) {
c.RedirectView("/other")
})
v.Page("/", func(c *Context) {
handlerCalled = true
c.View(func() h.H { return h.Div() })
})
handlerCalled = false
w := httptest.NewRecorder()
v.mux.ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
assert.Equal(t, http.StatusOK, w.Code)
assert.False(t, handlerCalled)
}
func TestMiddlewareChainOrder(t *testing.T) {
var order []string
v := New()
for _, label := range []string{"A", "B", "C"} {
l := label
v.Use(func(c *Context, next func()) {
order = append(order, l)
next()
})
}
v.Page("/", func(c *Context) {
order = append(order, "handler")
c.View(func() h.H { return h.Div() })
})
order = nil
w := httptest.NewRecorder()
v.mux.ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
assert.Equal(t, []string{"A", "B", "C", "handler"}, order)
}
func TestGroupPrefixRouting(t *testing.T) {
v := New()
g := v.Group("/admin")
g.Page("/dashboard", func(c *Context) {
c.View(func() h.H { return h.Div(h.Text("admin dashboard")) })
})
w := httptest.NewRecorder()
v.mux.ServeHTTP(w, httptest.NewRequest("GET", "/admin/dashboard", nil))
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "admin dashboard")
}
func TestGroupMiddlewareAppliesToGroupOnly(t *testing.T) {
var groupMwCalled bool
v := New()
g := v.Group("/admin", func(c *Context, next func()) {
groupMwCalled = true
next()
})
g.Page("/panel", func(c *Context) {
c.View(func() h.H { return h.Div(h.Text("panel")) })
})
v.Page("/public", func(c *Context) {
c.View(func() h.H { return h.Div(h.Text("public")) })
})
// Hit public page — group middleware should NOT run
groupMwCalled = false
w := httptest.NewRecorder()
v.mux.ServeHTTP(w, httptest.NewRequest("GET", "/public", nil))
assert.False(t, groupMwCalled)
assert.Contains(t, w.Body.String(), "public")
// Hit group page — group middleware should run
groupMwCalled = false
w = httptest.NewRecorder()
v.mux.ServeHTTP(w, httptest.NewRequest("GET", "/admin/panel", nil))
assert.True(t, groupMwCalled)
assert.Contains(t, w.Body.String(), "panel")
}
func TestGlobalMiddlewareAppliesToGroupPages(t *testing.T) {
var globalCalled bool
v := New()
v.Use(func(c *Context, next func()) {
globalCalled = true
next()
})
g := v.Group("/admin")
g.Page("/dash", func(c *Context) {
c.View(func() h.H { return h.Div(h.Text("dash")) })
})
globalCalled = false
w := httptest.NewRecorder()
v.mux.ServeHTTP(w, httptest.NewRequest("GET", "/admin/dash", nil))
assert.True(t, globalCalled)
assert.Contains(t, w.Body.String(), "dash")
}
func TestNestedGroupInheritsPrefixAndMiddleware(t *testing.T) {
var order []string
v := New()
admin := v.Group("/admin", func(c *Context, next func()) {
order = append(order, "admin")
next()
})
superAdmin := admin.Group("/super", func(c *Context, next func()) {
order = append(order, "super")
next()
})
superAdmin.Page("/secret", func(c *Context) {
order = append(order, "handler")
c.View(func() h.H { return h.Div(h.Text("secret")) })
})
order = nil
w := httptest.NewRecorder()
v.mux.ServeHTTP(w, httptest.NewRequest("GET", "/admin/super/secret", nil))
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, []string{"admin", "super", "handler"}, order)
assert.Contains(t, w.Body.String(), "secret")
}
func TestGroupUse(t *testing.T) {
var order []string
v := New()
g := v.Group("/api")
g.Use(func(c *Context, next func()) {
order = append(order, "added-later")
next()
})
g.Page("/items", func(c *Context) {
order = append(order, "handler")
c.View(func() h.H { return h.Div() })
})
order = nil
w := httptest.NewRecorder()
v.mux.ServeHTTP(w, httptest.NewRequest("GET", "/api/items", nil))
assert.Equal(t, []string{"added-later", "handler"}, order)
}
func TestRedirectViewSetsValidView(t *testing.T) {
v := New()
v.Page("/test", func(c *Context) {
c.RedirectView("/somewhere")
})
w := httptest.NewRecorder()
v.mux.ServeHTTP(w, httptest.NewRequest("GET", "/test", nil))
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "<!doctype html>")
}
func TestGlobalAndGroupMiddlewareOrder(t *testing.T) {
var order []string
v := New()
v.Use(func(c *Context, next func()) {
order = append(order, "global")
next()
})
g := v.Group("/g", func(c *Context, next func()) {
order = append(order, "group")
next()
})
g.Page("/page", func(c *Context) {
order = append(order, "handler")
c.View(func() h.H { return h.Div() })
})
order = nil
w := httptest.NewRecorder()
v.mux.ServeHTTP(w, httptest.NewRequest("GET", "/g/page", nil))
assert.Equal(t, []string{"global", "group", "handler"}, order)
}
// --- Action middleware tests ---
func TestActionMiddlewareRunsBeforeAction(t *testing.T) {
var order []string
v := New()
c := newContext("test", "/", v)
mw := func(_ *Context, next func()) {
order = append(order, "mw")
next()
}
trigger := c.Action(func() {
order = append(order, "action")
}, WithMiddleware(mw))
entry, err := c.getAction(trigger.id)
assert.NoError(t, err)
chainMiddleware(entry.middleware, func(_ *Context) { entry.fn() })(c)
assert.Equal(t, []string{"mw", "action"}, order)
}
func TestActionMiddlewareAbortSkipsAction(t *testing.T) {
actionCalled := false
v := New()
c := newContext("test", "/", v)
mw := func(_ *Context, next func()) {
// don't call next — action should not run
}
trigger := c.Action(func() {
actionCalled = true
}, WithMiddleware(mw))
entry, err := c.getAction(trigger.id)
assert.NoError(t, err)
chainMiddleware(entry.middleware, func(_ *Context) { entry.fn() })(c)
assert.False(t, actionCalled)
}
func TestActionMiddlewareChainOrder(t *testing.T) {
var order []string
v := New()
c := newContext("test", "/", v)
var mws []Middleware
for _, label := range []string{"A", "B", "C"} {
l := label
mws = append(mws, func(_ *Context, next func()) {
order = append(order, l)
next()
})
}
trigger := c.Action(func() {
order = append(order, "action")
}, WithMiddleware(mws...))
entry, err := c.getAction(trigger.id)
assert.NoError(t, err)
chainMiddleware(entry.middleware, func(_ *Context) { entry.fn() })(c)
assert.Equal(t, []string{"A", "B", "C", "action"}, order)
}
func TestActionMiddlewareCombinedWithRateLimit(t *testing.T) {
v := New()
c := newContext("test", "/", v)
mw := func(_ *Context, next func()) { next() }
trigger := c.Action(func() {}, WithRateLimit(5, 10), WithMiddleware(mw))
entry, err := c.getAction(trigger.id)
assert.NoError(t, err)
assert.NotNil(t, entry.limiter)
assert.Len(t, entry.middleware, 1)
}
func TestGroupWithEmptyPrefix(t *testing.T) {
var mwCalled bool
v := New()
g := v.Group("", func(c *Context, next func()) {
mwCalled = true
next()
})
g.Page("/dashboard", func(c *Context) {
c.View(func() h.H { return h.Div(h.Text("dash")) })
})
mwCalled = false
w := httptest.NewRecorder()
v.mux.ServeHTTP(w, httptest.NewRequest("GET", "/dashboard", nil))
assert.True(t, mwCalled)
assert.Contains(t, w.Body.String(), "dash")
}

190
nats.go Normal file
View File

@@ -0,0 +1,190 @@
package via
import (
"context"
"encoding/json"
"fmt"
"os"
"sync"
"time"
"github.com/delaneyj/toolbelt/embeddednats"
"github.com/nats-io/nats.go"
)
// defaultNATS is the process-scoped embedded NATS server.
type defaultNATS struct {
server *embeddednats.Server
nc *nats.Conn
js nats.JetStreamContext
cancel context.CancelFunc
dataDir string
}
var (
sharedNATS *defaultNATS
sharedNATSOnce sync.Once
sharedNATSErr error
)
// getSharedNATS returns a process-level singleton embedded NATS server.
// The server starts once and is reused across all V instances.
func getSharedNATS() (*defaultNATS, error) {
sharedNATSOnce.Do(func() {
sharedNATS, sharedNATSErr = startDefaultNATS()
})
return sharedNATS, sharedNATSErr
}
func startDefaultNATS() (dn *defaultNATS, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("nats server panic: %v", r)
}
}()
dataDir, err := os.MkdirTemp("", "via-nats-*")
if err != nil {
return nil, fmt.Errorf("create temp dir: %w", err)
}
ctx, cancel := context.WithCancel(context.Background())
ns, err := embeddednats.New(ctx, embeddednats.WithDirectory(dataDir))
if err != nil {
cancel()
os.RemoveAll(dataDir)
return nil, fmt.Errorf("start embedded nats: %w", err)
}
ns.WaitForServer()
nc, err := ns.Client()
if err != nil {
ns.Close()
cancel()
os.RemoveAll(dataDir)
return nil, fmt.Errorf("connect nats client: %w", err)
}
js, err := nc.JetStream()
if err != nil {
nc.Close()
ns.Close()
cancel()
os.RemoveAll(dataDir)
return nil, fmt.Errorf("init jetstream: %w", err)
}
return &defaultNATS{
server: ns,
nc: nc,
js: js,
cancel: cancel,
dataDir: dataDir,
}, nil
}
func (n *defaultNATS) Publish(subject string, data []byte) error {
return n.nc.Publish(subject, data)
}
func (n *defaultNATS) Subscribe(subject string, handler func(data []byte)) (Subscription, error) {
sub, err := n.nc.Subscribe(subject, func(msg *nats.Msg) {
handler(msg.Data)
})
if err != nil {
return nil, err
}
return sub, nil
}
// natsRef wraps a shared defaultNATS as a PubSub. Close is a no-op because
// the underlying server is process-scoped and outlives individual V instances.
type natsRef struct {
dn *defaultNATS
}
func (r *natsRef) Publish(subject string, data []byte) error {
return r.dn.Publish(subject, data)
}
func (r *natsRef) Subscribe(subject string, handler func(data []byte)) (Subscription, error) {
return r.dn.Subscribe(subject, handler)
}
func (r *natsRef) Close() error {
return nil
}
// NATSConn returns the underlying NATS connection from the built-in embedded
// server, or nil if a custom PubSub backend is in use.
func (v *V) NATSConn() *nats.Conn {
if v.defaultNATS != nil {
return v.defaultNATS.nc
}
return nil
}
// JetStream returns the JetStream context from the built-in embedded server,
// or nil if a custom PubSub backend is in use.
func (v *V) JetStream() nats.JetStreamContext {
if v.defaultNATS != nil {
return v.defaultNATS.js
}
return nil
}
// StreamConfig holds the parameters for creating or updating a JetStream stream.
type StreamConfig struct {
Name string
Subjects []string
MaxMsgs int64
MaxAge time.Duration
}
// EnsureStream creates or updates a JetStream stream matching cfg.
func EnsureStream(v *V, cfg StreamConfig) error {
js := v.JetStream()
if js == nil {
return fmt.Errorf("jetstream not available")
}
_, err := js.AddStream(&nats.StreamConfig{
Name: cfg.Name,
Subjects: cfg.Subjects,
Retention: nats.LimitsPolicy,
MaxMsgs: cfg.MaxMsgs,
MaxAge: cfg.MaxAge,
})
return err
}
// ReplayHistory fetches the last limit messages from subject,
// deserializing each as T. Returns an empty slice if nothing is available.
func ReplayHistory[T any](v *V, subject string, limit int) ([]T, error) {
js := v.JetStream()
if js == nil {
return nil, fmt.Errorf("jetstream not available")
}
sub, err := js.SubscribeSync(subject, nats.DeliverAll(), nats.OrderedConsumer())
if err != nil {
return nil, err
}
defer sub.Unsubscribe()
var msgs []T
for {
raw, err := sub.NextMsg(200 * time.Millisecond)
if err != nil {
break
}
var msg T
if json.Unmarshal(raw.Data, &msg) == nil {
msgs = append(msgs, msg)
}
}
if limit > 0 && len(msgs) > limit {
msgs = msgs[len(msgs)-limit:]
}
return msgs, nil
}

135
nats_test.go Normal file
View File

@@ -0,0 +1,135 @@
package via
import (
"sync"
"testing"
"time"
"github.com/ryanhamamura/via/h"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPubSub_RoundTrip(t *testing.T) {
v := New()
defer v.Shutdown()
var received []byte
done := make(chan struct{})
c := newContext("test-ctx", "/", v)
c.View(func() h.H { return h.Div() })
_, err := c.Subscribe("test.topic", func(data []byte) {
received = data
close(done)
})
require.NoError(t, err)
err = c.Publish("test.topic", []byte("hello"))
require.NoError(t, err)
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for message")
}
assert.Equal(t, []byte("hello"), received)
}
func TestPubSub_MultipleSubscribers(t *testing.T) {
v := New()
defer v.Shutdown()
var mu sync.Mutex
var results []string
var wg sync.WaitGroup
wg.Add(2)
c1 := newContext("ctx-1", "/", v)
c1.View(func() h.H { return h.Div() })
c2 := newContext("ctx-2", "/", v)
c2.View(func() h.H { return h.Div() })
c1.Subscribe("broadcast", func(data []byte) {
mu.Lock()
results = append(results, "c1:"+string(data))
mu.Unlock()
wg.Done()
})
c2.Subscribe("broadcast", func(data []byte) {
mu.Lock()
results = append(results, "c2:"+string(data))
mu.Unlock()
wg.Done()
})
c1.Publish("broadcast", []byte("msg"))
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for messages")
}
assert.Len(t, results, 2)
assert.Contains(t, results, "c1:msg")
assert.Contains(t, results, "c2:msg")
}
func TestPubSub_SubscriptionCleanupOnDispose(t *testing.T) {
v := New()
defer v.Shutdown()
c := newContext("cleanup-ctx", "/", v)
c.View(func() h.H { return h.Div() })
c.Subscribe("room.1", func(data []byte) {})
c.Subscribe("room.2", func(data []byte) {})
assert.Len(t, c.subscriptions, 2)
c.unsubscribeAll()
assert.Empty(t, c.subscriptions)
}
func TestPubSub_ManualUnsubscribe(t *testing.T) {
v := New()
defer v.Shutdown()
c := newContext("unsub-ctx", "/", v)
c.View(func() h.H { return h.Div() })
called := false
sub, err := c.Subscribe("topic", func(data []byte) {
called = true
})
require.NoError(t, err)
sub.Unsubscribe()
c.Publish("topic", []byte("ignored"))
time.Sleep(50 * time.Millisecond)
assert.False(t, called)
}
func TestPubSub_NoOpDuringPanicCheck(t *testing.T) {
v := New()
defer v.Shutdown()
// Panic-check context has id=""
c := newContext("", "/", v)
err := c.Publish("topic", []byte("data"))
assert.NoError(t, err)
sub, err := c.Subscribe("topic", func(data []byte) {})
assert.NoError(t, err)
assert.Nil(t, sub)
}

51
navigate.js Normal file
View File

@@ -0,0 +1,51 @@
(function() {
const meta = document.querySelector('meta[data-signals]');
if (!meta) return;
const raw = meta.getAttribute('data-signals');
const parsed = JSON.parse(raw.replace(/'/g, '"'));
const ctxID = parsed['via-ctx'];
const csrf = parsed['via-csrf'];
if (!ctxID || !csrf) return;
function navigate(url, popstate) {
const params = new URLSearchParams({
'via-ctx': ctxID,
'via-csrf': csrf,
'url': url,
});
if (popstate) params.set('popstate', '1');
fetch('/_navigate', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: params.toString()
}).then(function(res) {
if (!res.ok) window.location.href = url;
}).catch(function() {
window.location.href = url;
});
}
document.addEventListener('click', function(e) {
var el = e.target;
while (el && el.tagName !== 'A') el = el.parentElement;
if (!el) return;
if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
if (el.hasAttribute('target')) return;
if (el.hasAttribute('data-via-no-boost')) return;
var href = el.getAttribute('href');
if (!href || href.startsWith('#')) return;
try {
var url = new URL(href, window.location.origin);
if (url.origin !== window.location.origin) return;
e.preventDefault();
navigate(url.pathname + url.search + url.hash);
} catch(_) {}
});
var ready = false;
window.addEventListener('popstate', function() {
if (!ready) return;
navigate(window.location.pathname + window.location.search + window.location.hash, true);
});
setTimeout(function() { ready = true; }, 0);
})();

15
pubsub.go Normal file
View File

@@ -0,0 +1,15 @@
package via
// PubSub is an interface for publish/subscribe messaging backends.
// By default, New() starts an embedded NATS server. Supply a custom
// implementation via Config(Options{PubSub: yourBackend}) to override.
type PubSub interface {
Publish(subject string, data []byte) error
Subscribe(subject string, handler func(data []byte)) (Subscription, error)
Close() error
}
// Subscription represents an active subscription that can be manually unsubscribed.
type Subscription interface {
Unsubscribe() error
}

23
pubsub_helpers.go Normal file
View File

@@ -0,0 +1,23 @@
package via
import "encoding/json"
// Publish JSON-marshals msg and publishes to subject.
func Publish[T any](c *Context, subject string, msg T) error {
data, err := json.Marshal(msg)
if err != nil {
return err
}
return c.Publish(subject, data)
}
// Subscribe JSON-unmarshals each message as T and calls handler.
func Subscribe[T any](c *Context, subject string, handler func(T)) (Subscription, error) {
return c.Subscribe(subject, func(data []byte) {
var msg T
if err := json.Unmarshal(data, &msg); err != nil {
return
}
handler(msg)
})
}

68
pubsub_helpers_test.go Normal file
View File

@@ -0,0 +1,68 @@
package via
import (
"testing"
"time"
"github.com/ryanhamamura/via/h"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPublishSubscribe_RoundTrip(t *testing.T) {
v := New()
defer v.Shutdown()
type event struct {
Name string `json:"name"`
Count int `json:"count"`
}
var got event
done := make(chan struct{})
c := newContext("typed-ctx", "/", v)
c.View(func() h.H { return h.Div() })
_, err := Subscribe(c, "events", func(e event) {
got = e
close(done)
})
require.NoError(t, err)
err = Publish(c, "events", event{Name: "click", Count: 42})
require.NoError(t, err)
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for message")
}
assert.Equal(t, "click", got.Name)
assert.Equal(t, 42, got.Count)
}
func TestSubscribe_SkipsBadJSON(t *testing.T) {
v := New()
defer v.Shutdown()
type msg struct {
Text string `json:"text"`
}
called := false
c := newContext("bad-json-ctx", "/", v)
c.View(func() h.H { return h.Div() })
_, err := Subscribe(c, "topic", func(m msg) {
called = true
})
require.NoError(t, err)
// Publish raw invalid JSON — handler should silently skip
err = c.Publish("topic", []byte("not json"))
require.NoError(t, err)
time.Sleep(50 * time.Millisecond)
assert.False(t, called)
}

49
ratelimit.go Normal file
View File

@@ -0,0 +1,49 @@
package via
import "golang.org/x/time/rate"
const (
defaultActionRate float64 = 10.0
defaultActionBurst int = 20
)
// RateLimitConfig configures token-bucket rate limiting for actions.
// Zero values fall back to defaults. Rate of -1 disables limiting entirely.
type RateLimitConfig struct {
Rate float64
Burst int
}
// ActionOption configures per-action behaviour when passed to Context.Action.
type ActionOption func(*actionEntry)
type actionEntry struct {
fn func()
limiter *rate.Limiter // nil = use context default
middleware []Middleware
}
// WithRateLimit returns an ActionOption that gives this action its own
// token-bucket limiter, overriding the context-level default.
func WithRateLimit(r float64, burst int) ActionOption {
return func(e *actionEntry) {
e.limiter = newLimiter(RateLimitConfig{Rate: r, Burst: burst}, defaultActionRate, defaultActionBurst)
}
}
// newLimiter creates a *rate.Limiter from cfg, substituting defaults for zero
// values. A Rate of -1 disables limiting (returns nil).
func newLimiter(cfg RateLimitConfig, defaultRate float64, defaultBurst int) *rate.Limiter {
r := cfg.Rate
b := cfg.Burst
if r == -1 {
return nil
}
if r == 0 {
r = defaultRate
}
if b == 0 {
b = defaultBurst
}
return rate.NewLimiter(rate.Limit(r), b)
}

101
ratelimit_test.go Normal file
View File

@@ -0,0 +1,101 @@
package via
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewLimiter_Defaults(t *testing.T) {
l := newLimiter(RateLimitConfig{}, defaultActionRate, defaultActionBurst)
require.NotNil(t, l)
assert.InDelta(t, defaultActionRate, float64(l.Limit()), 0.001)
assert.Equal(t, defaultActionBurst, l.Burst())
}
func TestNewLimiter_CustomValues(t *testing.T) {
l := newLimiter(RateLimitConfig{Rate: 5, Burst: 10}, defaultActionRate, defaultActionBurst)
require.NotNil(t, l)
assert.InDelta(t, 5.0, float64(l.Limit()), 0.001)
assert.Equal(t, 10, l.Burst())
}
func TestNewLimiter_DisabledWithNegativeRate(t *testing.T) {
l := newLimiter(RateLimitConfig{Rate: -1}, defaultActionRate, defaultActionBurst)
assert.Nil(t, l)
}
func TestTokenBucket_AllowsBurstThenRejects(t *testing.T) {
l := newLimiter(RateLimitConfig{Rate: 1, Burst: 3}, 1, 3)
require.NotNil(t, l)
for i := 0; i < 3; i++ {
assert.True(t, l.Allow(), "request %d should be allowed within burst", i)
}
assert.False(t, l.Allow(), "request beyond burst should be rejected")
}
func TestWithRateLimit_CreatesLimiter(t *testing.T) {
entry := actionEntry{fn: func() {}}
opt := WithRateLimit(2, 4)
opt(&entry)
require.NotNil(t, entry.limiter)
assert.InDelta(t, 2.0, float64(entry.limiter.Limit()), 0.001)
assert.Equal(t, 4, entry.limiter.Burst())
}
func TestContextAction_WithRateLimit(t *testing.T) {
v := New()
c := newContext("test-rl", "/", v)
called := false
c.Action(func() { called = true }, WithRateLimit(1, 2))
// Verify the entry has its own limiter
for _, entry := range c.actionRegistry {
require.NotNil(t, entry.limiter)
assert.InDelta(t, 1.0, float64(entry.limiter.Limit()), 0.001)
assert.Equal(t, 2, entry.limiter.Burst())
}
assert.False(t, called)
}
func TestContextAction_DefaultNoPerActionLimiter(t *testing.T) {
v := New()
c := newContext("test-no-rl", "/", v)
c.Action(func() {})
for _, entry := range c.actionRegistry {
assert.Nil(t, entry.limiter, "entry without WithRateLimit should have nil limiter")
}
}
func TestContextLimiter_DefaultsApplied(t *testing.T) {
v := New()
c := newContext("test-ctx-limiter", "/", v)
require.NotNil(t, c.actionLimiter)
assert.InDelta(t, defaultActionRate, float64(c.actionLimiter.Limit()), 0.001)
assert.Equal(t, defaultActionBurst, c.actionLimiter.Burst())
}
func TestContextLimiter_DisabledViaConfig(t *testing.T) {
v := New()
v.actionRateLimit = RateLimitConfig{Rate: -1}
c := newContext("test-disabled", "/", v)
assert.Nil(t, c.actionLimiter)
}
func TestContextLimiter_CustomConfig(t *testing.T) {
v := New()
v.Config(Options{ActionRateLimit: RateLimitConfig{Rate: 50, Burst: 100}})
c := newContext("test-custom", "/", v)
require.NotNil(t, c.actionLimiter)
assert.InDelta(t, 50.0, float64(c.actionLimiter.Limit()), 0.001)
assert.Equal(t, 100, c.actionLimiter.Burst())
}

View File

@@ -1,76 +1,34 @@
package via
import (
"sync"
"sync/atomic"
"time"
)
// OnIntervalRoutine allows for defining concurrent goroutines safely. Goroutines started by *OnIntervalRoutine
// are tied to the *Context lifecycle.
type OnIntervalRoutine struct {
mu sync.RWMutex
ctxDisposed chan struct{}
localInterrupt chan struct{}
isRunning atomic.Bool
routineFn func()
tckDuration time.Duration
updateTkrChan chan time.Duration
}
func newOnInterval(ctxDisposedChan, pageStopChan chan struct{}, duration time.Duration, handler func()) func() {
localInterrupt := make(chan struct{})
var stopped atomic.Bool
// UpdateInterval sets a new interval duration for the internal *time.Ticker. If the provided
// duration is equal of less than 0, UpdateInterval does nothing.
func (r *OnIntervalRoutine) UpdateInterval(d time.Duration) {
r.mu.Lock()
defer r.mu.Unlock()
r.tckDuration = d
r.updateTkrChan <- d
}
// Start executes the predifined goroutine. If no predifined goroutine exists, or it already
// started, Start does nothing.
func (r *OnIntervalRoutine) Start() {
if !r.isRunning.CompareAndSwap(false, true) || r.routineFn == nil {
return
}
go r.routineFn()
}
// Stop interrupts the predifined goroutine. If no predifined goroutine exists, or it already
// ustopped, Stop does nothing.
func (r *OnIntervalRoutine) Stop() {
if !r.isRunning.CompareAndSwap(true, false) || r.routineFn == nil {
return
}
r.localInterrupt <- struct{}{}
}
func newOnIntervalRoutine(ctxDisposedChan chan struct{},
duration time.Duration, handler func()) *OnIntervalRoutine {
r := &OnIntervalRoutine{
ctxDisposed: ctxDisposedChan,
localInterrupt: make(chan struct{}),
updateTkrChan: make(chan time.Duration),
}
r.tckDuration = duration
r.routineFn = func() {
r.mu.RLock()
tkr := time.NewTicker(r.tckDuration)
r.mu.RUnlock()
defer tkr.Stop() // clean up the ticker when routine stops
go func() {
tkr := time.NewTicker(duration)
defer tkr.Stop()
for {
select {
case <-r.ctxDisposed: // dispose of the routine when ctx is disposed
case <-ctxDisposedChan:
return
case <-r.localInterrupt: // dispose of the routine on interrupt signal
case <-pageStopChan:
return
case <-localInterrupt:
return
case d := <-r.updateTkrChan:
tkr.Reset(d)
case <-tkr.C:
handler()
}
}
}()
return func() {
if stopped.CompareAndSwap(false, true) {
close(localInterrupt)
}
}
return r
}

130
rule.go Normal file
View File

@@ -0,0 +1,130 @@
package via
import (
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"unicode/utf8"
)
// Rule defines a single validation check for a Field.
type Rule struct {
validate func(val string) error
}
// Required rejects empty or whitespace-only values.
func Required(msg ...string) Rule {
m := "This field is required"
if len(msg) > 0 {
m = msg[0]
}
return Rule{func(val string) error {
if strings.TrimSpace(val) == "" {
return errors.New(m)
}
return nil
}}
}
// MinLen rejects values shorter than n characters.
func MinLen(n int, msg ...string) Rule {
m := fmt.Sprintf("Must be at least %d characters", n)
if len(msg) > 0 {
m = msg[0]
}
return Rule{func(val string) error {
if utf8.RuneCountInString(val) < n {
return errors.New(m)
}
return nil
}}
}
// MaxLen rejects values longer than n characters.
func MaxLen(n int, msg ...string) Rule {
m := fmt.Sprintf("Must be at most %d characters", n)
if len(msg) > 0 {
m = msg[0]
}
return Rule{func(val string) error {
if utf8.RuneCountInString(val) > n {
return errors.New(m)
}
return nil
}}
}
// Min parses the value as an integer and rejects values less than n.
func Min(n int, msg ...string) Rule {
m := fmt.Sprintf("Must be at least %d", n)
if len(msg) > 0 {
m = msg[0]
}
return Rule{func(val string) error {
v, err := strconv.Atoi(val)
if err != nil {
return errors.New("Must be a valid number")
}
if v < n {
return errors.New(m)
}
return nil
}}
}
// Max parses the value as an integer and rejects values greater than n.
func Max(n int, msg ...string) Rule {
m := fmt.Sprintf("Must be at most %d", n)
if len(msg) > 0 {
m = msg[0]
}
return Rule{func(val string) error {
v, err := strconv.Atoi(val)
if err != nil {
return errors.New("Must be a valid number")
}
if v > n {
return errors.New(m)
}
return nil
}}
}
// Pattern rejects values that don't match the regular expression re.
func Pattern(re string, msg ...string) Rule {
m := "Invalid format"
if len(msg) > 0 {
m = msg[0]
}
compiled := regexp.MustCompile(re)
return Rule{func(val string) error {
if !compiled.MatchString(val) {
return errors.New(m)
}
return nil
}}
}
var emailRegexp = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
// Email rejects values that don't look like an email address.
func Email(msg ...string) Rule {
m := "Invalid email address"
if len(msg) > 0 {
m = msg[0]
}
return Rule{func(val string) error {
if !emailRegexp.MatchString(val) {
return errors.New(m)
}
return nil
}}
}
// Custom creates a rule from a user-provided validation function.
// The function should return nil for valid input and an error for invalid input.
func Custom(fn func(string) error) Rule {
return Rule{validate: fn}
}

116
rule_test.go Normal file
View File

@@ -0,0 +1,116 @@
package via
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
func TestRequired(t *testing.T) {
r := Required()
assert.NoError(t, r.validate("hello"))
assert.Error(t, r.validate(""))
assert.Error(t, r.validate(" "))
}
func TestRequiredCustomMessage(t *testing.T) {
r := Required("name needed")
err := r.validate("")
assert.EqualError(t, err, "name needed")
}
func TestMinLen(t *testing.T) {
r := MinLen(3)
assert.NoError(t, r.validate("abc"))
assert.NoError(t, r.validate("abcd"))
assert.Error(t, r.validate("ab"))
assert.Error(t, r.validate(""))
}
func TestMinLenCustomMessage(t *testing.T) {
r := MinLen(5, "too short")
err := r.validate("ab")
assert.EqualError(t, err, "too short")
}
func TestMaxLen(t *testing.T) {
r := MaxLen(5)
assert.NoError(t, r.validate("abc"))
assert.NoError(t, r.validate("abcde"))
assert.Error(t, r.validate("abcdef"))
}
func TestMaxLenCustomMessage(t *testing.T) {
r := MaxLen(2, "too long")
err := r.validate("abc")
assert.EqualError(t, err, "too long")
}
func TestMin(t *testing.T) {
r := Min(5)
assert.NoError(t, r.validate("5"))
assert.NoError(t, r.validate("10"))
assert.Error(t, r.validate("4"))
assert.Error(t, r.validate("abc"))
}
func TestMinCustomMessage(t *testing.T) {
r := Min(10, "need 10+")
err := r.validate("3")
assert.EqualError(t, err, "need 10+")
}
func TestMax(t *testing.T) {
r := Max(10)
assert.NoError(t, r.validate("10"))
assert.NoError(t, r.validate("5"))
assert.Error(t, r.validate("11"))
assert.Error(t, r.validate("abc"))
}
func TestMaxCustomMessage(t *testing.T) {
r := Max(5, "too big")
err := r.validate("6")
assert.EqualError(t, err, "too big")
}
func TestPattern(t *testing.T) {
r := Pattern(`^\d{3}$`)
assert.NoError(t, r.validate("123"))
assert.Error(t, r.validate("12"))
assert.Error(t, r.validate("abcd"))
}
func TestPatternCustomMessage(t *testing.T) {
r := Pattern(`^\d+$`, "digits only")
err := r.validate("abc")
assert.EqualError(t, err, "digits only")
}
func TestEmail(t *testing.T) {
r := Email()
assert.NoError(t, r.validate("user@example.com"))
assert.NoError(t, r.validate("a.b+c@foo.co"))
assert.Error(t, r.validate("notanemail"))
assert.Error(t, r.validate("@example.com"))
assert.Error(t, r.validate("user@"))
assert.Error(t, r.validate(""))
}
func TestEmailCustomMessage(t *testing.T) {
r := Email("bad email")
err := r.validate("nope")
assert.EqualError(t, err, "bad email")
}
func TestCustom(t *testing.T) {
r := Custom(func(val string) error {
if val != "magic" {
return fmt.Errorf("must be magic")
}
return nil
})
assert.NoError(t, r.validate("magic"))
assert.EqualError(t, r.validate("other"), "must be magic")
}

View File

@@ -2,11 +2,34 @@ package via
import (
"context"
"database/sql"
"time"
"github.com/alexedwards/scs/sqlite3store"
"github.com/alexedwards/scs/v2"
)
// NewSQLiteSessionManager creates a session manager using SQLite for persistence.
// Creates the sessions table if it doesn't exist.
// The returned manager can be configured further (Lifetime, Cookie settings, etc.)
// before passing to Options.SessionManager.
func NewSQLiteSessionManager(db *sql.DB) (*scs.SessionManager, error) {
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
data BLOB NOT NULL,
expiry REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS sessions_expiry_idx ON sessions(expiry);
`)
if err != nil {
return nil, err
}
sm := scs.New()
sm.Store = sqlite3store.New(db)
return sm, nil
}
// Session provides access to the user's session data.
// Session data persists across page views for the same browser.
type Session struct {

View File

@@ -5,7 +5,7 @@ import (
"strconv"
"strings"
"github.com/go-via/via/h"
"github.com/ryanhamamura/via/h"
)
// Signal represents a value that is reactive in the browser. Signals
@@ -81,26 +81,3 @@ func (s *signal) Int() int {
return 0
}
// Int64 tries to read the signal value as an int64.
// Returns the value or 0 on failure.
func (s *signal) Int64() int64 {
if n, err := strconv.ParseInt(s.String(), 10, 64); err == nil {
return n
}
return 0
}
// Float64 tries to read the signal value as a float64.
// Returns the value or 0.0 on failure.
func (s *signal) Float() float64 {
if n, err := strconv.ParseFloat(s.String(), 64); err == nil {
return n
}
return 0.0
}
// Bytes tries to read the signal value as a []byte
// Returns the value or an empty []byte on failure.
func (s *signal) Bytes() []byte {
return []byte(s.String())
}

View File

@@ -4,7 +4,7 @@ import (
// "net/http/httptest"
"testing"
"github.com/go-via/via/h"
"github.com/ryanhamamura/via/h"
"github.com/stretchr/testify/assert"
)

143
static_test.go Normal file
View File

@@ -0,0 +1,143 @@
package via
import (
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"testing/fstest"
"github.com/stretchr/testify/assert"
)
func TestStatic(t *testing.T) {
dir := t.TempDir()
os.MkdirAll(filepath.Join(dir, "sub"), 0755)
os.WriteFile(filepath.Join(dir, "hello.txt"), []byte("hello world"), 0644)
os.WriteFile(filepath.Join(dir, "sub", "nested.txt"), []byte("nested"), 0644)
v := New()
v.Static("/assets/", dir)
t.Run("serves file", func(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/assets/hello.txt", nil)
v.mux.ServeHTTP(w, r)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "hello world", w.Body.String())
})
t.Run("serves nested file", func(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/assets/sub/nested.txt", nil)
v.mux.ServeHTTP(w, r)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "nested", w.Body.String())
})
t.Run("directory listing returns 404", func(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/assets/", nil)
v.mux.ServeHTTP(w, r)
assert.Equal(t, http.StatusNotFound, w.Code)
})
t.Run("subdirectory listing returns 404", func(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/assets/sub/", nil)
v.mux.ServeHTTP(w, r)
assert.Equal(t, http.StatusNotFound, w.Code)
})
t.Run("missing file returns 404", func(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/assets/nope.txt", nil)
v.mux.ServeHTTP(w, r)
assert.Equal(t, http.StatusNotFound, w.Code)
})
}
func TestStaticAutoSlash(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "ok.txt"), []byte("ok"), 0644)
v := New()
v.Static("/files", dir) // no trailing slash
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/files/ok.txt", nil)
v.mux.ServeHTTP(w, r)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "ok", w.Body.String())
}
func TestStaticFS(t *testing.T) {
fsys := fstest.MapFS{
"style.css": {Data: []byte("body{}")},
"js/app.js": {Data: []byte("console.log('hi')")},
}
v := New()
v.StaticFS("/static/", fsys)
t.Run("serves file", func(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/static/style.css", nil)
v.mux.ServeHTTP(w, r)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "body{}", w.Body.String())
})
t.Run("serves nested file", func(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/static/js/app.js", nil)
v.mux.ServeHTTP(w, r)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "console.log('hi')", w.Body.String())
})
t.Run("directory listing returns 404", func(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/static/", nil)
v.mux.ServeHTTP(w, r)
assert.Equal(t, http.StatusNotFound, w.Code)
})
t.Run("missing file returns 404", func(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/static/nope.css", nil)
v.mux.ServeHTTP(w, r)
assert.Equal(t, http.StatusNotFound, w.Code)
})
}
func TestStaticFSAutoSlash(t *testing.T) {
fsys := fstest.MapFS{
"ok.txt": {Data: []byte("ok")},
}
v := New()
v.StaticFS("/embed", fsys) // no trailing slash
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/embed/ok.txt", nil)
v.mux.ServeHTTP(w, r)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "ok", w.Body.String())
}
// Verify StaticFS accepts the fs.FS interface (compile-time check).
var _ fs.FS = fstest.MapFS{}

535
via.go
View File

@@ -7,90 +7,108 @@
package via
import (
"context"
"crypto/rand"
"crypto/subtle"
_ "embed"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"io/fs"
"net/http"
"net/url"
"os"
ossignal "os/signal"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
"github.com/alexedwards/scs/v2"
"github.com/go-via/via/h"
"github.com/rs/zerolog"
"github.com/ryanhamamura/via/h"
"github.com/starfederation/datastar-go/datastar"
)
//go:embed datastar.js
var datastarJS []byte
//go:embed navigate.js
var navigateJS []byte
// V is the root application.
// It manages page routing, user sessions, and SSE connections for live updates.
type V struct {
cfg Options
mux *http.ServeMux
contextRegistry map[string]*Context
contextRegistryMutex sync.RWMutex
documentHeadIncludes []h.H
documentFootIncludes []h.H
devModePageInitFnMap map[string]func(*Context)
sessionManager *scs.SessionManager
datastarPath string
datastarContent []byte
datastarHandlerRegistered bool
cfg Options
mux *http.ServeMux
server *http.Server
logger zerolog.Logger
contextRegistry map[string]*Context
contextRegistryMutex sync.RWMutex
documentHeadIncludes []h.H
documentFootIncludes []h.H
devModePageInitFnMap map[string]func(*Context)
pageRegistry map[string]func(*Context)
sessionManager *scs.SessionManager
pubsub PubSub
defaultNATS *defaultNATS
actionRateLimit RateLimitConfig
datastarPath string
datastarContent []byte
datastarOnce sync.Once
reaperStop chan struct{}
middleware []Middleware
layout func(func() h.H) h.H
}
func (v *V) logEvent(evt *zerolog.Event, c *Context) *zerolog.Event {
if c != nil && c.id != "" {
evt = evt.Str("via-ctx", c.id)
}
return evt
}
func (v *V) logFatal(format string, a ...any) {
log.Printf("[fatal] msg=%q", fmt.Sprintf(format, a...))
v.logEvent(v.logger.WithLevel(zerolog.FatalLevel), nil).Msgf(format, a...)
}
func (v *V) logErr(c *Context, format string, a ...any) {
cRef := ""
if c != nil && c.id != "" {
cRef = fmt.Sprintf("via-ctx=%q ", c.id)
}
log.Printf("[error] %smsg=%q", cRef, fmt.Sprintf(format, a...))
v.logEvent(v.logger.Error(), c).Msgf(format, a...)
}
func (v *V) logWarn(c *Context, format string, a ...any) {
cRef := ""
if c != nil && c.id != "" {
cRef = fmt.Sprintf("via-ctx=%q ", c.id)
}
if v.cfg.LogLvl >= LogLevelWarn {
log.Printf("[warn] %smsg=%q", cRef, fmt.Sprintf(format, a...))
}
v.logEvent(v.logger.Warn(), c).Msgf(format, a...)
}
func (v *V) logInfo(c *Context, format string, a ...any) {
cRef := ""
if c != nil && c.id != "" {
cRef = fmt.Sprintf("via-ctx=%q ", c.id)
}
if v.cfg.LogLvl >= LogLevelInfo {
log.Printf("[info] %smsg=%q", cRef, fmt.Sprintf(format, a...))
}
v.logEvent(v.logger.Info(), c).Msgf(format, a...)
}
func (v *V) logDebug(c *Context, format string, a ...any) {
cRef := ""
if c != nil && c.id != "" {
cRef = fmt.Sprintf("via-ctx=%q ", c.id)
}
if v.cfg.LogLvl == LogLevelDebug {
log.Printf("[debug] %smsg=%q", cRef, fmt.Sprintf(format, a...))
}
v.logEvent(v.logger.Debug(), c).Msgf(format, a...)
}
func newConsoleLogger(level zerolog.Level) zerolog.Logger {
return zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: "15:04:05"}).
With().Timestamp().Logger().Level(level)
}
// Config overrides the default configuration with the given options.
func (v *V) Config(cfg Options) {
if cfg.LogLvl != undefined {
v.cfg.LogLvl = cfg.LogLvl
if cfg.Logger != nil {
v.logger = *cfg.Logger
} else if cfg.LogLevel != nil || cfg.DevMode != v.cfg.DevMode {
level := zerolog.InfoLevel
if cfg.LogLevel != nil {
level = *cfg.LogLevel
}
if cfg.DevMode {
v.logger = newConsoleLogger(level)
} else {
v.logger = zerolog.New(os.Stderr).With().Timestamp().Logger().Level(level)
}
}
if cfg.DocumentTitle != "" {
v.cfg.DocumentTitle = cfg.DocumentTitle
@@ -111,13 +129,24 @@ func (v *V) Config(cfg Options) {
if cfg.SessionManager != nil {
v.sessionManager = cfg.SessionManager
}
if cfg.Datastar != nil {
if cfg.Datastar.Content != nil {
v.datastarContent = cfg.Datastar.Content
}
if cfg.Datastar.Path != "" {
v.datastarPath = cfg.Datastar.Path
}
if cfg.DatastarContent != nil {
v.datastarContent = cfg.DatastarContent
}
if cfg.DatastarPath != "" {
v.datastarPath = cfg.DatastarPath
}
if cfg.PubSub != nil {
v.defaultNATS = nil
v.pubsub = cfg.PubSub
}
if cfg.ContextSuspendAfter != 0 {
v.cfg.ContextSuspendAfter = cfg.ContextSuspendAfter
}
if cfg.ContextTTL != 0 {
v.cfg.ContextTTL = cfg.ContextTTL
}
if cfg.ActionRateLimit.Rate != 0 || cfg.ActionRateLimit.Burst != 0 {
v.actionRateLimit = cfg.ActionRateLimit
}
}
@@ -152,8 +181,16 @@ func (v *V) AppendToFoot(elements ...h.H) {
// })
// })
func (v *V) Page(route string, initContextFn func(c *Context)) {
wrapped := chainMiddleware(v.middleware, initContextFn)
v.page(route, initContextFn, wrapped)
}
// page registers a route with separate raw and wrapped init functions.
// raw is used for the panic-check at registration time; wrapped includes
// any middleware and is used as the live handler.
func (v *V) page(route string, raw, wrapped func(*Context)) {
v.ensureDatastarHandler()
// check for panics
// check for panics using the raw handler (no middleware)
func() {
defer func() {
if err := recover(); err != nil {
@@ -162,14 +199,14 @@ func (v *V) Page(route string, initContextFn func(c *Context)) {
}
}()
c := newContext("", "", v)
initContextFn(c)
raw(c)
c.view()
c.stopAllRoutines()
}()
// save page init function allows devmode to restore persisted ctx later
v.pageRegistry[route] = wrapped
if v.cfg.DevMode {
v.devModePageInitFnMap[route] = initContextFn
v.devModePageInitFnMap[route] = wrapped
}
v.mux.HandleFunc("GET "+route, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
v.logDebug(nil, "GET %s", r.URL.String())
@@ -183,7 +220,7 @@ func (v *V) Page(route string, initContextFn func(c *Context)) {
c.reqCtx = r.Context()
routeParams := extractParams(route, r.URL.Path)
c.injectRouteParams(routeParams)
initContextFn(c)
wrapped(c)
v.registerCtx(c)
if v.cfg.DevMode {
v.devModePersist(c)
@@ -191,10 +228,12 @@ func (v *V) Page(route string, initContextFn func(c *Context)) {
headElements := []h.H{h.Script(h.Type("module"), h.Src(v.datastarPath))}
headElements = append(headElements, v.documentHeadIncludes...)
headElements = append(headElements,
h.Meta(h.Data("signals", fmt.Sprintf("{'via-ctx':'%s'}", id))),
h.Meta(h.Data("signals", fmt.Sprintf("{'via-ctx':'%s','via-csrf':'%s'}", id, c.csrfToken))),
h.Meta(h.Data("init", "@get('/_sse')")),
h.Meta(h.Data("init", fmt.Sprintf(`window.addEventListener('beforeunload', (evt) => {
navigator.sendBeacon('/_session/close', '%s');});`, c.id))),
h.Meta(h.Attr("name", "view-transition"), h.Attr("content", "same-origin")),
h.Script(h.Raw(string(navigateJS))),
)
bodyElements := []h.H{c.view()}
@@ -208,8 +247,7 @@ func (v *V) Page(route string, initContextFn func(c *Context)) {
Title: v.cfg.DocumentTitle,
Head: headElements,
Body: bodyElements,
HTMLAttrs: []h.H{},
})
})
_ = view.Render(w)
}))
}
@@ -217,17 +255,17 @@ func (v *V) Page(route string, initContextFn func(c *Context)) {
func (v *V) registerCtx(c *Context) {
v.contextRegistryMutex.Lock()
defer v.contextRegistryMutex.Unlock()
if c == nil {
v.logErr(c, "failed to add nil context to registry")
return
}
v.contextRegistry[c.id] = c
v.logDebug(c, "new context added to registry")
v.logDebug(nil, "number of sessions in registry: %d", v.currSessionNum())
v.logDebug(nil, "number of sessions in registry: %d", len(v.contextRegistry))
}
func (v *V) currSessionNum() int {
return len(v.contextRegistry)
func (v *V) cleanupCtx(c *Context) {
c.dispose()
if v.cfg.DevMode {
v.devModeRemovePersisted(c)
}
v.unregisterCtx(c)
}
func (v *V) unregisterCtx(c *Context) {
@@ -239,7 +277,7 @@ func (v *V) unregisterCtx(c *Context) {
defer v.contextRegistryMutex.Unlock()
v.logDebug(c, "ctx removed from registry")
delete(v.contextRegistry, c.id)
v.logDebug(nil, "number of sessions in registry: %d", v.currSessionNum())
v.logDebug(nil, "number of sessions in registry: %d", len(v.contextRegistry))
}
func (v *V) getCtx(id string) (*Context, error) {
@@ -251,14 +289,150 @@ func (v *V) getCtx(id string) (*Context, error) {
return nil, fmt.Errorf("ctx '%s' not found", id)
}
// Start starts the Via HTTP server on the given address.
func (v *V) startReaper() {
ttl := v.cfg.ContextTTL
if ttl < 0 {
return
}
if ttl == 0 {
ttl = time.Hour
}
suspendAfter := v.cfg.ContextSuspendAfter
if suspendAfter == 0 {
suspendAfter = 15 * time.Minute
}
if suspendAfter > ttl {
suspendAfter = ttl
}
interval := suspendAfter / 3
if interval < 5*time.Second {
interval = 5 * time.Second
}
v.reaperStop = make(chan struct{})
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-v.reaperStop:
return
case <-ticker.C:
v.reapOrphanedContexts(suspendAfter, ttl)
}
}
}()
}
func (v *V) reapOrphanedContexts(suspendAfter, ttl time.Duration) {
now := time.Now()
v.contextRegistryMutex.RLock()
var toSuspend, toReap []*Context
for _, c := range v.contextRegistry {
if c.sseConnected.Load() {
continue
}
var disconnectedFor time.Duration
if dc := c.sseDisconnectedAt.Load(); dc != nil {
disconnectedFor = now.Sub(*dc)
} else {
disconnectedFor = now.Sub(c.createdAt)
}
if disconnectedFor > ttl {
toReap = append(toReap, c)
} else if disconnectedFor > suspendAfter && !c.suspended.Load() {
toSuspend = append(toSuspend, c)
}
}
v.contextRegistryMutex.RUnlock()
for _, c := range toSuspend {
v.logInfo(c, "suspending context (no SSE connection after %s)", suspendAfter)
c.suspend()
}
for _, c := range toReap {
v.logInfo(c, "reaping orphaned context (no SSE connection after %s)", ttl)
v.cleanupCtx(c)
}
}
// Start starts the Via HTTP server and blocks until a SIGINT or SIGTERM
// signal is received, then performs a graceful shutdown.
func (v *V) Start() {
v.logInfo(nil, "via started at [%s]", v.cfg.ServerAddress)
handler := http.Handler(v.mux)
if v.sessionManager != nil {
handler = v.sessionManager.LoadAndSave(v.mux)
}
log.Fatalf("[fatal] %v", http.ListenAndServe(v.cfg.ServerAddress, handler))
v.server = &http.Server{
Addr: v.cfg.ServerAddress,
Handler: handler,
}
v.startReaper()
errCh := make(chan error, 1)
go func() {
errCh <- v.server.ListenAndServe()
}()
v.logInfo(nil, "via started at [%s]", v.cfg.ServerAddress)
sigCh := make(chan os.Signal, 1)
ossignal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
select {
case sig := <-sigCh:
v.logInfo(nil, "received signal %v, shutting down", sig)
case err := <-errCh:
if err != nil && err != http.ErrServerClosed {
v.logger.Fatal().Err(err).Msg("http server failed")
}
return
}
v.Shutdown()
}
// Shutdown gracefully shuts down the server and all contexts.
// Safe for programmatic or test use.
func (v *V) Shutdown() {
if v.reaperStop != nil {
close(v.reaperStop)
}
v.logInfo(nil, "draining all contexts")
v.drainAllContexts()
if v.server != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := v.server.Shutdown(ctx); err != nil {
v.logErr(nil, "http server shutdown error: %v", err)
}
}
if v.pubsub != nil {
if err := v.pubsub.Close(); err != nil {
v.logErr(nil, "pubsub close error: %v", err)
}
}
v.defaultNATS = nil
v.logInfo(nil, "shutdown complete")
}
func (v *V) drainAllContexts() {
v.contextRegistryMutex.Lock()
contexts := make([]*Context, 0, len(v.contextRegistry))
for _, c := range v.contextRegistry {
contexts = append(contexts, c)
}
v.contextRegistry = make(map[string]*Context)
v.contextRegistryMutex.Unlock()
for _, c := range contexts {
v.logDebug(c, "disposing context")
c.dispose()
}
v.logInfo(nil, "drained %d context(s)", len(contexts))
}
// HTTPServeMux returns the underlying HTTP request multiplexer to enable user extentions, middleware and
@@ -270,21 +444,64 @@ func (v *V) HTTPServeMux() *http.ServeMux {
return v.mux
}
func (v *V) ensureDatastarHandler() {
if v.datastarHandlerRegistered {
return
// PubSub returns the configured PubSub backend, or nil if none is set.
func (v *V) PubSub() PubSub {
return v.pubsub
}
// Static serves files from a filesystem directory at the given URL prefix.
//
// Example:
//
// v.Static("/assets/", "./public")
func (v *V) Static(urlPrefix, dir string) {
if !strings.HasSuffix(urlPrefix, "/") {
urlPrefix += "/"
}
v.datastarHandlerRegistered = true
v.mux.HandleFunc("GET "+v.datastarPath, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/javascript")
_, _ = w.Write(v.datastarContent)
fileServer := http.StripPrefix(urlPrefix, http.FileServer(http.Dir(dir)))
v.mux.Handle("GET "+urlPrefix, noDirListing(fileServer))
}
// StaticFS serves files from an [fs.FS] at the given URL prefix.
// This is useful with //go:embed filesystems.
//
// Example:
//
// //go:embed static
// var staticFiles embed.FS
// v.StaticFS("/assets/", staticFiles)
func (v *V) StaticFS(urlPrefix string, fsys fs.FS) {
if !strings.HasSuffix(urlPrefix, "/") {
urlPrefix += "/"
}
fileServer := http.StripPrefix(urlPrefix, http.FileServerFS(fsys))
v.mux.Handle("GET "+urlPrefix, noDirListing(fileServer))
}
// noDirListing wraps a file server handler to return 404 for directory requests.
func noDirListing(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/") {
http.NotFound(w, r)
return
}
next.ServeHTTP(w, r)
})
}
func (v *V) ensureDatastarHandler() {
v.datastarOnce.Do(func() {
v.mux.HandleFunc("GET "+v.datastarPath, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/javascript")
_, _ = w.Write(v.datastarContent)
})
})
}
func (v *V) devModePersist(c *Context) {
p := filepath.Join(".via", "devmode", "ctx.json")
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
log.Fatalf("failed to create directory for devmode files: %v", err)
v.logFatal("failed to create directory for devmode files: %v", err)
}
// load persisted list from file, or empty list if file not found
@@ -326,10 +543,7 @@ func (v *V) devModeRemovePersisted(c *Context) {
}
file.Close()
// remove ctx to persisted list
if _, ok := ctxRegMap[c.id]; !ok {
delete(ctxRegMap, c.id)
}
delete(ctxRegMap, c.id)
// write persisted list to file
file, err = os.Create(p)
@@ -381,6 +595,7 @@ type patchType int
const (
patchTypeElements = iota
patchTypeElementsWithVT
patchTypeSignals
patchTypeScript
patchTypeRedirect
@@ -398,24 +613,21 @@ func New() *V {
v := &V{
mux: mux,
logger: newConsoleLogger(zerolog.InfoLevel),
contextRegistry: make(map[string]*Context),
devModePageInitFnMap: make(map[string]func(*Context)),
pageRegistry: make(map[string]func(*Context)),
sessionManager: scs.New(),
datastarPath: "/_datastar.js",
datastarContent: datastarJS,
cfg: Options{
DevMode: false,
ServerAddress: ":3000",
LogLvl: LogLevelInfo,
DocumentTitle: "⚡ Via",
},
}
v.mux.HandleFunc("GET /_sse", func(w http.ResponseWriter, r *http.Request) {
isReconnect := false
if r.Header.Get("last-event-id") == "via" {
isReconnect = true
}
var sigs map[string]any
_ = datastar.ReadSignals(r, &sigs)
cID, _ := sigs["via-ctx"].(string)
@@ -427,7 +639,9 @@ func New() *V {
}
c, err := v.getCtx(cID)
if err != nil {
v.logErr(nil, "sse stream failed to start: %v", err)
v.logInfo(nil, "context expired, reloading client: %s", cID)
sse := datastar.NewSSE(w, r)
sse.ExecuteScript("window.location.reload()")
return
}
c.reqCtx = r.Context()
@@ -437,33 +651,58 @@ func New() *V {
// use last-event-id to tell if request is a sse reconnect
sse.Send(datastar.EventTypePatchElements, []string{}, datastar.WithSSEEventId("via"))
// Drain stale patches on reconnect so the client gets fresh state
if c.sseDisconnectedAt.Load() != nil {
for {
select {
case <-c.patchChan:
default:
goto drained
}
}
drained:
}
c.sseConnected.Store(true)
c.sseDisconnectedAt.Store(nil)
v.logDebug(c, "SSE connection established")
go func() {
if isReconnect || v.cfg.DevMode {
c.Sync()
return
if c.suspended.Load() {
c.navMu.Lock()
c.suspended.Store(false)
if initFn := v.pageRegistry[c.route]; initFn != nil {
v.logInfo(c, "resuming suspended context")
initFn(c)
}
c.SyncSignals()
}()
c.navMu.Unlock()
}
go c.Sync()
for {
select {
case <-sse.Context().Done():
v.logDebug(c, "SSE connection ended")
c.sseConnected.Store(false)
now := time.Now()
c.sseDisconnectedAt.Store(&now)
return
case patch, ok := <-c.patchChan:
if !ok {
continue
}
case <-c.ctxDisposedChan:
v.logDebug(c, "context disposed, closing SSE")
return
case patch := <-c.patchChan:
switch patch.typ {
case patchTypeElements:
if err := sse.PatchElements(patch.content); err != nil {
// Only log if connection wasn't closed (avoids noise during shutdown/tests)
if sse.Context().Err() == nil {
v.logErr(c, "PatchElements failed: %v", err)
}
}
case patchTypeElementsWithVT:
if err := sse.PatchElements(patch.content, datastar.WithViewTransitions()); err != nil {
if sse.Context().Err() == nil {
v.logErr(c, "PatchElements (view transition) failed: %v", err)
}
}
case patchTypeSignals:
if err := sse.PatchSignals([]byte(patch.content)); err != nil {
if sse.Context().Err() == nil {
@@ -506,13 +745,29 @@ func New() *V {
v.logErr(nil, "action '%s' failed: %v", actionID, err)
return
}
csrfToken, _ := sigs["via-csrf"].(string)
if subtle.ConstantTimeCompare([]byte(csrfToken), []byte(c.csrfToken)) != 1 {
v.logWarn(c, "action '%s' rejected: invalid CSRF token", actionID)
http.Error(w, "invalid CSRF token", http.StatusForbidden)
return
}
if c.actionLimiter != nil && !c.actionLimiter.Allow() {
v.logWarn(c, "action '%s' rate limited", actionID)
http.Error(w, "rate limited", http.StatusTooManyRequests)
return
}
c.reqCtx = r.Context()
actionFn, err := c.getActionFn(actionID)
entry, err := c.getAction(actionID)
if err != nil {
v.logDebug(c, "action '%s' failed: %v", actionID, err)
return
}
// log err if actionFn panics
if entry.limiter != nil && !entry.limiter.Allow() {
v.logWarn(c, "action '%s' rate limited (per-action)", actionID)
http.Error(w, "rate limited", http.StatusTooManyRequests)
return
}
// log err if action panics
defer func() {
if r := recover(); r != nil {
v.logErr(c, "action '%s' failed: %v", actionID, r)
@@ -520,13 +775,50 @@ func New() *V {
}()
c.injectSignals(sigs)
actionFn()
if len(entry.middleware) > 0 {
chainMiddleware(entry.middleware, func(_ *Context) { entry.fn() })(c)
} else {
entry.fn()
}
})
v.mux.HandleFunc("POST /_navigate", func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
cID := r.FormValue("via-ctx")
csrfToken := r.FormValue("via-csrf")
navURL := r.FormValue("url")
popstate := r.FormValue("popstate") == "1"
if cID == "" || navURL == "" || !strings.HasPrefix(navURL, "/") {
http.Error(w, "missing or invalid parameters", http.StatusBadRequest)
return
}
c, err := v.getCtx(cID)
if err != nil {
v.logErr(nil, "navigate failed: %v", err)
http.Error(w, "context not found", http.StatusNotFound)
return
}
if subtle.ConstantTimeCompare([]byte(csrfToken), []byte(c.csrfToken)) != 1 {
v.logWarn(c, "navigate rejected: invalid CSRF token")
http.Error(w, "invalid CSRF token", http.StatusForbidden)
return
}
if c.actionLimiter != nil && !c.actionLimiter.Allow() {
v.logWarn(c, "navigate rate limited")
http.Error(w, "rate limited", http.StatusTooManyRequests)
return
}
c.reqCtx = r.Context()
v.logDebug(c, "SPA navigate to %s", navURL)
c.Navigate(navURL, popstate)
w.WriteHeader(http.StatusOK)
})
v.mux.HandleFunc("POST /_session/close", func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
log.Printf("Error reading body: %v", err)
v.logErr(nil, "error reading body: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
@@ -537,20 +829,31 @@ func New() *V {
v.logErr(c, "failed to handle session close: %v", err)
return
}
c.stopAllRoutines()
v.logDebug(c, "session close event triggered")
if v.cfg.DevMode {
v.devModeRemovePersisted(c)
}
v.unregisterCtx(c)
v.cleanupCtx(c)
})
dn, err := getSharedNATS()
if err != nil {
v.logWarn(nil, "embedded NATS unavailable: %v", err)
} else {
v.defaultNATS = dn
v.pubsub = &natsRef{dn: dn}
}
return v
}
func genRandID() string {
b := make([]byte, 4)
rand.Read(b)
return hex.EncodeToString(b)
}
func genCSRFToken() string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)[:8]
return hex.EncodeToString(b)
}
func extractParams(pattern, path string) map[string]string {
@@ -565,8 +868,24 @@ func extractParams(pattern, path string) map[string]string {
key := p[i][1 : len(p[i])-1] // remove {}
params[key] = u[i]
} else if p[i] != u[i] {
continue
return nil
}
}
return params
}
// matchRoute finds the registered page init function and extracted params for the given path.
func (v *V) matchRoute(path string) (route string, initFn func(*Context), params map[string]string) {
for pattern, fn := range v.pageRegistry {
if p := extractParams(pattern, path); p != nil {
return pattern, fn, p
}
}
return "", nil, nil
}
// Layout sets a layout function that wraps every page's view.
// The layout receives the page content as a function and returns the full view.
func (v *V) Layout(f func(func() h.H) h.H) {
v.layout = f
}

View File

@@ -1,11 +1,15 @@
package via
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/go-via/via/h"
"github.com/ryanhamamura/via/h"
"github.com/stretchr/testify/assert"
)
@@ -45,9 +49,7 @@ func TestCustomDatastarContent(t *testing.T) {
customScript := []byte("// Custom Datastar Script")
v := New()
v.Config(Options{
Datastar: &DatastarConfig{
Content: customScript,
},
DatastarContent: customScript,
})
v.Page("/", func(c *Context) {
c.View(func() h.H { return h.Div() })
@@ -65,9 +67,7 @@ func TestCustomDatastarContent(t *testing.T) {
func TestCustomDatastarPath(t *testing.T) {
v := New()
v.Config(Options{
Datastar: &DatastarConfig{
Path: "/assets/datastar.js",
},
DatastarPath: "/assets/datastar.js",
})
v.Page("/test", func(c *Context) {
c.View(func() h.H { return h.Div() })
@@ -132,6 +132,155 @@ func TestAction(t *testing.T) {
assert.Contains(t, body, "/_action/")
}
func TestEventTypes(t *testing.T) {
tests := []struct {
name string
attr string
buildEl func(trigger *actionTrigger) h.H
}{
{"OnSubmit", "data-on:submit", func(tr *actionTrigger) h.H { return h.Form(tr.OnSubmit()) }},
{"OnInput", "data-on:input", func(tr *actionTrigger) h.H { return h.Input(tr.OnInput()) }},
{"OnFocus", "data-on:focus", func(tr *actionTrigger) h.H { return h.Input(tr.OnFocus()) }},
{"OnBlur", "data-on:blur", func(tr *actionTrigger) h.H { return h.Input(tr.OnBlur()) }},
{"OnMouseEnter", "data-on:mouseenter", func(tr *actionTrigger) h.H { return h.Div(tr.OnMouseEnter()) }},
{"OnMouseLeave", "data-on:mouseleave", func(tr *actionTrigger) h.H { return h.Div(tr.OnMouseLeave()) }},
{"OnScroll", "data-on:scroll", func(tr *actionTrigger) h.H { return h.Div(tr.OnScroll()) }},
{"OnDblClick", "data-on:dblclick", func(tr *actionTrigger) h.H { return h.Div(tr.OnDblClick()) }},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var trigger *actionTrigger
v := New()
v.Page("/", func(c *Context) {
trigger = c.Action(func() {})
c.View(func() h.H { return tt.buildEl(trigger) })
})
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
v.mux.ServeHTTP(w, req)
body := w.Body.String()
assert.Contains(t, body, tt.attr)
assert.Contains(t, body, "/_action/"+trigger.id)
})
}
t.Run("WithSignal", func(t *testing.T) {
var trigger *actionTrigger
var sig *signal
v := New()
v.Page("/", func(c *Context) {
trigger = c.Action(func() {})
sig = c.Signal("val")
c.View(func() h.H {
return h.Div(trigger.OnDblClick(WithSignal(sig, "x")))
})
})
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
v.mux.ServeHTTP(w, req)
body := w.Body.String()
assert.Contains(t, body, "data-on:dblclick")
assert.Contains(t, body, "$"+sig.ID()+"=&#39;x&#39;")
})
}
func TestOnKeyDownWithWindow(t *testing.T) {
var trigger *actionTrigger
v := New()
v.Page("/", func(c *Context) {
trigger = c.Action(func() {})
c.View(func() h.H {
return h.Div(trigger.OnKeyDown("Enter", WithWindow()))
})
})
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
v.mux.ServeHTTP(w, req)
body := w.Body.String()
assert.Contains(t, body, "data-on:keydown__window")
assert.Contains(t, body, "evt.key===&#39;Enter&#39;")
}
func TestOnKeyDownMap(t *testing.T) {
t.Run("multiple bindings with different actions", func(t *testing.T) {
var move, shoot *actionTrigger
var dir *signal
v := New()
v.Page("/", func(c *Context) {
dir = c.Signal("none")
move = c.Action(func() {})
shoot = c.Action(func() {})
c.View(func() h.H {
return h.Div(
OnKeyDownMap(
KeyBind("w", move, WithSignal(dir, "up")),
KeyBind("ArrowUp", move, WithSignal(dir, "up"), WithPreventDefault()),
KeyBind(" ", shoot, WithPreventDefault()),
),
)
})
})
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
v.mux.ServeHTTP(w, req)
body := w.Body.String()
// Single attribute, window-scoped
assert.Contains(t, body, "data-on:keydown__window")
// Key dispatching
assert.Contains(t, body, "evt.key===&#39;w&#39;")
assert.Contains(t, body, "evt.key===&#39;ArrowUp&#39;")
assert.Contains(t, body, "evt.key===&#39; &#39;")
// Different actions referenced
assert.Contains(t, body, "/_action/"+move.id)
assert.Contains(t, body, "/_action/"+shoot.id)
// preventDefault only on ArrowUp and space branches
assert.Contains(t, body, "evt.key===&#39;ArrowUp&#39; ? (evt.preventDefault()")
assert.Contains(t, body, "evt.key===&#39; &#39; ? (evt.preventDefault()")
// 'w' branch should NOT have preventDefault
assert.NotContains(t, body, "evt.key===&#39;w&#39; ? (evt.preventDefault()")
})
t.Run("WithSignal per binding", func(t *testing.T) {
var move *actionTrigger
var dir *signal
v := New()
v.Page("/", func(c *Context) {
dir = c.Signal("none")
move = c.Action(func() {})
c.View(func() h.H {
return h.Div(
OnKeyDownMap(
KeyBind("w", move, WithSignal(dir, "up")),
KeyBind("s", move, WithSignal(dir, "down")),
),
)
})
})
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
v.mux.ServeHTTP(w, req)
body := w.Body.String()
assert.Contains(t, body, "$"+dir.ID()+"=&#39;up&#39;")
assert.Contains(t, body, "$"+dir.ID()+"=&#39;down&#39;")
})
t.Run("empty bindings returns nil", func(t *testing.T) {
result := OnKeyDownMap()
assert.Nil(t, result)
})
}
func TestConfig(t *testing.T) {
v := New()
v.Config(Options{DocumentTitle: "Test"})
@@ -144,3 +293,144 @@ func TestPage_PanicsOnNoView(t *testing.T) {
v.Page("/", func(c *Context) {})
})
}
func TestReaperCleansOrphanedContexts(t *testing.T) {
v := New()
c := newContext("orphan-1", "/", v)
c.createdAt = time.Now().Add(-time.Minute) // created 1 min ago
v.registerCtx(c)
_, err := v.getCtx("orphan-1")
assert.NoError(t, err)
v.reapOrphanedContexts(5*time.Second, 10*time.Second)
_, err = v.getCtx("orphan-1")
assert.Error(t, err, "orphaned context should have been reaped")
}
func TestReaperSuspendsContext(t *testing.T) {
v := New()
c := newContext("suspend-1", "/", v)
dc := time.Now().Add(-20 * time.Minute)
c.sseDisconnectedAt.Store(&dc)
v.registerCtx(c)
v.reapOrphanedContexts(15*time.Minute, time.Hour)
got, err := v.getCtx("suspend-1")
assert.NoError(t, err, "suspended context should still be in registry")
assert.True(t, got.suspended.Load(), "context should be marked suspended")
}
func TestReaperReapsAfterTTL(t *testing.T) {
v := New()
c := newContext("reap-1", "/", v)
dc := time.Now().Add(-2 * time.Hour)
c.sseDisconnectedAt.Store(&dc)
c.suspended.Store(true)
v.registerCtx(c)
v.reapOrphanedContexts(15*time.Minute, time.Hour)
_, err := v.getCtx("reap-1")
assert.Error(t, err, "context past TTL should have been reaped")
}
func TestReaperIgnoresAlreadySuspended(t *testing.T) {
v := New()
c := newContext("already-sus-1", "/", v)
dc := time.Now().Add(-20 * time.Minute)
c.sseDisconnectedAt.Store(&dc)
c.suspended.Store(true)
// give it a fresh pageStopChan so we can verify it's not re-closed
c.pageStopChan = make(chan struct{})
v.registerCtx(c)
v.reapOrphanedContexts(15*time.Minute, time.Hour)
got, err := v.getCtx("already-sus-1")
assert.NoError(t, err, "already-suspended context within TTL should survive")
assert.True(t, got.suspended.Load())
// pageStopChan should still be open (not re-suspended)
select {
case <-got.pageStopChan:
t.Fatal("pageStopChan was closed — context was re-suspended")
default:
}
}
func TestReaperIgnoresConnectedContexts(t *testing.T) {
v := New()
c := newContext("connected-1", "/", v)
c.createdAt = time.Now().Add(-time.Minute)
c.sseConnected.Store(true)
v.registerCtx(c)
v.reapOrphanedContexts(5*time.Second, 10*time.Second)
_, err := v.getCtx("connected-1")
assert.NoError(t, err, "connected context should survive reaping")
}
func TestReaperDisabledWithNegativeTTL(t *testing.T) {
v := New()
v.cfg.ContextTTL = -1
v.startReaper()
assert.Nil(t, v.reaperStop, "reaper should not start with negative TTL")
}
func TestCleanupCtxIdempotent(t *testing.T) {
v := New()
c := newContext("idempotent-1", "/", v)
v.registerCtx(c)
assert.NotPanics(t, func() {
v.cleanupCtx(c)
v.cleanupCtx(c)
})
_, err := v.getCtx("idempotent-1")
assert.Error(t, err, "context should be removed after cleanup")
}
func TestDevModeRemovePersistedFix(t *testing.T) {
v := New()
v.cfg.DevMode = true
dir := filepath.Join(t.TempDir(), ".via", "devmode")
p := filepath.Join(dir, "ctx.json")
assert.NoError(t, os.MkdirAll(dir, 0755))
// Write a persisted context
ctxRegMap := map[string]string{"test-ctx-1": "/"}
f, err := os.Create(p)
assert.NoError(t, err)
assert.NoError(t, json.NewEncoder(f).Encode(ctxRegMap))
f.Close()
// Patch devModeRemovePersisted to use our temp path by calling it
// directly — we need to override the path. Instead, test via the
// actual function by temporarily changing the working dir.
origDir, _ := os.Getwd()
assert.NoError(t, os.Chdir(t.TempDir()))
defer os.Chdir(origDir)
// Re-create the structure in the temp dir
assert.NoError(t, os.MkdirAll(filepath.Join(".via", "devmode"), 0755))
p2 := filepath.Join(".via", "devmode", "ctx.json")
f2, _ := os.Create(p2)
json.NewEncoder(f2).Encode(map[string]string{"test-ctx-1": "/"})
f2.Close()
c := newContext("test-ctx-1", "/", v)
v.devModeRemovePersisted(c)
// Read back and verify
f3, err := os.Open(p2)
assert.NoError(t, err)
defer f3.Close()
var result map[string]string
assert.NoError(t, json.NewDecoder(f3).Decode(&result))
assert.Empty(t, result, "persisted context should be removed")
}