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

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

View 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);
});
});

File diff suppressed because it is too large Load Diff

View 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);
});
}
});

View 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;
}

View 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);
});
});

View 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 '';
}
}

View 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};

View 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();
});
});

View 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);
}
}

View 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',
}

View 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;
};

View 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>;