Skip to main content

aerosocket_wasm/
lib.rs

1//! WebAssembly support for AeroSocket
2//!
3//! This crate provides a complete [`WebSocketClient`] for browser / WASM environments.
4//! It wraps the native `web_sys::WebSocket` API and exposes a clean Rust interface that
5//! is also optionally exported to JavaScript via `wasm-bindgen`.
6//!
7//! # Example
8//!
9//! ```rust,ignore
10//! use aerosocket_wasm::{WebSocketClient, WebSocketConfig};
11//! use wasm_bindgen_futures::spawn_local;
12//!
13//! spawn_local(async {
14//!     let cfg = WebSocketConfig::new().with_protocol("chat".to_string());
15//!     let mut client = WebSocketClient::new("wss://example.com/ws".to_string(), cfg);
16//!     client.connect().await.expect("connect failed");
17//!     client.send_text("hello").expect("send failed");
18//! });
19//! ```
20
21#![cfg_attr(docsrs, feature(doc_cfg))]
22#![warn(missing_docs, missing_debug_implementations, rust_2018_idioms)]
23#![doc(html_root_url = "https://docs.rs/aerosocket-wasm/")]
24
25#[cfg(feature = "wasm-bindgen")]
26use js_sys::Uint8Array;
27#[cfg(feature = "wasm-bindgen")]
28use wasm_bindgen::prelude::*;
29#[cfg(feature = "wasm-bindgen")]
30use web_sys::{CloseEvent, ErrorEvent, MessageEvent, WebSocket};
31
32// ── Configuration ─────────────────────────────────────────────────────────────
33
34/// Configuration for a [`WebSocketClient`].
35#[derive(Debug, Clone, Default)]
36pub struct WebSocketConfig {
37    /// Sub-protocols to negotiate during the opening handshake.
38    pub protocols: Vec<String>,
39}
40
41impl WebSocketConfig {
42    /// Create a new, empty configuration.
43    pub fn new() -> Self {
44        Self::default()
45    }
46
47    /// Add a sub-protocol to negotiate.
48    pub fn with_protocol(mut self, protocol: String) -> Self {
49        self.protocols.push(protocol);
50        self
51    }
52}
53
54// ── Client ────────────────────────────────────────────────────────────────────
55
56/// A WebSocket client for browser / WASM environments.
57///
58/// When the `wasm-bindgen` feature is active (the default) this struct can be
59/// exported to JavaScript via `#[wasm_bindgen]`.
60pub struct WebSocketClient {
61    /// Target URL.
62    url: String,
63    /// Negotiated configuration.
64    config: WebSocketConfig,
65    /// The underlying `web_sys::WebSocket`, present after a successful [`connect`](Self::connect).
66    #[cfg(feature = "wasm-bindgen")]
67    ws: Option<WebSocket>,
68    /// Placeholder for non-WASM builds so the struct is never zero-sized.
69    #[cfg(not(feature = "wasm-bindgen"))]
70    _private: (),
71}
72
73// Manual Debug impl — web_sys::WebSocket does not implement Debug.
74impl std::fmt::Debug for WebSocketClient {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        f.debug_struct("WebSocketClient")
77            .field("url", &self.url)
78            .field("protocols", &self.config.protocols)
79            .field(
80                "connected",
81                &{
82                    #[cfg(feature = "wasm-bindgen")]
83                    {
84                        self.ws.is_some()
85                    }
86                    #[cfg(not(feature = "wasm-bindgen"))]
87                    {
88                        false
89                    }
90                },
91            )
92            .finish()
93    }
94}
95
96impl WebSocketClient {
97    /// Create a new client for `url` with the given [`WebSocketConfig`].
98    ///
99    /// The connection is *not* established until [`connect`](Self::connect) is called.
100    pub fn new(url: String, config: WebSocketConfig) -> Self {
101        Self {
102            url,
103            config,
104            #[cfg(feature = "wasm-bindgen")]
105            ws: None,
106            #[cfg(not(feature = "wasm-bindgen"))]
107            _private: (),
108        }
109    }
110
111    /// Returns the URL this client was created with.
112    pub fn url(&self) -> &str {
113        &self.url
114    }
115
116    /// Returns `true` when the underlying socket is open and ready to send.
117    pub fn is_connected(&self) -> bool {
118        #[cfg(feature = "wasm-bindgen")]
119        {
120            self.ws
121                .as_ref()
122                .map(|ws| ws.ready_state() == WebSocket::OPEN)
123                .unwrap_or(false)
124        }
125        #[cfg(not(feature = "wasm-bindgen"))]
126        {
127            false
128        }
129    }
130}
131
132// ── WASM-only impls ────────────────────────────────────────────────────────────
133
134#[cfg(feature = "wasm-bindgen")]
135impl WebSocketClient {
136    /// Open the WebSocket connection.
137    ///
138    /// Registers `onopen`, `onmessage`, `onerror`, and `onclose` callbacks that
139    /// log to the browser console.  For production use, replace the closures with
140    /// application-specific logic.
141    ///
142    /// # Errors
143    ///
144    /// Returns a [`JsValue`] error if the browser rejects the URL or if the
145    /// socket is already open.
146    pub async fn connect(&mut self) -> Result<(), JsValue> {
147        if self.ws.is_some() {
148            return Err(JsValue::from_str("WebSocket is already connected"));
149        }
150
151        // Construct with sub-protocols if any were configured.
152        let ws = if self.config.protocols.is_empty() {
153            WebSocket::new(&self.url)
154        } else {
155            let protocols = js_sys::Array::new();
156            for p in &self.config.protocols {
157                protocols.push(&JsValue::from_str(p));
158            }
159            WebSocket::new_with_str_sequence(&self.url, &protocols)
160        }
161        .map_err(|e| {
162            JsValue::from_str(&format!(
163                "Failed to create WebSocket for '{}': {}",
164                self.url,
165                e.as_string().unwrap_or_default()
166            ))
167        })?;
168
169        // Prefer binary frames as ArrayBuffer so `onmessage` always receives
170        // typed data — consistent with the server-side framing layer.
171        ws.set_binary_type(web_sys::BinaryType::Arraybuffer);
172
173        let url_clone = self.url.clone();
174        let onopen = Closure::<dyn Fn()>::wrap(Box::new(move || {
175            web_sys::console::log_1(
176                &format!("[aerosocket-wasm] connected to {url_clone}").into(),
177            );
178        }));
179        ws.set_onopen(Some(onopen.as_ref().unchecked_ref()));
180        onopen.forget();
181
182        let onmessage = Closure::<dyn Fn(MessageEvent)>::wrap(Box::new(|event: MessageEvent| {
183            if let Ok(text) = event.data().dyn_into::<js_sys::JsString>() {
184                web_sys::console::log_2(&"[aerosocket-wasm] message:".into(), &text);
185            } else if let Ok(buf) = event.data().dyn_into::<js_sys::ArrayBuffer>() {
186                let arr = Uint8Array::new(&buf);
187                web_sys::console::log_2(
188                    &"[aerosocket-wasm] binary frame, bytes:".into(),
189                    &JsValue::from(arr.length()),
190                );
191            }
192        }));
193        ws.set_onmessage(Some(onmessage.as_ref().unchecked_ref()));
194        onmessage.forget();
195
196        let onerror = Closure::<dyn Fn(ErrorEvent)>::wrap(Box::new(|event: ErrorEvent| {
197            web_sys::console::error_1(
198                &format!("[aerosocket-wasm] error: {}", event.message()).into(),
199            );
200        }));
201        ws.set_onerror(Some(onerror.as_ref().unchecked_ref()));
202        onerror.forget();
203
204        let onclose = Closure::<dyn Fn(CloseEvent)>::wrap(Box::new(|event: CloseEvent| {
205            web_sys::console::log_1(
206                &format!(
207                    "[aerosocket-wasm] closed — code={} reason='{}'",
208                    event.code(),
209                    event.reason()
210                )
211                .into(),
212            );
213        }));
214        ws.set_onclose(Some(onclose.as_ref().unchecked_ref()));
215        onclose.forget();
216
217        self.ws = Some(ws);
218        Ok(())
219    }
220
221    /// Send a UTF-8 text message.
222    ///
223    /// # Errors
224    ///
225    /// Returns a [`JsValue`] error if the socket is not connected or if the
226    /// browser's `send()` call fails.
227    pub fn send_text(&self, text: &str) -> Result<(), JsValue> {
228        self.ws
229            .as_ref()
230            .ok_or_else(|| JsValue::from_str("WebSocket is not connected"))?
231            .send_with_str(text)
232            .map_err(|e| JsValue::from_str(&e.as_string().unwrap_or_default()))
233    }
234
235    /// Send a binary message.
236    ///
237    /// The slice is copied into a JS `Uint8Array` and transferred over the wire as
238    /// an `ArrayBuffer` frame.
239    ///
240    /// # Errors
241    ///
242    /// Returns a [`JsValue`] error if the socket is not connected or if the
243    /// browser's `send()` call fails.
244    pub fn send_binary(&self, data: &[u8]) -> Result<(), JsValue> {
245        let ws = self
246            .ws
247            .as_ref()
248            .ok_or_else(|| JsValue::from_str("WebSocket is not connected"))?;
249        let array = Uint8Array::from(data);
250        ws.send_with_array_buffer(&array.buffer())
251            .map_err(|e| JsValue::from_str(&e.as_string().unwrap_or_default()))
252    }
253
254    /// Close the WebSocket with the normal-closure code `1000`.
255    ///
256    /// # Errors
257    ///
258    /// Returns a [`JsValue`] error if the socket is not connected or if the
259    /// browser's `close()` call fails.
260    pub fn close(&mut self) -> Result<(), JsValue> {
261        let ws = self
262            .ws
263            .take()
264            .ok_or_else(|| JsValue::from_str("WebSocket is not connected"))?;
265        ws.close_with_code_and_reason(1000, "Normal closure")
266            .map_err(|e| JsValue::from_str(&e.as_string().unwrap_or_default()))
267    }
268
269    /// Return the underlying [`WebSocket`] ready-state code.
270    ///
271    /// | Code | Meaning      |
272    /// |------|--------------|
273    /// | 0    | CONNECTING   |
274    /// | 1    | OPEN         |
275    /// | 2    | CLOSING      |
276    /// | 3    | CLOSED       |
277    pub fn ready_state(&self) -> u16 {
278        self.ws
279            .as_ref()
280            .map(|ws| ws.ready_state())
281            .unwrap_or(WebSocket::CLOSED)
282    }
283}
284
285// ── Prelude ───────────────────────────────────────────────────────────────────
286
287/// Re-exports for convenient glob imports.
288pub mod prelude {
289    pub use crate::{WebSocketClient, WebSocketConfig};
290    pub use aerosocket_core::prelude::*;
291}
292
293// ── Tests ─────────────────────────────────────────────────────────────────────
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    #[test]
300    fn config_default_has_no_protocols() {
301        let cfg = WebSocketConfig::new();
302        assert!(cfg.protocols.is_empty());
303    }
304
305    #[test]
306    fn config_with_protocol_appends() {
307        let cfg = WebSocketConfig::new()
308            .with_protocol("chat".into())
309            .with_protocol("v2".into());
310        assert_eq!(cfg.protocols, ["chat", "v2"]);
311    }
312
313    #[test]
314    fn client_new_stores_url() {
315        let client = WebSocketClient::new("wss://example.com".into(), WebSocketConfig::new());
316        assert_eq!(client.url(), "wss://example.com");
317    }
318
319    #[test]
320    fn client_not_connected_initially() {
321        let client = WebSocketClient::new("wss://example.com".into(), WebSocketConfig::default());
322        assert!(!client.is_connected());
323    }
324
325    #[test]
326    fn client_debug_shows_url() {
327        let client = WebSocketClient::new("wss://debug.test".into(), WebSocketConfig::default());
328        let dbg = format!("{client:?}");
329        assert!(dbg.contains("wss://debug.test"));
330    }
331}