snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,43 @@
The Rocket.Chat Enterprise Edition (EE) license (the "EE License")
Copyright (c) 2015-2023 Rocket.Chat Technologies Corp.
With regard to the Rocket.Chat Software:
This software and associated documentation files (the "Software") may only be
used in production, if you (and any entity that you represent) have agreed to,
and are in compliance with, the Rocket.Chat Subscription Terms of Service,
available at https://rocket.chat/terms (the "EE Terms"), or other agreement
governing the use of the Software, as agreed by you and Rocket.Chat, and otherwise
have a valid Rocket.Chat Enterprise Edition subscription for the correct number
of user seats. Subject to the foregoing sentence, you are free to modify this
Software and publish patches to the Software. You agree that Rocket.Chat and/or
its licensors (as applicable) retain all right, title and interest in and to all
such modifications and/or patches, and all such modifications and/or patches may
only be used, copied, modified, displayed, distributed, or otherwise exploited
with a valid Rocket.Chat Enterprise Edition subscription for the correct number
of user seats. Notwithstanding the foregoing, you may copy and modify the Software
for development and testing purposes, without requiring a Subscription. You agree
that Rocket.Chat and/or its licensors (as applicable) retain all right, title and
interest in and to all such modifications. You are not granted any other rights
beyond what is expressly stated herein. Subject to the foregoing, it is forbidden
to copy, merge, publish, distribute, sublicense, and/or sell the Software.
This EE License applies only to the part of this Software that is not distributed
as part of Rocket.Chat Community Edition (CE). Any part of this Software distributed
as part of Rocket.Chat CE or is served client-side as an image, font, cascading
stylesheet (CSS), file which produces or is compiled, arranged, augmented, or combined
into client-side JavaScript, in whole or in part, is copyrighted under the MIT Expat
license. The full text of this EE License shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
For all third-party components incorporated into the Rocket.Chat Software,
those components are licensed under the original license provided by the owner
of the applicable component.

View File

@@ -0,0 +1,91 @@
# Getting started
Add `@rocket.chat/ddp-client` and `@rocket.chat/emitter` as dependencies of your project:
`yarn add @rocket.chat/ddp-client @rocket.chat/emitter`
or:
`npm install @rocket.chat/ddp-client @rocket.chat/emitter`
> @rocket.chat/emitter is listed as a peer dependency of ddp-client and is strictly necessary to make it work.
> Tip: The whole project is typed using typescript. For that reason, references to interfaces and objects will be kept to a minimum.
## Setting up the SDK
>This works out of the box for browsers. if you want to use it on NodeJS, you need to offer a `WebSocket` implementation and a `fetch` implementation.
First things first, let's import the SDK:
`import { DDPSDK } from '@rocket.chat/ddp-client';`
Now we need to create a new SDK instance. Fortunately, `DDPSDK` exposes a `create` function that initalizes everything for a quick setup:
`const sdk = DDPSDK.create('http://localhost:3000');`
We can then try to connect to the Rocket.Chat instance by doing:
`await sdk.connection.connect();`
You can check the connection status by referencing `sdk.connection.status`. If everything went right, it's value should be `'connected'`.
> If you're feeling fancy, you can create and connect in a single function call:
> `const sdk = DDPSDK.createAndConnect('http://localhost:3000');`
## SDK Modules
### Connection
Responsible for the connection to the server, status and connection states.
### Account
Responsible for the account management, login, logout, handle credentials, get user information, etc.
### ClientStream
Responsible for the DDP communication, method calls, subscriptions, etc.
### TimeoutControl
Responsible for the Reconnection control
### RestClient
Responsible for the REST API communication for more info [see here](https://developer.rocket.chat/reference/api/rest-api)
## Login handling
The just created `sdk` exposes an `account` interface (`sdk.account`), which should have everything you need. It has 3 methods:
1. `sdk.account.loginWithPassword(username, hashedPassword)`
- This method accepts 2 parameters, `username` and `password`. The password must be hashed as `sha-256` for it to work
2. `sdk.account.loginWithToken('userTokenGoesHere')`
- If you already got the token someway through the API, you can call this method.
3. `sdk.account.logout()`
- This one is self-explanatory.
While the `sdk` instance is kept in memory, you can find some user information and credentials by referencing `sdk.account.user`
## REST API
> TIP: You might have to enable CORS in your Rocket.Chat instance for this to work.
The sdk exposes a `rest` interface, which accept all rest methods (`get`, `post`, `put`, `delete`).
Example call:
`await sdk.rest.post('/v1/chat.sendMessage', { message: { rid: id, msg } });`
> WARNING: if you wrap a rest call in a try catch block, the error will be of type `Response`. By calling `error.json()` you get access to the server error response.
## Streams
Rocket.Chat uses websockets as to provide realtime data. You can subscribe to publications in order to listen to data updates.
Below is an example of subscribing to the room-messages publication, which receives message updates from a room:
```ts
const messages = new Map([]);
const stream = sdk.stream('room-messages', roomId, (args) => {
setMessages((messages) => {
messages.set(args._id, args);
return new Map(messages);
});
});
// Stop the stream when you're done
stream.stop();
```
> TIP: always unsubscribe from publications when you're done with them. This saves bandwidth and server resources

View File

@@ -0,0 +1,39 @@
import WebSocket from 'ws';
import { DDPSDK } from '../src/DDPSDK';
(global as any).WebSocket = global.WebSocket || WebSocket;
const run = async (url: string, token: string) => {
const sdk = await DDPSDK.createAndConnect(url);
try {
if (!token) {
throw new Error('Token is required');
}
await sdk.account.loginWithToken(token);
await sdk.stream('notify-room', 'GENERAL/user-activity', (args) =>
console.log('notify-user -> GENERAL/user-activity', JSON.stringify(args, undefined, 2)),
);
await sdk.stream('notify-user', 'GENERAL/rooms-changed', (args) =>
console.log('notify-user -> GENERAL/rooms-changed', JSON.stringify(args, undefined, 2)),
);
await sdk.stream('room-messages', 'GENERAL', (message) =>
console.log('room-messages -> GENERAL', JSON.stringify(message, undefined, 2)),
);
await sdk.stream('roles', 'roles', (args) => console.log('roles -> roles', JSON.stringify(args, undefined, 2)));
console.log('ROOMS', await sdk.client.callAsync('subscriptions/get'));
} catch (error) {
console.log('error', error);
}
return sdk;
};
void (async () => {
await run('wss://unstable.rocket.chat/websocket', process.env.INSTANCE_TOKEN || '');
})();

View File

@@ -0,0 +1 @@
export { WebSocket } from 'mock-socket';

View File

@@ -0,0 +1,133 @@
import WS from 'jest-websocket-mock';
import { handleConnection, handleMethod } from './helpers';
import { DDPSDK } from '../src/DDPSDK';
let server: WS;
beforeEach(async () => {
server = new WS('ws://localhost:1234/websocket');
});
afterEach(() => {
server.close();
WS.clean();
});
describe('login', () => {
it('should save credentials to user object - loginWithToken', async () => {
const sdk = DDPSDK.create('ws://localhost:1234');
await handleConnection(server, sdk.connection.connect());
const messageResult = {
id: 123,
token: 'token',
tokenExpires: { $date: 99999999 },
};
await handleMethod(server, 'login', [{ resume: 'token' }], JSON.stringify(messageResult), sdk.account.loginWithToken('token'));
const { user } = sdk.account;
expect(user?.token).toBe(messageResult.token);
expect((user?.tokenExpires as Date)?.toISOString()).toBe(new Date(messageResult.tokenExpires.$date).toISOString());
expect(user?.id).toBe(messageResult.id);
});
it('should save credentials to user object - loginWithPassword', async () => {
const sdk = DDPSDK.create('ws://localhost:1234');
await handleConnection(server, sdk.connection.connect());
const messageResult = {
id: 123,
token: 'token',
tokenExpires: { $date: 99999999 },
};
await handleMethod(
server,
'login',
[{ user: { username: 'username' }, password: { digest: 'password', algorithm: 'sha-256' } }],
JSON.stringify(messageResult),
sdk.account.loginWithPassword('username', 'password'),
);
const { user } = sdk.account;
expect(user?.token).toBe(messageResult.token);
expect((user?.tokenExpires as Date)?.toISOString()).toBe(new Date(messageResult.tokenExpires.$date).toISOString());
expect(user?.id).toBe(messageResult.id);
});
it('should logout', async () => {
const sdk = DDPSDK.create('ws://localhost:1234');
await handleConnection(server, sdk.connection.connect());
const loginResult = {
id: '123',
token: 'token',
tokenExpires: { $date: 99999999 },
};
await handleMethod(server, 'login', [{ resume: 'token' }], JSON.stringify(loginResult), sdk.account.loginWithToken('token'));
const cb = jest.fn();
sdk.account.on('uid', cb);
await handleMethod(server, 'logout', [], '{}', sdk.account.logout());
// uid setter only fires on transitions, so logging out from a logged-in
// state emits exactly once (string → undefined).
expect(cb).toHaveBeenCalledTimes(1);
expect(cb).toHaveBeenCalledWith(undefined);
const { user } = sdk.account;
expect(user).toBeUndefined();
});
it('should fire onLogin / onLogout on uid transitions only', async () => {
const sdk = DDPSDK.create('ws://localhost:1234');
await handleConnection(server, sdk.connection.connect());
const onLogin = jest.fn();
const onLogout = jest.fn();
sdk.account.onLogin(onLogin);
sdk.account.onLogout(onLogout);
const loginResult = {
id: '123',
token: 'token',
tokenExpires: { $date: 99999999 },
};
await handleMethod(server, 'login', [{ resume: 'token' }], JSON.stringify(loginResult), sdk.account.loginWithToken('token'));
expect(onLogin).toHaveBeenCalledTimes(1);
expect(onLogout).not.toHaveBeenCalled();
await handleMethod(server, 'logout', [], '{}', sdk.account.logout());
expect(onLogin).toHaveBeenCalledTimes(1);
expect(onLogout).toHaveBeenCalledTimes(1);
});
it('should fan out emailVerificationLink and pageLoadLogin events', () => {
const sdk = DDPSDK.create('ws://localhost:1234');
const verify = jest.fn();
const pageLoad = jest.fn();
const stopVerify = sdk.account.onEmailVerificationLink(verify);
sdk.account.onPageLoadLogin(pageLoad);
sdk.account.emit('emailVerificationLink', 'tok-1');
sdk.account.emit('pageLoadLogin', { error: 'totp-required' });
expect(verify).toHaveBeenCalledWith('tok-1');
expect(pageLoad).toHaveBeenCalledWith({ error: 'totp-required' });
stopVerify();
sdk.account.emit('emailVerificationLink', 'tok-2');
expect(verify).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,275 @@
import { ClientStreamImpl } from '../src/ClientStream';
import { DDPDispatcher } from '../src/DDPDispatcher';
import { MinimalDDPClient } from '../src/MinimalDDPClient';
describe('call procedures', () => {
it('should be able to call a method and receive a result', async () => {
const callback = jest.fn();
const ws = new DDPDispatcher();
const client = new ClientStreamImpl(ws);
const id = client.call('test', callback);
ws.handleMessage(
JSON.stringify({
msg: 'result',
result: ['arg1', 'arg2'],
id,
}),
);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(null, ['arg1', 'arg2']);
});
it('should be able to handle errors thrown by the method call', async () => {
const callback = jest.fn();
const ws = new MinimalDDPClient(() => undefined);
const client = new ClientStreamImpl(ws);
const id = client.call('test', callback);
ws.handleMessage(
JSON.stringify({
msg: 'result',
error: {
error: 400,
reason: 'Bad Request',
message: 'Bad Request [400]',
errorType: 'Meteor.Error',
},
id,
}),
);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith({
error: 400,
reason: 'Bad Request',
message: 'Bad Request [400]',
errorType: 'Meteor.Error',
});
});
it('should be able to callAsync a method and receive a result', async () => {
const ws = new MinimalDDPClient(() => undefined);
const client = new ClientStreamImpl(ws);
const promise = client.callAsync('test');
ws.handleMessage(
JSON.stringify({
msg: 'result',
result: ['arg1', 'arg2'],
id: promise.id,
}),
);
const result = await promise;
expect(result).toEqual(['arg1', 'arg2']);
});
it('should be able to callAsync a method and receive an error', async () => {
const ws = new MinimalDDPClient(() => undefined);
const client = new ClientStreamImpl(ws);
const promise = client.callAsync('test');
ws.handleMessage(
JSON.stringify({
msg: 'result',
error: {
error: 400,
reason: 'Bad Request',
message: 'Bad Request [400]',
errorType: 'Meteor.Error',
},
id: promise.id,
}),
);
await expect(promise).rejects.toEqual({
error: 400,
reason: 'Bad Request',
message: 'Bad Request [400]',
errorType: 'Meteor.Error',
});
});
it('should only call the further methods after the previous one has been resolved respecting the wait option', async () => {
const fn = jest.fn();
const dispatch = jest.fn();
const ws = new DDPDispatcher();
const client = new ClientStreamImpl(ws);
client.dispatcher.on('send', dispatch);
const call = client.callWithOptions('wait 1', { wait: true }, fn);
expect(client.dispatcher.queue.length).toBe(1);
const callNoWait = client.call('no wait', fn);
expect(client.dispatcher.queue.length).toBe(2);
const call2 = client.callWithOptions('wait 2', { wait: true }, fn);
expect(client.dispatcher.queue.length).toBe(3);
expect(dispatch).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenCalledTimes(0);
ws.handleMessage(
JSON.stringify({
msg: 'result',
result: ['arg1', 'arg2'],
id: call,
}),
);
expect(dispatch).toHaveBeenCalledTimes(3);
expect(fn).toHaveBeenCalledTimes(1);
ws.handleMessage(
JSON.stringify({
msg: 'result',
result: ['arg1', 'arg2'],
id: call2,
}),
);
expect(fn).toHaveBeenCalledTimes(2);
ws.handleMessage(
JSON.stringify({
msg: 'result',
result: ['arg1', 'arg2'],
id: callNoWait,
}),
);
expect(fn).toHaveBeenCalledTimes(3);
});
});
describe('subscribe procedures', () => {
it('should be able to subscribe to a collection and receive a result', async () => {
const ws = new MinimalDDPClient(() => undefined);
const client = new ClientStreamImpl(ws);
const subscription = client.subscribe('test');
ws.handleMessage(
JSON.stringify({
msg: 'ready',
subs: [subscription.id],
}),
);
await expect(subscription.ready()).resolves.toBeUndefined();
});
it('should be able to subscribe to a collection and receive an error', async () => {
const ws = new MinimalDDPClient(() => undefined);
const client = new ClientStreamImpl(ws);
const subscription = client.subscribe('test');
ws.handleMessage(
JSON.stringify({
msg: 'nosub',
error: {
error: 400,
reason: 'Bad Request',
message: 'Bad Request [400]',
errorType: 'Meteor.Error',
},
id: subscription.id,
}),
);
await expect(subscription.ready()).rejects.toEqual({
error: 400,
reason: 'Bad Request',
message: 'Bad Request [400]',
errorType: 'Meteor.Error',
});
});
it('should be able to unsubscribe from a collection', async () => {
const ws = new MinimalDDPClient(() => undefined);
const client = new ClientStreamImpl(ws);
const subscription = client.subscribe('test');
ws.handleMessage(
JSON.stringify({
msg: 'ready',
subs: [subscription.id],
}),
);
await expect(subscription.ready()).resolves.toBeUndefined();
const unsubPromise = client.unsubscribe(subscription.id);
ws.handleMessage(
JSON.stringify({
msg: 'nosub',
id: subscription.id,
}),
);
expect(unsubPromise).resolves.toEqual({
msg: 'nosub',
id: subscription.id,
});
});
it('should subscribe to a collection and receive values through the observer', async () => {
const ws = new MinimalDDPClient(() => undefined);
const client = new ClientStreamImpl(ws);
const promise = client.subscribe('test');
ws.handleMessage(
JSON.stringify({
msg: 'ready',
subs: [promise.id],
}),
);
await expect(promise.ready()).resolves.toBeUndefined();
const observer = jest.fn();
client.onCollection('test', observer);
ws.handleMessage(
JSON.stringify({
msg: 'added',
collection: 'test',
id: '1',
fields: {
name: 'test',
},
}),
);
expect(observer).toHaveBeenCalledTimes(1);
expect(observer).toHaveBeenCalledWith({
msg: 'added',
collection: 'test',
id: '1',
fields: {
name: 'test',
},
});
});
});
describe('connect procedure', () => {
it('should be able to connect to a server', async () => {
const ws = new MinimalDDPClient(() => undefined);
const client = new ClientStreamImpl(ws);
const promise = client.connect();
ws.handleMessage(
JSON.stringify({
msg: 'connected',
session: 'test',
}),
);
await expect(promise).resolves.toEqual({
msg: 'connected',
session: 'test',
});
});
});

View File

@@ -0,0 +1,370 @@
import WS from 'jest-websocket-mock';
import { handleConnection, handleConnectionAndRejects, handleMethod } from './helpers';
import { ConnectionImpl } from '../src/Connection';
import { MinimalDDPClient } from '../src/MinimalDDPClient';
let server: WS;
let connection: ConnectionImpl;
beforeEach(() => {
server = new WS('ws://localhost:1234/websocket');
});
afterEach(() => {
connection?.close();
server.close();
WS.clean();
jest.useRealTimers();
});
it('should connect', async () => {
const client = new MinimalDDPClient();
connection = new ConnectionImpl('ws://localhost:1234', WebSocket as any, client, { retryCount: 0, retryTime: 0 });
expect(connection.status).toBe('idle');
expect(connection.session).toBeUndefined();
await handleConnection(server, connection.connect());
expect(connection.session).toBe('session');
expect(connection.status).toBe('connected');
});
it('should handle a failing connection', async () => {
const client = new MinimalDDPClient();
connection = new ConnectionImpl('ws://localhost:1234', WebSocket as any, client, { retryCount: 0, retryTime: 0 });
expect(connection.status).toBe('idle');
expect(connection.session).toBeUndefined();
await expect(handleConnectionAndRejects(server, connection.connect())).rejects.toBe('1');
expect(connection.session).toBeUndefined();
expect(connection.status).toBe('failed');
});
it('should trigger a disconnect callback', async () => {
const client = new MinimalDDPClient();
connection = ConnectionImpl.create('ws://localhost:1234', globalThis.WebSocket, client, { retryCount: 0, retryTime: 0 });
expect(connection.status).toBe('idle');
expect(connection.session).toBeUndefined();
const disconnectCallback = jest.fn();
connection.on('connection', disconnectCallback);
await handleConnection(server, connection.connect());
expect(disconnectCallback).toHaveBeenNthCalledWith(1, 'connecting');
expect(disconnectCallback).toHaveBeenNthCalledWith(2, 'connected');
expect(disconnectCallback).toHaveBeenCalledTimes(2);
server.close();
expect(disconnectCallback).toHaveBeenCalledTimes(3);
expect(disconnectCallback).toHaveBeenNthCalledWith(3, 'disconnected');
expect(connection.status).toBe('disconnected');
});
it('should handle the close method', async () => {
const client = new MinimalDDPClient();
connection = ConnectionImpl.create('ws://localhost:1234', globalThis.WebSocket, client, {
retryCount: 0,
retryTime: 0,
});
server.nextMessage.then((message) => {
expect(message).toBe('{"msg":"connect","version":"1","support":["1","pre2","pre1"]}');
server.send('{"msg":"connected","session":"123"}');
});
expect(connection.status).toBe('idle');
expect(connection.session).toBeUndefined();
await expect(connection.connect()).resolves.toBe(true);
expect(connection.session).toBe('123');
expect(connection.status).toBe('connected');
connection.close();
expect(connection.status).toBe('closed');
});
it('should handle reconnecting', async () => {
const client = new MinimalDDPClient();
connection = ConnectionImpl.create('ws://localhost:1234', WebSocket, client, { retryCount: 1, retryTime: 100 });
expect(connection.status).toBe('idle');
expect(connection.session).toBeUndefined();
await handleConnection(server, connection.connect());
expect(connection.session).toBe('session');
expect(connection.status).toBe('connected');
// Fake timers are used to avoid waiting for the reconnect timeout
jest.useFakeTimers();
server.close();
WS.clean();
server = new WS('ws://localhost:1234/websocket');
expect(connection.status).toBe('disconnected');
await handleConnection(
server,
jest.advanceTimersByTimeAsync(200),
new Promise((resolve) => connection.once('reconnecting', () => resolve(undefined))),
new Promise((resolve) => {
const handler = (status: string) => {
if (status === 'connected') {
connection.off('connection', handler);
resolve(status);
}
};
connection.on('connection', handler);
}),
);
expect(connection.status).toBe('connected');
jest.useRealTimers();
});
it('should queue messages if the connection is not ready', async () => {
const client = new MinimalDDPClient();
connection = ConnectionImpl.create('ws://localhost:1234', globalThis.WebSocket, client, { retryCount: 0, retryTime: 0 });
await handleConnection(server, connection.connect());
connection.close();
expect(connection.status).toBe('closed');
client.emit('send', { msg: 'method', method: 'method', params: ['arg1', 'arg2'], id: '1' });
expect(connection.queue.size).toBe(1);
await handleConnection(server, connection.reconnect());
expect(connection.queue.size).toBe(0);
await handleMethod(server, 'method', ['arg1', 'arg2'], '1');
});
it('should be idempotent if reconnect is called while already connected', async () => {
const client = new MinimalDDPClient();
connection = ConnectionImpl.create('ws://localhost:1234', globalThis.WebSocket, client, { retryCount: 0, retryTime: 0 });
await handleConnection(server, connection.connect());
// Previous behavior was to throw "Connection in progress" — the consumer's
// `void this.reconnect()` paths (notably the ws.onclose retry timer)
// surfaced that as an unhandled rejection / pageError. Now a redundant
// reconnect is just a no-op resolving with the current state.
await expect(connection.reconnect()).resolves.toBe(true);
expect(connection.status).toBe('connected');
});
it('should be idempotent if connect is called while already connected', async () => {
const client = new MinimalDDPClient();
connection = ConnectionImpl.create('ws://localhost:1234', globalThis.WebSocket, client, { retryCount: 0, retryTime: 0 });
await handleConnection(server, connection.connect());
await expect(connection.connect()).resolves.toBe(true);
expect(connection.status).toBe('connected');
});
it('should share the in-flight connect promise with a concurrent connect() caller', async () => {
// Regression: while status === 'connecting', a second connect() used to
// receive Promise.resolve(true) immediately, hiding any subsequent
// 'failed' payload from the in-flight handshake. Both callers must now
// observe the real outcome.
const client = new MinimalDDPClient();
connection = new ConnectionImpl('ws://localhost:1234', WebSocket as any, client, { retryCount: 0, retryTime: 0 });
const first = connection.connect();
expect(connection.status).toBe('connecting');
const second = connection.connect();
expect(second).toBe(first);
await expect(handleConnectionAndRejects(server, first, second)).rejects.toBe('1');
expect(connection.status).toBe('failed');
});
it('should share the in-flight connect promise with a concurrent reconnect() caller', async () => {
// Same as above for the reconnect() entry point — the ws.onclose retry
// timer fires `reconnect()` and must piggyback on any handshake the
// consumer's bootstrap path already started.
const client = new MinimalDDPClient();
connection = new ConnectionImpl('ws://localhost:1234', WebSocket as any, client, { retryCount: 0, retryTime: 0 });
const first = connection.connect();
expect(connection.status).toBe('connecting');
const second = connection.reconnect();
expect(second).toBe(first);
await handleConnection(server, first, second);
expect(connection.status).toBe('connected');
});
it('should not surface the retry timer rejection when an external connect won the race', async () => {
// Regression: ws.onclose schedules a `void this.reconnect()` timer; if the
// consumer (e.g. ddpSdk.ts startConnect) opens a fresh socket before that
// timer fires, the timer used to reject with "Connection in progress" and,
// because of the leading `void`, the rejection became an unhandled
// rejection on the page. The timer must now no-op silently when the
// connection has already been re-established.
const client = new MinimalDDPClient();
connection = ConnectionImpl.create('ws://localhost:1234', WebSocket, client, { retryCount: 1, retryTime: 100 });
await handleConnection(server, connection.connect());
expect(connection.status).toBe('connected');
jest.useFakeTimers();
server.close();
WS.clean();
server = new WS('ws://localhost:1234/websocket');
expect(connection.status).toBe('disconnected');
// Track unhandled rejections on the timer's promise.
const unhandled = jest.fn();
process.on('unhandledRejection', unhandled);
// External code opens a new connection BEFORE the retry timer fires.
const externalConnect = handleConnection(server, connection.connect());
// Run the timer.
await jest.advanceTimersByTimeAsync(200);
await externalConnect;
// Drain any microtasks the timer might have queued.
await Promise.resolve();
await Promise.resolve();
expect(connection.status).toBe('connected');
expect(unhandled).not.toHaveBeenCalled();
process.off('unhandledRejection', unhandled);
jest.useRealTimers();
});
it('should reset retryCount on a successful connection so subsequent drops can retry', async () => {
// Regression: retryCount was only ever incremented on disconnect, never
// zeroed on successful (re)connect. With default retryCount=1 budget, a
// single force-logout cycle (server close → SDK reconnects → app calls
// `Meteor.logout()` → server's logout handler closes WS again) drained the
// budget, and the second close left the SDK permanently disconnected.
// Method frames queued on the SDK during that window stayed queued
// forever — observed in e2e-encryption/e2ee-passphrase-management as the
// next loginByUserState login frame never being delivered.
const client = new MinimalDDPClient();
connection = ConnectionImpl.create('ws://localhost:1234', WebSocket, client, { retryCount: 1, retryTime: 100 });
await handleConnection(server, connection.connect());
expect(connection.status).toBe('connected');
expect((connection as unknown as { retryCount: number }).retryCount).toBe(0);
// First disconnect → schedules retry (retryCount: 0 → 1).
jest.useFakeTimers();
server.close();
WS.clean();
server = new WS('ws://localhost:1234/websocket');
expect(connection.status).toBe('disconnected');
await handleConnection(
server,
jest.advanceTimersByTimeAsync(200),
new Promise((resolve) => connection.once('reconnecting', () => resolve(undefined))),
new Promise((resolve) => {
const handler = (status: string) => {
if (status === 'connected') {
connection.off('connection', handler);
resolve(status);
}
};
connection.on('connection', handler);
}),
);
jest.useRealTimers();
expect(connection.status).toBe('connected');
// Successful reconnect must zero the retry budget.
expect((connection as unknown as { retryCount: number }).retryCount).toBe(0);
// Second disconnect should still schedule a retry now that the budget reset.
jest.useFakeTimers();
server.close();
WS.clean();
server = new WS('ws://localhost:1234/websocket');
await handleConnection(
server,
jest.advanceTimersByTimeAsync(200),
new Promise((resolve) => connection.once('reconnecting', () => resolve(undefined))),
new Promise((resolve) => {
const handler = (status: string) => {
if (status === 'connected') {
connection.off('connection', handler);
resolve(status);
}
};
connection.on('connection', handler);
}),
);
jest.useRealTimers();
expect(connection.status).toBe('connected');
});
it('should ignore a stale ws.onclose that fires after the socket has been replaced', async () => {
// Regression: ws.onclose handlers were closed over the original ws but
// mutated `this.status`/`this.retryCount` unconditionally. If a late close
// event from an old socket arrives after a new socket is connected, the
// handler would flip status back to 'disconnected' and schedule another
// retry timer.
const client = new MinimalDDPClient();
connection = ConnectionImpl.create('ws://localhost:1234', WebSocket, client, { retryCount: 1, retryTime: 100 });
await handleConnection(server, connection.connect());
const firstWs = (connection as unknown as { ws: WebSocket }).ws;
expect(connection.status).toBe('connected');
jest.useFakeTimers();
server.close();
WS.clean();
server = new WS('ws://localhost:1234/websocket');
expect(connection.status).toBe('disconnected');
await handleConnection(
server,
jest.advanceTimersByTimeAsync(200),
new Promise((resolve) => connection.once('reconnecting', () => resolve(undefined))),
new Promise((resolve) => {
const handler = (status: string) => {
if (status === 'connected') {
connection.off('connection', handler);
resolve(status);
}
};
connection.on('connection', handler);
}),
);
expect(connection.status).toBe('connected');
jest.useRealTimers();
const secondWs = (connection as unknown as { ws: WebSocket }).ws;
expect(secondWs).not.toBe(firstWs);
const statusBefore = connection.status;
const retryBefore = (connection as unknown as { retryCount: number }).retryCount;
// Synthesize a late `close` event on the original socket — the handler
// must short-circuit because `this.ws !== ws` for the closed-over ws.
(firstWs as unknown as { onclose?: () => void }).onclose?.();
expect(connection.status).toBe(statusBefore);
expect((connection as unknown as { retryCount: number }).retryCount).toBe(retryBefore);
});

View File

@@ -0,0 +1,149 @@
import { DDPDispatcher } from '../src/DDPDispatcher';
import { MinimalDDPClient } from '../src/MinimalDDPClient';
const ddp = new MinimalDDPClient();
it('should create a block properly', () => {
const ddpDispatcher = new DDPDispatcher();
const test = ddp.call('test');
ddpDispatcher.wait(test);
expect(ddpDispatcher.queue).toEqual([
{
wait: true,
items: [test],
},
]);
});
it('should push an item to the block properly', () => {
const ddpDispatcher = new DDPDispatcher();
const test1 = ddp.call('test1');
const test2 = ddp.call('test2');
ddpDispatcher.wait(test1);
ddpDispatcher.dispatch(test2);
expect(ddpDispatcher.queue).toEqual([
{
wait: true,
items: [test1],
},
{
wait: false,
items: [test2],
},
]);
});
it('should not keep in the queue if the method doesnt wait', () => {
const ddpDispatcher = new DDPDispatcher();
const blockToRemove = ddp.call('test1');
const test2 = ddp.call('test2');
ddpDispatcher.dispatch(blockToRemove, { wait: true });
ddpDispatcher.dispatch(test2, { wait: false });
expect(ddpDispatcher.queue).toEqual([
{
wait: true,
items: [blockToRemove],
},
{
wait: false,
items: [test2],
},
]);
ddpDispatcher.removeItem(blockToRemove);
expect(ddpDispatcher.queue).toEqual([]);
});
it('should send outstanding blocks if there is no block waiting and item is added', () => {
const fn = jest.fn();
const ddpDispatcher = new DDPDispatcher();
ddpDispatcher.on('send', fn);
ddpDispatcher.dispatch(ddp.call('test1'));
expect(fn).toHaveBeenCalledTimes(1);
});
it('emits non-method payloads immediately, even when a wait block is at the head', () => {
// Regression: a connect frame dispatched while a wait `login` method is
// queued must still reach the server. Otherwise the DDP handshake never
// completes and the socket wedges open but unconnected.
const fn = jest.fn();
const ddpDispatcher = new DDPDispatcher();
ddpDispatcher.on('send', fn);
const login = ddp.call('login');
ddpDispatcher.dispatch(login, { wait: true });
expect(fn).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenNthCalledWith(1, login);
const connectPayload = { msg: 'connect' as const, version: '1', support: ['1'] };
ddpDispatcher.dispatch(connectPayload);
expect(fn).toHaveBeenCalledTimes(2);
expect(fn).toHaveBeenNthCalledWith(2, connectPayload);
const subPayload = { msg: 'sub' as const, id: 'a', name: 'foo', params: [] };
ddpDispatcher.dispatch(subPayload);
expect(fn).toHaveBeenCalledTimes(3);
expect(fn).toHaveBeenNthCalledWith(3, subPayload);
// Wait block remains pending — only the wait method is queued, the
// non-method frames bypassed it.
expect(ddpDispatcher.queue).toEqual([{ wait: true, items: [login] }]);
});
it('should send the next blocks if the outstanding block was completed', () => {
const fn = jest.fn();
const ddpDispatcher = new DDPDispatcher();
ddpDispatcher.on('send', fn);
const block1 = ddp.call('block1');
const block2 = ddp.call('block2');
const block3 = ddp.call('test2');
ddpDispatcher.dispatch(block1, { wait: true });
ddpDispatcher.dispatch(block2, { wait: true });
ddpDispatcher.dispatch(block3);
expect(fn).toHaveBeenCalledTimes(1);
ddpDispatcher.removeItem(block1);
expect(fn).toHaveBeenCalledTimes(2);
expect(ddpDispatcher.queue.length).toBe(2);
expect(ddpDispatcher.queue).toEqual([
{
wait: true,
items: [block2],
},
{
wait: false,
items: [block3],
},
]);
ddpDispatcher.removeItem(block2);
expect(ddpDispatcher.queue.length).toBe(0);
expect(ddpDispatcher.queue).toEqual([]);
expect(fn).toHaveBeenCalledTimes(3);
expect(fn).toHaveBeenNthCalledWith(1, block1);
expect(fn).toHaveBeenNthCalledWith(2, block2);
expect(fn).toHaveBeenNthCalledWith(3, block3);
});

View File

@@ -0,0 +1,310 @@
import util from 'node:util';
import WS from 'jest-websocket-mock';
import { WebSocket } from 'ws';
import { fireStreamChange, fireStreamAdded, fireStreamRemove, handleConnection, handleSubscription, handleMethod } from './helpers';
import { DDPSDK } from '../src/DDPSDK';
(global as any).WebSocket = (global as any).WebSocket || WebSocket;
export let server: WS;
const callXTimes = <F extends (...args: any) => any>(fn: F, times: number): F => {
return (async (...args) => {
const methods = [].concat(...Array(times));
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for (const _ of methods) {
await fn(...args);
}
}) as F;
};
beforeEach(async () => {
server = new WS('ws://localhost:1234/websocket');
});
afterEach(() => {
server.close();
WS.clean();
jest.useRealTimers();
});
it('should handle a stream of messages', async () => {
const cb = jest.fn();
const streamName = 'stream';
const streamParams = '123';
const sdk = DDPSDK.create('ws://localhost:1234');
await handleConnection(server, sdk.connection.connect());
const stream = sdk.stream(streamName, streamParams, cb);
await handleSubscription(server, stream.id, streamName, streamParams);
await stream.ready();
fireStreamChange(server, streamName, streamParams);
fireStreamChange(server, streamName, streamParams);
fireStreamChange(server, streamName, streamParams);
expect(cb).toHaveBeenCalledTimes(3);
fireStreamChange(server, streamName, `${streamParams}-another`);
expect(cb).toHaveBeenCalledTimes(3);
expect(cb).toHaveBeenNthCalledWith(1, 1);
sdk.connection.close();
});
it('should ignore messages other from changed', async () => {
const cb = jest.fn();
const streamName = 'stream';
const streamParams = '123';
const sdk = DDPSDK.create('ws://localhost:1234');
await handleConnection(server, sdk.connection.connect());
const stream = sdk.stream(streamName, streamParams, cb);
await handleSubscription(server, stream.id, streamName, streamParams);
await stream.ready();
fireStreamAdded(server, streamName, streamParams);
fireStreamRemove(server, streamName, streamParams);
expect(cb).toHaveBeenCalledTimes(0);
sdk.connection.close();
});
it('should handle streams after reconnect', async () => {
const cb = jest.fn();
const streamName = 'stream';
const streamParams = '123';
const sdk = DDPSDK.create('ws://localhost:1234');
await handleConnection(server, sdk.connection.connect());
const result = sdk.stream(streamName, streamParams, cb);
expect(result.isReady).toBe(false);
expect(sdk.client.subscriptions.size).toBe(1);
await handleSubscription(server, result.id, streamName, streamParams);
await result.ready();
const fire = callXTimes(fireStreamChange, 3);
await fire(server, streamName, streamParams);
expect(cb).toHaveBeenCalledTimes(3);
// Fake timers are used to avoid waiting for the reconnect timeout
jest.useFakeTimers();
server.close();
WS.clean();
server = new WS('ws://localhost:1234/websocket');
const reconnect = new Promise((resolve) => sdk.connection.once('reconnecting', () => resolve(undefined)));
const connecting = new Promise((resolve) => sdk.connection.once('connecting', () => resolve(undefined)));
const connected = new Promise((resolve) => sdk.connection.once('connected', () => resolve(undefined)));
await handleConnection(server, jest.advanceTimersByTimeAsync(1000), reconnect, connecting, connected);
// the client should reconnect and resubscribe
await Promise.all([handleSubscription(server, result.id, streamName, streamParams), jest.advanceTimersByTimeAsync(1000)]);
fire(server, streamName, streamParams);
await jest.advanceTimersByTimeAsync(1000);
expect(cb).toHaveBeenCalledTimes(6);
jest.useRealTimers();
sdk.connection.close();
});
it('should handle an unsubscribe stream after reconnect', async () => {
const cb = jest.fn();
const streamName = 'stream';
const streamParams = '123';
const sdk = DDPSDK.create('ws://localhost:1234');
await handleConnection(server, sdk.connection.connect());
const subscription = sdk.stream(streamName, streamParams, cb);
expect(subscription.isReady).toBe(false);
expect(sdk.client.subscriptions.size).toBe(1);
await handleSubscription(server, subscription.id, streamName, streamParams);
await expect(subscription.ready()).resolves.toBe(undefined);
expect(subscription.isReady).toBe(true);
fireStreamChange(server, streamName, streamParams);
fireStreamChange(server, streamName, streamParams);
fireStreamChange(server, streamName, streamParams);
expect(cb).toHaveBeenCalledTimes(3);
// Fake timers are used to avoid waiting for the reconnect timeout
jest.useFakeTimers();
server.close();
WS.clean();
server = new WS('ws://localhost:1234/websocket');
const reconnect = new Promise((resolve) => sdk.connection.once('reconnecting', () => resolve(undefined)));
const connecting = new Promise((resolve) => sdk.connection.once('connecting', () => resolve(undefined)));
const connected = new Promise((resolve) => sdk.connection.once('connected', () => resolve(undefined)));
await handleConnection(server, jest.advanceTimersByTimeAsync(1000), reconnect, connecting, connected);
await handleSubscription(server, subscription.id, streamName, streamParams);
expect(subscription.isReady).toBe(true);
fireStreamChange(server, streamName, streamParams);
subscription.stop();
expect(sdk.client.subscriptions.size).toBe(0);
fireStreamChange(server, streamName, streamParams);
fireStreamChange(server, streamName, streamParams);
jest.advanceTimersByTimeAsync(1000);
expect(cb).toHaveBeenCalledTimes(4);
expect(sdk.client.subscriptions.size).toBe(0);
jest.useRealTimers();
sdk.connection.close();
sdk.connection.close();
});
it('should create and connect to a stream', async () => {
const promise = DDPSDK.createAndConnect('ws://localhost:1234');
await handleConnection(server, promise);
const sdk = await promise;
sdk.connection.close();
});
it.skip('should try to loginWithToken after reconnection', async () => {
const sdk = DDPSDK.create('ws://localhost:1234');
await handleConnection(server, sdk.connection.connect());
const messageResult = {
id: 123,
token: 'token1234',
tokenExpires: { $date: 99999999 },
};
const { loginWithToken } = sdk.account;
const loginFn = jest.fn((token: string) => loginWithToken.apply(sdk.account, [token]));
sdk.account.loginWithToken = loginFn;
await handleMethod(server, 'login', [{ resume: 'token' }], JSON.stringify(messageResult), sdk.account.loginWithToken('token'));
expect(sdk.account.user?.token).toBe(messageResult.token);
expect(loginFn).toHaveBeenCalledTimes(1);
// Fake timers are used to avoid waiting for the reconnect timeout
jest.useFakeTimers();
server.close();
WS.clean();
server = new WS('ws://localhost:1234/websocket');
const reconnect = new Promise((resolve) => sdk.connection.once('reconnecting', () => resolve(undefined)));
const connecting = new Promise((resolve) => sdk.connection.once('connecting', () => resolve(undefined)));
const connected = new Promise((resolve) => sdk.connection.once('connected', () => resolve(undefined)));
await handleConnection(server, jest.advanceTimersByTimeAsync(1000), reconnect, connecting, connected);
jest.advanceTimersByTimeAsync(1000);
expect(loginFn).toHaveBeenCalledTimes(2);
jest.useRealTimers();
sdk.connection.close();
});
describe('Method call and Disconnection cases', () => {
it('should handle properly if the message was sent after disconnection', async () => {
const sdk = DDPSDK.create('ws://localhost:1234');
await handleConnection(server, sdk.connection.connect());
const [result] = await handleMethod(server, 'method', ['args1'], '1', sdk.client.callAsync('method', 'args1'));
expect(result).toBe(1);
// Fake timers are used to avoid waiting for the reconnect timeout
jest.useFakeTimers();
server.close();
WS.clean();
server = new WS('ws://localhost:1234/websocket');
const reconnect = new Promise((resolve) => sdk.connection.once('reconnecting', () => resolve(undefined)));
const connecting = new Promise((resolve) => sdk.connection.once('connecting', () => resolve(undefined)));
const connected = new Promise((resolve) => sdk.connection.once('connected', () => resolve(undefined)));
const callResult = sdk.client.callAsync('method', 'args2');
expect(util.inspect(callResult).includes('pending')).toBe(true);
await handleConnection(server, jest.advanceTimersByTimeAsync(1000), reconnect, connecting, connected);
const [result2] = await handleMethod(server, 'method', ['args2'], '1', callResult);
expect(util.inspect(callResult).includes('pending')).toBe(false);
expect(result2).toBe(1);
sdk.connection.close();
jest.useRealTimers();
});
it.skip('should handle properly if the message was sent before disconnection but got disconnected before receiving the response', async () => {
const sdk = DDPSDK.create('ws://localhost:1234');
await handleConnection(server, sdk.connection.connect());
const callResult = sdk.client.callAsync('method', 'args');
expect(util.inspect(callResult).includes('pending')).toBe(true);
// Fake timers are used to avoid waiting for the reconnect timeout
jest.useFakeTimers();
server.close();
WS.clean();
server = new WS('ws://localhost:1234/websocket');
const reconnect = new Promise((resolve) => sdk.connection.once('reconnecting', () => resolve(undefined)));
const connecting = new Promise((resolve) => sdk.connection.once('connecting', () => resolve(undefined)));
const connected = new Promise((resolve) => sdk.connection.once('connected', () => resolve(undefined)));
await handleConnection(server, jest.advanceTimersByTimeAsync(1000), reconnect, connecting, connected);
expect(util.inspect(callResult).includes('pending')).toBe(true);
const [result] = await handleMethod(server, 'method', ['args2'], '1', callResult);
expect(result).toBe(1);
jest.useRealTimers();
sdk.connection.close();
});
});

View File

@@ -0,0 +1,374 @@
import { MinimalDDPClient } from '../src/MinimalDDPClient';
describe('ping/pong mechanism', () => {
it('should reply with pong if no id is provided', () => {
const handlerCallback = jest.fn();
const senderCallback = jest.fn();
const client = new MinimalDDPClient(() => undefined);
client.once('message', handlerCallback);
client.once('send', senderCallback);
client.handleMessage(
JSON.stringify({
msg: 'ping',
}),
);
expect(handlerCallback).toHaveBeenCalledTimes(1);
expect(handlerCallback).toHaveBeenCalledWith({
msg: 'ping',
});
expect(senderCallback).toHaveBeenCalledTimes(1);
expect(senderCallback).toHaveBeenCalledWith({
msg: 'pong',
});
});
it('should reply with pong if id is provided', () => {
const handlerCallback = jest.fn();
const senderCallback = jest.fn();
const client = new MinimalDDPClient(() => undefined);
client.on('message', handlerCallback);
client.on('send', senderCallback);
client.handleMessage(
JSON.stringify({
msg: 'ping',
id: '123',
}),
);
expect(handlerCallback).toHaveBeenCalledTimes(1);
expect(handlerCallback).toHaveBeenCalledWith({
msg: 'ping',
id: '123',
});
expect(senderCallback).toHaveBeenCalledTimes(1);
expect(senderCallback).toHaveBeenCalledWith({
msg: 'pong',
id: '123',
});
});
it('should emit "pong" event if pong is received', () => {
const handlerCallback = jest.fn();
const client = new MinimalDDPClient(() => undefined);
client.once('pong', handlerCallback);
client.handleMessage(
JSON.stringify({
msg: 'pong',
}),
);
expect(handlerCallback).toHaveBeenCalledTimes(1);
expect(handlerCallback).toHaveBeenCalledWith({
msg: 'pong',
});
});
});
describe('subscription mechanism', () => {
it('should emit "ready" event after subscribe', () => {
const handlerCallback = jest.fn();
const client = new MinimalDDPClient(() => undefined);
const id = client.subscribe('test');
client.onPublish(id, handlerCallback);
client.handleMessage(
JSON.stringify({
msg: 'ready',
subs: [id],
}),
);
expect(handlerCallback).toHaveBeenCalledTimes(1);
expect(handlerCallback).toHaveBeenCalledWith({
msg: 'ready',
subs: [id],
});
});
it('should emit "nosub" event if something goes wrong', () => {
const handlerCallback = jest.fn();
const client = new MinimalDDPClient(() => undefined);
const id = client.subscribe('test');
client.onPublish(id, handlerCallback);
client.handleMessage(
JSON.stringify({
msg: 'nosub',
id,
}),
);
expect(handlerCallback).toHaveBeenCalledTimes(1);
expect(handlerCallback).toHaveBeenCalledWith({
msg: 'nosub',
id,
});
});
it('should emit "nosub" event after unsubscribe', () => {
const handlerCallback = jest.fn();
const nosubCallback = jest.fn();
const client = new MinimalDDPClient();
const id = client.subscribe('test');
client.onPublish(id, handlerCallback);
client.onNoSub(id, nosubCallback);
client.handleMessage(
JSON.stringify({
msg: 'ready',
subs: [id],
}),
);
client.unsubscribe(id);
client.handleMessage(
JSON.stringify({
msg: 'nosub',
id,
}),
);
expect(handlerCallback).toHaveBeenCalledTimes(1);
expect(handlerCallback).toHaveBeenCalledWith({
msg: 'ready',
subs: [id],
});
expect(nosubCallback).toHaveBeenCalledTimes(1);
expect(nosubCallback).toHaveBeenCalledWith({
msg: 'nosub',
id,
});
});
});
describe('subscription added/changed/removed mechanism', () => {
it('should receive "added" events after subscribe', () => {
const handlerCallback = jest.fn();
const readyCallback = jest.fn();
const client = new MinimalDDPClient(() => undefined);
const id = client.subscribe('test');
client.onCollection('test', handlerCallback);
client.onPublish(id, readyCallback);
client.handleMessage(
JSON.stringify({
msg: 'ready',
subs: [id],
}),
);
client.handleMessage(
JSON.stringify({
msg: 'added',
collection: 'test',
id,
fields: {
test: 'test',
},
}),
);
expect(readyCallback).toHaveBeenCalledTimes(1);
expect(readyCallback).toHaveBeenCalledWith({
msg: 'ready',
subs: [id],
});
expect(handlerCallback).toHaveBeenCalledTimes(1);
expect(handlerCallback).toHaveBeenCalledWith({
msg: 'added',
collection: 'test',
id,
fields: {
test: 'test',
},
});
});
it('should receive "changed" events after subscribe', () => {
const handlerCallback = jest.fn();
const readyCallback = jest.fn();
const client = new MinimalDDPClient(() => undefined);
const id = client.subscribe('test');
client.onCollection('test', handlerCallback);
client.onPublish(id, readyCallback);
client.handleMessage(
JSON.stringify({
msg: 'ready',
subs: [id],
}),
);
client.handleMessage(
JSON.stringify({
msg: 'changed',
collection: 'test',
id,
fields: {
test: 'test',
},
}),
);
expect(readyCallback).toHaveBeenCalledTimes(1);
expect(readyCallback).toHaveBeenCalledWith({
msg: 'ready',
subs: [id],
});
expect(handlerCallback).toHaveBeenCalledTimes(1);
expect(handlerCallback).toHaveBeenCalledWith({
msg: 'changed',
collection: 'test',
id,
fields: {
test: 'test',
},
});
});
it('should receive "removed" events after subscribe', () => {
const handlerCallback = jest.fn();
const readyCallback = jest.fn();
const client = new MinimalDDPClient(() => undefined);
const id = client.subscribe('test');
client.onCollection('test', handlerCallback);
client.onPublish(id, readyCallback);
client.handleMessage(
JSON.stringify({
msg: 'ready',
subs: [id],
}),
);
client.handleMessage(
JSON.stringify({
msg: 'removed',
collection: 'test',
id,
}),
);
expect(readyCallback).toHaveBeenCalledTimes(1);
expect(readyCallback).toHaveBeenCalledWith({
msg: 'ready',
subs: [id],
});
expect(handlerCallback).toHaveBeenCalledTimes(1);
expect(handlerCallback).toHaveBeenCalledWith({
msg: 'removed',
collection: 'test',
id,
});
});
});
describe('subscription updated mechanism', () => {
it('should receive "updated" events after subscribe and before ready', () => {
const handlerCallback = jest.fn();
const readyCallback = jest.fn();
const updatedCallback = jest.fn();
const client = new MinimalDDPClient(() => undefined);
const id = client.subscribe('test');
client.onCollection(id, handlerCallback);
client.onPublish(id, readyCallback);
client.onUpdate(id, updatedCallback);
client.handleMessage(
JSON.stringify({
msg: 'ready',
subs: [id],
}),
);
client.handleMessage(
JSON.stringify({
msg: 'updated',
methods: [id],
}),
);
expect(readyCallback).toHaveBeenCalledTimes(1);
expect(handlerCallback).toHaveBeenCalledTimes(0);
expect(updatedCallback).toHaveBeenCalledTimes(1);
expect(updatedCallback).toHaveBeenCalledWith({
msg: 'updated',
methods: [id],
});
});
});
describe('method mechanism', () => {
it('should emit "result" event after method call', () => {
const handlerCallback = jest.fn();
const client = new MinimalDDPClient(() => undefined);
const { id } = client.call('test');
client.onResult(id, handlerCallback);
client.handleMessage(
JSON.stringify({
msg: 'result',
params: [],
id,
}),
);
expect(handlerCallback).toHaveBeenCalledTimes(1);
expect(handlerCallback).toHaveBeenCalledWith({
msg: 'result',
params: [],
id,
});
});
});
describe('error mechanism', () => {
it('should throw error if the payload is not valid', () => {
const client = new MinimalDDPClient(() => undefined);
expect(() => {
client.handleMessage('test');
}).toThrow();
});
});

View File

@@ -0,0 +1,64 @@
import { TimeoutControl } from '../src/TimeoutControl';
jest.useFakeTimers();
jest.spyOn(global, 'setTimeout');
it('should call the heartbeat and timeout callbacks respecting the informed time', async function () {
const heartbeatCallback = jest.fn();
const timeoutCallback = jest.fn();
const timeout = new TimeoutControl(100);
timeout.reset();
expect(setTimeout).toHaveBeenCalledTimes(2);
timeout.on('heartbeat', heartbeatCallback);
timeout.on('timeout', timeoutCallback);
// At this point in time, the callback should not have been called yet
expect(heartbeatCallback).not.toHaveBeenCalled();
expect(timeoutCallback).not.toHaveBeenCalled();
jest.advanceTimersByTime(60);
expect(heartbeatCallback).toHaveBeenCalledTimes(1);
expect(timeoutCallback).not.toHaveBeenCalled();
jest.advanceTimersByTime(60);
expect(heartbeatCallback).toHaveBeenCalledTimes(1);
expect(timeoutCallback).toHaveBeenCalledTimes(1);
});
it('should never call the timeout callback if the reset method is called', async function () {
const heartbeatCallback = jest.fn();
const timeoutCallback = jest.fn();
const timeout = new TimeoutControl(100);
timeout.reset();
expect(setTimeout).toHaveBeenCalledTimes(2);
timeout.on('heartbeat', heartbeatCallback);
timeout.on('timeout', timeoutCallback);
// At this point in time, the callback should not have been called yet
expect(heartbeatCallback).not.toHaveBeenCalled();
expect(timeoutCallback).not.toHaveBeenCalled();
jest.advanceTimersByTime(60);
expect(heartbeatCallback).toHaveBeenCalledTimes(1);
expect(timeoutCallback).not.toHaveBeenCalled();
timeout.reset();
jest.advanceTimersByTime(60);
expect(heartbeatCallback).toHaveBeenCalledTimes(2);
expect(timeoutCallback).not.toHaveBeenCalled();
});
afterEach(() => {
jest.clearAllMocks();
});

View File

@@ -0,0 +1,64 @@
import type WS from 'jest-websocket-mock';
const acceptConnection = async (server: WS) => {
await server.nextMessage.then(async (message) => {
await expect(message).toBe('{"msg":"connect","version":"1","support":["1","pre2","pre1"]}');
server.send(`{"msg":"connected","session":"session"}`);
});
};
export const handleConnection = async (server: WS, ...client: Promise<unknown>[]) => {
await Promise.all([acceptConnection(server), ...client]);
};
export const handleConnectionAndRejects = async (server: WS, ...client: Promise<unknown>[]) => {
const suggestedVersion = '1';
return Promise.all([
server.nextMessage.then((message) => {
expect(message).toBe('{"msg":"connect","version":"1","support":["1","pre2","pre1"]}');
return server.send(`{"msg":"failed","version":"${suggestedVersion}"}`);
}),
...client,
]);
};
const handleConnectionButNoResponse = async (server: WS, method: string, params: string[]) => {
return server.nextMessage.then(async (msg) => {
if (typeof msg !== 'string') throw new Error('Expected message to be a string');
const message = JSON.parse(msg);
await expect(message).toMatchObject({
msg: 'method',
method,
params,
});
return message;
});
};
export const handleMethod = async (server: WS, method: string, params: any[], responseResult: string, ...client: Promise<unknown>[]) => {
const result = await handleConnectionButNoResponse(server, method, params);
return Promise.all([server.send(`{"msg":"result","id":"${result.id}","result":${responseResult}}`), ...client]).then((result) => {
result.shift();
return result;
});
};
export const handleSubscription = async (server: WS, id: string, streamName: string, streamParams: string) => {
await server.nextMessage.then(async (message) => {
await expect(message).toBe(
`{"msg":"sub","id":"${id}","name":"stream-${streamName}","params":["${streamParams}",{"useCollection":false,"args":[null]}]}`,
);
server.send(`{"msg":"ready","subs":["${id}"]}`);
});
};
export const fireStream = (action: 'changed' | 'removed' | 'added') => {
return (server: WS, streamName: string, streamParams: string) => {
return server.send(
`{"msg":"${action}","collection":"stream-${streamName}","id":"id","fields":{"eventName":"${streamParams}", "args":[1]}}`,
);
};
};
export const fireStreamChange = fireStream('changed');
export const fireStreamRemove = fireStream('removed');
export const fireStreamAdded = fireStream('added');

View File

@@ -0,0 +1,25 @@
import { Emitter } from '@rocket.chat/emitter';
import { wrapOnceEventIntoPromise } from '../src/wrapOnceEventIntoPromise';
it('should resolve', async () => {
const emitter = new Emitter();
const promise = wrapOnceEventIntoPromise(emitter, 'test');
emitter.emit('test', 'test');
const result = await promise;
expect(result).toBe('test');
});
it('should reject', async () => {
const emitter = new Emitter();
const promise = wrapOnceEventIntoPromise(emitter, 'test');
emitter.emit('test', { error: 'test' });
await expect(promise).rejects.toBe('test');
expect.assertions(1);
});

View File

@@ -0,0 +1,7 @@
import client from '@rocket.chat/jest-presets/client';
import type { Config } from 'jest';
export default {
preset: client.preset,
modulePathIgnorePatterns: ['<rootDir>/__tests__/helpers'],
} satisfies Config;

View File

@@ -0,0 +1,46 @@
{
"name": "@rocket.chat/ddp-client",
"version": "1.1.2",
"repository": {
"type": "git",
"url": "git+https://github.com/RocketChat/Rocket.Chat.git",
"directory": "packages/ddp-client"
},
"main": "./dist/index.js",
"typings": "./dist/index.d.ts",
"files": [
"/dist"
],
"scripts": {
"build": "tsc -p tsconfig.build.json",
"dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"test": "jest",
"testunit": "jest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@rocket.chat/api-client": "workspace:^",
"@rocket.chat/core-typings": "workspace:~",
"@rocket.chat/media-signaling": "workspace:^",
"@rocket.chat/rest-typings": "workspace:^"
},
"devDependencies": {
"@rocket.chat/jest-presets": "workspace:~",
"@rocket.chat/tsconfig": "workspace:*",
"@types/jest": "~30.0.0",
"@types/ws": "^8.18.1",
"eslint": "~9.39.5",
"jest": "~30.2.0",
"jest-websocket-mock": "~2.5.0",
"typescript": "~5.9.3",
"ws": "~8.21.3"
},
"peerDependencies": {
"@rocket.chat/emitter": "workspace:~"
},
"volta": {
"extends": "../../package.json"
}
}

View File

@@ -0,0 +1,191 @@
import { Emitter } from '@rocket.chat/emitter';
import { DDPDispatcher } from './DDPDispatcher';
import type { DDPDispatchOptions } from './MinimalDDPClient';
import type { ClientStream } from './types/ClientStream';
import type { DDPClient } from './types/DDPClient';
import type { Subscription } from './types/Subscription';
import type { MethodPayload } from './types/methodsPayloads';
import type { PublicationPayloads } from './types/publicationPayloads';
export class ClientStreamImpl extends Emitter implements ClientStream {
subscriptions = new Map<string, Subscription>();
constructor(
private ddp: DDPClient,
readonly dispatcher: DDPDispatcher = new DDPDispatcher(),
) {
ddp.onConnection(({ msg }) => {
if (msg === 'connected') {
this.emit('connected');
}
});
super();
}
private apply({
payload: ddpCallPayload,
options,
callback,
}: {
payload: MethodPayload;
callback?: (...args: any[]) => void;
options?: DDPDispatchOptions;
}): string {
this.dispatcher.dispatch(ddpCallPayload, options);
this.ddp.onResult(ddpCallPayload.id, (payload) => {
this.dispatcher.removeItem(ddpCallPayload);
if (typeof callback === 'function') {
if ('error' in payload) {
callback(payload.error);
} else {
callback(null, payload.result);
}
}
});
return ddpCallPayload.id;
}
call(method: string, ...params: any[]): string {
// get the last argument
return this.callWithOptions(method, {}, ...params);
}
callWithOptions(method: string, options: DDPDispatchOptions, ...params: any[]): string {
// get the last argument
const callback = params.pop();
// if it's not a function, then push it back
if (typeof callback !== 'function') {
params.push(callback);
}
const payload = this.ddp.call(method, params);
this.apply({ payload, callback, options });
return payload.id;
}
callAsync(method: string, ...params: any[]) {
return this.callAsyncWithOptions(method, {}, ...params);
}
callAsyncWithOptions(
method: string,
options: DDPDispatchOptions,
...params: any[]
): Promise<any> & {
id: string;
} {
const payload = this.ddp.call(method, params);
return Object.assign(
new Promise((resolve, reject) => {
this.apply({
payload,
options,
callback: (error, result) => {
if (error) {
reject(error);
} else {
resolve(result);
}
},
});
}),
{
id: payload.id,
},
);
}
subscribe(name: string, ...params: any[]) {
const id = this.ddp.subscribe(name, params);
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self = this;
const s = self.ddp.onPublish(id, (payload) => {
if ('error' in payload) {
result.error = payload.error;
this.subscriptions.delete(id);
return;
}
result.isReady = true;
this.subscriptions.set(id, {
...result,
isReady: true,
});
});
const stop = () => {
s();
void self.unsubscribe(id);
};
const result: Subscription = {
id,
name,
params,
async ready() {
const subscription = self.subscriptions.get(id);
if (!subscription) {
return Promise.reject(result.error);
}
if (subscription.isReady) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
this.onChange((payload) => {
if ('error' in payload) {
reject(payload.error);
return;
}
resolve();
});
});
},
isReady: false,
onChange: (cb) => {
self.ddp.onPublish(id, cb);
},
stop,
};
this.subscriptions.set(id, result);
return result;
}
unsubscribe(id: string): Promise<any> {
return new Promise((resolve, reject) => {
this.subscriptions.delete(id);
this.ddp.unsubscribe(id);
this.ddp.onNoSub(id, (payload) => {
if ('error' in payload) {
reject(payload.error);
} else {
resolve(payload);
}
});
});
}
connect(): Promise<any> {
this.ddp.connect();
return new Promise((resolve, reject) => {
this.ddp.onConnection((data) => {
if (data.msg === 'failed') reject(data);
else resolve(data);
});
});
}
onCollection(id: string, callback: (data: PublicationPayloads) => void) {
return this.ddp.onCollection(id, callback);
}
}

View File

@@ -0,0 +1,284 @@
import { Emitter } from '@rocket.chat/emitter';
import type { DDPClient } from './types/DDPClient';
// type Subscription = {
// name: string;
// params: unknown[];
// id: string;
// status: 'queued' | 'subscribing' | 'ready' | 'error';
// };
// type Method = {
// method: string;
// params: unknown[];
// id: string;
// status: 'queued' | 'calling' | 'ready' | 'error';
// };
type RetryOptions = {
retryCount: number;
retryTimer?: NodeJS.Timeout;
retryTime: number;
};
export type ConnectionStatus = 'idle' | 'connecting' | 'connected' | 'failed' | 'closed' | 'disconnected' | 'reconnecting';
export interface Connection extends Emitter<{
connection: ConnectionStatus;
connecting: void;
connected: string;
disconnected: void;
reconnecting: void;
close: void;
}> {
url: string;
ssl: boolean;
session?: string;
status: ConnectionStatus;
connect(): Promise<boolean>;
reconnect(): Promise<boolean>;
close(): void;
}
interface WebSocketConstructor {
new (url: string | URL, protocols?: string | string[]): WebSocket;
}
export class ConnectionImpl
extends Emitter<{
connection: ConnectionStatus;
connecting: void;
connected: string;
disconnected: void;
reconnecting: void;
close: void;
}>
implements Connection
{
ssl: boolean;
url: string;
session?: string;
status: ConnectionStatus = 'idle';
ws: WebSocket | undefined;
retryCount = 0;
private connectPromise?: Promise<boolean>;
public queue = new Set<string>();
constructor(
url: string,
private WS: WebSocketConstructor,
private client: DDPClient,
readonly retryOptions: RetryOptions = { retryCount: 0, retryTime: 1000 },
) {
super();
this.ssl = url.startsWith('https') || url.startsWith('wss');
this.url = url.replace(/^https?:\/\//, '').replace(/^wss?:\/\//, '');
this.client.onDispatchMessage((message: string) => {
if (this.ws && this.ws.readyState === this.ws.OPEN) {
this.ws.send(message);
return;
}
this.queue.add(message);
});
this.on('connected', () => {
this.queue.forEach((message) => {
this.ws?.send(message);
});
this.queue.clear();
});
}
private emitStatus() {
this.emit('connection', this.status);
}
reconnect(): Promise<boolean> {
// Idempotent — if another caller already started (or finished) a connection
// since this reconnect was scheduled, we don't need to do anything. The
// retry timer enqueued by `ws.onclose` runs with no awareness of any
// concurrent `connect()` (e.g. the consumer's own bootstrap or
// resume-on-userId-change path), so without this guard a late timer
// rejected with "Connection in progress" — and because the timer fires
// from `void this.reconnect()` the rejection became an unhandled
// rejection at the page level.
if (this.status === 'connected') {
clearTimeout(this.retryOptions.retryTimer);
return Promise.resolve(true);
}
if (this.status === 'connecting') {
// Share the in-flight handshake promise so a `failed` payload
// later in the same attempt isn't masked by a synthesized success.
clearTimeout(this.retryOptions.retryTimer);
return this.connectPromise as Promise<boolean>;
}
clearTimeout(this.retryOptions.retryTimer);
this.emit('reconnecting');
this.emit('connection', 'reconnecting');
return this.connect();
}
connect() {
// Same idempotency guard as `reconnect()` — multiple call sites
// (`reconnect()`, ws.onclose retry timer, external `startConnect`) can
// race; rejecting forced every caller to wrap in `.catch(() => {})`
// just to silence noise, and the internal timer's `void this.reconnect()`
// path didn't have a catch at all.
if (this.status === 'connected') {
clearTimeout(this.retryOptions.retryTimer);
return Promise.resolve(true);
}
if (this.status === 'connecting') {
clearTimeout(this.retryOptions.retryTimer);
return this.connectPromise as Promise<boolean>;
}
this.status = 'connecting';
const ws = new this.WS(`${this.ssl ? 'wss://' : 'ws://'}${this.url}/websocket`);
this.ws = ws;
// Build the in-flight promise and publish it on `this.connectPromise`
// before emitting any status change, so a synchronous re-entrant
// caller (an event listener that calls `connect()`/`reconnect()`)
// hits the `'connecting'` guard and gets this same promise.
const connectPromise = new Promise<boolean>((resolve, reject) => {
ws.onopen = () => {
ws.onmessage = (event) => {
this.client.handleMessage(String(event.data));
};
// The server may send an initial message which is a JSON object lacking a msg key. If so, the client should ignore it. The client does not have to wait for this message.
// (The message was once used to help implement Meteor's hot code reload feature; it is now only included to force old clients to update).
// this.client.onceMessage((data) => {
// if (data.msg === undefined) {
// return;
// }
// if (data.msg === 'failed') {
// return;
// }
// if (data.msg === 'connected') {
// return;
// }
// this.close();
// });
// The client sends a connect message.
this.client.connect();
// If the server is willing to speak the version of the protocol specified in the connect message, it sends back a connected message.
// Otherwise the server sends back a failed message with a version of DDP it would rather speak, informed by the connect message's support field, and closes the underlying transport.
this.client.onConnection((payload) => {
if (payload.msg === 'connected') {
this.status = 'connected';
// Reset the retry budget on successful connection so a future
// disconnect can schedule reconnects again. Without this,
// long-lived connections that recover once would burn through
// `retryCount` permanently and stop reconnecting on subsequent
// drops — observed when a server-side ws.close (logout, force-
// logout) chained with a reconnect cycle saturated the
// budget; the next disconnect left frames stuck in the
// dispatcher queue forever because the socket never came back.
this.retryCount = 0;
this.emitStatus();
this.emit('connected', payload.session);
this.session = payload.session;
return resolve(true);
}
if (payload.msg === 'failed') {
this.status = 'failed';
this.emitStatus();
this.emit('disconnected');
return reject(payload.version);
}
/* istanbul ignore next */
reject(new Error('Unknown message type'));
});
};
ws.onclose = () => {
// If a newer ws has already taken over (this socket was closed
// after `connect()` opened a replacement), ignore the late
// onclose. Otherwise its handler would clobber `this.status` and
// `retryCount`, and could even schedule a redundant retry timer
// that fires while the new socket is healthy — observed as the
// "Connection in progress" pageError racing on every reconnect.
if (this.ws !== ws) {
return;
}
clearTimeout(this.retryOptions.retryTimer);
if (this.status === 'closed') {
return;
}
this.status = 'disconnected';
this.emitStatus();
this.emit('disconnected');
if (this.retryCount >= this.retryOptions.retryCount) {
return;
}
this.retryCount += 1;
this.retryOptions.retryTimer = setTimeout(() => {
// Re-check the status when the timer actually fires. If the
// consumer bootstrapped a fresh `connect()` in the meantime
// (status flipped from 'disconnected' to 'connecting' or
// 'connected'), there's nothing for us to do. Without this
// the timer would call `this.reconnect()`, which (pre-this
// patch) rejected with "Connection in progress" and surfaced
// as an unhandled rejection.
if (this.status === 'connecting' || this.status === 'connected' || this.status === 'closed') {
return;
}
void this.reconnect();
}, this.retryOptions.retryTime * this.retryCount);
};
});
this.connectPromise = connectPromise;
this.emit('connecting');
this.emitStatus();
return connectPromise;
}
close() {
this.status = 'closed';
this.ws?.close();
this.emitStatus();
}
static create(
url: string,
webSocketImpl: WebSocketConstructor,
client: DDPClient,
retryOptions: RetryOptions = { retryCount: 0, retryTime: 1000 },
): ConnectionImpl {
return new ConnectionImpl(url, webSocketImpl, client, retryOptions);
}
}

View File

@@ -0,0 +1,116 @@
/**
* A queue of ddp blocking methods that are waiting to be sent to the server.
*/
import { MinimalDDPClient } from './MinimalDDPClient';
import type { OutgoingPayload } from './types/OutgoingPayload';
import type { MethodPayload } from './types/methodsPayloads';
type Blocks = {
wait: boolean;
items: MethodPayload[];
};
type Queue = Blocks[];
export class DDPDispatcher extends MinimalDDPClient {
queue: Queue = [];
override dispatch(payload: OutgoingPayload, options?: { wait?: boolean }) {
// Only method payloads participate in the wait/queue serialization that
// implements Meteor's wait-method semantics. Protocol-level frames
// (connect, sub, unsub, ping, pong) must be delivered immediately:
// queueing a `connect` frame behind a wait `login` block, for example,
// wedges the DDP handshake — the socket opens but `connect` never
// reaches the server, so the server never replies `connected` and the
// session never establishes.
if (payload.msg !== 'method') {
this.emit('send', payload);
return;
}
if (options?.wait) {
this.wait(payload);
return;
}
this.pushItem(payload);
}
wait(block: MethodPayload) {
this.queue.push({
wait: true,
items: [block],
});
if (this.queue.length === 1) {
this.sendOutstandingBlocks();
}
}
private pushItem(item: MethodPayload) {
const block = this.tail();
if (!block || block.wait) {
this.queue.push({
wait: false,
items: [item],
});
}
if (!block) {
this.sendOutstandingBlocks();
return;
}
if (block.wait) {
return;
}
block.items.push(item);
}
private tail() {
return this.queue[this.queue.length - 1];
}
private sendOutstandingBlocks() {
const block = this.queue[0];
if (!block) {
return;
}
block.items.forEach((payload) => {
this.emit('send', payload);
});
if (block.wait) {
return;
}
this.queue.shift();
this.sendOutstandingBlocks();
}
removeItem(item: MethodPayload) {
const block = this.queue[0];
if (!block) {
return;
// throw new Error('No block to remove item from');
}
const index = block.items.indexOf(item);
if (index === -1) {
return;
// throw new Error('Item not found in block');
}
block.items.splice(index, 1);
if (block.items.length === 0) {
this.queue.shift();
this.sendOutstandingBlocks();
}
}
}

View File

@@ -0,0 +1,147 @@
import { RestClient } from '@rocket.chat/api-client';
import { ClientStreamImpl } from './ClientStream';
import type { Connection } from './Connection';
import { ConnectionImpl } from './Connection';
import { DDPDispatcher } from './DDPDispatcher';
import { TimeoutControl } from './TimeoutControl';
import type { Account } from './types/Account';
import { AccountImpl } from './types/Account';
import type { ClientStream } from './types/ClientStream';
import type { SDK } from './types/SDK';
interface PublicationPayloads {
collection: string;
id: string;
msg: 'added' | 'changed' | 'removed';
fields: {
eventName: string;
args: [unknown];
};
}
const isValidPayload = (data: unknown): data is PublicationPayloads => {
if (typeof data !== 'object' && (data !== null || data !== undefined)) {
return false;
}
return true;
};
export class DDPSDK implements SDK {
constructor(
readonly connection: Connection,
readonly client: ClientStream,
readonly account: Account,
readonly timeoutControl: TimeoutControl,
readonly rest: RestClient,
) {}
call(method: string, ...params: unknown[]) {
return this.client.callAsync(method, ...params);
}
stream(name: string, data: unknown, cb: (...data: PublicationPayloads['fields']['args']) => void) {
const [key, args] = Array.isArray(data) ? data : [data];
const subscription = this.client.subscribe(`stream-${name}`, key, { useCollection: false, args: [args] });
const stop = subscription.stop.bind(subscription);
const cancel = [
() => stop(),
this.client.onCollection(`stream-${name}`, (data) => {
if (!isValidPayload(data)) {
return;
}
if (data.collection !== `stream-${name}`) {
return;
}
if (data.msg !== 'changed') {
return;
}
if (data.fields.eventName === key) {
cb(...data.fields.args);
}
}),
];
return Object.assign(subscription, {
stop: () => {
cancel.forEach((fn) => fn());
},
});
}
/**
* Compounds the Objects responsible for the SDK and returns it through
* SDK interface
*
* @param url - The URL of the server to connect to
* @param retryOptions - The options for the retry strategy of the connection
* @param retryOptions.retryCount - The number of times to retry the connection
* @param retryOptions.retryTime - The time to wait between retries
* @returns The SDK interface
*
* @example
* ```ts
* const sdk = DDPSDK.create('wss://open.rocket.chat/websocket');
* sdk.connection.connect();
* ```
*/
static create(url: string, retryOptions = { retryCount: 1, retryTime: 100 }): DDPSDK {
const ddp = new DDPDispatcher();
const connection = ConnectionImpl.create(url, WebSocket, ddp, retryOptions);
const stream = new ClientStreamImpl(ddp, ddp);
const account = new AccountImpl(stream);
const timeoutControl = TimeoutControl.create(ddp, connection);
const rest = new (class RestApiClient extends RestClient {
override getCredentials() {
if (!account.uid || !account.user?.token) {
return;
}
return {
'X-User-Id': account.uid,
'X-Auth-Token': account.user.token,
};
}
})({ baseUrl: url });
const sdk = new DDPSDK(connection, stream, account, timeoutControl, rest);
connection.on('connected', () => {
if (account.user?.token) {
void account.loginWithToken(account.user.token);
}
[...stream.subscriptions.entries()].forEach(([, sub]) => {
ddp.subscribeWithId(sub.id, sub.name, sub.params);
});
});
return sdk;
}
/**
* Same as `DDPSDK.create`, but also connects to the server and waits for the connection to be established
* @param url - The URL of the server to connect to
* @param retryOptions - The options for the retry strategy of the connection
* @param retryOptions.retryCount - The number of times to retry the connection
* @param retryOptions.retryTime - The time to wait between retries
* @returns A promise that resolves to the SDK interface
* @example
* ```ts
* const sdk = await DDPSDK.createAndConnect('wss://open.rocket.chat/websocket');
* ```
*/
static async createAndConnect(url: string, retryOptions = { retryCount: 1, retryTime: 100 }): Promise<DDPSDK> {
const sdk = DDPSDK.create(url, retryOptions);
await sdk.connection.connect();
return sdk;
}
}

View File

@@ -0,0 +1,241 @@
import { Emitter } from '@rocket.chat/emitter';
import type { DDPClient } from './types/DDPClient';
import type { IncomingPayload } from './types/IncomingPayload';
import type { OutgoingPayload } from './types/OutgoingPayload';
import type { RemoveListener } from './types/RemoveListener';
import type { ConnectedPayload, ConnectPayload, FailedPayload } from './types/connectionPayloads';
import type { PongPayload } from './types/heartbeatsPayloads';
import type { MethodPayload, ResultPayload, UpdatedPayload } from './types/methodsPayloads';
import type {
AddedPayload,
ChangedPayload,
NosubPayload,
PublicationPayloads,
ReadyPayload,
RemovedPayload,
ServerPublicationPayloads,
SubscribePayload,
UnsubscribePayload,
} from './types/publicationPayloads';
/**
* Creates a unique id for a DDP client.
* @returns {string} - A unique id for a DDP client.
*/
const getUniqueId = (() => {
let id = 0;
return () => `rc-ddp-client-${++id}`;
})();
const SUPPORTED_DDP_VERSIONS = ['1', 'pre2', 'pre1'];
export interface DDPDispatchOptions {
wait?: boolean;
}
interface MinimalDDPClientEvents {
pong: PongPayload;
connection: ConnectedPayload | FailedPayload;
message: IncomingPayload;
send: OutgoingPayload;
[x: `publication/${string}`]: NosubPayload | ReadyPayload;
[x: `nosub/${string}`]: NosubPayload;
[x: `collection/${string}`]: AddedPayload | ChangedPayload | RemovedPayload;
[x: `result/${string}`]: ResultPayload;
[x: `updated/${string}`]: UpdatedPayload;
}
/**
* This class was created to be used together with the `WebSocket` class.
* It is responsible for low-level communication with the server.
* It is responsible for sending and receiving messages.
* It does not provide any procedures for connecting or reconnecting.
* It does not provide any procedure to handle collections.
* @example
* ```ts
* const socket = new WebSocket('ws://localhost:3000/websocket');
* const ddp = new MinimalDDPClient(socket.send.bind(socket));
* ddp.on('message', (data) => {
* console.log('Received message', data);
* });
* socket.onmessage = ({ data }) => {
* ddp.handleMessage(data);
* };
* ```
*/
export class MinimalDDPClient extends Emitter<MinimalDDPClientEvents> implements DDPClient {
constructor(
send?: (params: string) => void,
readonly encode = JSON.stringify,
readonly decode = JSON.parse,
) {
super();
if (send) {
this.onDispatchMessage(send);
}
}
/**
* @remarks
* if the received message is a valid DDP message, it will be emitted as an event.
*
* @param payload - The incoming message as a string.
* @throws {Error} - If the message is not a string.
* @throws {Error} - If the message is not a valid JSON.
*/
handleMessage(payload: string): void {
const data = this.decode(payload) as IncomingPayload;
Object.freeze(data);
switch (data.msg) {
case 'ping':
this.pong(data.id);
break;
case 'pong':
this.emit('pong', data);
break;
// Streamer related messages
case 'nosub':
this.emit(`publication/${data.id}`, data);
this.emit(`nosub/${data.id}`, data);
break;
case 'ready':
data.subs.forEach((id) => {
// this.emit(`publication/${id}`, { msg: 'ready', id });
this.emit(`publication/${id}`, { msg: 'ready', subs: [id] });
});
break;
case 'added':
case 'changed':
case 'removed':
this.emit(`collection/${data.collection}`, data);
break;
// DDP/RCP related messages
case 'result':
this.emit(`result/${data.id}`, data);
break;
case 'updated':
data.methods.forEach((id) => {
this.emit(`updated/${id}`, data);
});
break;
case 'connected':
case 'failed':
this.emit('connection', data);
break;
}
this.emit('message', data);
}
onDispatchMessage(callback: (msg: string) => void): RemoveListener {
return this.on('send', (payload) => {
callback(this.encode(payload));
});
}
protected dispatch(payload: OutgoingPayload): void {
this.emit('send', payload);
}
call(method: string, params: any[] = []) {
const id = getUniqueId();
const payload: MethodPayload = {
msg: 'method',
method,
params,
id,
};
return payload;
}
subscribe(name: string, params?: any[]): string {
const id = getUniqueId();
return this.subscribeWithId(id, name, params);
}
subscribeWithId(id: string, name: string, params?: any[]): string {
const payload: SubscribePayload = {
msg: 'sub',
id,
name,
params,
};
this.dispatch(payload);
return id;
}
unsubscribe(id: string): void {
const payload: UnsubscribePayload = {
msg: 'unsub',
id,
};
this.dispatch(payload);
}
connect(): void {
const payload: ConnectPayload = {
msg: 'connect',
version: '1',
support: SUPPORTED_DDP_VERSIONS,
};
this.dispatch(payload);
}
onPublish(name: string, callback: (payload: ServerPublicationPayloads) => void): RemoveListener {
return this.once(`publication/${name}`, callback);
}
onResult(id: string, callback: (payload: ResultPayload) => void): RemoveListener {
return this.once(`result/${id}`, callback);
}
onUpdate(id: string, callback: (payload: UpdatedPayload) => void): RemoveListener {
return this.on(`updated/${id}`, callback);
}
onNoSub(id: string, callback: (payload: NosubPayload) => void): RemoveListener {
return this.once(`nosub/${id}`, callback);
}
onCollection(name: string, callback: (payload: PublicationPayloads) => void): RemoveListener {
return this.on(`collection/${name}`, callback);
}
onConnection(callback: (payload: ConnectedPayload | FailedPayload) => void): RemoveListener {
return this.once('connection', callback);
}
onceMessage(callback: (payload: IncomingPayload) => void): RemoveListener {
return this.once('message', callback);
}
onMessage(callback: (payload: IncomingPayload) => void): RemoveListener {
return this.on('message', callback);
}
ping(id?: string): void {
this.dispatch({
msg: 'ping',
...(id && { id }),
});
}
private pong(id?: string): void {
this.dispatch({
msg: 'pong',
...(id && { id }),
});
}
}

View File

@@ -0,0 +1,85 @@
import { Emitter } from '@rocket.chat/emitter';
import type { Connection } from './Connection';
import type { DDPClient } from './types/DDPClient';
export interface TimeoutControlEvents extends Emitter<{
timeout: void;
heartbeat: void;
}> {
reset(): void;
stop(): void;
readonly timeout: number;
readonly heartbeat: number;
}
export class TimeoutControl
extends Emitter<{
timeout: void;
heartbeat: void;
}>
implements TimeoutControlEvents
{
private timeoutId: ReturnType<typeof setTimeout> | undefined;
private heartbeatId: ReturnType<typeof setTimeout> | undefined;
constructor(
readonly timeout: number = 60_000,
readonly heartbeat: number = timeout / 2,
) {
super();
/* istanbul ignore next */
if (this.heartbeat >= this.timeout) {
throw new Error('Heartbeat must be less than timeout');
}
}
reset() {
if (this.timeoutId) {
clearTimeout(this.timeoutId);
}
if (this.heartbeatId) {
clearTimeout(this.heartbeatId);
}
this.timeoutId = setTimeout(() => this.emit('timeout'), this.timeout);
this.heartbeatId = setTimeout(() => this.emit('heartbeat'), this.heartbeat);
}
stop() {
if (this.timeoutId) {
clearTimeout(this.timeoutId);
}
if (this.heartbeatId) {
clearTimeout(this.heartbeatId);
}
}
static create(ddp: DDPClient, connection: Connection, timeout?: number, heartbeat?: number): TimeoutControl {
const timeoutControl = new TimeoutControl(timeout, heartbeat);
timeoutControl.on('heartbeat', () => {
ddp.ping();
});
timeoutControl.on('timeout', () => {
connection.close();
});
ddp.onMessage(() => timeoutControl.reset());
connection.on('close', () => {
timeoutControl.stop();
});
connection.on('disconnected', () => {
timeoutControl.stop();
});
connection.on('connected', () => {
timeoutControl.reset();
});
return timeoutControl;
}
}

View File

@@ -0,0 +1,9 @@
export * from './DDPSDK';
export * from './legacy/RocketchatSDKLegacy';
export * from './livechat/LivechatClientImpl';
export type * from './livechat/types/LivechatSDK';
export type * from './types/ClientStream';
export type * from './types/methods';
export type * from './types/streams';
export type { SDK } from './types/SDK';
export type { ConnectionStatus } from './Connection';

View File

@@ -0,0 +1,287 @@
/* eslint-disable @typescript-eslint/no-this-alias */
import { RestClient } from '@rocket.chat/api-client';
import type { IMessage, Serialized } from '@rocket.chat/core-typings';
import { Emitter } from '@rocket.chat/emitter';
import type { OperationParams, OperationResult } from '@rocket.chat/rest-typings';
import { ClientStreamImpl } from '../ClientStream';
import { ConnectionImpl } from '../Connection';
import { DDPDispatcher } from '../DDPDispatcher';
import { DDPSDK } from '../DDPSDK';
import { TimeoutControl } from '../TimeoutControl';
import { AccountImpl } from '../types/Account';
import type { ClientStream } from '../types/ClientStream';
import type { DDPDispatchOptions } from '../types/DDPClient';
import type { ServerMethodReturn, ServerMethods } from '../types/methods';
import type { StreamNames, StreamKeys, StreamerCallbackArgs } from '../types/streams';
import type {
APILegacy,
DPPLegacy,
RocketchatSdkLegacyEvents,
RocketchatSdkLegacyEventsKeys,
RocketchatSdkLegacyEventsValues,
} from './types/SDKLegacy';
declare module '../ClientStream' {
interface ClientStream {
callAsync<MethodName extends keyof ServerMethods>(
methodName: MethodName,
...params: Parameters<ServerMethods[MethodName]>
): Promise<ServerMethodReturn<MethodName>>;
callAsyncWithOptions<MethodName extends keyof ServerMethods>(
methodName: MethodName,
options: DDPDispatchOptions,
...params: Parameters<ServerMethods[MethodName]>
): Promise<ServerMethodReturn<MethodName>>;
}
}
declare module '../types/SDK' {
interface SDK {
stream<N extends StreamNames, K extends StreamKeys<N>>(
streamName: N,
key: K,
callback: (...args: StreamerCallbackArgs<N, K>) => void,
): ReturnType<ClientStream['subscribe']>;
}
}
interface RocketchatSDKLegacy extends APILegacy, DPPLegacy {}
export class RocketchatSdkLegacyImpl extends DDPSDK implements RocketchatSDKLegacy {
private ev = new Emitter<RocketchatSdkLegacyEvents>();
get url(): string {
return this.connection.url;
}
get users() {
const self = this;
return {
all(fields?: { name: 1; username: 1; status: 1; type: 1 }): Promise<Serialized<OperationResult<'GET', '/v1/users.list'>>> {
return self.rest.get('/v1/users.list', { fields: JSON.stringify(fields) });
},
allNames(): Promise<Serialized<OperationResult<'GET', '/v1/users.list'>>> {
return self.rest.get('/v1/users.list', { fields: JSON.stringify({ name: 1 }) });
},
allIDs(): Promise<Serialized<OperationResult<'GET', '/v1/users.list'>>> {
return self.rest.get('/v1/users.list', { fields: JSON.stringify({ _id: 1 }) });
},
online(fields?: { name: 1; username: 1; status: 1; type: 1 }): Promise<Serialized<OperationResult<'GET', '/v1/users.list'>>> {
return self.rest.get('/v1/users.list', { fields: JSON.stringify(fields), query: JSON.stringify({ status: { $ne: 'offline' } }) });
},
onlineNames(): Promise<Serialized<OperationResult<'GET', '/v1/users.list'>>> {
return self.rest.get('/v1/users.list', {
fields: JSON.stringify({ name: 1 }),
query: JSON.stringify({ status: { $ne: 'offline' } }),
});
},
onlineIds(): Promise<Serialized<OperationResult<'GET', '/v1/users.list'>>> {
return self.rest.get('/v1/users.list', {
fields: JSON.stringify({ _id: 1 }),
query: JSON.stringify({ status: { $ne: 'offline' } }),
});
},
info(username: string): Promise<Serialized<OperationResult<'GET', '/v1/users.info'>>> {
return self.rest.get('/v1/users.info', { username });
},
};
}
get rooms() {
const self = this;
return {
info: (
args:
| {
roomId: string;
}
| {
roomName: string;
},
): Promise<Serialized<OperationResult<'GET', '/v1/rooms.info'>>> => {
return self.rest.get('/v1/rooms.info', args);
},
join: (rid: string): Promise<Serialized<OperationResult<'POST', '/v1/channels.join'>>> => {
return self.rest.post('/v1/channels.join', { roomId: rid });
},
load: (rid: string, lastUpdate: Date): Promise<Serialized<OperationResult<'GET', '/v1/chat.syncMessages'>>> => {
return self.rest.get('/v1/chat.syncMessages', { roomId: rid, lastUpdate: lastUpdate.toISOString() });
},
leave: (rid: string): Promise<Serialized<OperationResult<'POST', '/v1/channels.leave'>>> => {
return self.rest.post('/v1/channels.leave', { roomId: rid });
},
};
}
joinRoom(args: { rid: string }): Promise<Serialized<OperationResult<'POST', '/v1/channels.join'>>> {
return this.rest.post('/v1/channels.join', { roomId: args.rid });
}
loadHistory(rid: string, lastUpdate: Date): Promise<Serialized<OperationResult<'GET', '/v1/chat.syncMessages'>>> {
return this.rest.get('/v1/chat.syncMessages', { roomId: rid, lastUpdate: lastUpdate.toISOString() });
}
leaveRoom(rid: string): Promise<Serialized<OperationResult<'POST', '/v1/channels.leave'>>> {
return this.rest.post('/v1/channels.leave', { roomId: rid });
}
get dm() {
const self = this;
return {
create(username: string): Promise<Serialized<OperationResult<'POST', '/v1/im.create'>>> {
return self.rest.post('/v1/im.create', { username });
},
};
}
channelInfo(args: { roomName: string } | { roomId: string }): Promise<Serialized<OperationResult<'GET', '/v1/channels.info'>>> {
return this.rest.get('/v1/channels.info', args);
}
privateInfo(args: { roomName: string } | { roomId: string }): Promise<Serialized<OperationResult<'GET', '/v1/groups.info'>>> {
return this.rest.get('/v1/groups.info', args);
}
editMessage(args: OperationParams<'POST', '/v1/chat.update'>): Promise<Serialized<OperationResult<'POST', '/v1/chat.update'>>> {
return this.rest.post('/v1/chat.update', args);
}
setReaction(emoji: string, messageId: string): Promise<Serialized<OperationResult<'POST', '/v1/chat.react'>>> {
return this.rest.post('/v1/chat.react', { emoji, messageId });
}
createDirectMessage(username: string): Promise<Serialized<OperationResult<'POST', '/v1/im.create'>>> {
return this.rest.post('/v1/im.create', { username });
}
sendMessage(message: IMessage | string, rid: string): Promise<Serialized<OperationResult<'POST', '/v1/chat.sendMessage'>>> {
return this.rest.post('/v1/chat.sendMessage', {
message:
typeof message === 'string'
? {
msg: message,
rid,
}
: {
...message,
rid,
},
});
}
resume({ token }: { token: string }): Promise<unknown> {
return this.account.loginWithToken(token);
}
login(credentials: { username: string; password: string }): Promise<unknown> {
return this.account.loginWithPassword(credentials.username, credentials.password);
}
onMessage(cb: (data: any) => void) {
return this.ev.on('message', cb);
}
methodCall(method: string, ...args: any[]) {
return this.client.callAsync(method, ...args);
}
subscribe(topic: string, ...args: any[]) {
return this.client.subscribe(topic, ...args).ready();
}
subscribeRoom(rid: string): Promise<unknown> {
return Promise.all([
this.stream('room-messages', rid, (...args) => this.ev.emit('message', args)),
this.stream('notify-room', `${rid}/typing`, (...args) => this.ev.emit('typing', args)),
this.stream('notify-room', `${rid}/deleteMessage`, (...args) => this.ev.emit('deleteMessage', args)),
]);
}
subscribeNotifyAll(): Promise<any> {
return Promise.all([
// this.stream('notify-all', 'roles-change', (...args) => this.ev.emit('roles-change', args)),
// this.stream('notify-all', 'updateEmojiCustom', (...args) => this.ev.emit('updateEmojiCustom', args)),
// this.stream('notify-all', 'deleteEmojiCustom', (...args) => this.ev.emit('deleteEmojiCustom', args)),
// this.stream('notify-all', 'updateAvatar', (...args) => this.ev.emit('updateAvatar', args)),
this.stream('notify-all', 'public-settings-changed', (...args) => this.ev.emit('public-settings-changed', args)),
]);
}
subscribeLoggedNotify(): Promise<any> {
return Promise.all([
this.stream('notify-logged', 'Users:NameChanged', (...args) => this.ev.emit('Users:NameChanged', args)),
this.stream('notify-logged', 'Users:Deleted', (...args) => this.ev.emit('Users:Deleted', args)),
this.stream('notify-logged', 'updateAvatar', (...args) => this.ev.emit('updateAvatar', args)),
this.stream('notify-logged', 'updateEmojiCustom', (...args) => this.ev.emit('updateEmojiCustom', args)),
this.stream('notify-logged', 'deleteEmojiCustom', (...args) => this.ev.emit('deleteEmojiCustom', args)),
this.stream('notify-logged', 'roles-change', (...args) => this.ev.emit('roles-change', args)),
this.stream('notify-logged', 'permissions-changed', (...args) => this.ev.emit('permissions-changed', args)),
]);
}
subscribeNotifyUser = (): Promise<any> => {
return Promise.all([
this.stream('notify-user', `${this.account.uid}/message`, (...args) => this.ev.emit('user-message', args)),
this.stream('notify-user', `${this.account.uid}/notification`, (...args) => this.ev.emit('notification', args)),
this.stream('notify-user', `${this.account.uid}/rooms-changed`, (...args) => this.ev.emit('rooms-changed', args)),
this.stream('notify-user', `${this.account.uid}/subscriptions-changed`, (...args) => this.ev.emit('subscriptions-changed', args)),
this.stream('notify-user', `${this.account.uid}/uiInteraction`, (...args) => this.ev.emit('uiInteraction', args)),
]);
};
onStreamData<E extends RocketchatSdkLegacyEventsKeys>(event: E, cb: (...data: RocketchatSdkLegacyEventsValues<E>) => void): () => void {
return this.ev.on(event, cb as any);
}
async disconnect(): Promise<unknown> {
return this.connection.close();
}
connect(_options: { useSsl: boolean; host: string; port: number }) {
return this.connection.connect();
}
unsubscribe(subscription: string): Promise<unknown> {
return this.client.unsubscribe(subscription);
}
unsubscribeAll(): Promise<unknown> {
return Promise.all(Object.entries(this.client.subscriptions).map(([, subscription]) => this.client.unsubscribe(subscription)));
}
static override create(url: string, retryOptions = { retryCount: 1, retryTime: 100 }): RocketchatSdkLegacyImpl {
const ddp = new DDPDispatcher();
const connection = ConnectionImpl.create(url, WebSocket, ddp, retryOptions);
const stream = new ClientStreamImpl(ddp, ddp);
const account = new AccountImpl(stream);
const timeoutControl = TimeoutControl.create(ddp, connection);
const rest = new (class RestApiClient extends RestClient {
override getCredentials() {
if (!account.uid || !account.user?.token) {
return;
}
return {
'X-User-Id': account.uid,
'X-Auth-Token': account.user?.token,
};
}
})({ baseUrl: url });
const sdk = new RocketchatSdkLegacyImpl(connection, stream, account, timeoutControl, rest);
connection.on('connected', () => {
Object.entries(stream.subscriptions).forEach(([, sub]) => {
ddp.subscribeWithId(sub.id, sub.name, sub.params);
});
});
return sdk;
}
}

View File

@@ -0,0 +1,98 @@
import type { IMessage, Serialized } from '@rocket.chat/core-typings';
import type { OperationParams, OperationResult } from '@rocket.chat/rest-typings';
import type { StreamerCallbackArgs } from '../../types/streams';
export interface APILegacy {
users: {
all(fields?: { name: 1; username: 1; status: 1; type: 1 }): Promise<Serialized<OperationResult<'GET', '/v1/users.list'>>>;
allNames(): Promise<Serialized<OperationResult<'GET', '/v1/users.list'>>>;
allIDs(): Promise<Serialized<OperationResult<'GET', '/v1/users.list'>>>;
online(fields?: { name: 1; username: 1; status: 1; type: 1 }): Promise<Serialized<OperationResult<'GET', '/v1/users.list'>>>;
onlineNames(): Promise<Serialized<OperationResult<'GET', '/v1/users.list'>>>;
onlineIds(): Promise<Serialized<OperationResult<'GET', '/v1/users.list'>>>;
info(username: string): Promise<Serialized<OperationResult<'GET', '/v1/users.info'>>>;
};
rooms: {
info(args: { roomName: string } | { roomId: string }): Promise<Serialized<OperationResult<'GET', '/v1/rooms.info'>>>;
join(rid: string): Promise<Serialized<OperationResult<'POST', '/v1/channels.join'>>>;
load(rid: string, lastUpdate: Date): Promise<Serialized<OperationResult<'GET', '/v1/chat.syncMessages'>>>;
leave(rid: string): Promise<Serialized<OperationResult<'POST', '/v1/channels.leave'>>>;
};
joinRoom(args: { rid: string }): Promise<Serialized<OperationResult<'POST', '/v1/channels.join'>>>;
loadHistory(rid: string, lastUpdate: Date): Promise<Serialized<OperationResult<'GET', '/v1/chat.syncMessages'>>>;
leaveRoom(rid: string): Promise<Serialized<OperationResult<'POST', '/v1/channels.leave'>>>;
dm: {
create(username: string): Promise<Serialized<OperationResult<'POST', '/v1/im.create'>>>;
};
createDirectMessage(username: string): Promise<Serialized<OperationResult<'POST', '/v1/im.create'>>>;
sendMessage(message: IMessage | string, rid: string): Promise<Serialized<OperationResult<'POST', '/v1/chat.sendMessage'>>>;
// getRoomIdByNameOrId(name: string): Promise<Serialized<OperationResult<'GET', '/v1/chat.getRoomIdByNameOrId'>>>;
// getRoomNameById(rid: string): Promise<Serialized<OperationResult<'GET', '/v1/chat.getRoomNameById'>>>;
// getRoomName(rid: string): Promise<Serialized<OperationResult<'GET', '/v1/chat.getRoomNameById'>>>;
// getRoomId(name: string): Promise<Serialized<OperationResult<'GET', '/v1/chat.find'>>>;
editMessage(args: OperationParams<'POST', '/v1/chat.update'>): Promise<Serialized<OperationResult<'POST', '/v1/chat.update'>>>;
setReaction(emoji: string, messageId: string): Promise<Serialized<OperationResult<'POST', '/v1/chat.react'>>>;
channelInfo(args: { roomName: string } | { roomId: string }): Promise<Serialized<OperationResult<'GET', '/v1/channels.info'>>>;
privateInfo(args: { roomName: string } | { roomId: string }): Promise<Serialized<OperationResult<'GET', '/v1/groups.info'>>>;
}
export interface DPPLegacy {
resume({ token }: { token: string }): Promise<unknown>;
login(credentials: { username: string; password: string }): Promise<unknown>;
connect(options: { useSsl: boolean; host: string; port: number }): Promise<unknown>;
disconnect(): Promise<unknown>;
onStreamData<E extends RocketchatSdkLegacyEventsKeys>(event: E, cb: (...data: RocketchatSdkLegacyEventsValues<E>) => void): () => void;
subscribe(topic: string, ...args: any[]): Promise<unknown>;
unsubscribe(subscription: unknown): Promise<unknown>;
unsubscribeAll(): Promise<unknown>;
subscribeRoom(rid: string, ...args: any[]): Promise<unknown>;
subscribeNotifyAll(): Promise<unknown>;
subscribeLoggedNotify(): Promise<unknown>;
subscribeNotifyUser(): Promise<unknown>;
url: string;
onMessage(cb: (data: any) => void): () => void;
methodCall(method: string, ...args: any[]): Promise<unknown>;
}
export type RocketchatSdkLegacyEvents = {
'message': StreamerCallbackArgs<'room-messages', string>;
'typing': StreamerCallbackArgs<'notify-room', `${string}/typing`>;
'deleteMessage': StreamerCallbackArgs<'notify-room', `${string}/deleteMessage`>;
'roles-change': StreamerCallbackArgs<'notify-logged', 'roles-change'>;
'updateEmojiCustom': StreamerCallbackArgs<'notify-logged', 'updateEmojiCustom'>;
'deleteEmojiCustom': StreamerCallbackArgs<'notify-logged', 'deleteEmojiCustom'>;
'public-settings-changed': StreamerCallbackArgs<'notify-all', 'public-settings-changed'>;
'permissions-changed': StreamerCallbackArgs<'notify-logged', 'permissions-changed'>;
'Users:NameChanged': StreamerCallbackArgs<'notify-logged', 'Users:NameChanged'>;
'Users:Deleted': StreamerCallbackArgs<'notify-logged', 'Users:Deleted'>;
'updateAvatar': StreamerCallbackArgs<'notify-logged', 'updateAvatar'>;
'user-message': StreamerCallbackArgs<'notify-user', `${string}/message`>;
'notification': StreamerCallbackArgs<'notify-user', `${string}/notification`>;
'rooms-changed': StreamerCallbackArgs<'notify-user', `${string}/rooms-changed`>;
'subscriptions-changed': StreamerCallbackArgs<'notify-user', `${string}/subscriptions-changed`>;
'uiInteraction': StreamerCallbackArgs<'notify-user', `${string}/uiInteraction`>;
};
export type RocketchatSdkLegacyEventsKeys = keyof RocketchatSdkLegacyEvents;
export type RocketchatSdkLegacyEventsValues<E extends RocketchatSdkLegacyEventsKeys> = RocketchatSdkLegacyEvents[E];

View File

@@ -0,0 +1,380 @@
import { RestClient } from '@rocket.chat/api-client';
import type { IOmnichannelRoom, Serialized } from '@rocket.chat/core-typings';
import { Emitter } from '@rocket.chat/emitter';
import type { OperationParams, OperationResult } from '@rocket.chat/rest-typings';
import { ClientStreamImpl } from '../ClientStream';
import { ConnectionImpl } from '../Connection';
import { DDPDispatcher } from '../DDPDispatcher';
import { DDPSDK } from '../DDPSDK';
import { TimeoutControl } from '../TimeoutControl';
import { AccountImpl } from '../types/Account';
import type { ClientStream } from '../types/ClientStream';
import type { DDPDispatchOptions } from '../types/DDPClient';
import type { ServerMethodReturn, ServerMethods } from '../types/methods';
import type { StreamKeys, StreamNames, StreamerCallbackArgs } from '../types/streams';
import type { LivechatEndpoints, LivechatRoomEvents, LivechatStream } from './types/LivechatSDK';
declare module '../ClientStream' {
interface ClientStream {
callAsync<MethodName extends keyof ServerMethods>(
methodName: MethodName,
...params: Parameters<ServerMethods[MethodName]>
): Promise<ServerMethodReturn<MethodName>>;
callAsyncWithOptions<MethodName extends keyof ServerMethods>(
methodName: MethodName,
options: DDPDispatchOptions,
...params: Parameters<ServerMethods[MethodName]>
): Promise<ServerMethodReturn<MethodName>>;
}
}
declare module '../DDPSDK' {
interface DDPSDK {
stream<N extends StreamNames, K extends StreamKeys<N>>(
streamName: N,
data: K | [K, unknown],
callback: (...args: StreamerCallbackArgs<N, K>) => void,
): ReturnType<ClientStream['subscribe']>;
}
}
export class LivechatClientImpl extends DDPSDK implements LivechatStream, LivechatEndpoints {
public token?: string;
public readonly credentials: { token?: string } = { token: this.token };
private ev = new Emitter<{
userActivity: StreamerCallbackArgs<'notify-room', `${string}/user-activity`>;
message: StreamerCallbackArgs<'room-messages', string>;
delete: StreamerCallbackArgs<'notify-room', `${string}/deleteMessage`>;
}>();
subscribeRoom(rid: string) {
return Promise.all([
this.onRoomMessage(rid, (...args) => this.ev.emit('message', args)),
this.onRoomUserActivity(rid, (...args) => this.ev.emit('userActivity', args)),
this.onRoomDeleteMessage(rid, (...args) => this.ev.emit('delete', args)),
]);
}
onMessage(cb: (...args: StreamerCallbackArgs<'room-messages', string>) => void): () => void {
return this.ev.on('message', (args) => cb(...args));
}
onUserActivity(cb: (username: string, events: string[]) => void): () => void {
return this.ev.on('userActivity', (args) => args[1] && cb(args[0], args[1]));
}
onRoomMessage(rid: string, cb: (...args: StreamerCallbackArgs<'room-messages', string>) => void) {
return this.stream('room-messages', [rid, { token: this.token, visitorToken: this.token }], cb).stop;
}
onRoomUserActivity(rid: string, cb: (...args: StreamerCallbackArgs<'notify-room', `${string}/user-activity`>) => void) {
return this.stream('notify-room', [`${rid}/user-activity`, { token: this.token, visitorToken: this.token }], cb).stop;
}
onRoomDeleteMessage(rid: string, cb: (...args: StreamerCallbackArgs<'notify-room', `${string}/deleteMessage`>) => void) {
return this.stream('notify-room', [`${rid}/deleteMessage`, { token: this.token, visitorToken: this.token }], cb).stop;
}
onAgentChange(rid: string, cb: (data: LivechatRoomEvents<'agentData'>) => void): () => void {
return this.stream('livechat-room', [rid, { token: this.token, visitorToken: this.token }], (data) => {
if (data.type === 'agentData') {
cb(data.data);
}
}).stop;
}
onAgentStatusChange(rid: string, cb: (data: LivechatRoomEvents<'agentStatus'>) => void): () => void {
return this.stream('livechat-room', [rid, { token: this.token, visitorToken: this.token }], (data) => {
if (data.type === 'agentStatus') {
cb(data.status);
}
}).stop;
}
onQueuePositionChange(rid: string, cb: (data: LivechatRoomEvents<'queueData' | 'agentData'>) => void): () => void {
return this.stream('livechat-room', rid, (data) => {
if (data.type === 'queueData') {
cb(data.data);
}
}).stop;
}
onVisitorChange(rid: string, cb: (data: LivechatRoomEvents<'visitorData'>) => void): () => void {
return this.stream('livechat-room', [rid, { token: this.token, visitorToken: this.token }], (data) => {
if (data.type === 'visitorData') {
cb(data.visitor);
}
}).stop;
}
notifyVisitorActivity(rid: string, username: string, activity: string[]) {
return this.client.callAsync('stream-notify-room', `${rid}/user-activity`, username, activity, { token: this.token });
}
notifyCallDeclined(rid: string) {
return this.client.callAsync('stream-notify-room', `${rid}/call`, 'decline');
}
// API GETTERS
async config(
params: OperationParams<'GET', '/v1/livechat/config'>,
): Promise<Serialized<OperationResult<'GET', '/v1/livechat/config'>['config']>> {
const { config } = await this.rest.get('/v1/livechat/config', params);
return config;
}
async room(params: OperationParams<'GET', '/v1/livechat/room'>): Promise<Serialized<IOmnichannelRoom>> {
if (!this.token) {
throw new Error('Invalid token');
}
const result = await this.rest.get('/v1/livechat/room', { ...params, token: this.token });
// TODO: On major version bump, normalize the return of /v1/livechat/room
function isRoomObject(
room: Serialized<IOmnichannelRoom> | { room: Serialized<IOmnichannelRoom> },
): room is { room: Serialized<IOmnichannelRoom> } {
return (room as { room: Serialized<IOmnichannelRoom> }).room !== undefined;
}
if (isRoomObject(result)) {
return result.room;
}
return result;
}
visitor(
params: OperationParams<'GET', '/v1/livechat/visitor'>,
): Promise<Serialized<OperationResult<'GET', '/v1/livechat/visitor'>['visitor']>> {
if (!this.token) {
throw new Error('Invalid token');
}
const endpoint = `/v1/livechat/visitor/${this.token}`;
return this.rest.get(endpoint as '/v1/livechat/visitor/:token', params);
}
nextAgent(
params: OperationParams<'GET', '/v1/livechat/agent.next/:token'>,
): Promise<Serialized<OperationResult<'GET', '/v1/livechat/agent.next/:token'>>> {
if (!this.token) {
throw new Error('Invalid token');
}
return this.rest.get(`/v1/livechat/agent.next/${this.token}`, params);
}
async agent(rid: string): Promise<Serialized<OperationResult<'GET', '/v1/livechat/agent.info/:rid/:token'>['agent']>> {
if (!this.token) {
throw new Error('Invalid token');
}
const { agent } = await this.rest.get(`/v1/livechat/agent.info/${rid}/${this.token}`);
return agent;
}
message(
id: string,
params: OperationParams<'GET', '/v1/livechat/message/:_id'>,
): Promise<Serialized<OperationResult<'GET', '/v1/livechat/message/:_id'>>> {
if (!this.token) {
throw new Error('Invalid token');
}
return this.rest.get(`/v1/livechat/message/${id}`, { ...params, token: this.token });
}
async loadMessages(
rid: string,
params: OperationParams<'GET', '/v1/livechat/messages.history/:rid'>,
): Promise<Serialized<OperationResult<'GET', '/v1/livechat/messages.history/:rid'>['messages']>> {
if (!this.token) {
throw new Error('Invalid token');
}
const { messages } = await this.rest.get(`/v1/livechat/messages.history/${rid}`, { ...params, token: this.token });
return messages;
}
// API POST
transferChat({
rid,
department,
}: {
rid: string;
department: string;
}): Promise<Serialized<OperationResult<'POST', '/v1/livechat/visitor/department.transfer'>>> {
if (!this.token) {
throw new Error('Invalid token');
}
return this.rest.post('/v1/livechat/visitor/department.transfer', { rid, token: this.token, department });
}
async grantVisitor(
guest: OperationParams<'POST', '/v1/livechat/visitor'>,
): Promise<Serialized<OperationResult<'POST', '/v1/livechat/visitor'>>> {
const result = await this.rest.post('/v1/livechat/visitor', guest);
this.token = result?.visitor.token;
return result;
}
login(guest: OperationParams<'POST', '/v1/livechat/visitor'>) {
return this.grantVisitor(guest);
}
closeChat({ rid }: { rid: string }): Promise<Serialized<OperationResult<'POST', '/v1/livechat/room.close'>>> {
if (!this.token) {
throw new Error('Invalid token');
}
return this.rest.post('/v1/livechat/room.close', { rid, token: this.token });
}
chatSurvey(
params: OperationParams<'POST', '/v1/livechat/room.survey'>,
): Promise<Serialized<OperationResult<'POST', '/v1/livechat/room.survey'>>> {
if (!this.token) {
throw new Error('Invalid token');
}
return this.rest.post('/v1/livechat/room.survey', { rid: params.rid, token: this.token, data: params.data });
}
sendMessage(
params: OperationParams<'POST', '/v1/livechat/message'>,
): Promise<Serialized<OperationResult<'POST', '/v1/livechat/message'>>> {
if (!this.token) {
throw new Error('Invalid token');
}
return this.rest.post('/v1/livechat/message', { ...params, token: this.token });
}
sendOfflineMessage(
params: OperationParams<'POST', '/v1/livechat/offline.message'>,
): Promise<Serialized<OperationResult<'POST', '/v1/livechat/offline.message'>>> {
return this.rest.post('/v1/livechat/offline.message', params);
}
sendVisitorNavigation(
params: OperationParams<'POST', '/v1/livechat/page.visited'>,
): Promise<Serialized<OperationResult<'POST', '/v1/livechat/page.visited'>>> {
return this.rest.post('/v1/livechat/page.visited', params);
}
requestTranscript(email: string, { rid }: { rid: string }): Promise<Serialized<OperationResult<'POST', '/v1/livechat/transcript'>>> {
if (!this.token) {
throw new Error('Invalid token');
}
return this.rest.post('/v1/livechat/transcript', { token: this.token, rid, email });
}
sendCustomField(
params: OperationParams<'POST', '/v1/livechat/custom.field'>,
): Promise<Serialized<OperationResult<'POST', '/v1/livechat/custom.field'>>> {
return this.rest.post('/v1/livechat/custom.field', params);
}
sendCustomFields(
params: OperationParams<'POST', '/v1/livechat/custom.fields'>,
): Promise<Serialized<OperationResult<'POST', '/v1/livechat/custom.fields'>>> {
return this.rest.post('/v1/livechat/custom.fields', params);
}
async updateVisitorStatus(newStatus: string): Promise<Serialized<OperationResult<'POST', '/v1/livechat/visitor.status'>['status']>> {
if (!this.token) {
throw new Error('Invalid token');
}
const { status } = await this.rest.post('/v1/livechat/visitor.status', { token: this.token, status: newStatus });
return status;
}
uploadFile(rid: string, file: File): Promise<ProgressEvent<EventTarget>> {
if (!this.token) {
throw new Error('Invalid token');
}
if (!file) {
throw new Error('Invalid file');
}
return new Promise((resolve, reject) => {
if (!this.token) {
return reject(new Error('Invalid token'));
}
return this.rest.upload(
`/v1/livechat/upload/${rid}`,
{ file },
{
load: resolve,
error: reject,
},
{ headers: { 'x-visitor-token': this.token } },
);
});
}
async sendUiInteraction(
payload: OperationParams<'POST', '/apps/ui.interaction/:id'>,
appId: string,
): Promise<Serialized<OperationResult<'POST', '/apps/ui.interaction/:id'>>> {
if (!this.token) {
throw new Error('Invalid token');
}
return this.rest.post(`/apps/ui.interaction/${appId}`, payload, { headers: { 'x-visitor-token': this.token } });
}
// API DELETE
deleteMessage(id: string, { rid }: { rid: string }): Promise<Serialized<OperationResult<'DELETE', '/v1/livechat/message/:_id'>>> {
if (!this.token) {
throw new Error('Invalid token');
}
return this.rest.delete(`/v1/livechat/message/${id}`, { rid, token: this.token });
}
deleteVisitor(): Promise<Serialized<OperationResult<'DELETE', '/v1/livechat/visitor'>>> {
if (!this.token) {
throw new Error('Invalid token');
}
return this.rest.delete(`/v1/livechat/visitor/${this.token}` as any);
}
// API PUT
editMessage(
id: string,
params: OperationParams<'PUT', '/v1/livechat/message/:_id'>,
): Promise<Serialized<OperationResult<'PUT', '/v1/livechat/message/:_id'>>> {
return this.rest.put(`/v1/livechat/message/${id}`, params);
}
unsubscribeAll(): Promise<unknown> {
const subscriptions = Array.from(this.client.subscriptions.keys());
return Promise.all(subscriptions.map((subscription) => this.client.unsubscribe(subscription)));
}
static override create(url: string, retryOptions = { retryCount: 3, retryTime: 10000 }): LivechatClientImpl {
// TODO: Decide what to do with the EJSON objects
const ddp = new DDPDispatcher();
const connection = ConnectionImpl.create(url, WebSocket, ddp, retryOptions);
const stream = new ClientStreamImpl(ddp, ddp);
const account = new AccountImpl(stream);
const timeoutControl = TimeoutControl.create(ddp, connection);
const rest = new RestClient({ baseUrl: url.replace(/^ws/, 'http') });
const sdk = new LivechatClientImpl(connection, stream, account, timeoutControl, rest);
connection.on('connected', () => {
for (const [, sub] of stream.subscriptions.entries()) {
ddp.subscribeWithId(sub.id, sub.name, sub.params);
}
});
return sdk;
}
}

View File

@@ -0,0 +1,100 @@
import type { Serialized } from '@rocket.chat/core-typings';
import type { OperationParams, OperationResult } from '@rocket.chat/rest-typings';
import type { StreamerCallbackArgs } from '../../types/streams';
export type LivechatRoomEvents<T> =
StreamerCallbackArgs<'livechat-room', `${string}`> extends [infer A]
? A extends { type: T; data: unknown }
? A['data']
: A extends { type: T; status: unknown }
? A['status']
: A extends { type: T; visitor: unknown }
? A['visitor']
: never
: never;
export interface LivechatStream {
notifyVisitorActivity(rid: string, username: string, activities: string[]): Promise<unknown>;
notifyCallDeclined(rid: string): Promise<unknown>;
subscribeRoom(rid: string): Promise<any>;
onMessage(cb: (...args: StreamerCallbackArgs<'room-messages', string>) => void): () => void;
onUserActivity(cb: (...args: StreamerCallbackArgs<'notify-room', `${string}/user-activity`>) => void): () => void;
onRoomMessage(rid: string, cb: (...args: StreamerCallbackArgs<'room-messages', string>) => void): () => void;
onRoomUserActivity(rid: string, cb: (...args: StreamerCallbackArgs<'notify-room', `${string}/user-activity`>) => void): () => void;
onRoomDeleteMessage(rid: string, cb: (...args: StreamerCallbackArgs<'notify-room', `${string}/deleteMessage`>) => void): () => void;
onAgentChange(rid: string, cb: (args: LivechatRoomEvents<'agentData'>) => void): () => void;
onAgentStatusChange(rid: string, cb: (args: LivechatRoomEvents<'agentStatus'>) => void): () => void;
onQueuePositionChange(rid: string, cb: (args: LivechatRoomEvents<'queueData' | 'agentData'>) => void): () => void;
onVisitorChange(rid: string, cb: (data: LivechatRoomEvents<'visitorData'>) => void): () => void;
}
export interface LivechatEndpoints {
// GET
config(args: OperationParams<'GET', '/v1/livechat/config'>): Promise<Serialized<OperationResult<'GET', '/v1/livechat/config'>['config']>>;
room(args: OperationParams<'GET', '/v1/livechat/room'>): Promise<Serialized<OperationResult<'GET', '/v1/livechat/room'>>>;
visitor(
args: OperationParams<'GET', '/v1/livechat/visitor/:token'>,
): Promise<Serialized<OperationResult<'GET', '/v1/livechat/visitor/:token'>>>;
nextAgent(
args: OperationParams<'GET', '/v1/livechat/agent.next/:token'>,
): Promise<Serialized<OperationResult<'GET', '/v1/livechat/agent.next/:token'>>>;
agent(rid: string): Promise<Serialized<OperationResult<'GET', '/v1/livechat/agent.info/:rid/:token'>['agent']>>;
message(
id: string,
args: OperationParams<'GET', '/v1/livechat/message/:_id'>,
): Promise<Serialized<OperationResult<'GET', '/v1/livechat/message/:_id'>>>;
loadMessages(
rid: string,
args: OperationParams<'GET', '/v1/livechat/messages.history/:rid'>,
): Promise<Serialized<OperationResult<'GET', '/v1/livechat/messages.history/:rid'>['messages']>>;
// videoCall(args: OperationParams<'GET', '/v1/livechat/video.call/:token'>): Promise<void>;
// POST
transferChat(
args: OperationParams<'POST', '/v1/livechat/visitor/department.transfer'>,
): Promise<Serialized<OperationResult<'POST', '/v1/livechat/visitor/department.transfer'>>>;
grantVisitor(
guest: OperationParams<'POST', '/v1/livechat/visitor'>,
): Promise<Serialized<OperationResult<'POST', '/v1/livechat/visitor'>>>;
login(guest: OperationParams<'POST', '/v1/livechat/visitor'>): Promise<Serialized<OperationResult<'POST', '/v1/livechat/visitor'>>>;
closeChat(args: { rid: string }): Promise<Serialized<OperationResult<'POST', '/v1/livechat/room.close'>>>;
// shareScreen(args: OperationParams<'POST', '/v1/livechat/room.shareScreen'>): Promise<void>;
chatSurvey(
args: OperationParams<'POST', '/v1/livechat/room.survey'>,
): Promise<Serialized<OperationResult<'POST', '/v1/livechat/room.survey'>>>;
updateVisitorStatus(status: string): Promise<Serialized<OperationResult<'POST', '/v1/livechat/visitor.status'>['status']>>;
sendMessage(args: OperationParams<'POST', '/v1/livechat/message'>): Promise<Serialized<OperationResult<'POST', '/v1/livechat/message'>>>;
sendOfflineMessage(
args: OperationParams<'POST', '/v1/livechat/offline.message'>,
): Promise<Serialized<OperationResult<'POST', '/v1/livechat/offline.message'>>>;
sendVisitorNavigation(
args: OperationParams<'POST', '/v1/livechat/page.visited'>,
): Promise<Serialized<OperationResult<'POST', '/v1/livechat/page.visited'>>>;
requestTranscript(email: string, { rid }: { rid: string }): Promise<Serialized<OperationResult<'POST', '/v1/livechat/transcript'>>>;
sendCustomField(
params: OperationParams<'POST', '/v1/livechat/custom.field'>,
): Promise<Serialized<OperationResult<'POST', '/v1/livechat/custom.field'>>>;
sendCustomFields(
params: OperationParams<'POST', '/v1/livechat/custom.fields'>,
): Promise<Serialized<OperationResult<'POST', '/v1/livechat/custom.fields'>>>;
uploadFile(rid: string, file: File): Promise<ProgressEvent<EventTarget>>;
// DELETE
deleteMessage(id: string, { rid }: { rid: string }): Promise<Serialized<OperationResult<'DELETE', '/v1/livechat/message/:_id'>>>;
deleteVisitor(args: OperationParams<'DELETE', '/v1/livechat/visitor/:token'>): Promise<void>;
// PUT
editMessage(
id: string,
args: OperationParams<'PUT', '/v1/livechat/message/:_id'>,
): Promise<Serialized<OperationResult<'PUT', '/v1/livechat/message/:_id'>>>;
sendUiInteraction(
payload: OperationParams<'POST', '/apps/ui.interaction/:id'>,
appId: string,
): Promise<Serialized<OperationResult<'POST', '/apps/ui.interaction/:id'>>>;
}

View File

@@ -0,0 +1,157 @@
import { Emitter } from '@rocket.chat/emitter';
import type { ClientStream } from './ClientStream';
type User = {
id: string;
username?: string;
token?: string;
tokenExpires?: Date;
} & Record<string, unknown>;
type AccountEvents = {
uid: string | undefined;
user: User;
emailVerificationLink: string;
pageLoadLogin: unknown;
};
export interface Account extends Emitter<AccountEvents> {
uid?: string;
user?: User;
loginWithPassword(username: string, password: string): Promise<void>;
loginWithToken(token: string): Promise<{
id: string;
token: string;
tokenExpires: Date;
}>;
logout(): Promise<void>;
onLogin(fn: () => void): () => void;
onLogout(fn: () => void): () => void;
onEmailVerificationLink(fn: (token: string) => void): () => void;
onPageLoadLogin(fn: (loginAttempt: unknown) => void): () => void;
}
export class AccountImpl extends Emitter<AccountEvents> implements Account {
private _uid?: string;
user?: { id: string; username?: string; token?: string; tokenExpires?: Date };
get uid(): string | undefined {
return this._uid;
}
// Setter emits only on transition so onLogin/onLogout fire once per login/logout,
// not on every credential refresh. Direct writes from outside the SDK
// (adoptAccountFromMeteorLoginResult, teardownAuthenticatedConnection) flow through
// here and become the canonical login signal regardless of transport mode.
set uid(value: string | undefined) {
if (value === this._uid) return;
this._uid = value;
this.emit('uid', value);
}
constructor(private readonly client: ClientStream) {
super();
client.onCollection('users', (data) => {
if (data.collection !== 'users') {
return;
}
if (!('fields' in data) || !(data.fields && 'username' in data.fields)) {
return;
}
this.user = {
...this.user,
id: data.id,
username: data.fields.username,
};
this.emit('user', this.user);
});
}
private saveCredentials(id: string, token: string, tokenExpires: string) {
this.user = {
...this.user,
token,
tokenExpires: new Date(tokenExpires),
id,
};
this.uid = id;
this.emit('user', this.user);
}
async loginWithPassword(username: string, password: string): Promise<void> {
const {
id,
token: resultToken,
tokenExpires: { $date },
} = await this.client.callAsyncWithOptions(
'login',
{
wait: true,
},
{
user: { username },
password: { digest: password, algorithm: 'sha-256' },
},
);
this.saveCredentials(id, resultToken, $date);
}
async loginWithToken(token: string) {
const result = await this.client.callAsyncWithOptions(
'login',
{
wait: true,
},
{
resume: token,
},
);
const {
id,
token: resultToken,
tokenExpires: { $date },
} = result;
this.saveCredentials(id, resultToken, $date);
return result;
}
async logout(): Promise<void> {
await this.client.callAsyncWithOptions('logout', {
wait: true,
});
this.user = undefined;
this.uid = undefined;
}
onLogin(fn: () => void): () => void {
return this.on('uid', (uid) => {
if (uid !== undefined) fn();
});
}
onLogout(fn: () => void): () => void {
return this.on('uid', (uid) => {
if (uid === undefined) fn();
});
}
// emailVerificationLink and pageLoadLogin have no native source in the SDK — the actual
// events come from Meteor's accounts-base (URL hash routing for verification, pending
// login attempts for OAuth). The bridge in apps/meteor/client/lib/sdk/ddpSdk.ts forwards
// Meteor's events into this emitter; flag-OFF mode delegates directly via meteorBackedSdk.
onEmailVerificationLink(fn: (token: string) => void): () => void {
return this.on('emailVerificationLink', fn);
}
onPageLoadLogin(fn: (loginAttempt: unknown) => void): () => void {
return this.on('pageLoadLogin', fn);
}
}

View File

@@ -0,0 +1,126 @@
import type { Emitter } from '@rocket.chat/emitter';
import type { DDPDispatchOptions } from '../MinimalDDPClient';
import type { Subscription } from './Subscription';
import type { PublicationPayloads } from './publicationPayloads';
export interface ClientStream extends Emitter {
/**
* Calls a method on the server.
* @param method - The name of the method to be called.
* @param params - The parameters to be passed to the method.
* @param params.lastArgument - The last argument can be a callback function, the first argument of the callback function is the error, the second argument is the result.
*
* @returns {string} - The id of the method call.
*/
call(method: string, ...params: any[]): string;
/**
* Calls a method on the server. The same as `call` but with options.
* This options are passed to the dispatcher. The dispatcher is responsible for handling the method call.
* So depending on the dispatcher, the options may or may not be used. By default, the dispatcher only uses the `wait` option.
* One reason to use options is to make the call wait for the result to dispatch other calls.
* Mainly for authentication purposes.
*
* @param method - The name of the method to be called.
* @param options - The options to be passed to the method.
* @param options.wait - If true, the call will wait for the result to dispatch other calls.
* @param params - The parameters to be passed to the method.
* @param params.lastArgument - The last argument can be a callback function, the first argument of the callback function is the error, the second argument is the result.
* @returns {string} - The id of the method call.
**/
callWithOptions(method: string, options: DDPDispatchOptions, ...params: any[]): string;
/**
* Calls a method on the server. The same as `call` but returns a promise.
* @param method - The name of the method to be called.
* @param params - The parameters to be passed to the method.
* @returns {Promise} - A promise that resolves when the server returns the result.
* @example
* ```ts
* const result = await ddp.callAsync('login', {
* user: {
* username: 'my-username',
* password: 'my-password',
* },
* });
* ```
*/
callAsync(
method: string,
...params: any[]
): Promise<any> & {
id: string;
};
/**
* Calls a method on the server. The same as `callWithOptions` but returns a promise.
* @param method - The name of the method to be called.
* @param options - The options to be passed to the method.
* @param options.wait - If true, the call will wait for the result to dispatch other calls.
* @param params - The parameters to be passed to the method.
* @returns {Promise} - A promise that resolves when the server returns the result.
*/
callAsyncWithOptions(
method: string,
options: DDPDispatchOptions,
...params: any[]
): Promise<any> & {
id: string;
};
/**
* Subscribes to a publication on the server.
* @param name - The name of the publication to subscribe to.
* @param params - The parameters to be passed to the publication.
* @returns {Promise} - A promise that resolves when the server returns the subscription id.
*/
subscribe(name: string, ...params: any[]): Subscription;
/**
* Unsubscribes from a publication on the server.
* @param id - The id of the subscription to unsubscribe from.
* @returns {Promise} - A promise that resolves when the server unsubscribes the subscription.
*/
unsubscribe(id: string): Promise<any>;
/**
* Connects to the server.
* @returns {Promise} - A promise that resolves when the server connects.
* @example
* ```ts
* await ddp.connect();
* ```
*/
connect(): Promise<any>;
/**
* Fired when the server send any collection update.
* usually this used after subscribing to a publication.
* @param id - The id/name of the collection.
* @param callback - The callback function to be called when the server sends any collection update.
* @returns {Function} - A function to stop listening for collection updates.
* @example
* ```ts
* const stop = ddp.onCollection('users', (data) => {
* console.log(data);
* });
* ```
*/
onCollection(id: string, callback: (data: PublicationPayloads) => void): () => void;
/**
* The list of subscriptions.
* @type {Map<string, { id: string; status: 'ready' | 'loading'; name: string; params: any[]; }>}
* @example
* ```ts
* const subscription = ddp.subscribe('my-subscription');
* console.log(ddp.subscriptions.get(subscription.id));
* // prints:
* // {
* // id: 'my-subscription',
* // status: 'loading',
* // name: 'my-subscription',
* // params: [],
* // }
* ```
*/
subscriptions: Map<string, Subscription>;
}

View File

@@ -0,0 +1,128 @@
import type { IncomingPayload } from './IncomingPayload';
import type { RemoveListener } from './RemoveListener';
import type { ConnectedPayload, FailedPayload } from './connectionPayloads';
import type { MethodPayload, ResultPayload, UpdatedPayload } from './methodsPayloads';
import type { NosubPayload, PublicationPayloads, ServerPublicationPayloads } from './publicationPayloads';
export interface DDPDispatchOptions {
wait?: boolean;
}
/**
* A low-level DDP client that can be used to communicate with a DDP server.
*/
export interface DDPClient {
/**
* Invokes a remote method.
* @param method The name of the method to invoke.
* @param params The parameters to pass to the method.
* @param options The options to pass to the method.
* @returns A string representing the method call.
*/
call(method: string, params: any[], options?: DDPDispatchOptions): MethodPayload;
/**
* Subscribes to a queue of stream messages.
* @param name The name of the queue to subscribe to.
* @param params The parameters that the queue takes.
* @returns A string representing the subscription id.
*/
subscribe(name: string, params: any[]): string;
/**
* Subscribes to a queue of stream messages.
* @param id arbitrary id to use for the subscription
* @param name The name of the queue to subscribe to.
* @param params The parameters that the queue takes.
* @returns A string representing the subscription id.
*/
subscribeWithId(id: string, name: string, params: any[]): string;
/**
* Unsubscribes from a queue of stream messages.
* @param id The id of the subscription to unsubscribe from.
*/
unsubscribe(id: string): void;
/**
* Handles a message received from the server.
*/
handleMessage(serialized: string): void;
/**
* Registers a callback to be called every time a message is sent to the server.
*/
onDispatchMessage(callback: (msg: string) => void): RemoveListener;
/**
* Sends a connect message to the server.
*/
connect(): void;
/**
* Registers a callback to be called once after the subscription is ready or rejected.
* @param id The id of the subscription.
* @param callback The callback to be called.
* @returns A function to unregister the callback.
*/
onPublish(name: string, callback: (payload: ServerPublicationPayloads) => void): RemoveListener;
/**
* Registers a callback to be called once after the method is resolved or rejected.
* @param id The id of the method.
* @param callback The callback to be called.
* @returns A function to unregister the callback.
*/
onResult(id: string, callback: (payload: ResultPayload) => void): RemoveListener;
/**
* Registers a callback to be called every time the subscription is updated.
* @param id The id of the subscription.
* @param callback The callback to be called.
* @returns A function to unregister the callback.
*/
onUpdate(id: string, callback: (payload: UpdatedPayload) => void): RemoveListener;
/**
* Registers a callback to be called once after the subscription is stopped.
* @param id The id of the subscription.
* @param callback The callback to be called.
* @returns A function to unregister the callback.
*/
onNoSub(id: string, callback: (payload: NosubPayload) => void): RemoveListener;
/**
* Registers a callback to be called every time a new document is added/updated/removed from the collection.
* @param name The name of the collection.
* @param callback The callback to be called.
* @returns A function to unregister the callback.
*/
onCollection(name: string, callback: (payload: PublicationPayloads) => void): RemoveListener;
/**
* Registers a callback to be called once after the connection is established or rejected.
* @param callback The callback to be called.
* @returns A function to unregister the callback.
*/
onConnection(callback: (payload: ConnectedPayload | FailedPayload) => void): RemoveListener;
/**
* Registers a callback to be called every time a message is received.
* @param callback The callback to be called.
* @returns A function to unregister the callback.
*/
onMessage(callback: (payload: IncomingPayload) => void): RemoveListener;
/**
* Registers a callback to be called once after a message is received.
* @param callback The callback to be called.
* @returns A function to unregister the callback.
*/
onceMessage(callback: (payload: IncomingPayload) => void): RemoveListener;
/**
* Sends a ping message to the server.
* @returns nothing
*/
ping(id?: string): void;
}

View File

@@ -0,0 +1,10 @@
import type { ConnectedPayload, FailedPayload } from './connectionPayloads';
import type { PingPayload, PongPayload } from './heartbeatsPayloads';
import type { ServerMethodPayloads } from './methodsPayloads';
import type { ServerPublicationPayloads } from './publicationPayloads';
/**
* @category incoming
*/
export type IncomingPayload =
PingPayload | PongPayload | ServerPublicationPayloads | ServerMethodPayloads | ConnectedPayload | FailedPayload;

View File

@@ -0,0 +1,9 @@
import type { ConnectPayload } from './connectionPayloads';
import type { PingPayload, PongPayload } from './heartbeatsPayloads';
import type { MethodPayload } from './methodsPayloads';
import type { ClientPublicationPayloads } from './publicationPayloads';
/**
* @category outgoing
*/
export type OutgoingPayload = PingPayload | PongPayload | ConnectPayload | ClientPublicationPayloads | MethodPayload;

View File

@@ -0,0 +1 @@
export type RemoveListener = () => void;

View File

@@ -0,0 +1,33 @@
import type { RestClient } from '@rocket.chat/api-client';
import type { Connection } from '../Connection';
import type { TimeoutControl } from '../TimeoutControl';
import type { Account } from './Account';
import type { ClientStream } from './ClientStream';
/*
* The following procedure is used for streaming data:
* In the original Meteor DDP, Collections were used and publications and subscriptions were used to synchronize data between the client and server.
* However, controlling the `mergebox` can be expensive and doesn't scale well for many clients.
* To address this issue, we are using a specific part of the original implementation of the DDP protocol to send the data directly to the client without using the mergebox.
* This allows the client to receive more data directly from the server, even if the data is the same as before.
* To maintain compatibility with the original Meteor DDP, we use virtual collections.
* These collections are not real collections, but rather a way to send data to the client.
* They are named with the prefix stream- followed by the name of the stream.
* Instead of storing the data, they simply call the changed method.
* It's up to the application to handle the changed method and use the data it contains.
* In order for the server to function properly, it is important that it is aware of the 'agreement' and uses the same assumptions.
*/
export interface SDK {
stream(name: string, params: unknown[], cb: (...data: unknown[]) => void): ReturnType<ClientStream['subscribe']>;
connection: Connection;
account: Account;
client: ClientStream;
timeoutControl: TimeoutControl;
rest: RestClient;
}

View File

@@ -0,0 +1,12 @@
import type { ServerPublicationPayloads } from './publicationPayloads';
export interface Subscription {
stop: () => void;
ready: () => Promise<void>;
isReady: boolean;
onChange: (cb: (arg: ServerPublicationPayloads) => void) => void;
id: string;
params: any[];
name: string;
error?: unknown;
}

View File

@@ -0,0 +1,30 @@
/**
* @category outgoing
*/
export type ConnectPayload = {
msg: 'connect';
/** previous DDP session identifier (if trying to reconnect to an existing DDP session) */
session?: string;
/** the proposed protocol version */
version: string;
/** protocol versions supported by the client, in order of preference */
support: string[];
};
/**
* @category incoming
*/
export type ConnectedPayload = {
msg: 'connected';
/** an identifier for the DDP session */
session: string;
};
/**
* @category incoming
*/
export type FailedPayload = {
msg: 'failed';
/** a suggested protocol version to connect with */
version: string;
};

View File

@@ -0,0 +1,23 @@
/**
* @category incoming | outgoing
*
* At any time after the connection is established either side may send a ping message. The other side
* must respond with a pong message as soon as possible.
*/
export type PingPayload = {
msg: 'ping';
/** identifier used to correlate with response */
id?: string;
};
/**
* @category incoming | outgoing
*
* The pong message is sent in response to a ping message. If the ping message included an id field,
* the pong message must include the same id field.
*/
export type PongPayload = {
msg: 'pong';
/** identifier as received in the ping message */
id?: string;
};

View File

@@ -0,0 +1,10 @@
export interface ServerMethods {
resetPassword(token: string, password: string): { token: string };
checkRegistrationSecretURL(hash: string): boolean;
}
export type ServerMethodName = keyof ServerMethods;
export type ServerMethodParameters<MethodName extends ServerMethodName> = Parameters<ServerMethods[MethodName]>;
export type ServerMethodReturn<MethodName extends ServerMethodName> = Awaited<ReturnType<ServerMethods[MethodName]>>;

View File

@@ -0,0 +1,46 @@
/**
* @category outgoing
*/
export type MethodPayload = {
msg: 'method';
/** method name */
method: string;
/** EJSON parameters to the method */
params: any[];
/** an arbitrary client-determined identifier for this method call */
id: string;
/** an arbitrary client-determined seed for pseudo-random generators */
randomSeed?: Record<string, unknown>;
};
/**
* @category incoming
*/
export type ResultPayload = {
msg: 'result';
/** the id passed to `'method'` */
id: string;
/** an error thrown by the method (or method-not-found) */
error?: unknown;
/** the EJSON return value of the method, if any */
result?: any;
};
/**
* @category incoming
*/
export type UpdatedPayload = {
msg: 'updated';
/** array of method ids; all of whose writes have been reflected in data messages */
methods: string[];
};
/**
* @category outgoing
*/
export type ClientMethodPayloads = MethodPayload;
/**
* @category incoming
*/
export type ServerMethodPayloads = ResultPayload | UpdatedPayload;

View File

@@ -0,0 +1,126 @@
/**
* @category outgoing
*/
export type SubscribePayload = {
msg: 'sub';
/** an arbitrary client-determined identifier for this subscription */
id: string;
/** the name of the subscription */
name: string;
/** parameters to the subscription */
params?: any[];
};
/**
* @category outgoing
*/
export type UnsubscribePayload = {
msg: 'unsub';
/** the id passed to `'sub'` */
id: string;
};
/**
* @category outgoing
*/
export type ClientPublicationPayloads = SubscribePayload | UnsubscribePayload;
/**
* @category incoming
*/
export type NosubPayload = {
msg: 'nosub';
/** the id passed to `'sub'` */
id: string;
/** an error thrown by the subscription (or sub-not-found) */
error?: unknown;
};
/**
* @category incoming
*/
export type AddedPayload = {
msg: 'added';
/** the name of the collection */
collection: string;
/** the document ID */
id: string;
/** the document fields */
fields?: Record<string, any>;
};
/**
* @category incoming
*/
export type ChangedPayload = {
msg: 'changed';
/** the name of the collection */
collection: string;
/** the document ID */
id: string;
/** the document fields */
fields?: Record<string, any>;
/** the document fields to delete */
cleared?: string[];
};
/**
* @category incoming
*/
export type RemovedPayload = {
msg: 'removed';
/** the name of the collection */
collection: string;
/** the document ID */
id: string;
};
/**
* @category incoming
*/
export type ReadyPayload = {
msg: 'ready';
/** the ids passed to `'sub'` which have sent their initial batch of data */
subs: string[];
};
/**
* @category incoming
*/
export type AddedBeforePayload = {
msg: 'addedBefore';
/** the name of the collection */
collection: string;
/** the document ID */
id: string;
/** the document fields */
fields?: Record<string, any>;
/** the document ID to add the document before, or `null` to add at the end */
before: string | null;
};
// movedBefore (server -> client):
// collection: string
// id: string (the document ID)
// before: string or null (the document ID to move the document before, or null to move to the end)
/**
* @category incoming
*/
export type MovedBeforePayload = {
msg: 'movedBefore';
/** the name of the collection */
collection: string;
/** the document ID */
id: string;
/** the document ID to move the document before, or `null` to move to the end */
before: string | null;
};
/**
* @category incoming
*/
export type ServerPublicationPayloads =
NosubPayload | AddedPayload | ChangedPayload | RemovedPayload | ReadyPayload | AddedBeforePayload | MovedBeforePayload;
export type PublicationPayloads = AddedPayload | ChangedPayload | RemovedPayload;

View File

@@ -0,0 +1,499 @@
import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus';
import type { ISetting as AppsSetting } from '@rocket.chat/apps-engine/definition/settings';
import type {
IMessage,
IRoom,
ISetting,
ISubscription,
IRole,
IEmoji,
ICustomSound,
INotificationDesktop,
IUser,
IOmnichannelRoom,
VideoConference,
IOmnichannelCannedResponse,
IIntegrationHistory,
IUserDataEvent,
ICalendarNotification,
ILivechatInquiryRecord,
ILivechatAgent,
IImportProgress,
IBanner,
LicenseLimitKind,
ICustomUserStatus,
IWebdavAccount,
MessageAttachment,
ISession,
PresenceStatusCode,
} from '@rocket.chat/core-typings';
import type { ServerMediaSignal } from '@rocket.chat/media-signaling';
import type * as UiKit from '@rocket.chat/ui-kit';
type ClientAction = 'inserted' | 'updated' | 'removed' | 'changed';
export interface StreamerEvents {
'roles': [
{
key: 'roles';
args: [
IRole & {
type: 'inserted' | 'updated' | 'removed' | 'changed';
},
];
},
];
'notify-room': [
{ key: `${string}/user-activity`; args: [username: string, activities: string[]] },
{ key: `${string}/typing`; args: [username: string, typing: boolean] },
{
key: `${string}/deleteMessageBulk`;
args: [
args: {
rid: IMessage['rid'];
excludePinned: boolean;
ignoreDiscussion: boolean;
ts: Record<string, Date>;
users: string[];
ids?: string[]; // message ids have priority over ts
showDeletedStatus?: boolean;
} & (
| {
filesOnly: true;
replaceFileAttachmentsWith?: MessageAttachment;
}
| {
filesOnly?: false;
}
),
];
},
{ key: `${string}/deleteMessage`; args: [{ _id: IMessage['_id'] }] },
{ key: `${string}/e2e.keyRequest`; args: [unknown] },
{ key: `${string}/videoconf`; args: [id: string] },
{ key: `${string}/messagesRead`; args: [{ until: Date; tmid?: string }] },
{ key: `${string}/messagesImported`; args: [null] },
/* @deprecated over videoconf*/
// { key: `${string}/${string}`; args: [id: string] },
];
'room-messages': [{ key: '__my_messages__'; args: [IMessage] }, { key: string; args: [message: IMessage, user?: IUser, room?: IRoom] }];
'notify-all': [
{
key: 'public-info';
args: [
| [key: 'public-settings-changed', args: ['inserted' | 'updated' | 'removed' | 'changed', ISetting]]
| [key: 'deleteCustomSound', args: [{ soundData: ICustomSound }]]
| [key: 'updateCustomSound', args: [{ soundData: ICustomSound }]],
];
},
{ key: 'public-settings-changed'; args: ['inserted' | 'updated' | 'removed' | 'changed', ISetting] },
{ key: 'deleteCustomSound'; args: [{ soundData: ICustomSound }] },
{ key: 'updateCustomSound'; args: [{ soundData: ICustomSound }] },
{ key: 'license'; args: [{ preventedActions: Record<LicenseLimitKind, boolean> }] | [] },
];
'notify-user': [
{ key: `${string}/rooms-changed`; args: ['inserted' | 'updated' | 'removed' | 'changed', IRoom] },
{
key: `${string}/subscriptions-changed`;
args:
| [
'removed',
{
_id: string;
u?: Pick<IUser, '_id' | 'username' | 'name'>;
rid?: string;
t?: string;
},
]
| [
'inserted' | 'updated',
Pick<
ISubscription,
| 't'
| 'ts'
| 'ls'
| 'lr'
| 'name'
| 'fname'
| 'rid'
| 'code'
| 'f'
| 'u'
| 'open'
| 'alert'
| 'roles'
| 'unread'
| 'prid'
| 'userMentions'
| 'groupMentions'
| 'archived'
| 'audioNotificationValue'
| 'desktopNotifications'
| 'mobilePushNotifications'
| 'emailNotifications'
| 'desktopPrefOrigin'
| 'mobilePrefOrigin'
| 'emailPrefOrigin'
| 'unreadAlert'
| '_updatedAt'
| 'blocked'
| 'blocker'
| 'autoTranslate'
| 'autoTranslateLanguage'
| 'disableNotifications'
| 'hideUnreadStatus'
| 'hideMentionStatus'
| 'muteGroupMentions'
| 'ignored'
| 'E2EKey'
| 'E2ESuggestedKey'
| 'oldRoomKeys'
| 'tunread'
| 'tunreadGroup'
| 'tunreadUser'
// Omnichannel fields
| 'department'
| 'v'
| 'onHold'
>,
];
},
{ key: `${string}/message`; args: [IMessage] },
{ key: `${string}/force_logout`; args: [ISession['sessionId'] | undefined] },
{
key: `${string}/webdav`;
args: [{ type: 'changed'; account: Partial<IWebdavAccount> } | { type: 'removed'; account: { _id: IWebdavAccount['_id'] } }];
},
{ key: `${string}/e2ekeyRequest`; args: [string, string] },
{ key: `${string}/notification`; args: [INotificationDesktop] },
{ key: `${string}/call.hangup`; args: [{ roomId: string }] },
{ key: `${string}/uiInteraction`; args: [UiKit.ServerInteraction] },
{
key: `${string}/video-conference`;
args: [{ action: string; params: { callId: VideoConference['_id']; uid: IUser['_id']; rid: IRoom['_id'] } }];
},
{ key: `${string}/media-signal`; args: [ServerMediaSignal] },
{ key: `${string}/userData`; args: [IUserDataEvent] },
{ key: `${string}/updateInvites`; args: [unknown] },
{ key: `${string}/departmentAgentData`; args: [unknown] },
{ key: `${string}/calendar`; args: [ICalendarNotification] },
{ key: `${string}/banners`; args: [IBanner] },
];
'importers': [
{
key: 'progress';
args: [{ rate: number } | IImportProgress];
},
];
'notify-logged': [
{
key: 'updateCustomUserStatus';
args: [
{
userStatusData: Omit<ICustomUserStatus, '_updatedAt'>;
},
];
},
{ key: 'permissions-changed'; args: ['inserted' | 'updated' | 'removed' | 'changed', ISetting] },
{ key: 'deleteEmojiCustom'; args: [{ emojiData: IEmoji }] },
{ key: 'updateEmojiCustom'; args: [{ emojiData: IEmoji }] },
/* @deprecated */
{ key: 'new-banner'; args: [{ bannerId: string }] },
{ key: 'banner-changed'; args: [{ bannerId: string }] },
{
key: 'roles-change';
args: [
{
type: 'added' | 'removed' | 'changed';
_id: IRole['_id'];
u?: { _id: IUser['_id']; username: IUser['username']; name?: IUser['name'] };
scope?: string;
},
];
},
{ key: 'Users:NameChanged'; args: [Pick<IUser, '_id' | 'name' | 'username'>] },
{ key: 'private-settings-changed'; args: ['inserted' | 'updated' | 'removed' | 'changed', ISetting] },
{ key: 'deleteCustomUserStatus'; args: [{ userStatusData: Omit<ICustomUserStatus, '_updatedAt'> }] },
{
key: 'user-status';
args: [
[
uid: IUser['_id'],
username: IUser['username'],
status: PresenceStatusCode,
statusText: IUser['statusText'],
name: IUser['name'],
roles: IUser['roles'],
statusSource?: IUser['statusSource'],
statusExpiresAt?: IUser['statusExpiresAt'],
],
];
},
{
key: 'Users:Deleted';
args: [
| {
userId: IUser['_id'];
messageErasureType: 'Delete';
replaceByUser?: never;
}
| {
userId: IUser['_id'];
messageErasureType: 'Unlink';
replaceByUser?: { _id: IUser['_id']; username: IUser['username']; alias: string };
},
];
},
{
key: 'updateAvatar';
args: [{ username: IUser['username']; etag: IUser['avatarETag'] } | { rid: IRoom['_id']; etag: IRoom['avatarETag'] }];
},
{ key: 'omnichannel.priority-changed'; args: [{ id: string; clientAction: ClientAction; name?: string }] },
];
'room-data': [{ key: string; args: [IOmnichannelRoom | Pick<IOmnichannelRoom, '_id'>] }];
'notify-room-users': [
{
key: `${string}/video-conference`;
args: [{ action: string; params: { callId: VideoConference['_id']; uid: IUser['_id']; rid: IRoom['_id'] } }];
},
{ key: `${string}/userData`; args: unknown[] },
];
'livechat-room': [
{
key: string;
args: [
| {
type: 'agentStatus';
status: string;
}
| {
type: 'queueData';
data:
| {
[k: string]: unknown;
}
| undefined;
}
| {
type: 'agentData';
data: ILivechatAgent | undefined | { hiddenInfo: boolean };
}
| {
type: 'visitorData';
visitor: unknown;
},
];
},
];
'user-presence': [
{
key: string;
args: [
[
username: string,
statusChanged?: PresenceStatusCode,
statusText?: string,
statusSource?: IUser['statusSource'],
statusExpiresAt?: IUser['statusExpiresAt'],
],
];
},
];
// TODO: rename to 'integration-history'
'integrationHistory': [
{
key: string;
args: [
| { type: 'removed'; id: string }
| {
id: string;
diff: unknown;
type: 'updated';
}
| {
type: 'inserted';
data: Partial<IIntegrationHistory>;
},
];
},
];
'canned-responses': [
{
key: 'canned-responses';
args:
| [{ type: 'removed'; _id: string }, { agentsId: string }]
| [{ type: 'removed'; _id: string }]
| [
{ type: 'changed' } & Omit<IOmnichannelCannedResponse, '_updatedAt' | '_createdAt'> & {
_createdAt?: Date | undefined;
},
]
| [{ type: 'changed' } & IOmnichannelCannedResponse, { agentsId: string }];
},
];
'livechat-inquiry-queue-observer': [
{
key: 'public';
args: [
{
type: 'added' | 'removed' | 'changed';
} & ILivechatInquiryRecord,
];
},
{
key: `department/${string}`;
args: [
{
type: 'added' | 'removed' | 'changed';
} & ILivechatInquiryRecord,
];
},
{
key: `agent/${string}`;
args: [
{
type: 'added' | 'removed' | 'changed';
} & ILivechatInquiryRecord,
];
},
{
key: `${string}`;
args: [
{
_id: string;
clientAction: string;
},
];
},
];
'apps': [
{ key: 'app/added'; args: [string] },
{ key: 'app/removed'; args: [string] },
{ key: 'app/updated'; args: [string] },
{
key: 'app/statusUpdate';
args: [
{
appId: string;
status: AppStatus;
},
];
},
{
key: 'app/settingUpdated';
args: [
{
appId: string;
setting: AppsSetting;
},
];
},
{ key: 'command/added'; args: [string] },
{ key: 'command/disabled'; args: [string] },
{ key: 'command/updated'; args: [string] },
{ key: 'command/removed'; args: [string] },
{ key: 'actions/changed'; args: [] },
{
key: 'apps';
args: [
| [key: 'app/added', args: [string]]
| [key: 'app/removed', args: [string]]
| [key: 'app/updated', args: [string]]
| [
key: 'app/statusUpdate',
args: [
{
appId: string;
status: AppStatus;
},
],
]
| [
key: 'app/settingUpdated',
args: [
{
appId: string;
setting: AppsSetting;
},
],
]
| [key: 'command/added', args: [string]]
| [key: 'command/disabled', args: [string]]
| [key: 'command/updated', args: [string]]
| [key: 'command/removed', args: [string]]
| [key: 'actions/changed', args: []],
];
},
];
'apps-engine': [
{ key: 'app/added'; args: [string] },
{ key: 'app/removed'; args: [string] },
{ key: 'app/updated'; args: [string] },
{
key: 'app/statusUpdate';
args: [
{
appId: string;
status: AppStatus;
},
];
},
{
key: 'app/settingUpdated';
args: [
{
appId: string;
setting: AppsSetting;
},
];
},
{ key: 'command/added'; args: [string] },
{ key: 'command/disabled'; args: [string] },
{ key: 'command/updated'; args: [string] },
{ key: 'command/removed'; args: [string] },
{ key: 'actions/changed'; args: [] },
];
'local': [
{
key: 'broadcast';
args: any[];
},
];
}
export type StreamNames = keyof StreamerEvents;
export type StreamKeys<S extends StreamNames> = StreamerEvents[S][number]['key'];
export type StreamerConfigs<N extends StreamNames> = StreamerEvents[N][number];
export type StreamerConfig<N extends StreamNames, K extends StreamKeys<N>> =
StreamerConfigs<N> extends infer U ? (U extends any ? ({ key: K; args: any } extends U ? U : never) : never) : never;
export type StreamerCallbackArgs<N extends StreamNames, K extends StreamKeys<N>> =
StreamerConfig<N, K> extends {
args: any;
}
? StreamerConfig<N, K>['args']
: never;
export type StreamerCallback<N extends StreamNames, K extends StreamKeys<N>> = (...args: StreamerCallbackArgs<N, K>) => void;

View File

@@ -0,0 +1,25 @@
import type { Emitter } from '@rocket.chat/emitter';
/**
* This function takes an event emitter (an object that emits events) and an event name
* as arguments, and returns a `Promise` that is resolved when the event is emitted, or
* is rejected if the event object has an `error` property.
* @param emitter The event emitter
* @param event The event name
* @returns A `Promise` that is resolved when the event is emitted, or is rejected if the event object has an `error` property
*/
export function wrapOnceEventIntoPromise<T extends { error?: string }>(emitter: Emitter, event: string) {
// Create a new Promise and set up its resolve and reject callbacks
return new Promise<T>((resolve, reject) => {
// When the event is emitted, call the appropriate callback
emitter.once(event, (data: T) => {
// If the event object has an error property, reject
if (data.error) {
return reject(data.error);
}
// Otherwise, resolve with the event object
resolve(data);
});
});
}

View File

@@ -0,0 +1,13 @@
{
"extends": "@rocket.chat/tsconfig/client.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"target": "es6",
"module": "commonjs",
"esModuleInterop": true,
"sourceMap": true
},
"include": ["./src/**/*"],
"exclude": ["**/*.spec.ts", "**/*.test.ts"]
}

View File

@@ -0,0 +1,10 @@
{
"extends": "@rocket.chat/tsconfig/client.json",
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"esModuleInterop": true,
"sourceMap": true,
},
"include": ["./src/**/*", "__examples__/**/*", "__mocks__/**/*"],
}