Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
742212fd20 | ||
|
|
60009124c9 | ||
|
|
42b21348cb | ||
|
|
58ad9a2699 | ||
|
|
f3a9c8036f | ||
|
|
6763e1a420 | ||
|
|
5d61149fa3 | ||
|
|
08b7dbd17f | ||
|
|
cd2bfb6978 | ||
|
|
539a2ad504 | ||
|
|
11c6354da0 | ||
|
|
719b389be6 | ||
|
|
1384e49e14 | ||
|
|
785f11e52d | ||
|
|
2f19874c17 | ||
|
|
27b8540b71 | ||
|
|
532651552a | ||
|
|
2310e45d35 |
14
.claude/commands/release.md
Normal file
14
.claude/commands/release.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
Create a new release for this project. Steps:
|
||||||
|
|
||||||
|
1. Fetch tags from all remotes so the version list is current.
|
||||||
|
2. Check for uncommitted changes. If any exist, commit them with a clean semantic commit message. No Claude attribution lines.
|
||||||
|
3. Review the commits since the last tag. Based on their content, recommend a semver bump:
|
||||||
|
- **major**: breaking/incompatible API changes
|
||||||
|
- **minor**: new features, meaningful new behavior
|
||||||
|
- **patch**: bug fixes, docs, refactoring with no new features
|
||||||
|
Present the proposed version, the bump rationale, and the commit list. Wait for user approval before continuing.
|
||||||
|
4. Tag the new version and push the tag + commits to all remotes (origin, gitea, etc.).
|
||||||
|
5. Generate release notes from the commits since the last tag, grouped by type (features, fixes, docs/refactoring).
|
||||||
|
6. Create a GitHub release using `gh release create`.
|
||||||
|
7. Create a Gitea release using `tea releases create` with the same notes.
|
||||||
|
8. Report both release URLs and confirm all remotes are up to date.
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -48,6 +48,7 @@ internal/examples/plugins/plugins
|
|||||||
internal/examples/realtimechart/realtimechart
|
internal/examples/realtimechart/realtimechart
|
||||||
internal/examples/shakespeare/shakespeare
|
internal/examples/shakespeare/shakespeare
|
||||||
internal/examples/nats-chatroom/nats-chatroom
|
internal/examples/nats-chatroom/nats-chatroom
|
||||||
|
/nats-chatroom
|
||||||
|
|
||||||
# NATS data directory
|
# NATS data directory
|
||||||
data/
|
data/
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ func main() {
|
|||||||
- **CSRF protection** — automatic token generation and validation on every action
|
- **CSRF protection** — automatic token generation and validation on every action
|
||||||
- **Rate limiting** — token-bucket algorithm, configurable globally and per-action
|
- **Rate limiting** — token-bucket algorithm, configurable globally and per-action
|
||||||
- **Event handling** — `OnClick`, `OnChange`, `OnSubmit`, `OnInput`, `OnFocus`, `OnBlur`, `OnMouseEnter`, `OnMouseLeave`, `OnScroll`, `OnDblClick`, `OnKeyDown`, and `OnKeyDownMap` for multi-key bindings
|
- **Event handling** — `OnClick`, `OnChange`, `OnSubmit`, `OnInput`, `OnFocus`, `OnBlur`, `OnMouseEnter`, `OnMouseLeave`, `OnScroll`, `OnDblClick`, `OnKeyDown`, and `OnKeyDownMap` for multi-key bindings
|
||||||
- **Timed routines** — `OnInterval` with start/stop/update controls, tied to context lifecycle
|
- **Timed routines** — `OnInterval` auto-starts a ticker goroutine, returns a stop function, tied to context lifecycle
|
||||||
- **Redirects** — `Redirect`, `ReplaceURL`, and format-string variants
|
- **Redirects** — `Redirect`, `ReplaceURL`, and format-string variants
|
||||||
- **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
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package via
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/ryanhamamura/via/h"
|
"github.com/ryanhamamura/via/h"
|
||||||
)
|
)
|
||||||
@@ -23,6 +24,8 @@ type triggerOpts struct {
|
|||||||
value string
|
value string
|
||||||
window bool
|
window bool
|
||||||
preventDefault bool
|
preventDefault bool
|
||||||
|
debounce time.Duration
|
||||||
|
throttle time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
type withSignalOpt struct {
|
type withSignalOpt struct {
|
||||||
@@ -58,6 +61,41 @@ func WithPreventDefault() ActionTriggerOption {
|
|||||||
return withPreventDefaultOpt{}
|
return withPreventDefaultOpt{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type withDebounceOpt struct{ d time.Duration }
|
||||||
|
|
||||||
|
func (o withDebounceOpt) apply(opts *triggerOpts) { opts.debounce = o.d }
|
||||||
|
|
||||||
|
// WithDebounce adds a debounce modifier to the event trigger.
|
||||||
|
func WithDebounce(d time.Duration) ActionTriggerOption { return withDebounceOpt{d} }
|
||||||
|
|
||||||
|
type withThrottleOpt struct{ d time.Duration }
|
||||||
|
|
||||||
|
func (o withThrottleOpt) apply(opts *triggerOpts) { opts.throttle = o.d }
|
||||||
|
|
||||||
|
// WithThrottle adds a throttle modifier to the event trigger.
|
||||||
|
func WithThrottle(d time.Duration) ActionTriggerOption { return withThrottleOpt{d} }
|
||||||
|
|
||||||
|
// formatDuration renders a duration as e.g. "200ms" for Datastar modifiers.
|
||||||
|
func formatDuration(d time.Duration) string {
|
||||||
|
return fmt.Sprintf("%dms", d.Milliseconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildAttrKey constructs a Datastar attribute key with modifiers.
|
||||||
|
// Order: event → debounce/throttle → window.
|
||||||
|
func buildAttrKey(event string, opts *triggerOpts) string {
|
||||||
|
key := "on:" + event
|
||||||
|
if opts.debounce > 0 {
|
||||||
|
key += "__debounce." + formatDuration(opts.debounce)
|
||||||
|
}
|
||||||
|
if opts.throttle > 0 {
|
||||||
|
key += "__throttle." + formatDuration(opts.throttle)
|
||||||
|
}
|
||||||
|
if opts.window {
|
||||||
|
key += "__window"
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
// WithSignal sets a signal value before triggering the action.
|
// 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{
|
return withSignalOpt{
|
||||||
@@ -97,62 +135,62 @@ func actionURL(id string) string {
|
|||||||
// to element nodes in a view.
|
// to element nodes in a view.
|
||||||
func (a *actionTrigger) OnClick(options ...ActionTriggerOption) h.H {
|
func (a *actionTrigger) OnClick(options ...ActionTriggerOption) h.H {
|
||||||
opts := applyOptions(options...)
|
opts := applyOptions(options...)
|
||||||
return h.Data("on:click", buildOnExpr(actionURL(a.id), &opts))
|
return h.Data(buildAttrKey("click", &opts), buildOnExpr(actionURL(a.id), &opts))
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnChange returns a via.h DOM attribute that triggers on input change. It can be added
|
// OnChange returns a via.h DOM attribute that triggers on input change. It can be added
|
||||||
// to element nodes in a view.
|
// to element nodes in a view.
|
||||||
func (a *actionTrigger) OnChange(options ...ActionTriggerOption) h.H {
|
func (a *actionTrigger) OnChange(options ...ActionTriggerOption) h.H {
|
||||||
opts := applyOptions(options...)
|
opts := applyOptions(options...)
|
||||||
return h.Data("on:change__debounce.200ms", buildOnExpr(actionURL(a.id), &opts))
|
return h.Data(buildAttrKey("change", &opts), buildOnExpr(actionURL(a.id), &opts))
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnSubmit returns a via.h DOM attribute that triggers on form submit.
|
// OnSubmit returns a via.h DOM attribute that triggers on form submit.
|
||||||
func (a *actionTrigger) OnSubmit(options ...ActionTriggerOption) h.H {
|
func (a *actionTrigger) OnSubmit(options ...ActionTriggerOption) h.H {
|
||||||
opts := applyOptions(options...)
|
opts := applyOptions(options...)
|
||||||
return h.Data("on:submit", buildOnExpr(actionURL(a.id), &opts))
|
return h.Data(buildAttrKey("submit", &opts), buildOnExpr(actionURL(a.id), &opts))
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnInput returns a via.h DOM attribute that triggers on input (without debounce).
|
// OnInput returns a via.h DOM attribute that triggers on input (without debounce).
|
||||||
func (a *actionTrigger) OnInput(options ...ActionTriggerOption) h.H {
|
func (a *actionTrigger) OnInput(options ...ActionTriggerOption) h.H {
|
||||||
opts := applyOptions(options...)
|
opts := applyOptions(options...)
|
||||||
return h.Data("on:input", buildOnExpr(actionURL(a.id), &opts))
|
return h.Data(buildAttrKey("input", &opts), buildOnExpr(actionURL(a.id), &opts))
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnFocus returns a via.h DOM attribute that triggers when the element gains focus.
|
// OnFocus returns a via.h DOM attribute that triggers when the element gains focus.
|
||||||
func (a *actionTrigger) OnFocus(options ...ActionTriggerOption) h.H {
|
func (a *actionTrigger) OnFocus(options ...ActionTriggerOption) h.H {
|
||||||
opts := applyOptions(options...)
|
opts := applyOptions(options...)
|
||||||
return h.Data("on:focus", buildOnExpr(actionURL(a.id), &opts))
|
return h.Data(buildAttrKey("focus", &opts), buildOnExpr(actionURL(a.id), &opts))
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnBlur returns a via.h DOM attribute that triggers when the element loses focus.
|
// OnBlur returns a via.h DOM attribute that triggers when the element loses focus.
|
||||||
func (a *actionTrigger) OnBlur(options ...ActionTriggerOption) h.H {
|
func (a *actionTrigger) OnBlur(options ...ActionTriggerOption) h.H {
|
||||||
opts := applyOptions(options...)
|
opts := applyOptions(options...)
|
||||||
return h.Data("on:blur", buildOnExpr(actionURL(a.id), &opts))
|
return h.Data(buildAttrKey("blur", &opts), buildOnExpr(actionURL(a.id), &opts))
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnMouseEnter returns a via.h DOM attribute that triggers when the mouse enters the element.
|
// OnMouseEnter returns a via.h DOM attribute that triggers when the mouse enters the element.
|
||||||
func (a *actionTrigger) OnMouseEnter(options ...ActionTriggerOption) h.H {
|
func (a *actionTrigger) OnMouseEnter(options ...ActionTriggerOption) h.H {
|
||||||
opts := applyOptions(options...)
|
opts := applyOptions(options...)
|
||||||
return h.Data("on:mouseenter", buildOnExpr(actionURL(a.id), &opts))
|
return h.Data(buildAttrKey("mouseenter", &opts), buildOnExpr(actionURL(a.id), &opts))
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnMouseLeave returns a via.h DOM attribute that triggers when the mouse leaves the element.
|
// OnMouseLeave returns a via.h DOM attribute that triggers when the mouse leaves the element.
|
||||||
func (a *actionTrigger) OnMouseLeave(options ...ActionTriggerOption) h.H {
|
func (a *actionTrigger) OnMouseLeave(options ...ActionTriggerOption) h.H {
|
||||||
opts := applyOptions(options...)
|
opts := applyOptions(options...)
|
||||||
return h.Data("on:mouseleave", buildOnExpr(actionURL(a.id), &opts))
|
return h.Data(buildAttrKey("mouseleave", &opts), buildOnExpr(actionURL(a.id), &opts))
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnScroll returns a via.h DOM attribute that triggers on scroll.
|
// OnScroll returns a via.h DOM attribute that triggers on scroll.
|
||||||
func (a *actionTrigger) OnScroll(options ...ActionTriggerOption) h.H {
|
func (a *actionTrigger) OnScroll(options ...ActionTriggerOption) h.H {
|
||||||
opts := applyOptions(options...)
|
opts := applyOptions(options...)
|
||||||
return h.Data("on:scroll", buildOnExpr(actionURL(a.id), &opts))
|
return h.Data(buildAttrKey("scroll", &opts), buildOnExpr(actionURL(a.id), &opts))
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnDblClick returns a via.h DOM attribute that triggers on double click.
|
// OnDblClick returns a via.h DOM attribute that triggers on double click.
|
||||||
func (a *actionTrigger) OnDblClick(options ...ActionTriggerOption) h.H {
|
func (a *actionTrigger) OnDblClick(options ...ActionTriggerOption) h.H {
|
||||||
opts := applyOptions(options...)
|
opts := applyOptions(options...)
|
||||||
return h.Data("on:dblclick", buildOnExpr(actionURL(a.id), &opts))
|
return h.Data(buildAttrKey("dblclick", &opts), buildOnExpr(actionURL(a.id), &opts))
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnKeyDown returns a via.h DOM attribute that triggers when a key is pressed.
|
// OnKeyDown returns a via.h DOM attribute that triggers when a key is pressed.
|
||||||
@@ -164,11 +202,7 @@ func (a *actionTrigger) OnKeyDown(key string, options ...ActionTriggerOption) h.
|
|||||||
if key != "" {
|
if key != "" {
|
||||||
condition = fmt.Sprintf("evt.key==='%s' &&", key)
|
condition = fmt.Sprintf("evt.key==='%s' &&", key)
|
||||||
}
|
}
|
||||||
attrName := "on:keydown"
|
return h.Data(buildAttrKey("keydown", &opts), fmt.Sprintf("%s%s", condition, buildOnExpr(actionURL(a.id), &opts)))
|
||||||
if opts.window {
|
|
||||||
attrName = "on:keydown__window"
|
|
||||||
}
|
|
||||||
return h.Data(attrName, fmt.Sprintf("%s%s", condition, buildOnExpr(actionURL(a.id), &opts)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// KeyBinding pairs a key with an action and per-binding options.
|
// KeyBinding pairs a key with an action and per-binding options.
|
||||||
|
|||||||
55
computed.go
Normal file
55
computed.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package via
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ryanhamamura/via/h"
|
||||||
|
)
|
||||||
|
|
||||||
|
// computedSignal is a read-only signal whose value is derived from other signals.
|
||||||
|
// It recomputes on every read and is included in patches only when the value changes.
|
||||||
|
type computedSignal struct {
|
||||||
|
id string
|
||||||
|
compute func() string
|
||||||
|
lastVal string
|
||||||
|
changed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *computedSignal) ID() string {
|
||||||
|
return s.id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *computedSignal) String() string {
|
||||||
|
return s.compute()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *computedSignal) Int() int {
|
||||||
|
if n, err := strconv.Atoi(s.String()); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *computedSignal) Bool() bool {
|
||||||
|
val := strings.ToLower(s.String())
|
||||||
|
return val == "true" || val == "1" || val == "yes" || val == "on"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *computedSignal) Text() h.H {
|
||||||
|
return h.Span(h.Data("text", "$"+s.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// recompute calls the compute function and sets changed if the value differs from lastVal.
|
||||||
|
func (s *computedSignal) recompute() {
|
||||||
|
val := s.compute()
|
||||||
|
if val != s.lastVal {
|
||||||
|
s.lastVal = val
|
||||||
|
s.changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *computedSignal) patchValue() string {
|
||||||
|
return fmt.Sprintf("%v", s.lastVal)
|
||||||
|
}
|
||||||
190
computed_test.go
Normal file
190
computed_test.go
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
package via
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/ryanhamamura/via/h"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestComputedBasic(t *testing.T) {
|
||||||
|
v := New()
|
||||||
|
var cs *computedSignal
|
||||||
|
v.Page("/", func(c *Context) {
|
||||||
|
sig1 := c.Signal("hello")
|
||||||
|
sig2 := c.Signal("world")
|
||||||
|
cs = c.Computed(func() string {
|
||||||
|
return sig1.String() + " " + sig2.String()
|
||||||
|
})
|
||||||
|
c.View(func() h.H { return h.Div() })
|
||||||
|
})
|
||||||
|
assert.Equal(t, "hello world", cs.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputedReactivity(t *testing.T) {
|
||||||
|
v := New()
|
||||||
|
var cs *computedSignal
|
||||||
|
var sig1 *signal
|
||||||
|
v.Page("/", func(c *Context) {
|
||||||
|
sig1 = c.Signal("a")
|
||||||
|
sig2 := c.Signal("b")
|
||||||
|
cs = c.Computed(func() string {
|
||||||
|
return sig1.String() + sig2.String()
|
||||||
|
})
|
||||||
|
c.View(func() h.H { return h.Div() })
|
||||||
|
})
|
||||||
|
assert.Equal(t, "ab", cs.String())
|
||||||
|
sig1.SetValue("x")
|
||||||
|
assert.Equal(t, "xb", cs.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputedInt(t *testing.T) {
|
||||||
|
v := New()
|
||||||
|
var cs *computedSignal
|
||||||
|
v.Page("/", func(c *Context) {
|
||||||
|
sig := c.Signal(21)
|
||||||
|
cs = c.Computed(func() string {
|
||||||
|
return fmt.Sprintf("%d", sig.Int()*2)
|
||||||
|
})
|
||||||
|
c.View(func() h.H { return h.Div() })
|
||||||
|
})
|
||||||
|
assert.Equal(t, 42, cs.Int())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputedBool(t *testing.T) {
|
||||||
|
v := New()
|
||||||
|
var cs *computedSignal
|
||||||
|
v.Page("/", func(c *Context) {
|
||||||
|
sig := c.Signal("true")
|
||||||
|
cs = c.Computed(func() string {
|
||||||
|
return sig.String()
|
||||||
|
})
|
||||||
|
c.View(func() h.H { return h.Div() })
|
||||||
|
})
|
||||||
|
assert.True(t, cs.Bool())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputedText(t *testing.T) {
|
||||||
|
v := New()
|
||||||
|
var cs *computedSignal
|
||||||
|
v.Page("/", func(c *Context) {
|
||||||
|
cs = c.Computed(func() string { return "hi" })
|
||||||
|
c.View(func() h.H { return h.Div() })
|
||||||
|
})
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := cs.Text().Render(&buf)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Contains(t, buf.String(), `data-text="$`+cs.ID()+`"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputedChangeDetection(t *testing.T) {
|
||||||
|
v := New()
|
||||||
|
var ctx *Context
|
||||||
|
var sig *signal
|
||||||
|
v.Page("/", func(c *Context) {
|
||||||
|
ctx = c
|
||||||
|
sig = c.Signal("a")
|
||||||
|
c.Computed(func() string {
|
||||||
|
return sig.String() + "!"
|
||||||
|
})
|
||||||
|
c.View(func() h.H { return h.Div() })
|
||||||
|
})
|
||||||
|
|
||||||
|
// First patch includes computed (changed=true from init)
|
||||||
|
patch1 := ctx.prepareSignalsForPatch()
|
||||||
|
assert.NotEmpty(t, patch1)
|
||||||
|
|
||||||
|
// Second patch: nothing changed, computed should not be included
|
||||||
|
patch2 := ctx.prepareSignalsForPatch()
|
||||||
|
// Regular signal still has changed=true (not reset in prepareSignalsForPatch),
|
||||||
|
// but computed should not appear since its value didn't change.
|
||||||
|
hasComputed := false
|
||||||
|
ctx.signals.Range(func(_, value any) bool {
|
||||||
|
if cs, ok := value.(*computedSignal); ok {
|
||||||
|
_, inPatch := patch2[cs.ID()]
|
||||||
|
hasComputed = inPatch
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
assert.False(t, hasComputed)
|
||||||
|
|
||||||
|
// After changing dependency, computed should reappear
|
||||||
|
sig.SetValue("b")
|
||||||
|
patch3 := ctx.prepareSignalsForPatch()
|
||||||
|
found := false
|
||||||
|
ctx.signals.Range(func(_, value any) bool {
|
||||||
|
if cs, ok := value.(*computedSignal); ok {
|
||||||
|
if v, ok := patch3[cs.ID()]; ok {
|
||||||
|
assert.Equal(t, "b!", v)
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
assert.True(t, found)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputedInComponent(t *testing.T) {
|
||||||
|
v := New()
|
||||||
|
var cs *computedSignal
|
||||||
|
var parentCtx *Context
|
||||||
|
v.Page("/", func(c *Context) {
|
||||||
|
parentCtx = c
|
||||||
|
c.Component(func(comp *Context) {
|
||||||
|
sig := comp.Signal("via")
|
||||||
|
cs = comp.Computed(func() string {
|
||||||
|
return "hello " + sig.String()
|
||||||
|
})
|
||||||
|
comp.View(func() h.H { return h.Div() })
|
||||||
|
})
|
||||||
|
c.View(func() h.H { return h.Div() })
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Equal(t, "hello via", cs.String())
|
||||||
|
// Verify it's stored on the parent page context
|
||||||
|
found := false
|
||||||
|
parentCtx.signals.Range(func(_, value any) bool {
|
||||||
|
if stored, ok := value.(*computedSignal); ok && stored.ID() == cs.ID() {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
assert.True(t, found)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputedIsReadOnly(t *testing.T) {
|
||||||
|
// Compile-time guarantee: *computedSignal has no Bind() or SetValue() methods.
|
||||||
|
// This test exists as documentation — if someone adds those methods, the
|
||||||
|
// interface assertion below will need updating and serve as a reminder.
|
||||||
|
var cs interface{} = &computedSignal{}
|
||||||
|
type writable interface {
|
||||||
|
SetValue(any)
|
||||||
|
}
|
||||||
|
type bindable interface {
|
||||||
|
Bind() h.H
|
||||||
|
}
|
||||||
|
_, isWritable := cs.(writable)
|
||||||
|
_, isBindable := cs.(bindable)
|
||||||
|
assert.False(t, isWritable, "computedSignal must not have SetValue")
|
||||||
|
assert.False(t, isBindable, "computedSignal must not have Bind")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputedInjectSignalsSkips(t *testing.T) {
|
||||||
|
v := New()
|
||||||
|
var ctx *Context
|
||||||
|
var cs *computedSignal
|
||||||
|
v.Page("/", func(c *Context) {
|
||||||
|
ctx = c
|
||||||
|
cs = c.Computed(func() string { return "fixed" })
|
||||||
|
c.View(func() h.H { return h.Div() })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Simulate browser sending back a value for the computed signal — should be ignored
|
||||||
|
ctx.injectSignals(map[string]any{
|
||||||
|
cs.ID(): "injected",
|
||||||
|
})
|
||||||
|
assert.Equal(t, "fixed", cs.String())
|
||||||
|
}
|
||||||
@@ -53,13 +53,23 @@ type Options struct {
|
|||||||
// Defaults to "/_datastar.js" if empty.
|
// Defaults to "/_datastar.js" if empty.
|
||||||
DatastarPath string
|
DatastarPath string
|
||||||
|
|
||||||
// PubSub enables publish/subscribe messaging. Use vianats.New() for an
|
// PubSub enables publish/subscribe messaging. When nil, an embedded NATS
|
||||||
// embedded NATS backend, or supply any PubSub implementation.
|
// server starts automatically in Start(). Supply any PubSub implementation
|
||||||
|
// to replace it.
|
||||||
PubSub PubSub
|
PubSub PubSub
|
||||||
|
|
||||||
|
// Streams declares JetStream streams to create when Start() initializes
|
||||||
|
// 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
|
// ContextTTL is the maximum time a context may exist without an SSE
|
||||||
// connection before the background reaper disposes it.
|
// connection before the background reaper fully disposes it.
|
||||||
// Default: 30s. Negative value disables the reaper.
|
// Default: 1h. Negative value disables the reaper.
|
||||||
ContextTTL time.Duration
|
ContextTTL time.Duration
|
||||||
|
|
||||||
// ActionRateLimit configures the default token-bucket rate limiter for
|
// ActionRateLimit configures the default token-bucket rate limiter for
|
||||||
|
|||||||
142
context.go
142
context.go
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
@@ -30,14 +31,19 @@ type Context struct {
|
|||||||
actionRegistry map[string]actionEntry
|
actionRegistry map[string]actionEntry
|
||||||
signals *sync.Map
|
signals *sync.Map
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
navMu sync.Mutex
|
||||||
ctxDisposedChan chan struct{}
|
ctxDisposedChan chan struct{}
|
||||||
|
pageStopChan chan struct{}
|
||||||
reqCtx context.Context
|
reqCtx context.Context
|
||||||
fields []*Field
|
fields []*Field
|
||||||
subscriptions []Subscription
|
subscriptions []Subscription
|
||||||
subsMu sync.Mutex
|
subsMu sync.Mutex
|
||||||
disposeOnce sync.Once
|
disposeOnce sync.Once
|
||||||
createdAt time.Time
|
createdAt time.Time
|
||||||
sseConnected atomic.Bool
|
sseConnected atomic.Bool
|
||||||
|
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.
|
||||||
@@ -48,7 +54,11 @@ func (c *Context) View(f func() h.H) {
|
|||||||
if f == nil {
|
if f == nil {
|
||||||
panic("nil viewfn")
|
panic("nil viewfn")
|
||||||
}
|
}
|
||||||
c.view = func() h.H { return h.Div(h.ID(c.id), f()) }
|
if c.app.layout != nil {
|
||||||
|
c.view = func() h.H { return h.Div(h.ID(c.id), c.app.layout(f)) }
|
||||||
|
} else {
|
||||||
|
c.view = func() h.H { return h.Div(h.ID(c.id), f()) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Component registers a subcontext that has self contained data, actions and signals.
|
// Component registers a subcontext that has self contained data, actions and signals.
|
||||||
@@ -131,17 +141,19 @@ func (c *Context) getAction(id string) (actionEntry, error) {
|
|||||||
return actionEntry{}, fmt.Errorf("action '%s' not found", id)
|
return actionEntry{}, fmt.Errorf("action '%s' not found", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnInterval starts a go routine that sets a time.Ticker with the given duration and executes
|
// OnInterval starts a goroutine that executes handler on every tick of the given duration.
|
||||||
// the given handler func() on every tick. Use *Routine.UpdateInterval to update the interval.
|
// The goroutine is tied to the context lifecycle and will stop when the context is disposed.
|
||||||
func (c *Context) OnInterval(duration time.Duration, handler func()) *OnIntervalRoutine {
|
// Returns a func() that stops the interval when called.
|
||||||
var cn chan struct{}
|
func (c *Context) OnInterval(duration time.Duration, handler func()) func() {
|
||||||
if c.isComponent() { // components use the chan on the parent page ctx
|
var disposeCh, pageCh chan struct{}
|
||||||
cn = c.parentPageCtx.ctxDisposedChan
|
if c.isComponent() {
|
||||||
|
disposeCh = c.parentPageCtx.ctxDisposedChan
|
||||||
|
pageCh = c.parentPageCtx.pageStopChan
|
||||||
} else {
|
} else {
|
||||||
cn = c.ctxDisposedChan
|
disposeCh = c.ctxDisposedChan
|
||||||
|
pageCh = c.pageStopChan
|
||||||
}
|
}
|
||||||
r := newOnIntervalRoutine(cn, duration, handler)
|
return newOnInterval(disposeCh, pageCh, duration, handler)
|
||||||
return r
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Signal creates a reactive signal and initializes it with the given value.
|
// Signal creates a reactive signal and initializes it with the given value.
|
||||||
@@ -196,6 +208,40 @@ func (c *Context) Signal(v any) *signal {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Computed creates a read-only signal whose value is derived from the given function.
|
||||||
|
// The function is called on every read (String/Int/Bool) for fresh values,
|
||||||
|
// and during sync to detect changes for browser patches.
|
||||||
|
//
|
||||||
|
// Computed signals cannot be bound to inputs or set manually.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// full := c.Computed(func() string {
|
||||||
|
// return first.String() + " " + last.String()
|
||||||
|
// })
|
||||||
|
// c.View(func() h.H {
|
||||||
|
// return h.Span(full.Text())
|
||||||
|
// })
|
||||||
|
func (c *Context) Computed(fn func() string) *computedSignal {
|
||||||
|
sigID := genRandID()
|
||||||
|
initial := fn()
|
||||||
|
cs := &computedSignal{
|
||||||
|
id: sigID,
|
||||||
|
compute: fn,
|
||||||
|
lastVal: initial,
|
||||||
|
changed: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.isComponent() {
|
||||||
|
c.parentPageCtx.signals.Store(sigID, cs)
|
||||||
|
} else {
|
||||||
|
c.signals.Store(sigID, cs)
|
||||||
|
}
|
||||||
|
return cs
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Context) injectSignals(sigs map[string]any) {
|
func (c *Context) injectSignals(sigs map[string]any) {
|
||||||
if sigs == nil {
|
if sigs == nil {
|
||||||
c.app.logErr(c, "signal injection failed: nil signals")
|
c.app.logErr(c, "signal injection failed: nil signals")
|
||||||
@@ -237,7 +283,8 @@ func (c *Context) prepareSignalsForPatch() map[string]any {
|
|||||||
defer c.mu.RUnlock()
|
defer c.mu.RUnlock()
|
||||||
updatedSigs := make(map[string]any)
|
updatedSigs := make(map[string]any)
|
||||||
c.signals.Range(func(sigID, value any) bool {
|
c.signals.Range(func(sigID, value any) bool {
|
||||||
if sig, ok := value.(*signal); ok {
|
switch sig := value.(type) {
|
||||||
|
case *signal:
|
||||||
if sig.err != nil {
|
if sig.err != nil {
|
||||||
c.app.logWarn(c, "signal '%s' is out of sync: %v", sig.id, sig.err)
|
c.app.logWarn(c, "signal '%s' is out of sync: %v", sig.id, sig.err)
|
||||||
return true
|
return true
|
||||||
@@ -245,6 +292,12 @@ func (c *Context) prepareSignalsForPatch() map[string]any {
|
|||||||
if sig.changed {
|
if sig.changed {
|
||||||
updatedSigs[sigID.(string)] = fmt.Sprintf("%v", sig.val)
|
updatedSigs[sigID.(string)] = fmt.Sprintf("%v", sig.val)
|
||||||
}
|
}
|
||||||
|
case *computedSignal:
|
||||||
|
sig.recompute()
|
||||||
|
if sig.changed {
|
||||||
|
updatedSigs[sigID.(string)] = sig.patchValue()
|
||||||
|
sig.changed = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
@@ -264,15 +317,22 @@ func (c *Context) sendPatch(p patch) {
|
|||||||
// Sync pushes the current view state and signal changes to the browser immediately
|
// Sync pushes the current view state and signal changes to the browser immediately
|
||||||
// over the live SSE event stream.
|
// over the live SSE event stream.
|
||||||
func (c *Context) Sync() {
|
func (c *Context) Sync() {
|
||||||
|
c.syncView(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Context) syncView(viewTransition bool) {
|
||||||
elemsPatch := new(bytes.Buffer)
|
elemsPatch := new(bytes.Buffer)
|
||||||
if err := c.view().Render(elemsPatch); err != nil {
|
if err := c.view().Render(elemsPatch); err != nil {
|
||||||
c.app.logErr(c, "sync view failed: %v", err)
|
c.app.logErr(c, "sync view failed: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.sendPatch(patch{patchTypeElements, elemsPatch.String()})
|
typ := patchType(patchTypeElements)
|
||||||
|
if viewTransition {
|
||||||
|
typ = patchTypeElementsWithVT
|
||||||
|
}
|
||||||
|
c.sendPatch(patch{typ, elemsPatch.String()})
|
||||||
|
|
||||||
updatedSigs := c.prepareSignalsForPatch()
|
updatedSigs := c.prepareSignalsForPatch()
|
||||||
|
|
||||||
if len(updatedSigs) != 0 {
|
if len(updatedSigs) != 0 {
|
||||||
outgoingSigs, _ := json.Marshal(updatedSigs)
|
outgoingSigs, _ := json.Marshal(updatedSigs)
|
||||||
c.sendPatch(patch{patchTypeSignals, string(outgoingSigs)})
|
c.sendPatch(patch{patchTypeSignals, string(outgoingSigs)})
|
||||||
@@ -369,6 +429,53 @@ func (c *Context) ReplaceURLf(format string, a ...any) {
|
|||||||
c.ReplaceURL(fmt.Sprintf(format, a...))
|
c.ReplaceURL(fmt.Sprintf(format, a...))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resetPageState tears down page-specific state (intervals, subscriptions,
|
||||||
|
// actions, signals, fields) without disposing the context itself. The SSE
|
||||||
|
// connection and context lifetime are unaffected.
|
||||||
|
func (c *Context) resetPageState() {
|
||||||
|
close(c.pageStopChan)
|
||||||
|
c.unsubscribeAll()
|
||||||
|
c.mu.Lock()
|
||||||
|
c.actionRegistry = make(map[string]actionEntry)
|
||||||
|
c.signals = new(sync.Map)
|
||||||
|
c.fields = nil
|
||||||
|
c.pageStopChan = make(chan struct{})
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// suspend frees page-scoped resources while keeping the context shell alive
|
||||||
|
// in the registry for seamless re-init on reconnect.
|
||||||
|
func (c *Context) suspend() {
|
||||||
|
c.resetPageState()
|
||||||
|
c.suspended.Store(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate performs an SPA navigation to the given path. It resets page state,
|
||||||
|
// runs the target page's init function (with middleware), and pushes the new
|
||||||
|
// view over the existing SSE connection with a view transition animation.
|
||||||
|
// If popstate is true, replaceState is used instead of pushState.
|
||||||
|
func (c *Context) Navigate(path string, popstate bool) {
|
||||||
|
c.navMu.Lock()
|
||||||
|
defer c.navMu.Unlock()
|
||||||
|
|
||||||
|
route, initFn, params := c.app.matchRoute(path)
|
||||||
|
if initFn == nil {
|
||||||
|
c.Redirect(path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.resetPageState()
|
||||||
|
c.route = route
|
||||||
|
c.injectRouteParams(params)
|
||||||
|
initFn(c)
|
||||||
|
c.syncView(true)
|
||||||
|
safe := strings.NewReplacer(`\`, `\\`, `'`, `\'`).Replace(path)
|
||||||
|
if popstate {
|
||||||
|
c.ExecScript(fmt.Sprintf("history.replaceState({},'','%s')", safe))
|
||||||
|
} else {
|
||||||
|
c.ExecScript(fmt.Sprintf("history.pushState({},'','%s')", safe))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// dispose idempotently tears down this context: unsubscribes all pubsub
|
// dispose idempotently tears down this context: unsubscribes all pubsub
|
||||||
// subscriptions and closes ctxDisposedChan to stop routines and exit the SSE loop.
|
// subscriptions and closes ctxDisposedChan to stop routines and exit the SSE loop.
|
||||||
func (c *Context) dispose() {
|
func (c *Context) dispose() {
|
||||||
@@ -379,7 +486,7 @@ func (c *Context) dispose() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// stopAllRoutines closes ctxDisposedChan, broadcasting to all listening
|
// stopAllRoutines closes ctxDisposedChan, broadcasting to all listening
|
||||||
// goroutines (OnIntervalRoutine, SSE loop) that this context is done.
|
// goroutines (OnInterval, SSE loop) that this context is done.
|
||||||
func (c *Context) stopAllRoutines() {
|
func (c *Context) stopAllRoutines() {
|
||||||
select {
|
select {
|
||||||
case <-c.ctxDisposedChan:
|
case <-c.ctxDisposedChan:
|
||||||
@@ -539,8 +646,9 @@ func newContext(id string, route string, v *V) *Context {
|
|||||||
actionLimiter: newLimiter(v.actionRateLimit, defaultActionRate, defaultActionBurst),
|
actionLimiter: newLimiter(v.actionRateLimit, defaultActionRate, defaultActionBurst),
|
||||||
actionRegistry: make(map[string]actionEntry),
|
actionRegistry: make(map[string]actionEntry),
|
||||||
signals: new(sync.Map),
|
signals: new(sync.Map),
|
||||||
patchChan: make(chan patch, 1),
|
patchChan: make(chan patch, 8),
|
||||||
ctxDisposedChan: make(chan struct{}, 1),
|
ctxDisposedChan: make(chan struct{}, 1),
|
||||||
|
pageStopChan: make(chan struct{}),
|
||||||
createdAt: time.Now(),
|
createdAt: time.Now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
179
docs/getting-started.md
Normal file
179
docs/getting-started.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# Getting Started
|
||||||
|
|
||||||
|
Via is a server-side reactive web framework for Go. The browser connects over SSE (Server-Sent Events), and all state lives on the server — signals, actions, and view rendering happen in Go. The browser is a thin display layer that Datastar keeps in sync via DOM morphing.
|
||||||
|
|
||||||
|
## Core Loop
|
||||||
|
|
||||||
|
Every Via app follows the same pattern:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/ryanhamamura/via"
|
||||||
|
"github.com/ryanhamamura/via/h"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
v := via.New()
|
||||||
|
|
||||||
|
v.Config(via.Options{
|
||||||
|
DocumentTitle: "My App",
|
||||||
|
})
|
||||||
|
|
||||||
|
v.Page("/", func(c *via.Context) {
|
||||||
|
count := 0
|
||||||
|
step := c.Signal(1)
|
||||||
|
|
||||||
|
increment := c.Action(func() {
|
||||||
|
count += step.Int()
|
||||||
|
c.Sync()
|
||||||
|
})
|
||||||
|
|
||||||
|
c.View(func() h.H {
|
||||||
|
return h.Div(
|
||||||
|
h.P(h.Textf("Count: %d", count)),
|
||||||
|
h.Label(
|
||||||
|
h.Text("Step: "),
|
||||||
|
h.Input(h.Type("number"), step.Bind()),
|
||||||
|
),
|
||||||
|
h.Button(h.Text("+"), increment.OnClick()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
v.Start()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
What happens:
|
||||||
|
|
||||||
|
1. `via.New()` creates the app, starts an embedded NATS server, and registers internal routes (`/_sse`, `/_action/{id}`, `/_navigate`, `/_session/close`).
|
||||||
|
2. `v.Config()` applies settings.
|
||||||
|
3. `v.Page()` registers a route. The init function receives a `*Context` where you define signals, actions, and the view.
|
||||||
|
4. `v.Start()` starts the HTTP server and blocks until SIGINT/SIGTERM.
|
||||||
|
|
||||||
|
When a browser hits the page, Via creates a new `Context`, runs the init function, renders the full HTML document, and opens an SSE connection. From that point, every `c.Sync()` re-renders the view and pushes a DOM patch to the browser.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```go
|
||||||
|
v.Config(via.Options{
|
||||||
|
DevMode: true,
|
||||||
|
ServerAddress: ":8080",
|
||||||
|
LogLevel: via.LogLevelDebug,
|
||||||
|
DocumentTitle: "My App",
|
||||||
|
Plugins: []via.Plugin{MyPlugin},
|
||||||
|
SessionManager: sm,
|
||||||
|
PubSub: customBackend,
|
||||||
|
ContextTTL: 60 * time.Second,
|
||||||
|
ActionRateLimit: via.RateLimitConfig{Rate: 20, Burst: 40},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Default | Description |
|
||||||
|
|-------|---------|-------------|
|
||||||
|
| `DevMode` | `false` | Enables context persistence across restarts, console logger, and Datastar inspector widget |
|
||||||
|
| `ServerAddress` | `":3000"` | HTTP listen address |
|
||||||
|
| `LogLevel` | `InfoLevel` | Minimum log level. Use `via.LogLevelDebug`, `LogLevelInfo`, `LogLevelWarn`, `LogLevelError` |
|
||||||
|
| `Logger` | (auto) | Replace the default logger entirely. When set, `LogLevel` and `DevMode` have no effect on logging |
|
||||||
|
| `DocumentTitle` | `"⚡ Via"` | The `<title>` of the HTML document |
|
||||||
|
| `Plugins` | `nil` | Slice of plugin functions executed during `Config()` |
|
||||||
|
| `SessionManager` | in-memory | Cookie-based session manager. See [PubSub and Sessions](pubsub-and-sessions.md) |
|
||||||
|
| `DatastarContent` | (embedded) | Custom Datastar JS bytes |
|
||||||
|
| `DatastarPath` | `"/_datastar.js"` | URL path for the Datastar script |
|
||||||
|
| `PubSub` | embedded NATS | Custom PubSub backend. Replaces the default NATS. See [PubSub and Sessions](pubsub-and-sessions.md) |
|
||||||
|
| `ContextTTL` | `30s` | Max time a context survives without an SSE connection before cleanup. Negative value disables the reaper |
|
||||||
|
| `ActionRateLimit` | `10 req/s, burst 20` | Default token-bucket rate limiter for action endpoints. Rate of `-1` disables limiting |
|
||||||
|
|
||||||
|
## Static Files
|
||||||
|
|
||||||
|
Serve files from a directory:
|
||||||
|
|
||||||
|
```go
|
||||||
|
v.Static("/assets/", "./static")
|
||||||
|
```
|
||||||
|
|
||||||
|
Or from an embedded filesystem:
|
||||||
|
|
||||||
|
```go
|
||||||
|
//go:embed static
|
||||||
|
var staticFS embed.FS
|
||||||
|
|
||||||
|
v.StaticFS("/assets/", staticFS)
|
||||||
|
```
|
||||||
|
|
||||||
|
Both disable directory listing and return 404 for directory paths.
|
||||||
|
|
||||||
|
## Head and Foot Injection
|
||||||
|
|
||||||
|
Add elements to every page's `<head>` or end of `<body>`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
v.AppendToHead(
|
||||||
|
h.Link(h.Rel("stylesheet"), h.Href("/assets/style.css")),
|
||||||
|
h.Meta(h.Attr("name", "viewport"), h.Attr("content", "width=device-width, initial-scale=1")),
|
||||||
|
)
|
||||||
|
|
||||||
|
v.AppendToFoot(
|
||||||
|
h.Script(h.Src("/assets/app.js")),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
These are additive and affect all pages globally.
|
||||||
|
|
||||||
|
## Plugins
|
||||||
|
|
||||||
|
A plugin is a `func(v *via.V)` that mutates the app during configuration — registering routes, injecting assets, or applying middleware.
|
||||||
|
|
||||||
|
```go
|
||||||
|
func PicoCSSPlugin(v *via.V) {
|
||||||
|
v.HTTPServeMux().HandleFunc("GET /css/pico.css", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/css")
|
||||||
|
w.Write(picoCSSBytes)
|
||||||
|
})
|
||||||
|
v.AppendToHead(h.Link(h.Rel("stylesheet"), h.Href("/css/pico.css")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage:
|
||||||
|
v.Config(via.Options{
|
||||||
|
Plugins: []via.Plugin{PicoCSSPlugin},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Plugins have full access to the `*V` public API: `HTTPServeMux()`, `AppendToHead()`, `AppendToFoot()`, `Config()`, etc.
|
||||||
|
|
||||||
|
## DevMode
|
||||||
|
|
||||||
|
Enable during development for a better feedback loop:
|
||||||
|
|
||||||
|
```go
|
||||||
|
v.Config(via.Options{DevMode: true})
|
||||||
|
```
|
||||||
|
|
||||||
|
What it does:
|
||||||
|
|
||||||
|
- **Console logger** — Human-readable log output with timestamps.
|
||||||
|
- **Context persistence** — Saves context-to-route mappings to `.via/devmode/ctx.json`. On server restart, reconnecting browsers restore their state instead of getting a blank page. Pair with [Air](https://github.com/air-verse/air) for hot-reloading.
|
||||||
|
- **Datastar inspector** — Injects a widget showing live signal values and SSE activity.
|
||||||
|
|
||||||
|
## Custom HTTP Handlers
|
||||||
|
|
||||||
|
Access the underlying `*http.ServeMux` for custom routes:
|
||||||
|
|
||||||
|
```go
|
||||||
|
mux := v.HTTPServeMux()
|
||||||
|
mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("ok"))
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Register custom handlers before calling `v.Start()`.
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
- [State and Interactivity](state-and-interactivity.md) — Signals, actions, components, validation
|
||||||
|
- [Routing and Navigation](routing-and-navigation.md) — Multi-page apps, middleware, SPA navigation
|
||||||
|
- [PubSub and Sessions](pubsub-and-sessions.md) — Real-time messaging, persistent sessions
|
||||||
|
- [HTML DSL](html-dsl.md) — The `h` package reference
|
||||||
|
- [Project Structure](project-structure.md) — Organizing files as your app grows
|
||||||
164
docs/html-dsl.md
Normal file
164
docs/html-dsl.md
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
# HTML DSL
|
||||||
|
|
||||||
|
Reference for the `h` package — Via's HTML builder.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The `h` package wraps [gomponents](https://github.com/maragudk/gomponents) with a single interface:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type H interface {
|
||||||
|
Render(w io.Writer) error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Every element, attribute, and text node implements `H`. Build HTML by nesting function calls:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "github.com/ryanhamamura/via/h"
|
||||||
|
|
||||||
|
h.Div(h.Class("card"),
|
||||||
|
h.H2(h.Text("Title")),
|
||||||
|
h.P(h.Textf("Count: %d", count)),
|
||||||
|
h.Button(h.Text("Click"), action.OnClick()),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
For cleaner templates, use a dot import:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import . "github.com/ryanhamamura/via/h"
|
||||||
|
|
||||||
|
Div(Class("card"),
|
||||||
|
H2(Text("Title")),
|
||||||
|
P(Textf("Count: %d", count)),
|
||||||
|
Button(Text("Click"), action.OnClick()),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Text Nodes
|
||||||
|
|
||||||
|
| Function | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `Text(s)` | Escaped text node |
|
||||||
|
| `Textf(fmt, args...)` | Escaped text with `fmt.Sprintf` |
|
||||||
|
| `Raw(s)` | Unescaped raw HTML — use for trusted content like SVG |
|
||||||
|
| `Rawf(fmt, args...)` | Unescaped raw HTML with `fmt.Sprintf` |
|
||||||
|
|
||||||
|
## Elements
|
||||||
|
|
||||||
|
Every element function takes `...H` children (elements, attributes, and text nodes mixed together) except `Style(v string)` and `Title(v string)` which take a single string.
|
||||||
|
|
||||||
|
### Document structure
|
||||||
|
|
||||||
|
`HTML`, `Head`, `Body`, `Main`, `Header`, `Footer`, `Section`, `Article`, `Aside`, `Nav`, `Div`, `Span`
|
||||||
|
|
||||||
|
### Headings
|
||||||
|
|
||||||
|
`H1`, `H2`, `H3`, `H4`, `H5`, `H6`
|
||||||
|
|
||||||
|
### Text
|
||||||
|
|
||||||
|
`P`, `A`, `Strong`, `Em`, `B`, `I`, `U`, `S`, `Small`, `Mark`, `Del`, `Ins`, `Sub`, `Sup`, `Abbr`, `Cite`, `Code`, `Pre`, `Samp`, `Kbd`, `Var`, `Q`, `BlockQuote`, `Dfn`, `Wbr`, `Br`, `Hr`
|
||||||
|
|
||||||
|
### Forms
|
||||||
|
|
||||||
|
`Form`, `Input`, `Textarea`, `Select`, `Option`, `OptGroup`, `Button`, `Label`, `FieldSet`, `Legend`, `DataList`, `Meter`, `Progress`
|
||||||
|
|
||||||
|
### Tables
|
||||||
|
|
||||||
|
`Table`, `THead`, `TBody`, `TFoot`, `Tr`, `Th`, `Td`, `Caption`, `Col`, `ColGroup`
|
||||||
|
|
||||||
|
### Lists
|
||||||
|
|
||||||
|
`Ul`, `Ol`, `Li`, `Dl`, `Dt`, `Dd`
|
||||||
|
|
||||||
|
### Media
|
||||||
|
|
||||||
|
`Img`, `Audio`, `Video`, `Source`, `Picture`, `Canvas`, `IFrame`, `Embed`, `Object`
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
`Details`, `Summary`, `Dialog`, `Template`, `NoScript`, `Figure`, `FigCaption`, `Address`, `Time`, `Base`, `Link`, `Meta`, `Script`, `Area`
|
||||||
|
|
||||||
|
### Special signatures
|
||||||
|
|
||||||
|
| Function | Signature | Notes |
|
||||||
|
|----------|-----------|-------|
|
||||||
|
| `Style(v)` | `func Style(v string) H` | Inline `style` attribute, not a container element |
|
||||||
|
| `StyleEl(children...)` | `func StyleEl(children ...H) H` | The `<style>` element as a container |
|
||||||
|
| `Title(v)` | `func Title(v string) H` | Sets `<title>` text |
|
||||||
|
|
||||||
|
## Attributes
|
||||||
|
|
||||||
|
### Generic
|
||||||
|
|
||||||
|
```go
|
||||||
|
Attr("name", "value") // name="value"
|
||||||
|
Attr("disabled") // boolean attribute (no value)
|
||||||
|
```
|
||||||
|
|
||||||
|
`Attr` with no value produces a boolean attribute. With one value, it produces a name-value pair. More than one value panics.
|
||||||
|
|
||||||
|
### Named helpers
|
||||||
|
|
||||||
|
| Function | HTML output |
|
||||||
|
|----------|-------------|
|
||||||
|
| `ID(v)` | `id="v"` |
|
||||||
|
| `Class(v)` | `class="v"` |
|
||||||
|
| `Href(v)` | `href="v"` |
|
||||||
|
| `Src(v)` | `src="v"` |
|
||||||
|
| `Type(v)` | `type="v"` |
|
||||||
|
| `Value(v)` | `value="v"` |
|
||||||
|
| `Placeholder(v)` | `placeholder="v"` |
|
||||||
|
| `Rel(v)` | `rel="v"` |
|
||||||
|
| `Role(v)` | `role="v"` |
|
||||||
|
| `Data(name, v)` | `data-name="v"` (auto-prefixes `data-`) |
|
||||||
|
|
||||||
|
## Conditional Rendering
|
||||||
|
|
||||||
|
```go
|
||||||
|
h.If(showError, h.P(h.Class("error"), h.Text("Something went wrong")))
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the node when `true`, `nil` (renders nothing) when `false`.
|
||||||
|
|
||||||
|
## Datastar Helpers
|
||||||
|
|
||||||
|
These produce attributes used by Datastar for client-side reactivity.
|
||||||
|
|
||||||
|
| Function | Output | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `DataInit(expr)` | `data-init="expr"` | Initialize client-side state |
|
||||||
|
| `DataEffect(expr)` | `data-effect="expr"` | Reactive side effect expression |
|
||||||
|
| `DataIgnoreMorph()` | `data-ignore-morph` | Skip this element during DOM morph. See [SPA Navigation](routing-and-navigation.md#dataignoremorph) |
|
||||||
|
| `DataViewTransition(name)` | `style="view-transition-name: name"` | Animate element across SPA navigations. See [View Transitions](routing-and-navigation.md#view-transitions) |
|
||||||
|
|
||||||
|
> `DataViewTransition` sets the entire `style` attribute. If you also need other inline styles, include `view-transition-name` directly in a `Style()` call.
|
||||||
|
|
||||||
|
## Utilities
|
||||||
|
|
||||||
|
### HTML5
|
||||||
|
|
||||||
|
Full HTML5 document template:
|
||||||
|
|
||||||
|
```go
|
||||||
|
h.HTML5(h.HTML5Props{
|
||||||
|
Title: "My Page",
|
||||||
|
Description: "Page description",
|
||||||
|
Language: "en",
|
||||||
|
Head: []h.H{h.Link(h.Rel("stylesheet"), h.Href("/style.css"))},
|
||||||
|
Body: []h.H{h.Div(h.Text("Hello"))},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Via uses this internally to render the initial page document. You typically don't need it directly.
|
||||||
|
|
||||||
|
### JoinAttrs
|
||||||
|
|
||||||
|
Joins attribute values from child nodes by spaces:
|
||||||
|
|
||||||
|
```go
|
||||||
|
h.JoinAttrs("class", h.Class("card"), h.Class("active"))
|
||||||
|
// → class="card active"
|
||||||
|
```
|
||||||
164
docs/project-structure.md
Normal file
164
docs/project-structure.md
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
# Project Structure
|
||||||
|
|
||||||
|
Via's closure-based page model pulls signals, actions, and views into a single scope — similar to Svelte's single-file components. This works well at every scale, but the way you organize files should evolve as your app grows.
|
||||||
|
|
||||||
|
## Stage 1: Everything in main.go
|
||||||
|
|
||||||
|
For small apps and prototypes, keep everything in `main.go`. This is the right choice when your app is under ~150 lines or has a single page.
|
||||||
|
|
||||||
|
Within the file, follow this ordering convention inside each page:
|
||||||
|
|
||||||
|
```go
|
||||||
|
v.Page("/", func(c *via.Context) {
|
||||||
|
// State — plain Go variables and signals
|
||||||
|
count := 0
|
||||||
|
step := c.Signal(1)
|
||||||
|
|
||||||
|
// Actions — event handlers that mutate state
|
||||||
|
increment := c.Action(func() {
|
||||||
|
count += step.Int()
|
||||||
|
c.Sync()
|
||||||
|
})
|
||||||
|
|
||||||
|
// View — returns the HTML tree
|
||||||
|
c.View(func() h.H {
|
||||||
|
return h.Div(
|
||||||
|
h.P(h.Textf("Count: %d", count)),
|
||||||
|
h.Button(h.Text("+"), increment.OnClick()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
State → signals → actions → view. This reads top-to-bottom and matches the data flow: state is declared, actions mutate it, the view renders it.
|
||||||
|
|
||||||
|
The [counter](../internal/examples/counter/main.go) and [greeter](../internal/examples/greeter/main.go) examples use this layout.
|
||||||
|
|
||||||
|
## Stage 2: Page per file
|
||||||
|
|
||||||
|
When `main.go` has multiple pages or exceeds ~150 lines, extract each page into its own file as a package-level function.
|
||||||
|
|
||||||
|
`main.go` becomes the app skeleton — setup, configuration, routes, and start:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/ryanhamamura/via"
|
||||||
|
"github.com/ryanhamamura/via/h"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
v := via.New()
|
||||||
|
v.Config(via.Options{
|
||||||
|
DocumentTitle: "My App",
|
||||||
|
})
|
||||||
|
|
||||||
|
v.AppendToHead(
|
||||||
|
h.Link(h.Rel("stylesheet"), h.Href("/css/pico.css")),
|
||||||
|
)
|
||||||
|
|
||||||
|
v.Page("/", HomePage)
|
||||||
|
v.Page("/chat", ChatPage)
|
||||||
|
|
||||||
|
v.Start()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each page lives in its own file with a descriptive name:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// home.go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/ryanhamamura/via"
|
||||||
|
"github.com/ryanhamamura/via/h"
|
||||||
|
)
|
||||||
|
|
||||||
|
func HomePage(c *via.Context) {
|
||||||
|
greeting := c.Signal("Hello")
|
||||||
|
|
||||||
|
c.View(func() h.H {
|
||||||
|
return h.Div(h.P(h.Text(greeting.String())))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Components follow the same pattern — keep them in the page file if single-use, or extract to their own file if reused across pages. Middleware goes in the same file as the route group it protects, or in `middleware.go` if shared.
|
||||||
|
|
||||||
|
```
|
||||||
|
myapp/
|
||||||
|
├── main.go # skeleton + routes
|
||||||
|
├── home.go # func HomePage(c *via.Context)
|
||||||
|
├── chat.go # func ChatPage(c *via.Context)
|
||||||
|
└── middleware.go # shared middleware
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stage 3: Co-located CSS and shared types
|
||||||
|
|
||||||
|
As pages accumulate custom styling, CSS strings in Go become hard to maintain — no syntax highlighting, no linting. Extract them to `.css` files alongside the pages they belong to and use `//go:embed` to load them.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// main.go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed chat.css
|
||||||
|
var chatCSS string
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
v := via.New()
|
||||||
|
|
||||||
|
v.AppendToHead(
|
||||||
|
h.Link(h.Rel("stylesheet"), h.Href("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css")),
|
||||||
|
h.StyleEl(h.Raw(chatCSS)),
|
||||||
|
)
|
||||||
|
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When multiple pages share the same structs, extract them to `types.go`. Framework-agnostic domain logic (helpers, dummy data, business rules) gets its own file too.
|
||||||
|
|
||||||
|
```
|
||||||
|
myapp/
|
||||||
|
├── main.go # skeleton + routes + global styles
|
||||||
|
├── home.go
|
||||||
|
├── chat.go
|
||||||
|
├── chat.css # //go:embed in main.go
|
||||||
|
├── types.go # shared types
|
||||||
|
└── userdata.go # helpers, dummy data
|
||||||
|
```
|
||||||
|
|
||||||
|
The [nats-chatroom](../internal/examples/nats-chatroom/) example demonstrates this layout.
|
||||||
|
|
||||||
|
## CSS Approaches
|
||||||
|
|
||||||
|
Via doesn't prescribe a CSS strategy. Two approaches work well:
|
||||||
|
|
||||||
|
**CSS framework classes in Go code** — Use Pico, Tailwind, or similar. Classes go directly in the view via `h.Class()`. Good for rapid prototyping since there's nothing to extract.
|
||||||
|
|
||||||
|
```go
|
||||||
|
h.Div(h.Class("container"),
|
||||||
|
h.Button(h.Class("primary"), h.Text("Save")),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Co-located `.css` files with `//go:embed`** — Write plain CSS in a separate file, embed it, and inject via `AppendToHead`. You get syntax highlighting, linting, and clean separation.
|
||||||
|
|
||||||
|
```go
|
||||||
|
//go:embed chat.css
|
||||||
|
var chatCSS string
|
||||||
|
|
||||||
|
// in main():
|
||||||
|
v.AppendToHead(h.StyleEl(h.Raw(chatCSS)))
|
||||||
|
```
|
||||||
|
|
||||||
|
Use a framework for quick prototypes and dashboards. Switch to co-located CSS files when you have significant custom styling or want tooling support.
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
- [Getting Started](getting-started.md) — The core loop and configuration
|
||||||
|
- [State and Interactivity](state-and-interactivity.md) — Signals, actions, components, validation
|
||||||
264
docs/pubsub-and-sessions.md
Normal file
264
docs/pubsub-and-sessions.md
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
# PubSub and Sessions
|
||||||
|
|
||||||
|
Infrastructure for multi-user real-time communication and persistent state.
|
||||||
|
|
||||||
|
## PubSub
|
||||||
|
|
||||||
|
Via includes an embedded NATS server that starts automatically with `v.Start()`. No external services required — pub/sub works out of the box.
|
||||||
|
|
||||||
|
### Interface
|
||||||
|
|
||||||
|
```go
|
||||||
|
type PubSub interface {
|
||||||
|
Publish(subject string, data []byte) error
|
||||||
|
Subscribe(subject string, handler func(data []byte)) (Subscription, error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Subscription interface {
|
||||||
|
Unsubscribe() error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
You can replace the default NATS with any backend implementing this interface via `Options.PubSub`.
|
||||||
|
|
||||||
|
### Basic pub/sub
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Subscribe to messages
|
||||||
|
via.Subscribe(c, "chat.room.general", func(msg ChatMessage) {
|
||||||
|
messages = append(messages, msg)
|
||||||
|
c.Sync()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Publish a message
|
||||||
|
via.Publish(c, "chat.room.general", ChatMessage{
|
||||||
|
User: username,
|
||||||
|
Message: text,
|
||||||
|
Time: time.Now().UnixMilli(),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
The generic helpers `via.Publish[T]` and `via.Subscribe[T]` handle JSON marshaling/unmarshaling automatically. They are package-level functions (not methods) because Go doesn't support generic methods.
|
||||||
|
|
||||||
|
Raw byte-level access is also available on the context:
|
||||||
|
|
||||||
|
```go
|
||||||
|
c.Publish("subject", []byte("raw data"))
|
||||||
|
c.Subscribe("subject", func(data []byte) { /* ... */ })
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auto-cleanup
|
||||||
|
|
||||||
|
Subscriptions created via `c.Subscribe()` or `via.Subscribe()` are tracked on the context and automatically unsubscribed when:
|
||||||
|
|
||||||
|
- The context is disposed (browser disconnects, tab closes)
|
||||||
|
- SPA navigation moves to a different page
|
||||||
|
|
||||||
|
You don't need to manually unsubscribe in normal usage.
|
||||||
|
|
||||||
|
### Custom backend
|
||||||
|
|
||||||
|
Replace the embedded NATS with your own PubSub implementation:
|
||||||
|
|
||||||
|
```go
|
||||||
|
v.Config(via.Options{
|
||||||
|
PubSub: myRedisBackend,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
This disables the embedded NATS server. The `NATSConn()` and `JetStream()` accessors will return nil.
|
||||||
|
|
||||||
|
## JetStream
|
||||||
|
|
||||||
|
NATS JetStream provides persistent, replayable message streams. Useful for chat history, event logs, or any scenario where new subscribers need to catch up on past messages.
|
||||||
|
|
||||||
|
### Declaring streams
|
||||||
|
|
||||||
|
The recommended approach is to declare streams in `Options.Streams`. They are created automatically when `v.Start()` initializes the embedded NATS server:
|
||||||
|
|
||||||
|
```go
|
||||||
|
v.Config(via.Options{
|
||||||
|
Streams: []via.StreamConfig{{
|
||||||
|
Name: "CHAT",
|
||||||
|
Subjects: []string{"chat.>"},
|
||||||
|
MaxMsgs: 1000,
|
||||||
|
MaxAge: 24 * time.Hour,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `Name` | Stream name |
|
||||||
|
| `Subjects` | NATS subjects to capture (supports wildcards: `>` matches all sub-levels) |
|
||||||
|
| `MaxMsgs` | Maximum number of messages to retain |
|
||||||
|
| `MaxAge` | Maximum age before messages are discarded |
|
||||||
|
|
||||||
|
For dynamic stream creation after startup, `EnsureStream` is also available:
|
||||||
|
|
||||||
|
```go
|
||||||
|
err := via.EnsureStream(v, via.StreamConfig{
|
||||||
|
Name: "EVENTS",
|
||||||
|
Subjects: []string{"events.>"},
|
||||||
|
MaxMsgs: 500,
|
||||||
|
MaxAge: 12 * time.Hour,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Replay history
|
||||||
|
|
||||||
|
Retrieve recent messages from a stream:
|
||||||
|
|
||||||
|
```go
|
||||||
|
messages, err := via.ReplayHistory[ChatMessage](v, "chat.room.general", 50)
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns up to the last `limit` messages on the subject, deserialized as `T`. Use this when a new user joins and needs to see recent history.
|
||||||
|
|
||||||
|
### Direct NATS access
|
||||||
|
|
||||||
|
For advanced use cases, access the NATS connection and JetStream context directly:
|
||||||
|
|
||||||
|
```go
|
||||||
|
nc := v.NATSConn() // *nats.Conn, nil if custom PubSub
|
||||||
|
js := v.JetStream() // nats.JetStreamContext, nil if custom PubSub
|
||||||
|
```
|
||||||
|
|
||||||
|
### PubSub accessor
|
||||||
|
|
||||||
|
Access the configured PubSub backend from the `V` instance:
|
||||||
|
|
||||||
|
```go
|
||||||
|
ps := v.PubSub() // via.PubSub interface, nil if none configured
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sessions
|
||||||
|
|
||||||
|
Via uses [SCS](https://github.com/alexedwards/scs) for cookie-based session management.
|
||||||
|
|
||||||
|
### Setup with SQLite
|
||||||
|
|
||||||
|
```go
|
||||||
|
db, _ := sql.Open("sqlite3", "app.db")
|
||||||
|
|
||||||
|
sm, _ := via.NewSQLiteSessionManager(db)
|
||||||
|
sm.Lifetime = 24 * time.Hour
|
||||||
|
sm.Cookie.SameSite = http.SameSiteLaxMode
|
||||||
|
|
||||||
|
v.Config(via.Options{SessionManager: sm})
|
||||||
|
```
|
||||||
|
|
||||||
|
`NewSQLiteSessionManager` creates the `sessions` table and index if they don't exist. The returned `*scs.SessionManager` can be configured further (lifetime, cookie settings) before passing to `Config`.
|
||||||
|
|
||||||
|
A default in-memory session manager is always available, even without explicit configuration. Use `NewSQLiteSessionManager` when you need sessions to survive server restarts.
|
||||||
|
|
||||||
|
### Session API
|
||||||
|
|
||||||
|
Access the session from any context:
|
||||||
|
|
||||||
|
```go
|
||||||
|
s := c.Session()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Getters:**
|
||||||
|
|
||||||
|
| Method | Return type |
|
||||||
|
|--------|-------------|
|
||||||
|
| `s.Get(key)` | `any` |
|
||||||
|
| `s.GetString(key)` | `string` |
|
||||||
|
| `s.GetInt(key)` | `int` |
|
||||||
|
| `s.GetBool(key)` | `bool` |
|
||||||
|
| `s.GetFloat64(key)` | `float64` |
|
||||||
|
| `s.GetTime(key)` | `time.Time` |
|
||||||
|
| `s.GetBytes(key)` | `[]byte` |
|
||||||
|
|
||||||
|
**Pop** (get and delete — useful for flash messages):
|
||||||
|
|
||||||
|
| Method | Return type |
|
||||||
|
|--------|-------------|
|
||||||
|
| `s.Pop(key)` | `any` |
|
||||||
|
| `s.PopString(key)` | `string` |
|
||||||
|
| `s.PopInt(key)` | `int` |
|
||||||
|
| `s.PopBool(key)` | `bool` |
|
||||||
|
| `s.PopFloat64(key)` | `float64` |
|
||||||
|
| `s.PopTime(key)` | `time.Time` |
|
||||||
|
| `s.PopBytes(key)` | `[]byte` |
|
||||||
|
|
||||||
|
**Mutators:**
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `s.Set(key, val)` | Store a value |
|
||||||
|
| `s.Delete(key)` | Remove a single key |
|
||||||
|
| `s.Clear()` | Remove all session data |
|
||||||
|
| `s.Destroy()` | Destroy the entire session (for logout) |
|
||||||
|
| `s.RenewToken()` | Regenerate session ID (prevents session fixation — call after login) |
|
||||||
|
|
||||||
|
**Introspection:**
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `s.Exists(key)` | True if key exists |
|
||||||
|
| `s.Keys()` | All keys in the session |
|
||||||
|
| `s.ID()` | Session token (cookie value) |
|
||||||
|
|
||||||
|
All getters return zero values if the key doesn't exist or the session manager is nil.
|
||||||
|
|
||||||
|
### Auth pattern
|
||||||
|
|
||||||
|
A common login/logout flow using sessions and middleware:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Middleware
|
||||||
|
func authRequired(c *via.Context, next func()) {
|
||||||
|
if c.Session().GetString("username") == "" {
|
||||||
|
c.Session().Set("flash", "Please log in first")
|
||||||
|
c.RedirectView("/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login page
|
||||||
|
v.Page("/login", func(c *via.Context) {
|
||||||
|
user := c.Signal("")
|
||||||
|
pass := c.Signal("")
|
||||||
|
flash := c.Session().PopString("flash")
|
||||||
|
|
||||||
|
login := c.Action(func() {
|
||||||
|
if authenticate(user.String(), pass.String()) {
|
||||||
|
c.Session().RenewToken()
|
||||||
|
c.Session().Set("username", user.String())
|
||||||
|
c.Redirect("/dashboard")
|
||||||
|
} else {
|
||||||
|
flash = "Invalid credentials"
|
||||||
|
c.Sync()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
c.View(func() h.H {
|
||||||
|
return h.Form(login.OnSubmit(),
|
||||||
|
h.If(flash != "", h.P(h.Text(flash))),
|
||||||
|
h.Input(h.Type("text"), user.Bind(), h.Placeholder("Username")),
|
||||||
|
h.Input(h.Type("password"), pass.Bind(), h.Placeholder("Password")),
|
||||||
|
h.Button(h.Type("submit"), h.Text("Log In")),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Protected pages
|
||||||
|
protected := v.Group("", authRequired)
|
||||||
|
protected.Page("/dashboard", dashboardHandler)
|
||||||
|
|
||||||
|
// Logout action (inside a protected page)
|
||||||
|
logout := c.Action(func() {
|
||||||
|
c.Session().Destroy()
|
||||||
|
c.Redirect("/login")
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Key points:
|
||||||
|
- Call `RenewToken()` after login to prevent session fixation.
|
||||||
|
- Use `PopString` for flash messages — they're read once then removed.
|
||||||
|
- Use `RedirectView` in middleware, `Redirect` in actions. See the [gotcha in routing](routing-and-navigation.md#middleware).
|
||||||
222
docs/routing-and-navigation.md
Normal file
222
docs/routing-and-navigation.md
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
# Routing and Navigation
|
||||||
|
|
||||||
|
Multi-page app structure, middleware, and Via's SPA navigation system.
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
|
||||||
|
Register a page with a route pattern and an init function:
|
||||||
|
|
||||||
|
```go
|
||||||
|
v.Page("/", func(c *via.Context) {
|
||||||
|
c.View(func() h.H {
|
||||||
|
return h.H1(h.Text("Home"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Routes use Go's standard `net/http.ServeMux` patterns. Via registers each page as a `GET` handler.
|
||||||
|
|
||||||
|
> **Gotcha:** Via runs every page init function at registration time (in a `defer/recover` block) to catch panics early. If your init function panics — e.g. by forgetting `c.View()` — the app crashes at startup, not at request time.
|
||||||
|
|
||||||
|
## Path Parameters
|
||||||
|
|
||||||
|
Use `{param}` syntax in route patterns:
|
||||||
|
|
||||||
|
```go
|
||||||
|
v.Page("/users/{id}/posts/{post_id}", func(c *via.Context) {
|
||||||
|
userID := c.GetPathParam("id")
|
||||||
|
postID := c.GetPathParam("post_id")
|
||||||
|
|
||||||
|
c.View(func() h.H {
|
||||||
|
return h.P(h.Textf("User %s, Post %s", userID, postID))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
`GetPathParam` returns an empty string if the parameter doesn't exist.
|
||||||
|
|
||||||
|
## Route Groups
|
||||||
|
|
||||||
|
Group pages under a shared prefix with shared middleware:
|
||||||
|
|
||||||
|
```go
|
||||||
|
admin := v.Group("/admin", authRequired)
|
||||||
|
admin.Page("/dashboard", dashboardHandler) // route: /admin/dashboard
|
||||||
|
admin.Page("/settings", settingsHandler) // route: /admin/settings
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nesting
|
||||||
|
|
||||||
|
Groups nest — the child inherits the parent's prefix and middleware:
|
||||||
|
|
||||||
|
```go
|
||||||
|
admin := v.Group("/admin", authRequired)
|
||||||
|
admin.Use(auditLog) // add middleware after creation
|
||||||
|
|
||||||
|
superAdmin := admin.Group("/super", superAdminOnly)
|
||||||
|
superAdmin.Page("/nuke", nukeHandler) // route: /admin/super/nuke
|
||||||
|
// middleware order: global → authRequired → auditLog → superAdminOnly → handler
|
||||||
|
```
|
||||||
|
|
||||||
|
### Empty prefix
|
||||||
|
|
||||||
|
Use an empty prefix when you need shared middleware without a path prefix:
|
||||||
|
|
||||||
|
```go
|
||||||
|
protected := v.Group("", authRequired)
|
||||||
|
protected.Page("/dashboard", dashboardHandler) // route: /dashboard
|
||||||
|
protected.Page("/profile", profileHandler) // route: /profile
|
||||||
|
```
|
||||||
|
|
||||||
|
## Middleware
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Middleware func(c *Context, next func())
|
||||||
|
```
|
||||||
|
|
||||||
|
Call `next()` to continue the chain. Return without calling `next()` to abort — but set a view first.
|
||||||
|
|
||||||
|
```go
|
||||||
|
func authRequired(c *via.Context, next func()) {
|
||||||
|
if c.Session().GetString("username") == "" {
|
||||||
|
c.Session().Set("flash", "Please log in")
|
||||||
|
c.RedirectView("/login")
|
||||||
|
return // don't call next — chain is aborted
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Gotcha:** Use `c.RedirectView()` in middleware, not `c.Redirect()`. The SSE connection isn't open yet during the initial page load, so `Redirect()` (which sends a patch over SSE) won't work. `RedirectView()` sets the view to one that triggers a redirect once SSE connects.
|
||||||
|
|
||||||
|
### Three levels
|
||||||
|
|
||||||
|
| Level | Registration | Scope |
|
||||||
|
|-------|-------------|-------|
|
||||||
|
| Global | `v.Use(mw...)` | Every page |
|
||||||
|
| Group | `v.Group(prefix, mw...)` or `g.Use(mw...)` | Pages in the group |
|
||||||
|
| Action | `c.Action(fn, via.WithMiddleware(mw...))` | A single action endpoint |
|
||||||
|
|
||||||
|
### Execution order
|
||||||
|
|
||||||
|
Middleware runs in registration order: global first, then group, then the handler.
|
||||||
|
|
||||||
|
```go
|
||||||
|
v.Use(logger) // 1st
|
||||||
|
admin := v.Group("/admin", auth) // 2nd
|
||||||
|
admin.Use(audit) // 3rd
|
||||||
|
admin.Page("/x", handler) // 4th
|
||||||
|
// execution: logger → auth → audit → handler
|
||||||
|
```
|
||||||
|
|
||||||
|
Action-level middleware runs after CSRF validation and rate limiting, when the action endpoint is invoked.
|
||||||
|
|
||||||
|
## SPA Navigation
|
||||||
|
|
||||||
|
Via intercepts same-origin link clicks and navigates without a full page reload. The SSE connection persists, and the new page's view is morphed into the DOM with a view transition.
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
1. `navigate.js` (embedded in every page) intercepts clicks on `<a>` elements.
|
||||||
|
2. For same-origin links, it POSTs to `/_navigate` with the context ID, CSRF token, and target URL.
|
||||||
|
3. The server calls `c.Navigate()`, which:
|
||||||
|
- Resets page state (stops intervals, unsubscribes PubSub, clears signals/actions/fields)
|
||||||
|
- Runs the target page's init function (with middleware) on the **same context**
|
||||||
|
- Pushes the new view via SSE with a view transition
|
||||||
|
- Updates the browser URL via `history.pushState()`
|
||||||
|
|
||||||
|
### What gets cleaned up on navigate
|
||||||
|
|
||||||
|
- Intervals stop (via `pageStopChan`)
|
||||||
|
- PubSub subscriptions are unsubscribed
|
||||||
|
- Signals, actions, and fields are cleared
|
||||||
|
- The new page starts completely fresh
|
||||||
|
|
||||||
|
The SSE connection and the context itself survive. This is what makes it an SPA — the existing stream is reused.
|
||||||
|
|
||||||
|
### Layouts
|
||||||
|
|
||||||
|
Define a layout to provide persistent chrome (nav bars, sidebars) that wraps every page:
|
||||||
|
|
||||||
|
```go
|
||||||
|
v.Layout(func(content func() h.H) h.H {
|
||||||
|
return h.Div(
|
||||||
|
h.Nav(
|
||||||
|
h.A(h.Href("/"), h.Text("Home")),
|
||||||
|
h.A(h.Href("/counter"), h.Text("Counter")),
|
||||||
|
h.A(h.Href("/clock"), h.Text("Clock")),
|
||||||
|
),
|
||||||
|
h.Main(content()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
The `content` parameter is the page's view function. During SPA navigation, the entire layout + content is re-rendered and morphed — Datastar's morph algorithm (idiomorph) efficiently updates only the changed parts, so the nav bar stays visually stable while the main content transitions.
|
||||||
|
|
||||||
|
> **Gotcha:** Layout state does not persist across navigations in the way page state doesn't — the layout is re-rendered from scratch each time. If you need state that survives navigation (like a selected nav item), derive it from the current route rather than storing it in a variable.
|
||||||
|
|
||||||
|
### View transitions
|
||||||
|
|
||||||
|
Animate elements across page navigations using the browser View Transitions API:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// On the home page:
|
||||||
|
h.H1(h.Text("Home"), h.DataViewTransition("page-title"))
|
||||||
|
|
||||||
|
// On the counter page:
|
||||||
|
h.H1(h.Text("Counter"), h.DataViewTransition("page-title"))
|
||||||
|
```
|
||||||
|
|
||||||
|
Elements with matching `view-transition-name` values animate smoothly during SPA navigation. `DataViewTransition` sets the CSS `view-transition-name` as an inline `style` attribute. If the element also needs other inline styles, set `view-transition-name` directly in a `Style()` call instead.
|
||||||
|
|
||||||
|
Via automatically includes the `<meta name="view-transition" content="same-origin">` tag to enable the API.
|
||||||
|
|
||||||
|
### Opting out
|
||||||
|
|
||||||
|
Add `data-via-no-boost` to links that should trigger a full page reload:
|
||||||
|
|
||||||
|
```go
|
||||||
|
h.A(h.Href("/"), h.Text("Full Reload"), h.Attr("data-via-no-boost"))
|
||||||
|
```
|
||||||
|
|
||||||
|
Links are also auto-ignored when:
|
||||||
|
- They have a `target` attribute (e.g. `target="_blank"`)
|
||||||
|
- Modifier keys are held (Ctrl, Meta, Shift, Alt)
|
||||||
|
- The `href` starts with `#` or is cross-origin
|
||||||
|
- The `href` is missing
|
||||||
|
|
||||||
|
### Programmatic navigation
|
||||||
|
|
||||||
|
Trigger SPA navigation from an action handler:
|
||||||
|
|
||||||
|
```go
|
||||||
|
goCounter := c.Action(func() {
|
||||||
|
c.Navigate("/counter", false)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
The second parameter controls history behavior: `false` for `pushState` (normal navigation), `true` for `replaceState` (back/forward).
|
||||||
|
|
||||||
|
If the path doesn't match any registered route, `Navigate` falls back to `c.Redirect()` (full page navigation).
|
||||||
|
|
||||||
|
### DataIgnoreMorph
|
||||||
|
|
||||||
|
Prevent Datastar from overwriting an element during morph:
|
||||||
|
|
||||||
|
```go
|
||||||
|
h.Div(h.ID("toast-container"), h.DataIgnoreMorph())
|
||||||
|
```
|
||||||
|
|
||||||
|
The element and its subtree are skipped during DOM patches. Useful for elements with client-side state: a focused input, an animation, a third-party widget, or a toast notification container.
|
||||||
|
|
||||||
|
## Custom HTTP Handlers
|
||||||
|
|
||||||
|
Access the underlying mux for non-Via routes (APIs, webhooks, health checks):
|
||||||
|
|
||||||
|
```go
|
||||||
|
mux := v.HTTPServeMux()
|
||||||
|
mux.HandleFunc("GET /api/health", healthHandler)
|
||||||
|
mux.HandleFunc("POST /api/webhook", webhookHandler)
|
||||||
|
```
|
||||||
|
|
||||||
|
Register before `v.Start()`. These routes bypass Via's context/SSE system entirely.
|
||||||
313
docs/state-and-interactivity.md
Normal file
313
docs/state-and-interactivity.md
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
# State and Interactivity
|
||||||
|
|
||||||
|
This is the core reactive model — signals, actions, views, components, and validation.
|
||||||
|
|
||||||
|
## Context Lifecycle
|
||||||
|
|
||||||
|
A `*Context` is created per browser visit. It holds all page state: signals, actions, fields, subscriptions, and the view function.
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser hits page → new Context created → init function runs → HTML rendered
|
||||||
|
↓
|
||||||
|
SSE connection opens ← browser loads page
|
||||||
|
↓
|
||||||
|
action fires → signals injected from browser → handler runs → Sync() → DOM patched
|
||||||
|
```
|
||||||
|
|
||||||
|
The context is disposed when the SSE connection closes (tab close, navigation away, network loss). A background reaper also cleans up contexts that never establish an SSE connection within `ContextTTL` (default 30s).
|
||||||
|
|
||||||
|
During [SPA navigation](routing-and-navigation.md#spa-navigation), the context itself survives — only page-level state (signals, actions, fields, intervals, subscriptions) is reset. The SSE connection persists.
|
||||||
|
|
||||||
|
## Signals
|
||||||
|
|
||||||
|
Signals are reactive values synchronized between server and browser. Create one with an initial value:
|
||||||
|
|
||||||
|
```go
|
||||||
|
name := c.Signal("world")
|
||||||
|
count := c.Signal(0)
|
||||||
|
items := c.Signal([]string{"a", "b"})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reading values
|
||||||
|
|
||||||
|
```go
|
||||||
|
name.String() // "world"
|
||||||
|
count.Int() // 0
|
||||||
|
count.Bool() // false (parses "true", "1", "yes", "on")
|
||||||
|
```
|
||||||
|
|
||||||
|
Signal values come from the browser. Before every action call, the browser sends all current signal values to the server. You always read the latest browser state inside action handlers.
|
||||||
|
|
||||||
|
### Writing values
|
||||||
|
|
||||||
|
```go
|
||||||
|
name.SetValue("Via")
|
||||||
|
c.SyncSignals() // push only changed signals to browser
|
||||||
|
// or
|
||||||
|
c.Sync() // re-render view AND push changed signals
|
||||||
|
```
|
||||||
|
|
||||||
|
`SetValue` marks the signal as changed. The change is not sent to the browser until you call `Sync()` or `SyncSignals()`.
|
||||||
|
|
||||||
|
### Rendering in the view
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Two-way binding on an input — browser edits update the signal
|
||||||
|
h.Input(h.Type("text"), name.Bind())
|
||||||
|
|
||||||
|
// Reactive text display — updates when the signal changes
|
||||||
|
h.Span(name.Text())
|
||||||
|
|
||||||
|
// Read value at render time — static until next Sync()
|
||||||
|
h.P(h.Textf("Count: %d", count.Int()))
|
||||||
|
```
|
||||||
|
|
||||||
|
`Bind()` outputs a `data-bind` attribute for two-way binding. `Text()` outputs a `<span data-text="$signalID">` for reactive display.
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
|
||||||
|
Actions are server-side event handlers. They run on the server when triggered by a browser event.
|
||||||
|
|
||||||
|
```go
|
||||||
|
submit := c.Action(func() {
|
||||||
|
// signals are already injected — read them here
|
||||||
|
fmt.Println(name.String())
|
||||||
|
count.SetValue(count.Int() + 1)
|
||||||
|
c.Sync()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Trigger methods
|
||||||
|
|
||||||
|
Attach an action to a DOM event by calling a trigger method in the view:
|
||||||
|
|
||||||
|
```go
|
||||||
|
h.Button(h.Text("Submit"), submit.OnClick())
|
||||||
|
h.Input(name.Bind(), submit.OnKeyDown("Enter"))
|
||||||
|
h.Select(category.Bind(), filter.OnChange())
|
||||||
|
h.Form(submit.OnSubmit())
|
||||||
|
```
|
||||||
|
|
||||||
|
Available triggers:
|
||||||
|
|
||||||
|
| Method | Event | Notes |
|
||||||
|
|--------|-------|-------|
|
||||||
|
| `OnClick()` | `click` | |
|
||||||
|
| `OnDblClick()` | `dblclick` | |
|
||||||
|
| `OnChange()` | `change` | 200ms debounce |
|
||||||
|
| `OnInput()` | `input` | No debounce |
|
||||||
|
| `OnSubmit()` | `submit` | |
|
||||||
|
| `OnKeyDown(key)` | `keydown` | Filtered by key name (e.g. `"Enter"`, `"Escape"`) |
|
||||||
|
| `OnFocus()` | `focus` | |
|
||||||
|
| `OnBlur()` | `blur` | |
|
||||||
|
| `OnMouseEnter()` | `mouseenter` | |
|
||||||
|
| `OnMouseLeave()` | `mouseleave` | |
|
||||||
|
| `OnScroll()` | `scroll` | |
|
||||||
|
|
||||||
|
### Trigger options
|
||||||
|
|
||||||
|
Every trigger method accepts `ActionTriggerOption` values:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Set a signal value before the action fires
|
||||||
|
submit.OnClick(via.WithSignal(mode, "delete"))
|
||||||
|
submit.OnClick(via.WithSignalInt(page, 3))
|
||||||
|
|
||||||
|
// Listen on window instead of the element
|
||||||
|
submit.OnKeyDown("Escape", via.WithWindow())
|
||||||
|
|
||||||
|
// Prevent browser default behavior
|
||||||
|
submit.OnKeyDown("ArrowDown", via.WithPreventDefault())
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-key dispatch
|
||||||
|
|
||||||
|
`OnKeyDownMap` binds multiple keys to different actions in a single attribute:
|
||||||
|
|
||||||
|
```go
|
||||||
|
via.OnKeyDownMap(
|
||||||
|
via.KeyBind("w", move, via.WithSignal(dir, "up")),
|
||||||
|
via.KeyBind("s", move, via.WithSignal(dir, "down")),
|
||||||
|
via.KeyBind("ArrowUp", move, via.WithSignal(dir, "up"), via.WithPreventDefault()),
|
||||||
|
via.KeyBind("ArrowDown", move, via.WithSignal(dir, "down"), via.WithPreventDefault()),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
This produces a single `data-on:keydown__window` attribute. Place it on any element in the view.
|
||||||
|
|
||||||
|
### Action options
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Per-action rate limiting (overrides the context-level default)
|
||||||
|
c.Action(handler, via.WithRateLimit(5, 10))
|
||||||
|
|
||||||
|
// Per-action middleware (runs after CSRF and rate-limit checks)
|
||||||
|
c.Action(handler, via.WithMiddleware(requireAdmin))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Views and Sync
|
||||||
|
|
||||||
|
Every page handler must call `c.View()` to define the UI:
|
||||||
|
|
||||||
|
```go
|
||||||
|
c.View(func() h.H {
|
||||||
|
return h.Div(
|
||||||
|
h.P(h.Textf("Hello, %s!", name.String())),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Gotcha:** If you forget `c.View()`, the app panics at startup during route registration — not at request time.
|
||||||
|
|
||||||
|
The view function is re-evaluated on every `c.Sync()`. The resulting HTML is pushed to the browser via SSE, where Datastar morphs the DOM.
|
||||||
|
|
||||||
|
### Sync variants
|
||||||
|
|
||||||
|
| Method | What it sends |
|
||||||
|
|--------|---------------|
|
||||||
|
| `c.Sync()` | Re-renders the view HTML **and** pushes changed signals |
|
||||||
|
| `c.SyncSignals()` | Pushes only changed signals, no view re-render |
|
||||||
|
| `c.SyncElements(elem...)` | Pushes specific HTML elements to merge into the DOM. Each element **must have an ID** matching an existing DOM element |
|
||||||
|
| `c.ExecScript(js)` | Sends JavaScript for the browser to execute (auto-removed after execution) |
|
||||||
|
|
||||||
|
Use `SyncSignals()` when only signal values changed and the view structure is the same. Use `SyncElements()` for targeted updates without re-rendering the entire view. Use `ExecScript()` to interact with client-side libraries (e.g. pushing data to a chart).
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
Extract reusable UI with `c.Component()`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func counterFn(c *via.Context) {
|
||||||
|
count := 0
|
||||||
|
step := c.Signal(1)
|
||||||
|
|
||||||
|
increment := c.Action(func() {
|
||||||
|
count += step.Int()
|
||||||
|
c.Sync()
|
||||||
|
})
|
||||||
|
|
||||||
|
c.View(func() h.H {
|
||||||
|
return h.Div(
|
||||||
|
h.P(h.Textf("Count: %d", count)),
|
||||||
|
h.Input(h.Type("number"), step.Bind()),
|
||||||
|
h.Button(h.Text("+"), increment.OnClick()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// In a page:
|
||||||
|
v.Page("/", func(c *via.Context) {
|
||||||
|
counter1 := c.Component(counterFn)
|
||||||
|
counter2 := c.Component(counterFn)
|
||||||
|
|
||||||
|
c.View(func() h.H {
|
||||||
|
return h.Div(
|
||||||
|
h.H2(h.Text("Counter 1")), counter1(),
|
||||||
|
h.H2(h.Text("Counter 2")), counter2(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Each component instance gets its own closure state, but signals, actions, and fields are registered on the parent page context. Components share the parent's SSE stream — `c.Sync()` from a component re-renders the entire page view.
|
||||||
|
|
||||||
|
## Fields and Validation
|
||||||
|
|
||||||
|
Fields are signals with validation rules. Use them for form inputs:
|
||||||
|
|
||||||
|
```go
|
||||||
|
username := c.Field("", via.Required(), via.MinLen(3), via.MaxLen(20))
|
||||||
|
email := c.Field("", via.Required(), via.Email())
|
||||||
|
age := c.Field("", via.Required(), via.Min(13), via.Max(120))
|
||||||
|
website := c.Field("", via.Pattern(`^https?://`, "Must start with http:// or https://"))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Built-in rules
|
||||||
|
|
||||||
|
| Rule | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `Required(msg...)` | Rejects empty/whitespace-only values |
|
||||||
|
| `MinLen(n, msg...)` | Minimum character count (Unicode-aware) |
|
||||||
|
| `MaxLen(n, msg...)` | Maximum character count (Unicode-aware) |
|
||||||
|
| `Min(n, msg...)` | Minimum numeric value (parsed as int) |
|
||||||
|
| `Max(n, msg...)` | Maximum numeric value (parsed as int) |
|
||||||
|
| `Email(msg...)` | Email format regex |
|
||||||
|
| `Pattern(re, msg...)` | Custom regex |
|
||||||
|
| `Custom(fn)` | `func(string) error` — return non-nil to fail |
|
||||||
|
|
||||||
|
All rules accept an optional custom error message as the last argument.
|
||||||
|
|
||||||
|
### Using fields in views and actions
|
||||||
|
|
||||||
|
```go
|
||||||
|
submit := c.Action(func() {
|
||||||
|
if !c.ValidateAll() {
|
||||||
|
c.Sync()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Server-side validation
|
||||||
|
if userExists(username.String()) {
|
||||||
|
username.AddError("Username taken")
|
||||||
|
c.Sync()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
createUser(username.String(), email.String())
|
||||||
|
c.ResetFields()
|
||||||
|
c.Sync()
|
||||||
|
})
|
||||||
|
|
||||||
|
c.View(func() h.H {
|
||||||
|
return h.Form(submit.OnSubmit(),
|
||||||
|
h.Input(h.Type("text"), username.Bind(), h.Placeholder("Username")),
|
||||||
|
h.If(username.HasError(), h.Small(h.Text(username.FirstError()))),
|
||||||
|
|
||||||
|
h.Input(h.Type("email"), email.Bind(), h.Placeholder("Email")),
|
||||||
|
h.If(email.HasError(), h.Small(h.Text(email.FirstError()))),
|
||||||
|
|
||||||
|
h.Button(h.Type("submit"), h.Text("Sign Up")),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `field.Validate()` | Run rules, return true if all pass |
|
||||||
|
| `field.HasError()` | True if any validation errors exist |
|
||||||
|
| `field.FirstError()` | First error message, or `""` |
|
||||||
|
| `field.Errors()` | All error messages |
|
||||||
|
| `field.AddError(msg)` | Add a custom server-side error |
|
||||||
|
| `field.ClearErrors()` | Remove all errors |
|
||||||
|
| `field.Reset()` | Restore initial value and clear errors |
|
||||||
|
| `c.ValidateAll(fields...)` | Validate given fields (or all if none specified). Does not short-circuit — all fields get validated so all errors are populated |
|
||||||
|
| `c.ResetFields(fields...)` | Reset given fields (or all if none specified) |
|
||||||
|
|
||||||
|
Fields embed `*signal`, so `Bind()`, `Text()`, `String()`, `Int()`, `Bool()`, `SetValue()`, and `ID()` all work.
|
||||||
|
|
||||||
|
## OnInterval
|
||||||
|
|
||||||
|
Run a function at regular intervals, tied to the page lifecycle:
|
||||||
|
|
||||||
|
```go
|
||||||
|
stop := c.OnInterval(time.Second, func() {
|
||||||
|
now = time.Now()
|
||||||
|
c.Sync()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- Starts immediately — no separate start call needed.
|
||||||
|
- Returns a `func()` that stops the interval (idempotent).
|
||||||
|
- Automatically stops on context disposal (tab close) or SPA navigation away.
|
||||||
|
- Call `c.Sync()` inside the handler to push updates to the browser.
|
||||||
|
|
||||||
|
## Navigation Helpers
|
||||||
|
|
||||||
|
| Method | Effect |
|
||||||
|
|--------|--------|
|
||||||
|
| `c.Redirect(url)` | Full page navigation. Disposes the context, browser loads a new page |
|
||||||
|
| `c.Redirectf(fmt, args...)` | `Redirect` with `fmt.Sprintf` |
|
||||||
|
| `c.RedirectView(url)` | Sets the view to trigger a redirect on SSE connect. Use in [middleware](routing-and-navigation.md#middleware) to abort the chain and redirect |
|
||||||
|
| `c.ReplaceURL(url)` | Updates the browser URL bar without navigation. Useful for reflecting state in query params |
|
||||||
|
| `c.ReplaceURLf(fmt, args...)` | `ReplaceURL` with `fmt.Sprintf` |
|
||||||
|
| `c.Navigate(path, popstate)` | [SPA navigation](routing-and-navigation.md#spa-navigation). Resets page state, runs the target page handler on the same context, pushes the new view with a view transition |
|
||||||
|
|
||||||
|
> **Gotcha:** In middleware, use `c.RedirectView()`, not `c.Redirect()`. `Redirect` sends a patch over SSE, but the SSE connection isn't established yet during the initial page load.
|
||||||
1
go.mod
1
go.mod
@@ -38,6 +38,5 @@ require (
|
|||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
golang.org/x/crypto v0.45.0 // indirect
|
golang.org/x/crypto v0.45.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
golang.org/x/time v0.14.0
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,3 +11,11 @@ func DataEffect(expression string) H {
|
|||||||
func DataIgnoreMorph() H {
|
func DataIgnoreMorph() H {
|
||||||
return Attr("data-ignore-morph")
|
return Attr("data-ignore-morph")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DataViewTransition sets the view-transition-name CSS property on an element
|
||||||
|
// via an inline style. Elements with matching names animate between pages
|
||||||
|
// during SPA navigation. If the element also needs other inline styles,
|
||||||
|
// include view-transition-name directly in the Style() call instead.
|
||||||
|
func DataViewTransition(name string) H {
|
||||||
|
return Attr("style", "view-transition-name: "+name)
|
||||||
|
}
|
||||||
|
|||||||
111
internal/examples/maplibre/main.go
Normal file
111
internal/examples/maplibre/main.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/ryanhamamura/via"
|
||||||
|
"github.com/ryanhamamura/via/h"
|
||||||
|
"github.com/ryanhamamura/via/maplibre"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
v := via.New()
|
||||||
|
v.Config(via.Options{
|
||||||
|
DocumentTitle: "MapLibre GL Example",
|
||||||
|
ServerAddress: ":7331",
|
||||||
|
DevMode: true,
|
||||||
|
Plugins: []via.Plugin{maplibre.Plugin},
|
||||||
|
})
|
||||||
|
|
||||||
|
v.Page("/", func(c *via.Context) {
|
||||||
|
m := maplibre.New(c, maplibre.Options{
|
||||||
|
Style: "https://demotiles.maplibre.org/style.json",
|
||||||
|
Center: maplibre.LngLat{Lng: -122.4194, Lat: 37.7749},
|
||||||
|
Zoom: 10,
|
||||||
|
Height: "500px",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Markers with popups
|
||||||
|
m.AddMarker("sf", maplibre.Marker{
|
||||||
|
LngLat: maplibre.LngLat{Lng: -122.4194, Lat: 37.7749},
|
||||||
|
Color: "#e74c3c",
|
||||||
|
Popup: &maplibre.Popup{
|
||||||
|
Content: "<strong>San Francisco</strong><p>The Golden City</p>",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
m.AddMarker("oak", maplibre.Marker{
|
||||||
|
LngLat: maplibre.LngLat{Lng: -122.2711, Lat: 37.8044},
|
||||||
|
Color: "#2ecc71",
|
||||||
|
Popup: &maplibre.Popup{
|
||||||
|
Content: "<strong>Oakland</strong>",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// GeoJSON polygon source + fill layer
|
||||||
|
m.AddSource("park", maplibre.GeoJSONSource{
|
||||||
|
Data: map[string]any{
|
||||||
|
"type": "Feature",
|
||||||
|
"geometry": map[string]any{
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": []any{[]any{
|
||||||
|
[]float64{-122.4547, 37.7654},
|
||||||
|
[]float64{-122.4547, 37.7754},
|
||||||
|
[]float64{-122.4387, 37.7754},
|
||||||
|
[]float64{-122.4387, 37.7654},
|
||||||
|
[]float64{-122.4547, 37.7654},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
"properties": map[string]any{
|
||||||
|
"name": "Golden Gate Park",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
m.AddLayer(maplibre.Layer{
|
||||||
|
ID: "park-fill",
|
||||||
|
Type: "fill",
|
||||||
|
Source: "park",
|
||||||
|
Paint: map[string]any{
|
||||||
|
"fill-color": "#2ecc71",
|
||||||
|
"fill-opacity": 0.3,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Viewport info signal (updated on action)
|
||||||
|
viewportInfo := c.Signal("")
|
||||||
|
|
||||||
|
// FlyTo action
|
||||||
|
flyToSF := c.Action(func() {
|
||||||
|
m.FlyTo(maplibre.LngLat{Lng: -122.4194, Lat: 37.7749}, 14)
|
||||||
|
})
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
|
||||||
|
c.View(func() h.H {
|
||||||
|
return h.Div(
|
||||||
|
h.Div(
|
||||||
|
h.Attr("style", "max-width:960px;margin:0 auto;padding:1rem;font-family:sans-serif"),
|
||||||
|
h.H1(h.Text("MapLibre GL Example")),
|
||||||
|
m.Element(),
|
||||||
|
h.Div(h.Attr("style", "margin-top:1rem;display:flex;gap:0.5rem"),
|
||||||
|
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()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
v.Start()
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
A chatroom built with Via and an **embedded NATS server**, demonstrating pub/sub messaging as an alternative to the custom `Rooms` implementation in `../chatroom`.
|
A chatroom built with Via and an **embedded NATS server**, demonstrating pub/sub messaging as an alternative to the custom `Rooms` implementation in `../chatroom`.
|
||||||
|
|
||||||
Uses `delaneyj/toolbelt/embeddednats` to run NATS inside the same binary - no external server required.
|
Via includes an embedded NATS server that starts automatically — no external server required.
|
||||||
|
|
||||||
## Key Differences from Original Chatroom
|
## Key Differences from Original Chatroom
|
||||||
|
|
||||||
@@ -25,21 +25,6 @@ That's it. No separate NATS server needed.
|
|||||||
|
|
||||||
Open multiple browser tabs at http://localhost:7331 to see messages broadcast across all clients.
|
Open multiple browser tabs at http://localhost:7331 to see messages broadcast across all clients.
|
||||||
|
|
||||||
## How Embedded NATS Works
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Start embedded NATS server (JetStream enabled by default)
|
|
||||||
ns, err := embeddednats.New(ctx,
|
|
||||||
embeddednats.WithDirectory("./data/nats"),
|
|
||||||
)
|
|
||||||
ns.WaitForServer()
|
|
||||||
|
|
||||||
// Get client connection to embedded server
|
|
||||||
nc, err := ns.Client()
|
|
||||||
```
|
|
||||||
|
|
||||||
Data is persisted to `./data/nats/` for JetStream durability.
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -65,14 +50,16 @@ Data is persisted to `./data/nats/` for JetStream durability.
|
|||||||
|
|
||||||
## JetStream Durability
|
## JetStream Durability
|
||||||
|
|
||||||
Messages persist to disk via JetStream:
|
Messages persist to disk via JetStream. Streams are declared in `Options.Streams` and created automatically when `v.Start()` initializes the embedded NATS server:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
js.AddStream(&nats.StreamConfig{
|
v.Config(via.Options{
|
||||||
Name: "CHAT",
|
Streams: []via.StreamConfig{{
|
||||||
Subjects: []string{"chat.>"},
|
Name: "CHAT",
|
||||||
MaxMsgs: 1000, // Keep last 1000 messages
|
Subjects: []string{"chat.>"},
|
||||||
MaxAge: 24 * time.Hour,
|
MaxMsgs: 1000,
|
||||||
|
MaxAge: 24 * time.Hour,
|
||||||
|
}},
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -87,23 +74,6 @@ Stop and restart the app - chat history survives.
|
|||||||
- Manual join/leave channels
|
- Manual join/leave channels
|
||||||
|
|
||||||
**This example - ~60 lines of NATS integration:**
|
**This example - ~60 lines of NATS integration:**
|
||||||
- `embeddednats.New()` starts the server
|
- `via.Subscribe(c, subject, handler)` for receiving
|
||||||
- `nc.Subscribe(subject, handler)` for receiving
|
- `via.Publish(c, subject, data)` for sending
|
||||||
- `nc.Publish(subject, data)` for sending
|
- Streams declared in `Options` — NATS handles delivery, no polling
|
||||||
- NATS handles delivery, no polling
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
If this pattern proves useful, it could be promoted to a Via plugin:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Hypothetical future API
|
|
||||||
v.Config(via.WithEmbeddedNATS("./data/nats"))
|
|
||||||
|
|
||||||
// In page init
|
|
||||||
c.Subscribe("events.user.*", func(data []byte) {
|
|
||||||
c.Sync()
|
|
||||||
})
|
|
||||||
|
|
||||||
c.Publish("events.user.login", userData)
|
|
||||||
```
|
|
||||||
|
|||||||
153
internal/examples/nats-chatroom/chat.css
Normal file
153
internal/examples/nats-chatroom/chat.css
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
body { margin: 0; }
|
||||||
|
|
||||||
|
/* Layout navbar */
|
||||||
|
.app-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--pico-card-background-color);
|
||||||
|
border-bottom: 1px solid var(--pico-muted-border-color);
|
||||||
|
}
|
||||||
|
.app-nav .brand {
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.nav-links a {
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chat page */
|
||||||
|
.chat-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: calc(100vh - 53px);
|
||||||
|
}
|
||||||
|
nav[role="tab-control"] ul li a[aria-current="page"] {
|
||||||
|
background-color: var(--pico-primary-background);
|
||||||
|
color: var(--pico-primary-inverse);
|
||||||
|
border-bottom: 2px solid var(--pico-primary);
|
||||||
|
}
|
||||||
|
.chat-message { display: flex; gap: 0.75rem; margin-bottom: 0.5rem; }
|
||||||
|
.avatar {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--pico-muted-border-color);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.avatar-lg {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
.bubble { flex: 1; }
|
||||||
|
.bubble p { margin: 0; }
|
||||||
|
.chat-history {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
padding-bottom: calc(88px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
.chat-input {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: var(--pico-background-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom));
|
||||||
|
border-top: 1px solid var(--pico-muted-border-color);
|
||||||
|
}
|
||||||
|
.chat-input fieldset {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* NATS badge with status dot */
|
||||||
|
.nats-badge {
|
||||||
|
background: #27AAE1;
|
||||||
|
color: white;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #4ade80;
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Profile page */
|
||||||
|
.profile-page {
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
.profile-preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--pico-card-background-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.preview-name {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.profile-form label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.emoji-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(10, 1fr);
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.emoji-option {
|
||||||
|
padding: 0.375rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.emoji-option:hover {
|
||||||
|
background: var(--pico-muted-border-color);
|
||||||
|
}
|
||||||
|
.emoji-selected {
|
||||||
|
border-color: var(--pico-primary);
|
||||||
|
background: var(--pico-primary-focus);
|
||||||
|
}
|
||||||
|
.profile-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.field-error {
|
||||||
|
color: var(--pico-del-color);
|
||||||
|
}
|
||||||
148
internal/examples/nats-chatroom/chat.go
Normal file
148
internal/examples/nats-chatroom/chat.go
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ryanhamamura/via"
|
||||||
|
"github.com/ryanhamamura/via/h"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
WithSignal = via.WithSignal
|
||||||
|
)
|
||||||
|
|
||||||
|
func ChatPage(c *via.Context) {
|
||||||
|
currentUser := UserInfo{
|
||||||
|
Name: c.Session().GetString(SessionKeyUsername),
|
||||||
|
Emoji: c.Session().GetString(SessionKeyEmoji),
|
||||||
|
}
|
||||||
|
|
||||||
|
roomSignal := c.Signal("Go")
|
||||||
|
statement := c.Signal("")
|
||||||
|
|
||||||
|
var messages []ChatMessage
|
||||||
|
var messagesMu sync.Mutex
|
||||||
|
currentRoom := "Go"
|
||||||
|
|
||||||
|
var currentSub via.Subscription
|
||||||
|
|
||||||
|
subscribeToRoom := func(room string) {
|
||||||
|
if currentSub != nil {
|
||||||
|
currentSub.Unsubscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
subject := "chat.room." + room
|
||||||
|
|
||||||
|
if hist, err := via.ReplayHistory[ChatMessage](v, subject, 50); err == nil {
|
||||||
|
messages = hist
|
||||||
|
}
|
||||||
|
|
||||||
|
sub, _ := via.Subscribe(c, subject, func(msg ChatMessage) {
|
||||||
|
messagesMu.Lock()
|
||||||
|
messages = append(messages, msg)
|
||||||
|
if len(messages) > 50 {
|
||||||
|
messages = messages[len(messages)-50:]
|
||||||
|
}
|
||||||
|
messagesMu.Unlock()
|
||||||
|
c.Sync()
|
||||||
|
})
|
||||||
|
currentSub = sub
|
||||||
|
currentRoom = room
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribeToRoom("Go")
|
||||||
|
|
||||||
|
// Heartbeat — keeps connected indicator alive
|
||||||
|
connected := true
|
||||||
|
c.OnInterval(30*time.Second, func() {
|
||||||
|
connected = true
|
||||||
|
c.Sync()
|
||||||
|
})
|
||||||
|
|
||||||
|
switchRoom := c.Action(func() {
|
||||||
|
newRoom := roomSignal.String()
|
||||||
|
if newRoom != currentRoom {
|
||||||
|
messagesMu.Lock()
|
||||||
|
messages = nil
|
||||||
|
messagesMu.Unlock()
|
||||||
|
subscribeToRoom(newRoom)
|
||||||
|
c.Sync()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
say := c.Action(func() {
|
||||||
|
msg := statement.String()
|
||||||
|
if msg == "" {
|
||||||
|
msg = randomDevQuote()
|
||||||
|
}
|
||||||
|
statement.SetValue("")
|
||||||
|
|
||||||
|
via.Publish(c, "chat.room."+currentRoom, ChatMessage{
|
||||||
|
User: currentUser,
|
||||||
|
Message: msg,
|
||||||
|
Time: time.Now().UnixMilli(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
c.View(func() h.H {
|
||||||
|
var tabs []h.H
|
||||||
|
for _, name := range roomNames {
|
||||||
|
isCurrent := name == currentRoom
|
||||||
|
tabs = append(tabs, h.Li(
|
||||||
|
h.A(
|
||||||
|
h.If(isCurrent, h.Attr("aria-current", "page")),
|
||||||
|
h.Text(name),
|
||||||
|
switchRoom.OnClick(WithSignal(roomSignal, name)),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
messagesMu.Lock()
|
||||||
|
chatHistoryChildren := []h.H{
|
||||||
|
h.Class("chat-history"),
|
||||||
|
h.Script(h.Raw(`new MutationObserver(()=>scrollChatToBottom()).observe(document.querySelector('.chat-history'), {childList:true})`)),
|
||||||
|
}
|
||||||
|
for _, msg := range messages {
|
||||||
|
chatHistoryChildren = append(chatHistoryChildren,
|
||||||
|
h.Div(h.Class("chat-message"),
|
||||||
|
h.Div(h.Class("avatar"), h.Attr("title", msg.User.Name), h.Text(msg.User.Emoji)),
|
||||||
|
h.Div(h.Class("bubble"),
|
||||||
|
h.P(h.Text(msg.Message)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
messagesMu.Unlock()
|
||||||
|
|
||||||
|
_ = connected
|
||||||
|
|
||||||
|
return h.Div(h.Class("chat-page"),
|
||||||
|
h.Nav(
|
||||||
|
h.Attr("role", "tab-control"),
|
||||||
|
h.Ul(tabs...),
|
||||||
|
h.Span(h.Class("nats-badge"),
|
||||||
|
h.Span(h.Class("status-dot")),
|
||||||
|
h.Text("NATS"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
h.Div(chatHistoryChildren...),
|
||||||
|
h.Div(
|
||||||
|
h.Class("chat-input"),
|
||||||
|
h.DataIgnoreMorph(),
|
||||||
|
currentUser.Avatar(),
|
||||||
|
h.FieldSet(
|
||||||
|
h.Attr("role", "group"),
|
||||||
|
h.Input(
|
||||||
|
h.Type("text"),
|
||||||
|
h.Placeholder(currentUser.Name+" says..."),
|
||||||
|
statement.Bind(),
|
||||||
|
h.Attr("autofocus"),
|
||||||
|
say.OnKeyDown("Enter"),
|
||||||
|
),
|
||||||
|
h.Button(h.Text("Send"), say.OnClick()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,126 +1,37 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
_ "embed"
|
||||||
"log"
|
"log"
|
||||||
"math/rand"
|
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ryanhamamura/via"
|
"github.com/ryanhamamura/via"
|
||||||
"github.com/ryanhamamura/via/h"
|
"github.com/ryanhamamura/via/h"
|
||||||
"github.com/ryanhamamura/via/vianats"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
//go:embed chat.css
|
||||||
WithSignal = via.WithSignal
|
var chatCSS string
|
||||||
)
|
|
||||||
|
|
||||||
// ChatMessage represents a message in a chat room
|
var v *via.V
|
||||||
type ChatMessage struct {
|
|
||||||
User UserInfo `json:"user"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Time int64 `json:"time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserInfo identifies a chat participant
|
|
||||||
type UserInfo struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Emoji string `json:"emoji"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *UserInfo) Avatar() h.H {
|
|
||||||
return h.Div(h.Class("avatar"), h.Attr("title", u.Name), h.Text(u.Emoji))
|
|
||||||
}
|
|
||||||
|
|
||||||
var roomNames = []string{"Go", "Rust", "Python", "JavaScript", "Clojure"}
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
ctx := context.Background()
|
v = via.New()
|
||||||
|
|
||||||
ps, err := vianats.New(ctx, "./data/nats")
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to start embedded NATS: %v", err)
|
|
||||||
}
|
|
||||||
defer ps.Close()
|
|
||||||
|
|
||||||
err = vianats.EnsureStream(ps, vianats.StreamConfig{
|
|
||||||
Name: "CHAT",
|
|
||||||
Subjects: []string{"chat.>"},
|
|
||||||
MaxMsgs: 1000,
|
|
||||||
MaxAge: 24 * time.Hour,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to ensure stream: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
v := via.New()
|
|
||||||
v.Config(via.Options{
|
v.Config(via.Options{
|
||||||
DevMode: true,
|
DevMode: true,
|
||||||
DocumentTitle: "NATS Chat",
|
DocumentTitle: "NATS Chat",
|
||||||
LogLevel: via.LogLevelInfo,
|
LogLevel: via.LogLevelInfo,
|
||||||
ServerAddress: ":7331",
|
ServerAddress: ":7331",
|
||||||
PubSub: ps,
|
Streams: []via.StreamConfig{{
|
||||||
|
Name: "CHAT",
|
||||||
|
Subjects: []string{"chat.>"},
|
||||||
|
MaxMsgs: 1000,
|
||||||
|
MaxAge: 24 * time.Hour,
|
||||||
|
}},
|
||||||
})
|
})
|
||||||
|
|
||||||
v.AppendToHead(
|
v.AppendToHead(
|
||||||
h.Link(h.Rel("stylesheet"), h.Href("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css")),
|
h.Link(h.Rel("stylesheet"), h.Href("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css")),
|
||||||
h.StyleEl(h.Raw(`
|
h.StyleEl(h.Raw(chatCSS)),
|
||||||
body { margin: 0; }
|
|
||||||
main {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100vh;
|
|
||||||
}
|
|
||||||
nav[role="tab-control"] ul li a[aria-current="page"] {
|
|
||||||
background-color: var(--pico-primary-background);
|
|
||||||
color: var(--pico-primary-inverse);
|
|
||||||
border-bottom: 2px solid var(--pico-primary);
|
|
||||||
}
|
|
||||||
.chat-message { display: flex; gap: 0.75rem; margin-bottom: 0.5rem; }
|
|
||||||
.avatar {
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--pico-muted-border-color);
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.bubble { flex: 1; }
|
|
||||||
.bubble p { margin: 0; }
|
|
||||||
.chat-history {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 1rem;
|
|
||||||
padding-bottom: calc(88px + env(safe-area-inset-bottom));
|
|
||||||
}
|
|
||||||
.chat-input {
|
|
||||||
position: fixed;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: var(--pico-background-color);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom));
|
|
||||||
border-top: 1px solid var(--pico-muted-border-color);
|
|
||||||
}
|
|
||||||
.chat-input fieldset {
|
|
||||||
flex: 1;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.nats-badge {
|
|
||||||
background: #27AAE1;
|
|
||||||
color: white;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
`)),
|
|
||||||
h.Script(h.Raw(`
|
h.Script(h.Raw(`
|
||||||
function scrollChatToBottom() {
|
function scrollChatToBottom() {
|
||||||
const chatHistory = document.querySelector('.chat-history');
|
const chatHistory = document.querySelector('.chat-history');
|
||||||
@@ -129,156 +40,38 @@ func main() {
|
|||||||
`)),
|
`)),
|
||||||
)
|
)
|
||||||
|
|
||||||
v.Page("/", func(c *via.Context) {
|
v.Layout(func(content func() h.H) h.H {
|
||||||
currentUser := randUser()
|
return h.Div(
|
||||||
roomSignal := c.Signal("Go")
|
h.Nav(h.Class("app-nav"),
|
||||||
statement := c.Signal("")
|
h.A(h.Href("/"), h.Class("brand"), h.Text("NATS Chat")),
|
||||||
|
h.Div(h.Class("nav-links"),
|
||||||
var messages []ChatMessage
|
h.A(h.Href("/"), h.Text("Chat")),
|
||||||
var messagesMu sync.Mutex
|
h.A(h.Href("/profile"), h.Text("Profile")),
|
||||||
currentRoom := "Go"
|
|
||||||
|
|
||||||
var currentSub via.Subscription
|
|
||||||
|
|
||||||
subscribeToRoom := func(room string) {
|
|
||||||
if currentSub != nil {
|
|
||||||
currentSub.Unsubscribe()
|
|
||||||
}
|
|
||||||
|
|
||||||
subject := "chat.room." + room
|
|
||||||
|
|
||||||
// Replay history from JetStream
|
|
||||||
if hist, err := vianats.ReplayHistory[ChatMessage](ps, subject, 50); err == nil {
|
|
||||||
messages = hist
|
|
||||||
}
|
|
||||||
|
|
||||||
sub, _ := via.Subscribe(c, subject, func(msg ChatMessage) {
|
|
||||||
messagesMu.Lock()
|
|
||||||
messages = append(messages, msg)
|
|
||||||
if len(messages) > 50 {
|
|
||||||
messages = messages[len(messages)-50:]
|
|
||||||
}
|
|
||||||
messagesMu.Unlock()
|
|
||||||
c.Sync()
|
|
||||||
})
|
|
||||||
currentSub = sub
|
|
||||||
currentRoom = room
|
|
||||||
}
|
|
||||||
|
|
||||||
subscribeToRoom("Go")
|
|
||||||
|
|
||||||
switchRoom := c.Action(func() {
|
|
||||||
newRoom := roomSignal.String()
|
|
||||||
if newRoom != currentRoom {
|
|
||||||
messagesMu.Lock()
|
|
||||||
messages = nil
|
|
||||||
messagesMu.Unlock()
|
|
||||||
subscribeToRoom(newRoom)
|
|
||||||
c.Sync()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
say := c.Action(func() {
|
|
||||||
msg := statement.String()
|
|
||||||
if msg == "" {
|
|
||||||
msg = randomDevQuote()
|
|
||||||
}
|
|
||||||
statement.SetValue("")
|
|
||||||
|
|
||||||
via.Publish(c, "chat.room."+currentRoom, ChatMessage{
|
|
||||||
User: currentUser,
|
|
||||||
Message: msg,
|
|
||||||
Time: time.Now().UnixMilli(),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
c.View(func() h.H {
|
|
||||||
var tabs []h.H
|
|
||||||
for _, name := range roomNames {
|
|
||||||
isCurrent := name == currentRoom
|
|
||||||
tabs = append(tabs, h.Li(
|
|
||||||
h.A(
|
|
||||||
h.If(isCurrent, h.Attr("aria-current", "page")),
|
|
||||||
h.Text(name),
|
|
||||||
switchRoom.OnClick(WithSignal(roomSignal, name)),
|
|
||||||
),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
messagesMu.Lock()
|
|
||||||
chatHistoryChildren := []h.H{
|
|
||||||
h.Class("chat-history"),
|
|
||||||
h.Script(h.Raw(`new MutationObserver(()=>scrollChatToBottom()).observe(document.querySelector('.chat-history'), {childList:true})`)),
|
|
||||||
}
|
|
||||||
for _, msg := range messages {
|
|
||||||
chatHistoryChildren = append(chatHistoryChildren,
|
|
||||||
h.Div(h.Class("chat-message"),
|
|
||||||
h.Div(h.Class("avatar"), h.Attr("title", msg.User.Name), h.Text(msg.User.Emoji)),
|
|
||||||
h.Div(h.Class("bubble"),
|
|
||||||
h.P(h.Text(msg.Message)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
messagesMu.Unlock()
|
|
||||||
|
|
||||||
return h.Main(h.Class("container"),
|
|
||||||
h.Nav(
|
|
||||||
h.Attr("role", "tab-control"),
|
|
||||||
h.Ul(tabs...),
|
|
||||||
h.Span(h.Class("nats-badge"), h.Text("NATS")),
|
|
||||||
),
|
),
|
||||||
h.Div(chatHistoryChildren...),
|
),
|
||||||
h.Div(
|
h.Main(h.Class("container"),
|
||||||
h.Class("chat-input"),
|
h.DataViewTransition("page-content"),
|
||||||
currentUser.Avatar(),
|
content(),
|
||||||
h.FieldSet(
|
),
|
||||||
h.Attr("role", "group"),
|
)
|
||||||
h.Input(
|
|
||||||
h.Type("text"),
|
|
||||||
h.Placeholder(currentUser.Name+" says..."),
|
|
||||||
statement.Bind(),
|
|
||||||
h.Attr("autofocus"),
|
|
||||||
say.OnKeyDown("Enter"),
|
|
||||||
),
|
|
||||||
h.Button(h.Text("Send"), say.OnClick()),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
log.Println("Starting NATS chatroom on :7331 (embedded NATS server)")
|
// Profile page — public, no auth required
|
||||||
|
v.Page("/profile", ProfilePage)
|
||||||
|
|
||||||
|
// Auth middleware — redirects to profile if no identity set
|
||||||
|
requireProfile := func(c *via.Context, next func()) {
|
||||||
|
if c.Session().GetString(SessionKeyUsername) == "" {
|
||||||
|
c.RedirectView("/profile")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat page — protected by profile middleware
|
||||||
|
protected := v.Group("", requireProfile)
|
||||||
|
protected.Page("/", ChatPage)
|
||||||
|
|
||||||
|
log.Println("Starting NATS chatroom on :7331")
|
||||||
v.Start()
|
v.Start()
|
||||||
}
|
}
|
||||||
|
|
||||||
func randUser() UserInfo {
|
|
||||||
adjectives := []string{"Happy", "Clever", "Brave", "Swift", "Gentle", "Wise", "Bold", "Calm", "Eager", "Fierce"}
|
|
||||||
animals := []string{"Panda", "Tiger", "Eagle", "Dolphin", "Fox", "Wolf", "Bear", "Hawk", "Otter", "Lion"}
|
|
||||||
emojis := []string{"🐼", "🐯", "🦅", "🐬", "🦊", "🐺", "🐻", "🦅", "🦦", "🦁"}
|
|
||||||
|
|
||||||
idx := rand.Intn(len(animals))
|
|
||||||
return UserInfo{
|
|
||||||
Name: adjectives[rand.Intn(len(adjectives))] + " " + animals[idx],
|
|
||||||
Emoji: emojis[idx],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var quoteIdx = rand.Intn(len(devQuotes))
|
|
||||||
var devQuotes = []string{
|
|
||||||
"Just use NATS.",
|
|
||||||
"Pub/sub all the things!",
|
|
||||||
"Messages are the new API.",
|
|
||||||
"JetStream for durability.",
|
|
||||||
"No more polling.",
|
|
||||||
"Event-driven architecture FTW.",
|
|
||||||
"Decouple everything.",
|
|
||||||
"NATS is fast.",
|
|
||||||
"Subjects are like topics.",
|
|
||||||
"Request-reply is cool.",
|
|
||||||
}
|
|
||||||
|
|
||||||
func randomDevQuote() string {
|
|
||||||
quoteIdx = (quoteIdx + 1) % len(devQuotes)
|
|
||||||
return devQuotes[quoteIdx]
|
|
||||||
}
|
|
||||||
|
|||||||
111
internal/examples/nats-chatroom/profile.go
Normal file
111
internal/examples/nats-chatroom/profile.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/ryanhamamura/via"
|
||||||
|
"github.com/ryanhamamura/via/h"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ProfilePage(c *via.Context) {
|
||||||
|
existingName := c.Session().GetString(SessionKeyUsername)
|
||||||
|
existingEmoji := c.Session().GetString(SessionKeyEmoji)
|
||||||
|
if existingEmoji == "" {
|
||||||
|
existingEmoji = emojiChoices[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
nameField := c.Field(existingName,
|
||||||
|
via.Required("Display name is required"),
|
||||||
|
via.MinLen(2, "Must be at least 2 characters"),
|
||||||
|
via.MaxLen(20, "Must be at most 20 characters"),
|
||||||
|
)
|
||||||
|
selectedEmoji := c.Signal(existingEmoji)
|
||||||
|
previewName := c.Computed(func() string {
|
||||||
|
if name := nameField.String(); name != "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return "Your Name"
|
||||||
|
})
|
||||||
|
|
||||||
|
saveToSession := func() bool {
|
||||||
|
if !c.ValidateAll() {
|
||||||
|
c.Sync()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
c.Session().Set(SessionKeyUsername, nameField.String())
|
||||||
|
c.Session().Set(SessionKeyEmoji, selectedEmoji.String())
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
save := c.Action(func() {
|
||||||
|
saveToSession()
|
||||||
|
})
|
||||||
|
|
||||||
|
saveAndChat := c.Action(func() {
|
||||||
|
if saveToSession() {
|
||||||
|
c.Navigate("/", false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
c.View(func() h.H {
|
||||||
|
// Emoji grid
|
||||||
|
emojiGrid := []h.H{h.Class("emoji-grid")}
|
||||||
|
for _, emoji := range emojiChoices {
|
||||||
|
cls := "emoji-option"
|
||||||
|
if emoji == selectedEmoji.String() {
|
||||||
|
cls += " emoji-selected"
|
||||||
|
}
|
||||||
|
emojiGrid = append(emojiGrid,
|
||||||
|
h.Button(
|
||||||
|
h.Class(cls),
|
||||||
|
h.Type("button"),
|
||||||
|
h.Text(emoji),
|
||||||
|
save.OnClick(WithSignal(selectedEmoji, emoji)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action buttons — "Start Chatting" only if editing is meaningful
|
||||||
|
actionButtons := []h.H{h.Class("profile-actions")}
|
||||||
|
if existingName != "" {
|
||||||
|
actionButtons = append(actionButtons,
|
||||||
|
h.Button(h.Text("Save"), save.OnClick(), h.Class("secondary")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
actionButtons = append(actionButtons,
|
||||||
|
h.Button(h.Text("Start Chatting"), saveAndChat.OnClick()),
|
||||||
|
)
|
||||||
|
|
||||||
|
return h.Div(h.Class("profile-page"),
|
||||||
|
h.H2(h.Text("Your Profile"), h.DataViewTransition("page-title")),
|
||||||
|
|
||||||
|
// Live preview
|
||||||
|
h.Div(h.Class("profile-preview"),
|
||||||
|
h.Div(h.Class("avatar avatar-lg"), h.Text(selectedEmoji.String())),
|
||||||
|
h.Span(h.Class("preview-name"), previewName.Text()),
|
||||||
|
),
|
||||||
|
|
||||||
|
h.Div(h.Class("profile-form"),
|
||||||
|
// Name field
|
||||||
|
h.Label(h.Text("Display Name"),
|
||||||
|
h.Input(
|
||||||
|
h.Type("text"),
|
||||||
|
h.Placeholder("Enter a display name"),
|
||||||
|
nameField.Bind(),
|
||||||
|
h.Attr("autofocus"),
|
||||||
|
saveAndChat.OnKeyDown("Enter"),
|
||||||
|
h.If(nameField.HasError(), h.Attr("aria-invalid", "true")),
|
||||||
|
),
|
||||||
|
h.If(nameField.HasError(),
|
||||||
|
h.Small(h.Class("field-error"), h.Text(nameField.FirstError())),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Emoji picker
|
||||||
|
h.Label(h.Text("Choose an Avatar")),
|
||||||
|
h.Div(emojiGrid...),
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
h.Div(actionButtons...),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
30
internal/examples/nats-chatroom/types.go
Normal file
30
internal/examples/nats-chatroom/types.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "github.com/ryanhamamura/via/h"
|
||||||
|
|
||||||
|
const (
|
||||||
|
SessionKeyUsername = "username"
|
||||||
|
SessionKeyEmoji = "emoji"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ChatMessage struct {
|
||||||
|
User UserInfo `json:"user"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Time int64 `json:"time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Emoji string `json:"emoji"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UserInfo) Avatar() h.H {
|
||||||
|
return h.Div(h.Class("avatar"), h.Attr("title", u.Name), h.Text(u.Emoji))
|
||||||
|
}
|
||||||
|
|
||||||
|
var roomNames = []string{"Go", "Rust", "Python", "JavaScript", "Clojure"}
|
||||||
|
|
||||||
|
var emojiChoices = []string{
|
||||||
|
"🐼", "🐯", "🦅", "🐬", "🦊", "🐺", "🐻", "🦦", "🦁", "🐸",
|
||||||
|
"🦄", "🐙", "🦀", "🐝", "🦋", "🐢", "🦉", "🐳", "🦈", "🐧",
|
||||||
|
}
|
||||||
22
internal/examples/nats-chatroom/userdata.go
Normal file
22
internal/examples/nats-chatroom/userdata.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "math/rand"
|
||||||
|
|
||||||
|
var quoteIdx = rand.Intn(len(devQuotes))
|
||||||
|
var devQuotes = []string{
|
||||||
|
"Just use NATS.",
|
||||||
|
"Pub/sub all the things!",
|
||||||
|
"Messages are the new API.",
|
||||||
|
"JetStream for durability.",
|
||||||
|
"No more polling.",
|
||||||
|
"Event-driven architecture FTW.",
|
||||||
|
"Decouple everything.",
|
||||||
|
"NATS is fast.",
|
||||||
|
"Subjects are like topics.",
|
||||||
|
"Request-reply is cool.",
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomDevQuote() string {
|
||||||
|
quoteIdx = (quoteIdx + 1) % len(devQuotes)
|
||||||
|
return devQuotes[quoteIdx]
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
@@ -11,7 +10,6 @@ import (
|
|||||||
|
|
||||||
"github.com/ryanhamamura/via"
|
"github.com/ryanhamamura/via"
|
||||||
"github.com/ryanhamamura/via/h"
|
"github.com/ryanhamamura/via/h"
|
||||||
"github.com/ryanhamamura/via/vianats"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var WithSignal = via.WithSignal
|
var WithSignal = via.WithSignal
|
||||||
@@ -49,31 +47,18 @@ func findBookmark(id string) (Bookmark, int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
ps, err := vianats.New(ctx, "./data/nats")
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to start embedded NATS: %v", err)
|
|
||||||
}
|
|
||||||
defer ps.Close()
|
|
||||||
|
|
||||||
err = vianats.EnsureStream(ps, vianats.StreamConfig{
|
|
||||||
Name: "BOOKMARKS",
|
|
||||||
Subjects: []string{"bookmarks.>"},
|
|
||||||
MaxMsgs: 1000,
|
|
||||||
MaxAge: 24 * time.Hour,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to ensure stream: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
v := via.New()
|
v := via.New()
|
||||||
v.Config(via.Options{
|
v.Config(via.Options{
|
||||||
DevMode: true,
|
DevMode: true,
|
||||||
DocumentTitle: "Bookmarks",
|
DocumentTitle: "Bookmarks",
|
||||||
LogLevel: via.LogLevelInfo,
|
LogLevel: via.LogLevelInfo,
|
||||||
ServerAddress: ":7331",
|
ServerAddress: ":7331",
|
||||||
PubSub: ps,
|
Streams: []via.StreamConfig{{
|
||||||
|
Name: "BOOKMARKS",
|
||||||
|
Subjects: []string{"bookmarks.>"},
|
||||||
|
MaxMsgs: 1000,
|
||||||
|
MaxAge: 24 * time.Hour,
|
||||||
|
}},
|
||||||
})
|
})
|
||||||
|
|
||||||
v.AppendToHead(
|
v.AppendToHead(
|
||||||
@@ -87,6 +72,12 @@ func main() {
|
|||||||
titleSignal := c.Signal("")
|
titleSignal := c.Signal("")
|
||||||
urlSignal := c.Signal("")
|
urlSignal := c.Signal("")
|
||||||
targetIDSignal := c.Signal("")
|
targetIDSignal := c.Signal("")
|
||||||
|
saveLabel := c.Computed(func() string {
|
||||||
|
if targetIDSignal.String() != "" {
|
||||||
|
return "Update Bookmark"
|
||||||
|
}
|
||||||
|
return "Add Bookmark"
|
||||||
|
})
|
||||||
|
|
||||||
via.Subscribe(c, "bookmarks.events", func(evt CRUDEvent) {
|
via.Subscribe(c, "bookmarks.events", func(evt CRUDEvent) {
|
||||||
if evt.UserID == userID {
|
if evt.UserID == userID {
|
||||||
@@ -216,11 +207,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
bookmarksMu.RUnlock()
|
bookmarksMu.RUnlock()
|
||||||
|
|
||||||
saveLabel := "Add Bookmark"
|
|
||||||
if isEditing {
|
|
||||||
saveLabel = "Update Bookmark"
|
|
||||||
}
|
|
||||||
|
|
||||||
return h.Div(h.Class("min-h-screen bg-base-200"),
|
return h.Div(h.Class("min-h-screen bg-base-200"),
|
||||||
// Navbar
|
// Navbar
|
||||||
h.Div(h.Class("navbar bg-base-100 shadow-sm"),
|
h.Div(h.Class("navbar bg-base-100 shadow-sm"),
|
||||||
@@ -236,7 +222,7 @@ func main() {
|
|||||||
// Form card
|
// Form card
|
||||||
h.Div(h.Class("card bg-base-100 shadow"),
|
h.Div(h.Class("card bg-base-100 shadow"),
|
||||||
h.Div(h.Class("card-body"),
|
h.Div(h.Class("card-body"),
|
||||||
h.H2(h.Class("card-title"), h.Text(saveLabel)),
|
h.H2(h.Class("card-title"), saveLabel.Text()),
|
||||||
h.Div(h.Class("flex flex-col gap-2"),
|
h.Div(h.Class("flex flex-col gap-2"),
|
||||||
h.Input(h.Class("input input-bordered w-full"), h.Type("text"), h.Placeholder("Title"), titleSignal.Bind()),
|
h.Input(h.Class("input input-bordered w-full"), h.Type("text"), h.Placeholder("Title"), titleSignal.Bind()),
|
||||||
h.Input(h.Class("input input-bordered w-full"), h.Type("text"), h.Placeholder("https://example.com"), urlSignal.Bind()),
|
h.Input(h.Class("input input-bordered w-full"), h.Type("text"), h.Placeholder("https://example.com"), urlSignal.Bind()),
|
||||||
@@ -244,7 +230,7 @@ func main() {
|
|||||||
h.If(isEditing,
|
h.If(isEditing,
|
||||||
h.Button(h.Class("btn btn-ghost"), h.Text("Cancel"), cancelEdit.OnClick()),
|
h.Button(h.Class("btn btn-ghost"), h.Text("Cancel"), cancelEdit.OnClick()),
|
||||||
),
|
),
|
||||||
h.Button(h.Class("btn btn-primary"), h.Text(saveLabel), save.OnClick()),
|
h.Button(h.Class("btn btn-primary"), saveLabel.Text(), save.OnClick()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -37,29 +37,33 @@ func main() {
|
|||||||
return 1000 / time.Duration(refreshRate.Int()) * time.Millisecond
|
return 1000 / time.Duration(refreshRate.Int()) * time.Millisecond
|
||||||
}
|
}
|
||||||
|
|
||||||
updateData := c.OnInterval(computedTickDuration(), func() {
|
var stopUpdate func()
|
||||||
ts := time.Now().UnixMilli()
|
startInterval := func() {
|
||||||
val := rand.ExpFloat64() * 10
|
stopUpdate = c.OnInterval(computedTickDuration(), func() {
|
||||||
|
ts := time.Now().UnixMilli()
|
||||||
|
val := rand.ExpFloat64() * 10
|
||||||
|
|
||||||
c.ExecScript(fmt.Sprintf(`
|
c.ExecScript(fmt.Sprintf(`
|
||||||
if (myChart) {
|
if (myChart) {
|
||||||
myChart.appendData({seriesIndex: 0, data: [[%d, %f]]});
|
myChart.appendData({seriesIndex: 0, data: [[%d, %f]]});
|
||||||
myChart.setOption({},{notMerge:false,lazyUpdate:true});
|
myChart.setOption({},{notMerge:false,lazyUpdate:true});
|
||||||
};
|
};
|
||||||
`, ts, val))
|
`, ts, val))
|
||||||
})
|
})
|
||||||
updateData.Start()
|
}
|
||||||
|
startInterval()
|
||||||
|
|
||||||
updateRefreshRate := c.Action(func() {
|
updateRefreshRate := c.Action(func() {
|
||||||
updateData.UpdateInterval(computedTickDuration())
|
stopUpdate()
|
||||||
|
startInterval()
|
||||||
})
|
})
|
||||||
|
|
||||||
toggleIsLive := c.Action(func() {
|
toggleIsLive := c.Action(func() {
|
||||||
isLive = isLiveSig.Bool()
|
isLive = isLiveSig.Bool()
|
||||||
if isLive {
|
if isLive {
|
||||||
updateData.Start()
|
startInterval()
|
||||||
} else {
|
} else {
|
||||||
updateData.Stop()
|
stopUpdate()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
c.View(func() h.H {
|
c.View(func() h.H {
|
||||||
|
|||||||
91
internal/examples/spa/main.go
Normal file
91
internal/examples/spa/main.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ryanhamamura/via"
|
||||||
|
. "github.com/ryanhamamura/via/h"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
v := via.New()
|
||||||
|
v.Config(via.Options{
|
||||||
|
DocumentTitle: "SPA Navigation",
|
||||||
|
ServerAddress: ":7331",
|
||||||
|
})
|
||||||
|
|
||||||
|
v.AppendToHead(
|
||||||
|
Raw(`<link rel="preconnect" href="https://fonts.googleapis.com">`),
|
||||||
|
Raw(`<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>`),
|
||||||
|
Raw(`<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">`),
|
||||||
|
Raw(`<style>body{font-family:'Inter',sans-serif;margin:0;background:#111;color:#eee}</style>`),
|
||||||
|
)
|
||||||
|
|
||||||
|
v.Layout(func(content func() H) H {
|
||||||
|
return Div(
|
||||||
|
Nav(
|
||||||
|
Style("display:flex;gap:1rem;padding:1rem;background:#222;"),
|
||||||
|
A(Href("/"), Text("Home"), Style("color:#fff")),
|
||||||
|
A(Href("/counter"), Text("Counter"), Style("color:#fff")),
|
||||||
|
A(Href("/clock"), Text("Clock"), Style("color:#fff")),
|
||||||
|
A(Href("https://github.com"), Text("GitHub (external)"), Style("color:#888")),
|
||||||
|
A(Href("/"), Text("Full Reload"), Attr("data-via-no-boost"), Style("color:#f88")),
|
||||||
|
),
|
||||||
|
Main(Style("padding:1rem"), content()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Home page
|
||||||
|
v.Page("/", func(c *via.Context) {
|
||||||
|
goCounter := c.Action(func() { c.Navigate("/counter", false) })
|
||||||
|
|
||||||
|
c.View(func() H {
|
||||||
|
return Div(
|
||||||
|
H1(Text("Home"), DataViewTransition("page-title")),
|
||||||
|
P(Text("Click the nav links above — no page reload, no white flash.")),
|
||||||
|
P(Text("Or navigate programmatically:")),
|
||||||
|
Button(Text("Go to Counter"), goCounter.OnClick()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Counter page — demonstrates signals and actions survive within a page,
|
||||||
|
// but reset on navigate away and back.
|
||||||
|
v.Page("/counter", func(c *via.Context) {
|
||||||
|
count := 0
|
||||||
|
increment := c.Action(func() { count++; c.Sync() })
|
||||||
|
goHome := c.Action(func() { c.Navigate("/", false) })
|
||||||
|
|
||||||
|
c.View(func() H {
|
||||||
|
return Div(
|
||||||
|
H1(Text("Counter"), DataViewTransition("page-title")),
|
||||||
|
P(Textf("Count: %d", count)),
|
||||||
|
Button(Text("+1"), increment.OnClick()),
|
||||||
|
Button(Text("Go Home"), goHome.OnClick(), Style("margin-left:0.5rem")),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clock page — demonstrates OnInterval cleanup on navigate.
|
||||||
|
v.Page("/clock", func(c *via.Context) {
|
||||||
|
now := time.Now().Format("15:04:05")
|
||||||
|
c.OnInterval(time.Second, func() {
|
||||||
|
now = time.Now().Format("15:04:05")
|
||||||
|
c.Sync()
|
||||||
|
})
|
||||||
|
|
||||||
|
c.View(func() H {
|
||||||
|
return Div(
|
||||||
|
H1(Text("Clock"), DataViewTransition("page-title")),
|
||||||
|
P(Text("This page has an OnInterval that ticks every second.")),
|
||||||
|
P(Textf("Current time: %s", now)),
|
||||||
|
P(Text("Navigate away and back — the old interval stops, a new one starts.")),
|
||||||
|
P(Textf("Proof this is a fresh page init: random = %d", time.Now().UnixNano()%1000)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
fmt.Println("SPA example running at http://localhost:7331")
|
||||||
|
v.Start()
|
||||||
|
}
|
||||||
226
maplibre/js.go
Normal file
226
maplibre/js.go
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
package maplibre
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// guard wraps JS code so it only runs when the map instance exists.
|
||||||
|
// The body can reference the map as `m`.
|
||||||
|
func guard(mapID, body string) string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
`(function(){var m=window.__via_maps&&window.__via_maps[%s];if(!m)return;%s})()`,
|
||||||
|
jsonStr(mapID), body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonStr JSON-encodes a string for safe embedding in JS.
|
||||||
|
func jsonStr(s string) string {
|
||||||
|
b, _ := json.Marshal(s)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonVal JSON-encodes an arbitrary value for safe embedding in JS.
|
||||||
|
func jsonVal(v any) string {
|
||||||
|
b, _ := json.Marshal(v)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// initScript generates the idempotent map initialization JS.
|
||||||
|
func initScript(m *Map) string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
b.WriteString(fmt.Sprintf(
|
||||||
|
`(function(){if(window.__via_maps&&window.__via_maps[%[1]s])return;`,
|
||||||
|
jsonStr(m.id),
|
||||||
|
))
|
||||||
|
|
||||||
|
b.WriteString(fmt.Sprintf(
|
||||||
|
`var map=new maplibregl.Map({container:%s,style:%s,center:[%s,%s],zoom:%s`,
|
||||||
|
jsonStr("_vmap_"+m.id),
|
||||||
|
jsonStr(m.opts.Style),
|
||||||
|
formatFloat(m.opts.Center.Lng),
|
||||||
|
formatFloat(m.opts.Center.Lat),
|
||||||
|
formatFloat(m.opts.Zoom),
|
||||||
|
))
|
||||||
|
if m.opts.Bearing != 0 {
|
||||||
|
b.WriteString(fmt.Sprintf(`,bearing:%s`, formatFloat(m.opts.Bearing)))
|
||||||
|
}
|
||||||
|
if m.opts.Pitch != 0 {
|
||||||
|
b.WriteString(fmt.Sprintf(`,pitch:%s`, formatFloat(m.opts.Pitch)))
|
||||||
|
}
|
||||||
|
if m.opts.MinZoom != 0 {
|
||||||
|
b.WriteString(fmt.Sprintf(`,minZoom:%s`, formatFloat(m.opts.MinZoom)))
|
||||||
|
}
|
||||||
|
if m.opts.MaxZoom != 0 {
|
||||||
|
b.WriteString(fmt.Sprintf(`,maxZoom:%s`, formatFloat(m.opts.MaxZoom)))
|
||||||
|
}
|
||||||
|
b.WriteString(`});`)
|
||||||
|
|
||||||
|
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={};`)
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
b.WriteString(`map.on('load',function(){`)
|
||||||
|
for _, src := range m.sources {
|
||||||
|
b.WriteString(fmt.Sprintf(`map.addSource(%s,%s);`, jsonStr(src.id), src.js))
|
||||||
|
}
|
||||||
|
for _, layer := range m.layers {
|
||||||
|
if layer.Before != "" {
|
||||||
|
b.WriteString(fmt.Sprintf(`map.addLayer(%s,%s);`, layer.toJS(), jsonStr(layer.Before)))
|
||||||
|
} else {
|
||||||
|
b.WriteString(fmt.Sprintf(`map.addLayer(%s);`, layer.toJS()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, me := range m.markers {
|
||||||
|
b.WriteString(markerBodyJS(me.id, me.marker))
|
||||||
|
}
|
||||||
|
for _, pe := range m.popups {
|
||||||
|
b.WriteString(popupBodyJS(pe.id, pe.popup))
|
||||||
|
}
|
||||||
|
b.WriteString(`});`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync viewport signals on moveend via hidden inputs
|
||||||
|
b.WriteString(fmt.Sprintf(`map.on('moveend',function(){`+
|
||||||
|
`var c=map.getCenter();`+
|
||||||
|
`var el=document.getElementById(%[1]s);if(!el)return;`+
|
||||||
|
`var inputs=el.querySelectorAll('input[data-bind]');`+
|
||||||
|
`inputs.forEach(function(inp){`+
|
||||||
|
`var sig=inp.getAttribute('data-bind');`+
|
||||||
|
`if(sig===%[2]s)inp.value=c.lng;`+
|
||||||
|
`else if(sig===%[3]s)inp.value=c.lat;`+
|
||||||
|
`else if(sig===%[4]s)inp.value=map.getZoom();`+
|
||||||
|
`else if(sig===%[5]s)inp.value=map.getBearing();`+
|
||||||
|
`else if(sig===%[6]s)inp.value=map.getPitch();`+
|
||||||
|
`inp.dispatchEvent(new Event('input',{bubbles:true}));`+
|
||||||
|
`});`+
|
||||||
|
`});`,
|
||||||
|
jsonStr("_vwrap_"+m.id),
|
||||||
|
jsonStr(m.centerLng.ID()),
|
||||||
|
jsonStr(m.centerLat.ID()),
|
||||||
|
jsonStr(m.zoom.ID()),
|
||||||
|
jsonStr(m.bearing.ID()),
|
||||||
|
jsonStr(m.pitch.ID()),
|
||||||
|
))
|
||||||
|
|
||||||
|
// ResizeObserver for auto-resize
|
||||||
|
b.WriteString(fmt.Sprintf(
|
||||||
|
`var ro=new ResizeObserver(function(){map.resize();});`+
|
||||||
|
`ro.observe(document.getElementById(%s));`,
|
||||||
|
jsonStr("_vmap_"+m.id),
|
||||||
|
))
|
||||||
|
|
||||||
|
// MutationObserver to clean up on DOM removal (SPA nav)
|
||||||
|
b.WriteString(fmt.Sprintf(
|
||||||
|
`var container=document.getElementById(%[1]s);`+
|
||||||
|
`if(container){var mo=new MutationObserver(function(){`+
|
||||||
|
`if(!document.contains(container)){`+
|
||||||
|
`mo.disconnect();ro.disconnect();map.remove();`+
|
||||||
|
`delete window.__via_maps[%[2]s];`+
|
||||||
|
`}});`+
|
||||||
|
`mo.observe(document.body,{childList:true,subtree:true});}`,
|
||||||
|
jsonStr("_vmap_"+m.id),
|
||||||
|
jsonStr(m.id),
|
||||||
|
))
|
||||||
|
|
||||||
|
b.WriteString(`})()`)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// markerBodyJS generates JS to add a marker, assuming `map` is in scope.
|
||||||
|
// Used inside the init script's load callback.
|
||||||
|
func markerBodyJS(markerID string, mk Marker) string {
|
||||||
|
var b strings.Builder
|
||||||
|
opts := "{"
|
||||||
|
if mk.Color != "" {
|
||||||
|
opts += fmt.Sprintf(`color:%s,`, jsonStr(mk.Color))
|
||||||
|
}
|
||||||
|
if mk.Draggable {
|
||||||
|
opts += `draggable:true,`
|
||||||
|
}
|
||||||
|
opts += "}"
|
||||||
|
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)))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// addMarkerJS generates a self-contained IIFE to add a marker post-render.
|
||||||
|
func addMarkerJS(mapID, markerID string, mk Marker) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(fmt.Sprintf(
|
||||||
|
`(function(){var map=window.__via_maps&&window.__via_maps[%s];if(!map)return;`,
|
||||||
|
jsonStr(mapID)))
|
||||||
|
// Remove existing marker with same ID
|
||||||
|
b.WriteString(fmt.Sprintf(
|
||||||
|
`if(map._via_markers[%[1]s]){map._via_markers[%[1]s].remove();delete map._via_markers[%[1]s];}`,
|
||||||
|
jsonStr(markerID)))
|
||||||
|
b.WriteString(markerBodyJS(markerID, mk))
|
||||||
|
b.WriteString(`})()`)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeMarkerJS generates JS to remove a marker. Expects `m` in scope (used inside guard).
|
||||||
|
func removeMarkerJS(markerID string) string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
`if(m._via_markers[%[1]s]){m._via_markers[%[1]s].remove();delete m._via_markers[%[1]s];}`,
|
||||||
|
jsonStr(markerID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// popupBodyJS generates JS to show a popup, assuming `map` is in scope.
|
||||||
|
func popupBodyJS(popupID string, p Popup) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(popupConstructorJS(p, "p"))
|
||||||
|
b.WriteString(fmt.Sprintf(
|
||||||
|
`p.setLngLat([%s,%s]).addTo(map);map._via_popups[%s]=p;`,
|
||||||
|
formatFloat(p.LngLat.Lng), formatFloat(p.LngLat.Lat), jsonStr(popupID)))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// showPopupJS generates a self-contained IIFE to show a popup post-render.
|
||||||
|
func showPopupJS(mapID, popupID string, p Popup) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(fmt.Sprintf(
|
||||||
|
`(function(){var map=window.__via_maps&&window.__via_maps[%s];if(!map)return;`,
|
||||||
|
jsonStr(mapID)))
|
||||||
|
// Close existing popup with same ID
|
||||||
|
b.WriteString(fmt.Sprintf(
|
||||||
|
`if(map._via_popups[%[1]s]){map._via_popups[%[1]s].remove();delete map._via_popups[%[1]s];}`,
|
||||||
|
jsonStr(popupID)))
|
||||||
|
b.WriteString(popupBodyJS(popupID, p))
|
||||||
|
b.WriteString(`})()`)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// closePopupJS generates JS to close a popup. Expects `m` in scope (used inside guard).
|
||||||
|
func closePopupJS(popupID string) string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
`if(m._via_popups[%[1]s]){m._via_popups[%[1]s].remove();delete m._via_popups[%[1]s];}`,
|
||||||
|
jsonStr(popupID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// popupConstructorJS generates JS to create a Popup object stored in varName.
|
||||||
|
func popupConstructorJS(p Popup, varName string) string {
|
||||||
|
opts := "{"
|
||||||
|
if p.HideCloseButton {
|
||||||
|
opts += `closeButton:false,`
|
||||||
|
}
|
||||||
|
if p.MaxWidth != "" {
|
||||||
|
opts += fmt.Sprintf(`maxWidth:%s,`, jsonStr(p.MaxWidth))
|
||||||
|
}
|
||||||
|
opts += "}"
|
||||||
|
return fmt.Sprintf(`var %s=new maplibregl.Popup(%s).setHTML(%s);`,
|
||||||
|
varName, opts, jsonStr(p.Content))
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatFloat(f float64) string {
|
||||||
|
return fmt.Sprintf("%g", f)
|
||||||
|
}
|
||||||
1
maplibre/maplibre-gl.css
Normal file
1
maplibre/maplibre-gl.css
Normal file
File diff suppressed because one or more lines are too long
59
maplibre/maplibre-gl.js
Normal file
59
maplibre/maplibre-gl.js
Normal file
File diff suppressed because one or more lines are too long
326
maplibre/maplibre.go
Normal file
326
maplibre/maplibre.go
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
// Package maplibre provides a Go API for MapLibre GL JS maps within Via applications.
|
||||||
|
//
|
||||||
|
// It follows the same ExecScript + DataIgnoreMorph pattern used for other client-side
|
||||||
|
// JS library integrations (e.g. ECharts in the realtimechart example).
|
||||||
|
package maplibre
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
_ "embed"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/ryanhamamura/via"
|
||||||
|
"github.com/ryanhamamura/via/h"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed maplibre-gl.js
|
||||||
|
var maplibreJS []byte
|
||||||
|
|
||||||
|
//go:embed maplibre-gl.css
|
||||||
|
var maplibreCSS []byte
|
||||||
|
|
||||||
|
// Plugin serves the embedded MapLibre GL JS/CSS and injects them into the document head.
|
||||||
|
func Plugin(v *via.V) {
|
||||||
|
v.HTTPServeMux().HandleFunc("GET /_maplibre/maplibre-gl.js", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/javascript")
|
||||||
|
_, _ = w.Write(maplibreJS)
|
||||||
|
})
|
||||||
|
v.HTTPServeMux().HandleFunc("GET /_maplibre/maplibre-gl.css", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/css")
|
||||||
|
_, _ = w.Write(maplibreCSS)
|
||||||
|
})
|
||||||
|
v.AppendToHead(
|
||||||
|
h.Link(h.Rel("stylesheet"), h.Href("/_maplibre/maplibre-gl.css")),
|
||||||
|
h.Script(h.Src("/_maplibre/maplibre-gl.js")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
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
|
||||||
|
|
||||||
|
rendered bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a Map bound to the given Via context with the provided options.
|
||||||
|
// It registers viewport signals on the context for browser → server sync.
|
||||||
|
func New(c *via.Context, opts Options) *Map {
|
||||||
|
if opts.Width == "" {
|
||||||
|
opts.Width = "100%"
|
||||||
|
}
|
||||||
|
if opts.Height == "" {
|
||||||
|
opts.Height = "400px"
|
||||||
|
}
|
||||||
|
|
||||||
|
m := &Map{
|
||||||
|
id: genID(),
|
||||||
|
ctx: c,
|
||||||
|
opts: opts,
|
||||||
|
}
|
||||||
|
|
||||||
|
m.centerLng = c.Signal(opts.Center.Lng)
|
||||||
|
m.centerLat = c.Signal(opts.Center.Lat)
|
||||||
|
m.zoom = c.Signal(opts.Zoom)
|
||||||
|
m.bearing = c.Signal(opts.Bearing)
|
||||||
|
m.pitch = c.Signal(opts.Pitch)
|
||||||
|
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Element returns the h.H DOM tree for the map. Call this once inside your View function.
|
||||||
|
// After Element() is called, subsequent source/layer/marker/popup operations
|
||||||
|
// use ExecScript instead of accumulating for the init script.
|
||||||
|
func (m *Map) Element() h.H {
|
||||||
|
m.rendered = true
|
||||||
|
|
||||||
|
return h.Div(h.ID("_vwrap_"+m.id),
|
||||||
|
// Map container — morph-ignored so MapLibre's DOM isn't destroyed on Sync()
|
||||||
|
h.Div(
|
||||||
|
h.ID("_vmap_"+m.id),
|
||||||
|
h.DataIgnoreMorph(),
|
||||||
|
h.Attr("style", fmt.Sprintf("width:%s;height:%s", m.opts.Width, m.opts.Height)),
|
||||||
|
),
|
||||||
|
// Hidden inputs for viewport signal binding (outside morph-ignored zone)
|
||||||
|
h.Input(h.Type("hidden"), m.centerLng.Bind()),
|
||||||
|
h.Input(h.Type("hidden"), m.centerLat.Bind()),
|
||||||
|
h.Input(h.Type("hidden"), m.zoom.Bind()),
|
||||||
|
h.Input(h.Type("hidden"), m.bearing.Bind()),
|
||||||
|
h.Input(h.Type("hidden"), m.pitch.Bind()),
|
||||||
|
// Init script
|
||||||
|
h.Script(h.Raw(initScript(m))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Viewport readers (signal → Go) ---
|
||||||
|
|
||||||
|
// Center returns the current map center from synced signals.
|
||||||
|
func (m *Map) Center() LngLat {
|
||||||
|
return LngLat{
|
||||||
|
Lng: parseFloat(m.centerLng.String()),
|
||||||
|
Lat: parseFloat(m.centerLat.String()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zoom returns the current map zoom level from the synced signal.
|
||||||
|
func (m *Map) Zoom() float64 {
|
||||||
|
return parseFloat(m.zoom.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bearing returns the current map bearing from the synced signal.
|
||||||
|
func (m *Map) Bearing() float64 {
|
||||||
|
return parseFloat(m.bearing.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pitch returns the current map pitch from the synced signal.
|
||||||
|
func (m *Map) Pitch() float64 {
|
||||||
|
return parseFloat(m.pitch.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Viewport setters (Go → browser) ---
|
||||||
|
|
||||||
|
// 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)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCenter sets the map center without animation.
|
||||||
|
func (m *Map) SetCenter(ll LngLat) {
|
||||||
|
m.exec(fmt.Sprintf(`m.setCenter([%s,%s]);`,
|
||||||
|
formatFloat(ll.Lng), formatFloat(ll.Lat)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetZoom sets the map zoom level without animation.
|
||||||
|
func (m *Map) SetZoom(z float64) {
|
||||||
|
m.exec(fmt.Sprintf(`m.setZoom(%s);`, formatFloat(z)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetBearing sets the map bearing without animation.
|
||||||
|
func (m *Map) SetBearing(b float64) {
|
||||||
|
m.exec(fmt.Sprintf(`m.setBearing(%s);`, formatFloat(b)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPitch sets the map pitch without animation.
|
||||||
|
func (m *Map) SetPitch(p float64) {
|
||||||
|
m.exec(fmt.Sprintf(`m.setPitch(%s);`, formatFloat(p)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetStyle changes the map's style URL.
|
||||||
|
func (m *Map) SetStyle(url string) {
|
||||||
|
m.exec(fmt.Sprintf(`m.setStyle(%s);`, jsonStr(url)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Source methods ---
|
||||||
|
|
||||||
|
// AddSource adds a source to the map. src should be a GeoJSONSource,
|
||||||
|
// VectorSource, RasterSource, or any JSON-marshalable value.
|
||||||
|
func (m *Map) AddSource(id string, src any) {
|
||||||
|
js := sourceJSON(src)
|
||||||
|
if !m.rendered {
|
||||||
|
m.sources = append(m.sources, sourceEntry{id: id, js: js})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.exec(fmt.Sprintf(`m.addSource(%s,%s);`, jsonStr(id), js))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveSource removes a source from the map.
|
||||||
|
// 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 {
|
||||||
|
if s.id == id {
|
||||||
|
m.sources = append(m.sources[:i], m.sources[i+1:]...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.exec(fmt.Sprintf(`m.removeSource(%s);`, jsonStr(id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateGeoJSONSource replaces the data of an existing GeoJSON source.
|
||||||
|
func (m *Map) UpdateGeoJSONSource(sourceID string, data any) {
|
||||||
|
m.exec(fmt.Sprintf(`m.getSource(%s).setData(%s);`, jsonStr(sourceID), jsonVal(data)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Layer methods ---
|
||||||
|
|
||||||
|
// AddLayer adds a layer to the map.
|
||||||
|
func (m *Map) AddLayer(layer Layer) {
|
||||||
|
if !m.rendered {
|
||||||
|
m.layers = append(m.layers, layer)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
before := "undefined"
|
||||||
|
if layer.Before != "" {
|
||||||
|
before = jsonStr(layer.Before)
|
||||||
|
}
|
||||||
|
m.exec(fmt.Sprintf(`m.addLayer(%s,%s);`, layer.toJS(), before))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveLayer removes a layer from the map.
|
||||||
|
// 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 {
|
||||||
|
if l.ID == id {
|
||||||
|
m.layers = append(m.layers[:i], m.layers[i+1:]...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.exec(fmt.Sprintf(`m.removeLayer(%s);`, jsonStr(id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPaintProperty sets a paint property on a layer.
|
||||||
|
func (m *Map) SetPaintProperty(layerID, name string, value any) {
|
||||||
|
m.exec(fmt.Sprintf(`m.setPaintProperty(%s,%s,%s);`,
|
||||||
|
jsonStr(layerID), jsonStr(name), jsonVal(value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLayoutProperty sets a layout property on a layer.
|
||||||
|
func (m *Map) SetLayoutProperty(layerID, name string, value any) {
|
||||||
|
m.exec(fmt.Sprintf(`m.setLayoutProperty(%s,%s,%s);`,
|
||||||
|
jsonStr(layerID), jsonStr(name), jsonVal(value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Marker methods ---
|
||||||
|
|
||||||
|
// AddMarker adds or replaces a marker on the map.
|
||||||
|
func (m *Map) AddMarker(id string, marker Marker) {
|
||||||
|
if !m.rendered {
|
||||||
|
m.markers = append(m.markers, markerEntry{id: id, marker: marker})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
js := addMarkerJS(m.id, id, marker)
|
||||||
|
m.ctx.ExecScript(js)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveMarker removes a marker from the map.
|
||||||
|
// 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 {
|
||||||
|
if me.id == id {
|
||||||
|
m.markers = append(m.markers[:i], m.markers[i+1:]...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.exec(removeMarkerJS(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Popup methods ---
|
||||||
|
|
||||||
|
// ShowPopup shows a standalone popup on the map.
|
||||||
|
func (m *Map) ShowPopup(id string, popup Popup) {
|
||||||
|
if !m.rendered {
|
||||||
|
m.popups = append(m.popups, popupEntry{id: id, popup: popup})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
js := showPopupJS(m.id, id, popup)
|
||||||
|
m.ctx.ExecScript(js)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClosePopup closes a standalone popup on the map.
|
||||||
|
// 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 {
|
||||||
|
if pe.id == id {
|
||||||
|
m.popups = append(m.popups[:i], m.popups[i+1:]...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.exec(closePopupJS(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Escape hatch ---
|
||||||
|
|
||||||
|
// Exec runs arbitrary JS with the map available as `m`.
|
||||||
|
func (m *Map) Exec(js string) {
|
||||||
|
m.exec(js)
|
||||||
|
}
|
||||||
|
|
||||||
|
// exec sends guarded JS to the browser via ExecScript.
|
||||||
|
func (m *Map) exec(body string) {
|
||||||
|
m.ctx.ExecScript(guard(m.id, body))
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFloat(s string) float64 {
|
||||||
|
f, _ := strconv.ParseFloat(s, 64)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func genID() string {
|
||||||
|
b := make([]byte, 4)
|
||||||
|
rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
175
maplibre/types.go
Normal file
175
maplibre/types.go
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
package maplibre
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// LngLat represents a geographic coordinate.
|
||||||
|
type LngLat struct {
|
||||||
|
Lng float64
|
||||||
|
Lat float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options configures the initial map state.
|
||||||
|
type Options struct {
|
||||||
|
// Style is the map style URL (required).
|
||||||
|
Style string
|
||||||
|
|
||||||
|
Center LngLat
|
||||||
|
Zoom float64
|
||||||
|
Bearing float64
|
||||||
|
Pitch float64
|
||||||
|
MinZoom float64
|
||||||
|
MaxZoom float64
|
||||||
|
|
||||||
|
// CSS dimensions for the map container. Defaults: "100%", "400px".
|
||||||
|
Width string
|
||||||
|
Height string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeoJSONSource provides inline GeoJSON data to MapLibre.
|
||||||
|
// Data should be a GeoJSON-marshalable value (struct, map, or json.RawMessage).
|
||||||
|
type GeoJSONSource struct {
|
||||||
|
Data any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s GeoJSONSource) toJS() string {
|
||||||
|
data, _ := json.Marshal(s.Data)
|
||||||
|
return `{"type":"geojson","data":` + string(data) + `}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VectorSource references a vector tile source.
|
||||||
|
type VectorSource struct {
|
||||||
|
URL string
|
||||||
|
Tiles []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s VectorSource) toJS() string {
|
||||||
|
obj := map[string]any{"type": "vector"}
|
||||||
|
if s.URL != "" {
|
||||||
|
obj["url"] = s.URL
|
||||||
|
}
|
||||||
|
if len(s.Tiles) > 0 {
|
||||||
|
obj["tiles"] = s.Tiles
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(obj)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RasterSource references a raster tile source.
|
||||||
|
type RasterSource struct {
|
||||||
|
URL string
|
||||||
|
Tiles []string
|
||||||
|
TileSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s RasterSource) toJS() string {
|
||||||
|
obj := map[string]any{"type": "raster"}
|
||||||
|
if s.URL != "" {
|
||||||
|
obj["url"] = s.URL
|
||||||
|
}
|
||||||
|
if len(s.Tiles) > 0 {
|
||||||
|
obj["tiles"] = s.Tiles
|
||||||
|
}
|
||||||
|
if s.TileSize > 0 {
|
||||||
|
obj["tileSize"] = s.TileSize
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(obj)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer describes a MapLibre style layer.
|
||||||
|
type Layer struct {
|
||||||
|
ID string
|
||||||
|
Type string
|
||||||
|
Source string
|
||||||
|
SourceLayer string
|
||||||
|
Paint map[string]any
|
||||||
|
Layout map[string]any
|
||||||
|
Filter any
|
||||||
|
MinZoom float64
|
||||||
|
MaxZoom float64
|
||||||
|
|
||||||
|
// Before inserts this layer before the given layer ID in the stack.
|
||||||
|
Before string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l Layer) toJS() string {
|
||||||
|
obj := map[string]any{
|
||||||
|
"id": l.ID,
|
||||||
|
"type": l.Type,
|
||||||
|
}
|
||||||
|
if l.Source != "" {
|
||||||
|
obj["source"] = l.Source
|
||||||
|
}
|
||||||
|
if l.SourceLayer != "" {
|
||||||
|
obj["source-layer"] = l.SourceLayer
|
||||||
|
}
|
||||||
|
if l.Paint != nil {
|
||||||
|
obj["paint"] = l.Paint
|
||||||
|
}
|
||||||
|
if l.Layout != nil {
|
||||||
|
obj["layout"] = l.Layout
|
||||||
|
}
|
||||||
|
if l.Filter != nil {
|
||||||
|
obj["filter"] = l.Filter
|
||||||
|
}
|
||||||
|
if l.MinZoom > 0 {
|
||||||
|
obj["minzoom"] = l.MinZoom
|
||||||
|
}
|
||||||
|
if l.MaxZoom > 0 {
|
||||||
|
obj["maxzoom"] = l.MaxZoom
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(obj)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marker describes a map marker.
|
||||||
|
type Marker struct {
|
||||||
|
LngLat LngLat
|
||||||
|
Color string
|
||||||
|
Draggable bool
|
||||||
|
Popup *Popup
|
||||||
|
}
|
||||||
|
|
||||||
|
// Popup describes a map popup.
|
||||||
|
//
|
||||||
|
// Content is rendered as HTML via MapLibre's setHTML. Do not pass untrusted
|
||||||
|
// user input without sanitizing it first.
|
||||||
|
type Popup struct {
|
||||||
|
Content string // HTML content
|
||||||
|
LngLat LngLat
|
||||||
|
HideCloseButton bool // true removes the close button (MapLibre shows it by default)
|
||||||
|
MaxWidth string
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceEntry pairs a source ID with its JS representation for pre-render accumulation.
|
||||||
|
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
|
||||||
|
}
|
||||||
210
nats.go
Normal file
210
nats.go
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
package via
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/delaneyj/toolbelt/embeddednats"
|
||||||
|
natsserver "github.com/nats-io/nats-server/v2/server"
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultNATS is the process-scoped embedded NATS server.
|
||||||
|
type defaultNATS struct {
|
||||||
|
server *embeddednats.Server
|
||||||
|
nc *nats.Conn
|
||||||
|
js nats.JetStreamContext
|
||||||
|
cancel context.CancelFunc
|
||||||
|
dataDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
sharedNATS *defaultNATS
|
||||||
|
sharedNATSOnce sync.Once
|
||||||
|
sharedNATSErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
// getSharedNATS returns a process-level singleton embedded NATS server.
|
||||||
|
// The server starts once and is reused across all V instances.
|
||||||
|
func getSharedNATS() (*defaultNATS, error) {
|
||||||
|
sharedNATSOnce.Do(func() {
|
||||||
|
sharedNATS, sharedNATSErr = startDefaultNATS()
|
||||||
|
})
|
||||||
|
return sharedNATS, sharedNATSErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func startDefaultNATS() (dn *defaultNATS, err error) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
err = fmt.Errorf("nats server panic: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
dataDir, err := os.MkdirTemp("", "via-nats-*")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create temp dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
ns, err := embeddednats.New(ctx,
|
||||||
|
embeddednats.WithDirectory(dataDir),
|
||||||
|
embeddednats.WithNATSServerOptions(&natsserver.Options{
|
||||||
|
JetStream: true,
|
||||||
|
StoreDir: dataDir,
|
||||||
|
Port: -1,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
os.RemoveAll(dataDir)
|
||||||
|
return nil, fmt.Errorf("start embedded nats: %w", err)
|
||||||
|
}
|
||||||
|
ready := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
ns.WaitForServer()
|
||||||
|
close(ready)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-ready:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
ns.Close()
|
||||||
|
cancel()
|
||||||
|
os.RemoveAll(dataDir)
|
||||||
|
return nil, fmt.Errorf("embedded nats server did not start within 10s")
|
||||||
|
}
|
||||||
|
|
||||||
|
nc, err := ns.Client()
|
||||||
|
if err != nil {
|
||||||
|
ns.Close()
|
||||||
|
cancel()
|
||||||
|
os.RemoveAll(dataDir)
|
||||||
|
return nil, fmt.Errorf("connect nats client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
js, err := nc.JetStream()
|
||||||
|
if err != nil {
|
||||||
|
nc.Close()
|
||||||
|
ns.Close()
|
||||||
|
cancel()
|
||||||
|
os.RemoveAll(dataDir)
|
||||||
|
return nil, fmt.Errorf("init jetstream: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &defaultNATS{
|
||||||
|
server: ns,
|
||||||
|
nc: nc,
|
||||||
|
js: js,
|
||||||
|
cancel: cancel,
|
||||||
|
dataDir: dataDir,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *defaultNATS) Publish(subject string, data []byte) error {
|
||||||
|
return n.nc.Publish(subject, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *defaultNATS) Subscribe(subject string, handler func(data []byte)) (Subscription, error) {
|
||||||
|
sub, err := n.nc.Subscribe(subject, func(msg *nats.Msg) {
|
||||||
|
handler(msg.Data)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return sub, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// natsRef wraps a shared defaultNATS as a PubSub. Close is a no-op because
|
||||||
|
// the underlying server is process-scoped and outlives individual V instances.
|
||||||
|
type natsRef struct {
|
||||||
|
dn *defaultNATS
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *natsRef) Publish(subject string, data []byte) error {
|
||||||
|
return r.dn.Publish(subject, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *natsRef) Subscribe(subject string, handler func(data []byte)) (Subscription, error) {
|
||||||
|
return r.dn.Subscribe(subject, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *natsRef) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NATSConn returns the underlying NATS connection from the built-in embedded
|
||||||
|
// server, or nil if a custom PubSub backend is in use.
|
||||||
|
func (v *V) NATSConn() *nats.Conn {
|
||||||
|
if v.defaultNATS != nil {
|
||||||
|
return v.defaultNATS.nc
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// JetStream returns the JetStream context from the built-in embedded server,
|
||||||
|
// or nil if a custom PubSub backend is in use.
|
||||||
|
func (v *V) JetStream() nats.JetStreamContext {
|
||||||
|
if v.defaultNATS != nil {
|
||||||
|
return v.defaultNATS.js
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamConfig holds the parameters for creating or updating a JetStream stream.
|
||||||
|
type StreamConfig struct {
|
||||||
|
Name string
|
||||||
|
Subjects []string
|
||||||
|
MaxMsgs int64
|
||||||
|
MaxAge time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureStream creates or updates a JetStream stream matching cfg.
|
||||||
|
func EnsureStream(v *V, cfg StreamConfig) error {
|
||||||
|
js := v.JetStream()
|
||||||
|
if js == nil {
|
||||||
|
return fmt.Errorf("jetstream not available")
|
||||||
|
}
|
||||||
|
_, err := js.AddStream(&nats.StreamConfig{
|
||||||
|
Name: cfg.Name,
|
||||||
|
Subjects: cfg.Subjects,
|
||||||
|
Retention: nats.LimitsPolicy,
|
||||||
|
MaxMsgs: cfg.MaxMsgs,
|
||||||
|
MaxAge: cfg.MaxAge,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplayHistory fetches the last limit messages from subject,
|
||||||
|
// deserializing each as T. Returns an empty slice if nothing is available.
|
||||||
|
func ReplayHistory[T any](v *V, subject string, limit int) ([]T, error) {
|
||||||
|
js := v.JetStream()
|
||||||
|
if js == nil {
|
||||||
|
return nil, fmt.Errorf("jetstream not available")
|
||||||
|
}
|
||||||
|
sub, err := js.SubscribeSync(subject, nats.DeliverAll(), nats.OrderedConsumer())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer sub.Unsubscribe()
|
||||||
|
|
||||||
|
var msgs []T
|
||||||
|
for {
|
||||||
|
raw, err := sub.NextMsg(200 * time.Millisecond)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
var msg T
|
||||||
|
if json.Unmarshal(raw.Data, &msg) == nil {
|
||||||
|
msgs = append(msgs, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if limit > 0 && len(msgs) > limit {
|
||||||
|
msgs = msgs[len(msgs)-limit:]
|
||||||
|
}
|
||||||
|
return msgs, nil
|
||||||
|
}
|
||||||
123
nats_test.go
123
nats_test.go
@@ -2,7 +2,6 @@ package via
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -11,88 +10,50 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
type mockHandler struct {
|
// setupNATSTest creates a *V with an embedded NATS server.
|
||||||
id int64
|
// Skips the test if NATS fails to start (e.g. port conflict in CI).
|
||||||
fn func([]byte)
|
func setupNATSTest(t *testing.T) *V {
|
||||||
active atomic.Bool
|
t.Helper()
|
||||||
}
|
v := New()
|
||||||
|
dn, err := getSharedNATS()
|
||||||
// mockPubSub implements PubSub for testing without NATS.
|
if err != nil {
|
||||||
type mockPubSub struct {
|
v.Shutdown()
|
||||||
mu sync.Mutex
|
t.Skipf("embedded NATS unavailable: %v", err)
|
||||||
subs map[string][]*mockHandler
|
|
||||||
nextID atomic.Int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func newMockPubSub() *mockPubSub {
|
|
||||||
return &mockPubSub{subs: make(map[string][]*mockHandler)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockPubSub) Publish(subject string, data []byte) error {
|
|
||||||
m.mu.Lock()
|
|
||||||
handlers := make([]*mockHandler, len(m.subs[subject]))
|
|
||||||
copy(handlers, m.subs[subject])
|
|
||||||
m.mu.Unlock()
|
|
||||||
for _, h := range handlers {
|
|
||||||
if h.active.Load() {
|
|
||||||
h.fn(data)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
v.defaultNATS = dn
|
||||||
}
|
v.pubsub = &natsRef{dn: dn}
|
||||||
|
t.Cleanup(v.Shutdown)
|
||||||
func (m *mockPubSub) Subscribe(subject string, handler func(data []byte)) (Subscription, error) {
|
return v
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
mh := &mockHandler{
|
|
||||||
id: m.nextID.Add(1),
|
|
||||||
fn: handler,
|
|
||||||
}
|
|
||||||
mh.active.Store(true)
|
|
||||||
m.subs[subject] = append(m.subs[subject], mh)
|
|
||||||
return &mockSub{handler: mh}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockPubSub) Close() error { return nil }
|
|
||||||
|
|
||||||
type mockSub struct {
|
|
||||||
handler *mockHandler
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *mockSub) Unsubscribe() error {
|
|
||||||
s.handler.active.Store(false)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPubSub_RoundTrip(t *testing.T) {
|
func TestPubSub_RoundTrip(t *testing.T) {
|
||||||
ps := newMockPubSub()
|
v := setupNATSTest(t)
|
||||||
v := New()
|
|
||||||
v.Config(Options{PubSub: ps})
|
|
||||||
|
|
||||||
var received []byte
|
var received []byte
|
||||||
var wg sync.WaitGroup
|
done := make(chan struct{})
|
||||||
wg.Add(1)
|
|
||||||
|
|
||||||
c := newContext("test-ctx", "/", v)
|
c := newContext("test-ctx", "/", v)
|
||||||
c.View(func() h.H { return h.Div() })
|
c.View(func() h.H { return h.Div() })
|
||||||
|
|
||||||
_, err := c.Subscribe("test.topic", func(data []byte) {
|
_, err := c.Subscribe("test.topic", func(data []byte) {
|
||||||
received = data
|
received = data
|
||||||
wg.Done()
|
close(done)
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = c.Publish("test.topic", []byte("hello"))
|
err = c.Publish("test.topic", []byte("hello"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
wg.Wait()
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for message")
|
||||||
|
}
|
||||||
assert.Equal(t, []byte("hello"), received)
|
assert.Equal(t, []byte("hello"), received)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPubSub_MultipleSubscribers(t *testing.T) {
|
func TestPubSub_MultipleSubscribers(t *testing.T) {
|
||||||
ps := newMockPubSub()
|
v := setupNATSTest(t)
|
||||||
v := New()
|
|
||||||
v.Config(Options{PubSub: ps})
|
|
||||||
|
|
||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
var results []string
|
var results []string
|
||||||
@@ -119,7 +80,17 @@ func TestPubSub_MultipleSubscribers(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
c1.Publish("broadcast", []byte("msg"))
|
c1.Publish("broadcast", []byte("msg"))
|
||||||
wg.Wait()
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for messages")
|
||||||
|
}
|
||||||
|
|
||||||
assert.Len(t, results, 2)
|
assert.Len(t, results, 2)
|
||||||
assert.Contains(t, results, "c1:msg")
|
assert.Contains(t, results, "c1:msg")
|
||||||
@@ -127,9 +98,7 @@ func TestPubSub_MultipleSubscribers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPubSub_SubscriptionCleanupOnDispose(t *testing.T) {
|
func TestPubSub_SubscriptionCleanupOnDispose(t *testing.T) {
|
||||||
ps := newMockPubSub()
|
v := setupNATSTest(t)
|
||||||
v := New()
|
|
||||||
v.Config(Options{PubSub: ps})
|
|
||||||
|
|
||||||
c := newContext("cleanup-ctx", "/", v)
|
c := newContext("cleanup-ctx", "/", v)
|
||||||
c.View(func() h.H { return h.Div() })
|
c.View(func() h.H { return h.Div() })
|
||||||
@@ -144,9 +113,7 @@ func TestPubSub_SubscriptionCleanupOnDispose(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPubSub_ManualUnsubscribe(t *testing.T) {
|
func TestPubSub_ManualUnsubscribe(t *testing.T) {
|
||||||
ps := newMockPubSub()
|
v := setupNATSTest(t)
|
||||||
v := New()
|
|
||||||
v.Config(Options{PubSub: ps})
|
|
||||||
|
|
||||||
c := newContext("unsub-ctx", "/", v)
|
c := newContext("unsub-ctx", "/", v)
|
||||||
c.View(func() h.H { return h.Div() })
|
c.View(func() h.H { return h.Div() })
|
||||||
@@ -160,28 +127,12 @@ func TestPubSub_ManualUnsubscribe(t *testing.T) {
|
|||||||
sub.Unsubscribe()
|
sub.Unsubscribe()
|
||||||
|
|
||||||
c.Publish("topic", []byte("ignored"))
|
c.Publish("topic", []byte("ignored"))
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(50 * time.Millisecond)
|
||||||
assert.False(t, called)
|
assert.False(t, called)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPubSub_NoOpWhenNotConfigured(t *testing.T) {
|
|
||||||
v := New()
|
|
||||||
|
|
||||||
c := newContext("noop-ctx", "/", v)
|
|
||||||
c.View(func() h.H { return h.Div() })
|
|
||||||
|
|
||||||
err := c.Publish("topic", []byte("data"))
|
|
||||||
assert.Error(t, err)
|
|
||||||
|
|
||||||
sub, err := c.Subscribe("topic", func(data []byte) {})
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Nil(t, sub)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPubSub_NoOpDuringPanicCheck(t *testing.T) {
|
func TestPubSub_NoOpDuringPanicCheck(t *testing.T) {
|
||||||
ps := newMockPubSub()
|
v := setupNATSTest(t)
|
||||||
v := New()
|
|
||||||
v.Config(Options{PubSub: ps})
|
|
||||||
|
|
||||||
// Panic-check context has id=""
|
// Panic-check context has id=""
|
||||||
c := newContext("", "/", v)
|
c := newContext("", "/", v)
|
||||||
|
|||||||
51
navigate.js
Normal file
51
navigate.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
(function() {
|
||||||
|
const meta = document.querySelector('meta[data-signals]');
|
||||||
|
if (!meta) return;
|
||||||
|
const raw = meta.getAttribute('data-signals');
|
||||||
|
const parsed = JSON.parse(raw.replace(/'/g, '"'));
|
||||||
|
const ctxID = parsed['via-ctx'];
|
||||||
|
const csrf = parsed['via-csrf'];
|
||||||
|
if (!ctxID || !csrf) return;
|
||||||
|
|
||||||
|
function navigate(url, popstate) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
'via-ctx': ctxID,
|
||||||
|
'via-csrf': csrf,
|
||||||
|
'url': url,
|
||||||
|
});
|
||||||
|
if (popstate) params.set('popstate', '1');
|
||||||
|
fetch('/_navigate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
|
body: params.toString()
|
||||||
|
}).then(function(res) {
|
||||||
|
if (!res.ok) window.location.href = url;
|
||||||
|
}).catch(function() {
|
||||||
|
window.location.href = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
var el = e.target;
|
||||||
|
while (el && el.tagName !== 'A') el = el.parentElement;
|
||||||
|
if (!el) return;
|
||||||
|
if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
|
||||||
|
if (el.hasAttribute('target')) return;
|
||||||
|
if (el.hasAttribute('data-via-no-boost')) return;
|
||||||
|
var href = el.getAttribute('href');
|
||||||
|
if (!href || href.startsWith('#')) return;
|
||||||
|
try {
|
||||||
|
var url = new URL(href, window.location.origin);
|
||||||
|
if (url.origin !== window.location.origin) return;
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(url.pathname + url.search + url.hash);
|
||||||
|
} catch(_) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
var ready = false;
|
||||||
|
window.addEventListener('popstate', function() {
|
||||||
|
if (!ready) return;
|
||||||
|
navigate(window.location.pathname + window.location.search + window.location.hash, true);
|
||||||
|
});
|
||||||
|
setTimeout(function() { ready = true; }, 0);
|
||||||
|
})();
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
package via
|
package via
|
||||||
|
|
||||||
// PubSub is an interface for publish/subscribe messaging backends.
|
// PubSub is an interface for publish/subscribe messaging backends.
|
||||||
// The vianats sub-package provides an embedded NATS implementation.
|
// By default, Start() launches an embedded NATS server if no backend
|
||||||
|
// has been configured. Supply a custom implementation via
|
||||||
|
// Config(Options{PubSub: yourBackend}) to override.
|
||||||
type PubSub interface {
|
type PubSub interface {
|
||||||
Publish(subject string, data []byte) error
|
Publish(subject string, data []byte) error
|
||||||
Subscribe(subject string, handler func(data []byte)) (Subscription, error)
|
Subscribe(subject string, handler func(data []byte)) (Subscription, error)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package via
|
package via
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sync"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/ryanhamamura/via/h"
|
"github.com/ryanhamamura/via/h"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -10,9 +10,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestPublishSubscribe_RoundTrip(t *testing.T) {
|
func TestPublishSubscribe_RoundTrip(t *testing.T) {
|
||||||
ps := newMockPubSub()
|
v := setupNATSTest(t)
|
||||||
v := New()
|
|
||||||
v.Config(Options{PubSub: ps})
|
|
||||||
|
|
||||||
type event struct {
|
type event struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -20,30 +18,31 @@ func TestPublishSubscribe_RoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var got event
|
var got event
|
||||||
var wg sync.WaitGroup
|
done := make(chan struct{})
|
||||||
wg.Add(1)
|
|
||||||
|
|
||||||
c := newContext("typed-ctx", "/", v)
|
c := newContext("typed-ctx", "/", v)
|
||||||
c.View(func() h.H { return h.Div() })
|
c.View(func() h.H { return h.Div() })
|
||||||
|
|
||||||
_, err := Subscribe(c, "events", func(e event) {
|
_, err := Subscribe(c, "events", func(e event) {
|
||||||
got = e
|
got = e
|
||||||
wg.Done()
|
close(done)
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = Publish(c, "events", event{Name: "click", Count: 42})
|
err = Publish(c, "events", event{Name: "click", Count: 42})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
wg.Wait()
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for message")
|
||||||
|
}
|
||||||
assert.Equal(t, "click", got.Name)
|
assert.Equal(t, "click", got.Name)
|
||||||
assert.Equal(t, 42, got.Count)
|
assert.Equal(t, 42, got.Count)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSubscribe_SkipsBadJSON(t *testing.T) {
|
func TestSubscribe_SkipsBadJSON(t *testing.T) {
|
||||||
ps := newMockPubSub()
|
v := setupNATSTest(t)
|
||||||
v := New()
|
|
||||||
v.Config(Options{PubSub: ps})
|
|
||||||
|
|
||||||
type msg struct {
|
type msg struct {
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
@@ -62,5 +61,6 @@ func TestSubscribe_SkipsBadJSON(t *testing.T) {
|
|||||||
err = c.Publish("topic", []byte("not json"))
|
err = c.Publish("topic", []byte("not json"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
assert.False(t, called)
|
assert.False(t, called)
|
||||||
}
|
}
|
||||||
|
|||||||
74
routine.go
74
routine.go
@@ -1,76 +1,34 @@
|
|||||||
package via
|
package via
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OnIntervalRoutine allows for defining concurrent goroutines safely. Goroutines started by *OnIntervalRoutine
|
func newOnInterval(ctxDisposedChan, pageStopChan chan struct{}, duration time.Duration, handler func()) func() {
|
||||||
// are tied to the *Context lifecycle.
|
localInterrupt := make(chan struct{})
|
||||||
type OnIntervalRoutine struct {
|
var stopped atomic.Bool
|
||||||
mu sync.RWMutex
|
|
||||||
ctxDisposed chan struct{}
|
|
||||||
localInterrupt chan struct{}
|
|
||||||
isRunning atomic.Bool
|
|
||||||
routineFn func()
|
|
||||||
tckDuration time.Duration
|
|
||||||
updateTkrChan chan time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateInterval sets a new interval duration for the internal *time.Ticker. If the provided
|
go func() {
|
||||||
// duration is equal of less than 0, UpdateInterval does nothing.
|
tkr := time.NewTicker(duration)
|
||||||
func (r *OnIntervalRoutine) UpdateInterval(d time.Duration) {
|
defer tkr.Stop()
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
r.tckDuration = d
|
|
||||||
r.updateTkrChan <- d
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start executes the predifined goroutine. If no predifined goroutine exists, or it already
|
|
||||||
// started, Start does nothing.
|
|
||||||
func (r *OnIntervalRoutine) Start() {
|
|
||||||
if !r.isRunning.CompareAndSwap(false, true) || r.routineFn == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
go r.routineFn()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop interrupts the predifined goroutine. If no predifined goroutine exists, or it already
|
|
||||||
// ustopped, Stop does nothing.
|
|
||||||
func (r *OnIntervalRoutine) Stop() {
|
|
||||||
if !r.isRunning.CompareAndSwap(true, false) || r.routineFn == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
r.localInterrupt <- struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newOnIntervalRoutine(ctxDisposedChan chan struct{},
|
|
||||||
duration time.Duration, handler func()) *OnIntervalRoutine {
|
|
||||||
r := &OnIntervalRoutine{
|
|
||||||
ctxDisposed: ctxDisposedChan,
|
|
||||||
localInterrupt: make(chan struct{}),
|
|
||||||
updateTkrChan: make(chan time.Duration),
|
|
||||||
}
|
|
||||||
r.tckDuration = duration
|
|
||||||
r.routineFn = func() {
|
|
||||||
r.mu.RLock()
|
|
||||||
tkr := time.NewTicker(r.tckDuration)
|
|
||||||
r.mu.RUnlock()
|
|
||||||
defer tkr.Stop() // clean up the ticker when routine stops
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-r.ctxDisposed: // dispose of the routine when ctx is disposed
|
case <-ctxDisposedChan:
|
||||||
return
|
return
|
||||||
case <-r.localInterrupt: // dispose of the routine on interrupt signal
|
case <-pageStopChan:
|
||||||
|
return
|
||||||
|
case <-localInterrupt:
|
||||||
return
|
return
|
||||||
case d := <-r.updateTkrChan:
|
|
||||||
tkr.Reset(d)
|
|
||||||
case <-tkr.C:
|
case <-tkr.C:
|
||||||
handler()
|
handler()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
if stopped.CompareAndSwap(false, true) {
|
||||||
|
close(localInterrupt)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return r
|
|
||||||
}
|
}
|
||||||
|
|||||||
175
via.go
175
via.go
@@ -35,6 +35,9 @@ import (
|
|||||||
//go:embed datastar.js
|
//go:embed datastar.js
|
||||||
var datastarJS []byte
|
var datastarJS []byte
|
||||||
|
|
||||||
|
//go:embed navigate.js
|
||||||
|
var navigateJS []byte
|
||||||
|
|
||||||
// V is the root application.
|
// V is the root application.
|
||||||
// It manages page routing, user sessions, and SSE connections for live updates.
|
// It manages page routing, user sessions, and SSE connections for live updates.
|
||||||
type V struct {
|
type V struct {
|
||||||
@@ -47,14 +50,17 @@ type V struct {
|
|||||||
documentHeadIncludes []h.H
|
documentHeadIncludes []h.H
|
||||||
documentFootIncludes []h.H
|
documentFootIncludes []h.H
|
||||||
devModePageInitFnMap map[string]func(*Context)
|
devModePageInitFnMap map[string]func(*Context)
|
||||||
|
pageRegistry map[string]func(*Context)
|
||||||
sessionManager *scs.SessionManager
|
sessionManager *scs.SessionManager
|
||||||
pubsub PubSub
|
pubsub PubSub
|
||||||
|
defaultNATS *defaultNATS
|
||||||
actionRateLimit RateLimitConfig
|
actionRateLimit RateLimitConfig
|
||||||
datastarPath string
|
datastarPath string
|
||||||
datastarContent []byte
|
datastarContent []byte
|
||||||
datastarOnce sync.Once
|
datastarOnce sync.Once
|
||||||
reaperStop chan struct{}
|
reaperStop chan struct{}
|
||||||
middleware []Middleware
|
middleware []Middleware
|
||||||
|
layout func(func() h.H) h.H
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *V) logEvent(evt *zerolog.Event, c *Context) *zerolog.Event {
|
func (v *V) logEvent(evt *zerolog.Event, c *Context) *zerolog.Event {
|
||||||
@@ -130,11 +136,18 @@ func (v *V) Config(cfg Options) {
|
|||||||
v.datastarPath = cfg.DatastarPath
|
v.datastarPath = cfg.DatastarPath
|
||||||
}
|
}
|
||||||
if cfg.PubSub != nil {
|
if cfg.PubSub != 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 {
|
if cfg.ContextTTL != 0 {
|
||||||
v.cfg.ContextTTL = cfg.ContextTTL
|
v.cfg.ContextTTL = cfg.ContextTTL
|
||||||
}
|
}
|
||||||
|
if cfg.Streams != nil {
|
||||||
|
v.cfg.Streams = cfg.Streams
|
||||||
|
}
|
||||||
if cfg.ActionRateLimit.Rate != 0 || cfg.ActionRateLimit.Burst != 0 {
|
if cfg.ActionRateLimit.Rate != 0 || cfg.ActionRateLimit.Burst != 0 {
|
||||||
v.actionRateLimit = cfg.ActionRateLimit
|
v.actionRateLimit = cfg.ActionRateLimit
|
||||||
}
|
}
|
||||||
@@ -194,6 +207,7 @@ func (v *V) page(route string, raw, wrapped func(*Context)) {
|
|||||||
c.stopAllRoutines()
|
c.stopAllRoutines()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
v.pageRegistry[route] = wrapped
|
||||||
if v.cfg.DevMode {
|
if v.cfg.DevMode {
|
||||||
v.devModePageInitFnMap[route] = wrapped
|
v.devModePageInitFnMap[route] = wrapped
|
||||||
}
|
}
|
||||||
@@ -221,6 +235,8 @@ func (v *V) page(route string, raw, wrapped func(*Context)) {
|
|||||||
h.Meta(h.Data("init", "@get('/_sse')")),
|
h.Meta(h.Data("init", "@get('/_sse')")),
|
||||||
h.Meta(h.Data("init", fmt.Sprintf(`window.addEventListener('beforeunload', (evt) => {
|
h.Meta(h.Data("init", fmt.Sprintf(`window.addEventListener('beforeunload', (evt) => {
|
||||||
navigator.sendBeacon('/_session/close', '%s');});`, c.id))),
|
navigator.sendBeacon('/_session/close', '%s');});`, c.id))),
|
||||||
|
h.Meta(h.Attr("name", "view-transition"), h.Attr("content", "same-origin")),
|
||||||
|
h.Script(h.Raw(string(navigateJS))),
|
||||||
)
|
)
|
||||||
|
|
||||||
bodyElements := []h.H{c.view()}
|
bodyElements := []h.H{c.view()}
|
||||||
@@ -282,9 +298,16 @@ func (v *V) startReaper() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if ttl == 0 {
|
if ttl == 0 {
|
||||||
ttl = 30 * time.Second
|
ttl = time.Hour
|
||||||
}
|
}
|
||||||
interval := ttl / 3
|
suspendAfter := v.cfg.ContextSuspendAfter
|
||||||
|
if suspendAfter == 0 {
|
||||||
|
suspendAfter = 15 * time.Minute
|
||||||
|
}
|
||||||
|
if suspendAfter > ttl {
|
||||||
|
suspendAfter = ttl
|
||||||
|
}
|
||||||
|
interval := suspendAfter / 3
|
||||||
if interval < 5*time.Second {
|
if interval < 5*time.Second {
|
||||||
interval = 5 * time.Second
|
interval = 5 * time.Second
|
||||||
}
|
}
|
||||||
@@ -297,24 +320,42 @@ func (v *V) startReaper() {
|
|||||||
case <-v.reaperStop:
|
case <-v.reaperStop:
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
v.reapOrphanedContexts(ttl)
|
v.reapOrphanedContexts(suspendAfter, ttl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *V) reapOrphanedContexts(ttl time.Duration) {
|
func (v *V) reapOrphanedContexts(suspendAfter, ttl time.Duration) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
v.contextRegistryMutex.RLock()
|
v.contextRegistryMutex.RLock()
|
||||||
var orphans []*Context
|
var toSuspend, toReap []*Context
|
||||||
for _, c := range v.contextRegistry {
|
for _, c := range v.contextRegistry {
|
||||||
if !c.sseConnected.Load() && now.Sub(c.createdAt) > ttl {
|
if c.sseConnected.Load() {
|
||||||
orphans = append(orphans, c)
|
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()
|
v.contextRegistryMutex.RUnlock()
|
||||||
|
|
||||||
for _, c := range orphans {
|
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.logInfo(c, "reaping orphaned context (no SSE connection after %s)", ttl)
|
||||||
v.cleanupCtx(c)
|
v.cleanupCtx(c)
|
||||||
}
|
}
|
||||||
@@ -323,6 +364,22 @@ func (v *V) reapOrphanedContexts(ttl time.Duration) {
|
|||||||
// 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() {
|
||||||
|
if v.pubsub == nil {
|
||||||
|
dn, err := getSharedNATS()
|
||||||
|
if err != nil {
|
||||||
|
v.logWarn(nil, "embedded NATS unavailable: %v", err)
|
||||||
|
} else {
|
||||||
|
v.defaultNATS = dn
|
||||||
|
v.pubsub = &natsRef{dn: dn}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, sc := range v.cfg.Streams {
|
||||||
|
if err := EnsureStream(v, sc); err != nil {
|
||||||
|
v.logger.Fatal().Err(err).Msgf("failed to create stream %q", sc.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handler := http.Handler(v.mux)
|
handler := http.Handler(v.mux)
|
||||||
if v.sessionManager != nil {
|
if v.sessionManager != nil {
|
||||||
handler = v.sessionManager.LoadAndSave(v.mux)
|
handler = v.sessionManager.LoadAndSave(v.mux)
|
||||||
@@ -379,6 +436,7 @@ func (v *V) Shutdown() {
|
|||||||
v.logErr(nil, "pubsub close error: %v", err)
|
v.logErr(nil, "pubsub close error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
v.defaultNATS = nil
|
||||||
|
|
||||||
v.logInfo(nil, "shutdown complete")
|
v.logInfo(nil, "shutdown complete")
|
||||||
}
|
}
|
||||||
@@ -408,6 +466,11 @@ func (v *V) HTTPServeMux() *http.ServeMux {
|
|||||||
return v.mux
|
return v.mux
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PubSub returns the configured PubSub backend, or nil if none is set.
|
||||||
|
func (v *V) PubSub() PubSub {
|
||||||
|
return v.pubsub
|
||||||
|
}
|
||||||
|
|
||||||
// Static serves files from a filesystem directory at the given URL prefix.
|
// Static serves files from a filesystem directory at the given URL prefix.
|
||||||
//
|
//
|
||||||
// Example:
|
// Example:
|
||||||
@@ -554,6 +617,7 @@ type patchType int
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
patchTypeElements = iota
|
patchTypeElements = iota
|
||||||
|
patchTypeElementsWithVT
|
||||||
patchTypeSignals
|
patchTypeSignals
|
||||||
patchTypeScript
|
patchTypeScript
|
||||||
patchTypeRedirect
|
patchTypeRedirect
|
||||||
@@ -574,6 +638,7 @@ func New() *V {
|
|||||||
logger: newConsoleLogger(zerolog.InfoLevel),
|
logger: newConsoleLogger(zerolog.InfoLevel),
|
||||||
contextRegistry: make(map[string]*Context),
|
contextRegistry: make(map[string]*Context),
|
||||||
devModePageInitFnMap: make(map[string]func(*Context)),
|
devModePageInitFnMap: make(map[string]func(*Context)),
|
||||||
|
pageRegistry: make(map[string]func(*Context)),
|
||||||
sessionManager: scs.New(),
|
sessionManager: scs.New(),
|
||||||
datastarPath: "/_datastar.js",
|
datastarPath: "/_datastar.js",
|
||||||
datastarContent: datastarJS,
|
datastarContent: datastarJS,
|
||||||
@@ -596,39 +661,77 @@ func New() *V {
|
|||||||
}
|
}
|
||||||
c, err := v.getCtx(cID)
|
c, err := v.getCtx(cID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
v.logErr(nil, "sse stream failed to start: %v", err)
|
v.logInfo(nil, "context expired, reloading client: %s", cID)
|
||||||
|
sse := datastar.NewSSE(w, r)
|
||||||
|
sse.ExecuteScript("window.location.reload()")
|
||||||
return
|
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))))
|
||||||
|
|
||||||
// use last-event-id to tell if request is a sse reconnect
|
// use last-event-id to tell if request is a sse reconnect
|
||||||
sse.Send(datastar.EventTypePatchElements, []string{}, datastar.WithSSEEventId("via"))
|
sse.Send(datastar.EventTypePatchElements, []string{}, datastar.WithSSEEventId("via"))
|
||||||
|
|
||||||
|
// Drain stale patches on reconnect so the client gets fresh state
|
||||||
|
if c.sseDisconnectedAt.Load() != nil {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.patchChan:
|
||||||
|
default:
|
||||||
|
goto drained
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drained:
|
||||||
|
}
|
||||||
c.sseConnected.Store(true)
|
c.sseConnected.Store(true)
|
||||||
|
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)
|
||||||
|
defer keepalive.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-sse.Context().Done():
|
case <-sse.Context().Done():
|
||||||
v.logDebug(c, "SSE connection ended")
|
v.logDebug(c, "SSE connection ended")
|
||||||
v.cleanupCtx(c)
|
c.sseConnected.Store(false)
|
||||||
|
dcNow := time.Now()
|
||||||
|
c.sseDisconnectedAt.Store(&dcNow)
|
||||||
return
|
return
|
||||||
case <-c.ctxDisposedChan:
|
case <-c.ctxDisposedChan:
|
||||||
v.logDebug(c, "context disposed, closing SSE")
|
v.logDebug(c, "context disposed, closing SSE")
|
||||||
return
|
return
|
||||||
|
case <-keepalive.C:
|
||||||
|
sse.PatchSignals([]byte("{}"))
|
||||||
case patch := <-c.patchChan:
|
case patch := <-c.patchChan:
|
||||||
switch patch.typ {
|
switch patch.typ {
|
||||||
case patchTypeElements:
|
case patchTypeElements:
|
||||||
if err := sse.PatchElements(patch.content); err != nil {
|
if err := sse.PatchElements(patch.content); err != nil {
|
||||||
// Only log if connection wasn't closed (avoids noise during shutdown/tests)
|
|
||||||
if sse.Context().Err() == nil {
|
if sse.Context().Err() == nil {
|
||||||
v.logErr(c, "PatchElements failed: %v", err)
|
v.logErr(c, "PatchElements failed: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case patchTypeElementsWithVT:
|
||||||
|
if err := sse.PatchElements(patch.content, datastar.WithViewTransitions()); err != nil {
|
||||||
|
if sse.Context().Err() == nil {
|
||||||
|
v.logErr(c, "PatchElements (view transition) failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
case patchTypeSignals:
|
case patchTypeSignals:
|
||||||
if err := sse.PatchSignals([]byte(patch.content)); err != nil {
|
if err := sse.PatchSignals([]byte(patch.content)); err != nil {
|
||||||
if sse.Context().Err() == nil {
|
if sse.Context().Err() == nil {
|
||||||
@@ -708,6 +811,39 @@ func New() *V {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
v.mux.HandleFunc("POST /_navigate", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = r.ParseForm()
|
||||||
|
cID := r.FormValue("via-ctx")
|
||||||
|
csrfToken := r.FormValue("via-csrf")
|
||||||
|
navURL := r.FormValue("url")
|
||||||
|
popstate := r.FormValue("popstate") == "1"
|
||||||
|
|
||||||
|
if cID == "" || navURL == "" || !strings.HasPrefix(navURL, "/") {
|
||||||
|
http.Error(w, "missing or invalid parameters", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c, err := v.getCtx(cID)
|
||||||
|
if err != nil {
|
||||||
|
v.logErr(nil, "navigate failed: %v", err)
|
||||||
|
http.Error(w, "context not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if subtle.ConstantTimeCompare([]byte(csrfToken), []byte(c.csrfToken)) != 1 {
|
||||||
|
v.logWarn(c, "navigate rejected: invalid CSRF token")
|
||||||
|
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.actionLimiter != nil && !c.actionLimiter.Allow() {
|
||||||
|
v.logWarn(c, "navigate rate limited")
|
||||||
|
http.Error(w, "rate limited", http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.reqCtx = r.Context()
|
||||||
|
v.logDebug(c, "SPA navigate to %s", navURL)
|
||||||
|
c.Navigate(navURL, popstate)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
v.mux.HandleFunc("POST /_session/close", func(w http.ResponseWriter, r *http.Request) {
|
v.mux.HandleFunc("POST /_session/close", func(w http.ResponseWriter, r *http.Request) {
|
||||||
body, err := io.ReadAll(r.Body)
|
body, err := io.ReadAll(r.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -725,6 +861,7 @@ func New() *V {
|
|||||||
v.logDebug(c, "session close event triggered")
|
v.logDebug(c, "session close event triggered")
|
||||||
v.cleanupCtx(c)
|
v.cleanupCtx(c)
|
||||||
})
|
})
|
||||||
|
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -757,3 +894,19 @@ func extractParams(pattern, path string) map[string]string {
|
|||||||
}
|
}
|
||||||
return params
|
return params
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// matchRoute finds the registered page init function and extracted params for the given path.
|
||||||
|
func (v *V) matchRoute(path string) (route string, initFn func(*Context), params map[string]string) {
|
||||||
|
for pattern, fn := range v.pageRegistry {
|
||||||
|
if p := extractParams(pattern, path); p != nil {
|
||||||
|
return pattern, fn, p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layout sets a layout function that wraps every page's view.
|
||||||
|
// The layout receives the page content as a function and returns the full view.
|
||||||
|
func (v *V) Layout(f func(func() h.H) h.H) {
|
||||||
|
v.layout = f
|
||||||
|
}
|
||||||
|
|||||||
234
via_test.go
234
via_test.go
@@ -127,7 +127,7 @@ func TestAction(t *testing.T) {
|
|||||||
v.mux.ServeHTTP(w, req)
|
v.mux.ServeHTTP(w, req)
|
||||||
body := w.Body.String()
|
body := w.Body.String()
|
||||||
assert.Contains(t, body, "data-on:click")
|
assert.Contains(t, body, "data-on:click")
|
||||||
assert.Contains(t, body, "data-on:change__debounce.200ms")
|
assert.Contains(t, body, "data-on:change")
|
||||||
assert.Contains(t, body, "data-on:keydown")
|
assert.Contains(t, body, "data-on:keydown")
|
||||||
assert.Contains(t, body, "/_action/")
|
assert.Contains(t, body, "/_action/")
|
||||||
}
|
}
|
||||||
@@ -281,6 +281,112 @@ func TestOnKeyDownMap(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFormatDuration(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
d time.Duration
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{200 * time.Millisecond, "200ms"},
|
||||||
|
{1 * time.Second, "1000ms"},
|
||||||
|
{50 * time.Millisecond, "50ms"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
assert.Equal(t, tt.want, formatDuration(tt.d))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAttrKey(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
event string
|
||||||
|
opts triggerOpts
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"bare event", "click", triggerOpts{}, "on:click"},
|
||||||
|
{"debounce only", "change", triggerOpts{debounce: 200 * time.Millisecond}, "on:change__debounce.200ms"},
|
||||||
|
{"throttle only", "scroll", triggerOpts{throttle: 100 * time.Millisecond}, "on:scroll__throttle.100ms"},
|
||||||
|
{"window only", "keydown", triggerOpts{window: true}, "on:keydown__window"},
|
||||||
|
{"debounce + window", "input", triggerOpts{debounce: 300 * time.Millisecond, window: true}, "on:input__debounce.300ms__window"},
|
||||||
|
{"throttle + window", "scroll", triggerOpts{throttle: 500 * time.Millisecond, window: true}, "on:scroll__throttle.500ms__window"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, buildAttrKey(tt.event, &tt.opts))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithDebounce(t *testing.T) {
|
||||||
|
var trigger *actionTrigger
|
||||||
|
v := New()
|
||||||
|
v.Page("/", func(c *Context) {
|
||||||
|
trigger = c.Action(func() {})
|
||||||
|
c.View(func() h.H {
|
||||||
|
return h.Button(trigger.OnClick(WithDebounce(300 * time.Millisecond)))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
v.mux.ServeHTTP(w, req)
|
||||||
|
body := w.Body.String()
|
||||||
|
assert.Contains(t, body, "data-on:click__debounce.300ms")
|
||||||
|
assert.Contains(t, body, "/_action/"+trigger.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithThrottle(t *testing.T) {
|
||||||
|
var trigger *actionTrigger
|
||||||
|
v := New()
|
||||||
|
v.Page("/", func(c *Context) {
|
||||||
|
trigger = c.Action(func() {})
|
||||||
|
c.View(func() h.H {
|
||||||
|
return h.Div(trigger.OnScroll(WithThrottle(100 * time.Millisecond)))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
v.mux.ServeHTTP(w, req)
|
||||||
|
body := w.Body.String()
|
||||||
|
assert.Contains(t, body, "data-on:scroll__throttle.100ms")
|
||||||
|
assert.Contains(t, body, "/_action/"+trigger.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithDebounceOnChange(t *testing.T) {
|
||||||
|
var trigger *actionTrigger
|
||||||
|
v := New()
|
||||||
|
v.Page("/", func(c *Context) {
|
||||||
|
trigger = c.Action(func() {})
|
||||||
|
c.View(func() h.H {
|
||||||
|
return h.Input(trigger.OnChange(WithDebounce(200 * time.Millisecond)))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
v.mux.ServeHTTP(w, req)
|
||||||
|
body := w.Body.String()
|
||||||
|
assert.Contains(t, body, "data-on:change__debounce.200ms")
|
||||||
|
assert.Contains(t, body, "/_action/"+trigger.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDebounceWithWindow(t *testing.T) {
|
||||||
|
var trigger *actionTrigger
|
||||||
|
v := New()
|
||||||
|
v.Page("/", func(c *Context) {
|
||||||
|
trigger = c.Action(func() {})
|
||||||
|
c.View(func() h.H {
|
||||||
|
return h.Div(trigger.OnKeyDown("Enter", WithDebounce(150*time.Millisecond), WithWindow()))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
v.mux.ServeHTTP(w, req)
|
||||||
|
body := w.Body.String()
|
||||||
|
assert.Contains(t, body, "data-on:keydown__debounce.150ms__window")
|
||||||
|
}
|
||||||
|
|
||||||
func TestConfig(t *testing.T) {
|
func TestConfig(t *testing.T) {
|
||||||
v := New()
|
v := New()
|
||||||
v.Config(Options{DocumentTitle: "Test"})
|
v.Config(Options{DocumentTitle: "Test"})
|
||||||
@@ -303,12 +409,66 @@ func TestReaperCleansOrphanedContexts(t *testing.T) {
|
|||||||
_, err := v.getCtx("orphan-1")
|
_, err := v.getCtx("orphan-1")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
v.reapOrphanedContexts(10 * time.Second)
|
v.reapOrphanedContexts(5*time.Second, 10*time.Second)
|
||||||
|
|
||||||
_, err = v.getCtx("orphan-1")
|
_, err = v.getCtx("orphan-1")
|
||||||
assert.Error(t, err, "orphaned context should have been reaped")
|
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) {
|
func TestReaperIgnoresConnectedContexts(t *testing.T) {
|
||||||
v := New()
|
v := New()
|
||||||
c := newContext("connected-1", "/", v)
|
c := newContext("connected-1", "/", v)
|
||||||
@@ -316,7 +476,7 @@ func TestReaperIgnoresConnectedContexts(t *testing.T) {
|
|||||||
c.sseConnected.Store(true)
|
c.sseConnected.Store(true)
|
||||||
v.registerCtx(c)
|
v.registerCtx(c)
|
||||||
|
|
||||||
v.reapOrphanedContexts(10 * time.Second)
|
v.reapOrphanedContexts(5*time.Second, 10*time.Second)
|
||||||
|
|
||||||
_, err := v.getCtx("connected-1")
|
_, err := v.getCtx("connected-1")
|
||||||
assert.NoError(t, err, "connected context should survive reaping")
|
assert.NoError(t, err, "connected context should survive reaping")
|
||||||
@@ -343,6 +503,74 @@ 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
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
// Package vianats provides an embedded NATS server with JetStream as a
|
|
||||||
// pub/sub backend for Via applications.
|
|
||||||
package vianats
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/delaneyj/toolbelt/embeddednats"
|
|
||||||
"github.com/nats-io/nats.go"
|
|
||||||
"github.com/ryanhamamura/via"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NATS implements via.PubSub using an embedded NATS server with JetStream.
|
|
||||||
type NATS struct {
|
|
||||||
server *embeddednats.Server
|
|
||||||
nc *nats.Conn
|
|
||||||
js nats.JetStreamContext
|
|
||||||
}
|
|
||||||
|
|
||||||
// New starts an embedded NATS server with JetStream enabled and returns a
|
|
||||||
// ready-to-use NATS instance. The server stores data in dataDir and shuts
|
|
||||||
// down when ctx is cancelled.
|
|
||||||
func New(ctx context.Context, dataDir string) (*NATS, error) {
|
|
||||||
ns, err := embeddednats.New(ctx, embeddednats.WithDirectory(dataDir))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("vianats: start server: %w", err)
|
|
||||||
}
|
|
||||||
ns.WaitForServer()
|
|
||||||
|
|
||||||
nc, err := ns.Client()
|
|
||||||
if err != nil {
|
|
||||||
ns.Close()
|
|
||||||
return nil, fmt.Errorf("vianats: connect client: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
js, err := nc.JetStream()
|
|
||||||
if err != nil {
|
|
||||||
nc.Close()
|
|
||||||
ns.Close()
|
|
||||||
return nil, fmt.Errorf("vianats: init jetstream: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &NATS{server: ns, nc: nc, js: js}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish sends data to the given subject using core NATS publish.
|
|
||||||
// JetStream captures messages automatically if a matching stream exists.
|
|
||||||
func (n *NATS) Publish(subject string, data []byte) error {
|
|
||||||
return n.nc.Publish(subject, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribe creates a core NATS subscription for real-time fan-out delivery.
|
|
||||||
func (n *NATS) Subscribe(subject string, handler func(data []byte)) (via.Subscription, error) {
|
|
||||||
sub, err := n.nc.Subscribe(subject, func(msg *nats.Msg) {
|
|
||||||
handler(msg.Data)
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return sub, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close shuts down the client connection and embedded server.
|
|
||||||
func (n *NATS) Close() error {
|
|
||||||
n.nc.Close()
|
|
||||||
return n.server.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Conn returns the underlying NATS connection for advanced usage.
|
|
||||||
func (n *NATS) Conn() *nats.Conn {
|
|
||||||
return n.nc
|
|
||||||
}
|
|
||||||
|
|
||||||
// JetStream returns the JetStream context for stream configuration and replay.
|
|
||||||
func (n *NATS) JetStream() nats.JetStreamContext {
|
|
||||||
return n.js
|
|
||||||
}
|
|
||||||
|
|
||||||
// StreamConfig holds the parameters for creating or updating a JetStream stream.
|
|
||||||
type StreamConfig struct {
|
|
||||||
Name string
|
|
||||||
Subjects []string
|
|
||||||
MaxMsgs int64
|
|
||||||
MaxAge time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnsureStream creates or updates a JetStream stream matching cfg.
|
|
||||||
func EnsureStream(n *NATS, cfg StreamConfig) error {
|
|
||||||
_, err := n.js.AddStream(&nats.StreamConfig{
|
|
||||||
Name: cfg.Name,
|
|
||||||
Subjects: cfg.Subjects,
|
|
||||||
Retention: nats.LimitsPolicy,
|
|
||||||
MaxMsgs: cfg.MaxMsgs,
|
|
||||||
MaxAge: cfg.MaxAge,
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReplayHistory fetches the last limit messages from subject,
|
|
||||||
// deserializing each as T. Returns an empty slice if nothing is available.
|
|
||||||
func ReplayHistory[T any](n *NATS, subject string, limit int) ([]T, error) {
|
|
||||||
sub, err := n.js.SubscribeSync(subject, nats.DeliverAll(), nats.OrderedConsumer())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer sub.Unsubscribe()
|
|
||||||
|
|
||||||
var msgs []T
|
|
||||||
for {
|
|
||||||
raw, err := sub.NextMsg(200 * time.Millisecond)
|
|
||||||
if err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
var msg T
|
|
||||||
if json.Unmarshal(raw.Data, &msg) == nil {
|
|
||||||
msgs = append(msgs, msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if limit > 0 && len(msgs) > limit {
|
|
||||||
msgs = msgs[len(msgs)-limit:]
|
|
||||||
}
|
|
||||||
return msgs, nil
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user