HypenHypen
Server SDKs

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

MethodDescription
.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 + 'static

The 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:

  1. Before handler: state is snapshotted as JSON
  2. Handler mutates state via &mut
  3. After handler: changed paths are detected (e.g., "count", "user.name", "items.0")
  4. 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();
HookSignatureCalled When
on_createdFn(&S, Option<&GlobalContext>)Module instance is mounted
on_destroyedFn(&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

FieldTypeDescription
errorSdkErrorThe error
action_nameOption<String>Set if error came from an action handler
lifecycleOption<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

PatternExample MatchParams
/users/:id/users/42["id": "42"]
/posts/:postId/comments/:commentId/posts/1/comments/5["postId": "1", "commentId": "5"]
/files/*/files/a/b/cwildcard match
/about/aboutexact 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 StructureComponent Name
button.hypenButton
user-card.hypenUserCard
my_component.hypenMyComponent
Button/component.hypenButton
Modal/index.hypenModal

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

MessageDirectionDescription
HelloClient → ServerInitial connection with optional session ID
SessionAckServer → ClientConfirms session with is_new / is_restored flags
InitialTreeServer → ClientFull render tree as patches
PatchServer → ClientIncremental UI updates
StateUpdateServer → ClientFull state snapshot
DispatchActionClient → ServerAction with optional payload
SessionExpiredServer → ClientSession 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

FeatureDescription
(none)Synchronous API only
asyncEnables 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_json for state serialization
  • tokio (optional, with async feature)

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