Files
Toju/toju-app/src/app/domains/server-directory/application/services/server-endpoint-state.service.ts
2026-06-05 06:16:02 +02:00

478 lines
13 KiB
TypeScript

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/logic/server-endpoint-defaults.logic';
import { ServerEndpointStorageService } from '../../infrastructure/services/server-endpoint-storage.service';
import type {
ConfiguredDefaultServerDefinition,
DefaultEndpointTemplate,
ServerEndpoint,
ServerEndpointVersions
} from '../../domain/models/server-directory.model';
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);
const exactEndpoint = this._servers().find((endpoint) => this.sanitiseUrl(endpoint.url) === sanitisedUrl);
return exactEndpoint ?? this.findHttpEndpointForHttpsUrl(sanitisedUrl);
}
resolveCanonicalEndpoint(endpoint: ServerEndpoint | null | undefined): ServerEndpoint | null {
if (!endpoint?.instanceId) {
return endpoint ?? null;
}
const equivalentEndpoints = this._servers().filter((candidate) => candidate.instanceId === endpoint.instanceId);
if (equivalentEndpoints.length <= 1) {
return endpoint;
}
return [...equivalentEndpoints].sort((left, right) => this.compareEndpointPreference(left, right))[0] ?? endpoint;
}
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);
this.clearDefaultEndpointDisabled(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.clearDisabledDefaultEndpointKeys(restoredEndpoints);
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
);
});
const target = this._servers().find((endpoint) => endpoint.id === endpointId);
if (target?.isDefault) {
this.clearDefaultEndpointDisabled(target);
}
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
)
);
const target = this._servers().find((endpoint) => endpoint.id === endpointId);
if (target?.isDefault) {
this.markDefaultEndpointDisabled(target);
}
this.saveEndpoints();
}
updateServerStatus(
endpointId: string,
status: ServerEndpoint['status'],
latency?: number,
versions?: ServerEndpointVersions,
serverTag?: string
): void {
this._servers.update((endpoints) => ensureCompatibleActiveEndpoint(endpoints.map((endpoint) => {
if (endpoint.id !== endpointId) {
return endpoint;
}
return {
...endpoint,
instanceId: versions?.serverInstanceId ?? endpoint.instanceId,
status,
latency,
serverTag: serverTag ?? endpoint.serverTag,
isActive: status === 'incompatible' && !endpoint.isDefault ? 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 disabledDefaultKeys = this.storage.loadDisabledDefaultEndpointKeys();
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,
isActive: this.isDefaultEndpointActive(matchedDefault.defaultKey, disabledDefaultKeys),
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: this.isDefaultEndpointActive(defaultEndpoint.defaultKey, disabledDefaultKeys)
});
}
}
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 markDefaultEndpointDisabled(endpoint: ServerEndpoint): void {
const defaultKey = endpoint.defaultKey ?? findDefaultEndpointKeyByUrl(this.defaultEndpoints, endpoint.url);
if (!defaultKey) {
return;
}
const disabledDefaultKeys = this.storage.loadDisabledDefaultEndpointKeys();
disabledDefaultKeys.add(defaultKey);
this.storage.saveDisabledDefaultEndpointKeys(disabledDefaultKeys);
}
private clearDefaultEndpointDisabled(endpoint: ServerEndpoint): void {
const defaultKey = endpoint.defaultKey ?? findDefaultEndpointKeyByUrl(this.defaultEndpoints, endpoint.url);
if (!defaultKey) {
return;
}
const disabledDefaultKeys = this.storage.loadDisabledDefaultEndpointKeys();
if (!disabledDefaultKeys.delete(defaultKey)) {
return;
}
this.storage.saveDisabledDefaultEndpointKeys(disabledDefaultKeys);
}
private clearDisabledDefaultEndpointKeys(endpoints: ServerEndpoint[]): void {
const disabledDefaultKeys = this.storage.loadDisabledDefaultEndpointKeys();
let didChange = false;
for (const endpoint of endpoints) {
const defaultKey = endpoint.defaultKey ?? findDefaultEndpointKeyByUrl(this.defaultEndpoints, endpoint.url);
if (!defaultKey) {
continue;
}
didChange = disabledDefaultKeys.delete(defaultKey) || didChange;
}
if (!didChange) {
return;
}
this.storage.saveDisabledDefaultEndpointKeys(disabledDefaultKeys);
}
private isDefaultEndpointActive(
defaultKey: string,
disabledDefaultKeys: Set<string>
): boolean {
return !disabledDefaultKeys.has(defaultKey);
}
private saveEndpoints(): void {
this.storage.saveEndpoints(this._servers());
}
private compareEndpointPreference(left: ServerEndpoint, right: ServerEndpoint): number {
const scoreDifference = this.endpointPreferenceScore(right) - this.endpointPreferenceScore(left);
if (scoreDifference !== 0) {
return scoreDifference;
}
return left.url.localeCompare(right.url);
}
private endpointPreferenceScore(endpoint: ServerEndpoint): number {
let score = 0;
if (endpoint.isDefault) {
score += 4;
}
if (this.usesSecureProtocol(endpoint.url)) {
score += 2;
}
if (!this.isIpHost(endpoint.url)) {
score += 1;
}
return score;
}
private usesSecureProtocol(url: string): boolean {
try {
return new URL(url).protocol === 'https:';
} catch {
return false;
}
}
private isIpHost(url: string): boolean {
try {
const hostname = new URL(url).hostname;
return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(hostname) || hostname.includes(':');
} catch {
return false;
}
}
private findHttpEndpointForHttpsUrl(url: string): ServerEndpoint | undefined {
const requestedUrl = this.parseUrl(url);
if (requestedUrl?.protocol !== 'https:') {
return undefined;
}
return this._servers().find((endpoint) => {
const endpointUrl = this.parseUrl(endpoint.url);
return endpointUrl?.protocol === 'http:'
&& endpointUrl.hostname === requestedUrl.hostname
&& endpointUrl.port === requestedUrl.port;
});
}
private parseUrl(url: string): URL | null {
try {
return new URL(url);
} catch {
return null;
}
}
}