Real-time state streaming with @StreamedState, @ObservedActor, stream resumption, filtering, and delta encoding...
Stream state changes from distributed actors to clients in realtime with automatic synchronization, reconnection, filtering, and bandwidth optimization.
Trebuchet's streaming feature allows distributed actors to expose reactive state that automatically updates all connected clients in realtime. This eliminates manual polling and provides a seamless, reactive experience with production-ready features like graceful reconnection, server-side filtering, and delta encoding.
Use the @StreamedState macro to make a property automatically notify subscribers:
@Trebuchet
public distributed actor TodoList {
@StreamedState public var state: State = State()
public distributed func addTodo(title: String) -> TodoItem {
let todo = TodoItem(title: title)
state.todos.append(todo) // Automatically notifies all subscribers
return todo
}
}
public struct State: Codable, Sendable {
var todos: [TodoItem] = []
}
Use @ObservedActor to automatically subscribe to state streams:
struct TodoListView: View {
@ObservedActor("todos", observe: \TodoList.observeState)
var state
var body: some View {
if let currentState = state {
List(currentState.todos) { todo in
Text(todo.title)
}
} else if $state.isConnecting {
ProgressView("Connecting...")
}
}
}
The @StreamedState macro transforms a property into a streaming state property with automatic change tracking. It generates:
_state_storage)_state_continuations)observeState())Example expansion:
// From:
@StreamedState var state: State = State()
// To:
private var _state_storage: State = State()
private var _state_continuations: [AsyncStream<State>.Continuation] = []
var state: State {
get { _state_storage }
set {
_state_storage = newValue
_notifyStateChange()
}
}
private func _notifyStateChange() {
for continuation in _state_continuations {
continuation.yield(_state_storage)
}
}
public func observeState() -> AsyncStream<State> {
AsyncStream { continuation in
_state_continuations.append(continuation)
continuation.yield(_state_storage) // Send initial state
continuation.onTermination = { [weak self] _ in
Task {
await self?._removeStateContinuation(continuation)
}
}
}
}
The @ObservedActor property wrapper provides:
$state.actor$state.isConnecting, $state.errorStreaming uses a multi-envelope protocol:
StreamStartEnvelope - Sent when stream is initiated
streamID: Unique identifier for this streamcallID: Correlates with the original invocationactorID: The actor being observedtargetIdentifier: The observe method nameStreamDataEnvelope - Sent for each state update
streamID: Stream identifiersequenceNumber: Monotonic counter for deduplicationdata: Encoded state valuetimestamp: When the update was generatedStreamEndEnvelope - Sent when stream completes
streamID: Stream identifierreason: Why the stream ended (completed, error, etc.)StreamErrorEnvelope - Sent on error
streamID: Stream identifiererrorMessage: Error descriptionStreamResumeEnvelope - Sent by client to resume after reconnection
streamID: Stream to resumelastSequence: Last sequence number receivedactorID: The actor to observetargetIdentifier: The observe method nameClient Server
ā ā
āā InvocationEnvelope āāāāāāāā>ā (call observeState())
ā callID: abc-123 ā
ā target: "observeState" ā
ā ā
ā<ā StreamStartEnvelope āāāāāāāā⤠(stream initiated)
ā streamID: xyz-789 ā
ā callID: abc-123 ā
ā ā
ā<ā StreamDataEnvelope āāāāāāāāā⤠(initial state)
ā streamID: xyz-789 ā
ā sequenceNumber: 1 ā
ā ā
ā [state changes on server] ā
ā ā
ā<ā StreamDataEnvelope āāāāāāāāā⤠(updated state)
ā streamID: xyz-789 ā
ā sequenceNumber: 2 ā
Implementation Status: ā Fully Implemented
Gracefully handles connection loss with automatic stream resumption, ensuring clients don't miss updates during brief disconnections.
Normal Operation:
On Disconnection:
On Reconnection:
// Server-side: Configure buffer size and TTL
let server = TrebuchetServer(/* ... */)
// Default: maxBufferSize: 100, ttl: 300 seconds
// For AWS Lambda
let handler = WebSocketLambdaHandler(/* ... */)
// Default: maxBufferSize: 100, ttl: 300 seconds
Client loses connection at sequence 42
Client reconnects 30 seconds later
Client ā Server: StreamResumeEnvelope {
streamID: xyz-789
lastSequence: 42
actorID: "todos"
targetIdentifier: "observeState"
}
Server checks buffer:
- Has sequences: 43, 44, 45, 46
Server ā Client: StreamDataEnvelope (seq: 43)
Server ā Client: StreamDataEnvelope (seq: 44)
Server ā Client: StreamDataEnvelope (seq: 45)
Server ā Client: StreamDataEnvelope (seq: 46)
Client now caught up!
For serverless deployments, buffer replay works when the same Lambda container handles reconnection (common due to warm containers). If a different container handles the request, the stream restarts from current state. This is a correct fallback behavior with no data loss.
Implementation Status: ā Fully Implemented
Server-side filtering reduces bandwidth and client-side processing by only sending relevant updates.
Only sends updates when the value actually changes from the previous value.
// Client subscribes with changed filter
let filter = StreamFilter.predefined("changed")
let stream = await todoList.observeState(filter: filter)
// Only receives updates when state changes (bytewise comparison)
Only sends updates for non-empty collections, strings, or dictionaries.
// Only receive updates when list has items
let filter = StreamFilter.predefined("nonEmpty")
let stream = await todoList.observeState(filter: filter)
Only sends updates when numeric values cross a threshold.
// Only receive when count exceeds 100
let filter = StreamFilter.predefined("threshold", parameters: [
"value": "100",
"comparison": "gt", // gt, gte, lt, lte, eq, neq
"field": "count" // optional: for nested values
])
let stream = await counter.observeState(filter: filter)
Supported comparisons:
gt or >: Greater thangte or >=: Greater than or equallt or <: Less thanlte or <=: Less than or equaleq or ==: Equalneq or !=: Not equalSends only changed fields to optimize bandwidth for large state objects.
Server Side:
Client Side:
// Make state support delta encoding
extension TodoList.State: DeltaCodable {
func delta(from previous: TodoList.State) -> TodoList.State? {
// Only include changed todos
let changedTodos = todos.filter { todo in
!previous.todos.contains(todo)
}
guard !changedTodos.isEmpty || pendingCount != previous.pendingCount else {
return nil // No changes
}
return State(todos: changedTodos, pendingCount: pendingCount)
}
func applying(delta: TodoList.State) -> TodoList.State {
var updated = self
// Merge changed todos
for todo in delta.todos {
if let index = updated.todos.firstIndex(where: { $0.id == todo.id }) {
updated.todos[index] = todo
} else {
updated.todos.append(todo)
}
}
updated.pendingCount = delta.pendingCount
return updated
}
}
// Server uses delta manager
let manager = DeltaStreamManager<TodoList.State>()
let delta = try await manager.encode(newState)
// Automatically sends delta when possible
// Client applies deltas
let applier = DeltaStreamApplier<TodoList.State>()
let currentState = try await applier.apply(delta)
@Trebuchet
public distributed actor GameServer {
@StreamedState public var gameState: GameState = GameState()
@StreamedState public var metrics: Metrics = Metrics()
// Macro generates:
// - observeGameState() -> AsyncStream<GameState>
// - observeMetrics() -> AsyncStream<Metrics>
}
let client = TrebuchetClient(transport: .webSocket(host: "localhost", port: 8080))
try await client.connect()
let todoList = try client.resolve(TodoList.self, id: "todos")
let stream = await todoList.observeState()
for await state in stream {
print("Todos: \(state.todos.count)")
}
struct GameView: View {
@ObservedActor("game", observe: \GameServer.observeGameState)
var gameState
@ObservedActor("game", observe: \GameServer.observeMetrics)
var metrics
var body: some View {
if let state = gameState, let metrics = metrics {
VStack {
Text("Score: \(state.score)")
Text("Players: \(metrics.playerCount)")
Button("Next Level") {
Task {
try? await $gameState.actor?.advanceLevel()
}
}
}
} else if $gameState.isConnecting {
ProgressView("Connecting...")
}
}
}
Seamlessly integrate persistent state storage with realtime streaming for serverless deployments.
Combines persistent state storage with automatic streaming updates:
import Trebuchet
import TrebuchetCloud
@Trebuchet
public distributed actor TodoList: StatefulStreamingActor {
public typealias PersistentState = State
private let stateStore: ActorStateStore
@StreamedState public var state = State()
public var persistentState: State {
get { state }
set { state = newValue }
}
public init(
actorSystem: TrebuchetActorSystem,
stateStore: ActorStateStore
) async throws {
self.actorSystem = actorSystem
self.stateStore = stateStore
try await loadState(from: stateStore)
}
public func loadState(from store: any ActorStateStore) async throws {
if let loaded = try await store.load(for: id.id, as: State.self) {
state = loaded // Triggers stream update to all clients
}
}
public func saveState(to store: any ActorStateStore) async throws {
try await store.save(state, for: id.id)
}
public distributed func addTodo(title: String) async throws -> TodoItem {
let todo = TodoItem(title: title)
var newState = state
newState.todos.append(todo)
state = newState // 1. Streams to all clients
try await saveState(to: stateStore) // 2. Persists to storage
return todo
}
}
The StatefulStreamingActor protocol provides convenience methods:
// Single field updates
try await updateState(\.count, to: state.count + 1, store: stateStore)
// Complex transformations
public distributed func completeTodo(_ id: UUID) async throws {
try await transformState(store: stateStore) { currentState in
var newState = currentState
if let index = newState.todos.firstIndex(where: { $0.id == id }) {
newState.todos[index].completed = true
}
newState.lastUpdated = Date()
return newState
}
// Automatically streams AND persists
}
Synchronize actor state across multiple instances using database change streams.
Implementation Status: ā Fully Implemented
Complete PostgreSQL integration with state storage and LISTEN/NOTIFY for multi-instance synchronization.
import TrebuchetPostgreSQL
// State Store for actor persistence
let stateStore = try await PostgreSQLStateStore(
host: "localhost",
database: "trebuchet",
username: "postgres",
password: "password"
)
// Stream Adapter for multi-instance synchronization
let adapter = try await PostgreSQLStreamAdapter(
host: "localhost",
database: "trebuchet",
username: "postgres"
)
let notificationStream = try await adapter.start()
// Process state change notifications
for await change in notificationStream {
print("Actor \(change.actorID) updated to sequence \(change.sequenceNumber)")
// Reload actor state from PostgreSQL
try await reloadActor(id: change.actorID)
}
Problem: Views don't update when state changes
Solutions:
@StreamedStateProblem: $state.isConnecting stays true
Solutions:
$state.errorProblem: "Cannot find 'observeState' in scope"
Solutions:
@Trebuchet macro is applied to actor@StreamedState is applied to property@ObservedActor documentation