Merge branch 'four' into feature/settle-restoration

# Conflicts:
#	src/htmx.js
This commit is contained in:
Carson Gross
2025-12-24 13:40:25 -07:00
10 changed files with 866 additions and 400 deletions

View File

@@ -28,13 +28,14 @@ The extension maintains a global connection registry that ensures:
Establishes a WebSocket connection to the specified URL.
```html
<div hx-ws:connect="ws://localhost:8080/chat" hx-trigger="load">
<div hx-ws:connect="/chat">
<!-- Content updated via WebSocket messages -->
</div>
```
**Key Features:**
- Supports `hx-trigger` to control when the connection is established (default: explicit trigger required)
- Connects immediately when element is processed (default behavior)
- Use `hx-trigger` to defer connection until a specific event (e.g., `hx-trigger="click"`)
- Can set `hx-target` and `hx-swap` for default message handling
- Connection is shared across all elements using the same URL
@@ -59,7 +60,7 @@ Sends data to the server via WebSocket.
**Explicit URL (establishes new connection):**
```html
<button hx-ws:send="ws://localhost:8080/actions" hx-vals='{"type":"ping"}'>
<button hx-ws:send="/actions" hx-vals='{"type":"ping"}'>
Ping
</button>
```
@@ -68,12 +69,31 @@ Sends data to the server via WebSocket.
The extension sends a JSON object containing:
```json
{
"headers": { /* request headers */ },
"values": { /* form data or hx-vals */ },
"request_id": "unique-id" // for response matching
"type": "request",
"request_id": "unique-id",
"event": "click",
"headers": {
"HX-Request": "true",
"HX-Current-URL": "https://example.com/page",
"HX-Trigger": "element-id",
"HX-Target": "#target"
},
"values": { /* form data or hx-vals - arrays for multi-value fields */ },
"path": "wss://example.com/ws",
"id": "element-id"
}
```
| Field | Description |
|-------|-------------|
| `type` | Always `"request"` for client-to-server messages |
| `request_id` | Unique ID for request/response matching |
| `event` | The DOM event type that triggered the send (e.g., `"click"`, `"submit"`, `"change"`) |
| `headers` | HTMX-style headers for server-side routing/processing |
| `values` | Form data and `hx-vals` - multi-value fields preserved as arrays |
| `path` | The normalized WebSocket URL |
| `id` | Element ID (only if the triggering element has an `id` attribute) |
## Message Format
### Server → Client (JSON Envelope)
@@ -121,18 +141,18 @@ Configure via `htmx.config.websockets`:
```javascript
htmx.config.websockets = {
reconnect: true, // Enable auto-reconnect (default: true)
reconnectDelay: 1000, // Initial delay in ms (default: 1000)
reconnectMaxDelay: 30000, // Max delay in ms (default: 30000)
reconnectJitter: true, // Add jitter to reconnect delays (default: true)
autoConnect: false, // Auto-connect on page load (default: false)
pauseInBackground: true // Pause reconnection when page hidden (default: true)
reconnectDelay: 1000, // Initial delay in ms (default: 1000)
reconnectMaxDelay: 30000, // Max delay in ms (default: 30000)
reconnectJitter: true, // Add jitter to reconnect delays (default: true)
pendingRequestTTL: 30000 // TTL for pending requests in ms (default: 30000)
};
```
**Reconnection Strategy:**
- Exponential backoff: `delay = min(reconnectDelay * 2^attempts, reconnectMaxDelay)`
- Jitter reduces delay by up to 25% to avoid thundering herd
- Respects page visibility API to pause reconnection in background tabs
- Exponential backoff: `delay = min(reconnectDelay * 2^(attempts-1), reconnectMaxDelay)`
- Jitter adds ±25% randomization to avoid thundering herd
- Attempts counter resets to 0 on successful connection
- To implement visibility-aware behavior, listen for `htmx:ws:reconnect` and cancel if `document.hidden`
## Events
@@ -153,7 +173,7 @@ htmx.config.websockets = {
**`htmx:ws:close`**
- Triggered when connection closes
- `detail`: `{ url, code, reason }`
- `detail`: `{ url, code, reason }` (code and reason from WebSocket CloseEvent)
**`htmx:ws:error`**
- Triggered on connection error
@@ -163,34 +183,88 @@ htmx.config.websockets = {
**`htmx:before:ws:send`**
- Triggered before sending a message
- `detail`: `{ data, element }`
- `detail`: `{ data, element, url }` (data is the message object, can be modified)
- Cancellable via `preventDefault()`
**`htmx:after:ws:send`**
- Triggered after message is sent
- `detail`: `{ data, element }`
- `detail`: `{ data, url }` (data is the sent message object)
**`htmx:wsSendError`**
- Triggered when send fails (e.g., no connection URL found)
- `detail`: `{ element }`
**`htmx:wsMessage`**
- Triggered for custom channel messages
- `detail`: `{ channel, format, payload, ... }`
- Triggered for any non-UI channel message (json, audio, binary, custom channels, etc.)
- `detail`: `{ channel, format, payload, element, ... }` (entire envelope plus target element)
- Use this event to implement custom message handling for your application
**`htmx:wsUnknownMessage`**
- Triggered for non-JSON messages
- `detail`: `{ data }` (raw message data)
- Triggered for messages that fail JSON parsing (invalid JSON)
- `detail`: `{ data, parseError }` (raw message data and parse error)
## Implementation Details
### URL Normalization
WebSocket URLs are automatically normalized:
- Relative paths (`/ws/chat`) are converted to absolute WebSocket URLs based on current page location
- `http://` is converted to `ws://`
- `https://` is converted to `wss://`
- Protocol-relative URLs (`//example.com/ws`) use `ws:` or `wss:` based on current page protocol
```html
<!-- All of these work: -->
<div hx-ws:connect="/ws/chat"> <!-- becomes wss://example.com/ws/chat on HTTPS -->
<div hx-ws:connect="ws://localhost:8080/ws">
<div hx-ws:connect="https://api.example.com/ws"> <!-- becomes wss://api.example.com/ws -->
```
### Trigger Semantics
By default, WebSocket connections are established immediately when the element is processed. Use `hx-trigger` only when you want to **defer** connection until a specific event.
```html
<!-- Connects immediately (default) -->
<div hx-ws:connect="/ws">
<!-- Defers connection until click -->
<div hx-ws:connect="/ws" hx-trigger="click">
```
**Important:** Only **bare event names** are supported for connection triggers. Modifiers like `once`, `delay`, `throttle`, `target`, `from`, `revealed`, and `intersect` are **not supported**.
```html
<!-- NOT supported: trigger modifiers -->
<div hx-ws:connect="/ws" hx-trigger="click delay:500ms"> <!-- delay ignored -->
<div hx-ws:connect="/ws" hx-trigger="intersect"> <!-- won't work -->
```
For complex connection control, use the `htmx:before:ws:connect` event:
```javascript
document.addEventListener('htmx:before:ws:connect', (e) => {
if (someCondition) {
e.preventDefault(); // Cancel connection
}
});
```
### HTML Swapping
When a `channel: "ui"` message arrives:
When a `channel: "ui"` message arrives, the extension uses htmx's internal `insertContent` API:
1. Determine target element (from message `target`, request context, or default `hx-target`)
2. Determine swap strategy (from message `swap`, or default `hx-swap`, or `innerHTML`)
3. Use htmx's swap mechanisms to update the DOM
4. Automatically processes swapped content with `htmx.process()`
3. Create a document fragment from the payload
4. Call `api.insertContent({target, swapSpec, fragment})`
This ensures WebSocket swaps get proper htmx behavior:
- All swap styles (innerHTML, outerHTML, beforebegin, afterend, etc.)
- Preserved elements (`hx-preserve`)
- Auto-focus handling
- Scroll handling
- Proper cleanup of removed elements
- `htmx.process()` called on newly inserted content (not the old target)
### Request-Response Matching
@@ -260,9 +334,8 @@ The initial design concept proposed:
### Live Chat
```html
<div hx-ws:connect="ws://localhost:8080/chat"
hx-trigger="load"
hx-target="#messages"
<div hx-ws:connect="/chat"
hx-target="#messages"
hx-swap="beforeend">
<div id="messages"></div>
<form hx-ws:send hx-trigger="submit">
@@ -274,8 +347,7 @@ The initial design concept proposed:
### Real-Time Notifications
```html
<div hx-ws:connect="ws://localhost:8080/notifications"
hx-trigger="load"
<div hx-ws:connect="/notifications"
hx-target="#notifications"
hx-swap="afterbegin">
<div id="notifications"></div>
@@ -284,7 +356,7 @@ The initial design concept proposed:
### Interactive Controls
```html
<div hx-ws:connect="ws://localhost:8080/counter" hx-trigger="load">
<div hx-ws:connect="/counter">
<div id="counter">0</div>
<button hx-ws:send hx-vals='{"action":"increment"}'>+</button>
<button hx-ws:send hx-vals='{"action":"decrement"}'>-</button>

View File

@@ -29,6 +29,7 @@
"test:webkit": "web-test-runner --browsers webkit --config test/web-test-runner.config.mjs --playwright",
"test:all": "web-test-runner --browsers chromium firefox webkit --config test/web-test-runner.config.mjs --playwright --concurrency 1",
"test:manual": "node test/manual/server.js",
"test:manual:ws": "node test/manual/ws-server.js",
"www": "bash scripts/www.sh",
"site": "npm run site:css & npm run site:serve",
"site:css": "cd www && npx @tailwindcss/cli -i ./static/css/input.css -o ./static/css/output.css --watch",
@@ -58,9 +59,7 @@
"brotli-cli": "^2.1.1",
"chai": "^4.5.0",
"mocha": "^11.7.4",
"terser": "^5.36.0"
},
"dependencies": {
"terser": "^5.36.0",
"ws": "^8.18.3"
}
}

View File

@@ -1,20 +1,33 @@
(() => {
let api;
// ========================================
// ATTRIBUTE HELPERS
// ========================================
// Helper to build proper attribute name respecting htmx prefix
function buildAttrName(suffix) {
// htmx.config.prefix replaces 'hx-' entirely, e.g. 'data-hx-'
// So 'hx-ws:connect' becomes 'data-hx-ws:connect'
let prefix = htmx.config.prefix || 'hx-';
return prefix + 'ws' + suffix;
}
// Helper to get attribute value, checking colon, hyphen, and plain variants
// Uses api.attributeValue for automatic prefix handling and inheritance support
function getWsAttribute(element, attrName) {
// Try colon variant first (hx-ws:connect)
// Try colon variant first (hx-ws:connect) - prefix applied automatically by htmx
let colonValue = api.attributeValue(element, 'hx-ws:' + attrName);
if (colonValue !== null && colonValue !== undefined) return colonValue;
if (colonValue != null) return colonValue;
// Try hyphen variant for JSX (hx-ws-connect)
let hyphenValue = api.attributeValue(element, 'hx-ws-' + attrName);
if (hyphenValue !== null && hyphenValue !== undefined) return hyphenValue;
if (hyphenValue != null) return hyphenValue;
// For 'send', also check plain 'hx-ws' (marker attribute)
if (attrName === 'send') {
let plainValue = api.attributeValue(element, 'hx-ws');
if (plainValue !== null && plainValue !== undefined) return plainValue;
if (plainValue != null) return plainValue;
}
return null;
@@ -26,6 +39,14 @@
return value !== null && value !== undefined;
}
// Build selector for WS attributes
function buildWsSelector(attrName) {
let colonAttr = buildAttrName(':' + attrName);
let hyphenAttr = buildAttrName('-' + attrName);
// Escape colon for CSS selector
return `[${colonAttr.replace(':', '\\:')}],[${hyphenAttr}]`;
}
// ========================================
// CONFIGURATION
// ========================================
@@ -36,12 +57,50 @@
reconnectDelay: 1000,
reconnectMaxDelay: 30000,
reconnectJitter: true,
autoConnect: false,
pauseInBackground: true
// Note: pauseInBackground is NOT implemented. Reconnection continues in background tabs.
// To implement visibility-aware behavior, listen for htmx:ws:reconnect and cancel if needed.
pendingRequestTTL: 30000 // TTL for pending requests in ms
};
return { ...defaults, ...(htmx.config.websockets || {}) };
}
// ========================================
// URL NORMALIZATION
// ========================================
function normalizeWebSocketUrl(url) {
// Already a WebSocket URL
if (url.startsWith('ws://') || url.startsWith('wss://')) {
return url;
}
// Convert http(s):// to ws(s)://
if (url.startsWith('http://')) {
return 'ws://' + url.slice(7);
}
if (url.startsWith('https://')) {
return 'wss://' + url.slice(8);
}
// Relative URL - build absolute ws(s):// URL based on current location
let protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
let host = window.location.host;
if (url.startsWith('//')) {
// Protocol-relative URL
return protocol + url;
}
if (url.startsWith('/')) {
// Absolute path
return protocol + '//' + host + url;
}
// Relative path - resolve against current location
let basePath = window.location.pathname.substring(0, window.location.pathname.lastIndexOf('/') + 1);
return protocol + '//' + host + basePath + url;
}
// ========================================
// CONNECTION REGISTRY
// ========================================
@@ -49,44 +108,52 @@
const connectionRegistry = new Map();
function getOrCreateConnection(url, element) {
if (connectionRegistry.has(url)) {
let entry = connectionRegistry.get(url);
let normalizedUrl = normalizeWebSocketUrl(url);
if (connectionRegistry.has(normalizedUrl)) {
let entry = connectionRegistry.get(normalizedUrl);
entry.refCount++;
entry.elements.add(element);
return entry;
}
// Create entry but DON'T add to registry yet - wait for before:ws:connect
let entry = {
url: normalizedUrl,
socket: null,
refCount: 1,
elements: new Set([element]),
reconnectAttempts: 0,
reconnectTimer: null,
pendingRequests: new Map()
pendingRequests: new Map(),
listeners: {} // Store listener references for proper cleanup
};
connectionRegistry.set(url, entry);
createWebSocket(url, entry);
// Fire cancelable event BEFORE storing in registry
if (!triggerEvent(element, 'htmx:before:ws:connect', { url: normalizedUrl })) {
// Event was cancelled - don't create connection or store entry
return null;
}
// Event passed - now store in registry and create socket
connectionRegistry.set(normalizedUrl, entry);
createWebSocket(normalizedUrl, entry);
return entry;
}
function createWebSocket(url, entry) {
let firstElement = entry.elements.values().next().value;
if (firstElement) {
if (!triggerEvent(firstElement, 'htmx:before:ws:connect', { url })) {
return;
}
}
// Close and remove listeners from old socket
// Close and remove listeners from old socket properly
if (entry.socket) {
let oldSocket = entry.socket;
entry.socket = null;
oldSocket.onopen = null;
oldSocket.onmessage = null;
oldSocket.onclose = null;
oldSocket.onerror = null;
// Remove listeners using stored references
if (entry.listeners.open) oldSocket.removeEventListener('open', entry.listeners.open);
if (entry.listeners.message) oldSocket.removeEventListener('message', entry.listeners.message);
if (entry.listeners.close) oldSocket.removeEventListener('close', entry.listeners.close);
if (entry.listeners.error) oldSocket.removeEventListener('error', entry.listeners.error);
try {
if (oldSocket.readyState === WebSocket.OPEN || oldSocket.readyState === WebSocket.CONNECTING) {
@@ -98,24 +165,30 @@
try {
entry.socket = new WebSocket(url);
entry.socket.addEventListener('open', () => {
// Don't reset reconnectAttempts immediately - allow backoff to persist across quick reconnections
// It will naturally decrease as the connection remains stable
// Create and store listener references
entry.listeners.open = () => {
// Reset reconnect attempts on successful connection
entry.reconnectAttempts = 0;
if (firstElement) {
triggerEvent(firstElement, 'htmx:after:ws:connect', { url, socket: entry.socket });
}
});
};
entry.socket.addEventListener('message', (event) => {
entry.listeners.message = (event) => {
handleMessage(entry, event);
});
};
entry.socket.addEventListener('close', (event) => {
entry.listeners.close = (event) => {
// Check if this socket is still the active one
if (event.target !== entry.socket) return;
if (firstElement) {
triggerEvent(firstElement, 'htmx:ws:close', { url });
triggerEvent(firstElement, 'htmx:ws:close', {
url,
code: event.code,
reason: event.reason
});
}
// Check if entry is still valid (not cleared)
@@ -125,15 +198,23 @@
if (config.reconnect && entry.refCount > 0) {
scheduleReconnect(url, entry);
} else {
cleanupPendingRequests(entry);
connectionRegistry.delete(url);
}
});
};
entry.socket.addEventListener('error', (error) => {
entry.listeners.error = (error) => {
if (firstElement) {
triggerEvent(firstElement, 'htmx:ws:error', { url, error });
}
});
};
// Add listeners
entry.socket.addEventListener('open', entry.listeners.open);
entry.socket.addEventListener('message', entry.listeners.message);
entry.socket.addEventListener('close', entry.listeners.close);
entry.socket.addEventListener('error', entry.listeners.error);
} catch (error) {
if (firstElement) {
triggerEvent(firstElement, 'htmx:ws:error', { url, error });
@@ -144,12 +225,12 @@
function scheduleReconnect(url, entry) {
let config = getConfig();
// Increment attempts before calculating delay for proper exponential backoff
let attempts = entry.reconnectAttempts;
// Increment attempts FIRST, then calculate delay
entry.reconnectAttempts++;
let attempts = entry.reconnectAttempts;
let delay = Math.min(
(config.reconnectDelay || 1000) * Math.pow(2, attempts),
(config.reconnectDelay || 1000) * Math.pow(2, attempts - 1),
config.reconnectMaxDelay || 30000
);
@@ -161,6 +242,7 @@
if (entry.refCount > 0) {
let firstElement = entry.elements.values().next().value;
if (firstElement) {
// attempts now means "this is attempt number N"
triggerEvent(firstElement, 'htmx:ws:reconnect', { url, attempts });
}
createWebSocket(url, entry);
@@ -169,9 +251,12 @@
}
function decrementRef(url, element) {
if (!connectionRegistry.has(url)) return;
// Try both original and normalized URL
let normalizedUrl = normalizeWebSocketUrl(url);
let entry = connectionRegistry.get(url);
if (!connectionRegistry.has(normalizedUrl)) return;
let entry = connectionRegistry.get(normalizedUrl);
entry.elements.delete(element);
entry.refCount--;
@@ -179,10 +264,31 @@
if (entry.reconnectTimer) {
clearTimeout(entry.reconnectTimer);
}
cleanupPendingRequests(entry);
if (entry.socket && entry.socket.readyState === WebSocket.OPEN) {
entry.socket.close();
}
connectionRegistry.delete(url);
connectionRegistry.delete(normalizedUrl);
}
}
// ========================================
// PENDING REQUEST MANAGEMENT
// ========================================
function cleanupPendingRequests(entry) {
entry.pendingRequests.clear();
}
function cleanupExpiredRequests(entry) {
let config = getConfig();
let now = Date.now();
let ttl = config.pendingRequestTTL || 30000;
for (let [requestId, pending] of entry.pendingRequests) {
if (now - pending.timestamp > ttl) {
entry.pendingRequests.delete(requestId);
}
}
}
@@ -190,38 +296,83 @@
// MESSAGE SENDING
// ========================================
// Check if a value looks like a URL (vs a boolean marker like "" or "true")
function looksLikeUrl(value) {
if (!value) return false;
// Check for URL-like patterns: paths, protocols, protocol-relative
return value.startsWith('/') ||
value.startsWith('.') ||
value.startsWith('ws:') ||
value.startsWith('wss:') ||
value.startsWith('http:') ||
value.startsWith('https:') ||
value.startsWith('//');
}
async function sendMessage(element, event) {
// Find connection URL
let url = getWsAttribute(element, 'send');
if (!url) {
// Look for nearest ancestor with hx-ws:connect or hx-ws-connect
let prefix = htmx.config.prefix || '';
let ancestor = element.closest('[' + prefix + 'hx-ws\\:connect],[' + prefix + 'hx-ws-connect]');
if (!looksLikeUrl(url)) {
// Value is empty, "true", or other non-URL marker - look for ancestor connection
let selector = buildWsSelector('connect');
let ancestor = element.closest(selector);
if (ancestor) {
url = getWsAttribute(ancestor, 'connect');
} else {
url = null;
}
}
if (!url) {
console.error('No WebSocket connection found for hx-ws:send element', element);
// Emit error event instead of console.error
triggerEvent(element, 'htmx:wsSendError', {
element,
error: 'No WebSocket connection found for element'
});
return;
}
let entry = connectionRegistry.get(url);
let normalizedUrl = normalizeWebSocketUrl(url);
let entry = connectionRegistry.get(normalizedUrl);
if (!entry || !entry.socket || entry.socket.readyState !== WebSocket.OPEN) {
triggerEvent(element, 'htmx:wsSendError', { url, error: 'Connection not open' });
triggerEvent(element, 'htmx:wsSendError', { url: normalizedUrl, error: 'Connection not open' });
return;
}
// Cleanup expired pending requests periodically
cleanupExpiredRequests(entry);
// Build message
let form = element.form || element.closest('form');
let body = api.collectFormData(element, form, event.submitter);
let valsResult = api.handleHxVals(element, body);
if (valsResult) await valsResult;
// Preserve multi-value form fields (checkboxes, multi-selects)
let values = {};
for (let [key, value] of body) {
values[key] = value;
if (key in values) {
// Convert to array if needed
if (!Array.isArray(values[key])) {
values[key] = [values[key]];
}
values[key].push(value);
} else {
values[key] = value;
}
}
// Build headers object
let headers = {
'HX-Request': 'true',
'HX-Current-URL': window.location.href
};
if (element.id) {
headers['HX-Trigger'] = element.id;
}
let targetAttr = api.attributeValue(element, 'hx-target');
if (targetAttr) {
headers['HX-Target'] = targetAttr;
}
let requestId = generateUUID();
@@ -229,29 +380,30 @@
type: 'request',
request_id: requestId,
event: event.type,
headers: headers,
values: values,
path: url
path: normalizedUrl
};
if (element.id) {
message.id = element.id;
}
// Allow modification via event
let detail = { message, element, url };
// Allow modification via event - use 'data' as documented
let detail = { data: message, element, url: normalizedUrl };
if (!triggerEvent(element, 'htmx:before:ws:send', detail)) {
return;
}
try {
entry.socket.send(JSON.stringify(detail.message));
entry.socket.send(JSON.stringify(detail.data));
// Store pending request for response matching
entry.pendingRequests.set(requestId, { element, timestamp: Date.now() });
triggerEvent(element, 'htmx:after:ws:send', { message: detail.message, url });
triggerEvent(element, 'htmx:after:ws:send', { data: detail.data, url: normalizedUrl });
} catch (error) {
triggerEvent(element, 'htmx:wsSendError', { url, error });
triggerEvent(element, 'htmx:wsSendError', { url: normalizedUrl, error });
}
}
@@ -272,10 +424,10 @@
try {
envelope = JSON.parse(event.data);
} catch (e) {
// Not JSON, emit unknown message event
// Not JSON, emit unknown message event for parse failures
let firstElement = entry.elements.values().next().value;
if (firstElement) {
triggerEvent(firstElement, 'htmx:wsUnknownMessage', { data: event.data });
triggerEvent(firstElement, 'htmx:wsUnknownMessage', { data: event.data, parseError: e });
}
return;
}
@@ -302,46 +454,45 @@
// Route based on channel
if (envelope.channel === 'ui' && envelope.format === 'html') {
handleHtmlMessage(targetElement, envelope);
} else if (envelope.channel && (envelope.channel === 'audio' || envelope.channel === 'json' || envelope.channel === 'binary')) {
// Known custom channel - emit event for extensions to handle
triggerEvent(targetElement, 'htmx:wsMessage', { ...envelope, element: targetElement });
} else {
// Unknown channel/format - emit unknown message event
triggerEvent(targetElement, 'htmx:wsUnknownMessage', { ...envelope, element: targetElement });
// Any non-ui/html message emits htmx:wsMessage for application handling
// This is extensible - apps can handle json, audio, binary, custom channels, etc.
triggerEvent(targetElement, 'htmx:wsMessage', { ...envelope, element: targetElement });
}
triggerEvent(targetElement, 'htmx:after:ws:message', { envelope, element: targetElement });
}
// ========================================
// HTML PARTIAL HANDLING
// HTML PARTIAL HANDLING - Using htmx.swap(ctx)
// ========================================
function handleHtmlMessage(element, envelope) {
let parser = new DOMParser();
let doc = parser.parseFromString(envelope.payload, 'text/html');
let doc = parser.parseFromString(envelope.payload || '', 'text/html');
// Find all hx-partial elements
// Find all hx-partial elements (legacy format)
let partials = doc.querySelectorAll('hx-partial');
if (partials.length === 0) {
// No partials, treat entire payload as content for element's target
let target = resolveTarget(element, envelope.target);
if (target) {
swapContent(target, envelope.payload, element, envelope.swap);
swapWithHtmx(target, envelope.payload, element, envelope.swap);
}
return;
}
partials.forEach(partial => {
// Process each partial
for (let partial of partials) {
let targetId = partial.getAttribute('id');
if (!targetId) return;
if (!targetId) continue;
let target = document.getElementById(targetId);
if (!target) return;
if (!target) continue;
swapContent(target, partial.innerHTML, element);
});
swapWithHtmx(target, partial.innerHTML, element);
}
}
function resolveTarget(element, envelopeTarget) {
@@ -361,54 +512,28 @@
return element;
}
function swapContent(target, content, sourceElement, envelopeSwap) {
function swapWithHtmx(target, content, sourceElement, envelopeSwap) {
// Determine swap style from envelope, element attribute, or default
let swapStyle = envelopeSwap || api.attributeValue(sourceElement, 'hx-swap') || htmx.config.defaultSwap;
// Parse swap style (just get the main style, ignore modifiers for now)
let style = swapStyle.split(' ')[0];
// Create a document fragment from the HTML content
let template = document.createElement('template');
template.innerHTML = content || '';
let fragment = template.content;
// Normalize swap style
style = normalizeSwapStyle(style);
// Use htmx's internal insertContent which handles:
// - All swap styles correctly
// - Processing new content with htmx.process()
// - Preserved elements
// - Auto-focus
// - Scroll handling
let task = {
target: target,
swapSpec: swapStyle, // Can be a string - insertContent will parse it
fragment: fragment
};
// Perform swap
switch (style) {
case 'innerHTML':
target.innerHTML = content;
break;
case 'outerHTML':
target.outerHTML = content;
break;
case 'beforebegin':
target.insertAdjacentHTML('beforebegin', content);
break;
case 'afterbegin':
target.insertAdjacentHTML('afterbegin', content);
break;
case 'beforeend':
target.insertAdjacentHTML('beforeend', content);
break;
case 'afterend':
target.insertAdjacentHTML('afterend', content);
break;
case 'delete':
target.remove();
break;
case 'none':
// Do nothing
break;
default:
target.innerHTML = content;
}
// Process new content with HTMX
htmx.process(target);
}
function normalizeSwapStyle(style) {
return style === 'before' ? 'beforebegin' :
style === 'after' ? 'afterend' :
style === 'prepend' ? 'afterbegin' :
style === 'append' ? 'beforeend' : style;
api.insertContent(task);
}
// ========================================
@@ -433,30 +558,38 @@
element._htmx = element._htmx || {};
element._htmx.wsInitialized = true;
let config = getConfig();
let triggerSpec = api.attributeValue(element, 'hx-trigger');
if (!triggerSpec && config.autoConnect === true) {
// Auto-connect on element initialization
getOrCreateConnection(connectUrl, element);
element._htmx = element._htmx || {};
element._htmx.wsUrl = connectUrl;
} else if (triggerSpec) {
// Connect based on trigger
if (!triggerSpec) {
// No trigger specified - connect immediately (default behavior)
// This is the most common use case: connect when element appears
let entry = getOrCreateConnection(connectUrl, element);
if (entry) {
element._htmx.wsUrl = entry.url;
}
} else {
// Connect based on explicit trigger
// Note: We only support bare event names for connection triggers.
// Modifiers like once, delay, throttle, from, target are NOT supported
// for connection establishment. Use htmx:before:ws:connect event for
// custom connection control logic.
let specs = api.parseTriggerSpecs(triggerSpec);
if (specs.length > 0) {
let spec = specs[0];
if (spec.name === 'load') {
getOrCreateConnection(connectUrl, element);
element._htmx = element._htmx || {};
element._htmx.wsUrl = connectUrl;
// Explicit load trigger - connect immediately
let entry = getOrCreateConnection(connectUrl, element);
if (entry) {
element._htmx.wsUrl = entry.url;
}
} else {
// Set up event listener for other triggers
// Set up event listener for other triggers (bare event name only)
element.addEventListener(spec.name, () => {
if (!element._htmx?.wsUrl) {
getOrCreateConnection(connectUrl, element);
element._htmx = element._htmx || {};
element._htmx.wsUrl = connectUrl;
let entry = getOrCreateConnection(connectUrl, element);
if (entry) {
element._htmx.wsUrl = entry.url;
}
}
}, { once: true });
}
@@ -467,7 +600,9 @@
function initializeSendElement(element) {
if (element._htmx?.wsSendInitialized) return;
let sendUrl = getWsAttribute(element, 'send');
let sendAttr = getWsAttribute(element, 'send');
// Only treat as URL if it looks like one (not "", "true", etc.)
let sendUrl = looksLikeUrl(sendAttr) ? sendAttr : null;
let triggerSpec = api.attributeValue(element, 'hx-trigger');
if (!triggerSpec) {
@@ -477,6 +612,9 @@
'click';
}
// Note: We only support bare event names for send triggers.
// Modifiers like once, delay, throttle, from, target are NOT supported.
// For complex trigger logic, use htmx:before:ws:send to implement custom behavior.
let specs = api.parseTriggerSpecs(triggerSpec);
if (specs.length > 0) {
let spec = specs[0];
@@ -490,9 +628,10 @@
// If this element has its own URL, ensure connection exists
if (sendUrl) {
if (!element._htmx?.wsUrl) {
getOrCreateConnection(sendUrl, element);
element._htmx = element._htmx || {};
element._htmx.wsUrl = sendUrl;
let entry = getOrCreateConnection(sendUrl, element);
if (entry) {
element._htmx.wsUrl = entry.url;
}
}
}
@@ -529,14 +668,16 @@
// Map legacy attributes to new ones (prefer hyphen variant for broader compatibility)
if (element.hasAttribute('ws-connect')) {
let url = element.getAttribute('ws-connect');
if (!element.hasAttribute('hx-ws-connect')) {
element.setAttribute('hx-ws-connect', url);
let hyphenAttr = buildAttrName('-connect');
if (!element.hasAttribute(hyphenAttr)) {
element.setAttribute(hyphenAttr, url);
}
}
if (element.hasAttribute('ws-send')) {
if (!element.hasAttribute('hx-ws-send')) {
element.setAttribute('hx-ws-send', '');
let hyphenAttr = buildAttrName('-send');
if (!element.hasAttribute(hyphenAttr)) {
element.setAttribute(hyphenAttr, '');
}
}
}
@@ -575,8 +716,13 @@
// Process the element itself
processNode(element);
// Process descendants
element.querySelectorAll('[hx-ws\\:connect], [hx-ws-connect], [hx-ws\\:send], [hx-ws-send], [hx-ws], [ws-connect], [ws-send]').forEach(processNode);
// Process descendants - build proper selector respecting prefix
let connectSelector = buildWsSelector('connect');
let sendSelector = buildWsSelector('send');
let plainAttr = buildAttrName('');
let fullSelector = `${connectSelector},${sendSelector},[${plainAttr}],[ws-connect],[ws-send]`;
element.querySelectorAll(fullSelector).forEach(processNode);
},
htmx_before_cleanup: (element) => {
@@ -606,8 +752,8 @@
entry.pendingRequests.clear();
});
},
get: (key) => connectionRegistry.get(key),
has: (key) => connectionRegistry.has(key),
get: (key) => connectionRegistry.get(normalizeWebSocketUrl(key)),
has: (key) => connectionRegistry.has(normalizeWebSocketUrl(key)),
size: connectionRegistry.size
})
};

View File

@@ -112,6 +112,7 @@ var htmx = (() => {
pauseInBackground: false
},
morphIgnore: ["data-htmx-powered"],
morphScanLimit: 10,
noSwap: [204, 304],
implicitInheritance: false
}
@@ -1372,6 +1373,8 @@ var htmx = (() => {
this.__cleanup(child)
}
target.replaceChildren(...fragment.childNodes);
} else if (swapSpec.style === 'textContent') {
target.textContent = fragment.textContent;
} else if (swapSpec.style === 'outerHTML') {
if (parentNode) {
settleTasks = this.__startCSSTransitions(fragment, parentNode);
@@ -1998,26 +2001,35 @@ var htmx = (() => {
}
__findBestMatch(ctx, node, startPoint, endPoint) {
let softMatch = null, nextSibling = node.nextSibling, siblingSoftMatchCount = 0, displaceMatchCount = 0;
let softMatch = null, nextSibling = node.nextSibling, siblingMatchCount = 0, displaceMatchCount = 0, scanLimit = this.config.morphScanLimit;
// Get ID count for this node to prioritize ID-based matches
let newSet = ctx.idMap.get(node), nodeMatchCount = newSet?.size || 0;
let cursor = startPoint;
while (cursor && cursor != endPoint) {
let oldSet = ctx.idMap.get(cursor);
if (this.__isSoftMatch(cursor, node)) {
// Hard match: matching IDs found in both nodes
if (oldSet && newSet && [...oldSet].some(id => newSet.has(id))) return cursor;
if (softMatch === null && !oldSet) {
if (!nodeMatchCount) return cursor;
else softMatch = cursor;
if (!oldSet) {
// Exact match: nodes are identical
if (scanLimit > 0 && cursor.isEqualNode(node)) return cursor;
// Soft match: same tag/type, save as fallback
if (!softMatch) softMatch = cursor;
}
}
// Stop if too many ID elements would be displaced
displaceMatchCount += oldSet?.size || 0;
if (displaceMatchCount > nodeMatchCount) break;
if (softMatch === null && nextSibling && this.__isSoftMatch(cursor, nextSibling)) {
siblingSoftMatchCount++;
// Look ahead: if next siblings match exactly, abort to let them match instead
if (nextSibling && scanLimit > 0 && cursor.isEqualNode(nextSibling)) {
siblingMatchCount++;
nextSibling = nextSibling.nextSibling;
if (siblingSoftMatchCount >= 2) softMatch = undefined;
if (siblingMatchCount >= 2) return null;
}
// Don't move elements containing focus
if (cursor.contains(document.activeElement)) break;
// Stop scanning if limit reached and no IDs to match
if (--scanLimit < 1 && nodeMatchCount === 0) break;
cursor = cursor.nextSibling;
}
return softMatch || null;

View File

@@ -283,7 +283,7 @@
<div class="demo-grid">
<!-- Chat Demo -->
<div class="demo-card" hx-ws:connect="ws://localhost:8080/chat" hx-trigger="load" hx-target="#chat" hx-swap="beforeend">
<div class="demo-card" hx-ws:connect="/chat" hx-trigger="load" hx-target="#chat" hx-swap="beforeend">
<h2>
<span class="status-indicator connected"></span>
Live Chat
@@ -296,7 +296,7 @@
</div>
<!-- Notifications -->
<div class="demo-card" hx-ws:connect="ws://localhost:8080/notifications" hx-trigger="load" hx-target="#notifications" hx-swap="afterbegin">
<div class="demo-card" hx-ws:connect="/notifications" hx-trigger="load" hx-target="#notifications" hx-swap="afterbegin">
<h2>
<span class="status-indicator connected"></span>
Live Notifications
@@ -305,7 +305,7 @@
</div>
<!-- Counter -->
<div class="demo-card" hx-ws:connect="ws://localhost:8080/counter" hx-trigger="load">
<div class="demo-card" hx-ws:connect="/counter" hx-trigger="load">
<h2>
<span class="status-indicator connected"></span>
Shared Counter
@@ -319,7 +319,7 @@
</div>
<!-- Stock Ticker -->
<div class="demo-card" hx-ws:connect="ws://localhost:8080/ticker" hx-trigger="load">
<div class="demo-card" hx-ws:connect="/ticker" hx-trigger="load">
<h2>
<span class="status-indicator connected"></span>
Stock Ticker
@@ -328,7 +328,7 @@
</div>
<!-- Dashboard -->
<div class="demo-card" hx-ws:connect="ws://localhost:8080/dashboard" hx-trigger="load">
<div class="demo-card" hx-ws:connect="/dashboard" hx-trigger="load">
<h2>
<span class="status-indicator connected"></span>
System Dashboard

View File

@@ -76,4 +76,23 @@ describe('hx-swap modifiers', function() {
assert.isAtLeast(elapsed, 100, 'Should wait at least 100ms')
assertTextContentIs('#main', 'Main Content')
})
it('textContent swap replaces text only', async function () {
mockResponse('GET', '/test', '<div><b>Bold</b> Text</div>')
createProcessedHTML('<div id="target" hx-get="/test" hx-swap="textContent"><span>Old</span></div>');
find('#target').click()
await forRequest()
assert.equal(find('#target').textContent, 'Bold Text')
assert.equal(find('#target').innerHTML, 'Bold Text')
})
it('textContent swap preserves target element', async function () {
mockResponse('GET', '/test', '<p>New Text</p>')
createProcessedHTML('<div id="target" class="test" hx-get="/test" hx-swap="textContent">Old</div>');
find('#target').click()
await forRequest()
assert.equal(find('#target').tagName, 'DIV')
assert.equal(find('#target').className, 'test')
assert.equal(find('#target').textContent, 'New Text')
})
})

View File

@@ -45,9 +45,9 @@ describe('hx-ws WebSocket extension', function() {
this.lastSent = data;
}
close() {
close(code = 1000, reason = '') {
this.readyState = MockWebSocket.CLOSED;
this.triggerEvent('close', {});
this.triggerEvent('close', { code, reason });
}
triggerEvent(event, data) {
@@ -58,10 +58,15 @@ describe('hx-ws WebSocket extension', function() {
}
}
// Helper to simulate receiving a message
// Helper to simulate receiving a message (JSON)
simulateMessage(data) {
this.triggerEvent('message', { data: JSON.stringify(data) });
}
// Helper to simulate receiving raw (non-JSON) message
simulateRawMessage(data) {
this.triggerEvent('message', { data: data });
}
};
window.WebSocket = mockWebSocket;
@@ -109,6 +114,18 @@ describe('hx-ws WebSocket extension', function() {
});
});
// Helper to check if URL ends with expected path (accounts for URL normalization)
function urlEndsWith(url, expectedPath) {
return url.endsWith(expectedPath);
}
// Helper to get normalized URL for registry lookups
function getNormalizedUrl(path) {
// The extension normalizes /path to ws://host/path or wss://host/path
let protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return protocol + '//' + window.location.host + path;
}
// ========================================
// 1. CONNECTION LIFECYCLE TESTS
// ========================================
@@ -119,13 +136,13 @@ describe('hx-ws WebSocket extension', function() {
let div = createProcessedHTML('<div hx-ext="ws" hx-ws:connect="/ws/test" hx-trigger="load"></div>');
await htmx.timeout(50);
assert.equal(mockWebSocketInstances.length, 1);
assert.equal(mockWebSocketInstances[0].url, '/ws/test');
assert.isTrue(urlEndsWith(mockWebSocketInstances[0].url, '/ws/test'), 'URL should end with /ws/test');
});
it('does not auto-connect without trigger', async function() {
it('auto-connects by default without explicit trigger', async function() {
let div = createProcessedHTML('<div hx-ws:connect="/ws/test"></div>');
await htmx.timeout(50);
assert.equal(mockWebSocketInstances.length, 0);
assert.equal(mockWebSocketInstances.length, 1, 'Should auto-connect when no trigger is specified');
});
it('connects on custom trigger event', async function() {
@@ -170,9 +187,12 @@ describe('hx-ws WebSocket extension', function() {
await htmx.timeout(50);
// Access internal registry (this assumes the extension exposes it for testing)
// Registry now uses normalized URLs, so we can pass relative path (it normalizes internally)
let registry = htmx.ext.ws.getRegistry?.();
if (registry) {
assert.equal(registry.get('/ws/shared').refCount, 2);
let entry = registry.get('/ws/shared');
assert.isNotNull(entry, 'Should find entry for /ws/shared');
assert.equal(entry.refCount, 2);
}
});
@@ -289,7 +309,7 @@ describe('hx-ws WebSocket extension', function() {
await htmx.timeout(50);
assert.equal(mockWebSocketInstances.length, 1);
assert.equal(mockWebSocketInstances[0].url, '/ws/direct');
assert.isTrue(urlEndsWith(mockWebSocketInstances[0].url, '/ws/direct'), 'URL should end with /ws/direct');
});
it('includes element id in message context', async function() {
@@ -720,26 +740,26 @@ describe('hx-ws WebSocket extension', function() {
assert.isTrue(errorFired);
});
it('emits htmx:wsUnknownMessage for unrecognized format', async function() {
it('emits htmx:wsUnknownMessage for non-JSON data', async function() {
let container = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load"></div>
`);
await htmx.timeout(50);
let unknownFired = false;
container.addEventListener('htmx:wsUnknownMessage', () => {
let receivedData = null;
container.addEventListener('htmx:wsUnknownMessage', (e) => {
unknownFired = true;
receivedData = e.detail.data;
});
let ws = mockWebSocketInstances[0];
ws.simulateMessage({
channel: 'unknown',
format: 'weird',
payload: 'something'
});
// Send raw non-JSON data
ws.simulateRawMessage('not valid json {{{');
await htmx.timeout(20);
assert.isTrue(unknownFired);
assert.equal(receivedData, 'not valid json {{{');
});
});
@@ -749,15 +769,14 @@ describe('hx-ws WebSocket extension', function() {
describe('Configuration', function() {
it('respects htmx.config.websockets.autoConnect', async function() {
htmx.config.websockets = { autoConnect: false };
it('defers connection when explicit trigger is specified', async function() {
let container = createProcessedHTML(`
<div hx-ws:connect="/ws/test"></div>
<div hx-ws:connect="/ws/test" hx-trigger="click"></div>
`);
await htmx.timeout(50);
assert.equal(mockWebSocketInstances.length, 0);
// Should not connect immediately when explicit trigger is set
assert.equal(mockWebSocketInstances.length, 0, 'Should not connect until trigger fires');
});
it('uses custom reconnectDelay from config', async function() {
@@ -889,7 +908,7 @@ describe('hx-ws WebSocket extension', function() {
`);
div.addEventListener('htmx:before:ws:send', (e) => {
e.detail.message.custom = 'added';
e.detail.data.custom = 'added';
});
await htmx.timeout(50);

View File

@@ -1,278 +1,458 @@
+++
title = "htmx Web Socket extension"
title = "htmx WebSocket Extension"
+++
The Web Sockets 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](#migrating-from-previous-versions) 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 a `WebSocket` connection against.
* Prefixes `ws` or `wss` can optionally be specified. If not specified, HTMX defaults to adding 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
or the event specified by [`hx-trigger`])
The WebSocket extension enables real-time, bidirectional communication with
[WebSocket](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_client_applications)
servers directly from HTML. It manages connections efficiently through reference counting, automatic reconnection,
and seamless integration with htmx's swap and event model.
## Installing
The fastest way to install `ws` is to load it via a CDN. Remember to always include the core htmx library before the extension and [enable the extension](#usage).
```HTML
The fastest way to install the WebSocket extension is to load it via a CDN. Include the core htmx library before the extension:
```html
<head>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js" integrity="sha384-/TgkGk7p307TH7EXJDuUlgG3Ce1UVolAOFopFekQkkXihi5u/6OCvVKyz1W+idaz" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx-ext-ws@2.0.4" integrity="sha384-1RwI/nvUSrMRuNj7hX1+27J8XDdCoSLf0EjEyF69nacuWyiJYoQ/j39RT1mSnd2G" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4/dist/htmx.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4/dist/ext/hx-ws.min.js"></script>
</head>
<body hx-ext="ws">
```
An unminified version is also available at https://cdn.jsdelivr.net/npm/htmx-ext-ws/dist/ws.js.
While the CDN approach is simple, you may want to consider [not using CDNs in production](https://blog.wesleyac.com/posts/why-not-javascript-cdn). The next easiest way to install `ws` is to simply copy it into your project. Download the extension from `https://cdn.jsdelivr.net/npm/htmx-ext-ws`, add it to the appropriate directory in your project and include it where necessary with a `<script>` tag.
The extension is automatically active once loaded—no `hx-ext` attribute required.
For npm-style build systems, you can install `ws` via [npm](https://www.npmjs.com/):
For npm-style build systems:
```bash
npm install htmx-ext-ws
npm install htmx.org
```
After installing, you'll need to use appropriate tooling to bundle `node_modules/htmx-ext-ws/dist/ws.js` (or `.min.js`). For example, you might bundle the extension with htmx core from `node_modules/htmx.org/dist/htmx.js` and project-specific code.
If you are using a bundler to manage your javascript (e.g. Webpack, Rollup):
- Install `htmx.org` and `htmx-ext-ws` via npm
- Import both packages to your `index.js`
```JS
import `htmx.org`;
import `htmx-ext-ws`;
Then import in your JavaScript:
```javascript
import 'htmx.org';
import 'htmx.org/dist/ext/hx-ws.js';
```
## Usage
```html
Use these attributes to configure WebSocket behavior:
<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">
| Attribute | Description |
|-----------|-------------|
| `hx-ws:connect="<url>"` | Establishes a WebSocket connection to the specified URL |
| `hx-ws:send` | Sends form data or `hx-vals` to the WebSocket on trigger |
| `hx-ws:send="<url>"` | Like `hx-ws:send` but creates its own connection to the URL |
**JSX-Compatible Variants:** For frameworks that don't support colons in attribute names, use hyphen variants: `hx-ws-connect` and `hx-ws-send`.
### Basic Example
```html
<div hx-ws:connect="/chatroom" hx-target="#messages" hx-swap="beforeend">
<div id="messages"></div>
<form hx-ws:send>
<input name="message" placeholder="Type a message...">
<button type="submit">Send</button>
</form>
</div>
```
### Configuration
This example:
1. Establishes a WebSocket connection to `/chatroom` when the page loads
2. Appends incoming HTML messages to `#messages`
3. Sends form data as JSON when the form is submitted
WebSockets extension support two configuration options:
## URL Normalization
- `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`
WebSocket URLs are automatically normalized:
### Receiving Messages from a WebSocket
| Input | Output (on HTTPS page) |
|-------|------------------------|
| `/ws/chat` | `wss://example.com/ws/chat` |
| `ws://localhost:8080/ws` | `ws://localhost:8080/ws` |
| `https://api.example.com/ws` | `wss://api.example.com/ws` |
| `//cdn.example.com/ws` | `wss://cdn.example.com/ws` |
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](https://htmx.org/attributes/hx-swap-oob/).
This means you can use simple relative paths in most cases, and the extension will construct the correct WebSocket URL.
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.
## Receiving Messages
```html
<!-- will be interpreted as hx-swap-oob="true" by default -->
<form id="form">
...
</form>
<!-- will be appended to #notifications div -->
<div id="notifications" hx-swap-oob="beforeend">
New message received
</div>
<!-- will be swapped using an extension -->
<div id="chat_room" hx-swap-oob="morphdom">
....
</div>
```
### JSON Envelope Format
### Sending Messages to a WebSocket
Messages from the server should be JSON objects:
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
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.
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) {
return retryCount * 1000 // return value in milliseconds
```json
{
"channel": "ui",
"format": "html",
"target": "#notifications",
"swap": "beforeend",
"payload": "<div class='notification'>New message!</div>",
"request_id": "abc-123"
}
```
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.
| Field | Default | Description |
|-------|---------|-------------|
| `channel` | `"ui"` | Message routing channel |
| `format` | `"html"` | Content format |
| `target` | Element's `hx-target` | CSS selector for target element |
| `swap` | Element's `hx-swap` | Swap strategy (innerHTML, beforeend, etc.) |
| `payload` | — | The content to swap |
| `request_id` | — | Matches response to original request |
### Events
**Minimal Example** (using all defaults):
```json
{"payload": "<div>Hello World</div>"}
```
WebSockets extensions exposes a set of events that allow you to observe and customize its behavior.
### Channels
#### Event - `htmx:wsConnecting` {#htmx:wsConnecting}
- **`ui` channel** (default): HTML content is swapped into the target element using htmx's swap pipeline
- **Custom channels**: Emit an `htmx:wsMessage` event for application handling
This event is triggered when a connection to a WebSocket endpoint is being attempted.
```javascript
// Handle custom channel messages
document.addEventListener('htmx:wsMessage', (e) => {
if (e.detail.channel === 'notifications') {
showNotification(e.detail.payload);
}
});
```
##### Details
### Request-Response Matching
* `detail.event.type` - the type of the event (`'connecting'`)
The extension generates a unique `request_id` for each message sent. When the server includes this `request_id` in the response:
#### Event - `htmx:wsOpen` {#htmx:wsOpen}
- Content is swapped into the element that originated the request
- That element's `hx-target` and `hx-swap` attributes are respected
- Enables request-response patterns over WebSocket
This event is triggered when a connection to a WebSocket endpoint has been established.
### Legacy Format (Deprecated)
##### Details
For backward compatibility, the extension also supports `<hx-partial>` elements:
* `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
```html
<hx-partial id="notifications">
<div>New notification</div>
</hx-partial>
```
#### Event - `htmx:wsClose` {#htmx:wsClose}
## Sending Messages
This event is triggered when a connection to a WebSocket endpoint has been closed normally.
You can check if the event was caused by an error by inspecting `detail.event` property.
When an element with `hx-ws:send` is triggered, the extension sends a JSON message:
##### Details
```json
{
"type": "request",
"request_id": "550e8400-e29b-41d4-a716-446655440000",
"event": "submit",
"headers": {
"HX-Request": "true",
"HX-Current-URL": "https://example.com/chat",
"HX-Trigger": "chat-form",
"HX-Target": "#messages"
},
"values": {
"message": "Hello!",
"tags": ["urgent", "public"]
},
"path": "wss://example.com/chatroom",
"id": "chat-form"
}
```
* `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
| Field | Description |
|-------|-------------|
| `type` | Always `"request"` for client-to-server messages |
| `request_id` | Unique ID for request/response matching |
| `event` | DOM event type that triggered the send (e.g., `"submit"`, `"click"`) |
| `headers` | HTMX-style headers for server-side routing |
| `values` | Form data and `hx-vals` (multi-value fields preserved as arrays) |
| `path` | The normalized WebSocket URL |
| `id` | Element ID (only present if element has an `id`) |
#### Event - `htmx:wsError` {#htmx:wsError}
### Forms
This event is triggered when `onerror` event on a socket is raised.
```html
<form hx-ws:send>
<input name="username">
<input name="message">
<button type="submit">Send</button>
</form>
```
##### Details
Form data is collected and sent as the `values` object. Multi-value fields (like checkboxes or multi-selects) are preserved as arrays.
* `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
### Buttons with hx-vals
#### Event - `htmx:wsBeforeMessage` {#htmx:wsBeforeMessage}
```html
<button hx-ws:send hx-vals='{"action": "increment"}'>+1</button>
```
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.
### Modifying Messages Before Send
If the event is cancelled, no further processing will occur.
Use the `htmx:before:ws:send` event to modify or cancel messages:
* `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
```javascript
document.addEventListener('htmx:before:ws:send', (e) => {
// Add authentication token
e.detail.data.headers['Authorization'] = 'Bearer ' + getToken();
// Or cancel the send
if (!isValid(e.detail.data)) {
e.preventDefault();
}
});
```
#### Event - `htmx:wsAfterMessage` {#htmx:wsAfterMessage}
## Configuration
This event is triggered when a message has been completely processed by htmx and all changes have been
settled, similar to `htmx:afterOnLoad`.
Configure the extension via `htmx.config.websockets`:
Cancelling this event has no effect.
```javascript
htmx.config.websockets = {
reconnect: true, // Enable auto-reconnect (default: true)
reconnectDelay: 1000, // Initial reconnect delay in ms (default: 1000)
reconnectMaxDelay: 30000, // Maximum reconnect delay in ms (default: 30000)
reconnectJitter: true, // Add randomization to delays (default: true)
pendingRequestTTL: 30000 // Time-to-live for pending requests in ms (default: 30000)
};
```
* `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
### Reconnection Strategy
#### Event - `htmx:wsConfigSend` {#htmx:wsConfigSend}
The extension uses exponential backoff with optional jitter:
This event is triggered when preparing to send a message from `ws-send` element.
Similarly to [`htmx:configRequest`](https://htmx.org/events#htmx:configRequest), it allows you to modify the message
before sending.
- **Base formula**: `delay = min(reconnectDelay × 2^(attempts-1), reconnectMaxDelay)`
- **Jitter**: Adds ±25% randomization to avoid thundering herd
- **Reset**: Attempts counter resets to 0 on successful connection
If the event is cancelled, no further processing will occur and no messages will be sent.
Example reconnection delays with defaults:
- Attempt 1: ~1000ms
- Attempt 2: ~2000ms
- Attempt 3: ~4000ms
- Attempt 4: ~8000ms
- Attempt 5+: ~30000ms (capped)
##### Details
### Connection Triggers
* `detail.parameters` - the parameters that will be submitted in the request
* `detail.unfilteredParameters` - the parameters that were found before filtering
by [`hx-params`](https://htmx.org/attributes/hx-params)
* `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`](https://htmx.org/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
By default, connections are established immediately when the element is processed:
#### Event - `htmx:wsBeforeSend` {#htmx:wsBeforeSend}
```html
<!-- Connects immediately when element appears (default) -->
<div hx-ws:connect="/ws">
This event is triggered just before sending a message. This includes messages from the queue.
Message can not be modified at this point.
<!-- Explicit load trigger - same behavior as no trigger -->
<div hx-ws:connect="/ws" hx-trigger="load">
If the event is cancelled, the message will be discarded from the queue and not sent.
<!-- Deferred connection - only connects when button is clicked -->
<div hx-ws:connect="/ws" hx-trigger="click from:#connect-btn">
```
##### Details
Use `hx-trigger` when you want to **delay** connection establishment (e.g., wait for user action).
* `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
**Note:** Only bare event names are supported for connection triggers. Modifiers like `delay`, `throttle`, `once` are **not supported**. For complex connection logic, use the `htmx:before:ws:connect` event.
#### Event - `htmx:wsAfterSend` {#htmx:wsAfterSend}
## Events
This event is triggered just after sending a message. This includes messages from the queue.
### Connection Lifecycle
Cancelling the event has no effect.
| Event | Cancelable | Detail | Description |
|-------|------------|--------|-------------|
| `htmx:before:ws:connect` | ✅ | `{url}` | Before establishing connection |
| `htmx:after:ws:connect` | ❌ | `{url, socket}` | After successful connection |
| `htmx:ws:close` | ❌ | `{url, code, reason}` | When connection closes |
| `htmx:ws:error` | ❌ | `{url, error}` | On connection error |
| `htmx:ws:reconnect` | ❌ | `{url, attempts}` | Before reconnection attempt |
##### Details
### Message Events
* `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
| Event | Cancelable | Detail | Description |
|-------|------------|--------|-------------|
| `htmx:before:ws:send` | ✅ | `{data, element, url}` | Before sending (data is modifiable) |
| `htmx:after:ws:send` | ❌ | `{data, url}` | After message sent |
| `htmx:wsSendError` | ❌ | `{element, error}` | When send fails |
| `htmx:before:ws:message` | ✅ | `{envelope, element}` | Before processing received message |
| `htmx:after:ws:message` | ❌ | `{envelope, element}` | After processing received message |
| `htmx:wsMessage` | ❌ | `{channel, format, payload, ...}` | For non-UI channel messages |
| `htmx:wsUnknownMessage` | ❌ | `{data, parseError}` | For non-JSON messages |
#### Socket wrapper
### Event Examples
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:
**Cancel Connection Based on Condition:**
```javascript
document.addEventListener('htmx:before:ws:connect', (e) => {
if (document.hidden) {
e.preventDefault(); // Don't connect in background tab
}
});
```
- `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.
**Handle Custom Messages:**
```javascript
document.addEventListener('htmx:wsMessage', (e) => {
if (e.detail.channel === 'audio') {
playAudioNotification(e.detail.payload);
}
});
```
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.
**Log All WebSocket Activity:**
```javascript
document.addEventListener('htmx:after:ws:connect', (e) => {
console.log('Connected to', e.detail.url);
});
document.addEventListener('htmx:ws:close', (e) => {
console.log('Disconnected from', e.detail.url, 'code:', e.detail.code);
});
```
### Testing with the Demo Server
## Connection Management
Htmx includes a demo WebSockets server written in Node.js that will help you to see WebSockets in action, and begin
bootstrapping your own WebSockets code. It is located in the /test/ws-sse folder of
the [`htmx-extensions`](https://github.com/bigskysoftware/htmx-extensions) repository. Look at /test/ws-sse/README.md
for instructions on running and using the test server.
### Reference Counting
### Migrating from Previous Versions
Multiple elements can share a single WebSocket connection:
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:
```html
<div hx-ws:connect="/notifications" id="notif-1">
<!-- Uses connection to /notifications -->
</div>
<div hx-ws:connect="/notifications" id="notif-2">
<!-- Shares the same connection -->
</div>
```
| 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 |
When all elements using a connection are removed from the DOM, the connection is automatically closed.
### Element Cleanup
When elements are removed (e.g., via htmx swap), the extension:
1. Decrements the reference count for the connection
2. Removes event listeners from the element
3. Closes the WebSocket if no elements remain
This happens automatically through htmx's element cleanup lifecycle.
## HTML Swapping
When a `channel: "ui"` message arrives, the extension uses htmx's internal `insertContent` API, which provides:
- All swap styles (`innerHTML`, `outerHTML`, `beforebegin`, `afterend`, `beforeend`, `afterbegin`, `delete`, `none`)
- Preserved elements (`hx-preserve`)
- Auto-focus handling
- Scroll handling
- Proper cleanup of removed elements
- `htmx.process()` called on newly inserted content
### Target Resolution
Target is determined in this order:
1. `target` field in the message envelope
2. `hx-target` attribute on the element that sent the request (if `request_id` matches)
3. `hx-target` attribute on the connection element
4. The connection element itself
### Swap Strategy
Swap strategy is determined in this order:
1. `swap` field in the message envelope
2. `hx-swap` attribute on the target element
3. `htmx.config.defaultSwap` (default: `innerHTML`)
## Examples
### Live Chat
```html
<div hx-ws:connect="/chat">
<div id="messages" hx-target="this" hx-swap="beforeend"></div>
<form hx-ws:send>
<input name="message" placeholder="Message..." autocomplete="off">
<button type="submit">Send</button>
</form>
</div>
```
Server sends:
```json
{"payload": "<div class='message'><b>User:</b> Hello!</div>"}
```
### Real-Time Notifications
```html
<div hx-ws:connect="/notifications"
hx-target="#notification-list"
hx-swap="afterbegin">
<div id="notification-list"></div>
</div>
```
### Interactive Counter
```html
<div hx-ws:connect="/counter">
<div id="count" hx-target="this">0</div>
<button hx-ws:send hx-vals='{"action":"increment"}'>+</button>
<button hx-ws:send hx-vals='{"action":"decrement"}'>-</button>
</div>
```
### Multiple Widgets Sharing Connection
```html
<div hx-ws:connect="/dashboard">
<div id="cpu-usage">--</div>
<div id="memory-usage">--</div>
<div id="disk-usage">--</div>
</div>
```
Server sends targeted updates:
```json
{"target": "#cpu-usage", "payload": "<span>45%</span>"}
{"target": "#memory-usage", "payload": "<span>2.3 GB</span>"}
```
## Migrating from Previous Versions
### Attribute Changes
| Old (htmx 2.x) | New (htmx 4.x) | Notes |
|----------------|----------------|-------|
| `ws-connect="<url>"` | `hx-ws:connect="<url>"` | Or `hx-ws-connect` for JSX |
| `ws-send` | `hx-ws:send` | Or `hx-ws-send` for JSX |
| `hx-ext="ws"` | Not required | Extension auto-registers when loaded |
The old `ws-connect` and `ws-send` attributes still work but emit a deprecation warning.
### Event Changes
| Old Event | New Event | Notes |
|-----------|-----------|-------|
| `htmx:wsConnecting` | — | Removed |
| `htmx:wsOpen` | `htmx:after:ws:connect` | Different detail structure |
| `htmx:wsClose` | `htmx:ws:close` | Now includes `code` and `reason` |
| `htmx:wsError` | `htmx:ws:error` | Similar |
| `htmx:wsBeforeMessage` | `htmx:before:ws:message` | Different detail structure |
| `htmx:wsAfterMessage` | `htmx:after:ws:message` | Different detail structure |
| `htmx:wsConfigSend` | `htmx:before:ws:send` | Modify `e.detail.data` instead |
| `htmx:wsBeforeSend` | `htmx:before:ws:send` | Combined into one event |
| `htmx:wsAfterSend` | `htmx:after:ws:send` | Similar |
### Configuration Changes
| Old | New |
|-----|-----|
| `htmx.config.wsReconnectDelay` | `htmx.config.websockets.reconnectDelay` |
| `createWebSocket` option | Not supported (use events) |
| `wsBinaryType` option | Not supported |
### Message Format Changes
**Send payload** now includes `type`, `request_id`, `event`, and structured `headers` object instead of `HEADERS` string.
**Receive format** now expects JSON envelope with `channel`, `format`, `target`, `swap`, `payload` fields instead of raw HTML or `hx-swap-oob`.
### Socket Wrapper Removed
The `socketWrapper` object is no longer exposed in events. Use the standard WebSocket events and the extension's event system instead.

View File

@@ -47,7 +47,25 @@ This approach minimizes DOM changes, which helps preserve:
## Configuration Options
htmx provides three configuration options to control morphing behavior:
htmx provides four configuration options to control morphing behavior:
### `htmx.config.morphScanLimit`
A number that limits how many siblings to scan when looking for matching elements during morphing. The default is `10`.
```javascript
// Increase scan limit for large lists
htmx.config.morphScanLimit = 100;
```
**How it works:** When morphing tries to match an element from the new content with an element in the old content, it scans through siblings to find exact matches. This limit prevents excessive scanning in very large DOM trees.
**Important:** Elements with matching IDs will always be found regardless of the scan limit, as ID-based matches are prioritized and continue scanning even after the limit is reached.
**Use cases:**
- **Large lists**: Increase the limit when morph accuracy is needed with long lists of items without IDs
- **Performance tuning**: Decrease the limit to improve performance if morphing is slow
- **Default behavior**: Most applications don't need to change this value
### `htmx.config.morphIgnore`

View File

@@ -194,6 +194,7 @@ listed below:
| `htmx.config.extensions` | defaults to `''`, a comma-separated list of extension names to load (e.g., `'preload,optimistic'`) |
| `htmx.config.sse` | configuration for Server-Sent Events (SSE) streams. An object with the following properties: `reconnect` (default: `false`), `reconnectMaxAttempts` (default: `10`), `reconnectDelay` (default: `500`ms), `reconnectMaxDelay` (default: `60000`ms), `reconnectJitter` (default: `0.3`), `pauseInBackground` (default: `false`) |
| `htmx.config.morphIgnore` | defaults to `["data-htmx-powered"]`, array of attribute names to ignore when morphing elements (see [Morphing](@/morphing.md)) |
| `htmx.config.morphScanLimit` | defaults to `10`, limits how many siblings to scan when matching elements during morphing. Increase for better accuracy with long lists without IDs (see [Morphing](@/morphing.md)) |
| `htmx.config.morphSkip` | defaults to `undefined`, CSS selector for elements that should be completely skipped during morphing (see [Morphing](@/morphing.md)) |
| `htmx.config.morphSkipChildren` | defaults to `undefined`, CSS selector for elements whose children should not be morphed (see [Morphing](@/morphing.md)) |
| `htmx.config.noSwap` | defaults to `[204, 304]`, array of HTTP status codes that should not trigger a swap |