feat: add QR scanner, improve UX, and simplify navigation

- Add live camera QR scanner for nsec/ncryptsec login
- Replace browser prompt() with proper password dialog for ncryptsec
- Add missing /notes/:id route for thread view navigation
- Remove explore section entirely (button, page, routes)
- Remove profile button from bottom nav, avatar now opens profile
- Remove "Notes" tab from feed, default to showing all posts/replies
- Add PasswordPromptProvider for secure password input
- Add SidebarDrawer for mobile navigation
- Add domain layer with value objects and adapters
- Various UI and navigation improvements

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
mleku
2025-12-28 04:00:16 +02:00
parent 6a7bfe0a3e
commit 2aa0a8c460
187 changed files with 42378 additions and 1454 deletions

View File

@@ -0,0 +1,921 @@
# C++ WebSocket Implementation for Nostr Relays (strfry patterns)
This reference documents high-performance WebSocket patterns from the strfry Nostr relay implementation in C++.
## Repository Information
- **Project:** strfry - High-performance Nostr relay
- **Repository:** https://github.com/hoytech/strfry
- **Language:** C++ (C++20)
- **WebSocket Library:** Custom fork of uWebSockets with epoll
- **Architecture:** Single-threaded I/O with specialized thread pools
## Core Architecture
### Thread Pool Design
strfry uses 6 specialized thread pools for different operations:
```
┌─────────────────────────────────────────────────────────────┐
│ Main Thread (I/O) │
│ - epoll event loop │
│ - WebSocket message reception │
│ - Connection management │
└─────────────────────────────────────────────────────────────┘
┌───────────────────┼───────────────────┐
│ │ │
┌────▼────┐ ┌───▼────┐ ┌───▼────┐
│Ingester │ │ReqWorker│ │Negentropy│
│ (3) │ │ (3) │ │ (2) │
└─────────┘ └─────────┘ └─────────┘
│ │ │
┌────▼────┐ ┌───▼────┐
│ Writer │ │ReqMonitor│
│ (1) │ │ (3) │
└─────────┘ └─────────┘
```
**Thread Pool Responsibilities:**
1. **WebSocket (1 thread):** Main I/O loop, epoll event handling
2. **Ingester (3 threads):** Event validation, signature verification, deduplication
3. **Writer (1 thread):** Database writes, event storage
4. **ReqWorker (3 threads):** Process REQ subscriptions, query database
5. **ReqMonitor (3 threads):** Monitor active subscriptions, send real-time events
6. **Negentropy (2 threads):** NIP-77 set reconciliation
**Deterministic thread assignment:**
```cpp
int threadId = connId % numThreads;
```
**Benefits:**
- **No lock contention:** Shared-nothing architecture
- **Predictable performance:** Same connection always same thread
- **CPU cache efficiency:** Thread-local data stays hot
### Connection State
```cpp
struct ConnectionState {
uint64_t connId; // Unique connection identifier
std::string remoteAddr; // Client IP address
// Subscription state
flat_str subId; // Current subscription ID
std::shared_ptr<Subscription> sub; // Subscription filter
uint64_t latestEventSent = 0; // Latest event ID sent
// Compression state (per-message deflate)
PerMessageDeflate pmd;
// Parsing state (reused buffer)
std::string parseBuffer;
// Signature verification context (reused)
secp256k1_context *secpCtx;
};
```
**Key design decisions:**
1. **Reusable parseBuffer:** Single allocation per connection
2. **Persistent secp256k1_context:** Expensive to create, reused for all signatures
3. **Connection ID:** Enables deterministic thread assignment
4. **Flat string (flat_str):** Value-semantic string-like type for zero-copy
## WebSocket Message Reception
### Main Event Loop (epoll)
```cpp
// Pseudocode representation of strfry's I/O loop
uWS::App app;
app.ws<ConnectionState>("/*", {
.compression = uWS::SHARED_COMPRESSOR,
.maxPayloadLength = 16 * 1024 * 1024,
.idleTimeout = 120,
.maxBackpressure = 1 * 1024 * 1024,
.upgrade = nullptr,
.open = [](auto *ws) {
auto *state = ws->getUserData();
state->connId = nextConnId++;
state->remoteAddr = getRemoteAddress(ws);
state->secpCtx = secp256k1_context_create(SECP256K1_CONTEXT_VERIFY);
LI << "New connection: " << state->connId << " from " << state->remoteAddr;
},
.message = [](auto *ws, std::string_view message, uWS::OpCode opCode) {
auto *state = ws->getUserData();
// Reuse parseBuffer to avoid allocation
state->parseBuffer.assign(message.data(), message.size());
try {
// Parse JSON (nlohmann::json)
auto json = nlohmann::json::parse(state->parseBuffer);
// Extract command type
auto cmdStr = json[0].get<std::string>();
if (cmdStr == "EVENT") {
handleEventMessage(ws, std::move(json));
}
else if (cmdStr == "REQ") {
handleReqMessage(ws, std::move(json));
}
else if (cmdStr == "CLOSE") {
handleCloseMessage(ws, std::move(json));
}
else if (cmdStr == "NEG-OPEN") {
handleNegentropyOpen(ws, std::move(json));
}
else {
sendNotice(ws, "unknown command: " + cmdStr);
}
}
catch (std::exception &e) {
sendNotice(ws, "Error: " + std::string(e.what()));
}
},
.close = [](auto *ws, int code, std::string_view message) {
auto *state = ws->getUserData();
LI << "Connection closed: " << state->connId
<< " code=" << code
<< " msg=" << std::string(message);
// Cleanup
secp256k1_context_destroy(state->secpCtx);
cleanupSubscription(state->connId);
},
});
app.listen(8080, [](auto *token) {
if (token) {
LI << "Listening on port 8080";
}
});
app.run();
```
**Key patterns:**
1. **epoll-based I/O:** Single thread handles thousands of connections
2. **Buffer reuse:** `state->parseBuffer` avoids allocation per message
3. **Move semantics:** `std::move(json)` transfers ownership to handler
4. **Exception handling:** Catches parsing errors, sends NOTICE
### Message Dispatch to Thread Pools
```cpp
void handleEventMessage(auto *ws, nlohmann::json &&json) {
auto *state = ws->getUserData();
// Pack message with connection ID
auto msg = MsgIngester{
.connId = state->connId,
.payload = std::move(json),
};
// Dispatch to Ingester thread pool (deterministic assignment)
tpIngester->dispatchToThread(state->connId, std::move(msg));
}
void handleReqMessage(auto *ws, nlohmann::json &&json) {
auto *state = ws->getUserData();
// Pack message
auto msg = MsgReq{
.connId = state->connId,
.payload = std::move(json),
};
// Dispatch to ReqWorker thread pool
tpReqWorker->dispatchToThread(state->connId, std::move(msg));
}
```
**Message passing pattern:**
```cpp
// ThreadPool::dispatchToThread
void dispatchToThread(uint64_t connId, Message &&msg) {
size_t threadId = connId % threads.size();
threads[threadId]->queue.push(std::move(msg));
}
```
**Benefits:**
- **Zero-copy:** `std::move` transfers ownership without copying
- **Deterministic:** Same connection always processed by same thread
- **Lock-free:** Each thread has own queue
## Event Ingestion Pipeline
### Ingester Thread Pool
```cpp
void IngesterThread::run() {
while (running) {
Message msg;
if (!queue.pop(msg, 100ms)) continue;
// Extract event from JSON
auto event = parseEvent(msg.payload);
// Validate event ID
if (!validateEventId(event)) {
sendOK(msg.connId, event.id, false, "invalid: id mismatch");
continue;
}
// Verify signature (using thread-local secp256k1 context)
if (!verifySignature(event, secpCtx)) {
sendOK(msg.connId, event.id, false, "invalid: signature verification failed");
continue;
}
// Check for duplicate (bloom filter + database)
if (isDuplicate(event.id)) {
sendOK(msg.connId, event.id, true, "duplicate: already have this event");
continue;
}
// Send to Writer thread
auto writerMsg = MsgWriter{
.connId = msg.connId,
.event = std::move(event),
};
tpWriter->dispatch(std::move(writerMsg));
}
}
```
**Validation sequence:**
1. Parse JSON into Event struct
2. Validate event ID matches content hash
3. Verify secp256k1 signature
4. Check duplicate (bloom filter for speed)
5. Forward to Writer thread for storage
### Writer Thread
```cpp
void WriterThread::run() {
// Single thread for all database writes
while (running) {
Message msg;
if (!queue.pop(msg, 100ms)) continue;
// Write to database
bool success = db.insertEvent(msg.event);
// Send OK to client
sendOK(msg.connId, msg.event.id, success,
success ? "" : "error: failed to store");
if (success) {
// Broadcast to subscribers
broadcastEvent(msg.event);
}
}
}
```
**Single-writer pattern:**
- Only one thread writes to database
- Eliminates write conflicts
- Simplified transaction management
### Event Broadcasting
```cpp
void broadcastEvent(const Event &event) {
// Serialize event JSON once
std::string eventJson = serializeEvent(event);
// Iterate all active subscriptions
for (auto &[connId, sub] : activeSubscriptions) {
// Check if filter matches
if (!sub->filter.matches(event)) continue;
// Check if event newer than last sent
if (event.id <= sub->latestEventSent) continue;
// Send to connection
auto msg = MsgWebSocket{
.connId = connId,
.payload = eventJson, // Reuse serialized JSON
};
tpWebSocket->dispatch(std::move(msg));
// Update latest sent
sub->latestEventSent = event.id;
}
}
```
**Critical optimization:** Serialize event JSON once, send to N subscribers
**Performance impact:** For 1000 subscribers, reduces:
- JSON serialization: 1000× → 1×
- Memory allocations: 1000× → 1×
- CPU time: ~100ms → ~1ms
## Subscription Management
### REQ Processing
```cpp
void ReqWorkerThread::run() {
while (running) {
MsgReq msg;
if (!queue.pop(msg, 100ms)) continue;
// Parse REQ message: ["REQ", subId, filter1, filter2, ...]
std::string subId = msg.payload[1];
// Create subscription object
auto sub = std::make_shared<Subscription>();
sub->subId = subId;
// Parse filters
for (size_t i = 2; i < msg.payload.size(); i++) {
Filter filter = parseFilter(msg.payload[i]);
sub->filters.push_back(filter);
}
// Store subscription
activeSubscriptions[msg.connId] = sub;
// Query stored events
std::vector<Event> events = db.queryEvents(sub->filters);
// Send matching events
for (const auto &event : events) {
sendEvent(msg.connId, subId, event);
}
// Send EOSE
sendEOSE(msg.connId, subId);
// Notify ReqMonitor to watch for real-time events
auto monitorMsg = MsgReqMonitor{
.connId = msg.connId,
.subId = subId,
};
tpReqMonitor->dispatchToThread(msg.connId, std::move(monitorMsg));
}
}
```
**Query optimization:**
```cpp
std::vector<Event> Database::queryEvents(const std::vector<Filter> &filters) {
// Combine filters with OR logic
std::string sql = "SELECT * FROM events WHERE ";
for (size_t i = 0; i < filters.size(); i++) {
if (i > 0) sql += " OR ";
sql += buildFilterSQL(filters[i]);
}
sql += " ORDER BY created_at DESC LIMIT 1000";
return executeQuery(sql);
}
```
**Filter SQL generation:**
```cpp
std::string buildFilterSQL(const Filter &filter) {
std::vector<std::string> conditions;
// Event IDs
if (!filter.ids.empty()) {
conditions.push_back("id IN (" + joinQuoted(filter.ids) + ")");
}
// Authors
if (!filter.authors.empty()) {
conditions.push_back("pubkey IN (" + joinQuoted(filter.authors) + ")");
}
// Kinds
if (!filter.kinds.empty()) {
conditions.push_back("kind IN (" + join(filter.kinds) + ")");
}
// Time range
if (filter.since) {
conditions.push_back("created_at >= " + std::to_string(*filter.since));
}
if (filter.until) {
conditions.push_back("created_at <= " + std::to_string(*filter.until));
}
// Tags (requires JOIN with tags table)
if (!filter.tags.empty()) {
for (const auto &[tagName, tagValues] : filter.tags) {
conditions.push_back(
"EXISTS (SELECT 1 FROM tags WHERE tags.event_id = events.id "
"AND tags.name = '" + tagName + "' "
"AND tags.value IN (" + joinQuoted(tagValues) + "))"
);
}
}
return "(" + join(conditions, " AND ") + ")";
}
```
### ReqMonitor for Real-Time Events
```cpp
void ReqMonitorThread::run() {
// Subscribe to event broadcast channel
auto eventSubscription = subscribeToEvents();
while (running) {
Event event;
if (!eventSubscription.receive(event, 100ms)) continue;
// Check all subscriptions assigned to this thread
for (auto &[connId, sub] : mySubscriptions) {
// Only process subscriptions for this thread
if (connId % numThreads != threadId) continue;
// Check if filter matches
bool matches = false;
for (const auto &filter : sub->filters) {
if (filter.matches(event)) {
matches = true;
break;
}
}
if (matches) {
sendEvent(connId, sub->subId, event);
}
}
}
}
```
**Pattern:** Monitor thread watches event stream, sends to matching subscriptions
### CLOSE Handling
```cpp
void handleCloseMessage(auto *ws, nlohmann::json &&json) {
auto *state = ws->getUserData();
// Parse CLOSE message: ["CLOSE", subId]
std::string subId = json[1];
// Remove subscription
activeSubscriptions.erase(state->connId);
LI << "Subscription closed: connId=" << state->connId
<< " subId=" << subId;
}
```
## Performance Optimizations
### 1. Event Batching
**Problem:** Serializing same event 1000× for 1000 subscribers is wasteful
**Solution:** Serialize once, send to all
```cpp
// BAD: Serialize for each subscriber
for (auto &sub : subscriptions) {
std::string json = serializeEvent(event); // Repeated!
send(sub.connId, json);
}
// GOOD: Serialize once
std::string json = serializeEvent(event);
for (auto &sub : subscriptions) {
send(sub.connId, json); // Reuse!
}
```
**Measurement:** For 1000 subscribers, reduces broadcast time from 100ms to 1ms
### 2. Move Semantics
**Problem:** Copying large JSON objects is expensive
**Solution:** Transfer ownership with `std::move`
```cpp
// BAD: Copies JSON object
void dispatch(Message msg) {
queue.push(msg); // Copy
}
// GOOD: Moves JSON object
void dispatch(Message &&msg) {
queue.push(std::move(msg)); // Move
}
```
**Benefit:** Zero-copy message passing between threads
### 3. Pre-allocated Buffers
**Problem:** Allocating buffer for each message
**Solution:** Reuse buffer per connection
```cpp
struct ConnectionState {
std::string parseBuffer; // Reused for all messages
};
void handleMessage(std::string_view msg) {
state->parseBuffer.assign(msg.data(), msg.size());
auto json = nlohmann::json::parse(state->parseBuffer);
// ...
}
```
**Benefit:** Eliminates 10,000+ allocations/second per connection
### 4. std::variant for Message Types
**Problem:** Virtual function calls for polymorphic messages
**Solution:** `std::variant` with `std::visit`
```cpp
// BAD: Virtual function (pointer indirection, vtable lookup)
struct Message {
virtual void handle() = 0;
};
// GOOD: std::variant (no indirection, inlined)
using Message = std::variant<
MsgIngester,
MsgReq,
MsgWriter,
MsgWebSocket
>;
void handle(Message &&msg) {
std::visit([](auto &&m) { m.handle(); }, msg);
}
```
**Benefit:** Compiler inlines visit, eliminates virtual call overhead
### 5. Bloom Filter for Duplicate Detection
**Problem:** Database query for every event to check duplicate
**Solution:** In-memory bloom filter for fast negative
```cpp
class DuplicateDetector {
BloomFilter bloom; // Fast probabilistic check
bool isDuplicate(const std::string &eventId) {
// Fast negative (definitely not seen)
if (!bloom.contains(eventId)) {
bloom.insert(eventId);
return false;
}
// Possible positive (maybe seen, check database)
if (db.eventExists(eventId)) {
return true;
}
// False positive
bloom.insert(eventId);
return false;
}
};
```
**Benefit:** 99% of duplicate checks avoid database query
### 6. Batch Queue Operations
**Problem:** Lock contention on message queue
**Solution:** Batch multiple pushes with single lock
```cpp
class MessageQueue {
std::mutex mutex;
std::deque<Message> queue;
void pushBatch(std::vector<Message> &messages) {
std::lock_guard lock(mutex);
for (auto &msg : messages) {
queue.push_back(std::move(msg));
}
}
};
```
**Benefit:** Reduces lock acquisitions by 10-100×
### 7. ZSTD Dictionary Compression
**Problem:** WebSocket compression slower than desired
**Solution:** Train ZSTD dictionary on typical Nostr messages
```cpp
// Train dictionary on corpus of Nostr events
std::string corpus = collectTypicalEvents();
ZSTD_CDict *dict = ZSTD_createCDict(
corpus.data(), corpus.size(),
compressionLevel
);
// Use dictionary for compression
size_t compressedSize = ZSTD_compress_usingCDict(
cctx, dst, dstSize,
src, srcSize, dict
);
```
**Benefit:** 10-20% better compression ratio, 2× faster decompression
### 8. String Views
**Problem:** Unnecessary string copies when parsing
**Solution:** Use `std::string_view` for zero-copy
```cpp
// BAD: Copies substring
std::string extractCommand(const std::string &msg) {
return msg.substr(0, 5); // Copy
}
// GOOD: View into original string
std::string_view extractCommand(std::string_view msg) {
return msg.substr(0, 5); // No copy
}
```
**Benefit:** Eliminates allocations during parsing
## Compression (permessage-deflate)
### WebSocket Compression Configuration
```cpp
struct PerMessageDeflate {
z_stream deflate_stream;
z_stream inflate_stream;
// Sliding window for compression history
static constexpr int WINDOW_BITS = 15;
static constexpr int MEM_LEVEL = 8;
void init() {
// Initialize deflate (compression)
deflate_stream.zalloc = Z_NULL;
deflate_stream.zfree = Z_NULL;
deflate_stream.opaque = Z_NULL;
deflateInit2(&deflate_stream,
Z_DEFAULT_COMPRESSION,
Z_DEFLATED,
-WINDOW_BITS, // Negative = no zlib header
MEM_LEVEL,
Z_DEFAULT_STRATEGY);
// Initialize inflate (decompression)
inflate_stream.zalloc = Z_NULL;
inflate_stream.zfree = Z_NULL;
inflate_stream.opaque = Z_NULL;
inflateInit2(&inflate_stream, -WINDOW_BITS);
}
std::string compress(std::string_view data) {
// Compress with sliding window
deflate_stream.next_in = (Bytef*)data.data();
deflate_stream.avail_in = data.size();
std::string compressed;
compressed.resize(deflateBound(&deflate_stream, data.size()));
deflate_stream.next_out = (Bytef*)compressed.data();
deflate_stream.avail_out = compressed.size();
deflate(&deflate_stream, Z_SYNC_FLUSH);
compressed.resize(compressed.size() - deflate_stream.avail_out);
return compressed;
}
};
```
**Typical compression ratios:**
- JSON events: 60-80% reduction
- Subscription filters: 40-60% reduction
- Binary events: 10-30% reduction
## Database Schema (LMDB)
strfry uses LMDB (Lightning Memory-Mapped Database) for event storage:
```cpp
// Key-value stores
struct EventDB {
// Primary event storage (key: event ID, value: event data)
lmdb::dbi eventsDB;
// Index by pubkey (key: pubkey + created_at, value: event ID)
lmdb::dbi pubkeyDB;
// Index by kind (key: kind + created_at, value: event ID)
lmdb::dbi kindDB;
// Index by tags (key: tag_name + tag_value + created_at, value: event ID)
lmdb::dbi tagsDB;
// Deletion index (key: event ID, value: deletion event ID)
lmdb::dbi deletionsDB;
};
```
**Why LMDB?**
- Memory-mapped I/O (kernel manages caching)
- Copy-on-write (MVCC without locks)
- Ordered keys (enables range queries)
- Crash-proof (no corruption on power loss)
## Monitoring and Metrics
### Connection Statistics
```cpp
struct RelayStats {
std::atomic<uint64_t> totalConnections{0};
std::atomic<uint64_t> activeConnections{0};
std::atomic<uint64_t> eventsReceived{0};
std::atomic<uint64_t> eventsSent{0};
std::atomic<uint64_t> bytesReceived{0};
std::atomic<uint64_t> bytesSent{0};
void recordConnection() {
totalConnections.fetch_add(1, std::memory_order_relaxed);
activeConnections.fetch_add(1, std::memory_order_relaxed);
}
void recordDisconnection() {
activeConnections.fetch_sub(1, std::memory_order_relaxed);
}
void recordEventReceived(size_t bytes) {
eventsReceived.fetch_add(1, std::memory_order_relaxed);
bytesReceived.fetch_add(bytes, std::memory_order_relaxed);
}
};
```
**Atomic operations:** Lock-free updates from multiple threads
### Performance Metrics
```cpp
struct PerformanceMetrics {
// Latency histograms
Histogram eventIngestionLatency;
Histogram subscriptionQueryLatency;
Histogram eventBroadcastLatency;
// Thread pool queue depths
std::atomic<size_t> ingesterQueueDepth{0};
std::atomic<size_t> writerQueueDepth{0};
std::atomic<size_t> reqWorkerQueueDepth{0};
void recordIngestion(std::chrono::microseconds duration) {
eventIngestionLatency.record(duration.count());
}
};
```
## Configuration
### relay.conf Example
```ini
[relay]
bind = 0.0.0.0
port = 8080
maxConnections = 10000
maxMessageSize = 16777216 # 16 MB
[ingester]
threads = 3
queueSize = 10000
[writer]
threads = 1
queueSize = 1000
batchSize = 100
[reqWorker]
threads = 3
queueSize = 10000
[db]
path = /var/lib/strfry/events.lmdb
maxSizeGB = 100
```
## Deployment Considerations
### System Limits
```bash
# Increase file descriptor limit
ulimit -n 65536
# Increase maximum socket connections
sysctl -w net.core.somaxconn=4096
# TCP tuning
sysctl -w net.ipv4.tcp_fin_timeout=15
sysctl -w net.ipv4.tcp_tw_reuse=1
```
### Memory Requirements
**Per connection:**
- ConnectionState: ~1 KB
- WebSocket buffers: ~32 KB (16 KB send + 16 KB receive)
- Compression state: ~400 KB (200 KB deflate + 200 KB inflate)
**Total:** ~433 KB per connection
**For 10,000 connections:** ~4.3 GB
### CPU Requirements
**Single-core can handle:**
- 1000 concurrent connections
- 10,000 events/sec ingestion
- 100,000 events/sec broadcast (cached)
**Recommended:**
- 8+ cores for 10,000 connections
- 16+ cores for 50,000 connections
## Summary
**Key architectural patterns:**
1. **Single-threaded I/O:** epoll handles all connections in one thread
2. **Specialized thread pools:** Different operations use dedicated threads
3. **Deterministic assignment:** Connection ID determines thread assignment
4. **Move semantics:** Zero-copy message passing
5. **Event batching:** Serialize once, send to many
6. **Pre-allocated buffers:** Reuse memory per connection
7. **Bloom filters:** Fast duplicate detection
8. **LMDB:** Memory-mapped database for zero-copy reads
**Performance characteristics:**
- **50,000+ concurrent connections** per server
- **100,000+ events/sec** throughput
- **Sub-millisecond** latency for broadcasts
- **10 GB+ event database** with fast queries
**When to use strfry patterns:**
- Need maximum performance (trading complexity)
- Have C++ expertise on team
- Running large public relay (thousands of users)
- Want minimal memory footprint
- Need to scale to 50K+ connections
**Trade-offs:**
- **Complexity:** More complex than Go/Rust implementations
- **Portability:** Linux-specific (epoll, LMDB)
- **Development speed:** Slower iteration than higher-level languages
**Further reading:**
- strfry repository: https://github.com/hoytech/strfry
- uWebSockets: https://github.com/uNetworking/uWebSockets
- LMDB: http://www.lmdb.tech/doc/
- epoll: https://man7.org/linux/man-pages/man7/epoll.7.html