refactor: true facades
This commit is contained in:
@@ -0,0 +1,376 @@
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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,
|
||||
instanceId: versions?.serverInstanceId ?? endpoint.instanceId,
|
||||
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());
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user