Move toju-app into own its folder

This commit is contained in:
2026-03-29 23:30:37 +02:00
parent 0467a7b612
commit 8162e0444a
287 changed files with 42 additions and 34 deletions

View File

@@ -0,0 +1,260 @@
import {
Injectable,
inject,
type Signal
} from '@angular/core';
import { Observable } from 'rxjs';
import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../core/constants';
import { User } from '../../../shared-kernel';
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,
ServerInfo,
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());
}
}