use crate::protocol::{SignalEnvelope, SignalPayload};
#[allow(unused_imports)] use futures_util::SinkExt;
use futures_util::StreamExt;
use matchbox_socket::async_trait::async_trait;
use matchbox_socket::{
PeerEvent, PeerId, PeerRequest, PeerSignal, SignalingError, Signaller, SignallerBuilder,
};
use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
use std::time::Duration;
use uuid::Uuid;
#[cfg(not(target_arch = "wasm32"))]
use async_tungstenite::WebSocketStream;
#[cfg(not(target_arch = "wasm32"))]
use async_tungstenite::async_std::{ConnectStream, connect_async};
#[cfg(not(target_arch = "wasm32"))]
use async_tungstenite::tungstenite;
#[cfg(target_arch = "wasm32")]
use ws_stream_wasm::{WsMessage as WasmWsMessage, WsMeta, WsStream};
const DID_NAMESPACE: Uuid = Uuid::from_bytes([
0x6b, 0xa7, 0xb8, 0x14, 0x9d, 0xad, 0x11, 0xd1, 0x80, 0xb4, 0x00, 0xc0, 0x4f, 0xd4, 0x30, 0xc8,
]);
fn session_id_to_peer_id(session_id: &str) -> PeerId {
match Uuid::parse_str(session_id) {
Ok(uuid) => PeerId(uuid),
Err(_) => PeerId(Uuid::new_v5(&DID_NAMESPACE, session_id.as_bytes())),
}
}
pub type PeerSessionMap = Arc<std::sync::RwLock<HashMap<PeerId, String>>>;
#[derive(bevy::prelude::Resource, Clone)]
pub struct PeerSessionMapRes(pub PeerSessionMap);
impl Default for PeerSessionMapRes {
fn default() -> Self {
Self(Arc::new(std::sync::RwLock::new(HashMap::new())))
}
}
impl PeerSessionMapRes {
pub fn session_id(&self, peer_id: &matchbox_socket::PeerId) -> Option<String> {
self.0
.read()
.unwrap_or_else(|e| e.into_inner())
.get(peer_id)
.cloned()
}
}
#[derive(bevy::prelude::Resource, Clone)]
pub struct TokenSourceRes(pub TokenSource);
pub type TokenSource = Arc<std::sync::RwLock<Option<String>>>;
#[derive(Debug, Clone)]
pub struct SymbiosSignallerBuilder {
token_source: Option<TokenSource>,
session_map: Option<PeerSessionMap>,
}
impl SymbiosSignallerBuilder {
fn current_token(&self) -> Option<String> {
self.token_source
.as_ref()
.and_then(|s| s.read().unwrap_or_else(|e| e.into_inner()).clone())
}
#[cfg(target_arch = "wasm32")]
fn current_token_fingerprint(&self) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
self.current_token().hash(&mut h);
h.finish()
}
}
#[cfg(target_arch = "wasm32")]
const WASM_MAX_BLIND_RETRIES: u32 = 5;
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
impl SignallerBuilder for SymbiosSignallerBuilder {
async fn new_signaller(
&self,
mut attempts: Option<u16>,
room_url: String,
) -> Result<Box<dyn Signaller>, SignalingError> {
#[cfg(target_arch = "wasm32")]
let mut wasm_blind_failures: u32 = 0;
#[cfg(target_arch = "wasm32")]
let mut wasm_last_failed_token: Option<u64> = None;
let signaller = 'connect: loop {
let ws = match self.try_connect(&room_url).await {
Ok(stream) => stream,
Err(e) => {
if is_http_client_error(&e) {
return Err(e);
}
#[cfg(target_arch = "wasm32")]
{
let token_fp = self.current_token_fingerprint();
if wasm_last_failed_token == Some(token_fp) {
wasm_blind_failures = wasm_blind_failures.saturating_add(1);
} else {
wasm_blind_failures = 1;
wasm_last_failed_token = Some(token_fp);
}
if wasm_blind_failures >= WASM_MAX_BLIND_RETRIES {
tracing::error!(
attempts = wasm_blind_failures,
"WASM relay connection failed {WASM_MAX_BLIND_RETRIES} times in \
a row with the same token; aborting (browser WebSocket API \
hides HTTP status codes, so we cannot tell auth failures \
apart from network errors — refresh the auth token to retry)"
);
return Err(SignalingError::UserImplementationError(
"wasm_blind_retry_exhausted: relay rejected the same token \
repeatedly; refresh the auth token before reconnecting"
.to_string(),
));
}
}
if let Some(ref mut remaining) = attempts {
if *remaining <= 1 {
return Err(SignalingError::NegotiationFailed(Box::new(e)));
}
*remaining -= 1;
tracing::warn!(
attempts_remaining = *remaining,
"connection to relay failed, retrying in 3s"
);
futures_timer::Delay::new(Duration::from_secs(3)).await;
continue 'connect;
}
tracing::warn!("connection to relay failed, retrying in 3s");
futures_timer::Delay::new(Duration::from_secs(3)).await;
continue 'connect;
}
};
let mut signaller = SymbiosSignaller {
ws,
local_peer_id: PeerId(Uuid::nil()),
session_to_peer: HashMap::new(),
peer_to_session: HashMap::new(),
pending_events: VecDeque::new(),
session_map: self.session_map.clone(),
};
match signaller.read_welcome().await {
Ok(()) => break signaller,
Err(e) => {
if let Some(ref mut remaining) = attempts {
if *remaining <= 1 {
return Err(e);
}
*remaining -= 1;
tracing::warn!(
attempts_remaining = *remaining,
"welcome handshake failed, retrying in 3s"
);
futures_timer::Delay::new(Duration::from_secs(3)).await;
continue 'connect;
}
tracing::warn!("welcome handshake failed, retrying in 3s");
futures_timer::Delay::new(Duration::from_secs(3)).await;
continue 'connect;
}
}
};
Ok(Box::new(signaller))
}
}
fn is_http_client_error(e: &SignalingError) -> bool {
matches!(e, SignalingError::UserImplementationError(s) if s.starts_with("http_client_error:"))
}
#[cfg(not(target_arch = "wasm32"))]
const WS_CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
#[cfg(not(target_arch = "wasm32"))]
impl SymbiosSignallerBuilder {
async fn try_connect(
&self,
room_url: &str,
) -> Result<WebSocketStream<ConnectStream>, SignalingError> {
use futures_util::future::Either;
let token = self.current_token();
let request = build_ws_request(room_url, token.as_deref())
.map_err(|e| SignalingError::UserImplementationError(e.to_string()))?;
let connect_fut = connect_async(request);
let timeout_fut = futures_timer::Delay::new(WS_CONNECT_TIMEOUT);
futures_util::pin_mut!(connect_fut);
futures_util::pin_mut!(timeout_fut);
match futures_util::future::select(connect_fut, timeout_fut).await {
Either::Left((result, _)) => {
let (stream, _) = result.map_err(|e| {
if let tungstenite::Error::Http(ref resp) = e {
let code = resp.status().as_u16();
if resp.status().is_client_error() {
tracing::error!(
status = code,
"relay rejected connection (HTTP 4xx) — not retrying"
);
return SignalingError::UserImplementationError(format!(
"http_client_error:{code}"
));
}
}
SignalingError::from(e)
})?;
Ok(stream)
}
Either::Right(_) => Err(SignalingError::UserImplementationError(
"WebSocket connection timed out".to_string(),
)),
}
}
}
#[cfg(not(target_arch = "wasm32"))]
fn build_ws_request(
url: &str,
access_jwt: Option<&str>,
) -> Result<tungstenite::http::Request<()>, tungstenite::Error> {
use tungstenite::client::IntoClientRequest;
let mut request = url.into_client_request()?;
if let Some(token) = access_jwt {
let header_value = format!("Bearer {token}")
.parse::<tungstenite::http::HeaderValue>()
.map_err(|e| tungstenite::Error::HttpFormat(e.into()))?;
request.headers_mut().insert("Authorization", header_value);
}
Ok(request)
}
#[cfg(target_arch = "wasm32")]
impl SymbiosSignallerBuilder {
async fn try_connect(&self, room_url: &str) -> Result<WsStream, SignalingError> {
let token = self.current_token();
let protocols: Vec<&str> = match token.as_deref() {
Some(t) => vec!["access_token", t],
None => vec![],
};
let (_meta, stream) = WsMeta::connect(room_url, Some(protocols))
.await
.map_err(|e| SignalingError::UserImplementationError(e.to_string()))?;
Ok(stream)
}
}
pub struct SymbiosSignaller {
#[cfg(not(target_arch = "wasm32"))]
ws: WebSocketStream<ConnectStream>,
#[cfg(target_arch = "wasm32")]
ws: WsStream,
local_peer_id: PeerId,
session_to_peer: HashMap<String, PeerId>,
peer_to_session: HashMap<PeerId, String>,
pending_events: VecDeque<PeerEvent>,
session_map: Option<PeerSessionMap>,
}
impl SymbiosSignaller {
fn publish_peer(&self, peer: PeerId, session_id: &str) {
if let Some(map) = &self.session_map {
map.write()
.unwrap_or_else(|e| e.into_inner())
.insert(peer, session_id.to_owned());
}
}
fn unpublish_peer(&self, peer: &PeerId) {
if let Some(map) = &self.session_map {
map.write().unwrap_or_else(|e| e.into_inner()).remove(peer);
}
}
}
impl SymbiosSignaller {
async fn read_welcome(&mut self) -> Result<(), SignalingError> {
let session_msg = self.read_text().await?;
let session_json: serde_json::Value = serde_json::from_str(&session_msg).map_err(|e| {
SignalingError::UserImplementationError(format!("invalid session_id message: {e}"))
})?;
let session_id = session_json
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| {
SignalingError::UserImplementationError("missing 'id' in session_id message".into())
})?;
self.local_peer_id = session_id_to_peer_id(session_id);
self.track_session(session_id.to_owned(), self.local_peer_id);
self.pending_events
.push_back(PeerEvent::IdAssigned(self.local_peer_id));
let peer_list_msg = self.read_text().await?;
let peer_list_json: serde_json::Value =
serde_json::from_str(&peer_list_msg).map_err(|e| {
SignalingError::UserImplementationError(format!("invalid peer_list message: {e}"))
})?;
if let Some(peers) = peer_list_json.get("peers").and_then(|v| v.as_array()) {
for peer_val in peers {
if let Some(sid) = peer_val.as_str() {
let pid = self.get_or_create_peer_id(sid);
self.pending_events.push_back(PeerEvent::NewPeer(pid));
}
}
}
Ok(())
}
fn get_or_create_peer_id(&mut self, session_id: &str) -> PeerId {
if let Some(&pid) = self.session_to_peer.get(session_id) {
tracing::debug!(
session = %session_id,
peer = %pid,
"peer ID lookup: reusing existing mapping",
);
return pid;
}
let pid = PeerId(uuid::Uuid::new_v4());
tracing::debug!(
session = %session_id,
peer = %pid,
known_peers = self.session_to_peer.len(),
"peer ID lookup: minted new PeerId (first time seeing this session)",
);
self.track_session(session_id.to_owned(), pid);
pid
}
fn track_session(&mut self, session_id: String, peer_id: PeerId) {
self.session_to_peer.insert(session_id.clone(), peer_id);
self.peer_to_session.insert(peer_id, session_id.clone());
if peer_id != self.local_peer_id {
self.publish_peer(peer_id, &session_id);
}
}
fn remove_peer(&mut self, session_id: &str) -> PeerId {
let pid = self
.session_to_peer
.remove(session_id)
.unwrap_or_else(|| session_id_to_peer_id(session_id));
self.peer_to_session.remove(&pid);
self.unpublish_peer(&pid);
pid
}
}
impl Drop for SymbiosSignaller {
fn drop(&mut self) {
let Some(map) = self.session_map.as_ref() else {
return;
};
let mut guard = map.write().unwrap_or_else(|e| e.into_inner());
for pid in self.peer_to_session.keys() {
guard.remove(pid);
}
}
}
#[cfg(not(target_arch = "wasm32"))]
impl SymbiosSignaller {
async fn read_text(&mut self) -> Result<String, SignalingError> {
loop {
match self.ws.next().await {
Some(Ok(tungstenite::Message::Text(t))) => return Ok(t.to_string()),
Some(Ok(tungstenite::Message::Close(_))) | None => {
return Err(SignalingError::StreamExhausted);
}
Some(Ok(_)) => continue, Some(Err(e)) => return Err(SignalingError::from(e)),
}
}
}
}
#[cfg(target_arch = "wasm32")]
impl SymbiosSignaller {
async fn read_text(&mut self) -> Result<String, SignalingError> {
loop {
match self.ws.next().await {
Some(WasmWsMessage::Text(t)) => return Ok(t),
Some(WasmWsMessage::Binary(_)) => continue,
None => return Err(SignalingError::StreamExhausted),
}
}
}
}
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
impl Signaller for SymbiosSignaller {
async fn send(&mut self, request: PeerRequest) -> Result<(), SignalingError> {
match request {
PeerRequest::Signal { receiver, data } => {
let target_session = match self.peer_to_session.get(&receiver) {
Some(s) => s.clone(),
None => {
tracing::debug!(%receiver, "dropping signal to unknown peer (likely disconnected)");
return Ok(());
}
};
let signal_kind = match &data {
PeerSignal::Offer(_) => "Offer",
PeerSignal::Answer(_) => "Answer",
PeerSignal::IceCandidate(_) => "IceCandidate",
};
tracing::debug!(
local = %self.local_peer_id,
%receiver,
target = %target_session,
signal = signal_kind,
"signaller TX",
);
let signal = match data {
PeerSignal::Offer(sdp) => SignalPayload::Offer(sdp),
PeerSignal::Answer(sdp) => SignalPayload::Answer(sdp),
PeerSignal::IceCandidate(c) => SignalPayload::IceCandidate(c),
};
let envelope = SignalEnvelope {
peer_id: target_session,
signal,
};
let json = serde_json::to_string(&envelope)
.map_err(|e| SignalingError::UserImplementationError(e.to_string()))?;
self.send_text(json).await
}
PeerRequest::KeepAlive => self.send_ping().await,
}
}
async fn next_message(&mut self) -> Result<PeerEvent, SignalingError> {
if let Some(event) = self.pending_events.pop_front() {
return Ok(event);
}
loop {
let text = self.read_text().await?;
if let Ok(envelope) = serde_json::from_str::<SignalEnvelope>(&text) {
let sender_id = &envelope.peer_id;
let signal_kind = match &envelope.signal {
SignalPayload::Offer(_) => "Offer",
SignalPayload::Answer(_) => "Answer",
SignalPayload::IceCandidate(_) => "IceCandidate",
SignalPayload::PeerJoined(_) => "PeerJoined",
SignalPayload::PeerLeft(_) => "PeerLeft",
};
tracing::debug!(
local = %self.local_peer_id,
sender_session = %sender_id,
signal = signal_kind,
"signaller RX",
);
return match envelope.signal {
SignalPayload::Offer(sdp) => {
let pid = self.get_or_create_peer_id(sender_id);
tracing::debug!(
local = %self.local_peer_id,
sender_session = %sender_id,
mapped_peer = %pid,
"RX Offer → forwarding to matchbox as PeerSignal::Offer",
);
Ok(PeerEvent::Signal {
sender: pid,
data: PeerSignal::Offer(sdp),
})
}
SignalPayload::Answer(sdp) => {
let pid = self.get_or_create_peer_id(sender_id);
tracing::debug!(
local = %self.local_peer_id,
sender_session = %sender_id,
mapped_peer = %pid,
"RX Answer → forwarding to matchbox as PeerSignal::Answer",
);
Ok(PeerEvent::Signal {
sender: pid,
data: PeerSignal::Answer(sdp),
})
}
SignalPayload::IceCandidate(c) => {
let pid = self.get_or_create_peer_id(sender_id);
Ok(PeerEvent::Signal {
sender: pid,
data: PeerSignal::IceCandidate(c),
})
}
SignalPayload::PeerJoined(ref id) => {
let pid = self.get_or_create_peer_id(id);
tracing::debug!(
local = %self.local_peer_id,
joined_session = %id,
mapped_peer = %pid,
"RX PeerJoined → registered mapping (not emitting NewPeer \
to avoid offer glare; will accept incoming Offer lazily)",
);
continue;
}
SignalPayload::PeerLeft(ref id) => {
let pid = self.remove_peer(id);
tracing::debug!(
local = %self.local_peer_id,
left_session = %id,
mapped_peer = %pid,
"RX PeerLeft → emitting PeerLeft",
);
Ok(PeerEvent::PeerLeft(pid))
}
};
}
tracing::debug!(msg = %text, "ignoring unrecognized relay message");
}
}
}
#[cfg(not(target_arch = "wasm32"))]
impl SymbiosSignaller {
async fn send_text(&mut self, text: String) -> Result<(), SignalingError> {
self.ws
.send(tungstenite::Message::Text(text.into()))
.await
.map_err(SignalingError::from)
}
async fn send_ping(&mut self) -> Result<(), SignalingError> {
self.ws
.send(tungstenite::Message::Ping(vec![].into()))
.await
.map_err(SignalingError::from)
}
}
#[cfg(target_arch = "wasm32")]
impl SymbiosSignaller {
async fn send_text(&mut self, text: String) -> Result<(), SignalingError> {
self.ws
.send(WasmWsMessage::Text(text))
.await
.map_err(|e| SignalingError::UserImplementationError(e.to_string()))
}
async fn send_ping(&mut self) -> Result<(), SignalingError> {
Ok(())
}
}
pub fn signaller_with_token_source(source: TokenSource) -> Arc<dyn SignallerBuilder> {
Arc::new(SymbiosSignallerBuilder {
token_source: Some(source),
session_map: None,
})
}
pub fn signaller_anonymous() -> Arc<dyn SignallerBuilder> {
Arc::new(SymbiosSignallerBuilder {
token_source: None,
session_map: None,
})
}
pub fn signaller_anonymous_with_map(session_map: PeerSessionMap) -> Arc<dyn SignallerBuilder> {
Arc::new(SymbiosSignallerBuilder {
token_source: None,
session_map: Some(session_map),
})
}
pub fn signaller_with_token_source_and_map(
source: TokenSource,
session_map: PeerSessionMap,
) -> Arc<dyn SignallerBuilder> {
Arc::new(SymbiosSignallerBuilder {
token_source: Some(source),
session_map: Some(session_map),
})
}