10 Commits

Author SHA1 Message Date
Ryan Hamamura
85af1722c3 feat: animate fleet of container ships along bay waypoints
All checks were successful
CI / Build and Test (push) Successful in 35s
CI / Build and Test (pull_request) Successful in 34s
Replace the static ship marker with three signal-backed ships that
lerp along waypoint loops through SF Bay (Golden Gate, Oakland, and
Sausalito ferry routes). All clients see the same ship positions via
shared Go state synced every second.
2026-02-20 11:09:28 -10:00
c2794fa0f9 style: simplify container ship SVG marker (#12)
All checks were successful
CI / Build and Test (push) Successful in 35s
2026-02-20 20:56:08 +00:00
7edd5ed1e6 style: replace simple ship icon with container vessel SVG (#11)
All checks were successful
CI / Build and Test (push) Successful in 36s
2026-02-20 20:53:04 +00:00
934805e707 feat: support custom HTML/SVG element markers in MapLibre (#10)
All checks were successful
CI / Build and Test (push) Successful in 34s
2026-02-20 20:40:19 +00:00
cbc5022e0d feat: sync all markers across clients in MapLibre example (#9)
All checks were successful
CI / Build and Test (push) Successful in 35s
2026-02-20 20:16:17 +00:00
74b32800f9 chore: gitignore nats-chatroom directory (#8)
Some checks failed
CI / Build and Test (push) Has been cancelled
2026-02-20 20:15:30 +00:00
cb13839157 fix: nil-close bug, stale docs, dead code, and tracked binaries (#7)
All checks were successful
CI / Build and Test (push) Successful in 34s
2026-02-20 20:00:44 +00:00
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
25 changed files with 307 additions and 400 deletions

View File

@@ -8,7 +8,7 @@ Create a PR on Gitea, wait for CI, and squash-merge it. Push code to both remote
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. 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. 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. 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: `git checkout main && git pull gitea main && git push origin main`. 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>`. 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`. 10. Prune refs: `git remote prune gitea && git remote prune origin`.
11. Report the merged PR URL. 11. Report the merged PR URL.

18
.gitignore vendored
View File

@@ -37,21 +37,15 @@ go.work.sum
# Air artifacts # Air artifacts
*tmp/ *tmp/
# binaries # Example binaries and data files
internal/examples/chatroom/chatroom internal/examples/*/[a-z]*[!.go]
internal/examples/counter/counter internal/examples/shakespeare/shake.db
internal/examples/countercomp/countercomp
internal/examples/greeter/greeter
internal/examples/livereload/livereload
internal/examples/picocss/picocss
internal/examples/plugins/plugins
internal/examples/realtimechart/realtimechart
internal/examples/shakespeare/shakespeare
internal/examples/nats-chatroom/nats-chatroom
/nats-chatroom
# NATS data directory # NATS data directory
data/ data/
# Standalone experiments
nats-chatroom
# Claude Code worktrees # Claude Code worktrees
.claude/worktrees/ .claude/worktrees/

View File

@@ -74,14 +74,13 @@ func main() {
- **Plugin system** — `func(v *V)` hooks for integrating CSS/JS libraries - **Plugin system** — `func(v *V)` hooks for integrating CSS/JS libraries
- **Structured logging** — zerolog with configurable levels; console output in dev, JSON in production - **Structured logging** — zerolog with configurable levels; console output in dev, JSON in production
- **Graceful shutdown** — listens for SIGINT/SIGTERM, drains contexts, closes pub/sub - **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 - **HTML DSL** — the `h` package provides type-safe Go-native HTML composition
## Examples ## Examples
The `internal/examples/` directory contains 14 runnable examples: The `internal/examples/` directory contains 19 runnable examples:
`chatroom` · `counter` · `countercomp` · `greeter` · `keyboard` · `livereload` · `nats-chatroom` · `pathparams` · `picocss` · `plugins` · `pubsub-crud` · `realtimechart` · `session` · `shakespeare` `chatroom` · `counter` · `countercomp` · `effectspike` · `greeter` · `keyboard` · `livereload` · `maplibre` · `middleware` · `nats-chatroom` · `pathparams` · `picocss` · `plugins` · `pubsub-crud` · `realtimechart` · `session` · `shakespeare` · `signup` · `spa`
## Experimental ## Experimental

View File

@@ -6,9 +6,13 @@ set -o pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT" cd "$ROOT"
echo "== CI: Format code ==" echo "== CI: Check formatting =="
go fmt ./... if [ -n "$(gofmt -l .)" ]; then
echo "OK: formatting complete" echo "ERROR: files not formatted:"
gofmt -l .
exit 1
fi
echo "OK: all files formatted"
echo "== CI: Run go vet ==" echo "== CI: Run go vet =="
if ! go vet ./...; then if ! go vet ./...; then

View File

@@ -1,7 +1,6 @@
package via package via
import ( import (
"fmt"
"strconv" "strconv"
"strings" "strings"
@@ -51,5 +50,5 @@ func (s *computedSignal) recompute() {
} }
func (s *computedSignal) patchValue() string { func (s *computedSignal) patchValue() string {
return fmt.Sprintf("%v", s.lastVal) return s.lastVal
} }

View File

@@ -1,8 +1,6 @@
package via package via
import ( import (
"time"
"github.com/alexedwards/scs/v2" "github.com/alexedwards/scs/v2"
"github.com/rs/zerolog" "github.com/rs/zerolog"
) )
@@ -62,16 +60,6 @@ type Options struct {
// the embedded NATS server. Ignored when a custom PubSub is configured. // the embedded NATS server. Ignored when a custom PubSub is configured.
Streams []StreamConfig Streams []StreamConfig
// ContextSuspendAfter is the time a context may be disconnected before
// the reaper suspends it (frees page resources but keeps the context
// shell for seamless re-init on reconnect). Default: 15m.
ContextSuspendAfter time.Duration
// ContextTTL is the maximum time a context may exist without an SSE
// connection before the background reaper fully disposes it.
// Default: 1h. Negative value disables the reaper.
ContextTTL time.Duration
// ActionRateLimit configures the default token-bucket rate limiter for // ActionRateLimit configures the default token-bucket rate limiter for
// action endpoints. Zero values use built-in defaults (10 req/s, burst 20). // action endpoints. Zero values use built-in defaults (10 req/s, burst 20).
// Set Rate to -1 to disable rate limiting entirely. // Set Rate to -1 to disable rate limiting entirely.

View File

@@ -42,8 +42,6 @@ type Context struct {
createdAt time.Time createdAt time.Time
sseConnected atomic.Bool sseConnected atomic.Bool
sseDisconnectedAt atomic.Pointer[time.Time] sseDisconnectedAt atomic.Pointer[time.Time]
lastSeenAt atomic.Pointer[time.Time]
suspended atomic.Bool
} }
// View defines the UI rendered by this context. // View defines the UI rendered by this context.
@@ -444,13 +442,6 @@ func (c *Context) resetPageState() {
c.mu.Unlock() c.mu.Unlock()
} }
// suspend frees page-scoped resources while keeping the context shell alive
// in the registry for seamless re-init on reconnect.
func (c *Context) suspend() {
c.resetPageState()
c.suspended.Store(true)
}
// Navigate performs an SPA navigation to the given path. It resets page state, // 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 // runs the target page's init function (with middleware), and pushes the new
// view over the existing SSE connection with a view transition animation. // view over the existing SSE connection with a view transition animation.

View File

@@ -66,7 +66,6 @@ v.Config(via.Options{
Plugins: []via.Plugin{MyPlugin}, Plugins: []via.Plugin{MyPlugin},
SessionManager: sm, SessionManager: sm,
PubSub: customBackend, PubSub: customBackend,
ContextTTL: 60 * time.Second,
ActionRateLimit: via.RateLimitConfig{Rate: 20, Burst: 40}, ActionRateLimit: via.RateLimitConfig{Rate: 20, Burst: 40},
}) })
``` ```
@@ -83,7 +82,6 @@ v.Config(via.Options{
| `DatastarContent` | (embedded) | Custom Datastar JS bytes | | `DatastarContent` | (embedded) | Custom Datastar JS bytes |
| `DatastarPath` | `"/_datastar.js"` | URL path for the Datastar script | | `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) | | `PubSub` | embedded NATS | Custom PubSub backend. Replaces the default NATS. See [PubSub and Sessions](pubsub-and-sessions.md) |
| `ContextTTL` | `30s` | Max time a context survives without an SSE connection before cleanup. Negative value disables the reaper |
| `ActionRateLimit` | `10 req/s, burst 20` | Default token-bucket rate limiter for action endpoints. Rate of `-1` disables limiting | | `ActionRateLimit` | `10 req/s, burst 20` | Default token-bucket rate limiter for action endpoints. Rate of `-1` disables limiting |
## Static Files ## Static Files

View File

@@ -14,7 +14,7 @@ Browser hits page → new Context created → init function runs → HTML render
action fires → signals injected from browser → handler runs → Sync() → DOM patched action fires → signals injected from browser → handler runs → Sync() → DOM patched
``` ```
The context is disposed when the SSE connection closes (tab close, navigation away, network loss). A background reaper also cleans up contexts that never establish an SSE connection within `ContextTTL` (default 30s). 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. 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.
@@ -94,7 +94,7 @@ Available triggers:
|--------|-------|-------| |--------|-------|-------|
| `OnClick()` | `click` | | | `OnClick()` | `click` | |
| `OnDblClick()` | `dblclick` | | | `OnDblClick()` | `dblclick` | |
| `OnChange()` | `change` | 200ms debounce | | `OnChange()` | `change` | |
| `OnInput()` | `input` | No debounce | | `OnInput()` | `input` | No debounce |
| `OnSubmit()` | `submit` | | | `OnSubmit()` | `submit` | |
| `OnKeyDown(key)` | `keydown` | Filtered by key name (e.g. `"Enter"`, `"Escape"`) | | `OnKeyDown(key)` | `keydown` | Filtered by key name (e.g. `"Enter"`, `"Escape"`) |

2
h/h.go
View File

@@ -5,7 +5,7 @@
// //
// h.Div( // h.Div(
// h.H1(h.Text("Hello, Via")), // h.H1(h.Text("Hello, Via")),
// h.P(h.Text("Pure Go. No tmplates.")), // h.P(h.Text("Pure Go. No templates.")),
// ) // )
package h package h

View File

@@ -2,7 +2,6 @@ package main
import ( import (
"github.com/ryanhamamura/via" "github.com/ryanhamamura/via"
// "github.com/go-via/via-plugin-picocss/picocss"
"github.com/ryanhamamura/via/h" "github.com/ryanhamamura/via/h"
) )
@@ -15,9 +14,6 @@ func main() {
DocumentTitle: "Live Reload Demo", DocumentTitle: "Live Reload Demo",
DevMode: true, DevMode: true,
LogLevel: via.LogLevelDebug, LogLevel: via.LogLevelDebug,
Plugins: []via.Plugin{
// picocss.Default
},
}) })
v.Page("/", func(c *via.Context) { v.Page("/", func(c *via.Context) {

View File

@@ -1,7 +1,11 @@
package main package main
import ( import (
"fmt"
"math"
"math/rand" "math/rand"
"strconv"
"sync"
"time" "time"
"github.com/ryanhamamura/via" "github.com/ryanhamamura/via"
@@ -9,6 +13,68 @@ import (
"github.com/ryanhamamura/via/maplibre" "github.com/ryanhamamura/via/maplibre"
) )
type posMsg struct {
Lng float64 `json:"lng"`
Lat float64 `json:"lat"`
}
var (
vehicleOnce sync.Once
vehicle struct {
mu sync.RWMutex
lng, lat float64
}
)
// shipState tracks a ship lerping along a loop of waypoints.
type shipState struct {
lng, lat float64
waypoints [][2]float64 // [lng, lat] pairs
wpIdx int // index of next target waypoint
progress float64 // 0..1 toward next waypoint
speed float64 // progress increment per tick
}
func (s *shipState) tick() {
s.progress += s.speed
for s.progress >= 1 {
s.progress -= 1
s.wpIdx = (s.wpIdx + 1) % len(s.waypoints)
}
from := s.waypoints[(s.wpIdx-1+len(s.waypoints))%len(s.waypoints)]
to := s.waypoints[s.wpIdx]
s.lng = from[0] + (to[0]-from[0])*s.progress
s.lat = from[1] + (to[1]-from[1])*s.progress
}
// heading returns clockwise degrees from north (for SVG rotation).
func (s *shipState) heading() float64 {
from := s.waypoints[(s.wpIdx-1+len(s.waypoints))%len(s.waypoints)]
to := s.waypoints[s.wpIdx]
dx := to[0] - from[0]
dy := to[1] - from[1]
// atan2 gives angle from +X axis; convert to CW from north
return math.Mod(math.Atan2(dx, dy)*180/math.Pi+360, 360)
}
var (
fleetOnce sync.Once
fleet struct {
mu sync.RWMutex
ships [3]shipState
}
)
const shipSVG = `<svg width="48" height="28" viewBox="0 0 80 44" xmlns="http://www.w3.org/2000/svg">` +
`<path d="M2 30 L10 42 L70 42 L78 30 Z" fill="#1b3a5c"/>` +
`<rect x="12" y="24" width="56" height="6" rx="1" fill="#2c5f8a"/>` +
`<rect x="18" y="14" width="46" height="10" rx="1" fill="#2c5f8a" stroke="#1b3a5c" stroke-width="0.5"/>` +
`<rect x="8" y="12" width="9" height="12" rx="1" fill="#d5dfe8" stroke="#2c5f8a" stroke-width="0.5"/>` +
`<rect x="9.5" y="13" width="6" height="4" rx="0.5" fill="#85c1e9"/>` +
`<rect x="10" y="4" width="5" height="8" rx="0.5" fill="#1b3a5c"/>` +
`<rect x="9.5" y="2" width="6" height="3" rx="0.5" fill="#c0392b"/>` +
`</svg>`
func main() { func main() {
v := via.New() v := via.New()
v.Config(via.Options{ v.Config(via.Options{
@@ -18,6 +84,65 @@ func main() {
Plugins: []via.Plugin{maplibre.Plugin}, Plugins: []via.Plugin{maplibre.Plugin},
}) })
// Single goroutine moves the vehicle — all clients read the same position.
vehicle.lng = -122.43
vehicle.lat = 37.77
vehicleOnce.Do(func() {
go func() {
for {
time.Sleep(time.Second)
vehicle.mu.Lock()
vehicle.lng = -122.43 + (rand.Float64()-0.5)*0.02
vehicle.lat = 37.77 + (rand.Float64()-0.5)*0.02
vehicle.mu.Unlock()
}
}()
})
// Fleet of ships following waypoint loops through SF Bay.
fleetOnce.Do(func() {
fleet.ships = [3]shipState{
{ // Golden Gate → Alcatraz → Pier 39 → back out
waypoints: [][2]float64{
{-122.478, 37.819}, {-122.423, 37.827},
{-122.410, 37.809}, {-122.423, 37.827},
},
speed: 0.03,
},
{ // Oakland → Treasure Island → Angel Island → loop
waypoints: [][2]float64{
{-122.330, 37.795}, {-122.370, 37.823},
{-122.432, 37.860}, {-122.370, 37.823},
},
speed: 0.02,
},
{ // Sausalito → Pier 39 ferry route
waypoints: [][2]float64{
{-122.480, 37.859}, {-122.435, 37.840},
{-122.410, 37.809}, {-122.435, 37.840},
},
speed: 0.025,
},
}
// Set initial positions.
for i := range fleet.ships {
s := &fleet.ships[i]
s.lng = s.waypoints[0][0]
s.lat = s.waypoints[0][1]
s.wpIdx = 1
}
go func() {
for {
time.Sleep(time.Second)
fleet.mu.Lock()
for i := range fleet.ships {
fleet.ships[i].tick()
}
fleet.mu.Unlock()
}
}()
})
v.Page("/", func(c *via.Context) { v.Page("/", func(c *via.Context) {
m := maplibre.New(c, maplibre.Options{ m := maplibre.New(c, maplibre.Options{
Style: "https://demotiles.maplibre.org/style.json", Style: "https://demotiles.maplibre.org/style.json",
@@ -45,23 +170,81 @@ func main() {
}, },
}) })
// Signal-backed marker — server pushes position updates // Animated container ships following waypoint routes
shipNames := [3]string{"MSC Adriatica", "Evergreen Harmony", "Maersk Aurora"}
type shipSignals struct{ lng, lat *via.Signal }
var ships [3]shipSignals
fleet.mu.RLock()
for i, s := range fleet.ships {
ships[i].lng = c.Signal(s.lng)
ships[i].lat = c.Signal(s.lat)
m.AddMarker(fmt.Sprintf("ship-%d", i), maplibre.Marker{
LngSignal: ships[i].lng,
LatSignal: ships[i].lat,
Element: shipSVG,
Anchor: "center",
Rotation: s.heading(),
Popup: &maplibre.Popup{
Content: fmt.Sprintf("<strong>%s</strong>", shipNames[i]),
},
})
}
fleet.mu.RUnlock()
// Custom SVG vehicle marker — reads shared Go state
vehicleLng := c.Signal(-122.43) vehicleLng := c.Signal(-122.43)
vehicleLat := c.Signal(37.77) vehicleLat := c.Signal(37.77)
m.AddMarker("vehicle", maplibre.Marker{ m.AddMarker("vehicle", maplibre.Marker{
LngSignal: vehicleLng, LngSignal: vehicleLng,
LatSignal: vehicleLat, LatSignal: vehicleLat,
Color: "#9b59b6", Element: `<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">` +
`<circle cx="10" cy="10" r="9" fill="#9b59b6" stroke="#fff" stroke-width="2"/>` +
`</svg>`,
Anchor: "center",
}) })
c.OnInterval(time.Second, func() { c.OnInterval(time.Second, func() {
vehicleLng.SetValue(-122.43 + (rand.Float64()-0.5)*0.02) vehicle.mu.RLock()
vehicleLat.SetValue(37.77 + (rand.Float64()-0.5)*0.02) lng, lat := vehicle.lng, vehicle.lat
vehicle.mu.RUnlock()
vehicleLng.SetValue(lng)
vehicleLat.SetValue(lat)
fleet.mu.RLock()
for i, s := range fleet.ships {
ships[i].lng.SetValue(s.lng)
ships[i].lat.SetValue(s.lat)
}
fleet.mu.RUnlock()
c.SyncSignals() c.SyncSignals()
}) })
// Draggable marker — user drags, signals update // Yellow click marker — synced across clients via PubSub
clickLng := c.Signal(-122.4194)
clickLat := c.Signal(37.7749)
m.AddMarker("clicked", maplibre.Marker{
LngSignal: clickLng,
LatSignal: clickLat,
Color: "#f39c12",
})
via.Subscribe(c, "map.click", func(msg posMsg) {
clickLng.SetValue(msg.Lng)
clickLat.SetValue(msg.Lat)
c.SyncSignals()
})
click := m.OnClick()
handleClick := c.Action(func() {
e := click.Data()
via.Publish(c, "map.click", posMsg{Lng: e.LngLat.Lng, Lat: e.LngLat.Lat})
})
// Blue draggable pin — synced across clients via PubSub
pinLng := c.Signal(-122.41) pinLng := c.Signal(-122.41)
pinLat := c.Signal(37.78) pinLat := c.Signal(37.78)
@@ -72,14 +255,16 @@ func main() {
Draggable: true, Draggable: true,
}) })
// Click event — click to place a marker via.Subscribe(c, "map.pin", func(msg posMsg) {
click := m.OnClick() pinLng.SetValue(msg.Lng)
handleClick := c.Action(func() { pinLat.SetValue(msg.Lat)
e := click.Data() c.SyncSignals()
m.AddMarker("clicked", maplibre.Marker{
LngLat: e.LngLat,
Color: "#f39c12",
}) })
handlePinDrag := c.Action(func() {
lng, _ := strconv.ParseFloat(pinLng.String(), 64)
lat, _ := strconv.ParseFloat(pinLat.String(), 64)
via.Publish(c, "map.pin", posMsg{Lng: lng, Lat: lat})
}) })
// GeoJSON polygon source + fill layer // GeoJSON polygon source + fill layer
@@ -111,7 +296,7 @@ func main() {
}, },
}) })
// FlyTo actions using CameraOptions // FlyTo actions
zoom14 := 14.0 zoom14 := 14.0
flyToSF := c.Action(func() { flyToSF := c.Action(func() {
m.FlyTo(maplibre.CameraOptions{ m.FlyTo(maplibre.CameraOptions{
@@ -134,6 +319,7 @@ func main() {
h.H1(h.Text("MapLibre GL Example")), h.H1(h.Text("MapLibre GL Example")),
m.Element( m.Element(
click.Input(handleClick.OnInput()), click.Input(handleClick.OnInput()),
h.Input(h.Type("hidden"), pinLng.Bind(), handlePinDrag.OnInput()),
), ),
h.Div(h.Attr("style", "margin-top:1rem;display:flex;gap:0.5rem;flex-wrap:wrap"), 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 San Francisco"), flyToSF.OnClick()),
@@ -142,6 +328,7 @@ func main() {
h.Div(h.Attr("style", "margin-top:0.5rem;font-size:0.9rem"), 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("Zoom: "), m.Zoom.Text()),
h.P(h.Text("Center: "), m.CenterLng.Text(), h.Text(", "), m.CenterLat.Text()), h.P(h.Text("Center: "), m.CenterLng.Text(), h.Text(", "), m.CenterLat.Text()),
h.P(h.Text("Click: "), clickLng.Text(), h.Text(", "), clickLat.Text()),
h.P(h.Text("Vehicle: "), vehicleLng.Text(), h.Text(", "), vehicleLat.Text()), h.P(h.Text("Vehicle: "), vehicleLng.Text(), h.Text(", "), vehicleLat.Text()),
h.P(h.Text("Draggable Pin: "), pinLng.Text(), h.Text(", "), pinLat.Text()), h.P(h.Text("Draggable Pin: "), pinLng.Text(), h.Text(", "), pinLat.Text()),
), ),

View File

@@ -4,17 +4,12 @@ import (
"strconv" "strconv"
"github.com/ryanhamamura/via" "github.com/ryanhamamura/via"
// "github.com/go-via/via-plugin-picocss/picocss"
. "github.com/ryanhamamura/via/h" . "github.com/ryanhamamura/via/h"
) )
func main() { func main() {
v := via.New() v := via.New()
v.Config(via.Options{
// Plugins: []via.Plugin{picocss.Default},
})
v.Page("/counters/{counter_id}/{start_at_step}", func(c *via.Context) { v.Page("/counters/{counter_id}/{start_at_step}", func(c *via.Context) {
counterID := c.GetPathParam("counter_id") counterID := c.GetPathParam("counter_id")

View File

@@ -2,7 +2,6 @@ package main
import ( import (
"github.com/ryanhamamura/via" "github.com/ryanhamamura/via"
// "github.com/go-via/via-plugin-picocss/picocss"
"github.com/ryanhamamura/via/h" "github.com/ryanhamamura/via/h"
) )
@@ -13,11 +12,6 @@ func main() {
v.Config(via.Options{ v.Config(via.Options{
DocumentTitle: "Via Counter", DocumentTitle: "Via Counter",
// Plugin is placed here. Use picocss.WithOptions(pococss.Options) to add the plugin
// with a different color theme or to enable a classes for a wide range of colors.
// Plugins: []via.Plugin{
// picocss.Default,
// },
}) })
v.Page("/", func(c *via.Context) { v.Page("/", func(c *via.Context) {

View File

@@ -6,7 +6,6 @@ import (
"time" "time"
"github.com/ryanhamamura/via" "github.com/ryanhamamura/via"
// "github.com/go-via/via-plugin-picocss/picocss"
"github.com/ryanhamamura/via/h" "github.com/ryanhamamura/via/h"
) )
@@ -16,9 +15,6 @@ func main() {
v.Config(via.Options{ v.Config(via.Options{
LogLevel: via.LogLevelDebug, LogLevel: via.LogLevelDebug,
DevMode: true, DevMode: true,
Plugins: []via.Plugin{
// picocss.Default,
},
}) })
v.AppendToHead( v.AppendToHead(

View File

@@ -6,9 +6,9 @@ import (
"log" "log"
"time" "time"
_ "github.com/mattn/go-sqlite3"
"github.com/ryanhamamura/via" "github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h" "github.com/ryanhamamura/via/h"
_ "github.com/mattn/go-sqlite3"
) )
type DataSource interface { type DataSource interface {
@@ -22,6 +22,9 @@ type ShakeDB struct {
findByTextStmt *sql.Stmt findByTextStmt *sql.Stmt
} }
// Prepare opens shake.db, a ~22 MB SQLite database of Shakespeare's works.
// Download from https://github.com/nicholasgasior/gopher-fizzbuzz/raw/master/shake.db
// and place it in this directory before running.
func (shakeDB *ShakeDB) Prepare() { func (shakeDB *ShakeDB) Prepare() {
db, err := sql.Open("sqlite3", "shake.db") db, err := sql.Open("sqlite3", "shake.db")
if err != nil { if err != nil {

View File

@@ -179,10 +179,25 @@ func initScript(m *Map) string {
// markerBodyJS generates JS to add a marker, assuming `map` is in scope. // markerBodyJS generates JS to add a marker, assuming `map` is in scope.
func markerBodyJS(mapID, markerID string, mk Marker) string { func markerBodyJS(mapID, markerID string, mk Marker) string {
var b strings.Builder var b strings.Builder
if mk.Element != "" {
b.WriteString(fmt.Sprintf(
`var _mkEl=document.createElement('div');_mkEl.innerHTML=%s;`,
jsonStr(mk.Element)))
}
opts := "{" opts := "{"
if mk.Color != "" { if mk.Element != "" {
opts += `element:_mkEl.firstElementChild||_mkEl,`
} else if mk.Color != "" {
opts += fmt.Sprintf(`color:%s,`, jsonStr(mk.Color)) opts += fmt.Sprintf(`color:%s,`, jsonStr(mk.Color))
} }
if mk.Anchor != "" {
opts += fmt.Sprintf(`anchor:%s,`, jsonStr(mk.Anchor))
}
if mk.Rotation != 0 {
opts += fmt.Sprintf(`rotation:%s,`, formatFloat(mk.Rotation))
}
if mk.Draggable { if mk.Draggable {
opts += `draggable:true,` opts += `draggable:true,`
} }

View File

@@ -302,6 +302,19 @@ type Marker struct {
Draggable bool Draggable bool
Popup *Popup Popup *Popup
// Element is raw HTML/SVG used as a custom marker instead of the
// default pin. When set, Color is ignored.
// Do not pass untrusted user input without sanitizing it first.
Element string
// Anchor controls which part of the element sits at the coordinate.
// Values: "center" (default for custom elements), "bottom" (default
// for the pin), "top", "left", "right", "top-left", etc.
Anchor string
// Rotation is clockwise degrees. Useful for directional icons (ships, vehicles).
Rotation float64
// Signal-backed position. When set, signals drive marker position reactively. // 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. // 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. // If Draggable is true, drag updates write back to these signals.

View File

@@ -80,4 +80,3 @@ func (s *Signal) Int() int {
} }
return 0 return 0
} }

View File

@@ -1,7 +1,6 @@
package via package via
import ( import (
// "net/http/httptest"
"testing" "testing"
"github.com/ryanhamamura/via/h" "github.com/ryanhamamura/via/h"

158
via.go
View File

@@ -58,7 +58,6 @@ type V struct {
datastarPath string datastarPath string
datastarContent []byte datastarContent []byte
datastarOnce sync.Once datastarOnce sync.Once
reaperStop chan struct{}
middleware []Middleware middleware []Middleware
layout func(func() h.H) h.H layout func(func() h.H) h.H
} }
@@ -139,12 +138,6 @@ func (v *V) Config(cfg Options) {
v.defaultNATS = nil v.defaultNATS = nil
v.pubsub = cfg.PubSub v.pubsub = cfg.PubSub
} }
if cfg.ContextSuspendAfter != 0 {
v.cfg.ContextSuspendAfter = cfg.ContextSuspendAfter
}
if cfg.ContextTTL != 0 {
v.cfg.ContextTTL = cfg.ContextTTL
}
if cfg.Streams != nil { if cfg.Streams != nil {
v.cfg.Streams = cfg.Streams v.cfg.Streams = cfg.Streams
} }
@@ -292,75 +285,6 @@ func (v *V) getCtx(id string) (*Context, error) {
return nil, fmt.Errorf("ctx '%s' not found", id) return nil, fmt.Errorf("ctx '%s' not found", id)
} }
func (v *V) startReaper() {
ttl := v.cfg.ContextTTL
if ttl < 0 {
return
}
if ttl == 0 {
ttl = time.Hour
}
suspendAfter := v.cfg.ContextSuspendAfter
if suspendAfter == 0 {
suspendAfter = 15 * time.Minute
}
if suspendAfter > ttl {
suspendAfter = ttl
}
interval := suspendAfter / 3
if interval < 5*time.Second {
interval = 5 * time.Second
}
v.reaperStop = make(chan struct{})
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-v.reaperStop:
return
case <-ticker.C:
v.reapOrphanedContexts(suspendAfter, ttl)
}
}
}()
}
func (v *V) reapOrphanedContexts(suspendAfter, ttl time.Duration) {
now := time.Now()
v.contextRegistryMutex.RLock()
var toSuspend, toReap []*Context
for _, c := range v.contextRegistry {
if c.sseConnected.Load() {
continue
}
// Use the most recent liveness signal
lastAlive := c.createdAt
if dc := c.sseDisconnectedAt.Load(); dc != nil && dc.After(lastAlive) {
lastAlive = *dc
}
if seen := c.lastSeenAt.Load(); seen != nil && seen.After(lastAlive) {
lastAlive = *seen
}
silentFor := now.Sub(lastAlive)
if silentFor > ttl {
toReap = append(toReap, c)
} else if silentFor > suspendAfter && !c.suspended.Load() {
toSuspend = append(toSuspend, c)
}
}
v.contextRegistryMutex.RUnlock()
for _, c := range toSuspend {
v.logInfo(c, "suspending context (no SSE connection after %s)", suspendAfter)
c.suspend()
}
for _, c := range toReap {
v.logInfo(c, "reaping orphaned context (no SSE connection after %s)", ttl)
v.cleanupCtx(c)
}
}
// Start starts the Via HTTP server and blocks until a SIGINT or SIGTERM // Start starts the Via HTTP server and blocks until a SIGINT or SIGTERM
// signal is received, then performs a graceful shutdown. // signal is received, then performs a graceful shutdown.
func (v *V) Start() { func (v *V) Start() {
@@ -389,8 +313,6 @@ func (v *V) Start() {
Handler: handler, Handler: handler,
} }
v.startReaper()
errCh := make(chan error, 1) errCh := make(chan error, 1)
go func() { go func() {
errCh <- v.server.ListenAndServe() errCh <- v.server.ListenAndServe()
@@ -417,9 +339,6 @@ func (v *V) Start() {
// Shutdown gracefully shuts down the server and all contexts. // Shutdown gracefully shuts down the server and all contexts.
// Safe for programmatic or test use. // Safe for programmatic or test use.
func (v *V) Shutdown() { func (v *V) Shutdown() {
if v.reaperStop != nil {
close(v.reaperStop)
}
v.logInfo(nil, "draining all contexts") v.logInfo(nil, "draining all contexts")
v.drainAllContexts() v.drainAllContexts()
@@ -520,36 +439,39 @@ func (v *V) ensureDatastarHandler() {
}) })
} }
func loadDevModeMap(path string) map[string]string {
m := make(map[string]string)
file, err := os.Open(path)
if err != nil {
return m
}
defer file.Close()
json.NewDecoder(file).Decode(&m)
return m
}
func saveDevModeMap(path string, m map[string]string) error {
file, err := os.Create(path)
if err != nil {
return err
}
defer file.Close()
return json.NewEncoder(file).Encode(m)
}
func (v *V) devModePersist(c *Context) { func (v *V) devModePersist(c *Context) {
p := filepath.Join(".via", "devmode", "ctx.json") p := filepath.Join(".via", "devmode", "ctx.json")
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
v.logFatal("failed to create directory for devmode files: %v", err) v.logFatal("failed to create directory for devmode files: %v", err)
} }
// load persisted list from file, or empty list if file not found ctxRegMap := loadDevModeMap(p)
file, err := os.Open(p)
ctxRegMap := make(map[string]string)
if err == nil {
json.NewDecoder(file).Decode(&ctxRegMap)
}
file.Close()
// add ctx to persisted list
if _, ok := ctxRegMap[c.id]; !ok { if _, ok := ctxRegMap[c.id]; !ok {
ctxRegMap[c.id] = c.route ctxRegMap[c.id] = c.route
} }
// write persisted list to file if err := saveDevModeMap(p, ctxRegMap); err != nil {
file, err = os.Create(p) v.logErr(c, "devmode failed to persist ctx: %v", err)
if err != nil {
v.logErr(c, "devmode failed to percist ctx: %v", err)
}
defer file.Close()
encoder := json.NewEncoder(file)
if err := encoder.Encode(ctxRegMap); err != nil {
v.logErr(c, "devmode failed to persist ctx")
} }
v.logDebug(c, "devmode persisted ctx to file") v.logDebug(c, "devmode persisted ctx to file")
} }
@@ -557,27 +479,11 @@ func (v *V) devModePersist(c *Context) {
func (v *V) devModeRemovePersisted(c *Context) { func (v *V) devModeRemovePersisted(c *Context) {
p := filepath.Join(".via", "devmode", "ctx.json") p := filepath.Join(".via", "devmode", "ctx.json")
// load persisted list from file, or empty list if file not found ctxRegMap := loadDevModeMap(p)
file, err := os.Open(p)
ctxRegMap := make(map[string]string)
if err == nil {
json.NewDecoder(file).Decode(&ctxRegMap)
}
file.Close()
delete(ctxRegMap, c.id) delete(ctxRegMap, c.id)
// write persisted list to file if err := saveDevModeMap(p, ctxRegMap); err != nil {
file, err = os.Create(p) v.logErr(c, "devmode failed to remove persisted ctx: %v", err)
if err != nil {
v.logErr(c, "devmode failed to remove percisted ctx: %v", err)
}
defer file.Close()
encoder := json.NewEncoder(file)
if err := encoder.Encode(ctxRegMap); err != nil {
v.logErr(c, "devmode failed to remove persisted ctx")
} }
v.logDebug(c, "devmode removed persisted ctx from file") v.logDebug(c, "devmode removed persisted ctx from file")
} }
@@ -667,8 +573,6 @@ func New() *V {
return return
} }
c.reqCtx = r.Context() c.reqCtx = r.Context()
now := time.Now()
c.lastSeenAt.Store(&now)
sse := datastar.NewSSE(w, r, datastar.WithCompression(datastar.WithBrotli(datastar.WithBrotliLevel(5)))) sse := datastar.NewSSE(w, r, datastar.WithCompression(datastar.WithBrotli(datastar.WithBrotliLevel(5))))
@@ -690,16 +594,6 @@ func New() *V {
c.sseDisconnectedAt.Store(nil) c.sseDisconnectedAt.Store(nil)
v.logDebug(c, "SSE connection established") v.logDebug(c, "SSE connection established")
if c.suspended.Load() {
c.navMu.Lock()
c.suspended.Store(false)
if initFn := v.pageRegistry[c.route]; initFn != nil {
v.logInfo(c, "resuming suspended context")
initFn(c)
}
c.navMu.Unlock()
}
go c.Sync() go c.Sync()
keepalive := time.NewTicker(30 * time.Second) keepalive := time.NewTicker(30 * time.Second)

View File

@@ -400,95 +400,6 @@ func TestPage_PanicsOnNoView(t *testing.T) {
}) })
} }
func TestReaperCleansOrphanedContexts(t *testing.T) {
v := New()
c := newContext("orphan-1", "/", v)
c.createdAt = time.Now().Add(-time.Minute) // created 1 min ago
v.registerCtx(c)
_, err := v.getCtx("orphan-1")
assert.NoError(t, err)
v.reapOrphanedContexts(5*time.Second, 10*time.Second)
_, err = v.getCtx("orphan-1")
assert.Error(t, err, "orphaned context should have been reaped")
}
func TestReaperSuspendsContext(t *testing.T) {
v := New()
c := newContext("suspend-1", "/", v)
c.createdAt = time.Now().Add(-30 * time.Minute)
dc := time.Now().Add(-20 * time.Minute)
c.sseDisconnectedAt.Store(&dc)
v.registerCtx(c)
v.reapOrphanedContexts(15*time.Minute, time.Hour)
got, err := v.getCtx("suspend-1")
assert.NoError(t, err, "suspended context should still be in registry")
assert.True(t, got.suspended.Load(), "context should be marked suspended")
}
func TestReaperReapsAfterTTL(t *testing.T) {
v := New()
c := newContext("reap-1", "/", v)
c.createdAt = time.Now().Add(-3 * time.Hour)
dc := time.Now().Add(-2 * time.Hour)
c.sseDisconnectedAt.Store(&dc)
c.suspended.Store(true)
v.registerCtx(c)
v.reapOrphanedContexts(15*time.Minute, time.Hour)
_, err := v.getCtx("reap-1")
assert.Error(t, err, "context past TTL should have been reaped")
}
func TestReaperIgnoresAlreadySuspended(t *testing.T) {
v := New()
c := newContext("already-sus-1", "/", v)
c.createdAt = time.Now().Add(-30 * time.Minute)
dc := time.Now().Add(-20 * time.Minute)
c.sseDisconnectedAt.Store(&dc)
c.suspended.Store(true)
// give it a fresh pageStopChan so we can verify it's not re-closed
c.pageStopChan = make(chan struct{})
v.registerCtx(c)
v.reapOrphanedContexts(15*time.Minute, time.Hour)
got, err := v.getCtx("already-sus-1")
assert.NoError(t, err, "already-suspended context within TTL should survive")
assert.True(t, got.suspended.Load())
// pageStopChan should still be open (not re-suspended)
select {
case <-got.pageStopChan:
t.Fatal("pageStopChan was closed — context was re-suspended")
default:
}
}
func TestReaperIgnoresConnectedContexts(t *testing.T) {
v := New()
c := newContext("connected-1", "/", v)
c.createdAt = time.Now().Add(-time.Minute)
c.sseConnected.Store(true)
v.registerCtx(c)
v.reapOrphanedContexts(5*time.Second, 10*time.Second)
_, err := v.getCtx("connected-1")
assert.NoError(t, err, "connected context should survive reaping")
}
func TestReaperDisabledWithNegativeTTL(t *testing.T) {
v := New()
v.cfg.ContextTTL = -1
v.startReaper()
assert.Nil(t, v.reaperStop, "reaper should not start with negative TTL")
}
func TestCleanupCtxIdempotent(t *testing.T) { func TestCleanupCtxIdempotent(t *testing.T) {
v := New() v := New()
c := newContext("idempotent-1", "/", v) c := newContext("idempotent-1", "/", v)
@@ -503,74 +414,6 @@ func TestCleanupCtxIdempotent(t *testing.T) {
assert.Error(t, err, "context should be removed after cleanup") assert.Error(t, err, "context should be removed after cleanup")
} }
func TestReaperRespectsLastSeenAt(t *testing.T) {
v := New()
c := newContext("seen-1", "/", v)
c.createdAt = time.Now().Add(-30 * time.Minute)
// Disconnected 20 min ago, but client retried (lastSeenAt) 2 min ago
dc := time.Now().Add(-20 * time.Minute)
c.sseDisconnectedAt.Store(&dc)
seen := time.Now().Add(-2 * time.Minute)
c.lastSeenAt.Store(&seen)
v.registerCtx(c)
v.reapOrphanedContexts(15*time.Minute, time.Hour)
_, err := v.getCtx("seen-1")
assert.NoError(t, err, "context with recent lastSeenAt should survive suspend threshold")
assert.False(t, c.suspended.Load(), "context should not be suspended")
}
func TestReaperFallsBackWithoutLastSeenAt(t *testing.T) {
v := New()
c := newContext("noseen-1", "/", v)
c.createdAt = time.Now().Add(-30 * time.Minute)
dc := time.Now().Add(-20 * time.Minute)
c.sseDisconnectedAt.Store(&dc)
// no lastSeenAt set — should fall back to sseDisconnectedAt
v.registerCtx(c)
v.reapOrphanedContexts(15*time.Minute, time.Hour)
got, err := v.getCtx("noseen-1")
assert.NoError(t, err, "context should still be in registry (suspended, not reaped)")
assert.True(t, got.suspended.Load(), "context should be suspended using sseDisconnectedAt fallback")
}
func TestReaperReapsWithStaleLastSeenAt(t *testing.T) {
v := New()
c := newContext("stale-seen-1", "/", v)
c.createdAt = time.Now().Add(-3 * time.Hour)
dc := time.Now().Add(-2 * time.Hour)
c.sseDisconnectedAt.Store(&dc)
// lastSeenAt is also old — beyond TTL
seen := time.Now().Add(-90 * time.Minute)
c.lastSeenAt.Store(&seen)
c.suspended.Store(true)
v.registerCtx(c)
v.reapOrphanedContexts(15*time.Minute, time.Hour)
_, err := v.getCtx("stale-seen-1")
assert.Error(t, err, "context with stale lastSeenAt beyond TTL should be reaped")
}
func TestLastSeenAtUpdatedOnSSEConnect(t *testing.T) {
v := New()
c := newContext("seen-sse-1", "/", v)
v.registerCtx(c)
assert.Nil(t, c.lastSeenAt.Load(), "lastSeenAt should be nil before SSE connect")
// Simulate what the SSE handler does after getCtx
now := time.Now()
c.lastSeenAt.Store(&now)
got := c.lastSeenAt.Load()
assert.NotNil(t, got, "lastSeenAt should be set after SSE connect")
assert.WithinDuration(t, now, *got, time.Second)
}
func TestDevModeRemovePersistedFix(t *testing.T) { func TestDevModeRemovePersistedFix(t *testing.T) {
v := New() v := New()
v.cfg.DevMode = true v.cfg.DevMode = true