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”.
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.
Is it necessary to embed a Chromium?
No. Just use system WebView/WebKit.
Is a Node runtime necessary?
Yes, but it can also be Bun or Deno, which are Node-API compatible
Is it necessary to embed JS Runtime?
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
Use Tauri with a JS runtime
JS runtime can be Node, Bun or Deno
If user has a JS runtime installed, then we don’t need to embed a JS runtime
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)
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.
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
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.
Let’s leave the Rust process aside and focus on the node/bun/deno process.
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.
No TypeScript type safety/autocomplete
Limited data types (only supports limited standard JSON data types)
No callback function support
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.
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 supportedconst channel = new RPCChannel<DenoProcessAPI, WebViewProcessAPI>(stdio, { expose: { initSqlite } })const rendererAPI = channel.getAPI()rendererAPI.sendNotification("Hello, world!")
frontend
/* -------------------------------------------------------------------------- *//* WebView Process *//* -------------------------------------------------------------------------- */// Spawn the sidecar processimport { RPCChannel, TauriShellStdio } from "kkrpc/browser"import { sendNotification } from '@tauri-apps/plugin-notification'const cmd = Command.sidecar("binaries/deno-sidecar")// or run deno binary directlyconst cmd = Command.create("deno", ["main.ts"])const process = await cmd.spawn()// Establish the bidirectional kkrpc channelconst stdio = new TauriShellStdio(cmd.stdout, process)const channel = new RPCChannel<WebViewProcessAPI, DenoProcessAPI>(stdio, { expose: { sendNotification } })const api = channel.getAPI()// Call the APIconsole.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.
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.