diff --git a/scripts/moduleReport.ts b/scripts/moduleReport.ts index 0436150012..2084541f78 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: 108, gzip: 33 }; +const minimalUsefulRealtimeBundleSizeThresholdsKiB = { raw: 118, gzip: 36 }; const baseClientNames = ['BaseRest', 'BaseRealtime']; diff --git a/src/common/lib/client/auth.ts b/src/common/lib/client/auth.ts index 3889eb54a4..afd9f4f079 100644 --- a/src/common/lib/client/auth.ts +++ b/src/common/lib/client/auth.ts @@ -155,10 +155,14 @@ class Auth { } else { /* Basic auth */ if (!options.key) { - const msg = - 'No authentication options provided; need one of: key, authUrl, or authCallback (or for testing only, token or tokenDetails)'; + const msg = 'No authentication options provided'; Logger.logAction(this.logger, Logger.LOG_ERROR, 'Auth()', msg); - throw new ErrorInfo(msg, 40160, 401); + throw new ErrorInfo({ + message: msg, + code: 40160, + statusCode: 401, + hint: 'Set one of the following in ClientOptions: key, authUrl, authCallback, token, or tokenDetails. For production use, prefer authUrl or authCallback for client-side (browser, mobile apps), or key for server-side.', + }); } Logger.logAction(this.logger, Logger.LOG_MINOR, 'Auth()', 'anonymous, using basic auth'); this._saveBasicOptions(options); @@ -269,7 +273,12 @@ class Auth { /* RSA10a: authorize() call implies token auth. If a key is passed it, we * just check if it doesn't clash and assume we're generating a token from it */ if (authOptions && authOptions.key && this.authOptions.key !== authOptions.key) { - throw new ErrorInfo('Unable to update auth options with incompatible key', 40102, 401); + throw new ErrorInfo({ + message: 'authorize called with a key that does not match the existing key being used by the client', + code: 40102, + statusCode: 401, + hint: 'To use a different key, construct a new Ably client with the key as a client option.', + }); } try { @@ -486,27 +495,39 @@ class Auth { } if (Platform.BufferUtils.isBuffer(body)) body = body.toString(); if (!contentType) { - cb(new ErrorInfo('authUrl response is missing a content-type header', 40170, 401), null); + const err = new ErrorInfo({ + message: 'authUrl response is missing a Content-Type header', + code: 40170, + statusCode: 401, + hint: 'Set a Content-Type response header on your authUrl endpoint: application/json for a TokenDetails/TokenRequest object, text/plain for a token string, or application/jwt for a JWT.', + }); + cb(err, null); return; } const json = contentType.indexOf('application/json') > -1, text = contentType.indexOf('text/plain') > -1 || contentType.indexOf('application/jwt') > -1; if (!json && !text) { - cb( - new ErrorInfo( - 'authUrl responded with unacceptable content-type ' + - contentType + - ', should be either text/plain, application/jwt or application/json', - 40170, - 401, - ), - null, - ); + const err = new ErrorInfo({ + message: + 'authUrl responded with unacceptable Content-Type ' + + contentType + + '. Expected one of: text/plain, application/jwt or application/json', + code: 40170, + statusCode: 401, + hint: 'Change your authUrl endpoint to respond with a Content-Type the SDK can parse: application/json (TokenDetails/TokenRequest), text/plain (token string), or application/jwt (JWT).', + }); + cb(err, null); return; } if (json) { if ((body as string).length > MAX_TOKEN_LENGTH) { - cb(new ErrorInfo('authUrl response exceeded max permitted length', 40170, 401), null); + const err = new ErrorInfo({ + message: 'authUrl JSON response exceeded the maximum permitted length of 128 KB', + code: 40170, + statusCode: 401, + hint: 'Two things commonly cause this. If your authUrl wraps the Ably token in an envelope with extra fields, return only the token payload (a token string, or a TokenRequest/TokenDetails object). If the token carries a very large capability, narrow it: grant a wildcard resource such as "*" or "namespace:*" instead of listing every channel.', + }); + cb(err, null); return; } try { @@ -585,7 +606,12 @@ class Auth { 'Auth()', 'library initialized with a token literal without any way to renew the token when it expires (no authUrl, authCallback, or key). See https://help.ably.io/error/40171 for help', ); - throw new ErrorInfo(msg, 40171, 403); + throw new ErrorInfo({ + message: msg, + code: 40171, + statusCode: 403, + hint: 'Initialise the client with one of ClientOptions.{ key, authUrl, authCallback } so the SDK can refresh tokens.', + }); } /* normalise token params */ @@ -626,9 +652,18 @@ class Auth { timeoutLength = this.client.options.timeouts.realtimeRequestTimeout, tokenRequestCallbackTimeout = Platform.Config.setTimeout(() => { tokenRequestCallbackTimeoutExpired = true; - const msg = 'Token request callback timed out after ' + timeoutLength / 1000 + ' seconds'; + const msg = + 'Token request via authCallback/authUrl did not complete within the configured timeout (' + + timeoutLength / 1000 + + ' seconds)'; Logger.logAction(this.logger, Logger.LOG_ERROR, 'Auth.requestToken()', msg); - reject(new ErrorInfo(msg, 40170, 401)); + const err = new ErrorInfo({ + message: msg, + code: 40170, + statusCode: 401, + hint: 'Add logging to your authCallback/authUrl to find where it stalls, and make sure it always resolves and that authUrl is reachable. If the work legitimately takes longer, raise ClientOptions.realtimeRequestTimeout.', + }); + reject(err); }, timeoutLength); tokenRequestCallback!(resolvedTokenParams, (err, tokenRequestOrDetails, contentType) => { @@ -648,29 +683,41 @@ class Auth { /* the response from the callback might be a token string, a signed request or a token details */ if (typeof tokenRequestOrDetails === 'string') { if (tokenRequestOrDetails.length === 0) { - reject(new ErrorInfo('Token string is empty', 40170, 401)); + const err = new ErrorInfo({ + message: 'Token string is empty', + code: 40170, + statusCode: 401, + hint: 'Return a non-empty value from your authCallback/authUrl: a token string, a TokenRequest, or a TokenDetails object.', + }); + reject(err); } else if (tokenRequestOrDetails.length > MAX_TOKEN_LENGTH) { - reject( - new ErrorInfo( - 'Token string exceeded max permitted length (was ' + tokenRequestOrDetails.length + ' bytes)', - 40170, - 401, - ), - ); + const err = new ErrorInfo({ + message: 'Token string exceeded max permitted length (was ' + tokenRequestOrDetails.length + ' bytes)', + code: 40170, + statusCode: 401, + hint: 'Return only the bare signed token string from your authCallback/authUrl, not an envelope, debug output, or a stringified TokenDetails wrapping it, since a token must serialise to under 128 KB. If the token carries a very large capability, narrow it: grant a wildcard resource such as "*" or "namespace:*" instead of listing every channel.', + }); + reject(err); } else if (tokenRequestOrDetails === 'undefined' || tokenRequestOrDetails === 'null') { /* common failure mode with poorly-implemented authCallbacks */ - reject(new ErrorInfo('Token string was literal null/undefined', 40170, 401)); + const err = new ErrorInfo({ + message: 'Token string was literal null/undefined', + code: 40170, + statusCode: 401, + hint: 'Return the token itself, not "undefined"/"null". Callbacks that have no value to return should pass an error instead.', + }); + reject(err); } else if ( tokenRequestOrDetails[0] === '{' && !(contentType && contentType.indexOf('application/jwt') > -1) ) { - reject( - new ErrorInfo( - "Token was double-encoded; make sure you're not JSON-encoding an already encoded token request or details", - 40170, - 401, - ), - ); + const err = new ErrorInfo({ + message: 'Token was double-encoded', + code: 40170, + statusCode: 401, + hint: 'Return TokenDetails/TokenRequest as a parsed object, or set Content-Type: application/jwt for JWT tokens.', + }); + reject(err); } else { resolve({ token: tokenRequestOrDetails } as API.TokenDetails); } @@ -681,18 +728,25 @@ class Auth { 'Expected token request callback to call back with a token string or token request/details object, but got a ' + typeof tokenRequestOrDetails; Logger.logAction(this.logger, Logger.LOG_ERROR, 'Auth.requestToken()', msg); - reject(new ErrorInfo(msg, 40170, 401)); + const err = new ErrorInfo({ + message: msg, + code: 40170, + statusCode: 401, + hint: 'If authenticating with an authCallback, update it to callback with (err, tokenStringOrTokenDetailsOrTokenRequest). If authenticating with an authUrl, update the server to respond with a token string or TokenDetails/TokenRequest JSON.', + }); + reject(err); return; } const objectSize = JSON.stringify(tokenRequestOrDetails).length; if (objectSize > MAX_TOKEN_LENGTH && !resolvedAuthOptions.suppressMaxLengthCheck) { - reject( - new ErrorInfo( + const err = new ErrorInfo({ + message: 'Token request/details object exceeded max permitted stringified size (was ' + objectSize + ' bytes)', - 40170, - 401, - ), - ); + code: 40170, + statusCode: 401, + hint: 'The TokenRequest/TokenDetails object must serialise to under 128 KB. Trim any unused fields, and if it carries a very large capability, narrow it: grant a wildcard resource such as "*" or "namespace:*" instead of listing every channel.', + }); + reject(err); return; } if ('issued' in tokenRequestOrDetails) { @@ -702,9 +756,15 @@ class Auth { } if (!('keyName' in tokenRequestOrDetails)) { const msg = - 'Expected token request callback to call back with a token string, token request object, or token details object'; + 'Expected token request callback to call back with a token string, token request object, or token details object. The returned object has neither a keyName nor an issued field.'; Logger.logAction(this.logger, Logger.LOG_ERROR, 'Auth.requestToken()', msg); - reject(new ErrorInfo(msg, 40170, 401)); + const err = new ErrorInfo({ + message: msg, + code: 40170, + statusCode: 401, + hint: 'Return a token string, a TokenRequest (an object with a `keyName` field), or a TokenDetails (an object with an `issued` field) from your authCallback/authUrl.', + }); + reject(err); return; } /* it's a token request, so make the request */ @@ -775,18 +835,33 @@ class Auth { const key = authOptions.key; if (!key) { - throw new ErrorInfo('No key specified', 40101, 403); + throw new ErrorInfo({ + message: 'No key specified', + code: 40101, + statusCode: 403, + hint: 'Pass { key } in the authOptions argument, or set ClientOptions.key and omit the authOptions argument. A passed authOptions replaces the stored options rather than merging.', + }); } const keyParts = key.split(':'), keyName = keyParts[0], keySecret = keyParts[1]; if (!keySecret) { - throw new ErrorInfo('Invalid key specified', 40101, 403); + throw new ErrorInfo({ + message: 'Invalid key specified: the key has no colon-separated secret', + code: 40101, + statusCode: 403, + hint: 'Copy the full "appId.keyId:secret" key including the colon and secret from the Ably dashboard. If you have the Ably CLI installed, `ably auth keys list` shows the keys configured on the current app.', + }); } if (tokenParams.clientId === '') { - throw new ErrorInfo('clientId can’t be an empty string', 40012, 400); + throw new ErrorInfo({ + message: 'clientId can’t be an empty string', + code: 40012, + statusCode: 400, + hint: 'Pass a non-empty clientId, or omit the field entirely for an anonymous token.', + }); } if ('capability' in tokenParams) { @@ -906,11 +981,13 @@ class Auth { if (token) { if (this._tokenClientIdMismatch(token.clientId)) { /* 403 to trigger a permanently failed client - RSA15c */ - throw new ErrorInfo( - 'Mismatch between clientId in token (' + token.clientId + ') and current clientId (' + this.clientId + ')', - 40102, - 403, - ); + throw new ErrorInfo({ + message: + 'Mismatch between clientId in token (' + token.clientId + ') and current clientId (' + this.clientId + ')', + code: 40102, + statusCode: 403, + hint: 'Issue the token with the same clientId as ClientOptions.clientId, or omit ClientOptions.clientId and let the token define it.', + }); } /* RSA4b1 -- if we have a server time offset set already, we can * automatically remove expired tokens. Else just use the cached token. If it is @@ -972,13 +1049,19 @@ class Auth { /* User-set: check types, '*' is disallowed, throw any errors */ _userSetClientId(clientId: string | undefined) { if (!(typeof clientId === 'string' || clientId === null)) { - throw new ErrorInfo('clientId must be either a string or null', 40012, 400); + throw new ErrorInfo({ + message: 'clientId must be either a string or null', + code: 40012, + statusCode: 400, + hint: 'Pass a stable string such as a user id to identify the client, or null (or omit it) for an anonymous client. Values like numbers or objects are not accepted.', + }); } else if (clientId === '*') { - throw new ErrorInfo( - 'Can’t use "*" as a clientId as that string is reserved. (To change the default token request behaviour to use a wildcard clientId, instantiate the library with {defaultTokenParams: {clientId: "*"}}), or if calling authorize(), pass it in as a tokenParam: authorize({clientId: "*"}, authOptions)', - 40012, - 400, - ); + throw new ErrorInfo({ + message: 'Can’t use "*" as a clientId as that string is reserved', + code: 40012, + statusCode: 400, + hint: 'ClientOptions.clientId sets one fixed identity and cannot be "*". To let this client act as any clientId, request a wildcard token instead: set defaultTokenParams: { clientId: "*" } on the client. The "*" belongs in the token request, not in ClientOptions.clientId.', + }); } else { const err = this._uncheckedSetClientId(clientId); if (err) throw err; @@ -991,7 +1074,12 @@ class Auth { /* Should never happen in normal circumstances as realtime should * recognise mismatch and return an error */ const msg = 'Unexpected clientId mismatch: client has ' + this.clientId + ', requested ' + clientId; - const err = new ErrorInfo(msg, 40102, 401); + const err = new ErrorInfo({ + message: msg, + code: 40102, + statusCode: 401, + hint: 'Issue the token with the matching clientId, or omit ClientOptions.clientId and let the token define it.', + }); Logger.logAction(this.logger, Logger.LOG_ERROR, 'Auth._uncheckedSetClientId()', msg); return err; } else { diff --git a/src/common/lib/client/baseclient.ts b/src/common/lib/client/baseclient.ts index e789620557..52257b22f8 100644 --- a/src/common/lib/client/baseclient.ts +++ b/src/common/lib/client/baseclient.ts @@ -76,21 +76,33 @@ class BaseClient { if (!keyMatch) { const msg = 'invalid key parameter'; Logger.logAction(this.logger, Logger.LOG_ERROR, 'BaseClient()', msg); - throw new ErrorInfo(msg, 40400, 404); + throw new ErrorInfo({ + message: msg, + code: 40400, + statusCode: 404, + hint: 'ClientOptions.key must be the full "appId.keyId:secret" string copied from the Ably dashboard. If you have the Ably CLI installed, `ably auth keys list` shows the keys configured on the current app.', + }); } normalOptions.keyName = keyMatch[1]; normalOptions.keySecret = keyMatch[2]; } if ('clientId' in normalOptions) { - if (!(typeof normalOptions.clientId === 'string' || normalOptions.clientId === null)) - throw new ErrorInfo('clientId must be either a string or null', 40012, 400); - else if (normalOptions.clientId === '*') - throw new ErrorInfo( - 'Can’t use "*" as a clientId as that string is reserved. (To change the default token request behaviour to use a wildcard clientId, use {defaultTokenParams: {clientId: "*"}})', - 40012, - 400, - ); + if (!(typeof normalOptions.clientId === 'string' || normalOptions.clientId === null)) { + throw new ErrorInfo({ + message: 'clientId must be either a string or null', + code: 40012, + statusCode: 400, + hint: 'Pass a stable string such as a user id to identify the client, or null (or omit it) for an anonymous client. Values like numbers or objects are not accepted.', + }); + } else if (normalOptions.clientId === '*') { + throw new ErrorInfo({ + message: 'Can’t use "*" as a clientId as that string is reserved', + code: 40012, + statusCode: 400, + hint: 'ClientOptions.clientId sets one fixed identity and cannot be "*". To let this client act as any clientId, request a wildcard token instead: set defaultTokenParams: { clientId: "*" } on the client. The "*" belongs in the token request, not in ClientOptions.clientId.', + }); + } } Logger.logAction(this.logger, Logger.LOG_MINOR, 'BaseClient()', 'started; version = ' + Defaults.version); diff --git a/src/common/lib/client/baserealtime.ts b/src/common/lib/client/baserealtime.ts index c32251397f..3ef26fca85 100644 --- a/src/common/lib/client/baserealtime.ts +++ b/src/common/lib/client/baserealtime.ts @@ -199,11 +199,13 @@ class Channels extends EventEmitter { channel = this.all[name] = new RealtimeChannel(this.realtime, name, channelOptions); } else if (channelOptions) { if (channel._shouldReattachToSetOptions(channelOptions, channel.channelOptions)) { - throw new ErrorInfo( - 'Channels.get() cannot be used to set channel options that would cause the channel to reattach. Please, use RealtimeChannel.setOptions() instead.', - 40000, - 400, - ); + throw new ErrorInfo({ + message: + 'Channels.get() cannot be used to set channel options that would cause the channel to reattach: channels.get() returns the existing channel instance.', + code: 40000, + statusCode: 400, + hint: 'To change params or modes on an existing channel, call channel.setOptions(opts) on the channel returned by channels.get(name). setOptions() re-attaches the channel to apply the new options.', + }); } channel.setOptions(channelOptions); } diff --git a/src/common/lib/client/paginatedresource.ts b/src/common/lib/client/paginatedresource.ts index e0ad472475..e5eef22380 100644 --- a/src/common/lib/client/paginatedresource.ts +++ b/src/common/lib/client/paginatedresource.ts @@ -147,7 +147,12 @@ export class PaginatedResult { return this.get(this._relParams!.first); } - throw new ErrorInfo('No link to the first page of results', 40400, 404); + throw new ErrorInfo({ + message: 'No link to the first page of results', + code: 40400, + statusCode: 404, + hint: 'first() is only available on results from a paginated REST query (such as channel history, presence, or stats), whose response includes a link to the first page. This result has no such link, so there is no first page to return.', + }); } async current(): Promise> { @@ -155,7 +160,12 @@ export class PaginatedResult { return this.get(this._relParams!.current); } - throw new ErrorInfo('No link to the current page of results', 40400, 404); + throw new ErrorInfo({ + message: 'No link to the current page of results', + code: 40400, + statusCode: 404, + hint: 'current() reloads the current page and is only available on results from a paginated REST query (such as channel history, presence, or stats). This result has no such link. To page through results, use next() with hasNext() or isLast() instead.', + }); } async next(): Promise | null> { diff --git a/src/common/lib/client/push.ts b/src/common/lib/client/push.ts index 4d57324c39..24a490f7c9 100644 --- a/src/common/lib/client/push.ts +++ b/src/common/lib/client/push.ts @@ -15,6 +15,14 @@ import type { import Platform from 'common/platform'; import type { ErrCallback } from 'common/types/utils'; +// Keep this byte-identical to the copy in src/plugins/push/pushactivation.ts. The plugin only +// type-imports from common client modules, so a value import here is not viable for the build. +const PUSH_ACTIVATION_NOT_AVAILABLE_HINT = + 'Run push.activate() in a browser environment with service worker support. From a server, use client.push.admin instead. Call client.push.admin.publish(recipient, payload) to send to a device or clientId. Call client.push.admin.deviceRegistrations.save(device) to register a device record.'; + +const PUSH_DEACTIVATION_NOT_AVAILABLE_HINT = + 'Run push.deactivate() in a browser environment with service worker support. From a server, call client.push.admin.deviceRegistrations.remove(deviceId) to remove a device registration.'; + class Push { client: BaseClient; admin: Admin; @@ -37,11 +45,24 @@ class Push { return; } if (!this.stateMachine) { - reject(new ErrorInfo('This platform is not supported as a target of push notifications', 40000, 400)); + const err = new ErrorInfo({ + message: + 'This platform is not supported as a target of push notifications: push activation requires a browser environment with service worker support', + code: 40000, + statusCode: 400, + hint: PUSH_ACTIVATION_NOT_AVAILABLE_HINT, + }); + reject(err); return; } if (this.stateMachine.activatedCallback) { - reject(new ErrorInfo('Activation already in progress', 40000, 400)); + const err = new ErrorInfo({ + message: 'Activation already in progress', + code: 40000, + statusCode: 400, + hint: 'Await the in-flight push.activate() before calling it again.', + }); + reject(err); return; } this.stateMachine.activatedCallback = (err: ErrorInfo) => { @@ -65,11 +86,24 @@ class Push { return; } if (!this.stateMachine) { - reject(new ErrorInfo('This platform is not supported as a target of push notifications', 40000, 400)); + const err = new ErrorInfo({ + message: + 'This platform is not supported as a target of push notifications: push activation requires a browser environment with service worker support', + code: 40000, + statusCode: 400, + hint: PUSH_DEACTIVATION_NOT_AVAILABLE_HINT, + }); + reject(err); return; } if (this.stateMachine.deactivatedCallback) { - reject(new ErrorInfo('Deactivation already in progress', 40000, 400)); + const err = new ErrorInfo({ + message: 'Deactivation already in progress', + code: 40000, + statusCode: 400, + hint: 'Await the in-flight push.deactivate() before calling it again.', + }); + reject(err); return; } this.stateMachine.deactivatedCallback = (err: ErrorInfo) => { @@ -156,11 +190,12 @@ class DeviceRegistrations { deviceId = deviceIdOrDetails.id || deviceIdOrDetails; if (typeof deviceId !== 'string' || !deviceId.length) { - throw new ErrorInfo( - 'First argument to DeviceRegistrations#get must be a deviceId string or DeviceDetails', - 40000, - 400, - ); + throw new ErrorInfo({ + message: 'First argument to DeviceRegistrations#get must be a deviceId string or DeviceDetails', + code: 40000, + statusCode: 400, + hint: 'Pass either the device id string or a DeviceDetails object with a non-empty .id field. The local device id is available from client.device().id after push.activate() completes. Alternatively pass the .id of a DeviceDetails returned by push.admin.deviceRegistrations.save().', + }); } Utils.mixin(headers, client.options.headers); @@ -209,11 +244,12 @@ class DeviceRegistrations { deviceId = deviceIdOrDetails.id || deviceIdOrDetails; if (typeof deviceId !== 'string' || !deviceId.length) { - throw new ErrorInfo( - 'First argument to DeviceRegistrations#remove must be a deviceId string or DeviceDetails', - 40000, - 400, - ); + throw new ErrorInfo({ + message: 'First argument to DeviceRegistrations#remove must be a deviceId string or DeviceDetails', + code: 40000, + statusCode: 400, + hint: 'Pass either the device id string or the DeviceDetails object (with a non-empty .id field). To deactivate the local device, call client.push.deactivate() instead.', + }); } Utils.mixin(headers, client.options.headers); diff --git a/src/common/lib/client/realtimeannotations.ts b/src/common/lib/client/realtimeannotations.ts index 6d698c7f3a..5390d55d7d 100644 --- a/src/common/lib/client/realtimeannotations.ts +++ b/src/common/lib/client/realtimeannotations.ts @@ -69,13 +69,15 @@ class RealtimeAnnotations { await channel.attach(); } - // explicit check for attach state in caes attachOnSubscribe=false + // explicit check for attach state in case attachOnSubscribe=false if ((this.channel.state === 'attached' && this.channel._mode & flags.ANNOTATION_SUBSCRIBE) === 0) { - throw new ErrorInfo( - "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)", - 93001, - 400, - ); + throw 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.', + }); } } diff --git a/src/common/lib/client/realtimechannel.ts b/src/common/lib/client/realtimechannel.ts index fcc825a36c..3db5cd0385 100644 --- a/src/common/lib/client/realtimechannel.ts +++ b/src/common/lib/client/realtimechannel.ts @@ -33,11 +33,23 @@ interface RealtimeHistoryParams { function validateChannelOptions(options?: API.ChannelOptions) { if (options && 'params' in options && !Utils.isObject(options.params)) { - return new ErrorInfo('options.params must be an object', 40000, 400); + const err = new ErrorInfo({ + message: 'options.params must be an object', + code: 40000, + statusCode: 400, + hint: 'Pass an object map of channel params (e.g. { rewind: "1" }), not a string or array.', + }); + return err; } if (options && 'modes' in options) { if (!Array.isArray(options.modes)) { - return new ErrorInfo('options.modes must be an array', 40000, 400); + const err = new ErrorInfo({ + message: 'options.modes must be an array', + code: 40000, + statusCode: 400, + hint: 'Pass an array of ChannelMode strings, e.g. { modes: ["publish", "subscribe"] }.', + }); + return err; } for (let i = 0; i < options.modes.length; i++) { const currentMode = options.modes[i]; @@ -46,7 +58,13 @@ function validateChannelOptions(options?: API.ChannelOptions) { typeof currentMode !== 'string' || !channelModes.includes(String.prototype.toUpperCase.call(currentMode)) ) { - return new ErrorInfo('Invalid channel mode: ' + currentMode, 40000, 400); + const err = new ErrorInfo({ + message: 'Invalid channel mode: ' + currentMode, + code: 40000, + statusCode: 400, + hint: `Valid ChannelMode values are: ${channelModes.join(', ').toLowerCase()}. The server grants only the modes the key or token capability permits and silently drops the rest on attach. After attach, channel.modes shows what was granted. If you have the Ably CLI installed, \`ably auth keys list\` shows your key's capabilities.`, + }); + return err; } } } @@ -175,12 +193,14 @@ class RealtimeChannel extends EventEmitter { } invalidStateError(): ErrorInfo { - return new ErrorInfo( - 'Channel operation failed as channel state is ' + this.state, - 90001, - 400, - this.errorReason || undefined, - ); + const err = new ErrorInfo({ + message: 'Channel operation failed as channel state is ' + this.state, + code: 90001, + statusCode: 400, + cause: this.errorReason || undefined, + hint: 'Inspect channel.errorReason for the underlying cause. It may be null after a clean detach. From "failed" or "detached", call channel.attach() to recover. From "suspended" the SDK re-attaches automatically, or call channel.attach() to retry now.', + }); + return err; } static processListenerArgs(args: unknown[]): any[] { @@ -273,11 +293,12 @@ class RealtimeChannel extends EventEmitter { messages = Message.fromValuesArray(first); params = args[1]; } else { - throw new ErrorInfo( - 'The single-argument form of publish() expects a message object or an array of message objects', - 40013, - 400, - ); + throw new ErrorInfo({ + message: 'The single-argument form of publish() expects a message object or an array of message objects', + code: 40013, + statusCode: 400, + hint: 'Call publish(name, data) for a single event, or publish(message | message[]) with a Message-shaped object.', + }); } const maxMessageSize = this.client.options.maxMessageSize; // TODO get rid of CipherOptions type assertion, indicates channeloptions types are broken @@ -285,11 +306,12 @@ class RealtimeChannel extends EventEmitter { /* RSL1i */ const size = getMessagesSize(wireMessages); if (size > maxMessageSize) { - throw new ErrorInfo( - `Maximum size of messages that can be published at once exceeded (was ${size} bytes; limit is ${maxMessageSize} bytes)`, - 40009, - 400, - ); + throw new ErrorInfo({ + message: `Maximum size of messages that can be published at once exceeded (was ${size} bytes, against a limit of ${maxMessageSize} bytes)`, + code: 40009, + statusCode: 400, + hint: 'Split the publish into multiple calls so each batch is under the limit. If a single message exceeds the limit, reduce its payload size. To lift the account limit, contact Ably support.', + }); } this.throwIfUnpublishableState(); @@ -419,8 +441,14 @@ class RealtimeChannel extends EventEmitter { case 'detached': return; // RTL5b - case 'failed': - throw new ErrorInfo('Unable to detach; channel state = failed', 90001, 400); + case 'failed': { + throw new ErrorInfo({ + message: 'Unable to detach as channel state is failed', + code: 90001, + statusCode: 400, + hint: 'A failed channel is not attached, so there is nothing to detach. Inspect channel.errorReason for the cause. Call channel.attach() to recover the channel, or channels.release(name) to discard it.', + }); + } default: // RTL5l: if connection is not connected, immediately transition to detached if (connectionManager.state.state !== 'connected') { @@ -506,8 +534,13 @@ class RealtimeChannel extends EventEmitter { switch (this.state) { case 'initialized': case 'detaching': - case 'detached': - throw new PartialErrorInfo('Unable to sync to channel; not attached', 40000); + case 'detached': { + // sync() is an internal SDK method, so no fix-it hint here — user/LLM code shouldn't reach this throw. + throw new PartialErrorInfo({ + message: 'Unable to sync to channel; not attached', + code: 40000, + }); + } default: } const connectionManager = this.connectionManager; @@ -951,12 +984,22 @@ class RealtimeChannel extends EventEmitter { timeoutPendingState(): void { switch (this.state) { case 'attaching': { - const err = new ErrorInfo('Channel attach timed out', 90007, 408); + const err = new ErrorInfo({ + message: 'Channel attach timed out', + code: 90007, + statusCode: 408, + hint: 'The channel is now suspended. The SDK retries the attach automatically while the connection is connected, and you can call channel.attach() to retry immediately. Inspect channel.errorReason if it keeps timing out.', + }); this.notifyState('suspended', err); break; } case 'detaching': { - const err = new ErrorInfo('Channel detach timed out', 90007, 408); + const err = new ErrorInfo({ + message: 'Channel detach timed out', + code: 90007, + statusCode: 408, + hint: 'The detach timed out and the channel is back in the attached state. Call channel.detach() again to retry. Inspect channel.errorReason if it keeps timing out.', + }); this.notifyState('attached', err); break; } @@ -1029,14 +1072,20 @@ class RealtimeChannel extends EventEmitter { if (params && params.untilAttach) { if (this.state !== 'attached') { - throw new ErrorInfo('option untilAttach requires the channel to be attached', 40000, 400); + throw new ErrorInfo({ + message: 'option untilAttach requires the channel to be attached, was: ' + this.state, + code: 40000, + statusCode: 400, + hint: 'Await channel.attach() before calling history({ untilAttach: true }).', + }); } if (!this.properties.attachSerial) { - throw new ErrorInfo( - 'untilAttach was specified and channel is attached, but attachSerial is not defined', - 40000, - 400, - ); + throw new ErrorInfo({ + message: 'untilAttach was specified and channel is attached, but attachSerial is not defined', + code: 40000, + statusCode: 400, + hint: 'Detach the channel (await channel.detach()) and re-attach (await channel.attach()) so the SDK records the attachSerial from the new attach, then retry history({ untilAttach: true }).', + }); } delete params.untilAttach; params.from_serial = this.properties.attachSerial; @@ -1055,12 +1104,15 @@ class RealtimeChannel extends EventEmitter { if (s === 'initialized' || s === 'detached' || s === 'failed') { return null; } - return new ErrorInfo( - 'Can only release a channel in a state where there is no possibility of further updates from the server being received (initialized, detached, or failed); was ' + + const err = new ErrorInfo({ + message: + 'Can only release a channel in a state where there is no possibility of further updates from the server being received (initialized, detached, or failed). The current state is ' + s, - 90001, - 400, - ); + code: 90001, + statusCode: 400, + hint: 'Call channel.detach() and wait for the channel to reach "detached" before calling channels.release(name).', + }); + return err; } setChannelSerial(channelSerial?: string | null): void { @@ -1122,11 +1174,12 @@ class RealtimeChannel extends EventEmitter { params?: Record, ): Promise { if (!message.serial) { - throw new ErrorInfo( - 'This message lacks a serial and cannot be updated. Make sure you have enabled "Message annotations, updates, and deletes" in channel settings on your dashboard.', - 40003, - 400, - ); + throw new ErrorInfo({ + message: 'This message lacks a serial', + code: 40003, + statusCode: 400, + hint: 'Pass the Message received from a subscribe callback (which carries .serial), not a freshly constructed object.', + }); } this.throwIfUnpublishableState(); diff --git a/src/common/lib/client/realtimepresence.ts b/src/common/lib/client/realtimepresence.ts index 047fe598ce..24f2d19dd2 100644 --- a/src/common/lib/client/realtimepresence.ts +++ b/src/common/lib/client/realtimepresence.ts @@ -62,7 +62,12 @@ class RealtimePresence extends EventEmitter { private async _enterImpl(data: unknown): Promise { if (isAnonymousOrWildcard(this)) { - throw new ErrorInfo('clientId must be specified to enter a presence channel', 40012, 400); + throw new ErrorInfo({ + message: 'clientId must be specified to enter a presence channel', + code: 40012, + statusCode: 400, + hint: 'Set ClientOptions.clientId (or include clientId in the token) before calling presence.enter() again. To enter on behalf of another identity, use presence.enterClient(otherId, data), which requires the library to be instantiated with an API key or a token bound to a wildcard clientId.', + }); } return this._enterOrUpdateClient(undefined, undefined, data, 'enter'); } @@ -74,7 +79,12 @@ class RealtimePresence extends EventEmitter { private async _updateImpl(data: unknown): Promise { if (isAnonymousOrWildcard(this)) { - throw new ErrorInfo('clientId must be specified to update presence data', 40012, 400); + throw new ErrorInfo({ + message: 'clientId must be specified to update presence data', + code: 40012, + statusCode: 400, + hint: 'Set ClientOptions.clientId (or include clientId in the token) before calling presence.update() again. To update on behalf of another identity, use presence.updateClient(otherId, data), which requires the library to be instantiated with an API key or a token bound to a wildcard clientId.', + }); } return this._enterOrUpdateClient(undefined, undefined, data, 'update'); } @@ -153,7 +163,12 @@ class RealtimePresence extends EventEmitter { 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); + throw new ErrorInfo({ + message: 'clientId must have been specified to enter or leave a presence channel', + code: 40012, + statusCode: 400, + hint: 'To leave a member entered on behalf of another identity, call presence.leaveClient(otherId). A client that never had a clientId has no self-entered member to leave. leaveClient requires the library to be instantiated with an API key or a token bound to a wildcard clientId.', + }); } return this.leaveClient(undefined, data); } @@ -195,12 +210,11 @@ class RealtimePresence extends EventEmitter { // by itself instead of attaching just in order to leave. // eslint-disable-next-line no-fallthrough default: { - const err = new PartialErrorInfo( - 'Unable to leave presence channel while in ' + channel.state + ' state', - 90001, - ); - err.code = 90001; - throw err; + throw new PartialErrorInfo({ + message: 'Unable to leave presence channel while in ' + channel.state + ' state', + code: 90001, + hint: 'presence.leave() only works while the channel is attached. From "suspended" or "attaching", await channel.attach() and retry, since your presence membership is restored when the channel re-attaches. From "initialized", "detached", or "failed" there is nothing to leave: either no member was entered, or the SDK already cleared your membership when the channel left the attached state.', + }); } } } @@ -224,6 +238,7 @@ class RealtimePresence extends EventEmitter { statusCode: 400, code: 91005, message: 'Presence state is out of sync due to channel being in the SUSPENDED state', + hint: 'Wait for the channel to reach "attached" before calling presence.get(), or pass { waitForSync: false } to read the last known (stale) members.', }); } return toMessages(this.members); @@ -253,11 +268,12 @@ class RealtimePresence extends EventEmitter { delete params.untilAttach; params.from_serial = this.channel.properties.attachSerial; } else { - throw new ErrorInfo( - 'option untilAttach requires the channel to be attached, was: ' + this.channel.state, - 40000, - 400, - ); + throw new ErrorInfo({ + message: 'option untilAttach requires the channel to be attached, was: ' + this.channel.state, + code: 40000, + statusCode: 400, + hint: 'Await channel.attach() before calling presence.history({ untilAttach: true }).', + }); } } @@ -428,7 +444,13 @@ class RealtimePresence extends EventEmitter { // RTP17g1: suppress id if the connId has changed const id = entry.connectionId === connId ? entry.id : undefined; this._enterOrUpdateClient(id, entry.clientId, entry.data, 'enter').catch((err) => { - const wrappedErr = new ErrorInfo('Presence auto re-enter failed', 91004, 400, err); + const wrappedErr = new ErrorInfo({ + message: 'Presence auto re-enter failed', + code: 91004, + statusCode: 400, + cause: err, + hint: 'Listen for the channel "update" event and call presence.enter(...) again once the channel is attached. For a member entered on behalf of another clientId, call presence.enterClient(clientId, data) instead.', + }); Logger.logAction( this.logger, Logger.LOG_ERROR, diff --git a/src/common/lib/client/rest.ts b/src/common/lib/client/rest.ts index 65cd973490..f22b255d4f 100644 --- a/src/common/lib/client/rest.ts +++ b/src/common/lib/client/rest.ts @@ -144,7 +144,12 @@ export class Rest { ); if (!Platform.Http.methods.includes(_method)) { - throw new ErrorInfo('Unsupported method ' + _method, 40500, 405); + throw new ErrorInfo({ + message: 'Unsupported method ' + _method, + code: 40500, + statusCode: 405, + hint: `Use one of: ${Platform.Http.methods.join(', ')}.`, + }); } if (Platform.Http.methodsWithBody.includes(_method)) { @@ -212,7 +217,12 @@ export class Rest { options?: TokenRevocationOptions, ): Promise { if (useTokenAuth(this.client.options)) { - throw new ErrorInfo('Cannot revoke tokens when using token auth', 40162, 401); + throw new ErrorInfo({ + message: 'Cannot revoke tokens when using token auth', + code: 40162, + statusCode: 401, + hint: 'Token revocation must use basic auth, so construct a separate Ably.Rest client with ClientOptions.key (the API key that issued the tokens) just for this call. Revocable tokens must have been enabled on the key in the Ably dashboard before the tokens were issued, otherwise there is nothing to revoke.', + }); } const keyName = this.client.options.keyName!; diff --git a/src/common/lib/client/restannotations.ts b/src/common/lib/client/restannotations.ts index 57c5f86069..fdaa04461c 100644 --- a/src/common/lib/client/restannotations.ts +++ b/src/common/lib/client/restannotations.ts @@ -24,11 +24,13 @@ export function serialFromMsgOrSerial(msgOrSerial: string | Message): string { break; } if (!messageSerial || typeof messageSerial !== 'string') { - throw new ErrorInfo( - 'First argument of annotations.publish() must be either a Message (or at least an object with a string `serial` property) or a message serial (string)', - 40003, - 400, - ); + throw new ErrorInfo({ + message: + 'The message argument of annotations.publish()/delete()/get() must be either a Message (or at least an object with a non-empty string `serial` property) or a message serial (non-empty string)', + code: 40003, + statusCode: 400, + hint: 'Pass the Message received from a subscribe callback (which carries .serial), or its serial string. Newly constructed Message objects do not have a serial.', + }); } return messageSerial; } @@ -40,11 +42,12 @@ export function constructValidateAnnotation( const messageSerial = serialFromMsgOrSerial(msgOrSerial); if (!annotationValues || typeof annotationValues !== 'object') { - throw new ErrorInfo( - 'Second argument of annotations.publish() must be an object (the intended annotation to publish)', - 40003, - 400, - ); + throw new ErrorInfo({ + message: 'Second argument of annotations.publish() must be an object (the intended annotation to publish)', + code: 40003, + statusCode: 400, + hint: 'Pass an Annotation-shaped object as the second argument, e.g. { type: "reaction:unique.v1", name: "👍" }.', + }); } const annotation = Annotation.fromValues(annotationValues); diff --git a/src/common/lib/client/restchannel.ts b/src/common/lib/client/restchannel.ts index 8e67062131..c63d8a5b6a 100644 --- a/src/common/lib/client/restchannel.ts +++ b/src/common/lib/client/restchannel.ts @@ -106,11 +106,13 @@ class RestChannel { messages = Message.fromValuesArray(first); params = args[1]; } else { - throw new ErrorInfo( - 'The single-argument form of publish() expects a message object or an array of message objects', - 40013, - 400, - ); + throw new ErrorInfo({ + message: + 'publish() expects an event name (string or null), a message object, or an array of message objects as its first argument', + code: 40013, + statusCode: 400, + hint: 'Call publish(name, data) for a single event, or publish(message | message[]) with a Message-shaped object.', + }); } if (!params) { @@ -139,11 +141,12 @@ class RestChannel { const size = getMessagesSize(wireMessages), maxMessageSize = options.maxMessageSize; if (size > maxMessageSize) { - throw new ErrorInfo( - `Maximum size of messages that can be published at once exceeded (was ${size} bytes; limit is ${maxMessageSize} bytes)`, - 40009, - 400, - ); + throw new ErrorInfo({ + message: `Maximum size of messages that can be published at once exceeded (was ${size} bytes, against a limit of ${maxMessageSize} bytes)`, + code: 40009, + statusCode: 400, + hint: 'Split the publish into multiple calls so each batch is under the limit. If you set ClientOptions.maxMessageSize yourself, raise it. It can only restrict below your account limit, not above it. To lift the account limit, contact Ably support.', + }); } return this._publish(serializeMessage(wireMessages, client._MsgPack, format), headers, params); diff --git a/src/common/lib/client/restchannelmixin.ts b/src/common/lib/client/restchannelmixin.ts index 85dae2b732..2c6653ba98 100644 --- a/src/common/lib/client/restchannelmixin.ts +++ b/src/common/lib/client/restchannelmixin.ts @@ -64,11 +64,12 @@ export class RestChannelMixin { static async getMessage(channel: RestChannel | RealtimeChannel, serialOrMessage: string | Message): Promise { const serial = typeof serialOrMessage === 'string' ? serialOrMessage : serialOrMessage.serial; if (!serial) { - throw new ErrorInfo( - 'This message lacks a serial. Make sure you have enabled "Message annotations, updates, and deletes" in channel settings on your dashboard.', - 40003, - 400, - ); + throw new ErrorInfo({ + message: 'This message lacks a serial', + code: 40003, + statusCode: 400, + hint: 'Pass the Message received from a subscribe callback (which carries .serial), or its serial string. Newly constructed Message objects do not have a serial.', + }); } const client = channel.client; @@ -97,11 +98,12 @@ export class RestChannelMixin { params?: Record, ): Promise { if (!message.serial) { - throw new ErrorInfo( - 'This message lacks a serial and cannot be updated. Make sure you have enabled "Message annotations, updates, and deletes" in channel settings on your dashboard.', - 40003, - 400, - ); + throw new ErrorInfo({ + message: 'This message lacks a serial', + code: 40003, + statusCode: 400, + hint: 'Pass the Message received from a subscribe callback (which carries .serial), not a freshly constructed object.', + }); } const client = channel.client; @@ -139,11 +141,12 @@ export class RestChannelMixin { ): Promise> { const serial = typeof serialOrMessage === 'string' ? serialOrMessage : serialOrMessage.serial; if (!serial) { - throw new ErrorInfo( - 'This message lacks a serial. Make sure you have enabled "Message annotations, updates, and deletes" in channel settings on your dashboard.', - 40003, - 400, - ); + throw new ErrorInfo({ + message: 'This message lacks a serial', + code: 40003, + statusCode: 400, + hint: 'Pass the Message received from a subscribe callback (which carries .serial), or its serial string. Newly constructed Message objects do not have a serial.', + }); } const client = channel.client; diff --git a/src/common/lib/transport/connectionmanager.ts b/src/common/lib/transport/connectionmanager.ts index e1dbff6355..655b58ae75 100644 --- a/src/common/lib/transport/connectionmanager.ts +++ b/src/common/lib/transport/connectionmanager.ts @@ -1898,7 +1898,12 @@ class ConnectionManager extends EventEmitter { async ping(): Promise { if (this.state.state !== 'connected') { - throw new ErrorInfo('Unable to ping service; not connected', 40000, 400); + throw new ErrorInfo({ + message: 'Unable to ping service: not connected', + code: 40000, + statusCode: 400, + hint: 'Wait for connection.state to be "connected" before calling ping(). Use await connection.whenState("connected") or connection.once("connected", …). From the "closed" or "failed" state, or "initialized" with autoConnect disabled, the SDK does not connect automatically, so call connection.connect() first.', + }); } const transport = this.activeProtocol?.getTransport(); @@ -1960,13 +1965,27 @@ class ConnectionManager extends EventEmitter { } else if (err.code === 40102) { this.notifyState({ state: 'failed', error: err }); } else if (err.statusCode === HttpStatusCodes.Forbidden) { - const msg = 'Client configured authentication provider returned 403; failing the connection'; + const msg = 'Client configured authentication provider returned 403, failing the connection'; Logger.logAction(this.logger, Logger.LOG_ERROR, 'ConnectionManager.actOnErrorFromAuthorize()', msg); - this.notifyState({ state: 'failed', error: new ErrorInfo(msg, 80019, 403, err) }); + const wrapped = new ErrorInfo({ + message: msg, + code: 80019, + statusCode: 403, + cause: err, + hint: 'Inspect cause for the underlying error, then fix your authUrl/authCallback so it does not respond 403 for a valid client.', + }); + this.notifyState({ state: 'failed', error: wrapped }); } else { const msg = 'Client configured authentication provider request failed'; Logger.logAction(this.logger, Logger.LOG_MINOR, 'ConnectionManager.actOnErrorFromAuthorize', msg); - this.notifyState({ state: this.state.failState as string, error: new ErrorInfo(msg, 80019, 401, err) }); + const wrapped = new ErrorInfo({ + message: msg, + code: 80019, + statusCode: 401, + cause: err, + hint: 'Check network connectivity to your authUrl/authCallback endpoint and that it returns a valid token shape. Inspect cause for the underlying error.', + }); + this.notifyState({ state: this.state.failState as string, error: wrapped }); } } diff --git a/src/common/lib/types/basemessage.ts b/src/common/lib/types/basemessage.ts index e77c343a0f..a83e50d674 100644 --- a/src/common/lib/types/basemessage.ts +++ b/src/common/lib/types/basemessage.ts @@ -132,7 +132,12 @@ export function encodeData( } // RSL4a, throw an error for unsupported types - throw new ErrorInfo('Data type is unsupported', 40013, 400); + throw new ErrorInfo({ + message: 'Data type is unsupported', + code: 40013, + statusCode: 400, + hint: 'Message data must be a string, Buffer/ArrayBuffer/TypedArray, plain object, or array. Convert other types (e.g. Date, Map, Set) to one of these before publishing.', + }); } export async function decode( @@ -212,14 +217,20 @@ export async function decodeData( } case 'vcdiff': if (!context.plugins || !context.plugins.vcdiff) { - throw new ErrorInfo('Missing Vcdiff decoder (https://github.com/ably-forks/vcdiff-decoder)', 40019, 400); + throw new ErrorInfo({ + message: 'Missing Vcdiff decoder (https://github.com/ably-forks/vcdiff-decoder)', + code: 40019, + statusCode: 400, + hint: 'Install @ably/vcdiff-decoder and pass it in ClientOptions.plugins.vcdiff.', + }); } if (typeof Uint8Array === 'undefined') { - throw new ErrorInfo( - 'Delta decoding not supported on this browser (need ArrayBuffer & Uint8Array)', - 40020, - 400, - ); + throw new ErrorInfo({ + message: 'Delta decoding not supported on this browser (need ArrayBuffer & Uint8Array)', + code: 40020, + statusCode: 400, + hint: 'Disable channel deltas (do not set delta in channel params) on environments without typed-array support, or upgrade the JavaScript runtime.', + }); } try { let deltaBase = context.baseEncodedPreviousPayload; @@ -236,7 +247,12 @@ export async function decodeData( ); lastPayload = decodedData; } catch (e) { - throw new ErrorInfo('Vcdiff delta decode failed with ' + e, 40018, 400); + throw new ErrorInfo({ + message: 'Vcdiff delta decode failed with ' + e, + code: 40018, + statusCode: 400, + hint: 'The SDK recovers automatically by re-attaching from the last successfully processed message, and the server re-sends the affected message in full before deltas resume. If this recurs, disable deltas for this channel by removing delta from the channel params.', + }); } continue; default: @@ -245,11 +261,12 @@ export async function decodeData( } } catch (e) { const err = e as ErrorInfo; - decodingError = new ErrorInfo( - `Error processing the ${xform} encoding, decoder returned ‘${err.message}’`, - err.code || 40013, - 400, - ); + decodingError = new ErrorInfo({ + message: `Error processing the ${xform} encoding, decoder returned ‘${err.message}’`, + code: err.code || 40013, + statusCode: 400, + hint: err.hint, + }); } finally { finalEncoding = (lastProcessedEncodingIndex as number) <= 0 ? null : xforms.slice(0, lastProcessedEncodingIndex).join('/'); diff --git a/src/common/lib/types/errorinfo.ts b/src/common/lib/types/errorinfo.ts index d4cbf65e1f..8c584ce9d4 100644 --- a/src/common/lib/types/errorinfo.ts +++ b/src/common/lib/types/errorinfo.ts @@ -29,6 +29,9 @@ export interface IConvertibleToErrorInfo { code: number; statusCode: number; detail?: Record; + hint?: string; + cause?: ErrorInfo | PartialErrorInfo; + href?: string; } export interface IConvertibleToPartialErrorInfo { @@ -36,6 +39,9 @@ export interface IConvertibleToPartialErrorInfo { code: number | null; statusCode?: number; detail?: Record; + hint?: string; + cause?: ErrorInfo | PartialErrorInfo; + href?: string; } export default class ErrorInfo extends Error implements IPartialErrorInfo, API.ErrorInfo { @@ -46,15 +52,43 @@ export default class ErrorInfo extends Error implements IPartialErrorInfo, API.E detail?: Record; hint?: string; - constructor(message: string, code: number, statusCode: number, cause?: ErrorInfo, detail?: Record) { - super(message); - if (typeof Object.setPrototypeOf !== 'undefined') { - Object.setPrototypeOf(this, ErrorInfo.prototype); + constructor(message: string, code: number, statusCode: number, cause?: ErrorInfo, detail?: Record); + constructor(values: IConvertibleToErrorInfo); + constructor( + messageOrValues: string | IConvertibleToErrorInfo, + code?: number, + statusCode?: number, + cause?: ErrorInfo, + detail?: Record, + ) { + if (typeof messageOrValues === 'object') { + const values = messageOrValues; + if ( + typeof values.message !== 'string' || + typeof values.code !== 'number' || + typeof values.statusCode !== 'number' || + (!Utils.isNil(values.detail) && (typeof values.detail !== 'object' || Array.isArray(values.detail))) + ) { + throw new Error('ErrorInfo: invalid values: ' + Platform.Config.inspect(values)); + } + super(values.message); + if (typeof Object.setPrototypeOf !== 'undefined') { + Object.setPrototypeOf(this, ErrorInfo.prototype); + } + this.code = values.code; + this.statusCode = values.statusCode; + this.detail = values.detail; + Object.assign(this, values); + } else { + super(messageOrValues); + if (typeof Object.setPrototypeOf !== 'undefined') { + Object.setPrototypeOf(this, ErrorInfo.prototype); + } + this.code = code as number; + this.statusCode = statusCode as number; + this.cause = cause; + this.detail = detail; } - this.code = code; - this.statusCode = statusCode; - this.cause = cause; - this.detail = detail; } toString(): string { @@ -62,16 +96,11 @@ export default class ErrorInfo extends Error implements IPartialErrorInfo, API.E } static fromValues(values: IConvertibleToErrorInfo): ErrorInfo { - const { message, code, statusCode, detail } = values; - if ( - typeof message !== 'string' || - typeof code !== 'number' || - typeof statusCode !== 'number' || - (!Utils.isNil(detail) && (typeof detail !== 'object' || Array.isArray(detail))) - ) { - throw new Error('ErrorInfo.fromValues(): invalid values: ' + Platform.Config.inspect(values)); - } - const result = Object.assign(new ErrorInfo(message, code, statusCode, undefined, detail), values); + // Delegate shape validation and field assignment to the options-object constructor; + // fromValues only adds the help.ably.io href default for server-decoded errors that + // arrive without one. SDK-thrown errors that use `new ErrorInfo({...})` directly do + // not get this default, by design. + const result = new ErrorInfo(values); if (result.code && !result.href) { result.href = 'https://help.ably.io/error/' + result.code; } @@ -93,15 +122,43 @@ export class PartialErrorInfo extends Error implements IPartialErrorInfo { statusCode?: number, cause?: ErrorInfo | PartialErrorInfo, detail?: Record, + ); + constructor(values: IConvertibleToPartialErrorInfo); + constructor( + messageOrValues: string | IConvertibleToPartialErrorInfo, + code?: number | null, + statusCode?: number, + cause?: ErrorInfo | PartialErrorInfo, + detail?: Record, ) { - super(message); - if (typeof Object.setPrototypeOf !== 'undefined') { - Object.setPrototypeOf(this, PartialErrorInfo.prototype); + if (typeof messageOrValues === 'object') { + const values = messageOrValues; + if ( + typeof values.message !== 'string' || + (!Utils.isNil(values.code) && typeof values.code !== 'number') || + (!Utils.isNil(values.statusCode) && typeof values.statusCode !== 'number') || + (!Utils.isNil(values.detail) && (typeof values.detail !== 'object' || Array.isArray(values.detail))) + ) { + throw new Error('PartialErrorInfo: invalid values: ' + Platform.Config.inspect(values)); + } + super(values.message); + if (typeof Object.setPrototypeOf !== 'undefined') { + Object.setPrototypeOf(this, PartialErrorInfo.prototype); + } + this.code = values.code; + this.statusCode = values.statusCode; + this.detail = values.detail; + Object.assign(this, values); + } else { + super(messageOrValues); + if (typeof Object.setPrototypeOf !== 'undefined') { + Object.setPrototypeOf(this, PartialErrorInfo.prototype); + } + this.code = code as number | null; + this.statusCode = statusCode; + this.cause = cause; + this.detail = detail; } - this.code = code; - this.statusCode = statusCode; - this.cause = cause; - this.detail = detail; } toString(): string { @@ -109,16 +166,9 @@ export class PartialErrorInfo extends Error implements IPartialErrorInfo { } static fromValues(values: IConvertibleToPartialErrorInfo): PartialErrorInfo { - const { message, code, statusCode, detail } = values; - if ( - typeof message !== 'string' || - (!Utils.isNil(code) && typeof code !== 'number') || - (!Utils.isNil(statusCode) && typeof statusCode !== 'number') || - (!Utils.isNil(detail) && (typeof detail !== 'object' || Array.isArray(detail))) - ) { - throw new Error('PartialErrorInfo.fromValues(): invalid values: ' + Platform.Config.inspect(values)); - } - const result = Object.assign(new PartialErrorInfo(message, code, statusCode, undefined, detail), values); + // Same shape as ErrorInfo.fromValues - delegate validation/assignment to the + // options-object constructor; href default applies only to the server-decoded path. + const result = new PartialErrorInfo(values); if (result.code && !result.href) { result.href = 'https://help.ably.io/error/' + result.code; } diff --git a/src/common/lib/util/defaults.ts b/src/common/lib/util/defaults.ts index b9c58d56bf..2b9ea7033b 100644 --- a/src/common/lib/util/defaults.ts +++ b/src/common/lib/util/defaults.ts @@ -175,10 +175,20 @@ export function getHosts(options: NormalisedClientOptions): string[] { function checkHost(host: string): void { if (typeof host !== 'string') { - throw new ErrorInfo('host must be a string; was a ' + typeof host, 40000, 400); + throw new ErrorInfo({ + message: 'host must be a string: was of type ' + typeof host, + code: 40000, + statusCode: 400, + hint: 'Make every entry of `fallbackHosts` a string. If you set `restHost` or `realtimeHost`, pass each as a single string, not an array or object.', + }); } if (!host.length) { - throw new ErrorInfo('host must not be zero-length', 40000, 400); + throw new ErrorInfo({ + message: 'host must not be zero-length: an entry of `fallbackHosts` is an empty string', + code: 40000, + statusCode: 400, + hint: 'Remove any empty-string entry from the `fallbackHosts` array.', + }); } } @@ -251,21 +261,24 @@ function checkIfClientOptionsAreValid(options: ClientOptions) { // REC1b if (options.endpoint && (options.environment || options.restHost || options.realtimeHost)) { // RSC1b - throw new ErrorInfo( - 'The `endpoint` option cannot be used in conjunction with the `environment`, `restHost`, or `realtimeHost` options.', - 40106, - 400, - ); + throw new ErrorInfo({ + message: + 'The `endpoint` option cannot be used in conjunction with the `environment`, `restHost`, or `realtimeHost` options.', + code: 40106, + statusCode: 400, + hint: 'Remove `environment`, `restHost`, and `realtimeHost` from `ClientOptions` and use only `endpoint`, which replaces them.', + }); } // REC1c if (options.environment && (options.restHost || options.realtimeHost)) { // RSC1b - throw new ErrorInfo( - 'The `environment` option cannot be used in conjunction with the `restHost`, or `realtimeHost` options.', - 40106, - 400, - ); + throw new ErrorInfo({ + message: 'The `environment` option cannot be used in conjunction with the `restHost`, or `realtimeHost` options.', + code: 40106, + statusCode: 400, + hint: 'Remove `environment`, `restHost`, and `realtimeHost` from `ClientOptions` and use only `endpoint`, which replaces them.', + }); } } diff --git a/src/common/lib/util/utils.ts b/src/common/lib/util/utils.ts index c548ca614e..9758167930 100644 --- a/src/common/lib/util/utils.ts +++ b/src/common/lib/util/utils.ts @@ -288,11 +288,14 @@ export function detectV1Callback(args: ArrayLike, v2TrailingFnArity: nu 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; + throw new ErrorInfo({ + message: 'v1 callback signature is no longer supported: v2 methods return a promise.', + code: 40025, + statusCode: 400, + hint: + 'Drop the trailing callback and `await` the returned promise. ' + + 'See https://github.com/ably/ably-js/blob/main/docs/migration-guides/v2/lib.md.', + }); } export function inspectError(err: unknown): string { @@ -470,11 +473,23 @@ export function matchDerivedChannel(name: string) { const regex = /^(\[([^?]*)(?:(.*))\])?(.+)$/; // eslint-disable-line const match = name.match(regex); if (!match || !match.length || match.length < 5) { - throw new ErrorInfo('regex match failed', 400, 40010); + throw new ErrorInfo({ + message: 'Channel name is empty or could not be parsed', + code: 40010, + statusCode: 400, + hint: + 'Pass a non-empty channel name to channels.getDerived(name, { filter: ... }) and put the filter expression in the filter option, not in the name. ' + + 'A channel-params prefix such as "[?rewind=1]foo" is allowed. See https://ably.com/docs/channels#derived.', + }); } // Fail if there is already a channel qualifier, eg [meta]foo should fail instead of just overriding with [filter=xyz]foo if (match![2]) { - throw new ErrorInfo(`cannot use a derived option with a ${match[2]} channel`, 400, 40010); + throw new ErrorInfo({ + message: `cannot use a derived option with a ${match[2]} channel`, + code: 40010, + statusCode: 400, + hint: `Use a base channel name instead, without the "${match[2]}" qualifier.`, + }); } // Return match values to be added to derive channel quantifier. return { @@ -499,7 +514,26 @@ export function arrEquals(a: any[], b: any[]) { } export function createMissingPluginError(pluginName: keyof ModularPlugins): ErrorInfo { - return new ErrorInfo(`${pluginName} plugin not provided`, 40019, 400); + // Push and LiveObjects are not exported by the modular variant; each has its own entry point. + let hint: string; + switch (pluginName) { + case 'Push': + hint = 'Import Push from "ably/push" and pass it in ClientOptions.plugins: { Push }.'; + break; + case 'LiveObjects': + hint = 'Import { LiveObjects } from "ably/liveobjects" and pass it in ClientOptions.plugins: { LiveObjects }.'; + break; + default: + hint = `Import ${pluginName} from "ably/modular" and pass it in ClientOptions.plugins: { ${pluginName} }. See the modular variant reference at https://sdk.ably.com/builds/ably/ably-js/main/typedoc/modules/modular.html.`; + break; + } + const err = new ErrorInfo({ + message: `${pluginName} plugin not provided`, + code: 40019, + statusCode: 400, + hint, + }); + return err; } export function throwMissingPluginError(pluginName: keyof ModularPlugins): never { @@ -554,7 +588,12 @@ export async function* listenerToAsyncIterator( yield eventQueue.shift()!; } else { if (resolveNext) { - throw new ErrorInfo('Concurrent next() calls are not supported', 40000, 400); + throw new ErrorInfo({ + message: 'Concurrent next() calls are not supported', + code: 40000, + statusCode: 400, + hint: 'Drive the async iterator from a single for-await-of loop.', + }); } // Otherwise wait for the next event to arrive diff --git a/src/plugins/liveobjects/realtimeobject.ts b/src/plugins/liveobjects/realtimeobject.ts index fc5b082051..abcbfbdb76 100644 --- a/src/plugins/liveobjects/realtimeobject.ts +++ b/src/plugins/liveobjects/realtimeobject.ts @@ -256,7 +256,7 @@ export class RealtimeObject { const size = encodedMsgs.reduce((acc, msg) => acc + msg.getMessageSize(), 0); if (size > maxMessageSize) { throw new this._client.ErrorInfo( - `Maximum size of object messages that can be published at once exceeded (was ${size} bytes; limit is ${maxMessageSize} bytes)`, + `Maximum size of object messages that can be published at once exceeded (was ${size} bytes, against a limit of ${maxMessageSize} bytes)`, 40009, 400, ); @@ -568,11 +568,21 @@ export class RealtimeObject { private _throwIfMissingChannelMode(expectedMode: 'object_subscribe' | 'object_publish'): void { // RTO2a - channel.modes is only populated on channel attachment, so use it only if it is set if (this._channel.modes != null && !this._channel.modes.includes(expectedMode)) { - throw new this._client.ErrorInfo(`"${expectedMode}" channel mode must be set for this operation`, 40024, 400); // RTO2a2 + throw new this._client.ErrorInfo({ + message: `"${expectedMode}" channel mode must be set for this operation`, + code: 40024, + statusCode: 400, + hint: `Include "${expectedMode}" in the channel modes: realtime.channels.get(name, { modes: ["${expectedMode}", ...] }), or call channel.setOptions({ modes: [...] }) on an existing channel to trigger a reattach. Calling channels.get(name, { modes }) on an existing channel throws. If the mode is still missing after the reattach, your API key lacks the capability corresponding to the mode ("object-subscribe" or "object-publish") on this channel and the server silently dropped it. If you have the Ably CLI installed, \`ably auth keys list\` shows your key's capabilities.`, + }); } // RTO2b - otherwise as a best effort use user provided channel options if (!this._client.Utils.allToLowerCase(this._channel.channelOptions.modes ?? []).includes(expectedMode)) { - throw new this._client.ErrorInfo(`"${expectedMode}" channel mode must be set for this operation`, 40024, 400); // RTO2b2 + throw new this._client.ErrorInfo({ + message: `"${expectedMode}" channel mode must be set for this operation`, + code: 40024, + statusCode: 400, + hint: `Include "${expectedMode}" in the channel modes: realtime.channels.get(name, { modes: ["${expectedMode}", ...] }), or call channel.setOptions({ modes: [...] }) on an existing channel to trigger a reattach. Calling channels.get(name, { modes }) on an existing channel throws. If the mode is still missing after the reattach, your API key lacks the capability corresponding to the mode ("object-subscribe" or "object-publish") on this channel and the server silently dropped it. If you have the Ably CLI installed, \`ably auth keys list\` shows your key's capabilities.`, + }); } } diff --git a/src/plugins/push/getW3CDeviceDetails.ts b/src/plugins/push/getW3CDeviceDetails.ts index 0cee91ef9c..1591183380 100644 --- a/src/plugins/push/getW3CDeviceDetails.ts +++ b/src/plugins/push/getW3CDeviceDetails.ts @@ -28,17 +28,34 @@ export async function getW3CPushDeviceDetails(machine: ActivationStateMachine) { const permission = await Notification.requestPermission(); if (permission !== 'granted') { - machine.handleEvent( - new GettingPushDeviceDetailsFailed(new ErrorInfo('User denied permission to send notifications', 400, 40000)), - ); + const err = + permission === 'denied' + ? new ErrorInfo({ + message: 'User denied permission to send notifications: browser notification permission is "denied"', + code: 40000, + statusCode: 400, + hint: 'Tell the user to re-enable notifications for this site in their browser settings, then call push.activate() again to retry registration. A re-request will not prompt while the permission stays "denied".', + }) + : new ErrorInfo({ + message: + 'Notification permission prompt was dismissed without a choice: browser notification permission is "default"', + code: 40000, + statusCode: 400, + hint: 'Surface UI explaining the value of notifications, then call push.activate() again to retry registration. The browser will show the permission prompt again.', + }); + machine.handleEvent(new GettingPushDeviceDetailsFailed(err)); return; } const swUrl = machine.client.options.pushServiceWorkerUrl; if (!swUrl) { - machine.handleEvent( - new GettingPushDeviceDetailsFailed(new ErrorInfo('Missing ClientOptions.pushServiceWorkerUrl', 400, 40000)), - ); + const err = new ErrorInfo({ + message: 'Missing ClientOptions.pushServiceWorkerUrl', + code: 40000, + statusCode: 400, + hint: 'Set ClientOptions.pushServiceWorkerUrl to the path of your service worker (e.g. "/ably-push-sw.js") so the SDK can register it for web push.', + }); + machine.handleEvent(new GettingPushDeviceDetailsFailed(err)); return; } diff --git a/src/plugins/push/pushactivation.ts b/src/plugins/push/pushactivation.ts index 53de078d34..860939ffb1 100644 --- a/src/plugins/push/pushactivation.ts +++ b/src/plugins/push/pushactivation.ts @@ -9,6 +9,11 @@ import { getW3CPushDeviceDetails } from './getW3CDeviceDetails'; import type BaseClient from 'common/lib/client/baseclient'; import type { PaginatedResult } from 'common/lib/client/paginatedresource'; +// Keep this byte-identical to the copy in src/common/lib/client/push.ts. This plugin only +// type-imports from common client modules, so a value import from there is not viable for the build. +const PUSH_ACTIVATION_NOT_AVAILABLE_HINT = + 'Run push.activate() in a browser environment with service worker support. From a server, use client.push.admin instead. Call client.push.admin.publish(recipient, payload) to send to a device or clientId. Call client.push.admin.deviceRegistrations.save(device) to register a device record.'; + const persistKeys = { deviceId: 'ably.push.deviceId', deviceSecret: 'ably.push.deviceSecret', @@ -62,11 +67,22 @@ export function localDeviceFactory(deviceDetails: typeof DeviceDetails) { async listSubscriptions(): Promise> { const Platform = this.rest.Platform; if (!Platform.Config.push) { - throw new this.rest.ErrorInfo('Push activation is not available on this platform', 40000, 400); + throw new this.rest.ErrorInfo({ + message: + 'Push activation is not available on this platform: it requires a browser environment with service worker support', + code: 40000, + statusCode: 400, + hint: PUSH_ACTIVATION_NOT_AVAILABLE_HINT, + }); } if (!this.id) { - throw new this.rest.ErrorInfo('Device not activated', 40000, 400); + throw new this.rest.ErrorInfo({ + message: 'Device not activated', + code: 40000, + statusCode: 400, + hint: 'Call client.push.activate() and await its completion before listing subscriptions.', + }); } if (!this.deviceIdentityToken) { @@ -96,7 +112,13 @@ export function localDeviceFactory(deviceDetails: typeof DeviceDetails) { loadPersisted() { const Platform = this.rest.Platform; if (!Platform.Config.push) { - throw new this.rest.ErrorInfo('Push activation is not available on this platform', 40000, 400); + throw new this.rest.ErrorInfo({ + message: + 'Push activation is not available on this platform: it requires a browser environment with service worker support', + code: 40000, + statusCode: 400, + hint: PUSH_ACTIVATION_NOT_AVAILABLE_HINT, + }); } this.platform = Platform.Config.push.platform; this.clientId = this.rest.auth.clientId ?? undefined; @@ -117,7 +139,13 @@ export function localDeviceFactory(deviceDetails: typeof DeviceDetails) { persist() { const config = this.rest.Platform.Config; if (!config.push) { - throw new this.rest.ErrorInfo('Push activation is not available on this platform', 40000, 400); + throw new this.rest.ErrorInfo({ + message: + 'Push activation is not available on this platform: it requires a browser environment with service worker support', + code: 40000, + statusCode: 400, + hint: PUSH_ACTIVATION_NOT_AVAILABLE_HINT, + }); } if (this.id) { config.push.storage.set(persistKeys.deviceId, this.id); @@ -193,7 +221,13 @@ export class ActivationStateMachine { get pushConfig() { if (!this._pushConfig) { - throw new this.client.ErrorInfo('This platform is not supported as a target of push notifications', 40000, 400); + throw new this.client.ErrorInfo({ + message: + 'This platform is not supported as a target of push notifications: push activation requires a browser environment with service worker support', + code: 40000, + statusCode: 400, + hint: PUSH_ACTIVATION_NOT_AVAILABLE_HINT, + }); } return this._pushConfig; } @@ -229,11 +263,14 @@ export class ActivationStateMachine { } if (!deviceRegistration) { - this.handleEvent( - new GettingDeviceRegistrationFailed( - new this.client.ErrorInfo('registerCallback did not return deviceRegistration', 40000, 400), - ), - ); + const err = new this.client.ErrorInfo({ + message: 'registerCallback did not return deviceRegistration', + code: 40000, + statusCode: 400, + hint: 'Your registerCallback must invoke its callback with (null, deviceRegistration).', + }); + this.handleEvent(new GettingDeviceRegistrationFailed(err)); + return; } if (isNew) { diff --git a/src/plugins/push/pushchannel.ts b/src/plugins/push/pushchannel.ts index 2208c5e569..cd3d1336c6 100644 --- a/src/plugins/push/pushchannel.ts +++ b/src/plugins/push/pushchannel.ts @@ -50,7 +50,12 @@ class PushChannel { const client = this.client; const clientId = this.client.auth.clientId; if (!clientId) { - throw new this.client.ErrorInfo('Cannot subscribe from client without client ID', 50000, 500); + throw new this.client.ErrorInfo({ + message: 'Cannot subscribe from client without client ID', + code: 50000, + statusCode: 500, + hint: 'Set ClientOptions.clientId before calling pushChannel.subscribeClient(). On a realtime client, a clientId carried in the token also satisfies this once the connection has connected. On a REST client, only ClientOptions.clientId works.', + }); } const format = client.options.useBinaryProtocol ? client.Utils.Format.msgpack : client.Utils.Format.json, body = { clientId: clientId, channel: this.channel.name }, @@ -67,7 +72,12 @@ class PushChannel { const clientId = this.client.auth.clientId; if (!clientId) { - throw new this.client.ErrorInfo('Cannot unsubscribe from client without client ID', 50000, 500); + throw new this.client.ErrorInfo({ + message: 'Cannot unsubscribe from client without client ID', + code: 50000, + statusCode: 500, + hint: 'Set ClientOptions.clientId before calling pushChannel.unsubscribeClient(). On a realtime client, a clientId carried in the token also satisfies this once the connection has connected. On a REST client, only ClientOptions.clientId works.', + }); } const format = client.options.useBinaryProtocol ? client.Utils.Format.msgpack : client.Utils.Format.json, headers = client.Defaults.defaultPostHeaders(client.options); @@ -105,7 +115,12 @@ class PushChannel { if (deviceIdentityToken) { return deviceIdentityToken; } else { - throw new this.client.ErrorInfo('Cannot subscribe from client without deviceIdentityToken', 50000, 500); + throw new this.client.ErrorInfo({ + message: 'Cannot subscribe or unsubscribe this device without a deviceIdentityToken', + code: 50000, + statusCode: 500, + hint: 'Activate this device first by awaiting client.push.activate().', + }); } } diff --git a/test/realtime/auth.test.js b/test/realtime/auth.test.js index 58a8ee1e46..3eddaa310e 100644 --- a/test/realtime/auth.test.js +++ b/test/realtime/auth.test.js @@ -1670,7 +1670,7 @@ define(['ably', 'shared_helper', 'async', 'chai'], function (Ably, Helper, async 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('Drop the trailing callback'); 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) { diff --git a/test/realtime/channel.test.js b/test/realtime/channel.test.js index 68357d9ef6..f4fb9e7579 100644 --- a/test/realtime/channel.test.js +++ b/test/realtime/channel.test.js @@ -1496,7 +1496,7 @@ define(['ably', 'shared_helper', 'async', 'chai'], function (Ably, Helper, async 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('Drop the trailing callback'); 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) { diff --git a/test/realtime/connection.test.js b/test/realtime/connection.test.js index 49c2d51d8a..d930a4e9f2 100644 --- a/test/realtime/connection.test.js +++ b/test/realtime/connection.test.js @@ -492,7 +492,7 @@ define(['ably', 'shared_helper', 'async', 'chai'], function (Ably, Helper, async 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('Drop the trailing callback'); 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) { diff --git a/test/realtime/presence.test.js b/test/realtime/presence.test.js index 648600a033..71247b624c 100644 --- a/test/realtime/presence.test.js +++ b/test/realtime/presence.test.js @@ -2490,7 +2490,7 @@ define(['ably', 'shared_helper', 'async', 'chai'], function (Ably, Helper, async 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('Drop the trailing callback'); 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) {