mirror of
https://github.com/bigskysoftware/htmx.git
synced 2026-01-25 05:06:13 +00:00
Merge branch 'four' into feature/settle-restoration
# Conflicts: # src/htmx.js
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
424
src/ext/hx-ws.js
424
src/ext/hx-ws.js
@@ -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
|
||||
})
|
||||
};
|
||||
|
||||
26
src/htmx.js
26
src/htmx.js
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user