UNPKG

@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

362 lines (321 loc) 12.2 kB
import * as Ably from 'ably'; import { dequal } from 'dequal'; import { ChatClient } from '../../core/chat-client.js'; import { ErrorCode } from '../../core/errors.js'; import { Logger } from '../../core/logger.js'; import { Room } from '../../core/room.js'; import { RoomOptions } from '../../core/room-options.js'; /** * Reference counting entry for a room. */ interface RoomRefCountEntry { count: number; roomName: string; options?: RoomOptions; resolvedRoom?: Room; resolutionError?: Ably.ErrorInfo; pendingRelease?: ReturnType<typeof setTimeout>; } /** * Normalizes an array item by sorting the keys of the object and recursively sorting the items in the array. * @param item The item to normalize. * @returns The normalized item. */ const normalizeArrayItem = (item: unknown): unknown => { if (item === null || typeof item !== 'object') { return item; } if (Array.isArray(item)) { return item.sort().map((nestedItem) => normalizeArrayItem(nestedItem)); } // For objects in arrays, sort keys const sortedObj: Record<string, unknown> = {}; const keys = Object.keys(item as Record<string, unknown>).sort(); for (const objKey of keys) { sortedObj[objKey] = normalizeArrayItem((item as Record<string, unknown>)[objKey]); } return sortedObj; }; /** * Replacer function for JSON.stringify that normalizes values to ensure consistent serialization. * Recursively sorts object keys and array items for deterministic output. * @param key The key being processed. * @param value The value being processed. * @returns The normalized value. */ const roomKeyReplacer = (key: string, value: unknown): unknown => { // Handle undefined values consistently if (value === undefined) { return undefined; } // Handle null or non-object values if (value === null || typeof value !== 'object') { return value; } // Handle arrays - sort the items and recursively normalize if (Array.isArray(value)) { return value.sort().map((item) => normalizeArrayItem(item)); } // Handle objects - sort the keys const sortedObj: Record<string, unknown> = {}; const keys = Object.keys(value as Record<string, unknown>).sort(); for (const objKey of keys) { sortedObj[objKey] = (value as Record<string, unknown>)[objKey]; } return sortedObj; }; /** * Creates a unique key for a room based on name and options. * Ensures that objects with the same properties but different key order produce the same key. * @param roomName The name of the room. * @param options The room options. * @returns A unique string key for the room. */ const createRoomKey = (roomName: string, options?: RoomOptions): string => JSON.stringify({ roomName, options }, roomKeyReplacer); /** * Reference counting manager for rooms within a ChatClientProvider. * This manages attach/release lifecycle based on reference counts. */ export class RoomReferenceManager { private readonly _refCounts = new Map<string, RoomRefCountEntry>(); private readonly _client: ChatClient; private readonly _logger: Logger; private readonly _releaseDelayMs = 100; // Delay before actually releasing to allow for abort private readonly _pendingReleases = new Map<string, Promise<void>>(); // Track pending releases by room name constructor(client: ChatClient, logger: Logger) { this._client = client; this._logger = logger; } /** * Get the client this manager is associated with. * @returns The chat client. */ get client(): ChatClient { return this._client; } /** * Increment reference count for a room. Attaches on first reference. * @param roomName The name of the room. * @param options The room options. * @returns A promise that resolves to the room instance. */ async addReference(roomName: string, options?: RoomOptions): Promise<Room> { this._logger.trace('RoomReferenceManager.addReference();'); const key = createRoomKey(roomName, options); const existing = this._refCounts.get(key); if (existing) { // If there's a pending release, abort it if (existing.pendingRelease) { clearTimeout(existing.pendingRelease); existing.pendingRelease = undefined; this._logger.debug('RoomReferenceManager.addReference(); aborted pending release', { roomName, options, }); } existing.count++; this._logger.debug('RoomReferenceManager.addReference(); incremented ref count', { roomName, options, count: existing.count, }); // Always return the resolved room from the existing reference // We should never call client.rooms.get() again as that would cause // "room already exists with different options" errors if (existing.resolvedRoom) { return existing.resolvedRoom; } // If the room hasn't resolved yet, wait for it // This shouldn't happen in normal circumstances since we await the room // creation below, but it's a safety net // We'll run a promise that checks every 100ms to see if the room has resolved return new Promise((resolve, reject) => { const interval = setInterval(() => { if (existing.resolvedRoom) { clearInterval(interval); resolve(existing.resolvedRoom); } else if (existing.resolutionError) { clearInterval(interval); reject(existing.resolutionError); } }, 100); }); } // Check if there's a pending release for this room name (regardless of options) const pendingRelease = this._pendingReleases.get(roomName); if (pendingRelease) { this._logger.debug('RoomReferenceManager.addReference(); waiting for pending release to complete', { roomName, options, }); try { await pendingRelease; } catch (error) { this._logger.debug('RoomReferenceManager.addReference(); pending release failed, continuing', { roomName, options, error, }); } } // Check if there's already a room with the same name but different options const existingWithDifferentOptions = [...this._refCounts.values()].find( (entry) => entry.roomName === roomName && !dequal(entry.options, options), ); if (existingWithDifferentOptions) { // Only allow different options if: // 1. There's a pending release for the existing room, OR // 2. The reference count is 0 (meaning it's scheduled for release) const hasPendingRelease = existingWithDifferentOptions.pendingRelease !== undefined; const hasZeroRefCount = existingWithDifferentOptions.count <= 0; if (!hasPendingRelease && !hasZeroRefCount) { // Room is actively being used with different options - throw an error throw new Ably.ErrorInfo( `cannot get room with different options; room "${roomName}" is already in use with different options`, ErrorCode.BadRequest, 400, ); } this._logger.debug( 'RoomReferenceManager.addReference(); found existing room with different options, releasing it first', { roomName, newOptions: options, existingOptions: existingWithDifferentOptions.options, hasPendingRelease, hasZeroRefCount, }, ); // Cancel any pending release for the existing room if (existingWithDifferentOptions.pendingRelease) { clearTimeout(existingWithDifferentOptions.pendingRelease); existingWithDifferentOptions.pendingRelease = undefined; } // Remove the existing room reference immediately const existingKey = createRoomKey(roomName, existingWithDifferentOptions.options); this._refCounts.delete(existingKey); // Create and track the release promise const releasePromise = this._client.rooms .release(roomName) .catch((error: unknown) => { this._logger.debug('RoomReferenceManager.addReference(); release of existing room failed', { roomName, error, }); }) .finally(() => { // Remove the pending release tracking when done this._pendingReleases.delete(roomName); }); // Track the pending release this._pendingReleases.set(roomName, releasePromise); // Wait for the release to complete try { await releasePromise; } catch (error) { this._logger.debug('RoomReferenceManager.addReference(); release of existing room failed, continuing', { roomName, error, }); } } // First reference - create entry and attach const entry: RoomRefCountEntry = { count: 1, roomName, options, }; this._refCounts.set(key, entry); this._logger.debug('RoomReferenceManager.addReference(); first reference, attaching room', { roomName, options, }); try { const room = await this._client.rooms.get(roomName, options); entry.resolvedRoom = room; // Attach the room on first reference void room.attach().catch((error: unknown) => { this._logger.error('RoomReferenceManager.addReference(); error attaching room', { roomName, options, error, }); }); return room; } catch (error: unknown) { // If room creation failed, clean up the entry, but also set the resolution error entry.resolutionError = error as Ably.ErrorInfo; this._refCounts.delete(key); this._logger.error('RoomReferenceManager.addReference(); error creating room', { roomName, options, error, }); throw error; } } /** * Decrement reference count for a room. Releases on last reference after a delay. * @param roomName The name of the room. * @param options The room options. */ removeReference(roomName: string, options?: RoomOptions): void { this._logger.trace('RoomReferenceManager.removeReference();'); const key = createRoomKey(roomName, options); const existing = this._refCounts.get(key); if (!existing) { this._logger.debug('RoomReferenceManager.removeReference(); no existing reference found', { roomName, options, }); return; } existing.count--; this._logger.debug('RoomReferenceManager.removeReference(); decremented ref count', { roomName, options, count: existing.count, }); if (existing.count <= 0) { // Schedule release after a delay to allow for abort this._logger.debug('RoomReferenceManager.removeReference(); scheduling delayed release', { roomName, options, delayMs: this._releaseDelayMs, }); existing.pendingRelease = setTimeout(() => { // Double-check that the entry still exists and should be released const currentEntry = this._refCounts.get(key); if (currentEntry && currentEntry.count <= 0) { this._refCounts.delete(key); this._logger.debug('RoomReferenceManager.removeReference(); executing delayed release', { roomName, options, }); // Create a promise for the release operation and track it const releasePromise = this._client.rooms .release(roomName) .catch((error: unknown) => { this._logger.debug('RoomReferenceManager.removeReference(); release failed', { roomName, options, error, }); }) .finally(() => { // Remove the pending release tracking when done this._pendingReleases.delete(roomName); }); // Track the pending release by room name this._pendingReleases.set(roomName, releasePromise); } }, this._releaseDelayMs); } } getReferenceCount(roomName: string, options?: RoomOptions): number { this._logger.trace('RoomReferenceManager.getReferenceCount();'); const key = createRoomKey(roomName, options); return this._refCounts.get(key)?.count ?? 0; } }