- Add navMu to serialize concurrent navigations on the same context - Replace url.PathEscape with targeted JS string escaper (PathEscape mangles full paths and doesn't escape single quotes) - Collapse syncWithViewTransition into syncView(bool) to remove duplication - Simplify popstate ready guard in navigate.js - Preserve URL hash during SPA navigation
604 lines
16 KiB
Go
604 lines
16 KiB
Go
package via
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"reflect"
|
|
"strings"
|
|
"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
|
|
navMu sync.Mutex
|
|
ctxDisposedChan chan struct{}
|
|
pageStopChan 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")
|
|
}
|
|
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.
|
|
// 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 goroutine that executes handler on every tick of the given duration.
|
|
// The goroutine is tied to the context lifecycle and will stop when the context is disposed.
|
|
// Returns a func() that stops the interval when called.
|
|
func (c *Context) OnInterval(duration time.Duration, handler func()) func() {
|
|
var disposeCh, pageCh chan struct{}
|
|
if c.isComponent() {
|
|
disposeCh = c.parentPageCtx.ctxDisposedChan
|
|
pageCh = c.parentPageCtx.pageStopChan
|
|
} else {
|
|
disposeCh = c.ctxDisposedChan
|
|
pageCh = c.pageStopChan
|
|
}
|
|
return newOnInterval(disposeCh, pageCh, duration, handler)
|
|
}
|
|
|
|
// 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() {
|
|
c.syncView(false)
|
|
}
|
|
|
|
func (c *Context) syncView(viewTransition bool) {
|
|
elemsPatch := new(bytes.Buffer)
|
|
if err := c.view().Render(elemsPatch); err != nil {
|
|
c.app.logErr(c, "sync view failed: %v", err)
|
|
return
|
|
}
|
|
typ := patchType(patchTypeElements)
|
|
if viewTransition {
|
|
typ = patchTypeElementsWithVT
|
|
}
|
|
c.sendPatch(patch{typ, 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...))
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
|
|
// 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
|
|
// 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 (OnInterval, 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, 8),
|
|
ctxDisposedChan: make(chan struct{}, 1),
|
|
pageStopChan: make(chan struct{}),
|
|
createdAt: time.Now(),
|
|
}
|
|
}
|