#![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};
#[derive(Debug, Clone, Default)]
pub struct WebSocketConfig {
pub protocols: Vec<String>,
}
impl WebSocketConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_protocol(mut self, protocol: String) -> Self {
self.protocols.push(protocol);
self
}
}
pub struct WebSocketClient {
url: String,
config: WebSocketConfig,
#[cfg(feature = "wasm-bindgen")]
ws: Option<WebSocket>,
#[cfg(not(feature = "wasm-bindgen"))]
_private: (),
}
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 {
pub fn new(url: String, config: WebSocketConfig) -> Self {
Self {
url,
config,
#[cfg(feature = "wasm-bindgen")]
ws: None,
#[cfg(not(feature = "wasm-bindgen"))]
_private: (),
}
}
pub fn url(&self) -> &str {
&self.url
}
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
}
}
}
#[cfg(feature = "wasm-bindgen")]
impl WebSocketClient {
pub async fn connect(&mut self) -> Result<(), JsValue> {
if self.ws.is_some() {
return Err(JsValue::from_str("WebSocket is already connected"));
}
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()
))
})?;
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(())
}
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()))
}
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()))
}
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()))
}
pub fn ready_state(&self) -> u16 {
self.ws
.as_ref()
.map(|ws| ws.ready_state())
.unwrap_or(WebSocket::CLOSED)
}
}
pub mod prelude {
pub use crate::{WebSocketClient, WebSocketConfig};
pub use aerosocket_core::prelude::*;
}
#[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"));
}
}