Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 113 additions & 1 deletion src/sse/README.md
Original file line number Diff line number Diff line change
@@ -1 +1,113 @@
See https://htmx.org/extensions/sse, or https://github.com/bigskysoftware/htmx/blob/master/www/content/extensions/sse.md
# htmx-ext-sse — Server Sent Events extension

The canonical documentation lives at <https://htmx.org/extensions/sse> (source: <https://github.com/bigskysoftware/htmx/blob/master/www/content/extensions/sse.md>).

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
<div hx-ext="sse"
sse-connect="/events"
hx-vals='{"room": "lobby", "token": "abc123"}'>
...
</div>
```

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. `<script src="https://cdn.jsdelivr.net/npm/sse.js@2/lib/sse.min.js"></script>`) 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
<div hx-ext="sse"
sse-connect="/api/stream"
hx-vals='{"query": "kittens"}'
sse-swap="result">
Waiting…
</div>
```

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 <https://htmx.org/extensions/sse>:

| Event | Description |
| --- | --- |
| `htmx:sseConfigConnect` | Fired before each EventSource is constructed. Cancelable. `detail` contains `url`, `options`, `parameters`, `headers`, `elt`. |
87 changes: 83 additions & 4 deletions src/sse/sse.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('&')
}

/**
Expand Down Expand Up @@ -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
Expand Down
126 changes: 125 additions & 1 deletion src/sse/test/ext/sse.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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('<div hx-ext="sse" sse-connect="/foo"></div>')
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('<div hx-ext="sse" sse-connect="/foo" hx-vals=\'{"a":"1","b":"hello world"}\'></div>')
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('<div hx-ext="sse" sse-connect="/foo?x=1" hx-vals=\'{"a":"2"}\'></div>')
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('<div hx-ext="sse" sse-connect="/foo"></div>')
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('<div hx-ext="sse" sse-connect="/foo"></div>')
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('<div hx-ext="sse" sse-connect="/foo"></div>')
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('<div hx-ext="sse" sse-connect="/foo"></div>')
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('<div hx-ext="sse" sse-connect="/foo"></div>')
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('<div hx-ext="sse" sse-connect="/foo" hx-vals=\'{"a":"1","b":"2"}\'></div>')
this.clock.tick(1)

capturedParameters.a.should.equal('1')
capturedParameters.b.should.equal('2')
} finally {
htmx.off('htmx:sseConfigConnect', handler)
}
})
})
Loading