This commit is contained in:
2026-03-20 03:05:29 +01:00
parent 429bb9d8ff
commit fe9c1dd1c0
139 changed files with 6308 additions and 4854 deletions

View File

@@ -0,0 +1,259 @@
import {
Injectable,
inject,
type Signal
} from '@angular/core';
import { Observable } from 'rxjs';
import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../core/constants';
import { ServerInfo, User } from '../../../core/models';
import { CLIENT_UPDATE_REQUIRED_MESSAGE } from '../domain/server-directory.constants';
import { ServerDirectoryApiService } from '../infrastructure/server-directory-api.service';
import type {
BanServerMemberRequest,
CreateServerInviteRequest,
KickServerMemberRequest,
ServerEndpoint,
ServerEndpointVersions,
ServerInviteInfo,
ServerJoinAccessRequest,
ServerJoinAccessResponse,
ServerSourceSelector,
UnbanServerMemberRequest
} from '../domain/server-directory.models';
import { ServerEndpointCompatibilityService } from '../infrastructure/server-endpoint-compatibility.service';
import { ServerEndpointHealthService } from '../infrastructure/server-endpoint-health.service';
import { ServerEndpointStateService } from './server-endpoint-state.service';
export { CLIENT_UPDATE_REQUIRED_MESSAGE };
@Injectable({ providedIn: 'root' })
export class ServerDirectoryFacade {
readonly servers: Signal<ServerEndpoint[]>;
readonly activeServers: Signal<ServerEndpoint[]>;
readonly hasMissingDefaultServers: Signal<boolean>;
readonly activeServer: Signal<ServerEndpoint | null>;
private readonly endpointState = inject(ServerEndpointStateService);
private readonly endpointCompatibility = inject(ServerEndpointCompatibilityService);
private readonly endpointHealth = inject(ServerEndpointHealthService);
private readonly api = inject(ServerDirectoryApiService);
private shouldSearchAllServers = true;
constructor() {
this.servers = this.endpointState.servers;
this.activeServers = this.endpointState.activeServers;
this.hasMissingDefaultServers = this.endpointState.hasMissingDefaultServers;
this.activeServer = this.endpointState.activeServer;
this.loadConnectionSettings();
void this.testAllServers();
}
addServer(server: { name: string; url: string }): ServerEndpoint {
return this.endpointState.addServer(server);
}
ensureServerEndpoint(
server: { name: string; url: string },
options?: { setActive?: boolean }
): ServerEndpoint {
return this.endpointState.ensureServerEndpoint(server, options);
}
findServerByUrl(url: string): ServerEndpoint | undefined {
return this.endpointState.findServerByUrl(url);
}
removeServer(endpointId: string): void {
this.endpointState.removeServer(endpointId);
}
restoreDefaultServers(): ServerEndpoint[] {
return this.endpointState.restoreDefaultServers();
}
setActiveServer(endpointId: string): void {
this.endpointState.setActiveServer(endpointId);
}
deactivateServer(endpointId: string): void {
this.endpointState.deactivateServer(endpointId);
}
updateServerStatus(
endpointId: string,
status: ServerEndpoint['status'],
latency?: number,
versions?: ServerEndpointVersions
): void {
this.endpointState.updateServerStatus(endpointId, status, latency, versions);
}
async ensureEndpointVersionCompatibility(selector?: ServerSourceSelector): Promise<boolean> {
const endpoint = this.api.resolveEndpoint(selector);
if (!endpoint || endpoint.status === 'incompatible') {
return false;
}
const clientVersion = await this.endpointCompatibility.getClientVersion();
if (!clientVersion) {
return true;
}
await this.testServer(endpoint.id);
const refreshedEndpoint = this.servers().find((candidate) => candidate.id === endpoint.id);
return !!refreshedEndpoint && refreshedEndpoint.status !== 'incompatible';
}
setSearchAllServers(enabled: boolean): void {
this.shouldSearchAllServers = enabled;
}
async testServer(endpointId: string): Promise<boolean> {
const endpoint = this.servers().find((entry) => entry.id === endpointId);
if (!endpoint) {
return false;
}
this.updateServerStatus(endpointId, 'checking');
const clientVersion = await this.endpointCompatibility.getClientVersion();
const healthResult = await this.endpointHealth.probeEndpoint(endpoint, clientVersion);
this.updateServerStatus(
endpointId,
healthResult.status,
healthResult.latency,
healthResult.versions
);
return healthResult.status === 'online';
}
async testAllServers(): Promise<void> {
await Promise.all(this.servers().map((endpoint) => this.testServer(endpoint.id)));
}
getApiBaseUrl(selector?: ServerSourceSelector): string {
return this.api.getApiBaseUrl(selector);
}
getWebSocketUrl(selector?: ServerSourceSelector): string {
return this.api.getWebSocketUrl(selector);
}
searchServers(query: string): Observable<ServerInfo[]> {
return this.api.searchServers(query, this.shouldSearchAllServers);
}
getServers(): Observable<ServerInfo[]> {
return this.api.getServers(this.shouldSearchAllServers);
}
getServer(serverId: string, selector?: ServerSourceSelector): Observable<ServerInfo | null> {
return this.api.getServer(serverId, selector);
}
registerServer(
server: Omit<ServerInfo, 'createdAt'> & { id?: string; password?: string | null },
selector?: ServerSourceSelector
): Observable<ServerInfo> {
return this.api.registerServer(server, selector);
}
updateServer(
serverId: string,
updates: Partial<ServerInfo> & {
currentOwnerId: string;
actingRole?: string;
password?: string | null;
},
selector?: ServerSourceSelector
): Observable<ServerInfo> {
return this.api.updateServer(serverId, updates, selector);
}
unregisterServer(serverId: string, selector?: ServerSourceSelector): Observable<void> {
return this.api.unregisterServer(serverId, selector);
}
getServerUsers(serverId: string, selector?: ServerSourceSelector): Observable<User[]> {
return this.api.getServerUsers(serverId, selector);
}
requestJoin(
request: ServerJoinAccessRequest,
selector?: ServerSourceSelector
): Observable<ServerJoinAccessResponse> {
return this.api.requestJoin(request, selector);
}
createInvite(
serverId: string,
request: CreateServerInviteRequest,
selector?: ServerSourceSelector
): Observable<ServerInviteInfo> {
return this.api.createInvite(serverId, request, selector);
}
getInvite(inviteId: string, selector?: ServerSourceSelector): Observable<ServerInviteInfo> {
return this.api.getInvite(inviteId, selector);
}
kickServerMember(
serverId: string,
request: KickServerMemberRequest,
selector?: ServerSourceSelector
): Observable<void> {
return this.api.kickServerMember(serverId, request, selector);
}
banServerMember(
serverId: string,
request: BanServerMemberRequest,
selector?: ServerSourceSelector
): Observable<void> {
return this.api.banServerMember(serverId, request, selector);
}
unbanServerMember(
serverId: string,
request: UnbanServerMemberRequest,
selector?: ServerSourceSelector
): Observable<void> {
return this.api.unbanServerMember(serverId, request, selector);
}
notifyLeave(serverId: string, userId: string, selector?: ServerSourceSelector): Observable<void> {
return this.api.notifyLeave(serverId, userId, selector);
}
updateUserCount(serverId: string, count: number): Observable<void> {
return this.api.updateUserCount(serverId, count);
}
sendHeartbeat(serverId: string): Observable<void> {
return this.api.sendHeartbeat(serverId);
}
private loadConnectionSettings(): void {
const stored = localStorage.getItem(STORAGE_KEY_CONNECTION_SETTINGS);
if (!stored) {
this.shouldSearchAllServers = true;
return;
}
try {
const parsed = JSON.parse(stored) as { searchAllServers?: boolean };
this.shouldSearchAllServers = parsed.searchAllServers ?? true;
} catch {
this.shouldSearchAllServers = true;
}
}
}

View File

@@ -0,0 +1,315 @@
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/server-endpoint-defaults';
import { ServerEndpointStorageService } from '../infrastructure/server-endpoint-storage.service';
import type {
ConfiguredDefaultServerDefinition,
DefaultEndpointTemplate,
ServerEndpoint,
ServerEndpointVersions
} from '../domain/server-directory.models';
function resolveDefaultHttpProtocol(): 'http' | 'https' {
return typeof window !== 'undefined' && window.location?.protocol === 'https:'
? 'https'
: 'http';
}
@Injectable({ providedIn: 'root' })
export class ServerEndpointStateService {
readonly servers: Signal<ServerEndpoint[]>;
readonly activeServers: Signal<ServerEndpoint[]>;
readonly hasMissingDefaultServers: Signal<boolean>;
readonly activeServer: Signal<ServerEndpoint | null>;
private readonly storage = inject(ServerEndpointStorageService);
private readonly _servers = signal<ServerEndpoint[]>([]);
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);
return this._servers().find((endpoint) => this.sanitiseUrl(endpoint.url) === sanitisedUrl);
}
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);
}
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.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
);
});
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
)
);
this.saveEndpoints();
}
updateServerStatus(
endpointId: string,
status: ServerEndpoint['status'],
latency?: number,
versions?: ServerEndpointVersions
): void {
this._servers.update((endpoints) => ensureCompatibleActiveEndpoint(endpoints.map((endpoint) => {
if (endpoint.id !== endpointId) {
return endpoint;
}
return {
...endpoint,
status,
latency,
isActive: status === 'incompatible' ? 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<string>();
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,
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: defaultEndpoint.isActive
});
}
}
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 saveEndpoints(): void {
this.storage.saveEndpoints(this._servers());
}
}

View File

@@ -0,0 +1 @@
export const CLIENT_UPDATE_REQUIRED_MESSAGE = 'Update the client in order to connect to other users';

View File

@@ -0,0 +1,115 @@
import type { ServerInfo } from '../../../core/models';
export type ServerEndpointStatus = 'online' | 'offline' | 'checking' | 'unknown' | 'incompatible';
export interface ConfiguredDefaultServerDefinition {
key?: string;
name?: string;
url?: string;
}
export interface DefaultServerDefinition {
key: string;
name: string;
url: string;
}
export interface ServerEndpointVersions {
serverVersion?: string | null;
clientVersion?: string | null;
}
export interface ServerEndpoint {
id: string;
name: string;
url: string;
isActive: boolean;
isDefault: boolean;
defaultKey?: string;
status: ServerEndpointStatus;
latency?: number;
serverVersion?: string;
clientVersion?: string;
}
export type DefaultEndpointTemplate = Omit<ServerEndpoint, 'id' | 'defaultKey'> & {
defaultKey: string;
};
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;
}
export interface ServerVersionCompatibilityResult {
isCompatible: boolean;
serverVersion: string | null;
}
export interface ServerHealthCheckPayload {
serverVersion?: unknown;
}
export interface ServerEndpointHealthResult {
status: ServerEndpointStatus;
latency?: number;
versions?: ServerEndpointVersions;
}

View File

@@ -0,0 +1,187 @@
import type {
ConfiguredDefaultServerDefinition,
DefaultEndpointTemplate,
DefaultServerDefinition,
ServerEndpoint
} from './server-directory.models';
export function sanitiseServerBaseUrl(rawUrl: string): string {
let cleaned = rawUrl.trim().replace(/\/+$/, '');
if (cleaned.toLowerCase().endsWith('/api')) {
cleaned = cleaned.slice(0, -4);
}
return cleaned;
}
export function normaliseConfiguredServerUrl(
rawUrl: string,
defaultProtocol: 'http' | 'https'
): 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 = `${defaultProtocol}:${cleaned}`;
} else if (!/^[a-z][a-z\d+.-]*:\/\//i.test(cleaned)) {
cleaned = `${defaultProtocol}://${cleaned}`;
}
return sanitiseServerBaseUrl(cleaned);
}
export function buildFallbackDefaultServerUrl(
configuredUrl: string | undefined,
defaultProtocol: 'http' | 'https'
): string {
if (configuredUrl?.trim()) {
return normaliseConfiguredServerUrl(configuredUrl, defaultProtocol);
}
return `${defaultProtocol}://localhost:3001`;
}
export function buildDefaultServerDefinitions(
configuredDefaults: ConfiguredDefaultServerDefinition[] | undefined,
configuredUrl: string | undefined,
defaultProtocol: 'http' | 'https'
): DefaultServerDefinition[] {
const seenKeys = new Set<string>();
const seenUrls = new Set<string>();
const definitions = (configuredDefaults ?? [])
.map((server, index) => {
const key = server.key?.trim() || `default-${index + 1}`;
const url = normaliseConfiguredServerUrl(server.url ?? '', defaultProtocol);
if (!key || !url || seenKeys.has(key) || seenUrls.has(url)) {
return null;
}
seenKeys.add(key);
seenUrls.add(url);
return {
key,
name: server.name?.trim() || (index === 0 ? 'Default Server' : `Default Server ${index + 1}`),
url
} satisfies DefaultServerDefinition;
})
.filter((definition): definition is DefaultServerDefinition => definition !== null);
if (definitions.length > 0) {
return definitions;
}
return [
{
key: 'default',
name: 'Default Server',
url: buildFallbackDefaultServerUrl(configuredUrl, defaultProtocol)
}
];
}
export function buildDefaultEndpointTemplates(
definitions: DefaultServerDefinition[]
): DefaultEndpointTemplate[] {
return definitions.map((definition) => ({
name: definition.name,
url: definition.url,
isActive: true,
isDefault: true,
defaultKey: definition.key,
status: 'unknown'
}));
}
export function hasEndpointForDefault(
endpoints: ServerEndpoint[],
defaultEndpoint: DefaultEndpointTemplate
): boolean {
return endpoints.some((endpoint) =>
endpoint.defaultKey === defaultEndpoint.defaultKey
|| sanitiseServerBaseUrl(endpoint.url) === defaultEndpoint.url
);
}
export function matchDefaultEndpointTemplate(
defaultEndpoints: DefaultEndpointTemplate[],
endpoint: ServerEndpoint,
sanitisedUrl: string,
claimedDefaultKeys: Set<string>
): DefaultEndpointTemplate | null {
if (endpoint.defaultKey) {
return defaultEndpoints.find(
(candidate) => candidate.defaultKey === endpoint.defaultKey && !claimedDefaultKeys.has(candidate.defaultKey)
) ?? null;
}
if (!endpoint.isDefault) {
return null;
}
const matchingCurrentDefault = defaultEndpoints.find(
(candidate) => candidate.url === sanitisedUrl && !claimedDefaultKeys.has(candidate.defaultKey)
);
if (matchingCurrentDefault) {
return matchingCurrentDefault;
}
return defaultEndpoints.find(
(candidate) => !claimedDefaultKeys.has(candidate.defaultKey)
) ?? null;
}
export function findDefaultEndpointKeyByUrl(
defaultEndpoints: DefaultEndpointTemplate[],
url: string
): string | null {
const sanitisedUrl = sanitiseServerBaseUrl(url);
return defaultEndpoints.find((endpoint) => endpoint.url === sanitisedUrl)?.defaultKey ?? null;
}
export function ensureAnyActiveEndpoint(endpoints: ServerEndpoint[]): ServerEndpoint[] {
if (endpoints.length === 0 || endpoints.some((endpoint) => endpoint.isActive)) {
return endpoints;
}
const nextEndpoints = [...endpoints];
nextEndpoints[0] = {
...nextEndpoints[0],
isActive: true
};
return nextEndpoints;
}
export function ensureCompatibleActiveEndpoint(endpoints: ServerEndpoint[]): ServerEndpoint[] {
if (endpoints.length === 0 || endpoints.some((endpoint) => endpoint.isActive)) {
return endpoints;
}
const fallbackIndex = endpoints.findIndex((endpoint) => endpoint.status !== 'incompatible');
if (fallbackIndex < 0) {
return endpoints;
}
const nextEndpoints = [...endpoints];
nextEndpoints[fallbackIndex] = {
...nextEndpoints[fallbackIndex],
isActive: true
};
return nextEndpoints;
}

View File

@@ -0,0 +1,3 @@
export * from './application/server-directory.facade';
export * from './domain/server-directory.constants';
export * from './domain/server-directory.models';

View File

@@ -0,0 +1,404 @@
/* eslint-disable @typescript-eslint/no-invalid-void-type */
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import {
Observable,
forkJoin,
of,
throwError
} from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { ServerInfo, User } from '../../../core/models';
import { ServerEndpointStateService } from '../application/server-endpoint-state.service';
import type {
BanServerMemberRequest,
CreateServerInviteRequest,
KickServerMemberRequest,
ServerEndpoint,
ServerInviteInfo,
ServerJoinAccessRequest,
ServerJoinAccessResponse,
ServerSourceSelector,
UnbanServerMemberRequest
} from '../domain/server-directory.models';
@Injectable({ providedIn: 'root' })
export class ServerDirectoryApiService {
private readonly http = inject(HttpClient);
private readonly endpointState = inject(ServerEndpointStateService);
getApiBaseUrl(selector?: ServerSourceSelector): string {
return `${this.resolveBaseServerUrl(selector)}/api`;
}
getWebSocketUrl(selector?: ServerSourceSelector): string {
return this.resolveBaseServerUrl(selector).replace(/^http/, 'ws');
}
resolveEndpoint(selector?: ServerSourceSelector): ServerEndpoint | null {
if (selector?.sourceId) {
return this.endpointState.servers().find((endpoint) => endpoint.id === selector.sourceId) ?? null;
}
if (selector?.sourceUrl) {
return this.endpointState.findServerByUrl(selector.sourceUrl) ?? null;
}
return this.endpointState.activeServer()
?? this.endpointState.servers().find((endpoint) => endpoint.status !== 'incompatible')
?? this.endpointState.servers()[0]
?? null;
}
searchServers(query: string, shouldSearchAllServers: boolean): Observable<ServerInfo[]> {
if (shouldSearchAllServers) {
return this.searchAllEndpoints(query);
}
return this.searchSingleEndpoint(query, this.getApiBaseUrl(), this.endpointState.activeServer());
}
getServers(shouldSearchAllServers: boolean): Observable<ServerInfo[]> {
if (shouldSearchAllServers) {
return this.getAllServersFromAllEndpoints();
}
return this.http
.get<{ servers: ServerInfo[]; total: number }>(`${this.getApiBaseUrl()}/servers`)
.pipe(
map((response) => this.normalizeServerList(response, this.endpointState.activeServer())),
catchError((error) => {
console.error('Failed to get servers:', error);
return of([]);
})
);
}
getServer(serverId: string, selector?: ServerSourceSelector): Observable<ServerInfo | null> {
return this.http
.get<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`)
.pipe(
map((server) => this.normalizeServerInfo(server, this.resolveEndpoint(selector))),
catchError((error) => {
console.error('Failed to get server:', error);
return of(null);
})
);
}
registerServer(
server: Omit<ServerInfo, 'createdAt'> & { id?: string; password?: string | null },
selector?: ServerSourceSelector
): Observable<ServerInfo> {
return this.http
.post<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers`, server)
.pipe(
catchError((error) => {
console.error('Failed to register server:', error);
return throwError(() => error);
})
);
}
updateServer(
serverId: string,
updates: Partial<ServerInfo> & {
currentOwnerId: string;
actingRole?: string;
password?: string | null;
},
selector?: ServerSourceSelector
): Observable<ServerInfo> {
return this.http
.put<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`, updates)
.pipe(
catchError((error) => {
console.error('Failed to update server:', error);
return throwError(() => error);
})
);
}
unregisterServer(serverId: string, selector?: ServerSourceSelector): Observable<void> {
return this.http
.delete<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`)
.pipe(
catchError((error) => {
console.error('Failed to unregister server:', error);
return throwError(() => error);
})
);
}
getServerUsers(serverId: string, selector?: ServerSourceSelector): Observable<User[]> {
return this.http
.get<User[]>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/users`)
.pipe(
catchError((error) => {
console.error('Failed to get server users:', error);
return of([]);
})
);
}
requestJoin(
request: ServerJoinAccessRequest,
selector?: ServerSourceSelector
): Observable<ServerJoinAccessResponse> {
return this.http
.post<ServerJoinAccessResponse>(
`${this.getApiBaseUrl(selector)}/servers/${request.roomId}/join`,
request
)
.pipe(
catchError((error) => {
console.error('Failed to send join request:', error);
return throwError(() => error);
})
);
}
createInvite(
serverId: string,
request: CreateServerInviteRequest,
selector?: ServerSourceSelector
): Observable<ServerInviteInfo> {
return this.http
.post<ServerInviteInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/invites`, request)
.pipe(
catchError((error) => {
console.error('Failed to create invite:', error);
return throwError(() => error);
})
);
}
getInvite(inviteId: string, selector?: ServerSourceSelector): Observable<ServerInviteInfo> {
return this.http
.get<ServerInviteInfo>(`${this.getApiBaseUrl(selector)}/invites/${inviteId}`)
.pipe(
catchError((error) => {
console.error('Failed to get invite:', error);
return throwError(() => error);
})
);
}
kickServerMember(
serverId: string,
request: KickServerMemberRequest,
selector?: ServerSourceSelector
): Observable<void> {
return this.http
.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/kick`, request)
.pipe(
catchError((error) => {
console.error('Failed to kick server member:', error);
return throwError(() => error);
})
);
}
banServerMember(
serverId: string,
request: BanServerMemberRequest,
selector?: ServerSourceSelector
): Observable<void> {
return this.http
.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/ban`, request)
.pipe(
catchError((error) => {
console.error('Failed to ban server member:', error);
return throwError(() => error);
})
);
}
unbanServerMember(
serverId: string,
request: UnbanServerMemberRequest,
selector?: ServerSourceSelector
): Observable<void> {
return this.http
.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/unban`, request)
.pipe(
catchError((error) => {
console.error('Failed to unban server member:', error);
return throwError(() => error);
})
);
}
notifyLeave(serverId: string, userId: string, selector?: ServerSourceSelector): Observable<void> {
return this.http
.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/leave`, { userId })
.pipe(
catchError((error) => {
console.error('Failed to notify leave:', error);
return of(undefined);
})
);
}
updateUserCount(serverId: string, count: number): Observable<void> {
return this.http
.patch<void>(`${this.getApiBaseUrl()}/servers/${serverId}/user-count`, { count })
.pipe(
catchError((error) => {
console.error('Failed to update user count:', error);
return of(undefined);
})
);
}
sendHeartbeat(serverId: string): Observable<void> {
return this.http
.post<void>(`${this.getApiBaseUrl()}/servers/${serverId}/heartbeat`, {})
.pipe(
catchError((error) => {
console.error('Failed to send heartbeat:', error);
return of(undefined);
})
);
}
private resolveBaseServerUrl(selector?: ServerSourceSelector): string {
if (selector?.sourceUrl) {
return this.endpointState.sanitiseUrl(selector.sourceUrl);
}
return this.resolveEndpoint(selector)?.url ?? this.endpointState.getPrimaryDefaultServerUrl();
}
private unwrapServersResponse(
response: { servers: ServerInfo[]; total: number } | ServerInfo[]
): ServerInfo[] {
return Array.isArray(response)
? response
: (response.servers ?? []);
}
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([]);
})
);
}
private searchAllEndpoints(query: string): Observable<ServerInfo[]> {
const onlineEndpoints = this.endpointState.activeServers().filter(
(endpoint) => endpoint.status !== 'offline'
);
if (onlineEndpoints.length === 0) {
return this.searchSingleEndpoint(query, this.getApiBaseUrl(), this.endpointState.activeServer());
}
return forkJoin(
onlineEndpoints.map((endpoint) => this.searchSingleEndpoint(query, `${endpoint.url}/api`, endpoint))
).pipe(
map((resultArrays) => resultArrays.flat()),
map((servers) => this.deduplicateById(servers))
);
}
private getAllServersFromAllEndpoints(): Observable<ServerInfo[]> {
const onlineEndpoints = this.endpointState.activeServers().filter(
(endpoint) => endpoint.status !== 'offline'
);
if (onlineEndpoints.length === 0) {
return this.http
.get<{ servers: ServerInfo[]; total: number }>(`${this.getApiBaseUrl()}/servers`)
.pipe(
map((response) => this.normalizeServerList(response, this.endpointState.activeServer())),
catchError(() => of([]))
);
}
return forkJoin(
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[]))
)
)
).pipe(map((resultArrays) => resultArrays.flat()));
}
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.endpointState.sanitiseUrl(sourceUrl)
: (source ? this.endpointState.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;
}
}

View File

@@ -0,0 +1,3 @@
export const SERVER_ENDPOINTS_STORAGE_KEY = 'metoyou_server_endpoints';
export const REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY = 'metoyou_removed_default_server_keys';
export const SERVER_HEALTH_CHECK_TIMEOUT_MS = 5000;

View File

@@ -0,0 +1,77 @@
import { Injectable, inject } from '@angular/core';
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
import type { ServerVersionCompatibilityResult } from '../domain/server-directory.models';
@Injectable({ providedIn: 'root' })
export class ServerEndpointCompatibilityService {
private readonly electronBridge = inject(ElectronBridgeService);
private clientVersionPromise: Promise<string | null> | null = null;
async getClientVersion(): Promise<string | null> {
if (!this.clientVersionPromise) {
this.clientVersionPromise = this.resolveClientVersion();
}
return await this.clientVersionPromise;
}
evaluateServerVersion(
rawServerVersion: unknown,
clientVersion: string | null
): ServerVersionCompatibilityResult {
const serverVersion = normalizeSemanticVersion(rawServerVersion);
return {
isCompatible: !clientVersion || (serverVersion !== null && serverVersion === clientVersion),
serverVersion
};
}
private async resolveClientVersion(): Promise<string | null> {
const electronApi = this.electronBridge.getApi();
if (!electronApi) {
return null;
}
try {
const state = await electronApi.getAutoUpdateState();
return normalizeSemanticVersion(state?.currentVersion);
} catch {
return null;
}
}
}
function normalizeSemanticVersion(rawVersion: unknown): string | null {
if (typeof rawVersion !== 'string') {
return null;
}
const trimmed = rawVersion.trim();
if (!trimmed) {
return null;
}
const match = trimmed.match(/^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/i);
if (!match) {
return null;
}
const major = Number.parseInt(match[1], 10);
const minor = Number.parseInt(match[2], 10);
const patch = Number.parseInt(match[3], 10);
if (
Number.isNaN(major)
|| Number.isNaN(minor)
|| Number.isNaN(patch)
) {
return null;
}
return `${major}.${minor}.${patch}`;
}

View File

@@ -0,0 +1,75 @@
import { Injectable, inject } from '@angular/core';
import { SERVER_HEALTH_CHECK_TIMEOUT_MS } from './server-directory.infrastructure.constants';
import type {
ServerEndpoint,
ServerEndpointHealthResult,
ServerHealthCheckPayload
} from '../domain/server-directory.models';
import { ServerEndpointCompatibilityService } from './server-endpoint-compatibility.service';
@Injectable({ providedIn: 'root' })
export class ServerEndpointHealthService {
private readonly endpointCompatibility = inject(ServerEndpointCompatibilityService);
async probeEndpoint(
endpoint: Pick<ServerEndpoint, 'url'>,
clientVersion: string | null
): Promise<ServerEndpointHealthResult> {
const startTime = Date.now();
try {
const response = await fetch(`${endpoint.url}/api/health`, {
method: 'GET',
signal: AbortSignal.timeout(SERVER_HEALTH_CHECK_TIMEOUT_MS)
});
const latency = Date.now() - startTime;
if (response.ok) {
const payload = await response.json() as ServerHealthCheckPayload;
const versionCompatibility = this.endpointCompatibility.evaluateServerVersion(
payload.serverVersion,
clientVersion
);
if (!versionCompatibility.isCompatible) {
return {
status: 'incompatible',
latency,
versions: {
serverVersion: versionCompatibility.serverVersion,
clientVersion
}
};
}
return {
status: 'online',
latency,
versions: {
serverVersion: versionCompatibility.serverVersion,
clientVersion
}
};
}
return { status: 'offline' };
} catch {
try {
const response = await fetch(`${endpoint.url}/api/servers`, {
method: 'GET',
signal: AbortSignal.timeout(SERVER_HEALTH_CHECK_TIMEOUT_MS)
});
const latency = Date.now() - startTime;
if (response.ok) {
return {
status: 'online',
latency
};
}
} catch { /* both checks failed */ }
return { status: 'offline' };
}
}
}

View File

@@ -0,0 +1,61 @@
import { Injectable } from '@angular/core';
import { REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY, SERVER_ENDPOINTS_STORAGE_KEY } from './server-directory.infrastructure.constants';
import type { ServerEndpoint } from '../domain/server-directory.models';
@Injectable({ providedIn: 'root' })
export class ServerEndpointStorageService {
loadEndpoints(): ServerEndpoint[] | null {
const stored = localStorage.getItem(SERVER_ENDPOINTS_STORAGE_KEY);
if (!stored) {
return null;
}
try {
const parsed = JSON.parse(stored) as unknown;
return Array.isArray(parsed)
? parsed as ServerEndpoint[]
: null;
} catch {
return null;
}
}
saveEndpoints(endpoints: ServerEndpoint[]): void {
localStorage.setItem(SERVER_ENDPOINTS_STORAGE_KEY, JSON.stringify(endpoints));
}
loadRemovedDefaultEndpointKeys(): Set<string> {
const stored = localStorage.getItem(REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY);
if (!stored) {
return new Set<string>();
}
try {
const parsed = JSON.parse(stored) as unknown;
if (!Array.isArray(parsed)) {
return new Set<string>();
}
return new Set(parsed.filter((value): value is string => typeof value === 'string'));
} catch {
return new Set<string>();
}
}
saveRemovedDefaultEndpointKeys(keys: Set<string>): void {
if (keys.size === 0) {
this.clearRemovedDefaultEndpointKeys();
return;
}
localStorage.setItem(REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY, JSON.stringify([...keys]));
}
clearRemovedDefaultEndpointKeys(): void {
localStorage.removeItem(REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY);
}
}