commit e7df9a96d223eccee9f212b16c5858628511ec47
parent 0befa387a036c25f289fb8b39c5c51190c1d4b9c
Author: triesap <tyson@radroots.org>
Date: Sun, 14 Jun 2026 02:31:29 -0700
runtime: add websocket session skeleton
- route root websocket upgrades into a TangleWebSocketSession shell
- keep NIP-11 responses on non-upgrade root requests
- prove the upgrade path through a real listener and websocket client
- verify formatting, runtime tests, workspace checks, and clippy
Diffstat:
6 files changed, 122 insertions(+), 3 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -1267,6 +1267,7 @@ dependencies = [
"tangle_store_pocket",
"tangle_test_support",
"tokio",
+ "tokio-tungstenite",
"tower",
"tracing",
]
diff --git a/crates/tangle_runtime/Cargo.toml b/crates/tangle_runtime/Cargo.toml
@@ -22,6 +22,7 @@ tracing = "0.1"
[dev-dependencies]
tangle_test_support = { path = "../tangle_test_support" }
tokio = { version = "1", features = ["macros", "rt", "time"] }
+tokio-tungstenite = "0.29"
tower = { version = "0.5", features = ["util"] }
[lints]
diff --git a/crates/tangle_runtime/src/lib.rs b/crates/tangle_runtime/src/lib.rs
@@ -11,6 +11,7 @@ pub(crate) mod pocket_conversion;
pub mod relay;
pub mod runtime;
pub mod server;
+pub mod session;
use std::{fmt, fs, path::Path, path::PathBuf};
diff --git a/crates/tangle_runtime/src/nip11.rs b/crates/tangle_runtime/src/nip11.rs
@@ -128,6 +128,10 @@ async fn base_relay_info(
State(document): State<BaseRelayInfoDocument>,
headers: HeaderMap,
) -> Response {
+ base_relay_info_response(document, headers)
+}
+
+pub fn base_relay_info_response(document: BaseRelayInfoDocument, headers: HeaderMap) -> Response {
if !accepts_nostr_json(headers.get(header::ACCEPT)) {
return (
StatusCode::NOT_FOUND,
diff --git a/crates/tangle_runtime/src/server.rs b/crates/tangle_runtime/src/server.rs
@@ -2,11 +2,21 @@
use crate::{
errors::BaseRelayError,
- nip11::{BaseRelayInfoConfig, BaseRelayInfoDocument, base_relay_info_router},
+ nip11::{BaseRelayInfoConfig, BaseRelayInfoDocument, base_relay_info_response},
ops::{BaseRelayReadinessState, base_relay_ops_router},
runtime::TangleRuntime,
+ session::TangleWebSocketSession,
};
-use axum::Router;
+use axum::{
+ Router,
+ extract::{
+ State,
+ ws::{WebSocketUpgrade, rejection::WebSocketUpgradeRejection},
+ },
+ response::{IntoResponse, Response},
+ routing::get,
+};
+use http::HeaderMap;
use std::net::SocketAddr;
use tokio::net::TcpListener;
@@ -77,7 +87,29 @@ pub fn tangle_http_router(
readiness: BaseRelayReadinessState,
info: BaseRelayInfoDocument,
) -> Router {
- base_relay_info_router(info).merge(base_relay_ops_router(readiness))
+ Router::new()
+ .route("/", get(tangle_root))
+ .with_state(TangleHttpState { info })
+ .merge(base_relay_ops_router(readiness))
+}
+
+#[derive(Debug, Clone)]
+struct TangleHttpState {
+ info: BaseRelayInfoDocument,
+}
+
+async fn tangle_root(
+ State(state): State<TangleHttpState>,
+ websocket: Result<WebSocketUpgrade, WebSocketUpgradeRejection>,
+ headers: HeaderMap,
+) -> Response {
+ match websocket {
+ Ok(websocket) => websocket
+ .protocols(["nostr"])
+ .on_upgrade(|socket| TangleWebSocketSession::new().run(socket))
+ .into_response(),
+ Err(_) => base_relay_info_response(state.info, headers),
+ }
}
#[cfg(test)]
@@ -93,6 +125,8 @@ mod tests {
use http::{Request, header};
use serde_json::json;
use std::path::{Path, PathBuf};
+ use tokio::net::TcpListener;
+ use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tower::ServiceExt;
#[tokio::test]
@@ -115,6 +149,42 @@ mod tests {
}
#[tokio::test]
+ async fn serve_until_shutdown_accepts_websocket_upgrade() {
+ let root = temp_root("websocket-upgrade");
+ let _ = std::fs::remove_dir_all(&root);
+ let runtime = TangleRuntime::open(runtime_config(&root)).expect("runtime");
+ let shutdown = runtime.shutdown_signal().clone();
+ let listener = TcpListener::bind("127.0.0.1:0").await.expect("listener");
+ let address = listener.local_addr().expect("address");
+ let task = tokio::spawn(super::serve_listener_until_shutdown(runtime, listener));
+ let mut request = format!("ws://{address}/")
+ .into_client_request()
+ .expect("request");
+ request.headers_mut().insert(
+ header::SEC_WEBSOCKET_PROTOCOL,
+ http::HeaderValue::from_static("nostr"),
+ );
+
+ let (_socket, response) = tokio_tungstenite::connect_async(request)
+ .await
+ .expect("websocket");
+
+ assert_eq!(response.status(), http::StatusCode::SWITCHING_PROTOCOLS);
+ assert_eq!(
+ response
+ .headers()
+ .get(header::SEC_WEBSOCKET_PROTOCOL)
+ .expect("protocol"),
+ "nostr"
+ );
+
+ shutdown.request_shutdown();
+ let report = task.await.expect("task").expect("serve");
+ assert_eq!(report.listen_addr(), address);
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[tokio::test]
async fn tangle_http_router_serves_nip11_health_and_ready_routes() {
let root = temp_root("http-router");
let config = runtime_config(&root);
diff --git a/crates/tangle_runtime/src/session.rs b/crates/tangle_runtime/src/session.rs
@@ -0,0 +1,42 @@
+#![forbid(unsafe_code)]
+
+use axum::extract::ws::WebSocket;
+use std::time::Instant;
+
+#[derive(Debug)]
+pub struct TangleWebSocketSession {
+ connected_at: Instant,
+}
+
+impl TangleWebSocketSession {
+ pub fn new() -> Self {
+ Self {
+ connected_at: Instant::now(),
+ }
+ }
+
+ pub fn connected_at(&self) -> Instant {
+ self.connected_at
+ }
+
+ pub async fn run(self, _socket: WebSocket) {}
+}
+
+impl Default for TangleWebSocketSession {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::TangleWebSocketSession;
+
+ #[test]
+ fn websocket_session_records_connection_time() {
+ let before = std::time::Instant::now();
+ let session = TangleWebSocketSession::new();
+
+ assert!(session.connected_at() >= before);
+ }
+}