diff --git a/content/contributing/livetemplate.md b/content/contributing/livetemplate.md index 2510cf3..fd70ffa 100644 --- a/content/contributing/livetemplate.md +++ b/content/contributing/livetemplate.md @@ -2,8 +2,8 @@ title: "Contributing to LiveTemplate Core Library" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "CONTRIBUTING.md" -source_ref: "v0.16.0" -source_commit: "f4f9147c7066382d821c022caa48683d0886ad9a" +source_ref: "v0.19.1" +source_commit: "fe690899b1400a0c3886206038c0b958b40554be" --- # Contributing to LiveTemplate Core Library diff --git a/content/guides/ephemeral-components.md b/content/guides/ephemeral-components.md index d582769..8f8824f 100644 --- a/content/guides/ephemeral-components.md +++ b/content/guides/ephemeral-components.md @@ -2,8 +2,8 @@ title: "Ephemeral Components Guide" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/guides/ephemeral-components.md" -source_ref: "v0.16.0" -source_commit: "f4f9147c7066382d821c022caa48683d0886ad9a" +source_ref: "v0.19.1" +source_commit: "fe690899b1400a0c3886206038c0b958b40554be" --- # Ephemeral Components Guide diff --git a/content/guides/observability.md b/content/guides/observability.md index 5e4bfd6..c4fde1d 100644 --- a/content/guides/observability.md +++ b/content/guides/observability.md @@ -2,8 +2,8 @@ title: "LiveTemplate Observability Guide" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/guides/OBSERVABILITY.md" -source_ref: "v0.16.0" -source_commit: "f4f9147c7066382d821c022caa48683d0886ad9a" +source_ref: "v0.19.1" +source_commit: "fe690899b1400a0c3886206038c0b958b40554be" --- # LiveTemplate Observability Guide diff --git a/content/guides/progressive-complexity.md b/content/guides/progressive-complexity.md index a5f3ffe..5916d14 100644 --- a/content/guides/progressive-complexity.md +++ b/content/guides/progressive-complexity.md @@ -2,8 +2,8 @@ title: "Progressive Complexity Guide" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/guides/progressive-complexity.md" -source_ref: "v0.16.0" -source_commit: "f4f9147c7066382d821c022caa48683d0886ad9a" +source_ref: "v0.19.1" +source_commit: "fe690899b1400a0c3886206038c0b958b40554be" --- # Progressive Complexity Guide diff --git a/content/guides/scaling.md b/content/guides/scaling.md index 2055034..ce9deb4 100644 --- a/content/guides/scaling.md +++ b/content/guides/scaling.md @@ -2,8 +2,8 @@ title: "LiveTemplate Scaling Guide" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/guides/SCALING.md" -source_ref: "v0.16.0" -source_commit: "f4f9147c7066382d821c022caa48683d0886ad9a" +source_ref: "v0.19.1" +source_commit: "fe690899b1400a0c3886206038c0b958b40554be" --- # LiveTemplate Scaling Guide diff --git a/content/guides/standard-html-reactivity.md b/content/guides/standard-html-reactivity.md index ff35f7d..67525b8 100644 --- a/content/guides/standard-html-reactivity.md +++ b/content/guides/standard-html-reactivity.md @@ -2,8 +2,8 @@ title: "Standard HTML Reactivity" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/guides/standard-html-reactivity.md" -source_ref: "v0.16.0" -source_commit: "f4f9147c7066382d821c022caa48683d0886ad9a" +source_ref: "v0.19.1" +source_commit: "fe690899b1400a0c3886206038c0b958b40554be" --- # Standard HTML Reactivity diff --git a/content/reference/api.md b/content/reference/api.md index 31a7bb7..b20d368 100644 --- a/content/reference/api.md +++ b/content/reference/api.md @@ -2,8 +2,8 @@ title: "Go Library API Reference" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/references/api-reference.md" -source_ref: "v0.16.0" -source_commit: "f4f9147c7066382d821c022caa48683d0886ad9a" +source_ref: "v0.19.1" +source_commit: "fe690899b1400a0c3886206038c0b958b40554be" --- # Go Library API Reference @@ -111,7 +111,9 @@ handler := tmpl.Handle(&TodoController{DB: db}, livetemplate.AsState(&TodoState{ | Option | Description | |--------|-------------| -| `WithStore(store SessionStore)` | Override the session store for this handler | +| `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` (a `New` option), not per-handler. --- @@ -423,11 +425,11 @@ Options passed to `New()`: | Option | Signature | Description | |--------|-----------|-------------| -| `WithDevMode` | `(enabled bool)` | Enable development mode | +| `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 (dev only) | +| `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 | @@ -437,6 +439,7 @@ Options passed to `New()`: | `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 | diff --git a/content/reference/authentication.md b/content/reference/authentication.md index abb3cae..24b9da9 100644 --- a/content/reference/authentication.md +++ b/content/reference/authentication.md @@ -2,8 +2,8 @@ title: "Authentication Reference" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/references/authentication.md" -source_ref: "v0.16.0" -source_commit: "f4f9147c7066382d821c022caa48683d0886ad9a" +source_ref: "v0.19.1" +source_commit: "fe690899b1400a0c3886206038c0b958b40554be" --- # Authentication Reference diff --git a/content/reference/client-attributes.md b/content/reference/client-attributes.md index 88c7a4f..afcab42 100644 --- a/content/reference/client-attributes.md +++ b/content/reference/client-attributes.md @@ -2,8 +2,8 @@ title: "Client Attributes Reference" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/references/client-attributes.md" -source_ref: "v0.16.0" -source_commit: "f4f9147c7066382d821c022caa48683d0886ad9a" +source_ref: "v0.19.1" +source_commit: "fe690899b1400a0c3886206038c0b958b40554be" --- # Client Attributes Reference diff --git a/content/reference/configuration.md b/content/reference/configuration.md index a3fd3e5..f6c9657 100644 --- a/content/reference/configuration.md +++ b/content/reference/configuration.md @@ -2,8 +2,8 @@ title: "LiveTemplate Configuration Guide" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/references/CONFIGURATION.md" -source_ref: "v0.16.0" -source_commit: "f4f9147c7066382d821c022caa48683d0886ad9a" +source_ref: "v0.19.1" +source_commit: "fe690899b1400a0c3886206038c0b958b40554be" --- # LiveTemplate Configuration Guide @@ -140,9 +140,9 @@ Enable development mode. - **Accepted values**: `true`, `false`, `1`, `0`, `yes`, `no`, `on`, `off` (case-insensitive) **Features when enabled**: -- Uses local client library instead of CDN +- Allows **all** WebSocket origins — disables the same-origin/CSRF check, so `WithPermissiveOriginCheck()` is redundant in dev (**never enable in production**) - More verbose logging -- Less strict origin checking +- Exposes `{{.lvt.DevMode}}` to templates **Use case**: Local development and debugging. @@ -181,6 +181,18 @@ Base directory for template auto-discovery. **Use case**: Override automatic template directory detection. Useful in containerized deployments where `runtime.Caller` may resolve to an unexpected path. +#### `LVT_MAX_TEMPLATE_DEPTH` + +Maximum nesting depth for recursive `{{template}}` invocations while building the reactive tree. + +- **Type**: Integer +- **Default**: `128` +- **Example**: `LVT_MAX_TEMPLATE_DEPTH=256` +- **Validation**: Must be a positive integer; an invalid value is a hard startup error +- **Option**: `WithMaxTemplateDepth(n)` + +**Use case**: Recursive templates (file trees, comment threads, nested navigation) render self-referential data. This cap stops a *cycle in the data* — a node whose children contain itself — from overflowing the stack, surfacing an error instead. Raise it only when your data is legitimately deeper than the default; the cap is a safety net, not a tuning knob. See [Template Support Matrix — Recursion depth](template-support-matrix.md#recursion-depth) for the first-render versus update behavior. + ### Graceful Shutdown #### `LVT_SHUTDOWN_TIMEOUT` @@ -345,7 +357,7 @@ tmpl := livetemplate.New("app", | `WithPubSubBroadcaster(broadcaster)` | Redis pub/sub for distributed deployments | | `WithComponentTemplates(sets...)` | Register component template sets | | `WithIgnoreTemplateDirs(dirs...)` | Skip directories during template discovery | -| `WithPermissiveOriginCheck()` | Bypass origin check (dev only) | +| `WithPermissiveOriginCheck()` | Bypass origin check without dev mode (`WithDevMode` already relaxes origins) | | `WithProgressiveEnhancement(enabled)` | Non-JS form submission support (default: true) | | `WithCookieMaxAge(duration)` | Session cookie max age (default: 365 days) | diff --git a/content/reference/controller-pattern.md b/content/reference/controller-pattern.md index 54ef9cd..ab50a73 100644 --- a/content/reference/controller-pattern.md +++ b/content/reference/controller-pattern.md @@ -2,8 +2,8 @@ title: "Controller+State Pattern Reference" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/references/controller-pattern.md" -source_ref: "v0.16.0" -source_commit: "f4f9147c7066382d821c022caa48683d0886ad9a" +source_ref: "v0.19.1" +source_commit: "fe690899b1400a0c3886206038c0b958b40554be" --- # Controller+State Pattern Reference @@ -213,10 +213,100 @@ func (c *TodoController) OnDisconnect() { - Cancel background jobs - Unsubscribe from data feeds +## State methods in templates + +Exported zero-arg methods on your State type are callable from templates just like +fields — `{{.ActiveCount}}` resolves to `ActiveCount()`'s return value. Methods +returning `(T, error)` are omitted (with a warning) when the error is non-nil. + +Because LiveTemplate converts State to a map before rendering, these methods are +*precomputed* — but only for methods your templates actually reference. A method that +appears nowhere in any template is never called, so an expensive or side-effecting +helper you keep on State but don't render costs nothing. + +Two caveats: + +- **Conditional references still run.** The scoping is by name, not by reachability: + `{{if .Show}}{{.Expensive}}{{end}}` still calls `Expensive()` on every render even + when `.Show` is false, because the name appears in the template text. Keep genuinely + expensive work in an action method, not a render-time State method. +- **Prefer methods without side effects.** Precompute timing is an implementation + detail; a State method should compute a view of the state, not mutate anything or + perform I/O. + +### Methods that take arguments (view helpers) + +Sometimes you want a method that takes a key and returns a per-item view — e.g. a +CSS class for a given row, or a formatted label for a given id. **Put it on a struct +field, not on the top-level State**, and call it through that field: + +```go +type RowViews struct{ selected map[string]bool } + +func (v RowViews) Class(id string) string { + if v.selected[id] { + return "row selected" + } + return "row" +} + +type BoardState struct { + Rows []Row + Views RowViews // a "view helper" sub-struct +} +``` + +```html +{{range .Rows}} +
{{.Label}}
+{{end}} +``` + +This works in both the initial HTTP render and WebSocket updates, and replaces the +common workaround of precomputing a `map[string]string` in an action and looking it +up by key in the template. It is the same shape as the framework's built-in +`{{.lvt.AriaInvalid "field"}}`. + +**Why the field matters — top-level arg-methods are not supported.** LiveTemplate +converts State to a map before rendering (to inject the `{{.lvt}}` namespace), and +Go's `html/template` cannot call an argument-accepting method once its receiver is a +map key — `{{.Class .ID}}` directly on State fails with *"Class is not a method but +has arguments"*. A struct **field** is stored in the map as a struct value, so both +renderers call its methods natively. Zero-arg State methods are unaffected (they are +precomputed, as above); only *argument-accepting* methods need the field. + ## Context API For the complete Context API (data extraction, HTTP operations, struct binding), see [API Reference — Context](api-reference.md#context). +### Request context + +`*livetemplate.Context` **embeds `context.Context`** — it carries the request/connection +context (cancellation, deadline, request-scoped values). So `ctx` *is* a `context.Context`: +pass it directly to any context-aware call (database queries, outbound HTTP, tracing) instead +of `context.Background()`. + +```go +func (c *TodoController) Add(state TodoState, ctx *livetemplate.Context) (TodoState, error) { + // ✅ ctx is a context.Context — propagates cancellation when the request/connection ends + if err := c.DB.InsertTodo(ctx, todo); err != nil { + return state, err + } + return state, nil +} +``` + +Reaching for `context.Background()` inside an action instead silently discards that +cancellation and any deadline or trace attached to the request: + +```go +// ❌ throws away request-scoped cancellation, deadline, and tracing +if err := c.DB.InsertTodo(context.Background(), todo); err != nil { ... } +``` + +Use `context.Background()` only for work that must deliberately **outlive** the request (e.g. +a fire-and-forget goroutine you start from the action) — not for the action's own calls. + ## Error Handling For validation errors, field errors, and template error display, see [Error Handling Reference](error-handling.md). @@ -266,7 +356,8 @@ type TodoController struct { } func (c *TodoController) Mount(state TodoState, ctx *livetemplate.Context) (TodoState, error) { - items, err := c.DB.GetTodos() + // Pass ctx (a context.Context) so the query is cancelled if the request ends. + items, err := c.DB.GetTodos(ctx) if err != nil { return state, fmt.Errorf("failed to load todos: %w", err) } @@ -285,7 +376,7 @@ func (c *TodoController) Add(state TodoState, ctx *livetemplate.Context) (TodoSt Title: title, } - if err := c.DB.InsertTodo(todo); err != nil { + if err := c.DB.InsertTodo(ctx, todo); err != nil { return state, fmt.Errorf("database error") } @@ -390,6 +481,22 @@ func (c *ChatController) RefreshMessages(state ChatState, ctx *livetemplate.Cont } ``` +**The published action reloads data only — it is not a re-Mount.** Notice `RefreshMessages` re-reads the messages and nothing else. Reach for the tempting shortcut and it bites you: + +```go +// ❌ Don't do this. A fan-out tick is neither a page load nor a connect. +func (c *ChatController) RefreshMessages(state ChatState, ctx *livetemplate.Context) (ChatState, error) { + return c.Mount(state, ctx) +} +``` + +`Mount` does **connect-time** work on top of loading: it `Subscribe`s to topics and runs any `IsInitialMount()`/`IsReconnect()`/`IsNewConnect()`-guarded setup (analytics, background goroutines, presence). A fan-out dispatch is an *action* — its connect-kind is `ConnectKindAction`, so all three of those helpers return `false`. Delegating the action to `Mount` therefore means any connect-kind-guarded setup **silently never runs**, and the unguarded `Subscribe` re-runs on every single broadcast — a cheap no-op for `ctx.SelfTopic()` (ACL-exempt and idempotent, as here), but a real per-tick ACL re-check for a developer topic like `Subscribe("room/" + id)`. Keep the two jobs separate: + +- **`Mount`** — subscribe + load. Runs on HTTP GET/POST, WS connect, WS reconnect. +- **The published action** (`RefreshMessages`) — load only. Runs on every fan-out tick. + +The shortcut *appears* to work when `Mount` is a pure data-load with no `Subscribe` or guarded setup (its fields just get re-set to the same values) — which is exactly why it's a trap: it breaks silently the day `Mount` grows a connect-time concern. So split them unconditionally, even while `Mount` is still a plain load; don't wait for the break. The runnable [`live-dashboard`](https://github.com/livetemplate/docs/tree/main/examples/live-dashboard) example follows the split from the start: `Mount` subscribes then snapshots; its `Refresh` action snapshots only. + **Ordering.** `Publish` queues onto a per-action drain. Call it **after** every `ctx.With*()` shallow-copy mutation; publishes queued before a `With*()` are stranded on the pre-copy Context and won't propagate. **Cap.** A single action can enqueue at most `MaxPublishesPerAction` (declared in `topic_context.go`) `Publish` calls before subsequent calls become hard errors. @@ -447,6 +554,25 @@ handler := tmpl.Handle(controller, livetemplate.AsState(initialState)) http.Handle("/", handler) ``` +### Serving static assets alongside the app + +The handler returned by `Handle` is mounted at `/`, where Go's `ServeMux` treats it as a **catch-all**: every request that doesn't match a more specific pattern falls through to it (including the WebSocket upgrade). To serve static files — images, CSS, downloads — next to your app, register them under a **more specific prefix** on the same mux. Longest-prefix-match routes those requests to the file server and everything else to the app: + +```go +handler := tmpl.Handle(controller, livetemplate.AsState(initialState)) + +// Files under /assets/… are served from ./assets. http.Dir already rejects +// "../" traversal, so http.FileServer needs no extra guard. +http.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("assets")))) + +// The app owns everything else. +http.Handle("/", handler) +``` + +The prefix just has to be distinct from your app's own routes — `ServeMux` does the rest. This composition is plain `net/http`; the framework adds nothing here on purpose. + +**When a prefix isn't enough.** If your app renders content that references assets at **arbitrary top-level paths** — e.g. user-authored HTML with `` resolving to `/diagram.png` — those paths can collide with app routes, and `ServeMux` can't disambiguate them by prefix. That case needs a fall-through wrapper: *serve the file if it exists under a safe root, otherwise defer to the app handler.* Keep it a small wrapper in your own `main` rather than reaching for a framework primitive — the security-relevant policy (which extensions to serve, embedded vs. disk, symlink resolution) is app-specific, and `http.Dir` / `http.FileServer` already supply the traversal-safe file access it builds on. + ## Upload Access For file upload configuration and handling, see [Upload Reference](uploads.md). diff --git a/content/reference/error-handling.md b/content/reference/error-handling.md index becc502..54b603b 100644 --- a/content/reference/error-handling.md +++ b/content/reference/error-handling.md @@ -2,8 +2,8 @@ title: "Error Handling Reference" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/references/error-handling.md" -source_ref: "v0.16.0" -source_commit: "f4f9147c7066382d821c022caa48683d0886ad9a" +source_ref: "v0.19.1" +source_commit: "fe690899b1400a0c3886206038c0b958b40554be" --- # Error Handling Reference diff --git a/content/reference/limitations.md b/content/reference/limitations.md index 2e900ee..83434d5 100644 --- a/content/reference/limitations.md +++ b/content/reference/limitations.md @@ -2,8 +2,8 @@ title: "Current Limitations" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/references/current-limitations.md" -source_ref: "v0.16.0" -source_commit: "f4f9147c7066382d821c022caa48683d0886ad9a" +source_ref: "v0.19.1" +source_commit: "fe690899b1400a0c3886206038c0b958b40554be" --- # Current Limitations diff --git a/content/reference/navigate.md b/content/reference/navigate.md index b586386..5803838 100644 --- a/content/reference/navigate.md +++ b/content/reference/navigate.md @@ -2,8 +2,8 @@ title: "Navigate Action Reference" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/references/navigate.md" -source_ref: "v0.16.0" -source_commit: "f4f9147c7066382d821c022caa48683d0886ad9a" +source_ref: "v0.19.1" +source_commit: "fe690899b1400a0c3886206038c0b958b40554be" --- # Navigate Action Reference diff --git a/content/reference/progressive-complexity.md b/content/reference/progressive-complexity.md index f69c763..a8b7b69 100644 --- a/content/reference/progressive-complexity.md +++ b/content/reference/progressive-complexity.md @@ -2,8 +2,8 @@ title: "Progressive Complexity Reference" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/references/progressive-complexity-reference.md" -source_ref: "v0.16.0" -source_commit: "f4f9147c7066382d821c022caa48683d0886ad9a" +source_ref: "v0.19.1" +source_commit: "fe690899b1400a0c3886206038c0b958b40554be" --- # Progressive Complexity Reference diff --git a/content/reference/pubsub.md b/content/reference/pubsub.md index d811619..32afa77 100644 --- a/content/reference/pubsub.md +++ b/content/reference/pubsub.md @@ -2,8 +2,8 @@ title: "PubSub Reference" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/references/pubsub.md" -source_ref: "v0.16.0" -source_commit: "f4f9147c7066382d821c022caa48683d0886ad9a" +source_ref: "v0.19.1" +source_commit: "fe690899b1400a0c3886206038c0b958b40554be" --- # PubSub Reference diff --git a/content/reference/server-actions.md b/content/reference/server-actions.md index f2253b8..c9cac29 100644 --- a/content/reference/server-actions.md +++ b/content/reference/server-actions.md @@ -2,8 +2,8 @@ title: "Server Actions Reference" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/references/server-actions.md" -source_ref: "v0.16.0" -source_commit: "f4f9147c7066382d821c022caa48683d0886ad9a" +source_ref: "v0.19.1" +source_commit: "fe690899b1400a0c3886206038c0b958b40554be" --- # Server Actions Reference @@ -12,14 +12,16 @@ Server actions let you push updates from server-side code to connected clients. ## Overview -LiveTemplate supports two types of updates: +LiveTemplate supports three 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 | +| **Topic Fan-out** | Server-side code | Many sessions across groups | Refreshing every viewer of a shared dashboard | -Server actions use the `Session` interface to trigger updates: +Server actions use the `Session` interface to trigger updates for **one session +group**: ```go // From any goroutine - timer, webhook handler, background job @@ -28,6 +30,11 @@ session.TriggerAction("notification", map[string]interface{}{ }) ``` +To reach **many sessions at once** from a background goroutine — every tab of a +user, or every viewer of a shared dashboard — without stashing a registry of +`Session` handles, use out-of-band topic fan-out instead. See +[Fanning out to many sessions](#fanning-out-to-many-sessions-without-a-handle-registry). + ## Session Interface ```go @@ -325,9 +332,17 @@ func (c *ExportController) ExportFailed(state ExportState, ctx *livetemplate.Con } ``` -### Real-time Notifications +### Real-time Notifications (legacy `sync.Map` pattern) + +Push notifications from any part of your application. -Push notifications from any part of your application: +> **Prefer topic fan-out for this.** The hand-rolled `sync.Map` of `Session` +> handles below predates the topic API and is kept only for the case where you +> genuinely need a specific per-session handle (e.g. to cancel a per-session +> goroutine). To notify a user's connections by ID, subscribe to the user's +> topic in `Mount` and call `handler.Publish(livetemplate.UserTopic(userID), …)` +> — no registry, no `OnConnect`/`OnDisconnect` bookkeeping, no pruning of dead +> handles. See [Fanning out to many sessions](#fanning-out-to-many-sessions-without-a-handle-registry). ```go // Global session registry (thread-safe) @@ -354,6 +369,84 @@ func NotifyUser(userID string, message string) { } ``` +### Fanning out to many sessions (without a handle registry) + +`Session.TriggerAction` targets **one** session group, so refreshing many +connections at once tempts you to keep a registry of `Session` handles and +iterate it (the pattern above) — which then needs `OnConnect`/`OnDisconnect` +bookkeeping and dead-handle pruning. You don't need any of that. Two primitives +compose into a registry-free fan-out: + +1. **Join in `Mount`** with `ctx.Subscribe(topic)`. Because it runs in `Mount`, + the subscription is re-established automatically on reconnect. +2. **Fan out from anywhere** with the `LiveHandler.Publish(topic, action, data)` + returned by `Handle()` — out-of-band (no `Context`), safe from any goroutine. + Every subscriber re-runs `action` against its own state, exactly like + `TriggerAction`, and re-renders. + +The `action` you publish is a normal controller method — typically a `Refresh` +that reloads shared data into state: + +```go +func (c *DashboardController) Refresh(s State, ctx *livetemplate.Context) (State, error) { + s.Stats = c.store.Snapshot() // re-read the shared source + return s, nil +} +``` + +**Per-user (all of one user's tabs) — no configuration:** + +```go +func (c *DashboardController) Mount(s State, ctx *livetemplate.Context) (State, error) { + // ctx.SelfTopic() is the ACL-exempt self-identity topic. For an + // authenticated user it is livetemplate.UserTopic(ctx.UserID()). + if err := ctx.Subscribe(ctx.SelfTopic()); err != nil { + return s, err + } + s.Stats = c.store.Snapshot() + return s, nil +} + +// Background goroutine — refresh every tab of one user: +handler.Publish(livetemplate.UserTopic("alice"), "Refresh", nil) +``` + +**Shared across all viewers (a developer topic) — requires an ACL.** Developer +topics are **deny-all by default**: a connection may only subscribe if you +configure [`WithTopicACL`](pubsub.md#topic-subscribe--publish-api) (or +`WithOpenTopics` in trusted single-tenant tools). This is deliberate — a +developer topic is cross-user, so its ACL is the only boundary. + +```go +tmpl := livetemplate.New("dash", + livetemplate.WithTopicACL(func(topic, userID string, r *http.Request) (bool, error) { + return topic == "dashboard", nil // authorize the shared topic + }), +) + +func (c *DashboardController) Mount(s State, ctx *livetemplate.Context) (State, error) { + if err := ctx.Subscribe("dashboard"); err != nil { + return s, err // surfaces an lvt:error envelope to the client if denied + } + s.Stats = c.store.Snapshot() + return s, nil +} + +// Background goroutine — refresh every viewer, in every group: +handler.Publish("dashboard", "Refresh", nil) +``` + +**Notes:** + +- The out-of-band dispatch response is a pure state update: the client receives + the re-rendered tree with no `meta.action` echo (unlike a client-initiated + action). +- `Publish` is scoped to one `LiveHandler`'s subscribers. If a shared group + spans two separate handlers (e.g. a `/home` and a `/board` page backed by + different controllers), publish on each handler. +- For horizontally scaled (multi-instance) deployments, configure + [`WithPubSubBroadcaster`](pubsub.md#setup) so topic fan-out crosses instances. + ## Thread Safety Session methods are thread-safe and can be called from any goroutine: @@ -410,7 +503,11 @@ func (c *Controller) triggerFromBackground() { - Simpler mental model - "push to myself" - No accidental cross-user data leaks - No authorization checks needed in controller code -- For admin broadcasts, use database + polling or dedicated admin endpoints + +For cross-user broadcasts (admin announcements, a shared dashboard), use a +developer topic with [`WithTopicACL`](#fanning-out-to-many-sessions-without-a-handle-registry) +— the ACL is the explicit authorization boundary `TriggerAction` deliberately +lacks. ## Multi-Tab/Multi-Device Behavior @@ -623,4 +720,5 @@ In multi-instance deployments, `TriggerAction()` automatically publishes to Redi - [Controller+State Pattern](controller-pattern.md) - Core architecture pattern - [Session Reference](session.md) - Session stores and connection management - [Authentication Reference](authentication.md) - User identification and custom authenticators +- [PubSub Reference](pubsub.md#topic-subscribe--publish-api) - Topic grammar, ACL, and out-of-band `handler.Publish` - [Scaling Guide](../guides/SCALING.md) - Horizontal scaling with Redis diff --git a/content/reference/session.md b/content/reference/session.md index dbfe5c8..a98d323 100644 --- a/content/reference/session.md +++ b/content/reference/session.md @@ -2,8 +2,8 @@ title: "Session Reference" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/references/session.md" -source_ref: "v0.16.0" -source_commit: "f4f9147c7066382d821c022caa48683d0886ad9a" +source_ref: "v0.19.1" +source_commit: "fe690899b1400a0c3886206038c0b958b40554be" --- # Session Reference @@ -74,6 +74,15 @@ Fields tagged with `lvt:"persist"` follow this persistence schedule. Untagged fi This matches the pre-`lvt:"persist"` behavior where a path change always meant fresh state. Treat `lvt:"persist"` as "survives refresh," not "survives navigation." +### What the session store is not for + +`lvt:"persist"` and the `SessionStore` provide **session continuity** — carrying UI state across a refresh or reconnect within a session group. They are not a durable application datastore: + +- The default `MemorySessionStore` is in-process. A **server relaunch loses every session**; the browser's cookie survives but now misses the fresh empty store, so persist fields fall back to their zero values and `Mount()` repopulates them. `RedisSessionStore` survives a relaunch, but its purpose is shared continuity across instances, not per-user durable storage. +- Persistence is keyed by the cookie-derived `groupID`. State stored there is scoped to *that browser session*, not to a user — the same person on a second device (a different cookie) gets a different group. + +So data that must **outlive a process relaunch** or mean the same thing **across a user's sessions and devices** — durable user preferences, saved documents, anything you'd call "app data" — belongs in your own store (a file, your database), loaded in `Mount()`. Keep only genuine session-continuity state (filters, pagination, an in-progress draft) on `lvt:"persist"`. Mixing the two is the trap: a preference parked in the session silently resets on the next relaunch. The framework deliberately owns session continuity and leaves durable app data to the app. + ### Explicit Peer Refresh ```go diff --git a/content/reference/template-support-matrix.md b/content/reference/template-support-matrix.md index 722a1cc..b9dd44e 100644 --- a/content/reference/template-support-matrix.md +++ b/content/reference/template-support-matrix.md @@ -2,8 +2,8 @@ title: "LiveTemplate Go Template Support Matrix" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/references/template-support-matrix.md" -source_ref: "v0.16.0" -source_commit: "f4f9147c7066382d821c022caa48683d0886ad9a" +source_ref: "v0.19.1" +source_commit: "fe690899b1400a0c3886206038c0b958b40554be" --- # LiveTemplate Go Template Support Matrix @@ -181,10 +181,29 @@ Stripping uses the HTML tokenizer (not a regex), so it is context-aware: |---------|--------|-------| | `{{define}}` / `{{template}}` | ✅ | Automatically flattened via `FlattenTemplate()` in `internal/parse/flatten.go` | | `{{block}}` | ✅ | Resolved during flattening; equivalent to `{{define}}` + `{{template}}` | -| Circular template references | ❌ | Not supported, would cause infinite loop | +| Recursive / circular template references | ✅ | Supported since v0.19.0 — direct self-recursion (`node -> node`), mutual recursion (`a -> b -> a`), longer cycles, and a self-referential entry point all render. `{{template}}` calls are normally inlined during flattening, which a cycle cannot survive, so `FlattenTemplate` detects templates reachable from themselves and leaves those invocations un-inlined, evaluating them at build time as nested `TreeNode`s. The recursive region stays inside the reactive tree, so updates diff per-leaf rather than re-sending the branch. Depth is capped — see [Recursion depth](#recursion-depth). | | Undefined template invocation | ❌ | Returns error from Go template engine | -Template composition is fully supported through automatic AST flattening. `FlattenTemplate()` walks the parsed template tree, identifies the entry point, and inlines all `{{template}}` invocations into a single flat template before tree generation. +Template composition is fully supported through automatic AST flattening. `FlattenTemplate()` walks the parsed template tree, identifies the entry point, and inlines all `{{template}}` invocations into a single flat template before tree generation. Templates that are reachable from themselves are the exception: they are left un-inlined and invoked at build time instead. + +### Recursion depth + +Recursive invocation is capped so that self-referential *data* (a node whose child list contains itself) surfaces an error instead of overflowing the stack. The cap defaults to **128** and is configurable: + +```go +lt.New("tree", lt.WithMaxTemplateDepth(256)) +``` + +```bash +export LVT_MAX_TEMPLATE_DEPTH=256 # must be a positive integer +``` + +Exceeding the cap on a **reactive update** returns a build-phase `ParseError` naming the template and the limit. Raise the cap only if your data is legitimately deeper — the default exists to catch cycles in data, not to constrain real nesting. + +Two behaviours differ from that and are worth knowing: + +- **First render with finite data deeper than the cap does not error.** The build degrades and still produces a usable, keyed page, so a too-low cap can go unnoticed until the first update fails. +- **If the initial tree build errors outright** — genuinely self-referential data, where a node's children contain the node itself — the initial render falls back to an HTML-structure-based tree and logs `Template parsing failed, falling back to HTML structure-based tree`. That region then stays on HTML-string diffing for the life of the template rather than the reactive path. A recursive region that renders but never updates reactively is the symptom; that warning in the server log is the confirmation. ## Custom Functions @@ -192,7 +211,8 @@ Template composition is fully supported through automatic AST flattening. `Flatt |---------|--------|-------| | Built-in Go functions | ✅ | All standard functions supported | | User-defined functions | ⚠️ | Must be registered with Go template engine | -| Method calls on data | ✅ | Works if methods are public | +| Zero-arg method calls on data | ✅ | Public methods; precomputed from State (`{{.Count}}`) | +| Arg-accepting method calls | ⚠️ | Only on a struct **field**, not top-level State — `{{.Views.Class .ID}}`, not `{{.Class .ID}}` (see [Controller Pattern — view helpers](controller-pattern.md#methods-that-take-arguments-view-helpers)) | ## Performance Considerations diff --git a/content/reference/uploads.md b/content/reference/uploads.md index 21f2e10..eb0f02d 100644 --- a/content/reference/uploads.md +++ b/content/reference/uploads.md @@ -2,8 +2,8 @@ title: "Upload Reference" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/references/uploads.md" -source_ref: "v0.16.0" -source_commit: "f4f9147c7066382d821c022caa48683d0886ad9a" +source_ref: "v0.19.1" +source_commit: "fe690899b1400a0c3886206038c0b958b40554be" --- # Upload Reference