{"id":"5c0a410e2e5c1fb7613b919eb66ca55dad0dc91dfab1c475b985f4800bde4113","pubkey":"7ed7d5c3abf06fa1c00f71f879856769f46ac92354c129b3ed5562506927e200","created_at":1777752273,"kind":30817,"tags":[["d","websocket-transport-specification"],["title","Websocket Transport Specification"],["k","22242"],["client","nostrhub.io"]],"content":"# Nostr Websocket Transport Specification\n\n## 1. Connection Establishment\n\nA client connects to a relay using the WebSocket protocol. A relay accepts connections and upgrades HTTP requests to WebSocket.\n\n### Rule 1.1: WebSocket Protocol\n\nAll communication uses WebSocket connections over `ws://` or `wss://` URLs.\n\n**Client behavior:**\n\n```\nconnection = create_websocket(relay_url)\nwait_for_open_event()\n```\n\n**Relay behavior:**\n\n```\nreceive HTTP GET with \"Upgrade: websocket\" header\nvalidate_connection_request()\nif accepted:\n  upgrade_to_websocket()\nelse:\n  return HTTP 429 or appropriate error\n```\n\n### Rule 1.2: URL Normalization\n\nClients must normalize relay URLs before connecting.\n\n**Normalization rules:**\n\n- Remove trailing slash\n- Ensure protocol prefix (`ws://` or `wss://`)\n- Convert to lowercase for comparison\n\n**Example:**\n\n```\nInput:  WSS://Relay.Example.COM/\nOutput: wss://relay.example.com\n\nInput:  relay.example.com\nOutput: wss://relay.example.com\n```\n\n### Rule 1.3: Connection States\n\nA connection exists in exactly one state at any time:\n\n- **DISCONNECTED**: No active connection\n- **CONNECTING**: Connection attempt in progress\n- **CONNECTED**: WebSocket open and ready\n- **CLOSING**: Graceful shutdown initiated\n\nState transitions:\n\n```\nDISCONNECTED → (connect) → CONNECTING\nCONNECTING → (open_event) → CONNECTED\nCONNECTING → (error) → DISCONNECTED\nCONNECTED → (disconnect) → CLOSING\nCLOSING → (close_complete) → DISCONNECTED\n```\n\n**Example client code:**\n\n```\nstate = DISCONNECTED\nconnect():\n  state = CONNECTING\n  ws.connect()\n\non_open():\n  state = CONNECTED\n\non_close():\n  state = DISCONNECTED\n```\n\n### Rule 1.4: Connection Readiness\n\nClients must wait for the WebSocket open event before sending messages. Relays must not send messages before the connection is fully established.\n\n**Client:**\n\n```\nconnection_promise = create_connection()\nawait connection_promise\nsend_message([\"REQ\", ...])\n```\n\n**Relay:**\n\n```\non_websocket_upgrade():\n  create_connection_context()\n  mark_connection_ready()\n  execute_connect_hooks()\n```\n\n### Rule 1.5: CORS Requirements (Relay)\n\nRelays must implement permissive CORS for non-WebSocket HTTP endpoints:\n\n```\nAccess-Control-Allow-Origin: *\nAccess-Control-Allow-Methods: HEAD, GET, POST, PUT, PATCH, DELETE\nAccess-Control-Max-Age: 86400\n```\n\n## 2. Message Framing\n\n### Rule 2.1: Frame Format\n\nAll messages are WebSocket text frames containing UTF-8 encoded JSON arrays.\n\n**Structure:**\n\n```\n[\"MESSAGE_TYPE\", argument1, argument2, ...]\n```\n\nThe first element identifies the message type. Remaining elements depend on the message type.\n\n**Example frame:**\n\n```\n[\"EVENT\", \"subscription-id-123\", {\"id\": \"abc...\", \"kind\": 1, ...}]\n```\n\n### Rule 2.2: Message Size Limits\n\nImplementations should enforce maximum message size limits.\n\n**Recommended limit:** 512,000 bytes\n\n**Behavior when exceeded:**\n\n- Client: Close connection with error\n- Relay: Close connection and remove client\n\n**Example enforcement:**\n\n```\non_message(text):\n  if length(text) > MAX_MESSAGE_SIZE:\n    close_connection(\"message too large\")\n    return\n  process_message(text)\n```\n\n### Rule 2.3: Write Safety\n\nImplementations must prevent concurrent writes to the WebSocket.\n\n**Pattern:**\n\n```\nmutex = create_mutex()\n\nsend_message(json_array):\n  mutex.lock()\n  websocket.send(JSON.stringify(json_array))\n  mutex.unlock()\n```\n\n**Why this matters:** Concurrent writes to a WebSocket cause panic or corruption in most implementations.\n\n### Rule 2.4: Message Parsing\n\nParse the JSON array and extract the message type from the first element.\n\n**Process:**\n\n```\non_text_frame(raw_text):\n  try:\n    json_array = JSON.parse(raw_text)\n    message_type = json_array[0]\n    dispatch_by_type(message_type, json_array)\n  catch parse_error:\n    send_notice(\"invalid JSON\")\n```\n\nUnknown message types should trigger a NOTICE but must not terminate the connection.\n\n**Example:**\n\n```\nReceived: [\"UNKNOWN_TYPE\", \"arg1\"]\nAction: Send [\"NOTICE\", \"unknown message type\"]\nKeep connection alive\n```\n\n## 3. Client-to-Relay Messages\n\n### Rule 3.1: REQ - Subscribe to Events\n\nRequest events matching filter criteria.\n\n**Format:**\n\n```json\n[\"REQ\", <subscription-id>, <filter>, <filter>, ...]\n```\n\n**Subscription ID:**\n\n- String chosen by client\n- Must be unique per connection\n- Recommended: alphanumeric, 8-64 characters\n\n**Example:**\n\n```json\n[\"REQ\", \"sub-1\", {\"kinds\": [1], \"limit\": 10}]\n```\n\n**Multiple filters example:**\n\n```json\n[\"REQ\", \"sub-2\", \n  {\"kinds\": [0], \"authors\": [\"abc123...\"]},\n  {\"kinds\": [1], \"#p\": [\"abc123...\"], \"limit\": 50}\n]\n```\n\nFilters within one REQ are combined with OR logic.\n\n### Rule 3.2: CLOSE - End Subscription\n\nStop receiving events for a subscription.\n\n**Format:**\n\n```json\n[\"CLOSE\", <subscription-id>]\n```\n\n**Example:**\n\n```json\n[\"CLOSE\", \"sub-1\"]\n```\n\n**Effect:**\n\n- Relay stops sending events for this subscription\n- Relay removes subscription from active set\n- Client frees associated resources\n\n### Rule 3.3: EVENT - Publish Event\n\nSubmit an event to the relay for storage and distribution.\n\n**Format:**\n\n```json\n[\"EVENT\", <event-object>]\n```\n\n**Example:**\n\n```json\n[\"EVENT\", {\n  \"id\": \"4376c65d2f232afbe9b882a35baa4f6fe8667c4e684749af565f981833ed6a65\",\n  \"pubkey\": \"6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93\",\n  \"created_at\": 1673347337,\n  \"kind\": 1,\n  \"tags\": [],\n  \"content\": \"Hello, relay!\",\n  \"sig\": \"908a15e...\"\n}]\n```\n\n**Relay validation:**\n\n```\nreceive_event(event):\n  validate_event_structure(event)\n  validate_event_id(event)\n  validate_event_signature(event)\n  if valid:\n    store_event(event)\n    forward_to_matching_subscriptions(event)\n    send_ok_accepted(event.id)\n  else:\n    send_ok_rejected(event.id, reason)\n```\n\n### Rule 3.4: AUTH - Authenticate\n\nRespond to an authentication challenge.\n\n**Format:**\n\n```json\n[\"AUTH\", <signed-event>]\n```\n\nThe signed event must be kind 22242 with specific tags (see Authentication section).\n\n**Example:**\n\n```json\n[\"AUTH\", {\n  \"kind\": 22242,\n  \"tags\": [\n    [\"relay\", \"wss://relay.example.com\"],\n    [\"challenge\", \"abc123...\"]\n  ],\n  \"content\": \"\",\n  ...\n}]\n```\n\n### Rule 3.5: COUNT - Request Count\n\nRequest a count of events matching filters without retrieving the events.\n\n**Format:**\n\n```json\n[\"COUNT\", <subscription-id>, <filter>, <filter>, ...]\n```\n\n**Example:**\n\n```json\n[\"COUNT\", \"count-1\", {\"kinds\": [1], \"authors\": [\"abc123...\"], \"since\": 1673347337}]\n```\n\n## 4. Relay-to-Client Messages\n\n### Rule 4.1: EVENT - Deliver Event\n\nSend an event matching an active subscription.\n\n**Format:**\n\n```json\n[\"EVENT\", <subscription-id>, <event-object>]\n```\n\n**Example:**\n\n```json\n[\"EVENT\", \"sub-1\", {\n  \"id\": \"4376c65d...\",\n  \"pubkey\": \"6e468422...\",\n  \"created_at\": 1673347337,\n  \"kind\": 1,\n  \"tags\": [],\n  \"content\": \"Hello!\",\n  \"sig\": \"908a15e...\"\n}]\n```\n\n**Client processing:**\n\n```\non_event_message(sub_id, event):\n  subscription = find_subscription(sub_id)\n  if subscription is null:\n    return  // discard unknown subscription\n  \n  if already_have_event(event.id):\n    return  // skip duplicate\n  \n  if not verify_event_signature(event):\n    return  // skip invalid\n  \n  if matches_filters(event, subscription.filters):\n    deliver_to_application(event)\n```\n\n### Rule 4.2: EOSE - End of Stored Events\n\nSignal completion of stored event delivery for a subscription.\n\n**Format:**\n\n```json\n[\"EOSE\", <subscription-id>]\n```\n\n**Example:**\n\n```json\n[\"EOSE\", \"sub-1\"]\n```\n\n**Message order guarantee:**\n\n```\nClient: [\"REQ\", \"sub-1\", {...}]\nRelay:  [\"EVENT\", \"sub-1\", {...stored event 1...}]\nRelay:  [\"EVENT\", \"sub-1\", {...stored event 2...}]\nRelay:  [\"EOSE\", \"sub-1\"]\nRelay:  [\"EVENT\", \"sub-1\", {...new real-time event...}]\n```\n\nAfter EOSE, events for this subscription are real-time only.\n\n### Rule 4.3: OK - Event Acceptance Response\n\nConfirm whether a published event was accepted.\n\n**Format:**\n\n```json\n[\"OK\", <event-id>, <true|false>, <message>]\n```\n\n**Accepted example:**\n\n```json\n[\"OK\", \"4376c65d...\", true, \"\"]\n```\n\n**Rejected example:**\n\n```json\n[\"OK\", \"4376c65d...\", false, \"invalid: event timestamp too far in future\"]\n```\n\n**Client behavior:**\n\n```\npending_publishes = Map<event_id, promise>\n\nsend_event(event):\n  promise = create_promise()\n  pending_publishes.set(event.id, promise)\n  send([\"EVENT\", event])\n  return promise\n\non_ok_message(event_id, accepted, message):\n  promise = pending_publishes.get(event_id)\n  if promise:\n    if accepted:\n      promise.resolve()\n    else:\n      promise.reject(message)\n    pending_publishes.delete(event_id)\n```\n\n### Rule 4.4: CLOSED - Subscription Terminated\n\nNotify client that relay has ended a subscription.\n\n**Format:**\n\n```json\n[\"CLOSED\", <subscription-id>, <message>]\n```\n\n**Example:**\n\n```json\n[\"CLOSED\", \"sub-1\", \"rate limited: too many subscriptions\"]\n```\n\n**Client behavior:**\n\n```\non_closed_message(sub_id, message):\n  subscription = find_subscription(sub_id)\n  if subscription:\n    subscription.mark_closed()\n    invoke_close_callback(message)\n    remove_subscription(sub_id)\n```\n\n### Rule 4.5: NOTICE - Human-Readable Message\n\nSend informational or error messages to the client.\n\n**Format:**\n\n```json\n[\"NOTICE\", <message>]\n```\n\n**Example:**\n\n```json\n[\"NOTICE\", \"This relay requires payment for writes\"]\n```\n\nNOTICE messages are for human consumption. Clients should log them but must not treat them as protocol errors.\n\n### Rule 4.6: AUTH - Request Authentication\n\nChallenge client to authenticate.\n\n**Format:**\n\n```json\n[\"AUTH\", <challenge-string>]\n```\n\n**Example:**\n\n```json\n[\"AUTH\", \"a1b2c3d4e5f6g7h8\"]\n```\n\n**Relay behavior:**\n\n```\non_connect(client):\n  challenge = generate_random_hex(16)\n  client.challenge = challenge\n  send([\"AUTH\", challenge])\n```\n\nSee Authentication section for complete flow.\n\n### Rule 4.7: COUNT - Count Response\n\nReturn count of events matching COUNT request.\n\n**Format:**\n\n```json\n[\"COUNT\", <subscription-id>, {\"count\": <integer>}]\n```\n\n**Example:**\n\n```json\n[\"COUNT\", \"count-1\", {\"count\": 42}]\n```\n\n## 5. Subscription Lifecycle\n\n### Rule 5.1: Creating Subscriptions\n\nA subscription begins when a client sends REQ and ends when either side sends CLOSE or CLOSED.\n\n**Client lifecycle:**\n\n```\nsubscription_id = generate_unique_id()\nsubscriptions.set(subscription_id, {\n  id: subscription_id,\n  filters: filters,\n  eosed: false,\n  closed: false\n})\nsend([\"REQ\", subscription_id, ...filters])\nstart_eose_timeout(subscription_id)\n```\n\n**Relay lifecycle:**\n\n```\non_req_message(client, sub_id, filters):\n  subscription = {\n    id: sub_id,\n    filters: filters,\n    client: client\n  }\n  active_subscriptions.add(subscription)\n  \n  stored_events = query_events(filters)\n  for event in stored_events:\n    send_to_client([\"EVENT\", sub_id, event])\n  \n  send_to_client([\"EOSE\", sub_id])\n  \n  // Continue sending real-time matching events\n```\n\n### Rule 5.2: Multiple Subscriptions\n\nA client may have multiple active subscriptions on one connection. Each must have a unique subscription ID.\n\n**Example:**\n\n```\nClient sends:\n[\"REQ\", \"sub-1\", {\"kinds\": [1], \"limit\": 10}]\n[\"REQ\", \"sub-2\", {\"kinds\": [0], \"authors\": [\"abc...\"]}]\n[\"REQ\", \"sub-3\", {\"kinds\": [7], \"#e\": [\"xyz...\"]}]\n\nAll three subscriptions active simultaneously.\n```\n\n### Rule 5.3: Subscription Replacement\n\nSending REQ with an existing subscription ID replaces the previous subscription.\n\n**Behavior:**\n\n```\non_req_message(client, sub_id, new_filters):\n  if subscription_exists(sub_id):\n    remove_old_subscription(sub_id)\n  create_new_subscription(sub_id, new_filters)\n  query_and_send_events(sub_id, new_filters)\n```\n\n**Example:**\n\n```\nClient: [\"REQ\", \"sub-1\", {\"kinds\": [1]}]\nRelay: [...events...] [\"EOSE\", \"sub-1\"]\n\nClient: [\"REQ\", \"sub-1\", {\"kinds\": [1, 6, 7]}]\nRelay: [...new events matching updated filters...] [\"EOSE\", \"sub-1\"]\n```\n\nThe old subscription is implicitly closed.\n\n### Rule 5.4: Closing Subscriptions\n\n**Client-initiated:**\n\n```\nclose_subscription(sub_id):\n  send([\"CLOSE# Nostr Client-Relay Transport Specification\n\n## 1. Connection Establishment\n\nA client connects to a relay using the WebSocket protocol. A relay accepts connections and upgrades HTTP requests to WebSocket.\n\n### Rule 1.1: WebSocket Protocol\n\nAll communication uses WebSocket connections over `ws://` or `wss://` URLs.\n\n**Client behavior:**\n\n```\nconnection = create_websocket(relay_url)\nwait_for_open_event()\n```\n\n**Relay behavior:**\n\n```\nreceive HTTP GET with \"Upgrade: websocket\" header\nvalidate_connection_request()\nif accepted:\n  upgrade_to_websocket()\nelse:\n  return HTTP 429 or appropriate error\n```\n\n### Rule 1.2: URL Normalization\n\nClients must normalize relay URLs before connecting.\n\n**Normalization rules:**\n\n- Remove trailing slash\n- Ensure protocol prefix (`ws://` or `wss://`)\n- Convert to lowercase for comparison\n\n**Example:**\n\n```\nInput:  WSS://Relay.Example.COM/\nOutput: wss://relay.example.com\n\nInput:  relay.example.com\nOutput: wss://relay.example.com\n```\n\n### Rule 1.3: Connection States\n\nA connection exists in exactly one state at any time:\n\n- **DISCONNECTED**: No active connection\n- **CONNECTING**: Connection attempt in progress\n- **CONNECTED**: WebSocket open and ready\n- **CLOSING**: Graceful shutdown initiated\n\nState transitions:\n\n```\nDISCONNECTED → (connect) → CONNECTING\nCONNECTING → (open_event) → CONNECTED\nCONNECTING → (error) → DISCONNECTED\nCONNECTED → (disconnect) → CLOSING\nCLOSING → (close_complete) → DISCONNECTED\n```\n\n**Example client code:**\n\n```\nstate = DISCONNECTED\nconnect():\n  state = CONNECTING\n  ws.connect()\n\non_open():\n  state = CONNECTED\n\non_close():\n  state = DISCONNECTED\n```\n\n### Rule 1.4: Connection Readiness\n\nClients must wait for the WebSocket open event before sending messages. Relays must not send messages before the connection is fully established.\n\n**Client:**\n\n```\nconnection_promise = create_connection()\nawait connection_promise\nsend_message([\"REQ\", ...])\n```\n\n**Relay:**\n\n```\non_websocket_upgrade():\n  create_connection_context()\n  mark_connection_ready()\n  execute_connect_hooks()\n```\n\n### Rule 1.5: CORS Requirements (Relay)\n\nRelays must implement permissive CORS for non-WebSocket HTTP endpoints:\n\n```\nAccess-Control-Allow-Origin: *\nAccess-Control-Allow-Methods: HEAD, GET, POST, PUT, PATCH, DELETE\nAccess-Control-Max-Age: 86400\n```\n\n## 2. Message Framing\n\n### Rule 2.1: Frame Format\n\nAll messages are WebSocket text frames containing UTF-8 encoded JSON arrays.\n\n**Structure:**\n\n```\n[\"MESSAGE_TYPE\", argument1, argument2, ...]\n```\n\nThe first element identifies the message type. Remaining elements depend on the message type.\n\n**Example frame:**\n\n```\n[\"EVENT\", \"subscription-id-123\", {\"id\": \"abc...\", \"kind\": 1, ...}]\n```\n\n### Rule 2.2: Message Size Limits\n\nImplementations should enforce maximum message size limits.\n\n**Recommended limit:** 512,000 bytes\n\n**Behavior when exceeded:**\n\n- Client: Close connection with error\n- Relay: Close connection and remove client\n\n**Example enforcement:**\n\n```\non_message(text):\n  if length(text) > MAX_MESSAGE_SIZE:\n    close_connection(\"message too large\")\n    return\n  process_message(text)\n```\n\n### Rule 2.3: Write Safety\n\nImplementations must prevent concurrent writes to the WebSocket.\n\n**Pattern:**\n\n```\nmutex = create_mutex()\n\nsend_message(json_array):\n  mutex.lock()\n  websocket.send(JSON.stringify(json_array))\n  mutex.unlock()\n```\n\n**Why this matters:** Concurrent writes to a WebSocket cause panic or corruption in most implementations.\n\n### Rule 2.4: Message Parsing\n\nParse the JSON array and extract the message type from the first element.\n\n**Process:**\n\n```\non_text_frame(raw_text):\n  try:\n    json_array = JSON.parse(raw_text)\n    message_type = json_array[0]\n    dispatch_by_type(message_type, json_array)\n  catch parse_error:\n    send_notice(\"invalid JSON\")\n```\n\nUnknown message types should trigger a NOTICE but must not terminate the connection.\n\n**Example:**\n\n```\nReceived: [\"UNKNOWN_TYPE\", \"arg1\"]\nAction: Send [\"NOTICE\", \"unknown message type\"]\nKeep connection alive\n```\n\n## 3. Client-to-Relay Messages\n\n### Rule 3.1: REQ - Subscribe to Events\n\nRequest events matching filter criteria.\n\n**Format:**\n\n```json\n[\"REQ\", <subscription-id>, <filter>, <filter>, ...]\n```\n\n**Subscription ID:**\n\n- String chosen by client\n- Must be unique per connection\n- Recommended: alphanumeric, 8-64 characters\n\n**Example:**\n\n```json\n[\"REQ\", \"sub-1\", {\"kinds\": [1], \"limit\": 10}]\n```\n\n**Multiple filters example:**\n\n```json\n[\"REQ\", \"sub-2\", \n  {\"kinds\": [0], \"authors\": [\"abc123...\"]},\n  {\"kinds\": [1], \"#p\": [\"abc123...\"], \"limit\": 50}\n]\n```\n\nFilters within one REQ are combined with OR logic.\n\n### Rule 3.2: CLOSE - End Subscription\n\nStop receiving events for a subscription.\n\n**Format:**\n\n```json\n[\"CLOSE\", <subscription-id>]\n```\n\n**Example:**\n\n```json\n[\"CLOSE\", \"sub-1\"]\n```\n\n**Effect:**\n\n- Relay stops sending events for this subscription\n- Relay removes subscription from active set\n- Client frees associated resources\n\n### Rule 3.3: EVENT - Publish Event\n\nSubmit an event to the relay for storage and distribution.\n\n**Format:**\n\n```json\n[\"EVENT\", <event-object>]\n```\n\n**Example:**\n\n```json\n[\"EVENT\", {\n  \"id\": \"4376c65d2f232afbe9b882a35baa4f6fe8667c4e684749af565f981833ed6a65\",\n  \"pubkey\": \"6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93\",\n  \"created_at\": 1673347337,\n  \"kind\": 1,\n  \"tags\": [],\n  \"content\": \"Hello, relay!\",\n  \"sig\": \"908a15e...\"\n}]\n```\n\n**Relay validation:**\n\n```\nreceive_event(event):\n  validate_event_structure(event)\n  validate_event_id(event)\n  validate_event_signature(event)\n  if valid:\n    store_event(event)\n    forward_to_matching_subscriptions(event)\n    send_ok_accepted(event.id)\n  else:\n    send_ok_rejected(event.id, reason)\n```\n\n### Rule 3.4: AUTH - Authenticate\n\nRespond to an authentication challenge.\n\n**Format:**\n\n```json\n[\"AUTH\", <signed-event>]\n```\n\nThe signed event must be kind 22242 with specific tags (see Authentication section).\n\n**Example:**\n\n```json\n[\"AUTH\", {\n  \"kind\": 22242,\n  \"tags\": [\n    [\"relay\", \"wss://relay.example.com\"],\n    [\"challenge\", \"abc123...\"]\n  ],\n  \"content\": \"\",\n  ...\n}]\n```\n\n### Rule 3.5: COUNT - Request Count\n\nRequest a count of events matching filters without retrieving the events.\n\n**Format:**\n\n```json\n[\"COUNT\", <subscription-id>, <filter>, <filter>, ...]\n```\n\n**Example:**\n\n```json\n[\"COUNT\", \"count-1\", {\"kinds\": [1], \"authors\": [\"abc123...\"], \"since\": 1673347337}]\n```\n\n## 4. Relay-to-Client Messages\n\n### Rule 4.1: EVENT - Deliver Event\n\nSend an event matching an active subscription.\n\n**Format:**\n\n```json\n[\"EVENT\", <subscription-id>, <event-object>]\n```\n\n**Example:**\n\n```json\n[\"EVENT\", \"sub-1\", {\n  \"id\": \"4376c65d...\",\n  \"pubkey\": \"6e468422...\",\n  \"created_at\": 1673347337,\n  \"kind\": 1,\n  \"tags\": [],\n  \"content\": \"Hello!\",\n  \"sig\": \"908a15e...\"\n}]\n```\n\n**Client processing:**\n\n```\non_event_message(sub_id, event):\n  subscription = find_subscription(sub_id)\n  if subscription is null:\n    return  // discard unknown subscription\n  \n  if already_have_event(event.id):\n    return  // skip duplicate\n  \n  if not verify_event_signature(event):\n    return  // skip invalid\n  \n  if matches_filters(event, subscription.filters):\n    deliver_to_application(event)\n```\n\n### Rule 4.2: EOSE - End of Stored Events\n\nSignal completion of stored event delivery for a subscription.\n\n**Format:**\n\n```json\n[\"EOSE\", <subscription-id>]\n```\n\n**Example:**\n\n```json\n[\"EOSE\", \"sub-1\"]\n```\n\n**Message order guarantee:**\n\n```\nClient: [\"REQ\", \"sub-1\", {...}]\nRelay:  [\"EVENT\", \"sub-1\", {...stored event 1...}]\nRelay:  [\"EVENT\", \"sub-1\", {...stored event 2...}]\nRelay:  [\"EOSE\", \"sub-1\"]\nRelay:  [\"EVENT\", \"sub-1\", {...new real-time event...}]\n```\n\nAfter EOSE, events for this subscription are real-time only.\n\n### Rule 4.3: OK - Event Acceptance Response\n\nConfirm whether a published event was accepted.\n\n**Format:**\n\n```json\n[\"OK\", <event-id>, <true|false>, <message>]\n```\n\n**Accepted example:**\n\n```json\n[\"OK\", \"4376c65d...\", true, \"\"]\n```\n\n**Rejected example:**\n\n```json\n[\"OK\", \"4376c65d...\", false, \"invalid: event timestamp too far in future\"]\n```\n\n**Client behavior:**\n\n```\npending_publishes = Map<event_id, promise>\n\nsend_event(event):\n  promise = create_promise()\n  pending_publishes.set(event.id, promise)\n  send([\"EVENT\", event])\n  return promise\n\non_ok_message(event_id, accepted, message):\n  promise = pending_publishes.get(event_id)\n  if promise:\n    if accepted:\n      promise.resolve()\n    else:\n      promise.reject(message)\n    pending_publishes.delete(event_id)\n```\n\n### Rule 4.4: CLOSED - Subscription Terminated\n\nNotify client that relay has ended a subscription.\n\n**Format:**\n\n```json\n[\"CLOSED\", <subscription-id>, <message>]\n```\n\n**Example:**\n\n```json\n[\"CLOSED\", \"sub-1\", \"rate limited: too many subscriptions\"]\n```\n\n**Client behavior:**\n\n```\non_closed_message(sub_id, message):\n  subscription = find_subscription(sub_id)\n  if subscription:\n    subscription.mark_closed()\n    invoke_close_callback(message)\n    remove_subscription(sub_id)\n```\n\n### Rule 4.5: NOTICE - Human-Readable Message\n\nSend informational or error messages to the client.\n\n**Format:**\n\n```json\n[\"NOTICE\", <message>]\n```\n\n**Example:**\n\n```json\n[\"NOTICE\", \"This relay requires payment for writes\"]\n```\n\nNOTICE messages are for human consumption. Clients should log them but must not treat them as protocol errors.\n\n### Rule 4.6: AUTH - Request Authentication\n\nChallenge client to authenticate.\n\n**Format:**\n\n```json\n[\"AUTH\", <challenge-string>]\n```\n\n**Example:**\n\n```json\n[\"AUTH\", \"a1b2c3d4e5f6g7h8\"]\n```\n\n**Relay behavior:**\n\n```\non_connect(client):\n  challenge = generate_random_hex(16)\n  client.challenge = challenge\n  send([\"AUTH\", challenge])\n```\n\nSee Authentication section for complete flow.\n\n### Rule 4.7: COUNT - Count Response\n\nReturn count of events matching COUNT request.\n\n**Format:**\n\n```json\n[\"COUNT\", <subscription-id>, {\"count\": <integer>}]\n```\n\n**Example:**\n\n```json\n[\"COUNT\", \"count-1\", {\"count\": 42}]\n```\n\n## 5. Subscription Lifecycle\n\n### Rule 5.1: Creating Subscriptions\n\nA subscription begins when a client sends REQ and ends when either side sends CLOSE or CLOSED.\n\n**Client lifecycle:**\n\n```\nsubscription_id = generate_unique_id()\nsubscriptions.set(subscription_id, {\n  id: subscription_id,\n  filters: filters,\n  eosed: false,\n  closed: false\n})\nsend([\"REQ\", subscription_id, ...filters])\nstart_eose_timeout(subscription_id)\n```\n\n**Relay lifecycle:**\n\n```\non_req_message(client, sub_id, filters):\n  subscription = {\n    id: sub_id,\n    filters: filters,\n    client: client\n  }\n  active_subscriptions.add(subscription)\n  \n  stored_events = query_events(filters)\n  for event in stored_events:\n    send_to_client([\"EVENT\", sub_id, event])\n  \n  send_to_client([\"EOSE\", sub_id])\n  \n  // Continue sending real-time matching events\n```\n\n### Rule 5.2: Multiple Subscriptions\n\nA client may have multiple active subscriptions on one connection. Each must have a unique subscription ID.\n\n**Example:**\n\n```\nClient sends:\n[\"REQ\", \"sub-1\", {\"kinds\": [1], \"limit\": 10}]\n[\"REQ\", \"sub-2\", {\"kinds\": [0], \"authors\": [\"abc...\"]}]\n[\"REQ\", \"sub-3\", {\"kinds\": [7], \"#e\": [\"xyz...\"]}]\n\nAll three subscriptions active simultaneously.\n```\n\n### Rule 5.3: Subscription Replacement\n\nSending REQ with an existing subscription ID replaces the previous subscription.\n\n**Behavior:**\n\n```\non_req_message(client, sub_id, new_filters):\n  if subscription_exists(sub_id):\n    remove_old_subscription(sub_id)\n  create_new_subscription(sub_id, new_filters)\n  query_and_send_events(sub_id, new_filters)\n```\n\n**Example:**\n\n```\nClient: [\"REQ\", \"sub-1\", {\"kinds\": [1]}]\nRelay: [...events...] [\"EOSE\", \"sub-1\"]\n\nClient: [\"REQ\", \"sub-1\", {\"kinds\": [1, 6, 7]}]\nRelay: [...new events matching updated filters...] [\"EOSE\", \"sub-1\"]\n```\n\nThe old subscription is implicitly closed.\n\n### Rule 5.4: Closing Subscriptions\n\n**Client-initiated:**\n\n```\nclose_subscription(sub_id):\n  send([\"CLOSE\", sub_id])\n  remove_subscription(sub_id)\n```\n\n**Relay-initiated:**\n\n```\nclose_subscription(client, sub_id, reason):\n  send_to_client([\"CLOSED\", sub_id, reason])\n  remove_subscription(sub_id)\n```\n\n**Example reasons for relay-initiated closure:**\n\n- Rate limit exceeded\n- Filter policy violation\n- Authentication required\n- Relay shutting down\n\n### Rule 5.5: Subscription State Tracking\n\nClients should track whether EOSE has been received for each subscription.\n\n**State:**\n\n```\nsubscription = {\n  id: \"sub-1\",\n  filters: [{...}],\n  eosed: false,  // set to true when EOSE received\n  closed: false  // set to true when CLOSE or CLOSED sent\n}\n```\n\nThis allows clients to distinguish stored events from real-time events.\n\n## 6. Event Publishing\n\n### Rule 6.1: Publishing Flow\n\n**Client:**\n\n```\npublish(event):\n  promise = create_promise()\n  timeout = set_timeout(PUBLISH_TIMEOUT, () => {\n    promise.reject(\"timeout\")\n  })\n  \n  pending_publishes.set(event.id, {promise, timeout})\n  send([\"EVENT\", event])\n  \n  return promise\n```\n\n**Relay:**\n\n```\non_event_message(client, event):\n  validation_result = validate_and_store(event)\n  \n  if validation_result.accepted:\n    forward_to_subscriptions(event)\n    send_to_client([\"OK\", event.id, true, \"\"])\n  else:\n    send_to_client([\"OK\", event.id, false, validation_result.reason])\n```\n\n### Rule 6.2: Publish Timeout\n\nClients should implement timeouts for event publishing.\n\n**Recommended timeout:** 4000-5000 milliseconds\n\n**Behavior:**\n\n```\nPUBLISH_TIMEOUT = 4400  // milliseconds\n\npublish_with_timeout(event):\n  promise = publish(event)\n  timeout_promise = sleep(PUBLISH_TIMEOUT).then(() => {\n    throw \"publish timeout\"\n  })\n  return race(promise, timeout_promise)\n```\n\n### Rule 6.3: OK Message Matching\n\nClients must match OK messages to pending publishes by event ID.\n\n**Example:**\n\n```\nClient publishes:\n  event_a = {id: \"aaa...\", ...}\n  event_b = {id: \"bbb...\", ...}\n  \nSend:\n  [\"EVENT\", event_a]\n  [\"EVENT\", event_b]\n\nReceive (order may vary):\n  [\"OK\", \"bbb...\", true, \"\"]\n  [\"OK\", \"aaa...\", false, \"duplicate: already have this event\"]\n\nMatch OK to original publish promise by event ID\n```\n\n## 7. Connection Health Monitoring\n\n### Rule 7.1: Ping-Pong Protocol (Server Environments)\n\nIn environments with native WebSocket ping support (Node.js, Go, etc.), implementations should use WebSocket ping frames.\n\n**Relay:**\n\n```\non_connect(client):\n  start_ping_timer(client)\n\nping_timer():\n  every PING_INTERVAL:\n    send_websocket_ping()\n    wait_for_pong(PONG_TIMEOUT)\n    if no_pong_received:\n      close_connection(\"ping timeout\")\n```\n\n**Recommended timings:**\n\n- PING_INTERVAL: 20-30 seconds\n- PONG_TIMEOUT: 20-30 seconds\n\n### Rule 7.2: Application-Level Keep-Alive (Browser)\n\nBrowsers don't expose WebSocket ping/pong. Use application-level heartbeat.\n\n**Client pattern:**\n\n```\nstart_heartbeat():\n  every HEARTBEAT_INTERVAL:\n    // Send dummy REQ for impossible event\n    nonce = random_hex(64)\n    send([\"REQ\", \"ping-\" + nonce, {\"ids\": [nonce]}])\n    wait_for_eose(HEARTBEAT_TIMEOUT)\n    if no_eose_received:\n      close_connection(\"heartbeat timeout\")\n    send([\"CLOSE\", \"ping-\" + nonce])\n```\n\nThe dummy REQ uses an ID that cannot exist (random 64-char hex), so relay responds with immediate EOSE.\n\n### Rule 7.3: Read Deadline Management\n\nRelays should set read deadlines on WebSocket connections.\n\n**Pattern:**\n\n```\non_connect(client):\n  set_read_deadline(now() + READ_DEADLINE)\n\non_message(client, message):\n  reset_read_deadline(now() + READ_DEADLINE)\n\non_read_deadline_exceeded(client):\n  close_connection(client)\n```\n\n**Recommended READ_DEADLINE:** 60 seconds\n\n### Rule 7.4: Idle Connection Timeout\n\nRelays may close idle connections after a period of inactivity.\n\n**Optional behavior:**\n\n```\nIDLE_TIMEOUT = 30 seconds\n\non_connect(client):\n  set_idle_timer(client)\n\non_any_message(client):\n  reset_idle_timer(client)\n\non_idle_timeout(client):\n  close_connection(client)\n```\n\nThis applies to non-WebSocket HTTP connections only. WebSocket connections use ping-pong or read deadline instead.\n\n## 8. Error Handling\n\n### Rule 8.1: Connection Errors\n\nWhen a WebSocket error occurs, both sides must clean up resources.\n\n**Client:**\n\n```\non_websocket_error(error):\n  log_error(error)\n  close_all_subscriptions(\"connection error\")\n  reject_all_pending_publishes(\"connection error\")\n  set_state(DISCONNECTED)\n```\n\n**Relay:**\n\n```\non_websocket_error(client, error):\n  log_error(error)\n  close_all_client_subscriptions(client)\n  remove_client_from_registry(client)\n```\n\n### Rule 8.2: Protocol Errors\n\nInvalid messages should trigger NOTICE but not close the connection.\n\n**Relay behavior:**\n\n```\non_invalid_json(client, raw_text):\n  send_to_client([\"NOTICE\", \"invalid JSON\"])\n  // Keep connection alive\n\non_unknown_message_type(client, message_type):\n  send_to_client([\"NOTICE\", \"unknown message type: \" + message_type])\n  // Keep connection alive\n\non_malformed_message(client, error):\n  send_to_client([\"NOTICE\", \"malformed message: \" + error])\n  // Keep connection alive\n```\n\n### Rule 8.3: Rate Limiting\n\nRelays may rate-limit clients at three levels.\n\n**Connection level:**\n\n```\non_connection_attempt(ip_address):\n  if exceeds_connection_rate_limit(ip_address):\n    return HTTP 429 \"Too Many Requests\"\n  accept_connection()\n```\n\n**Subscription level:**\n\n```\non_req_message(client, sub_id, filters):\n  if exceeds_subscription_rate_limit(client):\n    send([\"CLOSED\", sub_id, \"rate limited: too many subscriptions\"])\n    return\n  create_subscription(sub_id, filters)\n```\n\n**Event level:**\n\n```\non_event_message(client, event):\n  if exceeds_event_rate_limit(client):\n    send([\"OK\", event.id, false, \"rate limited: too many events\"])\n    return\n  store_event(event)\n```\n\n**Example rate limits:**\n\n- Connections: 1 per IP per 5 minutes\n- Subscriptions: 20 per client per minute\n- Events: 2 per client per 3 minutes\n\n### Rule 8.4: CLOSED Message Handling\n\nWhen a client receives CLOSED, it must stop expecting events for that subscription.\n\n**Client:**\n\n```\non_closed_message(sub_id, reason):\n  subscription = subscriptions.get(sub_id)\n  if subscription:\n    subscription.closed = true\n    clear_eose_timeout(sub_id)\n    invoke_close_callback(reason)\n    subscriptions.delete(sub_id)\n```\n\n**Common CLOSED reasons:**\n\n```\n\"rate limited: too many subscriptions\"\n\"invalid: filter too broad\"\n\"auth-required: this relay requires authentication\"\n\"error: internal relay error\"\n```\n\n### Rule 8.5: Unmatched Message Handling\n\nClients should ignore messages for unknown subscription IDs.\n\n**Pattern:**\n\n```\non_event_message(sub_id, event):\n  if not subscriptions.has(sub_id):\n    // Silently discard - subscription may have been closed\n    return\n  process_event(sub_id, event)\n\non_eose_message(sub_id):\n  if not subscriptions.has(sub_id):\n    return\n  mark_eosed(sub_id)\n```\n\nDo not log warnings or errors for unmatched messages. They may arrive after CLOSE due to network timing.\n\n## 9. Authentication (NIP-42)\n\n### Rule 9.1: AUTH Challenge\n\nRelays may require authentication for certain operations.\n\n**Relay sends challenge:**\n\n```\non_connect(client):\n  challenge = random_hex(16)\n  client.auth_challenge = challenge\n  send_to_client([\"AUTH\", challenge])\n```\n\n**Example:**\n\n```json\n[\"AUTH\", \"a1b2c3d4e5f6g7h8\"]\n```\n\n### Rule 9.2: AUTH Response\n\nClients respond with a signed kind 22242 event.\n\n**Event structure:**\n\n```json\n{\n  \"kind\": 22242,\n  \"tags\": [\n    [\"relay\", <relay-url>],\n    [\"challenge\", <challenge-string>]\n  ],\n  \"content\": \"\",\n  \"created_at\": <current-timestamp>,\n  \"pubkey\": <client-pubkey>,\n  \"id\": <computed-id>,\n  \"sig\": <signature>\n}\n```\n\n**Example:**\n\n```json\n[\"AUTH\", {\n  \"kind\": 22242,\n  \"tags\": [\n    [\"relay\", \"wss://relay.example.com\"],\n    [\"challenge\", \"a1b2c3d4e5f6g7h8\"]\n  ],\n  \"content\": \"\",\n  \"created_at\": 1673347337,\n  \"pubkey\": \"6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93\",\n  \"id\": \"f8c...\",\n  \"sig\": \"a3d...\"\n}]\n```\n\n**Relay validation:**\n\n```\non_auth_message(client, signed_event):\n  if signed_event.kind != 22242:\n    send([\"OK\", signed_event.id, false, \"error: wrong event kind\"])\n    return\n  \n  if not verify_signature(signed_event):\n    send([\"OK\", signed_event.id, false, \"error: invalid signature\"])\n    return\n  \n  challenge_tag = find_tag(signed_event.tags, \"challenge\")\n  if challenge_tag[1] != client.auth_challenge:\n    send([\"OK\", signed_event.id, false, \"error: challenge mismatch\"])\n    return\n  \n  client.authenticated_pubkey = signed_event.pubkey\n  send([\"OK\", signed_event.id, true, \"\"])\n```\n\n### Rule 9.3: Auth-Required Error Pattern\n\nRelays indicate authentication requirements via OK or CLOSED messages prefixed with \"auth-required:\".\n\n**For events:**\n\n```json\n[\"OK\", <event-id>, false, \"auth-required: this relay requires authentication for writes\"]\n```\n\n**For subscriptions:**\n\n```json\n[\"CLOSED\", <sub-id>, \"auth-required: authentication required for this subscription\"]\n```\n\n**Client retry pattern:**\n\n```\non_ok_rejected(event_id, message):\n  if message.starts_with(\"auth-required:\"):\n    auth_event = create_auth_event(relay.challenge)\n    send([\"AUTH\", auth_event])\n    wait_for_auth_ok()\n    // Retry original event\n    send([\"EVENT\", original_event])\n\non_closed_message(sub_id, message):\n  if message.starts_with(\"auth-required:\"):\n    auth_event = create_auth_event(relay.challenge)\n    send([\"AUTH\", auth_event])\n    wait_for_auth_ok()\n    // Retry original subscription\n    send([\"REQ\", sub_id, ...original_filters])\n```\n\n### Rule 9.4: Authentication State\n\nAuthentication persists for the connection lifetime.\n\n**Client tracking:**\n\n```\nrelay_state = {\n  challenge: \"a1b2c3d4e5f6g7h8\",\n  authenticated: false,\n  authenticated_pubkey: null\n}\n\non_auth_ok_received():\n  relay_state.authenticated = true\n  relay_state.authenticated_pubkey = my_pubkey\n```\n\n**Relay tracking:**\n\n```\nclient_state = {\n  challenge: \"a1b2c3d4e5f6g7h8\",\n  authenticated: false,\n  pubkey: null\n}\n\non_valid_auth(pubkey):\n  client_state.authenticated = true\n  client_state.pubkey = pubkey\n```\n\n## 10. Connection Termination\n\n### Rule 10.1: Graceful Shutdown\n\nBoth sides should attempt graceful shutdown when possible.\n\n**Client:**\n\n```\ndisconnect():\n  for subscription in active_subscriptions:\n    send([\"CLOSE\", subscription.id])\n  \n  send_websocket_close_frame()\n  wait_for_close_confirmation(timeout=1000ms)\n  close_websocket()\n```\n\n**Relay:**\n\n```\nshutdown():\n  for client in active_clients:\n    for subscription in client.subscriptions:\n      send_to_client([\"CLOSED\", subscription.id, \"relay shutting down\"])\n    \n    send_websocket_close_frame(client)\n    wait_for_close_confirmation(timeout=1000ms)\n    close_connection(client)\n```\n\n### Rule 10.2: Resource Cleanup\n\nAll resources must be freed on disconnection.\n\n**Client cleanup:**\n\n```\non_disconnect():\n  // Clear all subscriptions\n  for subscription in subscriptions.values():\n    subscription.closed = true\n    invoke_close_callback(\"connection closed\")\n  subscriptions.clear()\n  \n  // Reject pending publishes\n  for publish in pending_publishes.values():\n    publish.promise.reject(\"connection closed\")\n  pending_publishes.clear()\n  \n  // Clear timers\n  clear_all_timeouts()\n  \n  // Reset state\n  state = DISCONNECTED\n  authenticated = false\n```\n\n**Relay cleanup:**\n\n```\non_client_disconnect(client):\n  // Remove all subscriptions\n  for subscription in client.subscriptions:\n    remove_from_subscription_index(subscription)\n  client.subscriptions.clear()\n  \n  // Remove from client registry\n  clients.remove(client.id)\n  \n  // Cancel context\n  client.context.cancel()\n  \n  // Close socket\n  client.websocket.close()\n```\n\n### Rule 10.3: Close Frames\n\nUse standard WebSocket close codes.\n\n**Common close codes:**\n\n- 1000: Normal closure\n- 1001: Going away (client navigating away, server shutting down)\n- 1002: Protocol error\n- 1011: Unexpected condition (internal error)\n\n**Example:**\n\n```\nclose_connection(code, reason):\n  send_close_frame(code, reason)\n  wait_for_close_ack()\n  close_socket()\n```\n\n---\n\n## Optional Features\n\n## Optional: Reconnection Strategy\n\nClients may implement automatic reconnection after disconnection.\n\n### Optional Rule 11.1: Exponential Backoff\n\nUse exponential backoff with a maximum delay.\n\n**Algorithm:**\n\n```\nINITIAL_DELAY = 250  // milliseconds\nMAX_DELAY = 16000    // milliseconds\nattempt_count = 0\n\nreconnect():\n  delay = min(INITIAL_DELAY * (2 ** attempt_count), MAX_DELAY)\n  sleep(delay)\n  try_connect()\n  if success:\n    attempt_count = 0\n  else:\n    attempt_count += 1\n```\n\n**Example progression:**\n\n```\nAttempt 0: 250ms\nAttempt 1: 500ms\nAttempt 2: 1000ms\nAttempt 3: 2000ms\nAttempt 4: 4000ms\nAttempt 5: 8000ms\nAttempt 6+: 16000ms (capped)\n```\n\n### Optional Rule 11.2: Subscription Restoration\n\nReestablish subscriptions after reconnection.\n\n**Pattern:**\n\n```\non_reconnect_success():\n  for subscription in saved_subscriptions:\n    send([\"REQ\", subscription.id, ...subscription.filters])\n```\n\nClients should track active subscriptions separately from connection state to enable restoration.\n\n### Optional Rule 11.3: Event Retry\n\nRetry failed event publishes after reconnection.\n\n**Pattern:**\n\n```\noutbox = []  // Failed events pending retry\n\npublish(event):\n  try:\n    send([\"EVENT\", event])\n    wait_for_ok()\n  catch error:\n    outbox.push(event)\n\non_reconnect_success():\n  for event in outbox:\n    try:\n      send([\"EVENT\", event])\n      wait_for_ok()\n      remove_from_outbox(event)\n    catch error:\n      // Keep in outbox for next attempt\n```\n\n### Optional Rule 11.4: Extended Backoff for Specific Errors\n\nCertain errors warrant longer backoff periods.\n\n**Extended backoff triggers:**\n\n- HTTP 403 Forbidden\n- HTTP 410 Gone\n- HTTP 502 Bad Gateway\n- HTTP 503 Service Unavailable\n\n**Pattern:**\n\n```\non_connection_error(error):\n  if error.code in [403, 410, 502, 503]:\n    delay = EXTENDED_BACKOFF  // e.g., 5 minutes\n  else:\n    delay = calculate_exponential_backoff()\n  \n  sleep(delay)\n  retry_connection()\n```\n\n## Optional: Multi-Relay Coordination\n\nClients may connect to multiple relays simultaneously.\n\n### Optional Rule 12.1: Connection Pooling\n\nReuse relay connections across operations.\n\n**Pattern:**\n\n```\nrelay_pool = Map<url, relay_connection>\n\nget_relay(url):\n  if relay_pool.has(url):\n    return relay_pool.get(url)\n  \n  connection = create_connection(url)\n  relay_pool.set(url, connection)\n  return connection\n\non_relay_disconnect(url):\n  relay_pool.delete(url)\n```\n\n### Optional Rule 12.2: Event Deduplication\n\nTrack which events have been seen across relays.\n\n**Pattern:**\n\n```\nseen_events = Set<event_id>\n\non_event(relay, event):\n  if seen_events.has(event.id):\n    return  // Skip duplicate\n  \n  seen_events.add(event.id)\n  process_event(event)\n```\n\n**With relay tracking:**\n\n```\nevent_sources = Map<event_id, Set<relay_url>>\n\non_event(relay_url, event):\n  if not event_sources.has(event.id):\n    event_sources.set(event.id, new Set())\n    process_event(event)\n  \n  event_sources.get(event.id).add(relay_url)\n```\n\n### Optional Rule 12.3: Subscription Fan-Out\n\nSubscribe to the same filters on multiple relays.\n\n**Pattern:**\n\n```\nsubscribe_multi(relay_urls, filters):\n  subscription_id = generate_id()\n  \n  for url in relay_urls:\n    relay = get_relay(url)\n    relay.send([\"REQ\", subscription_id, ...filters])\n  \n  return subscription_id\n```\n\nAll relays use the same subscription ID for simplified management.\n\n### Optional Rule 12.4: EOSE Batching\n\nWait for EOSE from all relays before marking complete.\n\n**Pattern:**\n\n```\nmulti_subscription = {\n  id: \"sub-1\",\n  relay_urls: [\"wss://relay1.com\", \"wss://relay2.com\", \"wss://relay3.com\"],\n  eose_received: Set()\n}\n\non_eose(relay_url, sub_id):\n  multi_subscription.eose_received.add(relay_url)\n  \n  if multi_subscription.eose_received.size == multi_subscription.relay_urls.length:\n    invoke_complete_callback()\n```\n\n### Optional Rule 12.5: Publish Broadcasting\n\nPublish events to multiple relays and collect results.\n\n**Pattern:**\n\n```\npublish_multi(relay_urls, event):\n  promises = []\n  \n  for url in relay_urls:\n    relay = get_relay(url)\n    promise = relay.publish(event)\n    promises.push(promise)\n  \n  return Promise.all(promises)\n```\n\n**With partial success handling:**\n\n```\npublish_multi(relay_urls, event):\n  results = []\n  \n  for url in relay_urls:\n    relay = get_relay(url)\n    try:\n      await relay.publish(event)\n      results.push({url: url, success: true})\n    catch error:\n      results.push({url: url, success: false, error: error})\n  \n  return results\n```\n\n## Optional: Dynamic Filter Updates\n\nClients may update subscription filters without closing and reopening.\n\n### Optional Rule 13.1: Filter Replacement\n\nSend REQ with same subscription ID but new filters.\n\n**Pattern:**\n\n```\nsubscription = {\n  id: \"sub-1\",\n  filters: [{\"kinds\": [1], \"limit\": 10}]\n}\n\nupdate_filters(new_filters):\n  subscription.filters = new_filters\n  send([\"REQ\", subscription.id, ...new_filters])\n  // Relay treats as new subscription with same ID\n```\n\n### Optional Rule 13.2: Filter Change Detection\n\nOptimize by avoiding resend when filters haven't meaningfully changed.\n\n**Changes that warrant resend:**\n\n- Different event kinds\n- Different authors\n- Different tag filters\n- `since` timestamp moved backward\n- `until` timestamp changed\n- `limit` changed\n\n**Changes that may skip resend:**\n\n- `since` timestamp moved forward (natural progression)\n\n**Example:**\n\n```\nshould_resend_req(old_filters, new_filters):\n  if old_filters.kinds != new_filters.kinds:\n    return true\n  if old_filters.authors != new_filters.authors:\n    return true\n  if old_filters[\"#e\"] != new_filters[\"#e\"]:\n    return true\n  if new_filters.since < old_filters.since:\n    return true  // Looking backward in time\n  if new_filters.until != old_filters.until:\n    return true\n  \n  return false  // Only `since` moved forward\n```\n\n### Optional Rule 13.3: Observable Filters\n\nUse reactive primitives to automatically update subscriptions.\n\n**Pattern:**\n\n```\nfilters_observable = BehaviorSubject([{\"kinds\": [1]}])\n\nsubscribe_reactive(relay, sub_id, filters_observable):\n  filters_observable.subscribe(filters => {\n    relay.send([\"REQ\", sub_id, ...filters])\n  })\n\n// Later, update filters:\nfilters_observable.next([{\"kinds\": [1, 6, 7]}])\n// Subscription automatically updates\n```\n\n## Optional: Message Queuing\n\nClients may queue incoming messages to prevent blocking.\n\n### Optional Rule 14.1: Asynchronous Processing\n\nProcess messages in a non-blocking queue.\n\n**Pattern:**\n\n```\nmessage_queue = []\nprocessing = false\n\non_websocket_message(raw_text):\n  message_queue.push(raw_text)\n  if not processing:\n    start_queue_processor()\n\nasync start_queue_processor():\n  processing = true\n  while message_queue.length > 0:\n    message = message_queue.shift()\n    process_message(message)\n    await yield_thread()  // Allow other tasks to run\n  processing = false\n```\n\n### Optional Rule 14.2: Priority Queuing\n\nProcess certain message types before others.\n\n**Priority order:**\n\n1. EOSE (completes queries)\n2. OK (completes publishes)\n3. CLOSED (frees resources)\n4. EVENT (bulk of traffic)\n5. NOTICE (informational only)\n\n**Pattern:**\n\n```\npriority_queues = {\n  high: [],    // EOSE, OK, CLOSED\n  normal: [],  // EVENT\n  low: []      // NOTICE\n}\n\non_message(raw_text):\n  message_type = extract_type(raw_text)\n  priority = get_priority(message_type)\n  priority_queues[priority].push(raw_text)\n\nprocess_queues():\n  while has_messages():\n    if priority_queues.high.length > 0:\n      process(priority_queues.high.shift())\n    else if priority_queues.normal.length > 0:\n      process(priority_queues.normal.shift())\n    else if priority_queues.low.length > 0:\n      process(priority_queues.low.shift())\n```\n\n## Optional: Performance Optimizations\n\n### Optional Rule 15.1: Fast Event ID Extraction\n\nExtract event ID before full JSON parsing for early deduplication.\n\n**Pattern:**\n\n```\non_event_message(raw_text):\n  // Fast path: extract ID via regex before parsing\n  if raw_text.starts_with('[\"EVENT\"'):\n    event_id = extract_hex64(raw_text, '\"id\"')  // regex scan\n    \n    if already_have_event(event_id):\n      return  // Skip expensive JSON.parse()\n  \n  // Slow path: parse full JSON\n  [type, sub_id, event] = JSON.parse(raw_text)\n  process_event(sub_id, event)\n```\n\n**Regex pattern:**\n\n```\n/\"id\"\\s*:\\s*\"([0-9a-f]{64})\"/\n```\n\n### Optional Rule 15.2: Subscription Ref-Counting\n\nShare underlying subscriptions for identical requests.\n\n**Pattern:**\n\n```\nactive_reqs = Map<sub_id, {count: number, filters: Filter[]}>\n\nsubscribe(sub_id, filters):\n  if active_reqs.has(sub_id):\n    active_reqs.get(sub_id).count += 1\n    return  // Don't send duplicate REQ\n  \n  active_reqs.set(sub_id, {count: 1, filters: filters})\n  send([\"REQ\", sub_id, ...filters])\n\nunsubscribe(sub_id):\n  req = active_reqs.get(sub_id)\n  req.count -= 1\n  \n  if req.count == 0:\n    send([\"CLOSE\", sub_id])\n    active_reqs.delete(sub_id)\n```\n\n### Optional Rule 15.3: Event Validation Caching\n\nCache signature verification results.\n\n**Pattern:**\n\n```\nverified_events = Map<event_id, boolean>\n\nverify_event(event):\n  if verified_events.has(event.id):\n    return verified_events.get(event.id)\n  \n  is_valid = verify_signature(event)\n  verified_events.set(event.id, is_valid)\n  return is_valid\n```\n\n**Cache eviction:**\n\n```\nMAX_CACHE_SIZE = 10000\n\nif verified_events.size > MAX_CACHE_SIZE:\n  // Evict oldest entries\n  remove_oldest_entries(1000)\n```\n\n### Optional Rule 15.4: Trusted Relays\n\nSkip signature verification for known-good relays.\n\n**Pattern:**\n\n```\ntrusted_relay_urls = Set([\n  \"wss://relay1.example.com\",\n  \"wss://relay2.example.com\"\n])\n\non_event(relay_url, event):\n  if trusted_relay_urls.has(relay_url):\n    process_event(event)  // Skip verification\n  else:\n    if verify_event(event):\n      process_event(event)\n```\n\n## Optional: Connection Lifecycle Hooks\n\nImplementations may expose hooks for monitoring and extension.\n\n### Optional Rule 16.1: Connection State Callbacks\n\n**Client hooks:**\n\n```\non_connecting():\n  // Connection attempt started\n  show_loading_indicator()\n\non_connected():\n  // WebSocket open, ready to communicate\n  hide_loading_indicator()\n  restore_subscriptions()\n\non_disconnecting():\n  // Graceful shutdown initiated\n  show_reconnecting_message()\n\non_disconnected():\n  // Connection closed\n  clear_ui_state()\n  schedule_reconnection()\n```\n\n### Optional Rule 16.2: Message Interception\n\n**Relay hooks:**\n\n```\nbefore_event_store(event):\n  // Validate custom rules\n  if not meets_relay_policy(event):\n    return reject(\"policy violation\")\n  return accept()\n\nafter_event_store(event):\n  // Trigger side effects\n  update_search_index(event)\n  notify_external_systems(event)\n\nbefore_subscription_create(filters):\n  // Validate or modify filters\n  if too_broad(filters):\n    return reject(\"filter too broad\")\n  return accept()\n```\n\n### Optional Rule 16.3: Error Callbacks\n\n**Client hooks:**\n\n```\non_error(error):\n  log_to_monitoring(error)\n  show_user_message(error)\n\non_notice(message):\n  log_relay_notice(message)\n  \non_closed(sub_id, reason):\n  log_subscription_closure(sub_id, reason)\n  show_user_notification(reason)\n```\n\n---\n\n## Implementation Notes\n\n### Concurrency Models\n\nThis specification is compatible with multiple concurrency approaches:\n\n**Callback-based:**\n\n```\nrelay.on('event', (sub_id, event) => { ... })\nrelay.on('eose', (sub_id) => { ... })\n```\n\n**Promise-based:**\n\n```\nconst ok = await relay.publish(event)\nconst events = await relay.query(filters)\n```\n\n**Observable-based:**\n\n```\nrelay.subscribe(filters).subscribe(event => { ... })\n```\n\n**Async iterator:**\n\n```\nfor await (const event of relay.subscribe(filters)) { ... }\n```\n\nChoose based on language and platform conventions.\n\n### Memory Management\n\nImplementations must prevent memory leaks:\n\n1. **Remove closed subscriptions** from registries\n2. **Clear timeouts** when operations complete or connections close\n3. **Limit cache sizes** for deduplication and validation\n4. **Clean up on disconnect** - subscriptions, pending publishes, timers\n\n### Thread Safety\n\nIf using threads or processes:\n\n1. **Protect WebSocket writes** with mutex\n2. **Guard shared state** (subscription maps, event caches)\n3. **Use message passing** between connection handler and application logic\n\n### Platform Considerations\n\n**Browser:**\n\n- No native WebSocket ping-pong access\n- Use application-level heartbeat (dummy REQ)\n- Be aware of connection limits (typically 6-30 per domain)\n\n**Node.js:**\n\n- Native ping-pong available via `ws` library\n- Can handle thousands of concurrent connections\n- Use clustering for horizontal scaling\n\n**Mobile:**\n\n- Connections may break when app backgrounds\n- Implement reconnection on app foreground\n- Consider battery impact of keep-alive frequency\n\n**Server (Relay):**\n\n- Must handle high concurrent connection counts\n- Implement connection limits and rate limits\n- Use efficient data structures (swap-delete for O(1) removal)\", sub_id])\n  remove_subscription(sub_id)\n```\n\n**Relay-initiated:**\n\n```\nclose_subscription(client, sub_id, reason):\n  send_to_client([\"CLOSED\", sub_id, reason])\n  remove_subscription(sub_id)\n```\n\n**Example reasons for relay-initiated closure:**\n\n- Rate limit exceeded\n- Filter policy violation\n- Authentication required\n- Relay shutting down\n\n### Rule 5.5: Subscription State Tracking\n\nClients should track whether EOSE has been received for each subscription.\n\n**State:**\n\n```\nsubscription = {\n  id: \"sub-1\",\n  filters: [{...}],\n  eosed: false,  // set to true when EOSE received\n  closed: false  // set to true when CLOSE or CLOSED sent\n}\n```\n\nThis allows clients to distinguish stored events from real-time events.\n\n## 6. Event Publishing\n\n### Rule 6.1: Publishing Flow\n\n**Client:**\n\n```\npublish(event):\n  promise = create_promise()\n  timeout = set_timeout(PUBLISH_TIMEOUT, () => {\n    promise.reject(\"timeout\")\n  })\n  \n  pending_publishes.set(event.id, {promise, timeout})\n  send([\"EVENT\", event])\n  \n  return promise\n```\n\n**Relay:**\n\n```\non_event_message(client, event):\n  validation_result = validate_and_store(event)\n  \n  if validation_result.accepted:\n    forward_to_subscriptions(event)\n    send_to_client([\"OK\", event.id, true, \"\"])\n  else:\n    send_to_client([\"OK\", event.id, false, validation_result.reason])\n```\n\n### Rule 6.2: Publish Timeout\n\nClients should implement timeouts for event publishing.\n\n**Recommended timeout:** 4000-5000 milliseconds\n\n**Behavior:**\n\n```\nPUBLISH_TIMEOUT = 4400  // milliseconds\n\npublish_with_timeout(event):\n  promise = publish(event)\n  timeout_promise = sleep(PUBLISH_TIMEOUT).then(() => {\n    throw \"publish timeout\"\n  })\n  return race(promise, timeout_promise)\n```\n\n### Rule 6.3: OK Message Matching\n\nClients must match OK messages to pending publishes by event ID.\n\n**Example:**\n\n```\nClient publishes:\n  event_a = {id: \"aaa...\", ...}\n  event_b = {id: \"bbb...\", ...}\n  \nSend:\n  [\"EVENT\", event_a]\n  [\"EVENT\", event_b]\n\nReceive (order may vary):\n  [\"OK\", \"bbb...\", true, \"\"]\n  [\"OK\", \"aaa...\", false, \"duplicate: already have this event\"]\n\nMatch OK to original publish promise by event ID\n```\n\n## 7. Connection Health Monitoring\n\n### Rule 7.1: Ping-Pong Protocol (Server Environments)\n\nIn environments with native WebSocket ping support (Node.js, Go, etc.), implementations should use WebSocket ping frames.\n\n**Relay:**\n\n```\non_connect(client):\n  start_ping_timer(client)\n\nping_timer():\n  every PING_INTERVAL:\n    send_websocket_ping()\n    wait_for_pong(PONG_TIMEOUT)\n    if no_pong_received:\n      close_connection(\"ping timeout\")\n```\n\n**Recommended timings:**\n\n- PING_INTERVAL: 20-30 seconds\n- PONG_TIMEOUT: 20-30 seconds\n\n### Rule 7.2: Application-Level Keep-Alive (Browser)\n\nBrowsers don't expose WebSocket ping/pong. Use application-level heartbeat.\n\n**Client pattern:**\n\n```\nstart_heartbeat():\n  every HEARTBEAT_INTERVAL:\n    // Send dummy REQ for impossible event\n    nonce = random_hex(64)\n    send([\"REQ\", \"ping-\" + nonce, {\"ids\": [nonce]}])\n    wait_for_eose(HEARTBEAT_TIMEOUT)\n    if no_eose_received:\n      close_connection(\"heartbeat timeout\")\n    send([\"CLOSE\", \"ping-\" + nonce])\n```\n\nThe dummy REQ uses an ID that cannot exist (random 64-char hex), so relay responds with immediate EOSE.\n\n### Rule 7.3: Read Deadline Management\n\nRelays should set read deadlines on WebSocket connections.\n\n**Pattern:**\n\n```\non_connect(client):\n  set_read_deadline(now() + READ_DEADLINE)\n\non_message(client, message):\n  reset_read_deadline(now() + READ_DEADLINE)\n\non_read_deadline_exceeded(client):\n  close_connection(client)\n```\n\n**Recommended READ_DEADLINE:** 60 seconds\n\n### Rule 7.4: Idle Connection Timeout\n\nRelays may close idle connections after a period of inactivity.\n\n**Optional behavior:**\n\n```\nIDLE_TIMEOUT = 30 seconds\n\non_connect(client):\n  set_idle_timer(client)\n\non_any_message(client):\n  reset_idle_timer(client)\n\non_idle_timeout(client):\n  close_connection(client)\n```\n\nThis applies to non-WebSocket HTTP connections only. WebSocket connections use ping-pong or read deadline instead.\n\n## 8. Error Handling\n\n### Rule 8.1: Connection Errors\n\nWhen a WebSocket error occurs, both sides must clean up resources.\n\n**Client:**\n\n```\non_websocket_error(error):\n  log_error(error)\n  close_all_subscriptions(\"connection error\")\n  reject_all_pending_publishes(\"connection error\")\n  set_state(DISCONNECTED)\n```\n\n**Relay:**\n\n```\non_websocket_error(client, error):\n  log_error(error)\n  close_all_client_subscriptions(client)\n  remove_client_from_registry(client)\n```\n\n### Rule 8.2: Protocol Errors\n\nInvalid messages should trigger NOTICE but not close the connection.\n\n**Relay behavior:**\n\n```\non_invalid_json(client, raw_text):\n  send_to_client([\"NOTICE\", \"invalid JSON\"])\n  // Keep connection alive\n\non_unknown_message_type(client, message_type):\n  send_to_client([\"NOTICE\", \"unknown message type: \" + message_type])\n  // Keep connection alive\n\non_malformed_message(client, error):\n  send_to_client([\"NOTICE\", \"malformed message: \" + error])\n  // Keep connection alive\n```\n\n### Rule 8.3: Rate Limiting\n\nRelays may rate-limit clients at three levels.\n\n**Connection level:**\n\n```\non_connection_attempt(ip_address):\n  if exceeds_connection_rate_limit(ip_address):\n    return HTTP 429 \"Too Many Requests\"\n  accept_connection()\n```\n\n**Subscription level:**\n\n```\non_req_message(client, sub_id, filters):\n  if exceeds_subscription_rate_limit(client):\n    send([\"CLOSED\", sub_id, \"rate limited: too many subscriptions\"])\n    return\n  create_subscription(sub_id, filters)\n```\n\n**Event level:**\n\n```\non_event_message(client, event):\n  if exceeds_event_rate_limit(client):\n    send([\"OK\", event.id, false, \"rate limited: too many events\"])\n    return\n  store_event(event)\n```\n\n**Example rate limits:**\n\n- Connections: 1 per IP per 5 minutes\n- Subscriptions: 20 per client per minute\n- Events: 2 per client per 3 minutes\n\n### Rule 8.4: CLOSED Message Handling\n\nWhen a client receives CLOSED, it must stop expecting events for that subscription.\n\n**Client:**\n\n```\non_closed_message(sub_id, reason):\n  subscription = subscriptions.get(sub_id)\n  if subscription:\n    subscription.closed = true\n    clear_eose_timeout(sub_id)\n    invoke_close_callback(reason)\n    subscriptions.delete(sub_id)\n```\n\n**Common CLOSED reasons:**\n\n```\n\"rate limited: too many subscriptions\"\n\"invalid: filter too broad\"\n\"auth-required: this relay requires authentication\"\n\"error: internal relay error\"\n```\n\n### Rule 8.5: Unmatched Message Handling\n\nClients should ignore messages for unknown subscription IDs.\n\n**Pattern:**\n\n```\non_event_message(sub_id, event):\n  if not subscriptions.has(sub_id):\n    // Silently discard - subscription may have been closed\n    return\n  process_event(sub_id, event)\n\non_eose_message(sub_id):\n  if not subscriptions.has(sub_id):\n    return\n  mark_eosed(sub_id)\n```\n\nDo not log warnings or errors for unmatched messages. They may arrive after CLOSE due to network timing.\n\n## 9. Authentication (NIP-42)\n\n### Rule 9.1: AUTH Challenge\n\nRelays may require authentication for certain operations.\n\n**Relay sends challenge:**\n\n```\non_connect(client):\n  challenge = random_hex(16)\n  client.auth_challenge = challenge\n  send_to_client([\"AUTH\", challenge])\n```\n\n**Example:**\n\n```json\n[\"AUTH\", \"a1b2c3d4e5f6g7h8\"]\n```\n\n### Rule 9.2: AUTH Response\n\nClients respond with a signed kind 22242 event.\n\n**Event structure:**\n\n```json\n{\n  \"kind\": 22242,\n  \"tags\": [\n    [\"relay\", <relay-url>],\n    [\"challenge\", <challenge-string>]\n  ],\n  \"content\": \"\",\n  \"created_at\": <current-timestamp>,\n  \"pubkey\": <client-pubkey>,\n  \"id\": <computed-id>,\n  \"sig\": <signature>\n}\n```\n\n**Example:**\n\n```json\n[\"AUTH\", {\n  \"kind\": 22242,\n  \"tags\": [\n    [\"relay\", \"wss://relay.example.com\"],\n    [\"challenge\", \"a1b2c3d4e5f6g7h8\"]\n  ],\n  \"content\": \"\",\n  \"created_at\": 1673347337,\n  \"pubkey\": \"6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93\",\n  \"id\": \"f8c...\",\n  \"sig\": \"a3d...\"\n}]\n```\n\n**Relay validation:**\n\n```\non_auth_message(client, signed_event):\n  if signed_event.kind != 22242:\n    send([\"OK\", signed_event.id, false, \"error: wrong event kind\"])\n    return\n  \n  if not verify_signature(signed_event):\n    send([\"OK\", signed_event.id, false, \"error: invalid signature\"])\n    return\n  \n  challenge_tag = find_tag(signed_event.tags, \"challenge\")\n  if challenge_tag[1] != client.auth_challenge:\n    send([\"OK\", signed_event.id, false, \"error: challenge mismatch\"])\n    return\n  \n  client.authenticated_pubkey = signed_event.pubkey\n  send([\"OK\", signed_event.id, true, \"\"])\n```\n\n### Rule 9.3: Auth-Required Error Pattern\n\nRelays indicate authentication requirements via OK or CLOSED messages prefixed with \"auth-required:\".\n\n**For events:**\n\n```json\n[\"OK\", <event-id>, false, \"auth-required: this relay requires authentication for writes\"]\n```\n\n**For subscriptions:**\n\n```json\n[\"CLOSED\", <sub-id>, \"auth-required: authentication required for this subscription\"]\n```\n\n**Client retry pattern:**\n\n```\non_ok_rejected(event_id, message):\n  if message.starts_with(\"auth-required:\"):\n    auth_event = create_auth_event(relay.challenge)\n    send([\"AUTH\", auth_event])\n    wait_for_auth_ok()\n    // Retry original event\n    send([\"EVENT\", original_event])\n\non_closed_message(sub_id, message):\n  if message.starts_with(\"auth-required:\"):\n    auth_event = create_auth_event(relay.challenge)\n    send([\"AUTH\", auth_event])\n    wait_for_auth_ok()\n    // Retry original subscription\n    send([\"REQ\", sub_id, ...original_filters])\n```\n\n### Rule 9.4: Authentication State\n\nAuthentication persists for the connection lifetime.\n\n**Client tracking:**\n\n```\nrelay_state = {\n  challenge: \"a1b2c3d4e5f6g7h8\",\n  authenticated: false,\n  authenticated_pubkey: null\n}\n\non_auth_ok_received():\n  relay_state.authenticated = true\n  relay_state.authenticated_pubkey = my_pubkey\n```\n\n**Relay tracking:**\n\n```\nclient_state = {\n  challenge: \"a1b2c3d4e5f6g7h8\",\n  authenticated: false,\n  pubkey: null\n}\n\non_valid_auth(pubkey):\n  client_state.authenticated = true\n  client_state.pubkey = pubkey\n```\n\n## 10. Connection Termination\n\n### Rule 10.1: Graceful Shutdown\n\nBoth sides should attempt graceful shutdown when possible.\n\n**Client:**\n\n```\ndisconnect():\n  for subscription in active_subscriptions:\n    send([\"CLOSE\", subscription.id])\n  \n  send_websocket_close_frame()\n  wait_for_close_confirmation(timeout=1000ms)\n  close_websocket()\n```\n\n**Relay:**\n\n```\nshutdown():\n  for client in active_clients:\n    for subscription in client.subscriptions:\n      send_to_client([\"CLOSED\", subscription.id, \"relay shutting down\"])\n    \n    send_websocket_close_frame(client)\n    wait_for_close_confirmation(timeout=1000ms)\n    close_connection(client)\n```\n\n### Rule 10.2: Resource Cleanup\n\nAll resources must be freed on disconnection.\n\n**Client cleanup:**\n\n```\non_disconnect():\n  // Clear all subscriptions\n  for subscription in subscriptions.values():\n    subscription.closed = true\n    invoke_close_callback(\"connection closed\")\n  subscriptions.clear()\n  \n  // Reject pending publishes\n  for publish in pending_publishes.values():\n    publish.promise.reject(\"connection closed\")\n  pending_publishes.clear()\n  \n  // Clear timers\n  clear_all_timeouts()\n  \n  // Reset state\n  state = DISCONNECTED\n  authenticated = false\n```\n\n**Relay cleanup:**\n\n```\non_client_disconnect(client):\n  // Remove all subscriptions\n  for subscription in client.subscriptions:\n    remove_from_subscription_index(subscription)\n  client.subscriptions.clear()\n  \n  // Remove from client registry\n  clients.remove(client.id)\n  \n  // Cancel context\n  client.context.cancel()\n  \n  // Close socket\n  client.websocket.close()\n```\n\n### Rule 10.3: Close Frames\n\nUse standard WebSocket close codes.\n\n**Common close codes:**\n\n- 1000: Normal closure\n- 1001: Going away (client navigating away, server shutting down)\n- 1002: Protocol error\n- 1011: Unexpected condition (internal error)\n\n**Example:**\n\n```\nclose_connection(code, reason):\n  send_close_frame(code, reason)\n  wait_for_close_ack()\n  close_socket()\n```\n\n---\n\n## Optional Features\n\n## Optional: Reconnection Strategy\n\nClients may implement automatic reconnection after disconnection.\n\n### Optional Rule 11.1: Exponential Backoff\n\nUse exponential backoff with a maximum delay.\n\n**Algorithm:**\n\n```\nINITIAL_DELAY = 250  // milliseconds\nMAX_DELAY = 16000    // milliseconds\nattempt_count = 0\n\nreconnect():\n  delay = min(INITIAL_DELAY * (2 ** attempt_count), MAX_DELAY)\n  sleep(delay)\n  try_connect()\n  if success:\n    attempt_count = 0\n  else:\n    attempt_count += 1\n```\n\n**Example progression:**\n\n```\nAttempt 0: 250ms\nAttempt 1: 500ms\nAttempt 2: 1000ms\nAttempt 3: 2000ms\nAttempt 4: 4000ms\nAttempt 5: 8000ms\nAttempt 6+: 16000ms (capped)\n```\n\n### Optional Rule 11.2: Subscription Restoration\n\nReestablish subscriptions after reconnection.\n\n**Pattern:**\n\n```\non_reconnect_success():\n  for subscription in saved_subscriptions:\n    send([\"REQ\", subscription.id, ...subscription.filters])\n```\n\nClients should track active subscriptions separately from connection state to enable restoration.\n\n### Optional Rule 11.3: Event Retry\n\nRetry failed event publishes after reconnection.\n\n**Pattern:**\n\n```\noutbox = []  // Failed events pending retry\n\npublish(event):\n  try:\n    send([\"EVENT\", event])\n    wait_for_ok()\n  catch error:\n    outbox.push(event)\n\non_reconnect_success():\n  for event in outbox:\n    try:\n      send([\"EVENT\", event])\n      wait_for_ok()\n      remove_from_outbox(event)\n    catch error:\n      // Keep in outbox for next attempt\n```\n\n### Optional Rule 11.4: Extended Backoff for Specific Errors\n\nCertain errors warrant longer backoff periods.\n\n**Extended backoff triggers:**\n\n- HTTP 403 Forbidden\n- HTTP 410 Gone\n- HTTP 502 Bad Gateway\n- HTTP 503 Service Unavailable\n\n**Pattern:**\n\n```\non_connection_error(error):\n  if error.code in [403, 410, 502, 503]:\n    delay = EXTENDED_BACKOFF  // e.g., 5 minutes\n  else:\n    delay = calculate_exponential_backoff()\n  \n  sleep(delay)\n  retry_connection()\n```\n\n## Optional: Multi-Relay Coordination\n\nClients may connect to multiple relays simultaneously.\n\n### Optional Rule 12.1: Connection Pooling\n\nReuse relay connections across operations.\n\n**Pattern:**\n\n```\nrelay_pool = Map<url, relay_connection>\n\nget_relay(url):\n  if relay_pool.has(url):\n    return relay_pool.get(url)\n  \n  connection = create_connection(url)\n  relay_pool.set(url, connection)\n  return connection\n\non_relay_disconnect(url):\n  relay_pool.delete(url)\n```\n\n### Optional Rule 12.2: Event Deduplication\n\nTrack which events have been seen across relays.\n\n**Pattern:**\n\n```\nseen_events = Set<event_id>\n\non_event(relay, event):\n  if seen_events.has(event.id):\n    return  // Skip duplicate\n  \n  seen_events.add(event.id)\n  process_event(event)\n```\n\n**With relay tracking:**\n\n```\nevent_sources = Map<event_id, Set<relay_url>>\n\non_event(relay_url, event):\n  if not event_sources.has(event.id):\n    event_sources.set(event.id, new Set())\n    process_event(event)\n  \n  event_sources.get(event.id).add(relay_url)\n```\n\n### Optional Rule 12.3: Subscription Fan-Out\n\nSubscribe to the same filters on multiple relays.\n\n**Pattern:**\n\n```\nsubscribe_multi(relay_urls, filters):\n  subscription_id = generate_id()\n  \n  for url in relay_urls:\n    relay = get_relay(url)\n    relay.send([\"REQ\", subscription_id, ...filters])\n  \n  return subscription_id\n```\n\nAll relays use the same subscription ID for simplified management.\n\n### Optional Rule 12.4: EOSE Batching\n\nWait for EOSE from all relays before marking complete.\n\n**Pattern:**\n\n```\nmulti_subscription = {\n  id: \"sub-1\",\n  relay_urls: [\"wss://relay1.com\", \"wss://relay2.com\", \"wss://relay3.com\"],\n  eose_received: Set()\n}\n\non_eose(relay_url, sub_id):\n  multi_subscription.eose_received.add(relay_url)\n  \n  if multi_subscription.eose_received.size == multi_subscription.relay_urls.length:\n    invoke_complete_callback()\n```\n\n### Optional Rule 12.5: Publish Broadcasting\n\nPublish events to multiple relays and collect results.\n\n**Pattern:**\n\n```\npublish_multi(relay_urls, event):\n  promises = []\n  \n  for url in relay_urls:\n    relay = get_relay(url)\n    promise = relay.publish(event)\n    promises.push(promise)\n  \n  return Promise.all(promises)\n```\n\n**With partial success handling:**\n\n```\npublish_multi(relay_urls, event):\n  results = []\n  \n  for url in relay_urls:\n    relay = get_relay(url)\n    try:\n      await relay.publish(event)\n      results.push({url: url, success: true})\n    catch error:\n      results.push({url: url, success: false, error: error})\n  \n  return results\n```\n\n## Optional: Dynamic Filter Updates\n\nClients may update subscription filters without closing and reopening.\n\n### Optional Rule 13.1: Filter Replacement\n\nSend REQ with same subscription ID but new filters.\n\n**Pattern:**\n\n```\nsubscription = {\n  id: \"sub-1\",\n  filters: [{\"kinds\": [1], \"limit\": 10}]\n}\n\nupdate_filters(new_filters):\n  subscription.filters = new_filters\n  send([\"REQ\", subscription.id, ...new_filters])\n  // Relay treats as new subscription with same ID\n```\n\n### Optional Rule 13.2: Filter Change Detection\n\nOptimize by avoiding resend when filters haven't meaningfully changed.\n\n**Changes that warrant resend:**\n\n- Different event kinds\n- Different authors\n- Different tag filters\n- `since` timestamp moved backward\n- `until` timestamp changed\n- `limit` changed\n\n**Changes that may skip resend:**\n\n- `since` timestamp moved forward (natural progression)\n\n**Example:**\n\n```\nshould_resend_req(old_filters, new_filters):\n  if old_filters.kinds != new_filters.kinds:\n    return true\n  if old_filters.authors != new_filters.authors:\n    return true\n  if old_filters[\"#e\"] != new_filters[\"#e\"]:\n    return true\n  if new_filters.since < old_filters.since:\n    return true  // Looking backward in time\n  if new_filters.until != old_filters.until:\n    return true\n  \n  return false  // Only `since` moved forward\n```\n\n### Optional Rule 13.3: Observable Filters\n\nUse reactive primitives to automatically update subscriptions.\n\n**Pattern:**\n\n```\nfilters_observable = BehaviorSubject([{\"kinds\": [1]}])\n\nsubscribe_reactive(relay, sub_id, filters_observable):\n  filters_observable.subscribe(filters => {\n    relay.send([\"REQ\", sub_id, ...filters])\n  })\n\n// Later, update filters:\nfilters_observable.next([{\"kinds\": [1, 6, 7]}])\n// Subscription automatically updates\n```\n\n## Optional: Message Queuing\n\nClients may queue incoming messages to prevent blocking.\n\n### Optional Rule 14.1: Asynchronous Processing\n\nProcess messages in a non-blocking queue.\n\n**Pattern:**\n\n```\nmessage_queue = []\nprocessing = false\n\non_websocket_message(raw_text):\n  message_queue.push(raw_text)\n  if not processing:\n    start_queue_processor()\n\nasync start_queue_processor():\n  processing = true\n  while message_queue.length > 0:\n    message = message_queue.shift()\n    process_message(message)\n    await yield_thread()  // Allow other tasks to run\n  processing = false\n```\n\n### Optional Rule 14.2: Priority Queuing\n\nProcess certain message types before others.\n\n**Priority order:**\n\n1. EOSE (completes queries)\n2. OK (completes publishes)\n3. CLOSED (frees resources)\n4. EVENT (bulk of traffic)\n5. NOTICE (informational only)\n\n**Pattern:**\n\n```\npriority_queues = {\n  high: [],    // EOSE, OK, CLOSED\n  normal: [],  // EVENT\n  low: []      // NOTICE\n}\n\non_message(raw_text):\n  message_type = extract_type(raw_text)\n  priority = get_priority(message_type)\n  priority_queues[priority].push(raw_text)\n\nprocess_queues():\n  while has_messages():\n    if priority_queues.high.length > 0:\n      process(priority_queues.high.shift())\n    else if priority_queues.normal.length > 0:\n      process(priority_queues.normal.shift())\n    else if priority_queues.low.length > 0:\n      process(priority_queues.low.shift())\n```\n\n## Optional: Performance Optimizations\n\n### Optional Rule 15.1: Fast Event ID Extraction\n\nExtract event ID before full JSON parsing for early deduplication.\n\n**Pattern:**\n\n```\non_event_message(raw_text):\n  // Fast path: extract ID via regex before parsing\n  if raw_text.starts_with('[\"EVENT\"'):\n    event_id = extract_hex64(raw_text, '\"id\"')  // regex scan\n    \n    if already_have_event(event_id):\n      return  // Skip expensive JSON.parse()\n  \n  // Slow path: parse full JSON\n  [type, sub_id, event] = JSON.parse(raw_text)\n  process_event(sub_id, event)\n```\n\n**Regex pattern:**\n\n```\n/\"id\"\\s*:\\s*\"([0-9a-f]{64})\"/\n```\n\n### Optional Rule 15.2: Subscription Ref-Counting\n\nShare underlying subscriptions for identical requests.\n\n**Pattern:**\n\n```\nactive_reqs = Map<sub_id, {count: number, filters: Filter[]}>\n\nsubscribe(sub_id, filters):\n  if active_reqs.has(sub_id):\n    active_reqs.get(sub_id).count += 1\n    return  // Don't send duplicate REQ\n  \n  active_reqs.set(sub_id, {count: 1, filters: filters})\n  send([\"REQ\", sub_id, ...filters])\n\nunsubscribe(sub_id):\n  req = active_reqs.get(sub_id)\n  req.count -= 1\n  \n  if req.count == 0:\n    send([\"CLOSE\", sub_id])\n    active_reqs.delete(sub_id)\n```\n\n### Optional Rule 15.3: Event Validation Caching\n\nCache signature verification results.\n\n**Pattern:**\n\n```\nverified_events = Map<event_id, boolean>\n\nverify_event(event):\n  if verified_events.has(event.id):\n    return verified_events.get(event.id)\n  \n  is_valid = verify_signature(event)\n  verified_events.set(event.id, is_valid)\n  return is_valid\n```\n\n**Cache eviction:**\n\n```\nMAX_CACHE_SIZE = 10000\n\nif verified_events.size > MAX_CACHE_SIZE:\n  // Evict oldest entries\n  remove_oldest_entries(1000)\n```\n\n### Optional Rule 15.4: Trusted Relays\n\nSkip signature verification for known-good relays.\n\n**Pattern:**\n\n```\ntrusted_relay_urls = Set([\n  \"wss://relay1.example.com\",\n  \"wss://relay2.example.com\"\n])\n\non_event(relay_url, event):\n  if trusted_relay_urls.has(relay_url):\n    process_event(event)  // Skip verification\n  else:\n    if verify_event(event):\n      process_event(event)\n```\n\n## Optional: Connection Lifecycle Hooks\n\nImplementations may expose hooks for monitoring and extension.\n\n### Optional Rule 16.1: Connection State Callbacks\n\n**Client hooks:**\n\n```\non_connecting():\n  // Connection attempt started\n  show_loading_indicator()\n\non_connected():\n  // WebSocket open, ready to communicate\n  hide_loading_indicator()\n  restore_subscriptions()\n\non_disconnecting():\n  // Graceful shutdown initiated\n  show_reconnecting_message()\n\non_disconnected():\n  // Connection closed\n  clear_ui_state()\n  schedule_reconnection()\n```\n\n### Optional Rule 16.2: Message Interception\n\n**Relay hooks:**\n\n```\nbefore_event_store(event):\n  // Validate custom rules\n  if not meets_relay_policy(event):\n    return reject(\"policy violation\")\n  return accept()\n\nafter_event_store(event):\n  // Trigger side effects\n  update_search_index(event)\n  notify_external_systems(event)\n\nbefore_subscription_create(filters):\n  // Validate or modify filters\n  if too_broad(filters):\n    return reject(\"filter too broad\")\n  return accept()\n```\n\n### Optional Rule 16.3: Error Callbacks\n\n**Client hooks:**\n\n```\non_error(error):\n  log_to_monitoring(error)\n  show_user_message(error)\n\non_notice(message):\n  log_relay_notice(message)\n  \non_closed(sub_id, reason):\n  log_subscription_closure(sub_id, reason)\n  show_user_notification(reason)\n```\n\n---\n\n## Implementation Notes\n\n### Concurrency Models\n\nThis specification is compatible with multiple concurrency approaches:\n\n**Callback-based:**\n\n```\nrelay.on('event', (sub_id, event) => { ... })\nrelay.on('eose', (sub_id) => { ... })\n```\n\n**Promise-based:**\n\n```\nconst ok = await relay.publish(event)\nconst events = await relay.query(filters)\n```\n\n**Observable-based:**\n\n```\nrelay.subscribe(filters).subscribe(event => { ... })\n```\n\n**Async iterator:**\n\n```\nfor await (const event of relay.subscribe(filters)) { ... }\n```\n\nChoose based on language and platform conventions.\n\n### Memory Management\n\nImplementations must prevent memory leaks:\n\n1. **Remove closed subscriptions** from registries\n2. **Clear timeouts** when operations complete or connections close\n3. **Limit cache sizes** for deduplication and validation\n4. **Clean up on disconnect** - subscriptions, pending publishes, timers\n\n### Thread Safety\n\nIf using threads or processes:\n\n1. **Protect WebSocket writes** with mutex\n2. **Guard shared state** (subscription maps, event caches)\n3. **Use message passing** between connection handler and application logic\n\n### Platform Considerations\n\n**Browser:**\n\n- No native WebSocket ping-pong access\n- Use application-level heartbeat (dummy REQ)\n- Be aware of connection limits (typically 6-30 per domain)\n\n**Node.js:**\n\n- Native ping-pong available via `ws` library\n- Can handle thousands of concurrent connections\n- Use clustering for horizontal scaling\n\n**Mobile:**\n\n- Connections may break when app backgrounds\n- Implement reconnection on app foreground\n- Consider battery impact of keep-alive frequency\n\n**Server (Relay):**\n\n- Must handle high concurrent connection counts\n- Implement connection limits and rate limits\n- Use efficient data structures (swap-delete for O(1) removal)","sig":"f13ec05a016f2338743c2720fcfbd2e179a2673df0f16a3fb4c87a6b1478bdc2048334dc0dfd7ef1b6098561050193f22d5dff0ca25372469670039fc0c202f1"}