Type-safe RPC for Rust + TypeScript, with Axum and Tauri support.
Define your RPC functions once in Rust. TypeScript types are auto-generated. Call them from the frontend with full type safety.
- One source of truth — Rust functions drive TypeScript types via specta codegen
- Dual runtime — Axum (HTTP) and Tauri (IPC) backends from the same router
- Subscriptions — Server-sent events (Axum) or
Channel<string>(Tauri), typed asAsyncIterable - Middleware — HookLayer before/after, TracingLayer, custom FnLayer
- BigInt safe — Automatic BigInt envelope across all transports
- TanStack Query —
@fnrpc/tanstack-queryfor query/mutation/stream/live utilities
use fnrpc::{rpc_query, rpc_mutate, rpc_subscribe, RpcErr};
// Single param, no context
#[rpc_query]
async fn health_check() -> String {
"ok".into()
}
// Multi-param query with context
#[rpc_query]
async fn get_user(ctx: &Ctx, id: i64) -> Result<User, RpcErr> {
// ctx.db.query(...)
todo!()
}
// Mutation — structured input
#[derive(specta::Type, serde::Deserialize)]
struct CreateUserInput {
name: String,
email: String,
}
#[rpc_mutate]
async fn create_user(ctx: &Ctx, input: CreateUserInput) -> Result<User, RpcErr> {
// INSERT INTO users (name, email) ...
todo!()
}
// Subscription — sync fn returning a Stream
#[rpc_subscribe]
fn watch_user(ctx: &Ctx, id: i64) -> Pin<Box<dyn Stream<Item = Result<UserUpdate, RpcErr>> + Send + '_>> {
// ...
}
// Subscription with POST — input sent in body instead of URL query params
#[rpc_subscribe("post")]
fn large_stream(ctx: &Ctx, input: LargeInput) -> Pin<Box<dyn Stream<Item = Result<Output, RpcErr>> + Send + '_>> {
// ...
}use fnrpc::router::RpcRouterBuilder;
let router = RpcRouterBuilder::<Ctx>::new()
.query(health_check)
.query(get_user)
.mutate(create_user)
.subscribe(watch_user)
.layer(HookLayer::new()
.before(|ctx, path, input| tracing::info!("{path} invoked")))
.layer(TracingLayer)
.build();use std::sync::Arc;
use axum::Router;
use fnrpc_axum::{FnrpcState, handle};
Router::new()
.route("/fnrpc/{*path}", axum::routing::get(handle::<Ctx>).post(handle::<Ctx>))
.with_state(Arc::new(FnrpcState {
router: Arc::new(router),
ctx_from_headers: Arc::new(|headers| Ctx {
db: db_pool.clone(),
user_id: extract_user_id(&headers),
}),
}))
.layer(cors);// scripts/gen_fnrpc.rs
fn main() {
let router = build_router();
fnrpc::gen_ts_client::write_ts_client(
&router,
"http://localhost:3000/fnrpc",
Path::new("../src/bindings.ts"),
)
.expect("codegen failed");
}import { createClient, fetchTransport, tauriTransport } from "@fnrpc/client";
import type { Procedures } from "./bindings";
import { __procedureMeta } from "./bindings";
import { isTauri } from "@tauri-apps/api/core";
const transport = (() => {
try {
if (isTauri()) {
return tauriTransport(() => import("@tauri-apps/api/core"));
}
} catch {}
return fetchTransport({ url: "http://localhost:19110/fnrpc" });
})();
export const fnrpc = createClient<Procedures>(transport, __procedureMeta);const user = await fnrpc.get_user(42);
// ^? UserRust functions with multiple params accept a tuple in TypeScript:
const add = await fnrpc.add([1, 2]); // fn add(a: i32, b: i32)For structured input, pass an object:
const created = await fnrpc.create_user({ name: "Alice", email: "alice@example.com" });const stream = await fnrpc.watch_user(42);
for await (const update of stream) {
console.log("user updated:", update);
}For POST subscriptions (large inputs), the transport automatically sends the input in the request body.
Abort a subscription via AbortSignal:
const controller = new AbortController();
const stream = await fnrpc.watch_user(42, controller.signal);
setTimeout(() => controller.abort(), 5000);try {
await fnrpc.get_user(999);
} catch (err) {
if (isRpcError(err)) {
// { code: "NOT_FOUND", message: "...", data: unknown }
}
}Use @fnrpc/tanstack-query to create typed query/mutation/stream utilities for your RPCs.
import { createTanstackQueryUtils } from "@fnrpc/tanstack-query";
export const client = createTanstackQueryUtils(fnrpc);import { useQuery, useMutation } from "@tanstack/react-query";
// Query
const { data: user } = useQuery(client.get_user.queryOptions(42));
// Mutation
const mutation = useMutation(client.create_user.mutationOptions());
mutation.mutate({ name: "Alice", email: "alice@example.com" });
// Streamed — accumulates chunks into an array
const { data: updates } = useQuery(client.watch_user.streamedOptions(42));
// Live — each chunk updates the cache in real time
const { data: lastUpdate } = useQuery(client.watch_user.liveOptions(42));import { createQuery, createMutation } from "@tanstack/solid-query";
// Query
const query = createQuery(() => client.get_user.queryOptions(42));
// Mutation
const mutation = createMutation(() => client.create_user.mutationOptions());
mutation.mutate({ name: "Alice", email: "alice@example.com" });
// Streamed
const streamed = createQuery(() => client.watch_user.streamedOptions(42));
// Live
const live = createQuery(() => client.watch_user.liveOptions(42));| Package | Description |
|---|---|
fnrpc |
Rust core: macros, router, codegen, middleware, handler traits |
fnrpc-axum |
Axum integration: FnrpcState, handle |
fnrpc-macros |
Proc macros: #[rpc_query], #[rpc_mutate], #[rpc_subscribe] |
@fnrpc/client |
TypeScript typed client, Proxy-based, fetch & Tauri transports |
@fnrpc/tanstack-query |
TanStack Query utilities: queryOptions, mutationOptions, streamedQuery, liveQuery |
- tauri-solid-tanstack — Tauri + SolidJS + TanStack Query
See SUMMARY.md for detailed architecture, proc macro reference, middleware docs, handler traits, and test index.
See Roadmap.md.