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