UNPKG

pubnub

Version:

Publish & Subscribe Real-time Messaging with PubNub

942 lines (809 loc) 24.4 kB
/** * Subscription REST API module. */ import { createMalformedResponseError, createValidationError, PubNubError } from '../../errors/pubnub-error'; import { TransportResponse } from '../types/transport-response'; import { ICryptoModule } from '../interfaces/crypto-module'; import { encodeNames, messageFingerprint } from '../utils'; import * as Subscription from '../types/api/subscription'; import { AbstractRequest } from '../components/request'; import * as FileSharing from '../types/api/file-sharing'; import RequestOperation from '../constants/operations'; import * as AppContext from '../types/api/app-context'; import { KeySet, Payload, Query } from '../types/api'; // -------------------------------------------------------- // ---------------------- Defaults ------------------------ // -------------------------------------------------------- // region Defaults /** * Whether should subscribe to channels / groups presence announcements or not. */ const WITH_PRESENCE = false; // endregion // -------------------------------------------------------- // ------------------------ Types ------------------------- // -------------------------------------------------------- // region Types /** * PubNub-defined event types by payload. */ export enum PubNubEventType { /** * Presence change event. */ Presence = -2, /** * Regular message event. * * **Note:** This is default type assigned for non-presence events if `e` field is missing. */ Message = -1, /** * Signal data event. */ Signal = 1, /** * App Context object event. */ AppContext, /** * Message reaction event. */ MessageAction, /** * Files event. */ Files, } /** * Time cursor. * * Cursor used by subscription loop to identify point in time after which updates will be * delivered. */ type SubscriptionCursor = { /** * PubNub high-precision timestamp. * * Aside of specifying exact time of receiving data / event this token used to catchup / * follow on real-time updates. */ t: string; /** * Data center region for which `timetoken` has been generated. */ r: number; }; // endregion // region Presence service response /** * Periodical presence change service response. */ type PresenceIntervalData = { /** * Periodical subscribed channels and groups presence change announcement. */ action: 'interval'; /** * Unix timestamp when presence event has been triggered. */ timestamp: number; /** * The current occupancy after the presence change is updated. */ occupancy: number; /** * The list of unique user identifiers that `joined` the channel since the last interval * presence update. */ join?: string[]; /** * The list of unique user identifiers that `left` the channel since the last interval * presence update. */ leave?: string[]; /** * The list of unique user identifiers that `timeout` the channel since the last interval * presence update. */ timeout?: string[]; /** * Indicates whether presence should be requested manually using {@link PubNubCore.hereNow hereNow()} * or not. * * Depending on from the presence activity, the resulting interval update can be too large to be * returned as a presence event with subscribe REST API response. The server will set this flag to * `true` in this case. */ hereNowRefresh: boolean; /** * Indicates whether presence should be requested manually or not. * * **Warning:** This is internal property which will be removed after processing. * * @internal */ here_now_refresh?: boolean; }; /** * Subscribed user presence information change service response. */ type PresenceChangeData = { /** * Change if user's presence. * * User's presence may change between: `join`, `leave` and `timeout`. */ action: 'join' | 'leave' | 'timeout'; /** * Unix timestamp when presence event has been triggered. */ timestamp: number; /** * Unique identification of the user for whom presence information changed. */ uuid: string; /** * The current occupancy after the presence change is updated. */ occupancy: number; /** * The user's state associated with the channel has been updated. * * @deprecated Use set state methods to specify associated user's data instead of passing to * subscribe. */ data?: { [p: string]: Payload }; }; /** * Associated user presence state change service response. */ type PresenceStateChangeData = { /** * Subscribed user associated presence state change. */ action: 'state-change'; /** * Unix timestamp when presence event has been triggered. */ timestamp: number; /** * Unique identification of the user for whom associated presence state has been changed. */ uuid: string; /** * The user's state associated with the channel has been updated. */ state: { [p: string]: Payload }; }; /** * Channel presence service response. */ export type PresenceData = PresenceIntervalData | PresenceChangeData | PresenceStateChangeData; // endregion // region Message Actions service response /** * Message reaction change service response. */ export type MessageActionData = { /** * The type of event that happened during the message action update. * * Possible values are: * - `added` - action has been added to the message * - `removed` - action has been removed from message */ event: 'added' | 'removed'; /** * Information about message action for which update has been generated. */ data: { /** * Timetoken of message for which action has been added / removed. */ messageTimetoken: string; /** * Timetoken of message action which has been added / removed. */ actionTimetoken: string; /** * Message action type. */ type: string; /** * Value associated with message action {@link type}. */ value: string; }; /** * Name of service which generated update for message action. */ source: string; /** * Version of service which generated update for message action. */ version: string; }; // endregion // region App Context service data /** * VSP Objects change events. */ type AppContextVSPEvents = 'updated' | 'removed'; /** * App Context Objects change events. */ type AppContextEvents = 'set' | 'delete'; /** * Common real-time App Context Object service response. */ type ObjectData<Event extends string, Type extends string, AppContextObject> = { /** * The type of event that happened during the object update. */ event: Event; /** * App Context object type. */ type: Type; /** * App Context object information. * * App Context object can be one of: * - `channel` / `space` * - `uuid` / `user` * - `membership` */ data: AppContextObject; /** * Name of service which generated update for object. */ source: string; /** * Version of service which generated update for object. */ version: string; }; /** * `Channel` object change real-time service response. */ type ChannelObjectData = ObjectData< AppContextEvents, 'channel', AppContext.ChannelMetadataObject<AppContext.CustomData> >; /** * `Space` object change real-time service response. */ export type SpaceObjectData = ObjectData< AppContextVSPEvents, 'space', AppContext.ChannelMetadataObject<AppContext.CustomData> >; /** * `Uuid` object change real-time service response. */ type UuidObjectData = ObjectData<AppContextEvents, 'uuid', AppContext.UUIDMetadataObject<AppContext.CustomData>>; /** * `User` object change real-time service response. */ export type UserObjectData = ObjectData< AppContextVSPEvents, 'user', AppContext.UUIDMetadataObject<AppContext.CustomData> >; /** * `Membership` object change real-time service response. */ type MembershipObjectData = ObjectData< AppContextEvents, 'membership', Omit<AppContext.ObjectData<AppContext.CustomData>, 'id'> & { /** * User membership status. */ status?: string; /** * User membership type. */ type?: string; /** * `Uuid` object which has been used to create relationship with `channel`. */ uuid: { /** * Unique `user` object identifier. */ id: string; }; /** * `Channel` object which has been used to create relationship with `uuid`. */ channel: { /** * Unique `channel` object identifier. */ id: string; }; } >; /** * VSP `Membership` object change real-time service response. */ export type VSPMembershipObjectData = ObjectData< AppContextVSPEvents, 'membership', Omit<AppContext.ObjectData<AppContext.CustomData>, 'id'> & { /** * `User` object which has been used to create relationship with `space`. */ user: { /** * Unique `user` object identifier. */ id: string; }; /** * `Space` object which has been used to create relationship with `user`. */ space: { /** * Unique `channel` object identifier. */ id: string; }; } >; /** * App Context service response. */ export type AppContextObjectData = ChannelObjectData | UuidObjectData | MembershipObjectData; // endregion // region File service response /** * File service response. */ export type FileData = { /** * Message which has been associated with uploaded file. */ message?: Payload; /** * Information about uploaded file. */ file: { /** * Unique identifier of uploaded file. */ id: string; /** * Actual name with which file has been stored. */ name: string; }; }; // endregion /** * Service response data envelope. * * Each entry from `m` list wrapped into this object. * * @internal */ type Envelope = { /** * Shard number on which the event has been stored. */ a: string; /** * A numeric representation of enabled debug flags. */ f: number; /** * PubNub defined event type. */ e?: PubNubEventType; /** * Identifier of client which sent message (set only when Publish REST API endpoint called with * `uuid`). */ i?: string; /** * Sequence number (set only when Publish REST API endpoint called with `seqn`). */ s?: number; /** * Event "publish" time. * * This is the time when message has been received by {@link https://www.pubnub.com|PubNub} network. */ p: SubscriptionCursor; /** * User-defined (local) "publish" time. */ o?: SubscriptionCursor; /** * Name of channel where update received. */ c: string; /** * Event payload. * * **Note:** One more type not mentioned here to keep type system working ({@link Payload}). */ d: PresenceData | MessageActionData | AppContextObjectData | FileData | string; /** * Actual name of subscription through which event has been delivered. * * PubNub client can be used to subscribe to the group of channels to receive updates and * (group name will be set for field). With this approach there will be no need to separately * add *N* number of channels to `subscribe` method call. */ b?: string; /** * User-provided metadata during `publish` method usage. */ u?: { [p: string]: Payload }; /** * User-provided message type (set only when `publish` called with `type`). */ cmt?: string; /** * Identifier of space into which message has been published (set only when `publish` called * with `space_id`). */ si?: string; }; /** * Subscribe REST API service success response. * * @internal */ type ServiceResponse = { /** * Next subscription cursor. * * The cursor contains information about the start of the next real-time update timeframe. */ t: SubscriptionCursor; /** * List of updates. * * Contains list of real-time updates received using previous subscription cursor. */ m: Envelope[]; }; /** * Request configuration parameters. * * @internal */ export type SubscribeRequestParameters = Subscription.SubscribeParameters & { /** * Timetoken's region identifier. */ region?: number; /** * Subscriber `userId` presence timeout. * * For how long (in seconds) user will be `online` without sending any new subscribe or * heartbeat requests. */ heartbeat?: number; /** * Real-time events filtering expression. */ filterExpression?: string | null; /** * PubNub REST API access key set. */ keySet: KeySet; /** * Received data decryption module. */ crypto?: ICryptoModule; /** * File download Url generation function. * * @param id - Unique identifier of the file which should be downloaded. * @param name - Name with which file has been stored. * @param channel - Name of the channel from which file should be downloaded. */ getFileUrl: (parameters: FileSharing.FileUrlParameters) => string; /** * Whether request has been created on user demand or not. */ onDemand?: boolean; }; // endregion /** * Base subscription request implementation. * * Subscription request used in small variations in two cases: * - subscription manager * - event engine * * @internal */ export class BaseSubscribeRequest extends AbstractRequest<Subscription.SubscriptionResponse, ServiceResponse> { constructor(protected readonly parameters: SubscribeRequestParameters) { super({ cancellable: true }); // Apply default request parameters. this.parameters.withPresence ??= WITH_PRESENCE; this.parameters.channelGroups ??= []; this.parameters.channels ??= []; } operation(): RequestOperation { return RequestOperation.PNSubscribeOperation; } validate(): string | undefined { const { keySet: { subscribeKey }, channels, channelGroups, } = this.parameters; if (!subscribeKey) return 'Missing Subscribe Key'; if (!channels && !channelGroups) return '`channels` and `channelGroups` both should not be empty'; } async parse(response: TransportResponse): Promise<Subscription.SubscriptionResponse> { let serviceResponse: ServiceResponse | undefined; let responseText: string | undefined; try { responseText = AbstractRequest.decoder.decode(response.body); const parsedJson = JSON.parse(responseText); serviceResponse = parsedJson as ServiceResponse; } catch (error) { console.error('Error parsing JSON response:', error); } if (!serviceResponse) { throw new PubNubError( 'Service response error, check status for details', createMalformedResponseError(responseText, response.status), ); } const events: Subscription.SubscriptionResponse['messages'] = serviceResponse.m .filter((envelope) => { const subscribable = envelope.b === undefined ? envelope.c : envelope.b; return ( (this.parameters.channels && this.parameters.channels.includes(subscribable)) || (this.parameters.channelGroups && this.parameters.channelGroups.includes(subscribable)) ); }) .map((envelope) => { let { e: eventType } = envelope; // Resolve missing event type. eventType ??= envelope.c.endsWith('-pnpres') ? PubNubEventType.Presence : PubNubEventType.Message; const pn_mfp = messageFingerprint(envelope.d); // Check whether payload is string (potentially encrypted data). if (eventType != PubNubEventType.Signal && typeof envelope.d === 'string') { if (eventType == PubNubEventType.Message) { return { type: PubNubEventType.Message, data: this.messageFromEnvelope(envelope), pn_mfp, }; } return { type: PubNubEventType.Files, data: this.fileFromEnvelope(envelope), pn_mfp, }; } else if (eventType == PubNubEventType.Message) { return { type: PubNubEventType.Message, data: this.messageFromEnvelope(envelope), pn_mfp, }; } else if (eventType === PubNubEventType.Presence) { return { type: PubNubEventType.Presence, data: this.presenceEventFromEnvelope(envelope), pn_mfp, }; } else if (eventType == PubNubEventType.Signal) { return { type: PubNubEventType.Signal, data: this.signalFromEnvelope(envelope), pn_mfp, }; } else if (eventType === PubNubEventType.AppContext) { return { type: PubNubEventType.AppContext, data: this.appContextFromEnvelope(envelope), pn_mfp, }; } else if (eventType === PubNubEventType.MessageAction) { return { type: PubNubEventType.MessageAction, data: this.messageActionFromEnvelope(envelope), pn_mfp, }; } return { type: PubNubEventType.Files, data: this.fileFromEnvelope(envelope), pn_mfp, }; }); return { cursor: { timetoken: serviceResponse.t.t, region: serviceResponse.t.r }, messages: events, }; } protected get headers(): Record<string, string> | undefined { return { ...(super.headers ?? {}), accept: 'text/javascript' }; } // -------------------------------------------------------- // ------------------ Envelope parsing -------------------- // -------------------------------------------------------- // region Envelope parsing private presenceEventFromEnvelope(envelope: Envelope): Subscription.Presence { const { d: payload } = envelope; const [channel, subscription] = this.subscriptionChannelFromEnvelope(envelope); // Clean up channel and subscription name from presence suffix. const trimmedChannel = channel.replace('-pnpres', ''); // Backward compatibility with deprecated properties. const actualChannel = subscription !== null ? trimmedChannel : null; const subscribedChannel = subscription !== null ? subscription : trimmedChannel; if (typeof payload !== 'string') { if ('data' in payload) { // @ts-expect-error This is `state-change` object which should have `state` field. payload['state'] = payload.data; delete payload.data; } else if ('action' in payload && payload.action === 'interval') { payload.hereNowRefresh = payload.here_now_refresh ?? false; delete payload.here_now_refresh; } } return { channel: trimmedChannel, subscription, actualChannel, subscribedChannel, timetoken: envelope.p.t, ...(payload as PresenceData), }; } private messageFromEnvelope(envelope: Envelope): Subscription.Message { const [channel, subscription] = this.subscriptionChannelFromEnvelope(envelope); const [message, decryptionError] = this.decryptedData<Payload>(envelope.d); // Backward compatibility with deprecated properties. const actualChannel = subscription !== null ? channel : null; const subscribedChannel = subscription !== null ? subscription : channel; // Basic message event payload. const event: Subscription.Message = { channel, subscription, actualChannel, subscribedChannel, timetoken: envelope.p.t, publisher: envelope.i, message, }; if (envelope.u) event.userMetadata = envelope.u; if (envelope.cmt) event.customMessageType = envelope.cmt; if (decryptionError) event.error = decryptionError; return event; } private signalFromEnvelope(envelope: Envelope): Subscription.Signal { const [channel, subscription] = this.subscriptionChannelFromEnvelope(envelope); const event: Subscription.Signal = { channel, subscription, timetoken: envelope.p.t, publisher: envelope.i, message: envelope.d, }; if (envelope.u) event.userMetadata = envelope.u; if (envelope.cmt) event.customMessageType = envelope.cmt; return event; } private messageActionFromEnvelope(envelope: Envelope): Subscription.MessageAction { const [channel, subscription] = this.subscriptionChannelFromEnvelope(envelope); const action = envelope.d as MessageActionData; return { channel, subscription, timetoken: envelope.p.t, publisher: envelope.i, event: action.event, data: { ...action.data, uuid: envelope.i!, }, }; } private appContextFromEnvelope(envelope: Envelope): Subscription.AppContextObject { const [channel, subscription] = this.subscriptionChannelFromEnvelope(envelope); const object = envelope.d as AppContextObjectData; return { channel, subscription, timetoken: envelope.p.t, message: object, }; } private fileFromEnvelope(envelope: Envelope): Subscription.File { const [channel, subscription] = this.subscriptionChannelFromEnvelope(envelope); const [file, decryptionError] = this.decryptedData<Subscription.File | string>(envelope.d); let errorMessage = decryptionError; // Basic file event payload. const event: Subscription.File = { channel, subscription, timetoken: envelope.p.t, publisher: envelope.i, }; if (envelope.u) event.userMetadata = envelope.u; if (!file) errorMessage ??= `File information payload is missing.`; else if (typeof file === 'string') errorMessage ??= `Unexpected file information payload data type.`; else { event.message = file.message; if (file.file) { event.file = { id: file.file.id, name: file.file.name, url: this.parameters.getFileUrl({ id: file.file.id, name: file.file.name, channel }), }; } } if (envelope.cmt) event.customMessageType = envelope.cmt; if (errorMessage) event.error = errorMessage; return event; } // endregion private subscriptionChannelFromEnvelope(envelope: Envelope): [string, string | null] { return [envelope.c, envelope.b === undefined ? envelope.c : envelope.b]; } /** * Decrypt provided `data`. * * @param [data] - Message or file information which should be decrypted if possible. * * @returns Tuple with decrypted data and decryption error (if any). */ private decryptedData<T extends Payload = Payload>(data: Payload): [T, string | undefined] { if (!this.parameters.crypto || typeof data !== 'string') return [data as T, undefined]; let payload: Payload | null; let error: string | undefined; try { const decryptedData = this.parameters.crypto.decrypt(data); payload = decryptedData instanceof ArrayBuffer ? JSON.parse(SubscribeRequest.decoder.decode(decryptedData)) : decryptedData; } catch (err) { payload = null; error = `Error while decrypting message content: ${(err as Error).message}`; } return [(payload ?? data) as T, error]; } } /** * Subscribe request. * * @internal */ export class SubscribeRequest extends BaseSubscribeRequest { protected get path(): string { const { keySet: { subscribeKey }, channels, } = this.parameters; return `/v2/subscribe/${subscribeKey}/${encodeNames(channels?.sort() ?? [], ',')}/0`; } protected get queryParameters(): Query { const { channelGroups, filterExpression, heartbeat, state, timetoken, region, onDemand } = this.parameters; const query: Query = {}; if (onDemand) query['on-demand'] = 1; if (channelGroups && channelGroups.length > 0) query['channel-group'] = channelGroups.sort().join(','); if (filterExpression && filterExpression.length > 0) query['filter-expr'] = filterExpression; if (heartbeat) query.heartbeat = heartbeat; if (state && Object.keys(state).length > 0) query['state'] = JSON.stringify(state); if (timetoken !== undefined && typeof timetoken === 'string') { if (timetoken.length > 0 && timetoken !== '0') query['tt'] = timetoken; } else if (timetoken !== undefined && timetoken > 0) query['tt'] = timetoken; if (region) query['tr'] = region; return query; } }