GitHub: https://github.com/kunkunsh/kkrpc/tree/main/examples/tauri-demo

Excalidraw Diagram

Tauri and Electron IPC This is how IPC work in Tauri and Electron. They look similar, but the DX is completely different. The main difference is the language of the backend. Tauri uses Rust, and Electron uses Node.js. Rust yields minimum binary size, while smaller ecosystem and much more complex syntax. Node yields great JS ecosystem, but larger binary size.

The DX of Electron is undoubtedly much better. For example, you can use ORM like Drizzle or Prisma in Electron. While in Tauri you usually need to work with RAW SQL query. Rust also has Diesel, but the experience of TypeScript ORM is still much better.

The binary size of an Electron app usually starts at 240MB (with Electron vite). The size are mostly from a embedded Chromium browser and Node runtime. Tauri uses system webview and Rust to avoid bundling too much stuff into its binary, and can result in a binary of as small as <1MB.

So, what if we can combine the benefits of both side, and still keep the binary size as small as possible? Is this even possible?

Analysis

Let’s start with the “First Principle”.

  1. What is our goal?
    • Use JS runtime (Node.js)
    • Build a bidirectional RPC for IPC between frontend and backend processes
      • Bidirectional means, frontend can call functions in backend; and backend can call functions in frontend.
  2. Is it necessary to embed a Chromium?
    • No. Just use system WebView/WebKit.
  3. Is a Node runtime necessary?
    1. Yes, but it can also be Bun or Deno, which are Node-API compatible
  4. Is it necessary to embed JS Runtime?
    1. No. 2 options: embed a JS runtime as sidecar; or use an external JS runtime (node, bun, deno)

Combining all of the above, the conclusion we can draw is

  1. Use Tauri with a JS runtime
  2. JS runtime can be Node, Bun or Deno
  3. If user has a JS runtime installed, then we don’t need to embed a JS runtime
  4. If embedding a JS runtime is necessary, we can keep the sidecar as small as possible (e.g. with bun build --compile or deno compile)
    1. The result of a compiled bun/deno binary contains all the dependencies you need, starts at ~60MB, and contribute ~20MB to the Tauri app installer size.
  • If the 20MB+ increase in installer size tradeoff is acceptable, then let’s keep going.
  • Or if you can make sure your user’s computer has bun/node/deno installed already, then this will be a zero cost JS runtime for you.

IPC

The frontend and backend needs an IPC protocol to communicate. Let’s look at how easy it is in Electron first.

// Preload (Isolated World)
const { contextBridge, ipcRenderer } = require('electron')
 
contextBridge.exposeInMainWorld(
  'electron',
  {
    doThing: () => ipcRenderer.send('do-a-thing')
  }
)
 
// Renderer (Main World)
window.electron.doThing()

This is RPC. It is possible to call frontend code from the main process, but through events, not as easy as this. So Electron also doesn’t provide a perfect bidirectional RPC. (It’s also possible with kkrpc)

So how to do this in Tauri? Tauri has commands, which allow JS to call rust functions. Now we want to call JS functions in backend.

HTTP IPC

HTTP IPC

The most naive/simplest option is HTTP. You can use REST (OpenAPI), tRPC, GraphQL, gRPC to communicate between frontend and backend. Among these popular protocols, only Web Socket and gRPC are bidirectional. What does this mean? HTTP is by its nature unidirectional, i.e. client (frontend) can actively call REST API endpoint, but the server (bun/deno/node process) cannot actively invoke frontend functions. In order to build a bidirectional RPC, we have to use Web Socket or gRPC bidirectional streaming. On top of Web Socket, we can build a RPC layer like JSON-RPC (kkrpc supports this).

stdio RPC

Web Socket definitely a viable solution. What we can conclude from the discussion above is that, the communication channel must be bidirectional to build a bidirectional RPC. Web Socket works, the only cost is to run a web socket server, which is only a port on client’s computer. Sounds perfect. Is there an even cheaper solution? Yes, stdio is bidirectional, and we already have it when frontend spawns the process.

stdio

Let’s leave the Rust process aside and focus on the node/bun/deno process.

stdin+stdout

Working with stdin and stdout is very difficult, so we need a layer of abstraction like JSON-RPC. I have some concerns with JSON-RPC.

  1. No TypeScript type safety/autocomplete
  2. Limited data types (only supports limited standard JSON data types)
  3. No callback function support
  4. No nested object support I solved all these limitations in kkrpc.
  • Since we assume both sides are TypeScript, I used JS proxy (inspired by comlink) to achieve type safety, autocomplete and nested object function calls.
  • Since both sides are JS/TS, we can use superjson to support more data types.
  • Support callback functions.

kkrpc sets up a bidirectional RPC channel on top of stdio. stdio+kkrpc

Sample Code

For each type of JS environment, I provided an IO adapter. For example DenoIo and TauriShellStdio. They are injected into RPCChannel to establish the bidirectional communication. Each side can expose pretty much any functions/objects for the other side to call. For example, the deno process can expose sqlite functions to frontend, and frontend can expose UI related functions for the deno process to invoke.

/* -------------------------------------------------------------------------- */
/*                                Deno Process                                */
/* -------------------------------------------------------------------------- */
import { DenoIo, RPCChannel } from "kkrpc"
import { initSqlite } from "./api.ts"
 
const stdio = new DenoIo(Deno.stdin.readable)
// expose an object containing functions to the other side
// nested objects are supported
const channel = new RPCChannel<DenoProcessAPI, WebViewProcessAPI>(stdio, { expose: { initSqlite } })
const rendererAPI = channel.getAPI()
rendererAPI.sendNotification("Hello, world!")
frontend
/* -------------------------------------------------------------------------- */
/*                               WebView Process                              */
/* -------------------------------------------------------------------------- */
// Spawn the sidecar process
import { RPCChannel, TauriShellStdio } from "kkrpc/browser"
import { sendNotification } from '@tauri-apps/plugin-notification'
const cmd = Command.sidecar("binaries/deno-sidecar")
// or run deno binary directly
const cmd = Command.create("deno", ["main.ts"])
 
const process = await cmd.spawn()
 
// Establish the bidirectional kkrpc channel
const stdio = new TauriShellStdio(cmd.stdout, process)
const channel = new RPCChannel<WebViewProcessAPI, DenoProcessAPI>(stdio, { expose: { sendNotification } })
const api = channel.getAPI()
 
// Call the API
console.log(await api.initSqlite())

TIP

You can expose a JS object of arbitrary depth, and call it like usual.

const api = channel.getAPI()
api.db.sqlite.migrateDB(src, dest)

So, you can write multiple types of APIs in classes, and expose them at once.

const channel = new RPCChannel(stdio, {
	expose: {
		db: {
			sqlite: new SQLite(),
			mysql: new MySQL(),
		},
		fsScanner: new FsScanner(),
	}
})

The WebViewProcessAPI and DenoProcessAPI are generics for type checking and autocomplete. You can auto generate them with the typeof keyword. Here is the type declaration for RPCChannel.

declare class RPCChannel<LocalAPI extends Record<string, any>, RemoteAPI extends Record<string, any>, Io extends IoInterface = IoInterface> {

The first generic type (LocalAPI) is what you want to expose from the current process, and the second generic type (RemoteAPI) is what you expect to be expose to you from the remote side of the channel. The api from channel.getAPI() will have the same type as RemoteAPI.