#automatic #typescript #rpc #json-rpc #json-rpc-server #client #generated

rstrpc

Type-safe framework for an JSONRPC server written in Rust with an automatically generated TypeScript client

1 unstable release

0.0.1 Mar 10, 2024

#55 in #json-rpc-server

MIT license

27KB
544 lines

rstrpc - Rust/TypeScript RPC

Generate type-safe TypeScript clients for your Rust APIs, with Serde compatibility, subscriptions, and more!

Features:

  • Context based middleware

  • Subscriptions

  • Nested routers

  • Serde compatibility for serialising and deserialising parameters and return values

  • Standardised JSONRPC 2.0 implementation, including ws and http transport layers

  • Built using ts-rs and jsonrpsee

Example

Check out the example directory for a full example.

Rust Server

#[derive(Clone, Default)]
pub struct Ctx {
    count: Arc<AtomicUsize>,
}

// Handlers are defined as functions, where the function name will be the name of the handler
#[handler]
async fn hello_world(_ctx: Ctx) -> String {
    "Hello, world!".to_string()
}

// Handlers have access to the app state
#[handler]
async fn count(ctx: Ctx) -> usize {
    ctx.count.fetch_add(1, Ordering::Relaxed)
}

// Handlers can accept parameters
#[handler]
async fn count_by(ctx: Ctx, amount: usize) -> usize {
    ctx.count.fetch_add(amount, Ordering::Relaxed)
}

// Handlers can return a stream, in order to act as a subscription
#[handler(subscription)]
async fn countdown(ctx: AppCtx, min: usize, max: usize) -> impl Stream<Item = usize> {
    stream::iter(min..=max).then(|n| async move {
        n
    })
}

#[tokio::main]
async fn main() {
    // Build the app, attaching handlers as required
    let app = Router::new()
        .handler(hello_world)
        .handler(count);

    // Create a stop channel so that the server can be programatically terminated
    let (stop_handle, server_handle) = stop_channel();

    // Global app context
    let ctx = Ctx::default();

    // Create or nest app into an existing axum server
    let router = axum::Router::<()>::new()
        .route("/", get(|| async { "another endpoint!" }))
        .nest_service("/rpc", app.to_service(move |_| {
            // For each request that comes in, clone the context so that it can be shared around
            ctx.clone()
        }, stop_handle));

    // Start the axum rounter as normal
    hyper::Server::bind(&SocketAddr::from([127, 0, 0, 1], 9944))
        .serve(router.into_make_service())
        .await
        .unwrap();

    // Upon termination of the hyper server, properly shutdown the RPC server
    server_handle.stop().unwrap();
}

TypeScript

import { ws } from "@rstrpc/client";

// This type is automatically generated based on the Rust API
import type { Server } from "./bindings.ts";

// Start a new client, passing the type as a generic parameter
const client = ws<Server>("ws://localhost:9944/rpc");

// Handlers can be accessed from the client just by calling the method!
const message = await client.hello_world();
console.log(message); // "Hello, world!"

for (let i = 0; i < 5; i++) {
    const count = await client.count();
    console.log(`The count is: ${count}`);
}

// Parameters are typed, and are passed as if it were a regular function
await client.count_by(10);

// Subscriptions are just like regular handlers, except they also accept life-cycle handlers for
// data, errors, and subcription end
await client.countdown(1, 4).subscribe({
	on_data: (data) => {
		console.log(`Countdown: ${data}`);
	},
	on_end: () => {
		console.log("Countdown done!");
	}
});

Acknowledgements

  • rspc: Similar concept, however uses a bespoke solution for generating TypeScript types from Rust structs, which isn't completely compatible with all of Serde's features for serialising and deserialising structs.

  • trpc: Needs no introduction, however it being restricted to TypeScript backends makes it relatively useless for Rust developers.

Dependencies

~12–23MB
~330K SLoC