Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions ably.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,13 @@ export interface ClientOptions<Plugins = CorePlugins> 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}.
*/
Expand Down
15 changes: 12 additions & 3 deletions src/common/lib/client/realtimeannotations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
umair-ably marked this conversation as resolved.
}
}

Expand Down
36 changes: 32 additions & 4 deletions src/common/lib/client/realtimechannel.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -99,6 +99,7 @@ class RealtimeChannel extends EventEmitter {
};
errorReason: ErrorInfo | null;
_mode = 0;
_silentSubscribeWarned = false;
_attachResume: boolean;
_decodingContext: EncodingDecodingContext;
_lastPayload: {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
19 changes: 19 additions & 0 deletions src/common/lib/client/realtimepresence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(),
);
Comment thread
umair-ably marked this conversation as resolved.
}

const members = this.members;
if (waitForSync) {
await members.waitSync();
Expand Down
9 changes: 9 additions & 0 deletions src/common/lib/util/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
37 changes: 37 additions & 0 deletions test/realtime/channel.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
});
65 changes: 65 additions & 0 deletions test/realtime/presence.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
});
});
});
Loading