1import {makeNostrEvent, type NostrEvent} from './identity'
3// Minimal nostr client, modeled on trystero's nostr strategy but trimmed to
4// only what we need: publish to a topic, and subscribe to a topic. Topics are
5// carried in an 'x' tag; each topic maps to an ephemeral event kind (20000+)
6// so relays don't store the messages.
8const RELAYS = [
9 'wss://relay.damus.io',
10 'wss://nos.lol',
11 'wss://relay.mostr.pub',
12 'wss://purplerelay.com'
13]
15const TAG = 'x'
17const strToNum = (str: string, limit: number): number => {
18 let sum = 0
19 for (let i = 0; i < str.length; i++) sum += str.charCodeAt(i)
20 return sum % limit
21}
23const kindForTopic = (topic: string): number => strToNum(topic, 10000) + 20000
25const nowSec = (): number => Math.floor(Date.now() / 1000)
27const genSubId = (): string =>
28 Array.from({length: 16}, () =>
29 Math.floor(Math.random() * 16).toString(16)
30 ).join('')
32type TopicHandler = (content: string, fromPubkey: string) => void
34// We publish every event to all relays and subscribe on all relays, so each
35// event can arrive several times. Remember recently seen event ids and drop
36// repeats so handlers fire exactly once per event.
37const SEEN_CAP = 1000
39export class Nostr {
40 private sockets: WebSocket[] = []
41 private subs = new Map<string, {topic: string; handler: TopicHandler}>()
42 private seen = new Set<string>()
44 constructor() {
45 for (const url of RELAYS) this.connect(url)
46 }
48 private connect(url: string) {
49 let ws: WebSocket
50 try {
51 ws = new WebSocket(url)
52 } catch {
53 return
54 }
55 this.sockets.push(ws)
57 ws.onopen = () => {
58 // (re)send all active subscriptions on this socket
59 for (const [subId, {topic}] of this.subs) this.sendReq(ws, subId, topic)
60 }
62 ws.onmessage = ev => {
63 let msg: unknown
64 try {
65 msg = JSON.parse(ev.data as string)
66 } catch {
67 return
68 }
69 if (!Array.isArray(msg) || msg[0] !== 'EVENT') return
70 const subId = msg[1] as string
71 const event = msg[2] as NostrEvent
72 const sub = this.subs.get(subId)
73 if (!sub || !event || typeof event.content !== 'string') return
74 if (event.id) {
75 if (this.seen.has(event.id)) return
76 this.seen.add(event.id)
77 if (this.seen.size > SEEN_CAP) {
78 for (const id of this.seen) {
79 this.seen.delete(id)
80 if (this.seen.size <= SEEN_CAP / 2) break
81 }
82 }
83 }
84 sub.handler(event.content, event.pubkey)
85 }
87 ws.onclose = () => {
88 this.sockets = this.sockets.filter(s => s !== ws)
89 // reconnect after a short delay
90 setTimeout(() => this.connect(url), 3000)
91 }
93 ws.onerror = () => ws.close()
94 }
96 private sendReq(ws: WebSocket, subId: string, topic: string) {
97 if (ws.readyState !== WebSocket.OPEN) return
98 ws.send(
99 JSON.stringify([
100 'REQ',
101 subId,
102 {kinds: [kindForTopic(topic)], since: nowSec(), ['#' + TAG]: [topic]}
103 ])
104 )
105 }
107 /** Subscribe to a topic. Handler fires once per incoming event. */
108 subscribe(topic: string, handler: TopicHandler): () => void {
109 const subId = genSubId()
110 this.subs.set(subId, {topic, handler})
111 for (const ws of this.sockets) this.sendReq(ws, subId, topic)
112 return () => {
113 this.subs.delete(subId)
114 for (const ws of this.sockets) {
115 if (ws.readyState === WebSocket.OPEN) {
116 ws.send(JSON.stringify(['CLOSE', subId]))
117 }
118 }
119 }
120 }
122 /** Publish a signed event to a topic. */
123 async publish(topic: string, content: string): Promise<void> {
124 const event = await makeNostrEvent(
125 kindForTopic(topic),
126 [[TAG, topic]],
127 content
128 )
129 const payload = JSON.stringify(['EVENT', event])
130 for (const ws of this.sockets) {
131 if (ws.readyState === WebSocket.OPEN) ws.send(payload)
132 }
133 }
134}
136const sha256Hex = async (str: string): Promise<string> => {
137 const buf = await crypto.subtle.digest(
138 'SHA-256',
139 new TextEncoder().encode(str)
140 )
141 return Array.from(new Uint8Array(buf))
142 .map(b => b.toString(16).padStart(2, '0'))
143 .join('')
144}
146/** Topic everyone in a room announces on / listens to for presence. The room
147 * ID is any string (exact match — no normalization). */
148export const roomTopic = (roomId: string): Promise<string> =>
149 sha256Hex(`commonroom:${roomId}`)
151/** Per-peer topic used to deliver WebRTC signaling to a specific peer. */
152export const peerTopic = (root: string, peerId: string): Promise<string> =>
153 sha256Hex(`${root}:${peerId}`)