// SPDX-FileCopyrightText: 2025-2026 Alpin Insight Solutions GmbH & Co. KG
// SPDX-License-Identifier: AGPL-3.0-only
/**
* Insight UI WebSocket bridge for the HTMX ws extension.
*
* This layer stays transport-neutral and does not render payload-specific UI.
* HTML messages remain owned by HTMX, while non-HTML frames are surfaced as
* DOM events so host adapters can decide how to render them.
*
* @namespace InsightUI.WebSocket
* @example
* // HTML structure
*
*
*
*
* // Listen for custom events
* element.addEventListener('insight-ui:websocket-message', (e) => {
* console.log(e.detail.message, e.detail.data);
* });
*/
window.InsightUI = window.InsightUI || {};
InsightUI.WebSocket = InsightUI.WebSocket || {};
Object.assign(InsightUI.WebSocket, {
/** @type {boolean} Whether the WebSocket bridge has been initialized */
initialized: InsightUI.WebSocket.initialized || false,
/** @type {Object} Event handler references for cleanup */
handlers: InsightUI.WebSocket.handlers || {},
/**
* Initializes the WebSocket bridge by registering HTMX event listeners.
* Safe to call multiple times - will only initialize once.
*/
init: function() {
if (this.initialized || typeof htmx === 'undefined' || !document.body) {
return;
}
this.handlers = {
open: (evt) => this.handleStatusEvent(evt, 'connected'),
close: (evt) => this.handleStatusEvent(evt, 'disconnected'),
error: (evt) => this.handleStatusEvent(evt, 'error'),
connecting: (evt) => this.handleStatusEvent(evt, 'connecting'),
message: (evt) => this.handleMessageEvent(evt),
};
document.body.addEventListener('htmx:wsOpen', this.handlers.open);
document.body.addEventListener('htmx:wsClose', this.handlers.close);
document.body.addEventListener('htmx:wsError', this.handlers.error);
document.body.addEventListener('htmx:wsConnecting', this.handlers.connecting);
document.body.addEventListener('htmx:wsAfterMessage', this.handlers.message);
this.initialized = true;
},
/**
* Destroys the WebSocket bridge and removes all event listeners.
*/
destroy: function() {
if (!this.initialized || !document.body) {
return;
}
document.body.removeEventListener('htmx:wsOpen', this.handlers.open);
document.body.removeEventListener('htmx:wsClose', this.handlers.close);
document.body.removeEventListener('htmx:wsError', this.handlers.error);
document.body.removeEventListener('htmx:wsConnecting', this.handlers.connecting);
document.body.removeEventListener('htmx:wsAfterMessage', this.handlers.message);
this.handlers = {};
this.initialized = false;
},
/**
* Handles WebSocket status events (open, close, error, connecting).
*
* @param {Event} evt - The HTMX WebSocket event
* @param {string} state - The connection state: "connected", "disconnected", "error", or "connecting"
*/
handleStatusEvent: function(evt, state) {
const component = this.getComponent(evt.target);
if (!component) {
return;
}
this.updateStatus(component, state);
},
/**
* Handles incoming WebSocket messages.
* HTML messages are ignored (handled by HTMX), non-HTML messages trigger custom events.
*
* @param {Event} evt - The HTMX wsAfterMessage event
*/
handleMessageEvent: function(evt) {
const component = this.getComponent(evt.target);
const message = evt.detail?.message;
if (!component || typeof message !== 'string') {
return;
}
const trimmedMessage = message.trim();
if (!trimmedMessage || trimmedMessage.startsWith('<')) {
return;
}
const detail = { message };
const parsedData = this.parseJsonMessage(message);
if (parsedData !== null) {
detail.data = parsedData;
}
component.dispatchEvent(
new CustomEvent('insight-ui:websocket-message', {
bubbles: true,
detail,
})
);
if (parsedData !== null) {
component.dispatchEvent(
new CustomEvent('insight-ui:websocket-json-message', {
bubbles: true,
detail: {
message,
data: parsedData,
},
})
);
}
},
/**
* Finds the closest WebSocket component ancestor of an element.
*
* @param {HTMLElement} target - The element to search from
* @returns {HTMLElement|null} The WebSocket component element or null
*/
getComponent: function(target) {
if (!target || typeof target.closest !== 'function') {
return null;
}
return target.closest('[data-insight-websocket]');
},
/**
* Gets the status display element within a WebSocket component.
*
* @param {HTMLElement} component - The WebSocket component element
* @returns {HTMLElement|null} The status element or null
*/
getStatusElement: function(component) {
return component?.querySelector('[data-insight-websocket-status]') || null;
},
/**
* Updates the status display and dispatches a status event.
*
* @param {HTMLElement} component - The WebSocket component element
* @param {string} state - The connection state
*/
updateStatus: function(component, state) {
const statusElement = this.getStatusElement(component);
const stateConfig = this.statusMap[state];
if (!statusElement || !stateConfig) {
return;
}
statusElement.textContent = stateConfig.text;
statusElement.className = stateConfig.className;
component.dispatchEvent(
new CustomEvent('insight-ui:websocket-status', {
bubbles: true,
detail: { state },
})
);
},
/**
* Attempts to parse a message as JSON.
*
* @param {string} message - The message to parse
* @returns {Object|null} The parsed JSON object or null if parsing fails
*/
parseJsonMessage: function(message) {
try {
return JSON.parse(message);
} catch {
return null;
}
},
/**
* Status configuration map with display text and CSS classes for each state.
* @type {Object.}
*/
statusMap: {
connected: {
text: 'Connected',
className: 'insight-websocket__status-text insight-websocket__status-text--connected',
},
disconnected: {
text: 'Disconnected',
className: 'insight-websocket__status-text insight-websocket__status-text--disconnected',
},
error: {
text: 'Connection error',
className: 'insight-websocket__status-text insight-websocket__status-text--error',
},
connecting: {
text: 'Establishing connection...',
className: 'insight-websocket__status-text insight-websocket__status-text--connecting',
},
},
});