AphrodyBun GitHub

Runtime docs · Guides · Runtime & Debugging

Build a Tauri app with Bun as its runtime

packages/bun-tauri holds the aphrody fork of Tauri 3 (alpha) as its own Cargo workspace: the core crates (aphrody-tauri, -runtime, -runtime-cef, -utils, -plugin, -build, -codegen, -macros, -cli, -bundler) and one plugin crate, tauri-plugin-aphrody. That crate contains every plugin of the fork plus the MCP debug bridge and tauri-wrap. Its JavaScript side is @aphrody/tauri.

The code comes from aphrody-labs/aphrody@55652e3f56 (crates/tauri, crates/ui/tauri-plugin-mcp-bridge, crates/ui/tauri-wrap), under Apache-2.0 OR MIT. The MCP bridge is MIT (hypothesi).


One crate, one plugin per module

Each module is still its own Tauri plugin. Command names (plugin:fs|read_file) and permission identifiers (fs:allow-read-file) are the same as in the separate plugins. A Cargo feature with the same name enables each module:

FeaturePluginNotes
(always)aphrodyBun runtime: bun_info, bun_request, bun_restart
autostart, clipboard-manager, deep-link, dialog, fs, global-shortcut, log, notification, opener, os, process, single-instance, store, updater, window-statesame namein default
fs-watch, log-colored, log-tracing, notification-windows7-compat, single-instance-semver, single-instance-deep-link, updater-zip, updater-rustls-tls, updater-native-tls, updater-system-proxy, dialog-xdg-portalsub-featuresrenamed from the per-plugin features
mcp-bridgemcp-bridgeopt-in, pulls tauri-runtime-cef
wrap, wrap-bundle, wrap-debug-bridgetauri-wrap (no IPC)opt-in

A single crate can publish permissions for several plugins because of a change in tauri-utils and tauri-plugin. tauri_plugin::Bundle builds every member: it writes permissions/<name>/, the autogenerated command permissions, the scope schema and the global API script. Each member's permission list is announced as cargo:__BUNDLE__<NAME>_PERMISSION_FILES_PATH. The app's tauri-build maps that key back to the plugin <name> (tauri_utils::acl::build::plugin_name_from_dep_var). generate_handler![#![plugin(<name>)] …] makes sure unused-command removal targets the right plugin.

src-tauri/src/main.rs
use tauri_plugin_aphrody::BuilderExt;

fn main() {
    tauri::Builder::default()
        // the `aphrody` plugin + every enabled module that takes no argument
        .aphrody_plugins()
        .plugin(tauri_plugin_aphrody::single_instance::init(|_app, _argv, _cwd| {}))
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

aphrody_plugins() does not register single-instance (it needs a callback), updater (it needs plugins > updater with a public key), mcp-bridge or wrap.


Bun as the backend

The aphrody plugin starts a Bun server next to the app and stops it when the app exits. Configure it under plugins > aphrody > bun:

tauri.conf.json
{
  "plugins": {
    "aphrody": {
      "bun": { "entry": "server.ts", "hot": true }
    }
  }
}
KeyMeaning
entryScript run with bun [--hot] <entry>. The bun binary is binary, then BUN_TAURI_BUN, then PATH.
sidecarName of a bun build --compile executable placed next to the app (bundle > externalBin). Takes precedence over entry.
hotPass --hot so Bun.serve hot-reloads in development.
args, env, cwdExtra arguments, environment and working directory.
portFixed port. By default the plugin picks a free loopback port.
readyTimeoutMsHow long to wait for the port to accept connections (default 15000).
autostartStart with the app (default true). Otherwise it starts on the first bun_restart.

The server receives its port in PORT, which Bun.serve uses by default, and in BUN_TAURI_PORT. It also gets BUN_TAURI=1. The plugin waits until the port accepts connections. Each output line goes to the log crate and is emitted as an aphrody://bun-log event.

server.ts
import { serveForTauri } from "@aphrody/tauri/server";

serveForTauri({
  routes: { "/api/hello": () => Response.json({ runtime: `bun ${Bun.version}` }) },
});

In the webview, bunRequest and bunFetch go through IPC, so you need no CORS headers and no CSP entry for the port:

src/main.ts
import { bunFetch, bunInfo, onBunLog } from "@aphrody/tauri/bun";

const res = await bunFetch("/api/hello");
console.log(await res.json(), await bunInfo());
await onBunLog(({ stream, line }) => console.log(stream, line));

The default permission aphrody:default grants bun_info and bun_request. Restarting the server needs aphrody:allow-bun-restart. When withGlobalTauri is on, the same calls are available as window.__TAURI__.aphrody.

For a release build, compile the server with bun build --compile server.ts --outfile binaries/server-<target-triple>. List it under bundle > externalBin and set "sidecar": "server".


Runtimes and platforms

Tauri 3 has no default runtime. Pass one with Builder::runtime(...).

  • CEF (aphrody-tauri-runtime-cef, CEF 152.3.0) is the desktop runtime on Windows and Linux with glibc. On Linux it forces X11/XWayland. On Windows Chromium runs without a sandbox, whatever the policy.
  • Alpine (musl): CEF does not ship musl builds. A window on Alpine depends on the WebKitGTK runtime (aphrody-tauri-runtime-wry), which comes from the shared webview layer (packages/bun-webview-core). The runtime crates belong to that layer, not to this plugin.
  • Mobile: the Android and iOS projects of each plugin are kept under crates/tauri-plugin-aphrody/mobile/<name>/. They are not wired into the bundle yet.

Test

cd packages/bun-tauri
cargo test -p tauri-plugin-aphrody

tests/e2e.rs builds a MockRuntime app for each module and resolves its ACL from the permission files the build script generated. It then calls the commands through real IPC, as a webview would. That covers os, fs (inside and outside its scope), store, log, window-state, notification and deep-link. It also starts a real Bun server through aphrody and calls bun_request and bun_restart.

dialog, clipboard-manager, global-shortcut and opener need a desktop session, so this suite does not run them.