snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
This commit is contained in:
49
webapp/platform/client/CLAUDE.OPTIONAL.md
Normal file
49
webapp/platform/client/CLAUDE.OPTIONAL.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# CLAUDE: `platform/client/` (`@mattermost/client`)
|
||||
|
||||
## Purpose
|
||||
- Implements the Client4 HTTP layer and WebSocket client used by all Mattermost web apps and plugins.
|
||||
- Source of truth for API endpoint definitions and low-level networking helpers.
|
||||
|
||||
## Structure
|
||||
- `src/client4.ts` – REST endpoints, auth handling, retries.
|
||||
- `src/websocket.ts` – WebSocket manager for real-time events.
|
||||
- `src/helpers.ts` / `errors.ts` – shared logic for response parsing and error types.
|
||||
|
||||
## Client4 Usage
|
||||
Singleton HTTP client for all Mattermost API requests. Methods return `Promise<ClientResponse<T>>`:
|
||||
|
||||
```typescript
|
||||
interface ClientResponse<T> {
|
||||
response: Response; // Fetch Response object
|
||||
headers: Headers; // Response headers
|
||||
data: T; // Parsed response data
|
||||
}
|
||||
```
|
||||
|
||||
### Usage Rules
|
||||
1. **Actions Only**: `Client4` should only be called from Redux actions, never directly in components.
|
||||
2. **Error Handling**: Always handle errors appropriately.
|
||||
|
||||
## Adding New Endpoints
|
||||
Add new API methods to `client4.ts`. Keep signatures Promise-based.
|
||||
|
||||
```typescript
|
||||
getSomething = (id: string) => {
|
||||
return this.doFetch<SomethingType>(
|
||||
`${this.getSomethingRoute(id)}`,
|
||||
{method: 'get'},
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
- Throw `ClientError` (see `errors.ts`) with enough context.
|
||||
- Include `forceLogoutIfNecessary` logic upstream in calling actions; do not couple that here.
|
||||
|
||||
## WebSocket Client
|
||||
`websocket.ts` provides real-time event handling, connection management, and automatic reconnection. Accessed via `WebSocketClient` wrapper in web app.
|
||||
|
||||
## Guidelines
|
||||
- Follow `webapp/STYLE_GUIDE.md → Networking`.
|
||||
- Each new server API must be added here first, including TypeScript types and tests.
|
||||
- Never reference React or browser globals—this package must run in Node.
|
||||
122
webapp/platform/client/README.md
Normal file
122
webapp/platform/client/README.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# Mattermost Client
|
||||
|
||||
[](https://www.npmjs.com/package/@mattermost/client)
|
||||
|
||||
This package contains the JavaScript/TypeScript client for [Mattermost](https://github.com/mattermost/mattermost). It's used by [the Mattermost web app](https://github.com/mattermost/mattermost/tree/master/webapp/channels) and related projects.
|
||||
|
||||
## Installation
|
||||
|
||||
### JavaScript
|
||||
|
||||
```sh
|
||||
$ npm install @mattermost/client
|
||||
```
|
||||
|
||||
### TypeScript
|
||||
|
||||
```sh
|
||||
$ npm install @mattermost/client @mattermost/types
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Rest Client
|
||||
|
||||
To use this client, create an instance of `Client4`, set the server URL, and log in, and then you can start making requests.
|
||||
|
||||
```js
|
||||
import {Client4} from '@mattermost/client';
|
||||
|
||||
const client = new Client4();
|
||||
client.setUrl('https://mymattermostserver.example.com');
|
||||
|
||||
client.login('username', 'password').then((user) => {
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
If you already have a session token or a user access token, you can call `Client4.setToken` instead of logging in.
|
||||
|
||||
```js
|
||||
import {Client4} from '@mattermost/client';
|
||||
|
||||
const client = new Client4();
|
||||
client.setUrl('https://mymattermostserver.example.com');
|
||||
|
||||
client.setToken('accesstoken');
|
||||
```
|
||||
|
||||
If needed, methods exist to set other headers such as the User-Agent (`Client4.setUserAgent`), the CSRF token (`Client4.setCSRF`), or any extra headers you wish to include (`Client4.setHeader`).
|
||||
|
||||
Methods of `Client4` which make requests to the server return a `Promise` which does the following:
|
||||
|
||||
- On success, the promise resolves to a `ClientResponse<T>` object which contains the the [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) (`response`), a [Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) of headers (`headers`), and the data sent from the server (`data`).
|
||||
- On an error, the promise rejects with a `ClientError` which contains the error message and the URL being requested. If the error happened on the server, the status code and an error ID (`server_error_id`) are included.
|
||||
|
||||
```js
|
||||
let user;
|
||||
try {
|
||||
user = (await client.getUser('userid')).data;
|
||||
} catch (e) {
|
||||
console.error(`An error occurred when making a request to ${e.url}: ${e.message}`);
|
||||
}
|
||||
```
|
||||
|
||||
### WebSocket Client
|
||||
|
||||
To use the WebSocket client, create an instance of `WebSocketClient` and then call its `initialize` method with the connection URL and an optional session token or user access token. After that, you can call the client's `addMessageListener` method to register a listener which will be called whenever a WebSocket message is received from the server.
|
||||
|
||||
```js
|
||||
import {WebSocketClient} from '@mattermost/client';
|
||||
|
||||
// If you already have an instance of Client4, you can call its getWebSocketUrl method to get this URL
|
||||
const connectionUrl = 'https://mymattermostserver.example.com/api/v4/websocket';
|
||||
|
||||
// In a browser, the token may be passed automatically from a cookie
|
||||
const authToken = process.env.TOKEN;
|
||||
|
||||
const wsClient = new WebSocketClient();
|
||||
wsClient.initialize(connectionUrl, authToken);
|
||||
|
||||
wsClient.addMessageListener((msg) => {
|
||||
if (msg.event === 'posted') {
|
||||
console.log('New post received', JSON.parse(msg.data.post));
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
#### Node.js
|
||||
|
||||
Note that `WebSocketClient` expects `globalThis.WebSocket` to be defined as it was originally written for use in the Mattermost web app. If you're using it in a Node.js environment, you should set `globalThis.WebSocket` before instantiating the `WebSocketClient`.
|
||||
|
||||
```js
|
||||
import WebSocket from 'ws';
|
||||
|
||||
if (!globalThis.WebSocket) {
|
||||
globalThis.WebSocket = WebSocket;
|
||||
}
|
||||
|
||||
const wsClient = new WebSocketClient();
|
||||
```
|
||||
|
||||
This can also be done using dynamic imports if you're using them.
|
||||
|
||||
```js
|
||||
if (!globalThis.WebSocket) {
|
||||
const {WebSocket} = await import('ws');
|
||||
globalThis.WebSocket = WebSocket;
|
||||
}
|
||||
|
||||
const wsClient = new WebSocketClient();
|
||||
```
|
||||
|
||||
## Compilation and Packaging
|
||||
|
||||
As a member of Mattermost with write access to our NPM organization, you can build and publish this package by running the following commands:
|
||||
|
||||
```bash
|
||||
npm run build --workspace=platform/client
|
||||
npm publish --workspace=platform/client
|
||||
```
|
||||
|
||||
Make sure to increment the version number in `package.json` first! You can add `-0`, `-1`, etc for pre-release versions.
|
||||
9
webapp/platform/client/babel.config.js
Normal file
9
webapp/platform/client/babel.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
module.exports = {
|
||||
presets: [
|
||||
['@babel/preset-env', {targets: {node: 'current'}}],
|
||||
'@babel/preset-typescript',
|
||||
],
|
||||
};
|
||||
23
webapp/platform/client/eslint.config.mjs
Normal file
23
webapp/platform/client/eslint.config.mjs
Normal file
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import eslintPlugin from '@mattermost/eslint-plugin';
|
||||
|
||||
export default [
|
||||
...eslintPlugin.configs.base,
|
||||
{
|
||||
files: ['**/*.js', '**/*.mjs', '**/*.ts'],
|
||||
rules: {
|
||||
'no-restricted-globals': [
|
||||
'error',
|
||||
'window',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.test.js', '**/*.test.ts'],
|
||||
rules: {
|
||||
'no-restricted-globals': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
19
webapp/platform/client/jest.config.js
Normal file
19
webapp/platform/client/jest.config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/** @type {import('jest').Config} */
|
||||
|
||||
module.exports = {
|
||||
moduleNameMapper: {
|
||||
'^@mattermost/types/(.*)$': '<rootDir>/../types/src/$1',
|
||||
},
|
||||
testPathIgnorePatterns: ['/node_modules/', '/lib/'],
|
||||
setupFiles: ['<rootDir>/setup_jest.ts'],
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{js,jsx,ts,tsx}',
|
||||
],
|
||||
coveragePathIgnorePatterns: [
|
||||
'/node_modules/',
|
||||
],
|
||||
coverageReporters: ['json', 'lcov', 'text-summary'],
|
||||
};
|
||||
47
webapp/platform/client/package.json
Normal file
47
webapp/platform/client/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@mattermost/client",
|
||||
"version": "12.0.0",
|
||||
"description": "JavaScript/TypeScript client for Mattermost",
|
||||
"keywords": [
|
||||
"mattermost"
|
||||
],
|
||||
"homepage": "https://github.com/mattermost/mattermost/tree/master/webapp/platform/client#readme",
|
||||
"license": "MIT",
|
||||
"type": "commonjs",
|
||||
"files": [
|
||||
"lib"
|
||||
],
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/index.d.ts",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/mattermost/mattermost.git",
|
||||
"directory": "webapp/platform/client"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "30.0.0",
|
||||
"@types/node-fetch": "2.6.13",
|
||||
"jest": "30.1.3",
|
||||
"nock": "13.2.8",
|
||||
"node-fetch": "2.7.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mattermost/types": "12.0.0",
|
||||
"typescript": "^4.3.0 || ^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --build tsconfig.build.json --verbose",
|
||||
"check": "eslint ./src eslint.config.mjs --quiet",
|
||||
"fix": "eslint ./src eslint.config.mjs --quiet --fix",
|
||||
"run": "tsc --watch --preserveWatchOutput",
|
||||
"test": "jest",
|
||||
"test-ci": "jest --ci --forceExit --detectOpenHandles --maxWorkers=100% --logHeapUsage",
|
||||
"clean": "rm -rf lib node_modules *.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
8
webapp/platform/client/setup_jest.ts
Normal file
8
webapp/platform/client/setup_jest.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import nodeFetch from 'node-fetch';
|
||||
|
||||
// Nock 13 can't mock Node's built-in fetch, so we have to replace the it with node-fetch to be able to mock it despite
|
||||
// some mismatched parameters.
|
||||
globalThis.fetch = nodeFetch as unknown as typeof fetch;
|
||||
484
webapp/platform/client/src/client4.test.ts
Normal file
484
webapp/platform/client/src/client4.test.ts
Normal file
@@ -0,0 +1,484 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import nock from 'nock';
|
||||
|
||||
import Client4, {ClientError, HEADER_X_VERSION_ID} from './client4';
|
||||
import {buildQueryString} from './helpers';
|
||||
|
||||
describe('Client4', () => {
|
||||
beforeAll(() => {
|
||||
if (!nock.isActive()) {
|
||||
nock.activate();
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
nock.restore();
|
||||
});
|
||||
|
||||
describe('property field routes', () => {
|
||||
let client: Client4;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new Client4();
|
||||
client.setUrl('http://mattermost.example.com');
|
||||
});
|
||||
|
||||
test('getPropertyFieldsRoute should build correct URL', () => {
|
||||
expect(client.getPropertyFieldsRoute('my_group', 'user')).toBe(
|
||||
'http://mattermost.example.com/api/v4/properties/groups/my_group/user/fields',
|
||||
);
|
||||
});
|
||||
|
||||
test('getPropertyFieldRoute should build correct URL', () => {
|
||||
expect(client.getPropertyFieldRoute('my_group', 'user', 'field123')).toBe(
|
||||
'http://mattermost.example.com/api/v4/properties/groups/my_group/user/fields/field123',
|
||||
);
|
||||
});
|
||||
|
||||
test('getPropertyFields should send GET with correct query params', async () => {
|
||||
const fields = [{id: 'f1', name: 'test'}];
|
||||
nock(client.getBaseRoute()).
|
||||
get('/properties/groups/grp/user/fields').
|
||||
query({target_type: 'system', per_page: '10', cursor_id: 'abc', cursor_create_at: '999'}).
|
||||
reply(200, fields);
|
||||
|
||||
const result = await client.getPropertyFields('grp', 'user', 'system', undefined, {
|
||||
perPage: 10,
|
||||
cursorId: 'abc',
|
||||
cursorCreateAt: 999,
|
||||
});
|
||||
|
||||
expect(result).toEqual(fields);
|
||||
});
|
||||
|
||||
test('getPropertyFields should include target_id when provided', async () => {
|
||||
nock(client.getBaseRoute()).
|
||||
get('/properties/groups/grp/user/fields').
|
||||
query({target_type: 'channel', target_id: 'ch1'}).
|
||||
reply(200, []);
|
||||
|
||||
const result = await client.getPropertyFields('grp', 'user', 'channel', 'ch1');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test('getPropertyFields should send minimal params when no options', async () => {
|
||||
nock(client.getBaseRoute()).
|
||||
get('/properties/groups/grp/user/fields').
|
||||
query({target_type: 'system'}).
|
||||
reply(200, []);
|
||||
|
||||
const result = await client.getPropertyFields('grp', 'user', 'system');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test('createPropertyField should send POST with field body', async () => {
|
||||
const field = {name: 'classification', type: 'select' as const, target_type: 'system'};
|
||||
const created = {id: 'new1', ...field};
|
||||
|
||||
nock(client.getBaseRoute()).
|
||||
post('/properties/groups/grp/user/fields', (body) => {
|
||||
return body.name === 'classification' && body.type === 'select';
|
||||
}).
|
||||
reply(201, created);
|
||||
|
||||
const result = await client.createPropertyField('grp', 'user', field);
|
||||
expect(result).toEqual(created);
|
||||
});
|
||||
|
||||
test('patchPropertyField should send PATCH to field URL', async () => {
|
||||
const patch = {attrs: {options: []}};
|
||||
const patched = {id: 'f1', name: 'classification', attrs: {options: []}};
|
||||
|
||||
nock(client.getBaseRoute()).
|
||||
patch('/properties/groups/grp/user/fields/f1', (body) => {
|
||||
return body.attrs !== undefined;
|
||||
}).
|
||||
reply(200, patched);
|
||||
|
||||
const result = await client.patchPropertyField('grp', 'user', 'f1', patch);
|
||||
expect(result).toEqual(patched);
|
||||
});
|
||||
|
||||
test('deletePropertyField should send DELETE to field URL', async () => {
|
||||
nock(client.getBaseRoute()).
|
||||
delete('/properties/groups/grp/user/fields/f1').
|
||||
reply(200, {status: 'OK'});
|
||||
|
||||
const result = await client.deletePropertyField('grp', 'user', 'f1');
|
||||
expect(result).toEqual({status: 'OK'});
|
||||
});
|
||||
});
|
||||
|
||||
describe('access control field autocomplete', () => {
|
||||
let client: Client4;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new Client4();
|
||||
client.setUrl('http://mattermost.example.com');
|
||||
});
|
||||
|
||||
test('getAccessControlFields sends include_resource_fields when requested', async () => {
|
||||
const fields = [{id: 'f1', name: 'classification'}];
|
||||
nock(client.getBaseRoute()).
|
||||
get('/access_control_policies/cel/autocomplete/fields').
|
||||
query({after: '', limit: '100', include_resource_fields: 'true'}).
|
||||
reply(200, fields);
|
||||
|
||||
const result = await client.getAccessControlFields('', 100, undefined, undefined, true);
|
||||
expect(result).toEqual(fields);
|
||||
});
|
||||
|
||||
test('getAccessControlFields omits include_resource_fields by default', async () => {
|
||||
nock(client.getBaseRoute()).
|
||||
get('/access_control_policies/cel/autocomplete/fields').
|
||||
query((q) => q.include_resource_fields === undefined && q.after === '' && q.limit === '100').
|
||||
reply(200, []);
|
||||
|
||||
const result = await client.getAccessControlFields('', 100);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('content flagging routes', () => {
|
||||
let client: Client4;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new Client4();
|
||||
client.setUrl('http://mattermost.example.com');
|
||||
});
|
||||
|
||||
test('flagPost should send comment as a plain string', async () => {
|
||||
let receivedBody: any;
|
||||
nock(client.getBaseRoute()).
|
||||
post('/content_flagging/post/post123/flag', (body) => {
|
||||
receivedBody = body;
|
||||
return true;
|
||||
}).
|
||||
reply(200, {status: 'OK'});
|
||||
|
||||
await client.flagPost('post123', 'Spam', 'looks suspicious');
|
||||
|
||||
expect(receivedBody).toEqual({reason: 'Spam', comment: 'looks suspicious'});
|
||||
});
|
||||
|
||||
test('flagPost should preserve an empty comment as an empty string', async () => {
|
||||
let receivedBody: any;
|
||||
nock(client.getBaseRoute()).
|
||||
post('/content_flagging/post/post123/flag', (body) => {
|
||||
receivedBody = body;
|
||||
return true;
|
||||
}).
|
||||
reply(200, {status: 'OK'});
|
||||
|
||||
await client.flagPost('post123', 'Spam', '');
|
||||
|
||||
expect(receivedBody).toEqual({reason: 'Spam', comment: ''});
|
||||
});
|
||||
|
||||
test('removeFlaggedPost should send comment as a plain string', async () => {
|
||||
let receivedBody: any;
|
||||
nock(client.getBaseRoute()).
|
||||
put('/content_flagging/post/post123/remove', (body) => {
|
||||
receivedBody = body;
|
||||
return true;
|
||||
}).
|
||||
reply(200, {status: 'OK'});
|
||||
|
||||
await client.removeFlaggedPost('post123', 'violates policy');
|
||||
|
||||
expect(receivedBody).toEqual({comment: 'violates policy'});
|
||||
});
|
||||
|
||||
test('removeFlaggedPost should preserve an empty comment as an empty string', async () => {
|
||||
let receivedBody: any;
|
||||
nock(client.getBaseRoute()).
|
||||
put('/content_flagging/post/post123/remove', (body) => {
|
||||
receivedBody = body;
|
||||
return true;
|
||||
}).
|
||||
reply(200, {status: 'OK'});
|
||||
|
||||
await client.removeFlaggedPost('post123', '');
|
||||
|
||||
expect(receivedBody).toEqual({comment: ''});
|
||||
});
|
||||
|
||||
test('keepFlaggedPost should send comment as a plain string', async () => {
|
||||
let receivedBody: any;
|
||||
nock(client.getBaseRoute()).
|
||||
put('/content_flagging/post/post123/keep', (body) => {
|
||||
receivedBody = body;
|
||||
return true;
|
||||
}).
|
||||
reply(200, {status: 'OK'});
|
||||
|
||||
await client.keepFlaggedPost('post123', 'looks fine');
|
||||
|
||||
expect(receivedBody).toEqual({comment: 'looks fine'});
|
||||
});
|
||||
|
||||
test('keepFlaggedPost should preserve an empty comment as an empty string', async () => {
|
||||
let receivedBody: any;
|
||||
nock(client.getBaseRoute()).
|
||||
put('/content_flagging/post/post123/keep', (body) => {
|
||||
receivedBody = body;
|
||||
return true;
|
||||
}).
|
||||
reply(200, {status: 'OK'});
|
||||
|
||||
await client.keepFlaggedPost('post123', '');
|
||||
|
||||
expect(receivedBody).toEqual({comment: ''});
|
||||
});
|
||||
|
||||
test('getPostExposureReportUrl should build correct URL', () => {
|
||||
expect(client.getPostExposureReportUrl('post123')).toBe(
|
||||
'http://mattermost.example.com/api/v4/content_flagging/post/post123/exposure_report',
|
||||
);
|
||||
});
|
||||
|
||||
test('generatePostExposureReport should return the CSV blob and the filename from Content-Disposition', async () => {
|
||||
nock(client.getBaseRoute()).
|
||||
post('/content_flagging/post/post123/exposure_report').
|
||||
reply(200, '#Post ID,post123\nUser ID,Username\nuid1,alice\n', {
|
||||
'Content-Type': 'text/csv; charset=utf-8',
|
||||
'Content-Disposition': 'attachment; filename="post-exposure-post123-1700000000000.csv"',
|
||||
});
|
||||
|
||||
const result = await client.generatePostExposureReport('post123');
|
||||
|
||||
expect(result.filename).toEqual('post-exposure-post123-1700000000000.csv');
|
||||
expect(typeof result.blob.text).toBe('function');
|
||||
expect(await result.blob.text()).toContain('uid1,alice');
|
||||
});
|
||||
|
||||
test('generatePostExposureReport should fall back to a generated filename when the header is absent', async () => {
|
||||
nock(client.getBaseRoute()).
|
||||
post('/content_flagging/post/post123/exposure_report').
|
||||
reply(200, 'User ID,Username\n', {'Content-Type': 'text/csv; charset=utf-8'});
|
||||
|
||||
const result = await client.generatePostExposureReport('post123');
|
||||
|
||||
expect(result.filename).toMatch(/^post-exposure-post123-\d+\.csv$/);
|
||||
});
|
||||
|
||||
test('generatePostExposureReport should surface server errors as ClientError', async () => {
|
||||
nock(client.getBaseRoute()).
|
||||
post('/content_flagging/post/post123/exposure_report').
|
||||
reply(400, {
|
||||
id: 'api.data_spillage.error.post_not_in_progress',
|
||||
message: 'The review of this post is already closed.',
|
||||
status_code: 400,
|
||||
}, {'Content-Type': 'application/json'});
|
||||
|
||||
await expect(client.generatePostExposureReport('post123')).rejects.toMatchObject({
|
||||
server_error_id: 'api.data_spillage.error.post_not_in_progress',
|
||||
status_code: 400,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('team access control routes', () => {
|
||||
let client: Client4;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new Client4();
|
||||
client.setUrl('http://mattermost.example.com');
|
||||
});
|
||||
|
||||
test('assignTeamsToAccessControlPolicy POSTs team_ids to the assign route', async () => {
|
||||
let receivedBody: any;
|
||||
nock(client.getBaseRoute()).
|
||||
post('/access_control_policies/pol1/assign', (body) => {
|
||||
receivedBody = body;
|
||||
return true;
|
||||
}).
|
||||
reply(200, {status: 'OK'});
|
||||
|
||||
const result = await client.assignTeamsToAccessControlPolicy('pol1', ['team1', 'team2']);
|
||||
|
||||
expect(receivedBody).toEqual({team_ids: ['team1', 'team2']});
|
||||
expect(result).toEqual({status: 'OK'});
|
||||
});
|
||||
|
||||
test('unassignTeamsFromAccessControlPolicy DELETEs team_ids to the unassign route', async () => {
|
||||
let receivedBody: any;
|
||||
nock(client.getBaseRoute()).
|
||||
delete('/access_control_policies/pol1/unassign', (body) => {
|
||||
receivedBody = body;
|
||||
return true;
|
||||
}).
|
||||
reply(200, {status: 'OK'});
|
||||
|
||||
const result = await client.unassignTeamsFromAccessControlPolicy('pol1', ['team1']);
|
||||
|
||||
expect(receivedBody).toEqual({team_ids: ['team1']});
|
||||
expect(result).toEqual({status: 'OK'});
|
||||
});
|
||||
|
||||
test('getTeamAccessControlPolicy GETs the per-team policy route', async () => {
|
||||
const response = {policy: null, enforced: false};
|
||||
nock(client.getBaseRoute()).
|
||||
get('/teams/team1/access_control/policy').
|
||||
reply(200, response);
|
||||
|
||||
const result = await client.getTeamAccessControlPolicy('team1');
|
||||
expect(result).toEqual(response);
|
||||
});
|
||||
|
||||
test('getProfilesMatchingTeamPolicy GETs users with not_in_team + abac_match_only', async () => {
|
||||
const profiles = [{id: 'u1'}];
|
||||
nock(client.getBaseRoute()).
|
||||
get('/users').
|
||||
query({not_in_team: 'team1', per_page: '60', abac_match_only: 'true'}).
|
||||
reply(200, profiles);
|
||||
|
||||
const result = await client.getProfilesMatchingTeamPolicy('team1');
|
||||
expect(result).toEqual(profiles);
|
||||
});
|
||||
|
||||
test('getProfilesMatchingTeamPolicy includes cursor_id when provided', async () => {
|
||||
const profiles: any[] = [];
|
||||
nock(client.getBaseRoute()).
|
||||
get('/users').
|
||||
query({not_in_team: 'team1', per_page: '60', abac_match_only: 'true', cursor_id: 'u9'}).
|
||||
reply(200, profiles);
|
||||
|
||||
const result = await client.getProfilesMatchingTeamPolicy('team1', 60, 'u9');
|
||||
expect(result).toEqual(profiles);
|
||||
});
|
||||
|
||||
test('getTeams sends for_directory=true so admins are filtered on the directory listing', async () => {
|
||||
const teams = [{id: 't1'}];
|
||||
nock(client.getBaseRoute()).
|
||||
get('/teams').
|
||||
query({page: '0', per_page: '60', include_total_count: 'true', exclude_policy_constrained: 'false', for_directory: 'true'}).
|
||||
reply(200, teams);
|
||||
|
||||
const result = await client.getTeams(0, 60, true, false, true);
|
||||
expect(result).toEqual(teams);
|
||||
});
|
||||
|
||||
test('getTeams defaults for_directory to false so the management listing stays complete', async () => {
|
||||
const teams = [{id: 't1'}];
|
||||
nock(client.getBaseRoute()).
|
||||
get('/teams').
|
||||
query({page: '0', per_page: '60', include_total_count: 'false', exclude_policy_constrained: 'false', for_directory: 'false'}).
|
||||
reply(200, teams);
|
||||
|
||||
const result = await client.getTeams(0, 60);
|
||||
expect(result).toEqual(teams);
|
||||
});
|
||||
});
|
||||
|
||||
describe('doFetchWithResponse', () => {
|
||||
test('serverVersion should be set from response header', async () => {
|
||||
const client = new Client4();
|
||||
client.setUrl('http://mattermost.example.com');
|
||||
|
||||
expect(client.serverVersion).toEqual('');
|
||||
|
||||
nock(client.getBaseRoute()).
|
||||
get('/users/me').
|
||||
reply(200, '{}', {[HEADER_X_VERSION_ID]: '5.0.0.5.0.0.abc123'});
|
||||
|
||||
await client.getMe();
|
||||
|
||||
expect(client.serverVersion).toEqual('5.0.0.5.0.0.abc123');
|
||||
|
||||
nock(client.getBaseRoute()).
|
||||
get('/users/me').
|
||||
reply(200, '{}', {[HEADER_X_VERSION_ID]: '5.3.0.5.3.0.abc123'});
|
||||
|
||||
await client.getMe();
|
||||
|
||||
expect(client.serverVersion).toEqual('5.3.0.5.3.0.abc123');
|
||||
});
|
||||
|
||||
test('should parse NDJSON responses correctly', async () => {
|
||||
const client = new Client4();
|
||||
client.setUrl('http://mattermost.example.com');
|
||||
|
||||
const userId = 'dummy-user-id';
|
||||
const page = -1; // Special value to trigger NDJSON response
|
||||
|
||||
// Sample NDJSON data with multiple channel memberships on separate lines
|
||||
const ndjsonData = '{"user_id":"dummy-user-id","channel_id":"channel1","roles":"channel_user"}\n' +
|
||||
'{"user_id":"dummy-user-id","channel_id":"channel2","roles":"channel_user channel_admin"}\n' +
|
||||
'{"user_id":"dummy-user-id","channel_id":"channel3","roles":"channel_user"}';
|
||||
|
||||
// Create a mock endpoint for getAllChannelsMembers that returns NDJSON data
|
||||
nock(client.getBaseRoute()).
|
||||
get(`/users/${userId}/channel_members${buildQueryString({page, per_page: 60})}`).
|
||||
reply(200, ndjsonData, {'Content-Type': 'application/x-ndjson'});
|
||||
|
||||
// Call the getAllChannelsMembers method which will use our implementation for NDJSON
|
||||
const result = await client.getAllChannelsMembers(userId, page);
|
||||
|
||||
// Verify the response was parsed as an array of objects
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]).toEqual({user_id: 'dummy-user-id', channel_id: 'channel1', roles: 'channel_user'});
|
||||
expect(result[1]).toEqual({user_id: 'dummy-user-id', channel_id: 'channel2', roles: 'channel_user channel_admin'});
|
||||
expect(result[2]).toEqual({user_id: 'dummy-user-id', channel_id: 'channel3', roles: 'channel_user'});
|
||||
});
|
||||
|
||||
test('should parse ZIP responses as blobs', async () => {
|
||||
const client = new Client4();
|
||||
client.setUrl('http://mattermost.example.com');
|
||||
|
||||
const postId = 'dummy-post-id';
|
||||
const zipData = Buffer.from('zip contents');
|
||||
|
||||
nock(client.getBaseRoute()).
|
||||
post(`/content_flagging/post/${postId}/report`, {comment: 'investigation note'}).
|
||||
reply(200, zipData, {'Content-Type': 'application/zip'});
|
||||
|
||||
const result = await client.generateFlaggedPostReport(postId, 'investigation note');
|
||||
|
||||
expect(typeof result.text).toBe('function');
|
||||
expect(result.size).toEqual(zipData.length);
|
||||
expect(result.type).toEqual('application/zip');
|
||||
expect(await result.text()).toEqual('zip contents');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ClientError', () => {
|
||||
test('standard fields should be enumerable', () => {
|
||||
const error = new ClientError('https://example.com', {
|
||||
message: 'This is a message',
|
||||
server_error_id: 'test.app_error',
|
||||
status_code: 418,
|
||||
url: 'https://example.com/api/v4/error',
|
||||
});
|
||||
|
||||
const copy = {...error};
|
||||
|
||||
expect(copy.message).toEqual(error.message);
|
||||
expect(copy.server_error_id).toEqual(error.server_error_id);
|
||||
expect(copy.status_code).toEqual(error.status_code);
|
||||
expect(copy.url).toEqual(error.url);
|
||||
});
|
||||
|
||||
test('cause should be preserved when provided', () => {
|
||||
const cause = new Error('the original error');
|
||||
const error = new ClientError('https://example.com', {
|
||||
message: 'This is a message',
|
||||
server_error_id: 'test.app_error',
|
||||
status_code: 418,
|
||||
url: 'https://example.com/api/v4/error',
|
||||
}, cause);
|
||||
|
||||
const copy = {...error};
|
||||
|
||||
expect(copy.message).toEqual(error.message);
|
||||
expect(copy.server_error_id).toEqual(error.server_error_id);
|
||||
expect(copy.status_code).toEqual(error.status_code);
|
||||
expect(copy.url).toEqual(error.url);
|
||||
expect(error.cause).toEqual(cause);
|
||||
});
|
||||
});
|
||||
|
||||
5530
webapp/platform/client/src/client4.ts
Normal file
5530
webapp/platform/client/src/client4.ts
Normal file
File diff suppressed because it is too large
Load Diff
36
webapp/platform/client/src/errors.test.ts
Normal file
36
webapp/platform/client/src/errors.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import Client4 from './client4';
|
||||
import {cleanUrlForLogging} from './errors';
|
||||
|
||||
describe('cleanUrlForLogging', () => {
|
||||
const baseUrl = 'https://mattermost.example.com/subpath';
|
||||
|
||||
const client = new Client4();
|
||||
client.setUrl(baseUrl);
|
||||
|
||||
const testCases = [{
|
||||
name: 'should remove server URL',
|
||||
input: client.getUserRoute('me'),
|
||||
expected: `${client.urlVersion}/users/me`,
|
||||
}, {
|
||||
name: 'should filter user IDs',
|
||||
input: client.getUserRoute('1234'),
|
||||
expected: `${client.urlVersion}/users/<filtered>`,
|
||||
}, {
|
||||
name: 'should filter email addresses',
|
||||
input: `${client.getUsersRoute()}/email/test@example.com`,
|
||||
expected: `${client.urlVersion}/users/email/<filtered>`,
|
||||
}, {
|
||||
name: 'should filter query parameters',
|
||||
input: `${client.getUserRoute('me')}?foo=bar`,
|
||||
expected: `${client.urlVersion}/users/me?<filtered>`,
|
||||
}];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
test(testCase.name, () => {
|
||||
expect(cleanUrlForLogging(baseUrl, testCase.input)).toEqual(testCase.expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
52
webapp/platform/client/src/errors.ts
Normal file
52
webapp/platform/client/src/errors.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
// Given a URL from an API request, return a URL that has any parts removed that are either sensitive or that would
|
||||
// prevent properly grouping the messages in Sentry.
|
||||
export function cleanUrlForLogging(baseUrl: string, apiUrl: string): string {
|
||||
let url = apiUrl;
|
||||
|
||||
// Trim the host name
|
||||
url = url.substring(baseUrl.length);
|
||||
|
||||
// Filter the query string
|
||||
const index = url.indexOf('?');
|
||||
if (index !== -1) {
|
||||
url = url.substring(0, index);
|
||||
}
|
||||
|
||||
// A non-exhaustive whitelist to exclude parts of the URL that are unimportant (eg IDs) or may be sentsitive
|
||||
// (eg email addresses). We prefer filtering out fields that aren't recognized because there should generally
|
||||
// be enough left over for debugging.
|
||||
//
|
||||
// Note that new API routes don't need to be added here since this shouldn't be happening for newly added routes.
|
||||
const whitelist = [
|
||||
'api', 'v4', 'users', 'teams', 'scheme', 'name', 'members', 'channels', 'posts', 'reactions', 'commands',
|
||||
'files', 'preferences', 'hooks', 'incoming', 'outgoing', 'oauth', 'apps', 'emoji', 'brand', 'image',
|
||||
'data_retention', 'jobs', 'plugins', 'roles', 'system', 'timezones', 'schemes', 'redirect_location', 'patch',
|
||||
'mfa', 'password', 'reset', 'send', 'active', 'verify', 'terms_of_service', 'login', 'logout', 'ids',
|
||||
'usernames', 'me', 'username', 'email', 'default', 'sessions', 'revoke', 'all', 'audits', 'device', 'status',
|
||||
'search', 'switch', 'authorized', 'authorize', 'deauthorize', 'tokens', 'disable', 'enable', 'exists', 'unread',
|
||||
'invite', 'batch', 'stats', 'import', 'schemeRoles', 'direct', 'group', 'convert', 'view', 'search_autocomplete',
|
||||
'thread', 'info', 'flagged', 'pinned', 'pin', 'unpin', 'opengraph', 'actions', 'thumbnail', 'preview', 'link',
|
||||
'delete', 'logs', 'ping', 'config', 'client', 'license', 'websocket', 'webrtc', 'token', 'regen_token',
|
||||
'autocomplete', 'execute', 'regen_secret', 'policy', 'type', 'cancel', 'reload', 'environment', 's3_test', 'file',
|
||||
'caches', 'invalidate', 'database', 'recycle', 'compliance', 'reports', 'cluster', 'ldap', 'test', 'sync', 'saml',
|
||||
'certificate', 'public', 'private', 'idp', 'elasticsearch', 'purge_indexes', 'analytics', 'old', 'webapp', 'fake',
|
||||
];
|
||||
|
||||
url = url.split('/').map((part) => {
|
||||
if (part !== '' && whitelist.indexOf(part) === -1) {
|
||||
return '<filtered>';
|
||||
}
|
||||
|
||||
return part;
|
||||
}).join('/');
|
||||
|
||||
if (index !== -1) {
|
||||
// Add this on afterwards since it wouldn't pass the whitelist
|
||||
url += '?<filtered>';
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
73
webapp/platform/client/src/helpers.test.ts
Normal file
73
webapp/platform/client/src/helpers.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {buildQueryString, extractFilenameFromContentDisposition} from './helpers';
|
||||
|
||||
describe('Helpers', () => {
|
||||
test.each([
|
||||
[{}, ''],
|
||||
[{a: 1}, '?a=1'],
|
||||
[{a: 1, b: 'str'}, '?a=1&b=str'],
|
||||
[{a: 1, b: 'str', c: undefined}, '?a=1&b=str'],
|
||||
[{a: 1, b: 'str', c: 0}, '?a=1&b=str&c=0'],
|
||||
[{a: 1, b: 'str', c: ''}, '?a=1&b=str&c='],
|
||||
[{a: 1, b: undefined, c: 'str'}, '?a=1&c=str'],
|
||||
])('buildQueryString with %o should return %s', (params, expected) => {
|
||||
expect(buildQueryString(params)).toEqual(expected);
|
||||
});
|
||||
|
||||
describe('extractFilenameFromContentDisposition', () => {
|
||||
const fallback = 'fallback.csv';
|
||||
|
||||
test.each([
|
||||
['attachment; filename="report.csv"', 'report.csv'],
|
||||
['attachment; filename=report.csv', 'report.csv'],
|
||||
["attachment; filename='report.csv'", 'report.csv'],
|
||||
['attachment; filename="post-exposure-abc123-1700000000000.csv"', 'post-exposure-abc123-1700000000000.csv'],
|
||||
|
||||
// An unquoted value ends at the parameter separator, with or without surrounding space.
|
||||
['attachment; filename=report.csv; size=1', 'report.csv'],
|
||||
['attachment; filename=report.csv;size=1', 'report.csv'],
|
||||
['attachment; filename = report.csv ; size=1', 'report.csv'],
|
||||
|
||||
// A quoted value may contain the separator and escaped quotes.
|
||||
['attachment; filename="report; final.csv"', 'report; final.csv'],
|
||||
['attachment; filename="say \\"hi\\".csv"', 'say "hi".csv'],
|
||||
["attachment; filename='say \\'hi\\'.csv'", "say 'hi'.csv"],
|
||||
['attachment; filename="back\\\\slash.csv"', 'back\\slash.csv'],
|
||||
["attachment; filename='back\\\\slash.csv'", 'back\\slash.csv'],
|
||||
|
||||
// RFC 5987 extended values are percent-decoded, and preferred over plain filename.
|
||||
["attachment; filename*=UTF-8''report.csv", 'report.csv'],
|
||||
["attachment; filename*=UTF-8''report%20name.csv", 'report name.csv'],
|
||||
["attachment; filename=ascii.csv; filename*=UTF-8''unicode%E2%9C%93.csv", 'unicode✓.csv'],
|
||||
["attachment; filename*=UTF-8''report.csv; size=1", 'report.csv'],
|
||||
|
||||
// An undecodable or malformed extended value falls through to plain filename, then fallback.
|
||||
["attachment; filename=ascii.csv; filename*=UTF-8''%E0%A4%A.csv", 'ascii.csv'],
|
||||
['attachment; filename*=no-charset-delimiters', fallback],
|
||||
["attachment; filename*=UTF-8''%E0%A4%A.csv", fallback],
|
||||
|
||||
['attachment; filename=', fallback],
|
||||
['attachment; filename=""', fallback],
|
||||
['inline', fallback],
|
||||
['', fallback],
|
||||
[null, fallback],
|
||||
[undefined, fallback],
|
||||
])('with header %p should return %p', (header, expected) => {
|
||||
expect(extractFilenameFromContentDisposition(header, fallback)).toEqual(expected);
|
||||
});
|
||||
|
||||
// Only the "\\." alternative may consume a backslash. If the fallback character class
|
||||
// could consume it too, every escaped character would have two ways to match and an
|
||||
// unterminated value would backtrack exponentially, blocking the calling thread.
|
||||
test.each([
|
||||
['"', '\\!'],
|
||||
["'", '\\&'],
|
||||
])('with an unterminated %p quoted value repeating %p should not backtrack', (quote, escaped) => {
|
||||
const value = escaped.repeat(80);
|
||||
|
||||
expect(extractFilenameFromContentDisposition(`attachment; filename=${quote}${value}`, fallback)).toEqual(`${quote}${value}`);
|
||||
}, 1000);
|
||||
});
|
||||
});
|
||||
66
webapp/platform/client/src/helpers.ts
Normal file
66
webapp/platform/client/src/helpers.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export function buildQueryString(parameters: Record<string, any>): string {
|
||||
const keys = Object.keys(parameters);
|
||||
if (keys.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const queryParams = Object.entries(parameters).
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
filter(([_, value]) => value !== undefined).
|
||||
map(([key, value]) => `${key}=${encodeURIComponent(value)}`).
|
||||
join('&');
|
||||
|
||||
return queryParams.length > 0 ? `?${queryParams}` : '';
|
||||
}
|
||||
|
||||
// extractFilenameFromContentDisposition returns the filename advertised by a Content-Disposition
|
||||
// response header, falling back to the given name when the header is missing or unparsable.
|
||||
export function extractFilenameFromContentDisposition(header: string | null | undefined, fallback: string): string {
|
||||
if (!header) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// RFC 6266 prefers the RFC 5987 extended parameter when a header carries both.
|
||||
const extended = (/filename\*\s*=\s*([^;]*)/i).exec(header);
|
||||
if (extended) {
|
||||
const decoded = decodeExtendedFilename(extended[1].trim());
|
||||
if (decoded) {
|
||||
return decoded;
|
||||
}
|
||||
}
|
||||
|
||||
// A quoted value may legitimately contain the ";" parameter separator, so consume it as a
|
||||
// unit. An unquoted value ends at the next ";" or at the end of the header; without that
|
||||
// bound a trailing parameter such as "; size=1" would be read as part of the filename.
|
||||
const quoted = (/filename\s*=\s*"((?:\\.|[^"\\])*)"/i).exec(header) ??
|
||||
(/filename\s*=\s*'((?:\\.|[^'\\])*)'/i).exec(header);
|
||||
if (quoted) {
|
||||
return quoted[1].replace(/\\(.)/g, '$1').trim() || fallback;
|
||||
}
|
||||
|
||||
const unquoted = (/filename\s*=\s*([^;]*)/i).exec(header);
|
||||
if (unquoted) {
|
||||
return unquoted[1].trim() || fallback;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// decodeExtendedFilename decodes an RFC 5987 "charset'language'percent-encoded-value" parameter.
|
||||
// It returns an empty string when the value is malformed or percent-decoding fails, so callers
|
||||
// fall through to the plain "filename" parameter rather than surfacing a partial value.
|
||||
function decodeExtendedFilename(value: string): string {
|
||||
const parts = (/^[\w-]+'[\w-]*'(.*)$/).exec(value);
|
||||
if (!parts) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return decodeURIComponent(parts[1]).trim();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
18
webapp/platform/client/src/index.ts
Normal file
18
webapp/platform/client/src/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type * as WebSocketMessages from './websocket_messages';
|
||||
|
||||
export {
|
||||
default as Client4,
|
||||
ClientError,
|
||||
DEFAULT_LIMIT_AFTER,
|
||||
DEFAULT_LIMIT_BEFORE,
|
||||
} from './client4';
|
||||
|
||||
export {extractFilenameFromContentDisposition} from './helpers';
|
||||
|
||||
export {default as WebSocketClient} from './websocket';
|
||||
export {WebSocketEvents} from './websocket_events';
|
||||
export type {BaseWebSocketMessage, JsonEncodedValue, WebSocketBroadcast, WebSocketMessage} from './websocket_message';
|
||||
export {WebSocketMessages};
|
||||
664
webapp/platform/client/src/websocket.test.ts
Normal file
664
webapp/platform/client/src/websocket.test.ts
Normal file
@@ -0,0 +1,664 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import WebSocketClient from './websocket';
|
||||
|
||||
// Define some browser globals that aren't defined in node
|
||||
if (typeof WebSocket === 'undefined') {
|
||||
(global as any).WebSocket = {
|
||||
CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3,
|
||||
};
|
||||
}
|
||||
|
||||
// Mock Event class if it's not defined
|
||||
if (typeof Event === 'undefined') {
|
||||
(global as any).Event = class MockEvent {
|
||||
type: string;
|
||||
|
||||
constructor(type: string) {
|
||||
this.type = type;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Mock CloseEvent class if it's not defined
|
||||
if (typeof CloseEvent === 'undefined') {
|
||||
(global as any).CloseEvent = class MockCloseEvent extends (global as any).Event {
|
||||
code: number;
|
||||
reason: string;
|
||||
wasClean: boolean;
|
||||
|
||||
constructor(type: string, options?: {code?: number; reason?: string; wasClean?: boolean}) {
|
||||
super(type);
|
||||
this.code = options?.code || 0;
|
||||
this.reason = options?.reason || '';
|
||||
this.wasClean = options?.wasClean || false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class MockWebSocket {
|
||||
readonly binaryType: BinaryType = 'blob';
|
||||
readonly bufferedAmount: number = 0;
|
||||
readonly extensions: string = '';
|
||||
|
||||
readonly CONNECTING = WebSocket.CONNECTING;
|
||||
readonly OPEN = WebSocket.OPEN;
|
||||
readonly CLOSING = WebSocket.CLOSING;
|
||||
readonly CLOSED = WebSocket.CLOSED;
|
||||
|
||||
public url: string = '';
|
||||
readonly protocol: string = '';
|
||||
public readyState: number = WebSocket.CONNECTING;
|
||||
|
||||
public onopen: (() => void) | null = null;
|
||||
public onclose: (() => void) | null = null;
|
||||
public onerror: (() => void) | null = null;
|
||||
public onmessage: ((evt: any) => void) | null = null;
|
||||
|
||||
open() {
|
||||
this.readyState = WebSocket.OPEN;
|
||||
if (this.onopen) {
|
||||
this.onopen();
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = WebSocket.CLOSED;
|
||||
if (this.onclose) {
|
||||
this.onclose();
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
send(msg: any) { }
|
||||
addEventListener() { }
|
||||
removeEventListener() { }
|
||||
dispatchEvent(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
describe('websocketclient', () => {
|
||||
test('initialize should register connection callbacks', () => {
|
||||
const mockWebSocket = new MockWebSocket();
|
||||
|
||||
const client = new WebSocketClient({
|
||||
newWebSocketFn: (url: string) => {
|
||||
mockWebSocket.url = url;
|
||||
return mockWebSocket;
|
||||
},
|
||||
});
|
||||
|
||||
client.initialize('mock.url');
|
||||
|
||||
expect(mockWebSocket.onopen).toBeTruthy();
|
||||
expect(mockWebSocket.onclose).toBeTruthy();
|
||||
|
||||
client.close();
|
||||
});
|
||||
|
||||
test('should reconnect on websocket close', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const mockWebSocket = new MockWebSocket();
|
||||
const openSpy = jest.spyOn(mockWebSocket, 'open');
|
||||
|
||||
const client = new WebSocketClient({
|
||||
newWebSocketFn: (url: string) => {
|
||||
mockWebSocket.url = url;
|
||||
mockWebSocket.open();
|
||||
return mockWebSocket;
|
||||
},
|
||||
minWebSocketRetryTime: 10,
|
||||
reconnectJitterRange: 10,
|
||||
});
|
||||
|
||||
client.initialize('mock.url');
|
||||
expect(openSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
mockWebSocket.close();
|
||||
|
||||
jest.advanceTimersByTime(100);
|
||||
|
||||
client.close();
|
||||
expect(openSpy).toHaveBeenCalledTimes(2);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('should close during reconnection delay', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const mockWebSocket = new MockWebSocket();
|
||||
const openSpy = jest.spyOn(mockWebSocket, 'open');
|
||||
|
||||
const client = new WebSocketClient({
|
||||
newWebSocketFn: (url: string) => {
|
||||
mockWebSocket.url = url;
|
||||
setTimeout(() => {
|
||||
if (mockWebSocket.onopen) {
|
||||
mockWebSocket.open();
|
||||
}
|
||||
}, 1);
|
||||
return mockWebSocket;
|
||||
},
|
||||
minWebSocketRetryTime: 50,
|
||||
reconnectJitterRange: 1,
|
||||
});
|
||||
|
||||
const initializeSpy = jest.spyOn(client, 'initialize');
|
||||
client.initialize('mock.url');
|
||||
mockWebSocket.close();
|
||||
|
||||
jest.advanceTimersByTime(10);
|
||||
|
||||
client.close();
|
||||
|
||||
jest.advanceTimersByTime(80);
|
||||
|
||||
client.close();
|
||||
expect(initializeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(openSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('should not re-open if initialize called during reconnection delay', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const mockWebSocket = new MockWebSocket();
|
||||
const openSpy = jest.spyOn(mockWebSocket, 'open');
|
||||
|
||||
const client = new WebSocketClient({
|
||||
newWebSocketFn: (url: string) => {
|
||||
mockWebSocket.url = url;
|
||||
setTimeout(() => {
|
||||
if (mockWebSocket.onopen) {
|
||||
mockWebSocket.open();
|
||||
}
|
||||
}, 1);
|
||||
return mockWebSocket;
|
||||
},
|
||||
minWebSocketRetryTime: 50,
|
||||
reconnectJitterRange: 1,
|
||||
});
|
||||
|
||||
const initializeSpy = jest.spyOn(client, 'initialize');
|
||||
client.initialize('mock.url');
|
||||
mockWebSocket.close();
|
||||
|
||||
jest.advanceTimersByTime(10);
|
||||
|
||||
client.initialize('mock.url');
|
||||
expect(initializeSpy).toHaveBeenCalledTimes(2);
|
||||
expect(openSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
jest.advanceTimersByTime(80);
|
||||
|
||||
client.close();
|
||||
expect(initializeSpy).toHaveBeenCalledTimes(3);
|
||||
expect(openSpy).toHaveBeenCalledTimes(2);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('should not register second reconnection timeout if onclose called twice', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const mockWebSocket = new MockWebSocket();
|
||||
const openSpy = jest.spyOn(mockWebSocket, 'open');
|
||||
|
||||
const client = new WebSocketClient({
|
||||
newWebSocketFn: (url: string) => {
|
||||
mockWebSocket.url = url;
|
||||
setTimeout(() => {
|
||||
if (mockWebSocket.onopen) {
|
||||
mockWebSocket.open();
|
||||
}
|
||||
}, 1);
|
||||
return mockWebSocket;
|
||||
},
|
||||
minWebSocketRetryTime: 50,
|
||||
reconnectJitterRange: 1,
|
||||
});
|
||||
|
||||
const initializeSpy = jest.spyOn(client, 'initialize');
|
||||
client.initialize('mock.url');
|
||||
mockWebSocket.close();
|
||||
|
||||
jest.advanceTimersByTime(10);
|
||||
|
||||
mockWebSocket.close();
|
||||
|
||||
jest.advanceTimersByTime(80);
|
||||
|
||||
client.close();
|
||||
expect(initializeSpy).toHaveBeenCalledTimes(2);
|
||||
expect(openSpy).toHaveBeenCalledTimes(2);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('should stay connected after ping response', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const mockWebSocket = new MockWebSocket();
|
||||
const client = new WebSocketClient({
|
||||
newWebSocketFn: (url: string) => {
|
||||
mockWebSocket.url = url;
|
||||
setTimeout(() => {
|
||||
if (mockWebSocket.onopen) {
|
||||
mockWebSocket.open();
|
||||
}
|
||||
}, 1);
|
||||
return mockWebSocket;
|
||||
},
|
||||
minWebSocketRetryTime: 1,
|
||||
reconnectJitterRange: 1,
|
||||
clientPingInterval: 1,
|
||||
});
|
||||
|
||||
let numPings = 0;
|
||||
let numPongs = 0;
|
||||
mockWebSocket.send = (evt) => {
|
||||
const msg = JSON.parse(evt);
|
||||
|
||||
if (msg.action !== 'ping') {
|
||||
return;
|
||||
}
|
||||
numPings++;
|
||||
|
||||
const rsp = {
|
||||
text: 'pong',
|
||||
seq_reply: msg.seq,
|
||||
};
|
||||
|
||||
if (mockWebSocket.onmessage) {
|
||||
mockWebSocket.onmessage({data: JSON.stringify(rsp)});
|
||||
numPongs++;
|
||||
}
|
||||
};
|
||||
|
||||
const openSpy = jest.spyOn(mockWebSocket, 'open');
|
||||
const closeSpy = jest.spyOn(mockWebSocket, 'close');
|
||||
|
||||
client.initialize('mock.url');
|
||||
|
||||
jest.advanceTimersByTime(30);
|
||||
|
||||
client.close();
|
||||
|
||||
expect(openSpy).toHaveBeenCalledTimes(1);
|
||||
expect(closeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(numPings).toBeGreaterThan(10);
|
||||
expect(numPongs).toBeGreaterThan(10);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('should reconnect after no ping response', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const mockWebSocket = new MockWebSocket();
|
||||
const client = new WebSocketClient({
|
||||
newWebSocketFn: (url: string) => {
|
||||
mockWebSocket.url = url;
|
||||
setTimeout(() => {
|
||||
if (mockWebSocket.onopen) {
|
||||
mockWebSocket.open();
|
||||
}
|
||||
}, 1);
|
||||
return mockWebSocket;
|
||||
},
|
||||
minWebSocketRetryTime: 1,
|
||||
reconnectJitterRange: 1,
|
||||
clientPingInterval: 10,
|
||||
});
|
||||
|
||||
let numPings = 0;
|
||||
let numPongs = 0;
|
||||
mockWebSocket.send = (evt) => {
|
||||
const msg = JSON.parse(evt);
|
||||
|
||||
if (msg.action !== 'ping') {
|
||||
return;
|
||||
}
|
||||
numPings++;
|
||||
|
||||
// stop responding after three pings
|
||||
if (numPings > 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rsp = {
|
||||
text: 'pong',
|
||||
seq_reply: msg.seq,
|
||||
};
|
||||
|
||||
if (mockWebSocket.onmessage) {
|
||||
mockWebSocket.onmessage({data: JSON.stringify(rsp)});
|
||||
numPongs++;
|
||||
}
|
||||
};
|
||||
|
||||
mockWebSocket.open = jest.fn(mockWebSocket.open);
|
||||
mockWebSocket.close = jest.fn(() => {
|
||||
mockWebSocket.readyState = WebSocket.CLOSED;
|
||||
if (mockWebSocket.onclose) {
|
||||
mockWebSocket.onclose();
|
||||
}
|
||||
if (jest.mocked(mockWebSocket.close).mock.calls.length === 3) {
|
||||
setTimeout(() => {
|
||||
client.close();
|
||||
}, 1);
|
||||
}
|
||||
});
|
||||
|
||||
client.initialize('mock.url');
|
||||
|
||||
jest.advanceTimersByTime(100);
|
||||
|
||||
client.close();
|
||||
|
||||
expect(mockWebSocket.open).toHaveBeenCalledTimes(3);
|
||||
expect(mockWebSocket.close).toHaveBeenCalledTimes(3);
|
||||
expect(numPings).toBe(6);
|
||||
expect(numPongs).toBe(3);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('should reset ping interval state when reconnecting during pending ping', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const mockWebSocket = new MockWebSocket();
|
||||
const client = new WebSocketClient({
|
||||
newWebSocketFn: (url: string) => {
|
||||
mockWebSocket.url = url;
|
||||
setTimeout(() => {
|
||||
if (mockWebSocket.onopen) {
|
||||
mockWebSocket.open();
|
||||
}
|
||||
}, 1);
|
||||
return mockWebSocket;
|
||||
},
|
||||
minWebSocketRetryTime: 1,
|
||||
reconnectJitterRange: 1,
|
||||
clientPingInterval: 15,
|
||||
});
|
||||
|
||||
let numPings = 0;
|
||||
let numPongs = 0;
|
||||
mockWebSocket.send = (evt) => {
|
||||
const msg = JSON.parse(evt);
|
||||
|
||||
if (msg.action !== 'ping') {
|
||||
return;
|
||||
}
|
||||
numPings++;
|
||||
|
||||
// don't respond to second ping
|
||||
if (numPings === 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rsp = {
|
||||
text: 'pong',
|
||||
seq_reply: msg.seq,
|
||||
};
|
||||
|
||||
if (mockWebSocket.onmessage) {
|
||||
mockWebSocket.onmessage({data: JSON.stringify(rsp)});
|
||||
numPongs++;
|
||||
}
|
||||
};
|
||||
|
||||
const openSpy = jest.spyOn(mockWebSocket, 'open');
|
||||
const closeSpy = jest.spyOn(mockWebSocket, 'close');
|
||||
|
||||
client.initialize('mock.url');
|
||||
|
||||
// Let first ping happen
|
||||
jest.advanceTimersByTime(10);
|
||||
expect(numPings).toBe(1);
|
||||
expect(numPongs).toBe(1);
|
||||
|
||||
// Let second ping happen
|
||||
jest.advanceTimersByTime(10);
|
||||
expect(numPings).toBe(2);
|
||||
expect(numPongs).toBe(1);
|
||||
|
||||
// Ensure we've still only connected once, and haven't disconnected, yet
|
||||
expect(openSpy).toHaveBeenCalledTimes(1);
|
||||
expect(closeSpy).toHaveBeenCalledTimes(0);
|
||||
|
||||
// Close and reopen connection before ping timeout
|
||||
mockWebSocket.close();
|
||||
|
||||
// Let new connection run for a while to ensure no immediate reconnect
|
||||
jest.advanceTimersByTime(100);
|
||||
client.close();
|
||||
|
||||
expect(numPings).toBe(9);
|
||||
expect(numPongs).toBe(numPings - 1); // Ensure we only skipped the first response
|
||||
expect(openSpy).toHaveBeenCalledTimes(2); // Initial open and one reconnect
|
||||
expect(closeSpy).toHaveBeenCalledTimes(2); // Manual close and final close
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('online/offline', () => {
|
||||
const originalWindow = globalThis.window;
|
||||
|
||||
// Mock window and navigator if they're not defined
|
||||
beforeAll(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
const eventHandlers: {[key: string]: Array<(event: Event) => void>} = {};
|
||||
|
||||
// Create a mock window object with working event handlers
|
||||
(globalThis as any).window = {
|
||||
addEventListener: jest.fn((event: string, handler: (event: Event) => void) => {
|
||||
if (!eventHandlers[event]) {
|
||||
eventHandlers[event] = [];
|
||||
}
|
||||
eventHandlers[event].push(handler);
|
||||
}),
|
||||
removeEventListener: jest.fn((event: string, handler: (event: Event) => void) => {
|
||||
if (eventHandlers[event]) {
|
||||
const index = eventHandlers[event].indexOf(handler);
|
||||
if (index !== -1) {
|
||||
eventHandlers[event].splice(index, 1);
|
||||
}
|
||||
}
|
||||
}),
|
||||
dispatchEvent: jest.fn((event: Event) => {
|
||||
const handlers = eventHandlers[event.type] || [];
|
||||
handlers.forEach((handler) => handler(event));
|
||||
return true;
|
||||
}),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
globalThis.window = originalWindow;
|
||||
});
|
||||
|
||||
test('should add network event listener on initialize', () => {
|
||||
// Mock window.addEventListener
|
||||
const originalAddEventListener = window.addEventListener;
|
||||
const originalRemoveEventListener = window.removeEventListener;
|
||||
|
||||
const addEventListenerMock = jest.fn();
|
||||
const removeEventListenerMock = jest.fn();
|
||||
|
||||
window.addEventListener = addEventListenerMock;
|
||||
window.removeEventListener = removeEventListenerMock;
|
||||
|
||||
// Create client
|
||||
const mockWebSocket = new MockWebSocket();
|
||||
const client = new WebSocketClient({
|
||||
newWebSocketFn: (url: string) => {
|
||||
mockWebSocket.url = url;
|
||||
return mockWebSocket;
|
||||
},
|
||||
});
|
||||
|
||||
// No listeners should be added on construction
|
||||
expect(addEventListenerMock).not.toHaveBeenCalled();
|
||||
|
||||
// Initialize should add listeners
|
||||
client.initialize('mock.url');
|
||||
|
||||
// Verify event listeners are added on initialize
|
||||
expect(addEventListenerMock).toHaveBeenCalledWith('online', expect.any(Function));
|
||||
|
||||
// Clean up
|
||||
client.close();
|
||||
|
||||
// Verify event listeners are removed
|
||||
expect(removeEventListenerMock).toHaveBeenCalledWith('online', expect.any(Function));
|
||||
|
||||
// Restore mocks
|
||||
window.addEventListener = originalAddEventListener;
|
||||
window.removeEventListener = originalRemoveEventListener;
|
||||
});
|
||||
|
||||
test('should reconnect when network comes online', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
var connected = true;
|
||||
const mockWebSocket = new MockWebSocket();
|
||||
const newWebSocketFn = jest.fn((url: string) => {
|
||||
mockWebSocket.url = url;
|
||||
|
||||
// selectively simulate the network being down
|
||||
setTimeout(() => {
|
||||
if (!connected && mockWebSocket.onclose) {
|
||||
mockWebSocket.close();
|
||||
}
|
||||
}, 1);
|
||||
|
||||
return mockWebSocket;
|
||||
});
|
||||
|
||||
// Use a small minWebSocketRetryTime to speed up the test
|
||||
const client = new WebSocketClient({
|
||||
newWebSocketFn,
|
||||
minWebSocketRetryTime: 100,
|
||||
maxWebSocketRetryTime: 1000,
|
||||
});
|
||||
|
||||
// Initialize the client
|
||||
client.initialize('mock.url');
|
||||
mockWebSocket.open();
|
||||
expect(newWebSocketFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Simulate network going offline
|
||||
mockWebSocket.close();
|
||||
connected = false;
|
||||
|
||||
// Connection should be closed
|
||||
expect(mockWebSocket.readyState).toBe(WebSocket.CLOSED);
|
||||
|
||||
// Wait a very long time to max out retry timeout
|
||||
jest.advanceTimersByTime(10000);
|
||||
|
||||
// Reset the mock to track the next retry
|
||||
// which should be quicker than the max
|
||||
newWebSocketFn.mockClear();
|
||||
|
||||
// Simulate network coming back online
|
||||
connected = true;
|
||||
const onlineEvent = new Event('online');
|
||||
window.dispatchEvent(onlineEvent);
|
||||
|
||||
// Should not reconnect immediately (should wait for the timeout)
|
||||
expect(newWebSocketFn).not.toHaveBeenCalled();
|
||||
|
||||
// Advance timers to trigger the reconnect
|
||||
jest.advanceTimersByTime(110);
|
||||
|
||||
// Should have reconnected after the timeout
|
||||
expect(newWebSocketFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Clean up
|
||||
client.close();
|
||||
|
||||
// Reset timers
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('should send ping when network goes offline', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const mockWebSocket = new MockWebSocket();
|
||||
const client = new WebSocketClient({
|
||||
newWebSocketFn: (url: string) => {
|
||||
mockWebSocket.url = url;
|
||||
setTimeout(() => {
|
||||
if (mockWebSocket.onopen) {
|
||||
mockWebSocket.open();
|
||||
}
|
||||
}, 1);
|
||||
return mockWebSocket;
|
||||
},
|
||||
clientPingInterval: 300,
|
||||
});
|
||||
|
||||
let numPings = 0;
|
||||
mockWebSocket.send = (evt) => {
|
||||
const msg = JSON.parse(evt);
|
||||
if (msg.action !== 'ping') {
|
||||
return;
|
||||
}
|
||||
numPings++;
|
||||
};
|
||||
|
||||
const openSpy = jest.spyOn(mockWebSocket, 'open');
|
||||
const closeSpy = jest.spyOn(mockWebSocket, 'close');
|
||||
|
||||
client.initialize('mock.url');
|
||||
jest.advanceTimersByTime(10);
|
||||
|
||||
expect(mockWebSocket.readyState).toBe(WebSocket.OPEN);
|
||||
expect(openSpy).toHaveBeenCalledTimes(1);
|
||||
expect(closeSpy).toHaveBeenCalledTimes(0);
|
||||
expect(numPings).toBe(1);
|
||||
|
||||
// Simulate network going offline
|
||||
const offlineEvent = new Event('offline');
|
||||
window.dispatchEvent(offlineEvent);
|
||||
jest.advanceTimersByTime(10);
|
||||
|
||||
client.close();
|
||||
|
||||
expect(openSpy).toHaveBeenCalledTimes(1);
|
||||
expect(closeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(numPings).toBe(2);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
test('should be able to use WebSocketClient in a non-browser environment', () => {
|
||||
const mockWebSocket = new MockWebSocket();
|
||||
|
||||
const client = new WebSocketClient({
|
||||
newWebSocketFn: (url: string) => {
|
||||
mockWebSocket.url = url;
|
||||
return mockWebSocket;
|
||||
},
|
||||
});
|
||||
|
||||
expect(window).not.toBeDefined();
|
||||
|
||||
client.initialize('mock.url');
|
||||
|
||||
expect(mockWebSocket.onopen).toBeTruthy();
|
||||
expect(mockWebSocket.onclose).toBeTruthy();
|
||||
|
||||
client.close();
|
||||
});
|
||||
});
|
||||
678
webapp/platform/client/src/websocket.ts
Normal file
678
webapp/platform/client/src/websocket.ts
Normal file
@@ -0,0 +1,678 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {WebSocketEvents} from './websocket_events';
|
||||
import type {WebSocketMessage} from './websocket_message';
|
||||
|
||||
export type MessageListener = (msg: WebSocketMessage) => void;
|
||||
export type FirstConnectListener = () => void;
|
||||
export type ReconnectListener = () => void;
|
||||
export type MissedMessageListener = () => void;
|
||||
export type ErrorListener = (event: Event) => void;
|
||||
export type CloseListener = (connectFailCount: number) => void;
|
||||
|
||||
export type WebSocketClientConfig = {
|
||||
maxWebSocketFails: number;
|
||||
minWebSocketRetryTime: number;
|
||||
maxWebSocketRetryTime: number;
|
||||
reconnectJitterRange: number;
|
||||
newWebSocketFn: (url: string) => WebSocket;
|
||||
clientPingInterval: number;
|
||||
};
|
||||
|
||||
// Custom close error codes must be in the range of 4000-4999
|
||||
const clientPingTimeoutErrCode = 4000;
|
||||
const clientSequenceMismatchErrCode = 4001;
|
||||
|
||||
const defaultWebSocketClientConfig: WebSocketClientConfig = {
|
||||
maxWebSocketFails: 7,
|
||||
minWebSocketRetryTime: 3000, // 3 seconds
|
||||
maxWebSocketRetryTime: 300000, // 5 minutes
|
||||
reconnectJitterRange: 2000, // 2 seconds
|
||||
newWebSocketFn: (url: string) => {
|
||||
return new WebSocket(url);
|
||||
},
|
||||
clientPingInterval: 30000, // 30 seconds
|
||||
};
|
||||
|
||||
export default class WebSocketClient {
|
||||
private config: WebSocketClientConfig;
|
||||
|
||||
private conn: WebSocket | null;
|
||||
|
||||
// responseSequence is the number to track a response sent
|
||||
// via the websocket. A response will always have the same sequence number
|
||||
// as the request.
|
||||
private responseSequence: number;
|
||||
|
||||
// serverSequence is the incrementing sequence number from the
|
||||
// server-sent event stream.
|
||||
private serverSequence: number;
|
||||
private connectFailCount: number;
|
||||
private responseCallbacks: {[x: number]: ((msg: any) => void)};
|
||||
private lastErrCode: string | null;
|
||||
|
||||
/**
|
||||
* @deprecated Use messageListeners instead
|
||||
*/
|
||||
private eventCallback: MessageListener | null = null;
|
||||
|
||||
/**
|
||||
* @deprecated Use firstConnectListeners instead
|
||||
*/
|
||||
private firstConnectCallback: FirstConnectListener | null = null;
|
||||
|
||||
/**
|
||||
* @deprecated Use reconnectListeners instead
|
||||
*/
|
||||
private reconnectCallback: ReconnectListener | null = null;
|
||||
|
||||
/**
|
||||
* @deprecated Use missedMessageListeners instead
|
||||
*/
|
||||
private missedEventCallback: MissedMessageListener | null = null;
|
||||
|
||||
/**
|
||||
* @deprecated Use errorListeners instead
|
||||
*/
|
||||
private errorCallback: ErrorListener | null = null;
|
||||
|
||||
/**
|
||||
* @deprecated Use closeListeners instead
|
||||
*/
|
||||
private closeCallback: CloseListener | null = null;
|
||||
|
||||
private messageListeners = new Set<MessageListener>();
|
||||
private firstConnectListeners = new Set<FirstConnectListener>();
|
||||
private reconnectListeners = new Set<ReconnectListener>();
|
||||
private missedMessageListeners = new Set<MissedMessageListener>();
|
||||
private errorListeners = new Set<ErrorListener>();
|
||||
private closeListeners = new Set<CloseListener>();
|
||||
|
||||
private connectionId: string | null;
|
||||
private serverHostname: string | null;
|
||||
private postedAck: boolean;
|
||||
|
||||
private pingInterval: ReturnType<typeof setInterval> | null;
|
||||
private waitingForPong: boolean;
|
||||
|
||||
// reconnectTimeout is used for automatic reconnect after socket close
|
||||
private reconnectTimeout: ReturnType<typeof setTimeout> | null;
|
||||
|
||||
// Network event handlers
|
||||
private onlineHandler: (() => void) | null = null;
|
||||
private offlineHandler: (() => void) | null = null;
|
||||
|
||||
constructor(config?: Partial<WebSocketClientConfig>) {
|
||||
this.conn = null;
|
||||
this.responseSequence = 1;
|
||||
this.serverSequence = 0;
|
||||
this.connectFailCount = 0;
|
||||
this.responseCallbacks = {};
|
||||
this.connectionId = '';
|
||||
this.serverHostname = '';
|
||||
this.postedAck = false;
|
||||
this.reconnectTimeout = null;
|
||||
this.config = {...defaultWebSocketClientConfig, ...config};
|
||||
this.pingInterval = null;
|
||||
this.waitingForPong = false;
|
||||
this.lastErrCode = null;
|
||||
}
|
||||
|
||||
// on connect, only send auth cookie and blank state.
|
||||
// on hello, get the connectionID and store it.
|
||||
// on reconnect, send cookie, connectionID, sequence number.
|
||||
initialize(connectionUrl: string, token?: string, postedAck?: boolean) {
|
||||
if (this.conn) {
|
||||
return;
|
||||
}
|
||||
|
||||
// We have a timeout waiting to re-initialize the websocket.
|
||||
// We should wait until that fires before initializing,
|
||||
// otherwise we may not respect the configured backoff.
|
||||
if (this.reconnectTimeout) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (connectionUrl == null) {
|
||||
console.log('websocket must have connection url'); //eslint-disable-line no-console
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.connectFailCount === 0) {
|
||||
console.log('websocket connecting to ' + connectionUrl); //eslint-disable-line no-console
|
||||
}
|
||||
|
||||
if (typeof postedAck != 'undefined') {
|
||||
this.postedAck = postedAck;
|
||||
}
|
||||
|
||||
// Setup network event listener
|
||||
// Remove existing listeners if any
|
||||
if (this.onlineHandler) {
|
||||
globalThis.window?.removeEventListener('online', this.onlineHandler);
|
||||
}
|
||||
if (this.offlineHandler) {
|
||||
globalThis.window?.removeEventListener('offline', this.offlineHandler);
|
||||
}
|
||||
|
||||
this.onlineHandler = () => {
|
||||
// If we're already connected, don't need to do anything
|
||||
if (this.conn && this.conn.readyState === WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('network online event received, scheduling reconnect'); // eslint-disable-line no-console
|
||||
|
||||
// Set a timer to reconnect after a delay to avoid rapid connection attempts
|
||||
this.clearReconnectTimeout();
|
||||
this.reconnectTimeout = setTimeout(
|
||||
() => {
|
||||
this.reconnectTimeout = null;
|
||||
this.initialize(connectionUrl, token, this.postedAck);
|
||||
},
|
||||
this.config.minWebSocketRetryTime,
|
||||
);
|
||||
};
|
||||
|
||||
this.offlineHandler = () => {
|
||||
// If we've detected a full disconnection, don't need to do anything more
|
||||
if (this.conn && this.conn.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('network offline event received, checking connection'); // eslint-disable-line no-console
|
||||
|
||||
// If we haven't detected a full disconnection,
|
||||
// send a ping immediately to test the socket
|
||||
//
|
||||
// NOTE: There is a potential race condition here with the regular ping interval.
|
||||
// If we send this ping close to when the interval check occurs (e.g., at 29.5s of the 30s interval),
|
||||
// the server might not have enough time to respond before the interval executes.
|
||||
// When the interval runs, it will see we're still waiting for a pong and close the connection,
|
||||
// even though the network might be fine and the server just needs a bit more time to respond.
|
||||
// This race condition is rare and the impact is just an unnecessary reconnect,
|
||||
// so we accept this limitation to keep the implementation simple.
|
||||
this.waitingForPong = true;
|
||||
this.ping(() => {
|
||||
this.waitingForPong = false;
|
||||
});
|
||||
};
|
||||
|
||||
globalThis.window?.addEventListener('online', this.onlineHandler);
|
||||
globalThis.window?.addEventListener('offline', this.offlineHandler);
|
||||
|
||||
// Add connection id, and last_sequence_number to the query param.
|
||||
// We cannot use a cookie because it will bleed across tabs.
|
||||
// We cannot also send it as part of the auth_challenge, because the session cookie is already sent with the request.
|
||||
let websocketUrl = `${connectionUrl}?connection_id=${this.connectionId}&sequence_number=${this.serverSequence}`;
|
||||
|
||||
if (this.postedAck) {
|
||||
websocketUrl += '&posted_ack=true';
|
||||
}
|
||||
|
||||
if (this.lastErrCode) {
|
||||
websocketUrl += `&disconnect_err_code=${encodeURIComponent(this.lastErrCode)}`;
|
||||
}
|
||||
|
||||
if (this.config.newWebSocketFn) {
|
||||
this.conn = this.config.newWebSocketFn(websocketUrl);
|
||||
} else {
|
||||
this.conn = new WebSocket(websocketUrl);
|
||||
}
|
||||
|
||||
const onclose = (event: CloseEvent) => {
|
||||
this.conn = null;
|
||||
this.responseSequence = 1;
|
||||
|
||||
if (!this.lastErrCode && event && event.code) {
|
||||
this.lastErrCode = `${event.code}`;
|
||||
}
|
||||
|
||||
if (this.connectFailCount === 0) {
|
||||
console.log(`websocket closed: ${this.lastErrCode}`); //eslint-disable-line no-console
|
||||
}
|
||||
|
||||
this.connectFailCount++;
|
||||
|
||||
this.closeCallback?.(this.connectFailCount);
|
||||
this.closeListeners.forEach((listener) => listener(this.connectFailCount));
|
||||
|
||||
// Make sure we stop pinging if the connection is closed
|
||||
this.stopPingInterval();
|
||||
|
||||
// If we've failed a bunch of connections then start backing off
|
||||
let retryTime = this.config.minWebSocketRetryTime;
|
||||
if (this.connectFailCount > this.config.maxWebSocketFails) {
|
||||
retryTime = retryTime * this.connectFailCount * this.connectFailCount;
|
||||
if (retryTime > this.config.maxWebSocketRetryTime) {
|
||||
retryTime = this.config.maxWebSocketRetryTime;
|
||||
}
|
||||
}
|
||||
|
||||
// Applying jitter to avoid thundering herd problems.
|
||||
retryTime += Math.random() * this.config.reconnectJitterRange;
|
||||
|
||||
// If we already have a reconnect timeout waiting,
|
||||
// we should let that handle the next connection.
|
||||
if (this.reconnectTimeout) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectTimeout = setTimeout(
|
||||
() => {
|
||||
this.reconnectTimeout = null;
|
||||
this.initialize(connectionUrl, token, this.postedAck);
|
||||
},
|
||||
retryTime,
|
||||
);
|
||||
};
|
||||
this.conn.onclose = onclose;
|
||||
|
||||
this.conn.onopen = () => {
|
||||
if (token) {
|
||||
this.sendMessage('authentication_challenge', {token});
|
||||
}
|
||||
|
||||
this.lastErrCode = null;
|
||||
|
||||
if (this.connectFailCount > 0) {
|
||||
console.log('websocket re-established connection'); //eslint-disable-line no-console
|
||||
|
||||
this.reconnectCallback?.();
|
||||
this.reconnectListeners.forEach((listener) => listener());
|
||||
} else if (this.firstConnectCallback || this.firstConnectListeners.size > 0) {
|
||||
this.firstConnectCallback?.();
|
||||
this.firstConnectListeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
this.stopPingInterval();
|
||||
|
||||
// Send a ping immediately to test the socket
|
||||
this.waitingForPong = true;
|
||||
this.ping(() => {
|
||||
this.waitingForPong = false;
|
||||
});
|
||||
|
||||
// And every 30 seconds after, checking to ensure
|
||||
// we're getting responses from the server
|
||||
this.pingInterval = setInterval(
|
||||
() => {
|
||||
if (!this.waitingForPong) {
|
||||
this.waitingForPong = true;
|
||||
this.ping(() => {
|
||||
this.waitingForPong = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.stopPingInterval();
|
||||
|
||||
// If we aren't connected, we should already be trying to
|
||||
// re-connect the websocket. So there's nothing more to do here.
|
||||
if (!this.conn || this.conn.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('ping received no response within time limit: re-establishing websocket'); //eslint-disable-line no-console
|
||||
|
||||
const closeEvent = new CloseEvent('close', {
|
||||
code: clientPingTimeoutErrCode,
|
||||
wasClean: false,
|
||||
});
|
||||
|
||||
// Calling conn.close() will trigger the onclose callback,
|
||||
// but sometimes with a significant delay. So instead, we
|
||||
// call the onclose callback ourselves immediately. We also
|
||||
// unset the callback on the old connection to ensure it
|
||||
// is only called once.
|
||||
this.connectFailCount = 0;
|
||||
this.responseSequence = 1;
|
||||
this.conn.onclose = () => {};
|
||||
this.conn.close();
|
||||
onclose(closeEvent);
|
||||
},
|
||||
this.config.clientPingInterval);
|
||||
|
||||
this.connectFailCount = 0;
|
||||
};
|
||||
|
||||
this.conn.onerror = (evt) => {
|
||||
if (this.connectFailCount <= 1) {
|
||||
console.log('websocket error'); //eslint-disable-line no-console
|
||||
console.log(evt); //eslint-disable-line no-console
|
||||
}
|
||||
|
||||
this.errorCallback?.(evt);
|
||||
this.errorListeners.forEach((listener) => listener(evt));
|
||||
};
|
||||
|
||||
this.conn.onmessage = (evt) => {
|
||||
const msg = JSON.parse(evt.data);
|
||||
if (msg.seq_reply) {
|
||||
// This indicates a reply to a websocket request.
|
||||
// We ignore sequence number validation of message responses
|
||||
// and only focus on the purely server side event stream.
|
||||
if (msg.error) {
|
||||
console.log(msg); //eslint-disable-line no-console
|
||||
}
|
||||
|
||||
if (this.responseCallbacks[msg.seq_reply]) {
|
||||
this.responseCallbacks[msg.seq_reply](msg);
|
||||
Reflect.deleteProperty(this.responseCallbacks, msg.seq_reply);
|
||||
}
|
||||
} else if (this.eventCallback || this.messageListeners.size > 0) {
|
||||
// We check the hello packet, which is always the first packet in a stream.
|
||||
if (msg.event === WebSocketEvents.Hello && (this.missedEventCallback || this.missedMessageListeners.size > 0)) {
|
||||
console.log('got connection id ', msg.data.connection_id); //eslint-disable-line no-console
|
||||
// If we already have a connectionId present, and server sends a different one,
|
||||
// that means it's either a long timeout, or server restart, or sequence number is not found.
|
||||
// Then we do the sync calls, and reset sequence number to 0.
|
||||
if (this.connectionId !== '' && this.connectionId !== msg.data.connection_id) {
|
||||
console.log('long timeout, or server restart, or sequence number is not found.'); //eslint-disable-line no-console
|
||||
|
||||
this.missedEventCallback?.();
|
||||
|
||||
for (const listener of this.missedMessageListeners) {
|
||||
try {
|
||||
listener();
|
||||
} catch (e) {
|
||||
console.log(`missed message listener "${listener.name}" failed: ${e}`); // eslint-disable-line no-console
|
||||
}
|
||||
}
|
||||
|
||||
this.serverSequence = 0;
|
||||
}
|
||||
|
||||
// If it's a fresh connection, we have to set the connectionId regardless.
|
||||
// And if it's an existing connection, setting it again is harmless, and keeps the code simple.
|
||||
this.connectionId = msg.data.connection_id;
|
||||
|
||||
// Also update the server hostname
|
||||
this.serverHostname = msg.data.server_hostname;
|
||||
}
|
||||
|
||||
// Now we check for sequence number, and if it does not match,
|
||||
// we just disconnect and reconnect.
|
||||
if (msg.seq !== this.serverSequence) {
|
||||
console.log('missed websocket event, act_seq=' + msg.seq + ' exp_seq=' + this.serverSequence); //eslint-disable-line no-console
|
||||
|
||||
const closeEvent = new CloseEvent('close', {
|
||||
code: clientSequenceMismatchErrCode,
|
||||
wasClean: false,
|
||||
});
|
||||
|
||||
// Calling conn.close() will trigger the onclose callback,
|
||||
// but sometimes with a significant delay. So instead, we
|
||||
// call the onclose callback ourselves immediately. We also
|
||||
// unset the callback on the old connection to ensure it
|
||||
// is only called once.
|
||||
this.connectFailCount = 0;
|
||||
this.responseSequence = 1;
|
||||
if (this.conn) {
|
||||
this.conn.onclose = () => {};
|
||||
this.conn.close();
|
||||
onclose(closeEvent);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.serverSequence = msg.seq + 1;
|
||||
|
||||
this.eventCallback?.(msg);
|
||||
this.messageListeners.forEach((listener) => listener(msg));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use addMessageListener instead
|
||||
*/
|
||||
setEventCallback(callback: MessageListener) {
|
||||
this.eventCallback = callback;
|
||||
}
|
||||
|
||||
addMessageListener(listener: MessageListener) {
|
||||
this.messageListeners.add(listener);
|
||||
|
||||
if (this.messageListeners.size > 5) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`WebSocketClient has ${this.messageListeners.size} message listeners registered`);
|
||||
}
|
||||
}
|
||||
|
||||
removeMessageListener(listener: MessageListener) {
|
||||
this.messageListeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use addFirstConnectListener instead
|
||||
*/
|
||||
setFirstConnectCallback(callback: FirstConnectListener) {
|
||||
this.firstConnectCallback = callback;
|
||||
}
|
||||
|
||||
addFirstConnectListener(listener: FirstConnectListener) {
|
||||
this.firstConnectListeners.add(listener);
|
||||
|
||||
if (this.firstConnectListeners.size > 5) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`WebSocketClient has ${this.firstConnectListeners.size} first connect listeners registered`);
|
||||
}
|
||||
}
|
||||
|
||||
removeFirstConnectListener(listener: FirstConnectListener) {
|
||||
this.firstConnectListeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use addReconnectListener instead
|
||||
*/
|
||||
setReconnectCallback(callback: ReconnectListener) {
|
||||
this.reconnectCallback = callback;
|
||||
}
|
||||
|
||||
addReconnectListener(listener: ReconnectListener) {
|
||||
this.reconnectListeners.add(listener);
|
||||
|
||||
if (this.reconnectListeners.size > 5) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`WebSocketClient has ${this.reconnectListeners.size} reconnect listeners registered`);
|
||||
}
|
||||
}
|
||||
|
||||
removeReconnectListener(listener: ReconnectListener) {
|
||||
this.reconnectListeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use addMissedMessageListener instead
|
||||
*/
|
||||
setMissedEventCallback(callback: MissedMessageListener) {
|
||||
this.missedEventCallback = callback;
|
||||
}
|
||||
|
||||
addMissedMessageListener(listener: MissedMessageListener) {
|
||||
this.missedMessageListeners.add(listener);
|
||||
|
||||
if (this.missedMessageListeners.size > 5) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`WebSocketClient has ${this.missedMessageListeners.size} missed message listeners registered`);
|
||||
}
|
||||
}
|
||||
|
||||
removeMissedMessageListener(listener: MissedMessageListener) {
|
||||
this.missedMessageListeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use addErrorListener instead
|
||||
*/
|
||||
setErrorCallback(callback: ErrorListener) {
|
||||
this.errorCallback = callback;
|
||||
}
|
||||
|
||||
addErrorListener(listener: ErrorListener) {
|
||||
this.errorListeners.add(listener);
|
||||
|
||||
if (this.errorListeners.size > 5) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`WebSocketClient has ${this.errorListeners.size} error listeners registered`);
|
||||
}
|
||||
}
|
||||
|
||||
removeErrorListener(listener: ErrorListener) {
|
||||
this.errorListeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use addCloseListener instead
|
||||
*/
|
||||
setCloseCallback(callback: CloseListener) {
|
||||
this.closeCallback = callback;
|
||||
}
|
||||
|
||||
addCloseListener(listener: CloseListener) {
|
||||
this.closeListeners.add(listener);
|
||||
|
||||
if (this.closeListeners.size > 5) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`WebSocketClient has ${this.closeListeners.size} close listeners registered`);
|
||||
}
|
||||
}
|
||||
|
||||
removeCloseListener(listener: CloseListener) {
|
||||
this.closeListeners.delete(listener);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.connectFailCount = 0;
|
||||
this.responseSequence = 1;
|
||||
this.clearReconnectTimeout();
|
||||
this.lastErrCode = null;
|
||||
this.stopPingInterval();
|
||||
|
||||
if (this.conn && this.conn.readyState === WebSocket.OPEN) {
|
||||
this.conn.onclose = () => {};
|
||||
this.conn.close();
|
||||
this.conn = null;
|
||||
console.log('websocket closed manually'); //eslint-disable-line no-console
|
||||
}
|
||||
|
||||
if (this.onlineHandler) {
|
||||
globalThis.window?.removeEventListener('online', this.onlineHandler);
|
||||
this.onlineHandler = null;
|
||||
}
|
||||
if (this.offlineHandler) {
|
||||
globalThis.window?.removeEventListener('offline', this.offlineHandler);
|
||||
this.offlineHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
clearReconnectTimeout() {
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
this.reconnectTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
stopPingInterval() {
|
||||
if (this.pingInterval) {
|
||||
clearInterval(this.pingInterval);
|
||||
this.pingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
ping(responseCallback?: (msg: any) => void) {
|
||||
const msg = {
|
||||
action: 'ping',
|
||||
seq: this.responseSequence++,
|
||||
};
|
||||
|
||||
if (responseCallback) {
|
||||
this.responseCallbacks[msg.seq] = responseCallback;
|
||||
}
|
||||
|
||||
if (this.conn && this.conn.readyState === WebSocket.OPEN) {
|
||||
this.conn.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage(action: string, data: any, responseCallback?: (msg: any) => void) {
|
||||
const msg = {
|
||||
action,
|
||||
seq: this.responseSequence++,
|
||||
data,
|
||||
};
|
||||
|
||||
if (responseCallback) {
|
||||
this.responseCallbacks[msg.seq] = responseCallback;
|
||||
}
|
||||
|
||||
// Only try to send the message if the websocket is open.
|
||||
// If the websocket is closed here, we will drop the message.
|
||||
if (this.conn && this.conn.readyState === WebSocket.OPEN) {
|
||||
this.conn.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
userTyping(channelId: string, parentId: string, callback?: () => void) {
|
||||
const data = {
|
||||
channel_id: channelId,
|
||||
parent_id: parentId,
|
||||
};
|
||||
this.sendMessage('user_typing', data, callback);
|
||||
}
|
||||
|
||||
updateActiveChannel(channelId: string, callback?: (msg: any) => void) {
|
||||
const data = {
|
||||
channel_id: channelId,
|
||||
};
|
||||
this.sendMessage('presence', data, callback);
|
||||
}
|
||||
|
||||
updateActiveTeam(teamId: string, callback?: (msg: any) => void) {
|
||||
const data = {
|
||||
team_id: teamId,
|
||||
};
|
||||
this.sendMessage('presence', data, callback);
|
||||
}
|
||||
|
||||
updateActiveThread(isThreadView: boolean, channelId: string, callback?: (msg: any) => void) {
|
||||
const data = {
|
||||
thread_channel_id: channelId,
|
||||
is_thread_view: isThreadView,
|
||||
};
|
||||
this.sendMessage('presence', data, callback);
|
||||
}
|
||||
|
||||
userUpdateActiveStatus(userIsActive: boolean, manual: boolean, callback?: () => void) {
|
||||
const data = {
|
||||
user_is_active: userIsActive,
|
||||
manual,
|
||||
};
|
||||
this.sendMessage('user_update_active_status', data, callback);
|
||||
}
|
||||
|
||||
acknowledgePostedNotification(postId: string, status: string, reason?: string, postedData?: string) {
|
||||
const data = {
|
||||
post_id: postId,
|
||||
user_agent: globalThis.window?.navigator?.userAgent ?? '',
|
||||
status,
|
||||
reason,
|
||||
data: postedData,
|
||||
};
|
||||
|
||||
this.sendMessage('posted_notify_ack', data);
|
||||
}
|
||||
|
||||
getStatuses(callback?: () => void) {
|
||||
this.sendMessage('get_statuses', null, callback);
|
||||
}
|
||||
|
||||
getStatusesByIds(userIds: string[], callback?: () => void) {
|
||||
const data = {
|
||||
user_ids: userIds,
|
||||
};
|
||||
this.sendMessage('get_statuses_by_ids', data, callback);
|
||||
}
|
||||
}
|
||||
108
webapp/platform/client/src/websocket_events.ts
Normal file
108
webapp/platform/client/src/websocket_events.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export const enum WebSocketEvents {
|
||||
Typing = 'typing',
|
||||
Posted = 'posted',
|
||||
PostEdited = 'post_edited',
|
||||
PostDeleted = 'post_deleted',
|
||||
PostUnread = 'post_unread',
|
||||
BurnOnReadPostRevealed = 'post_revealed',
|
||||
BurnOnReadPostBurned = 'post_burned',
|
||||
BurnOnReadPostAllRevealed = 'burn_on_read_all_revealed',
|
||||
ChannelConverted = 'channel_converted',
|
||||
ChannelCreated = 'channel_created',
|
||||
ChannelDeleted = 'channel_deleted',
|
||||
ChannelRestored = 'channel_restored',
|
||||
ChannelUpdated = 'channel_updated',
|
||||
ChannelMemberUpdated = 'channel_member_updated',
|
||||
ChannelSchemeUpdated = 'channel_scheme_updated',
|
||||
DirectAdded = 'direct_added',
|
||||
GroupAdded = 'group_added',
|
||||
NewUser = 'new_user',
|
||||
AddedToTeam = 'added_to_team',
|
||||
LeaveTeam = 'leave_team',
|
||||
UpdateTeam = 'update_team',
|
||||
DeleteTeam = 'delete_team',
|
||||
RestoreTeam = 'restore_team', // This isn't currently used by the web app
|
||||
UpdateTeamScheme = 'update_team_scheme',
|
||||
UserAdded = 'user_added',
|
||||
UserUpdated = 'user_updated',
|
||||
UserRoleUpdated = 'user_role_updated',
|
||||
MemberRoleUpdated = 'memberrole_updated',
|
||||
UserRemoved = 'user_removed',
|
||||
PreferenceChanged = 'preference_changed',
|
||||
PreferencesChanged = 'preferences_changed',
|
||||
PreferencesDeleted = 'preferences_deleted',
|
||||
EphemeralMessage = 'ephemeral_message',
|
||||
StatusChange = 'status_change',
|
||||
Hello = 'hello',
|
||||
AuthenticationChallenge = 'authentication_challenge', // This isn't currently used by the web app, and it's a message that would be sent from a client to the server
|
||||
ReactionAdded = 'reaction_added',
|
||||
ReactionRemoved = 'reaction_removed',
|
||||
Response = 'response',
|
||||
EmojiAdded = 'emoji_added',
|
||||
MultipleChannelsViewed = 'multiple_channels_viewed',
|
||||
PluginStatusesChanged = 'plugin_statuses_changed',
|
||||
PluginEnabled = 'plugin_enabled',
|
||||
PluginDisabled = 'plugin_disabled',
|
||||
RoleUpdated = 'role_updated',
|
||||
LicenseChanged = 'license_changed',
|
||||
ConfigChanged = 'config_changed',
|
||||
OpenDialog = 'open_dialog',
|
||||
GuestsDeactivated = 'guests_deactivated', // This isn't currently used by the web app
|
||||
UserActivationStatusChange = 'user_activation_status_change',
|
||||
ReceivedGroup = 'received_group',
|
||||
ReceivedGroupAssociatedToTeam = 'received_group_associated_to_team',
|
||||
ReceivedGroupNotAssociatedToTeam = 'received_group_not_associated_to_team',
|
||||
ReceivedGroupAssociatedToChannel = 'received_group_associated_to_channel',
|
||||
ReceivedGroupNotAssociatedToChannel = 'received_group_not_associated_to_channel',
|
||||
GroupMemberDeleted = 'group_member_deleted',
|
||||
GroupMemberAdded = 'group_member_add',
|
||||
SidebarCategoryCreated = 'sidebar_category_created',
|
||||
SidebarCategoryUpdated = 'sidebar_category_updated',
|
||||
SidebarCategoryDeleted = 'sidebar_category_deleted',
|
||||
SidebarCategoryOrderUpdated = 'sidebar_category_order_updated',
|
||||
PropertyFieldCreated = 'property_field_created',
|
||||
PropertyFieldUpdated = 'property_field_updated',
|
||||
PropertyFieldDeleted = 'property_field_deleted',
|
||||
PropertyValuesUpdated = 'property_values_updated',
|
||||
CloudSubscriptionChanged = 'cloud_subscription_changed',
|
||||
ThreadUpdated = 'thread_updated',
|
||||
ThreadFollowChanged = 'thread_follow_changed',
|
||||
ThreadReadChanged = 'thread_read_changed',
|
||||
FirstAdminVisitMarketplaceStatusReceived = 'first_admin_visit_marketplace_status_received',
|
||||
DraftCreated = 'draft_created',
|
||||
DraftUpdated = 'draft_updated',
|
||||
DraftDeleted = 'draft_deleted',
|
||||
PostAcknowledgementAdded = 'post_acknowledgement_added',
|
||||
PostAcknowledgementRemoved = 'post_acknowledgement_removed',
|
||||
PersistentNotificationTriggered = 'persistent_notification_triggered',
|
||||
HostedCustomerSignupProgressUpdated = 'hosted_customer_signup_progress_updated',
|
||||
ChannelBookmarkCreated = 'channel_bookmark_created',
|
||||
ChannelBookmarkUpdated = 'channel_bookmark_updated',
|
||||
ChannelBookmarkDeleted = 'channel_bookmark_deleted',
|
||||
ChannelBookmarkSorted = 'channel_bookmark_sorted',
|
||||
ChannelAccessControlUpdated = 'channel_access_control_updated',
|
||||
PermissionPolicyUpdated = 'permission_policy_updated',
|
||||
TeamAccessControlUpdated = 'team_access_control_updated',
|
||||
PresenceIndicator = 'presence',
|
||||
PostedNotifyAck = 'posted_notify_ack', // This isn't currently used by the web app
|
||||
ScheduledPostCreated = 'scheduled_post_created',
|
||||
ScheduledPostUpdated = 'scheduled_post_updated',
|
||||
ScheduledPostDeleted = 'scheduled_post_deleted',
|
||||
CPAFieldCreated = 'custom_profile_attributes_field_created',
|
||||
CPAFieldUpdated = 'custom_profile_attributes_field_updated',
|
||||
CPAFieldDeleted = 'custom_profile_attributes_field_deleted',
|
||||
CPAValuesUpdated = 'custom_profile_attributes_values_updated',
|
||||
ContentFlaggingReportValueUpdated = 'content_flagging_report_value_updated',
|
||||
JobUpdated = 'job_updated',
|
||||
RecapUpdated = 'recap_updated',
|
||||
PostTranslationUpdated = 'post_translation_updated',
|
||||
FileDownloadRejected = 'file_download_rejected',
|
||||
FileUploadRejected = 'file_upload_rejected',
|
||||
ShowToast = 'show_toast',
|
||||
SharedChannelRemoteUpdated = 'shared_channel_remote_updated',
|
||||
ChannelJoinRequestCreated = 'channel_join_request_created',
|
||||
ChannelJoinRequestUpdated = 'channel_join_request_updated',
|
||||
}
|
||||
135
webapp/platform/client/src/websocket_message.ts
Normal file
135
webapp/platform/client/src/websocket_message.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {WebSocketEvents} from './websocket_events';
|
||||
import type * as Messages from './websocket_messages';
|
||||
|
||||
export type WebSocketMessage = (
|
||||
Messages.Hello |
|
||||
Messages.AuthenticationChallenge |
|
||||
Messages.Response |
|
||||
|
||||
Messages.Posted |
|
||||
Messages.PostEdited |
|
||||
Messages.PostDeleted |
|
||||
Messages.PostUnread |
|
||||
Messages.BurnOnReadPostRevealed |
|
||||
Messages.BurnOnReadPostBurned |
|
||||
Messages.BurnOnReadPostAllRevealed |
|
||||
Messages.EphemeralPost |
|
||||
Messages.PostReaction |
|
||||
Messages.PostAcknowledgement |
|
||||
Messages.PostDraft |
|
||||
Messages.PersistentNotificationTriggered |
|
||||
Messages.ScheduledPost |
|
||||
Messages.PostTranslationUpdated |
|
||||
|
||||
Messages.ThreadUpdated |
|
||||
Messages.ThreadFollowedChanged |
|
||||
Messages.ThreadReadChanged |
|
||||
|
||||
Messages.ChannelCreated |
|
||||
Messages.ChannelUpdated |
|
||||
Messages.ChannelConverted |
|
||||
Messages.SharedChannelRemoteUpdated |
|
||||
Messages.ChannelSchemeUpdated |
|
||||
Messages.ChannelDeleted |
|
||||
Messages.ChannelRestored |
|
||||
Messages.DirectChannelCreated |
|
||||
Messages.GroupChannelCreated |
|
||||
Messages.UserAddedToChannel |
|
||||
Messages.UserRemovedFromChannel |
|
||||
Messages.ChannelMemberUpdated |
|
||||
Messages.MultipleChannelsViewed |
|
||||
|
||||
Messages.ChannelBookmarkCreated |
|
||||
Messages.ChannelBookmarkUpdated |
|
||||
Messages.ChannelBookmarkDeleted |
|
||||
Messages.ChannelBookmarkSorted |
|
||||
|
||||
Messages.ChannelAccessControlUpdated |
|
||||
Messages.PermissionPolicyUpdated |
|
||||
Messages.TeamAccessControlUpdated |
|
||||
|
||||
Messages.ChannelJoinRequestCreated |
|
||||
Messages.ChannelJoinRequestUpdated |
|
||||
|
||||
Messages.Team |
|
||||
Messages.UpdateTeamScheme |
|
||||
Messages.UserAddedToTeam |
|
||||
Messages.UserRemovedFromTeam |
|
||||
Messages.TeamMemberRoleUpdated |
|
||||
|
||||
Messages.NewUser |
|
||||
Messages.UserUpdated |
|
||||
Messages.UserActivationStatusChanged |
|
||||
Messages.UserRoleUpdated |
|
||||
Messages.StatusChanged |
|
||||
Messages.Typing |
|
||||
|
||||
Messages.ReceivedGroup |
|
||||
Messages.GroupAssociatedToTeam |
|
||||
Messages.GroupAssociatedToChannel |
|
||||
Messages.GroupMember |
|
||||
|
||||
Messages.PreferenceChanged |
|
||||
Messages.PreferencesChanged |
|
||||
|
||||
Messages.SidebarCategoryCreated |
|
||||
Messages.SidebarCategoryUpdated |
|
||||
Messages.SidebarCategoryDeleted |
|
||||
Messages.SidebarCategoryOrderUpdated |
|
||||
Messages.PropertyFieldCreated |
|
||||
Messages.PropertyFieldUpdated |
|
||||
Messages.PropertyFieldDeleted |
|
||||
Messages.PropertyValuesUpdated |
|
||||
|
||||
Messages.EmojiAdded |
|
||||
|
||||
Messages.RoleUpdated |
|
||||
|
||||
Messages.ConfigChanged |
|
||||
Messages.GuestsDeactivated |
|
||||
Messages.LicenseChanged |
|
||||
Messages.CloudSubscriptionChanged |
|
||||
Messages.FirstAdminVisitMarketplaceStatusReceived |
|
||||
Messages.HostedCustomerSignupProgressUpdated |
|
||||
|
||||
Messages.CPAFieldCreated |
|
||||
Messages.CPAFieldUpdated |
|
||||
Messages.CPAFieldDeleted |
|
||||
Messages.CPAValuesUpdated |
|
||||
|
||||
Messages.ContentFlaggingReportValueUpdated |
|
||||
|
||||
Messages.JobUpdated |
|
||||
Messages.RecapUpdated |
|
||||
|
||||
Messages.FileDownloadRejected |
|
||||
Messages.FileUploadRejected |
|
||||
Messages.ShowToast |
|
||||
|
||||
Messages.Plugin |
|
||||
Messages.PluginStatusesChanged |
|
||||
Messages.OpenDialog |
|
||||
|
||||
BaseWebSocketMessage<WebSocketEvents.PresenceIndicator, unknown> |
|
||||
BaseWebSocketMessage<WebSocketEvents.PostedNotifyAck, unknown>
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export type JsonEncodedValue<T> = string;
|
||||
|
||||
export type BaseWebSocketMessage<Event, T = Record<string, never>> = {
|
||||
event: Event;
|
||||
data: T;
|
||||
broadcast: WebSocketBroadcast;
|
||||
seq: number;
|
||||
};
|
||||
|
||||
export type WebSocketBroadcast = {
|
||||
omit_users: Record<string, boolean>;
|
||||
user_id: string;
|
||||
channel_id: string;
|
||||
team_id: string;
|
||||
};
|
||||
557
webapp/platform/client/src/websocket_messages.ts
Normal file
557
webapp/platform/client/src/websocket_messages.ts
Normal file
@@ -0,0 +1,557 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {ChannelBookmarkWithFileInfo, UpdateChannelBookmarkResponse} from '@mattermost/types/channel_bookmarks';
|
||||
import type {ChannelCategory} from '@mattermost/types/channel_categories';
|
||||
import type {Channel, ChannelJoinRequest, ChannelMembership, ChannelType} from '@mattermost/types/channels';
|
||||
import type {Limits, Subscription} from '@mattermost/types/cloud';
|
||||
import type {ClientConfig, ClientLicense} from '@mattermost/types/config';
|
||||
import type {Draft} from '@mattermost/types/drafts';
|
||||
import type {CustomEmoji} from '@mattermost/types/emojis';
|
||||
import type {Group, GroupMember as GroupMemberType} from '@mattermost/types/groups';
|
||||
import type {OpenDialogRequest} from '@mattermost/types/integrations';
|
||||
import type {Job} from '@mattermost/types/jobs';
|
||||
import type {PluginManifest} from '@mattermost/types/plugins';
|
||||
import type {Post, PostAcknowledgement as PostAcknowledgementType} from '@mattermost/types/posts';
|
||||
import type {PreferenceType} from '@mattermost/types/preferences';
|
||||
import type {PropertyField, PropertyValue} from '@mattermost/types/properties';
|
||||
import type {Reaction} from '@mattermost/types/reactions';
|
||||
import type {Role} from '@mattermost/types/roles';
|
||||
import type {ScheduledPost as ScheduledPostType} from '@mattermost/types/schedule_post';
|
||||
import type {Team as TeamType, TeamMembership} from '@mattermost/types/teams';
|
||||
import type {UserThread} from '@mattermost/types/threads';
|
||||
import type {UserProfile, UserStatus} from '@mattermost/types/users';
|
||||
|
||||
import type {WebSocketEvents} from './websocket_events';
|
||||
import type {BaseWebSocketMessage, JsonEncodedValue} from './websocket_message';
|
||||
|
||||
// WebSocket-related messages
|
||||
|
||||
export type Hello = BaseWebSocketMessage<WebSocketEvents.Hello, {
|
||||
server_version: string;
|
||||
connection_id: string;
|
||||
server_hostname?: string;
|
||||
}>;
|
||||
|
||||
export type AuthenticationChallenge = BaseWebSocketMessage<WebSocketEvents.AuthenticationChallenge, unknown>;
|
||||
|
||||
export type Response = BaseWebSocketMessage<WebSocketEvents.Response>;
|
||||
|
||||
// Post, reactions, and acknowledgement messages
|
||||
|
||||
export type Posted = BaseWebSocketMessage<WebSocketEvents.Posted, {
|
||||
channel_type: ChannelType;
|
||||
channel_display_name: string;
|
||||
channel_name: string;
|
||||
sender_name: string;
|
||||
team_id: string;
|
||||
set_online: boolean;
|
||||
otherFile?: boolean;
|
||||
image?: boolean;
|
||||
post: JsonEncodedValue<Post>;
|
||||
|
||||
/**
|
||||
* If the current user is mentioned by this post, this field will contain the ID of that user. Otherwise,
|
||||
* it will be empty.
|
||||
*/
|
||||
mentions?: JsonEncodedValue<string[]>;
|
||||
|
||||
/**
|
||||
* If the current user is following this post, this field will contain the ID of that user. Otherwise,
|
||||
* it will be empty.
|
||||
*/
|
||||
followers?: JsonEncodedValue<string[]>;
|
||||
|
||||
should_ack?: boolean;
|
||||
}>;
|
||||
|
||||
export type PostEdited = BaseWebSocketMessage<WebSocketEvents.PostEdited, {
|
||||
post: JsonEncodedValue<Post>;
|
||||
}>;
|
||||
|
||||
export type PostDeleted = BaseWebSocketMessage<WebSocketEvents.PostDeleted, {
|
||||
post: JsonEncodedValue<Post>;
|
||||
|
||||
/** The user ID of the user who deleted the post, only sent to admin users. */
|
||||
delete_by?: string;
|
||||
}>;
|
||||
|
||||
export type PostUnread = BaseWebSocketMessage<WebSocketEvents.PostUnread, {
|
||||
msg_count: number;
|
||||
msg_count_root: number;
|
||||
mention_count: number;
|
||||
mention_count_root: number;
|
||||
urgent_mention_count: number;
|
||||
last_viewed_at: number;
|
||||
post_id: string;
|
||||
}>;
|
||||
|
||||
export type BurnOnReadPostRevealed = BaseWebSocketMessage<WebSocketEvents.BurnOnReadPostRevealed, {
|
||||
post?: string | Post;
|
||||
recipients?: string[];
|
||||
}>;
|
||||
|
||||
export type BurnOnReadPostBurned = BaseWebSocketMessage<WebSocketEvents.BurnOnReadPostBurned, {
|
||||
post_id: string;
|
||||
}>;
|
||||
|
||||
export type BurnOnReadPostAllRevealed = BaseWebSocketMessage<WebSocketEvents.BurnOnReadPostAllRevealed, {
|
||||
post_id: string;
|
||||
sender_expire_at: number;
|
||||
}>;
|
||||
|
||||
export type EphemeralPost = BaseWebSocketMessage<WebSocketEvents.EphemeralMessage, {
|
||||
post: JsonEncodedValue<Post>;
|
||||
}>;
|
||||
|
||||
export type PostReaction =
|
||||
BaseWebSocketMessage<WebSocketEvents.ReactionAdded | WebSocketEvents.ReactionRemoved, {
|
||||
reaction: JsonEncodedValue<Reaction>;
|
||||
}>;
|
||||
|
||||
export type PostAcknowledgement =
|
||||
BaseWebSocketMessage<WebSocketEvents.PostAcknowledgementAdded | WebSocketEvents.PostAcknowledgementRemoved, {
|
||||
acknowledgement: JsonEncodedValue<PostAcknowledgementType>;
|
||||
}>;
|
||||
|
||||
export type PostDraft =
|
||||
BaseWebSocketMessage<WebSocketEvents.DraftCreated | WebSocketEvents.DraftUpdated | WebSocketEvents.DraftDeleted, {
|
||||
draft: JsonEncodedValue<Draft>;
|
||||
}>;
|
||||
|
||||
export type PersistentNotificationTriggered =
|
||||
BaseWebSocketMessage<WebSocketEvents.PersistentNotificationTriggered, {
|
||||
post: JsonEncodedValue<Post>;
|
||||
channel_type: ChannelType;
|
||||
channel_display_name: string;
|
||||
channel_name: string;
|
||||
sender_name: string;
|
||||
team_id: string;
|
||||
otherFile?: boolean;
|
||||
image?: boolean;
|
||||
mentions?: JsonEncodedValue<string[]>;
|
||||
}>;
|
||||
|
||||
export type ScheduledPost =
|
||||
BaseWebSocketMessage<WebSocketEvents.ScheduledPostCreated | WebSocketEvents.ScheduledPostUpdated | WebSocketEvents.ScheduledPostDeleted, {
|
||||
scheduledPost: JsonEncodedValue<ScheduledPostType>;
|
||||
}>;
|
||||
|
||||
// Thread messages
|
||||
|
||||
export type ThreadUpdated = BaseWebSocketMessage<WebSocketEvents.ThreadUpdated, {
|
||||
thread: JsonEncodedValue<UserThread>;
|
||||
|
||||
previous_unread_mentions?: number;
|
||||
previous_unread_replies?: number;
|
||||
}>;
|
||||
|
||||
export type ThreadFollowedChanged = BaseWebSocketMessage<WebSocketEvents.ThreadFollowChanged, {
|
||||
thread_id: string;
|
||||
state: boolean;
|
||||
reply_count: number;
|
||||
}>;
|
||||
|
||||
export type ThreadReadChanged = BaseWebSocketMessage<WebSocketEvents.ThreadReadChanged, (
|
||||
|
||||
// App.UpdateThreadsReadForUser
|
||||
Record<string, never>
|
||||
) | (
|
||||
|
||||
// App.MarkChannelsAsViewed
|
||||
{
|
||||
timestamp: number;
|
||||
}
|
||||
) | (
|
||||
|
||||
// App.UpdateThreadReadForUser
|
||||
{
|
||||
thread_id: string;
|
||||
timestamp: number;
|
||||
unread_mentions: number;
|
||||
unread_replies: number;
|
||||
previous_unread_mentions: number;
|
||||
previous_unread_replies: number;
|
||||
channel_id: string;
|
||||
}
|
||||
)>;
|
||||
|
||||
// Channel and channel member messages
|
||||
|
||||
export type ChannelCreated = BaseWebSocketMessage<WebSocketEvents.ChannelCreated, {
|
||||
channel_id: string;
|
||||
team_id: string;
|
||||
}>;
|
||||
|
||||
export type ChannelUpdated = BaseWebSocketMessage<WebSocketEvents.ChannelUpdated, {
|
||||
|
||||
// Normally, the channel field is sent, except in some cases where a shared channel is updated in which case
|
||||
// the channel_id field is used.
|
||||
|
||||
channel?: JsonEncodedValue<Channel>;
|
||||
channel_id?: string;
|
||||
}>;
|
||||
|
||||
export type ChannelConverted = BaseWebSocketMessage<WebSocketEvents.ChannelConverted, {
|
||||
channel_id: string;
|
||||
channel_type?: ChannelType;
|
||||
}>;
|
||||
|
||||
export type SharedChannelRemoteUpdated = BaseWebSocketMessage<WebSocketEvents.SharedChannelRemoteUpdated, {
|
||||
channel_id: string;
|
||||
}>;
|
||||
|
||||
export type ChannelSchemeUpdated = BaseWebSocketMessage<WebSocketEvents.ChannelSchemeUpdated>;
|
||||
|
||||
export type ChannelDeleted = BaseWebSocketMessage<WebSocketEvents.ChannelDeleted, {
|
||||
channel_id: string;
|
||||
delete_at: number;
|
||||
}>;
|
||||
|
||||
export type ChannelRestored = BaseWebSocketMessage<WebSocketEvents.ChannelRestored, {
|
||||
channel_id: string;
|
||||
}>;
|
||||
|
||||
export type DirectChannelCreated = BaseWebSocketMessage<WebSocketEvents.DirectAdded, {
|
||||
creator_id: string;
|
||||
teammate_id: string;
|
||||
}>;
|
||||
|
||||
export type GroupChannelCreated = BaseWebSocketMessage<WebSocketEvents.GroupAdded, {
|
||||
teammate_ids: JsonEncodedValue<string[]>;
|
||||
}>;
|
||||
|
||||
export type UserAddedToChannel = BaseWebSocketMessage<WebSocketEvents.UserAdded, {
|
||||
user_id: string;
|
||||
team_id: string;
|
||||
}>;
|
||||
|
||||
export type UserRemovedFromChannel = BaseWebSocketMessage<WebSocketEvents.UserRemoved, {
|
||||
|
||||
/** The user ID of the user that was removed from the channel. It isn't sent to the user who was removed. */
|
||||
user_id?: string;
|
||||
|
||||
/** The ID of the channel that the user was removed from. It's only sent to the user who was removed. */
|
||||
channel_id?: string;
|
||||
|
||||
remover_id: string;
|
||||
}>;
|
||||
|
||||
export type ChannelMemberUpdated = BaseWebSocketMessage<WebSocketEvents.ChannelMemberUpdated, {
|
||||
channelMember: JsonEncodedValue<ChannelMembership>;
|
||||
}>;
|
||||
|
||||
export type MultipleChannelsViewed = BaseWebSocketMessage<WebSocketEvents.MultipleChannelsViewed, {
|
||||
channel_times: Record<string, number>;
|
||||
}>;
|
||||
|
||||
// Channel bookmark messages
|
||||
|
||||
export type ChannelBookmarkCreated = BaseWebSocketMessage<WebSocketEvents.ChannelBookmarkCreated, {
|
||||
bookmark: JsonEncodedValue<ChannelBookmarkWithFileInfo>;
|
||||
}>;
|
||||
|
||||
export type ChannelBookmarkUpdated = BaseWebSocketMessage<WebSocketEvents.ChannelBookmarkUpdated, {
|
||||
bookmarks: JsonEncodedValue<UpdateChannelBookmarkResponse>;
|
||||
}>;
|
||||
|
||||
export type ChannelBookmarkDeleted = BaseWebSocketMessage<WebSocketEvents.ChannelBookmarkDeleted, {
|
||||
bookmark: JsonEncodedValue<ChannelBookmarkWithFileInfo>;
|
||||
}>;
|
||||
|
||||
export type ChannelBookmarkSorted = BaseWebSocketMessage<WebSocketEvents.ChannelBookmarkSorted, {
|
||||
bookmarks: JsonEncodedValue<ChannelBookmarkWithFileInfo[]>;
|
||||
}>;
|
||||
|
||||
// Channel access control messages
|
||||
|
||||
export type ChannelAccessControlUpdated = BaseWebSocketMessage<WebSocketEvents.ChannelAccessControlUpdated, {
|
||||
channel: JsonEncodedValue<Channel>;
|
||||
}>;
|
||||
|
||||
// Emitted after a system-scoped (TypePermission) permission policy is created, updated,
|
||||
// deleted, or toggled active. No payload — the policy is global so the client reconciles
|
||||
// the currently visible channel only.
|
||||
export type PermissionPolicyUpdated = BaseWebSocketMessage<WebSocketEvents.PermissionPolicyUpdated, Record<string, never>>;
|
||||
|
||||
export type TeamAccessControlUpdated = BaseWebSocketMessage<WebSocketEvents.TeamAccessControlUpdated, {
|
||||
team: JsonEncodedValue<TeamType>;
|
||||
}>;
|
||||
|
||||
// Discoverable Private Channels — join request messages
|
||||
//
|
||||
// Both events carry the request row as a JSON-encoded string under `request`,
|
||||
// plus the unencoded channel id under `channel_id` so consumers can route
|
||||
// without parsing the body when they only need to invalidate caches.
|
||||
|
||||
export type ChannelJoinRequestCreated = BaseWebSocketMessage<WebSocketEvents.ChannelJoinRequestCreated, {
|
||||
request: JsonEncodedValue<ChannelJoinRequest>;
|
||||
channel_id: string;
|
||||
}>;
|
||||
|
||||
export type ChannelJoinRequestUpdated = BaseWebSocketMessage<WebSocketEvents.ChannelJoinRequestUpdated, {
|
||||
request: JsonEncodedValue<ChannelJoinRequest>;
|
||||
channel_id: string;
|
||||
}>;
|
||||
|
||||
// Team and team member messages
|
||||
|
||||
export type Team =
|
||||
BaseWebSocketMessage<WebSocketEvents.UpdateTeam | WebSocketEvents.DeleteTeam | WebSocketEvents.RestoreTeam, {
|
||||
team: JsonEncodedValue<TeamType>;
|
||||
}>;
|
||||
|
||||
export type UserAddedToTeam = BaseWebSocketMessage<WebSocketEvents.AddedToTeam, {
|
||||
team_id: string;
|
||||
user_id: string;
|
||||
}>;
|
||||
|
||||
export type UserRemovedFromTeam = BaseWebSocketMessage<WebSocketEvents.LeaveTeam, {
|
||||
user_id: string;
|
||||
team_id: string;
|
||||
}>;
|
||||
|
||||
export type UpdateTeamScheme = BaseWebSocketMessage<WebSocketEvents.UpdateTeamScheme, {
|
||||
team: JsonEncodedValue<Team>;
|
||||
}>;
|
||||
|
||||
export type TeamMemberRoleUpdated = BaseWebSocketMessage<WebSocketEvents.MemberRoleUpdated, {
|
||||
member: JsonEncodedValue<TeamMembership>;
|
||||
}>;
|
||||
|
||||
// User and status messages
|
||||
|
||||
export type NewUser = BaseWebSocketMessage<WebSocketEvents.NewUser, {
|
||||
user_id: string;
|
||||
}>;
|
||||
|
||||
export type UserUpdated = BaseWebSocketMessage<WebSocketEvents.UserUpdated, {
|
||||
|
||||
/** This user may be missing sensitive data based on if the recipient is that user or an admin. */
|
||||
user: UserProfile;
|
||||
}>;
|
||||
|
||||
export type UserActivationStatusChanged = BaseWebSocketMessage<WebSocketEvents.UserActivationStatusChange>;
|
||||
|
||||
export type UserRoleUpdated = BaseWebSocketMessage<WebSocketEvents.UserRoleUpdated, {
|
||||
user_id: string;
|
||||
roles: string;
|
||||
}>;
|
||||
|
||||
export type StatusChanged = BaseWebSocketMessage<WebSocketEvents.StatusChange, {
|
||||
status: UserStatus['status'];
|
||||
user_id: string;
|
||||
}>;
|
||||
|
||||
export type Typing = BaseWebSocketMessage<WebSocketEvents.Typing, {
|
||||
parent_id: string;
|
||||
user_id: string;
|
||||
}>;
|
||||
|
||||
// Group-related messages
|
||||
|
||||
export type ReceivedGroup = BaseWebSocketMessage<WebSocketEvents.ReceivedGroup, {
|
||||
group: JsonEncodedValue<Group>;
|
||||
}>;
|
||||
|
||||
export type GroupAssociatedToTeam =
|
||||
BaseWebSocketMessage<WebSocketEvents.ReceivedGroupAssociatedToTeam | WebSocketEvents.ReceivedGroupNotAssociatedToTeam, {
|
||||
group_id: string;
|
||||
}>;
|
||||
|
||||
export type GroupAssociatedToChannel =
|
||||
BaseWebSocketMessage<WebSocketEvents.ReceivedGroupAssociatedToChannel | WebSocketEvents.ReceivedGroupNotAssociatedToChannel, {
|
||||
group_id: string;
|
||||
}>;
|
||||
|
||||
export type GroupMember =
|
||||
BaseWebSocketMessage<WebSocketEvents.GroupMemberAdded | WebSocketEvents.GroupMemberDeleted, {
|
||||
group_member: JsonEncodedValue<GroupMemberType>;
|
||||
}>;
|
||||
|
||||
// Preference messages
|
||||
|
||||
export type PreferenceChanged = BaseWebSocketMessage<WebSocketEvents.PreferenceChanged, {
|
||||
preference: JsonEncodedValue<PreferenceType>;
|
||||
}>;
|
||||
|
||||
export type PreferencesChanged =
|
||||
BaseWebSocketMessage<WebSocketEvents.PreferencesChanged | WebSocketEvents.PreferencesDeleted, {
|
||||
preferences: JsonEncodedValue<PreferenceType[]>;
|
||||
}>;
|
||||
|
||||
// Channel sidebar messages
|
||||
|
||||
export type SidebarCategoryCreated = BaseWebSocketMessage<WebSocketEvents.SidebarCategoryCreated, {
|
||||
category_id: string;
|
||||
}>;
|
||||
|
||||
export type SidebarCategoryUpdated = BaseWebSocketMessage<WebSocketEvents.SidebarCategoryUpdated, {
|
||||
updatedCategories: JsonEncodedValue<ChannelCategory[]>;
|
||||
}>;
|
||||
|
||||
export type SidebarCategoryDeleted = BaseWebSocketMessage<WebSocketEvents.SidebarCategoryDeleted, {
|
||||
category_id: string;
|
||||
}>;
|
||||
|
||||
export type SidebarCategoryOrderUpdated = BaseWebSocketMessage<WebSocketEvents.SidebarCategoryOrderUpdated, {
|
||||
order: string[];
|
||||
}>;
|
||||
|
||||
// Property system messages
|
||||
|
||||
export type PropertyFieldCreated = BaseWebSocketMessage<WebSocketEvents.PropertyFieldCreated, {
|
||||
property_field: JsonEncodedValue<PropertyField>;
|
||||
object_type: string;
|
||||
}>;
|
||||
|
||||
export type PropertyFieldUpdated = BaseWebSocketMessage<WebSocketEvents.PropertyFieldUpdated, {
|
||||
property_field: JsonEncodedValue<PropertyField>;
|
||||
object_type: string;
|
||||
}>;
|
||||
|
||||
export type PropertyFieldDeleted = BaseWebSocketMessage<WebSocketEvents.PropertyFieldDeleted, {
|
||||
field_id: string;
|
||||
object_type: string;
|
||||
}>;
|
||||
|
||||
export type PropertyValuesUpdated = BaseWebSocketMessage<WebSocketEvents.PropertyValuesUpdated, {
|
||||
object_type?: string;
|
||||
target_id?: string;
|
||||
field_id?: string;
|
||||
values: JsonEncodedValue<Array<PropertyValue<unknown>>>;
|
||||
}>;
|
||||
|
||||
// Emoji messages
|
||||
|
||||
export type EmojiAdded = BaseWebSocketMessage<WebSocketEvents.EmojiAdded, {
|
||||
emoji: JsonEncodedValue<Omit<CustomEmoji, 'category'>>;
|
||||
}>;
|
||||
|
||||
// Role messages
|
||||
|
||||
export type RoleUpdated = BaseWebSocketMessage<WebSocketEvents.RoleUpdated, {
|
||||
role: JsonEncodedValue<Role>;
|
||||
}>;
|
||||
|
||||
// Configuration and license messages
|
||||
|
||||
export type ConfigChanged = BaseWebSocketMessage<WebSocketEvents.ConfigChanged, {
|
||||
config: ClientConfig;
|
||||
}>;
|
||||
|
||||
export type GuestsDeactivated = BaseWebSocketMessage<WebSocketEvents.GuestsDeactivated>;
|
||||
|
||||
export type LicenseChanged = BaseWebSocketMessage<WebSocketEvents.LicenseChanged, {
|
||||
license: ClientLicense;
|
||||
}>;
|
||||
|
||||
export type CloudSubscriptionChanged = BaseWebSocketMessage<WebSocketEvents.CloudSubscriptionChanged, {
|
||||
limits?: Limits;
|
||||
subscription: Subscription;
|
||||
}>;
|
||||
|
||||
export type FirstAdminVisitMarketplaceStatusReceived =
|
||||
BaseWebSocketMessage<WebSocketEvents.FirstAdminVisitMarketplaceStatusReceived, {
|
||||
firstAdminVisitMarketplaceStatus: JsonEncodedValue<boolean>;
|
||||
}>;
|
||||
|
||||
export type HostedCustomerSignupProgressUpdated =
|
||||
BaseWebSocketMessage<WebSocketEvents.HostedCustomerSignupProgressUpdated, {
|
||||
progress: string;
|
||||
}>;
|
||||
|
||||
// Custom properties messages
|
||||
|
||||
export type CPAFieldCreated = BaseWebSocketMessage<WebSocketEvents.CPAFieldCreated, {
|
||||
field: PropertyField;
|
||||
}>;
|
||||
|
||||
export type CPAFieldUpdated = BaseWebSocketMessage<WebSocketEvents.CPAFieldUpdated, {
|
||||
field: PropertyField;
|
||||
delete_values: boolean;
|
||||
}>;
|
||||
|
||||
export type CPAFieldDeleted = BaseWebSocketMessage<WebSocketEvents.CPAFieldDeleted, {
|
||||
field_id: string;
|
||||
}>;
|
||||
|
||||
export type CPAValuesUpdated = BaseWebSocketMessage<WebSocketEvents.CPAValuesUpdated, {
|
||||
user_id: string;
|
||||
values: Array<PropertyValue<unknown>>;
|
||||
}>;
|
||||
|
||||
// Content flagging messages
|
||||
|
||||
export type ContentFlaggingReportValueUpdated =
|
||||
BaseWebSocketMessage<WebSocketEvents.ContentFlaggingReportValueUpdated, {
|
||||
property_values: JsonEncodedValue<Array<PropertyValue<unknown>>>;
|
||||
target_id: string;
|
||||
}>;
|
||||
|
||||
// Job messages
|
||||
|
||||
export type JobUpdated = BaseWebSocketMessage<WebSocketEvents.JobUpdated, {
|
||||
job: JsonEncodedValue<Job>;
|
||||
}>;
|
||||
|
||||
// Recap messages
|
||||
|
||||
export type RecapUpdated = BaseWebSocketMessage<WebSocketEvents.RecapUpdated, {
|
||||
recap_id: string;
|
||||
}>;
|
||||
|
||||
// Post translation messages
|
||||
|
||||
export type PostTranslationUpdated = BaseWebSocketMessage<WebSocketEvents.PostTranslationUpdated, {
|
||||
object_id: string;
|
||||
translations: Record<string, {
|
||||
state: 'ready' | 'skipped' | 'processing' | 'unavailable';
|
||||
translation?: string;
|
||||
translation_type?: string;
|
||||
src_lang?: string;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
// Plugin and integration messages
|
||||
|
||||
export type Plugin = BaseWebSocketMessage<WebSocketEvents.PluginEnabled | WebSocketEvents.PluginDisabled, {
|
||||
manifest: PluginManifest;
|
||||
}>;
|
||||
|
||||
// Signals admin clients to refetch the full plugin statuses on demand. plugin_statuses is always
|
||||
// an empty array: it carries no data, but is retained (rather than omitted) so clients that call
|
||||
// array methods on it don't break.
|
||||
export type PluginStatusesChanged = BaseWebSocketMessage<WebSocketEvents.PluginStatusesChanged, {
|
||||
plugin_statuses: never[];
|
||||
}>;
|
||||
|
||||
export type OpenDialog = BaseWebSocketMessage<WebSocketEvents.OpenDialog, {
|
||||
dialog: JsonEncodedValue<OpenDialogRequest>;
|
||||
}>;
|
||||
|
||||
export type FileDownloadRejected = BaseWebSocketMessage<WebSocketEvents.FileDownloadRejected, {
|
||||
file_id: string;
|
||||
file_name: string;
|
||||
rejection_reason: string;
|
||||
channel_id: string;
|
||||
post_id: string;
|
||||
download_type: string;
|
||||
}>;
|
||||
|
||||
export type FileUploadRejected = BaseWebSocketMessage<WebSocketEvents.FileUploadRejected, {
|
||||
file_name: string;
|
||||
rejection_reason: string;
|
||||
channel_id: string;
|
||||
}>;
|
||||
|
||||
export type ShowToast = BaseWebSocketMessage<WebSocketEvents.ShowToast, {
|
||||
message: string;
|
||||
position?: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Unknown is used for WebSocket messages which don't come from Mattermost itself. It's primarily intended for use
|
||||
* by plugins.
|
||||
*/
|
||||
export type Unknown = BaseWebSocketMessage<string, unknown>;
|
||||
27
webapp/platform/client/tsconfig.build.json
Normal file
27
webapp/platform/client/tsconfig.build.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"target": "es2022",
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"jsx": "react",
|
||||
"outDir": "./lib",
|
||||
"rootDir": "./src",
|
||||
"composite": true,
|
||||
},
|
||||
"include": [
|
||||
"./src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"**/*.test.*"
|
||||
],
|
||||
"references": [
|
||||
{"path": "../types"}
|
||||
]
|
||||
}
|
||||
25
webapp/platform/client/tsconfig.json
Normal file
25
webapp/platform/client/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"target": "es2022",
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"jsx": "react",
|
||||
"preserveConstEnums": true,
|
||||
"outDir": "./lib",
|
||||
"rootDir": "./src",
|
||||
"composite": true,
|
||||
},
|
||||
"include": [
|
||||
"./src/**/*"
|
||||
],
|
||||
"references": [
|
||||
{"path": "../types"}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user