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
4 changes: 2 additions & 2 deletions cmd/site/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,11 @@ func main() {
)))

// shared-notepad — per-user state map + explicit peer refresh via
// ctx.BroadcastAction("Refresh", nil). The recipe TEACHES BasicAuth
// ctx.Publish(ctx.SelfTopic(), "Refresh", nil). The recipe TEACHES BasicAuth
// (the e2e suite + examples/shared-notepad use notepad.NewDemoBasicAuth);
// the embed here uses AnonymousAuthenticator because tinkerdown's
// embed-lvt server-side prefetch can't forward Authorization headers.
// Same-browser tabs share the cookie, so the BroadcastAction
// Same-browser tabs share the cookie, so the Publish-to-SelfTopic
// multi-tab refresh story still works in the embed; cross-browser
// users get different identities for the isolation demo.
mux.Handle("/apps/shared-notepad/", http.StripPrefix("/apps/shared-notepad", notepad.Handler(
Expand Down
30 changes: 21 additions & 9 deletions content/recipes/counter/_app/counter.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ package counter
import "github.com/livetemplate/livetemplate"

// CounterState is per-session state — pure data, cloned per session by
// livetemplate. AnonymousAuthenticator (handler.go) keeps state private
// per browser; BroadcastAction (below) keeps a single user's tabs in
// livetemplate. AnonymousAuthenticator (handler.go) keeps state private per
// browser; Publish to SelfTopic() (below) keeps a single user's tabs in
// sync without leaking state to other visitors.
type CounterState struct {
Counter int
Expand All @@ -14,20 +14,32 @@ type CounterState struct {
// exposes action methods invoked by name from the template.
type CounterController struct{}

// Increment is invoked when the user clicks the "+1" button. The
// runtime calls it with a clone of the current state and stores
// whatever you return. The BroadcastAction call tells the runtime
// to apply this same action on every other connected client, so
// multiple embeds and tabs stay in lockstep.
// Mount subscribes the self-topic so peer tabs of the same session receive
// the Increment / Decrement dispatches Publish'd from the actions below.
func (c *CounterController) Mount(s CounterState, ctx *livetemplate.Context) (CounterState, error) {
if err := ctx.Subscribe(ctx.SelfTopic()); err != nil {
return s, err
}
return s, nil
}

// Increment is invoked when the user clicks the "+1" button. The runtime
// calls it with a clone of the current state and stores whatever you return.
// The Publish call tells peer tabs subscribed to the same SelfTopic() to run
// Increment too, keeping multiple embeds and tabs in lockstep.
func (c *CounterController) Increment(s CounterState, ctx *livetemplate.Context) (CounterState, error) {
s.Counter++
ctx.BroadcastAction("Increment", nil)
if err := ctx.Publish(ctx.SelfTopic(), "Increment", nil); err != nil {
return s, err
}
return s, nil
}

// Decrement follows the same pattern.
func (c *CounterController) Decrement(s CounterState, ctx *livetemplate.Context) (CounterState, error) {
s.Counter--
ctx.BroadcastAction("Decrement", nil)
if err := ctx.Publish(ctx.SelfTopic(), "Decrement", nil); err != nil {
return s, err
}
return s, nil
}
4 changes: 2 additions & 2 deletions content/recipes/patterns/_app/data.go
Original file line number Diff line number Diff line change
Expand Up @@ -345,8 +345,8 @@ func allPatterns() []PatternCategory {
{
Name: "Real-Time & Multi-User",
Patterns: []PatternLink{
{Name: "Multi-User Refresh", Path: "/patterns/realtime/multi-user-sync", Description: "Explicit peer refresh via BroadcastAction", Implemented: true},
{Name: "Broadcasting", Path: "/patterns/realtime/broadcasting", Description: "Cross-connection updates via BroadcastAction", Implemented: true},
{Name: "Multi-User Refresh", Path: "/patterns/realtime/multi-user-sync", Description: "Explicit peer refresh via Publish to SelfTopic()", Implemented: true},
{Name: "Broadcasting", Path: "/patterns/realtime/broadcasting", Description: "Cross-connection updates via Publish to SelfTopic()", Implemented: true},
{Name: "Presence Tracking", Path: "/patterns/realtime/presence", Description: "Explicit join/leave with shared state", Implemented: true},
{Name: "Reconnection Recovery", Path: "/patterns/realtime/reconnection", Description: "State persistence across disconnects", Implemented: true},
{Name: "Live Preview", Path: "/patterns/realtime/live-preview", Description: "Real-time input preview via Change()", Implemented: true},
Expand Down
2 changes: 1 addition & 1 deletion content/recipes/patterns/_app/handlers_lists.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ func infiniteScrollHandler() http.Handler {

// --- Sortable List ---

// SortableController holds the list ordering process-wide so it persists across reloads (live multi-tab sync would need BroadcastAction).
// SortableController holds the list ordering process-wide so it persists across reloads (live multi-tab sync would need Publish to SelfTopic()).
type SortableController struct {
mu sync.Mutex
items []SortableItem
Expand Down
60 changes: 40 additions & 20 deletions content/recipes/patterns/_app/handlers_realtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,24 @@ type MultiUserSyncController struct {
counter int
}

// Mount runs on every initial render. Without it, a tab that opens
// AFTER other tabs have incremented would render Counter:0 and only
// converge on the next peer refresh. Same fix as PresenceController.
// Mount runs on every initial render. Subscribing the self-topic wires this
// connection to receive RefreshCounter from peer Publishes. Without the
// initial-render counter read, a tab that opens AFTER other tabs have
// incremented would render Counter:0 and only converge on the next peer
// publish. Same fix as PresenceController.
func (c *MultiUserSyncController) Mount(state MultiUserSyncState, ctx *livetemplate.Context) (MultiUserSyncState, error) {
if err := ctx.Subscribe(ctx.SelfTopic()); err != nil {
return state, err
}
c.mu.RLock()
state.Counter = c.counter
c.mu.RUnlock()
return state, nil
}

// RefreshCounter is explicitly broadcast to peer connections after Increment.
// The state arg is the peer's local state; we replace its Counter from the
// shared controller value so all tabs converge.
// RefreshCounter is the action peer connections run when Increment publishes
// to SelfTopic(). The state arg is the peer's local state; we replace its
// Counter from the shared controller value so all tabs converge.
func (c *MultiUserSyncController) RefreshCounter(state MultiUserSyncState, ctx *livetemplate.Context) (MultiUserSyncState, error) {
c.mu.RLock()
state.Counter = c.counter
Expand All @@ -42,7 +47,9 @@ func (c *MultiUserSyncController) Increment(state MultiUserSyncState, ctx *livet
c.counter++
state.Counter = c.counter
c.mu.Unlock()
ctx.BroadcastAction("RefreshCounter", nil)
if err := ctx.Publish(ctx.SelfTopic(), "RefreshCounter", nil); err != nil {
return state, err
}
return state, nil
}

Expand Down Expand Up @@ -71,6 +78,9 @@ func (c *BroadcastingController) snapshotLocked() []BroadcastMessage {
}

func (c *BroadcastingController) Mount(state BroadcastingState, ctx *livetemplate.Context) (BroadcastingState, error) {
if err := ctx.Subscribe(ctx.SelfTopic()); err != nil {
return state, err
}
c.mu.RLock()
state.Messages = c.snapshotLocked()
c.mu.RUnlock()
Expand Down Expand Up @@ -100,16 +110,18 @@ func (c *BroadcastingController) Send(state BroadcastingState, ctx *livetemplate
c.mu.Lock()
c.nextID++
// No cap on c.messages: deliberately omitted to keep the demo focused
// on the BroadcastAction mechanism. Production apps would ring-buffer,
// paginate, or persist to a store with TTL.
// 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()
// BroadcastAction must come after the lock release — holding the
// connection registry mutex while queuing broadcasts can deadlock with
// peer dispatches that take the same mutex from the other side. Peers
// 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.
ctx.BroadcastAction("NewMessage", nil)
if err := ctx.Publish(ctx.SelfTopic(), "NewMessage", nil); err != nil {
return state, err
}
return state, nil
}

Expand Down Expand Up @@ -144,11 +156,15 @@ func newPresenceController() *PresenceController {
return &PresenceController{onlineUsers: make(map[string]bool)}
}

// Mount runs on every initial render. Without it a new visitor's
// state.OnlineCount would default to 0 even when other users are
// already in the shared map — they'd see "0 user(s) online" until
// the next Join/Leave broadcast updates them.
// Mount runs on every initial render. Subscribing the self-topic wires this
// connection to receive PresenceChanged from peer Publishes. Without the
// initial-render OnlineCount read, a new visitor's state.OnlineCount would
// default to 0 even when other users are already in the shared map — they'd
// see "0 user(s) online" until the next Join/Leave publish updates them.
func (c *PresenceController) Mount(state PresenceState, ctx *livetemplate.Context) (PresenceState, error) {
if err := ctx.Subscribe(ctx.SelfTopic()); err != nil {
return state, err
}
c.mu.RLock()
state.OnlineCount = len(c.onlineUsers)
c.mu.RUnlock()
Expand All @@ -166,7 +182,9 @@ func (c *PresenceController) Join(state PresenceState, ctx *livetemplate.Context
state.Joined = true
state.OnlineCount = len(c.onlineUsers)
c.mu.Unlock()
ctx.BroadcastAction("PresenceChanged", nil)
if err := ctx.Publish(ctx.SelfTopic(), "PresenceChanged", nil); err != nil {
return state, err
}
return state, nil
}

Expand All @@ -180,13 +198,15 @@ func (c *PresenceController) Leave(state PresenceState, ctx *livetemplate.Contex
state.Joined = false
state.OnlineCount = len(c.onlineUsers)
c.mu.Unlock()
ctx.BroadcastAction("PresenceChanged", nil)
if err := ctx.Publish(ctx.SelfTopic(), "PresenceChanged", nil); err != nil {
return state, err
}
return state, nil
}

// PresenceChanged refreshes only the shared OnlineCount. Username and
// Joined are per-connection identity and must NOT be overwritten from a
// peer broadcast — every connection's own Join/Leave is the only thing
// peer publish — every connection's own Join/Leave is the only thing
// that mutates those fields locally.
func (c *PresenceController) PresenceChanged(state PresenceState, ctx *livetemplate.Context) (PresenceState, error) {
c.mu.RLock()
Expand Down
30 changes: 21 additions & 9 deletions content/recipes/shared-notepad/_app/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,14 @@ type NotepadState struct {

// >>> region:mount
// 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.
// 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 {
Expand All @@ -50,10 +55,10 @@ func (c *NotepadController) Mount(state NotepadState, ctx *livetemplate.Context)
// <<< region:mount

// >>> region:save
// 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.
// 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)
Expand All @@ -63,7 +68,14 @@ func (c *NotepadController) Save(state NotepadState, ctx *livetemplate.Context)
c.notes[ctx.UserID()] = state
c.mu.Unlock()

ctx.BroadcastAction("Refresh", nil)
// 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
}

Expand All @@ -81,11 +93,11 @@ func (c *NotepadController) Change(state NotepadState, ctx *livetemplate.Context
}

// >>> region:refresh
// Refresh is the action peer tabs run when Save broadcasts. It re-reads
// 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 BroadcastAction("Refresh", nil)
// 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()
Expand Down
17 changes: 9 additions & 8 deletions content/recipes/shared-notepad/_app/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@
// state under a sync.RWMutex. Mount + Refresh both read from it;
// Save writes.
//
// - ctx.BroadcastAction("Refresh", nil) is the explicit peer-refresh
// primitive. After Save commits, every peer connection in the same
// session group (other tabs of the same user) runs Refresh and
// re-reads from the map.
// - ctx.Publish(ctx.SelfTopic(), "Refresh", nil) is the explicit
// peer-refresh primitive. After Save commits, every peer connection
// subscribed to the same SelfTopic() (other tabs of the same user)
// runs Refresh and re-reads from the map. Mount establishes the
// Subscribe.
//
// There is no main() here. Production runs via the docs single-binary
// container, mounted by cmd/site at /apps/shared-notepad/. The example
Expand Down Expand Up @@ -77,9 +78,9 @@ func extractTemplate() string {
// >>> region: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.
// 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.
Expand All @@ -105,7 +106,7 @@ func NewDemoBasicAuth() livetemplate.Authenticator {
// extract the LiveTemplate wrapper, and that prefetch can't
// forward Authorization headers — a BasicAuth mount would degrade
// to "live demo unavailable" in the docs page. Same-browser tabs
// share the cookie, so the multi-tab BroadcastAction demo still
// share the cookie, so the multi-tab Publish-to-SelfTopic demo still
// works; different browsers get different identities for isolation.
func Handler(opts ...livetemplate.Option) http.Handler {
controller := &NotepadController{
Expand Down
21 changes: 17 additions & 4 deletions content/recipes/todos/_app/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ type TodoController struct {
}

func (c *TodoController) Mount(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
// Subscribe self-topic so peer tabs of the same user receive the
// RefreshTodos dispatch from Publish calls in actions below.
if err := ctx.Subscribe(ctx.SelfTopic()); err != nil {
return state, err
}
state.Username = ctx.UserID()
state = initComponents(state)
return c.loadTodos(context.Background(), state, ctx.UserID())
Expand Down Expand Up @@ -60,7 +65,9 @@ func (c *TodoController) Add(state TodoState, ctx *livetemplate.Context) (TodoSt
if err != nil {
return state, err
}
ctx.BroadcastAction("RefreshTodos", nil)
if err := ctx.Publish(ctx.SelfTopic(), "RefreshTodos", nil); err != nil {
return state, err
}
return state, nil
}

Expand Down Expand Up @@ -99,7 +106,9 @@ func (c *TodoController) Toggle(state TodoState, ctx *livetemplate.Context) (Tod
if err != nil {
return state, err
}
ctx.BroadcastAction("RefreshTodos", nil)
if err := ctx.Publish(ctx.SelfTopic(), "RefreshTodos", nil); err != nil {
return state, err
}
return state, nil
}

Expand Down Expand Up @@ -134,7 +143,9 @@ func (c *TodoController) ConfirmDeleteConfirm(state TodoState, ctx *livetemplate
if err != nil {
return state, err
}
ctx.BroadcastAction("RefreshTodos", nil)
if err := ctx.Publish(ctx.SelfTopic(), "RefreshTodos", nil); err != nil {
return state, err
}
return state, nil
}

Expand Down Expand Up @@ -225,7 +236,9 @@ func (c *TodoController) ClearCompleted(state TodoState, ctx *livetemplate.Conte
if err != nil {
return state, err
}
ctx.BroadcastAction("RefreshTodos", nil)
if err := ctx.Publish(ctx.SelfTopic(), "RefreshTodos", nil); err != nil {
return state, err
}
return state, nil
}

Expand Down
2 changes: 1 addition & 1 deletion e2e/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ require (
github.com/chromedp/chromedp v0.14.2
github.com/gorilla/websocket v1.5.3
github.com/livetemplate/docs v0.0.0-00010101000000-000000000000
github.com/livetemplate/livetemplate v0.9.0
github.com/livetemplate/livetemplate v0.9.2
github.com/livetemplate/lvt v0.1.6
)

Expand Down
4 changes: 2 additions & 2 deletions e2e/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,8 @@ github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kUL
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/livetemplate/livetemplate v0.9.0 h1:6vHNNpZLIY4gO08/Gth3vr1F3A3ANwSKTQvfuy1I8js=
github.com/livetemplate/livetemplate v0.9.0/go.mod h1:GMvZKyPUq8LSGfgD3pftKOHa6v+I+RDYyff2mNjeAYs=
github.com/livetemplate/livetemplate v0.9.2 h1:zF/mbhXxp5uLX3UBdgkdBk+Jmhfx3g5DAlUCisTXoZM=
github.com/livetemplate/livetemplate v0.9.2/go.mod h1:GMvZKyPUq8LSGfgD3pftKOHa6v+I+RDYyff2mNjeAYs=
github.com/livetemplate/lvt v0.1.6 h1:1rDU5hDo+EtZ0mT+868wYD9czF2EHEgdacS4kpIUPQ4=
github.com/livetemplate/lvt v0.1.6/go.mod h1:OrTdx3zvh0WeuugVueQoRG3ILRNJe/dThErxKsos6Rw=
github.com/livetemplate/lvt/components v0.1.2 h1:MM2M5IZnsUAu0py9ZbtcQCo0bvUrL4Z3Ly/yDkYNyag=
Expand Down
Loading
Loading