static/js/hypermedia.js
Hypermedia
Links and forms are the controls; HTML is the answer. Around 525 lines of ours in place of a framework -- and the page you are reading navigates through it right now.
Contract
The response says what changed
hypermedia.jsEvery top-level element of a response replaces the element on the page with the same id. A top-level <title> sets the tab title. Nothing else is interpreted -- no swap modes, no client-side targets, no out-of-band markers.
<span id="hm-count">7</span>
# lands on the element with that id.
# Nothing else on the page is touched.Links and forms are the controls
hypermedia.js<a href> and <form action method>, intercepted at the document level. Progressive enhancement is structural rather than a rule to remember: the fallback and the enhancement are the same markup.
Anything else
A control that is neither gets data-action="/url" plus data-method -- the same two words a form spells action and method. Give it a name and it submits its own value too, which is all a lone switch or checkbox needs.
A stream is a response that keeps arriving
hypermedia.jsdata-stream="/url" on an element holds a server-sent event stream open for as long as that element is on the page. Every message's data is HTML, applied by the same same-id rule -- so a handler pushes the components it would have rendered for a request, and there is no second vocabulary to learn.
It stays one-way on purpose: this is the server's mouth, never its ear. Actions remain ordinary requests.
s := web.NewStream(w)
for {
select {
case <-tick.C:
s.Send(ctx, Region(n))
case <-r.Context().Done():
return
}
}Failure is a full page load
hypermedia.jsA network error, a non-HTML answer or an error status during navigation hands the URL to the browser, which can always render it -- because every control is a real URL. The app cannot get stuck half-swapped.
The bar above never re-renders
showcase/shell.goClick between the pages in the top bar and watch it: the bar is outside the swapped region, so it is not in any response. If a shell flickers, the fragment branch is missing or its root lost its id.
Live
Six controls, and every answer is a fragment. Open the network panel if you like -- each one is a handful of bytes, and the rest of this page is never re-sent. One of them is meant to fail, and the last is the only one the server speaks first.
A fragment lands by id
showcase/showcase.goThe form posts the current count; the server answers with one element carrying id="hm-counter". No state on the server, no state on the client -- the DOM is the state.
A control that is not a form
showcase/showcase.godata-action plus data-method on a switch. It is a named field, so it submits itself the way a one-field form would -- and since the browser already moved it, the answer is one word, not the control.
The browser moved the switch on the click, so the answer is the word beside it and nothing more -- a control is never replaced under the finger touching it.
A failure puts the control back
showcase/showcase.goThe same optimism, and the other half of it. This switch moves on the click like the one beside it, but the endpoint always refuses -- so no fragment arrives to correct it, and the runtime restores it from the markup the server rendered. Nothing was remembered to do that: defaultChecked is the checked attribute, which is the server's own answer, still sitting in the DOM.
It flips, the toast says why, and it flips back -- a control never keeps a state the server never accepted.
data-confirm holds the request
showcase/showcase.goThe runtime only gates the request -- the question's wording and its dialog belong to the app. With no hm:confirm listener it falls back to the browser's own confirm, which is what happens here.
Q3 invoice.pdf
184 KB, added last Tuesday
X-Trigger fires an event
showcase/showcase.goSometimes the server knows something the page wants to react to, and it is not a change to any element. X-Trigger names events to dispatch on document once the fragments are in place; showcase.js listens and raises a toast.
The response body is empty. The header is the whole message.
The server pushes, unasked
showcase/showcase.goPress it and the answer to that POST is this element carrying data-stream, which is what opens the connection. From then on the server sends a fragment per step down one stream, each landing by the same id, until the last one leaves the attribute off -- and that is how a stream ends. There is no client-side stream code here, and no retry code anywhere: EventSource redials on its own, and the runtime revives a stream a backgrounded tab killed.
Nothing is connected yet: this element carries no data-stream, so there is no stream to hold. The button is an ordinary POST, and the answer to it is this same element with the attribute on it -- which is what opens the connection.
Headers
Four response headers say what a body cannot.
The four
web/core.go| Header | Means | Helper |
|---|---|---|
X-Redirect | Load this URL for real -- leave the fragment path entirely. | web.Redirect |
X-Location | Go there without a full reload; this body is discarded. | web.Location |
X-Push-Url | File this answer under that URL. | -- |
X-Trigger | Dispatch these events on document once applied. | -- |
Client API
hypermedia.jshm.visit(url)navigatehm.request(url, opts)everything elsehm:applieddetail {url, method, pushed, elements, ids}hm:confirmcancelable; detail.proceed() sends ithm:errora request that could not be applied
data-native opts out
AGENTS.mdTwo cases, both load-bearing: the response must arrive on a real navigation (login, register, logout -- iOS Safari drops a session cookie set on a fetch response), or the page's own script already handles that click.
The runtime's listeners are on document and a bundle's are registered later, so without it the runtime wins the race and fires a second request.
Tradeoffs
Deliberately absent
hypermedia.jsSwap modes, client-side targets, out-of-band markers, DOM history snapshots, request queues, an extension system. Back and forward re-fetch instead of restoring a cached DOM: one round trip, and never a stale page.
What you give up
hypermedia.jsOffline-first behaviour is not free here: it wants client state, and there is none. Anything genuinely local (a canvas, a text editor, the chat composer) is a page script that marks its controls data-native and owns them.
Optimism is the exception, and only in one shape: a control may move before the answer arrives when putting it back is a re-derivation rather than a remembered undo -- a switch re-read from the markup, a nav tab re-derived from the URL. An optimistic list insert or a mutation queue would need the previous state kept somewhere, which is the client cache this runtime exists without.
Unmatched fragments warn
hypermedia.jsA fragment whose id is nowhere on the page is logged loudly rather than silently dropped -- the console is where a wrong id shows up, which is why the id is the contract and not a convention.
The chat does not use the stream
AGENTS.mdThe AI chat has its own SSE protocol and its own renderer, and that is settled rather than a gap -- it was converted to data-stream and reverted. A typewriter is display pacing, and replace-by-id cannot express it: replacing an element destroys the node identity the reveal is measured against, partial markdown is not prefix-stable, and a tool chip inside a fragment cannot wait for the text before it to finish typing.
So the rule is cadence. A region that changes when state changes is what data-stream is for; a turn arriving token by token, wanting to be paced, is not.
Why not htmx
AGENTS.mdNothing is wrong with htmx. This is one file we can read end to end and change in an afternoon, in place of 48KB of someone else's semantics -- and the file is the same across every app of this lineage.