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