Back to Marketplace
FREE
Unvetted
Grow Business

Tauri + JS Runtime Integration

Add JavaScript runtime backend capabilities to Tauri v2 desktop apps. Covers both using the tauri-plugin-js plugin and building from scratch. Use when integrating Bun, Node.js, or Deno as backend processes in Tauri, setting up type-safe RPC between f

Install in one line

mfkvault install tauri-js-runtime-integration

Requires the MFKVault CLI. Prefer MCP?

New skill
No reviews yet
New skill
🤖 Claude Code Cursor💻 Codex🦞 OpenClaw
FREE

Free to install — no account needed

Copy the command below and paste into your agent.

Instant access • No coding needed • No account needed

What you get in 5 minutes

  • Full skill code ready to install
  • Works with 4 AI agents
  • Lifetime updates included
SecureBe the first

Description

--- name: tauri-js-runtime description: Add JavaScript runtime backend capabilities to Tauri v2 desktop apps. Covers both using the tauri-plugin-js plugin and building from scratch. Use when integrating Bun, Node.js, or Deno as backend processes in Tauri, setting up type-safe RPC between frontend and JS runtimes, creating Electron-like architectures in Tauri, or managing child processes with stdio communication. version: 1.0.0 license: MIT metadata: domain: desktop-apps tags: - tauri - bun - node - deno - rpc - kkrpc - electron-alternative - process-management - compiled-sidecar --- # Tauri + JS Runtime Integration Give Tauri apps full JS runtime backends (Bun, Node.js, Deno) with type-safe bidirectional RPC. This covers two approaches: using the `tauri-plugin-js` plugin, and building the integration from scratch. ## When to Use - User wants to run JS/TS backend code from a Tauri desktop app - User asks about Electron alternatives or "Electron-like" features in Tauri - User needs to spawn/manage child processes (Bun, Node, Deno) from Rust - User wants type-safe RPC between a Tauri webview and a JS runtime - User needs stdio-based IPC between Rust and a child process - User asks about kkrpc integration with Tauri - User wants multi-window apps where windows share backend processes - User needs runtime detection (which runtimes are installed, paths, versions) - User wants to ship a Tauri app without requiring JS runtimes on user machines (compiled sidecars) - User asks about `bun build --compile` or `deno compile` with Tauri ## Core Architecture ``` Frontend (Webview) <-- Tauri Events --> Rust Core <-- stdio --> JS Runtime ``` - **Rust** spawns child processes, pipes their stdin/stdout/stderr, and relays data via Tauri events - **Rust never parses RPC payloads** — it forwards raw newline-delimited strings - **kkrpc** handles the RPC protocol on both ends (frontend webview + backend runtime) - **Frontend IO adapter** bridges Tauri events to kkrpc's IoInterface (read/write/on/off) - **Multi-window** works because all windows receive the same Tauri events; kkrpc request IDs handle routing ## Approach A: Using tauri-plugin-js (Recommended) The plugin handles process management, stdio relay, event emission, and provides a frontend npm package with typed wrappers and an IO adapter. ### Step 1: Install **Rust** — add to `src-tauri/Cargo.toml`: ```toml [dependencies] tauri-plugin-js = "0.1" ``` **Frontend** — install npm packages: ```bash pnpm add tauri-plugin-js-api kkrpc ``` ### Step 2: Register the plugin In `src-tauri/src/lib.rs`: ```rust pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_js::init()) .run(tauri::generate_context!()) .expect("error while running tauri application"); } ``` ### Step 3: Add permissions In `src-tauri/capabilities/default.json`: ```json { "permissions": [ "core:default", "js:default" ] } ``` ### Step 4: Define a shared API type Create a type definition shared between frontend and backend workers: ```typescript // backends/shared-api.ts export interface BackendAPI { add(a: number, b: number): Promise<number>; echo(message: string): Promise<string>; getSystemInfo(): Promise<{ runtime: string; pid: number; platform: string; arch: string; }>; } ``` ### Step 5: Write backend workers Each runtime has its own IO adapter from kkrpc: **Bun** (`backends/bun-worker.ts`): ```typescript import { RPCChannel, BunIo } from "kkrpc"; import type { BackendAPI } from "./shared-api"; const api: BackendAPI = { async add(a, b) { return a + b; }, async echo(msg) { return `[bun] ${msg}`; }, async getSystemInfo() { return { runtime: "bun", pid: process.pid, platform: process.platform, arch: process.arch }; }, }; const io = new BunIo(Bun.stdin.stream()); const channel = new RPCChannel(io, { expose: api }); ``` **Node** (`backends/node-worker.mjs`): ```javascript import { RPCChannel, NodeIo } from "kkrpc"; const api = { async add(a, b) { return a + b; }, async echo(msg) { return `[node] ${msg}`; }, async getSystemInfo() { return { runtime: "node", pid: process.pid, platform: process.platform, arch: process.arch }; }, }; const io = new NodeIo(process.stdin, process.stdout); const channel = new RPCChannel(io, { expose: api }); ``` **Deno** (`backends/deno-worker.ts`): ```typescript import { DenoIo, RPCChannel } from "npm:kkrpc/deno"; import type { BackendAPI } from "./shared-api.ts"; // .ts extension required by Deno const api: BackendAPI = { async add(a, b) { return a + b; }, async echo(msg) { return `[deno] ${msg}`; }, async getSystemInfo() { return { runtime: "deno", pid: Deno.pid, platform: Deno.build.os, arch: Deno.build.arch }; }, }; const io = new DenoIo(Deno.stdin.readable); const channel = new RPCChannel(io, { expose: api }); ``` ### Step 6: Frontend — spawn and call ```typescript import { spawn, createChannel, onStdout, onStderr, onExit } from "tauri-plugin-js-api"; import type { BackendAPI } from "../backends/shared-api"; // Spawn const cwd = await resolve("..", "backends"); // from @tauri-apps/api/path await spawn("my-worker", { runtime: "bun", script: "bun-worker.ts", cwd }); // Events onStdout("my-worker", (data) => console.log(data)); onStderr("my-worker", (data) => console.error(data)); onExit("my-worker", (code) => console.log("exited", code)); // Type-safe RPC const { api } = await createChannel<Record<string, never>, BackendAPI>("my-worker"); const result = await api.add(5, 3); // compile-time checked ``` ### Step 7: Compiled binary sidecars (no runtime on user machine) Both Bun and Deno can compile TS workers into standalone executables. The compiled binaries preserve stdin/stdout behavior, so kkrpc works unchanged. **Compile with target triple suffix:** ```bash TARGET=$(rustc -vV | grep host | cut -d' ' -f2) # Bun — compile directly from the project directory bun build --compile --minify backends/bun-worker.ts --outfile src-tauri/binaries/bun-worker-$TARGET # Deno — MUST compile from a separate Deno package (see pitfall #8 below) deno compile --allow-all --output src-tauri/binaries/deno-worker-$TARGET path/to/deno-package/main.ts ``` **Configure Tauri to bundle sidecars** in `src-tauri/tauri.conf.json`: ```json { "bundle": { "externalBin": ["binaries/bun-worker", "binaries/deno-worker"] } } ``` Tauri automatically appends the current platform's triple when resolving `externalBin` paths, so the binary is included in the app bundle and runs on the user's machine without any runtime installed. **Spawn with `sidecar` instead of `runtime`:** ```typescript import { spawn, createChannel } from "tauri-plugin-js-api"; await spawn("compiled-worker", { sidecar: "bun-worker" }); // RPC works identically const { api } = await createChannel<Record<string, never>, BackendAPI>("compiled-worker"); await api.add(5, 3); // => 8 ``` **Key points:** - `config.sidecar` resolves the binary via Tauri's sidecar mechanism — looks next to the app executable, tries both plain name (production) and `{name}-{triple}` (development) - The same worker TS source compiles into a binary that runs identically to the runtime-based version - `getSystemInfo()` still reports `runtime: "bun"` or `runtime: "deno"` — the runtime is embedded in the binary - No filesystem path resolution needed on the frontend — just pass the sidecar name ### Step 8: Runtime detection (optional) ```typescript import { detectRuntimes, setRuntimePath } from "tauri-plugin-js-api"; const runtimes = await detectRuntimes(); // [{ name: "bun", available: true, version: "1.2.0", path: "/usr/local/bin/bun" }, ...] // Override path for a specific runtime await setRuntimePath("node", "/custom/path/to/node"); ``` ### Plugin API Summary | Command | Description | |---------|-------------| | `spawn(name, config)` | Start a named process | | `kill(name)` | Kill by name | | `killAll()` | Kill all | | `restart(name, config?)` | Restart with optional new config | | `listProcesses()` | List running processes | | `getStatus(name)` | Get process status | | `writeStdin(name, data)` | Write raw string to stdin | | `detectRuntimes()` | Detect bun/node/deno availability | | `setRuntimePath(rt, path)` | Set custom executable path | | `getRuntimePaths()` | Get custom path overrides | | Event | Payload | |-------|---------| | `js-process-stdout` | `{ name: string, data: string }` | | `js-process-stderr` | `{ name: string, data: string }` | | `js-process-exit` | `{ name: string, code: number \| null }` | --- ## Approach B: Building from Scratch When you need full control or a different architecture (e.g., single shared process instead of named processes, Tauri event relay instead of direct stdio, or SvelteKit/other frameworks). ### Step 1: Rust — spawn and relay Add tokio to `src-tauri/Cargo.toml`: ```toml [dependencies] tokio = { version = "1", features = ["process", "io-util", "sync", "rt"] } ``` Core Rust pattern in `src-tauri/src/lib.rs`: ```rust use std::sync::Arc; use tauri::{async_runtime, AppHandle, Emitter, Listener, Manager}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::{Child, ChildStdin, Command}; use tokio::sync::Mutex; struct ProcessState { child: Child, stdin: ChildStdin, } struct AppState { process: Arc<Mutex<Option<ProcessState>>>, } fn spawn_runtime(app: &AppHandle) -> Result<ProcessState, String> { let mut cmd = Command::new("bun"); cmd.args(["src/backend/main.ts"]); cmd.stdin(std::process::Stdio::piped()); cmd.stdout(std::process::Stdio::piped()); cmd.stderr(std::process::Stdio::piped()); let mut child = cmd.spawn().map_err(|e| e.to_string())?; let stdin = child.stdin.take().ok_or("no stdin")?; let stdout = child.stdout.take().ok_or("no stdout")?; let stderr = child.stderr.take().ok_or("no stderr")?; // Relay stdout to all frontend windows via Tauri events let handle = app.clone(); async_runtime::spawn(async move { let reader = BufReader::new(stdout); let mut lines = reader.lines(); while let Ok(Some(line)) = lines.next_line().await { let _ = handle.emit("runtime-stdout", &line); } }); // Relay stderr let handle2 = app.clone(); async_runtime::spawn(async move { let reader = BufReader::new(stderr); let mut lines = reader.lines(); while let Ok(Some(line)) = lines.next_line().await { eprintln!("[runtime stderr] {}", line); let _ = handle2.emit("runtime-stderr", &line); } }); Ok(ProcessState { child, stdin }) } ``` Listen for frontend-to-runtime messages: ```rust // In .setup() closure: app.listen("frontend-to-runtime", move |event| { let payload = event.payload().to_string(); let state = state_clone.clone(); async_runtime::spawn(async move { let mut guard = state.lock().await; if let Some(ref mut proc) = *guard { let msg: String = serde_json::from_str(&payload).unwrap_or(payload); let mut to_write = msg; if !to_write.ends_with('\n') { to_write.push('\n'); } let _ = proc.stdin.write_all(to_write.as_bytes()).await; let _ = proc.stdin.flush().await; } }); }); ``` ### Step 2: Frontend IO adapter Bridge Tauri events to kkrpc's IoInterface: ```typescript import { emit, listen, type UnlistenFn } from "@tauri-apps/api/event"; export class TauriEventIo { name = "tauri-event-io"; isDestroyed = false; private listeners: Set<(msg: string) => void> = new Set(); private queue: string[] = []; private pendingReads: Array<(value: string | null) => void> = []; private unlisten: UnlistenFn | null = null; async initialize(): Promise<void> { this.unlisten = await listen<string>("runtime-stdout", (event) => { // CRITICAL: re-append \n that BufReader::lines() strips const message = event.payload + "\n"; for (const listener of this.listeners) listener(message); if (this.pendingReads.length > 0) { this.pendingReads.shift()!(message); } else { this.queue.push(message); } }); } async read(): Promise<string | null> { if (this.isDestroyed) return new Promise(() => {}); // hang, don't spin if (this.queue.length > 0) return this.queue.shift()!; return new Promise((resolve) => this.pendingReads.push(resolve)); } async write(message: string): Promise<void> { await emit("frontend-to-runtime", message); } on(event: "message" | "error", listener: (msg: string) => void) { if (event === "message") this.listeners.add(listener); } off(event: "message" | "error", listener: Function) { if (event === "message") this.listeners.delete(listener as any); } destroy() { this.isDestroyed = true; this.unlisten?.(); this.pendingReads.forEach((r) => r(null)); this.pendingReads = []; this.queue = []; this.listeners.clear(); } } ``` ### Step 3: Connect kkrpc ```typescript import { RPCChannel } from "kkrpc/browser"; import type { BackendAPI } from "../backend/types"; const io = new TauriEventIo(); await io.initialize(); const channel = new RPCChannel<{}, BackendAPI>(io, { expose: {} }); const api = channel.getAPI() as BackendAPI; // Type-safe calls const result = await api.add(5, 3); ``` ### Step 4: Clean shutdown ```rust // In .build().run() callback: .run(move |app_handle, event| { if let RunEvent::ExitRequested { .. } = &event { let state = app_handle.state::<AppState>(); let proc = state.process.clone(); async_runtime::block_on(async { let mut guard = proc.lock().await; if let Some(mut proc) = guard.take() { drop(proc.stdin); // drop stdin first let _ = proc.child.kill().await; let _ = proc.child.wait().await; } }); } }); ``` ### Step 5: Capabilities for multi-window ```json { "windows": ["main", "window-*"], "permissions": [ "core:default", "core:event:default", "core:webview:allow-create-webview-window" ] } ``` Note: `WebviewWindow` from `@tauri-apps/api/webviewWindow` requires `core:webview:allow-create-webview-window`, not `core:window:allow-create`. --- ## Critical Pitfalls ### 1. Newline framing Rust's `BufReader::lines()` strips trailing `\n`. kkrpc (and all newline-delimited JSON protocols) need `\n` to delimit messages. **The frontend IO adapter MUST re-append `\n`** to every payload received from Tauri events. ### 2. kkrpc read loop spin kkrpc's internal `listen()` loop continues on `null` reads — it only stops if the IO adapter has `isDestroyed === true`. If `read()` returns `null` without `isDestroyed` being set, the loop spins at 100% CPU. **Solution:** `read()` should return a never-resolving promise when destroyed, and expose `isDestroyed`. ### 3. Channel cleanup Call `channel.destroy()` (not just `io.destroy()`) to properly reject pending RPC promises. The channel's destroy will call io.destroy internally. ### 4. Mutex contention in Rust The Tauri event listener for stdin writes and the kill/restart commands both need the process mutex. **Take the process handle out of the lock scope before kill/wait.** Drop stdin first to unblock pending writes. ### 5. Tauri event serialization Tauri events serialize payloads as JSON strings. When the Rust event listener receives a message to forward to stdin, it may need to deserialize the outer JSON string wrapper: `serde_json::from_str::<String>(&payload)`. ### 6. Vite pre-bundle cache When using the plugin with `file:` dependency links, Vite caches the pre-bundled version. After rebuilding the plugin's guest-js, delete `node_modules/.vite` in the consuming app and run `pnpm install` to pick up new exports. ### 7. Deno imports Deno workers must use `npm:kkrpc/deno` for the import specifier and `.ts` file extensions for local imports (e.g., `./shared-api.ts`). ### 8. `deno compile` and `node_modules` `deno compile` will crash with a stack overflow if run from a directory that contains `node_modules` — Deno attempts to traverse and compile the entire directory tree. **Deno worker source must live in a separate directory** that is set up as its own Deno package with a `deno.json` declaring dependencies (e.g., kkrpc). Example setup: ``` examples/ deno-compile/ # Separate Deno package — no node_modules here deno.json # { "imports": { "kkrpc/deno": "npm:kkrpc/deno" } } main.ts # Deno worker source shared-api.ts # Type definitions (copy from backends/) tauri-app/ backends/ # Contains node_modules from npm deno-worker.ts # Used for dev mode (deno run), NOT for deno compile ``` Run `deno install` in the deno package directory to cache dependencies before compiling. The build script should compile from the separate directory: ```bash deno compile --allow-all --output src-tauri/binaries/deno-worker-$TARGET ../deno-compile/main.ts ``` ### 9. Dev vs Prod mode In dev mode, spawn runtimes directly (`bun script.ts`) or use compiled binaries via `config.sidecar` (see Step 7 above). In production, consider: - **Compiled sidecar (recommended):** `bun build --compile` / `deno compile` produces a standalone binary — use `config.sidecar` to spawn it, and Tauri's `externalBin` to bundle it. No runtime needed on user machines. - **Bundled JS scripts:** Worker scripts import `kkrpc` which needs `node_modules`. Bundle them first with `bun build --target bun/node` to inline dependencies, then add as Tauri resources via `bundle.resources`. Resolve at runtime with `resolveResource()` from `@tauri-apps/api/path` - The Rust code checks for sidecar first, then falls back to bundled JS with system runtime ## Production Deployment ### Option 1: Compiled sidecar (no runtime needed on user machine) ```bash TARGET=$(rustc -vV | grep host | cut -d' ' -f2) bun build --compile src/backend/main.ts --outfile src-tauri/binaries/backend-$TARGET ``` Add to `tauri.conf.json`: ```json { "bundle": { "externalBin": ["binaries/backend"] } } ``` Spawn with: ```typescript await spawn("backend", { sidecar: "backend" }); ``` ### Option 2: Bundled JS as resource (requires runtime on user machine) Bundle the worker to inline dependencies (kkrpc): ```bash bun build backends/worker.ts --target bun --outfile src-tauri/workers/worker.js ``` Add to `tauri.conf.json`: ```json { "bundle": { "resources": { "workers/worker.js": "workers/worker.js" } } } ``` Spawn with: ```typescript import { resolveResource } from "@tauri-apps/api/path"; const script = await resolveResource("workers/worker.js"); await spawn("worker", { runtime: "bun", script }); ``` ## References - [kkrpc](https://github.com/nicepkg/kkrpc) — cross-runtime RPC library - [Tauri v2 Plugin Guide](https://tauri.app/develop/plugins/) - [Tauri v2 Capabilities](https://tauri.app/security/capabilities/)

Preview in:

Security Status

Unvetted

Not yet security scanned

Time saved
How much time did this skill save you?

Related AI Tools

More Grow Business tools you might like

codex-collab

Free

Use when the user asks to invoke, delegate to, or collaborate with Codex on any task. Also use PROACTIVELY when an independent, non-Claude perspective from Codex would add value — second opinions on code, plans, architecture, or design decisions.

Rails Upgrade Analyzer

Free

Analyze Rails application upgrade path. Checks current version, finds latest release, fetches upgrade notes and diffs, then performs selective upgrade preserving local customizations.

Asta MCP — Academic Paper Search

Free

Domain expertise for Ai2 Asta MCP tools (Semantic Scholar corpus). Intent-to-tool routing, safe defaults, workflow patterns, and pitfall warnings for academic paper search, citation traversal, and author discovery.

Hand Drawn Diagrams

Free

Create hand-drawn Excalidraw diagrams, flows, explainers, wireframes, and page mockups. Default to monochrome sketch output; allow restrained color only for page mockups when the user explicitly wants webpage-like fidelity.

Move Code Quality Checker

Free

Analyzes Move language packages against the official Move Book Code Quality Checklist. Use this skill when reviewing Move code, checking Move 2024 Edition compliance, or analyzing Move packages for best practices. Activates automatically when working

Claude Memory Kit

Free

"Persistent memory system for Claude Code. Your agent remembers everything across sessions and projects. Two-layer architecture: hot cache (MEMORY.md) + knowledge wiki. Safety hooks prevent context loss. /close-day captures your day in one command. Z