A message sent in one tab appears in every other tab that joined. The shared log
lives on the controller behind a mutex; Mount subscribes each connection with
ctx.Subscribe(ctx.SelfTopic()) and snapshots the log into local state. Send
appends under the lock, releases, then ctx.Publish(ctx.SelfTopic(), "NewMessage", nil)
fans the action out so every subscribed peer re-reads the log. Username is
per-connection — deliberately not persisted — so two tabs can join as different users.
Broadcasting
Mount calls ctx.Subscribe(ctx.SelfTopic()) to opt this connection in to peer fan-out; Send then calls ctx.Publish(ctx.SelfTopic(), "NewMessage", nil) to fan the named action out to every other connection that subscribed. Peers receive it as a regular action invocation; their handler reads the shared message log under a mutex and refreshes local state. The publish is queued during the action and executes after it returns successfully.
Try: Open this page in a second tab and Join with a different name. Sending in either tab publishes to both subscribed peers. The shared log lives on the controller; each tab's Username is per-connection (not persisted) so two tabs in the same browser stay independent — see Reconnection Recovery for the persist case.
Limitation: The shared message log is in-memory and uncapped — production apps would ring-buffer, paginate, or persist to a TTL store. Kept simple here to focus on the Subscribe/Publish mechanism itself.
Template
A join form swaps for the message list plus a send form once the user has a name.
<h3>Broadcasting</h3>
<p><small>Mount calls <code>ctx.Subscribe(ctx.SelfTopic())</code> to opt this connection in to peer fan-out; Send then calls <code>ctx.Publish(ctx.SelfTopic(), "NewMessage", nil)</code> to fan the named action out to every other connection that subscribed. Peers receive it as a regular action invocation; their handler reads the shared message log under a mutex and refreshes local state. The publish is queued during the action and executes after it returns successfully.</small></p>
{{if eq .Username ""}}
<form method="POST">
<fieldset role="group">
<input name="username" placeholder="Pick a name…" aria-label="Username" required>
<button name="join">Join</button>
</fieldset>
</form>
{{else}}
<p><small>Posting as <strong>{{.Username}}</strong></small></p>
<div class="messages" lvt-fx:scroll="bottom-sticky">
{{range .Messages}}
<p data-key="{{.ID}}"><strong>{{.User}}:</strong> {{.Text}}</p>
{{else}}
<p><small><em>No messages yet — send one to publish it to all subscribed peers.</em></small></p>
{{end}}
</div>
<form method="POST">
<fieldset role="group">
<input name="text" placeholder="Type a message…" aria-label="Message" required>
<button name="send">Send</button>
</fieldset>
</form>
{{end}}
Send appends under the lock, releases it, then publishes so every peer converges:
func (c *BroadcastingController) Send(state BroadcastingState, ctx *livetemplate.Context) (BroadcastingState, error) {
if state.Username == "" {
return state, nil
}
text := strings.TrimSpace(ctx.GetString("text"))
if text == "" {
return state, nil
}
c.mu.Lock()
c.nextID++
// No cap on c.messages: deliberately omitted to keep the demo focused
// on the Publish-to-SelfTopic mechanism. Production apps would
// ring-buffer, paginate, or persist to a store with TTL.
c.messages = append(c.messages, BroadcastMessage{ID: c.nextID, User: state.Username, Text: text})
state.Messages = c.snapshotLocked()
c.mu.Unlock()
// Publish must come after the lock release — holding the connection
// registry mutex while queuing peer dispatches can deadlock with peer
// dispatches that take the same mutex from the other side. Peers
// receive "NewMessage" and refresh their local copy.
if err := ctx.Publish(ctx.SelfTopic(), "NewMessage", nil); err != nil {
return state, err
}
return state, nil
}
type BroadcastingState struct {
Title string
Category string
// Username is intentionally NOT lvt:"persist" — persist storage is keyed
// by session group (state.go:1421 SessionStore.Set(ctx, groupID, ...)),
// so persisting it would force every tab in the same browser to share a
// single Username. The whole point of the demo is letting two tabs join
// as different users; per-connection state is what makes that work.
// Reconnect Recovery (#29) covers the persist scenario instead.
Username string
Messages []BroadcastMessage
}
A shared, append-only feed — chat, an activity log, live comments — that every
connection in the session should see grow.
Each connection keeps its own identity while reading one shared source of truth.
For the full deep-dive on the mutex rules and pub/sub scope, see
Broadcasting, deeper. Use
Presence Tracking when you only need to
know who is currently connected.