tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

commit 1bddd8afa8465dcb0e22ec119197aad851cb0a0d
parent 384791318a65845bf744ea5bd48fc595b3e77c4f
Author: triesap <tyson@radroots.org>
Date:   Sat,  6 Jun 2026 01:11:55 -0700

ws: add websocket route

Diffstat:
MCargo.lock | 35+++++++++++++++++++++++++++++++++--
Mcrates/tangle_runtime/Cargo.toml | 2+-
Mcrates/tangle_runtime/src/lib.rs | 98++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
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();