Something went wrong. Try again.
/x/perimental project monorepo
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514/*WebSockets Extension============================This extension adds support for WebSockets to htmx. See /www/extensions/ws.md for usage instructions.*/
(function () { /** @type {import("../htmx").HtmxInternalApi} */ var api;
htmx.defineExtension("ws", { /** * init is called once, when this extension is first registered. * @param {import("../htmx").HtmxInternalApi} apiRef */ init: function (apiRef) { // Store reference to internal API api = apiRef;
// Default function for creating new EventSource objects if (!htmx.createWebSocket) { htmx.createWebSocket = createWebSocket; }
// Default setting for reconnect delay if (!htmx.config.wsReconnectDelay) { htmx.config.wsReconnectDelay = "full-jitter"; } },
/** * onEvent handles all events passed to this extension. * * @param {string} name * @param {Event} evt */ onEvent: function (name, evt) { var parent = evt.target || evt.detail.elt; switch (name) { // Try to close the socket when elements are removed case "htmx:beforeCleanupElement": var internalData = api.getInternalData(parent);
if (internalData.webSocket) { internalData.webSocket.close(); } return;
// Try to create websockets when elements are processed case "htmx:beforeProcessNode": forEach( queryAttributeOnThisOrChildren(parent, "ws-connect"), function (child) { ensureWebSocket(child); }, ); forEach( queryAttributeOnThisOrChildren(parent, "ws-send"), function (child) { ensureWebSocketSend(child); }, ); } }, });
function splitOnWhitespace(trigger) { return trigger.trim().split(/\s+/); }
function getLegacyWebsocketURL(elt) { var legacySSEValue = api.getAttributeValue(elt, "hx-ws"); if (legacySSEValue) { var values = splitOnWhitespace(legacySSEValue); for (var i = 0; i < values.length; i++) { var value = values[i].split(/:(.+)/); if (value[0] === "connect") { return value[1]; } } } }
/** * ensureWebSocket creates a new WebSocket on the designated element, using * the element's "ws-connect" attribute. * @param {HTMLElement} socketElt * @returns */ function ensureWebSocket(socketElt) { // If the element containing the WebSocket connection no longer exists, then // do not connect/reconnect the WebSocket. if (!api.bodyContains(socketElt)) { return; }
// Get the source straight from the element's value var wssSource = api.getAttributeValue(socketElt, "ws-connect");
if (wssSource == null || wssSource === "") { var legacySource = getLegacyWebsocketURL(socketElt); if (legacySource == null) { return; } else { wssSource = legacySource; } }
// Guarantee that the wssSource value is a fully qualified URL if (wssSource.indexOf("/") === 0) { var base_part = location.hostname + (location.port ? ":" + location.port : ""); if (location.protocol === "https:") { wssSource = "wss://" + base_part + wssSource; } else if (location.protocol === "http:") { wssSource = "ws://" + base_part + wssSource; } }
var socketWrapper = createWebsocketWrapper(socketElt, function () { return htmx.createWebSocket(wssSource); });
socketWrapper.addEventListener("message", function (event) { if (maybeCloseWebSocketSource(socketElt)) { return; }
var response = event.data; if ( !api.triggerEvent(socketElt, "htmx:wsBeforeMessage", { message: response, socketWrapper: socketWrapper.publicInterface, }) ) { return; }
api.withExtensions(socketElt, function (extension) { response = extension.transformResponse(response, null, socketElt); });
var settleInfo = api.makeSettleInfo(socketElt); var fragment = api.makeFragment(response);
if (fragment.children.length) { var children = Array.from(fragment.children); for (var i = 0; i < children.length; i++) { api.oobSwap( api.getAttributeValue(children[i], "hx-swap-oob") || "true", children[i], settleInfo, ); } }
api.settleImmediately(settleInfo.tasks); api.triggerEvent(socketElt, "htmx:wsAfterMessage", { message: response, socketWrapper: socketWrapper.publicInterface, }); });
// Put the WebSocket into the HTML Element's custom data. api.getInternalData(socketElt).webSocket = socketWrapper; }
/** * @typedef {Object} WebSocketWrapper * @property {WebSocket} socket * @property {Array<{message: string, sendElt: Element}>} messageQueue * @property {number} retryCount * @property {(message: string, sendElt: Element) => void} sendImmediately sendImmediately sends message regardless of websocket connection state * @property {(message: string, sendElt: Element) => void} send * @property {(event: string, handler: Function) => void} addEventListener * @property {() => void} handleQueuedMessages * @property {() => void} init * @property {() => void} close */ /** * * @param socketElt * @param socketFunc * @returns {WebSocketWrapper} */ function createWebsocketWrapper(socketElt, socketFunc) { var wrapper = { socket: null, messageQueue: [], retryCount: 0,
/** @type {Object<string, Function[]>} */ events: {},
addEventListener: function (event, handler) { if (this.socket) { this.socket.addEventListener(event, handler); }
if (!this.events[event]) { this.events[event] = []; }
this.events[event].push(handler); },
sendImmediately: function (message, sendElt) { if (!this.socket) { api.triggerErrorEvent(); } if ( !sendElt || api.triggerEvent(sendElt, "htmx:wsBeforeSend", { message, socketWrapper: this.publicInterface, }) ) { this.socket.send(message); sendElt && api.triggerEvent(sendElt, "htmx:wsAfterSend", { message, socketWrapper: this.publicInterface, }); } },
send: function (message, sendElt) { if (this.socket.readyState !== this.socket.OPEN) { this.messageQueue.push({ message, sendElt }); } else { this.sendImmediately(message, sendElt); } },
handleQueuedMessages: function () { while (this.messageQueue.length > 0) { var queuedItem = this.messageQueue[0]; if (this.socket.readyState === this.socket.OPEN) { this.sendImmediately(queuedItem.message, queuedItem.sendElt); this.messageQueue.shift(); } else { break; } } },
init: function () { if (this.socket && this.socket.readyState === this.socket.OPEN) { // Close discarded socket this.socket.close(); }
// Create a new WebSocket and event handlers /** @type {WebSocket} */ var socket = socketFunc();
// The event.type detail is added for interface conformance with the // other two lifecycle events (open and close) so a single handler method // can handle them polymorphically, if required. api.triggerEvent(socketElt, "htmx:wsConnecting", { event: { type: "connecting" }, });
this.socket = socket;
socket.onopen = function (e) { wrapper.retryCount = 0; api.triggerEvent(socketElt, "htmx:wsOpen", { event: e, socketWrapper: wrapper.publicInterface, }); wrapper.handleQueuedMessages(); };
socket.onclose = function (e) { // If socket should not be connected, stop further attempts to establish connection // If Abnormal Closure/Service Restart/Try Again Later, then set a timer to reconnect after a pause. if ( !maybeCloseWebSocketSource(socketElt) && [1006, 1012, 1013].indexOf(e.code) >= 0 ) { var delay = getWebSocketReconnectDelay(wrapper.retryCount); setTimeout(function () { wrapper.retryCount += 1; wrapper.init(); }, delay); }
// Notify client code that connection has been closed. Client code can inspect `event` field // to determine whether closure has been valid or abnormal api.triggerEvent(socketElt, "htmx:wsClose", { event: e, socketWrapper: wrapper.publicInterface, }); };
socket.onerror = function (e) { api.triggerErrorEvent(socketElt, "htmx:wsError", { error: e, socketWrapper: wrapper, }); maybeCloseWebSocketSource(socketElt); };
var events = this.events; Object.keys(events).forEach(function (k) { events[k].forEach(function (e) { socket.addEventListener(k, e); }); }); },
close: function () { this.socket.close(); }, };
wrapper.init();
wrapper.publicInterface = { send: wrapper.send.bind(wrapper), sendImmediately: wrapper.sendImmediately.bind(wrapper), queue: wrapper.messageQueue, };
return wrapper; }
/** * ensureWebSocketSend attaches trigger handles to elements with * "ws-send" attribute * @param {HTMLElement} elt */ function ensureWebSocketSend(elt) { var legacyAttribute = api.getAttributeValue(elt, "hx-ws"); if (legacyAttribute && legacyAttribute !== "send") { return; }
var webSocketParent = api.getClosestMatch(elt, hasWebSocket); processWebSocketSend(webSocketParent, elt); }
/** * hasWebSocket function checks if a node has webSocket instance attached * @param {HTMLElement} node * @returns {boolean} */ function hasWebSocket(node) { return api.getInternalData(node).webSocket != null; }
/** * processWebSocketSend adds event listeners to the <form> element so that * messages can be sent to the WebSocket server when the form is submitted. * @param {HTMLElement} socketElt * @param {HTMLElement} sendElt */ function processWebSocketSend(socketElt, sendElt) { var nodeData = api.getInternalData(sendElt); var triggerSpecs = api.getTriggerSpecs(sendElt); triggerSpecs.forEach(function (ts) { api.addTriggerHandler(sendElt, ts, nodeData, function (elt, evt) { if (maybeCloseWebSocketSource(socketElt)) { return; }
/** @type {WebSocketWrapper} */ var socketWrapper = api.getInternalData(socketElt).webSocket; var headers = api.getHeaders(sendElt, api.getTarget(sendElt)); var results = api.getInputValues(sendElt, "post"); var errors = results.errors; var rawParameters = Object.assign({}, results.values); var expressionVars = api.getExpressionVars(sendElt); var allParameters = api.mergeObjects(rawParameters, expressionVars); var filteredParameters = api.filterValues(allParameters, sendElt);
var sendConfig = { parameters: filteredParameters, unfilteredParameters: allParameters, headers, errors,
triggeringEvent: evt, messageBody: undefined, socketWrapper: socketWrapper.publicInterface, };
if (!api.triggerEvent(elt, "htmx:wsConfigSend", sendConfig)) { return; }
if (errors && errors.length > 0) { api.triggerEvent(elt, "htmx:validation:halted", errors); return; }
var body = sendConfig.messageBody; if (body === undefined) { var toSend = Object.assign({}, sendConfig.parameters); if (sendConfig.headers) { toSend.HEADERS = headers; } body = JSON.stringify(toSend); }
socketWrapper.send(body, elt);
if (evt && api.shouldCancel(evt, elt)) { evt.preventDefault(); } }); }); }
/** * getWebSocketReconnectDelay is the default easing function for WebSocket reconnects. * @param {number} retryCount // The number of retries that have already taken place * @returns {number} */ function getWebSocketReconnectDelay(retryCount) { /** @type {"full-jitter" | ((retryCount:number) => number)} */ var delay = htmx.config.wsReconnectDelay; if (typeof delay === "function") { return delay(retryCount); } if (delay === "full-jitter") { var exp = Math.min(retryCount, 6); var maxDelay = 1000 * Math.pow(2, exp); return maxDelay * Math.random(); }
logError( 'htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"', ); }
/** * maybeCloseWebSocketSource checks to the if the element that created the WebSocket * still exists in the DOM. If NOT, then the WebSocket is closed and this function * returns TRUE. If the element DOES EXIST, then no action is taken, and this function * returns FALSE. * * @param {*} elt * @returns */ function maybeCloseWebSocketSource(elt) { if (!api.bodyContains(elt)) { api.getInternalData(elt).webSocket.close(); return true; } return false; }
/** * createWebSocket is the default method for creating new WebSocket objects. * it is hoisted into htmx.createWebSocket to be overridden by the user, if needed. * * @param {string} url * @returns WebSocket */ function createWebSocket(url) { var sock = new WebSocket(url, []); sock.binaryType = htmx.config.wsBinaryType; return sock; }
/** * queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT. * * @param {HTMLElement} elt * @param {string} attributeName */ function queryAttributeOnThisOrChildren(elt, attributeName) { var result = [];
// If the parent element also contains the requested attribute, then add it to the results too. if ( api.hasAttribute(elt, attributeName) || api.hasAttribute(elt, "hx-ws") ) { result.push(elt); }
// Search all child nodes that match the requested attribute elt .querySelectorAll( "[" + attributeName + "], [data-" + attributeName + "], [data-hx-ws], [hx-ws]", ) .forEach(function (node) { result.push(node); });
return result; }
/** * @template T * @param {T[]} arr * @param {(T) => void} func */ function forEach(arr, func) { if (arr) { for (var i = 0; i < arr.length; i++) { func(arr[i]); } } }})();