From 56e6810284ab45227f675b62b2aaed704416e79a Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Sat, 20 Dec 2025 07:39:09 +1300 Subject: [PATCH 1/3] add textContent swap style (#3593) Co-authored-by: MichaelWest22 --- src/htmx.js | 2 ++ test/tests/attributes/hx-swap.js | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/htmx.js b/src/htmx.js index a241c7d6..fed72684 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -1390,6 +1390,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) { this.__captureCSSTransitions(task, parentNode); diff --git a/test/tests/attributes/hx-swap.js b/test/tests/attributes/hx-swap.js index 9d3b0898..97234d84 100644 --- a/test/tests/attributes/hx-swap.js +++ b/test/tests/attributes/hx-swap.js @@ -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', '
Bold Text
') + createProcessedHTML('
Old
'); + 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', '

New Text

') + createProcessedHTML('
Old
'); + find('#target').click() + await forRequest() + assert.equal(find('#target').tagName, 'DIV') + assert.equal(find('#target').className, 'test') + assert.equal(find('#target').textContent, 'New Text') + }) }) From 37cf0e8c6c866671fd8e457d57f6bab0e5548eb5 Mon Sep 17 00:00:00 2001 From: Stu Kennedy Date: Fri, 19 Dec 2025 18:42:36 +0000 Subject: [PATCH 2/3] WebSocket Extension (hx-ws) Improvements (#3592) * refactor: Enhance WebSocket extension with URL normalization, improved request management, and refined message handling for better reliability and clarity. feat: Add manual WebSocket server script and enhance WebSocket documentation with detailed message formats and connection management improvements. feat: Include event type in WebSocket messages and update documentation for message format * refactor: Update WebSocket extension to connect immediately by default, enhance documentation on connection triggers, and improve message handling examples. * feat: Introduce URL validation for WebSocket send attributes to ensure proper connection handling and prevent non-URL markers from being processed. --- dev/four/design/WEBSOCKETS.md | 132 ++++++-- package.json | 5 +- src/ext/hx-ws.js | 424 ++++++++++++++++--------- test/manual/ws.html | 10 +- test/tests/ext/hx-ws.js | 61 ++-- www/content/extensions/ws.md | 568 ++++++++++++++++++++++------------ 6 files changed, 808 insertions(+), 392 deletions(-) diff --git a/dev/four/design/WEBSOCKETS.md b/dev/four/design/WEBSOCKETS.md index 19d1359f..bb88826a 100644 --- a/dev/four/design/WEBSOCKETS.md +++ b/dev/four/design/WEBSOCKETS.md @@ -28,13 +28,14 @@ The extension maintains a global connection registry that ensures: Establishes a WebSocket connection to the specified URL. ```html -
+
``` **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 - ``` @@ -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 + +
+
+
+``` + +### 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 + +
+ + +
+``` + +**Important:** Only **bare event names** are supported for connection triggers. Modifiers like `once`, `delay`, `throttle`, `target`, `from`, `revealed`, and `intersect` are **not supported**. + +```html + +
+
+``` + +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 -
@@ -274,8 +347,7 @@ The initial design concept proposed: ### Real-Time Notifications ```html -
@@ -284,7 +356,7 @@ The initial design concept proposed: ### Interactive Controls ```html -
+
0
diff --git a/package.json b/package.json index 0623f3b8..6991c961 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/ext/hx-ws.js b/src/ext/hx-ws.js index 16f1a88c..f4cf6e5d 100644 --- a/src/ext/hx-ws.js +++ b/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 }) }; diff --git a/test/manual/ws.html b/test/manual/ws.html index 5a9b97d9..bed7e0b3 100644 --- a/test/manual/ws.html +++ b/test/manual/ws.html @@ -283,7 +283,7 @@
-
+

Live Chat @@ -296,7 +296,7 @@

-
+

Live Notifications @@ -305,7 +305,7 @@

-
+

Shared Counter @@ -319,7 +319,7 @@

-
+

Stock Ticker @@ -328,7 +328,7 @@

-
+

System Dashboard diff --git a/test/tests/ext/hx-ws.js b/test/tests/ext/hx-ws.js index f1cb72f2..3125b36d 100644 --- a/test/tests/ext/hx-ws.js +++ b/test/tests/ext/hx-ws.js @@ -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('
'); 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('
'); 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(`
`); 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(` -
+
`); 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); diff --git a/www/content/extensions/ws.md b/www/content/extensions/ws.md index 6e757fd1..5274901e 100644 --- a/www/content/extensions/ws.md +++ b/www/content/extensions/ws.md @@ -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=""` or `ws-connect=":"` - 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 - - + + - ``` -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 `