Skip to main content

gbp_stack_wasm/
lib.rs

1//! Browser/WASM bindings for the Group Protocol Stack.
2//!
3//! Exported classes: [`MlsContext`], [`GroupNode`], [`GtpClient`].
4//! The default wasm-pack init function is automatically generated.
5
6#![cfg(target_arch = "wasm32")]
7
8use gbp_core::{MemberId, PayloadCodec, StreamType};
9use gbp_node::{Event, GroupNode as RustGroupNode, Sealer};
10use gtp::{GtpAccept, GtpClient as RustGtpClient};
11use js_sys::{Array, Object, Reflect, Uint8Array};
12use std::cell::RefCell;
13use wasm_bindgen::prelude::*;
14
15// ─── helpers ────────────────────────────────────────────────────────────────
16
17fn set(obj: &Object, key: &str, val: &JsValue) {
18    Reflect::set(obj, &JsValue::from_str(key), val).unwrap_throw();
19}
20
21fn u8s(bytes: &[u8]) -> JsValue {
22    Uint8Array::from(bytes).into()
23}
24
25fn event_to_js(ev: Event) -> JsValue {
26    let obj = Object::new();
27    match ev {
28        Event::PayloadReceived(p) => {
29            set(&obj, "kind", &"payload_received".into());
30            set(&obj, "streamType", &JsValue::from_f64(p.stream_type.as_u8() as f64));
31            set(&obj, "plaintext", &u8s(&p.plaintext));
32            set(&obj, "sequenceNo", &JsValue::from_f64(p.sequence_no as f64));
33        }
34        Event::StateChanged { from, to } => {
35            set(&obj, "kind", &"state_changed".into());
36            set(&obj, "from", &JsValue::from_str(&from.to_string()));
37            set(&obj, "to", &JsValue::from_str(&to.to_string()));
38        }
39        Event::EpochAdvanced { epoch, transition_id } => {
40            set(&obj, "kind", &"epoch_advanced".into());
41            set(&obj, "epoch", &js_sys::BigInt::from(epoch).into());
42            set(&obj, "transitionId", &JsValue::from_f64(transition_id as f64));
43        }
44        Event::Error { code, reason, fatal, retryable, .. } => {
45            set(&obj, "kind", &"error".into());
46            set(&obj, "code", &JsValue::from_f64(code as f64));
47            set(&obj, "reason", &JsValue::from_str(&reason));
48            set(&obj, "fatal", &JsValue::from_bool(fatal));
49            set(&obj, "retryable", &JsValue::from_bool(retryable));
50        }
51        Event::Control { from, opcode, transition_id, .. } => {
52            set(&obj, "kind", &"control".into());
53            set(&obj, "from", &JsValue::from_f64(from as f64));
54            set(&obj, "opcode", &JsValue::from_f64(opcode as u8 as f64));
55            set(&obj, "transitionId", &JsValue::from_f64(transition_id as f64));
56        }
57        _ => {
58            set(&obj, "kind", &"other".into());
59        }
60    }
61    obj.into()
62}
63
64// ─── MlsContext ──────────────────────────────────────────────────────────────
65
66/// MLS group state for one member.
67///
68/// JS usage:
69/// ```js
70/// const mls = MlsContext.create("alice");
71/// console.log(mls.epoch); // bigint
72/// ```
73#[wasm_bindgen]
74pub struct MlsContext {
75    inner: RefCell<gbp_mls::MlsContext>,
76}
77
78#[wasm_bindgen]
79impl MlsContext {
80    /// Creates a new member identity and an empty group.
81    #[wasm_bindgen(js_name = "create", static_method_of = MlsContext)]
82    pub fn create(user_id: &str) -> MlsContext {
83        let (ctx, _kpb) = gbp_mls::MlsContext::new_member(user_id.as_bytes())
84            .expect_throw("MlsContext::new_member failed");
85        MlsContext { inner: RefCell::new(ctx) }
86    }
87
88    /// Current MLS group epoch.
89    #[wasm_bindgen(getter)]
90    pub fn epoch(&self) -> u64 {
91        self.inner.borrow().epoch()
92    }
93}
94
95// ─── GroupNode ───────────────────────────────────────────────────────────────
96
97/// GBP group node — framing, AEAD, replay window, control plane.
98///
99/// JS usage:
100/// ```js
101/// const gid = new Uint8Array(16); // 16-byte group id
102/// const node = GroupNode.create(1, gid);
103/// node.bootstrapAsCreator(mls.epoch);
104/// const events = node.onWire(mls, wireBytes);
105/// ```
106#[wasm_bindgen]
107pub struct GroupNode {
108    inner: RefCell<RustGroupNode>,
109}
110
111#[wasm_bindgen]
112impl GroupNode {
113    /// Creates a node for `leaf_index` (member id) and the given 16-byte group id.
114    #[wasm_bindgen(js_name = "create", static_method_of = GroupNode)]
115    pub fn create(leaf_index: u32, group_id_bytes: &[u8]) -> GroupNode {
116        let gid: [u8; 16] = group_id_bytes.try_into().unwrap_or([0u8; 16]);
117        let node = RustGroupNode::new(leaf_index as MemberId, gid);
118        GroupNode { inner: RefCell::new(node) }
119    }
120
121    /// Drives the node to `ACTIVE` as the group creator at the given epoch.
122    #[wasm_bindgen(js_name = "bootstrapAsCreator")]
123    pub fn bootstrap_as_creator(&self, epoch: u64) {
124        self.inner.borrow_mut().bootstrap_as_creator(epoch);
125    }
126
127    /// Drives the node to `ACTIVE` as a joiner.
128    #[wasm_bindgen(js_name = "bootstrapAsJoiner")]
129    pub fn bootstrap_as_joiner(&self, epoch: u64, expected_first_tid: u32) {
130        self.inner.borrow_mut().bootstrap_as_joiner(epoch, expected_first_tid);
131    }
132
133    /// Delivers a wire frame and returns the resulting events array.
134    ///
135    /// Each element is a plain JS object with at least `{ kind: string }`.
136    /// For `kind === "payload_received"`: `{ streamType, plaintext, sequenceNo }`.
137    #[wasm_bindgen(js_name = "onWire")]
138    pub fn on_wire(&self, mls: &MlsContext, wire_bytes: &[u8]) -> Array {
139        let mut node = self.inner.borrow_mut();
140        let mut mls_inner = mls.inner.borrow_mut();
141        let events = node.on_wire(&mut *mls_inner, wire_bytes).unwrap_or_default();
142        let arr = Array::new();
143        for ev in events {
144            arr.push(&event_to_js(ev));
145        }
146        arr
147    }
148
149    /// Polls pending timeout events (call ~every 500 ms from the app loop).
150    #[wasm_bindgen(js_name = "checkTimeouts")]
151    pub fn check_timeouts(&self) -> Array {
152        let events = self.inner.borrow_mut().check_timeouts();
153        let arr = Array::new();
154        for ev in events {
155            arr.push(&event_to_js(ev));
156        }
157        arr
158    }
159
160    /// The `transition_id` of the last applied epoch transition.
161    #[wasm_bindgen(getter, js_name = "lastTransitionId")]
162    pub fn last_transition_id(&self) -> u32 {
163        self.inner.borrow().last_transition_id
164    }
165
166    /// Current epoch as seen by the GBP layer.
167    #[wasm_bindgen(getter, js_name = "currentEpoch")]
168    pub fn current_epoch(&self) -> u64 {
169        self.inner.borrow().current_epoch
170    }
171
172    /// This node's member id (leaf index).
173    #[wasm_bindgen(getter, js_name = "memberId")]
174    pub fn member_id(&self) -> u32 {
175        self.inner.borrow().member_id
176    }
177}
178
179// ─── GtpClient ───────────────────────────────────────────────────────────────
180
181/// Group Text Protocol client — idempotent text delivery over GBP.
182///
183/// JS usage:
184/// ```js
185/// const gtp = GtpClient.create();
186/// const frame = gtp.send(node, mls, 0, 1n, "hello");
187/// // frame.wire: Uint8Array  — hand to transport
188///
189/// // on receive:
190/// const result = gtp.accept(plaintext, mls.epoch);
191/// // result.text: string | null
192/// ```
193#[wasm_bindgen]
194pub struct GtpClient {
195    inner: RefCell<RustGtpClient>,
196}
197
198#[wasm_bindgen]
199impl GtpClient {
200    /// Creates an empty GTP client.
201    #[wasm_bindgen(js_name = "create", static_method_of = GtpClient)]
202    pub fn create() -> GtpClient {
203        GtpClient { inner: RefCell::new(RustGtpClient::new()) }
204    }
205
206    /// Sends a text message.
207    ///
208    /// Returns `{ wire: Uint8Array, to: number }` or `null` on error.
209    /// Pass `target = 0` to address all members (broadcast).
210    #[wasm_bindgen(js_name = "send")]
211    pub fn send(
212        &self,
213        node: &GroupNode,
214        mls: &MlsContext,
215        target: u32,
216        message_id: u64,
217        text: &str,
218    ) -> JsValue {
219        let mut gtp = self.inner.borrow_mut();
220        let mut n = node.inner.borrow_mut();
221        let mut m = mls.inner.borrow_mut();
222        match gtp.send(&mut *n, &mut *m, target as MemberId, message_id, text, PayloadCodec::Cbor) {
223            Ok(frame) => {
224                let obj = Object::new();
225                set(&obj, "wire", &u8s(&frame.wire));
226                set(&obj, "to", &JsValue::from_f64(frame.to as f64));
227                obj.into()
228            }
229            Err(_) => JsValue::NULL,
230        }
231    }
232
233    /// Accepts a plaintext GTP payload delivered by the GBP layer.
234    ///
235    /// Returns `{ text: string, messageId: bigint, senderId: number }` or `null` on error.
236    #[wasm_bindgen(js_name = "accept")]
237    pub fn accept(&self, plaintext: &[u8], epoch: u64) -> JsValue {
238        let mut gtp = self.inner.borrow_mut();
239        match gtp.accept(plaintext, epoch, PayloadCodec::Cbor) {
240            Ok(result) => {
241                let msg = match result {
242                    GtpAccept::New(m) | GtpAccept::Duplicate(m) => m,
243                };
244                let text = String::from_utf8_lossy(&msg.content).into_owned();
245                let obj = Object::new();
246                set(&obj, "text", &JsValue::from_str(&text));
247                set(&obj, "messageId", &js_sys::BigInt::from(msg.message_id).into());
248                set(&obj, "senderId", &JsValue::from_f64(msg.sender_id as f64));
249                obj.into()
250            }
251            Err(_) => JsValue::NULL,
252        }
253    }
254
255    /// Resets the idempotency set unconditionally.
256    #[wasm_bindgen(js_name = "reset")]
257    pub fn reset(&self) {
258        self.inner.borrow_mut().reset();
259    }
260}