1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-08-06 12:02:40 +03:00

Fetch capabilities in the background (#4246)

* Fetch capabilities in the background

& keep them up to date

* Add missed await

* Replace some more runAllTimers

and round down the wait time for sanity

* Remove double comment

* Typo

* Add a method back that will fetch capabilities if they're not already there

* Add tests

* Catch exception here too

* Add test for room version code
This commit is contained in:
David Baker
2024-06-19 11:24:56 +01:00
committed by GitHub
parent c70aa33367
commit 819fc75202
11 changed files with 340 additions and 99 deletions

View File

@@ -796,7 +796,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
const result = await aliceCrypto.checkKeyBackupAndEnable(); const result = await aliceCrypto.checkKeyBackupAndEnable();
expect(result).toBeTruthy(); expect(result).toBeTruthy();
jest.runAllTimers(); jest.advanceTimersByTime(10 * 60 * 1000);
await failurePromise; await failurePromise;
// Fix the endpoint to do successful uploads // Fix the endpoint to do successful uploads
@@ -829,7 +829,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
}); });
// run the timers, which will make the backup loop redo the request // run the timers, which will make the backup loop redo the request
await jest.runAllTimersAsync(); await jest.advanceTimersByTimeAsync(10 * 60 * 1000);
await successPromise; await successPromise;
await allKeysUploadedPromise; await allKeysUploadedPromise;
}); });

View File

@@ -1293,18 +1293,109 @@ describe("MatrixClient", function () {
}); });
describe("getCapabilities", () => { describe("getCapabilities", () => {
it("should cache by default", async () => { it("should return cached capabilities if present", async () => {
const capsObject = {
"m.change_password": false,
};
httpBackend!.when("GET", "/versions").respond(200, {});
httpBackend!.when("GET", "/pushrules").respond(200, {});
httpBackend!.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
httpBackend.when("GET", "/capabilities").respond(200, { httpBackend.when("GET", "/capabilities").respond(200, {
capabilities: { capabilities: capsObject,
"m.change_password": false,
},
}); });
const prom = httpBackend.flushAllExpected();
const capabilities1 = await client.getCapabilities(); client.startClient();
const capabilities2 = await client.getCapabilities(); await httpBackend!.flushAllExpected();
expect(await client.getCapabilities()).toEqual(capsObject);
});
it("should fetch capabilities if cache not present", async () => {
const capsObject = {
"m.change_password": false,
};
httpBackend.when("GET", "/capabilities").respond(200, {
capabilities: capsObject,
});
const capsPromise = client.getCapabilities();
await httpBackend!.flushAllExpected();
expect(await capsPromise).toEqual(capsObject);
});
});
describe("getCachedCapabilities", () => {
it("should return cached capabilities or undefined", async () => {
const capsObject = {
"m.change_password": false,
};
httpBackend!.when("GET", "/versions").respond(200, {});
httpBackend!.when("GET", "/pushrules").respond(200, {});
httpBackend!.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
httpBackend.when("GET", "/capabilities").respond(200, {
capabilities: capsObject,
});
expect(client.getCachedCapabilities()).toBeUndefined();
client.startClient();
await httpBackend!.flushAllExpected();
expect(client.getCachedCapabilities()).toEqual(capsObject);
});
});
describe("fetchCapabilities", () => {
const capsObject = {
"m.change_password": false,
};
beforeEach(() => {
httpBackend.when("GET", "/capabilities").respond(200, {
capabilities: capsObject,
});
});
afterEach(() => {
jest.useRealTimers();
});
it("should always fetch capabilities and then cache", async () => {
const prom = client.fetchCapabilities();
await httpBackend.flushAllExpected();
const caps = await prom;
expect(caps).toEqual(capsObject);
});
it("should write-through the cache", async () => {
httpBackend!.when("GET", "/versions").respond(200, {});
httpBackend!.when("GET", "/pushrules").respond(200, {});
httpBackend!.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
client.startClient();
await httpBackend!.flushAllExpected();
expect(client.getCachedCapabilities()).toEqual(capsObject);
const newCapsObject = {
"m.change_password": true,
};
httpBackend.when("GET", "/capabilities").respond(200, {
capabilities: newCapsObject,
});
const prom = client.fetchCapabilities();
await httpBackend.flushAllExpected();
await prom; await prom;
expect(capabilities1).toStrictEqual(capabilities2); expect(client.getCachedCapabilities()).toEqual(newCapsObject);
}); });
}); });

View File

@@ -105,13 +105,13 @@ describe("MatrixClient syncing errors", () => {
await client!.startClient(); await client!.startClient();
expect(await syncEvents[0].promise).toBe(SyncState.Error); expect(await syncEvents[0].promise).toBe(SyncState.Error);
jest.runAllTimers(); // this will skip forward to trigger the keepAlive/sync jest.advanceTimersByTime(60 * 1000); // this will skip forward to trigger the keepAlive/sync
expect(await syncEvents[1].promise).toBe(SyncState.Error); expect(await syncEvents[1].promise).toBe(SyncState.Error);
jest.runAllTimers(); // this will skip forward to trigger the keepAlive/sync jest.advanceTimersByTime(60 * 1000); // this will skip forward to trigger the keepAlive/sync
expect(await syncEvents[2].promise).toBe(SyncState.Prepared); expect(await syncEvents[2].promise).toBe(SyncState.Prepared);
jest.runAllTimers(); // this will skip forward to trigger the keepAlive/sync jest.advanceTimersByTime(60 * 1000); // this will skip forward to trigger the keepAlive/sync
expect(await syncEvents[3].promise).toBe(SyncState.Syncing); expect(await syncEvents[3].promise).toBe(SyncState.Syncing);
jest.runAllTimers(); // this will skip forward to trigger the keepAlive/sync jest.advanceTimersByTime(60 * 1000); // this will skip forward to trigger the keepAlive/sync
expect(await syncEvents[4].promise).toBe(SyncState.Syncing); expect(await syncEvents[4].promise).toBe(SyncState.Syncing);
}); });
@@ -119,6 +119,7 @@ describe("MatrixClient syncing errors", () => {
jest.useFakeTimers(); jest.useFakeTimers();
fetchMock.config.overwriteRoutes = false; fetchMock.config.overwriteRoutes = false;
fetchMock fetchMock
.get("end:capabilities", {})
.getOnce("end:versions", {}) // first version check without credentials needs to succeed .getOnce("end:versions", {}) // first version check without credentials needs to succeed
.get("end:versions", unknownTokenErrorData) // further version checks fails with 401 .get("end:versions", unknownTokenErrorData) // further version checks fails with 401
.get("end:pushrules/", 401) // fails with 401 without an error. This does happen in practice e.g. with Synapse .get("end:pushrules/", 401) // fails with 401 without an error. This does happen in practice e.g. with Synapse

View File

@@ -88,6 +88,6 @@ export const mockClientMethodsEvents = () => ({
export const mockClientMethodsServer = (): Partial<Record<MethodLikeKeys<MatrixClient>, unknown>> => ({ export const mockClientMethodsServer = (): Partial<Record<MethodLikeKeys<MatrixClient>, unknown>> => ({
getIdentityServerUrl: jest.fn(), getIdentityServerUrl: jest.fn(),
getHomeserverUrl: jest.fn(), getHomeserverUrl: jest.fn(),
getCapabilities: jest.fn().mockReturnValue({}), getCachedCapabilities: jest.fn().mockReturnValue({}),
doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(false), doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(false),
}); });

View File

@@ -116,7 +116,7 @@ function makeMockClient(opts: {
}, },
}; };
}, },
getCapabilities() { getCachedCapabilities() {
return opts.msc3882r0Only return opts.msc3882r0Only
? {} ? {}
: { : {

View File

@@ -4034,4 +4034,47 @@ describe("Room", function () {
expect(room.getLastThread()).toBe(thread2); expect(room.getLastThread()).toBe(thread2);
}); });
}); });
describe("getRecommendedVersion", () => {
it("returns the server's recommended version from capabilities", async () => {
const client = new TestClient(userA).client;
client.getCapabilities = jest.fn().mockReturnValue({
["m.room_versions"]: {
default: "1",
available: ["1", "2"],
},
});
const room = new Room(roomId, client, userA);
expect(await room.getRecommendedVersion()).toEqual({
version: "1",
needsUpgrade: false,
urgent: false,
});
});
it("force-refreshes versions to make sure an upgrade is necessary", async () => {
const client = new TestClient(userA).client;
client.getCapabilities = jest.fn().mockReturnValue({
["m.room_versions"]: {
default: "5",
available: ["5"],
},
});
client.fetchCapabilities = jest.fn().mockResolvedValue({
["m.room_versions"]: {
default: "1",
available: ["1"],
},
});
const room = new Room(roomId, client, userA);
expect(await room.getRecommendedVersion()).toEqual({
version: "1",
needsUpgrade: false,
urgent: false,
});
expect(client.fetchCapabilities).toHaveBeenCalled();
});
});
}); });

View File

@@ -226,6 +226,7 @@ import { getRelationsThreadFilter } from "./thread-utils";
import { KnownMembership, Membership } from "./@types/membership"; import { KnownMembership, Membership } from "./@types/membership";
import { RoomMessageEventContent, StickerEventContent } from "./@types/events"; import { RoomMessageEventContent, StickerEventContent } from "./@types/events";
import { ImageInfo } from "./@types/media"; import { ImageInfo } from "./@types/media";
import { Capabilities, ServerCapabilities } from "./serverCapabilities";
export type Store = IStore; export type Store = IStore;
@@ -233,7 +234,6 @@ export type ResetTimelineCallback = (roomId: string) => boolean;
const SCROLLBACK_DELAY_MS = 3000; const SCROLLBACK_DELAY_MS = 3000;
export const CRYPTO_ENABLED: boolean = isCryptoAvailable(); export const CRYPTO_ENABLED: boolean = isCryptoAvailable();
const CAPABILITIES_CACHE_MS = 21600000; // 6 hours - an arbitrary value
const TURN_CHECK_INTERVAL = 10 * 60 * 1000; // poll for turn credentials every 10 minutes const TURN_CHECK_INTERVAL = 10 * 60 * 1000; // poll for turn credentials every 10 minutes
export const UNSTABLE_MSC3852_LAST_SEEN_UA = new UnstableValue( export const UNSTABLE_MSC3852_LAST_SEEN_UA = new UnstableValue(
@@ -518,26 +518,6 @@ export interface IStartClientOpts {
export interface IStoredClientOpts extends IStartClientOpts {} export interface IStoredClientOpts extends IStartClientOpts {}
export enum RoomVersionStability {
Stable = "stable",
Unstable = "unstable",
}
export interface IRoomVersionsCapability {
default: string;
available: Record<string, RoomVersionStability>;
}
export interface ICapability {
enabled: boolean;
}
export interface IChangePasswordCapability extends ICapability {}
export interface IThreadsCapability extends ICapability {}
export interface IGetLoginTokenCapability extends ICapability {}
export const GET_LOGIN_TOKEN_CAPABILITY = new NamespacedValue( export const GET_LOGIN_TOKEN_CAPABILITY = new NamespacedValue(
"m.get_login_token", "m.get_login_token",
"org.matrix.msc3882.get_login_token", "org.matrix.msc3882.get_login_token",
@@ -547,19 +527,6 @@ export const UNSTABLE_MSC2666_SHARED_ROOMS = "uk.half-shot.msc2666";
export const UNSTABLE_MSC2666_MUTUAL_ROOMS = "uk.half-shot.msc2666.mutual_rooms"; export const UNSTABLE_MSC2666_MUTUAL_ROOMS = "uk.half-shot.msc2666.mutual_rooms";
export const UNSTABLE_MSC2666_QUERY_MUTUAL_ROOMS = "uk.half-shot.msc2666.query_mutual_rooms"; export const UNSTABLE_MSC2666_QUERY_MUTUAL_ROOMS = "uk.half-shot.msc2666.query_mutual_rooms";
/**
* A representation of the capabilities advertised by a homeserver as defined by
* [Capabilities negotiation](https://spec.matrix.org/v1.6/client-server-api/#get_matrixclientv3capabilities).
*/
export interface Capabilities {
[key: string]: any;
"m.change_password"?: IChangePasswordCapability;
"m.room_versions"?: IRoomVersionsCapability;
"io.element.thread"?: IThreadsCapability;
"m.get_login_token"?: IGetLoginTokenCapability;
"org.matrix.msc3882.get_login_token"?: IGetLoginTokenCapability;
}
enum CrossSigningKeyType { enum CrossSigningKeyType {
MasterKey = "master_key", MasterKey = "master_key",
SelfSigningKey = "self_signing_key", SelfSigningKey = "self_signing_key",
@@ -1293,10 +1260,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// TODO: This should expire: https://github.com/matrix-org/matrix-js-sdk/issues/1020 // TODO: This should expire: https://github.com/matrix-org/matrix-js-sdk/issues/1020
protected serverVersionsPromise?: Promise<IServerVersions>; protected serverVersionsPromise?: Promise<IServerVersions>;
public cachedCapabilities?: {
capabilities: Capabilities;
expiration: number;
};
protected clientWellKnown?: IClientWellKnown; protected clientWellKnown?: IClientWellKnown;
protected clientWellKnownPromise?: Promise<IClientWellKnown>; protected clientWellKnownPromise?: Promise<IClientWellKnown>;
protected turnServers: ITurnServer[] = []; protected turnServers: ITurnServer[] = [];
@@ -1325,6 +1288,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public readonly matrixRTC: MatrixRTCSessionManager; public readonly matrixRTC: MatrixRTCSessionManager;
private serverCapabilitiesService: ServerCapabilities;
public constructor(opts: IMatrixClientCreateOpts) { public constructor(opts: IMatrixClientCreateOpts) {
super(); super();
@@ -1418,6 +1383,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// the underlying session management and doesn't use any actual media capabilities // the underlying session management and doesn't use any actual media capabilities
this.matrixRTC = new MatrixRTCSessionManager(this); this.matrixRTC = new MatrixRTCSessionManager(this);
this.serverCapabilitiesService = new ServerCapabilities(this.http);
this.on(ClientEvent.Sync, this.fixupRoomNotifications); this.on(ClientEvent.Sync, this.fixupRoomNotifications);
this.timelineSupport = Boolean(opts.timelineSupport); this.timelineSupport = Boolean(opts.timelineSupport);
@@ -1540,6 +1507,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
} }
this.toDeviceMessageQueue.start(); this.toDeviceMessageQueue.start();
this.serverCapabilitiesService.start();
} }
/** /**
@@ -1593,6 +1561,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
this.toDeviceMessageQueue.stop(); this.toDeviceMessageQueue.stop();
this.matrixRTC.stop(); this.matrixRTC.stop();
this.serverCapabilitiesService.stop();
} }
/** /**
@@ -2095,47 +2065,35 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
} }
/** /**
* Gets the capabilities of the homeserver. Always returns an object of * Gets the cached capabilities of the homeserver, returning cached ones if available.
* capability keys and their options, which may be empty. * If there are no cached capabilities and none can be fetched, throw an exception.
* @param fresh - True to ignore any cached values. *
* @returns Promise which resolves to the capabilities of the homeserver * @returns Promise resolving with The capabilities of the homeserver
* @returns Rejects: with an error response.
*/ */
public getCapabilities(fresh = false): Promise<Capabilities> { public async getCapabilities(): Promise<Capabilities> {
const now = new Date().getTime(); const caps = this.serverCapabilitiesService.getCachedCapabilities();
if (caps) return caps;
return this.serverCapabilitiesService.fetchCapabilities();
}
if (this.cachedCapabilities && !fresh) { /**
if (now < this.cachedCapabilities.expiration) { * Gets the cached capabilities of the homeserver. If none have been fetched yet,
this.logger.debug("Returning cached capabilities"); * return undefined.
return Promise.resolve(this.cachedCapabilities.capabilities); *
} * @returns The capabilities of the homeserver
} */
public getCachedCapabilities(): Capabilities | undefined {
return this.serverCapabilitiesService.getCachedCapabilities();
}
type Response = { /**
capabilities?: Capabilities; * Fetches the latest capabilities from the homeserver, ignoring any cached
}; * versions. The newly returned version is cached.
return this.http *
.authedRequest<Response>(Method.Get, "/capabilities") * @returns A promise which resolves to the capabilities of the homeserver
.catch((e: Error): Response => { */
// We swallow errors because we need a default object anyhow public fetchCapabilities(): Promise<Capabilities> {
this.logger.error(e); return this.serverCapabilitiesService.fetchCapabilities();
return {};
})
.then((r = {}) => {
const capabilities = r["capabilities"] || {};
// If the capabilities missed the cache, cache it for a shorter amount
// of time to try and refresh them later.
const cacheMs = Object.keys(capabilities).length ? CAPABILITIES_CACHE_MS : 60000 + Math.random() * 5000;
this.cachedCapabilities = {
capabilities,
expiration: now + cacheMs,
};
this.logger.debug("Caching capabilities: ", capabilities);
return capabilities;
});
} }
/** /**

View File

@@ -24,6 +24,7 @@ import { RoomWidgetClient, ICapabilities } from "./embedded";
import { CryptoStore } from "./crypto/store/base"; import { CryptoStore } from "./crypto/store/base";
export * from "./client"; export * from "./client";
export * from "./serverCapabilities";
export * from "./embedded"; export * from "./embedded";
export * from "./http-api"; export * from "./http-api";
export * from "./autodiscovery"; export * from "./autodiscovery";

View File

@@ -41,7 +41,7 @@ import {
RelationType, RelationType,
UNSIGNED_THREAD_ID_FIELD, UNSIGNED_THREAD_ID_FIELD,
} from "../@types/event"; } from "../@types/event";
import { IRoomVersionsCapability, MatrixClient, PendingEventOrdering, RoomVersionStability } from "../client"; import { MatrixClient, PendingEventOrdering } from "../client";
import { GuestAccess, HistoryVisibility, JoinRule, ResizeMethod } from "../@types/partials"; import { GuestAccess, HistoryVisibility, JoinRule, ResizeMethod } from "../@types/partials";
import { Filter, IFilterDefinition } from "../filter"; import { Filter, IFilterDefinition } from "../filter";
import { RoomState, RoomStateEvent, RoomStateEventHandlerMap } from "./room-state"; import { RoomState, RoomStateEvent, RoomStateEventHandlerMap } from "./room-state";
@@ -70,6 +70,7 @@ import { RoomReceipts } from "./room-receipts";
import { compareEventOrdering } from "./compare-event-ordering"; import { compareEventOrdering } from "./compare-event-ordering";
import * as utils from "../utils"; import * as utils from "../utils";
import { KnownMembership, Membership } from "../@types/membership"; import { KnownMembership, Membership } from "../@types/membership";
import { Capabilities, IRoomVersionsCapability, RoomVersionStability } from "../serverCapabilities";
// These constants are used as sane defaults when the homeserver doesn't support // These constants are used as sane defaults when the homeserver doesn't support
// the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be // the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be
@@ -611,7 +612,10 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
* Resolves to the version the room should be upgraded to. * Resolves to the version the room should be upgraded to.
*/ */
public async getRecommendedVersion(): Promise<IRecommendedVersion> { public async getRecommendedVersion(): Promise<IRecommendedVersion> {
const capabilities = await this.client.getCapabilities(); let capabilities: Capabilities = {};
try {
capabilities = await this.client.getCapabilities();
} catch (e) {}
let versionCap = capabilities["m.room_versions"]; let versionCap = capabilities["m.room_versions"];
if (!versionCap) { if (!versionCap) {
versionCap = { versionCap = {
@@ -636,8 +640,12 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
"to be supporting a newer room version we don't know about.", "to be supporting a newer room version we don't know about.",
); );
const caps = await this.client.getCapabilities(true); try {
versionCap = caps["m.room_versions"]; capabilities = await this.client.fetchCapabilities();
} catch (e) {
logger.warn("Failed to refresh room version capabilities", e);
}
versionCap = capabilities["m.room_versions"];
if (!versionCap) { if (!versionCap) {
logger.warn("No room version capability - assuming upgrade required."); logger.warn("No room version capability - assuming upgrade required.");
return result; return result;

View File

@@ -22,12 +22,12 @@ import {
LegacyRendezvousFailureReason as RendezvousFailureReason, LegacyRendezvousFailureReason as RendezvousFailureReason,
RendezvousIntent, RendezvousIntent,
} from "."; } from ".";
import { IGetLoginTokenCapability, MatrixClient, GET_LOGIN_TOKEN_CAPABILITY } from "../client"; import { MatrixClient, GET_LOGIN_TOKEN_CAPABILITY } from "../client";
import { buildFeatureSupportMap, Feature, ServerSupport } from "../feature"; import { buildFeatureSupportMap, Feature, ServerSupport } from "../feature";
import { logger } from "../logger"; import { logger } from "../logger";
import { sleep } from "../utils"; import { sleep } from "../utils";
import { CrossSigningKey } from "../crypto-api"; import { CrossSigningKey } from "../crypto-api";
import { Device } from "../matrix"; import { Capabilities, Device, IGetLoginTokenCapability } from "../matrix";
enum PayloadType { enum PayloadType {
Start = "m.login.start", Start = "m.login.start",
@@ -109,7 +109,10 @@ export class MSC3906Rendezvous {
logger.info(`Connected to secure channel with checksum: ${checksum} our intent is ${this.ourIntent}`); logger.info(`Connected to secure channel with checksum: ${checksum} our intent is ${this.ourIntent}`);
// in stable and unstable r1 the availability is exposed as a capability // in stable and unstable r1 the availability is exposed as a capability
const capabilities = await this.client.getCapabilities(); let capabilities: Capabilities = {};
try {
capabilities = await this.client.getCapabilities();
} catch (e) {}
// in r0 of MSC3882 the availability is exposed as a feature flag // in r0 of MSC3882 the availability is exposed as a feature flag
const features = await buildFeatureSupportMap(await this.client.getVersions()); const features = await buildFeatureSupportMap(await this.client.getVersions());
const capability = GET_LOGIN_TOKEN_CAPABILITY.findIn<IGetLoginTokenCapability>(capabilities); const capability = GET_LOGIN_TOKEN_CAPABILITY.findIn<IGetLoginTokenCapability>(capabilities);

136
src/serverCapabilities.ts Normal file
View File

@@ -0,0 +1,136 @@
/*
Copyright 2024 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { IHttpOpts, MatrixHttpApi, Method } from "./http-api";
import { logger } from "./logger";
// How often we update the server capabilities.
// 6 hours - an arbitrary value, but they should change very infrequently.
const CAPABILITIES_CACHE_MS = 6 * 60 * 60 * 1000;
// How long we want before retrying if we couldn't fetch
const CAPABILITIES_RETRY_MS = 30 * 1000;
export interface ICapability {
enabled: boolean;
}
export interface IChangePasswordCapability extends ICapability {}
export interface IThreadsCapability extends ICapability {}
export interface IGetLoginTokenCapability extends ICapability {}
export interface ISetDisplayNameCapability extends ICapability {}
export interface ISetAvatarUrlCapability extends ICapability {}
export enum RoomVersionStability {
Stable = "stable",
Unstable = "unstable",
}
export interface IRoomVersionsCapability {
default: string;
available: Record<string, RoomVersionStability>;
}
/**
* A representation of the capabilities advertised by a homeserver as defined by
* [Capabilities negotiation](https://spec.matrix.org/v1.6/client-server-api/#get_matrixclientv3capabilities).
*/
export interface Capabilities {
[key: string]: any;
"m.change_password"?: IChangePasswordCapability;
"m.room_versions"?: IRoomVersionsCapability;
"io.element.thread"?: IThreadsCapability;
"m.get_login_token"?: IGetLoginTokenCapability;
"org.matrix.msc3882.get_login_token"?: IGetLoginTokenCapability;
"m.set_displayname"?: ISetDisplayNameCapability;
"m.set_avatar_url"?: ISetAvatarUrlCapability;
}
type CapabilitiesResponse = {
capabilities: Capabilities;
};
/**
* Manages storing and periodically refreshing the server capabilities.
*/
export class ServerCapabilities {
private capabilities?: Capabilities;
private retryTimeout?: ReturnType<typeof setTimeout>;
private refreshTimeout?: ReturnType<typeof setInterval>;
public constructor(private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>) {}
/**
* Starts periodically fetching the server capabilities.
*/
public start(): void {
this.poll().then();
}
/**
* Stops the service
*/
public stop(): void {
this.clearTimeouts();
}
/**
* Returns the cached capabilities, or undefined if none are cached.
* @returns the current capabilities, if any.
*/
public getCachedCapabilities(): Capabilities | undefined {
return this.capabilities;
}
/**
* Fetches the latest server capabilities from the homeserver and returns them, or rejects
* on failure.
*/
public fetchCapabilities = async (): Promise<Capabilities> => {
const resp = await this.http.authedRequest<CapabilitiesResponse>(Method.Get, "/capabilities");
this.capabilities = resp["capabilities"];
return this.capabilities;
};
private poll = async (): Promise<void> => {
try {
await this.fetchCapabilities();
this.clearTimeouts();
this.refreshTimeout = setTimeout(this.poll, CAPABILITIES_CACHE_MS);
logger.debug("Fetched new server capabilities");
} catch (e) {
this.clearTimeouts();
const howLong = Math.floor(CAPABILITIES_RETRY_MS + Math.random() * 5000);
this.retryTimeout = setTimeout(this.poll, howLong);
logger.warn(`Failed to refresh capabilities: retrying in ${howLong}ms`, e);
}
};
private clearTimeouts(): void {
if (this.refreshTimeout) {
clearInterval(this.refreshTimeout);
this.refreshTimeout = undefined;
}
if (this.retryTimeout) {
clearTimeout(this.retryTimeout);
this.retryTimeout = undefined;
}
}
}