Compare commits
20 Commits
v37.1.0-rc.0
...
v37.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f9e68c8f3 | |||
| 097d645c19 | |||
| e62aabccd9 | |||
| 0f3bcf3736 | |||
| 8395919f0f | |||
| 6cdc68d19d | |||
| c879258545 | |||
| b14cc82682 | |||
| 9f9be701e7 | |||
| f552370c26 | |||
| 5f3f08071f | |||
| db7e3e3cf3 | |||
| 3e512711d7 | |||
| f636976f15 | |||
| 905df2f7ca | |||
| c1c473e6c6 | |||
| 4835ca61ec | |||
| abdc8b2c2e | |||
| 89fe90d27c | |||
| 86e0eb11cc |
@@ -27,7 +27,7 @@ jobs:
|
||||
steps:
|
||||
# We create the status here and then update it to success/failure in the `report` stage
|
||||
# This provides an easy link to this workflow_run from the PR before Sonarcloud is done.
|
||||
- uses: guibranco/github-status-action-v2@7ca807c2ba3401be532d29a876b93262108099fb
|
||||
- uses: guibranco/github-status-action-v2@5ef6e175c333bc629f3718b083c8a2ff6e0bbfbc
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
state: pending
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
|
||||
- name: "🩻 SonarCloud Scan"
|
||||
id: sonarcloud
|
||||
uses: matrix-org/sonarcloud-workflow-action@v3.3
|
||||
uses: matrix-org/sonarcloud-workflow-action@v4.0
|
||||
# workflow_run fails report against the develop commit always, we don't want that for PRs
|
||||
continue-on-error: ${{ github.event.workflow_run.head_branch != 'develop' }}
|
||||
with:
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
revision: ${{ github.event.workflow_run.head_sha }}
|
||||
token: ${{ secrets.SONAR_TOKEN }}
|
||||
|
||||
- uses: guibranco/github-status-action-v2@7ca807c2ba3401be532d29a876b93262108099fb
|
||||
- uses: guibranco/github-status-action-v2@5ef6e175c333bc629f3718b083c8a2ff6e0bbfbc
|
||||
if: always()
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
steps:
|
||||
- name: Skip SonarCloud on merge queues
|
||||
if: env.ENABLE_COVERAGE == 'false'
|
||||
uses: guibranco/github-status-action-v2@7ca807c2ba3401be532d29a876b93262108099fb
|
||||
uses: guibranco/github-status-action-v2@5ef6e175c333bc629f3718b083c8a2ff6e0bbfbc
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
state: success
|
||||
|
||||
@@ -1,3 +1,36 @@
|
||||
Changes in [37.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.2.0) (2025-03-25)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* Add reportRoom API ([#4753](https://github.com/matrix-org/matrix-js-sdk/pull/4753)). Contributed by @Half-Shot.
|
||||
* MatrixRTC: New membership manager ([#4726](https://github.com/matrix-org/matrix-js-sdk/pull/4726)). Contributed by @toger5.
|
||||
* Add disableKeyStorage() to crypto API ([#4742](https://github.com/matrix-org/matrix-js-sdk/pull/4742)). Contributed by @dbkr.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Allow port differing in OIDC dynamic registration URIs ([#4749](https://github.com/matrix-org/matrix-js-sdk/pull/4749)). Contributed by @t3chguy.
|
||||
* OIDC: only pass logo\_uri, policy\_uri, tos\_uri if they conform to "common base" ([#4748](https://github.com/matrix-org/matrix-js-sdk/pull/4748)). Contributed by @t3chguy.
|
||||
|
||||
|
||||
Changes in [37.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.1.0) (2025-03-11)
|
||||
==================================================================================================
|
||||
## 🦖 Deprecations
|
||||
|
||||
* MatrixRTC: MembershipManager test cases and deprecation of MatrixRTCSession.room ([#4713](https://github.com/matrix-org/matrix-js-sdk/pull/4713)). Contributed by @toger5.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
* Add `EventType.SecretRequest` and `EventType.SecretSend` ([#4728](https://github.com/matrix-org/matrix-js-sdk/pull/4728)). Contributed by @richvdh.
|
||||
* Attest npm package provenance ([#4724](https://github.com/matrix-org/matrix-js-sdk/pull/4724)). Contributed by @t3chguy.
|
||||
* Report backup key import progress on start and improve types ([#4711](https://github.com/matrix-org/matrix-js-sdk/pull/4711)). Contributed by @ajbura.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Handle unexpected token refresh failures gracefully ([#4731](https://github.com/matrix-org/matrix-js-sdk/pull/4731)). Contributed by @t3chguy.
|
||||
* Fix idempotency issue around token refresh ([#4730](https://github.com/matrix-org/matrix-js-sdk/pull/4730)). Contributed by @t3chguy.
|
||||
* Delete the dehydrated device when resetEncryption is called ([#4727](https://github.com/matrix-org/matrix-js-sdk/pull/4727)). Contributed by @uhoreg.
|
||||
|
||||
|
||||
Changes in [37.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.0.0) (2025-02-25)
|
||||
==================================================================================================
|
||||
## 🚨 BREAKING CHANGES
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "37.1.0-rc.0",
|
||||
"version": "37.2.0",
|
||||
"description": "Matrix Client-Server SDK for Javascript",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/matrix-org/matrix-js-sdk"
|
||||
"url": "git+https://github.com/matrix-org/matrix-js-sdk.git"
|
||||
},
|
||||
"keywords": [
|
||||
"matrix-org"
|
||||
@@ -82,7 +82,7 @@
|
||||
"@babel/preset-typescript": "^7.12.7",
|
||||
"@casualbot/jest-sonar-reporter": "2.2.7",
|
||||
"@peculiar/webcrypto": "^1.4.5",
|
||||
"@stylistic/eslint-plugin": "^3.0.0",
|
||||
"@stylistic/eslint-plugin": "^4.0.0",
|
||||
"@types/content-type": "^1.1.5",
|
||||
"@types/debug": "^4.1.7",
|
||||
"@types/jest": "^29.0.0",
|
||||
@@ -122,7 +122,7 @@
|
||||
"ts-node": "^10.9.2",
|
||||
"typedoc": "^0.27.0",
|
||||
"typedoc-plugin-coverage": "^3.0.0",
|
||||
"typedoc-plugin-mdn-links": "^4.0.0",
|
||||
"typedoc-plugin-mdn-links": "^5.0.0",
|
||||
"typedoc-plugin-missing-exports": "^3.0.0",
|
||||
"typescript": "^5.4.2"
|
||||
},
|
||||
|
||||
+20
-20
@@ -49,26 +49,26 @@ const testOIDCToken = {
|
||||
token_type: "Bearer",
|
||||
};
|
||||
class MockWidgetApi extends EventEmitter {
|
||||
public start = jest.fn();
|
||||
public requestCapability = jest.fn();
|
||||
public requestCapabilities = jest.fn();
|
||||
public requestCapabilityForRoomTimeline = jest.fn();
|
||||
public requestCapabilityToSendEvent = jest.fn();
|
||||
public requestCapabilityToReceiveEvent = jest.fn();
|
||||
public requestCapabilityToSendMessage = jest.fn();
|
||||
public requestCapabilityToReceiveMessage = jest.fn();
|
||||
public requestCapabilityToSendState = jest.fn();
|
||||
public requestCapabilityToReceiveState = jest.fn();
|
||||
public requestCapabilityToSendToDevice = jest.fn();
|
||||
public requestCapabilityToReceiveToDevice = jest.fn();
|
||||
public start = jest.fn().mockResolvedValue(undefined);
|
||||
public requestCapability = jest.fn().mockResolvedValue(undefined);
|
||||
public requestCapabilities = jest.fn().mockResolvedValue(undefined);
|
||||
public requestCapabilityForRoomTimeline = jest.fn().mockResolvedValue(undefined);
|
||||
public requestCapabilityToSendEvent = jest.fn().mockResolvedValue(undefined);
|
||||
public requestCapabilityToReceiveEvent = jest.fn().mockResolvedValue(undefined);
|
||||
public requestCapabilityToSendMessage = jest.fn().mockResolvedValue(undefined);
|
||||
public requestCapabilityToReceiveMessage = jest.fn().mockResolvedValue(undefined);
|
||||
public requestCapabilityToSendState = jest.fn().mockResolvedValue(undefined);
|
||||
public requestCapabilityToReceiveState = jest.fn().mockResolvedValue(undefined);
|
||||
public requestCapabilityToSendToDevice = jest.fn().mockResolvedValue(undefined);
|
||||
public requestCapabilityToReceiveToDevice = jest.fn().mockResolvedValue(undefined);
|
||||
public sendRoomEvent = jest.fn(
|
||||
(eventType: string, content: unknown, roomId?: string, delay?: number, parentDelayId?: string) =>
|
||||
async (eventType: string, content: unknown, roomId?: string, delay?: number, parentDelayId?: string) =>
|
||||
delay === undefined && parentDelayId === undefined
|
||||
? { event_id: `$${Math.random()}` }
|
||||
: { delay_id: `id-${Math.random()}` },
|
||||
);
|
||||
public sendStateEvent = jest.fn(
|
||||
(
|
||||
async (
|
||||
eventType: string,
|
||||
stateKey: string,
|
||||
content: unknown,
|
||||
@@ -80,17 +80,17 @@ class MockWidgetApi extends EventEmitter {
|
||||
? { event_id: `$${Math.random()}` }
|
||||
: { delay_id: `id-${Math.random()}` },
|
||||
);
|
||||
public updateDelayedEvent = jest.fn();
|
||||
public sendToDevice = jest.fn();
|
||||
public requestOpenIDConnectToken = jest.fn(() => {
|
||||
public updateDelayedEvent = jest.fn().mockResolvedValue(undefined);
|
||||
public sendToDevice = jest.fn().mockResolvedValue(undefined);
|
||||
public requestOpenIDConnectToken = jest.fn(async () => {
|
||||
return testOIDCToken;
|
||||
return new Promise<IOpenIDCredentials>(() => {
|
||||
return testOIDCToken;
|
||||
});
|
||||
});
|
||||
public readStateEvents = jest.fn(() => []);
|
||||
public getTurnServers = jest.fn(() => []);
|
||||
public sendContentLoaded = jest.fn();
|
||||
public readStateEvents = jest.fn(async () => []);
|
||||
public getTurnServers = jest.fn(async () => []);
|
||||
public sendContentLoaded = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
public transport = {
|
||||
reply: jest.fn(),
|
||||
|
||||
@@ -607,6 +607,26 @@ describe("MatrixClient", function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe("reportRoom", function () {
|
||||
const roomId = "!foo:bar";
|
||||
const reason = "rubbish room";
|
||||
|
||||
it("should send an invite HTTP POST", async function () {
|
||||
httpLookups = [
|
||||
{
|
||||
method: "POST",
|
||||
path: "/rooms/!foo%3Abar/report",
|
||||
data: {},
|
||||
expectBody: {
|
||||
reason,
|
||||
},
|
||||
},
|
||||
];
|
||||
await client.reportRoom(roomId, reason);
|
||||
expect(httpLookups.length).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendEvent", () => {
|
||||
const roomId = "!room:example.org";
|
||||
const body = "This is the body";
|
||||
|
||||
@@ -19,10 +19,11 @@ limitations under the License.
|
||||
|
||||
import { type MockedFunction, type Mock } from "jest-mock";
|
||||
|
||||
import { EventType, HTTPError, MatrixError, type Room } from "../../../src";
|
||||
import { EventType, HTTPError, MatrixError, UnsupportedDelayedEventsEndpointError, type Room } from "../../../src";
|
||||
import { type Focus, type LivekitFocusActive, type SessionMembershipData } from "../../../src/matrixrtc";
|
||||
import { LegacyMembershipManager } from "../../../src/matrixrtc/MembershipManager";
|
||||
import { LegacyMembershipManager } from "../../../src/matrixrtc/LegacyMembershipManager";
|
||||
import { makeMockClient, makeMockRoom, membershipTemplate, mockCallMembership, type MockClient } from "./mocks";
|
||||
import { MembershipManager } from "../../../src/matrixrtc/NewMembershipManager";
|
||||
import { defer } from "../../../src/utils";
|
||||
|
||||
function waitForMockCall(method: MockedFunction<any>, returnVal?: Promise<any>) {
|
||||
@@ -44,9 +45,10 @@ function createAsyncHandle(method: MockedFunction<any>) {
|
||||
* Tests different MembershipManager implementations. Some tests don't apply to `LegacyMembershipManager`
|
||||
* use !FailsForLegacy to skip those. See: testEnvironment for more details.
|
||||
*/
|
||||
|
||||
describe.each([
|
||||
{ TestMembershipManager: LegacyMembershipManager, description: "LegacyMembershipManager" },
|
||||
// { TestMembershipManager: MembershipManager, description: "MembershipManager" },
|
||||
{ TestMembershipManager: MembershipManager, description: "MembershipManager" },
|
||||
])("$description", ({ TestMembershipManager }) => {
|
||||
let client: MockClient;
|
||||
let room: Room;
|
||||
@@ -244,7 +246,12 @@ describe.each([
|
||||
const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock);
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive);
|
||||
delayedHandle.reject?.(Error("Server does not support the delayed events API"));
|
||||
delayedHandle.reject?.(
|
||||
new UnsupportedDelayedEventsEndpointError(
|
||||
"Server does not support the delayed events API",
|
||||
"sendDelayedStateEvent",
|
||||
),
|
||||
);
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("does try to schedule a delayed leave event again if rate limited", async () => {
|
||||
@@ -328,6 +335,7 @@ describe.each([
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue("unknown");
|
||||
await manager.leave();
|
||||
|
||||
// We send a normal leave event since we failed using updateDelayedEvent with the "send" action.
|
||||
expect(client.sendStateEvent).toHaveBeenLastCalledWith(
|
||||
room.roomId,
|
||||
@@ -337,9 +345,9 @@ describe.each([
|
||||
);
|
||||
});
|
||||
// FailsForLegacy because legacy implementation always sends the empty state event even though it isn't needed
|
||||
it("does nothing if not joined !FailsForLegacy", async () => {
|
||||
it("does nothing if not joined !FailsForLegacy", () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
await manager.leave();
|
||||
expect(async () => await manager.leave()).not.toThrow();
|
||||
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
|
||||
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -470,10 +478,10 @@ describe.each([
|
||||
// !FailsForLegacy because the expires logic was removed for the legacy call manager.
|
||||
// Delayed events should replace it entirely but before they have wide adoption
|
||||
// the expiration logic still makes sense.
|
||||
// TODO: add git commit when we removed it.
|
||||
it("extends `expires` when call still active !FailsForLegacy", async () => {
|
||||
// TODO: Add git commit when we removed it.
|
||||
async function testExpires(expire: number, headroom?: number) {
|
||||
const manager = new TestMembershipManager(
|
||||
{ membershipExpiryTimeout: 10_000 },
|
||||
{ membershipExpiryTimeout: expire, membershipExpiryTimeoutHeadroom: headroom },
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
@@ -482,13 +490,19 @@ describe.each([
|
||||
await waitForMockCall(client.sendStateEvent);
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
const sentMembership = (client.sendStateEvent as Mock).mock.calls[0][2] as SessionMembershipData;
|
||||
expect(sentMembership.expires).toBe(10_000);
|
||||
expect(sentMembership.expires).toBe(expire);
|
||||
for (let i = 2; i <= 12; i++) {
|
||||
await jest.advanceTimersByTimeAsync(10_000);
|
||||
await jest.advanceTimersByTimeAsync(expire);
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(i);
|
||||
const sentMembership = (client.sendStateEvent as Mock).mock.lastCall![2] as SessionMembershipData;
|
||||
expect(sentMembership.expires).toBe(10_000 * i);
|
||||
expect(sentMembership.expires).toBe(expire * i);
|
||||
}
|
||||
}
|
||||
it("extends `expires` when call still active !FailsForLegacy", async () => {
|
||||
await testExpires(10_000);
|
||||
});
|
||||
it("extends `expires` using headroom configuration !FailsForLegacy", async () => {
|
||||
await testExpires(10_000, 1_000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -544,7 +558,7 @@ describe.each([
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
// FailsForLegacy as implementation does not re-check membership before retrying.
|
||||
it("abandons retry loop if leave() was called !FailsForLegacy", async () => {
|
||||
it("abandons retry loop if leave() was called before sending state event !FailsForLegacy", async () => {
|
||||
const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent);
|
||||
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
@@ -565,7 +579,6 @@ describe.each([
|
||||
await manager.leave();
|
||||
|
||||
// Wait for all timers to be setup
|
||||
// await flushPromises();
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
|
||||
// No new events should have been sent:
|
||||
@@ -603,4 +616,87 @@ describe.each([
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("unrecoverable errors", () => {
|
||||
// !FailsForLegacy because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors.
|
||||
it("throws, when reaching maximum number of retries for initial delayed event creation !FailsForLegacy", async () => {
|
||||
const delayEventSendError = jest.fn();
|
||||
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(
|
||||
new MatrixError(
|
||||
{ errcode: "M_LIMIT_EXCEEDED" },
|
||||
429,
|
||||
undefined,
|
||||
undefined,
|
||||
new Headers({ "Retry-After": "2" }),
|
||||
),
|
||||
);
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive, delayEventSendError);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await jest.advanceTimersByTimeAsync(2000);
|
||||
}
|
||||
expect(delayEventSendError).toHaveBeenCalled();
|
||||
});
|
||||
// !FailsForLegacy because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors.
|
||||
it("throws, when reaching maximum number of retries !FailsForLegacy", async () => {
|
||||
const delayEventRestartError = jest.fn();
|
||||
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue(
|
||||
new MatrixError(
|
||||
{ errcode: "M_LIMIT_EXCEEDED" },
|
||||
429,
|
||||
undefined,
|
||||
undefined,
|
||||
new Headers({ "Retry-After": "1" }),
|
||||
),
|
||||
);
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive, delayEventRestartError);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
}
|
||||
expect(delayEventRestartError).toHaveBeenCalled();
|
||||
});
|
||||
it("falls back to using pure state events when some error occurs while sending delayed events !FailsForLegacy", async () => {
|
||||
const unrecoverableError = jest.fn();
|
||||
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(new HTTPError("unknown", 601));
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive, unrecoverableError);
|
||||
await waitForMockCall(client.sendStateEvent);
|
||||
expect(unrecoverableError).not.toHaveBeenCalledWith();
|
||||
expect(client.sendStateEvent).toHaveBeenCalled();
|
||||
});
|
||||
it("retries before failing in case its a network error !FailsForLegacy", async () => {
|
||||
const unrecoverableError = jest.fn();
|
||||
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(new HTTPError("unknown", 501));
|
||||
const manager = new TestMembershipManager(
|
||||
{ callMemberEventRetryDelayMinimum: 1000, maximumNetworkErrorRetryCount: 7 },
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
);
|
||||
manager.join([focus], focusActive, unrecoverableError);
|
||||
for (let retries = 0; retries < 7; retries++) {
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(retries + 1);
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
}
|
||||
expect(unrecoverableError).toHaveBeenCalled();
|
||||
expect(unrecoverableError.mock.lastCall![0].message).toMatch(
|
||||
"The MembershipManager shut down because of the end condition",
|
||||
);
|
||||
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
it("falls back to using pure state events when UnsupportedDelayedEventsEndpointError encountered for delayed events !FailsForLegacy", async () => {
|
||||
const unrecoverableError = jest.fn();
|
||||
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(
|
||||
new UnsupportedDelayedEventsEndpointError("not supported", "sendDelayedStateEvent"),
|
||||
);
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive, unrecoverableError);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(unrecoverableError).not.toHaveBeenCalled();
|
||||
expect(client.sendStateEvent).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ export const membershipTemplate: SessionMembershipData = {
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
scope: "m.room",
|
||||
focus_active: { type: "livekit", livekit_service_url: "https://lk.url" },
|
||||
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
|
||||
foci_preferred: [
|
||||
{
|
||||
livekit_alias: "!alias:something.org",
|
||||
|
||||
@@ -29,9 +29,10 @@ describe("registerOidcClient()", () => {
|
||||
redirectUris: [baseUrl],
|
||||
clientName,
|
||||
applicationType: "web",
|
||||
tosUri: "http://tos-uri",
|
||||
policyUri: "http://policy-uri",
|
||||
tosUri: "https://just.testing/tos",
|
||||
policyUri: "https://policy.just.testing",
|
||||
contacts: ["admin@example.com"],
|
||||
logoUri: `${baseUrl}:8443/logo.png`,
|
||||
};
|
||||
const dynamicClientId = "xyz789";
|
||||
|
||||
@@ -67,6 +68,9 @@ describe("registerOidcClient()", () => {
|
||||
id_token_signed_response_alg: "RS256",
|
||||
token_endpoint_auth_method: "none",
|
||||
application_type: "web",
|
||||
tos_uri: "https://just.testing/tos",
|
||||
policy_uri: "https://policy.just.testing",
|
||||
logo_uri: `${baseUrl}:8443/logo.png`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -114,4 +118,24 @@ describe("registerOidcClient()", () => {
|
||||
),
|
||||
).rejects.toThrow(OidcError.DynamicRegistrationNotSupported);
|
||||
});
|
||||
|
||||
it("should filter out invalid URIs", async () => {
|
||||
fetchMockJest.post(delegatedAuthConfig.registration_endpoint!, {
|
||||
status: 200,
|
||||
body: JSON.stringify({ client_id: dynamicClientId }),
|
||||
});
|
||||
expect(
|
||||
await registerOidcClient(delegatedAuthConfig, {
|
||||
...metadata,
|
||||
tosUri: "http://just.testing/tos",
|
||||
policyUri: "https://policy-uri/",
|
||||
}),
|
||||
).toEqual(dynamicClientId);
|
||||
expect(JSON.parse(fetchMockJest.mock.calls[0][1]!.body as string)).not.toEqual(
|
||||
expect.objectContaining({
|
||||
tos_uri: "http://just.testing/tos",
|
||||
policy_uri: "https://policy-uri/",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2318,6 +2318,43 @@ describe("RustCrypto", () => {
|
||||
expect(dehydratedDeviceIsDeleted).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("disableKeyStorage", () => {
|
||||
it("should disable key storage", async () => {
|
||||
const secretStorage = {
|
||||
getDefaultKeyId: jest.fn().mockResolvedValue("bloop"),
|
||||
setDefaultKeyId: jest.fn(),
|
||||
store: jest.fn(),
|
||||
} as unknown as ServerSideSecretStorage;
|
||||
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
|
||||
let backupIsDeleted = false;
|
||||
fetchMock.delete("path:/_matrix/client/v3/room_keys/version/1", () => {
|
||||
backupIsDeleted = true;
|
||||
return {};
|
||||
});
|
||||
|
||||
let dehydratedDeviceIsDeleted = false;
|
||||
fetchMock.delete("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", () => {
|
||||
dehydratedDeviceIsDeleted = true;
|
||||
return { device_id: "ADEVICEID" };
|
||||
});
|
||||
|
||||
const rustCrypto = await makeTestRustCrypto(makeMatrixHttpApi(), undefined, undefined, secretStorage);
|
||||
await rustCrypto.disableKeyStorage();
|
||||
|
||||
expect(secretStorage.store).toHaveBeenCalledWith("m.cross_signing.master", null);
|
||||
expect(secretStorage.store).toHaveBeenCalledWith("m.cross_signing.self_signing", null);
|
||||
expect(secretStorage.store).toHaveBeenCalledWith("m.cross_signing.user_signing", null);
|
||||
expect(secretStorage.store).toHaveBeenCalledWith("m.megolm_backup.v1", null);
|
||||
expect(secretStorage.store).toHaveBeenCalledWith("m.secret_storage.key.bloop", null);
|
||||
expect(secretStorage.setDefaultKeyId).toHaveBeenCalledWith(null);
|
||||
|
||||
expect(backupIsDeleted).toBeTruthy();
|
||||
expect(dehydratedDeviceIsDeleted).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** Build a MatrixHttpApi instance */
|
||||
|
||||
+35
-5
@@ -240,6 +240,7 @@ import {
|
||||
validateAuthMetadataAndKeys,
|
||||
} from "./oidc/index.ts";
|
||||
import { type EmptyObject } from "./@types/common.ts";
|
||||
import { UnsupportedDelayedEventsEndpointError } from "./errors.ts";
|
||||
|
||||
export type Store = IStore;
|
||||
|
||||
@@ -3351,7 +3352,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
txnId?: string,
|
||||
): Promise<SendDelayedEventResponse> {
|
||||
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
|
||||
throw Error("Server does not support the delayed events API");
|
||||
throw new UnsupportedDelayedEventsEndpointError(
|
||||
"Server does not support the delayed events API",
|
||||
"sendDelayedEvent",
|
||||
);
|
||||
}
|
||||
|
||||
this.addThreadRelationIfNeeded(content, threadId, roomId);
|
||||
@@ -3374,7 +3378,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
opts: IRequestOpts = {},
|
||||
): Promise<SendDelayedEventResponse> {
|
||||
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
|
||||
throw Error("Server does not support the delayed events API");
|
||||
throw new UnsupportedDelayedEventsEndpointError(
|
||||
"Server does not support the delayed events API",
|
||||
"sendDelayedStateEvent",
|
||||
);
|
||||
}
|
||||
|
||||
const pathParams = {
|
||||
@@ -3398,7 +3405,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
// eslint-disable-next-line
|
||||
public async _unstable_getDelayedEvents(fromToken?: string): Promise<DelayedEventInfo> {
|
||||
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
|
||||
throw Error("Server does not support the delayed events API");
|
||||
throw new UnsupportedDelayedEventsEndpointError(
|
||||
"Server does not support the delayed events API",
|
||||
"getDelayedEvents",
|
||||
);
|
||||
}
|
||||
|
||||
const queryDict = fromToken ? { from: fromToken } : undefined;
|
||||
@@ -3420,7 +3430,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
requestOptions: IRequestOpts = {},
|
||||
): Promise<EmptyObject> {
|
||||
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
|
||||
throw Error("Server does not support the delayed events API");
|
||||
throw new UnsupportedDelayedEventsEndpointError(
|
||||
"Server does not support the delayed events API",
|
||||
"updateDelayedEvent",
|
||||
);
|
||||
}
|
||||
|
||||
const path = utils.encodeUri("/delayed_events/$delayId", {
|
||||
@@ -8028,7 +8041,24 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches or paginates a room hierarchy as defined by MSC2946.
|
||||
* Reports a room as inappropriate to the server, which may then notify the appropriate people.
|
||||
*
|
||||
* This API was introduced in Matrix v1.13.
|
||||
*
|
||||
* @param roomId - The room being reported.
|
||||
* @param reason - The reason the room is being reported. May be blank.
|
||||
* @returns Promise which resolves to an empty object if successful
|
||||
*/
|
||||
public reportRoom(roomId: string, reason: string): Promise<EmptyObject> {
|
||||
const path = utils.encodeUri("/rooms/$roomId/report", {
|
||||
$roomId: roomId,
|
||||
});
|
||||
|
||||
return this.http.authedRequest(Method.Post, path, undefined, { reason });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches or paginates a room hierarchy asmatrix-js-sdk/spec/unit/matrix-client.spec.ts defined by MSC2946.
|
||||
* Falls back gracefully to sourcing its data from `getSpaceSummary` if this API is not yet supported by the server.
|
||||
* @param roomId - The ID of the space-room to use as the root of the summary.
|
||||
* @param limit - The maximum number of rooms to return per page.
|
||||
|
||||
@@ -629,6 +629,16 @@ export interface CryptoApi {
|
||||
*/
|
||||
resetKeyBackup(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Disables server-side key storage and deletes server-side backups.
|
||||
* * Deletes the current key backup version, if any (but not any previous versions).
|
||||
* * Disables 4S, deleting the info for the default key, the default key pointer itself and any
|
||||
* known 4S data (cross-signing keys and the megolm key backup key).
|
||||
* * Deletes any dehydrated devices.
|
||||
* * Sets the "m.org.matrix.custom.backup_disabled" account data flag to indicate that the user has disabled backups.
|
||||
*/
|
||||
disableKeyStorage(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Deletes the given key backup.
|
||||
*
|
||||
|
||||
+62
-27
@@ -50,12 +50,12 @@ import {
|
||||
} from "./client.ts";
|
||||
import { SyncApi, SyncState } from "./sync.ts";
|
||||
import { SlidingSyncSdk } from "./sliding-sync-sdk.ts";
|
||||
import { MatrixError } from "./http-api/errors.ts";
|
||||
import { ConnectionError, MatrixError } from "./http-api/errors.ts";
|
||||
import { User } from "./models/user.ts";
|
||||
import { type Room } from "./models/room.ts";
|
||||
import { type ToDeviceBatch, type ToDevicePayload } from "./models/ToDeviceMessage.ts";
|
||||
import { MapWithDefault, recursiveMapToObject } from "./utils.ts";
|
||||
import { type EmptyObject, TypedEventEmitter } from "./matrix.ts";
|
||||
import { type EmptyObject, TypedEventEmitter, UnsupportedDelayedEventsEndpointError } from "./matrix.ts";
|
||||
|
||||
interface IStateEventRequest {
|
||||
eventType: string;
|
||||
@@ -358,13 +358,15 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
// Delayed event special case.
|
||||
if (delayOpts) {
|
||||
// TODO: updatePendingEvent for delayed events?
|
||||
const response = await this.widgetApi.sendRoomEvent(
|
||||
event.getType(),
|
||||
content,
|
||||
room.roomId,
|
||||
"delay" in delayOpts ? delayOpts.delay : undefined,
|
||||
"parent_delay_id" in delayOpts ? delayOpts.parent_delay_id : undefined,
|
||||
);
|
||||
const response = await this.widgetApi
|
||||
.sendRoomEvent(
|
||||
event.getType(),
|
||||
content,
|
||||
room.roomId,
|
||||
"delay" in delayOpts ? delayOpts.delay : undefined,
|
||||
"parent_delay_id" in delayOpts ? delayOpts.parent_delay_id : undefined,
|
||||
)
|
||||
.catch(timeoutToConnectionError);
|
||||
return this.validateSendDelayedEventResponse(response);
|
||||
}
|
||||
|
||||
@@ -374,7 +376,9 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
|
||||
let response: ISendEventFromWidgetResponseData;
|
||||
try {
|
||||
response = await this.widgetApi.sendRoomEvent(event.getType(), content, room.roomId);
|
||||
response = await this.widgetApi
|
||||
.sendRoomEvent(event.getType(), content, room.roomId)
|
||||
.catch(timeoutToConnectionError);
|
||||
} catch (e) {
|
||||
this.updatePendingEventStatus(room, event, EventStatus.NOT_SENT);
|
||||
throw e;
|
||||
@@ -397,7 +401,9 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
content: any,
|
||||
stateKey = "",
|
||||
): Promise<ISendEventResponse> {
|
||||
const response = await this.widgetApi.sendStateEvent(eventType, stateKey, content, roomId);
|
||||
const response = await this.widgetApi
|
||||
.sendStateEvent(eventType, stateKey, content, roomId)
|
||||
.catch(timeoutToConnectionError);
|
||||
if (response.event_id === undefined) {
|
||||
throw new Error("'event_id' absent from response to an event request");
|
||||
}
|
||||
@@ -416,17 +422,22 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
stateKey = "",
|
||||
): Promise<SendDelayedEventResponse> {
|
||||
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
|
||||
throw Error("Server does not support the delayed events API");
|
||||
throw new UnsupportedDelayedEventsEndpointError(
|
||||
"Server does not support the delayed events API",
|
||||
"sendDelayedStateEvent",
|
||||
);
|
||||
}
|
||||
|
||||
const response = await this.widgetApi.sendStateEvent(
|
||||
eventType,
|
||||
stateKey,
|
||||
content,
|
||||
roomId,
|
||||
"delay" in delayOpts ? delayOpts.delay : undefined,
|
||||
"parent_delay_id" in delayOpts ? delayOpts.parent_delay_id : undefined,
|
||||
);
|
||||
const response = await this.widgetApi
|
||||
.sendStateEvent(
|
||||
eventType,
|
||||
stateKey,
|
||||
content,
|
||||
roomId,
|
||||
"delay" in delayOpts ? delayOpts.delay : undefined,
|
||||
"parent_delay_id" in delayOpts ? delayOpts.parent_delay_id : undefined,
|
||||
)
|
||||
.catch(timeoutToConnectionError);
|
||||
return this.validateSendDelayedEventResponse(response);
|
||||
}
|
||||
|
||||
@@ -443,20 +454,25 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
// eslint-disable-next-line
|
||||
public async _unstable_updateDelayedEvent(delayId: string, action: UpdateDelayedEventAction): Promise<EmptyObject> {
|
||||
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
|
||||
throw Error("Server does not support the delayed events API");
|
||||
throw new UnsupportedDelayedEventsEndpointError(
|
||||
"Server does not support the delayed events API",
|
||||
"updateDelayedEvent",
|
||||
);
|
||||
}
|
||||
|
||||
await this.widgetApi.updateDelayedEvent(delayId, action);
|
||||
await this.widgetApi.updateDelayedEvent(delayId, action).catch(timeoutToConnectionError);
|
||||
return {};
|
||||
}
|
||||
|
||||
public async sendToDevice(eventType: string, contentMap: SendToDeviceContentMap): Promise<EmptyObject> {
|
||||
await this.widgetApi.sendToDevice(eventType, false, recursiveMapToObject(contentMap));
|
||||
await this.widgetApi
|
||||
.sendToDevice(eventType, false, recursiveMapToObject(contentMap))
|
||||
.catch(timeoutToConnectionError);
|
||||
return {};
|
||||
}
|
||||
|
||||
public async getOpenIdToken(): Promise<IOpenIDToken> {
|
||||
const token = await this.widgetApi.requestOpenIDConnectToken();
|
||||
const token = await this.widgetApi.requestOpenIDConnectToken().catch(timeoutToConnectionError);
|
||||
// the IOpenIDCredentials from the widget-api and IOpenIDToken form the matrix-js-sdk are compatible.
|
||||
// we still recreate the token to make this transparent and catch'able by the linter in case the types change in the future.
|
||||
return <IOpenIDToken>{
|
||||
@@ -474,7 +490,9 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
contentMap.getOrCreate(userId).set(deviceId, payload);
|
||||
}
|
||||
|
||||
await this.widgetApi.sendToDevice(eventType, false, recursiveMapToObject(contentMap));
|
||||
await this.widgetApi
|
||||
.sendToDevice(eventType, false, recursiveMapToObject(contentMap))
|
||||
.catch(timeoutToConnectionError);
|
||||
}
|
||||
|
||||
public async encryptAndSendToDevices(userDeviceInfoArr: OlmDevice[], payload: object): Promise<void> {
|
||||
@@ -484,7 +502,9 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
contentMap.getOrCreate(userId).set(deviceId, payload);
|
||||
}
|
||||
|
||||
await this.widgetApi.sendToDevice((payload as { type: string }).type, true, recursiveMapToObject(contentMap));
|
||||
await this.widgetApi
|
||||
.sendToDevice((payload as { type: string }).type, true, recursiveMapToObject(contentMap))
|
||||
.catch(timeoutToConnectionError);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -505,7 +525,9 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
encrypted: boolean,
|
||||
contentMap: SendToDeviceContentMap,
|
||||
): Promise<void> {
|
||||
await this.widgetApi.sendToDevice(eventType, encrypted, recursiveMapToObject(contentMap));
|
||||
await this.widgetApi
|
||||
.sendToDevice(eventType, encrypted, recursiveMapToObject(contentMap))
|
||||
.catch(timeoutToConnectionError);
|
||||
}
|
||||
|
||||
// Overridden since we get TURN servers automatically over the widget API,
|
||||
@@ -670,3 +692,16 @@ function processAndThrow(error: unknown): never {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This converts an "Request timed out" error from the PostmessageTransport into a ConnectionError.
|
||||
* It either throws the original error or a new ConnectionError.
|
||||
**/
|
||||
function timeoutToConnectionError(error: unknown): never {
|
||||
// TODO: this should not check on error.message but instead it should be a specific type
|
||||
// error instanceof WidgetTimeoutError
|
||||
if (error instanceof Error && error.message === "Request timed out") {
|
||||
throw new ConnectionError("widget api timeout");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -52,3 +52,16 @@ export class ClientStoppedError extends Error {
|
||||
super("MatrixClient has been stopped");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This error is thrown when the Homeserver does not support the delayed events enpdpoints.
|
||||
*/
|
||||
export class UnsupportedDelayedEventsEndpointError extends Error {
|
||||
public constructor(
|
||||
message: string,
|
||||
public clientEndpoint: "sendDelayedEvent" | "updateDelayedEvent" | "sendDelayedStateEvent" | "getDelayedEvents",
|
||||
) {
|
||||
super(message);
|
||||
this.name = "UnsupportedDelayedEventsEndpointError";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,9 +39,9 @@ interface TypedResponse<T> extends Response {
|
||||
json(): Promise<T>;
|
||||
}
|
||||
|
||||
export type ResponseType<T, O extends IHttpOpts> = O extends undefined
|
||||
? T
|
||||
: O extends { onlyData: true }
|
||||
export type ResponseType<T, O extends IHttpOpts> = O extends { json: false }
|
||||
? string
|
||||
: O extends { onlyData: true } | undefined
|
||||
? T
|
||||
: TypedResponse<T>;
|
||||
|
||||
@@ -371,7 +371,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
}
|
||||
|
||||
if (this.opts.onlyData) {
|
||||
return json ? res.json() : res.text();
|
||||
return (json ? res.json() : res.text()) as ResponseType<T, O>;
|
||||
}
|
||||
return res as ResponseType<T, O>;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
/*
|
||||
Copyright 2025 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 { EventType } from "../@types/event.ts";
|
||||
import { UpdateDelayedEventAction } from "../@types/requests.ts";
|
||||
import type { MatrixClient } from "../client.ts";
|
||||
@@ -11,44 +27,7 @@ import { type Focus } from "./focus.ts";
|
||||
import { isLivekitFocusActive } from "./LivekitFocus.ts";
|
||||
import { type MembershipConfig } from "./MatrixRTCSession.ts";
|
||||
import { type EmptyObject } from "../@types/common.ts";
|
||||
/**
|
||||
* This interface defines what a MembershipManager uses and exposes.
|
||||
* This interface is what we use to write tests and allows to change the actual implementation
|
||||
* Without breaking tests because of some internal method renaming.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export interface IMembershipManager {
|
||||
/**
|
||||
* If we are trying to join the session.
|
||||
* It does not reflect if the room state is already configures to represent us being joined.
|
||||
* It only means that the Manager is running.
|
||||
* @returns true if we intend to be participating in the MatrixRTC session
|
||||
*/
|
||||
isJoined(): boolean;
|
||||
/**
|
||||
* Start sending all necessary events to make this user participant in the RTC session.
|
||||
* @param fociPreferred the list of preferred foci to use in the joined RTC membership event.
|
||||
* @param fociActive the active focus to use in the joined RTC membership event.
|
||||
*/
|
||||
join(fociPreferred: Focus[], fociActive?: Focus): void;
|
||||
/**
|
||||
* Send all necessary events to make this user leave the RTC session.
|
||||
* @param timeout the maximum duration in ms until the promise is forced to resolve.
|
||||
* @returns It resolves with true in case the leave was sent successfully.
|
||||
* It resolves with false in case we hit the timeout before sending successfully.
|
||||
*/
|
||||
leave(timeout?: number): Promise<boolean>;
|
||||
/**
|
||||
* Call this if the MatrixRTC session members have changed.
|
||||
*/
|
||||
onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void>;
|
||||
/**
|
||||
* The used active focus in the currently joined session.
|
||||
* @returns the used active focus in the currently joined session or undefined if not joined.
|
||||
*/
|
||||
getActiveFocus(): Focus | undefined;
|
||||
}
|
||||
import { type IMembershipManager } from "./NewMembershipManager.ts";
|
||||
|
||||
/**
|
||||
* This internal class is used by the MatrixRTCSession to manage the local user's own membership of the session.
|
||||
@@ -64,7 +43,8 @@ export interface IMembershipManager {
|
||||
*
|
||||
* It is recommended to only use this interface for testing to allow replacing this class.
|
||||
*
|
||||
* @internal
|
||||
* @internal
|
||||
* @deprecated Use {@link MembershipManager} instead
|
||||
*/
|
||||
export class LegacyMembershipManager implements IMembershipManager {
|
||||
private relativeExpiry: number | undefined;
|
||||
@@ -25,8 +25,10 @@ import { RoomStateEvent } from "../models/room-state.ts";
|
||||
import { type Focus } from "./focus.ts";
|
||||
import { KnownMembership } from "../@types/membership.ts";
|
||||
import { type MatrixEvent } from "../models/event.ts";
|
||||
import { LegacyMembershipManager, type IMembershipManager } from "./MembershipManager.ts";
|
||||
import { MembershipManager, type IMembershipManager } from "./NewMembershipManager.ts";
|
||||
import { EncryptionManager, type IEncryptionManager, type Statistics } from "./EncryptionManager.ts";
|
||||
import { LegacyMembershipManager } from "./LegacyMembershipManager.ts";
|
||||
import { logDurationSync } from "../utils.ts";
|
||||
|
||||
const logger = rootLogger.getChild("MatrixRTCSession");
|
||||
|
||||
@@ -39,6 +41,8 @@ export enum MatrixRTCSessionEvent {
|
||||
JoinStateChanged = "join_state_changed",
|
||||
// The key used to encrypt media has changed
|
||||
EncryptionKeyChanged = "encryption_key_changed",
|
||||
/** The membership manager had to shut down caused by an unrecoverable error */
|
||||
MembershipManagerError = "membership_manager_error",
|
||||
}
|
||||
|
||||
export type MatrixRTCSessionEventHandlerMap = {
|
||||
@@ -52,9 +56,17 @@ export type MatrixRTCSessionEventHandlerMap = {
|
||||
encryptionKeyIndex: number,
|
||||
participantId: string,
|
||||
) => void;
|
||||
[MatrixRTCSessionEvent.MembershipManagerError]: (error: unknown) => void;
|
||||
};
|
||||
|
||||
export interface MembershipConfig {
|
||||
/**
|
||||
* Use the new Manager.
|
||||
*
|
||||
* Default: `false`.
|
||||
*/
|
||||
useNewMembershipManager?: boolean;
|
||||
|
||||
/**
|
||||
* The timeout (in milliseconds) after we joined the call, that our membership should expire
|
||||
* unless we have explicitly updated it.
|
||||
@@ -63,6 +75,17 @@ export interface MembershipConfig {
|
||||
*/
|
||||
membershipExpiryTimeout?: number;
|
||||
|
||||
/**
|
||||
* The time in (in milliseconds) which the manager will prematurely send the updated state event before the membership `expires` time to make sure it
|
||||
* sends the updated state event early enough.
|
||||
*
|
||||
* A headroom of 1000ms and a `membershipExpiryTimeout` of 10000ms would result in the first membership event update after 9s and
|
||||
* a membership event that would be considered expired after 10s.
|
||||
*
|
||||
* This value does not have an effect on the value of `SessionMembershipData.expires`.
|
||||
*/
|
||||
membershipExpiryTimeoutHeadroom?: number;
|
||||
|
||||
/**
|
||||
* The period (in milliseconds) with which we check that our membership event still exists on the
|
||||
* server. If it is not found we create it again.
|
||||
@@ -90,6 +113,16 @@ export interface MembershipConfig {
|
||||
* @deprecated It should be possible to make it stable without this.
|
||||
*/
|
||||
callMemberEventRetryJitter?: number;
|
||||
|
||||
/**
|
||||
* The maximum number of retries that the manager will do for delayed event sending/updating and state event sending when a server rate limit has been hit.
|
||||
*/
|
||||
maximumRateLimitRetryCount?: number;
|
||||
|
||||
/**
|
||||
* The maximum number of retries that the manager will do for delayed event sending/updating and state event sending when a network error occurs.
|
||||
*/
|
||||
maximumNetworkErrorRetryCount?: number;
|
||||
}
|
||||
|
||||
export interface EncryptionConfig {
|
||||
@@ -318,18 +351,28 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
* @param joinConfig - Additional configuration for the joined session.
|
||||
*/
|
||||
public joinRoomSession(fociPreferred: Focus[], fociActive?: Focus, joinConfig?: JoinSessionConfig): void {
|
||||
// create MembershipManager
|
||||
if (this.isJoined()) {
|
||||
logger.info(`Already joined to session in room ${this.roomSubset.roomId}: ignoring join call`);
|
||||
return;
|
||||
} else {
|
||||
this.membershipManager = new LegacyMembershipManager(joinConfig, this.roomSubset, this.client, () =>
|
||||
this.getOldestMembership(),
|
||||
);
|
||||
// Create MembershipManager
|
||||
if (joinConfig?.useNewMembershipManager ?? false) {
|
||||
this.membershipManager = new MembershipManager(joinConfig, this.roomSubset, this.client, () =>
|
||||
this.getOldestMembership(),
|
||||
);
|
||||
} else {
|
||||
this.membershipManager = new LegacyMembershipManager(joinConfig, this.roomSubset, this.client, () =>
|
||||
this.getOldestMembership(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Join!
|
||||
this.membershipManager!.join(fociPreferred, fociActive);
|
||||
this.membershipManager!.join(fociPreferred, fociActive, (e) => {
|
||||
logger.error("MembershipManager encountered an unrecoverable error: ", e);
|
||||
this.emit(MatrixRTCSessionEvent.MembershipManagerError, e);
|
||||
this.emit(MatrixRTCSessionEvent.JoinStateChanged, this.isJoined());
|
||||
});
|
||||
this.encryptionManager!.join(joinConfig);
|
||||
|
||||
this.emit(MatrixRTCSessionEvent.JoinStateChanged, true);
|
||||
@@ -492,7 +535,9 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
|
||||
if (changed) {
|
||||
logger.info(`Memberships for call in room ${this.roomSubset.roomId} have changed: emitting`);
|
||||
this.emit(MatrixRTCSessionEvent.MembershipsChanged, oldMemberships, this.memberships);
|
||||
logDurationSync(logger, "emit MatrixRTCSessionEvent.MembershipsChanged", () => {
|
||||
this.emit(MatrixRTCSessionEvent.MembershipsChanged, oldMemberships, this.memberships);
|
||||
});
|
||||
|
||||
void this.membershipManager?.onRTCSessionMemberUpdate(this.memberships);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,943 @@
|
||||
/*
|
||||
Copyright 2025 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 { EventType } from "../@types/event.ts";
|
||||
import { UpdateDelayedEventAction } from "../@types/requests.ts";
|
||||
import { type MatrixClient } from "../client.ts";
|
||||
import { UnsupportedDelayedEventsEndpointError } from "../errors.ts";
|
||||
import { ConnectionError, HTTPError, MatrixError } from "../http-api/errors.ts";
|
||||
import { logger as rootLogger } from "../logger.ts";
|
||||
import { type Room } from "../models/room.ts";
|
||||
import { defer, type IDeferred } from "../utils.ts";
|
||||
import { type CallMembership, DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "./CallMembership.ts";
|
||||
import { type Focus } from "./focus.ts";
|
||||
import { isLivekitFocusActive } from "./LivekitFocus.ts";
|
||||
import { type MembershipConfig } from "./MatrixRTCSession.ts";
|
||||
import { ActionScheduler, type ActionUpdate } from "./NewMembershipManagerActionScheduler.ts";
|
||||
|
||||
const logger = rootLogger.getChild("MatrixRTCSession");
|
||||
|
||||
/**
|
||||
* This interface defines what a MembershipManager uses and exposes.
|
||||
* This interface is what we use to write tests and allows changing the actual implementation
|
||||
* without breaking tests because of some internal method renaming.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export interface IMembershipManager {
|
||||
/**
|
||||
* If we are trying to join, or have successfully joined the session.
|
||||
* It does not reflect if the room state is already configured to represent us being joined.
|
||||
* It only means that the Manager should be trying to connect or to disconnect running.
|
||||
* The Manager is still running right after isJoined becomes false to send the disconnect events.
|
||||
* (A more accurate name would be `isActivated`)
|
||||
* @returns true if we intend to be participating in the MatrixRTC session
|
||||
*/
|
||||
isJoined(): boolean;
|
||||
/**
|
||||
* Start sending all necessary events to make this user participate in the RTC session.
|
||||
* @param fociPreferred the list of preferred foci to use in the joined RTC membership event.
|
||||
* @param fociActive the active focus to use in the joined RTC membership event.
|
||||
* @throws can throw if it exceeds a configured maximum retry.
|
||||
*/
|
||||
join(fociPreferred: Focus[], fociActive?: Focus, onError?: (error: unknown) => void): void;
|
||||
/**
|
||||
* Send all necessary events to make this user leave the RTC session.
|
||||
* @param timeout the maximum duration in ms until the promise is forced to resolve.
|
||||
* @returns It resolves with true in case the leave was sent successfully.
|
||||
* It resolves with false in case we hit the timeout before sending successfully.
|
||||
*/
|
||||
leave(timeout?: number): Promise<boolean>;
|
||||
/**
|
||||
* Call this if the MatrixRTC session members have changed.
|
||||
*/
|
||||
onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void>;
|
||||
/**
|
||||
* The used active focus in the currently joined session.
|
||||
* @returns the used active focus in the currently joined session or undefined if not joined.
|
||||
*/
|
||||
getActiveFocus(): Focus | undefined;
|
||||
}
|
||||
|
||||
/* MembershipActionTypes:
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│SendFirstDelayedEvent│
|
||||
└─────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
┌────────────│SendJoinEvent│────────────┐
|
||||
│ └─────────────┘ │
|
||||
│ ┌─────┐ ┌──────┐ │ ┌──────┐
|
||||
▼ ▼ │ │ ▼ ▼ ▼ │
|
||||
┌────────────┐ │ │ ┌───────────────────┐ │
|
||||
│UpdateExpiry│ │ │ │RestartDelayedEvent│ │
|
||||
└────────────┘ │ │ └───────────────────┘ │
|
||||
│ │ │ │ │ │
|
||||
└─────┘ └──────┘ │ │
|
||||
│ │
|
||||
┌────────────────────┐ │ │
|
||||
│SendMainDelayedEvent│◄───────┘ │
|
||||
└───────────────────┬┘ │
|
||||
│ │
|
||||
└─────────────────────┘
|
||||
STOP ALL ABOVE
|
||||
▼
|
||||
┌───────────────────────────────┐
|
||||
│ SendScheduledDelayedLeaveEvent│
|
||||
└───────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
│SendLeaveEvent│
|
||||
└──────────────┘
|
||||
|
||||
*/
|
||||
/**
|
||||
* The different types of actions the MembershipManager can take.
|
||||
* @internal
|
||||
*/
|
||||
export enum MembershipActionType {
|
||||
SendFirstDelayedEvent = "SendFirstDelayedEvent",
|
||||
// -> MembershipActionType.SendJoinEvent if successful
|
||||
// -> DelayedLeaveActionType.SendFirstDelayedEvent on error, retry sending the first delayed event.
|
||||
SendJoinEvent = "SendJoinEvent",
|
||||
// -> MembershipActionType.SendJoinEvent if we run into a rate limit and need to retry
|
||||
// -> MembershipActionType.Update if we successfully send the join event then schedule the expire event update
|
||||
// -> DelayedLeaveActionType.RestartDelayedEvent to recheck the delayed event
|
||||
RestartDelayedEvent = "RestartDelayedEvent",
|
||||
// -> DelayedLeaveActionType.SendMainDelayedEvent on missing delay id but there is a rtc state event
|
||||
// -> DelayedLeaveActionType.SendFirstDelayedEvent on missing delay id and there is no state event
|
||||
// -> DelayedLeaveActionType.RestartDelayedEvent on success we schedule the next restart
|
||||
UpdateExpiry = "UpdateExpiry",
|
||||
// -> MembershipActionType.Update if the timeout has passed so the next update is required.
|
||||
SendMainDelayedEvent = "SendMainDelayedEvent",
|
||||
// -> DelayedLeaveActionType.RestartDelayedEvent on success start updating the delayed event
|
||||
// -> DelayedLeaveActionType.SendMainDelayedEvent on error try again
|
||||
SendScheduledDelayedLeaveEvent = "SendScheduledDelayedLeaveEvent",
|
||||
// -> MembershipActionType.SendLeaveEvent on failiour (not found) we need to send the leave manually and cannot use the scheduled delayed event
|
||||
// -> DelayedLeaveActionType.SendScheduledDelayedLeaveEvent on error we try again.
|
||||
SendLeaveEvent = "SendLeaveEvent",
|
||||
// -> MembershipActionType.SendLeaveEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export interface ActionSchedulerState {
|
||||
/** The delayId we got when successfully sending the delayed leave event.
|
||||
* Gets set to undefined if the server claims it cannot find the delayed event anymore. */
|
||||
delayId?: string;
|
||||
/** Stores how often we have update the `expires` field.
|
||||
* `expireUpdateIterations` * `membershipEventExpiryTimeout` resolves to the value the expires field should contain next */
|
||||
expireUpdateIterations: number;
|
||||
/** The time at which we send the first state event. The time the call started from the DAG point of view.
|
||||
* This is used to compute the local sleep timestamps when to next update the member event with a new expires value. */
|
||||
startTime: number;
|
||||
/** The manager is in the state where its actually connected to the session. */
|
||||
hasMemberStateEvent: boolean;
|
||||
// There can be multiple retries at once so we need to store counters per action
|
||||
// e.g. the send update membership and the restart delayed could be rate limited at the same time.
|
||||
/** Retry counter for rate limits */
|
||||
rateLimitRetries: Map<MembershipActionType, number>;
|
||||
/** Retry counter for other errors */
|
||||
networkErrorRetries: Map<MembershipActionType, number>;
|
||||
}
|
||||
|
||||
enum Status {
|
||||
Disconnected = "Disconnected",
|
||||
Connecting = "Connecting",
|
||||
ConnectingFailed = "ConnectingFailed",
|
||||
Connected = "Connected",
|
||||
Reconnecting = "Reconnecting",
|
||||
Disconnecting = "Disconnecting",
|
||||
Stuck = "Stuck",
|
||||
Unknown = "Unknown",
|
||||
}
|
||||
|
||||
/**
|
||||
* This class is responsible for sending all events relating to the own membership of a matrixRTC call.
|
||||
* It has the following tasks:
|
||||
* - Send the users leave delayed event before sending the membership
|
||||
* - Send the users membership if the state machine is started
|
||||
* - Check if the delayed event was canceled due to sending the membership
|
||||
* - update the delayed event (`restart`)
|
||||
* - Update the state event every ~5h = `DEFAULT_EXPIRE_DURATION` (so it does not get treated as expired)
|
||||
* - When the state machine is stopped:
|
||||
* - Disconnect the member
|
||||
* - Stop the timer for the delay refresh
|
||||
* - Stop the timer for updating the state event
|
||||
*/
|
||||
export class MembershipManager implements IMembershipManager {
|
||||
private activated = false;
|
||||
public isJoined(): boolean {
|
||||
return this.activated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the MembershipManager in a state where it tries to be joined.
|
||||
* It will send delayed events and membership events
|
||||
* @param fociPreferred
|
||||
* @param focusActive
|
||||
* @param onError This will be called once the membership manager encounters an unrecoverable error.
|
||||
* This should bubble up the the frontend to communicate that the call does not work in the current environment.
|
||||
*/
|
||||
public join(fociPreferred: Focus[], focusActive?: Focus, onError?: (error: unknown) => void): void {
|
||||
if (this.scheduler.running) {
|
||||
logger.error("MembershipManager is already running. Ignoring join request.");
|
||||
return;
|
||||
}
|
||||
this.fociPreferred = fociPreferred;
|
||||
this.focusActive = focusActive;
|
||||
this.leavePromiseDefer = undefined;
|
||||
this.activated = true;
|
||||
|
||||
this.state = MembershipManager.defaultState;
|
||||
|
||||
this.scheduler
|
||||
.startWithJoin()
|
||||
.then(() => {
|
||||
if (!this.scheduler.running) {
|
||||
this.leavePromiseDefer?.resolve(true);
|
||||
this.leavePromiseDefer = undefined;
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error("MembershipManager stopped because: ", e);
|
||||
onError?.(e);
|
||||
})
|
||||
// Should already be set to false when calling `leave` in non error cases.
|
||||
.finally(() => (this.activated = false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave from the call (Send an rtc session event with content: `{}`)
|
||||
* @param timeout the maximum duration this promise will take to resolve
|
||||
* @returns true if it managed to leave and false if the timeout condition happened.
|
||||
*/
|
||||
public leave(timeout?: number): Promise<boolean> {
|
||||
if (!this.scheduler.running) {
|
||||
logger.warn("Called MembershipManager.leave() even though the MembershipManager is not running");
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
// We use the promise to track if we already scheduled a leave event
|
||||
// So we do not check scheduler.actions/scheduler.insertions
|
||||
if (!this.leavePromiseDefer) {
|
||||
// reset scheduled actions so we will not do any new actions.
|
||||
this.leavePromiseDefer = defer<boolean>();
|
||||
this.activated = false;
|
||||
this.scheduler.initiateLeave();
|
||||
if (timeout) setTimeout(() => this.leavePromiseDefer?.resolve(false), timeout);
|
||||
}
|
||||
return this.leavePromiseDefer.promise;
|
||||
}
|
||||
private leavePromiseDefer?: IDeferred<boolean>;
|
||||
|
||||
public async onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void> {
|
||||
const isMyMembership = (m: CallMembership): boolean =>
|
||||
m.sender === this.client.getUserId() && m.deviceId === this.client.getDeviceId();
|
||||
|
||||
if (this.isJoined() && !memberships.some(isMyMembership)) {
|
||||
// If one of these actions are scheduled or are getting inserted in the next iteration, we should already
|
||||
// take care of our missing membership.
|
||||
const sendingMembershipActions = [
|
||||
MembershipActionType.SendFirstDelayedEvent,
|
||||
MembershipActionType.SendJoinEvent,
|
||||
];
|
||||
logger.warn("Missing own membership: force re-join");
|
||||
if (this.scheduler.actions.find((a) => sendingMembershipActions.includes(a.type as MembershipActionType))) {
|
||||
logger.error(
|
||||
"NewMembershipManger tried adding another `SendFirstDelayedEvent` actions even though we already have one in the Queue\nActionQueueOnMemberUpdate:",
|
||||
this.scheduler.actions,
|
||||
);
|
||||
} else {
|
||||
// Only react to our own membership missing if we have not already scheduled sending a new membership DirectMembershipManagerAction.Join
|
||||
this.state.hasMemberStateEvent = false;
|
||||
this.scheduler.initiateJoin();
|
||||
}
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public getActiveFocus(): Focus | undefined {
|
||||
if (this.focusActive) {
|
||||
// A livekit active focus
|
||||
if (isLivekitFocusActive(this.focusActive)) {
|
||||
if (this.focusActive.focus_selection === "oldest_membership") {
|
||||
const oldestMembership = this.getOldestMembership();
|
||||
return oldestMembership?.getPreferredFoci()[0];
|
||||
}
|
||||
} else {
|
||||
logger.warn("Unknown own ActiveFocus type. This makes it impossible to connect to an SFU.");
|
||||
}
|
||||
} else {
|
||||
// We do not understand the membership format (could be legacy). We default to oldestMembership
|
||||
// Once there are other methods this is a hard error!
|
||||
const oldestMembership = this.getOldestMembership();
|
||||
return oldestMembership?.getPreferredFoci()[0];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws if the client does not return user or device id.
|
||||
* @param joinConfig
|
||||
* @param room
|
||||
* @param client
|
||||
* @param getOldestMembership
|
||||
*/
|
||||
public constructor(
|
||||
private joinConfig: MembershipConfig | undefined,
|
||||
private room: Pick<Room, "getLiveTimeline" | "roomId" | "getVersion">,
|
||||
private client: Pick<
|
||||
MatrixClient,
|
||||
| "getUserId"
|
||||
| "getDeviceId"
|
||||
| "sendStateEvent"
|
||||
| "_unstable_sendDelayedStateEvent"
|
||||
| "_unstable_updateDelayedEvent"
|
||||
>,
|
||||
private getOldestMembership: () => CallMembership | undefined,
|
||||
) {
|
||||
const [userId, deviceId] = [this.client.getUserId(), this.client.getDeviceId()];
|
||||
if (userId === null) throw Error("Missing userId in client");
|
||||
if (deviceId === null) throw Error("Missing deviceId in client");
|
||||
this.deviceId = deviceId;
|
||||
this.stateKey = this.makeMembershipStateKey(userId, deviceId);
|
||||
this.state = MembershipManager.defaultState;
|
||||
}
|
||||
|
||||
// MembershipManager mutable state.
|
||||
private state: ActionSchedulerState;
|
||||
private static get defaultState(): ActionSchedulerState {
|
||||
return {
|
||||
hasMemberStateEvent: false,
|
||||
delayId: undefined,
|
||||
|
||||
startTime: 0,
|
||||
rateLimitRetries: new Map(),
|
||||
networkErrorRetries: new Map(),
|
||||
expireUpdateIterations: 1,
|
||||
};
|
||||
}
|
||||
// Membership Event static parameters:
|
||||
private deviceId: string;
|
||||
private stateKey: string;
|
||||
private fociPreferred?: Focus[];
|
||||
private focusActive?: Focus;
|
||||
|
||||
// Config:
|
||||
private membershipServerSideExpiryTimeoutOverride?: number;
|
||||
|
||||
private get callMemberEventRetryDelayMinimum(): number {
|
||||
return this.joinConfig?.callMemberEventRetryDelayMinimum ?? 3_000;
|
||||
}
|
||||
private get membershipEventExpiryTimeout(): number {
|
||||
return this.joinConfig?.membershipExpiryTimeout ?? DEFAULT_EXPIRE_DURATION;
|
||||
}
|
||||
private get membershipEventExpiryTimeoutHeadroom(): number {
|
||||
return this.joinConfig?.membershipExpiryTimeoutHeadroom ?? 5_000;
|
||||
}
|
||||
private computeNextExpiryActionTs(iteration: number): number {
|
||||
return (
|
||||
this.state.startTime +
|
||||
this.membershipEventExpiryTimeout * iteration -
|
||||
this.membershipEventExpiryTimeoutHeadroom
|
||||
);
|
||||
}
|
||||
private get membershipServerSideExpiryTimeout(): number {
|
||||
return (
|
||||
this.membershipServerSideExpiryTimeoutOverride ??
|
||||
this.joinConfig?.membershipServerSideExpiryTimeout ??
|
||||
8_000
|
||||
);
|
||||
}
|
||||
private get membershipKeepAlivePeriod(): number {
|
||||
return this.joinConfig?.membershipKeepAlivePeriod ?? 5_000;
|
||||
}
|
||||
private get maximumRateLimitRetryCount(): number {
|
||||
return this.joinConfig?.maximumRateLimitRetryCount ?? 10;
|
||||
}
|
||||
private get maximumNetworkErrorRetryCount(): number {
|
||||
return this.joinConfig?.maximumNetworkErrorRetryCount ?? 10;
|
||||
}
|
||||
|
||||
// Scheduler:
|
||||
private oldStatus?: Status;
|
||||
private scheduler = new ActionScheduler((type): Promise<ActionUpdate> => {
|
||||
if (this.oldStatus) {
|
||||
// we put this at the beginning of the actions scheduler loop handle callback since it is a loop this
|
||||
// is equivalent to running it at the end of the loop. (just after applying the status/action list changes)
|
||||
logger.debug(`MembershipManager applied action changes. Status: ${this.oldStatus} -> ${this.status}`);
|
||||
}
|
||||
this.oldStatus = this.status;
|
||||
logger.debug(`MembershipManager before processing action. status=${this.oldStatus}`);
|
||||
return this.membershipLoopHandler(type);
|
||||
});
|
||||
|
||||
// LOOP HANDLER:
|
||||
private async membershipLoopHandler(type: MembershipActionType): Promise<ActionUpdate> {
|
||||
this.oldStatus = this.status;
|
||||
switch (type) {
|
||||
case MembershipActionType.SendFirstDelayedEvent: {
|
||||
// Before we start we check if we come from a state where we have a delay id.
|
||||
if (!this.state.delayId) {
|
||||
return this.sendFirstDelayedLeaveEvent(); // Normal case without any previous delayed id.
|
||||
} else {
|
||||
// This can happen if someone else (or another client) removes our own membership event.
|
||||
// It will trigger `onRTCSessionMemberUpdate` queue `MembershipActionType.SendFirstDelayedEvent`.
|
||||
// We might still have our delayed event from the previous participation and dependent on the server this might not
|
||||
// get automatically removed if the state changes. Hence It would remove our membership unexpectedly shortly after the rejoin.
|
||||
//
|
||||
// In this block we will try to cancel this delayed event before setting up a new one.
|
||||
|
||||
return this.cancelKnownDelayIdBeforeSendFirstDelayedEvent(this.state.delayId);
|
||||
}
|
||||
}
|
||||
case MembershipActionType.RestartDelayedEvent: {
|
||||
if (!this.state.delayId) {
|
||||
// Delay id got reset. This action was used to check if the hs canceled the delayed event when the join state got sent.
|
||||
return createInsertActionUpdate(
|
||||
this.state.hasMemberStateEvent
|
||||
? MembershipActionType.SendMainDelayedEvent
|
||||
: MembershipActionType.SendFirstDelayedEvent,
|
||||
);
|
||||
}
|
||||
return this.restartDelayedEvent(this.state.delayId);
|
||||
}
|
||||
case MembershipActionType.SendMainDelayedEvent: {
|
||||
return this.sendMainDelayedEvent();
|
||||
}
|
||||
case MembershipActionType.SendScheduledDelayedLeaveEvent: {
|
||||
// We are already good
|
||||
if (!this.state.hasMemberStateEvent) {
|
||||
return { replace: [] };
|
||||
}
|
||||
if (this.state.delayId) {
|
||||
return this.sendScheduledDelayedLeaveEventOrFallbackToSendLeaveEvent(this.state.delayId);
|
||||
} else {
|
||||
return createInsertActionUpdate(MembershipActionType.SendLeaveEvent);
|
||||
}
|
||||
}
|
||||
case MembershipActionType.SendJoinEvent: {
|
||||
return this.sendJoinEvent();
|
||||
}
|
||||
case MembershipActionType.UpdateExpiry: {
|
||||
return this.updateExpiryOnJoinedEvent();
|
||||
}
|
||||
case MembershipActionType.SendLeaveEvent: {
|
||||
// We are good already
|
||||
if (!this.state.hasMemberStateEvent) {
|
||||
return { replace: [] };
|
||||
}
|
||||
// This is only a fallback in case we do not have working delayed events support.
|
||||
// first we should try to just send the scheduled leave event
|
||||
return this.sendFallbackLeaveEvent();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HANDLERS (used in the membershipLoopHandler)
|
||||
private async sendFirstDelayedLeaveEvent(): Promise<ActionUpdate> {
|
||||
return await this.client
|
||||
._unstable_sendDelayedStateEvent(
|
||||
this.room.roomId,
|
||||
{
|
||||
delay: this.membershipServerSideExpiryTimeout,
|
||||
},
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{}, // leave event
|
||||
this.stateKey,
|
||||
)
|
||||
.then((response) => {
|
||||
// On success we reset retries and set delayId.
|
||||
this.state.rateLimitRetries.set(MembershipActionType.SendFirstDelayedEvent, 0);
|
||||
this.state.networkErrorRetries.set(MembershipActionType.SendFirstDelayedEvent, 0);
|
||||
this.state.delayId = response.delay_id;
|
||||
return createInsertActionUpdate(MembershipActionType.SendJoinEvent);
|
||||
})
|
||||
.catch((e) => {
|
||||
const repeatActionType = MembershipActionType.SendFirstDelayedEvent;
|
||||
if (this.manageMaxDelayExceededSituation(e)) {
|
||||
return createInsertActionUpdate(repeatActionType);
|
||||
}
|
||||
const update = this.actionUpdateFromErrors(e, repeatActionType, "sendDelayedStateEvent");
|
||||
if (update) return update;
|
||||
|
||||
// log and fall through
|
||||
if (this.isUnsupportedDelayedEndpoint(e)) {
|
||||
logger.info("Not using delayed event because the endpoint is not supported");
|
||||
} else {
|
||||
logger.info("Not using delayed event because: " + e);
|
||||
}
|
||||
// On any other error we fall back to not using delayed events and send the join state event immediately
|
||||
return createInsertActionUpdate(MembershipActionType.SendJoinEvent);
|
||||
});
|
||||
}
|
||||
|
||||
private async cancelKnownDelayIdBeforeSendFirstDelayedEvent(delayId: string): Promise<ActionUpdate> {
|
||||
// Remove all running updates and restarts
|
||||
return await this.client
|
||||
._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Cancel)
|
||||
.then(() => {
|
||||
this.state.delayId = undefined;
|
||||
this.resetRateLimitCounter(MembershipActionType.SendFirstDelayedEvent);
|
||||
return createReplaceActionUpdate(MembershipActionType.SendFirstDelayedEvent);
|
||||
})
|
||||
.catch((e) => {
|
||||
const repeatActionType = MembershipActionType.SendFirstDelayedEvent;
|
||||
const update = this.actionUpdateFromErrors(e, repeatActionType, "updateDelayedEvent");
|
||||
if (update) return update;
|
||||
|
||||
if (this.isNotFoundError(e)) {
|
||||
// If we get a M_NOT_FOUND we know that the delayed event got already removed.
|
||||
// This means we are good and can set it to undefined and run this again.
|
||||
this.state.delayId = undefined;
|
||||
return createReplaceActionUpdate(repeatActionType);
|
||||
}
|
||||
if (this.isUnsupportedDelayedEndpoint(e)) {
|
||||
return createReplaceActionUpdate(MembershipActionType.SendJoinEvent);
|
||||
}
|
||||
// We do not just ignore and log this error since we would also need to reset the delayId.
|
||||
|
||||
// This becomes an unrecoverable error case since something is significantly off if we don't hit any of the above cases
|
||||
// when state.delayId !== undefined
|
||||
// We do not just ignore and log this error since we would also need to reset the delayId.
|
||||
// It is cleaner if we, the frontend, rejoins instead of resetting the delayId here and behaving like in the success case.
|
||||
throw Error(
|
||||
"We failed to cancel a delayed event where we already had a delay id with an error we cannot automatically handle",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async restartDelayedEvent(delayId: string): Promise<ActionUpdate> {
|
||||
return await this.client
|
||||
._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Restart)
|
||||
.then(() => {
|
||||
this.resetRateLimitCounter(MembershipActionType.RestartDelayedEvent);
|
||||
return createInsertActionUpdate(
|
||||
MembershipActionType.RestartDelayedEvent,
|
||||
this.membershipKeepAlivePeriod,
|
||||
);
|
||||
})
|
||||
.catch((e) => {
|
||||
const repeatActionType = MembershipActionType.RestartDelayedEvent;
|
||||
if (this.isNotFoundError(e)) {
|
||||
this.state.delayId = undefined;
|
||||
return createInsertActionUpdate(MembershipActionType.SendMainDelayedEvent);
|
||||
}
|
||||
// If the HS does not support delayed events we wont reschedule.
|
||||
if (this.isUnsupportedDelayedEndpoint(e)) return {};
|
||||
|
||||
// TODO this also needs a test: get rate limit while checking id delayed event is scheduled
|
||||
const update = this.actionUpdateFromErrors(e, repeatActionType, "updateDelayedEvent");
|
||||
if (update) return update;
|
||||
|
||||
// In other error cases we have no idea what is happening
|
||||
throw Error("Could not restart delayed event, even though delayed events are supported. " + e);
|
||||
});
|
||||
}
|
||||
|
||||
private async sendMainDelayedEvent(): Promise<ActionUpdate> {
|
||||
return await this.client
|
||||
._unstable_sendDelayedStateEvent(
|
||||
this.room.roomId,
|
||||
{
|
||||
delay: this.membershipServerSideExpiryTimeout,
|
||||
},
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{}, // leave event
|
||||
this.stateKey,
|
||||
)
|
||||
.then((response) => {
|
||||
this.state.delayId = response.delay_id;
|
||||
this.resetRateLimitCounter(MembershipActionType.SendMainDelayedEvent);
|
||||
return createInsertActionUpdate(
|
||||
MembershipActionType.RestartDelayedEvent,
|
||||
this.membershipKeepAlivePeriod,
|
||||
);
|
||||
})
|
||||
.catch((e) => {
|
||||
const repeatActionType = MembershipActionType.SendMainDelayedEvent;
|
||||
// Don't do any other delayed event work if its not supported.
|
||||
if (this.isUnsupportedDelayedEndpoint(e)) return {};
|
||||
|
||||
if (this.manageMaxDelayExceededSituation(e)) {
|
||||
return createInsertActionUpdate(repeatActionType);
|
||||
}
|
||||
const update = this.actionUpdateFromErrors(e, repeatActionType, "updateDelayedEvent");
|
||||
if (update) return update;
|
||||
|
||||
throw Error("Could not send delayed event, even though delayed events are supported. " + e);
|
||||
});
|
||||
}
|
||||
|
||||
private async sendScheduledDelayedLeaveEventOrFallbackToSendLeaveEvent(delayId: string): Promise<ActionUpdate> {
|
||||
return await this.client
|
||||
._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Send)
|
||||
.then(() => {
|
||||
this.state.hasMemberStateEvent = false;
|
||||
this.resetRateLimitCounter(MembershipActionType.SendScheduledDelayedLeaveEvent);
|
||||
|
||||
return { replace: [] };
|
||||
})
|
||||
.catch((e) => {
|
||||
const repeatActionType = MembershipActionType.SendLeaveEvent;
|
||||
if (this.isUnsupportedDelayedEndpoint(e)) return {};
|
||||
if (this.isNotFoundError(e)) {
|
||||
this.state.delayId = undefined;
|
||||
return createInsertActionUpdate(repeatActionType);
|
||||
}
|
||||
const update = this.actionUpdateFromErrors(e, repeatActionType, "updateDelayedEvent");
|
||||
if (update) return update;
|
||||
|
||||
// On any other error we fall back to SendLeaveEvent (this includes hard errors from rate limiting)
|
||||
logger.warn(
|
||||
"Encountered unexpected error during SendScheduledDelayedLeaveEvent. Falling back to SendLeaveEvent",
|
||||
e,
|
||||
);
|
||||
return createInsertActionUpdate(repeatActionType);
|
||||
});
|
||||
}
|
||||
|
||||
private async sendJoinEvent(): Promise<ActionUpdate> {
|
||||
return await this.client
|
||||
.sendStateEvent(
|
||||
this.room.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
this.makeMyMembership(this.membershipEventExpiryTimeout),
|
||||
this.stateKey,
|
||||
)
|
||||
.then(() => {
|
||||
this.state.startTime = Date.now();
|
||||
// The next update should already use twice the membershipEventExpiryTimeout
|
||||
this.state.expireUpdateIterations = 1;
|
||||
this.state.hasMemberStateEvent = true;
|
||||
this.resetRateLimitCounter(MembershipActionType.SendJoinEvent);
|
||||
return {
|
||||
insert: [
|
||||
{ ts: Date.now(), type: MembershipActionType.RestartDelayedEvent },
|
||||
{
|
||||
ts: this.computeNextExpiryActionTs(this.state.expireUpdateIterations),
|
||||
type: MembershipActionType.UpdateExpiry,
|
||||
},
|
||||
],
|
||||
};
|
||||
})
|
||||
.catch((e) => {
|
||||
const update = this.actionUpdateFromErrors(e, MembershipActionType.SendJoinEvent, "sendStateEvent");
|
||||
if (update) return update;
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
|
||||
private async updateExpiryOnJoinedEvent(): Promise<ActionUpdate> {
|
||||
const nextExpireUpdateIteration = this.state.expireUpdateIterations + 1;
|
||||
return await this.client
|
||||
.sendStateEvent(
|
||||
this.room.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
this.makeMyMembership(this.membershipEventExpiryTimeout * nextExpireUpdateIteration),
|
||||
this.stateKey,
|
||||
)
|
||||
.then(() => {
|
||||
// Success, we reset retries and schedule update.
|
||||
this.resetRateLimitCounter(MembershipActionType.UpdateExpiry);
|
||||
this.state.expireUpdateIterations = nextExpireUpdateIteration;
|
||||
return {
|
||||
insert: [
|
||||
{
|
||||
ts: this.computeNextExpiryActionTs(nextExpireUpdateIteration),
|
||||
type: MembershipActionType.UpdateExpiry,
|
||||
},
|
||||
],
|
||||
};
|
||||
})
|
||||
.catch((e) => {
|
||||
const update = this.actionUpdateFromErrors(e, MembershipActionType.UpdateExpiry, "sendStateEvent");
|
||||
if (update) return update;
|
||||
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
private async sendFallbackLeaveEvent(): Promise<ActionUpdate> {
|
||||
return await this.client
|
||||
.sendStateEvent(this.room.roomId, EventType.GroupCallMemberPrefix, {}, this.stateKey)
|
||||
.then(() => {
|
||||
this.resetRateLimitCounter(MembershipActionType.SendLeaveEvent);
|
||||
this.state.hasMemberStateEvent = false;
|
||||
return { replace: [] };
|
||||
})
|
||||
.catch((e) => {
|
||||
const update = this.actionUpdateFromErrors(e, MembershipActionType.SendLeaveEvent, "sendStateEvent");
|
||||
if (update) return update;
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
|
||||
// HELPERS
|
||||
private makeMembershipStateKey(localUserId: string, localDeviceId: string): string {
|
||||
const stateKey = `${localUserId}_${localDeviceId}`;
|
||||
if (/^org\.matrix\.msc(3757|3779)\b/.exec(this.room.getVersion())) {
|
||||
return stateKey;
|
||||
} else {
|
||||
return `_${stateKey}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs our own membership
|
||||
*/
|
||||
private makeMyMembership(expires: number): SessionMembershipData {
|
||||
return {
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
application: "m.call",
|
||||
device_id: this.deviceId,
|
||||
expires,
|
||||
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
|
||||
foci_preferred: this.fociPreferred ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
// Error checks and handlers
|
||||
|
||||
/**
|
||||
* Check if its a NOT_FOUND error
|
||||
* @param error the error causing this handler check/execution
|
||||
* @returns true if its a not found error
|
||||
*/
|
||||
private isNotFoundError(error: unknown): boolean {
|
||||
return error instanceof MatrixError && error.errcode === "M_NOT_FOUND";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a DelayExceeded timeout and update the TimeoutOverride for the next try
|
||||
* @param error the error causing this handler check/execution
|
||||
* @returns true if its a delay exceeded error and we updated the local TimeoutOverride
|
||||
*/
|
||||
private manageMaxDelayExceededSituation(error: unknown): boolean {
|
||||
if (
|
||||
error instanceof MatrixError &&
|
||||
error.errcode === "M_UNKNOWN" &&
|
||||
error.data["org.matrix.msc4140.errcode"] === "M_MAX_DELAY_EXCEEDED"
|
||||
) {
|
||||
const maxDelayAllowed = error.data["org.matrix.msc4140.max_delay"];
|
||||
if (typeof maxDelayAllowed === "number" && this.membershipServerSideExpiryTimeout > maxDelayAllowed) {
|
||||
this.membershipServerSideExpiryTimeoutOverride = maxDelayAllowed;
|
||||
}
|
||||
logger.warn("Retry sending delayed disconnection event due to server timeout limitations:", error);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private actionUpdateFromErrors(
|
||||
error: unknown,
|
||||
type: MembershipActionType,
|
||||
method: string,
|
||||
): ActionUpdate | undefined {
|
||||
const updateLimit = this.actionUpdateFromRateLimitError(error, method, type);
|
||||
if (updateLimit) return updateLimit;
|
||||
const updateNetwork = this.actionUpdateFromNetworkErrorRetry(error, type);
|
||||
if (updateNetwork) return updateNetwork;
|
||||
}
|
||||
/**
|
||||
* Check if we have a rate limit error and schedule the same action again if we dont exceed the rate limit retry count yet.
|
||||
* @param error the error causing this handler check/execution
|
||||
* @param method the method used for the throw message
|
||||
* @param type which MembershipActionType we reschedule because of a rate limit.
|
||||
* @throws If it is a rate limit error and the retry count got exceeded
|
||||
* @returns Returns true if we handled the error by rescheduling the correct next action.
|
||||
* Returns false if it is not a network error.
|
||||
*/
|
||||
private actionUpdateFromRateLimitError(
|
||||
error: unknown,
|
||||
method: string,
|
||||
type: MembershipActionType,
|
||||
): ActionUpdate | undefined {
|
||||
// "Is rate limit"-boundary
|
||||
if (!((error instanceof HTTPError || error instanceof MatrixError) && error.isRateLimitError())) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// retry boundary
|
||||
const rateLimitRetries = this.state.rateLimitRetries.get(type) ?? 0;
|
||||
if (rateLimitRetries < this.maximumRateLimitRetryCount) {
|
||||
let resendDelay: number;
|
||||
const defaultMs = 5000;
|
||||
try {
|
||||
resendDelay = error.getRetryAfterMs() ?? defaultMs;
|
||||
logger.info(`Rate limited by server, retrying in ${resendDelay}ms`);
|
||||
} catch (e) {
|
||||
logger.warn(
|
||||
`Error while retrieving a rate-limit retry delay, retrying after default delay of ${defaultMs}`,
|
||||
e,
|
||||
);
|
||||
resendDelay = defaultMs;
|
||||
}
|
||||
this.state.rateLimitRetries.set(type, rateLimitRetries + 1);
|
||||
return createInsertActionUpdate(type, resendDelay);
|
||||
}
|
||||
|
||||
throw Error("Exceeded maximum retries for " + type + " attempts (client." + method + "): " + (error as Error));
|
||||
}
|
||||
|
||||
/**
|
||||
* FIXME Don't Check the error and retry the same MembershipAction again in the configured time and for the configured retry count.
|
||||
* @param error the error causing this handler check/execution
|
||||
* @param type the action type that we need to repeat because of the error
|
||||
* @throws If it is a network error and the retry count got exceeded
|
||||
* @returns
|
||||
* Returns true if we handled the error by rescheduling the correct next action.
|
||||
* Returns false if it is not a network error.
|
||||
*/
|
||||
private actionUpdateFromNetworkErrorRetry(error: unknown, type: MembershipActionType): ActionUpdate | undefined {
|
||||
// "Is a network error"-boundary
|
||||
const retries = this.state.networkErrorRetries.get(type) ?? 0;
|
||||
const retryDurationString = this.callMemberEventRetryDelayMinimum / 1000 + "s";
|
||||
const retryCounterString = "(" + retries + "/" + this.maximumNetworkErrorRetryCount + ")";
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
logger.warn(
|
||||
"Network local timeout error while sending event, retrying in " +
|
||||
retryDurationString +
|
||||
" " +
|
||||
retryCounterString,
|
||||
error,
|
||||
);
|
||||
} else if (error instanceof Error && error.message.includes("updating delayed event")) {
|
||||
// TODO: We do not want error message matching here but instead the error should be a typed HTTPError
|
||||
// and be handled below automatically (the same as in the SPA case).
|
||||
//
|
||||
// The error originates because of https://github.com/matrix-org/matrix-widget-api/blob/5d81d4a26ff69e4bd3ddc79a884c9527999fb2f4/src/ClientWidgetApi.ts#L698-L701
|
||||
// uses `e` instance of HttpError (and not MatrixError)
|
||||
// The element web widget driver (only checks for MatrixError) is then failing to process (`processError`) it as a typed error: https://github.com/element-hq/element-web/blob/471712cbf06a067e5499bd5d2d7a75f693d9a12d/src/stores/widgets/StopGapWidgetDriver.ts#L711-L715
|
||||
// So it will not call: `error.asWidgetApiErrorData()` which is also missing for `HttpError`
|
||||
//
|
||||
// A proper fix would be to either find a place to convert the `HttpError` into a `MatrixError` and the `processError`
|
||||
// method to handle it as expected or to adjust `processError` to also process `HttpError`'s.
|
||||
logger.warn(
|
||||
"delayed event update timeout error, retrying in " + retryDurationString + " " + retryCounterString,
|
||||
error,
|
||||
);
|
||||
} else if (error instanceof ConnectionError) {
|
||||
logger.warn(
|
||||
"Network connection error while sending event, retrying in " +
|
||||
retryDurationString +
|
||||
" " +
|
||||
retryCounterString,
|
||||
error,
|
||||
);
|
||||
} else if (
|
||||
(error instanceof HTTPError || error instanceof MatrixError) &&
|
||||
typeof error.httpStatus === "number" &&
|
||||
error.httpStatus >= 500 &&
|
||||
error.httpStatus < 600
|
||||
) {
|
||||
logger.warn(
|
||||
"Server error while sending event, retrying in " + retryDurationString + " " + retryCounterString,
|
||||
error,
|
||||
);
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// retry boundary
|
||||
if (retries < this.maximumNetworkErrorRetryCount) {
|
||||
this.state.networkErrorRetries.set(type, retries + 1);
|
||||
return createInsertActionUpdate(type, this.callMemberEventRetryDelayMinimum);
|
||||
}
|
||||
|
||||
// Failure
|
||||
throw Error(
|
||||
"Reached maximum (" + this.maximumNetworkErrorRetryCount + ") retries cause by: " + (error as Error),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if its an UnsupportedDelayedEventsEndpointError and which implies that we cannot do any delayed event logic
|
||||
* @param error The error to check
|
||||
* @returns true it its an UnsupportedDelayedEventsEndpointError
|
||||
*/
|
||||
private isUnsupportedDelayedEndpoint(error: unknown): boolean {
|
||||
return error instanceof UnsupportedDelayedEventsEndpointError;
|
||||
}
|
||||
|
||||
private resetRateLimitCounter(type: MembershipActionType): void {
|
||||
this.state.rateLimitRetries.set(type, 0);
|
||||
this.state.networkErrorRetries.set(type, 0);
|
||||
}
|
||||
|
||||
public get status(): Status {
|
||||
const actions = this.scheduler.actions;
|
||||
if (actions.length === 1) {
|
||||
const { type } = actions[0];
|
||||
switch (type) {
|
||||
case MembershipActionType.SendFirstDelayedEvent:
|
||||
case MembershipActionType.SendJoinEvent:
|
||||
case MembershipActionType.SendMainDelayedEvent:
|
||||
return Status.Connecting;
|
||||
case MembershipActionType.UpdateExpiry: // where no delayed events
|
||||
return Status.Connected;
|
||||
case MembershipActionType.SendScheduledDelayedLeaveEvent:
|
||||
case MembershipActionType.SendLeaveEvent:
|
||||
return Status.Disconnecting;
|
||||
default:
|
||||
// pass through as not expected
|
||||
}
|
||||
} else if (actions.length === 2) {
|
||||
const types = actions.map((a) => a.type);
|
||||
// normal state for connected with delayed events
|
||||
if (
|
||||
(types.includes(MembershipActionType.RestartDelayedEvent) ||
|
||||
types.includes(MembershipActionType.SendMainDelayedEvent)) &&
|
||||
types.includes(MembershipActionType.UpdateExpiry)
|
||||
) {
|
||||
return Status.Connected;
|
||||
}
|
||||
} else if (actions.length === 3) {
|
||||
const types = actions.map((a) => a.type);
|
||||
// It is a correct connected state if we already schedule the next Restart but have not yet cleaned up
|
||||
// the current restart.
|
||||
if (
|
||||
types.filter((t) => t === MembershipActionType.RestartDelayedEvent).length === 2 &&
|
||||
types.includes(MembershipActionType.UpdateExpiry)
|
||||
) {
|
||||
return Status.Connected;
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.scheduler.running) {
|
||||
return Status.Disconnected;
|
||||
}
|
||||
|
||||
logger.error("MembershipManager has an unknown state. Actions: ", actions);
|
||||
return Status.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
function createInsertActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate {
|
||||
return {
|
||||
insert: [{ ts: Date.now() + (offset ?? 0), type }],
|
||||
};
|
||||
}
|
||||
|
||||
function createReplaceActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate {
|
||||
return {
|
||||
replace: [{ ts: Date.now() + (offset ?? 0), type }],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { logger as rootLogger } from "../logger.ts";
|
||||
import { type EmptyObject } from "../matrix.ts";
|
||||
import { sleep } from "../utils.ts";
|
||||
import { MembershipActionType } from "./NewMembershipManager.ts";
|
||||
|
||||
const logger = rootLogger.getChild("MatrixRTCSession");
|
||||
|
||||
/** @internal */
|
||||
export interface Action {
|
||||
/**
|
||||
* When this action should be executed
|
||||
*/
|
||||
ts: number;
|
||||
/**
|
||||
* The state of the different loops
|
||||
* can also be thought of as the type of the action
|
||||
*/
|
||||
type: MembershipActionType;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export type ActionUpdate =
|
||||
| {
|
||||
/** Replace all existing scheduled actions with this new array */
|
||||
replace: Action[];
|
||||
}
|
||||
| {
|
||||
/** Add these actions to the existing scheduled actions */
|
||||
insert: Action[];
|
||||
}
|
||||
| EmptyObject;
|
||||
|
||||
/**
|
||||
* This scheduler tracks the state of the current membership participation
|
||||
* and runs one central timer that wakes up a handler callback with the correct action + state
|
||||
* whenever necessary.
|
||||
*
|
||||
* It can also be awakened whenever a new action is added which is
|
||||
* earlier then the current "next awake".
|
||||
* @internal
|
||||
*/
|
||||
export class ActionScheduler {
|
||||
/**
|
||||
* This is tracking the state of the scheduler loop.
|
||||
* Only used to prevent starting the loop twice.
|
||||
*/
|
||||
public running = false;
|
||||
|
||||
public constructor(
|
||||
/** This is the callback called for each scheduled action (`this.addAction()`) */
|
||||
private membershipLoopHandler: (type: MembershipActionType) => Promise<ActionUpdate>,
|
||||
) {}
|
||||
|
||||
// function for the wakeup mechanism (in case we add an action externally and need to leave the current sleep)
|
||||
private wakeup: (update: ActionUpdate) => void = (update: ActionUpdate): void => {
|
||||
logger.error("Cannot call wakeup before calling `startWithJoin()`");
|
||||
};
|
||||
private _actions: Action[] = [];
|
||||
public get actions(): Action[] {
|
||||
return this._actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* This starts the main loop of the membership manager that handles event sending, delayed event sending and delayed event restarting.
|
||||
* @param initialActions The initial actions the manager will start with. It should be enough to pass: DelayedLeaveActionType.Initial
|
||||
* @returns Promise that resolves once all actions have run and no more are scheduled.
|
||||
* @throws This throws an error if one of the actions throws.
|
||||
* In most other error cases the manager will try to handle any server errors by itself.
|
||||
*/
|
||||
public async startWithJoin(): Promise<void> {
|
||||
if (this.running) {
|
||||
logger.error("Cannot call startWithJoin() on NewMembershipActionScheduler while already running");
|
||||
return;
|
||||
}
|
||||
this.running = true;
|
||||
this._actions = [{ ts: Date.now(), type: MembershipActionType.SendFirstDelayedEvent }];
|
||||
try {
|
||||
while (this._actions.length > 0) {
|
||||
// Sort so next (smallest ts) action is at the beginning
|
||||
this._actions.sort((a, b) => a.ts - b.ts);
|
||||
const nextAction = this._actions[0];
|
||||
let wakeupUpdate: ActionUpdate | undefined = undefined;
|
||||
|
||||
// while we await for the next action, wakeup has to resolve the wakeupPromise
|
||||
const wakeupPromise = new Promise<void>((resolve) => {
|
||||
this.wakeup = (update: ActionUpdate): void => {
|
||||
wakeupUpdate = update;
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
if (nextAction.ts > Date.now()) await Promise.race([wakeupPromise, sleep(nextAction.ts - Date.now())]);
|
||||
|
||||
let handlerResult: ActionUpdate = {};
|
||||
if (!wakeupUpdate) {
|
||||
logger.debug(
|
||||
`Current MembershipManager processing: ${nextAction.type}\nQueue:`,
|
||||
this._actions,
|
||||
`\nDate.now: "${Date.now()}`,
|
||||
);
|
||||
try {
|
||||
// `this.wakeup` can also be called and sets the `wakupUpdate` object while we are in the handler.
|
||||
handlerResult = await this.membershipLoopHandler(nextAction.type as MembershipActionType);
|
||||
} catch (e) {
|
||||
throw Error(`The MembershipManager shut down because of the end condition: ${e}`);
|
||||
}
|
||||
}
|
||||
// remove the processed action only after we are done processing
|
||||
this._actions.splice(0, 1);
|
||||
// The wakeupUpdate always wins since that is a direct external update.
|
||||
const actionUpdate = wakeupUpdate ?? handlerResult;
|
||||
|
||||
if ("replace" in actionUpdate) {
|
||||
this._actions = actionUpdate.replace;
|
||||
} else if ("insert" in actionUpdate) {
|
||||
this._actions.push(...actionUpdate.insert);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Set the rtc session running state since we cannot recover from here and the consumer user of the
|
||||
// MatrixRTCSession class needs to manually rejoin.
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
logger.debug("Leave MembershipManager ActionScheduler loop (no more actions)");
|
||||
}
|
||||
|
||||
public initiateJoin(): void {
|
||||
this.wakeup?.({ replace: [{ ts: Date.now(), type: MembershipActionType.SendFirstDelayedEvent }] });
|
||||
}
|
||||
public initiateLeave(): void {
|
||||
this.wakeup?.({ replace: [{ ts: Date.now(), type: MembershipActionType.SendScheduledDelayedLeaveEvent }] });
|
||||
}
|
||||
}
|
||||
+17
-4
@@ -54,8 +54,18 @@ interface OidcRegistrationRequestBody {
|
||||
|
||||
export const DEVICE_CODE_SCOPE = "urn:ietf:params:oauth:grant-type:device_code";
|
||||
|
||||
// Check that URIs have a common base, as per the MSC2966 definition
|
||||
const urlHasCommonBase = (base: URL, urlStr?: string): boolean => {
|
||||
if (!urlStr) return false;
|
||||
const url = new URL(urlStr);
|
||||
if (url.protocol !== base.protocol) return false;
|
||||
if (url.hostname !== base.hostname && !url.hostname.endsWith(`.${base.hostname}`)) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Attempts dynamic registration against the configured registration endpoint
|
||||
* Attempts dynamic registration against the configured registration endpoint.
|
||||
* Will ignore any URIs that do not use client_uri as a common base as per the spec.
|
||||
* @param delegatedAuthConfig - Auth config from {@link discoverAndValidateOIDCIssuerWellKnown}
|
||||
* @param clientMetadata - The metadata for the client which to register
|
||||
* @returns Promise<string> resolved with registered clientId
|
||||
@@ -74,6 +84,8 @@ export const registerOidcClient = async (
|
||||
throw new Error(OidcError.DynamicRegistrationNotSupported);
|
||||
}
|
||||
|
||||
const commonBase = new URL(clientMetadata.clientUri);
|
||||
|
||||
// https://openid.net/specs/openid-connect-registration-1_0.html
|
||||
const metadata: OidcRegistrationRequestBody = {
|
||||
client_name: clientMetadata.clientName,
|
||||
@@ -84,11 +96,12 @@ export const registerOidcClient = async (
|
||||
id_token_signed_response_alg: "RS256",
|
||||
token_endpoint_auth_method: "none",
|
||||
application_type: clientMetadata.applicationType,
|
||||
logo_uri: clientMetadata.logoUri,
|
||||
contacts: clientMetadata.contacts,
|
||||
policy_uri: clientMetadata.policyUri,
|
||||
tos_uri: clientMetadata.tosUri,
|
||||
logo_uri: urlHasCommonBase(commonBase, clientMetadata.logoUri) ? clientMetadata.logoUri : undefined,
|
||||
policy_uri: urlHasCommonBase(commonBase, clientMetadata.policyUri) ? clientMetadata.policyUri : undefined,
|
||||
tos_uri: urlHasCommonBase(commonBase, clientMetadata.tosUri) ? clientMetadata.tosUri : undefined,
|
||||
};
|
||||
|
||||
const headers = {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -1276,6 +1276,24 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
this.checkKeyBackupAndEnable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#disableKeyStorage}.
|
||||
*/
|
||||
public async disableKeyStorage(): Promise<void> {
|
||||
// Get the key backup version we're using
|
||||
const info = await this.getKeyBackupInfo();
|
||||
if (info?.version) {
|
||||
await this.deleteKeyBackupVersion(info.version);
|
||||
} else {
|
||||
logger.error("Can't delete key backup version: no version available");
|
||||
}
|
||||
|
||||
// also turn off 4S, since this is also storing keys on the server.
|
||||
await this.deleteSecretStorage();
|
||||
|
||||
await this.dehydratedDeviceManager.delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs the given object with the current device and current identity (if available).
|
||||
* As defined in {@link https://spec.matrix.org/v1.8/appendices/#signing-json | Signing JSON}.
|
||||
@@ -1447,17 +1465,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
// Disable backup, and delete all the backups from the server
|
||||
await this.backupManager.deleteAllKeyBackupVersions();
|
||||
|
||||
// Remove the stored secrets in the secret storage
|
||||
await this.secretStorage.store("m.cross_signing.master", null);
|
||||
await this.secretStorage.store("m.cross_signing.self_signing", null);
|
||||
await this.secretStorage.store("m.cross_signing.user_signing", null);
|
||||
await this.secretStorage.store("m.megolm_backup.v1", null);
|
||||
|
||||
// Remove the recovery key
|
||||
const defaultKeyId = await this.secretStorage.getDefaultKeyId();
|
||||
if (defaultKeyId) await this.secretStorage.store(`m.secret_storage.key.${defaultKeyId}`, null);
|
||||
// Disable the recovery key and the secret storage
|
||||
await this.secretStorage.setDefaultKeyId(null);
|
||||
this.deleteSecretStorage();
|
||||
|
||||
// Reset the cross-signing keys
|
||||
await this.crossSigningIdentity.bootstrapCrossSigning({
|
||||
@@ -1471,6 +1479,24 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
this.logger.debug("resetEncryption: ended");
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the secret storage key, default key pointer and all (known) secret storage data
|
||||
* from the user's account data
|
||||
*/
|
||||
private async deleteSecretStorage(): Promise<void> {
|
||||
// Remove the stored secrets in the secret storage
|
||||
await this.secretStorage.store("m.cross_signing.master", null);
|
||||
await this.secretStorage.store("m.cross_signing.self_signing", null);
|
||||
await this.secretStorage.store("m.cross_signing.user_signing", null);
|
||||
await this.secretStorage.store("m.megolm_backup.v1", null);
|
||||
|
||||
// Remove the recovery key
|
||||
const defaultKeyId = await this.secretStorage.getDefaultKeyId();
|
||||
if (defaultKeyId) await this.secretStorage.store(`m.secret_storage.key.${defaultKeyId}`, null);
|
||||
// Disable the recovery key and the secret storage
|
||||
await this.secretStorage.setDefaultKeyId(null);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// SyncCryptoCallbacks implementation
|
||||
|
||||
@@ -405,6 +405,23 @@ export async function logDuration<T>(logger: BaseLogger, name: string, block: ()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to log the duration of a synchronous block.
|
||||
*
|
||||
* @param logger - The logger to log to.
|
||||
* @param name - The name of the operation.
|
||||
* @param block - The block to execute.
|
||||
*/
|
||||
export function logDurationSync<T>(logger: BaseLogger, name: string, block: () => T): T {
|
||||
const start = Date.now();
|
||||
try {
|
||||
return block();
|
||||
} finally {
|
||||
const end = Date.now();
|
||||
logger.debug(`[Perf]: ${name} took ${end - start}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Promise/async version of {@link setImmediate}.
|
||||
*
|
||||
|
||||
@@ -1069,9 +1069,9 @@
|
||||
regenerator-runtime "^0.14.0"
|
||||
|
||||
"@babel/runtime@^7.12.5", "@babel/runtime@^7.8.4":
|
||||
version "7.26.9"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.26.9.tgz#aa4c6facc65b9cb3f87d75125ffd47781b475433"
|
||||
integrity sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==
|
||||
version "7.26.10"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.26.10.tgz#a07b4d8fa27af131a633d7b3524db803eb4764c2"
|
||||
integrity sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==
|
||||
dependencies:
|
||||
regenerator-runtime "^0.14.0"
|
||||
|
||||
@@ -1717,12 +1717,12 @@
|
||||
ignore "^5.1.8"
|
||||
p-map "^4.0.0"
|
||||
|
||||
"@stylistic/eslint-plugin@^3.0.0":
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@stylistic/eslint-plugin/-/eslint-plugin-3.1.0.tgz#a9f655c518f76bfc5feb46b467d0f06e511b289d"
|
||||
integrity sha512-pA6VOrOqk0+S8toJYhQGv2MWpQQR0QpeUo9AhNkC49Y26nxBQ/nH1rta9bUU1rPw2fJ1zZEMV5oCX5AazT7J2g==
|
||||
"@stylistic/eslint-plugin@^4.0.0":
|
||||
version "4.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@stylistic/eslint-plugin/-/eslint-plugin-4.2.0.tgz#7860ea84aa7ee3b21757907b863eb62f4f8b0455"
|
||||
integrity sha512-8hXezgz7jexGHdo5WN6JBEIPHCSFyyU4vgbxevu4YLVS5vl+sxqAAGyXSzfNDyR6xMNSH5H1x67nsXcYMOHtZA==
|
||||
dependencies:
|
||||
"@typescript-eslint/utils" "^8.13.0"
|
||||
"@typescript-eslint/utils" "^8.23.0"
|
||||
eslint-visitor-keys "^4.2.0"
|
||||
espree "^10.3.0"
|
||||
estraverse "^5.3.0"
|
||||
@@ -1876,9 +1876,9 @@
|
||||
undici-types "~5.26.4"
|
||||
|
||||
"@types/node@18":
|
||||
version "18.19.76"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.76.tgz#7991658e0ba41ad30cc8be01c9bbe580d58f2112"
|
||||
integrity sha512-yvR7Q9LdPz2vGpmpJX5LolrgRdWvB67MJKDPSgIIzpFbaf9a1j/f5DnLp5VDyHGMR0QZHlTr1afsD87QCXFHKw==
|
||||
version "18.19.79"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.79.tgz#82fde7ac17809f4738a494b22273f0f7e6754f6e"
|
||||
integrity sha512-90K8Oayimbctc5zTPHPfZloc/lGVs7f3phUAAMcTgEPtg8kKquGZDERC8K4vkBYkQQh48msiYUslYtxTWvqcAg==
|
||||
dependencies:
|
||||
undici-types "~5.26.4"
|
||||
|
||||
@@ -1930,29 +1930,29 @@
|
||||
"@types/yargs-parser" "*"
|
||||
|
||||
"@typescript-eslint/eslint-plugin@^8.0.0":
|
||||
version "8.24.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.24.1.tgz#d104c2a6212304c649105b18af2c110b4a1dd4ae"
|
||||
integrity sha512-ll1StnKtBigWIGqvYDVuDmXJHVH4zLVot1yQ4fJtLpL7qacwkxJc1T0bptqw+miBQ/QfUbhl1TcQ4accW5KUyA==
|
||||
version "8.26.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.26.0.tgz#7e880faf91f89471c30c141951e15f0eb3a0599e"
|
||||
integrity sha512-cLr1J6pe56zjKYajK6SSSre6nl1Gj6xDp1TY0trpgPzjVbgDwd09v2Ws37LABxzkicmUjhEeg/fAUjPJJB1v5Q==
|
||||
dependencies:
|
||||
"@eslint-community/regexpp" "^4.10.0"
|
||||
"@typescript-eslint/scope-manager" "8.24.1"
|
||||
"@typescript-eslint/type-utils" "8.24.1"
|
||||
"@typescript-eslint/utils" "8.24.1"
|
||||
"@typescript-eslint/visitor-keys" "8.24.1"
|
||||
"@typescript-eslint/scope-manager" "8.26.0"
|
||||
"@typescript-eslint/type-utils" "8.26.0"
|
||||
"@typescript-eslint/utils" "8.26.0"
|
||||
"@typescript-eslint/visitor-keys" "8.26.0"
|
||||
graphemer "^1.4.0"
|
||||
ignore "^5.3.1"
|
||||
natural-compare "^1.4.0"
|
||||
ts-api-utils "^2.0.1"
|
||||
|
||||
"@typescript-eslint/parser@^8.0.0":
|
||||
version "8.24.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.24.1.tgz#67965c2d2ddd7eadb2f094c395695db8334ef9a2"
|
||||
integrity sha512-Tqoa05bu+t5s8CTZFaGpCH2ub3QeT9YDkXbPd3uQ4SfsLoh1/vv2GEYAioPoxCWJJNsenXlC88tRjwoHNts1oQ==
|
||||
version "8.26.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.26.0.tgz#9b4d2198e89f64fb81e83167eedd89a827d843a9"
|
||||
integrity sha512-mNtXP9LTVBy14ZF3o7JG69gRPBK/2QWtQd0j0oH26HcY/foyJJau6pNUez7QrM5UHnSvwlQcJXKsk0I99B9pOA==
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager" "8.24.1"
|
||||
"@typescript-eslint/types" "8.24.1"
|
||||
"@typescript-eslint/typescript-estree" "8.24.1"
|
||||
"@typescript-eslint/visitor-keys" "8.24.1"
|
||||
"@typescript-eslint/scope-manager" "8.26.0"
|
||||
"@typescript-eslint/types" "8.26.0"
|
||||
"@typescript-eslint/typescript-estree" "8.26.0"
|
||||
"@typescript-eslint/visitor-keys" "8.26.0"
|
||||
debug "^4.3.4"
|
||||
|
||||
"@typescript-eslint/scope-manager@8.21.0":
|
||||
@@ -1963,21 +1963,21 @@
|
||||
"@typescript-eslint/types" "8.21.0"
|
||||
"@typescript-eslint/visitor-keys" "8.21.0"
|
||||
|
||||
"@typescript-eslint/scope-manager@8.24.1":
|
||||
version "8.24.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.24.1.tgz#1e1e76ec4560aa85077ab36deb9b2bead4ae124e"
|
||||
integrity sha512-OdQr6BNBzwRjNEXMQyaGyZzgg7wzjYKfX2ZBV3E04hUCBDv3GQCHiz9RpqdUIiVrMgJGkXm3tcEh4vFSHreS2Q==
|
||||
"@typescript-eslint/scope-manager@8.26.0":
|
||||
version "8.26.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.26.0.tgz#b06623fad54a3a77fadab5f652ef75ed3780b545"
|
||||
integrity sha512-E0ntLvsfPqnPwng8b8y4OGuzh/iIOm2z8U3S9zic2TeMLW61u5IH2Q1wu0oSTkfrSzwbDJIB/Lm8O3//8BWMPA==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.24.1"
|
||||
"@typescript-eslint/visitor-keys" "8.24.1"
|
||||
"@typescript-eslint/types" "8.26.0"
|
||||
"@typescript-eslint/visitor-keys" "8.26.0"
|
||||
|
||||
"@typescript-eslint/type-utils@8.24.1":
|
||||
version "8.24.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.24.1.tgz#99113e1df63d1571309d87eef68967344c78dd65"
|
||||
integrity sha512-/Do9fmNgCsQ+K4rCz0STI7lYB4phTtEXqqCAs3gZW0pnK7lWNkvWd5iW545GSmApm4AzmQXmSqXPO565B4WVrw==
|
||||
"@typescript-eslint/type-utils@8.26.0":
|
||||
version "8.26.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.26.0.tgz#9ee8cc98184b5f66326578de9c097edc89da6f68"
|
||||
integrity sha512-ruk0RNChLKz3zKGn2LwXuVoeBcUMh+jaqzN461uMMdxy5H9epZqIBtYj7UiPXRuOpaALXGbmRuZQhmwHhaS04Q==
|
||||
dependencies:
|
||||
"@typescript-eslint/typescript-estree" "8.24.1"
|
||||
"@typescript-eslint/utils" "8.24.1"
|
||||
"@typescript-eslint/typescript-estree" "8.26.0"
|
||||
"@typescript-eslint/utils" "8.26.0"
|
||||
debug "^4.3.4"
|
||||
ts-api-utils "^2.0.1"
|
||||
|
||||
@@ -1986,10 +1986,10 @@
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.21.0.tgz#58f30aec8db8212fd886835dc5969cdf47cb29f5"
|
||||
integrity sha512-PAL6LUuQwotLW2a8VsySDBwYMm129vFm4tMVlylzdoTybTHaAi0oBp7Ac6LhSrHHOdLM3efH+nAR6hAWoMF89A==
|
||||
|
||||
"@typescript-eslint/types@8.24.1":
|
||||
version "8.24.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.24.1.tgz#8777a024f3afc4ace5e48f9a804309c6dd38f95a"
|
||||
integrity sha512-9kqJ+2DkUXiuhoiYIUvIYjGcwle8pcPpdlfkemGvTObzgmYfJ5d0Qm6jwb4NBXP9W1I5tss0VIAnWFumz3mC5A==
|
||||
"@typescript-eslint/types@8.26.0":
|
||||
version "8.26.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.26.0.tgz#c4e93a8faf3a38a8d8adb007dc7834f1c89ee7bf"
|
||||
integrity sha512-89B1eP3tnpr9A8L6PZlSjBvnJhWXtYfZhECqlBl1D9Lme9mHO6iWlsprBtVenQvY1HMhax1mWOjhtL3fh/u+pA==
|
||||
|
||||
"@typescript-eslint/typescript-estree@8.21.0":
|
||||
version "8.21.0"
|
||||
@@ -2005,13 +2005,13 @@
|
||||
semver "^7.6.0"
|
||||
ts-api-utils "^2.0.0"
|
||||
|
||||
"@typescript-eslint/typescript-estree@8.24.1":
|
||||
version "8.24.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.24.1.tgz#3bb479401f8bd471b3c6dd3db89e7256977c54db"
|
||||
integrity sha512-UPyy4MJ/0RE648DSKQe9g0VDSehPINiejjA6ElqnFaFIhI6ZEiZAkUI0D5MCk0bQcTf/LVqZStvQ6K4lPn/BRg==
|
||||
"@typescript-eslint/typescript-estree@8.26.0":
|
||||
version "8.26.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.26.0.tgz#128972172005a7376e34ed2ecba4e29363b0cad1"
|
||||
integrity sha512-tiJ1Hvy/V/oMVRTbEOIeemA2XoylimlDQ03CgPPNaHYZbpsc78Hmngnt+WXZfJX1pjQ711V7g0H7cSJThGYfPQ==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.24.1"
|
||||
"@typescript-eslint/visitor-keys" "8.24.1"
|
||||
"@typescript-eslint/types" "8.26.0"
|
||||
"@typescript-eslint/visitor-keys" "8.26.0"
|
||||
debug "^4.3.4"
|
||||
fast-glob "^3.3.2"
|
||||
is-glob "^4.0.3"
|
||||
@@ -2019,15 +2019,15 @@
|
||||
semver "^7.6.0"
|
||||
ts-api-utils "^2.0.1"
|
||||
|
||||
"@typescript-eslint/utils@8.24.1", "@typescript-eslint/utils@^8.13.0":
|
||||
version "8.24.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.24.1.tgz#08d14eac33cfb3456feeee5a275b8ad3349e52ed"
|
||||
integrity sha512-OOcg3PMMQx9EXspId5iktsI3eMaXVwlhC8BvNnX6B5w9a4dVgpkQZuU8Hy67TolKcl+iFWq0XX+jbDGN4xWxjQ==
|
||||
"@typescript-eslint/utils@8.26.0", "@typescript-eslint/utils@^8.23.0":
|
||||
version "8.26.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.26.0.tgz#845d20ed8378a5594e6445f54e53b972aee7b3e6"
|
||||
integrity sha512-2L2tU3FVwhvU14LndnQCA2frYC8JnPDVKyQtWFPf8IYFMt/ykEN1bPolNhNbCVgOmdzTlWdusCTKA/9nKrf8Ig==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.4.0"
|
||||
"@typescript-eslint/scope-manager" "8.24.1"
|
||||
"@typescript-eslint/types" "8.24.1"
|
||||
"@typescript-eslint/typescript-estree" "8.24.1"
|
||||
"@typescript-eslint/scope-manager" "8.26.0"
|
||||
"@typescript-eslint/types" "8.26.0"
|
||||
"@typescript-eslint/typescript-estree" "8.26.0"
|
||||
|
||||
"@typescript-eslint/utils@^6.0.0 || ^7.0.0 || ^8.0.0":
|
||||
version "8.21.0"
|
||||
@@ -2047,12 +2047,12 @@
|
||||
"@typescript-eslint/types" "8.21.0"
|
||||
eslint-visitor-keys "^4.2.0"
|
||||
|
||||
"@typescript-eslint/visitor-keys@8.24.1":
|
||||
version "8.24.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.24.1.tgz#8bdfe47a89195344b34eb21ef61251562148202b"
|
||||
integrity sha512-EwVHlp5l+2vp8CoqJm9KikPZgi3gbdZAtabKT9KPShGeOcJhsv4Zdo3oc8T8I0uKEmYoU4ItyxbptjF08enaxg==
|
||||
"@typescript-eslint/visitor-keys@8.26.0":
|
||||
version "8.26.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.26.0.tgz#a4876216756c69130ea958df3b77222c2ad95290"
|
||||
integrity sha512-2z8JQJWAzPdDd51dRQ/oqIJxe99/hoLIqmf8RMCAJQtYDc535W/Jt2+RTP4bP0aKeBG1F65yjIZuczOXCmbWwg==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.24.1"
|
||||
"@typescript-eslint/types" "8.26.0"
|
||||
eslint-visitor-keys "^4.2.0"
|
||||
|
||||
"@ungap/structured-clone@^1.2.0":
|
||||
@@ -3486,13 +3486,20 @@ fast-levenshtein@^2.0.6:
|
||||
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
|
||||
integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==
|
||||
|
||||
fastq@^1.15.0, fastq@^1.6.0:
|
||||
fastq@^1.15.0:
|
||||
version "1.19.0"
|
||||
resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.19.0.tgz#a82c6b7c2bb4e44766d865f07997785fecfdcb89"
|
||||
integrity sha512-7SFSRCNjBQIZH/xZR3iy5iQYR8aGBE0h3VG6/cwlbrpdciNYBMotQav8c1XI3HjHH+NikUpP53nPdlZSdWmFzA==
|
||||
dependencies:
|
||||
reusify "^1.0.4"
|
||||
|
||||
fastq@^1.6.0:
|
||||
version "1.19.1"
|
||||
resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.19.1.tgz#d50eaba803c8846a883c16492821ebcd2cda55f5"
|
||||
integrity sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==
|
||||
dependencies:
|
||||
reusify "^1.0.4"
|
||||
|
||||
fb-watchman@^2.0.0:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.2.tgz#e9524ee6b5c77e9e5001af0f85f3adbb8623255c"
|
||||
@@ -5686,9 +5693,9 @@ retry@^0.13.1:
|
||||
integrity sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==
|
||||
|
||||
reusify@^1.0.4:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"
|
||||
integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f"
|
||||
integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==
|
||||
|
||||
rfdc@^1.4.1:
|
||||
version "1.4.1"
|
||||
@@ -6309,10 +6316,10 @@ typedoc-plugin-coverage@^3.0.0:
|
||||
resolved "https://registry.yarnpkg.com/typedoc-plugin-coverage/-/typedoc-plugin-coverage-3.4.1.tgz#13b445cecb674845945e218c4560bbd91299af83"
|
||||
integrity sha512-V23DAwinAMpGMGcL1R1s8Snr26hrjfIdwGf+4jR/65ZdmeAN+yRX0onfx5JlembTQhR6AePQ/parfQNS0AZ64A==
|
||||
|
||||
typedoc-plugin-mdn-links@^4.0.0:
|
||||
version "4.0.13"
|
||||
resolved "https://registry.yarnpkg.com/typedoc-plugin-mdn-links/-/typedoc-plugin-mdn-links-4.0.13.tgz#bf78dc341d63bc556ec26e76cd63c828862aee6f"
|
||||
integrity sha512-vbfQHlPGU0AXuyEeMpKDEMS5FuF5EN5aVSPqxK0vbyhbC7vrE8H2AFwI2avWdPgo9crpld2EtT1IUhkAogAefw==
|
||||
typedoc-plugin-mdn-links@^5.0.0:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/typedoc-plugin-mdn-links/-/typedoc-plugin-mdn-links-5.0.1.tgz#2ff23f997e71a2c77cd43590455754e29e4875f0"
|
||||
integrity sha512-eofdcc2nZZpipz/ubjG+7UYMi6Xu95svUwnZ+ClJh6NJdrv7kAOerL9N3iDOpo5kwQeK86GqPWwnv6LUGo5Wrw==
|
||||
|
||||
typedoc-plugin-missing-exports@^3.0.0:
|
||||
version "3.1.0"
|
||||
@@ -6320,9 +6327,9 @@ typedoc-plugin-missing-exports@^3.0.0:
|
||||
integrity sha512-Sogbaj+qDa21NjB3SlIw4JXSwmcl/WOjwiPNaVEcPhpNG/MiRTtpwV81cT7h1cbu9StpONFPbddYWR0KV/fTWA==
|
||||
|
||||
typedoc@^0.27.0:
|
||||
version "0.27.7"
|
||||
resolved "https://registry.yarnpkg.com/typedoc/-/typedoc-0.27.7.tgz#09047ffb5c845f45765de26c68b77260867fe967"
|
||||
integrity sha512-K/JaUPX18+61W3VXek1cWC5gwmuLvYTOXJzBvD9W7jFvbPnefRnCHQCEPw7MSNrP/Hj7JJrhZtDDLKdcYm6ucg==
|
||||
version "0.27.9"
|
||||
resolved "https://registry.yarnpkg.com/typedoc/-/typedoc-0.27.9.tgz#5e0a7bc32bfc7bd0b70a353f4f1d5cba3d667c46"
|
||||
integrity sha512-/z585740YHURLl9DN2jCWe6OW7zKYm6VoQ93H0sxZ1cwHQEQrUn5BJrEnkWhfzUdyO+BLGjnKUZ9iz9hKloFDw==
|
||||
dependencies:
|
||||
"@gerrit0/mini-shiki" "^1.24.0"
|
||||
lunr "^2.3.9"
|
||||
@@ -6331,9 +6338,9 @@ typedoc@^0.27.0:
|
||||
yaml "^2.6.1"
|
||||
|
||||
typescript@^5.4.2:
|
||||
version "5.7.3"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.7.3.tgz#919b44a7dbb8583a9b856d162be24a54bf80073e"
|
||||
integrity sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==
|
||||
version "5.8.2"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.2.tgz#8170b3702f74b79db2e5a96207c15e65807999e4"
|
||||
integrity sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==
|
||||
|
||||
uc.micro@^2.0.0, uc.micro@^2.1.0:
|
||||
version "2.1.0"
|
||||
|
||||
Reference in New Issue
Block a user