I really like Server-Sent Events: the protocol is quite simple and
effective, and using it from a browser is easy. Listen for a message
event
on an EventSource
, and it just works. gostatic hot reload functionality is
built using SSE, and it works very well and takes just a pinch of code.
Let’s get to my current usecase though. I’m making an AI-powered editor right now - and streaming response from OpenAI API looks so much better than a really long wait for a full response. Plus you can abort that response in-flight if you see it going awry.
Which means I need to send context to a server, right? And the only way to
convey anything to a server using EventSource
object is through the URL. There
is no way I can pass enough context through an URL, and I really don’t want to
use WebSockets here. It’s a totally different beast, and why would I reach for
something that different if I have an almost perfect tool for that job?
So I searched the internets a bit and found an implementation of SSE using fetch. I had to upgrade it a little bit — there was no protocol parsing, but it’s just a few lines of code and it works beautifully.
Except later on I decided to make that “abort” button. How do you abort a
fetch
request? You create an AbortController
, then pass it as
fetch(url, {signal: controller.signal})
, and then call an .abort()
method. Awkward? Very much so. But at the very least is it working? Not at all!
I mean, yeah, request is aborted. But your promise (the one that fetch
returned) is never rejected (nor resolved, of course). And in Firefox’ devtools
console you get an error pointing to the line with controller.abort()
call. You can’t catch it with a try/catch
. Of course! Chrome is even better:
it reports an error on the first line of your HTML. OooOoOoo momma I’m a web
developer help me before I killed a man.
One option is to reject that promise by myself, but it sounds dirty and does not
get rid of the weird errors in devtools. So I reached to an old friend
XMLHttpRequest
and that guy is reliable as ever! Behold the mighty:
function sseevent(message) {
let type = 'message', start = 0;
if (message.startsWith('event: ')) {
start = message.indexOf('\n');
type = message.slice(7, start);
}
start = message.indexOf(': ', start) + 2;
let data = message.slice(start, message.length);
return new MessageEvent(type, {data: data})
}
export function XhrSource(url, opts) {
const eventTarget = new EventTarget();
const xhr = new XMLHttpRequest();
xhr.open(opts.method || 'GET', url, true);
for (var k in opts.headers) {
xhr.setRequestHeader(k, opts.headers[k]);
}
var ongoing = false, start = 0;
xhr.onprogress = function() {
if (!ongoing) {
// onloadstart is sync with `xhr.send`, listeners don't have a chance
ongoing = true;
eventTarget.dispatchEvent(new Event('open', {
status: xhr.status,
headers: xhr.getAllResponseHeaders(),
url: xhr.responseUrl,
}));
}
var i, chunk;
while ((i = xhr.responseText.indexOf('\n\n', start)) >= 0) {
chunk = xhr.responseText.slice(start, i);
start = i + 2;
if (chunk.length) {
eventTarget.dispatchEvent(sseevent(chunk));
}
}
}
xhr.onloadend = _ => {
eventTarget.dispatchEvent(new CloseEvent('close'))
}
xhr.timeout = opts.timeout;
xhr.ontimeout = _ => {
eventTarget.dispatchEvent(new CloseEvent('error', {reason: 'Network request timed out'}));
}
xhr.onerror = _ => {
eventTarget.dispatchEvent(new CloseEvent('error', {reason: xhr.responseText || 'Network request failed'}));
}
xhr.onabort = _ => {
eventTarget.dispatchEvent(new CloseEvent('error', {reason: 'Network request aborted'}));
}
eventTarget.close = _ => {
xhr.abort();
}
xhr.send(opts.body);
return eventTarget;
}
That’s the full implementation of an EventSource
(at least for my use case),
even the method to close connection is called close()
, just as in real
EventSource
. So you would use it like that (just an example, tune for your own
needs):
const xs = XhrSource('/your/api/url/', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({some: data})
});
xs.addEventListener('error', e => {
outputEl.textContent += 'ERROR: ' + e.reason;
});
xs.addEventListener('close', e => {
outputEl.textContent += '\nDONE';
});
xs.addEventListener('message', e => {
const msg = JSON.parse(e.data);
outputEl.textContent += msg.content;
});
One interesting thing to note here is that loadstart
event is sent
synchronously with the xhr.send(opts.body)
call - before the return
happens. This feels weird to me, since no listeners are ready by the time… But
if I put xhr.send
in setTimeout
, then data starts arriving visibly
later. I have no idea why, profiling and looking at network panel did not give
me any insights, so if you know what’s up or get other results or anything -
hit me up, I’d love to understand what is going on.
All in all it feels like this post took more time to write than the code - and it’s much simpler than DecodablePipe stuff from Rob’s post. Works well for me, so maybe it’ll do the same for you. :)