Rust SDK
Complete guide to building Hypen servers with Rust
Rust SDK
The Rust SDK (hypen-server) provides a server-side Hypen implementation for Rust applications. It features a fluent builder API for module definitions, typed action handlers with automatic serde deserialization, Serialize + DeserializeOwned state with automatic JSON diffing, session management, and Remote UI streaming over WebSocket.
Installation
Add to your Cargo.toml:
[dependencies]
hypen-server = "0.4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"For async action handlers:
[dependencies]
hypen-server = { version = "0.4", features = ["async"] }
tokio = { version = "1", features = ["full"] }Quick Start
Create a counter module with the fluent API and serve it over WebSocket:
use hypen_server::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Default, Serialize, Deserialize)]
struct CounterState {
count: i32,
}
fn main() -> Result<()> {
// 1. Define the module.
let counter = HypenApp::module::<CounterState>("Counter")
.state(CounterState::default())
.on_action::<()>("increment", |state, _, _ctx| { state.count += 1; })
.on_action::<()>("decrement", |state, _, _ctx| { state.count -= 1; })
.ui(r#"
Column {
Text("Count: @{state.count}")
Button("@actions.increment") { Text("+") }
Button("@actions.decrement") { Text("-") }
}
"#)
.build();
// 2. Register modules on a HypenApp via route-based wiring.
let app = HypenApp::builder()
.route("/", counter)
// .route("/feed", feed_module)
.build();
// 3. Plug `app` into your HTTP framework (Axum, Actix, etc.) via the
// framework integration crate. The SDK provides the module system
// and state management; the transport layer is framework-specific.
Ok(())
}For quick unit testing you can also instantiate a single module without the HypenApp wrapper using ModuleInstance::new(Arc::new(counter), None)?. Real deployments should use HypenApp::builder() + route-based registration.
The UI template uses Hypen DSL:
Column {
Text("Count: @{state.count}")
Button("@actions.increment") { Text("+") }
Button("@actions.decrement") { Text("-") }
}State is any type implementing Serialize + DeserializeOwned + Clone + Send + Sync. Mutate directly in handlers via &mut — changes are automatically diffed and synced to the engine.
Module Builder API
HypenApp::module::<S>("name") returns a ModuleBuilder<S> for fluent chaining. All configuration methods return self. Terminate with .build() or .ui().
let module = HypenApp::module::<MyState>("MyModule")
.state(MyState::default())
.persist()
.on_created(|state, ctx| { /* ... */ })
.on_action::<()>("doSomething", |state, _, ctx| { /* ... */ })
.on_destroyed(|state, ctx| { /* ... */ })
.on_error(|ctx| ErrorResult { handled: true })
.ui("Column { Text(\"Hello\") }")
.build();Builder Methods
| Method | Description |
|---|---|
.state(initial) | Set initial state (required) |
.ui(template) | Inline Hypen DSL template |
.ui_file(path) | Load UI from .hypen file |
.on_action::<P>(name, handler) | Register action handler with typed payload |
.on_created(handler) | Called when module is mounted |
.on_destroyed(handler) | Called when module is unmounted |
.on_error(handler) | Custom error handler |
.persist() | Module survives route navigation |
.build() | Finalize into ModuleDefinition<S> |
State Trait
Any type that satisfies these bounds automatically implements State:
pub trait State: Serialize + DeserializeOwned + Clone + Send + Sync + 'static {}Use #[derive] to satisfy this:
#[derive(Clone, Serialize, Deserialize)]
struct AppState {
count: i32,
user: Option<User>,
items: Vec<String>,
}Action Handlers
No Payload
Use () as the type parameter for actions without payloads:
.on_action::<()>("increment", |state, _, _ctx| {
state.count += 1;
})Typed Payloads
Any serde::Deserialize type works as a payload — it's automatically deserialized from the client's JSON:
#[derive(Deserialize)]
struct AddPayload {
amount: i32,
}
.on_action::<AddPayload>("add", |state, payload, _ctx| {
state.count += payload.amount;
})Scalar Payloads
Simple types work directly:
.on_action::<String>("setName", |state, name, _ctx| {
state.name = name;
})
.on_action::<i32>("setCount", |state, value, _ctx| {
state.count = value;
})Raw JSON Payloads
Use serde_json::Value for dynamic payloads:
.on_action::<serde_json::Value>("raw", |state, json, _ctx| {
if let Some(n) = json.as_i64() {
state.count = n as i32;
}
})Handler Signature
All action handlers have this signature:
fn(&mut S, PayloadType, Option<&GlobalContext>) + Send + Sync + 'staticThe handler receives:
&mut S— mutable reference to state (mutate directly)PayloadType— deserialized payload (()for no payload)Option<&GlobalContext>— global context if the module was created with one
Async Action Handlers
With the async feature, use on_action_async for handlers that perform async work. Async handlers receive owned state and must return the new state:
#[derive(Deserialize)]
struct FetchPayload {
id: String,
}
let module = HypenApp::module::<AppState>("AsyncDemo")
.state(AppState::default())
.on_action_async::<FetchPayload>("fetch", |mut state, payload, _ctx| {
Box::pin(async move {
state.loading = true;
let result = fetch_data(&payload.id).await;
state.data = Some(result);
state.loading = false;
state // return modified state
})
})
.build();Async variants are also available for lifecycle hooks:
.on_created_async(|state, ctx| {
Box::pin(async move { /* async init */ })
})State Management
Automatic Diffing
The SDK automatically snapshots state before each handler, then diffs after to detect changes:
- Before handler: state is snapshotted as JSON
- Handler mutates state via
&mut - After handler: changed paths are detected (e.g.,
"count","user.name","items.0") - Only changed paths are synced to the engine
.on_action::<()>("updateUser", |state, _, _| {
state.user.name = "Alice".into(); // only "user.name" is synced
state.user.age = 30; // only "user.age" is synced
// state.count is untouched — not synced
})JSON State Access
For dynamic state inspection:
let instance = ModuleInstance::new(Arc::new(def), None)?;
instance.mount();
let state: MyState = instance.get_state();
let json: serde_json::Value = instance.get_state_json()?;Lifecycle Hooks
let module = HypenApp::module::<CounterState>("Counter")
.state(CounterState { count: 0 })
.on_created(|state, ctx| {
println!("Module created with count: {}", state.count);
})
.on_action::<()>("increment", |state, _, _| {
state.count += 1;
})
.on_destroyed(|state, ctx| {
println!("Module destroyed at count: {}", state.count);
})
.build();| Hook | Signature | Called When |
|---|---|---|
on_created | Fn(&S, Option<&GlobalContext>) | Module instance is mounted |
on_destroyed | Fn(&S, Option<&GlobalContext>) | Module instance is unmounted |
Error Handling
Register a custom error handler to intercept failures in action handlers and lifecycle hooks:
.on_error(|ctx| {
if let Some(action) = &ctx.action_name {
eprintln!("Action '{}' failed: {:?}", action, ctx.error);
} else if let Some(phase) = &ctx.lifecycle {
eprintln!("Lifecycle '{}' failed: {:?}", phase, ctx.error);
}
ErrorResult { handled: true } // suppress the error
})Error Types
pub enum SdkError {
Engine(EngineError), // Rendering engine error
ModuleNotFound(String), // Unknown module name
ActionPayload { action, message }, // Payload deserialization failed
StateSerde(String), // State serialization error
Route(String), // Routing error
Component(String), // Component loading error
Other(String), // Catch-all
}ErrorContext Fields
| Field | Type | Description |
|---|---|---|
error | SdkError | The error |
action_name | Option<String> | Set if error came from an action handler |
lifecycle | Option<String> | "created" or "destroyed" if from a lifecycle hook |
Routing
HypenRouter
URL-based navigation with pattern matching:
let router = HypenRouter::new();
// Navigate
router.push("/users/123");
router.replace("/login");
// Query current state
router.current_path(); // "/users/123"
router.query(); // HashMap of query params
router.state(); // RouteState { current_path, params, query, previous_path }
// Pattern matching
let m = router.match_path("/users/:id", "/users/42").unwrap();
// m.params["id"] == "42"
router.is_active("/users/:id"); // true
// URL construction
let url = HypenRouter::build_url("/search", &query_params);
// Listen for navigation
let id = router.on_navigate(|from, to| {
println!("{:?} → {}", from, to);
});Pattern Syntax
| Pattern | Example Match | Params |
|---|---|---|
/users/:id | /users/42 | ["id": "42"] |
/posts/:postId/comments/:commentId | /posts/1/comments/5 | ["postId": "1", "commentId": "5"] |
/files/* | /files/a/b/c | wildcard match |
/about | /about | exact match |
App-Level Routing
Register route-to-module mappings on HypenApp:
let app = HypenApp::builder()
.route("/", home_module)
.route("/users/:id", user_module)
.route("/counter", counter_module)
.components_dir("./components")
.build();
// Match routes
if let Some((pattern, module_name)) = app.match_route("/users/42") {
// pattern == "/users/:id"
}
// Navigate
app.navigate("/counter");ManagedRouter
For route-driven module lifecycle with LRU persistence, use
ManagedRouter.
Unlike TS/Go/Swift/Kotlin, Rust routes take a factory closure
(Arc<dyn Fn() -> Result<Arc<dyn ManagedModule>>>) instead of
looking up a typed definition from a registry — the type-erased
ManagedModule trait sidesteps ModuleInstance<S>'s generics.
use hypen_server::managed_router::{ManagedRouter, ManagedRouterOptions, RouteDefinition};
let managed = ManagedRouter::new(
Arc::clone(&router),
Arc::clone(&ctx),
ManagedRouterOptions::default(), // LRU cap 10, persist=false
);
managed.add_route(RouteDefinition::factory("/", "Home", {
let app = Arc::clone(&app);
let def = Arc::clone(&home_def);
move || {
let inst = app.instantiate(Arc::clone(&def))?;
Ok(Arc::new(inst) as _)
}
}).persist(true));
managed.start();RemoteSession routing
RemoteSession owns a per-session HypenRouter and installs the
reserved @router.push / @router.replace / @router.back /
@router.forward engine actions at construction — DSL authors write
.onClick(@router.push, to: "/search") and it Just Works. The
session also mirrors the router path into primary state.location
when the primary state shape carries that field.
Rust's on_activated lifecycle hook is read-only (matches
on_created and on_destroyed). For route-param-driven data
loading, use session.on_route_enter:
session.on_route_enter("/comments/:postId", move |params, state, _ctx| {
let post_id = params.get("postId").cloned().unwrap_or_default();
let comments = load_comments(&post_id);
if let Some(slot) = state.get_mut("comments") {
if let Some(obj) = slot.as_object_mut() {
obj.insert("postId".into(), Value::String(post_id));
obj.insert("comments".into(), serde_json::to_value(&comments).unwrap());
}
}
});Hooks fire after every nav (push / replace / back), receive the extracted params, and can mutate any scope's state slot directly. Mutations are flushed to the engine inside the same dispatch so patches ship in the same WS frame.
See the Routing guide for the DSL-level contract and cross-SDK equivalents.
Event System
Pub/sub messaging for cross-module communication:
let emitter = EventEmitter::new();
// Subscribe
let id = emitter.on("user:login", |payload: &Value| {
println!("User logged in: {:?}", payload);
});
// One-time listener
emitter.once("init:complete", |payload: &Value| {
println!("Init done");
});
// Emit
emitter.emit("user:login", &json!({"name": "Alice"}));
// Unsubscribe
emitter.off(id);
// Cleanup
emitter.remove_all("user:login");
emitter.clear();Built-in Framework Events
use hypen_server::events::framework;
framework::MODULE_CREATED // "module:created"
framework::MODULE_DESTROYED // "module:destroyed"
framework::ROUTE_CHANGED // "route:changed"
framework::STATE_UPDATED // "state:updated"
framework::ACTION_DISPATCHED // "action:dispatched"
framework::ERROR // "error"Global Context
Cross-module communication hub:
use std::sync::Arc;
let ctx = Arc::new(GlobalContext::new());
// Register module state
ctx.register_module_state("counter", json!({"count": 0}));
// Access other modules' state
let state = ctx.get_module_state("counter");
// Query
ctx.has_module("counter"); // true
ctx.module_names(); // vec!["counter"]
ctx.global_state(); // merged view of all module states
// Events via context
ctx.events().emit("custom:event", &json!({"data": "value"}));
let id = ctx.events().on("custom:event", |payload| {
println!("Event: {:?}", payload);
});
// Router access
ctx.set_router(Arc::new(HypenRouter::new()));
let router = ctx.router();Using Context in Handlers
.on_created(|state, ctx| {
if let Some(ctx) = ctx {
// Read another module's state
let theme = ctx.get_module_state("theme");
// Emit events
ctx.events().emit("counter:ready", &json!(state));
// Access router
if let Some(router) = ctx.router() {
println!("Current route: {}", router.current_path());
}
}
})Component Discovery
Discover .hypen templates from the filesystem:
let mut registry = ComponentRegistry::new();
// Load all components from a directory
let names = registry.load_dir("./components")?;
// Manual registration
registry.register("Header", "<template source>", Some(path));
// Load single file
let name = registry.load_file("./components/Footer.hypen")?;
// Lookup
registry.get("Header"); // Option<&ComponentEntry>
registry.has("Header"); // true
registry.names(); // vec!["Header", "Footer"]Discovery Naming Conventions
| File Structure | Component Name |
|---|---|
button.hypen | Button |
user-card.hypen | UserCard |
my_component.hypen | MyComponent |
Button/component.hypen | Button |
Modal/index.hypen | Modal |
Remote UI Protocol
The SDK includes RemoteSession for server-driven UI over WebSocket:
let config = SessionConfig {
module_name: "App".into(),
ui_source: r#"Column { Text("@{state.count}") }"#.into(),
components: ComponentRegistry::new(),
initial_state: json!({"count": 0}),
action_names: vec!["increment".into()],
};
let session = RemoteSession::new(config);
session.set_action_handler(|action, payload, state| {
// Modify and return new state
let mut s = state.clone();
if action == "increment" {
if let Some(count) = s.get_mut("count") {
*count = json!(count.as_i64().unwrap_or(0) + 1);
}
}
s
});
// On WebSocket connect:
let init_msgs = session.handle_hello(None);
for msg in init_msgs {
ws.send(msg);
}
// On WebSocket message:
let responses = session.handle_message(&incoming_json);
for resp in responses {
ws.send(resp);
}Protocol Flow
Client Server
│─ hello ─────────>│
│<─ sessionAck ────│
│<─ initialTree ───│
│─ dispatchAction ─>│
│<─ patch ─────────│
│<─ stateUpdate ───│RemoteMessage Types
| Message | Direction | Description |
|---|---|---|
Hello | Client → Server | Initial connection with optional session ID |
SessionAck | Server → Client | Confirms session with is_new / is_restored flags |
InitialTree | Server → Client | Full render tree as patches |
Patch | Server → Client | Incremental UI updates |
StateUpdate | Server → Client | Full state snapshot |
DispatchAction | Client → Server | Action with optional payload |
SessionExpired | Server → Client | Session timed out |
Module Instance Lifecycle
let def = Arc::new(counter_definition);
let instance = ModuleInstance::new(def, Some(ctx.clone()))?;
// Register patch listener
instance.on_patches(|patches| {
for patch in patches {
println!("{:?}", patch);
}
});
// Mount (triggers on_created)
instance.mount();
assert!(instance.is_mounted());
// Dispatch actions
instance.dispatch_action("increment", None)?;
instance.dispatch_action("add", Some(json!({"amount": 5})))?;
// Read state
let state = instance.get_state();
let json = instance.get_state_json()?;
// Unmount (triggers on_destroyed)
instance.unmount();Feature Flags
| Feature | Description |
|---|---|
| (none) | Synchronous API only |
async | Enables on_action_async, on_created_async, on_destroyed_async, mount_async, unmount_async, dispatch_action_async, and BoxFuture type |
Requirements
- Rust 1.70+ (2021 edition)
serde+serde_jsonfor state serializationtokio(optional, withasyncfeature)
See Also
- TypeScript SDK — TypeScript server SDK with sessions and component discovery
- Go SDK — Go server SDK reference
- Kotlin SDK — Kotlin/JVM SDK with type-safe DSL and coroutine support
- Swift SDK — Swift server SDK with typed action enums
- Remote App Tutorial — Step-by-step guide to building a remote app