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:

Every attribute starts a request or guards one. An element that only shows and hides another element makes no request, so it uses an application attribute and the application script handles it. The library had such a toggle once. It looked like part of the server contract, but it was not.

Client behavior

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

One function, listen, binds every attribute above, once, when the page loads. The application scripts follow the same rule: one file per behavior, each with a listen the composition file calls, and each attribute owned by one file. I learned this from the alternative. A player that bound the document on every mount held a detached audio element after an ajax navigation, so one click on play started two episodes. A drawer that bound the document on every open closed a tooltip ten times on the tenth open. A body listener prevents both defects, because the page binds it once.

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. Only a click behaves the same for the right button, a drag away, and the keyboard. 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 complete 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" />

input[type=radio][ajax-post] posts on change and disables the whole radio group until the request settles, so a second pick cannot race the first. It finds the group by name, rather than by a selector built from the name: a name is data, and a quote in one would make the selector mean something else.

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

Building a URL

A value a person typed goes through an encoder, on both sides of the request. The client builds a query with URLSearchParams:

const params = new URLSearchParams({ url: input.value });
ajax.fetchAndRun("GET", `/docs/fetch?${params}`);

Interpolated instead, a YouTube link includes &t=, which the server reads as a second parameter, and the handler rejects the request with a 400. A # puts the rest of the URL into a fragment, which the server never sees.

The server lists the parameters it puts back, rather than passing r.URL.RequestURI() through:

func PushURL(r *http.Request, names ...string) string {
	got := r.URL.Query()
	params := url.Values{}
	for _, name := range names {
		if v := strings.TrimSpace(got.Get(name)); v != "" {
			params.Set(name, v)
		}
	}
	if len(params) == 0 {
		return r.URL.Path
	}
	return r.URL.Path + "?" + params.Encode()
}

The template writes the result into a JavaScript string literal, in the :javascript block that calls ajax.pushURL. A quote in the query ends that literal. An allowlist of parameter names also drops whatever else a visitor appended.

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 holds 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]")),
	}
}

The client disables the form for the upload and enables it again in a finally. A rejected file type and a presign that answers nothing both leave the loop early. Without the finally, the form stayed disabled and the only way on was a reload.

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 with neither header, because such a request does not come from a browser and cannot be a CSRF vector. The bypass list holds 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. Each fragment brings its own scripts, so the attribute set stays small.

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>;
  pushURL: (url: 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);
    });
  },

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

    const nextStateObject = { ajaxURL: url };
    history.pushState(nextStateObject, "", url);
  },
};

window.ajax = ajax;

// listen binds every ajax hook attribute, once, on document.body.
// app.ts calls it on DOMContentLoaded, as it calls each behavior
// file's listen(). It is not on window.ajax: a template sends a
// request, and no template rebinds the listeners.
export function listen(): void {
  // 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();
  });

  // Debounce timer map and helper
  const debounceTimers = new WeakMap<HTMLElement, number>();

  function debounced(input: HTMLElement, fn: () => void | Promise<void>): void {
    const delay =
      parseInt(input.getAttribute("ajax-debounce-on-type") || "", 10) || 200;

    const prev = debounceTimers.get(input);
    if (prev !== undefined) {
      clearTimeout(prev);
    }

    const timer = window.setTimeout(async () => {
      debounceTimers.delete(input);
      await fn();
    }, delay);

    debounceTimers.set(input, timer);
  }

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

    debounced(input, () => {
      const form = input.closest("form");
      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.requestSubmit();
      }
    });
  });

  // input[ajax-post-on-type], textarea[ajax-post-on-type] (debounced POST)
  document.body.addEventListener("input", (event) => {
    const input = (event.target as HTMLElement).closest<HTMLElement>(
      "input[ajax-post-on-type], textarea[ajax-post-on-type]",
    );
    if (!input) {
      return;
    }

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

    debounced(input, async () => {
      const form = input.closest("form");
      if (!form) {
        return;
      }

      const body = new FormData(form);
      await ajax.fetchAndRun("POST", url, body);
    });
  });

  // input[type="radio"][ajax-post] (post the pick, group disabled
  // until the request settles, so a second pick cannot race the first)
  document.body.addEventListener("change", async (event) => {
    const radio = (event.target as Element).closest<HTMLInputElement>(
      "input[type=\"radio\"][ajax-post]",
    );
    if (!radio?.checked) {
      return;
    }
    const url = radio.getAttribute("ajax-post");
    if (!url) {
      return;
    }

    // By name, rather than a selector built from it: a name is data,
    // and a quote in one would make the selector mean something else.
    const group = radio.name
      ? Array.from(document.getElementsByName(radio.name))
      : [radio];
    const disable = (on: boolean) =>
      group.forEach((el) => ((el as HTMLInputElement).disabled = on));
    disable(true);
    try {
      await ajax.fetchAndRun("POST", url);
    } finally {
      disable(false);
    }
  });

  // 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);

    // In a finally, because a rejected file type and a presign that
    // answers nothing both leave early. Without it the form stayed
    // disabled and the only way on was a reload.
    try {
      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,
          size: file.size,
        });
        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),
        );
      }
    } finally {
      ajax.enable(form);
    }
  });

  // 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;
    }
  });
}

← All articles