/* eslint-disable @typescript-eslint/member-ordering, @angular-eslint/prefer-inject, @typescript-eslint/no-invalid-void-type */ import { Injectable, signal, computed } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable, of, throwError, forkJoin } from 'rxjs'; import { catchError, map } from 'rxjs/operators'; import { ServerInfo, JoinRequest, User } from '../models/index'; import { v4 as uuidv4 } from 'uuid'; import { environment } from '../../../environments/environment'; /** * A configured server endpoint that the user can connect to. */ export interface ServerEndpoint { /** Unique endpoint identifier. */ id: string; /** Human-readable label shown in the UI. */ name: string; /** Base URL (e.g. `http://localhost:3001`). */ url: string; /** Whether this is the currently selected endpoint. */ isActive: boolean; /** Whether this is the built-in default endpoint. */ isDefault: boolean; /** Most recent health-check result. */ status: 'online' | 'offline' | 'checking' | 'unknown'; /** Last measured round-trip latency (ms). */ latency?: number; } /** localStorage key that persists the user's configured endpoints. */ const ENDPOINTS_STORAGE_KEY = 'metoyou_server_endpoints'; /** Timeout (ms) for server health-check and alternative-endpoint pings. */ const HEALTH_CHECK_TIMEOUT_MS = 5000; function getDefaultHttpProtocol(): 'http' | 'https' { return typeof window !== 'undefined' && window.location?.protocol === 'https:' ? 'https' : 'http'; } function normaliseDefaultServerUrl(rawUrl: string): string { let cleaned = rawUrl.trim(); if (!cleaned) return ''; if (cleaned.toLowerCase().startsWith('ws://')) { cleaned = `http://${cleaned.slice(5)}`; } else if (cleaned.toLowerCase().startsWith('wss://')) { cleaned = `https://${cleaned.slice(6)}`; } else if (cleaned.startsWith('//')) { cleaned = `${getDefaultHttpProtocol()}:${cleaned}`; } else if (!/^[a-z][a-z\d+.-]*:\/\//i.test(cleaned)) { cleaned = `${getDefaultHttpProtocol()}://${cleaned}`; } cleaned = cleaned.replace(/\/+$/, ''); if (cleaned.toLowerCase().endsWith('/api')) { cleaned = cleaned.slice(0, -4); } return cleaned; } /** * Derive the default server URL from the environment when provided, * otherwise match the current page protocol automatically. */ function buildDefaultServerUrl(): string { const configuredUrl = environment.defaultServerUrl?.trim(); if (configuredUrl) { return normaliseDefaultServerUrl(configuredUrl); } return `${getDefaultHttpProtocol()}://localhost:3001`; } /** Blueprint for the built-in default endpoint. */ const DEFAULT_ENDPOINT: Omit = { name: 'Local Server', url: buildDefaultServerUrl(), isActive: true, isDefault: true, status: 'unknown' }; /** * Manages the user's list of configured server endpoints and * provides an HTTP client for server-directory API calls * (search, register, join/leave, heartbeat, etc.). * * Endpoints are persisted in `localStorage` and exposed as * Angular signals for reactive consumption. */ @Injectable({ providedIn: 'root' }) export class ServerDirectoryService { private readonly _servers = signal([]); /** Whether search queries should be fanned out to all non-offline endpoints. */ private shouldSearchAllServers = false; /** Reactive list of all configured endpoints. */ readonly servers = computed(() => this._servers()); /** The currently active endpoint, falling back to the first in the list. */ readonly activeServer = computed( () => this._servers().find((endpoint) => endpoint.isActive) ?? this._servers()[0] ); constructor(private readonly http: HttpClient) { this.loadEndpoints(); } /** * Add a new server endpoint (inactive by default). * * @param server - Name and URL of the endpoint to add. */ addServer(server: { name: string; url: string }): void { const sanitisedUrl = this.sanitiseUrl(server.url); const newEndpoint: ServerEndpoint = { id: uuidv4(), name: server.name, url: sanitisedUrl, isActive: false, isDefault: false, status: 'unknown' }; this._servers.update((endpoints) => [...endpoints, newEndpoint]); this.saveEndpoints(); } /** * Remove an endpoint by ID. * The built-in default endpoint cannot be removed. If the removed * endpoint was active, the first remaining endpoint is activated. */ removeServer(endpointId: string): void { const endpoints = this._servers(); const target = endpoints.find((endpoint) => endpoint.id === endpointId); if (target?.isDefault) return; const wasActive = target?.isActive; this._servers.update((list) => list.filter((endpoint) => endpoint.id !== endpointId)); if (wasActive) { this._servers.update((list) => { if (list.length > 0) list[0].isActive = true; return [...list]; }); } this.saveEndpoints(); } /** Activate a specific endpoint and deactivate all others. */ setActiveServer(endpointId: string): void { this._servers.update((endpoints) => endpoints.map((endpoint) => ({ ...endpoint, isActive: endpoint.id === endpointId })) ); this.saveEndpoints(); } /** Update the health status and optional latency of an endpoint. */ updateServerStatus( endpointId: string, status: ServerEndpoint['status'], latency?: number ): void { this._servers.update((endpoints) => endpoints.map((endpoint) => endpoint.id === endpointId ? { ...endpoint, status, latency } : endpoint ) ); this.saveEndpoints(); } /** Enable or disable fan-out search across all endpoints. */ setSearchAllServers(enabled: boolean): void { this.shouldSearchAllServers = enabled; } /** * Probe a single endpoint's health and update its status. * * @param endpointId - ID of the endpoint to test. * @returns `true` if the server responded successfully. */ async testServer(endpointId: string): Promise { const endpoint = this._servers().find((entry) => entry.id === endpointId); if (!endpoint) return false; this.updateServerStatus(endpointId, 'checking'); const startTime = Date.now(); try { const response = await fetch(`${endpoint.url}/api/health`, { method: 'GET', signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS) }); const latency = Date.now() - startTime; if (response.ok) { this.updateServerStatus(endpointId, 'online', latency); return true; } this.updateServerStatus(endpointId, 'offline'); return false; } catch { // Fall back to the /servers endpoint try { const response = await fetch(`${endpoint.url}/api/servers`, { method: 'GET', signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS) }); const latency = Date.now() - startTime; if (response.ok) { this.updateServerStatus(endpointId, 'online', latency); return true; } } catch { /* both checks failed */ } this.updateServerStatus(endpointId, 'offline'); return false; } } /** Probe all configured endpoints in parallel. */ async testAllServers(): Promise { const endpoints = this._servers(); await Promise.all(endpoints.map((endpoint) => this.testServer(endpoint.id))); } /** Expose the API base URL for external consumers. */ getApiBaseUrl(): string { return this.buildApiBaseUrl(); } /** Get the WebSocket URL derived from the active endpoint. */ getWebSocketUrl(): string { const active = this.activeServer(); if (!active) return buildDefaultServerUrl().replace(/^http/, 'ws'); return active.url.replace(/^http/, 'ws'); } /** * Search for public servers matching a query string. * When {@link shouldSearchAllServers} is `true`, the search is * fanned out to every non-offline endpoint. */ searchServers(query: string): Observable { if (this.shouldSearchAllServers) { return this.searchAllEndpoints(query); } return this.searchSingleEndpoint(query, this.buildApiBaseUrl(), this.activeServer()); } /** Retrieve the full list of public servers. */ getServers(): Observable { if (this.shouldSearchAllServers) { return this.getAllServersFromAllEndpoints(); } return this.http .get<{ servers: ServerInfo[]; total: number }>(`${this.buildApiBaseUrl()}/servers`) .pipe( map((response) => this.normalizeServerList(response, this.activeServer())), catchError((error) => { console.error('Failed to get servers:', error); return of([]); }) ); } /** Fetch details for a single server. */ getServer(serverId: string): Observable { return this.http .get(`${this.buildApiBaseUrl()}/servers/${serverId}`) .pipe( map((server) => this.normalizeServerInfo(server, this.activeServer())), catchError((error) => { console.error('Failed to get server:', error); return of(null); }) ); } /** Register a new server listing in the directory. */ registerServer( server: Omit & { id?: string } ): Observable { return this.http .post(`${this.buildApiBaseUrl()}/servers`, server) .pipe( catchError((error) => { console.error('Failed to register server:', error); return throwError(() => error); }) ); } /** Update an existing server listing. */ updateServer( serverId: string, updates: Partial & { currentOwnerId: string } ): Observable { return this.http .put(`${this.buildApiBaseUrl()}/servers/${serverId}`, updates) .pipe( catchError((error) => { console.error('Failed to update server:', error); return throwError(() => error); }) ); } /** Remove a server listing from the directory. */ unregisterServer(serverId: string): Observable { return this.http .delete(`${this.buildApiBaseUrl()}/servers/${serverId}`) .pipe( catchError((error) => { console.error('Failed to unregister server:', error); return throwError(() => error); }) ); } /** Retrieve users currently connected to a server. */ getServerUsers(serverId: string): Observable { return this.http .get(`${this.buildApiBaseUrl()}/servers/${serverId}/users`) .pipe( catchError((error) => { console.error('Failed to get server users:', error); return of([]); }) ); } /** Send a join request for a server and receive the signaling URL. */ requestJoin( request: JoinRequest ): Observable<{ success: boolean; signalingUrl?: string }> { return this.http .post<{ success: boolean; signalingUrl?: string }>( `${this.buildApiBaseUrl()}/servers/${request.roomId}/join`, request ) .pipe( catchError((error) => { console.error('Failed to send join request:', error); return throwError(() => error); }) ); } /** Notify the directory that a user has left a server. */ notifyLeave(serverId: string, userId: string): Observable { return this.http .post(`${this.buildApiBaseUrl()}/servers/${serverId}/leave`, { userId }) .pipe( catchError((error) => { console.error('Failed to notify leave:', error); return of(undefined); }) ); } /** Update the live user count for a server listing. */ updateUserCount(serverId: string, count: number): Observable { return this.http .patch(`${this.buildApiBaseUrl()}/servers/${serverId}/user-count`, { count }) .pipe( catchError((error) => { console.error('Failed to update user count:', error); return of(undefined); }) ); } /** Send a heartbeat to keep the server listing active. */ sendHeartbeat(serverId: string): Observable { return this.http .post(`${this.buildApiBaseUrl()}/servers/${serverId}/heartbeat`, {}) .pipe( catchError((error) => { console.error('Failed to send heartbeat:', error); return of(undefined); }) ); } /** * Build the active endpoint's API base URL, stripping trailing * slashes and accidental `/api` suffixes. */ private buildApiBaseUrl(): string { const active = this.activeServer(); const rawUrl = active ? active.url : buildDefaultServerUrl(); let base = rawUrl.replace(/\/+$/, ''); if (base.toLowerCase().endsWith('/api')) { base = base.slice(0, -4); } return `${base}/api`; } /** Strip trailing slashes and `/api` suffix from a URL. */ private sanitiseUrl(rawUrl: string): string { let cleaned = rawUrl.trim().replace(/\/+$/, ''); if (cleaned.toLowerCase().endsWith('/api')) { cleaned = cleaned.slice(0, -4); } return cleaned; } /** * Handle both `{ servers: [...] }` and direct `ServerInfo[]` * response shapes from the directory API. */ private unwrapServersResponse( response: { servers: ServerInfo[]; total: number } | ServerInfo[] ): ServerInfo[] { if (Array.isArray(response)) return response; return response.servers ?? []; } /** Search a single endpoint for servers matching a query. */ private searchSingleEndpoint( query: string, apiBaseUrl: string, source?: ServerEndpoint | null ): Observable { const params = new HttpParams().set('q', query); return this.http .get<{ servers: ServerInfo[]; total: number }>(`${apiBaseUrl}/servers`, { params }) .pipe( map((response) => this.normalizeServerList(response, source)), catchError((error) => { console.error('Failed to search servers:', error); return of([]); }) ); } /** Fan-out search across all non-offline endpoints, deduplicating results. */ private searchAllEndpoints(query: string): Observable { const onlineEndpoints = this._servers().filter( (endpoint) => endpoint.status !== 'offline' ); if (onlineEndpoints.length === 0) { return this.searchSingleEndpoint(query, this.buildApiBaseUrl(), this.activeServer()); } const requests = onlineEndpoints.map((endpoint) => this.searchSingleEndpoint(query, `${endpoint.url}/api`, endpoint) ); return forkJoin(requests).pipe( map((resultArrays) => resultArrays.flat()), map((servers) => this.deduplicateById(servers)) ); } /** Retrieve all servers from all non-offline endpoints. */ private getAllServersFromAllEndpoints(): Observable { const onlineEndpoints = this._servers().filter( (endpoint) => endpoint.status !== 'offline' ); if (onlineEndpoints.length === 0) { return this.http .get<{ servers: ServerInfo[]; total: number }>(`${this.buildApiBaseUrl()}/servers`) .pipe( map((response) => this.normalizeServerList(response, this.activeServer())), catchError(() => of([])) ); } const requests = onlineEndpoints.map((endpoint) => this.http .get<{ servers: ServerInfo[]; total: number }>(`${endpoint.url}/api/servers`) .pipe( map((response) => this.normalizeServerList(response, endpoint)), catchError(() => of([] as ServerInfo[])) ) ); return forkJoin(requests).pipe(map((resultArrays) => resultArrays.flat())); } /** Remove duplicate servers (by `id`), keeping the first occurrence. */ private deduplicateById(items: T[]): T[] { const seen = new Set(); return items.filter((item) => { if (seen.has(item.id)) return false; seen.add(item.id); return true; }); } private normalizeServerList( response: { servers: ServerInfo[]; total: number } | ServerInfo[], source?: ServerEndpoint | null ): ServerInfo[] { return this.unwrapServersResponse(response).map((server) => this.normalizeServerInfo(server, source)); } private normalizeServerInfo( server: ServerInfo | Record, source?: ServerEndpoint | null ): ServerInfo { const candidate = server as Record; const userCount = typeof candidate['userCount'] === 'number' ? candidate['userCount'] : (typeof candidate['currentUsers'] === 'number' ? candidate['currentUsers'] : 0); const maxUsers = typeof candidate['maxUsers'] === 'number' ? candidate['maxUsers'] : 0; const isPrivate = typeof candidate['isPrivate'] === 'boolean' ? candidate['isPrivate'] : candidate['isPrivate'] === 1; return { id: typeof candidate['id'] === 'string' ? candidate['id'] : '', name: typeof candidate['name'] === 'string' ? candidate['name'] : 'Unnamed server', description: typeof candidate['description'] === 'string' ? candidate['description'] : undefined, topic: typeof candidate['topic'] === 'string' ? candidate['topic'] : undefined, hostName: typeof candidate['hostName'] === 'string' ? candidate['hostName'] : (typeof candidate['sourceName'] === 'string' ? candidate['sourceName'] : (source?.name ?? 'Unknown API')), ownerId: typeof candidate['ownerId'] === 'string' ? candidate['ownerId'] : undefined, ownerName: typeof candidate['ownerName'] === 'string' ? candidate['ownerName'] : undefined, ownerPublicKey: typeof candidate['ownerPublicKey'] === 'string' ? candidate['ownerPublicKey'] : undefined, userCount, maxUsers, isPrivate, tags: Array.isArray(candidate['tags']) ? candidate['tags'] as string[] : [], createdAt: typeof candidate['createdAt'] === 'number' ? candidate['createdAt'] : Date.now(), sourceId: typeof candidate['sourceId'] === 'string' ? candidate['sourceId'] : source?.id, sourceName: typeof candidate['sourceName'] === 'string' ? candidate['sourceName'] : source?.name }; } /** Load endpoints from localStorage, syncing the built-in default endpoint if needed. */ private loadEndpoints(): void { const stored = localStorage.getItem(ENDPOINTS_STORAGE_KEY); if (!stored) { this.initialiseDefaultEndpoint(); return; } try { let endpoints = JSON.parse(stored) as ServerEndpoint[]; // Ensure at least one endpoint is active if (endpoints.length > 0 && !endpoints.some((ep) => ep.isActive)) { endpoints[0].isActive = true; } const defaultServerUrl = buildDefaultServerUrl(); endpoints = endpoints.map((endpoint) => { if (endpoint.isDefault) { return { ...endpoint, url: defaultServerUrl }; } return endpoint; }); this._servers.set(endpoints); this.saveEndpoints(); } catch { this.initialiseDefaultEndpoint(); } } /** Create and persist the built-in default endpoint. */ private initialiseDefaultEndpoint(): void { const defaultEndpoint: ServerEndpoint = { ...DEFAULT_ENDPOINT, id: uuidv4() }; this._servers.set([defaultEndpoint]); this.saveEndpoints(); } /** Persist the current endpoint list to localStorage. */ private saveEndpoints(): void { localStorage.setItem(ENDPOINTS_STORAGE_KEY, JSON.stringify(this._servers())); } }