DocsServer features
RPC & uploads
A Rust function the browser can call by name, beside the page that calls it. Typed arguments, typed returns, CSRF protection by default, server streaming, and uploads with progress.
An rpc answers once, and an RpcStream answers in pieces. When both sides need to speak for as long as the page is open, that is a WebSocket.
Placement and dispatch
The browser posts to the page URL it is currently on, with a header naming the function. The generated router dispatches page-owned names first, then falls through to the global component registry.
| Where the call happens | Where the #[rpc] goes |
|---|---|
| A page | The calling page.rs. |
| An application component | src/components/<component>.rs. |
| A library component | Beside the library component. |
| A layout, error, not-found or route.rs | Nowhere — expose an ordinary HTTP method from route.rs instead. |
Page RPCs are reachable only on their own page URLs, so two different pages may reuse a name. Component RPC names are application-wide, because a component can render on any page: they must be unique across everything linked, and cannot collide with a page RPC name.
The Rust side
// src/app/greeter/page.rs — beside the page that calls it.
use crate::rahti::rpc;
#[rpc]
pub async fn greet(name: String) -> Greeting {
Greeting {
text: format!("Hello, {name}."),
}
}#[rpc]takesauth, or nothing.#[rpc(auth)]answers 401 unless the browser carries a session. There is no other argument, and in particular no role.- functions may be sync or async, but cannot be methods or generic;
- parameters need plain names and owned, deserializable types —
String, not&str; - the payload key must match the Rust parameter name;
- return a serializable value, or
rahti::Result<T>; Option<T>accepts an absent or null argument;- the original function stays callable from Rust.
const result = await pp.rpc("greet", { name });Successful values resolve from JSON. An Err(Error) becomes an HTTP status plus a JSON error message, and the Promise rejects.
use crate::rahti::{Error, Result, rpc};
use axum::http::StatusCode;
#[rpc]
pub async fn note(id: u32) -> Result<Note> {
let note = store::find(id)
.await?
.ok_or_else(|| Error::new(StatusCode::NOT_FOUND, "no such note"))?;
Ok(note)
}Errors from below
rahti::Error implements From<E> for every E: std::error::Error, so a sea_orm::DbErr crosses ? inside rahti::Result<T> with no conversion written anywhere.
// `rahti::Error` implements `From<E>` for every `E: std::error::Error`, so
// a database error crosses `?` with no conversion written anywhere.
#[rpc]
pub async fn list() -> Result<Vec<todo::Model>> {
Ok(todo::Entity::find().all(db()).await?)
}Client options
pp.rpc(name, data?, optionsOrAbort?). Passing true as the third argument is shorthand for { abortPrevious: true }.
| Option | Purpose |
|---|---|
| abortPrevious | Cancels the previous call with this name. The typeahead option. |
| url / csrfUrl | Override the endpoint and the CSRF source. The default URL is the current route. |
| credentials | The fetch credentials mode. |
| onStream / onStreamError / onStreamComplete | Server-streaming callbacks. |
| onUploadProgress / onUploadComplete | Upload progress callbacks. |
// The third argument is options, or `true` as shorthand for
// { abortPrevious: true }.
const results = await pp.rpc("search", { term }, true);
const saved = await pp.rpc("save", { text }, {
abortPrevious: false,
onUploadProgress: (sent, total) => setPercent((sent / total) * 100),
});Server streaming
Return an RpcStream to produce an event stream.
use crate::rahti::{RpcStream, rpc};
#[rpc]
pub async fn count(to: u32) -> RpcStream {
RpcStream::from_iter(1..=to)
}await pp.rpc("count", { to: 5 }, {
onStream: (value) => append(value),
onStreamError: (error) => showError(error),
onStreamComplete: () => finish(),
});| Construction | For |
|---|---|
| RpcStream::new(stream) | An asynchronous stream of serializable values. |
| RpcStream::from_iter(values) | An iterator. |
| RpcStream::channel(capacity) | A (RpcSender, RpcStream) pair. |
| RpcSender::send(value).await | One serialized event. |
Each event is one JSON value. The request stays active until the stream closes, and can be cancelled by abortPrevious or by component disposal.
Files that spool: RpcFile
Including a browser File or a non-empty FileList in the payload makes pp.rpc send multipart/form-data.
| Parameter type | Accepts |
|---|---|
| RpcFile | Exactly one file. |
| Option<RpcFile> | Zero or one. |
| Vec<RpcFile> | Several fields under the same payload key. |
use crate::rahti::{Result, RpcFile, rpc};
#[rpc]
pub async fn avatar(user: u32, image: RpcFile) -> Result<String> {
// The reported filename is user input. `safe_name` before a path, and
// still choose the destination directory yourself.
let path = std::path::Path::new("uploads").join(image.safe_name());
image.save(&path).await?;
Ok(path.display().to_string())
}// Including a File or a non-empty FileList makes pp.rpc use multipart.
const input = document.querySelector("input[type=file]");
await pp.rpc("avatar", { user: 7, image: input.files[0] }, {
onUploadProgress: (sent, total) => setPercent((sent / total) * 100),
onUploadComplete: () => setPercent(100),
});The API covers metadata (name, safe_name, content_type, len, is_empty, is_spilled), content (bytes, into_bytes, text, reader, into_stream) and persistence (save, copy_to). Small files stay in memory; larger ones spill to a temporary file while arriving. Clones keep a spilled file alive, and the temporary file is removed when no owner remains.
Bytes as they arrive: RpcUpload
Use RpcUpload when the server must process bytes as they arrive, without spooling the whole upload first.
use crate::rahti::{Result, RpcUpload, rpc};
// At most one RpcUpload, and it must be the final parameter — the browser's
// file value is last on the multipart wire.
#[rpc]
pub async fn store(note: String, upload: RpcUpload) -> Result<Stored> {
let bytes = upload.save("uploads/incoming.bin").await?;
Ok(Stored { note, bytes })
}- at most one
RpcUploadparameter; - it must be the final parameter;
- the browser's file value must be last on the multipart wire — the PulsePoint client deliberately appends all non-file fields before files;
- a missing or non-multipart file is an RPC error.
The API covers name, safe_name, content_type, read_so_far, chunk, save, copy_to and collect — the last converting the remaining stream into an RpcFile with a chosen spill threshold.
Limits
| Limit | Default | Override |
|---|---|---|
| Total request | 128 MiB | RAHTI_MAX_UPLOAD |
| Per file | Unlimited, except by the total | RAHTI_MAX_FILE |
| Spill threshold | 256 KiB | RAHTI_SPILL_THRESHOLD |
| Spill directory | The OS temporary directory | RAHTI_SPILL_DIR |
Values are byte counts, and the effective per-file limit never exceeds the total request limit. Keep application-level content validation separate from transport limits: a 10 MB file that is not a valid image is a transport success and an application failure.
