Skip to content

Commit 15df186

Browse files
jb55claude
andcommitted
relay: embed a localhost nostr relay for dogfooding
Add a `nostrdb_relay` crate: a minimal NIP-01 relay served directly over an `Ndb` handle (EVENT ingest, REQ with stored replay + EOSE + live streaming, CLOSE). It speaks just enough of the protocol for external CLI tooling to publish and read events against a running app's local nostrdb — the groundwork for dogfooding Headway. notedeck starts it by default on 127.0.0.1:6677; `--relay-bind <addr>` overrides the bind address and `--no-local-relay` disables it. Tests never start it (no runtime, and it must not open a port). The handle is held on `Notedeck` so it shuts down with the app. Closes #1485 Changelog-Added: Embedded localhost nostr relay (--relay-bind / --no-local-relay) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 098e2e4 commit 15df186

8 files changed

Lines changed: 493 additions & 0 deletions

File tree

Cargo.lock

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ members = [
99
"crates/notedeck_messages",
1010
"crates/notedeck_notebook",
1111
"crates/notedeck_headway",
12+
"crates/nostrdb_relay",
1213
"crates/notedeck_nostrverse",
1314
"crates/notedeck_ui",
1415
"crates/notedeck_clndash",
@@ -82,6 +83,7 @@ notedeck_dave = { path = "crates/notedeck_dave" }
8283
notedeck_messages = { path = "crates/notedeck_messages" }
8384
notedeck_notebook = { path = "crates/notedeck_notebook" }
8485
notedeck_headway = { path = "crates/notedeck_headway" }
86+
nostrdb_relay = { path = "crates/nostrdb_relay" }
8587
notedeck_nostrverse = { path = "crates/notedeck_nostrverse" }
8688
renderbud = { path = "crates/renderbud", features = ["egui"] }
8789
notedeck_ui = { path = "crates/notedeck_ui" }

crates/nostrdb_relay/Cargo.toml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[package]
2+
name = "nostrdb_relay"
3+
edition = "2024"
4+
version.workspace = true
5+
6+
[dependencies]
7+
nostrdb = { workspace = true }
8+
serde_json = { workspace = true }
9+
tracing = { workspace = true }
10+
tokio = { workspace = true, features = ["net", "sync"] }
11+
tokio-tungstenite = "0.24"
12+
futures-util = "0.3"
13+
14+
[dev-dependencies]
15+
tempfile = { workspace = true }
16+
tokio = { workspace = true, features = ["net", "sync", "time", "macros", "rt-multi-thread"] }
17+
tokio-tungstenite = "0.24"
18+
futures-util = "0.3"

crates/nostrdb_relay/src/lib.rs

Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
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, &note_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, &note_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

Comments
 (0)