Events
An event sends a change from its handler to every open page that shows the affected data. Subjects can limit delivery to a user, signal value or browser tab. A message broker carries events across server processes.
Why events
An action that takes datapages.SSE patches only the tab that sent the request. To update other tabs, users or server processes, dispatch an event:
tab A server broker
POST /add/ -------------> POSTAdd
Notes.Add(...)
Dispatch(EventNotesChanged) ---> "notes.changed"
|
tab A stream <--- patch ---- OnNotesChanged <----------------------+
tab B stream <--- patch ---- OnNotesChanged <----------------------+
Every tab of a page that handles events holds 1 SSE connection at the page path plus _$/. Datapages opens the stream and subscribes it to the subjects handled by that page. An arriving event calls the page's OnXXX handler, which sends patches through the tab's stream.
Declare an event
An event is a struct type whose name starts with Event. The first line of its doc comment names its subject in quotes:
// EventNotesChanged is "notes.changed"
type EventNotesChanged struct{}
Datapages publishes the event to the broker under this subject. Fields carry a payload, which Datapages encodes as JSON, and each payload field needs a json tag. 2 event types must not use the same subject.
Dispatch an event
Add a datapages.Dispatcher[EventXXX] parameter to the handler that changes the data, 1 per event type:
// POSTAdd is /add
func (p PageIndex) POSTAdd(
r *http.Request,
signals datapages.Signals[struct {
Text string `json:"text"`
}],
notesChanged datapages.Dispatcher[EventNotesChanged],
) error {
text := strings.TrimSpace(signals.Values.Text)
if text == "" {
return datapages.ErrBadRequest
}
if err := p.App.Notes.Add(r.Context(), text); err != nil {
return err
}
return notesChanged.Dispatch(EventNotesChanged{})
}
Dispatch publishes with the request context and returns the error of the broker. Actions, page GET methods and stream hooks may take dispatchers. Event handlers and GETXXX file actions may not.
A handler with several dispatchers publishes in call order. A failed publish neither undoes the earlier ones nor stops the later ones. Collect the errors with errors.Join. To publish after the handler returned, as from a goroutine, call DispatchCtx with a context of its own, such as context.WithoutCancel(r.Context()) with a deadline.
Handle an event
A page handles EventXXX with a method named On plus the event name without Event. It takes the event and a datapages.SSE:
func (p PageIndex) OnNotesChanged(
event EventNotesChanged, sse datapages.SSE,
) error {
return sse.PatchElement(noteList(p.App.Notes.List()))
}
templ noteList(list []string) {
<ul id="notes">
for _, n := range list {
<li>{ n }</li>
}
</ul>
}
PatchElement morphs the rendered list into the element with the ID notes in every tab of the page, the tab that added the note included. Most changes follow the same 5 steps:
- Validate the command.
- Change the authoritative data.
- Dispatch an event.
- Re-read the data in each
OnXXXhandler. - Render a complete current fragment.
Treat an event as a notification, not as the source of truth. A tab can miss events, as Delivery describes. A handler that renders the whole list from the store corrects the page with the next event that reaches it. A handler that appends the text of the event to the page leaves a gap for every event it missed. Performance explains why a whole fragment costs little on a compressed stream.
An event handler may also take session for the visitor of the tab, datapages.StreamID and datapages.State[T]. It cannot take signals, because a tab sends its signals once, when its stream connects. Put what the handler needs in the event, in the store, or in per-tab state.
Datapages logs the error an OnXXX handler returns and keeps the stream open. It does not pass that error to RecoverError, so send the feedback with sse before returning. A panic reaches RecoverError as a datapages.PanicError and ends the stream, which the page then reconnects.
Address events with subjects
An event without subject fields reaches every stream of every page that handles it. Subject fields narrow the delivery. Their values follow the subject of the event in field order, separated by periods. EventMessageReceived{Recipient: "u1"} with the subject "messages.received" publishes to messages.received.u1. What a page subscribes to depends on the type of the field:
| Field | A page that handles the event subscribes to |
|---|---|
datapages.SubjectUser |
the user ID of the session the stream belongs to |
datapages.Subject with a signal:"name" tag |
the value of the signal name when the stream connects |
datapages.Subject without a tag |
every value |
datapages.SubjectStateID |
the tab's state ID, see Per-tab state |
Declare subject fields before the payload fields, export them, use exactly these types and do not tag them json:"-". 1 dispatch publishes to 1 subject. To reach several values, dispatch once per value. Datapages escapes ., *, >, % and whitespace in a value into 1 literal segment, which lets an email address serve as a user ID and makes a * that a client sends match only the value *. An empty value fails the dispatch. An event with subject fields also claims every subject below its own, so "notes" with a subject field conflicts with "notes.changed".
Address a user
A datapages.SubjectUser field delivers the event to the streams of 1 signed-in user:
// EventMessageReceived is "messages.received"
type EventMessageReceived struct {
Recipient datapages.SubjectUser `json:"recipient"`
From string `json:"from"`
}
// POSTSend is /inbox/send
func (p PageInbox) POSTSend(
r *http.Request,
session Session,
signals datapages.Signals[struct {
To []string `json:"to"`
Text string `json:"text"`
}],
received datapages.Dispatcher[EventMessageReceived],
) error {
if session.IsGuest() {
return datapages.ErrForbidden
}
var errs []error
for _, to := range signals.Values.To {
err := p.App.Inbox.Deliver(r.Context(), session.UserID(), to, signals.Values.Text)
if err == nil {
err = received.Dispatch(EventMessageReceived{
Recipient: datapages.SubjectUser(to),
From: session.UserID(),
})
}
errs = append(errs, err)
}
return errors.Join(errs...)
}
func (p PageInbox) OnMessageReceived(
event EventMessageReceived, sse datapages.SSE, session Session,
) error {
return sse.PatchElement(inbox(p.App.Inbox.For(session.UserID())))
}
The application needs a session type. See Sessions and auth. An event has at most 1 SubjectUser field, and the field takes no signal tag. A page whose events are all user-addressed and which holds no per-tab state opens its stream only for a signed-in visitor. A page that also handles public events or holds per-tab state serves signed-out visitors a second stream at _$/anon/, which carries no user-addressed events.
Scope by a signal
A signal tag binds a datapages.Subject field to a Datastar signal. Each tab subscribes to the value it holds when its stream connects:
// EventRoomMessage is "rooms.message"
type EventRoomMessage struct {
Room datapages.Subject `json:"room" signal:"room"`
Text string `json:"text"`
}
templ pageRoom(room string, msgs []string) {
<main data-signals:room={ templ.JSONString(room) }>
<h1>{ room }</h1>
@roomMessages(msgs)
</main>
}
A dispatch with Room: "red" reaches the tabs of room red and no other. A stream that connects without the signal, or with an empty value, receives 400. The tag names a signal path, such as signal:"chat.room" for a nested signal. Signal-scoped subject fields lists the restrictions.
Delivery
Delivery is at most once and has no replay. Only the streams subscribed when the event is published receive it. A tab misses events in these cases:
- Datastar closes the stream of a hidden tab. Datapages reloads the page when the tab becomes visible again, which renders the data it missed. A
GETthat returnsdatapages.EnableBackgroundStreaming(true)keeps the stream open instead. - Each subscription of the in-memory broker buffers 16 messages,
messaging.DefaultBrokerChanBuffer, and each of the NATS broker up to 32. When a page handles events slower than they arrive, the broker drops what does not fit. It does not block the publisher and does not tell the page. With Prometheus,datapages_event_broker_deliveries_dropped_totalcounts the drops. - A stream that reconnects after a network failure or a deployment misses what was published in between.
Stream hooks
StreamOpen runs when a tab's stream opens and StreamClose when it closes. Use them to acquire and release what a stream holds. This stream embed counts the open tabs and tells every tab the count:
// EventPresenceChanged is "presence.changed"
type EventPresenceChanged struct{}
// Presence is a stream embed. A page that embeds it shows how many tabs
// have a page of this process open.
type Presence struct{ App *App }
func (p Presence) StreamOpen(
r *http.Request,
streamID datapages.StreamID,
presenceChanged datapages.Dispatcher[EventPresenceChanged],
) error {
p.App.presence.add(streamID)
if err := presenceChanged.Dispatch(EventPresenceChanged{}); err != nil {
// StreamClose does not run after a failed StreamOpen.
p.App.presence.remove(streamID)
return err
}
return nil
}
func (p Presence) StreamClose(
r *http.Request,
streamID datapages.StreamID,
presenceChanged datapages.Dispatcher[EventPresenceChanged],
) error {
p.App.presence.remove(streamID)
return presenceChanged.Dispatch(EventPresenceChanged{})
}
func (p Presence) OnPresenceChanged(
event EventPresenceChanged, sse datapages.SSE,
) error {
return sse.PatchElement(presenceCount(p.App.presence.count()))
}
- Both hooks take
r *http.Requestand adatapages.StreamID, adatapages.State[T]or both. The stream ID identifies the stream within the process and pairs the 2 calls. Keep it on the server. StreamOpenmay also takesse,session,signalsand dispatchers.StreamClosemay takesessionand dispatchers.StreamOpenruns before any event handler of the stream. If it returns an error or panics,RecoverErrorreceives the error when the app defines it, the stream ends, the page reconnects after 5 seconds, andStreamClosedoes not run. Release what it acquired before returning the error.StreamCloseruns after the last event handler of the stream. Datapages logs its error. Its dispatchers publish with the request context without its cancelation, which lets them publish after the client is gone.
Share handlers between pages
A struct in the app package whose name does not start with Page and which declares App *App is a stream embed. A page that embeds it gets its event handlers and stream hooks:
// PageIndex is /
type PageIndex struct {
App *App
Presence
}
A handler that the page declares itself replaces the embedded one. A stream embed cannot declare GET or actions, and 2 types that a page embeds must not handle the same event. Stream Embeds has the exact rules.
Events across server processes
The broker passed to datapages.NewServer carries the events. The in-memory broker of modules/messaging/inmem connects the streams of 1 process. The broker of modules/messaging/natscore publishes through NATS, so an event dispatched on 1 process reaches the streams of every process connected to the same NATS server. The cmd/server/main.go that datapages init writes creates it with natscore.New(conn, natscore.Config{}). Deployment covers running several processes.
Events declared outside the app package
An event type may live in a package that the app package imports, directly or not. Several applications of 1 module then share it. Each dispatches and handles the same type under the same subject:
// Package events provides the event types that the applications of this module share.
package events
// EventAnnouncement is "announcement"
type EventAnnouncement struct {
Text string `json:"text"`
}
func (PageNews) OnAnnouncement(
event events.EventAnnouncement, sse datapages.SSE,
) error {
return sse.PatchElement(announcement(event.Text))
}
Applications that share a user-addressed event must use the same user IDs. Multiple applications shows the module layout.
See also
Dispatcher[EventXXX]: declaring events, dispatchers and subject fields.- Event delivery: when a stream misses events.
- Pages: the signatures of
OnXXX,StreamOpenandStreamClose. - Per-tab state: values an event handler reads for 1 tab, and events addressed to 1 tab.
- When the result arrives through an event: keep a loading indicator on until the event handler patches the tab.
- Sessions and auth: the session behind user-addressed events.
example/todolist: a list that every tab re-renders on each change.