Server Actions Reference

Server actions let you push updates from server-side code to connected clients. Use them for timers, webhooks, background job notifications, real-time data feeds, and any scenario where the server initiates a UI update.

Overview

LiveTemplate supports two types of updates:

Type Trigger Scope Use Case
Client Action User interaction (click, submit) Same session group Form submissions, button clicks
Server Action Server-side code Same session group Timers, webhooks, background jobs

Server actions use the Session interface to trigger updates:

// From any goroutine - timer, webhook handler, background job
session.TriggerAction("notification", map[string]interface{}{
    "message": "Your export is ready!",
})

Session Interface

type Session interface {
    // TriggerAction dispatches the action to the controller,
    // then sends the updated template to ALL connections for this user.
    TriggerAction(action string, data map[string]interface{}) error
}

Key Points:

Getting the Session Reference

Access the Session through the OnConnect lifecycle method on your controller:

type TimerController struct {
    session livetemplate.Session
    mu      sync.Mutex
}

func (c *TimerController) OnConnect(state TimerState, ctx *livetemplate.Context) (TimerState, error) {
    c.mu.Lock()
    c.session = ctx.Session()
    c.mu.Unlock()

    // Start background timer
    go c.runTimer(ctx)
    return state, nil
}

func (c *TimerController) OnDisconnect() {
    c.mu.Lock()
    c.session = nil
    c.mu.Unlock()
}

Lifecycle:

1. WebSocket connection established
   └─► OnConnect(state, ctx) called
       └─► Store ctx.Session() for later use

2. Connection active
   └─► Use session.TriggerAction() from background goroutines

3. WebSocket connection closed
   └─► OnDisconnect() called
       └─► Clean up session reference

Context (ctx):

Complete Timer Example

type TimerState struct {
    Seconds int
}

type TimerController struct {
    session livetemplate.Session
    mu      sync.Mutex
}

func (c *TimerController) OnConnect(state TimerState, ctx *livetemplate.Context) (TimerState, error) {
    c.mu.Lock()
    c.session = ctx.Session()
    c.mu.Unlock()

    // Start background timer
    go c.runTimer(ctx)
    return state, nil
}

func (c *TimerController) OnDisconnect() {
    c.mu.Lock()
    c.session = nil
    c.mu.Unlock()
}

func (c *TimerController) runTimer(ctx context.Context) {
    ticker := time.NewTicker(time.Second)
    defer ticker.Stop()

    for {
        select {
        case <-ctx.Done():
            return // Connection closed
        case <-ticker.C:
            c.mu.Lock()
            session := c.session
            c.mu.Unlock()

            if session != nil {
                session.TriggerAction("tick", nil)
            }
        }
    }
}

func (c *TimerController) Tick(state TimerState, ctx *livetemplate.Context) (TimerState, error) {
    state.Seconds++
    return state, nil
}

func (c *TimerController) Reset(state TimerState, ctx *livetemplate.Context) (TimerState, error) {
    state.Seconds = 0
    return state, nil
}

Common Patterns

Timer/Tick Updates

Periodic updates (dashboards, live data, countdowns):

func (c *Controller) OnConnect(state State, ctx *livetemplate.Context) (State, error) {
    c.session = ctx.Session()
    go c.runTicker(ctx)
    return state, nil
}

func (c *Controller) runTicker(ctx context.Context) {
    ticker := time.NewTicker(5 * time.Second)
    defer ticker.Stop()

    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            if c.session != nil {
                c.session.TriggerAction("refresh", nil)
            }
        }
    }
}

func (c *Controller) Refresh(state State, ctx *livetemplate.Context) (State, error) {
    state.Data = c.fetchLatestData()
    return state, nil
}

Webhook-Triggered Updates

External events pushing updates to users:

// HTTP handler receives webhook from external service
func handleWebhook(w http.ResponseWriter, r *http.Request) {
    var payload WebhookPayload
    json.NewDecoder(r.Body).Decode(&payload)

    // Get session for target user (stored during OnConnect)
    if session := getUserSession(payload.UserID); session != nil {
        session.TriggerAction("notification", map[string]interface{}{
            "message": payload.Message,
            "type":    "webhook",
        })
    }

    w.WriteHeader(http.StatusOK)
}

Welcome Message After Connect

Greet users after page loads:

func (c *AuthController) OnConnect(state AuthState, ctx *livetemplate.Context) (AuthState, error) {
    c.session = ctx.Session()

    if state.IsLoggedIn {
        // Send welcome after short delay (let page render first)
        go func() {
            time.Sleep(500 * time.Millisecond)
            if c.session != nil {
                c.session.TriggerAction("serverWelcome", map[string]interface{}{
                    "message": fmt.Sprintf("Welcome back, %s!", state.Username),
                })
            }
        }()
    }

    return state, nil
}

func (c *AuthController) ServerWelcome(state AuthState, ctx *livetemplate.Context) (AuthState, error) {
    state.WelcomeMessage = ctx.GetString("message")
    return state, nil
}

Background Job Completion

Notify users when async jobs finish. Use proper cleanup with context cancellation:

type ExportState struct {
    ExportStatus string
}

type ExportController struct {
    session      livetemplate.Session
    cancelExport context.CancelFunc
    mu           sync.Mutex
}

func (c *ExportController) OnConnect(state ExportState, ctx *livetemplate.Context) (ExportState, error) {
    c.mu.Lock()
    c.session = ctx.Session()
    c.mu.Unlock()
    return state, nil
}

func (c *ExportController) OnDisconnect() {
    c.mu.Lock()
    defer c.mu.Unlock()

    // Cancel any running export when user disconnects
    if c.cancelExport != nil {
        c.cancelExport()
        c.cancelExport = nil
    }
    c.session = nil
}

func (c *ExportController) StartExport(state ExportState, ctx *livetemplate.Context) (ExportState, error) {
    // Create cancellable context for the background job
    jobCtx, cancel := context.WithCancel(context.Background())

    c.mu.Lock()
    c.cancelExport = cancel
    c.mu.Unlock()

    go func() {
        defer cancel() // Clean up when done

        result, err := performLongRunningExport(jobCtx)

        // Check if cancelled before notifying
        select {
        case <-jobCtx.Done():
            return // User disconnected, don't notify
        default:
        }

        c.mu.Lock()
        session := c.session
        c.mu.Unlock()

        if session != nil {
            if err != nil {
                session.TriggerAction("exportFailed", map[string]interface{}{
                    "error": err.Error(),
                })
            } else {
                session.TriggerAction("exportComplete", map[string]interface{}{
                    "downloadURL": result.URL,
                })
            }
        }
    }()

    state.ExportStatus = "Processing..."
    return state, nil
}

func (c *ExportController) ExportComplete(state ExportState, ctx *livetemplate.Context) (ExportState, error) {
    state.ExportStatus = "Complete"
    state.DownloadURL = ctx.GetString("downloadURL")
    return state, nil
}

func (c *ExportController) ExportFailed(state ExportState, ctx *livetemplate.Context) (ExportState, error) {
    state.ExportStatus = "Failed: " + ctx.GetString("error")
    return state, nil
}

Real-time Notifications

Push notifications from any part of your application:

// Global session registry (thread-safe)
var userSessions = sync.Map{}

func (c *Controller) OnConnect(state State, ctx *livetemplate.Context) (State, error) {
    c.session = ctx.Session()
    userSessions.Store(ctx.UserID(), c.session)
    return state, nil
}

func (c *Controller) OnDisconnect() {
    userSessions.Delete(c.userID)
    c.session = nil
}

// Call from anywhere in your application
func NotifyUser(userID string, message string) {
    if session, ok := userSessions.Load(userID); ok {
        session.(livetemplate.Session).TriggerAction("notification", map[string]interface{}{
            "message": message,
        })
    }
}

Thread Safety

Session methods are thread-safe and can be called from any goroutine:

// Safe: Multiple goroutines using session concurrently
go func() { session.TriggerAction("update1", nil) }()
go func() { session.TriggerAction("update2", nil) }()

However, you must protect access to the session field itself:

type Controller struct {
    session livetemplate.Session
    mu      sync.Mutex
}

func (c *Controller) OnConnect(state State, ctx *livetemplate.Context) (State, error) {
    c.mu.Lock()
    c.session = ctx.Session()
    c.mu.Unlock()
    return state, nil
}

func (c *Controller) OnDisconnect() {
    c.mu.Lock()
    c.session = nil
    c.mu.Unlock()
}

func (c *Controller) triggerFromBackground() {
    c.mu.Lock()
    session := c.session
    c.mu.Unlock()

    if session != nil {
        session.TriggerAction("update", nil)
    }
}

Security Model

Session is scoped to the current user only:

Why this design?

Multi-Tab/Multi-Device Behavior

When a user has multiple tabs or devices connected:

Client Action (from Tab 1):

User clicks button in Tab 1
    └─► Tab 1's action method called
        └─► action may call ctx.Publish(ctx.SelfTopic(), "RefreshTodos", nil)
        └─► Tab 1 receives update
        └─► Tab 2, Tab 3 receive the explicit peer action — but only if they Subscribed to ctx.SelfTopic() in Mount

Cross-tab updates are explicit and two-step: subscribe to ctx.SelfTopic() in Mount (the ACL-exempt self-identity topic), then call ctx.Publish(ctx.SelfTopic(), "ActionName", nil) from the action that changed shared state. A connection that did not subscribe receives nothing — peer fan-out is opt-in.

Server Action (TriggerAction):

Background job completes
    └─► session.TriggerAction("jobComplete", data)
        └─► ALL tabs receive the action via action method
        └─► ALL tabs are updated simultaneously

Disconnect & Reconnect Contract

TriggerAction is best-effort, not durable. When a background goroutine calls TriggerAction during a brief WebSocket disconnect (network blip, tab throttling, cellular handoff), the payload is lost — the framework does not buffer or replay it. The cookie-bound groupID is stable across reconnects, so the next TriggerAction after the WebSocket comes back will reach the user, but the dispatch that fired during the gap is gone.

This is a deliberate design — see the TriggerAction reconnect-buffering proposal.

Detecting the gap

In single-instance mode, TriggerAction returns the typed sentinel ErrSessionDisconnected when the session has no local connections and the configured broadcaster (if any) does not implement pubsub.GroupActionBroadcaster. A plain pubsub.Broadcaster that lacks the GroupActionBroadcaster capability still triggers this sentinel — the type-assertion gate, not the presence of a broadcaster, is what matters:

go func() {
    for {
        time.Sleep(tickRate)
        if err := session.TriggerAction("tick", payload); err != nil {
            if errors.Is(err, livetemplate.ErrSessionDisconnected) {
                return // Clean shutdown — session is gone.
            }
            slog.Warn("TriggerAction transient failure", "err", err)
            // continue or return depending on caller policy
        }
    }
}()

In multi-instance mode (with a broadcaster that implements pubsub.GroupActionBroadcaster), TriggerAction returns nil even with zero local connections — the broadcaster may deliver the dispatch to another instance. A persistent PubSub outage logs publish-failure warnings but TriggerAction keeps returning nil, so the error return is not a reliable stop signal under multi-instance deployments. Goroutines must therefore impose their own lifetime bound.

The simplest pattern is a self-bounded goroutine — finite iterations, no controller state, no OnDisconnect coordination required. The sketch below uses stand-in names (tickRate, payload); substitute your concrete tick interval and action data:

func (c *Ctrl) OnConnect(state State, ctx *livetemplate.Context) (State, error) {
    session := ctx.Session() // always non-nil in lifecycle methods; see handleWebSocket in mount.go
    go func() {
        const maxTicks = 60 // pick a horizon appropriate to the job
        for i := 0; i < maxTicks; i++ {
            time.Sleep(tickRate)
            // In multi-instance mode the error return is not a stop signal
            // (TriggerAction returns nil with zero local connections), but
            // it IS an observability hook for transient pubsub failures.
            // Log at warn level rather than discarding. Single-instance
            // callers MUST check for ErrSessionDisconnected and exit on
            // it — see the example earlier in this section.
            if err := session.TriggerAction("tick", payload); err != nil {
                slog.Warn("TriggerAction failed", "err", err)
            }
        }
    }()
    return state, nil
}

For unbounded or externally-cancellable work, the goroutine needs a context.CancelFunc — but do not store that cancel on the controller as a single field. Controllers are singletons (one *Controller serves every session — see controller-pattern.md), so a single stopWork slot is overwritten by the next user's OnConnect, and OnDisconnect() has no parameter to identify which session is disconnecting. Cancel funcs must be keyed by groupID (or similar per-session identifier) in a sync.Map, mirroring the NotificationController pattern in controller-pattern.md. Do not pass *livetemplate.Context to the goroutine — that context lives only for the duration of one action call.

Recovery contract: idempotent handlers + OnConnect re-spawn

Two rules cover the gap:

  1. Push handlers must be idempotent. A handler that runs once must produce the same final state as one that runs twice. The reconnect-during-loading double-fire race documented under Implementation Notes in patterns.md makes this concrete: if the client disconnects and reconnects while a goroutine is still sleeping, two goroutines may race to dispatch — both land successfully on the new connection. Idempotent handlers absorb this; non-idempotent ones (counter increments, list appends, side effects) corrupt state.

  2. Reconnect recovery lives in OnConnect. Persisted state (any field tagged lvt:"persist") is restored before OnConnect runs on the new connection. Use that state to detect "work was in flight when the prior connection dropped" and re-spawn.

    Load-bearing requirement: the field backing the predicate below (state.InProgress() in the sketch) must carry the lvt:"persist" tag. Unpersisted fields reset to their zero value on reconnect, so the re-spawn guard would never fire — a silent footgun that makes the recovery pattern look like it's working in single-render tests but silently fail in production.

    // Sketch with stand-in names — substitute your concrete state type
    // and predicate (InProgress, runWork, JobID).
    
    type State struct {
        JobID   string `lvt:"persist"` // identifies the in-flight job
        Loading bool   `lvt:"persist"` // backing field for the predicate below
        // ... other fields ...
    }
    
    // Both fields above MUST carry lvt:"persist" or this method returns
    // false on every reconnect (Loading would reset to its zero value)
    // and the re-spawn guard never fires.
    func (s State) InProgress() bool { return s.Loading }
    
    func (c *Ctrl) OnConnect(state State, ctx *livetemplate.Context) (State, error) {
        // Re-spawn whenever state shows in-flight work. On a fresh new-connect,
        // InProgress() is the zero value (false), so this is a no-op. On
        // reconnect, restored persisted state reflects whatever the prior
        // connection committed.
        if !state.InProgress() {
            return state, nil
        }
        // Local capture — the goroutine holds this reference for the duration
        // of the work. No need to store on the controller like the timer
        // examples above; this re-spawn is one-shot per OnConnect call.
        session := ctx.Session()
        // runWork must (a) be idempotent across multiple OnConnect re-spawns
        // (the same JobID may be respawned if the client reconnects mid-flight)
        // and (b) terminate cleanly — either by exiting on
        // ErrSessionDisconnected (single-instance) or by bounding its
        // iteration count (multi-instance). See the canonical goroutine
        // patterns earlier in this section.
        go runWork(session, state.JobID)
        return state, nil
    }
    

Prefer the state.InProgress() check in the recipe above over ctx.IsReconnect(). The state-predicate check covers both fresh connects and reconnects without needing to disambiguate them, and sidesteps the subtle helper semantics described below.

ctx.IsReconnect() has non-obvious semantics worth knowing if you do reach for it directly: it returns true whenever any persisted state was restored, including the normal initial-HTTP-GET → WS flow — not only post-blip reconnects. (The framework persists state at the end of the HTTP-path Mount and restores it when the WS opens, so the first WS OnConnect after a fresh page load also sees IsReconnect() == true.) This behavior requires at least one lvt:"persist" field on the state struct; states with no persist fields always produce IsReconnect()==false because there is nothing to restore. Pairing with ctx.IsNewConnect() only distinguishes "brand-new WS session with no persisted history at all" from "any persisted state was restored" — it does not separate "first WS after page load" from "WS resumed after a blip," since both have persisted state and so both produce IsReconnect()==true, IsNewConnect()==false. See the Controller Pattern reference for the full semantics.

When the contract is not enough

If you have a push that genuinely cannot be made idempotent (strict once-only audit log, paid-API result stream, etc.) the implicit contract is not enough. Open a new issue referencing #342 and describing the exact non-idempotency. The buffering proposal captures the design sketch for the durable variant that would solve it, gated on a real use case.

Distributed Deployments

In multi-instance deployments, TriggerAction() automatically publishes to Redis so all instances can update their local connections. See the PubSub Reference for setup, channel schema, and subscription lifecycle.

See Also

source: livetemplate/livetemplate · path: docs/references/server-actions.md · ref: v0.13.0 · commit: 4c5f1c71b2de