tangle


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

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:
MCargo.lock | 1+
Mcrates/tangle_runtime/Cargo.toml | 1+
Mcrates/tangle_runtime/src/lib.rs | 1+
Mcrates/tangle_runtime/src/nip11.rs | 4++++
Mcrates/tangle_runtime/src/server.rs | 76+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Acrates/tangle_runtime/src/session.rs | 42++++++++++++++++++++++++++++++++++++++++++
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); + } +}