Primate Native, conditional exports, debarrelling

11 August 2024 • by terrablue

This is a (perhaps slightly cautionary) tale of considerable length. If you are in a hurry, I suggest you lay it aside and come back at a time of leisure.

Back after the release of Primate 0.31 in May, I was starting to think about the next steps. There are many things I want to achieve with Primate, including improving the ecosystem with additional commonly-used modules; adding the ability to have reactive server values (also known as liveview, of Phoenix Liveview reknown) that originate from database updates and trickle back down to the client; supporting deployments to different providers, and a whole lot more. But priorities are mostly dictated by the needs of users, and one of our early adopters, chovy, suggested it would be interesting to be able to create native apps from Primate projects.

His gripe with other approaches out there, such as Tauri, was it that requires knowing Rust -- certainly not something every developer has the time or will to learn. Without passing judgment on Tauri here, it's a given that Rust isn't for everyone. And besides, one of Primate's strong points is flexibility, and if I added desktop support, it'd be nearing a situation where there's nothing quite similar out there that offers the same toolbox.

So right on to it, right? Bun, which Primate has long supported, has the ability to compile desktop apps, so let's just have it run Primate in compile mode and voilà, all done.

Well, not so fast.

A story of two worlds

Web apps and native apps are two completely different beasts. A web app exists from a user perspective at one locality -- on the server, regardless of whether it's distributed across the world. Disregarding caching artefacts, every time you visit a web page, a copy of the app is downloaded onto your device and run. Native apps are, until updated, installed once, and their permission model, access to data sources, and many other features, vastly contrast those of web apps.

Aspect Native Web
Requires connection No (unless explicit) Yes (except for some PWAs)
Execution Local Remote
Sandboxing Operating system Browser
Configuration User home Delegated auth (cookies, tokens)
Execution locality Wherever started, changes On the server, stays same
Secrets Exposable by reverse-engineering As secure as the server they're on
Painted by GUI library Browser HTML/CSS engine

The list goes on and on. So trying to just bun build --compile Primate was obviously not going to cut it. The first challenge was thinking about how to paint the desktop app.

There are several approaches here, but perhaps the most straightforward one, if one wishes to keep compatibility with the web, is to use a webview. They're installed on virtually every operating system and lend themselves well to packaging a web app into a desktop container.

I was looking around for different webview libraries, and found a minimalistic webview library. Unfortunately, as is all too common today, there were three different projects to bring this library to Node, Deno and Bun, respectively, doing almost exactly the same (and more or less borrowing directly from each other).

I would have probably specifically used the webview-bun project, were it not missing support for the ability to use it in combination with web workers. This is the crux of the matter: the GUI process needs to run separately from the main JavaScript loop running the server, so as to not block it. If I didn't have a server and simply wanted to create a webview GUI project, this wouldn't be a concern (and Primate might, in the future, allow for projects with essentially no server, especially if all the content can be compiled into static assets during buildtime).

But Primate does have a server -- it's a full-stack framework, after all. So using that library wasn't an option. And besides, I felt that it would be probably better to use this opportunity to create a proper webview solution with diverging paths for Node, Deno and Bun, @rcompat/webview.

Alright. So before we can have a GUI for Primate, we need to have the ability to have a GUI in general that's independent of the main process, using web workers.

Into the rabbit hole

Creating @rcompat/webview for Bun wasn't very hard. It's essentially using Bun's excellent FFI module to map the C functions that the webview library exposes to JavaScript so that they can be called from there. In a matter of an hour or so, I was up and running, doing something to the effect of

import Webview from "@rcompat/webview";

const webview = new Webview();
webview.navigate("https://primatejs.com");
webview.run();

This is fairly straightforward. If you have Bun installed on your computer, then running

bun init -y && bun install @rcompat/webview && echo \
'import W from "@rcompat/webview"; const w = new W(); w.navigate("https://primatejs.com"); w.run();' \
> app.js && bun app.js

Will do the job. You should be able to see a webview window opening and therein the Primate website. Nice.

But this wasn't the particularly challenging part. The main issue was, as already mentioned, that if you try to run this piece of code from within a Bun server, it will block execution. You will see the webview window come up, but it will be blank. And if you then open your browser and try to access http://localhost:6161 (the typical address of a Primate server), it will stall indefinitely. Yup, it's blocking the main thread.

Fortunately, Bun's support for Workers (or Web Workers) is also excellent, allowing you to spawn a web worker on another thread, and inside it, you could run your webview, essentially your web browser client, accessing your backend. Server and client, two independent threads.

... this was the theory, at least. And like all theories, it's great on paper, and possibly dreadful in execution. The problem I was facing was that while I could spawn a new worker thread, this worker -- particularly within a compiled app -- could not access any imports that aren't native to Bun, that is, that are not prefixed with the bun: namespace (like bun:ffi).

This puts any kind of code reusability, with @rcompat/webview or anything else, completely out of the question. We can have a wonderfully runtime-key-diverging @rcompat/webview, working all the same for Node, Deno and Bun behind a uniform API, supporting different platforms, but we cannot use it within a worker that's running in a compiled app.

Small detour: runtime keys

I had been developing rcompat alongside Primate for quite some time now, and it went through quite a few iterations, from being called runtime-compat to its current name, from being one package, rcompat, with paths to the different modules, like rcompat/http and rcompat/fs, then onto a monorepo with distinct packages like @rcompat/http and @rcompat/fs, first with barrelled, then with almost entirely debarrelled imports (more on that later), and in between a significant port to TypeScript, which would not have become a reality without the expertise of r-cyr, for which I cannot be grateful enough. I am ever humbled to work with people who are more intelligent than I am.

Anyway, between all these twists and turns, I discovered, to my joy, this little page concerning runtime keys. I'd known about WinterCG for a while, and they're doing solid work, but this eclipses, in my perception, everything else they do.

To understand why, consider what I had been doing up until then to create divergent paths between the different runtimes, and how I've seen it done in the wild

let runtime = "node";

if (typeof Bun !== "undefined")
  runtime = "bun";

if (typeof Deno !== "undefined")
  runtime = "deno";

export default runtime;

This would serve as a base function to get the current runtime and diverge using different implementations:

import runtime from "./runtime.js";
import bun from "./impl/bun.js";
import deno from "./impl/deno.js";
import node from "./impl/node.js";

export default { bun, deno, node }[runtime];

Beyond being not particularly pretty code, it's also extremely inefficient: it means that regardless what runtime you use, everything gets pulled in, because you cannot have distinct entrypoints in your library for the different runtimes. Not to mention it's also not particularly resistant against someone modifying the global scope: it would be enough to set globalThis.Bun = "not really bun!"; at any time before the above code is called to fool it into thinking it's running Bun, and miserably fail later when it tries to use native APIs that do not exist. It's not smart.

Runtime keys, on the other hand, are any unifier's dream: they allow you to set up different entrypoint files to your library that will only be used by the respective runtime -- and even browsers and most notable cloud providers support them, meaning you can be particularly granular about who you target, and how.

{
  "exports": {
    ".": {
      "bun": "./src/bun/index.js",
      "deno": "./src/deno/index.js",
      "node": "./src/node/index.js"
    }
  }
}

I had known about the browser runtime key, having seen it cleverly used in Svelte to differentiate between the server-side and the client-side version of the framework, but I hadn't abstracted its usage -- or known that it was possible to diverge between different runtimes.

More rabbits

Alright, nice! We got our neatly diverging runtime keys in rcompat, the webview binding implementation looks good, but we're still faced with the problem of making it all reusable within Primate Native. All this beauty is for nothing if in the end, we have to duplicate our neatly organised webview code into one big messy file that can contain nothing but native Bun imports. That kind of sucks.

But what if we don't have to do it -- what if we can comfortably author in separate files, keeping our codebase clean and modular, and only bundle it for production? Splitting up code is anyway mostly for the programmer's benefit, not for the benefit of the computer. And no one ever said you can't bundle stuff purely for the backend.

So here, the solution was moving up a level by bundling the worker to be published as part of @rcompat/webview. In fact, an rcompat user has the choice of importing webview from @rcompat/webview, for normal, blocking usage -- in case no server is involved; or importing from @rcompat/webview/worker, using the exact same API as before, but guaranteeing that this time, the code will run in a worker that won't otherwise block execution.

This goes even further: when you import from @rcompat/webview/worker, you get auto-detection of your platform and thus the right dynamic library loaded. It's great if you're just testing or writing a program for your operating system. But for cross-compilation purposes, you can explicitly name the platform: @rcompat/webview/worker/linux-x64. More on that later when we get to debarrelling the world.

All things now considered, I had now reached, as far as the GUI was concerned, my goal: I could import a non-blocking, embeddable worker from @rcompat/webview, which I could use to show the client after our server has started. Mission accomplished!

Well, only if the mission statement was to be able to show a webview window and have it access the backend server, showing a plaintext "Hi" response, without blocking execution, as a sort of futile exercise in "we can run a server and a GUI client alongside". As it turned out, there was a lot more to do before the entirety of a Primate app could be packaged into a binary.

Rethinking the build system

Back in Primate 0.31, the world was simple. We had two commands: npx primate to run the app in development mode (with hot reload and other goodies), and npx primate serve to run the app in production mode. In both cases, we created a build directory, which was mostly for the benefit of the bundler to place the build artefacts there, but also to do a few things that are necessary during runtime, like compiling non-JS server components (Svelte, JSX) to JavaScript, which is necessary since the runtime doesn't really import anything other than .js files (at least until esload becomes a thing), and a few other things like transforming buildtime identifiers.

But in all of that, the build directory wasn't imagined as something you could just copy into another computer and run it from there. Sure, it would have probably worked somehow with a few adjustments, but it wasn't the stated goal -- the idea was that you'd run npx primate serve on the server, and it would build and run the app in one.

Adjusting Primate so it can build for native too meant for a chance to reimagine the build system where it would have two phases, build and serve: running npx primate would build, and then directly serve, that would remain the same as before from the user's perspective. But running in production would be a little different: you'd have to first run npx primate build, which would create everything you need to run the app later out of the build directory using npx primate serve, on the same computer you built it on or on another.

This approach lends itself rather well to the idea of build targets, because it can not only be used to differentiate between web and desktop, but also later extended into specifically adapted builds for cloud providers or static web pages. This is somewhat similar to the idea of adapters in SvelteKit, but is more fundamental, because SvelteKit cannot be built for the desktop.

So, a new build system it is, then. I was by now slowly easing into the business of generating code (I had already dabbled in that with the webview worker, but it was mostly throwing the bundler an entrypoint bone and letting it run its job to completion). The reason why I had to generate code here are the wholly different semantics Bun uses between running code normally and compiling apps, mostly as far as imports are concerned. Consider the following code.

import file from "@rcompat/fs/file";

const index_route = await import("../routes/index.js");

This code is basically how Primate used to load routes dynamically from the routes directory, in 0.31. The example loads just one specific route, but the actual use case is more complex: loading many different routes of different depths inside the route directory into an object whose keys are the full paths (starting from routes as root), and whose values are the imported files.

This works pretty well for web apps, but for native apps, Bun has two ways to include imports or assets. The first one are statically analysable imports: if you have import file from "@rcompat/fs/file";, file and all its imports will be included in the compiled app. If you use the --minify flag when compiling, Bun can even include dynamic imports, but only if it can analyse them (this is not an officially documented feature).

That means that while import("../routes/index.js") would work, a function that scans the filesystem recursively and produces an array of results wouldn't be included in the compiled app.

import collect from "@rcompat/fs/collect";

export default async () =>
  // won't be included in the compiled app
  Promise.all((await collect(`${import.meta.dir}/../routes`))
    .map(route => import(/* import the route */)));

In addition, non-JS files (HTML, CSS, static assets) are typically loaded from the filesystem as needed, potentially cached, and then served to the client. They too need to be included in the compiled binary.

All these concerns only hold if we want a truly portable app: one that we can run from any directory, or offer for download to other users. If we always ran the app from the directory where it was compiled, we wouldn't need to go to such extents, but that would also make it kind of useless.

These concerns meant that I needed to generate different code for different targets. The web target can just load files normally: it will always run from the build directory, where all its assets are available, relative to itself. But the native target needs to include everything it will ever need within the executable. Seems like we're in need of diverging paths, again.

Here is an example of how we would diverge on loading an HTML page that we use to render our components in. The web version is simple:

import file from "@rcompat/fs/file";

const { dir } = import.meta;
const index_html = await file(`${dir}/../pages/index.html`).text();

index_html now contains the contents of the file that is on disk. Compare this to the native version:

import file from "@rcompat/fs/file";
import index_html_import from "../pages/index.html" with { type: "file" };

const index_html = await file(index_html).text();

What happens in the second example is that Bun replaces the index_html_import import path with an internal import path which contains a copy of the original file. So during runtime, it would be something like

import file from "@rcompat/fs/file";
import index_html_import from "/$bunfs/generated-path-for-index-html";

const index_html = await file(index_html).text();

Which is more or less identical with the web target example, with the guarantee that the import comes from the internal filesystem of the executable and is always there.

Here we can begin to see why the web and native targets need to generate target-specific code that imports differently. But this isn't limited to code generation that is completely within our control. Our dependencies too, might unfortunately contain code that isn't compatible with Bun's strategy of embedding. One prominent example is the Svelte compiler, available via the svelte/compiler import, which attempts to dynamically load a JSON file and would thus fail to be included in an executable.

"But hold on -- 'Svelte compiler' you say, why do you need the Svelte compiler at all during runtime? Compiling components to JavaScript has been already handled during buildtime."

And you'd be perfectly correct in noting that. We don't need any form of compilation during runtime: all our components have been already converted to a format understandable by the runtime. We are now confronted with the challenge that some of our dependencies are required at buildtime, others at runtime, and yet others may not be imported at runtime. Hm. Quite the mess, again.

Return of the runtime keys

Thankfully, we're not completely stranded here. Previously we discussed using set runtime keys, like bun for Bun, deno for Deno, to create entrypoints which load native code specific to a runtime. This concept can be extended (in both Bun and Node, but not yet in Deno) to support custom conditions.

bun --conditions runtime
# or node --conditions runtime

Running the runtime with this flag set means it could use the specified condition, in our case runtime, to load our runtime-specific code. For Primate, this means we can truly separate our build system into a build (normal default condition) and a serve phase (with the runtime condition).

This also solves us a related fundamental issue: using the same user-provided primate.config.js file, with different interpretations during buildtime and runtime. Imagine you're a Svelte user and have this configuration file.

import svelte from "@primate/svelte";

export default {
  modules: [svelte()],
};

Normally, if our Primate configuration file were written in JSON, we would simply parse it during buildtime, change whatever we need to change so it fits with how it should work during runtime, and stringify it back to the build directory. But being that the Primate configuration file is written in JavaScript, it cannot be serialised. We need to keep it as is.

Using our runtime export condition, we can cleverly manipulate the meaning of the @primate/svelte import. During buildtime, it will load a different file than in runtime; the former will import svelte/compiler, but that won't end up in our binary. The latter will only include whatever's needed during runtime to render Svelte components.

A clarification: in the context of compiling apps, "buildtime" means the phase where we create everything in our build directory, including the generated target file and "runtime" means feeding bun build --compile the target file, causing it to compile an executable that works as though one called Bun on the target file.

{
  "exports": {
    ".": {
      "runtime": "./src/runtime/index.js",
      "default": "./src/default/index.js"
    }
  }
}

Note that in export conditions, order matters. default is a catch-all condition, and needs to be last. That's all why, before, we put bun and deno before node, because they typically also support the node export condition, for reasons of compatibility.

Armed with this newfound wisdom, we can now separate all Primate modules into a buildtime and a runtime part. Some of them won't need both; others will only perform a few checks during buildtime and bail out if you've done something wrong (like not having a locale directory but trying to use @primate/i18n); but most of them will contain pro forma entrypoints for both conditions. And we will benefit tremendously from including just the parts that we really need in our resulting executable.

... if it weren't for barrel exports.

Debarrelling the world

This has been already long-winded (and hopefully useful), but we're approaching the final act, I promise. In between slowly homing in on a solution and converting all Primate modules to the new runtime-key-based build/serve-dual format, the problem of barrelled exports (both in rcompat and Primate) came to light.

Consider the following piece of code.

import { useState } from "react";

There is a major flaw with this piece of code, which is not immediately discernible. It uses a named import from a single entrypoint which exports many different public-facing functions of the library and is called a barrel export. Barrel exports are particularly harmful because of their side-effects: in this case, you're not only importing useState and loading it into memory, but also everything else that that file contains, even though you never explicitly asked for it.

Beyond blowing up the size of the code you load into memory, barrel exports can create all other sorts of harms, for instance if any of their imports contains actual side-effects (i.e., isn't a pure module). Or, in the case of Primate Native, including a lot of code in the executable that will never be used, increasing its size unnecessarily.

Thus, in the later phases of working on Primate 0.32, I found myself debarrelling rcompat and Primate almost completely. Instead of doing something like

import { file } from "@rcompat/fs";

We now have deep imports with

import file from "@rcompat/fs/file";

There are a few exceptions, as in @rcompat/http/mime, which does not offer individual entrypoints for the MIME types, i.e.

import { jpg } from "@rcompat/http/mime";

But here, there was another consideration: @rcompat/http/mime also exports a resolve function that takes a file extension and returns the appropriate MIME type. In other words, this function already includes all tracked MIME types, so that I felt it unnecessary to break it down to an extremely granular level. That goes to say that one doesn't have to debarrel everything, and it might get absurd at some point -- but it makes sense to debarrel most stuff.

In addition, this has other advantages: having one import per line shows up neatly in git diffs, and making everything a default export means that you can call the import whatever you want, without having to resort to use as (which is arguably a cognitive anti-pattern, since it looks like destructuring, but uses different syntax).

To properly debarrel your code, you can make use of the wildcard capabilities of package.json's exports field:

{
  "exports": {
    "./handler/*": "./src/handlers/*.js",
  }
}

This type of export means that, if your package's name is primate, the import primate/handler/view would load the file at ./src/handlers/view.js, and only it.

Alongside debarrelling, on the kind advice of r-cyr, I converted most of the Primate and rcompat modules to using private imports.

Private imports and proper encapsulation

Although it was already introduced in Node 12, package.json imports seem to be rarely used in the wild. They are similar to the exports field (in that they support runtime keys/conditions), but they must begin with # (which is appropriately also the symbol for private fields in classes, in JavaScript) and are private to the package. This is great if you to want to differentiate between a private and public part of your library. Consider the following (simplified) package.json file of @rcompat/fs.

{
  "imports": {
    "#*": "./lib/private/*/index.js"
    "#native/*": "./lib/native/private/*.js"
  },
  "exports": {
    "./*":  "./lib/default/*/index.js"
  }

From anywhere within the package, you can use #FileRef to import the FileRef class, which is located at ./lib/private/FileRef/index.js. From without the package, anything you import will come from ./lib/default -- which mostly contains reexported private functionality. In some rcompat packages, I've actually moved to renaming the "default" directory to "public" in order to make it clear what's exposed and what's not.

Also (neither rcompat nor Primate currently makes use of it), the imports field permits mapping to external packages, potentially allowing you to split up runtime-divergent implementations into different packages.

Final act: use only what you own

One last thing, which came up in the wake of the 0.32 release and necessitated a patch, was a problem that relates to how different package managers handle the node_modules directory.

When you run npm install, npm will install all packages, including transitive dependencies (if hoistable) into node_modules. So if package a has a dependency b, you will see both a and b in your node_modules directory.

This means that if you then create a renegade.js file in your project directory that, for some reason, imports a transitive dependency (b), it would work.

Other package managers, like pnpm and Deno, work differently: they also place the user's own, directly installed dependencies into node_modules, but the rest is put in a hidden store inside node_modules/.pnpm (or node_modules/.deno) that uses symbolic links.

During the target generation, I was first using imports that I did not own directly, from the perspective of the generated file, which is located in build. Imports such as @rcompat/fs, which are used by many Primate packages but not explicitly installed by the user unless needed. This led to a difference when using npm vs pnpm/Deno: with npm, you didn't need to explicitly install these transitive dependencies, because they were all hoisted, and with the other package managers you did, otherwise Primate wouldn't run.

Regardless what behaviour you would consider correct, Primate should work flawlessly across runtimes and package managers, which has led me to modify the generated files to only use dependencies that the user installed: in the case of the web target, only primate, and in the case of the desktop target, primate and @primate/native. After that, Primate was running well in all scenarios.

Fin

If you benefitted from this post, consider supporting the rcompat and Primate (website, github) projects by starring/watching on Github, using, filing bug reports or feature requests, or hopping onto chat to talk to us.