RelaySet.ts raw
1 import { Event, kinds } from 'nostr-tools'
2 import { RelayUrl, Timestamp } from '../shared'
3
4 /**
5 * Result of a relay set modification
6 */
7 export type RelaySetChange =
8 | { type: 'added'; relay: RelayUrl }
9 | { type: 'removed'; relay: RelayUrl }
10 | { type: 'no_change' }
11
12 /**
13 * RelaySet Aggregate
14 *
15 * Represents a named collection of relays (kind 30002 in Nostr).
16 * Used for organizing relays into groups like "fast relays", "paid relays", etc.
17 *
18 * Invariants:
19 * - Name is required and non-empty
20 * - No duplicate relay URLs
21 * - All URLs must be valid WebSocket URLs
22 */
23 export class RelaySet {
24 private readonly _relays: Map<string, RelayUrl>
25
26 private constructor(
27 private readonly _id: string,
28 private _name: string,
29 relays: RelayUrl[]
30 ) {
31 this._relays = new Map()
32 for (const relay of relays) {
33 this._relays.set(relay.value, relay)
34 }
35 }
36
37 /**
38 * Create a new empty RelaySet with a generated ID
39 */
40 static create(name: string, id?: string): RelaySet {
41 const setId = id || crypto.randomUUID().replace(/-/g, '').slice(0, 12)
42 return new RelaySet(setId, name.trim() || 'Unnamed Set', [])
43 }
44
45 /**
46 * Create a RelaySet with initial relays
47 */
48 static createWithRelays(name: string, relayUrls: string[], id?: string): RelaySet {
49 const set = RelaySet.create(name, id)
50 for (const url of relayUrls) {
51 const relay = RelayUrl.tryCreate(url)
52 if (relay) {
53 set._relays.set(relay.value, relay)
54 }
55 }
56 return set
57 }
58
59 /**
60 * Reconstruct a RelaySet from a Nostr kind 30002 event
61 */
62 static fromEvent(event: Event): RelaySet {
63 if (event.kind !== kinds.Relaysets) {
64 throw new Error(`Expected kind ${kinds.Relaysets}, got ${event.kind}`)
65 }
66
67 let id = ''
68 let name = ''
69 const relays: RelayUrl[] = []
70
71 for (const tag of event.tags) {
72 if (tag[0] === 'd' && tag[1]) {
73 id = tag[1]
74 } else if (tag[0] === 'title' && tag[1]) {
75 name = tag[1]
76 } else if (tag[0] === 'relay' && tag[1]) {
77 const relay = RelayUrl.tryCreate(tag[1])
78 if (relay) {
79 relays.push(relay)
80 }
81 }
82 }
83
84 return new RelaySet(id || 'unknown', name || 'Unnamed Set', relays)
85 }
86
87 /**
88 * The unique identifier for this relay set
89 */
90 get id(): string {
91 return this._id
92 }
93
94 /**
95 * The display name of this relay set
96 */
97 get name(): string {
98 return this._name
99 }
100
101 /**
102 * Number of relays in this set
103 */
104 get count(): number {
105 return this._relays.size
106 }
107
108 /**
109 * Check if the set is empty
110 */
111 get isEmpty(): boolean {
112 return this._relays.size === 0
113 }
114
115 /**
116 * Get all relays in this set
117 */
118 getRelays(): RelayUrl[] {
119 return Array.from(this._relays.values())
120 }
121
122 /**
123 * Get all relay URLs as strings
124 */
125 getRelayUrls(): string[] {
126 return Array.from(this._relays.keys())
127 }
128
129 /**
130 * Check if a relay is in this set
131 */
132 hasRelay(relay: RelayUrl): boolean {
133 return this._relays.has(relay.value)
134 }
135
136 /**
137 * Check if a relay URL string is in this set
138 */
139 hasRelayUrl(url: string): boolean {
140 const relay = RelayUrl.tryCreate(url)
141 return relay ? this._relays.has(relay.value) : false
142 }
143
144 /**
145 * Rename this relay set
146 */
147 rename(newName: string): void {
148 this._name = newName.trim() || 'Unnamed Set'
149 }
150
151 /**
152 * Add a relay to this set
153 *
154 * @returns RelaySetChange indicating what changed
155 */
156 addRelay(relay: RelayUrl): RelaySetChange {
157 if (this._relays.has(relay.value)) {
158 return { type: 'no_change' }
159 }
160
161 this._relays.set(relay.value, relay)
162 return { type: 'added', relay }
163 }
164
165 /**
166 * Add a relay by URL string
167 *
168 * @returns RelaySetChange or null if URL is invalid
169 */
170 addRelayUrl(url: string): RelaySetChange | null {
171 const relay = RelayUrl.tryCreate(url)
172 if (!relay) return null
173 return this.addRelay(relay)
174 }
175
176 /**
177 * Remove a relay from this set
178 *
179 * @returns RelaySetChange indicating what changed
180 */
181 removeRelay(relay: RelayUrl): RelaySetChange {
182 if (!this._relays.has(relay.value)) {
183 return { type: 'no_change' }
184 }
185
186 this._relays.delete(relay.value)
187 return { type: 'removed', relay }
188 }
189
190 /**
191 * Remove a relay by URL string
192 *
193 * @returns RelaySetChange or null if URL is invalid
194 */
195 removeRelayUrl(url: string): RelaySetChange | null {
196 const relay = RelayUrl.tryCreate(url)
197 if (!relay) return null
198 return this.removeRelay(relay)
199 }
200
201 /**
202 * Replace all relays with a new list
203 */
204 setRelays(relays: RelayUrl[]): void {
205 this._relays.clear()
206 for (const relay of relays) {
207 this._relays.set(relay.value, relay)
208 }
209 }
210
211 /**
212 * Convert to the 'a' tag reference format
213 */
214 toATag(pubkey: string): string[] {
215 return ['a', `${kinds.Relaysets}:${pubkey}:${this._id}`]
216 }
217
218 /**
219 * Convert to Nostr event tags format
220 */
221 toTags(): string[][] {
222 const tags: string[][] = [
223 ['d', this._id],
224 ['title', this._name]
225 ]
226
227 for (const relay of this._relays.values()) {
228 tags.push(['relay', relay.value])
229 }
230
231 return tags
232 }
233
234 /**
235 * Convert to a draft event for publishing
236 */
237 toDraftEvent(): { kind: number; content: string; created_at: number; tags: string[][] } {
238 return {
239 kind: kinds.Relaysets,
240 content: '',
241 created_at: Timestamp.now().unix,
242 tags: this.toTags()
243 }
244 }
245 }
246