The IPC Bridge: How JavaScript invoke() Reaches Rust Commands
Prerequisites
- ›Article 1: Architecture and Crate Map
- ›Article 2: App Lifecycle and Builder Pattern
- ›Understanding of HTTP custom protocols and URI schemes
- ›Rust procedural macros concepts
- ›TypeScript/JavaScript fundamentals
The IPC Bridge: How JavaScript invoke() Reaches Rust Commands
The heart of every Tauri application is the bridge between JavaScript and Rust. When your frontend calls invoke("greet", { name: "World" }), the message travels through a custom URI protocol, passes security verification, gets routed to the correct command handler, has its arguments deserialized, and sends a response back. This article traces every hop.
The JS Side: invoke() and TAURI_INTERNALS
The developer-facing API lives in packages/api/src/core.ts. The invoke() function is remarkably thin:
async function invoke<T>(
cmd: string,
args: InvokeArgs = {},
options?: InvokeOptions
): Promise<T> {
return window.__TAURI_INTERNALS__.invoke(cmd, args, options)
}
All the real work happens in window.__TAURI_INTERNALS__, which is set up by the InvokeInitializationScript — a JavaScript template rendered at compile time with the invoke key, OS name, and IPC processing function baked in.
The initialization script (templated from scripts/ipc-protocol.js) creates __TAURI_INTERNALS__.invoke which:
- Runs any
SERIALIZE_TO_IPC_FNtransformations on argument values - Generates callback and error IDs via
transformCallback() - Sends an HTTP POST to the
ipc://localhostcustom protocol with headers carrying the callback ID, error ID, and the invoke key - Returns a
Promisethat resolves when the callback fires
sequenceDiagram
participant App as Frontend Code
participant TI as __TAURI_INTERNALS__
participant IPC as ipc:// Protocol Handler
participant WV as Webview::on_message()
App->>TI: invoke("greet", {name: "World"})
TI->>TI: serialize args, create callback
TI->>IPC: POST ipc://localhost/greet<br/>Headers: Tauri-Callback, Tauri-Error, Tauri-Invoke-Key
IPC->>IPC: parse_invoke_request()
IPC->>WV: on_message(InvokeRequest)
WV->>WV: verify invoke key
WV->>WV: ACL check
WV->>WV: dispatch to command handler
WV-->>IPC: InvokeResponse
IPC-->>TI: HTTP Response with Tauri-Response header
TI-->>App: Promise resolves/rejects
The Transport Layer: Custom Protocol and Headers
On the Rust side, the IPC protocol handler is registered in crates/tauri/src/ipc/protocol.rs. Three custom headers carry the IPC metadata:
const TAURI_CALLBACK_HEADER_NAME: &str = "Tauri-Callback";
const TAURI_ERROR_HEADER_NAME: &str = "Tauri-Error";
const TAURI_INVOKE_KEY_HEADER_NAME: &str = "Tauri-Invoke-Key";
The handler function get() registers as a URI scheme protocol. When a POST request arrives, it:
- Calls
parse_invoke_request()to extract the command name from the URL path, headers from the HTTP request, and the body (JSON or raw bytes) - Looks up the webview by label
- Passes the parsed
InvokeRequesttowebview.on_message() - Receives a response through a callback closure that maps it to an HTTP response with the
Tauri-Responseheader set to either"ok"or"error"
CORS headers (Access-Control-Allow-Origin: *) are added to every response, and OPTIONS preflight requests return just the CORS headers. This is necessary because the webview's origin differs from the ipc:// scheme.
flowchart TD
REQ["HTTP POST to ipc://localhost/{cmd}"] --> PARSE["parse_invoke_request()"]
PARSE --> LOOKUP["Find webview by label"]
LOOKUP -->|"Found"| ONMSG["webview.on_message(request)"]
LOOKUP -->|"Not found"| ERR404["404 Response"]
ONMSG --> RESPOND["Invoke responder callback"]
RESPOND --> OK["Tauri-Response: ok<br/>200 + body"]
RESPOND --> FAIL["Tauri-Response: error<br/>400 + error body"]
Invoke Key Verification and ACL Check
The on_message() method on Webview implements a two-phase security gate.
Phase 1: Invoke Key Verification (lines 1723–1738). Every IPC request carries the invoke key that was injected into the webview at initialization. The handler compares it against the key stored in the AppManager. If they don't match, the request is silently dropped. This prevents malicious scripts from crafting IPC calls — only code running in a properly initialized Tauri webview has the key.
Phase 2: ACL Resolution (lines 1771–1826). The RuntimeAuthority resolves whether the command is allowed for this specific webview:
let (resolved_acl, has_app_acl_manifest) = {
let runtime_authority = manager.runtime_authority.lock().unwrap();
let acl = runtime_authority.resolve_access(
&request.cmd,
message.webview.window_ref().label(),
message.webview.label(),
&acl_origin,
);
(acl, runtime_authority.has_app_manifest())
};
The resolve_access method performs a BTree lookup on the command name, then checks whether the requesting webview's label and window's label match any of the patterns in the resolved permissions. If the command is gated by the ACL and no permission matches, the request is rejected with a descriptive error in debug builds or a generic "not allowed" message in release builds.
Tip: The ACL check is skipped for commands that don't have any permissions defined, unless the app has defined its own ACL manifest. This means simple apps with no capability files work out of the box — the security system is additive, not blocking by default for app-defined commands.
Command Routing: Core vs Plugin Commands
After security checks pass, the command is routed based on its name. Plugin commands follow a naming convention:
plugin:{plugin_name}|{command_name}
The parsing at lines 1788–1794 splits this:
let plugin_command = request.cmd.strip_prefix("plugin:").map(|raw_command| {
let mut tokens = raw_command.split('|');
let plugin = tokens.next().unwrap();
let command = tokens.next().map(|c| c.to_string()).unwrap_or_default();
(plugin, command)
});
flowchart TD
CMD["Incoming Command"] --> CHECK{"Starts with 'plugin:'?"}
CHECK -->|"Yes"| SPLIT["Split on '|'"]
SPLIT --> PLUGIN["plugin_name = 'fs'<br/>command = 'read_file'"]
PLUGIN --> DISPATCH_P["plugins.extend_api(invoke)"]
CHECK -->|"No"| CORE["Regular command"]
CORE --> DISPATCH_C["invoke_handler(invoke)"]
For plugin commands, the invoke is dispatched to the plugin's extend_api() method. For regular commands, it goes to the invoke_handler — the function generated by generate_handler!.
The #[command] Macro and CommandArg Trait
The #[command] proc macro transforms a regular Rust function into an IPC-compatible command. For a function like:
#[tauri::command]
fn greet(name: String, state: State<'_, AppState>) -> String {
format!("Hello, {}!", name)
}
The macro generates a wrapper function that:
- Extracts each parameter using the
CommandArgtrait - Calls the original function
- Wraps the result in an
InvokeResponse
The CommandArg trait is the key abstraction:
pub trait CommandArg<'de, R: Runtime>: Sized {
fn from_command(command: CommandItem<'de, R>) -> Result<Self, InvokeError>;
}
There's a blanket implementation for any type that implements Deserialize (line 62), which deserializes the value from the JSON body. But special types like State<T>, Window, Webview, and AppHandle have custom implementations that extract the value from the invocation context without touching the JSON payload at all. This is why you can mix JSON-deserialized arguments with framework types in command signatures.
The generate_handler! macro takes a list of command functions and produces a closure that matches on the command name and calls the appropriate wrapper.
The Channel API: Streaming Data to the Frontend
Not all IPC is request-response. The Channel API enables streaming data from Rust to JavaScript — useful for progress events, file system watchers, or any long-lived data flow.
On the Rust side, Channel<TSend> wraps an Arc<ChannelInner> with an on_message callback. The key optimization is threshold-based transport (lines 35–39):
const MAX_JSON_DIRECT_EXECUTE_THRESHOLD: usize = 8192;
const MAX_RAW_DIRECT_EXECUTE_THRESHOLD: usize = 1024;
Small payloads (under 8KB for JSON, 1KB for raw) are sent by evaluating JavaScript directly in the webview — essentially calling window.__TAURI_INTERNALS__.callback(id, data). Larger payloads are stored in a ChannelDataIpcQueue and the JS side fetches them via a special plugin:__TAURI_CHANNEL__|fetch command. This avoids the overhead of base64-encoding large binary data into eval strings.
On the JavaScript side, the Channel class maintains message ordering through an index-based system:
sequenceDiagram
participant Rust as Rust Channel
participant WV as WebView
participant JS as JS Channel
Rust->>WV: send(index=0, message=A)
Rust->>WV: send(index=1, message=B)
Note over WV: index=1 arrives first (out of order)
WV->>JS: callback({index: 1, message: B})
JS->>JS: Queue B at index 1
WV->>JS: callback({index: 0, message: A})
JS->>JS: Process A (index matches)
JS->>JS: Drain queue: process B
Rust->>WV: send({end: true, index: 2})
JS->>JS: All messages received, cleanup
Each message carries an index. The JS Channel tracks #nextMessageIndex and only processes messages in order. Out-of-order messages are buffered in #pendingMessages until the expected index arrives. When the Rust side drops the channel, it sends an {end: true, index: N} signal so the JS side knows to clean up.
Tip: The
SERIALIZE_TO_IPC_FNsymbol (exported as'__TAURI_TO_IPC_KEY__') lets you define custom serialization for complex TypeScript types. If your class implements[SERIALIZE_TO_IPC_FN](), that method is called before the value is sent over IPC — perfect for Rust enum representations that don't map naturally to JS objects.
In the next article, we'll zoom in on the security model that gates all this IPC traffic — the capability-based ACL system, scope constraints, and the multiple layers of defense against web-based attacks.