Markdown Files
ssexi is a companion library for fixi.js that adds automatic Server-Sent Events (SSE) support.
Part of the fixi project.
When a fixi fetch() returns a response with Content-Type: text/event-stream, ssexi takes over and
streams HTML into the target element as messages arrive.
Here is an example:
<script src="fixi.js"></script>
<script src="ssexi.js"></script>
<button fx-action="/stream"
fx-swap="beforeend"
fx-target="#output">
Start Stream
</button>
<div id="output"></div>
When the button is clicked, fixi issues a GET to /stream. If the server responds with
Content-Type: text/event-stream, ssexi parses the SSE stream and swaps each message's data into the
#output div, appending via beforeend.
No special attributes are needed; ssexi detects SSE responses automatically.
Minimalism
ssexi shares fixi's philosophy of radical minimalism. It adds SSE streaming support in a single file with no additional attributes, no configuration, and no dependencies beyond fixi itself.
Like fixi, ssexi takes advantage of modern JavaScript features:
asyncgenerators for parsing SSE streams- The Streams API via
ReadableStream.getReader() TextDecoderfor streaming byte-to-text decoding
A hard constraint is that the unminified, uncompressed size of ssexi.js stays below the minified + gzipped size of preact. Current sizes are listed on the fixi project site.
The ssexi project consists of four files:
ssexi.js, the code for the librarytest.html, the test suite for the library- This
README.md, which is the documentation npm.sh, which generates npm releases of the library
Installing
ssexi is designed to be easily vendored, that is, copied, into your project alongside your copy of fixi:
curl https://raw.githubusercontent.com/bigskysoftware/ssexi/refs/heads/main/ssexi.js >> ssexi.js
You can also use the JSDelivr CDN for local development or testing:
<script src="https://cdn.jsdelivr.net/gh/bigskysoftware/ssexi@main/ssexi.js"></script>
Finally, ssexi is available on NPM as the ssexi package.
Support
You can get support for ssexi via:
Modus Operandi
ssexi is implemented as a single fx:config event listener. I encourage you to look at
the source; it is short enough to read in a few minutes.
Integration With fixi
When fixi fires the fx:config event, ssexi wraps the cfg.fetch
function. The wrapper calls the real fetch(), checks the Content-Type header of the response, and if it contains
text/event-stream, ssexi takes over:
- An
fx:sse:openevent is fired on the target element - The response body is read as a stream and parsed according to the SSE specification
- For each message, an
fx:sse:messageevent is fired - Unnamed messages (no
event:field) have theirdataswapped into the target element - Named messages (with
event:field) are dispatched asfx:sse:{eventName}events and are not swapped - When the stream ends, an
fx:sse:closeevent is fired
If the response is not text/event-stream, it passes through to fixi untouched.
Accept Header
Loading ssexi sets a default Accept: text/html, text/event-stream header on every fixi
request, so that backends doing content negotiation can decide whether to return a one-shot
HTML fragment or an SSE stream from the same URL. The header is added with ??=, so any
Accept you've already set (in an fx:config listener, or via window.fixiCfg.headers)
wins:
elt.addEventListener('fx:config', (e) => {
// overrides ssexi's default for this element
e.detail.cfg.headers.Accept = 'text/event-stream'
})
text/html is always listed so auth redirects, error pages, and HTML-only endpoints keep
working unchanged. Servers that don't look at Accept are unaffected.
SSE Parsing
ssexi implements a compliant SSE parser as an async generator. It handles:
- Line endings:
\r\n,\r, or\n - Comments (lines starting with
:) - Multi-line
datafields (joined with\n) - The
event,id, andretryfields - Chunked delivery (partial lines buffered across reads)
The cfg.sse Object
When ssexi detects an SSE response, it creates a cfg.sse object on the fixi config with the following properties:
lastEventId- theidof the most recently received message (updated as messages arrive)retry- the most recentretry:value from the server (in milliseconds), ornullreader- theReadableStreamDefaultReaderfor the response body
These properties are available in all ssexi events and provide the plugin points needed to implement reconnection, background disconnecting, and stream cancellation:
target.addEventListener("fx:sse:close", (evt) => {
let {lastEventId, retry} = evt.detail.cfg.sse
// use lastEventId and retry to implement reconnection logic
})
target.addEventListener("fx:sse:open", (evt) => {
let reader = evt.detail.cfg.sse.reader
// store reader reference for later cancellation
})
Swapping
For SSE responses, ssexi uses the fx-swap value from fixi's config.
Common swap styles for SSE:
fx-swap | behavior |
|---|---|
innerHTML | Each message replaces the target's content (good for progressive rendering) |
beforeend | Each message is appended to the target (good for chat, feeds, logs) |
afterbegin | Each message is prepended to the target |
outerHTML | First message replaces the target element, subsequent messages append after it |
outerHTML Behavior
When fx-swap is outerHTML (fixi's default), ssexi handles it specially for streaming:
- The first message replaces the target element via
outerHTML, just as fixi normally would - Subsequent messages are appended after the replaced content via
afterend - An internal anchor element is used to track the insertion point and is removed when the stream ends
This means the original target element is replaced by the first message's HTML, and subsequent messages accumulate
after it. Because the original target is replaced, ssexi events after the first message will bubble through the
anchor's parent rather than the original target; listen on a parent element or document when using outerHTML:
document.addEventListener("fx:sse:message", (evt) => {
console.log("message:", evt.detail.message.data)
})
You can also set cfg.sseSwap in the fx:config event to use a different swap style for SSE than for normal
responses:
document.addEventListener("fx:config", (evt) => {
evt.detail.cfg.sseSwap = "beforeend"
})
Routing One Stream To Multiple Targets
An SSE message's event: field is normally a name (and dispatches fx:sse:{name} without
swapping; see fx:sse:{eventName}). As a special case, if the event:
value parses as JSON, ssexi treats it as a per-message override of the swap parameters.
All fields are optional:
| field | default | effect |
|---|---|---|
target | cfg.target | CSS selector for where this message's data is swapped |
swap | cfg.sseSwap / cfg.swap | Swap style for this message (innerHTML, beforeend, ...) |
transition | none | If truthy, wrap this swap in document.startViewTransition |
event: {"target":"#clock"}
data: 12:34:56
event: {"target":"#log","swap":"beforeend"}
data: <div class="line">user signed in</div>
event: {"transition":true}
data: <p>same target, but morphed via a view transition</p>
This lets one SSE connection fan out to several panels at once, each with its own swap
mode. target is resolved with document.querySelector; if it doesn't match anything
the message is dropped silently.
The JSON must start with { to be recognised; anything else is treated as a regular
named event and dispatched without swapping.
Transitions
ssexi does not wrap every swap in a View
Transition. View
transitions don't queue (a new one cancels the previous one's .finished promise), so
wrapping each frame of a streamed response would either serialise the stream into
multi-second sequences or strand a transition mid-flight. The default is plain swaps;
reach for ordinary CSS transitions on the swapped content for continuous animations.
For occasional, deliberate moments where a view transition is what you want, set
{"transition": true} in a JSON event (see the routing table above). ssexi will
await cfg.transition(swap).finished for that single message before reading the next
one, so the rest of the stream stays paused while the transition plays. Use it sparingly
on slow-moving streams; firing transition messages back-to-back will still cause earlier
ones to abort.
Events
ssexi fires the following events on the target element. All events bubble, are composed, and are cancelable.
| event | detail | description |
|---|---|---|
fx:sse:open | cfg, response | Fired when an SSE stream is detected. Cancel to prevent processing. |
fx:sse:message | cfg, message | Fired for every SSE message before swapping. Cancel to stop the stream. |
fx:sse:swapped | cfg, message | Fired after a message's content has been swapped into the target. Use this for post-swap reactions like auto-scroll. |
fx:sse:{eventName} | cfg, message | Fired for messages with an event: field. These are not swapped. |
fx:sse:close | cfg | Fired when the stream ends normally. |
fx:sse:error | cfg, error | Fired if an error occurs during streaming. |
fx:sse:open
Fired on the target element when a response with Content-Type: text/event-stream is detected. The evt.detail
contains cfg (the fixi config object) and response (the fetch Response).
If you call preventDefault() on this event, the stream will not be processed and the target will not be modified.
fx:sse:message
Fired for every SSE message (both named and unnamed). The evt.detail.message object has the following
properties:
data- the message data (multi-linedata:fields joined with\n)event- the event name (empty string if unnamed)id- the message id (empty string if not set)retry- the reconnection delay in milliseconds (if aretry:field was present), ornull
If you call preventDefault() on this event, the stream will stop processing (the current message will not be
swapped or dispatched, and no further messages will be read).
You can also use this event to modify the message data before it is swapped:
target.addEventListener("fx:sse:message", (evt) => {
evt.detail.message.data = markdown(evt.detail.message.data)
})
fx:sse:swapped
Fired on the target element after an unnamed message's data has been swapped in.
The evt.detail is the same shape as fx:sse:message (cfg, message), but at this
point the new content is already in the DOM, so reading layout properties returns post-swap
values. Useful for auto-scroll, syntax-highlighting newly streamed code, etc.:
<div id="log" on-fx:sse:swapped="this.scrollTop = this.scrollHeight"></div>
Not fired for named events (which aren't swapped) or for cancelled fx:sse:message events.
fx:sse:{eventName}
When an SSE message has an event: field, ssexi dispatches a custom event with that name prefixed by fx:sse:.
For example, a message with event: status will fire fx:sse:status on the target element.
Named events are not swapped into the DOM; they are for JavaScript handling:
target.addEventListener("fx:sse:status", (evt) => {
console.log("status update:", evt.detail.message.data)
})
fx:sse:close
Fired when the SSE stream ends normally (the server closes the connection).
fx:sse:error
Fired if an error occurs during stream processing. The evt.detail.error property contains the thrown value.
Server Side
Your server endpoint should respond with Content-Type: text/event-stream and send
SSE-formatted
messages:
data: <p>First update</p>
data: <p>Second update</p>
event: done
data: finished
Each message is one or more data: lines followed by a blank line. Messages without an event: field will have their
data swapped into the target. Messages with an event: field will be dispatched as DOM events.
Example: Python/Flask
from flask import Flask, Response
import time
app = Flask(__name__)
@app.route('/stream')
def stream():
def generate():
for i in range(5):
yield f"data: <p>Message {i + 1}</p>\n\n"
time.sleep(1)
yield "event: done\ndata: finished\n\n"
return Response(generate(), content_type='text/event-stream')
Example: Node/Express
app.get('/stream', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
let i = 0
let interval = setInterval(() => {
if (++i > 5) {
res.write('event: done\ndata: finished\n\n')
res.end()
clearInterval(interval)
} else {
res.write(`data: <p>Message ${i}</p>\n\n`)
}
}, 1000)
})
Examples
Streaming Chat
<form fx-action="/chat" fx-method="POST"
fx-swap="beforeend" fx-target="#messages">
<input name="message" placeholder="Type a message...">
<button>Send</button>
</form>
<div id="messages"></div>
Each SSE message from the server appends a new HTML fragment to the #messages div.
Progressive Rendering
<button fx-action="/render" fx-swap="innerHTML" fx-target="#content">
Load Content
</button>
<div id="content">Click to load...</div>
Each SSE message replaces the content of #content, allowing the server to progressively refine the output.
Closing a Stream on a Named Event
<div id="feed"></div>
<script>
document.getElementById("feed").addEventListener("fx:sse:done", (evt) => {
console.log("stream complete")
})
</script>
<button fx-action="/feed" fx-swap="beforeend" fx-target="#feed">
Start Feed
</button>
When the server sends event: done, the fx:sse:done event fires on the target. The stream continues to
completion naturally; the named event is simply dispatched for your code to react to.
Stopping a Stream Early
You can stop processing a stream by canceling the fx:sse:message event:
<button fx-action="/long-stream" fx-swap="beforeend" fx-target="#out">
Start
</button>
<button onclick="document.getElementById('out').dataset.stop = 'true'">
Stop
</button>
<div id="out"></div>
<script>
document.getElementById("out").addEventListener("fx:sse:message", (evt) => {
if (evt.target.dataset.stop) evt.preventDefault()
})
</script>
Reconnection and Lifecycle
ssexi supports three opt-in config flags for managing stream lifecycle. Set them in an
fx:config listener (or on the returned cfg before the stream starts):
| flag | behavior |
|---|---|
cfg.sseReconnect | On close or error, wait sse.retry ms (or 3000) and re-fetch with a Last-Event-ID header. |
cfg.ssePauseOnHidden | Cancel the reader when document.hidden; resume (with Last-Event-ID) when visible. |
cfg.sseDisconnectOnHidden | Close the stream when document.hidden. No resume; the caller must re-trigger. |
Example:
btn.addEventListener("fx:config", (e) => {
e.detail.cfg.sseReconnect = true
e.detail.cfg.ssePauseOnHidden = true
})
cfg.sse.close()
At any time you can stop the stream (and the reconnect loop) by calling cfg.sse.close().
It sets cfg.sse.closed = true and cancels the underlying reader:
target.addEventListener("fx:sse:message", (e) => {
if (shouldStop(e.detail.message)) e.detail.cfg.sse.close()
})
Custom Reconnect Policy
If the built-in reconnect doesn't match your needs (e.g. you want exponential backoff),
leave cfg.sseReconnect off and implement your own in an fx:sse:close / fx:sse:error
listener using cfg.trigger to re-fire the triggering event:
document.addEventListener("fx:sse:close", (evt) => {
let cfg = evt.detail.cfg, elt = cfg.trigger.target
if (!elt.isConnected) return
let attempt = elt.__ssexiAttempt = (elt.__ssexiAttempt || 0) + 1
let delay = Math.min((cfg.sse?.retry || 500) * 2 ** (attempt - 1), 60000)
delay += delay * 0.3 * (Math.random() * 2 - 1) // jitter
setTimeout(() => elt.dispatchEvent(new Event(cfg.trigger.type)), delay)
})
Note that cancelling the reader will cause an fx:sse:error event to fire (not fx:sse:close), since the stream
did not end naturally. You can alternatively use cfg.abort() to abort the underlying fetch, which has the same
effect.
Mocking
You can mock SSE responses the same way you mock regular fixi responses, by replacing cfg.fetch in the
fx:config event. The mock should return a Response with a ReadableStream body and
Content-Type: text/event-stream:
document.addEventListener("fx:config", (evt) => {
evt.detail.cfg.fetch = () => {
let encoder = new TextEncoder()
let messages = ["data: hello\n\n", "data: world\n\n"]
let i = 0
let stream = new ReadableStream({
pull(controller) {
if (i < messages.length)
controller.enqueue(encoder.encode(messages[i++]))
else
controller.close()
}
})
return Promise.resolve(
new Response(stream, {headers: {'Content-Type': 'text/event-stream'}})
)
}
})
LICENCE
Zero-Clause BSD
=============
Permission to use, copy, modify, and/or distribute this software for
any purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL
WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES
OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE
FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY
DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.