tauri-plugin-js 0.1.0

Bring Node/Bun/Deno to Tauri apps with full Type Safety (kkrpc)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
---
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 = { path = "../path/to/tauri-plugin-js" }
```

**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/