Initial port

- rebuilding this fork because of a corrupted git repository.  This branch is "working" but needs more testing to be certain.
- major changes to the way that htmx handles extensions.  this will need to be vetted more carefully before we proceed.
This commit is contained in:
Ben Pate 2021-10-24 11:56:45 -06:00
parent e9434be443
commit 260b82314a
4 changed files with 250 additions and 125 deletions

173
src/ext/sse.js Normal file
View File

@ -0,0 +1,173 @@
/*
Server Sent Events Extension
============================
This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions.
*/
(function(){
htmx.defineExtension("sse", {
/**
* onEvent handles all events passed to this extension.
*
* @param {string} name
* @param {Event} evt
* @param {import("../htmx").HtmxExtensionApi} api
* @returns void
*/
onEvent: function(name, evt, api) {
switch (name) {
// Try to remove remove an EventSource when elements are removed
case "htmx:beforeCleanupElement":
if (api.hasAttribute("hx-swap")) {
var source = api.getInternalData(evt.target, "sseEventSource")
if (source != null) {
source.close();
}
return;
}
// Try to create EventSources when elements are processed
case "htmx:afterProcessNode":
var parent = evt.target;
// get URL from element's attribute
var sseURL = api.getAttributeValue(evt.target, "sse-url")
if (sseURL == undefined) {
return;
}
// Default function for creating new EventSource objects
if (htmx.createEventSource == undefined) {
htmx.createEventSource = createEventSource;
}
// Connect to the EventSource
var source = htmx.createEventSource(sseURL);
source.onerror = function (err) {
api.triggerErrorEvent(parent, "htmx:sseError", {error:err, source:source});
maybeCloseSSESource(parent);
};
api.getInternalData(parent).sseEventSource = source;
// Add message handlers for every `sse-swap` attribute
queryAttributeOnThisOrChildren(api, parent, "sse-swap").forEach(function(child) {
var sseEventName = api.getAttributeValue(child, "sse-swap")
var listener = function(event) {
// If the parent is missing then close SSE and remove listener
if (maybeCloseSSESource(api, parent)) {
source.removeEventListener(sseEventName, listener);
return;
}
// swap the response into the DOM and trigger a notification
api.swap(child, event.data)
api.triggerEvent(parent, "htmx:sseMessage", event)
};
// Register the new listener
api.getInternalData(parent).sseEventListener = listener;
source.addEventListener(sseEventName, listener);
})
// Add message handlers for every `hx-trigger="sse:*"` attribute
queryAttributeOnThisOrChildren(api, parent, "hx-trigger").forEach(function(child) {
var sseEventName = api.getAttributeValue(child, "hx-trigger")
// Only process hx-triggers for events with the "sse:" prefix
if (sseEventName.slice(0, 4) != "sse:") {
return;
}
var listener = function(event) {
// If parent is missing, then close SSE and remove listener
if (maybeCloseSSESource(api, parent)) {
source.removeEventListener(sseEventName, listener);
return;
}
// Trigger events to be handled by the rest of htmx
api.triggerEvent(child, sseEventName, event)
api.triggerEvent(child, "htmx:sseMessage", event)
}
// Register the new listener
api.getInternalData(parent).sseEventListener = listener;
source.addEventListener(sseEventName, listener);
})
}
}
});
///////////////////////////////////////////////
// HELPER FUNCTIONS
///////////////////////////////////////////////
/**
* createEventSource is the default method for creating new EventSource objects.
* it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
*
* @param {string} url
* @returns EventSource
*/
function createEventSource(url){
return new EventSource(url, {withCredentials:true})
}
/**
* maybeCloseSSESource confirms that the parent element still exists.
* If not, then any associated SSE source is closed and the function returns true.
*
* @param {import("../htmx").HtmxExtensionApi} api
* @param {HTMLElement} elt
* @returns boolean
*/
function maybeCloseSSESource(api, elt) {
if (!api.bodyContains(elt)) {
var source = api.getInternalData("sseEventSource")
if (source != undefined) {
source.close()
// source = null
return true;
}
}
return false;
}
/**
* queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
*
* @param {HTMLElement} elt
* @param {string} attributeName
*/
function queryAttributeOnThisOrChildren(api, 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)) {
result.push(elt);
}
// Search all child nodes that match the requested attribute
elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "]").forEach(function(node) {
result.push(node)
})
return result
}
})();

18
src/htmx.d.ts vendored
View File

@ -24,3 +24,21 @@ export interface HtmxConfig {
}
export declare var htmx: HtmxApi
export interface HtmxExtension {
onEvent: (name: string, event: Event, api: HtmxExtensionApi) => boolean;
transformResponse: (text: string, xhr: XMLHttpRequest, elt: HTMLElement, api: HtmxExtensionApi) => string;
isInlineSwap: (swapStyle: string, api: HtmxExtensionApi) => boolean;
handleSwap: (swapStyle: string, target: HTMLElement, fragment: string, settleInfo: Object, api: HtmxExtensionApi) => boolean;
encodeParameters: (xhr: XMLHttpRequest, parameters: Object, elt: HTMLElement, api: HtmxExtensionApi) => void;
}
export interface HtmxExtensionApi {
bodyContains: (element: HTMLElement) => boolean;
hasAttribute: (element: HTMLElement, qualifiedName: string) => boolean;
getAttributeValue: (element: HTMLElement, qualifiedName: string) => string | null;
getInternalData: (element: HTMLElement) => Object;
triggerEvent: (element: HTMLElement, eventName: string, detail: any) => void;
triggerErrorEvent: (element: HTMLElement, eventName: string, detail: any) => void;
swap: (element: HTMLElement, content: string) => void;
}

View File

@ -61,9 +61,6 @@ return (function () {
},
parseInterval:parseInterval,
_:internalEval,
createEventSource: function(url){
return new EventSource(url, {withCredentials:true})
},
createWebSocket: function(url){
return new WebSocket(url, []);
},
@ -594,12 +591,12 @@ return (function () {
function cleanUpElement(element) {
var internalData = getInternalData(element);
triggerEvent(element, "htmx:beforeCleanupElement")
if (internalData.webSocket) {
internalData.webSocket.close();
}
if (internalData.sseEventSource) {
internalData.sseEventSource.close();
}
if (internalData.listenerInfos) {
forEach(internalData.listenerInfos, function(info) {
if (element !== info.on) {
@ -1186,8 +1183,8 @@ return (function () {
}
var response = event.data;
withExtensions(elt, function(extension){
response = extension.transformResponse(response, null, elt);
withExtensions(elt, function(extension, api){
response = extension.transformResponse(response, null, elt, api);
});
var settleInfo = makeSettleInfo(elt);
@ -1252,98 +1249,6 @@ return (function () {
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) {
@ -1369,9 +1274,7 @@ 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") {
if (triggerSpec.trigger === "revealed") {
initScrollHandler();
maybeReveal(elt);
} else if (triggerSpec.trigger === "intersect") {
@ -1443,8 +1346,9 @@ return (function () {
function findElementsToProcess(elt) {
if (elt.querySelectorAll) {
var boostedElts = isBoosted() ? ", a, form" : "";
var results = elt.querySelectorAll(VERB_SELECTOR + boostedElts + ", [hx-sse], [data-hx-sse], [hx-ws]," +
" [data-hx-ws]");
// var results = elt.querySelectorAll(VERB_SELECTOR + boostedElts + ", [data-hx-ext], [hx-ws], [data-hx-ws]");
// TODO: Probably **remove** [hx-ext] from this list before done. I'm not sure it actually belongs here long-term.
var results = elt.querySelectorAll(VERB_SELECTOR + boostedElts + ", [hx-ext], [data-hx-ext], [hx-ws], [data-hx-ws]");
return results;
} else {
return [];
@ -1495,11 +1399,6 @@ 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);
@ -1541,10 +1440,43 @@ return (function () {
return eventName === "htmx:afterProcessNode"
}
/**
* `withExtensions` locates all active extensions for a provided element, then
* executes the provided function using each of the active extensions. It should
* be called internally at every extendable execution point in htmx.
*
* @param {HTMLElement} elt
* @param {(elt:any, api?: Object) => void} toDo
* @returns void
*/
function withExtensions(elt, toDo) {
// create API object to pass into each toDo function
/** @type {import("./htmx").HtmxExtensionApi} */
var api = {
hasAttribute: hasAttribute,
getAttributeValue: getAttributeValue,
getInternalData: getInternalData,
bodyContains: bodyContains,
triggerEvent: triggerEvent,
triggerErrorEvent: triggerErrorEvent,
swap: function(elt, content) {
withExtensions(elt, function(extension){
content = extension.transformResponse(content, null, elt);
});
var swapSpec = getSwapSpecification(elt)
var target = getTarget(elt)
var settleInfo = makeSettleInfo(elt);
selectAndSwap(swapSpec.swapStyle, elt, target, content, settleInfo)
settleImmediately(settleInfo.tasks)
}
};
forEach(getExtensions(elt), function(extension){
try {
toDo(extension);
toDo(extension, api);
} catch (e) {
logError(e);
}
@ -1579,8 +1511,8 @@ return (function () {
var kebabedEvent = makeEvent(kebabName, event.detail);
eventResult = eventResult && elt.dispatchEvent(kebabedEvent)
}
withExtensions(elt, function (extension) {
eventResult = eventResult && (extension.onEvent(eventName, event) !== false)
withExtensions(elt, function (extension, api) {
eventResult = eventResult && (extension.onEvent(eventName, event, api) !== false)
});
return eventResult;
}
@ -2010,9 +1942,9 @@ return (function () {
function encodeParamsForBody(xhr, elt, filteredParameters) {
var encodedParameters = null;
withExtensions(elt, function (extension) {
withExtensions(elt, function (extension, api) {
if (encodedParameters == null) {
encodedParameters = extension.encodeParameters(xhr, filteredParameters, elt);
encodedParameters = extension.encodeParameters(xhr, filteredParameters, elt, api);
}
});
if (encodedParameters != null) {
@ -2492,8 +2424,8 @@ return (function () {
cancelPolling(elt);
}
withExtensions(elt, function (extension) {
serverResponse = extension.transformResponse(serverResponse, xhr, elt);
withExtensions(elt, function (extension, api) {
serverResponse = extension.transformResponse(serverResponse, xhr, elt, api);
});
// Save current page
@ -2628,6 +2560,7 @@ return (function () {
}
function getExtensions(elt, extensionsToReturn, extensionsToIgnore) {
if (elt == undefined) {
return extensionsToReturn;
}

View File

@ -1,6 +1,8 @@
<html>
<head>
<script src="../../src/htmx.js"></script>
<script src="../../src/htmx.js"></script>
<script src="../../src/ext/server-sent-events.js"></script>
<script src="../../src/ext/debug.js"></script>
<script>
// "withCredentials:false" is necessary to circumvent CORS restrictions
htmx.createEventSource = function(url){
@ -30,14 +32,13 @@
</head>
<body>
<div id="page">
<div id="page" hx-ext="debug">
<h3>Multiple Listeners. message only</h3>
<div class="container" hx-sse="connect:http://sseplaceholder.openfollow.org/posts.html swap:message">Waiting for Posts...</div>
<div class="container" hx-sse="connect:http://sseplaceholder.openfollow.org/comments.html swap:message">Waiting for Comments...</div>
<div class="container" hx-sse="connect:http://sseplaceholder.openfollow.org/albums.html swap:message">Waiting for Albums...</div>
<div class="container" hx-sse="connect:http://sseplaceholder.openfollow.org/todos.html swap:message">Waiting for ToDos...</div>
<div class="container" hx-sse="connect:http://sseplaceholder.openfollow.org/users.html swap:message">Waiting for Users...</div>
<div class="container" hx-ext="sse" sse-url="http://localhost/posts.html" sse-swap="message">Waiting for Posts...</div>
<div class="container" hx-ext="sse" sse-url="http://localhost/comments.html" sse-swap="message">Waiting for Comments...</div>
<div class="container" hx-ext="sse" sse-url="http://localhost/albums.html" sse-swap="message">Waiting for Albums...</div>
<div class="container" hx-ext="sse" sse-url="http://localhost/todos.html" sse-swap="message">Waiting for ToDos...</div>
<div class="container" hx-ext="sse" sse-url="http://localhost/users.html" sse-swap="message">Waiting for Users...</div>
</div>
</body>
</html>