5 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
20 changed files with 763 additions and 451 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.
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: `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>`.
10. Prune refs: `git remote prune gitea && git remote prune origin`.
11. Report the merged PR URL.

View File

@@ -97,7 +97,7 @@ func buildAttrKey(event string, opts *triggerOpts) string {
}
// 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),
@@ -105,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),

View File

@@ -26,7 +26,7 @@ func TestComputedBasic(t *testing.T) {
func TestComputedReactivity(t *testing.T) {
v := New()
var cs *computedSignal
var sig1 *signal
var sig1 *Signal
v.Page("/", func(c *Context) {
sig1 = c.Signal("a")
sig2 := c.Signal("b")
@@ -83,7 +83,7 @@ func TestComputedText(t *testing.T) {
func TestComputedChangeDetection(t *testing.T) {
v := New()
var ctx *Context
var sig *signal
var sig *Signal
v.Page("/", func(c *Context) {
ctx = c
sig = c.Signal("a")

View File

@@ -1,8 +1,6 @@
package via
import (
"time"
"github.com/alexedwards/scs/v2"
"github.com/rs/zerolog"
)
@@ -62,16 +60,6 @@ type Options struct {
// the embedded NATS server. Ignored when a custom PubSub is configured.
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
// action endpoints. Zero values use built-in defaults (10 req/s, burst 20).
// Set Rate to -1 to disable rate limiting entirely.

View File

@@ -25,7 +25,7 @@ type Context struct {
app *V
view func() h.H
routeParams map[string]string
parentPageCtx *Context
parentPageCtx *Context
patchChan chan patch
actionLimiter *rate.Limiter
actionRegistry map[string]actionEntry
@@ -39,11 +39,9 @@ type Context struct {
subscriptions []Subscription
subsMu sync.Mutex
disposeOnce sync.Once
createdAt time.Time
sseConnected atomic.Bool
sseDisconnectedAt atomic.Pointer[time.Time]
lastSeenAt atomic.Pointer[time.Time]
suspended atomic.Bool
createdAt time.Time
sseConnected atomic.Bool
sseDisconnectedAt atomic.Pointer[time.Time]
}
// View defines the UI rendered by this context.
@@ -175,11 +173,11 @@ func (c *Context) OnInterval(duration time.Duration, handler func()) func() {
// 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),
@@ -191,7 +189,7 @@ func (c *Context) Signal(v any) *signal {
v = string(j)
}
}
sig := &signal{
sig := &Signal{
id: sigID,
val: v,
changed: true,
@@ -254,13 +252,13 @@ func (c *Context) injectSignals(sigs map[string]any) {
for sigID, val := range sigs {
item, ok := c.signals.Load(sigID)
if !ok {
c.signals.Store(sigID, &signal{
c.signals.Store(sigID, &Signal{
id: sigID,
val: val,
})
continue
}
if sig, ok := item.(*signal); ok {
if sig, ok := item.(*Signal); ok {
sig.val = val
sig.changed = false
}
@@ -284,13 +282,14 @@ func (c *Context) prepareSignalsForPatch() map[string]any {
updatedSigs := make(map[string]any)
c.signals.Range(func(sigID, value any) bool {
switch sig := value.(type) {
case *signal:
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()
@@ -443,13 +442,6 @@ func (c *Context) resetPageState() {
c.mu.Unlock()
}
// suspend frees page-scoped resources while keeping the context shell alive
// in the registry for seamless re-init on reconnect.
func (c *Context) suspend() {
c.resetPageState()
c.suspended.Store(true)
}
// Navigate performs an SPA navigation to the given path. It resets page state,
// runs the target page's init function (with middleware), and pushes the new
// view over the existing SSE connection with a view transition animation.
@@ -594,7 +586,7 @@ func (c *Context) unsubscribeAll() {
// can operate on all fields by default.
func (c *Context) Field(initial any, rules ...Rule) *Field {
f := &Field{
signal: c.Signal(initial),
Signal: c.Signal(initial),
rules: rules,
initialVal: initial,
}
@@ -638,17 +630,17 @@ func newContext(id string, route string, v *V) *Context {
}
return &Context{
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(),
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(),
}
}

View File

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

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
```
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.

View File

@@ -1,10 +1,10 @@
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)
// It embeds *Signal, so all signal methods (Bind, String, Int, Bool, SetValue, Text, ID)
// work transparently.
type Field struct {
*signal
*Signal
rules []Rule
errors []string
initialVal any

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

@@ -1,7 +1,8 @@
package main
import (
"fmt"
"math/rand"
"time"
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
@@ -25,7 +26,10 @@ func main() {
Height: "500px",
})
// Markers with popups
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",
@@ -41,6 +45,43 @@ func main() {
},
})
// 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{
@@ -70,24 +111,20 @@ func main() {
},
})
// Viewport info signal (updated on action)
viewportInfo := c.Signal("")
// FlyTo action
// FlyTo actions using CameraOptions
zoom14 := 14.0
flyToSF := c.Action(func() {
m.FlyTo(maplibre.LngLat{Lng: -122.4194, Lat: 37.7749}, 14)
m.FlyTo(maplibre.CameraOptions{
Center: &maplibre.LngLat{Lng: -122.4194, Lat: 37.7749},
Zoom: &zoom14,
})
})
flyToOak := c.Action(func() {
m.FlyTo(maplibre.LngLat{Lng: -122.2711, Lat: 37.8044}, 14)
})
// Read viewport action
readViewport := c.Action(func() {
center := m.Center()
zoom := m.Zoom()
viewportInfo.SetValue(fmt.Sprintf("Center: %.4f, %.4f | Zoom: %.1f", center.Lng, center.Lat, zoom))
c.Sync()
m.FlyTo(maplibre.CameraOptions{
Center: &maplibre.LngLat{Lng: -122.2711, Lat: 37.8044},
Zoom: &zoom14,
})
})
c.View(func() h.H {
@@ -95,13 +132,19 @@ func main() {
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(),
h.Div(h.Attr("style", "margin-top:1rem;display:flex;gap:0.5rem"),
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.Button(h.Text("Read Viewport"), readViewport.OnClick()),
),
h.P(viewportInfo.Text()),
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()),
),
),
)
})

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

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

@@ -36,8 +36,9 @@ func initScript(m *Map) string {
jsonStr(m.id),
))
// Build constructor options object
b.WriteString(fmt.Sprintf(
`var map=new maplibregl.Map({container:%s,style:%s,center:[%s,%s],zoom:%s`,
`var opts={container:%s,style:%s,center:[%s,%s],zoom:%s`,
jsonStr("_vmap_"+m.id),
jsonStr(m.opts.Style),
formatFloat(m.opts.Center.Lng),
@@ -56,14 +57,49 @@ func initScript(m *Map) string {
if m.opts.MaxZoom != 0 {
b.WriteString(fmt.Sprintf(`,maxZoom:%s`, formatFloat(m.opts.MaxZoom)))
}
b.WriteString(`});`)
// 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={};`)
b.WriteString(`map._via_markers={};map._via_popups={};map._via_controls={};`)
// Pre-render sources, layers, markers, popups run on 'load'
if len(m.sources) > 0 || len(m.layers) > 0 || len(m.markers) > 0 || len(m.popups) > 0 {
// 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))
@@ -76,14 +112,22 @@ func initScript(m *Map) string {
}
}
for _, me := range m.markers {
b.WriteString(markerBodyJS(me.id, me.marker))
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();`+
@@ -96,15 +140,16 @@ func initScript(m *Map) string {
`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()),
jsonStr(m.CenterLng.ID()),
jsonStr(m.CenterLat.ID()),
jsonStr(m.Zoom.ID()),
jsonStr(m.Bearing.ID()),
jsonStr(m.Pitch.ID()),
))
// ResizeObserver for auto-resize
@@ -132,8 +177,7 @@ func initScript(m *Map) string {
}
// markerBodyJS generates JS to add a marker, assuming `map` is in scope.
// Used inside the init script's load callback.
func markerBodyJS(markerID string, mk Marker) string {
func markerBodyJS(mapID, markerID string, mk Marker) string {
var b strings.Builder
opts := "{"
if mk.Color != "" {
@@ -143,16 +187,64 @@ func markerBodyJS(markerID string, mk Marker) string {
opts += `draggable:true,`
}
opts += "}"
b.WriteString(fmt.Sprintf(`var mk=new maplibregl.Marker(%s).setLngLat([%s,%s]);`,
opts, formatFloat(mk.LngLat.Lng), formatFloat(mk.LngLat.Lat)))
// 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
@@ -163,7 +255,7 @@ func addMarkerJS(mapID, markerID string, mk Marker) string {
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(markerID, mk))
b.WriteString(markerBodyJS(mapID, markerID, mk))
b.WriteString(`})()`)
return b.String()
}
@@ -221,6 +313,106 @@ func popupConstructorJS(p Popup, varName string) string {
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)
}

View File

@@ -8,6 +8,7 @@ import (
"crypto/rand"
_ "embed"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strconv"
@@ -38,29 +39,25 @@ func Plugin(v *via.V) {
)
}
// viaSignal is the interface satisfied by via's *signal type.
type viaSignal interface {
ID() string
String() string
SetValue(any)
Bind() h.H
}
// 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
// Viewport signals for browser → server sync
centerLng, centerLat viaSignal
zoom, bearing, pitch viaSignal
// Pre-render accumulation
sources []sourceEntry
layers []Layer
markers []markerEntry
popups []popupEntry
sources []sourceEntry
layers []Layer
markers []markerEntry
popups []popupEntry
events []eventEntry
controls []controlEntry
rendered bool
}
@@ -81,11 +78,11 @@ func New(c *via.Context, opts Options) *Map {
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)
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
}
@@ -93,10 +90,14 @@ func New(c *via.Context, opts Options) *Map {
// 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.
func (m *Map) Element() h.H {
//
// 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
return h.Div(h.ID("_vwrap_"+m.id),
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),
@@ -104,14 +105,39 @@ func (m *Map) Element() h.H {
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()),
// Init script
h.Script(h.Raw(initScript(m))),
)
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) ---
@@ -119,32 +145,43 @@ func (m *Map) Element() h.H {
// 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()),
Lng: parseFloat(m.CenterLng.String()),
Lat: parseFloat(m.CenterLat.String()),
}
}
// Zoom returns the current map zoom level from the synced signal.
func (m *Map) Zoom() float64 {
return parseFloat(m.zoom.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)))
}
// Bearing returns the current map bearing from the synced signal.
func (m *Map) Bearing() float64 {
return parseFloat(m.bearing.String())
// EaseTo eases the map to the target camera state.
func (m *Map) EaseTo(opts CameraOptions) {
m.exec(fmt.Sprintf(`m.easeTo(%s);`, cameraOptionsJS(opts)))
}
// Pitch returns the current map pitch from the synced signal.
func (m *Map) Pitch() float64 {
return parseFloat(m.pitch.String())
// 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)))
}
// --- Viewport setters (Go → browser) ---
// 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))
}
}
// FlyTo animates the map to the given center and zoom.
func (m *Map) FlyTo(center LngLat, zoom float64) {
m.exec(fmt.Sprintf(`m.flyTo({center:[%s,%s],zoom:%s});`,
formatFloat(center.Lng), formatFloat(center.Lat), formatFloat(zoom)))
// Stop aborts any in-progress camera animation.
func (m *Map) Stop() {
m.exec(`m.stop();`)
}
// SetCenter sets the map center without animation.
@@ -175,10 +212,9 @@ func (m *Map) SetStyle(url string) {
// --- Source methods ---
// AddSource adds a source to the map. src should be a GeoJSONSource,
// VectorSource, RasterSource, or any JSON-marshalable value.
func (m *Map) AddSource(id string, src any) {
js := sourceJSON(src)
// 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
@@ -187,7 +223,6 @@ func (m *Map) AddSource(id string, src any) {
}
// RemoveSource removes a source from the map.
// Before render, it removes a previously accumulated source. After render, it issues an ExecScript.
func (m *Map) RemoveSource(id string) {
if !m.rendered {
for i, s := range m.sources {
@@ -222,7 +257,6 @@ func (m *Map) AddLayer(layer Layer) {
}
// RemoveLayer removes a layer from the map.
// Before render, it removes a previously accumulated layer. After render, it issues an ExecScript.
func (m *Map) RemoveLayer(id string) {
if !m.rendered {
for i, l := range m.layers {
@@ -261,7 +295,6 @@ func (m *Map) AddMarker(id string, marker Marker) {
}
// RemoveMarker removes a marker from the map.
// Before render, it removes a previously accumulated marker. After render, it issues an ExecScript.
func (m *Map) RemoveMarker(id string) {
if !m.rendered {
for i, me := range m.markers {
@@ -288,7 +321,6 @@ func (m *Map) ShowPopup(id string, popup Popup) {
}
// ClosePopup closes a standalone popup on the map.
// Before render, it removes a previously accumulated popup. After render, it issues an ExecScript.
func (m *Map) ClosePopup(id string) {
if !m.rendered {
for i, pe := range m.popups {
@@ -302,6 +334,64 @@ func (m *Map) ClosePopup(id string) {
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`.
@@ -314,6 +404,30 @@ 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

View File

@@ -1,6 +1,10 @@
package maplibre
import "encoding/json"
import (
"encoding/json"
"github.com/ryanhamamura/via"
)
// LngLat represents a geographic coordinate.
type LngLat struct {
@@ -8,6 +12,20 @@ type LngLat struct {
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).
@@ -23,6 +41,30 @@ type Options struct {
// 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.
@@ -31,7 +73,7 @@ type GeoJSONSource struct {
Data any
}
func (s GeoJSONSource) toJS() string {
func (s GeoJSONSource) sourceJS() string {
data, _ := json.Marshal(s.Data)
return `{"type":"geojson","data":` + string(data) + `}`
}
@@ -42,7 +84,7 @@ type VectorSource struct {
Tiles []string
}
func (s VectorSource) toJS() string {
func (s VectorSource) sourceJS() string {
obj := map[string]any{"type": "vector"}
if s.URL != "" {
obj["url"] = s.URL
@@ -61,7 +103,7 @@ type RasterSource struct {
TileSize int
}
func (s RasterSource) toJS() string {
func (s RasterSource) sourceJS() string {
obj := map[string]any{"type": "raster"}
if s.URL != "" {
obj["url"] = s.URL
@@ -76,21 +118,135 @@ func (s RasterSource) toJS() string {
return string(b)
}
// sourceJSON converts a source value to its JS object literal string.
func sourceJSON(src any) string {
switch s := src.(type) {
case GeoJSONSource:
return s.toJS()
case VectorSource:
return s.toJS()
case RasterSource:
return s.toJS()
default:
b, _ := json.Marshal(src)
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
@@ -137,12 +293,20 @@ func (l Layer) toJS() string {
return string(b)
}
// --- Marker ---
// Marker describes a map marker.
type Marker struct {
LngLat LngLat
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.
@@ -152,24 +316,44 @@ type Marker struct {
type Popup struct {
Content string // HTML content
LngLat LngLat
HideCloseButton bool // true removes the close button (MapLibre shows it by default)
HideCloseButton bool // true removes the close button (MapLibre shows it by default)
MaxWidth string
}
// sourceEntry pairs a source ID with its JS representation for pre-render accumulation.
// --- 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
}
// markerEntry pairs a marker ID with its definition for pre-render accumulation.
type markerEntry struct {
id string
marker Marker
}
// popupEntry pairs a popup ID with its definition for pre-render accumulation.
type popupEntry struct {
id string
popup Popup
}
type eventEntry struct {
event string
layerID string
signal *via.Signal
}
type controlEntry struct {
id string
ctrl Control
}

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,36 +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
}

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

View File

@@ -81,8 +81,8 @@ func TestStaticAutoSlash(t *testing.T) {
func TestStaticFS(t *testing.T) {
fsys := fstest.MapFS{
"style.css": {Data: []byte("body{}")},
"js/app.js": {Data: []byte("console.log('hi')")},
"style.css": {Data: []byte("body{}")},
"js/app.js": {Data: []byte("console.log('hi')")},
}
v := New()

101
via.go
View File

@@ -58,7 +58,6 @@ type V struct {
datastarPath string
datastarContent []byte
datastarOnce sync.Once
reaperStop chan struct{}
middleware []Middleware
layout func(func() h.H) h.H
}
@@ -139,12 +138,6 @@ func (v *V) Config(cfg Options) {
v.defaultNATS = nil
v.pubsub = cfg.PubSub
}
if cfg.ContextSuspendAfter != 0 {
v.cfg.ContextSuspendAfter = cfg.ContextSuspendAfter
}
if cfg.ContextTTL != 0 {
v.cfg.ContextTTL = cfg.ContextTTL
}
if cfg.Streams != nil {
v.cfg.Streams = cfg.Streams
}
@@ -247,10 +240,10 @@ func (v *V) page(route string, raw, wrapped func(*Context)) {
bodyElements = append(bodyElements, h.Raw("<dataspa-inspector/>"))
}
view := h.HTML5(h.HTML5Props{
Title: v.cfg.DocumentTitle,
Head: headElements,
Body: bodyElements,
})
Title: v.cfg.DocumentTitle,
Head: headElements,
Body: bodyElements,
})
_ = view.Render(w)
}))
}
@@ -292,75 +285,6 @@ func (v *V) getCtx(id string) (*Context, error) {
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
// signal is received, then performs a graceful shutdown.
func (v *V) Start() {
@@ -389,8 +313,6 @@ func (v *V) Start() {
Handler: handler,
}
v.startReaper()
errCh := make(chan error, 1)
go func() {
errCh <- v.server.ListenAndServe()
@@ -417,9 +339,6 @@ func (v *V) Start() {
// Shutdown gracefully shuts down the server and all contexts.
// Safe for programmatic or test use.
func (v *V) Shutdown() {
if v.reaperStop != nil {
close(v.reaperStop)
}
v.logInfo(nil, "draining all contexts")
v.drainAllContexts()
@@ -667,8 +586,6 @@ func New() *V {
return
}
c.reqCtx = r.Context()
now := time.Now()
c.lastSeenAt.Store(&now)
sse := datastar.NewSSE(w, r, datastar.WithCompression(datastar.WithBrotli(datastar.WithBrotliLevel(5))))
@@ -690,16 +607,6 @@ func New() *V {
c.sseDisconnectedAt.Store(nil)
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()
keepalive := time.NewTicker(30 * time.Second)

View File

@@ -90,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")
@@ -106,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() {})
@@ -134,9 +134,9 @@ func TestAction(t *testing.T) {
func TestEventTypes(t *testing.T) {
tests := []struct {
name string
attr string
buildEl func(trigger *actionTrigger) h.H
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()) }},
@@ -167,7 +167,7 @@ func TestEventTypes(t *testing.T) {
t.Run("WithSignal", func(t *testing.T) {
var trigger *actionTrigger
var sig *signal
var sig *Signal
v := New()
v.Page("/", func(c *Context) {
trigger = c.Action(func() {})
@@ -207,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")
@@ -251,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")
@@ -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) {
v := New()
c := newContext("idempotent-1", "/", v)
@@ -503,74 +414,6 @@ func TestCleanupCtxIdempotent(t *testing.T) {
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) {
v := New()
v.cfg.DevMode = true