Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

RPC System

Ultimo's RPC system provides type-safe remote procedure calls with automatic TypeScript generation.

Overview

The RPC system lets you define procedures in Rust that automatically generate type-safe TypeScript clients. Choose between REST mode (individual endpoints) or JSON-RPC mode (single endpoint).

query/mutation require the client-gen feature. Every example on this page uses them (the versions that derive TypeScript types from your Rust types via #[derive(TS)]) — add features = ["client-gen"] to your ultimo dependency, or .query()/.mutation() won't resolve. The query_with_types/mutation_with_types escape hatch (manual TS strings) doesn't need it.

REST Mode

In REST mode, each RPC procedure gets its own generated TypeScript client method — but unlike JSON-RPC mode, there's no mount_rpc-style one-liner to mount it: RpcMode::Rest only affects the generated client's shape (GET for queries, POST for mutations) and generate_openapi's output. You mount each procedure as its own route yourself, dispatching through RpcRegistry::call:

// Cargo.toml: ultimo = { version = "...", features = ["client-gen"] }
use ultimo::prelude::*;
use ultimo::rpc::{RpcMode, RpcRegistry, TS};
use serde::{Deserialize, Serialize};
 
#[derive(Debug, Deserialize, Serialize, TS)]
struct GetUserInput {
    id: u32,
}
 
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
struct User {
    id: u32,
    name: String,
    email: String,
}
 
#[derive(Debug, Deserialize, Serialize, TS)]
struct CreateUserInput {
    name: String,
    email: String,
}
 
#[tokio::main]
async fn main() -> Result<()> {
    let mut app = Ultimo::new();
 
    // Create RPC registry in REST mode. TS types are still derived
    // automatically — REST mode only changes how the client dispatches.
    let rpc = RpcRegistry::new_with_mode(RpcMode::Rest);
 
    // Register a query (uses GET in the generated client)
    rpc.query("getUser", |input: GetUserInput| async move {
        Ok(User {
            id: input.id,
            name: "Alice".to_string(),
            email: "alice@example.com".to_string(),
        })
    });
 
    // Register a mutation (uses POST in the generated client)
    rpc.mutation("createUser", |input: CreateUserInput| async move {
        Ok(User {
            id: 2,
            name: input.name,
            email: input.email,
        })
    });
 
    // Generate TypeScript client
    rpc.generate_client_file("../frontend/src/lib/client.ts")?;
 
    // Mount each procedure as its own route — REST mode has no single-call
    // mount helper (that's JSON-RPC mode's `app.mount_rpc`).
    let get_user_rpc = rpc.clone();
    app.get("/api/getUser", move |ctx: Context| {
        let rpc = get_user_rpc.clone();
        async move {
            let id: u32 = ctx
                .req
                .query("id")
                .and_then(|v| v.parse().ok())
                .ok_or_else(|| UltimoError::BadRequest("missing or invalid 'id'".into()))?;
            let result = rpc.call("getUser", serde_json::json!({ "id": id })).await?;
            ctx.json(result).await
        }
    });
 
    let create_user_rpc = rpc.clone();
    app.post("/api/createUser", move |ctx: Context| {
        let rpc = create_user_rpc.clone();
        async move {
            let input: CreateUserInput = ctx.req.json().await?;
            let result = rpc.call("createUser", serde_json::to_value(input)?).await?;
            ctx.json(result).await
        }
    });
 
    app.listen("127.0.0.1:3000").await
}
Manually mounted endpoints:
  • GET /api/getUser?id=1
  • POST /api/createUser

Calling a procedure directly

RpcRegistry::call(name, params) -> Result<serde_json::Value> dispatches one registered procedure by name, decoupled from any particular HTTP shape — that's what the manual routes above (and JSON-RPC mode's internal dispatch) are built on. Reach for it directly whenever you need to invoke a procedure from your own route or middleware, outside the request shapes mount_rpc or the REST routes above expect.

JSON-RPC Mode

All procedures use a single endpoint, mounted in one call with mount_rpc:

// Cargo.toml: ultimo = { version = "...", features = ["client-gen"] }
use ultimo::prelude::*;
use ultimo::rpc::{RpcRegistry, TS};
use serde::{Deserialize, Serialize};
 
#[derive(Debug, Deserialize, Serialize, TS)]
struct GetUserInput {
    id: u32,
}
 
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
struct User {
    id: u32,
    name: String,
    email: String,
}
 
#[tokio::main]
async fn main() -> Result<()> {
    let mut app = Ultimo::new();
 
    // Create RPC registry (JSON-RPC is default)
    let rpc = RpcRegistry::new();
 
    // Register procedures — types are derived from GetUserInput/User via TS
    rpc.query("getUser", |input: GetUserInput| async move {
        Ok(User {
            id: input.id,
            name: "Alice".to_string(),
            email: "alice@example.com".to_string(),
        })
    });
 
    // Generate TypeScript client
    rpc.generate_client_file("../frontend/src/lib/client.ts")?;
 
    // Single RPC endpoint — dispatches every procedure by name, and handles
    // single calls, batches, and notifications per the JSON-RPC 2.0 spec.
    app.mount_rpc("/rpc", rpc);
 
    app.listen("127.0.0.1:3000").await
}
All requests go to:
  • POST /rpc

Request body:

{
  "method": "getUser",
  "params": { "id": 1 }
}

TypeScript Client

The generated TypeScript client is the same for both modes:

import { UltimoRpcClient } from "./lib/client";
 
const client = new UltimoRpcClient();
 
// Fully type-safe calls
const user = await client.getUser({ id: 1 });
console.log(user.name); // ✅ TypeScript knows the shape!
 
const newUser = await client.createUser({
  name: "Bob",
  email: "bob@example.com",
});

When to Use Each Mode

REST Mode

Use when:
  • Building public APIs
  • HTTP caching is important
  • Want clear URLs in browser DevTools
  • Need standard HTTP semantics
Benefits:
  • ✅ RESTful URLs (/api/getUser)
  • ✅ HTTP caching works out of the box
  • ✅ Clear in network inspector
  • ✅ Works with standard HTTP tools

JSON-RPC Mode

Use when:
  • Building internal APIs
  • Want simple routing
  • Need request batching
  • Prefer RPC semantics
Benefits:
  • ✅ Single endpoint (/rpc)
  • ✅ Simple routing logic
  • ✅ Easy to batch requests
  • ✅ Clear RPC semantics

Client Generation Timing

The generate_client_file() call happens once at application startup - it does not interrupt your running server or get called on every request. When your Rust app starts, it generates the TypeScript file, then proceeds to start the server normally.

Development vs Production

For optimal workflows, generate the client only in development builds:

#[tokio::main]
async fn main() -> Result<()> {
    let mut app = Ultimo::new();
    let rpc = RpcRegistry::new();
 
    // Register your RPC methods...
    rpc.query("getUser", |input: GetUserInput| async move { /* ... */ });
 
    // Generate TypeScript client (development only)
    #[cfg(debug_assertions)]
    {
        rpc.generate_client_file("../frontend/src/lib/client.ts")?;
        println!("✅ TypeScript client generated");
    }
 
    app.mount_rpc("/rpc", rpc);
 
    app.listen("127.0.0.1:3000").await
}

This approach:

  • Development: Auto-generates client on every restart (when you add/modify RPC methods)
  • Production: Skips generation for faster startup
  • No interruption: Happens before the server starts listening, doesn't affect request handling

Where the generated file lands

generate_client_file(path) writes to exactly the path you give it, resolved relative to your program's current working directory at runtime (typically your crate root, since that's where cargo run starts it) — the same as any std::fs::write call. From a backend/ crate next to a frontend/ crate, rpc.generate_client_file("../frontend/src/lib/client.ts") lands at <workspace>/frontend/src/lib/client.ts, exactly as it reads.

This is not the same as ts-rs's own #[ts(export, export_to = "...")] attribute, which TS (re-exported as ultimo::rpc::TS under client-gen) still carries if you reach for it directly — for example to export a standalone type that isn't part of any RPC procedure, like an SSE payload (see Type Definitions below for the RPC-procedure case, which never needs #[ts(export)] at all). export_to on that attribute resolves relative to <CARGO_MANIFEST_DIR>/bindings/, not the crate root — a different, hidden base directory that generate_client_file doesn't have. The naive guess, #[ts(export, export_to = "../frontend/src/lib/bindings.ts")] from a backend/ crate, expecting <workspace>/frontend/..., actually lands at backend/bindings/../frontend/... = backend/frontend/... — a stray directory nested inside backend/, easy to not notice since cargo test (which triggers the export) succeeds either way. To actually reach <workspace>/frontend/... from a backend/ crate one level under the workspace root, you need one extra .. to first escape the implicit bindings/ directory: #[ts(export, export_to = "../../frontend/src/lib/bindings.ts")]. Prefer generate_client_file for anything reachable from RpcRegistry; reserve #[ts(export, export_to = ...)] for genuinely standalone types, and double-check the resulting path the first time you add it.

Complete Example

Backend

// Cargo.toml: ultimo = { version = "...", features = ["client-gen"] }
use ultimo::prelude::*;
use ultimo::rpc::{RpcRegistry, TS};
use serde::{Deserialize, Serialize};
 
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
struct User {
    id: u32,
    name: String,
    email: String,
}
 
#[derive(Debug, Deserialize, Serialize, TS)]
struct ListUsersInput {
    page: Option<u32>,
    limit: Option<u32>,
}
 
#[derive(Debug, Serialize, TS)]
struct ListUsersOutput {
    users: Vec<User>,
    total: u32,
    page: u32,
}
 
#[derive(Debug, Deserialize, Serialize, TS)]
struct CreateUserInput {
    name: String,
    email: String,
}
 
#[tokio::main]
async fn main() -> Result<()> {
    let mut app = Ultimo::new();
    let rpc = RpcRegistry::new();
 
    // List users (query) — types are derived from ListUsersInput/ListUsersOutput
    rpc.query("listUsers", |input: ListUsersInput| async move {
        let page = input.page.unwrap_or(1);
        let limit = input.limit.unwrap_or(10);
 
        Ok(ListUsersOutput {
            users: vec![],
            total: 0,
            page,
        })
    });
 
    // Create user (mutation)
    rpc.mutation("createUser", |input: CreateUserInput| async move {
        Ok(User {
            id: 1,
            name: input.name,
            email: input.email,
        })
    });
 
    // Generate client
    rpc.generate_client_file("../frontend/src/lib/client.ts")?;
    println!("✅ TypeScript client generated");
 
    // Mount RPC endpoint — single call, handles single/batch/notification requests
    app.mount_rpc("/rpc", rpc);
 
    app.listen("127.0.0.1:3000").await
}

Generated TypeScript Client

The generate_client_file() call produces a type-safe client:

// Auto-generated TypeScript client for Ultimo RPC (JSON-RPC 2.0)
// DO NOT EDIT - This file is automatically generated
 
export interface JsonRpcError {
  code: number;
  message: string;
  data?: unknown;
}
 
export class JsonRpcClientError extends Error {
  constructor(public readonly error: JsonRpcError) {
    super(error.message);
    this.name = "JsonRpcClientError";
  }
}
 
export class UltimoRpcClient {
  private _idCounter = 0;
 
  constructor(private baseUrl: string = "/api/rpc") {}
 
  private nextId(): number {
    return ++this._idCounter;
  }
 
  private async call<T>(method: string, params: unknown): Promise<T> {
    const id = this.nextId();
    const response = await fetch(this.baseUrl, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ jsonrpc: "2.0", method, params, id }),
    });
 
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }
 
    const data = await response.json();
    if (data.error) {
      throw new JsonRpcClientError(data.error);
    }
    return data.result as T;
  }
 
  /** Send a notification (fire-and-forget, no response expected) */
  async notify(method: string, params?: unknown): Promise<void> {
    /* ... */
  }
 
  /** Execute multiple RPC calls in a single HTTP request */
  async batch(/* ... */) {
    /* ... */
  }
 
  async listUsers(params: ListUsersInput): Promise<ListUsersOutput> {
    return this.call("listUsers", params);
  }
 
  async createUser(params: CreateUserInput): Promise<User> {
    return this.call("createUser", params);
  }
}
 
// Type Definitions
export type User = { id: number, name: string, email: string, };
export type ListUsersInput = { page: number | null, limit: number | null, };
export type ListUsersOutput = { users: Array<User>, total: number, page: number, };
export type CreateUserInput = { name: string, email: string, };

Frontend with React Query

import { UltimoRpcClient } from "./lib/client";
import { useQuery, useMutation } from "@tanstack/react-query";
 
const client = new UltimoRpcClient("/api/rpc");
 
function UserList() {
  const { data } = useQuery({
    queryKey: ["users"],
    queryFn: () => client.listUsers({ page: 1, limit: 10 }),
  });
 
  const createUser = useMutation({
    mutationFn: (input) => client.createUser(input),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["users"] });
    },
  });
 
  return (
    <div>
      {data?.users.map((user) => (
        <div key={user.id}>{user.name}</div>
      ))}
 
      <button
        onClick={() =>
          createUser.mutate({
            name: "New User",
            email: "new@example.com",
          })
        }
      >
        Create User
      </button>
    </div>
  );
}

Type Definitions

Nested structs, Option, Vec, and enums all derive into the matching TypeScript shape automatically — no manual type strings needed:

use ultimo::rpc::TS;
 
#[derive(Debug, Deserialize, Serialize, TS)]
enum Role {
    Admin,
    User,
}
 
#[derive(Debug, Deserialize, Serialize, TS)]
struct SearchFilters {
    role: Option<Role>,
    active: Option<bool>,
}
 
#[derive(Debug, Deserialize, Serialize, TS)]
struct Pagination {
    page: u32,
    limit: u32,
}
 
#[derive(Debug, Deserialize, Serialize, TS)]
struct SearchUsersInput {
    query: String,
    filters: Option<SearchFilters>,
    pagination: Option<Pagination>,
}
 
#[derive(Debug, Serialize, TS)]
struct SearchUsersOutput {
    results: Vec<User>,
    total: u32,
    has_more: bool,
}
 
rpc.query("searchUsers", |input: SearchUsersInput| async move {
    // ...
    Ok(SearchUsersOutput { results: vec![], total: 0, has_more: false })
});

For a type that can't derive TS (e.g. one defined in another crate), query_with_types/mutation_with_types take the same handler with explicit TypeScript type strings instead:

rpc.query_with_types(
    "searchUsers",
    handler,
    "{ query: string; filters?: { role?: 'admin' | 'user' } }".to_string(),
    "{ results: User[]; total: number; hasMore: boolean }".to_string(),
);

Error Handling

RPC procedures automatically handle errors:

#[derive(Debug, Deserialize, Serialize, TS)]
struct DeleteUserInput {
    id: u32,
}
 
#[derive(Debug, Serialize, TS)]
struct DeleteUserOutput {
    success: bool,
}
 
rpc.mutation("deleteUser", |input: DeleteUserInput| async move {
    let user = find_user(input.id)
        .ok_or_else(|| UltimoError::NotFound("User not found".to_string()))?;
 
    delete_user(user).await?;
 
    Ok(DeleteUserOutput { success: true })
});

TypeScript client catches errors:

try {
  await client.deleteUser({ id: 999 });
} catch (error) {
  console.error("Delete failed:", error.message);
}

Best Practices

✅ Use Descriptive Names

// Good
rpc.query("getUserProfile", handler);
rpc.mutation("updateUserEmail", handler);
 
// Bad
rpc.query("get", handler);
rpc.mutation("update", handler);

✅ Separate Queries and Mutations

// Queries - read-only operations
rpc.query("listUsers", handler);
rpc.query("getUser", handler);
 
// Mutations - write operations
rpc.mutation("createUser", handler);
rpc.mutation("deleteUser", handler);

✅ Validate Input

use ultimo::rpc::TS;
use ultimo::validate;
use validator::Validate;
 
#[derive(Debug, Deserialize, Serialize, TS, Validate)]
struct CreateUserInput {
    #[validate(length(min = 3, max = 50))]
    name: String,
 
    #[validate(email)]
    email: String,
}
 
rpc.mutation("createUser", |input: CreateUserInput| async move {
    validate(&input)?;
    // ... create user
    Ok(User { id: 1, name: input.name, email: input.email })
});

JSON-RPC 2.0 Protocol

Ultimo's JSON-RPC mode is fully compliant with the JSON-RPC 2.0 specification, with batch support, notifications, and structured errors.

Basic Setup

app.mount_rpc(path, rpc) mounts an RpcRegistry at a single POST endpoint that dispatches every procedure by name — single calls, batches, and notifications, per spec:

use ultimo::prelude::*;
use ultimo::rpc::{RpcRegistry, TS};
 
#[derive(Debug, Deserialize, Serialize, TS)]
struct AddInput {
    a: i32,
    b: i32,
}
 
#[derive(Debug, Serialize, TS)]
struct AddOutput {
    sum: i32,
}
 
#[tokio::main]
async fn main() -> Result<()> {
    let mut app = Ultimo::new();
    let rpc = RpcRegistry::new();
 
    rpc.query("add", |input: AddInput| async move {
        Ok(AddOutput { sum: input.a + input.b })
    });
 
    app.mount_rpc("/rpc", rpc);
 
    app.listen("127.0.0.1:3000").await
}

mount_rpc is sugar over calling RpcRegistry::handle_request yourself and translating its JsonRpcOutput into a Response — reach for the manual form only if you need custom behavior around the call (auth checks, different status codes, etc.) that doesn't fit a single route:

app.post("/rpc", move |ctx: Context| {
    let rpc = rpc.clone();
    async move {
        let body = ctx.req.bytes().await?;
        let output = rpc.handle_request(&body).await;
        match output.into_body() {
            Some(bytes) => {
                let value: serde_json::Value = serde_json::from_slice(&bytes)?;
                ctx.json(value).await
            }
            None => {
                // A notification (no id) produces no response body.
                ctx.status(204).await;
                ctx.text("").await
            }
        }
    }
});

Single Request

// Request
{"jsonrpc": "2.0", "method": "add", "params": {"a": 3, "b": 4}, "id": 1}
 
// Response
{"jsonrpc": "2.0", "result": {"sum": 7}, "id": 1}

Batch Requests

Send multiple calls in a single HTTP request — all execute concurrently:

// Request (array)
[
  {"jsonrpc": "2.0", "method": "add", "params": {"a": 1, "b": 2}, "id": 1},
  {"jsonrpc": "2.0", "method": "add", "params": {"a": 10, "b": 20}, "id": 2},
  {"jsonrpc": "2.0", "method": "getUser", "params": {"id": 42}, "id": 3}
]
 
// Response (array, correlated by id)
[
  {"jsonrpc": "2.0", "result": {"sum": 3}, "id": 1},
  {"jsonrpc": "2.0", "result": {"sum": 30}, "id": 2},
  {"jsonrpc": "2.0", "result": {"id": 42, "name": "Alice"}, "id": 3}
]

Notifications

A request without an id field is a notification — the server processes it but sends no response:

// Notification (no response)
{ "jsonrpc": "2.0", "method": "logEvent", "params": { "event": "page_view" } }

You can mix notifications with regular requests in a batch:

[
  {"jsonrpc": "2.0", "method": "add", "params": {"a": 1, "b": 1}, "id": 1},
  {"jsonrpc": "2.0", "method": "logEvent", "params": {"event": "calc"}},
  {"jsonrpc": "2.0", "method": "add", "params": {"a": 2, "b": 2}, "id": 2}
]
// Response only contains results for requests with id:
[
  {"jsonrpc": "2.0", "result": {"sum": 2}, "id": 1},
  {"jsonrpc": "2.0", "result": {"sum": 4}, "id": 2}
]

Error Responses

Errors follow the JSON-RPC 2.0 error format with standard codes:

// Method not found
{"jsonrpc": "2.0", "error": {"code": -32601, "message": "Procedure 'unknown' not found"}, "id": 1}
 
// Invalid params
{"jsonrpc": "2.0", "error": {"code": -32602, "message": "Invalid input: ..."}, "id": 2}
 
// Parse error (invalid JSON)
{"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}, "id": null}
CodeMeaning
-32700Parse error (invalid JSON)
-32600Invalid request (missing required fields)
-32601Method not found
-32602Invalid params
-32603Internal error

TypeScript Client with Batch

The generated TypeScript client includes batch and notification support:

import { UltimoRpcClient } from "./lib/client";
 
const client = new UltimoRpcClient("/rpc");
 
// Single call (fully typed)
const result = await client.add({ a: 3, b: 4 });
 
// Batch multiple calls in one HTTP request
const results = await client.batch([
  { method: "add", params: { a: 1, b: 2 } },
  { method: "add", params: { a: 10, b: 20 } },
  { method: "getUser", params: { id: 42 } },
]);
// results[0].result === { sum: 3 }
// results[1].result === { sum: 30 }
// results[2].result === { id: 42, name: "Alice" }
 
// Fire-and-forget notification
await client.notify("logEvent", { event: "button_click" });

Backward Compatibility

If you send a request without the jsonrpc: "2.0" field, Ultimo responds in the legacy format:

// Legacy request
{"method": "add", "params": {"a": 1, "b": 2}}
 
// Legacy response
{"result": {"sum": 3}}

This means existing clients continue to work without changes.