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

520 lines (467 loc) 18.6 kB
import * as Ably from 'ably'; import { E_CANCELED, Mutex } from 'async-mutex'; import { TypingEventType, TypingSetEvent, TypingSetEventType } from './events.js'; import { Logger } from './logger.js'; import { ephemeralMessage } from './realtime.js'; import { subscribe } from './realtime-subscriptions.js'; import { InternalTypingOptions } from './room-options.js'; import { Subscription } from './subscription.js'; import EventEmitter, { wrap } from './utils/event-emitter.js'; /** * This interface is used to interact with typing in a chat room including subscribing to typing events and * fetching the current set of typing clients. * * Get an instance via {@link Room.typing}. */ export interface Typing { /** * Subscribe a given listener to all typing events from users in the chat room. * @param listener A listener to be called when the typing state of a user in the room changes. * @returns A response object that allows you to control the subscription to typing events. */ subscribe(listener: TypingListener): Subscription; /** * Get the current typers, a set of clientIds. * @returns The set of clientIds that are currently typing. */ current(): Set<string>; /** * This will send a `typing.started` event to the server. * Events are throttled according to the `heartbeatThrottleMs` room option. * If an event has been sent within the interval, this operation is no-op. * * * Calls to `keystroke()` and `stop()` are serialized and will always resolve in the correct order. * - For example, if multiple `keystroke()` calls are made in quick succession before the first `keystroke()` call has * sent a `typing.started` event to the server, followed by one `stop()` call, the `stop()` call will execute * as soon as the first `keystroke()` call completes. * All intermediate `keystroke()` calls will be treated as no-ops. * - The most recent operation (`keystroke()` or `stop()`) will always determine the final state, ensuring operations * resolve to a consistent and correct state. * @returns A promise which resolves upon success of the operation and rejects with an {@link Ably.ErrorInfo} object upon its failure. * @throws If the `Connection` is not in the `Connected` state. * @throws If the operation fails to send the event to the server. * @throws If there is a problem acquiring the mutex that controls serialization. */ keystroke(): Promise<void>; /** * This will send a `typing.stopped` event to the server. * If the user was not currently typing, this operation is no-op. * * Calls to `keystroke()` and `stop()` are serialized and will always resolve in the correct order. * - For example, if multiple `keystroke()` calls are made in quick succession before the first `keystroke()` call has * sent a `typing.started` event to the server, followed by one `stop()` call, the `stop()` call will execute * as soon as the first `keystroke()` call completes. * All intermediate `keystroke()` calls will be treated as no-ops. * - The most recent operation (`keystroke()` or `stop()`) will always determine the final state, ensuring operations * resolve to a consistent and correct state. * @returns A promise which resolves upon success of the operation and rejects with an {@link Ably.ErrorInfo} object upon its failure. * @throws If the `Connection` is not in the `Connected` state. * @throws If the operation fails to send the event to the server. * @throws If there is a problem acquiring the mutex that controls serialization. */ stop(): Promise<void>; } /** * A listener which listens for typing events. * @param event The typing event. */ export type TypingListener = (event: TypingSetEvent) => void; /** * Represents the typing events mapped to their respective event payloads. */ interface TypingEventsMap { [TypingSetEventType.SetChanged]: TypingSetEvent; } /** * Represents a timer handle that can be undefined. */ type TypingTimerHandle = ReturnType<typeof setTimeout> | undefined; /** * @inheritDoc */ export class DefaultTyping extends EventEmitter<TypingEventsMap> implements Typing { private readonly _clientId: string; private readonly _channel: Ably.RealtimeChannel; private readonly _connection: Ably.Connection; private readonly _logger: Logger; // Throttle for the heartbeat, how often we should emit a typing event with repeated calls to keystroke() // CHA-T10 private readonly _heartbeatThrottleMs: number; // Grace period for inactivity before another user is considered to have stopped typing // CHA-T10a private readonly _timeoutMs = 2000; private _heartbeatTimerId: TypingTimerHandle; private readonly _currentlyTyping: Map<string, TypingTimerHandle>; // Mutex for controlling `keystroke` and `stop` operations private readonly _mutex = new Mutex(); // Cleanup function for the channel subscription private readonly _unsubscribeTypingEvents: () => void; /** * Constructs a new `DefaultTyping` instance. * @param options The options for typing in the room. * @param connection The connection instance. * @param channel The channel for the room. * @param clientId The client ID of the user. * @param logger An instance of the Logger. */ constructor( options: InternalTypingOptions, connection: Ably.Connection, channel: Ably.RealtimeChannel, clientId: string, logger: Logger, ) { super(); this._clientId = clientId; this._channel = channel; this._connection = connection; // Interval for the heartbeat, how often we should emit a typing event with repeated calls to start() this._heartbeatThrottleMs = options.heartbeatThrottleMs; // Map of clientIds to their typing timers, used to track typing state this._currentlyTyping = new Map<string, TypingTimerHandle>(); this._logger = logger; // Use subscription helper to create cleanup function this._unsubscribeTypingEvents = subscribe( this._channel, [TypingEventType.Started, TypingEventType.Stopped], this._internalSubscribeToEvents.bind(this), ); } /** * Clears all typing states. * This includes clearing all timeouts and the currently typing map. */ private _clearAllTypingStates(): void { this._logger.debug(`DefaultTyping._clearAllTypingStates(); clearing all typing states`); this._clearHeartbeatTimer(); this._clearCurrentlyTyping(); } /** * Clears the heartbeat timer. */ private _clearHeartbeatTimer(): void { this._logger.trace(`DefaultTyping._clearHeartbeatTimer(); clearing heartbeat timer`); if (this._heartbeatTimerId) { clearTimeout(this._heartbeatTimerId); this._heartbeatTimerId = undefined; } } /** * Clears the currently typing store and removes all timeouts for associated clients. */ private _clearCurrentlyTyping(): void { this._logger.trace('DefaultTyping._clearCurrentlyTyping(); clearing current store and timeouts'); // Clear all client typing timeouts for (const [, timeoutId] of this._currentlyTyping.entries()) { clearTimeout(timeoutId); } // Clear the currently typing map this._currentlyTyping.clear(); } /** * CHA-T16 * @inheritDoc */ current(): Set<string> { this._logger.trace(`DefaultTyping.current();`); return new Set<string>(this._currentlyTyping.keys()); } /** * @inheritDoc */ get channel(): Ably.RealtimeChannel { return this._channel; } /** * Start the heartbeat timer. This will expire after the configured interval. */ private _startHeartbeatTimer(): void { if (!this._heartbeatTimerId) { this._logger.trace(`DefaultTyping.startHeartbeatTimer();`); const timer = (this._heartbeatTimerId = setTimeout(() => { this._logger.debug(`DefaultTyping.startHeartbeatTimer(); heartbeat timer expired`); // CHA-T2a if (timer === this._heartbeatTimerId) { this._heartbeatTimerId = undefined; } }, this._heartbeatThrottleMs)); } } /** * @inheritDoc */ async keystroke(): Promise<void> { this._logger.trace(`DefaultTyping.keystroke();`); this._mutex.cancel(); // Acquire a mutex try { await this._mutex.acquire(); } catch (error: unknown) { if (error === E_CANCELED) { this._logger.debug(`DefaultTyping.keystroke(); mutex was canceled by a later operation`); return; } throw new Ably.ErrorInfo('mutex acquisition failed', 50000, 500); } try { // Check if connection is connected // CHA-T4e if (this._connection.state !== 'connected') { this._logger.error(`DefaultTyping.keystroke(); connection is not connected`, { status: this._connection.state, }); throw new Ably.ErrorInfo('cannot type, connection is not connected', 40000, 400); } // Check whether user is already typing before publishing again // CHA-T4c1, CHA-T4c2 if (this._heartbeatTimerId) { this._logger.debug(`DefaultTyping.keystroke(); no-op, already typing and heartbeat timer has not expired`); return; } // Perform the publish // CHA-T4a3 await this._channel.publish(ephemeralMessage(TypingEventType.Started)); // Start the timer after publishing // CHA-T4a5 this._startHeartbeatTimer(); this._logger.trace(`DefaultTyping.keystroke(); starting timers`); } finally { this._logger.trace(`DefaultTyping.keystroke(); releasing mutex`); this._mutex.release(); } } /** * @inheritDoc */ async stop(): Promise<void> { this._logger.trace(`DefaultTyping.stop();`); this._mutex.cancel(); // Acquire a mutex try { await this._mutex.acquire(); } catch (error: unknown) { if (error === E_CANCELED) { this._logger.debug(`DefaultTyping.stop(); mutex was canceled by a later operation`); return; } throw new Ably.ErrorInfo('mutex acquisition failed', 50000, 500); } try { // Check if connection is connected if (this._connection.state !== 'connected') { this._logger.error(`DefaultTyping.stop(); connection is not connected`, { status: this._connection.state, }); throw new Ably.ErrorInfo('cannot stop typing, connection is not connected', 40000, 400); } // If the user is not typing, do nothing. // CHA-T5f if (!this._heartbeatTimerId) { this._logger.debug(`DefaultTyping.stop(); no-op, not currently typing`); return; } // CHA-T5d await this._channel.publish(ephemeralMessage(TypingEventType.Stopped)); this._logger.trace(`DefaultTyping.stop(); clearing timers`); // CHA-T5e // Clear the heartbeat timer clearTimeout(this._heartbeatTimerId); this._heartbeatTimerId = undefined; } finally { this._logger.trace(`DefaultTyping.stop(); releasing mutex`); this._mutex.release(); } } /** * @inheritDoc */ subscribe(listener: TypingListener): Subscription { this._logger.trace(`DefaultTyping.subscribe();`); const wrapped = wrap(listener); this.on(wrapped); return { unsubscribe: () => { this._logger.trace('DefaultTyping.unsubscribe();'); this.off(wrapped); }, }; } /** * @inheritDoc */ // CHA-RL3h async dispose(): Promise<void> { this._logger.trace(`DefaultTyping.dispose();`); // Keep trying to acquire the mutex; wait 200 ms between attempts. for (;;) { try { this._mutex.cancel(); await this._mutex.acquire(); break; // success – exit the loop } catch (error: unknown) { if (error === E_CANCELED) { // In this case, the mutex was canceled by a later operation, // but we are trying to release, so we should always take precedence here. // Let's continue trying to acquire it until we win the acquisition lock. this._logger.debug(`DefaultTyping.dispose(); mutex was canceled`); await new Promise((resolve) => setTimeout(resolve, 200)); this._logger.debug(`DefaultTyping.dispose(); retrying mutex acquisition`); } else { // If we encounter any other error, we log it and exit the loop. // This is to ensure that we don't get stuck in an infinite loop // if the mutex acquisition fails for some other non-retryable reason. this._logger.error(`DefaultTyping.dispose(); failed to acquire mutex; could not complete resource disposal`, { error, }); return; } } } this._clearAllTypingStates(); this._unsubscribeTypingEvents(); this.off(); this._mutex.release(); } /** * Update the currently typing users. This method is called when a typing event is received. * It will also acquire a mutex to ensure that the currentlyTyping state is updated safely. * @param clientId The client ID of the user. * @param event The typing event. */ private _updateCurrentlyTyping(clientId: string, event: TypingEventType): void { this._logger.trace(`DefaultTyping._updateCurrentlyTyping();`, { clientId, event }); if (event === TypingEventType.Started) { this._handleTypingStart(clientId); } else { this._handleTypingStop(clientId); } } /** * Starts a new inactivity timer for the client. * This timer will expire after the configured timeout, * which is the sum of the heartbeat interval and the inactivity timeout. * @param clientId The client ID for which to start the timer. * @returns The timeout ID for the new timer. */ private _startNewClientInactivityTimer(clientId: string): ReturnType<typeof setTimeout> { this._logger.trace(`DefaultTyping._startNewClientInactivityTimer(); starting new inactivity timer`, { clientId, }); // Set or reset the typing timeout for this client const timeoutId = setTimeout(() => { this._logger.trace(`DefaultTyping._startNewClientInactivityTimer(); client typing timeout expired`, { clientId, }); // Verify the timer is still valid (it might have been reset) if (this._currentlyTyping.get(clientId) !== timeoutId) { this._logger.debug(`DefaultTyping._startNewClientInactivityTimer(); timeout already cleared; ignoring`, { clientId, }); return; } // Remove client whose timeout has expired this._currentlyTyping.delete(clientId); this.emit(TypingSetEventType.SetChanged, { type: TypingSetEventType.SetChanged, currentlyTyping: new Set<string>(this._currentlyTyping.keys()), change: { clientId, type: TypingEventType.Stopped, }, }); }, this._heartbeatThrottleMs + this._timeoutMs); return timeoutId; } /** * Handles logic for TypingEventType.Started, including starting a new timeout or resetting an existing one. * @param clientId The client ID that started typing. */ private _handleTypingStart(clientId: string): void { this._logger.debug(`DefaultTyping._handleTypingStart();`, { clientId }); // Start a new timeout for the client const timeoutId = this._startNewClientInactivityTimer(clientId); const existingTimeout = this._currentlyTyping.get(clientId); // Set the new timeout for the client this._currentlyTyping.set(clientId, timeoutId); if (existingTimeout) { // Heartbeat - User is already typing, we just need to clear the existing timeout this._logger.debug(`DefaultTyping._handleTypingStart(); received heartbeat for currently typing client`, { clientId, }); clearTimeout(existingTimeout); } else { // Otherwise, we need to emit a new typing event this._logger.debug(`DefaultTyping._handleTypingStart(); new client started typing`, { clientId, }); this.emit(TypingSetEventType.SetChanged, { type: TypingSetEventType.SetChanged, currentlyTyping: new Set<string>(this._currentlyTyping.keys()), change: { clientId, type: TypingEventType.Started, }, }); } } /** * Handles logic for TypingEventType.Stopped, including clearing the timeout for the client. * @param clientId The client ID that stopped typing. */ private _handleTypingStop(clientId: string): void { const existingTimeout = this._currentlyTyping.get(clientId); if (!existingTimeout) { // Stop requested for a client that isn't currently typing this._logger.trace( `DefaultTyping._handleTypingStop(); received "Stop" event for client not in currentlyTyping list`, { clientId }, ); return; } // Stop typing: clear their timeout and remove from the currently typing set this._logger.debug(`DefaultTyping._handleTypingStop(); client stopped typing`, { clientId }); clearTimeout(existingTimeout); this._currentlyTyping.delete(clientId); // Emit stop event only when the client is removed this.emit(TypingSetEventType.SetChanged, { type: TypingSetEventType.SetChanged, currentlyTyping: new Set<string>(this._currentlyTyping.keys()), change: { clientId, type: TypingEventType.Stopped, }, }); } /** * Subscribe to internal events. This listens to events and converts them into typing updates, with validation. * @param inbound The inbound message containing typing event data. */ private _internalSubscribeToEvents = (inbound: Ably.InboundMessage): void => { const { name, clientId } = inbound; this._logger.trace(`DefaultTyping._internalSubscribeToEvents(); received event`, { name, clientId, }); if (!clientId) { this._logger.error(`DefaultTyping._internalSubscribeToEvents(); invalid clientId in received event`, { inbound, }); return; } // Safety check to ensure we are handling only typing events if (name === TypingEventType.Started || name === TypingEventType.Stopped) { this._updateCurrentlyTyping(clientId, name); } else { this._logger.warn(`DefaultTyping._internalSubscribeToEvents(); unrecognized event`, { name, }); } }; get heartbeatThrottleMs(): number { return this._heartbeatThrottleMs; } get hasHeartbeatTimer(): boolean { return !!this._heartbeatTimerId; } }