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 { defineRoutefunction defineRoute<GetHandlerData extends R.HandlerDataHtml = undefined, PostHandlerData extends R.HandlerDataHtml = undefined, CatchAllHandlerData extends R.HandlerDataHtml = undefined, StaticPathOptions extends R.StaticPathOptionsGeneric | undefined = undefined, RouteContext extends R.RouteContextGeneric = {
...;
}>(options: {
handler?: StaticPathOptions extends object ? never : R.Handler<CatchAllHandlerData> | {
GET?: R.Handler<GetHandlerData>;
POST?: R.Handler<PostHandlerData>;
QUERY?: R.Handler<Response>;
PUT?: R.Handler<Response>;
PATCH?: R.Handler<Response>;
DELETE?: R.Handler<Response>;
HEAD?: R.Handler<Response>;
OPTIONS?: R.Handler<Response>;
} | undefined;
staticPaths?: () => R.MaybePromise<StaticPathOptions[]> | undefined;
prerender?: boolean | undefined;
document?: R.DocumentTemplate<RouteContext> | undefined;
template?: R.BodyTemplate<RouteContext> | undefined;
}): (RouteModule: typeof R.RouteModule) => R.RouteModule
Defines a file-based route for Gracile to consume.
} 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: () => ServerRenderedTemplate
} from '../document.js';
export default defineRoutedefineRoute<undefined, undefined, undefined, undefined, {
url: URL;
props: undefined;
params: Parameters;
}>(options: {
handler?: Handler<undefined> | {
GET?: Handler<undefined> | undefined;
... 6 more ...;
OPTIONS?: Handler<...> | undefined;
} | undefined;
staticPaths?: (() => MaybePromise<...> | undefined) | undefined;
prerender?: boolean | undefined;
document?: DocumentTemplate<...> | undefined;
template?: BodyTemplate<...> | undefined;
}): (RouteModule: typeof RouteModule) => RouteModule
Defines a file-based route for Gracile to consume.
({
documentdocument?: DocumentTemplate<{
url: URL;
props: undefined;
params: Parameters;
}> | undefined
A function that returns a server only template.
Route context is provided at runtime during the build.
: (contextcontext: {
url: URL;
props: undefined;
params: Parameters;
}
) => documentfunction document(): ServerRenderedTemplate
({ ...contextcontext: {
url: URL;
props: undefined;
params: Parameters;
}
, titletitle: string
: 'Streams' }),
templatetemplate?: BodyTemplate<{
url: URL;
props: undefined;
params: Parameters;
}> | undefined
A function that returns a server only or a Lit client hydratable template.
Route context is provided at runtime during the build.
: () => 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: CustomElementRegistry
Defines a new custom element, mapping the given name to the given constructor as an autonomous custom element.
.defineCustomElementRegistry.define(name: string, constructor: CustomElementConstructor, options?: ElementDefinitionOptions): void
(
'simple-event-sourcer',
class extends HTMLElementvar HTMLElement: {
new (): HTMLElement;
prototype: HTMLElement;
}
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) => EventSource
Only available through the --experimental-eventsource flag.
(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)
Appends an event listener for events whose type attribute value is type. The callback argument sets the callback that will be invoked when the event is dispatched.
The options argument sets listener-specific options. For compatibility this can be a boolean, in which case the method behaves exactly as if the value was specified as options's capture.
When set to true, options's capture prevents callback from being invoked when the event's eventPhase attribute value is BUBBLING_PHASE. When false (or not present), callback will not be invoked when event's eventPhase attribute value is CAPTURING_PHASE. Either way, callback will be invoked if event's eventPhase attribute value is AT_TARGET.
When set to true, options's passive indicates that the callback will not cancel the event by invoking preventDefault(). This is used to enable performance optimizations described in § 2.8 Observing event listeners.
When set to true, options's once indicates that the callback will only be invoked once after which the event listener will be removed.
If an AbortSignal is passed for options's signal, then the event listener will be removed when signal is aborted.
The event listener is appended to target's event listener list and is not appended if it has the same type, callback, and capture.
('open', (eventevent: Event
) => {
consolevar console: Console
The console
module provides a simple debugging console that is similar to the
JavaScript console mechanism provided by web browsers.
The module exports two specific components:
- A
Console
class with methods such as console.log()
, console.error()
and console.warn()
that can be used to write to any Node.js stream.
- A global
console
instance configured to write to process.stdout
and
process.stderr
. The global console
can be used without importing the node:console
module.
Warning: The global console object's methods are neither consistently
synchronous like the browser APIs they resemble, nor are they consistently
asynchronous like all other Node.js streams. See the note on process I/O
for
more information.
Example using the global console
:
console.log('hello world');
// Prints: hello world, to stdout
console.log('hello %s', 'world');
// Prints: hello world, to stdout
console.error(new Error('Whoops, something bad happened'));
// Prints error message and stack trace to stderr:
// Error: Whoops, something bad happened
// at [eval]:5:15
// at Script.runInThisContext (node:vm:132:18)
// at Object.runInThisContext (node:vm:309:38)
// at node:internal/process/execution:77:19
// at [eval]-wrapper:6:22
// at evalScript (node:internal/process/execution:76:60)
// at node:internal/main/eval_string:23:3
const name = 'Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr
Example using the Console
class:
const out = getStreamSomehow();
const err = getStreamSomehow();
const myConsole = new console.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(new Error('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
const name = 'Will Robinson';
myConsole.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to err
.logConsole.log(message?: any, ...optionalParams: any[]): void (+1 overload)
Prints to stdout
with newline. Multiple arguments can be passed, with the
first used as the primary message and all additional used as substitution
values similar to printf(3)
(the arguments are all passed to util.format()
).
const count = 5;
console.log('count: %d', count);
// Prints: count: 5, to stdout
console.log('count:', count);
// Prints: count: 5, to stdout
See util.format()
for more information.
(eventevent: Event
);
});
this.#eventSource.addEventListenerEventSource.addEventListener<"message">(type: "message", listener: (this: EventSource, ev: MessageEvent<any>) => any, options?: boolean | AddEventListenerOptions): void (+2 overloads)
Appends an event listener for events whose type attribute value is type. The callback argument sets the callback that will be invoked when the event is dispatched.
The options argument sets listener-specific options. For compatibility this can be a boolean, in which case the method behaves exactly as if the value was specified as options's capture.
When set to true, options's capture prevents callback from being invoked when the event's eventPhase attribute value is BUBBLING_PHASE. When false (or not present), callback will not be invoked when event's eventPhase attribute value is CAPTURING_PHASE. Either way, callback will be invoked if event's eventPhase attribute value is AT_TARGET.
When set to true, options's passive indicates that the callback will not cancel the event by invoking preventDefault(). This is used to enable performance optimizations described in § 2.8 Observing event listeners.
When set to true, options's once indicates that the callback will only be invoked once after which the event listener will be removed.
If an AbortSignal is passed for options's signal, then the event listener will be removed when signal is aborted.
The event listener is appended to target's event listener list and is not appended if it has the same type, callback, and capture.
('message', (eventevent: MessageEvent<any>
) => {
consolevar console: Console
The console
module provides a simple debugging console that is similar to the
JavaScript console mechanism provided by web browsers.
The module exports two specific components:
- A
Console
class with methods such as console.log()
, console.error()
and console.warn()
that can be used to write to any Node.js stream.
- A global
console
instance configured to write to process.stdout
and
process.stderr
. The global console
can be used without importing the node:console
module.
Warning: The global console object's methods are neither consistently
synchronous like the browser APIs they resemble, nor are they consistently
asynchronous like all other Node.js streams. See the note on process I/O
for
more information.
Example using the global console
:
console.log('hello world');
// Prints: hello world, to stdout
console.log('hello %s', 'world');
// Prints: hello world, to stdout
console.error(new Error('Whoops, something bad happened'));
// Prints error message and stack trace to stderr:
// Error: Whoops, something bad happened
// at [eval]:5:15
// at Script.runInThisContext (node:vm:132:18)
// at Object.runInThisContext (node:vm:309:38)
// at node:internal/process/execution:77:19
// at [eval]-wrapper:6:22
// at evalScript (node:internal/process/execution:76:60)
// at node:internal/main/eval_string:23:3
const name = 'Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr
Example using the Console
class:
const out = getStreamSomehow();
const err = getStreamSomehow();
const myConsole = new console.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(new Error('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
const name = 'Will Robinson';
myConsole.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to err
.logConsole.log(message?: any, ...optionalParams: any[]): void (+1 overload)
Prints to stdout
with newline. Multiple arguments can be passed, with the
first used as the primary message and all additional used as substitution
values similar to printf(3)
(the arguments are all passed to util.format()
).
const count = 5;
console.log('count: %d', count);
// Prints: count: 5, to stdout
console.log('count:', count);
// Prints: count: 5, to stdout
See util.format()
for more information.
(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)[]): void
Inserts 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: Document
.createElementDocument.createElement<"code">(tagName: "code", options?: ElementCreationOptions): HTMLElement (+2 overloads)
Creates an instance of the element for the specified tag.
('code').innerHTMLInnerHTML.innerHTML: string
= `${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 { defineRoutefunction defineRoute<GetHandlerData extends R.HandlerDataHtml = undefined, PostHandlerData extends R.HandlerDataHtml = undefined, CatchAllHandlerData extends R.HandlerDataHtml = undefined, StaticPathOptions extends R.StaticPathOptionsGeneric | undefined = undefined, RouteContext extends R.RouteContextGeneric = {
...;
}>(options: {
handler?: StaticPathOptions extends object ? never : R.Handler<CatchAllHandlerData> | {
GET?: R.Handler<GetHandlerData>;
POST?: R.Handler<PostHandlerData>;
QUERY?: R.Handler<Response>;
PUT?: R.Handler<Response>;
PATCH?: R.Handler<Response>;
DELETE?: R.Handler<Response>;
HEAD?: R.Handler<Response>;
OPTIONS?: R.Handler<Response>;
} | undefined;
staticPaths?: () => R.MaybePromise<StaticPathOptions[]> | undefined;
prerender?: boolean | undefined;
document?: R.DocumentTemplate<RouteContext> | undefined;
template?: R.BodyTemplate<RouteContext> | undefined;
}): (RouteModule: typeof R.RouteModule) => R.RouteModule
Defines a file-based route for Gracile to consume.
} 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>
ReadableStreamDefaultController
class is a global reference for import { ReadableStreamDefaultController } from 'node:stream/web'
.
https://nodejs.org/api/globals.html#class-readablestreamdefaultcontroller
<string>,
datadata: string
: string,
) {
return controllercontroller: ReadableStreamDefaultController<string>
.enqueueReadableStreamDefaultController<string>.enqueue(chunk?: string | undefined): void
(`data: ${datadata: string
}\n\n`);
}
const INTERVAL_MSconst INTERVAL_MS: 1000
= 1000;
export default defineRoutedefineRoute<undefined, undefined, Response, undefined, {
url: URL;
props: never;
params: Parameters;
}>(options: {
handler?: {
GET?: Handler<undefined> | undefined;
POST?: Handler<...> | undefined;
... 5 more ...;
OPTIONS?: Handler<...> | undefined;
} | Handler<...> | undefined;
staticPaths?: (() => MaybePromise<...> | undefined) | undefined;
prerender?: boolean | undefined;
document?: DocumentTemplate<...> | undefined;
template?: BodyTemplate<...> | undefined;
}): (RouteModule: typeof RouteModule) => RouteModule
Defines a file-based route for Gracile to consume.
({
handlerhandler?: {
GET?: Handler<undefined> | undefined;
POST?: Handler<undefined> | undefined;
QUERY?: Handler<Response> | undefined;
PUT?: Handler<Response> | undefined;
PATCH?: Handler<...> | undefined;
DELETE?: Handler<...> | undefined;
HEAD?: Handler<...> | undefined;
OPTIONS?: Handler<...> | undefined;
} | Handler<...> | undefined
A function or an object containing functions named after HTTP methods.
A handler can return either a standard Response
that will terminate the
request pipeline, or any object to populate the current route template
and document contexts.
: () => {
let intervalIdlet intervalId: NodeJS.Timeout
: ReturnTypetype ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any
Obtain the return type of a function type
<typeof setIntervalfunction setInterval(handler: TimerHandler, timeout?: number, ...arguments: any[]): number (+2 overloads)
>;
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: NodeJS.Timeout
= setIntervalfunction setInterval<[]>(callback: () => void, ms?: number): NodeJS.Timeout (+2 overloads)
Schedules repeated execution of callback
every delay
milliseconds.
When delay
is larger than 2147483647
or less than 1
, the delay
will be
set to 1
. Non-integer delays are truncated to an integer.
If callback
is not a function, a TypeError
will be thrown.
This method has a custom variant for promises that is available using timersPromises.setInterval()
.
(() => {
const dataconst data: string
= `— Ping! — ${new Datevar Date: DateConstructor
new () => Date (+4 overloads)
().toISOStringDate.toISOString(): string
Returns 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(intervalId: NodeJS.Timeout | string | number | undefined): void (+1 overload)
Cancels a Timeout
object created by setInterval()
.
(intervalIdlet intervalId: NodeJS.Timeout
),
});
return new Responsevar Response: new (body?: BodyInit | null, init?: ResponseInit) => Response
This Fetch API interface 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()
”.