config.js raw

   1  /**
   2   * Relay configuration module for dual-mode operation (embedded vs standalone)
   3   *
   4   * Embedded mode: Dashboard served from same origin as relay (no CORS needed)
   5   * Standalone mode: Dashboard hosted separately, connects to remote relay (CORS required)
   6   */
   7  
   8  import { get } from 'svelte/store';
   9  import { relayUrl, isStandaloneMode, relayInfo, relayConnectionStatus } from './stores.js';
  10  
  11  // Build-time configuration (set via rollup replace plugin)
  12  const BUILD_STANDALONE_MODE = typeof process !== 'undefined' &&
  13      process.env && process.env.STANDALONE_MODE === 'true';
  14  const BUILD_DEFAULT_RELAY_URL = typeof process !== 'undefined' &&
  15      process.env && process.env.DEFAULT_RELAY_URL || '';
  16  
  17  /**
  18   * Initialize configuration on app startup
  19   * Call this from main.js before rendering App
  20   */
  21  export function initConfig() {
  22      // Detect standalone mode:
  23      // 1. Explicitly built as standalone
  24      // 2. Has a configured relay URL in localStorage
  25      // 3. Running from file:// protocol
  26      // 4. Not running on a typical relay port (3334) - likely a static server
  27      const hasStoredRelay = !!localStorage.getItem("relayUrl");
  28      const isFileProtocol = window.location.protocol === 'file:';
  29      const isNonRelayPort = !['3334', '7777', '443', '80', ''].includes(window.location.port);
  30  
  31      const standalone = BUILD_STANDALONE_MODE || hasStoredRelay || isFileProtocol || isNonRelayPort;
  32      isStandaloneMode.set(standalone);
  33  
  34      // Set default relay URL from build config if not already set
  35      if (BUILD_DEFAULT_RELAY_URL && !get(relayUrl)) {
  36          relayUrl.set(BUILD_DEFAULT_RELAY_URL);
  37      }
  38  
  39      console.log('[config] Initialized:', {
  40          standaloneMode: standalone,
  41          buildStandalone: BUILD_STANDALONE_MODE,
  42          hasStoredRelay,
  43          isNonRelayPort,
  44          port: window.location.port,
  45          relayUrl: get(relayUrl) || '(same origin)'
  46      });
  47  }
  48  
  49  /**
  50   * Get the HTTP base URL for API calls
  51   * @returns {string} Base URL (e.g., "https://relay.example.com")
  52   */
  53  export function getApiBase() {
  54      const url = get(relayUrl);
  55      if (url) {
  56          return normalizeHttpUrl(url);
  57      }
  58      return window.location.origin;
  59  }
  60  
  61  /**
  62   * Get the WebSocket URL for relay connection
  63   * @returns {string} WebSocket URL (e.g., "wss://relay.example.com/")
  64   */
  65  export function getWsUrl() {
  66      const url = get(relayUrl);
  67      if (url) {
  68          return normalizeWsUrl(url);
  69      }
  70      const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
  71      return `${protocol}//${window.location.host}/`;
  72  }
  73  
  74  /**
  75   * Get array of relay URLs for nostr-tools SimplePool
  76   * @returns {string[]} Array with single relay URL
  77   */
  78  export function getRelayUrls() {
  79      return [getWsUrl()];
  80  }
  81  
  82  /**
  83   * Check if running in standalone mode
  84   * @returns {boolean}
  85   */
  86  export function isStandalone() {
  87      return get(isStandaloneMode);
  88  }
  89  
  90  /**
  91   * Check if a relay URL is configured (either stored or same-origin)
  92   * @returns {boolean}
  93   */
  94  export function hasRelayConfigured() {
  95      // In embedded mode, always configured (same origin)
  96      // In standalone mode, need explicit URL
  97      if (!get(isStandaloneMode)) {
  98          return true;
  99      }
 100      return !!get(relayUrl);
 101  }
 102  
 103  /**
 104   * Set the relay URL and trigger connection
 105   * @param {string} url - Relay URL (http/https/ws/wss)
 106   */
 107  export function setRelayUrl(url) {
 108      const normalized = url ? normalizeHttpUrl(url) : '';
 109      relayUrl.set(normalized);
 110  
 111      if (normalized) {
 112          // Mark as standalone since we have an explicit URL
 113          isStandaloneMode.set(true);
 114      }
 115  }
 116  
 117  /**
 118   * Fetch and validate relay info via NIP-11
 119   * @param {string} [url] - Optional URL to check (defaults to current relay)
 120   * @returns {Promise<object|null>} Relay info or null on error
 121   */
 122  export async function fetchRelayInfoFromUrl(url) {
 123      const baseUrl = url ? normalizeHttpUrl(url) : getApiBase();
 124  
 125      try {
 126          relayConnectionStatus.set("connecting");
 127  
 128          const response = await fetch(baseUrl, {
 129              headers: {
 130                  Accept: "application/nostr+json",
 131              },
 132          });
 133  
 134          if (!response.ok) {
 135              throw new Error(`HTTP ${response.status}: ${response.statusText}`);
 136          }
 137  
 138          const info = await response.json();
 139  
 140          // Validate it looks like relay info
 141          if (!info.name && !info.supported_nips) {
 142              throw new Error("Invalid relay info response");
 143          }
 144  
 145          relayInfo.set(info);
 146          relayConnectionStatus.set("connected");
 147  
 148          return info;
 149      } catch (error) {
 150          console.error('[config] Failed to fetch relay info:', error);
 151          relayConnectionStatus.set("error");
 152          relayInfo.set(null);
 153          return null;
 154      }
 155  }
 156  
 157  /**
 158   * Connect to a new relay URL
 159   * Validates via NIP-11 first, falls back to WebSocket test if CORS blocks NIP-11
 160   * @param {string} url - Relay URL
 161   * @returns {Promise<{success: boolean, info?: object, error?: string}>}
 162   */
 163  export async function connectToRelay(url) {
 164      console.log('[config] connectToRelay called with:', url);
 165      if (!url) {
 166          return { success: false, error: "URL is required" };
 167      }
 168  
 169      const normalized = normalizeHttpUrl(url);
 170      console.log('[config] Normalized HTTP URL:', normalized);
 171  
 172      // Try to fetch relay info to validate
 173      const info = await fetchRelayInfoFromUrl(normalized);
 174      console.log('[config] fetchRelayInfoFromUrl returned:', info ? 'success' : 'null');
 175  
 176      if (info) {
 177          // NIP-11 worked, store the URL
 178          setRelayUrl(normalized);
 179          return { success: true, info };
 180      }
 181  
 182      // NIP-11 failed (likely CORS), try WebSocket connection test
 183      console.log('[config] NIP-11 failed, trying WebSocket connection test');
 184      const wsUrl = normalizeWsUrl(url);
 185      console.log('[config] Normalized WS URL:', wsUrl);
 186      const wsResult = await testWebSocketConnection(wsUrl);
 187      console.log('[config] WebSocket test complete:', wsResult);
 188  
 189      if (wsResult.success) {
 190          // WebSocket worked, store the URL
 191          setRelayUrl(normalized);
 192          relayConnectionStatus.set("connected");
 193          // Create minimal relay info
 194          const minimalInfo = { name: wsUrl };
 195          relayInfo.set(minimalInfo);
 196          return { success: true, info: minimalInfo };
 197      }
 198  
 199      return { success: false, error: wsResult.error || "Could not connect to relay" };
 200  }
 201  
 202  /**
 203   * Test WebSocket connection to a relay
 204   * @param {string} wsUrl - WebSocket URL
 205   * @returns {Promise<{success: boolean, error?: string}>}
 206   */
 207  async function testWebSocketConnection(wsUrl) {
 208      console.log('[config] Testing WebSocket connection to:', wsUrl);
 209      return new Promise((resolve) => {
 210          let resolved = false;
 211          let ws = null;
 212  
 213          const safeResolve = (result) => {
 214              if (!resolved) {
 215                  resolved = true;
 216                  console.log('[config] WebSocket test result:', result);
 217                  resolve(result);
 218              }
 219          };
 220  
 221          const timeout = setTimeout(() => {
 222              console.log('[config] WebSocket connection timed out');
 223              if (ws) ws.close();
 224              safeResolve({ success: false, error: "Connection timed out" });
 225          }, 5000);
 226  
 227          try {
 228              ws = new WebSocket(wsUrl);
 229  
 230              ws.onopen = () => {
 231                  console.log('[config] WebSocket connected successfully');
 232                  clearTimeout(timeout);
 233                  ws.close();
 234                  safeResolve({ success: true });
 235              };
 236  
 237              ws.onerror = (error) => {
 238                  console.log('[config] WebSocket error:', error);
 239                  clearTimeout(timeout);
 240                  safeResolve({ success: false, error: "WebSocket connection failed" });
 241              };
 242  
 243              ws.onclose = (event) => {
 244                  console.log('[config] WebSocket closed:', event.code, event.reason);
 245                  clearTimeout(timeout);
 246                  if (event.code !== 1000 && !resolved) {
 247                      safeResolve({ success: false, error: `Connection closed: ${event.reason || 'code ' + event.code}` });
 248                  }
 249              };
 250          } catch (err) {
 251              console.error('[config] WebSocket creation error:', err);
 252              clearTimeout(timeout);
 253              safeResolve({ success: false, error: err.message || "Failed to create WebSocket" });
 254          }
 255      });
 256  }
 257  
 258  // ==================== URL Normalization Helpers ====================
 259  
 260  /**
 261   * Normalize URL to HTTP(S) format
 262   * @param {string} url
 263   * @returns {string}
 264   */
 265  function normalizeHttpUrl(url) {
 266      let normalized = url.trim();
 267  
 268      // Convert WebSocket URLs to HTTP
 269      if (normalized.startsWith('wss://')) {
 270          normalized = 'https://' + normalized.slice(6);
 271      } else if (normalized.startsWith('ws://')) {
 272          normalized = 'http://' + normalized.slice(5);
 273      }
 274  
 275      // Add protocol if missing
 276      if (!normalized.startsWith('http://') && !normalized.startsWith('https://')) {
 277          normalized = 'https://' + normalized;
 278      }
 279  
 280      // Remove trailing slash
 281      return normalized.replace(/\/$/, '');
 282  }
 283  
 284  /**
 285   * Normalize URL to WebSocket format
 286   * @param {string} url
 287   * @returns {string}
 288   */
 289  export function normalizeWsUrl(url) {
 290      let normalized = url.trim();
 291  
 292      // Convert HTTP URLs to WebSocket
 293      if (normalized.startsWith('https://')) {
 294          normalized = 'wss://' + normalized.slice(8);
 295      } else if (normalized.startsWith('http://')) {
 296          normalized = 'ws://' + normalized.slice(7);
 297      }
 298  
 299      // Add protocol if missing
 300      if (!normalized.startsWith('ws://') && !normalized.startsWith('wss://')) {
 301          normalized = 'wss://' + normalized;
 302      }
 303  
 304      // Ensure trailing slash for relay URL
 305      if (!normalized.endsWith('/')) {
 306          normalized += '/';
 307      }
 308  
 309      return normalized;
 310  }
 311