1use crate::messages::{
2 AgentMessage, ComponentSerializationRequest, DevClientResponse, LLMRequest,
3 LoadFileToStaticDirRequest, UserlandSourceUpdateRequest,
4};
5use anyhow::{anyhow, Result};
6use ewebsock::{WsEvent, WsMessage};
7use pax_manifest::ComponentDefinition;
8use std::time::Duration;
9#[cfg(not(target_arch = "wasm32"))]
10use std::time::Instant;
11use url::Url;
12#[cfg(target_arch = "wasm32")]
13use web_time::Instant;
14
15const WEBSOCKET_RECONNECT_INITIAL_DELAY: Duration = Duration::from_millis(500);
16const WEBSOCKET_RECONNECT_MAX_DELAY: Duration = Duration::from_secs(5);
17
18#[derive(Debug)]
19pub(crate) enum ConnectionEvent {
20 Opened,
21 Message(AgentMessage),
22 Disconnected,
23}
24
25pub struct WebSocketConnection {
26 url: String,
27 sender: Option<ewebsock::WsSender>,
28 recver: Option<ewebsock::WsReceiver>,
29 label: String,
30 pub alive: bool,
31 allow_reconnect: bool,
32 connecting: bool,
33 next_reconnect_at: Option<Instant>,
34 reconnect_delay: Duration,
35}
36
37impl WebSocketConnection {
38 pub fn new(
39 addr: &str,
40 versioning_prefix: Option<&str>,
41 label: impl Into<String>,
42 ) -> Result<Self> {
43 let url = build_socket_url(addr, versioning_prefix)?;
44 let (sender, recver) = connect_socket(&url)?;
45
46 Ok(Self {
47 url,
48 sender: Some(sender),
49 recver: Some(recver),
50 label: label.into(),
51 alive: false,
54 allow_reconnect: true,
55 connecting: true,
56 next_reconnect_at: None,
57 reconnect_delay: WEBSOCKET_RECONNECT_INITIAL_DELAY,
58 })
59 }
60
61 pub fn offline(label: impl Into<String>) -> Self {
65 Self {
66 url: String::new(),
67 sender: None,
68 recver: None,
69 label: label.into(),
70 alive: false,
71 allow_reconnect: false,
72 connecting: false,
73 next_reconnect_at: None,
74 reconnect_delay: WEBSOCKET_RECONNECT_INITIAL_DELAY,
75 }
76 }
77
78 pub fn send_agent_message(&mut self, message: &AgentMessage) -> Result<()> {
79 if !self.alive || self.connecting {
80 return Err(anyhow!("design-server socket is not open"));
81 }
82 let msg_bytes = rmp_serde::to_vec(message)?;
83 self.sender()
84 .ok_or_else(|| anyhow!("design-server socket is not connected"))?
85 .send(ewebsock::WsMessage::Binary(msg_bytes));
86 Ok(())
87 }
88
89 pub fn send_component_update(&mut self, component: &ComponentDefinition) -> Result<()> {
90 let component_bytes = rmp_serde::to_vec(component)?;
91 self.send_connected(
92 AgentMessage::ComponentSerializationRequest(ComponentSerializationRequest {
93 component_bytes,
94 }),
95 "couldn't send component update: connection to design-server was lost",
96 )
97 }
98
99 pub fn send_llm_request(&mut self, llm_request: LLMRequest) -> Result<()> {
100 self.send_connected(
101 AgentMessage::LLMRequest(llm_request),
102 "couldn't send LLM request: connection to pub pax was lost",
103 )
104 }
105
106 pub fn send_file_to_static_dir(&mut self, name: &str, data: Vec<u8>) -> Result<()> {
107 self.send_connected(
108 AgentMessage::LoadFileToStaticDirRequest(LoadFileToStaticDirRequest {
109 name: name.to_owned(),
110 data,
111 }),
112 "couldn't send file: connection to design-server was lost",
113 )
114 }
115
116 pub fn send_dev_client_response(&mut self, response: DevClientResponse) -> Result<()> {
117 self.send_connected(
118 AgentMessage::DevClientResponse(response),
119 "couldn't send dev response: connection to design-server was lost",
120 )
121 }
122
123 pub fn send_userland_source_update_request(
124 &mut self,
125 request: UserlandSourceUpdateRequest,
126 ) -> Result<()> {
127 self.send_connected(
128 AgentMessage::UserlandSourceUpdateRequest(request),
129 "couldn't send userland source update: connection to design-server was lost",
130 )
131 }
132
133 fn send_connected(&mut self, message: AgentMessage, disconnected_error: &str) -> Result<()> {
134 if !self.alive {
135 return Err(anyhow!(disconnected_error.to_owned()));
136 }
137 self.send_agent_message(&message)
138 }
139
140 pub(crate) fn handle_recv(&mut self) -> Result<Vec<ConnectionEvent>> {
141 self.reconnect_if_needed();
142
143 let mut connection_events = vec![];
144 while let Some(event) = self.recver.as_ref().and_then(|recver| recver.try_recv()) {
145 match event {
146 WsEvent::Opened => {
147 self.alive = true;
148 self.connecting = false;
149 self.next_reconnect_at = None;
150 self.reconnect_delay = WEBSOCKET_RECONNECT_INITIAL_DELAY;
151 connection_events.push(ConnectionEvent::Opened);
152 }
153 WsEvent::Message(message) => match message {
154 WsMessage::Binary(msg_bytes) => {
155 let msg: AgentMessage = match rmp_serde::from_slice(&msg_bytes) {
156 Ok(msg) => msg,
157 Err(err) => {
158 log::warn!(
159 "{} received invalid websocket message: {err}",
160 self.label
161 );
162 continue;
163 }
164 };
165 if let AgentMessage::DisconnectNotification(notification) = &msg {
166 self.allow_reconnect = notification.allow_reconnect;
167 if !notification.allow_reconnect {
168 log::info!(
169 "{} reconnect disabled by server: {}",
170 self.label,
171 notification.reason
172 );
173 }
174 }
175 connection_events.push(ConnectionEvent::Message(msg));
176 }
177 WsMessage::Ping(data) => {
178 if let Some(sender) = self.sender() {
179 sender.send(WsMessage::Pong(data));
180 }
181 }
182 WsMessage::Pong(_) | WsMessage::Text(_) | WsMessage::Unknown(_) => {}
183 },
184 WsEvent::Error(e) => {
185 log::warn!("{} web socket error: {e}", self.label);
186 self.schedule_reconnect();
187 connection_events.push(ConnectionEvent::Disconnected);
188 }
189 WsEvent::Closed => {
190 log::warn!("{} web socket was closed", self.label);
191 self.schedule_reconnect();
192 connection_events.push(ConnectionEvent::Disconnected);
193 }
194 }
195 }
196 Ok(connection_events)
197 }
198
199 fn sender(&mut self) -> Option<&mut ewebsock::WsSender> {
200 self.sender.as_mut()
201 }
202
203 fn schedule_reconnect(&mut self) {
204 self.sender = None;
205 self.recver = None;
206 self.alive = false;
207 self.connecting = false;
208 if !self.allow_reconnect {
209 self.next_reconnect_at = None;
210 return;
211 }
212 if self.next_reconnect_at.is_none() {
213 self.next_reconnect_at = Some(Instant::now() + self.reconnect_delay);
214 self.reconnect_delay = self
215 .reconnect_delay
216 .saturating_mul(2)
217 .min(WEBSOCKET_RECONNECT_MAX_DELAY);
218 }
219 }
220
221 fn reconnect_if_needed(&mut self) {
222 if !self.allow_reconnect {
223 return;
224 }
225 if self.alive || self.connecting {
226 return;
227 }
228 let Some(next_reconnect_at) = self.next_reconnect_at else {
229 return;
230 };
231 if Instant::now() < next_reconnect_at {
232 return;
233 }
234
235 self.next_reconnect_at = None;
236 match connect_socket(&self.url) {
237 Ok((sender, recver)) => {
238 self.sender = Some(sender);
239 self.recver = Some(recver);
240 self.alive = false;
241 self.connecting = true;
242 self.next_reconnect_at = None;
243 log::info!("{} reconnecting to {}", self.label, self.url);
244 }
245 Err(err) => {
246 log::warn!("{} reconnect failed: {err}", self.label);
247 self.schedule_reconnect();
248 }
249 }
250 }
251
252 pub fn shutdown_permanently(&mut self) {
253 self.allow_reconnect = false;
254 if let Some(sender) = self.sender.as_mut() {
255 let _ = sender.close();
256 }
257 self.sender = None;
258 self.recver = None;
259 self.alive = false;
260 self.connecting = false;
261 self.next_reconnect_at = None;
262 }
263}
264
265fn build_socket_url(addr: &str, versioning_prefix: Option<&str>) -> Result<String> {
266 let mut url = Url::parse(addr).map_err(|e| anyhow!("Invalid URL: {}", e))?;
267
268 let ws_scheme = match url.scheme() {
269 "http" => "ws",
270 "https" => "wss",
271 "ws" | "wss" => url.scheme(),
272 _ => return Err(anyhow!("Unsupported URL scheme: {}", url.scheme())),
273 }
274 .to_owned();
275
276 url.set_scheme(&ws_scheme)
277 .map_err(|_| anyhow!("Failed to set URL scheme"))?;
278
279 let versioning_prefix = versioning_prefix.unwrap_or("");
280 let new_path = format!("{}/ws", versioning_prefix);
281 url.set_path(&new_path);
282
283 Ok(url.to_string())
284}
285
286fn connect_socket(url: &str) -> Result<(ewebsock::WsSender, ewebsock::WsReceiver)> {
287 ewebsock::connect_with_wakeup(
288 url.to_owned(),
289 ewebsock::Options::default(),
290 wake_designtime_loop,
291 )
292 .map_err(|err| anyhow!("Couldn't create socket connection: {err}"))
293}
294
295#[cfg(target_arch = "wasm32")]
296fn wake_designtime_loop() {
297 if let Some(window) = web_sys::window() {
298 if let Ok(event) = web_sys::Event::new("pax-designtime-wakeup") {
299 let _ = window.dispatch_event(&event);
300 }
301 }
302}
303
304#[cfg(not(target_arch = "wasm32"))]
305fn wake_designtime_loop() {}
306
307#[cfg(test)]
308mod tests {
309 use super::{
310 build_socket_url, ConnectionEvent, WebSocketConnection, WEBSOCKET_RECONNECT_INITIAL_DELAY,
311 WEBSOCKET_RECONNECT_MAX_DELAY,
312 };
313 use crate::messages::{AgentMessage, FileChangedNotification};
314 use ewebsock::{WsEvent, WsMessage};
315
316 fn test_connection(recver: ewebsock::WsReceiver) -> WebSocketConnection {
317 WebSocketConnection {
318 url: "ws://127.0.0.1:1/ws".to_string(),
319 sender: None,
320 recver: Some(recver),
321 label: "test".to_string(),
322 alive: true,
323 allow_reconnect: true,
324 connecting: false,
325 next_reconnect_at: None,
326 reconnect_delay: WEBSOCKET_RECONNECT_INITIAL_DELAY,
327 }
328 }
329
330 #[test]
331 fn builds_websocket_url_from_http_origin() {
332 assert_eq!(
333 build_socket_url("http://127.0.0.1:8080", None).unwrap(),
334 "ws://127.0.0.1:8080/ws"
335 );
336 }
337
338 #[test]
339 fn preserves_version_prefix_when_building_websocket_url() {
340 assert_eq!(
341 build_socket_url("https://pub.pax.dev", Some("/v0")).unwrap(),
342 "wss://pub.pax.dev/v0/ws"
343 );
344 }
345
346 #[test]
347 fn ignores_invalid_websocket_messages() {
348 let (recver, on_event) = ewebsock::WsReceiver::new();
349 let _ = on_event(WsEvent::Message(WsMessage::Binary(vec![0xc1])));
350 let mut connection = test_connection(recver);
351 let messages = connection.handle_recv().unwrap();
352
353 assert!(messages.is_empty());
354 assert!(connection.alive);
355 }
356
357 #[test]
358 fn forwards_decoded_messages_without_applying_policy() {
359 let (recver, on_event) = ewebsock::WsReceiver::new();
360 let message = AgentMessage::ProjectFileChangedNotification(FileChangedNotification {});
361 let _ = on_event(WsEvent::Message(WsMessage::Binary(
362 rmp_serde::to_vec(&message).unwrap(),
363 )));
364 let mut connection = test_connection(recver);
365
366 let messages = connection.handle_recv().unwrap();
367
368 assert!(matches!(
369 messages.as_slice(),
370 [ConnectionEvent::Message(
371 AgentMessage::ProjectFileChangedNotification(_)
372 )]
373 ));
374 }
375
376 #[test]
377 fn schedules_reconnect_after_close_without_error() {
378 let (recver, on_event) = ewebsock::WsReceiver::new();
379 let _ = on_event(WsEvent::Closed);
380 let mut connection = test_connection(recver);
381
382 let messages = connection.handle_recv().unwrap();
383
384 assert!(matches!(
385 messages.as_slice(),
386 [ConnectionEvent::Disconnected]
387 ));
388 assert!(!connection.alive);
389 assert!(!connection.connecting);
390 assert!(connection.next_reconnect_at.is_some());
391 }
392
393 #[test]
394 fn schedules_reconnect_after_error_without_error() {
395 let (recver, on_event) = ewebsock::WsReceiver::new();
396 let _ = on_event(WsEvent::Error(String::new()));
397 let mut connection = test_connection(recver);
398
399 let messages = connection.handle_recv().unwrap();
400
401 assert!(matches!(
402 messages.as_slice(),
403 [ConnectionEvent::Disconnected]
404 ));
405 assert!(!connection.alive);
406 assert!(!connection.connecting);
407 assert!(connection.next_reconnect_at.is_some());
408 }
409
410 #[test]
411 fn reconnect_attempts_use_capped_backoff() {
412 let (recver, _on_event) = ewebsock::WsReceiver::new();
413 let mut connection = test_connection(recver);
414
415 connection.schedule_reconnect();
416 assert_eq!(
417 connection.reconnect_delay,
418 WEBSOCKET_RECONNECT_INITIAL_DELAY.saturating_mul(2)
419 );
420
421 for _ in 0..8 {
422 connection.next_reconnect_at = None;
423 connection.schedule_reconnect();
424 }
425 assert_eq!(connection.reconnect_delay, WEBSOCKET_RECONNECT_MAX_DELAY);
426 }
427
428 #[test]
429 fn offline_transport_never_reconnects() {
430 let mut connection = WebSocketConnection::offline("test-offline");
431
432 assert!(connection.handle_recv().unwrap().is_empty());
433 assert!(!connection.alive);
434 assert!(!connection.allow_reconnect);
435 assert!(connection.next_reconnect_at.is_none());
436 }
437
438 #[test]
439 fn connecting_transport_rejects_sends_until_opened() {
440 let (recver, on_event) = ewebsock::WsReceiver::new();
441 let mut connection = test_connection(recver);
442 connection.alive = false;
443 connection.connecting = true;
444
445 let err = connection
446 .send_agent_message(&AgentMessage::ProjectFileChangedNotification(
447 FileChangedNotification {},
448 ))
449 .unwrap_err();
450 assert_eq!(err.to_string(), "design-server socket is not open");
451
452 let _ = on_event(WsEvent::Opened);
453 let events = connection.handle_recv().unwrap();
454 assert!(matches!(events.as_slice(), [ConnectionEvent::Opened]));
455 assert!(connection.alive);
456 assert!(!connection.connecting);
457 }
458}