|
| 1 | +//! A minimal embedded [NIP-01] nostr relay served over a nostrdb [`Ndb`] handle. |
| 2 | +//! |
| 3 | +//! This exists so external tooling — CLI utilities for dogfooding Headway — can |
| 4 | +//! publish and read nostr events directly against a running app's local |
| 5 | +//! nostrdb. It speaks just enough of NIP-01 to be useful: |
| 6 | +//! |
| 7 | +//! - `["EVENT", {…}]` — ingest the event into ndb, reply with `OK`. |
| 8 | +//! - `["REQ", <sub>, <filter>…]` — replay stored matches, send `EOSE`, then |
| 9 | +//! live-stream newly ingested matches until the subscription is closed. |
| 10 | +//! - `["CLOSE", <sub>]` — stop a live subscription. |
| 11 | +//! |
| 12 | +//! There is deliberately no NIP-11, NIP-42 auth, or NIP-77 negentropy. Access |
| 13 | +//! control is "bind to localhost" — this is a dogfooding port, not a public |
| 14 | +//! relay. |
| 15 | +//! |
| 16 | +//! [NIP-01]: https://github.com/nostr-protocol/nips/blob/master/01.md |
| 17 | +
|
| 18 | +use std::collections::HashMap; |
| 19 | +use std::net::SocketAddr; |
| 20 | + |
| 21 | +use futures_util::{SinkExt, StreamExt}; |
| 22 | +use nostrdb::{Filter, Ndb, Transaction}; |
| 23 | +use serde_json::{Value, json}; |
| 24 | +use tokio::net::{TcpListener, TcpStream}; |
| 25 | +use tokio::sync::{mpsc, oneshot, watch}; |
| 26 | +use tokio_tungstenite::accept_async; |
| 27 | +use tokio_tungstenite::tungstenite::Message; |
| 28 | + |
| 29 | +type BoxError = Box<dyn std::error::Error + Send + Sync>; |
| 30 | + |
| 31 | +/// How many stored events a single `REQ` replays before `EOSE`. |
| 32 | +const STORED_QUERY_LIMIT: i32 = 500; |
| 33 | +/// How many freshly-ingested notes we drain per subscription wakeup. |
| 34 | +const LIVE_BATCH: u32 = 64; |
| 35 | + |
| 36 | +/// A running relay. Dropping the handle (or calling [`shutdown`](Self::shutdown)) |
| 37 | +/// stops the accept loop; in-flight connection tasks then wind down on their own. |
| 38 | +pub struct RelayHandle { |
| 39 | + local_addr: SocketAddr, |
| 40 | + shutdown: watch::Sender<bool>, |
| 41 | +} |
| 42 | + |
| 43 | +impl RelayHandle { |
| 44 | + /// The address the relay actually bound to (useful when binding to port 0). |
| 45 | + pub fn local_addr(&self) -> SocketAddr { |
| 46 | + self.local_addr |
| 47 | + } |
| 48 | + |
| 49 | + /// The `ws://` URL clients should connect to. |
| 50 | + pub fn url(&self) -> String { |
| 51 | + format!("ws://{}", self.local_addr) |
| 52 | + } |
| 53 | + |
| 54 | + /// Signal the accept loop to stop. |
| 55 | + pub fn shutdown(&self) { |
| 56 | + let _ = self.shutdown.send(true); |
| 57 | + } |
| 58 | +} |
| 59 | + |
| 60 | +impl Drop for RelayHandle { |
| 61 | + fn drop(&mut self) { |
| 62 | + self.shutdown(); |
| 63 | + } |
| 64 | +} |
| 65 | + |
| 66 | +/// Bind a NIP-01 relay to `addr` and spawn its accept loop on the current Tokio |
| 67 | +/// runtime. Returns immediately with a [`RelayHandle`]. |
| 68 | +/// |
| 69 | +/// Binds synchronously (so a port conflict surfaces here, not in a detached |
| 70 | +/// task) and must be called from within a Tokio runtime context. |
| 71 | +pub fn spawn(ndb: Ndb, addr: SocketAddr) -> std::io::Result<RelayHandle> { |
| 72 | + let std_listener = std::net::TcpListener::bind(addr)?; |
| 73 | + let local_addr = std_listener.local_addr()?; |
| 74 | + std_listener.set_nonblocking(true)?; |
| 75 | + let listener = TcpListener::from_std(std_listener)?; |
| 76 | + |
| 77 | + let (shutdown, shutdown_rx) = watch::channel(false); |
| 78 | + tokio::spawn(accept_loop(listener, ndb, shutdown_rx)); |
| 79 | + |
| 80 | + tracing::info!("nostrdb_relay listening on ws://{local_addr}"); |
| 81 | + Ok(RelayHandle { |
| 82 | + local_addr, |
| 83 | + shutdown, |
| 84 | + }) |
| 85 | +} |
| 86 | + |
| 87 | +async fn accept_loop(listener: TcpListener, ndb: Ndb, mut shutdown_rx: watch::Receiver<bool>) { |
| 88 | + loop { |
| 89 | + tokio::select! { |
| 90 | + accepted = listener.accept() => { |
| 91 | + let Ok((stream, _peer)) = accepted else { continue }; |
| 92 | + let ndb = ndb.clone(); |
| 93 | + let shutdown_rx = shutdown_rx.clone(); |
| 94 | + tokio::spawn(async move { |
| 95 | + if let Err(err) = serve_connection(stream, ndb, shutdown_rx).await { |
| 96 | + tracing::debug!("nostrdb_relay connection ended: {err}"); |
| 97 | + } |
| 98 | + }); |
| 99 | + } |
| 100 | + _ = shutdown_rx.changed() => { |
| 101 | + if *shutdown_rx.borrow() { |
| 102 | + break; |
| 103 | + } |
| 104 | + } |
| 105 | + } |
| 106 | + } |
| 107 | +} |
| 108 | + |
| 109 | +async fn serve_connection( |
| 110 | + stream: TcpStream, |
| 111 | + ndb: Ndb, |
| 112 | + mut shutdown_rx: watch::Receiver<bool>, |
| 113 | +) -> Result<(), BoxError> { |
| 114 | + let ws = accept_async(stream).await?; |
| 115 | + let (mut ws_tx, mut ws_rx) = ws.split(); |
| 116 | + |
| 117 | + // Subscription tasks push frames here; the connection drains them to the |
| 118 | + // socket. Keeping the original `out_tx` alive means `recv()` never returns |
| 119 | + // `None` while the connection lives. |
| 120 | + let (out_tx, mut out_rx) = mpsc::unbounded_channel::<Message>(); |
| 121 | + // subscription id -> cancel signal for its live-streaming task. |
| 122 | + let mut subs: HashMap<String, oneshot::Sender<()>> = HashMap::new(); |
| 123 | + |
| 124 | + loop { |
| 125 | + tokio::select! { |
| 126 | + outgoing = out_rx.recv() => { |
| 127 | + if let Some(msg) = outgoing { |
| 128 | + ws_tx.send(msg).await?; |
| 129 | + } |
| 130 | + } |
| 131 | + incoming = ws_rx.next() => { |
| 132 | + let Some(msg) = incoming else { break }; |
| 133 | + match msg? { |
| 134 | + Message::Text(text) => { |
| 135 | + handle_client_frame(&text, &ndb, &out_tx, &mut subs); |
| 136 | + } |
| 137 | + Message::Ping(payload) => ws_tx.send(Message::Pong(payload)).await?, |
| 138 | + Message::Close(_) => break, |
| 139 | + _ => {} |
| 140 | + } |
| 141 | + } |
| 142 | + _ = shutdown_rx.changed() => { |
| 143 | + if *shutdown_rx.borrow() { |
| 144 | + break; |
| 145 | + } |
| 146 | + } |
| 147 | + } |
| 148 | + } |
| 149 | + |
| 150 | + // Dropping the cancel senders stops every live subscription task, which then |
| 151 | + // unsubscribes from ndb. |
| 152 | + subs.clear(); |
| 153 | + Ok(()) |
| 154 | +} |
| 155 | + |
| 156 | +/// Parse and act on one client text frame. Errors are reported to the client as |
| 157 | +/// `NOTICE` rather than dropping the connection. |
| 158 | +fn handle_client_frame( |
| 159 | + text: &str, |
| 160 | + ndb: &Ndb, |
| 161 | + out_tx: &mpsc::UnboundedSender<Message>, |
| 162 | + subs: &mut HashMap<String, oneshot::Sender<()>>, |
| 163 | +) { |
| 164 | + let Ok(Value::Array(frame)) = serde_json::from_str::<Value>(text) else { |
| 165 | + let _ = out_tx.send(notice("could not parse message")); |
| 166 | + return; |
| 167 | + }; |
| 168 | + |
| 169 | + match frame.first().and_then(Value::as_str) { |
| 170 | + Some("EVENT") => handle_event(text, &frame, ndb, out_tx), |
| 171 | + Some("REQ") => handle_req(&frame, ndb, out_tx, subs), |
| 172 | + Some("CLOSE") => { |
| 173 | + if let Some(sub_id) = frame.get(1).and_then(Value::as_str) { |
| 174 | + subs.remove(sub_id); |
| 175 | + } |
| 176 | + } |
| 177 | + _ => { |
| 178 | + let _ = out_tx.send(notice("unrecognized message")); |
| 179 | + } |
| 180 | + } |
| 181 | +} |
| 182 | + |
| 183 | +fn handle_event(text: &str, frame: &[Value], ndb: &Ndb, out_tx: &mpsc::UnboundedSender<Message>) { |
| 184 | + let event_id = frame |
| 185 | + .get(1) |
| 186 | + .and_then(|e| e.get("id")) |
| 187 | + .and_then(Value::as_str) |
| 188 | + .unwrap_or(""); |
| 189 | + |
| 190 | + // The client frame is already `["EVENT", {…}]`, exactly what |
| 191 | + // `process_client_event` expects, so we hand it the raw text verbatim. |
| 192 | + match ndb.process_client_event(text) { |
| 193 | + Ok(()) => { |
| 194 | + let _ = out_tx.send(ok(event_id, true, "")); |
| 195 | + } |
| 196 | + Err(err) => { |
| 197 | + let _ = out_tx.send(ok(event_id, false, &format!("error: {err}"))); |
| 198 | + } |
| 199 | + } |
| 200 | +} |
| 201 | + |
| 202 | +fn handle_req( |
| 203 | + frame: &[Value], |
| 204 | + ndb: &Ndb, |
| 205 | + out_tx: &mpsc::UnboundedSender<Message>, |
| 206 | + subs: &mut HashMap<String, oneshot::Sender<()>>, |
| 207 | +) { |
| 208 | + let Some(sub_id) = frame.get(1).and_then(Value::as_str) else { |
| 209 | + let _ = out_tx.send(notice("REQ missing subscription id")); |
| 210 | + return; |
| 211 | + }; |
| 212 | + let sub_id = sub_id.to_owned(); |
| 213 | + |
| 214 | + let filters: Vec<Filter> = frame[2..] |
| 215 | + .iter() |
| 216 | + .filter_map(|f| Filter::from_json(&f.to_string()).ok()) |
| 217 | + .collect(); |
| 218 | + |
| 219 | + // Stored phase, run synchronously here: everything already in ndb that |
| 220 | + // matches, then EOSE. Doing it before we spawn keeps the non-`Send` `Filter` |
| 221 | + // and `Transaction` off the awaiting task entirely. |
| 222 | + if let Ok(txn) = Transaction::new(ndb) |
| 223 | + && let Ok(results) = ndb.query(&txn, &filters, STORED_QUERY_LIMIT) |
| 224 | + { |
| 225 | + for result in results { |
| 226 | + if let Ok(note_json) = result.note.json() |
| 227 | + && out_tx.send(event(&sub_id, ¬e_json)).is_err() |
| 228 | + { |
| 229 | + return; |
| 230 | + } |
| 231 | + } |
| 232 | + } |
| 233 | + if out_tx.send(eose(&sub_id)).is_err() { |
| 234 | + return; |
| 235 | + } |
| 236 | + |
| 237 | + // Live phase: a fresh subscription only reports future ingests. Subscribe |
| 238 | + // here (still synchronous) so the spawned task captures only `Send` values. |
| 239 | + let Ok(sub) = ndb.subscribe(&filters) else { |
| 240 | + return; |
| 241 | + }; |
| 242 | + |
| 243 | + // A re-REQ of an existing id replaces the old subscription: inserting drops |
| 244 | + // the previous cancel sender, which stops the old streaming task. |
| 245 | + let (cancel_tx, cancel_rx) = oneshot::channel(); |
| 246 | + subs.insert(sub_id.clone(), cancel_tx); |
| 247 | + |
| 248 | + tokio::spawn(stream_subscription( |
| 249 | + ndb.clone(), |
| 250 | + sub, |
| 251 | + sub_id, |
| 252 | + out_tx.clone(), |
| 253 | + cancel_rx, |
| 254 | + )); |
| 255 | +} |
| 256 | + |
| 257 | +/// Live-stream newly ingested matches for one subscription until it's cancelled |
| 258 | +/// (CLOSE, re-REQ, or connection drop) or the client's outgoing channel closes. |
| 259 | +/// Captures only `Send` values so it can live on a spawned task. |
| 260 | +async fn stream_subscription( |
| 261 | + mut ndb: Ndb, |
| 262 | + sub: nostrdb::Subscription, |
| 263 | + sub_id: String, |
| 264 | + out_tx: mpsc::UnboundedSender<Message>, |
| 265 | + mut cancel_rx: oneshot::Receiver<()>, |
| 266 | +) { |
| 267 | + loop { |
| 268 | + tokio::select! { |
| 269 | + _ = &mut cancel_rx => break, |
| 270 | + notes = ndb.wait_for_notes(sub, LIVE_BATCH) => { |
| 271 | + let Ok(keys) = notes else { break }; |
| 272 | + let Ok(txn) = Transaction::new(&ndb) else { break }; |
| 273 | + for key in keys { |
| 274 | + if let Ok(note) = ndb.get_note_by_key(&txn, key) |
| 275 | + && let Ok(note_json) = note.json() |
| 276 | + && out_tx.send(event(&sub_id, ¬e_json)).is_err() { |
| 277 | + let _ = ndb.unsubscribe(sub); |
| 278 | + return; |
| 279 | + } |
| 280 | + } |
| 281 | + } |
| 282 | + } |
| 283 | + } |
| 284 | + let _ = ndb.unsubscribe(sub); |
| 285 | +} |
| 286 | + |
| 287 | +fn ok(event_id: &str, status: bool, message: &str) -> Message { |
| 288 | + Message::Text(json!(["OK", event_id, status, message]).to_string()) |
| 289 | +} |
| 290 | + |
| 291 | +fn eose(sub_id: &str) -> Message { |
| 292 | + Message::Text(json!(["EOSE", sub_id]).to_string()) |
| 293 | +} |
| 294 | + |
| 295 | +fn notice(message: &str) -> Message { |
| 296 | + Message::Text(json!(["NOTICE", message]).to_string()) |
| 297 | +} |
| 298 | + |
| 299 | +/// `["EVENT", <sub>, <note>]`. The note is already serialized JSON, so we splice |
| 300 | +/// it in rather than parse-and-reserialize. |
| 301 | +fn event(sub_id: &str, note_json: &str) -> Message { |
| 302 | + Message::Text(format!(r#"["EVENT",{},{}]"#, json!(sub_id), note_json)) |
| 303 | +} |
0 commit comments