Scope: This reference documents the
livetemplateGo library (github.com/livetemplate/livetemplate). For the CLI tool, see the lvt repository.
package main
import (
"log"
"net/http"
"github.com/livetemplate/livetemplate"
)
type AppController struct {
// Dependencies (singleton, never cloned)
}
type AppState struct {
Count int
}
func (c *AppController) Increment(state AppState, ctx *livetemplate.Context) (AppState, error) {
state.Count++
return state, nil
}
func main() {
tmpl, err := livetemplate.New("app")
if err != nil {
log.Fatal(err)
}
tmpl.Parse(`<h1>Count: {{.Count}}</h1><button name="increment">+</button>`)
handler := tmpl.Handle(&AppController{}, livetemplate.AsState(&AppState{}))
http.Handle("/", handler)
log.Fatal(http.ListenAndServe(":8080", nil))
}
The Template type manages template parsing, execution, and tree-based update generation.
func New(name string, opts ...Option) (*Template, error)
Creates a new template with the given name and options. Auto-discovers template files in the caller's directory.
tmpl, err := livetemplate.New("todos",
livetemplate.WithDevMode(true),
livetemplate.WithSessionStore(redisStore),
)
func (t *Template) Parse(text string) (*Template, error)
Parses a template string. Supports {{define}}, {{block}}, and {{template}} composition via automatic flattening.
func (t *Template) ParseFiles(filenames ...string) (*Template, error)
Parses template files. The first file is the main template; additional files provide definitions.
func (t *Template) ParseGlob(pattern string) (*Template, error)
Parses all template files matching the glob pattern.
func (t *Template) Funcs(funcMap template.FuncMap) *Template
Registers custom template functions. Must be called before Parse or ParseFiles.
func (t *Template) Handle(controller interface{}, state State, opts ...HandleOption) LiveHandler
Creates an HTTP/WebSocket handler from a controller and initial state. The controller is a singleton holding dependencies; state is cloned per session.
handler := tmpl.Handle(&TodoController{DB: db}, livetemplate.AsState(&TodoState{}))
HandleOption:
| Option | Description |
|---|---|
WithEphemeralSweepTTL(ttl time.Duration) |
How long an idle ephemeral HTTP cache entry survives before eviction (default 30m; no effect in persistent mode) |
The session store is set at construction via
WithSessionStore(aNewoption), not per-handler.
func Validate(templateText string, opts ...ValidateOption) ([]Diagnostic, error)
Parses templateText the way the live renderer does — against the framework's real function set and any supplied component templates — and returns the problems found. An empty slice means the template parses cleanly and will be served rather than silently dropped.
Why this exists: Execute and ExecuteUpdates catch a first-render failure and fall back to an HTML-structure tree. A malformed template — an unclosed {{range}}, an unknown function, an unresolved {{template}} — therefore renders degraded and returns no error. A tool that wants to reject a bad template before serving it had nothing to call. Validate is that call.
type Diagnostic struct {
Line int // 1-based line in the supplied text; 0 if the parser reported none
Severity Severity // SeverityError today; SeverityWarning is reserved
Message string
}
| Severity | Meaning |
|---|---|
SeverityError |
A problem that prevents the template from being served: the block is dropped at serve time and renders nothing. |
SeverityWarning |
Reserved for problems that degrade a template rather than break it — the home for the data-dependent checks a future sample-data mode would surface. Not emitted today. |
Diagnostics are not errors. The returned error is reserved for infrastructure failures — a component set that itself fails to parse, or an internal fault. A template that does not parse is always reported as a Diagnostic, never as an error. This mirrors the shape of a linter: problems in the input are data, not errors.
diags, err := livetemplate.Validate(text)
if err != nil {
return fmt.Errorf("validation could not run: %w", err) // infrastructure fault
}
for _, d := range diags {
fmt.Printf("%s:%d: %s: %s\n", path, d.Line, d.Severity, d.Message)
}
if len(diags) > 0 {
return errors.New("template rejected")
}
What it checks: the syntax and composition layer — unclosed or malformed actions ({{range}}, {{if}}, {{with}}), unknown functions (checked against the framework's own builtins, which a downstream caller cannot enumerate, so this check cannot be reproduced outside the module), and unresolved component or composition templates.
What it does not check: data-dependent problems that only surface when the template is executed against a value. Those are out of scope until a sample-data mode is added.
At most one diagnostic per call today. The underlying parser stops at the first error rather than recovering, so the slice has length 0 or 1. The slice shape anticipates the multi-error reporting a recovering pass could add.
func WithValidateComponents(sets ...*TemplateSet) ValidateOption
Makes the given component template sets available, so a template invoking {{template "ns:name" .}} resolves the same way it does at serve time. Pass the same sets you pass to New(WithComponentTemplates(...)); without them a component reference is reported — correctly — as an unresolved template.
diags, err := livetemplate.Validate(text,
livetemplate.WithValidateComponents(uiKit),
)
Each call re-parses the supplied component sets from their fs.FS. That suits pre-serve and dev-time validation rather than per-keystroke use against a large component library.
Line-number caveat: HTML comments are stripped before parsing (matching serve), so a diagnostic below a multi-line HTML comment can report a line shifted by the comment's height.
For patterns, examples, and usage guide, see Controller+State Pattern.
type State interface {
encoding.BinaryMarshaler
encoding.BinaryUnmarshaler
Inner() any
}
State represents serializable session data. Use AsState[T]() instead of implementing directly.
func AsState[T any](s *T) State
Wraps a plain struct pointer to satisfy the State interface using JSON serialization.
Controllers may implement these optional lifecycle methods:
// Called on every HTTP request (GET and POST) and every WebSocket connect (new and reconnect)
func (c *Controller) Mount(state S, ctx *livetemplate.Context) (S, error)
// Called on each WebSocket connect (including reconnects)
func (c *Controller) OnConnect(state S, ctx *livetemplate.Context) (S, error)
// Called when a WebSocket disconnects
func (c *Controller) OnDisconnect()
Action methods handle user interactions. They receive the current state and return the modified state:
func (c *Controller) ActionName(state S, ctx *livetemplate.Context) (S, error)
Action dispatch is automatic: <button name="addItem"> dispatches to AddItem().
| Tag | Description |
|---|---|
lvt:"persist" |
Field is persisted to SessionStore, survives page refresh. Fields without this tag are ephemeral. |
func AssertPureState[T any](t *testing.T)
Validates that a state type contains only serializable data (no *sql.DB, *slog.Logger, etc.).
Context is the unified context for all lifecycle and action methods. It embeds context.Context.
func NewContext(ctx context.Context, action string, data map[string]interface{}) *Context
Primarily useful in tests. In production, Context is created internally and passed to controller methods.
| Method | Signature | Description |
|---|---|---|
Action |
() string |
Returns the action name that triggered this context |
UserID |
() string |
Returns the authenticated user's ID |
Session |
() Session |
Returns the Session for server-initiated actions |
IsHTTP |
() bool |
Whether this is an HTTP (not WebSocket) context |
SelfTopic |
() string |
Returns the reserved-namespace topic (lvt:session:<groupID>) that identifies this session's own connections. ACL-exempt — always subscribable. |
Subscribe |
(topic string) error |
Subscribes the calling connection to a topic. SelfTopic() bypasses the ACL; developer topics run WithTopicACL. Returns *TopicForbiddenError when the ACL denies. |
Unsubscribe |
(topic string) error |
Removes the calling connection's subscription. |
Publish |
(topic, action string, data map[string]interface{}) |
Queues a named action dispatch to every connection subscribed to the topic. Fan-out is opt-in — only Subscribers receive it. Deferred until the current action completes successfully. |
| Method | Signature | Description |
|---|---|---|
GetString |
(key string) string |
Get a string value from action data |
GetInt |
(key string) int |
Get an integer value |
GetFloat |
(key string) float64 |
Get a float value |
GetBool |
(key string) bool |
Get a boolean value |
Has |
(key string) bool |
Check if a key exists in action data |
Get |
(key string) interface{} |
Get a raw value |
Bind |
(v interface{}) error |
Unmarshal action data into a struct |
BindAndValidate |
(v interface{}, validate *validator.Validate) error |
Bind and validate in one step |
These methods return ErrNoHTTPContext when called from a WebSocket action.
| Method | Signature | Description |
|---|---|---|
SetCookie |
(cookie *http.Cookie) error |
Set an HTTP cookie |
DeleteCookie |
(name string) error |
Delete an HTTP cookie |
GetCookie |
(name string) (*http.Cookie, error) |
Get an HTTP cookie |
Redirect |
(url string, code int) error |
Send an HTTP redirect (3xx only, relative paths only) |
| Method | Signature | Description |
|---|---|---|
HasUploads |
(name string) bool |
Check if uploads exist for a field |
GetCompletedUploads |
(name string) []*UploadEntry |
Get completed upload entries |
func (c *Context) SetFlash(key, message string, opts ...FlashOption)
func (c *Context) ClearFlash(key string)
func FlashExpiry(d time.Duration) FlashOption
Manages flash messages available in templates via .lvt.Flash(key). Common keys: "success", "error", "info", "warning".
Flash persists until explicitly cleared with ClearFlash (or until FlashExpiry elapses). Background updates such as TriggerAction or scan-loop refreshes do not touch flash. See Flash Message Lifecycle for the full lifecycle, multi-tab behavior, and v0.8 → v0.9 migration guidance.
| Method | Signature | Description |
|---|---|---|
WithUserID |
(userID string) *Context |
Returns new Context with user ID |
WithSession |
(session Session) *Context |
Returns new Context with session |
WithHTTP |
(w http.ResponseWriter, r *http.Request) *Context |
Returns new Context with HTTP |
WithAction |
(action string) *Context |
Returns new Context with action name |
WithData |
(data map[string]interface{}) *Context |
Returns new Context with data |
WithUploads |
(uploads UploadAccessor) *Context |
Returns new Context with uploads |
WithFlashSetter |
(setter FlashSetter) *Context |
Returns new Context with flash setter |
type Session interface {
TriggerAction(action string, data map[string]interface{}) error
}
Enables server-initiated actions for every connection in the current session group. Use cases: timers, background job notifications, webhook-triggered updates.
Scope — Session.TriggerAction targets a session group (groupID),
not a user identity (userID). For the typical anonymous flow where each
browser session maps to one group via cookie, this is equivalent to
"all tabs of this browser". For authenticated flows the mapping depends
on how the Authenticator assigns groupIDs:
GetSessionGroup returns a stable groupID keyed on userID, all of
a user's devices share one group and TriggerAction fans out to all
of them.GetSessionGroup returns a per-session groupID, each device has
its own group and TriggerAction only fans out within a single
device's tabs.Accessed via ctx.Session() inside your controller's OnConnect(state, ctx)
lifecycle method (or any action method). The returned Session handle
can be captured and used from background goroutines. See
Server Actions for examples.
func Async[S any, R any](
ctx *Context,
work func(context.Context) (R, error),
apply func(s S, result R, err error) (S, error),
)
Runs work off the connection event loop, then re-enters the loop to apply
its result to the current session state and re-render the originating
connection. Reduces the manual two-action loading pattern
from ~15 lines / 2 methods to ~7 lines / 1 method.
work runs in a supervised goroutine. It receives a context.Context
tied to the connection's lifetime (cancelled on disconnect). It must not
touch session state — only its own inputs.apply runs on the event loop against the latest state at
completion time, not a snapshot from when work started. Any state
changes from other actions during the async window are visible. Mutate
only the fields you own.work completes, the goroutine is
cancelled and apply never runs.Async gets the completion render. For group-wide fan-out, capture
session := ctx.Session() before defining apply, then call
session.TriggerAction() from within the apply closure (ctx
itself is not in scope inside apply).apply receives only (state, result, err) — there is no *Context,
so the completion render cannot set a flash, write a cookie, or navigate.
Anything that needs ctx on the second render has to arrive as a real
action: keep the manual two-action pattern, or capture the session and
TriggerAction from inside apply. State changes are unaffected — those
are what apply returns.Example:
func (c *Controller) Greet(state State, ctx *livetemplate.Context) (State, error) {
state.Loading = true
name := strings.TrimSpace(ctx.GetString("name"))
livetemplate.Async(ctx,
func(ctx context.Context) (string, error) {
time.Sleep(700 * time.Millisecond) // simulate slow work
return name, nil
},
func(s State, name string, err error) (State, error) {
s.Name = name
s.Loading = false
return s, nil
},
)
return state, nil // render #1: Loading=true (spinner on)
// render #2 happens automatically when work completes: Loading=false
}
Scope: Async is supported only inside action handlers (e.g.
Greet, Save) that run on the per-connection WebSocket event loop.
Calling Async in Mount(), OnConnect(), dispatched actions, server-
initiated actions, upload handlers, or HTTP POST handlers logs a warning
and drops the operation — there is no persistent connection or event loop
to re-enter.
{{.lvt.Pending}}: A framework-provided template variable that is true
on the render that registered async work and false on all other renders.
It has per-render semantics: if another action or a peer dispatch
triggers a render on the same connection while async work is still in
flight, that render will see Pending=false (it did not register async
work). For loading indicators that must stay visible across interleaved
renders, use an explicit Loading bool field in your state instead.
Use {{.lvt.Pending}} when the loading indicator is purely visual and
single-action flows are the norm:
<button name="greet" {{if .lvt.Pending}}disabled{{end}}>
{{if .lvt.Pending}}Loading...{{else}}Greet{{end}}
</button>
See Loading States §7.3 for the full comparison of loading approaches.
type LiveHandler interface {
http.Handler
Shutdown(ctx context.Context) error
MetricsHandler() http.Handler
Publish(topic, action string, data map[string]interface{}) error
Func() http.HandlerFunc
}
Returned by Template.Handle(). Serves both HTTP and WebSocket requests.
| Method | Description |
|---|---|
ServeHTTP |
Handles HTTP requests and WebSocket upgrades |
Shutdown |
Gracefully drains connections with context timeout |
MetricsHandler |
Returns Prometheus metrics endpoint handler |
Publish |
Fans a topic action out to subscribers from outside an action handler (PubSub) |
Func |
Returns ServeHTTP as an http.HandlerFunc |
A LiveHandler is an ordinary net/http handler — one ServeHTTP serves the
initial GET render, form-action POSTs, and the WebSocket upgrade. It composes
with net/http routing directly, and Func() covers the entry points that take
a function instead of an http.Handler:
handler := tmpl.Handle(&CounterController{}, livetemplate.AsState(&CounterState{}))
http.Handle("/counter", handler) // http.Handler
mux.Handle("/counter", handler) // ServeMux
http.HandleFunc("/counter", handler.Func()) // http.HandlerFunc
mux.HandleFunc("GET /counter", handler.Func()) // Go 1.22+ method patterns
mux.HandleFunc("POST /counter", handler.Func()) // form actions still dispatch
Func() is exactly handler.ServeHTTP — neither form is preferred, and taking
it does not give up Shutdown, Publish or MetricsHandler, which stay on the
LiveHandler it came from.
Mounting under a subtree with http.StripPrefix is supported; the handler reads
the request path as rewritten.
Middleware must forward http.Hijacker. A WebSocket upgrade takes over the
raw connection, so it needs the http.ResponseWriter to implement
http.Hijacker. Middleware that only observes the request is fine; middleware
that wraps the writer (logging, gzip, status capture) hides Hijack unless it
forwards the method:
// Breaks the upgrade: embedding promotes Write/Header/WriteHeader, not Hijack.
type wrapped struct{ http.ResponseWriter }
The failure is partial and easy to miss — GET and POST keep rendering, only the
upgrade is refused with a 500 — so a failed upgrade logs a hint naming
middleware whenever the writer is not hijackable. To keep such middleware,
either forward Hijack to the underlying writer, or leave the writer unwrapped
when livetemplate.WSIsUpgrade(r) is true.
The hint is attached on the writer's own defect, which need not be what the
accompanying error reports — an upgrader can reject a handshake earlier (a
disallowed Origin, say) and never reach the hijack. Read it as a second
failure the upgrade would have hit regardless, not as the reported cause.
type Authenticator interface {
Identify(r *http.Request) (userID string, err error)
GetSessionGroup(r *http.Request, userID string) (groupID string, err error)
}
Maps HTTP requests to user IDs and session groups.
AnonymousAuthenticator (default): Browser-based session grouping via persistent cookie. All tabs in the same browser share state. No configuration needed.
BasicAuthenticator: HTTP Basic Auth with user-provided validation.
Security: Always use HTTPS. No built-in rate limiting or account lockout -- enforce brute-force protection externally.
func NewBasicAuthenticator(validateFunc func(username, password string) (bool, error)) *BasicAuthenticator
type SessionStore interface {
Get(ctx context.Context, groupID string) interface{}
Set(ctx context.Context, groupID string, state interface{})
Delete(ctx context.Context, groupID string)
List(ctx context.Context) []string
}
Optional optimization for targeted persistence of individual stores:
type SingleStoreSetter interface {
SetStore(ctx context.Context, groupID string, storeName string, store interface{})
}
In-memory store with automatic cleanup. Suitable for single-instance deployments.
func NewMemorySessionStore(opts ...SessionStoreOption) *MemorySessionStore
| Option | Default | Description |
|---|---|---|
WithCleanupTTL(ttl) |
24h | TTL for inactive session groups |
WithCleanupInterval(interval) |
1h | Cleanup goroutine interval |
Redis-backed store for distributed deployments. Supports Redis, Redis Cluster, Ring, and Sentinel.
func NewRedisSessionStore(client redis.UniversalClient, opts ...RedisSessionStoreOption) *RedisSessionStore
| Option | Default | Description |
|---|---|---|
WithSessionTTL(ttl) |
24h | Session expiry in Redis |
WithMaxRetries(n) |
3 | Retry attempts with exponential backoff |
WithRetryDelay(delay) |
100ms | Base delay between retries |
Both implementations satisfy SingleStoreSetter.
func LoadEnvConfig() (*EnvConfig, error)
Load configuration from environment variables with LVT_ prefix. Call config.ToOptions()... to convert to template options.
| Variable | Type | Default | Description |
|---|---|---|---|
LVT_MAX_CONNECTIONS |
int64 | 0 (unlimited) | Max concurrent WebSocket connections |
LVT_MAX_CONNECTIONS_PER_GROUP |
int64 | 0 (unlimited) | Max connections per session group |
LVT_ALLOWED_ORIGINS |
string | "" | Comma-separated allowed WebSocket origins |
LVT_DEV_MODE |
bool | false | Enable development mode |
LVT_WEBSOCKET_DISABLED |
bool | false | HTTP-only mode |
LVT_LOADING_DISABLED |
bool | false | Disable automatic loading indicator |
LVT_TEMPLATE_BASE_DIR |
string | "" | Base directory for template discovery |
LVT_PROGRESSIVE_ENHANCEMENT |
bool | true | Enable non-JS form submission |
LVT_WS_BUFFER_SIZE |
int | 50 | WebSocket send buffer size per connection |
Options passed to New():
| Option | Signature | Description |
|---|---|---|
WithDevMode |
(enabled bool) |
Enable dev mode: allows all WS origins, verbose logging, {{.lvt.DevMode}} |
WithSessionStore |
(store SessionStore) |
Set session store |
WithAuthenticator |
(auth Authenticator) |
Set authenticator |
WithAllowedOrigins |
(origins []string) |
Allowed WebSocket origins |
WithPermissiveOriginCheck |
() |
Bypass origin check without dev mode (WithDevMode already relaxes origins) |
WithMaxConnections |
(max int64) |
Max WebSocket connections |
WithMaxConnectionsPerGroup |
(max int64) |
Max connections per group |
WithWebSocketDisabled |
() |
HTTP-only mode |
WithWebSocketBufferSize |
(size int) |
WebSocket send buffer size |
WithLoadingDisabled |
() |
Disable loading indicator |
WithMessageRateLimit |
(messagesPerSecond float64, burstCapacity int) |
WebSocket rate limiting |
WithCookieMaxAge |
(maxAge time.Duration) |
Session cookie max age |
WithUpgrader |
(upgrader *websocket.Upgrader) |
Custom WebSocket upgrader |
WithParseFiles |
(files ...string) |
Explicit template files |
WithParseFS |
(fsys fs.FS, patterns ...string) |
Templates from an fs.FS (e.g. embed.FS); precedence over WithParseFiles |
WithTemplateBaseDir |
(dir string) |
Template discovery base dir |
WithIgnoreTemplateDirs |
(dirs ...string) |
Skip directories during discovery |
WithUpload |
(name string, config UploadConfig) |
Configure upload field |
WithPubSubBroadcaster |
(broadcaster pubsub.Broadcaster) |
Enable cross-instance peer fan-out via Redis Pub/Sub |
WithComponentTemplates |
(sets ...*TemplateSet) |
Register component templates |
WithProgressiveEnhancement |
(enabled bool) |
Non-JS form submission support |
type UploadConfig struct {
Accept []string // Allowed MIME types or extensions
MaxEntries int // Max concurrent files (0 = unlimited)
MaxFileSize int64 // Max file size in bytes (0 = unlimited)
AutoUpload bool // Start upload on file selection
ChunkSize int // WebSocket chunk size (default: 256KB)
External Presigner // Optional presigner for direct-to-storage uploads
}
type UploadEntry struct {
ID string
ClientName string // Original filename
ClientType string // MIME type
ClientSize int64 // File size in bytes
Progress int // 0-100
Done bool // Upload completed
Error string // Error message
TempPath string // Server-side temp file (server uploads)
ExternalRef string // Storage reference (external uploads)
}
type Presigner interface {
Presign(entry *UploadEntry) (UploadMeta, error)
}
type UploadMeta struct {
Uploader string // Provider name (e.g., "s3")
URL string // Presigned upload URL
Fields map[string]string // Form fields for multipart POST
Headers map[string]string // HTTP headers for PUT
}
For complete upload documentation including S3 configuration, see Upload Reference.
func NewHealthHandler(timeout time.Duration) *HealthHandler
Provides Kubernetes-ready /health/live and /health/ready endpoints. Register checkers via health.RegisterChecker(name, checker).
type HealthChecker interface {
Check(ctx context.Context) error
}
Built-in: NewSessionStoreHealthChecker(store), NewRedisHealthChecker(store).
Package pubsub provides cross-instance messaging for horizontally scaled deployments. See the PubSub Reference for the complete API including Broadcaster, DynamicSubscriber, the livetemplate:broadcast:* channel namespace, channel schema, and subscription lifecycle.
| Error | Description |
|---|---|
ErrNoHTTPContext |
HTTP method called from WebSocket action |
ErrInvalidRedirectCode |
Redirect status code is not 3xx |
ErrInvalidRedirectURL |
Redirect URL is not a valid relative path |
ErrMethodNotFound |
No controller method matches the action name |
type FieldError struct {
Field string
Message string
}
func NewFieldError(field string, err error) FieldError
type MultiError []FieldError
A collection of field errors. Implements the error interface. Return from action methods to display per-field validation errors in templates.
func ValidationToMultiError(err error) MultiError
Converts go-playground/validator errors to MultiError.