Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -0,0 +1,16 @@
[package]
name = "nomifun-realtime"
version.workspace = true
edition.workspace = true
[dependencies]
nomifun-api-types.workspace = true
axum.workspace = true
tokio.workspace = true
dashmap.workspace = true
futures-util.workspace = true
serde_json.workspace = true
tracing.workspace = true
[dev-dependencies]
tokio-tungstenite.workspace = true
@@ -0,0 +1,147 @@
use nomifun_api_types::WebSocketMessage;
use tokio::sync::broadcast;
use tracing::warn;
/// Trait for broadcasting WebSocket events to all connected clients.
///
/// Business modules depend on this trait (via `Arc<dyn EventBroadcaster>`)
/// to push events without coupling to WebSocket internals.
///
/// Note: `send_to` (unicast) is intentionally NOT part of this trait.
/// Unicast is a connection-management concern handled by `WebSocketManager`.
pub trait EventBroadcaster: Send + Sync {
/// Broadcast an event to all connected WebSocket clients.
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>);
}
/// Default implementation of [`EventBroadcaster`] backed by
/// `tokio::sync::broadcast` channel.
///
/// The broadcast channel is used for module-to-WebSocket event fan-out.
/// Each `WebSocketManager` connection subscribes to this channel and
/// forwards received events to its per-connection `mpsc` sender.
pub struct BroadcastEventBus {
tx: broadcast::Sender<WebSocketMessage<serde_json::Value>>,
}
impl BroadcastEventBus {
/// Create a new event bus with the given channel capacity.
pub fn new(capacity: usize) -> Self {
let (tx, _rx) = broadcast::channel(capacity);
Self { tx }
}
/// Subscribe to receive broadcast events.
///
/// Each WebSocket connection calls this once to get its own receiver.
pub fn subscribe(&self) -> broadcast::Receiver<WebSocketMessage<serde_json::Value>> {
self.tx.subscribe()
}
/// Returns the number of active subscribers.
pub fn receiver_count(&self) -> usize {
self.tx.receiver_count()
}
}
impl EventBroadcaster for BroadcastEventBus {
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
if let Err(e) = self.tx.send(event) {
warn!(
event_name = %e.0.name,
"broadcast failed: no active receivers"
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn new_bus_has_zero_receivers() {
let bus = BroadcastEventBus::new(16);
assert_eq!(bus.receiver_count(), 0);
}
#[test]
fn subscribe_increments_receiver_count() {
let bus = BroadcastEventBus::new(16);
let _rx1 = bus.subscribe();
assert_eq!(bus.receiver_count(), 1);
let _rx2 = bus.subscribe();
assert_eq!(bus.receiver_count(), 2);
}
#[test]
fn drop_receiver_decrements_count() {
let bus = BroadcastEventBus::new(16);
let rx = bus.subscribe();
assert_eq!(bus.receiver_count(), 1);
drop(rx);
assert_eq!(bus.receiver_count(), 0);
}
#[test]
fn broadcast_without_receivers_does_not_panic() {
let bus = BroadcastEventBus::new(16);
let event = WebSocketMessage::new("test", json!({}));
bus.broadcast(event);
}
#[tokio::test]
async fn broadcast_delivers_to_subscriber() {
let bus = BroadcastEventBus::new(16);
let mut rx = bus.subscribe();
let event = WebSocketMessage::new("chat:update", json!({"id": 1}));
bus.broadcast(event);
let received = rx.recv().await.unwrap();
assert_eq!(received.name, "chat:update");
assert_eq!(received.data["id"], 1);
}
#[tokio::test]
async fn broadcast_delivers_to_all_subscribers() {
let bus = BroadcastEventBus::new(16);
let mut rx1 = bus.subscribe();
let mut rx2 = bus.subscribe();
let event = WebSocketMessage::new("ping", json!({"ts": 100}));
bus.broadcast(event);
let msg1 = rx1.recv().await.unwrap();
let msg2 = rx2.recv().await.unwrap();
assert_eq!(msg1.name, "ping");
assert_eq!(msg2.name, "ping");
assert_eq!(msg1.data, msg2.data);
}
#[tokio::test]
async fn multiple_broadcasts_in_order() {
let bus = BroadcastEventBus::new(16);
let mut rx = bus.subscribe();
for i in 0..5 {
let event = WebSocketMessage::new(format!("event-{i}"), json!({"seq": i}));
bus.broadcast(event);
}
for i in 0..5 {
let msg = rx.recv().await.unwrap();
assert_eq!(msg.name, format!("event-{i}"));
assert_eq!(msg.data["seq"], i);
}
}
#[test]
fn trait_object_compatible() {
let bus = BroadcastEventBus::new(16);
let broadcaster: &dyn EventBroadcaster = &bus;
let event = WebSocketMessage::new("test", json!(null));
broadcaster.broadcast(event);
}
}
@@ -0,0 +1,492 @@
use std::sync::Arc;
use axum::extract::WebSocketUpgrade;
use axum::extract::ws::{CloseFrame, Message, WebSocket};
use axum::http::HeaderMap;
use axum::response::IntoResponse;
use futures_util::{SinkExt, StreamExt};
use nomifun_api_types::WebSocketMessage;
use serde_json::{Value, json};
use tokio::sync::mpsc;
use tracing::{debug, info};
use crate::manager::{TokenValidator, WebSocketManager};
use crate::router::MessageRouter;
use crate::types::{ConnectionId, PER_CONNECTION_BUFFER, WebSocketCloseCode, WsOutbound};
/// Extracts a JWT token from WebSocket upgrade request headers.
///
/// Injected by `nomifun-app` — wraps `nomifun_auth::extract_token_from_ws_headers`
/// so that `nomifun-realtime` does not depend on `nomifun-auth` directly.
pub type TokenExtractor = Arc<dyn Fn(&HeaderMap) -> Option<String> + Send + Sync>;
/// Shared state required by the WebSocket upgrade handler.
#[derive(Clone)]
pub struct WsHandlerState {
pub manager: Arc<WebSocketManager>,
pub router: Arc<dyn MessageRouter>,
pub token_validator: TokenValidator,
pub token_extractor: TokenExtractor,
}
/// Axum handler for HTTP → WebSocket upgrade.
///
/// Extracts a JWT token from the request headers, validates it,
/// and upgrades the connection to WebSocket on success.
/// On authentication failure, sends `auth-expired` and closes with 1008.
///
/// When the token is carried via `Sec-WebSocket-Protocol`, the server
/// echoes the protocol header back so the client handshake succeeds.
pub async fn ws_upgrade_handler(
ws: WebSocketUpgrade,
headers: HeaderMap,
axum::extract::State(state): axum::extract::State<WsHandlerState>,
) -> impl IntoResponse {
let token = (state.token_extractor)(&headers);
// Echo Sec-WebSocket-Protocol so clients using it for auth
// receive a valid subprotocol negotiation response.
let ws = if let Some(protocol) = headers
.get("sec-websocket-protocol")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_owned())
{
ws.protocols([protocol])
} else {
ws
};
ws.on_upgrade(move |socket| async move {
handle_socket(socket, token, state).await;
})
}
/// Post-upgrade connection handler.
///
/// Validates the token, registers the client, spawns send/recv loops.
async fn handle_socket(socket: WebSocket, token: Option<String>, state: WsHandlerState) {
let Some(token) = token else {
send_close_no_token(socket).await;
return;
};
if !(state.token_validator)(&token) {
send_auth_expired_and_close(socket).await;
return;
}
let (tx, rx) = mpsc::channel::<WsOutbound>(PER_CONNECTION_BUFFER);
let conn_id = state.manager.add_client(token, tx);
info!(%conn_id, "websocket connection established");
let (ws_sender, ws_receiver) = socket.split();
let send_handle = tokio::spawn(send_loop(conn_id, rx, ws_sender));
recv_loop(conn_id, ws_receiver, &state).await;
// Recv loop exited — client disconnected or errored.
send_handle.abort();
state.manager.remove_client(conn_id);
info!(%conn_id, "websocket connection closed");
}
/// Send a close frame with 1008 when no token is provided.
async fn send_close_no_token(mut socket: WebSocket) {
let close = Message::Close(Some(CloseFrame {
code: WebSocketCloseCode::PolicyViolation.as_u16(),
reason: "no token provided".into(),
}));
let _ = socket.send(close).await;
}
/// Send `auth-expired` event then close with 1008.
async fn send_auth_expired_and_close(mut socket: WebSocket) {
let auth_expired = WebSocketMessage::new("auth-expired", json!({"message": "Token expired or invalid"}));
if let Ok(text) = serde_json::to_string(&auth_expired) {
let _ = socket.send(Message::Text(text.into())).await;
}
let close = Message::Close(Some(CloseFrame {
code: WebSocketCloseCode::PolicyViolation.as_u16(),
reason: "authentication failed".into(),
}));
let _ = socket.send(close).await;
}
// -------------------------------------------------------------------
// Send loop
// -------------------------------------------------------------------
/// Reads `WsOutbound` from the per-connection channel and forwards
/// them to the WebSocket sink.
async fn send_loop(
conn_id: ConnectionId,
mut rx: mpsc::Receiver<WsOutbound>,
mut sender: futures_util::stream::SplitSink<WebSocket, Message>,
) {
while let Some(outbound) = rx.recv().await {
let msg = match outbound {
WsOutbound::Text(text) => Message::Text(text.into()),
WsOutbound::Close(code, reason) => Message::Close(Some(CloseFrame {
code: code.as_u16(),
reason: reason.into(),
})),
};
if sender.send(msg).await.is_err() {
debug!(%conn_id, "send loop: socket write failed, exiting");
break;
}
}
}
// -------------------------------------------------------------------
// Receive loop
// -------------------------------------------------------------------
/// Reads messages from the WebSocket stream, parses JSON, routes.
async fn recv_loop(
conn_id: ConnectionId,
mut receiver: futures_util::stream::SplitStream<WebSocket>,
state: &WsHandlerState,
) {
while let Some(result) = receiver.next().await {
let msg = match result {
Ok(m) => m,
Err(e) => {
debug!(%conn_id, error = %e, "recv error, closing");
break;
}
};
match msg {
Message::Text(text) => {
handle_text_message(conn_id, &text, state);
}
Message::Close(_) => {
debug!(%conn_id, "received close frame");
break;
}
// Ping/Pong at the WebSocket protocol level are handled
// automatically by axum/tungstenite. Binary frames are ignored.
_ => {}
}
}
}
/// Process a text message: parse JSON, dispatch to built-in or router.
fn handle_text_message(conn_id: ConnectionId, text: &str, state: &WsHandlerState) {
let parsed: Result<WebSocketMessage<Value>, _> = serde_json::from_str(text);
let msg = match parsed {
Ok(m) => m,
Err(_) => {
send_error_response(state, conn_id);
return;
}
};
match msg.name.as_str() {
"pong" => {
state.manager.update_last_ping(conn_id);
}
"subscribe-show-open" => {
handle_subscribe_show_open(state, conn_id, msg.data);
}
name => {
state.router.route(conn_id, name, msg.data);
}
}
}
/// Send an error response for invalid message format.
fn send_error_response(state: &WsHandlerState, conn_id: ConnectionId) {
let error = json!({
"error": "Invalid message format",
"expected": r#"{ "name": "event-name", "data": {...} }"#
});
if let Ok(text) = serde_json::to_string(&error) {
state.manager.send_raw_to(conn_id, WsOutbound::Text(text));
}
}
/// Handle `subscribe-show-open`: reply with `show-open-request`.
///
/// The inbound `data` is the @office-ai/platform bridge envelope
/// `{ id, data: <user-params> }`. The renderer awaits a callback whose event
/// name embeds `id` (`subscribe.callback-show-open<id>`), so we must echo it
/// back; without it, the frontend's `useDirectorySelection` hook builds the
/// wrong callback name and the original `invoke()` Promise never resolves.
///
/// `isFileMode` is `true` when `properties` contains `openFile`
/// but NOT `openDirectory`.
fn handle_subscribe_show_open(state: &WsHandlerState, conn_id: ConnectionId, data: Value) {
let id = data.get("id").and_then(|v| v.as_str()).unwrap_or("").to_owned();
let inner = data.get("data").unwrap_or(&Value::Null);
let properties = inner
.get("properties")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let has_open_file = properties.iter().any(|v| v.as_str() == Some("openFile"));
let has_open_directory = properties.iter().any(|v| v.as_str() == Some("openDirectory"));
let is_file_mode = has_open_file && !has_open_directory;
let response = WebSocketMessage::new(
"show-open-request",
json!({
"id": id,
"properties": properties,
"isFileMode": is_file_mode,
}),
);
state.manager.send_to(conn_id, response);
}
#[cfg(test)]
mod tests {
use super::*;
fn test_state(manager: Arc<WebSocketManager>) -> WsHandlerState {
WsHandlerState {
manager,
router: Arc::new(crate::router::NoopMessageRouter),
token_validator: Arc::new(|_| true),
token_extractor: Arc::new(|_| None),
}
}
#[test]
fn subscribe_show_open_file_mode() {
let manager = Arc::new(WebSocketManager::new());
let (tx, mut rx) = mpsc::channel(PER_CONNECTION_BUFFER);
let conn_id = manager.add_client("tok".into(), tx);
let state = test_state(manager);
let data = json!({"id": "abc123", "data": {"properties": ["openFile"]}});
handle_subscribe_show_open(&state, conn_id, data);
let msg = rx.try_recv().unwrap();
match msg {
WsOutbound::Text(text) => {
let parsed: Value = serde_json::from_str(&text).unwrap();
assert_eq!(parsed["name"], "show-open-request");
assert_eq!(parsed["data"]["id"], "abc123");
assert_eq!(parsed["data"]["isFileMode"], true);
assert_eq!(parsed["data"]["properties"], json!(["openFile"]));
}
_ => panic!("expected Text"),
}
}
#[test]
fn subscribe_show_open_directory_mode() {
let manager = Arc::new(WebSocketManager::new());
let (tx, mut rx) = mpsc::channel(PER_CONNECTION_BUFFER);
let conn_id = manager.add_client("tok".into(), tx);
let state = test_state(manager);
let data = json!({"id": "dir1", "data": {"properties": ["openDirectory"]}});
handle_subscribe_show_open(&state, conn_id, data);
let msg = rx.try_recv().unwrap();
match msg {
WsOutbound::Text(text) => {
let parsed: Value = serde_json::from_str(&text).unwrap();
assert_eq!(parsed["data"]["id"], "dir1");
assert_eq!(parsed["data"]["isFileMode"], false);
}
_ => panic!("expected Text"),
}
}
#[test]
fn subscribe_show_open_mixed_mode() {
let manager = Arc::new(WebSocketManager::new());
let (tx, mut rx) = mpsc::channel(PER_CONNECTION_BUFFER);
let conn_id = manager.add_client("tok".into(), tx);
let state = test_state(manager);
let data = json!({"id": "mixed", "data": {"properties": ["openFile", "openDirectory"]}});
handle_subscribe_show_open(&state, conn_id, data);
let msg = rx.try_recv().unwrap();
match msg {
WsOutbound::Text(text) => {
let parsed: Value = serde_json::from_str(&text).unwrap();
assert_eq!(parsed["data"]["id"], "mixed");
assert_eq!(parsed["data"]["isFileMode"], false);
}
_ => panic!("expected Text"),
}
}
#[test]
fn subscribe_show_open_empty_properties() {
let manager = Arc::new(WebSocketManager::new());
let (tx, mut rx) = mpsc::channel(PER_CONNECTION_BUFFER);
let conn_id = manager.add_client("tok".into(), tx);
let state = test_state(manager);
let data = json!({"id": "empty", "data": {"properties": []}});
handle_subscribe_show_open(&state, conn_id, data);
let msg = rx.try_recv().unwrap();
match msg {
WsOutbound::Text(text) => {
let parsed: Value = serde_json::from_str(&text).unwrap();
assert_eq!(parsed["data"]["id"], "empty");
assert_eq!(parsed["data"]["isFileMode"], false);
}
_ => panic!("expected Text"),
}
}
#[test]
fn subscribe_show_open_missing_properties() {
let manager = Arc::new(WebSocketManager::new());
let (tx, mut rx) = mpsc::channel(PER_CONNECTION_BUFFER);
let conn_id = manager.add_client("tok".into(), tx);
let state = test_state(manager);
handle_subscribe_show_open(&state, conn_id, json!({"id": "noprops", "data": {}}));
let msg = rx.try_recv().unwrap();
match msg {
WsOutbound::Text(text) => {
let parsed: Value = serde_json::from_str(&text).unwrap();
assert_eq!(parsed["data"]["id"], "noprops");
assert_eq!(parsed["data"]["isFileMode"], false);
assert_eq!(parsed["data"]["properties"], json!([]));
}
_ => panic!("expected Text"),
}
}
#[test]
fn subscribe_show_open_missing_id_falls_back_to_empty_string() {
let manager = Arc::new(WebSocketManager::new());
let (tx, mut rx) = mpsc::channel(PER_CONNECTION_BUFFER);
let conn_id = manager.add_client("tok".into(), tx);
let state = test_state(manager);
handle_subscribe_show_open(&state, conn_id, json!({}));
let msg = rx.try_recv().unwrap();
match msg {
WsOutbound::Text(text) => {
let parsed: Value = serde_json::from_str(&text).unwrap();
assert_eq!(parsed["data"]["id"], "");
assert_eq!(parsed["data"]["isFileMode"], false);
assert_eq!(parsed["data"]["properties"], json!([]));
}
_ => panic!("expected Text"),
}
}
#[test]
fn text_message_pong_updates_last_ping() {
let manager = Arc::new(WebSocketManager::new());
let (tx, _rx) = mpsc::channel(PER_CONNECTION_BUFFER);
let conn_id = manager.add_client("tok".into(), tx);
let state = test_state(manager);
std::thread::sleep(std::time::Duration::from_millis(5));
handle_text_message(conn_id, r#"{"name":"pong","data":{}}"#, &state);
// No panic = success (update_last_ping was called)
}
#[test]
fn text_message_invalid_json_sends_error() {
let manager = Arc::new(WebSocketManager::new());
let (tx, mut rx) = mpsc::channel(PER_CONNECTION_BUFFER);
let conn_id = manager.add_client("tok".into(), tx);
let state = test_state(manager);
handle_text_message(conn_id, "not json", &state);
let msg = rx.try_recv().unwrap();
match msg {
WsOutbound::Text(text) => {
let parsed: Value = serde_json::from_str(&text).unwrap();
assert_eq!(parsed["error"], "Invalid message format");
assert!(parsed["expected"].is_string());
}
_ => panic!("expected error text"),
}
}
#[test]
fn text_message_missing_fields_sends_error() {
let manager = Arc::new(WebSocketManager::new());
let (tx, mut rx) = mpsc::channel(PER_CONNECTION_BUFFER);
let conn_id = manager.add_client("tok".into(), tx);
let state = test_state(manager);
handle_text_message(conn_id, r#"{"foo":"bar"}"#, &state);
let msg = rx.try_recv().unwrap();
match msg {
WsOutbound::Text(text) => {
let parsed: Value = serde_json::from_str(&text).unwrap();
assert_eq!(parsed["error"], "Invalid message format");
}
_ => panic!("expected error text"),
}
}
#[test]
fn text_message_routes_unknown_to_router() {
use std::sync::atomic::{AtomicBool, Ordering};
struct TestRouter {
called: AtomicBool,
}
impl MessageRouter for TestRouter {
fn route(&self, _conn_id: ConnectionId, _name: &str, _data: Value) {
self.called.store(true, Ordering::Relaxed);
}
}
let manager = Arc::new(WebSocketManager::new());
let (tx, _rx) = mpsc::channel(PER_CONNECTION_BUFFER);
let conn_id = manager.add_client("tok".into(), tx);
let router = Arc::new(TestRouter {
called: AtomicBool::new(false),
});
let state = WsHandlerState {
manager,
router: router.clone(),
token_validator: Arc::new(|_| true),
token_extractor: Arc::new(|_| None),
};
handle_text_message(
conn_id,
r#"{"name":"conversation.send-message","data":{"text":"hi"}}"#,
&state,
);
assert!(router.called.load(Ordering::Relaxed));
}
#[test]
fn error_response_to_disconnected_client_is_noop() {
let manager = Arc::new(WebSocketManager::new());
let (tx, rx) = mpsc::channel(PER_CONNECTION_BUFFER);
let conn_id = manager.add_client("tok".into(), tx);
drop(rx); // close channel
let state = test_state(manager.clone());
// Should not panic — client will be removed
send_error_response(&state, conn_id);
assert_eq!(manager.client_count(), 0);
}
}
@@ -0,0 +1,15 @@
//! WebSocket connection manager, event broadcasting, and token-validated upgrade handler.
pub mod broadcaster;
pub mod handler;
pub mod manager;
pub mod router;
pub mod types;
pub use broadcaster::{BroadcastEventBus, EventBroadcaster};
pub use handler::{TokenExtractor, WsHandlerState, ws_upgrade_handler};
pub use manager::{TokenValidator, WebSocketManager};
pub use router::{MessageRouter, NoopMessageRouter};
pub use types::{
ClientInfo, ConnectionId, HEARTBEAT_INTERVAL, HEARTBEAT_TIMEOUT, PER_CONNECTION_BUFFER, WebSocketCloseCode,
WsOutbound,
};
@@ -0,0 +1,572 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use dashmap::DashMap;
use nomifun_api_types::WebSocketMessage;
use serde_json::json;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tracing::{debug, info, warn};
use crate::broadcaster::EventBroadcaster;
use crate::types::{ClientInfo, ConnectionId, HEARTBEAT_INTERVAL, HEARTBEAT_TIMEOUT, WebSocketCloseCode, WsOutbound};
/// Validates whether a JWT token is still valid.
/// Returns `true` if the token is valid, `false` if expired or revoked.
pub type TokenValidator = Arc<dyn Fn(&str) -> bool + Send + Sync>;
/// Manages active WebSocket connections, heartbeat detection,
/// and provides broadcast/unicast messaging.
pub struct WebSocketManager {
connections: Arc<DashMap<ConnectionId, ClientInfo>>,
next_id: AtomicU64,
}
impl WebSocketManager {
pub fn new() -> Self {
Self {
connections: Arc::new(DashMap::new()),
next_id: AtomicU64::new(1),
}
}
/// Register a new client connection and return its assigned ID.
pub fn add_client(&self, token: String, tx: mpsc::Sender<WsOutbound>) -> ConnectionId {
let id = ConnectionId(self.next_id.fetch_add(1, Ordering::Relaxed));
let info = ClientInfo {
token,
last_ping: Instant::now(),
tx,
};
self.connections.insert(id, info);
debug!(%id, "client added");
id
}
/// Remove a client connection by ID.
pub fn remove_client(&self, conn_id: ConnectionId) {
if self.connections.remove(&conn_id).is_some() {
debug!(%conn_id, "client removed");
}
}
/// Update the last heartbeat timestamp for a connection.
pub fn update_last_ping(&self, conn_id: ConnectionId) {
if let Some(mut client) = self.connections.get_mut(&conn_id) {
client.last_ping = Instant::now();
}
}
/// Returns the number of active connections.
pub fn client_count(&self) -> usize {
self.connections.len()
}
/// Send a message to all connected clients.
///
/// Uses `try_send` for backpressure — full channels drop the message
/// with a warning; closed channels trigger client removal.
pub fn broadcast_all(&self, msg: WebSocketMessage<serde_json::Value>) {
let text = match serde_json::to_string(&msg) {
Ok(t) => t,
Err(e) => {
warn!(error = %e, "failed to serialize broadcast message");
return;
}
};
let mut disconnected = Vec::new();
for entry in self.connections.iter() {
let conn_id = *entry.key();
match entry.value().tx.try_send(WsOutbound::Text(text.clone())) {
Ok(()) => {}
Err(mpsc::error::TrySendError::Full(_)) => {
warn!(%conn_id, "outbound channel full, message dropped");
}
Err(mpsc::error::TrySendError::Closed(_)) => {
disconnected.push(conn_id);
}
}
}
for conn_id in disconnected {
self.remove_client(conn_id);
}
}
/// Send a message to a specific connection.
pub fn send_to(&self, conn_id: ConnectionId, msg: WebSocketMessage<serde_json::Value>) {
let text = match serde_json::to_string(&msg) {
Ok(t) => t,
Err(e) => {
warn!(
%conn_id, error = %e,
"failed to serialize unicast message"
);
return;
}
};
self.send_raw_to(conn_id, WsOutbound::Text(text));
}
/// Send a raw outbound message to a specific connection.
///
/// Used for non-`WebSocketMessage` payloads (e.g. error responses).
pub fn send_raw_to(&self, conn_id: ConnectionId, outbound: WsOutbound) {
if let Some(client) = self.connections.get(&conn_id) {
match client.tx.try_send(outbound) {
Ok(()) => {}
Err(mpsc::error::TrySendError::Full(_)) => {
warn!(%conn_id, "outbound channel full, message dropped");
}
Err(mpsc::error::TrySendError::Closed(_)) => {
drop(client);
self.remove_client(conn_id);
}
}
}
}
/// Start the heartbeat check loop.
///
/// Every `HEARTBEAT_INTERVAL` (30s), iterates all connections:
/// 1. Timeout check — closes connections with no pong for `HEARTBEAT_TIMEOUT`
/// 2. Token expiry — validates token and sends `auth-expired` if invalid
/// 3. Sends a `ping` message with current timestamp
///
/// Returns a `JoinHandle` — abort it to stop the heartbeat loop.
pub fn start_heartbeat(&self, token_validator: TokenValidator) -> JoinHandle<()> {
let connections = Arc::clone(&self.connections);
tokio::spawn(async move {
let mut interval = tokio::time::interval(HEARTBEAT_INTERVAL);
loop {
interval.tick().await;
heartbeat_tick(&connections, &token_validator);
}
})
}
}
impl Default for WebSocketManager {
fn default() -> Self {
Self::new()
}
}
impl EventBroadcaster for WebSocketManager {
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
self.broadcast_all(event);
}
}
/// Single heartbeat tick: check timeouts, token validity, send pings.
fn heartbeat_tick(connections: &DashMap<ConnectionId, ClientInfo>, token_validator: &TokenValidator) {
let now = Instant::now();
let mut to_remove = Vec::new();
for entry in connections.iter() {
let conn_id = *entry.key();
let client = entry.value();
// 1. Heartbeat timeout
if now.duration_since(client.last_ping) > HEARTBEAT_TIMEOUT {
info!(%conn_id, "heartbeat timeout, closing connection");
let _ = client.tx.try_send(WsOutbound::Close(
WebSocketCloseCode::PolicyViolation,
"heartbeat timeout".into(),
));
to_remove.push(conn_id);
continue;
}
// 2. Token expiry
if !token_validator(&client.token) {
info!(%conn_id, "token expired, closing connection");
let auth_expired = WebSocketMessage::new("auth-expired", json!({"message": "Token expired"}));
if let Ok(text) = serde_json::to_string(&auth_expired) {
let _ = client.tx.try_send(WsOutbound::Text(text));
}
let _ = client.tx.try_send(WsOutbound::Close(
WebSocketCloseCode::PolicyViolation,
"token expired".into(),
));
to_remove.push(conn_id);
continue;
}
// 3. Send ping
let duration = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
let timestamp = duration.as_secs() * 1000 + u64::from(duration.subsec_millis());
let ping = WebSocketMessage::new("ping", json!({"timestamp": timestamp}));
if let Ok(text) = serde_json::to_string(&ping) {
match client.tx.try_send(WsOutbound::Text(text)) {
Ok(()) => {}
Err(mpsc::error::TrySendError::Full(_)) => {
warn!(%conn_id, "outbound channel full, ping dropped");
}
Err(mpsc::error::TrySendError::Closed(_)) => {
to_remove.push(conn_id);
}
}
}
}
for conn_id in to_remove {
connections.remove(&conn_id);
debug!(%conn_id, "connection removed by heartbeat");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::PER_CONNECTION_BUFFER;
fn always_valid() -> TokenValidator {
Arc::new(|_| true)
}
fn always_expired() -> TokenValidator {
Arc::new(|_| false)
}
fn new_client_tx() -> (mpsc::Sender<WsOutbound>, mpsc::Receiver<WsOutbound>) {
mpsc::channel(PER_CONNECTION_BUFFER)
}
#[test]
fn add_client_assigns_sequential_ids() {
let mgr = WebSocketManager::new();
let (tx1, _rx1) = new_client_tx();
let (tx2, _rx2) = new_client_tx();
let id1 = mgr.add_client("token-a".into(), tx1);
let id2 = mgr.add_client("token-b".into(), tx2);
assert_eq!(id1, ConnectionId(1));
assert_eq!(id2, ConnectionId(2));
assert_eq!(mgr.client_count(), 2);
}
#[test]
fn remove_client_decrements_count() {
let mgr = WebSocketManager::new();
let (tx, _rx) = new_client_tx();
let id = mgr.add_client("token".into(), tx);
assert_eq!(mgr.client_count(), 1);
mgr.remove_client(id);
assert_eq!(mgr.client_count(), 0);
}
#[test]
fn remove_nonexistent_client_is_noop() {
let mgr = WebSocketManager::new();
mgr.remove_client(ConnectionId(999));
assert_eq!(mgr.client_count(), 0);
}
#[test]
fn update_last_ping_refreshes_timestamp() {
let mgr = WebSocketManager::new();
let (tx, _rx) = new_client_tx();
let id = mgr.add_client("token".into(), tx);
let before = mgr.connections.get(&id).map(|c| c.last_ping).unwrap();
// Small busy-wait to ensure time advances
std::thread::sleep(std::time::Duration::from_millis(5));
mgr.update_last_ping(id);
let after = mgr.connections.get(&id).map(|c| c.last_ping).unwrap();
assert!(after > before);
}
#[test]
fn update_last_ping_nonexistent_is_noop() {
let mgr = WebSocketManager::new();
mgr.update_last_ping(ConnectionId(999));
}
#[test]
fn broadcast_all_delivers_to_all() {
let mgr = WebSocketManager::new();
let (tx1, mut rx1) = new_client_tx();
let (tx2, mut rx2) = new_client_tx();
mgr.add_client("t1".into(), tx1);
mgr.add_client("t2".into(), tx2);
let event = WebSocketMessage::new("test-event", json!({"key": "val"}));
mgr.broadcast_all(event);
let msg1 = rx1.try_recv().unwrap();
let msg2 = rx2.try_recv().unwrap();
match (&msg1, &msg2) {
(WsOutbound::Text(t1), WsOutbound::Text(t2)) => {
assert_eq!(t1, t2);
assert!(t1.contains("test-event"));
}
_ => panic!("expected Text messages"),
}
}
#[test]
fn broadcast_all_removes_closed_channels() {
let mgr = WebSocketManager::new();
let (tx1, rx1) = new_client_tx();
let (tx2, _rx2) = new_client_tx();
mgr.add_client("t1".into(), tx1);
mgr.add_client("t2".into(), tx2);
// Drop rx1 to close the channel
drop(rx1);
let event = WebSocketMessage::new("test", json!(null));
mgr.broadcast_all(event);
// Client 1 should be removed
assert_eq!(mgr.client_count(), 1);
}
#[test]
fn broadcast_all_handles_full_channel() {
let mgr = WebSocketManager::new();
// Use a channel with capacity 1
let (tx, _rx) = mpsc::channel(1);
mgr.add_client("tok".into(), tx);
// Fill the channel
mgr.broadcast_all(WebSocketMessage::new("e1", json!(null)));
// This should warn but not remove the client
mgr.broadcast_all(WebSocketMessage::new("e2", json!(null)));
assert_eq!(mgr.client_count(), 1);
}
#[test]
fn send_to_delivers_to_target_only() {
let mgr = WebSocketManager::new();
let (tx1, mut rx1) = new_client_tx();
let (tx2, mut rx2) = new_client_tx();
let id1 = mgr.add_client("t1".into(), tx1);
mgr.add_client("t2".into(), tx2);
let msg = WebSocketMessage::new("unicast", json!({"for": "id1"}));
mgr.send_to(id1, msg);
assert!(rx1.try_recv().is_ok());
assert!(rx2.try_recv().is_err());
}
#[test]
fn send_to_nonexistent_is_noop() {
let mgr = WebSocketManager::new();
let msg = WebSocketMessage::new("ghost", json!(null));
mgr.send_to(ConnectionId(999), msg);
}
#[test]
fn send_to_removes_closed_channel() {
let mgr = WebSocketManager::new();
let (tx, rx) = new_client_tx();
let id = mgr.add_client("tok".into(), tx);
drop(rx);
mgr.send_to(id, WebSocketMessage::new("test", json!(null)));
assert_eq!(mgr.client_count(), 0);
}
#[test]
fn heartbeat_tick_sends_ping_to_healthy_connection() {
let connections = Arc::new(DashMap::new());
let (tx, mut rx) = new_client_tx();
connections.insert(
ConnectionId(1),
ClientInfo {
token: "valid".into(),
last_ping: Instant::now(),
tx,
},
);
heartbeat_tick(&connections, &always_valid());
// Should still be connected
assert_eq!(connections.len(), 1);
// Should have received a ping
let msg = rx.try_recv().unwrap();
match msg {
WsOutbound::Text(text) => {
let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(parsed["name"], "ping");
assert!(parsed["data"]["timestamp"].is_u64());
}
_ => panic!("expected Text ping"),
}
}
#[test]
fn heartbeat_tick_removes_timed_out_connection() {
let connections = Arc::new(DashMap::new());
let (tx, mut rx) = new_client_tx();
// Set last_ping to well past the timeout
let old_ping = Instant::now() - (HEARTBEAT_TIMEOUT * 2);
connections.insert(
ConnectionId(1),
ClientInfo {
token: "valid".into(),
last_ping: old_ping,
tx,
},
);
heartbeat_tick(&connections, &always_valid());
// Connection should be removed
assert_eq!(connections.len(), 0);
// Should have received a close frame
let msg = rx.try_recv().unwrap();
assert_eq!(
msg,
WsOutbound::Close(WebSocketCloseCode::PolicyViolation, "heartbeat timeout".into())
);
}
#[test]
fn heartbeat_tick_removes_expired_token_connection() {
let connections = Arc::new(DashMap::new());
let (tx, mut rx) = new_client_tx();
connections.insert(
ConnectionId(1),
ClientInfo {
token: "expired-token".into(),
last_ping: Instant::now(),
tx,
},
);
heartbeat_tick(&connections, &always_expired());
// Connection should be removed
assert_eq!(connections.len(), 0);
// Should have received auth-expired event then close
let msg1 = rx.try_recv().unwrap();
match msg1 {
WsOutbound::Text(text) => {
let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(parsed["name"], "auth-expired");
}
_ => panic!("expected auth-expired Text"),
}
let msg2 = rx.try_recv().unwrap();
assert_eq!(
msg2,
WsOutbound::Close(WebSocketCloseCode::PolicyViolation, "token expired".into())
);
}
#[test]
fn heartbeat_tick_timeout_takes_priority_over_token_check() {
let connections = Arc::new(DashMap::new());
let (tx, mut rx) = new_client_tx();
// Both timed out AND expired token
let old_ping = Instant::now() - (HEARTBEAT_TIMEOUT * 2);
connections.insert(
ConnectionId(1),
ClientInfo {
token: "expired".into(),
last_ping: old_ping,
tx,
},
);
heartbeat_tick(&connections, &always_expired());
assert_eq!(connections.len(), 0);
// Only close frame from timeout (no auth-expired text)
let msg = rx.try_recv().unwrap();
assert_eq!(
msg,
WsOutbound::Close(WebSocketCloseCode::PolicyViolation, "heartbeat timeout".into())
);
// No more messages
assert!(rx.try_recv().is_err());
}
#[test]
fn heartbeat_tick_mixed_connections() {
let connections = Arc::new(DashMap::new());
// Healthy connection
let (tx1, _rx1) = new_client_tx();
connections.insert(
ConnectionId(1),
ClientInfo {
token: "good".into(),
last_ping: Instant::now(),
tx: tx1,
},
);
// Timed-out connection
let (tx2, _rx2) = new_client_tx();
connections.insert(
ConnectionId(2),
ClientInfo {
token: "good".into(),
last_ping: Instant::now() - (HEARTBEAT_TIMEOUT * 2),
tx: tx2,
},
);
let selective_validator: TokenValidator = Arc::new(|_| true);
heartbeat_tick(&connections, &selective_validator);
// Only healthy connection remains
assert_eq!(connections.len(), 1);
assert!(connections.contains_key(&ConnectionId(1)));
}
#[test]
fn event_broadcaster_impl_delegates_to_broadcast_all() {
let mgr = WebSocketManager::new();
let (tx, mut rx) = new_client_tx();
mgr.add_client("tok".into(), tx);
let broadcaster: &dyn EventBroadcaster = &mgr;
broadcaster.broadcast(WebSocketMessage::new("via-trait", json!({})));
let msg = rx.try_recv().unwrap();
match msg {
WsOutbound::Text(text) => {
assert!(text.contains("via-trait"));
}
_ => panic!("expected Text"),
}
}
#[test]
fn default_creates_empty_manager() {
let mgr = WebSocketManager::default();
assert_eq!(mgr.client_count(), 0);
}
}
@@ -0,0 +1,47 @@
use crate::types::ConnectionId;
/// Routes upstream WebSocket messages to business logic handlers.
///
/// The `name` field of the incoming `WebSocketMessage` determines
/// which handler processes the message. Phase 4 provides only a
/// no-op implementation; concrete routing is added in later phases.
pub trait MessageRouter: Send + Sync {
/// Route an upstream message to the appropriate handler.
///
/// Called for any message whose `name` is not handled internally
/// by the WebSocket layer (i.e. not `pong` or `subscribe-show-open`).
fn route(&self, conn_id: ConnectionId, name: &str, data: serde_json::Value);
}
/// A no-op message router that silently discards all messages.
///
/// Used as a placeholder until business modules provide real routing.
pub struct NoopMessageRouter;
impl MessageRouter for NoopMessageRouter {
fn route(&self, conn_id: ConnectionId, name: &str, _data: serde_json::Value) {
tracing::debug!(
%conn_id,
message_name = name,
"no router registered, message discarded"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn noop_router_does_not_panic() {
let router = NoopMessageRouter;
router.route(ConnectionId(1), "some-event", json!({"key": "val"}));
}
#[test]
fn noop_router_is_trait_object_compatible() {
let router: Box<dyn MessageRouter> = Box::new(NoopMessageRouter);
router.route(ConnectionId(42), "test", json!(null));
}
}
@@ -0,0 +1,117 @@
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
/// Unique identifier for a WebSocket connection.
///
/// Generated by `AtomicU64` counter in `WebSocketManager`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ConnectionId(pub u64);
impl std::fmt::Display for ConnectionId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "conn-{}", self.0)
}
}
/// Outbound message for a WebSocket connection.
///
/// Sent through the per-connection `mpsc` channel to the send loop.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WsOutbound {
/// UTF-8 text frame.
Text(String),
/// Close frame with status code and reason.
Close(WebSocketCloseCode, String),
}
/// WebSocket close codes per RFC 6455.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u16)]
pub enum WebSocketCloseCode {
/// 1000 — normal closure.
NormalClosure = 1000,
/// 1008 — policy violation (auth failure, heartbeat timeout).
PolicyViolation = 1008,
}
impl WebSocketCloseCode {
/// Return the numeric close code.
pub fn as_u16(self) -> u16 {
self as u16
}
}
/// Per-connection client state maintained by the server.
pub struct ClientInfo {
/// The JWT token this connection authenticated with.
pub token: String,
/// Timestamp of last ping/pong activity.
pub last_ping: Instant,
/// Sender for outbound messages to this connection.
pub tx: mpsc::Sender<WsOutbound>,
}
/// Server sends ping every 30 seconds.
pub const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
/// Connection is dropped if no pong received within 60 seconds.
pub const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(60);
/// Bounded capacity of the per-connection outbound message channel.
pub const PER_CONNECTION_BUFFER: usize = 64;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn connection_id_display() {
let id = ConnectionId(42);
assert_eq!(id.to_string(), "conn-42");
}
#[test]
fn connection_id_equality() {
assert_eq!(ConnectionId(1), ConnectionId(1));
assert_ne!(ConnectionId(1), ConnectionId(2));
}
#[test]
fn connection_id_hash() {
use std::collections::HashSet;
let mut set = HashSet::new();
set.insert(ConnectionId(1));
set.insert(ConnectionId(2));
set.insert(ConnectionId(1));
assert_eq!(set.len(), 2);
}
#[test]
fn ws_outbound_text() {
let msg = WsOutbound::Text("hello".into());
assert_eq!(msg, WsOutbound::Text("hello".into()));
}
#[test]
fn ws_outbound_close() {
let msg = WsOutbound::Close(WebSocketCloseCode::PolicyViolation, "policy violation".into());
assert_eq!(
msg,
WsOutbound::Close(WebSocketCloseCode::PolicyViolation, "policy violation".into())
);
}
#[test]
fn close_code_values() {
assert_eq!(WebSocketCloseCode::NormalClosure.as_u16(), 1000);
assert_eq!(WebSocketCloseCode::PolicyViolation.as_u16(), 1008);
}
#[test]
fn constants() {
assert_eq!(HEARTBEAT_INTERVAL, Duration::from_secs(30));
assert_eq!(HEARTBEAT_TIMEOUT, Duration::from_secs(60));
assert_eq!(PER_CONNECTION_BUFFER, 64);
}
}
@@ -0,0 +1,86 @@
use std::sync::Arc;
use nomifun_api_types::WebSocketMessage;
use nomifun_realtime::{BroadcastEventBus, EventBroadcaster};
use serde_json::json;
#[tokio::test]
async fn broadcast_to_multiple_subscribers() {
let bus = Arc::new(BroadcastEventBus::new(64));
let mut rx1 = bus.subscribe();
let mut rx2 = bus.subscribe();
let mut rx3 = bus.subscribe();
let event = WebSocketMessage::new("test:broadcast", json!({"key": "value"}));
bus.broadcast(event);
let msg1 = rx1.recv().await.unwrap();
let msg2 = rx2.recv().await.unwrap();
let msg3 = rx3.recv().await.unwrap();
assert_eq!(msg1.name, "test:broadcast");
assert_eq!(msg2.name, "test:broadcast");
assert_eq!(msg3.name, "test:broadcast");
assert_eq!(msg1.data, msg2.data);
assert_eq!(msg2.data, msg3.data);
}
#[tokio::test]
async fn late_subscriber_misses_earlier_events() {
let bus = BroadcastEventBus::new(64);
// Broadcast before any subscriber exists
bus.broadcast(WebSocketMessage::new("early", json!({})));
// Subscribe after the broadcast
let mut rx = bus.subscribe();
// Broadcast a new event
bus.broadcast(WebSocketMessage::new("late", json!({})));
let msg = rx.recv().await.unwrap();
assert_eq!(msg.name, "late");
}
#[tokio::test]
async fn dropped_subscriber_does_not_block_broadcast() {
let bus = BroadcastEventBus::new(64);
let rx = bus.subscribe();
assert_eq!(bus.receiver_count(), 1);
drop(rx);
assert_eq!(bus.receiver_count(), 0);
// Broadcast should succeed without panic
bus.broadcast(WebSocketMessage::new("after-drop", json!({})));
}
#[tokio::test]
async fn trait_object_via_arc() {
let bus = Arc::new(BroadcastEventBus::new(64));
let mut rx = bus.subscribe();
let broadcaster: Arc<dyn EventBroadcaster> = bus.clone();
broadcaster.broadcast(WebSocketMessage::new("via-trait", json!({"n": 42})));
let msg = rx.recv().await.unwrap();
assert_eq!(msg.name, "via-trait");
assert_eq!(msg.data["n"], 42);
}
#[tokio::test]
async fn high_throughput_broadcast() {
let bus = Arc::new(BroadcastEventBus::new(256));
let mut rx = bus.subscribe();
let count = 100;
for i in 0..count {
bus.broadcast(WebSocketMessage::new(format!("evt-{i}"), json!({"seq": i})));
}
for i in 0..count {
let msg = rx.recv().await.unwrap();
assert_eq!(msg.name, format!("evt-{i}"));
assert_eq!(msg.data["seq"], i);
}
}
@@ -0,0 +1,395 @@
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use axum::Router;
use axum::routing::get;
use futures_util::{SinkExt, StreamExt};
use nomifun_api_types::WebSocketMessage;
use nomifun_realtime::{
ConnectionId, MessageRouter, NoopMessageRouter, WebSocketManager, WsHandlerState, ws_upgrade_handler,
};
use serde_json::{Value, json};
use tokio::net::TcpListener;
use tokio_tungstenite::tungstenite;
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
/// Start an axum server with the WebSocket handler and return its address.
async fn start_server(state: WsHandlerState) -> SocketAddr {
let app = Router::new().route("/ws", get(ws_upgrade_handler)).with_state(state);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
addr
}
fn default_state() -> (WsHandlerState, Arc<WebSocketManager>) {
let manager = Arc::new(WebSocketManager::new());
let state = WsHandlerState {
manager: manager.clone(),
router: Arc::new(NoopMessageRouter),
token_validator: Arc::new(|t| t == "valid-token"),
token_extractor: Arc::new(|headers| {
headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
.map(|s| s.to_owned())
}),
};
(state, manager)
}
/// Connect with an Authorization header.
async fn connect_with_token(
addr: SocketAddr,
token: &str,
) -> (
futures_util::stream::SplitSink<
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
tungstenite::Message,
>,
futures_util::stream::SplitStream<
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
>,
) {
let url = format!("ws://{addr}/ws");
let request = tungstenite::http::Request::builder()
.uri(&url)
.header("Host", addr.to_string())
.header("Connection", "Upgrade")
.header("Upgrade", "websocket")
.header("Sec-WebSocket-Version", "13")
.header("Sec-WebSocket-Key", tungstenite::handshake::client::generate_key())
.header("Authorization", format!("Bearer {token}"))
.body(())
.unwrap();
let (ws, _) = tokio_tungstenite::connect_async(request).await.unwrap();
ws.split()
}
/// Connect without any auth header.
async fn connect_no_token(
addr: SocketAddr,
) -> tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>> {
let url = format!("ws://{addr}/ws");
let (ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap();
ws
}
/// Read the next text message within a timeout.
async fn read_text<S>(stream: &mut S) -> Value
where
S: StreamExt<Item = Result<tungstenite::Message, tungstenite::Error>> + Unpin,
{
let timeout = Duration::from_secs(5);
tokio::time::timeout(timeout, async {
loop {
match stream.next().await {
Some(Ok(tungstenite::Message::Text(t))) => {
return serde_json::from_str::<Value>(&t).unwrap();
}
Some(Ok(tungstenite::Message::Close(_))) => {
panic!("unexpected close frame");
}
Some(Err(e)) => {
panic!("read error: {e}");
}
None => {
panic!("stream ended");
}
_ => continue, // skip ping/pong/binary
}
}
})
.await
.expect("read timed out")
}
/// Read until a close frame is received, returning the close code.
async fn read_close<S>(stream: &mut S) -> Option<u16>
where
S: StreamExt<Item = Result<tungstenite::Message, tungstenite::Error>> + Unpin,
{
let timeout = Duration::from_secs(5);
tokio::time::timeout(timeout, async {
loop {
match stream.next().await {
Some(Ok(tungstenite::Message::Close(frame))) => {
return frame.map(|f| f.code.into());
}
Some(Ok(_)) => continue,
Some(Err(_)) => return None,
None => return None,
}
}
})
.await
.expect("read_close timed out")
}
fn send_json(text: &str) -> tungstenite::Message {
tungstenite::Message::Text(text.into())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[tokio::test]
async fn valid_token_connects_successfully() {
let (state, manager) = default_state();
let addr = start_server(state).await;
let (_tx, _rx) = connect_with_token(addr, "valid-token").await;
// Allow connection to register
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(manager.client_count(), 1);
}
#[tokio::test]
async fn no_token_closes_with_1008() {
let (state, _) = default_state();
let addr = start_server(state).await;
let mut ws = connect_no_token(addr).await;
let code = read_close(&mut ws).await;
assert_eq!(code, Some(1008));
}
#[tokio::test]
async fn invalid_token_sends_auth_expired_then_closes() {
let (state, _) = default_state();
let addr = start_server(state).await;
let (_, mut rx) = connect_with_token(addr, "bad-token").await;
let msg = read_text(&mut rx).await;
assert_eq!(msg["name"], "auth-expired");
let code = read_close(&mut rx).await;
assert_eq!(code, Some(1008));
}
#[tokio::test]
async fn invalid_json_message_returns_error() {
let (state, _) = default_state();
let addr = start_server(state).await;
let (mut tx, mut rx) = connect_with_token(addr, "valid-token").await;
tokio::time::sleep(Duration::from_millis(50)).await;
tx.send(send_json("not valid json")).await.unwrap();
let msg = read_text(&mut rx).await;
assert_eq!(msg["error"], "Invalid message format");
assert!(msg["expected"].is_string());
}
#[tokio::test]
async fn missing_fields_returns_error() {
let (state, _) = default_state();
let addr = start_server(state).await;
let (mut tx, mut rx) = connect_with_token(addr, "valid-token").await;
tokio::time::sleep(Duration::from_millis(50)).await;
tx.send(send_json(r#"{"foo":"bar"}"#)).await.unwrap();
let msg = read_text(&mut rx).await;
assert_eq!(msg["error"], "Invalid message format");
}
#[tokio::test]
async fn subscribe_show_open_replies_with_show_open_request() {
let (state, _) = default_state();
let addr = start_server(state).await;
let (mut tx, mut rx) = connect_with_token(addr, "valid-token").await;
tokio::time::sleep(Duration::from_millis(50)).await;
// Mirrors the @office-ai/platform bridge envelope shape produced by
// `invoke('show-open', { properties: ['openFile'] })`.
let payload = json!({
"name": "subscribe-show-open",
"data": {"id": "abc123", "data": {"properties": ["openFile"]}}
});
tx.send(send_json(&payload.to_string())).await.unwrap();
let msg = read_text(&mut rx).await;
assert_eq!(msg["name"], "show-open-request");
assert_eq!(msg["data"]["id"], "abc123");
assert_eq!(msg["data"]["isFileMode"], true);
assert_eq!(msg["data"]["properties"], json!(["openFile"]));
}
#[tokio::test]
async fn subscribe_show_open_directory_mode() {
let (state, _) = default_state();
let addr = start_server(state).await;
let (mut tx, mut rx) = connect_with_token(addr, "valid-token").await;
tokio::time::sleep(Duration::from_millis(50)).await;
let payload = json!({
"name": "subscribe-show-open",
"data": {"id": "dir1", "data": {"properties": ["openFile", "openDirectory"]}}
});
tx.send(send_json(&payload.to_string())).await.unwrap();
let msg = read_text(&mut rx).await;
assert_eq!(msg["name"], "show-open-request");
assert_eq!(msg["data"]["id"], "dir1");
assert_eq!(msg["data"]["isFileMode"], false);
}
#[tokio::test]
async fn broadcast_reaches_all_connected_clients() {
let (state, manager) = default_state();
let addr = start_server(state).await;
let (_, mut rx1) = connect_with_token(addr, "valid-token").await;
let (_, mut rx2) = connect_with_token(addr, "valid-token").await;
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(manager.client_count(), 2);
let event = WebSocketMessage::new("test-broadcast", json!({"seq": 1}));
manager.broadcast_all(event);
let msg1 = read_text(&mut rx1).await;
let msg2 = read_text(&mut rx2).await;
assert_eq!(msg1["name"], "test-broadcast");
assert_eq!(msg2["name"], "test-broadcast");
}
#[tokio::test]
async fn unicast_reaches_only_target() {
let (state, manager) = default_state();
let addr = start_server(state).await;
let (_, mut rx1) = connect_with_token(addr, "valid-token").await;
let (_, mut rx2) = connect_with_token(addr, "valid-token").await;
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(manager.client_count(), 2);
// IDs are sequential starting from 1
let first_conn_id = ConnectionId(1);
let msg = WebSocketMessage::new("unicast-test", json!({"target": true}));
manager.send_to(first_conn_id, msg);
let received = read_text(&mut rx1).await;
assert_eq!(received["name"], "unicast-test");
// rx2 should not have received anything — check with short timeout
let timeout_result = tokio::time::timeout(Duration::from_millis(200), rx2.next()).await;
assert!(timeout_result.is_err(), "rx2 should not receive the unicast");
}
#[tokio::test]
async fn client_disconnect_removes_from_manager() {
let (state, manager) = default_state();
let addr = start_server(state).await;
let (mut tx, _rx) = connect_with_token(addr, "valid-token").await;
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(manager.client_count(), 1);
// Send close frame
tx.send(tungstenite::Message::Close(None)).await.unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(manager.client_count(), 0);
}
#[tokio::test]
async fn pong_message_does_not_generate_response() {
let (state, _) = default_state();
let addr = start_server(state).await;
let (mut tx, mut rx) = connect_with_token(addr, "valid-token").await;
tokio::time::sleep(Duration::from_millis(50)).await;
let pong = json!({"name": "pong", "data": {}});
tx.send(send_json(&pong.to_string())).await.unwrap();
// pong should not generate any response
let timeout_result = tokio::time::timeout(Duration::from_millis(200), rx.next()).await;
assert!(timeout_result.is_err(), "pong should not generate a response");
}
#[tokio::test]
async fn unknown_message_routed_to_message_router() {
use std::sync::atomic::{AtomicBool, Ordering};
struct TrackingRouter {
called: AtomicBool,
}
impl MessageRouter for TrackingRouter {
fn route(&self, _conn_id: ConnectionId, _name: &str, _data: Value) {
self.called.store(true, Ordering::Relaxed);
}
}
let manager = Arc::new(WebSocketManager::new());
let router = Arc::new(TrackingRouter {
called: AtomicBool::new(false),
});
let state = WsHandlerState {
manager: manager.clone(),
router: router.clone(),
token_validator: Arc::new(|t| t == "valid-token"),
token_extractor: Arc::new(|headers| {
headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
.map(|s| s.to_owned())
}),
};
let addr = start_server(state).await;
let (mut tx, _rx) = connect_with_token(addr, "valid-token").await;
tokio::time::sleep(Duration::from_millis(50)).await;
let msg = json!({"name": "custom.business-event", "data": {"key": "val"}});
tx.send(send_json(&msg.to_string())).await.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(router.called.load(Ordering::Relaxed));
}
#[tokio::test]
async fn multiple_concurrent_connections() {
let (state, manager) = default_state();
let addr = start_server(state).await;
let mut handles = Vec::new();
for _ in 0..10 {
handles.push(tokio::spawn(
async move { connect_with_token(addr, "valid-token").await },
));
}
let mut connections = Vec::new();
for h in handles {
connections.push(h.await.unwrap());
}
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(manager.client_count(), 10);
}
@@ -0,0 +1,294 @@
use std::sync::Arc;
use std::time::Duration;
use nomifun_api_types::WebSocketMessage;
use nomifun_realtime::{
ConnectionId, PER_CONNECTION_BUFFER, TokenValidator, WebSocketCloseCode, WebSocketManager, WsOutbound,
};
use serde_json::json;
use tokio::sync::mpsc;
fn always_valid() -> TokenValidator {
Arc::new(|_| true)
}
fn new_client_tx() -> (mpsc::Sender<WsOutbound>, mpsc::Receiver<WsOutbound>) {
mpsc::channel(PER_CONNECTION_BUFFER)
}
// --- Connection lifecycle ---
#[test]
fn register_and_remove_multiple_clients() {
let mgr = WebSocketManager::new();
let mut ids = Vec::new();
for i in 0..10 {
let (tx, _rx) = new_client_tx();
let id = mgr.add_client(format!("token-{i}"), tx);
ids.push(id);
}
assert_eq!(mgr.client_count(), 10);
// Remove every other client
for id in ids.iter().step_by(2) {
mgr.remove_client(*id);
}
assert_eq!(mgr.client_count(), 5);
// Remove remaining
for id in ids.iter().skip(1).step_by(2) {
mgr.remove_client(*id);
}
assert_eq!(mgr.client_count(), 0);
}
#[test]
fn connection_ids_are_unique_and_monotonic() {
let mgr = WebSocketManager::new();
let mut ids = Vec::new();
for _ in 0..100 {
let (tx, _rx) = new_client_tx();
ids.push(mgr.add_client("tok".into(), tx));
}
// Check uniqueness
let mut sorted = ids.clone();
sorted.sort_by_key(|id| id.0);
sorted.dedup();
assert_eq!(sorted.len(), 100);
// Check monotonic
for window in ids.windows(2) {
assert!(window[0].0 < window[1].0);
}
}
// --- Broadcast ---
#[test]
fn broadcast_all_delivers_identical_content_to_every_client() {
let mgr = WebSocketManager::new();
let mut receivers = Vec::new();
for i in 0..5 {
let (tx, rx) = new_client_tx();
mgr.add_client(format!("token-{i}"), tx);
receivers.push(rx);
}
let event = WebSocketMessage::new("notification", json!({"level": "info", "text": "hello"}));
mgr.broadcast_all(event);
let mut texts = Vec::new();
for rx in &mut receivers {
match rx.try_recv().unwrap() {
WsOutbound::Text(t) => texts.push(t),
other => panic!("expected Text, got {other:?}"),
}
}
// All received identical content
assert!(texts.windows(2).all(|w| w[0] == w[1]));
assert!(texts[0].contains("notification"));
}
#[test]
fn broadcast_cleans_up_disconnected_clients_transparently() {
let mgr = WebSocketManager::new();
// 3 live clients
let (tx1, _rx1) = new_client_tx();
let (tx2, _rx2) = new_client_tx();
let (tx3, _rx3) = new_client_tx();
mgr.add_client("a".into(), tx1);
mgr.add_client("b".into(), tx2);
mgr.add_client("c".into(), tx3);
// 2 dead clients (receivers dropped)
let (tx4, rx4) = new_client_tx();
let (tx5, rx5) = new_client_tx();
mgr.add_client("dead-1".into(), tx4);
mgr.add_client("dead-2".into(), tx5);
drop(rx4);
drop(rx5);
assert_eq!(mgr.client_count(), 5);
mgr.broadcast_all(WebSocketMessage::new("check", json!(null)));
// Dead clients should be removed
assert_eq!(mgr.client_count(), 3);
}
// --- Unicast ---
#[test]
fn send_to_reaches_only_target_connection() {
let mgr = WebSocketManager::new();
let mut pairs: Vec<(ConnectionId, mpsc::Receiver<WsOutbound>)> = Vec::new();
for i in 0..5 {
let (tx, rx) = new_client_tx();
let id = mgr.add_client(format!("token-{i}"), tx);
pairs.push((id, rx));
}
let target_id = pairs[2].0;
mgr.send_to(target_id, WebSocketMessage::new("private", json!({"secret": true})));
for (id, rx) in &mut pairs {
if *id == target_id {
let msg = rx.try_recv().unwrap();
match msg {
WsOutbound::Text(t) => assert!(t.contains("private")),
other => panic!("expected Text, got {other:?}"),
}
} else {
assert!(rx.try_recv().is_err(), "non-target {id} should not receive message");
}
}
}
// --- Heartbeat integration ---
#[tokio::test]
async fn heartbeat_sends_ping_and_keeps_healthy_connections() {
let mgr = WebSocketManager::new();
let (tx, mut rx) = new_client_tx();
mgr.add_client("valid-token".into(), tx);
let handle = mgr.start_heartbeat(always_valid());
// Wait for first heartbeat tick (interval is 30s, but first tick fires immediately)
let msg = tokio::time::timeout(Duration::from_secs(2), rx.recv())
.await
.expect("timeout waiting for ping")
.expect("channel closed");
match msg {
WsOutbound::Text(text) => {
let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(parsed["name"], "ping");
assert!(parsed["data"]["timestamp"].is_u64());
}
other => panic!("expected ping Text, got {other:?}"),
}
// Connection should still be alive
assert_eq!(mgr.client_count(), 1);
handle.abort();
}
#[tokio::test]
async fn heartbeat_closes_expired_token_with_auth_expired_event() {
let mgr = WebSocketManager::new();
let (tx, mut rx) = new_client_tx();
mgr.add_client("bad-token".into(), tx);
let expired_validator: TokenValidator = Arc::new(|_| false);
let handle = mgr.start_heartbeat(expired_validator);
// Expect auth-expired event
let msg1 = tokio::time::timeout(Duration::from_secs(2), rx.recv())
.await
.expect("timeout")
.expect("closed");
match msg1 {
WsOutbound::Text(text) => {
let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(parsed["name"], "auth-expired");
assert!(parsed["data"]["message"].is_string());
}
other => panic!("expected auth-expired, got {other:?}"),
}
// Expect close frame
let msg2 = tokio::time::timeout(Duration::from_secs(1), rx.recv())
.await
.expect("timeout")
.expect("closed");
assert_eq!(
msg2,
WsOutbound::Close(WebSocketCloseCode::PolicyViolation, "token expired".into())
);
// Connection should be removed
assert_eq!(mgr.client_count(), 0);
handle.abort();
}
// --- Concurrent access ---
#[test]
fn concurrent_add_remove_does_not_panic() {
let mgr = Arc::new(WebSocketManager::new());
let mut handles = Vec::new();
// Spawn threads that add clients
for i in 0..10 {
let mgr = Arc::clone(&mgr);
handles.push(std::thread::spawn(move || {
let (tx, _rx) = new_client_tx();
mgr.add_client(format!("thread-{i}"), tx)
}));
}
let ids: Vec<ConnectionId> = handles.into_iter().map(|h| h.join().unwrap()).collect();
assert_eq!(mgr.client_count(), 10);
// All IDs should be unique
let mut unique = ids.clone();
unique.sort_by_key(|id| id.0);
unique.dedup();
assert_eq!(unique.len(), 10);
// Remove all concurrently
let mut handles = Vec::new();
for id in ids {
let mgr = Arc::clone(&mgr);
handles.push(std::thread::spawn(move || {
mgr.remove_client(id);
}));
}
for h in handles {
h.join().unwrap();
}
assert_eq!(mgr.client_count(), 0);
}
#[test]
fn concurrent_broadcast_does_not_panic() {
let mgr = Arc::new(WebSocketManager::new());
let mut _receivers = Vec::new();
for i in 0..5 {
let (tx, rx) = new_client_tx();
mgr.add_client(format!("tok-{i}"), tx);
_receivers.push(rx);
}
let mut handles = Vec::new();
for i in 0..10 {
let mgr = Arc::clone(&mgr);
handles.push(std::thread::spawn(move || {
mgr.broadcast_all(WebSocketMessage::new(format!("event-{i}"), json!(null)));
}));
}
for h in handles {
h.join().unwrap();
}
// All clients should still be connected
assert_eq!(mgr.client_count(), 5);
}