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// ── CRDT events ────────────────────────────────────────────────────────
215
216pub const CRDT_REMOTE_UPDATE: &str = "crdtRemoteUpdate";
217pub const CRDT_AWARENESS: &str = "crdtAwareness";
218pub const CRDT_PEER_CHANGE: &str = "crdtPeerChange";
219
220#[derive(Debug, Serialize)]
221#[serde(rename_all = "camelCase")]
222pub struct CrdtRemoteUpdatePayload {
223    pub room_id: String,
224    pub update_base64: String,
225}
226
227#[derive(Debug, Serialize)]
228#[serde(rename_all = "camelCase")]
229pub struct MeshPrivateReceiptPayload {
230    pub capability: String,
231    pub data_base64: String,
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub author: Option<String>,
234}
235
236#[derive(Debug, Serialize)]
237#[serde(rename_all = "camelCase")]
238pub struct CrdtAwarenessPayload {
239    pub room_id: String,
240    pub client_id: u64,
241    /// JSON-encoded ephemeral awareness/presence state (e.g., cursor position,
242    /// selection range). Consumers should parse this as JSON.
243    pub state: String,
244}
245
246#[derive(Debug, Serialize)]
247#[serde(rename_all = "camelCase")]
248pub struct CrdtPeerChangePayload {
249    pub room_id: String,
250    pub peers: Vec<String>,
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    fn json(v: &impl serde::Serialize) -> String {
258        serde_json::to_string(v).unwrap()
259    }
260
261    #[test]
262    fn data_connected_json_shape() {
263        let p = DataConnectedPayload {
264            conn_id: 1,
265            peer: "alice".into(),
266        };
267        assert_eq!(json(&p), r#"{"connId":1,"peer":"alice"}"#);
268    }
269
270    #[test]
271    fn data_message_json_shape() {
272        let p = DataMessagePayload {
273            conn_id: 2,
274            data: "hello".into(),
275        };
276        assert_eq!(json(&p), r#"{"connId":2,"data":"hello"}"#);
277    }
278
279    #[test]
280    fn data_binary_json_shape() {
281        let p = DataBinaryPayload {
282            conn_id: 3,
283            data_base64: "AQID".into(),
284        };
285        assert_eq!(json(&p), r#"{"connId":3,"dataBase64":"AQID"}"#);
286    }
287
288    #[test]
289    fn data_closed_json_shape() {
290        let p = DataClosedPayload {
291            conn_id: 4,
292            reason: "done".into(),
293        };
294        assert_eq!(json(&p), r#"{"connId":4,"reason":"done"}"#);
295    }
296
297    #[test]
298    fn data_error_json_shape() {
299        let p = DataErrorPayload {
300            conn_id: 5,
301            error: "fail".into(),
302        };
303        assert_eq!(json(&p), r#"{"connId":5,"error":"fail"}"#);
304    }
305
306    #[test]
307    fn data_incoming_call_json_shape() {
308        let p = DataIncomingCallPayload {
309            conn_id: 6,
310            peer: "bob".into(),
311        };
312        assert_eq!(json(&p), r#"{"connId":6,"peer":"bob"}"#);
313    }
314
315    #[test]
316    fn media_track_ready_json_shape() {
317        let p = MediaTrackReadyPayload {
318            track_id: 1,
319            kind: "video".into(),
320        };
321        assert_eq!(json(&p), r#"{"trackId":1,"kind":"video"}"#);
322    }
323
324    #[test]
325    fn media_connected_json_shape() {
326        let p = MediaConnectedPayload {
327            session_id: 2,
328            peer: "carol".into(),
329        };
330        assert_eq!(json(&p), r#"{"sessionId":2,"peer":"carol"}"#);
331    }
332
333    #[test]
334    fn media_remote_track_json_shape() {
335        let p = MediaRemoteTrackPayload {
336            session_id: 3,
337            track_id: 10,
338            kind: "audio".into(),
339        };
340        assert_eq!(json(&p), r#"{"sessionId":3,"trackId":10,"kind":"audio"}"#);
341    }
342
343    #[test]
344    fn media_closed_json_shape() {
345        let p = MediaClosedPayload {
346            session_id: 4,
347            reason: "hangup".into(),
348        };
349        assert_eq!(json(&p), r#"{"sessionId":4,"reason":"hangup"}"#);
350    }
351
352    #[test]
353    fn media_error_json_shape() {
354        let p = MediaErrorPayload {
355            session_id: 5,
356            error: "timeout".into(),
357        };
358        assert_eq!(json(&p), r#"{"sessionId":5,"error":"timeout"}"#);
359    }
360
361    #[test]
362    fn media_incoming_call_json_shape() {
363        let p = MediaIncomingCallPayload {
364            peer: "dave".into(),
365            peer_address: None,
366            group_id: None,
367            group_size: None,
368        };
369        assert_eq!(json(&p), r#"{"peer":"dave"}"#);
370    }
371
372    #[test]
373    fn media_incoming_call_group_json_shape() {
374        let p = MediaIncomingCallPayload {
375            peer: "alice".into(),
376            peer_address: Some("alice".into()),
377            group_id: Some("grp1".into()),
378            group_size: Some(3),
379        };
380        assert_eq!(
381            json(&p),
382            r#"{"peer":"alice","peerAddress":"alice","groupId":"grp1","groupSize":3}"#
383        );
384    }
385
386    #[test]
387    fn media_incoming_call_1_to_1_omits_group_fields() {
388        let p = MediaIncomingCallPayload {
389            peer: "bob".into(),
390            peer_address: None,
391            group_id: None,
392            group_size: None,
393        };
394        assert_eq!(json(&p), r#"{"peer":"bob"}"#);
395    }
396
397    #[test]
398    fn media_signaling_progress_json_shape() {
399        let p = MediaSignalingProgressPayload {
400            session_id: 7,
401            stage: "ice".into(),
402        };
403        assert_eq!(json(&p), r#"{"sessionId":7,"stage":"ice"}"#);
404    }
405
406    #[test]
407    fn media_track_stopped_json_shape() {
408        let p = MediaTrackStoppedPayload {
409            track_id: 42,
410            kind: "screen".into(),
411            session_id: 0,
412        };
413        assert_eq!(json(&p), r#"{"trackId":42,"kind":"screen","sessionId":0}"#);
414    }
415
416    #[test]
417    fn statement_json_shape() {
418        let p = StatementPayload {
419            author: "alice".into(),
420            channel: "chat".into(),
421            data: "hello".into(),
422            timestamp_ms: 1710000000000,
423        };
424        assert_eq!(
425            json(&p),
426            r#"{"author":"alice","channel":"chat","data":"hello","timestampMs":1710000000000}"#
427        );
428    }
429
430    #[test]
431    fn mesh_topic_json_shape() {
432        let payload = MeshTopicPayload {
433            topic: "room/1".into(),
434            data_base64: "AQID".into(),
435            author: Some("alice".into()),
436        };
437        assert_eq!(
438            json(&payload),
439            r#"{"topic":"room/1","dataBase64":"AQID","author":"alice"}"#
440        );
441    }
442
443    #[test]
444    fn mesh_query_json_shape() {
445        let payload = MeshQueryPayload {
446            request_id: "req-1".into(),
447            path: "mesh/object/1".into(),
448            data_base64: "AQID".into(),
449            author: None,
450        };
451        assert_eq!(
452            json(&payload),
453            r#"{"requestId":"req-1","path":"mesh/object/1","dataBase64":"AQID"}"#
454        );
455    }
456
457    #[test]
458    fn mesh_reply_json_shape() {
459        let payload = MeshReplyPayload {
460            request_id: "req-1".into(),
461            data_base64: Some("AQID".into()),
462            reason: None,
463            expires_at_ms: None,
464            author: Some("bob".into()),
465        };
466        assert_eq!(
467            json(&payload),
468            r#"{"requestId":"req-1","dataBase64":"AQID","author":"bob"}"#
469        );
470    }
471
472    #[test]
473    fn mesh_reply_negative_json_shape() {
474        let payload = MeshReplyPayload {
475            request_id: "req-2".into(),
476            data_base64: None,
477            reason: Some(MeshObjectReadReason::Expired),
478            expires_at_ms: Some(1710000000000),
479            author: None,
480        };
481        assert_eq!(
482            json(&payload),
483            r#"{"requestId":"req-2","dataBase64":null,"reason":"expired","expiresAtMs":1710000000000}"#
484        );
485    }
486
487    #[test]
488    fn mesh_presence_json_shape() {
489        let payload = MeshPresencePayload {
490            peer_id: "peer-1".into(),
491            state: "up".into(),
492        };
493        assert_eq!(json(&payload), r#"{"peerId":"peer-1","state":"up"}"#);
494    }
495
496    #[test]
497    fn mesh_private_control_json_shape() {
498        let payload = MeshPrivateControlPayload {
499            capability: "mesh-private-capability-1".into(),
500            envelope: MeshControlEnvelope {
501                mode: crate::executor_contract::MeshControlMode::Encrypted,
502                data_base64: "AQID".into(),
503            },
504            author: Some("alice".into()),
505        };
506        assert_eq!(
507            json(&payload),
508            r#"{"capability":"mesh-private-capability-1","envelope":{"mode":"encrypted","dataBase64":"AQID"},"author":"alice"}"#
509        );
510    }
511
512    #[test]
513    fn mesh_private_receipt_json_shape() {
514        let payload = MeshPrivateReceiptPayload {
515            capability: "mesh-private-capability-1".into(),
516            data_base64: "AQID".into(),
517            author: None,
518        };
519        assert_eq!(
520            json(&payload),
521            r#"{"capability":"mesh-private-capability-1","dataBase64":"AQID"}"#
522        );
523    }
524
525    #[test]
526    fn crdt_remote_update_json_shape() {
527        let p = CrdtRemoteUpdatePayload {
528            room_id: "doc-abc".into(),
529            update_base64: "AQID".into(),
530        };
531        assert_eq!(json(&p), r#"{"roomId":"doc-abc","updateBase64":"AQID"}"#);
532    }
533
534    #[test]
535    fn crdt_awareness_json_shape() {
536        let p = CrdtAwarenessPayload {
537            room_id: "doc-abc".into(),
538            client_id: 42,
539            state: r#"{"cursor":{"index":5}}"#.into(),
540        };
541        assert_eq!(
542            json(&p),
543            r#"{"roomId":"doc-abc","clientId":42,"state":"{\"cursor\":{\"index\":5}}"}"#
544        );
545    }
546
547    #[test]
548    fn crdt_peer_change_json_shape() {
549        let p = CrdtPeerChangePayload {
550            room_id: "doc-abc".into(),
551            peers: vec!["alice".into(), "bob".into()],
552        };
553        assert_eq!(json(&p), r#"{"roomId":"doc-abc","peers":["alice","bob"]}"#);
554    }
555
556    #[test]
557    fn crdt_peer_change_empty_peers_json_shape() {
558        let p = CrdtPeerChangePayload {
559            room_id: "doc-abc".into(),
560            peers: vec![],
561        };
562        assert_eq!(json(&p), r#"{"roomId":"doc-abc","peers":[]}"#);
563    }
564}