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