web / asyncHTML
asyncHTML is a small client library for building server-rendered web apps. HTML elements make async HTTP requests, the server responds with HTML fragments, and the library swaps them into the page. It is one TypeScript file, around 500 lines, with no dependencies and no build step beyond the one already bundling the app's JavaScript.
It occupies the same space as htmx: hypermedia as the engine of application state, without a client-side framework. The difference is that asyncHTML is small enough to read in a sitting and own outright, and it defines a tight contract with the server. This article documents that contract, using a Go and Haml app as the source of truth.
Attributes
Elements opt in with ah- attributes. A link that loads a form:
<a ah-get="/notes/new">+ Note</a>
Clicking it issues GET /notes/new, and the response HTML runs. A form
that posts:
<form ah-post="/notes/create_for_person">
<input type="hidden" name="person_id" value="42" />
<textarea name="comments"></textarea>
<input type="submit" value="Save" />
</form>
The full vocabulary:
a[ah-get],a[ah-post]: request on clickform[ah-get],form[ah-post]: request on submitinput[ah-submit-on-type]: debounced submit of the enclosing forminput[ah-post-on-type],textarea[ah-post-on-type]: debounced POSTinput[type=file][ah-upload]: presign and upload to object storage[ah-get-on-load]: fire a GET when the element enters the DOM[ah-toggle]: toggle a.hiddenclass on a selector[ah-confirm]:window.confirmbefore acting
The request
Every request is built through one function so the security options are uniform:
buildRequest: (url, options) => {
const headers = new Headers(options?.headers || undefined);
const method = options?.method?.toUpperCase() || "GET";
const nonIdempotent = ["POST", "PUT", "PATCH", "DELETE"];
if (nonIdempotent.includes(method)) {
const csrfToken =
document.querySelector("[name='csrf-token']")?.content ?? "";
headers.append(asyncHTML.config.csrfHeader, csrfToken);
}
headers.append(asyncHTML.config.refererHeader, window.location.href);
return new Request(url, {
...options,
headers,
credentials: "same-origin",
});
},
Two headers matter to the server. The referer header (AH-Referer)
carries the page that made the request. The CSRF header
(AH-CSRF-Token) carries the token, and only for unsafe methods, since
GET and HEAD never mutate.
credentials: "same-origin" sends cookies only to same-origin URLs.
The server knows it is talking to asyncHTML
The AH-Referer header does double duty. It tells the server that a
request came from asyncHTML rather than a full-page navigation, which
decides whether to return a fragment or a whole page:
// IsAjax reports whether the request is an asyncHTML request.
func IsAjax(r *http.Request) bool {
return r.Header.Get("AH-Referer") != ""
}
Handlers that only ever serve fragments reject anything else:
func (h *Handler) CreateForPerson(w http.ResponseWriter, r *http.Request) {
if err := webutil.ValidateParams(r, personCreateParams...); err != nil {
h.WriteError(w, 400, err.Error())
return
}
if !webutil.IsAjax(r) {
h.WriteError(w, 400, "ajax only")
return
}
// ...
}
It also names the page to return to after a successful mutation. The handler reads it straight off the request:
h.Redirect(w, r, r.Header.Get("AH-Referer"))
Redirects without the double GET
The interesting server-side decision is how to redirect. A standard
HTTP 303 does not work well through fetch. The Fetch API's redirect
options each fail in their own way:
redirect: "follow"(the default) follows the 303 to aGET, then JS seesresp.redirectedand navigates toresp.url, turning one POST into POST then GET then GETredirect: "error"rejects with a network error and loses the targetredirect: "manual"returns an opaque response whoseLocationheader CORS will not let you read
asyncHTML uses a custom response header instead. The server returns 200
with AH-Location, and the client navigates:
const location = resp.headers.get("AH-Location");
if (location) {
window.location.href = location;
return;
}
On the server, one redirect helper branches on whether the request is
asyncHTML, and validates the target is same-origin before trusting it:
func redirect(w http.ResponseWriter, r *http.Request, location string) {
if location == "" {
location = "/"
}
if !webutil.SameOriginOrInternalPath(r, location) {
w.WriteHeader(400)
return
}
if !webutil.IsAjax(r) {
http.Redirect(w, r, location, 303)
return
}
w.Header().Set("AH-Location", webutil.AbsoluteURL(r, location))
w.WriteHeader(200)
}
The same mechanism handles auth failure. When CSRF validation rejects an
async request, the middleware returns AH-Location pointing at the
referring page so the browser reloads into the login flow, rather than
splicing a login page into a fragment:
func (m CSRF) reject(w http.ResponseWriter, r *http.Request) {
m.session.Clear(r)
if location := ajaxReloadLocation(r); location != "" {
w.Header().Set("AH-Location", location)
w.WriteHeader(200)
return
}
http.Redirect(w, r, "/login", 303)
}
ajaxReloadLocation returns the AH-Referer value only after a
same-origin check, so the redirect target can never be attacker-chosen.
Running fragment scripts
Setting innerHTML does not execute embedded <script> tags; the HTML5
spec forbids it. asyncHTML re-emits each one as a fresh <script>
element wrapped in an IIFE:
Array.from(tmp.querySelectorAll("script")).forEach((script) => {
const replacement = document.createElement("script");
replacement.text = `(function () {\n${script.text}\n})();`;
document.head.appendChild(replacement);
document.head.removeChild(replacement);
});
The IIFE preserves function-scope isolation. Using dynamic <script>
elements rather than new Function or eval means the Content Security
Policy can omit 'unsafe-eval'.
This lets a fragment carry its own behavior. A note form returns markup plus the script that opens it in a drawer:
%template#tmp
%form{ "ah-post": data.form_url }
%textarea{ name: "comments" }
%input{ type: "submit", value: "Save" }
:javascript
APP.openDrawer("template#tmp");
APP.focus("textarea[name='comments']");
Because fragments run their own scripts, the server owns their safety.
Any request-derived value interpolated into a fragment script must be
encoded for the JavaScript-string context first. Reflecting a header or
query param raw lets a crafted URL break out of the string and run as
script in the victim's session, which is reflected XSS. Pass it through
a server-side escape_javascript helper before it reaches the fragment.
Multipart forms on the server
ah-post forms submit FormData, which fetch sends as
multipart/form-data. Go's r.ParseForm ignores multipart bodies, and
once it runs r.FormValue will not parse them either, so the parse has
to branch on content type:
func parsePostForm(r *http.Request) error {
if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") {
return r.ParseMultipartForm(maxMultipartMemory)
}
return r.ParseForm()
}
File uploads
ah-upload keeps large files off the app server. The client asks the
server for a presigned URL, PUTs the file straight to object storage,
then appends hidden inputs describing the upload so the eventual form
submit carries only metadata:
const resp = await asyncHTML.fetch("POST", presignedUrl, headers, body);
const { url, key } = await resp.json();
await fetch(url, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
});
form.appendChild(asyncHTML.createHiddenInput(`${name}[name]`, file.name));
form.appendChild(asyncHTML.createHiddenInput(`${name}[type]`, file.type));
form.appendChild(asyncHTML.createHiddenInput(`${name}[object_key]`, key));
The server reads those three keys back with a shared helper, so the key names live in one place:
func FileUpload(r *http.Request, name string) Upload {
return Upload{
Name: strings.TrimSpace(r.FormValue(name + "[name]")),
Type: strings.TrimSpace(r.FormValue(name + "[type]")),
ObjectKey: strings.TrimSpace(r.FormValue(name + "[object_key]")),
}
}
Security
The client sets a floor: credentials: "same-origin", a CSRF token on
unsafe methods, and same-origin validation on every redirect target. The
server enforces the rest.
CSRF checks compare the header token against the session token, for unsafe methods only:
func isSafeMethod(method string) bool {
switch method {
case "GET", "HEAD", "OPTIONS", "TRACE":
return true
default:
return false
}
}
That token guards only unsafe methods, so ah-get handlers must stay
side-effect-free. A state change reachable over GET is exploitable on
its own: a cross-site link carries the victim's cookies with no token
required. Route every mutation through ah-post.
Handlers also allowlist their parameters and reject anything unexpected with a 400, so a fragment endpoint has a small, declared surface. See go/handlers for that pattern and go/html-templates for rendering the fragments.
When to reach for it
asyncHTML fits an app that renders HTML on the server and wants
interactivity without shipping a client framework. The whole thing is
readable in one file, the server contract is four headers
(AH-Referer, AH-CSRF-Token, AH-Location, and the CSRF meta tag),
and there is nothing to upgrade. For an app already committed to
server-rendered HTML, that is most of the value of htmx at a size you
can hold in your head.