RelayUrl.ts raw
1 import { InvalidRelayUrlError } from '../errors'
2
3 /**
4 * Value object representing a normalized Nostr relay WebSocket URL.
5 * Immutable, self-validating, with equality by value.
6 */
7 export class RelayUrl {
8 private constructor(private readonly _value: string) {}
9
10 /**
11 * Create a RelayUrl from a WebSocket URL string.
12 * Normalizes the URL (lowercases, removes trailing slash).
13 * @throws InvalidRelayUrlError if the URL is invalid
14 */
15 static create(url: string): RelayUrl {
16 const normalized = RelayUrl.normalize(url)
17 if (!normalized) {
18 throw new InvalidRelayUrlError(url)
19 }
20 return new RelayUrl(normalized)
21 }
22
23 /**
24 * Try to create a RelayUrl. Returns null if invalid.
25 */
26 static tryCreate(url: string): RelayUrl | null {
27 try {
28 return RelayUrl.create(url)
29 } catch {
30 return null
31 }
32 }
33
34 /**
35 * Check if a string is a valid WebSocket URL.
36 */
37 static isValid(url: string): boolean {
38 return RelayUrl.normalize(url) !== null
39 }
40
41 private static normalize(url: string): string | null {
42 try {
43 const trimmed = url.trim()
44 if (!trimmed) return null
45
46 const parsed = new URL(trimmed)
47 if (!['ws:', 'wss:'].includes(parsed.protocol)) {
48 return null
49 }
50
51 // Normalize: lowercase host, remove trailing slash, keep path
52 let normalized = `${parsed.protocol}//${parsed.host.toLowerCase()}`
53 if (parsed.pathname && parsed.pathname !== '/') {
54 normalized += parsed.pathname.replace(/\/$/, '')
55 }
56
57 return normalized
58 } catch {
59 return null
60 }
61 }
62
63 /** The normalized URL string */
64 get value(): string {
65 return this._value
66 }
67
68 /** The URL without the protocol prefix */
69 get shortForm(): string {
70 return this._value.replace(/^wss?:\/\//, '')
71 }
72
73 /** Whether this is a secure (wss://) connection */
74 get isSecure(): boolean {
75 return this._value.startsWith('wss:')
76 }
77
78 /** Whether this is a Tor onion address */
79 get isOnion(): boolean {
80 return this._value.includes('.onion')
81 }
82
83 /** Whether this is a local network address */
84 get isLocalNetwork(): boolean {
85 return (
86 this._value.includes('localhost') ||
87 this._value.includes('127.0.0.1') ||
88 this._value.includes('192.168.') ||
89 this._value.includes('10.') ||
90 this._value.includes('172.16.')
91 )
92 }
93
94 /** Extract the hostname from the URL */
95 get hostname(): string {
96 try {
97 return new URL(this._value).hostname
98 } catch {
99 return this._value
100 }
101 }
102
103 /** Check equality with another RelayUrl */
104 equals(other: RelayUrl): boolean {
105 return this._value === other._value
106 }
107
108 /** Returns the URL string */
109 toString(): string {
110 return this._value
111 }
112
113 /** For JSON serialization */
114 toJSON(): string {
115 return this._value
116 }
117 }
118