diff --git a/spec/unit/matrix-client.spec.ts b/spec/unit/matrix-client.spec.ts index f3d3bf2e42..a42b59e1d3 100644 --- a/spec/unit/matrix-client.spec.ts +++ b/spec/unit/matrix-client.spec.ts @@ -24,6 +24,7 @@ import { type MockedObject, type Mocked } from "vitest"; import { logger } from "../../src/logger"; import { ClientEvent, + type IClientWellKnown, type IMatrixClientCreateOpts, type ITurnServerResponse, MatrixClient, @@ -48,6 +49,7 @@ import * as testUtils from "../test-utils/test-utils"; import { makeBeaconInfoContent } from "../../src/content-helpers"; import { M_BEACON_INFO } from "../../src/@types/beacon"; import { + AutoDiscovery, ClientPrefix, ConditionKind, ContentHelpers, @@ -199,6 +201,12 @@ describe("MatrixClient", function () { data: {}, }; + const RTC_TRANSPORT_RESPONSE: HttpLookup = { + method: "GET", + path: "/rtc/transports/", + data: { rtc_transports: [] }, + }; + const FILTER_PATH = "/user/" + encodeURIComponent(userId) + "/filter"; const FILTER_RESPONSE: HttpLookup = { @@ -397,6 +405,7 @@ describe("MatrixClient", function () { pendingLookup = null; httpLookups = []; httpLookups.push(PUSH_RULES_RESPONSE); + httpLookups.push(RTC_TRANSPORT_RESPONSE); httpLookups.push(FILTER_RESPONSE); httpLookups.push(SYNC_RESPONSE); }); @@ -1603,7 +1612,7 @@ describe("MatrixClient", function () { }); it("should not POST /filter if a matching filter already exists", async function () { - httpLookups = [PUSH_RULES_RESPONSE, SYNC_RESPONSE]; + httpLookups = [PUSH_RULES_RESPONSE, RTC_TRANSPORT_RESPONSE, SYNC_RESPONSE]; const filterId = "ehfewf"; vi.mocked(store.getFilterIdByName).mockReturnValue(filterId); const filter = new Filter("0", filterId); @@ -1718,6 +1727,7 @@ describe("MatrixClient", function () { }); it("should work on /sync", async () => { + httpLookups.push(RTC_TRANSPORT_RESPONSE); httpLookups.push({ method: "GET", path: "/sync", @@ -1754,6 +1764,7 @@ describe("MatrixClient", function () { path: "/pushrules/", error: { errcode: "NOPE_NOPE_NOPE" }, }); + httpLookups.push(RTC_TRANSPORT_RESPONSE); httpLookups.push(PUSH_RULES_RESPONSE); httpLookups.push(FILTER_RESPONSE); httpLookups.push(SYNC_RESPONSE); @@ -1814,6 +1825,7 @@ describe("MatrixClient", function () { const expectedStates: [string, string | null][] = []; httpLookups = []; httpLookups.push(PUSH_RULES_RESPONSE); + httpLookups.push(RTC_TRANSPORT_RESPONSE); httpLookups.push({ method: "POST", path: FILTER_PATH, @@ -3890,6 +3902,30 @@ describe("MatrixClient", function () { ]); }); }); + + describe("Well-known", () => { + it("caches the well-known value", async () => { + const A_WELLKNOWN: IClientWellKnown = { + "m.homeserver": { + base_url: "https://hs.org", + }, + "m.identity_server": { + base_url: "https://is.org", + }, + }; + + void client.startClient(); + + vi.spyOn(AutoDiscovery, "getRawClientConfig").mockResolvedValue(A_WELLKNOWN); + + const value = await client.waitForClientWellKnown(); + expect(value).toStrictEqual(A_WELLKNOWN); + + const cached = client.getClientWellKnown(); + expect(cached).toStrictEqual(A_WELLKNOWN); + }); + }); + describe("getUrlPreview", () => { it("makes a well-formed request to the new endpoint", async () => { client.getVersions = vi.fn().mockResolvedValue({ diff --git a/spec/unit/pollingCachedValue.spec.ts b/spec/unit/pollingCachedValue.spec.ts new file mode 100644 index 0000000000..37ca5d5072 --- /dev/null +++ b/spec/unit/pollingCachedValue.spec.ts @@ -0,0 +1,248 @@ +/* +Copyright 2026 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 { afterEach, beforeEach, describe, vi, it, expect, type Mock } from "vitest"; +import { PollingCachedValue } from "../../src/pollingCachedValue.ts"; +import { MatrixError } from "../../src"; +import { logger } from "../../src/logger.ts"; + +describe("PollingCachedValue", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + const TTL = 10000; + let fetchFn: Mock<() => Promise>; + let cachedCallback: Mock<(value: string) => void>; + let clientCachedValue: PollingCachedValue; + + beforeEach(() => { + fetchFn = vi.fn<() => Promise>(); + cachedCallback = vi.fn<(value: string) => void>(); + clientCachedValue = new PollingCachedValue({ + name: "mock", + logger, + ttlMillis: TTL, + fetch: fetchFn, + onValueCached: cachedCallback, + }); + }); + + it("should fetch and cache the value", async () => { + fetchFn.mockResolvedValue("HelloWorld!"); + + const value = await clientCachedValue.wait(); + + expect(value).toBe("HelloWorld!"); + expect(clientCachedValue.get()).toBe("HelloWorld!"); + expect(cachedCallback).toHaveBeenCalledWith("HelloWorld!"); + }); + + it("should not call again once the value is cached", async () => { + fetchFn.mockResolvedValue("HelloWorld!"); + + const value = await clientCachedValue.wait(); + + expect(value).toBe("HelloWorld!"); + + // ask the value again + await clientCachedValue.wait(); + + expect(clientCachedValue.get()).toBe("HelloWorld!"); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + + it("should expire the cache after the specified time", async () => { + fetchFn.mockResolvedValue("HelloWorld!"); + + const value = await clientCachedValue.wait(); + expect(value).toBe("HelloWorld!"); + + fetchFn.mockResolvedValue("NewValue!"); + + // Within the refresh period, so still the old value + await vi.advanceTimersByTimeAsync(TTL / 2); + expect(await clientCachedValue.wait()).toBe("HelloWorld!"); + + // advance time by 1001ms to trigger cache expiration + await vi.advanceTimersByTimeAsync(TTL / 2 + 10); + + const newValue = await clientCachedValue.wait(); + expect(newValue).toBe("NewValue!"); + + expect(fetchFn).toHaveBeenCalledTimes(2); + }); + + it("should automatically retry limit exceeded transient errors", async () => { + fetchFn.mockImplementation(() => { + throw new MatrixError( + { errcode: "M_LIMIT_EXCEEDED", error: "Too many requests", retry_after_ms: 1000 }, + 429, + ); + }); + + const fetchPromise = clientCachedValue.wait(); + let settled = false; + void fetchPromise.finally(() => { + settled = true; + }); + + await vi.advanceTimersByTimeAsync(100); + + // should still be pending + await Promise.resolve(); // flush microtasks + expect(settled).toBe(false); // still pending + + fetchFn.mockResolvedValue("OOO"); + await vi.advanceTimersByTimeAsync(2000); + + const value = await fetchPromise; + expect(value).toBe("OOO"); + expect(settled).toBe(true); + + expect(fetchFn).toHaveBeenCalledTimes(2); + }); + + it("should start fetching and avoid scheduling refresh once stopped", async () => { + const resolver = Promise.withResolvers(); + fetchFn.mockImplementation(() => resolver.promise); + + clientCachedValue.start(); + expect(fetchFn).toHaveBeenCalledTimes(1); + + clientCachedValue.stop(); + resolver.resolve("FromStart"); + await clientCachedValue.wait(); + + expect(clientCachedValue.get()).toBe("FromStart"); + + await vi.advanceTimersByTimeAsync(TTL + 10); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + + it("should clear an existing timeout handle when stopped", async () => { + const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout"); + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); + setTimeoutSpy.mockReturnValue(123 as unknown as ReturnType); + + clientCachedValue.start(); + await vi.advanceTimersByTimeAsync(100); + clientCachedValue.stop(); + + expect(clearTimeoutSpy).toHaveBeenCalledWith(123); + clearTimeoutSpy.mockRestore(); + setTimeoutSpy.mockRestore(); + }); + + it("should not arm a refresh trigger if no ttl is provided", async () => { + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); + setTimeoutSpy.mockReturnValue(123 as unknown as ReturnType); + + clientCachedValue = new PollingCachedValue({ name: "mock", logger, fetch: fetchFn }); + clientCachedValue.start(); + await vi.advanceTimersByTimeAsync(100); + + expect(setTimeoutSpy).not.toHaveBeenCalled(); + setTimeoutSpy.mockRestore(); + }); + + it("should let start() override the configured ttl", async () => { + fetchFn.mockResolvedValue("HelloWorld!"); + + // Configured with TTL, but started with a shorter one + clientCachedValue.start(TTL / 10); + await vi.advanceTimersByTimeAsync(TTL / 10 + 10); + + expect(fetchFn).toHaveBeenCalledTimes(2); + }); + + it("should keep the configured ttl when start() is given none", async () => { + fetchFn.mockResolvedValue("HelloWorld!"); + + clientCachedValue.start(); + + await vi.advanceTimersByTimeAsync(TTL / 2); + expect(fetchFn).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(TTL / 2 + 10); + expect(fetchFn).toHaveBeenCalledTimes(2); + }); + + it("should retry fetch on next wait for non-cacheable errors", async () => { + const nonRetryableError = new MatrixError({ errcode: "M_FORBIDDEN", error: "Forbidden" }, 403); + fetchFn.mockRejectedValueOnce(nonRetryableError).mockResolvedValueOnce("Recovered"); + + const firstValue = clientCachedValue.wait(); + await expect(firstValue).rejects.toThrow(nonRetryableError); + expect(fetchFn).toHaveBeenCalledTimes(1); + expect(clientCachedValue.get()).toBeUndefined(); + + const secondValue = await clientCachedValue.wait(); + expect(secondValue).toBe("Recovered"); + expect(fetchFn).toHaveBeenCalledTimes(2); + }); + + it("should stop retrying immediately when stopped during fetch error", async () => { + const retryableError = new MatrixError( + { errcode: "M_LIMIT_EXCEEDED", error: "Too many requests", retry_after_ms: 1000 }, + 429, + ); + fetchFn.mockRejectedValue(retryableError); + clientCachedValue.stop(); + + const value = await clientCachedValue.wait(); + expect(value).toBeUndefined(); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + + it("should reuse same promise for rapid calls", async () => { + const loader = Promise.withResolvers(); + fetchFn.mockImplementation(() => loader.promise); + + const race = Promise.race([clientCachedValue.wait(), clientCachedValue.wait(), clientCachedValue.wait()]); + + await vi.runOnlyPendingTimersAsync(); + + loader.resolve("FOO"); + const value = await race; + expect(value).toBe("FOO"); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + + it("should reuse rejected promise when error is marked cacheable", async () => { + const cacheableError = new MatrixError({ errcode: "M_FORBIDDEN", error: "Forbidden" }, 403); + const cacheableClientCachedValue = new PollingCachedValue({ + name: "mock", + logger, + ttlMillis: TTL, + fetch: fetchFn, + shouldCacheError: () => true, + }); + fetchFn.mockRejectedValue(cacheableError); + + const firstValue = cacheableClientCachedValue.wait(); + await expect(firstValue).rejects.toThrow(cacheableError); + expect(fetchFn).toHaveBeenCalledTimes(1); + + const secondValue = cacheableClientCachedValue.wait(); + await expect(secondValue).rejects.toThrow(cacheableError); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/spec/unit/rtcTransportsCachedValue.spec.ts b/spec/unit/rtcTransportsCachedValue.spec.ts new file mode 100644 index 0000000000..9db55625e3 --- /dev/null +++ b/spec/unit/rtcTransportsCachedValue.spec.ts @@ -0,0 +1,86 @@ +/* +Copyright 2026 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 { describe, vi, it, expect, type MockedObject, beforeEach, afterEach } from "vitest"; +import { type MatrixClient, MatrixError } from "../../src"; +import { logger } from "../../src/logger.ts"; +import { createRtcTransportsCachedValue } from "../../src/rtcTransportsCachedValue.ts"; +import { type PollingCachedValue } from "../../src/pollingCachedValue.ts"; +import { type Transport } from "../../src/matrixrtc"; + +describe("RtcTransportsCachedValue", () => { + let mockClient: MockedObject; + let rtcTransportsCachedValue: PollingCachedValue; + + beforeEach(() => { + mockClient = { + _unstable_getRTCTransports: vi.fn(), + emit: vi.fn(), + } as unknown as MockedObject; + rtcTransportsCachedValue = createRtcTransportsCachedValue(mockClient, logger); + }); + + it("should fetch the transport using discovery api", async () => { + const transport: Transport = { type: "livekit", livekit_service_url: "http://test.com" }; + + mockClient._unstable_getRTCTransports.mockResolvedValue([transport]); + + const result = await rtcTransportsCachedValue.wait(); + + expect(result).toEqual([transport]); + }); + + it("should not hammering server if end-point not supported", async () => { + mockClient._unstable_getRTCTransports.mockRejectedValue(new MatrixError({ errcode: "M_NOT_FOUND" }, 404)); + + await rtcTransportsCachedValue.wait().catch(() => {}); + await rtcTransportsCachedValue.wait().catch(() => {}); + await rtcTransportsCachedValue.wait().catch(() => {}); + + expect(mockClient._unstable_getRTCTransports).toHaveBeenCalledTimes(1); + + expect(rtcTransportsCachedValue.get()).toBeUndefined(); + }); + + describe("Auto Refresh", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("Should refresh once a day", async () => { + const transport: Transport = { type: "livekit", livekit_service_url: "http://test.com" }; + + // First no transports, then one + mockClient._unstable_getRTCTransports.mockResolvedValueOnce([]).mockResolvedValueOnce([transport]); + + const result = await rtcTransportsCachedValue.wait(); + + expect(result).toStrictEqual([]); + + await vi.advanceTimersByTimeAsync(60 * 60 * 1000); // Advance 1h + expect(await rtcTransportsCachedValue.wait()).toStrictEqual([]); + + await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000); // Advance by one day + + // Should have been refreshed automatically + expect(rtcTransportsCachedValue.get()).toStrictEqual([transport]); + }); + }); +}); diff --git a/src/client.ts b/src/client.ts index 85e827679b..e8cadfb3cb 100644 --- a/src/client.ts +++ b/src/client.ts @@ -53,7 +53,7 @@ import * as utils from "./utils.ts"; import { deepCompare, noUnsafeEventProps, type QueryDict, replaceParam, safeSet, sleep } from "./utils.ts"; import { Direction, EventTimeline } from "./models/event-timeline.ts"; import { type IActionsObject, PushProcessor } from "./pushprocessor.ts"; -import { AutoDiscovery, type AutoDiscoveryAction } from "./autodiscovery.ts"; +import { type AutoDiscoveryAction } from "./autodiscovery.ts"; import { encodeUnpaddedBase64Url } from "./base64.ts"; import { TypedReEmitter } from "./ReEmitter.ts"; import { logger, type Logger } from "./logger.ts"; @@ -247,6 +247,9 @@ import { type EmptyObject } from "./@types/common.ts"; import { UnsupportedDelayedEventsEndpointError, UnsupportedStickyEventsEndpointError } from "./errors.ts"; import { type Transport } from "./matrixrtc/index.ts"; import { RetentionPolicyService } from "./retentionPolicy.ts"; +import { createRtcTransportsCachedValue } from "./rtcTransportsCachedValue.ts"; +import { createWellKnownCachedValue } from "./wellKnownCachedValue.ts"; +import { type PollingCachedValue } from "./pollingCachedValue.ts"; export type Store = IStore; @@ -1095,6 +1098,7 @@ export enum ClientEvent { TurnServers = "turnServers", TurnServersError = "turnServers.error", UserProfileUpdate = "userProfileUpdate", + RtcTransportsUpdated = "rtcTransportsUpdated", } type RoomEvents = @@ -1165,6 +1169,7 @@ export type ClientEventHandlerMap = { [ClientEvent.ReceivedVoipEvent]: (event: MatrixEvent) => void; [ClientEvent.TurnServers]: (servers: ITurnServer[]) => void; [ClientEvent.TurnServersError]: (error: Error, fatal: boolean) => void; + [ClientEvent.RtcTransportsUpdated]: (transports: Transport[]) => void; /** * * @param userId - the user ID of the profile which was updated @@ -1273,7 +1278,6 @@ export class MatrixClient extends TypedEventEmitter; protected syncedLeftRooms = false; protected clientOpts?: IStoredClientOpts; - protected clientWellKnownIntervalID?: ReturnType; protected canResetTimelineCallback?: ResetTimelineCallback; public canSupport = new Map(); @@ -1285,8 +1289,6 @@ export class MatrixClient extends TypedEventEmitter; - protected clientWellKnown?: IClientWellKnown; - protected clientWellKnownPromise?: Promise; protected turnServers: ITurnServer[] = []; protected turnServersExpiry = 0; protected checkTurnServersIntervalID?: ReturnType; @@ -1316,6 +1318,9 @@ export class MatrixClient extends TypedEventEmitter; + protected cachedWellKnown: PollingCachedValue; + public constructor(opts: IMatrixClientCreateOpts) { super(); @@ -1432,6 +1437,9 @@ export class MatrixClient extends TypedEventEmitter this.logger.info("Sync startup aborted with an error:", e)); - if (this.clientOpts.clientWellKnownPollPeriod !== undefined) { - this.clientWellKnownIntervalID = setInterval(() => { - void this.fetchClientWellKnown(); - }, 1000 * this.clientOpts.clientWellKnownPollPeriod); - void this.fetchClientWellKnown(); - } + this.cachedWellKnown.start( + this.clientOpts.clientWellKnownPollPeriod !== undefined + ? 1000 * this.clientOpts.clientWellKnownPollPeriod + : undefined, + ); this.toDeviceMessageQueue.start(); this.serverCapabilitiesService.start(); if (this._unstable_shouldApplyMessageRetention) { this.retentionPolicyService?.start(); } + + this.cachedRtcTransports.start(); } /** @@ -1568,15 +1577,14 @@ export class MatrixClient extends TypedEventEmitter { - // `getRawClientConfig` does not throw or reject on network errors, instead - // it absorbs errors and returns `{}`. - this.clientWellKnownPromise = AutoDiscovery.getRawClientConfig(this.getDomain() ?? undefined); - this.clientWellKnown = await this.clientWellKnownPromise; - this.emit(ClientEvent.ClientWellKnown, this.clientWellKnown); - } - public getClientWellKnown(): IClientWellKnown | undefined { - return this.clientWellKnown; + return this.cachedWellKnown.get(); } - public waitForClientWellKnown(): Promise { + public async waitForClientWellKnown(): Promise { if (!this.clientRunning) { throw new Error("Client is not running"); } - return this.clientWellKnownPromise!; + const wellKnown = await this.cachedWellKnown.wait(); + return wellKnown!; } /** diff --git a/src/embedded.ts b/src/embedded.ts index 0624d9d757..b65f177bf5 100644 --- a/src/embedded.ts +++ b/src/embedded.ts @@ -357,12 +357,9 @@ export class RoomWidgetClient extends MatrixClient { ); } - if (opts.clientWellKnownPollPeriod !== undefined) { - this.clientWellKnownIntervalID = setInterval(() => { - this.fetchClientWellKnown(); - }, 1000 * opts.clientWellKnownPollPeriod); - this.fetchClientWellKnown(); - } + this.cachedWellKnown.start( + opts.clientWellKnownPollPeriod !== undefined ? 1000 * opts.clientWellKnownPollPeriod : undefined, + ); this.setSyncState(SyncState.Syncing); logger.info("Finished initial sync"); diff --git a/src/pollingCachedValue.ts b/src/pollingCachedValue.ts new file mode 100644 index 0000000000..e2a2445314 --- /dev/null +++ b/src/pollingCachedValue.ts @@ -0,0 +1,184 @@ +/* +Copyright 2026 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 { calculateRetryBackoff } from "./http-api/index.ts"; +import { type Logger } from "./logger.ts"; +import { sleep } from "./utils.ts"; + +/** + * How to fetch and cache a given value, as passed to {@link PollingCachedValue}. + */ +export interface PollingCachedValueOptions { + /** The name of the cached value (for tracing/logs). */ + name: string; + /** The logger to derive this value's child logger from. */ + logger: Logger; + /** Fetches a fresh value. Rejections are retried according to the retry policy. */ + fetch: () => Promise; + /** + * The default time-to-live before the value gets refreshed, overridable per run + * via {@link PollingCachedValue.start}. + * If undefined the value never expires; use {@link PollingCachedValue.refresh} to clear the cache. + */ + ttlMillis?: number; + /** Called when a new value is cached. Use to emit changes if needed. */ + onValueCached?: (value: ValueType) => void; + /** + * Determines whether a non-transient error should be cached. + * By default errors are not cached, i.e. the next attempt retries the fetch. + */ + shouldCacheError?: (error: unknown) => boolean; +} + +/** + * Defines a generic mechanism to fetch and cache a value, refreshing it periodically. + */ +export class PollingCachedValue { + private cached?: ValueType; + private fetchPromise?: Promise; + private ttlTimeoutHandle?: ReturnType; + + private isStopped = false; + private ttlMillis?: number; + + private readonly logger: Logger; + + /** + * Build a generic mechanism allowing to fetch and cache a given value. + * @param opts - Describes what to fetch and how to cache it, see {@link PollingCachedValueOptions}. + */ + public constructor(private readonly opts: PollingCachedValueOptions) { + this.ttlMillis = opts.ttlMillis; + this.logger = opts.logger.getChild(`PollingCachedValue<${opts.name}>`); + } + + /** + * Gets the cached value if any. + */ + public get(): ValueType | undefined { + return this.cached; + } + + /** + * Ensures that the value was fetched once before getting the actual cached value. + */ + public async wait(): Promise { + await this.doFetch(); + return this.cached; + } + + /** + * Call this as early as possible and when the value can already be fetched. + * @param ttlMillis - The time-to-live before the value gets refreshed, overriding the one + * given at construction. If omitted the configured default is kept. + */ + public start(ttlMillis?: number): void { + if (ttlMillis !== undefined) { + this.ttlMillis = ttlMillis; + } + this.isStopped = false; + // Request a fetch as soon as possible + this.doFetch().catch((err) => { + this.logger.debug("Cached value fetch on start did fail", err); + }); + } + /** + * Call when the client is stopped, or when the cached value is no longer needed. + */ + public stop(): void { + if (this.ttlTimeoutHandle) { + clearTimeout(this.ttlTimeoutHandle); + } + this.isStopped = true; + } + + /** + * Force to refresh. + * @throws + */ + public async refresh(): Promise { + await this.doFetch(true); + return this.cached; + } + + /** + * Internal fetch method + * @param force - if set to true will force a fetch of the value even if a fetch is already in progress. + * @private + */ + private async doFetch(force: boolean = false): Promise { + if (this.fetchPromise && !force) { + await this.fetchPromise; + return; + } + + this.fetchPromise = this.fetchWithRetryPolicy(); + + try { + this.cached = await this.fetchPromise; + if (this.isStopped) { + this.fetchPromise = undefined; + return; + } + this.logger.trace(`New cachedValue: ${this.cached}`); + this.opts.onValueCached?.(this.cached); + // The value is cached only for a given time. + if (this.ttlMillis) { + this.ttlTimeoutHandle = setTimeout(() => { + this.fetchPromise = undefined; + void this.doFetch(); + }, this.ttlMillis); + } + } catch (error) { + this.logger.debug(`Error fetching server value for ${error}`); + if (this.isStopped) { + this.fetchPromise = undefined; + return; + } + if (!this.opts.shouldCacheError?.(error)) { + // clear the promise, i.e next tentative will retry to fetch + this.fetchPromise = undefined; + } + throw error; + } + } + + /** + * Gracefully handle transient error retries, respect retry_after_millis for rate limits. + * @private + */ + private async fetchWithRetryPolicy(): Promise { + let currentRetryCount = 0; + while (true) { + try { + return await this.opts.fetch(); + } catch (e) { + this.logger.trace(`Failed to fetch retry: ${e}`); + if (this.isStopped) { + throw e; + } + currentRetryCount++; + const backoff = calculateRetryBackoff(e, currentRetryCount, true); + if (backoff < 0) { + // Max number of retries reached, or error is not retryable. rethrow the error + throw e; + } + // wait for the specified time and then retry the request + await sleep(backoff); + } + } + } +} diff --git a/src/rtcTransportsCachedValue.ts b/src/rtcTransportsCachedValue.ts new file mode 100644 index 0000000000..ced1684d6a --- /dev/null +++ b/src/rtcTransportsCachedValue.ts @@ -0,0 +1,40 @@ +/* +Copyright 2026 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 { PollingCachedValue } from "./pollingCachedValue.ts"; +import { type Transport } from "./matrixrtc/index.ts"; +import { ClientEvent, type MatrixClient } from "./client.ts"; +import { type Logger } from "./logger.ts"; +import { MatrixError } from "./http-api/index.ts"; + +const TRANSPORT_CACHE_TTL_MILLISECONDS = 1000 * 60 * 60 * 24; // 1 day + +/** + * Builds a cache for the rtc/transport discovery end-point of the given client. + */ +export function createRtcTransportsCachedValue(client: MatrixClient, logger: Logger): PollingCachedValue { + return new PollingCachedValue({ + name: "RTCTransports", + logger, + ttlMillis: TRANSPORT_CACHE_TTL_MILLISECONDS, + fetch: () => client._unstable_getRTCTransports(), + onValueCached: (transports) => client.emit(ClientEvent.RtcTransportsUpdated, transports), + // If the end point is not supported by the homeserver it will be a 404 error. + // This is a final error, it can be cached, no need to retry everytime. + // It will retry when ttl has expired. + shouldCacheError: (error) => error instanceof MatrixError && error.httpStatus === 404, + }); +} diff --git a/src/wellKnownCachedValue.ts b/src/wellKnownCachedValue.ts new file mode 100644 index 0000000000..bcb1e4ac23 --- /dev/null +++ b/src/wellKnownCachedValue.ts @@ -0,0 +1,34 @@ +/* +Copyright 2026 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 { PollingCachedValue } from "./pollingCachedValue.ts"; +import { ClientEvent, type IClientWellKnown, type MatrixClient } from "./client.ts"; +import { type Logger } from "./logger.ts"; +import { AutoDiscovery } from "./autodiscovery.ts"; + +/** + * Builds a cache for the well-known configuration of the given client. + */ +export function createWellKnownCachedValue(client: MatrixClient, logger: Logger): PollingCachedValue { + return new PollingCachedValue({ + name: "WellKnown", + logger, + // No expiration by default + ttlMillis: undefined, + fetch: () => AutoDiscovery.getRawClientConfig(client.getDomain() ?? undefined), + onValueCached: (wellKnown) => client.emit(ClientEvent.ClientWellKnown, wellKnown), + }); +}