Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

jsonrpc_bidirectional_service!

Use jsonrpc_bidirectional_service! for typed JSON-RPC traffic over WebSockets. It generates server-side dispatch for client calls, client-side method helpers, typed notification handling, and optional server-to-client request support.

Dependencies And Features

[dependencies]
async-trait = "0.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
ras-auth-core = "0.2.0"
ras-jsonrpc-types = "0.2.0"
ras-jsonrpc-bidirectional-types = "0.2.0"
ras-jsonrpc-bidirectional-macro = { version = "0.2.0", default-features = false }
ras-jsonrpc-bidirectional-server = { version = "0.2.0", optional = true }
ras-jsonrpc-bidirectional-client = { version = "0.2.0", optional = true }

[features]
default = []
server = [
    "ras-jsonrpc-bidirectional-macro/server",
    "dep:ras-jsonrpc-bidirectional-server",
]
client = [
    "ras-jsonrpc-bidirectional-macro/client",
    "dep:ras-jsonrpc-bidirectional-client",
]

These API-crate features forward to the macro crate and enable the runtime dependencies used by the generated surface. The WebSocket server depends on the API crate with features = ["server"]; TUI, native, or browser clients depend on it with features = ["client"].

If server_to_client_calls is used, the server feature also needs optional tokio and uuid dependencies because generated server-side client handles track pending responses and timeouts.

Define The Service

use ras_jsonrpc_bidirectional_macro::jsonrpc_bidirectional_service;
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SendMessageRequest {
    pub channel: String,
    pub body: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SendMessageResponse {
    pub message_id: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageReceived {
    pub channel: String,
    pub body: String,
}

jsonrpc_bidirectional_service!({
    service_name: ChatService,
    client_to_server: [
        WITH_PERMISSIONS(["user"]) send_message(SendMessageRequest) -> SendMessageResponse,
    ],
    server_to_client: [
        message_received(MessageReceived),
    ],
    server_to_client_calls: [
    ]
});

client_to_server methods support the same UNAUTHORIZED, OPTIONAL_AUTH, and WITH_PERMISSIONS(["a"] | ["b", "c"]) style as the HTTP JSON-RPC macro (see Auth In The API Contract). An OPTIONAL_AUTH method is built from the connection’s optional user, so the server must allow anonymous connections (require_auth(false)) for anonymous callers to reach it. OPTIONAL_AUTH is not allowed on server_to_client calls — those are outbound, so there is no inbound caller to identify.

Implement And Mount The Server

Server handlers receive the connection id and connection manager. WITH_PERMISSIONS methods also receive &AuthenticatedUser; OPTIONAL_AUTH methods receive a ras_auth_core::Caller (in the same position) instead.

#[async_trait::async_trait]
impl ChatServiceService for ChatServiceImpl {
    async fn send_message(
        &self,
        client_id: ras_jsonrpc_bidirectional_types::ConnectionId,
        connection_manager: &dyn ras_jsonrpc_bidirectional_types::ConnectionManager,
        user: &ras_auth_core::AuthenticatedUser,
        request: SendMessageRequest,
    ) -> Result<SendMessageResponse, Box<dyn std::error::Error + Send + Sync>> {
        todo!("persist and broadcast the message")
    }

    async fn notify_message_received(
        &self,
        connection_id: ras_jsonrpc_bidirectional_types::ConnectionId,
        params: MessageReceived,
    ) -> ras_jsonrpc_bidirectional_types::Result<()> {
        Ok(())
    }
}
let websocket_service = ChatServiceBuilder::new(ChatServiceImpl, my_auth_provider)
    .require_auth(false)
    .build();

let app = axum::Router::new()
    .route("/ws", axum::routing::get(ras_jsonrpc_bidirectional_server::websocket_handler::<_>))
    .with_state(websocket_service);

require_auth(true) requires credentials for the connection as a whole. Method-level permissions are still enforced for protected calls.

Connection Security Defaults

The generated builder produces a WebSocketService. Its defaults are tuned for production, and every one of them is overridable on ras_jsonrpc_bidirectional_server::WebSocketServiceBuilder or by implementing the corresponding WebSocketService method:

SettingDefaultWhat it does
max_message_size1 MiBEnforced at the transport, so an oversized frame is rejected before it is buffered.
subscription_limits64 topics per message, 256 per connection, 256-byte names, 100 000 globallyAn over-limit Subscribe gets an invalid-params error and the connection stays open.
keepaliveping every 30 s, close after 90 s idleReclaims half-open sockets. Browsers and tungstenite answer pings automatically.
auth_revalidation_interval30 sRe-runs the auth provider on the connection’s token. Failure closes the socket.
on_permission_changeDropSubscriptionsOn successful re-validation every held subscription is re-run through authorize_subscribe; topics no longer authorized are dropped, and any topic message already queued for the socket is discarded at send time. Close closes the socket instead so the client must reconnect.
max_connections10 000Enforced atomically with a semaphore permit held for the connection’s lifetime. None lifts the cap.

All of these are also settable on the generated <Service>Builder.

Topic subscriptions are default-deny: override authorize_subscribe on the handler to allow the topics a connection is entitled to. The limits above are enforced inside ConnectionContext::subscribe itself, the only way to add a subscription, so they hold from any handler callback and with any ConnectionManager. It returns an error on refusal; the default handle_subscribe logs and continues. WITH_PERMISSIONS checks go through your provider’s check_permissions, so wildcard or hierarchical permission schemes behave the same over WebSocket as over REST.

Authenticating From A Browser

A browser WebSocket cannot set headers. The WASM client therefore offers two subprotocols on upgrade, ras-jsonrpc and token.<jwt>; the server reads the token from the second and selects the first, so the token is never echoed in the response. Native clients send Authorization: Bearer <token>. Tokens are never placed in the URL query string, and the server does not read them from there.

Client Usage

The client feature generates a typed client builder, method calls, connection helpers, and notification registration:

let mut client = ChatServiceClientBuilder::new("ws://localhost:3000/ws")
    .with_jwt_token(token)
    .build()
    .await?;

client.on_message_received(|message| {
    println!("{}: {}", message.channel, message.body);
});

client.connect().await?;
let sent = client.send_message(SendMessageRequest {
    channel: "general".to_string(),
    body: "hello".to_string(),
}).await?;

In application code, it is usually useful to register all notification handlers before connect, then wrap common calls behind a small app-level client:

client.on_message_received(|message| {
    println!("{}: {}", message.channel, message.body);
});

client.on_user_joined(|event| {
    println!("{} joined", event.username);
});

client.connect().await?;

let rooms = client.list_rooms(ListRoomsRequest {}).await?;

client
    .send_message(SendMessageRequest {
        channel: rooms.default_channel,
        body: "hello".to_string(),
    })
    .await?;

This macro does not currently generate OpenRPC. Use HTTP jsonrpc_service! when an OpenRPC document is required.

See examples/bidirectional-chat.