Server-Sent Events manipulation

SSE are a powerful way to retrieve real-time data streams from the server to your client.

As Gracile relies on standard the Response API, you can use a ReadableStream for the body to use.
Implement your underlying source for your custom streamer, set the correct content-type to text/event-stream, and you’re good to use Server Sent Events!

Here is how to achieve that within Gracile, in only 3 steps, with this full-stack implementation.


Basic project layout for this demo:

  • my-project/
    • src/
      • routes/
        • server-events.ts - The endpoint that send a stream back.
        • streams.client.ts - Where we put the client-only custom element.
        • streams.ts - The page for testing SSE in the browser.

1. Define a route and a client view

Nothing outstanding here.
As usual, we define a route, here /streams/, with a very basic HTML template.

In this template, we’ll embed a <simple-event-sourcer>, client-only custom element, that we will define later.

📄 /src/routes/streams.ts

import { defineRoute } from '@gracile/gracile/route';
import { html } from 'lit';

import { document } from '../document.js';

export default defineRoute({
  document: (context) => document({ ...context, title: 'Streams' }),

  template: () => html`
    <main>
      <p>Let the stream flow!</p>

      <hr />

      <simple-event-sourcer>
        <pre>
Dump…
---------------------------------------------------------
</pre
        >
      </simple-event-sourcer>
    </main>
  `,
});

2. Create a stream reader custom element

In the sibling streams.client.ts file, we’ll’ define our vanilla custom element.

The EventSource browser API is the key part here.

📄 /src/routes/streams.client.ts

const eventSourceUrl = '/server-events/';

customElements.define(
  'simple-event-sourcer',
  class extends HTMLElement {
     This is a standard, globally available API!
    #eventSource = new EventSource(eventSourceUrl); 

    #dump = this.querySelector('pre')!;

    connectedCallback() {
      this.#eventSource.addEventListener('open', (event) => {
        console.log(event);
      });

      this.#eventSource.addEventListener('message', (event) => {
        console.log(event);

        this.dump(event);
      });
    }

    dump(event: Event) {
      if (('data' in event && typeof event.data === 'string') === false)
        throw new Error('Incorrect stream!');

      this.#dump.append(
        (document.createElement('code').innerHTML = `${event.data}\n`),
      );
    }
  },
);

3. Create a server-sent events endpoint

Finally, here is the real meat, the server endpoint that will output our event stream.

It’s a very trivial implementation with a cyclic timer that outputs a ping message.

We are defining the “underlying source” for the ReadableStream, so we can control what data it will provide for our HTTP response body.

📄 /src/routes/server-events.ts

import { defineRoute } from '@gracile/gracile/route';

 Just a small helper for formatting `EventSource` data chunk.
function enqueue(
  controller: ReadableStreamDefaultController<string>,
  data: string,
) {
  return controller.enqueue(`data: ${data}\n\n`);
}

const INTERVAL_MS = 1000;

export default defineRoute({
  handler: () => {
    let intervalId: ReturnType<typeof setInterval>;

    const body = new ReadableStream<string>({
      start(controller) {
        enqueue(controller, 'Hello World!');

         Do the work here.
        intervalId = setInterval(() => {
          const data = `— Ping! — ${new Date().toISOString()} —`;

          enqueue(controller, data); 
        }, INTERVAL_MS);

         Could react to external events and send messages from here.
        // myExternalEventTarget.addEventListener(…)
      },

      cancel: () => clearInterval(intervalId),
    });

    return new Response(body, {
      headers: { 'content-type': 'text/event-stream' },
    });
  },
});

Event source needs to receive chunks with the data: (.*)\n\n format to be understood. That’s why we can wrap this in a tiny helper to not mess this up, with proper validation etc., especially if it’s getting used in multiple places.


To go further, you could replace this dumb, intervalled stream with something that reacts to internal server events, to forward them.
EventTarget is a good fit for this. It’s available in Node, Deno, etc., and browsers of course!

See also Fetch Event Source, a library that brings “A better API for making Event Source requests, with all the features of fetch()”.