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/mutationrequire theclient-genfeature. Every example on this page uses them (the versions that derive TypeScript types from your Rust types via#[derive(TS)]) — addfeatures = ["client-gen"]to yourultimodependency, or.query()/.mutation()won't resolve. Thequery_with_types/mutation_with_typesescape 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
}GET /api/getUser?id=1POST /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
}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
- ✅ 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
- ✅ 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}| Code | Meaning |
|---|---|
| -32700 | Parse error (invalid JSON) |
| -32600 | Invalid request (missing required fields) |
| -32601 | Method not found |
| -32602 | Invalid params |
| -32603 | Internal 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.