36 Commits

Author SHA1 Message Date
f833498b65 docs: clarify pr command step 8 for worktree usage (#6)
All checks were successful
CI / Build and Test (push) Successful in 37s
2026-02-20 19:39:26 +00:00
6064ddd856 style: normalize struct field alignment (#5)
All checks were successful
CI / Build and Test (push) Successful in 36s
2026-02-20 19:31:32 +00:00
dc56261b58 fix: remove context reaper to prevent background tabs from going stale (#4)
Some checks failed
CI / Build and Test (push) Failing after 35s
2026-02-20 19:11:12 +00:00
c0f4782f2b fix: maplibre reactive signal bugs and stale signal re-push (#3)
Some checks failed
CI / Build and Test (push) Failing after 36s
2026-02-20 18:31:27 +00:00
47dcab8fea chore: make Gitea the primary remote, GitHub as mirror (#2)
Some checks failed
CI / Build and Test (push) Has been cancelled
2026-02-20 18:25:39 +00:00
Ryan Hamamura
e63ebd1401 ci: re-trigger workflow
Some checks failed
CI / Build and Test (push) Has been cancelled
2026-02-19 14:49:48 -10:00
Ryan Hamamura
b26ded951f ci: trigger initial workflow run
Some checks failed
CI / Build and Test (push) Has been cancelled
2026-02-19 14:45:19 -10:00
Ryan Hamamura
8bb1b99ae9 chore: add PR workflow and worktree-aware release process
Some checks failed
CI / Build and Test (push) Has been cancelled
2026-02-19 14:43:46 -10:00
Ryan Hamamura
0d8bf04446 chore: add worktree support for parallel Claude Code sessions
Some checks failed
CI / Build and Test (push) Has been cancelled
2026-02-19 13:57:06 -10:00
Ryan Hamamura
742212fd20 feat: add maplibre subpackage for type-safe MapLibre GL JS maps
Some checks failed
CI / Build and Test (push) Has been cancelled
Provides a Go API for interactive maps within Via applications:
- Plugin serves vendored MapLibre GL JS v4.7.1 assets
- Map struct with pre/post-render source, layer, marker, popup management
- Viewport signal sync (center, zoom, bearing, pitch) via hidden inputs
- FlyTo, SetCenter, SetZoom and other viewport setters via ExecScript
- Idempotent init script with SPA cleanup via MutationObserver
- Example app demonstrating markers, GeoJSON layers, and FlyTo actions
2026-02-19 13:37:16 -10:00
Ryan Hamamura
60009124c9 feat: add declarative Options.Streams for automatic JetStream stream creation
Some checks failed
CI / Build and Test (push) Has been cancelled
Streams listed in Options.Streams are created by Start() when the
embedded NATS server initializes, replacing manual EnsureStream calls
during setup. Migrates nats-chatroom and pubsub-crud examples.
2026-02-19 12:24:44 -10:00
Ryan Hamamura
42b21348cb fix: use random port for embedded NATS to avoid binding conflicts
Port 0 is treated as default (4222) by NATS server, causing hangs when
that port is unavailable. Port -1 (RANDOM_PORT) binds to an OS-assigned
free port, which is correct for an embedded server.
2026-02-19 12:24:37 -10:00
Ryan Hamamura
58ad9a2699 feat: add SSE keepalive and liveness tracking for resilient connections
Some checks failed
CI / Build and Test (push) Has been cancelled
Add 30s keepalive pings to prevent proxy/CDN idle timeouts from killing
SSE connections silently. Track lastSeenAt on each SSE connect attempt
so Datastar's retry signals keep contexts alive through the reaper.
2026-02-19 12:07:25 -10:00
Ryan Hamamura
f3a9c8036f refactor: use computed signals in pubsub-crud and chatroom examples
Some checks failed
CI / Build and Test (push) Has been cancelled
2026-02-19 09:03:13 -10:00
Ryan Hamamura
6763e1a420 feat: add computed signals for derived reactive values
All checks were successful
CI / Build and Test (push) Successful in 33s
Read-only signals whose value is a function of other signals,
recomputed automatically at sync time. Supports String, Int, Bool,
and Text methods. Components store computed signals on the parent
page context like regular signals.
2026-02-18 09:22:40 -10:00
Ryan Hamamura
5d61149fa3 fix: make embedded NATS opt-in so tests don't hang
All checks were successful
CI / Build and Test (push) Successful in 31s
Move NATS startup from New() to Start(), so tests that don't use
pubsub never block on server initialization. Add a 10s timeout to
WaitForServer() and skip NATS tests gracefully when unavailable.
2026-02-18 08:45:03 -10:00
Ryan Hamamura
08b7dbd17f feat: add WithDebounce and WithThrottle action trigger options
Unify event attribute construction through buildAttrKey() so debounce,
throttle, and window modifiers compose cleanly. OnChange no longer
hardcodes a 200ms debounce — callers opt in explicitly.
2026-02-18 08:44:58 -10:00
Ryan Hamamura
cd2bfb6978 feat: add /release claude command for automated releases
All checks were successful
CI / Build and Test (push) Successful in 29s
2026-02-13 18:05:24 -10:00
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
60 changed files with 6383 additions and 818 deletions

14
.claude/commands/pr.md Normal file
View File

@@ -0,0 +1,14 @@
Create a PR on Gitea, wait for CI, and squash-merge it. Push code to both remotes.
1. If in a worktree (working directory contains `.claude/worktrees/`), you are already on a feature branch — do NOT create a new one. Otherwise, create a new branch from main with a descriptive name.
2. Stage and commit all changes with a clean, semantic commit message. No Claude attribution lines.
3. Fetch latest main and rebase: `git fetch gitea main && git rebase gitea/main`.
- If conflicts occur, abort the rebase (`git rebase --abort`), analyze the conflicting files, write a plan to resolve them, and present the plan to the user before proceeding.
4. Push the branch to both remotes: `git push -u gitea <branch> && git push origin <branch>` (use `--force-with-lease` if already pushed).
5. Create a Gitea PR: `tea pr create --head <branch> --base main`. Reference related issues with `#X`. Only use `Closes #X` if the PR fully resolves the issue.
6. Wait for CI to pass: poll Gitea CI status. If CI fails, report the failure and stop — do not merge.
7. Once CI passes, squash-merge on Gitea: `tea pr merge <index> --style squash` with a clean, semantic commit message including the PR number. No Claude attribution lines.
8. Update local main and push to both remotes. If in a worktree, `main` is checked out in the primary tree, so run from there: `cd <primary-worktree> && git pull gitea main && git push origin main` (the primary worktree path is the repo root without `.claude/worktrees/…`). If not in a worktree: `git checkout main && git pull gitea main && git push origin main`.
9. Clean up remote branches: `git push gitea --delete <branch> && git push origin --delete <branch>`.
10. Prune refs: `git remote prune gitea && git remote prune origin`.
11. Report the merged PR URL.

View File

@@ -0,0 +1,20 @@
Create a new release on Gitea. Push tags to both remotes.
## Pre-flight
1. **Worktree guard**: If the working directory is inside `.claude/worktrees/`, STOP and tell the user: "Releases must be created from a non-worktree session on main. Exit this worktree or start a new session, then run /release." Do not proceed.
2. Verify you are on `main`. If not, STOP.
3. Verify there are no uncommitted changes. If there are, STOP — they should go through a PR.
4. Run `git pull --ff-only` on main. Fetch tags from all remotes.
## Release
5. Review commits since the last tag. Recommend a semver bump:
- **major**: breaking/incompatible API changes
- **minor**: new features, meaningful new behavior
- **patch**: bug fixes, docs, refactoring with no new features
Present the proposed version, bump rationale, and commit list. Wait for user approval.
6. Tag the new version. Push the tag to both remotes: `git push gitea <tag> && git push origin <tag>`.
7. Generate release notes grouped by type (features, fixes, chores).
8. Create a Gitea release with `tea releases create` using the notes.
9. Report the release URL and confirm all remotes are up to date.

View File

@@ -8,9 +8,6 @@ on:
branches:
- '**'
permissions:
contents: read
jobs:
build-test:
name: Build and Test

4
.gitignore vendored
View File

@@ -48,6 +48,10 @@ internal/examples/plugins/plugins
internal/examples/realtimechart/realtimechart
internal/examples/shakespeare/shakespeare
internal/examples/nats-chatroom/nats-chatroom
/nats-chatroom
# NATS data directory
data/
# Claude Code worktrees
.claude/worktrees/

20
CLAUDE.md Normal file
View File

@@ -0,0 +1,20 @@
# Via Project Instructions
## Workflow
All changes go through PRs:
1. Enter a worktree (`EnterWorktree`) at session start.
2. Make changes, commit with semantic messages.
3. `/pr` to push, open a PR, wait for CI, and squash-merge.
## Releasing
Run `/release` from a **non-worktree session on main**. It tags and publishes
what is already on main — it does not commit new changes.
## Worktree Usage
Always enter a worktree at the start of a session using the `EnterWorktree`
tool. This prevents parallel Claude Code sessions from interfering with each
other.

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

@@ -3,6 +3,7 @@ package via
import (
"fmt"
"strconv"
"time"
"github.com/ryanhamamura/via/h"
)
@@ -23,6 +24,8 @@ type triggerOpts struct {
value string
window bool
preventDefault bool
debounce time.Duration
throttle time.Duration
}
type withSignalOpt struct {
@@ -58,8 +61,43 @@ func WithPreventDefault() ActionTriggerOption {
return withPreventDefaultOpt{}
}
type withDebounceOpt struct{ d time.Duration }
func (o withDebounceOpt) apply(opts *triggerOpts) { opts.debounce = o.d }
// WithDebounce adds a debounce modifier to the event trigger.
func WithDebounce(d time.Duration) ActionTriggerOption { return withDebounceOpt{d} }
type withThrottleOpt struct{ d time.Duration }
func (o withThrottleOpt) apply(opts *triggerOpts) { opts.throttle = o.d }
// WithThrottle adds a throttle modifier to the event trigger.
func WithThrottle(d time.Duration) ActionTriggerOption { return withThrottleOpt{d} }
// formatDuration renders a duration as e.g. "200ms" for Datastar modifiers.
func formatDuration(d time.Duration) string {
return fmt.Sprintf("%dms", d.Milliseconds())
}
// buildAttrKey constructs a Datastar attribute key with modifiers.
// Order: event → debounce/throttle → window.
func buildAttrKey(event string, opts *triggerOpts) string {
key := "on:" + event
if opts.debounce > 0 {
key += "__debounce." + formatDuration(opts.debounce)
}
if opts.throttle > 0 {
key += "__throttle." + formatDuration(opts.throttle)
}
if opts.window {
key += "__window"
}
return key
}
// WithSignal sets a signal value before triggering the action.
func WithSignal(sig *signal, value string) ActionTriggerOption {
func WithSignal(sig *Signal, value string) ActionTriggerOption {
return withSignalOpt{
signalID: sig.ID(),
value: fmt.Sprintf("'%s'", value),
@@ -67,7 +105,7 @@ func WithSignal(sig *signal, value string) ActionTriggerOption {
}
// WithSignalInt sets a signal to an int value before triggering the action.
func WithSignalInt(sig *signal, value int) ActionTriggerOption {
func WithSignalInt(sig *Signal, value int) ActionTriggerOption {
return withSignalOpt{
signalID: sig.ID(),
value: strconv.Itoa(value),
@@ -97,14 +135,62 @@ func actionURL(id string) string {
// to element nodes in a view.
func (a *actionTrigger) OnClick(options ...ActionTriggerOption) h.H {
opts := applyOptions(options...)
return h.Data("on:click", buildOnExpr(actionURL(a.id), &opts))
return h.Data(buildAttrKey("click", &opts), buildOnExpr(actionURL(a.id), &opts))
}
// OnChange returns a via.h DOM attribute that triggers on input change. It can be added
// to element nodes in a view.
func (a *actionTrigger) OnChange(options ...ActionTriggerOption) h.H {
opts := applyOptions(options...)
return h.Data("on:change__debounce.200ms", buildOnExpr(actionURL(a.id), &opts))
return h.Data(buildAttrKey("change", &opts), 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(buildAttrKey("submit", &opts), 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(buildAttrKey("input", &opts), 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(buildAttrKey("focus", &opts), 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(buildAttrKey("blur", &opts), 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(buildAttrKey("mouseenter", &opts), 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(buildAttrKey("mouseleave", &opts), 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(buildAttrKey("scroll", &opts), 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(buildAttrKey("dblclick", &opts), buildOnExpr(actionURL(a.id), &opts))
}
// OnKeyDown returns a via.h DOM attribute that triggers when a key is pressed.
@@ -116,11 +202,7 @@ func (a *actionTrigger) OnKeyDown(key string, options ...ActionTriggerOption) h.
if key != "" {
condition = fmt.Sprintf("evt.key==='%s' &&", key)
}
attrName := "on:keydown"
if opts.window {
attrName = "on:keydown__window"
}
return h.Data(attrName, fmt.Sprintf("%s%s", condition, buildOnExpr(actionURL(a.id), &opts)))
return h.Data(buildAttrKey("keydown", &opts), fmt.Sprintf("%s%s", condition, buildOnExpr(actionURL(a.id), &opts)))
}
// KeyBinding pairs a key with an action and per-binding options.

55
computed.go Normal file
View File

@@ -0,0 +1,55 @@
package via
import (
"fmt"
"strconv"
"strings"
"github.com/ryanhamamura/via/h"
)
// computedSignal is a read-only signal whose value is derived from other signals.
// It recomputes on every read and is included in patches only when the value changes.
type computedSignal struct {
id string
compute func() string
lastVal string
changed bool
}
func (s *computedSignal) ID() string {
return s.id
}
func (s *computedSignal) String() string {
return s.compute()
}
func (s *computedSignal) Int() int {
if n, err := strconv.Atoi(s.String()); err == nil {
return n
}
return 0
}
func (s *computedSignal) Bool() bool {
val := strings.ToLower(s.String())
return val == "true" || val == "1" || val == "yes" || val == "on"
}
func (s *computedSignal) Text() h.H {
return h.Span(h.Data("text", "$"+s.id))
}
// recompute calls the compute function and sets changed if the value differs from lastVal.
func (s *computedSignal) recompute() {
val := s.compute()
if val != s.lastVal {
s.lastVal = val
s.changed = true
}
}
func (s *computedSignal) patchValue() string {
return fmt.Sprintf("%v", s.lastVal)
}

190
computed_test.go Normal file
View File

@@ -0,0 +1,190 @@
package via
import (
"bytes"
"fmt"
"testing"
"github.com/ryanhamamura/via/h"
"github.com/stretchr/testify/assert"
)
func TestComputedBasic(t *testing.T) {
v := New()
var cs *computedSignal
v.Page("/", func(c *Context) {
sig1 := c.Signal("hello")
sig2 := c.Signal("world")
cs = c.Computed(func() string {
return sig1.String() + " " + sig2.String()
})
c.View(func() h.H { return h.Div() })
})
assert.Equal(t, "hello world", cs.String())
}
func TestComputedReactivity(t *testing.T) {
v := New()
var cs *computedSignal
var sig1 *Signal
v.Page("/", func(c *Context) {
sig1 = c.Signal("a")
sig2 := c.Signal("b")
cs = c.Computed(func() string {
return sig1.String() + sig2.String()
})
c.View(func() h.H { return h.Div() })
})
assert.Equal(t, "ab", cs.String())
sig1.SetValue("x")
assert.Equal(t, "xb", cs.String())
}
func TestComputedInt(t *testing.T) {
v := New()
var cs *computedSignal
v.Page("/", func(c *Context) {
sig := c.Signal(21)
cs = c.Computed(func() string {
return fmt.Sprintf("%d", sig.Int()*2)
})
c.View(func() h.H { return h.Div() })
})
assert.Equal(t, 42, cs.Int())
}
func TestComputedBool(t *testing.T) {
v := New()
var cs *computedSignal
v.Page("/", func(c *Context) {
sig := c.Signal("true")
cs = c.Computed(func() string {
return sig.String()
})
c.View(func() h.H { return h.Div() })
})
assert.True(t, cs.Bool())
}
func TestComputedText(t *testing.T) {
v := New()
var cs *computedSignal
v.Page("/", func(c *Context) {
cs = c.Computed(func() string { return "hi" })
c.View(func() h.H { return h.Div() })
})
var buf bytes.Buffer
err := cs.Text().Render(&buf)
assert.NoError(t, err)
assert.Contains(t, buf.String(), `data-text="$`+cs.ID()+`"`)
}
func TestComputedChangeDetection(t *testing.T) {
v := New()
var ctx *Context
var sig *Signal
v.Page("/", func(c *Context) {
ctx = c
sig = c.Signal("a")
c.Computed(func() string {
return sig.String() + "!"
})
c.View(func() h.H { return h.Div() })
})
// First patch includes computed (changed=true from init)
patch1 := ctx.prepareSignalsForPatch()
assert.NotEmpty(t, patch1)
// Second patch: nothing changed, computed should not be included
patch2 := ctx.prepareSignalsForPatch()
// Regular signal still has changed=true (not reset in prepareSignalsForPatch),
// but computed should not appear since its value didn't change.
hasComputed := false
ctx.signals.Range(func(_, value any) bool {
if cs, ok := value.(*computedSignal); ok {
_, inPatch := patch2[cs.ID()]
hasComputed = inPatch
}
return true
})
assert.False(t, hasComputed)
// After changing dependency, computed should reappear
sig.SetValue("b")
patch3 := ctx.prepareSignalsForPatch()
found := false
ctx.signals.Range(func(_, value any) bool {
if cs, ok := value.(*computedSignal); ok {
if v, ok := patch3[cs.ID()]; ok {
assert.Equal(t, "b!", v)
found = true
}
}
return true
})
assert.True(t, found)
}
func TestComputedInComponent(t *testing.T) {
v := New()
var cs *computedSignal
var parentCtx *Context
v.Page("/", func(c *Context) {
parentCtx = c
c.Component(func(comp *Context) {
sig := comp.Signal("via")
cs = comp.Computed(func() string {
return "hello " + sig.String()
})
comp.View(func() h.H { return h.Div() })
})
c.View(func() h.H { return h.Div() })
})
assert.Equal(t, "hello via", cs.String())
// Verify it's stored on the parent page context
found := false
parentCtx.signals.Range(func(_, value any) bool {
if stored, ok := value.(*computedSignal); ok && stored.ID() == cs.ID() {
found = true
}
return true
})
assert.True(t, found)
}
func TestComputedIsReadOnly(t *testing.T) {
// Compile-time guarantee: *computedSignal has no Bind() or SetValue() methods.
// This test exists as documentation — if someone adds those methods, the
// interface assertion below will need updating and serve as a reminder.
var cs interface{} = &computedSignal{}
type writable interface {
SetValue(any)
}
type bindable interface {
Bind() h.H
}
_, isWritable := cs.(writable)
_, isBindable := cs.(bindable)
assert.False(t, isWritable, "computedSignal must not have SetValue")
assert.False(t, isBindable, "computedSignal must not have Bind")
}
func TestComputedInjectSignalsSkips(t *testing.T) {
v := New()
var ctx *Context
var cs *computedSignal
v.Page("/", func(c *Context) {
ctx = c
cs = c.Computed(func() string { return "fixed" })
c.View(func() h.H { return h.Div() })
})
// Simulate browser sending back a value for the computed signal — should be ignored
ctx.injectSignals(map[string]any{
cs.ID(): "injected",
})
assert.Equal(t, "fixed", cs.String())
}

View File

@@ -51,7 +51,17 @@ type Options struct {
// 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 enables publish/subscribe messaging. When nil, an embedded NATS
// server starts automatically in Start(). Supply any PubSub implementation
// to replace it.
PubSub PubSub
// Streams declares JetStream streams to create when Start() initializes
// the embedded NATS server. Ignored when a custom PubSub is configured.
Streams []StreamConfig
// 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,12 +5,14 @@ import (
"context"
"encoding/json"
"fmt"
"maps"
"reflect"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/ryanhamamura/via/h"
"golang.org/x/time/rate"
)
// Context is the living bridge between Go and the browser.
@@ -19,20 +21,27 @@ 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
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]
}
// View defines the UI rendered by this context.
@@ -43,7 +52,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.
@@ -75,7 +88,6 @@ func (c *Context) Component(initCtx func(c *Context)) func() h.H {
compCtx.parentPageCtx = c
}
initCtx(compCtx)
c.componentRegistry[id] = compCtx
return compCtx.view
}
@@ -100,39 +112,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.
@@ -154,11 +173,11 @@ func (c *Context) OnInterval(duration time.Duration, handler func()) *OnInterval
// the Context before each action call.
// If any signal value is updated by the server, the update is automatically sent to the
// browser when using Sync() or SyncSignsls().
func (c *Context) Signal(v any) *signal {
func (c *Context) Signal(v any) *Signal {
sigID := genRandID()
if v == nil {
c.app.logErr(c, "failed to bind signal: nil signal value")
return &signal{
return &Signal{
id: sigID,
val: "error",
err: fmt.Errorf("context '%s' failed to bind signal '%s': nil signal value", c.id, sigID),
@@ -170,7 +189,7 @@ func (c *Context) Signal(v any) *signal {
v = string(j)
}
}
sig := &signal{
sig := &Signal{
id: sigID,
val: v,
changed: true,
@@ -187,6 +206,40 @@ func (c *Context) Signal(v any) *signal {
}
// Computed creates a read-only signal whose value is derived from the given function.
// The function is called on every read (String/Int/Bool) for fresh values,
// and during sync to detect changes for browser patches.
//
// Computed signals cannot be bound to inputs or set manually.
//
// Example:
//
// full := c.Computed(func() string {
// return first.String() + " " + last.String()
// })
// c.View(func() h.H {
// return h.Span(full.Text())
// })
func (c *Context) Computed(fn func() string) *computedSignal {
sigID := genRandID()
initial := fn()
cs := &computedSignal{
id: sigID,
compute: fn,
lastVal: initial,
changed: true,
}
c.mu.Lock()
defer c.mu.Unlock()
if c.isComponent() {
c.parentPageCtx.signals.Store(sigID, cs)
} else {
c.signals.Store(sigID, cs)
}
return cs
}
func (c *Context) injectSignals(sigs map[string]any) {
if sigs == nil {
c.app.logErr(c, "signal injection failed: nil signals")
@@ -197,15 +250,15 @@ func (c *Context) injectSignals(sigs map[string]any) {
defer c.mu.Unlock()
for sigID, val := range sigs {
if _, ok := c.signals.Load(sigID); !ok {
c.signals.Store(sigID, &signal{
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 {
if sig, ok := item.(*Signal); ok {
sig.val = val
sig.changed = false
}
@@ -228,13 +281,21 @@ func (c *Context) prepareSignalsForPatch() map[string]any {
defer c.mu.RUnlock()
updatedSigs := make(map[string]any)
c.signals.Range(func(sigID, value any) bool {
if sig, ok := value.(*signal); ok {
switch sig := value.(type) {
case *Signal:
if sig.err != nil {
c.app.logWarn(c, "signal '%s' is out of sync: %v", sig.id, sig.err)
return true
}
if sig.changed {
updatedSigs[sigID.(string)] = fmt.Sprintf("%v", sig.val)
sig.changed = false
}
case *computedSignal:
sig.recompute()
if sig.changed {
updatedSigs[sigID.(string)] = sig.patchValue()
sig.changed = false
}
}
return true
@@ -255,15 +316,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)})
@@ -320,6 +388,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.
@@ -351,6 +428,46 @@ func (c *Context) ReplaceURLf(format string, a ...any) {
c.ReplaceURL(fmt.Sprintf(format, a...))
}
// 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()
}
// 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() {
@@ -361,7 +478,7 @@ func (c *Context) dispose() {
}
// stopAllRoutines closes ctxDisposedChan, broadcasting to all listening
// goroutines (OnIntervalRoutine, SSE loop) that this context is done.
// goroutines (OnInterval, SSE loop) that this context is done.
func (c *Context) stopAllRoutines() {
select {
case <-c.ctxDisposedChan:
@@ -375,12 +492,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
@@ -466,20 +580,67 @@ func (c *Context) unsubscribeAll() {
}
}
// 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 {
panic("create context failed: app pointer is nil")
}
return &Context{
id: id,
route: route,
routeParams: make(map[string]string),
app: v,
componentRegistry: make(map[string]*Context),
actionRegistry: make(map[string]func()),
signals: new(sync.Map),
patchChan: make(chan patch, 1),
ctxDisposedChan: make(chan struct{}, 1),
id: id,
route: route,
csrfToken: genCSRFToken(),
routeParams: make(map[string]string),
app: v,
actionLimiter: newLimiter(v.actionRateLimit, defaultActionRate, defaultActionBurst),
actionRegistry: make(map[string]actionEntry),
signals: new(sync.Map),
patchChan: make(chan patch, 8),
ctxDisposedChan: make(chan struct{}, 1),
pageStopChan: make(chan struct{}),
createdAt: time.Now(),
}
}

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

@@ -0,0 +1,177 @@
# 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,
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) |
| `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

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

@@ -0,0 +1,264 @@
# PubSub and Sessions
Infrastructure for multi-user real-time communication and persistent state.
## PubSub
Via includes an embedded NATS server that starts automatically with `v.Start()`. 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.
### Declaring streams
The recommended approach is to declare streams in `Options.Streams`. They are created automatically when `v.Start()` initializes the embedded NATS server:
```go
v.Config(via.Options{
Streams: []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 |
For dynamic stream creation after startup, `EnsureStream` is also available:
```go
err := via.EnsureStream(v, via.StreamConfig{
Name: "EVENTS",
Subjects: []string{"events.>"},
MaxMsgs: 500,
MaxAge: 12 * time.Hour,
})
```
### 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 lives until the browser tab closes (detected via a `beforeunload` beacon) or the server shuts down. There is no background reaper — contexts persist across temporary SSE disconnections so backgrounded tabs resume seamlessly.
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())
}

2
go.mod
View File

@@ -14,6 +14,7 @@ require (
github.com/rs/zerolog v1.34.0
github.com/starfederation/datastar-go v1.0.3
github.com/stretchr/testify v1.11.1
golang.org/x/time v0.14.0
)
require (
@@ -37,6 +38,5 @@ require (
github.com/valyala/bytebufferpool v1.0.0 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/time v0.14.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

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

@@ -0,0 +1,52 @@
// Spike to validate that Datastar's data-effect re-evaluates when signals are
// updated via PatchSignals from the server, and that Via's hex signal IDs work
// in $signalID expression syntax.
package main
import (
"fmt"
"math/rand"
"time"
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
)
func main() {
v := via.New()
v.Config(via.Options{
DocumentTitle: "data-effect Spike",
ServerAddress: ":7332",
DevMode: true,
})
v.Page("/", func(c *via.Context) {
x := c.Signal(0)
y := c.Signal(0)
c.OnInterval(time.Second, func() {
x.SetValue(rand.Intn(500))
y.SetValue(rand.Intn(500))
c.SyncSignals()
})
c.View(func() h.H {
return h.Div(
h.Attr("style", "padding:1rem;font-family:sans-serif"),
h.H1(h.Text("data-effect Spike")),
h.P(h.Text("x: "), x.Text(), h.Text(" y: "), y.Text()),
h.Div(
h.ID("box"),
h.Attr("style", "width:20px;height:20px;background:red;position:absolute"),
h.DataEffect(fmt.Sprintf(
"document.getElementById('box').style.left=$%s+'px';"+
"document.getElementById('box').style.top=$%s+'px'",
x.ID(), y.ID(),
)),
),
)
})
})
v.Start()
}

View File

@@ -0,0 +1,154 @@
package main
import (
"math/rand"
"time"
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
"github.com/ryanhamamura/via/maplibre"
)
func main() {
v := via.New()
v.Config(via.Options{
DocumentTitle: "MapLibre GL Example",
ServerAddress: ":7331",
DevMode: true,
Plugins: []via.Plugin{maplibre.Plugin},
})
v.Page("/", func(c *via.Context) {
m := maplibre.New(c, maplibre.Options{
Style: "https://demotiles.maplibre.org/style.json",
Center: maplibre.LngLat{Lng: -122.4194, Lat: 37.7749},
Zoom: 10,
Height: "500px",
})
m.AddControl("nav", maplibre.NavigationControl{})
m.AddControl("scale", maplibre.ScaleControl{Unit: "metric"})
// Static markers with popups
m.AddMarker("sf", maplibre.Marker{
LngLat: maplibre.LngLat{Lng: -122.4194, Lat: 37.7749},
Color: "#e74c3c",
Popup: &maplibre.Popup{
Content: "<strong>San Francisco</strong><p>The Golden City</p>",
},
})
m.AddMarker("oak", maplibre.Marker{
LngLat: maplibre.LngLat{Lng: -122.2711, Lat: 37.8044},
Color: "#2ecc71",
Popup: &maplibre.Popup{
Content: "<strong>Oakland</strong>",
},
})
// Signal-backed marker — server pushes position updates
vehicleLng := c.Signal(-122.43)
vehicleLat := c.Signal(37.77)
m.AddMarker("vehicle", maplibre.Marker{
LngSignal: vehicleLng,
LatSignal: vehicleLat,
Color: "#9b59b6",
})
c.OnInterval(time.Second, func() {
vehicleLng.SetValue(-122.43 + (rand.Float64()-0.5)*0.02)
vehicleLat.SetValue(37.77 + (rand.Float64()-0.5)*0.02)
c.SyncSignals()
})
// Draggable marker — user drags, signals update
pinLng := c.Signal(-122.41)
pinLat := c.Signal(37.78)
m.AddMarker("pin", maplibre.Marker{
LngSignal: pinLng,
LatSignal: pinLat,
Color: "#3498db",
Draggable: true,
})
// Click event — click to place a marker
click := m.OnClick()
handleClick := c.Action(func() {
e := click.Data()
m.AddMarker("clicked", maplibre.Marker{
LngLat: e.LngLat,
Color: "#f39c12",
})
})
// GeoJSON polygon source + fill layer
m.AddSource("park", maplibre.GeoJSONSource{
Data: map[string]any{
"type": "Feature",
"geometry": map[string]any{
"type": "Polygon",
"coordinates": []any{[]any{
[]float64{-122.4547, 37.7654},
[]float64{-122.4547, 37.7754},
[]float64{-122.4387, 37.7754},
[]float64{-122.4387, 37.7654},
[]float64{-122.4547, 37.7654},
}},
},
"properties": map[string]any{
"name": "Golden Gate Park",
},
},
})
m.AddLayer(maplibre.Layer{
ID: "park-fill",
Type: "fill",
Source: "park",
Paint: map[string]any{
"fill-color": "#2ecc71",
"fill-opacity": 0.3,
},
})
// FlyTo actions using CameraOptions
zoom14 := 14.0
flyToSF := c.Action(func() {
m.FlyTo(maplibre.CameraOptions{
Center: &maplibre.LngLat{Lng: -122.4194, Lat: 37.7749},
Zoom: &zoom14,
})
})
flyToOak := c.Action(func() {
m.FlyTo(maplibre.CameraOptions{
Center: &maplibre.LngLat{Lng: -122.2711, Lat: 37.8044},
Zoom: &zoom14,
})
})
c.View(func() h.H {
return h.Div(
h.Div(
h.Attr("style", "max-width:960px;margin:0 auto;padding:1rem;font-family:sans-serif"),
h.H1(h.Text("MapLibre GL Example")),
m.Element(
click.Input(handleClick.OnInput()),
),
h.Div(h.Attr("style", "margin-top:1rem;display:flex;gap:0.5rem;flex-wrap:wrap"),
h.Button(h.Text("Fly to San Francisco"), flyToSF.OnClick()),
h.Button(h.Text("Fly to Oakland"), flyToOak.OnClick()),
),
h.Div(h.Attr("style", "margin-top:0.5rem;font-size:0.9rem"),
h.P(h.Text("Zoom: "), m.Zoom.Text()),
h.P(h.Text("Center: "), m.CenterLng.Text(), h.Text(", "), m.CenterLat.Text()),
h.P(h.Text("Vehicle: "), vehicleLng.Text(), h.Text(", "), vehicleLat.Text()),
h.P(h.Text("Draggable Pin: "), pinLng.Text(), h.Text(", "), pinLat.Text()),
),
),
)
})
})
v.Start()
}

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

@@ -2,7 +2,7 @@
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.
Via includes an embedded NATS server that starts automatically no external server required.
## Key Differences from Original Chatroom
@@ -25,21 +25,6 @@ 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
```
@@ -65,14 +50,16 @@ Data is persisted to `./data/nats/` for JetStream durability.
## JetStream Durability
Messages persist to disk via JetStream:
Messages persist to disk via JetStream. Streams are declared in `Options.Streams` and created automatically when `v.Start()` initializes the embedded NATS server:
```go
js.AddStream(&nats.StreamConfig{
Name: "CHAT",
Subjects: []string{"chat.>"},
MaxMsgs: 1000, // Keep last 1000 messages
MaxAge: 24 * time.Hour,
v.Config(via.Options{
Streams: []via.StreamConfig{{
Name: "CHAT",
Subjects: []string{"chat.>"},
MaxMsgs: 1000,
MaxAge: 24 * time.Hour,
}},
})
```
@@ -87,23 +74,6 @@ Stop and restart the app - chat history survives.
- 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)
```
- `via.Subscribe(c, subject, handler)` for receiving
- `via.Publish(c, subject, data)` for sending
- Streams declared in `Options` — NATS handles delivery, no polling

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

@@ -1,126 +1,37 @@
package main
import (
"context"
_ "embed"
"log"
"math/rand"
"sync"
"time"
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
"github.com/ryanhamamura/via/vianats"
)
var (
WithSignal = via.WithSignal
)
//go:embed chat.css
var chatCSS string
// ChatMessage represents a message in a chat room
type ChatMessage struct {
User UserInfo `json:"user"`
Message string `json:"message"`
Time int64 `json:"time"`
}
// UserInfo identifies a chat participant
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 v *via.V
func main() {
ctx := context.Background()
ps, err := vianats.New(ctx, "./data/nats")
if err != nil {
log.Fatalf("Failed to start embedded NATS: %v", err)
}
defer ps.Close()
err = vianats.EnsureStream(ps, vianats.StreamConfig{
Name: "CHAT",
Subjects: []string{"chat.>"},
MaxMsgs: 1000,
MaxAge: 24 * time.Hour,
})
if err != nil {
log.Fatalf("Failed to ensure stream: %v", err)
}
v := via.New()
v = via.New()
v.Config(via.Options{
DevMode: true,
DocumentTitle: "NATS Chat",
LogLevel: via.LogLevelInfo,
ServerAddress: ":7331",
PubSub: ps,
Streams: []via.StreamConfig{{
Name: "CHAT",
Subjects: []string{"chat.>"},
MaxMsgs: 1000,
MaxAge: 24 * time.Hour,
}},
})
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(`
body { margin: 0; }
main {
display: flex;
flex-direction: column;
height: 100vh;
}
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;
}
.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 {
background: #27AAE1;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
margin-left: auto;
}
`)),
h.StyleEl(h.Raw(chatCSS)),
h.Script(h.Raw(`
function scrollChatToBottom() {
const chatHistory = document.querySelector('.chat-history');
@@ -129,156 +40,38 @@ func main() {
`)),
)
v.Page("/", func(c *via.Context) {
currentUser := randUser()
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
// Replay history from JetStream
if hist, err := vianats.ReplayHistory[ChatMessage](ps, 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")
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()
return h.Main(h.Class("container"),
h.Nav(
h.Attr("role", "tab-control"),
h.Ul(tabs...),
h.Span(h.Class("nats-badge"), h.Text("NATS")),
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.Div(chatHistoryChildren...),
h.Div(
h.Class("chat-input"),
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()),
),
),
)
})
),
h.Main(h.Class("container"),
h.DataViewTransition("page-content"),
content(),
),
)
})
log.Println("Starting NATS chatroom on :7331 (embedded NATS server)")
// 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")
v.Start()
}
func randUser() UserInfo {
adjectives := []string{"Happy", "Clever", "Brave", "Swift", "Gentle", "Wise", "Bold", "Calm", "Eager", "Fierce"}
animals := []string{"Panda", "Tiger", "Eagle", "Dolphin", "Fox", "Wolf", "Bear", "Hawk", "Otter", "Lion"}
emojis := []string{"🐼", "🐯", "🦅", "🐬", "🦊", "🐺", "🐻", "🦅", "🦦", "🦁"}
idx := rand.Intn(len(animals))
return UserInfo{
Name: adjectives[rand.Intn(len(adjectives))] + " " + animals[idx],
Emoji: emojis[idx],
}
}
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

@@ -0,0 +1,111 @@
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)
previewName := c.Computed(func() string {
if name := nameField.String(); name != "" {
return name
}
return "Your Name"
})
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()),
)
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"), previewName.Text()),
),
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

@@ -1,7 +1,6 @@
package main
import (
"context"
"crypto/rand"
"fmt"
"html"
@@ -11,7 +10,6 @@ import (
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
"github.com/ryanhamamura/via/vianats"
)
var WithSignal = via.WithSignal
@@ -49,31 +47,18 @@ func findBookmark(id string) (Bookmark, int) {
}
func main() {
ctx := context.Background()
ps, err := vianats.New(ctx, "./data/nats")
if err != nil {
log.Fatalf("Failed to start embedded NATS: %v", err)
}
defer ps.Close()
err = vianats.EnsureStream(ps, vianats.StreamConfig{
Name: "BOOKMARKS",
Subjects: []string{"bookmarks.>"},
MaxMsgs: 1000,
MaxAge: 24 * time.Hour,
})
if err != nil {
log.Fatalf("Failed to ensure stream: %v", err)
}
v := via.New()
v.Config(via.Options{
DevMode: true,
DocumentTitle: "Bookmarks",
LogLevel: via.LogLevelInfo,
ServerAddress: ":7331",
PubSub: ps,
Streams: []via.StreamConfig{{
Name: "BOOKMARKS",
Subjects: []string{"bookmarks.>"},
MaxMsgs: 1000,
MaxAge: 24 * time.Hour,
}},
})
v.AppendToHead(
@@ -87,6 +72,12 @@ func main() {
titleSignal := c.Signal("")
urlSignal := c.Signal("")
targetIDSignal := c.Signal("")
saveLabel := c.Computed(func() string {
if targetIDSignal.String() != "" {
return "Update Bookmark"
}
return "Add Bookmark"
})
via.Subscribe(c, "bookmarks.events", func(evt CRUDEvent) {
if evt.UserID == userID {
@@ -216,11 +207,6 @@ func main() {
}
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"),
@@ -236,7 +222,7 @@ func main() {
// 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.H2(h.Class("card-title"), saveLabel.Text()),
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()),
@@ -244,7 +230,7 @@ func main() {
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()),
h.Button(h.Class("btn btn-primary"), saveLabel.Text(), save.OnClick()),
),
),
),

View File

@@ -15,8 +15,8 @@ func main() {
v.Config(via.Options{
LogLevel: via.LogLevelDebug,
DevMode: true,
Plugins: []via.Plugin{
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

@@ -29,7 +29,17 @@ func main() {
SessionManager: sm,
})
// Login page
// 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("")
@@ -64,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")
@@ -74,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,9 +6,9 @@ import (
"log"
"time"
_ "github.com/mattn/go-sqlite3"
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
_ "github.com/mattn/go-sqlite3"
)
type DataSource interface {

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()
}

418
maplibre/js.go Normal file
View File

@@ -0,0 +1,418 @@
package maplibre
import (
"encoding/json"
"fmt"
"strings"
)
// guard wraps JS code so it only runs when the map instance exists.
// The body can reference the map as `m`.
func guard(mapID, body string) string {
return fmt.Sprintf(
`(function(){var m=window.__via_maps&&window.__via_maps[%s];if(!m)return;%s})()`,
jsonStr(mapID), body,
)
}
// jsonStr JSON-encodes a string for safe embedding in JS.
func jsonStr(s string) string {
b, _ := json.Marshal(s)
return string(b)
}
// jsonVal JSON-encodes an arbitrary value for safe embedding in JS.
func jsonVal(v any) string {
b, _ := json.Marshal(v)
return string(b)
}
// initScript generates the idempotent map initialization JS.
func initScript(m *Map) string {
var b strings.Builder
b.WriteString(fmt.Sprintf(
`(function(){if(window.__via_maps&&window.__via_maps[%[1]s])return;`,
jsonStr(m.id),
))
// Build constructor options object
b.WriteString(fmt.Sprintf(
`var opts={container:%s,style:%s,center:[%s,%s],zoom:%s`,
jsonStr("_vmap_"+m.id),
jsonStr(m.opts.Style),
formatFloat(m.opts.Center.Lng),
formatFloat(m.opts.Center.Lat),
formatFloat(m.opts.Zoom),
))
if m.opts.Bearing != 0 {
b.WriteString(fmt.Sprintf(`,bearing:%s`, formatFloat(m.opts.Bearing)))
}
if m.opts.Pitch != 0 {
b.WriteString(fmt.Sprintf(`,pitch:%s`, formatFloat(m.opts.Pitch)))
}
if m.opts.MinZoom != 0 {
b.WriteString(fmt.Sprintf(`,minZoom:%s`, formatFloat(m.opts.MinZoom)))
}
if m.opts.MaxZoom != 0 {
b.WriteString(fmt.Sprintf(`,maxZoom:%s`, formatFloat(m.opts.MaxZoom)))
}
// Interaction toggles
writeBoolOpt := func(name string, val *bool) {
if val != nil {
if *val {
b.WriteString(fmt.Sprintf(`,%s:true`, name))
} else {
b.WriteString(fmt.Sprintf(`,%s:false`, name))
}
}
}
writeBoolOpt("scrollZoom", m.opts.ScrollZoom)
writeBoolOpt("boxZoom", m.opts.BoxZoom)
writeBoolOpt("dragRotate", m.opts.DragRotate)
writeBoolOpt("dragPan", m.opts.DragPan)
writeBoolOpt("keyboard", m.opts.Keyboard)
writeBoolOpt("doubleClickZoom", m.opts.DoubleClickZoom)
writeBoolOpt("touchZoomRotate", m.opts.TouchZoomRotate)
writeBoolOpt("touchPitch", m.opts.TouchPitch)
writeBoolOpt("renderWorldCopies", m.opts.RenderWorldCopies)
if m.opts.MaxBounds != nil {
b.WriteString(fmt.Sprintf(`,maxBounds:[[%s,%s],[%s,%s]]`,
formatFloat(m.opts.MaxBounds.SW.Lng), formatFloat(m.opts.MaxBounds.SW.Lat),
formatFloat(m.opts.MaxBounds.NE.Lng), formatFloat(m.opts.MaxBounds.NE.Lat)))
}
b.WriteString(`};`)
// Merge Extra options
if len(m.opts.Extra) > 0 {
extra, _ := json.Marshal(m.opts.Extra)
b.WriteString(fmt.Sprintf(`Object.assign(opts,%s);`, string(extra)))
}
b.WriteString(`var map=new maplibregl.Map(opts);`)
b.WriteString(`if(!window.__via_maps)window.__via_maps={};`)
b.WriteString(fmt.Sprintf(`window.__via_maps[%s]=map;`, jsonStr(m.id)))
b.WriteString(`map._via_markers={};map._via_popups={};map._via_controls={};`)
// Pre-render sources, layers, markers, popups, controls run on 'load'
hasLoad := len(m.sources) > 0 || len(m.layers) > 0 || len(m.markers) > 0 || len(m.popups) > 0 || len(m.controls) > 0
if hasLoad {
b.WriteString(`map.on('load',function(){`)
for _, src := range m.sources {
b.WriteString(fmt.Sprintf(`map.addSource(%s,%s);`, jsonStr(src.id), src.js))
}
for _, layer := range m.layers {
if layer.Before != "" {
b.WriteString(fmt.Sprintf(`map.addLayer(%s,%s);`, layer.toJS(), jsonStr(layer.Before)))
} else {
b.WriteString(fmt.Sprintf(`map.addLayer(%s);`, layer.toJS()))
}
}
for _, me := range m.markers {
b.WriteString(markerBodyJS(m.id, me.id, me.marker))
}
for _, pe := range m.popups {
b.WriteString(popupBodyJS(pe.id, pe.popup))
}
for _, ce := range m.controls {
b.WriteString(controlBodyJS(ce.id, ce.ctrl))
}
b.WriteString(`});`)
}
// Event listeners
for _, ev := range m.events {
b.WriteString(eventListenerJS(m.id, ev))
}
// Sync viewport signals on moveend via hidden inputs
b.WriteString(fmt.Sprintf(`map.on('moveend',function(){`+
`var c=map.getCenter();`+
`var el=document.getElementById(%[1]s);if(!el)return;`+
`var inputs=el.querySelectorAll('input[data-bind]');`+
`inputs.forEach(function(inp){`+
`var sig=inp.getAttribute('data-bind');`+
`if(sig===%[2]s)inp.value=c.lng;`+
`else if(sig===%[3]s)inp.value=c.lat;`+
`else if(sig===%[4]s)inp.value=map.getZoom();`+
`else if(sig===%[5]s)inp.value=map.getBearing();`+
`else if(sig===%[6]s)inp.value=map.getPitch();`+
`else return;`+
`inp.dispatchEvent(new Event('input',{bubbles:true}));`+
`});`+
`});`,
jsonStr("_vwrap_"+m.id),
jsonStr(m.CenterLng.ID()),
jsonStr(m.CenterLat.ID()),
jsonStr(m.Zoom.ID()),
jsonStr(m.Bearing.ID()),
jsonStr(m.Pitch.ID()),
))
// ResizeObserver for auto-resize
b.WriteString(fmt.Sprintf(
`var ro=new ResizeObserver(function(){map.resize();});`+
`ro.observe(document.getElementById(%s));`,
jsonStr("_vmap_"+m.id),
))
// MutationObserver to clean up on DOM removal (SPA nav)
b.WriteString(fmt.Sprintf(
`var container=document.getElementById(%[1]s);`+
`if(container){var mo=new MutationObserver(function(){`+
`if(!document.contains(container)){`+
`mo.disconnect();ro.disconnect();map.remove();`+
`delete window.__via_maps[%[2]s];`+
`}});`+
`mo.observe(document.body,{childList:true,subtree:true});}`,
jsonStr("_vmap_"+m.id),
jsonStr(m.id),
))
b.WriteString(`})()`)
return b.String()
}
// markerBodyJS generates JS to add a marker, assuming `map` is in scope.
func markerBodyJS(mapID, markerID string, mk Marker) string {
var b strings.Builder
opts := "{"
if mk.Color != "" {
opts += fmt.Sprintf(`color:%s,`, jsonStr(mk.Color))
}
if mk.Draggable {
opts += `draggable:true,`
}
opts += "}"
// Determine initial position
if mk.LngSignal != nil && mk.LatSignal != nil {
b.WriteString(fmt.Sprintf(`var mk=new maplibregl.Marker(%s).setLngLat([%s,%s]);`,
opts, mk.LngSignal.String(), mk.LatSignal.String()))
} else {
b.WriteString(fmt.Sprintf(`var mk=new maplibregl.Marker(%s).setLngLat([%s,%s]);`,
opts, formatFloat(mk.LngLat.Lng), formatFloat(mk.LngLat.Lat)))
}
if mk.Popup != nil {
b.WriteString(popupConstructorJS(*mk.Popup, "pk"))
b.WriteString(`mk.setPopup(pk);`)
}
b.WriteString(fmt.Sprintf(`mk.addTo(map);map._via_markers[%s]=mk;`, jsonStr(markerID)))
// Dragend → signal writeback
if mk.Draggable && mk.LngSignal != nil && mk.LatSignal != nil {
b.WriteString(dragendHandlerJS(mapID, markerID, mk))
}
return b.String()
}
// dragendHandlerJS generates JS that writes marker position back to signal hidden inputs on dragend.
func dragendHandlerJS(mapID, markerID string, mk Marker) string {
return fmt.Sprintf(
`mk.on('dragend',function(){`+
`var pos=mk.getLngLat();`+
`var el=document.getElementById(%[1]s);if(!el)return;`+
`var inputs=el.querySelectorAll('input[data-bind]');`+
`inputs.forEach(function(inp){`+
`var sig=inp.getAttribute('data-bind');`+
`if(sig===%[2]s){inp.value=pos.lng;inp.dispatchEvent(new Event('input',{bubbles:true}))}`+
`if(sig===%[3]s){inp.value=pos.lat;inp.dispatchEvent(new Event('input',{bubbles:true}))}`+
`});`+
`});`,
jsonStr("_vwrap_"+mapID),
jsonStr(mk.LngSignal.ID()),
jsonStr(mk.LatSignal.ID()),
)
}
// markerEffectExpr generates a data-effect expression that moves a signal-backed marker
// when its signals change.
func markerEffectExpr(mapID, markerID string, mk Marker) string {
// Read signals before the guard so Datastar tracks them as dependencies
// even when the map/marker hasn't loaded yet on first evaluation.
return fmt.Sprintf(
`var lng=$%s,lat=$%s;`+
`var m=window.__via_maps&&window.__via_maps[%s];`+
`if(m&&m._via_markers[%s]){`+
`m._via_markers[%s].setLngLat([lng,lat])}`,
mk.LngSignal.ID(), mk.LatSignal.ID(),
jsonStr(mapID), jsonStr(markerID), jsonStr(markerID),
)
}
// addMarkerJS generates a self-contained IIFE to add a marker post-render.
func addMarkerJS(mapID, markerID string, mk Marker) string {
var b strings.Builder
b.WriteString(fmt.Sprintf(
`(function(){var map=window.__via_maps&&window.__via_maps[%s];if(!map)return;`,
jsonStr(mapID)))
// Remove existing marker with same ID
b.WriteString(fmt.Sprintf(
`if(map._via_markers[%[1]s]){map._via_markers[%[1]s].remove();delete map._via_markers[%[1]s];}`,
jsonStr(markerID)))
b.WriteString(markerBodyJS(mapID, markerID, mk))
b.WriteString(`})()`)
return b.String()
}
// removeMarkerJS generates JS to remove a marker. Expects `m` in scope (used inside guard).
func removeMarkerJS(markerID string) string {
return fmt.Sprintf(
`if(m._via_markers[%[1]s]){m._via_markers[%[1]s].remove();delete m._via_markers[%[1]s];}`,
jsonStr(markerID))
}
// popupBodyJS generates JS to show a popup, assuming `map` is in scope.
func popupBodyJS(popupID string, p Popup) string {
var b strings.Builder
b.WriteString(popupConstructorJS(p, "p"))
b.WriteString(fmt.Sprintf(
`p.setLngLat([%s,%s]).addTo(map);map._via_popups[%s]=p;`,
formatFloat(p.LngLat.Lng), formatFloat(p.LngLat.Lat), jsonStr(popupID)))
return b.String()
}
// showPopupJS generates a self-contained IIFE to show a popup post-render.
func showPopupJS(mapID, popupID string, p Popup) string {
var b strings.Builder
b.WriteString(fmt.Sprintf(
`(function(){var map=window.__via_maps&&window.__via_maps[%s];if(!map)return;`,
jsonStr(mapID)))
// Close existing popup with same ID
b.WriteString(fmt.Sprintf(
`if(map._via_popups[%[1]s]){map._via_popups[%[1]s].remove();delete map._via_popups[%[1]s];}`,
jsonStr(popupID)))
b.WriteString(popupBodyJS(popupID, p))
b.WriteString(`})()`)
return b.String()
}
// closePopupJS generates JS to close a popup. Expects `m` in scope (used inside guard).
func closePopupJS(popupID string) string {
return fmt.Sprintf(
`if(m._via_popups[%[1]s]){m._via_popups[%[1]s].remove();delete m._via_popups[%[1]s];}`,
jsonStr(popupID))
}
// popupConstructorJS generates JS to create a Popup object stored in varName.
func popupConstructorJS(p Popup, varName string) string {
opts := "{"
if p.HideCloseButton {
opts += `closeButton:false,`
}
if p.MaxWidth != "" {
opts += fmt.Sprintf(`maxWidth:%s,`, jsonStr(p.MaxWidth))
}
opts += "}"
return fmt.Sprintf(`var %s=new maplibregl.Popup(%s).setHTML(%s);`,
varName, opts, jsonStr(p.Content))
}
// --- Control JS ---
// controlBodyJS generates JS to add a control, assuming `map` is in scope.
func controlBodyJS(controlID string, ctrl Control) string {
return fmt.Sprintf(
`var ctrl=%s;map.addControl(ctrl,%s);map._via_controls[%s]=ctrl;`,
ctrl.controlJS(), jsonStr(ctrl.controlPosition()), jsonStr(controlID))
}
// addControlJS generates a self-contained IIFE to add a control post-render.
func addControlJS(mapID, controlID string, ctrl Control) string {
return fmt.Sprintf(
`(function(){var map=window.__via_maps&&window.__via_maps[%[1]s];if(!map)return;`+
`if(map._via_controls[%[2]s]){map.removeControl(map._via_controls[%[2]s]);delete map._via_controls[%[2]s];}`+
`var ctrl=%[3]s;map.addControl(ctrl,%[4]s);map._via_controls[%[2]s]=ctrl;`+
`})()`,
jsonStr(mapID), jsonStr(controlID), ctrl.controlJS(), jsonStr(ctrl.controlPosition()))
}
// removeControlJS generates JS to remove a control. Expects `m` in scope.
func removeControlJS(controlID string) string {
return fmt.Sprintf(
`if(m._via_controls[%[1]s]){m.removeControl(m._via_controls[%[1]s]);delete m._via_controls[%[1]s];}`,
jsonStr(controlID))
}
// --- Event JS ---
// eventListenerJS generates JS to register a map event listener that writes
// event data to a hidden signal input.
func eventListenerJS(mapID string, ev eventEntry) string {
var handler string
if ev.layerID != "" {
handler = fmt.Sprintf(
`map.on(%[1]s,%[2]s,function(e){`+
`var d={lngLat:{Lng:e.lngLat.lng,Lat:e.lngLat.lat},point:[e.point.x,e.point.y],layerID:%[2]s};`+
`if(e.features)d.features=e.features.map(function(f){return JSON.parse(JSON.stringify(f))});`+
`var el=document.getElementById(%[3]s);if(!el)return;`+
`var inp=el.querySelector('input[data-bind=%[4]s]');`+
`if(inp){inp.value=JSON.stringify(d);inp.dispatchEvent(new Event('input',{bubbles:true}))}`+
`});`,
jsonStr(ev.event), jsonStr(ev.layerID),
jsonStr("_vwrap_"+mapID), jsonStr(ev.signal.ID()),
)
} else {
handler = fmt.Sprintf(
`map.on(%[1]s,function(e){`+
`var d={lngLat:{Lng:e.lngLat.lng,Lat:e.lngLat.lat},point:[e.point.x,e.point.y]};`+
`var el=document.getElementById(%[2]s);if(!el)return;`+
`var inp=el.querySelector('input[data-bind=%[3]s]');`+
`if(inp){inp.value=JSON.stringify(d);inp.dispatchEvent(new Event('input',{bubbles:true}))}`+
`});`,
jsonStr(ev.event),
jsonStr("_vwrap_"+mapID), jsonStr(ev.signal.ID()),
)
}
return handler
}
// --- Camera options JS ---
// cameraOptionsJS converts CameraOptions to a JS object literal string.
func cameraOptionsJS(opts CameraOptions) string {
obj := map[string]any{}
if opts.Center != nil {
obj["center"] = []float64{opts.Center.Lng, opts.Center.Lat}
}
if opts.Zoom != nil {
obj["zoom"] = *opts.Zoom
}
if opts.Bearing != nil {
obj["bearing"] = *opts.Bearing
}
if opts.Pitch != nil {
obj["pitch"] = *opts.Pitch
}
if opts.Duration != nil {
obj["duration"] = *opts.Duration
}
if opts.Speed != nil {
obj["speed"] = *opts.Speed
}
if opts.Curve != nil {
obj["curve"] = *opts.Curve
}
if opts.Padding != nil {
obj["padding"] = map[string]int{
"top": opts.Padding.Top,
"bottom": opts.Padding.Bottom,
"left": opts.Padding.Left,
"right": opts.Padding.Right,
}
}
if opts.Animate != nil {
obj["animate"] = *opts.Animate
}
b, _ := json.Marshal(obj)
return string(b)
}
func formatFloat(f float64) string {
return fmt.Sprintf("%g", f)
}

1
maplibre/maplibre-gl.css Normal file

File diff suppressed because one or more lines are too long

59
maplibre/maplibre-gl.js Normal file

File diff suppressed because one or more lines are too long

440
maplibre/maplibre.go Normal file
View File

@@ -0,0 +1,440 @@
// Package maplibre provides a Go API for MapLibre GL JS maps within Via applications.
//
// It follows the same ExecScript + DataIgnoreMorph pattern used for other client-side
// JS library integrations (e.g. ECharts in the realtimechart example).
package maplibre
import (
"crypto/rand"
_ "embed"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strconv"
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
)
//go:embed maplibre-gl.js
var maplibreJS []byte
//go:embed maplibre-gl.css
var maplibreCSS []byte
// Plugin serves the embedded MapLibre GL JS/CSS and injects them into the document head.
func Plugin(v *via.V) {
v.HTTPServeMux().HandleFunc("GET /_maplibre/maplibre-gl.js", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/javascript")
_, _ = w.Write(maplibreJS)
})
v.HTTPServeMux().HandleFunc("GET /_maplibre/maplibre-gl.css", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css")
_, _ = w.Write(maplibreCSS)
})
v.AppendToHead(
h.Link(h.Rel("stylesheet"), h.Href("/_maplibre/maplibre-gl.css")),
h.Script(h.Src("/_maplibre/maplibre-gl.js")),
)
}
// Map represents a MapLibre GL map instance bound to a Via context.
type Map struct {
// Viewport signals — readable with .Text(), .String(), etc.
CenterLng *via.Signal
CenterLat *via.Signal
Zoom *via.Signal
Bearing *via.Signal
Pitch *via.Signal
id string
ctx *via.Context
opts Options
sources []sourceEntry
layers []Layer
markers []markerEntry
popups []popupEntry
events []eventEntry
controls []controlEntry
rendered bool
}
// New creates a Map bound to the given Via context with the provided options.
// It registers viewport signals on the context for browser → server sync.
func New(c *via.Context, opts Options) *Map {
if opts.Width == "" {
opts.Width = "100%"
}
if opts.Height == "" {
opts.Height = "400px"
}
m := &Map{
id: genID(),
ctx: c,
opts: opts,
}
m.CenterLng = c.Signal(opts.Center.Lng)
m.CenterLat = c.Signal(opts.Center.Lat)
m.Zoom = c.Signal(opts.Zoom)
m.Bearing = c.Signal(opts.Bearing)
m.Pitch = c.Signal(opts.Pitch)
return m
}
// Element returns the h.H DOM tree for the map. Call this once inside your View function.
// After Element() is called, subsequent source/layer/marker/popup operations
// use ExecScript instead of accumulating for the init script.
//
// Extra children are appended inside the wrapper div (useful for event inputs
// and data-effect binding elements).
func (m *Map) Element(extra ...h.H) h.H {
m.rendered = true
children := []h.H{
h.ID("_vwrap_" + m.id),
// Map container — morph-ignored so MapLibre's DOM isn't destroyed on Sync()
h.Div(
h.ID("_vmap_"+m.id),
h.DataIgnoreMorph(),
h.Attr("style", fmt.Sprintf("width:%s;height:%s", m.opts.Width, m.opts.Height)),
),
// Hidden inputs for viewport signal binding (outside morph-ignored zone)
h.Input(h.Type("hidden"), m.CenterLng.Bind()),
h.Input(h.Type("hidden"), m.CenterLat.Bind()),
h.Input(h.Type("hidden"), m.Zoom.Bind()),
h.Input(h.Type("hidden"), m.Bearing.Bind()),
h.Input(h.Type("hidden"), m.Pitch.Bind()),
}
// data-effect elements for signal-backed markers
for _, me := range m.markers {
if me.marker.LngSignal != nil && me.marker.LatSignal != nil {
children = append(children, h.Div(
h.Attr("style", "display:none"),
h.DataEffect(markerEffectExpr(m.id, me.id, me.marker)),
))
}
}
// Hidden inputs for signal-backed marker position writeback (drag → signal)
for _, me := range m.markers {
if me.marker.LngSignal != nil && me.marker.LatSignal != nil {
children = append(children,
h.Input(h.Type("hidden"), me.marker.LngSignal.Bind()),
h.Input(h.Type("hidden"), me.marker.LatSignal.Bind()),
)
}
}
children = append(children, extra...)
// Init script last
children = append(children, h.Script(h.Raw(initScript(m))))
return h.Div(children...)
}
// --- Viewport readers (signal → Go) ---
// Center returns the current map center from synced signals.
func (m *Map) Center() LngLat {
return LngLat{
Lng: parseFloat(m.CenterLng.String()),
Lat: parseFloat(m.CenterLat.String()),
}
}
// --- Camera methods ---
// FlyTo animates the map to the target camera state.
func (m *Map) FlyTo(opts CameraOptions) {
m.exec(fmt.Sprintf(`m.flyTo(%s);`, cameraOptionsJS(opts)))
}
// EaseTo eases the map to the target camera state.
func (m *Map) EaseTo(opts CameraOptions) {
m.exec(fmt.Sprintf(`m.easeTo(%s);`, cameraOptionsJS(opts)))
}
// JumpTo jumps the map to the target camera state without animation.
func (m *Map) JumpTo(opts CameraOptions) {
m.exec(fmt.Sprintf(`m.jumpTo(%s);`, cameraOptionsJS(opts)))
}
// FitBounds fits the map to the given bounds with optional camera options.
func (m *Map) FitBounds(bounds LngLatBounds, opts ...CameraOptions) {
boundsJS := fmt.Sprintf("[[%s,%s],[%s,%s]]",
formatFloat(bounds.SW.Lng), formatFloat(bounds.SW.Lat),
formatFloat(bounds.NE.Lng), formatFloat(bounds.NE.Lat))
if len(opts) > 0 {
m.exec(fmt.Sprintf(`m.fitBounds(%s,%s);`, boundsJS, cameraOptionsJS(opts[0])))
} else {
m.exec(fmt.Sprintf(`m.fitBounds(%s);`, boundsJS))
}
}
// Stop aborts any in-progress camera animation.
func (m *Map) Stop() {
m.exec(`m.stop();`)
}
// SetCenter sets the map center without animation.
func (m *Map) SetCenter(ll LngLat) {
m.exec(fmt.Sprintf(`m.setCenter([%s,%s]);`,
formatFloat(ll.Lng), formatFloat(ll.Lat)))
}
// SetZoom sets the map zoom level without animation.
func (m *Map) SetZoom(z float64) {
m.exec(fmt.Sprintf(`m.setZoom(%s);`, formatFloat(z)))
}
// SetBearing sets the map bearing without animation.
func (m *Map) SetBearing(b float64) {
m.exec(fmt.Sprintf(`m.setBearing(%s);`, formatFloat(b)))
}
// SetPitch sets the map pitch without animation.
func (m *Map) SetPitch(p float64) {
m.exec(fmt.Sprintf(`m.setPitch(%s);`, formatFloat(p)))
}
// SetStyle changes the map's style URL.
func (m *Map) SetStyle(url string) {
m.exec(fmt.Sprintf(`m.setStyle(%s);`, jsonStr(url)))
}
// --- Source methods ---
// AddSource adds a source to the map.
func (m *Map) AddSource(id string, src Source) {
js := src.sourceJS()
if !m.rendered {
m.sources = append(m.sources, sourceEntry{id: id, js: js})
return
}
m.exec(fmt.Sprintf(`m.addSource(%s,%s);`, jsonStr(id), js))
}
// RemoveSource removes a source from the map.
func (m *Map) RemoveSource(id string) {
if !m.rendered {
for i, s := range m.sources {
if s.id == id {
m.sources = append(m.sources[:i], m.sources[i+1:]...)
return
}
}
return
}
m.exec(fmt.Sprintf(`m.removeSource(%s);`, jsonStr(id)))
}
// UpdateGeoJSONSource replaces the data of an existing GeoJSON source.
func (m *Map) UpdateGeoJSONSource(sourceID string, data any) {
m.exec(fmt.Sprintf(`m.getSource(%s).setData(%s);`, jsonStr(sourceID), jsonVal(data)))
}
// --- Layer methods ---
// AddLayer adds a layer to the map.
func (m *Map) AddLayer(layer Layer) {
if !m.rendered {
m.layers = append(m.layers, layer)
return
}
before := "undefined"
if layer.Before != "" {
before = jsonStr(layer.Before)
}
m.exec(fmt.Sprintf(`m.addLayer(%s,%s);`, layer.toJS(), before))
}
// RemoveLayer removes a layer from the map.
func (m *Map) RemoveLayer(id string) {
if !m.rendered {
for i, l := range m.layers {
if l.ID == id {
m.layers = append(m.layers[:i], m.layers[i+1:]...)
return
}
}
return
}
m.exec(fmt.Sprintf(`m.removeLayer(%s);`, jsonStr(id)))
}
// SetPaintProperty sets a paint property on a layer.
func (m *Map) SetPaintProperty(layerID, name string, value any) {
m.exec(fmt.Sprintf(`m.setPaintProperty(%s,%s,%s);`,
jsonStr(layerID), jsonStr(name), jsonVal(value)))
}
// SetLayoutProperty sets a layout property on a layer.
func (m *Map) SetLayoutProperty(layerID, name string, value any) {
m.exec(fmt.Sprintf(`m.setLayoutProperty(%s,%s,%s);`,
jsonStr(layerID), jsonStr(name), jsonVal(value)))
}
// --- Marker methods ---
// AddMarker adds or replaces a marker on the map.
func (m *Map) AddMarker(id string, marker Marker) {
if !m.rendered {
m.markers = append(m.markers, markerEntry{id: id, marker: marker})
return
}
js := addMarkerJS(m.id, id, marker)
m.ctx.ExecScript(js)
}
// RemoveMarker removes a marker from the map.
func (m *Map) RemoveMarker(id string) {
if !m.rendered {
for i, me := range m.markers {
if me.id == id {
m.markers = append(m.markers[:i], m.markers[i+1:]...)
return
}
}
return
}
m.exec(removeMarkerJS(id))
}
// --- Popup methods ---
// ShowPopup shows a standalone popup on the map.
func (m *Map) ShowPopup(id string, popup Popup) {
if !m.rendered {
m.popups = append(m.popups, popupEntry{id: id, popup: popup})
return
}
js := showPopupJS(m.id, id, popup)
m.ctx.ExecScript(js)
}
// ClosePopup closes a standalone popup on the map.
func (m *Map) ClosePopup(id string) {
if !m.rendered {
for i, pe := range m.popups {
if pe.id == id {
m.popups = append(m.popups[:i], m.popups[i+1:]...)
return
}
}
return
}
m.exec(closePopupJS(id))
}
// --- Control methods ---
// AddControl adds a control to the map.
func (m *Map) AddControl(id string, ctrl Control) {
if !m.rendered {
m.controls = append(m.controls, controlEntry{id: id, ctrl: ctrl})
return
}
m.exec(addControlJS(m.id, id, ctrl))
}
// RemoveControl removes a control from the map.
func (m *Map) RemoveControl(id string) {
if !m.rendered {
for i, ce := range m.controls {
if ce.id == id {
m.controls = append(m.controls[:i], m.controls[i+1:]...)
return
}
}
return
}
m.exec(removeControlJS(id))
}
// --- Event methods ---
// OnClick returns a MapEvent that fires on map click.
func (m *Map) OnClick() *MapEvent {
return m.on("click", "")
}
// OnLayerClick returns a MapEvent that fires on click of a specific layer.
func (m *Map) OnLayerClick(layerID string) *MapEvent {
return m.on("click", layerID)
}
// OnMouseMove returns a MapEvent that fires on map mouse movement.
func (m *Map) OnMouseMove() *MapEvent {
return m.on("mousemove", "")
}
// OnContextMenu returns a MapEvent that fires on right-click.
func (m *Map) OnContextMenu() *MapEvent {
return m.on("contextmenu", "")
}
func (m *Map) on(event, layerID string) *MapEvent {
sig := m.ctx.Signal("")
ev := &MapEvent{signal: sig}
m.events = append(m.events, eventEntry{
event: event,
layerID: layerID,
signal: sig,
})
return ev
}
// --- Escape hatch ---
// Exec runs arbitrary JS with the map available as `m`.
func (m *Map) Exec(js string) {
m.exec(js)
}
// exec sends guarded JS to the browser via ExecScript.
func (m *Map) exec(body string) {
m.ctx.ExecScript(guard(m.id, body))
}
// --- MapEvent ---
// MapEvent wraps a signal that receives map event data as JSON.
type MapEvent struct {
signal *via.Signal
}
// Bind returns the data-bind attribute for this event's signal.
func (e *MapEvent) Bind() h.H { return e.signal.Bind() }
// Data parses the event signal's JSON value into EventData.
func (e *MapEvent) Data() EventData {
var d EventData
json.Unmarshal([]byte(e.signal.String()), &d)
return d
}
// Input creates a hidden input wired to this event's signal.
// Pass action triggers (e.g. handleClick.OnInput()) as attrs.
func (e *MapEvent) Input(attrs ...h.H) h.H {
all := append([]h.H{h.Type("hidden"), e.Bind()}, attrs...)
return h.Input(all...)
}
func parseFloat(s string) float64 {
f, _ := strconv.ParseFloat(s, 64)
return f
}
func genID() string {
b := make([]byte, 4)
rand.Read(b)
return hex.EncodeToString(b)
}

359
maplibre/types.go Normal file
View File

@@ -0,0 +1,359 @@
package maplibre
import (
"encoding/json"
"github.com/ryanhamamura/via"
)
// LngLat represents a geographic coordinate.
type LngLat struct {
Lng float64
Lat float64
}
// LngLatBounds represents a rectangular geographic area.
type LngLatBounds struct {
SW LngLat
NE LngLat
}
// Padding represents padding in pixels on each side of the map viewport.
type Padding struct {
Top int
Bottom int
Left int
Right int
}
// Options configures the initial map state.
type Options struct {
// Style is the map style URL (required).
Style string
Center LngLat
Zoom float64
Bearing float64
Pitch float64
MinZoom float64
MaxZoom float64
// CSS dimensions for the map container. Defaults: "100%", "400px".
Width string
Height string
// Interaction toggles (nil = MapLibre default)
ScrollZoom *bool
BoxZoom *bool
DragRotate *bool
DragPan *bool
Keyboard *bool
DoubleClickZoom *bool
TouchZoomRotate *bool
TouchPitch *bool
RenderWorldCopies *bool
MaxBounds *LngLatBounds
// Extra is merged last into the MapLibre constructor options object,
// allowing pass-through of any option not covered above.
Extra map[string]any
}
// --- Source interface ---
// Source is implemented by map data sources (GeoJSON, vector, raster, etc.).
type Source interface {
sourceJS() string
}
// GeoJSONSource provides inline GeoJSON data to MapLibre.
// Data should be a GeoJSON-marshalable value (struct, map, or json.RawMessage).
type GeoJSONSource struct {
Data any
}
func (s GeoJSONSource) sourceJS() string {
data, _ := json.Marshal(s.Data)
return `{"type":"geojson","data":` + string(data) + `}`
}
// VectorSource references a vector tile source.
type VectorSource struct {
URL string
Tiles []string
}
func (s VectorSource) sourceJS() string {
obj := map[string]any{"type": "vector"}
if s.URL != "" {
obj["url"] = s.URL
}
if len(s.Tiles) > 0 {
obj["tiles"] = s.Tiles
}
b, _ := json.Marshal(obj)
return string(b)
}
// RasterSource references a raster tile source.
type RasterSource struct {
URL string
Tiles []string
TileSize int
}
func (s RasterSource) sourceJS() string {
obj := map[string]any{"type": "raster"}
if s.URL != "" {
obj["url"] = s.URL
}
if len(s.Tiles) > 0 {
obj["tiles"] = s.Tiles
}
if s.TileSize > 0 {
obj["tileSize"] = s.TileSize
}
b, _ := json.Marshal(obj)
return string(b)
}
// RawSource is an escape hatch that passes an arbitrary JSON-marshalable
// value directly as a MapLibre source definition.
type RawSource struct {
Value any
}
func (s RawSource) sourceJS() string {
b, _ := json.Marshal(s.Value)
return string(b)
}
// --- Control interface ---
// Control is implemented by map controls (navigation, scale, etc.).
type Control interface {
controlJS() string
controlPosition() string
}
// NavigationControl adds zoom and rotation buttons.
type NavigationControl struct {
Position string // "top-right" (default), "top-left", "bottom-right", "bottom-left"
ShowCompass *bool
ShowZoom *bool
VisualizeRoll *bool
VisualizePitch *bool
}
func (c NavigationControl) controlJS() string {
opts := map[string]any{}
if c.ShowCompass != nil {
opts["showCompass"] = *c.ShowCompass
}
if c.ShowZoom != nil {
opts["showZoom"] = *c.ShowZoom
}
if c.VisualizeRoll != nil {
opts["visualizeRoll"] = *c.VisualizeRoll
}
if c.VisualizePitch != nil {
opts["visualizePitch"] = *c.VisualizePitch
}
b, _ := json.Marshal(opts)
return "new maplibregl.NavigationControl(" + string(b) + ")"
}
func (c NavigationControl) controlPosition() string {
if c.Position == "" {
return "top-right"
}
return c.Position
}
// ScaleControl displays a scale bar.
type ScaleControl struct {
Position string // default "bottom-left"
MaxWidth int
Unit string // "metric", "imperial", "nautical"
}
func (c ScaleControl) controlJS() string {
opts := map[string]any{}
if c.MaxWidth > 0 {
opts["maxWidth"] = c.MaxWidth
}
if c.Unit != "" {
opts["unit"] = c.Unit
}
b, _ := json.Marshal(opts)
return "new maplibregl.ScaleControl(" + string(b) + ")"
}
func (c ScaleControl) controlPosition() string {
if c.Position == "" {
return "bottom-left"
}
return c.Position
}
// GeolocateControl adds a button to track the user's location.
type GeolocateControl struct {
Position string // default "top-right"
}
func (c GeolocateControl) controlJS() string {
return "new maplibregl.GeolocateControl()"
}
func (c GeolocateControl) controlPosition() string {
if c.Position == "" {
return "top-right"
}
return c.Position
}
// FullscreenControl adds a fullscreen toggle button.
type FullscreenControl struct {
Position string // default "top-right"
}
func (c FullscreenControl) controlJS() string {
return "new maplibregl.FullscreenControl()"
}
func (c FullscreenControl) controlPosition() string {
if c.Position == "" {
return "top-right"
}
return c.Position
}
// --- Camera options ---
// CameraOptions configures animated camera movements (FlyTo, EaseTo, JumpTo).
// Nil pointer fields are omitted from the JS call.
type CameraOptions struct {
Center *LngLat
Zoom *float64
Bearing *float64
Pitch *float64
Duration *int // milliseconds
Speed *float64 // FlyTo only
Curve *float64 // FlyTo only
Padding *Padding
Animate *bool
}
// --- Layer ---
// Layer describes a MapLibre style layer.
type Layer struct {
ID string
Type string
Source string
SourceLayer string
Paint map[string]any
Layout map[string]any
Filter any
MinZoom float64
MaxZoom float64
// Before inserts this layer before the given layer ID in the stack.
Before string
}
func (l Layer) toJS() string {
obj := map[string]any{
"id": l.ID,
"type": l.Type,
}
if l.Source != "" {
obj["source"] = l.Source
}
if l.SourceLayer != "" {
obj["source-layer"] = l.SourceLayer
}
if l.Paint != nil {
obj["paint"] = l.Paint
}
if l.Layout != nil {
obj["layout"] = l.Layout
}
if l.Filter != nil {
obj["filter"] = l.Filter
}
if l.MinZoom > 0 {
obj["minzoom"] = l.MinZoom
}
if l.MaxZoom > 0 {
obj["maxzoom"] = l.MaxZoom
}
b, _ := json.Marshal(obj)
return string(b)
}
// --- Marker ---
// Marker describes a map marker.
type Marker struct {
LngLat LngLat // static position (used when signals are nil)
Color string
Draggable bool
Popup *Popup
// Signal-backed position. When set, signals drive marker position reactively.
// Initial position is read from the signal values. LngLat is ignored when signals are set.
// If Draggable is true, drag updates write back to these signals.
LngSignal *via.Signal
LatSignal *via.Signal
}
// Popup describes a map popup.
//
// Content is rendered as HTML via MapLibre's setHTML. Do not pass untrusted
// user input without sanitizing it first.
type Popup struct {
Content string // HTML content
LngLat LngLat
HideCloseButton bool // true removes the close button (MapLibre shows it by default)
MaxWidth string
}
// --- Event data ---
// EventData contains data from a map event (click, mousemove, etc.).
type EventData struct {
LngLat LngLat `json:"lngLat"`
Point [2]float64 `json:"point"`
Features []json.RawMessage `json:"features,omitempty"`
LayerID string `json:"layerID,omitempty"`
}
// --- Internal accumulation entries ---
type sourceEntry struct {
id string
js string
}
type markerEntry struct {
id string
marker Marker
}
type popupEntry struct {
id string
popup Popup
}
type eventEntry struct {
event string
layerID string
signal *via.Signal
}
type controlEntry struct {
id string
ctrl Control
}

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")
}

210
nats.go Normal file
View File

@@ -0,0 +1,210 @@
package via
import (
"context"
"encoding/json"
"fmt"
"os"
"sync"
"time"
"github.com/delaneyj/toolbelt/embeddednats"
natsserver "github.com/nats-io/nats-server/v2/server"
"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),
embeddednats.WithNATSServerOptions(&natsserver.Options{
JetStream: true,
StoreDir: dataDir,
Port: -1,
}),
)
if err != nil {
cancel()
os.RemoveAll(dataDir)
return nil, fmt.Errorf("start embedded nats: %w", err)
}
ready := make(chan struct{})
go func() {
ns.WaitForServer()
close(ready)
}()
select {
case <-ready:
case <-time.After(10 * time.Second):
ns.Close()
cancel()
os.RemoveAll(dataDir)
return nil, fmt.Errorf("embedded nats server did not start within 10s")
}
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
}

View File

@@ -2,7 +2,6 @@ package via
import (
"sync"
"sync/atomic"
"testing"
"time"
@@ -11,88 +10,50 @@ import (
"github.com/stretchr/testify/require"
)
type mockHandler struct {
id int64
fn func([]byte)
active atomic.Bool
}
// mockPubSub implements PubSub for testing without NATS.
type mockPubSub struct {
mu sync.Mutex
subs map[string][]*mockHandler
nextID atomic.Int64
}
func newMockPubSub() *mockPubSub {
return &mockPubSub{subs: make(map[string][]*mockHandler)}
}
func (m *mockPubSub) Publish(subject string, data []byte) error {
m.mu.Lock()
handlers := make([]*mockHandler, len(m.subs[subject]))
copy(handlers, m.subs[subject])
m.mu.Unlock()
for _, h := range handlers {
if h.active.Load() {
h.fn(data)
}
// setupNATSTest creates a *V with an embedded NATS server.
// Skips the test if NATS fails to start (e.g. port conflict in CI).
func setupNATSTest(t *testing.T) *V {
t.Helper()
v := New()
dn, err := getSharedNATS()
if err != nil {
v.Shutdown()
t.Skipf("embedded NATS unavailable: %v", err)
}
return nil
}
func (m *mockPubSub) Subscribe(subject string, handler func(data []byte)) (Subscription, error) {
m.mu.Lock()
defer m.mu.Unlock()
mh := &mockHandler{
id: m.nextID.Add(1),
fn: handler,
}
mh.active.Store(true)
m.subs[subject] = append(m.subs[subject], mh)
return &mockSub{handler: mh}, nil
}
func (m *mockPubSub) Close() error { return nil }
type mockSub struct {
handler *mockHandler
}
func (s *mockSub) Unsubscribe() error {
s.handler.active.Store(false)
return nil
v.defaultNATS = dn
v.pubsub = &natsRef{dn: dn}
t.Cleanup(v.Shutdown)
return v
}
func TestPubSub_RoundTrip(t *testing.T) {
ps := newMockPubSub()
v := New()
v.Config(Options{PubSub: ps})
v := setupNATSTest(t)
var received []byte
var wg sync.WaitGroup
wg.Add(1)
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
wg.Done()
close(done)
})
require.NoError(t, err)
err = c.Publish("test.topic", []byte("hello"))
require.NoError(t, err)
wg.Wait()
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) {
ps := newMockPubSub()
v := New()
v.Config(Options{PubSub: ps})
v := setupNATSTest(t)
var mu sync.Mutex
var results []string
@@ -119,7 +80,17 @@ func TestPubSub_MultipleSubscribers(t *testing.T) {
})
c1.Publish("broadcast", []byte("msg"))
wg.Wait()
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")
@@ -127,9 +98,7 @@ func TestPubSub_MultipleSubscribers(t *testing.T) {
}
func TestPubSub_SubscriptionCleanupOnDispose(t *testing.T) {
ps := newMockPubSub()
v := New()
v.Config(Options{PubSub: ps})
v := setupNATSTest(t)
c := newContext("cleanup-ctx", "/", v)
c.View(func() h.H { return h.Div() })
@@ -144,9 +113,7 @@ func TestPubSub_SubscriptionCleanupOnDispose(t *testing.T) {
}
func TestPubSub_ManualUnsubscribe(t *testing.T) {
ps := newMockPubSub()
v := New()
v.Config(Options{PubSub: ps})
v := setupNATSTest(t)
c := newContext("unsub-ctx", "/", v)
c.View(func() h.H { return h.Div() })
@@ -160,28 +127,12 @@ func TestPubSub_ManualUnsubscribe(t *testing.T) {
sub.Unsubscribe()
c.Publish("topic", []byte("ignored"))
time.Sleep(10 * time.Millisecond)
time.Sleep(50 * time.Millisecond)
assert.False(t, called)
}
func TestPubSub_NoOpWhenNotConfigured(t *testing.T) {
v := New()
c := newContext("noop-ctx", "/", v)
c.View(func() h.H { return h.Div() })
err := c.Publish("topic", []byte("data"))
assert.Error(t, err)
sub, err := c.Subscribe("topic", func(data []byte) {})
assert.Error(t, err)
assert.Nil(t, sub)
}
func TestPubSub_NoOpDuringPanicCheck(t *testing.T) {
ps := newMockPubSub()
v := New()
v.Config(Options{PubSub: ps})
v := setupNATSTest(t)
// Panic-check context has id=""
c := newContext("", "/", v)

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);
})();

View File

@@ -1,7 +1,9 @@
package via
// PubSub is an interface for publish/subscribe messaging backends.
// The vianats sub-package provides an embedded NATS implementation.
// By default, Start() launches an embedded NATS server if no backend
// has been configured. 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)

View File

@@ -1,8 +1,8 @@
package via
import (
"sync"
"testing"
"time"
"github.com/ryanhamamura/via/h"
"github.com/stretchr/testify/assert"
@@ -10,9 +10,7 @@ import (
)
func TestPublishSubscribe_RoundTrip(t *testing.T) {
ps := newMockPubSub()
v := New()
v.Config(Options{PubSub: ps})
v := setupNATSTest(t)
type event struct {
Name string `json:"name"`
@@ -20,30 +18,31 @@ func TestPublishSubscribe_RoundTrip(t *testing.T) {
}
var got event
var wg sync.WaitGroup
wg.Add(1)
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
wg.Done()
close(done)
})
require.NoError(t, err)
err = Publish(c, "events", event{Name: "click", Count: 42})
require.NoError(t, err)
wg.Wait()
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) {
ps := newMockPubSub()
v := New()
v.Config(Options{PubSub: ps})
v := setupNATSTest(t)
type msg struct {
Text string `json:"text"`
@@ -62,5 +61,6 @@ func TestSubscribe_SkipsBadJSON(t *testing.T) {
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

@@ -9,27 +9,27 @@ import (
)
// Signal represents a value that is reactive in the browser. Signals
// are synct with the server right before an action triggers.
// are synced with the server right before an action triggers.
//
// Use Bind() to connect a signal to an input and Text() to display it
// reactively on an html element.
type signal struct {
type Signal struct {
id string
val any
changed bool
err error
}
// ID returns the signal ID
func (s *signal) ID() string {
// ID returns the signal ID.
func (s *Signal) ID() string {
return s.id
}
// Err returns a signal error or nil if it contains no error.
//
// It is useful to check for errors after updating signals with
// dinamic values.
func (s *signal) Err() error {
// dynamic values.
func (s *Signal) Err() error {
return s.err
}
@@ -39,7 +39,7 @@ func (s *signal) Err() error {
// Example:
//
// h.Input(h.Type("number"), mysignal.Bind())
func (s *signal) Bind() h.H {
func (s *Signal) Bind() h.H {
return h.Data("bind", s.id)
}
@@ -48,59 +48,35 @@ func (s *signal) Bind() h.H {
// Example:
//
// h.Div(mysignal.Text())
func (s *signal) Text() h.H {
func (s *Signal) Text() h.H {
return h.Span(h.Data("text", "$"+s.id))
}
// SetValue updates the signals value and marks it for synchronization with the browser.
// The change will be propagated to the browser using *Context.Sync() or *Context.SyncSignals().
func (s *signal) SetValue(v any) {
func (s *Signal) SetValue(v any) {
s.val = v
s.changed = true
s.err = nil
}
// String return the signal value as a string.
func (s *signal) String() string {
// String returns the signal value as a string.
func (s *Signal) String() string {
return fmt.Sprintf("%v", s.val)
}
// Bool tries to read the signal value as a bool.
// Returns the value or false on failure.
func (s *signal) Bool() bool {
func (s *Signal) Bool() bool {
val := strings.ToLower(s.String())
return val == "true" || val == "1" || val == "yes" || val == "on"
}
// Int tries to read the signal value as an int.
// Returns the value or 0 on failure.
func (s *signal) Int() int {
func (s *Signal) Int() int {
if n, err := strconv.Atoi(s.String()); err == nil {
return n
}
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

@@ -27,7 +27,7 @@ func TestSignalReturnAsString(t *testing.T) {
for _, testcase := range testcases {
t.Run(testcase.desc, func(t *testing.T) {
t.Parallel()
var sig *signal
var sig *Signal
v := New()
v.Page("/", func(c *Context) {
sig = c.Signal(testcase.given)
@@ -57,7 +57,7 @@ func TestSignalReturnAsStringComplexTypes(t *testing.T) {
for _, testcase := range testcases {
t.Run(testcase.desc, func(t *testing.T) {
t.Parallel()
var sig *signal
var sig *Signal
v := New()
v.Page("/", func(c *Context) {
c.View(func() h.H { return nil })

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{}

293
via.go
View File

@@ -9,11 +9,13 @@ package via
import (
"context"
"crypto/rand"
"crypto/subtle"
_ "embed"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"io/fs"
"net/http"
"net/url"
"os"
@@ -33,23 +35,31 @@ import (
//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
server *http.Server
logger zerolog.Logger
contextRegistry map[string]*Context
contextRegistryMutex sync.RWMutex
documentHeadIncludes []h.H
documentFootIncludes []h.H
devModePageInitFnMap map[string]func(*Context)
sessionManager *scs.SessionManager
pubsub PubSub
datastarPath string
datastarContent []byte
datastarOnce sync.Once
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
middleware []Middleware
layout func(func() h.H) h.H
}
func (v *V) logEvent(evt *zerolog.Event, c *Context) *zerolog.Event {
@@ -125,8 +135,15 @@ func (v *V) Config(cfg Options) {
v.datastarPath = cfg.DatastarPath
}
if cfg.PubSub != nil {
v.defaultNATS = nil
v.pubsub = cfg.PubSub
}
if cfg.Streams != nil {
v.cfg.Streams = cfg.Streams
}
if cfg.ActionRateLimit.Rate != 0 || cfg.ActionRateLimit.Burst != 0 {
v.actionRateLimit = cfg.ActionRateLimit
}
}
// AppendToHead appends the given h.H nodes to the head of the base HTML document.
@@ -160,8 +177,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 {
@@ -170,14 +195,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())
@@ -191,7 +216,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)
@@ -199,10 +224,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()}
@@ -213,10 +240,9 @@ func (v *V) Page(route string, initContextFn func(c *Context)) {
bodyElements = append(bodyElements, h.Raw("<dataspa-inspector/>"))
}
view := h.HTML5(h.HTML5Props{
Title: v.cfg.DocumentTitle,
Head: headElements,
Body: bodyElements,
HTMLAttrs: []h.H{},
Title: v.cfg.DocumentTitle,
Head: headElements,
Body: bodyElements,
})
_ = view.Render(w)
}))
@@ -225,17 +251,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) {
@@ -247,7 +273,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) {
@@ -262,6 +288,22 @@ func (v *V) getCtx(id string) (*Context, error) {
// 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() {
if v.pubsub == nil {
dn, err := getSharedNATS()
if err != nil {
v.logWarn(nil, "embedded NATS unavailable: %v", err)
} else {
v.defaultNATS = dn
v.pubsub = &natsRef{dn: dn}
}
}
for _, sc := range v.cfg.Streams {
if err := EnsureStream(v, sc); err != nil {
v.logger.Fatal().Err(err).Msgf("failed to create stream %q", sc.Name)
}
}
handler := http.Handler(v.mux)
if v.sessionManager != nil {
handler = v.sessionManager.LoadAndSave(v.mux)
@@ -291,16 +333,12 @@ func (v *V) Start() {
return
}
v.shutdown()
v.Shutdown()
}
// Shutdown gracefully shuts down the server and all contexts.
// Safe for programmatic or test use.
func (v *V) Shutdown() {
v.shutdown()
}
func (v *V) shutdown() {
v.logInfo(nil, "draining all contexts")
v.drainAllContexts()
@@ -317,6 +355,7 @@ func (v *V) shutdown() {
v.logErr(nil, "pubsub close error: %v", err)
}
}
v.defaultNATS = nil
v.logInfo(nil, "shutdown complete")
}
@@ -346,6 +385,51 @@ func (v *V) HTTPServeMux() *http.ServeMux {
return v.mux
}
// 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 += "/"
}
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) {
@@ -400,10 +484,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)
@@ -455,6 +536,7 @@ type patchType int
const (
patchTypeElements = iota
patchTypeElementsWithVT
patchTypeSignals
patchTypeScript
patchTypeRedirect
@@ -475,6 +557,7 @@ func New() *V {
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,
@@ -497,7 +580,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()
@@ -507,29 +592,53 @@ 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() {
c.Sync()
}()
go c.Sync()
keepalive := time.NewTicker(30 * time.Second)
defer keepalive.Stop()
for {
select {
case <-sse.Context().Done():
v.logDebug(c, "SSE connection ended")
c.sseConnected.Store(false)
dcNow := time.Now()
c.sseDisconnectedAt.Store(&dcNow)
return
case <-c.ctxDisposedChan:
v.logDebug(c, "context disposed, closing SSE")
return
case <-keepalive.C:
sse.PatchSignals([]byte("{}"))
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 {
@@ -572,13 +681,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)
@@ -586,7 +711,44 @@ 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) {
@@ -603,20 +765,23 @@ func New() *V {
v.logErr(c, "failed to handle session close: %v", err)
return
}
c.dispose()
v.logDebug(c, "session close event triggered")
if v.cfg.DevMode {
v.devModeRemovePersisted(c)
}
v.unregisterCtx(c)
v.cleanupCtx(c)
})
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 {
@@ -631,8 +796,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,9 +1,13 @@
package via
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/ryanhamamura/via/h"
"github.com/stretchr/testify/assert"
@@ -86,7 +90,7 @@ func TestCustomDatastarPath(t *testing.T) {
}
func TestSignal(t *testing.T) {
var sig *signal
var sig *Signal
v := New()
v.Page("/", func(c *Context) {
sig = c.Signal("test")
@@ -102,7 +106,7 @@ func TestSignal(t *testing.T) {
func TestAction(t *testing.T) {
var trigger *actionTrigger
var sig *signal
var sig *Signal
v := New()
v.Page("/", func(c *Context) {
trigger = c.Action(func() {})
@@ -123,11 +127,65 @@ func TestAction(t *testing.T) {
v.mux.ServeHTTP(w, req)
body := w.Body.String()
assert.Contains(t, body, "data-on:click")
assert.Contains(t, body, "data-on:change__debounce.200ms")
assert.Contains(t, body, "data-on:change")
assert.Contains(t, body, "data-on:keydown")
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()
@@ -149,7 +207,7 @@ func TestOnKeyDownWithWindow(t *testing.T) {
func TestOnKeyDownMap(t *testing.T) {
t.Run("multiple bindings with different actions", func(t *testing.T) {
var move, shoot *actionTrigger
var dir *signal
var dir *Signal
v := New()
v.Page("/", func(c *Context) {
dir = c.Signal("none")
@@ -193,7 +251,7 @@ func TestOnKeyDownMap(t *testing.T) {
t.Run("WithSignal per binding", func(t *testing.T) {
var move *actionTrigger
var dir *signal
var dir *Signal
v := New()
v.Page("/", func(c *Context) {
dir = c.Signal("none")
@@ -223,6 +281,112 @@ func TestOnKeyDownMap(t *testing.T) {
})
}
func TestFormatDuration(t *testing.T) {
tests := []struct {
d time.Duration
want string
}{
{200 * time.Millisecond, "200ms"},
{1 * time.Second, "1000ms"},
{50 * time.Millisecond, "50ms"},
}
for _, tt := range tests {
assert.Equal(t, tt.want, formatDuration(tt.d))
}
}
func TestBuildAttrKey(t *testing.T) {
tests := []struct {
name string
event string
opts triggerOpts
want string
}{
{"bare event", "click", triggerOpts{}, "on:click"},
{"debounce only", "change", triggerOpts{debounce: 200 * time.Millisecond}, "on:change__debounce.200ms"},
{"throttle only", "scroll", triggerOpts{throttle: 100 * time.Millisecond}, "on:scroll__throttle.100ms"},
{"window only", "keydown", triggerOpts{window: true}, "on:keydown__window"},
{"debounce + window", "input", triggerOpts{debounce: 300 * time.Millisecond, window: true}, "on:input__debounce.300ms__window"},
{"throttle + window", "scroll", triggerOpts{throttle: 500 * time.Millisecond, window: true}, "on:scroll__throttle.500ms__window"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, buildAttrKey(tt.event, &tt.opts))
})
}
}
func TestWithDebounce(t *testing.T) {
var trigger *actionTrigger
v := New()
v.Page("/", func(c *Context) {
trigger = c.Action(func() {})
c.View(func() h.H {
return h.Button(trigger.OnClick(WithDebounce(300 * time.Millisecond)))
})
})
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
v.mux.ServeHTTP(w, req)
body := w.Body.String()
assert.Contains(t, body, "data-on:click__debounce.300ms")
assert.Contains(t, body, "/_action/"+trigger.id)
}
func TestWithThrottle(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.OnScroll(WithThrottle(100 * time.Millisecond)))
})
})
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
v.mux.ServeHTTP(w, req)
body := w.Body.String()
assert.Contains(t, body, "data-on:scroll__throttle.100ms")
assert.Contains(t, body, "/_action/"+trigger.id)
}
func TestWithDebounceOnChange(t *testing.T) {
var trigger *actionTrigger
v := New()
v.Page("/", func(c *Context) {
trigger = c.Action(func() {})
c.View(func() h.H {
return h.Input(trigger.OnChange(WithDebounce(200 * time.Millisecond)))
})
})
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
v.mux.ServeHTTP(w, req)
body := w.Body.String()
assert.Contains(t, body, "data-on:change__debounce.200ms")
assert.Contains(t, body, "/_action/"+trigger.id)
}
func TestDebounceWithWindow(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", WithDebounce(150*time.Millisecond), 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__debounce.150ms__window")
}
func TestConfig(t *testing.T) {
v := New()
v.Config(Options{DocumentTitle: "Test"})
@@ -235,3 +399,58 @@ func TestPage_PanicsOnNoView(t *testing.T) {
v.Page("/", func(c *Context) {})
})
}
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")
}

View File

@@ -1,127 +0,0 @@
// Package vianats provides an embedded NATS server with JetStream as a
// pub/sub backend for Via applications.
package vianats
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/delaneyj/toolbelt/embeddednats"
"github.com/nats-io/nats.go"
"github.com/ryanhamamura/via"
)
// NATS implements via.PubSub using an embedded NATS server with JetStream.
type NATS struct {
server *embeddednats.Server
nc *nats.Conn
js nats.JetStreamContext
}
// New starts an embedded NATS server with JetStream enabled and returns a
// ready-to-use NATS instance. The server stores data in dataDir and shuts
// down when ctx is cancelled.
func New(ctx context.Context, dataDir string) (*NATS, error) {
ns, err := embeddednats.New(ctx, embeddednats.WithDirectory(dataDir))
if err != nil {
return nil, fmt.Errorf("vianats: start server: %w", err)
}
ns.WaitForServer()
nc, err := ns.Client()
if err != nil {
ns.Close()
return nil, fmt.Errorf("vianats: connect client: %w", err)
}
js, err := nc.JetStream()
if err != nil {
nc.Close()
ns.Close()
return nil, fmt.Errorf("vianats: init jetstream: %w", err)
}
return &NATS{server: ns, nc: nc, js: js}, nil
}
// Publish sends data to the given subject using core NATS publish.
// JetStream captures messages automatically if a matching stream exists.
func (n *NATS) Publish(subject string, data []byte) error {
return n.nc.Publish(subject, data)
}
// Subscribe creates a core NATS subscription for real-time fan-out delivery.
func (n *NATS) Subscribe(subject string, handler func(data []byte)) (via.Subscription, error) {
sub, err := n.nc.Subscribe(subject, func(msg *nats.Msg) {
handler(msg.Data)
})
if err != nil {
return nil, err
}
return sub, nil
}
// Close shuts down the client connection and embedded server.
func (n *NATS) Close() error {
n.nc.Close()
return n.server.Close()
}
// Conn returns the underlying NATS connection for advanced usage.
func (n *NATS) Conn() *nats.Conn {
return n.nc
}
// JetStream returns the JetStream context for stream configuration and replay.
func (n *NATS) JetStream() nats.JetStreamContext {
return n.js
}
// 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(n *NATS, cfg StreamConfig) error {
_, err := n.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](n *NATS, subject string, limit int) ([]T, error) {
sub, err := n.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
}