a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
1/**
2 * HTTP module for declarative backend integration
3 *
4 * Provides HTTP request/response handling with DOM swapping capabilities for server-rendered HTML fragments and JSON responses.
5 */
6
7import type { Nullable, Optional } from "$types/helpers";
8import type {
9 BindingContext,
10 HttpMethod,
11 HttpResponse,
12 ParsedHttpConfig,
13 PluginContext,
14 RequestConfig,
15 RetryConfig,
16 Scope,
17 SwapStrategy,
18} from "$types/volt";
19import { registerDirective } from "./binder";
20import { report } from "./error";
21import { evaluate } from "./evaluator";
22import { sleep } from "./shared";
23
24type IndicatorStrategy = "display" | "class";
25
26type CapturedState = {
27 focusPath: number[] | null;
28 scrollPositions: Map<number[], { top: number; left: number }>;
29 inputValues: Map<number[], string | boolean>;
30};
31
32const indicatorStrategies = new WeakMap<Element, IndicatorStrategy>();
33
34/**
35 * Make an HTTP request and return the parsed response
36 *
37 * Handles both HTML and JSON responses based on Content-Type header.
38 * Throws an error for network failures or status >= 400
39 *
40 * @param conf - Request configuration
41 * @returns Promise resolving to HttpResponse
42 */
43export async function request(conf: RequestConfig): Promise<HttpResponse> {
44 const { method, url, headers = {}, body } = conf;
45
46 try {
47 const response = await fetch(url, { method, headers: { ...headers }, body });
48
49 const contentType = response.headers.get("content-type") || "";
50 const isHTML = contentType.includes("text/html");
51 const isJSON = contentType.includes("application/json");
52
53 let html: Optional<string>;
54 let json: Optional<unknown>;
55
56 if (isHTML) {
57 html = await response.text();
58 } else if (isJSON) {
59 json = await response.json();
60 } else {
61 html = await response.text();
62 }
63
64 return {
65 status: response.status,
66 statusText: response.statusText,
67 headers: response.headers,
68 html,
69 json,
70 ok: response.ok,
71 };
72 } catch (error) {
73 throw new Error(`HTTP request failed: ${error instanceof Error ? error.message : String(error)}`);
74 }
75}
76
77/**
78 * Capture state that should be preserved during DOM swap
79 */
80function captureState(root: Element): CapturedState {
81 const state: CapturedState = { focusPath: null, scrollPositions: new Map(), inputValues: new Map() };
82
83 const activeEl = document.activeElement;
84 if (activeEl && root.contains(activeEl)) {
85 state.focusPath = getElementPath(activeEl, root);
86 }
87
88 const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
89 let currentNode: Nullable<Node> = walker.currentNode;
90
91 while (currentNode) {
92 const el = currentNode as Element;
93 const path = getElementPath(el, root);
94
95 if (el.scrollTop > 0 || el.scrollLeft > 0) {
96 state.scrollPositions.set(path, { top: el.scrollTop, left: el.scrollLeft });
97 }
98
99 if (el instanceof HTMLInputElement) {
100 if (el.type === "checkbox" || el.type === "radio") {
101 state.inputValues.set(path, el.checked);
102 } else {
103 state.inputValues.set(path, el.value);
104 }
105 } else if (el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) {
106 state.inputValues.set(path, el.value);
107 }
108
109 currentNode = walker.nextNode();
110 }
111
112 return state;
113}
114
115/**
116 * Get the path to an element from a root element as an array of child indices representing the path from root to element.
117 */
118function getElementPath(el: Element, root: Element): number[] {
119 const path: number[] = [];
120 let current: Nullable<Element> = el;
121
122 while (current && current !== root) {
123 const parent: Nullable<Element> = current.parentElement;
124 if (!parent) break;
125
126 const index = [...parent.children].indexOf(current);
127 if (index === -1) break;
128
129 path.unshift(index);
130 current = parent;
131 }
132
133 return path;
134}
135
136function getElementByPath(path: number[], root: Element): Nullable<Element> {
137 let current: Element = root;
138
139 for (const index of path) {
140 const children = [...current.children];
141 if (index >= children.length) return null;
142 current = children[index];
143 }
144
145 return current;
146}
147
148function restoreState(root: Element, state: CapturedState): void {
149 if (state.focusPath) {
150 const element = getElementByPath(state.focusPath, root);
151 if (element instanceof HTMLElement) {
152 element.focus();
153 }
154 }
155
156 for (const [path, position] of state.scrollPositions) {
157 const element = getElementByPath(path, root);
158 if (element) {
159 element.scrollTop = position.top;
160 element.scrollLeft = position.left;
161 }
162 }
163
164 for (const [path, value] of state.inputValues) {
165 const element = getElementByPath(path, root);
166 if (element instanceof HTMLInputElement) {
167 if (element.type === "checkbox" || element.type === "radio") {
168 element.checked = value as boolean;
169 } else {
170 element.value = value as string;
171 }
172 } else if (element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement) {
173 element.value = value as string;
174 }
175 }
176}
177
178/**
179 * Apply a swap strategy to update the DOM with new content
180 *
181 * Preserves focus, scroll position, and input state when using innerHTML or outerHTML strategies.
182 *
183 * @param target - Target element to update
184 * @param content - HTML content to insert
185 * @param strategy - Swap strategy to use
186 */
187export function swap(target: Element, content: string, strategy: SwapStrategy = "innerHTML"): void {
188 const shouldPreserveState = strategy === "innerHTML" || strategy === "outerHTML";
189 const state = shouldPreserveState ? captureState(target) : null;
190
191 switch (strategy) {
192 case "innerHTML": {
193 target.innerHTML = content;
194 if (state) restoreState(target, state);
195 break;
196 }
197 case "outerHTML": {
198 const parent = target.parentElement;
199 const nextSibling = target.nextElementSibling;
200 target.outerHTML = content;
201
202 if (state && parent) {
203 const newElement = nextSibling ? nextSibling.previousElementSibling : parent.lastElementChild;
204 if (newElement) restoreState(newElement, state);
205 }
206 break;
207 }
208 case "beforebegin": {
209 target.insertAdjacentHTML("beforebegin", content);
210 break;
211 }
212 case "afterbegin": {
213 target.insertAdjacentHTML("afterbegin", content);
214 break;
215 }
216 case "beforeend": {
217 target.insertAdjacentHTML("beforeend", content);
218 break;
219 }
220 case "afterend": {
221 target.insertAdjacentHTML("afterend", content);
222 break;
223 }
224 case "delete": {
225 target.remove();
226 break;
227 }
228 case "none": {
229 break;
230 }
231 default: {
232 report(new Error(`Unknown swap strategy: ${strategy as string}`), {
233 source: "http",
234 level: "warn",
235 element: target as HTMLElement,
236 });
237 }
238 }
239}
240
241/**
242 * Serialize a form element to FormData
243 *
244 * @param form - Form element to serialize
245 * @returns FormData object containing form fields
246 */
247export function serializeForm(form: HTMLFormElement): FormData {
248 return new FormData(form);
249}
250
251/**
252 * Serialize a form element to JSON
253 *
254 * @param form - Form element to serialize
255 * @returns JSON object containing form fields
256 */
257export function serializeFormToJSON(form: HTMLFormElement): Record<string, unknown> {
258 const formData = new FormData(form);
259 const object: Record<string, unknown> = {};
260
261 for (const [key, value] of formData.entries()) {
262 if (Object.hasOwn(object, key)) {
263 if (!Array.isArray(object[key])) {
264 object[key] = [object[key]];
265 }
266 (object[key] as unknown[]).push(value);
267 } else {
268 object[key] = value;
269 }
270 }
271
272 return object;
273}
274
275/**
276 * Parse HTTP configuration from element attributes
277 *
278 * Reads data-volt-trigger, data-volt-target, data-volt-swap, data-volt-headers,
279 * data-volt-retry, data-volt-retry-delay, and data-volt-indicator from the
280 * element's dataset and returns parsed configuration.
281 *
282 * @param el - Element to parse configuration from
283 * @param scope - Scope for evaluating expressions
284 * @returns Parsed HTTP configuration with defaults
285 */
286export function parseHttpConfig(el: Element, scope: Scope): ParsedHttpConfig {
287 const dataset = (el as HTMLElement).dataset;
288
289 const trigger = dataset.voltTrigger || getDefaultTrigger(el);
290
291 let target: string | Element = el;
292 if (dataset.voltTarget) {
293 const trimmed = dataset.voltTarget.trim();
294 if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
295 target = trimmed.slice(1, -1);
296 } else {
297 const targetValue = evaluate(dataset.voltTarget, scope);
298 if (typeof targetValue === "string") {
299 target = targetValue;
300 } else if (targetValue instanceof Element) {
301 target = targetValue;
302 }
303 }
304 }
305
306 const swap = (dataset.voltSwap as SwapStrategy) || "innerHTML";
307
308 let headers: Record<string, string> = {};
309 if (dataset.voltHeaders) {
310 try {
311 const headersValue = evaluate(dataset.voltHeaders, scope);
312 if (typeof headersValue === "object" && headersValue !== null) {
313 headers = headersValue as Record<string, string>;
314 }
315 } catch (error) {
316 report(error as Error, {
317 source: "http",
318 element: el as HTMLElement,
319 directive: "data-volt-headers",
320 expression: dataset.voltHeaders,
321 });
322 }
323 }
324
325 let retry: Optional<RetryConfig>;
326 if (dataset.voltRetry) {
327 const maxAttempts = Number.parseInt(dataset.voltRetry, 10);
328 const initialDelay = dataset.voltRetryDelay ? Number.parseInt(dataset.voltRetryDelay, 10) : 1000;
329
330 if (!Number.isNaN(maxAttempts) && maxAttempts > 0) {
331 retry = { maxAttempts, initialDelay };
332 }
333 }
334
335 const indicator = dataset.voltIndicator;
336
337 return { trigger, target, swap, headers, retry, indicator };
338}
339
340function getDefaultTrigger(el: Element): string {
341 if (el instanceof HTMLFormElement) {
342 return "submit";
343 }
344 return "click";
345}
346
347/**
348 * Set loading state on an element
349 *
350 * Sets data-volt-loading="true" attribute to indicate ongoing request.
351 * Shows indicator if data-volt-indicator is set.
352 *
353 * @param el - Element to mark as loading
354 * @param indicator - Optional indicator selector
355 */
356export function setLoadingState(el: Element, indicator?: string): void {
357 (el as HTMLElement).dataset.voltLoading = "true";
358
359 if (indicator) {
360 showIndicator(indicator);
361 }
362
363 el.dispatchEvent(new CustomEvent("volt:loading", { detail: { element: el }, bubbles: true, cancelable: false }));
364}
365
366/**
367 * Set error state on an element
368 *
369 * Sets data-volt-error attribute with error message.
370 * Hides indicator if data-volt-indicator is set.
371 *
372 * @param el - Element to mark as errored
373 * @param msg - Error message
374 * @param indicator - Optional indicator selector
375 */
376export function setErrorState(el: Element, msg: string, indicator?: string): void {
377 (el as HTMLElement).dataset.voltError = msg;
378
379 if (indicator) {
380 hideIndicator(indicator);
381 }
382
383 el.dispatchEvent(
384 new CustomEvent("volt:error", { detail: { element: el, message: msg }, bubbles: true, cancelable: false }),
385 );
386}
387
388/**
389 * Clear loading and error states from an element
390 *
391 * Removes data-volt-loading, data-volt-error, and data-volt-retry-attempt attributes.
392 * Hides indicator if data-volt-indicator is set.
393 *
394 * @param el - Element to clear states from
395 * @param indicator - Optional indicator selector
396 */
397export function clearStates(el: Element, indicator?: string): void {
398 delete (el as HTMLElement).dataset.voltLoading;
399 delete (el as HTMLElement).dataset.voltError;
400 delete (el as HTMLElement).dataset.voltRetryAttempt;
401
402 if (indicator) {
403 hideIndicator(indicator);
404 }
405
406 el.dispatchEvent(new CustomEvent("volt:success", { detail: { element: el }, bubbles: true, cancelable: false }));
407}
408
409/**
410 * Detect the appropriate visibility strategy for an indicator element
411 *
412 * - If element has display: none (inline or computed), use display toggling
413 * - If element has a class containing "hidden", use class toggling
414 * - Otherwise, default to class toggling
415 */
416function detectIndicatorStrategy(el: Element): IndicatorStrategy {
417 if (indicatorStrategies.has(el)) {
418 return indicatorStrategies.get(el)!;
419 }
420
421 const htmlElement = el as HTMLElement;
422 const inlineDisplay = htmlElement.style.display;
423 const computedDisplay = globalThis.getComputedStyle(htmlElement).display;
424
425 if (inlineDisplay === "none" || computedDisplay === "none") {
426 indicatorStrategies.set(el, "display");
427 return "display";
428 }
429
430 const hasHiddenClass = [...el.classList].some((cls) => cls.toLowerCase().includes("hidden"));
431 if (hasHiddenClass) {
432 indicatorStrategies.set(el, "class");
433 return "class";
434 }
435
436 indicatorStrategies.set(el, "class");
437 return "class";
438}
439
440/**
441 * Show an indicator element using the appropriate visibility strategy
442 */
443function showIndicatorElement(el: Element): void {
444 const strategy = detectIndicatorStrategy(el);
445 const htmlElement = el as HTMLElement;
446
447 if (strategy === "display") {
448 htmlElement.style.display = "";
449 } else {
450 const hiddenClass = [...el.classList].find((cls) => cls.toLowerCase().includes("hidden")) || "hidden";
451 el.classList.remove(hiddenClass);
452 }
453}
454
455/**
456 * Hide an indicator element using the appropriate visibility strategy
457 */
458function hideIndicatorElement(el: Element): void {
459 const strategy = detectIndicatorStrategy(el);
460 const htmlElement = el as HTMLElement;
461
462 if (strategy === "display") {
463 htmlElement.style.display = "none";
464 } else {
465 const hiddenClass = [...el.classList].find((cls) => cls.toLowerCase().includes("hidden")) || "hidden";
466 el.classList.add(hiddenClass);
467 }
468}
469
470/**
471 * Show loading indicator(s) specified by selector
472 *
473 * @param selector - CSS selector for indicator element(s)
474 */
475export function showIndicator(selector: string): void {
476 const indicators = document.querySelectorAll(selector);
477 for (const indicator of indicators) {
478 showIndicatorElement(indicator);
479 }
480}
481
482/**
483 * Hide loading indicator(s) specified by selector
484 *
485 * @param selector - CSS selector for indicator element(s)
486 */
487export function hideIndicator(selector: string): void {
488 const indicators = document.querySelectorAll(selector);
489 for (const indicator of indicators) {
490 hideIndicatorElement(indicator);
491 }
492}
493
494/**
495 * Resolve target element from configuration
496 *
497 * @param targetConf - Target selector or element
498 * @param defaultEl - Default element if target is "this" or undefined
499 * @returns Resolved target element or undefined if not found
500 */
501function resolveTarget(targetConf: string | Element, defaultEl: Element): Optional<Element> {
502 if (targetConf instanceof Element) {
503 return targetConf;
504 }
505
506 if (targetConf === "this" || targetConf === "") {
507 return defaultEl;
508 }
509
510 const target = document.querySelector(targetConf);
511 if (!target) {
512 report(new Error(`Target element not found: ${targetConf}`), {
513 source: "http",
514 level: "warn",
515 element: defaultEl as HTMLElement,
516 directive: "data-volt-target",
517 });
518 return undefined;
519 }
520
521 return target;
522}
523
524function classifyError(error: unknown): "network" | "server" | "client" | "other" {
525 if (error instanceof Error && error.message.includes("HTTP")) {
526 const match = error.message.match(/HTTP (\d+):/);
527 if (match) {
528 const status = Number.parseInt(match[1], 10);
529 if (status >= 500 && status < 600) return "server";
530 if (status >= 400 && status < 500) return "client";
531 }
532 }
533
534 if (error instanceof Error && (error.message.includes("fetch") || error.message.includes("network"))) {
535 return "network";
536 }
537
538 return "other";
539}
540
541/**
542 * Determine if an error should be retried based on smart retry logic
543 *
544 * - Network errors: Always retry
545 * - 5xx server errors: Always retry
546 * - 4xx client errors: Never retry
547 * - Other errors: Never retry
548 */
549function shouldRetry(error: unknown): boolean {
550 const errorType = classifyError(error);
551 return errorType === "network" || errorType === "server";
552}
553
554/**
555 * Calculate retry delay based on error type and attempt number
556 *
557 * - Network errors: No delay (immediate retry)
558 * - Server errors: Exponential backoff (initialDelay × 2^attempt)
559 * - Other errors: No retry
560 */
561function calculateRetryDelay(error: unknown, attempt: number, initialDelay: number): number {
562 const errorType = classifyError(error);
563
564 if (errorType === "network") {
565 return 0;
566 }
567
568 if (errorType === "server") {
569 return initialDelay * 2 ** attempt;
570 }
571
572 return 0;
573}
574
575/**
576 * Perform an HTTP request with configuration from element attributes
577 *
578 * Handles the full request lifecycle: loading state, request, swap, error handling, and smart retry.
579 *
580 * @param el - Element that triggered the request
581 * @param method - HTTP method
582 * @param url - Request URL
583 * @param conf - Parsed HTTP configuration
584 * @param body - Optional request body
585 */
586async function performRequest(
587 el: Element,
588 method: HttpMethod,
589 url: string,
590 conf: ParsedHttpConfig,
591 body?: string | FormData,
592): Promise<void> {
593 const target = resolveTarget(conf.target, el);
594 if (!target) {
595 return;
596 }
597
598 setLoadingState(target, conf.indicator);
599
600 let lastError: unknown;
601 const maxAttempts = conf.retry ? conf.retry.maxAttempts + 1 : 1;
602 const initialDelay = conf.retry?.initialDelay ?? 1000;
603
604 for (let attempt = 0; attempt < maxAttempts; attempt++) {
605 try {
606 if (attempt > 0) {
607 (target as HTMLElement).dataset.voltRetryAttempt = String(attempt);
608 (target as HTMLElement).dataset.voltLoading = "retrying";
609 target.dispatchEvent(
610 new CustomEvent("volt:retry", { detail: { element: target, attempt }, bubbles: true, cancelable: false }),
611 );
612 }
613
614 const response = await request({ method, url, headers: conf.headers, body });
615
616 if (!response.ok) {
617 throw new Error(`HTTP ${response.status}: ${response.statusText}`);
618 }
619
620 clearStates(target, conf.indicator);
621
622 if (response.html !== undefined) {
623 swap(target, response.html, conf.swap);
624 } else if (response.json !== undefined) {
625 console.warn("JSON responses are not yet integrated with signal updates. HTML response expected.");
626 }
627
628 return;
629 } catch (error) {
630 lastError = error;
631
632 const isLastAttempt = attempt === maxAttempts - 1;
633 const canRetry = conf.retry && shouldRetry(error);
634
635 if (isLastAttempt || !canRetry) {
636 break;
637 }
638
639 const delay = calculateRetryDelay(error, attempt, initialDelay);
640 if (delay > 0) {
641 await sleep(delay);
642 }
643 }
644 }
645
646 const errorMessage = lastError instanceof Error ? lastError.message : String(lastError);
647 setErrorState(target, errorMessage, conf.indicator);
648 report(lastError as Error, { source: "http", element: el as HTMLElement, httpMethod: method, httpUrl: url });
649}
650
651export function bindGet(ctx: BindingContext, url: string): void {
652 bindHttpMethod(ctx, "GET", url);
653}
654
655export function bindPost(ctx: BindingContext, url: string): void {
656 bindHttpMethod(ctx, "POST", url);
657}
658
659export function bindPut(ctx: BindingContext, url: string): void {
660 bindHttpMethod(ctx, "PUT", url);
661}
662
663export function bindPatch(ctx: BindingContext, url: string): void {
664 bindHttpMethod(ctx, "PATCH", url);
665}
666
667export function bindDelete(ctx: BindingContext, url: string): void {
668 bindHttpMethod(ctx, "DELETE", url);
669}
670
671/**
672 * Generic HTTP method binding handler
673 * Attaches an event listener that triggers an HTTP request when fired & automatically serializes forms for POST/PUT/PATCH methods.
674 */
675function bindHttpMethod(ctx: BindingContext | PluginContext, method: HttpMethod, url: string): void {
676 const config = parseHttpConfig(ctx.element, ctx.scope);
677 const urlValue = evaluate(url, ctx.scope);
678 const resolvedUrl = String(urlValue);
679
680 const handler = async (event: Event) => {
681 if (config.trigger === "submit" || ctx.element instanceof HTMLFormElement) {
682 event.preventDefault();
683 }
684
685 let body: Optional<string | FormData>;
686
687 if (method !== "GET" && method !== "DELETE" && ctx.element instanceof HTMLFormElement) {
688 body = serializeForm(ctx.element);
689 }
690
691 await performRequest(ctx.element, method, resolvedUrl, config, body);
692 };
693
694 ctx.element.addEventListener(config.trigger, handler);
695
696 const cleanup = () => {
697 ctx.element.removeEventListener(config.trigger, handler);
698 };
699
700 if ("addCleanup" in ctx) {
701 ctx.addCleanup(cleanup);
702 } else {
703 ctx.cleanups.push(cleanup);
704 }
705}
706
707/**
708 * Auto-register HTTP directives when this module is imported
709 * This enables tree-shaking: if the HTTP module isn't imported, these directives won't be included in the bundle.
710 */
711registerDirective("get", bindGet);
712registerDirective("post", bindPost);
713registerDirective("put", bindPut);
714registerDirective("patch", bindPatch);
715registerDirective("delete", bindDelete);