web / live regions

My web pages are server-rendered HTML with a small script. I want a page to show a write within a second of it landing: a check that went green, a comment a teammate posted, a deploy that reached production. I want that without a frontend framework and without a second copy of the page's state in JavaScript.

The server sends one signal, "something changed", over Server-Sent Events. go / wakeups covers how it makes that signal. The page answers the signal by fetching parts of itself again, as HTML fragments, and swapping them in. This article is about the page.

A region

A live region is an element with the class live and an id. The id is the name of the fragment template that renders it, and the path the server serves it at. An ancestor carries data-live-base, the URL the region asks under:

.change{ "data-live-base": changeurl }
  %span.live#checks
    = render "checks"
  .side-section.live#commits
    = render "commits"
  %section.live#comments
    = render "comments"

The page renders checks.hml inline. The endpoint /change/APP-42/checks renders the same file with locals from the same function. One file for both, because a swap puts the endpoint's markup where the page's was, and two sources drift.

A region is whatever element holds the whole answer. Where the label is fixed, the region is a span beside it. Where the label changes with the data, the region is the section, label and all.

The server keeps the regions in one table, keyed by name:

var changeSections = map[string]section{
	"checks":   {locals: checksLocals},
	"commits":  {locals: commitsLocals},
	"comments": {locals: commentsLocals},
	"diff":     {locals: diffLocals, etag: diffETag},
}

The table is the dispatch for the endpoint, and a test compares the set of .live ids each page renders against it. A region without an endpoint would 404 on every wake, and the script would skip it, and a reader would look at old words under a page that says nothing.

The swap

The script opens an EventSource on /wake. Each event starts a 150 ms debounce, and the debounce runs one pass over the regions:

const pass = async () => {
  for (const el of document.querySelectorAll(".live")) {
    const base = el.closest("[data-live-base]");
    if (!base || !el.id) continue;
    try {
      const h = el.dataset.etag
        ? { "If-None-Match": el.dataset.etag }
        : undefined;
      const r = await fetch(base.dataset.liveBase + "/" + el.id, {
        cache: "no-store",
        headers: h,
      });
      el.classList.toggle("stale", !r.ok && r.status !== 304);
      if (!r.ok) continue;
      const tag = r.headers.get("ETag");
      if (tag) el.dataset.etag = tag;
      const html = await r.text();
      if (html === el.innerHTML) continue;
      el.innerHTML = html;
    } catch (e) {
      el.classList.add("stale");
    }
  }
};

The debounce coalesces a burst of writes into one pass. The pass asks for the regions one after another, because the diff is one of them and a page that asked for eight regions at once would ask for the diff alongside the other seven.

One pass runs at a time. A wake that arrives during a pass sets a flag, and the pass runs again when it finishes. Two passes racing means two answers per region with no order, and the reader believes the one that lands last.

The first version of this script re-fetched the whole page and replaced the body. On a 500-file change that is half a megabyte of diff, read from git and highlighted, to learn that a check went green. And the wake fires for every write in every repo, so every open page paid that cost for every write anywhere.

ETags

A swap collapses a text selection inside the region. The wake fires for every write, so a reader copying a description lost the selection each time a check went green somewhere else.

So every fragment carries an ETag, the region sends it back as If-None-Match, and the server answers 304 when the markup did not change. A 304 leaves the region alone.

Most regions hash their own markup:

func writeFragment(w http.ResponseWriter, req *http.Request, view string, locals map[string]any) {
	out, err := renderFragment(view, locals)
	if err != nil {
		http.Error(w, "template error", 500)
		return
	}
	if w.Header().Get("ETag") == "" && notModified(w, req, fragmentETag(out)) {
		return
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.Write([]byte(out))
}

func notModified(w http.ResponseWriter, req *http.Request, etag string) bool {
	if etag == "" {
		return false
	}
	w.Header().Set("ETag", etag)
	if req.Header.Get("If-None-Match") != etag {
		return false
	}
	w.WriteHeader(304)
	return true
}

The hash skips the words inside <time> elements. Those words are relative ("68s ago") and differ between two renders of the same data, so the comments region answered 200 to every wake for two minutes after a comment landed. The datetime attribute stays in the hash, and it is what changes when the data does.

The diff names a cheaper version: the head commit. The server checks it before it reads git:

func (s *server) writeSection(w http.ResponseWriter, req *http.Request, name string, sec section, c *change) {
	if sec.etag != nil && notModified(w, req, sec.etag(c)) {
		return
	}
	locals, err := sec.locals(s, req.Context(), c)
	if err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	writeFragment(w, req, name, locals)
}

The page writes that version into data-etag at render time, so the first wake already sends it. The other regions render without a version, so the first wake asks for each one unconditionally and compares the answer against innerHTML. Every wake after that sends the tag the answer carried.

Relative times

An age is a <time> element with a datetime attribute and words inside it. A script rewrites the words every five seconds from the attribute:

function update() {
  for (const e of document.querySelectorAll("time[datetime]")) {
    e.innerText = reltime(new Date(e.dateTime));
  }
}
setInterval(update, 5 * 1000);

A page that sits open for an hour reads "1h ago" without a wake. The fragment ETag ignores the words for the same reason: the words are a function of the clock, and the clock is not a change.

When it stops

The failure this mechanism exists to prevent is stale text under a page that looks live. So each failure marks itself.

A region whose fetch failed gets the class stale. A page render that could not fill a region writes data-stale="1", and the script turns that into the same class on load. One banner at the top of the page reads "not updating -- reload" while any region is stale. One banner, and not a sentence per region, because a wake asks for every region at once, and whatever stops one usually stops all of them. The page that said it per region said "not updating" eight times about one fact.

EventSource reconnects on its own and reports every reconnect as an error. A proxy timeout or a closed laptop is not news, so the script waits ten seconds before it lights the banner. When the stream opens again after being down, the script runs a pass, since a stream that was down left writes to catch up on.

A fetch that follows a redirect to the login page navigates there. A login form swapped into a dashboard looks idle, which is worse than a page that says the session ended.

Output as it happens

A running check's output is a second kind of live: a stream, not a snapshot. The page for a running check opens an EventSource on the check's stream URL and appends each message to a <pre>:

const out = document.getElementById("live-output");
const es = new EventSource(path);
es.onmessage = (e) =>
  out.appendChild(document.createTextNode(JSON.parse(e.data)));
es.addEventListener("done", () => {
  eof.hidden = false;
  es.close();
});

Each message is a JSON-encoded string. SSE frames on newlines, and test output is full of them, so the encoding is what keeps a chunk in one piece. A done event marks the end, and the page shows eof, so a reader can tell a finished check from a stream that stopped.

On the server, the handler waits for a worker to pick the job up, and writes one message, "in queue", while it waits. Then it copies the worker's chunks into the response as they arrive, and it flushes after each write.

The worker tails the output file and posts each new chunk as its own short POST. It does not stream one long request body. A CDN in front of the server buffers a request body until the request completes, so one long body would arrive all at once when the check ended. Many short requests arrive as they are sent.

Fit

This fits a server-rendered application where the server already owns the state and the templates. The page holds no model of its own. It holds markup, and it asks for fresh markup when told to. The pieces are EventSource, fetch, innerHTML, and an ETag, and each does what the platform says it does.

I use it in cibot, whose change page has eight live regions, and whose check page streams the output of a running check.

← All articles