refactor: stricter domain: server-directory
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
areRoomSignalSourcesEqual,
|
||||
buildRoomSignalSelector,
|
||||
buildRoomSignalSource,
|
||||
getSourceUrlFromSignalingUrl
|
||||
} from './room-signal-source.logic';
|
||||
|
||||
describe('room-signal-source helpers', () => {
|
||||
it('converts signaling urls back to normalized source urls', () => {
|
||||
expect(getSourceUrlFromSignalingUrl('wss://signal.toju.app')).toBe('https://signal.toju.app');
|
||||
expect(getSourceUrlFromSignalingUrl('ws://46.59.68.77:3001')).toBe('http://46.59.68.77:3001');
|
||||
});
|
||||
|
||||
it('prefers the resolved endpoint when normalizing a room source', () => {
|
||||
expect(buildRoomSignalSource({
|
||||
sourceId: 'stale-id',
|
||||
sourceName: 'Stale Source',
|
||||
sourceUrl: 'https://old.example.com',
|
||||
signalingUrl: 'wss://signal.toju.app'
|
||||
}, {
|
||||
id: 'primary-id',
|
||||
name: 'Primary Signal',
|
||||
url: 'https://signal.toju.app/'
|
||||
})).toEqual({
|
||||
sourceId: 'primary-id',
|
||||
sourceName: 'Primary Signal',
|
||||
sourceUrl: 'https://signal.toju.app'
|
||||
});
|
||||
});
|
||||
|
||||
it('builds selectors from signaling urls when no source url is persisted yet', () => {
|
||||
expect(buildRoomSignalSelector({
|
||||
signalingUrl: 'wss://signal-sweden.toju.app',
|
||||
fallbackName: 'Toju Signal Sweden'
|
||||
})).toEqual({
|
||||
sourceUrl: 'https://signal-sweden.toju.app'
|
||||
});
|
||||
});
|
||||
|
||||
it('treats equivalent persisted and signaling-derived sources as equal', () => {
|
||||
expect(areRoomSignalSourcesEqual(
|
||||
{ sourceUrl: 'https://signal.toju.app/' },
|
||||
{ signalingUrl: 'wss://signal.toju.app' }
|
||||
)).toBeTrue();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { ServerEndpoint, ServerSourceSelector } from '../models/server-directory.model';
|
||||
import { normaliseConfiguredServerUrl, sanitiseServerBaseUrl } from './server-endpoint-defaults.logic';
|
||||
|
||||
export interface RoomSignalSource {
|
||||
sourceId?: string;
|
||||
sourceName?: string;
|
||||
sourceUrl?: string;
|
||||
}
|
||||
|
||||
export interface RoomSignalSourceInput extends RoomSignalSource {
|
||||
fallbackName?: string;
|
||||
signalingUrl?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_SIGNAL_SOURCE_NAME = 'Signal Server';
|
||||
|
||||
export function getSourceUrlFromSignalingUrl(signalingUrl?: string): string | undefined {
|
||||
const normalizedUrl = normalizeString(signalingUrl);
|
||||
|
||||
if (!normalizedUrl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const resolvedUrl = normaliseConfiguredServerUrl(normalizedUrl, 'https');
|
||||
|
||||
return resolvedUrl || undefined;
|
||||
}
|
||||
|
||||
export function buildRoomSignalSource(
|
||||
source?: RoomSignalSourceInput | null,
|
||||
endpoint?: Pick<ServerEndpoint, 'id' | 'name' | 'url'> | null
|
||||
): RoomSignalSource {
|
||||
const sourceId = normalizeString(endpoint?.id) ?? normalizeString(source?.sourceId);
|
||||
const sourceUrl = endpoint
|
||||
? sanitiseServerBaseUrl(endpoint.url)
|
||||
: (normalizeUrl(source?.sourceUrl) ?? getSourceUrlFromSignalingUrl(source?.signalingUrl));
|
||||
const sourceName = normalizeString(endpoint?.name)
|
||||
?? normalizeString(source?.sourceName)
|
||||
?? normalizeString(source?.fallbackName)
|
||||
?? (sourceUrl ? DEFAULT_SIGNAL_SOURCE_NAME : undefined);
|
||||
|
||||
return {
|
||||
sourceId,
|
||||
sourceName,
|
||||
sourceUrl
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRoomSignalSelector(
|
||||
source?: RoomSignalSourceInput | null
|
||||
): ServerSourceSelector | undefined {
|
||||
const normalizedSource = buildRoomSignalSource(source);
|
||||
|
||||
if (normalizedSource.sourceId) {
|
||||
return { sourceId: normalizedSource.sourceId };
|
||||
}
|
||||
|
||||
if (normalizedSource.sourceUrl) {
|
||||
return { sourceUrl: normalizedSource.sourceUrl };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function hasRoomSignalSource(source?: RoomSignalSourceInput | null): boolean {
|
||||
return !!buildRoomSignalSelector(source);
|
||||
}
|
||||
|
||||
export function areRoomSignalSourcesEqual(
|
||||
left?: RoomSignalSourceInput | null,
|
||||
right?: RoomSignalSourceInput | null
|
||||
): boolean {
|
||||
const normalizedLeft = buildRoomSignalSource(left);
|
||||
const normalizedRight = buildRoomSignalSource(right);
|
||||
|
||||
return normalizedLeft.sourceId === normalizedRight.sourceId
|
||||
&& normalizedLeft.sourceName === normalizedRight.sourceName
|
||||
&& normalizedLeft.sourceUrl === normalizedRight.sourceUrl;
|
||||
}
|
||||
|
||||
function normalizeString(value: string | undefined | null): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function normalizeUrl(value: string | undefined | null): string | undefined {
|
||||
const normalizedValue = normalizeString(value);
|
||||
|
||||
return normalizedValue ? sanitiseServerBaseUrl(normalizedValue) : undefined;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import type {
|
||||
ConfiguredDefaultServerDefinition,
|
||||
DefaultEndpointTemplate,
|
||||
DefaultServerDefinition,
|
||||
ServerEndpoint
|
||||
} from '../models/server-directory.model';
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user