🤖 Live pages
A live page changes when the data behind it changes. Someone adds a note in one tab, and the list in another tab updates. Nobody presses reload.
The page still works with scripts off. It is an ordinary page, as current as the moment it loaded. Live updates are an extra on top.
How it works
Your server keeps a counter for each topic. A topic is a name for a
thing that can change, like notes/7, the notes of user 7. When you
write to the database, you tell the server that topic changed.
Every open page holds a stream open to the server. When a topic moves, the stream says so. It sends the topic name and its new number, and nothing else. No data goes down the stream.
The page then asks for the part that changed, from its own URL, as its own viewer. Your handler answers with the same template and the same checks it uses for the whole page. That part is called a region.
In a new app
An app made with rastrillo new is already wired for live pages.
Router in app.go builds the hub, with no topics yet. The stream is
mounted at /live, the hub's middleware runs first, every page is drawn through
pages.Render, and the layout has the head tag and the notice. A page
with no region opens no stream.
What is left is the part only your app knows. Here a list of messages updates in every open tab when someone posts one.
Declare the topic where Router builds the hub:
app.live, err = live.New(live.Config{
Path: string(livePage),
Topics: map[string]live.Allow{
"messages": func(*http.Request, string) bool { return true },
},
})
Mark the region in the page's template, and use it where the list goes:
{{define "region:messages"}}<ul id="messages" {{.Live.Attrs "messages"}}>
{{range .Messages}}<li>{{.}}</li>{{end}}
</ul>{{end}}
{{template "region:messages" .}}
Tell the hub after the write, in the handler that saves:
app.live.Notify("messages")
visit.Flash("Sent.")
visit.Redirect("/")
Let the form swap in place:
<form method="post" action="{{path $ "/messages"}}" data-live-form="messages">
The form sits outside the region, so data-live-form names it: the
answer swaps the list and the notice in place. The form keeps what was
typed; to clear it after sending, put the form in a region of its own
that follows the same topic, since the form's own region is swapped
too. Every page's data gets
Live and Notice from render.go, so the region can call
.Live.Attrs and the flash shows in the notice.
In an app made with -auth magic, the stream sits behind sign-in, so
a topic's check can ask auth.From(request) who is watching.
The rest of this page is how each piece works, and what to do in an
app that rastrillo new did not make. Here everyone may follow
messages. When a topic belongs to one person, put them in the key, as
the next section explains.
Declare your topics
Make the hub once, at boot:
hub, err := live.New(live.Config{
Path: "/live",
Topics: map[string]live.Allow{
"notes": func(r *http.Request, key string) bool {
uid, ok := sessions.UserID(r)
return ok && strconv.FormatInt(uid, 10) == key
},
},
})
Path is where the stream lives. Each topic gets a check that says
whether this request may follow this key.
Put the owner in the key. A topic called notes with no key would
wake every signed-in page when anyone writes, and that tells Bob that
Alice wrote something. With notes/<user id>, only Alice's pages hear
about Alice's notes.
Names are lowercase letters, digits and hyphens, up to 32 characters.
Keys are letters, digits, _ and -, up to 64.
Mount it
Do three things:
r.Use(hub.Middleware) // first, before any handler queries anything
r.Group(func(r chi.Router) {
r.Use(sess.Require)
r.Handle("/live", hub.Handler()) // behind sign-in
})
opts.Live = hub // before rastrillo.Serve
Middleware marks when each request began. If a write lands while a
page is still being built, the page looks one step behind and fetches
once more. It never misses a change.
Put the stream behind your sign-in. The topic checks read the session, so the stream must come after your session middleware or behind sign-in.
Set Options.Live. Without it, an open stream holds shutdown for the
full 10-second drain.
A scaffolded app already has static/live.js and links it from its
layout. In an older app, run rastrillo doctor --fix and add the
<script defer> tag after busy.js.
Tell the server after you write
a.db.Create(&n)
a.live.Notify("notes/" + strconv.FormatInt(uid, 10))
Call Notify after the write has committed. If you call it before, a
page can fetch at once, read the old rows, and stay on them.
Notify never blocks. A topic you did not declare is logged and
skipped. On a nil hub it does nothing.
A change that happens at a time, such as a post going live at 9:00, has no write to follow. Tell the hub when it is due:
a.live.NotifyAt(p.PublishAt, "posts")
Call it at boot for each future time you have stored, and whenever a
schedule changes. See NotifyAt.
Mark a region
A region is an element with an id, and a template named after it.
{{define "region:notes-list"}}<div id="notes-list" {{.Live.Attrs .Content.Topic}}>
...the list...
</div>{{end}}
Attrs writes the attributes the script reads: the topics, each at
the version this page was built from. Never type them by hand.
The region: prefix matters. A request can only ever render a
template whose name starts with it. It cannot reach your layout or a
partial.
Use the region in the page like any template:
{{template "region:notes-list" .}}
Add the head tag to your layout, and the notice region above your content:
{{.Live.Head (T $L "rastrillo.ui.live.paused") (T $L "rastrillo.ui.live.unconfirmed")}}
...
{{template "region:notice" .}}
Head writes the meta tag that tells the script where the stream is.
Leave it out and the page stays static. The two strings are notes the
script shows when updates pause, or when a change could not be
confirmed. Both come translated with Rastrillo.
The notice is the flash message. Define region:notice as a
<div id="notice" role="status"> that is always on the page, empty
when there is no message. Screen readers announce a change to it.
Your page data needs a Live live.View field, set to hub.View(r).
Answer fragment requests
When a page asks for a region, the request carries a
Rastrillo-Fragment header. Answer it with Pages.Render. It handles the header in the one order
that keeps flash messages from being lost:
func (a *app) renderStatus(w http.ResponseWriter, r *http.Request, status int, name string, content any) {
err := a.pages.Render(w, r, status, name, func(takeFlash bool) (any, error) {
var fl flash.Flash
var hasFlash bool
if takeFlash {
fl, hasFlash = flash.Take(w, r)
}
return page{ /* ... */ Live: a.live.View(r), Flash: fl, HasFlash: hasFlash, Content: content}, nil
})
if err != nil {
http.Error(w, "something went wrong", http.StatusInternalServerError)
}
}
Your function builds the page data, and takes the flash only when
takeFlash is true. A refresh caused by the stream does not show the
notice, so it must not take the flash. When the answer is "go
somewhere else" (a form whose redirect went to another page, or a
request for regions this page does not have), your function is not
called at all, and the flash waits for the page you land on.
status is your response's status, such as 422 for a form with
errors; 0 means 200. If your function, the localizer check or a
template fails, nothing has been written, and you send your own error.
An error while writing the response can come after it has started.
With your own template map instead of ui.Pages, call
live.Render with the page's
templates. See examples/notes/internal/notes/render.go for the full
version.
Forms that swap in place
Add data-live-form to a form:
<form method="post" action="/notes/{{.ID}}/delete" data-live-form>
The script sends the form with fetch. It swaps in the region the
form sits in, any regions you list in the attribute's value, and the
notice. With scripts off, the form posts and the redirect reloads the
page, as it always did.
Your handler does not change. It writes, calls Notify, sets a flash
and redirects.
If the answer is a 400 or 422, the script puts focus on the first invalid field. If the answer is a 5xx, or nothing comes back, the script does not know whether the change happened. It keeps what you typed, says so in the notice, fetches the regions again, and does not send the form a second time. Making a repeat submit safe stays your job.
What the script leaves alone
It never swaps a region while someone is editing in it, meaning a
focused field or a field changed from its default. The region waits
and updates when editing ends. Focus moves to the replacement, never
to <body>.
It reconnects on its own, with growing pauses. If updates stay down for about ten seconds, or fetches keep failing, it shows the paused note, unless the notice is already showing a message.
On CARLOS
Live pages work on every route, including one that hibernates. The
script asks the CARLOS edge to hold its stream, with carlos_hold=1 in
the stream's URL. While the instance sleeps, the edge keeps the page's
connection open. The stream never wakes the instance and never keeps
it awake. Off CARLOS the marker does nothing.
When something else wakes the instance, the edge reconnects the stream. The server has restarted while it slept, so each open page fetches its live regions once. After that, only changes cost requests.
An app made before this change sends its stream without the marker, so
an open page keeps a hibernating instance awake. Run rastrillo doctor --fix to copy the new live.js.
A few things to keep in mind:
- Keep
Lifetimeat 30 seconds or more. The edge closes a held stream after five shorter connections in a row. - A time you passed to
NotifyAtdoes not fire while the instance sleeps. The next wake fetches every open page's regions anyway. - If the stream route redirects a signed-out viewer, the edge ends the stream instead of following the redirect. The script tries again later, as it does for any stream that ends.
Read this page as markdown — exact, unstyled, and cheap for an agent to fetch.