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:
a[ajax-get],a[ajax-post]: request on clickform[ajax-get],form[ajax-post]: request on submitform[ajax-submit-on-change]: submit when a control changesinput[ajax-submit-on-type]: debounced submit of the enclosing forminput[ajax-post-on-type],textarea[ajax-post-on-type]: debounced POSTajax-debounce-on-type: debounce interval in ms for either of the aboveinput[type=file][ajax-upload]: presign and upload to object storageajax-name: prefix for the hidden inputsajax-uploadwrites back[ajax-get-on-load]: fire a GET when the element enters the DOM[ajax-toggle]: toggle a.hiddenclass on a selector[ajax-confirm]:window.confirmbefore acting
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;