nostr-1.3.0.0: lib/Nostr/Relay.hs
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-|
Module : Nostr.Relay
Description : NIP-01 client-relay communication types and utilities
Copyright : (c) Emre YILMAZ, 2026
License : MIT
Maintainer : z@emre.xyz
This module defines the message types for client-relay communication
according to NIP-01 specification.
-}
module Nostr.Relay
( -- * Core Types
SubscriptionId(..)
, ClientMessage(..)
, RelayMessage(..)
, Filter(..)
, RelayConnection(..)
-- * Filter Helpers
, defaultFilter
-- * WebSocket Connection
, connectRelay
, sendMessage
, receiveMessage
, closeConnection
) where
import Control.Concurrent (forkIO, killThread, threadDelay)
import Control.Concurrent.STM
import Control.Exception (finally, catch, SomeException)
import Control.Monad (forever)
import Data.Aeson (FromJSON(..), ToJSON(..), object, withArray, withObject, (.:?), (.=))
import qualified Data.Aeson as A
import qualified Data.Aeson.Types as A
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Vector as V
import GHC.Generics (Generic)
import qualified Network.WebSockets as WS
import qualified Wuss
import Nostr.Event
-- | Subscription ID used to identify subscriptions
-- Can be any arbitrary string chosen by the client
newtype SubscriptionId = SubscriptionId { unSubscriptionId :: Text }
deriving (Show, Eq, Ord, Generic)
instance ToJSON SubscriptionId where
toJSON (SubscriptionId sid) = toJSON sid
instance FromJSON SubscriptionId where
parseJSON v = SubscriptionId <$> parseJSON v
-- ============================================================================
-- Client Messages (Client → Relay)
-- ============================================================================
-- | Messages sent from client to relay
data ClientMessage
= CMEvent Event -- ^ ["EVENT", <event>]
| CMReq SubscriptionId [Filter] -- ^ ["REQ", <subscription_id>, <filter1>, <filter2>, ...]
| CMClose SubscriptionId -- ^ ["CLOSE", <subscription_id>]
| CMAuth Event -- ^ ["AUTH", <signed-event>] (NIP-42)
deriving (Show, Eq, Generic)
instance ToJSON ClientMessage where
toJSON (CMEvent event) =
A.Array $ V.fromList [A.String "EVENT", A.toJSON event]
toJSON (CMReq subId filters) =
A.Array $ V.fromList $ [A.String "REQ", A.toJSON subId] ++ map A.toJSON filters
toJSON (CMClose subId) =
A.Array $ V.fromList [A.String "CLOSE", A.toJSON subId]
toJSON (CMAuth event) =
A.Array $ V.fromList [A.String "AUTH", A.toJSON event]
instance FromJSON ClientMessage where
parseJSON = withArray "ClientMessage" $ \arr -> do
when (V.length arr < 2) $
fail "ClientMessage array must have at least 2 elements"
msgType <- parseJSON (V.head arr) :: A.Parser Text
case msgType of
"EVENT" -> do
when (V.length arr /= 2) $
fail "EVENT message must have exactly 2 elements"
event <- parseJSON (arr V.! 1)
return $ CMEvent event
"REQ" -> do
when (V.length arr < 3) $
fail "REQ message must have at least 3 elements (type, subscription_id, filter)"
subId <- parseJSON (arr V.! 1)
filters <- mapM parseJSON (V.toList $ V.drop 2 arr)
return $ CMReq subId filters
"CLOSE" -> do
when (V.length arr /= 2) $
fail "CLOSE message must have exactly 2 elements"
subId <- parseJSON (arr V.! 1)
return $ CMClose subId
"AUTH" -> do
when (V.length arr /= 2) $
fail "AUTH message must have exactly 2 elements"
event <- parseJSON (arr V.! 1)
return $ CMAuth event
_ -> fail $ "Unknown client message type: " ++ T.unpack msgType
where
when :: Bool -> A.Parser () -> A.Parser ()
when True action = action
when False _ = return ()
-- ============================================================================
-- Relay Messages (Relay → Client)
-- ============================================================================
-- | Messages sent from relay to client
data RelayMessage
= RMEvent SubscriptionId Event -- ^ ["EVENT", <subscription_id>, <event>]
| RMOK EventId Bool Text -- ^ ["OK", <event_id>, <accepted>, <message>]
| RMEOSE SubscriptionId -- ^ ["EOSE", <subscription_id>]
| RMClosed SubscriptionId Text -- ^ ["CLOSED", <subscription_id>, <message>]
| RMNotice Text -- ^ ["NOTICE", <message>]
| RMAuth Text -- ^ ["AUTH", <challenge-string>] (NIP-42)
deriving (Show, Eq, Generic)
instance ToJSON RelayMessage where
toJSON (RMEvent subId event) =
A.Array $ V.fromList [A.String "EVENT", A.toJSON subId, A.toJSON event]
toJSON (RMOK eventId accepted msg) =
A.Array $ V.fromList [A.String "OK", A.toJSON eventId, A.toJSON accepted, A.toJSON msg]
toJSON (RMEOSE subId) =
A.Array $ V.fromList [A.String "EOSE", A.toJSON subId]
toJSON (RMClosed subId msg) =
A.Array $ V.fromList [A.String "CLOSED", A.toJSON subId, A.toJSON msg]
toJSON (RMNotice msg) =
A.Array $ V.fromList [A.String "NOTICE", A.toJSON msg]
toJSON (RMAuth challenge) =
A.Array $ V.fromList [A.String "AUTH", A.toJSON challenge]
instance FromJSON RelayMessage where
parseJSON = withArray "RelayMessage" $ \arr -> do
when (V.length arr < 2) $
fail "RelayMessage array must have at least 2 elements"
msgType <- parseJSON (V.head arr) :: A.Parser Text
case msgType of
"EVENT" -> do
when (V.length arr /= 3) $
fail "EVENT message must have exactly 3 elements"
subId <- parseJSON (arr V.! 1)
event <- parseJSON (arr V.! 2)
return $ RMEvent subId event
"OK" -> do
when (V.length arr /= 4) $
fail "OK message must have exactly 4 elements"
eventId <- parseJSON (arr V.! 1)
accepted <- parseJSON (arr V.! 2)
msg <- parseJSON (arr V.! 3)
return $ RMOK eventId accepted msg
"EOSE" -> do
when (V.length arr /= 2) $
fail "EOSE message must have exactly 2 elements"
subId <- parseJSON (arr V.! 1)
return $ RMEOSE subId
"CLOSED" -> do
when (V.length arr /= 3) $
fail "CLOSED message must have exactly 3 elements"
subId <- parseJSON (arr V.! 1)
msg <- parseJSON (arr V.! 2)
return $ RMClosed subId msg
"NOTICE" -> do
when (V.length arr /= 2) $
fail "NOTICE message must have exactly 2 elements"
msg <- parseJSON (arr V.! 1)
return $ RMNotice msg
"AUTH" -> do
when (V.length arr /= 2) $
fail "AUTH message must have exactly 2 elements"
challenge <- parseJSON (arr V.! 1)
return $ RMAuth challenge
_ -> fail $ "Unknown relay message type: " ++ T.unpack msgType
where
when :: Bool -> A.Parser () -> A.Parser ()
when True action = action
when False _ = return ()
-- ============================================================================
-- Filter
-- ============================================================================
-- | Filter for subscription queries
-- All fields are optional, matching events must satisfy all present conditions
data Filter = Filter
{ filterIds :: Maybe [EventId] -- ^ Event IDs to match
, filterAuthors :: Maybe [PubKey] -- ^ Author public keys to match
, filterKinds :: Maybe [Kind] -- ^ Event kinds to match
, filterETags :: Maybe [EventId] -- ^ #e tags to match
, filterPTags :: Maybe [PubKey] -- ^ #p tags to match
, filterSince :: Maybe Timestamp -- ^ Events after this timestamp
, filterUntil :: Maybe Timestamp -- ^ Events before this timestamp
, filterLimit :: Maybe Int -- ^ Maximum number of events to return
} deriving (Show, Eq, Generic)
-- | Default empty filter (matches everything)
defaultFilter :: Filter
defaultFilter = Filter
{ filterIds = Nothing
, filterAuthors = Nothing
, filterKinds = Nothing
, filterETags = Nothing
, filterPTags = Nothing
, filterSince = Nothing
, filterUntil = Nothing
, filterLimit = Nothing
}
instance ToJSON Filter where
toJSON f = object $ catMaybes
[ ("ids" .=) <$> filterIds f
, ("authors" .=) <$> filterAuthors f
, ("kinds" .=) <$> filterKinds f
, ("#e" .=) <$> filterETags f
, ("#p" .=) <$> filterPTags f
, ("since" .=) <$> filterSince f
, ("until" .=) <$> filterUntil f
, ("limit" .=) <$> filterLimit f
]
where
catMaybes :: [Maybe a] -> [a]
catMaybes = foldr (\mx xs -> maybe xs (:xs) mx) []
instance FromJSON Filter where
parseJSON = withObject "Filter" $ \v -> Filter
<$> v .:? "ids"
<*> v .:? "authors"
<*> v .:? "kinds"
<*> v .:? "#e"
<*> v .:? "#p"
<*> v .:? "since"
<*> v .:? "until"
<*> v .:? "limit"
-- ============================================================================
-- WebSocket Connection
-- ============================================================================
-- | Relay WebSocket connection
data RelayConnection = RelayConnection
{ relayUrl :: Text
, sendChan :: TChan ClientMessage
, recvChan :: TChan RelayMessage
, cleanup :: IO ()
}
-- | Connect to a Nostr relay via WebSocket
-- Takes a relay URL (e.g., "wss://relay.damus.io") and starts background threads
connectRelay :: Text -> IO RelayConnection
connectRelay url = do
sChan <- newTChanIO
rChan <- newTChanIO
let (isSecure, host, port, path) = parseRelayUrl url
-- Connection handler shared between secure and plain connections
let wsApp conn = do
-- Fork a sender thread that reads from sChan and sends to WS
senderTid <- forkIO $ forever $ do
msg <- atomically $ readTChan sChan
WS.sendTextData conn (A.encode msg)
-- Main loop: receive from WS and write to rChan
flip finally (killThread senderTid) $ forever $ do
msgData <- WS.receiveData conn
case A.eitherDecode msgData of
Right relayMsg -> atomically $ writeTChan rChan relayMsg
Left _ -> return ()
-- Connection loop with exponential backoff
let connectLoop delay = do
let runConn = if isSecure
then Wuss.runSecureClient (T.unpack host) (fromIntegral port) (T.unpack path) wsApp
else WS.runClient (T.unpack host) port (T.unpack path) wsApp
catch runConn (\(_ :: SomeException) -> do
threadDelay delay
let newDelay = min 60000000 (delay * 2)
connectLoop newDelay
)
tid <- forkIO $ connectLoop 1000000 -- Start with 1 second delay
return $ RelayConnection url sChan rChan (killThread tid)
where
-- Parse relay URL to extract scheme, host, port, and path
parseRelayUrl :: Text -> (Bool, Text, Int, Text)
parseRelayUrl u =
let (secure, stripped) = case T.stripPrefix "wss://" u of
Just s -> (True, s)
Nothing -> case T.stripPrefix "ws://" u of
Just s -> (False, s)
Nothing -> (True, u) -- Default to secure
(hostPort, pathWithSlash) = T.breakOn "/" stripped
path = if T.null pathWithSlash then "/" else pathWithSlash
(host, portText) = T.breakOn ":" hostPort
port = case T.stripPrefix ":" portText of
Just p -> case reads (T.unpack p) of
[(n, "")] -> n
_ -> if secure then 443 else 80
Nothing -> if secure then 443 else 80
in (secure, host, port, path)
-- | Send a client message to the relay
sendMessage :: RelayConnection -> ClientMessage -> IO ()
sendMessage conn msg = atomically $ writeTChan (sendChan conn) msg
-- | Receive a relay message from the relay
-- This is a blocking operation that waits for the next message
receiveMessage :: RelayConnection -> IO RelayMessage
receiveMessage conn = atomically $ readTChan (recvChan conn)
-- | Close the relay connection
closeConnection :: RelayConnection -> IO ()
closeConnection conn = cleanup conn