import { Injectable, computed, inject, signal, type Signal } from '@angular/core'; import { v4 as uuidv4 } from 'uuid'; import { environment } from '../../../../../environments/environment'; import { buildDefaultEndpointTemplates, buildDefaultServerDefinitions, ensureAnyActiveEndpoint, ensureCompatibleActiveEndpoint, findDefaultEndpointKeyByUrl, hasEndpointForDefault, matchDefaultEndpointTemplate, sanitiseServerBaseUrl } from '../../domain/logic/server-endpoint-defaults.logic'; import { ServerEndpointStorageService } from '../../infrastructure/services/server-endpoint-storage.service'; import type { ConfiguredDefaultServerDefinition, DefaultEndpointTemplate, ServerEndpoint, ServerEndpointVersions } from '../../domain/models/server-directory.model'; function resolveDefaultHttpProtocol(): 'http' | 'https' { return typeof window !== 'undefined' && window.location?.protocol === 'https:' ? 'https' : 'http'; } @Injectable({ providedIn: 'root' }) export class ServerEndpointStateService { readonly servers: Signal; readonly activeServers: Signal; readonly hasMissingDefaultServers: Signal; readonly activeServer: Signal; private readonly storage = inject(ServerEndpointStorageService); private readonly _servers = signal([]); private readonly defaultEndpoints: DefaultEndpointTemplate[]; private readonly primaryDefaultServerUrl: string; constructor() { const defaultServerDefinitions = buildDefaultServerDefinitions( Array.isArray(environment.defaultServers) ? environment.defaultServers as ConfiguredDefaultServerDefinition[] : [], environment.defaultServerUrl, resolveDefaultHttpProtocol() ); this.defaultEndpoints = buildDefaultEndpointTemplates(defaultServerDefinitions); this.primaryDefaultServerUrl = this.defaultEndpoints[0]?.url ?? 'http://localhost:3001'; this.servers = computed(() => this._servers()); this.activeServers = computed(() => this._servers().filter((endpoint) => endpoint.isActive && endpoint.status !== 'incompatible') ); this.hasMissingDefaultServers = computed(() => this.defaultEndpoints.some((endpoint) => !hasEndpointForDefault(this._servers(), endpoint)) ); this.activeServer = computed(() => this.activeServers()[0] ?? null); this.loadEndpoints(); } getPrimaryDefaultServerUrl(): string { return this.primaryDefaultServerUrl; } sanitiseUrl(rawUrl: string): string { return sanitiseServerBaseUrl(rawUrl); } addServer(server: { name: string; url: string }): ServerEndpoint { const newEndpoint: ServerEndpoint = { id: uuidv4(), name: server.name, url: this.sanitiseUrl(server.url), isActive: true, isDefault: false, status: 'unknown' }; this._servers.update((endpoints) => [...endpoints, newEndpoint]); this.saveEndpoints(); return newEndpoint; } ensureServerEndpoint( server: { name: string; url: string }, options?: { setActive?: boolean } ): ServerEndpoint { const existing = this.findServerByUrl(server.url); if (existing) { if (options?.setActive) { this.setActiveServer(existing.id); } return existing; } const created = this.addServer(server); if (options?.setActive) { this.setActiveServer(created.id); } return created; } findServerByUrl(url: string): ServerEndpoint | undefined { const sanitisedUrl = this.sanitiseUrl(url); const exactEndpoint = this._servers().find((endpoint) => this.sanitiseUrl(endpoint.url) === sanitisedUrl); return exactEndpoint ?? this.findHttpEndpointForHttpsUrl(sanitisedUrl); } resolveCanonicalEndpoint(endpoint: ServerEndpoint | null | undefined): ServerEndpoint | null { if (!endpoint?.instanceId) { return endpoint ?? null; } const equivalentEndpoints = this._servers().filter((candidate) => candidate.instanceId === endpoint.instanceId); if (equivalentEndpoints.length <= 1) { return endpoint; } return [...equivalentEndpoints].sort((left, right) => this.compareEndpointPreference(left, right))[0] ?? endpoint; } removeServer(endpointId: string): void { const endpoints = this._servers(); const target = endpoints.find((endpoint) => endpoint.id === endpointId); if (!target || endpoints.length <= 1) { return; } if (target.isDefault) { this.markDefaultEndpointRemoved(target); this.clearDefaultEndpointDisabled(target); } const updatedEndpoints = ensureAnyActiveEndpoint( endpoints.filter((endpoint) => endpoint.id !== endpointId) ); this._servers.set(updatedEndpoints); this.saveEndpoints(); } restoreDefaultServers(): ServerEndpoint[] { const restoredEndpoints = this.defaultEndpoints .filter((defaultEndpoint) => !hasEndpointForDefault(this._servers(), defaultEndpoint)) .map((defaultEndpoint) => ({ ...defaultEndpoint, id: uuidv4(), isActive: true })); if (restoredEndpoints.length === 0) { this.storage.clearRemovedDefaultEndpointKeys(); return []; } this._servers.update((endpoints) => ensureAnyActiveEndpoint([...endpoints, ...restoredEndpoints])); this.storage.clearRemovedDefaultEndpointKeys(); this.clearDisabledDefaultEndpointKeys(restoredEndpoints); this.saveEndpoints(); return restoredEndpoints; } setActiveServer(endpointId: string): void { this._servers.update((endpoints) => { const target = endpoints.find((endpoint) => endpoint.id === endpointId); if (!target || target.status === 'incompatible') { return endpoints; } return endpoints.map((endpoint) => endpoint.id === endpointId ? { ...endpoint, isActive: true } : endpoint ); }); const target = this._servers().find((endpoint) => endpoint.id === endpointId); if (target?.isDefault) { this.clearDefaultEndpointDisabled(target); } this.saveEndpoints(); } deactivateServer(endpointId: string): void { if (this.activeServers().length <= 1) { return; } this._servers.update((endpoints) => endpoints.map((endpoint) => endpoint.id === endpointId ? { ...endpoint, isActive: false } : endpoint ) ); const target = this._servers().find((endpoint) => endpoint.id === endpointId); if (target?.isDefault) { this.markDefaultEndpointDisabled(target); } this.saveEndpoints(); } updateServerStatus( endpointId: string, status: ServerEndpoint['status'], latency?: number, versions?: ServerEndpointVersions, serverTag?: string ): void { this._servers.update((endpoints) => ensureCompatibleActiveEndpoint(endpoints.map((endpoint) => { if (endpoint.id !== endpointId) { return endpoint; } return { ...endpoint, instanceId: versions?.serverInstanceId ?? endpoint.instanceId, status, latency, serverTag: serverTag ?? endpoint.serverTag, isActive: status === 'incompatible' && !endpoint.isDefault ? false : endpoint.isActive, serverVersion: versions?.serverVersion ?? endpoint.serverVersion, clientVersion: versions?.clientVersion ?? endpoint.clientVersion }; }))); this.saveEndpoints(); } private loadEndpoints(): void { const storedEndpoints = this.storage.loadEndpoints(); if (!storedEndpoints) { this.initialiseDefaultEndpoints(); return; } this._servers.set(this.reconcileStoredEndpoints(storedEndpoints)); this.saveEndpoints(); } private initialiseDefaultEndpoints(): void { this._servers.set(this.defaultEndpoints.map((endpoint) => ({ ...endpoint, id: uuidv4() }))); this.saveEndpoints(); } private reconcileStoredEndpoints(storedEndpoints: ServerEndpoint[]): ServerEndpoint[] { const reconciled: ServerEndpoint[] = []; const claimedDefaultKeys = new Set(); const disabledDefaultKeys = this.storage.loadDisabledDefaultEndpointKeys(); const removedDefaultKeys = this.storage.loadRemovedDefaultEndpointKeys(); for (const endpoint of storedEndpoints) { if (!endpoint || typeof endpoint.id !== 'string' || typeof endpoint.url !== 'string') { continue; } const sanitisedUrl = this.sanitiseUrl(endpoint.url); const matchedDefault = matchDefaultEndpointTemplate( this.defaultEndpoints, endpoint, sanitisedUrl, claimedDefaultKeys ); if (matchedDefault) { claimedDefaultKeys.add(matchedDefault.defaultKey); reconciled.push({ ...endpoint, name: matchedDefault.name, url: matchedDefault.url, isActive: this.isDefaultEndpointActive(matchedDefault.defaultKey, disabledDefaultKeys), isDefault: true, defaultKey: matchedDefault.defaultKey, status: endpoint.status ?? 'unknown' }); continue; } reconciled.push({ ...endpoint, url: sanitisedUrl, status: endpoint.status ?? 'unknown' }); } for (const defaultEndpoint of this.defaultEndpoints) { if ( !claimedDefaultKeys.has(defaultEndpoint.defaultKey) && !removedDefaultKeys.has(defaultEndpoint.defaultKey) && !hasEndpointForDefault(reconciled, defaultEndpoint) ) { reconciled.push({ ...defaultEndpoint, id: uuidv4(), isActive: this.isDefaultEndpointActive(defaultEndpoint.defaultKey, disabledDefaultKeys) }); } } return ensureAnyActiveEndpoint(reconciled); } private markDefaultEndpointRemoved(endpoint: ServerEndpoint): void { const defaultKey = endpoint.defaultKey ?? findDefaultEndpointKeyByUrl(this.defaultEndpoints, endpoint.url); if (!defaultKey) { return; } const removedDefaultKeys = this.storage.loadRemovedDefaultEndpointKeys(); removedDefaultKeys.add(defaultKey); this.storage.saveRemovedDefaultEndpointKeys(removedDefaultKeys); } private markDefaultEndpointDisabled(endpoint: ServerEndpoint): void { const defaultKey = endpoint.defaultKey ?? findDefaultEndpointKeyByUrl(this.defaultEndpoints, endpoint.url); if (!defaultKey) { return; } const disabledDefaultKeys = this.storage.loadDisabledDefaultEndpointKeys(); disabledDefaultKeys.add(defaultKey); this.storage.saveDisabledDefaultEndpointKeys(disabledDefaultKeys); } private clearDefaultEndpointDisabled(endpoint: ServerEndpoint): void { const defaultKey = endpoint.defaultKey ?? findDefaultEndpointKeyByUrl(this.defaultEndpoints, endpoint.url); if (!defaultKey) { return; } const disabledDefaultKeys = this.storage.loadDisabledDefaultEndpointKeys(); if (!disabledDefaultKeys.delete(defaultKey)) { return; } this.storage.saveDisabledDefaultEndpointKeys(disabledDefaultKeys); } private clearDisabledDefaultEndpointKeys(endpoints: ServerEndpoint[]): void { const disabledDefaultKeys = this.storage.loadDisabledDefaultEndpointKeys(); let didChange = false; for (const endpoint of endpoints) { const defaultKey = endpoint.defaultKey ?? findDefaultEndpointKeyByUrl(this.defaultEndpoints, endpoint.url); if (!defaultKey) { continue; } didChange = disabledDefaultKeys.delete(defaultKey) || didChange; } if (!didChange) { return; } this.storage.saveDisabledDefaultEndpointKeys(disabledDefaultKeys); } private isDefaultEndpointActive( defaultKey: string, disabledDefaultKeys: Set ): boolean { return !disabledDefaultKeys.has(defaultKey); } private saveEndpoints(): void { this.storage.saveEndpoints(this._servers()); } private compareEndpointPreference(left: ServerEndpoint, right: ServerEndpoint): number { const scoreDifference = this.endpointPreferenceScore(right) - this.endpointPreferenceScore(left); if (scoreDifference !== 0) { return scoreDifference; } return left.url.localeCompare(right.url); } private endpointPreferenceScore(endpoint: ServerEndpoint): number { let score = 0; if (endpoint.isDefault) { score += 4; } if (this.usesSecureProtocol(endpoint.url)) { score += 2; } if (!this.isIpHost(endpoint.url)) { score += 1; } return score; } private usesSecureProtocol(url: string): boolean { try { return new URL(url).protocol === 'https:'; } catch { return false; } } private isIpHost(url: string): boolean { try { const hostname = new URL(url).hostname; return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(hostname) || hostname.includes(':'); } catch { return false; } } private findHttpEndpointForHttpsUrl(url: string): ServerEndpoint | undefined { const requestedUrl = this.parseUrl(url); if (requestedUrl?.protocol !== 'https:') { return undefined; } return this._servers().find((endpoint) => { const endpointUrl = this.parseUrl(endpoint.url); return endpointUrl?.protocol === 'http:' && endpointUrl.hostname === requestedUrl.hostname && endpointUrl.port === requestedUrl.port; }); } private parseUrl(url: string): URL | null { try { return new URL(url); } catch { return null; } } }