solovyov.net

Server-Sent Events, but with POST

6 min read · js, programming

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 it’s 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. Wooohooo momma I’m a web developer help me before I killed a man.

One option is to reject that promise by myself, 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. :)

If you like what you read — subscribe to my Twitter, I always post links to new posts there. Or, in case you're an old school person longing for an ancient technology, put a link to my RSS feed in your feed reader (it's actually Atom feed, but who cares).

Other recent posts

ngrok for the wicked, or expose your ports comfortably
PostgreSQL collation
History Snapshotting in TwinSpark
Code streaming: hundred ounces of nuances