Skip to main content

host_extensions/
events.rs

1//! Canonical event names and payload types for `__hostPush` events.
2//!
3//! Both host-rs (native) and dotli (WASM) must use these names and payload
4//! shapes when pushing events to SPAs.  Defining them here ensures that any
5//! SPA running on either host sees identical JSON.
6
7use serde::Serialize;
8
9use crate::executor_contract::{MeshControlEnvelope, MeshObjectReadReason};
10
11// ── Data events ─────────────────────────────────────────────────────────
12
13pub const DATA_CONNECTED: &str = "dataConnected";
14pub const DATA_MESSAGE: &str = "dataMessage";
15pub const DATA_BINARY: &str = "dataBinary";
16pub const DATA_CLOSED: &str = "dataClosed";
17pub const DATA_ERROR: &str = "dataError";
18pub const DATA_INCOMING_CALL: &str = "dataIncomingCall";
19
20#[derive(Debug, Serialize)]
21#[serde(rename_all = "camelCase")]
22pub struct DataConnectedPayload {
23    pub conn_id: u64,
24    pub peer: String,
25}
26
27#[derive(Debug, Serialize)]
28#[serde(rename_all = "camelCase")]
29pub struct DataMessagePayload {
30    pub conn_id: u64,
31    pub data: String,
32}
33
34#[derive(Debug, Serialize)]
35#[serde(rename_all = "camelCase")]
36pub struct DataBinaryPayload {
37    pub conn_id: u64,
38    pub data_base64: String,
39}
40
41#[derive(Debug, Serialize)]
42#[serde(rename_all = "camelCase")]
43pub struct DataClosedPayload {
44    pub conn_id: u64,
45    pub reason: String,
46}
47
48#[derive(Debug, Serialize)]
49#[serde(rename_all = "camelCase")]
50pub struct DataErrorPayload {
51    pub conn_id: u64,
52    pub error: String,
53}
54
55#[derive(Debug, Serialize)]
56#[serde(rename_all = "camelCase")]
57pub struct DataIncomingCallPayload {
58    pub conn_id: u64,
59    pub peer: String,
60}
61
62// ── Media events ────────────────────────────────────────────────────────
63
64pub const MEDIA_TRACK_READY: &str = "mediaTrackReady";
65pub const MEDIA_CONNECTED: &str = "mediaConnected";
66pub const MEDIA_REMOTE_TRACK: &str = "mediaRemoteTrack";
67pub const MEDIA_CLOSED: &str = "mediaClosed";
68pub const MEDIA_ERROR: &str = "mediaError";
69pub const MEDIA_INCOMING_CALL: &str = "mediaIncomingCall";
70pub const MEDIA_SIGNALING_PROGRESS: &str = "mediaSignalingProgress";
71pub const MEDIA_TRACK_STOPPED: &str = "mediaTrackStopped";
72
73#[derive(Debug, Serialize)]
74#[serde(rename_all = "camelCase")]
75pub struct MediaTrackReadyPayload {
76    pub track_id: u64,
77    pub kind: String,
78}
79
80#[derive(Debug, Serialize)]
81#[serde(rename_all = "camelCase")]
82pub struct MediaConnectedPayload {
83    pub session_id: u64,
84    pub peer: String,
85}
86
87#[derive(Debug, Serialize)]
88#[serde(rename_all = "camelCase")]
89pub struct MediaRemoteTrackPayload {
90    pub session_id: u64,
91    pub track_id: u64,
92    pub kind: String,
93}
94
95#[derive(Debug, Serialize)]
96#[serde(rename_all = "camelCase")]
97pub struct MediaClosedPayload {
98    pub session_id: u64,
99    pub reason: String,
100}
101
102#[derive(Debug, Serialize)]
103#[serde(rename_all = "camelCase")]
104pub struct MediaErrorPayload {
105    pub session_id: u64,
106    pub error: String,
107}
108
109#[derive(Debug, Serialize)]
110#[serde(rename_all = "camelCase")]
111pub struct MediaIncomingCallPayload {
112    pub peer: String,
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub peer_address: Option<String>,
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub group_id: Option<String>,
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub group_size: Option<u32>,
119}
120
121#[derive(Debug, Serialize)]
122#[serde(rename_all = "camelCase")]
123pub struct MediaSignalingProgressPayload {
124    pub session_id: u64,
125    pub stage: String,
126}
127
128/// Fired when a track is stopped, either programmatically or by the user
129/// dismissing a screen share via the browser's native share picker.
130///
131/// `session_id` is 0 for tracks not associated with an active session.
132#[derive(Debug, Serialize)]
133#[serde(rename_all = "camelCase")]
134pub struct MediaTrackStoppedPayload {
135    pub track_id: u64,
136    pub kind: String,
137    pub session_id: u64,
138}
139
140// ── Statement events ────────────────────────────────────────────────────
141
142pub const STATEMENT: &str = "statement";
143
144/// Payload for the `statement` push event.
145///
146/// Hosts MUST include `timestamp_ms` — it is the millisecond Unix epoch
147/// timestamp from the statement store, not the local receive time.
148#[derive(Debug, Serialize)]
149#[serde(rename_all = "camelCase")]
150pub struct StatementPayload {
151    pub author: String,
152    pub channel: String,
153    pub data: String,
154    pub timestamp_ms: u64,
155}
156
157// ── Mesh events ─────────────────────────────────────────────────────────
158
159pub const MESH_TOPIC: &str = "meshTopic";
160pub const MESH_QUERY: &str = "meshQuery";
161pub const MESH_REPLY: &str = "meshReply";
162pub const MESH_PRESENCE: &str = "meshPresence";
163pub const MESH_PRIVATE_CONTROL: &str = "meshPrivateControl";
164pub const MESH_PRIVATE_RECEIPT: &str = "meshPrivateReceipt";
165
166#[derive(Debug, Serialize)]
167#[serde(rename_all = "camelCase")]
168pub struct MeshTopicPayload {
169    pub topic: String,
170    pub data_base64: String,
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub author: Option<String>,
173}
174
175#[derive(Debug, Serialize)]
176#[serde(rename_all = "camelCase")]
177pub struct MeshQueryPayload {
178    pub request_id: String,
179    pub path: String,
180    pub data_base64: String,
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub author: Option<String>,
183}
184
185#[derive(Debug, Serialize)]
186#[serde(rename_all = "camelCase")]
187pub struct MeshReplyPayload {
188    pub request_id: String,
189    pub data_base64: Option<String>,
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub reason: Option<MeshObjectReadReason>,
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub expires_at_ms: Option<u64>,
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub author: Option<String>,
196}
197
198#[derive(Debug, Serialize)]
199#[serde(rename_all = "camelCase")]
200pub struct MeshPresencePayload {
201    pub peer_id: String,
202    pub state: String,
203}
204
205#[derive(Debug, Serialize)]
206#[serde(rename_all = "camelCase")]
207pub struct MeshPrivateControlPayload {
208    pub capability: String,
209    pub envelope: MeshControlEnvelope,
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub author: Option<String>,
212}
213
214#[derive(Debug, Serialize)]
215#[serde(rename_all = "camelCase")]
216pub struct MeshPrivateReceiptPayload {
217    pub capability: String,
218    pub data_base64: String,
219    #[serde(skip_serializing_if = "Option::is_none")]
220    pub author: Option<String>,
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    fn json(v: &impl serde::Serialize) -> String {
228        serde_json::to_string(v).unwrap()
229    }
230
231    #[test]
232    fn data_connected_json_shape() {
233        let p = DataConnectedPayload {
234            conn_id: 1,
235            peer: "alice".into(),
236        };
237        assert_eq!(json(&p), r#"{"connId":1,"peer":"alice"}"#);
238    }
239
240    #[test]
241    fn data_message_json_shape() {
242        let p = DataMessagePayload {
243            conn_id: 2,
244            data: "hello".into(),
245        };
246        assert_eq!(json(&p), r#"{"connId":2,"data":"hello"}"#);
247    }
248
249    #[test]
250    fn data_binary_json_shape() {
251        let p = DataBinaryPayload {
252            conn_id: 3,
253            data_base64: "AQID".into(),
254        };
255        assert_eq!(json(&p), r#"{"connId":3,"dataBase64":"AQID"}"#);
256    }
257
258    #[test]
259    fn data_closed_json_shape() {
260        let p = DataClosedPayload {
261            conn_id: 4,
262            reason: "done".into(),
263        };
264        assert_eq!(json(&p), r#"{"connId":4,"reason":"done"}"#);
265    }
266
267    #[test]
268    fn data_error_json_shape() {
269        let p = DataErrorPayload {
270            conn_id: 5,
271            error: "fail".into(),
272        };
273        assert_eq!(json(&p), r#"{"connId":5,"error":"fail"}"#);
274    }
275
276    #[test]
277    fn data_incoming_call_json_shape() {
278        let p = DataIncomingCallPayload {
279            conn_id: 6,
280            peer: "bob".into(),
281        };
282        assert_eq!(json(&p), r#"{"connId":6,"peer":"bob"}"#);
283    }
284
285    #[test]
286    fn media_track_ready_json_shape() {
287        let p = MediaTrackReadyPayload {
288            track_id: 1,
289            kind: "video".into(),
290        };
291        assert_eq!(json(&p), r#"{"trackId":1,"kind":"video"}"#);
292    }
293
294    #[test]
295    fn media_connected_json_shape() {
296        let p = MediaConnectedPayload {
297            session_id: 2,
298            peer: "carol".into(),
299        };
300        assert_eq!(json(&p), r#"{"sessionId":2,"peer":"carol"}"#);
301    }
302
303    #[test]
304    fn media_remote_track_json_shape() {
305        let p = MediaRemoteTrackPayload {
306            session_id: 3,
307            track_id: 10,
308            kind: "audio".into(),
309        };
310        assert_eq!(json(&p), r#"{"sessionId":3,"trackId":10,"kind":"audio"}"#);
311    }
312
313    #[test]
314    fn media_closed_json_shape() {
315        let p = MediaClosedPayload {
316            session_id: 4,
317            reason: "hangup".into(),
318        };
319        assert_eq!(json(&p), r#"{"sessionId":4,"reason":"hangup"}"#);
320    }
321
322    #[test]
323    fn media_error_json_shape() {
324        let p = MediaErrorPayload {
325            session_id: 5,
326            error: "timeout".into(),
327        };
328        assert_eq!(json(&p), r#"{"sessionId":5,"error":"timeout"}"#);
329    }
330
331    #[test]
332    fn media_incoming_call_json_shape() {
333        let p = MediaIncomingCallPayload {
334            peer: "dave".into(),
335            peer_address: None,
336            group_id: None,
337            group_size: None,
338        };
339        assert_eq!(json(&p), r#"{"peer":"dave"}"#);
340    }
341
342    #[test]
343    fn media_incoming_call_group_json_shape() {
344        let p = MediaIncomingCallPayload {
345            peer: "alice".into(),
346            peer_address: Some("alice".into()),
347            group_id: Some("grp1".into()),
348            group_size: Some(3),
349        };
350        assert_eq!(
351            json(&p),
352            r#"{"peer":"alice","peerAddress":"alice","groupId":"grp1","groupSize":3}"#
353        );
354    }
355
356    #[test]
357    fn media_incoming_call_1_to_1_omits_group_fields() {
358        let p = MediaIncomingCallPayload {
359            peer: "bob".into(),
360            peer_address: None,
361            group_id: None,
362            group_size: None,
363        };
364        assert_eq!(json(&p), r#"{"peer":"bob"}"#);
365    }
366
367    #[test]
368    fn media_signaling_progress_json_shape() {
369        let p = MediaSignalingProgressPayload {
370            session_id: 7,
371            stage: "ice".into(),
372        };
373        assert_eq!(json(&p), r#"{"sessionId":7,"stage":"ice"}"#);
374    }
375
376    #[test]
377    fn media_track_stopped_json_shape() {
378        let p = MediaTrackStoppedPayload {
379            track_id: 42,
380            kind: "screen".into(),
381            session_id: 0,
382        };
383        assert_eq!(json(&p), r#"{"trackId":42,"kind":"screen","sessionId":0}"#);
384    }
385
386    #[test]
387    fn statement_json_shape() {
388        let p = StatementPayload {
389            author: "alice".into(),
390            channel: "chat".into(),
391            data: "hello".into(),
392            timestamp_ms: 1710000000000,
393        };
394        assert_eq!(
395            json(&p),
396            r#"{"author":"alice","channel":"chat","data":"hello","timestampMs":1710000000000}"#
397        );
398    }
399
400    #[test]
401    fn mesh_topic_json_shape() {
402        let payload = MeshTopicPayload {
403            topic: "room/1".into(),
404            data_base64: "AQID".into(),
405            author: Some("alice".into()),
406        };
407        assert_eq!(
408            json(&payload),
409            r#"{"topic":"room/1","dataBase64":"AQID","author":"alice"}"#
410        );
411    }
412
413    #[test]
414    fn mesh_query_json_shape() {
415        let payload = MeshQueryPayload {
416            request_id: "req-1".into(),
417            path: "mesh/object/1".into(),
418            data_base64: "AQID".into(),
419            author: None,
420        };
421        assert_eq!(
422            json(&payload),
423            r#"{"requestId":"req-1","path":"mesh/object/1","dataBase64":"AQID"}"#
424        );
425    }
426
427    #[test]
428    fn mesh_reply_json_shape() {
429        let payload = MeshReplyPayload {
430            request_id: "req-1".into(),
431            data_base64: Some("AQID".into()),
432            reason: None,
433            expires_at_ms: None,
434            author: Some("bob".into()),
435        };
436        assert_eq!(
437            json(&payload),
438            r#"{"requestId":"req-1","dataBase64":"AQID","author":"bob"}"#
439        );
440    }
441
442    #[test]
443    fn mesh_reply_negative_json_shape() {
444        let payload = MeshReplyPayload {
445            request_id: "req-2".into(),
446            data_base64: None,
447            reason: Some(MeshObjectReadReason::Expired),
448            expires_at_ms: Some(1710000000000),
449            author: None,
450        };
451        assert_eq!(
452            json(&payload),
453            r#"{"requestId":"req-2","dataBase64":null,"reason":"expired","expiresAtMs":1710000000000}"#
454        );
455    }
456
457    #[test]
458    fn mesh_presence_json_shape() {
459        let payload = MeshPresencePayload {
460            peer_id: "peer-1".into(),
461            state: "up".into(),
462        };
463        assert_eq!(json(&payload), r#"{"peerId":"peer-1","state":"up"}"#);
464    }
465
466    #[test]
467    fn mesh_private_control_json_shape() {
468        let payload = MeshPrivateControlPayload {
469            capability: "mesh-private-capability-1".into(),
470            envelope: MeshControlEnvelope {
471                mode: crate::executor_contract::MeshControlMode::Encrypted,
472                data_base64: "AQID".into(),
473            },
474            author: Some("alice".into()),
475        };
476        assert_eq!(
477            json(&payload),
478            r#"{"capability":"mesh-private-capability-1","envelope":{"mode":"encrypted","dataBase64":"AQID"},"author":"alice"}"#
479        );
480    }
481
482    #[test]
483    fn mesh_private_receipt_json_shape() {
484        let payload = MeshPrivateReceiptPayload {
485            capability: "mesh-private-capability-1".into(),
486            data_base64: "AQID".into(),
487            author: None,
488        };
489        assert_eq!(
490            json(&payload),
491            r#"{"capability":"mesh-private-capability-1","dataBase64":"AQID"}"#
492        );
493    }
494}