aerosocket-wasm 0.5.0

WebAssembly support for AeroSocket
Documentation
//! WebAssembly support for AeroSocket
//!
//! This crate provides a complete [`WebSocketClient`] for browser / WASM environments.
//! It wraps the native `web_sys::WebSocket` API and exposes a clean Rust interface that
//! is also optionally exported to JavaScript via `wasm-bindgen`.
//!
//! # Example
//!
//! ```rust,ignore
//! use aerosocket_wasm::{WebSocketClient, WebSocketConfig};
//! use wasm_bindgen_futures::spawn_local;
//!
//! spawn_local(async {
//!     let cfg = WebSocketConfig::new().with_protocol("chat".to_string());
//!     let mut client = WebSocketClient::new("wss://example.com/ws".to_string(), cfg);
//!     client.connect().await.expect("connect failed");
//!     client.send_text("hello").expect("send failed");
//! });
//! ```

#![cfg_attr(docsrs, feature(doc_cfg))]
#![warn(missing_docs, missing_debug_implementations, rust_2018_idioms)]
#![doc(html_root_url = "https://docs.rs/aerosocket-wasm/")]

#[cfg(feature = "wasm-bindgen")]
use js_sys::Uint8Array;
#[cfg(feature = "wasm-bindgen")]
use wasm_bindgen::prelude::*;
#[cfg(feature = "wasm-bindgen")]
use web_sys::{CloseEvent, ErrorEvent, MessageEvent, WebSocket};

// ── Configuration ─────────────────────────────────────────────────────────────

/// Configuration for a [`WebSocketClient`].
#[derive(Debug, Clone, Default)]
pub struct WebSocketConfig {
    /// Sub-protocols to negotiate during the opening handshake.
    pub protocols: Vec<String>,
}

impl WebSocketConfig {
    /// Create a new, empty configuration.
    pub fn new() -> Self {
        Self::default()
    }

    /// Add a sub-protocol to negotiate.
    pub fn with_protocol(mut self, protocol: String) -> Self {
        self.protocols.push(protocol);
        self
    }
}

// ── Client ────────────────────────────────────────────────────────────────────

/// A WebSocket client for browser / WASM environments.
///
/// When the `wasm-bindgen` feature is active (the default) this struct can be
/// exported to JavaScript via `#[wasm_bindgen]`.
pub struct WebSocketClient {
    /// Target URL.
    url: String,
    /// Negotiated configuration.
    config: WebSocketConfig,
    /// The underlying `web_sys::WebSocket`, present after a successful [`connect`](Self::connect).
    #[cfg(feature = "wasm-bindgen")]
    ws: Option<WebSocket>,
    /// Placeholder for non-WASM builds so the struct is never zero-sized.
    #[cfg(not(feature = "wasm-bindgen"))]
    _private: (),
}

// Manual Debug impl — web_sys::WebSocket does not implement Debug.
impl std::fmt::Debug for WebSocketClient {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("WebSocketClient")
            .field("url", &self.url)
            .field("protocols", &self.config.protocols)
            .field(
                "connected",
                &{
                    #[cfg(feature = "wasm-bindgen")]
                    {
                        self.ws.is_some()
                    }
                    #[cfg(not(feature = "wasm-bindgen"))]
                    {
                        false
                    }
                },
            )
            .finish()
    }
}

impl WebSocketClient {
    /// Create a new client for `url` with the given [`WebSocketConfig`].
    ///
    /// The connection is *not* established until [`connect`](Self::connect) is called.
    pub fn new(url: String, config: WebSocketConfig) -> Self {
        Self {
            url,
            config,
            #[cfg(feature = "wasm-bindgen")]
            ws: None,
            #[cfg(not(feature = "wasm-bindgen"))]
            _private: (),
        }
    }

    /// Returns the URL this client was created with.
    pub fn url(&self) -> &str {
        &self.url
    }

    /// Returns `true` when the underlying socket is open and ready to send.
    pub fn is_connected(&self) -> bool {
        #[cfg(feature = "wasm-bindgen")]
        {
            self.ws
                .as_ref()
                .map(|ws| ws.ready_state() == WebSocket::OPEN)
                .unwrap_or(false)
        }
        #[cfg(not(feature = "wasm-bindgen"))]
        {
            false
        }
    }
}

// ── WASM-only impls ────────────────────────────────────────────────────────────

#[cfg(feature = "wasm-bindgen")]
impl WebSocketClient {
    /// Open the WebSocket connection.
    ///
    /// Registers `onopen`, `onmessage`, `onerror`, and `onclose` callbacks that
    /// log to the browser console.  For production use, replace the closures with
    /// application-specific logic.
    ///
    /// # Errors
    ///
    /// Returns a [`JsValue`] error if the browser rejects the URL or if the
    /// socket is already open.
    pub async fn connect(&mut self) -> Result<(), JsValue> {
        if self.ws.is_some() {
            return Err(JsValue::from_str("WebSocket is already connected"));
        }

        // Construct with sub-protocols if any were configured.
        let ws = if self.config.protocols.is_empty() {
            WebSocket::new(&self.url)
        } else {
            let protocols = js_sys::Array::new();
            for p in &self.config.protocols {
                protocols.push(&JsValue::from_str(p));
            }
            WebSocket::new_with_str_sequence(&self.url, &protocols)
        }
        .map_err(|e| {
            JsValue::from_str(&format!(
                "Failed to create WebSocket for '{}': {}",
                self.url,
                e.as_string().unwrap_or_default()
            ))
        })?;

        // Prefer binary frames as ArrayBuffer so `onmessage` always receives
        // typed data — consistent with the server-side framing layer.
        ws.set_binary_type(web_sys::BinaryType::Arraybuffer);

        let url_clone = self.url.clone();
        let onopen = Closure::<dyn Fn()>::wrap(Box::new(move || {
            web_sys::console::log_1(
                &format!("[aerosocket-wasm] connected to {url_clone}").into(),
            );
        }));
        ws.set_onopen(Some(onopen.as_ref().unchecked_ref()));
        onopen.forget();

        let onmessage = Closure::<dyn Fn(MessageEvent)>::wrap(Box::new(|event: MessageEvent| {
            if let Ok(text) = event.data().dyn_into::<js_sys::JsString>() {
                web_sys::console::log_2(&"[aerosocket-wasm] message:".into(), &text);
            } else if let Ok(buf) = event.data().dyn_into::<js_sys::ArrayBuffer>() {
                let arr = Uint8Array::new(&buf);
                web_sys::console::log_2(
                    &"[aerosocket-wasm] binary frame, bytes:".into(),
                    &JsValue::from(arr.length()),
                );
            }
        }));
        ws.set_onmessage(Some(onmessage.as_ref().unchecked_ref()));
        onmessage.forget();

        let onerror = Closure::<dyn Fn(ErrorEvent)>::wrap(Box::new(|event: ErrorEvent| {
            web_sys::console::error_1(
                &format!("[aerosocket-wasm] error: {}", event.message()).into(),
            );
        }));
        ws.set_onerror(Some(onerror.as_ref().unchecked_ref()));
        onerror.forget();

        let onclose = Closure::<dyn Fn(CloseEvent)>::wrap(Box::new(|event: CloseEvent| {
            web_sys::console::log_1(
                &format!(
                    "[aerosocket-wasm] closed — code={} reason='{}'",
                    event.code(),
                    event.reason()
                )
                .into(),
            );
        }));
        ws.set_onclose(Some(onclose.as_ref().unchecked_ref()));
        onclose.forget();

        self.ws = Some(ws);
        Ok(())
    }

    /// Send a UTF-8 text message.
    ///
    /// # Errors
    ///
    /// Returns a [`JsValue`] error if the socket is not connected or if the
    /// browser's `send()` call fails.
    pub fn send_text(&self, text: &str) -> Result<(), JsValue> {
        self.ws
            .as_ref()
            .ok_or_else(|| JsValue::from_str("WebSocket is not connected"))?
            .send_with_str(text)
            .map_err(|e| JsValue::from_str(&e.as_string().unwrap_or_default()))
    }

    /// Send a binary message.
    ///
    /// The slice is copied into a JS `Uint8Array` and transferred over the wire as
    /// an `ArrayBuffer` frame.
    ///
    /// # Errors
    ///
    /// Returns a [`JsValue`] error if the socket is not connected or if the
    /// browser's `send()` call fails.
    pub fn send_binary(&self, data: &[u8]) -> Result<(), JsValue> {
        let ws = self
            .ws
            .as_ref()
            .ok_or_else(|| JsValue::from_str("WebSocket is not connected"))?;
        let array = Uint8Array::from(data);
        ws.send_with_array_buffer(&array.buffer())
            .map_err(|e| JsValue::from_str(&e.as_string().unwrap_or_default()))
    }

    /// Close the WebSocket with the normal-closure code `1000`.
    ///
    /// # Errors
    ///
    /// Returns a [`JsValue`] error if the socket is not connected or if the
    /// browser's `close()` call fails.
    pub fn close(&mut self) -> Result<(), JsValue> {
        let ws = self
            .ws
            .take()
            .ok_or_else(|| JsValue::from_str("WebSocket is not connected"))?;
        ws.close_with_code_and_reason(1000, "Normal closure")
            .map_err(|e| JsValue::from_str(&e.as_string().unwrap_or_default()))
    }

    /// Return the underlying [`WebSocket`] ready-state code.
    ///
    /// | Code | Meaning      |
    /// |------|--------------|
    /// | 0    | CONNECTING   |
    /// | 1    | OPEN         |
    /// | 2    | CLOSING      |
    /// | 3    | CLOSED       |
    pub fn ready_state(&self) -> u16 {
        self.ws
            .as_ref()
            .map(|ws| ws.ready_state())
            .unwrap_or(WebSocket::CLOSED)
    }
}

// ── Prelude ───────────────────────────────────────────────────────────────────

/// Re-exports for convenient glob imports.
pub mod prelude {
    pub use crate::{WebSocketClient, WebSocketConfig};
    pub use aerosocket_core::prelude::*;
}

// ── Tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn config_default_has_no_protocols() {
        let cfg = WebSocketConfig::new();
        assert!(cfg.protocols.is_empty());
    }

    #[test]
    fn config_with_protocol_appends() {
        let cfg = WebSocketConfig::new()
            .with_protocol("chat".into())
            .with_protocol("v2".into());
        assert_eq!(cfg.protocols, ["chat", "v2"]);
    }

    #[test]
    fn client_new_stores_url() {
        let client = WebSocketClient::new("wss://example.com".into(), WebSocketConfig::new());
        assert_eq!(client.url(), "wss://example.com");
    }

    #[test]
    fn client_not_connected_initially() {
        let client = WebSocketClient::new("wss://example.com".into(), WebSocketConfig::default());
        assert!(!client.is_connected());
    }

    #[test]
    fn client_debug_shows_url() {
        let client = WebSocketClient::new("wss://debug.test".into(), WebSocketConfig::default());
        let dbg = format!("{client:?}");
        assert!(dbg.contains("wss://debug.test"));
    }
}