htmx.org
Version:
high power tools for html
295 lines (249 loc) • 8.33 kB
JavaScript
/*
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 == undefined) {
htmx.createWebSocket = createWebSocket;
}
// Default setting for reconnect delay
if (htmx.config.wsReconnectDelay == undefined) {
htmx.config.wsReconnectDelay = "full-jitter";
}
},
/**
* onEvent handles all events passed to this extension.
*
* @param {string} name
* @param {Event} evt
*/
onEvent: function(name, evt) {
switch (name) {
// Try to remove remove an EventSource when elements are removed
case "htmx:beforeCleanupElement":
var internalData = api.getInternalData(evt.target)
if (internalData.webSocket != undefined) {
internalData.webSocket.close();
}
return;
// Try to create EventSources when elements are processed
case "htmx:afterProcessNode":
var parent = evt.target;
forEach(queryAttributeOnThisOrChildren(parent, "ws-connect"), function(child) {
ensureWebSocket(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} elt
* @param {number=} retryCount
* @returns
*/
function ensureWebSocket(elt, retryCount) {
// If the element containing the WebSocket connection no longer exists, then
// do not connect/reconnect the WebSocket.
if (!api.bodyContains(elt)) {
return;
}
// Get the source straight from the element's value
var wssSource = api.getAttributeValue(elt, "ws-connect")
if (wssSource == null || wssSource === "") {
var legacySource = getLegacyWebsocketURL(elt);
if (legacySource == null) {
return;
} else {
wssSource = legacySource;
}
}
// Default value for retryCount
if (retryCount == undefined) {
retryCount = 0;
}
// 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;
}
}
// Create a new WebSocket and event handlers
/** @type {WebSocket} */
var socket = htmx.createWebSocket(wssSource);
socket.onopen = function (e) {
retryCount = 0;
}
socket.onclose = function (e) {
// If Abnormal Closure/Service Restart/Try Again Later, then set a timer to reconnect after a pause.
if ([1006, 1012, 1013].indexOf(e.code) >= 0) {
var delay = getWebSocketReconnectDelay(retryCount);
setTimeout(function() {
ensureWebSocket(elt, retryCount+1);
}, delay);
}
};
socket.onerror = function (e) {
api.triggerErrorEvent(elt, "htmx:wsError", {error:e, socket:socket});
maybeCloseWebSocketSource(elt);
};
socket.addEventListener('message', function (event) {
if (maybeCloseWebSocketSource(elt)) {
return;
}
var response = event.data;
api.withExtensions(elt, function(extension){
response = extension.transformResponse(response, null, elt);
});
var settleInfo = api.makeSettleInfo(elt);
var fragment = api.makeFragment(response);
if (fragment.children.length) {
for (var i = 0; i < fragment.children.length; i++) {
api.oobSwap(api.getAttributeValue(fragment.children[i], "hx-swap-oob") || "true", fragment.children[i], settleInfo);
}
}
api.settleImmediately(settleInfo.tasks);
});
// Re-connect any ws-send commands as well.
forEach(queryAttributeOnThisOrChildren(elt, "ws-send"), function(child) {
var legacyAttribute = api.getAttributeValue(child, "hx-ws");
if (legacyAttribute && legacyAttribute !== 'send') {
return;
}
processWebSocketSend(elt, child);
});
// Put the WebSocket into the HTML Element's custom data.
api.getInternalData(elt).webSocket = socket;
}
/**
* 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} parent
* @param {HTMLElement} child
*/
function processWebSocketSend(parent, child) {
child.addEventListener(api.getTriggerSpecs(child)[0].trigger, function (evt) {
var webSocket = api.getInternalData(parent).webSocket;
var headers = api.getHeaders(child, parent);
var results = api.getInputValues(child, 'post');
var errors = results.errors;
var rawParameters = results.values;
var expressionVars = api.getExpressionVars(child);
var allParameters = api.mergeObjects(rawParameters, expressionVars);
var filteredParameters = api.filterValues(allParameters, child);
filteredParameters['HEADERS'] = headers;
if (errors && errors.length > 0) {
api.triggerEvent(child, 'htmx:validation:halted', errors);
return;
}
webSocket.send(JSON.stringify(filteredParameters));
if(api.shouldCancel(child)){
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){
return new WebSocket(url, []);
}
/**
* 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]);
}
}
}
})();