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:

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:

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;

← All articles