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
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
# until upstream fixes the vendored asset embed (Phase 0 finding T0-1).
# `TINKERDOWN_REF` selects which branch/tag to clone and is overridable.

ARG TINKERDOWN_REF=v0.2.0
ARG TINKERDOWN_REF=v0.2.2

# ---- Stage 1: Build TypeScript client assets for tinkerdown ----
FROM node:20-alpine AS client-builder
Expand Down
2 changes: 1 addition & 1 deletion cmd/site/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import (
"net/http"
"os"

counter "github.com/livetemplate/docs/content/getting-started/_app/counter"
counter "github.com/livetemplate/docs/content/recipes/counter/_app"
)

func main() {
Expand Down
40 changes: 27 additions & 13 deletions content/getting-started/your-first-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,32 +25,51 @@ You'll have a `go.mod` and an empty directory. We'll add three files: `counter.g

Create `counter.go`. First the state:

```go include="./_app/counter/counter.go" lines="5-11"
```go include="../recipes/counter/_app/counter.go" lines="5-11"
```

State is a value type, not a pointer — controllers receive a copy and return a (possibly modified) copy. The framework manages the swap.

Then a controller and two action methods:

```go include="./_app/counter/counter.go" lines="13-33"
```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).

Now wire it up in `main.go`:

```go include="./_app/counter/main.go" lines="25-52"
```go
package main

import (
"log"
"net/http"

"github.com/livetemplate/livetemplate"
)

func main() {
tmpl := livetemplate.Must(livetemplate.New("counter",
livetemplate.WithParseFiles("counter.tmpl"),
))
handler := tmpl.Handle(&CounterController{}, livetemplate.AsState(&CounterState{}))

mux := http.NewServeMux()
mux.Handle("/", handler)
log.Fatal(http.ListenAndServe(":9090", mux))
}
```

`livetemplate.New("counter")` parses `counter.tmpl` from the same directory. `tmpl.Handle(controller, AsState(initial))` is the standard wiring — controller for actions, initial state for new sessions.

The `WithAuthenticator(sharedAuth{})` option uses a constant-groupID authenticator so all connections share state — Step 6 has the why and the `sharedAuth` definition.
By default LiveTemplate uses `AnonymousAuthenticator`, which gives each browser a stable session group via cookie. Two consequences worth knowing about now: each browser gets its own state (no cross-user leaks), and tabs from the same browser share state — that's what makes the broadcast demo at Step 6 work.

## Step 3 — Write the template

Create `counter.tmpl`:

```html include="./_app/counter/counter.tmpl"
```html include="../recipes/counter/_app/counter.tmpl"
```

The `<button name="increment">` attribute is the routing trigger — clicking that button posts the form and the framework calls `Increment()` on the controller.
Expand Down Expand Up @@ -89,10 +108,10 @@ Same Go code. Same template. Two lines of HTML promote the experience from serve

Look at the handlers from Step 2 — note the highlighted lines:

```go include="./_app/counter/counter.go" lines="22-33" highlight="24,31"
```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 connected client — multiple tabs, multiple embeds, multiple users. Without it, each session has its own count; with it, they stay in lockstep.
`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.

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

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

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.)

> **Why constant-groupID auth?** Here's the `sharedAuth` referenced in `main.go`:
>
> ```go include="./_app/counter/main.go" lines="11-23"
> ```
>
> Every connection lands in the same session group, so `BroadcastAction` from any one client reaches all the others. A real app uses a per-user authenticator; for a tutorial counter served alongside the docs, putting everyone in one group is what makes the side-by-side demo visible to every reader.
> **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.

## What you just built

Expand Down
11 changes: 3 additions & 8 deletions content/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,22 +24,17 @@ The widget above is a real, deployed LiveTemplate app — the same code as the [

The state and handlers — `counter.go`:

```go include="./getting-started/_app/counter/counter.go" lines="5-33"
```go include="./recipes/counter/_app/counter.go" lines="9-33"
```

The template — `counter.tmpl`:

```html include="./getting-started/_app/counter/counter.tmpl"
```

The wire-up (the heart of `main.go`):

```go include="./getting-started/_app/counter/main.go" lines="26-41"
```html include="./recipes/counter/_app/counter.tmpl"
```

A button's `name` attribute IS the routing key — `<button name="increment">` posts `increment` and LiveTemplate dispatches to the `Increment` method on the controller. The protocol between HTML and Go is just the form data the browser already sends.

[Read the full walkthrough →](/getting-started/your-first-app)
[Read the full walkthrough →](/getting-started/your-first-app) — or jump to [Counter, deeper](/recipes/counter) for the production-shaped story (broadcast routing, session models, scaling).

## What happens between a click and a DOM update

Expand Down
123 changes: 123 additions & 0 deletions content/recipes/counter/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
---
title: "Counter, deeper"
description: "Past the +1 button: how BroadcastAction routes between sessions, why AnonymousAuthenticator is the right default for public demos, and where this pattern stops scaling."
source_repo: https://github.com/livetemplate/docs
source_path: content/recipes/counter/index.md
---

# Counter, deeper

Most "counter" demos stop at "click +1, see number tick." Useful for proving the framework works; not so useful when you actually have to ship one. This recipe goes past the demo into the production-shaped questions: how `BroadcastAction` routes between sessions, why the cookie-bound session group matters for "multi-tab sync without leaking to other users," and what breaks first when this pattern meets real load.

The code is the same counter from [Your First App](/getting-started/your-first-app) — but the framing is different. Where that walkthrough builds the counter from scratch, this one stares at the four lines that do the actual work and unpacks them.

```embed-lvt path="/apps/counter/" upstream="http://localhost:9091" height="180px"
```

## Anatomy of the handler

The whole thing fits in three files. State + controller in one (the part you'd write):

```go include="./_app/counter.go" lines="9-33"
```

And a wiring file that exposes an `http.Handler`:

```go include="./_app/handler.go" lines="49-66"
```

There's not much to it. The choices that matter for production are the two `livetemplate.With*` options. Everything else is mechanical.

## Why `AnonymousAuthenticator` is the production default

LiveTemplate's `Authenticator` interface answers a single question on every HTTP and WebSocket request: *"who is this client, and which session group do they belong to?"* The session group is what `BroadcastAction` routes between. Two requests with the same group ID share state; different group IDs don't.

`AnonymousAuthenticator` (the framework's default, what this recipe uses) issues a cookie-bound group ID on first contact:

- Same browser, multiple tabs → same cookie → same group → broadcast works
- Different browser → different cookie → different group → isolated state
- Incognito window → its own cookie → its own group → clean slate

For a public docs site, that's the right shape. Every reader gets their own private counter on first visit, can prove broadcast within their own browser, and the demo can't be polluted by a stranger's clicks.

The alternative — a constant-group authenticator that puts every visitor in one shared group — is a demo-flavored shortcut. It makes a global ticker visible to all visitors, which is punchy on a marketing page but fails the "clean slate for thousands of users" test. We used it briefly during early development; the production switch to `AnonymousAuthenticator` was a one-line change with no other code impact:

```go
// Before — every visitor saw the same global counter
livetemplate.WithAuthenticator(sharedAuth{})

// After — each browser gets its own session group
livetemplate.WithAuthenticator(&livetemplate.AnonymousAuthenticator{})
```

The `BroadcastAction` calls didn't change. The state struct didn't change. Only the routing rule for "who counts as the same session" changed, and that one swap converted a demo into a production-shaped widget.

## How `BroadcastAction` routes

The two action methods do the obvious thing — bump the counter, return the new state — and then call `ctx.BroadcastAction`:

```go include="./_app/counter.go" lines="22-33" highlight="24,31"
```

`BroadcastAction("Increment", nil)` adds an action to the broadcast queue. It does **not** apply the action immediately to other connections; it queues it. After the current request's response is sent, the framework drains the queue: for every other connection in the same session group, run `Increment` against that connection's local state.

Two consequences worth knowing:

- **Each connection still has its own state copy.** Broadcast doesn't share state — it replays actions. A connection that's been disconnected for a while doesn't get a magical state update; it gets the actions it missed when it reconnects, applied in order.
- **The broadcast is fire-and-forget within a request.** The current request's caller doesn't wait for the broadcast to finish. If you broadcast and then return, the response goes to the originating client immediately; the other clients see the update milliseconds later as the queue drains.

To prove the routing, here are two embeds against the same recipe app, side by side:

<div class="recipe-side-by-side" style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">

```embed-lvt path="/apps/counter/" upstream="http://localhost:9091" session="recipe-counter-deeper" height="200px"
```

```embed-lvt path="/apps/counter/" upstream="http://localhost:9091" session="recipe-counter-deeper" height="200px"
```

</div>

Click `+1` on one. The other ticks too — same browser, same cookie, same group, broadcast routes between them. Open this page in an incognito window: that incognito counter starts at zero and won't see your normal-window clicks. Different cookie, different group.

## Session group lifecycle

Worth pausing on what "session group" actually means in time.

1. **First visit**: the browser has no cookie. `AnonymousAuthenticator.GetSessionGroup` issues a fresh group ID and sets it as a cookie. The connection joins that group.
2. **Subsequent requests** (next tab, page refresh, WebSocket reconnect): the cookie is sent, the same group ID is returned, the connection joins the existing group.
3. **Cookie cleared / different browser**: a new group ID is issued. Old state is unreachable from the new group.
4. **Server restart**: cookies persist but in-memory session state is gone. New connections start fresh; broadcast queue is empty until clients reconnect and trigger new actions.

The group ID is the *only* thing tying a connection to its peers. Two browsers that somehow had the same cookie value would be in the same group. Two tabs from one browser are in the same group not because of the same TCP connection or anything similar — purely because of the shared cookie.

## When this pattern scales — and when it doesn't

This recipe is a deliberately small slice. The scaling story behind it is real:

| Scenario | Works? | Notes |
|---|---|---|
| One user, multiple tabs, single instance | ✅ Trivially. The broadcast queue runs in-process, the cost is one `Increment` call per connected tab. |
| Multiple users, single instance | ✅ Each user has their own session group; broadcasts stay scoped. |
| Multiple users, multiple instances (Fly machines, Kubernetes replicas) | ⚠️ Needs `WithPubSubBroadcaster` — by default a broadcast only reaches connections on the *same* instance. With Redis-backed broadcasting the broadcast fans out across instances. See [PubSub Reference](/reference/pubsub). |
Comment on lines +98 to +102
| One group with thousands of connections (everyone broadcasting at high frequency) | ❌ Broadcast cost is O(N) per action; thousand-connection groups broadcasting at 100Hz mean 100k+ in-process calls per second. Either shard the group or use a different sync primitive. |
| Cross-user shared state (everyone sees everyone) | ⚠️ Possible — write a custom `Authenticator` that returns a constant group ID — but you've now built a write-amplification machine that any visitor can poke. Production examples need rate limiting, read-only modes, or moderation. |

`AnonymousAuthenticator` keeps you on the easy side of every row: per-user groups bound the fan-out, and the multi-instance question only matters once you've outgrown a single Fly machine.

## What the wiring file actually does

The full handler in `handler.go` is just the constructor expressed as a function. It exists because this recipe is mounted by the docs site's `cmd/site` aggregator — there's no standalone `main()`. In your own app you'd write a `main()` that does the same thing inline (`livetemplate.Must(...)` → `tmpl.Handle(...)` → `http.ListenAndServe`) and call it a day. Exposing it as a `Handler()` constructor is just so it can be mounted inside another binary's HTTP server.

```go include="./_app/handler.go" lines="14-46"
```

The `embed.FS` + temp-file dance at the top is a workaround for `livetemplate.WithParseFiles` taking filesystem paths — when the template ships inside the binary, we extract it once at first use. If you're running the standard "ship a directory of templates next to the binary" shape, you skip all this and pass the relative path directly.

## What next?

- [Reference — Authentication](/reference/authentication) — the full `Authenticator` interface, beyond the anonymous default.
- [Reference — PubSub & Broadcasting](/reference/pubsub) — multi-instance broadcasting via Redis.
- [Reference — Server Actions](/reference/server-actions) — the action lifecycle, including `BroadcastAction` ordering rules and gotchas.
- [Sync, Broadcast & Multi-User Sessions](/recipes/sync-and-broadcast) — when `Sync()` is enough and when you need broadcast.
- [Your First App](/getting-started/your-first-app) — if you arrived here cold, the from-scratch walkthrough is the better starting point.
2 changes: 2 additions & 0 deletions content/tinkerdown.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ navigation:
path: "recipes"
collapsed: false
pages:
- title: "Counter, deeper"
path: "recipes/counter/index.md"
- title: "Live Patterns Catalog"
path: "patterns/index.md"
- title: "How a LiveTemplate Update Flows"
Expand Down
6 changes: 4 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ module github.com/livetemplate/docs

go 1.26.1

require gopkg.in/yaml.v3 v3.0.1
require (
github.com/livetemplate/livetemplate v0.8.23
gopkg.in/yaml.v3 v3.0.1
)

require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
Expand All @@ -15,7 +18,6 @@ require (
github.com/gorilla/websocket v1.5.3 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/livetemplate/livetemplate v0.8.23 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/redis/go-redis/v9 v9.17.2 // indirect
Expand Down
Loading
Loading