Building Actors in Theater
This guide walks you through creating actors in Theater, from basic concepts to advanced patterns, with practical examples.
Quick Start
Create a new actor project:
cargo new my-actor
cd my-actor
Add dependencies to Cargo.toml:
[package]
name = "my-actor"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = { version = "0.4", features = ["serde"] }
Project Structure
my-actor/
├── Cargo.toml # Project configuration
├── actor.toml # Actor manifest
├── src/
│ ├── lib.rs # Actor implementation
│ └── state.rs # State management
└── wit/ # Interface definitions
└── actor.wit # Actor interface
Basic Actor Implementation
Here's a complete example of a simple counter actor:
#![allow(unused)] fn main() { // src/lib.rs use bindings::exports::ntwk::theater::actor::Guest as ActorGuest; use bindings::ntwk::theater::types::{Event, Json}; use bindings::ntwk::theater::runtime::log; use serde::{Deserialize, Serialize}; // Define actor state #[derive(Serialize, Deserialize)] struct State { count: i32, last_updated: String, } // Define message types #[derive(Deserialize)] #[serde(tag = "type")] enum Message { Increment { amount: i32 }, Decrement { amount: i32 }, Reset, } struct Component; impl ActorGuest for Component { fn init() -> Vec<u8> { log("Initializing counter actor"); let initial_state = State { count: 0, last_updated: chrono::Utc::now().to_string(), }; serde_json::to_vec(&initial_state).unwrap() } fn handle(evt: Event, state: Vec<u8>) -> Vec<u8> { log(&format!("Handling event: {:?}", evt)); let mut current_state: State = serde_json::from_slice(&state).unwrap(); if let Ok(message) = serde_json::from_slice(&evt.data) { match message { Message::Increment { amount } => { current_state.count += amount; } Message::Decrement { amount } => { current_state.count -= amount; } Message::Reset => { current_state.count = 0; } } current_state.last_updated = chrono::Utc::now().to_string(); } serde_json::to_vec(¤t_state).unwrap() } } bindings::export!(Component with_types_in bindings); }
Actor Manifest
Configure your actor in actor.toml:
name = "counter-actor"
component_path = "target/wasm32-wasi/release/counter_actor.wasm"
[interface]
implements = "ntwk:theater/actor"
requires = []
[[handlers]]
type = "http-server"
config = { port = 8080 }
[logging]
level = "debug"
output = "stdout"
Adding HTTP Capabilities
Extend the actor to handle HTTP requests:
#![allow(unused)] fn main() { use bindings::exports::ntwk::theater::http_server::Guest as HttpGuest; use bindings::ntwk::theater::http_server::{HttpRequest, HttpResponse}; impl HttpGuest for Component { fn handle_request(req: HttpRequest, state: Json) -> (HttpResponse, Json) { match (req.method.as_str(), req.path.as_str()) { // Get current count ("GET", "/count") => { let current_state: State = serde_json::from_slice(&state).unwrap(); (HttpResponse { status: 200, headers: vec![ ("Content-Type".to_string(), "application/json".to_string()) ], body: Some(serde_json::json!({ "count": current_state.count, "last_updated": current_state.last_updated }).to_string().into_bytes()), }, state) }, // Increment count ("POST", "/increment") => { if let Some(body) = req.body { if let Ok(increment) = serde_json::from_slice::<serde_json::Value>(&body) { let amount = increment["amount"].as_i64().unwrap_or(1) as i32; let evt = Event { event_type: "increment".to_string(), parent: None, data: serde_json::json!({ "type": "Increment", "amount": amount }).to_string().into_bytes(), }; let new_state = Component::handle(evt, state); return (HttpResponse { status: 200, headers: vec![ ("Content-Type".to_string(), "application/json".to_string()) ], body: Some(b"{"status":"ok"}".to_vec()), }, new_state); } } (HttpResponse { status: 400, headers: vec![], body: Some(b"{"error":"invalid request"}".to_vec()), }, state) }, _ => (HttpResponse { status: 404, headers: vec![], body: None, }, state) } } } }
Adding WebSocket Support
Enable real-time updates with WebSocket support:
#![allow(unused)] fn main() { use bindings::exports::ntwk::theater::websocket_server::Guest as WebSocketGuest; use bindings::ntwk::theater::websocket_server::{ WebSocketMessage, WebSocketResponse, MessageType }; impl WebSocketGuest for Component { fn handle_message(msg: WebSocketMessage, state: Json) -> (Json, WebSocketResponse) { match msg.ty { MessageType::Text => { if let Some(text) = msg.text { // Parse command if let Ok(command) = serde_json::from_str::<serde_json::Value>(&text) { match command["action"].as_str() { Some("subscribe") => { // Send current state let current_state: State = serde_json::from_slice(&state).unwrap(); return (state, WebSocketResponse { messages: vec![WebSocketMessage { ty: MessageType::Text, text: Some(serde_json::json!({ "type": "update", "count": current_state.count }).to_string()), data: None, }] }); }, _ => {} } } } }, _ => {} } (state, WebSocketResponse { messages: vec![] }) } } }
Using Host Functions
Theater provides several host functions for common operations:
#![allow(unused)] fn main() { use bindings::ntwk::theater::runtime::{log, spawn}; use bindings::ntwk::theater::filesystem::read_file; // Logging log("Actor processing message..."); // Spawn another actor spawn("other-actor.toml"); // Read a file let content = read_file("config.json"); }
State Management Best Practices
- Use Strong Typing
#![allow(unused)] fn main() { #[derive(Serialize, Deserialize)] struct State { data: HashMap<String, Value>, metadata: Metadata, updated_at: DateTime<Utc>, } #[derive(Serialize, Deserialize)] struct Metadata { version: u32, owner: String, } }
- Handle Errors Gracefully
#![allow(unused)] fn main() { fn handle(evt: Event, state: Json) -> Json { let current_state: State = match serde_json::from_slice(&state) { Ok(state) => state, Err(e) => { log(&format!("Error parsing state: {}", e)); return state; // Return unchanged state on error } }; // Process event... } }
- Include Timestamps
#![allow(unused)] fn main() { fn update_state(mut state: State) -> State { state.updated_at = chrono::Utc::now(); state } }
Testing
Create tests for your actor:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; #[test] fn test_increment() { let state = State { count: 0, last_updated: chrono::Utc::now().to_string(), }; let event = Event { event_type: "increment".to_string(), parent: None, data: serde_json::json!({ "type": "Increment", "amount": 5 }).to_string().into_bytes(), }; let state_json = serde_json::to_vec(&state).unwrap(); let new_state_json = Component::handle(event, state_json); let new_state: State = serde_json::from_slice(&new_state_json).unwrap(); assert_eq!(new_state.count, 5); } } }
Advanced Patterns
1. State History
#![allow(unused)] fn main() { #[derive(Serialize, Deserialize)] struct State { current: StateData, history: VecDeque<StateChange>, } #[derive(Serialize, Deserialize)] struct StateChange { timestamp: DateTime<Utc>, change_type: String, previous_value: Value, } }
2. Event Correlation
#![allow(unused)] fn main() { #[derive(Serialize, Deserialize)] struct Event { id: String, correlation_id: Option<String>, causation_id: Option<String>, data: Value, } }
3. Validation Chain
#![allow(unused)] fn main() { fn validate_state(state: &State) -> Result<(), String> { validate_constraints(state)?; validate_relationships(state)?; validate_business_rules(state)?; Ok(()) } }
Development Tips
- Use the runtime log function liberally
- Test with different message types
- Verify state transitions
- Handle all error cases
- Monitor the hash chain
- Test all handler interfaces
Common Pitfalls
-
Not Handling JSON Errors
- Always handle deserialization errors
- Validate JSON structure
- Handle missing fields
-
State Inconsistency
- Validate state after changes
- Keep state updates atomic
- Handle partial updates
-
Missing Error Logging
- Log all errors
- Include context
- Track error patterns
-
Resource Management
- Clean up resources
- Handle timeouts
- Monitor memory usage