diff --git a/README.md b/README.md index 257337d2ca3..cb3b9f8110e 100644 --- a/README.md +++ b/README.md @@ -33,10 +33,8 @@ In Node.js ---------- Ensure you have the latest LTS version of Node.js installed. - -This SDK targets Node 12 for compatibility, which translates to ES6. If you're using -a bundler like webpack you'll likely have to transpile dependencies, including this -SDK, to match your target browsers. +This library relies on `fetch` which is available in Node from v18.0.0 - it should work fine also with polyfills. +If you wish to use a ponyfill or adapter of some sort then pass it as `fetchFn` to the MatrixClient constructor options. Using `yarn` instead of `npm` is recommended. Please see the Yarn [install guide](https://classic.yarnpkg.com/en/docs/install) if you do not have it already. diff --git a/package.json b/package.json index fda529c9147..710bb281d87 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "20.1.0", "description": "Matrix Client-Server SDK for Javascript", "engines": { - "node": ">=12.9.0" + "node": ">=16.0.0" }, "scripts": { "prepublishOnly": "yarn build", @@ -55,14 +55,12 @@ "dependencies": { "@babel/runtime": "^7.12.5", "another-json": "^0.2.0", - "browser-request": "^0.3.3", "bs58": "^5.0.0", "content-type": "^1.0.4", "loglevel": "^1.7.1", "matrix-events-sdk": "^0.0.1-beta.7", "p-retry": "4", "qs": "^6.9.6", - "request": "^2.88.2", "unhomoglyph": "^1.0.6" }, "devDependencies": { @@ -81,9 +79,9 @@ "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.13.tgz", "@types/bs58": "^4.0.1", "@types/content-type": "^1.1.5", + "@types/domexception": "^4.0.0", "@types/jest": "^29.0.0", "@types/node": "16", - "@types/request": "^2.48.5", "@typescript-eslint/eslint-plugin": "^5.6.0", "@typescript-eslint/parser": "^5.6.0", "allchange": "^1.0.6", @@ -92,6 +90,7 @@ "better-docs": "^2.4.0-beta.9", "browserify": "^17.0.0", "docdash": "^1.2.0", + "domexception": "^4.0.0", "eslint": "8.24.0", "eslint-config-google": "^0.14.0", "eslint-import-resolver-typescript": "^3.5.1", @@ -104,7 +103,7 @@ "jest-mock": "^27.5.1", "jest-sonar-reporter": "^2.0.0", "jsdoc": "^3.6.6", - "matrix-mock-request": "^2.1.2", + "matrix-mock-request": "^2.5.0", "rimraf": "^3.0.2", "terser": "^5.5.1", "tsify": "^5.0.2", @@ -115,6 +114,9 @@ "testMatch": [ "/spec/**/*.spec.{js,ts}" ], + "setupFilesAfterEnv": [ + "/spec/setupTests.ts" + ], "collectCoverageFrom": [ "/src/**/*.{js,ts}" ], diff --git a/spec/TestClient.ts b/spec/TestClient.ts index 0a6c4e0eee2..6056884dd31 100644 --- a/spec/TestClient.ts +++ b/spec/TestClient.ts @@ -30,7 +30,6 @@ import { MockStorageApi } from "./MockStorageApi"; import { encodeUri } from "../src/utils"; import { IDeviceKeys, IOneTimeKey } from "../src/crypto/dehydration"; import { IKeyBackupSession } from "../src/crypto/keybackup"; -import { IHttpOpts } from "../src/http-api"; import { IKeysUploadResponse, IUploadKeysRequest } from '../src/client'; /** @@ -56,11 +55,11 @@ export class TestClient { this.httpBackend = new MockHttpBackend(); const fullOptions: ICreateClientOpts = { - baseUrl: "http://" + userId + ".test.server", + baseUrl: "http://" + userId?.slice(1).replace(":", ".") + ".test.server", userId: userId, accessToken: accessToken, deviceId: deviceId, - request: this.httpBackend.requestFn as IHttpOpts["request"], + fetchFn: this.httpBackend.fetchFn as typeof global.fetch, ...options, }; if (!fullOptions.cryptoStore) { diff --git a/spec/browserify/sync-browserify.spec.ts b/spec/browserify/sync-browserify.spec.ts index 8a087c80722..648a72dc416 100644 --- a/spec/browserify/sync-browserify.spec.ts +++ b/spec/browserify/sync-browserify.spec.ts @@ -14,46 +14,66 @@ See the License for the specific language governing permissions and limitations under the License. */ -// load XmlHttpRequest mock +import HttpBackend from "matrix-mock-request"; + import "./setupTests"; import "../../dist/browser-matrix"; // uses browser-matrix instead of the src -import * as utils from "../test-utils/test-utils"; -import { TestClient } from "../TestClient"; +import type { MatrixClient, ClientEvent } from "../../src"; const USER_ID = "@user:test.server"; const DEVICE_ID = "device_id"; const ACCESS_TOKEN = "access_token"; const ROOM_ID = "!room_id:server.test"; +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace NodeJS { + interface Global { + matrixcs: { + MatrixClient: typeof MatrixClient; + ClientEvent: typeof ClientEvent; + }; + } + } +} + describe("Browserify Test", function() { - let client; - let httpBackend; + let client: MatrixClient; + let httpBackend: HttpBackend; beforeEach(() => { - const testClient = new TestClient(USER_ID, DEVICE_ID, ACCESS_TOKEN); - - client = testClient.client; - httpBackend = testClient.httpBackend; + httpBackend = new HttpBackend(); + client = new global.matrixcs.MatrixClient({ + baseUrl: "http://test.server", + userId: USER_ID, + accessToken: ACCESS_TOKEN, + deviceId: DEVICE_ID, + fetchFn: httpBackend.fetchFn as typeof global.fetch, + }); httpBackend.when("GET", "/versions").respond(200, {}); httpBackend.when("GET", "/pushrules").respond(200, {}); httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" }); - - client.startClient(); }); afterEach(async () => { client.stopClient(); - httpBackend.stop(); + client.http.abort(); + httpBackend.verifyNoOutstandingRequests(); + httpBackend.verifyNoOutstandingExpectation(); + await httpBackend.stop(); }); - it("Sync", function() { - const event = utils.mkMembership({ - room: ROOM_ID, - mship: "join", - user: "@other_user:server.test", - name: "Displayname", - }); + it("Sync", async () => { + const event = { + type: "m.room.member", + room_id: ROOM_ID, + content: { + membership: "join", + name: "Displayname", + }, + event_id: "$foobar", + }; const syncData = { next_batch: "batch1", @@ -71,11 +91,16 @@ describe("Browserify Test", function() { }; httpBackend.when("GET", "/sync").respond(200, syncData); - return Promise.race([ - httpBackend.flushAllExpected(), - new Promise((_, reject) => { - client.once("sync.unexpectedError", reject); - }), - ]); + httpBackend.when("GET", "/sync").respond(200, syncData); + + const syncPromise = new Promise(r => client.once(global.matrixcs.ClientEvent.Sync, r)); + const unexpectedErrorFn = jest.fn(); + client.once(global.matrixcs.ClientEvent.SyncUnexpectedError, unexpectedErrorFn); + + client.startClient(); + + await httpBackend.flushAllExpected(); + await syncPromise; + expect(unexpectedErrorFn).not.toHaveBeenCalled(); }, 20000); // additional timeout as this test can take quite a while }); diff --git a/spec/integ/matrix-client-methods.spec.ts b/spec/integ/matrix-client-methods.spec.ts index fdf32d9e985..5bec405cbdd 100644 --- a/spec/integ/matrix-client-methods.spec.ts +++ b/spec/integ/matrix-client-methods.spec.ts @@ -16,13 +16,12 @@ limitations under the License. import HttpBackend from "matrix-mock-request"; import * as utils from "../test-utils/test-utils"; -import { CRYPTO_ENABLED, MatrixClient, IStoredClientOpts } from "../../src/client"; +import { CRYPTO_ENABLED, IStoredClientOpts, MatrixClient } from "../../src/client"; import { MatrixEvent } from "../../src/models/event"; -import { Filter, MemoryStore, Room } from "../../src/matrix"; +import { Filter, MemoryStore, Method, Room, SERVICE_TYPES } from "../../src/matrix"; import { TestClient } from "../TestClient"; import { THREAD_RELATION_TYPE } from "../../src/models/thread"; import { IFilterDefinition } from "../../src/filter"; -import { FileType } from "../../src/http-api"; import { ISearchResults } from "../../src/@types/search"; import { IStore } from "../../src/store"; @@ -65,28 +64,27 @@ describe("MatrixClient", function() { describe("uploadContent", function() { const buf = Buffer.from('hello world'); + const file = buf; + const opts = { + type: "text/plain", + name: "hi.txt", + }; + it("should upload the file", function() { httpBackend!.when( "POST", "/_matrix/media/r0/upload", ).check(function(req) { expect(req.rawData).toEqual(buf); expect(req.queryParams?.filename).toEqual("hi.txt"); - if (!(req.queryParams?.access_token == accessToken || - req.headers["Authorization"] == "Bearer " + accessToken)) { - expect(true).toBe(false); - } + expect(req.headers["Authorization"]).toBe("Bearer " + accessToken); expect(req.headers["Content-Type"]).toEqual("text/plain"); // @ts-ignore private property expect(req.opts.json).toBeFalsy(); // @ts-ignore private property expect(req.opts.timeout).toBe(undefined); - }).respond(200, "content", true); + }).respond(200, '{"content_uri": "content"}', true); - const prom = client!.uploadContent({ - stream: buf, - name: "hi.txt", - type: "text/plain", - } as unknown as FileType); + const prom = client!.uploadContent(file, opts); expect(prom).toBeTruthy(); @@ -96,8 +94,7 @@ describe("MatrixClient", function() { expect(uploads[0].loaded).toEqual(0); const prom2 = prom.then(function(response) { - // for backwards compatibility, we return the raw JSON - expect(response).toEqual("content"); + expect(response.content_uri).toEqual("content"); const uploads = client!.getCurrentUploads(); expect(uploads.length).toEqual(0); @@ -107,28 +104,6 @@ describe("MatrixClient", function() { return prom2; }); - it("should parse the response if rawResponse=false", function() { - httpBackend!.when( - "POST", "/_matrix/media/r0/upload", - ).check(function(req) { - // @ts-ignore private property - expect(req.opts.json).toBeFalsy(); - }).respond(200, { "content_uri": "uri" }); - - const prom = client!.uploadContent({ - stream: buf, - name: "hi.txt", - type: "text/plain", - } as unknown as FileType, { - rawResponse: false, - }).then(function(response) { - expect(response.content_uri).toEqual("uri"); - }); - - httpBackend!.flush(''); - return prom; - }); - it("should parse errors into a MatrixError", function() { httpBackend!.when( "POST", "/_matrix/media/r0/upload", @@ -141,11 +116,7 @@ describe("MatrixClient", function() { "error": "broken", }); - const prom = client!.uploadContent({ - stream: buf, - name: "hi.txt", - type: "text/plain", - } as unknown as FileType).then(function(response) { + const prom = client!.uploadContent(file, opts).then(function(response) { throw Error("request not failed"); }, function(error) { expect(error.httpStatus).toEqual(400); @@ -157,30 +128,18 @@ describe("MatrixClient", function() { return prom; }); - it("should return a promise which can be cancelled", function() { - const prom = client!.uploadContent({ - stream: buf, - name: "hi.txt", - type: "text/plain", - } as unknown as FileType); + it("should return a promise which can be cancelled", async () => { + const prom = client!.uploadContent(file, opts); const uploads = client!.getCurrentUploads(); expect(uploads.length).toEqual(1); expect(uploads[0].promise).toBe(prom); expect(uploads[0].loaded).toEqual(0); - const prom2 = prom.then(function(response) { - throw Error("request not aborted"); - }, function(error) { - expect(error).toEqual("aborted"); - - const uploads = client!.getCurrentUploads(); - expect(uploads.length).toEqual(0); - }); - const r = client!.cancelUpload(prom); expect(r).toBe(true); - return prom2; + await expect(prom).rejects.toThrow("Aborted"); + expect(client.getCurrentUploads()).toHaveLength(0); }); }); @@ -202,6 +161,30 @@ describe("MatrixClient", function() { client!.joinRoom(roomId); httpBackend!.verifyNoOutstandingRequests(); }); + + it("should send request to inviteSignUrl if specified", async () => { + const roomId = "!roomId:server"; + const inviteSignUrl = "https://id.server/sign/this/for/me"; + const viaServers = ["a", "b", "c"]; + const signature = { + sender: "sender", + mxid: "@sender:foo", + token: "token", + signatures: {}, + }; + + httpBackend!.when("POST", inviteSignUrl).respond(200, signature); + httpBackend!.when("POST", "/join/" + encodeURIComponent(roomId)).check(request => { + expect(request.data.third_party_signed).toEqual(signature); + }).respond(200, { room_id: roomId }); + + const prom = client.joinRoom(roomId, { + inviteSignUrl, + viaServers, + }); + await httpBackend!.flushAllExpected(); + expect((await prom).roomId).toBe(roomId); + }); }); describe("getFilter", function() { @@ -676,7 +659,7 @@ describe("MatrixClient", function() { // The vote event has been copied into the thread const eventRefWithThreadId = withThreadId( eventPollResponseReference, eventPollStartThreadRoot.getId()); - expect(eventRefWithThreadId.threadId).toBeTruthy(); + expect(eventRefWithThreadId.threadRootId).toBeTruthy(); expect(threaded).toEqual([ eventPollStartThreadRoot, @@ -1178,15 +1161,150 @@ describe("MatrixClient", function() { expect(await prom).toStrictEqual(response); }); }); + + describe("logout", () => { + it("should abort pending requests when called with stopClient=true", async () => { + httpBackend.when("POST", "/logout").respond(200, {}); + const fn = jest.fn(); + client.http.request(Method.Get, "/test").catch(fn); + client.logout(true); + await httpBackend.flush(undefined); + expect(fn).toHaveBeenCalled(); + }); + }); + + describe("sendHtmlEmote", () => { + it("should send valid html emote", async () => { + httpBackend.when("PUT", "/send").check(req => { + expect(req.data).toStrictEqual({ + "msgtype": "m.emote", + "body": "Body", + "formatted_body": "

Body

", + "format": "org.matrix.custom.html", + "org.matrix.msc1767.message": expect.anything(), + }); + }).respond(200, { event_id: "$foobar" }); + const prom = client.sendHtmlEmote("!room:server", "Body", "

Body

"); + await httpBackend.flush(undefined); + await expect(prom).resolves.toStrictEqual({ event_id: "$foobar" }); + }); + }); + + describe("sendHtmlMessage", () => { + it("should send valid html message", async () => { + httpBackend.when("PUT", "/send").check(req => { + expect(req.data).toStrictEqual({ + "msgtype": "m.text", + "body": "Body", + "formatted_body": "

Body

", + "format": "org.matrix.custom.html", + "org.matrix.msc1767.message": expect.anything(), + }); + }).respond(200, { event_id: "$foobar" }); + const prom = client.sendHtmlMessage("!room:server", "Body", "

Body

"); + await httpBackend.flush(undefined); + await expect(prom).resolves.toStrictEqual({ event_id: "$foobar" }); + }); + }); + + describe("forget", () => { + it("should remove from store by default", async () => { + const room = new Room("!roomId:server", client, userId); + client.store.storeRoom(room); + expect(client.store.getRooms()).toContain(room); + + httpBackend.when("POST", "/forget").respond(200, {}); + await Promise.all([ + client.forget(room.roomId), + httpBackend.flushAllExpected(), + ]); + expect(client.store.getRooms()).not.toContain(room); + }); + }); + + describe("getCapabilities", () => { + it("should cache by default", async () => { + httpBackend!.when("GET", "/capabilities").respond(200, { + capabilities: { + "m.change_password": false, + }, + }); + const prom = httpBackend!.flushAllExpected(); + const capabilities1 = await client!.getCapabilities(); + const capabilities2 = await client!.getCapabilities(); + await prom; + + expect(capabilities1).toStrictEqual(capabilities2); + }); + }); + + describe("getTerms", () => { + it("should return Identity Server terms", async () => { + httpBackend!.when("GET", "/terms").respond(200, { foo: "bar" }); + const prom = client!.getTerms(SERVICE_TYPES.IS, "http://identity.server"); + await httpBackend!.flushAllExpected(); + await expect(prom).resolves.toEqual({ foo: "bar" }); + }); + + it("should return Integrations Manager terms", async () => { + httpBackend!.when("GET", "/terms").respond(200, { foo: "bar" }); + const prom = client!.getTerms(SERVICE_TYPES.IM, "http://im.server"); + await httpBackend!.flushAllExpected(); + await expect(prom).resolves.toEqual({ foo: "bar" }); + }); + }); + + describe("publicRooms", () => { + it("should use GET request if no server or filter is specified", () => { + httpBackend!.when("GET", "/publicRooms").respond(200, {}); + client!.publicRooms({}); + return httpBackend!.flushAllExpected(); + }); + + it("should use GET request if only server is specified", () => { + httpBackend!.when("GET", "/publicRooms").check(request => { + expect(request.queryParams.server).toBe("server1"); + }).respond(200, {}); + client!.publicRooms({ server: "server1" }); + return httpBackend!.flushAllExpected(); + }); + + it("should use POST request if filter is specified", () => { + httpBackend!.when("POST", "/publicRooms").check(request => { + expect(request.data.filter.generic_search_term).toBe("foobar"); + }).respond(200, {}); + client!.publicRooms({ filter: { generic_search_term: "foobar" } }); + return httpBackend!.flushAllExpected(); + }); + }); + + describe("login", () => { + it("should persist values to the client opts", async () => { + const token = "!token&"; + const userId = "@m:t"; + + httpBackend!.when("POST", "/login").respond(200, { + access_token: token, + user_id: userId, + }); + const prom = client!.login("fake.login", {}); + await httpBackend!.flushAllExpected(); + const resp = await prom; + expect(resp.access_token).toBe(token); + expect(resp.user_id).toBe(userId); + expect(client.getUserId()).toBe(userId); + expect(client.http.opts.accessToken).toBe(token); + }); + }); }); -function withThreadId(event, newThreadId) { +function withThreadId(event: MatrixEvent, newThreadId: string): MatrixEvent { const ret = event.toSnapshot(); ret.setThreadId(newThreadId); return ret; } -const buildEventMessageInThread = (root) => new MatrixEvent({ +const buildEventMessageInThread = (root: MatrixEvent) => new MatrixEvent({ "age": 80098509, "content": { "algorithm": "m.megolm.v1.aes-sha2", @@ -1233,7 +1351,7 @@ const buildEventPollResponseReference = () => new MatrixEvent({ "user_id": "@andybalaam-test1:matrix.org", }); -const buildEventReaction = (event) => new MatrixEvent({ +const buildEventReaction = (event: MatrixEvent) => new MatrixEvent({ "content": { "m.relates_to": { "event_id": event.getId(), @@ -1252,7 +1370,7 @@ const buildEventReaction = (event) => new MatrixEvent({ "room_id": "!STrMRsukXHtqQdSeHa:matrix.org", }); -const buildEventRedaction = (event) => new MatrixEvent({ +const buildEventRedaction = (event: MatrixEvent) => new MatrixEvent({ "content": { }, @@ -1286,7 +1404,7 @@ const buildEventPollStartThreadRoot = () => new MatrixEvent({ "user_id": "@andybalaam-test1:matrix.org", }); -const buildEventReply = (target) => new MatrixEvent({ +const buildEventReply = (target: MatrixEvent) => new MatrixEvent({ "age": 80098509, "content": { "algorithm": "m.megolm.v1.aes-sha2", @@ -1452,7 +1570,7 @@ const buildEventCreate = () => new MatrixEvent({ "user_id": "@andybalaam-test1:matrix.org", }); -function assertObjectContains(obj, expected) { +function assertObjectContains(obj: object, expected: any): void { for (const k in expected) { if (expected.hasOwnProperty(k)) { expect(obj[k]).toEqual(expected[k]); diff --git a/spec/integ/matrix-client-opts.spec.ts b/spec/integ/matrix-client-opts.spec.ts index 71403007464..5ea4fba7718 100644 --- a/spec/integ/matrix-client-opts.spec.ts +++ b/spec/integ/matrix-client-opts.spec.ts @@ -5,7 +5,6 @@ import { MatrixClient } from "../../src/matrix"; import { MatrixScheduler } from "../../src/scheduler"; import { MemoryStore } from "../../src/store/memory"; import { MatrixError } from "../../src/http-api"; -import { ICreateClientOpts } from "../../src/client"; import { IStore } from "../../src/store"; describe("MatrixClient opts", function() { @@ -69,7 +68,7 @@ describe("MatrixClient opts", function() { let client; beforeEach(function() { client = new MatrixClient({ - request: httpBackend.requestFn as unknown as ICreateClientOpts['request'], + fetchFn: httpBackend.fetchFn as typeof global.fetch, store: undefined, baseUrl: baseUrl, userId: userId, @@ -129,7 +128,7 @@ describe("MatrixClient opts", function() { let client; beforeEach(function() { client = new MatrixClient({ - request: httpBackend.requestFn as unknown as ICreateClientOpts['request'], + fetchFn: httpBackend.fetchFn as typeof global.fetch, store: new MemoryStore() as IStore, baseUrl: baseUrl, userId: userId, @@ -143,7 +142,7 @@ describe("MatrixClient opts", function() { }); it("shouldn't retry sending events", function(done) { - httpBackend.when("PUT", "/txn1").fail(500, new MatrixError({ + httpBackend.when("PUT", "/txn1").respond(500, new MatrixError({ errcode: "M_SOMETHING", error: "Ruh roh", })); diff --git a/spec/integ/matrix-client-room-timeline.spec.ts b/spec/integ/matrix-client-room-timeline.spec.ts index 48ecee32b4b..42d90d91c73 100644 --- a/spec/integ/matrix-client-room-timeline.spec.ts +++ b/spec/integ/matrix-client-room-timeline.spec.ts @@ -18,7 +18,7 @@ import HttpBackend from "matrix-mock-request"; import * as utils from "../test-utils/test-utils"; import { EventStatus } from "../../src/models/event"; -import { ClientEvent, IEvent, MatrixClient, RoomEvent } from "../../src"; +import { MatrixError, ClientEvent, IEvent, MatrixClient, RoomEvent } from "../../src"; import { TestClient } from "../TestClient"; describe("MatrixClient room timelines", function() { @@ -802,17 +802,14 @@ describe("MatrixClient room timelines", function() { it('Timeline recovers after `/context` request to generate new timeline fails', async () => { // `/context` request for `refreshLiveTimeline()` -> `getEventTimeline()` // to construct a new timeline from. - httpBackend!.when("GET", contextUrl) - .respond(500, function() { - // The timeline should be cleared at this point in the refresh - expect(room.timeline.length).toEqual(0); - - return { - errcode: 'TEST_FAKE_ERROR', - error: 'We purposely intercepted this /context request to make it fail ' + - 'in order to test whether the refresh timeline code is resilient', - }; - }); + httpBackend!.when("GET", contextUrl).check(() => { + // The timeline should be cleared at this point in the refresh + expect(room.timeline.length).toEqual(0); + }).respond(500, new MatrixError({ + errcode: 'TEST_FAKE_ERROR', + error: 'We purposely intercepted this /context request to make it fail ' + + 'in order to test whether the refresh timeline code is resilient', + })); // Refresh the timeline and expect it to fail const settledFailedRefreshPromises = await Promise.allSettled([ diff --git a/spec/integ/matrix-client-syncing.spec.ts b/spec/integ/matrix-client-syncing.spec.ts index 78795051cc8..fd8fd7b7d18 100644 --- a/spec/integ/matrix-client-syncing.spec.ts +++ b/spec/integ/matrix-client-syncing.spec.ts @@ -1572,7 +1572,7 @@ describe("MatrixClient syncing (IndexedDB version)", () => { const idbHttpBackend = idbTestClient.httpBackend; const idbClient = idbTestClient.client; idbHttpBackend.when("GET", "/versions").respond(200, {}); - idbHttpBackend.when("GET", "/pushrules").respond(200, {}); + idbHttpBackend.when("GET", "/pushrules/").respond(200, {}); idbHttpBackend.when("POST", "/filter").respond(200, { filter_id: "a filter id" }); await idbClient.initCrypto(); diff --git a/spec/integ/sliding-sync-sdk.spec.ts b/spec/integ/sliding-sync-sdk.spec.ts index 47e6ba8dfb0..3e50064a6d3 100644 --- a/spec/integ/sliding-sync-sdk.spec.ts +++ b/spec/integ/sliding-sync-sdk.spec.ts @@ -23,12 +23,13 @@ import { TestClient } from "../TestClient"; import { IRoomEvent, IStateEvent } from "../../src/sync-accumulator"; import { MatrixClient, MatrixEvent, NotificationCountType, JoinRule, MatrixError, - EventType, IPushRules, PushRuleKind, TweakName, ClientEvent, + EventType, IPushRules, PushRuleKind, TweakName, ClientEvent, RoomMemberEvent, } from "../../src"; import { SlidingSyncSdk } from "../../src/sliding-sync-sdk"; import { SyncState } from "../../src/sync"; import { IStoredClientOpts } from "../../src/client"; import { logger } from "../../src/logger"; +import { emitPromise } from "../test-utils/test-utils"; describe("SlidingSyncSdk", () => { let client: MatrixClient | undefined; @@ -530,6 +531,7 @@ describe("SlidingSyncSdk", () => { ], }); await httpBackend!.flush("/profile", 1, 1000); + await emitPromise(client!, RoomMemberEvent.Name); const room = client!.getRoom(roomId)!; expect(room).toBeDefined(); const inviteeMember = room.getMember(invitee)!; diff --git a/spec/setupTests.ts b/spec/setupTests.ts new file mode 100644 index 00000000000..bbd70fe3d97 --- /dev/null +++ b/spec/setupTests.ts @@ -0,0 +1,19 @@ +/* +Copyright 2022 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 DOMException from "domexception"; + +global.DOMException = DOMException; diff --git a/spec/unit/autodiscovery.spec.ts b/spec/unit/autodiscovery.spec.ts index 939f477977a..13688c25b25 100644 --- a/spec/unit/autodiscovery.spec.ts +++ b/spec/unit/autodiscovery.spec.ts @@ -17,13 +17,12 @@ limitations under the License. import MockHttpBackend from "matrix-mock-request"; -import { request } from "../../src/matrix"; import { AutoDiscovery } from "../../src/autodiscovery"; describe("AutoDiscovery", function() { const getHttpBackend = (): MockHttpBackend => { const httpBackend = new MockHttpBackend(); - request(httpBackend.requestFn); + AutoDiscovery.setFetchFn(httpBackend.fetchFn as typeof global.fetch); return httpBackend; }; @@ -176,8 +175,7 @@ describe("AutoDiscovery", function() { ]); }); - it("should return FAIL_PROMPT when .well-known does not have a base_url for " + - "m.homeserver (empty string)", function() { + it("should return FAIL_PROMPT when .well-known does not have a base_url for m.homeserver (empty string)", () => { const httpBackend = getHttpBackend(); httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { "m.homeserver": { @@ -205,8 +203,7 @@ describe("AutoDiscovery", function() { ]); }); - it("should return FAIL_PROMPT when .well-known does not have a base_url for " + - "m.homeserver (no property)", function() { + it("should return FAIL_PROMPT when .well-known does not have a base_url for m.homeserver (no property)", () => { const httpBackend = getHttpBackend(); httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { "m.homeserver": {}, @@ -232,8 +229,7 @@ describe("AutoDiscovery", function() { ]); }); - it("should return FAIL_ERROR when .well-known has an invalid base_url for " + - "m.homeserver (disallowed scheme)", function() { + it("should return FAIL_ERROR when .well-known has an invalid base_url for m.homeserver (disallowed scheme)", () => { const httpBackend = getHttpBackend(); httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { "m.homeserver": { @@ -679,4 +675,76 @@ describe("AutoDiscovery", function() { }), ]); }); + + it("should return FAIL_PROMPT for connection errors", () => { + const httpBackend = getHttpBackend(); + httpBackend.when("GET", "/.well-known/matrix/client").fail(0, undefined); + return Promise.all([ + httpBackend.flushAllExpected(), + AutoDiscovery.findClientConfig("example.org").then((conf) => { + const expected = { + "m.homeserver": { + state: "FAIL_PROMPT", + error: AutoDiscovery.ERROR_INVALID, + base_url: null, + }, + "m.identity_server": { + state: "PROMPT", + error: null, + base_url: null, + }, + }; + + expect(conf).toEqual(expected); + }), + ]); + }); + + it("should return FAIL_PROMPT for fetch errors", () => { + const httpBackend = getHttpBackend(); + httpBackend.when("GET", "/.well-known/matrix/client").fail(0, new Error("CORS or something")); + return Promise.all([ + httpBackend.flushAllExpected(), + AutoDiscovery.findClientConfig("example.org").then((conf) => { + const expected = { + "m.homeserver": { + state: "FAIL_PROMPT", + error: AutoDiscovery.ERROR_INVALID, + base_url: null, + }, + "m.identity_server": { + state: "PROMPT", + error: null, + base_url: null, + }, + }; + + expect(conf).toEqual(expected); + }), + ]); + }); + + it("should return FAIL_PROMPT for invalid JSON", () => { + const httpBackend = getHttpBackend(); + httpBackend.when("GET", "/.well-known/matrix/client").respond(200, "", true); + return Promise.all([ + httpBackend.flushAllExpected(), + AutoDiscovery.findClientConfig("example.org").then((conf) => { + const expected = { + "m.homeserver": { + state: "FAIL_PROMPT", + error: AutoDiscovery.ERROR_INVALID, + base_url: null, + }, + "m.identity_server": { + state: "PROMPT", + error: null, + base_url: null, + }, + }; + + expect(conf).toEqual(expected); + }), + ]); + }); }); diff --git a/spec/unit/crypto/backup.spec.ts b/spec/unit/crypto/backup.spec.ts index acec47fcee2..ca4c09c532c 100644 --- a/spec/unit/crypto/backup.spec.ts +++ b/spec/unit/crypto/backup.spec.ts @@ -30,7 +30,7 @@ import { Crypto } from "../../../src/crypto"; import { resetCrossSigningKeys } from "./crypto-utils"; import { BackupManager } from "../../../src/crypto/backup"; import { StubStore } from "../../../src/store/stub"; -import { IAbortablePromise, MatrixScheduler } from '../../../src'; +import { MatrixScheduler } from '../../../src'; const Olm = global.Olm; @@ -131,7 +131,7 @@ function makeTestClient(cryptoStore) { baseUrl: "https://my.home.server", idBaseUrl: "https://identity.server", accessToken: "my.access.token", - request: jest.fn(), // NOP + fetchFn: jest.fn(), // NOP store: store, scheduler: scheduler, userId: "@alice:bar", @@ -197,7 +197,7 @@ describe("MegolmBackup", function() { // to tick the clock between the first try and the retry. const realSetTimeout = global.setTimeout; jest.spyOn(global, 'setTimeout').mockImplementation(function(f, n) { - return realSetTimeout(f, n/100); + return realSetTimeout(f!, n/100); }); }); @@ -298,25 +298,25 @@ describe("MegolmBackup", function() { }); let numCalls = 0; return new Promise((resolve, reject) => { - client.http.authedRequest = function( - callback, method, path, queryParams, data, opts, - ) { + client.http.authedRequest = function( + method, path, queryParams, data, opts, + ): Promise { ++numCalls; expect(numCalls).toBeLessThanOrEqual(1); if (numCalls >= 2) { // exit out of retry loop if there's something wrong reject(new Error("authedRequest called too many timmes")); - return Promise.resolve({}) as IAbortablePromise; + return Promise.resolve({} as T); } expect(method).toBe("PUT"); expect(path).toBe("/room_keys/keys"); expect(queryParams.version).toBe('1'); - expect(data.rooms[ROOM_ID].sessions).toBeDefined(); - expect(data.rooms[ROOM_ID].sessions).toHaveProperty( + expect((data as Record).rooms[ROOM_ID].sessions).toBeDefined(); + expect((data as Record).rooms[ROOM_ID].sessions).toHaveProperty( groupSession.session_id(), ); resolve(); - return Promise.resolve({}) as IAbortablePromise; + return Promise.resolve({} as T); }; client.crypto.backupManager.backupGroupSession( "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", @@ -381,25 +381,25 @@ describe("MegolmBackup", function() { }); let numCalls = 0; return new Promise((resolve, reject) => { - client.http.authedRequest = function( - callback, method, path, queryParams, data, opts, - ) { + client.http.authedRequest = function( + method, path, queryParams, data, opts, + ): Promise { ++numCalls; expect(numCalls).toBeLessThanOrEqual(1); if (numCalls >= 2) { // exit out of retry loop if there's something wrong reject(new Error("authedRequest called too many timmes")); - return Promise.resolve({}) as IAbortablePromise; + return Promise.resolve({} as T); } expect(method).toBe("PUT"); expect(path).toBe("/room_keys/keys"); expect(queryParams.version).toBe('1'); - expect(data.rooms[ROOM_ID].sessions).toBeDefined(); - expect(data.rooms[ROOM_ID].sessions).toHaveProperty( + expect((data as Record).rooms[ROOM_ID].sessions).toBeDefined(); + expect((data as Record).rooms[ROOM_ID].sessions).toHaveProperty( groupSession.session_id(), ); resolve(); - return Promise.resolve({}) as IAbortablePromise; + return Promise.resolve({} as T); }; client.crypto.backupManager.backupGroupSession( "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", @@ -439,7 +439,7 @@ describe("MegolmBackup", function() { new Promise((resolve, reject) => { let backupInfo; client.http.authedRequest = function( - callback, method, path, queryParams, data, opts, + method, path, queryParams, data, opts, ) { ++numCalls; expect(numCalls).toBeLessThanOrEqual(2); @@ -449,23 +449,23 @@ describe("MegolmBackup", function() { try { // make sure auth_data is signed by the master key olmlib.pkVerify( - data.auth_data, client.getCrossSigningId(), "@alice:bar", + (data as Record).auth_data, client.getCrossSigningId(), "@alice:bar", ); } catch (e) { reject(e); - return Promise.resolve({}) as IAbortablePromise; + return Promise.resolve({}); } backupInfo = data; - return Promise.resolve({}) as IAbortablePromise; + return Promise.resolve({}); } else if (numCalls === 2) { expect(method).toBe("GET"); expect(path).toBe("/room_keys/version"); resolve(); - return Promise.resolve(backupInfo) as IAbortablePromise; + return Promise.resolve(backupInfo); } else { // exit out of retry loop if there's something wrong reject(new Error("authedRequest called too many times")); - return Promise.resolve({}) as IAbortablePromise; + return Promise.resolve({}); } }; }), @@ -495,7 +495,7 @@ describe("MegolmBackup", function() { baseUrl: "https://my.home.server", idBaseUrl: "https://identity.server", accessToken: "my.access.token", - request: jest.fn(), // NOP + fetchFn: jest.fn(), // NOP store: store, scheduler: scheduler, userId: "@alice:bar", @@ -542,30 +542,30 @@ describe("MegolmBackup", function() { let numCalls = 0; await new Promise((resolve, reject) => { - client.http.authedRequest = function( - callback, method, path, queryParams, data, opts, - ) { + client.http.authedRequest = function( + method, path, queryParams, data, opts, + ): Promise { ++numCalls; expect(numCalls).toBeLessThanOrEqual(2); if (numCalls >= 3) { // exit out of retry loop if there's something wrong reject(new Error("authedRequest called too many timmes")); - return Promise.resolve({}) as IAbortablePromise; + return Promise.resolve({} as T); } expect(method).toBe("PUT"); expect(path).toBe("/room_keys/keys"); expect(queryParams.version).toBe('1'); - expect(data.rooms[ROOM_ID].sessions).toBeDefined(); - expect(data.rooms[ROOM_ID].sessions).toHaveProperty( + expect((data as Record).rooms[ROOM_ID].sessions).toBeDefined(); + expect((data as Record).rooms[ROOM_ID].sessions).toHaveProperty( groupSession.session_id(), ); if (numCalls > 1) { resolve(); - return Promise.resolve({}) as IAbortablePromise; + return Promise.resolve({} as T); } else { return Promise.reject( new Error("this is an expected failure"), - ) as IAbortablePromise; + ); } }; return client.crypto.backupManager.backupGroupSession( diff --git a/spec/unit/crypto/cross-signing.spec.ts b/spec/unit/crypto/cross-signing.spec.ts index 30c1bf82ce3..bfa7625cbe8 100644 --- a/spec/unit/crypto/cross-signing.spec.ts +++ b/spec/unit/crypto/cross-signing.spec.ts @@ -141,7 +141,7 @@ describe("Cross Signing", function() { }; alice.uploadKeySignatures = async () => ({ failures: {} }); alice.setAccountData = async () => ({}); - alice.getAccountDataFromServer = async (): Promise => ({} as T); + alice.getAccountDataFromServer = async (): Promise => ({} as T); const authUploadDeviceSigningKeys = async func => await func({}); // Try bootstrap, expecting `authUploadDeviceSigningKeys` to pass diff --git a/spec/unit/crypto/secrets.spec.ts b/spec/unit/crypto/secrets.spec.ts index d6ae8c3a363..7692292fff2 100644 --- a/spec/unit/crypto/secrets.spec.ts +++ b/spec/unit/crypto/secrets.spec.ts @@ -109,16 +109,13 @@ describe("Secrets", function() { const secretStorage = alice.crypto.secretStorage; jest.spyOn(alice, 'setAccountData').mockImplementation( - async function(eventType, contents, callback) { + async function(eventType, contents) { alice.store.storeAccountDataEvents([ new MatrixEvent({ type: eventType, content: contents, }), ]); - if (callback) { - callback(undefined, undefined); - } return {}; }); @@ -192,7 +189,7 @@ describe("Secrets", function() { }, }, ); - alice.setAccountData = async function(eventType, contents, callback) { + alice.setAccountData = async function(eventType, contents) { alice.store.storeAccountDataEvents([ new MatrixEvent({ type: eventType, @@ -332,7 +329,7 @@ describe("Secrets", function() { ); bob.uploadDeviceSigningKeys = async () => ({}); bob.uploadKeySignatures = jest.fn().mockResolvedValue(undefined); - bob.setAccountData = async function(eventType, contents, callback) { + bob.setAccountData = async function(eventType, contents) { const event = new MatrixEvent({ type: eventType, content: contents, diff --git a/spec/unit/event-mapper.spec.ts b/spec/unit/event-mapper.spec.ts index a444c34fb0c..c21348c80e4 100644 --- a/spec/unit/event-mapper.spec.ts +++ b/spec/unit/event-mapper.spec.ts @@ -29,7 +29,7 @@ describe("eventMapperFor", function() { client = new MatrixClient({ baseUrl: "https://my.home.server", accessToken: "my.access.token", - request: function() {} as any, // NOP + fetchFn: function() {} as any, // NOP store: { getRoom(roomId: string): Room | null { return rooms.find(r => r.roomId === roomId); diff --git a/spec/unit/http-api/__snapshots__/index.spec.ts.snap b/spec/unit/http-api/__snapshots__/index.spec.ts.snap new file mode 100644 index 00000000000..e6487ddeb0f --- /dev/null +++ b/spec/unit/http-api/__snapshots__/index.spec.ts.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MatrixHttpApi should return expected object from \`getContentUri\` 1`] = ` +{ + "base": "http://baseUrl", + "params": { + "access_token": "token", + }, + "path": "/_matrix/media/r0/upload", +} +`; diff --git a/spec/unit/http-api/fetch.spec.ts b/spec/unit/http-api/fetch.spec.ts new file mode 100644 index 00000000000..e100f2d9387 --- /dev/null +++ b/spec/unit/http-api/fetch.spec.ts @@ -0,0 +1,223 @@ +/* +Copyright 2022 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 { FetchHttpApi } from "../../../src/http-api/fetch"; +import { TypedEventEmitter } from "../../../src/models/typed-event-emitter"; +import { ClientPrefix, HttpApiEvent, HttpApiEventHandlerMap, IdentityPrefix, IHttpOpts, Method } from "../../../src"; +import { emitPromise } from "../../test-utils/test-utils"; + +describe("FetchHttpApi", () => { + const baseUrl = "http://baseUrl"; + const idBaseUrl = "http://idBaseUrl"; + const prefix = ClientPrefix.V3; + + it("should support aborting multiple times", () => { + const fetchFn = jest.fn().mockResolvedValue({ ok: true }); + const api = new FetchHttpApi(new TypedEventEmitter(), { baseUrl, prefix, fetchFn }); + + api.request(Method.Get, "/foo"); + api.request(Method.Get, "/baz"); + expect(fetchFn.mock.calls[0][0].href.endsWith("/foo")).toBeTruthy(); + expect(fetchFn.mock.calls[0][1].signal.aborted).toBeFalsy(); + expect(fetchFn.mock.calls[1][0].href.endsWith("/baz")).toBeTruthy(); + expect(fetchFn.mock.calls[1][1].signal.aborted).toBeFalsy(); + + api.abort(); + expect(fetchFn.mock.calls[0][1].signal.aborted).toBeTruthy(); + expect(fetchFn.mock.calls[1][1].signal.aborted).toBeTruthy(); + + api.request(Method.Get, "/bar"); + expect(fetchFn.mock.calls[2][0].href.endsWith("/bar")).toBeTruthy(); + expect(fetchFn.mock.calls[2][1].signal.aborted).toBeFalsy(); + + api.abort(); + expect(fetchFn.mock.calls[2][1].signal.aborted).toBeTruthy(); + }); + + it("should fall back to global fetch if fetchFn not provided", () => { + global.fetch = jest.fn(); + expect(global.fetch).not.toHaveBeenCalled(); + const api = new FetchHttpApi(new TypedEventEmitter(), { baseUrl, prefix }); + api.fetch("test"); + expect(global.fetch).toHaveBeenCalled(); + }); + + it("should update identity server base url", () => { + const api = new FetchHttpApi(new TypedEventEmitter(), { baseUrl, prefix }); + expect(api.opts.idBaseUrl).toBeUndefined(); + api.setIdBaseUrl("https://id.foo.bar"); + expect(api.opts.idBaseUrl).toBe("https://id.foo.bar"); + }); + + describe("idServerRequest", () => { + it("should throw if no idBaseUrl", () => { + const api = new FetchHttpApi(new TypedEventEmitter(), { baseUrl, prefix }); + expect(() => api.idServerRequest(Method.Get, "/test", {}, IdentityPrefix.V2)) + .toThrow("No identity server base URL set"); + }); + + it("should send params as query string for GET requests", () => { + const fetchFn = jest.fn().mockResolvedValue({ ok: true }); + const api = new FetchHttpApi(new TypedEventEmitter(), { baseUrl, idBaseUrl, prefix, fetchFn }); + api.idServerRequest(Method.Get, "/test", { foo: "bar", via: ["a", "b"] }, IdentityPrefix.V2); + expect(fetchFn.mock.calls[0][0].searchParams.get("foo")).toBe("bar"); + expect(fetchFn.mock.calls[0][0].searchParams.getAll("via")).toEqual(["a", "b"]); + }); + + it("should send params as body for non-GET requests", () => { + const fetchFn = jest.fn().mockResolvedValue({ ok: true }); + const api = new FetchHttpApi(new TypedEventEmitter(), { baseUrl, idBaseUrl, prefix, fetchFn }); + const params = { foo: "bar", via: ["a", "b"] }; + api.idServerRequest(Method.Post, "/test", params, IdentityPrefix.V2); + expect(fetchFn.mock.calls[0][0].searchParams.get("foo")).not.toBe("bar"); + expect(JSON.parse(fetchFn.mock.calls[0][1].body)).toStrictEqual(params); + }); + + it("should add Authorization header if token provided", () => { + const fetchFn = jest.fn().mockResolvedValue({ ok: true }); + const api = new FetchHttpApi(new TypedEventEmitter(), { baseUrl, idBaseUrl, prefix, fetchFn }); + api.idServerRequest(Method.Post, "/test", {}, IdentityPrefix.V2, "token"); + expect(fetchFn.mock.calls[0][1].headers.Authorization).toBe("Bearer token"); + }); + }); + + it("should return the Response object if onlyData=false", async () => { + const res = { ok: true }; + const fetchFn = jest.fn().mockResolvedValue(res); + const api = new FetchHttpApi(new TypedEventEmitter(), { baseUrl, prefix, fetchFn, onlyData: false }); + await expect(api.requestOtherUrl(Method.Get, "http://url")).resolves.toBe(res); + }); + + it("should return text if json=false", async () => { + const text = "418 I'm a teapot"; + const fetchFn = jest.fn().mockResolvedValue({ ok: true, text: jest.fn().mockResolvedValue(text) }); + const api = new FetchHttpApi(new TypedEventEmitter(), { baseUrl, prefix, fetchFn, onlyData: true }); + await expect(api.requestOtherUrl(Method.Get, "http://url", undefined, { + json: false, + })).resolves.toBe(text); + }); + + it("should send token via query params if useAuthorizationHeader=false", () => { + const fetchFn = jest.fn().mockResolvedValue({ ok: true }); + const api = new FetchHttpApi(new TypedEventEmitter(), { + baseUrl, + prefix, + fetchFn, + accessToken: "token", + useAuthorizationHeader: false, + }); + api.authedRequest(Method.Get, "/path"); + expect(fetchFn.mock.calls[0][0].searchParams.get("access_token")).toBe("token"); + }); + + it("should send token via headers by default", () => { + const fetchFn = jest.fn().mockResolvedValue({ ok: true }); + const api = new FetchHttpApi(new TypedEventEmitter(), { + baseUrl, + prefix, + fetchFn, + accessToken: "token", + }); + api.authedRequest(Method.Get, "/path"); + expect(fetchFn.mock.calls[0][1].headers["Authorization"]).toBe("Bearer token"); + }); + + it("should not send a token if not calling `authedRequest`", () => { + const fetchFn = jest.fn().mockResolvedValue({ ok: true }); + const api = new FetchHttpApi(new TypedEventEmitter(), { + baseUrl, + prefix, + fetchFn, + accessToken: "token", + }); + api.request(Method.Get, "/path"); + expect(fetchFn.mock.calls[0][0].searchParams.get("access_token")).toBeFalsy(); + expect(fetchFn.mock.calls[0][1].headers["Authorization"]).toBeFalsy(); + }); + + it("should ensure no token is leaked out via query params if sending via headers", () => { + const fetchFn = jest.fn().mockResolvedValue({ ok: true }); + const api = new FetchHttpApi(new TypedEventEmitter(), { + baseUrl, + prefix, + fetchFn, + accessToken: "token", + useAuthorizationHeader: true, + }); + api.authedRequest(Method.Get, "/path", { access_token: "123" }); + expect(fetchFn.mock.calls[0][0].searchParams.get("access_token")).toBeFalsy(); + expect(fetchFn.mock.calls[0][1].headers["Authorization"]).toBe("Bearer token"); + }); + + it("should not override manually specified access token via query params", () => { + const fetchFn = jest.fn().mockResolvedValue({ ok: true }); + const api = new FetchHttpApi(new TypedEventEmitter(), { + baseUrl, + prefix, + fetchFn, + accessToken: "token", + useAuthorizationHeader: false, + }); + api.authedRequest(Method.Get, "/path", { access_token: "RealToken" }); + expect(fetchFn.mock.calls[0][0].searchParams.get("access_token")).toBe("RealToken"); + }); + + it("should not override manually specified access token via header", () => { + const fetchFn = jest.fn().mockResolvedValue({ ok: true }); + const api = new FetchHttpApi(new TypedEventEmitter(), { + baseUrl, + prefix, + fetchFn, + accessToken: "token", + useAuthorizationHeader: true, + }); + api.authedRequest(Method.Get, "/path", undefined, undefined, { + headers: { Authorization: "Bearer RealToken" }, + }); + expect(fetchFn.mock.calls[0][1].headers["Authorization"]).toBe("Bearer RealToken"); + }); + + it("should not override Accept header", () => { + const fetchFn = jest.fn().mockResolvedValue({ ok: true }); + const api = new FetchHttpApi(new TypedEventEmitter(), { baseUrl, prefix, fetchFn }); + api.authedRequest(Method.Get, "/path", undefined, undefined, { + headers: { Accept: "text/html" }, + }); + expect(fetchFn.mock.calls[0][1].headers["Accept"]).toBe("text/html"); + }); + + it("should emit NoConsent when given errcode=M_CONTENT_NOT_GIVEN", async () => { + const fetchFn = jest.fn().mockResolvedValue({ + ok: false, + headers: { + get(name: string): string | null { + return name === "Content-Type" ? "application/json" : null; + }, + }, + text: jest.fn().mockResolvedValue(JSON.stringify({ + errcode: "M_CONSENT_NOT_GIVEN", + error: "Ye shall ask for consent", + })), + }); + const emitter = new TypedEventEmitter(); + const api = new FetchHttpApi(emitter, { baseUrl, prefix, fetchFn }); + + await Promise.all([ + emitPromise(emitter, HttpApiEvent.NoConsent), + expect(api.authedRequest(Method.Get, "/path")).rejects.toThrow("Ye shall ask for consent"), + ]); + }); +}); diff --git a/spec/unit/http-api/index.spec.ts b/spec/unit/http-api/index.spec.ts new file mode 100644 index 00000000000..89e122452f6 --- /dev/null +++ b/spec/unit/http-api/index.spec.ts @@ -0,0 +1,228 @@ +/* +Copyright 2022 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 DOMException from "domexception"; +import { mocked } from "jest-mock"; + +import { ClientPrefix, MatrixHttpApi, Method, UploadResponse } from "../../../src"; +import { TypedEventEmitter } from "../../../src/models/typed-event-emitter"; + +type Writeable = { -readonly [P in keyof T]: T[P] }; + +jest.useFakeTimers(); + +describe("MatrixHttpApi", () => { + const baseUrl = "http://baseUrl"; + const prefix = ClientPrefix.V3; + + let xhr: Partial>; + let upload: Promise; + + const DONE = 0; + + global.DOMException = DOMException; + + beforeEach(() => { + xhr = { + upload: {} as XMLHttpRequestUpload, + open: jest.fn(), + send: jest.fn(), + abort: jest.fn(), + setRequestHeader: jest.fn(), + onreadystatechange: undefined, + getResponseHeader: jest.fn(), + }; + // We stub out XHR here as it is not available in JSDOM + // @ts-ignore + global.XMLHttpRequest = jest.fn().mockReturnValue(xhr); + // @ts-ignore + global.XMLHttpRequest.DONE = DONE; + }); + + afterEach(() => { + upload?.catch(() => {}); + // Abort any remaining requests + xhr.readyState = DONE; + xhr.status = 0; + // @ts-ignore + xhr.onreadystatechange?.(new Event("test")); + }); + + it("should fall back to `fetch` where xhr is unavailable", () => { + global.XMLHttpRequest = undefined; + const fetchFn = jest.fn().mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue({}) }); + const api = new MatrixHttpApi(new TypedEventEmitter(), { baseUrl, prefix, fetchFn }); + upload = api.uploadContent({} as File); + expect(fetchFn).toHaveBeenCalled(); + }); + + it("should prefer xhr where available", () => { + const fetchFn = jest.fn().mockResolvedValue({ ok: true }); + const api = new MatrixHttpApi(new TypedEventEmitter(), { baseUrl, prefix, fetchFn }); + upload = api.uploadContent({} as File); + expect(fetchFn).not.toHaveBeenCalled(); + expect(xhr.open).toHaveBeenCalled(); + }); + + it("should send access token in query params if header disabled", () => { + const api = new MatrixHttpApi(new TypedEventEmitter(), { + baseUrl, + prefix, + accessToken: "token", + useAuthorizationHeader: false, + }); + upload = api.uploadContent({} as File); + expect(xhr.open) + .toHaveBeenCalledWith(Method.Post, baseUrl.toLowerCase() + "/_matrix/media/r0/upload?access_token=token"); + expect(xhr.setRequestHeader).not.toHaveBeenCalledWith("Authorization"); + }); + + it("should send access token in header by default", () => { + const api = new MatrixHttpApi(new TypedEventEmitter(), { + baseUrl, + prefix, + accessToken: "token", + }); + upload = api.uploadContent({} as File); + expect(xhr.open).toHaveBeenCalledWith(Method.Post, baseUrl.toLowerCase() + "/_matrix/media/r0/upload"); + expect(xhr.setRequestHeader).toHaveBeenCalledWith("Authorization", "Bearer token"); + }); + + it("should include filename by default", () => { + const api = new MatrixHttpApi(new TypedEventEmitter(), { baseUrl, prefix }); + upload = api.uploadContent({} as File, { name: "name" }); + expect(xhr.open) + .toHaveBeenCalledWith(Method.Post, baseUrl.toLowerCase() + "/_matrix/media/r0/upload?filename=name"); + }); + + it("should allow not sending the filename", () => { + const api = new MatrixHttpApi(new TypedEventEmitter(), { baseUrl, prefix }); + upload = api.uploadContent({} as File, { name: "name", includeFilename: false }); + expect(xhr.open).toHaveBeenCalledWith(Method.Post, baseUrl.toLowerCase() + "/_matrix/media/r0/upload"); + }); + + it("should abort xhr when the upload is aborted", () => { + const api = new MatrixHttpApi(new TypedEventEmitter(), { baseUrl, prefix }); + upload = api.uploadContent({} as File); + api.cancelUpload(upload); + expect(xhr.abort).toHaveBeenCalled(); + return expect(upload).rejects.toThrow("Aborted"); + }); + + it("should timeout if no progress in 30s", () => { + const api = new MatrixHttpApi(new TypedEventEmitter(), { baseUrl, prefix }); + upload = api.uploadContent({} as File); + jest.advanceTimersByTime(25000); + // @ts-ignore + xhr.upload.onprogress(new Event("progress", { loaded: 1, total: 100 })); + jest.advanceTimersByTime(25000); + expect(xhr.abort).not.toHaveBeenCalled(); + jest.advanceTimersByTime(5000); + expect(xhr.abort).toHaveBeenCalled(); + }); + + it("should call progressHandler", () => { + const api = new MatrixHttpApi(new TypedEventEmitter(), { baseUrl, prefix }); + const progressHandler = jest.fn(); + upload = api.uploadContent({} as File, { progressHandler }); + const progressEvent = new Event("progress") as ProgressEvent; + Object.assign(progressEvent, { loaded: 1, total: 100 }); + // @ts-ignore + xhr.upload.onprogress(progressEvent); + expect(progressHandler).toHaveBeenCalledWith({ loaded: 1, total: 100 }); + + Object.assign(progressEvent, { loaded: 95, total: 100 }); + // @ts-ignore + xhr.upload.onprogress(progressEvent); + expect(progressHandler).toHaveBeenCalledWith({ loaded: 95, total: 100 }); + }); + + it("should error when no response body", () => { + const api = new MatrixHttpApi(new TypedEventEmitter(), { baseUrl, prefix }); + upload = api.uploadContent({} as File); + + xhr.readyState = DONE; + xhr.responseText = ""; + xhr.status = 200; + // @ts-ignore + xhr.onreadystatechange?.(new Event("test")); + + return expect(upload).rejects.toThrow("No response body."); + }); + + it("should error on a 400-code", () => { + const api = new MatrixHttpApi(new TypedEventEmitter(), { baseUrl, prefix }); + upload = api.uploadContent({} as File); + + xhr.readyState = DONE; + xhr.responseText = '{"errcode": "M_NOT_FOUND", "error": "Not found"}'; + xhr.status = 404; + mocked(xhr.getResponseHeader).mockReturnValue("application/json"); + // @ts-ignore + xhr.onreadystatechange?.(new Event("test")); + + return expect(upload).rejects.toThrow("Not found"); + }); + + it("should return response on successful upload", () => { + const api = new MatrixHttpApi(new TypedEventEmitter(), { baseUrl, prefix }); + upload = api.uploadContent({} as File); + + xhr.readyState = DONE; + xhr.responseText = '{"content_uri": "mxc://server/foobar"}'; + xhr.status = 200; + mocked(xhr.getResponseHeader).mockReturnValue("application/json"); + // @ts-ignore + xhr.onreadystatechange?.(new Event("test")); + + return expect(upload).resolves.toStrictEqual({ content_uri: "mxc://server/foobar" }); + }); + + it("should abort xhr when calling `cancelUpload`", () => { + const api = new MatrixHttpApi(new TypedEventEmitter(), { baseUrl, prefix }); + upload = api.uploadContent({} as File); + expect(api.cancelUpload(upload)).toBeTruthy(); + expect(xhr.abort).toHaveBeenCalled(); + }); + + it("should return false when `cancelUpload` is called but unsuccessful", async () => { + const api = new MatrixHttpApi(new TypedEventEmitter(), { baseUrl, prefix }); + upload = api.uploadContent({} as File); + + xhr.readyState = DONE; + xhr.status = 500; + mocked(xhr.getResponseHeader).mockReturnValue("application/json"); + // @ts-ignore + xhr.onreadystatechange?.(new Event("test")); + await upload.catch(() => {}); + + expect(api.cancelUpload(upload)).toBeFalsy(); + expect(xhr.abort).not.toHaveBeenCalled(); + }); + + it("should return active uploads in `getCurrentUploads`", () => { + const api = new MatrixHttpApi(new TypedEventEmitter(), { baseUrl, prefix }); + upload = api.uploadContent({} as File); + expect(api.getCurrentUploads().find(u => u.promise === upload)).toBeTruthy(); + api.cancelUpload(upload); + expect(api.getCurrentUploads().find(u => u.promise === upload)).toBeFalsy(); + }); + + it("should return expected object from `getContentUri`", () => { + const api = new MatrixHttpApi(new TypedEventEmitter(), { baseUrl, prefix, accessToken: "token" }); + expect(api.getContentUri()).toMatchSnapshot(); + }); +}); diff --git a/spec/unit/http-api/utils.spec.ts b/spec/unit/http-api/utils.spec.ts new file mode 100644 index 00000000000..1a266c8423c --- /dev/null +++ b/spec/unit/http-api/utils.spec.ts @@ -0,0 +1,183 @@ +/* +Copyright 2022 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 { mocked } from "jest-mock"; + +import { + anySignal, + ConnectionError, + MatrixError, + parseErrorResponse, + retryNetworkOperation, + timeoutSignal, +} from "../../../src"; +import { sleep } from "../../../src/utils"; + +jest.mock("../../../src/utils"); + +describe("timeoutSignal", () => { + jest.useFakeTimers(); + + it("should fire abort signal after specified timeout", () => { + const signal = timeoutSignal(3000); + const onabort = jest.fn(); + signal.onabort = onabort; + expect(signal.aborted).toBeFalsy(); + expect(onabort).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(3000); + expect(signal.aborted).toBeTruthy(); + expect(onabort).toHaveBeenCalled(); + }); +}); + +describe("anySignal", () => { + jest.useFakeTimers(); + + it("should fire when any signal fires", () => { + const { signal } = anySignal([ + timeoutSignal(3000), + timeoutSignal(2000), + ]); + + const onabort = jest.fn(); + signal.onabort = onabort; + expect(signal.aborted).toBeFalsy(); + expect(onabort).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(2000); + expect(signal.aborted).toBeTruthy(); + expect(onabort).toHaveBeenCalled(); + }); + + it("should cleanup when instructed", () => { + const { signal, cleanup } = anySignal([ + timeoutSignal(3000), + timeoutSignal(2000), + ]); + + const onabort = jest.fn(); + signal.onabort = onabort; + expect(signal.aborted).toBeFalsy(); + expect(onabort).not.toHaveBeenCalled(); + + cleanup(); + jest.advanceTimersByTime(2000); + expect(signal.aborted).toBeFalsy(); + expect(onabort).not.toHaveBeenCalled(); + }); + + it("should abort immediately if passed an aborted signal", () => { + const controller = new AbortController(); + controller.abort(); + const { signal } = anySignal([controller.signal]); + expect(signal.aborted).toBeTruthy(); + }); +}); + +describe("parseErrorResponse", () => { + it("should resolve Matrix Errors from XHR", () => { + expect(parseErrorResponse({ + getResponseHeader(name: string): string | null { + return name === "Content-Type" ? "application/json" : null; + }, + status: 500, + } as XMLHttpRequest, '{"errcode": "TEST"}')).toStrictEqual(new MatrixError({ + errcode: "TEST", + }, 500)); + }); + + it("should resolve Matrix Errors from fetch", () => { + expect(parseErrorResponse({ + headers: { + get(name: string): string | null { + return name === "Content-Type" ? "application/json" : null; + }, + }, + status: 500, + } as Response, '{"errcode": "TEST"}')).toStrictEqual(new MatrixError({ + errcode: "TEST", + }, 500)); + }); + + it("should handle no type gracefully", () => { + expect(parseErrorResponse({ + headers: { + get(name: string): string | null { + return null; + }, + }, + status: 500, + } as Response, '{"errcode": "TEST"}')).toStrictEqual(new Error("Server returned 500 error")); + }); + + it("should handle invalid type gracefully", () => { + expect(parseErrorResponse({ + headers: { + get(name: string): string | null { + return name === "Content-Type" ? " " : null; + }, + }, + status: 500, + } as Response, '{"errcode": "TEST"}')) + .toStrictEqual(new Error("Error parsing Content-Type ' ': TypeError: invalid media type")); + }); + + it("should handle plaintext errors", () => { + expect(parseErrorResponse({ + headers: { + get(name: string): string | null { + return name === "Content-Type" ? "text/plain" : null; + }, + }, + status: 418, + } as Response, "I'm a teapot")).toStrictEqual(new Error("Server returned 418 error: I'm a teapot")); + }); +}); + +describe("retryNetworkOperation", () => { + it("should retry given number of times with exponential sleeps", async () => { + const err = new ConnectionError("test"); + const fn = jest.fn().mockRejectedValue(err); + mocked(sleep).mockResolvedValue(undefined); + await expect(retryNetworkOperation(4, fn)).rejects.toThrow(err); + expect(fn).toHaveBeenCalledTimes(4); + expect(mocked(sleep)).toHaveBeenCalledTimes(3); + expect(mocked(sleep).mock.calls[0][0]).toBe(2000); + expect(mocked(sleep).mock.calls[1][0]).toBe(4000); + expect(mocked(sleep).mock.calls[2][0]).toBe(8000); + }); + + it("should bail out on errors other than ConnectionError", async () => { + const err = new TypeError("invalid JSON"); + const fn = jest.fn().mockRejectedValue(err); + mocked(sleep).mockResolvedValue(undefined); + await expect(retryNetworkOperation(3, fn)).rejects.toThrow(err); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("should return newest ConnectionError when giving up", async () => { + const err1 = new ConnectionError("test1"); + const err2 = new ConnectionError("test2"); + const err3 = new ConnectionError("test3"); + const errors = [err1, err2, err3]; + const fn = jest.fn().mockImplementation(() => { + throw errors.shift(); + }); + mocked(sleep).mockResolvedValue(undefined); + await expect(retryNetworkOperation(3, fn)).rejects.toThrow(err3); + }); +}); diff --git a/spec/unit/matrix-client.spec.ts b/spec/unit/matrix-client.spec.ts index 2b8faf5065a..458c05f203e 100644 --- a/spec/unit/matrix-client.spec.ts +++ b/spec/unit/matrix-client.spec.ts @@ -103,7 +103,7 @@ describe("MatrixClient", function() { ]; let acceptKeepalives: boolean; let pendingLookup = null; - function httpReq(cb, method, path, qp, data, prefix) { + function httpReq(method, path, qp, data, prefix) { if (path === KEEP_ALIVE_PATH && acceptKeepalives) { return Promise.resolve({ unstable_features: { @@ -132,7 +132,6 @@ describe("MatrixClient", function() { method: method, path: path, }; - pendingLookup.promise.abort = () => {}; // to make it a valid IAbortablePromise return pendingLookup.promise; } if (next.path === path && next.method === method) { @@ -178,7 +177,7 @@ describe("MatrixClient", function() { baseUrl: "https://my.home.server", idBaseUrl: identityServerUrl, accessToken: "my.access.token", - request: function() {} as any, // NOP + fetchFn: function() {} as any, // NOP store: store, scheduler: scheduler, userId: userId, @@ -1153,8 +1152,7 @@ describe("MatrixClient", function() { // event type combined const expectedEventType = M_BEACON_INFO.name; - const [callback, method, path, queryParams, requestContent] = client.http.authedRequest.mock.calls[0]; - expect(callback).toBeFalsy(); + const [method, path, queryParams, requestContent] = client.http.authedRequest.mock.calls[0]; expect(method).toBe('PUT'); expect(path).toEqual( `/rooms/${encodeURIComponent(roomId)}/state/` + @@ -1168,7 +1166,7 @@ describe("MatrixClient", function() { await client.unstable_setLiveBeacon(roomId, content); // event type combined - const [, , path, , requestContent] = client.http.authedRequest.mock.calls[0]; + const [, path, , requestContent] = client.http.authedRequest.mock.calls[0]; expect(path).toEqual( `/rooms/${encodeURIComponent(roomId)}/state/` + `${encodeURIComponent(M_BEACON_INFO.name)}/${encodeURIComponent(userId)}`, @@ -1229,7 +1227,7 @@ describe("MatrixClient", function() { it("is called with plain text topic and callback and sends state event", async () => { const sendStateEvent = createSendStateEventMock("pizza"); client.sendStateEvent = sendStateEvent; - await client.setRoomTopic(roomId, "pizza", () => {}); + await client.setRoomTopic(roomId, "pizza"); expect(sendStateEvent).toHaveBeenCalledTimes(1); }); @@ -1244,15 +1242,9 @@ describe("MatrixClient", function() { describe("setPassword", () => { const auth = { session: 'abcdef', type: 'foo' }; const newPassword = 'newpassword'; - const callback = () => {}; - - const passwordTest = (expectedRequestContent: any, expectedCallback?: Function) => { - const [callback, method, path, queryParams, requestContent] = client.http.authedRequest.mock.calls[0]; - if (expectedCallback) { - expect(callback).toBe(expectedCallback); - } else { - expect(callback).toBeFalsy(); - } + + const passwordTest = (expectedRequestContent: any) => { + const [method, path, queryParams, requestContent] = client.http.authedRequest.mock.calls[0]; expect(method).toBe('POST'); expect(path).toEqual('/account/password'); expect(queryParams).toBeFalsy(); @@ -1269,8 +1261,8 @@ describe("MatrixClient", function() { }); it("no logout_devices specified + callback", async () => { - await client.setPassword(auth, newPassword, callback); - passwordTest({ auth, new_password: newPassword }, callback); + await client.setPassword(auth, newPassword); + passwordTest({ auth, new_password: newPassword }); }); it("overload logoutDevices=true", async () => { @@ -1279,8 +1271,8 @@ describe("MatrixClient", function() { }); it("overload logoutDevices=true + callback", async () => { - await client.setPassword(auth, newPassword, true, callback); - passwordTest({ auth, new_password: newPassword, logout_devices: true }, callback); + await client.setPassword(auth, newPassword, true); + passwordTest({ auth, new_password: newPassword, logout_devices: true }); }); it("overload logoutDevices=false", async () => { @@ -1289,8 +1281,8 @@ describe("MatrixClient", function() { }); it("overload logoutDevices=false + callback", async () => { - await client.setPassword(auth, newPassword, false, callback); - passwordTest({ auth, new_password: newPassword, logout_devices: false }, callback); + await client.setPassword(auth, newPassword, false); + passwordTest({ auth, new_password: newPassword, logout_devices: false }); }); }); @@ -1305,8 +1297,7 @@ describe("MatrixClient", function() { const result = await client.getLocalAliases(roomId); // Current version of the endpoint we support is v3 - const [callback, method, path, queryParams, data, opts] = client.http.authedRequest.mock.calls[0]; - expect(callback).toBeFalsy(); + const [method, path, queryParams, data, opts] = client.http.authedRequest.mock.calls[0]; expect(data).toBeFalsy(); expect(method).toBe('GET'); expect(path).toEqual(`/rooms/${encodeURIComponent(roomId)}/aliases`); diff --git a/spec/unit/models/MSC3089TreeSpace.spec.ts b/spec/unit/models/MSC3089TreeSpace.spec.ts index fdc8101ebb8..ef099fede78 100644 --- a/spec/unit/models/MSC3089TreeSpace.spec.ts +++ b/spec/unit/models/MSC3089TreeSpace.spec.ts @@ -890,9 +890,8 @@ describe("MSC3089TreeSpace", () => { expect(contents.length).toEqual(fileContents.length); expect(opts).toMatchObject({ includeFilename: false, - onlyContentUri: true, // because the tests rely on this - we shouldn't really be testing for this. }); - return Promise.resolve(mxc); + return Promise.resolve({ content_uri: mxc }); }); client.uploadContent = uploadFn; @@ -950,9 +949,8 @@ describe("MSC3089TreeSpace", () => { expect(contents.length).toEqual(fileContents.length); expect(opts).toMatchObject({ includeFilename: false, - onlyContentUri: true, // because the tests rely on this - we shouldn't really be testing for this. }); - return Promise.resolve(mxc); + return Promise.resolve({ content_uri: mxc }); }); client.uploadContent = uploadFn; diff --git a/spec/unit/pusher.spec.ts b/spec/unit/pusher.spec.ts index 4a27ef55b5b..dd46770a4aa 100644 --- a/spec/unit/pusher.spec.ts +++ b/spec/unit/pusher.spec.ts @@ -16,7 +16,7 @@ limitations under the License. import MockHttpBackend from 'matrix-mock-request'; -import { IHttpOpts, MatrixClient, PUSHER_ENABLED } from "../../src/matrix"; +import { MatrixClient, PUSHER_ENABLED } from "../../src/matrix"; import { mkPusher } from '../test-utils/test-utils'; const realSetTimeout = setTimeout; @@ -35,7 +35,7 @@ describe("Pushers", () => { client = new MatrixClient({ baseUrl: "https://my.home.server", accessToken: "my.access.token", - request: httpBackend.requestFn as unknown as IHttpOpts["request"], + fetchFn: httpBackend.fetchFn as typeof global.fetch, }); }); diff --git a/spec/unit/queueToDevice.spec.ts b/spec/unit/queueToDevice.spec.ts index a1ae2bcfe93..c5b1f8a29d2 100644 --- a/spec/unit/queueToDevice.spec.ts +++ b/spec/unit/queueToDevice.spec.ts @@ -17,7 +17,7 @@ limitations under the License. import MockHttpBackend from 'matrix-mock-request'; import { indexedDB as fakeIndexedDB } from 'fake-indexeddb'; -import { IHttpOpts, IndexedDBStore, MatrixEvent, MemoryStore, Room } from "../../src"; +import { IndexedDBStore, MatrixEvent, MemoryStore, Room } from "../../src"; import { MatrixClient } from "../../src/client"; import { ToDeviceBatch } from '../../src/models/ToDeviceMessage'; import { logger } from '../../src/logger'; @@ -89,7 +89,7 @@ describe.each([ client = new MatrixClient({ baseUrl: "https://my.home.server", accessToken: "my.access.token", - request: httpBackend.requestFn as IHttpOpts["request"], + fetchFn: httpBackend.fetchFn as typeof global.fetch, store, }); }); diff --git a/spec/unit/read-receipt.spec.ts b/spec/unit/read-receipt.spec.ts index 2e906a3cc41..07acaa184dd 100644 --- a/spec/unit/read-receipt.spec.ts +++ b/spec/unit/read-receipt.spec.ts @@ -18,7 +18,6 @@ import MockHttpBackend from 'matrix-mock-request'; import { ReceiptType } from '../../src/@types/read_receipts'; import { MatrixClient } from "../../src/client"; -import { IHttpOpts } from '../../src/http-api'; import { EventType } from '../../src/matrix'; import { MAIN_ROOM_TIMELINE } from '../../src/models/read-receipt'; import { encodeUri } from '../../src/utils'; @@ -87,7 +86,7 @@ describe("Read receipt", () => { client = new MatrixClient({ baseUrl: "https://my.home.server", accessToken: "my.access.token", - request: httpBackend.requestFn as unknown as IHttpOpts["request"], + fetchFn: httpBackend.fetchFn as typeof global.fetch, }); client.isGuest = () => false; }); @@ -146,5 +145,23 @@ describe("Read receipt", () => { await httpBackend.flushAllExpected(); await flushPromises(); }); + + it("sends a valid room read receipt even when body omitted", async () => { + httpBackend.when( + "POST", encodeUri("/rooms/$roomId/receipt/$receiptType/$eventId", { + $roomId: ROOM_ID, + $receiptType: ReceiptType.Read, + $eventId: threadEvent.getId(), + }), + ).check((request) => { + expect(request.data).toEqual({}); + }).respond(200, {}); + + mockServerSideSupport(client, false); + client.sendReceipt(threadEvent, ReceiptType.Read, undefined); + + await httpBackend.flushAllExpected(); + await flushPromises(); + }); }); }); diff --git a/spec/unit/utils.spec.ts b/spec/unit/utils.spec.ts index 5d804022ede..1b11f2a7cd3 100644 --- a/spec/unit/utils.spec.ts +++ b/spec/unit/utils.spec.ts @@ -26,9 +26,7 @@ describe("utils", function() { foo: "bar", baz: "beer@", }; - expect(utils.encodeParams(params)).toEqual( - "foo=bar&baz=beer%40", - ); + expect(utils.encodeParams(params).toString()).toEqual("foo=bar&baz=beer%40"); }); it("should handle boolean and numeric values", function() { @@ -37,7 +35,24 @@ describe("utils", function() { number: 12345, boolean: false, }; - expect(utils.encodeParams(params)).toEqual("string=foobar&number=12345&boolean=false"); + expect(utils.encodeParams(params).toString()).toEqual("string=foobar&number=12345&boolean=false"); + }); + + it("should handle string arrays", () => { + const params = { + via: ["one", "two", "three"], + }; + expect(utils.encodeParams(params).toString()).toEqual("via=one&via=two&via=three"); + }); + }); + + describe("decodeParams", () => { + it("should be able to decode multiple values into an array", () => { + const params = "foo=bar&via=a&via=b&via=c"; + expect(utils.decodeParams(params)).toEqual({ + foo: "bar", + via: ["a", "b", "c"], + }); }); }); diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 679a6afba6e..6b512434902 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -30,6 +30,8 @@ declare global { namespace NodeJS { interface Global { localStorage: Storage; + // marker variable used to detect both the browser & node entrypoints being used at once + __js_sdk_entrypoint: unknown; } } diff --git a/src/@types/partials.ts b/src/@types/partials.ts index a729d80dc64..bf27eab0e53 100644 --- a/src/@types/partials.ts +++ b/src/@types/partials.ts @@ -40,11 +40,6 @@ export enum Preset { export type ResizeMethod = "crop" | "scale"; -// TODO move to http-api after TSification -export interface IAbortablePromise extends Promise { - abort(): void; -} - export type IdServerUnbindResult = "no-support" | "success"; // Knock and private are reserved keywords which are not yet implemented. diff --git a/src/@types/requests.ts b/src/@types/requests.ts index cb292bf2cf0..f9095455e15 100644 --- a/src/@types/requests.ts +++ b/src/@types/requests.ts @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { Callback } from "../client"; import { IContent, IEvent } from "../models/event"; import { Preset, Visibility } from "./partials"; import { IEventWithRoomId, SearchKey } from "./search"; @@ -130,16 +129,6 @@ export interface IRoomDirectoryOptions { third_party_instance_id?: string; } -export interface IUploadOpts { - name?: string; - includeFilename?: boolean; - type?: string; - rawResponse?: boolean; - onlyContentUri?: boolean; - callback?: Callback; - progressHandler?: (state: {loaded: number, total: number}) => void; -} - export interface IAddThreePidOnlyBody { auth?: { type: string; diff --git a/src/ToDeviceMessageQueue.ts b/src/ToDeviceMessageQueue.ts index 12827d8bbc8..aaff912de69 100644 --- a/src/ToDeviceMessageQueue.ts +++ b/src/ToDeviceMessageQueue.ts @@ -28,7 +28,7 @@ const MAX_BATCH_SIZE = 20; export class ToDeviceMessageQueue { private sending = false; private running = true; - private retryTimeout: number = null; + private retryTimeout: ReturnType | null = null; private retryAttempts = 0; constructor(private client: MatrixClient) { @@ -68,7 +68,7 @@ export class ToDeviceMessageQueue { logger.debug("Attempting to send queued to-device messages"); this.sending = true; - let headBatch; + let headBatch: IndexedToDeviceBatch; try { while (this.running) { headBatch = await this.client.store.getOldestToDeviceBatch(); @@ -92,7 +92,7 @@ export class ToDeviceMessageQueue { // bored and giving up for now if (Math.floor(e.httpStatus / 100) === 4) { logger.error("Fatal error when sending to-device message - dropping to-device batch!", e); - await this.client.store.removeToDeviceBatch(headBatch.id); + await this.client.store.removeToDeviceBatch(headBatch!.id); } else { logger.info("Automatic retry limit reached for to-device messages."); } diff --git a/src/autodiscovery.ts b/src/autodiscovery.ts index 71cbd2105f9..8bf87e5177e 100644 --- a/src/autodiscovery.ts +++ b/src/autodiscovery.ts @@ -17,10 +17,9 @@ limitations under the License. /** @module auto-discovery */ -import { ServerResponse } from "http"; - import { IClientWellKnown, IWellKnownConfig } from "./client"; import { logger } from './logger'; +import { MatrixError, Method, timeoutSignal } from "./http-api"; // Dev note: Auto discovery is part of the spec. // See: https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery @@ -395,6 +394,19 @@ export class AutoDiscovery { } } + private static fetch(resource: URL | string, options?: RequestInit): ReturnType { + if (this.fetchFn) { + return this.fetchFn(resource, options); + } + return global.fetch(resource, options); + } + + private static fetchFn?: typeof global.fetch; + + public static setFetchFn(fetchFn: typeof global.fetch): void { + AutoDiscovery.fetchFn = fetchFn; + } + /** * Fetches a JSON object from a given URL, as expected by all .well-known * related lookups. If the server gives a 404 then the `action` will be @@ -411,45 +423,55 @@ export class AutoDiscovery { * @return {Promise} Resolves to the returned state. * @private */ - private static fetchWellKnownObject(uri: string): Promise { - return new Promise((resolve) => { - // eslint-disable-next-line - const request = require("./matrix").getRequest(); - if (!request) throw new Error("No request library available"); - request( - { method: "GET", uri, timeout: 5000 }, - (error: Error, response: ServerResponse, body: string) => { - if (error || response?.statusCode < 200 || response?.statusCode >= 300) { - const result = { error, raw: {} }; - return resolve(response?.statusCode === 404 - ? { - ...result, - action: AutoDiscoveryAction.IGNORE, - reason: AutoDiscovery.ERROR_MISSING_WELLKNOWN, - } : { - ...result, - action: AutoDiscoveryAction.FAIL_PROMPT, - reason: error?.message || "General failure", - }); - } - - try { - return resolve({ - raw: JSON.parse(body), - action: AutoDiscoveryAction.SUCCESS, - }); - } catch (err) { - return resolve({ - error: err, - raw: {}, - action: AutoDiscoveryAction.FAIL_PROMPT, - reason: err?.name === "SyntaxError" - ? AutoDiscovery.ERROR_INVALID_JSON - : AutoDiscovery.ERROR_INVALID, - }); - } - }, - ); - }); + private static async fetchWellKnownObject(url: string): Promise { + let response: Response; + + try { + response = await AutoDiscovery.fetch(url, { + method: Method.Get, + signal: timeoutSignal(5000), + }); + + if (response.status === 404) { + return { + raw: {}, + action: AutoDiscoveryAction.IGNORE, + reason: AutoDiscovery.ERROR_MISSING_WELLKNOWN, + }; + } + + if (!response.ok) { + return { + raw: {}, + action: AutoDiscoveryAction.FAIL_PROMPT, + reason: "General failure", + }; + } + } catch (err) { + const error = err as Error | string | undefined; + return { + error, + raw: {}, + action: AutoDiscoveryAction.FAIL_PROMPT, + reason: (error)?.message || "General failure", + }; + } + + try { + return { + raw: await response.json(), + action: AutoDiscoveryAction.SUCCESS, + }; + } catch (err) { + const error = err as Error | string | undefined; + return { + error, + raw: {}, + action: AutoDiscoveryAction.FAIL_PROMPT, + reason: (error as MatrixError)?.name === "SyntaxError" + ? AutoDiscovery.ERROR_INVALID_JSON + : AutoDiscovery.ERROR_INVALID, + }; + } } } diff --git a/src/browser-index.js b/src/browser-index.js index 3e3627fa9d8..86e887bd49f 100644 --- a/src/browser-index.js +++ b/src/browser-index.js @@ -14,25 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -import request from "browser-request"; -import queryString from "qs"; - import * as matrixcs from "./matrix"; -if (matrixcs.getRequest()) { +if (global.__js_sdk_entrypoint) { throw new Error("Multiple matrix-js-sdk entrypoints detected!"); } - -matrixcs.request(function(opts, fn) { - // We manually fix the query string for browser-request because - // it doesn't correctly handle cases like ?via=one&via=two. Instead - // we mimic `request`'s query string interface to make it all work - // as expected. - // browser-request will happily take the constructed string as the - // query string without trying to modify it further. - opts.qs = queryString.stringify(opts.qs || {}, opts.qsStringifyOptions); - return request(opts, fn); -}); +global.__js_sdk_entrypoint = true; // just *accessing* indexedDB throws an exception in firefox with // indexeddb disabled. diff --git a/src/client.ts b/src/client.ts index a8857abbbe1..d8392ae924c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -36,7 +36,7 @@ import { CallEvent, CallEventHandlerMap, createNewMatrixCall, MatrixCall, suppor import { Filter, IFilterDefinition, IRoomEventFilter } from "./filter"; import { CallEventHandlerEvent, CallEventHandler, CallEventHandlerEventHandlerMap } from './webrtc/callEventHandler'; import * as utils from './utils'; -import { sleep } from './utils'; +import { QueryDict, sleep } from './utils'; import { Direction, EventTimeline } from "./models/event-timeline"; import { IActionsObject, PushProcessor } from "./pushprocessor"; import { AutoDiscovery, AutoDiscoveryAction } from "./autodiscovery"; @@ -49,22 +49,17 @@ import { IRoomEncryption, RoomList } from './crypto/RoomList'; import { logger } from './logger'; import { SERVICE_TYPES } from './service-types'; import { - FileType, HttpApiEvent, HttpApiEventHandlerMap, - IHttpOpts, - IUpload, + Upload, + UploadOpts, MatrixError, MatrixHttpApi, Method, - PREFIX_IDENTITY_V2, - PREFIX_MEDIA_R0, - PREFIX_R0, - PREFIX_UNSTABLE, - PREFIX_V1, - PREFIX_V3, retryNetworkOperation, - UploadContentResponseType, + ClientPrefix, + MediaPrefix, + IdentityPrefix, IHttpOpts, FileType, UploadResponse, } from "./http-api"; import { Crypto, @@ -154,7 +149,6 @@ import { IRoomDirectoryOptions, ISearchOpts, ISendEventResponse, - IUploadOpts, } from "./@types/requests"; import { EventType, @@ -168,7 +162,7 @@ import { UNSTABLE_MSC3088_PURPOSE, UNSTABLE_MSC3089_TREE_SUBTYPE, } from "./@types/event"; -import { IAbortablePromise, IdServerUnbindResult, IImageInfo, Preset, Visibility } from "./@types/partials"; +import { IdServerUnbindResult, IImageInfo, Preset, Visibility } from "./@types/partials"; import { EventMapper, eventMapperFor, MapperOpts } from "./event-mapper"; import { randomString } from "./randomstring"; import { BackupManager, IKeyBackup, IKeyBackupCheck, IPreparedKeyBackupVersion, TrustInfo } from "./crypto/backup"; @@ -209,7 +203,6 @@ import { buildFeatureSupportMap, Feature, ServerSupport } from "./feature"; export type Store = IStore; -export type Callback = (err: Error | any | null, data?: T) => void; export type ResetTimelineCallback = (roomId: string) => boolean; const SCROLLBACK_DELAY_MS = 3000; @@ -262,12 +255,10 @@ export interface ICreateClientOpts { scheduler?: MatrixScheduler; /** - * The function to invoke for HTTP - * requests. The value of this property is typically require("request") - * as it returns a function which meets the required interface. See - * {@link requestFunction} for more information. + * The function to invoke for HTTP requests. + * Most supported environments have a global `fetch` registered to which this will fall back. */ - request?: IHttpOpts["request"]; + fetchFn?: typeof global.fetch; userId?: string; @@ -622,7 +613,7 @@ export interface IUploadKeysRequest { "org.matrix.msc2732.fallback_keys"?: Record; } -interface IOpenIDToken { +export interface IOpenIDToken { access_token: string; token_type: "Bearer" | string; matrix_server_name: string; @@ -930,15 +921,15 @@ export class MatrixClient extends TypedEventEmitter } = {}; public identityServer: IIdentityServerProvider; - public http: MatrixHttpApi; // XXX: Intended private, used in code. + public http: MatrixHttpApi; // XXX: Intended private, used in code. public crypto?: Crypto; // XXX: Intended private, used in code. public cryptoCallbacks: ICryptoCallbacks; // XXX: Intended private, used in code. public callEventHandler: CallEventHandler; // XXX: Intended private, used in code. @@ -1007,7 +998,7 @@ export class MatrixClient extends TypedEventEmitter[0], { + fetchFn: opts.fetchFn, baseUrl: opts.baseUrl, idBaseUrl: opts.idBaseUrl, accessToken: opts.accessToken, - request: opts.request, - prefix: PREFIX_R0, + prefix: ClientPrefix.R0, onlyData: true, extraParams: opts.queryParams, localTimeoutMs: opts.localTimeoutMs, @@ -1315,7 +1306,6 @@ export class MatrixClient extends TypedEventEmitter( - undefined, Method.Post, "/dehydrated_device/claim", undefined, @@ -1356,7 +1346,6 @@ export class MatrixClient extends TypedEventEmitter { try { return await this.http.authedRequest( - undefined, Method.Get, "/dehydrated_device", undefined, undefined, @@ -1649,9 +1638,7 @@ export class MatrixClient extends TypedEventEmitter { + return this.http.authedRequest(Method.Get, "/capabilities").catch((e: Error): void => { // We swallow errors because we need a default object anyhow logger.error(e); }).then((r: { capabilities?: ICapabilities } = {}) => { @@ -2271,7 +2258,7 @@ export class MatrixClient extends TypedEventEmitter} Resolves to the number of sessions requiring backup + * @returns {Promise} Resolves to the number of sessions requiring backup */ public countSessionsNeedingBackup(): Promise { if (!this.crypto) { @@ -2683,8 +2670,8 @@ export class MatrixClient extends TypedEventEmitter( - undefined, Method.Get, "/room_keys/version", undefined, undefined, - { prefix: PREFIX_V3 }, + Method.Get, "/room_keys/version", undefined, undefined, + { prefix: ClientPrefix.V3 }, ); } catch (e) { if (e.errcode === 'M_NOT_FOUND') { @@ -2839,8 +2826,8 @@ export class MatrixClient extends TypedEventEmitter( - undefined, Method.Post, "/room_keys/version", undefined, data, - { prefix: PREFIX_V3 }, + Method.Post, "/room_keys/version", undefined, data, + { prefix: ClientPrefix.V3 }, ); // We could assume everything's okay and enable directly, but this ensures @@ -2854,7 +2841,7 @@ export class MatrixClient extends TypedEventEmitter { + public async deleteKeyBackupVersion(version: string): Promise { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } @@ -2870,20 +2857,16 @@ export class MatrixClient extends TypedEventEmitter; - public sendKeyBackup(roomId: string, sessionId: undefined, version: string, data: IKeyBackup): Promise; - public sendKeyBackup(roomId: string, sessionId: string, version: string, data: IKeyBackup): Promise; + public sendKeyBackup( + roomId: undefined, + sessionId: undefined, + version: string | undefined, + data: IKeyBackup, + ): Promise; public sendKeyBackup( roomId: string, + sessionId: undefined, + version: string | undefined, + data: IKeyBackup, + ): Promise; + public sendKeyBackup( + roomId: string, + sessionId: string, + version: string | undefined, + data: IKeyBackup, + ): Promise; + public async sendKeyBackup( + roomId: string | undefined, sessionId: string | undefined, version: string | undefined, data: IKeyBackup, @@ -2924,9 +2922,9 @@ export class MatrixClient extends TypedEventEmitter} Resolves to the number of sessions requiring a backup. + * @returns {Promise} Resolves to the number of sessions requiring a backup. */ public flagAllGroupSessionsForBackup(): Promise { if (!this.crypto) { @@ -3216,8 +3214,8 @@ export class MatrixClient extends TypedEventEmitter( - undefined, Method.Get, path.path, path.queryData, undefined, - { prefix: PREFIX_UNSTABLE }, + Method.Get, path.path, path.queryData, undefined, + { prefix: ClientPrefix.Unstable }, ); if ((res as IRoomsKeysResponse).rooms) { @@ -3267,22 +3265,18 @@ export class MatrixClient extends TypedEventEmitter; - public deleteKeysFromBackup(roomId: string, sessionId: undefined, version: string): Promise; - public deleteKeysFromBackup(roomId: string, sessionId: string, version: string): Promise; - public deleteKeysFromBackup( - roomId: string | undefined, - sessionId: string | undefined, - version: string, - ): Promise { + public deleteKeysFromBackup(roomId: undefined, sessionId: undefined, version?: string): Promise; + public deleteKeysFromBackup(roomId: string, sessionId: undefined, version?: string): Promise; + public deleteKeysFromBackup(roomId: string, sessionId: string, version?: string): Promise; + public async deleteKeysFromBackup(roomId?: string, sessionId?: string, version?: string): Promise { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } const path = this.makeKeyBackupPath(roomId, sessionId, version); - return this.http.authedRequest( - undefined, Method.Delete, path.path, path.queryData, undefined, - { prefix: PREFIX_UNSTABLE }, + await this.http.authedRequest( + Method.Delete, path.path, path.queryData, undefined, + { prefix: ClientPrefix.Unstable }, ); } @@ -3322,13 +3316,12 @@ export class MatrixClient extends TypedEventEmitter { + public getMediaConfig(): Promise { return this.http.authedRequest( - callback, Method.Get, "/config", undefined, undefined, { - prefix: PREFIX_MEDIA_R0, + Method.Get, "/config", undefined, undefined, { + prefix: MediaPrefix.R0, }, ); } @@ -3410,22 +3403,17 @@ export class MatrixClient extends TypedEventEmitter { + public setAccountData(eventType: EventType | string, content: IContent): Promise<{}> { const path = utils.encodeUri("/user/$userId/account_data/$type", { $userId: this.credentials.userId, $type: eventType, }); - const promise = retryNetworkOperation(5, () => { - return this.http.authedRequest(undefined, Method.Put, path, undefined, content); + return retryNetworkOperation(5, () => { + return this.http.authedRequest(Method.Put, path, undefined, content); }); - if (callback) { - promise.then(result => callback(null, result), callback); - } - return promise; } /** @@ -3442,11 +3430,10 @@ export class MatrixClient extends TypedEventEmitter(eventType: string): Promise { + public async getAccountDataFromServer(eventType: string): Promise { if (this.isInitialSyncComplete()) { const event = this.store.getAccountData(eventType); if (!event) { @@ -3454,14 +3441,14 @@ export class MatrixClient extends TypedEventEmitter(); } const path = utils.encodeUri("/user/$userId/account_data/$type", { $userId: this.credentials.userId, $type: eventType, }); try { - return await this.http.authedRequest(undefined, Method.Get, path); + return await this.http.authedRequest(Method.Get, path); } catch (e) { if (e.data?.errcode === 'M_NOT_FOUND') { return null; @@ -3483,16 +3470,15 @@ export class MatrixClient extends TypedEventEmitter { + public setIgnoredUsers(userIds: string[]): Promise<{}> { const content = { ignored_users: {} }; userIds.forEach((u) => { content.ignored_users[u] = {}; }); - return this.setAccountData("m.ignored_user_list", content, callback); + return this.setAccountData("m.ignored_user_list", content); } /** @@ -3513,31 +3499,25 @@ export class MatrixClient extends TypedEventEmitter Default: true. * @param {boolean} opts.inviteSignUrl If the caller has a keypair 3pid invite, the signing URL is passed in this parameter. * @param {string[]} opts.viaServers The server names to try and join through in addition to those that are automatically chosen. - * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: Room object. * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public async joinRoom(roomIdOrAlias: string, opts?: IJoinRoomOpts, callback?: Callback): Promise { - // to help people when upgrading.. - if (utils.isFunction(opts)) { - throw new Error("Expected 'opts' object, got function."); - } - opts = opts || {}; + public async joinRoom(roomIdOrAlias: string, opts: IJoinRoomOpts = {}): Promise { if (opts.syncRoom === undefined) { opts.syncRoom = true; } const room = this.getRoom(roomIdOrAlias); - if (room && room.hasMembershipState(this.credentials.userId, "join")) { + if (room?.hasMembershipState(this.credentials.userId, "join")) { return Promise.resolve(room); } let signPromise: Promise = Promise.resolve(); if (opts.inviteSignUrl) { - signPromise = this.http.requestOtherUrl( - undefined, Method.Post, - opts.inviteSignUrl, { mxid: this.credentials.userId }, + signPromise = this.http.requestOtherUrl( + Method.Post, + new URL(opts.inviteSignUrl), { mxid: this.credentials.userId }, ); } @@ -3546,8 +3526,6 @@ export class MatrixClient extends TypedEventEmitter { - return this.sendStateEvent(roomId, EventType.RoomName, { name: name }, undefined, callback); + public setRoomName(roomId: string, name: string): Promise { + return this.sendStateEvent(roomId, EventType.RoomName, { name: name }); } /** * @param {string} roomId * @param {string} topic * @param {string} htmlTopic Optional. - * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: TODO * @return {module:http-api.MatrixError} Rejects: with an error response. */ @@ -3637,76 +3611,59 @@ export class MatrixClient extends TypedEventEmitter; - public setRoomTopic( - roomId: string, - topic: string, - callback?: Callback, - ): Promise; - public setRoomTopic( - roomId: string, - topic: string, - htmlTopicOrCallback?: string | Callback, ): Promise { - const isCallback = typeof htmlTopicOrCallback === 'function'; - const htmlTopic = isCallback ? undefined : htmlTopicOrCallback; - const callback = isCallback ? htmlTopicOrCallback : undefined; const content = ContentHelpers.makeTopicContent(topic, htmlTopic); - return this.sendStateEvent(roomId, EventType.RoomTopic, content, undefined, callback); + return this.sendStateEvent(roomId, EventType.RoomTopic, content); } /** * @param {string} roomId - * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: to an object keyed by tagId with objects containing a numeric order field. * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public getRoomTags(roomId: string, callback?: Callback): Promise { + public getRoomTags(roomId: string): Promise { const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags", { $userId: this.credentials.userId, $roomId: roomId, }); - return this.http.authedRequest(callback, Method.Get, path); + return this.http.authedRequest(Method.Get, path); } /** * @param {string} roomId * @param {string} tagName name of room tag to be set * @param {object} metadata associated with that tag to be stored - * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: to an empty object * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public setRoomTag(roomId: string, tagName: string, metadata: ITagMetadata, callback?: Callback): Promise<{}> { + public setRoomTag(roomId: string, tagName: string, metadata: ITagMetadata): Promise<{}> { const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags/$tag", { $userId: this.credentials.userId, $roomId: roomId, $tag: tagName, }); - return this.http.authedRequest(callback, Method.Put, path, undefined, metadata); + return this.http.authedRequest(Method.Put, path, undefined, metadata); } /** * @param {string} roomId * @param {string} tagName name of room tag to be removed - * @param {module:client.callback} callback Optional. - * @return {Promise} Resolves: void + * @return {Promise} Resolves: to an empty object * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public deleteRoomTag(roomId: string, tagName: string, callback?: Callback): Promise { + public deleteRoomTag(roomId: string, tagName: string): Promise<{}> { const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags/$tag", { $userId: this.credentials.userId, $roomId: roomId, $tag: tagName, }); - return this.http.authedRequest(callback, Method.Delete, path); + return this.http.authedRequest(Method.Delete, path); } /** * @param {string} roomId * @param {string} eventType event type to be set * @param {object} content event content - * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: to an empty object {} * @return {module:http-api.MatrixError} Rejects: with an error response. */ @@ -3714,14 +3671,13 @@ export class MatrixClient extends TypedEventEmitter, - callback?: Callback, ): Promise<{}> { const path = utils.encodeUri("/user/$userId/rooms/$roomId/account_data/$type", { $userId: this.credentials.userId, $roomId: roomId, $type: eventType, }); - return this.http.authedRequest(callback, Method.Put, path, undefined, content); + return this.http.authedRequest(Method.Put, path, undefined, content); } /** @@ -3730,7 +3686,6 @@ export class MatrixClient extends TypedEventEmitter { let content = { users: {}, @@ -3753,7 +3707,7 @@ export class MatrixClient extends TypedEventEmitter; public sendEvent( roomId: string, @@ -3809,18 +3761,15 @@ export class MatrixClient extends TypedEventEmitter; public sendEvent( roomId: string, threadId: string | null, eventType: string | IContent, content: IContent | string, - txnId?: string | Callback, - callback?: Callback, + txnId?: string, ): Promise { if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) { - callback = txnId as Callback; txnId = content as string; content = eventType as IContent; eventType = threadId; @@ -3848,7 +3797,7 @@ export class MatrixClient extends TypedEventEmitter { - if (utils.isFunction(txnId)) { - callback = txnId as any as Callback; // convert for legacy - txnId = undefined; - } - if (!txnId) { txnId = this.makeTxnId(); } @@ -3929,18 +3871,17 @@ export class MatrixClient extends TypedEventEmitter { + private encryptAndSendEvent(room: Room, event: MatrixEvent): Promise { let cancelled = false; // Add an extra Promise.resolve() to turn synchronous exceptions into promise rejections, // so that we can handle synchronous and asynchronous exceptions with the @@ -3986,9 +3927,6 @@ export class MatrixClient extends TypedEventEmitter { - callback?.(null, res); - return res; }).catch(err => { logger.error("Error sending event", err.stack || err); try { @@ -4000,8 +3938,6 @@ export class MatrixClient extends TypedEventEmitter( - undefined, Method.Put, path, undefined, event.getWireContent(), + Method.Put, path, undefined, event.getWireContent(), ).then((res) => { logger.log(`Event sent to ${event.getRoomId()} with event id ${res.event_id}`); return res; @@ -4120,11 +4056,8 @@ export class MatrixClient extends TypedEventEmitter; public redactEvent( roomId: string, threadId: string | null, eventId: string, txnId?: string | undefined, - cbOrOpts?: Callback | IRedactOpts, + opts?: IRedactOpts, ): Promise; public redactEvent( roomId: string, threadId: string | null, eventId?: string, - txnId?: string | Callback | IRedactOpts, - cbOrOpts?: Callback | IRedactOpts, + txnId?: string | IRedactOpts, + opts?: IRedactOpts, ): Promise { if (!eventId?.startsWith(EVENT_ID_PREFIX)) { - cbOrOpts = txnId as (Callback | IRedactOpts); + opts = txnId as IRedactOpts; txnId = eventId; eventId = threadId; threadId = null; } - const opts = typeof (cbOrOpts) === 'object' ? cbOrOpts : {}; - const reason = opts.reason; - const callback = typeof (cbOrOpts) === 'function' ? cbOrOpts : undefined; + const reason = opts?.reason; return this.sendCompleteEvent(roomId, threadId, { type: EventType.RoomRedaction, content: { reason }, redacts: eventId, - }, txnId as string, callback); + }, txnId as string); } /** @@ -4169,7 +4100,6 @@ export class MatrixClient extends TypedEventEmitter; public sendMessage( roomId: string, threadId: string | null, content: IContent, txnId?: string, - callback?: Callback, ): Promise; public sendMessage( roomId: string, threadId: string | null | IContent, content: IContent | string, - txnId?: string | Callback, - callback?: Callback, + txnId?: string, ): Promise { if (typeof threadId !== "string" && threadId !== null) { - callback = txnId as Callback; txnId = content as string; content = threadId as IContent; threadId = null; } - if (utils.isFunction(txnId)) { - callback = txnId as any as Callback; // for legacy - txnId = undefined; - } // Populate all outbound events with Extensible Events metadata to ensure there's a // reasonably large pool of messages to parse. @@ -4248,8 +4170,7 @@ export class MatrixClient extends TypedEventEmitter; public sendTextMessage( roomId: string, threadId: string | null, body: string, txnId?: string, - callback?: Callback, ): Promise; public sendTextMessage( roomId: string, threadId: string | null, body: string, - txnId?: string | Callback, - callback?: Callback, + txnId?: string, ): Promise { if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) { - callback = txnId as Callback; txnId = body; body = threadId; threadId = null; } const content = ContentHelpers.makeTextMessage(body); - return this.sendMessage(roomId, threadId, content, txnId as string, callback); + return this.sendMessage(roomId, threadId, content, txnId); } /** @@ -4297,7 +4213,6 @@ export class MatrixClient extends TypedEventEmitter; public sendNotice( roomId: string, threadId: string | null, body: string, txnId?: string, - callback?: Callback, ): Promise; public sendNotice( roomId: string, threadId: string | null, body: string, - txnId?: string | Callback, - callback?: Callback, + txnId?: string, ): Promise { if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) { - callback = txnId as Callback; txnId = body; body = threadId; threadId = null; } const content = ContentHelpers.makeNotice(body); - return this.sendMessage(roomId, threadId, content, txnId as string, callback); + return this.sendMessage(roomId, threadId, content, txnId); } /** @@ -4336,7 +4247,6 @@ export class MatrixClient extends TypedEventEmitter; public sendEmoteMessage( roomId: string, threadId: string | null, body: string, txnId?: string, - callback?: Callback, ): Promise; public sendEmoteMessage( roomId: string, threadId: string | null, body: string, - txnId?: string | Callback, - callback?: Callback, + txnId?: string, ): Promise { if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) { - callback = txnId as Callback; txnId = body; body = threadId; threadId = null; } const content = ContentHelpers.makeEmoteMessage(body); - return this.sendMessage(roomId, threadId, content, txnId as string, callback); + return this.sendMessage(roomId, threadId, content, txnId); } /** @@ -4376,7 +4282,6 @@ export class MatrixClient extends TypedEventEmitter; public sendImageMessage( roomId: string, @@ -4393,34 +4297,27 @@ export class MatrixClient extends TypedEventEmitter; public sendImageMessage( roomId: string, threadId: string | null, url: string | IImageInfo, info?: IImageInfo | string, - text: Callback | string = "Image", - callback?: Callback, + text = "Image", ): Promise { if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) { - callback = text as Callback; text = info as string || "Image"; info = url as IImageInfo; url = threadId as string; threadId = null; } - if (utils.isFunction(text)) { - callback = text as any as Callback; // legacy - text = undefined; - } const content = { msgtype: MsgType.Image, url: url, info: info, body: text, }; - return this.sendMessage(roomId, threadId, content, undefined, callback); + return this.sendMessage(roomId, threadId, content); } /** @@ -4429,7 +4326,6 @@ export class MatrixClient extends TypedEventEmitter; public sendStickerMessage( roomId: string, @@ -4446,34 +4341,27 @@ export class MatrixClient extends TypedEventEmitter; public sendStickerMessage( roomId: string, threadId: string | null, url: string | IImageInfo, info?: IImageInfo | string, - text: Callback | string = "Sticker", - callback?: Callback, + text = "Sticker", ): Promise { if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) { - callback = text as Callback; text = info as string || "Sticker"; info = url as IImageInfo; url = threadId as string; threadId = null; } - if (utils.isFunction(text)) { - callback = text as any as Callback; // legacy - text = undefined; - } const content = { url: url, info: info, body: text, }; - return this.sendEvent(roomId, threadId, EventType.Sticker, content, undefined, callback); + return this.sendEvent(roomId, threadId, EventType.Sticker, content); } /** @@ -4481,7 +4369,6 @@ export class MatrixClient extends TypedEventEmitter; public sendHtmlMessage( roomId: string, threadId: string | null, body: string, htmlBody: string, - callback?: Callback, ): Promise; public sendHtmlMessage( roomId: string, threadId: string | null, body: string, - htmlBody: string | Callback, - callback?: Callback, + htmlBody?: string, ): Promise { if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) { - callback = htmlBody as Callback; htmlBody = body as string; body = threadId; threadId = null; } - const content = ContentHelpers.makeHtmlMessage(body, htmlBody as string); - return this.sendMessage(roomId, threadId, content, undefined, callback); + const content = ContentHelpers.makeHtmlMessage(body, htmlBody); + return this.sendMessage(roomId, threadId, content); } /** * @param {string} roomId * @param {string} body * @param {string} htmlBody - * @param {module:client.callback} callback Optional. Deprecated * @return {Promise} Resolves: to a ISendEventResponse object * @return {module:http-api.MatrixError} Rejects: with an error response. */ @@ -4527,30 +4409,26 @@ export class MatrixClient extends TypedEventEmitter; public sendHtmlNotice( roomId: string, threadId: string | null, body: string, htmlBody: string, - callback?: Callback, ): Promise; public sendHtmlNotice( roomId: string, threadId: string | null, body: string, - htmlBody: string | Callback, - callback?: Callback, + htmlBody?: string, ): Promise { if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) { - callback = htmlBody as Callback; htmlBody = body as string; body = threadId; threadId = null; } - const content = ContentHelpers.makeHtmlNotice(body, htmlBody as string); - return this.sendMessage(roomId, threadId, content, undefined, callback); + const content = ContentHelpers.makeHtmlNotice(body, htmlBody); + return this.sendMessage(roomId, threadId, content); } /** @@ -4558,7 +4436,6 @@ export class MatrixClient extends TypedEventEmitter; public sendHtmlEmote( roomId: string, threadId: string | null, body: string, htmlBody: string, - callback?: Callback, ): Promise; public sendHtmlEmote( roomId: string, threadId: string | null, body: string, - htmlBody: string | Callback, - callback?: Callback, + htmlBody?: string, ): Promise { if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) { - callback = htmlBody as Callback; htmlBody = body as string; body = threadId; threadId = null; } - const content = ContentHelpers.makeHtmlEmote(body, htmlBody as string); - return this.sendMessage(roomId, threadId, content, undefined, callback); + const content = ContentHelpers.makeHtmlEmote(body, htmlBody); + return this.sendMessage(roomId, threadId, content); } /** @@ -4598,7 +4471,6 @@ export class MatrixClient extends TypedEventEmitter { - if (typeof (body) === 'function') { - callback = body as any as Callback; // legacy - body = {}; - } - if (this.isGuest()) { return Promise.resolve({}); // guests cannot send receipts so don't bother. } @@ -4631,7 +4497,7 @@ export class MatrixClient extends TypedEventEmitter { if (!event) return; const eventId = event.getId(); @@ -4660,7 +4524,7 @@ export class MatrixClient extends TypedEventEmitter { + public getUrlPreview(url: string, ts: number): Promise { // bucket the timestamp to the nearest minute to prevent excessive spam to the server // Surely 60-second accuracy is enough for anyone. ts = Math.floor(ts / 60000) * 60000; @@ -4740,20 +4603,15 @@ export class MatrixClient extends TypedEventEmitter(Method.Get, "/preview_url", { + url, + ts: ts.toString(), + }, undefined, { + prefix: MediaPrefix.R0, + }); // TODO: Expire the URL preview cache sometimes this.urlPreviewCache[key] = resp; return resp; @@ -4763,11 +4621,10 @@ export class MatrixClient extends TypedEventEmitter { + public sendTyping(roomId: string, isTyping: boolean, timeoutMs: number): Promise<{}> { if (this.isGuest()) { return Promise.resolve({}); // guests cannot send typing notifications so don't bother. } @@ -4782,7 +4639,7 @@ export class MatrixClient extends TypedEventEmitter { - return this.membershipChange(roomId, userId, "invite", reason, callback); + public invite(roomId: string, userId: string, reason?: string): Promise<{}> { + return this.membershipChange(roomId, userId, "invite", reason); } /** * Invite a user to a room based on their email address. * @param {string} roomId The room to invite the user to. * @param {string} email The email address to invite. - * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: {} an empty object. * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public inviteByEmail(roomId: string, email: string, callback?: Callback): Promise<{}> { - return this.inviteByThreePid(roomId, "email", email, callback); + public inviteByEmail(roomId: string, email: string): Promise<{}> { + return this.inviteByThreePid(roomId, "email", email); } /** @@ -4892,11 +4747,10 @@ export class MatrixClient extends TypedEventEmitter { + public async inviteByThreePid(roomId: string, medium: string, address: string): Promise<{}> { const path = utils.encodeUri( "/rooms/$roomId/invite", { $roomId: roomId }, @@ -4925,17 +4779,16 @@ export class MatrixClient extends TypedEventEmitter { - return this.membershipChange(roomId, undefined, "leave", undefined, callback); + public leave(roomId: string): Promise<{}> { + return this.membershipChange(roomId, undefined, "leave"); } /** @@ -4989,28 +4842,22 @@ export class MatrixClient extends TypedEventEmitter { + return this.membershipChange(roomId, userId, "ban", reason); } /** * @param {string} roomId * @param {boolean} deleteRoom True to delete the room from the store on success. * Default: true. - * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: {} an empty object. * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public forget(roomId: string, deleteRoom?: boolean, callback?: Callback): Promise<{}> { - if (deleteRoom === undefined) { - deleteRoom = true; - } - const promise = this.membershipChange(roomId, undefined, "forget", undefined, - callback); + public forget(roomId: string, deleteRoom = true): Promise<{}> { + const promise = this.membershipChange(roomId, undefined, "forget"); if (!deleteRoom) { return promise; } @@ -5024,11 +4871,10 @@ export class MatrixClient extends TypedEventEmitter { + public unban(roomId: string, userId: string): Promise<{}> { // unbanning != set their state to leave: this used to be // the case, but was then changed so that leaving was always // a revoking of privilege, otherwise two people racing to @@ -5040,20 +4886,17 @@ export class MatrixClient extends TypedEventEmitter { + public kick(roomId: string, userId: string, reason?: string): Promise<{}> { const path = utils.encodeUri("/rooms/$roomId/kick", { $roomId: roomId, }); @@ -5061,9 +4904,7 @@ export class MatrixClient extends TypedEventEmitter { // API returns an empty object - if (utils.isFunction(reason)) { - callback = reason as any as Callback; // legacy - reason = undefined; - } - const path = utils.encodeUri("/rooms/$room_id/$membership", { $room_id: roomId, $membership: membership, }); return this.http.authedRequest( - callback, Method.Post, path, undefined, { + Method.Post, path, undefined, { user_id: userId, // may be undefined e.g. on leave reason: reason, }, @@ -5108,29 +4943,27 @@ export class MatrixClient extends TypedEventEmitter; - public setProfileInfo(info: "displayname", data: { displayname: string }, callback?: Callback): Promise<{}>; - public setProfileInfo(info: "avatar_url" | "displayname", data: object, callback?: Callback): Promise<{}> { + public setProfileInfo(info: "avatar_url", data: { avatar_url: string }): Promise<{}>; + public setProfileInfo(info: "displayname", data: { displayname: string }): Promise<{}>; + public setProfileInfo(info: "avatar_url" | "displayname", data: object): Promise<{}> { const path = utils.encodeUri("/profile/$userId/$info", { $userId: this.credentials.userId, $info: info, }); - return this.http.authedRequest(callback, Method.Put, path, undefined, data); + return this.http.authedRequest(Method.Put, path, undefined, data); } /** * @param {string} name - * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: {} an empty object. * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public async setDisplayName(name: string, callback?: Callback): Promise<{}> { - const prom = await this.setProfileInfo("displayname", { displayname: name }, callback); + public async setDisplayName(name: string): Promise<{}> { + const prom = await this.setProfileInfo("displayname", { displayname: name }); // XXX: synthesise a profile update for ourselves because Synapse is broken and won't const user = this.getUser(this.getUserId()); if (user) { @@ -5142,12 +4975,11 @@ export class MatrixClient extends TypedEventEmitter { - const prom = await this.setProfileInfo("avatar_url", { avatar_url: url }, callback); + public async setAvatarUrl(url: string): Promise<{}> { + const prom = await this.setProfileInfo("avatar_url", { avatar_url: url }); // XXX: synthesise a profile update for ourselves because Synapse is broken and won't const user = this.getUser(this.getUserId()); if (user) { @@ -5184,12 +5016,11 @@ export class MatrixClient extends TypedEventEmitter { + public async setPresence(opts: IPresenceOpts): Promise { const path = utils.encodeUri("/presence/$userId/status", { $userId: this.credentials.userId, }); @@ -5202,23 +5033,20 @@ export class MatrixClient extends TypedEventEmitter { + public getPresence(userId: string): Promise { const path = utils.encodeUri("/presence/$userId/status", { $userId: userId, }); - return this.http.authedRequest(callback, Method.Get, path); + return this.http.authedRequest(Method.Get, path); } /** @@ -5232,17 +5060,12 @@ export class MatrixClient extends TypedEventEmitterRoom.oldState.paginationToken will be * null. * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public scrollback(room: Room, limit = 30, callback?: Callback): Promise { - if (utils.isFunction(limit)) { - callback = limit as any as Callback; // legacy - limit = undefined; - } + public scrollback(room: Room, limit = 30): Promise { let timeToWaitMs = 0; let info = this.ongoingScrollbacks[room.roomId] || {}; @@ -5294,13 +5117,11 @@ export class MatrixClient extends TypedEventEmitter { this.ongoingScrollbacks[room.roomId] = { errorTs: Date.now(), }; - callback?.(err); reject(err); }); }); @@ -5367,7 +5188,7 @@ export class MatrixClient extends TypedEventEmitter(undefined, Method.Get, path, params); + const res = await this.http.authedRequest(Method.Get, path, params); if (!res.event) { throw new Error("'event' not in '/context' result - homeserver too old?"); } @@ -5538,7 +5359,7 @@ export class MatrixClient extends TypedEventEmitter(undefined, Method.Get, path, params, undefined, opts) + return this.http.authedRequest(Method.Get, path, params, undefined, opts) .then(res => ({ ...res, start: res.prev_batch, @@ -5662,7 +5483,7 @@ export class MatrixClient extends TypedEventEmitter( - undefined, Method.Get, path, params, + Method.Get, path, params, ).then(async (res) => { const token = res.next_token; const matrixEvents: MatrixEvent[] = []; @@ -6007,7 +5828,6 @@ export class MatrixClient extends TypedEventEmitter { + public searchMessageText(opts: ISearchOpts): Promise { const roomEvents: ISearchRequestBody["search_categories"]["room_events"] = { search_term: opts.query, }; @@ -6204,7 +6024,7 @@ export class MatrixClient extends TypedEventEmitter(undefined, Method.Post, path, undefined, content) + return this.http.authedRequest(Method.Post, path, undefined, content) .then((response) => { // persist the filter const filter = Filter.fromJson(this.credentials.userId, response.filter_id, content); @@ -6402,7 +6222,7 @@ export class MatrixClient extends TypedEventEmitter(undefined, Method.Get, path).then((response) => { + return this.http.authedRequest(Method.Get, path).then((response) => { // persist the filter const filter = Filter.fromJson(userId, filterId, response); this.store.storeFilter(filter); @@ -6477,9 +6297,7 @@ export class MatrixClient extends TypedEventEmitter { @@ -6490,12 +6308,11 @@ export class MatrixClient extends TypedEventEmitter { - return this.http.authedRequest(callback, Method.Get, "/voip/turnServer"); + public turnServer(): Promise { + return this.http.authedRequest(Method.Get, "/voip/turnServer"); } /** @@ -6601,7 +6418,7 @@ export class MatrixClient extends TypedEventEmitter r['admin']); // pull out the specific boolean we want } @@ -6617,7 +6434,7 @@ export class MatrixClient extends TypedEventEmitter { @@ -6692,8 +6507,8 @@ export class MatrixClient extends TypedEventEmitter( - undefined, Method.Get, path, undefined, undefined, - { prefix: PREFIX_UNSTABLE }, + Method.Get, path, undefined, undefined, + { prefix: ClientPrefix.Unstable }, ); return res.joined; } @@ -6709,7 +6524,6 @@ export class MatrixClient extends TypedEventEmitter( - undefined, // callback Method.Get, "/_matrix/client/versions", undefined, // queryParams undefined, // data @@ -6997,12 +6811,12 @@ export class MatrixClient extends TypedEventEmitter { return this.http.authedRequest<{ available: true }>( - undefined, Method.Get, '/register/available', { username }, + Method.Get, '/register/available', { username }, ).then((response) => { return response.available; }).catch(response => { @@ -7099,7 +6913,6 @@ export class MatrixClient extends TypedEventEmitter { // backwards compat if (bindThreepids === true) { @@ -7119,11 +6931,6 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types + public registerGuest(opts: { body?: any }): Promise { // TODO: Types opts = opts || {}; opts.body = opts.body || {}; - return this.registerRequest(opts.body, "guest", callback); + return this.registerRequest(opts.body, "guest"); } /** * @param {Object} data parameters for registration request * @param {string=} kind type of user to register. may be "guest" - * @param {module:client.callback=} callback * @return {Promise} Resolves: to the /register response * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public registerRequest(data: IRegisterRequestParams, kind?: string, callback?: Callback): Promise { + public registerRequest(data: IRegisterRequestParams, kind?: string): Promise { const params: { kind?: string } = {}; if (kind) { params.kind = kind; } - return this.http.request(callback, Method.Post, "/register", params, data); + return this.http.request(Method.Post, "/register", params, data); } /** @@ -7221,35 +7026,32 @@ export class MatrixClient extends TypedEventEmitter { return this.http.authedRequest( - undefined, Method.Post, "/refresh", undefined, { refresh_token: refreshToken }, { - prefix: PREFIX_V1, + prefix: ClientPrefix.V1, inhibitLogoutEmit: true, // we don't want to cause logout loops }, ); } /** - * @param {module:client.callback} callback Optional. * @return {Promise} Resolves to the available login flows * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public loginFlows(callback?: Callback): Promise { - return this.http.request(callback, Method.Get, "/login"); + public loginFlows(): Promise { + return this.http.request(Method.Get, "/login"); } /** * @param {string} loginType * @param {Object} data - * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: TODO * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public login(loginType: string, data: any, callback?: Callback): Promise { // TODO: Types + public login(loginType: string, data: any): Promise { // TODO: Types const loginData = { type: loginType, }; @@ -7257,46 +7059,42 @@ export class MatrixClient extends TypedEventEmitter { - if (response && response.access_token && response.user_id) { - this.http.opts.accessToken = response.access_token; - this.credentials = { - userId: response.user_id, - }; - } - - if (callback) { - callback(error, response); - } - }, Method.Post, "/login", undefined, loginData, - ); + return this.http.authedRequest<{ + access_token?: string; + user_id?: string; + }>(Method.Post, "/login", undefined, loginData).then(response => { + if (response.access_token && response.user_id) { + this.http.opts.accessToken = response.access_token; + this.credentials = { + userId: response.user_id, + }; + } + return response; + }); } /** * @param {string} user * @param {string} password - * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: TODO * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public loginWithPassword(user: string, password: string, callback?: Callback): Promise { // TODO: Types + public loginWithPassword(user: string, password: string): Promise { // TODO: Types return this.login("m.login.password", { user: user, password: password, - }, callback); + }); } /** * @param {string} relayState URL Callback after SAML2 Authentication - * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: TODO * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public loginWithSAML2(relayState: string, callback?: Callback): Promise { // TODO: Types + public loginWithSAML2(relayState: string): Promise { // TODO: Types return this.login("m.login.saml2", { relay_state: relayState, - }, callback); + }); } /** @@ -7333,19 +7131,18 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types + public loginWithToken(token: string): Promise { // TODO: Types return this.login("m.login.token", { token: token, - }, callback); + }); } /** @@ -7354,11 +7151,10 @@ export class MatrixClient extends TypedEventEmitter { + public async logout(stopClient = false): Promise<{}> { if (this.crypto?.backupManager?.getKeyBackupEnabled()) { try { while (await this.crypto.backupManager.backupPendingKeys(200) > 0); @@ -7372,11 +7168,10 @@ export class MatrixClient extends TypedEventEmitter { - if (typeof (erase) === 'function') { - throw new Error('deactivateAccount no longer accepts a callback parameter'); - } - const body: any = {}; if (auth) { body.auth = auth; @@ -7404,7 +7195,7 @@ export class MatrixClient extends TypedEventEmitter> { const body: UIARequest<{}> = { auth }; return this.http.authedRequest( - undefined, // no callback support Method.Post, "/org.matrix.msc3882/login/token", undefined, // no query params body, - { prefix: PREFIX_UNSTABLE }, + { prefix: ClientPrefix.Unstable }, ); } @@ -7443,7 +7233,7 @@ export class MatrixClient extends TypedEventEmitter{room_id: {string}} * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public async createRoom( - options: ICreateRoomOpts, - callback?: Callback, - ): Promise<{ room_id: string }> { // eslint-disable-line camelcase + public async createRoom(options: ICreateRoomOpts): Promise<{ room_id: string }> { // eslint-disable-line camelcase // some valid options include: room_alias_name, visibility, invite // inject the id_access_token if inviting 3rd party addresses @@ -7481,7 +7267,7 @@ export class MatrixClient extends TypedEventEmitter { + public roomState(roomId: string): Promise { const path = utils.encodeUri("/rooms/$roomId/state", { $roomId: roomId }); - return this.http.authedRequest(callback, Method.Get, path); + return this.http.authedRequest(Method.Get, path); } /** * Get an event in a room by its event id. * @param {string} roomId * @param {string} eventId - * @param {module:client.callback} callback Optional. * * @return {Promise} Resolves to an object containing the event. * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public fetchRoomEvent( - roomId: string, - eventId: string, - callback?: Callback, - ): Promise { + public fetchRoomEvent(roomId: string, eventId: string): Promise { const path = utils.encodeUri( "/rooms/$roomId/event/$eventId", { $roomId: roomId, $eventId: eventId, }, ); - return this.http.authedRequest(callback, Method.Get, path); + return this.http.authedRequest(Method.Get, path); } /** @@ -7567,7 +7347,6 @@ export class MatrixClient extends TypedEventEmitter { const queryParams: Record = {}; if (includeMembership) { @@ -7593,7 +7371,7 @@ export class MatrixClient extends TypedEventEmitter { // eslint-disable-line camelcase const path = utils.encodeUri("/rooms/$roomId/upgrade", { $roomId: roomId }); - return this.http.authedRequest( - undefined, Method.Post, path, undefined, { new_version: newVersion }, - ); + return this.http.authedRequest(Method.Post, path, undefined, { new_version: newVersion }); } /** @@ -7618,7 +7394,6 @@ export class MatrixClient extends TypedEventEmitter> { const pathParams = { $roomId: roomId, @@ -7637,9 +7411,7 @@ export class MatrixClient extends TypedEventEmitter { const pathParams = { $roomId: roomId, @@ -7667,27 +7437,21 @@ export class MatrixClient extends TypedEventEmitter { - if (utils.isFunction(limit)) { - callback = limit as any as Callback; // legacy - limit = undefined; - } - + public roomInitialSync(roomId: string, limit: number): Promise { const path = utils.encodeUri("/rooms/$roomId/initialSync", { $roomId: roomId }, ); - return this.http.authedRequest(callback, Method.Get, path, { limit: limit?.toString() ?? "30" }); + return this.http.authedRequest(Method.Get, path, { limit: limit?.toString() ?? "30" }); } /** @@ -7726,7 +7490,7 @@ export class MatrixClient extends TypedEventEmitter { const path = utils.encodeUri("/joined_rooms", {}); - return this.http.authedRequest(undefined, Method.Get, path); + return this.http.authedRequest(Method.Get, path); } /** @@ -7749,7 +7513,7 @@ export class MatrixClient extends TypedEventEmitter { - if (typeof (options) == 'function') { - callback = options; - options = {}; - } - if (options === undefined) { - options = {}; - } - - const queryParams: any = {}; - if (options.server) { - queryParams.server = options.server; - delete options.server; - } - - if (Object.keys(options).length === 0 && Object.keys(queryParams).length === 0) { - return this.http.authedRequest(callback, Method.Get, "/publicRooms"); + public publicRooms( + { server, limit, since, ...options }: IRoomDirectoryOptions = {}, + ): Promise { + const queryParams: QueryDict = { server, limit, since }; + if (Object.keys(options).length === 0) { + return this.http.authedRequest(Method.Get, "/publicRooms", queryParams); } else { - return this.http.authedRequest(callback, Method.Post, "/publicRooms", queryParams, options); + return this.http.authedRequest(Method.Post, "/publicRooms", queryParams, options); } } @@ -7791,33 +7543,31 @@ export class MatrixClient extends TypedEventEmitter { + public createAlias(alias: string, roomId: string): Promise<{}> { const path = utils.encodeUri("/directory/room/$alias", { $alias: alias, }); const data = { room_id: roomId, }; - return this.http.authedRequest(callback, Method.Put, path, undefined, data); + return this.http.authedRequest(Method.Put, path, undefined, data); } /** * Delete an alias to room ID mapping. This alias must be on your local server, * and you must have sufficient access to do this operation. * @param {string} alias The room alias to delete. - * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: an empty object {}. * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public deleteAlias(alias: string, callback?: Callback): Promise<{}> { + public deleteAlias(alias: string): Promise<{}> { const path = utils.encodeUri("/directory/room/$alias", { $alias: alias, }); - return this.http.authedRequest(callback, Method.Delete, path); + return this.http.authedRequest(Method.Delete, path); } /** @@ -7829,53 +7579,49 @@ export class MatrixClient extends TypedEventEmitter { const path = utils.encodeUri("/rooms/$roomId/aliases", { $roomId: roomId }); - const prefix = PREFIX_V3; - return this.http.authedRequest(undefined, Method.Get, path, undefined, undefined, { prefix }); + const prefix = ClientPrefix.V3; + return this.http.authedRequest(Method.Get, path, undefined, undefined, { prefix }); } /** * Get room info for the given alias. * @param {string} alias The room alias to resolve. - * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: Object with room_id and servers. * @return {module:http-api.MatrixError} Rejects: with an error response. */ public getRoomIdForAlias( alias: string, - callback?: Callback, ): Promise<{ room_id: string, servers: string[] }> { // eslint-disable-line camelcase // TODO: deprecate this or resolveRoomAlias const path = utils.encodeUri("/directory/room/$alias", { $alias: alias, }); - return this.http.authedRequest(callback, Method.Get, path); + return this.http.authedRequest(Method.Get, path); } /** * @param {string} roomAlias - * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: Object with room_id and servers. * @return {module:http-api.MatrixError} Rejects: with an error response. */ // eslint-disable-next-line camelcase - public resolveRoomAlias(roomAlias: string, callback?: Callback): Promise<{ room_id: string, servers: string[] }> { + public resolveRoomAlias(roomAlias: string): Promise<{ room_id: string, servers: string[] }> { // TODO: deprecate this or getRoomIdForAlias const path = utils.encodeUri("/directory/room/$alias", { $alias: roomAlias }); - return this.http.request(callback, Method.Get, path); + return this.http.request(Method.Get, path); } /** * Get the visibility of a room in the current HS's room directory * @param {string} roomId - * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: TODO * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public getRoomDirectoryVisibility(roomId: string, callback?: Callback): Promise<{ visibility: Visibility }> { + public getRoomDirectoryVisibility(roomId: string): Promise<{ visibility: Visibility }> { const path = utils.encodeUri("/directory/list/room/$roomId", { $roomId: roomId, }); - return this.http.authedRequest(callback, Method.Get, path); + return this.http.authedRequest(Method.Get, path); } /** @@ -7884,15 +7630,14 @@ export class MatrixClient extends TypedEventEmitter { + public setRoomDirectoryVisibility(roomId: string, visibility: Visibility): Promise<{}> { const path = utils.encodeUri("/directory/list/room/$roomId", { $roomId: roomId, }); - return this.http.authedRequest(callback, Method.Put, path, undefined, { visibility }); + return this.http.authedRequest(Method.Put, path, undefined, { visibility }); } /** @@ -7904,7 +7649,6 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types const path = utils.encodeUri("/directory/list/appservice/$networkId/$roomId", { $networkId: networkId, $roomId: roomId, }); - return this.http.authedRequest( - callback, Method.Put, path, undefined, { "visibility": visibility }, - ); + return this.http.authedRequest(Method.Put, path, undefined, { "visibility": visibility }); } /** @@ -7940,7 +7681,7 @@ export class MatrixClient extends TypedEventEmitter( - file: FileType, - opts?: O, - ): IAbortablePromise> { - return this.http.uploadContent(file, opts); + public uploadContent(file: FileType, opts?: UploadOpts): Promise { + return this.http.uploadContent(file, opts); } /** * Cancel a file upload in progress - * @param {Promise} promise The promise returned from uploadContent + * @param {Promise} upload The object returned from uploadContent * @return {boolean} true if canceled, otherwise false */ - public cancelUpload(promise: IAbortablePromise): boolean { - return this.http.cancelUpload(promise); + public cancelUpload(upload: Promise): boolean { + return this.http.cancelUpload(upload); } /** @@ -8007,7 +7741,7 @@ export class MatrixClient extends TypedEventEmitter { - if (utils.isFunction(info)) { - callback = info as any as Callback; // legacy - info = undefined; - } - const path = info ? utils.encodeUri("/profile/$userId/$info", { $userId: userId, $info: info }) : utils.encodeUri("/profile/$userId", { $userId: userId }); - return this.http.authedRequest(callback, Method.Get, path); + return this.http.authedRequest(Method.Get, path); } /** - * @param {module:client.callback} callback Optional. * @return {Promise} Resolves to a list of the user's threepids. * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public getThreePids(callback?: Callback): Promise<{ threepids: IThreepid[] }> { - return this.http.authedRequest(callback, Method.Get, "/account/3pid"); + public getThreePids(): Promise<{ threepids: IThreepid[] }> { + return this.http.authedRequest(Method.Get, "/account/3pid"); } /** @@ -8056,19 +7782,16 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types + public addThreePid(creds: any, bind: boolean): Promise { // TODO: Types const path = "/account/3pid"; const data = { 'threePidCreds': creds, 'bind': bind, }; - return this.http.authedRequest( - callback, Method.Post, path, undefined, data, - ); + return this.http.authedRequest(Method.Post, path, undefined, data); } /** @@ -8085,8 +7808,8 @@ export class MatrixClient extends TypedEventEmitter { const path = "/account/3pid/add"; - const prefix = await this.isVersionSupported("r0.6.0") ? PREFIX_R0 : PREFIX_UNSTABLE; - return this.http.authedRequest(undefined, Method.Post, path, undefined, data, { prefix }); + const prefix = await this.isVersionSupported("r0.6.0") ? ClientPrefix.R0 : ClientPrefix.Unstable; + return this.http.authedRequest(Method.Post, path, undefined, data, { prefix }); } /** @@ -8105,11 +7828,8 @@ export class MatrixClient extends TypedEventEmitter { const path = "/account/3pid/bind"; - const prefix = await this.isVersionSupported("r0.6.0") ? - PREFIX_R0 : PREFIX_UNSTABLE; - return this.http.authedRequest( - undefined, Method.Post, path, undefined, data, { prefix }, - ); + const prefix = await this.isVersionSupported("r0.6.0") ? ClientPrefix.R0 : ClientPrefix.Unstable; + return this.http.authedRequest(Method.Post, path, undefined, data, { prefix }); } /** @@ -8134,8 +7854,8 @@ export class MatrixClient extends TypedEventEmitter { const path = "/account/3pid/delete"; - return this.http.authedRequest(undefined, Method.Post, path, undefined, { medium, address }); + return this.http.authedRequest(Method.Post, path, undefined, { medium, address }); } /** @@ -8160,36 +7880,14 @@ export class MatrixClient extends TypedEventEmitter; - public setPassword( - authDict: any, - newPassword: string, - logoutDevices: boolean, - callback?: Callback, - ): Promise<{}>; - public setPassword( - authDict: any, - newPassword: string, - logoutDevices?: Callback | boolean, - callback?: Callback, + logoutDevices?: boolean, ): Promise<{}> { - if (typeof logoutDevices === 'function') { - callback = logoutDevices; - } - if (typeof logoutDevices !== 'boolean') { - // Use backwards compatible behaviour of not specifying logout_devices - // This way it is left up to the server: - logoutDevices = undefined; - } - const path = "/account/password"; const data = { 'auth': authDict, @@ -8197,9 +7895,7 @@ export class MatrixClient extends TypedEventEmitter( - callback, Method.Post, path, undefined, data, - ); + return this.http.authedRequest<{}>(Method.Post, path, undefined, data); } /** @@ -8208,7 +7904,7 @@ export class MatrixClient extends TypedEventEmitter { - return this.http.authedRequest(undefined, Method.Get, "/devices"); + return this.http.authedRequest(Method.Get, "/devices"); } /** @@ -8221,7 +7917,7 @@ export class MatrixClient extends TypedEventEmitter { - const response = await this.http.authedRequest(callback, Method.Get, "/pushers"); + public async getPushers(): Promise<{ pushers: IPusher[] }> { + const response = await this.http.authedRequest<{ pushers: IPusher[] }>(Method.Get, "/pushers"); // Migration path for clients that connect to a homeserver that does not support // MSC3881 yet, see https://github.com/matrix-org/matrix-spec-proposals/blob/kerry/remote-push-toggle/proposals/3881-remote-push-notification-toggling.md#migration @@ -8310,13 +8005,12 @@ export class MatrixClient extends TypedEventEmitter { + public setPusher(pusher: IPusherRequest): Promise<{}> { const path = "/pushers/set"; - return this.http.authedRequest(callback, Method.Post, path, undefined, pusher); + return this.http.authedRequest(Method.Post, path, undefined, pusher); } /** @@ -8336,12 +8030,11 @@ export class MatrixClient extends TypedEventEmitter { - return this.http.authedRequest(callback, Method.Get, "/pushrules/").then((rules: IPushRules) => { + public getPushRules(): Promise { + return this.http.authedRequest(Method.Get, "/pushrules/").then((rules: IPushRules) => { return PushProcessor.rewriteDefaultRules(rules); }); } @@ -8351,7 +8044,6 @@ export class MatrixClient extends TypedEventEmitter, body: Pick, - callback?: Callback, ): Promise<{}> { // NB. Scope not uri encoded because devices need the '/' const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId", { $kind: kind, $ruleId: ruleId, }); - return this.http.authedRequest(callback, Method.Put, path, undefined, body); + return this.http.authedRequest(Method.Put, path, undefined, body); } /** * @param {string} scope * @param {string} kind * @param {string} ruleId - * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: an empty object {} * @return {module:http-api.MatrixError} Rejects: with an error response. */ @@ -8382,14 +8072,13 @@ export class MatrixClient extends TypedEventEmitter, - callback?: Callback, ): Promise<{}> { // NB. Scope not uri encoded because devices need the '/' const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId", { $kind: kind, $ruleId: ruleId, }); - return this.http.authedRequest(callback, Method.Delete, path); + return this.http.authedRequest(Method.Delete, path); } /** @@ -8398,7 +8087,6 @@ export class MatrixClient extends TypedEventEmitter { const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId/enabled", { $kind: kind, $ruleId: ruleId, }); - return this.http.authedRequest( - callback, Method.Put, path, undefined, { "enabled": enabled }, - ); + return this.http.authedRequest(Method.Put, path, undefined, { "enabled": enabled }); } /** @@ -8424,7 +8109,6 @@ export class MatrixClient extends TypedEventEmitter { const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId/actions", { $kind: kind, $ruleId: ruleId, }); - return this.http.authedRequest( - callback, Method.Put, path, undefined, { "actions": actions }, - ); + return this.http.authedRequest(Method.Put, path, undefined, { "actions": actions }); } /** @@ -8449,19 +8130,17 @@ export class MatrixClient extends TypedEventEmitter { const queryParams: any = {}; if (opts.next_batch) { queryParams.next_batch = opts.next_batch; } - return this.http.authedRequest(callback, Method.Post, "/search", queryParams, opts.body); + return this.http.authedRequest(Method.Post, "/search", queryParams, opts.body); } /** @@ -8472,24 +8151,21 @@ export class MatrixClient extends TypedEventEmitter { - return this.http.authedRequest(callback, Method.Post, "/keys/upload", undefined, content); + return this.http.authedRequest(Method.Post, "/keys/upload", undefined, content); } public uploadKeySignatures(content: KeySignatures): Promise { return this.http.authedRequest( - undefined, Method.Post, '/keys/signatures/upload', undefined, + Method.Post, '/keys/signatures/upload', undefined, content, { - prefix: PREFIX_UNSTABLE, + prefix: ClientPrefix.Unstable, }, ); } @@ -8507,13 +8183,7 @@ export class MatrixClient extends TypedEventEmitter { - if (utils.isFunction(opts)) { - // opts used to be 'callback'. - throw new Error('downloadKeysForUsers no longer accepts a callback parameter'); - } - opts = opts || {}; - + public downloadKeysForUsers(userIds: string[], opts: { token?: string } = {}): Promise { const content: any = { device_keys: {}, }; @@ -8524,7 +8194,7 @@ export class MatrixClient extends TypedEventEmitter { // API returns empty object const data = Object.assign({}, keys); if (auth) Object.assign(data, { auth }); return this.http.authedRequest( - undefined, Method.Post, "/keys/device_signing/upload", undefined, data, { - prefix: PREFIX_UNSTABLE, + Method.Post, "/keys/device_signing/upload", undefined, data, { + prefix: ClientPrefix.Unstable, }, ); } @@ -8613,11 +8283,8 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types const params = { @@ -8660,8 +8325,8 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types const params = { @@ -8710,8 +8373,8 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types - const params = { - sid: sid, - client_secret: clientSecret, - token: msisdnToken, - }; - - return this.http.requestOtherUrl( - undefined, Method.Post, url, undefined, params, - ); + const u = new URL(url); + u.searchParams.set("sid", sid); + u.searchParams.set("client_secret", clientSecret); + u.searchParams.set("token", msisdnToken); + return this.http.requestOtherUrl(Method.Post, u); } /** @@ -8795,8 +8454,8 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types return this.http.idServerRequest( - undefined, Method.Get, "/hash_details", - null, PREFIX_IDENTITY_V2, identityAccessToken, + Method.Get, "/hash_details", + null, IdentityPrefix.V2, identityAccessToken, ); } @@ -8864,8 +8523,8 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types // Note: we're using the V2 API by calling this function, but our @@ -8912,7 +8569,6 @@ export class MatrixClient extends TypedEventEmitter p.address === address); if (!result) { - if (callback) callback(null, {}); return {}; } @@ -8928,7 +8584,6 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types return this.http.idServerRequest( - undefined, Method.Get, "/account", - undefined, PREFIX_IDENTITY_V2, identityAccessToken, + Method.Get, "/account", + undefined, IdentityPrefix.V2, identityAccessToken, ); } @@ -9020,7 +8675,7 @@ export class MatrixClient extends TypedEventEmitter { return this.http.authedRequest>( - undefined, Method.Get, "/thirdparty/protocols", + Method.Get, "/thirdparty/protocols", ).then((response) => { // sanity check if (!response || typeof (response) !== 'object') { @@ -9067,7 +8722,7 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types const url = this.termsUrlForService(serviceType, baseUrl); - return this.http.requestOtherUrl(undefined, Method.Get, url); + return this.http.requestOtherUrl(Method.Get, url); } public agreeToTerms( @@ -9098,10 +8753,13 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types const url = this.termsUrlForService(serviceType, baseUrl); + utils.encodeParams({ + user_accepts: termsUrls, + }, url.searchParams); const headers = { Authorization: "Bearer " + accessToken, }; - return this.http.requestOtherUrl(undefined, Method.Post, url, null, { user_accepts: termsUrls }, { headers }); + return this.http.requestOtherUrl(Method.Post, url, null, { headers }); } /** @@ -9118,7 +8776,7 @@ export class MatrixClient extends TypedEventEmitter(undefined, Method.Get, path, queryParams, undefined, { - prefix: PREFIX_V1, + return this.http.authedRequest(Method.Get, path, queryParams, undefined, { + prefix: ClientPrefix.V1, }).catch(e => { if (e.errcode === "M_UNRECOGNIZED") { // fall back to the prefixed hierarchy API. - return this.http.authedRequest(undefined, Method.Get, path, queryParams, undefined, { + return this.http.authedRequest(Method.Get, path, queryParams, undefined, { prefix: "/_matrix/client/unstable/org.matrix.msc2946", }); } @@ -9179,7 +8837,7 @@ export class MatrixClient extends TypedEventEmitter { + req: MSC3575SlidingSyncRequest, + proxyBaseUrl?: string, + abortSignal?: AbortSignal, + ): Promise { const qps: Record = {}; if (req.pos) { qps.pos = req.pos; @@ -9252,7 +8913,6 @@ export class MatrixClient extends TypedEventEmitter( - undefined, Method.Post, "/sync", qps, @@ -9261,6 +8921,7 @@ export class MatrixClient extends TypedEventEmitter { const path = utils.encodeUri("/rooms/$roomid/summary", { $roomid: roomIdOrAlias }); - return this.http.authedRequest(undefined, Method.Get, path, { via }, undefined, { - qsStringifyOptions: { arrayFormat: 'repeat' }, + return this.http.authedRequest(Method.Get, path, { via }, undefined, { prefix: "/_matrix/client/unstable/im.nheko.summary", }); } @@ -9314,7 +8974,7 @@ export class MatrixClient extends TypedEventEmitter { // eslint-disable-line camelcase - return this.http.authedRequest(undefined, Method.Get, "/account/whoami"); + return this.http.authedRequest(Method.Get, "/account/whoami"); } /** @@ -9333,7 +8993,6 @@ export class MatrixClient extends TypedEventEmitter( - undefined, Method.Put, "/dehydrated_device", undefined, @@ -273,7 +272,6 @@ export class DehydrationManager { logger.log("Uploading keys to server"); await this.crypto.baseApis.http.authedRequest( - undefined, Method.Post, "/keys/upload/" + encodeURI(deviceId), undefined, diff --git a/src/http-api.ts b/src/http-api.ts deleted file mode 100644 index 1a7376b00a0..00000000000 --- a/src/http-api.ts +++ /dev/null @@ -1,1140 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 - 2021 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. -*/ - -/** - * This is an internal module. See {@link MatrixHttpApi} for the public class. - * @module http-api - */ - -import { parse as parseContentType, ParsedMediaType } from "content-type"; - -import type { IncomingHttpHeaders, IncomingMessage } from "http"; -import type { Request as _Request, CoreOptions } from "request"; -// we use our own implementation of setTimeout, so that if we get suspended in -// the middle of a /sync, we cancel the sync as soon as we awake, rather than -// waiting for the delay to elapse. -import * as callbacks from "./realtime-callbacks"; -import { IUploadOpts } from "./@types/requests"; -import { IAbortablePromise, IUsageLimit } from "./@types/partials"; -import { IDeferred, sleep } from "./utils"; -import { Callback } from "./client"; -import * as utils from "./utils"; -import { logger } from './logger'; -import { TypedEventEmitter } from "./models/typed-event-emitter"; - -/* -TODO: -- CS: complete register function (doing stages) -- Identity server: linkEmail, authEmail, bindEmail, lookup3pid -*/ - -/** - * A constant representing the URI path for release 0 of the Client-Server HTTP API. - */ -export const PREFIX_R0 = "/_matrix/client/r0"; - -/** - * A constant representing the URI path for the legacy release v1 of the Client-Server HTTP API. - */ -export const PREFIX_V1 = "/_matrix/client/v1"; - -/** - * A constant representing the URI path for Client-Server API endpoints versioned at v3. - */ -export const PREFIX_V3 = "/_matrix/client/v3"; - -/** - * A constant representing the URI path for as-yet unspecified Client-Server HTTP APIs. - */ -export const PREFIX_UNSTABLE = "/_matrix/client/unstable"; - -/** - * URI path for v1 of the the identity API - * @deprecated Use v2. - */ -export const PREFIX_IDENTITY_V1 = "/_matrix/identity/api/v1"; - -/** - * URI path for the v2 identity API - */ -export const PREFIX_IDENTITY_V2 = "/_matrix/identity/v2"; - -/** - * URI path for the media repo API - */ -export const PREFIX_MEDIA_R0 = "/_matrix/media/r0"; - -type RequestProps = "method" - | "withCredentials" - | "json" - | "headers" - | "qs" - | "body" - | "qsStringifyOptions" - | "useQuerystring" - | "timeout"; - -export interface IHttpOpts { - baseUrl: string; - idBaseUrl?: string; - prefix: string; - onlyData: boolean; - accessToken?: string; - extraParams?: Record; - localTimeoutMs?: number; - useAuthorizationHeader?: boolean; - request(opts: Pick & { - uri: string; - method: Method; - // eslint-disable-next-line camelcase - _matrix_opts: IHttpOpts; - }, callback: RequestCallback): IRequest; -} - -interface IRequest extends _Request { - onprogress?(e: unknown): void; -} - -interface IRequestOpts { - prefix?: string; - baseUrl?: string; - localTimeoutMs?: number; - headers?: Record; - json?: boolean; // defaults to true - qsStringifyOptions?: CoreOptions["qsStringifyOptions"]; - bodyParser?(body: string): T; - - // Set to true to prevent the request function from emitting - // a Session.logged_out event. This is intended for use on - // endpoints where M_UNKNOWN_TOKEN is a valid/notable error - // response, such as with token refreshes. - inhibitLogoutEmit?: boolean; -} - -export interface IUpload { - loaded: number; - total: number; - promise: IAbortablePromise; -} - -interface IContentUri { - base: string; - path: string; - params: { - // eslint-disable-next-line camelcase - access_token: string; - }; -} - -type ResponseType | void = void> = - O extends { bodyParser: (body: string) => T } ? T : - O extends { json: false } ? string : - T; - -interface IUploadResponse { - // eslint-disable-next-line camelcase - content_uri: string; -} - -// This type's defaults only work for the Browser -// in the Browser we default rawResponse = false & onlyContentUri = true -// in Node we default rawResponse = true & onlyContentUri = false -export type UploadContentResponseType = - O extends undefined ? string : - O extends { rawResponse: true } ? string : - O extends { onlyContentUri: true } ? string : - O extends { rawResponse: false } ? IUploadResponse : - O extends { onlyContentUri: false } ? IUploadResponse : - string; - -export enum Method { - Get = "GET", - Put = "PUT", - Post = "POST", - Delete = "DELETE", -} - -export type FileType = Document | XMLHttpRequestBodyInit; - -export enum HttpApiEvent { - SessionLoggedOut = "Session.logged_out", - NoConsent = "no_consent", -} - -export type HttpApiEventHandlerMap = { - [HttpApiEvent.SessionLoggedOut]: (err: MatrixError) => void; - [HttpApiEvent.NoConsent]: (message: string, consentUri: string) => void; -}; - -/** - * Construct a MatrixHttpApi. - * @constructor - * @param {EventEmitter} eventEmitter The event emitter to use for emitting events - * @param {Object} opts The options to use for this HTTP API. - * @param {string} opts.baseUrl Required. The base client-server URL e.g. - * 'http://localhost:8008'. - * @param {Function} opts.request Required. The function to call for HTTP - * requests. This function must look like function(opts, callback){ ... }. - * @param {string} opts.prefix Required. The matrix client prefix to use, e.g. - * '/_matrix/client/r0'. See PREFIX_R0 and PREFIX_UNSTABLE for constants. - * - * @param {boolean} opts.onlyData True to return only the 'data' component of the - * response (e.g. the parsed HTTP body). If false, requests will return an - * object with the properties code, headers and data. - * - * @param {string=} opts.accessToken The access_token to send with requests. Can be - * null to not send an access token. - * @param {Object=} opts.extraParams Optional. Extra query parameters to send on - * requests. - * @param {Number=} opts.localTimeoutMs The default maximum amount of time to wait - * before timing out the request. If not specified, there is no timeout. - * @param {boolean} [opts.useAuthorizationHeader = false] Set to true to use - * Authorization header instead of query param to send the access token to the server. - */ -export class MatrixHttpApi { - private uploads: IUpload[] = []; - - constructor( - private eventEmitter: TypedEventEmitter, - public readonly opts: IHttpOpts, - ) { - utils.checkObjectHasKeys(opts, ["baseUrl", "request", "prefix"]); - opts.onlyData = !!opts.onlyData; - opts.useAuthorizationHeader = !!opts.useAuthorizationHeader; - } - - /** - * Sets the base URL for the identity server - * @param {string} url The new base url - */ - public setIdBaseUrl(url: string): void { - this.opts.idBaseUrl = url; - } - - /** - * Get the content repository url with query parameters. - * @return {Object} An object with a 'base', 'path' and 'params' for base URL, - * path and query parameters respectively. - */ - public getContentUri(): IContentUri { - return { - base: this.opts.baseUrl, - path: "/_matrix/media/r0/upload", - params: { - access_token: this.opts.accessToken, - }, - }; - } - - /** - * Upload content to the homeserver - * - * @param {object} file The object to upload. On a browser, something that - * can be sent to XMLHttpRequest.send (typically a File). Under node.js, - * a Buffer, String or ReadStream. - * - * @param {object} opts options object - * - * @param {string=} opts.name Name to give the file on the server. Defaults - * to file.name. - * - * @param {boolean=} opts.includeFilename if false will not send the filename, - * e.g for encrypted file uploads where filename leaks are undesirable. - * Defaults to true. - * - * @param {string=} opts.type Content-type for the upload. Defaults to - * file.type, or applicaton/octet-stream. - * - * @param {boolean=} opts.rawResponse Return the raw body, rather than - * parsing the JSON. Defaults to false (except on node.js, where it - * defaults to true for backwards compatibility). - * - * @param {boolean=} opts.onlyContentUri Just return the content URI, - * rather than the whole body. Defaults to false (except on browsers, - * where it defaults to true for backwards compatibility). Ignored if - * opts.rawResponse is true. - * - * @param {Function=} opts.callback Deprecated. Optional. The callback to - * invoke on success/failure. See the promise return values for more - * information. - * - * @param {Function=} opts.progressHandler Optional. Called when a chunk of - * data has been uploaded, with an object containing the fields `loaded` - * (number of bytes transferred) and `total` (total size, if known). - * - * @return {Promise} Resolves to response object, as - * determined by this.opts.onlyData, opts.rawResponse, and - * opts.onlyContentUri. Rejects with an error (usually a MatrixError). - */ - public uploadContent( - file: FileType, - opts?: O, - ): IAbortablePromise> { - if (utils.isFunction(opts)) { - // opts used to be callback, backwards compatibility - opts = { - callback: opts as unknown as IUploadOpts["callback"], - } as O; - } else if (!opts) { - opts = {} as O; - } - - // default opts.includeFilename to true (ignoring falsey values) - const includeFilename = opts.includeFilename !== false; - - // if the file doesn't have a mime type, use a default since - // the HS errors if we don't supply one. - const contentType = opts.type || (file as File).type || 'application/octet-stream'; - const fileName = opts.name || (file as File).name; - - // We used to recommend setting file.stream to the thing to upload on - // Node.js. As of 2019-06-11, this is still in widespread use in various - // clients, so we should preserve this for simple objects used in - // Node.js. File API objects (via either the File or Blob interfaces) in - // the browser now define a `stream` method, which leads to trouble - // here, so we also check the type of `stream`. - let body = file; - const bodyStream = (body as File | Blob).stream; // this type is wrong but for legacy reasons is good enough - if (bodyStream && typeof bodyStream !== "function") { - logger.warn( - "Using `file.stream` as the content to upload. Future " + - "versions of the js-sdk will change this to expect `file` to " + - "be the content directly.", - ); - body = bodyStream; - } - - // backwards-compatibility hacks where we used to do different things - // between browser and node. - let rawResponse = opts.rawResponse; - if (rawResponse === undefined) { - if (global.XMLHttpRequest) { - rawResponse = false; - } else { - logger.warn( - "Returning the raw JSON from uploadContent(). Future " + - "versions of the js-sdk will change this default, to " + - "return the parsed object. Set opts.rawResponse=false " + - "to change this behaviour now.", - ); - rawResponse = true; - } - } - - let onlyContentUri = opts.onlyContentUri; - if (!rawResponse && onlyContentUri === undefined) { - if (global.XMLHttpRequest) { - logger.warn( - "Returning only the content-uri from uploadContent(). " + - "Future versions of the js-sdk will change this " + - "default, to return the whole response object. Set " + - "opts.onlyContentUri=false to change this behaviour now.", - ); - onlyContentUri = true; - } else { - onlyContentUri = false; - } - } - - // browser-request doesn't support File objects because it deep-copies - // the options using JSON.parse(JSON.stringify(options)). Instead of - // loading the whole file into memory as a string and letting - // browser-request base64 encode and then decode it again, we just - // use XMLHttpRequest directly. - // (browser-request doesn't support progress either, which is also kind - // of important here) - - const upload = { loaded: 0, total: 0 } as IUpload; - let promise: IAbortablePromise>; - - // XMLHttpRequest doesn't parse JSON for us. request normally does, but - // we're setting opts.json=false so that it doesn't JSON-encode the - // request, which also means it doesn't JSON-decode the response. Either - // way, we have to JSON-parse the response ourselves. - let bodyParser: ((body: string) => any) | undefined; - if (!rawResponse) { - bodyParser = function(rawBody: string) { - let body = JSON.parse(rawBody); - if (onlyContentUri) { - body = body.content_uri; - if (body === undefined) { - throw Error('Bad response'); - } - } - return body; - }; - } - - if (global.XMLHttpRequest) { - const defer = utils.defer>(); - const xhr = new global.XMLHttpRequest(); - const cb = requestCallback(defer, opts.callback, this.opts.onlyData); - - const timeoutFn = function() { - xhr.abort(); - cb(new Error('Timeout')); - }; - - // set an initial timeout of 30s; we'll advance it each time we get a progress notification - let timeoutTimer = callbacks.setTimeout(timeoutFn, 30000); - - xhr.onreadystatechange = function() { - let resp: string; - switch (xhr.readyState) { - case global.XMLHttpRequest.DONE: - callbacks.clearTimeout(timeoutTimer); - try { - if (xhr.status === 0) { - throw new AbortError(); - } - if (!xhr.responseText) { - throw new Error('No response body.'); - } - resp = xhr.responseText; - if (bodyParser) { - resp = bodyParser(resp); - } - } catch (err) { - err.httpStatus = xhr.status; - cb(err); - return; - } - cb(undefined, xhr, resp); - break; - } - }; - xhr.upload.addEventListener("progress", function(ev) { - callbacks.clearTimeout(timeoutTimer); - upload.loaded = ev.loaded; - upload.total = ev.total; - timeoutTimer = callbacks.setTimeout(timeoutFn, 30000); - if (opts.progressHandler) { - opts.progressHandler({ - loaded: ev.loaded, - total: ev.total, - }); - } - }); - let url = this.opts.baseUrl + "/_matrix/media/r0/upload"; - - const queryArgs = []; - - if (includeFilename && fileName) { - queryArgs.push("filename=" + encodeURIComponent(fileName)); - } - - if (!this.opts.useAuthorizationHeader) { - queryArgs.push("access_token=" + encodeURIComponent(this.opts.accessToken)); - } - - if (queryArgs.length > 0) { - url += "?" + queryArgs.join("&"); - } - - xhr.open("POST", url); - if (this.opts.useAuthorizationHeader) { - xhr.setRequestHeader("Authorization", "Bearer " + this.opts.accessToken); - } - xhr.setRequestHeader("Content-Type", contentType); - xhr.send(body); - promise = defer.promise as IAbortablePromise>; - - // dirty hack (as per doRequest) to allow the upload to be cancelled. - promise.abort = xhr.abort.bind(xhr); - } else { - const queryParams: Record = {}; - - if (includeFilename && fileName) { - queryParams.filename = fileName; - } - - const headers: Record = { "Content-Type": contentType }; - - // authedRequest uses `request` which is no longer maintained. - // `request` has a bug where if the body is zero bytes then you get an error: `Argument error, options.body`. - // See https://github.com/request/request/issues/920 - // if body looks like a byte array and empty then set the Content-Length explicitly as a workaround: - if ((body as unknown as ArrayLike).length === 0) { - headers["Content-Length"] = "0"; - } - - promise = this.authedRequest>( - opts.callback, Method.Post, "/upload", queryParams, body, { - prefix: "/_matrix/media/r0", - headers, - json: false, - bodyParser, - }, - ); - } - - // remove the upload from the list on completion - upload.promise = promise.finally(() => { - for (let i = 0; i < this.uploads.length; ++i) { - if (this.uploads[i] === upload) { - this.uploads.splice(i, 1); - return; - } - } - }) as IAbortablePromise>; - - // copy our dirty abort() method to the new promise - upload.promise.abort = promise.abort; - this.uploads.push(upload); - - return upload.promise as IAbortablePromise>; - } - - public cancelUpload(promise: IAbortablePromise): boolean { - if (promise.abort) { - promise.abort(); - return true; - } - return false; - } - - public getCurrentUploads(): IUpload[] { - return this.uploads; - } - - public idServerRequest( - callback: Callback, - method: Method, - path: string, - params: Record, - prefix: string, - accessToken: string, - ): Promise { - if (!this.opts.idBaseUrl) { - throw new Error("No identity server base URL set"); - } - - const fullUri = this.opts.idBaseUrl + prefix + path; - - if (callback !== undefined && !utils.isFunction(callback)) { - throw Error( - "Expected callback to be a function but got " + typeof callback, - ); - } - - const opts = { - uri: fullUri, - method, - withCredentials: false, - json: true, // we want a JSON response if we can - _matrix_opts: this.opts, - headers: {}, - } as Parameters[0]; - - if (method === Method.Get) { - opts.qs = params; - } else if (typeof params === "object") { - opts.json = params; - } - - if (accessToken) { - opts.headers['Authorization'] = `Bearer ${accessToken}`; - } - - const defer = utils.defer(); - this.opts.request(opts, requestCallback(defer, callback, this.opts.onlyData)); - return defer.promise; - } - - /** - * Perform an authorised request to the homeserver. - * @param {Function} callback Optional. The callback to invoke on - * success/failure. See the promise return values for more information. - * @param {string} method The HTTP method e.g. "GET". - * @param {string} path The HTTP path after the supplied prefix e.g. - * "/createRoom". - * - * @param {Object=} queryParams A dict of query params (these will NOT be - * urlencoded). If unspecified, there will be no query params. - * - * @param {Object} [data] The HTTP JSON body. - * - * @param {Object|Number=} opts additional options. If a number is specified, - * this is treated as `opts.localTimeoutMs`. - * - * @param {Number=} opts.localTimeoutMs The maximum amount of time to wait before - * timing out the request. If not specified, there is no timeout. - * - * @param {string=} opts.prefix The full prefix to use e.g. - * "/_matrix/client/v2_alpha". If not specified, uses this.opts.prefix. - * - * @param {string=} opts.baseUrl The alternative base url to use. - * If not specified, uses this.opts.baseUrl - * - * @param {Object=} opts.headers map of additional request headers - * - * @return {Promise} Resolves to {data: {Object}, - * headers: {Object}, code: {Number}}. - * If onlyData is set, this will resolve to the data - * object only. - * @return {module:http-api.MatrixError} Rejects with an error if a problem - * occurred. This includes network problems and Matrix-specific error JSON. - */ - public authedRequest = IRequestOpts>( - callback: Callback | undefined, - method: Method, - path: string, - queryParams?: Record, - data?: CoreOptions["body"], - opts?: O | number, // number is legacy - ): IAbortablePromise> { - if (!queryParams) queryParams = {}; - let requestOpts = (opts || {}) as O; - - if (this.opts.useAuthorizationHeader) { - if (isFinite(opts as number)) { - // opts used to be localTimeoutMs - requestOpts = { - localTimeoutMs: opts as number, - } as O; - } - - if (!requestOpts.headers) { - requestOpts.headers = {}; - } - if (!requestOpts.headers.Authorization) { - requestOpts.headers.Authorization = "Bearer " + this.opts.accessToken; - } - if (queryParams.access_token) { - delete queryParams.access_token; - } - } else if (!queryParams.access_token) { - queryParams.access_token = this.opts.accessToken; - } - - const requestPromise = this.request(callback, method, path, queryParams, data, requestOpts); - - requestPromise.catch((err: MatrixError) => { - if (err.errcode == 'M_UNKNOWN_TOKEN' && !requestOpts?.inhibitLogoutEmit) { - this.eventEmitter.emit(HttpApiEvent.SessionLoggedOut, err); - } else if (err.errcode == 'M_CONSENT_NOT_GIVEN') { - this.eventEmitter.emit(HttpApiEvent.NoConsent, err.message, err.data.consent_uri); - } - }); - - // return the original promise, otherwise tests break due to it having to - // go around the event loop one more time to process the result of the request - return requestPromise; - } - - /** - * Perform a request to the homeserver without any credentials. - * @param {Function} callback Optional. The callback to invoke on - * success/failure. See the promise return values for more information. - * @param {string} method The HTTP method e.g. "GET". - * @param {string} path The HTTP path after the supplied prefix e.g. - * "/createRoom". - * - * @param {Object=} queryParams A dict of query params (these will NOT be - * urlencoded). If unspecified, there will be no query params. - * - * @param {Object} [data] The HTTP JSON body. - * - * @param {Object=} opts additional options - * - * @param {Number=} opts.localTimeoutMs The maximum amount of time to wait before - * timing out the request. If not specified, there is no timeout. - * - * @param {string=} opts.prefix The full prefix to use e.g. - * "/_matrix/client/v2_alpha". If not specified, uses this.opts.prefix. - * - * @param {Object=} opts.headers map of additional request headers - * - * @return {Promise} Resolves to {data: {Object}, - * headers: {Object}, code: {Number}}. - * If onlyData is set, this will resolve to the data - * object only. - * @return {module:http-api.MatrixError} Rejects with an error if a problem - * occurred. This includes network problems and Matrix-specific error JSON. - */ - public request = IRequestOpts>( - callback: Callback | undefined, - method: Method, - path: string, - queryParams?: CoreOptions["qs"], - data?: CoreOptions["body"], - opts?: O, - ): IAbortablePromise> { - const prefix = opts?.prefix ?? this.opts.prefix; - const baseUrl = opts?.baseUrl ?? this.opts.baseUrl; - const fullUri = baseUrl + prefix + path; - - return this.requestOtherUrl(callback, method, fullUri, queryParams, data, opts); - } - - /** - * Perform a request to an arbitrary URL. - * @param {Function} callback Optional. The callback to invoke on - * success/failure. See the promise return values for more information. - * @param {string} method The HTTP method e.g. "GET". - * @param {string} uri The HTTP URI - * - * @param {Object=} queryParams A dict of query params (these will NOT be - * urlencoded). If unspecified, there will be no query params. - * - * @param {Object} [data] The HTTP JSON body. - * - * @param {Object=} opts additional options - * - * @param {Number=} opts.localTimeoutMs The maximum amount of time to wait before - * timing out the request. If not specified, there is no timeout. - * - * @param {string=} opts.prefix The full prefix to use e.g. - * "/_matrix/client/v2_alpha". If not specified, uses this.opts.prefix. - * - * @param {Object=} opts.headers map of additional request headers - * - * @return {Promise} Resolves to {data: {Object}, - * headers: {Object}, code: {Number}}. - * If onlyData is set, this will resolve to the data - * object only. - * @return {module:http-api.MatrixError} Rejects with an error if a problem - * occurred. This includes network problems and Matrix-specific error JSON. - */ - public requestOtherUrl = IRequestOpts>( - callback: Callback | undefined, - method: Method, - uri: string, - queryParams?: CoreOptions["qs"], - data?: CoreOptions["body"], - opts?: O | number, // number is legacy - ): IAbortablePromise> { - let requestOpts = (opts || {}) as O; - if (isFinite(opts as number)) { - // opts used to be localTimeoutMs - requestOpts = { - localTimeoutMs: opts as number, - } as O; - } - - return this.doRequest(callback, method, uri, queryParams, data, requestOpts); - } - - /** - * Form and return a homeserver request URL based on the given path - * params and prefix. - * @param {string} path The HTTP path after the supplied prefix e.g. - * "/createRoom". - * @param {Object} queryParams A dict of query params (these will NOT be - * urlencoded). - * @param {string} prefix The full prefix to use e.g. - * "/_matrix/client/v2_alpha". - * @return {string} URL - */ - public getUrl(path: string, queryParams: CoreOptions["qs"], prefix: string): string { - let queryString = ""; - if (queryParams) { - queryString = "?" + utils.encodeParams(queryParams); - } - return this.opts.baseUrl + prefix + path + queryString; - } - - /** - * @private - * - * @param {function} callback - * @param {string} method - * @param {string} uri - * @param {object} queryParams - * @param {object|string} data - * @param {object=} opts - * - * @param {boolean} [opts.json =true] Json-encode data before sending, and - * decode response on receipt. (We will still json-decode error - * responses, even if this is false.) - * - * @param {object=} opts.headers extra request headers - * - * @param {number=} opts.localTimeoutMs client-side timeout for the - * request. Default timeout if falsy. - * - * @param {function=} opts.bodyParser function to parse the body of the - * response before passing it to the promise and callback. - * - * @return {Promise} a promise which resolves to either the - * response object (if this.opts.onlyData is truthy), or the parsed - * body. Rejects - * - * Generic T is the callback/promise resolve type - * Generic O should be inferred - */ - private doRequest = IRequestOpts>( - callback: Callback | undefined, - method: Method, - uri: string, - queryParams?: Record, - data?: CoreOptions["body"], - opts?: O, - ): IAbortablePromise> { - if (callback !== undefined && !utils.isFunction(callback)) { - throw Error("Expected callback to be a function but got " + typeof callback); - } - - if (this.opts.extraParams) { - queryParams = { - ...(queryParams || {}), - ...this.opts.extraParams, - }; - } - - const headers = Object.assign({}, opts.headers || {}); - if (!opts) opts = {} as O; - const json = opts.json ?? true; - let bodyParser = opts.bodyParser; - - // we handle the json encoding/decoding here, because request and - // browser-request make a mess of it. Specifically, they attempt to - // json-decode plain-text error responses, which in turn means that the - // actual error gets swallowed by a SyntaxError. - - if (json) { - if (data) { - data = JSON.stringify(data); - headers['content-type'] = 'application/json'; - } - - if (!headers['accept']) { - headers['accept'] = 'application/json'; - } - - if (bodyParser === undefined) { - bodyParser = function(rawBody: string) { - return JSON.parse(rawBody); - }; - } - } - - const defer = utils.defer(); - - let timeoutId: number; - let timedOut = false; - let req: IRequest; - const localTimeoutMs = opts.localTimeoutMs || this.opts.localTimeoutMs; - - const resetTimeout = () => { - if (localTimeoutMs) { - if (timeoutId) { - callbacks.clearTimeout(timeoutId); - } - timeoutId = callbacks.setTimeout(function() { - timedOut = true; - req?.abort?.(); - defer.reject(new MatrixError({ - error: "Locally timed out waiting for a response", - errcode: "ORG.MATRIX.JSSDK_TIMEOUT", - timeout: localTimeoutMs, - })); - }, localTimeoutMs); - } - }; - resetTimeout(); - - const reqPromise = defer.promise as IAbortablePromise>; - - try { - req = this.opts.request( - { - uri: uri, - method: method, - withCredentials: false, - qs: queryParams, - qsStringifyOptions: opts.qsStringifyOptions, - useQuerystring: true, - body: data, - json: false, - timeout: localTimeoutMs, - headers: headers || {}, - _matrix_opts: this.opts, - }, - (err, response, body) => { - if (localTimeoutMs) { - callbacks.clearTimeout(timeoutId); - if (timedOut) { - return; // already rejected promise - } - } - - const handlerFn = requestCallback(defer, callback, this.opts.onlyData, bodyParser); - handlerFn(err, response, body); - }, - ); - if (req) { - // This will only work in a browser, where opts.request is the - // `browser-request` import. Currently, `request` does not support progress - // updates - see https://github.com/request/request/pull/2346. - // `browser-request` returns an XHRHttpRequest which exposes `onprogress` - if ('onprogress' in req) { - req.onprogress = (e) => { - // Prevent the timeout from rejecting the deferred promise if progress is - // seen with the request - resetTimeout(); - }; - } - - // FIXME: This is EVIL, but I can't think of a better way to expose - // abort() operations on underlying HTTP requests :( - if (req.abort) { - reqPromise.abort = req.abort.bind(req); - } - } - } catch (ex) { - defer.reject(ex); - if (callback) { - callback(ex); - } - } - return reqPromise; - } -} - -type RequestCallback = (err?: Error, response?: XMLHttpRequest | IncomingMessage, body?: string) => void; - -// if using onlyData=false then wrap your expected data type in this generic -export interface IResponse { - code: number; - data: T; - headers?: IncomingHttpHeaders; -} - -function getStatusCode(response: XMLHttpRequest | IncomingMessage): number { - return (response as XMLHttpRequest).status || (response as IncomingMessage).statusCode; -} - -/* - * Returns a callback that can be invoked by an HTTP request on completion, - * that will either resolve or reject the given defer as well as invoke the - * given userDefinedCallback (if any). - * - * HTTP errors are transformed into javascript errors and the deferred is rejected. - * - * If bodyParser is given, it is used to transform the body of the successful - * responses before passing to the defer/callback. - * - * If onlyData is true, the defer/callback is invoked with the body of the - * response, otherwise the result object (with `code` and `data` fields) - * - */ -function requestCallback( - defer: IDeferred, - userDefinedCallback?: Callback, - onlyData = false, - bodyParser?: (body: string) => T, -): RequestCallback { - return function(err: Error, response: XMLHttpRequest | IncomingMessage, body: string): void { - if (err) { - // the unit tests use matrix-mock-request, which throw the string "aborted" when aborting a request. - // See https://github.com/matrix-org/matrix-mock-request/blob/3276d0263a561b5b8326b47bae720578a2c7473a/src/index.js#L48 - const aborted = err.name === "AbortError" || (err as any as string) === "aborted"; - if (!aborted && !(err instanceof MatrixError)) { - // browser-request just throws normal Error objects, - // not `TypeError`s like fetch does. So just assume any - // error is due to the connection. - err = new ConnectionError("request failed", err); - } - } - - let data: T | string = body; - - if (!err) { - try { - if (getStatusCode(response) >= 400) { - err = parseErrorResponse(response, body); - } else if (bodyParser) { - data = bodyParser(body); - } - } catch (e) { - err = new Error(`Error parsing server response: ${e}`); - } - } - - if (err) { - defer.reject(err); - userDefinedCallback?.(err); - } else if (onlyData) { - defer.resolve(data as T); - userDefinedCallback?.(null, data as T); - } else { - const res: IResponse = { - code: getStatusCode(response), - - // XXX: why do we bother with this? it doesn't work for - // XMLHttpRequest, so clearly we don't use it. - headers: (response as IncomingMessage).headers, - data: data as T, - }; - // XXX: the variations in caller-expected types here are horrible, - // typescript doesn't do conditional types based on runtime values - defer.resolve(res as any as T); - userDefinedCallback?.(null, res as any as T); - } - }; -} - -/** - * Attempt to turn an HTTP error response into a Javascript Error. - * - * If it is a JSON response, we will parse it into a MatrixError. Otherwise - * we return a generic Error. - * - * @param {XMLHttpRequest|http.IncomingMessage} response response object - * @param {String} body raw body of the response - * @returns {Error} - */ -function parseErrorResponse(response: XMLHttpRequest | IncomingMessage, body?: string) { - const httpStatus = getStatusCode(response); - const contentType = getResponseContentType(response); - - let err; - if (contentType) { - if (contentType.type === 'application/json') { - const jsonBody = typeof(body) === 'object' ? body : JSON.parse(body); - err = new MatrixError(jsonBody); - } else if (contentType.type === 'text/plain') { - err = new Error(`Server returned ${httpStatus} error: ${body}`); - } - } - - if (!err) { - err = new Error(`Server returned ${httpStatus} error`); - } - err.httpStatus = httpStatus; - return err; -} - -/** - * extract the Content-Type header from the response object, and - * parse it to a `{type, parameters}` object. - * - * returns null if no content-type header could be found. - * - * @param {XMLHttpRequest|http.IncomingMessage} response response object - * @returns {{type: String, parameters: Object}?} parsed content-type header, or null if not found - */ -function getResponseContentType(response: XMLHttpRequest | IncomingMessage): ParsedMediaType { - let contentType; - if ((response as XMLHttpRequest).getResponseHeader) { - // XMLHttpRequest provides getResponseHeader - contentType = (response as XMLHttpRequest).getResponseHeader("Content-Type"); - } else if ((response as IncomingMessage).headers) { - // request provides http.IncomingMessage which has a message.headers map - contentType = (response as IncomingMessage).headers['content-type'] || null; - } - - if (!contentType) { - return null; - } - - try { - return parseContentType(contentType); - } catch (e) { - throw new Error(`Error parsing Content-Type '${contentType}': ${e}`); - } -} - -interface IErrorJson extends Partial { - [key: string]: any; // extensible - errcode?: string; - error?: string; -} - -/** - * Construct a Matrix error. This is a JavaScript Error with additional - * information specific to the standard Matrix error response. - * @constructor - * @param {Object} errorJson The Matrix error JSON returned from the homeserver. - * @prop {string} errcode The Matrix 'errcode' value, e.g. "M_FORBIDDEN". - * @prop {string} name Same as MatrixError.errcode but with a default unknown string. - * @prop {string} message The Matrix 'error' value, e.g. "Missing token." - * @prop {Object} data The raw Matrix error JSON used to construct this object. - * @prop {number} httpStatus The numeric HTTP status code given - */ -export class MatrixError extends Error { - public readonly errcode: string; - public readonly data: IErrorJson; - public httpStatus?: number; // set by http-api - - constructor(errorJson: IErrorJson = {}) { - super(`MatrixError: ${errorJson.errcode}`); - this.errcode = errorJson.errcode; - this.name = errorJson.errcode || "Unknown error code"; - this.message = errorJson.error || "Unknown message"; - this.data = errorJson; - } -} - -/** - * Construct a ConnectionError. This is a JavaScript Error indicating - * that a request failed because of some error with the connection, either - * CORS was not correctly configured on the server, the server didn't response, - * the request timed out, or the internet connection on the client side went down. - * @constructor - */ -export class ConnectionError extends Error { - constructor(message: string, cause: Error = undefined) { - super(message + (cause ? `: ${cause.message}` : "")); - } - - get name() { - return "ConnectionError"; - } -} - -export class AbortError extends Error { - constructor() { - super("Operation aborted"); - } - - get name() { - return "AbortError"; - } -} - -/** - * Retries a network operation run in a callback. - * @param {number} maxAttempts maximum attempts to try - * @param {Function} callback callback that returns a promise of the network operation. If rejected with ConnectionError, it will be retried by calling the callback again. - * @return {any} the result of the network operation - * @throws {ConnectionError} If after maxAttempts the callback still throws ConnectionError - */ -export async function retryNetworkOperation(maxAttempts: number, callback: () => Promise): Promise { - let attempts = 0; - let lastConnectionError = null; - while (attempts < maxAttempts) { - try { - if (attempts > 0) { - const timeout = 1000 * Math.pow(2, attempts); - logger.log(`network operation failed ${attempts} times,` + - ` retrying in ${timeout}ms...`); - await sleep(timeout); - } - return callback(); - } catch (err) { - if (err instanceof ConnectionError) { - attempts += 1; - lastConnectionError = err; - } else { - throw err; - } - } - } - throw lastConnectionError; -} diff --git a/src/http-api/errors.ts b/src/http-api/errors.ts new file mode 100644 index 00000000000..d44b23e7a2e --- /dev/null +++ b/src/http-api/errors.ts @@ -0,0 +1,64 @@ +/* +Copyright 2022 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 { IUsageLimit } from "../@types/partials"; + +interface IErrorJson extends Partial { + [key: string]: any; // extensible + errcode?: string; + error?: string; +} + +/** + * Construct a Matrix error. This is a JavaScript Error with additional + * information specific to the standard Matrix error response. + * @constructor + * @param {Object} errorJson The Matrix error JSON returned from the homeserver. + * @prop {string} errcode The Matrix 'errcode' value, e.g. "M_FORBIDDEN". + * @prop {string} name Same as MatrixError.errcode but with a default unknown string. + * @prop {string} message The Matrix 'error' value, e.g. "Missing token." + * @prop {Object} data The raw Matrix error JSON used to construct this object. + * @prop {number} httpStatus The numeric HTTP status code given + */ +export class MatrixError extends Error { + public readonly errcode?: string; + public readonly data: IErrorJson; + + constructor(errorJson: IErrorJson = {}, public httpStatus?: number) { + super(`MatrixError: ${errorJson.errcode}`); + this.errcode = errorJson.errcode; + this.name = errorJson.errcode || "Unknown error code"; + this.message = errorJson.error || "Unknown message"; + this.data = errorJson; + } +} + +/** + * Construct a ConnectionError. This is a JavaScript Error indicating + * that a request failed because of some error with the connection, either + * CORS was not correctly configured on the server, the server didn't response, + * the request timed out, or the internet connection on the client side went down. + * @constructor + */ +export class ConnectionError extends Error { + constructor(message: string, cause?: Error) { + super(message + (cause ? `: ${cause.message}` : "")); + } + + get name() { + return "ConnectionError"; + } +} diff --git a/src/http-api/fetch.ts b/src/http-api/fetch.ts new file mode 100644 index 00000000000..4fecaaecf8d --- /dev/null +++ b/src/http-api/fetch.ts @@ -0,0 +1,327 @@ +/* +Copyright 2022 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. +*/ + +/** + * This is an internal module. See {@link MatrixHttpApi} for the public class. + * @module http-api + */ + +import * as utils from "../utils"; +import { TypedEventEmitter } from "../models/typed-event-emitter"; +import { Method } from "./method"; +import { ConnectionError, MatrixError } from "./errors"; +import { HttpApiEvent, HttpApiEventHandlerMap, IHttpOpts, IRequestOpts } from "./interface"; +import { anySignal, parseErrorResponse, timeoutSignal } from "./utils"; +import { QueryDict } from "../utils"; + +type Body = Record | BodyInit; + +interface TypedResponse extends Response { + json(): Promise; +} + +export type ResponseType = + O extends undefined ? T : + O extends { onlyData: true } ? T : + TypedResponse; + +export class FetchHttpApi { + private abortController = new AbortController(); + + constructor( + private eventEmitter: TypedEventEmitter, + public readonly opts: O, + ) { + utils.checkObjectHasKeys(opts, ["baseUrl", "prefix"]); + opts.onlyData = !!opts.onlyData; + opts.useAuthorizationHeader = opts.useAuthorizationHeader ?? true; + } + + public abort(): void { + this.abortController.abort(); + this.abortController = new AbortController(); + } + + public fetch(resource: URL | string, options?: RequestInit): ReturnType { + if (this.opts.fetchFn) { + return this.opts.fetchFn(resource, options); + } + return global.fetch(resource, options); + } + + /** + * Sets the base URL for the identity server + * @param {string} url The new base url + */ + public setIdBaseUrl(url: string): void { + this.opts.idBaseUrl = url; + } + + public idServerRequest( + method: Method, + path: string, + params: Record, + prefix: string, + accessToken?: string, + ): Promise> { + if (!this.opts.idBaseUrl) { + throw new Error("No identity server base URL set"); + } + + let queryParams: QueryDict | undefined = undefined; + let body: Record | undefined = undefined; + if (method === Method.Get) { + queryParams = params; + } else { + body = params; + } + + const fullUri = this.getUrl(path, queryParams, prefix, this.opts.idBaseUrl); + + const opts: IRequestOpts = { + json: true, + headers: {}, + }; + if (accessToken) { + opts.headers.Authorization = `Bearer ${accessToken}`; + } + + return this.requestOtherUrl(method, fullUri, body, opts); + } + + /** + * Perform an authorised request to the homeserver. + * @param {string} method The HTTP method e.g. "GET". + * @param {string} path The HTTP path after the supplied prefix e.g. + * "/createRoom". + * + * @param {Object=} queryParams A dict of query params (these will NOT be + * urlencoded). If unspecified, there will be no query params. + * + * @param {Object} [body] The HTTP JSON body. + * + * @param {Object|Number=} opts additional options. If a number is specified, + * this is treated as `opts.localTimeoutMs`. + * + * @param {Number=} opts.localTimeoutMs The maximum amount of time to wait before + * timing out the request. If not specified, there is no timeout. + * + * @param {string=} opts.prefix The full prefix to use e.g. + * "/_matrix/client/v2_alpha". If not specified, uses this.opts.prefix. + * + * @param {string=} opts.baseUrl The alternative base url to use. + * If not specified, uses this.opts.baseUrl + * + * @param {Object=} opts.headers map of additional request headers + * + * @return {Promise} Resolves to {data: {Object}, + * headers: {Object}, code: {Number}}. + * If onlyData is set, this will resolve to the data + * object only. + * @return {module:http-api.MatrixError} Rejects with an error if a problem + * occurred. This includes network problems and Matrix-specific error JSON. + */ + public authedRequest( + method: Method, + path: string, + queryParams?: QueryDict, + body?: Body, + opts: IRequestOpts = {}, + ): Promise> { + if (!queryParams) queryParams = {}; + + if (this.opts.useAuthorizationHeader) { + if (!opts.headers) { + opts.headers = {}; + } + if (!opts.headers.Authorization) { + opts.headers.Authorization = "Bearer " + this.opts.accessToken; + } + if (queryParams.access_token) { + delete queryParams.access_token; + } + } else if (!queryParams.access_token) { + queryParams.access_token = this.opts.accessToken; + } + + const requestPromise = this.request(method, path, queryParams, body, opts); + + requestPromise.catch((err: MatrixError) => { + if (err.errcode == 'M_UNKNOWN_TOKEN' && !opts?.inhibitLogoutEmit) { + this.eventEmitter.emit(HttpApiEvent.SessionLoggedOut, err); + } else if (err.errcode == 'M_CONSENT_NOT_GIVEN') { + this.eventEmitter.emit(HttpApiEvent.NoConsent, err.message, err.data.consent_uri); + } + }); + + // return the original promise, otherwise tests break due to it having to + // go around the event loop one more time to process the result of the request + return requestPromise; + } + + /** + * Perform a request to the homeserver without any credentials. + * @param {string} method The HTTP method e.g. "GET". + * @param {string} path The HTTP path after the supplied prefix e.g. + * "/createRoom". + * + * @param {Object=} queryParams A dict of query params (these will NOT be + * urlencoded). If unspecified, there will be no query params. + * + * @param {Object} [body] The HTTP JSON body. + * + * @param {Object=} opts additional options + * + * @param {Number=} opts.localTimeoutMs The maximum amount of time to wait before + * timing out the request. If not specified, there is no timeout. + * + * @param {string=} opts.prefix The full prefix to use e.g. + * "/_matrix/client/v2_alpha". If not specified, uses this.opts.prefix. + * + * @param {Object=} opts.headers map of additional request headers + * + * @return {Promise} Resolves to {data: {Object}, + * headers: {Object}, code: {Number}}. + * If onlyData is set, this will resolve to the data + * object only. + * @return {module:http-api.MatrixError} Rejects with an error if a problem + * occurred. This includes network problems and Matrix-specific error JSON. + */ + public request( + method: Method, + path: string, + queryParams?: QueryDict, + body?: Body, + opts?: IRequestOpts, + ): Promise> { + const fullUri = this.getUrl(path, queryParams, opts?.prefix, opts?.baseUrl); + return this.requestOtherUrl(method, fullUri, body, opts); + } + + /** + * Perform a request to an arbitrary URL. + * @param {string} method The HTTP method e.g. "GET". + * @param {string} url The HTTP URL object. + * + * @param {Object} [body] The HTTP JSON body. + * + * @param {Object=} opts additional options + * + * @param {Number=} opts.localTimeoutMs The maximum amount of time to wait before + * timing out the request. If not specified, there is no timeout. + * + * @param {Object=} opts.headers map of additional request headers + * + * @return {Promise} Resolves to data unless `onlyData` is specified as false, + * where the resolved value will be a fetch Response object. + * @return {module:http-api.MatrixError} Rejects with an error if a problem + * occurred. This includes network problems and Matrix-specific error JSON. + */ + public async requestOtherUrl( + method: Method, + url: URL | string, + body?: Body, + opts: Pick = {}, + ): Promise> { + const headers = Object.assign({}, opts.headers || {}); + const json = opts.json ?? true; + // We can't use getPrototypeOf here as objects made in other contexts e.g. over postMessage won't have same ref + const jsonBody = json && body?.constructor?.name === Object.name; + + if (json) { + if (jsonBody && !headers["Content-Type"]) { + headers["Content-Type"] = "application/json"; + } + + if (!headers["Accept"]) { + headers["Accept"] = "application/json"; + } + } + + const timeout = opts.localTimeoutMs ?? this.opts.localTimeoutMs; + const signals = [ + this.abortController.signal, + ]; + if (timeout !== undefined) { + signals.push(timeoutSignal(timeout)); + } + if (opts.abortSignal) { + signals.push(opts.abortSignal); + } + + let data: BodyInit; + if (jsonBody) { + data = JSON.stringify(body); + } else { + data = body as BodyInit; + } + + const { signal, cleanup } = anySignal(signals); + + let res: Response; + try { + res = await this.fetch(url, { + signal, + method, + body: data, + headers, + mode: "cors", + redirect: "follow", + referrer: "", + referrerPolicy: "no-referrer", + cache: "no-cache", + credentials: "omit", // we send credentials via headers + }); + } catch (e) { + if (e.name === "AbortError") { + throw e; + } + throw new ConnectionError("fetch failed", e); + } finally { + cleanup(); + } + + if (!res.ok) { + throw parseErrorResponse(res, await res.text()); + } + + if (this.opts.onlyData) { + return json ? res.json() : res.text(); + } + return res as ResponseType; + } + + /** + * Form and return a homeserver request URL based on the given path params and prefix. + * @param {string} path The HTTP path after the supplied prefix e.g. "/createRoom". + * @param {Object} queryParams A dict of query params (these will NOT be urlencoded). + * @param {string} prefix The full prefix to use e.g. "/_matrix/client/v2_alpha", defaulting to this.opts.prefix. + * @param {string} baseUrl The baseUrl to use e.g. "https://matrix.org/", defaulting to this.opts.baseUrl. + * @return {string} URL + */ + public getUrl( + path: string, + queryParams?: QueryDict, + prefix?: string, + baseUrl?: string, + ): URL { + const url = new URL((baseUrl ?? this.opts.baseUrl) + (prefix ?? this.opts.prefix) + path); + if (queryParams) { + utils.encodeParams(queryParams, url.searchParams); + } + return url; + } +} diff --git a/src/http-api/index.ts b/src/http-api/index.ts new file mode 100644 index 00000000000..62e4b478e4a --- /dev/null +++ b/src/http-api/index.ts @@ -0,0 +1,216 @@ +/* +Copyright 2022 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 { FetchHttpApi } from "./fetch"; +import { FileType, IContentUri, IHttpOpts, Upload, UploadOpts, UploadResponse } from "./interface"; +import { MediaPrefix } from "./prefix"; +import * as utils from "../utils"; +import * as callbacks from "../realtime-callbacks"; +import { Method } from "./method"; +import { ConnectionError, MatrixError } from "./errors"; +import { parseErrorResponse } from "./utils"; + +export * from "./interface"; +export * from "./prefix"; +export * from "./errors"; +export * from "./method"; +export * from "./utils"; + +export class MatrixHttpApi extends FetchHttpApi { + private uploads: Upload[] = []; + + /** + * Upload content to the homeserver + * + * @param {object} file The object to upload. On a browser, something that + * can be sent to XMLHttpRequest.send (typically a File). Under node.js, + * a Buffer, String or ReadStream. + * + * @param {object} opts options object + * + * @param {string=} opts.name Name to give the file on the server. Defaults + * to file.name. + * + * @param {boolean=} opts.includeFilename if false will not send the filename, + * e.g for encrypted file uploads where filename leaks are undesirable. + * Defaults to true. + * + * @param {string=} opts.type Content-type for the upload. Defaults to + * file.type, or application/octet-stream. + * + * @param {boolean=} opts.rawResponse Return the raw body, rather than + * parsing the JSON. Defaults to false (except on node.js, where it + * defaults to true for backwards compatibility). + * + * @param {boolean=} opts.onlyContentUri Just return the content URI, + * rather than the whole body. Defaults to false (except on browsers, + * where it defaults to true for backwards compatibility). Ignored if + * opts.rawResponse is true. + * + * @param {Function=} opts.progressHandler Optional. Called when a chunk of + * data has been uploaded, with an object containing the fields `loaded` + * (number of bytes transferred) and `total` (total size, if known). + * + * @return {Promise} Resolves to response object, as + * determined by this.opts.onlyData, opts.rawResponse, and + * opts.onlyContentUri. Rejects with an error (usually a MatrixError). + */ + public uploadContent(file: FileType, opts: UploadOpts = {}): Promise { + const includeFilename = opts.includeFilename ?? true; + const abortController = opts.abortController ?? new AbortController(); + + // If the file doesn't have a mime type, use a default since the HS errors if we don't supply one. + const contentType = opts.type ?? (file as File).type ?? 'application/octet-stream'; + const fileName = opts.name ?? (file as File).name; + + const upload = { + loaded: 0, + total: 0, + abortController, + } as Upload; + const defer = utils.defer(); + + if (global.XMLHttpRequest) { + const xhr = new global.XMLHttpRequest(); + + const timeoutFn = function() { + xhr.abort(); + defer.reject(new Error("Timeout")); + }; + + // set an initial timeout of 30s; we'll advance it each time we get a progress notification + let timeoutTimer = callbacks.setTimeout(timeoutFn, 30000); + + xhr.onreadystatechange = function() { + switch (xhr.readyState) { + case global.XMLHttpRequest.DONE: + callbacks.clearTimeout(timeoutTimer); + try { + if (xhr.status === 0) { + throw new DOMException(xhr.statusText, "AbortError"); // mimic fetch API + } + if (!xhr.responseText) { + throw new Error('No response body.'); + } + + if (xhr.status >= 400) { + defer.reject(parseErrorResponse(xhr, xhr.responseText)); + } else { + defer.resolve(JSON.parse(xhr.responseText)); + } + } catch (err) { + if (err.name === "AbortError") { + defer.reject(err); + return; + } + + (err).httpStatus = xhr.status; + defer.reject(new ConnectionError("request failed", err)); + } + break; + } + }; + + xhr.upload.onprogress = (ev: ProgressEvent) => { + callbacks.clearTimeout(timeoutTimer); + upload.loaded = ev.loaded; + upload.total = ev.total; + timeoutTimer = callbacks.setTimeout(timeoutFn, 30000); + opts.progressHandler?.({ + loaded: ev.loaded, + total: ev.total, + }); + }; + + const url = this.getUrl("/upload", undefined, MediaPrefix.R0); + + if (includeFilename && fileName) { + url.searchParams.set("filename", encodeURIComponent(fileName)); + } + + if (!this.opts.useAuthorizationHeader && this.opts.accessToken) { + url.searchParams.set("access_token", encodeURIComponent(this.opts.accessToken)); + } + + xhr.open(Method.Post, url.href); + if (this.opts.useAuthorizationHeader && this.opts.accessToken) { + xhr.setRequestHeader("Authorization", "Bearer " + this.opts.accessToken); + } + xhr.setRequestHeader("Content-Type", contentType); + xhr.send(file); + + abortController.signal.addEventListener("abort", () => { + xhr.abort(); + }); + } else { + const queryParams: utils.QueryDict = {}; + if (includeFilename && fileName) { + queryParams.filename = fileName; + } + + const headers: Record = { "Content-Type": contentType }; + + this.authedRequest( + Method.Post, "/upload", queryParams, file, { + prefix: MediaPrefix.R0, + headers, + abortSignal: abortController.signal, + }, + ).then(response => { + return this.opts.onlyData ? response : response.json(); + }).then(defer.resolve, defer.reject); + } + + // remove the upload from the list on completion + upload.promise = defer.promise.finally(() => { + utils.removeElement(this.uploads, elem => elem === upload); + }); + abortController.signal.addEventListener("abort", () => { + utils.removeElement(this.uploads, elem => elem === upload); + defer.reject(new DOMException("Aborted", "AbortError")); + }); + this.uploads.push(upload); + return upload.promise; + } + + public cancelUpload(promise: Promise): boolean { + const upload = this.uploads.find(u => u.promise === promise); + if (upload) { + upload.abortController.abort(); + return true; + } + return false; + } + + public getCurrentUploads(): Upload[] { + return this.uploads; + } + + /** + * Get the content repository url with query parameters. + * @return {Object} An object with a 'base', 'path' and 'params' for base URL, + * path and query parameters respectively. + */ + public getContentUri(): IContentUri { + return { + base: this.opts.baseUrl, + path: MediaPrefix.R0 + "/upload", + params: { + access_token: this.opts.accessToken, + }, + }; + } +} diff --git a/src/http-api/interface.ts b/src/http-api/interface.ts new file mode 100644 index 00000000000..c798bec0d6c --- /dev/null +++ b/src/http-api/interface.ts @@ -0,0 +1,93 @@ +/* +Copyright 2022 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 { MatrixError } from "./errors"; + +export interface IHttpOpts { + fetchFn?: typeof global.fetch; + + baseUrl: string; + idBaseUrl?: string; + prefix: string; + extraParams?: Record; + + accessToken?: string; + useAuthorizationHeader?: boolean; // defaults to true + + onlyData?: boolean; + localTimeoutMs?: number; +} + +export interface IRequestOpts { + baseUrl?: string; + prefix?: string; + + headers?: Record; + abortSignal?: AbortSignal; + localTimeoutMs?: number; + json?: boolean; // defaults to true + + // Set to true to prevent the request function from emitting a Session.logged_out event. + // This is intended for use on endpoints where M_UNKNOWN_TOKEN is a valid/notable error response, + // such as with token refreshes. + inhibitLogoutEmit?: boolean; +} + +export interface IContentUri { + base: string; + path: string; + params: { + // eslint-disable-next-line camelcase + access_token: string; + }; +} + +export enum HttpApiEvent { + SessionLoggedOut = "Session.logged_out", + NoConsent = "no_consent", +} + +export type HttpApiEventHandlerMap = { + [HttpApiEvent.SessionLoggedOut]: (err: MatrixError) => void; + [HttpApiEvent.NoConsent]: (message: string, consentUri: string) => void; +}; + +export interface UploadProgress { + loaded: number; + total: number; +} + +export interface UploadOpts { + name?: string; + type?: string; + includeFilename?: boolean; + progressHandler?(progress: UploadProgress): void; + abortController?: AbortController; +} + +export interface Upload { + loaded: number; + total: number; + promise: Promise; + abortController: AbortController; +} + +export interface UploadResponse { + // eslint-disable-next-line camelcase + content_uri: string; +} + +export type FileType = XMLHttpRequestBodyInit; diff --git a/src/http-api/method.ts b/src/http-api/method.ts new file mode 100644 index 00000000000..1914360e3a3 --- /dev/null +++ b/src/http-api/method.ts @@ -0,0 +1,22 @@ +/* +Copyright 2022 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. +*/ + +export enum Method { + Get = "GET", + Put = "PUT", + Post = "POST", + Delete = "DELETE", +} diff --git a/src/http-api/prefix.ts b/src/http-api/prefix.ts new file mode 100644 index 00000000000..8111bc3557a --- /dev/null +++ b/src/http-api/prefix.ts @@ -0,0 +1,53 @@ +/* +Copyright 2022 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. +*/ + +export enum ClientPrefix { + /** + * A constant representing the URI path for release 0 of the Client-Server HTTP API. + */ + R0 = "/_matrix/client/r0", + /** + * A constant representing the URI path for the legacy release v1 of the Client-Server HTTP API. + */ + V1 = "/_matrix/client/v1", + /** + * A constant representing the URI path for Client-Server API endpoints versioned at v3. + */ + V3 = "/_matrix/client/v3", + /** + * A constant representing the URI path for as-yet unspecified Client-Server HTTP APIs. + */ + Unstable = "/_matrix/client/unstable", +} + +export enum IdentityPrefix { + /** + * URI path for v1 of the identity API + * @deprecated Use v2. + */ + V1 = "/_matrix/identity/api/v1", + /** + * URI path for the v2 identity API + */ + V2 = "/_matrix/identity/api/v2", +} + +export enum MediaPrefix { + /** + * URI path for the media repo API + */ + R0 = "/_matrix/media/r0", +} diff --git a/src/http-api/utils.ts b/src/http-api/utils.ts new file mode 100644 index 00000000000..220e0300b05 --- /dev/null +++ b/src/http-api/utils.ts @@ -0,0 +1,149 @@ +/* +Copyright 2022 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 { parse as parseContentType, ParsedMediaType } from "content-type"; + +import { logger } from "../logger"; +import { sleep } from "../utils"; +import { ConnectionError, MatrixError } from "./errors"; + +// Ponyfill for https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout +export function timeoutSignal(ms: number): AbortSignal { + const controller = new AbortController(); + setTimeout(() => { + controller.abort(); + }, ms); + + return controller.signal; +} + +export function anySignal(signals: AbortSignal[]): { + signal: AbortSignal; + cleanup(): void; +} { + const controller = new AbortController(); + + function cleanup() { + for (const signal of signals) { + signal.removeEventListener("abort", onAbort); + } + } + + function onAbort() { + controller.abort(); + cleanup(); + } + + for (const signal of signals) { + if (signal.aborted) { + onAbort(); + break; + } + signal.addEventListener("abort", onAbort); + } + + return { + signal: controller.signal, + cleanup, + }; +} + +/** + * Attempt to turn an HTTP error response into a Javascript Error. + * + * If it is a JSON response, we will parse it into a MatrixError. Otherwise + * we return a generic Error. + * + * @param {XMLHttpRequest|Response} response response object + * @param {String} body raw body of the response + * @returns {Error} + */ +export function parseErrorResponse(response: XMLHttpRequest | Response, body?: string): Error { + let contentType: ParsedMediaType; + try { + contentType = getResponseContentType(response); + } catch (e) { + return e; + } + + if (contentType?.type === "application/json" && body) { + return new MatrixError(JSON.parse(body), response.status); + } + if (contentType?.type === "text/plain") { + return new Error(`Server returned ${response.status} error: ${body}`); + } + return new Error(`Server returned ${response.status} error`); +} + +function isXhr(response: XMLHttpRequest | Response): response is XMLHttpRequest { + return "getResponseHeader" in response; +} + +/** + * extract the Content-Type header from the response object, and + * parse it to a `{type, parameters}` object. + * + * returns null if no content-type header could be found. + * + * @param {XMLHttpRequest|Response} response response object + * @returns {{type: String, parameters: Object}?} parsed content-type header, or null if not found + */ +function getResponseContentType(response: XMLHttpRequest | Response): ParsedMediaType | null { + let contentType: string | null; + if (isXhr(response)) { + contentType = response.getResponseHeader("Content-Type"); + } else { + contentType = response.headers.get("Content-Type"); + } + + if (!contentType) return null; + + try { + return parseContentType(contentType); + } catch (e) { + throw new Error(`Error parsing Content-Type '${contentType}': ${e}`); + } +} + +/** + * Retries a network operation run in a callback. + * @param {number} maxAttempts maximum attempts to try + * @param {Function} callback callback that returns a promise of the network operation. If rejected with ConnectionError, it will be retried by calling the callback again. + * @return {any} the result of the network operation + * @throws {ConnectionError} If after maxAttempts the callback still throws ConnectionError + */ +export async function retryNetworkOperation(maxAttempts: number, callback: () => Promise): Promise { + let attempts = 0; + let lastConnectionError: ConnectionError | null = null; + while (attempts < maxAttempts) { + try { + if (attempts > 0) { + const timeout = 1000 * Math.pow(2, attempts); + logger.log(`network operation failed ${attempts} times, retrying in ${timeout}ms...`); + await sleep(timeout); + } + return await callback(); + } catch (err) { + if (err instanceof ConnectionError) { + attempts += 1; + lastConnectionError = err; + } else { + throw err; + } + } + } + throw lastConnectionError; +} diff --git a/src/index.ts b/src/index.ts index c651438fb75..4b84224353f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,17 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import * as request from "request"; - import * as matrixcs from "./matrix"; import * as utils from "./utils"; import { logger } from './logger'; -if (matrixcs.getRequest()) { +if (global.__js_sdk_entrypoint) { throw new Error("Multiple matrix-js-sdk entrypoints detected!"); } - -matrixcs.request(request); +global.__js_sdk_entrypoint = true; try { // eslint-disable-next-line @typescript-eslint/no-var-requires diff --git a/src/matrix.ts b/src/matrix.ts index 6813655a995..eaa9b09e76b 100644 --- a/src/matrix.ts +++ b/src/matrix.ts @@ -55,41 +55,6 @@ export { createNewMatrixCall, } from "./webrtc/call"; -// expose the underlying request object so different environments can use -// different request libs (e.g. request or browser-request) -let requestInstance; - -/** - * The function used to perform HTTP requests. Only use this if you want to - * use a different HTTP library, e.g. Angular's $http. This should - * be set prior to calling {@link createClient}. - * @param {requestFunction} r The request function to use. - */ -export function request(r) { - requestInstance = r; -} - -/** - * Return the currently-set request function. - * @return {requestFunction} The current request function. - */ -export function getRequest() { - return requestInstance; -} - -/** - * Apply wrapping code around the request function. The wrapper function is - * installed as the new request handler, and when invoked it is passed the - * previous value, along with the options and callback arguments. - * @param {requestWrapperFunction} wrapper The wrapping function. - */ -export function wrapRequest(wrapper) { - const origRequest = requestInstance; - requestInstance = function(options, callback) { - return wrapper(origRequest, options, callback); - }; -} - let cryptoStoreFactory = () => new MemoryCryptoStore; /** @@ -128,15 +93,13 @@ export interface ICryptoCallbacks { /** * Construct a Matrix Client. Similar to {@link module:client.MatrixClient} * except that the 'request', 'store' and 'scheduler' dependencies are satisfied. - * @param {(Object|string)} opts The configuration options for this client. If + * @param {(Object)} opts The configuration options for this client. If * this is a string, it is assumed to be the base URL. These configuration * options will be passed directly to {@link module:client.MatrixClient}. * @param {Object} opts.store If not set, defaults to * {@link module:store/memory.MemoryStore}. * @param {Object} opts.scheduler If not set, defaults to * {@link module:scheduler~MatrixScheduler}. - * @param {requestFunction} opts.request If not set, defaults to the function - * supplied to {@link request} which defaults to the request module from NPM. * * @param {module:crypto.store.base~CryptoStore=} opts.cryptoStore * crypto store implementation. Calls the factory supplied to @@ -148,13 +111,7 @@ export interface ICryptoCallbacks { * @see {@link module:client.MatrixClient} for the full list of options for * opts. */ -export function createClient(opts: ICreateClientOpts | string) { - if (typeof opts === "string") { - opts = { - "baseUrl": opts, - }; - } - opts.request = opts.request || requestInstance; +export function createClient(opts: ICreateClientOpts) { opts.store = opts.store || new MemoryStore({ localStorage: global.localStorage, }); @@ -163,23 +120,6 @@ export function createClient(opts: ICreateClientOpts | string) { return new MatrixClient(opts); } -/** - * The request function interface for performing HTTP requests. This matches the - * API for the {@link https://github.com/request/request#requestoptions-callback| - * request NPM module}. The SDK will attempt to call this function in order to - * perform an HTTP request. - * @callback requestFunction - * @param {Object} opts The options for this HTTP request. - * @param {string} opts.uri The complete URI. - * @param {string} opts.method The HTTP method. - * @param {Object} opts.qs The query parameters to append to the URI. - * @param {Object} opts.body The JSON-serializable object. - * @param {boolean} opts.json True if this is a JSON request. - * @param {Object} opts._matrix_opts The underlying options set for - * {@link MatrixHttpApi}. - * @param {requestCallback} callback The request callback. - */ - /** * A wrapper for the request function interface. * @callback requestWrapperFunction diff --git a/src/models/MSC3089TreeSpace.ts b/src/models/MSC3089TreeSpace.ts index 0426d596e2e..9a9deec68ac 100644 --- a/src/models/MSC3089TreeSpace.ts +++ b/src/models/MSC3089TreeSpace.ts @@ -476,10 +476,8 @@ export class MSC3089TreeSpace { info: Partial, additionalContent?: IContent, ): Promise { - const mxc = await this.client.uploadContent(encryptedContents, { + const { content_uri: mxc } = await this.client.uploadContent(encryptedContents, { includeFilename: false, - onlyContentUri: true, - rawResponse: false, // make this explicit otherwise behaviour is different on browser vs NodeJS }); info.url = mxc; diff --git a/src/scheduler.ts b/src/scheduler.ts index 271982b745a..2131e95c253 100644 --- a/src/scheduler.ts +++ b/src/scheduler.ts @@ -24,7 +24,7 @@ import { logger } from './logger'; import { MatrixEvent } from "./models/event"; import { EventType } from "./@types/event"; import { IDeferred } from "./utils"; -import { MatrixError } from "./http-api"; +import { ConnectionError, MatrixError } from "./http-api"; import { ISendEventResponse } from "./@types/requests"; const DEBUG = false; // set true to enable console logging. @@ -68,9 +68,7 @@ export class MatrixScheduler { // client error; no amount of retrying with save you now. return -1; } - // we ship with browser-request which returns { cors: rejected } when trying - // with no connection, so if we match that, give up since they have no conn. - if (err["cors"] === "rejected") { + if (err instanceof ConnectionError) { return -1; } diff --git a/src/sliding-sync-sdk.ts b/src/sliding-sync-sdk.ts index 21d2af63f0d..e1072f8daa9 100644 --- a/src/sliding-sync-sdk.ts +++ b/src/sliding-sync-sdk.ts @@ -674,7 +674,7 @@ export class SlidingSyncSdk { member._requestedProfileInfo = true; // try to get a cached copy first. const user = client.getUser(member.userId); - let promise; + let promise: ReturnType; if (user) { promise = Promise.resolve({ avatar_url: user.avatarUrl, diff --git a/src/sliding-sync.ts b/src/sliding-sync.ts index da6419c9676..5297ebd14b4 100644 --- a/src/sliding-sync.ts +++ b/src/sliding-sync.ts @@ -15,11 +15,11 @@ limitations under the License. */ import { logger } from './logger'; -import { IAbortablePromise } from "./@types/partials"; import { MatrixClient } from "./client"; import { IRoomEvent, IStateEvent } from "./sync-accumulator"; -import { TypedEventEmitter } from "./models//typed-event-emitter"; +import { TypedEventEmitter } from "./models/typed-event-emitter"; import { sleep, IDeferred, defer } from "./utils"; +import { ConnectionError } from "./http-api"; // /sync requests allow you to set a timeout= but the request may continue // beyond that and wedge forever, so we need to track how long we are willing @@ -353,7 +353,8 @@ export class SlidingSync extends TypedEventEmitter(); // the *desired* room subscriptions private confirmedRoomSubscriptions = new Set(); - private pendingReq?: IAbortablePromise; + private pendingReq?: Promise; + private abortController?: AbortController; /** * Create a new sliding sync instance @@ -700,7 +701,8 @@ export class SlidingSync extends TypedEventEmitter; + getOldestToDeviceBatch(): Promise; /** * Removes a specific batch of to-device messages from the queue diff --git a/src/store/indexeddb-backend.ts b/src/store/indexeddb-backend.ts index 3a14fed7dee..f7547d6e531 100644 --- a/src/store/indexeddb-backend.ts +++ b/src/store/indexeddb-backend.ts @@ -33,7 +33,7 @@ export interface IIndexedDBBackend { getClientOptions(): Promise; storeClientOptions(options: IStartClientOpts): Promise; saveToDeviceBatches(batches: ToDeviceBatchWithTxnId[]): Promise; - getOldestToDeviceBatch(): Promise; + getOldestToDeviceBatch(): Promise; removeToDeviceBatch(id: number): Promise; } diff --git a/src/store/indexeddb-remote-backend.ts b/src/store/indexeddb-remote-backend.ts index 8be023f2bc6..d36404b90df 100644 --- a/src/store/indexeddb-remote-backend.ts +++ b/src/store/indexeddb-remote-backend.ts @@ -138,7 +138,7 @@ export class RemoteIndexedDBStoreBackend implements IIndexedDBBackend { return this.doCmd('saveToDeviceBatches', [batches]); } - public async getOldestToDeviceBatch(): Promise { + public async getOldestToDeviceBatch(): Promise { return this.doCmd('getOldestToDeviceBatch'); } diff --git a/src/store/indexeddb.ts b/src/store/indexeddb.ts index 44f684bdf8f..2bc4f28f6e5 100644 --- a/src/store/indexeddb.ts +++ b/src/store/indexeddb.ts @@ -357,7 +357,7 @@ export class IndexedDBStore extends MemoryStore { return this.backend.saveToDeviceBatches(batches); } - public getOldestToDeviceBatch(): Promise { + public getOldestToDeviceBatch(): Promise { return this.backend.getOldestToDeviceBatch(); } diff --git a/src/sync.ts b/src/sync.ts index cee5e7f09f4..60fb34aed7f 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -57,7 +57,6 @@ import { RoomStateEvent, IMarkerFoundOptions } from "./models/room-state"; import { RoomMemberEvent } from "./models/room-member"; import { BeaconEvent } from "./models/beacon"; import { IEventsResponse } from "./@types/requests"; -import { IAbortablePromise } from "./@types/partials"; import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync"; import { Feature, ServerSupport } from "./feature"; @@ -164,7 +163,8 @@ type WrappedRoom = T & { */ export class SyncApi { private _peekRoom: Optional = null; - private currentSyncRequest: Optional> = null; + private currentSyncRequest: Optional> = null; + private abortController?: AbortController; private syncState: Optional = null; private syncStateData: Optional = null; // additional data (eg. error object for failed sync) private catchingUp = false; @@ -298,9 +298,9 @@ export class SyncApi { getFilterName(client.credentials.userId, "LEFT_ROOMS"), filter, ).then(function(filterId) { qps.filter = filterId; - return client.http.authedRequest( - undefined, Method.Get, "/sync", qps as any, undefined, localTimeoutMs, - ); + return client.http.authedRequest(Method.Get, "/sync", qps as any, undefined, { + localTimeoutMs, + }); }).then(async (data) => { let leaveRooms = []; if (data.rooms?.leave) { @@ -433,11 +433,11 @@ export class SyncApi { } // FIXME: gut wrenching; hard-coded timeout values - this.client.http.authedRequest(undefined, Method.Get, "/events", { + this.client.http.authedRequest(Method.Get, "/events", { room_id: peekRoom.roomId, timeout: String(30 * 1000), from: token, - }, undefined, 50 * 1000).then((res) => { + }, undefined, { localTimeoutMs: 50 * 1000 }).then((res) => { if (this._peekRoom !== peekRoom) { debuglog("Stopped peeking in room %s", peekRoom.roomId); return; @@ -652,6 +652,7 @@ export class SyncApi { */ public async sync(): Promise { this.running = true; + this.abortController = new AbortController(); global.window?.addEventListener?.("online", this.onOnline, false); @@ -738,7 +739,7 @@ export class SyncApi { // but do not have global.window.removeEventListener. global.window?.removeEventListener?.("online", this.onOnline, false); this.running = false; - this.currentSyncRequest?.abort(); + this.abortController?.abort(); if (this.keepAliveTimer) { clearTimeout(this.keepAliveTimer); this.keepAliveTimer = null; @@ -902,12 +903,12 @@ export class SyncApi { } } - private doSyncRequest(syncOptions: ISyncOptions, syncToken: string): IAbortablePromise { + private doSyncRequest(syncOptions: ISyncOptions, syncToken: string): Promise { const qps = this.getSyncParams(syncOptions, syncToken); - return this.client.http.authedRequest( - undefined, Method.Get, "/sync", qps as any, undefined, - qps.timeout + BUFFER_PERIOD_MS, - ); + return this.client.http.authedRequest(Method.Get, "/sync", qps as any, undefined, { + localTimeoutMs: qps.timeout + BUFFER_PERIOD_MS, + abortSignal: this.abortController?.signal, + }); } private getSyncParams(syncOptions: ISyncOptions, syncToken: string): ISyncParams { @@ -1521,7 +1522,6 @@ export class SyncApi { }; this.client.http.request( - undefined, // callback Method.Get, "/_matrix/client/versions", undefined, // queryParams undefined, // data diff --git a/src/utils.ts b/src/utils.ts index e4b8b466e92..591803296f7 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -59,17 +59,23 @@ export function internaliseString(str: string): string { * {"foo": "bar", "baz": "taz"} * @return {string} The encoded string e.g. foo=bar&baz=taz */ -export function encodeParams(params: Record): string { - const searchParams = new URLSearchParams(); +export function encodeParams(params: QueryDict, urlSearchParams?: URLSearchParams): URLSearchParams { + const searchParams = urlSearchParams ?? new URLSearchParams(); for (const [key, val] of Object.entries(params)) { if (val !== undefined && val !== null) { - searchParams.set(key, String(val)); + if (Array.isArray(val)) { + val.forEach(v => { + searchParams.append(key, String(v)); + }); + } else { + searchParams.append(key, String(val)); + } } } - return searchParams.toString(); + return searchParams; } -export type QueryDict = Record; +export type QueryDict = Record; /** * Decode a query string in `application/x-www-form-urlencoded` format. @@ -80,8 +86,8 @@ export type QueryDict = Record; * This behaviour matches Node's qs.parse but is built on URLSearchParams * for native web compatibility */ -export function decodeParams(query: string): QueryDict { - const o: QueryDict = {}; +export function decodeParams(query: string): Record { + const o: Record = {}; const params = new URLSearchParams(query); for (const key of params.keys()) { const val = params.getAll(key); diff --git a/src/webrtc/call.ts b/src/webrtc/call.ts index 90e20cc37ce..ad98e207910 100644 --- a/src/webrtc/call.ts +++ b/src/webrtc/call.ts @@ -298,7 +298,7 @@ export class MatrixCall extends TypedEventEmitter; + private inviteTimeout?: ReturnType; // The logic of when & if a call is on hold is nontrivial and explained in is*OnHold // This flag represents whether we want the other party to be on hold @@ -322,7 +322,7 @@ export class MatrixCall extends TypedEventEmitter; + private callLengthInterval?: ReturnType; private callLength = 0; constructor(opts: CallOpts) { @@ -1689,7 +1689,7 @@ export class MatrixCall extends TypedEventEmitter { - this.inviteTimeout = null; + this.inviteTimeout = undefined; if (this.state === CallState.InviteSent) { this.hangup(CallErrorCode.InviteTimeout, false); } @@ -2004,11 +2004,11 @@ export class MatrixCall extends TypedEventEmitter