commit 1bddd8afa8465dcb0e22ec119197aad851cb0a0d
parent 384791318a65845bf744ea5bd48fc595b3e77c4f
Author: triesap <tyson@radroots.org>
Date: Sat, 6 Jun 2026 01:11:55 -0700
ws: add websocket route
Diffstat:
3 files changed, 129 insertions(+), 6 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -251,6 +251,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
+ "base64",
"bytes",
"form_urlencoded",
"futures-util",
@@ -269,8 +270,10 @@ dependencies = [
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
+ "sha1",
"sync_wrapper",
"tokio",
+ "tokio-tungstenite 0.29.0",
"tower",
"tower-layer",
"tower-service",
@@ -4133,7 +4136,19 @@ dependencies = [
"futures-util",
"log",
"tokio",
- "tungstenite",
+ "tungstenite 0.28.0",
+]
+
+[[package]]
+name = "tokio-tungstenite"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
+dependencies = [
+ "futures-util",
+ "log",
+ "tokio",
+ "tungstenite 0.29.0",
]
[[package]]
@@ -4150,7 +4165,7 @@ dependencies = [
"js-sys",
"thiserror",
"tokio",
- "tokio-tungstenite",
+ "tokio-tungstenite 0.28.0",
"wasm-bindgen",
"web-sys",
]
@@ -4337,6 +4352,22 @@ dependencies = [
]
[[package]]
+name = "tungstenite"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
+dependencies = [
+ "bytes",
+ "data-encoding",
+ "http",
+ "httparse",
+ "log",
+ "rand 0.9.4",
+ "sha1",
+ "thiserror",
+]
+
+[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/crates/tangle_runtime/Cargo.toml b/crates/tangle_runtime/Cargo.toml
@@ -8,7 +8,7 @@ license.workspace = true
description = "HTTP, WebSocket, and CLI runtime surfaces for tangle"
[dependencies]
-axum = "0.8"
+axum = { version = "0.8", features = ["ws"] }
http = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
diff --git a/crates/tangle_runtime/src/lib.rs b/crates/tangle_runtime/src/lib.rs
@@ -2,6 +2,7 @@
use axum::{
Json, Router,
+ extract::ws::WebSocketUpgrade,
extract::{Path, RawQuery, State},
response::{IntoResponse, Response},
routing::get,
@@ -170,6 +171,27 @@ impl RelayConnection {
}
}
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct WebSocketHttpState {
+ connection_config: RelayConnectionConfig,
+}
+
+impl WebSocketHttpState {
+ pub fn new(connection_config: RelayConnectionConfig) -> Self {
+ Self { connection_config }
+ }
+
+ pub fn connection_config(&self) -> &RelayConnectionConfig {
+ &self.connection_config
+ }
+}
+
+impl Default for WebSocketHttpState {
+ fn default() -> Self {
+ Self::new(RelayConnectionConfig::default())
+ }
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApiErrorCode {
InvalidRequest,
@@ -687,6 +709,12 @@ pub fn relay_info_router(document: RelayInfoDocument) -> Router {
.with_state(document)
}
+pub fn websocket_router(state: WebSocketHttpState) -> Router {
+ Router::new()
+ .route("/", get(websocket_upgrade))
+ .with_state(state)
+}
+
pub fn listings_router(state: ListingsHttpState) -> Router {
Router::new()
.route("/api/listings", get(listings))
@@ -727,6 +755,17 @@ async fn relay_info(State(relay_info): State<RelayInfoDocument>, headers: Header
.into_response()
}
+async fn websocket_upgrade(
+ State(state): State<WebSocketHttpState>,
+ websocket: WebSocketUpgrade,
+) -> Response {
+ websocket
+ .on_upgrade(move |_socket| async move {
+ let _connection_config = state.connection_config;
+ })
+ .into_response()
+}
+
async fn listings(
State(state): State<ListingsHttpState>,
RawQuery(query): RawQuery,
@@ -1316,9 +1355,9 @@ mod tests {
ApiError, ApiErrorBody, ApiErrorCode, ApiErrorEnvelope, ListingsHttpState,
ReadinessCheckStatus, ReadinessState, RelayConnection, RelayConnectionConfig,
RelayConnectionId, RelayInfoDocument, TANGLE_RELAY_SOFTWARE, TANGLE_SUPPORTED_NIPS,
- health_router, listing_item_document, listing_projection_query, listings_router,
- parse_listing_query, parse_marketplace_search_query, relay_info_router,
- search_document_query,
+ WebSocketHttpState, health_router, listing_item_document, listing_projection_query,
+ listings_router, parse_listing_query, parse_marketplace_search_query, relay_info_router,
+ search_document_query, websocket_router,
};
use axum::{body::Body, response::IntoResponse};
use http::{HeaderValue, Request, StatusCode, header};
@@ -1490,6 +1529,59 @@ mod tests {
assert_eq!(connection.subscriptions_mut().active_count(), 0);
}
+ #[test]
+ fn websocket_state_uses_relay_connection_config() {
+ let config = RelayConnectionConfig::new(
+ "wss://relay.radroots.test",
+ 60,
+ RateLimitConfig::new(5, 10).expect("rate limit"),
+ RuntimeLimits::default(),
+ )
+ .expect("config");
+ let state = WebSocketHttpState::new(config.clone());
+ let default_state = WebSocketHttpState::default();
+
+ assert_eq!(state.connection_config(), &config);
+ assert_eq!(
+ default_state.connection_config().relay_url(),
+ "wss://relay.radroots.test"
+ );
+ }
+
+ #[tokio::test]
+ async fn websocket_route_requires_upgrade_headers() {
+ let response = websocket_router(WebSocketHttpState::default())
+ .oneshot(
+ Request::builder()
+ .uri("/")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("response");
+
+ assert_eq!(response.status(), StatusCode::BAD_REQUEST);
+ }
+
+ #[tokio::test]
+ async fn websocket_route_requires_hyper_upgrade_extension() {
+ let response = websocket_router(WebSocketHttpState::default())
+ .oneshot(
+ Request::builder()
+ .uri("/")
+ .header(header::CONNECTION, "upgrade")
+ .header(header::UPGRADE, "websocket")
+ .header("sec-websocket-version", "13")
+ .header("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ==")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("response");
+
+ assert_eq!(response.status(), StatusCode::UPGRADE_REQUIRED);
+ }
+
#[tokio::test]
async fn api_error_into_response_keeps_public_envelope_shape() {
let response = ApiError::not_found("listing not found").into_response();