1#![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#[derive(Debug, Clone, Default)]
36pub struct WebSocketConfig {
37 pub protocols: Vec<String>,
39}
40
41impl WebSocketConfig {
42 pub fn new() -> Self {
44 Self::default()
45 }
46
47 pub fn with_protocol(mut self, protocol: String) -> Self {
49 self.protocols.push(protocol);
50 self
51 }
52}
53
54pub struct WebSocketClient {
61 url: String,
63 config: WebSocketConfig,
65 #[cfg(feature = "wasm-bindgen")]
67 ws: Option<WebSocket>,
68 #[cfg(not(feature = "wasm-bindgen"))]
70 _private: (),
71}
72
73impl 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 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 pub fn url(&self) -> &str {
113 &self.url
114 }
115
116 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#[cfg(feature = "wasm-bindgen")]
135impl WebSocketClient {
136 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 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 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 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 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 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 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
285pub mod prelude {
289 pub use crate::{WebSocketClient, WebSocketConfig};
290 pub use aerosocket_core::prelude::*;
291}
292
293#[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}