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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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.