478 lines
13 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|