diff --git a/src/editors/jetbrains/htmx.web-types.json b/src/editors/jetbrains/htmx.web-types.json index 64af559bd..cfefde7fc 100644 --- a/src/editors/jetbrains/htmx.web-types.json +++ b/src/editors/jetbrains/htmx.web-types.json @@ -566,18 +566,18 @@ "doc-url": "https://four.htmx.org/extensions/hx-sse#htmxafterssemessage" }, { - "name": "before:ws:connection", + "name": "ws:before:connection", "description": "Fires before a WebSocket connection attempt. `detail.connection` can be modified; set `cancelled` or cancel the event to stop connecting.", - "doc-url": "https://four.htmx.org/extensions/hx-ws#htmxbeforewsconnection" + "doc-url": "https://four.htmx.org/extensions/hx-ws#htmxwsbeforeconnection" }, { - "name": "after:ws:connection", + "name": "ws:after:connection", "description": "Fires after a successful WebSocket connection. `detail.connection` describes the connection.", - "doc-url": "https://four.htmx.org/extensions/hx-ws#htmxafterwsconnection" + "doc-url": "https://four.htmx.org/extensions/hx-ws#htmxwsafterconnection" }, { "name": "ws:close", - "description": "Fires when a WebSocket connection closes. `detail.connection`, `detail.reason`, and `detail.code` describe the close.", + "description": "Fires when a WebSocket connection closes. `detail.connection`, `detail.reason`, and `detail.code` describe the close. Codes in `detail.connection.config.reconnectCodes` reconnect.", "doc-url": "https://four.htmx.org/extensions/hx-ws#htmxwsclose" }, { @@ -586,24 +586,24 @@ "doc-url": "https://four.htmx.org/extensions/hx-ws#htmxwserror" }, { - "name": "before:ws:request", - "description": "Fires before sending a WebSocket message. `detail.headers` and `detail.body` are modifiable. Cancel to skip sending.", - "doc-url": "https://four.htmx.org/extensions/hx-ws#htmxbeforewsrequest" + "name": "ws:before:message:outgoing", + "description": "Fires before sending a WebSocket message. Modify `detail.message`, use `detail.waitUntil()` to delay sending, or set `detail.cancelled` to cancel.", + "doc-url": "https://four.htmx.org/extensions/hx-ws#htmxwsbeforemessageoutgoing" }, { - "name": "after:ws:request", - "description": "Fires after a WebSocket message is sent. `detail.headers` and `detail.body` contain the sent payload.", - "doc-url": "https://four.htmx.org/extensions/hx-ws#htmxafterwsrequest" + "name": "ws:after:message:outgoing", + "description": "Fires after a WebSocket message is sent. `detail.message.data` is the value passed to `WebSocket.send()`.", + "doc-url": "https://four.htmx.org/extensions/hx-ws#htmxwsaftermessageoutgoing" }, { - "name": "before:ws:message", - "description": "Fires before a WebSocket message is processed. `detail.message.text`, `detail.message.json`, and `detail.message.cancelled` are available.", - "doc-url": "https://four.htmx.org/extensions/hx-ws#htmxbeforewsmessage" + "name": "ws:before:message:incoming", + "description": "Fires before an incoming WebSocket message is processed. Convert `detail.message`, use `detail.waitUntil()` to delay processing, or set `detail.cancelled` to cancel. Associated messages fire from their sending element.", + "doc-url": "https://four.htmx.org/extensions/hx-ws#htmxwsbeforemessageincoming" }, { - "name": "after:ws:message", - "description": "Fires after a WebSocket message is processed. `detail.message.text` and `detail.message.json` are available.", - "doc-url": "https://four.htmx.org/extensions/hx-ws#htmxafterwsmessage" + "name": "ws:after:message:incoming", + "description": "Fires after an incoming WebSocket message is processed. `detail.message.data` preserves the native data; `text()`, `json()`, `blob()`, and `arrayBuffer()` convert it. Associated messages fire from their sending element.", + "doc-url": "https://four.htmx.org/extensions/hx-ws#htmxwsaftermessageincoming" }, { "name": "download:start", diff --git a/src/ext/hx-ws.js b/src/ext/hx-ws.js index 3138c6bad..67ed6194f 100644 --- a/src/ext/hx-ws.js +++ b/src/ext/hx-ws.js @@ -1,4 +1,5 @@ (() => { + const MESSAGE_ID_MAX_AGE = 30000; let api; // Build a CSS selector for querySelectorAll, respecting prefix + metaCharacter @@ -15,29 +16,28 @@ // ======================================== function getConfig(element) { - const defaults = { + let hxConfig = api.HCON.parse(api.attributeValue(element, 'hx-config')).ws || {}; + + return { reconnect: true, + reconnectCodes: [ + 1001, // Going Away + 1005, // No Status Received + 1006, // Abnormal Closure + 1011, // Internal Error + 1012, // Service Restart + 1013, // Try Again Later + 1014 // Bad Gateway + ], reconnectDelay: 500, reconnectMaxDelay: 60000, reconnectMaxAttempts: Infinity, reconnectJitter: 0.3, pauseOnBackground: true, - pendingRequestTTL: 30000 + maxOutgoingMessagesQueueSize: 100, + ...htmx.config.ws, // global defaults + ...hxConfig // hx-config overrides }; - let global = htmx.config.ws || {}; - let perElement = {}; - if (element) { - let ctx = api.createRequestContext(element, new CustomEvent('_')); - perElement = ctx.request.ws || {}; - } - let merged = { ...defaults, ...global, ...perElement }; - - // Backwards compat: boolean reconnectJitter (old API used true/false) - if (typeof merged.reconnectJitter === 'boolean') { - merged.reconnectJitter = merged.reconnectJitter ? 0.3 : 0; - } - - return merged; } // ======================================== @@ -96,13 +96,16 @@ socket: null, attempt: 0, timer: null, - pendingRequests: new Map(), + pendingMessages: new Map(), + queue: [], + receiving: Promise.resolve(), + sending: Promise.resolve(), abortController: null, visibilityHandler: null, cancelled: false }; - if (!api.triggerHtmxEvent(element, 'htmx:before:ws:connection', {connection}) || connection.cancelled) { + if (!api.triggerHtmxEvent(element, 'htmx:ws:before:connection', {connection}) || connection.cancelled) { api.triggerHtmxEvent(element, 'htmx:ws:close', { connection, reason: 'cancelled', code: null }); @@ -148,7 +151,8 @@ if (connection.abortController) { connection.abortController.abort(); } - connection.pendingRequests.clear(); + connection.pendingMessages.clear(); + connection.queue.length = 0; if (connection.socket) { try { if (connection.socket.readyState === WebSocket.OPEN || connection.socket.readyState === WebSocket.CONNECTING) { @@ -187,17 +191,23 @@ connection.socket.addEventListener('open', () => { let elt = findConnectedElement(url); if (elt) { - api.triggerHtmxEvent(elt, 'htmx:after:ws:connection', {connection}); + api.triggerHtmxEvent(elt, 'htmx:ws:after:connection', {connection}); } else { // Element was removed while connecting — orphaned socket cleanupOrphanedConnection(url, connection); return; } connection.attempt = 0; + flushQueue(connection); }, opts); connection.socket.addEventListener('message', (event) => { - handleMessage(connection, event); + connection.receiving = connection.receiving + .then(() => handleMessage(connection, event)) + .catch(error => { + let elt = findConnectedElement(connection.url); + if (elt) api.triggerHtmxEvent(elt, 'htmx:ws:error', { url: connection.url, error }); + }); }, opts); connection.socket.addEventListener('close', (event) => { @@ -213,7 +223,7 @@ let config = connection.config; if (config.pauseOnBackground && document.hidden) return; - if (config.reconnect && findConnectedElement(url)) { + if (config.reconnect && config.reconnectCodes.includes(event.code) && findConnectedElement(url)) { scheduleReconnect(url, connection); } else { // No element or reconnect disabled — full cleanup @@ -259,7 +269,7 @@ let elt = findConnectedElement(url); if (elt) { connection.cancelled = false; - if (!api.triggerHtmxEvent(elt, 'htmx:before:ws:connection', {connection}) || connection.cancelled) { + if (!api.triggerHtmxEvent(elt, 'htmx:ws:before:connection', {connection}) || connection.cancelled) { api.triggerHtmxEvent(elt, 'htmx:ws:close', { connection, reason: 'cancelled', code: null }); @@ -292,7 +302,8 @@ if (connection.abortController) { connection.abortController.abort(); } - connection.pendingRequests.clear(); + connection.pendingMessages.clear(); + connection.queue.length = 0; api.triggerHtmxEvent(element, 'htmx:ws:close', { connection, reason: 'removed', code: null }); @@ -303,25 +314,42 @@ } // ======================================== - // PENDING REQUEST MANAGEMENT // ======================================== + // PENDING MESSAGE MANAGEMENT + // ======================================== - function cleanupExpiredRequests(connection) { - let config = connection.config; + function cleanupExpiredMessages(connection) { let now = Date.now(); - let timeout = config.pendingRequestTTL || 30000; - for (let [requestId, pending] of connection.pendingRequests) { - if (now - pending.timestamp > timeout) { - connection.pendingRequests.delete(requestId); + for (let [messageId, pending] of connection.pendingMessages) { + if (now - pending.timestamp > MESSAGE_ID_MAX_AGE) { + connection.pendingMessages.delete(messageId); } } } // ======================================== - // REQUESTS + // MESSAGES // ======================================== - async function sendRequest(element, event) { + function transmitMessage(connection, element, message) { + try { + connection.socket.send(message.data); + let messageId = message.headers['HX-Message-ID']; + if (messageId) connection.pendingMessages.set(messageId, { element, timestamp: Date.now() }); + api.triggerHtmxEvent(element, 'htmx:ws:after:message:outgoing', {message}); + } catch (error) { + api.triggerHtmxEvent(element, 'htmx:ws:error', { url: connection.url, error }); + } + } + + function flushQueue(connection) { + while (connection.queue.length && connection.socket?.readyState === WebSocket.OPEN) { + let queuedMessage = connection.queue.shift(); + transmitMessage(connection, queuedMessage.element, queuedMessage.message); + } + } + + async function sendMessage(element, event) { // hx-ws:send="/url" creates its own connection; hx-ws:send (no value) uses ancestor's let sendAttr = api.attributeValue(element, 'hx-ws:send'); let url = (sendAttr && sendAttr !== 'true') ? sendAttr : null; @@ -342,143 +370,197 @@ let normalizedUrl = normalizeWebSocketUrl(url); let connection = connections.get(normalizedUrl); - // Wait for socket to open if still connecting - if (connection && connection.socket && connection.socket.readyState === WebSocket.CONNECTING) { - await new Promise(resolve => { - connection.socket.addEventListener('open', resolve, { once: true }); - connection.socket.addEventListener('close', resolve, { once: true }); - connection.socket.addEventListener('error', resolve, { once: true }); - }); - } - - if (!connection || !connection.socket || connection.socket.readyState !== WebSocket.OPEN) { + if (!connection) { api.triggerHtmxEvent(element, 'htmx:ws:error', { url: normalizedUrl, error: 'Connection not open' }); return; } - // [Correlation] Cleanup expired pending requests periodically - cleanupExpiredRequests(connection); + // [Correlation] Cleanup expired pending messages periodically + cleanupExpiredMessages(connection); // Build headers using core's request context (same as HTTP requests) let ctx = api.createRequestContext(element, event); let headers = {...ctx.request.headers}; delete headers['Accept']; - // [Correlation] Add request ID as a header - let requestId = crypto.randomUUID(); - headers['HX-Request-ID'] = requestId; + // [Correlation] Add message ID as a header + headers['HX-Message-ID'] = crypto.randomUUID(); - // Build body from form data + // Build outgoing values from form data. let form = element.form || element.closest('form'); let formData = api.collectFormData(element, form, event.submitter); // Preserve multi-value form fields (checkboxes, multi-selects) - let body = {}; + let values = {}; for (let [key, value] of formData) { - if (key in body) { - body[key] = [].concat(body[key], value); + if (key in values) { + values[key] = [].concat(values[key], value); } else { - body[key] = value; + values[key] = value; } } // Merge hx-vals after serialization to preserve JS types (numbers, booleans) - let valsResult = api.getAttributeObject(element, 'hx-vals', obj => Object.assign(body, obj)); - if (valsResult) await valsResult; + let hxValsResult = api.getAttributeObject(element, 'hx-vals', obj => Object.assign(values, obj)); - let detail = { headers, body }; - if (!api.triggerHtmxEvent(element, 'htmx:before:ws:request', detail)) { - return; - } + let outgoingMessage = connection.sending.then(async () => { + if (hxValsResult) await hxValsResult; + delete values.headers; - try { - connection.socket.send(JSON.stringify(detail)); + let pendingWork = []; + let message = { + headers, + values, + data: undefined + }; + let detail = { + message, + cancelled: false, + waitUntil(promise) { + pendingWork.push(Promise.resolve(promise)); + } + }; + let shouldSend = api.triggerHtmxEvent(element, 'htmx:ws:before:message:outgoing', detail); - // [Correlation] Store pending request for response matching - connection.pendingRequests.set(requestId, { element, timestamp: Date.now() }); + try { + await Promise.all(pendingWork); + if (!shouldSend || detail.cancelled) return; - api.triggerHtmxEvent(element, 'htmx:after:ws:request', detail); - } catch (error) { - api.triggerHtmxEvent(element, 'htmx:ws:error', { url: normalizedUrl, error }); - } + message.data ??= JSON.stringify({ ...message.values, headers: message.headers }); + if (connections.get(normalizedUrl) !== connection) { + api.triggerHtmxEvent(element, 'htmx:ws:error', { url: normalizedUrl, error: 'Connection closed' }); + return; + } + + if (connection.socket?.readyState === WebSocket.OPEN) { + transmitMessage(connection, element, message); + } else if (connection.queue.length >= connection.config.maxOutgoingMessagesQueueSize) { + api.triggerHtmxEvent(element, 'htmx:ws:error', { + url: normalizedUrl, + error: 'Outgoing messages queue is full' + }); + } else { + connection.queue.push({element, message}); + } + } catch (error) { + api.triggerHtmxEvent(element, 'htmx:ws:error', { url: normalizedUrl, error }); + } + }); + connection.sending = outgoingMessage.catch(() => {}); + await outgoingMessage; } // ======================================== // MESSAGE RECEIVING & ROUTING // ======================================== - function handleMessage(connection, event) { + async function handleMessage(connection, event) { + let data = event.data; + let textResult; + let jsonResult; + let arrayBufferResult; + let blobResult; + let pendingWork = []; + let message = { + data, + type: typeof data === 'string' ? 'text' : 'binary', + text() { + return textResult ??= typeof data === 'string' + ? Promise.resolve(data) + : data instanceof Blob + ? data.text() + : Promise.resolve(new TextDecoder().decode(data)); + }, + json() { + return jsonResult ??= message.text().then(JSON.parse); + }, + arrayBuffer() { + return arrayBufferResult ??= data instanceof ArrayBuffer + ? Promise.resolve(data) + : data instanceof Blob + ? data.arrayBuffer() + : Promise.resolve(new TextEncoder().encode(data).buffer); + }, + blob() { + return blobResult ??= data instanceof Blob + ? Promise.resolve(data) + : Promise.resolve(new Blob([data])); + } + }; + let json = null; - try { - json = JSON.parse(event.data); - } catch (e) { - // Not JSON - will be treated as raw HTML below - } - - // [Correlation] Cleanup expired pending requests on every message - cleanupExpiredRequests(connection); - - // [Correlation] Match response to originating element, or fall back to first subscriber - let connectionElement = null; - let requestId = json?.['HX-Request-ID'] || json?.request_id; - if (requestId && connection.pendingRequests.has(requestId)) { - connectionElement = connection.pendingRequests.get(requestId).element; - connection.pendingRequests.delete(requestId); - // If the correlated element has been removed from the DOM, fall back - if (!connectionElement.isConnected) { - connectionElement = findConnectedElement(connection.url); + if (message.type === 'text') { + try { + json = await message.json(); + } catch (e) { + // Non-JSON text is treated as raw HTML. } - } else { - connectionElement = findConnectedElement(connection.url); } - if (!connectionElement) { + // [Correlation] Cleanup expired pending messages on every message + cleanupExpiredMessages(connection); + + let messageId = json?.headers?.['HX-Message-ID']; + let pending = connection.pendingMessages.get(messageId); + if (pending) connection.pendingMessages.delete(messageId); + + // Route associated incoming messages through their sender. + let element = pending?.element; + if (!element?.isConnected) element = findConnectedElement(connection.url); + + if (!element) { // No element in DOM for this connection — orphan cleanup cleanupOrphanedConnection(connection.url, connection); return; } let detail = { - message: { text: event.data, json, cancelled: false } + message, + cancelled: false, + waitUntil(promise) { + pendingWork.push(Promise.resolve(promise)); + } }; + let shouldProcess = api.triggerHtmxEvent(element, 'htmx:ws:before:message:incoming', detail); - if (!api.triggerHtmxEvent(connectionElement, 'htmx:before:ws:message', detail) || detail.message.cancelled) { - return; - } + await Promise.all(pendingWork); + if (!shouldProcess || detail.cancelled) return; // JSON with 'content' or 'payload' field: swap the HTML // Raw (non-JSON) string: swap the entire string as HTML // JSON without 'content'/'payload': data-only message, no swap (handle via events) let html; - if (detail.message.json) { - if (detail.message.json.content !== undefined) { - html = detail.message.json.content; - } else if (detail.message.json.payload !== undefined) { - html = detail.message.json.payload; // backwards compat + if (json) { + if (json.content !== undefined) { + html = json.content; + } else if (json.payload !== undefined) { + html = json.payload; // backwards compat // Warn once per connection (not on every message) if (!connection._payloadWarnFired) { console.warn('htmx: [hx-ws] json.payload is deprecated; use json.content instead'); connection._payloadWarnFired = true; } } - } else { - html = detail.message.text; + } else if (message.type === 'text') { + html = await message.text(); } if (html != null) { - let target = detail.message.json?.target || api.attributeValue(connectionElement, 'hx-target'); - let swap = detail.message.json?.swap || api.attributeValue(connectionElement, 'hx-swap'); - - htmx.swap({ - sourceElement: connectionElement, - target: target || connectionElement, - swap: swap || (target ? htmx.config.defaultSwap : 'none'), + let target = json?.target || api.attributeValue(element, 'hx-target'); + let swap = json?.swap || api.attributeValue(element, 'hx-swap') || htmx.config.defaultSwap; + if (!/(?:^|\s)swapEmpty(?::(?:true|false))?(?=\s|$)/.test(swap)) swap += ' swapEmpty:false'; + + await htmx.swap({ + sourceElement: element, + target: target || element, + swap, + select: json?.select ?? api.attributeValue(element, 'hx-select'), + selectOOB: api.attributeValue(element, 'hx-select-oob'), text: html, transition: false }); } - delete detail.message.cancelled; - api.triggerHtmxEvent(connectionElement, 'htmx:after:ws:message', detail); + api.triggerHtmxEvent(element, 'htmx:ws:after:message:incoming', {message}); } // ======================================== @@ -526,7 +608,7 @@ element._htmx.ws.url = connection.url; } } - await sendRequest(element, evt); + await sendMessage(element, evt); }); element._htmx.ws.sendInitialized = true; } @@ -636,7 +718,8 @@ if (connection.socket) { connection.socket.close(); } - connection.pendingRequests.clear(); + connection.pendingMessages.clear(); + connection.queue.length = 0; }); }, get: (key) => connections.get(normalizeWebSocketUrl(key)), diff --git a/src/scripts/upgrade-check.py b/src/scripts/upgrade-check.py index a91f939c8..33eae8612 100755 --- a/src/scripts/upgrade-check.py +++ b/src/scripts/upgrade-check.py @@ -90,13 +90,13 @@ } WS_EVENT_RENAMES = { - "htmx:wsOpen": "htmx:after:ws:connection", + "htmx:wsOpen": "htmx:ws:after:connection", "htmx:wsClose": "htmx:ws:close", - "htmx:wsConfigSend": "htmx:before:ws:request", - "htmx:wsBeforeSend": "htmx:before:ws:request", - "htmx:wsAfterSend": "htmx:after:ws:request", - "htmx:wsBeforeMessage": "htmx:before:ws:message", - "htmx:wsAfterMessage": "htmx:after:ws:message", + "htmx:wsConfigSend": "htmx:ws:before:message:outgoing", + "htmx:wsBeforeSend": "htmx:ws:before:message:outgoing", + "htmx:wsAfterSend": "htmx:ws:after:message:outgoing", + "htmx:wsBeforeMessage": "htmx:ws:before:message:incoming", + "htmx:wsAfterMessage": "htmx:ws:after:message:incoming", } # Extension attribute renames diff --git a/test/manual/WS_README.md b/test/manual/WS_README.md index d10bb32d5..d217e3f09 100644 --- a/test/manual/WS_README.md +++ b/test/manual/WS_README.md @@ -27,7 +27,7 @@ A beautiful, comprehensive demonstration of the `hx-ws` extension showcasing rea ### 2. **Live Notifications** - Receive random notifications every 5-8 seconds - Shows real-time server push -- Uses `beforeend` swap to prepend new notifications +- Uses `afterbegin` to prepend new notifications ### 3. **Shared Counter** - Multiple clients share the same counter state @@ -64,32 +64,40 @@ A beautiful, comprehensive demonstration of the `hx-ws` extension showcasing rea ## 🎨 Key Concepts -### HTML Partial Format +### HTML Message Format -Server messages use this format: +Server messages use `content` for HTML and may specify a target and serialized swap specification: ```json { - "channel": "ui", - "format": "html", - "payload": "Content" + "content": "

Content

", + "target": "#target-id", + "swap": "beforeend settle:10ms" } ``` -### Request/Response Pattern +### Message Flow Client sends: ```json { - "type": "request", - "request_id": "uuid-here", - "values": { - "message": "Hello!" - } + "headers": { + "HX-Message-ID": "uuid-here" + }, + "message": "Hello!" } ``` -Server responds with matching `request_id` to target the originating element. +The server copies `HX-Message-ID` into the incoming message: + +```json +{ + "headers": { + "HX-Message-ID": "uuid-here" + }, + "content": "

Saved

" +} +``` ### Multiple Partials @@ -112,7 +120,8 @@ htmx.config.ws = { reconnectMaxDelay: 60000, // Max delay (ms) reconnectMaxAttempts: Infinity,// Max reconnect attempts reconnectJitter: 0.3, // Jitter factor (0-1) - pauseOnBackground: true // Pause connection when tab is backgrounded + pauseOnBackground: true, // Pause connection when tab is backgrounded + protocols: null // Optional WebSocket subprotocols }; ``` @@ -143,7 +152,7 @@ htmx.config.ws = { ### Button Actions ```html - ``` @@ -160,8 +169,9 @@ htmx.config.ws = { ## 🐛 Debugging The demo includes a live event log that shows: -- Connection events (`htmx:before:ws:connection`, `htmx:after:ws:connection`) -- Message events (`htmx:before:ws:send`, `htmx:after:ws:message`) +- Connection events (`htmx:ws:before:connection`, `htmx:ws:after:connection`) +- Outgoing message events (`htmx:ws:before:message:outgoing`, `htmx:ws:after:message:outgoing`) +- Message events (`htmx:ws:before:message:incoming`, `htmx:ws:after:message:incoming`) - Error events (`htmx:ws:error`, `htmx:ws:close`) ## 🤝 Contributing @@ -176,7 +186,7 @@ Try modifying the demos to learn: ## 📖 Documentation For full documentation, visit: -- [HTMX WebSocket Extension Docs](https://htmx.org/extensions/websockets/) +- [htmx WebSocket Extension Docs](https://four.htmx.org/extensions/hx-ws) - [WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) ## 🎉 Have Fun! diff --git a/test/manual/ws-server.js b/test/manual/ws-server.js index 7232fb2eb..2266137be 100644 --- a/test/manual/ws-server.js +++ b/test/manual/ws-server.js @@ -118,7 +118,7 @@ wss.on('connection', (ws, req) => { function handleChatConnection(ws) { // Send welcome message (no hx-partial, let hx-swap handle it) ws.send(JSON.stringify({ - payload: '
👋 Welcome to the chat!
' + new Date().toLocaleTimeString() + '
' + content: '
👋 Welcome to the chat!
' + new Date().toLocaleTimeString() + '
' })); } @@ -143,7 +143,7 @@ function handleNotificationsConnection(ws) { const html = `
${notification}
${new Date().toLocaleTimeString()}
`; broadcast('notifications', { - payload: html + content: html }); setTimeout(sendNotification, 5000 + Math.random() * 3000); @@ -156,7 +156,7 @@ function handleNotificationsConnection(ws) { function handleCounterConnection(ws) { // Send current counter value ws.send(JSON.stringify({ - payload: `${counter}` + content: `${counter}` })); } @@ -172,7 +172,7 @@ function handleTickerConnection(ws) { ).join(''); ws.send(JSON.stringify({ - payload: `${html}` + content: `${html}` })); // Update prices every 2-3 seconds @@ -196,7 +196,7 @@ function handleTickerConnection(ws) { }).join(''); broadcast('ticker', { - payload: `${html}` + content: `${html}` }); setTimeout(updatePrices, 2000 + Math.random() * 1000); @@ -214,7 +214,7 @@ function handleDashboardConnection(ws) { const disk = Math.floor(Math.random() * 100); broadcast('dashboard', { - payload: ` + content: ` CPU: ${cpu}% Memory: ${memory}% Disk: ${disk}% @@ -233,15 +233,15 @@ function handleMessage(ws, data) { if (ws.channel === 'chat') { // Broadcast chat message - if (data.values && data.values.message) { - const message = data.values.message; + if (data.message) { + const message = data.message; // Don't use hx-partial - just send raw HTML and let hx-swap="beforeend" handle it const html = `
${escapeHtml(message)}
${new Date().toLocaleTimeString()}
`; // Echo back to sender ws.send(JSON.stringify({ - payload: html, - request_id: data.request_id + headers: { 'HX-Message-ID': data.headers?.['HX-Message-ID'] }, + content: html })); // Simulate bot response after 1 second @@ -259,13 +259,13 @@ function handleMessage(ws, data) { const botHtml = `
🤖 ${botResponse}
${new Date().toLocaleTimeString()}
`; broadcast('chat', { - payload: botHtml + content: botHtml }); }, 1000); } } else if (ws.channel === 'counter') { // Handle counter actions - const action = data.values?.action || data.action; + const action = data.action; if (action === 'increment') { counter++; @@ -277,8 +277,8 @@ function handleMessage(ws, data) { // Broadcast new counter value to all clients broadcast('counter', { - payload: `${counter}`, - request_id: data.request_id + headers: { 'HX-Message-ID': data.headers?.['HX-Message-ID'] }, + content: `${counter}` }); } } diff --git a/test/manual/ws.html b/test/manual/ws.html index 7599d659f..8ee28bf4c 100644 --- a/test/manual/ws.html +++ b/test/manual/ws.html @@ -385,7 +385,7 @@

} // Listen to WebSocket events - document.addEventListener('htmx:before:ws:connection', (e) => { + document.addEventListener('htmx:ws:before:connection', (e) => { let attempt = e.detail.connection.attempt; if (attempt === 0) { logEvent('CONNECT', `Connecting to ${e.detail.connection.url}`); @@ -394,7 +394,7 @@

} }); - document.addEventListener('htmx:after:ws:connection', (e) => { + document.addEventListener('htmx:ws:after:connection', (e) => { logEvent('CONNECTED', `Connected to ${e.detail.url}`); }); @@ -406,16 +406,16 @@

logEvent('ERROR', `WebSocket error: ${e.detail.url}`); }); - document.addEventListener('htmx:before:ws:send', (e) => { + document.addEventListener('htmx:ws:before:message:outgoing', (e) => { logEvent('SEND', `Sending message`); }); - document.addEventListener('htmx:after:ws:message', (e) => { + document.addEventListener('htmx:ws:after:message:incoming', (e) => { logEvent('MESSAGE', `Received message`); }); // Clear chat input after sending - document.addEventListener('htmx:after:ws:send', (e) => { + document.addEventListener('htmx:ws:after:message:outgoing', (e) => { if (e.target.matches('form')) { const input = e.target.querySelector('input[name="message"]'); if (input) { diff --git a/test/tests/ext/hx-ws.js b/test/tests/ext/hx-ws.js index 0821f7670..9f8154dac 100644 --- a/test/tests/ext/hx-ws.js +++ b/test/tests/ext/hx-ws.js @@ -44,6 +44,8 @@ describe('hx-ws WebSocket extension', function() { throw new Error('WebSocket is not open'); } this.lastSent = data; + this.sentMessages ??= []; + this.sentMessages.push(data); } close(code = 1000, reason = '') { @@ -210,12 +212,9 @@ describe('hx-ws WebSocket extension', function() { assert.equal(mockWebSocketInstances.length, 1); let ws = mockWebSocketInstances[0]; - - await htmx.swap({ - text: '', - target: document.getElementById('container'), - swap: 'innerHTML' - }); + let target = document.getElementById('container'); + + await htmx.swap({ text: '', target, swap: 'innerHTML', sourceElement: target }); await htmx.timeout(50); assert.equal(ws.readyState, mockWebSocket.CLOSED); @@ -231,12 +230,9 @@ describe('hx-ws WebSocket extension', function() { await htmx.timeout(50); let ws = mockWebSocketInstances[0]; - - await htmx.swap({ - text: '', - target: document.getElementById('div1'), - swap: 'delete' - }); + let target = document.getElementById('div1'); + + await htmx.swap({ text: '', target, swap: 'delete', sourceElement: target }); await htmx.timeout(50); assert.equal(ws.readyState, mockWebSocket.OPEN); @@ -259,11 +255,8 @@ describe('hx-ws WebSocket extension', function() { // Remove div1 (the element captured as firstElement in createWebSocket) // but keep the connection alive via div2 - await htmx.swap({ - text: '', - target: document.getElementById('div1'), - swap: 'delete' - }); + let target = document.getElementById('div1'); + await htmx.swap({ text: '', target, swap: 'delete', sourceElement: target }); await htmx.timeout(50); // Trigger an error on the still-open socket @@ -282,18 +275,88 @@ describe('hx-ws WebSocket extension', function() { describe('Message Sending', function() { - it('sends message on load trigger (waits for socket open)', async function() { + it('queues a message until the initial connection opens', async function() { let div = createProcessedHTML(`
`); - await htmx.timeout(50); + await htmx.timeout(1); let ws = mockWebSocketInstances[0]; - assert.isDefined(ws.lastSent, 'Should have sent a message on load'); - let sent = JSON.parse(ws.lastSent); - assert.equal(sent.body.test, 'load'); + let connection = htmx.ext.ws.getRegistry().get('/ws/test'); + assert.equal(connection.queue.length, 1); + assert.isUndefined(ws.lastSent); + + await htmx.timeout(30); + + assert.equal(connection.queue.length, 0); + assert.equal(JSON.parse(ws.lastSent).test, 'load'); + }); + + it('queues messages during reconnect and sends them in order', async function() { + htmx.config.ws = { reconnectDelay: 50, reconnectJitter: 0 }; + let div = createProcessedHTML(` +
+ + +
+ `); + await htmx.timeout(20); + + let sentOrders = []; + div.addEventListener('htmx:ws:after:message:outgoing', event => { + sentOrders.push(event.detail.message.values.order); + }); + + mockWebSocketInstances[0].close(1006); + let buttons = div.querySelectorAll('button'); + buttons[0].click(); + buttons[1].click(); + await htmx.timeout(10); + + let connection = htmx.ext.ws.getRegistry().get('/ws/test'); + assert.equal(connection.queue.length, 2); + assert.deepEqual(sentOrders, []); + + await htmx.timeout(70); + + let sent = mockWebSocketInstances[1].sentMessages.map(JSON.parse); + assert.deepEqual(sent.map(message => message.order), ['first', 'second']); + assert.deepEqual(sentOrders, ['first', 'second']); + assert.equal(connection.queue.length, 0); + }); + + it('rejects messages when the outgoing queue is full', async function() { + htmx.config.ws = { + reconnectDelay: 50, + reconnectJitter: 0, + maxOutgoingMessagesQueueSize: 1 + }; + let div = createProcessedHTML(` +
+ + +
+ `); + await htmx.timeout(20); + + let errors = []; + div.addEventListener('htmx:ws:error', event => errors.push(event.detail.error)); + mockWebSocketInstances[0].close(1006); + let buttons = div.querySelectorAll('button'); + buttons[0].click(); + buttons[1].click(); + await htmx.timeout(10); + + let connection = htmx.ext.ws.getRegistry().get('/ws/test'); + assert.equal(connection.queue.length, 1); + assert.deepEqual(errors, ['Outgoing messages queue is full']); + + await htmx.timeout(70); + + let sent = mockWebSocketInstances[1].sentMessages.map(JSON.parse); + assert.deepEqual(sent.map(message => message.order), ['first']); }); it('sends message with hx-ws:send on form submit', async function() { @@ -315,8 +378,9 @@ describe('hx-ws WebSocket extension', function() { assert.isDefined(ws.lastSent); let sent = JSON.parse(ws.lastSent); - assert.isDefined(sent.headers['HX-Request-ID']); - assert.equal(sent.body.message, 'hello'); + assert.isDefined(sent.headers['HX-Message-ID']); + assert.equal(sent.message, 'hello'); + assert.notProperty(sent, 'body'); assert.isDefined(sent.headers['HX-Source']); assert.isDefined(sent.headers['HX-Current-URL']); }); @@ -355,7 +419,7 @@ describe('hx-ws WebSocket extension', function() { let ws = mockWebSocketInstances[0]; let sent = JSON.parse(ws.lastSent); - assert.equal(sent.body.extra, 'data'); + assert.equal(sent.extra, 'data'); }); it('preserves JS types (number, boolean) from hx-vals', async function() { @@ -370,9 +434,9 @@ describe('hx-ws WebSocket extension', function() { await htmx.timeout(20); let sent = JSON.parse(mockWebSocketInstances[0].lastSent); - assert.strictEqual(sent.body.count, 42, 'number should not be coerced to string'); - assert.strictEqual(sent.body.active, true, 'boolean should not be coerced to string'); - assert.strictEqual(sent.body.ratio, 1.5, 'float should not be coerced to string'); + assert.strictEqual(sent.count, 42, 'number should not be coerced to string'); + assert.strictEqual(sent.active, true, 'boolean should not be coerced to string'); + assert.strictEqual(sent.ratio, 1.5, 'float should not be coerced to string'); }); it('hx-vals overrides form field with correct type', async function() { @@ -389,7 +453,7 @@ describe('hx-ws WebSocket extension', function() { await htmx.timeout(20); let sent = JSON.parse(mockWebSocketInstances[0].lastSent); - assert.strictEqual(sent.body.count, 99, 'hx-vals number should win over form string value'); + assert.strictEqual(sent.count, 99, 'hx-vals number should win over form string value'); }); it('finds connection from nearest ancestor', async function() { @@ -451,7 +515,7 @@ describe('hx-ws WebSocket extension', function() { assert.equal(sent.headers['HX-Source'], 'button#my-button'); }); - it('generates unique request_id for each message', async function() { + it('generates a unique HX-Message-ID for each message', async function() { let div = createProcessedHTML(`
@@ -462,11 +526,11 @@ describe('hx-ws WebSocket extension', function() { let button = div.querySelector('button'); button.click(); await htmx.timeout(20); - let firstId = JSON.parse(mockWebSocketInstances[0].lastSent).headers['HX-Request-ID']; + let firstId = JSON.parse(mockWebSocketInstances[0].lastSent).headers['HX-Message-ID']; button.click(); await htmx.timeout(20); - let secondId = JSON.parse(mockWebSocketInstances[0].lastSent).headers['HX-Request-ID']; + let secondId = JSON.parse(mockWebSocketInstances[0].lastSent).headers['HX-Message-ID']; assert.notEqual(firstId, secondId); }); @@ -504,7 +568,7 @@ describe('hx-ws WebSocket extension', function() { let ws = mockWebSocketInstances[0]; let sent = JSON.parse(ws.lastSent); - assert.equal(sent.body.asyncField, 'asyncValue'); + assert.equal(sent.asyncField, 'asyncValue'); delete window.testAsyncValue; }); @@ -664,29 +728,50 @@ describe('hx-ws WebSocket extension', function() { delete window.wsScriptAttrTest; }); - it('matches request_id for request/response pattern', async function() { + // Incoming messages with matching IDs use their sender for relative targets and swap lifecycle. + it('routes incoming messages with matching IDs through the sending element', async function() { let container = createProcessedHTML(`
- -
+
+ +
`); await htmx.timeout(50); - + let button = document.getElementById('btn'); + let result = container.querySelector('.result'); + let eventSource, finalContext, mainTask; + button.addEventListener('htmx:before:swap', event => { + eventSource = event.target; + finalContext = event.detail.ctx; + mainTask = event.detail.tasks.find(task => task.type === 'main'); + }); + button.addEventListener('htmx:ws:before:message:outgoing', event => { + event.detail.message.headers['HX-Message-ID'] = 'custom-message-id'; + }, { once: true }); + button.click(); await htmx.timeout(20); - + let ws = mockWebSocketInstances[0]; let sent = JSON.parse(ws.lastSent); - + assert.equal(sent.headers['HX-Message-ID'], 'custom-message-id'); + ws.simulateMessage({ - content: 'Response', - 'HX-Request-ID': sent.headers['HX-Request-ID'] + content: '

Response

', + swap: 'beforeend swap:10ms settle:0', + headers: { 'HX-Message-ID': sent.headers['HX-Message-ID'] } }); - await htmx.timeout(20); - - assert.include(document.getElementById('result').innerHTML, 'Response'); + await htmx.timeout(30); + + assert.equal(eventSource, button); + assert.equal(finalContext.target, 'closest .result'); + assert.equal(mainTask.target, result); + assert.equal(mainTask.swapSpec.style, 'beforeend'); + assert.equal(mainTask.swapSpec.swap, '10ms'); + assert.equal(mainTask.swapSpec.settle, 0); + assert.equal(document.getElementById('response').parentElement, result); }); }); @@ -706,9 +791,9 @@ describe('hx-ws WebSocket extension', function() { let eventFired = false; let eventMessage = null; - container.addEventListener('htmx:after:ws:message', (e) => { + container.addEventListener('htmx:ws:after:message:incoming', async (e) => { eventFired = true; - eventMessage = e.detail.message.json; + eventMessage = await e.detail.message.json(); }); let ws = mockWebSocketInstances[0]; @@ -720,14 +805,39 @@ describe('hx-ws WebSocket extension', function() { assert.equal(document.getElementById('content').textContent, 'Original', 'Data-only messages should not swap'); }); - it('fires htmx:before:ws:message for all messages', async function() { + it('exposes binary messages without swapping them', async function() { + let container = createProcessedHTML(` +
+
Original
+
+ `); + await htmx.timeout(50); + + let receivedMessage; + container.addEventListener('htmx:ws:before:message:incoming', event => { + receivedMessage = event.detail.message; + }); + + let data = new TextEncoder().encode(JSON.stringify({ content: '

Not swapped

' })).buffer; + mockWebSocketInstances[0].simulateRawMessage(data); + await htmx.timeout(20); + + assert.equal(document.getElementById('content').textContent, 'Original'); + assert.equal(receivedMessage.type, 'binary'); + assert.strictEqual(receivedMessage.data, data); + assert.strictEqual(await receivedMessage.arrayBuffer(), data); + assert.equal((await receivedMessage.json()).content, '

Not swapped

'); + assert.instanceOf(await receivedMessage.blob(), Blob); + }); + + it('fires htmx:ws:before:message:incoming for all messages', async function() { let container = createProcessedHTML(`
`); await htmx.timeout(50); let beforeFired = false; - container.addEventListener('htmx:before:ws:message', () => { + container.addEventListener('htmx:ws:before:message:incoming', () => { beforeFired = true; }); @@ -748,7 +858,7 @@ describe('hx-ws WebSocket extension', function() { `); await htmx.timeout(50); - container.addEventListener('htmx:before:ws:message', (e) => { + container.addEventListener('htmx:ws:before:message:incoming', (e) => { e.preventDefault(); }); @@ -761,19 +871,91 @@ describe('hx-ws WebSocket extension', function() { assert.equal(document.getElementById('content').textContent, 'Original'); }); - it('uses swap:none for raw HTML when no hx-target is set', async function() { + // Bare connections follow normal target and swap defaults. + it('waits for incoming message work before processing', async function() { let container = createProcessedHTML(` -
+
Original
`); await htmx.timeout(50); + container.addEventListener('htmx:ws:before:message:incoming', (event) => { + event.detail.waitUntil(htmx.timeout(20).then(() => { + event.detail.cancelled = true; + })); + }); + + mockWebSocketInstances[0].simulateMessage({ content: '

Changed

' }); + await htmx.timeout(5); + assert.equal(document.getElementById('content').textContent, 'Original'); + + await htmx.timeout(30); + assert.equal(document.getElementById('content').textContent, 'Original'); + }); + + it('processes incoming messages in arrival order', async function() { + let container = createProcessedHTML(` +
+
initial
+
+ `); + await htmx.timeout(50); + + let messageNumber = 0; + container.addEventListener('htmx:ws:before:message:incoming', (event) => { + if (++messageNumber === 1) event.detail.waitUntil(htmx.timeout(30)); + }); + + let ws = mockWebSocketInstances[0]; + ws.simulateMessage({ content: 'first' }); + ws.simulateMessage({ content: 'second' }); + await htmx.timeout(60); + + assert.equal(document.getElementById('value').textContent, 'second'); + }); + + it('waits for an incoming swap before processing the next message', async function() { + createProcessedHTML(` +
+
initial
+
+ `); + await htmx.timeout(50); + + let ws = mockWebSocketInstances[0]; + ws.simulateMessage({ content: 'first', swap: 'innerHTML swap:30ms' }); + ws.simulateMessage({ content: 'second' }); + await htmx.timeout(60); + + assert.equal(document.getElementById('value').textContent, 'second'); + }); + + it('swaps raw HTML into the connection element by default', async function() { + let container = createProcessedHTML(` +
Original
+ `); + await htmx.timeout(50); + let ws = mockWebSocketInstances[0]; - ws.simulateRawMessage('
Should not replace
'); + ws.simulateRawMessage('

Updated

'); await htmx.timeout(20); - assert.equal(document.getElementById('content').textContent, 'Original'); + assert.equal(container.innerHTML, '

Updated

'); + }); + + // JSON content uses the same target and swap defaults as raw HTML. + it('swaps JSON content into the connection element by default', async function() { + let container = createProcessedHTML(` +
Original
+ `); + await htmx.timeout(50); + + let ws = mockWebSocketInstances[0]; + ws.simulateMessage({ content: '

Updated

' }); + await htmx.timeout(20); + + assert.equal(container.innerHTML, '

Updated

'); }); it('swaps raw HTML into hx-target when set', async function() { @@ -834,19 +1016,50 @@ describe('hx-ws WebSocket extension', function() { assert.isTrue(closeFired); }); - it('attempts reconnection on close when config.reconnect is true', async function() { - htmx.config.ws = { reconnect: true, reconnectDelay: 50 }; - - let container = createProcessedHTML(` -
- `); + it('uses transient close codes by default', async function() { + createProcessedHTML('
'); + await htmx.timeout(20); + + let connection = htmx.ext.ws.getRegistry().get('/ws/test'); + assert.deepEqual(connection.config.reconnectCodes, [1001, 1005, 1006, 1011, 1012, 1013, 1014]); + }); + + it('reconnects after every default close code', async function() { + htmx.config.ws = { reconnectDelay: 5, reconnectJitter: 0 }; + + createProcessedHTML('
'); + await htmx.timeout(20); + + for (let code of [1001, 1005, 1006, 1011, 1012, 1013, 1014]) { + let count = mockWebSocketInstances.length; + mockWebSocketInstances[count - 1].close(code); + await htmx.timeout(20); + assert.equal(mockWebSocketInstances.length, count + 1, `close code ${code}`); + } + }); + + it('does not reconnect after a normal close', async function() { + htmx.config.ws = { reconnect: true, reconnectDelay: 20 }; + + createProcessedHTML('
'); await htmx.timeout(50); - - let firstWs = mockWebSocketInstances[0]; - firstWs.close(); - await htmx.timeout(100); - - assert.isTrue(mockWebSocketInstances.length > 1, 'Should create new WebSocket for reconnection'); + + mockWebSocketInstances[0].close(1000); + await htmx.timeout(50); + + assert.equal(mockWebSocketInstances.length, 1); + }); + + it('uses custom reconnectCodes', async function() { + htmx.config.ws = { reconnectCodes: [1000], reconnectDelay: 20 }; + + createProcessedHTML('
'); + await htmx.timeout(50); + + mockWebSocketInstances[0].close(1000); + await htmx.timeout(50); + + assert.equal(mockWebSocketInstances.length, 2); }); it('does not reconnect when config.reconnect is false', async function() { @@ -858,13 +1071,13 @@ describe('hx-ws WebSocket extension', function() { await htmx.timeout(50); let firstWs = mockWebSocketInstances[0]; - firstWs.close(); + firstWs.close(1006); await htmx.timeout(100); assert.equal(mockWebSocketInstances.length, 1); }); - it('emits htmx:before:ws:connection with attempt > 0 on reconnect', async function() { + it('emits htmx:ws:before:connection with attempt > 0 on reconnect', async function() { htmx.config.ws = { reconnect: true, reconnectDelay: 50 }; let container = createProcessedHTML(` @@ -872,7 +1085,7 @@ describe('hx-ws WebSocket extension', function() { `); let reconnectAttempt = null; - container.addEventListener('htmx:before:ws:connection', (e) => { + container.addEventListener('htmx:ws:before:connection', (e) => { if (e.detail.connection.attempt > 0) { reconnectAttempt = e.detail.connection.attempt; } @@ -880,7 +1093,7 @@ describe('hx-ws WebSocket extension', function() { await htmx.timeout(50); let firstWs = mockWebSocketInstances[0]; - firstWs.close(); + firstWs.close(1006); await htmx.timeout(100); assert.equal(reconnectAttempt, 1); @@ -899,7 +1112,7 @@ describe('hx-ws WebSocket extension', function() { await htmx.timeout(50); let reconnectTimes = []; - container.addEventListener('htmx:before:ws:connection', (e) => { + container.addEventListener('htmx:ws:before:connection', (e) => { if (e.detail.connection.attempt > 0) { reconnectTimes.push(Date.now()); } @@ -907,17 +1120,17 @@ describe('hx-ws WebSocket extension', function() { // First close let ws = mockWebSocketInstances[mockWebSocketInstances.length - 1]; - ws.close(); + ws.close(1006); await htmx.timeout(200); // Second close ws = mockWebSocketInstances[mockWebSocketInstances.length - 1]; - ws.close(); + ws.close(1006); await htmx.timeout(300); // Third close ws = mockWebSocketInstances[mockWebSocketInstances.length - 1]; - ws.close(); + ws.close(1006); await htmx.timeout(500); // Verify delays are increasing @@ -967,21 +1180,36 @@ describe('hx-ws WebSocket extension', function() { assert.include(document.getElementById('content').innerHTML, 'Raw HTML update'); }); - it('uses swap:none for non-JSON messages without hx-target', async function() { + // OOB-only messages update their targets without clearing the connection element. + it('defaults swapEmpty to false for OOB-only messages', async function() { let container = createProcessedHTML(` -
-
Original
-
+
Original
+
Waiting
`); await htmx.timeout(50); - + let ws = mockWebSocketInstances[0]; - // Send raw HTML without hx-partial targeting — should not wipe connection element - ws.simulateRawMessage('

Should not appear

'); + ws.simulateRawMessage('
Connected
'); await htmx.timeout(20); - - // Connection element content should be preserved - assert.include(document.getElementById('ws-conn').innerHTML, 'Original'); + + assert.equal(document.getElementById('ws-conn').textContent, 'Original'); + assert.equal(document.getElementById('status').textContent, 'Connected'); + }); + + // Explicit swapEmpty:true restores the normal empty main swap. + it('allows swapEmpty:true to clear the connection element', async function() { + let container = createProcessedHTML(` +
Original
+
Waiting
+ `); + await htmx.timeout(50); + + let ws = mockWebSocketInstances[0]; + ws.simulateRawMessage('
Connected
'); + await htmx.timeout(20); + + assert.equal(document.getElementById('ws-conn').textContent, ''); + assert.equal(document.getElementById('status').textContent, 'Connected'); }); it('processes hx-partial in non-JSON messages even without hx-target', async function() { @@ -999,7 +1227,7 @@ describe('hx-ws WebSocket extension', function() { assert.include(document.getElementById('widget').innerHTML, 'Updated via partial'); }); - it('fires htmx:before:ws:message for non-JSON data with message=null', async function() { + it('fires htmx:ws:before:message:incoming for non-JSON data', async function() { let container = createProcessedHTML(`
Original
@@ -1009,10 +1237,9 @@ describe('hx-ws WebSocket extension', function() { let eventFired = false; let receivedData = null; - let receivedMessage = 'not-set'; - container.addEventListener('htmx:before:ws:message', (e) => { + container.addEventListener('htmx:ws:before:message:incoming', async (e) => { eventFired = true; - receivedMessage = e.detail.message.json; + receivedData = await e.detail.message.text(); }); let ws = mockWebSocketInstances[0]; @@ -1020,10 +1247,10 @@ describe('hx-ws WebSocket extension', function() { await htmx.timeout(20); assert.isTrue(eventFired); - assert.isNull(receivedMessage, 'message.json should be null for raw messages'); + assert.equal(receivedData, '

Raw content

'); }); - it('prevents swap when htmx:before:ws:message is cancelled for raw data', async function() { + it('prevents swap when htmx:ws:before:message:incoming is cancelled for raw data', async function() { let container = createProcessedHTML(`
Original
@@ -1031,8 +1258,8 @@ describe('hx-ws WebSocket extension', function() { `); await htmx.timeout(50); - container.addEventListener('htmx:before:ws:message', (e) => { - if (!e.detail.message.json) e.detail.message.cancelled = true; + container.addEventListener('htmx:ws:before:message:incoming', (e) => { + if (e.detail.message.type === 'text') e.detail.cancelled = true; }); let ws = mockWebSocketInstances[0]; @@ -1073,7 +1300,7 @@ describe('hx-ws WebSocket extension', function() { let ws = mockWebSocketInstances[0]; let closeTime = Date.now(); - ws.close(); + ws.close(1006); await htmx.timeout(100); assert.equal(mockWebSocketInstances.length, 1, 'Should not reconnect yet'); @@ -1096,7 +1323,7 @@ describe('hx-ws WebSocket extension', function() { // This test just ensures jitter doesn't break reconnection let ws = mockWebSocketInstances[0]; - ws.close(); + ws.close(1006); await htmx.timeout(200); assert.isTrue(mockWebSocketInstances.length > 1); @@ -1116,13 +1343,13 @@ describe('hx-ws WebSocket extension', function() { await htmx.timeout(50); let reconnectCount = 0; - container.addEventListener('htmx:before:ws:connection', (e) => { + container.addEventListener('htmx:ws:before:connection', (e) => { if (e.detail.connection.attempt > 0) reconnectCount++; }); // Close the first connection — this triggers reconnect attempt 1 let ws = mockWebSocketInstances[0]; - ws.close(); + ws.close(1006); await htmx.timeout(50); // The reconnected socket auto-opens (mock behavior), which resets @@ -1155,14 +1382,14 @@ describe('hx-ws WebSocket extension', function() { await htmx.timeout(50); let reconnectAttempts = []; - container.addEventListener('htmx:before:ws:connection', (e) => { + container.addEventListener('htmx:ws:before:connection', (e) => { if (e.detail.connection.attempt > 0) { reconnectAttempts.push(e.detail.connection.attempt); } }); let ws = mockWebSocketInstances[mockWebSocketInstances.length - 1]; - ws.close(); + ws.close(1006); await htmx.timeout(200); assert.isAtLeast(reconnectAttempts.length, 1, 'Should have at least 1 reconnect'); @@ -1182,7 +1409,7 @@ describe('hx-ws WebSocket extension', function() { await htmx.timeout(50); let reconnectAttempts = []; - container.addEventListener('htmx:before:ws:connection', (e) => { + container.addEventListener('htmx:ws:before:connection', (e) => { if (e.detail.connection.attempt > 0) { reconnectAttempts.push(e.detail.connection.attempt); } @@ -1191,11 +1418,11 @@ describe('hx-ws WebSocket extension', function() { // Each reconnect succeeds (mock auto-opens), so reconnectAttempts // resets to 0 — each subsequent close starts at attempt 1 again let ws = mockWebSocketInstances[mockWebSocketInstances.length - 1]; - ws.close(); + ws.close(1006); await htmx.timeout(50); ws = mockWebSocketInstances[mockWebSocketInstances.length - 1]; - ws.close(); + ws.close(1006); await htmx.timeout(50); assert.isAtLeast(reconnectAttempts.length, 2, 'Should have at least 2 reconnects'); @@ -1214,14 +1441,14 @@ describe('hx-ws WebSocket extension', function() { await htmx.timeout(50); let reconnectAttempts = []; - container.addEventListener('htmx:before:ws:connection', (e) => { + container.addEventListener('htmx:ws:before:connection', (e) => { if (e.detail.connection.attempt > 0) { reconnectAttempts.push(e.detail.connection.attempt); } }); let ws = mockWebSocketInstances[mockWebSocketInstances.length - 1]; - ws.close(); + ws.close(1006); await htmx.timeout(100); // Per-element config set reconnectDelay to 20ms (not global 5000ms), @@ -1237,19 +1464,19 @@ describe('hx-ws WebSocket extension', function() { }; let container = createProcessedHTML(` -
+
`); await htmx.timeout(50); let reconnectAttempts = []; - container.addEventListener('htmx:before:ws:connection', (e) => { + container.addEventListener('htmx:ws:before:connection', (e) => { if (e.detail.connection.attempt > 0) { reconnectAttempts.push(e.detail.connection.attempt); } }); let ws = mockWebSocketInstances[mockWebSocketInstances.length - 1]; - ws.close(); + ws.close(1000); await htmx.timeout(100); assert.isAtLeast(reconnectAttempts.length, 1, 'Should reconnect using per-element JSON config'); @@ -1277,7 +1504,7 @@ describe('hx-ws WebSocket extension', function() { assert.equal(errorMsg, 'Connection not open'); }); - it('raw messages go through before/after:ws:message with message=null', async function() { + it('raw messages go through incoming message events', async function() { let container = createProcessedHTML(`
Original
@@ -1287,10 +1514,10 @@ describe('hx-ws WebSocket extension', function() { let beforeDetail = null; let afterDetail = null; - container.addEventListener('htmx:before:ws:message', (e) => { + container.addEventListener('htmx:ws:before:message:incoming', (e) => { beforeDetail = e.detail; }); - container.addEventListener('htmx:after:ws:message', (e) => { + container.addEventListener('htmx:ws:after:message:incoming', (e) => { afterDetail = e.detail; }); @@ -1298,15 +1525,16 @@ describe('hx-ws WebSocket extension', function() { ws.simulateRawMessage('

Updated

'); await htmx.timeout(20); - assert.isNotNull(beforeDetail, 'before:ws:message should fire for raw messages'); - assert.isNull(beforeDetail.message.json, 'message.json should be null for raw data'); - assert.isString(beforeDetail.message.text, 'message.text should be present'); + assert.isNotNull(beforeDetail, 'ws:before:message:incoming should fire for raw messages'); + assert.equal(beforeDetail.message.type, 'text'); + assert.equal(beforeDetail.message.data, '

Updated

'); + assert.equal(await beforeDetail.message.text(), beforeDetail.message.data); - assert.isNotNull(afterDetail, 'after:ws:message should fire for raw messages'); - assert.isNull(afterDetail.message.json, 'message.json should be null in after event too'); + assert.isNotNull(afterDetail, 'ws:after:message:incoming should fire for raw messages'); + assert.strictEqual(afterDetail.message, beforeDetail.message); }); - it('JSON messages go through before/after:ws:message with message object', async function() { + it('JSON messages go through incoming message events', async function() { let container = createProcessedHTML(`
@@ -1315,7 +1543,7 @@ describe('hx-ws WebSocket extension', function() { await htmx.timeout(50); let beforeDetail = null; - container.addEventListener('htmx:before:ws:message', (e) => { + container.addEventListener('htmx:ws:before:message:incoming', (e) => { beforeDetail = e.detail; }); @@ -1325,9 +1553,9 @@ describe('hx-ws WebSocket extension', function() { }); await htmx.timeout(20); - assert.isNotNull(beforeDetail, 'before:ws:message should fire'); - assert.isNotNull(beforeDetail.message.json, 'message.json should be set for JSON messages'); - assert.isDefined(beforeDetail.message.json.content, 'message.json should have content field'); + assert.isNotNull(beforeDetail, 'ws:before:message:incoming should fire'); + let json = await beforeDetail.message.json(); + assert.isDefined(json.content, 'message.json() should parse the message'); }); it('passes protocols to WebSocket constructor', async function() { htmx.config.ws = { protocols: 'my-protocol' }; @@ -1376,13 +1604,13 @@ describe('hx-ws WebSocket extension', function() { describe('Event Emission', function() { - it('emits htmx:before:ws:connection before connection', async function() { + it('emits htmx:ws:before:connection before connection', async function() { let beforeFired = false; let attempt = null; let container = document.createElement('div'); container.innerHTML = '
'; - container.addEventListener('htmx:before:ws:connection', (e) => { + container.addEventListener('htmx:ws:before:connection', (e) => { beforeFired = true; attempt = e.detail.connection.attempt; }); @@ -1396,12 +1624,12 @@ describe('hx-ws WebSocket extension', function() { container.remove(); }); - it('emits htmx:after:ws:connection after connection', async function() { + it('emits htmx:ws:after:connection after connection', async function() { let afterFired = false; let container = document.createElement('div'); container.innerHTML = '
'; - container.addEventListener('htmx:after:ws:connection', () => { + container.addEventListener('htmx:ws:after:connection', () => { afterFired = true; }); @@ -1413,11 +1641,11 @@ describe('hx-ws WebSocket extension', function() { container.remove(); }); - it('can cancel initial connection via htmx:before:ws:connection', async function() { + it('can cancel initial connection via htmx:ws:before:connection', async function() { let container = document.createElement('div'); container.innerHTML = '
'; - container.addEventListener('htmx:before:ws:connection', (e) => { + container.addEventListener('htmx:ws:before:connection', (e) => { e.detail.connection.cancelled = true; }); @@ -1429,14 +1657,14 @@ describe('hx-ws WebSocket extension', function() { container.remove(); }); - it('can cancel reconnection via htmx:before:ws:connection', async function() { + it('can cancel reconnection via htmx:ws:before:connection', async function() { htmx.config.ws = { reconnect: true, reconnectDelay: 50 }; let container = createProcessedHTML(`
`); - container.addEventListener('htmx:before:ws:connection', (e) => { + container.addEventListener('htmx:ws:before:connection', (e) => { if (e.detail.connection.attempt > 0) { e.detail.connection.cancelled = true; } @@ -1444,13 +1672,13 @@ describe('hx-ws WebSocket extension', function() { await htmx.timeout(50); let firstWs = mockWebSocketInstances[0]; - firstWs.close(); + firstWs.close(1006); await htmx.timeout(150); assert.equal(mockWebSocketInstances.length, 1, 'Should not reconnect when cancelled'); }); - it('emits htmx:before:ws:request before sending', async function() { + it('emits htmx:ws:before:message:outgoing before sending', async function() { let beforeFired = false; let div = createProcessedHTML(`
@@ -1458,7 +1686,7 @@ describe('hx-ws WebSocket extension', function() {
`); - div.addEventListener('htmx:before:ws:request', () => { + div.addEventListener('htmx:ws:before:message:outgoing', () => { beforeFired = true; }); @@ -1469,34 +1697,37 @@ describe('hx-ws WebSocket extension', function() { assert.isTrue(beforeFired); }); - it('emits htmx:after:ws:request after sending', async function() { - let afterFired = false; + it('emits htmx:ws:after:message:outgoing after sending', async function() { + let afterMessage; let div = createProcessedHTML(`
`); - div.addEventListener('htmx:after:ws:request', () => { - afterFired = true; + div.addEventListener('htmx:ws:after:message:outgoing', (event) => { + afterMessage = event.detail.message; }); await htmx.timeout(50); div.querySelector('button').click(); await htmx.timeout(20); - assert.isTrue(afterFired); + assert.isString(afterMessage.data); + assert.deepEqual(JSON.parse(afterMessage.data), { + headers: afterMessage.headers + }); }); - it('allows modifying message via htmx:before:ws:request', async function() { + it('allows modifying message via htmx:ws:before:message:outgoing', async function() { let div = createProcessedHTML(`
`); - div.addEventListener('htmx:before:ws:request', (e) => { - e.detail.body.custom = 'added'; + div.addEventListener('htmx:ws:before:message:outgoing', (e) => { + e.detail.message.values.custom = 'added'; }); await htmx.timeout(50); @@ -1505,17 +1736,92 @@ describe('hx-ws WebSocket extension', function() { let ws = mockWebSocketInstances[0]; let sent = JSON.parse(ws.lastSent); - assert.equal(sent.body.custom, 'added'); + assert.equal(sent.custom, 'added'); }); - it('can cancel send via htmx:before:ws:request', async function() { + it('waits for async authorization before sending', async function() { let div = createProcessedHTML(`
`); + let resolveToken; + let token = new Promise(resolve => resolveToken = resolve); + + div.addEventListener('htmx:ws:before:message:outgoing', (event) => { + event.detail.waitUntil(token.then(value => { + event.detail.message.headers.Authorization = `Bearer ${value}`; + })); + }); - div.addEventListener('htmx:before:ws:request', (e) => { + await htmx.timeout(50); + div.querySelector('button').click(); + await htmx.timeout(5); + + let ws = mockWebSocketInstances[0]; + assert.isUndefined(ws.lastSent); + + resolveToken('abc123'); + await htmx.timeout(10); + assert.equal(JSON.parse(ws.lastSent).headers.Authorization, 'Bearer abc123'); + }); + + it('sends outgoing messages in trigger order', async function() { + let div = createProcessedHTML(` +
+ +
+ `); + let messageNumber = 0; + + div.addEventListener('htmx:ws:before:message:outgoing', (event) => { + event.detail.message.values.messageNumber = ++messageNumber; + if (messageNumber === 1) event.detail.waitUntil(htmx.timeout(30)); + }); + + await htmx.timeout(50); + let button = div.querySelector('button'); + button.click(); + button.click(); + await htmx.timeout(60); + + let sent = mockWebSocketInstances[0].sentMessages.map(JSON.parse); + assert.deepEqual(sent.map(message => message.messageNumber), [1, 2]); + }); + + it('sends replacement WebSocket data', async function() { + let div = createProcessedHTML(` +
+ +
+ `); + let data = new Uint8Array([1, 2, 3]); + let afterMessage; + + div.addEventListener('htmx:ws:before:message:outgoing', (event) => { + event.detail.message.data = data; + }); + div.addEventListener('htmx:ws:after:message:outgoing', (event) => { + afterMessage = event.detail.message; + }); + + await htmx.timeout(50); + div.querySelector('button').click(); + await htmx.timeout(20); + + let ws = mockWebSocketInstances[0]; + assert.strictEqual(ws.lastSent, data); + assert.strictEqual(afterMessage.data, data); + }); + + it('can cancel send via htmx:ws:before:message:outgoing', async function() { + let div = createProcessedHTML(` +
+ +
+ `); + + div.addEventListener('htmx:ws:before:message:outgoing', (e) => { e.preventDefault(); }); @@ -1734,6 +2040,57 @@ describe('hx-ws WebSocket extension', function() { assert.include(content.innerHTML, 'Item 1'); assert.include(content.innerHTML, 'Item 2'); }); + + // Incoming HTML uses inherited hx-select before the main swap. + it('uses element hx-select', async function() { + let container = createProcessedHTML(` +
+
+
+ `); + await htmx.timeout(50); + + let ws = mockWebSocketInstances[0]; + ws.simulateRawMessage('

Selected

Ignored
'); + await htmx.timeout(20); + + assert.equal(document.getElementById('content').innerHTML, '

Selected

'); + }); + + // A JSON select overrides inherited hx-select for one incoming message. + it('message select overrides element hx-select', async function() { + let container = createProcessedHTML(` +
+
+
+ `); + await htmx.timeout(50); + + let ws = mockWebSocketInstances[0]; + ws.simulateMessage({ + content: '

Default

Override

', + select: '.override' + }); + await htmx.timeout(20); + + assert.equal(document.getElementById('content').innerHTML, '

Override

'); + }); + + // Incoming HTML uses inherited hx-select-oob for client-selected OOB updates. + it('uses element hx-select-oob', async function() { + let container = createProcessedHTML(` +
Original
+
Waiting
+ `); + await htmx.timeout(50); + + let ws = mockWebSocketInstances[0]; + ws.simulateRawMessage('
Connected
'); + await htmx.timeout(20); + + assert.equal(document.getElementById('ws-conn').textContent, 'Original'); + assert.equal(document.getElementById('status').textContent, 'Connected'); + }); it('message target overrides element hx-target', async function() { let container = createProcessedHTML(` @@ -1782,7 +2139,7 @@ describe('hx-ws WebSocket extension', function() { describe('Bug Regressions', function() { - it('htmx:after:ws:connection reports correct attempt number on reconnect', async function() { + it('htmx:ws:after:connection reports correct attempt number on reconnect', async function() { htmx.config.ws = { reconnect: true, reconnectDelay: 50, reconnectJitter: 0 }; let container = createProcessedHTML(` @@ -1791,20 +2148,20 @@ describe('hx-ws WebSocket extension', function() { await htmx.timeout(50); let reportedAttempt = null; - container.addEventListener('htmx:after:ws:connection', (e) => { + container.addEventListener('htmx:ws:after:connection', (e) => { reportedAttempt = e.detail.connection.attempt; }); // Close to trigger reconnect let ws = mockWebSocketInstances[0]; - ws.close(); + ws.close(1006); await htmx.timeout(150); assert.isNotNull(reportedAttempt, 'after:ws:connection should have fired on reconnect'); assert.equal(reportedAttempt, 1, 'Reconnection attempt should be 1, not 0'); }); - it('htmx:before:ws:message includes raw data string', async function() { + it('htmx:ws:before:message:incoming includes raw data string', async function() { let container = createProcessedHTML(`
@@ -1812,20 +2169,18 @@ describe('hx-ws WebSocket extension', function() { `); await htmx.timeout(50); - let receivedData = null; let receivedMessage = null; - container.addEventListener('htmx:before:ws:message', (e) => { - receivedData = e.detail.message.text; - receivedMessage = e.detail.message.json; + container.addEventListener('htmx:ws:before:message:incoming', async (e) => { + receivedMessage = e.detail.message; }); let ws = mockWebSocketInstances[0]; ws.simulateMessage({ content: '

Hello

' }); await htmx.timeout(20); - assert.isString(receivedData, 'text should be the raw string'); - assert.isNotNull(receivedMessage, 'json should be the parsed JSON'); - assert.equal(receivedMessage.content, '

Hello

'); + assert.equal(receivedMessage.data, JSON.stringify({ content: '

Hello

' })); + assert.equal(await receivedMessage.text(), receivedMessage.data); + assert.equal((await receivedMessage.json()).content, '

Hello

'); }); }); @@ -1851,10 +2206,12 @@ describe('hx-ws WebSocket extension', function() { assert.isTrue(registry.has('/ws/test'), 'Connection should be in registry'); // Swap out the ws-host element entirely (simulates hx-swap replacing it) + let target = document.getElementById('outer'); await htmx.swap({ text: '
Replaced — no hx-ws:connect
', - target: document.getElementById('outer'), - swap: 'innerHTML' + target, + swap: 'innerHTML', + sourceElement: target }); await htmx.timeout(50); @@ -1929,14 +2286,16 @@ describe('hx-ws WebSocket extension', function() { let registry = htmx.ext.ws.getRegistry(); // Close to trigger reconnect scheduling - ws.close(); + ws.close(1006); await htmx.timeout(20); // Remove element during the reconnect delay + let target = document.getElementById('outer'); await htmx.swap({ text: '
No more WS
', - target: document.getElementById('outer'), - swap: 'innerHTML' + target, + swap: 'innerHTML', + sourceElement: target }); await htmx.timeout(250); @@ -2047,53 +2406,7 @@ describe('hx-ws WebSocket extension', function() { }); // ======================================== - // 14. RECONNECT JITTER BOOLEAN COMPAT (POLISH) - // ======================================== - - describe('reconnectJitter Boolean Compatibility', function() { - - it('treats reconnectJitter: true as 0.3 (default jitter)', async function() { - htmx.config.ws = { - reconnect: true, - reconnectDelay: 50, - reconnectJitter: true - }; - - let container = createProcessedHTML(` -
- `); - await htmx.timeout(50); - - // Should reconnect without breaking (true * delay would give NaN-like behavior) - let ws = mockWebSocketInstances[0]; - ws.close(); - await htmx.timeout(150); - - assert.isTrue(mockWebSocketInstances.length > 1, 'Should reconnect with boolean jitter=true'); - }); - - it('treats reconnectJitter: false as 0 (no jitter)', async function() { - htmx.config.ws = { - reconnect: true, - reconnectDelay: 50, - reconnectJitter: false - }; - - let container = createProcessedHTML(` -
- `); - await htmx.timeout(50); - - let ws = mockWebSocketInstances[0]; - ws.close(); - await htmx.timeout(100); - - assert.isTrue(mockWebSocketInstances.length > 1, 'Should reconnect with boolean jitter=false'); - }); - }); - - // ======================================== - // 15. ADDITIONAL FINDINGS — DEEP REVIEW + // 14. ADDITIONAL FINDINGS — DEEP REVIEW // ======================================== describe('Deep Review Fixes', function() { @@ -2115,25 +2428,23 @@ describe('hx-ws WebSocket extension', function() { let ws = mockWebSocketInstances[0]; let sent = JSON.parse(ws.lastSent); - let requestId = sent.headers['HX-Request-ID']; + let messageId = sent.headers['HX-Message-ID']; - // Remove the form that sent the request (simulates swap replacing the form) + // Remove the form that sent the message (simulates swap replacing the form) form.remove(); await htmx.timeout(20); - // Server responds with the request ID — should fall back to live connect element + // Server sends the message ID — should fall back to live connect element ws.simulateMessage({ content: 'Response', - 'HX-Request-ID': requestId + headers: { 'HX-Message-ID': messageId } }); await htmx.timeout(20); assert.include(document.getElementById('result').innerHTML, 'Response'); }); - it('cleans up expired pending requests on message receive', async function() { - htmx.config.ws = { pendingRequestTTL: 50 }; - + it('cleans up expired pending messages on message receive', async function() { let container = createProcessedHTML(`
@@ -2148,16 +2459,14 @@ describe('hx-ws WebSocket extension', function() { let ws = mockWebSocketInstances[0]; let registry = htmx.ext.ws.getRegistry(); let conn = registry.get('/ws/test'); - assert.equal(conn.pendingRequests.size, 1, 'Should have 1 pending request'); - - // Wait for TTL to expire - await htmx.timeout(100); + assert.equal(conn.pendingMessages.size, 1, 'Should have 1 pending message'); + conn.pendingMessages.values().next().value.timestamp -= 30001; // Receive any message — should trigger cleanup ws.simulateMessage({ type: 'ping' }); await htmx.timeout(20); - assert.equal(conn.pendingRequests.size, 0, 'Expired pending request should be cleaned up'); + assert.equal(conn.pendingMessages.size, 0, 'Expired pending message should be cleaned up'); }); it('closeConnection aborts the AbortController', async function() { @@ -2175,11 +2484,8 @@ describe('hx-ws WebSocket extension', function() { let ac = conn.abortController; // Remove the element to trigger closeConnection - await htmx.swap({ - text: '', - target: document.getElementById('outer'), - swap: 'innerHTML' - }); + let target = document.getElementById('outer'); + await htmx.swap({ text: '', target, swap: 'innerHTML', sourceElement: target }); await htmx.timeout(50); assert.isTrue(ac.signal.aborted, 'AbortController should be aborted on close'); diff --git a/www/src/content/extensions/03-hx-ws.md b/www/src/content/extensions/03-hx-ws.md index d853bae09..f7ffb7dd3 100644 --- a/www/src/content/extensions/03-hx-ws.md +++ b/www/src/content/extensions/03-hx-ws.md @@ -24,7 +24,7 @@ If you used [`ws`](https://htmx.org/extensions/ws/) in htmx 2.0, see [migration Open a [persistent](#wsreconnect) WebSocket connection: ```html -
+
...
``` @@ -38,18 +38,16 @@ The browser receives this WebSocket message: The result is: ```html -
+

New message

``` -The explicit target enables the normal swap. htmx uses: +htmx uses the same rules as with a `text/html` response: - [`hx-target="this"`](/reference/attributes/hx-target#this) - [`hx-swap="innerHTML"`](/reference/attributes/hx-swap#innerhtml) (from [`htmx.config.defaultSwap`](/reference/config/htmx-config-defaultSwap)) -Without an element or JSON target, plain incoming HTML uses `swap:none`. Explicit [`hx-swap-oob`](/reference/attributes/hx-swap-oob) and [``](/reference/tags/hx-partial) swaps still run. - **Choose the Swap** Use [`hx-swap`](/reference/attributes/hx-swap) and [`hx-target`](/reference/attributes/hx-target) to choose how and where updates swap: @@ -88,12 +86,17 @@ The result is:
``` +You can also use: + +- [`hx-select`](/reference/attributes/hx-select) to select content for the swap +- [`hx-select-oob`](/reference/attributes/hx-select-oob) to select more elements to swap + ### Update Elements -Use explicit extra swaps to update several elements: +Start with the page elements to update: ```html -
+

Old

@@ -101,7 +104,7 @@ Use explicit extra swaps to update several elements:
Offline
``` -The server sends an [`hx-swap-oob`](/reference/attributes/hx-swap-oob) element and an [``](/reference/tags/hx-partial): +We’ll send an [`hx-swap-oob`](/reference/attributes/hx-swap-oob) element and an [``](/reference/tags/hx-partial) (new in 4.0): ```html @@ -116,7 +119,7 @@ The server sends an [`hx-swap-oob`](/reference/attributes/hx-swap-oob) element a The page becomes: ```html -
+

Old

@@ -125,20 +128,48 @@ The page becomes:
Online
``` -`hx-swap="none"` disables the connection element's normal swap. The explicit extra swaps still run. +
+Why wasn't the normal swap used? + +After htmx extracts extra swaps, the normal swap may be empty: + +```text +(empty) +``` + +[`hx-swap-oob`](/reference/attributes/hx-swap-oob) and [``](/reference/tags/hx-partial) elements are extracted before the normal swap. By default, [`swapEmpty:false`](/reference/attributes/hx-swap#swapempty) leaves the connection unchanged. + +The server can also mix these updates with ordinary HTML: + +```html +

New chat content

+ + +Busy +``` + +The first element uses the connection's target and swap. The partial updates `#status`. + +To disable the connection's swap, set `hx-swap="none"`: + +```html +
+ ... +
+``` + +[`hx-swap-oob`](/reference/attributes/hx-swap-oob) and [``](/reference/tags/hx-partial) swaps still run. + +
### Send a Message -Add [`hx-ws:send`](#hx-wssend) to a form inside the connection: +Add [`hx-ws:send`](#hx-wssend) to an input inside the connection: ```html
- -
- - -
+
``` @@ -148,19 +179,17 @@ The outgoing message is: { "headers": { "HX-Request": "true", - "HX-Request-ID": "550e8400-e29b-41d4-a716-446655440000", + "HX-Message-ID": "550e8400-e29b-41d4-a716-446655440000", "HX-Request-Type": "partial", - "HX-Source": "form", + "HX-Source": "input", "HX-Target": "div#messages", "HX-Current-URL": "https://example.com/chat" }, - "body": { - "message": "Hello" - } + "message": "Hello" } ``` -`headers` contains htmx metadata. `body` contains form values and [`hx-vals`](/reference/attributes/hx-vals). +`headers` is reserved for metadata. Form values and `hx-vals` use the other top-level keys. Repeat a form field to send an array: @@ -172,16 +201,16 @@ Repeat a form field to send an array: ``` +The outgoing message is: + ```jsonc { "headers": { /* ... */ }, - "body": { - "tag": ["urgent", "public"] - } + "tag": ["urgent", "public"] } ``` -`hx-vals` overrides form values without coercing its types: +[`hx-vals`](/reference/attributes/hx-vals) overrides form values without coercing its types: ```html
@@ -191,7 +220,7 @@ Repeat a form field to send an array: ``` ```jsonc -{ "headers": { /* ... */ }, "body": { "count": 2 } } +{ "headers": { /* ... */ }, "count": 2 } ``` ### Override an Incoming Swap @@ -202,27 +231,45 @@ Use JSON to override the connection's swap: { "content": "

New message

", "target": "#messages", - "swap": "beforeend settle:10ms" + "swap": "beforeend settle:10ms", + "select": ".message" } ``` +- `headers`: metadata such as `HX-Message-ID` - `content`: the HTML to swap - `target`: where to swap it - `swap`: a serialized [`hx-swap`](/reference/attributes/hx-swap) specification -- `HX-Request-ID`: an optional top-level sender correlation ID -- `request_id`: a supported legacy correlation ID +- `select`: what to select from `content` + +HTTP `HX-Re*` headers replace values already chosen for a request. + +A WebSocket message may arrive without a request, so its JSON fields can choose those values from the start: + +| JSON field | HTTP response header | Element default | +|------------|----------------------|-----------------| +| `target` | [`HX-Retarget`](/reference/headers/HX-Retarget) | [`hx-target`](/reference/attributes/hx-target) | +| `swap` | [`HX-Reswap`](/reference/headers/HX-Reswap) | [`hx-swap`](/reference/attributes/hx-swap) | +| `select` | [`HX-Reselect`](/reference/headers/HX-Reselect) | [`hx-select`](/reference/attributes/hx-select) | + +`content` uses the same `hx-target`, `hx-swap`, and `hx-select` attributes as plain HTML. `hx-swap-oob` and `` inside it still produce independent swaps. The JSON fields override the corresponding attributes: ```text TARGET -JSON target --> hx-target --> connection element +JSON target --> hx-target --> connection element* SWAP -JSON swap --> hx-swap --> defaultSwap when a target is set +JSON swap --> hx-swap --> defaultSwap + +SELECT +JSON select --> hx-select --> all content + +* incoming messages with a matching HX-Message-ID use the sending element ``` -`hx-swap-oob` and `` inside `content` still produce independent swaps. +`hx-select-oob` remains an element setting. A server can use `hx-swap-oob` or `` inside `content` instead. ### Handle Custom Messages @@ -235,22 +282,36 @@ JSON without `content` is not swapped: } ``` -Handle it with [`htmx:before:ws:message`](#htmxbeforewsmessage): +Handle it with [`htmx:ws:before:message:incoming`](#htmxwsbeforemessageincoming): ```js -document.addEventListener('htmx:before:ws:message', event => { - let message = event.detail.message.json - if (message?.type === 'notification') showNotification(message) +document.addEventListener('htmx:ws:before:message:incoming', async event => { + let message = await event.detail.message.json() + if (message.type === 'notification') showNotification(message) }) ``` -The event exposes: +Cancel the event to take over custom or binary processing: + +```js +document.addEventListener('htmx:ws:before:message:incoming', async event => { + event.preventDefault() + handleCustomMessage(await event.detail.message.text()) +}) +``` -- `message.text`: the original text -- `message.json`: the parsed object, or `null` -- `message.cancelled`: set to `true` to skip built-in handling +`message.data` contains the original string, `Blob`, or `ArrayBuffer`. -You can also call `event.preventDefault()` to take over processing. +Conversions are cached: + +```js +await message.text() +await message.json() +await message.blob() +await message.arrayBuffer() +``` + +Cancel to skip built-in handling. Binary messages are not swapped automatically. ### Persistent Connections @@ -275,12 +336,33 @@ All [`hx-trigger` modifiers](/reference/attributes/hx-trigger#event-modifiers) a Give `hx-ws:send` a URL to open a connection: ```html - ``` -Clicking the button opens `/actions` and sends the values over that connection. +Clicking the button opens `/actions` and sends `action=refresh` over that connection. + +##### Send During Reconnect + +A user can trigger messages while a connection is reconnecting: + +```html +
+ + +
+``` + +If the user clicks **Save**, then **Refresh**, htmx sends both when the connection opens: + +```jsonc +// First +{ "headers": { /* ... */ }, "action": "save" } + +// Then +{ "headers": { /* ... */ }, "action": "refresh" } +``` ##### Use Shared Connections @@ -288,44 +370,61 @@ Put several [`hx-ws:send`](#hx-wssend) elements inside one [`hx-ws:connect`](#hx ```html
- -
+
``` -Both buttons use the same WebSocket connection. Copy an outgoing [`HX-Request-ID`](#hx-request-id) into the top level of its incoming message to use the sending button's target: +Both buttons use the same WebSocket connection, but each incoming message needs the right target. + +**Route Incoming Messages** + +Copy an outgoing [`HX-Message-ID`](#hx-message-id) into the incoming message: ```json { - "HX-Request-ID": "550e8400-e29b-41d4-a716-446655440000", + "headers": { + "HX-Message-ID": "550e8400-e29b-41d4-a716-446655440000" + }, "content": "

Saved

" } ``` -Without the ID, a live connection element handles the message. +Without the ID, the connection element handles the message. + +With the ID: + +- Save uses `#save-result` +- Delete uses `#delete-result` +- Relative targets and swap events use the sending button -Separate `hx-ws:connect` elements with the same URL also share one connection: +**Reuse by URL** + +Separate `hx-ws:connect` elements with the same URL share a connection too: ```html
``` -The connection closes when htmx removes its last element. +Only one connection to `/actions` is opened. It closes when htmx removes its last element. #### Close Connections -By default, a WebSocket close schedules a reconnect. Set [`ws.reconnect:false`](#wsreconnect) when the connection should remain closed: +Close with code `1000` to stop reconnecting: -```html -
+```js +socket.close(1000, 'done') ``` +Codes in [`ws.reconnectCodes`](#wsreconnectcodes) reconnect instead. + #### Configure Connections You can configure `hx-ws` in three places: @@ -361,27 +460,28 @@ These values are read when the connection is created. Opens a WebSocket connection: ```html -
+
``` -Incoming HTML uses: - -- [`hx-target`](/reference/attributes/hx-target): enables the normal swap and chooses its target -- [`hx-swap`](/reference/attributes/hx-swap): defaults to [`htmx.config.defaultSwap`](/reference/config/htmx-config-defaultSwap) when a target is set +Incoming HTML uses these inherited swap attributes: -Without an element or JSON target, ordinary incoming HTML uses `swap:none`. Explicit `hx-swap-oob` and `` swaps still run. +- [`hx-target`](/reference/attributes/hx-target): defaults to the connection element +- [`hx-swap`](/reference/attributes/hx-swap): defaults to [`htmx.config.defaultSwap`](/reference/config/htmx-config-defaultSwap) +- [`hx-select`](/reference/attributes/hx-select): selects content for the connection's swap +- [`hx-select-oob`](/reference/attributes/hx-select-oob): selects more elements to swap -Other defaults: +Defaults: -- [`hx-trigger="load"`](/reference/attributes/hx-trigger#load) +- [`swapEmpty:false`](/reference/attributes/hx-swap#swapempty); set it explicitly in `hx-swap` to override it +- [`hx-trigger="load"`](/reference/attributes/hx-trigger#load); use [`hx-trigger`](#open-connections) to change it - [`ws.reconnect:true`](#wsreconnect) - [`ws.pauseOnBackground:true`](#wspauseonbackground) -Elements using the same normalized URL share one connection. +[Elements using the same URL share one connection](#use-shared-connections). ### `hx-ws:send` -Sends form data and [`hx-vals`](/reference/attributes/hx-vals) as `{headers, body}` JSON. +Sends form data and [`hx-vals`](/reference/attributes/hx-vals) as JSON. ```html
@@ -398,42 +498,84 @@ Sends form data and [`hx-vals`](/reference/attributes/hx-vals) as `{headers, bod Default [`hx-trigger`](/reference/attributes/hx-trigger): -- `change` for inputs other than button and submit inputs, plus `