init
This commit is contained in:
277
node_modules/matrix-js-sdk/src/http-api/errors.ts
generated
vendored
Normal file
277
node_modules/matrix-js-sdk/src/http-api/errors.ts
generated
vendored
Normal file
@@ -0,0 +1,277 @@
|
||||
/*
|
||||
Copyright 2022 - 2024 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type IMatrixApiError as IWidgetMatrixError } from "matrix-widget-api";
|
||||
|
||||
import { type IUsageLimit } from "../@types/partials.ts";
|
||||
import { type MatrixEvent } from "../models/event.ts";
|
||||
import { NamespacedValue } from "../NamespacedValue.ts";
|
||||
import { hasRequiredStringProperty, isRecord } from "../@types/type-guards.ts";
|
||||
|
||||
interface IErrorJson extends Partial<IUsageLimit> {
|
||||
[key: string]: any; // extensible
|
||||
errcode?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a generic HTTP error. This is a JavaScript Error with additional information
|
||||
* specific to HTTP responses.
|
||||
* @param msg - The error message to include.
|
||||
* @param httpStatus - The HTTP response status code.
|
||||
* @param httpHeaders - The HTTP response headers.
|
||||
*/
|
||||
export class HTTPError extends Error {
|
||||
public constructor(
|
||||
msg: string,
|
||||
public readonly httpStatus?: number,
|
||||
public readonly httpHeaders?: Headers,
|
||||
) {
|
||||
super(msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this error was due to rate-limiting on the server side (and should therefore be retried after a delay).
|
||||
*
|
||||
* If this returns `true`, {@link getRetryAfterMs} can be called to retrieve the server-side
|
||||
* recommendation for the retry period.
|
||||
*
|
||||
* @returns Whether this error is due to rate-limiting.
|
||||
*/
|
||||
public isRateLimitError(): boolean {
|
||||
return this.httpStatus === 429;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns The recommended delay in milliseconds to wait before retrying
|
||||
* the request that triggered this error, or null if no delay is recommended.
|
||||
* @throws Error if the recommended delay is an invalid value.
|
||||
* @see {@link safeGetRetryAfterMs} for a version of this check that doesn't throw.
|
||||
*/
|
||||
public getRetryAfterMs(): number | null {
|
||||
const retryAfter = this.httpHeaders?.get("Retry-After");
|
||||
if (retryAfter != null) {
|
||||
if (/^\d+$/.test(retryAfter)) {
|
||||
const ms = Number.parseInt(retryAfter) * 1000;
|
||||
if (!Number.isFinite(ms)) {
|
||||
throw new Error("Retry-After header integer value is too large");
|
||||
}
|
||||
return ms;
|
||||
}
|
||||
const date = new Date(retryAfter);
|
||||
if (date.toUTCString() !== retryAfter) {
|
||||
throw new Error("Retry-After header value is not a valid HTTP-date or non-negative decimal integer");
|
||||
}
|
||||
return date.getTime() - Date.now();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given (JSON-parsed) response body looks like a Matrix error
|
||||
* response as specified in https://spec.matrix.org/v1.19/client-server-api/#standard-error-response
|
||||
*
|
||||
* @param response - the parsed response body to check
|
||||
* @returns whether the response is a valid {@link MatrixError}
|
||||
*/
|
||||
export function isMatrixErrorResponse(response: unknown): response is MatrixError {
|
||||
return (
|
||||
isRecord(response) &&
|
||||
hasRequiredStringProperty(response, "error") &&
|
||||
hasRequiredStringProperty(response, "errcode")
|
||||
);
|
||||
}
|
||||
|
||||
export class MatrixError extends HTTPError {
|
||||
// The Matrix 'errcode' value, e.g. "M_FORBIDDEN".
|
||||
public readonly errcode?: string;
|
||||
// The Matrix 'error' value.
|
||||
public readonly error?: string;
|
||||
// The raw Matrix error JSON used to construct this object.
|
||||
public data: IErrorJson;
|
||||
|
||||
/**
|
||||
* Construct a Matrix error. This is a JavaScript Error with additional
|
||||
* information specific to the standard Matrix error response.
|
||||
* @param errorJson - The Matrix error JSON returned from the homeserver.
|
||||
* @param httpStatus - The numeric HTTP status code given
|
||||
* @param httpHeaders - The HTTP response headers given
|
||||
*/
|
||||
public constructor(
|
||||
errorJson: IErrorJson = {},
|
||||
httpStatus?: number,
|
||||
public url?: string,
|
||||
public event?: MatrixEvent,
|
||||
httpHeaders?: Headers,
|
||||
) {
|
||||
let message = errorJson.error || "Unknown message";
|
||||
if (httpStatus) {
|
||||
message = `[${httpStatus}] ${message}`;
|
||||
}
|
||||
if (url) {
|
||||
message = `${message} (${url})`;
|
||||
}
|
||||
super(`MatrixError: ${message}`, httpStatus, httpHeaders);
|
||||
this.errcode = errorJson.errcode;
|
||||
this.error = errorJson.error;
|
||||
this.name = errorJson.errcode || "Unknown error code";
|
||||
this.data = errorJson;
|
||||
}
|
||||
|
||||
public isRateLimitError(): boolean {
|
||||
return (
|
||||
this.errcode === "M_LIMIT_EXCEEDED" ||
|
||||
((this.errcode === "M_UNKNOWN" || this.errcode === undefined) && super.isRateLimitError())
|
||||
);
|
||||
}
|
||||
|
||||
public getRetryAfterMs(): number | null {
|
||||
const headerValue = super.getRetryAfterMs();
|
||||
if (headerValue !== null) {
|
||||
return headerValue;
|
||||
}
|
||||
// Note: retry_after_ms is deprecated as of spec version v1.10
|
||||
if (this.errcode === "M_LIMIT_EXCEEDED" && "retry_after_ms" in this.data) {
|
||||
if (!Number.isInteger(this.data.retry_after_ms)) {
|
||||
throw new Error("retry_after_ms is not an integer");
|
||||
}
|
||||
return this.data.retry_after_ms;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns this error expressed as a JSON payload
|
||||
* for use by Widget API error responses.
|
||||
*/
|
||||
public asWidgetApiErrorData(): IWidgetMatrixError {
|
||||
const headers: Record<string, string> = {};
|
||||
if (this.httpHeaders) {
|
||||
for (const [name, value] of this.httpHeaders) {
|
||||
headers[name] = value;
|
||||
}
|
||||
}
|
||||
return {
|
||||
http_status: this.httpStatus ?? 400,
|
||||
http_headers: headers,
|
||||
url: this.url ?? "",
|
||||
response: {
|
||||
errcode: this.errcode ?? "M_UNKNOWN",
|
||||
error: this.data.error ?? "Unknown message",
|
||||
...this.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns a new {@link MatrixError} from a JSON payload
|
||||
* received from Widget API error responses.
|
||||
*/
|
||||
public static fromWidgetApiErrorData(data: IWidgetMatrixError): MatrixError {
|
||||
return new MatrixError(data.response, data.http_status, data.url, undefined, new Headers(data.http_headers));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns The recommended delay in milliseconds to wait before retrying the request.
|
||||
* @param error - The error to check for a retry delay.
|
||||
* @param defaultMs - The delay to use if the error was not due to rate-limiting or if no valid delay is recommended.
|
||||
*/
|
||||
export function safeGetRetryAfterMs(error: unknown, defaultMs: number): number {
|
||||
if (!(error instanceof HTTPError) || !error.isRateLimitError()) {
|
||||
return defaultMs;
|
||||
}
|
||||
try {
|
||||
return error.getRetryAfterMs() ?? defaultMs;
|
||||
} catch {
|
||||
return defaultMs;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export class ConnectionError extends Error {
|
||||
public constructor(message: string, cause?: Error) {
|
||||
super(message + (cause ? `: ${cause.message}` : ""));
|
||||
}
|
||||
|
||||
public get name(): string {
|
||||
return "ConnectionError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a TokenRefreshError. This indicates that a request failed due to the token being expired,
|
||||
* and attempting to refresh said token also failed but in a way which was not indicative of token invalidation.
|
||||
* Assumed to be a temporary failure.
|
||||
*/
|
||||
export class TokenRefreshError extends Error {
|
||||
public constructor(cause?: Error) {
|
||||
super(cause?.message ?? "");
|
||||
}
|
||||
|
||||
public get name(): string {
|
||||
return "TokenRefreshError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a TokenRefreshError. This indicates that a request failed due to the token being expired,
|
||||
* and attempting to refresh said token failed in a way indicative of token invalidation.
|
||||
*/
|
||||
export class TokenRefreshLogoutError extends Error {
|
||||
public constructor(cause?: Error) {
|
||||
super(cause?.message ?? "");
|
||||
}
|
||||
|
||||
public get name(): string {
|
||||
return "TokenRefreshLogoutError";
|
||||
}
|
||||
}
|
||||
|
||||
export const MatrixSafetyErrorCode = new NamespacedValue(null, "ORG.MATRIX.MSC4387_SAFETY");
|
||||
|
||||
/***
|
||||
* This error is thrown when the homeserver refuses to handle an action due to a
|
||||
* safety concern.
|
||||
* @see https://github.com/matrix-org/matrix-spec-proposals/pull/4387
|
||||
*/
|
||||
export class MatrixSafetyError extends MatrixError {
|
||||
/**
|
||||
* The kinds of harms detected by the server.
|
||||
* @see https://github.com/matrix-org/matrix-spec-proposals/pull/4387 for a list of spec defined harms.
|
||||
*/
|
||||
public readonly harms: Set<string>;
|
||||
/**
|
||||
* The date at which a request can be reattempted.
|
||||
*/
|
||||
public readonly expiry?: Date;
|
||||
public constructor(...props: ConstructorParameters<typeof MatrixError>) {
|
||||
super(...props);
|
||||
const body = props[0];
|
||||
|
||||
this.harms = new Set(body && "harms" in body && Array.isArray(body.harms) ? body.harms : []);
|
||||
this.message = `${super.message} (${[...this.harms].join(", ")})`;
|
||||
if (body && "expiry" in body && typeof body.expiry === "number") {
|
||||
this.expiry = new Date(body.expiry);
|
||||
}
|
||||
}
|
||||
}
|
||||
377
node_modules/matrix-js-sdk/src/http-api/fetch.ts
generated
vendored
Normal file
377
node_modules/matrix-js-sdk/src/http-api/fetch.ts
generated
vendored
Normal file
@@ -0,0 +1,377 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
import { checkObjectHasKeys, deepCopy, encodeParams } from "../utils.ts";
|
||||
import { type TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { Method } from "./method.ts";
|
||||
import { ConnectionError, MatrixError, TokenRefreshError } from "./errors.ts";
|
||||
import {
|
||||
type BaseRequestOpts,
|
||||
HttpApiEvent,
|
||||
type HttpApiEventHandlerMap,
|
||||
type IHttpOpts,
|
||||
type IRequestOpts,
|
||||
type Body,
|
||||
} from "./interface.ts";
|
||||
import { anySignal, parseErrorResponse, timeoutSignal } from "./utils.ts";
|
||||
import { type QueryDict } from "../utils.ts";
|
||||
import { TokenRefresher, TokenRefreshOutcome } from "./refresh.ts";
|
||||
|
||||
export class FetchHttpApi<O extends IHttpOpts> {
|
||||
private abortController = new AbortController();
|
||||
private readonly tokenRefresher: TokenRefresher;
|
||||
|
||||
public constructor(
|
||||
private eventEmitter: TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>,
|
||||
public readonly opts: O,
|
||||
) {
|
||||
checkObjectHasKeys(opts, ["baseUrl", "prefix"]);
|
||||
if (!opts.onlyData) {
|
||||
throw new Error("Constructing FetchHttpApi without `onlyData=true` is no longer supported.");
|
||||
}
|
||||
opts.useAuthorizationHeader = opts.useAuthorizationHeader ?? true;
|
||||
|
||||
this.tokenRefresher = new TokenRefresher(opts);
|
||||
}
|
||||
|
||||
public abort(): void {
|
||||
this.abortController.abort();
|
||||
this.abortController = new AbortController();
|
||||
}
|
||||
|
||||
public fetch(resource: URL | string, options?: RequestInit): ReturnType<typeof globalThis.fetch> {
|
||||
if (this.opts.fetchFn) {
|
||||
return this.opts.fetchFn(resource, options);
|
||||
}
|
||||
return globalThis.fetch(resource, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the base URL for the identity server
|
||||
* @param url - The new base url
|
||||
*/
|
||||
public setIdBaseUrl(url?: string): void {
|
||||
this.opts.idBaseUrl = url;
|
||||
}
|
||||
|
||||
public idServerRequest<T extends object = Record<string, unknown>>(
|
||||
method: Method,
|
||||
path: string,
|
||||
params: Record<string, string | string[]> | undefined,
|
||||
prefix: string,
|
||||
accessToken?: string,
|
||||
): Promise<T> {
|
||||
if (!this.opts.idBaseUrl) {
|
||||
throw new Error("No identity server base URL set");
|
||||
}
|
||||
|
||||
let queryParams: QueryDict | undefined = undefined;
|
||||
let body: Record<string, string | string[]> | 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 method - The HTTP method e.g. "GET".
|
||||
* @param path - The HTTP path <b>after</b> the supplied prefix e.g.
|
||||
* "/createRoom".
|
||||
*
|
||||
* @param queryParams - A dict of query params (these will NOT be
|
||||
* urlencoded). If unspecified, there will be no query params.
|
||||
*
|
||||
* @param body - The HTTP JSON body.
|
||||
*
|
||||
* @param paramOpts - additional options.
|
||||
* When `paramOpts.doNotAttemptTokenRefresh` is true, token refresh will not be attempted
|
||||
* when an expired token is encountered. Used to only attempt token refresh once.
|
||||
*
|
||||
* @returns The parsed response.
|
||||
* @throws Error if a problem occurred. This includes network problems and Matrix-specific error JSON.
|
||||
*/
|
||||
public authedRequest<T>(
|
||||
method: Method,
|
||||
path: string,
|
||||
queryParams: QueryDict = {},
|
||||
body?: Body,
|
||||
paramOpts: IRequestOpts = {},
|
||||
): Promise<T> {
|
||||
return this.doAuthedRequest<T>(1, method, path, queryParams, body, paramOpts);
|
||||
}
|
||||
|
||||
// Wrapper around public method authedRequest to allow for tracking retry attempt counts
|
||||
private async doAuthedRequest<T>(
|
||||
attempt: number,
|
||||
method: Method,
|
||||
path: string,
|
||||
queryParams: QueryDict,
|
||||
body?: Body,
|
||||
paramOpts: IRequestOpts = {},
|
||||
): Promise<T> {
|
||||
// avoid mutating paramOpts so they can be used on retry
|
||||
const opts = deepCopy(paramOpts);
|
||||
// we have to manually copy the abortSignal over as it is not a plain object
|
||||
opts.abortSignal = paramOpts.abortSignal;
|
||||
|
||||
// Take a snapshot of the current token state before we start the request so we can reference it if we error
|
||||
const requestSnapshot = await this.tokenRefresher.prepareForRequest();
|
||||
if (requestSnapshot.accessToken) {
|
||||
if (this.opts.useAuthorizationHeader) {
|
||||
if (!opts.headers) {
|
||||
opts.headers = {};
|
||||
}
|
||||
if (!opts.headers.Authorization) {
|
||||
opts.headers.Authorization = `Bearer ${requestSnapshot.accessToken}`;
|
||||
}
|
||||
if (queryParams.access_token) {
|
||||
delete queryParams.access_token;
|
||||
}
|
||||
} else if (!queryParams.access_token) {
|
||||
queryParams.access_token = requestSnapshot.accessToken;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.request<T>(method, path, queryParams, body, opts);
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (!(error instanceof MatrixError)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (error.errcode === "M_UNKNOWN_TOKEN") {
|
||||
const outcome = await this.tokenRefresher.handleUnknownToken(requestSnapshot, attempt);
|
||||
if (outcome === TokenRefreshOutcome.Success) {
|
||||
// if we got a new token retry the request
|
||||
return this.doAuthedRequest(attempt + 1, method, path, queryParams, body, paramOpts);
|
||||
}
|
||||
if (outcome === TokenRefreshOutcome.Failure) {
|
||||
throw new TokenRefreshError(error);
|
||||
}
|
||||
|
||||
if (!opts?.inhibitLogoutEmit) {
|
||||
this.eventEmitter.emit(HttpApiEvent.SessionLoggedOut, error);
|
||||
}
|
||||
} else if (error.errcode == "M_CONSENT_NOT_GIVEN") {
|
||||
this.eventEmitter.emit(HttpApiEvent.NoConsent, error.message, error.data.consent_uri);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a request to the homeserver without any credentials.
|
||||
* @param method - The HTTP method e.g. "GET".
|
||||
* @param path - The HTTP path <b>after</b> the supplied prefix e.g.
|
||||
* "/createRoom".
|
||||
*
|
||||
* @param queryParams - A dict of query params (these will NOT be
|
||||
* urlencoded). If unspecified, there will be no query params.
|
||||
*
|
||||
* @param body - The HTTP JSON body.
|
||||
*
|
||||
* @param opts - additional options
|
||||
*
|
||||
* @returns The parsed response.
|
||||
* @throws Error if a problem occurred. This includes network problems and Matrix-specific error JSON.
|
||||
*/
|
||||
public request<T = unknown>(
|
||||
method: Method,
|
||||
path: string,
|
||||
queryParams?: QueryDict,
|
||||
body?: Body,
|
||||
opts?: IRequestOpts,
|
||||
): Promise<T> {
|
||||
const fullUri = this.getUrl(path, queryParams, opts?.prefix, opts?.baseUrl);
|
||||
return this.requestOtherUrl<T>(method, fullUri, body, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a request to an arbitrary URL.
|
||||
* @param method - The HTTP method e.g. "GET".
|
||||
* @param url - The HTTP URL object.
|
||||
*
|
||||
* @param body - The HTTP JSON body.
|
||||
*
|
||||
* @param opts - additional options
|
||||
*
|
||||
* @returns The parsed response.
|
||||
* @throws Error if a problem occurred. This includes network problems and Matrix-specific error JSON.
|
||||
*/
|
||||
public async requestOtherUrl<T>(
|
||||
method: Method,
|
||||
url: URL | string,
|
||||
body?: Body,
|
||||
opts: BaseRequestOpts = {},
|
||||
): Promise<T> {
|
||||
if (opts.json !== undefined && opts.rawResponseBody !== undefined) {
|
||||
throw new Error("Invalid call to `FetchHttpApi` sets both `opts.json` and `opts.rawResponseBody`");
|
||||
}
|
||||
|
||||
const urlForLogs = this.sanitizeUrlForLogs(url);
|
||||
|
||||
this.opts.logger?.debug(`FetchHttpApi: --> ${method} ${urlForLogs}`);
|
||||
|
||||
const headers = Object.assign({}, opts.headers || {});
|
||||
|
||||
const jsonResponse = !opts.rawResponseBody && opts.json !== false;
|
||||
if (jsonResponse) {
|
||||
if (!headers["Accept"]) {
|
||||
headers["Accept"] = "application/json";
|
||||
}
|
||||
}
|
||||
|
||||
const timeout = opts.localTimeoutMs ?? this.opts.localTimeoutMs;
|
||||
const keepAlive = opts.keepAlive ?? false;
|
||||
const signals = [this.abortController.signal];
|
||||
if (timeout !== undefined) {
|
||||
signals.push(timeoutSignal(timeout));
|
||||
}
|
||||
if (opts.abortSignal) {
|
||||
signals.push(opts.abortSignal);
|
||||
}
|
||||
|
||||
// If the body is an object, encode it as JSON and set the `Content-Type` header,
|
||||
// unless that has been explicitly inhibited by setting `opts.json: false`.
|
||||
// We can't use getPrototypeOf here as objects made in other contexts e.g. over postMessage won't have same ref
|
||||
let data: BodyInit;
|
||||
if (opts.json !== false && body?.constructor?.name === Object.name) {
|
||||
data = JSON.stringify(body);
|
||||
if (!headers["Content-Type"]) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
} else {
|
||||
data = body as BodyInit;
|
||||
}
|
||||
|
||||
const { signal, cleanup } = anySignal(signals);
|
||||
|
||||
// Set cache mode based on presence of Authorization header.
|
||||
// Browsers/proxies do not cache responses to requests with Authorization headers.
|
||||
// So specifying "no-cache" is redundant, and actually prevents caching
|
||||
// of preflight requests in CORS scenarios. As such, we only set "no-cache"
|
||||
// when there is no Authorization header.
|
||||
const cacheMode = "Authorization" in headers ? undefined : "no-cache";
|
||||
|
||||
let res: Response;
|
||||
const start = Date.now();
|
||||
try {
|
||||
res = await this.fetch(url, {
|
||||
signal,
|
||||
method,
|
||||
body: data,
|
||||
headers,
|
||||
mode: "cors",
|
||||
redirect: "follow",
|
||||
referrer: "",
|
||||
referrerPolicy: "no-referrer",
|
||||
cache: cacheMode,
|
||||
credentials: "omit", // we send credentials via headers
|
||||
keepalive: keepAlive,
|
||||
priority: opts.priority,
|
||||
});
|
||||
|
||||
this.opts.logger?.debug(
|
||||
`FetchHttpApi: <-- ${method} ${urlForLogs} [${Date.now() - start}ms ${res.status}]`,
|
||||
);
|
||||
} catch (e) {
|
||||
this.opts.logger?.debug(`FetchHttpApi: <-- ${method} ${urlForLogs} [${Date.now() - start}ms ${e}]`);
|
||||
if ((<Error>e).name === "AbortError") {
|
||||
throw e;
|
||||
}
|
||||
throw new ConnectionError("fetch failed", <Error>e);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw parseErrorResponse(res, await res.text());
|
||||
}
|
||||
|
||||
if (opts.rawResponseBody) {
|
||||
return (await res.blob()) as T;
|
||||
} else if (jsonResponse) {
|
||||
return await res.json();
|
||||
} else {
|
||||
return (await res.text()) as T;
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizeUrlForLogs(url: URL | string): string {
|
||||
try {
|
||||
let asUrl: URL;
|
||||
if (typeof url === "string") {
|
||||
asUrl = new URL(url);
|
||||
} else {
|
||||
asUrl = url;
|
||||
}
|
||||
// Remove the values of any URL params that could contain potential secrets
|
||||
const sanitizedQs = new URLSearchParams();
|
||||
for (const key of asUrl.searchParams.keys()) {
|
||||
sanitizedQs.append(key, "xxx");
|
||||
}
|
||||
const sanitizedQsString = sanitizedQs.toString();
|
||||
const sanitizedQsUrlPiece = sanitizedQsString ? `?${sanitizedQsString}` : "";
|
||||
|
||||
return asUrl.origin + asUrl.pathname + sanitizedQsUrlPiece;
|
||||
} catch {
|
||||
// defensive coding for malformed url
|
||||
return "??";
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Form and return a homeserver request URL based on the given path params and prefix.
|
||||
* @param path - The HTTP path <b>after</b> the supplied prefix e.g. "/createRoom".
|
||||
* @param queryParams - A dict of query params (these will NOT be urlencoded).
|
||||
* @param prefix - The full prefix to use e.g. "/_matrix/client/v2_alpha", defaulting to this.opts.prefix.
|
||||
* @param baseUrl - The baseUrl to use e.g. "https://matrix.org", defaulting to this.opts.baseUrl.
|
||||
* @returns URL
|
||||
*/
|
||||
public getUrl(path: string, queryParams?: QueryDict, prefix?: string, baseUrl?: string): URL {
|
||||
const baseUrlWithFallback = baseUrl ?? this.opts.baseUrl;
|
||||
const baseUrlWithoutTrailingSlash = baseUrlWithFallback.endsWith("/")
|
||||
? baseUrlWithFallback.slice(0, -1)
|
||||
: baseUrlWithFallback;
|
||||
const url = new URL(baseUrlWithoutTrailingSlash + (prefix ?? this.opts.prefix) + path);
|
||||
// If there are any params, encode and append them to the URL.
|
||||
if (this.opts.extraParams || queryParams) {
|
||||
const mergedParams = { ...this.opts.extraParams, ...queryParams };
|
||||
encodeParams(mergedParams, url.searchParams);
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
}
|
||||
172
node_modules/matrix-js-sdk/src/http-api/index.ts
generated
vendored
Normal file
172
node_modules/matrix-js-sdk/src/http-api/index.ts
generated
vendored
Normal file
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
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.ts";
|
||||
import { type FileType, type IHttpOpts, type Upload, type UploadOpts, type UploadResponse } from "./interface.ts";
|
||||
import { MediaPrefix } from "./prefix.ts";
|
||||
import { type QueryDict, removeElement } from "../utils.ts";
|
||||
import * as callbacks from "../realtime-callbacks.ts";
|
||||
import { Method } from "./method.ts";
|
||||
import { ConnectionError } from "./errors.ts";
|
||||
import { parseErrorResponse } from "./utils.ts";
|
||||
|
||||
export * from "./interface.ts";
|
||||
export * from "./prefix.ts";
|
||||
export * from "./errors.ts";
|
||||
export * from "./method.ts";
|
||||
export * from "./utils.ts";
|
||||
|
||||
export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
|
||||
private uploads: Upload[] = [];
|
||||
|
||||
/**
|
||||
* Upload content to the homeserver
|
||||
*
|
||||
* @param 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 opts - options object
|
||||
*
|
||||
* @returns Promise which resolves to response object, or rejects with an error (usually a MatrixError).
|
||||
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
|
||||
* @see MatrixSafetyError
|
||||
*/
|
||||
public uploadContent(file: FileType, opts: UploadOpts = {}): Promise<UploadResponse> {
|
||||
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 uploadResolvers = Promise.withResolvers<UploadResponse>();
|
||||
|
||||
if (globalThis.XMLHttpRequest) {
|
||||
const xhr = new globalThis.XMLHttpRequest();
|
||||
|
||||
const timeoutFn = function (): void {
|
||||
xhr.abort();
|
||||
uploadResolvers.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 (): void {
|
||||
switch (xhr.readyState) {
|
||||
case globalThis.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) {
|
||||
uploadResolvers.reject(parseErrorResponse(xhr, xhr.responseText));
|
||||
} else {
|
||||
uploadResolvers.resolve(JSON.parse(xhr.responseText));
|
||||
}
|
||||
} catch (err) {
|
||||
if ((<Error>err).name === "AbortError") {
|
||||
uploadResolvers.reject(err);
|
||||
return;
|
||||
}
|
||||
uploadResolvers.reject(new ConnectionError("request failed", <Error>err));
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
xhr.upload.onprogress = (ev: ProgressEvent): void => {
|
||||
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.V3);
|
||||
|
||||
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: QueryDict = {};
|
||||
if (includeFilename && fileName) {
|
||||
queryParams.filename = fileName;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { "Content-Type": contentType };
|
||||
|
||||
this.authedRequest<UploadResponse>(Method.Post, "/upload", queryParams, file, {
|
||||
prefix: MediaPrefix.V3,
|
||||
headers,
|
||||
abortSignal: abortController.signal,
|
||||
}).then(uploadResolvers.resolve, uploadResolvers.reject);
|
||||
}
|
||||
|
||||
// remove the upload from the list on completion
|
||||
upload.promise = uploadResolvers.promise.finally(() => {
|
||||
removeElement(this.uploads, (elem) => elem === upload);
|
||||
});
|
||||
abortController.signal.addEventListener("abort", () => {
|
||||
removeElement(this.uploads, (elem) => elem === upload);
|
||||
uploadResolvers.reject(new DOMException("Aborted", "AbortError"));
|
||||
});
|
||||
this.uploads.push(upload);
|
||||
return upload.promise;
|
||||
}
|
||||
|
||||
public cancelUpload(promise: Promise<UploadResponse>): 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;
|
||||
}
|
||||
}
|
||||
217
node_modules/matrix-js-sdk/src/http-api/interface.ts
generated
vendored
Normal file
217
node_modules/matrix-js-sdk/src/http-api/interface.ts
generated
vendored
Normal file
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
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 { type MatrixError } from "./errors.ts";
|
||||
import { type Logger } from "../logger.ts";
|
||||
import { type QueryDict } from "../utils.ts";
|
||||
|
||||
export type Body = Record<string, any> | BodyInit;
|
||||
|
||||
/**
|
||||
* Unencrypted access and (optional) refresh token
|
||||
*/
|
||||
export type AccessTokens = {
|
||||
/**
|
||||
* The new access token to use for authenticated requests
|
||||
*/
|
||||
accessToken: string;
|
||||
/**
|
||||
* The new refresh token to use for refreshing tokens, optional
|
||||
*/
|
||||
refreshToken?: string;
|
||||
/**
|
||||
* Approximate date when the access token will expire, optional
|
||||
*/
|
||||
expiry?: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Function that performs token refresh using the given refreshToken.
|
||||
* Returns a promise that resolves to the refreshed access and (optional) refresh tokens.
|
||||
*
|
||||
* Can be passed to HttpApi instance as {@link IHttpOpts.tokenRefreshFunction} during client creation {@link ICreateClientOpts}
|
||||
*/
|
||||
export type TokenRefreshFunction = (refreshToken: string) => Promise<AccessTokens>;
|
||||
|
||||
/** Options object for `FetchHttpApi` and {@link MatrixHttpApi}. */
|
||||
export interface IHttpOpts {
|
||||
fetchFn?: typeof globalThis.fetch;
|
||||
|
||||
baseUrl: string;
|
||||
idBaseUrl?: string;
|
||||
prefix: string;
|
||||
extraParams?: QueryDict;
|
||||
|
||||
accessToken?: string;
|
||||
/**
|
||||
* Used in conjunction with tokenRefreshFunction to attempt token refresh
|
||||
*/
|
||||
refreshToken?: string;
|
||||
/**
|
||||
* Function to attempt token refresh when a possibly expired token is encountered
|
||||
* Optional, only called when a refreshToken is present
|
||||
*/
|
||||
tokenRefreshFunction?: TokenRefreshFunction;
|
||||
|
||||
/**
|
||||
* Whether to use the HTTP Authorization header over the `access_token` query parameter
|
||||
* @deprecated as of v1.11 in https://spec.matrix.org/v1.17/client-server-api/#using-access-tokens
|
||||
*/
|
||||
useAuthorizationHeader?: boolean; // defaults to true
|
||||
|
||||
/** For historical reasons, must be set to `true`. Will eventually be removed. */
|
||||
onlyData?: boolean;
|
||||
|
||||
localTimeoutMs?: number;
|
||||
|
||||
/** Optional logger instance. If provided, requests and responses will be logged. */
|
||||
logger?: Logger;
|
||||
}
|
||||
|
||||
/** Options object for `FetchHttpApi.requestOtherUrl`. */
|
||||
export interface BaseRequestOpts extends Pick<RequestInit, "priority"> {
|
||||
/**
|
||||
* map of additional request headers
|
||||
*/
|
||||
headers?: Record<string, string>;
|
||||
abortSignal?: AbortSignal;
|
||||
/**
|
||||
* The maximum amount of time to wait before
|
||||
* timing out the request. If not specified, there is no timeout.
|
||||
*/
|
||||
localTimeoutMs?: number;
|
||||
keepAlive?: boolean; // defaults to false
|
||||
|
||||
/**
|
||||
* By default, we will:
|
||||
*
|
||||
* * If the `body` is an object, JSON-encode it and set `Content-Type: application/json` in the
|
||||
* request headers (unless overridden by {@link headers}).
|
||||
*
|
||||
* * Set `Accept: application/json` in the request headers (again, unless overridden by {@link headers}).
|
||||
*
|
||||
* * Parse the response as JSON and return the parsed response.
|
||||
*
|
||||
* Setting this to `false` inhibits all three behaviors, and the response is instead parsed as a UTF-8 string. It
|
||||
* defaults to `true`, unless {@link rawResponseBody} is set.
|
||||
*
|
||||
* @deprecated Instead of setting this to `false`, set {@link rawResponseBody} to `true`.
|
||||
*/
|
||||
json?: boolean;
|
||||
|
||||
/**
|
||||
* Setting this to `true` does two things:
|
||||
*
|
||||
* * Inhibits the automatic addition of `Accept: application/json` in the request headers.
|
||||
*
|
||||
* * Causes the raw response to be returned as a {@link https://developer.mozilla.org/en-US/docs/Web/API/Blob|Blob}
|
||||
* instead of parsing it as JSON.
|
||||
*/
|
||||
rawResponseBody?: boolean;
|
||||
}
|
||||
|
||||
export interface IRequestOpts extends BaseRequestOpts {
|
||||
/**
|
||||
* The alternative base url to use.
|
||||
* If not specified, uses this.opts.baseUrl
|
||||
*/
|
||||
baseUrl?: string;
|
||||
/**
|
||||
* The full prefix to use e.g.
|
||||
* "/_matrix/client/v2_alpha". If not specified, uses this.opts.prefix.
|
||||
*/
|
||||
prefix?: string;
|
||||
|
||||
// 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 enum HttpApiEvent {
|
||||
SessionLoggedOut = "Session.logged_out",
|
||||
NoConsent = "no_consent",
|
||||
}
|
||||
|
||||
export type HttpApiEventHandlerMap = {
|
||||
/**
|
||||
* Fires whenever the login session the JS SDK is using is no
|
||||
* longer valid and the user must log in again.
|
||||
* NB. This only fires when action is required from the user, not
|
||||
* when then login session can be renewed by using a refresh token.
|
||||
* @example
|
||||
* ```
|
||||
* matrixClient.on("Session.logged_out", function(errorObj){
|
||||
* // show the login screen
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
[HttpApiEvent.SessionLoggedOut]: (err: MatrixError) => void;
|
||||
/**
|
||||
* Fires when the JS SDK receives a M_CONSENT_NOT_GIVEN error in response
|
||||
* to a HTTP request.
|
||||
* @example
|
||||
* ```
|
||||
* matrixClient.on("no_consent", function(message, contentUri) {
|
||||
* console.info(message + ' Go to ' + contentUri);
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
[HttpApiEvent.NoConsent]: (message: string, consentUri: string) => void;
|
||||
};
|
||||
|
||||
export interface UploadProgress {
|
||||
loaded: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface UploadOpts {
|
||||
/**
|
||||
* Name to give the file on the server. Defaults to <tt>file.name</tt>.
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* Content-type for the upload. Defaults to
|
||||
* <tt>file.type</tt>, or <tt>applicaton/octet-stream</tt>.
|
||||
*/
|
||||
type?: string;
|
||||
/**
|
||||
* if false will not send the filename,
|
||||
* e.g for encrypted file uploads where filename leaks are undesirable.
|
||||
* Defaults to true.
|
||||
*/
|
||||
includeFilename?: boolean;
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
progressHandler?(progress: UploadProgress): void;
|
||||
abortController?: AbortController;
|
||||
}
|
||||
|
||||
export interface Upload {
|
||||
loaded: number;
|
||||
total: number;
|
||||
promise: Promise<UploadResponse>;
|
||||
abortController: AbortController;
|
||||
}
|
||||
|
||||
export interface UploadResponse {
|
||||
content_uri: string;
|
||||
}
|
||||
|
||||
export type FileType = XMLHttpRequestBodyInit;
|
||||
25
node_modules/matrix-js-sdk/src/http-api/method.ts
generated
vendored
Normal file
25
node_modules/matrix-js-sdk/src/http-api/method.ts
generated
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
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",
|
||||
Options = "OPTIONS",
|
||||
Head = "HEAD",
|
||||
Patch = "PATCH",
|
||||
}
|
||||
48
node_modules/matrix-js-sdk/src/http-api/prefix.ts
generated
vendored
Normal file
48
node_modules/matrix-js-sdk/src/http-api/prefix.ts
generated
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
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 Client-Server API endpoints versioned at v1.
|
||||
*/
|
||||
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 the v2 identity API
|
||||
*/
|
||||
V2 = "/_matrix/identity/v2",
|
||||
}
|
||||
|
||||
export enum MediaPrefix {
|
||||
/**
|
||||
* A constant representing the URI path for Client-Server API Media endpoints versioned at v1.
|
||||
*/
|
||||
V1 = "/_matrix/media/v1",
|
||||
/**
|
||||
* A constant representing the URI path for Client-Server API Media endpoints versioned at v3.
|
||||
*/
|
||||
V3 = "/_matrix/media/v3",
|
||||
}
|
||||
166
node_modules/matrix-js-sdk/src/http-api/refresh.ts
generated
vendored
Normal file
166
node_modules/matrix-js-sdk/src/http-api/refresh.ts
generated
vendored
Normal file
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixError, TokenRefreshLogoutError } from "./errors.ts";
|
||||
import { type IHttpOpts } from "./interface.ts";
|
||||
import { sleep } from "../utils.ts";
|
||||
|
||||
/**
|
||||
* This is an internal module. See {@link MatrixHttpApi} for the public class.
|
||||
*/
|
||||
|
||||
export const enum TokenRefreshOutcome {
|
||||
Success = "success",
|
||||
Failure = "failure",
|
||||
Logout = "logout",
|
||||
}
|
||||
|
||||
interface Snapshot {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiry?: Date;
|
||||
}
|
||||
|
||||
// If the token expires in less than this time amount of time, we will eagerly refresh it before making the intended request.
|
||||
const REFRESH_IF_TOKEN_EXPIRES_WITHIN_MS = 500;
|
||||
// If we get an unknown token error and the token expires in less than this time amount of time, we will refresh it before making the intended request.
|
||||
// Otherwise, we will error as the token should not have expired yet and we need to avoid retrying indefinitely.
|
||||
const REFRESH_ON_ERROR_IF_TOKEN_EXPIRES_WITHIN_MS = 60 * 1000;
|
||||
|
||||
type Opts = Pick<IHttpOpts, "tokenRefreshFunction" | "logger" | "refreshToken" | "accessToken">;
|
||||
|
||||
/**
|
||||
* This class is responsible for managing the access token and refresh token for authenticated requests.
|
||||
* It will automatically refresh the access token when it is about to expire, and will handle unknown token errors.
|
||||
*/
|
||||
export class TokenRefresher {
|
||||
public constructor(private readonly opts: Opts) {}
|
||||
|
||||
/**
|
||||
* Promise used to block authenticated requests during a token refresh to avoid repeated expected errors.
|
||||
* @private
|
||||
*/
|
||||
private tokenRefreshPromise?: Promise<TokenRefreshOutcome>;
|
||||
|
||||
private latestTokenRefreshExpiry?: Date;
|
||||
|
||||
/**
|
||||
* This function is called before every request to ensure that the access token is valid.
|
||||
* @returns a snapshot containing the access token and other properties which must be passed to the handleUnknownToken
|
||||
* handler if an M_UNKNOWN_TOKEN error is encountered.
|
||||
*/
|
||||
public async prepareForRequest(): Promise<Snapshot> {
|
||||
// Ensure our token is refreshed before we build the headers/params
|
||||
await this.refreshIfNeeded();
|
||||
|
||||
return {
|
||||
accessToken: this.opts.accessToken!,
|
||||
refreshToken: this.opts.refreshToken,
|
||||
expiry: this.latestTokenRefreshExpiry,
|
||||
};
|
||||
}
|
||||
|
||||
private async refreshIfNeeded(): Promise<unknown> {
|
||||
if (this.tokenRefreshPromise) {
|
||||
return this.tokenRefreshPromise;
|
||||
}
|
||||
// If we don't know the token expiry, we can't eagerly refresh
|
||||
if (!this.latestTokenRefreshExpiry) return;
|
||||
|
||||
const expiresIn = this.latestTokenRefreshExpiry.getTime() - Date.now();
|
||||
if (expiresIn <= REFRESH_IF_TOKEN_EXPIRES_WITHIN_MS) {
|
||||
await this._handleUnknownToken();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is called when an M_UNKNOWN_TOKEN error is encountered.
|
||||
* It will attempt to refresh the access token if it is unknown, and will return a TokenRefreshOutcome.
|
||||
* @param snapshot - the snapshot returned by prepareForRequest
|
||||
* @param attempt - the number of attempts made for this request so far
|
||||
* @returns a TokenRefreshOutcome indicating the result of the refresh attempt
|
||||
*/
|
||||
public async handleUnknownToken(snapshot: Snapshot, attempt: number): Promise<TokenRefreshOutcome> {
|
||||
return this._handleUnknownToken(snapshot, attempt);
|
||||
}
|
||||
|
||||
private async _handleUnknownToken(): Promise<TokenRefreshOutcome>;
|
||||
private async _handleUnknownToken(snapshot: Snapshot, attempt: number): Promise<TokenRefreshOutcome>;
|
||||
private async _handleUnknownToken(snapshot?: Snapshot, attempt?: number): Promise<TokenRefreshOutcome> {
|
||||
if (snapshot?.expiry) {
|
||||
// If our token is unknown, but it should not have expired yet, then we should not refresh
|
||||
const expiresIn = snapshot.expiry.getTime() - Date.now();
|
||||
// If it still has plenty of time left on the clock, we assume something else must be wrong and
|
||||
// do not refresh. Otherwise if it's expired, or will soon, we try refreshing.
|
||||
if (expiresIn >= REFRESH_ON_ERROR_IF_TOKEN_EXPIRES_WITHIN_MS) {
|
||||
return TokenRefreshOutcome.Logout;
|
||||
}
|
||||
}
|
||||
|
||||
if (!snapshot || snapshot?.accessToken === this.opts.accessToken) {
|
||||
// If we have a snapshot, but the access token is the same as the current one then a refresh
|
||||
// did not happen behind us but one may be ongoing anyway
|
||||
this.tokenRefreshPromise ??= this.doTokenRefresh(attempt);
|
||||
|
||||
try {
|
||||
return await this.tokenRefreshPromise;
|
||||
} finally {
|
||||
this.tokenRefreshPromise = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// We may end up here if the token was refreshed in the background due to another request
|
||||
return TokenRefreshOutcome.Success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to refresh access tokens.
|
||||
* On success, sets new access and refresh tokens in opts.
|
||||
* @returns Promise that resolves to a boolean - true when token was refreshed successfully
|
||||
*/
|
||||
private async doTokenRefresh(attempt?: number): Promise<TokenRefreshOutcome> {
|
||||
if (!this.opts.refreshToken || !this.opts.tokenRefreshFunction) {
|
||||
this.opts.logger?.error("Unable to refresh token - no refresh token or refresh function");
|
||||
return TokenRefreshOutcome.Logout;
|
||||
}
|
||||
|
||||
if (attempt && attempt > 1) {
|
||||
// Exponential backoff to ensure we don't trash the server, up to 2^5 seconds
|
||||
await sleep(1000 * Math.min(32, 2 ** attempt));
|
||||
}
|
||||
|
||||
try {
|
||||
this.opts.logger?.debug("Attempting to refresh token");
|
||||
const { accessToken, refreshToken, expiry } = await this.opts.tokenRefreshFunction(this.opts.refreshToken);
|
||||
this.opts.accessToken = accessToken;
|
||||
this.opts.refreshToken = refreshToken;
|
||||
this.latestTokenRefreshExpiry = expiry;
|
||||
this.opts.logger?.debug("... token refresh complete, new token expiry:", expiry);
|
||||
|
||||
// successfully got new tokens
|
||||
return TokenRefreshOutcome.Success;
|
||||
} catch (error) {
|
||||
// If we get a TokenError or MatrixError, we should log out, otherwise assume transient
|
||||
if (error instanceof TokenRefreshLogoutError || error instanceof MatrixError) {
|
||||
this.opts.logger?.error("Failed to refresh token", error);
|
||||
return TokenRefreshOutcome.Logout;
|
||||
}
|
||||
|
||||
this.opts.logger?.warn("Failed to refresh token", error);
|
||||
return TokenRefreshOutcome.Failure;
|
||||
}
|
||||
}
|
||||
}
|
||||
212
node_modules/matrix-js-sdk/src/http-api/utils.ts
generated
vendored
Normal file
212
node_modules/matrix-js-sdk/src/http-api/utils.ts
generated
vendored
Normal file
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
Copyright 2022 - 2024 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { parse as parseContentType, type ContentType } from "content-type";
|
||||
|
||||
import { logger } from "../logger.ts";
|
||||
import { sleep } from "../utils.ts";
|
||||
import {
|
||||
ConnectionError,
|
||||
HTTPError,
|
||||
MatrixError,
|
||||
MatrixSafetyError,
|
||||
MatrixSafetyErrorCode,
|
||||
safeGetRetryAfterMs,
|
||||
} from "./errors.ts";
|
||||
|
||||
// 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(this: void): void;
|
||||
} {
|
||||
const controller = new AbortController();
|
||||
|
||||
function cleanup(): void {
|
||||
for (const signal of signals) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
function onAbort(): void {
|
||||
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 response - response object
|
||||
* @param body - raw body of the response
|
||||
* @returns
|
||||
*/
|
||||
export function parseErrorResponse(response: XMLHttpRequest | Response, body?: string): Error {
|
||||
const httpHeaders = isXhr(response)
|
||||
? new Headers(
|
||||
response
|
||||
.getAllResponseHeaders()
|
||||
.trim()
|
||||
.split(/[\r\n]+/)
|
||||
.map((header): [string, string] => {
|
||||
const colonIdx = header.indexOf(":");
|
||||
return [header.substring(0, colonIdx), header.substring(colonIdx + 1)];
|
||||
}),
|
||||
)
|
||||
: response.headers;
|
||||
|
||||
let contentType: ContentType | null;
|
||||
try {
|
||||
contentType = getResponseContentType(httpHeaders);
|
||||
} catch (e) {
|
||||
return <Error>e;
|
||||
}
|
||||
if (contentType?.type === "application/json" && body) {
|
||||
const errorBody = JSON.parse(body);
|
||||
if (errorBody.errcode && MatrixSafetyErrorCode.matches(errorBody.errcode)) {
|
||||
return new MatrixSafetyError(
|
||||
errorBody,
|
||||
response.status,
|
||||
isXhr(response) ? response.responseURL : response.url,
|
||||
undefined,
|
||||
httpHeaders,
|
||||
);
|
||||
}
|
||||
return new MatrixError(
|
||||
errorBody,
|
||||
response.status,
|
||||
isXhr(response) ? response.responseURL : response.url,
|
||||
undefined,
|
||||
httpHeaders,
|
||||
);
|
||||
}
|
||||
if (contentType?.type === "text/plain") {
|
||||
return new HTTPError(`Server returned ${response.status} error: ${body}`, response.status, httpHeaders);
|
||||
}
|
||||
return new HTTPError(`Server returned ${response.status} error`, response.status, httpHeaders);
|
||||
}
|
||||
|
||||
function isXhr(response: XMLHttpRequest | Response): response is XMLHttpRequest {
|
||||
return "getResponseHeader" in response;
|
||||
}
|
||||
|
||||
/**
|
||||
* extract the Content-Type header from response headers, and
|
||||
* parse it to a `{type, parameters}` object.
|
||||
*
|
||||
* returns null if no content-type header could be found.
|
||||
*
|
||||
* @param response - response object
|
||||
* @returns parsed content-type header, or null if not found
|
||||
*/
|
||||
function getResponseContentType(headers: Headers): ContentType | null {
|
||||
const contentType = headers.get("Content-Type");
|
||||
if (contentType === null) return null;
|
||||
return parseContentType(contentType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retries a network operation run in a callback.
|
||||
* @param maxAttempts - maximum attempts to try
|
||||
* @param callback - callback that returns a promise of the network operation. If rejected with ConnectionError, it will be retried by calling the callback again.
|
||||
* @returns the result of the network operation
|
||||
* @throws {@link ConnectionError} If after maxAttempts the callback still throws ConnectionError
|
||||
*/
|
||||
export async function retryNetworkOperation<T>(maxAttempts: number, callback: () => Promise<T>): Promise<T> {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the backoff time for a request retry attempt.
|
||||
* This produces wait times of 2, 4, 8, and 16 seconds (30s total) after which we give up. If the
|
||||
* failure was due to a rate limited request, the time specified in the error is returned.
|
||||
*
|
||||
* Returns -1 if the error is not retryable, or if we reach the maximum number of attempts.
|
||||
*
|
||||
* @param err - The error thrown by the http call
|
||||
* @param attempts - The number of attempts made so far, including the one that just failed.
|
||||
* @param retryConnectionError - Whether to retry on {@link ConnectionError} (CORS, connection is down, etc.)
|
||||
*/
|
||||
export function calculateRetryBackoff(err: any, attempts: number, retryConnectionError: boolean): number {
|
||||
if (attempts > 4) {
|
||||
return -1; // give up
|
||||
}
|
||||
|
||||
if (err instanceof ConnectionError && !retryConnectionError) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (err.httpStatus && Math.floor(err.httpStatus / 100) === 4 && err.httpStatus !== 429) {
|
||||
// client error; no amount of retrying will save you now (except for rate limiting which is handled below)
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (err.name === "AbortError") {
|
||||
// this is a client timeout, that is already very high 60s/80s
|
||||
// we don't want to retry, as it could do it for very long
|
||||
return -1;
|
||||
}
|
||||
|
||||
// If we are trying to send an event (or similar) that is too large in any way, then retrying won't help
|
||||
if (err.name === "M_TOO_LARGE") {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return safeGetRetryAfterMs(err, 1000 * Math.pow(2, attempts));
|
||||
}
|
||||
Reference in New Issue
Block a user