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
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:
3070
packages/ddp-client/CHANGELOG.md
Normal file
3070
packages/ddp-client/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
43
packages/ddp-client/LICENSE
Normal file
43
packages/ddp-client/LICENSE
Normal 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.
|
||||
91
packages/ddp-client/README.md
Normal file
91
packages/ddp-client/README.md
Normal 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
|
||||
39
packages/ddp-client/__examples__/simple.ts
Normal file
39
packages/ddp-client/__examples__/simple.ts
Normal 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 || '');
|
||||
})();
|
||||
1
packages/ddp-client/__mocks__/ws.ts
Normal file
1
packages/ddp-client/__mocks__/ws.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { WebSocket } from 'mock-socket';
|
||||
133
packages/ddp-client/__tests__/Account.spec.ts
Normal file
133
packages/ddp-client/__tests__/Account.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
275
packages/ddp-client/__tests__/ClientStream.spec.ts
Normal file
275
packages/ddp-client/__tests__/ClientStream.spec.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
370
packages/ddp-client/__tests__/Connection.spec.ts
Normal file
370
packages/ddp-client/__tests__/Connection.spec.ts
Normal 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);
|
||||
});
|
||||
149
packages/ddp-client/__tests__/DDPDispatcher.spec.ts
Normal file
149
packages/ddp-client/__tests__/DDPDispatcher.spec.ts
Normal 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);
|
||||
});
|
||||
310
packages/ddp-client/__tests__/DDPSDK.spec.ts
Normal file
310
packages/ddp-client/__tests__/DDPSDK.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
374
packages/ddp-client/__tests__/MinimalDDPClient.spec.ts
Normal file
374
packages/ddp-client/__tests__/MinimalDDPClient.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
64
packages/ddp-client/__tests__/Timeout.spec.ts
Normal file
64
packages/ddp-client/__tests__/Timeout.spec.ts
Normal 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();
|
||||
});
|
||||
64
packages/ddp-client/__tests__/helpers/index.ts
Normal file
64
packages/ddp-client/__tests__/helpers/index.ts
Normal 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');
|
||||
@@ -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);
|
||||
});
|
||||
7
packages/ddp-client/jest.config.ts
Normal file
7
packages/ddp-client/jest.config.ts
Normal 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;
|
||||
46
packages/ddp-client/package.json
Normal file
46
packages/ddp-client/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
191
packages/ddp-client/src/ClientStream.ts
Normal file
191
packages/ddp-client/src/ClientStream.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
284
packages/ddp-client/src/Connection.ts
Normal file
284
packages/ddp-client/src/Connection.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
116
packages/ddp-client/src/DDPDispatcher.ts
Normal file
116
packages/ddp-client/src/DDPDispatcher.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
147
packages/ddp-client/src/DDPSDK.ts
Normal file
147
packages/ddp-client/src/DDPSDK.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
241
packages/ddp-client/src/MinimalDDPClient.ts
Normal file
241
packages/ddp-client/src/MinimalDDPClient.ts
Normal 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 }),
|
||||
});
|
||||
}
|
||||
}
|
||||
85
packages/ddp-client/src/TimeoutControl.ts
Normal file
85
packages/ddp-client/src/TimeoutControl.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
9
packages/ddp-client/src/index.ts
Normal file
9
packages/ddp-client/src/index.ts
Normal 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';
|
||||
287
packages/ddp-client/src/legacy/RocketchatSDKLegacy.ts
Normal file
287
packages/ddp-client/src/legacy/RocketchatSDKLegacy.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
98
packages/ddp-client/src/legacy/types/SDKLegacy.ts
Normal file
98
packages/ddp-client/src/legacy/types/SDKLegacy.ts
Normal 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];
|
||||
380
packages/ddp-client/src/livechat/LivechatClientImpl.ts
Normal file
380
packages/ddp-client/src/livechat/LivechatClientImpl.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
100
packages/ddp-client/src/livechat/types/LivechatSDK.ts
Normal file
100
packages/ddp-client/src/livechat/types/LivechatSDK.ts
Normal 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'>>>;
|
||||
}
|
||||
157
packages/ddp-client/src/types/Account.ts
Normal file
157
packages/ddp-client/src/types/Account.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
126
packages/ddp-client/src/types/ClientStream.ts
Normal file
126
packages/ddp-client/src/types/ClientStream.ts
Normal 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>;
|
||||
}
|
||||
128
packages/ddp-client/src/types/DDPClient.ts
Normal file
128
packages/ddp-client/src/types/DDPClient.ts
Normal 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;
|
||||
}
|
||||
10
packages/ddp-client/src/types/IncomingPayload.ts
Normal file
10
packages/ddp-client/src/types/IncomingPayload.ts
Normal 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;
|
||||
9
packages/ddp-client/src/types/OutgoingPayload.ts
Normal file
9
packages/ddp-client/src/types/OutgoingPayload.ts
Normal 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;
|
||||
1
packages/ddp-client/src/types/RemoveListener.ts
Normal file
1
packages/ddp-client/src/types/RemoveListener.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type RemoveListener = () => void;
|
||||
33
packages/ddp-client/src/types/SDK.ts
Normal file
33
packages/ddp-client/src/types/SDK.ts
Normal 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;
|
||||
}
|
||||
12
packages/ddp-client/src/types/Subscription.ts
Normal file
12
packages/ddp-client/src/types/Subscription.ts
Normal 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;
|
||||
}
|
||||
30
packages/ddp-client/src/types/connectionPayloads.ts
Normal file
30
packages/ddp-client/src/types/connectionPayloads.ts
Normal 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;
|
||||
};
|
||||
23
packages/ddp-client/src/types/heartbeatsPayloads.ts
Normal file
23
packages/ddp-client/src/types/heartbeatsPayloads.ts
Normal 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;
|
||||
};
|
||||
10
packages/ddp-client/src/types/methods.ts
Normal file
10
packages/ddp-client/src/types/methods.ts
Normal 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]>>;
|
||||
46
packages/ddp-client/src/types/methodsPayloads.ts
Normal file
46
packages/ddp-client/src/types/methodsPayloads.ts
Normal 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;
|
||||
126
packages/ddp-client/src/types/publicationPayloads.ts
Normal file
126
packages/ddp-client/src/types/publicationPayloads.ts
Normal 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;
|
||||
499
packages/ddp-client/src/types/streams.ts
Normal file
499
packages/ddp-client/src/types/streams.ts
Normal 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;
|
||||
25
packages/ddp-client/src/wrapOnceEventIntoPromise.ts
Normal file
25
packages/ddp-client/src/wrapOnceEventIntoPromise.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
13
packages/ddp-client/tsconfig.build.json
Normal file
13
packages/ddp-client/tsconfig.build.json
Normal 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"]
|
||||
}
|
||||
10
packages/ddp-client/tsconfig.json
Normal file
10
packages/ddp-client/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@rocket.chat/tsconfig/client.json",
|
||||
"compilerOptions": {
|
||||
"target": "es6",
|
||||
"module": "commonjs",
|
||||
"esModuleInterop": true,
|
||||
"sourceMap": true,
|
||||
},
|
||||
"include": ["./src/**/*", "__examples__/**/*", "__mocks__/**/*"],
|
||||
}
|
||||
Reference in New Issue
Block a user