Improvements in WebSocket extension

* Implement proper `hx-trigger` support
* Expose trigger handling API to extensions
* Implement safe message sending with sending queue
* Fix `ws-send` attributes connecting in new elements
* Fix OOB swapping of multiple elements in response
This commit is contained in:
Denis Palashevskii 2022-03-16 00:00:47 +04:00
parent bcd68ad437
commit 8bb0399052
2 changed files with 183 additions and 120 deletions

View File

@ -13,7 +13,7 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
/**
* init is called once, when this extension is first registered.
* @param {import("../htmx").HtmxInternalApi} apiRef
* @param {import("../htmx").HtmxInternalApi} apiRef
*/
init: function(apiRef) {
@ -33,9 +33,9 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
/**
* onEvent handles all events passed to this extension.
*
* @param {string} name
* @param {Event} evt
*
* @param {string} name
* @param {Event} evt
*/
onEvent: function(name, evt) {
@ -50,7 +50,7 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
internalData.webSocket.close();
}
return;
// Try to create EventSources when elements are processed
case "htmx:afterProcessNode":
var parent = evt.target;
@ -58,6 +58,9 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
forEach(queryAttributeOnThisOrChildren(parent, "ws-connect"), function(child) {
ensureWebSocket(child)
});
forEach(queryAttributeOnThisOrChildren(parent, "ws-send"), function (child) {
ensureWebSocketSend(child)
});
}
}
});
@ -81,14 +84,14 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
/**
* ensureWebSocket creates a new WebSocket on the designated element, using
* the element's "ws-connect" attribute.
* @param {HTMLElement} elt
* @param {number=} retryCount
* @returns
* 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
// If the element containing the WebSocket connection no longer exists, then
// do not connect/reconnect the WebSocket.
if (!api.bodyContains(elt)) {
return;
@ -125,16 +128,19 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
/** @type {WebSocket} */
var socket = htmx.createWebSocket(wssSource);
var messageQueue = [];
socket.onopen = function (e) {
retryCount = 0;
handleQueuedMessages(messageQueue, socket);
}
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) {
if ([1006, 1012, 1013].indexOf(e.code) >= 0) {
var delay = getWebSocketReconnectDelay(retryCount);
setTimeout(function() {
ensureWebSocket(elt, retryCount+1);
ensureWebSocket(elt, retryCount+1);
}, delay);
}
};
@ -158,25 +164,42 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
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);
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);
});
// 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;
api.getInternalData(elt).webSocketMessageQueue = messageQueue;
}
/**
* 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;
}
/**
@ -184,29 +207,65 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
* 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(evt, child)){
evt.preventDefault();
}
var nodeData = api.getInternalData(child);
let triggerSpecs = api.getTriggerSpecs(child);
triggerSpecs.forEach(function(ts) {
api.addTriggerHandler(child, ts, nodeData, function (evt) {
var webSocket = api.getInternalData(parent).webSocket;
var messageQueue = api.getInternalData(parent).webSocketMessageQueue;
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;
}
webSocketSend(webSocket, JSON.stringify(filteredParameters), messageQueue);
if(api.shouldCancel(evt, child)){
evt.preventDefault();
}
});
});
}
/**
* webSocketSend provides a safe way to send messages through a WebSocket.
* It checks that the socket is in OPEN state and, otherwise, awaits for it.
* @param {WebSocket} socket
* @param {string} message
* @param {string[]} messageQueue
* @return {boolean}
*/
function webSocketSend(socket, message, messageQueue) {
if (socket.readyState != socket.OPEN) {
messageQueue.push(message);
} else {
socket.send(message);
}
}
/**
* handleQueuedMessages sends messages awaiting in the message queue
*/
function handleQueuedMessages(messageQueue, socket) {
while (messageQueue.length > 0) {
var message = messageQueue[0]
if (socket.readyState == socket.OPEN) {
socket.send(message);
messageQueue.shift()
} else {
break;
}
}
}
/**
* getWebSocketReconnectDelay is the default easing function for WebSocket reconnects.
* @param {number} retryCount // The number of retries that have already taken place
@ -230,12 +289,12 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
/**
* 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
* 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
*
* @param {*} elt
* @returns
*/
function maybeCloseWebSocketSource(elt) {
if (!api.bodyContains(elt)) {
@ -248,8 +307,8 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
/**
* 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
*
* @param {string} url
* @returns WebSocket
*/
function createWebSocket(url){
@ -258,9 +317,9 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
/**
* queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
*
* @param {HTMLElement} elt
* @param {string} attributeName
*
* @param {HTMLElement} elt
* @param {string} attributeName
*/
function queryAttributeOnThisOrChildren(elt, attributeName) {
@ -281,8 +340,8 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
/**
* @template T
* @param {T[]} arr
* @param {(T) => void} func
* @param {T[]} arr
* @param {(T) => void} func
*/
function forEach(arr, func) {
if (arr) {
@ -292,4 +351,5 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
}
}
})();
})();

View File

@ -76,6 +76,7 @@ return (function () {
/** @type {import("./htmx").HtmxInternalApi} */
var internalAPI = {
addTriggerHandler: addTriggerHandler,
bodyContains: bodyContains,
canAccessLocalStorage: canAccessLocalStorage,
filterValues: filterValues,
@ -172,13 +173,11 @@ return (function () {
* @returns {HTMLElement | null}
*/
function getClosestMatch(elt, condition) {
if (condition(elt)) {
return elt;
} else if (parentElt(elt)) {
return getClosestMatch(parentElt(elt), condition);
} else {
return null;
while (elt && !condition(elt)) {
elt = parentElt(elt);
}
return elt ? elt : null;
}
function getAttributeValueWithDisinheritance(initialElement, ancestor, attributeName){
@ -609,7 +608,7 @@ return (function () {
//====================================================================
// Node processing
//====================================================================
var DUMMY_ELT = getDocument().createElement("output"); // dummy element for bad selectors
function findAttributeTargets(elt, attrName) {
var attrTarget = getClosestAttributeValue(elt, attrName);
@ -1188,14 +1187,14 @@ return (function () {
getInternalData(elt).cancelled = true;
}
function processPolling(elt, verb, path, spec) {
function processPolling(elt, handler, spec) {
var nodeData = getInternalData(elt);
nodeData.timeout = setTimeout(function () {
if (bodyContains(elt) && nodeData.cancelled !== true) {
if (!maybeFilterEvent(spec, makeEvent('hx:poll:trigger', {triggerSpec:spec, target:elt}))) {
issueAjaxRequest(verb, path, elt);
handler(elt);
}
processPolling(elt, verb, getAttributeValue(elt, "hx-" + verb), spec);
processPolling(elt, handler, spec);
}
}, spec.pollInterval);
}
@ -1223,7 +1222,9 @@ return (function () {
path = getRawAttribute(elt, 'action');
}
triggerSpecs.forEach(function(triggerSpec) {
addEventListener(elt, verb, path, nodeData, triggerSpec, true);
addEventListener(elt, function(evt) {
issueAjaxRequest(verb, path, elt, evt)
}, nodeData, triggerSpec, true);
});
}
}
@ -1267,7 +1268,7 @@ return (function () {
return false;
}
function addEventListener(elt, verb, path, nodeData, triggerSpec, explicitCancel) {
function addEventListener(elt, handler, nodeData, triggerSpec, explicitCancel) {
var eltsToListenOn;
if (triggerSpec.from) {
eltsToListenOn = querySelectorAllExt(elt, triggerSpec.from);
@ -1328,17 +1329,15 @@ return (function () {
if (triggerSpec.throttle) {
if (!elementData.throttle) {
issueAjaxRequest(verb, path, elt, evt);
handler(elt, evt);
elementData.throttle = setTimeout(function () {
elementData.throttle = null;
}, triggerSpec.throttle);
}
} else if (triggerSpec.delay) {
elementData.delayed = setTimeout(function () {
issueAjaxRequest(verb, path, elt, evt);
}, triggerSpec.delay);
elementData.delayed = setTimeout(function() { handler(elt, evt) }, triggerSpec.delay);
} else {
issueAjaxRequest(verb, path, elt, evt);
handler(elt, evt);
}
}
};
@ -1356,7 +1355,7 @@ return (function () {
var windowIsScrolling = false // used by initScrollHandler
var scrollHandler = null;
function initScrollHandler() {
function initScrollHandler(handler) {
if (!scrollHandler) {
scrollHandler = function() {
windowIsScrolling = true
@ -1366,25 +1365,22 @@ return (function () {
if (windowIsScrolling) {
windowIsScrolling = false;
forEach(getDocument().querySelectorAll("[hx-trigger='revealed'],[data-hx-trigger='revealed']"), function (elt) {
maybeReveal(elt);
maybeReveal(elt, handler);
})
}
}, 200);
}
}
function maybeReveal(elt) {
function maybeReveal(elt, handler) {
if (!hasAttribute(elt,'data-hx-revealed') && isScrolledIntoView(elt)) {
elt.setAttribute('data-hx-revealed', 'true');
var nodeData = getInternalData(elt);
if (nodeData.initialized) {
issueAjaxRequest(nodeData.verb, nodeData.path, elt);
handler(elt);
} else {
// if the node isn't initialized, wait for it before triggering the request
elt.addEventListener("htmx:afterProcessNode",
function () {
issueAjaxRequest(nodeData.verb, nodeData.path, elt);
}, {once: true});
elt.addEventListener("htmx:afterProcessNode", function(evt) { handler(elt) }, {once: true});
}
}
}
@ -1571,14 +1567,14 @@ return (function () {
}
}
function processSSETrigger(elt, verb, path, sseEventName) {
function processSSETrigger(elt, handler, sseEventName) {
var sseSourceElt = getClosestMatch(elt, hasEventSource);
if (sseSourceElt) {
var sseEventSource = getInternalData(sseSourceElt).sseEventSource;
var sseListener = function () {
if (!maybeCloseSSESource(sseSourceElt)) {
if (bodyContains(elt)) {
issueAjaxRequest(verb, path, elt);
handler(elt);
} else {
sseEventSource.removeEventListener(sseEventName, sseListener);
}
@ -1604,11 +1600,11 @@ return (function () {
//====================================================================
function loadImmediately(elt, verb, path, nodeData, delay) {
function loadImmediately(elt, handler, nodeData, delay) {
var load = function(){
if (!nodeData.loaded) {
nodeData.loaded = true;
issueAjaxRequest(verb, path, elt);
handler(elt);
}
}
if (delay) {
@ -1627,46 +1623,52 @@ return (function () {
nodeData.path = path;
nodeData.verb = verb;
triggerSpecs.forEach(function(triggerSpec) {
if (triggerSpec.sseEvent) {
processSSETrigger(elt, verb, path, triggerSpec.sseEvent);
} else if (triggerSpec.trigger === "revealed") {
initScrollHandler();
maybeReveal(elt);
} else if (triggerSpec.trigger === "intersect") {
var observerOptions = {};
if (triggerSpec.root) {
observerOptions.root = querySelectorExt(elt, triggerSpec.root)
}
if (triggerSpec.threshold) {
observerOptions.threshold = parseFloat(triggerSpec.threshold);
}
var observer = new IntersectionObserver(function (entries) {
for (var i = 0; i < entries.length; i++) {
var entry = entries[i];
if (entry.isIntersecting) {
triggerEvent(elt, "intersect");
break;
}
}
}, observerOptions);
observer.observe(elt);
addEventListener(elt, verb, path, nodeData, triggerSpec);
} else if (triggerSpec.trigger === "load") {
if (!maybeFilterEvent(triggerSpec, makeEvent("load", {elt:elt}))) {
loadImmediately(elt, verb, path, nodeData, triggerSpec.delay);
}
} else if (triggerSpec.pollInterval) {
nodeData.polling = true;
processPolling(elt, verb, path, triggerSpec);
} else {
addEventListener(elt, verb, path, nodeData, triggerSpec);
}
addTriggerHandler(elt, triggerSpec, nodeData, function (elt, evt) {
issueAjaxRequest(verb, path, elt, evt)
})
});
}
});
return explicitAction;
}
function addTriggerHandler(elt, triggerSpec, nodeData, handler) {
if (triggerSpec.sseEvent) {
processSSETrigger(elt, handler, triggerSpec.sseEvent);
} else if (triggerSpec.trigger === "revealed") {
initScrollHandler(handler);
maybeReveal(elt, handler);
} else if (triggerSpec.trigger === "intersect") {
var observerOptions = {};
if (triggerSpec.root) {
observerOptions.root = querySelectorExt(elt, triggerSpec.root)
}
if (triggerSpec.threshold) {
observerOptions.threshold = parseFloat(triggerSpec.threshold);
}
var observer = new IntersectionObserver(function (entries) {
for (var i = 0; i < entries.length; i++) {
var entry = entries[i];
if (entry.isIntersecting) {
triggerEvent(elt, "intersect");
break;
}
}
}, observerOptions);
observer.observe(elt);
addEventListener(elt, handler, nodeData, triggerSpec);
} else if (triggerSpec.trigger === "load") {
if (!maybeFilterEvent(triggerSpec, makeEvent("load", {elt:elt}))) {
loadImmediately(elt, handler, nodeData, triggerSpec.delay);
}
} else if (triggerSpec.pollInterval) {
nodeData.polling = true;
processPolling(elt, handler, triggerSpec);
} else {
addEventListener(elt, handler, nodeData, triggerSpec);
}
}
function evalScript(script) {
if (script.type === "text/javascript" || script.type === "module" || script.type === "") {
var newScript = getDocument().createElement("script");
@ -2885,9 +2887,9 @@ return (function () {
target = beforeSwapDetails.target; // allow re-targeting
serverResponse = beforeSwapDetails.serverResponse; // allow updating content
isError = beforeSwapDetails.isError; // allow updating error
responseInfo.failed = isError; // Make failed property available to response events
responseInfo.successful = !isError; // Make successful property available to response events
responseInfo.successful = !isError; // Make successful property available to response events
if (beforeSwapDetails.shouldSwap) {
if (xhr.status === 286) {
@ -3177,3 +3179,4 @@ return (function () {
}
)()
}));