use crate::{
agents::Agent,
errors::{AtomicError, AtomicResult},
sync::protocol,
};
use futures::{SinkExt, StreamExt};
use tokio::sync::{broadcast, mpsc};
use tokio_tungstenite::{connect_async, tungstenite::Message};
#[derive(Clone, Debug)]
pub enum WsMessage {
Commit(String),
Resource(String),
LoroSyncUpdate { subject: String, update: Vec<u8> },
LoroEphemeralUpdate { subject: String, update: Vec<u8> },
PresenceUpdate { subject: String, update: Vec<u8> },
Authenticated,
BlobResponse { hash: [u8; 32], bytes: Vec<u8> },
Update {
subject: String,
loro_bytes: Vec<u8>,
commit_id: Option<String>,
is_snapshot: bool,
is_push: bool,
},
Destroy { subject: String },
CommitOk {
request_id: u16,
commit_json: String,
},
SyncOk { drive: String },
SyncDiff {
drive: String,
pull: Vec<String>,
push: Vec<String>,
remove: Vec<String>,
},
SyncPush {
drive: String,
entries: Vec<(String, Vec<u8>)>,
last: bool,
},
BlobRequest { hash: [u8; 32] },
Error(String),
}
pub struct WsClient {
tx: mpsc::Sender<Message>,
broadcast_tx: broadcast::Sender<WsMessage>,
}
impl WsClient {
pub async fn connect(url: &str) -> AtomicResult<Self> {
let (ws_stream, _response) = connect_async(url)
.await
.map_err(|e| format!("WebSocket connection failed to {}: {}", url, e))?;
let (mut write, mut read) = ws_stream.split();
let (tx, mut rx) = mpsc::channel::<Message>(64);
let (broadcast_tx, _) = broadcast::channel::<WsMessage>(256);
let broadcast_tx_clone = broadcast_tx.clone();
tokio::spawn(async move {
while let Some(msg) = rx.recv().await {
if write.send(msg).await.is_err() {
break;
}
}
});
tokio::spawn(async move {
while let Some(Ok(msg)) = read.next().await {
let parsed = match msg {
Message::Text(text) => Some(parse_server_message(&text)),
Message::Binary(bin) => parse_binary_message(&bin),
_ => None,
};
if let Some(parsed) = parsed {
let _ = broadcast_tx_clone.send(parsed);
}
}
});
Ok(Self { tx, broadcast_tx })
}
pub fn subscribe(&self) -> broadcast::Receiver<WsMessage> {
self.broadcast_tx.subscribe()
}
pub async fn authenticate(&self, agent: &Agent) -> AtomicResult<()> {
let frame = protocol::encode_auth(agent, &agent.subject.to_string())?;
self.authenticate_with_frame(frame).await
}
pub async fn authenticate_with_frame(&self, frame: Vec<u8>) -> AtomicResult<()> {
let mut rx = self.subscribe();
self.send_binary(frame).await?;
let timeout = tokio::time::timeout(std::time::Duration::from_secs(5), async {
while let Ok(msg) = rx.recv().await {
match msg {
WsMessage::Authenticated => return Ok(()),
WsMessage::Error(e) => {
return Err(AtomicError::from(format!("Auth failed: {}", e)));
}
_ => continue,
}
}
Err(AtomicError::from("WebSocket closed during authentication"))
});
timeout
.await
.map_err(|_| AtomicError::from("Authentication timed out"))?
}
pub async fn subscribe_resource(&self, subject: &str) -> AtomicResult<()> {
self.send_raw(&format!("SUBSCRIBE {}", subject)).await
}
pub async fn subscribe_loro_sync(&self, subject: &str) -> AtomicResult<()> {
self.send_raw(&format!(
"LORO_SYNC_SUBSCRIBE {}",
serde_json::json!({ "subject": subject })
))
.await
}
pub async fn send_loro_sync_update(&self, subject: &str, update: &[u8]) -> AtomicResult<()> {
let b64 = crate::agents::encode_base64(update);
self.send_raw(&format!(
"LORO_SYNC_UPDATE {}",
serde_json::json!({ "subject": subject, "update": b64 })
))
.await
}
pub async fn send_loro_ephemeral_update(
&self,
subject: &str,
update: &[u8],
) -> AtomicResult<()> {
let b64 = crate::agents::encode_base64(update);
self.send_raw(&format!(
"LORO_EPHEMERAL_UPDATE {}",
serde_json::json!({ "subject": subject, "update": b64 })
))
.await
}
pub async fn subscribe_presence(&self, drive: &str) -> AtomicResult<()> {
self.send_raw(&format!(
"PRESENCE_SUBSCRIBE {}",
serde_json::json!({ "subject": drive })
))
.await
}
pub async fn unsubscribe_presence(&self, drive: &str) -> AtomicResult<()> {
self.send_raw(&format!(
"PRESENCE_UNSUBSCRIBE {}",
serde_json::json!({ "subject": drive })
))
.await
}
pub async fn send_presence_update(&self, drive: &str, update: &[u8]) -> AtomicResult<()> {
let b64 = crate::agents::encode_base64(update);
self.send_raw(&format!(
"PRESENCE_UPDATE {}",
serde_json::json!({ "subject": drive, "update": b64 })
))
.await
}
pub async fn fetch_blob(&self, hash: &[u8; 32]) -> AtomicResult<Vec<u8>> {
let mut rx = self.subscribe();
self.send_binary(protocol::encode_blob_request(hash))
.await?;
let timeout = tokio::time::timeout(std::time::Duration::from_secs(10), async {
while let Ok(msg) = rx.recv().await {
match msg {
WsMessage::BlobResponse {
hash: rcv_hash,
bytes,
} if rcv_hash == *hash => return Ok(bytes),
WsMessage::Error(e) => {
return Err(AtomicError::from(format!("Blob fetch error: {}", e)));
}
_ => continue,
}
}
Err(AtomicError::from("WebSocket closed during blob fetch"))
});
timeout
.await
.map_err(|_| AtomicError::from("Timeout fetching blob"))?
}
pub async fn send_raw(&self, msg: &str) -> AtomicResult<()> {
self.tx
.send(Message::Text(msg.to_string().into()))
.await
.map_err(|e| format!("Failed to send WebSocket message: {}", e).into())
}
pub async fn send_binary(&self, bytes: Vec<u8>) -> AtomicResult<()> {
self.tx
.send(Message::Binary(bytes.into()))
.await
.map_err(|e| format!("Failed to send WebSocket binary: {}", e).into())
}
pub async fn subscribe_drive(&self, drive_subject: &str) -> AtomicResult<()> {
self.send_binary(protocol::encode_sub(drive_subject)).await
}
pub async fn subscribe_query(
&self,
property: &str,
value: &str,
drive: &str,
) -> AtomicResult<()> {
let json = serde_json::json!({
"property": property,
"value": value,
"drive": drive,
});
self.send_raw(&format!("SUBSCRIBE_QUERY {}", json)).await
}
pub async fn post_commit(&self, request_id: u16, commit_json: &str) -> AtomicResult<String> {
let mut rx = self.subscribe();
self.send_binary(protocol::encode_commit(request_id, commit_json))
.await?;
let timeout = tokio::time::timeout(std::time::Duration::from_secs(30), async {
while let Ok(msg) = rx.recv().await {
match msg {
WsMessage::CommitOk {
request_id: rid,
commit_json,
} if rid == request_id => return Ok(commit_json),
WsMessage::Error(e) => {
return Err(AtomicError::from(format!("COMMIT failed: {}", e)));
}
_ => continue,
}
}
Err(AtomicError::from(
"WebSocket closed while waiting for COMMIT_OK",
))
});
timeout
.await
.map_err(|_| AtomicError::from("COMMIT timed out"))?
}
}
fn parse_server_message(text: &str) -> WsMessage {
if let Some(stripped) = text.strip_prefix("COMMIT ") {
WsMessage::Commit(stripped.to_string())
} else if let Some(stripped) = text.strip_prefix("RESOURCE ") {
WsMessage::Resource(stripped.to_string())
} else if let Some(stripped) = text.strip_prefix("LORO_SYNC_UPDATE ") {
match serde_json::from_str::<serde_json::Value>(stripped) {
Ok(v) => {
let subject = v["subject"].as_str().unwrap_or("").to_string();
let update_b64 = v["update"].as_str().unwrap_or("");
let update = crate::agents::decode_base64(update_b64).unwrap_or_default();
WsMessage::LoroSyncUpdate { subject, update }
}
Err(_) => WsMessage::Error(format!("Invalid LORO_SYNC_UPDATE: {}", text)),
}
} else if let Some(stripped) = text.strip_prefix("LORO_EPHEMERAL_UPDATE ") {
match serde_json::from_str::<serde_json::Value>(stripped) {
Ok(v) => {
let subject = v["subject"].as_str().unwrap_or("").to_string();
let update_b64 = v["update"].as_str().unwrap_or("");
let update = crate::agents::decode_base64(update_b64).unwrap_or_default();
WsMessage::LoroEphemeralUpdate { subject, update }
}
Err(_) => WsMessage::Error(format!("Invalid LORO_EPHEMERAL_UPDATE: {}", text)),
}
} else if let Some(stripped) = text.strip_prefix("PRESENCE_UPDATE ") {
match serde_json::from_str::<serde_json::Value>(stripped) {
Ok(v) => {
let subject = v["subject"].as_str().unwrap_or("").to_string();
let update_b64 = v["update"].as_str().unwrap_or("");
let update = crate::agents::decode_base64(update_b64).unwrap_or_default();
WsMessage::PresenceUpdate { subject, update }
}
Err(_) => WsMessage::Error(format!("Invalid PRESENCE_UPDATE: {}", text)),
}
} else if text.starts_with("AUTHENTICATED") {
WsMessage::Authenticated
} else if let Some(stripped) = text.strip_prefix("ERROR ") {
WsMessage::Error(stripped.to_string())
} else {
WsMessage::Error(format!("Unknown message: {}", text))
}
}
fn parse_binary_message(bin: &[u8]) -> Option<WsMessage> {
use protocol::tag;
let tag = *bin.first()?;
match tag {
tag::AUTH_OK => Some(WsMessage::Authenticated),
tag::ERROR => {
if bin.len() < 5 {
return Some(WsMessage::Error("Malformed ERROR frame".into()));
}
let msg = std::str::from_utf8(&bin[5..])
.unwrap_or("(non-utf8 error message)")
.to_string();
Some(WsMessage::Error(msg))
}
tag::BLOB_RESPONSE => {
let resp = protocol::decode_blob_response(&bin[1..])?;
Some(WsMessage::BlobResponse {
hash: resp.hash,
bytes: resp.bytes,
})
}
tag::UPDATE => decode_update_frame(&bin[1..]),
tag::DESTROY => {
if bin.len() < 3 {
return None;
}
let subject = std::str::from_utf8(&bin[3..]).ok()?.to_string();
Some(WsMessage::Destroy { subject })
}
tag::COMMIT_OK => {
let decoded = protocol::decode_commit(&bin[1..])?;
Some(WsMessage::CommitOk {
request_id: decoded.request_id,
commit_json: decoded.commit_json.to_string(),
})
}
tag::SYNC_OK => {
let data = &bin[1..];
if data.len() < 2 {
return None;
}
let drive_len = u16::from_be_bytes([data[0], data[1]]) as usize;
let drive = std::str::from_utf8(data.get(2..2 + drive_len)?)
.ok()?
.to_string();
Some(WsMessage::SyncOk { drive })
}
tag::SYNC_DIFF => {
let diff = protocol::decode_sync_diff(&bin[1..])?;
Some(WsMessage::SyncDiff {
drive: diff.drive,
pull: diff.pull,
push: diff.push,
remove: diff.remove,
})
}
tag::SYNC_PUSH => {
let push = protocol::decode_sync_push(&bin[1..])?;
Some(WsMessage::SyncPush {
drive: push.drive,
entries: push
.entries
.into_iter()
.map(|e| (e.subject, e.loro_bytes))
.collect(),
last: push.last,
})
}
tag::BLOB_REQUEST => {
let hash = protocol::decode_blob_request(&bin[1..])?;
Some(WsMessage::BlobRequest { hash })
}
_ => None,
}
}
fn decode_update_frame(payload: &[u8]) -> Option<WsMessage> {
use protocol::flags;
let decoded = protocol::decode_update(payload)?;
Some(WsMessage::Update {
subject: decoded.subject,
loro_bytes: decoded.loro_bytes,
commit_id: decoded.commit_id,
is_snapshot: decoded.flag_bits & flags::SNAPSHOT != 0,
is_push: decoded.flag_bits & flags::PUSH != 0,
})
}