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