From 546e346e9817bf2beeac4e539a430559e2c1f670 Mon Sep 17 00:00:00 2001 From: Ben Pate Date: Sat, 12 Feb 2022 11:11:30 -0700 Subject: [PATCH] Restore hx-ws and hx-sse tags (#811) absolute :crown: * Restore WS and SSE code First pass at restoring removed ws and sse code. More to come. * More progress on WS and SSE restore * Update htmx.js crucial whitespace * Update documentation * Combine SSE and WS servers into single "realtime" demo * Realtime Test Server Updates - separated tests for old- and new- style calling - updated intro content and stylesheet - removed extensions from manual test suite * Remove SSE/WS from manual tests --- src/htmx.js | 245 +++++++++++++++++- test/index.html | 3 - test/manual/index.html | 13 - test/realtime/README.md | 57 ++++ test/realtime/go.mod | 22 ++ test/{servers/sse => realtime}/go.sum | 6 +- test/{servers/sse => realtime}/server.go | 75 +++++- .../static/black_transparent.svg | 0 .../sse => realtime}/static/data.json | 0 test/realtime/static/index.html | 52 ++++ test/realtime/static/sse-about.html | 34 +++ .../static/sse-multichannel-ext.html} | 6 + test/realtime/static/sse-multichannel.html | 25 ++ .../static/sse-multiple-ext.html} | 6 + test/realtime/static/sse-multiple.html | 22 ++ .../static/sse-settle-ext.html} | 7 + test/realtime/static/sse-settle.html | 28 ++ .../static/sse-simple-ext.html} | 6 + test/realtime/static/sse-simple.html | 26 ++ .../static/sse-target-ext.html} | 5 + test/realtime/static/sse-target.html | 12 + .../static/sse-triggers-ext.html} | 5 + test/realtime/static/sse-triggers.html | 26 ++ test/realtime/static/stylesheet.css | 222 ++++++++++++++++ .../static/white_transparent.svg | 0 test/realtime/static/ws-about.html | 34 +++ .../static/ws-echo-ext.html} | 6 + test/realtime/static/ws-echo.html | 39 +++ .../static/ws-heartbeat-ext.html} | 6 + test/realtime/static/ws-heartbeat.html | 23 ++ .../static/ws-reconnect-ext.html} | 0 test/realtime/static/ws-reconnect.html | 22 ++ test/servers/sse/README.md | 43 --- test/servers/sse/go.mod | 9 - test/servers/sse/static/index.html | 63 ----- test/servers/sse/static/stylesheet.css | 102 -------- test/servers/ws/README.md | 4 +- www/attributes/hx-sse.md | 9 +- www/attributes/hx-ws.md | 9 +- 39 files changed, 1025 insertions(+), 247 deletions(-) create mode 100644 test/realtime/README.md create mode 100644 test/realtime/go.mod rename test/{servers/sse => realtime}/go.sum (92%) rename test/{servers/sse => realtime}/server.go (82%) rename test/{servers/sse => realtime}/static/black_transparent.svg (100%) rename test/{servers/sse => realtime}/static/data.json (100%) create mode 100644 test/realtime/static/index.html create mode 100644 test/realtime/static/sse-about.html rename test/{servers/sse/static/sse-multichannel.html => realtime/static/sse-multichannel-ext.html} (82%) create mode 100644 test/realtime/static/sse-multichannel.html rename test/{servers/sse/static/sse-multiple.html => realtime/static/sse-multiple-ext.html} (72%) create mode 100644 test/realtime/static/sse-multiple.html rename test/{servers/sse/static/sse-settle.html => realtime/static/sse-settle-ext.html} (84%) create mode 100644 test/realtime/static/sse-settle.html rename test/{servers/sse/static/sse-simple.html => realtime/static/sse-simple-ext.html} (83%) create mode 100644 test/realtime/static/sse-simple.html rename test/{servers/sse/static/sse-target.html => realtime/static/sse-target-ext.html} (84%) create mode 100644 test/realtime/static/sse-target.html rename test/{servers/sse/static/sse-triggers.html => realtime/static/sse-triggers-ext.html} (85%) create mode 100644 test/realtime/static/sse-triggers.html create mode 100644 test/realtime/static/stylesheet.css rename test/{servers/sse => realtime}/static/white_transparent.svg (100%) create mode 100644 test/realtime/static/ws-about.html rename test/{servers/ws/static/ws-echo.html => realtime/static/ws-echo-ext.html} (82%) create mode 100644 test/realtime/static/ws-echo.html rename test/{servers/ws/static/ws-heartbeat.html => realtime/static/ws-heartbeat-ext.html} (71%) create mode 100644 test/realtime/static/ws-heartbeat.html rename test/{servers/ws/static/websocket-reconnect.html => realtime/static/ws-reconnect-ext.html} (100%) create mode 100644 test/realtime/static/ws-reconnect.html delete mode 100644 test/servers/sse/README.md delete mode 100644 test/servers/sse/go.mod delete mode 100644 test/servers/sse/static/index.html delete mode 100644 test/servers/sse/static/stylesheet.css diff --git a/src/htmx.js b/src/htmx.js index 4015e4c4..98b8cae5 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -57,12 +57,19 @@ return (function () { attributesToSettle:["class", "style", "width", "height"], withCredentials:false, timeout:0, + wsReconnectDelay: 'full-jitter', disableSelector: "[hx-disable], [data-hx-disable]", useTemplateFragments: false, scrollBehavior: 'smooth', }, parseInterval:parseInterval, _:internalEval, + createEventSource: function(url){ + return new EventSource(url, {withCredentials:true}) + }, + createWebSocket: function(url){ + return new WebSocket(url, []); + }, version: "1.7.0" }; @@ -562,6 +569,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); @@ -760,6 +768,12 @@ return (function () { function cleanUpElement(element) { var internalData = getInternalData(element); + if (internalData.webSocket) { + internalData.webSocket.close(); + } + if (internalData.sseEventSource) { + internalData.sseEventSource.close(); + } triggerEvent(element, "htmx:beforeCleanupElement") @@ -1066,6 +1080,8 @@ return (function () { every.eventFilter = eventFilter; } triggerSpecs.push(every); + } else if (trigger.indexOf("sse:") === 0) { + triggerSpecs.push({trigger: 'sse', sseEvent: trigger.substr(4)}); } else { var triggerSpec = {trigger: trigger}; var eventFilter = maybeGenerateConditional(elt, tokens, "event"); @@ -1339,6 +1355,219 @@ return (function () { } } + //==================================================================== + // Web Sockets + //==================================================================== + + function processWebSocketInfo(elt, nodeData, info) { + var values = splitOnWhitespace(info); + for (var i = 0; i < values.length; i++) { + var value = values[i].split(/:(.+)/); + if (value[0] === "connect") { + ensureWebSocket(elt, value[1], 0); + } + if (value[0] === "send") { + processWebSocketSend(elt); + } + } + } + + function ensureWebSocket(elt, wssSource, retryCount) { + if (!bodyContains(elt)) { + return; // stop ensuring websocket connection when socket bearing element ceases to exist + } + + if (wssSource.indexOf("/") == 0) { // complete absolute paths only + 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 socket = htmx.createWebSocket(wssSource); + socket.onerror = function (e) { + triggerErrorEvent(elt, "htmx:wsError", {error:e, socket:socket}); + maybeCloseWebSocketSource(elt); + }; + + socket.onclose = function (e) { + if ([1006, 1012, 1013].indexOf(e.code) >= 0) { // Abnormal Closure/Service Restart/Try Again Later + var delay = getWebSocketReconnectDelay(retryCount); + setTimeout(function() { + ensureWebSocket(elt, wssSource, retryCount+1); // creates a websocket with a new timeout + }, delay); + } + }; + socket.onopen = function (e) { + retryCount = 0; + } + + getInternalData(elt).webSocket = socket; + socket.addEventListener('message', function (event) { + if (maybeCloseWebSocketSource(elt)) { + return; + } + + var response = event.data; + withExtensions(elt, function(extension){ + response = extension.transformResponse(response, null, elt); + }); + + var settleInfo = makeSettleInfo(elt); + var fragment = makeFragment(response); + var children = toArray(fragment.children); + for (var i = 0; i < children.length; i++) { + var child = children[i]; + oobSwap(getAttributeValue(child, "hx-swap-oob") || "true", child, settleInfo); + } + + settleImmediately(settleInfo.tasks); + }); + } + + function maybeCloseWebSocketSource(elt) { + if (!bodyContains(elt)) { + getInternalData(elt).webSocket.close(); + return true; + } + } + + function processWebSocketSend(elt) { + var webSocketSourceElt = getClosestMatch(elt, function (parent) { + return getInternalData(parent).webSocket != null; + }); + if (webSocketSourceElt) { + elt.addEventListener(getTriggerSpecs(elt)[0].trigger, function (evt) { + var webSocket = getInternalData(webSocketSourceElt).webSocket; + var headers = getHeaders(elt, webSocketSourceElt); + var results = getInputValues(elt, 'post'); + var errors = results.errors; + var rawParameters = results.values; + var expressionVars = getExpressionVars(elt); + var allParameters = mergeObjects(rawParameters, expressionVars); + var filteredParameters = filterValues(allParameters, elt); + filteredParameters['HEADERS'] = headers; + if (errors && errors.length > 0) { + triggerEvent(elt, 'htmx:validation:halted', errors); + return; + } + webSocket.send(JSON.stringify(filteredParameters)); + if(shouldCancel(evt, elt)){ + evt.preventDefault(); + } + }); + } else { + triggerErrorEvent(elt, "htmx:noWebSocketSourceError"); + } + } + + function getWebSocketReconnectDelay(retryCount) { + var delay = htmx.config.wsReconnectDelay; + if (typeof delay === 'function') { + // @ts-ignore + 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"'); + } + + //==================================================================== + // Server Sent Events + //==================================================================== + + function processSSEInfo(elt, nodeData, info) { + var values = splitOnWhitespace(info); + for (var i = 0; i < values.length; i++) { + var value = values[i].split(/:(.+)/); + if (value[0] === "connect") { + processSSESource(elt, value[1]); + } + + if ((value[0] === "swap")) { + processSSESwap(elt, value[1]) + } + } + } + + function processSSESource(elt, sseSrc) { + var source = htmx.createEventSource(sseSrc); + source.onerror = function (e) { + triggerErrorEvent(elt, "htmx:sseError", {error:e, source:source}); + maybeCloseSSESource(elt); + }; + getInternalData(elt).sseEventSource = source; + } + + function processSSESwap(elt, sseEventName) { + var sseSourceElt = getClosestMatch(elt, hasEventSource); + if (sseSourceElt) { + var sseEventSource = getInternalData(sseSourceElt).sseEventSource; + var sseListener = function (event) { + if (maybeCloseSSESource(sseSourceElt)) { + sseEventSource.removeEventListener(sseEventName, sseListener); + return; + } + + /////////////////////////// + // TODO: merge this code with AJAX and WebSockets code in the future. + + var response = event.data; + withExtensions(elt, function(extension){ + response = extension.transformResponse(response, null, elt); + }); + + var swapSpec = getSwapSpecification(elt) + var target = getTarget(elt) + var settleInfo = makeSettleInfo(elt); + + selectAndSwap(swapSpec.swapStyle, elt, target, response, settleInfo) + settleImmediately(settleInfo.tasks) + triggerEvent(elt, "htmx:sseMessage", event) + }; + + getInternalData(elt).sseListener = sseListener; + sseEventSource.addEventListener(sseEventName, sseListener); + } else { + triggerErrorEvent(elt, "htmx:noSSESourceError"); + } + } + + function processSSETrigger(elt, verb, path, 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); + } else { + sseEventSource.removeEventListener(sseEventName, sseListener); + } + } + }; + getInternalData(elt).sseListener = sseListener; + sseEventSource.addEventListener(sseEventName, sseListener); + } else { + triggerErrorEvent(elt, "htmx:noSSESourceError"); + } + } + + function maybeCloseSSESource(elt) { + if (!bodyContains(elt)) { + getInternalData(elt).sseEventSource.close(); + return true; + } + } + + function hasEventSource(node) { + return getInternalData(node).sseEventSource != null; + } + //==================================================================== function loadImmediately(elt, verb, path, nodeData, delay) { @@ -1364,7 +1593,9 @@ return (function () { nodeData.path = path; nodeData.verb = verb; triggerSpecs.forEach(function(triggerSpec) { - if (triggerSpec.trigger === "revealed") { + if (triggerSpec.sseEvent) { + processSSETrigger(elt, verb, path, triggerSpec.sseEvent); + } else if (triggerSpec.trigger === "revealed") { initScrollHandler(); maybeReveal(elt); } else if (triggerSpec.trigger === "intersect") { @@ -1439,7 +1670,8 @@ return (function () { function findElementsToProcess(elt) { if (elt.querySelectorAll) { var boostedElts = isBoosted() ? ", a, form" : ""; - var results = elt.querySelectorAll(VERB_SELECTOR + boostedElts + ", [hx-ext], [data-hx-ext]"); + var results = elt.querySelectorAll(VERB_SELECTOR + boostedElts + ", [hx-sse], [data-hx-sse], [hx-ws]," + + " [data-hx-ws], [hx-ext], [hx-data-ext]"); return results; } else { return []; @@ -1490,6 +1722,15 @@ return (function () { initButtonTracking(elt); } + var sseInfo = getAttributeValue(elt, 'hx-sse'); + if (sseInfo) { + processSSEInfo(elt, nodeData, sseInfo); + } + + var wsInfo = getAttributeValue(elt, 'hx-ws'); + if (wsInfo) { + processWebSocketInfo(elt, nodeData, wsInfo); + } triggerEvent(elt, "htmx:afterProcessNode"); } } diff --git a/test/index.html b/test/index.html index d0fcf900..ad5b8b14 100644 --- a/test/index.html +++ b/test/index.html @@ -126,9 +126,6 @@ - - - diff --git a/test/manual/index.html b/test/manual/index.html index a98fe104..e7f1941d 100644 --- a/test/manual/index.html +++ b/test/manual/index.html @@ -24,19 +24,6 @@
  • Targets
  • -
  • SSE - -
  • -
  • Websocket - -
  • History