Contents

Native Rust Module

Write the native module in Rust instead of C++.

Overview 

The native module can be written in Rust. It serves the same purposes as a native C++ module and uses the same Protobuf services, the same generated TypeScript code, and the same native.registerService() API in the main process. Only the native side differs.

A project has one native module, in C++ or in Rust. Cargo.toml in the project root marks a Rust module, CMakeLists.txt a C++ one.

Prerequisites 

To compile the native Rust module, you need Rust and the linker of your platform.

Windows
macOS
Linux
  • Windows 10 (64-bit) or later.
  • Install Microsoft C++ Build Tools for the linker. Make sure you select the Desktop development with C++ workload during installation.
  • Install rustup.
  • macOS 14 (Apple Silicon) or later.
  • Install Command Line Tools for the linker using xcode-select --install.
  • Install rustup.
  • Ubuntu 22.04 (64-bit) or later.
  • Install GCC for the linker: sudo apt install build-essential
  • Install rustup.

You don’t need to install a particular Rust version. The project pins it in rust-toolchain.toml, and rustup installs that version on the first build. The build also installs the Rust target of the platform it builds for:

PlatformRust target
macOS, Apple Siliconaarch64-apple-darwin
macOS, Intelx86_64-apple-darwin
Windowsx86_64-pc-windows-msvc
Linuxx86_64-unknown-linux-gnu

You don’t need to install protoc: the build uses the one that comes with MōBrowser.

Creating a project with a native module 

Run the scaffolding tool:

npm create mobrowser-app@latest

When asked “Add a native module?”, select “Rust”. Or pass the option on the command line:

npm create mobrowser-app@latest -- --name App --framework React --library Shadcn --native rust

The project comes with a sample GreetService.

Adding a native module to an existing project 

To add a Rust module to a project that doesn’t have a native module, run:

npm run add -- native --lang rust

The command creates the files of the module, the src/native/proto/ directory for your Protobuf definitions, and adds the @mobrowser/native dependency. The module is created without any services.

If the project already has a Cargo.toml, the command stops without changing anything.

Project structure 

A project with a native Rust module includes additional files and directories:

.cargo/
assets/
resources/
src/
├── main/
├── native/
    ├── lib.rs
    ├── proto/
        ├── greet.proto
├── renderer/
build.rs
Cargo.lock
Cargo.toml
rust-toolchain.toml
mobrowser.conf.json
package.json
tsconfig.json
vite.config.ts
  • Cargo.toml describes the module: a cdylib named mobrowser_client and its dependencies.
  • Cargo.lock pins the versions of all crates the module uses. Commit it to version control.
  • build.rs generates Rust code from the .proto files on every build.
  • rust-toolchain.toml pins the Rust version.
  • .cargo/config.toml puts the Cargo output in the build/ directory.
  • src/native/lib.rs is the code of the module.

The mobrowser and mobrowser-build crates come with the @mobrowser/native package. Cargo.toml references them from node_modules, so they always match the MōBrowser version of the project.

Calling Rust from TypeScript 

Define a service in src/native/proto/greet.proto:

syntax = "proto3";

import "google/protobuf/wrappers.proto";

message Person {
  string name = 1;
}

service GreetService {
  rpc SayHello (Person) returns (google.protobuf.StringValue);
}

For every service, build.rs generates:

  • A trait named after the service, with an async method per RPC.
  • A <Service>Server struct that registers an implementation of the trait.
  • A <name>_client module with functions that call a main-process implementation of the service.

In src/native/lib.rs, implement the trait and register the implementation in the launch() function:

pub mod proto {
    mobrowser::include_proto!();
}

use proto::{GreetService, GreetServiceServer, Person};

struct Greeter;

impl GreetService for Greeter {
    async fn say_hello(&self, person: Person) -> Result<String, String> {
        Ok(format!("Hello, {}!", person.name))
    }
}

mobrowser::main!(launch);

fn launch() {
    mobrowser::register_service(GreetServiceServer::new(Greeter));
}

mobrowser::main! makes launch() the entry point of the module. MōBrowser calls it once on startup, and the application waits for it, so keep it short.

The messages and services of a .proto file with a package are in a module named after the package, for example proto::demo::Person.

In src/main/index.ts, call the Rust service as you would a C++ one:

import { native } from './gen/native';

const response = await native.greet.SayHello({ name: 'John Doe' })
console.log(response.value) // Hello, John Doe!

Note: Streaming RPCs are not supported.

Returning results 

Return Ok(response) to resolve the promise in the main process, or Err(message) to reject it:

async fn say_hello(&self, person: Person) -> Result<String, String> {
    if person.name.is_empty() {
        return Err("The name is empty".into());
    }
    Ok(format!("Hello, {}!", person.name))
}
try {
  await native.greet.SayHello({ name: '' })
} catch (error) {
  console.error(error.message) // The name is empty
}

If the method panics, the promise is rejected with panic: <message>. The application keeps running.

Keeping state 

MōBrowser can run several calls at the same time, on different threads, and they all use the same instance of your service. That’s why the service must be Send + Sync, which means it’s safe to use from several threads at once. The compiler checks it for you.

Plain data that the methods only read is fine as is. To store data that the methods change, wrap it in a Mutex or use atomics:

use std::sync::Mutex;

struct Greeter {
    names: Mutex<Vec<String>>,
}

impl GreetService for Greeter {
    async fn say_hello(&self, person: Person) -> Result<String, String> {
        let mut names = self.names.lock().unwrap();
        names.push(person.name.clone());
        Ok(format!("Hello, {}! You are visitor #{}.", person.name, names.len()))
    }
}

fn launch() {
    mobrowser::register_service(GreetServiceServer::new(Greeter {
        names: Mutex::new(Vec::new()),
    }));
}

Release the lock before any .await, or the code won’t compile.

Blocking work 

The methods run on a small pool of Tokio threads. Reading files, waiting on locks, or long computations block those threads and delay other calls. Run such work with spawn_blocking, which moves it to a separate pool:

async fn say_hello(&self, person: Person) -> Result<String, String> {
    let greeting = mobrowser::tokio::task::spawn_blocking(move || {
        // Stands for slow work, such as reading a large file.
        std::thread::sleep(std::time::Duration::from_secs(2));
        format!("Hello, {}!", person.name)
    })
    .await
    .map_err(|error| error.to_string())?;
    Ok(greeting)
}

Timers and other Tokio features 

mobrowser::tokio includes only the Tokio runtime. To use timers, files, channels, or other Tokio features, add tokio with those features to Cargo.toml:

[dependencies]
tokio = { version = "1", features = ["time"] }
async fn say_hello(&self, person: Person) -> Result<String, String> {
    tokio::time::sleep(std::time::Duration::from_millis(500)).await;
    Ok(format!("Hello, {}!", person.name))
}

Using your own runtime 

By default, MōBrowser creates a Tokio runtime for the methods. To use your own, for example to limit the number of threads, pass its handle to mobrowser::runtime::set_handle() in launch(), before registering the services:

[dependencies]
tokio = { version = "1", features = ["rt-multi-thread"] }
fn launch() {
    let runtime = tokio::runtime::Builder::new_multi_thread()
        .worker_threads(2)
        .enable_all()
        .build()
        .expect("failed to start the Tokio runtime");
    mobrowser::runtime::set_handle(runtime.handle().clone())
        .expect("the runtime is already set");
    // Keep the runtime until the application exits.
    std::mem::forget(runtime);

    mobrowser::register_service(GreetServiceServer::new(Greeter));
}

Calling TypeScript from Rust 

In src/main/index.ts, implement the service and register it using the native.registerService() function:

import { native } from '@mobrowser/api';
import { GreetServiceDescriptor } from './gen/native_service';
import { Person } from './gen/native/greet';

native.registerService(GreetServiceDescriptor, {
  async SayHello(person: Person) {
    return { value: `Hello, ${person.name}!` };
  },
})

In Rust, call it through the generated client module:

let greeting = proto::greet_client::say_hello(&Person { name: "John Doe".into() }).await?;

Building 

npm run dev and npm run build build the module with Cargo and bundle it with the application. The Rust code is generated from the .proto files on every build; npm run gen generates only the TypeScript code.

Add crates to Cargo.toml as in any Rust project. npm run dev updates Cargo.lock; npm run build uses exactly the versions in it and fails if it is missing or out of date, so commit it.

npm run sbom lists the crates linked into the module, not build-only ones such as procedural macros.