commit 97a1482be3ba20a202270874e14ad9b7b9ca32dd
parent 2dd8e1879b57901096844ff831f107475e18a628
Author: kernelkind <kernelkind@gmail.com>
Date: Mon, 2 Feb 2026 12:25:44 -0500
feat(relay): support CLOSED message
Signed-off-by: kernelkind <kernelkind@gmail.com>
Diffstat:
3 files changed, 41 insertions(+), 0 deletions(-)
diff --git a/crates/enostr/src/relay/message.rs b/crates/enostr/src/relay/message.rs
@@ -18,6 +18,7 @@ pub enum RelayMessage<'a> {
Eose(&'a str),
Event(&'a str, &'a str),
Notice(&'a str),
+ Closed(&'a str, &'a str),
}
#[derive(Debug)]
@@ -79,6 +80,11 @@ impl<'a> RelayMessage<'a> {
RelayMessage::Event(sub_id, ev)
}
+ /// Construct a relay `CLOSED` message with its subscription id and reason.
+ pub fn closed(sub_id: &'a str, message: &'a str) -> Self {
+ RelayMessage::Closed(sub_id, message)
+ }
+
pub fn from_json(msg: &'a str) -> Result<RelayMessage<'a>> {
if msg.is_empty() {
return Err(Error::Empty);
@@ -141,6 +147,18 @@ impl<'a> RelayMessage<'a> {
));
}
+ // CLOSED (NIP-01)
+ // Relay response format: ["CLOSED", <subscription_id>, <message>]
+ if msg.starts_with("[\"CLOSED\"") {
+ let parts: Vec<&'a str> =
+ serde_json::from_str(msg).map_err(|err| Error::DecodeFailed(err.to_string()))?;
+ if parts.len() != 3 || parts[0] != "CLOSED" {
+ return Err(Error::DecodeFailed("Invalid CLOSED format".into()));
+ }
+
+ return Ok(Self::closed(parts[1], parts[2]));
+ }
+
// OK (NIP-20)
// Relay response format: ["OK",<event_id>, <true|false>, <message>]
if &msg[0..=5] == "[\"OK\"," && msg.len() >= 78 {
@@ -206,6 +224,13 @@ mod tests {
Ok(RelayMessage::eose("random-subscription-id")),
),
(
+ r#"["CLOSED","sub1","error: shutting down idle subscription"]"#,
+ Ok(RelayMessage::closed(
+ "sub1",
+ "error: shutting down idle subscription",
+ )),
+ ),
+ (
r#"["OK","b1a649ebe8b435ec71d3784793f3bbf4b93e64e17568a741aecd4c7ddeafce30",true,"pow: difficulty 25>=24"]"#,
Ok(RelayMessage::ok(
"b1a649ebe8b435ec71d3784793f3bbf4b93e64e17568a741aecd4c7ddeafce30",
@@ -246,6 +271,10 @@ mod tests {
r#"["OK","b1a649ebe8b435ec71d3784793f3bbf4b93e64e17568a741aecd4c7ddeafce30",hello,404]"#,
Err(Error::DecodeFailed("bad boolean value".into())),
),
+ (
+ r#"["CLOSED","sub1"]"#,
+ Err(Error::DecodeFailed("Invalid CLOSED format".into())),
+ ),
];
for (input, expected) in tests {
diff --git a/crates/enostr/src/relay/subs_debug.rs b/crates/enostr/src/relay/subs_debug.rs
@@ -60,6 +60,9 @@ impl From<RelayEvent<'_>> for OwnedRelayEvent {
RelayMessage::Eose(s) => format!("EOSE:{s}"),
RelayMessage::Event(_, s) => format!("EVENT:{s}"),
RelayMessage::Notice(s) => format!("NOTICE:{s}"),
+ RelayMessage::Closed(sub_id, message) => {
+ format!("CLOSED:{sub_id}:{message}")
+ }
};
OwnedRelayEvent::Message(relay_msg)
}
@@ -249,6 +252,9 @@ fn calculate_relay_message_size(message: &RelayMessage) -> usize {
RelayMessage::Eose(str_ref)
| RelayMessage::Event(str_ref, _)
| RelayMessage::Notice(str_ref) => mem::size_of_val(message) + str_ref.len(),
+ RelayMessage::Closed(sub_id, reason) => {
+ mem::size_of_val(message) + sub_id.len() + reason.len()
+ }
}
}
diff --git a/crates/notedeck/src/app.rs b/crates/notedeck/src/app.rs
@@ -546,6 +546,12 @@ fn process_message_core(ctx: &mut AppContext<'_>, relay: &str, msg: &RelayMessag
RelayMessage::Eose(id) => {
tracing::trace!("Relay {} received eose: {id}", relay)
}
+ RelayMessage::Closed(sid, reason) => {
+ tracing::trace!(
+ "Relay {} with sub {sid} received close because: {reason}",
+ relay
+ );
+ }
}
}