Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions content/getting-started/your-first-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Then a controller and two action methods:
```go include="../recipes/counter/_app/counter.go" lines="13-33"
```

Action methods are exported on the controller, and their names ARE the action names — `Increment` and `Decrement` are what the template will reference. The `BroadcastAction` calls are how multi-tab sync works (Step 6).
Action methods are exported on the controller, and their names ARE the action names — `Increment` and `Decrement` are what the template will reference. The `Mount` + `Publish` calls are how multi-tab sync works (Step 6).

Now wire it up in `main.go`:

Expand Down Expand Up @@ -111,7 +111,7 @@ Look at the handlers from Step 2 — note the highlighted lines:
```go include="../recipes/counter/_app/counter.go" lines="22-33" highlight="24,31"
```

`ctx.BroadcastAction("Increment", nil)` (and the matching `Decrement`) tells LiveTemplate to apply the same action on every other connection in the same session group — multiple tabs and embeds within your browser. Without it, each tab has its own count; with it, they stay in lockstep.
Two things make multi-tab sync work. In `Mount`, `ctx.Subscribe(ctx.SelfTopic())` opts the connection in to peer fan-out for its own session (`SelfTopic()` resolves to the reserved-namespace string `lvt:session:<groupID>` and is ACL-exempt). Then in each action, `ctx.Publish(ctx.SelfTopic(), "Increment", nil)` (and the matching `Decrement`) fans the named action out to every other connection that subscribed. Without the Subscribe, the Publish has no receiver; without the Publish, no peer ever runs the action. With both, the tabs stay in lockstep.

To prove it, here are two embeds against the same counter, side by side:

Expand All @@ -125,7 +125,7 @@ To prove it, here are two embeds against the same counter, side by side:

</div>

Click `+1` in one — watch the other update in real time. They're talking to the same upstream session, and `BroadcastAction` is what makes them stay synced. (On a narrow viewport the embeds stack vertically — the broadcast still works.)
Click `+1` in one — watch the other update in real time. They're talking to the same upstream session, and the Mount-side `Subscribe(SelfTopic())` plus action-side `Publish(SelfTopic(), ...)` are what makes them stay synced. (On a narrow viewport the embeds stack vertically — the fan-out still works.)

> **Why does this stay scoped to your browser?** LiveTemplate's default authenticator (`AnonymousAuthenticator`) uses a cookie to assign each browser a stable session group. Tabs from the same browser share that group — that's why the two embeds above sync. Different browsers — or an incognito window in the same browser — get different cookies, different groups, and isolated state. For a public docs site this is the right default: every visitor gets a clean slate, and the broadcast demo still proves the feature within their own browser. See [Recipes/Counter, deeper](/recipes/counter) for the full session-group + scaling story.

Expand All @@ -145,5 +145,5 @@ You wrote a counter that:
- [How a LiveTemplate Update Flows](/recipes/architecture-flow) — the sequence diagram of what happened between your click and the DOM patch.
- [UI pattern recipes](/recipes/ui-patterns/) — 33 live, reactive UI idioms you can copy. Forms, lists, search, real-time, navigation, feedback.
- [Server API reference](/reference/api) — `New`, `Handle`, `Context`, action method dispatch.
- [Broadcast & Server Push](/recipes/sync-and-broadcast) — when to use `BroadcastAction()` vs `TriggerAction()`, and how sessions are scoped.
- [Sync & Server Push](/recipes/sync-and-broadcast) — when to use `Subscribe`/`Publish` peer fan-out vs `TriggerAction()`, and how sessions are scoped.
- [App recipes](/recipes/apps/) — runnable apps including chat, todos, file uploads, auth.
2 changes: 1 addition & 1 deletion content/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ LiveTemplate is a Go library for building reactive web UIs from standard `html/t
```embed-lvt path="/apps/counter/" upstream="http://localhost:9091" height="320px"
```

Click the buttons. Each click POSTs the action to the Go server; the server runs `Increment`, re-renders the template, diffs against the previous render, and sends only the changed text node back. The form, the buttons, and the count display are never re-created — only the count's text changes. Open this page in a second tab on the same machine: clicks in one tab show up in the other in real time, because every handler ends with `ctx.BroadcastAction(...)`.
Click the buttons. Each click POSTs the action to the Go server; the server runs `Increment`, re-renders the template, diffs against the previous render, and sends only the changed text node back. The form, the buttons, and the count display are never re-created — only the count's text changes. Open this page in a second tab on the same machine: clicks in one tab show up in the other in real time, because each tab opts in to peer fan-out via `ctx.Subscribe(ctx.SelfTopic())` in `Mount` and every handler ends with `ctx.Publish(ctx.SelfTopic(), ...)`.

The widget above is a real, deployed LiveTemplate app — the same code as the [Your First App](/getting-started/your-first-app) tutorial, embedded inline through tinkerdown's auto-proxy.

Expand Down
2 changes: 1 addition & 1 deletion content/recipes/apps/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Runnable application recipes demonstrating LiveTemplate usage with various featu

The todo app demonstrates LiveTemplate's core features in ~150 lines of Go + ~80 lines of HTML:

- **Real-time sync** — open two tabs as the same user; changes appear instantly via explicit `BroadcastAction`
- **Real-time sync** — open two tabs as the same user; changes appear instantly via an opt-in `ctx.Subscribe(ctx.SelfTopic())` in Mount and an explicit `ctx.Publish(ctx.SelfTopic(), ...)` from each mutating action
- **Standard HTML forms** — `<form method="POST" name="add">` routes to `Add()` with zero configuration
- **Live search & sort** — `Change()` auto-wires input events with 300ms debounce
- **Validation** — `ErrorTag`, `AriaInvalid`, `AriaDisabled` template helpers
Expand Down
2 changes: 1 addition & 1 deletion content/recipes/architecture-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ Open browser DevTools → Network → WS to watch the WebSocket frames flow. The

## What you can change to see this in action

Open the [todos example](/recipes/apps/todos) in two browser tabs. Add an item in tab 1 — it appears in tab 2 within ~30ms because the controller calls `ctx.BroadcastAction("RefreshTodos", nil)` after each shared mutation.
Open the [todos example](/recipes/apps/todos) in two browser tabs. Add an item in tab 1 — it appears in tab 2 within ~30ms because each tab opted into peer fan-out via `ctx.Subscribe(ctx.SelfTopic())` in `Mount`, and the controller calls `ctx.Publish(ctx.SelfTopic(), "RefreshTodos", nil)` after each shared mutation.

## How this page works

Expand Down
31 changes: 16 additions & 15 deletions content/recipes/broadcasting.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
---
title: "Broadcasting, deeper"
description: "How ctx.BroadcastAction routes within a session group, why some state belongs on the controller and not in lvt:\"persist\", and the two mutex rules that keep it from deadlocking."
description: "How Subscribe(SelfTopic())+Publish routes within a session group, why some state belongs on the controller and not in lvt:\"persist\", and the two mutex rules that keep it from deadlocking."
source_repo: https://github.com/livetemplate/docs
source_path: content/recipes/broadcasting.md
---

# Broadcasting, deeper

[Counter, deeper](/recipes/counter) showed `ctx.BroadcastAction("Increment", nil)` keeping every tab in a single browser in sync — a counter clicked in one tab ticks in the others. The scope of "every tab" was the [session group](/reference/session): the browser's cookie pins all its tabs to one group, and `BroadcastAction` fans the named action out to every connection in that group.
[Counter, deeper](/recipes/counter) showed `ctx.Subscribe(ctx.SelfTopic())` in `Mount` plus `ctx.Publish(ctx.SelfTopic(), "Increment", nil)` keeping every tab in a single browser in sync — a counter clicked in one tab ticks in the others. The scope of "every tab" was the [session group](/reference/session): the browser's cookie pins all its tabs to one group, `SelfTopic()` resolves to `lvt:session:<groupID>` for that group, and `Publish` fans the named action out to every connection that subscribed.

Broadcasting goes further within the same scope. Counter shared one integer; this pattern shares a multi-author message log. Same `BroadcastAction` primitive, two design choices that change everything — which fields are per-connection vs persisted, and where the source of truth lives.
Broadcasting goes further within the same scope. Counter shared one integer; this pattern shares a multi-author message log. Same Subscribe/Publish primitives, two design choices that change everything — which fields are per-connection vs persisted, and where the source of truth lives.

```embed-lvt path="/recipes/ui-patterns/realtime/broadcasting" upstream="http://localhost:9091" height="380px"
```

Open the page in a second tab. Join with a different name. Send a message from either side. Both update. Both tabs are in the same session group (same cookie), so the broadcast reaches both — but each tab keeps its own `Username` because identity is per-connection, not persisted.
Open the page in a second tab. Join with a different name. Send a message from either side. Both update. Both tabs are in the same session group (same cookie), so each tab's `SelfTopic()` resolves to the same string, and a Publish from either reaches both — but each tab keeps its own `Username` because identity is per-connection, not persisted.

(For a setup where every visitor — across browsers, across machines — sees the same broadcasts, you'd swap [`AnonymousAuthenticator`](/reference/authentication) for one that returns a constant group ID. That's an authentication choice, not a `BroadcastAction` choice.)
(For a setup where every visitor — across browsers, across machines — sees the same fan-out, you'd swap [`AnonymousAuthenticator`](/reference/authentication) for one that returns a constant group ID, or define a developer-named topic like `"announcements"` and admit it in `WithTopicACL`. That's an authentication or ACL choice, not a `Publish` choice.)

## Anatomy of the state

Expand All @@ -25,7 +25,7 @@ Open the page in a second tab. Join with a different name. Send a message from e

Note what's *not* persisted. `Username` looks like a candidate for `lvt:"persist"` — it's user identity, surely you want it to survive a reconnect? But persist storage is keyed by **session group**, so persisting `Username` would force every tab in the same browser to share one identity, defeating the demo where two tabs join as different users.

The pattern that *does* persist state across reconnects is `ReconnectionState` (also in this file) — different recipe, same package. Same broadcast scope (session group), but every connection sees the same value across drops because the field is `lvt:"persist"`-tagged.
The pattern that *does* persist state across reconnects is `ReconnectionState` (also in this file) — different recipe, same package. Same fan-out scope (session group), but every connection sees the same value across drops because the field is `lvt:"persist"`-tagged.

## Where the messages live

Expand All @@ -34,36 +34,37 @@ The pattern that *does* persist state across reconnects is `ReconnectionState` (

The message log is on the **controller**, not in state. State is per-connection; the controller is the singleton dependency layer the [Controller+State pattern](/reference/controller-pattern) puts in front of every connection routed to this handler. `c.messages` is the source of truth — every tab reads from it under the same `RWMutex`.

The `Mount` method runs on every initial render — without it, a tab that opens *after* others have sent messages would render with `Messages: nil` until the next broadcast arrives. Mount snapshots the current log into per-connection state so each tab starts coherent.
The `Mount` method runs on every initial render — and in v0.10.0 it does **two** things: opt the connection into peer fan-out via `ctx.Subscribe(ctx.SelfTopic())`, *and* snapshot the current log into per-connection state. Without the Subscribe, a Publish from another tab would have no receiver in this session and the demo wouldn't work. Without the snapshot, a tab that opens *after* others have sent messages would render with `Messages: nil` until the next Publish arrives.

## The broadcast
## Sending — Publish under the lock-release rule

```go include="./patterns/_app/handlers_realtime.go" region="broadcasting-send"
```

Two non-obvious mutex rules in this method:

1. **`BroadcastAction` after the lock release.** Holding the connection registry mutex while queuing broadcasts can deadlock with peer dispatches taking the same mutex from the other side. The pattern: mutate-and-snapshot under your lock, release, *then* broadcast.
1. **`Publish` after the lock release.** Holding the connection registry mutex while queuing publishes can deadlock with peer dispatches taking the same mutex from the other side. The pattern: mutate-and-snapshot under your lock, release, *then* Publish.

2. **`snapshotLocked()` requires the caller hold the lock.** A naked `slices.Clone(c.messages)` reads concurrently with `Send`'s append and races. The `Locked` suffix is documentation: violate it and you get a data race the test suite will catch under `-race`.

The third rule is implicit — `c.messages` is uncapped here. Production apps would ring-buffer, paginate, or persist to a TTL store. This demo skips that to keep the focus on `BroadcastAction` itself.
The third rule is implicit — `c.messages` is uncapped here. Production apps would ring-buffer, paginate, or persist to a TTL store. This demo skips that to keep the focus on the fan-out machinery itself.

## What peers do

```go include="./patterns/_app/handlers_realtime.go" region="broadcasting-newmessage"
```

`NewMessage` runs on every peer when the broadcast fires. It reads the shared log under `RLock` and copies into per-connection state. The template re-renders; the diff goes over the wire as patches, not full HTML.
`NewMessage` runs on every peer connection that subscribed to `SelfTopic()` when the Publish fires. It reads the shared log under `RLock` and copies into per-connection state. The template re-renders; the diff goes over the wire as patches, not full HTML.

This is why broadcast volume isn't proportional to message size: each peer's wire bytes equal the diff between its local state before and after `NewMessage`, which is roughly "one new message appended to the messages list."
This is why fan-out volume isn't proportional to message size: each peer's wire bytes equal the diff between its local state before and after `NewMessage`, which is roughly "one new message appended to the messages list."

## When this scales

Single process, single replica: works as-shown. The mutex serializes appends; the broadcast is in-process Pub/Sub.
Single process, single replica: works as-shown. The mutex serializes appends; the fan-out is in-process pub/sub.

Multi-replica: swap in-process broadcast for Redis Pub/Sub via [`WithPubSubBroadcaster`](/reference/pubsub). The handler shape stays identical — the `Send` and `NewMessage` methods don't change. What changes is *where* `c.messages` lives (a shared store instead of a Go slice) and *how* `BroadcastAction` propagates (Redis publish, replica subscribers fire `NewMessage` on their connections).
Multi-replica: swap in-process fan-out for Redis Pub/Sub via [`WithPubSubBroadcaster`](/reference/pubsub). The handler shape stays identical — the `Mount`, `Send`, and `NewMessage` methods don't change. What changes is *where* `c.messages` lives (a shared store instead of a Go slice) and *how* the Publish propagates (Redis publish to `livetemplate:topic_action:<topic>`, replica subscribers fire `NewMessage` on their own subscribed connections; the framework's seen-ring deduplicates the SUBSCRIBE+PSUBSCRIBE double-fire for cross-instance wildcard topics).

## What's next

The reconnection-recovery pattern (live demo at [/recipes/ui-patterns/realtime/reconnection](/recipes/ui-patterns/realtime/reconnection)) is the persist-state companion. Same `BroadcastAction` shape, but the demo state survives a WebSocket drop because the fields are `lvt:"persist"`-tagged. A future recipe will go deep on it; for now the live widget plus its source in the same `_app/` is the reference.
The reconnection-recovery pattern (live demo at [/recipes/ui-patterns/realtime/reconnection](/recipes/ui-patterns/realtime/reconnection)) is the persist-state companion. Same Subscribe/Publish shape, but the demo state survives a WebSocket drop because the fields are `lvt:"persist"`-tagged. A future recipe will go deep on it; for now the live widget plus its source in the same `_app/` is the reference.

Loading
Loading