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.
It occupies the same space as htmx: hypermedia as the engine of application state without a client-side framework. The difference is a clearer contract. Where htmx offers a broad vocabulary of attributes that you must hold in your head to know how they compose, asyncHTML keeps the attribute set small and gives the rest to the server.
A response carries the HTML for a swap plus any JavaScript that should run with
it, including navigation and history updates. asyncHTML exposes those as
utilities like pushURL, called from that JavaScript rather than encoded as
more attributes. A fragment co-locates structure and behavior in one template
that reads a little like a Svelte file.
I wrote asyncHTML in app that traced back through early-2010s Ruby on Rails.
It was using link_to path, remote: true, which rendered data-remote and let
a document-delegated UJS script respond
with .js.haml. asyncHTML keeps that model of small attributes, delegated
listeners, and the server sending HTML with the script to run beside it.
This article documents that contract, using a Go and hml app as the source of truth.
Attributes
Elements opt in with ah- attributes. A link that loads a form:
%a{ "ah-get": "/notes/new_for_person?person_id=42" }
+ Note
Clicking it issues GET /notes/new_for_person, and the response HTML runs.
A response is markup plus any <script> to place it: the library injects
the markup and executes the scripts. The scripts can do whatever the DOM
allows. A common convention is to wrap the markup in a <template> so it is
not rendered until a script clones it. For example, into a drawer:
%template#tmp
%form{ "ah-post": "/notes/create_for_person" }
%input{ type: "hidden", name: "person_id", value: "42" }
%textarea{ name: "comments" }
%input{ type: "submit", value: "Save" }
:javascript
APP.openDrawer("template#tmp");
Submitting the form issues POST /notes/create_for_person, and its
response runs the same way.
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 POSTah-debounce-on-type: debounce interval in ms for either of the aboveinput[type=file][ah-upload]: presign and upload to object storageah-name: prefix for the hidden inputsah-uploadwrites back[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
How the client behaves
The listeners are delegated on document.body, not bound per element,
so a fragment swapped into the page is live the moment it lands. That
is why ah-get-on-load re-fires against freshly inserted content.
Links act on mousedown rather than click, so navigation starts on the
press. While a request is in flight the triggering link or form
disables its submit buttons, then re-enables when the response lands,
so a double click cannot fire twice. ah-confirm gates that path with
window.confirm before anything is disabled.
Two attributes act on typing. ah-submit-on-type submits the enclosing
form, so it round-trips through whatever that form already declares.
ah-post-on-type posts the form to a side endpoint without submitting
it, which is how a note autosaves while you keep editing. Each debounces
on the last keystroke, and one knob tunes both: ah-debounce-on-type,
defaulting to 200 ms.
<input name="q" ah-submit-on-type ah-debounce-on-type="300" />
A form[ah-get] serializes its string fields into the query string and
skips File inputs, so a search form round-trips as a plain GET.
For navigation that should update the address bar, asyncHTML.pushURL
pushes an entry, and a popstate listener reloads the recorded URL so
the back button behaves.
The request
Every request goes through one buildRequest function so the security
options are uniform (see the source). Two headers matter to
the server: AH-Referer carries the page that made the request, and
AH-CSRF-Token carries the token, for unsafe methods only, since GET
and HEAD never mutate.
Every request is also pinned to the origin: credentials: "same-origin"
sends cookies only to same-origin URLs, and mode: "same-origin" aborts
any cross-origin request outright.
The server knows it is talking to asyncHTML
The AH-Referer header does double duty. First, it tells the server 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
}
// ...
}
Second, it names the page to return to after a successful mutation, read 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. While a standard
HTTP 303 See Other is correct for full-page form submissions (it forces
the browser to issue a GET for the next page), it fails through fetch.
Its 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, keeping each fragment's script
in its own scope. Using dynamic <script> elements rather than new Function or eval lets the Content Security Policy omit
'unsafe-eval'.
This lets a fragment carry its own behavior. The note fragment above
opens itself in a drawer with APP.openDrawer, and its script could
go on to focus the textarea or wire up other behavior.
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: 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
must 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 input carries the
presign URL, names its field with ah-name, and constrains types with
accept:
%input{ type: "file", "ah-upload": "/uploads/presign", "ah-name": "attachment", accept: "image/png,image/jpeg" }
On change, the client skips any file whose type accept excludes, asks
the server for a presigned URL, PUTs the file straight to object
storage, then appends hidden inputs ([name], [type], [object_key])
so the eventual form submit carries only metadata. 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's same-origin floor and CSRF header are only a start; 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
}
}
Because that token guards only unsafe methods, 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/web-framework for that pattern and go/html-templates for rendering the fragments.
The token model predates Sec-Fetch-Site. Go 1.25's
http.NewCrossOriginProtection rejects cross-origin mutations from the
fetch metadata the browser already sends, which can retire the token
and its meta tag entirely. See Filippo Valsorda's
writeup.
When to reach for it
asyncHTML fits an app that renders HTML on the server and wants
interactivity without shipping a client framework. The server contract
is three headers (AH-Referer, AH-CSRF-Token, AH-Location) plus
the CSRF meta tag, and the attribute surface stays small because
behavior travels as JavaScript with the fragment that needs it. For an
app already committed to server-rendered HTML, that is most of the
value of htmx with far less to reason about.
Source
The whole library, asyncHTML.ts:
"use strict";
interface AsyncHTMLConfig {
csrfHeader: string;
refererHeader: string;
}
interface AsyncHTML {
buildRequest: (url: string, options?: RequestInit) => Request;
config: AsyncHTMLConfig;
configure: (customConfig: Partial<AsyncHTMLConfig>) => void;
confirm: (el: HTMLElement) => boolean;
createHiddenInput: (name: string, value: string) => HTMLInputElement;
disable: (element: HTMLFormElement | HTMLAnchorElement) => void;
enable: (element: HTMLFormElement | HTMLElement) => void;
fetch: (
method: string,
url: string,
headers: Headers | undefined,
body: string | FormData | null,
) => Promise<Response | undefined>;
fetchAndRun: (
method: string,
url: string,
body?: string | FormData | null,
) => Promise<void>;
listen: (customConfig?: Partial<AsyncHTMLConfig>) => void;
pushURL: (url: string) => void;
toggle: (selector: string) => void;
triggerGetOnLoad: (root?: ParentNode) => void;
}
declare global {
interface Window {
asyncHTML: AsyncHTML;
}
}
export const asyncHTML: AsyncHTML = {
buildRequest: (url: string, options?: RequestInit): Request => {
const headers = new Headers(options?.headers || undefined);
// only add CSRF header for non-idempotent HTTP methods
const method = options?.method?.toUpperCase() || "GET";
const nonIdempotentMethods = ["POST", "PUT", "PATCH", "DELETE"];
if (nonIdempotentMethods.includes(method)) {
const csrfToken =
document.querySelector<HTMLMetaElement>("[name='csrf-token']")
?.content ?? "";
headers.append(asyncHTML.config.csrfHeader, csrfToken);
}
// always add referer header
headers.append(asyncHTML.config.refererHeader, window.location.href);
const secureOptions: RequestInit = {
...options,
headers: headers,
credentials: "same-origin",
mode: "same-origin",
};
return new Request(url, secureOptions);
},
config: {
csrfHeader: "AH-CSRF-Token",
refererHeader: "AH-Referer",
},
configure: (customConfig: Partial<typeof asyncHTML.config>) => {
asyncHTML.config = { ...asyncHTML.config, ...customConfig };
},
confirm: (el: HTMLElement): boolean => {
const txt = el.getAttribute("ah-confirm");
if (txt === null) {
return true;
}
return window.confirm(txt);
},
createHiddenInput: (name: string, value: string) => {
const hidden = document.createElement("input");
hidden.type = "hidden";
hidden.name = name;
hidden.value = value;
return hidden;
},
disable: (element: HTMLFormElement | HTMLAnchorElement): void => {
if (element.tagName === "FORM") {
// It's a form, disable all relevant children
const elements = element.querySelectorAll("button, input[type='submit']");
elements.forEach((el: Element) => {
(el as HTMLElement).setAttribute("disabled", "true");
(el as HTMLElement).classList.add("disabled");
});
} else {
// It's not a form, disable the element itself
element.setAttribute("disabled", "true");
element.classList.add("disabled");
}
},
enable: (element: HTMLFormElement | HTMLElement): void => {
if (element.tagName === "FORM") {
// It's a form, enable all relevant children
const elements = element.querySelectorAll("button, input[type='submit']");
elements.forEach((el: Element) => {
(el as HTMLElement).removeAttribute("disabled");
(el as HTMLElement).classList.remove("disabled");
});
} else {
// It's not a form, enable the element itself
element.removeAttribute("disabled");
element.classList.remove("disabled");
}
},
fetch: async (
method: string,
url: string,
headers: Headers | undefined,
body: string | FormData | null = null,
) => {
// build request
const req = asyncHTML.buildRequest(url, {
method,
headers,
body,
});
// fetch
let resp;
try {
resp = await fetch(req);
} catch (error) {
return;
}
// handle redirect (200 with AH-Location header)
const location = resp.headers.get("AH-Location");
if (location) {
window.location.href = location;
return;
}
// handle error
if (!resp.ok) {
return;
}
return resp;
},
fetchAndRun: async (
method: string,
url: string,
body: string | FormData | null = null,
) => {
const headers = new Headers({
Accept: "text/html",
});
const resp = await asyncHTML.fetch(method, url, headers, body);
if (!resp) {
return;
}
const html = await resp.text();
if (html) {
// set up temp container
const tmp = document.createElement("div");
document.body.appendChild(tmp);
// inject HTML
tmp.innerHTML = html;
// Run inline scripts. Setting innerHTML does not execute embedded
// <script> tags (HTML5 spec), so we re-emit each one as a fresh
// <script> element. Wrapping in an IIFE preserves the function-
// scope isolation a previous `new Function(...)` implementation
// provided, and dropping `new Function` lets the CSP omit
// 'unsafe-eval' .
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);
});
// remove temp container
document.body.removeChild(tmp);
}
},
// Fire [ah-get-on-load] requests for any matching elements under `root`.
// Called both on initial page load (against document.body) and after any
// ajax DOM replacement so freshly-inserted content's load hooks run too.
triggerGetOnLoad: (root: ParentNode = document.body): void => {
root.querySelectorAll("[ah-get-on-load]").forEach((element) => {
const url = element.getAttribute("ah-get-on-load");
if (!url) {
return;
}
asyncHTML.fetchAndRun("GET", url);
});
},
listen: () => {
// a[ah-get]
document.body.addEventListener("mousedown", async (event) => {
const a = (event.target as HTMLElement).closest("a[ah-get]");
if (!a || !(a instanceof HTMLAnchorElement)) {
return;
}
event.preventDefault();
const url = a.getAttribute("ah-get");
if (!url) {
return;
}
asyncHTML.disable(a);
if (!asyncHTML.confirm(a)) {
asyncHTML.enable(a);
return;
}
await asyncHTML.fetchAndRun("GET", url);
asyncHTML.enable(a);
});
// a[ah-post]
document.body.addEventListener("mousedown", async (event) => {
const a = (event.target as HTMLElement).closest("a[ah-post]");
if (!a || !(a instanceof HTMLAnchorElement)) {
return;
}
event.preventDefault();
const url = a.getAttribute("ah-post");
if (!url) {
return;
}
asyncHTML.disable(a);
if (!asyncHTML.confirm(a)) {
asyncHTML.enable(a);
return;
}
await asyncHTML.fetchAndRun("POST", url);
asyncHTML.enable(a);
});
// form[ah-post]
document.body.addEventListener("submit", async (event) => {
const form = (event.target as HTMLElement).closest("form[ah-post]");
if (!form || !(form instanceof HTMLFormElement)) {
return;
}
event.preventDefault();
const url = form.getAttribute("ah-post");
if (!url) {
return;
}
asyncHTML.disable(form);
if (!asyncHTML.confirm(form)) {
asyncHTML.enable(form);
return;
}
const body = new FormData(form);
await asyncHTML.fetchAndRun("POST", url, body);
asyncHTML.enable(form);
});
// form[ah-get]
document.body.addEventListener("submit", async (event) => {
const form = (event.target as HTMLElement).closest("form[ah-get]");
if (!form || !(form instanceof HTMLFormElement)) {
return;
}
event.preventDefault();
const baseUrl = form.getAttribute("ah-get");
if (!baseUrl) {
return;
}
asyncHTML.disable(form);
if (!asyncHTML.confirm(form)) {
asyncHTML.enable(form);
return;
}
// Build query string from string values only; skip File entries,
// which URLSearchParams would otherwise stringify to "[object File]".
const searchParams = new URLSearchParams();
for (const [key, value] of new FormData(form).entries()) {
if (typeof value === "string") {
searchParams.append(key, value);
}
}
const params = searchParams.toString();
const url = baseUrl + (baseUrl.includes("?") ? "&" : "?") + params;
try {
await asyncHTML.fetchAndRun("GET", url);
} finally {
asyncHTML.enable(form);
}
});
// input[ah-submit-on-type] (debounced submit)
const debounceTimers = new WeakMap<HTMLInputElement, number>();
document.body.addEventListener("input", (event) => {
const input = (event.target as HTMLElement).closest(
"input[ah-submit-on-type]",
) as HTMLInputElement | null;
if (!input) {
return;
}
// Parse debounce interval; default to 200 ms
const delay =
parseInt(input.getAttribute("ah-debounce-on-type") || "", 10) || 200;
// Reset any existing timer for this input
const prev = debounceTimers.get(input);
if (prev !== undefined) {
clearTimeout(prev);
}
const timer = window.setTimeout(() => {
debounceTimers.delete(input);
const form = input.closest("form") as HTMLFormElement;
if (!form) {
return;
}
// Trigger a normal submit so the other listeners
// (form[ah-post] / form[ah-get]) can do their work.
if ("requestSubmit" in form) {
(form as HTMLFormElement).requestSubmit();
}
}, delay);
debounceTimers.set(input, timer);
});
// input[ah-post-on-type], textarea[ah-post-on-type] (debounced POST)
const postOnTypeTimers = new WeakMap<HTMLElement, number>();
document.body.addEventListener("input", (event) => {
const input = (event.target as HTMLElement).closest<HTMLFormElement>(
"input[ah-post-on-type], textarea[ah-post-on-type]",
);
if (!input) {
return;
}
const url = input.getAttribute("ah-post-on-type");
if (!url) {
return;
}
const delay =
parseInt(input.getAttribute("ah-debounce-on-type") || "", 10) || 200;
const prev = postOnTypeTimers.get(input);
if (prev !== undefined) {
clearTimeout(prev);
}
const timer = window.setTimeout(async () => {
postOnTypeTimers.delete(input);
const form = input.closest("form") as HTMLFormElement;
if (!form) {
return;
}
const body = new FormData(form);
await asyncHTML.fetchAndRun("POST", url, body);
}, delay);
postOnTypeTimers.set(input, timer);
});
// input[type="file"][ah-upload]
document.body.addEventListener("change", async (event) => {
const input = (event.target as HTMLElement).closest(
"input[type='file'][ah-upload]",
);
if (
!input
|| !(input instanceof HTMLInputElement)
|| !input.files
|| input.files.length === 0
) {
return;
}
const form = input.closest("form");
if (!form) {
return;
}
const presignedUrl = input.getAttribute("ah-upload");
if (!presignedUrl) {
return;
}
const name = input.getAttribute("ah-name");
if (!name) {
return;
}
event.preventDefault();
asyncHTML.disable(form);
for (const file of input.files) {
const acceptedFileTypes = input.accept
.split(",")
.map((type) => type.trim());
if (!acceptedFileTypes.includes(file.type)) {
return;
}
const headers = new Headers({
Accept: "application/json",
"Content-Type": "application/json",
});
const body = JSON.stringify({
filename: file.name,
filetype: file.type,
});
const resp = await asyncHTML.fetch("POST", presignedUrl, headers, body);
if (!resp) {
return;
}
const { url, key } = await resp.json();
// Upload the file to S3 using the presigned URL
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),
);
}
asyncHTML.enable(form);
});
// [ah-toggle]
document.body.addEventListener("click", (event) => {
const el = (event.target as HTMLElement).closest("[ah-toggle]");
if (!el) {
return;
}
event.preventDefault();
const selector = el.getAttribute("ah-toggle");
if (!selector) {
return;
}
asyncHTML.toggle(selector);
});
// pop URL off the browser history stack so the back button works
// after asyncHTML.pushURL is used
window.addEventListener("popstate", (event) => {
if (event.state && event.state.ahURL) {
window.location.href = event.state.ahURL;
}
});
},
pushURL: (url: string) => {
const currentStateObject = { ahURL: location.href };
history.replaceState(currentStateObject, "", location.href);
const nextStateObject = { ahURL: url };
history.pushState(nextStateObject, "", url);
},
toggle: (selector: string) => {
if (!selector) {
return;
}
const target = document.querySelector<HTMLElement>(selector);
if (!target) {
return;
}
target.classList.toggle("hidden");
},
};
window.asyncHTML = asyncHTML;