@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
218 lines (189 loc) • 7.04 kB
text/typescript
import * as Ably from 'ably';
import { ChannelOptionsMerger } from './channel-manager.js';
import { ChatApi } from './chat-api.js';
import { OccupancyEvent, OccupancyEventType, RealtimeMetaEventType } from './events.js';
import { Logger } from './logger.js';
import { OccupancyData, parseOccupancyMessage } from './occupancy-parser.js';
import { subscribe } from './realtime-subscriptions.js';
import { InternalRoomOptions } from './room-options.js';
import { Subscription } from './subscription.js';
import EventEmitter, { emitterHasListeners, wrap } from './utils/event-emitter.js';
/**
* This interface is used to interact with occupancy in a chat room: subscribing to occupancy updates and
* fetching the current room occupancy metrics.
*
* Get an instance via {@link Room.occupancy}.
*/
export interface Occupancy {
/**
* Subscribe a given listener to occupancy updates of the chat room.
*
* Note: This requires occupancy events to be enabled via the `enableEvents` option in
* the {@link OccupancyOptions} options provided to the room. If this is not enabled, an error will be thrown.
* @param listener A listener to be called when the occupancy of the room changes.
* @returns A subscription object that can be used to unsubscribe the listener.
* @throws {Ably.ErrorInfo} If occupancy events are not enabled for this room.
*/
subscribe(listener: OccupancyListener): Subscription;
/**
* Get the current occupancy of the chat room.
* @returns A promise that resolves to the current occupancy of the chat room.
*/
get(): Promise<OccupancyData>;
/**
* Get the latest occupancy data received from realtime events.
* @returns The latest occupancy data, or undefined if no realtime events have been received yet.
* @throws {Ably.ErrorInfo} If occupancy events are not enabled for this room.
*/
current(): OccupancyData | undefined;
}
/**
* A listener that is called when the occupancy of a chat room changes.
* @param event The occupancy event.
*/
export type OccupancyListener = (event: OccupancyEvent) => void;
interface OccupancyEventsMap {
[OccupancyEventType.Updated]: OccupancyEvent;
}
/**
* @inheritDoc
*/
export class DefaultOccupancy implements Occupancy {
private readonly _roomName: string;
private readonly _channel: Ably.RealtimeChannel;
private readonly _chatApi: ChatApi;
private readonly _logger: Logger;
private readonly _emitter = new EventEmitter<OccupancyEventsMap>();
private readonly _roomOptions: InternalRoomOptions;
private _latestOccupancyData?: OccupancyData;
private readonly _unsubscribeOccupancyEvents: () => void;
/**
* Constructs a new `DefaultOccupancy` instance.
* @param roomName The unique identifier of the room.
* @param channel An instance of the Realtime channel.
* @param chatApi An instance of the ChatApi.
* @param logger An instance of the Logger.
* @param roomOptions The room options.
*/
constructor(
roomName: string,
channel: Ably.RealtimeChannel,
chatApi: ChatApi,
logger: Logger,
roomOptions: InternalRoomOptions,
) {
this._roomName = roomName;
this._channel = channel;
this._chatApi = chatApi;
this._logger = logger;
this._roomOptions = roomOptions;
// Create bound listener
const occupancyEventsListener = this._internalOccupancyListener.bind(this);
// Use subscription helper to create cleanup function
if (this._roomOptions.occupancy.enableEvents) {
this._logger.debug('DefaultOccupancy(); subscribing to occupancy events');
this._unsubscribeOccupancyEvents = subscribe(
this._channel,
[RealtimeMetaEventType.Occupancy],
occupancyEventsListener,
);
} else {
this._unsubscribeOccupancyEvents = () => {
// No-op function when events are not enabled
};
}
}
/**
* @inheritdoc
*/
subscribe(listener: OccupancyListener): Subscription {
this._logger.trace('Occupancy.subscribe();');
if (!this._roomOptions.occupancy.enableEvents) {
throw new Ably.ErrorInfo(
'cannot subscribe to occupancy; occupancy events are not enabled in room options',
40000,
400,
) as unknown as Error;
}
const wrapped = wrap(listener);
this._emitter.on(wrapped);
return {
unsubscribe: () => {
this._logger.trace('Occupancy.unsubscribe();');
this._emitter.off(wrapped);
},
};
}
/**
* @inheritdoc
*/
async get(): Promise<OccupancyData> {
this._logger.trace('Occupancy.get();');
return this._chatApi.getOccupancy(this._roomName);
}
/**
* @inheritdoc
*/
current(): OccupancyData | undefined {
this._logger.trace('Occupancy.current();');
// CHA-O7c
if (!this._roomOptions.occupancy.enableEvents) {
throw new Ably.ErrorInfo(
'cannot get current occupancy; occupancy events are not enabled in room options',
40000,
400,
) as unknown as Error;
}
// CHA-07a
// CHA-07b
return this._latestOccupancyData;
}
/**
* An internal listener that listens for occupancy events from the underlying channel and translates them into
* occupancy events for the public API.
* @param message The inbound message containing occupancy data.
*/
private _internalOccupancyListener(message: Ably.InboundMessage): void {
this._logger.trace('Occupancy._internalOccupancyListener();', message);
this._latestOccupancyData = parseOccupancyMessage(message);
this._emitter.emit(OccupancyEventType.Updated, {
type: OccupancyEventType.Updated,
occupancy: this._latestOccupancyData,
});
}
/**
* Merges the channel options for the room with the ones required for occupancy.
* @param roomOptions The internal room options.
* @returns A function that merges the channel options for the room with the ones required for occupancy.
*/
static channelOptionMerger(roomOptions: InternalRoomOptions): ChannelOptionsMerger {
return (options) => {
// Occupancy not required, so we can skip this.
if (!roomOptions.occupancy.enableEvents) {
return options;
}
return { ...options, params: { ...options.params, occupancy: 'metrics' } };
};
}
/**
* Disposes of the occupancy instance, removing all listeners and subscriptions.
* This method should be called when the room is being released to ensure proper cleanup.
* @internal
*/
dispose(): void {
this._logger.trace('DefaultOccupancy.dispose();');
// Remove occupancy event subscriptions using stored unsubscribe function
this._unsubscribeOccupancyEvents();
// Remove user-level listeners
this._emitter.off();
this._logger.debug('DefaultOccupancy.dispose(); disposed successfully');
}
/**
* Checks if there are any listeners registered by users.
* @internal
* @returns true if there are listeners, false otherwise.
*/
hasListeners(): boolean {
return emitterHasListeners(this._emitter);
}
}