diff --git a/ably.d.ts b/ably.d.ts index c807176653..9460b232e1 100644 --- a/ably.d.ts +++ b/ably.d.ts @@ -1990,6 +1990,25 @@ export declare interface Auth { * @returns A promise which, upon success, will be fulfilled with a {@link TokenDetails} object. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. */ authorize(tokenParams?: TokenParams, authOptions?: AuthOptions): Promise; + /** + * @deprecated v1 callback signature — no longer supported. Use {@link Auth.authorize | `realtime.auth.authorize(tokenParams)`} and `await` the returned promise. See [the v2 migration guide](https://github.com/ably/ably-js/blob/main/docs/migration-guides/v2/lib.md). + * @example + * ```ts + * // v1 (no longer supported — IDE shows this with strikethrough): + * realtime.auth.authorize(tokenParams, authOptions, (err, token) => {}); + * + * // v2: + * const token = await realtime.auth.authorize(tokenParams, authOptions); + * ``` + * @param tokenParams - A {@link TokenParams} object. + * @param authOptions - An {@link AuthOptions} object. + * @param callback - v1 Node-style callback (no longer supported). + */ + authorize( + tokenParams: TokenParams | null, + authOptions: AuthOptions | null, + callback: StandardCallback, + ): never; /** * Creates and signs an Ably {@link TokenRequest} based on the specified (or if none specified, the client library stored) {@link TokenParams} and {@link AuthOptions}. Note this can only be used when the API `key` value is available locally. Otherwise, the Ably {@link TokenRequest} must be obtained from the key owner. Use this to generate an Ably {@link TokenRequest} in order to implement an Ably Token request callback for use by other clients. Both {@link TokenParams} and {@link AuthOptions} are optional. When omitted or `null`, the default token parameters and authentication options for the client library are used, as specified in the {@link ClientOptions} when the client library was instantiated, or later updated with an explicit `authorize` request. Values passed in are used instead of, rather than being merged with, the default values. To understand why an Ably {@link TokenRequest} may be issued to clients in favor of a token, see [Token Authentication explained](https://ably.com/docs/core-features/authentication/#token-authentication). * @@ -1998,6 +2017,25 @@ export declare interface Auth { * @returns A promise which, upon success, will be fulfilled with a {@link TokenRequest} object. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. */ createTokenRequest(tokenParams?: TokenParams, authOptions?: AuthOptions): Promise; + /** + * @deprecated v1 callback signature — no longer supported. Use {@link Auth.createTokenRequest | `realtime.auth.createTokenRequest(tokenParams)`} and `await` the returned promise. See [the v2 migration guide](https://github.com/ably/ably-js/blob/main/docs/migration-guides/v2/lib.md). + * @example + * ```ts + * // v1 (no longer supported — IDE shows this with strikethrough): + * realtime.auth.createTokenRequest(tokenParams, authOptions, (err, req) => {}); + * + * // v2: + * const req = await realtime.auth.createTokenRequest(tokenParams, authOptions); + * ``` + * @param tokenParams - A {@link TokenParams} object. + * @param authOptions - An {@link AuthOptions} object. + * @param callback - v1 Node-style callback (no longer supported). + */ + createTokenRequest( + tokenParams: TokenParams | null, + authOptions: AuthOptions | null, + callback: StandardCallback, + ): never; /** * Calls the `requestToken` REST API endpoint to obtain an Ably Token according to the specified {@link TokenParams} and {@link AuthOptions}. Both {@link TokenParams} and {@link AuthOptions} are optional. When omitted or `null`, the default token parameters and authentication options for the client library are used, as specified in the {@link ClientOptions} when the client library was instantiated, or later updated with an explicit `authorize` request. Values passed in are used instead of, rather than being merged with, the default values. To understand why an Ably {@link TokenRequest} may be issued to clients in favor of a token, see [Token Authentication explained](https://ably.com/docs/core-features/authentication/#token-authentication). * @@ -2006,6 +2044,25 @@ export declare interface Auth { * @returns A promise which, upon success, will be fulfilled with a {@link TokenDetails} object. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. */ requestToken(TokenParams?: TokenParams, authOptions?: AuthOptions): Promise; + /** + * @deprecated v1 callback signature — no longer supported. Use {@link Auth.requestToken | `realtime.auth.requestToken(tokenParams)`} and `await` the returned promise. See [the v2 migration guide](https://github.com/ably/ably-js/blob/main/docs/migration-guides/v2/lib.md). + * @example + * ```ts + * // v1 (no longer supported — IDE shows this with strikethrough): + * realtime.auth.requestToken(tokenParams, authOptions, (err, token) => {}); + * + * // v2: + * const token = await realtime.auth.requestToken(tokenParams, authOptions); + * ``` + * @param tokenParams - A {@link TokenParams} object. + * @param authOptions - An {@link AuthOptions} object. + * @param callback - v1 Node-style callback (no longer supported). + */ + requestToken( + tokenParams: TokenParams | null, + authOptions: AuthOptions | null, + callback: StandardCallback, + ): never; /** * Revokes the tokens specified by the provided array of {@link TokenRevocationTargetSpecifier}s. Only tokens issued by an API key that had revocable tokens enabled before the token was issued can be revoked. See the [token revocation docs](https://ably.com/docs/core-features/authentication#token-revocation) for more information. * @@ -2091,6 +2148,20 @@ export declare interface RealtimePresence { * @returns A promise which, upon success, will be fulfilled with an array of {@link PresenceMessage} objects. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. */ get(params?: RealtimePresenceParams): Promise; + /** + * @deprecated v1 callback signature — no longer supported. Use {@link RealtimePresence.get | `channel.presence.get(params)`} and `await` the returned promise. See [the v2 migration guide](https://github.com/ably/ably-js/blob/main/docs/migration-guides/v2/lib.md). + * @example + * ```ts + * // v1 (no longer supported — IDE shows this with strikethrough): + * channel.presence.get(params, (err, members) => {}); + * + * // v2: + * const members = await channel.presence.get(params); + * ``` + * @param params - A {@link RealtimePresenceParams} object. + * @param callback - v1 Node-style callback (no longer supported). + */ + get(params: RealtimePresenceParams | null, callback: StandardCallback): never; /** * Retrieves a {@link PaginatedResult} object, containing an array of historical {@link PresenceMessage} objects for the channel. If the channel is configured to persist messages, then presence messages can be retrieved from history for up to 72 hours in the past. If not, presence messages can only be retrieved from history for up to two minutes in the past. * @@ -2098,6 +2169,20 @@ export declare interface RealtimePresence { * @returns A promise which, upon success, will be fulfilled with a {@link PaginatedResult} object containing an array of {@link PresenceMessage} objects. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. */ history(params?: RealtimeHistoryParams): Promise>; + /** + * @deprecated v1 callback signature — no longer supported. Use {@link RealtimePresence.history | `channel.presence.history(params)`} and `await` the returned promise. See [the v2 migration guide](https://github.com/ably/ably-js/blob/main/docs/migration-guides/v2/lib.md). + * @example + * ```ts + * // v1 (no longer supported — IDE shows this with strikethrough): + * channel.presence.history(params, (err, result) => {}); + * + * // v2: + * const result = await channel.presence.history(params); + * ``` + * @param params - A {@link RealtimeHistoryParams} object. + * @param callback - v1 Node-style callback (no longer supported). + */ + history(params: RealtimeHistoryParams | null, callback: StandardCallback>): never; /** * Registers a listener that is called each time a {@link PresenceMessage} matching a given {@link PresenceAction}, or an action within an array of {@link PresenceAction | `PresenceAction`s}, is received on the channel, such as a new member entering the presence set. * @@ -2113,6 +2198,25 @@ export declare interface RealtimePresence { * @returns A promise which resolves upon success of the channel {@link RealtimeChannel.attach | `attach()`} operation and rejects with an {@link ErrorInfo} object upon its failure. */ subscribe(listener?: messageCallback): Promise; + /** + * @deprecated v1 callback signature — no longer supported. Use {@link RealtimePresence.subscribe | `channel.presence.subscribe(action, listener)`} and `await` the returned promise. See [the v2 migration guide](https://github.com/ably/ably-js/blob/main/docs/migration-guides/v2/lib.md). + * @example + * ```ts + * // v1 (no longer supported — IDE shows this with strikethrough): + * channel.presence.subscribe('enter', listener, (err) => {}); + * + * // v2: + * await channel.presence.subscribe('enter', listener); + * ``` + * @param action - A {@link PresenceAction} or an array of {@link PresenceAction | `PresenceAction`s}. + * @param listener - An event listener function. + * @param callback - v1 Node-style callback (no longer supported). + */ + subscribe( + action: PresenceAction | Array, + listener: messageCallback, + callback: ErrorCallback, + ): never; /** * Enters the presence set for the channel, optionally passing a `data` payload. A `clientId` is required to be present on a channel. * @@ -2120,6 +2224,20 @@ export declare interface RealtimePresence { * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. */ enter(data?: any): Promise; + /** + * @deprecated v1 callback signature — no longer supported. Use {@link RealtimePresence.enter | `channel.presence.enter(data)`} and `await` the returned promise. See [the v2 migration guide](https://github.com/ably/ably-js/blob/main/docs/migration-guides/v2/lib.md). + * @example + * ```ts + * // v1 (no longer supported — IDE shows this with strikethrough): + * channel.presence.enter(data, (err) => {}); + * + * // v2: + * await channel.presence.enter(data); + * ``` + * @param data - The payload associated with the presence member. + * @param callback - v1 Node-style callback (no longer supported). + */ + enter(data: any, callback: ErrorCallback): never; /** * Updates the `data` payload for a presence member. If called before entering the presence set, this is treated as an {@link PresenceActions.ENTER} event. * @@ -2127,6 +2245,20 @@ export declare interface RealtimePresence { * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. */ update(data?: any): Promise; + /** + * @deprecated v1 callback signature — no longer supported. Use {@link RealtimePresence.update | `channel.presence.update(data)`} and `await` the returned promise. See [the v2 migration guide](https://github.com/ably/ably-js/blob/main/docs/migration-guides/v2/lib.md). + * @example + * ```ts + * // v1 (no longer supported — IDE shows this with strikethrough): + * channel.presence.update(data, (err) => {}); + * + * // v2: + * await channel.presence.update(data); + * ``` + * @param data - The payload to update for the presence member. + * @param callback - v1 Node-style callback (no longer supported). + */ + update(data: any, callback: ErrorCallback): never; /** * Leaves the presence set for the channel. A client must have previously entered the presence set before they can leave it. * @@ -2134,6 +2266,20 @@ export declare interface RealtimePresence { * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. */ leave(data?: any): Promise; + /** + * @deprecated v1 callback signature — no longer supported. Use {@link RealtimePresence.leave | `channel.presence.leave(data)`} and `await` the returned promise. See [the v2 migration guide](https://github.com/ably/ably-js/blob/main/docs/migration-guides/v2/lib.md). + * @example + * ```ts + * // v1 (no longer supported — IDE shows this with strikethrough): + * channel.presence.leave(data, (err) => {}); + * + * // v2: + * await channel.presence.leave(data); + * ``` + * @param data - The payload associated with the presence member. + * @param callback - v1 Node-style callback (no longer supported). + */ + leave(data: any, callback: ErrorCallback): never; /** * Enters the presence set of the channel for a given `clientId`. Enables a single client to update presence on behalf of any number of clients using a single connection. The library must have been instantiated with an API key or a token bound to a wildcard `clientId`. * @@ -2505,6 +2651,21 @@ export declare interface RealtimeChannel extends EventEmitter {}); + * + * // v2: + * channel.unsubscribe('event', listener); + * ``` + * @param event - The event name. + * @param listener - An event listener function. + * @param callback - v1 Node-style callback (no longer supported). + */ + unsubscribe(event: string, listener: messageCallback, callback: ErrorCallback): never; /** * A {@link RealtimePresence} object. @@ -2537,6 +2698,20 @@ export declare interface RealtimeChannel extends EventEmitter>; + /** + * @deprecated v1 callback signature — no longer supported. Use {@link RealtimeChannel.history | `channel.history(params)`} and `await` the returned promise. See [the v2 migration guide](https://github.com/ably/ably-js/blob/main/docs/migration-guides/v2/lib.md). + * @example + * ```ts + * // v1 (no longer supported — IDE shows this with strikethrough): + * channel.history(params, (err, result) => {}); + * + * // v2: + * const result = await channel.history(params); + * ``` + * @param params - A {@link RealtimeHistoryParams} object. + * @param callback - v1 Node-style callback (no longer supported). + */ + history(params: RealtimeHistoryParams | null, callback: StandardCallback>): never; /** * Sets the {@link ChannelOptions} for the channel. * @@ -2577,6 +2752,21 @@ export declare interface RealtimeChannel extends EventEmitter): Promise; + /** + * @deprecated v1 callback signature — no longer supported. Use {@link RealtimeChannel.subscribe | `channel.subscribe(name, listener)`} and `await` the returned promise. See [the v2 migration guide](https://github.com/ably/ably-js/blob/main/docs/migration-guides/v2/lib.md). + * @example + * ```ts + * // v1 (no longer supported — IDE shows this with strikethrough): + * channel.subscribe('event', listener, (err) => { if (err) console.error(err); }); + * + * // v2: + * await channel.subscribe('event', listener); + * ``` + * @param event - The event name. + * @param listener - An event listener function. + * @param callback - v1 Node-style callback (no longer supported). + */ + subscribe(event: string, listener: messageCallback, callback: ErrorCallback): never; /** * Publishes a single message to the channel with the given event name and payload. When publish is called with this client library, it won't attempt to implicitly attach to the channel, so long as [transient publishing](https://ably.com/docs/realtime/channels#transient-publish) is available in the library. Otherwise, the client will implicitly attach. * @@ -2602,6 +2792,21 @@ export declare interface RealtimeChannel extends EventEmitter; + /** + * @deprecated v1 callback signature — no longer supported. Use {@link RealtimeChannel.publish | `channel.publish(name, data)`} and `await` the returned promise. See [the v2 migration guide](https://github.com/ably/ably-js/blob/main/docs/migration-guides/v2/lib.md). + * @example + * ```ts + * // v1 (no longer supported — IDE shows this with strikethrough): + * channel.publish('event', data, (err) => { if (err) console.error(err); }); + * + * // v2: + * await channel.publish('event', data); + * ``` + * @param name - The event name. + * @param data - The message payload. + * @param callback - v1 Node-style callback (no longer supported). + */ + publish(name: string, data: any, callback: ErrorCallback): never; /** * If the channel is already in the given state, returns a promise which immediately resolves to `null`. Else, calls {@link EventEmitter.once | `once()`} to return a promise which resolves the next time the channel transitions to the given state. * @@ -3292,6 +3497,19 @@ export declare interface Connection * @returns A promise which, upon success, will be fulfilled with the response time in milliseconds. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. */ ping(): Promise; + /** + * @deprecated v1 callback signature — no longer supported. Use {@link Connection.ping | `realtime.connection.ping()`} and `await` the returned promise. See [the v2 migration guide](https://github.com/ably/ably-js/blob/main/docs/migration-guides/v2/lib.md). + * @example + * ```ts + * // v1 (no longer supported — IDE shows this with strikethrough): + * realtime.connection.ping((err, responseTime) => {}); + * + * // v2: + * const responseTime = await realtime.connection.ping(); + * ``` + * @param callback - v1 Node-style callback (no longer supported). + */ + ping(callback: StandardCallback): never; /** * If the connection is already in the given state, returns a promise which immediately resolves to `null`. Else, calls {@link EventEmitter.once | `once()`} to return a promise which resolves the next time the connection transitions to the given state. * @@ -3702,6 +3920,14 @@ export declare class ErrorInfo extends Error { * Optional map of string key-value pairs containing structured metadata associated with the error. */ detail?: Record; + /** + * Actionable remediation guidance describing *how* to fix the error — distinct from + * `message`, which summarises *what* went wrong. Written as prose suitable for an + * agent or human to act on without further lookup (typically including the + * canonical replacement call and a doc link where applicable). Present only on + * SDK-originating errors that have meaningful remediation steps. + */ + hint?: string; /** * Construct an ErrorInfo object. diff --git a/scripts/moduleReport.ts b/scripts/moduleReport.ts index e64fade24b..e7cf5979e3 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: 106, gzip: 32 }; +const minimalUsefulRealtimeBundleSizeThresholdsKiB = { raw: 107, gzip: 33 }; const baseClientNames = ['BaseRest', 'BaseRealtime']; diff --git a/src/common/lib/client/auth.ts b/src/common/lib/client/auth.ts index c470666bc8..dcc6bcdacb 100644 --- a/src/common/lib/client/auth.ts +++ b/src/common/lib/client/auth.ts @@ -254,7 +254,15 @@ class Auth { */ async authorize(tokenParams: API.TokenParams | null, authOptions: AuthOptions | null): Promise; - async authorize( + authorize(...args: unknown[]): Promise { + Utils.detectV1Callback(args, 0); + return this._authorizeImpl( + args[0] as Record | null | undefined, + args[1] as AuthOptions | null | undefined, + ); + } + + private async _authorizeImpl( tokenParams?: Record | null, authOptions?: AuthOptions | null, ): Promise { @@ -390,7 +398,15 @@ class Auth { */ async requestToken(tokenParams: API.TokenParams | null, authOptions: AuthOptions): Promise; - async requestToken(tokenParams?: API.TokenParams | null, authOptions?: AuthOptions): Promise { + requestToken(...args: unknown[]): Promise { + Utils.detectV1Callback(args, 0); + return this._requestTokenImpl(args[0] as API.TokenParams | null | undefined, args[1] as AuthOptions | undefined); + } + + private async _requestTokenImpl( + tokenParams?: API.TokenParams | null, + authOptions?: AuthOptions, + ): Promise { /* RSA8e: if authOptions passed in, they're used instead of stored, don't merge them */ const resolvedAuthOptions = authOptions || this.authOptions; const resolvedTokenParams = tokenParams || Utils.copy(this.tokenParams); @@ -744,7 +760,15 @@ class Auth { * - timestamp: (optional) the time in ms since the epoch. If none is specified, * the system will be queried for a time value to use. */ - async createTokenRequest(tokenParams: API.TokenParams | null, authOptions: any): Promise { + createTokenRequest(...args: unknown[]): Promise { + Utils.detectV1Callback(args, 0); + return this._createTokenRequestImpl(args[0] as API.TokenParams | null, args[1]); + } + + private async _createTokenRequestImpl( + tokenParams: API.TokenParams | null, + authOptions: any, + ): Promise { /* RSA9h: if authOptions passed in, they're used instead of stored, don't merge them */ authOptions = authOptions || this.authOptions; tokenParams = tokenParams || Utils.copy(this.tokenParams); diff --git a/src/common/lib/client/connection.ts b/src/common/lib/client/connection.ts index 7ea0311f36..295b1f04cd 100644 --- a/src/common/lib/client/connection.ts +++ b/src/common/lib/client/connection.ts @@ -3,6 +3,7 @@ import ConnectionManager from '../transport/connectionmanager'; import Logger from '../util/logger'; import ConnectionStateChange from './connectionstatechange'; import ErrorInfo from '../types/errorinfo'; +import * as Utils from '../util/utils'; import { NormalisedClientOptions } from '../../types/ClientOptions'; import BaseRealtime from './baserealtime'; import Platform from 'common/platform'; @@ -46,7 +47,12 @@ class Connection extends EventEmitter { this.connectionManager.requestState({ state: 'connecting' }); } - async ping(): Promise { + ping(...args: unknown[]): Promise { + Utils.detectV1Callback(args, 0); + return this._pingImpl(); + } + + private async _pingImpl(): Promise { Logger.logAction(this.logger, Logger.LOG_MINOR, 'Connection.ping()', ''); return this.connectionManager.ping(); } diff --git a/src/common/lib/client/realtimechannel.ts b/src/common/lib/client/realtimechannel.ts index 388338b3ab..fe34cb5877 100644 --- a/src/common/lib/client/realtimechannel.ts +++ b/src/common/lib/client/realtimechannel.ts @@ -250,7 +250,12 @@ class RealtimeChannel extends EventEmitter { return false; } - async publish(...args: any[]): Promise { + publish(...args: unknown[]): Promise { + Utils.detectV1Callback(args, 0); + return this._publishImpl(args); + } + + private async _publishImpl(args: any[]): Promise { const first = args[0], second = args[1]; let messages: Message[]; @@ -453,7 +458,12 @@ class RealtimeChannel extends EventEmitter { this.send(msg); } - async subscribe(...args: unknown[] /* [event], listener */): Promise { + subscribe(...args: unknown[] /* [event], listener */): Promise { + Utils.detectV1Callback(args, 2); + return this._subscribeImpl(args); + } + + private async _subscribeImpl(args: unknown[]): Promise { const [event, listener] = RealtimeChannel.processListenerArgs(args); if (this.state === 'failed') { @@ -985,7 +995,12 @@ class RealtimeChannel extends EventEmitter { } } - history = async function ( + history = function (this: RealtimeChannel, ...args: unknown[]): Promise> { + Utils.detectV1Callback(args, 0); + return this._historyImpl(args[0] as RealtimeHistoryParams | null); + } as any; + + private _historyImpl = async function ( this: RealtimeChannel, params: RealtimeHistoryParams | null, ): Promise> { diff --git a/src/common/lib/client/realtimepresence.ts b/src/common/lib/client/realtimepresence.ts index 5b9820e90e..9ea798f112 100644 --- a/src/common/lib/client/realtimepresence.ts +++ b/src/common/lib/client/realtimepresence.ts @@ -54,14 +54,24 @@ class RealtimePresence extends EventEmitter { this.pendingPresence = []; } - async enter(data: unknown): Promise { + enter(...args: unknown[]): Promise { + Utils.detectV1Callback(args, 0); + return this._enterImpl(args[0]); + } + + private async _enterImpl(data: unknown): Promise { if (isAnonymousOrWildcard(this)) { throw new ErrorInfo('clientId must be specified to enter a presence channel', 40012, 400); } return this._enterOrUpdateClient(undefined, undefined, data, 'enter'); } - async update(data: unknown): Promise { + update(...args: unknown[]): Promise { + Utils.detectV1Callback(args, 0); + return this._updateImpl(args[0]); + } + + private async _updateImpl(data: unknown): Promise { if (isAnonymousOrWildcard(this)) { throw new ErrorInfo('clientId must be specified to update presence data', 40012, 400); } @@ -129,7 +139,12 @@ class RealtimePresence extends EventEmitter { } } - async leave(data: unknown): Promise { + leave(...args: unknown[]): Promise { + Utils.detectV1Callback(args, 0); + return this._leaveImpl(args[0]); + } + + private async _leaveImpl(data: unknown): Promise { if (isAnonymousOrWildcard(this)) { throw new ErrorInfo('clientId must have been specified to enter or leave a presence channel', 40012, 400); } @@ -176,7 +191,12 @@ class RealtimePresence extends EventEmitter { } } - async get(params?: RealtimePresenceParams): Promise { + get(...args: unknown[]): Promise { + Utils.detectV1Callback(args, 0); + return this._getImpl(args[0] as RealtimePresenceParams | undefined); + } + + private async _getImpl(params?: RealtimePresenceParams): Promise { const waitForSync = !params || ('waitForSync' in params ? params.waitForSync : true); function toMessages(members: PresenceMap): PresenceMessage[] { @@ -204,7 +224,12 @@ class RealtimePresence extends EventEmitter { return toMessages(this.members); } - async history(params: RealtimeHistoryParams | null): Promise> { + history(...args: unknown[]): Promise> { + Utils.detectV1Callback(args, 0); + return this._historyImpl(args[0] as RealtimeHistoryParams | null); + } + + private async _historyImpl(params: RealtimeHistoryParams | null): Promise> { Logger.logAction(this.logger, Logger.LOG_MICRO, 'RealtimePresence.history()', 'channel = ' + this.name); // We fetch this first so that any plugin-not-provided error takes priority over other errors const restMixin = this.channel.client.rest.presenceMixin; @@ -407,7 +432,12 @@ class RealtimePresence extends EventEmitter { }); } - async subscribe(..._args: unknown[] /* [event], listener */): Promise { + subscribe(..._args: unknown[] /* [event], listener */): Promise { + Utils.detectV1Callback(_args, 2); + return this._subscribeImpl(_args); + } + + private async _subscribeImpl(_args: unknown[]): Promise { const args = RealtimeChannel.processListenerArgs(_args); const event = args[0]; const listener = args[1]; diff --git a/src/common/lib/types/errorinfo.ts b/src/common/lib/types/errorinfo.ts index 14990c9e56..d4cbf65e1f 100644 --- a/src/common/lib/types/errorinfo.ts +++ b/src/common/lib/types/errorinfo.ts @@ -8,6 +8,7 @@ export interface IPartialErrorInfo extends Error { cause?: ErrorInfo | PartialErrorInfo; href?: string; detail?: Record; + hint?: string; } function toString(err: ErrorInfo | PartialErrorInfo) { @@ -16,6 +17,7 @@ function toString(err: ErrorInfo | PartialErrorInfo) { if (err.statusCode) result += '; statusCode=' + err.statusCode; if (err.code) result += '; code=' + err.code; if (err.cause) result += '; cause=' + Utils.inspectError(err.cause); + if (err.hint) result += '; hint=' + err.hint; if (err.detail && Object.keys(err.detail).length > 0) result += '; detail=' + JSON.stringify(err.detail); if (err.href && !(err.message && err.message.indexOf('help.ably.io') > -1)) result += '; see ' + err.href + ' '; result += ']'; @@ -42,6 +44,7 @@ export default class ErrorInfo extends Error implements IPartialErrorInfo, API.E cause?: ErrorInfo; href?: string; detail?: Record; + hint?: string; constructor(message: string, code: number, statusCode: number, cause?: ErrorInfo, detail?: Record) { super(message); @@ -82,6 +85,7 @@ export class PartialErrorInfo extends Error implements IPartialErrorInfo { cause?: ErrorInfo | PartialErrorInfo; href?: string; detail?: Record; + hint?: string; constructor( message: string, diff --git a/src/common/lib/util/utils.ts b/src/common/lib/util/utils.ts index 9c158442cd..3a1bd875de 100644 --- a/src/common/lib/util/utils.ts +++ b/src/common/lib/util/utils.ts @@ -263,6 +263,38 @@ export function isErrorInfoOrPartialErrorInfo(err: unknown): err is ErrorInfo | return typeof err == 'object' && err !== null && (err instanceof ErrorInfo || err instanceof PartialErrorInfo); } +/** + * Detect a v1-style trailing callback on a public method's args list and throw a + * steering error. v2 removed callback support from these methods, but agents trained + * on v1 docs keep passing callbacks — without this check, the callback is silently + * swallowed and the call hangs (subscribe) or no-ops (publish). + * + * Apply only to methods whose v1 form accepted a Node-style (err, result) callback + * and which are promise-only in v2. Do not apply to APIs that legitimately accept a + * trailing function (e.g. ClientOptions.authCallback). + * + * Fires when the trailing arg is a function AND either: + * - `args.length > v2TrailingFnArity` (the trailing fn is beyond the arity at + * which v2 legitimately ends in a function), or + * - the second-to-last arg is also a function — none of the v2 forms take two + * trailing functions, so e.g. `subscribe(listener, callback)` is always v1 + * even though it matches the arity of `subscribe(event, listener)`. + * + * @param v2TrailingFnArity - The arity at which v2 legitimately ends in a function + * (e.g. `subscribe(event, listener)` → 2). For methods where v2 never ends in a + * function (`authorize`, `publish`, `requestToken`, ...), pass 0. + */ +export function detectV1Callback(args: ArrayLike, v2TrailingFnArity: number): void { + const n = args.length; + if (typeof args[n - 1] !== 'function') return; + if (n <= v2TrailingFnArity && typeof args[n - 2] !== 'function') return; + const err = new ErrorInfo('v1 callback signature is no longer supported.', 40025, 400); + err.hint = + 'v2 uses Promises — drop the trailing callback and `await` the returned promise. ' + + 'See https://github.com/ably/ably-js/blob/main/docs/migration-guides/v2/lib.md'; + throw err; +} + export function inspectError(err: unknown): string { if ( err instanceof Error || diff --git a/src/platform/react-hooks/src/hooks/useChannel.ts b/src/platform/react-hooks/src/hooks/useChannel.ts index a1c7d4735a..aaf0f6f388 100644 --- a/src/platform/react-hooks/src/hooks/useChannel.ts +++ b/src/platform/react-hooks/src/hooks/useChannel.ts @@ -1,5 +1,5 @@ import * as Ably from 'ably'; -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { ChannelParameters } from '../AblyReactHooks.js'; import { useAbly } from './useAbly.js'; import { useStateErrors } from './useStateErrors.js'; @@ -56,8 +56,8 @@ export function useChannel( const ablyMessageCallbackRef = useRef(ablyMessageCallback); - const history: Ably.RealtimeChannel['history'] = useCallback( - (params?: Ably.RealtimeHistoryParams) => channel.history(params), + const history: Ably.RealtimeChannel['history'] = useMemo( + () => ((params?: Ably.RealtimeHistoryParams) => channel.history(params)) as Ably.RealtimeChannel['history'], [channel], ); diff --git a/test/realtime/auth.test.js b/test/realtime/auth.test.js index ee59f1cece..58a8ee1e46 100644 --- a/test/realtime/auth.test.js +++ b/test/realtime/auth.test.js @@ -1643,5 +1643,41 @@ define(['ably', 'shared_helper', 'async', 'chai'], function (Ably, Helper, async }); }; }); + + /** + * v1-style trailing-callback shape on Auth.{authorize, requestToken, + * createTokenRequest} throws synchronously with a steering ErrorInfo. + */ + [ + { method: 'authorize', invoke: (auth) => auth.authorize(null, null, () => {}) }, + { method: 'requestToken', invoke: (auth) => auth.requestToken(null, null, () => {}) }, + { method: 'createTokenRequest', invoke: (auth) => auth.createTokenRequest(null, null, () => {}) }, + ].forEach(function (testCase) { + it('v1_callback_auth_' + testCase.method + '_throws_synchronously', function (done) { + var helper = this.test.helper, + realtime = helper.AblyRealtime({ autoConnect: false }); + + try { + testCase.invoke(realtime.auth); + helper.closeAndFinish( + done, + realtime, + new Error('expected auth.' + testCase.method + '() to throw on v1 callback shape'), + ); + } catch (err) { + try { + expect(err.code).to.equal(40025); + expect(err.message).to.contain('v1 callback signature'); + expect(err.message).to.contain('no longer supported'); + expect(err.hint).to.be.a('string'); + expect(err.hint).to.contain('v2 uses Promises'); + expect(err.hint).to.contain('https://github.com/ably/ably-js/blob/main/docs/migration-guides/v2/lib.md'); + helper.closeAndFinish(done, realtime); + } catch (assertionErr) { + helper.closeAndFinish(done, realtime, assertionErr); + } + } + }); + }); }); }); diff --git a/test/realtime/channel.test.js b/test/realtime/channel.test.js index 788478d6fc..5f1d6f89ee 100644 --- a/test/realtime/channel.test.js +++ b/test/realtime/channel.test.js @@ -1449,6 +1449,61 @@ define(['ably', 'shared_helper', 'async', 'chai'], function (Ably, Helper, async }); }); + /** + * v1-style trailing-callback shape on RealtimeChannel.{publish, subscribe, + * history} throws synchronously with a steering ErrorInfo whose message + * diagnoses *what* went wrong and whose hint prescribes the v2 replacement + * call. + */ + [ + { method: 'publish', invoke: (ch) => ch.publish('event', { hello: 'world' }, () => {}) }, + { + method: 'subscribe', + invoke: (ch) => + ch.subscribe( + 'event', + () => {}, + () => {}, + ), + }, + { + method: 'subscribe_listener_callback', + invoke: (ch) => + ch.subscribe( + () => {}, + () => {}, + ), + }, + { method: 'history', invoke: (ch) => ch.history(null, () => {}) }, + ].forEach(function (testCase) { + it('v1_callback_' + testCase.method + '_throws_synchronously', function (done) { + var helper = this.test.helper, + realtime = helper.AblyRealtime({ autoConnect: false }), + channel = realtime.channels.get('v1cb_channel_' + testCase.method); + + try { + testCase.invoke(channel); + helper.closeAndFinish( + done, + realtime, + new Error('expected ' + testCase.method + '() to throw on v1 callback shape'), + ); + } catch (err) { + try { + expect(err.code).to.equal(40025); + expect(err.message).to.contain('v1 callback signature'); + expect(err.message).to.contain('no longer supported'); + expect(err.hint).to.be.a('string'); + expect(err.hint).to.contain('v2 uses Promises'); + expect(err.hint).to.contain('https://github.com/ably/ably-js/blob/main/docs/migration-guides/v2/lib.md'); + helper.closeAndFinish(done, realtime); + } catch (assertionErr) { + helper.closeAndFinish(done, realtime, assertionErr); + } + } + }); + }); + /** * A channel attach that times out should be retried * diff --git a/test/realtime/connection.test.js b/test/realtime/connection.test.js index 4c398bdd65..49c2d51d8a 100644 --- a/test/realtime/connection.test.js +++ b/test/realtime/connection.test.js @@ -473,5 +473,32 @@ define(['ably', 'shared_helper', 'async', 'chai'], function (Ably, Helper, async await helper.closeAndFinishAsync(realtime); }); + + /** + * v1-style trailing-callback shape on Connection.ping throws synchronously + * with a steering ErrorInfo. + */ + it('v1_callback_ping_throws_synchronously', function (done) { + const helper = this.test.helper; + const realtime = helper.AblyRealtime({ autoConnect: false }); + try { + realtime.connection.ping(function noopCallback(err) { + void err; + }); + helper.closeAndFinish(done, realtime, new Error('expected ping() to throw on v1 callback shape')); + } catch (err) { + try { + expect(err.code).to.equal(40025); + expect(err.message).to.contain('v1 callback signature'); + expect(err.message).to.contain('no longer supported'); + expect(err.hint).to.be.a('string'); + expect(err.hint).to.contain('v2 uses Promises'); + expect(err.hint).to.contain('https://github.com/ably/ably-js/blob/main/docs/migration-guides/v2/lib.md'); + helper.closeAndFinish(done, realtime); + } catch (assertionErr) { + helper.closeAndFinish(done, realtime, assertionErr); + } + } + }); }); }); diff --git a/test/realtime/presence.test.js b/test/realtime/presence.test.js index 6bf79d78d3..0e8b8cfafd 100644 --- a/test/realtime/presence.test.js +++ b/test/realtime/presence.test.js @@ -2309,5 +2309,61 @@ define(['ably', 'shared_helper', 'async', 'chai'], function (Ably, Helper, async ); }); }); + + /** + * v1-style trailing-callback shape on RealtimePresence.{enter, update, leave, + * get, history, subscribe} throws synchronously with a steering ErrorInfo. + */ + [ + { method: 'enter', invoke: (presence) => presence.enter({ data: 'x' }, () => {}) }, + { method: 'update', invoke: (presence) => presence.update({ data: 'x' }, () => {}) }, + { method: 'leave', invoke: (presence) => presence.leave({ data: 'x' }, () => {}) }, + { method: 'get', invoke: (presence) => presence.get(null, () => {}) }, + { method: 'history', invoke: (presence) => presence.history(null, () => {}) }, + { + method: 'subscribe', + invoke: (presence) => + presence.subscribe( + 'enter', + () => {}, + () => {}, + ), + }, + { + method: 'subscribe_listener_callback', + invoke: (presence) => + presence.subscribe( + () => {}, + () => {}, + ), + }, + ].forEach(function (testCase) { + it('v1_callback_presence_' + testCase.method + '_throws_synchronously', function (done) { + var helper = this.test.helper, + realtime = helper.AblyRealtime({ clientId: testClientId, autoConnect: false }), + channel = realtime.channels.get('v1cb_presence_' + testCase.method); + + try { + testCase.invoke(channel.presence); + helper.closeAndFinish( + done, + realtime, + new Error('expected presence.' + testCase.method + '() to throw on v1 callback shape'), + ); + } catch (err) { + try { + expect(err.code).to.equal(40025); + expect(err.message).to.contain('v1 callback signature'); + expect(err.message).to.contain('no longer supported'); + expect(err.hint).to.be.a('string'); + expect(err.hint).to.contain('v2 uses Promises'); + expect(err.hint).to.contain('https://github.com/ably/ably-js/blob/main/docs/migration-guides/v2/lib.md'); + helper.closeAndFinish(done, realtime); + } catch (assertionErr) { + helper.closeAndFinish(done, realtime, assertionErr); + } + } + }); + }); }); });