fix(leptos): wire format corrections from whole-branch review
This commit is contained in:
@@ -18,15 +18,15 @@ impl std::fmt::Display for ApiError {
|
|||||||
impl std::error::Error for ApiError {}
|
impl std::error::Error for ApiError {}
|
||||||
|
|
||||||
fn get_base_url() -> String {
|
fn get_base_url() -> String {
|
||||||
// Try to read from a JS global set by index.html, or fall back to localhost
|
if let Some(window) = web_sys::window() {
|
||||||
let default = "http://localhost:3001";
|
let location = window.location();
|
||||||
js_sys::global()
|
let protocol = location.protocol().unwrap_or_else(|_| "http:".to_string());
|
||||||
.unchecked_ref::<web_sys::Window>()
|
let protocol = protocol.trim_end_matches(':');
|
||||||
.location()
|
let host = location.host().unwrap_or_else(|_| "localhost:3001".to_string());
|
||||||
.hostname()
|
format!("{}://{}", protocol, host)
|
||||||
.ok()
|
} else {
|
||||||
.map(|_| format!("http://localhost:3001"))
|
"http://localhost:3001".to_string()
|
||||||
.unwrap_or_else(|| default.to_string())
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_auth_header() -> Option<String> {
|
fn get_auth_header() -> Option<String> {
|
||||||
@@ -42,7 +42,7 @@ pub async fn request<T: DeserializeOwned>(
|
|||||||
) -> Result<T, ApiError> {
|
) -> Result<T, ApiError> {
|
||||||
let url = format!("{}{}", get_base_url(), path);
|
let url = format!("{}{}", get_base_url(), path);
|
||||||
|
|
||||||
let mut headers = Headers::new().map_err(|_| ApiError {
|
let headers = Headers::new().map_err(|_| ApiError {
|
||||||
message: "Failed to create headers".to_string(),
|
message: "Failed to create headers".to_string(),
|
||||||
status_code: 0,
|
status_code: 0,
|
||||||
})?;
|
})?;
|
||||||
@@ -51,13 +51,16 @@ pub async fn request<T: DeserializeOwned>(
|
|||||||
headers.set("X-Admin-Password", &password).ok();
|
headers.set("X-Admin-Password", &password).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut opts = RequestInit::new();
|
if body.is_some() {
|
||||||
|
headers.set("Content-Type", "application/json").ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
let opts = RequestInit::new();
|
||||||
opts.set_method(method);
|
opts.set_method(method);
|
||||||
opts.set_headers(&headers);
|
opts.set_headers(&headers);
|
||||||
opts.set_mode(RequestMode::Cors);
|
opts.set_mode(RequestMode::Cors);
|
||||||
|
|
||||||
if let Some(json_body) = body {
|
if let Some(json_body) = body {
|
||||||
headers.set("Content-Type", "application/json").ok();
|
|
||||||
opts.set_body(&JsValue::from_str(json_body));
|
opts.set_body(&JsValue::from_str(json_body));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ pub async fn get_voice_status() -> Result<VoiceStatus, ApiError> {
|
|||||||
|
|
||||||
/// POST /api/voice/connect { guildId, channelId }
|
/// POST /api/voice/connect { guildId, channelId }
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
struct ConnectPayload {
|
struct ConnectPayload {
|
||||||
guild_id: String,
|
guild_id: String,
|
||||||
channel_id: String,
|
channel_id: String,
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ pub fn App() -> impl IntoView {
|
|||||||
provide_context(auth.clone());
|
provide_context(auth.clone());
|
||||||
provide_context(ui.clone());
|
provide_context(ui.clone());
|
||||||
|
|
||||||
let ws = WsContext::new("ws://localhost:3001");
|
let ws = WsContext::new("ws://localhost:3001/ws");
|
||||||
provide_context(ws.clone());
|
provide_context(ws.clone());
|
||||||
|
|
||||||
// Auth check: redirect "live" tab to "messages" if not authenticated
|
// Auth check: redirect "live" tab to "messages" if not authenticated
|
||||||
|
|||||||
@@ -101,7 +101,11 @@ impl WsContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"media_state" => {
|
"media_state" => {
|
||||||
if let Some(d) = data.and_then(|v| serde_json::from_value::<MediaState>(v.clone()).ok()) {
|
// Backend sends initial state with "state" key, live updates with "data"
|
||||||
|
let raw = data
|
||||||
|
.or_else(|| parsed.get("state"))
|
||||||
|
.cloned();
|
||||||
|
if let Some(d) = raw.and_then(|v| serde_json::from_value::<MediaState>(v).ok()) {
|
||||||
if let Some(cb) = self.on_media_state.borrow().as_ref() {
|
if let Some(cb) = self.on_media_state.borrow().as_ref() {
|
||||||
cb(d);
|
cb(d);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,40 +55,81 @@ impl WsHandle {
|
|||||||
|
|
||||||
let url = self.url.clone();
|
let url = self.url.clone();
|
||||||
let status_clone = self.set_status.clone();
|
let status_clone = self.set_status.clone();
|
||||||
// Clone the Rc wrapper (cheap pointer copy, inner RefCell is shared)
|
|
||||||
let event_clone: std::rc::Rc<std::cell::RefCell<Option<Box<dyn Fn(WsEvent)>>>> = self.on_event.clone();
|
let event_clone: std::rc::Rc<std::cell::RefCell<Option<Box<dyn Fn(WsEvent)>>>> = self.on_event.clone();
|
||||||
let ws_holder = &self.ws as *const std::cell::RefCell<Option<WebSocket>>;
|
let ws_holder = &self.ws as *const std::cell::RefCell<Option<WebSocket>>;
|
||||||
let reconnect_attempt = &self.reconnect_attempt as *const std::cell::Cell<u32>;
|
let reconnect_attempt = &self.reconnect_attempt as *const std::cell::Cell<u32>;
|
||||||
|
|
||||||
// Clone again for closures
|
Self::perform_connect(&url, status_clone, event_clone, ws_holder, reconnect_attempt);
|
||||||
let status2 = status_clone.clone();
|
}
|
||||||
let status3 = status_clone.clone();
|
|
||||||
|
|
||||||
match WebSocket::new(&url) {
|
/// Shared connection setup used for both initial connect and reconnection.
|
||||||
|
/// Takes raw pointers because it must be callable from `wasm_bindgen` closures
|
||||||
|
/// that cannot borrow `self`.
|
||||||
|
#[allow(unsafe_code)]
|
||||||
|
fn perform_connect(
|
||||||
|
url: &str,
|
||||||
|
set_status: WriteSignal<WsStatus>,
|
||||||
|
on_event: std::rc::Rc<std::cell::RefCell<Option<Box<dyn Fn(WsEvent)>>>>,
|
||||||
|
ws_holder: *const std::cell::RefCell<Option<WebSocket>>,
|
||||||
|
reconnect_attempt: *const std::cell::Cell<u32>,
|
||||||
|
) {
|
||||||
|
let url_owned = url.to_string();
|
||||||
|
let status1 = set_status.clone();
|
||||||
|
let status2 = set_status.clone();
|
||||||
|
let status3 = set_status.clone();
|
||||||
|
let event_clone = on_event.clone();
|
||||||
|
|
||||||
|
match WebSocket::new(&url_owned) {
|
||||||
Ok(ws) => {
|
Ok(ws) => {
|
||||||
// Store reference
|
// Store reference
|
||||||
unsafe { *(*ws_holder).borrow_mut() = Some(ws.clone()) };
|
unsafe { *(*ws_holder).borrow_mut() = Some(ws.clone()) };
|
||||||
|
|
||||||
// onopen
|
// onopen
|
||||||
let onopen_cb = Closure::<dyn Fn(web_sys::ProgressEvent)>::new(move |_| {
|
let onopen_cb = Closure::<dyn Fn(web_sys::ProgressEvent)>::new(move |_| {
|
||||||
status_clone.set(WsStatus::Connected);
|
status1.set(WsStatus::Connected);
|
||||||
unsafe { (*reconnect_attempt).set(0) };
|
unsafe { (*reconnect_attempt).set(0) };
|
||||||
});
|
});
|
||||||
ws.set_onopen(Some(onopen_cb.as_ref().unchecked_ref()));
|
ws.set_onopen(Some(onopen_cb.as_ref().unchecked_ref()));
|
||||||
onopen_cb.forget();
|
onopen_cb.forget();
|
||||||
|
|
||||||
// onclose
|
// onclose — schedule reconnect with exponential backoff
|
||||||
|
let event_for_close = event_clone.clone();
|
||||||
let onclose_cb = Closure::<dyn Fn(CloseEvent)>::new(move |_| {
|
let onclose_cb = Closure::<dyn Fn(CloseEvent)>::new(move |_| {
|
||||||
status2.set(WsStatus::Disconnected);
|
status2.set(WsStatus::Disconnected);
|
||||||
unsafe { *(*ws_holder).borrow_mut() = None };
|
unsafe { *(*ws_holder).borrow_mut() = None };
|
||||||
|
|
||||||
|
let attempt = unsafe { (*reconnect_attempt).get() };
|
||||||
|
if attempt >= 20 {
|
||||||
|
status2.set(WsStatus::Error("Max reconnect attempts reached".to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Full-jitter exponential backoff: min(1000 * 2^attempt, 30000) * (0.5 + random * 0.5)
|
||||||
|
let base = core::cmp::min(1000u32 * (1u32 << attempt), 30000u32);
|
||||||
|
let jitter = 0.5 + js_sys::Math::random() * 0.5;
|
||||||
|
let delay_ms = (base as f64 * jitter) as u32;
|
||||||
|
unsafe { (*reconnect_attempt).set(attempt + 1) };
|
||||||
|
|
||||||
|
let url_reconnect = url_owned.clone();
|
||||||
|
let status_rc = status2.clone();
|
||||||
|
let event_rc = event_for_close.clone();
|
||||||
|
let reconnect_fn = Closure::<dyn Fn()>::new(move || {
|
||||||
|
Self::perform_connect(&url_reconnect, status_rc.clone(), event_rc.clone(), ws_holder, reconnect_attempt);
|
||||||
|
});
|
||||||
|
web_sys::window()
|
||||||
|
.and_then(|w| {
|
||||||
|
w.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||||
|
reconnect_fn.as_ref().unchecked_ref(),
|
||||||
|
delay_ms as i32,
|
||||||
|
).ok()
|
||||||
|
});
|
||||||
|
reconnect_fn.forget();
|
||||||
});
|
});
|
||||||
ws.set_onclose(Some(onclose_cb.as_ref().unchecked_ref()));
|
ws.set_onclose(Some(onclose_cb.as_ref().unchecked_ref()));
|
||||||
onclose_cb.forget();
|
onclose_cb.forget();
|
||||||
|
|
||||||
// onerror
|
// onerror
|
||||||
let onerror_cb = Closure::<dyn Fn(ErrorEvent)>::new(move |e: ErrorEvent| {
|
let onerror_cb = Closure::<dyn Fn(ErrorEvent)>::new(move |e: ErrorEvent| {
|
||||||
let msg = e.message();
|
status3.set(WsStatus::Error(e.message()));
|
||||||
status3.set(WsStatus::Error(msg));
|
|
||||||
});
|
});
|
||||||
ws.set_onerror(Some(onerror_cb.as_ref().unchecked_ref()));
|
ws.set_onerror(Some(onerror_cb.as_ref().unchecked_ref()));
|
||||||
onerror_cb.forget();
|
onerror_cb.forget();
|
||||||
@@ -105,12 +146,7 @@ impl WsHandle {
|
|||||||
u8view.copy_to(&mut bytes);
|
u8view.copy_to(&mut bytes);
|
||||||
cb(WsEvent::Binary(bytes));
|
cb(WsEvent::Binary(bytes));
|
||||||
} else {
|
} else {
|
||||||
// Try Blob
|
// Blob — would need async FileReader, skip for now
|
||||||
let data = e.data();
|
|
||||||
let blob = data.dyn_ref::<web_sys::Blob>();
|
|
||||||
if blob.is_some() {
|
|
||||||
// Blob handling would need async FileReader — skip for now
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -118,7 +154,7 @@ impl WsHandle {
|
|||||||
onmsg_cb.forget();
|
onmsg_cb.forget();
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
status_clone.set(WsStatus::Error(
|
set_status.set(WsStatus::Error(
|
||||||
js_sys::Error::from(e).to_string().as_string().unwrap_or_default(),
|
js_sys::Error::from(e).to_string().as_string().unwrap_or_default(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ pub struct Channel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct GuildVoiceEntry {
|
pub struct GuildVoiceEntry {
|
||||||
pub guild_id: String,
|
pub guild_id: String,
|
||||||
pub channel_id: String,
|
pub channel_id: String,
|
||||||
|
|||||||
@@ -32,5 +32,6 @@ pub struct VoiceRecordingListResponse {
|
|||||||
pub items: Vec<VoiceRecording>,
|
pub items: Vec<VoiceRecording>,
|
||||||
#[serde(rename = "nextCursor")]
|
#[serde(rename = "nextCursor")]
|
||||||
pub next_cursor: Option<String>,
|
pub next_cursor: Option<String>,
|
||||||
|
#[serde(rename = "hasMore")]
|
||||||
pub has_more: bool,
|
pub has_more: bool,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use crate::guild::GuildVoiceEntry;
|
use crate::guild::GuildVoiceEntry;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct VoiceStatus {
|
pub struct VoiceStatus {
|
||||||
pub connected: bool,
|
pub connected: bool,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -19,6 +20,7 @@ pub struct ActiveSpeaker {
|
|||||||
pub id: Option<String>,
|
pub id: Option<String>,
|
||||||
pub user_id: String,
|
pub user_id: String,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub avatar: String,
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub avatar: Option<String>,
|
||||||
pub speaking: bool,
|
pub speaking: bool,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user