This is mostly a typical Ruby on Rails application with FayeWebsocket server and Sidekiq for background jobs.
Clients connect to the FayeWebsocket server and only communicate with it in terms of the Nostr protocol.
Websocket server assigns persistent connection_id
to identify the client. and opens a separate connection to Redis.
Websocket server is responsible for initial validation of the system, subscriber and events, i.e.:
- Is the system in maintenance mode?
- Is max number connections to the server has not yet reached the limit?
- Is the subscriber rate-limited?
- Is NIP-43 enforced with min auth_level by configuration? Then check it or close the connection.
- Is NIP-43 not enforced? Then send NIP-42 AUTH event with challenge equal to
connection_id
- Is message payload matches content length?
- Is message payload a valid JSON?
- Is message payload conforms to known Nostr events schemas?
- Is the subscriber authorized to execute this event?
- Does the event's payload match basic sanity checks like
since < until
in filters?
In case all validations passed and event should be processed, Websocket server populates minimal required data structures in Redis (for example creates a SET client_reqs:<CONNECTION_ID>
with a list of provided subscription_ids
in case of REQ
event) and creates corresponding Sidekiq Job.
Event | Job |
---|---|
REQ |
NewSubscription |
CLOSE |
— |
EVENT |
NewEvent |
COUNT |
CountRequest |
AUTH |
— |
At this point request processing part is finished on the WebsocketServer side and it awaits the result of the processed Sidekiq job.
Each Websocket server connection handler instance creates a separate thread where it listens to Redis Pub/Sub
messages on channels matching pattern events:<CONNECTION_ID>:*
.
Those messages may either be Nostr events that should be sent to the client (as is or slightly modified on Websocket server side) or a message to close connection.
WebsocketServer manages NIP-11 HTTP response.
Websocket server is also responsible for properly handling connection close
and resources cleanup i.e. Redis data structures that belong to the connection_id
and Redis connection created specifically for this client.
Connection may be closed due to following reasons:
- Client closed connection
- NIP-43 is forced with min auth_level but client didn't pass it
- Connection was manually closed from admin UI
Currently redis:7
is used for Sidekiq jobs and RedisStack of version 7.2.0-v0 is used for the rest of the features. In future it is planned to add RedisStack of version 6.2.6-v9 for graph related queries and split main RedisStack to more granular features like the following:
- Pub/Sub
- Caching
- Rate limiting and traffic
- Subscriptions management (RediSearch)
- Settings (maintenance, max_allowed_connections)
- Auth
- etc
Keep in mind for higher workloads some kind of proxy (connection pooler) may be required, like Twemproxy for instance.
Data Structure | Type | Description |
---|---|---|
client_reqs:<CONNECTION_ID> |
SET | subscription_id list per connection |
connections |
SET | list of active connections |
connections_authenticators |
HASH | event kind-22242 id that validated connection |
subscriptions:<CONNECTION_ID>:<SUBSCRIPTION_ID> |
JSON (RedisJSON) | contains filters |
subscriptions_idx |
RediSearch Index (FT.CREATE ) |
indexes subscriptions for search using FT.SEARCH command |
authentications |
HASH | pubkey per connection |
authorizations |
HASH | auth_level per connection |
requests |
HASH | requests count per connection |
incoming_traffic |
HASH | incoming traffic per connection |
outgoing_traffic |
HASH | outgoing traffic per connection |
connections_ips |
HASH | IP address per connection |
connections_starts |
HASH | start time per connection |
events22242:<EVENT_22242_ID> |
EXPIRABLE STRING | indicates that the event was already used for authentications, expires when event becomes invalid |
maintenance |
STRING | prevents new connections, doesn't break existing |
unlimited_ips |
SET | list of IP addresses that won't be a subject to rate limiting |
max_allowed_connections |
STRING | NULL or 0 means unlimited |
email_confirmations:<TOKEN> |
EXPIRABLE STRING | Used to confirm users emails on Sign Up |
Main communication between WebsocketServer and other components (ApplicationServer, Sidekiq worker) is going through Redis PUBSUB
.
It is possible to subscriber to those messages if necessary.
For example, in order to build some extension/plugin or integration.
Currently all the messages are publishied to channels of the following pattern:
events:<CONNECTION_ID>:<SUBSCRIPTION_ID>:<COMMAND>"
SUBSCRIPTION_ID
may equal to _
if it is not used in specific command.
See the list of commands with their Nostr equivalents and description:
Command | Nostr Equivalent | Example Payload |
---|---|---|
FOUND_END | EOSE | — |
FOUND_EVENT | EVENT | {"id": "...", "sig": "...", ...} |
OK | OK | ["OK", "<MESSAGE>"] |
COUNT | COUNT | "5" |
NOTICE | NOTICE | <MESSAGE> |
TERMINATE | — | [4000, "<REASON>"] |
Keep in mind, there is no message for AUTH
because it is handled directly in WebsocketServer and not through Redis.
Choosing what actual Redis server to use and how to configure it consider the following. Websocket server uses minimal amounts of data in Redis and its more tolerant to critical failures with the worst that can happen — clients will have to reconnect and will lose some responses. While Sidekiq workers are more reliant on data in Redis and consume much more traffic (i.e. events payload) The main thing to remember is the eviction policy in order not to accidentally lose some Sidekiq jobs during high peak traffic.
Nostr business logic is running inside of background jobs. Those jobs may change Redis data, persist/delete Events in PostgreSQL, publish messages to redis channels with Nostr events responses or connection termination commands.
Ruby on Rails application served by Puma application server to manage incoming requests. Puma is responsible for routing websocket connections from clients to WebsocketServer. It also provides admin dashboard HTTP part and any future extensions i.e.: public dashboard, user portal, HTTP API, GraphQL etc
To manage application-specific tasks native ruby gem Clockwork is used instead of Cron.
Mirrors is a process that is responsible to launch Websocket Clients that connect to external Nostr relays based on user configuration in database. This process is started with bin/mirrors
and has to constantly run in background.
Tested against 14,15 and 16 versions of PostgreSQL but at the moment there are no version-specific SQL so in theory many versions should be compatible.
Database currently has the following core tables: authors
, delete_events
, events
, searchable_tags
, trusted_authors
which are slightly optimized for storage (by normalizing authors
public keys for example).
And secondary, not Nostr-specific tables: users
, user_pubkeys
, relay_mirrors
, invoices
, author_subscriptions
.
Proper indexing strategy is a subject to change.
Keep in mind, applications do need maintenance from time to time. In default setup of Saltivka its PostgreSQL Database which needs it the most. See config/crontab
for an example of a possible maintenance and adjust it to your specific needs.
Also, for higher workloads some kind of proxy (connection pooler) may be required, like Pgpool-II for instance.
This project is aimed to be as standard Ruby on Rails project as possible.
Meaning if there is a trade-off between The Rails Way and something else, most of the time it will be The Rails Way unless 20% of efforts allow to gain 80% of profit.
However, since Ruby in general and Puma/FayeWebsocket don't play really well with multi threading and WebsocketServer is the most important part in Nostr, one may want to rewrite WebsocketServer component in something more concurrency-friendly but keep the rest of the features.
To support such a potential development, Websocket server (see app/relay
) will be implemented in the most simple way with the least amount of dependencies.
Currently main dependencies (outside of app/relay
) are:
FayeWebsocket
ActiveSupport
Rails.logger
Sentry
RELAY_CONFIG
MemStore
It shouldn't be too complex to rewrite the Websocket component into another language for more efficient connection handling.