DEV Community

Ripon C Malo
Ripon C Malo

Posted on

Shipping a Node.js server as a native desktop app with Tauri and Bun

I have a small Node server — Express, a WebSocket hub, about 2,000 lines. It's a
LAN file-transfer tool called MeghXL. Running
it means cloning a repo, npm install, npm start. That's fine for me and a wall
for everyone else.

I wanted a double-clickable app for macOS, Windows and Linux, where the user has no
idea Node exists. Here's what that actually took.

Why not Electron

Electron would have been the boring answer, and boring answers are usually right.
It bundles Node, so the server just runs in the main process. No sidecar, no IPC
puzzle.

I went with Tauri for one reason: size. My finished installers are 28–41 MB. The
Electron equivalent starts around 150 MB before I've written anything, because it
ships Chromium. For a tool whose pitch is "small enough to audit in an evening",
handing people a 150 MB download undercuts the whole argument.

The cost is that Tauri is Rust and uses the OS webview, so the Node server can't
live inside it. It has to be a separate process.

The sidecar

Tauri has a mechanism for this: externalBin. You give it a binary, it bundles it,
and your Rust code spawns it.

{
  "bundle": {
    "externalBin": ["binaries/meghxl-server"]
  }
}
Enter fullscreen mode Exit fullscreen mode

The catch: it must be one file. A server.js plus a node_modules tree is not
a binary.

Compiling Node to a single executable

Three options, and the differences matter:

  • pkg — the classic answer, now archived and unmaintained.
  • Node SEA (single executable applications) — official, but fiddly with CommonJS across many files.
  • bun build --compile — one command, and it cross-compiles.

Bun won:

bun build ./server.js \
  --compile \
  --target=bun-darwin-x64 \
  --outfile meghxl-server-x86_64-apple-darwin
Enter fullscreen mode Exit fullscreen mode

That produces an 82 MB self-contained executable in about half a second. It runs
Express, ws, multer and mDNS discovery with no runtime installed.

The thing that made this work at all: all seven of my runtime dependencies are
pure JavaScript. No native addons. If one of them had shipped a .node binary, the
whole approach would have collapsed and I'd be back at Electron. Check this before
you plan around it:

find node_modules -name "*.node" | head
Enter fullscreen mode Exit fullscreen mode

Empty output is the green light.

The filename is load-bearing

Tauri resolves sidecars by target triple. The binary must be named
<name>-<triple>, matching what rustc -vV reports as host:

TRIPLE=$(rustc -vV | sed -n 's/^host: //p')
# meghxl-server-x86_64-apple-darwin
# meghxl-server-x86_64-pc-windows-msvc.exe
Enter fullscreen mode Exit fullscreen mode

Get this wrong and the build fails late, at bundling, with a confusing message.

Static files have no source tree any more

My server did this:

app.use(express.static(path.join(__dirname, 'public')));
Enter fullscreen mode Exit fullscreen mode

Inside a compiled binary, __dirname is a virtual path. There is no public/
directory beside the executable.

The fix is to make the location configurable, ship the files as a Tauri resource,
and have the Rust side pass the resolved path in:

// config.js
publicDir: path.resolve(
  process.env.PUBLIC_DIR || path.join(__dirname, '..', 'public')
),
Enter fullscreen mode Exit fullscreen mode
env.insert("PUBLIC_DIR".into(), public_dir(app).to_string_lossy().into_owned());
Enter fullscreen mode Exit fullscreen mode

Same for anything the server writes. Uploads and the metadata store go to the OS
user-data directory, not inside the app bundle — bundles are read-only in practice
and get replaced wholesale on update.

Three bugs worth stealing

Drag-and-drop stopped working. Tauri installs its own native file-drop handler
on every window, which swallows OS drops and emits Tauri events instead. My HTML5
dropzone fired with an empty dataTransfer.files. One line:

.disable_drag_drop_handler()
Enter fullscreen mode Exit fullscreen mode

Downloads opened instead of saving. A Tauri window has no download machinery
unless you give it one. Clicking a download link rendered the file in the window,
and closing it lost the file — even though the server was correctly sending
Content-Disposition: attachment. You need an explicit handler:

.on_download(move |_webview, event| {
    match event {
        DownloadEvent::Requested { destination, .. } => {
            *destination = unique_path(&download_dir(&app), &name);
        }
        DownloadEvent::Finished { success, .. } => { /* notify */ }
        _ => {}
    }
    true
})
Enter fullscreen mode Exit fullscreen mode

Note that on macOS Finished never reports the path, so keep your own copy of what
you assigned in Requested.

The orphaned server. This one cost me hours. Force-quitting the app left the
sidecar running, holding the port. My app is designed to attach to an existing
server rather than fight for the port — so every subsequent launch attached to a
stale process and silently served old code. New builds appeared to change
nothing.

The fix ties the child's life to the parent, using the fact that Tauri gives the
sidecar a piped stdin:

// EOF on stdin means the parent is gone
if (process.env.MEGHXL_EXIT_WITH_PARENT === '1') {
  process.stdin.resume();
  for (const ev of ['end', 'close', 'error']) process.stdin.on(ev, shutdown);
}
Enter fullscreen mode Exit fullscreen mode

Opt-in via an env var the app sets, so running from a terminal — where stdin is a
TTY — is unaffected.

Building all four platforms

Tauri cannot cross-compile between operating systems, so each target needs its own
runner:

strategy:
  matrix:
    include:
      - { os: macos-14,       target: aarch64-apple-darwin }
      - { os: macos-13,       target: x86_64-apple-darwin }
      - { os: windows-latest, target: x86_64-pc-windows-msvc }
      - { os: ubuntu-22.04,   target: x86_64-unknown-linux-gnu }
Enter fullscreen mode Exit fullscreen mode

Two things bit me here.

Bundle targets are platform-specific. My config said "targets": ["app", "dmg"]
from when this was macOS-only. Windows and Linux compiled the binary, bundled
nothing, and failed with "No artifacts were found" — while macOS passed, so I
didn't notice until I had a four-platform workflow. "targets": "all" lets each
runner produce its own natives.

Intel macOS runners are scarce. macos-13 is the last Intel image and
GitHub is retiring it; my job sat queued for 40 minutes and never started. If you
need an Intel build, plan to cross-compile x86_64-apple-darwin from an ARM
runner instead.

Signed updates

Tauri's updater refuses anything not signed by your key:

npx tauri signer generate -w ~/.keys/updater.key
Enter fullscreen mode Exit fullscreen mode

The public half goes in tauri.conf.json, the private half in a GitHub secret. The
app then only installs a package whose signature verifies against the key compiled
into it — so a compromised download host can't push malicious code.

Guard that private key with your life. Lose it and no existing install will ever
accept an update again; every user reinstalls by hand.

Was it worth it

Installers are 28–41 MB. Users install nothing but the app. The server code didn't
change except for one configurable path and one stdin listener.

If your dependencies are pure JS, this path is genuinely straightforward. If any of
them is native, use Electron and don't feel bad about it.

Code's here if it's useful: github.com/riponcm/MeghXL
(Apache-2.0). The Tauri shell is one Rust file worth skimming.

Top comments (0)