- Introduced CLAUDE.md to provide guidance for working with the Claude Code repository, including project overview, build commands, testing guidelines, and performance considerations. - Added INDEX.md for a structured overview of the strfry WebSocket implementation analysis, detailing document contents and usage. - Created SKILL.md for the nostr-websocket skill, covering WebSocket protocol fundamentals, connection management, and performance optimization techniques. - Included multiple reference documents for Go, C++, and Rust implementations of WebSocket patterns, enhancing the knowledge base for developers. - Updated deployment and build documentation to reflect new multi-platform capabilities and pure Go build processes. - Bumped version to reflect the addition of extensive documentation and resources for developers working with Nostr relays and WebSocket connections.
978 lines
26 KiB
Markdown
978 lines
26 KiB
Markdown
---
|
|
name: nostr-websocket
|
|
description: This skill should be used when implementing, debugging, or discussing WebSocket connections for Nostr relays. Provides comprehensive knowledge of RFC 6455 WebSocket protocol, production-ready implementation patterns in Go (khatru), C++ (strfry), and Rust (nostr-rs-relay), including connection lifecycle, message framing, subscription management, and performance optimization techniques specific to Nostr relay operations.
|
|
---
|
|
|
|
# Nostr WebSocket Programming
|
|
|
|
## Overview
|
|
|
|
Implement robust, high-performance WebSocket connections for Nostr relays following RFC 6455 specifications and battle-tested production patterns. This skill provides comprehensive guidance on WebSocket protocol fundamentals, connection management, message handling, and language-specific implementation strategies using proven codebases.
|
|
|
|
## Core WebSocket Protocol (RFC 6455)
|
|
|
|
### Connection Upgrade Handshake
|
|
|
|
The WebSocket connection begins with an HTTP upgrade request:
|
|
|
|
**Client Request Headers:**
|
|
- `Upgrade: websocket` - Required
|
|
- `Connection: Upgrade` - Required
|
|
- `Sec-WebSocket-Key` - 16-byte random value, base64-encoded
|
|
- `Sec-WebSocket-Version: 13` - Required
|
|
- `Origin` - Required for browser clients (security)
|
|
|
|
**Server Response (HTTP 101):**
|
|
- `HTTP/1.1 101 Switching Protocols`
|
|
- `Upgrade: websocket`
|
|
- `Connection: Upgrade`
|
|
- `Sec-WebSocket-Accept` - SHA-1(client_key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"), base64-encoded
|
|
|
|
**Security validation:** Always verify the `Sec-WebSocket-Accept` value matches expected computation. Reject connections with missing or incorrect values.
|
|
|
|
### Frame Structure
|
|
|
|
WebSocket frames use binary encoding with variable-length fields:
|
|
|
|
**Header (minimum 2 bytes):**
|
|
- **FIN bit** (1 bit) - Final fragment indicator
|
|
- **RSV1-3** (3 bits) - Reserved for extensions (must be 0)
|
|
- **Opcode** (4 bits) - Frame type identifier
|
|
- **MASK bit** (1 bit) - Payload masking indicator
|
|
- **Payload length** (7, 7+16, or 7+64 bits) - Variable encoding
|
|
|
|
**Payload length encoding:**
|
|
- 0-125: Direct 7-bit value
|
|
- 126: Next 16 bits contain length
|
|
- 127: Next 64 bits contain length
|
|
|
|
### Frame Opcodes
|
|
|
|
**Data Frames:**
|
|
- `0x0` - Continuation frame
|
|
- `0x1` - Text frame (UTF-8)
|
|
- `0x2` - Binary frame
|
|
|
|
**Control Frames:**
|
|
- `0x8` - Connection close
|
|
- `0x9` - Ping
|
|
- `0xA` - Pong
|
|
|
|
**Control frame constraints:**
|
|
- Maximum 125-byte payload
|
|
- Cannot be fragmented
|
|
- Must be processed immediately
|
|
|
|
### Masking Requirements
|
|
|
|
**Critical security requirement:**
|
|
- Client-to-server frames MUST be masked
|
|
- Server-to-client frames MUST NOT be masked
|
|
- Masking uses XOR with 4-byte random key
|
|
- Prevents cache poisoning and intermediary attacks
|
|
|
|
**Masking algorithm:**
|
|
```
|
|
transformed[i] = original[i] XOR masking_key[i MOD 4]
|
|
```
|
|
|
|
### Ping/Pong Keep-Alive
|
|
|
|
**Purpose:** Detect broken connections and maintain NAT traversal
|
|
|
|
**Pattern:**
|
|
1. Either endpoint sends Ping (0x9) with optional payload
|
|
2. Recipient responds with Pong (0xA) containing identical payload
|
|
3. Implement timeouts to detect unresponsive connections
|
|
|
|
**Nostr relay recommendations:**
|
|
- Send pings every 30-60 seconds
|
|
- Timeout after 60-120 seconds without pong response
|
|
- Close connections exceeding timeout threshold
|
|
|
|
### Close Handshake
|
|
|
|
**Initiation:** Either peer sends Close frame (0x8)
|
|
|
|
**Close frame structure:**
|
|
- Optional 2-byte status code
|
|
- Optional UTF-8 reason string
|
|
|
|
**Common status codes:**
|
|
- `1000` - Normal closure
|
|
- `1001` - Going away (server shutdown/navigation)
|
|
- `1002` - Protocol error
|
|
- `1003` - Unsupported data type
|
|
- `1006` - Abnormal closure (no close frame)
|
|
- `1011` - Server error
|
|
|
|
**Proper shutdown sequence:**
|
|
1. Initiator sends Close frame
|
|
2. Recipient responds with Close frame
|
|
3. Both close TCP connection
|
|
|
|
## Nostr Relay WebSocket Architecture
|
|
|
|
### Message Flow Overview
|
|
|
|
```
|
|
Client Relay
|
|
| |
|
|
|--- HTTP Upgrade ------->|
|
|
|<-- 101 Switching -------|
|
|
| |
|
|
|--- ["EVENT", {...}] --->| (Validate, store, broadcast)
|
|
|<-- ["OK", id, ...] -----|
|
|
| |
|
|
|--- ["REQ", id, {...}]-->| (Query + subscribe)
|
|
|<-- ["EVENT", id, {...}]-| (Stored events)
|
|
|<-- ["EOSE", id] --------| (End of stored)
|
|
|<-- ["EVENT", id, {...}]-| (Real-time events)
|
|
| |
|
|
|--- ["CLOSE", id] ------>| (Unsubscribe)
|
|
| |
|
|
|--- Close Frame -------->|
|
|
|<-- Close Frame ---------|
|
|
```
|
|
|
|
### Critical Concurrency Considerations
|
|
|
|
**Write concurrency:** WebSocket libraries panic/error on concurrent writes. Always protect writes with:
|
|
- Mutex locks (Go, C++)
|
|
- Single-writer goroutine/thread pattern
|
|
- Message queue with dedicated sender
|
|
|
|
**Read concurrency:** Concurrent reads generally allowed but not useful - implement single reader loop per connection.
|
|
|
|
**Subscription management:** Concurrent access to subscription maps requires synchronization or lock-free data structures.
|
|
|
|
## Language-Specific Implementation Patterns
|
|
|
|
### Go Implementation (khatru-style)
|
|
|
|
**Recommended library:** `github.com/fasthttp/websocket`
|
|
|
|
**Connection structure:**
|
|
```go
|
|
type WebSocket struct {
|
|
conn *websocket.Conn
|
|
mutex sync.Mutex // Protects writes
|
|
|
|
Request *http.Request // Original HTTP request
|
|
Context context.Context // Cancellation context
|
|
cancel context.CancelFunc
|
|
|
|
// NIP-42 authentication
|
|
Challenge string
|
|
AuthedPublicKey string
|
|
|
|
// Concurrent session management
|
|
negentropySessions *xsync.MapOf[string, *NegentropySession]
|
|
}
|
|
|
|
// Thread-safe write
|
|
func (ws *WebSocket) WriteJSON(v any) error {
|
|
ws.mutex.Lock()
|
|
defer ws.mutex.Unlock()
|
|
return ws.conn.WriteJSON(v)
|
|
}
|
|
```
|
|
|
|
**Lifecycle pattern (dual goroutines):**
|
|
```go
|
|
// Read goroutine
|
|
go func() {
|
|
defer cleanup()
|
|
|
|
ws.conn.SetReadLimit(maxMessageSize)
|
|
ws.conn.SetReadDeadline(time.Now().Add(pongWait))
|
|
ws.conn.SetPongHandler(func(string) error {
|
|
ws.conn.SetReadDeadline(time.Now().Add(pongWait))
|
|
return nil
|
|
})
|
|
|
|
for {
|
|
typ, msg, err := ws.conn.ReadMessage()
|
|
if err != nil {
|
|
return // Connection closed
|
|
}
|
|
|
|
if typ == websocket.PingMessage {
|
|
ws.WriteMessage(websocket.PongMessage, nil)
|
|
continue
|
|
}
|
|
|
|
// Parse and handle message in separate goroutine
|
|
go handleMessage(msg)
|
|
}
|
|
}()
|
|
|
|
// Write/ping goroutine
|
|
go func() {
|
|
defer cleanup()
|
|
ticker := time.NewTicker(pingPeriod)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
if err := ws.WriteMessage(websocket.PingMessage, nil); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
```
|
|
|
|
**Key patterns:**
|
|
- **Mutex-protected writes** - Prevent concurrent write panics
|
|
- **Context-based lifecycle** - Clean cancellation hierarchy
|
|
- **Swap-delete for subscriptions** - O(1) removal from listener arrays
|
|
- **Zero-copy string conversion** - `unsafe.String()` for message parsing
|
|
- **Goroutine-per-message** - Sequential parsing, concurrent handling
|
|
- **Hook-based extensibility** - Plugin architecture without core modifications
|
|
|
|
**Configuration constants:**
|
|
```go
|
|
WriteWait: 10 * time.Second // Write timeout
|
|
PongWait: 60 * time.Second // Pong timeout
|
|
PingPeriod: 30 * time.Second // Ping interval (< PongWait)
|
|
MaxMessageSize: 512000 // 512 KB limit
|
|
```
|
|
|
|
**Subscription management:**
|
|
```go
|
|
type listenerSpec struct {
|
|
id string
|
|
cancel context.CancelCauseFunc
|
|
index int
|
|
subrelay *Relay
|
|
}
|
|
|
|
// Efficient removal with swap-delete
|
|
func (rl *Relay) removeListenerId(ws *WebSocket, id string) {
|
|
rl.clientsMutex.Lock()
|
|
defer rl.clientsMutex.Unlock()
|
|
|
|
if specs, ok := rl.clients[ws]; ok {
|
|
for i := len(specs) - 1; i >= 0; i-- {
|
|
if specs[i].id == id {
|
|
specs[i].cancel(ErrSubscriptionClosedByClient)
|
|
specs[i] = specs[len(specs)-1]
|
|
specs = specs[:len(specs)-1]
|
|
rl.clients[ws] = specs
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
For detailed khatru implementation examples, see [references/khatru_implementation.md](references/khatru_implementation.md).
|
|
|
|
### C++ Implementation (strfry-style)
|
|
|
|
**Recommended library:** Custom fork of `uWebSockets` with epoll
|
|
|
|
**Architecture highlights:**
|
|
- Single-threaded I/O using epoll for connection multiplexing
|
|
- Thread pool architecture: 6 specialized pools (WebSocket, Ingester, Writer, ReqWorker, ReqMonitor, Negentropy)
|
|
- "Shared nothing" message-passing design eliminates lock contention
|
|
- Deterministic thread assignment: `connId % numThreads`
|
|
|
|
**Connection structure:**
|
|
```cpp
|
|
struct ConnectionState {
|
|
uint64_t connId;
|
|
std::string remoteAddr;
|
|
flat_str subId; // Subscription ID
|
|
std::shared_ptr<Subscription> sub;
|
|
PerMessageDeflate pmd; // Compression state
|
|
uint64_t latestEventSent = 0;
|
|
|
|
// Message parsing state
|
|
secp256k1_context *secpCtx;
|
|
std::string parseBuffer;
|
|
};
|
|
```
|
|
|
|
**Message handling pattern:**
|
|
```cpp
|
|
// WebSocket message callback
|
|
ws->onMessage([=](std::string_view msg, uWS::OpCode opCode) {
|
|
// Reuse buffer to avoid allocations
|
|
state->parseBuffer.assign(msg.data(), msg.size());
|
|
|
|
try {
|
|
auto json = nlohmann::json::parse(state->parseBuffer);
|
|
auto cmdStr = json[0].get<std::string>();
|
|
|
|
if (cmdStr == "EVENT") {
|
|
// Send to Ingester thread pool
|
|
auto packed = MsgIngester::Message(connId, std::move(json));
|
|
tpIngester->dispatchToThread(connId, std::move(packed));
|
|
}
|
|
else if (cmdStr == "REQ") {
|
|
// Send to ReqWorker thread pool
|
|
auto packed = MsgReq::Message(connId, std::move(json));
|
|
tpReqWorker->dispatchToThread(connId, std::move(packed));
|
|
}
|
|
} catch (std::exception &e) {
|
|
sendNotice("Error: " + std::string(e.what()));
|
|
}
|
|
});
|
|
```
|
|
|
|
**Critical performance optimizations:**
|
|
|
|
1. **Event batching** - Serialize event JSON once, reuse for thousands of subscribers:
|
|
```cpp
|
|
// Single serialization
|
|
std::string eventJson = event.toJson();
|
|
|
|
// Broadcast to all matching subscriptions
|
|
for (auto &[connId, sub] : activeSubscriptions) {
|
|
if (sub->matches(event)) {
|
|
sendToConnection(connId, eventJson); // Reuse serialized JSON
|
|
}
|
|
}
|
|
```
|
|
|
|
2. **Move semantics** - Zero-copy message passing:
|
|
```cpp
|
|
tpIngester->dispatchToThread(connId, std::move(message));
|
|
```
|
|
|
|
3. **Pre-allocated buffers** - Single reusable buffer per connection:
|
|
```cpp
|
|
state->parseBuffer.assign(msg.data(), msg.size());
|
|
```
|
|
|
|
4. **std::variant dispatch** - Type-safe without virtual function overhead:
|
|
```cpp
|
|
std::variant<MsgReq, MsgIngester, MsgWriter> message;
|
|
std::visit([](auto&& msg) { msg.handle(); }, message);
|
|
```
|
|
|
|
For detailed strfry implementation examples, see [references/strfry_implementation.md](references/strfry_implementation.md).
|
|
|
|
### Rust Implementation (nostr-rs-relay-style)
|
|
|
|
**Recommended libraries:**
|
|
- `tokio-tungstenite 0.17` - Async WebSocket support
|
|
- `tokio 1.x` - Async runtime
|
|
- `serde_json` - Message parsing
|
|
|
|
**WebSocket configuration:**
|
|
```rust
|
|
let config = WebSocketConfig {
|
|
max_send_queue: Some(1024),
|
|
max_message_size: settings.limits.max_ws_message_bytes,
|
|
max_frame_size: settings.limits.max_ws_frame_bytes,
|
|
..Default::default()
|
|
};
|
|
|
|
let ws_stream = WebSocketStream::from_raw_socket(
|
|
upgraded,
|
|
Role::Server,
|
|
Some(config),
|
|
).await;
|
|
```
|
|
|
|
**Connection state:**
|
|
```rust
|
|
pub struct ClientConn {
|
|
client_ip_addr: String,
|
|
client_id: Uuid,
|
|
subscriptions: HashMap<String, Subscription>,
|
|
max_subs: usize,
|
|
auth: Nip42AuthState,
|
|
}
|
|
|
|
pub enum Nip42AuthState {
|
|
NoAuth,
|
|
Challenge(String),
|
|
AuthPubkey(String),
|
|
}
|
|
```
|
|
|
|
**Async message loop with tokio::select!:**
|
|
```rust
|
|
async fn nostr_server(
|
|
repo: Arc<dyn NostrRepo>,
|
|
mut ws_stream: WebSocketStream<Upgraded>,
|
|
broadcast: Sender<Event>,
|
|
mut shutdown: Receiver<()>,
|
|
) {
|
|
let mut conn = ClientConn::new(client_ip);
|
|
let mut bcast_rx = broadcast.subscribe();
|
|
let mut ping_interval = tokio::time::interval(Duration::from_secs(300));
|
|
|
|
loop {
|
|
tokio::select! {
|
|
// Handle shutdown
|
|
_ = shutdown.recv() => { break; }
|
|
|
|
// Send periodic pings
|
|
_ = ping_interval.tick() => {
|
|
ws_stream.send(Message::Ping(Vec::new())).await.ok();
|
|
}
|
|
|
|
// Handle broadcast events (real-time)
|
|
Ok(event) = bcast_rx.recv() => {
|
|
for (id, sub) in conn.subscriptions() {
|
|
if sub.interested_in_event(&event) {
|
|
let msg = format!("[\"EVENT\",\"{}\",{}]", id,
|
|
serde_json::to_string(&event)?);
|
|
ws_stream.send(Message::Text(msg)).await.ok();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle incoming client messages
|
|
Some(result) = ws_stream.next() => {
|
|
match result {
|
|
Ok(Message::Text(msg)) => {
|
|
handle_nostr_message(&msg, &mut conn).await;
|
|
}
|
|
Ok(Message::Binary(_)) => {
|
|
send_notice("binary messages not accepted").await;
|
|
}
|
|
Ok(Message::Ping(_) | Message::Pong(_)) => {
|
|
continue; // Auto-handled by tungstenite
|
|
}
|
|
Ok(Message::Close(_)) | Err(_) => {
|
|
break;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Subscription filtering:**
|
|
```rust
|
|
pub struct ReqFilter {
|
|
pub ids: Option<Vec<String>>,
|
|
pub kinds: Option<Vec<u64>>,
|
|
pub since: Option<u64>,
|
|
pub until: Option<u64>,
|
|
pub authors: Option<Vec<String>>,
|
|
pub limit: Option<u64>,
|
|
pub tags: Option<HashMap<char, HashSet<String>>>,
|
|
}
|
|
|
|
impl ReqFilter {
|
|
pub fn interested_in_event(&self, event: &Event) -> bool {
|
|
self.ids_match(event)
|
|
&& self.since.map_or(true, |t| event.created_at >= t)
|
|
&& self.until.map_or(true, |t| event.created_at <= t)
|
|
&& self.kind_match(event.kind)
|
|
&& self.authors_match(event)
|
|
&& self.tag_match(event)
|
|
}
|
|
|
|
fn ids_match(&self, event: &Event) -> bool {
|
|
self.ids.as_ref()
|
|
.map_or(true, |ids| ids.iter().any(|id| event.id.starts_with(id)))
|
|
}
|
|
}
|
|
```
|
|
|
|
**Error handling:**
|
|
```rust
|
|
match ws_stream.next().await {
|
|
Some(Ok(Message::Text(msg))) => { /* handle */ }
|
|
|
|
Some(Err(WsError::Capacity(MessageTooLong{size, max_size}))) => {
|
|
send_notice(&format!("message too large ({} > {})", size, max_size)).await;
|
|
continue;
|
|
}
|
|
|
|
None | Some(Ok(Message::Close(_))) => {
|
|
info!("client closed connection");
|
|
break;
|
|
}
|
|
|
|
Some(Err(WsError::Io(e))) => {
|
|
warn!("IO error: {:?}", e);
|
|
break;
|
|
}
|
|
|
|
_ => { break; }
|
|
}
|
|
```
|
|
|
|
For detailed Rust implementation examples, see [references/rust_implementation.md](references/rust_implementation.md).
|
|
|
|
## Common Implementation Patterns
|
|
|
|
### Pattern 1: Dual Goroutine/Task Architecture
|
|
|
|
**Purpose:** Separate read and write concerns, enable ping/pong management
|
|
|
|
**Structure:**
|
|
- **Reader goroutine/task:** Blocks on `ReadMessage()`, handles incoming frames
|
|
- **Writer goroutine/task:** Sends periodic pings, processes outgoing message queue
|
|
|
|
**Benefits:**
|
|
- Natural separation of concerns
|
|
- Ping timer doesn't block message processing
|
|
- Clean shutdown coordination via context/channels
|
|
|
|
### Pattern 2: Subscription Lifecycle
|
|
|
|
**Create subscription (REQ):**
|
|
1. Parse filter from client message
|
|
2. Query database for matching stored events
|
|
3. Send stored events to client
|
|
4. Send EOSE (End of Stored Events)
|
|
5. Add subscription to active listeners for real-time events
|
|
|
|
**Handle real-time event:**
|
|
1. Check all active subscriptions
|
|
2. For each matching subscription:
|
|
- Apply filter matching logic
|
|
- Send EVENT message to client
|
|
3. Track broadcast count for monitoring
|
|
|
|
**Close subscription (CLOSE):**
|
|
1. Find subscription by ID
|
|
2. Cancel subscription context
|
|
3. Remove from active listeners
|
|
4. Clean up resources
|
|
|
|
### Pattern 3: Write Serialization
|
|
|
|
**Problem:** Concurrent writes cause panics/errors in WebSocket libraries
|
|
|
|
**Solutions:**
|
|
|
|
**Mutex approach (Go, C++):**
|
|
```go
|
|
func (ws *WebSocket) WriteJSON(v any) error {
|
|
ws.mutex.Lock()
|
|
defer ws.mutex.Unlock()
|
|
return ws.conn.WriteJSON(v)
|
|
}
|
|
```
|
|
|
|
**Single-writer goroutine (Alternative):**
|
|
```go
|
|
type writeMsg struct {
|
|
data []byte
|
|
done chan error
|
|
}
|
|
|
|
go func() {
|
|
for msg := range writeChan {
|
|
msg.done <- ws.conn.WriteMessage(websocket.TextMessage, msg.data)
|
|
}
|
|
}()
|
|
```
|
|
|
|
### Pattern 4: Connection Cleanup
|
|
|
|
**Essential cleanup steps:**
|
|
1. Cancel all subscription contexts
|
|
2. Stop ping ticker/interval
|
|
3. Remove connection from active clients map
|
|
4. Close WebSocket connection
|
|
5. Close TCP connection
|
|
6. Log connection statistics
|
|
|
|
**Go cleanup function:**
|
|
```go
|
|
kill := func() {
|
|
// Cancel contexts
|
|
cancel()
|
|
ws.cancel()
|
|
|
|
// Stop timers
|
|
ticker.Stop()
|
|
|
|
// Remove from tracking
|
|
rl.removeClientAndListeners(ws)
|
|
|
|
// Close connection
|
|
ws.conn.Close()
|
|
|
|
// Trigger hooks
|
|
for _, ondisconnect := range rl.OnDisconnect {
|
|
ondisconnect(ctx)
|
|
}
|
|
}
|
|
defer kill()
|
|
```
|
|
|
|
### Pattern 5: Event Broadcasting Optimization
|
|
|
|
**Naive approach (inefficient):**
|
|
```go
|
|
// DON'T: Serialize for each subscriber
|
|
for _, listener := range listeners {
|
|
if listener.filter.Matches(event) {
|
|
json := serializeEvent(event) // Repeated work!
|
|
listener.ws.WriteJSON(json)
|
|
}
|
|
}
|
|
```
|
|
|
|
**Optimized approach:**
|
|
```go
|
|
// DO: Serialize once, reuse for all subscribers
|
|
eventJSON, err := json.Marshal(event)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
for _, listener := range listeners {
|
|
if listener.filter.Matches(event) {
|
|
listener.ws.WriteMessage(websocket.TextMessage, eventJSON)
|
|
}
|
|
}
|
|
```
|
|
|
|
**Savings:** For 1000 subscribers, reduces 1000 JSON serializations to 1.
|
|
|
|
## Security Considerations
|
|
|
|
### Origin Validation
|
|
|
|
Always validate the `Origin` header for browser-based clients:
|
|
|
|
```go
|
|
upgrader := websocket.Upgrader{
|
|
CheckOrigin: func(r *http.Request) bool {
|
|
origin := r.Header.Get("Origin")
|
|
return isAllowedOrigin(origin) // Implement allowlist
|
|
},
|
|
}
|
|
```
|
|
|
|
**Default behavior:** Most libraries reject all cross-origin connections. Override with caution.
|
|
|
|
### Rate Limiting
|
|
|
|
Implement rate limits for:
|
|
- Connection establishment (per IP)
|
|
- Message throughput (per connection)
|
|
- Subscription creation (per connection)
|
|
- Event publication (per connection, per pubkey)
|
|
|
|
```go
|
|
// Example: Connection rate limiting
|
|
type rateLimiter struct {
|
|
connections map[string]*rate.Limiter
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func (rl *Relay) checkRateLimit(ip string) bool {
|
|
limiter := rl.rateLimiter.getLimiter(ip)
|
|
return limiter.Allow()
|
|
}
|
|
```
|
|
|
|
### Message Size Limits
|
|
|
|
Configure limits to prevent memory exhaustion:
|
|
|
|
```go
|
|
ws.conn.SetReadLimit(maxMessageSize) // e.g., 512 KB
|
|
```
|
|
|
|
```rust
|
|
max_message_size: Some(512_000),
|
|
max_frame_size: Some(16_384),
|
|
```
|
|
|
|
### Subscription Limits
|
|
|
|
Prevent resource exhaustion:
|
|
- Max subscriptions per connection (typically 10-20)
|
|
- Max subscription ID length (prevent hash collision attacks)
|
|
- Require specific filters (prevent full database scans)
|
|
|
|
```rust
|
|
const MAX_SUBSCRIPTION_ID_LEN: usize = 256;
|
|
const MAX_SUBS_PER_CLIENT: usize = 20;
|
|
|
|
if subscriptions.len() >= MAX_SUBS_PER_CLIENT {
|
|
return Err(Error::SubMaxExceededError);
|
|
}
|
|
```
|
|
|
|
### Authentication (NIP-42)
|
|
|
|
Implement challenge-response authentication:
|
|
|
|
1. **Generate challenge on connect:**
|
|
```go
|
|
challenge := make([]byte, 8)
|
|
rand.Read(challenge)
|
|
ws.Challenge = hex.EncodeToString(challenge)
|
|
```
|
|
|
|
2. **Send AUTH challenge when required:**
|
|
```json
|
|
["AUTH", "<challenge>"]
|
|
```
|
|
|
|
3. **Validate AUTH event:**
|
|
```go
|
|
func validateAuthEvent(event *Event, challenge, relayURL string) bool {
|
|
// Check kind 22242
|
|
if event.Kind != 22242 { return false }
|
|
|
|
// Check challenge in tags
|
|
if !hasTag(event, "challenge", challenge) { return false }
|
|
|
|
// Check relay URL
|
|
if !hasTag(event, "relay", relayURL) { return false }
|
|
|
|
// Check timestamp (within 10 minutes)
|
|
if abs(time.Now().Unix() - event.CreatedAt) > 600 { return false }
|
|
|
|
// Verify signature
|
|
return event.CheckSignature()
|
|
}
|
|
```
|
|
|
|
## Performance Optimization Techniques
|
|
|
|
### 1. Connection Pooling
|
|
|
|
Reuse connections for database queries:
|
|
```go
|
|
db, _ := sql.Open("postgres", dsn)
|
|
db.SetMaxOpenConns(25)
|
|
db.SetMaxIdleConns(5)
|
|
db.SetConnMaxLifetime(5 * time.Minute)
|
|
```
|
|
|
|
### 2. Event Caching
|
|
|
|
Cache frequently accessed events:
|
|
```go
|
|
type EventCache struct {
|
|
cache *lru.Cache
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
func (ec *EventCache) Get(id string) (*Event, bool) {
|
|
ec.mu.RLock()
|
|
defer ec.mu.RUnlock()
|
|
if val, ok := ec.cache.Get(id); ok {
|
|
return val.(*Event), true
|
|
}
|
|
return nil, false
|
|
}
|
|
```
|
|
|
|
### 3. Batch Database Queries
|
|
|
|
Execute queries concurrently for multi-filter subscriptions:
|
|
```go
|
|
var wg sync.WaitGroup
|
|
for _, filter := range filters {
|
|
wg.Add(1)
|
|
go func(f Filter) {
|
|
defer wg.Done()
|
|
events := queryDatabase(f)
|
|
sendEvents(events)
|
|
}(filter)
|
|
}
|
|
wg.Wait()
|
|
sendEOSE()
|
|
```
|
|
|
|
### 4. Compression (permessage-deflate)
|
|
|
|
Enable WebSocket compression for text frames:
|
|
```go
|
|
upgrader := websocket.Upgrader{
|
|
EnableCompression: true,
|
|
}
|
|
```
|
|
|
|
**Typical savings:** 60-80% bandwidth reduction for JSON messages
|
|
|
|
**Trade-off:** Increased CPU usage (usually worthwhile)
|
|
|
|
### 5. Monitoring and Metrics
|
|
|
|
Track key performance indicators:
|
|
- Connections (active, total, per IP)
|
|
- Messages (received, sent, per type)
|
|
- Events (stored, broadcast, per second)
|
|
- Subscriptions (active, per connection)
|
|
- Query latency (p50, p95, p99)
|
|
- Database pool utilization
|
|
|
|
```go
|
|
// Prometheus-style metrics
|
|
type Metrics struct {
|
|
Connections prometheus.Gauge
|
|
MessagesRecv prometheus.Counter
|
|
MessagesSent prometheus.Counter
|
|
EventsStored prometheus.Counter
|
|
QueryDuration prometheus.Histogram
|
|
}
|
|
```
|
|
|
|
## Testing WebSocket Implementations
|
|
|
|
### Unit Testing
|
|
|
|
Test individual components in isolation:
|
|
|
|
```go
|
|
func TestFilterMatching(t *testing.T) {
|
|
filter := Filter{
|
|
Kinds: []int{1, 3},
|
|
Authors: []string{"abc123"},
|
|
}
|
|
|
|
event := &Event{
|
|
Kind: 1,
|
|
PubKey: "abc123",
|
|
}
|
|
|
|
if !filter.Matches(event) {
|
|
t.Error("Expected filter to match event")
|
|
}
|
|
}
|
|
```
|
|
|
|
### Integration Testing
|
|
|
|
Test WebSocket connection handling:
|
|
|
|
```go
|
|
func TestWebSocketConnection(t *testing.T) {
|
|
// Start test server
|
|
server := startTestRelay(t)
|
|
defer server.Close()
|
|
|
|
// Connect client
|
|
ws, _, err := websocket.DefaultDialer.Dial(server.URL, nil)
|
|
if err != nil {
|
|
t.Fatalf("Failed to connect: %v", err)
|
|
}
|
|
defer ws.Close()
|
|
|
|
// Send REQ
|
|
req := `["REQ","test",{"kinds":[1]}]`
|
|
if err := ws.WriteMessage(websocket.TextMessage, []byte(req)); err != nil {
|
|
t.Fatalf("Failed to send REQ: %v", err)
|
|
}
|
|
|
|
// Read EOSE
|
|
_, msg, err := ws.ReadMessage()
|
|
if err != nil {
|
|
t.Fatalf("Failed to read message: %v", err)
|
|
}
|
|
|
|
if !strings.Contains(string(msg), "EOSE") {
|
|
t.Errorf("Expected EOSE, got: %s", msg)
|
|
}
|
|
}
|
|
```
|
|
|
|
### Load Testing
|
|
|
|
Use tools like `websocat` or custom scripts:
|
|
|
|
```bash
|
|
# Connect 1000 concurrent clients
|
|
for i in {1..1000}; do
|
|
(websocat "ws://localhost:8080" <<< '["REQ","test",{"kinds":[1]}]' &)
|
|
done
|
|
```
|
|
|
|
Monitor server metrics during load testing:
|
|
- CPU usage
|
|
- Memory consumption
|
|
- Connection count
|
|
- Message throughput
|
|
- Database query rate
|
|
|
|
## Debugging and Troubleshooting
|
|
|
|
### Common Issues
|
|
|
|
**1. Concurrent write panic/error**
|
|
|
|
**Symptom:** `concurrent write to websocket connection` error
|
|
|
|
**Solution:** Ensure all writes protected by mutex or use single-writer pattern
|
|
|
|
**2. Connection timeouts**
|
|
|
|
**Symptom:** Connections close after 60 seconds
|
|
|
|
**Solution:** Implement ping/pong mechanism properly:
|
|
```go
|
|
ws.SetPongHandler(func(string) error {
|
|
ws.SetReadDeadline(time.Now().Add(pongWait))
|
|
return nil
|
|
})
|
|
```
|
|
|
|
**3. Memory leaks**
|
|
|
|
**Symptom:** Memory usage grows over time
|
|
|
|
**Common causes:**
|
|
- Subscriptions not removed on disconnect
|
|
- Event channels not closed
|
|
- Goroutines not terminated
|
|
|
|
**Solution:** Ensure cleanup function called on disconnect
|
|
|
|
**4. Slow subscription queries**
|
|
|
|
**Symptom:** EOSE delayed by seconds
|
|
|
|
**Solution:**
|
|
- Add database indexes on filtered columns
|
|
- Implement query timeouts
|
|
- Consider caching frequently accessed events
|
|
|
|
### Logging Best Practices
|
|
|
|
Log critical events with context:
|
|
|
|
```go
|
|
log.Printf(
|
|
"connection closed: cid=%s ip=%s duration=%v sent=%d recv=%d",
|
|
conn.ID,
|
|
conn.IP,
|
|
time.Since(conn.ConnectedAt),
|
|
conn.EventsSent,
|
|
conn.EventsRecv,
|
|
)
|
|
```
|
|
|
|
Use log levels appropriately:
|
|
- **DEBUG:** Message parsing, filter matching
|
|
- **INFO:** Connection lifecycle, subscription changes
|
|
- **WARN:** Rate limit violations, invalid messages
|
|
- **ERROR:** Database errors, unexpected panics
|
|
|
|
## Resources
|
|
|
|
This skill includes comprehensive reference documentation with production code examples:
|
|
|
|
### references/
|
|
|
|
- **websocket_protocol.md** - Complete RFC 6455 specification details including frame structure, opcodes, masking algorithm, and security considerations
|
|
- **khatru_implementation.md** - Go WebSocket patterns from khatru including connection lifecycle, subscription management, and performance optimizations (3000+ lines)
|
|
- **strfry_implementation.md** - C++ high-performance patterns from strfry including thread pool architecture, message batching, and zero-copy techniques (2000+ lines)
|
|
- **rust_implementation.md** - Rust async patterns from nostr-rs-relay including tokio::select! usage, error handling, and subscription filtering (2000+ lines)
|
|
|
|
Load these references when implementing specific language solutions or troubleshooting complex WebSocket issues. |