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.BroadcastAction("Refresh", nil) is the explicit peer-refresh primitive. After a Save, every other tab of the same user runs Refresh and re-reads from the map.That last one is the v0.9.0 shape. 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 broadcasts so authors control when peers refresh, not the framework.
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 session-group ID
// (BroadcastAction routing). 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 broadcasts between them work; alice and bob land in different groups 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. It binds Username to whoever just authenticated, then re-reads the textarea content from the per-user map:
// Mount runs on every fresh state (page load, reconnect with stale
// state). It 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) {
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 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 broadcasts
// a "Refresh" action to peer connections in the same session group
// (other tabs of the same user). The framework drains the broadcast
// 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()
ctx.BroadcastAction("Refresh", nil)
return state, nil
}
ctx.BroadcastAction("Refresh", nil) doesn't run Refresh immediately on other connections — it enqueues the action for the framework's broadcast pipeline. After the current request's response is sent back to the originating tab, the framework drains the queue: for every peer connection in the same session group (other tabs of the same authenticated user), it dispatches Refresh against that connection's local state.
Two consequences worth knowing:
For the deeper model — when to broadcast versus when to use session.TriggerAction for server-owned work — see Broadcast & Server Push.
Refresh is the action peer tabs run when Save broadcasts. It's just a regular controller method — not a framework-reserved name:
// Refresh is the action peer tabs run when Save broadcasts. 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 BroadcastAction("Refresh", nil)
// 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 BroadcastAction("Refresh", nil) call in Save naming this same string. Renaming the method also requires updating the broadcast call — and every peer connection that's running an older controller will see the broadcast 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 broadcast | Single Fly machine, in-memory peer registry | WithPubSubBroadcaster (Redis) so BroadcastAction("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.lvt-form:preserve story by itself.Authenticator interface.