Tutorial 52: Typed Server Functions
#[server_function] lets one concrete async Rust signature describe both the
native server implementation and its Wasm caller. The transport is explicit:
the macro also creates a <function>_rpc_router() that you mount in the server.
It does not discover routes through runtime reflection.
use rullst::{Router, server_function};
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
pub struct SumResponse {
pub value: u32,
}
#[server_function(path = "/api/rpc/math/add")]
pub async fn add(left: u32, right: u32) -> rullst::rpc::RpcResult<SumResponse> {
Ok(SumResponse {
value: left.saturating_add(right),
})
}
pub fn rpc_routes() -> Router {
add_rpc_router()
}
fn main() {
let _router = rpc_routes();
}
When this function is compiled for the native server, its written body runs.
When compiled for wasm32, calling add(20, 22).await serializes (20, 22)
and returns the decoded RpcResult<SumResponse>. Transport failures are
machine-readable RpcFailure values; they never become a fabricated default
application value. The caller exists only in a wasm32-unknown-unknown build
of the crate, such as the island build in
Tutorial 18.
Mount the generated route inside server policy
Merge the generated router before applying the standard security baseline and
your domain layers. rullst::Server applies that baseline to the router it
serves, so there it is enough to merge the generated router into the
application router; the example calls apply_security_baseline directly to
show the composition:
use rullst::{Router, server_function};
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize)]
pub struct SumResponse { pub value: u32 }
#[server_function(path = "/api/rpc/math/add")]
pub async fn add(left: u32, right: u32) -> rullst::rpc::RpcResult<SumResponse> {
Ok(SumResponse { value: left.saturating_add(right) })
}
fn secured_transport() -> Result<axum::Router, rullst::SecurityBaselineError> {
let app = Router::new()
.merge_axum(add_rpc_router().into_axum())
.into_axum();
rullst::apply_security_baseline(
app,
rullst::SecurityConfig::default(),
rullst::config::Environment::Production,
)
}
fn main() -> Result<(), rullst::SecurityBaselineError> {
let _app = secured_transport()?;
Ok(())
}
The production baseline verifies the double-submit CSRF cookie/header pair.
The Wasm caller reads the bounded rullst_csrf cookie and forwards it as
X-CSRF-Token. Server issues that cookie only in staging and production;
without it (as in Development) the caller sends no token and lets the server
decide, and a non-JSON 403 is reported as rpc.csrf_token_missing. Other
non-JSON failures are rpc.http_failure; retryable() is true for 429,
502, 503 and 504 and for any response with Retry-After, which covers
the framework’s lifecycle-drain and Traffic Shield rejections. The
application must still add session/authentication, trusted tenant resolution,
object/role authorization and rate limiting in the order documented by
ProductionPreset. Never accept role, owner or tenant authority
from a function argument.
Failure codes
Application failures use the same lowercase dotted-code grammar as the shared client contract:
#![allow(unused)]
fn main() {
fn capacity_failure() -> Result<rullst::rpc::RpcFailure, rullst::client_contract::ClientContractError> {
rullst::rpc::RpcFailure::application("course.capacity_reached", false)
}
let failure = capacity_failure()?;
assert_eq!(failure.code(), "course.capacity_reached");
Ok::<(), rullst::client_contract::ClientContractError>(())
}
Do not place provider bodies, database errors, PII, tokens or debug text in a failure code. Log sensitive diagnostics only through an approved server-side telemetry policy.
When the server rejects an envelope that still carries a valid request_id
(an unsupported version such as a cached older Wasm client, or a payload of the
wrong shape), the failure echoes that identifier. The caller therefore receives
rpc.version_unsupported or rpc.request_invalid rather than
rpc.correlation_mismatch. Content-type and body-size rejections happen before
the body is parsed and still use a server-generated identifier; the generated
client applies the same limits before sending.
Exact limits
- zero to 16 simple identifier parameters;
- owned parameter types and one owned output type implementing the needed Serde traits;
- a concrete async free function with no generics, receiver,
unsafe, extern ABI or variadic arguments; rullst::rpc::RpcResult<T>as the return type;- an optional same-origin path below
/api/rpc/, using at most 128 ASCII bytes; - 256 KiB encoded request and response policy;
- JSON POST transport with a versioned envelope and request-ID correlation.
These bounds do not make a mutation exactly once. Put a stable idempotency key in the domain payload and enforce it transactionally on the server when replay would be harmful. Actual browser-engine compatibility, network availability and deployed identity policy must be tested by the application.