@ably/chat
Version:
Ably Chat is a set of purpose-built APIs for a host of chat features enabling you to create 1:1, 1:Many, Many:1 and Many:Many chat rooms for any scale. It is designed to meet a wide range of chat use cases, such as livestreams, in-game communication, cust
435 lines (378 loc) • 14.6 kB
text/typescript
import * as Ably from 'ably';
import { dequal } from 'dequal';
import { ChatApi } from './chat-api.js';
import { ChatClientOptions, NormalizedChatClientOptions } from './config.js';
import { ErrorCode } from './errors.js';
import { randomId } from './id.js';
import { Logger } from './logger.js';
import { DefaultRoom, Room } from './room.js';
import { normalizeRoomOptions, RoomOptions } from './room-options.js';
/**
* Manages the lifecycle of chat rooms.
*/
export interface Rooms {
/**
* Gets a room reference by its unique identifier. The Rooms class ensures that only one reference
* exists for each room. A new reference object is created if it doesn't already
* exist, or if the one used previously was released using release(name).
*
* Always call `release(name)` after the Room object is no longer needed.
*
* If a call to `get` is made for a room that is currently being released, then the promise will resolve only when
* the release operation is complete.
*
* If a call to `get` is made, followed by a subsequent call to `release` before the promise resolves, then the
* promise will reject with an error.
* @param name The unique identifier of the room.
* @param options The options for the room.
* @throws {@link ErrorInfo} if a room with the same name but different options already exists.
* @returns Room A promise to a new or existing Room object.
*/
get(name: string, options?: RoomOptions): Promise<Room>;
/**
* Release the Room object if it exists. This method only releases the reference
* to the Room object from the Rooms instance and detaches the room from Ably. It does not unsubscribe to any
* events.
*
* After calling this function, the room object is no-longer usable. If you wish to get the room object again,
* you must call {@link Rooms.get}.
*
* Calling this function will abort any in-progress `get` calls for the same room.
* @param name The unique identifier of the room.
*/
release(name: string): Promise<void>;
/**
* Disposes all rooms that are currently in the rooms map.
* This method releases all rooms concurrently and clears the rooms map.
* @returns A promise that resolves when all rooms have been released.
*/
dispose(): Promise<void>;
/**
* Get the client options used to create the Chat instance.
* @returns ChatClientOptions
*/
get clientOptions(): ChatClientOptions;
/**
* Get the number of rooms currently in the rooms map.
* @returns The number of rooms currently in the rooms map.
*/
get count(): number;
}
/**
* Represents an entry in the chat room map.
*/
interface RoomMapEntry {
/**
* The promise that will eventually resolve to the room.
*/
promise: Promise<DefaultRoom>;
/**
* A random, internal identifier useful for debugging and logging.
*/
nonce: string;
/**
* The options for the room.
*/
options: RoomOptions | undefined;
/**
* An abort controller to abort the get operation if the room is released before the get operation completes.
*/
abort?: AbortController;
}
/**
* Manages the chat rooms.
*/
export class DefaultRooms implements Rooms {
private readonly _realtime: Ably.Realtime;
private readonly _chatApi: ChatApi;
private readonly _clientOptions: NormalizedChatClientOptions;
private readonly _rooms: Map<string, RoomMapEntry> = new Map<string, RoomMapEntry>();
private readonly _releasing = new Map<string, Promise<void>>();
private readonly _logger: Logger;
private _isReact = false;
private _disposed = false;
/**
* Constructs a new Rooms instance.
* @param realtime An instance of the Ably Realtime client.
* @param clientOptions The client options from the chat instance.
* @param logger An instance of the Logger.
*/
constructor(realtime: Ably.Realtime, clientOptions: NormalizedChatClientOptions, logger: Logger) {
this._realtime = realtime;
this._chatApi = new ChatApi(realtime, logger);
this._clientOptions = clientOptions;
this._logger = logger;
}
/**
* @inheritDoc
*/
async get(name: string, options?: RoomOptions): Promise<Room> {
this._logger.trace('Rooms.get();', { roomName: name });
this._ensureNotDisposed();
const existingRoom = this._rooms.get(name);
if (existingRoom) {
return this._handleExistingRoom(existingRoom, name, options);
}
const ongoingRelease = this._releasing.get(name);
const nonce = randomId();
if (!ongoingRelease) {
return this._createNewRoom(name, nonce, options);
}
return this._waitForReleaseAndCreateRoom(name, nonce, options, ongoingRelease);
}
/**
* @inheritDoc
*/
async release(name: string): Promise<void> {
this._logger.trace('Rooms.release();', { roomName: name });
const existingRoom = this._rooms.get(name);
const ongoingRelease = this._releasing.get(name);
if (!existingRoom) {
return this._handleNonExistentRoomRelease(name, ongoingRelease);
}
if (ongoingRelease) {
return this._handleConcurrentRelease(name, existingRoom, ongoingRelease);
}
return this._performRoomRelease(name, existingRoom);
}
/**
* Disposes all rooms that are currently in the rooms map and waits for any ongoing release operations to complete.
* This method releases all rooms concurrently, waits for any in-flight releases to finish, and clears the rooms map.
* After this method resolves, all rooms will have been fully released and cleaned up.
* @internal
* @returns A promise that resolves when all rooms have been released.
*/
async dispose(): Promise<void> {
this._logger.trace('Rooms.dispose();');
// Mark this instance as disposed
this._disposed = true;
// Get all room names currently in the map
const roomNames = [...this._rooms.keys()];
if (roomNames.length === 0) {
this._logger.debug('Rooms.dispose(); no rooms to release');
return;
}
// Release all rooms concurrently
const releasePromises = roomNames.map((roomName) => this.release(roomName));
// Ensure we wait for all ongoing releases too, since we guarantee that all rooms are released after this call
// resolves.
const inFlight = [...this._releasing.values()];
const all = [...releasePromises, ...inFlight];
this._logger.debug('Rooms.dispose(); releasing rooms', { roomCount: roomNames.length, roomNames });
await Promise.all(all);
this._logger.debug('Rooms.dispose(); all rooms released successfully');
}
/**
* Get the client options used to create the Chat instance.
* @returns ChatClientOptions
*/
get clientOptions(): ChatClientOptions {
return this._clientOptions;
}
/**
* @inheritDoc
*/
get count(): number {
return this._rooms.size;
}
/**
* Ensures the rooms instance has not been disposed.
*/
private _ensureNotDisposed(): void {
if (this._disposed) {
throw new Ably.ErrorInfo('cannot get room, rooms instance has been disposed', 40000, 400);
}
}
/**
* Handles the case where a room already exists.
* @param existingRoom The existing room entry in the map.
* @param name The unique identifier of the room.
* @param options The options for the room.
* @returns A promise that resolves to the existing room.
*/
private async _handleExistingRoom(existingRoom: RoomMapEntry, name: string, options?: RoomOptions): Promise<Room> {
if (!dequal(existingRoom.options, options)) {
throw new Ably.ErrorInfo('room already exists with different options', 40000, 400);
}
this._logger.debug('Rooms.get(); returning existing room', {
roomName: name,
nonce: existingRoom.nonce,
options,
});
return await existingRoom.promise;
}
/**
* Creates a new room when no existing room or ongoing release exists.
* @param name The unique identifier of the room.
* @param nonce A random, internal identifier useful for debugging and logging.
* @param options The options for the room.
* @returns A new room object.
*/
private _createNewRoom(name: string, nonce: string, options?: RoomOptions): Room {
const room = this._makeRoom(name, nonce, options);
const entry: RoomMapEntry = {
promise: Promise.resolve(room),
nonce: nonce,
options: options,
};
this._rooms.set(name, entry);
this._logger.debug('Rooms.get(); returning new room', { roomName: name, nonce: room.nonce });
return room; // No need to await Promise.resolve(room)
}
/**
* Waits for an ongoing release to complete, then creates a new room.
* @param name The unique identifier of the room.
* @param nonce A random, internal identifier useful for debugging and logging.
* @param options The options for the room.
* @param ongoingRelease The promise of an ongoing release operation.
* @returns A promise that resolves to a room.
*/
private async _waitForReleaseAndCreateRoom(
name: string,
nonce: string,
options: RoomOptions | undefined,
ongoingRelease: Promise<void>,
): Promise<Room> {
const abortController = new AbortController();
const roomPromise = this._createAbortableRoomPromise(name, nonce, options, ongoingRelease, abortController);
this._rooms.set(name, {
promise: roomPromise,
options: options,
nonce: nonce,
abort: abortController,
});
this._logger.debug('Rooms.get(); creating new promise dependent on previous release', { roomName: name });
return await roomPromise;
}
/**
* Creates a promise that can be aborted if the room is released before completion.
* @param name The unique identifier of the room.
* @param nonce A random, internal identifier useful for debugging and logging.
* @param options The options for the room.
* @param ongoingRelease A promise that resolves when the previous release operation is complete.
* @param abortController An AbortController to manage the abort signal.
* @returns A promise that resolves to a new room or rejects if the operation is aborted.
*/
private _createAbortableRoomPromise(
name: string,
nonce: string,
options: RoomOptions | undefined,
ongoingRelease: Promise<void>,
abortController: AbortController,
): Promise<DefaultRoom> {
return new Promise<DefaultRoom>((resolve, reject) => {
const abortListener = () => {
this._logger.debug('Rooms.get(); aborted before init', { roomName: name });
reject(
new Ably.ErrorInfo(
'room released before get operation could complete',
ErrorCode.RoomReleasedBeforeOperationCompleted,
400,
),
);
};
abortController.signal.addEventListener('abort', abortListener);
ongoingRelease
.then(() => {
if (abortController.signal.aborted) {
this._logger.debug('Rooms.get(); aborted before releasing promise resolved', { roomName: name });
return;
}
this._logger.debug('Rooms.get(); releasing finished', { roomName: name });
const room = this._makeRoom(name, nonce, options);
abortController.signal.removeEventListener('abort', abortListener);
resolve(room);
})
.catch((error: unknown) => {
abortController.signal.removeEventListener('abort', abortListener);
reject(error as Error);
});
});
}
/**
* Handles release when no room exists.
* @param name The unique identifier of the room.
* @param ongoingRelease An ongoing release promise, if any.
* @returns A promise that resolves when the release operation is complete.
*/
private async _handleNonExistentRoomRelease(name: string, ongoingRelease?: Promise<void>): Promise<void> {
if (ongoingRelease) {
this._logger.debug('Rooms.release(); waiting for previous release call', { roomName: name });
await ongoingRelease;
return;
}
this._logger.debug('Rooms.release(); room does not exist', { roomName: name });
}
/**
* Handles release when there's already a release in progress.
* @param name The unique identifier of the room.
* @param existingRoom The existing room entry in the map.
* @param ongoingRelease The promise of an ongoing release operation.
*/
private async _handleConcurrentRelease(
name: string,
existingRoom: RoomMapEntry,
ongoingRelease: Promise<void>,
): Promise<void> {
if (existingRoom.abort) {
this._logger.debug('Rooms.release(); aborting get call', {
roomName: name,
existingNonce: existingRoom.nonce,
});
existingRoom.abort.abort();
this._rooms.delete(name);
}
await ongoingRelease;
}
/**
* Performs the actual room release operation.
* @param name The unique identifier of the room.
* @param existingRoom The existing room entry in the map.
*/
private async _performRoomRelease(name: string, existingRoom: RoomMapEntry): Promise<void> {
this._rooms.delete(name);
const releasePromise = this._executeRoomRelease(name, existingRoom);
this._releasing.set(name, releasePromise);
this._logger.debug('Rooms.release(); creating new release promise', {
roomName: name,
nonce: existingRoom.nonce,
});
await releasePromise;
}
/**
* Executes the room release and cleanup.
* @param name The unique identifier of the room.
* @param existingRoom The existing room entry in the map.
*/
private async _executeRoomRelease(name: string, existingRoom: RoomMapEntry): Promise<void> {
const room = await existingRoom.promise;
this._logger.debug('Rooms.release(); releasing room', { roomName: name, nonce: existingRoom.nonce });
await room.release();
this._logger.debug('Rooms.release(); room released', { roomName: name, nonce: existingRoom.nonce });
this._releasing.delete(name);
}
/**
* makes a new room object
* @param name The unique identifier of the room.
* @param nonce A random, internal identifier useful for debugging and logging.
* @param options The options for the room.
* @returns DefaultRoom A new room object.
*/
private _makeRoom(name: string, nonce: string, options: RoomOptions | undefined): DefaultRoom {
return new DefaultRoom(
name,
nonce,
normalizeRoomOptions(options, this._isReact),
this._realtime,
this._chatApi,
this._logger,
);
}
/**
* Sets react JS mode.
*/
useReact(): void {
this._logger.trace('Rooms.useReact();');
this._isReact = true;
}
}