diff --git a/ably.d.ts b/ably.d.ts index b8e85c6a4..b4bf04ef5 100644 --- a/ably.d.ts +++ b/ably.d.ts @@ -2335,12 +2335,104 @@ export interface Message { * Timestamp of when the message was received by Ably, as milliseconds since the Unix epoch. */ timestamp?: number; + /** + * The action type of the message, one of the {@link MessageAction} enum values. + */ + action?: MessageAction; + /** + * This message's unique serial. + */ + serial?: string; + /** + * The serial of the message that this message is a reference to. + */ + refSerial?: string; + /** + * The type of reference this message is, in relation to the message it references. + */ + refType?: string; + /** + * If an `update` operation was applied to this message, this will be the timestamp the update occurred. + */ + updatedAt?: number; + /** + * If a `deletion` operation was applied to this message, this will be the timestamp the deletion occurred. + */ + deletedAt?: number; + /** + * If this message resulted from an operation, this will contain the operation details. + */ + operation?: Operation; } +/** + * Contains the details of an operation, such as update or deletion, supplied by the actioning client. + */ +export interface Operation { + /** + * The client ID of the client that initiated the operation. + */ + clientId?: string; + /** + * The description provided by the client that initiated the operation. + */ + description?: string; + /** + * A JSON object of string key-value pairs that may contain metadata associated with the operation. + */ + metadata?: Record; +} + +/** + * The namespace containing the different types of message actions. + */ +declare namespace MessageActions { + /** + * Message action has not been set. + */ + type MESSAGE_UNSET = 'message.unset'; + /** + * Message action for a newly created message. + */ + type MESSAGE_CREATE = 'message.create'; + /** + * Message action for an updated message. + */ + type MESSAGE_UPDATE = 'message.update'; + /** + * Message action for a deleted message. + */ + type MESSAGE_DELETE = 'message.delete'; + /** + * Message action for a newly created annotation. + */ + type ANNOTATION_CREATE = 'annotation.create'; + /** + * Message action for a deleted annotation. + */ + type ANNOTATION_DELETE = 'annotation.delete'; + /** + * Message action for a meta-message that contains channel occupancy information. + */ + type META_OCCUPANCY = 'meta.occupancy'; +} + +/** + * Describes the possible action types used on an {@link Message}. + */ +export type MessageAction = + | MessageActions.MESSAGE_UNSET + | MessageActions.MESSAGE_CREATE + | MessageActions.MESSAGE_UPDATE + | MessageActions.MESSAGE_DELETE + | MessageActions.ANNOTATION_CREATE + | MessageActions.ANNOTATION_DELETE + | MessageActions.META_OCCUPANCY; + /** * A message received from Ably. */ -export type InboundMessage = Message & Required>; +export type InboundMessage = Message & Required>; /** * Static utilities related to messages. diff --git a/package.json b/package.json index 0a8271645..0a82a90ec 100644 --- a/package.json +++ b/package.json @@ -139,6 +139,7 @@ "grunt": "grunt", "test": "npm run test:node", "test:node": "npm run build:node && npm run build:push && mocha", + "test:grep": "npm run build:node && npm run build:push && mocha --grep", "test:node:skip-build": "mocha", "test:webserver": "grunt test:webserver", "test:playwright": "node test/support/runPlaywrightTests.js", diff --git a/scripts/moduleReport.ts b/scripts/moduleReport.ts index 25daba894..aacec5a00 100644 --- a/scripts/moduleReport.ts +++ b/scripts/moduleReport.ts @@ -6,7 +6,7 @@ import { gzip } from 'zlib'; import Table from 'cli-table'; // The maximum size we allow for a minimal useful Realtime bundle (i.e. one that can subscribe to a channel) -const minimalUsefulRealtimeBundleSizeThresholdsKiB = { raw: 98, gzip: 30 }; +const minimalUsefulRealtimeBundleSizeThresholdsKiB = { raw: 100, gzip: 31 }; const baseClientNames = ['BaseRest', 'BaseRealtime']; diff --git a/src/common/lib/client/restchannel.ts b/src/common/lib/client/restchannel.ts index 3138e8611..e27133c6e 100644 --- a/src/common/lib/client/restchannel.ts +++ b/src/common/lib/client/restchannel.ts @@ -2,12 +2,12 @@ import * as Utils from '../util/utils'; import Logger from '../util/logger'; import RestPresence from './restpresence'; import Message, { - fromValues as messageFromValues, - fromValuesArray as messagesFromValuesArray, encodeArray as encodeMessagesArray, serialize as serializeMessage, getMessagesSize, CipherOptions, + fromValues as messageFromValues, + fromValuesArray as messagesFromValuesArray, } from '../types/message'; import ErrorInfo from '../types/errorinfo'; import { PaginatedResult } from './paginatedresource'; diff --git a/src/common/lib/types/defaultmessage.ts b/src/common/lib/types/defaultmessage.ts index dfc4a02b1..79fffccf5 100644 --- a/src/common/lib/types/defaultmessage.ts +++ b/src/common/lib/types/defaultmessage.ts @@ -1,10 +1,11 @@ import Message, { CipherOptions, - fromEncoded, - fromEncodedArray, - encode, decode, + encode, EncodingDecodingContext, + fromEncoded, + fromEncodedArray, + fromValues, } from './message'; import * as API from '../../../../ably'; import Platform from 'common/platform'; @@ -25,8 +26,8 @@ export class DefaultMessage extends Message { } // Used by tests - static fromValues(values: unknown): Message { - return Object.assign(new Message(), values); + static fromValues(values: Message | Record, options?: { stringifyAction?: boolean }): Message { + return fromValues(values, options); } // Used by tests diff --git a/src/common/lib/types/message.ts b/src/common/lib/types/message.ts index 7cc8b80ac..f0d9c5394 100644 --- a/src/common/lib/types/message.ts +++ b/src/common/lib/types/message.ts @@ -9,6 +9,30 @@ import * as API from '../../../../ably'; import { IUntypedCryptoStatic } from 'common/types/ICryptoStatic'; import { MsgPack } from 'common/types/msgpack'; +const MessageActionArray: API.MessageAction[] = [ + 'message.unset', + 'message.create', + 'message.update', + 'message.delete', + 'annotation.create', + 'annotation.delete', + 'meta.occupancy', +]; + +const MessageActionMap = new Map(MessageActionArray.map((action, index) => [action, index])); + +const ReverseMessageActionMap = new Map( + MessageActionArray.map((action, index) => [index, action]), +); + +function toMessageActionString(actionNumber: number): API.MessageAction | undefined { + return ReverseMessageActionMap.get(actionNumber); +} + +function toMessageActionNumber(messageAction?: API.MessageAction): number | undefined { + return messageAction ? MessageActionMap.get(messageAction) : undefined; +} + export type CipherOptions = { channelCipher: { encrypt: Function; @@ -82,7 +106,7 @@ export async function fromEncoded( encoded: unknown, inputOptions?: API.ChannelOptions, ): Promise { - const msg = fromValues(encoded); + const msg = fromValues(encoded as Message | Record, { stringifyAction: true }); const options = normalizeCipherOptions(Crypto, logger, inputOptions ?? null); /* if decoding fails at any point, catch and return the message decoded to * the fullest extent possible */ @@ -260,7 +284,7 @@ export async function fromResponseBody( } for (let i = 0; i < body.length; i++) { - const msg = (body[i] = fromValues(body[i])); + const msg = (body[i] = fromValues(body[i], { stringifyAction: true })); try { await decode(msg, options); } catch (e) { @@ -270,14 +294,22 @@ export async function fromResponseBody( return body; } -export function fromValues(values: unknown): Message { +export function fromValues( + values: Message | Record, + options?: { stringifyAction?: boolean }, +): Message { + const stringifyAction = options?.stringifyAction; + if (stringifyAction) { + const action = toMessageActionString(values.action as number) || values.action; + return Object.assign(new Message(), { ...values, action }); + } return Object.assign(new Message(), values); } export function fromValuesArray(values: unknown[]): Message[] { const count = values.length, result = new Array(count); - for (let i = 0; i < count; i++) result[i] = fromValues(values[i]); + for (let i = 0; i < count; i++) result[i] = fromValues(values[i] as Record); return result; } @@ -304,6 +336,13 @@ class Message { encoding?: string | null; extras?: any; size?: number; + action?: API.MessageAction | number; + serial?: string; + refSerial?: string; + refType?: string; + updatedAt?: number; + deletedAt?: number; + operation?: API.Operation; /** * Overload toJSON() to intercept JSON.stringify() @@ -334,6 +373,13 @@ class Message { connectionId: this.connectionId, connectionKey: this.connectionKey, extras: this.extras, + serial: this.serial, + action: toMessageActionNumber(this.action as API.MessageAction) || this.action, + refSerial: this.refSerial, + refType: this.refType, + updatedAt: this.updatedAt, + deletedAt: this.deletedAt, + operation: this.operation, encoding, data, }; @@ -355,6 +401,14 @@ class Message { else result += '; data (json)=' + JSON.stringify(this.data); } if (this.extras) result += '; extras=' + JSON.stringify(this.extras); + + if (this.action) result += '; action=' + this.action; + if (this.serial) result += '; serial=' + this.serial; + if (this.refSerial) result += '; refSerial=' + this.refSerial; + if (this.refType) result += '; refType=' + this.refType; + if (this.updatedAt) result += '; updatedAt=' + this.updatedAt; + if (this.deletedAt) result += '; deletedAt=' + this.deletedAt; + if (this.operation) result += '; operation=' + JSON.stringify(this.operation); result += ']'; return result; } diff --git a/src/common/lib/types/protocolmessage.ts b/src/common/lib/types/protocolmessage.ts index eaa622a8d..ccb15841f 100644 --- a/src/common/lib/types/protocolmessage.ts +++ b/src/common/lib/types/protocolmessage.ts @@ -81,16 +81,26 @@ export function fromDeserialized( presenceMessagePlugin: PresenceMessagePlugin | null, ): ProtocolMessage { const error = deserialized.error; - if (error) deserialized.error = ErrorInfo.fromValues(error as ErrorInfo); + if (error) { + deserialized.error = ErrorInfo.fromValues(error as ErrorInfo); + } + const messages = deserialized.messages as Message[]; - if (messages) for (let i = 0; i < messages.length; i++) messages[i] = messageFromValues(messages[i]); + if (messages) { + for (let i = 0; i < messages.length; i++) { + messages[i] = messageFromValues(messages[i], { stringifyAction: true }); + } + } const presence = presenceMessagePlugin ? (deserialized.presence as PresenceMessage[]) : undefined; if (presenceMessagePlugin) { - if (presence && presenceMessagePlugin) - for (let i = 0; i < presence.length; i++) + if (presence && presenceMessagePlugin) { + for (let i = 0; i < presence.length; i++) { presence[i] = presenceMessagePlugin.presenceMessageFromValues(presence[i], true); + } + } } + return Object.assign(new ProtocolMessage(), { ...deserialized, presence }); } diff --git a/test/realtime/crypto.test.js b/test/realtime/crypto.test.js index 14cb65330..18df2f11e 100644 --- a/test/realtime/crypto.test.js +++ b/test/realtime/crypto.test.js @@ -395,6 +395,7 @@ define(['ably', 'shared_helper', 'async', 'chai'], function (Ably, Helper, async helper.recordPrivateApi('call.msgpack.decode'); var messageFromMsgpack = Message.fromValues( msgpack.decode(BufferUtils.base64Decode(msgpackEncodedMessage)), + { stringifyAction: true }, ); try { @@ -439,6 +440,7 @@ define(['ably', 'shared_helper', 'async', 'chai'], function (Ably, Helper, async helper.recordPrivateApi('call.msgpack.decode'); var messageFromMsgpack = Message.fromValues( msgpack.decode(BufferUtils.base64Decode(msgpackEncodedMessage)), + { stringifyAction: true }, ); try { diff --git a/test/realtime/message.test.js b/test/realtime/message.test.js index 6d0fe8f7c..6cb2cfd0a 100644 --- a/test/realtime/message.test.js +++ b/test/realtime/message.test.js @@ -4,6 +4,7 @@ define(['ably', 'shared_helper', 'async', 'chai'], function (Ably, Helper, async var expect = chai.expect; let config = Ably.Realtime.Platform.Config; var createPM = Ably.protocolMessageFromDeserialized; + var Message = Ably.Realtime.Message; var publishIntervalHelper = function (currentMessageNum, channel, dataFn, onPublish) { return function () { @@ -1271,6 +1272,56 @@ define(['ably', 'shared_helper', 'async', 'chai'], function (Ably, Helper, async channel.publish('end', null); }); }); + /** + * @spec TM2j + */ + describe('DefaultMessage.fromValues stringify action', function () { + const testCases = [ + { + description: 'should stringify the numeric action', + action: 1, + options: { stringifyAction: true }, + expectedString: '[Message; action=message.create]', + expectedJSON: { action: 1 }, + }, + { + description: 'should not stringify the numeric action', + action: 1, + options: { stringifyAction: false }, + expectedString: '[Message; action=1]', + expectedJSON: { action: 1 }, + }, + { + description: 'should accept an already stringified action', + action: 'message.update', + options: { stringifyAction: true }, + expectedString: '[Message; action=message.update]', + expectedJSON: { action: 2 }, + }, + { + description: 'should handle no action provided', + action: undefined, + options: { stringifyAction: true }, + expectedString: '[Message]', + expectedJSON: { action: undefined }, + }, + { + description: 'should handle unknown action provided', + action: 10, + options: { stringifyAction: true }, + expectedString: '[Message; action=10]', + expectedJSON: { action: 10 }, + }, + ]; + testCases.forEach(({ description, action, options, expectedString, expectedJSON }) => { + it(description, function () { + const values = { action }; + const message = Message.fromValues(values, options); + expect(message.toString()).to.equal(expectedString); + expect(message.toJSON()).to.deep.contains(expectedJSON); + }); + }); + }); /** * @spec RTS5 diff --git a/test/support/runPlaywrightTests.js b/test/support/runPlaywrightTests.js index edbcc1531..5ff2d9106 100644 --- a/test/support/runPlaywrightTests.js +++ b/test/support/runPlaywrightTests.js @@ -68,14 +68,20 @@ const runTests = async (browserType) => { // Use page.evaluate to add these functions as event listeners to the 'testLog' and 'testResult' Custom Events. // These events are fired by the custom mocha reporter in playwrightSetup.js - page.evaluate(() => { + const grep = process.env.MOCHA_GREP; + page.evaluate((grep) => { window.addEventListener('testLog', ({ type, detail }) => { onTestLog({ type, detail }); }); window.addEventListener('testResult', ({ type, detail }) => { onTestResult({ type, detail }); }); - }); + // Set grep pattern in the browser context + // allows easy filtering of tests. + if (grep) { + window.mocha.grep(new RegExp(grep)); + } + }, grep); }); };