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
Code scanning - action / CodeQL-Build (push) Failing after 2s
Auto-close duplicate issues / auto-close-duplicates (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
Code scanning - action / CodeQL-Build (push) Failing after 2s
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user