mirror of
https://github.com/bigskysoftware/htmx.git
synced 2025-09-27 13:01:03 +00:00
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:
parent
e9434be443
commit
260b82314a
173
src/ext/sse.js
Normal file
173
src/ext/sse.js
Normal 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
18
src/htmx.d.ts
vendored
@ -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;
|
||||
}
|
167
src/htmx.js
167
src/htmx.js
@ -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;
|
||||
}
|
||||
|
@ -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>
|
Loading…
x
Reference in New Issue
Block a user