The socket had no recovery path: a mid-game drop left the table showing stale state with no indication, and plays were silently swallowed by the dead channel. Surface the server close code from ws::connect and add a reconnect driver in the game page: transient losses retry with exponential backoff (capped, then a manual Retry), while deliberate closes (session expired, game gone) stop retrying. Reconnects are free resyncs because the server pushes a full state snapshot on connect. Also gate card clicks while disconnected, show a connection banner, and drop the ticking interval and pending retries on unmount.
102 lines
3.3 KiB
Rust
102 lines
3.3 KiB
Rust
//! WebSocket client for live play.
|
|
use std::cell::RefCell;
|
|
use std::rc::Rc;
|
|
|
|
use futures::channel::mpsc;
|
|
use futures::{SinkExt, StreamExt};
|
|
use gloo_net::websocket::{futures::WebSocket, Message, WebSocketError};
|
|
use wasm_bindgen_futures::spawn_local;
|
|
|
|
use crate::model::ServerMessage;
|
|
|
|
fn ws_url(game_id: &str) -> String {
|
|
let location = web_sys::window().unwrap().location();
|
|
let protocol = location.protocol().unwrap_or_else(|_| "http:".into());
|
|
let scheme = if protocol == "https:" { "wss" } else { "ws" };
|
|
let host = location.host().unwrap_or_else(|_| "127.0.0.1:8080".into());
|
|
format!("{scheme}://{host}/ws/games/{game_id}")
|
|
}
|
|
|
|
/// A cloneable handle to the game websocket. Incoming server messages are
|
|
/// pushed into the receiver returned by [`connect`].
|
|
#[derive(Clone)]
|
|
pub struct GameSocket {
|
|
sender: Rc<RefCell<mpsc::UnboundedSender<String>>>,
|
|
}
|
|
|
|
impl GameSocket {
|
|
/// Play a card, optionally capturing the given table cards.
|
|
pub fn play(&self, card: &str, capture: Option<Vec<String>>) {
|
|
let mut msg = serde_json::json!({ "action": "play", "card": card });
|
|
if let Some(capture) = capture {
|
|
msg["capture"] = serde_json::json!(capture);
|
|
}
|
|
self.send_json(msg);
|
|
}
|
|
|
|
/// Ask the server for a fresh snapshot.
|
|
#[allow(dead_code)]
|
|
pub fn sync(&self) {
|
|
self.send_json(serde_json::json!({ "action": "state" }));
|
|
}
|
|
|
|
/// Acknowledge the hand-end scoring summary.
|
|
pub fn ack(&self) {
|
|
self.send_json(serde_json::json!({ "action": "ack" }));
|
|
}
|
|
|
|
fn send_json(&self, value: serde_json::Value) {
|
|
let _ = self.sender.borrow_mut().unbounded_send(value.to_string());
|
|
}
|
|
}
|
|
|
|
/// Open the websocket for `game_id` and forward parsed server messages to
|
|
/// `on_message`. Returns the socket handle, or `None` if the connection
|
|
/// could not be created.
|
|
///
|
|
/// `on_close` fires exactly once when the connection ends; it receives the
|
|
/// server close code when one was sent (e.g. 4401 unauthenticated, 4403 not
|
|
/// seated, 4404 unknown game) or `None` for an abnormal network loss.
|
|
pub fn connect(
|
|
game_id: &str,
|
|
on_message: impl Fn(ServerMessage) + 'static,
|
|
on_close: impl Fn(Option<u16>) + 'static,
|
|
) -> Option<GameSocket> {
|
|
let ws = WebSocket::open(&ws_url(game_id)).ok()?;
|
|
let (mut write, mut read) = ws.split();
|
|
|
|
// Outgoing channel: GameSocket::send_json queues text frames here.
|
|
let (out_tx, mut out_rx) = mpsc::unbounded::<String>();
|
|
spawn_local(async move {
|
|
while let Some(text) = out_rx.next().await {
|
|
if write.send(Message::Text(text)).await.is_err() {
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
|
|
spawn_local(async move {
|
|
let mut close_code = None;
|
|
while let Some(msg) = read.next().await {
|
|
match msg {
|
|
Ok(Message::Text(text)) => {
|
|
if let Ok(parsed) = serde_json::from_str::<ServerMessage>(&text) {
|
|
on_message(parsed);
|
|
}
|
|
}
|
|
Ok(Message::Bytes(_)) => {}
|
|
Err(WebSocketError::ConnectionClose(e)) => {
|
|
close_code = Some(e.code);
|
|
break;
|
|
}
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
on_close(close_code);
|
|
});
|
|
|
|
Some(GameSocket {
|
|
sender: Rc::new(RefCell::new(out_tx)),
|
|
})
|
|
}
|