Switch from the standard library log package to rs/zerolog with ConsoleWriter for colorful terminal output in dev mode and JSON output in production. Users can now provide their own logger via Options.Logger or set the level via Options.LogLevel.
473 lines
12 KiB
Go
473 lines
12 KiB
Go
package via
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"maps"
|
|
"reflect"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/ryanhamamura/via/h"
|
|
)
|
|
|
|
// 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
|
|
app *V
|
|
view func() h.H
|
|
routeParams map[string]string
|
|
componentRegistry map[string]*Context
|
|
parentPageCtx *Context
|
|
patchChan chan patch
|
|
actionRegistry map[string]func()
|
|
signals *sync.Map
|
|
mu sync.RWMutex
|
|
ctxDisposedChan chan struct{}
|
|
reqCtx context.Context
|
|
subscriptions []Subscription
|
|
subsMu sync.Mutex
|
|
}
|
|
|
|
// 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)
|
|
c.componentRegistry[id] = 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()) *actionTrigger {
|
|
id := genRandID()
|
|
if f == nil {
|
|
c.app.logErr(c, "failed to bind action '%s' to context: nil func", id)
|
|
return nil
|
|
}
|
|
|
|
if c.isComponent() {
|
|
c.parentPageCtx.actionRegistry[id] = f
|
|
} else {
|
|
c.actionRegistry[id] = f
|
|
}
|
|
return &actionTrigger{id}
|
|
}
|
|
|
|
func (c *Context) getActionFn(id string) (func(), error) {
|
|
if f, ok := c.actionRegistry[id]; ok {
|
|
return f, nil
|
|
}
|
|
return nil, 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 {
|
|
if _, ok := c.signals.Load(sigID); !ok {
|
|
c.signals.Store(sigID, &signal{
|
|
id: sigID,
|
|
val: val,
|
|
})
|
|
continue
|
|
}
|
|
item, _ := c.signals.Load(sigID)
|
|
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 := bytes.NewBuffer(make([]byte, 0))
|
|
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})
|
|
}
|
|
|
|
// 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...))
|
|
}
|
|
|
|
// stopAllRoutines stops all go routines tied to this Context preventing goroutine leaks.
|
|
func (c *Context) stopAllRoutines() {
|
|
select {
|
|
case c.ctxDisposedChan <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
func (c *Context) injectRouteParams(params map[string]string) {
|
|
if params == nil {
|
|
return
|
|
}
|
|
m := make(map[string]string)
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
maps.Copy(m, params)
|
|
c.routeParams = m
|
|
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
}
|
|
|
|
func newContext(id string, route string, v *V) *Context {
|
|
if v == nil {
|
|
panic("create context failed: app pointer is nil")
|
|
}
|
|
|
|
return &Context{
|
|
id: id,
|
|
route: route,
|
|
routeParams: make(map[string]string),
|
|
app: v,
|
|
componentRegistry: make(map[string]*Context),
|
|
actionRegistry: make(map[string]func()),
|
|
signals: new(sync.Map),
|
|
patchChan: make(chan patch, 1),
|
|
ctxDisposedChan: make(chan struct{}, 1),
|
|
}
|
|
}
|