App-wide server-side push in SvelteKit using Server-Sent Events

28 July 2023 • 4-minute read

The usage of a Node.js PassThrough Stream is the key to tying together the pieces of the server-side push solution in this blog entry. This, however, still feels constrained by the fact that SSEs need to reside in a GET endpoint. A question logically follows: is it possible for disparate parts of the server to fire off messages intended for dispatch by the client side without the client sending a message first, i.e, trigger server-side pushes from anywhere? I have implemented a solution in SvelteKit and again, the key here is the PassThrough Stream.

Solution

The server-side solution is obvious. Instantiate an exportable PassThrough Stream object somewhere and whenever there's some domain action that requires a server-side push, simply import this PassThrough object to the call site and write data (the message) into it. A better design would be to encapsulate this Stream object in some class and write methods for writing into it, like in the following snippet.

1

2
    // message-sender.ts
3

4
    import { PassThrough } from 'node:stream'
5

6
    class MessageSender {
7
        message_stream = new PassThrough()
8

9
        domain_action_1 = () => {
10
            const message = { ... }
11
            this.message_stream.write(JSON.stringify(message))
12
        }
13

14
        domain_action_2 = () => {
15
            const message = { ... }
16
            this.message_stream.write(JSON.stringify(message))
17
        }
18

19
        ...
20

21
    }
22

23
    const message_sender = new MessageSender()
24
    export default message_sender
25
    

The SSE GET endpoint is simple. Just import the PassThrough Stream object here and attach an on.("data", ...) handler for the controller to enqueue. Don't forget the data: prefix and the two newlines.

1

2
    // +server.ts
3

4
    import message_sender from "$lib/../../message-sender"
5
    import type { RequestHandler } from "@sveltejs/kit"
6

7
    export const GET = (async ({ request }) => {
8
        const stream = new ReadableStream({
9
            start(controller) {
10
                message_sender.message_stream.on("data", (data) => {
11
                    controller.enqueue(`data:${data}\n\n`)
12
                }
13
            }
14
        })
15

16
        return new Response(stream, {
17
            headers: {
18
                "Content-Type": "text/event-stream",
19
                "Connection": "keep-alive",
20
            }
21
        })
22
    }) satisfies RequestHandler
23
    

The client-side solution asks if it's possible to listen to the SSE endpoint and dispatch the received messages regardless of the user's current location in the website. I discovered that SvelteKit's hooks.client file is capable of doing this. This is where an EventSource would be constructed with the URL of the SSE GET endpoint as argument.

1

2
    // hooks.client.ts
3

4
    ...
5

6
    const app_wide_events_sse = new EventSource("/api/listen")
7
        app_wide_events_sse.onmessage = (event: any) => {
8
        const parsed_message = JSON.parse(event.data)
9
        
10
        // Handle all the many messages here.
11
        
12
        return () => { global_app_events_sse.close() }
13
    }
14

15
    ...
16
    

The hooks.client file is a special file in SvelteKit whose code, specifically client-side code, will run when the app starts up, so you can take advantage of Svelte's reactivity features here.

Improvements

To prevent bloating the hooks.client file, you can import a function here instead that handles all the different messages you receive from the SSE endpoint.

The message_stream object is mutable, so instead of exposing it and then attaching an on.("data", ...) handler to it, the class encapsulating it can instead have a function that takes in a closure as argument that captures the data object written into message_stream.

Unlike the entry last time where the SSE is busy sending data on short intervals, a more general, app-wide use of SSEs like this may lead to the SSE pushing messages sparsely over time and may result to the disconnection of the client to the GET endpoint due to timeout. Prevent this by sending 'keep-alive' messages. This can simply be ":/n/n". Use setInterval() for this with interval set to as small as 15 seconds. This periodic code should live in SvelteKit's hooks.server file, the server-side equivalent of the hooks.client file.