Embedded TypeScript: Hosting a Frontend on a ESP32
How we hacked web technologies to run on a ESP.
You might have read about how (and why!) we use Rust on embedded. If not, go check out my previous article! In this one, I will talk about how we added a frontend based on Typescript into the mix.
Some background: I work for a company building second-life storage systems. To track our system, we have an embedded device, written in Rust, which pushes data to the cloud. Now, when installing a new system at the customer's site, this device needs to be configured and connected to the customer's WiFi.
We need some kind of interface to enter the configuration and write that onto the device. First, the device installer needs to connect to the device somehow. But this is easy! As presented in the last blog post, we use an ESP32, which comes with WiFi built-in. So we can just open up a hotspot and connect to it.
We can then use a command line interface, which connects over the access point to the ESP, to perform the actual configuration. However, the installation typically is done by non-programmers. Instead, we need some kind of user interface. An app sounds like a good idea! But then it has to stay compatible with multiple iterations of our embedded device (which we call "edge device") and keep supporting older versions.
Instead, we opted to develop a small website, which is served by the edge device and opens up once you log into its WiFi (similar to the captive portals that open up when connecting to some airport WiFi). This website is not just HTML, we also need some JavaScript for custom logic before sending the request to a different endpoint and to let the user know whether their request was successful.
This is one of the earlier iterations of our portal (the current state is rather boring since it only has one field):
To build this, I first started up a new Next.js project, which is my go-to web development framework. But react-dom alone is just way too big:
Soo, back to the roots! Throw in some of that sweet .html
and .js
along with a pinch of .css
, et voila - we have a website.
I wish it were as easy as that. But we have some key limitations when building a frontend for our edge device, that I didn't mention until now:
It has to be small. Really small. Every single byte will be a byte less, which we can use to buffer messages in case of an internet outage.
We don't have a real (but a "real-time") operating system. Yes, we have a basic notion of scheduling and threads. But, there is no such thing as a file as part of a filesystem. Just bytes. So we can't just serve a bunch of files via HTTP.
Everything we write is very hardware-dependent. There is no Linux networking stack ready to be used. So if we interact with the networking stack, we directly interact with the hardware (through a small client/server abstraction) as we do with most things on embedded. So no chance of running any modern HTTP server.
Let's first tackle the second problem of embedding the website onto the device. We have a bunch of files that make up the website. But what we need in the end, is just a chunk of bytes (remember, we don't have a filesystem).
Without a framework, we have to build the build pipeline ourselves. We use Webpack (but it's on my to-do list to check out Turbopack, which is written in Rust) to bundle all JavaScript files and all CSS files together. This also allows us to use TypeScript!
TypeScript is an extension to JavaScript which adds types (duh). It makes programs safer and easier to maintain. However, we can still profit from the vast JavaScript ecosystem, because it's interoperable with it - TypeScript just transpiles to JavaScript in the end (which makes it run in browsers without web assembly). Also, since we already use Rust, one of the safest programming languages out there, we just couldn't get away with using JavaScript!
You can embed TypeScript, CSS and other resources like images into HTML, but we didn't find a neat solution to do it with Webpack (but I'm sure there is a plugin for this somewhere out there). I then found monolith on GitHub. It's meant for archiving webpages into a single file, but it also solves exactly our use case. We run it right after Webpack and it embeds all the website's resources base64-encoded into one single HTML file, which we then embed into our code.
Challenge solved! Next up: Making it small. We already use a quite minimal CSS library called Skeleton. Webpack has a minification step and monolith embeds resources in base64. That makes it small, but not small enough.
Luckily browsers nowadays support compression. Meaning, we can send the client our compressed HTML file, and as long as we include information about the compression in the response headers, the client will decompress it first. We managed to reduce the size of the website by 65% by using the Brotli compression algorithm! Great! Our new pipeline now looks like this: Run the bundler (Webpack), run monolith to get one file and then compress it using Brotli.
So are we done? Not quite! There is another requirement I didn't tell you about: On the website, we need to be able to display if the device is already configured. We also want to show the current version, state and some other stuff. We could just make a request from our frontend back to the backend and ask for that information. But this would require a second API endpoint which builds some JSON response with all that data. This would increase our code complexity, and we would have to handle a bunch of new error cases (remember, everything is way harder on embedded). Also, it's a bit weird: We just sent the whole website with all its resources, why can't we send this data along with it? Because the body was compressed and inserted during compile time!
So, what are our options besides that nasty second request? We cannot look for some byte markers and modify the bytes directly since it's already compressed - and it would be horrible to maintain. In our HTTP response, the body is compressed, but remember what such a response looks like:
We still have the headers we can use to pass information to the clients. Headers are not compressed and allow us to send arbitrary key-value pairs to the client. However, you cannot access them from the body (HTML/JavaScript) to actually display them (probably due to some security concerns)! Or can you?!
Let me present to you: The server-timing header! This header allows the server to pass some performance metrics to the client - which can read its values through the browser's JavaScript API. The values can be set like this: server-timing: key1;desc="value1", key2;desc="value2"
. We can then simply use window.performance.getEntriesByType('navigation')
to read those values. Easy!
Buuut, Safari does not support this yet. So as neat as this sounds - we can't use it. However, there is yet another header type we can (ab)use in a similar way. Cookies! Cookies are meant to store information about the user across browser sessions. They are set by headers and can be read out with JavaScript (or TypeScript in our case). However, they behave a bit differently than the server-timing header, because they are persistent. So in order to use them, we set a really low expiry date and delete them right after reading them.
And with that, we have a neat little frontend which we can use to configure our systems. If you haven't yet read the first part of this article, go over here to learn more about why we chose Rust in the first place.