Files
Toju/src/app/core/services/server-directory.service.ts

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()));
}
}