Refactor and code designing
This commit is contained in:
@@ -5,423 +5,530 @@ import { catchError, map } from 'rxjs/operators';
|
||||
import { ServerInfo, JoinRequest, User } from '../models';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'metoyou_server_endpoints';
|
||||
/** localStorage key that persists the user's configured endpoints. */
|
||||
const ENDPOINTS_STORAGE_KEY = 'metoyou_server_endpoints';
|
||||
|
||||
/** Derive default server URL from current page protocol (handles SSL toggle). */
|
||||
function getDefaultServerUrl(): string {
|
||||
/** Timeout (ms) for server health-check and alternative-endpoint pings. */
|
||||
const HEALTH_CHECK_TIMEOUT_MS = 5000;
|
||||
|
||||
/**
|
||||
* Derive the default server URL from the current page protocol so that
|
||||
* SSL/TLS is matched automatically.
|
||||
*/
|
||||
function buildDefaultServerUrl(): string {
|
||||
if (typeof window !== 'undefined' && window.location) {
|
||||
const proto = window.location.protocol === 'https:' ? 'https' : 'http';
|
||||
return `${proto}://localhost:3001`;
|
||||
const protocol = window.location.protocol === 'https:' ? 'https' : 'http';
|
||||
return `${protocol}://localhost:3001`;
|
||||
}
|
||||
return 'http://localhost:3001';
|
||||
}
|
||||
|
||||
const DEFAULT_SERVER: Omit<ServerEndpoint, 'id'> = {
|
||||
/** Blueprint for the built-in default endpoint. */
|
||||
const DEFAULT_ENDPOINT: Omit<ServerEndpoint, 'id'> = {
|
||||
name: 'Local Server',
|
||||
url: getDefaultServerUrl(),
|
||||
url: buildDefaultServerUrl(),
|
||||
isActive: true,
|
||||
isDefault: true,
|
||||
status: 'unknown',
|
||||
};
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
/**
|
||||
* 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[]>([]);
|
||||
private _searchAllServers = false;
|
||||
|
||||
/** 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());
|
||||
readonly activeServer = computed(() => this._servers().find((s) => s.isActive) || this._servers()[0]);
|
||||
|
||||
constructor(private http: HttpClient) {
|
||||
this.loadServers();
|
||||
/** 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();
|
||||
}
|
||||
|
||||
private loadServers(): void {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
try {
|
||||
let 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;
|
||||
}
|
||||
// Migrate default localhost entries to match current protocol
|
||||
const expectedProto = (typeof window !== 'undefined' && window.location?.protocol === 'https:') ? 'https' : 'http';
|
||||
servers = servers.map((s) => {
|
||||
if (s.isDefault && /^https?:\/\/localhost:\d+$/.test(s.url)) {
|
||||
return { ...s, url: s.url.replace(/^https?/, expectedProto) };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
this._servers.set(servers);
|
||||
this.saveServers();
|
||||
} catch {
|
||||
this.initializeDefaultServer();
|
||||
/**
|
||||
* 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<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;
|
||||
}
|
||||
} else {
|
||||
this.initializeDefaultServer();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
private initializeDefaultServer(): void {
|
||||
const defaultServer: ServerEndpoint = {
|
||||
...DEFAULT_SERVER,
|
||||
id: uuidv4(),
|
||||
};
|
||||
this._servers.set([defaultServer]);
|
||||
this.saveServers();
|
||||
/** Probe all configured endpoints in parallel. */
|
||||
async testAllServers(): Promise<void> {
|
||||
const endpoints = this._servers();
|
||||
await Promise.all(endpoints.map((endpoint) => this.testServer(endpoint.id)));
|
||||
}
|
||||
|
||||
private saveServers(): void {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this._servers()));
|
||||
/** Expose the API base URL for external consumers. */
|
||||
getApiBaseUrl(): string {
|
||||
return this.buildApiBaseUrl();
|
||||
}
|
||||
|
||||
private get baseUrl(): string {
|
||||
/** Get the WebSocket URL derived from the active endpoint. */
|
||||
getWebSocketUrl(): string {
|
||||
const active = this.activeServer();
|
||||
const raw = active ? active.url : getDefaultServerUrl();
|
||||
// Strip trailing slashes and any accidental '/api'
|
||||
let base = raw.replace(/\/+$/,'');
|
||||
if (!active) {
|
||||
const protocol = (typeof window !== 'undefined' && window.location?.protocol === 'https:') ? 'wss' : 'ws';
|
||||
return `${protocol}://localhost:3001`;
|
||||
}
|
||||
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<ServerInfo[]> {
|
||||
if (this.shouldSearchAllServers) {
|
||||
return this.searchAllEndpoints(query);
|
||||
}
|
||||
return this.searchSingleEndpoint(query, this.buildApiBaseUrl());
|
||||
}
|
||||
|
||||
/** 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.unwrapServersResponse(response)),
|
||||
catchError((error) => {
|
||||
console.error('Failed to get servers:', error);
|
||||
return of([]);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Fetch details for a single server. */
|
||||
getServer(serverId: string): Observable<ServerInfo | null> {
|
||||
return this.http
|
||||
.get<ServerInfo>(`${this.buildApiBaseUrl()}/servers/${serverId}`)
|
||||
.pipe(
|
||||
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 },
|
||||
): Observable<ServerInfo> {
|
||||
return this.http
|
||||
.post<ServerInfo>(`${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<ServerInfo>,
|
||||
): Observable<ServerInfo> {
|
||||
return this.http
|
||||
.patch<ServerInfo>(`${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<void> {
|
||||
return this.http
|
||||
.delete<void>(`${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<User[]> {
|
||||
return this.http
|
||||
.get<User[]>(`${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<void> {
|
||||
return this.http
|
||||
.post<void>(`${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<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(): 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`;
|
||||
}
|
||||
|
||||
// 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: uuidv4(),
|
||||
name: server.name,
|
||||
// Sanitize: remove trailing slashes and any '/api'
|
||||
url: (() => {
|
||||
let u = server.url.trim();
|
||||
u = u.replace(/\/+$/,'');
|
||||
if (u.toLowerCase().endsWith('/api')) u = u.slice(0, -4);
|
||||
return u;
|
||||
})(),
|
||||
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];
|
||||
});
|
||||
/** 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);
|
||||
}
|
||||
this.saveServers();
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
setActiveServer(id: string): void {
|
||||
this._servers.update((servers) =>
|
||||
servers.map((s) => ({
|
||||
...s,
|
||||
isActive: s.id === id,
|
||||
}))
|
||||
);
|
||||
this.saveServers();
|
||||
/**
|
||||
* 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 ?? [];
|
||||
}
|
||||
|
||||
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[]> {
|
||||
/** Search a single endpoint for servers matching a query. */
|
||||
private searchSingleEndpoint(
|
||||
query: string,
|
||||
apiBaseUrl: 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([]);
|
||||
})
|
||||
);
|
||||
return this.http
|
||||
.get<{ servers: ServerInfo[]; total: number }>(`${apiBaseUrl}/servers`, { params })
|
||||
.pipe(
|
||||
map((response) => this.unwrapServersResponse(response)),
|
||||
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');
|
||||
/** 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 (servers.length === 0) {
|
||||
return this.searchSingleServer(query, this.baseUrl);
|
||||
if (onlineEndpoints.length === 0) {
|
||||
return this.searchSingleEndpoint(query, this.buildApiBaseUrl());
|
||||
}
|
||||
|
||||
const requests = servers.map((server) =>
|
||||
this.searchSingleServer(query, `${server.url}/api`).pipe(
|
||||
const requests = onlineEndpoints.map((endpoint) =>
|
||||
this.searchSingleEndpoint(query, `${endpoint.url}/api`).pipe(
|
||||
map((results) =>
|
||||
results.map((r) => ({
|
||||
...r,
|
||||
sourceId: server.id,
|
||||
sourceName: server.name,
|
||||
}))
|
||||
)
|
||||
)
|
||||
results.map((server) => ({
|
||||
...server,
|
||||
sourceId: endpoint.id,
|
||||
sourceName: endpoint.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([]);
|
||||
})
|
||||
map((resultArrays) => resultArrays.flat()),
|
||||
map((servers) => this.deduplicateById(servers)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Retrieve all servers from all non-offline endpoints. */
|
||||
private getAllServersFromAllEndpoints(): Observable<ServerInfo[]> {
|
||||
const servers = this._servers().filter((s) => s.status !== 'offline');
|
||||
const onlineEndpoints = this._servers().filter(
|
||||
(endpoint) => endpoint.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([]))
|
||||
);
|
||||
if (onlineEndpoints.length === 0) {
|
||||
return this.http
|
||||
.get<{ servers: ServerInfo[]; total: number }>(`${this.buildApiBaseUrl()}/servers`)
|
||||
.pipe(
|
||||
map((response) => this.unwrapServersResponse(response)),
|
||||
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([]))
|
||||
)
|
||||
const requests = onlineEndpoints.map((endpoint) =>
|
||||
this.http
|
||||
.get<{ servers: ServerInfo[]; total: number }>(`${endpoint.url}/api/servers`)
|
||||
.pipe(
|
||||
map((response) => {
|
||||
const results = this.unwrapServersResponse(response);
|
||||
return results.map((server) => ({
|
||||
...server,
|
||||
sourceId: endpoint.id,
|
||||
sourceName: endpoint.name,
|
||||
}));
|
||||
}),
|
||||
catchError(() => of([] as ServerInfo[])),
|
||||
),
|
||||
);
|
||||
|
||||
return forkJoin(requests).pipe(map((results) => results.flat()));
|
||||
return forkJoin(requests).pipe(map((resultArrays) => resultArrays.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);
|
||||
})
|
||||
);
|
||||
/** 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;
|
||||
});
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const proto = (typeof window !== 'undefined' && window.location?.protocol === 'https:') ? 'wss' : 'ws';
|
||||
return `${proto}://localhost:3001`;
|
||||
/** Load endpoints from localStorage, migrating protocol if needed. */
|
||||
private loadEndpoints(): void {
|
||||
const stored = localStorage.getItem(ENDPOINTS_STORAGE_KEY);
|
||||
if (!stored) {
|
||||
this.initialiseDefaultEndpoint();
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert http(s) to ws(s)
|
||||
return active.url.replace(/^http/, 'ws');
|
||||
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;
|
||||
}
|
||||
|
||||
// Migrate localhost entries to match the current page protocol
|
||||
const expectedProtocol =
|
||||
typeof window !== 'undefined' && window.location?.protocol === 'https:'
|
||||
? 'https'
|
||||
: 'http';
|
||||
|
||||
endpoints = endpoints.map((endpoint) => {
|
||||
if (endpoint.isDefault && /^https?:\/\/localhost:\d+$/.test(endpoint.url)) {
|
||||
return { ...endpoint, url: endpoint.url.replace(/^https?/, expectedProtocol) };
|
||||
}
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user