diff --git a/ably.d.ts b/ably.d.ts index 6566edd9e..e10bfa288 100644 --- a/ably.d.ts +++ b/ably.d.ts @@ -388,6 +388,13 @@ export interface ClientOptions extends AuthOptions { */ autoConnect?: boolean; + /** + * When `true`, operations that would otherwise fail silently, such as those gated on a channel mode, reject with an {@link ErrorInfo} with a `hint` describing the cause and remediation. When `false`, the same paths emit a warning log and return their legacy silent value. The default is `false`. A future major version will change the default to `true`. + * + * @defaultValue `false` + */ + strictMode?: boolean; + /** * When a {@link TokenParams} object is provided, it overrides the client library defaults when issuing new Ably Tokens or Ably {@link TokenRequest | `TokenRequest`s}. */ diff --git a/src/common/lib/client/realtimeannotations.ts b/src/common/lib/client/realtimeannotations.ts index 5390d55d7..39fdbf6bd 100644 --- a/src/common/lib/client/realtimeannotations.ts +++ b/src/common/lib/client/realtimeannotations.ts @@ -70,14 +70,23 @@ class RealtimeAnnotations { } // explicit check for attach state in case attachOnSubscribe=false - if ((this.channel.state === 'attached' && this.channel._mode & flags.ANNOTATION_SUBSCRIBE) === 0) { - throw new ErrorInfo({ + if (this.channel.state === 'attached' && (this.channel._mode & flags.ANNOTATION_SUBSCRIBE) === 0) { + const err = new ErrorInfo({ message: "You are trying to add an annotation listener, but you haven't requested the annotation_subscribe channel mode in ChannelOptions, so this won't do anything (we only deliver annotations to clients who have explicitly requested them)", code: 93001, statusCode: 400, - hint: 'Enable the mode on the channel with channel.setOptions({ modes: ["subscribe", "annotation_subscribe"] }), which re-attaches with the new mode (calling channels.get(name, { modes }) on an existing channel throws, and appending to channel.modes does not enable it server-side). If the re-attach is rejected by the server, confirm the channel namespace has "Message annotations, updates, appends, and deletes" enabled in the Ably dashboard and that your API key has annotation-subscribe capability on this channel. If you have the Ably CLI installed, `ably apps rules list` shows which namespaces have it enabled and `ably auth keys list` shows the capabilities of your key.', + hint: 'Include "annotation_subscribe" in the channel modes: realtime.channels.get(name, { modes: ["annotation_subscribe", ...] }), or call channel.setOptions({ modes: [...] }) on an existing channel to trigger a reattach. Calling channels.get(name, { modes }) on an existing channel throws. The server only grants the mode if your key or token has the annotation-subscribe capability. Without it the attach succeeds but the server silently drops the mode and annotations are never delivered. `ably auth keys list` shows your key\'s capabilities.', }); + Logger.logActionNoStrip( + this.logger, + Logger.LOG_MAJOR, + 'RealtimeAnnotations.subscribe()', + err.message + '; hint=' + err.hint, + ); + // The listener stays registered despite the throw, matching subscribe()'s existing + // semantics: the listener is always added regardless of attach outcome. + throw err; } } diff --git a/src/common/lib/client/realtimechannel.ts b/src/common/lib/client/realtimechannel.ts index 3db5cd038..297fb0ed1 100644 --- a/src/common/lib/client/realtimechannel.ts +++ b/src/common/lib/client/realtimechannel.ts @@ -1,4 +1,4 @@ -import { actions, channelModes } from '../types/protocolmessagecommon'; +import { actions, channelModes, flags } from '../types/protocolmessagecommon'; import ProtocolMessage, { fromValues as protocolMessageFromValues } from '../types/protocolmessage'; import EventEmitter from '../util/eventemitter'; import * as Utils from '../util/utils'; @@ -99,6 +99,7 @@ class RealtimeChannel extends EventEmitter { }; errorReason: ErrorInfo | null; _mode = 0; + _silentSubscribeWarned = false; _attachResume: boolean; _decodingContext: EncodingDecodingContext; _lastPayload: { @@ -508,11 +509,37 @@ class RealtimeChannel extends EventEmitter { } // (RTL7g) + let stateChange: ChannelStateChange | null = null; if (this.channelOptions.attachOnSubscribe !== false) { - return this.attach(); - } else { - return null; + stateChange = await this.attach(); + } + + // Whether or not we attached on subscribe, if the channel ended up attached without the + // subscribe mode the server will never deliver messages to this listener. + if (this.state === 'attached' && (this._mode & flags.SUBSCRIBE) === 0) { + const err = new ErrorInfo({ + message: + 'The channel was attached without the subscribe mode, so the server will not deliver messages to this listener.', + code: 90009, + statusCode: 400, + hint: 'Include "subscribe" in the channel modes: realtime.channels.get(name, { modes: ["subscribe", ...] }), or call channel.setOptions({ modes: [...] }) on an existing channel to trigger a reattach. Alternatively, omit modes entirely and ensure your token/API-key capability permits subscribe on this channel. If you have the Ably CLI installed, `ably auth keys list` shows your key\'s capabilities.', + }); + if (this.client.options.strictMode === true) { + // The listener stays registered despite the throw, matching subscribe()'s existing + // semantics: the listener is always added regardless of attach outcome. + throw err; + } + if (!this._silentSubscribeWarned) { + Logger.logActionNoStrip( + this.logger, + Logger.LOG_ERROR, + 'RealtimeChannel.subscribe()', + err.message + '; hint=' + err.hint + Logger.silentFailureLogSuffix(), + ); + this._silentSubscribeWarned = true; + } } + return stateChange; } unsubscribe(...args: unknown[] /* [event], listener */): void { @@ -625,6 +652,7 @@ class RealtimeChannel extends EventEmitter { case actions.ATTACHED: { this.properties.attachSerial = message.channelSerial; this._mode = message.getMode(); + this._silentSubscribeWarned = false; this.params = (message as any).params || {}; const modesFromFlags = message.decodeModesFromFlags(); this.modes = (modesFromFlags && (Utils.allToLowerCase(modesFromFlags) as API.ChannelMode[])) || undefined; diff --git a/src/common/lib/client/realtimepresence.ts b/src/common/lib/client/realtimepresence.ts index 24f2d19dd..736152f72 100644 --- a/src/common/lib/client/realtimepresence.ts +++ b/src/common/lib/client/realtimepresence.ts @@ -4,6 +4,7 @@ import Logger from '../util/logger'; import PresenceMessage, { WirePresenceMessage } from '../types/presencemessage'; import type { CipherOptions } from '../types/basemessage'; import ErrorInfo, { PartialErrorInfo } from '../types/errorinfo'; +import { flags } from '../types/protocolmessagecommon'; import RealtimeChannel from './realtimechannel'; import Multicaster from '../util/multicaster'; import ChannelStateChange from './channelstatechange'; @@ -245,6 +246,24 @@ class RealtimePresence extends EventEmitter { } await this.channel.ensureAttached(); + + if ((this.channel._mode & flags.PRESENCE_SUBSCRIBE) === 0) { + const err = new ErrorInfo({ + message: + 'The channel was attached without the presence_subscribe mode, so the server has not delivered any members to this client.', + code: 91008, + statusCode: 400, + hint: 'Include "presence_subscribe" in the channel modes: realtime.channels.get(name, { modes: ["presence_subscribe", ...] }), or call channel.setOptions({ modes: [...] }) on an existing channel to trigger a reattach. Alternatively, omit modes entirely and ensure your token/API-key capability permits subscribe on this channel. If you have the Ably CLI installed, `ably auth keys list` shows your key\'s capabilities.', + }); + if (this.channel.client.options.strictMode === true) throw err; + Logger.logActionNoStrip( + this.logger, + Logger.LOG_ERROR, + 'RealtimePresence.get()', + err.message + '; hint=' + err.hint + Logger.silentFailureLogSuffix(), + ); + } + const members = this.members; if (waitForSync) { await members.waitSync(); diff --git a/src/common/lib/util/logger.ts b/src/common/lib/util/logger.ts index 03c5506d5..c4430b99b 100644 --- a/src/common/lib/util/logger.ts +++ b/src/common/lib/util/logger.ts @@ -117,6 +117,15 @@ class Logger { logger.logAction(level, action, message); } + /** + * Suffix appended to the silent-failure warning emitted when `ClientOptions.strictMode` is off, so the reader knows the same call will reject with an error in a future major version. + * + * The suffix is for log output only. Do not put it into `ErrorInfo.hint`. The hint is also surfaced on the error when `ClientOptions.strictMode` is enabled, where the suffix would be misleading. + */ + static silentFailureLogSuffix(): string { + return ' This call currently fails silently because clientOptions.strictMode is not enabled. A future major version will change the default to true. Set clientOptions.strictMode: true to make this call reject with an error now.'; + } + private logAction(level: LogLevels, action: string, message?: string) { if (this.shouldLog(level)) { (level === LogLevels.Error ? this.logErrorHandler : this.logHandler)('Ably: ' + action + ': ' + message, level); diff --git a/test/realtime/channel.test.js b/test/realtime/channel.test.js index f4fb9e757..9bbb31fd4 100644 --- a/test/realtime/channel.test.js +++ b/test/realtime/channel.test.js @@ -2116,5 +2116,42 @@ define(['ably', 'shared_helper', 'async', 'chai'], function (Ably, Helper, async await detachPromise; }, realtime); }); + + describe('subscribe() without subscribe mode', function () { + it('with strictMode:true, attach resolves but subscribe rejects with 90009 and a subscribe-mode hint', async function () { + const helper = this.test.helper; + const realtime = helper.AblyRealtime({ strictMode: true }); + await helper.monitorConnectionThenCloseAndFinishAsync(async () => { + const channel = realtime.channels.get('subscribe-without-mode-strict-' + String(Math.random()).slice(2), { + modes: ['publish'], + }); + let caught; + try { + await channel.subscribe(function () {}); + } catch (err) { + caught = err; + } + expect(caught, 'expected channel.subscribe() to reject').to.exist; + expect(caught.code).to.equal(90009); + expect(caught.hint).to.be.a('string'); + expect(caught.hint).to.contain('subscribe'); + expect(caught.hint).to.contain('ably auth keys list'); + }, realtime); + }); + + it('with strictMode disabled (default), subscribe resolves and the listener is registered without server delivery', async function () { + const helper = this.test.helper; + const realtime = helper.AblyRealtime(); + await helper.monitorConnectionThenCloseAndFinishAsync(async () => { + const channel = realtime.channels.get('subscribe-without-mode-silent-' + String(Math.random()).slice(2), { + modes: ['publish'], + }); + const result = await channel.subscribe(function () {}); + // attach resolves (ChannelStateChange or null) without throwing; the listener is harmless because the server will never deliver + expect(result === null || (result && typeof result === 'object')).to.equal(true); + expect(channel.state).to.equal('attached'); + }, realtime); + }); + }); }); }); diff --git a/test/realtime/presence.test.js b/test/realtime/presence.test.js index 71247b624..d6fe5464a 100644 --- a/test/realtime/presence.test.js +++ b/test/realtime/presence.test.js @@ -2499,5 +2499,70 @@ define(['ably', 'shared_helper', 'async', 'chai'], function (Ably, Helper, async } }); }); + + describe('presence.get() without presence_subscribe mode', function () { + it('with strictMode:true, rejects with 91008 and hint naming presence_subscribe', function (done) { + const helper = this.test.helper; + const channelName = 'presence-get-without-mode-strict-' + String(Math.random()).slice(2); + let realtime; + try { + realtime = helper.AblyRealtime({ strictMode: true }); + realtime.connection.on('connected', function () { + const channel = realtime.channels.get(channelName, { modes: ['publish'] }); + Helper.whenPromiseSettles(channel.attach(), function (attachErr) { + if (attachErr) { + helper.closeAndFinish(done, realtime, attachErr); + return; + } + Helper.whenPromiseSettles(channel.presence.get(), function (err) { + try { + expect(err, 'expected presence.get() to reject').to.exist; + expect(err.code).to.equal(91008); + expect(err.hint).to.be.a('string'); + expect(err.hint).to.contain('presence_subscribe'); + expect(err.hint).to.contain('ably auth keys list'); + helper.closeAndFinish(done, realtime); + } catch (assertionErr) { + helper.closeAndFinish(done, realtime, assertionErr); + } + }); + }); + }); + helper.monitorConnection(done, realtime); + } catch (err) { + helper.closeAndFinish(done, realtime, err); + } + }); + + it('with strictMode disabled (default), logs a warning and resolves to []', function (done) { + const helper = this.test.helper; + const channelName = 'presence-get-without-mode-silent-' + String(Math.random()).slice(2); + let realtime; + try { + realtime = helper.AblyRealtime(); + realtime.connection.on('connected', function () { + const channel = realtime.channels.get(channelName, { modes: ['publish'] }); + Helper.whenPromiseSettles(channel.attach(), function (attachErr) { + if (attachErr) { + helper.closeAndFinish(done, realtime, attachErr); + return; + } + Helper.whenPromiseSettles(channel.presence.get(), function (err, members) { + try { + expect(err, 'expected presence.get() not to throw with strictMode off').to.not.exist; + expect(members).to.deep.equal([]); + helper.closeAndFinish(done, realtime); + } catch (assertionErr) { + helper.closeAndFinish(done, realtime, assertionErr); + } + }); + }); + }); + helper.monitorConnection(done, realtime); + } catch (err) { + helper.closeAndFinish(done, realtime, err); + } + }); + }); }); });