diff --git a/content/contributing/livetemplate.md b/content/contributing/livetemplate.md index 63d428c..fb0db2e 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.11.1" -source_commit: "37dae7f35e960ff7647a0f1eb51d89bcc62d173a" +source_ref: "v0.12.0" +source_commit: "6e9ead8f8e03f16170c2e41d407e24a646c96c22" --- # Contributing to LiveTemplate Core Library @@ -17,7 +17,7 @@ LiveTemplate is distributed across multiple repositories. Please use the appropr - **[Core Library](https://github.com/livetemplate/livetemplate)** (this repo) - Go server-side library - **[Client Library](https://github.com/livetemplate/client)** - TypeScript client for browsers → [Client CONTRIBUTING.md](/contributing/client) - **[CLI Tool (lvt)](https://github.com/livetemplate/lvt)** - Code generator and dev server → [LVT CONTRIBUTING.md](/contributing/cli) -- **[Examples](https://github.com/livetemplate/examples)** - Example applications → [Examples CONTRIBUTING.md](/contributing/examples) +- **[Examples](https://github.com/livetemplate/examples)** - Example applications → [Examples CONTRIBUTING.md](https://github.com/livetemplate/examples/blob/main/CONTRIBUTING.md) --- diff --git a/content/guides/ephemeral-components.md b/content/guides/ephemeral-components.md index 8a20709..a4d3bec 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.11.1" -source_commit: "37dae7f35e960ff7647a0f1eb51d89bcc62d173a" +source_ref: "v0.12.0" +source_commit: "6e9ead8f8e03f16170c2e41d407e24a646c96c22" --- # Ephemeral Components Guide diff --git a/content/guides/observability.md b/content/guides/observability.md index 3bd6d62..0a21a41 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.11.1" -source_commit: "37dae7f35e960ff7647a0f1eb51d89bcc62d173a" +source_ref: "v0.12.0" +source_commit: "6e9ead8f8e03f16170c2e41d407e24a646c96c22" --- # LiveTemplate Observability Guide diff --git a/content/guides/progressive-complexity.md b/content/guides/progressive-complexity.md index 29a0631..03e213e 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.11.1" -source_commit: "37dae7f35e960ff7647a0f1eb51d89bcc62d173a" +source_ref: "v0.12.0" +source_commit: "6e9ead8f8e03f16170c2e41d407e24a646c96c22" --- # Progressive Complexity Guide diff --git a/content/guides/scaling.md b/content/guides/scaling.md index 991ee08..4f3a54a 100644 --- a/content/guides/scaling.md +++ b/content/guides/scaling.md @@ -1,10 +1,9 @@ --- title: "LiveTemplate Scaling Guide" -description: "How to scale LiveTemplate apps from one process to Redis-backed multi-instance production deployments." source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/guides/SCALING.md" -source_ref: "v0.11.1" -source_commit: "37dae7f35e960ff7647a0f1eb51d89bcc62d173a" +source_ref: "v0.12.0" +source_commit: "6e9ead8f8e03f16170c2e41d407e24a646c96c22" --- # LiveTemplate Scaling Guide @@ -13,17 +12,6 @@ source_commit: "37dae7f35e960ff7647a0f1eb51d89bcc62d173a" **Last Updated:** 2025-11-01 -## At a glance - -| Situation | Default choice | See | -|---|---|---| -| Prototype or small internal tool | Single instance with memory session store | [Tier 1: Single Host](#tier-1-single-host-hobby) | -| Small production app | Add Redis for persistence and safer restarts | [Tier 2: Small Production](#tier-2-small-production-startup) | -| Multiple app instances | Redis session store plus pub/sub | [Migration Guide: Memory to Redis Session Store](#migration-guide-memory-to-redis-session-store) | -| Kubernetes or orchestrated deploy | Use tier checklists before scaling up | [Scaling Checklist](#scaling-checklist) | -| Capacity planning | Estimate WebSocket memory and Redis memory separately | [Capacity Planning](#capacity-planning) | -| Production rollout | Monitor connection counts, publish latency, and error rates | [Monitoring and Alerting](#monitoring-and-alerting) | - --- ## Overview diff --git a/content/guides/standard-html-reactivity.md b/content/guides/standard-html-reactivity.md index a481c9c..3d160dd 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.11.1" -source_commit: "37dae7f35e960ff7647a0f1eb51d89bcc62d173a" +source_ref: "v0.12.0" +source_commit: "6e9ead8f8e03f16170c2e41d407e24a646c96c22" --- # Standard HTML Reactivity @@ -103,19 +103,30 @@ See [PubSub Reference](../references/pubsub.md) for details. ## Comparison with Other Frameworks -Every major reactive framework requires custom attributes on HTML elements. LiveTemplate is unique in making standard HTML reactive without modification. +Every major reactive framework makes HTML reactive by adding a layer on top of it — custom attributes (`hx-*`, `wire:*`, `phx-*`) or a templating DSL. -| Framework | Form markup required | Custom attributes | -|-----------|---------------------|-------------------| -| **htmx** | `
` | `hx-post`, `hx-target`, `hx-swap`, `hx-trigger` | -| **Laravel Livewire** | `` | `wire:submit`, `wire:model`, `wire:click` | -| **Phoenix LiveView** | `` | `phx-submit`, `phx-click`, `phx-change` | -| **LiveTemplate** | `` | None for standard interactions | +LiveTemplate keeps the HTML standard and moves the reactivity to the server. You add an `lvt-*` attribute only when the behavior is something HTML itself cannot define — timing, keyboard shortcuts, reactive DOM — never to make ordinary HTML reactive. + +The boundary is *what HTML can express*, not *how common the case is*. + +| Framework | Markup for a form action | Attributes to make it reactive | +|-----------|--------------------------|--------------------------------| +| **htmx** | `` | `hx-post`, `hx-target`, `hx-swap`, `hx-trigger` | +| **templ + htmx** | `` (authored in templ, a Go DSL) | `hx-post`, `hx-target`, `hx-swap`, `hx-trigger` | +| **Laravel Livewire** | `` (Blade) | `wire:submit`, `wire:model`, `wire:click` | +| **Phoenix LiveView** | `` (HEEx) | `phx-submit`, `phx-click`, `phx-change` | +| **LiveTemplate** | `` (standard `html/template`) | None for standard interactions; `lvt-*` only for what HTML can't express | ### htmx htmx extends HTML with `hx-*` attributes for AJAX interactions. A form without `hx-post` submits normally (full page reload). Every interactive element needs explicit `hx-*` attributes. +### templ + htmx + +[templ](https://templ.guide) is a Go DSL for authoring and composing HTML as type-safe Go components — a popular alternative to `html/template`. It is a *templating* layer, not an interactivity layer, so it is commonly paired with htmx for reactivity. That means two things to learn and adopt: a new markup language **and** `hx-*` attributes on the rendered HTML. + +LiveTemplate takes the opposite trade: it stays on Go's standard `html/template` (no new DSL) and provides the reactivity itself. You compose with what `html/template` already gives you — partials and the `{{template}}` action — plus per-session state and one render-and-diff pipeline, rather than adopting a new language for either authoring or interactivity. If you specifically want compile-time-checked, function-composed markup, templ is the better fit; if you want standard HTML to be reactive without a DSL or `hx-*` wiring, that's LiveTemplate. + ### Laravel Livewire Livewire uses `wire:*` directives in PHP/Blade templates. `wire:submit` captures form submissions, `wire:model` enables two-way binding. State is serialized into HTML attributes. diff --git a/content/reference/api.md b/content/reference/api.md index db4ce35..a988537 100644 --- a/content/reference/api.md +++ b/content/reference/api.md @@ -1,27 +1,15 @@ --- title: "Go Library API Reference" -description: "Reference for the LiveTemplate Go API: templates, controllers, state, context, sessions, auth, stores, uploads, and pub/sub." source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/references/api-reference.md" -source_ref: "v0.11.1" -source_commit: "37dae7f35e960ff7647a0f1eb51d89bcc62d173a" +source_ref: "v0.12.0" +source_commit: "6e9ead8f8e03f16170c2e41d407e24a646c96c22" --- # Go Library API Reference > **Scope:** This reference documents the **`livetemplate` Go library** (`github.com/livetemplate/livetemplate`). For the CLI tool, see the [lvt repository](https://github.com/livetemplate/lvt). -## At a glance - -| Need | Start with | See | -|---|---|---| -| Parse and mount a template | `New`, `ParseFiles`, `Handle` | [Template](#template) | -| Keep dependencies separate from UI state | Controller singleton + `AsState` | [Controller+State Pattern](#controllerstate-pattern) | -| Read submitted form data | `ctx.GetString`, `ctx.Bind`, `ctx.BindAndValidate` | [Data Extraction](#data-extraction) | -| Persist lightweight session fields | `lvt:"persist"` on state fields | [Session](#session) | -| Push work to connected clients | `Session.TriggerAction` or `ctx.Publish` | [Session](#session), [PubSub](#pubsub-cross-instance-peer-fan-out) | -| Configure auth, stores, uploads, and limits | `Option` and `HandleOption` values | [Configuration](#configuration) | - ## Quick Start ```go diff --git a/content/reference/authentication.md b/content/reference/authentication.md index 4ef438f..4a02caa 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.11.1" -source_commit: "37dae7f35e960ff7647a0f1eb51d89bcc62d173a" +source_ref: "v0.12.0" +source_commit: "6e9ead8f8e03f16170c2e41d407e24a646c96c22" --- # Authentication Reference @@ -201,16 +201,45 @@ ctx.DeleteCookie("session_token") // Sets MaxAge = -1 ctx.Redirect("/dashboard", http.StatusSeeOther) // 303 ``` -**Security:** Only relative paths starting with `/` are allowed. This prevents open redirect vulnerabilities: +The target may be an **absolute-path reference** (`/dashboard`) or a **relative +reference** (`""`, `.`, `./settings`, `../list`). Relative references are +emitted as-is in the `Location` header, so the browser resolves them against +its own request URL. + +**Redirecting to your own mount (recipes behind `http.StripPrefix`):** When a +handler is mounted at a subpath via `http.StripPrefix("/apps/login/", handler)`, +`r.URL.Path` is stripped before the handler sees it, so the handler can't +reconstruct its own mount. Use the empty string — "reload self" — and let the +browser resolve it against the full URL: + +```go +// POST-Redirect-GET back to the recipe's own mount, wherever it's mounted. +return state, ctx.Redirect("", http.StatusSeeOther) +``` + +This lands back at `/apps/login/` in production and at `/` under a root-mounted +test server — no `mountPath` argument needs threading through the handler. + +> **Mount with a trailing slash.** The empty-string "reload self" form resolves +> to `./` (the current directory), so it relies on the canonical trailing-slash +> mount `http.StripPrefix("/apps/login/", …)`. An exact-match mount *without* a +> trailing slash (`http.StripPrefix("/apps/login", …)` serving `/apps/login`) +> would resolve `./` to the parent path. + +**Security:** Relative references are origin-confined (RFC 3986 resolution +keeps the current scheme+host), so they can't be open-redirect vectors. The +guard rejects anything that could escape the current origin: ```go // Valid redirects -ctx.Redirect("/dashboard", http.StatusSeeOther) // OK -ctx.Redirect("/users/profile", http.StatusFound) // OK +ctx.Redirect("/dashboard", http.StatusSeeOther) // OK (absolute path) +ctx.Redirect("", http.StatusSeeOther) // OK (reload self) +ctx.Redirect("./settings", http.StatusSeeOther) // OK (relative) // Invalid redirects (rejected with ErrInvalidRedirectURL) -ctx.Redirect("https://evil.com", http.StatusFound) // Rejected +ctx.Redirect("https://evil.com", http.StatusFound) // Rejected (has scheme/host) ctx.Redirect("//evil.com", http.StatusFound) // Rejected (protocol-relative) +ctx.Redirect("/\\evil.com", http.StatusFound) // Rejected (backslash bypass) ``` ### Error Types @@ -224,7 +253,7 @@ var ( ErrInvalidRedirectCode = errors.New("invalid redirect status code (must be 3xx)") // Returned when Redirect URL could cause open redirect vulnerability - ErrInvalidRedirectURL = errors.New("invalid redirect URL (must be relative path starting with /)") + ErrInvalidRedirectURL = errors.New("invalid redirect URL (must be a path or relative reference with no scheme or host)") ) ``` diff --git a/content/reference/client-attributes.md b/content/reference/client-attributes.md index e047800..2bd3062 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.11.1" -source_commit: "37dae7f35e960ff7647a0f1eb51d89bcc62d173a" +source_ref: "v0.12.0" +source_commit: "6e9ead8f8e03f16170c2e41d407e24a646c96c22" --- # Client Attributes Reference diff --git a/content/reference/configuration.md b/content/reference/configuration.md index 62cca13..3e1ab0e 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.11.1" -source_commit: "37dae7f35e960ff7647a0f1eb51d89bcc62d173a" +source_ref: "v0.12.0" +source_commit: "6e9ead8f8e03f16170c2e41d407e24a646c96c22" --- # LiveTemplate Configuration Guide diff --git a/content/reference/controller-pattern.md b/content/reference/controller-pattern.md index 03df04e..30feb86 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.11.1" -source_commit: "37dae7f35e960ff7647a0f1eb51d89bcc62d173a" +source_ref: "v0.12.0" +source_commit: "6e9ead8f8e03f16170c2e41d407e24a646c96c22" --- # Controller+State Pattern Reference diff --git a/content/reference/error-handling.md b/content/reference/error-handling.md index fe31913..30ea300 100644 --- a/content/reference/error-handling.md +++ b/content/reference/error-handling.md @@ -1,26 +1,15 @@ --- title: "Error Handling Reference" -description: "How LiveTemplate propagates server errors, validation failures, form lifecycle events, template error state, and flash messages." source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/references/error-handling.md" -source_ref: "v0.11.1" -source_commit: "37dae7f35e960ff7647a0f1eb51d89bcc62d173a" +source_ref: "v0.12.0" +source_commit: "6e9ead8f8e03f16170c2e41d407e24a646c96c22" --- # Error Handling Reference Complete guide to error handling in LiveTemplate applications. -## At a glance - -| Need | Use | See | -|---|---|---| -| Stop an action and keep state unchanged | Return an error from the controller method | [Server-Side Errors](#server-side-errors) | -| Show field-level validation messages | `FieldError`, `MultiError`, or validator integration | [Validation Errors](#validation-errors) | -| Render errors in templates | Error helpers and error-aware template state | [Template Error Display](#template-error-display) | -| React to failed submissions in the browser | `lvt:error` lifecycle hooks | [Client-Side Error Handling](#client-side-error-handling) | -| Show one-shot success or failure messages | Flash helpers on `Context` | [Flash Messages](#flash-messages) | - ## Table of Contents - [Overview](#overview) diff --git a/content/reference/limitations.md b/content/reference/limitations.md index 2f3b6c7..0ed5b20 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.11.1" -source_commit: "37dae7f35e960ff7647a0f1eb51d89bcc62d173a" +source_ref: "v0.12.0" +source_commit: "6e9ead8f8e03f16170c2e41d407e24a646c96c22" --- # Current Limitations diff --git a/content/reference/navigate.md b/content/reference/navigate.md index b112812..237f567 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.11.1" -source_commit: "37dae7f35e960ff7647a0f1eb51d89bcc62d173a" +source_ref: "v0.12.0" +source_commit: "6e9ead8f8e03f16170c2e41d407e24a646c96c22" --- # Navigate Action Reference diff --git a/content/reference/progressive-complexity.md b/content/reference/progressive-complexity.md index 1609fc2..9235081 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.11.1" -source_commit: "37dae7f35e960ff7647a0f1eb51d89bcc62d173a" +source_ref: "v0.12.0" +source_commit: "6e9ead8f8e03f16170c2e41d407e24a646c96c22" --- # Progressive Complexity Reference diff --git a/content/reference/pubsub.md b/content/reference/pubsub.md index 152c38e..c2ac686 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.11.1" -source_commit: "37dae7f35e960ff7647a0f1eb51d89bcc62d173a" +source_ref: "v0.12.0" +source_commit: "6e9ead8f8e03f16170c2e41d407e24a646c96c22" --- # PubSub Reference diff --git a/content/reference/server-actions.md b/content/reference/server-actions.md index ccc31c2..c585cd7 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.11.1" -source_commit: "37dae7f35e960ff7647a0f1eb51d89bcc62d173a" +source_ref: "v0.12.0" +source_commit: "6e9ead8f8e03f16170c2e41d407e24a646c96c22" --- # Server Actions Reference @@ -435,6 +435,185 @@ Background job completes └─► 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](../proposals/triggeraction-reconnect-buffering.md). + +### 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 +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: + +```go +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](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](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`](../proposals/patterns.md#implementation-notes-accumulated-from-completed-sessions) + 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. + + ```go + // 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](controller-pattern.md) 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](https://github.com/livetemplate/livetemplate/issues/342) and +describing the exact non-idempotency. The +[buffering proposal](../proposals/triggeraction-reconnect-buffering.md) +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](pubsub.md) for setup, channel schema, and subscription lifecycle. diff --git a/content/reference/session.md b/content/reference/session.md index ff7c32a..ee39556 100644 --- a/content/reference/session.md +++ b/content/reference/session.md @@ -1,10 +1,9 @@ --- title: "Session Reference" -description: "Reference for LiveTemplate session groups, state persistence, session stores, connection management, and WebSocket configuration." source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/references/session.md" -source_ref: "v0.11.1" -source_commit: "37dae7f35e960ff7647a0f1eb51d89bcc62d173a" +source_ref: "v0.12.0" +source_commit: "6e9ead8f8e03f16170c2e41d407e24a646c96c22" --- # Session Reference @@ -13,17 +12,6 @@ Session infrastructure in LiveTemplate handles state storage, connection managem For pushing updates from server-side code, see [Server Actions Reference](server-actions.md). -## At a glance - -| Need | Use | See | -|---|---|---| -| Isolate user state | Authenticator session groups | [Key Concepts](#key-concepts) | -| Persist filters, page number, or similar UI state | `lvt:"persist"` on selected state fields | [State Persistence](#state-persistence) | -| Reload database-backed data on each request | Populate ephemeral fields in `Mount()` | [State Persistence](#state-persistence) | -| Sync same-user tabs after a mutation | `ctx.Subscribe(ctx.SelfTopic())` + `ctx.Publish(...)` | [Explicit Peer Refresh](#explicit-peer-refresh) | -| Scale sessions beyond one process | Redis-backed session store and pub/sub | [Session Store Interface](#session-store-interface) | -| Limit connection pressure | WebSocket and connection configuration | [Connection Management](#connection-management) | - ## Overview ### Key Concepts diff --git a/content/reference/template-support-matrix.md b/content/reference/template-support-matrix.md index c6afcab..4c5332b 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.11.1" -source_commit: "37dae7f35e960ff7647a0f1eb51d89bcc62d173a" +source_ref: "v0.12.0" +source_commit: "6e9ead8f8e03f16170c2e41d407e24a646c96c22" --- # LiveTemplate Go Template Support Matrix diff --git a/content/reference/uploads.md b/content/reference/uploads.md index e5b7a86..1f7bfd4 100644 --- a/content/reference/uploads.md +++ b/content/reference/uploads.md @@ -2,19 +2,94 @@ title: "Upload Reference" source_repo: "https://github.com/livetemplate/livetemplate" source_path: "docs/references/uploads.md" -source_ref: "v0.11.1" -source_commit: "37dae7f35e960ff7647a0f1eb51d89bcc62d173a" +source_ref: "v0.12.0" +source_commit: "6e9ead8f8e03f16170c2e41d407e24a646c96c22" --- # Upload Reference ## Overview -LiveTemplate provides a file upload system with support for: -- **WebSocket chunked uploads** - Large file uploads with real-time progress -- **External uploads** - Direct uploads to S3/cloud storage via presigned URLs -- **Progress tracking** - Real-time upload progress with validation -- **Template helpers** - Display upload state in your templates +LiveTemplate provides a file upload system with four **modes**, chosen purely by +server config on an otherwise identical ``: + +| Mode | Bytes path | Server sees bytes? | Local disk? | Config | +|------|------------|--------------------|-------------|--------| +| **Volume** *(default)* | browser → server → retained directory | yes | yes | `Mode: UploadModeVolume, Dir: "..."` | +| **Direct** | browser → cloud via presigned URL | no | no | `Mode: UploadModeDirect, External: presigner` | +| **Proxied** | browser → server → remote storage, streamed | yes | **no** | `Mode: UploadModeProxied` + `OnUpload` | +| **Preview** | stays on the device | metadata only | no | `Mode: UploadModePreview` | + +```go +livetemplate.WithUpload("avatar", livetemplate.UploadConfig{ + Mode: livetemplate.UploadModeProxied, // stream to remote storage, zero disk + Accept: []string{"image/*"}, + MaxFileSize: 5 << 20, +}) +``` + +The mode is delivered to the client per-entry, so the same markup and the same +`ctx.GetCompletedUploads(name)` consumption work across every mode. See +[Upload modes](#upload-modes) below and the `upload-modes` example. + +## Upload modes + +### Volume — staged to the server's disk + +`Mode: UploadModeVolume` (the default) stages bytes to the server's filesystem. +With `Dir` set the file is **retained** there and the app owns its lifecycle; with +no `Dir` it stages to a temp dir that is cleaned up when the connection closes +(the legacy stage-then-move pattern). Read the path from `entry.TempPath`. + +### Direct — browser uploads straight to cloud storage + +`Mode: UploadModeDirect` with an `External` presigner has the browser PUT bytes +straight to S3/GCS/etc. via a presigned URL — they never touch the server. Read +the reference from `entry.ExternalRef`. (Setting `External` without an explicit +`Mode` is treated as Direct for backward compatibility.) + +### Proxied — stream through the server with zero local disk + +`Mode: UploadModeProxied` streams the in-flight bytes straight to a handler with +no local-disk staging — ideal for forwarding to remote object storage. The +controller implements `UploadStreamer`: + +```go +func (c *Controller) OnUpload(part *livetemplate.UploadPart, ctx *livetemplate.Context) error { + ref, err := myBackend.Put(ctx, part.Filename, part) // part is an io.Reader + if err != nil { + return err + } + part.SetResult(ref) // surfaced via GetCompletedUploads(...).ExternalRef + return nil +} +``` + +The reader enforces `MaxFileSize` mid-stream, returning `ErrUploadTooLarge` (a +distinct sentinel, not `io.EOF`) so a truncated stream aborts instead of +committing a partial object. Because nothing stages to disk, a pure-Proxied app +needs no writable working directory and never creates `.uploads`. + +> **Note:** Adding a Proxied field routes **every** multipart POST to that +> handler through the streaming path, including requests carrying only Volume +> fields. Those Volume parts are staged to disk as usual (equivalent to the +> default path), so mixing modes on one handler is fine — just be aware the +> coupling exists. + +### Preview — file stays on the device + +`Mode: UploadModePreview` keeps the file in the browser; only its metadata +(name/type/size) reaches the server. Render the on-device preview with the +template helper: + +```html + +{{.lvt.UploadPreview "draft"}} +``` + +The client fills the placeholder from `URL.createObjectURL` and never uploads the +bytes. The server records a metadata-only entry (`entry.Preview == true`, no +`TempPath`/`ExternalRef`) readable via `GetCompletedUploads`. ## Quick Start