fix: Broken voice states and connectivity drops

This commit is contained in:
2026-04-11 12:32:22 +02:00
parent 0865c2fe33
commit ef1182d46f
28 changed files with 1244 additions and 162 deletions

View File

@@ -90,10 +90,15 @@ stateDiagram-v2
The facade exposes `testServer(endpointId)` and `testAllServers()`. Both delegate to `ServerEndpointHealthService.probeEndpoint()`, which:
1. Sends `GET /api/health` with a 5-second timeout
2. On success, checks the response's `serverVersion` against the client version via `ServerEndpointCompatibilityService`
3. If versions are incompatible, the endpoint is marked `incompatible` and deactivated
4. If `/api/health` fails, falls back to `GET /api/servers` as a basic liveness check
5. Updates the endpoint's status, latency, and version info in the state service
2. Reads the response's `serverVersion` and stable `serverInstanceId`
3. Checks the reported version against the client version via `ServerEndpointCompatibilityService`
4. If versions are incompatible, the endpoint is marked `incompatible` and deactivated
5. If `/api/health` fails, falls back to `GET /api/servers` as a basic liveness check
6. Updates the endpoint's status, latency, and version info in the state service
`serverInstanceId` lets the client detect when multiple configured URLs point at the same backend. `ServerEndpointStateService.resolveCanonicalEndpoint()` prefers one canonical endpoint per backend instance so REST calls, WebSocket routing, and room fallback logic do not treat same-instance aliases as different signaling clusters.
Room signaling now waits for that initial health sweep before the first saved-room reconnect attempt. That avoids a cold-start race where alias endpoints could open separate WebSocket managers before `serverInstanceId` had been learned.
```mermaid
sequenceDiagram
@@ -106,7 +111,7 @@ sequenceDiagram
Health->>API: GET /api/health (5s timeout)
alt 200 OK
API-->>Health: { serverVersion }
API-->>Health: { serverVersion, serverInstanceId }
Health->>Compat: evaluateServerVersion(serverVersion, clientVersion)
Compat-->>Health: { isCompatible, serverVersion }
Health-->>Facade: online / incompatible + latency + versions
@@ -132,6 +137,10 @@ The facade's `searchServers(query)` method supports two modes controlled by a `s
The API service normalises every `ServerInfo` response, filling in `sourceId`, `sourceName`, and `sourceUrl` so the UI knows which endpoint each server came from.
That search fan-out is discovery only. Once a room is created or joined, the room keeps an authoritative signal-server affinity via its `sourceId` / `sourceUrl`. The join response can repair stale saved metadata, and reconnect logic now retries that authoritative endpoint first before probing any other configured endpoints.
Fallback stays temporary. If the authoritative endpoint is unavailable, the client can probe other active compatible endpoints as a last resort for the current session, but it does not rewrite the room's saved affinity to that fallback endpoint.
## Server-owned room metadata
`ServerInfo` also carries the server-owned `channels` list for each room. Register and update calls persist this channel metadata on the server, and search or hydration responses return the normalised channel list so text and voice channel topology survives reloads, reconnects, and fresh joins.
@@ -147,6 +156,8 @@ Default servers are configured in the environment file. The state service builds
- `restoreDefaultServers()` re-adds any removed defaults and clears the removal tracking
- The primary default URL is used as a fallback when no endpoint is resolved
Saved rooms can also self-heal their endpoint metadata. If a room has missing or stale source information, the client now searches the configured endpoints for that room, restores the correct source mapping, and persists the repair locally.
URL sanitisation strips trailing slashes and `/api` suffixes. Protocol-less URLs get `http` or `https` based on the current page protocol.
## Server administration

View File

@@ -21,6 +21,12 @@ import type {
ServerSourceSelector,
UnbanServerMemberRequest
} from '../domain/server-directory.models';
import {
buildRoomSignalSelector,
buildRoomSignalSource,
type RoomSignalSource,
type RoomSignalSourceInput
} from '../domain/room-signal-source';
import { ServerEndpointCompatibilityService } from '../infrastructure/server-endpoint-compatibility.service';
import { ServerEndpointHealthService } from '../infrastructure/server-endpoint-health.service';
import { ServerEndpointStateService } from './server-endpoint-state.service';
@@ -38,6 +44,7 @@ export class ServerDirectoryFacade {
private readonly endpointCompatibility = inject(ServerEndpointCompatibilityService);
private readonly endpointHealth = inject(ServerEndpointHealthService);
private readonly api = inject(ServerDirectoryApiService);
private readonly initialServerHealthCheck: Promise<void>;
private shouldSearchAllServers = true;
constructor() {
@@ -47,7 +54,11 @@ export class ServerDirectoryFacade {
this.activeServer = this.endpointState.activeServer;
this.loadConnectionSettings();
void this.testAllServers();
this.initialServerHealthCheck = this.testAllServers().catch(() => undefined);
}
async awaitInitialServerHealthCheck(): Promise<void> {
await this.initialServerHealthCheck;
}
addServer(server: { name: string; url: string }): ServerEndpoint {
@@ -110,6 +121,81 @@ export class ServerDirectoryFacade {
return !!refreshedEndpoint && refreshedEndpoint.status !== 'incompatible';
}
resolveRoomEndpoint(
source?: RoomSignalSourceInput,
options?: { ensureEndpoint?: boolean; setActive?: boolean }
): ServerEndpoint | null {
const normalizedSource = buildRoomSignalSource(source);
if (normalizedSource.sourceId) {
const endpointById = this.endpointState.resolveCanonicalEndpoint(
this.servers().find((endpoint) => endpoint.id === normalizedSource.sourceId) ?? null
);
if (endpointById) {
return endpointById;
}
}
if (!normalizedSource.sourceUrl) {
return null;
}
const endpointByUrl = this.endpointState.resolveCanonicalEndpoint(
this.findServerByUrl(normalizedSource.sourceUrl) ?? null
);
if (endpointByUrl) {
return endpointByUrl;
}
if (!options?.ensureEndpoint) {
return null;
}
return this.ensureServerEndpoint({
name: normalizedSource.sourceName ?? 'Signal Server',
url: normalizedSource.sourceUrl
}, {
setActive: options.setActive
});
}
normaliseRoomSignalSource(
source?: RoomSignalSourceInput,
options?: { ensureEndpoint?: boolean; setActive?: boolean }
): RoomSignalSource {
const endpoint = this.resolveRoomEndpoint(source, options);
return buildRoomSignalSource(source, endpoint);
}
buildRoomSignalSelector(
source?: RoomSignalSourceInput,
options?: { ensureEndpoint?: boolean; setActive?: boolean }
): ServerSourceSelector | undefined {
return buildRoomSignalSelector(this.normaliseRoomSignalSource(source, options));
}
getFallbackRoomEndpoints(source?: RoomSignalSourceInput): ServerEndpoint[] {
const primaryEndpoint = this.resolveRoomEndpoint(source);
const primarySource = this.normaliseRoomSignalSource(source);
const primaryUrl = primarySource.sourceUrl ? this.endpointState.sanitiseUrl(primarySource.sourceUrl) : null;
const primaryInstanceId = primaryEndpoint?.instanceId ?? null;
return this.activeServers().filter((endpoint) => {
if (endpoint.status === 'incompatible') {
return false;
}
if (primaryInstanceId && endpoint.instanceId === primaryInstanceId) {
return false;
}
return !primaryUrl || this.endpointState.sanitiseUrl(endpoint.url) !== primaryUrl;
});
}
setSearchAllServers(enabled: boolean): void {
this.shouldSearchAllServers = enabled;
}
@@ -159,6 +245,10 @@ export class ServerDirectoryFacade {
return this.api.getServer(serverId, selector);
}
findServerAcrossActiveEndpoints(serverId: string, source?: RoomSignalSourceInput): Observable<ServerInfo | null> {
return this.api.findServerAcrossActiveEndpoints(serverId, source);
}
registerServer(
server: Omit<ServerInfo, 'createdAt'> & { id?: string; password?: string | null },
selector?: ServerSourceSelector

View File

@@ -121,6 +121,20 @@ export class ServerEndpointStateService {
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);
@@ -208,6 +222,7 @@ export class ServerEndpointStateService {
return {
...endpoint,
instanceId: versions?.serverInstanceId ?? endpoint.instanceId,
status,
latency,
isActive: status === 'incompatible' ? false : endpoint.isActive,
@@ -312,4 +327,50 @@ export class ServerEndpointStateService {
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;
}
}
}

View File

@@ -0,0 +1,46 @@
import {
areRoomSignalSourcesEqual,
buildRoomSignalSelector,
buildRoomSignalSource,
getSourceUrlFromSignalingUrl
} from './room-signal-source';
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();
});
});

View File

@@ -0,0 +1,95 @@
import type { ServerEndpoint, ServerSourceSelector } from './server-directory.models';
import { normaliseConfiguredServerUrl, sanitiseServerBaseUrl } from './server-endpoint-defaults';
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;
}

View File

@@ -45,12 +45,14 @@ export interface DefaultServerDefinition {
}
export interface ServerEndpointVersions {
serverInstanceId?: string | null;
serverVersion?: string | null;
clientVersion?: string | null;
}
export interface ServerEndpoint {
id: string;
instanceId?: string;
name: string;
url: string;
isActive: boolean;
@@ -135,6 +137,7 @@ export interface ServerVersionCompatibilityResult {
}
export interface ServerHealthCheckPayload {
serverInstanceId?: unknown;
serverVersion?: unknown;
}

View File

@@ -134,6 +134,15 @@ export class InviteComponent implements OnInit {
sourceId: context.endpoint.id,
sourceUrl: context.sourceUrl
}));
const resolvedSource = this.serverDirectory.normaliseRoomSignalSource({
sourceId: joinResponse.server.sourceId ?? context.endpoint.id,
sourceName: joinResponse.server.sourceName ?? context.endpoint.name,
sourceUrl: joinResponse.server.sourceUrl ?? context.sourceUrl,
signalingUrl: joinResponse.signalingUrl,
fallbackName: joinResponse.server.sourceName ?? context.endpoint.name ?? invite.server.name
}, {
ensureEndpoint: true
});
this.store.dispatch(
RoomsActions.joinRoom({
@@ -145,9 +154,8 @@ export class InviteComponent implements OnInit {
Array.isArray(joinResponse.server.channels) && joinResponse.server.channels.length > 0
? joinResponse.server.channels
: invite.server.channels,
sourceId: context.endpoint.id,
sourceName: context.endpoint.name,
sourceUrl: context.sourceUrl
...resolvedSource,
signalingUrl: joinResponse.signalingUrl
}
})
);

View File

@@ -273,13 +273,24 @@ export class ServerSearchComponent implements OnInit {
sourceId: server.sourceId,
sourceUrl: server.sourceUrl
}));
const resolvedSource = this.serverDirectory.normaliseRoomSignalSource({
sourceId: response.server.sourceId ?? server.sourceId,
sourceName: response.server.sourceName ?? server.sourceName,
sourceUrl: response.server.sourceUrl ?? server.sourceUrl,
signalingUrl: response.signalingUrl,
fallbackName: response.server.sourceName ?? server.sourceName ?? server.name
}, {
ensureEndpoint: true
});
const resolvedServer = {
...server,
...response.server,
channels:
Array.isArray(response.server.channels) && response.server.channels.length > 0
? response.server.channels
: server.channels
: server.channels,
...resolvedSource,
signalingUrl: response.signalingUrl
};
this.closePasswordDialog();

View File

@@ -1,3 +1,4 @@
export * from './application/server-directory.facade';
export * from './domain/server-directory.constants';
export * from './domain/server-directory.models';
export * from './domain/room-signal-source';

View File

@@ -29,6 +29,7 @@ import type {
ServerSourceSelector,
UnbanServerMemberRequest
} from '../domain/server-directory.models';
import type { RoomSignalSourceInput } from '../domain/room-signal-source';
@Injectable({ providedIn: 'root' })
export class ServerDirectoryApiService {
@@ -45,14 +46,16 @@ export class ServerDirectoryApiService {
resolveEndpoint(selector?: ServerSourceSelector): ServerEndpoint | null {
if (selector?.sourceId) {
return this.endpointState.servers().find((endpoint) => endpoint.id === selector.sourceId) ?? null;
const endpoint = this.endpointState.servers().find((candidate) => candidate.id === selector.sourceId) ?? null;
return this.endpointState.resolveCanonicalEndpoint(endpoint);
}
if (selector?.sourceUrl) {
return this.endpointState.findServerByUrl(selector.sourceUrl) ?? null;
return this.endpointState.resolveCanonicalEndpoint(this.endpointState.findServerByUrl(selector.sourceUrl) ?? null);
}
return (
return this.endpointState.resolveCanonicalEndpoint(
this.endpointState.activeServer() ??
this.endpointState.servers().find((endpoint) => endpoint.status !== 'incompatible') ??
this.endpointState.servers()[0] ??
@@ -92,6 +95,45 @@ export class ServerDirectoryApiService {
);
}
findServerAcrossActiveEndpoints(serverId: string, source?: RoomSignalSourceInput): Observable<ServerInfo | null> {
const candidateEndpoints = this.getSearchableEndpoints();
const preferredSourceUrl = source?.sourceUrl ? this.endpointState.sanitiseUrl(source.sourceUrl) : undefined;
const prioritizedEndpoints = [...candidateEndpoints].sort((left, right) => {
if (preferredSourceUrl) {
const leftMatches = this.endpointState.sanitiseUrl(left.url) === preferredSourceUrl;
const rightMatches = this.endpointState.sanitiseUrl(right.url) === preferredSourceUrl;
if (leftMatches !== rightMatches) {
return leftMatches ? -1 : 1;
}
}
if (source?.sourceId) {
const leftMatches = left.id === source.sourceId;
const rightMatches = right.id === source.sourceId;
if (leftMatches !== rightMatches) {
return leftMatches ? -1 : 1;
}
}
return 0;
});
if (prioritizedEndpoints.length === 0) {
return this.getServer(serverId, this.resolveSelector(source));
}
return forkJoin(
prioritizedEndpoints.map((endpoint) => this.getServer(serverId, {
sourceId: endpoint.id,
sourceUrl: endpoint.url
}))
).pipe(
map((servers) => servers.find((server): server is ServerInfo => !!server) ?? null)
);
}
registerServer(
server: Omit<ServerInfo, 'createdAt'> & { id?: string; password?: string | null },
selector?: ServerSourceSelector
@@ -221,11 +263,17 @@ export class ServerDirectoryApiService {
}
private resolveBaseServerUrl(selector?: ServerSourceSelector): string {
const resolvedEndpoint = this.resolveEndpoint(selector);
if (resolvedEndpoint) {
return resolvedEndpoint.url;
}
if (selector?.sourceUrl) {
return this.endpointState.sanitiseUrl(selector.sourceUrl);
}
return this.resolveEndpoint(selector)?.url ?? this.endpointState.getPrimaryDefaultServerUrl();
return this.endpointState.getPrimaryDefaultServerUrl();
}
private unwrapServersResponse(response: { servers: ServerInfo[]; total: number } | ServerInfo[]): ServerInfo[] {
@@ -245,7 +293,7 @@ export class ServerDirectoryApiService {
}
private searchAllEndpoints(query: string): Observable<ServerInfo[]> {
const onlineEndpoints = this.endpointState.activeServers().filter((endpoint) => endpoint.status !== 'offline');
const onlineEndpoints = this.getSearchableEndpoints();
if (onlineEndpoints.length === 0) {
return this.searchSingleEndpoint(query, this.getApiBaseUrl(), this.endpointState.activeServer());
@@ -258,7 +306,7 @@ export class ServerDirectoryApiService {
}
private getAllServersFromAllEndpoints(): Observable<ServerInfo[]> {
const onlineEndpoints = this.endpointState.activeServers().filter((endpoint) => endpoint.status !== 'offline');
const onlineEndpoints = this.getSearchableEndpoints();
if (onlineEndpoints.length === 0) {
return this.http.get<{ servers: ServerInfo[]; total: number }>(`${this.getApiBaseUrl()}/servers`).pipe(
@@ -277,6 +325,30 @@ export class ServerDirectoryApiService {
).pipe(map((resultArrays) => resultArrays.flat()));
}
private getSearchableEndpoints(): ServerEndpoint[] {
const activeEndpoints = this.endpointState.activeServers().filter((endpoint) => endpoint.status !== 'offline');
if (activeEndpoints.length > 0) {
return activeEndpoints;
}
const activeServer = this.endpointState.activeServer();
return activeServer ? [activeServer] : [];
}
private resolveSelector(source?: RoomSignalSourceInput): ServerSourceSelector | undefined {
if (source?.sourceId) {
return { sourceId: source.sourceId };
}
if (source?.sourceUrl) {
return { sourceUrl: source.sourceUrl };
}
return undefined;
}
private deduplicateById<T extends { id: string }>(items: T[]): T[] {
const seen = new Set<string>();
@@ -298,13 +370,14 @@ export class ServerDirectoryApiService {
const candidate = server as Record<string, unknown>;
const sourceName = this.getStringValue(candidate['sourceName']);
const sourceUrl = this.getStringValue(candidate['sourceUrl']);
const resolvedSource = this.endpointState.resolveCanonicalEndpoint(source);
return {
id: this.getStringValue(candidate['id']) ?? '',
name: this.getStringValue(candidate['name']) ?? 'Unnamed server',
description: this.getStringValue(candidate['description']),
topic: this.getStringValue(candidate['topic']),
hostName: this.getStringValue(candidate['hostName']) ?? sourceName ?? source?.name ?? 'Unknown API',
hostName: this.getStringValue(candidate['hostName']) ?? sourceName ?? resolvedSource?.name ?? 'Unknown API',
ownerId: this.getStringValue(candidate['ownerId']),
ownerName: this.getStringValue(candidate['ownerName']),
ownerPublicKey: this.getStringValue(candidate['ownerPublicKey']),
@@ -319,9 +392,13 @@ export class ServerDirectoryApiService {
roleAssignments: this.getRoleAssignmentsValue(candidate['roleAssignments']),
channelPermissions: this.getChannelPermissionOverridesValue(candidate['channelPermissions']),
createdAt: this.getNumberValue(candidate['createdAt'], Date.now()),
sourceId: this.getStringValue(candidate['sourceId']) ?? source?.id,
sourceName: sourceName ?? source?.name,
sourceUrl: sourceUrl ? this.endpointState.sanitiseUrl(sourceUrl) : source ? this.endpointState.sanitiseUrl(source.url) : undefined
sourceId: this.getStringValue(candidate['sourceId']) ?? resolvedSource?.id,
sourceName: sourceName ?? resolvedSource?.name,
sourceUrl: sourceUrl
? this.endpointState.sanitiseUrl(sourceUrl)
: resolvedSource
? this.endpointState.sanitiseUrl(resolvedSource.url)
: undefined
};
}

View File

@@ -30,12 +30,16 @@ export class ServerEndpointHealthService {
payload.serverVersion,
clientVersion
);
const serverInstanceId = typeof payload.serverInstanceId === 'string' && payload.serverInstanceId.trim().length > 0
? payload.serverInstanceId.trim()
: undefined;
if (!versionCompatibility.isCompatible) {
return {
status: 'incompatible',
latency,
versions: {
serverInstanceId,
serverVersion: versionCompatibility.serverVersion,
clientVersion
}
@@ -46,6 +50,7 @@ export class ServerEndpointHealthService {
status: 'online',
latency,
versions: {
serverInstanceId,
serverVersion: versionCompatibility.serverVersion,
clientVersion
}