Published on January 14, 2024

Streaming Data to the Browser via Server-Sent Events (SSE) and Astro

Streaming Data to the Browser via Server-Sent Events (SSE) and Astro

Say you have an application that needs to display real-time data to the user. For example, a dashboard that displays the number of users currently online, or a chat application that displays new messages as they arrive, or a stock market application that displays the latest stock prices.

This is a common problem that many applications face, and usually, the first solution that comes to mind is to use WebSockets. However, WebSockets are not the only solution to this problem. In fact, there is a much simpler solution that is often overlooked or unknown to many developers: Server-Sent Events (SSE).

In this post, I'll walk you through setting up Server-Sent Events (SSE) in Astro to build a lightweight real-time chat server.

What are Server-Sent Events (SSE)?

Server-Sent Events (SSE) is a web standard enabling servers to push data to clients over a single, long-lived HTTP connection. It's a perfect fit for unidirectional data flows where the server needs to update the client, such as sending JSON payloads, text updates, or HTML fragments to dynamically refresh the UI.

One of the key advantages of SSE is its native support in modern browsers, eliminating the need for external libraries or polyfills. It's also generally easier on server resources compared to WebSockets, especially when bidirectional communication isn't required.

Setting Up Astro

Before diving into the chat functionality, you'll need an Astro project. If you're starting from scratch, use the following command to create a fresh Astro project and add the Node.js adapter.

npm create astro@latest
npx astro add node

Creating the Chat Logic

First, we need to create a basic chat controller that will handle the chat logic on the server. This is the heart of our application. It will store messages in memory and will provide a way for us to subscribe to new messages as they arrive.

Let's create a file called chat.ts in the src/controllers directory and let's get to work.

// src/controllers/chat.ts
export default class ChatController {
private static instance: ChatController;
private constructor() {}

static getInstance(): ChatController {
if (!ChatController.instance) {
ChatController.instance = new ChatController();
}
return ChatController.instance;
}
}

Next, I'm going to create a messages array to store the messages in memory with a getMessages() method to retrieve the messages and an addMessage() method to add new messages to the array.

// src/controllers/chat.ts
export default class ChatController {
// ...
private messages: string[] = [];
public addMessage(message: string): void {
this.messages.push(message);
}

public getMessages(): string[] {
return this.messages;
}
}

Finally, I'm going to create a subscribe() and unsubscribe() method to allow us to subscribe to new messages as they arrive. For this, I'm going to use the EventEmitter class from Node.js.

// src/controllers/chat.ts
import EventEmitter from 'events';

class ChatController {
// ...
private emitter = new EventEmitter();

public subscribe(callback: (message: string) => void): void {
this.emitter.on('message', callback);
}

public unsubscribe(callback: (message: string) => void): void {
this.emitter.off('message', callback);
}

public addMessage(message: string): void {
this.messages.push(message);
this.emitter.emit('message', message);
}
}

As you can see, I'm using the on() method to subscribe to the message event and the off() method to unsubscribe from the message event. I'm also using the emit() method to emit the message event when a new message is added to the messages array.

Creating the Chat Page

Now that we have the chat logic in place, let's create a page to display the chat messages.

// src/pages/index.astro
---
import Layout from "../layouts/Layout.astro";
import ChatController from "../controllers/chat";

const messages = ChatController.getInstance().getMessages();
---

<Layout title="Chat Server">
<main>
<h1>Chat</h1>
<ul id="messages">
{messages.map((message) => <li>{message}</li>)}
</ul>
<form id="chat">
<input id="message" type="text" />
<button type="submit">Send</button>
</form>
</main>
</Layout>

Here, we are server-side rendering the messages using the getMessages() method from the ChatController class. We are also adding a form to allow us to send new messages to the server.

Handle Form Submission

In the same file, let's add some JavaScript to handle the form submission.

// src/pages/index.astro

<script is:inline>
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById("chat");
const input = document.getElementById("message");
form.addEventListener("submit", (e) => {
e.preventDefault();
fetch("/api/chat", {
method: "POST",
body: JSON.stringify({ message: input.value }),
headers: {
"Content-Type": "application/json",
},
});
input.value = "";
});
});
</script>

This code here is pretty straightforward. We are adding an event listener to the submit event of the form and sending a POST request to the /pages/api/chat endpoint with the message as the body of the request. We are also clearing the input field after the request is sent to make it a bit more user-friendly.

Note: I am using the is:inline attribute to inline the JavaScript code in the HTML file.

Creating the Chat API

However, this code here doesn't do anything yet. We need to create an endpoint to handle the POST request and add the message to the messages array.

// src/pages/api/chat.ts
import type { APIRoute } from 'astro';
import ChatController from '../../controllers/chat';

export const POST: APIRoute = async ({ request }) => {
const { message } = await request.json();
ChatController.getInstance().addMessage(message);
return new Response(null, { status: 204 });
};

Here we are creating a REST API endpoint that will handle the POST request and add the message to the messages array. We are also returning a 204 status code to indicate that the request was successful.

With this in place, we can now send messages to the server. However, if you try this out, you'll notice that new messages are not being reflected in the UI. This is because we are not updating the UI when new messages are added to the messages array.

This is where SSE comes in. Let's see how we can use SSE to stream the messages to the browser in real-time.

Setting Up SSE Endpoint

To set up SSE, we need to create a new endpoint that will handle the GET request and send the messages to the client as they arrive.

// src/api/stream.ts
import type { APIRoute } from 'astro';

export const GET: APIRoute = async ({ request }) => {
const body = new ReadableStream({
start(controller) {
// Text encoder for converting strings to Uint8Array
const encoder = new TextEncoder();

// Send event to client
const sendEvent = (data: any) => {
const message = `data: ${JSON.stringify(data)}\n\n`;
controller.enqueue(encoder.encode(message));
};

// Handle the connection closing
request.signal.addEventListener('abort', () => {
controller.close();
});
}
});

return new Response(body, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive'
}
});
};

Let's break this down. First, we are creating a new ReadableStream and passing it to the Response constructor. This will allow us to send data to the client as it arrives.

ReadableStream takes a start() function with a controller argument. This controller argument allows us to enqueue data to the stream using the enqueue() method. This is how we will send data to the client.

Next, we are creating a sendEvent() function that will take the data as an argument and send it to the client.

SSE requires us to send the data in a specific format. Each message must be prefixed with data: and suffixed with \n\n. This is why we are using the JSON.stringify() method to convert the data to a string and then prefixing it with data: and suffixed with \n\n. We are then using the TextEncoder class to convert the string to a Uint8Array before sending it to the client.

There are other optional fields that we can send to the client, such as id, event, and retry. However, we are not going to use them in this example.

Finally, we are adding an event listener to the request.signal object to handle the connection closing. This will allow us to close the stream when the connection is closed.

Subscribing to New Messages

This code here, however, does not do anything yet. We need to bring in the ChatController class and subscribe to new messages as they arrive.

// src/api/stream.ts
import type { APIRoute } from 'astro';
import ChatController from '../../controllers/chat';

export const GET: APIRoute = async ({ request }) => {
const body = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();

const sendEvent = (data: any) => {
const message = `data: ${JSON.stringify(data)}\n\n`;
controller.enqueue(encoder.encode(message));
};

// Subscribe to new messages
ChatController.getInstance().subscribe(sendEvent);

request.signal.addEventListener('abort', () => {
// Unsubscribe from new messages
ChatController.getInstance().unsubscribe(sendEvent);
controller.close();
});
}
});

return new Response(body, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive'
}
});
};

Here, every time a new connection is made, we are subscribing to new messages using the subscribe() method from the ChatController class. We are also unsubscribing from new messages when the connection is closed using the unsubscribe() method from the ChatController class. This will ensure that we don't send messages to the client when the connection is closed.

Subscribing to SSE

Now that we have the SSE endpoint in place, let's go back to the chat page and subscribe to the SSE endpoint.

Browsers that support SSE (which is most modern browsers) provide a built-in EventSource class that allows us to subscribe to SSE endpoints. We can then use the onmessage event handler to listen for new messages and consume them as they arrive.

// src/pages/index.astro

<script is:inline>
document.addEventListener('DOMContentLoaded', () => {
// ...
const eventSource = new EventSource('/api/stream');
eventSource.onmessage = (event) => {
const messages = document.getElementById('messages');
const li = document.createElement('li');
li.innerText = JSON.parse(event.data);
messages.appendChild(li);
};
});
</script>

In this case, I am using the onmessage event handler to listen for new messages, parse the data, and append it to the list of messages.

Bringing It All Together

With all the components in place, it's time to test our real-time chat application.

Run the development server:

npm run dev

Open the application in your browser, send messages, and watch as they appear in real-time across multiple tabs or windows.

That is cool, isn't it? 😎

Conclusion

That's it! We have successfully set up Server-Sent Events (SSE) in Astro and created a simple chat server that streams messages to the browser in real-time.

The nice thing about SSEs is that they are simple to use and they work over a single, long-lived HTTP connection. This makes them ideal for sending JSON, text data, or even HTML snippets to update the client's UI in real-time.

Additionally, a nice feature of the EventSource class is that it will automatically reconnect if the connection is lost. This means that if the connection is lost for any reason, the client will automatically reconnect and continue receiving messages.

I hope this post has encouraged you to give it a try and see how it can help you build better applications.

Happy coding, and until next time, keep streaming those events! 🚀

Interested in LogSnag?

Get started right now for free!