web / ajax

ajax is a TypeScript library of about 500 lines for server-rendered web applications. HTML elements make asynchronous HTTP requests. The server responds with HTML fragments and scripts. The library runs them in the page.

The attribute set is small. The server holds state and navigation. This article documents the contract with a Go and hml backend.

Attributes

Elements opt in with ajax- attributes. A link that loads a form:

%a{ "ajax-get": "/notes/new_for_person?person_id=42" }
  + Note

A click sends GET /notes/new_for_person. The response contains markup and <script> tags to place it. The library injects the markup and runs the scripts. A <template> keeps the markup hidden until a script clones it, for example into a drawer:

%template#tmp
  %form{ "ajax-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");

Submit sends POST /notes/create_for_person. The response runs the same way.

The full vocabulary:

Client behavior

Event listeners attach to document.body, so swapped fragments work without rebinding. Buttons disable while a request runs.

The four triggers (a and form, GET and POST) share one path, submitTrigger. It reads the attribute, disables the element, asks for confirmation, sends the request, and enables the element again.

Links act on click, not mousedown. mousedown fires on the right button too, so a right-click on an ajax-post link sent the POST before the context menu opened, and the Enter key on a focused link sent nothing. A click is the one event a right button, a drag away, and the keyboard agree on. A modified click (a second button, or a held Meta, Ctrl, Shift, or Alt key) goes to the browser, so a link with an href still opens in a new tab.

form[ajax-submit-on-change] submits on change, without a debounce, because a click on a checkbox is a finished input where a keystroke is not. It fits a form whose controls are the whole interaction, such as a row of checkboxes that narrows a chart below it. A Submit button there would be a second click for a choice already made.

ajax-submit-on-type submits the enclosing form on input. ajax-post-on-type posts to another endpoint for autosave. Both debounce by ajax-debounce-on-type (default 200 ms).

<input name="q" ajax-submit-on-type ajax-debounce-on-type="300" />

form[ajax-get] serializes inputs into a query string and skips file inputs. ajax.pushURL manages history entries.

Superseded requests

A search box submits on every keystroke. The browser runs whatever HTML and script come back, so a slow early response can replace the table a later response already drew. fetchAndRun takes a key. A form keys a request on itself, so a later GET aborts the one in flight with an AbortController:

if (key !== undefined && method.toUpperCase() === "GET") {
  abortControllers.get(key)?.abort();
  controller = new AbortController();
  abortControllers.set(key, controller);
}

A body can finish arriving after a later request aborts this one, so the client reads the signal again before it injects the fragment. A stale fragment never runs a script that writes the address bar.

A POST is never aborted. It can have committed already, and the caller cannot tell whether it did.

fetchAndRun resolves false when a later request aborted this one. A superseded request leaves the form disabled. The request that aborted it enables the form when it finishes.

The request

buildRequest builds every request with same-origin credentials and mode. Ajax-Referer carries the page URL.

The Ajax-Referer header

The server uses Ajax-Referer for two things. First, to detect an ajax request and choose a fragment or a full page:

// IsAjax reports whether the request is an AJAX request.
func IsAjax(r *http.Request) bool {
	return r.Header.Get("Ajax-Referer") != ""
}

Handlers that serve only fragments reject all other requests:

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, as the redirect target after a mutation:

h.Redirect(w, r, r.Header.Get("Ajax-Referer"))

Redirects

A 303 redirect in fetch causes a second GET or a CORS error. ajax returns status 200 with an Ajax-Location header. The client navigates:

const location = resp.headers.get("Ajax-Location");
if (location) {
  window.location.href = location;
  return;
}

The server helper checks the target origin, then branches on IsAjax:

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("Ajax-Location", webutil.AbsoluteURL(r, location))
	w.WriteHeader(200)
}

The login middleware uses the same helper. An ajax request from an expired session gets Ajax-Location: /login, and the browser loads the full page.

Fragment scripts

A <script> inserted with innerHTML does not run. ajax creates a new <script> element for each one, wrapped in an IIFE. This avoids 'unsafe-eval' in the Content Security Policy. Server templates must escape dynamic values in scripts to prevent XSS.

Multipart forms

ajax-post forms send FormData, which fetch encodes as multipart/form-data. Go's r.ParseForm ignores a multipart body. The parser checks the 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

ajax-upload sends files to object storage, not the application server. The input names the presign URL, the field prefix (ajax-name), and the accepted types:

%input{ type: "file", "ajax-upload": "/uploads/presign", "ajax-name": "attachment", accept: "image/png,image/jpeg" }

On change, the client checks the file against accept, requests a presigned URL, and uploads with PUT. It then adds hidden inputs [name], [type], and [object_key] to the form. The server reads them with a helper:

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

There is no CSRF token. Middleware rejects an unsafe cross-origin browser request on Sec-Fetch-Site, and falls back to Origin against Host. Go 1.25 ships this as http.NewCrossOriginProtection:

func CrossOrigin(next http.Handler) http.Handler {
	protection := http.NewCrossOriginProtection()
	for _, path := range signatureVerifiedPaths {
		protection.AddInsecureBypassPattern(path)
	}
	return protection.Handler(next)
}

The check reads the request, so it stores nothing, and there is no token for an overlapping request to drop. It allows a request that carries neither header, because such a request is not a browser and so not a CSRF vector. The bypass list names webhook endpoints that verify a signature on the body. See Filippo Valsorda's writeup for the reasoning behind the Go API.

The check guards only unsafe methods, so an ajax-get handler must not change state. I route every mutation through ajax-post.

Handlers allowlist parameters and reject unexpected input with status 400. See go/web-framework for parameter validation and go/html-templates for template rendering.

Fit

ajax fits a server-rendered application that needs interactivity without a frontend framework. The server contract is two headers, Ajax-Referer and Ajax-Location. Scripts travel with the fragments, so the attribute set stays small. It covers most of what I used htmx for, with less code.

Source

The whole library, ajax.ts:

"use strict";

interface Ajax {
  buildRequest: (url: string, options?: RequestInit) => Request;
  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,
    signal?: AbortSignal,
  ) => Promise<Response | undefined>;
  // fetchAndRun resolves false when a later request with the same key
  // aborted this one, and true otherwise.
  fetchAndRun: (
    method: string,
    url: string,
    body?: string | FormData | null,
    key?: object,
  ) => Promise<boolean>;
  listen: () => void;
  pushURL: (url: string) => void;
  toggle: (selector: string) => void;
  triggerGetOnLoad: (root?: ParentNode) => void;
}

declare global {
  interface Window {
    ajax: Ajax;
  }
}

// refererHeader carries the originating page so the server can reload
// it on an ajax redirect. It has nothing to do with cross-origin
// protection, which the browser handles on its own via Sec-Fetch-Site.
const refererHeader = "Ajax-Referer";

// abortControllers holds the request in flight for each key, so a new
// request aborts the one it supersedes. A caller that wants concurrent
// requests passes no key.
const abortControllers = new WeakMap<object, AbortController>();

// submitTrigger runs one ajax-get or ajax-post element: it disables
// the element, asks for a confirmation, sends the request, and enables
// the element again. A form sends its fields, as a query string on a
// GET and as a body on a POST, and keys the request on itself so a
// later submit aborts this one. A superseded request leaves the form
// disabled: the request that aborted it enables the form when it
// finishes.
const submitTrigger = async (
  el: HTMLAnchorElement | HTMLFormElement,
  method: "GET" | "POST",
  attr: string,
): Promise<void> => {
  const target = el.getAttribute(attr);
  if (!target) {
    return;
  }

  ajax.disable(el);

  if (!ajax.confirm(el)) {
    ajax.enable(el);
    return;
  }

  let url = target;
  let body: FormData | null = null;
  let key: object | undefined;

  if (el instanceof HTMLFormElement) {
    key = el;
    if (method === "POST") {
      body = new FormData(el);
    } else {
      // Build the query string from string values only; skip File
      // entries, which URLSearchParams stringifies to "[object File]".
      const params = new URLSearchParams();
      for (const [name, value] of new FormData(el).entries()) {
        if (typeof value === "string") {
          params.append(name, value);
        }
      }
      url = target + (target.includes("?") ? "&" : "?") + params.toString();
    }
  }

  if (await ajax.fetchAndRun(method, url, body, key)) {
    ajax.enable(el);
  }
};

export const ajax: Ajax = {
  buildRequest: (url: string, options?: RequestInit): Request => {
    const headers = new Headers(options?.headers || undefined);

    headers.append(refererHeader, window.location.href);

    const secureOptions: RequestInit = {
      ...options,
      headers: headers,
      credentials: "same-origin",
      mode: "same-origin",
    };

    return new Request(url, secureOptions);
  },

  confirm: (el: HTMLElement): boolean => {
    const txt = el.getAttribute("ajax-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,
    signal?: AbortSignal,
  ) => {
    // build request
    const req = ajax.buildRequest(url, {
      method,
      headers,
      body,
      signal,
    });

    // fetch
    let resp;
    try {
      resp = await fetch(req);
    } catch (error) {
      return;
    }

    // handle redirect (200 with Ajax-Location header)
    const location = resp.headers.get("Ajax-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,
    key?: object,
  ) => {
    // Cancel a GET only. An aborted POST can have committed already,
    // and the caller cannot tell whether it did.
    let controller: AbortController | undefined;
    if (key !== undefined && method.toUpperCase() === "GET") {
      abortControllers.get(key)?.abort();
      controller = new AbortController();
      abortControllers.set(key, controller);
    }
    const superseded = () => controller?.signal.aborted === true;

    try {
      const headers = new Headers({
        Accept: "text/html",
      });

      const resp = await ajax.fetch(
        method,
        url,
        headers,
        body,
        controller?.signal,
      );

      // A body can finish arriving after a later request aborts this
      // one, so read the signal again before the injection runs a
      // script that writes the address bar.
      const html = resp ? await resp.text().catch(() => "") : "";
      if (html && !superseded()) {
        // 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);
      }

      return !superseded();
    } finally {
      if (
        key !== undefined && controller
        && abortControllers.get(key) === controller
      ) {
        abortControllers.delete(key);
      }
    }
  },

  // Fire [ajax-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("[ajax-get-on-load]").forEach((element) => {
      const url = element.getAttribute("ajax-get-on-load");
      if (!url) {
        return;
      }

      ajax.fetchAndRun("GET", url);
    });
  },

  listen: () => {
    // a[ajax-get], a[ajax-post], form[ajax-get], form[ajax-post]
    //
    // A link acts on click, not on mousedown: a click is the one event
    // a right button, a drag away, and the Enter key all agree on.
    for (const method of ["GET", "POST"] as const) {
      const attr = method === "GET" ? "ajax-get" : "ajax-post";

      document.body.addEventListener("click", (event) => {
        const a = (event.target as HTMLElement).closest(`a[${attr}]`);
        if (!a || !(a instanceof HTMLAnchorElement)) {
          return;
        }
        // Leave a modified click to the browser, so a link with an
        // href still opens in a new tab or window.
        if (
          event.button !== 0 || event.metaKey || event.ctrlKey
          || event.shiftKey || event.altKey
        ) {
          return;
        }
        event.preventDefault();
        submitTrigger(a, method, attr);
      });

      document.body.addEventListener("submit", (event) => {
        const form = (event.target as HTMLElement).closest(`form[${attr}]`);
        if (!form || !(form instanceof HTMLFormElement)) {
          return;
        }
        event.preventDefault();
        submitTrigger(form, method, attr);
      });
    }

    // form[ajax-submit-on-change] (submit as soon as a control settles)
    //
    // For a form whose controls are the whole interaction: a row of
    // checkboxes narrowing what is drawn below it, where a Submit button
    // would be a second click for a choice already made. Undebounced,
    // because a click is a finished input where a keystroke is not.
    document.body.addEventListener("change", (event) => {
      const form = (event.target as HTMLElement).closest(
        "form[ajax-submit-on-change]",
      );
      if (!form || !(form instanceof HTMLFormElement)) {
        return;
      }
      form.requestSubmit();
    });

    // input[ajax-submit-on-type] (debounced submit)
    const debounceTimers = new WeakMap<HTMLInputElement, number>();

    document.body.addEventListener("input", (event) => {
      const input = (event.target as HTMLElement).closest(
        "input[ajax-submit-on-type]",
      ) as HTMLInputElement | null;
      if (!input) {
        return;
      }

      // Parse debounce interval; default to 200 ms
      const delay =
        parseInt(input.getAttribute("ajax-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[ajax-post] / form[ajax-get]) can do their work.
        if ("requestSubmit" in form) {
          (form as HTMLFormElement).requestSubmit();
        }
      }, delay);

      debounceTimers.set(input, timer);
    });

    // input[ajax-post-on-type], textarea[ajax-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[ajax-post-on-type], textarea[ajax-post-on-type]",
      );
      if (!input) {
        return;
      }

      const url = input.getAttribute("ajax-post-on-type");
      if (!url) {
        return;
      }

      const delay =
        parseInt(input.getAttribute("ajax-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 ajax.fetchAndRun("POST", url, body);
      }, delay);

      postOnTypeTimers.set(input, timer);
    });

    // input[type="file"][ajax-upload]
    document.body.addEventListener("change", async (event) => {
      const input = (event.target as HTMLElement).closest(
        "input[type=\"file\"][ajax-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("ajax-upload");
      if (!presignedUrl) {
        return;
      }

      const name = input.getAttribute("ajax-name");
      if (!name) {
        return;
      }

      event.preventDefault();

      ajax.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 ajax.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(
          ajax.createHiddenInput(`${name}[name]`, file.name),
        );
        form.appendChild(
          ajax.createHiddenInput(`${name}[type]`, file.type),
        );
        form.appendChild(
          ajax.createHiddenInput(`${name}[object_key]`, key),
        );
      }

      ajax.enable(form);
    });

    // [ajax-toggle]
    document.body.addEventListener("click", (event) => {
      const el = (event.target as HTMLElement).closest("[ajax-toggle]");
      if (!el) {
        return;
      }
      event.preventDefault();

      const selector = el.getAttribute("ajax-toggle");
      if (!selector) {
        return;
      }

      ajax.toggle(selector);
    });

    // pop URL off the browser history stack so the back button works
    // after ajax.pushURL is used
    window.addEventListener("popstate", (event) => {
      if (event.state && event.state.ajaxURL) {
        window.location.href = event.state.ajaxURL;
      }
    });
  },

  pushURL: (url: string) => {
    const currentStateObject = { ajaxURL: location.href };
    history.replaceState(currentStateObject, "", location.href);

    const nextStateObject = { ajaxURL: 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.ajax = ajax;

← All articles