a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
at main 21 kB view raw
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);