Architecting server-side push for Node SerialPort in SvelteKit using Server-Sent Events

13 July 2023 • 5-minute read

At work, I am currently working on creating some control panel application for our IoT hardware. The requirements necessitate a lot of recurring tasks; overall, an I/O-bound application. I decided to use Node.js as the runtime for the backend for its event-loop-based asynchronicity and Svelte and SvelteKit for frontend and meta-framework, respectively. The use of the SerialPort library is for abstracting away the details of connecting to serial ports, in our case, ports connected to our Arduino microcontrollers. For simplicity, this fullstack setup will have its Node backend be ran locally on a Raspberry Pi that is connected to a touch-screen display.

A Svelte and SvelteKit crash course

Feel free to skip to the next section if you are already familiar with Svelte and SvelteKit's project structure.

Svelte is a frontend framework for writing declarative and reactive UI. Sveltekit is a meta-framework built on top of Svelte for developing web applications. Its analog in React would be Next.js; in Vue, it would be Nuxt.js.

Svelte has its own .svelte file type that either defines a page or a custom component. A .svelte file may contain a script tag for JavaScript or TypeScript, HTML and possibly other Svelte components, and a style tag for CSS.

SvelteKit has a filesystem-based router. This means that the pages in a web app are described by the directories found in the codebase. In a barebones SvelteKit project, src/routes is the root route. It contains a single +page.svelte file. The +page name is SvelteKit's convention for specifying a web page in the app. Since this lone +page.svelte file resides in the src/routes directory, it is the default web page shown when no other URL paths are specified.

More routes can be nested inside src/routes. Folder names correspond to URL paths and a +page.svelte inside a folder defines what UI and functionality that web page contains. So far, these are all about page endpoints. SvelteKit uses a different convention for defining standalone endpoints for making APIs.

For standalone endpoints, SvelteKit expects a +server.ts or +server.js file inside a folder. Again, the folder name would be the URL path, but this time, the lone +server file inside contains the logic for HTTP Request-Response. As convention (but not required), standalone endpoints can be contained in a src/routes/api directory.

Objective

I want to be able to push Arduino readings to the UI, in a sort of real-time data monitoring screen in the app. The SerialPort library handles the opening of the relevant port, the parsing of the bytestream, and the calling of a callback that delegates what to do with the data. The other ingredient here are Server-Sent Events. SSEs reside in a GET endpoint and allow servers to push data to the frontend without the frontend periodically asking for data via normal HTTP Request-Response. A crucial technical detail to note here is that SSEs in SvelteKit are implemented using ReadableStream.

The challenge here is that the port cannot be simply opened (and closed) in a standalone /src/route/api.../+server.ts endpoint inside ReadableStream code. The port has other responsibilities, such as saving the readings to a local database and sending select readings to a different backend in our project's system. This port code then has to live in its own module.

Solution

Originally, this functionality was implemented using WebSockets, but the WebSocket setup in SvelteKit is hacky, and it also felt overkill as the client didn't really need duplex communications with the backend.

The solution became glaringly obvious once I revisited the ancient port code I wrote months ago. SerialPort ports are essentially Node EventEmitter and Stream under the hood. The solution then is to write the Arduino readings into a Stream and pass this Stream object to the SSE GET endpoint.

This blog entry documents an example of how the relevant pieces of the solution can fit given the tech stack and constraints. The rest of this entry shows code samples. A note on notation: I use ... to mean that there exist intermediate pieces of code in between the code that I actually show here.

The SerialPort library

1

2
    // port.ts
3

4
    import { SerialPort } from 'serialport'
5
    import { ReadlineParser } from 'serialport'
6
    import { PassThrough } from 'node:stream'
7

8
    const port = new SerialPort({
9
        path: "/dev/ttyACM_DEVICE0",
10
        baudRate: 9600,
11
        autoOpen: false
12
    })
13

14
    const parser = this.port.pipe(new ReadlineParser({
15
        delimiter: "\r\n",
16
        includeDelimiter: true
17
    }))
18

19
    export const stream = new PassThrough()
20

21
    ...
22

23
    this.parser.on("data", (data) => {
24
        this.stream.write(data)
25
    })
26

27
    ...
28
    

The port object abstracts the connection to the port connected to a microcontroller. The parser object abstracts the parsing of the bytestream read by the port object. The convention I and our IoT engineer agreed upon here is to use a carriage return and a newline per data reading entry, hence the usage of something called the ReadlineParser. The crucial solution piece here is the stream object. It's a PassThrough stream that simply passes data written into it to its output with no transformations.

Server-Sent Event endpoint

1

2
    // +server.ts
3
    // This file resides in /routes/api/sse-endpoint/
4

5
    import port from "$lib/../../port"
6
    import type { RequestHandler } from "@sveltejs/kit"
7

8
    export const GET = (async ({ request }) => {
9
        const stream = new ReadableStream({
10
            start(controller) {
11
                /** Initializations here */
12
                port.stream.on("data", (data) => {
13
                    controller.enqueue(`data:${data}\n\n`)
14
                })
15
            },
16
            cancel() {
17
                /** Cleanup here */
18
            }
19
        })
20
        return new Response(stream, {
21
            headers: {
22
                "Content-Type": "text/event-stream",
23
            }
24
        })
25
    }) satisfies RequestHandler
26
    

This is how standalone SSE endpoints are written in SvelteKit. It is a GET verb that returns a Response that must contain some ReadableStream with content type set to text/event-stream. Line 12 contains the crucial solution piece, which is the stream object exported from the port code. This is the part where what's written into it shall be read to be eventually sent to the client, using the .on("data", ...) handler. Line 13 shows that it is mandatory in SSEs for the payload to be prefixed by data: and to be suffixed by two newlines.

The client-side

1

2
    // +page.svelte
3

4
    <script lang="ts">
5
        const subscribe_to_realtime = () => {
6
            const sse = new EventSource("/api/sse-endpoint")
7
            sse.onmessage = (event: any) => {
8
                    const payload = JSON.parse(event.data);
9
                    /** Now do whatever with this payload */
10
                }
11
            }
12
            return () => { sse.close() }
13
        }
14

15
        ...
16
        
17
        onMount(() => {
18
            
19
            ...
20

21
            const unsub_on_leave = subscribe_to_realtime()
22
            return unsub_on_leave
23
        })
24
    </script>
25

26
    ...
27

28
    html here
29

30
    ...
31
    

An EventSource object must be constructed with the URL path argument set to the path of the SSE GET endpoint. This code resides in some +page.svelte file which means it shows UI. Whenever the user navigates into this page, the Arduino data ends up here. When they leave, the resources are cleaned up. The onMount() code handles all these cleanup but its explanation is out of the scope of this entry so just look up its documentation.

References: