Server-sent events
Learning objectives
- Knows what server-sent events are and what they are used for.
- Knows how to push messages from the server to the client using server-sent events.
An alternative to long and short polling is the use of server-sent events. Server-sent events is a one-directional push-based technology where, once the client has initiated a connection, the server can send events to the client.
Sending events from server
When using server-sent events, the server responds with the Content-Type
header value text/event-stream
, and starts writing data to the response. The concrete implementation of this depends on the framework. With Deno, for example, one would use the ReadableStream which is a part of the Web Streams API.
The data in the event stream must be encoded using UTF-8, and each sent event with data should be prefixed with data:
, which is followed by the actual data that is being sent to the client. Events are separated from each others with two line breaks.
The following example outlines a concrete example of a server with server-sent events.
import { serve } from "https://deno.land/std@0.222.1/http/server.ts";
const message = new TextEncoder().encode("data: ping\n\n");
const handleRequest = async (request) => {
let interval;
const body = new ReadableStream({
start(controller) {
interval = setInterval(() => {
controller.enqueue(message);
}, 1000);
},
cancel() {
clearInterval(interval);
},
});
return new Response(body, {
headers: {
"Content-Type": "text/event-stream",
},
});
};
serve(handleRequest, { port: 7777 });
When you run the above program and access the application using a browser, you'll notice that the server keeps sending the message "data: ping", which is appended to the screen.
The key part of the implementation is the ReadableStream
, which is used to define a function start
that is called when a connection is established (the function receives the established connection as a parameter), and a function cancel
, that is used to end the connection. In the above example, the message
is sent through the connection once per second -- sending is done using the enqueue
method of the connection.
Note that NGINX configuration must be adjusted to allow server-sent events when used. Some discussion on this is available on StackOverflow.
Handling events on the client
Server-sent events are handled on the client using the EventSource interface. EventSource is used to open up a persistent connection to a server sending events with the text/event-stream
format. Events are handled -- well -- as events, where the functionality for handling events is defined for the event source.
For example, the following JavaScript code would open up a connection to /api/sse
, and log the incoming events to the console.
const eventSource = new EventSource("/api/sse");
eventSource.onmessage = (event) => {
console.log(event.data);
};
EventSource and CORS
When testing out the EventSource so that the port (or server) differs from the host of the client, CORS needs to be enabled on the server. For testing, one can simply add the Access-Control-Allow-Origin
header to the server response, as follows.
return new Response(body, {
headers: {
"Content-Type": "text/event-stream",
"Access-Control-Allow-Origin": "*",
},
});
By default, the connection created using EventSource is persistent, and if the connection is closed, browsers reopen it. You can try this out stopping the server that is running the events -- the browser will repeatedly try to open up a connection to the server, even though it is down.
Closing a connection to the server is done with the close
method of the event source. As an example, one could have an application with the functionality for opening and closing a connection, where the key functionality would be as follows.
let eventSource;
const openConnection = () => {
eventSource = new EventSource("/api/sse");
eventSource.onmessage = (event) => {
console.log(event.data);
};
};
const closeConnection = () => {
eventSource.close();
};
EventSource and Svelte
When working with Svelte components, it is meaningful to create the EventSource
only after the component has been rendered to the DOM. Svelte provides an onMount
function that can be used for defining functionality that happens after the component has been rendered -- this is in particular meaningful when working with the combination of Astro and Svelte.
One possible implementation for working with server-sent events with Svelte would be as follows. In the following, the connection is opened when the component is rendered, and closed either when the user presses a button or when the component is no longer visible (given that the connection is open, i.e. the readyState is 1). The event data is added to a list, which is shown to the user.
<script>
import { onMount } from "svelte";
let events = [];
let eventSource;
onMount(() => {
eventSource = new EventSource("/api/sse");
eventSource.onmessage = (event) => {
events = [...events, event.data];
};
eventSource.onerror = (event) => {
console.log(event);
};
return () => {
if (eventSource.readyState === 1) {
eventSource.close();
}
};
});
const closeEventStream = () => {
eventSource.close();
};
</script>
<h2>Server-sent events ({events.length})</h2>
<button on:click={closeEventStream}>Close connection</button>
<ul>
{#each events as event}
<li>{event}</li>
{/each}
</ul>
Server-sent events to multiple clients
In our previous example, the data is sent always to each individual client separately, as the interval is defined as a part of the request. If one would wish to create functionality where the events are sent to all the clients that are connected to the server, we would need to keep a collection of open connections. A simple approach would consist of a Set that provides easy adding and removing of objects, and allows iterating over objects within the set.
The following exmaple outlines a draft implementation of this -- all clients connected to the server would receive the same messages from the server.
// ...
const content = ["Never", "gonna", "give", "you", "up"];
const encoder = new TextEncoder();
let i = 0;
let controllers = new Set();
setInterval(() => {
const msg = encoder.encode(`data: ${content[i]}\n\n`);
controllers.forEach((controller) => controller.enqueue(msg));
i = (i + 1) % content.length;
}, 1000);
const handleRequest = async (request) => {
let controller;
const body = new ReadableStream({
start(c) {
controller = c;
controllers.add(controller);
},
cancel() {
controllers.delete(controller);
},
});
return new Response(body, {
headers: {
"Content-Type": "text/event-stream",
"Access-Control-Allow-Origin": "*",
"Connection": "keep-alive",
},
});
};
// ...