832 lines
25 KiB
TypeScript
832 lines
25 KiB
TypeScript
/* 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, 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;
|
|
}
|
|
|
|
export interface ServerSourceSelector {
|
|
sourceId?: string;
|
|
sourceUrl?: string;
|
|
}
|
|
|
|
export interface ServerJoinAccessRequest {
|
|
roomId: string;
|
|
userId: string;
|
|
userPublicKey: string;
|
|
displayName: string;
|
|
password?: string;
|
|
inviteId?: string;
|
|
}
|
|
|
|
export interface ServerJoinAccessResponse {
|
|
success: boolean;
|
|
signalingUrl: string;
|
|
joinedBefore: boolean;
|
|
via: 'membership' | 'password' | 'invite' | 'public';
|
|
server: ServerInfo;
|
|
}
|
|
|
|
export interface CreateServerInviteRequest {
|
|
requesterUserId: string;
|
|
requesterDisplayName?: string;
|
|
requesterRole?: string;
|
|
}
|
|
|
|
export interface ServerInviteInfo {
|
|
id: string;
|
|
serverId: string;
|
|
createdAt: number;
|
|
expiresAt: number;
|
|
inviteUrl: string;
|
|
browserUrl: string;
|
|
appUrl: string;
|
|
sourceUrl: string;
|
|
createdBy?: string;
|
|
createdByDisplayName?: string;
|
|
isExpired: boolean;
|
|
server: ServerInfo;
|
|
}
|
|
|
|
export interface KickServerMemberRequest {
|
|
actorUserId: string;
|
|
actorRole?: string;
|
|
targetUserId: string;
|
|
}
|
|
|
|
export interface BanServerMemberRequest extends KickServerMemberRequest {
|
|
banId?: string;
|
|
displayName?: string;
|
|
reason?: string;
|
|
expiresAt?: number;
|
|
}
|
|
|
|
export interface UnbanServerMemberRequest {
|
|
actorUserId: string;
|
|
actorRole?: string;
|
|
banId?: string;
|
|
targetUserId?: string;
|
|
}
|
|
|
|
/** 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<ServerEndpoint, 'id'> = {
|
|
name: 'Default 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<ServerEndpoint[]>([]);
|
|
|
|
/** 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 }): ServerEndpoint {
|
|
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();
|
|
return newEndpoint;
|
|
}
|
|
|
|
/** Ensure an endpoint exists for a given URL, optionally activating it. */
|
|
ensureServerEndpoint(
|
|
server: { name: string; url: string },
|
|
options?: { setActive?: boolean }
|
|
): ServerEndpoint {
|
|
const sanitisedUrl = this.sanitiseUrl(server.url);
|
|
const existing = this.findServerByUrl(sanitisedUrl);
|
|
|
|
if (existing) {
|
|
if (options?.setActive) {
|
|
this.setActiveServer(existing.id);
|
|
}
|
|
|
|
return existing;
|
|
}
|
|
|
|
const created = this.addServer({ name: server.name,
|
|
url: sanitisedUrl });
|
|
|
|
if (options?.setActive) {
|
|
this.setActiveServer(created.id);
|
|
}
|
|
|
|
return created;
|
|
}
|
|
|
|
/** Find a configured endpoint by URL. */
|
|
findServerByUrl(url: string): ServerEndpoint | undefined {
|
|
const sanitisedUrl = this.sanitiseUrl(url);
|
|
|
|
return this._servers().find((endpoint) => this.sanitiseUrl(endpoint.url) === sanitisedUrl);
|
|
}
|
|
|
|
/**
|
|
* 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<boolean> {
|
|
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<void> {
|
|
const endpoints = this._servers();
|
|
|
|
await Promise.all(endpoints.map((endpoint) => this.testServer(endpoint.id)));
|
|
}
|
|
|
|
/** Expose the API base URL for external consumers. */
|
|
getApiBaseUrl(selector?: ServerSourceSelector): string {
|
|
return this.buildApiBaseUrl(selector);
|
|
}
|
|
|
|
/** Get the WebSocket URL derived from the active endpoint. */
|
|
getWebSocketUrl(selector?: ServerSourceSelector): string {
|
|
return this.resolveBaseServerUrl(selector).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<ServerInfo[]> {
|
|
if (this.shouldSearchAllServers) {
|
|
return this.searchAllEndpoints(query);
|
|
}
|
|
|
|
return this.searchSingleEndpoint(query, this.buildApiBaseUrl(), this.activeServer());
|
|
}
|
|
|
|
/** Retrieve the full list of public servers. */
|
|
getServers(): Observable<ServerInfo[]> {
|
|
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, selector?: ServerSourceSelector): Observable<ServerInfo | null> {
|
|
return this.http
|
|
.get<ServerInfo>(`${this.buildApiBaseUrl(selector)}/servers/${serverId}`)
|
|
.pipe(
|
|
map((server) => this.normalizeServerInfo(server, this.resolveEndpoint(selector))),
|
|
catchError((error) => {
|
|
console.error('Failed to get server:', error);
|
|
return of(null);
|
|
})
|
|
);
|
|
}
|
|
|
|
/** Register a new server listing in the directory. */
|
|
registerServer(
|
|
server: Omit<ServerInfo, 'createdAt'> & { id?: string; password?: string | null },
|
|
selector?: ServerSourceSelector
|
|
): Observable<ServerInfo> {
|
|
return this.http
|
|
.post<ServerInfo>(`${this.buildApiBaseUrl(selector)}/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<ServerInfo> & {
|
|
currentOwnerId: string;
|
|
actingRole?: string;
|
|
password?: string | null;
|
|
},
|
|
selector?: ServerSourceSelector
|
|
): Observable<ServerInfo> {
|
|
return this.http
|
|
.put<ServerInfo>(`${this.buildApiBaseUrl(selector)}/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, selector?: ServerSourceSelector): Observable<void> {
|
|
return this.http
|
|
.delete<void>(`${this.buildApiBaseUrl(selector)}/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, selector?: ServerSourceSelector): Observable<User[]> {
|
|
return this.http
|
|
.get<User[]>(`${this.buildApiBaseUrl(selector)}/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: ServerJoinAccessRequest,
|
|
selector?: ServerSourceSelector
|
|
): Observable<ServerJoinAccessResponse> {
|
|
return this.http
|
|
.post<ServerJoinAccessResponse>(
|
|
`${this.buildApiBaseUrl(selector)}/servers/${request.roomId}/join`,
|
|
request
|
|
)
|
|
.pipe(
|
|
catchError((error) => {
|
|
console.error('Failed to send join request:', error);
|
|
return throwError(() => error);
|
|
})
|
|
);
|
|
}
|
|
|
|
/** Create an expiring invite link for a server. */
|
|
createInvite(
|
|
serverId: string,
|
|
request: CreateServerInviteRequest,
|
|
selector?: ServerSourceSelector
|
|
): Observable<ServerInviteInfo> {
|
|
return this.http
|
|
.post<ServerInviteInfo>(`${this.buildApiBaseUrl(selector)}/servers/${serverId}/invites`, request)
|
|
.pipe(
|
|
catchError((error) => {
|
|
console.error('Failed to create invite:', error);
|
|
return throwError(() => error);
|
|
})
|
|
);
|
|
}
|
|
|
|
/** Retrieve public invite metadata. */
|
|
getInvite(inviteId: string, selector?: ServerSourceSelector): Observable<ServerInviteInfo> {
|
|
return this.http
|
|
.get<ServerInviteInfo>(`${this.buildApiBaseUrl(selector)}/invites/${inviteId}`)
|
|
.pipe(
|
|
catchError((error) => {
|
|
console.error('Failed to get invite:', error);
|
|
return throwError(() => error);
|
|
})
|
|
);
|
|
}
|
|
|
|
/** Remove a member's stored join access for a server. */
|
|
kickServerMember(
|
|
serverId: string,
|
|
request: KickServerMemberRequest,
|
|
selector?: ServerSourceSelector
|
|
): Observable<void> {
|
|
return this.http
|
|
.post<void>(`${this.buildApiBaseUrl(selector)}/servers/${serverId}/moderation/kick`, request)
|
|
.pipe(
|
|
catchError((error) => {
|
|
console.error('Failed to kick server member:', error);
|
|
return throwError(() => error);
|
|
})
|
|
);
|
|
}
|
|
|
|
/** Ban a member from a server invite/password access list. */
|
|
banServerMember(
|
|
serverId: string,
|
|
request: BanServerMemberRequest,
|
|
selector?: ServerSourceSelector
|
|
): Observable<void> {
|
|
return this.http
|
|
.post<void>(`${this.buildApiBaseUrl(selector)}/servers/${serverId}/moderation/ban`, request)
|
|
.pipe(
|
|
catchError((error) => {
|
|
console.error('Failed to ban server member:', error);
|
|
return throwError(() => error);
|
|
})
|
|
);
|
|
}
|
|
|
|
/** Remove a stored server ban. */
|
|
unbanServerMember(
|
|
serverId: string,
|
|
request: UnbanServerMemberRequest,
|
|
selector?: ServerSourceSelector
|
|
): Observable<void> {
|
|
return this.http
|
|
.post<void>(`${this.buildApiBaseUrl(selector)}/servers/${serverId}/moderation/unban`, request)
|
|
.pipe(
|
|
catchError((error) => {
|
|
console.error('Failed to unban server member:', error);
|
|
return throwError(() => error);
|
|
})
|
|
);
|
|
}
|
|
|
|
/** Remove a user's remembered membership after leaving a server. */
|
|
notifyLeave(serverId: string, userId: string, selector?: ServerSourceSelector): Observable<void> {
|
|
return this.http
|
|
.post<void>(`${this.buildApiBaseUrl(selector)}/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<void> {
|
|
return this.http
|
|
.patch<void>(`${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<void> {
|
|
return this.http
|
|
.post<void>(`${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(selector?: ServerSourceSelector): string {
|
|
return `${this.resolveBaseServerUrl(selector)}/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;
|
|
}
|
|
|
|
private resolveEndpoint(selector?: ServerSourceSelector): ServerEndpoint | null {
|
|
if (selector?.sourceId) {
|
|
return this._servers().find((endpoint) => endpoint.id === selector.sourceId) ?? null;
|
|
}
|
|
|
|
if (selector?.sourceUrl) {
|
|
return this.findServerByUrl(selector.sourceUrl) ?? null;
|
|
}
|
|
|
|
return this.activeServer() ?? this._servers()[0] ?? null;
|
|
}
|
|
|
|
private resolveBaseServerUrl(selector?: ServerSourceSelector): string {
|
|
if (selector?.sourceUrl) {
|
|
return this.sanitiseUrl(selector.sourceUrl);
|
|
}
|
|
|
|
return this.resolveEndpoint(selector)?.url ?? buildDefaultServerUrl();
|
|
}
|
|
|
|
/**
|
|
* 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<ServerInfo[]> {
|
|
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<ServerInfo[]> {
|
|
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<ServerInfo[]> {
|
|
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<T extends { id: string }>(items: T[]): T[] {
|
|
const seen = new Set<string>();
|
|
|
|
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<string, unknown>,
|
|
source?: ServerEndpoint | null
|
|
): ServerInfo {
|
|
const candidate = server as Record<string, unknown>;
|
|
const sourceName = this.getStringValue(candidate['sourceName']);
|
|
const sourceUrl = this.getStringValue(candidate['sourceUrl']);
|
|
|
|
return {
|
|
id: this.getStringValue(candidate['id']) ?? '',
|
|
name: this.getStringValue(candidate['name']) ?? 'Unnamed server',
|
|
description: this.getStringValue(candidate['description']),
|
|
topic: this.getStringValue(candidate['topic']),
|
|
hostName: this.getStringValue(candidate['hostName']) ?? sourceName ?? source?.name ?? 'Unknown API',
|
|
ownerId: this.getStringValue(candidate['ownerId']),
|
|
ownerName: this.getStringValue(candidate['ownerName']),
|
|
ownerPublicKey: this.getStringValue(candidate['ownerPublicKey']),
|
|
userCount: this.getNumberValue(candidate['userCount'], this.getNumberValue(candidate['currentUsers'])),
|
|
maxUsers: this.getNumberValue(candidate['maxUsers']),
|
|
hasPassword: this.getBooleanValue(candidate['hasPassword']),
|
|
isPrivate: this.getBooleanValue(candidate['isPrivate']),
|
|
tags: Array.isArray(candidate['tags']) ? candidate['tags'] as string[] : [],
|
|
createdAt: this.getNumberValue(candidate['createdAt'], Date.now()),
|
|
sourceId: this.getStringValue(candidate['sourceId']) ?? source?.id,
|
|
sourceName: sourceName ?? source?.name,
|
|
sourceUrl: sourceUrl
|
|
? this.sanitiseUrl(sourceUrl)
|
|
: (source ? this.sanitiseUrl(source.url) : undefined)
|
|
};
|
|
}
|
|
|
|
private getBooleanValue(value: unknown): boolean {
|
|
return typeof value === 'boolean' ? value : value === 1;
|
|
}
|
|
|
|
private getNumberValue(value: unknown, fallback = 0): number {
|
|
return typeof value === 'number' ? value : fallback;
|
|
}
|
|
|
|
private getStringValue(value: unknown): string | undefined {
|
|
return typeof value === 'string' ? value : undefined;
|
|
}
|
|
|
|
/** 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()));
|
|
}
|
|
}
|