Read OSS

The IPC Bridge: How JavaScript invoke() Reaches Rust Commands

Advanced

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:

  1. Runs any SERIALIZE_TO_IPC_FN transformations on argument values
  2. Generates callback and error IDs via transformCallback()
  3. Sends an HTTP POST to the ipc://localhost custom protocol with headers carrying the callback ID, error ID, and the invoke key
  4. Returns a Promise that 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:

  1. 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)
  2. Looks up the webview by label
  3. Passes the parsed InvokeRequest to webview.on_message()
  4. Receives a response through a callback closure that maps it to an HTTP response with the Tauri-Response header 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:

  1. Extracts each parameter using the CommandArg trait
  2. Calls the original function
  3. 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_FN symbol (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.