Tip
For htmx 2, use the htmx-2 branch. For the legacy htmx 1 implementation, use the htmx-1.0 branch.
The htmx SignalR extension provides integration with SignalR servers for htmx-powered pages, allowing bidirectional real-time client-server communication. It's an ASP.NET Core-native alternative to htmx's WebSockets and SSE extensions.
SignalR is an open-source library that simplifies adding real-time web functionality to apps. Real-time web functionality enables server-side code to push content to clients instantly.
This version of the extension is made for htmx 4.
On the client, install htmx, SignalR, and the extension script, and use the provided attributes:
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/htmx.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@microsoft/signalr@10.0.11/dist/browser/signalr.min.js"></script>
<script src="/js/hx-signalr.js"></script>
<section hx-signalr:connect="/chat">
<form hx-signalr:send="SendMessage">
<input name="message" required>
<button type="submit">Send</button>
</form>
<div hx-signalr:subscribe="ReceiveMessage"
hx-swap="beforeend"></div>
</section>On the ASP.NET Core server, create and register a SignalR hub:
public sealed record ChatRequest(string Message);
public sealed class ChatHub : Hub
{
public async Task SendMessage(ChatRequest request)
{
var html = $"<p>{HtmlEncoder.Default.Encode(request.Message)}</p>";
await Clients.All.SendAsync("ReceiveMessage", html);
}
}
builder.Services.AddSignalR();
app.MapHub<ChatHub>("/chat");Download the extension script from the dist/ directory or build it from the TypeScript source yourself.
You also need the htmx and SignalR scripts included in the page. Both should be included
before the extension script:
<script src="/js/htmx.min.js"></script>
<script src="/js/signalr.min.js"></script>
<script src="/js/hx-signalr.js"></script>If you use the htmx 4 extension whitelist, add it there:
<meta name="htmx-config" content="extensions:hx-signalr">This attribute marks an element as a HubConnection owner. It will create a hub connection via the specified URL.
<main hx-signalr:connect="/notifications">
<!-- This subscriber and sender use /notifications. -->
<output hx-signalr:subscribe="UnreadCount"></output>
<button hx-signalr:send="MarkAllRead">Mark all read</button>
<section hx-signalr:connect="/presence">
<!-- This subscriber uses /presence. -->
<output hx-signalr:subscribe="OnlineUsers"></output>
</section>
</main>To delay the connection until a certain event, you can specify hx-trigger on the same element.
<section id="chat"
hx-signalr:connect="/chat"
hx-trigger="open-chat once">
</section>
<button onclick="htmx.trigger('#chat', 'open-chat')">Open chat</button>Other attributes will find a connection to use by looking for a parent with an hx-signalr:connect attribute.
This attribute subscribes the element to one or more client hub methods. Content from the incoming messages
will be swapped the same way as usual htmx responses. All swap-related attributes
(hx-target, hx-swap, hx-select[-oob]) are supported.
<section hx-signalr:connect="/dashboard">
<div hx-signalr:subscribe="StatusChanged"></div>
<ol id="activity"></ol>
<div hx-signalr:subscribe="AuditCreated, AuditUpdated"
hx-target="#activity"
hx-swap="beforeend"></div>
</section>The method subscription expects a single method argument, which can be either a string or an object.
String arguments are treated like raw HTML content and swapped as is:
await Clients.All.SendAsync("StatusChanged", "<strong>Online</strong>");Objects are expected to follow the following structure (which is suspiciously the same as the message
format of the hx-ws extension):
{
"content": "<li class=\"activity\">Deployment completed</li>",
"target": "#activity",
"swap": "beforeend settle:10ms",
"select": ".activity"
}| Property | Purpose |
|---|---|
content |
HTML content that will be swapped |
target |
Server-side hx-target override |
swap |
Server-side hx-swap override |
select |
Server-side hx-select override |
You can send them from the server like so:
await Clients.Caller.SendAsync("AuditCreated", new
{
content = "<li class=\"activity\">Created</li>",
target = "#activity",
swap = "beforeend",
select = ".activity"
});This attribute is used to send messages to the specified server method.
<section hx-signalr:connect="/orders">
<form hx-signalr:send="CreateOrder">
<input name="productId" value="42">
<input name="quantity" type="number" value="2">
<button type="submit" name="action" value="buy">Buy</button>
</form>
</section>Request data will be serialized as a JSON object, where each form field is mapped into a corresponding property.
Additionally, htmx request headers are attached as a headers
property:
{
"productId": "42",
"quantity": "2",
"action": "buy",
"headers": {
"HX-Request": "true",
"HX-Source": "form",
"HX-Current-URL": "https://example.com/orders"
}
}hx-vals and hx-include attributes are supported as well.
Message sending from elements is serialized on the connection instance, so that messages will always be sent
in the order in which sends were triggered, even if event handlers or hx-vals processing require async operations.
<button hx-signalr:send="SaveDraft"
hx-include="#title"
hx-vals='js:{ revision: 3, token: await getToken() }'>
Save
</button>The message is sent on the specified hx-trigger. In case the attribute is not present, the following default
events will be used instead:
| Element | Default trigger |
|---|---|
<form> |
submit |
Text-like <input>, <select>, or <textarea> |
change |
| Any other element | click |
<button hx-signalr:send="Refresh"
hx-trigger="click once delay:250ms">
Refresh
</button>Previous versions of the extension tried their best to keep track of element attributes so that explicit
reinitialization was not required. This version steps away from this approach. If you want to use
custom JS to update hx-signalr:* attributes, you will need to call htmx.process(element, true) after
the change, where true means full element reinitialization.
The exception is hx-signalr:send, which will always use the actual attribute value. You still need to reinitialize
the element if hx-trigger has changed.
const subscription = document.querySelector('#live-feed')
subscription.setAttribute('hx-signalr:subscribe', 'ArchivedItem')
htmx.process(subscription, true)Extension configuration is available through the global htmx.config.signalr object. It allows you to set SignalR
HubConnection settings.
htmx.config.signalr = {
logging: signalR.LogLevel.Information,
urlOptions: {
accessTokenFactory: () => sessionStorage.getItem('access-token')
},
automaticReconnect: [0, 2_000, 10_000, 30_000],
keepAliveInterval: 15_000,
serverTimeout: 30_000,
statefulReconnect: { bufferSize: 100_000 },
maxOutgoingMessagesQueueSize: 100
}Global configuration can be set with <meta name="htmx-config">. Element configuration via hx-config is also
supported:
<section hx-signalr:connect="/chat"
hx-config='{"signalr":{"automaticReconnect":[0,1000,5000],"serverTimeout":60000}}'>
</section>Refer to the SignalR HubConnectionBuilder docs for detailed descriptions of its settings.
maxOutgoingMessagesQueueSize sets the upper bound of the internal message queue, which is used to queue messages
during hub reconnection.
createHubConnection allows you to set a custom factory method for the HubConnection, giving you full
control over its creation.
htmx.config.signalr.createHubConnection = (url, owner, config) => {
console.debug('Creating a connection for', owner)
return new signalR.HubConnectionBuilder()
.withUrl(url, config.urlOptions)
.withAutomaticReconnect()
.build()
}The hx-signalr extension emits events for the hub connection lifecycle and incoming and outgoing message processing. They can be used to update page state based on connection and message status, customize connection configuration, or modify message content.
Some events emit a detail.cancelled field. Setting it to true or calling .preventDefault()
will halt the process that emitted the event.
Some events expose a waitUntil(work: Promise<unknown>) method. It can be used to queue asynchronous work that
will be awaited if event processing requires asynchronous operations.
After the hub is connected, most events will expose a connection object. It's a wrapper around the raw
HubConnection instance that handles message queueing, serialization, and safer event subscription. Please note
that while it's just a JS object with public properties, modifying them may have no effect or may break the connection.
Properties and methods that are not described below are implementation details and can be changed at any time.
url: string- hub URLconfig: HtmxSignalRConfig- hub configurationsubscribe(method: string, handleMessage: (m: any) => Promise<unknown>): void- add method handlerunsubscribe(m: string, handleMessage: (m: any) => Promise<unknown>): void- remove method handlersendFromElement(elt: Element, messageProvider: Promise<OutgoingMessage>): Promise<void>- sends a message as if it was sent from anhx-signalr:sendelement, with all corresponding events emitted from said element.messageProvideris the async provider of the message. For a description ofOutgoingMessage, refer to thehtmx:signalr:before:message:outgoingevent,detail.messagefieldsend(method: string, message: Record<string, unknown>, callback: () => {}): Promise<void>- sendsmessageto the specifiedmethod. If the hub is reconnecting, sending will be queued internally until the connection is reestablished.callbackwill be invoked after the message has been sent.stop(reason: string): voidstops the connection, halting all further operations
These events are emitted from the hx-signalr:connect element.
This event is emitted when a hub connection is about to be established.
detail.url- hub URL. Can be modifieddetail.config- configuration that will be used by the hub. Can be modifieddetail.cancelled- cancellation flag
document.addEventListener('htmx:signalr:before:connection', event => {
if (!currentUser.mayConnect) {
event.preventDefault()
return
}
event.detail.config.logging = signalR.LogLevel.Warning
})This event is emitted after the hub connection is fully established.
detail.connection- connection instance
document.addEventListener('htmx:signalr:after:connection', event => {
console.log('Connected to', event.detail.connection.url)
})This event is emitted when the hub connection starts trying to reconnect.
detail.connection- connection instancedetail.error- error that caused reconnection
document.addEventListener('htmx:signalr:reconnecting', event => {
document.querySelector('#connection-state').textContent = 'Reconnecting…'
console.warn(event.detail.error)
})This event is emitted when the hub connection has reconnected successfully.
detail.connection- connection instance
document.addEventListener('htmx:signalr:reconnected', event => {
document.querySelector('#connection-state').textContent = 'Connected'
console.log('Reconnected to', event.detail.connection.url)
})This event is emitted when the hub connection has been fully closed.
detail.connection- connection instancedetail.error- error that caused closuredetail.reason- closure reason as text
reason has the following built-in values:
removedwhen the connection element is cleaned up by htmxcancelledwhenhtmx:signalr:before:connectionevent is cancelledclosedwhen the connection is closed otherwise
You can pass your own value to the connection instance's stop(reason) method.
document.addEventListener('htmx:signalr:close', event => {
console.log(`Connection ${event.detail.reason}`, event.detail.error)
})This event is emitted when an unexpected error occurs.
detail.error- the error object
document.addEventListener('htmx:signalr:error', event => {
console.error('SignalR extension error:', event.detail.error)
})These events are emitted from hx-signalr:subscribe elements.
This event is emitted when a message has been received and before any processing.
detail.connection- connection instancedetail.cancelled- cancellation flagdetail.message.method- method that received the messagedetail.message.data- raw content of the message. Can be modifieddetail.waitUntil()- queues async work to wait for before further processing
document.addEventListener('htmx:signalr:before:message:incoming', event => {
if (event.detail.message.method === 'AdminNotice' && !currentUser.isAdmin) {
event.preventDefault()
return
}
event.detail.waitUntil(loadDisplayPreferences().then(preferences => {
event.detail.message.data = decorateMessage(
event.detail.message.data,
preferences
)
}))
})This event is emitted when a message has been received and after processing is fully completed.
detail.connection- connection instancedetail.message.method- method that received the messagedetail.message.data- raw content of the message
document.addEventListener('htmx:signalr:after:message:incoming', event => {
console.log('Swapped message from', event.detail.message.method)
})These events are emitted from hx-signalr:send elements.
This event is emitted when message sending has been triggered, after all the needed information for the message has been collected, and before the message is sent.
-
detail.connection- connection instance -
detail.cancelled- cancellation flag -
detail.message.method- destination method for the message. Can be modified -
detail.message.values- form values that will be sent. Can be modified -
detail.message.headers- htmx headers that will be sent. Can be modified -
detail.message.data- raw content of the message. Can be modified.Initially, it's
undefined, and later it will be filled by combiningvaluesandheaders. If an event handler sets a value, that value will be sent as is instead. -
detail.waitUntil()- queues async work to wait for before further processing
document.addEventListener('htmx:signalr:before:message:outgoing', event => {
event.detail.waitUntil(getAccessToken().then(token => {
event.detail.message.headers.Authorization = `Bearer ${token}`
}))
if (event.detail.message.method === 'DeleteAccount') {
event.detail.message.data = {
confirmation: 'DELETE',
headers: event.detail.message.headers
}
}
})This event is emitted when a message has been sent.
detail.connection- connection instancedetail.message.method- destination method for the messagedetail.message.values- form values that were sentdetail.message.headers- htmx headers that were sentdetail.message.data- raw content of the sent message
document.addEventListener('htmx:signalr:after:message:outgoing', event => {
console.log('Sent', event.detail.message.method)
})This project is licensed under the MIT License.
The implementation is based on the official WebSocket extension, licensed under the BSD Zero-Clause License.