Skip to main content

hypen_engine/serialize/
remote.rs

1use crate::reconcile::Patch;
2use serde::{Deserialize, Serialize};
3
4/// Wire protocol messages for Remote UI (server-driven rendering).
5///
6/// Remote UI allows the Hypen engine to run on a server while a thin client
7/// (browser, mobile app, embedded device) renders the UI. Communication is
8/// bidirectional over WebSocket, SSE, or any ordered transport.
9///
10/// # Message Flow
11///
12/// ```text
13/// Server                           Client
14///   │                                │
15///   │──── InitialTree ──────────────>│  (full tree + state on connect)
16///   │                                │
17///   │──── Patch ─────────────────────>│  (incremental updates)
18///   │                                │
19///   │<─── DispatchAction ────────────│  (user interaction)
20///   │                                │
21///   │──── StateUpdate ──────────────>│  (state sync after action)
22///   │──── Patch ─────────────────────>│  (resulting UI changes)
23/// ```
24///
25/// # Revision Tracking
26///
27/// Each `InitialTree` and `PatchStream` carries a monotonically increasing
28/// `revision` number. Clients should:
29/// - Apply patches in revision order
30/// - Detect gaps (missed patches) and request a full `InitialTree`
31/// - Ignore patches with a revision ≤ the last applied revision
32///
33/// # Integrity Hashing
34///
35/// The optional `hash` field on `InitialTree` and `PatchStream` enables
36/// end-to-end integrity verification. When present, clients can hash their
37/// tree state and compare to detect corruption or missed patches.
38///
39/// # Serialization
40///
41/// Messages serialize with a `"type"` discriminator in camelCase:
42///
43/// ```json
44/// {"type": "initialTree", "module": "App", "state": {...}, "patches": [...], "revision": 0}
45/// {"type": "patch", "module": "App", "patches": [...], "revision": 1}
46/// {"type": "dispatchAction", "module": "App", "action": "click", "payload": null}
47/// {"type": "stateUpdate", "module": "App", "state": {...}}
48/// ```
49#[derive(Debug, Clone, Serialize, Deserialize)]
50#[serde(tag = "type", rename_all = "camelCase")]
51pub enum RemoteMessage {
52    /// Full tree snapshot sent when a client first connects.
53    ///
54    /// Contains the complete state and the full patch sequence needed to
55    /// construct the tree from scratch.
56    InitialTree(InitialTree),
57
58    /// Incremental patch stream for state-driven updates.
59    Patch(PatchStream),
60
61    /// Action dispatched from the client (user interaction).
62    ///
63    /// The server should route this to the module's action handler and
64    /// respond with a `StateUpdate` + `Patch` if state changed.
65    DispatchAction {
66        /// Target module name
67        module: String,
68        /// Action name (e.g. `"increment"`, `"submit"`)
69        action: String,
70        /// Optional action payload
71        payload: Option<serde_json::Value>,
72    },
73
74    /// State update pushed from the server to the client.
75    ///
76    /// Sent after an action handler modifies state, so the client can
77    /// keep its local state cache in sync.
78    StateUpdate {
79        module: String,
80        state: serde_json::Value,
81    },
82}
83
84/// Full tree snapshot sent to newly-connected clients.
85///
86/// Contains everything a client needs to render the initial UI: the module
87/// state, the complete patch sequence, and a revision baseline.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct InitialTree {
90    /// Module name
91    pub module: String,
92
93    /// Initial state snapshot
94    pub state: serde_json::Value,
95
96    /// Initial patches to construct the tree
97    pub patches: Vec<Patch>,
98
99    /// Revision number (starts at 0)
100    pub revision: u64,
101
102    /// Optional integrity hash
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub hash: Option<String>,
105}
106
107impl InitialTree {
108    pub fn new(module: String, state: serde_json::Value, patches: Vec<Patch>) -> Self {
109        Self {
110            module,
111            state,
112            patches,
113            revision: 0,
114            hash: None,
115        }
116    }
117
118    pub fn with_hash(mut self, hash: String) -> Self {
119        self.hash = Some(hash);
120        self
121    }
122}
123
124/// Incremental patch stream for state-driven UI updates.
125///
126/// Sent from server to client after each state change. The `revision`
127/// field enables ordering and gap detection.
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct PatchStream {
130    /// Module name
131    pub module: String,
132
133    /// Patches to apply
134    pub patches: Vec<Patch>,
135
136    /// Revision number (monotonically increasing)
137    pub revision: u64,
138
139    /// Optional integrity hash
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub hash: Option<String>,
142}
143
144impl PatchStream {
145    pub fn new(module: String, patches: Vec<Patch>, revision: u64) -> Self {
146        Self {
147            module,
148            patches,
149            revision,
150            hash: None,
151        }
152    }
153
154    pub fn with_hash(mut self, hash: String) -> Self {
155        self.hash = Some(hash);
156        self
157    }
158}
159
160/// Helper to serialize messages to JSON
161pub fn serialize_message(message: &RemoteMessage) -> Result<String, serde_json::Error> {
162    serde_json::to_string(message)
163}
164
165/// Helper to deserialize messages from JSON
166pub fn deserialize_message(json: &str) -> Result<RemoteMessage, serde_json::Error> {
167    serde_json::from_str(json)
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn test_serialize_initial_tree() {
176        let initial = InitialTree::new(
177            "TestModule".to_string(),
178            serde_json::json!({"count": 0}),
179            vec![],
180        );
181
182        let message = RemoteMessage::InitialTree(initial);
183        let json = serialize_message(&message).unwrap();
184
185        assert!(json.contains("initialTree"));
186        assert!(json.contains("TestModule"));
187    }
188
189    #[test]
190    fn test_roundtrip() {
191        let message = RemoteMessage::DispatchAction {
192            module: "Test".to_string(),
193            action: "increment".to_string(),
194            payload: Some(serde_json::json!({"amount": 1})),
195        };
196
197        let json = serialize_message(&message).unwrap();
198        let deserialized = deserialize_message(&json).unwrap();
199
200        match deserialized {
201            RemoteMessage::DispatchAction { module, action, .. } => {
202                assert_eq!(module, "Test");
203                assert_eq!(action, "increment");
204            }
205            _ => panic!("Wrong message type"),
206        }
207    }
208}