/ concept-collection / commonroom-recorder
Sign in
concept-collection / commonroom-recorder
commonroom-recorder / src / nostr.ts
170 lines · 4.9 KBCodeBlameHistory
200df8aRecord commonroom audio and chat from the command lineJeremy Magland 1import {makeNostrEvent, type NostrEvent} from './identity.js'
3// Ported from commonroom's nostr.ts (which is modeled on trystero's nostr
4// strategy): publish to a topic, subscribe to a topic. Topics are carried in
5// an 'x' tag; each topic maps to an ephemeral event kind (20000+) so relays
6// don't store the messages. Node >= 22 has the browser WebSocket API built in,
7// so this is a near-verbatim port — the one addition is close(), which a CLI
8// needs and a browser page does not.
10const RELAYS = [
11 'wss://relay.damus.io',
12 'wss://nos.lol',
13 'wss://relay.mostr.pub',
14 'wss://purplerelay.com'
17const TAG = 'x'
19const strToNum = (str: string, limit: number): number => {
20 let sum = 0
21 for (let i = 0; i < str.length; i++) sum += str.charCodeAt(i)
22 return sum % limit
25const kindForTopic = (topic: string): number => strToNum(topic, 10000) + 20000
27const nowSec = (): number => Math.floor(Date.now() / 1000)
29const genSubId = (): string =>
30 Array.from({length: 16}, () =>
31 Math.floor(Math.random() * 16).toString(16)
32 ).join('')
34type TopicHandler = (content: string, fromPubkey: string) => void
36// We publish every event to all relays and subscribe on all relays, so each
37// event can arrive several times. Remember recently seen event ids and drop
38// repeats so handlers fire exactly once per event.
39const SEEN_CAP = 1000
41export class Nostr {
42 private sockets: WebSocket[] = []
43 private subs = new Map<string, {topic: string; handler: TopicHandler}>()
44 private seen = new Set<string>()
45 private closed = false
47 constructor() {
48 for (const url of RELAYS) this.connect(url)
49 }
51 private connect(url: string) {
52 if (this.closed) return
53 let ws: WebSocket
54 try {
55 ws = new WebSocket(url)
56 } catch {
57 return
58 }
59 this.sockets.push(ws)
61 ws.onopen = () => {
62 // (re)send all active subscriptions on this socket
63 for (const [subId, {topic}] of this.subs) this.sendReq(ws, subId, topic)
64 }
66 ws.onmessage = ev => {
67 let msg: unknown
68 try {
69 msg = JSON.parse(ev.data as string)
70 } catch {
71 return
72 }
73 if (!Array.isArray(msg) || msg[0] !== 'EVENT') return
74 const subId = msg[1] as string
75 const event = msg[2] as NostrEvent
76 const sub = this.subs.get(subId)
77 if (!sub || !event || typeof event.content !== 'string') return
78 if (event.id) {
79 if (this.seen.has(event.id)) return
80 this.seen.add(event.id)
81 if (this.seen.size > SEEN_CAP) {
82 for (const id of this.seen) {
83 this.seen.delete(id)
84 if (this.seen.size <= SEEN_CAP / 2) break
85 }
86 }
87 }
88 sub.handler(event.content, event.pubkey)
89 }
91 ws.onclose = () => {
92 this.sockets = this.sockets.filter(s => s !== ws)
93 // reconnect after a short delay
94 if (!this.closed) setTimeout(() => this.connect(url), 3000)
95 }
97 ws.onerror = () => ws.close()
98 }
100 private sendReq(ws: WebSocket, subId: string, topic: string) {
101 if (ws.readyState !== WebSocket.OPEN) return
102 ws.send(
103 JSON.stringify([
104 'REQ',
105 subId,
106 {kinds: [kindForTopic(topic)], since: nowSec(), ['#' + TAG]: [topic]}
107 ])
108 )
109 }
111 /** Subscribe to a topic. Handler fires once per incoming event. */
112 subscribe(topic: string, handler: TopicHandler): () => void {
113 const subId = genSubId()
114 this.subs.set(subId, {topic, handler})
115 for (const ws of this.sockets) this.sendReq(ws, subId, topic)
116 return () => {
117 this.subs.delete(subId)
118 for (const ws of this.sockets) {
119 if (ws.readyState === WebSocket.OPEN) {
120 ws.send(JSON.stringify(['CLOSE', subId]))
121 }
122 }
123 }
124 }
126 /** Publish a signed event to a topic. */
127 async publish(topic: string, content: string): Promise<void> {
128 const event = await makeNostrEvent(
129 kindForTopic(topic),
130 [[TAG, topic]],
131 content
132 )
133 const payload = JSON.stringify(['EVENT', event])
134 for (const ws of this.sockets) {
135 if (ws.readyState === WebSocket.OPEN) ws.send(payload)
136 }
137 }
139 /** Close every relay socket and stop reconnecting. */
140 close() {
141 this.closed = true
142 this.subs.clear()
143 for (const ws of this.sockets.splice(0)) {
144 try {
145 ws.close()
146 } catch {
147 /* ignore */
148 }
149 }
150 }
153const sha256Hex = async (str: string): Promise<string> => {
154 const buf = await crypto.subtle.digest(
155 'SHA-256',
156 new TextEncoder().encode(str)
157 )
158 return Array.from(new Uint8Array(buf))
159 .map(b => b.toString(16).padStart(2, '0'))
160 .join('')
163/** Topic everyone in a room announces on / listens to for presence. The room
164 * ID is any string (exact match — no normalization). */
165export const roomTopic = (roomId: string): Promise<string> =>
166 sha256Hex(`commonroom:${roomId}`)
168/** Per-peer topic used to deliver WebRTC signaling to a specific peer. */
169export const peerTopic = (root: string, peerId: string): Promise<string> =>
170 sha256Hex(`${root}:${peerId}`)
moveopenescclose