This commit is contained in:
2025-12-28 05:37:19 +01:00
commit 87c722b5ae
74 changed files with 10264 additions and 0 deletions

View File

@@ -0,0 +1,392 @@
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';
export interface ServerEndpoint {
id: string;
name: string;
url: string;
isActive: boolean;
isDefault: boolean;
status: 'online' | 'offline' | 'checking' | 'unknown';
latency?: number;
}
const STORAGE_KEY = 'metoyou_server_endpoints';
const DEFAULT_SERVER: Omit<ServerEndpoint, 'id'> = {
name: 'Local Server',
url: 'http://localhost:3001',
isActive: true,
isDefault: true,
status: 'unknown',
};
@Injectable({
providedIn: 'root',
})
export class ServerDirectoryService {
private readonly _servers = signal<ServerEndpoint[]>([]);
private _searchAllServers = false;
readonly servers = computed(() => this._servers());
readonly activeServer = computed(() => this._servers().find((s) => s.isActive) || this._servers()[0]);
constructor(private http: HttpClient) {
this.loadServers();
}
private loadServers(): void {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
try {
const servers = JSON.parse(stored) as ServerEndpoint[];
// Ensure at least one is active
if (!servers.some((s) => s.isActive) && servers.length > 0) {
servers[0].isActive = true;
}
this._servers.set(servers);
} catch {
this.initializeDefaultServer();
}
} else {
this.initializeDefaultServer();
}
}
private initializeDefaultServer(): void {
const defaultServer: ServerEndpoint = {
...DEFAULT_SERVER,
id: crypto.randomUUID(),
};
this._servers.set([defaultServer]);
this.saveServers();
}
private saveServers(): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this._servers()));
}
private get baseUrl(): string {
const active = this.activeServer();
return active ? `${active.url}/api` : 'http://localhost:3001/api';
}
// Expose API base URL for consumers that need to call server endpoints
getApiBaseUrl(): string {
return this.baseUrl;
}
// Server management methods
addServer(server: { name: string; url: string }): void {
const newServer: ServerEndpoint = {
id: crypto.randomUUID(),
name: server.name,
url: server.url.replace(/\/$/, ''), // Remove trailing slash
isActive: false,
isDefault: false,
status: 'unknown',
};
this._servers.update((servers) => [...servers, newServer]);
this.saveServers();
}
removeServer(id: string): void {
const servers = this._servers();
const server = servers.find((s) => s.id === id);
if (server?.isDefault) return; // Can't remove default server
const wasActive = server?.isActive;
this._servers.update((servers) => servers.filter((s) => s.id !== id));
// If removed server was active, activate the first server
if (wasActive) {
this._servers.update((servers) => {
if (servers.length > 0) {
servers[0].isActive = true;
}
return [...servers];
});
}
this.saveServers();
}
setActiveServer(id: string): void {
this._servers.update((servers) =>
servers.map((s) => ({
...s,
isActive: s.id === id,
}))
);
this.saveServers();
}
updateServerStatus(id: string, status: ServerEndpoint['status'], latency?: number): void {
this._servers.update((servers) =>
servers.map((s) => (s.id === id ? { ...s, status, latency } : s))
);
this.saveServers();
}
setSearchAllServers(value: boolean): void {
this._searchAllServers = value;
}
async testServer(id: string): Promise<boolean> {
const server = this._servers().find((s) => s.id === id);
if (!server) return false;
this.updateServerStatus(id, 'checking');
const startTime = Date.now();
try {
const response = await fetch(`${server.url}/api/health`, {
method: 'GET',
signal: AbortSignal.timeout(5000),
});
const latency = Date.now() - startTime;
if (response.ok) {
this.updateServerStatus(id, 'online', latency);
return true;
} else {
this.updateServerStatus(id, 'offline');
return false;
}
} catch {
// Try alternative endpoint
try {
const response = await fetch(`${server.url}/api/servers`, {
method: 'GET',
signal: AbortSignal.timeout(5000),
});
const latency = Date.now() - startTime;
if (response.ok) {
this.updateServerStatus(id, 'online', latency);
return true;
}
} catch {
// Server is offline
}
this.updateServerStatus(id, 'offline');
return false;
}
}
async testAllServers(): Promise<void> {
const servers = this._servers();
await Promise.all(servers.map((s) => this.testServer(s.id)));
}
// Search for servers - optionally across all configured endpoints
searchServers(query: string): Observable<ServerInfo[]> {
if (this._searchAllServers) {
return this.searchAllServerEndpoints(query);
}
return this.searchSingleServer(query, this.baseUrl);
}
private searchSingleServer(query: string, baseUrl: string): Observable<ServerInfo[]> {
const params = new HttpParams().set('q', query);
return this.http.get<{ servers: ServerInfo[]; total: number }>(`${baseUrl}/servers`, { params }).pipe(
map((response) => {
// Handle both wrapped response { servers: [...] } and direct array
if (Array.isArray(response)) {
return response;
}
return response.servers || [];
}),
catchError((error) => {
console.error('Failed to search servers:', error);
return of([]);
})
);
}
private searchAllServerEndpoints(query: string): Observable<ServerInfo[]> {
const servers = this._servers().filter((s) => s.status !== 'offline');
if (servers.length === 0) {
return this.searchSingleServer(query, this.baseUrl);
}
const requests = servers.map((server) =>
this.searchSingleServer(query, `${server.url}/api`).pipe(
map((results) =>
results.map((r) => ({
...r,
sourceId: server.id,
sourceName: server.name,
}))
)
)
);
return forkJoin(requests).pipe(
map((results) => results.flat()),
// Remove duplicates based on server ID
map((servers) => {
const seen = new Set<string>();
return servers.filter((s) => {
if (seen.has(s.id)) return false;
seen.add(s.id);
return true;
});
})
);
}
// Get all available servers
getServers(): Observable<ServerInfo[]> {
if (this._searchAllServers) {
return this.getAllServersFromAllEndpoints();
}
return this.http.get<{ servers: ServerInfo[]; total: number }>(`${this.baseUrl}/servers`).pipe(
map((response) => {
if (Array.isArray(response)) {
return response;
}
return response.servers || [];
}),
catchError((error) => {
console.error('Failed to get servers:', error);
return of([]);
})
);
}
private getAllServersFromAllEndpoints(): Observable<ServerInfo[]> {
const servers = this._servers().filter((s) => s.status !== 'offline');
if (servers.length === 0) {
return this.http.get<{ servers: ServerInfo[]; total: number }>(`${this.baseUrl}/servers`).pipe(
map((response) => (Array.isArray(response) ? response : response.servers || [])),
catchError(() => of([]))
);
}
const requests = servers.map((server) =>
this.http.get<{ servers: ServerInfo[]; total: number }>(`${server.url}/api/servers`).pipe(
map((response) => {
const results = Array.isArray(response) ? response : response.servers || [];
return results.map((r) => ({
...r,
sourceId: server.id,
sourceName: server.name,
}));
}),
catchError(() => of([]))
)
);
return forkJoin(requests).pipe(map((results) => results.flat()));
}
// Get server details
getServer(serverId: string): Observable<ServerInfo | null> {
return this.http.get<ServerInfo>(`${this.baseUrl}/servers/${serverId}`).pipe(
catchError((error) => {
console.error('Failed to get server:', error);
return of(null);
})
);
}
// Register a new server (with optional pre-generated ID)
registerServer(server: Omit<ServerInfo, 'createdAt'> & { id?: string }): Observable<ServerInfo> {
return this.http.post<ServerInfo>(`${this.baseUrl}/servers`, server).pipe(
catchError((error) => {
console.error('Failed to register server:', error);
return throwError(() => error);
})
);
}
// Update server info
updateServer(serverId: string, updates: Partial<ServerInfo>): Observable<ServerInfo> {
return this.http.patch<ServerInfo>(`${this.baseUrl}/servers/${serverId}`, updates).pipe(
catchError((error) => {
console.error('Failed to update server:', error);
return throwError(() => error);
})
);
}
// Remove server from directory
unregisterServer(serverId: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/servers/${serverId}`).pipe(
catchError((error) => {
console.error('Failed to unregister server:', error);
return throwError(() => error);
})
);
}
// Get users in a server
getServerUsers(serverId: string): Observable<User[]> {
return this.http.get<User[]>(`${this.baseUrl}/servers/${serverId}/users`).pipe(
catchError((error) => {
console.error('Failed to get server users:', error);
return of([]);
})
);
}
// Send join request
requestJoin(request: JoinRequest): Observable<{ success: boolean; signalingUrl?: string }> {
return this.http
.post<{ success: boolean; signalingUrl?: string }>(
`${this.baseUrl}/servers/${request.roomId}/join`,
request
)
.pipe(
catchError((error) => {
console.error('Failed to send join request:', error);
return throwError(() => error);
})
);
}
// Notify server of user leaving
notifyLeave(serverId: string, userId: string): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/servers/${serverId}/leave`, { userId }).pipe(
catchError((error) => {
console.error('Failed to notify leave:', error);
return of(undefined);
})
);
}
// Update user count for a server
updateUserCount(serverId: string, count: number): Observable<void> {
return this.http.patch<void>(`${this.baseUrl}/servers/${serverId}/user-count`, { count }).pipe(
catchError((error) => {
console.error('Failed to update user count:', error);
return of(undefined);
})
);
}
// Heartbeat to keep server active in directory
sendHeartbeat(serverId: string): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/servers/${serverId}/heartbeat`, {}).pipe(
catchError((error) => {
console.error('Failed to send heartbeat:', error);
return of(undefined);
})
);
}
// Get the WebSocket URL for the active server
getWebSocketUrl(): string {
const active = this.activeServer();
if (!active) return 'ws://localhost:3001';
// Convert http(s) to ws(s)
return active.url.replace(/^http/, 'ws');
}
}