Shared Notepad
Logged in as — Save syncs across all your tabs
The smallest authenticated multi-user app: a textarea, per-user persistence, and multi-tab sync. The whole thing fits in a controller with four action methods, a handler that wires BasicAuthenticator as a default option, and ten lines of template — the rest is framework machinery you don't write.
What makes this recipe worth a page is the seam between three independently-useful primitives:
BasicAuthenticator turns the HTTP Authorization header into a stable identity. Username becomes both ctx.UserID() and the session-group ID.ctx.UserID() is enough to isolate per-user state without a database. Alice's notes never leak to Bob.ctx.Subscribe(ctx.SelfTopic()) in Mount plus ctx.Publish(ctx.SelfTopic(), "Refresh", nil) in Save is the explicit peer-refresh pattern. Peer fan-out is opt-in: each tab that wants to receive updates registers via the Mount-side Subscribe; the action that mutated shared state fans out via Publish. Every subscribed tab of the same user runs Refresh and re-reads from the map.That two-step shape is the v0.10.0 surface. Earlier versions of LiveTemplate auto-dispatched a controller method named Sync on every peer after every action; livetemplate#406 removed that auto-dispatch in favour of explicit peer fan-out. The v0.10.0 split made the receiver-side opt-in explicit too.
Try it right here. Type some text and click Save:
To see the peer-refresh, open this page in a second browser tab — the embed there shares the same cookie-bound identity — and click Save in one. The other tab's content updates without you doing anything on it. For the cross-identity isolation story, open the page in a private window: different cookie, different ctx.UserID(), different session group, completely separate state.
A note on the embed's authenticator. The recipe text below shows
NewDemoBasicAuth— that's whatexamples/shared-notepad/and the e2e suite use, and it's the production-shaped wiring where the username from theAuthorizationheader becomesctx.UserID(). The embed above usesAnonymousAuthenticatorinstead — a cookie-bound session ID with no credential prompt — because tinkerdown's embed-lvt does a server-side prefetch to extract the LiveTemplate wrapper, and that prefetch can't forwardAuthorizationheaders. The controller code is identical either way; only the source ofctx.UserID()changes.
State is pure data, cloned per session. The three lvt:"persist" tags keep the textarea content and metadata alive across reconnects via the framework's client-side state checksum:
// NotepadController holds per-user notepad state. The map is keyed by
// ctx.UserID() (the username from BasicAuth). A real app would back
// this with a database; the map is fine for a recipe.
type NotepadController struct {
mu sync.RWMutex
notes map[string]NotepadState // userID -> latest state
}
// NotepadState is pure data, cloned per session. lvt:"persist" tags
// keep the textarea content and metadata alive across page refreshes
// (the framework round-trips them through a client-side state
// checksum). Username is derived from ctx.UserID() on Mount and isn't
// persisted — it would be wrong to trust a client-supplied identity.
type NotepadState struct {
Username string `json:"username"`
Content string `json:"content" lvt:"persist"`
SavedAt string `json:"saved_at" lvt:"persist"`
CharCount int `json:"char_count" lvt:"persist"`
}
Username is intentionally not persisted — it's re-derived from ctx.UserID() on every Mount, and trusting a client-supplied username would be an authorization bug waiting to happen.
The handler exposes two authenticator flavours and lets the caller pick. Production-shaped is BasicAuth:
// NewDemoBasicAuth returns the authenticator the recipe text teaches:
// BasicAuth with password "demo", any username. The username becomes
// both ctx.UserID() (per-user state map key) and the SelfTopic() identity
// (Publish routing key — lvt:user:<UserID>). This is the production-shaped
// wiring and what examples/shared-notepad + the e2e suite use.
//
// The docs-site mount (cmd/site) uses AnonymousAuthenticator instead
// of this — see the Handler doc comment for the reason.
func NewDemoBasicAuth() livetemplate.Authenticator {
return livetemplate.NewBasicAuthenticator(func(_, password string) (bool, error) {
return password == "demo", nil
})
}
BasicAuthenticator answers two questions for every request:
Authenticate(...) — does the credential pass? Here, any username with password demo. A real app would check against a user table.GroupID(...) — what session-group does this client belong to? BasicAuthenticator returns the username, so two tabs authenticated as alice land in the same group and ctx.SelfTopic() resolves to the same topic string for both — Publish from one reaches the other. Alice and bob land in different groups, get different SelfTopic() strings, and nothing crosses.ctx.UserID() in any action handler returns whatever the authenticator decided — username here.
Mount runs on every fresh state — first page load, reconnect with stale state, or a state-restoring navigation. Three things happen: opt this connection into peer fan-out via ctx.Subscribe(ctx.SelfTopic()), bind Username to whoever just authenticated, and re-read the textarea content from the per-user map:
// Mount runs on every fresh state (page load, reconnect with stale
// state). It subscribes the self-topic so peer tabs of the same user
// receive the Refresh dispatch from Save's Publish below, binds Username
// to the authenticated user, and rehydrates the textarea from the
// controller's per-user map.
func (c *NotepadController) Mount(state NotepadState, ctx *livetemplate.Context) (NotepadState, error) {
if err := ctx.Subscribe(ctx.SelfTopic()); err != nil {
return state, err
}
state.Username = ctx.UserID()
c.mu.RLock()
if saved, ok := c.notes[ctx.UserID()]; ok {
state.Content = saved.Content
state.CharCount = saved.CharCount
state.SavedAt = saved.SavedAt
}
c.mu.RUnlock()
return state, nil
}
The Subscribe line is the receiver-side opt-in. Without it, the Publish in Save would have no subscribers in this session group and the peer tab wouldn't refresh. SelfTopic() is ACL-exempt — it always succeeds — and the explicit _ = documents that we've considered the return value (a denied developer topic would surface as a *TopicForbiddenError; the self-topic can't be denied).
The c.mu.RLock is the only concurrency primitive in the recipe. Save takes the write lock; Mount, Refresh, and the implicit page-load reads take the read lock. For a production app this would be a database transaction, not a map[string]NotepadState — but the controller-shape is the same.
The interesting line is the last one before the return:
// Save writes the textarea content into the per-user map and Publishes a
// "Refresh" action to peer connections subscribed to SelfTopic() (other
// tabs of the same user). The framework drains the publish queue after
// this action's response is sent.
func (c *NotepadController) Save(state NotepadState, ctx *livetemplate.Context) (NotepadState, error) {
state.Content = ctx.GetString("content")
state.CharCount = utf8.RuneCountInString(state.Content)
state.SavedAt = time.Now().Format("15:04:05")
c.mu.Lock()
c.notes[ctx.UserID()] = state
c.mu.Unlock()
// Propagate Publish's error rather than log-and-swallow: the only errors
// it can return are programmer errors (empty SelfTopic from a
// misconfigured Authenticator, or the per-action publish cap exceeded).
// Surfacing them loudly is a feature. Same pattern in every recipe app
// that Publishes to SelfTopic().
if err := ctx.Publish(ctx.SelfTopic(), "Refresh", nil); err != nil {
return state, err
}
return state, nil
}
ctx.Publish(ctx.SelfTopic(), "Refresh", nil) doesn't run Refresh immediately on other connections — it enqueues the action for the framework's publish pipeline. After the current request's response is sent back to the originating tab, the framework drains the queue: for every peer connection that subscribed to SelfTopic() (other tabs of the same authenticated user), it dispatches Refresh against that connection's local state.
Three consequences worth knowing:
Subscribe, no fan-out. If Mount doesn't subscribe, Publish runs cleanly but reaches zero peer connections in the group. The Mount-side opt-in is the receiver-side contract.For the deeper model — when to use Subscribe/Publish peer fan-out versus session.TriggerAction for server-owned work — see Pubsub and Server push.
Refresh is the action peer tabs run when Save publishes. It's just a regular controller method — not a framework-reserved name:
// Refresh is the action peer tabs run when Save publishes. It re-reads
// the latest state from the per-user map. Note this is a regular
// controller action, not a framework-reserved name — pre-v0.9.0 the
// framework auto-dispatched a Sync() method; that was removed in
// livetemplate#406 in favour of explicit Publish-to-SelfTopic() calls
// for clearer control over when peers actually refresh.
func (c *NotepadController) Refresh(state NotepadState, ctx *livetemplate.Context) (NotepadState, error) {
c.mu.RLock()
if saved, ok := c.notes[ctx.UserID()]; ok {
state.Content = saved.Content
state.CharCount = saved.CharCount
state.SavedAt = saved.SavedAt
}
c.mu.RUnlock()
return state, nil
}
The Refresh name is convention; the framework doesn't care. What matters is the Publish(SelfTopic(), "Refresh", nil) call in Save naming this same string. Renaming the method also requires updating the publish call — and every peer connection that's running an older controller will see the published action and fail to route it.
The textarea form uses lvt-form:preserve, which tells the framework to retain the form's input values across a re-render:
<article>
<header>
<hgroup>
<h1>Shared Notepad</h1>
<p>Logged in as <strong>{{.Username}}</strong> — Save syncs across all your tabs</p>
</hgroup>
</header>
<form method="POST" lvt-form:preserve>
<label for="content">Your notes
<textarea id="content" name="content" rows="12" placeholder="Start typing...">{{.Content}}</textarea>
</label>
<footer>
<div class="grid">
<small id="charcount">{{.CharCount}} characters{{if .SavedAt}} — saved at {{.SavedAt}}{{end}}</small>
<button type="submit" name="save">Save</button>
</div>
</footer>
</form>
</article>
Without lvt-form:preserve, a Save would re-render the article with the new SavedAt timestamp and the textarea would briefly flash empty before the framework re-binds {{.Content}}. Preserve keeps the user's typing intact during the round-trip.
For the deeper pattern (including how preserve composes with Change() for live preview), see Patterns › Preserve inputs.
Three production extensions that don't change the recipe's shape:
| Concern | This recipe | Production shape |
|---|---|---|
| Persistence | map[string]NotepadState in process memory |
File-backed SQLite or Postgres; per-user rows; Save becomes an UPDATE |
| Auth | BasicAuth with hardcoded password check | BasicAuthenticator with bcrypt password compare, or a custom Authenticator that validates session tokens |
| Multi-instance fan-out | Single Fly machine, in-memory peer registry | WithPubSubBroadcaster (Redis) so Publish(SelfTopic(), "Refresh", ...) reaches peer instances |
| Audit | No event log | Append-only audit table; Save writes the event before returning |
None of those changes the four action methods on NotepadController. The shape carries over.
OnConnect + session.TriggerAction instead of header auth.TriggerAction for server-owned work.lvt-form:preserve story by itself.Authenticator interface.