Fields created via Context.Field are now tracked on the page context, so ValidateAll() and ResetFields() with no arguments operate on all fields by default. Explicit field args still work for selective use. Also switches MinLen/MaxLen to utf8.RuneCountInString for correct unicode handling and replaces fmt.Errorf with errors.New where format strings are unnecessary.
547 lines
14 KiB
Go
547 lines
14 KiB
Go
package via
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"reflect"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/ryanhamamura/via/h"
|
|
"golang.org/x/time/rate"
|
|
)
|
|
|
|
// Context is the living bridge between Go and the browser.
|
|
//
|
|
// It holds runtime state, defines actions, manages reactive signals, and defines UI through View.
|
|
type Context struct {
|
|
id string
|
|
route string
|
|
csrfToken string
|
|
app *V
|
|
view func() h.H
|
|
routeParams map[string]string
|
|
parentPageCtx *Context
|
|
patchChan chan patch
|
|
actionLimiter *rate.Limiter
|
|
actionRegistry map[string]actionEntry
|
|
signals *sync.Map
|
|
mu sync.RWMutex
|
|
ctxDisposedChan chan struct{}
|
|
reqCtx context.Context
|
|
fields []*Field
|
|
subscriptions []Subscription
|
|
subsMu sync.Mutex
|
|
disposeOnce sync.Once
|
|
createdAt time.Time
|
|
sseConnected atomic.Bool
|
|
}
|
|
|
|
// View defines the UI rendered by this context.
|
|
// The function should return an h.H element (from via/h).
|
|
//
|
|
// Changes to signals or state can be pushed live with Sync().
|
|
func (c *Context) View(f func() h.H) {
|
|
if f == nil {
|
|
panic("nil viewfn")
|
|
}
|
|
c.view = func() h.H { return h.Div(h.ID(c.id), f()) }
|
|
}
|
|
|
|
// Component registers a subcontext that has self contained data, actions and signals.
|
|
// It returns the component's view as a DOM node fn that can be placed in the view
|
|
// of the parent. Components can be added to components.
|
|
//
|
|
// Example:
|
|
//
|
|
// counterCompFn := func(c *via.Context) {
|
|
// (...)
|
|
// }
|
|
//
|
|
// v.Page("/", func(c *via.Context) {
|
|
// counterComp := c.Component(counterCompFn)
|
|
//
|
|
// c.View(func() h.H {
|
|
// return h.Div(
|
|
// h.H1(h.Text("Counter")),
|
|
// counterComp(),
|
|
// )
|
|
// })
|
|
// })
|
|
func (c *Context) Component(initCtx func(c *Context)) func() h.H {
|
|
id := c.id + "/_component/" + genRandID()
|
|
compCtx := newContext(id, c.route, c.app)
|
|
if c.isComponent() {
|
|
compCtx.parentPageCtx = c.parentPageCtx
|
|
} else {
|
|
compCtx.parentPageCtx = c
|
|
}
|
|
initCtx(compCtx)
|
|
return compCtx.view
|
|
}
|
|
|
|
func (c *Context) isComponent() bool {
|
|
return c.parentPageCtx != nil
|
|
}
|
|
|
|
// Action registers an event handler and returns a trigger to that event that
|
|
// that can be added to the view fn as any other via.h element.
|
|
//
|
|
// Example:
|
|
//
|
|
// n := 0
|
|
// increment := c.Action(func(){
|
|
// n++
|
|
// c.Sync()
|
|
// })
|
|
//
|
|
// c.View(func() h.H {
|
|
// return h.Div(
|
|
// h.P(h.Textf("Value of n: %d", n)),
|
|
// h.Button(h.Text("Increment n"), increment.OnClick()),
|
|
// )
|
|
// })
|
|
func (c *Context) Action(f func(), opts ...ActionOption) *actionTrigger {
|
|
id := genRandID()
|
|
if f == nil {
|
|
c.app.logErr(c, "failed to bind action '%s' to context: nil func", id)
|
|
return nil
|
|
}
|
|
|
|
entry := actionEntry{fn: f}
|
|
for _, opt := range opts {
|
|
opt(&entry)
|
|
}
|
|
|
|
if c.isComponent() {
|
|
c.parentPageCtx.actionRegistry[id] = entry
|
|
} else {
|
|
c.actionRegistry[id] = entry
|
|
}
|
|
return &actionTrigger{id}
|
|
}
|
|
|
|
func (c *Context) getAction(id string) (actionEntry, error) {
|
|
if e, ok := c.actionRegistry[id]; ok {
|
|
return e, nil
|
|
}
|
|
return actionEntry{}, fmt.Errorf("action '%s' not found", id)
|
|
}
|
|
|
|
// OnInterval starts a go routine that sets a time.Ticker with the given duration and executes
|
|
// the given handler func() on every tick. Use *Routine.UpdateInterval to update the interval.
|
|
func (c *Context) OnInterval(duration time.Duration, handler func()) *OnIntervalRoutine {
|
|
var cn chan struct{}
|
|
if c.isComponent() { // components use the chan on the parent page ctx
|
|
cn = c.parentPageCtx.ctxDisposedChan
|
|
} else {
|
|
cn = c.ctxDisposedChan
|
|
}
|
|
r := newOnIntervalRoutine(cn, duration, handler)
|
|
return r
|
|
}
|
|
|
|
// Signal creates a reactive signal and initializes it with the given value.
|
|
// Use Bind() to link the value of input elements to the signal and Text() to
|
|
// display the signal value and watch the UI update live as the input changes.
|
|
//
|
|
// Example:
|
|
//
|
|
// mysignal := c.Signal("world")
|
|
//
|
|
// c.View(func() h.H {
|
|
// return h.Div(
|
|
// h.P(h.Span(h.Text("Hello, ")), h.Span(mysignal.Text())),
|
|
// h.Input(mysignal.Bind()),
|
|
// )
|
|
// })
|
|
//
|
|
// Signals are 'alive' only in the browser, but Via always injects their values into
|
|
// the Context before each action call.
|
|
// If any signal value is updated by the server, the update is automatically sent to the
|
|
// browser when using Sync() or SyncSignsls().
|
|
func (c *Context) Signal(v any) *signal {
|
|
sigID := genRandID()
|
|
if v == nil {
|
|
c.app.logErr(c, "failed to bind signal: nil signal value")
|
|
return &signal{
|
|
id: sigID,
|
|
val: "error",
|
|
err: fmt.Errorf("context '%s' failed to bind signal '%s': nil signal value", c.id, sigID),
|
|
}
|
|
}
|
|
switch reflect.TypeOf(v).Kind() {
|
|
case reflect.Slice, reflect.Struct:
|
|
if j, err := json.Marshal(v); err == nil {
|
|
v = string(j)
|
|
}
|
|
}
|
|
sig := &signal{
|
|
id: sigID,
|
|
val: v,
|
|
changed: true,
|
|
}
|
|
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.isComponent() { // components register signals on parent page
|
|
c.parentPageCtx.signals.Store(sigID, sig)
|
|
} else {
|
|
c.signals.Store(sigID, sig)
|
|
}
|
|
return sig
|
|
|
|
}
|
|
|
|
func (c *Context) injectSignals(sigs map[string]any) {
|
|
if sigs == nil {
|
|
c.app.logErr(c, "signal injection failed: nil signals")
|
|
return
|
|
}
|
|
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
for sigID, val := range sigs {
|
|
item, ok := c.signals.Load(sigID)
|
|
if !ok {
|
|
c.signals.Store(sigID, &signal{
|
|
id: sigID,
|
|
val: val,
|
|
})
|
|
continue
|
|
}
|
|
if sig, ok := item.(*signal); ok {
|
|
sig.val = val
|
|
sig.changed = false
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *Context) getPatchChan() chan patch {
|
|
// components use parent page sse stream
|
|
var patchChan chan patch
|
|
if c.isComponent() {
|
|
patchChan = c.parentPageCtx.patchChan
|
|
} else {
|
|
patchChan = c.patchChan
|
|
}
|
|
return patchChan
|
|
}
|
|
|
|
func (c *Context) prepareSignalsForPatch() map[string]any {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
updatedSigs := make(map[string]any)
|
|
c.signals.Range(func(sigID, value any) bool {
|
|
if sig, ok := value.(*signal); ok {
|
|
if sig.err != nil {
|
|
c.app.logWarn(c, "signal '%s' is out of sync: %v", sig.id, sig.err)
|
|
return true
|
|
}
|
|
if sig.changed {
|
|
updatedSigs[sigID.(string)] = fmt.Sprintf("%v", sig.val)
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
return updatedSigs
|
|
}
|
|
|
|
// sendPatch queues a patch on this *Context sse stream. If the sse is closed or queue is full, the patch
|
|
// is dropped to prevent runtime blocks.
|
|
func (c *Context) sendPatch(p patch) {
|
|
patchChan := c.getPatchChan()
|
|
select {
|
|
case patchChan <- p:
|
|
default: // closed or buffer full - drop patch without blocking
|
|
}
|
|
}
|
|
|
|
// Sync pushes the current view state and signal changes to the browser immediately
|
|
// over the live SSE event stream.
|
|
func (c *Context) Sync() {
|
|
elemsPatch := new(bytes.Buffer)
|
|
if err := c.view().Render(elemsPatch); err != nil {
|
|
c.app.logErr(c, "sync view failed: %v", err)
|
|
return
|
|
}
|
|
c.sendPatch(patch{patchTypeElements, elemsPatch.String()})
|
|
|
|
updatedSigs := c.prepareSignalsForPatch()
|
|
|
|
if len(updatedSigs) != 0 {
|
|
outgoingSigs, _ := json.Marshal(updatedSigs)
|
|
c.sendPatch(patch{patchTypeSignals, string(outgoingSigs)})
|
|
}
|
|
}
|
|
|
|
// SyncElements pushes an immediate html patch over the live SSE stream to the
|
|
// browser that merges with the DOM
|
|
//
|
|
// For the merge to occur, each top lever element in the patch needs to have
|
|
// an ID that matches the ID of an element that already sits in the view.
|
|
//
|
|
// Example:
|
|
//
|
|
// If the view already contains the element:
|
|
//
|
|
// h.Div(
|
|
// h.ID("my-element"),
|
|
// h.P(h.Text("Hello from Via!"))
|
|
// )
|
|
//
|
|
// Then, the merge will only occur if the ID of one of the top level elements in the patch
|
|
// matches 'my-element'.
|
|
func (c *Context) SyncElements(elem ...h.H) {
|
|
b := bytes.NewBuffer(nil)
|
|
for idx, el := range elem {
|
|
if el == nil {
|
|
c.app.logWarn(c, "sync elements failed: element at idx=%d is nil", idx)
|
|
continue
|
|
}
|
|
if err := el.Render(b); err != nil {
|
|
c.app.logWarn(c, "sync elements failed: element at idx=%d has invalid html", idx)
|
|
continue
|
|
}
|
|
}
|
|
c.sendPatch(patch{patchTypeElements, b.String()})
|
|
}
|
|
|
|
// SyncSignals pushes the current signal changes to the browser immediately
|
|
// over the live SSE event stream.
|
|
func (c *Context) SyncSignals() {
|
|
updatedSigs := c.prepareSignalsForPatch()
|
|
if len(updatedSigs) != 0 {
|
|
outgoingSignals, _ := json.Marshal(updatedSigs)
|
|
c.sendPatch(patch{patchTypeSignals, string(outgoingSignals)})
|
|
}
|
|
}
|
|
|
|
func (c *Context) ExecScript(s string) {
|
|
if s == "" {
|
|
c.app.logWarn(c, "exec script failed: empty script")
|
|
return
|
|
}
|
|
c.sendPatch(patch{patchTypeScript, s})
|
|
}
|
|
|
|
// RedirectView sets a view that redirects the browser to the given URL.
|
|
// Use this in middleware to abort the chain and redirect in one step.
|
|
func (c *Context) RedirectView(url string) {
|
|
c.View(func() h.H {
|
|
c.Redirect(url)
|
|
return h.Div()
|
|
})
|
|
}
|
|
|
|
// Redirect navigates the browser to the given URL.
|
|
// This triggers a full page navigation - the current context will be disposed
|
|
// and a new context created at the destination URL.
|
|
func (c *Context) Redirect(url string) {
|
|
if url == "" {
|
|
c.app.logWarn(c, "redirect failed: empty url")
|
|
return
|
|
}
|
|
c.sendPatch(patch{patchTypeRedirect, url})
|
|
}
|
|
|
|
// Redirectf navigates the browser to a URL constructed from the format string and arguments.
|
|
func (c *Context) Redirectf(format string, a ...any) {
|
|
c.Redirect(fmt.Sprintf(format, a...))
|
|
}
|
|
|
|
// ReplaceURL updates the browser's URL and history without triggering navigation.
|
|
// Useful for updating query params or path to reflect UI state changes.
|
|
func (c *Context) ReplaceURL(url string) {
|
|
if url == "" {
|
|
c.app.logWarn(c, "replace url failed: empty url")
|
|
return
|
|
}
|
|
c.sendPatch(patch{patchTypeReplaceURL, url})
|
|
}
|
|
|
|
// ReplaceURLf updates the browser's URL using a format string.
|
|
func (c *Context) ReplaceURLf(format string, a ...any) {
|
|
c.ReplaceURL(fmt.Sprintf(format, a...))
|
|
}
|
|
|
|
// dispose idempotently tears down this context: unsubscribes all pubsub
|
|
// subscriptions and closes ctxDisposedChan to stop routines and exit the SSE loop.
|
|
func (c *Context) dispose() {
|
|
c.disposeOnce.Do(func() {
|
|
c.unsubscribeAll()
|
|
c.stopAllRoutines()
|
|
})
|
|
}
|
|
|
|
// stopAllRoutines closes ctxDisposedChan, broadcasting to all listening
|
|
// goroutines (OnIntervalRoutine, SSE loop) that this context is done.
|
|
func (c *Context) stopAllRoutines() {
|
|
select {
|
|
case <-c.ctxDisposedChan:
|
|
// already closed
|
|
default:
|
|
close(c.ctxDisposedChan)
|
|
}
|
|
}
|
|
|
|
func (c *Context) injectRouteParams(params map[string]string) {
|
|
if params == nil {
|
|
return
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.routeParams = params
|
|
}
|
|
|
|
// GetPathParam retrieves the value from the page request URL for the given parameter name
|
|
// or an empty string if not found.
|
|
//
|
|
// Example:
|
|
//
|
|
// v.Page("/users/{user_id}", func(c *via.Context) {
|
|
//
|
|
// userID := GetPathParam("user_id")
|
|
//
|
|
// c.View(func() h.H {
|
|
// return h.Div(
|
|
// h.H1(h.Textf("User ID: %s", userID)),
|
|
// )
|
|
// })
|
|
// })
|
|
func (c *Context) GetPathParam(param string) string {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
if p, ok := c.routeParams[param]; ok {
|
|
return p
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Session returns the session for this context.
|
|
// Session data persists across page views for the same browser.
|
|
// Returns a no-op session if no SessionManager is configured.
|
|
func (c *Context) Session() *Session {
|
|
return &Session{
|
|
ctx: c.reqCtx,
|
|
manager: c.app.sessionManager,
|
|
}
|
|
}
|
|
|
|
// Publish sends data to the given subject via the configured PubSub backend.
|
|
// Returns an error if no PubSub is configured. No-ops during panic-check init.
|
|
func (c *Context) Publish(subject string, data []byte) error {
|
|
if c.id == "" {
|
|
return nil
|
|
}
|
|
if c.app.pubsub == nil {
|
|
return fmt.Errorf("pubsub not configured")
|
|
}
|
|
return c.app.pubsub.Publish(subject, data)
|
|
}
|
|
|
|
// Subscribe creates a subscription on the configured PubSub backend.
|
|
// The subscription is tracked for automatic cleanup when the context is disposed.
|
|
// Returns an error if no PubSub is configured. No-ops during panic-check init.
|
|
func (c *Context) Subscribe(subject string, handler func(data []byte)) (Subscription, error) {
|
|
if c.id == "" {
|
|
return nil, nil
|
|
}
|
|
if c.app.pubsub == nil {
|
|
return nil, fmt.Errorf("pubsub not configured")
|
|
}
|
|
sub, err := c.app.pubsub.Subscribe(subject, handler)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Track on page context for cleanup (components use parent, like signals/actions)
|
|
target := c
|
|
if c.isComponent() {
|
|
target = c.parentPageCtx
|
|
}
|
|
target.subsMu.Lock()
|
|
target.subscriptions = append(target.subscriptions, sub)
|
|
target.subsMu.Unlock()
|
|
return sub, nil
|
|
}
|
|
|
|
// unsubscribeAll cleans up all tracked subscriptions for this context and its components.
|
|
func (c *Context) unsubscribeAll() {
|
|
c.subsMu.Lock()
|
|
subs := c.subscriptions
|
|
c.subscriptions = nil
|
|
c.subsMu.Unlock()
|
|
for _, sub := range subs {
|
|
sub.Unsubscribe()
|
|
}
|
|
}
|
|
|
|
// Field creates a signal with validation rules attached.
|
|
// The initial value seeds both the signal and the reset target.
|
|
// The field is tracked on the context so ValidateAll/ResetFields
|
|
// can operate on all fields by default.
|
|
func (c *Context) Field(initial any, rules ...Rule) *Field {
|
|
f := &Field{
|
|
signal: c.Signal(initial),
|
|
rules: rules,
|
|
initialVal: initial,
|
|
}
|
|
target := c
|
|
if c.isComponent() {
|
|
target = c.parentPageCtx
|
|
}
|
|
target.fields = append(target.fields, f)
|
|
return f
|
|
}
|
|
|
|
// ValidateAll runs Validate on each field, returning true only if all pass.
|
|
// With no arguments it validates every field tracked on this context.
|
|
func (c *Context) ValidateAll(fields ...*Field) bool {
|
|
if len(fields) == 0 {
|
|
fields = c.fields
|
|
}
|
|
ok := true
|
|
for _, f := range fields {
|
|
if !f.Validate() {
|
|
ok = false
|
|
}
|
|
}
|
|
return ok
|
|
}
|
|
|
|
// ResetFields resets each field to its initial value and clears errors.
|
|
// With no arguments it resets every field tracked on this context.
|
|
func (c *Context) ResetFields(fields ...*Field) {
|
|
if len(fields) == 0 {
|
|
fields = c.fields
|
|
}
|
|
for _, f := range fields {
|
|
f.Reset()
|
|
}
|
|
}
|
|
|
|
func newContext(id string, route string, v *V) *Context {
|
|
if v == nil {
|
|
panic("create context failed: app pointer is nil")
|
|
}
|
|
|
|
return &Context{
|
|
id: id,
|
|
route: route,
|
|
csrfToken: genCSRFToken(),
|
|
routeParams: make(map[string]string),
|
|
app: v,
|
|
actionLimiter: newLimiter(v.actionRateLimit, defaultActionRate, defaultActionBurst),
|
|
actionRegistry: make(map[string]actionEntry),
|
|
signals: new(sync.Map),
|
|
patchChan: make(chan patch, 1),
|
|
ctxDisposedChan: make(chan struct{}, 1),
|
|
createdAt: time.Now(),
|
|
}
|
|
}
|