From c8c52c5cb3769ad3be288b52d4a64ed81e27ca1a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 17:17:39 +0000 Subject: [PATCH] Add configurable SSE/WS connections (hx-vals + config events) with tests and docs Agent-Logs-Url: https://github.com/XChikuX/htmx-extensions/sessions/276ae3c0-c984-43d2-9286-fa4a1845c56b Co-authored-by: XChikuX <5894493+XChikuX@users.noreply.github.com> --- src/sse/README.md | 114 +++++++++++++++++++++++++++++++++++- src/sse/sse.js | 87 +++++++++++++++++++++++++-- src/sse/test/ext/sse.js | 126 +++++++++++++++++++++++++++++++++++++++- src/ws/README.md | 64 +++++++++++++++++++- src/ws/test/ext/ws.js | 78 ++++++++++++++++++++++++- src/ws/ws.js | 56 ++++++++++++++++++ 6 files changed, 517 insertions(+), 8 deletions(-) diff --git a/src/sse/README.md b/src/sse/README.md index e7f38c6..2a0ee14 100644 --- a/src/sse/README.md +++ b/src/sse/README.md @@ -1 +1,113 @@ -See https://htmx.org/extensions/sse, or https://github.com/bigskysoftware/htmx/blob/master/www/content/extensions/sse.md \ No newline at end of file +# htmx-ext-sse — Server Sent Events extension + +The canonical documentation lives at (source: ). + +This page documents extension features that go beyond the canonical reference, in particular how to configure the SSE connection (set query parameters from `hx-vals`, override the URL, send Authorization headers, perform a `POST` connection via an `EventSource` polyfill, etc.). + +## Configuring the connection + +### Sending `hx-vals` as query parameters + +Any values declared on the connecting element through [`hx-vals`](https://htmx.org/attributes/hx-vals/) (or `hx-vars`) are automatically URL-encoded and appended to the `sse-connect` URL as query parameters before the `EventSource` is opened. Existing query strings on the URL are preserved. + +```html +
+ ... +
+``` + +Opens an `EventSource` against `/events?room=lobby&token=abc123`. + +### The `htmx:sseConfigConnect` event + +Right before the extension calls `htmx.createEventSource(url, options)` it fires a cancelable `htmx:sseConfigConnect` event on the element. The event's `detail` object lets you inspect and override everything about the connection: + +| `detail` property | Description | +| --- | --- | +| `url` | The fully-built URL (including any appended `hx-vals`). Mutate to change. | +| `options` | The object passed as the second argument to the `EventSource` constructor. Defaults to `{ withCredentials: true }`. | +| `parameters` | The plain object of `hx-vals` / `hx-vars` collected from the element. | +| `headers` | The standard htmx headers object (as produced by the internal `getHeaders` API). | +| `elt` | The element that initiated the connection. | + +Calling `event.preventDefault()` aborts the connection entirely; this is useful when, for example, you want to wait for an auth token before opening the stream. + +```javascript +document.body.addEventListener('htmx:sseConfigConnect', function (evt) { + // Add a dynamic query parameter + evt.detail.url += (evt.detail.url.includes('?') ? '&' : '?') + 'tab=' + activeTab() + // Disable credentials for this particular connection + evt.detail.options.withCredentials = false +}) +``` + +## Using a custom `EventSource` (and sending Authorization headers / POST bodies) + +The native browser `EventSource` does **not** support custom request headers, custom HTTP methods, or request bodies. To enable those things — for example to send an `Authorization: Bearer …` header, or to `POST` a payload to start the stream — you can drop in a polyfill that mimics the `EventSource` API. A widely used one is [`sse.js`](https://github.com/mpetazzoni/sse.js) by Maxime Petazzoni. + +The extension exposes two seams that make this completely transparent: + +1. `htmx.createEventSource(url, options)` is a function on the global `htmx` object that the extension uses to construct every connection. Replace it with your own factory to use any EventSource-shaped object you like. +2. The `options` argument is whatever you put into `evt.detail.options` from a `htmx:sseConfigConnect` handler (defaulting to `{ withCredentials: true }`). Polyfills such as `sse.js` accept extra keys here — `method`, `headers`, `payload`, `start`, etc. + +### Step-by-step: send Authorization headers with a POST SSE connection + +> The example below assumes you've loaded `sse.js` (e.g. ``) so that the global `SSE` constructor is available. + +1. **Load `sse.js` before the htmx SSE extension.** The extension reads `htmx.createEventSource` lazily on every connection, so the order matters only for the override step below — but loading `sse.js` first is the simplest setup. + +2. **Tell htmx to use `SSE` instead of the built-in `EventSource`.** `sse.js` exposes its constructor as both `SSE` and `EventSource` (you can also globally replace `window.EventSource = SSE` per the `sse.js` README, but the explicit override below is safer because it only affects htmx and keeps any other code using the real `EventSource`): + + ```javascript + htmx.createEventSource = function (url, options) { + // `options` is the object that the SSE extension built and that any + // htmx:sseConfigConnect handler may have customized. + return new SSE(url, options) + } + ``` + +3. **Use a `htmx:sseConfigConnect` listener to fill in the headers / method / payload.** Anything you put on `evt.detail.options` will reach the constructor above. + + ```javascript + document.body.addEventListener('htmx:sseConfigConnect', function (evt) { + evt.detail.options.method = 'POST' + evt.detail.options.headers = { + 'Authorization': 'Bearer ' + localStorage.getItem('token'), + 'Content-Type': 'application/json' + } + // Move the hx-vals out of the URL and into the POST body + evt.detail.options.payload = JSON.stringify(evt.detail.parameters) + evt.detail.url = evt.detail.url.split('?')[0] + }) + ``` + +4. **Use the SSE extension as normal.** Nothing in your markup has to change: + + ```html +
+ Waiting… +
+ ``` + + The extension will: + + - collect `{"query": "kittens"}` from `hx-vals`, + - fire `htmx:sseConfigConnect` so your listener can rewrite the URL/options as shown above, + - and finally call `htmx.createEventSource(url, options)`, which constructs an `SSE` (from `sse.js`) that performs a `POST /api/stream` with your Authorization header and a JSON body of `{"query":"kittens"}`. + +### Reusing the same handler for many endpoints + +Because `htmx:sseConfigConnect` bubbles, a single listener on `document.body` can configure every SSE connection on the page. Use `evt.detail.elt` (or its attributes) to decide whether to act on a specific connection. + +## Events fired by this extension + +In addition to those documented at : + +| Event | Description | +| --- | --- | +| `htmx:sseConfigConnect` | Fired before each EventSource is constructed. Cancelable. `detail` contains `url`, `options`, `parameters`, `headers`, `elt`. | diff --git a/src/sse/sse.js b/src/sse/sse.js index 886e3f9..866619d 100644 --- a/src/sse/sse.js +++ b/src/sse/sse.js @@ -68,13 +68,58 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions /** * createEventSource is the default method for creating new EventSource objects. - * it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed. + * It is hoisted into htmx.createEventSource to be overridden by the user, if needed. + * + * The `options` argument is the object passed as the second parameter to the + * `EventSource` constructor. The browser's built-in `EventSource` only reads + * the `withCredentials` flag from it, but EventSource polyfills (e.g. `sse.js` + * from https://github.com/mpetazzoni/sse.js) can support additional + * properties such as `method`, `headers`, `payload`, etc., which makes it + * possible to perform POST connections, send Authorization headers, and so + * on. See the extension README for examples. * * @param {string} url + * @param {EventSourceInit & {headers?: Object, method?: string, payload?: any}} [options] * @returns EventSource */ - function createEventSource(url) { - return new EventSource(url, { withCredentials: true }) + function createEventSource(url, options) { + return new EventSource(url, options || { withCredentials: true }) + } + + /** + * appendValuesToUrl serializes `values` as URL query parameters and appends + * them to `url`, preserving any query string that may already be present. + * + * @param {string} url + * @param {Object} values + * @returns {string} + */ + function appendValuesToUrl(url, values) { + if (!values) { + return url + } + var parts = [] + for (var key in values) { + if (!Object.prototype.hasOwnProperty.call(values, key)) { + continue + } + var value = values[key] + if (value === null || value === undefined) { + continue + } + if (Array.isArray(value)) { + for (var i = 0; i < value.length; i++) { + parts.push(encodeURIComponent(key) + '=' + encodeURIComponent(value[i])) + } + } else { + parts.push(encodeURIComponent(key) + '=' + encodeURIComponent(value)) + } + } + if (parts.length === 0) { + return url + } + var separator = url.indexOf('?') === -1 ? '?' : '&' + return url + separator + parts.join('&') } /** @@ -194,7 +239,41 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions } function ensureEventSource(elt, url, retryCount) { - var source = htmx.createEventSource(url) + // Collect any hx-vals / hx-vars (and inputs) declared on the element so + // that they can be sent along with the connection request. For standard + // EventSource these are appended as URL query parameters. For polyfilled + // EventSource implementations that support a body (e.g. sse.js), they are + // also exposed as `parameters` on the config event so user code can move + // them into the request payload if needed. + var expressionVars = api.getExpressionVars(elt) || {} + var headers = api.getHeaders(elt, api.getTarget(elt)) + + // Build the initial options object that will be passed to the EventSource + // constructor. The standard EventSource only reads `withCredentials`, but + // polyfills like sse.js accept `method`, `headers`, `payload`, etc. + var options = { withCredentials: true } + + // Append the gathered values to the URL by default. Users can remove + // them in the htmx:sseConfigConnect handler if they want to send them + // through the body of a POST request instead. + var finalUrl = appendValuesToUrl(url, expressionVars) + + var sseConfig = { + url: finalUrl, + options: options, + parameters: expressionVars, + headers: headers, + elt: elt + } + + // Fire htmx:sseConfigConnect so user code can override the URL or the + // options object (e.g. set method/payload/headers for a polyfilled + // EventSource). If the event is cancelled, abort the connection. + if (!api.triggerEvent(elt, 'htmx:sseConfigConnect', sseConfig)) { + return + } + + var source = htmx.createEventSource(sseConfig.url, sseConfig.options) source.onerror = function(err) { // Log an error event diff --git a/src/sse/test/ext/sse.js b/src/sse/test/ext/sse.js index 3a20bfe..729447a 100644 --- a/src/sse/test/ext/sse.js +++ b/src/sse/test/ext/sse.js @@ -66,9 +66,10 @@ describe('sse extension', function() { this.clock = sinon.useFakeTimers(); var test = this clearWorkArea() - htmx.createEventSource = function(url) { + htmx.createEventSource = function(url, options) { var eventSource = mockEventSource() test.eventSource = eventSource + eventSource.options = options eventSource.connect(url) return eventSource } @@ -702,4 +703,127 @@ describe('sse extension', function() { byId('d1').innerHTML.should.equal('div1 updated') byId('d2').innerHTML.should.equal('div2 updated') }) + + it('passes a default options object containing withCredentials to createEventSource', function() { + make('
') + this.clock.tick(1) + + this.eventSource.url.should.equal('/foo') + this.eventSource.options.should.be.an('object') + this.eventSource.options.withCredentials.should.equal(true) + }) + + it('appends hx-vals to the sse-connect URL as query parameters', function() { + make('
') + this.clock.tick(1) + + // URL contains the appended hx-vals, with proper encoding of spaces + this.eventSource.url.should.contain('/foo?') + this.eventSource.url.should.contain('a=1') + this.eventSource.url.should.contain('b=hello%20world') + }) + + it('preserves an existing query string when appending hx-vals to sse-connect', function() { + make('
') + this.clock.tick(1) + + this.eventSource.url.should.equal('/foo?x=1&a=2') + }) + + it('does not modify the URL when no hx-vals are provided', function() { + make('
') + this.clock.tick(1) + + this.eventSource.url.should.equal('/foo') + }) + + it('fires htmx:sseConfigConnect before opening the EventSource', function() { + var configEvent = null + var handler = function(evt) { + configEvent = evt.detail + } + htmx.on('htmx:sseConfigConnect', handler) + try { + make('
') + this.clock.tick(1) + + configEvent.should.exist + configEvent.url.should.equal('/foo') + configEvent.options.should.be.an('object') + configEvent.options.withCredentials.should.equal(true) + configEvent.parameters.should.be.an('object') + configEvent.headers.should.be.an('object') + configEvent.elt.should.exist + } finally { + htmx.off('htmx:sseConfigConnect', handler) + } + }) + + it('allows htmx:sseConfigConnect to override the connection URL', function() { + var handler = function(evt) { + evt.detail.url = '/overridden?token=abc' + } + htmx.on('htmx:sseConfigConnect', handler) + try { + make('
') + this.clock.tick(1) + + this.eventSource.url.should.equal('/overridden?token=abc') + } finally { + htmx.off('htmx:sseConfigConnect', handler) + } + }) + + it('allows htmx:sseConfigConnect to override the options object (e.g. for an sse.js polyfill)', function() { + var handler = function(evt) { + evt.detail.options.method = 'POST' + evt.detail.options.headers = { Authorization: 'Bearer token' } + evt.detail.options.payload = JSON.stringify({ hello: 'world' }) + } + htmx.on('htmx:sseConfigConnect', handler) + try { + make('
') + this.clock.tick(1) + + this.eventSource.options.method.should.equal('POST') + this.eventSource.options.headers.Authorization.should.equal('Bearer token') + this.eventSource.options.payload.should.equal('{"hello":"world"}') + } finally { + htmx.off('htmx:sseConfigConnect', handler) + } + }) + + it('aborts the connection when htmx:sseConfigConnect is cancelled', function() { + var test = this + test.eventSource = null + var handler = function(evt) { + evt.preventDefault() + } + htmx.on('htmx:sseConfigConnect', handler) + try { + make('
') + this.clock.tick(1) + // The handler cancelled the event, so createEventSource should not have been called + should.not.exist(test.eventSource) + } finally { + htmx.off('htmx:sseConfigConnect', handler) + } + }) + + it('exposes hx-vals through the htmx:sseConfigConnect parameters detail', function() { + var capturedParameters = null + var handler = function(evt) { + capturedParameters = evt.detail.parameters + } + htmx.on('htmx:sseConfigConnect', handler) + try { + make('
') + this.clock.tick(1) + + capturedParameters.a.should.equal('1') + capturedParameters.b.should.equal('2') + } finally { + htmx.off('htmx:sseConfigConnect', handler) + } + }) }) diff --git a/src/ws/README.md b/src/ws/README.md index f5c5b45..164065f 100644 --- a/src/ws/README.md +++ b/src/ws/README.md @@ -1 +1,63 @@ -See https://htmx.org/extensions/ws, or https://github.com/bigskysoftware/htmx/blob/master/www/content/extensions/ws.md \ No newline at end of file +# htmx-ext-ws — WebSockets extension + +The canonical documentation lives at (source: ). + +This page documents extension features that go beyond the canonical reference, in particular how to configure the WebSocket connection (set query parameters from `hx-vals`, override the URL, etc.). + +## Configuring the connection + +### Sending `hx-vals` as query parameters + +Any values declared on the connecting element through [`hx-vals`](https://htmx.org/attributes/hx-vals/) (or `hx-vars`) are automatically URL-encoded and appended to the `ws-connect` URL as query parameters before the WebSocket is opened. Existing query strings on the URL are preserved. + +```html +
+ ... +
+``` + +Opens a WebSocket against `/chat?room=lobby&token=abc123`. This is the recommended way to pass authentication tokens (such as a JWT) to a WebSocket endpoint, since the browser's `WebSocket` constructor does not support custom request headers. + +### The `htmx:wsConfigConnect` event + +Right before the extension calls `htmx.createWebSocket(url)` it fires a cancelable `htmx:wsConfigConnect` event on the element. The event's `detail` object lets you inspect and override the connection: + +| `detail` property | Description | +| --- | --- | +| `url` | The fully-built URL (including any appended `hx-vals`). Mutate to change. | +| `parameters` | The plain object of `hx-vals` / `hx-vars` collected from the element. | +| `elt` | The element that initiated the connection. | + +Calling `event.preventDefault()` aborts the connection entirely; this is useful when, for example, you want to wait for an auth token before opening the socket. + +```javascript +document.body.addEventListener('htmx:wsConfigConnect', function (evt) { + // Add a freshly-minted token at connect time + evt.detail.url += (evt.detail.url.includes('?') ? '&' : '?') + + 'token=' + encodeURIComponent(getAuthToken()) +}) +``` + +This event is the connection-time counterpart of the existing `htmx:wsConfigSend` event, which fires before each message is sent. + +## Customizing the `WebSocket` instance + +The extension uses `htmx.createWebSocket(url)` to construct every WebSocket. Replace that function if you need to use a custom `WebSocket` subclass or set protocols: + +```javascript +htmx.createWebSocket = function (url) { + var ws = new WebSocket(url, ['my-subprotocol']) + ws.binaryType = 'arraybuffer' + return ws +} +``` + +## Events fired by this extension + +In addition to those documented at : + +| Event | Description | +| --- | --- | +| `htmx:wsConfigConnect` | Fired before each WebSocket is constructed. Cancelable. `detail` contains `url`, `parameters`, `elt`. | diff --git a/src/ws/test/ext/ws.js b/src/ws/test/ext/ws.js index 35bf6c7..1a1bb00 100644 --- a/src/ws/test/ext/ws.js +++ b/src/ws/test/ext/ws.js @@ -99,7 +99,9 @@ describe('web-sockets extension', function() { clearWorkArea() this.oldCreateWebSocket = htmx.createWebSocket - htmx.createWebSocket = function() { + var test = this + htmx.createWebSocket = function(url) { + test.lastWebSocketUrl = url mockedSocket.client.connect() return mockedSocket.client } @@ -850,4 +852,78 @@ describe('web-sockets extension', function() { } }) }) + + describe('connection configuration', function() { + it('appends hx-vals to the ws-connect URL as query parameters', function() { + make('
') + this.tickMock() + + this.lastWebSocketUrl.should.contain('ws://localhost:8080/chat?') + this.lastWebSocketUrl.should.contain('room=main') + this.lastWebSocketUrl.should.contain('token=abc') + }) + + it('preserves an existing query string when appending hx-vals to ws-connect', function() { + make('
') + this.tickMock() + + this.lastWebSocketUrl.should.equal('ws://localhost:8080/chat?v=1&room=main') + }) + + it('does not modify the ws-connect URL when no hx-vals are provided', function() { + make('
') + this.tickMock() + + this.lastWebSocketUrl.should.equal('ws://localhost:8080/chat') + }) + + it('fires htmx:wsConfigConnect before opening the WebSocket', function() { + var configEvent = null + var handler = function(evt) { + configEvent = evt.detail + } + htmx.on('htmx:wsConfigConnect', handler) + try { + make('
') + this.tickMock() + + configEvent.should.exist + configEvent.url.should.equal('ws://localhost:8080/chat') + configEvent.parameters.should.be.an('object') + configEvent.elt.should.exist + } finally { + htmx.off('htmx:wsConfigConnect', handler) + } + }) + + it('allows htmx:wsConfigConnect to override the connection URL', function() { + var handler = function(evt) { + evt.detail.url = 'ws://localhost:8080/overridden?token=abc' + } + htmx.on('htmx:wsConfigConnect', handler) + try { + make('
') + this.tickMock() + + this.lastWebSocketUrl.should.equal('ws://localhost:8080/overridden?token=abc') + } finally { + htmx.off('htmx:wsConfigConnect', handler) + } + }) + + it('aborts the connection when htmx:wsConfigConnect is cancelled', function() { + this.lastWebSocketUrl = null + var handler = function(evt) { + evt.preventDefault() + } + htmx.on('htmx:wsConfigConnect', handler) + try { + make('
') + this.tickMock() + should.not.exist(this.lastWebSocketUrl) + } finally { + htmx.off('htmx:wsConfigConnect', handler) + } + }) + }) }) diff --git a/src/ws/ws.js b/src/ws/ws.js index 9ab33bd..a1cd791 100644 --- a/src/ws/ws.js +++ b/src/ws/ws.js @@ -113,6 +113,26 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f } } + // Collect any hx-vals / hx-vars defined on the connecting element and + // append them as query string parameters to the WebSocket URL. This is + // useful for passing dynamic values (e.g. an auth token or a room id) to + // the WebSocket server, since the WebSocket constructor does not support + // custom headers or a body. + var expressionVars = api.getExpressionVars(socketElt) || {} + wssSource = appendValuesToUrl(wssSource, expressionVars) + + // Fire htmx:wsConfigConnect so user code can override the URL before the + // WebSocket is opened. If the event is cancelled, abort the connection. + var connectConfig = { + url: wssSource, + parameters: expressionVars, + elt: socketElt + } + if (!api.triggerEvent(socketElt, 'htmx:wsConfigConnect', connectConfig)) { + return + } + wssSource = connectConfig.url + var socketWrapper = createWebsocketWrapper(socketElt, function() { return htmx.createWebSocket(wssSource) }) @@ -434,6 +454,42 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f return sock } + /** + * appendValuesToUrl serializes `values` as URL query parameters and appends + * them to `url`, preserving any query string that may already be present. + * + * @param {string} url + * @param {Object} values + * @returns {string} + */ + function appendValuesToUrl(url, values) { + if (!values) { + return url + } + var parts = [] + for (var key in values) { + if (!Object.prototype.hasOwnProperty.call(values, key)) { + continue + } + var value = values[key] + if (value === null || value === undefined) { + continue + } + if (Array.isArray(value)) { + for (var i = 0; i < value.length; i++) { + parts.push(encodeURIComponent(key) + '=' + encodeURIComponent(value[i])) + } + } else { + parts.push(encodeURIComponent(key) + '=' + encodeURIComponent(value)) + } + } + if (parts.length === 0) { + return url + } + var separator = url.indexOf('?') === -1 ? '?' : '&' + return url + separator + parts.join('&') + } + /** * queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT. *