/ concept-collection / commonview
Sign in
concept-collection / commonview
commonview / src / p2p / nostr.ts
137 lines · 3.8 KBCodeBlameHistory
4a0be26Initial commit: P2P counter app (nostr discovery + WebRTC mesh)Jeremy Magland 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'
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
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
34export class Nostr {
35 private sockets: WebSocket[] = []
36 private subs = new Map<string, {topic: string; handler: TopicHandler}>()
38 constructor() {
39 for (const url of RELAYS) this.connect(url)
40 }
42 private connect(url: string) {
43 let ws: WebSocket
44 try {
45 ws = new WebSocket(url)
46 } catch {
47 return
48 }
49 this.sockets.push(ws)
51 ws.onopen = () => {
52 // (re)send all active subscriptions on this socket
53 for (const [subId, {topic}] of this.subs) this.sendReq(ws, subId, topic)
54 }
56 ws.onmessage = ev => {
57 let msg: unknown
58 try {
59 msg = JSON.parse(ev.data as string)
60 } catch {
61 return
62 }
63 if (!Array.isArray(msg) || msg[0] !== 'EVENT') return
64 const subId = msg[1] as string
65 const event = msg[2] as NostrEvent
66 const sub = this.subs.get(subId)
67 if (sub && event && typeof event.content === 'string') {
68 sub.handler(event.content, event.pubkey)
69 }
70 }
72 ws.onclose = () => {
73 this.sockets = this.sockets.filter(s => s !== ws)
74 // reconnect after a short delay
75 setTimeout(() => this.connect(url), 3000)
76 }
78 ws.onerror = () => ws.close()
79 }
81 private sendReq(ws: WebSocket, subId: string, topic: string) {
82 if (ws.readyState !== WebSocket.OPEN) return
83 ws.send(
84 JSON.stringify([
85 'REQ',
86 subId,
87 {kinds: [kindForTopic(topic)], since: nowSec(), ['#' + TAG]: [topic]}
88 ])
89 )
90 }
92 /** Subscribe to a topic. Handler fires for each incoming event. */
93 subscribe(topic: string, handler: TopicHandler): () => void {
94 const subId = genSubId()
95 this.subs.set(subId, {topic, handler})
96 for (const ws of this.sockets) this.sendReq(ws, subId, topic)
97 return () => {
98 this.subs.delete(subId)
99 for (const ws of this.sockets) {
100 if (ws.readyState === WebSocket.OPEN) {
101 ws.send(JSON.stringify(['CLOSE', subId]))
102 }
103 }
104 }
105 }
107 /** Publish a signed event to a topic. */
108 async publish(topic: string, content: string): Promise<void> {
109 const event = await makeNostrEvent(
110 kindForTopic(topic),
111 [[TAG, topic]],
112 content
113 )
114 const payload = JSON.stringify(['EVENT', event])
115 for (const ws of this.sockets) {
116 if (ws.readyState === WebSocket.OPEN) ws.send(payload)
117 }
118 }
121const sha256Hex = async (str: string): Promise<string> => {
122 const buf = await crypto.subtle.digest(
123 'SHA-256',
124 new TextEncoder().encode(str)
125 )
126 return Array.from(new Uint8Array(buf))
127 .map(b => b.toString(16).padStart(2, '0'))
128 .join('')
131/** Topic everyone in a room announces on / listens to for discovery. */
132export const rootTopic = (roomId: string): Promise<string> =>
133 sha256Hex(`commonview:${roomId}`)
135/** Per-peer topic used to deliver WebRTC signaling to a specific peer. */
136export const peerTopic = (root: string, peerId: string): Promise<string> =>
137 sha256Hex(`${root}:${peerId}`)
moveopenescclose