From 515165e447182d1e38ec800f07dab72f1e183687 Mon Sep 17 00:00:00 2001 From: jay Date: Tue, 30 Dec 2025 09:51:03 -0500 Subject: [PATCH] Add Nostr Transport Specification --- Nostr-Transport-Specification.md | 1816 ++++++++++++++++++++++++++++++ 1 file changed, 1816 insertions(+) create mode 100644 Nostr-Transport-Specification.md diff --git a/Nostr-Transport-Specification.md b/Nostr-Transport-Specification.md new file mode 100644 index 0000000..8cdf1d8 --- /dev/null +++ b/Nostr-Transport-Specification.md @@ -0,0 +1,1816 @@ +# Nostr Websocket Transport Specification + +## 1. Connection Establishment + +A client connects to a relay using the WebSocket protocol. A relay accepts connections and upgrades HTTP requests to WebSocket. + +### Rule 1.1: WebSocket Protocol + +All communication uses WebSocket connections over `ws://` or `wss://` URLs. + +**Client behavior:** + +``` +connection = create_websocket(relay_url) +wait_for_open_event() +``` + +**Relay behavior:** + +``` +receive HTTP GET with "Upgrade: websocket" header +validate_connection_request() +if accepted: + upgrade_to_websocket() +else: + return HTTP 429 or appropriate error +``` + +### Rule 1.2: URL Normalization + +Clients must normalize relay URLs before connecting. + +**Normalization rules:** + +- Remove trailing slash +- Ensure protocol prefix (`ws://` or `wss://`) +- Convert to lowercase for comparison + +**Example:** + +``` +Input: WSS://Relay.Example.COM/ +Output: wss://relay.example.com + +Input: relay.example.com +Output: wss://relay.example.com +``` + +### Rule 1.3: Connection States + +A connection exists in exactly one state at any time: + +- **DISCONNECTED**: No active connection +- **CONNECTING**: Connection attempt in progress +- **CONNECTED**: WebSocket open and ready +- **CLOSING**: Graceful shutdown initiated + +State transitions: + +``` +DISCONNECTED → (connect) → CONNECTING +CONNECTING → (open_event) → CONNECTED +CONNECTING → (error) → DISCONNECTED +CONNECTED → (disconnect) → CLOSING +CLOSING → (close_complete) → DISCONNECTED +``` + +**Example client code:** + +``` +state = DISCONNECTED +connect(): + state = CONNECTING + ws.connect() + +on_open(): + state = CONNECTED + +on_close(): + state = DISCONNECTED +``` + +### Rule 1.4: Connection Readiness + +Clients must wait for the WebSocket open event before sending messages. Relays must not send messages before the connection is fully established. + +**Client:** + +``` +connection_promise = create_connection() +await connection_promise +send_message(["REQ", ...]) +``` + +**Relay:** + +``` +on_websocket_upgrade(): + create_connection_context() + mark_connection_ready() + execute_connect_hooks() +``` + +### Rule 1.5: CORS Requirements (Relay) + +Relays must implement permissive CORS for non-WebSocket HTTP endpoints: + +``` +Access-Control-Allow-Origin: * +Access-Control-Allow-Methods: HEAD, GET, POST, PUT, PATCH, DELETE +Access-Control-Max-Age: 86400 +``` + +## 2. Message Framing + +### Rule 2.1: Frame Format + +All messages are WebSocket text frames containing UTF-8 encoded JSON arrays. + +**Structure:** + +``` +["MESSAGE_TYPE", argument1, argument2, ...] +``` + +The first element identifies the message type. Remaining elements depend on the message type. + +**Example frame:** + +``` +["EVENT", "subscription-id-123", {"id": "abc...", "kind": 1, ...}] +``` + +### Rule 2.2: Message Size Limits + +Implementations should enforce maximum message size limits. + +**Recommended limit:** 512,000 bytes + +**Behavior when exceeded:** + +- Client: Close connection with error +- Relay: Close connection and remove client + +**Example enforcement:** + +``` +on_message(text): + if length(text) > MAX_MESSAGE_SIZE: + close_connection("message too large") + return + process_message(text) +``` + +### Rule 2.3: Write Safety + +Implementations must prevent concurrent writes to the WebSocket. + +**Pattern:** + +``` +mutex = create_mutex() + +send_message(json_array): + mutex.lock() + websocket.send(JSON.stringify(json_array)) + mutex.unlock() +``` + +**Why this matters:** Concurrent writes to a WebSocket cause panic or corruption in most implementations. + +### Rule 2.4: Message Parsing + +Parse the JSON array and extract the message type from the first element. + +**Process:** + +``` +on_text_frame(raw_text): + try: + json_array = JSON.parse(raw_text) + message_type = json_array[0] + dispatch_by_type(message_type, json_array) + catch parse_error: + send_notice("invalid JSON") +``` + +Unknown message types should trigger a NOTICE but must not terminate the connection. + +**Example:** + +``` +Received: ["UNKNOWN_TYPE", "arg1"] +Action: Send ["NOTICE", "unknown message type"] +Keep connection alive +``` + +## 3. Client-to-Relay Messages + +### Rule 3.1: REQ - Subscribe to Events + +Request events matching filter criteria. + +**Format:** + +```json +["REQ", , , , ...] +``` + +**Subscription ID:** + +- String chosen by client +- Must be unique per connection +- Recommended: alphanumeric, 8-64 characters + +**Example:** + +```json +["REQ", "sub-1", {"kinds": [1], "limit": 10}] +``` + +**Multiple filters example:** + +```json +["REQ", "sub-2", + {"kinds": [0], "authors": ["abc123..."]}, + {"kinds": [1], "#p": ["abc123..."], "limit": 50} +] +``` + +Filters within one REQ are combined with OR logic. + +### Rule 3.2: CLOSE - End Subscription + +Stop receiving events for a subscription. + +**Format:** + +```json +["CLOSE", ] +``` + +**Example:** + +```json +["CLOSE", "sub-1"] +``` + +**Effect:** + +- Relay stops sending events for this subscription +- Relay removes subscription from active set +- Client frees associated resources + +### Rule 3.3: EVENT - Publish Event + +Submit an event to the relay for storage and distribution. + +**Format:** + +```json +["EVENT", ] +``` + +**Example:** + +```json +["EVENT", { + "id": "4376c65d2f232afbe9b882a35baa4f6fe8667c4e684749af565f981833ed6a65", + "pubkey": "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93", + "created_at": 1673347337, + "kind": 1, + "tags": [], + "content": "Hello, relay!", + "sig": "908a15e..." +}] +``` + +**Relay validation:** + +``` +receive_event(event): + validate_event_structure(event) + validate_event_id(event) + validate_event_signature(event) + if valid: + store_event(event) + forward_to_matching_subscriptions(event) + send_ok_accepted(event.id) + else: + send_ok_rejected(event.id, reason) +``` + +### Rule 3.4: AUTH - Authenticate + +Respond to an authentication challenge. + +**Format:** + +```json +["AUTH", ] +``` + +The signed event must be kind 22242 with specific tags (see Authentication section). + +**Example:** + +```json +["AUTH", { + "kind": 22242, + "tags": [ + ["relay", "wss://relay.example.com"], + ["challenge", "abc123..."] + ], + "content": "", + ... +}] +``` + +### Rule 3.5: COUNT - Request Count + +Request a count of events matching filters without retrieving the events. + +**Format:** + +```json +["COUNT", , , , ...] +``` + +**Example:** + +```json +["COUNT", "count-1", {"kinds": [1], "authors": ["abc123..."], "since": 1673347337}] +``` + +## 4. Relay-to-Client Messages + +### Rule 4.1: EVENT - Deliver Event + +Send an event matching an active subscription. + +**Format:** + +```json +["EVENT", , ] +``` + +**Example:** + +```json +["EVENT", "sub-1", { + "id": "4376c65d...", + "pubkey": "6e468422...", + "created_at": 1673347337, + "kind": 1, + "tags": [], + "content": "Hello!", + "sig": "908a15e..." +}] +``` + +**Client processing:** + +``` +on_event_message(sub_id, event): + subscription = find_subscription(sub_id) + if subscription is null: + return // discard unknown subscription + + if already_have_event(event.id): + return // skip duplicate + + if not verify_event_signature(event): + return // skip invalid + + if matches_filters(event, subscription.filters): + deliver_to_application(event) +``` + +### Rule 4.2: EOSE - End of Stored Events + +Signal completion of stored event delivery for a subscription. + +**Format:** + +```json +["EOSE", ] +``` + +**Example:** + +```json +["EOSE", "sub-1"] +``` + +**Message order guarantee:** + +``` +Client: ["REQ", "sub-1", {...}] +Relay: ["EVENT", "sub-1", {...stored event 1...}] +Relay: ["EVENT", "sub-1", {...stored event 2...}] +Relay: ["EOSE", "sub-1"] +Relay: ["EVENT", "sub-1", {...new real-time event...}] +``` + +After EOSE, events for this subscription are real-time only. + +### Rule 4.3: OK - Event Acceptance Response + +Confirm whether a published event was accepted. + +**Format:** + +```json +["OK", , , ] +``` + +**Accepted example:** + +```json +["OK", "4376c65d...", true, ""] +``` + +**Rejected example:** + +```json +["OK", "4376c65d...", false, "invalid: event timestamp too far in future"] +``` + +**Client behavior:** + +``` +pending_publishes = Map + +send_event(event): + promise = create_promise() + pending_publishes.set(event.id, promise) + send(["EVENT", event]) + return promise + +on_ok_message(event_id, accepted, message): + promise = pending_publishes.get(event_id) + if promise: + if accepted: + promise.resolve() + else: + promise.reject(message) + pending_publishes.delete(event_id) +``` + +### Rule 4.4: CLOSED - Subscription Terminated + +Notify client that relay has ended a subscription. + +**Format:** + +```json +["CLOSED", , ] +``` + +**Example:** + +```json +["CLOSED", "sub-1", "rate limited: too many subscriptions"] +``` + +**Client behavior:** + +``` +on_closed_message(sub_id, message): + subscription = find_subscription(sub_id) + if subscription: + subscription.mark_closed() + invoke_close_callback(message) + remove_subscription(sub_id) +``` + +### Rule 4.5: NOTICE - Human-Readable Message + +Send informational or error messages to the client. + +**Format:** + +```json +["NOTICE", ] +``` + +**Example:** + +```json +["NOTICE", "This relay requires payment for writes"] +``` + +NOTICE messages are for human consumption. Clients should log them but must not treat them as protocol errors. + +### Rule 4.6: AUTH - Request Authentication + +Challenge client to authenticate. + +**Format:** + +```json +["AUTH", ] +``` + +**Example:** + +```json +["AUTH", "a1b2c3d4e5f6g7h8"] +``` + +**Relay behavior:** + +``` +on_connect(client): + challenge = generate_random_hex(16) + client.challenge = challenge + send(["AUTH", challenge]) +``` + +See Authentication section for complete flow. + +### Rule 4.7: COUNT - Count Response + +Return count of events matching COUNT request. + +**Format:** + +```json +["COUNT", , {"count": }] +``` + +**Example:** + +```json +["COUNT", "count-1", {"count": 42}] +``` + +## 5. Subscription Lifecycle + +### Rule 5.1: Creating Subscriptions + +A subscription begins when a client sends REQ and ends when either side sends CLOSE or CLOSED. + +**Client lifecycle:** + +``` +subscription_id = generate_unique_id() +subscriptions.set(subscription_id, { + id: subscription_id, + filters: filters, + eosed: false, + closed: false +}) +send(["REQ", subscription_id, ...filters]) +start_eose_timeout(subscription_id) +``` + +**Relay lifecycle:** + +``` +on_req_message(client, sub_id, filters): + subscription = { + id: sub_id, + filters: filters, + client: client + } + active_subscriptions.add(subscription) + + stored_events = query_events(filters) + for event in stored_events: + send_to_client(["EVENT", sub_id, event]) + + send_to_client(["EOSE", sub_id]) + + // Continue sending real-time matching events +``` + +### Rule 5.2: Multiple Subscriptions + +A client may have multiple active subscriptions on one connection. Each must have a unique subscription ID. + +**Example:** + +``` +Client sends: +["REQ", "sub-1", {"kinds": [1], "limit": 10}] +["REQ", "sub-2", {"kinds": [0], "authors": ["abc..."]}] +["REQ", "sub-3", {"kinds": [7], "#e": ["xyz..."]}] + +All three subscriptions active simultaneously. +``` + +### Rule 5.3: Subscription Replacement + +Sending REQ with an existing subscription ID replaces the previous subscription. + +**Behavior:** + +``` +on_req_message(client, sub_id, new_filters): + if subscription_exists(sub_id): + remove_old_subscription(sub_id) + create_new_subscription(sub_id, new_filters) + query_and_send_events(sub_id, new_filters) +``` + +**Example:** + +``` +Client: ["REQ", "sub-1", {"kinds": [1]}] +Relay: [...events...] ["EOSE", "sub-1"] + +Client: ["REQ", "sub-1", {"kinds": [1, 6, 7]}] +Relay: [...new events matching updated filters...] ["EOSE", "sub-1"] +``` + +The old subscription is implicitly closed. + +### Rule 5.4: Closing Subscriptions + +**Client-initiated:** + +``` +close_subscription(sub_id): + send(["CLOSE", sub_id]) + remove_subscription(sub_id) +``` + +**Relay-initiated:** + +``` +close_subscription(client, sub_id, reason): + send_to_client(["CLOSED", sub_id, reason]) + remove_subscription(sub_id) +``` + +**Example reasons for relay-initiated closure:** + +- Rate limit exceeded +- Filter policy violation +- Authentication required +- Relay shutting down + +### Rule 5.5: Subscription State Tracking + +Clients should track whether EOSE has been received for each subscription. + +**State:** + +``` +subscription = { + id: "sub-1", + filters: [{...}], + eosed: false, // set to true when EOSE received + closed: false // set to true when CLOSE or CLOSED sent +} +``` + +This allows clients to distinguish stored events from real-time events. + +## 6. Event Publishing + +### Rule 6.1: Publishing Flow + +**Client:** + +``` +publish(event): + promise = create_promise() + timeout = set_timeout(PUBLISH_TIMEOUT, () => { + promise.reject("timeout") + }) + + pending_publishes.set(event.id, {promise, timeout}) + send(["EVENT", event]) + + return promise +``` + +**Relay:** + +``` +on_event_message(client, event): + validation_result = validate_and_store(event) + + if validation_result.accepted: + forward_to_subscriptions(event) + send_to_client(["OK", event.id, true, ""]) + else: + send_to_client(["OK", event.id, false, validation_result.reason]) +``` + +### Rule 6.2: Publish Timeout + +Clients should implement timeouts for event publishing. + +**Recommended timeout:** 4000-5000 milliseconds + +**Behavior:** + +``` +PUBLISH_TIMEOUT = 4400 // milliseconds + +publish_with_timeout(event): + promise = publish(event) + timeout_promise = sleep(PUBLISH_TIMEOUT).then(() => { + throw "publish timeout" + }) + return race(promise, timeout_promise) +``` + +### Rule 6.3: OK Message Matching + +Clients must match OK messages to pending publishes by event ID. + +**Example:** + +``` +Client publishes: + event_a = {id: "aaa...", ...} + event_b = {id: "bbb...", ...} + +Send: + ["EVENT", event_a] + ["EVENT", event_b] + +Receive (order may vary): + ["OK", "bbb...", true, ""] + ["OK", "aaa...", false, "duplicate: already have this event"] + +Match OK to original publish promise by event ID +``` + +## 7. Connection Health Monitoring + +### Rule 7.1: Ping-Pong Protocol (Server Environments) + +In environments with native WebSocket ping support (Node.js, Go, etc.), implementations should use WebSocket ping frames. + +**Relay:** + +``` +on_connect(client): + start_ping_timer(client) + +ping_timer(): + every PING_INTERVAL: + send_websocket_ping() + wait_for_pong(PONG_TIMEOUT) + if no_pong_received: + close_connection("ping timeout") +``` + +**Recommended timings:** + +- PING_INTERVAL: 20-30 seconds +- PONG_TIMEOUT: 20-30 seconds + +### Rule 7.2: Application-Level Keep-Alive (Browser) + +Browsers don't expose WebSocket ping/pong. Use application-level heartbeat. + +**Client pattern:** + +``` +start_heartbeat(): + every HEARTBEAT_INTERVAL: + // Send dummy REQ for impossible event + nonce = random_hex(64) + send(["REQ", "ping-" + nonce, {"ids": [nonce]}]) + wait_for_eose(HEARTBEAT_TIMEOUT) + if no_eose_received: + close_connection("heartbeat timeout") + send(["CLOSE", "ping-" + nonce]) +``` + +The dummy REQ uses an ID that cannot exist (random 64-char hex), so relay responds with immediate EOSE. + +### Rule 7.3: Read Deadline Management + +Relays should set read deadlines on WebSocket connections. + +**Pattern:** + +``` +on_connect(client): + set_read_deadline(now() + READ_DEADLINE) + +on_message(client, message): + reset_read_deadline(now() + READ_DEADLINE) + +on_read_deadline_exceeded(client): + close_connection(client) +``` + +**Recommended READ_DEADLINE:** 60 seconds + +### Rule 7.4: Idle Connection Timeout + +Relays may close idle connections after a period of inactivity. + +**Optional behavior:** + +``` +IDLE_TIMEOUT = 30 seconds + +on_connect(client): + set_idle_timer(client) + +on_any_message(client): + reset_idle_timer(client) + +on_idle_timeout(client): + close_connection(client) +``` + +This applies to non-WebSocket HTTP connections only. WebSocket connections use ping-pong or read deadline instead. + +## 8. Error Handling + +### Rule 8.1: Connection Errors + +When a WebSocket error occurs, both sides must clean up resources. + +**Client:** + +``` +on_websocket_error(error): + log_error(error) + close_all_subscriptions("connection error") + reject_all_pending_publishes("connection error") + set_state(DISCONNECTED) +``` + +**Relay:** + +``` +on_websocket_error(client, error): + log_error(error) + close_all_client_subscriptions(client) + remove_client_from_registry(client) +``` + +### Rule 8.2: Protocol Errors + +Invalid messages should trigger NOTICE but not close the connection. + +**Relay behavior:** + +``` +on_invalid_json(client, raw_text): + send_to_client(["NOTICE", "invalid JSON"]) + // Keep connection alive + +on_unknown_message_type(client, message_type): + send_to_client(["NOTICE", "unknown message type: " + message_type]) + // Keep connection alive + +on_malformed_message(client, error): + send_to_client(["NOTICE", "malformed message: " + error]) + // Keep connection alive +``` + +### Rule 8.3: Rate Limiting + +Relays may rate-limit clients at three levels. + +**Connection level:** + +``` +on_connection_attempt(ip_address): + if exceeds_connection_rate_limit(ip_address): + return HTTP 429 "Too Many Requests" + accept_connection() +``` + +**Subscription level:** + +``` +on_req_message(client, sub_id, filters): + if exceeds_subscription_rate_limit(client): + send(["CLOSED", sub_id, "rate limited: too many subscriptions"]) + return + create_subscription(sub_id, filters) +``` + +**Event level:** + +``` +on_event_message(client, event): + if exceeds_event_rate_limit(client): + send(["OK", event.id, false, "rate limited: too many events"]) + return + store_event(event) +``` + +**Example rate limits:** + +- Connections: 1 per IP per 5 minutes +- Subscriptions: 20 per client per minute +- Events: 2 per client per 3 minutes + +### Rule 8.4: CLOSED Message Handling + +When a client receives CLOSED, it must stop expecting events for that subscription. + +**Client:** + +``` +on_closed_message(sub_id, reason): + subscription = subscriptions.get(sub_id) + if subscription: + subscription.closed = true + clear_eose_timeout(sub_id) + invoke_close_callback(reason) + subscriptions.delete(sub_id) +``` + +**Common CLOSED reasons:** + +``` +"rate limited: too many subscriptions" +"invalid: filter too broad" +"auth-required: this relay requires authentication" +"error: internal relay error" +``` + +### Rule 8.5: Unmatched Message Handling + +Clients should ignore messages for unknown subscription IDs. + +**Pattern:** + +``` +on_event_message(sub_id, event): + if not subscriptions.has(sub_id): + // Silently discard - subscription may have been closed + return + process_event(sub_id, event) + +on_eose_message(sub_id): + if not subscriptions.has(sub_id): + return + mark_eosed(sub_id) +``` + +Do not log warnings or errors for unmatched messages. They may arrive after CLOSE due to network timing. + +## 9. Authentication (NIP-42) + +### Rule 9.1: AUTH Challenge + +Relays may require authentication for certain operations. + +**Relay sends challenge:** + +``` +on_connect(client): + challenge = random_hex(16) + client.auth_challenge = challenge + send_to_client(["AUTH", challenge]) +``` + +**Example:** + +```json +["AUTH", "a1b2c3d4e5f6g7h8"] +``` + +### Rule 9.2: AUTH Response + +Clients respond with a signed kind 22242 event. + +**Event structure:** + +```json +{ + "kind": 22242, + "tags": [ + ["relay", ], + ["challenge", ] + ], + "content": "", + "created_at": , + "pubkey": , + "id": , + "sig": +} +``` + +**Example:** + +```json +["AUTH", { + "kind": 22242, + "tags": [ + ["relay", "wss://relay.example.com"], + ["challenge", "a1b2c3d4e5f6g7h8"] + ], + "content": "", + "created_at": 1673347337, + "pubkey": "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93", + "id": "f8c...", + "sig": "a3d..." +}] +``` + +**Relay validation:** + +``` +on_auth_message(client, signed_event): + if signed_event.kind != 22242: + send(["OK", signed_event.id, false, "error: wrong event kind"]) + return + + if not verify_signature(signed_event): + send(["OK", signed_event.id, false, "error: invalid signature"]) + return + + challenge_tag = find_tag(signed_event.tags, "challenge") + if challenge_tag[1] != client.auth_challenge: + send(["OK", signed_event.id, false, "error: challenge mismatch"]) + return + + client.authenticated_pubkey = signed_event.pubkey + send(["OK", signed_event.id, true, ""]) +``` + +### Rule 9.3: Auth-Required Error Pattern + +Relays indicate authentication requirements via OK or CLOSED messages prefixed with "auth-required:". + +**For events:** + +```json +["OK", , false, "auth-required: this relay requires authentication for writes"] +``` + +**For subscriptions:** + +```json +["CLOSED", , "auth-required: authentication required for this subscription"] +``` + +**Client retry pattern:** + +``` +on_ok_rejected(event_id, message): + if message.starts_with("auth-required:"): + auth_event = create_auth_event(relay.challenge) + send(["AUTH", auth_event]) + wait_for_auth_ok() + // Retry original event + send(["EVENT", original_event]) + +on_closed_message(sub_id, message): + if message.starts_with("auth-required:"): + auth_event = create_auth_event(relay.challenge) + send(["AUTH", auth_event]) + wait_for_auth_ok() + // Retry original subscription + send(["REQ", sub_id, ...original_filters]) +``` + +### Rule 9.4: Authentication State + +Authentication persists for the connection lifetime. + +**Client tracking:** + +``` +relay_state = { + challenge: "a1b2c3d4e5f6g7h8", + authenticated: false, + authenticated_pubkey: null +} + +on_auth_ok_received(): + relay_state.authenticated = true + relay_state.authenticated_pubkey = my_pubkey +``` + +**Relay tracking:** + +``` +client_state = { + challenge: "a1b2c3d4e5f6g7h8", + authenticated: false, + pubkey: null +} + +on_valid_auth(pubkey): + client_state.authenticated = true + client_state.pubkey = pubkey +``` + +## 10. Connection Termination + +### Rule 10.1: Graceful Shutdown + +Both sides should attempt graceful shutdown when possible. + +**Client:** + +``` +disconnect(): + for subscription in active_subscriptions: + send(["CLOSE", subscription.id]) + + send_websocket_close_frame() + wait_for_close_confirmation(timeout=1000ms) + close_websocket() +``` + +**Relay:** + +``` +shutdown(): + for client in active_clients: + for subscription in client.subscriptions: + send_to_client(["CLOSED", subscription.id, "relay shutting down"]) + + send_websocket_close_frame(client) + wait_for_close_confirmation(timeout=1000ms) + close_connection(client) +``` + +### Rule 10.2: Resource Cleanup + +All resources must be freed on disconnection. + +**Client cleanup:** + +``` +on_disconnect(): + // Clear all subscriptions + for subscription in subscriptions.values(): + subscription.closed = true + invoke_close_callback("connection closed") + subscriptions.clear() + + // Reject pending publishes + for publish in pending_publishes.values(): + publish.promise.reject("connection closed") + pending_publishes.clear() + + // Clear timers + clear_all_timeouts() + + // Reset state + state = DISCONNECTED + authenticated = false +``` + +**Relay cleanup:** + +``` +on_client_disconnect(client): + // Remove all subscriptions + for subscription in client.subscriptions: + remove_from_subscription_index(subscription) + client.subscriptions.clear() + + // Remove from client registry + clients.remove(client.id) + + // Cancel context + client.context.cancel() + + // Close socket + client.websocket.close() +``` + +### Rule 10.3: Close Frames + +Use standard WebSocket close codes. + +**Common close codes:** + +- 1000: Normal closure +- 1001: Going away (client navigating away, server shutting down) +- 1002: Protocol error +- 1011: Unexpected condition (internal error) + +**Example:** + +``` +close_connection(code, reason): + send_close_frame(code, reason) + wait_for_close_ack() + close_socket() +``` + +--- + +## Optional Features + +## Optional: Reconnection Strategy + +Clients may implement automatic reconnection after disconnection. + +### Optional Rule 11.1: Exponential Backoff + +Use exponential backoff with a maximum delay. + +**Algorithm:** + +``` +INITIAL_DELAY = 250 // milliseconds +MAX_DELAY = 16000 // milliseconds +attempt_count = 0 + +reconnect(): + delay = min(INITIAL_DELAY * (2 ** attempt_count), MAX_DELAY) + sleep(delay) + try_connect() + if success: + attempt_count = 0 + else: + attempt_count += 1 +``` + +**Example progression:** + +``` +Attempt 0: 250ms +Attempt 1: 500ms +Attempt 2: 1000ms +Attempt 3: 2000ms +Attempt 4: 4000ms +Attempt 5: 8000ms +Attempt 6+: 16000ms (capped) +``` + +### Optional Rule 11.2: Subscription Restoration + +Reestablish subscriptions after reconnection. + +**Pattern:** + +``` +on_reconnect_success(): + for subscription in saved_subscriptions: + send(["REQ", subscription.id, ...subscription.filters]) +``` + +Clients should track active subscriptions separately from connection state to enable restoration. + +### Optional Rule 11.3: Event Retry + +Retry failed event publishes after reconnection. + +**Pattern:** + +``` +outbox = [] // Failed events pending retry + +publish(event): + try: + send(["EVENT", event]) + wait_for_ok() + catch error: + outbox.push(event) + +on_reconnect_success(): + for event in outbox: + try: + send(["EVENT", event]) + wait_for_ok() + remove_from_outbox(event) + catch error: + // Keep in outbox for next attempt +``` + +### Optional Rule 11.4: Extended Backoff for Specific Errors + +Certain errors warrant longer backoff periods. + +**Extended backoff triggers:** + +- HTTP 403 Forbidden +- HTTP 410 Gone +- HTTP 502 Bad Gateway +- HTTP 503 Service Unavailable + +**Pattern:** + +``` +on_connection_error(error): + if error.code in [403, 410, 502, 503]: + delay = EXTENDED_BACKOFF // e.g., 5 minutes + else: + delay = calculate_exponential_backoff() + + sleep(delay) + retry_connection() +``` + +## Optional: Multi-Relay Coordination + +Clients may connect to multiple relays simultaneously. + +### Optional Rule 12.1: Connection Pooling + +Reuse relay connections across operations. + +**Pattern:** + +``` +relay_pool = Map + +get_relay(url): + if relay_pool.has(url): + return relay_pool.get(url) + + connection = create_connection(url) + relay_pool.set(url, connection) + return connection + +on_relay_disconnect(url): + relay_pool.delete(url) +``` + +### Optional Rule 12.2: Event Deduplication + +Track which events have been seen across relays. + +**Pattern:** + +``` +seen_events = Set + +on_event(relay, event): + if seen_events.has(event.id): + return // Skip duplicate + + seen_events.add(event.id) + process_event(event) +``` + +**With relay tracking:** + +``` +event_sources = Map> + +on_event(relay_url, event): + if not event_sources.has(event.id): + event_sources.set(event.id, new Set()) + process_event(event) + + event_sources.get(event.id).add(relay_url) +``` + +### Optional Rule 12.3: Subscription Fan-Out + +Subscribe to the same filters on multiple relays. + +**Pattern:** + +``` +subscribe_multi(relay_urls, filters): + subscription_id = generate_id() + + for url in relay_urls: + relay = get_relay(url) + relay.send(["REQ", subscription_id, ...filters]) + + return subscription_id +``` + +All relays use the same subscription ID for simplified management. + +### Optional Rule 12.4: EOSE Batching + +Wait for EOSE from all relays before marking complete. + +**Pattern:** + +``` +multi_subscription = { + id: "sub-1", + relay_urls: ["wss://relay1.com", "wss://relay2.com", "wss://relay3.com"], + eose_received: Set() +} + +on_eose(relay_url, sub_id): + multi_subscription.eose_received.add(relay_url) + + if multi_subscription.eose_received.size == multi_subscription.relay_urls.length: + invoke_complete_callback() +``` + +### Optional Rule 12.5: Publish Broadcasting + +Publish events to multiple relays and collect results. + +**Pattern:** + +``` +publish_multi(relay_urls, event): + promises = [] + + for url in relay_urls: + relay = get_relay(url) + promise = relay.publish(event) + promises.push(promise) + + return Promise.all(promises) +``` + +**With partial success handling:** + +``` +publish_multi(relay_urls, event): + results = [] + + for url in relay_urls: + relay = get_relay(url) + try: + await relay.publish(event) + results.push({url: url, success: true}) + catch error: + results.push({url: url, success: false, error: error}) + + return results +``` + +## Optional: Dynamic Filter Updates + +Clients may update subscription filters without closing and reopening. + +### Optional Rule 13.1: Filter Replacement + +Send REQ with same subscription ID but new filters. + +**Pattern:** + +``` +subscription = { + id: "sub-1", + filters: [{"kinds": [1], "limit": 10}] +} + +update_filters(new_filters): + subscription.filters = new_filters + send(["REQ", subscription.id, ...new_filters]) + // Relay treats as new subscription with same ID +``` + +### Optional Rule 13.2: Filter Change Detection + +Optimize by avoiding resend when filters haven't meaningfully changed. + +**Changes that warrant resend:** + +- Different event kinds +- Different authors +- Different tag filters +- `since` timestamp moved backward +- `until` timestamp changed +- `limit` changed + +**Changes that may skip resend:** + +- `since` timestamp moved forward (natural progression) + +**Example:** + +``` +should_resend_req(old_filters, new_filters): + if old_filters.kinds != new_filters.kinds: + return true + if old_filters.authors != new_filters.authors: + return true + if old_filters["#e"] != new_filters["#e"]: + return true + if new_filters.since < old_filters.since: + return true // Looking backward in time + if new_filters.until != old_filters.until: + return true + + return false // Only `since` moved forward +``` + +### Optional Rule 13.3: Observable Filters + +Use reactive primitives to automatically update subscriptions. + +**Pattern:** + +``` +filters_observable = BehaviorSubject([{"kinds": [1]}]) + +subscribe_reactive(relay, sub_id, filters_observable): + filters_observable.subscribe(filters => { + relay.send(["REQ", sub_id, ...filters]) + }) + +// Later, update filters: +filters_observable.next([{"kinds": [1, 6, 7]}]) +// Subscription automatically updates +``` + +## Optional: Message Queuing + +Clients may queue incoming messages to prevent blocking. + +### Optional Rule 14.1: Asynchronous Processing + +Process messages in a non-blocking queue. + +**Pattern:** + +``` +message_queue = [] +processing = false + +on_websocket_message(raw_text): + message_queue.push(raw_text) + if not processing: + start_queue_processor() + +async start_queue_processor(): + processing = true + while message_queue.length > 0: + message = message_queue.shift() + process_message(message) + await yield_thread() // Allow other tasks to run + processing = false +``` + +### Optional Rule 14.2: Priority Queuing + +Process certain message types before others. + +**Priority order:** + +1. EOSE (completes queries) +2. OK (completes publishes) +3. CLOSED (frees resources) +4. EVENT (bulk of traffic) +5. NOTICE (informational only) + +**Pattern:** + +``` +priority_queues = { + high: [], // EOSE, OK, CLOSED + normal: [], // EVENT + low: [] // NOTICE +} + +on_message(raw_text): + message_type = extract_type(raw_text) + priority = get_priority(message_type) + priority_queues[priority].push(raw_text) + +process_queues(): + while has_messages(): + if priority_queues.high.length > 0: + process(priority_queues.high.shift()) + else if priority_queues.normal.length > 0: + process(priority_queues.normal.shift()) + else if priority_queues.low.length > 0: + process(priority_queues.low.shift()) +``` + +## Optional: Performance Optimizations + +### Optional Rule 15.1: Fast Event ID Extraction + +Extract event ID before full JSON parsing for early deduplication. + +**Pattern:** + +``` +on_event_message(raw_text): + // Fast path: extract ID via regex before parsing + if raw_text.starts_with('["EVENT"'): + event_id = extract_hex64(raw_text, '"id"') // regex scan + + if already_have_event(event_id): + return // Skip expensive JSON.parse() + + // Slow path: parse full JSON + [type, sub_id, event] = JSON.parse(raw_text) + process_event(sub_id, event) +``` + +**Regex pattern:** + +``` +/"id"\s*:\s*"([0-9a-f]{64})"/ +``` + +### Optional Rule 15.2: Subscription Ref-Counting + +Share underlying subscriptions for identical requests. + +**Pattern:** + +``` +active_reqs = Map + +subscribe(sub_id, filters): + if active_reqs.has(sub_id): + active_reqs.get(sub_id).count += 1 + return // Don't send duplicate REQ + + active_reqs.set(sub_id, {count: 1, filters: filters}) + send(["REQ", sub_id, ...filters]) + +unsubscribe(sub_id): + req = active_reqs.get(sub_id) + req.count -= 1 + + if req.count == 0: + send(["CLOSE", sub_id]) + active_reqs.delete(sub_id) +``` + +### Optional Rule 15.3: Event Validation Caching + +Cache signature verification results. + +**Pattern:** + +``` +verified_events = Map + +verify_event(event): + if verified_events.has(event.id): + return verified_events.get(event.id) + + is_valid = verify_signature(event) + verified_events.set(event.id, is_valid) + return is_valid +``` + +**Cache eviction:** + +``` +MAX_CACHE_SIZE = 10000 + +if verified_events.size > MAX_CACHE_SIZE: + // Evict oldest entries + remove_oldest_entries(1000) +``` + +### Optional Rule 15.4: Trusted Relays + +Skip signature verification for known-good relays. + +**Pattern:** + +``` +trusted_relay_urls = Set([ + "wss://relay1.example.com", + "wss://relay2.example.com" +]) + +on_event(relay_url, event): + if trusted_relay_urls.has(relay_url): + process_event(event) // Skip verification + else: + if verify_event(event): + process_event(event) +``` + +## Optional: Connection Lifecycle Hooks + +Implementations may expose hooks for monitoring and extension. + +### Optional Rule 16.1: Connection State Callbacks + +**Client hooks:** + +``` +on_connecting(): + // Connection attempt started + show_loading_indicator() + +on_connected(): + // WebSocket open, ready to communicate + hide_loading_indicator() + restore_subscriptions() + +on_disconnecting(): + // Graceful shutdown initiated + show_reconnecting_message() + +on_disconnected(): + // Connection closed + clear_ui_state() + schedule_reconnection() +``` + +### Optional Rule 16.2: Message Interception + +**Relay hooks:** + +``` +before_event_store(event): + // Validate custom rules + if not meets_relay_policy(event): + return reject("policy violation") + return accept() + +after_event_store(event): + // Trigger side effects + update_search_index(event) + notify_external_systems(event) + +before_subscription_create(filters): + // Validate or modify filters + if too_broad(filters): + return reject("filter too broad") + return accept() +``` + +### Optional Rule 16.3: Error Callbacks + +**Client hooks:** + +``` +on_error(error): + log_to_monitoring(error) + show_user_message(error) + +on_notice(message): + log_relay_notice(message) + +on_closed(sub_id, reason): + log_subscription_closure(sub_id, reason) + show_user_notification(reason) +``` + +--- + +## Implementation Notes + +### Concurrency Models + +This specification is compatible with multiple concurrency approaches: + +**Callback-based:** + +``` +relay.on('event', (sub_id, event) => { ... }) +relay.on('eose', (sub_id) => { ... }) +``` + +**Promise-based:** + +``` +const ok = await relay.publish(event) +const events = await relay.query(filters) +``` + +**Observable-based:** + +``` +relay.subscribe(filters).subscribe(event => { ... }) +``` + +**Async iterator:** + +``` +for await (const event of relay.subscribe(filters)) { ... } +``` + +Choose based on language and platform conventions. + +### Memory Management + +Implementations must prevent memory leaks: + +1. **Remove closed subscriptions** from registries +2. **Clear timeouts** when operations complete or connections close +3. **Limit cache sizes** for deduplication and validation +4. **Clean up on disconnect** - subscriptions, pending publishes, timers + +### Thread Safety + +If using threads or processes: + +1. **Protect WebSocket writes** with mutex +2. **Guard shared state** (subscription maps, event caches) +3. **Use message passing** between connection handler and application logic + +### Platform Considerations + +**Browser:** + +- No native WebSocket ping-pong access +- Use application-level heartbeat (dummy REQ) +- Be aware of connection limits (typically 6-30 per domain) + +**Node.js:** + +- Native ping-pong available via `ws` library +- Can handle thousands of concurrent connections +- Use clustering for horizontal scaling + +**Mobile:** + +- Connections may break when app backgrounds +- Implement reconnection on app foreground +- Consider battery impact of keep-alive frequency + +**Server (Relay):** + +- Must handle high concurrent connection counts +- Implement connection limits and rate limits +- Use efficient data structures (swap-delete for O(1) removal) \ No newline at end of file