relay.ts raw
1 import { IdentityId, RelayId } from '../value-objects';
2 import type { RelaySnapshot } from '../repositories/relay-repository';
3
4 /**
5 * Relay entity - represents a Nostr relay configuration for an identity.
6 */
7 export class Relay {
8 private readonly _id: RelayId;
9 private readonly _identityId: IdentityId;
10 private _url: string;
11 private _read: boolean;
12 private _write: boolean;
13
14 private constructor(
15 id: RelayId,
16 identityId: IdentityId,
17 url: string,
18 read: boolean,
19 write: boolean
20 ) {
21 this._id = id;
22 this._identityId = identityId;
23 this._url = Relay.normalizeUrl(url);
24 this._read = read;
25 this._write = write;
26 }
27
28 // ─────────────────────────────────────────────────────────────────────────
29 // Factory Methods
30 // ─────────────────────────────────────────────────────────────────────────
31
32 /**
33 * Create a new relay configuration.
34 *
35 * @param identityId - The identity this relay belongs to
36 * @param url - The relay WebSocket URL
37 * @param read - Whether to read events from this relay
38 * @param write - Whether to write events to this relay
39 */
40 static create(
41 identityId: IdentityId,
42 url: string,
43 read = true,
44 write = true
45 ): Relay {
46 Relay.validateUrl(url);
47
48 return new Relay(
49 RelayId.generate(),
50 identityId,
51 url,
52 read,
53 write
54 );
55 }
56
57 /**
58 * Reconstitute a relay from storage.
59 */
60 static fromSnapshot(snapshot: RelaySnapshot): Relay {
61 return new Relay(
62 RelayId.from(snapshot.id),
63 IdentityId.from(snapshot.identityId),
64 snapshot.url,
65 snapshot.read,
66 snapshot.write
67 );
68 }
69
70 // ─────────────────────────────────────────────────────────────────────────
71 // Getters
72 // ─────────────────────────────────────────────────────────────────────────
73
74 get id(): RelayId {
75 return this._id;
76 }
77
78 get identityId(): IdentityId {
79 return this._identityId;
80 }
81
82 get url(): string {
83 return this._url;
84 }
85
86 get read(): boolean {
87 return this._read;
88 }
89
90 get write(): boolean {
91 return this._write;
92 }
93
94 // ─────────────────────────────────────────────────────────────────────────
95 // Behavior
96 // ─────────────────────────────────────────────────────────────────────────
97
98 /**
99 * Update the relay URL.
100 */
101 updateUrl(newUrl: string): void {
102 Relay.validateUrl(newUrl);
103 this._url = Relay.normalizeUrl(newUrl);
104 }
105
106 /**
107 * Enable reading from this relay.
108 */
109 enableRead(): void {
110 this._read = true;
111 }
112
113 /**
114 * Disable reading from this relay.
115 */
116 disableRead(): void {
117 this._read = false;
118 }
119
120 /**
121 * Enable writing to this relay.
122 */
123 enableWrite(): void {
124 this._write = true;
125 }
126
127 /**
128 * Disable writing to this relay.
129 */
130 disableWrite(): void {
131 this._write = false;
132 }
133
134 /**
135 * Set both read and write permissions.
136 */
137 setPermissions(read: boolean, write: boolean): void {
138 this._read = read;
139 this._write = write;
140 }
141
142 /**
143 * Check if this relay is enabled for either read or write.
144 */
145 isEnabled(): boolean {
146 return this._read || this._write;
147 }
148
149 /**
150 * Check if this relay has the same URL as another (case-insensitive).
151 */
152 hasSameUrl(url: string): boolean {
153 return this._url.toLowerCase() === Relay.normalizeUrl(url).toLowerCase();
154 }
155
156 /**
157 * Check if this relay belongs to a specific identity.
158 */
159 belongsTo(identityId: IdentityId): boolean {
160 return this._identityId.equals(identityId);
161 }
162
163 // ─────────────────────────────────────────────────────────────────────────
164 // Persistence
165 // ─────────────────────────────────────────────────────────────────────────
166
167 /**
168 * Convert to a snapshot for persistence.
169 */
170 toSnapshot(): RelaySnapshot {
171 return {
172 id: this._id.value,
173 identityId: this._identityId.value,
174 url: this._url,
175 read: this._read,
176 write: this._write,
177 };
178 }
179
180 /**
181 * Create a clone for modification without affecting the original.
182 */
183 clone(): Relay {
184 return new Relay(
185 this._id,
186 this._identityId,
187 this._url,
188 this._read,
189 this._write
190 );
191 }
192
193 // ─────────────────────────────────────────────────────────────────────────
194 // Equality
195 // ─────────────────────────────────────────────────────────────────────────
196
197 /**
198 * Check equality based on relay ID.
199 */
200 equals(other: Relay): boolean {
201 return this._id.equals(other._id);
202 }
203
204 // ─────────────────────────────────────────────────────────────────────────
205 // Helpers
206 // ─────────────────────────────────────────────────────────────────────────
207
208 private static normalizeUrl(url: string): string {
209 let normalized = url.trim();
210 // Remove trailing slash
211 if (normalized.endsWith('/')) {
212 normalized = normalized.slice(0, -1);
213 }
214 return normalized;
215 }
216
217 private static validateUrl(url: string): void {
218 const normalized = Relay.normalizeUrl(url);
219
220 if (!normalized) {
221 throw new InvalidRelayUrlError('Relay URL cannot be empty');
222 }
223
224 // Must start with wss:// or ws://
225 if (!normalized.startsWith('wss://') && !normalized.startsWith('ws://')) {
226 throw new InvalidRelayUrlError(
227 'Relay URL must start with wss:// or ws://'
228 );
229 }
230
231 // Try to parse as URL
232 try {
233 new URL(normalized);
234 } catch {
235 throw new InvalidRelayUrlError(`Invalid relay URL: ${url}`);
236 }
237 }
238 }
239
240 /**
241 * Error thrown when a relay URL is invalid.
242 */
243 export class InvalidRelayUrlError extends Error {
244 constructor(message: string) {
245 super(message);
246 this.name = 'InvalidRelayUrlError';
247 }
248 }
249
250 /**
251 * Helper to convert relay list to NIP-65 format.
252 */
253 export function toNip65RelayList(
254 relays: Relay[]
255 ): Record<string, { read: boolean; write: boolean }> {
256 const result: Record<string, { read: boolean; write: boolean }> = {};
257
258 for (const relay of relays) {
259 if (relay.isEnabled()) {
260 result[relay.url] = {
261 read: relay.read,
262 write: relay.write,
263 };
264 }
265 }
266
267 return result;
268 }
269