Add events support for WebSockets (#1126)

* Add events support for WebSockets

* Stop reconnection attempts after element has been removed

* Add tests for websockets

* Hide socker wrapper behind a more strict interface to avoid breaking changes in the future

* Fix legacy websocket tests interfering with new extension tests

* Minor doc fixes

* Add `wsBinaryType` configuration option
This commit is contained in:
Denis Palashevskii 2022-12-04 02:53:59 +04:00 committed by GitHub
parent 8520f6f374
commit 36b017bc26
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 723 additions and 149 deletions

View File

@ -44,6 +44,7 @@
"mocha": "^7.2.0",
"mocha-chrome": "^2.2.0",
"mocha-webdriver-runner": "^0.6.3",
"mock-socket": "^9.1.5",
"sass": "^1.51.0",
"sinon": "^9.2.4",
"typescript": "^4.5.5",

View File

@ -4,7 +4,7 @@ WebSockets Extension
This extension adds support for WebSockets to htmx. See /www/extensions/ws.md for usage instructions.
*/
(function(){
(function () {
/** @type {import("../htmx").HtmxInternalApi} */
var api;
@ -15,18 +15,18 @@ 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
*/
init: function(apiRef) {
init: function (apiRef) {
// Store reference to internal API
api = apiRef;
// Default function for creating new EventSource objects
if (htmx.createWebSocket == undefined) {
if (!htmx.createWebSocket) {
htmx.createWebSocket = createWebSocket;
}
// Default setting for reconnect delay
if (htmx.config.wsReconnectDelay == undefined) {
if (!htmx.config.wsReconnectDelay) {
htmx.config.wsReconnectDelay = "full-jitter";
}
},
@ -37,30 +37,30 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
* @param {string} name
* @param {Event} evt
*/
onEvent: function(name, evt) {
onEvent: function (name, evt) {
switch (name) {
// Try to remove remove an EventSource when elements are removed
case "htmx:beforeCleanupElement":
// Try to close the socket when elements are removed
case "htmx:beforeCleanupElement":
var internalData = api.getInternalData(evt.target)
var internalData = api.getInternalData(evt.target)
if (internalData.webSocket != undefined) {
internalData.webSocket.close();
}
return;
if (internalData.webSocket) {
internalData.webSocket.close();
}
return;
// Try to create EventSources when elements are processed
case "htmx:afterProcessNode":
var parent = evt.target;
// Try to create websockets when elements are processed
case "htmx:afterProcessNode":
var parent = evt.target;
forEach(queryAttributeOnThisOrChildren(parent, "ws-connect"), function(child) {
ensureWebSocket(child)
});
forEach(queryAttributeOnThisOrChildren(parent, "ws-send"), function (child) {
ensureWebSocketSend(child)
});
forEach(queryAttributeOnThisOrChildren(parent, "ws-connect"), function (child) {
ensureWebSocket(child)
});
forEach(queryAttributeOnThisOrChildren(parent, "ws-send"), function (child) {
ensureWebSocketSend(child)
});
}
}
});
@ -85,23 +85,22 @@ 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
* @param {HTMLElement} socketElt
* @returns
*/
function ensureWebSocket(elt, retryCount) {
function ensureWebSocket(socketElt) {
// If the element containing the WebSocket connection no longer exists, then
// do not connect/reconnect the WebSocket.
if (!api.bodyContains(elt)) {
if (!api.bodyContains(socketElt)) {
return;
}
// Get the source straight from the element's value
var wssSource = api.getAttributeValue(elt, "ws-connect")
var wssSource = api.getAttributeValue(socketElt, "ws-connect")
if (wssSource == null || wssSource === "") {
var legacySource = getLegacyWebsocketURL(elt);
var legacySource = getLegacyWebsocketURL(socketElt);
if (legacySource == null) {
return;
} else {
@ -109,58 +108,38 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
}
}
// 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:') {
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:') {
} else if (location.protocol === 'http:') {
wssSource = "ws://" + base_part + wssSource;
}
}
// Create a new WebSocket and event handlers
/** @type {WebSocket} */
var socket = htmx.createWebSocket(wssSource);
var socketWrapper = createWebsocketWrapper(socketElt, function () {
return 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) {
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)) {
socketWrapper.addEventListener('message', function (event) {
if (maybeCloseWebSocketSource(socketElt)) {
return;
}
var response = event.data;
api.withExtensions(elt, function(extension){
response = extension.transformResponse(response, null, elt);
if (!api.triggerEvent(socketElt, "htmx:wsBeforeMessage", {
message: response,
socketWrapper: socketWrapper.publicInterface
})) {
return;
}
api.withExtensions(socketElt, function (extension) {
response = extension.transformResponse(response, null, socketElt);
});
var settleInfo = api.makeSettleInfo(elt);
var settleInfo = api.makeSettleInfo(socketElt);
var fragment = api.makeFragment(response);
if (fragment.children.length) {
@ -171,11 +150,148 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
}
api.settleImmediately(settleInfo.tasks);
api.triggerEvent(socketElt, "htmx:wsAfterMessage", { message: response, socketWrapper: socketWrapper.publicInterface })
});
// Put the WebSocket into the HTML Element's custom data.
api.getInternalData(elt).webSocket = socket;
api.getInternalData(elt).webSocketMessageQueue = messageQueue;
api.getInternalData(socketElt).webSocket = socketWrapper;
}
/**
* @typedef {Object} WebSocketWrapper
* @property {WebSocket} socket
* @property {Array<{message: string, sendElt: Element}>} messageQueue
* @property {number} retryCount
* @property {(message: string, sendElt: Element) => void} sendImmediately sendImmediately sends message regardless of websocket connection state
* @property {(message: string, sendElt: Element) => void} send
* @property {(event: string, handler: Function) => void} addEventListener
* @property {() => void} handleQueuedMessages
* @property {() => void} init
* @property {() => void} close
*/
/**
*
* @param socketElt
* @param socketFunc
* @returns {WebSocketWrapper}
*/
function createWebsocketWrapper(socketElt, socketFunc) {
var wrapper = {
publicInterface: {
send: this.send,
sendImmediately: this.sendImmediately,
queue: this.queue
},
socket: null,
messageQueue: [],
retryCount: 0,
/** @type {Object<string, Function[]>} */
events: {},
addEventListener: function (event, handler) {
if (this.socket) {
this.socket.addEventListener(event, handler);
}
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(handler);
},
sendImmediately: function (message, sendElt) {
if (!this.socket) {
api.triggerErrorEvent()
}
if (sendElt && api.triggerEvent(sendElt, 'htmx:wsBeforeSend', {
message: message,
socketWrapper: this.publicInterface
})) {
this.socket.send(message);
sendElt && api.triggerEvent(sendElt, 'htmx:wsAfterSend', {
message: message,
socketWrapper: this.publicInterface
})
}
},
send: function (message, sendElt) {
if (this.socket.readyState !== this.socket.OPEN) {
this.messageQueue.push({ message: message, sendElt: sendElt });
} else {
this.sendImmediately(message, sendElt);
}
},
handleQueuedMessages: function () {
while (this.messageQueue.length > 0) {
var queuedItem = this.messageQueue[0]
if (this.socket.readyState === this.socket.OPEN) {
this.sendImmediately(queuedItem.message, queuedItem.sendElt);
this.messageQueue.shift();
} else {
break;
}
}
},
init: function () {
if (this.socket && this.socket.readyState === this.socket.OPEN) {
// Close discarded socket
this.socket.close()
}
// Create a new WebSocket and event handlers
/** @type {WebSocket} */
var socket = socketFunc();
this.socket = socket;
socket.onopen = function (e) {
wrapper.retryCount = 0;
api.triggerEvent(socketElt, "htmx:wsOpen", { event: e, socketWrapper: wrapper.publicInterface });
wrapper.handleQueuedMessages();
}
socket.onclose = function (e) {
// If socket should not be connected, stop further attempts to establish connection
// If Abnormal Closure/Service Restart/Try Again Later, then set a timer to reconnect after a pause.
if (!maybeCloseWebSocketSource(socketElt) && [1006, 1012, 1013].indexOf(e.code) >= 0) {
var delay = getWebSocketReconnectDelay(wrapper.retryCount);
setTimeout(function () {
wrapper.retryCount += 1;
wrapper.init();
}, delay);
}
// Notify client code that connection has been closed. Client code can inspect `event` field
// to determine whether closure has been valid or abnormal
api.triggerEvent(socketElt, "htmx:wsClose", { event: e, socketWrapper: wrapper.publicInterface })
};
socket.onerror = function (e) {
api.triggerErrorEvent(socketElt, "htmx:wsError", { error: e, socketWrapper: wrapper });
maybeCloseWebSocketSource(socketElt);
};
var events = this.events;
Object.keys(events).forEach(function (k) {
events[k].forEach(function (e) {
socket.addEventListener(k, e);
})
});
},
close: function () {
this.socket.close()
}
}
wrapper.init();
return wrapper;
}
/**
@ -205,67 +321,65 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
/**
* 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
* @param {HTMLElement} socketElt
* @param {HTMLElement} sendElt
*/
function processWebSocketSend(parent, child) {
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);
function processWebSocketSend(socketElt, sendElt) {
var nodeData = api.getInternalData(sendElt);
var triggerSpecs = api.getTriggerSpecs(sendElt);
triggerSpecs.forEach(function (ts) {
api.addTriggerHandler(sendElt, ts, nodeData, function (elt, evt) {
if (maybeCloseWebSocketSource(socketElt)) {
return;
}
webSocketSend(webSocket, JSON.stringify(filteredParameters), messageQueue);
if(api.shouldCancel(evt, child)){
/** @type {WebSocketWrapper} */
var socketWrapper = api.getInternalData(socketElt).webSocket;
var headers = api.getHeaders(sendElt, socketElt);
var results = api.getInputValues(sendElt, 'post');
var errors = results.errors;
var rawParameters = results.values;
var expressionVars = api.getExpressionVars(sendElt);
var allParameters = api.mergeObjects(rawParameters, expressionVars);
var filteredParameters = api.filterValues(allParameters, sendElt);
var sendConfig = {
parameters: filteredParameters,
unfilteredParameters: allParameters,
headers: headers,
errors: errors,
triggeringEvent: evt,
messageBody: undefined,
socketWrapper: socketWrapper.publicInterface
};
if (!api.triggerEvent(elt, 'htmx:wsConfigSend', sendConfig)) {
return;
}
if (errors && errors.length > 0) {
api.triggerEvent(elt, 'htmx:validation:halted', errors);
return;
}
var body = sendConfig.messageBody;
if (body === undefined) {
var toSend = Object.assign({}, sendConfig.parameters);
if (sendConfig.headers)
toSend['HEADERS'] = headers;
body = JSON.stringify(toSend);
}
socketWrapper.send(body, elt);
if (api.shouldCancel(evt, elt)) {
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
@ -273,7 +387,7 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
*/
function getWebSocketReconnectDelay(retryCount) {
/** @type {"full-jitter" | (retryCount:number) => number} */
/** @type {"full-jitter" | ((retryCount:number) => number)} */
var delay = htmx.config.wsReconnectDelay;
if (typeof delay === 'function') {
return delay(retryCount);
@ -296,7 +410,7 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
* @param {*} elt
* @returns
*/
function maybeCloseWebSocketSource(elt) {
function maybeCloseWebSocketSource(elt) {
if (!api.bodyContains(elt)) {
api.getInternalData(elt).webSocket.close();
return true;
@ -311,8 +425,10 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
* @param {string} url
* @returns WebSocket
*/
function createWebSocket(url){
return new WebSocket(url, []);
function createWebSocket(url) {
var sock = new WebSocket(url, []);
sock.binaryType = htmx.config.wsBinaryType;
return sock;
}
/**
@ -331,7 +447,7 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
}
// Search all child nodes that match the requested attribute
elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "], [data-hx-ws], [hx-ws]").forEach(function(node) {
elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "], [data-hx-ws], [hx-ws]").forEach(function (node) {
result.push(node)
})

View File

@ -58,6 +58,7 @@ return (function () {
withCredentials:false,
timeout:0,
wsReconnectDelay: 'full-jitter',
wsBinaryType: 'blob',
disableSelector: "[hx-disable], [data-hx-disable]",
useTemplateFragments: false,
scrollBehavior: 'smooth',
@ -69,7 +70,9 @@ return (function () {
return new EventSource(url, {withCredentials:true})
},
createWebSocket: function(url){
return new WebSocket(url, []);
var sock = new WebSocket(url, []);
sock.binaryType = htmx.config.wsBinaryType;
return sock;
},
version: "1.8.5"
};

View File

@ -32,6 +32,7 @@ describe("hx-ws attribute", function() {
var socket = mockWebsocket();
this.socket = socket;
clearWorkArea();
this.oldCreateWebSocket = htmx.createWebSocket;
htmx.createWebSocket = function(){
return socket
};
@ -39,6 +40,7 @@ describe("hx-ws attribute", function() {
afterEach(function () {
this.server.restore();
clearWorkArea();
htmx.createWebSocket = this.oldCreateWebSocket;
});
it('handles a basic call back', function () {

295
test/ext/ws.js Normal file
View File

@ -0,0 +1,295 @@
describe("web-sockets extension", function () {
beforeEach(function () {
this.server = makeServer();
this.socketServer = new Mock.Server('ws://localhost:8080');
this.messages = [];
this.clock = sinon.useFakeTimers();
this.socketServer.on('connection', function (socket) {
socket.on('message', function (event) {
this.messages.push(event)
}.bind(this))
}.bind(this))
/* Mock socket library is cool, but it uses setTimeout to emulate asynchronous nature of the network.
* To avoid unexpected behavior, make sure to call this method whenever socket would have a network communication,
* e.g., when connecting, disconnecting, sending messages. */
this.tickMock = function () {
this.clock.tick(5);
}
clearWorkArea();
});
afterEach(function () {
clearWorkArea();
this.socketServer.close();
this.socketServer.stop();
this.clock.restore();
});
it('can establish connection with the server', function () {
this.socketServer.clients().length.should.equal(0);
make('<div hx-ext="ws" ws-connect="ws://localhost:8080">');
this.socketServer.clients().length.should.equal(1);
this.tickMock();
})
it('is closed after removal by swap', function () {
this.server.respondWith("GET", "/test", "Clicked!");
var div = make('<div hx-get="/test" hx-swap="outerHTML" hx-ext="ws" ws-connect="ws://localhost:8080">');
this.tickMock();
this.socketServer.clients().length.should.equal(1);
div.click();
this.server.respond();
this.tickMock();
this.socketServer.clients().length.should.equal(0);
})
it('is closed after removal by js when message is received', function () {
this.server.respondWith("GET", "/test", "Clicked!");
var div = make('<div hx-get="/test" hx-swap="outerHTML" hx-ext="ws" ws-connect="ws://localhost:8080">');
this.tickMock();
this.socketServer.clients().length.should.equal(1);
div.parentElement.removeChild(div);
this.socketServer.emit('message', 'foo');
this.tickMock();
this.socketServer.clients().length.should.equal(0);
})
it('sends data to the server', function () {
var div = make('<div hx-ext="ws" ws-connect="ws://localhost:8080"><div ws-send id="d1">div1</div></div>');
this.tickMock();
byId("d1").click();
this.tickMock();
this.messages.length.should.equal(1);
})
it('handles message from the server', function () {
var div = make('<div hx-ext="ws" ws-connect="ws://localhost:8080"><div id="d1">div1</div><div id="d2">div2</div></div>');
this.tickMock();
this.socketServer.emit('message', "<div id=\"d1\">replaced</div>")
this.tickMock();
byId("d1").innerHTML.should.equal("replaced");
byId("d2").innerHTML.should.equal("div2");
})
it('raises event when socket connected', function () {
var myEventCalled = false;
var handler = function (evt) {
myEventCalled = true;
};
htmx.on("htmx:wsOpen", handler)
make('<div hx-ext="ws" ws-connect="ws://localhost:8080">');
this.tickMock();
myEventCalled.should.be.true;
htmx.off("htmx:wsOpen", handler)
})
it('raises event when socket closed', function () {
var myEventCalled = false;
var handler = function (evt) {
myEventCalled = true;
};
var div = make('<div hx-get="/test" hx-swap="outerHTML" hx-ext="ws" ws-connect="ws://localhost:8080">');
htmx.on(div, "htmx:wsClose", handler)
this.tickMock();
div.parentElement.removeChild(div);
this.socketServer.emit('message', 'foo');
this.tickMock();
myEventCalled.should.be.true;
this.tickMock();
htmx.off(div, "htmx:wsClose", handler)
})
it('raises htmx:wsConfig when sending, allows message modification', function () {
var myEventCalled = false;
function handle(evt) {
myEventCalled = true;
evt.detail.parameters.foo = "bar";
}
htmx.on("htmx:wsConfigSend", handle)
var div = make('<div hx-ext="ws" ws-connect="ws://localhost:8080"><div ws-send id="d1">div1</div></div>');
this.tickMock();
byId("d1").click();
this.tickMock();
myEventCalled.should.be.true;
this.messages.length.should.equal(1);
this.messages[0].should.contains('"foo":"bar"')
htmx.off("htmx:wsConfigSend", handle)
})
it('cancels sending when htmx:wsConfigSend is cancelled', function () {
var myEventCalled = false;
function handle(evt) {
myEventCalled = true;
evt.preventDefault();
}
htmx.on("htmx:wsConfigSend", handle)
var div = make('<div hx-ext="ws" ws-connect="ws://localhost:8080"><div ws-send id="d1">div1</div></div>');
this.tickMock();
byId("d1").click();
this.messages.length.should.equal(0);
myEventCalled.should.be.true;
htmx.off("htmx:wsConfigSend", handle);
})
it('raises htmx:wsBeforeSend when sending', function () {
var myEventCalled = false;
function handle(evt) {
myEventCalled = true;
}
htmx.on("htmx:wsBeforeSend", handle)
var div = make('<div hx-ext="ws" ws-connect="ws://localhost:8080"><div ws-send id="d1">div1</div></div>');
this.tickMock();
byId("d1").click();
this.tickMock();
myEventCalled.should.be.true;
this.messages.length.should.equal(1);
htmx.off("htmx:wsBeforeSend", handle)
})
it('cancels sending when htmx:wsBeforeSend is cancelled', function () {
var myEventCalled = false;
function handle(evt) {
myEventCalled = true;
evt.preventDefault();
}
htmx.on("htmx:wsBeforeSend", handle)
var div = make('<div hx-ext="ws" ws-connect="ws://localhost:8080"><div ws-send id="d1">div1</div></div>');
this.tickMock();
byId("d1").click();
this.tickMock();
myEventCalled.should.be.true;
this.messages.length.should.equal(0);
htmx.off("htmx:wsBeforeSend", handle)
})
it('raises htmx:wsAfterSend when sending', function () {
var myEventCalled = false;
function handle(evt) {
myEventCalled = true;
}
htmx.on("htmx:wsAfterSend", handle)
var div = make('<div hx-ext="ws" ws-connect="ws://localhost:8080"><div ws-send id="d1">div1</div></div>');
this.tickMock();
byId("d1").click();
this.tickMock();
myEventCalled.should.be.true;
this.messages.length.should.equal(1);
htmx.off("htmx:wsAfterSend", handle)
})
it('raises htmx:wsBeforeMessage when receiving message from the server', function () {
var myEventCalled = false;
function handle(evt) {
myEventCalled = true;
}
htmx.on("htmx:wsBeforeMessage", handle)
var div = make('<div hx-ext="ws" ws-connect="ws://localhost:8080"><div id="d1">div1</div><div id="d2">div2</div></div>');
this.tickMock();
this.socketServer.emit('message', "<div id=\"d1\">replaced</div>")
this.tickMock();
myEventCalled.should.be.true;
htmx.off("htmx:wsBeforeMessage", handle)
})
it('cancels swap when htmx:wsBeforeMessage was cancelled', function () {
var myEventCalled = false;
function handle(evt) {
myEventCalled = true;
evt.preventDefault();
}
htmx.on("htmx:wsBeforeMessage", handle)
var div = make('<div hx-ext="ws" ws-connect="ws://localhost:8080"><div id="d1">div1</div><div id="d2">div2</div></div>');
this.tickMock();
this.socketServer.emit('message', "<div id=\"d1\">replaced</div>")
this.tickMock();
myEventCalled.should.be.true;
byId("d1").innerHTML.should.equal("div1");
byId("d2").innerHTML.should.equal("div2");
htmx.off("htmx:wsBeforeMessage", handle)
})
it('raises htmx:wsAfterMessage when message was completely processed', function () {
var myEventCalled = false;
function handle(evt) {
myEventCalled = true;
}
htmx.on("htmx:wsAfterMessage", handle)
var div = make('<div hx-ext="ws" ws-connect="ws://localhost:8080"><div id="d1">div1</div><div id="d2">div2</div></div>');
this.tickMock();
this.socketServer.emit('message', "<div id=\"d1\">replaced</div>")
this.tickMock();
myEventCalled.should.be.true;
htmx.off("htmx:wsAfterMessage", handle)
})
});

View File

@ -33,6 +33,7 @@
<script src="../node_modules/mocha/mocha.js"></script>
<script src="../node_modules/mocha-webdriver-runner/dist/mocha-webdriver-client.js"></script>
<script src="../node_modules/sinon/pkg/sinon.js"></script>
<script src="../node_modules/mock-socket/dist/mock-socket.js"></script>
<script src="../src/htmx.js"></script>
<script class="mocha-init">
mocha.setup('bdd');
@ -137,6 +138,9 @@
<script src="../src/ext/multi-swap.js"></script>
<script src="ext/multi-swap.js"></script>
<script src="../src/ext/ws.js"></script>
<script src="ext/ws.js"></script>
<!-- events last so they don't screw up other tests -->
<script src="core/events.js"></script>
@ -147,7 +151,7 @@
</script>
<em>Work Area</em>
<hr/>
<div id="work-area" hx-history-elt hx-ext="sse, ws">
<div id="work-area" hx-history-elt hx-ext="sse">
</div>
</body>
</html>

View File

@ -1,39 +1,58 @@
---
layout: layout.njk
title: </> htmx - high power tools for html
title: websockets extension - </> htmx - high power tools for html
---
## The `WebSockets` Extension
The `WebSockets` extension enables easy, bi-directional communication with [Web Sockets](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_client_applications) servers directly from HTML. This replaces the experimental `hx-ws` attribute built into previous versions of htmx. For help migrating from older versions, see the [Migrating] guide at the bottom of this page.
The `WebSockets` extension enables easy, bi-directional communication
with [Web Sockets](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_client_applications)
servers directly from HTML. This replaces the experimental `hx-ws` attribute built into previous versions of htmx. For
help migrating from older versions, see the [Migrating] guide at the bottom of this page.
Use the following attributes to configure how WebSockets behave:
* `ws-connect="<url>"` or `ws-connect="<prefix>:<url>"` - A URL to establish an `WebSocket` connection against.
* Prefixes `ws` or `wss` can optionally be specified. If not specified, HTMX defaults to add the location's scheme-type, host and port to have browsers send cookies via websockets.
* `ws-send` - Sends a message to the nearest websocket based on the trigger value for the element (either the natural event
of the event specified by [`hx-trigger`])
* Prefixes `ws` or `wss` can optionally be specified. If not specified, HTMX defaults to add the location's scheme-type,
host and port to have browsers send cookies via websockets.
* `ws-send` - Sends a message to the nearest websocket based on the trigger value for the element (either the natural
event
of the event specified by [`hx-trigger`])
### Usage
```html
<div hx-ext="ws" ws-connect="/chatroom">
<div hx-ext="ws" ws-connect="/chatroom">
<div id="notifications"></div>
<div id="chat_room">
...
...
</div>
<form id="form" ws-send>
<input name="chat_message">
</form>
</div>
</div>
```
### Configuration
WebSockets extension support two configuration options:
- `createWebSocket` - a factory function that can be used to create a custom WebSocket instances. Must be a function,
returning `WebSocket` object
- `wsBinaryType` - a string value, that defines
socket's [`binaryType`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/binaryType) property. Default value
is `blob`
### Receiving Messages from a WebSocket
The example above establishes a WebSocket to the `/chatroom` end point. Content that is sent down from the websocket will
be parsed as HTML and swapped in by the `id` property, using the same logic as [Out of Band Swaps](/attributes/hx-swap-oob).
The example above establishes a WebSocket to the `/chatroom` end point. Content that is sent down from the websocket
will
be parsed as HTML and swapped in by the `id` property, using the same logic
as [Out of Band Swaps](/attributes/hx-swap-oob).
As such, if you want to change the swapping method (e.g., append content at the end of an element or delegate swapping to an extension),
As such, if you want to change the swapping method (e.g., append content at the end of an element or delegate swapping
to an extension),
you need to specify that in the message body, sent by the server.
```html
@ -53,7 +72,8 @@ you need to specify that in the message body, sent by the server.
### Sending Messages to a WebSocket
In the example above, the form uses the `ws-send` attribute to indicate that when it is submitted, the form values should be **serialized as JSON**
In the example above, the form uses the `ws-send` attribute to indicate that when it is submitted, the form values
should be **serialized as JSON**
and send to the nearest enclosing `WebSocket`, in this case the `/chatroom` endpoint.
The serialized values will include a field, `HEADERS`, that includes the headers normally submitted with an htmx
@ -61,32 +81,165 @@ request.
### Automatic Reconnection
If the WebSocket is closed unexpectedly, due to `Abnormal Closure`, `Service Restart` or `Try Again Later`, this extension will attempt to reconnect until the connection is reestablished.
If the WebSocket is closed unexpectedly, due to `Abnormal Closure`, `Service Restart` or `Try Again Later`, this
extension will attempt to reconnect until the connection is reestablished.
By default, the extension uses a full-jitter [exponential-backoff algorithm](https://en.wikipedia.org/wiki/Exponential_backoff) that chooses a randomized retry delay that grows exponentially over time. You can use a different algorithm by writing it into `htmx.config.wsReconnectDelay`. This function takes a single parameter, the number of retries, and returns the time (in milliseconds) to wait before trying again.
By default, the extension uses a
full-jitter [exponential-backoff algorithm](https://en.wikipedia.org/wiki/Exponential_backoff) that chooses a randomized
retry delay that grows exponentially over time. You can use a different algorithm by writing it
into `htmx.config.wsReconnectDelay`. This function takes a single parameter, the number of retries, and returns the
time (in milliseconds) to wait before trying again.
```javascript
// example reconnect delay that you shouldn't use because
// it's not as good as the algorithm that's already in place
htmx.config.wsReconnectDelay = function(retryCount) {
htmx.config.wsReconnectDelay = function (retryCount) {
return retryCount * 1000 // return value in milliseconds
}
```
The extension also implements a simple queuing mechanism that keeps messages in memory when the socket is not in `OPEN` state and sends them once the connection is restored.
The extension also implements a simple queuing mechanism that keeps messages in memory when the socket is not in `OPEN`
state and sends them once the connection is restored.
### Events
WebSockets extensions exposes a set of events that allow you to observe and customize its behavior.
#### <a name="htmx:wsOpen"></a> Event - [`htmx:wsOpen`](#htmx:wsOpen)
This event is triggered when a connection to WebSockets endpoint has been established.
##### Details
* `detail.elt` - the element that holds the socket (the one with `ws-connect` attribute)
* `detail.event` - the original event from the socket
* `detail.socketWrapper` - the wrapper around socket object
#### <a name="htmx:wsClose"></a> Event - [`htmx:wsClose`](#htmx:wsClose)
This event is triggered when a connection to WebSockets endpoint has been closed normally.
You can check if the event was caused by an error by inspecting `detail.event` property.
##### Details
* `detail.elt` - the element that holds the socket (the one with `ws-connect` attribute)
* `detail.event` - the original event from the socket
* `detail.socketWrapper` - the wrapper around socket object
#### <a name="htmx:wsError"></a> Event - [`htmx:wsError`](#htmx:wsError)
This event is triggered when `onerror` event on a socket is raised.
##### Details
* `detail.elt` - the element that holds the socket (the one with `ws-connect` attribute)
* `detail.error` - the error object
* `detail.socketWrapper` - the wrapper around socket object
#### <a name="htmx:wsBeforeMessage"></a> Event - [`htmx:wsBeforeMessage`](#htmx:wsBeforeMessage)
This event is triggered when a message has just been received by a socket, similar to `htmx:beforeOnLoad`. This event
fires
before any processing occurs.
If the event is cancelled, no further processing will occur.
* `detail.elt` - the element that holds the socket (the one with `ws-connect` attribute)
* `detail.message` - raw message content
* `detail.socketWrapper` - the wrapper around socket object
#### <a name="htmx:wsAfterMessage"></a> Event - [`htmx:wsAfterMessage`](#htmx:wsAfterMessage)
This event is triggered when a message has been completely processed by htmx and all changes have been
settled, similar to `htmx:afterOnLoad`.
Cancelling this event has no effect.
* `detail.elt` - the element that holds the socket (the one with `ws-connect` attribute)
* `detail.message` - raw message content
* `detail.socketWrapper` - the wrapper around socket object
#### <a name="htmx:wsConfigSend"></a> Event - [`htmx:wsConfigSend`](#htmx:wsConfigSend)
This event is triggered when preparing to send a message from `ws-send` element.
Similarly to [`htmx:configRequest`](/events#htmx:configRequest), it allows you to modify the message
before sending.
If the event is cancelled, no further processing will occur and no messages will be sent.
##### Details
* `detail.parameters` - the parameters that will be submitted in the request
* `detail.unfilteredParameters` - the parameters that were found before filtering
by [`hx-select`](/attributes/hx-select)
* `detail.headers` - the request headers. Will be attached to the body in `HEADERS` property, if not falsy
* `detail.errors` - validation errors. Will prevent sending and
trigger [`htmx:validation:halted`](/events#htmx:validation:halted) event if not empty
* `detail.triggeringEvent` - the event that triggered sending
* `detail.messageBody` - raw message body that will be sent to the socket. Undefined, can be set to value of any type,
supported by WebSockets. If set, will override
default JSON serialization. Useful, if you want to use some other format, like XML or MessagePack
* `detail.elt` - the element that dispatched the sending (the one with `ws-send` attribute)
* `detail.socketWrapper` - the wrapper around socket object
#### <a name="htmx:wsBeforeSend"></a> Event - [`htmx:wsBeforeSend`](#htmx:wsBeforeSend)
This event is triggered just before sending a message. This includes messages from the queue.
Message can not be modified at this point.
If the event is cancelled, the message will be discarded from the queue and not sent.
##### Details
* `detail.elt` - the element that dispatched the request (the one with `ws-connect` attribute)
* `detail.message` - the raw message content
* `detail.socketWrapper` - the wrapper around socket object
#### <a name="htmx:wsAfterSend"></a> Event - [`htmx:wsAfterSend`](#htmx:wsAfterSend)
This event is triggered just after sending a message. This includes messages from the queue.
Cancelling the event has no effect.
##### Details
* `detail.elt` - the element that dispatched the request (the one with `ws-connect` attribute)
* `detail.message` - the raw message content
* `detail.socketWrapper` - the wrapper around socket object
#### Socket wrapper
You may notice that all events expose `detail.socketWrapper` property. This wrapper holds the socket
object itself and the message queue. It also encapsulates reconnection algorithm. It exposes a few members:
- `send(message, fromElt)` - sends a message safely. If the socket is not open, the message will be persisted in the
queue
instead and sent when the socket is ready.
- `sendImmediately(message, fromElt)` - attempts to send a message regardless of socket state, bypassing the queue. May
fail
- `queue` - an array of messages, awaiting in the queue.
This wrapper can be used in your event handlers to monitor and manipulate the queue (e.g., you can reset the queue when
reconnecting), and to send additional messages (e.g., if you want to send data in batches).
The `fromElt` parameter is optional and, when specified, will trigger corresponding websocket events from
specified element, namely `htmx:wsBeforeSend` and `htmx:wsAfterSend` events when sending your messages.
### Testing with the Demo Server
Htmx includes a demo WebSockets server written in Go that will help you to see WebSockets in action, and begin bootstrapping your own WebSockets code. It is located in the /test/servers/ws folder of the htmx distribution. Look at /test/servers/ws/README.md for instructions on running and using the test server.
Htmx includes a demo WebSockets server written in Go that will help you to see WebSockets in action, and begin
bootstrapping your own WebSockets code. It is located in the /test/servers/ws folder of the htmx distribution. Look at
/test/servers/ws/README.md for instructions on running and using the test server.
### Migrating from Previous Versions
Previous versions of htmx used a built-in tag `hx-ws` to implement WebSockets. This code has been migrated into an extension instead. Here are the steps you need to take to migrate to this version:
Previous versions of htmx used a built-in tag `hx-ws` to implement WebSockets. This code has been migrated into an
extension instead. Here are the steps you need to take to migrate to this version:
| Old Attribute | New Attribute | Comments |
|-------------------------|----------------------|-------------------|
| `hx-ws=""` | `hx-ext="ws"` | Use the `hx-ext="ws"` attribute to install the WebSockets extension into any HTML element. |
| Old Attribute | New Attribute | Comments |
|-------------------------|----------------------|----------------------------------------------------------------------------------------------------------------------------------|
| `hx-ws=""` | `hx-ext="ws"` | Use the `hx-ext="ws"` attribute to install the WebSockets extension into any HTML element. |
| `hx-ws="connect:<url>"` | `ws-connect="<url>"` | Add a new attribute `ws-connect` to the tag that defines the extension to specify the URL of the WebSockets server you're using. |
| `hx-ws="send"` | `ws-send=""` | Add a new attribute `ws-send` to mark any child forms that should send data to your WebSocket server |
| `hx-ws="send"` | `ws-send=""` | Add a new attribute `ws-send` to mark any child forms that should send data to your WebSocket server |
### Source