Files
udp_nostr/docs-ideas/udp_nostr_relay_plan.md
2026-08-12 16:34:22 -04:00

8.3 KiB

UDP Nostr Relay — Architecture Plan

Overview

A web page where a user signs in with their Nostr key, creates a message, and sends it as a single UDP datagram to a relay. The relay validates the signature, stores the event, and serves it to subscribers via the standard Nostr WebSocket protocol.


Key Constraints Discovered

1. Browsers Cannot Send UDP

This is the critical constraint. Browsers have no JavaScript API for sending raw UDP datagrams to arbitrary hosts. The only UDP-like capability is WebRTC, which is designed for peer-to-peer media streams via ICE/STUN/TURN — not for firing a single datagram at a server.

Implication: The web page cannot send UDP directly. It must send the signed event to a bridge via HTTP or WebSocket, and the bridge fires the UDP datagram to the relay.

2. TCP and UDP Ports Are Separate Namespaces

A server can have both a TCP listener and a UDP listener on the same port number without conflict. This is how QUIC/HTTP3 works — it's UDP on port 443 alongside the HTTPS server on TCP port 443.

Implication: You can run a UDP Nostr receiver on port 443 even if your web server (nginx, caddy) is already using TCP port 443. They do not interfere.

3. The Relay Must Serve Subscribers via WebSocket

Nostr relays communicate with subscribers via WebSocket (NIP-01). The UDP receiver is just the ingress path. The relay still needs a WebSocket server for subscribers to connect and receive events.


Architecture

flowchart LR
    subgraph "User's Browser"
        WP[Web Page<br/>Nostr sign-in + compose]
        EXT[Nostr Extension<br/>nos2x / Alby / nsec]
    end
    subgraph "Relay Server"
        BR[Bridge<br/>HTTP endpoint<br/>receives signed event]
        UDPR[UDP Receiver<br/>port 443 or 8888<br/>listens for datagrams]
        VAL[Signature Validator<br/>verifies event sig]
        STORE[Event Store<br/>in-memory array]
        WS[WebSocket Server<br/>port 8008<br/>Nostr relay protocol]
    end
    subgraph "Subscriber"
        SUB[Nostr client<br/>connects via WebSocket]
    end

    WP -- "HTTP POST /publish" --> BR
    WP -- "sign with nsec" --> EXT
    BR -- "fire UDP datagram" --> UDPR
    UDPR -- "raw event bytes" --> VAL
    VAL -- "valid event" --> STORE
    STORE -- "new event" --> WS
    WS -- "NIP-01 EVENT message" --> SUB

    style BR fill:#48a,color:#fff
    style UDPR fill:#4a4,color:#fff
    style WS fill:#a84,color:#fff

Data Flow

  1. User composes message in the web page, signs it with their Nostr key (via browser extension or in-page nsec)
  2. Web page POSTs the signed JSON event to the bridge HTTP endpoint
  3. Bridge fires the event as a single UDP datagram to the relay's UDP receiver (could be localhost or a remote address)
  4. UDP Receiver receives the datagram, checks it fits in one packet (under 1472 bytes)
  5. Signature Validator verifies the Schnorr signature against the pubkey in the event
  6. Event Store appends the valid event to an in-memory array
  7. WebSocket Server broadcasts the new event to all connected subscribers per NIP-01

Component Specifications

1. Web Page (udp_nostr_web/)

Files:

  • index.html — The page layout
  • app.js — Nostr key management, event creation, signing, HTTP POST
  • style.css — Minimal styling

Capabilities:

  • Sign in with nsec (text input) or browser extension (nos2x/Alby via window.nostr)
  • Compose a text message
  • Create a Nostr event (kind 1) with the message as content
  • Sign the event
  • POST the signed JSON to the bridge at /publish
  • Display the response (success/failure, event ID, size check)

Constraints enforced client-side:

  • Warn if content exceeds ~1135 characters (would exceed 1472 byte UDP limit)
  • Show the total JSON size before sending

2. Bridge + UDP Receiver + Relay (Single Node.js Process)

File: udp_nostr_relay.js

This is a single process that combines three roles:

A. HTTP Bridge (port 3000)

Endpoint Method Body Response
/publish POST Signed Nostr event JSON { ok: true, id, size } or { ok: false, error }

On receiving a valid event:

  1. Validates the JSON structure (required fields present)
  2. Fires the event as a UDP datagram to the UDP receiver (localhost:UDPPORT)
  3. Sets the DF flag (Don't Fragment) to ensure single-packet delivery
  4. Returns success/failure to the browser

B. UDP Receiver (port 443 or 8888)

  • Listens for UDP datagrams
  • Checks size ≤ 1472 bytes (single-packet guarantee)
  • Parses JSON
  • Validates required Nostr fields
  • Verifies the Schnorr signature using the pubkey in the event
  • If valid: stores the event and broadcasts to WebSocket subscribers
  • If invalid: silently drops (no response — no handshake to probe)

C. WebSocket Server (port 8008)

  • Implements the Nostr relay protocol (NIP-01 subset):
    • REQ message: subscriber requests events (returns stored events + streams new ones)
    • EVENT message: subscriber publishes an event (alternative to UDP path)
    • CLOSE message: subscriber unsubscribes
  • On new valid event (from UDP or WebSocket), broadcasts EVENT to all matching subscribers

3. Signature Verification

Uses the @noble/secp256k1 library (pure JS, no native deps) to verify Schnorr signatures:

event_id = sha256(serialized_event_without_id_and_sig)
valid = schnorr_verify(event_id, sig, pubkey)

The event ID is computed from the serialized event fields [0, pubkey, created_at, kind, tags, content] per NIP-01.


Port Strategy

Port Protocol Service Notes
443 UDP Nostr UDP Receiver Blends with QUIC/HTTP3 traffic. Does NOT conflict with TCP 443 (HTTPS).
3000 TCP HTTP Bridge Receives events from the web page. Could also be 443 if using a reverse proxy.
8008 TCP WebSocket Relay Standard Nostr relay port. Subscribers connect here.

Why Port 443 for UDP?

  • Censorship resistance: UDP on port 443 is indistinguishable from QUIC/HTTP3 traffic to a passive observer. Blocking it would break a significant portion of the web.
  • No conflict: TCP and UDP have separate port namespaces. Your HTTPS server (nginx on TCP 443) and the UDP Nostr receiver (UDP 443) coexist peacefully.
  • Practical consideration: Some cloud providers and firewalls may block UDP on non-standard ports. Port 443 is universally allowed.

Tradeoff

If you already have a service using UDP on port 443 (e.g., a QUIC-enabled web server), you cannot have two UDP listeners on the same port. In that case, use a nearby port like 4443 or 8888.


Implementation Plan

Step 1: Create the Relay (udp_nostr_relay.js)

A single Node.js process with three components:

udp_nostr_relay/
├── relay.js          # Main entry point — starts all three servers
├── package.json      # Dependencies: @noble/secp256k1, ws
├── udp_receiver.js   # UDP listener, signature validation, event storage
├── http_bridge.js    # HTTP endpoint for browser POST
├── ws_relay.js       # WebSocket Nostr relay for subscribers
└── event_store.js    # In-memory event storage with subscription matching

Step 2: Create the Web Page (udp_nostr_web/)

udp_nostr_web/
├── index.html        # The page
├── app.js            # Nostr key mgmt, signing, HTTP POST
└── style.css         # Minimal styling

Step 3: Test the Full Flow

  1. Start the relay: node relay.js
  2. Open the web page in a browser
  3. Enter nsec or connect extension
  4. Compose a message and send
  5. Verify the event appears in the relay
  6. Connect a Nostr client (e.g., noscl or another browser tab) via WebSocket to port 8008
  7. Verify the subscriber receives the event

Open Questions for You

  1. Port preference: Do you want the UDP receiver on port 443 (blends with QUIC) or a simpler port like 8888 (no potential conflicts)?
  2. Nostr key handling: Should the web page use a browser extension (nos2x/Alby) for signing, or accept an nsec directly? Extension is more secure but requires the user to have it installed.
  3. Relay location: Is the relay on the same machine as the web page (localhost demo) or a remote server? This affects the bridge-to-UDP path.
  4. Persistence: Should events persist across relay restarts (simple JSON file or SQLite) or is in-memory sufficient for the demo?