Intermission

I figured that I only publish anything to my blog when it reaches the level of polish I expect from a good technical article. That means that most of my notes never leave emacs. It’s, frankly, boring. I will experiment with turning more of my personal notes into posts even though they might seem like ramblings. You know where to provide the feedback if you don’t like it (or if you do).


I’ve experimented with some modern web development frameworks recently and my experience was somewhat strange. My first stop at “let’s write a web app” was rust (I had fun writing some embedded rust recently, and it seemed like a good continuation). Dioxus is the state-of-the-art rust framework to write full-stack web apps.

The overall feeling was not too bad. I genuinely dislike writing any rust that’s not hardcore embedded, because it feels sickening to just #[derive(Clone)] every little bit. Yeah, I understand that it’s how I can guarantee the strings will outline the scope, and I understand that closures probably have to move those strings in, but why? It’s such an awkward mental model of “let’s just duplicate everything” as opposed to the finesse of automatic reference counting and garbage collectors.

Armed with tailwind (don’t blame me, LLMs are good at writing tailwind and I hate centering divs myself), I made some progress until the cracks showed up. Dioxus follows the general model of React, both with state management and server-side calling. The problem that you face is that your main.rs is both the point of entry for your backend and frontend. Most of the crates become optional (so that you don’t include postgres in your frontend or web-sys in your backend). The code complexity starts to grow, wrapped with endless #[cfg(feature = "server")] bits. Worst of it all, now the LSP hates you too, because it can’t see past a disabled feature and it ignores all server code altogether (part of that is on me: I use vim nowadays and vim lsp setup is not as straightforward).

Facing the growing complexity and the distinct lack of fun (where’s fun in explaining the compiler that yes, I have to build web-sys on server too because otherwise the dependency management is hell but no, it must never call into it on server because that explodes?), I decided to somewhat give up on that project. It was a nice experiment, and I didn’t want to drop it completely, though, so I turned my eye to the language I haven’t weritten in for many years.

Javascript.

Apparently, the state of the art in the JS/TS world is NextJS. Probably, I should have tried Nuxt instead; I remember Vue being fun, but I thought I’d take easy steps (and also, my frontend LLM was Vercel’s v0, which is NextJS-oriented).

Getting NextJS up and running was easy. Remembering React wasn’t too bad. Trying to diversify and learn something new, I opted into newer frameworks and paradigms, e.g. instead of the tried and true MobX I went with Valtio (a great decision!). I also tried to embrace some newer state management paradigms and server-side interactions. It all worked fine until suddenly I realised that I’m fighting literally the same battle as with the Rust code: fullstack was a pain to deal with!

And yes, I had to repeatedly mark my components as "use client" because they depended on things like the window’s innerWidth (fun with canvas). I opted for server functions for my API and they worked until they stopped—in largely mysterious ways, and I had to smear the rest of the code with "use server" excessively. What if you have both in one file, like your auth handlers? Obviously, you split that file in two now! Things mostly worked, even though the “type safety” over the API border was more of a meme than a real promise. You still have to validate the incoming data with zod, you still have to do auth checks, so what’s the point? The only thing the framework does for you is JSON serialization.

Again, it mostly worked until it broke apart in even more fun ways. My DB? Postgres, powered by prisma. Prisma uses a bunch of blobs because it’s not just Javascript code. Those blobs work in Docker, mostly, until they don’t, because some incompatibility between what they do, the linux kernel, and the macOS’s Rosetta emulation that allows you to target linux/x86_64 on a darwin/amd64 makes them break. Don’t want prisma? Sure, there are other ORMs, like drizzle. Which even supports the postgres client built into my JS runtime of choice—bun. Of course, NextJS’s custom bundler has no concept of bundling for bun, so now the code doesn’t work again because your DB tries to make a legit import that your bundler doesn’t understand. Throw away bun and replace it with node-postgres? Sure. Now, parts of other code that used to work break because one file that wasn’t marked as "use server" explicitly transitively pulls on node’s “crypto” and that doesn’t exist in the frontend. You try to untangle that and realise that it breaks every expectation of the server functions you had and the only reasonable way is to remove them and rewrite the code in a traditional way of API calls and fetch() calls.

What a mess.

On one hand, I understand the desire to have a framework that handles both sides of a complex application, to be allowed to forget that there’s a lot happening between the client and the server. In fact, I still remember the amazing simplicity of Cocoa’s RPC: distributed objects. It looked something like

1
2
3
4
5
NSConnection *conn = [NSConnection
                      connectionWithRegisteredName:@"RemoteService"
                                              host:nil];
id remoteObj = [conn rootProxy];
[remoteObj doSomething];

The remoteObj represented something somewhere else, but you’d deal with it as it was just another local object. Amazing. Powerful. Also, fragile.

When we think about RPC, even if it’s just a REST API between a web application and its server, we think about various concerns like data serialization, validation, authentication, authorization, etc. Indeed, if your app can call a server function in react, someone else can, too. You can’t trust that endpoint to only receive perfectly sanitized data. You have to know if whatever the magic that allows you to await on a server function will treat all of those concerns, or some. And that means you need to understand the framework you work with on a much deeperlevel than its authors intended you to.

Afterthoughts.

It’s interesting how Clojure fullstack actually works, becasue Clojure offers a REPL that can switch from Clojure to ClojureScript flawlessly based on the context and where the shared code is a separate file type that will explicitly tell you which parts of the language are shared and which are not. A very pleasing experience, if your tooling plays along.