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.
- …
- src/
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 { defineRouteimport defineRoute } from '@gracile/gracile/route';
import { htmlconst html: (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult<1>Interprets a template literal as an HTML template that can efficiently
render to and update a container.
const header = (title: string) => html`<h1>${title}</h1>`;
The html tag returns a description of the DOM to render as a value. It is
lazy, meaning no work is done until the template is rendered. When rendering,
if a template comes from the same expression as a previously rendered result,
it's efficiently updated instead of replaced.
} from 'lit';
import { documentconst document: () => any } from '../document.js';
export default defineRouteimport defineRoute ({
documentdocument: (context: any) => any : (contextcontext: any ) => documentfunction document(): any ({ ...contextcontext: any , titletitle: string : 'Streams' }),
templatetemplate: () => TemplateResult<1> : () => htmlconst html: (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult<1>Interprets a template literal as an HTML template that can efficiently
render to and update a container.
const header = (title: string) => html`<h1>${title}</h1>`;
The html tag returns a description of the DOM to render as a value. It is
lazy, meaning no work is done until the template is rendered. When rendering,
if a template comes from the same expression as a previously rendered result,
it's efficiently updated instead of replaced.
`
<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 eventSourceUrlconst eventSourceUrl: "/server-events/" = '/server-events/';
customElementsvar customElements: CustomElementRegistryThe customElements read-only property of the Window interface returns a reference to the CustomElementRegistry object, which can be used to register new custom elements and get information about previously registered custom elements.
.defineCustomElementRegistry.define(name: string, constructor: CustomElementConstructor, options?: ElementDefinitionOptions): voidThe define() method of the CustomElementRegistry interface adds a definition for a custom element to the custom element registry, mapping its name to the constructor which will be used to create it.
(
'simple-event-sourcer',
class extends HTMLElementvar HTMLElement: {
new (): HTMLElement;
prototype: HTMLElement;
}
The HTMLElement interface represents any HTML element. Some elements directly implement this interface, while others implement it via an interface that inherits it.
{
This is a standard, globally available API!
#eventSource = new EventSourcevar EventSource: new (url: string | URL, eventSourceInitDict?: EventSourceInit) => EventSourceThe EventSource interface is web content's interface to server-sent events.
(eventSourceUrlconst eventSourceUrl: "/server-events/" );
#dump = this.querySelectorParentNode.querySelector<"pre">(selectors: "pre"): HTMLPreElement | null (+4 overloads)Returns the first element that is a descendant of node that matches selectors.
('pre')!;
connectedCallbackfunction (Anonymous class).connectedCallback(): void () {
this.#eventSource.addEventListenerEventSource.addEventListener<"open">(type: "open", listener: (this: EventSource, ev: Event) => any, options?: boolean | AddEventListenerOptions): void (+2 overloads)The addEventListener() method of the EventTarget interface sets up a function that will be called whenever the specified event is delivered to the target.
('open', (eventevent: Event ) => {
consolevar console: Console .logConsole.log(...data: any[]): voidThe console.log() static method outputs a message to the console.
(eventevent: Event );
});
this.#eventSource.addEventListenerEventSource.addEventListener<"message">(type: "message", listener: (this: EventSource, ev: MessageEvent<any>) => any, options?: boolean | AddEventListenerOptions): void (+2 overloads)The addEventListener() method of the EventTarget interface sets up a function that will be called whenever the specified event is delivered to the target.
('message', (eventevent: MessageEvent<any> ) => {
consolevar console: Console .logConsole.log(...data: any[]): voidThe console.log() static method outputs a message to the console.
(eventevent: MessageEvent<any> );
this.dumpfunction (Anonymous class).dump(event: Event): void (eventevent: MessageEvent<any> );
});
}
dumpfunction (Anonymous class).dump(event: Event): void (eventevent: Event : Event) {
if (('data' in eventevent: Event && typeof eventevent: Event & Record<"data", unknown> .datadata: unknown === 'string') === false)
throw new Errorvar Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
('Incorrect stream!');
this.#dump.appendParentNode.append(...nodes: (Node | string)[]): voidInserts nodes after the last child of node, while replacing strings in nodes with equivalent Text nodes.
Throws a "HierarchyRequestError" DOMException if the constraints of the node tree are violated.
(
(documentvar document: Documentwindow.document returns a reference to the document contained in the window.
.createElementDocument.createElement<"code">(tagName: "code", options?: ElementCreationOptions): HTMLElement (+2 overloads)In an HTML document, the document.createElement() method creates the HTML element specified by localName, or an HTMLUnknownElement if localName isn't recognized.
('code').innerHTMLElement.innerHTML: stringThe innerHTML property of the Element interface gets or sets the HTML or XML markup contained within the element, omitting any shadow roots in both cases.
= `${eventevent: 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 { defineRouteimport defineRoute } from '@gracile/gracile/route';
Just a small helper for formatting `EventSource` data chunk.
function enqueuefunction enqueue(controller: ReadableStreamDefaultController<string>, data: string): void (
controllercontroller: ReadableStreamDefaultController<string> : ReadableStreamDefaultControllerinterface ReadableStreamDefaultController<R = any>The ReadableStreamDefaultController interface of the Streams API represents a controller allowing control of a ReadableStream's state and internal queue. Default controllers are for streams that are not byte streams.
<string>,
datadata: string : string,
) {
return controllercontroller: ReadableStreamDefaultController<string> .enqueueReadableStreamDefaultController<string>.enqueue(chunk: string): voidThe enqueue() method of the ReadableStreamDefaultController interface enqueues a given chunk in the associated stream.
(`data: ${datadata: string }\n\n`);
}
const INTERVAL_MSconst INTERVAL_MS: 1000 = 1000;
export default defineRouteimport defineRoute ({
handlerhandler: () => Response : () => {
let intervalIdlet intervalId: number : ReturnTypetype ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : anyObtain the return type of a function type
<typeof setIntervalfunction setInterval(handler: TimerHandler, timeout?: number, ...arguments: any[]): number >;
const bodyconst body: ReadableStream<string> = new ReadableStreamvar ReadableStream: new <string>(underlyingSource: UnderlyingDefaultSource<string>, strategy?: QueuingStrategy<string> | undefined) => ReadableStream<string> (+2 overloads) <string>({
startUnderlyingDefaultSource<string>.start?: ((controller: ReadableStreamDefaultController<string>) => any) | undefined (controllercontroller: ReadableStreamDefaultController<string> ) {
enqueuefunction enqueue(controller: ReadableStreamDefaultController<string>, data: string): void (controllercontroller: ReadableStreamDefaultController<string> , 'Hello World!');
Do the work here.
intervalIdlet intervalId: number = setIntervalfunction setInterval(handler: TimerHandler, timeout?: number, ...arguments: any[]): number (() => {
const dataconst data: string = `— Ping! — ${new Datevar Date: DateConstructor
new () => Date (+4 overloads)
().toISOStringDate.toISOString(): stringReturns a date as a string value in ISO format.
()} —`;
enqueuefunction enqueue(controller: ReadableStreamDefaultController<string>, data: string): void (controllercontroller: ReadableStreamDefaultController<string> , dataconst data: string );
}, INTERVAL_MSconst INTERVAL_MS: 1000 );
// myExternalEventTarget.addEventListener(…)
},
cancelUnderlyingDefaultSource<string>.cancel?: UnderlyingSourceCancelCallback | undefined : () => clearIntervalfunction clearInterval(id: number | undefined): void (intervalIdlet intervalId: number ),
});
return new Responsevar Response: new (body?: BodyInit | null, init?: ResponseInit) => ResponseThe Response interface of the Fetch API represents the response to a request.
(bodyconst body: ReadableStream<string> , {
headersResponseInit.headers?: HeadersInit | undefined : { '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()”.