From 86dd31926bb4cb6dfbb15ae065623976a70c9bfa Mon Sep 17 00:00:00 2001 From: umair Date: Tue, 26 May 2026 11:54:16 +0100 Subject: [PATCH 01/20] DX-1209: inline fix-it hints on SDK ErrorInfo throw sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopts the message-vs-hint split DX-1204 introduced (`err.message` says *what* failed; `err.hint` says *how* to fix it) and applies it at every ErrorInfo throw in the SDK where a fix-it hint adds value. Server-relayed codes (40140, 40142, 40160, 40300, 42910, …) are deliberately out of scope here — that propagation is being tracked separately. Covered (~80 sites across 19 files): - realtimechannel.ts — channel options validation, publish shape (40013), max size (40009), detach-while-failed, invalidStateError, release-state, attach/detach timeout, untilAttach, sendUpdate missing serial, sync not-attached. - realtimepresence.ts — clientId missing for enter/update/leave, leave-in-incompatible-state, get() on suspended, untilAttach, auto- re-enter failure. - auth.ts — no auth options, incompatible key (×2), authUrl/authCallback errors, no-renewable-token, no/invalid key, empty/wildcard/non-string clientId, token clientId mismatch, token-shape rejection. - baseclient.ts — invalid key format, clientId type/wildcard. - defaults.ts — endpoint/environment/host conflicts, host validation. - rest.ts — unsupported HTTP method, revoke-tokens-under-token-auth. - baserealtime.ts — channels.get with mode change after first call. - paginatedresource.ts — no link to first/current page. - utils.ts — derived-channel regex, missing-plugin, concurrent iterator. - connectionmanager.ts — ping when not connected, authUrl/authCallback 403/error. - basemessage.ts — unsupported data type, vcdiff plugin missing/decode/ unsupported. - push.ts / pushactivation.ts / pushchannel.ts / getW3CDeviceDetails.ts — push platform/state, registration callback, deviceId/device-identity preconditions. - restchannel.ts / restchannelmixin.ts / restannotations.ts / realtimeannotations.ts — message-serial requirements, annotation shape/mode. Skipped per `LLMEvalUpdatedPlan.md` A1 scope: - LiveObjects 92xxx (messages already specific). - Transport 80xxx lifecycle codes (auto-recovered; a hint would encourage retry code that fights the SDK). - 50000 internal errors (only actionable advice is file-an-issue, not worth a per-site hint). Tests pin the hint strings so silent rephrasing flags as drift, modelled on the DX-1204 v1-callback assertions. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/common/lib/client/auth.ts | 138 ++++++++++++------ src/common/lib/client/baseclient.ts | 19 ++- src/common/lib/client/baserealtime.ts | 5 +- src/common/lib/client/paginatedresource.ts | 8 +- src/common/lib/client/push.ts | 34 ++++- src/common/lib/client/realtimeannotations.ts | 5 +- src/common/lib/client/realtimechannel.ts | 63 ++++++-- src/common/lib/client/realtimepresence.ts | 29 +++- src/common/lib/client/rest.ts | 9 +- src/common/lib/client/restannotations.ts | 10 +- src/common/lib/client/restchannel.ts | 9 +- src/common/lib/client/restchannelmixin.ts | 15 +- src/common/lib/transport/connectionmanager.ts | 15 +- src/common/lib/types/basemessage.ts | 22 ++- src/common/lib/util/defaults.ts | 18 ++- src/common/lib/util/utils.ts | 18 ++- src/plugins/push/getW3CDeviceDetails.ts | 14 +- src/plugins/push/pushactivation.ts | 40 +++-- src/plugins/push/pushchannel.ts | 15 +- test/unit/error-hints.test.js | 77 ++++++++++ 20 files changed, 443 insertions(+), 120 deletions(-) create mode 100644 test/unit/error-hints.test.js diff --git a/src/common/lib/client/auth.ts b/src/common/lib/client/auth.ts index 3889eb54a4..90c7291c60 100644 --- a/src/common/lib/client/auth.ts +++ b/src/common/lib/client/auth.ts @@ -158,7 +158,10 @@ class Auth { const msg = 'No authentication options provided; need one of: key, authUrl, or authCallback (or for testing only, token or tokenDetails)'; Logger.logAction(this.logger, Logger.LOG_ERROR, 'Auth()', msg); - throw new ErrorInfo(msg, 40160, 401); + const err = new ErrorInfo(msg, 40160, 401); + err.hint = + 'Pass one of ClientOptions.{ key, authUrl, authCallback, token, tokenDetails }. For production, prefer authUrl or authCallback so the API key stays on your server.'; + throw err; } Logger.logAction(this.logger, Logger.LOG_MINOR, 'Auth()', 'anonymous, using basic auth'); this._saveBasicOptions(options); @@ -269,7 +272,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); + { + const err = new ErrorInfo('Unable to update auth options with incompatible key', 40102, 401); + err.hint = + 'auth.authorize() cannot change the API key — the new authOptions.key differs from the one the client was constructed with. To use a different key, construct a new Ably client.'; + throw err; + } } try { @@ -486,27 +494,33 @@ 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('authUrl response is missing a content-type header', 40170, 401); + err.hint = + 'Have your auth endpoint return a Content-Type of application/json (TokenDetails/TokenRequest), text/plain (token string) or application/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( + 'authUrl responded with unacceptable content-type ' + + contentType + + ', should be either text/plain, application/jwt or application/json', + 40170, + 401, ); + err.hint = + 'Update your auth endpoint to return Content-Type application/json, text/plain or application/jwt — the SDK cannot parse other content types.'; + 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('authUrl response exceeded max permitted length', 40170, 401); + err.hint = + 'authUrl payloads must be under 128 KB. Your endpoint is likely returning more than just a TokenDetails/TokenRequest object — trim it down.'; + cb(err, null); return; } try { @@ -585,7 +599,10 @@ 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); + const err = new ErrorInfo(msg, 40171, 403); + err.hint = + 'Initialise the client with one of ClientOptions.{ key, authUrl, authCallback } so the SDK can refresh tokens. A bare token/tokenDetails alone cannot be renewed once expired.'; + throw err; } /* normalise token params */ @@ -628,7 +645,10 @@ class Auth { tokenRequestCallbackTimeoutExpired = true; const msg = 'Token request callback timed out after ' + timeoutLength / 1000 + ' seconds'; Logger.logAction(this.logger, Logger.LOG_ERROR, 'Auth.requestToken()', msg); - reject(new ErrorInfo(msg, 40170, 401)); + const err = new ErrorInfo(msg, 40170, 401); + err.hint = + 'Your authCallback/authUrl did not respond in time. Make sure the callback invokes its callback parameter (or resolves its promise) on every code path.'; + reject(err); }, timeoutLength); tokenRequestCallback!(resolvedTokenParams, (err, tokenRequestOrDetails, contentType) => { @@ -648,29 +668,37 @@ 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('Token string is empty', 40170, 401); + err.hint = + 'Your authCallback returned an empty string. Return a non-empty token string, or a TokenDetails/TokenRequest 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( + 'Token string exceeded max permitted length (was ' + tokenRequestOrDetails.length + ' bytes)', + 40170, + 401, ); + err.hint = + 'Tokens must be under 128 KB. Your endpoint is returning more than the token itself — return only the token string or a TokenDetails object.'; + 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('Token string was literal null/undefined', 40170, 401); + err.hint = + 'Your authCallback stringified a null/undefined token. 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( + "Token was double-encoded; make sure you're not JSON-encoding an already encoded token request or details", + 40170, + 401, ); + err.hint = + 'Return TokenDetails/TokenRequest as an object (not a JSON-encoded string), or set the response Content-Type to application/jwt for JWT tokens.'; + reject(err); } else { resolve({ token: tokenRequestOrDetails } as API.TokenDetails); } @@ -681,18 +709,22 @@ 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(msg, 40170, 401); + err.hint = + 'authCallback must invoke its callback with (err, tokenStringOrTokenDetailsOrTokenRequest). authUrl must 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( - 'Token request/details object exceeded max permitted stringified size (was ' + objectSize + ' bytes)', - 40170, - 401, - ), + const err = new ErrorInfo( + 'Token request/details object exceeded max permitted stringified size (was ' + objectSize + ' bytes)', + 40170, + 401, ); + err.hint = + 'Token objects must serialise to under 128 KB. Trim unused fields from your TokenDetails/TokenRequest, or set authOptions.suppressMaxLengthCheck if you understand the risk.'; + reject(err); return; } if ('issued' in tokenRequestOrDetails) { @@ -704,7 +736,10 @@ class Auth { const msg = 'Expected token request callback to call back with a token string, token request object, or token details object'; Logger.logAction(this.logger, Logger.LOG_ERROR, 'Auth.requestToken()', msg); - reject(new ErrorInfo(msg, 40170, 401)); + const err = new ErrorInfo(msg, 40170, 401); + err.hint = + 'Your authCallback/authUrl returned an object without a `keyName` (so it was treated as a TokenDetails) and that shape was also rejected. Return either a token string, a TokenRequest (with keyName), or a TokenDetails (with token).'; + reject(err); return; } /* it's a token request, so make the request */ @@ -775,18 +810,25 @@ class Auth { const key = authOptions.key; if (!key) { - throw new ErrorInfo('No key specified', 40101, 403); + const err = new ErrorInfo('No key specified', 40101, 403); + err.hint = + 'createTokenRequest needs an API key. Pass ClientOptions.key on the client or { key } in the authOptions argument. Token-auth clients cannot mint token requests themselves.'; + throw err; } const keyParts = key.split(':'), keyName = keyParts[0], keySecret = keyParts[1]; if (!keySecret) { - throw new ErrorInfo('Invalid key specified', 40101, 403); + const err = new ErrorInfo('Invalid key specified', 40101, 403); + err.hint = 'API keys are "appId.keyId:secret". Copy the full key including the colon from the Ably dashboard.'; + throw err; } if (tokenParams.clientId === '') { - throw new ErrorInfo('clientId can’t be an empty string', 40012, 400); + const err = new ErrorInfo('clientId can’t be an empty string', 40012, 400); + err.hint = 'Pass a non-empty clientId, or omit the field entirely to mint an anonymous token.'; + throw err; } if ('capability' in tokenParams) { @@ -906,11 +948,14 @@ class Auth { if (token) { if (this._tokenClientIdMismatch(token.clientId)) { /* 403 to trigger a permanently failed client - RSA15c */ - throw new ErrorInfo( + const err = new ErrorInfo( 'Mismatch between clientId in token (' + token.clientId + ') and current clientId (' + this.clientId + ')', 40102, 403, ); + err.hint = + 'Mint the token with the same clientId as ClientOptions.clientId, or omit ClientOptions.clientId and let the token define it. The two cannot diverge.'; + throw err; } /* 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 +1017,18 @@ 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); + const err = new ErrorInfo('clientId must be either a string or null', 40012, 400); + err.hint = 'Pass a string (e.g. a user id) or null for an anonymous client. Numbers and objects are not accepted.'; + throw err; } else if (clientId === '*') { - throw new ErrorInfo( + const err = 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, ); + err.hint = + 'Move "*" out of ClientOptions.clientId. For a wildcard token, set defaultTokenParams: { clientId: "*" } on the client, or pass it to authorize() as a tokenParam.'; + throw err; } else { const err = this._uncheckedSetClientId(clientId); if (err) throw err; @@ -992,6 +1042,8 @@ class Auth { * recognise mismatch and return an error */ const msg = 'Unexpected clientId mismatch: client has ' + this.clientId + ', requested ' + clientId; const err = new ErrorInfo(msg, 40102, 401); + err.hint = + 'A clientId from the token does not match ClientOptions.clientId. Mint 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..6207b46d80 100644 --- a/src/common/lib/client/baseclient.ts +++ b/src/common/lib/client/baseclient.ts @@ -76,21 +76,30 @@ class BaseClient { if (!keyMatch) { const msg = 'invalid key parameter'; Logger.logAction(this.logger, Logger.LOG_ERROR, 'BaseClient()', msg); - throw new ErrorInfo(msg, 40400, 404); + const err = new ErrorInfo(msg, 40400, 404); + err.hint = + 'ClientOptions.key must be the full "appId.keyId:secret" string copied from the Ably dashboard. If you only have a token, use ClientOptions.token / tokenDetails instead.'; + throw err; } 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( + if (!(typeof normalOptions.clientId === 'string' || normalOptions.clientId === null)) { + const err = new ErrorInfo('clientId must be either a string or null', 40012, 400); + err.hint = 'Pass a string (e.g. a user id) or null for an anonymous client. Numbers and objects are not accepted.'; + throw err; + } else if (normalOptions.clientId === '*') { + const err = 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, ); + err.hint = + 'Move "*" out of ClientOptions.clientId. For a wildcard token, set defaultTokenParams: { clientId: "*" } on the client instead.'; + throw err; + } } 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..fd6a262331 100644 --- a/src/common/lib/client/baserealtime.ts +++ b/src/common/lib/client/baserealtime.ts @@ -199,11 +199,14 @@ 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( + const err = 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, ); + err.hint = + 'channels.get(name) returns the existing channel — call channel.setOptions(opts) to change params/modes (which may trigger a re-attach). Only the first channels.get() for a name applies options without a re-attach.'; + throw err; } channel.setOptions(channelOptions); } diff --git a/src/common/lib/client/paginatedresource.ts b/src/common/lib/client/paginatedresource.ts index e0ad472475..bb4439c23b 100644 --- a/src/common/lib/client/paginatedresource.ts +++ b/src/common/lib/client/paginatedresource.ts @@ -147,7 +147,9 @@ export class PaginatedResult { return this.get(this._relParams!.first); } - throw new ErrorInfo('No link to the first page of results', 40400, 404); + const err = new ErrorInfo('No link to the first page of results', 40400, 404); + err.hint = 'Check hasFirst() before calling first(). Empty result sets and single-page responses have no first-page link.'; + throw err; } async current(): Promise> { @@ -155,7 +157,9 @@ export class PaginatedResult { return this.get(this._relParams!.current); } - throw new ErrorInfo('No link to the current page of results', 40400, 404); + const err = new ErrorInfo('No link to the current page of results', 40400, 404); + err.hint = 'Check hasCurrent() before calling current(). The current-page link is only set after at least one page has been fetched.'; + throw err; } async next(): Promise | null> { diff --git a/src/common/lib/client/push.ts b/src/common/lib/client/push.ts index 4d57324c39..a4f9a6a977 100644 --- a/src/common/lib/client/push.ts +++ b/src/common/lib/client/push.ts @@ -37,11 +37,19 @@ 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('This platform is not supported as a target of push notifications', 40000, 400); + err.hint = + 'Push activation is only supported on browsers with the Push API (Chrome/Firefox/Edge/Safari) and on iOS/Android via the native SDKs. Use Push admin (publish to a device/clientId) from server contexts.'; + reject(err); + } return; } if (this.stateMachine.activatedCallback) { - reject(new ErrorInfo('Activation already in progress', 40000, 400)); + const err = new ErrorInfo('Activation already in progress', 40000, 400); + err.hint = + 'Await the in-flight push.activate() before calling it again. Concurrent activations are not supported.'; + reject(err); return; } this.stateMachine.activatedCallback = (err: ErrorInfo) => { @@ -65,11 +73,19 @@ 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('This platform is not supported as a target of push notifications', 40000, 400); + err.hint = + 'Push activation is only supported on browsers with the Push API (Chrome/Firefox/Edge/Safari) and on iOS/Android via the native SDKs. Use Push admin (publish to a device/clientId) from server contexts.'; + reject(err); + } return; } if (this.stateMachine.deactivatedCallback) { - reject(new ErrorInfo('Deactivation already in progress', 40000, 400)); + const err = new ErrorInfo('Deactivation already in progress', 40000, 400); + err.hint = + 'Await the in-flight push.deactivate() before calling it again. Concurrent deactivations are not supported.'; + reject(err); return; } this.stateMachine.deactivatedCallback = (err: ErrorInfo) => { @@ -156,11 +172,14 @@ class DeviceRegistrations { deviceId = deviceIdOrDetails.id || deviceIdOrDetails; if (typeof deviceId !== 'string' || !deviceId.length) { - throw new ErrorInfo( + const err = new ErrorInfo( 'First argument to DeviceRegistrations#get must be a deviceId string or DeviceDetails', 40000, 400, ); + err.hint = + 'Pass either the device id string returned from push.activate(), or the DeviceDetails object (with a non-empty .id field).'; + throw err; } Utils.mixin(headers, client.options.headers); @@ -209,11 +228,14 @@ class DeviceRegistrations { deviceId = deviceIdOrDetails.id || deviceIdOrDetails; if (typeof deviceId !== 'string' || !deviceId.length) { - throw new ErrorInfo( + const err = new ErrorInfo( 'First argument to DeviceRegistrations#remove must be a deviceId string or DeviceDetails', 40000, 400, ); + err.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.'; + throw err; } Utils.mixin(headers, client.options.headers); diff --git a/src/common/lib/client/realtimeannotations.ts b/src/common/lib/client/realtimeannotations.ts index 6d698c7f3a..4d95904dd6 100644 --- a/src/common/lib/client/realtimeannotations.ts +++ b/src/common/lib/client/realtimeannotations.ts @@ -71,11 +71,14 @@ class RealtimeAnnotations { // explicit check for attach state in caes attachOnSubscribe=false if ((this.channel.state === 'attached' && this.channel._mode & flags.ANNOTATION_SUBSCRIBE) === 0) { - throw new ErrorInfo( + const err = 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, ); + err.hint = + 'Re-create the channel with annotation_subscribe in modes: realtime.channels.get(name, { modes: ["subscribe", "annotation_subscribe", ...] }). Also confirm your token capability grants annotation-subscribe on this channel.'; + throw err; } } diff --git a/src/common/lib/client/realtimechannel.ts b/src/common/lib/client/realtimechannel.ts index fcc825a36c..4c34bf1398 100644 --- a/src/common/lib/client/realtimechannel.ts +++ b/src/common/lib/client/realtimechannel.ts @@ -33,11 +33,15 @@ 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('options.params must be an object', 40000, 400); + err.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('options.modes must be an array', 40000, 400); + err.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 +50,9 @@ 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('Invalid channel mode: ' + currentMode, 40000, 400); + err.hint = `Valid ChannelMode values are: ${channelModes.join(', ').toLowerCase()}.`; + return err; } } } @@ -175,12 +181,15 @@ class RealtimeChannel extends EventEmitter { } invalidStateError(): ErrorInfo { - return new ErrorInfo( + const err = new ErrorInfo( 'Channel operation failed as channel state is ' + this.state, 90001, 400, this.errorReason || undefined, ); + err.hint = + 'Inspect channel.errorReason for the underlying cause, then call channel.attach() to recover. From "failed" or "suspended", a fresh attach() is required before further channel operations.'; + return err; } static processListenerArgs(args: unknown[]): any[] { @@ -273,11 +282,13 @@ class RealtimeChannel extends EventEmitter { messages = Message.fromValuesArray(first); params = args[1]; } else { - throw new ErrorInfo( + const err = new ErrorInfo( 'The single-argument form of publish() expects a message object or an array of message objects', 40013, 400, ); + err.hint = 'Call publish(name, data) for a single event, or publish(message | message[]) with a Message-shaped object.'; + throw err; } const maxMessageSize = this.client.options.maxMessageSize; // TODO get rid of CipherOptions type assertion, indicates channeloptions types are broken @@ -285,11 +296,14 @@ class RealtimeChannel extends EventEmitter { /* RSL1i */ const size = getMessagesSize(wireMessages); if (size > maxMessageSize) { - throw new ErrorInfo( + const err = new ErrorInfo( `Maximum size of messages that can be published at once exceeded (was ${size} bytes; limit is ${maxMessageSize} bytes)`, 40009, 400, ); + err.hint = + 'Split the publish into multiple calls so each batch is under the limit, or contact support to raise maxMessageSize for your app.'; + throw err; } this.throwIfUnpublishableState(); @@ -419,8 +433,12 @@ class RealtimeChannel extends EventEmitter { case 'detached': return; // RTL5b - case 'failed': - throw new ErrorInfo('Unable to detach; channel state = failed', 90001, 400); + case 'failed': { + const err = new ErrorInfo('Unable to detach; channel state = failed', 90001, 400); + err.hint = + 'A failed channel cannot be detached. Release it via channels.release(name) and call channels.get(name) again to start a fresh channel.'; + throw err; + } default: // RTL5l: if connection is not connected, immediately transition to detached if (connectionManager.state.state !== 'connected') { @@ -506,8 +524,12 @@ 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': { + const err = new PartialErrorInfo('Unable to sync to channel; not attached', 40000); + err.hint = + 'sync() can only run on an attached or attaching channel. Await channel.attach() (or channel.whenState("attached")) before calling sync().'; + throw err; + } default: } const connectionManager = this.connectionManager; @@ -952,11 +974,15 @@ class RealtimeChannel extends EventEmitter { switch (this.state) { case 'attaching': { const err = new ErrorInfo('Channel attach timed out', 90007, 408); + err.hint = + 'The server did not acknowledge the attach within realtimeRequestTimeout. The SDK will retry automatically once the connection is healthy; check connection.state and connection.errorReason.'; this.notifyState('suspended', err); break; } case 'detaching': { const err = new ErrorInfo('Channel detach timed out', 90007, 408); + err.hint = + 'The server did not acknowledge the detach within realtimeRequestTimeout. The channel has reverted to attached; retry detach() once the connection is stable.'; this.notifyState('attached', err); break; } @@ -1029,14 +1055,18 @@ 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); + const err = new ErrorInfo('option untilAttach requires the channel to be attached', 40000, 400); + err.hint = 'Await channel.attach() (or channel.whenState("attached")) before calling history({ untilAttach: true }).'; + throw err; } if (!this.properties.attachSerial) { - throw new ErrorInfo( + const err = new ErrorInfo( 'untilAttach was specified and channel is attached, but attachSerial is not defined', 40000, 400, ); + err.hint = 'Re-attach the channel and try again; the SDK could not record an attachSerial from the previous attach.'; + throw err; } delete params.untilAttach; params.from_serial = this.properties.attachSerial; @@ -1055,12 +1085,14 @@ class RealtimeChannel extends EventEmitter { if (s === 'initialized' || s === 'detached' || s === 'failed') { return null; } - return new ErrorInfo( + const err = 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 ' + s, 90001, 400, ); + err.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 +1154,14 @@ class RealtimeChannel extends EventEmitter { params?: Record, ): Promise { if (!message.serial) { - throw new ErrorInfo( + const err = 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, ); + err.hint = + 'Pass the Message you received from a subscribe callback (which carries .serial), not a freshly constructed object. Also confirm the namespace enables message annotations/updates/deletes in the Ably dashboard.'; + throw err; } this.throwIfUnpublishableState(); diff --git a/src/common/lib/client/realtimepresence.ts b/src/common/lib/client/realtimepresence.ts index 047fe598ce..fa3a46d24a 100644 --- a/src/common/lib/client/realtimepresence.ts +++ b/src/common/lib/client/realtimepresence.ts @@ -62,7 +62,10 @@ 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); + const err = new ErrorInfo('clientId must be specified to enter a presence channel', 40012, 400); + err.hint = + 'Set ClientOptions.clientId (or include clientId in the token) before calling presence.enter(). To enter on behalf of another identity, use presence.enterClient(clientId, data).'; + throw err; } return this._enterOrUpdateClient(undefined, undefined, data, 'enter'); } @@ -74,7 +77,10 @@ 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); + const err = new ErrorInfo('clientId must be specified to update presence data', 40012, 400); + err.hint = + 'Set ClientOptions.clientId (or include clientId in the token) before calling presence.update(). To update on behalf of another identity, use presence.updateClient(clientId, data).'; + throw err; } return this._enterOrUpdateClient(undefined, undefined, data, 'update'); } @@ -153,7 +159,10 @@ 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); + const err = new ErrorInfo('clientId must have been specified to enter or leave a presence channel', 40012, 400); + err.hint = + 'Anonymous clients cannot publish presence. Set ClientOptions.clientId (or include clientId in the token), or use presence.leaveClient(clientId) to leave on behalf of a specific identity.'; + throw err; } return this.leaveClient(undefined, data); } @@ -200,6 +209,8 @@ class RealtimePresence extends EventEmitter { 90001, ); err.code = 90001; + err.hint = + 'The channel is in "initialized" or "failed" state, so the client is not currently a presence member. From "initialized" there is no presence entry to leave; from "failed" inspect channel.errorReason and re-attach before retrying.'; throw err; } } @@ -220,11 +231,14 @@ class RealtimePresence extends EventEmitter { /* Special-case the suspended state: can still get (stale) presence set if waitForSync is false */ if (this.channel.state === 'suspended') { if (waitForSync) { - throw ErrorInfo.fromValues({ + const err = ErrorInfo.fromValues({ statusCode: 400, code: 91005, message: 'Presence state is out of sync due to channel being in the SUSPENDED state', }); + err.hint = + 'Wait for the channel to reach "attached" before calling presence.get(), or pass { waitForSync: false } to read the last known (potentially stale) members.'; + throw err; } return toMessages(this.members); } @@ -253,11 +267,14 @@ class RealtimePresence extends EventEmitter { delete params.untilAttach; params.from_serial = this.channel.properties.attachSerial; } else { - throw new ErrorInfo( + const err = new ErrorInfo( 'option untilAttach requires the channel to be attached, was: ' + this.channel.state, 40000, 400, ); + err.hint = + 'Await channel.attach() (or channel.whenState("attached")) before calling presence.history({ untilAttach: true }).'; + throw err; } } @@ -429,6 +446,8 @@ class RealtimePresence extends EventEmitter { 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); + wrappedErr.hint = + 'After a connection recovery the SDK could not re-enter this client into presence. Listen for the channel "update" event and call presence.enter(...) again once the channel is attached.'; 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..bdb0cd2fef 100644 --- a/src/common/lib/client/rest.ts +++ b/src/common/lib/client/rest.ts @@ -144,7 +144,9 @@ export class Rest { ); if (!Platform.Http.methods.includes(_method)) { - throw new ErrorInfo('Unsupported method ' + _method, 40500, 405); + const err = new ErrorInfo('Unsupported method ' + _method, 40500, 405); + err.hint = `Use one of: ${Platform.Http.methods.join(', ')}.`; + throw err; } if (Platform.Http.methodsWithBody.includes(_method)) { @@ -212,7 +214,10 @@ export class Rest { options?: TokenRevocationOptions, ): Promise { if (useTokenAuth(this.client.options)) { - throw new ErrorInfo('Cannot revoke tokens when using token auth', 40162, 401); + const err = new ErrorInfo('Cannot revoke tokens when using token auth', 40162, 401); + err.hint = + 'Token revocation requires basic auth. Construct a separate Ably.Rest client with ClientOptions.key (the API key with the revoke capability) just for this call.'; + throw err; } const keyName = this.client.options.keyName!; diff --git a/src/common/lib/client/restannotations.ts b/src/common/lib/client/restannotations.ts index 57c5f86069..9bb50a8622 100644 --- a/src/common/lib/client/restannotations.ts +++ b/src/common/lib/client/restannotations.ts @@ -24,11 +24,14 @@ export function serialFromMsgOrSerial(msgOrSerial: string | Message): string { break; } if (!messageSerial || typeof messageSerial !== 'string') { - throw new ErrorInfo( + const err = 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, ); + err.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.'; + throw err; } return messageSerial; } @@ -40,11 +43,14 @@ export function constructValidateAnnotation( const messageSerial = serialFromMsgOrSerial(msgOrSerial); if (!annotationValues || typeof annotationValues !== 'object') { - throw new ErrorInfo( + const err = new ErrorInfo( 'Second argument of annotations.publish() must be an object (the intended annotation to publish)', 40003, 400, ); + err.hint = + 'Pass an Annotation-shaped object as the second argument, e.g. { type: "reaction:unique.v1", name: "👍" }.'; + throw err; } const annotation = Annotation.fromValues(annotationValues); diff --git a/src/common/lib/client/restchannel.ts b/src/common/lib/client/restchannel.ts index 8e67062131..aea5221456 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( + const err = new ErrorInfo( 'The single-argument form of publish() expects a message object or an array of message objects', 40013, 400, ); + err.hint = 'Call publish(name, data) for a single event, or publish(message | message[]) with a Message-shaped object.'; + throw err; } if (!params) { @@ -139,11 +141,14 @@ class RestChannel { const size = getMessagesSize(wireMessages), maxMessageSize = options.maxMessageSize; if (size > maxMessageSize) { - throw new ErrorInfo( + const err = new ErrorInfo( `Maximum size of messages that can be published at once exceeded (was ${size} bytes; limit is ${maxMessageSize} bytes)`, 40009, 400, ); + err.hint = + 'Split the publish into multiple calls so each batch is under the limit, or contact support to raise maxMessageSize for your app.'; + throw err; } 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..71d6d6a3da 100644 --- a/src/common/lib/client/restchannelmixin.ts +++ b/src/common/lib/client/restchannelmixin.ts @@ -64,11 +64,14 @@ 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( + const err = 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, ); + err.hint = + 'Pass the Message received from a subscribe callback (which carries .serial), or its serial string. Also confirm the namespace enables message annotations/updates/deletes in the Ably dashboard.'; + throw err; } const client = channel.client; @@ -97,11 +100,14 @@ export class RestChannelMixin { params?: Record, ): Promise { if (!message.serial) { - throw new ErrorInfo( + const err = 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, ); + err.hint = + 'Pass the Message received from a subscribe callback (which carries .serial), not a freshly constructed object. Also confirm the namespace enables message annotations/updates/deletes in the Ably dashboard.'; + throw err; } const client = channel.client; @@ -139,11 +145,14 @@ export class RestChannelMixin { ): Promise> { const serial = typeof serialOrMessage === 'string' ? serialOrMessage : serialOrMessage.serial; if (!serial) { - throw new ErrorInfo( + const err = 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, ); + err.hint = + 'Pass the Message received from a subscribe callback (which carries .serial), or its serial string. Also confirm the namespace enables message annotations/updates/deletes in the Ably dashboard.'; + throw err; } const client = channel.client; diff --git a/src/common/lib/transport/connectionmanager.ts b/src/common/lib/transport/connectionmanager.ts index e1dbff6355..b502a5ddb4 100644 --- a/src/common/lib/transport/connectionmanager.ts +++ b/src/common/lib/transport/connectionmanager.ts @@ -1898,7 +1898,10 @@ class ConnectionManager extends EventEmitter { async ping(): Promise { if (this.state.state !== 'connected') { - throw new ErrorInfo('Unable to ping service; not connected', 40000, 400); + const err = new ErrorInfo('Unable to ping service; not connected', 40000, 400); + err.hint = + 'Wait for connection.state to be "connected" before calling ping(). Use await connection.whenState("connected") or connection.once("connected", …).'; + throw err; } const transport = this.activeProtocol?.getTransport(); @@ -1962,11 +1965,17 @@ class ConnectionManager extends EventEmitter { } else if (err.statusCode === HttpStatusCodes.Forbidden) { 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(msg, 80019, 403, err); + wrapped.hint = + 'Your authUrl/authCallback returned 403. Fix the auth endpoint: the request reached Ably, but your server refused to mint a token. Inspect cause for the underlying error.'; + 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(msg, 80019, 401, err); + wrapped.hint = + 'Your authUrl/authCallback could not be reached or returned an error. Check network connectivity to the auth endpoint and that it returns a valid token shape; the underlying error is in cause.'; + 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..c31a39ffe3 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); + { + const err = new ErrorInfo('Data type is unsupported', 40013, 400); + err.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.'; + throw err; + } } 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); + const err = new ErrorInfo('Missing Vcdiff decoder (https://github.com/ably-forks/vcdiff-decoder)', 40019, 400); + err.hint = + 'You enabled the delta channel option but did not provide the Vcdiff plugin. Install @ably/vcdiff-decoder and pass it in ClientOptions.plugins.vcdiff.'; + throw err; } if (typeof Uint8Array === 'undefined') { - throw new ErrorInfo( + const err = new ErrorInfo( 'Delta decoding not supported on this browser (need ArrayBuffer & Uint8Array)', 40020, 400, ); + err.hint = + 'Disable channel deltas (do not set delta in channel params) on environments without typed-array support, or upgrade the JavaScript runtime.'; + throw err; } try { let deltaBase = context.baseEncodedPreviousPayload; @@ -236,7 +247,10 @@ export async function decodeData( ); lastPayload = decodedData; } catch (e) { - throw new ErrorInfo('Vcdiff delta decode failed with ' + e, 40018, 400); + const err = new ErrorInfo('Vcdiff delta decode failed with ' + e, 40018, 400); + err.hint = + 'The SDK will recover by re-attaching without delta. If you see this repeatedly, the base payload has likely diverged — disable channel deltas for this channel.'; + throw err; } continue; default: diff --git a/src/common/lib/util/defaults.ts b/src/common/lib/util/defaults.ts index b9c58d56bf..728603d207 100644 --- a/src/common/lib/util/defaults.ts +++ b/src/common/lib/util/defaults.ts @@ -175,10 +175,14 @@ 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); + const err = new ErrorInfo('host must be a string; was a ' + typeof host, 40000, 400); + err.hint = 'Pass `endpoint` as a single hostname string (e.g. "main.realtime.ably.net"), not an array or object.'; + throw err; } if (!host.length) { - throw new ErrorInfo('host must not be zero-length', 40000, 400); + const err = new ErrorInfo('host must not be zero-length', 40000, 400); + err.hint = 'Omit `endpoint`/`restHost`/`realtimeHost` to use the Ably default, or pass a non-empty hostname.'; + throw err; } } @@ -251,21 +255,27 @@ function checkIfClientOptionsAreValid(options: ClientOptions) { // REC1b if (options.endpoint && (options.environment || options.restHost || options.realtimeHost)) { // RSC1b - throw new ErrorInfo( + const err = new ErrorInfo( 'The `endpoint` option cannot be used in conjunction with the `environment`, `restHost`, or `realtimeHost` options.', 40106, 400, ); + err.hint = + 'Use only `endpoint` (the v2 option). `environment`, `restHost`, and `realtimeHost` are legacy v1 names — remove them from ClientOptions.'; + throw err; } // REC1c if (options.environment && (options.restHost || options.realtimeHost)) { // RSC1b - throw new ErrorInfo( + const err = new ErrorInfo( 'The `environment` option cannot be used in conjunction with the `restHost`, or `realtimeHost` options.', 40106, 400, ); + err.hint = + 'These are mutually exclusive legacy host options. Replace all of them with the v2 `endpoint` option, which subsumes both.'; + throw err; } } diff --git a/src/common/lib/util/utils.ts b/src/common/lib/util/utils.ts index c548ca614e..cc53d44044 100644 --- a/src/common/lib/util/utils.ts +++ b/src/common/lib/util/utils.ts @@ -470,11 +470,16 @@ 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); + const err = new ErrorInfo('regex match failed', 400, 40010); + err.hint = + 'Channel names with derived options must look like "[filter=...]name". See https://ably.com/docs/channels/options/derived.'; + throw err; } // 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); + const err = new ErrorInfo(`cannot use a derived option with a ${match[2]} channel`, 400, 40010); + err.hint = `A "${match[2]}" channel already has a qualifier; derived options like filter= cannot be layered on top. Use a base channel name instead.`; + throw err; } // Return match values to be added to derive channel quantifier. return { @@ -499,7 +504,9 @@ export function arrEquals(a: any[], b: any[]) { } export function createMissingPluginError(pluginName: keyof ModularPlugins): ErrorInfo { - return new ErrorInfo(`${pluginName} plugin not provided`, 40019, 400); + const err = new ErrorInfo(`${pluginName} plugin not provided`, 40019, 400); + err.hint = `Import ${pluginName} from "ably/modular" and pass it in ClientOptions.plugins: { ${pluginName} }. See https://ably.com/docs/getting-started/modular.`; + return err; } export function throwMissingPluginError(pluginName: keyof ModularPlugins): never { @@ -554,7 +561,10 @@ export async function* listenerToAsyncIterator( yield eventQueue.shift()!; } else { if (resolveNext) { - throw new ErrorInfo('Concurrent next() calls are not supported', 40000, 400); + const err = new ErrorInfo('Concurrent next() calls are not supported', 40000, 400); + err.hint = + 'Drive the async iterator from a single for-await-of loop. Calling next() twice concurrently is not supported.'; + throw err; } // Otherwise wait for the next event to arrive diff --git a/src/plugins/push/getW3CDeviceDetails.ts b/src/plugins/push/getW3CDeviceDetails.ts index 0cee91ef9c..b588ccf256 100644 --- a/src/plugins/push/getW3CDeviceDetails.ts +++ b/src/plugins/push/getW3CDeviceDetails.ts @@ -28,17 +28,19 @@ 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 = new ErrorInfo('User denied permission to send notifications', 400, 40000); + err.hint = + 'The browser denied the Notification permission prompt. The user must accept notifications before push activation can complete; surface a UI explaining the value before requesting 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('Missing ClientOptions.pushServiceWorkerUrl', 400, 40000); + err.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..243a8eab8c 100644 --- a/src/plugins/push/pushactivation.ts +++ b/src/plugins/push/pushactivation.ts @@ -62,11 +62,19 @@ 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); + { + const err = new this.rest.ErrorInfo('Push activation is not available on this platform', 40000, 400); + err.hint = + 'Push activation requires a supported platform (browser with Push API, or native iOS/Android). Use Push admin (publish to a device/clientId) from server contexts.'; + throw err; + } } if (!this.id) { - throw new this.rest.ErrorInfo('Device not activated', 40000, 400); + const err = new this.rest.ErrorInfo('Device not activated', 40000, 400); + err.hint = + 'Call client.push.activate(registerCallback) and await its completion before listing subscriptions or other device-scoped operations.'; + throw err; } if (!this.deviceIdentityToken) { @@ -96,7 +104,12 @@ 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); + { + const err = new this.rest.ErrorInfo('Push activation is not available on this platform', 40000, 400); + err.hint = + 'Push activation requires a supported platform (browser with Push API, or native iOS/Android). Use Push admin (publish to a device/clientId) from server contexts.'; + throw err; + } } this.platform = Platform.Config.push.platform; this.clientId = this.rest.auth.clientId ?? undefined; @@ -117,7 +130,12 @@ 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); + { + const err = new this.rest.ErrorInfo('Push activation is not available on this platform', 40000, 400); + err.hint = + 'Push activation requires a supported platform (browser with Push API, or native iOS/Android). Use Push admin (publish to a device/clientId) from server contexts.'; + throw err; + } } if (this.id) { config.push.storage.set(persistKeys.deviceId, this.id); @@ -193,7 +211,10 @@ 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); + const err = new this.client.ErrorInfo('This platform is not supported as a target of push notifications', 40000, 400); + err.hint = + 'Push activation requires a supported platform (browser with Push API, or native iOS/Android). Use Push admin (publish to a device/clientId) from server contexts.'; + throw err; } return this._pushConfig; } @@ -229,11 +250,10 @@ 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('registerCallback did not return deviceRegistration', 40000, 400); + err.hint = + 'Your registerCallback must invoke its callback with (null, deviceRegistration). Returning undefined or null in the second argument fails activation.'; + this.handleEvent(new GettingDeviceRegistrationFailed(err)); } if (isNew) { diff --git a/src/plugins/push/pushchannel.ts b/src/plugins/push/pushchannel.ts index 2208c5e569..dca7b7d6ca 100644 --- a/src/plugins/push/pushchannel.ts +++ b/src/plugins/push/pushchannel.ts @@ -50,7 +50,10 @@ 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); + const err = new this.client.ErrorInfo('Cannot subscribe from client without client ID', 50000, 500); + err.hint = + 'Set ClientOptions.clientId (or include clientId in the token) before calling pushChannel.subscribeClient(). Anonymous clients cannot subscribe to push by clientId.'; + throw err; } const format = client.options.useBinaryProtocol ? client.Utils.Format.msgpack : client.Utils.Format.json, body = { clientId: clientId, channel: this.channel.name }, @@ -67,7 +70,10 @@ class PushChannel { const clientId = this.client.auth.clientId; if (!clientId) { - throw new this.client.ErrorInfo('Cannot unsubscribe from client without client ID', 50000, 500); + const err = new this.client.ErrorInfo('Cannot unsubscribe from client without client ID', 50000, 500); + err.hint = + 'Set ClientOptions.clientId (or include clientId in the token) before calling pushChannel.unsubscribeClient().'; + throw err; } const format = client.options.useBinaryProtocol ? client.Utils.Format.msgpack : client.Utils.Format.json, headers = client.Defaults.defaultPostHeaders(client.options); @@ -105,7 +111,10 @@ class PushChannel { if (deviceIdentityToken) { return deviceIdentityToken; } else { - throw new this.client.ErrorInfo('Cannot subscribe from client without deviceIdentityToken', 50000, 500); + const err = new this.client.ErrorInfo('Cannot subscribe from client without deviceIdentityToken', 50000, 500); + err.hint = + 'Activate this device first by awaiting client.push.activate(registerCallback) — the device must hold an identity token before subscribing to push.'; + throw err; } } diff --git a/test/unit/error-hints.test.js b/test/unit/error-hints.test.js new file mode 100644 index 0000000000..2e075c4ad2 --- /dev/null +++ b/test/unit/error-hints.test.js @@ -0,0 +1,77 @@ +'use strict'; + +/* + * DX-1209 — hint pinning tests for inline SDK throw sites. + * + * Tests pin the hint strings so we don't drift the LLM-discoverable surface. + * If you change a hint, update the assertion too — the drift check is the + * point. + * + * Hints for server-relayed codes (40140 token expired, 40160 capability, etc.) + * are out of scope for this ticket; that propagation is tracked separately. + */ + +define(['chai', 'ably'], function (chai, Ably) { + const { expect } = chai; + + describe('DX-1209 / error hints — inline at SDK throw sites', function () { + describe('BaseClient / ClientOptions construction', function () { + it('invalid key format (40400) carries a key-format hint', function () { + try { + new Ably.Rest({ key: 'not-a-valid-key' }); + throw new Error('expected constructor to throw'); + } catch (err) { + expect(err.code).to.equal(40400); + expect(err.hint).to.contain('appId'); + expect(err.hint).to.contain('keyId'); + } + }); + + it('wildcard clientId (40012) carries a defaultTokenParams hint', function () { + try { + new Ably.Rest({ key: 'a.b:c', clientId: '*' }); + throw new Error('expected constructor to throw'); + } catch (err) { + expect(err.code).to.equal(40012); + expect(err.hint).to.contain('defaultTokenParams'); + } + }); + + it('no auth options (40160) carries a key/authUrl/authCallback hint', function () { + try { + new Ably.Rest({}); + throw new Error('expected constructor to throw'); + } catch (err) { + expect(err.code).to.equal(40160); + expect(err.hint).to.contain('authUrl'); + expect(err.hint).to.contain('authCallback'); + } + }); + + it('endpoint + environment together (40106) carries a v2-naming hint', function () { + try { + new Ably.Rest({ key: 'a.b:c', endpoint: 'foo', environment: 'sandbox' }); + throw new Error('expected constructor to throw'); + } catch (err) { + expect(err.code).to.equal(40106); + expect(err.hint).to.contain('endpoint'); + expect(err.hint).to.contain('legacy'); + } + }); + }); + + describe('Utils.createMissingPluginError', function () { + it('missing plugin error carries an import hint', function () { + const rest = new Ably.Rest({ key: 'a.b:c' }); + try { + rest._FilteredSubscriptions; // triggers throwMissingPluginError('MessageInteractions') + throw new Error('expected getter to throw'); + } catch (err) { + expect(err.code).to.equal(40019); + expect(err.hint).to.contain('ably/modular'); + expect(err.hint).to.contain('plugins'); + } + }); + }); + }); +}); From f7867671673d996c0d4064a90f753c983159e41c Mon Sep 17 00:00:00 2001 From: umair Date: Tue, 26 May 2026 18:04:48 +0100 Subject: [PATCH 02/20] DX-1209: tighten hint language, forecast server walls, add CLI tips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sharpens ~15 of the inline error hints DX-1209 introduced based on ~120 LLM-pilot runs (see PR description for the full evaluation). Language: remove soft hedges (`probably`, `likely`, `potentially`, `may trigger`) in favour of active, definitive guidance. Second-wall forecasts: where a client-side fix opens up a server-side rejection, the hint now names the downstream cause inline. Applied to annotation_subscribe (realtimeannotations.ts), object_subscribe (liveobjects/realtimeobject.ts), wildcard clientId (auth.ts, baseclient.ts), presence.enter/update/leave (realtimepresence.ts), invalid channel modes (realtimechannel.ts), publish wrong-shape (realtimechannel.ts, restchannel.ts), and message annotations dashboard config (restchannelmixin.ts, realtimechannel.ts). The pattern brings LiveObjects 92xxx into scope — pilots showed weaker-model abandon rates of 40% on this surface without the forecast, 0% with it. Anti-hack notes: annotation_subscribe and object_subscribe hints now say "appending to channel.modes after attach() does not enable the mode server-side" so agents stop mutating the local modes array. Ably CLI pointers: where a CLI command would close the diagnosis loop, the hint adds a conditional `If you have the Ably CLI installed, ...` sentence (e.g. `ably auth keys list` for capability errors, `ably apps rules list` for channel-namespace settings). The conditional phrasing avoids regressing agents on machines without the CLI — an imperative phrasing caused 3/5 Opus runs to time out trying to invoke a missing binary in a confirmation pilot. Push platform-not-supported hints (push.ts ×2, pushactivation.ts ×4) now lead with the structural impossibility — "push.activate() cannot succeed in Node.js/server contexts (there is no device to register)" — and name the specific admin APIs to use instead. This turned 22-30-tool-call monkey-patch attempts into clean 7-tool-call acknowledgements that the SDK is right and the call is misused. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/common/lib/client/auth.ts | 6 +++--- src/common/lib/client/baseclient.ts | 4 ++-- src/common/lib/client/baserealtime.ts | 2 +- src/common/lib/client/push.ts | 4 ++-- src/common/lib/client/realtimeannotations.ts | 2 +- src/common/lib/client/realtimechannel.ts | 6 +++--- src/common/lib/client/realtimepresence.ts | 8 ++++---- src/common/lib/client/rest.ts | 2 +- src/common/lib/client/restchannel.ts | 2 +- src/common/lib/client/restchannelmixin.ts | 6 +++--- src/common/lib/types/basemessage.ts | 2 +- src/plugins/liveobjects/realtimeobject.ts | 8 ++++++-- src/plugins/push/pushactivation.ts | 8 ++++---- 13 files changed, 32 insertions(+), 28 deletions(-) diff --git a/src/common/lib/client/auth.ts b/src/common/lib/client/auth.ts index 90c7291c60..9b75112b89 100644 --- a/src/common/lib/client/auth.ts +++ b/src/common/lib/client/auth.ts @@ -519,7 +519,7 @@ class Auth { if ((body as string).length > MAX_TOKEN_LENGTH) { const err = new ErrorInfo('authUrl response exceeded max permitted length', 40170, 401); err.hint = - 'authUrl payloads must be under 128 KB. Your endpoint is likely returning more than just a TokenDetails/TokenRequest object — trim it down.'; + 'authUrl payloads must be under 128 KB. Your endpoint is returning more than a TokenDetails/TokenRequest object — return only the token shape, not wrapping JSON or extra fields.'; cb(err, null); return; } @@ -821,7 +821,7 @@ class Auth { if (!keySecret) { const err = new ErrorInfo('Invalid key specified', 40101, 403); - err.hint = 'API keys are "appId.keyId:secret". Copy the full key including the colon from the Ably dashboard.'; + err.hint = 'API keys are "appId.keyId:secret". Copy the full key including the colon from the Ably dashboard. If you have the Ably CLI installed, `ably auth keys list` shows the keys configured on the current app.'; throw err; } @@ -1027,7 +1027,7 @@ class Auth { 400, ); err.hint = - 'Move "*" out of ClientOptions.clientId. For a wildcard token, set defaultTokenParams: { clientId: "*" } on the client, or pass it to authorize() as a tokenParam.'; + 'Move "*" out of ClientOptions.clientId. For a wildcard token, set defaultTokenParams: { clientId: "*" } on the client, or pass it to authorize() as a tokenParam. The API key must have wildcard-clientId capability in the Ably dashboard, otherwise the server rejects the token request. If you have the Ably CLI installed, `ably auth keys list` shows your key\'s capabilities.'; throw err; } else { const err = this._uncheckedSetClientId(clientId); diff --git a/src/common/lib/client/baseclient.ts b/src/common/lib/client/baseclient.ts index 6207b46d80..730a4b8f22 100644 --- a/src/common/lib/client/baseclient.ts +++ b/src/common/lib/client/baseclient.ts @@ -78,7 +78,7 @@ class BaseClient { Logger.logAction(this.logger, Logger.LOG_ERROR, 'BaseClient()', msg); const err = new ErrorInfo(msg, 40400, 404); err.hint = - 'ClientOptions.key must be the full "appId.keyId:secret" string copied from the Ably dashboard. If you only have a token, use ClientOptions.token / tokenDetails instead.'; + 'ClientOptions.key must be the full "appId.keyId:secret" string copied from the Ably dashboard. If you only have a token, use ClientOptions.token / tokenDetails instead. If you have the Ably CLI installed, `ably auth keys list` shows the keys configured on the current app.'; throw err; } normalOptions.keyName = keyMatch[1]; @@ -97,7 +97,7 @@ class BaseClient { 400, ); err.hint = - 'Move "*" out of ClientOptions.clientId. For a wildcard token, set defaultTokenParams: { clientId: "*" } on the client instead.'; + 'Move "*" out of ClientOptions.clientId. For a wildcard token, set defaultTokenParams: { clientId: "*" } on the client instead. The API key must have wildcard-clientId capability in the Ably dashboard, otherwise the server rejects the token request. If you have the Ably CLI installed, `ably auth keys list` shows your key\'s capabilities.'; throw err; } } diff --git a/src/common/lib/client/baserealtime.ts b/src/common/lib/client/baserealtime.ts index fd6a262331..1f0d444f37 100644 --- a/src/common/lib/client/baserealtime.ts +++ b/src/common/lib/client/baserealtime.ts @@ -205,7 +205,7 @@ class Channels extends EventEmitter { 400, ); err.hint = - 'channels.get(name) returns the existing channel — call channel.setOptions(opts) to change params/modes (which may trigger a re-attach). Only the first channels.get() for a name applies options without a re-attach.'; + 'channels.get(name) returns the existing channel — call channel.setOptions(opts) to change params/modes (this triggers a re-attach if modes or params changed). Only the first channels.get() for a name applies options without a re-attach.'; throw err; } channel.setOptions(channelOptions); diff --git a/src/common/lib/client/push.ts b/src/common/lib/client/push.ts index a4f9a6a977..c18f4928f7 100644 --- a/src/common/lib/client/push.ts +++ b/src/common/lib/client/push.ts @@ -40,7 +40,7 @@ class Push { { const err = new ErrorInfo('This platform is not supported as a target of push notifications', 40000, 400); err.hint = - 'Push activation is only supported on browsers with the Push API (Chrome/Firefox/Edge/Safari) and on iOS/Android via the native SDKs. Use Push admin (publish to a device/clientId) from server contexts.'; + 'push.activate() registers this process as a push target — it cannot succeed in Node.js/server contexts (there is no device to register). Use client.push.admin to manage other devices from a server: client.push.admin.publish(recipient, payload) to send to a device or clientId, client.push.admin.deviceRegistrations.save(device) to register a device record.'; reject(err); } return; @@ -76,7 +76,7 @@ class Push { { const err = new ErrorInfo('This platform is not supported as a target of push notifications', 40000, 400); err.hint = - 'Push activation is only supported on browsers with the Push API (Chrome/Firefox/Edge/Safari) and on iOS/Android via the native SDKs. Use Push admin (publish to a device/clientId) from server contexts.'; + 'push.activate() registers this process as a push target — it cannot succeed in Node.js/server contexts (there is no device to register). Use client.push.admin to manage other devices from a server: client.push.admin.publish(recipient, payload) to send to a device or clientId, client.push.admin.deviceRegistrations.save(device) to register a device record.'; reject(err); } return; diff --git a/src/common/lib/client/realtimeannotations.ts b/src/common/lib/client/realtimeannotations.ts index 4d95904dd6..5da58cf4e6 100644 --- a/src/common/lib/client/realtimeannotations.ts +++ b/src/common/lib/client/realtimeannotations.ts @@ -77,7 +77,7 @@ class RealtimeAnnotations { 400, ); err.hint = - 'Re-create the channel with annotation_subscribe in modes: realtime.channels.get(name, { modes: ["subscribe", "annotation_subscribe", ...] }). Also confirm your token capability grants annotation-subscribe on this channel.'; + 'Re-create the channel with annotation_subscribe in modes: realtime.channels.get(name, { modes: ["subscribe", "annotation_subscribe", ...] }). If the subsequent attach is rejected by the server, the channel namespace must have "Message annotations, updates, and deletes" enabled in the Ably dashboard, and your API key must have annotation-subscribe capability on this channel. If you have the Ably CLI installed, `ably apps rules list` shows which channel namespaces have Mutable Messages enabled, and `ably auth keys list` shows your key\'s capabilities. Note: appending to channel.modes after attach() does not enable the mode server-side — the array reflects what the server granted, not what you requested.'; throw err; } } diff --git a/src/common/lib/client/realtimechannel.ts b/src/common/lib/client/realtimechannel.ts index 4c34bf1398..408a7ec307 100644 --- a/src/common/lib/client/realtimechannel.ts +++ b/src/common/lib/client/realtimechannel.ts @@ -51,7 +51,7 @@ function validateChannelOptions(options?: API.ChannelOptions) { !channelModes.includes(String.prototype.toUpperCase.call(currentMode)) ) { const err = new ErrorInfo('Invalid channel mode: ' + currentMode, 40000, 400); - err.hint = `Valid ChannelMode values are: ${channelModes.join(', ').toLowerCase()}.`; + err.hint = `Valid ChannelMode values are: ${channelModes.join(', ').toLowerCase()}. The server also enforces this — your token/API-key capability must permit the requested modes on this channel, otherwise the subsequent attach is rejected. If you have the Ably CLI installed, \`ably auth keys list\` shows your key's capabilities.`; return err; } } @@ -287,7 +287,7 @@ class RealtimeChannel extends EventEmitter { 40013, 400, ); - err.hint = 'Call publish(name, data) for a single event, or publish(message | message[]) with a Message-shaped object.'; + err.hint = 'Call publish(name, data) for a single event, or publish(message | message[]) with a Message-shaped object. If the resulting publish is rejected by the server, your token/API-key capability must include "publish" on this channel. If you have the Ably CLI installed, `ably auth keys list` shows your key\'s capabilities.'; throw err; } const maxMessageSize = this.client.options.maxMessageSize; @@ -1160,7 +1160,7 @@ class RealtimeChannel extends EventEmitter { 400, ); err.hint = - 'Pass the Message you received from a subscribe callback (which carries .serial), not a freshly constructed object. Also confirm the namespace enables message annotations/updates/deletes in the Ably dashboard.'; + 'Pass the Message you received from a subscribe callback (which carries .serial), not a freshly constructed object. The namespace must enable message annotations/updates/deletes in the Ably dashboard. If you have the Ably CLI installed, `ably apps rules list` shows which channel namespaces have Mutable Messages enabled.'; throw err; } diff --git a/src/common/lib/client/realtimepresence.ts b/src/common/lib/client/realtimepresence.ts index fa3a46d24a..c502eb7c7c 100644 --- a/src/common/lib/client/realtimepresence.ts +++ b/src/common/lib/client/realtimepresence.ts @@ -64,7 +64,7 @@ class RealtimePresence extends EventEmitter { if (isAnonymousOrWildcard(this)) { const err = new ErrorInfo('clientId must be specified to enter a presence channel', 40012, 400); err.hint = - 'Set ClientOptions.clientId (or include clientId in the token) before calling presence.enter(). To enter on behalf of another identity, use presence.enterClient(clientId, data).'; + 'Set ClientOptions.clientId (or include clientId in the token) before calling presence.enter(). To enter on behalf of another identity, use presence.enterClient(clientId, data). If the resulting presence message is rejected by the server, your token/API-key capability must include "presence" on this channel. If you have the Ably CLI installed, `ably auth keys list` shows your key\'s capabilities.'; throw err; } return this._enterOrUpdateClient(undefined, undefined, data, 'enter'); @@ -79,7 +79,7 @@ class RealtimePresence extends EventEmitter { if (isAnonymousOrWildcard(this)) { const err = new ErrorInfo('clientId must be specified to update presence data', 40012, 400); err.hint = - 'Set ClientOptions.clientId (or include clientId in the token) before calling presence.update(). To update on behalf of another identity, use presence.updateClient(clientId, data).'; + 'Set ClientOptions.clientId (or include clientId in the token) before calling presence.update(). To update on behalf of another identity, use presence.updateClient(clientId, data). If the resulting presence message is rejected by the server, your token/API-key capability must include "presence" on this channel. If you have the Ably CLI installed, `ably auth keys list` shows your key\'s capabilities.'; throw err; } return this._enterOrUpdateClient(undefined, undefined, data, 'update'); @@ -161,7 +161,7 @@ class RealtimePresence extends EventEmitter { if (isAnonymousOrWildcard(this)) { const err = new ErrorInfo('clientId must have been specified to enter or leave a presence channel', 40012, 400); err.hint = - 'Anonymous clients cannot publish presence. Set ClientOptions.clientId (or include clientId in the token), or use presence.leaveClient(clientId) to leave on behalf of a specific identity.'; + 'Anonymous clients cannot publish presence. Set ClientOptions.clientId (or include clientId in the token), or use presence.leaveClient(clientId) to leave on behalf of a specific identity. If the resulting leave is rejected by the server, your token/API-key capability must include "presence" on this channel. If you have the Ably CLI installed, `ably auth keys list` shows your key\'s capabilities.'; throw err; } return this.leaveClient(undefined, data); @@ -237,7 +237,7 @@ class RealtimePresence extends EventEmitter { message: 'Presence state is out of sync due to channel being in the SUSPENDED state', }); err.hint = - 'Wait for the channel to reach "attached" before calling presence.get(), or pass { waitForSync: false } to read the last known (potentially stale) members.'; + 'Wait for the channel to reach "attached" before calling presence.get(), or pass { waitForSync: false } to read the last known (stale) members.'; throw err; } return toMessages(this.members); diff --git a/src/common/lib/client/rest.ts b/src/common/lib/client/rest.ts index bdb0cd2fef..a9cced53b6 100644 --- a/src/common/lib/client/rest.ts +++ b/src/common/lib/client/rest.ts @@ -216,7 +216,7 @@ export class Rest { if (useTokenAuth(this.client.options)) { const err = new ErrorInfo('Cannot revoke tokens when using token auth', 40162, 401); err.hint = - 'Token revocation requires basic auth. Construct a separate Ably.Rest client with ClientOptions.key (the API key with the revoke capability) just for this call.'; + 'Token revocation requires basic auth. Construct a separate Ably.Rest client with ClientOptions.key (the API key with the revoke capability) just for this call. If you have the Ably CLI installed, `ably auth keys list` shows which keys have the revoke capability.'; throw err; } diff --git a/src/common/lib/client/restchannel.ts b/src/common/lib/client/restchannel.ts index aea5221456..c433387c81 100644 --- a/src/common/lib/client/restchannel.ts +++ b/src/common/lib/client/restchannel.ts @@ -111,7 +111,7 @@ class RestChannel { 40013, 400, ); - err.hint = 'Call publish(name, data) for a single event, or publish(message | message[]) with a Message-shaped object.'; + err.hint = 'Call publish(name, data) for a single event, or publish(message | message[]) with a Message-shaped object. If the resulting publish is rejected by the server, your token/API-key capability must include "publish" on this channel. If you have the Ably CLI installed, `ably auth keys list` shows your key\'s capabilities.'; throw err; } diff --git a/src/common/lib/client/restchannelmixin.ts b/src/common/lib/client/restchannelmixin.ts index 71d6d6a3da..268a28ec79 100644 --- a/src/common/lib/client/restchannelmixin.ts +++ b/src/common/lib/client/restchannelmixin.ts @@ -70,7 +70,7 @@ export class RestChannelMixin { 400, ); err.hint = - 'Pass the Message received from a subscribe callback (which carries .serial), or its serial string. Also confirm the namespace enables message annotations/updates/deletes in the Ably dashboard.'; + 'Pass the Message received from a subscribe callback (which carries .serial), or its serial string. The namespace must enable message annotations/updates/deletes in the Ably dashboard. If you have the Ably CLI installed, `ably apps rules list` shows which channel namespaces have Mutable Messages enabled.'; throw err; } @@ -106,7 +106,7 @@ export class RestChannelMixin { 400, ); err.hint = - 'Pass the Message received from a subscribe callback (which carries .serial), not a freshly constructed object. Also confirm the namespace enables message annotations/updates/deletes in the Ably dashboard.'; + 'Pass the Message received from a subscribe callback (which carries .serial), not a freshly constructed object. The namespace must enable message annotations/updates/deletes in the Ably dashboard. If you have the Ably CLI installed, `ably apps rules list` shows which channel namespaces have Mutable Messages enabled.'; throw err; } @@ -151,7 +151,7 @@ export class RestChannelMixin { 400, ); err.hint = - 'Pass the Message received from a subscribe callback (which carries .serial), or its serial string. Also confirm the namespace enables message annotations/updates/deletes in the Ably dashboard.'; + 'Pass the Message received from a subscribe callback (which carries .serial), or its serial string. The namespace must enable message annotations/updates/deletes in the Ably dashboard. If you have the Ably CLI installed, `ably apps rules list` shows which channel namespaces have Mutable Messages enabled.'; throw err; } diff --git a/src/common/lib/types/basemessage.ts b/src/common/lib/types/basemessage.ts index c31a39ffe3..e273f03a21 100644 --- a/src/common/lib/types/basemessage.ts +++ b/src/common/lib/types/basemessage.ts @@ -249,7 +249,7 @@ export async function decodeData( } catch (e) { const err = new ErrorInfo('Vcdiff delta decode failed with ' + e, 40018, 400); err.hint = - 'The SDK will recover by re-attaching without delta. If you see this repeatedly, the base payload has likely diverged — disable channel deltas for this channel.'; + 'The SDK recovers by re-attaching without delta. If you see this repeatedly, the base payload has diverged — disable channel deltas for this channel.'; throw err; } continue; diff --git a/src/plugins/liveobjects/realtimeobject.ts b/src/plugins/liveobjects/realtimeobject.ts index fc5b082051..1f7412117f 100644 --- a/src/plugins/liveobjects/realtimeobject.ts +++ b/src/plugins/liveobjects/realtimeobject.ts @@ -568,11 +568,15 @@ 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 + const err = new this._client.ErrorInfo(`"${expectedMode}" channel mode must be set for this operation`, 40024, 400); // RTO2a2 + err.hint = `Re-create the channel with "${expectedMode}" in modes: realtime.channels.get(name, { modes: [..., "${expectedMode}"] }). If the subsequent attach is rejected by the server, the channel namespace must have LiveObjects enabled in the Ably dashboard, and your API key must have the corresponding capability on this channel. If you have the Ably CLI installed, \`ably apps rules list\` shows channel-namespace settings and \`ably auth keys list\` shows your key's capabilities. Note: appending to channel.modes after attach() does not enable the mode server-side — the array reflects what the server granted, not what you requested.`; + throw err; } // 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 + const err = new this._client.ErrorInfo(`"${expectedMode}" channel mode must be set for this operation`, 40024, 400); // RTO2b2 + err.hint = `Re-create the channel with "${expectedMode}" in modes: realtime.channels.get(name, { modes: [..., "${expectedMode}"] }). If the subsequent attach is rejected by the server, the channel namespace must have LiveObjects enabled in the Ably dashboard, and your API key must have the corresponding capability on this channel. If you have the Ably CLI installed, \`ably apps rules list\` shows channel-namespace settings and \`ably auth keys list\` shows your key's capabilities.`; + throw err; } } diff --git a/src/plugins/push/pushactivation.ts b/src/plugins/push/pushactivation.ts index 243a8eab8c..c05ef52600 100644 --- a/src/plugins/push/pushactivation.ts +++ b/src/plugins/push/pushactivation.ts @@ -65,7 +65,7 @@ export function localDeviceFactory(deviceDetails: typeof DeviceDetails) { { const err = new this.rest.ErrorInfo('Push activation is not available on this platform', 40000, 400); err.hint = - 'Push activation requires a supported platform (browser with Push API, or native iOS/Android). Use Push admin (publish to a device/clientId) from server contexts.'; + 'push.activate() registers this process as a push target — it cannot succeed in Node.js/server contexts (there is no device to register). Use client.push.admin to manage other devices from a server: client.push.admin.publish(recipient, payload) to send to a device or clientId, client.push.admin.deviceRegistrations.save(device) to register a device record.'; throw err; } } @@ -107,7 +107,7 @@ export function localDeviceFactory(deviceDetails: typeof DeviceDetails) { { const err = new this.rest.ErrorInfo('Push activation is not available on this platform', 40000, 400); err.hint = - 'Push activation requires a supported platform (browser with Push API, or native iOS/Android). Use Push admin (publish to a device/clientId) from server contexts.'; + 'push.activate() registers this process as a push target — it cannot succeed in Node.js/server contexts (there is no device to register). Use client.push.admin to manage other devices from a server: client.push.admin.publish(recipient, payload) to send to a device or clientId, client.push.admin.deviceRegistrations.save(device) to register a device record.'; throw err; } } @@ -133,7 +133,7 @@ export function localDeviceFactory(deviceDetails: typeof DeviceDetails) { { const err = new this.rest.ErrorInfo('Push activation is not available on this platform', 40000, 400); err.hint = - 'Push activation requires a supported platform (browser with Push API, or native iOS/Android). Use Push admin (publish to a device/clientId) from server contexts.'; + 'push.activate() registers this process as a push target — it cannot succeed in Node.js/server contexts (there is no device to register). Use client.push.admin to manage other devices from a server: client.push.admin.publish(recipient, payload) to send to a device or clientId, client.push.admin.deviceRegistrations.save(device) to register a device record.'; throw err; } } @@ -213,7 +213,7 @@ export class ActivationStateMachine { if (!this._pushConfig) { const err = new this.client.ErrorInfo('This platform is not supported as a target of push notifications', 40000, 400); err.hint = - 'Push activation requires a supported platform (browser with Push API, or native iOS/Android). Use Push admin (publish to a device/clientId) from server contexts.'; + 'push.activate() registers this process as a push target — it cannot succeed in Node.js/server contexts (there is no device to register). Use client.push.admin to manage other devices from a server: client.push.admin.publish(recipient, payload) to send to a device or clientId, client.push.admin.deviceRegistrations.save(device) to register a device record.'; throw err; } return this._pushConfig; From 28460a2015fc6647c024769b3f8f4b18c02f4246 Mon Sep 17 00:00:00 2001 From: umair Date: Wed, 27 May 2026 11:14:48 +0100 Subject: [PATCH 03/20] DX-1209: add scripts/hint-coverage.ts + wire into lint CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Static drift-guard for the ~90 inline err.hint sites DX-1209 added. For every err.hint = assignment under src/, asserts the hint string is non-empty and — if a per-code rubric exists — contains required tokens (e.g. 93001 must contain "annotation_subscribe" and "modes"; 40024 must contain the dynamic "expectedMode" identifier and "modes"; 40162 must contain "revoke" and "key"; etc). The rubric is deliberately scoped: codes shared across semantically unrelated throw sites (40000/40012/40013/40400/40500) are left out since a code-level rule would over-constrain. New entries are a 4-line block per error code. Wired into the existing Lint workflow (.github/workflows/check.yml) alongside npm run lint / format:check so drift fails PRs cheaply. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/check.yml | 1 + package.json | 1 + scripts/hint-coverage.ts | 276 ++++++++++++++++++++++++++++++++++++ 3 files changed, 278 insertions(+) create mode 100644 scripts/hint-coverage.ts diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 7f5e3363bb..ce5e6e608c 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -27,6 +27,7 @@ jobs: - run: npm ci - run: npm run lint - run: npm run format:check + - run: npm run hintcoverage - run: npx tsc --noEmit ably.d.ts modular.d.ts # for some reason, this doesn't work in CI using `npx attw --pack .` - run: npm pack diff --git a/package.json b/package.json index c153c64663..7f43687477 100644 --- a/package.json +++ b/package.json @@ -193,6 +193,7 @@ "sourcemap": "source-map-explorer build/ably.min.js", "modulereport": "tsc --noEmit --esModuleInterop scripts/moduleReport.ts && esr scripts/moduleReport.ts", "speccoveragereport": "tsc --noEmit --esModuleInterop --target ES2017 --moduleResolution node scripts/specCoverageReport.ts && esr scripts/specCoverageReport.ts", + "hintcoverage": "tsc --noEmit --esModuleInterop --target ES2017 --moduleResolution node scripts/hint-coverage.ts && esr scripts/hint-coverage.ts", "process-private-api-data": "tsc --noEmit --esModuleInterop --strictNullChecks scripts/processPrivateApiData/run.ts && esr scripts/processPrivateApiData/run.ts", "docs": "typedoc" } diff --git a/scripts/hint-coverage.ts b/scripts/hint-coverage.ts new file mode 100644 index 0000000000..292cc2ab93 --- /dev/null +++ b/scripts/hint-coverage.ts @@ -0,0 +1,276 @@ +/* + * DX-1209 — static hint-coverage check. + * + * Statically scans every `.ts` file under src/ for `err.hint = ...` + * assignments associated with an `ErrorInfo` throw and verifies: + * + * 1. The hint string is non-empty. + * 2. If we have a rubric entry for the ErrorInfo code, the hint contains + * every required substring (`contains`) or matches every required + * regex (`matches`). + * + * Hints are now a discoverable surface for LLMs and humans alike; this + * check is a cheap drift guard for that surface. It does NOT lock down + * exact wording — wording is allowed to evolve. It DOES lock down + * presence and the API-name / concept tokens we don't want silently + * renamed (e.g. `annotation_subscribe`, `defaultTokenParams`). + * + * Add new entries to RUBRIC as new hint sites land. Missing rubric entries + * for an error code are not an error — only an explicit rule violation is. + * + * Run with: + * tsc --noEmit --esModuleInterop --target ES2017 --moduleResolution node scripts/hint-coverage.ts \ + * && esr scripts/hint-coverage.ts + * + * (Wired up as `npm run hintcoverage` in package.json.) + */ + +import fs from 'fs'; +import path from 'path'; +import { glob } from 'glob'; +import { exit } from 'process'; + +interface HintEntry { + file: string; + line: number; + code: number | null; + hintText: string; + isTemplate: boolean; +} + +type RubricRule = { kind: 'contains'; value: string } | { kind: 'matches'; value: RegExp }; + +interface RubricEntry { + description: string; + require: RubricRule[]; +} + +/** + * Per-error-code rubric. Add entries for codes whose hint contains + * API names / concept tokens we want guarded against silent rename. + * + * Rules of thumb when adding entries: + * - Pin SDK API identifiers (`enterClient`, `defaultTokenParams`, ...) + * and Ably product/feature words (`annotation_subscribe`, `Mutable + * Messages`, ...). They're the public-ish surface. + * - Avoid pinning prose ("must", "the resulting"). Wording is allowed + * to evolve. + * - Use `matches` (regex) only when the token genuinely varies. + */ +const RUBRIC: Record = { + 40009: { + description: 'publish payload exceeds maxMessageSize', + require: [{ kind: 'contains', value: 'maxMessageSize' }], + }, + 40019: { + description: 'missing plugin', + // 40019 is shared between modular-plugin missing (ably/modular) and the + // vcdiff-decoder missing site. Either install path is acceptable. + require: [{ kind: 'matches', value: /ably\/modular|@ably\/vcdiff-decoder/ }], + }, + 40024: { + description: 'LiveObjects channel mode missing', + require: [ + { kind: 'contains', value: 'modes' }, + { kind: 'contains', value: 'expectedMode' }, // dynamic; rendered as object_subscribe/_publish + ], + }, + 40106: { + description: 'endpoint + environment conflict', + require: [{ kind: 'contains', value: 'endpoint' }], + }, + 40160: { + description: 'no authentication options', + require: [ + { kind: 'contains', value: 'authUrl' }, + { kind: 'contains', value: 'authCallback' }, + ], + }, + 40162: { + description: 'revokeTokens under token auth', + require: [ + { kind: 'contains', value: 'revoke' }, + { kind: 'contains', value: 'key' }, + ], + }, + 93001: { + description: 'annotation_subscribe mode missing', + require: [ + { kind: 'contains', value: 'annotation_subscribe' }, + { kind: 'contains', value: 'modes' }, + ], + }, + // Codes deliberately NOT keyed in the rubric: + // 40000, 40012, 40013, 40400, 40500 — each is shared across multiple + // unrelated throw sites, so a code-level rubric over-constrains. Add + // message-keyed rules in a follow-up if/when needed. +}; + +const SRC_ROOT = path.resolve(__dirname, '..', 'src'); + +function stripBlockComments(src: string): string { + return src.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' ')); +} + +function extractHints(filePath: string): HintEntry[] { + const raw = fs.readFileSync(filePath, 'utf8'); + const src = stripBlockComments(raw); + const lines = src.split('\n'); + const entries: HintEntry[] = []; + + // Track the most-recent ErrorInfo code we saw within a sliding window. + let pendingCode: number | null = null; + let pendingCodeLine = -Infinity; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Detect `new ErrorInfo(... , , ...)` — code can land on the same + // line as the constructor or on a later line in a multiline call. + const errInfoIdx = line.indexOf('new ErrorInfo('); + const errInfoClientIdx = line.indexOf('.ErrorInfo('); + if (errInfoIdx >= 0 || errInfoClientIdx >= 0) { + // Look ahead up to 6 lines for the code argument. + const span = lines.slice(i, i + 6).join(' '); + const codeMatch = span.match(/,\s*(\d{4,5})\s*,/); + if (codeMatch) { + pendingCode = parseInt(codeMatch[1], 10); + pendingCodeLine = i; + } + } + + // Detect `err.hint = ...` — string body may span multiple lines. + const hintMatch = line.match(/(\w+)\.hint\s*=\s*(.*)$/); + if (hintMatch) { + // Drop the pending code if it's too far away (likely unrelated). + const code = i - pendingCodeLine <= 12 ? pendingCode : null; + + // Reassemble the RHS until we hit a semicolon at the end of a line. + const rhsParts: string[] = [hintMatch[2]]; + let j = i; + while (!rhsParts[rhsParts.length - 1].trimEnd().endsWith(';') && j < lines.length - 1) { + j++; + rhsParts.push(lines[j]); + } + const rhs = rhsParts.join(' ').replace(/;$/, '').trim(); + + // Classify literal vs template. + const isTemplate = rhs.startsWith('`'); + // Extract a best-effort literal text by collapsing template segments + // to spaces so we can still check for substring tokens. + let text = rhs; + if (rhs.startsWith("'") || rhs.startsWith('"') || rhs.startsWith('`')) { + const quote = rhs[0]; + // Remove leading + trailing quote. + text = rhs.slice(1); + const lastQuoteIdx = text.lastIndexOf(quote); + if (lastQuoteIdx >= 0) text = text.slice(0, lastQuoteIdx); + // Collapse interpolations (`${...}`) to the bare identifier(s) inside + // them. Lets token checks succeed on dynamic variable names without + // requiring runtime evaluation. E.g. `${expectedMode}` -> `expectedMode`. + text = text.replace(/\$\{([^}]*)\}/g, (_, expr) => { + const id = String(expr).trim(); + // Pull only identifier-like fragments; drop method calls and operators + // so the substring search isn't polluted by syntax. + return ' ' + id.replace(/[^A-Za-z0-9_]+/g, ' ') + ' '; + }); + } + + entries.push({ + file: path.relative(process.cwd(), filePath), + line: i + 1, + code, + hintText: text, + isTemplate, + }); + } + } + + return entries; +} + +interface Failure { + kind: 'empty' | 'rubric'; + entry: HintEntry; + detail: string; +} + +function check(entry: HintEntry): Failure[] { + const failures: Failure[] = []; + const text = entry.hintText.trim(); + if (text.length === 0) { + failures.push({ kind: 'empty', entry, detail: 'hint is empty' }); + return failures; + } + if (entry.code != null && RUBRIC[entry.code]) { + const rules = RUBRIC[entry.code].require; + for (const rule of rules) { + if (rule.kind === 'contains' && !text.includes(rule.value)) { + failures.push({ + kind: 'rubric', + entry, + detail: `code ${entry.code} hint must contain "${rule.value}"`, + }); + } else if (rule.kind === 'matches' && !rule.value.test(text)) { + failures.push({ + kind: 'rubric', + entry, + detail: `code ${entry.code} hint must match ${rule.value}`, + }); + } + } + } + return failures; +} + +async function main() { + const files = await glob('**/*.ts', { + cwd: SRC_ROOT, + absolute: true, + ignore: ['**/*.test.ts', '**/*.d.ts'], + }); + + let allEntries: HintEntry[] = []; + for (const f of files) { + allEntries = allEntries.concat(extractHints(f)); + } + + const failures: Failure[] = []; + for (const e of allEntries) { + failures.push(...check(e)); + } + + // Per-code summary for visibility. + const byCode = new Map(); + for (const e of allEntries) { + const list = byCode.get(e.code) ?? []; + list.push(e); + byCode.set(e.code, list); + } + + console.log(`hint-coverage: scanned ${files.length} files, found ${allEntries.length} err.hint assignments\n`); + const codes = [...byCode.keys()].sort((a, b) => (a ?? -1) - (b ?? -1)); + for (const code of codes) { + const list = byCode.get(code)!; + const rubric = code != null && RUBRIC[code] ? ` [${RUBRIC[code].description}]` : ''; + console.log(` ${code ?? '(no code)'} × ${list.length}${rubric}`); + } + + if (failures.length === 0) { + console.log(`\n✓ all hints non-empty and pass rubric checks`); + return; + } + + console.log(`\n✗ ${failures.length} failure(s):\n`); + for (const f of failures) { + console.log(` ${f.entry.file}:${f.entry.line} (code ${f.entry.code ?? 'unknown'})`); + console.log(` ${f.detail}`); + console.log(` hint: ${JSON.stringify(f.entry.hintText.slice(0, 120))}${f.entry.hintText.length > 120 ? '…' : ''}`); + } + exit(1); +} + +main().catch((err) => { + console.error(err); + exit(2); +}); From ccb7b6d590f6b8d5121c28ab133a605475cd63fa Mon Sep 17 00:00:00 2001 From: umair Date: Wed, 27 May 2026 11:24:09 +0100 Subject: [PATCH 04/20] DX-1209: address Lint + Bundle CI failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prettier --write across the 9 hint-affected files (8 SDK source + the new scripts/hint-coverage.ts) and bump the minimal-Realtime bundle threshold to keep the size guard green. - Lint: prettier formatting on the trailing CLI-line additions and the multi-line hint strings. No semantic change. - Bundle: minimal-Realtime raw 107 → 116 KiB and gzip 33 → 36 KiB in scripts/moduleReport.ts. The hint extensions across the audit (capability forecasts, CLI mentions, anti-hack notes, Push admin pivot) added ~9 KiB raw / ~2 KiB gzipped to the minimal bundle. Same threshold-bump pattern fff1c1bc used for the initial DX-1209 hint additions (106→107 / 32→33). Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/hint-coverage.ts | 4 +++- scripts/moduleReport.ts | 2 +- src/common/lib/client/auth.ts | 6 ++++-- src/common/lib/client/baseclient.ts | 3 ++- src/common/lib/client/paginatedresource.ts | 6 ++++-- src/common/lib/client/realtimechannel.ts | 12 ++++++++---- src/common/lib/client/restchannel.ts | 3 ++- src/common/lib/types/basemessage.ts | 6 +++++- src/plugins/liveobjects/realtimeobject.ts | 12 ++++++++++-- src/plugins/push/pushactivation.ts | 6 +++++- 10 files changed, 44 insertions(+), 16 deletions(-) diff --git a/scripts/hint-coverage.ts b/scripts/hint-coverage.ts index 292cc2ab93..ac2d42b408 100644 --- a/scripts/hint-coverage.ts +++ b/scripts/hint-coverage.ts @@ -265,7 +265,9 @@ async function main() { for (const f of failures) { console.log(` ${f.entry.file}:${f.entry.line} (code ${f.entry.code ?? 'unknown'})`); console.log(` ${f.detail}`); - console.log(` hint: ${JSON.stringify(f.entry.hintText.slice(0, 120))}${f.entry.hintText.length > 120 ? '…' : ''}`); + console.log( + ` hint: ${JSON.stringify(f.entry.hintText.slice(0, 120))}${f.entry.hintText.length > 120 ? '…' : ''}`, + ); } exit(1); } diff --git a/scripts/moduleReport.ts b/scripts/moduleReport.ts index 0436150012..bd59f05f84 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: 116, gzip: 36 }; const baseClientNames = ['BaseRest', 'BaseRealtime']; diff --git a/src/common/lib/client/auth.ts b/src/common/lib/client/auth.ts index 9b75112b89..43cd14bbea 100644 --- a/src/common/lib/client/auth.ts +++ b/src/common/lib/client/auth.ts @@ -821,7 +821,8 @@ class Auth { if (!keySecret) { const err = new ErrorInfo('Invalid key specified', 40101, 403); - err.hint = 'API keys are "appId.keyId:secret". Copy the full key including the colon from the Ably dashboard. If you have the Ably CLI installed, `ably auth keys list` shows the keys configured on the current app.'; + err.hint = + 'API keys are "appId.keyId:secret". Copy the full key including the colon from the Ably dashboard. If you have the Ably CLI installed, `ably auth keys list` shows the keys configured on the current app.'; throw err; } @@ -1018,7 +1019,8 @@ class Auth { _userSetClientId(clientId: string | undefined) { if (!(typeof clientId === 'string' || clientId === null)) { const err = new ErrorInfo('clientId must be either a string or null', 40012, 400); - err.hint = 'Pass a string (e.g. a user id) or null for an anonymous client. Numbers and objects are not accepted.'; + err.hint = + 'Pass a string (e.g. a user id) or null for an anonymous client. Numbers and objects are not accepted.'; throw err; } else if (clientId === '*') { const err = new ErrorInfo( diff --git a/src/common/lib/client/baseclient.ts b/src/common/lib/client/baseclient.ts index 730a4b8f22..61008757a6 100644 --- a/src/common/lib/client/baseclient.ts +++ b/src/common/lib/client/baseclient.ts @@ -88,7 +88,8 @@ class BaseClient { if ('clientId' in normalOptions) { if (!(typeof normalOptions.clientId === 'string' || normalOptions.clientId === null)) { const err = new ErrorInfo('clientId must be either a string or null', 40012, 400); - err.hint = 'Pass a string (e.g. a user id) or null for an anonymous client. Numbers and objects are not accepted.'; + err.hint = + 'Pass a string (e.g. a user id) or null for an anonymous client. Numbers and objects are not accepted.'; throw err; } else if (normalOptions.clientId === '*') { const err = new ErrorInfo( diff --git a/src/common/lib/client/paginatedresource.ts b/src/common/lib/client/paginatedresource.ts index bb4439c23b..d66bb61efe 100644 --- a/src/common/lib/client/paginatedresource.ts +++ b/src/common/lib/client/paginatedresource.ts @@ -148,7 +148,8 @@ export class PaginatedResult { } const err = new ErrorInfo('No link to the first page of results', 40400, 404); - err.hint = 'Check hasFirst() before calling first(). Empty result sets and single-page responses have no first-page link.'; + err.hint = + 'Check hasFirst() before calling first(). Empty result sets and single-page responses have no first-page link.'; throw err; } @@ -158,7 +159,8 @@ export class PaginatedResult { } const err = new ErrorInfo('No link to the current page of results', 40400, 404); - err.hint = 'Check hasCurrent() before calling current(). The current-page link is only set after at least one page has been fetched.'; + err.hint = + 'Check hasCurrent() before calling current(). The current-page link is only set after at least one page has been fetched.'; throw err; } diff --git a/src/common/lib/client/realtimechannel.ts b/src/common/lib/client/realtimechannel.ts index 408a7ec307..92085f1b0c 100644 --- a/src/common/lib/client/realtimechannel.ts +++ b/src/common/lib/client/realtimechannel.ts @@ -287,7 +287,8 @@ class RealtimeChannel extends EventEmitter { 40013, 400, ); - err.hint = 'Call publish(name, data) for a single event, or publish(message | message[]) with a Message-shaped object. If the resulting publish is rejected by the server, your token/API-key capability must include "publish" on this channel. If you have the Ably CLI installed, `ably auth keys list` shows your key\'s capabilities.'; + err.hint = + 'Call publish(name, data) for a single event, or publish(message | message[]) with a Message-shaped object. If the resulting publish is rejected by the server, your token/API-key capability must include "publish" on this channel. If you have the Ably CLI installed, `ably auth keys list` shows your key\'s capabilities.'; throw err; } const maxMessageSize = this.client.options.maxMessageSize; @@ -1056,7 +1057,8 @@ class RealtimeChannel extends EventEmitter { if (params && params.untilAttach) { if (this.state !== 'attached') { const err = new ErrorInfo('option untilAttach requires the channel to be attached', 40000, 400); - err.hint = 'Await channel.attach() (or channel.whenState("attached")) before calling history({ untilAttach: true }).'; + err.hint = + 'Await channel.attach() (or channel.whenState("attached")) before calling history({ untilAttach: true }).'; throw err; } if (!this.properties.attachSerial) { @@ -1065,7 +1067,8 @@ class RealtimeChannel extends EventEmitter { 40000, 400, ); - err.hint = 'Re-attach the channel and try again; the SDK could not record an attachSerial from the previous attach.'; + err.hint = + 'Re-attach the channel and try again; the SDK could not record an attachSerial from the previous attach.'; throw err; } delete params.untilAttach; @@ -1091,7 +1094,8 @@ class RealtimeChannel extends EventEmitter { 90001, 400, ); - err.hint = 'Call channel.detach() and wait for the channel to reach "detached" before calling channels.release(name).'; + err.hint = + 'Call channel.detach() and wait for the channel to reach "detached" before calling channels.release(name).'; return err; } diff --git a/src/common/lib/client/restchannel.ts b/src/common/lib/client/restchannel.ts index c433387c81..96e69b7236 100644 --- a/src/common/lib/client/restchannel.ts +++ b/src/common/lib/client/restchannel.ts @@ -111,7 +111,8 @@ class RestChannel { 40013, 400, ); - err.hint = 'Call publish(name, data) for a single event, or publish(message | message[]) with a Message-shaped object. If the resulting publish is rejected by the server, your token/API-key capability must include "publish" on this channel. If you have the Ably CLI installed, `ably auth keys list` shows your key\'s capabilities.'; + err.hint = + 'Call publish(name, data) for a single event, or publish(message | message[]) with a Message-shaped object. If the resulting publish is rejected by the server, your token/API-key capability must include "publish" on this channel. If you have the Ably CLI installed, `ably auth keys list` shows your key\'s capabilities.'; throw err; } diff --git a/src/common/lib/types/basemessage.ts b/src/common/lib/types/basemessage.ts index e273f03a21..49b7aa7f22 100644 --- a/src/common/lib/types/basemessage.ts +++ b/src/common/lib/types/basemessage.ts @@ -217,7 +217,11 @@ export async function decodeData( } case 'vcdiff': if (!context.plugins || !context.plugins.vcdiff) { - const err = new ErrorInfo('Missing Vcdiff decoder (https://github.com/ably-forks/vcdiff-decoder)', 40019, 400); + const err = new ErrorInfo( + 'Missing Vcdiff decoder (https://github.com/ably-forks/vcdiff-decoder)', + 40019, + 400, + ); err.hint = 'You enabled the delta channel option but did not provide the Vcdiff plugin. Install @ably/vcdiff-decoder and pass it in ClientOptions.plugins.vcdiff.'; throw err; diff --git a/src/plugins/liveobjects/realtimeobject.ts b/src/plugins/liveobjects/realtimeobject.ts index 1f7412117f..c42a8ca0b8 100644 --- a/src/plugins/liveobjects/realtimeobject.ts +++ b/src/plugins/liveobjects/realtimeobject.ts @@ -568,13 +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)) { - const err = new this._client.ErrorInfo(`"${expectedMode}" channel mode must be set for this operation`, 40024, 400); // RTO2a2 + const err = new this._client.ErrorInfo( + `"${expectedMode}" channel mode must be set for this operation`, + 40024, + 400, + ); // RTO2a2 err.hint = `Re-create the channel with "${expectedMode}" in modes: realtime.channels.get(name, { modes: [..., "${expectedMode}"] }). If the subsequent attach is rejected by the server, the channel namespace must have LiveObjects enabled in the Ably dashboard, and your API key must have the corresponding capability on this channel. If you have the Ably CLI installed, \`ably apps rules list\` shows channel-namespace settings and \`ably auth keys list\` shows your key's capabilities. Note: appending to channel.modes after attach() does not enable the mode server-side — the array reflects what the server granted, not what you requested.`; throw err; } // RTO2b - otherwise as a best effort use user provided channel options if (!this._client.Utils.allToLowerCase(this._channel.channelOptions.modes ?? []).includes(expectedMode)) { - const err = new this._client.ErrorInfo(`"${expectedMode}" channel mode must be set for this operation`, 40024, 400); // RTO2b2 + const err = new this._client.ErrorInfo( + `"${expectedMode}" channel mode must be set for this operation`, + 40024, + 400, + ); // RTO2b2 err.hint = `Re-create the channel with "${expectedMode}" in modes: realtime.channels.get(name, { modes: [..., "${expectedMode}"] }). If the subsequent attach is rejected by the server, the channel namespace must have LiveObjects enabled in the Ably dashboard, and your API key must have the corresponding capability on this channel. If you have the Ably CLI installed, \`ably apps rules list\` shows channel-namespace settings and \`ably auth keys list\` shows your key's capabilities.`; throw err; } diff --git a/src/plugins/push/pushactivation.ts b/src/plugins/push/pushactivation.ts index c05ef52600..a8bc21c48c 100644 --- a/src/plugins/push/pushactivation.ts +++ b/src/plugins/push/pushactivation.ts @@ -211,7 +211,11 @@ export class ActivationStateMachine { get pushConfig() { if (!this._pushConfig) { - const err = new this.client.ErrorInfo('This platform is not supported as a target of push notifications', 40000, 400); + const err = new this.client.ErrorInfo( + 'This platform is not supported as a target of push notifications', + 40000, + 400, + ); err.hint = 'push.activate() registers this process as a push target — it cannot succeed in Node.js/server contexts (there is no device to register). Use client.push.admin to manage other devices from a server: client.push.admin.publish(recipient, payload) to send to a device or clientId, client.push.admin.deviceRegistrations.save(device) to register a device record.'; throw err; From 9cc0ff8fb7e7a74d8bf0b9e06e7b19357031f105 Mon Sep 17 00:00:00 2001 From: umair Date: Thu, 28 May 2026 10:32:30 +0100 Subject: [PATCH 05/20] DX-1209: fix push plugin bugs surfaced by PR #2233 review getW3CDeviceDetails.ts: swap ErrorInfo argument order at two throw sites where (statusCode, code) had been transposed. The ErrorInfo constructor expects (message, code, statusCode); the two calls were passing (message, 400, 40000) where 400 is HTTP status and 40000 is the SDK error code. Pre-existing bug pre-DX-1209, flagged by CodeRabbit. pushactivation.ts: add missing `return;` after the GettingDeviceRegistrationFailed handleEvent call so an undefined deviceRegistration cannot fall through into GotDeviceRegistration on the next line. Without the return, the activation state machine would emit a registration event with `deviceRegistration as any === undefined`. Flagged by CodeRabbit as a critical correctness issue. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/plugins/push/getW3CDeviceDetails.ts | 4 ++-- src/plugins/push/pushactivation.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/plugins/push/getW3CDeviceDetails.ts b/src/plugins/push/getW3CDeviceDetails.ts index b588ccf256..2767bfd298 100644 --- a/src/plugins/push/getW3CDeviceDetails.ts +++ b/src/plugins/push/getW3CDeviceDetails.ts @@ -28,7 +28,7 @@ export async function getW3CPushDeviceDetails(machine: ActivationStateMachine) { const permission = await Notification.requestPermission(); if (permission !== 'granted') { - const err = new ErrorInfo('User denied permission to send notifications', 400, 40000); + const err = new ErrorInfo('User denied permission to send notifications', 40000, 400); err.hint = 'The browser denied the Notification permission prompt. The user must accept notifications before push activation can complete; surface a UI explaining the value before requesting again.'; machine.handleEvent(new GettingPushDeviceDetailsFailed(err)); @@ -37,7 +37,7 @@ export async function getW3CPushDeviceDetails(machine: ActivationStateMachine) { const swUrl = machine.client.options.pushServiceWorkerUrl; if (!swUrl) { - const err = new ErrorInfo('Missing ClientOptions.pushServiceWorkerUrl', 400, 40000); + const err = new ErrorInfo('Missing ClientOptions.pushServiceWorkerUrl', 40000, 400); err.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)); diff --git a/src/plugins/push/pushactivation.ts b/src/plugins/push/pushactivation.ts index a8bc21c48c..59900d95b6 100644 --- a/src/plugins/push/pushactivation.ts +++ b/src/plugins/push/pushactivation.ts @@ -258,6 +258,7 @@ export class ActivationStateMachine { err.hint = 'Your registerCallback must invoke its callback with (null, deviceRegistration). Returning undefined or null in the second argument fails activation.'; this.handleEvent(new GettingDeviceRegistrationFailed(err)); + return; } if (isNew) { From e225978c430c8cb4e22cf2bd09537ba46be165a4 Mon Sep 17 00:00:00 2001 From: umair Date: Thu, 28 May 2026 10:34:38 +0100 Subject: [PATCH 06/20] DX-1209: extend ErrorInfo with values-object constructor overload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hint-bearing throw sites in the SDK currently follow a 3-step pattern: const err = new ErrorInfo('msg', 40000, 400); err.hint = 'how to fix...'; throw err; Add an options-object overload to ErrorInfo and PartialErrorInfo so the same throw collapses to one call: throw new ErrorInfo({ message, code, statusCode, hint }); The overload mirrors `fromValues` semantics: validate the shape, set the fields, Object.assign for hint/cause/href/extras, auto-populate href from the error code if not already set. Server-decoded errors still flow through ErrorInfo.fromValues unchanged and continue to carry their server-provided href and extra fields (requestId, serverId) via the same Object.assign mechanism. Also extend IConvertibleToErrorInfo and IConvertibleToPartialErrorInfo to declare the hint, cause, and href fields that Object.assign has been carrying through at runtime since the interface was introduced. This is a type-only widening — no runtime behaviour change for any existing caller. Addresses AndyTWF's review-feedback question on whether fromValues should accept hint directly so the SDK throw pattern is a single call. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/common/lib/types/errorinfo.ts | 120 +++++++++++++++++++++--------- 1 file changed, 85 insertions(+), 35 deletions(-) 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; } From 03c056ff6c39aec17d02121291565c55180d5e17 Mon Sep 17 00:00:00 2001 From: umair Date: Thu, 28 May 2026 10:43:13 +0100 Subject: [PATCH 07/20] DX-1209: migrate hint-bearing throw sites to single-call form Address AndyTWF's review-feedback question on whether the SDK should expose hint as a constructor parameter so the throw pattern is one call instead of three. Now that ErrorInfo (and PartialErrorInfo) accept a values-object overload, all ~85 inline hint sites move from const err = new ErrorInfo('msg', code, statusCode); err.hint = 'how to fix...'; throw err; to throw new ErrorInfo({ message, code, statusCode, hint: '...' }); (or `const err = new ErrorInfo({...}); reject(err);` where the variable is used by a callback). One call site that already used ErrorInfo.fromValues({...}) for its construction (realtimepresence.ts get-when-suspended) folds the hint into the same object literal. Also address CodeRabbit nits in the push plugin: - Drop the unnecessary block braces wrapping single throws/rejects in src/common/lib/client/push.ts (2 sites) and src/plugins/push/pushactivation.ts (3 sites) and src/common/lib/client/auth.ts (1 site) and src/common/lib/types/basemessage.ts (1 site). - Extract the four-times-repeated "push.activate() registers this process..." hint into module-level PUSH_NOT_AVAILABLE_HINT constants in pushactivation.ts and push.ts. No wording changes in this commit; subsequent commits will refine hint content per Andy's substantive feedback. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/common/lib/client/auth.ts | 228 ++++++++++-------- src/common/lib/client/baseclient.ts | 35 +-- src/common/lib/client/baserealtime.ts | 15 +- src/common/lib/client/paginatedresource.ts | 20 +- src/common/lib/client/push.ts | 75 +++--- src/common/lib/client/realtimeannotations.ts | 15 +- src/common/lib/client/realtimechannel.ts | 157 ++++++------ src/common/lib/client/realtimepresence.ts | 73 +++--- src/common/lib/client/rest.ts | 19 +- src/common/lib/client/restannotations.ts | 29 +-- src/common/lib/client/restchannel.ts | 28 +-- src/common/lib/client/restchannelmixin.ts | 45 ++-- src/common/lib/transport/connectionmanager.ts | 30 ++- src/common/lib/types/basemessage.ts | 50 ++-- src/common/lib/util/defaults.ts | 47 ++-- src/common/lib/util/utils.ts | 50 ++-- src/plugins/liveobjects/realtimeobject.ts | 26 +- src/plugins/push/getW3CDeviceDetails.ts | 18 +- src/plugins/push/pushactivation.ts | 72 +++--- src/plugins/push/pushchannel.ts | 30 ++- 20 files changed, 581 insertions(+), 481 deletions(-) diff --git a/src/common/lib/client/auth.ts b/src/common/lib/client/auth.ts index 43cd14bbea..60899e7bdd 100644 --- a/src/common/lib/client/auth.ts +++ b/src/common/lib/client/auth.ts @@ -158,10 +158,12 @@ class Auth { const msg = 'No authentication options provided; need one of: key, authUrl, or authCallback (or for testing only, token or tokenDetails)'; Logger.logAction(this.logger, Logger.LOG_ERROR, 'Auth()', msg); - const err = new ErrorInfo(msg, 40160, 401); - err.hint = - 'Pass one of ClientOptions.{ key, authUrl, authCallback, token, tokenDetails }. For production, prefer authUrl or authCallback so the API key stays on your server.'; - throw err; + throw new ErrorInfo({ + message: msg, + code: 40160, + statusCode: 401, + hint: 'Pass one of ClientOptions.{ key, authUrl, authCallback, token, tokenDetails }. For production, prefer authUrl or authCallback so the API key stays on your server.', + }); } Logger.logAction(this.logger, Logger.LOG_MINOR, 'Auth()', 'anonymous, using basic auth'); this._saveBasicOptions(options); @@ -272,12 +274,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) { - { - const err = new ErrorInfo('Unable to update auth options with incompatible key', 40102, 401); - err.hint = - 'auth.authorize() cannot change the API key — the new authOptions.key differs from the one the client was constructed with. To use a different key, construct a new Ably client.'; - throw err; - } + throw new ErrorInfo({ + message: 'Unable to update auth options with incompatible key', + code: 40102, + statusCode: 401, + hint: 'auth.authorize() cannot change the API key — the new authOptions.key differs from the one the client was constructed with. To use a different key, construct a new Ably client.', + }); } try { @@ -494,32 +496,38 @@ class Auth { } if (Platform.BufferUtils.isBuffer(body)) body = body.toString(); if (!contentType) { - const err = new ErrorInfo('authUrl response is missing a content-type header', 40170, 401); - err.hint = - 'Have your auth endpoint return a Content-Type of application/json (TokenDetails/TokenRequest), text/plain (token string) or application/jwt.'; + const err = new ErrorInfo({ + message: 'authUrl response is missing a content-type header', + code: 40170, + statusCode: 401, + hint: 'Have your auth endpoint return a Content-Type of application/json (TokenDetails/TokenRequest), text/plain (token string) or application/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) { - const err = new ErrorInfo( - 'authUrl responded with unacceptable content-type ' + + const err = new ErrorInfo({ + message: + 'authUrl responded with unacceptable content-type ' + contentType + ', should be either text/plain, application/jwt or application/json', - 40170, - 401, - ); - err.hint = - 'Update your auth endpoint to return Content-Type application/json, text/plain or application/jwt — the SDK cannot parse other content types.'; + code: 40170, + statusCode: 401, + hint: 'Update your auth endpoint to return Content-Type application/json, text/plain or application/jwt — the SDK cannot parse other content types.', + }); cb(err, null); return; } if (json) { if ((body as string).length > MAX_TOKEN_LENGTH) { - const err = new ErrorInfo('authUrl response exceeded max permitted length', 40170, 401); - err.hint = - 'authUrl payloads must be under 128 KB. Your endpoint is returning more than a TokenDetails/TokenRequest object — return only the token shape, not wrapping JSON or extra fields.'; + const err = new ErrorInfo({ + message: 'authUrl response exceeded max permitted length', + code: 40170, + statusCode: 401, + hint: 'authUrl payloads must be under 128 KB. Your endpoint is returning more than a TokenDetails/TokenRequest object — return only the token shape, not wrapping JSON or extra fields.', + }); cb(err, null); return; } @@ -599,10 +607,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', ); - const err = new ErrorInfo(msg, 40171, 403); - err.hint = - 'Initialise the client with one of ClientOptions.{ key, authUrl, authCallback } so the SDK can refresh tokens. A bare token/tokenDetails alone cannot be renewed once expired.'; - throw err; + 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. A bare token/tokenDetails alone cannot be renewed once expired.', + }); } /* normalise token params */ @@ -645,9 +655,12 @@ class Auth { tokenRequestCallbackTimeoutExpired = true; const msg = 'Token request callback timed out after ' + timeoutLength / 1000 + ' seconds'; Logger.logAction(this.logger, Logger.LOG_ERROR, 'Auth.requestToken()', msg); - const err = new ErrorInfo(msg, 40170, 401); - err.hint = - 'Your authCallback/authUrl did not respond in time. Make sure the callback invokes its callback parameter (or resolves its promise) on every code path.'; + const err = new ErrorInfo({ + message: msg, + code: 40170, + statusCode: 401, + hint: 'Your authCallback/authUrl did not respond in time. Make sure the callback invokes its callback parameter (or resolves its promise) on every code path.', + }); reject(err); }, timeoutLength); @@ -668,36 +681,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) { - const err = new ErrorInfo('Token string is empty', 40170, 401); - err.hint = - 'Your authCallback returned an empty string. Return a non-empty token string, or a TokenDetails/TokenRequest object.'; + const err = new ErrorInfo({ + message: 'Token string is empty', + code: 40170, + statusCode: 401, + hint: 'Your authCallback returned an empty string. Return a non-empty token string, or a TokenDetails/TokenRequest object.', + }); reject(err); } else if (tokenRequestOrDetails.length > MAX_TOKEN_LENGTH) { - const err = new ErrorInfo( - 'Token string exceeded max permitted length (was ' + tokenRequestOrDetails.length + ' bytes)', - 40170, - 401, - ); - err.hint = - 'Tokens must be under 128 KB. Your endpoint is returning more than the token itself — return only the token string or a TokenDetails object.'; + const err = new ErrorInfo({ + message: 'Token string exceeded max permitted length (was ' + tokenRequestOrDetails.length + ' bytes)', + code: 40170, + statusCode: 401, + hint: 'Tokens must be under 128 KB. Your endpoint is returning more than the token itself — return only the token string or a TokenDetails object.', + }); reject(err); } else if (tokenRequestOrDetails === 'undefined' || tokenRequestOrDetails === 'null') { /* common failure mode with poorly-implemented authCallbacks */ - const err = new ErrorInfo('Token string was literal null/undefined', 40170, 401); - err.hint = - 'Your authCallback stringified a null/undefined token. Return the token itself, not "undefined"/"null"; callbacks that have no value to return should pass an error instead.'; + const err = new ErrorInfo({ + message: 'Token string was literal null/undefined', + code: 40170, + statusCode: 401, + hint: 'Your authCallback stringified a null/undefined token. 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) ) { - const err = new ErrorInfo( - "Token was double-encoded; make sure you're not JSON-encoding an already encoded token request or details", - 40170, - 401, - ); - err.hint = - 'Return TokenDetails/TokenRequest as an object (not a JSON-encoded string), or set the response Content-Type to application/jwt for JWT tokens.'; + const err = new ErrorInfo({ + message: + "Token was double-encoded; make sure you're not JSON-encoding an already encoded token request or details", + code: 40170, + statusCode: 401, + hint: 'Return TokenDetails/TokenRequest as an object (not a JSON-encoded string), or set the response Content-Type to application/jwt for JWT tokens.', + }); reject(err); } else { resolve({ token: tokenRequestOrDetails } as API.TokenDetails); @@ -709,21 +727,24 @@ 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); - const err = new ErrorInfo(msg, 40170, 401); - err.hint = - 'authCallback must invoke its callback with (err, tokenStringOrTokenDetailsOrTokenRequest). authUrl must respond with a token string or TokenDetails/TokenRequest JSON.'; + const err = new ErrorInfo({ + message: msg, + code: 40170, + statusCode: 401, + hint: 'authCallback must invoke its callback with (err, tokenStringOrTokenDetailsOrTokenRequest). authUrl must 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) { - const err = new ErrorInfo( - 'Token request/details object exceeded max permitted stringified size (was ' + objectSize + ' bytes)', - 40170, - 401, - ); - err.hint = - 'Token objects must serialise to under 128 KB. Trim unused fields from your TokenDetails/TokenRequest, or set authOptions.suppressMaxLengthCheck if you understand the risk.'; + const err = new ErrorInfo({ + message: + 'Token request/details object exceeded max permitted stringified size (was ' + objectSize + ' bytes)', + code: 40170, + statusCode: 401, + hint: 'Token objects must serialise to under 128 KB. Trim unused fields from your TokenDetails/TokenRequest, or set authOptions.suppressMaxLengthCheck if you understand the risk.', + }); reject(err); return; } @@ -736,9 +757,12 @@ class Auth { const msg = 'Expected token request callback to call back with a token string, token request object, or token details object'; Logger.logAction(this.logger, Logger.LOG_ERROR, 'Auth.requestToken()', msg); - const err = new ErrorInfo(msg, 40170, 401); - err.hint = - 'Your authCallback/authUrl returned an object without a `keyName` (so it was treated as a TokenDetails) and that shape was also rejected. Return either a token string, a TokenRequest (with keyName), or a TokenDetails (with token).'; + const err = new ErrorInfo({ + message: msg, + code: 40170, + statusCode: 401, + hint: 'Your authCallback/authUrl returned an object without a `keyName` (so it was treated as a TokenDetails) and that shape was also rejected. Return either a token string, a TokenRequest (with keyName), or a TokenDetails (with token).', + }); reject(err); return; } @@ -810,26 +834,33 @@ class Auth { const key = authOptions.key; if (!key) { - const err = new ErrorInfo('No key specified', 40101, 403); - err.hint = - 'createTokenRequest needs an API key. Pass ClientOptions.key on the client or { key } in the authOptions argument. Token-auth clients cannot mint token requests themselves.'; - throw err; + throw new ErrorInfo({ + message: 'No key specified', + code: 40101, + statusCode: 403, + hint: 'createTokenRequest needs an API key. Pass ClientOptions.key on the client or { key } in the authOptions argument. Token-auth clients cannot mint token requests themselves.', + }); } const keyParts = key.split(':'), keyName = keyParts[0], keySecret = keyParts[1]; if (!keySecret) { - const err = new ErrorInfo('Invalid key specified', 40101, 403); - err.hint = - 'API keys are "appId.keyId:secret". Copy the full key including the colon from the Ably dashboard. If you have the Ably CLI installed, `ably auth keys list` shows the keys configured on the current app.'; - throw err; + throw new ErrorInfo({ + message: 'Invalid key specified', + code: 40101, + statusCode: 403, + hint: 'API keys are "appId.keyId:secret". Copy the full key including the colon 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 === '') { - const err = new ErrorInfo('clientId can’t be an empty string', 40012, 400); - err.hint = 'Pass a non-empty clientId, or omit the field entirely to mint an anonymous token.'; - throw err; + 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 to mint an anonymous token.', + }); } if ('capability' in tokenParams) { @@ -949,14 +980,13 @@ class Auth { if (token) { if (this._tokenClientIdMismatch(token.clientId)) { /* 403 to trigger a permanently failed client - RSA15c */ - const err = new ErrorInfo( - 'Mismatch between clientId in token (' + token.clientId + ') and current clientId (' + this.clientId + ')', - 40102, - 403, - ); - err.hint = - 'Mint the token with the same clientId as ClientOptions.clientId, or omit ClientOptions.clientId and let the token define it. The two cannot diverge.'; - throw err; + throw new ErrorInfo({ + message: + 'Mismatch between clientId in token (' + token.clientId + ') and current clientId (' + this.clientId + ')', + code: 40102, + statusCode: 403, + hint: 'Mint the token with the same clientId as ClientOptions.clientId, or omit ClientOptions.clientId and let the token define it. The two cannot diverge.', + }); } /* 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 @@ -1018,19 +1048,20 @@ class Auth { /* User-set: check types, '*' is disallowed, throw any errors */ _userSetClientId(clientId: string | undefined) { if (!(typeof clientId === 'string' || clientId === null)) { - const err = new ErrorInfo('clientId must be either a string or null', 40012, 400); - err.hint = - 'Pass a string (e.g. a user id) or null for an anonymous client. Numbers and objects are not accepted.'; - throw err; + throw new ErrorInfo({ + message: 'clientId must be either a string or null', + code: 40012, + statusCode: 400, + hint: 'Pass a string (e.g. a user id) or null for an anonymous client. Numbers and objects are not accepted.', + }); } else if (clientId === '*') { - const err = 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, - ); - err.hint = - 'Move "*" out of ClientOptions.clientId. For a wildcard token, set defaultTokenParams: { clientId: "*" } on the client, or pass it to authorize() as a tokenParam. The API key must have wildcard-clientId capability in the Ably dashboard, otherwise the server rejects the token request. If you have the Ably CLI installed, `ably auth keys list` shows your key\'s capabilities.'; - throw err; + throw new ErrorInfo({ + message: + '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)', + code: 40012, + statusCode: 400, + hint: 'Move "*" out of ClientOptions.clientId. For a wildcard token, set defaultTokenParams: { clientId: "*" } on the client, or pass it to authorize() as a tokenParam. The API key must have wildcard-clientId capability in the Ably dashboard, otherwise the server rejects the token request. If you have the Ably CLI installed, `ably auth keys list` shows your key\'s capabilities.', + }); } else { const err = this._uncheckedSetClientId(clientId); if (err) throw err; @@ -1043,9 +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); - err.hint = - 'A clientId from the token does not match ClientOptions.clientId. Mint the token with the matching clientId, or omit ClientOptions.clientId and let the token define it.'; + const err = new ErrorInfo({ + message: msg, + code: 40102, + statusCode: 401, + hint: 'A clientId from the token does not match ClientOptions.clientId. Mint 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 61008757a6..8232776ce2 100644 --- a/src/common/lib/client/baseclient.ts +++ b/src/common/lib/client/baseclient.ts @@ -76,10 +76,12 @@ class BaseClient { if (!keyMatch) { const msg = 'invalid key parameter'; Logger.logAction(this.logger, Logger.LOG_ERROR, 'BaseClient()', msg); - const err = new ErrorInfo(msg, 40400, 404); - err.hint = - 'ClientOptions.key must be the full "appId.keyId:secret" string copied from the Ably dashboard. If you only have a token, use ClientOptions.token / tokenDetails instead. If you have the Ably CLI installed, `ably auth keys list` shows the keys configured on the current app.'; - throw err; + 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 only have a token, use ClientOptions.token / tokenDetails instead. 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]; @@ -87,19 +89,20 @@ class BaseClient { if ('clientId' in normalOptions) { if (!(typeof normalOptions.clientId === 'string' || normalOptions.clientId === null)) { - const err = new ErrorInfo('clientId must be either a string or null', 40012, 400); - err.hint = - 'Pass a string (e.g. a user id) or null for an anonymous client. Numbers and objects are not accepted.'; - throw err; + throw new ErrorInfo({ + message: 'clientId must be either a string or null', + code: 40012, + statusCode: 400, + hint: 'Pass a string (e.g. a user id) or null for an anonymous client. Numbers and objects are not accepted.', + }); } else if (normalOptions.clientId === '*') { - const err = 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, - ); - err.hint = - 'Move "*" out of ClientOptions.clientId. For a wildcard token, set defaultTokenParams: { clientId: "*" } on the client instead. The API key must have wildcard-clientId capability in the Ably dashboard, otherwise the server rejects the token request. If you have the Ably CLI installed, `ably auth keys list` shows your key\'s capabilities.'; - throw err; + throw new ErrorInfo({ + message: + '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: "*"}})', + code: 40012, + statusCode: 400, + hint: 'Move "*" out of ClientOptions.clientId. For a wildcard token, set defaultTokenParams: { clientId: "*" } on the client instead. The API key must have wildcard-clientId capability in the Ably dashboard, otherwise the server rejects the token request. If you have the Ably CLI installed, `ably auth keys list` shows your key\'s capabilities.', + }); } } diff --git a/src/common/lib/client/baserealtime.ts b/src/common/lib/client/baserealtime.ts index 1f0d444f37..c448eb9cc2 100644 --- a/src/common/lib/client/baserealtime.ts +++ b/src/common/lib/client/baserealtime.ts @@ -199,14 +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)) { - const err = 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, - ); - err.hint = - 'channels.get(name) returns the existing channel — call channel.setOptions(opts) to change params/modes (this triggers a re-attach if modes or params changed). Only the first channels.get() for a name applies options without a re-attach.'; - throw err; + throw new ErrorInfo({ + message: + 'Channels.get() cannot be used to set channel options that would cause the channel to reattach. Please, use RealtimeChannel.setOptions() instead.', + code: 40000, + statusCode: 400, + hint: 'channels.get(name) returns the existing channel — call channel.setOptions(opts) to change params/modes (this triggers a re-attach if modes or params changed). Only the first channels.get() for a name applies options without a re-attach.', + }); } channel.setOptions(channelOptions); } diff --git a/src/common/lib/client/paginatedresource.ts b/src/common/lib/client/paginatedresource.ts index d66bb61efe..241bcc4100 100644 --- a/src/common/lib/client/paginatedresource.ts +++ b/src/common/lib/client/paginatedresource.ts @@ -147,10 +147,12 @@ export class PaginatedResult { return this.get(this._relParams!.first); } - const err = new ErrorInfo('No link to the first page of results', 40400, 404); - err.hint = - 'Check hasFirst() before calling first(). Empty result sets and single-page responses have no first-page link.'; - throw err; + throw new ErrorInfo({ + message: 'No link to the first page of results', + code: 40400, + statusCode: 404, + hint: 'Check hasFirst() before calling first(). Empty result sets and single-page responses have no first-page link.', + }); } async current(): Promise> { @@ -158,10 +160,12 @@ export class PaginatedResult { return this.get(this._relParams!.current); } - const err = new ErrorInfo('No link to the current page of results', 40400, 404); - err.hint = - 'Check hasCurrent() before calling current(). The current-page link is only set after at least one page has been fetched.'; - throw err; + throw new ErrorInfo({ + message: 'No link to the current page of results', + code: 40400, + statusCode: 404, + hint: 'Check hasCurrent() before calling current(). The current-page link is only set after at least one page has been fetched.', + }); } async next(): Promise | null> { diff --git a/src/common/lib/client/push.ts b/src/common/lib/client/push.ts index c18f4928f7..d75c8611b4 100644 --- a/src/common/lib/client/push.ts +++ b/src/common/lib/client/push.ts @@ -15,6 +15,9 @@ import type { import Platform from 'common/platform'; import type { ErrCallback } from 'common/types/utils'; +const PUSH_NOT_AVAILABLE_HINT = + 'push.activate() registers this process as a push target — it cannot succeed in Node.js/server contexts (there is no device to register). Use client.push.admin to manage other devices from a server: client.push.admin.publish(recipient, payload) to send to a device or clientId, client.push.admin.deviceRegistrations.save(device) to register a device record.'; + class Push { client: BaseClient; admin: Admin; @@ -37,18 +40,22 @@ class Push { return; } if (!this.stateMachine) { - { - const err = new ErrorInfo('This platform is not supported as a target of push notifications', 40000, 400); - err.hint = - 'push.activate() registers this process as a push target — it cannot succeed in Node.js/server contexts (there is no device to register). Use client.push.admin to manage other devices from a server: client.push.admin.publish(recipient, payload) to send to a device or clientId, client.push.admin.deviceRegistrations.save(device) to register a device record.'; - reject(err); - } + const err = new ErrorInfo({ + message: 'This platform is not supported as a target of push notifications', + code: 40000, + statusCode: 400, + hint: PUSH_NOT_AVAILABLE_HINT, + }); + reject(err); return; } if (this.stateMachine.activatedCallback) { - const err = new ErrorInfo('Activation already in progress', 40000, 400); - err.hint = - 'Await the in-flight push.activate() before calling it again. Concurrent activations are not supported.'; + const err = new ErrorInfo({ + message: 'Activation already in progress', + code: 40000, + statusCode: 400, + hint: 'Await the in-flight push.activate() before calling it again. Concurrent activations are not supported.', + }); reject(err); return; } @@ -73,18 +80,22 @@ class Push { return; } if (!this.stateMachine) { - { - const err = new ErrorInfo('This platform is not supported as a target of push notifications', 40000, 400); - err.hint = - 'push.activate() registers this process as a push target — it cannot succeed in Node.js/server contexts (there is no device to register). Use client.push.admin to manage other devices from a server: client.push.admin.publish(recipient, payload) to send to a device or clientId, client.push.admin.deviceRegistrations.save(device) to register a device record.'; - reject(err); - } + const err = new ErrorInfo({ + message: 'This platform is not supported as a target of push notifications', + code: 40000, + statusCode: 400, + hint: PUSH_NOT_AVAILABLE_HINT, + }); + reject(err); return; } if (this.stateMachine.deactivatedCallback) { - const err = new ErrorInfo('Deactivation already in progress', 40000, 400); - err.hint = - 'Await the in-flight push.deactivate() before calling it again. Concurrent deactivations are not supported.'; + const err = new ErrorInfo({ + message: 'Deactivation already in progress', + code: 40000, + statusCode: 400, + hint: 'Await the in-flight push.deactivate() before calling it again. Concurrent deactivations are not supported.', + }); reject(err); return; } @@ -172,14 +183,12 @@ class DeviceRegistrations { deviceId = deviceIdOrDetails.id || deviceIdOrDetails; if (typeof deviceId !== 'string' || !deviceId.length) { - const err = new ErrorInfo( - 'First argument to DeviceRegistrations#get must be a deviceId string or DeviceDetails', - 40000, - 400, - ); - err.hint = - 'Pass either the device id string returned from push.activate(), or the DeviceDetails object (with a non-empty .id field).'; - throw err; + 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 returned from push.activate(), or the DeviceDetails object (with a non-empty .id field).', + }); } Utils.mixin(headers, client.options.headers); @@ -228,14 +237,12 @@ class DeviceRegistrations { deviceId = deviceIdOrDetails.id || deviceIdOrDetails; if (typeof deviceId !== 'string' || !deviceId.length) { - const err = new ErrorInfo( - 'First argument to DeviceRegistrations#remove must be a deviceId string or DeviceDetails', - 40000, - 400, - ); - err.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.'; - throw err; + 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 5da58cf4e6..fd12b95373 100644 --- a/src/common/lib/client/realtimeannotations.ts +++ b/src/common/lib/client/realtimeannotations.ts @@ -71,14 +71,13 @@ class RealtimeAnnotations { // explicit check for attach state in caes attachOnSubscribe=false if ((this.channel.state === 'attached' && this.channel._mode & flags.ANNOTATION_SUBSCRIBE) === 0) { - const err = 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, - ); - err.hint = - 'Re-create the channel with annotation_subscribe in modes: realtime.channels.get(name, { modes: ["subscribe", "annotation_subscribe", ...] }). If the subsequent attach is rejected by the server, the channel namespace must have "Message annotations, updates, and deletes" enabled in the Ably dashboard, and your API key must have annotation-subscribe capability on this channel. If you have the Ably CLI installed, `ably apps rules list` shows which channel namespaces have Mutable Messages enabled, and `ably auth keys list` shows your key\'s capabilities. Note: appending to channel.modes after attach() does not enable the mode server-side — the array reflects what the server granted, not what you requested.'; - throw err; + 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: 'Re-create the channel with annotation_subscribe in modes: realtime.channels.get(name, { modes: ["subscribe", "annotation_subscribe", ...] }). If the subsequent attach is rejected by the server, the channel namespace must have "Message annotations, updates, and deletes" enabled in the Ably dashboard, and your API key must have annotation-subscribe capability on this channel. If you have the Ably CLI installed, `ably apps rules list` shows which channel namespaces have Mutable Messages enabled, and `ably auth keys list` shows your key\'s capabilities. Note: appending to channel.modes after attach() does not enable the mode server-side — the array reflects what the server granted, not what you requested.', + }); } } diff --git a/src/common/lib/client/realtimechannel.ts b/src/common/lib/client/realtimechannel.ts index 92085f1b0c..3bb6316cd1 100644 --- a/src/common/lib/client/realtimechannel.ts +++ b/src/common/lib/client/realtimechannel.ts @@ -33,14 +33,22 @@ interface RealtimeHistoryParams { function validateChannelOptions(options?: API.ChannelOptions) { if (options && 'params' in options && !Utils.isObject(options.params)) { - const err = new ErrorInfo('options.params must be an object', 40000, 400); - err.hint = 'Pass an object map of channel params (e.g. { rewind: "1" }), not a string or array.'; + 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)) { - const err = new ErrorInfo('options.modes must be an array', 40000, 400); - err.hint = 'Pass an array of ChannelMode strings, e.g. { modes: ["publish", "subscribe"] }.'; + 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++) { @@ -50,8 +58,12 @@ function validateChannelOptions(options?: API.ChannelOptions) { typeof currentMode !== 'string' || !channelModes.includes(String.prototype.toUpperCase.call(currentMode)) ) { - const err = new ErrorInfo('Invalid channel mode: ' + currentMode, 40000, 400); - err.hint = `Valid ChannelMode values are: ${channelModes.join(', ').toLowerCase()}. The server also enforces this — your token/API-key capability must permit the requested modes on this channel, otherwise the subsequent attach is rejected. If you have the Ably CLI installed, \`ably auth keys list\` shows your key's capabilities.`; + const err = new ErrorInfo({ + message: 'Invalid channel mode: ' + currentMode, + code: 40000, + statusCode: 400, + hint: `Valid ChannelMode values are: ${channelModes.join(', ').toLowerCase()}. The server also enforces this — your token/API-key capability must permit the requested modes on this channel, otherwise the subsequent attach is rejected. If you have the Ably CLI installed, \`ably auth keys list\` shows your key's capabilities.`, + }); return err; } } @@ -181,14 +193,13 @@ class RealtimeChannel extends EventEmitter { } invalidStateError(): ErrorInfo { - const err = new ErrorInfo( - 'Channel operation failed as channel state is ' + this.state, - 90001, - 400, - this.errorReason || undefined, - ); - err.hint = - 'Inspect channel.errorReason for the underlying cause, then call channel.attach() to recover. From "failed" or "suspended", a fresh attach() is required before further channel operations.'; + 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, then call channel.attach() to recover. From "failed" or "suspended", a fresh attach() is required before further channel operations.', + }); return err; } @@ -282,14 +293,12 @@ class RealtimeChannel extends EventEmitter { messages = Message.fromValuesArray(first); params = args[1]; } else { - const err = new ErrorInfo( - 'The single-argument form of publish() expects a message object or an array of message objects', - 40013, - 400, - ); - err.hint = - 'Call publish(name, data) for a single event, or publish(message | message[]) with a Message-shaped object. If the resulting publish is rejected by the server, your token/API-key capability must include "publish" on this channel. If you have the Ably CLI installed, `ably auth keys list` shows your key\'s capabilities.'; - throw err; + 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. If the resulting publish is rejected by the server, your token/API-key capability must include "publish" on this channel. If you have the Ably CLI installed, `ably auth keys list` shows your key\'s capabilities.', + }); } const maxMessageSize = this.client.options.maxMessageSize; // TODO get rid of CipherOptions type assertion, indicates channeloptions types are broken @@ -297,14 +306,12 @@ class RealtimeChannel extends EventEmitter { /* RSL1i */ const size = getMessagesSize(wireMessages); if (size > maxMessageSize) { - const err = new ErrorInfo( - `Maximum size of messages that can be published at once exceeded (was ${size} bytes; limit is ${maxMessageSize} bytes)`, - 40009, - 400, - ); - err.hint = - 'Split the publish into multiple calls so each batch is under the limit, or contact support to raise maxMessageSize for your app.'; - throw err; + throw new ErrorInfo({ + message: `Maximum size of messages that can be published at once exceeded (was ${size} bytes; limit is ${maxMessageSize} bytes)`, + code: 40009, + statusCode: 400, + hint: 'Split the publish into multiple calls so each batch is under the limit, or contact support to raise maxMessageSize for your app.', + }); } this.throwIfUnpublishableState(); @@ -435,10 +442,12 @@ class RealtimeChannel extends EventEmitter { return; // RTL5b case 'failed': { - const err = new ErrorInfo('Unable to detach; channel state = failed', 90001, 400); - err.hint = - 'A failed channel cannot be detached. Release it via channels.release(name) and call channels.get(name) again to start a fresh channel.'; - throw err; + throw new ErrorInfo({ + message: 'Unable to detach; channel state = failed', + code: 90001, + statusCode: 400, + hint: 'A failed channel cannot be detached. Release it via channels.release(name) and call channels.get(name) again to start a fresh channel.', + }); } default: // RTL5l: if connection is not connected, immediately transition to detached @@ -526,10 +535,11 @@ class RealtimeChannel extends EventEmitter { case 'initialized': case 'detaching': case 'detached': { - const err = new PartialErrorInfo('Unable to sync to channel; not attached', 40000); - err.hint = - 'sync() can only run on an attached or attaching channel. Await channel.attach() (or channel.whenState("attached")) before calling sync().'; - throw err; + throw new PartialErrorInfo({ + message: 'Unable to sync to channel; not attached', + code: 40000, + hint: 'sync() can only run on an attached or attaching channel. Await channel.attach() (or channel.whenState("attached")) before calling sync().', + }); } default: } @@ -974,16 +984,22 @@ class RealtimeChannel extends EventEmitter { timeoutPendingState(): void { switch (this.state) { case 'attaching': { - const err = new ErrorInfo('Channel attach timed out', 90007, 408); - err.hint = - 'The server did not acknowledge the attach within realtimeRequestTimeout. The SDK will retry automatically once the connection is healthy; check connection.state and connection.errorReason.'; + const err = new ErrorInfo({ + message: 'Channel attach timed out', + code: 90007, + statusCode: 408, + hint: 'The server did not acknowledge the attach within realtimeRequestTimeout. The SDK will retry automatically once the connection is healthy; check connection.state and connection.errorReason.', + }); this.notifyState('suspended', err); break; } case 'detaching': { - const err = new ErrorInfo('Channel detach timed out', 90007, 408); - err.hint = - 'The server did not acknowledge the detach within realtimeRequestTimeout. The channel has reverted to attached; retry detach() once the connection is stable.'; + const err = new ErrorInfo({ + message: 'Channel detach timed out', + code: 90007, + statusCode: 408, + hint: 'The server did not acknowledge the detach within realtimeRequestTimeout. The channel has reverted to attached; retry detach() once the connection is stable.', + }); this.notifyState('attached', err); break; } @@ -1056,20 +1072,20 @@ class RealtimeChannel extends EventEmitter { if (params && params.untilAttach) { if (this.state !== 'attached') { - const err = new ErrorInfo('option untilAttach requires the channel to be attached', 40000, 400); - err.hint = - 'Await channel.attach() (or channel.whenState("attached")) before calling history({ untilAttach: true }).'; - throw err; + throw new ErrorInfo({ + message: 'option untilAttach requires the channel to be attached', + code: 40000, + statusCode: 400, + hint: 'Await channel.attach() (or channel.whenState("attached")) before calling history({ untilAttach: true }).', + }); } if (!this.properties.attachSerial) { - const err = new ErrorInfo( - 'untilAttach was specified and channel is attached, but attachSerial is not defined', - 40000, - 400, - ); - err.hint = - 'Re-attach the channel and try again; the SDK could not record an attachSerial from the previous attach.'; - throw err; + throw new ErrorInfo({ + message: 'untilAttach was specified and channel is attached, but attachSerial is not defined', + code: 40000, + statusCode: 400, + hint: 'Re-attach the channel and try again; the SDK could not record an attachSerial from the previous attach.', + }); } delete params.untilAttach; params.from_serial = this.properties.attachSerial; @@ -1088,14 +1104,14 @@ class RealtimeChannel extends EventEmitter { if (s === 'initialized' || s === 'detached' || s === 'failed') { return null; } - const err = 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); was ' + s, - 90001, - 400, - ); - err.hint = - 'Call channel.detach() and wait for the channel to reach "detached" before calling channels.release(name).'; + code: 90001, + statusCode: 400, + hint: 'Call channel.detach() and wait for the channel to reach "detached" before calling channels.release(name).', + }); return err; } @@ -1158,14 +1174,13 @@ class RealtimeChannel extends EventEmitter { params?: Record, ): Promise { if (!message.serial) { - const err = 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, - ); - err.hint = - 'Pass the Message you received from a subscribe callback (which carries .serial), not a freshly constructed object. The namespace must enable message annotations/updates/deletes in the Ably dashboard. If you have the Ably CLI installed, `ably apps rules list` shows which channel namespaces have Mutable Messages enabled.'; - throw err; + throw new ErrorInfo({ + message: + '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.', + code: 40003, + statusCode: 400, + hint: 'Pass the Message you received from a subscribe callback (which carries .serial), not a freshly constructed object. The namespace must enable message annotations/updates/deletes in the Ably dashboard. If you have the Ably CLI installed, `ably apps rules list` shows which channel namespaces have Mutable Messages enabled.', + }); } this.throwIfUnpublishableState(); diff --git a/src/common/lib/client/realtimepresence.ts b/src/common/lib/client/realtimepresence.ts index c502eb7c7c..13ae034921 100644 --- a/src/common/lib/client/realtimepresence.ts +++ b/src/common/lib/client/realtimepresence.ts @@ -62,10 +62,12 @@ class RealtimePresence extends EventEmitter { private async _enterImpl(data: unknown): Promise { if (isAnonymousOrWildcard(this)) { - const err = new ErrorInfo('clientId must be specified to enter a presence channel', 40012, 400); - err.hint = - 'Set ClientOptions.clientId (or include clientId in the token) before calling presence.enter(). To enter on behalf of another identity, use presence.enterClient(clientId, data). If the resulting presence message is rejected by the server, your token/API-key capability must include "presence" on this channel. If you have the Ably CLI installed, `ably auth keys list` shows your key\'s capabilities.'; - throw err; + 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(). To enter on behalf of another identity, use presence.enterClient(clientId, data). If the resulting presence message is rejected by the server, your token/API-key capability must include "presence" on this channel. If you have the Ably CLI installed, `ably auth keys list` shows your key\'s capabilities.', + }); } return this._enterOrUpdateClient(undefined, undefined, data, 'enter'); } @@ -77,10 +79,12 @@ class RealtimePresence extends EventEmitter { private async _updateImpl(data: unknown): Promise { if (isAnonymousOrWildcard(this)) { - const err = new ErrorInfo('clientId must be specified to update presence data', 40012, 400); - err.hint = - 'Set ClientOptions.clientId (or include clientId in the token) before calling presence.update(). To update on behalf of another identity, use presence.updateClient(clientId, data). If the resulting presence message is rejected by the server, your token/API-key capability must include "presence" on this channel. If you have the Ably CLI installed, `ably auth keys list` shows your key\'s capabilities.'; - throw err; + 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(). To update on behalf of another identity, use presence.updateClient(clientId, data). If the resulting presence message is rejected by the server, your token/API-key capability must include "presence" on this channel. If you have the Ably CLI installed, `ably auth keys list` shows your key\'s capabilities.', + }); } return this._enterOrUpdateClient(undefined, undefined, data, 'update'); } @@ -159,10 +163,12 @@ class RealtimePresence extends EventEmitter { private async _leaveImpl(data: unknown): Promise { if (isAnonymousOrWildcard(this)) { - const err = new ErrorInfo('clientId must have been specified to enter or leave a presence channel', 40012, 400); - err.hint = - 'Anonymous clients cannot publish presence. Set ClientOptions.clientId (or include clientId in the token), or use presence.leaveClient(clientId) to leave on behalf of a specific identity. If the resulting leave is rejected by the server, your token/API-key capability must include "presence" on this channel. If you have the Ably CLI installed, `ably auth keys list` shows your key\'s capabilities.'; - throw err; + throw new ErrorInfo({ + message: 'clientId must have been specified to enter or leave a presence channel', + code: 40012, + statusCode: 400, + hint: 'Anonymous clients cannot publish presence. Set ClientOptions.clientId (or include clientId in the token), or use presence.leaveClient(clientId) to leave on behalf of a specific identity. If the resulting leave is rejected by the server, your token/API-key capability must include "presence" on this channel. If you have the Ably CLI installed, `ably auth keys list` shows your key\'s capabilities.', + }); } return this.leaveClient(undefined, data); } @@ -204,14 +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; - err.hint = - 'The channel is in "initialized" or "failed" state, so the client is not currently a presence member. From "initialized" there is no presence entry to leave; from "failed" inspect channel.errorReason and re-attach before retrying.'; - throw err; + throw new PartialErrorInfo({ + message: 'Unable to leave presence channel while in ' + channel.state + ' state', + code: 90001, + hint: 'The channel is in "initialized" or "failed" state, so the client is not currently a presence member. From "initialized" there is no presence entry to leave; from "failed" inspect channel.errorReason and re-attach before retrying.', + }); } } } @@ -231,14 +234,12 @@ class RealtimePresence extends EventEmitter { /* Special-case the suspended state: can still get (stale) presence set if waitForSync is false */ if (this.channel.state === 'suspended') { if (waitForSync) { - const err = ErrorInfo.fromValues({ + throw ErrorInfo.fromValues({ 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.', }); - err.hint = - 'Wait for the channel to reach "attached" before calling presence.get(), or pass { waitForSync: false } to read the last known (stale) members.'; - throw err; } return toMessages(this.members); } @@ -267,14 +268,12 @@ class RealtimePresence extends EventEmitter { delete params.untilAttach; params.from_serial = this.channel.properties.attachSerial; } else { - const err = new ErrorInfo( - 'option untilAttach requires the channel to be attached, was: ' + this.channel.state, - 40000, - 400, - ); - err.hint = - 'Await channel.attach() (or channel.whenState("attached")) before calling presence.history({ untilAttach: true }).'; - throw err; + throw new ErrorInfo({ + message: 'option untilAttach requires the channel to be attached, was: ' + this.channel.state, + code: 40000, + statusCode: 400, + hint: 'Await channel.attach() (or channel.whenState("attached")) before calling presence.history({ untilAttach: true }).', + }); } } @@ -445,9 +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); - wrappedErr.hint = - 'After a connection recovery the SDK could not re-enter this client into presence. Listen for the channel "update" event and call presence.enter(...) again once the channel is attached.'; + const wrappedErr = new ErrorInfo({ + message: 'Presence auto re-enter failed', + code: 91004, + statusCode: 400, + cause: err, + hint: 'After a connection recovery the SDK could not re-enter this client into presence. Listen for the channel "update" event and call presence.enter(...) again once the channel is attached.', + }); Logger.logAction( this.logger, Logger.LOG_ERROR, diff --git a/src/common/lib/client/rest.ts b/src/common/lib/client/rest.ts index a9cced53b6..f9dd66f0ad 100644 --- a/src/common/lib/client/rest.ts +++ b/src/common/lib/client/rest.ts @@ -144,9 +144,12 @@ export class Rest { ); if (!Platform.Http.methods.includes(_method)) { - const err = new ErrorInfo('Unsupported method ' + _method, 40500, 405); - err.hint = `Use one of: ${Platform.Http.methods.join(', ')}.`; - throw err; + 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)) { @@ -214,10 +217,12 @@ export class Rest { options?: TokenRevocationOptions, ): Promise { if (useTokenAuth(this.client.options)) { - const err = new ErrorInfo('Cannot revoke tokens when using token auth', 40162, 401); - err.hint = - 'Token revocation requires basic auth. Construct a separate Ably.Rest client with ClientOptions.key (the API key with the revoke capability) just for this call. If you have the Ably CLI installed, `ably auth keys list` shows which keys have the revoke capability.'; - throw err; + throw new ErrorInfo({ + message: 'Cannot revoke tokens when using token auth', + code: 40162, + statusCode: 401, + hint: 'Token revocation requires basic auth. Construct a separate Ably.Rest client with ClientOptions.key (the API key with the revoke capability) just for this call. If you have the Ably CLI installed, `ably auth keys list` shows which keys have the revoke capability.', + }); } const keyName = this.client.options.keyName!; diff --git a/src/common/lib/client/restannotations.ts b/src/common/lib/client/restannotations.ts index 9bb50a8622..52f93a0fa7 100644 --- a/src/common/lib/client/restannotations.ts +++ b/src/common/lib/client/restannotations.ts @@ -24,14 +24,13 @@ export function serialFromMsgOrSerial(msgOrSerial: string | Message): string { break; } if (!messageSerial || typeof messageSerial !== 'string') { - const err = 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, - ); - err.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.'; - throw err; + throw new ErrorInfo({ + message: + '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)', + 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; } @@ -43,14 +42,12 @@ export function constructValidateAnnotation( const messageSerial = serialFromMsgOrSerial(msgOrSerial); if (!annotationValues || typeof annotationValues !== 'object') { - const err = new ErrorInfo( - 'Second argument of annotations.publish() must be an object (the intended annotation to publish)', - 40003, - 400, - ); - err.hint = - 'Pass an Annotation-shaped object as the second argument, e.g. { type: "reaction:unique.v1", name: "👍" }.'; - throw err; + 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 96e69b7236..0b23cb6fb2 100644 --- a/src/common/lib/client/restchannel.ts +++ b/src/common/lib/client/restchannel.ts @@ -106,14 +106,12 @@ class RestChannel { messages = Message.fromValuesArray(first); params = args[1]; } else { - const err = new ErrorInfo( - 'The single-argument form of publish() expects a message object or an array of message objects', - 40013, - 400, - ); - err.hint = - 'Call publish(name, data) for a single event, or publish(message | message[]) with a Message-shaped object. If the resulting publish is rejected by the server, your token/API-key capability must include "publish" on this channel. If you have the Ably CLI installed, `ably auth keys list` shows your key\'s capabilities.'; - throw err; + 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. If the resulting publish is rejected by the server, your token/API-key capability must include "publish" on this channel. If you have the Ably CLI installed, `ably auth keys list` shows your key\'s capabilities.', + }); } if (!params) { @@ -142,14 +140,12 @@ class RestChannel { const size = getMessagesSize(wireMessages), maxMessageSize = options.maxMessageSize; if (size > maxMessageSize) { - const err = new ErrorInfo( - `Maximum size of messages that can be published at once exceeded (was ${size} bytes; limit is ${maxMessageSize} bytes)`, - 40009, - 400, - ); - err.hint = - 'Split the publish into multiple calls so each batch is under the limit, or contact support to raise maxMessageSize for your app.'; - throw err; + throw new ErrorInfo({ + message: `Maximum size of messages that can be published at once exceeded (was ${size} bytes; limit is ${maxMessageSize} bytes)`, + code: 40009, + statusCode: 400, + hint: 'Split the publish into multiple calls so each batch is under the limit, or contact support to raise maxMessageSize for your app.', + }); } 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 268a28ec79..c85ec9a3ab 100644 --- a/src/common/lib/client/restchannelmixin.ts +++ b/src/common/lib/client/restchannelmixin.ts @@ -64,14 +64,13 @@ export class RestChannelMixin { static async getMessage(channel: RestChannel | RealtimeChannel, serialOrMessage: string | Message): Promise { const serial = typeof serialOrMessage === 'string' ? serialOrMessage : serialOrMessage.serial; if (!serial) { - const err = 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, - ); - err.hint = - 'Pass the Message received from a subscribe callback (which carries .serial), or its serial string. The namespace must enable message annotations/updates/deletes in the Ably dashboard. If you have the Ably CLI installed, `ably apps rules list` shows which channel namespaces have Mutable Messages enabled.'; - throw err; + throw new ErrorInfo({ + message: + 'This message lacks a serial. Make sure you have enabled "Message annotations, updates, and deletes" in channel settings on your dashboard.', + code: 40003, + statusCode: 400, + hint: 'Pass the Message received from a subscribe callback (which carries .serial), or its serial string. The namespace must enable message annotations/updates/deletes in the Ably dashboard. If you have the Ably CLI installed, `ably apps rules list` shows which channel namespaces have Mutable Messages enabled.', + }); } const client = channel.client; @@ -100,14 +99,13 @@ export class RestChannelMixin { params?: Record, ): Promise { if (!message.serial) { - const err = 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, - ); - err.hint = - 'Pass the Message received from a subscribe callback (which carries .serial), not a freshly constructed object. The namespace must enable message annotations/updates/deletes in the Ably dashboard. If you have the Ably CLI installed, `ably apps rules list` shows which channel namespaces have Mutable Messages enabled.'; - throw err; + throw new ErrorInfo({ + message: + '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.', + code: 40003, + statusCode: 400, + hint: 'Pass the Message received from a subscribe callback (which carries .serial), not a freshly constructed object. The namespace must enable message annotations/updates/deletes in the Ably dashboard. If you have the Ably CLI installed, `ably apps rules list` shows which channel namespaces have Mutable Messages enabled.', + }); } const client = channel.client; @@ -145,14 +143,13 @@ export class RestChannelMixin { ): Promise> { const serial = typeof serialOrMessage === 'string' ? serialOrMessage : serialOrMessage.serial; if (!serial) { - const err = 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, - ); - err.hint = - 'Pass the Message received from a subscribe callback (which carries .serial), or its serial string. The namespace must enable message annotations/updates/deletes in the Ably dashboard. If you have the Ably CLI installed, `ably apps rules list` shows which channel namespaces have Mutable Messages enabled.'; - throw err; + throw new ErrorInfo({ + message: + 'This message lacks a serial. Make sure you have enabled "Message annotations, updates, and deletes" in channel settings on your dashboard.', + code: 40003, + statusCode: 400, + hint: 'Pass the Message received from a subscribe callback (which carries .serial), or its serial string. The namespace must enable message annotations/updates/deletes in the Ably dashboard. If you have the Ably CLI installed, `ably apps rules list` shows which channel namespaces have Mutable Messages enabled.', + }); } const client = channel.client; diff --git a/src/common/lib/transport/connectionmanager.ts b/src/common/lib/transport/connectionmanager.ts index b502a5ddb4..2f8383774a 100644 --- a/src/common/lib/transport/connectionmanager.ts +++ b/src/common/lib/transport/connectionmanager.ts @@ -1898,10 +1898,12 @@ class ConnectionManager extends EventEmitter { async ping(): Promise { if (this.state.state !== 'connected') { - const err = new ErrorInfo('Unable to ping service; not connected', 40000, 400); - err.hint = - 'Wait for connection.state to be "connected" before calling ping(). Use await connection.whenState("connected") or connection.once("connected", …).'; - throw err; + 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", …).', + }); } const transport = this.activeProtocol?.getTransport(); @@ -1965,16 +1967,24 @@ class ConnectionManager extends EventEmitter { } else if (err.statusCode === HttpStatusCodes.Forbidden) { const msg = 'Client configured authentication provider returned 403; failing the connection'; Logger.logAction(this.logger, Logger.LOG_ERROR, 'ConnectionManager.actOnErrorFromAuthorize()', msg); - const wrapped = new ErrorInfo(msg, 80019, 403, err); - wrapped.hint = - 'Your authUrl/authCallback returned 403. Fix the auth endpoint: the request reached Ably, but your server refused to mint a token. Inspect cause for the underlying error.'; + const wrapped = new ErrorInfo({ + message: msg, + code: 80019, + statusCode: 403, + cause: err, + hint: 'Your authUrl/authCallback returned 403. Fix the auth endpoint: the request reached Ably, but your server refused to mint a token. Inspect cause for the underlying error.', + }); 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); - const wrapped = new ErrorInfo(msg, 80019, 401, err); - wrapped.hint = - 'Your authUrl/authCallback could not be reached or returned an error. Check network connectivity to the auth endpoint and that it returns a valid token shape; the underlying error is in cause.'; + const wrapped = new ErrorInfo({ + message: msg, + code: 80019, + statusCode: 401, + cause: err, + hint: 'Your authUrl/authCallback could not be reached or returned an error. Check network connectivity to the auth endpoint and that it returns a valid token shape; the underlying error is in cause.', + }); 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 49b7aa7f22..070da5f904 100644 --- a/src/common/lib/types/basemessage.ts +++ b/src/common/lib/types/basemessage.ts @@ -132,12 +132,12 @@ export function encodeData( } // RSL4a, throw an error for unsupported types - { - const err = new ErrorInfo('Data type is unsupported', 40013, 400); - err.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.'; - throw err; - } + 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( @@ -217,24 +217,20 @@ export async function decodeData( } case 'vcdiff': if (!context.plugins || !context.plugins.vcdiff) { - const err = new ErrorInfo( - 'Missing Vcdiff decoder (https://github.com/ably-forks/vcdiff-decoder)', - 40019, - 400, - ); - err.hint = - 'You enabled the delta channel option but did not provide the Vcdiff plugin. Install @ably/vcdiff-decoder and pass it in ClientOptions.plugins.vcdiff.'; - throw err; + throw new ErrorInfo({ + message: 'Missing Vcdiff decoder (https://github.com/ably-forks/vcdiff-decoder)', + code: 40019, + statusCode: 400, + hint: 'You enabled the delta channel option but did not provide the Vcdiff plugin. Install @ably/vcdiff-decoder and pass it in ClientOptions.plugins.vcdiff.', + }); } if (typeof Uint8Array === 'undefined') { - const err = new ErrorInfo( - 'Delta decoding not supported on this browser (need ArrayBuffer & Uint8Array)', - 40020, - 400, - ); - err.hint = - 'Disable channel deltas (do not set delta in channel params) on environments without typed-array support, or upgrade the JavaScript runtime.'; - throw err; + 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; @@ -251,10 +247,12 @@ export async function decodeData( ); lastPayload = decodedData; } catch (e) { - const err = new ErrorInfo('Vcdiff delta decode failed with ' + e, 40018, 400); - err.hint = - 'The SDK recovers by re-attaching without delta. If you see this repeatedly, the base payload has diverged — disable channel deltas for this channel.'; - throw err; + throw new ErrorInfo({ + message: 'Vcdiff delta decode failed with ' + e, + code: 40018, + statusCode: 400, + hint: 'The SDK recovers by re-attaching without delta. If you see this repeatedly, the base payload has diverged — disable channel deltas for this channel.', + }); } continue; default: diff --git a/src/common/lib/util/defaults.ts b/src/common/lib/util/defaults.ts index 728603d207..854db8e9cc 100644 --- a/src/common/lib/util/defaults.ts +++ b/src/common/lib/util/defaults.ts @@ -175,14 +175,20 @@ export function getHosts(options: NormalisedClientOptions): string[] { function checkHost(host: string): void { if (typeof host !== 'string') { - const err = new ErrorInfo('host must be a string; was a ' + typeof host, 40000, 400); - err.hint = 'Pass `endpoint` as a single hostname string (e.g. "main.realtime.ably.net"), not an array or object.'; - throw err; + throw new ErrorInfo({ + message: 'host must be a string; was a ' + typeof host, + code: 40000, + statusCode: 400, + hint: 'Pass `endpoint` as a single hostname string (e.g. "main.realtime.ably.net"), not an array or object.', + }); } if (!host.length) { - const err = new ErrorInfo('host must not be zero-length', 40000, 400); - err.hint = 'Omit `endpoint`/`restHost`/`realtimeHost` to use the Ably default, or pass a non-empty hostname.'; - throw err; + throw new ErrorInfo({ + message: 'host must not be zero-length', + code: 40000, + statusCode: 400, + hint: 'Omit `endpoint`/`restHost`/`realtimeHost` to use the Ably default, or pass a non-empty hostname.', + }); } } @@ -255,27 +261,24 @@ function checkIfClientOptionsAreValid(options: ClientOptions) { // REC1b if (options.endpoint && (options.environment || options.restHost || options.realtimeHost)) { // RSC1b - const err = new ErrorInfo( - 'The `endpoint` option cannot be used in conjunction with the `environment`, `restHost`, or `realtimeHost` options.', - 40106, - 400, - ); - err.hint = - 'Use only `endpoint` (the v2 option). `environment`, `restHost`, and `realtimeHost` are legacy v1 names — remove them from ClientOptions.'; - throw err; + throw new ErrorInfo({ + message: + 'The `endpoint` option cannot be used in conjunction with the `environment`, `restHost`, or `realtimeHost` options.', + code: 40106, + statusCode: 400, + hint: 'Use only `endpoint` (the v2 option). `environment`, `restHost`, and `realtimeHost` are legacy v1 names — remove them from ClientOptions.', + }); } // REC1c if (options.environment && (options.restHost || options.realtimeHost)) { // RSC1b - const err = new ErrorInfo( - 'The `environment` option cannot be used in conjunction with the `restHost`, or `realtimeHost` options.', - 40106, - 400, - ); - err.hint = - 'These are mutually exclusive legacy host options. Replace all of them with the v2 `endpoint` option, which subsumes both.'; - throw err; + throw new ErrorInfo({ + message: 'The `environment` option cannot be used in conjunction with the `restHost`, or `realtimeHost` options.', + code: 40106, + statusCode: 400, + hint: 'These are mutually exclusive legacy host options. Replace all of them with the v2 `endpoint` option, which subsumes both.', + }); } } diff --git a/src/common/lib/util/utils.ts b/src/common/lib/util/utils.ts index cc53d44044..236b34ad60 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.', + code: 40025, + statusCode: 400, + 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', + }); } export function inspectError(err: unknown): string { @@ -470,16 +473,21 @@ export function matchDerivedChannel(name: string) { const regex = /^(\[([^?]*)(?:(.*))\])?(.+)$/; // eslint-disable-line const match = name.match(regex); if (!match || !match.length || match.length < 5) { - const err = new ErrorInfo('regex match failed', 400, 40010); - err.hint = - 'Channel names with derived options must look like "[filter=...]name". See https://ably.com/docs/channels/options/derived.'; - throw err; + throw new ErrorInfo({ + message: 'regex match failed', + code: 400, + statusCode: 40010, + hint: 'Channel names with derived options must look like "[filter=...]name". See https://ably.com/docs/channels/options/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]) { - const err = new ErrorInfo(`cannot use a derived option with a ${match[2]} channel`, 400, 40010); - err.hint = `A "${match[2]}" channel already has a qualifier; derived options like filter= cannot be layered on top. Use a base channel name instead.`; - throw err; + throw new ErrorInfo({ + message: `cannot use a derived option with a ${match[2]} channel`, + code: 400, + statusCode: 40010, + hint: `A "${match[2]}" channel already has a qualifier; derived options like filter= cannot be layered on top. Use a base channel name instead.`, + }); } // Return match values to be added to derive channel quantifier. return { @@ -504,8 +512,12 @@ export function arrEquals(a: any[], b: any[]) { } export function createMissingPluginError(pluginName: keyof ModularPlugins): ErrorInfo { - const err = new ErrorInfo(`${pluginName} plugin not provided`, 40019, 400); - err.hint = `Import ${pluginName} from "ably/modular" and pass it in ClientOptions.plugins: { ${pluginName} }. See https://ably.com/docs/getting-started/modular.`; + const err = new ErrorInfo({ + message: `${pluginName} plugin not provided`, + code: 40019, + statusCode: 400, + hint: `Import ${pluginName} from "ably/modular" and pass it in ClientOptions.plugins: { ${pluginName} }. See https://ably.com/docs/getting-started/modular.`, + }); return err; } @@ -561,10 +573,12 @@ export async function* listenerToAsyncIterator( yield eventQueue.shift()!; } else { if (resolveNext) { - const err = new ErrorInfo('Concurrent next() calls are not supported', 40000, 400); - err.hint = - 'Drive the async iterator from a single for-await-of loop. Calling next() twice concurrently is not supported.'; - throw err; + 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. Calling next() twice concurrently is not supported.', + }); } // Otherwise wait for the next event to arrive diff --git a/src/plugins/liveobjects/realtimeobject.ts b/src/plugins/liveobjects/realtimeobject.ts index c42a8ca0b8..61b91a6b55 100644 --- a/src/plugins/liveobjects/realtimeobject.ts +++ b/src/plugins/liveobjects/realtimeobject.ts @@ -568,23 +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)) { - const err = new this._client.ErrorInfo( - `"${expectedMode}" channel mode must be set for this operation`, - 40024, - 400, - ); // RTO2a2 - err.hint = `Re-create the channel with "${expectedMode}" in modes: realtime.channels.get(name, { modes: [..., "${expectedMode}"] }). If the subsequent attach is rejected by the server, the channel namespace must have LiveObjects enabled in the Ably dashboard, and your API key must have the corresponding capability on this channel. If you have the Ably CLI installed, \`ably apps rules list\` shows channel-namespace settings and \`ably auth keys list\` shows your key's capabilities. Note: appending to channel.modes after attach() does not enable the mode server-side — the array reflects what the server granted, not what you requested.`; - throw err; + throw new this._client.ErrorInfo({ + message: `"${expectedMode}" channel mode must be set for this operation`, + code: 40024, + statusCode: 400, + hint: `Re-create the channel with "${expectedMode}" in modes: realtime.channels.get(name, { modes: [..., "${expectedMode}"] }). If the subsequent attach is rejected by the server, the channel namespace must have LiveObjects enabled in the Ably dashboard, and your API key must have the corresponding capability on this channel. If you have the Ably CLI installed, \`ably apps rules list\` shows channel-namespace settings and \`ably auth keys list\` shows your key's capabilities. Note: appending to channel.modes after attach() does not enable the mode server-side — the array reflects what the server granted, not what you requested.`, + }); } // RTO2b - otherwise as a best effort use user provided channel options if (!this._client.Utils.allToLowerCase(this._channel.channelOptions.modes ?? []).includes(expectedMode)) { - const err = new this._client.ErrorInfo( - `"${expectedMode}" channel mode must be set for this operation`, - 40024, - 400, - ); // RTO2b2 - err.hint = `Re-create the channel with "${expectedMode}" in modes: realtime.channels.get(name, { modes: [..., "${expectedMode}"] }). If the subsequent attach is rejected by the server, the channel namespace must have LiveObjects enabled in the Ably dashboard, and your API key must have the corresponding capability on this channel. If you have the Ably CLI installed, \`ably apps rules list\` shows channel-namespace settings and \`ably auth keys list\` shows your key's capabilities.`; - throw err; + throw new this._client.ErrorInfo({ + message: `"${expectedMode}" channel mode must be set for this operation`, + code: 40024, + statusCode: 400, + hint: `Re-create the channel with "${expectedMode}" in modes: realtime.channels.get(name, { modes: [..., "${expectedMode}"] }). If the subsequent attach is rejected by the server, the channel namespace must have LiveObjects enabled in the Ably dashboard, and your API key must have the corresponding capability on this channel. If you have the Ably CLI installed, \`ably apps rules list\` shows channel-namespace settings and \`ably auth keys list\` shows your key's capabilities.`, + }); } } diff --git a/src/plugins/push/getW3CDeviceDetails.ts b/src/plugins/push/getW3CDeviceDetails.ts index 2767bfd298..76f5813558 100644 --- a/src/plugins/push/getW3CDeviceDetails.ts +++ b/src/plugins/push/getW3CDeviceDetails.ts @@ -28,18 +28,24 @@ export async function getW3CPushDeviceDetails(machine: ActivationStateMachine) { const permission = await Notification.requestPermission(); if (permission !== 'granted') { - const err = new ErrorInfo('User denied permission to send notifications', 40000, 400); - err.hint = - 'The browser denied the Notification permission prompt. The user must accept notifications before push activation can complete; surface a UI explaining the value before requesting again.'; + const err = new ErrorInfo({ + message: 'User denied permission to send notifications', + code: 40000, + statusCode: 400, + hint: 'The browser denied the Notification permission prompt. The user must accept notifications before push activation can complete; surface a UI explaining the value before requesting again.', + }); machine.handleEvent(new GettingPushDeviceDetailsFailed(err)); return; } const swUrl = machine.client.options.pushServiceWorkerUrl; if (!swUrl) { - const err = new ErrorInfo('Missing ClientOptions.pushServiceWorkerUrl', 40000, 400); - err.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.'; + 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 59900d95b6..54914b71c2 100644 --- a/src/plugins/push/pushactivation.ts +++ b/src/plugins/push/pushactivation.ts @@ -9,6 +9,9 @@ import { getW3CPushDeviceDetails } from './getW3CDeviceDetails'; import type BaseClient from 'common/lib/client/baseclient'; import type { PaginatedResult } from 'common/lib/client/paginatedresource'; +const PUSH_NOT_AVAILABLE_HINT = + 'push.activate() registers this process as a push target — it cannot succeed in Node.js/server contexts (there is no device to register). Use client.push.admin to manage other devices from a server: client.push.admin.publish(recipient, payload) to send to a device or clientId, client.push.admin.deviceRegistrations.save(device) to register a device record.'; + const persistKeys = { deviceId: 'ably.push.deviceId', deviceSecret: 'ably.push.deviceSecret', @@ -62,19 +65,21 @@ export function localDeviceFactory(deviceDetails: typeof DeviceDetails) { async listSubscriptions(): Promise> { const Platform = this.rest.Platform; if (!Platform.Config.push) { - { - const err = new this.rest.ErrorInfo('Push activation is not available on this platform', 40000, 400); - err.hint = - 'push.activate() registers this process as a push target — it cannot succeed in Node.js/server contexts (there is no device to register). Use client.push.admin to manage other devices from a server: client.push.admin.publish(recipient, payload) to send to a device or clientId, client.push.admin.deviceRegistrations.save(device) to register a device record.'; - throw err; - } + throw new this.rest.ErrorInfo({ + message: 'Push activation is not available on this platform', + code: 40000, + statusCode: 400, + hint: PUSH_NOT_AVAILABLE_HINT, + }); } if (!this.id) { - const err = new this.rest.ErrorInfo('Device not activated', 40000, 400); - err.hint = - 'Call client.push.activate(registerCallback) and await its completion before listing subscriptions or other device-scoped operations.'; - throw err; + throw new this.rest.ErrorInfo({ + message: 'Device not activated', + code: 40000, + statusCode: 400, + hint: 'Call client.push.activate(registerCallback) and await its completion before listing subscriptions or other device-scoped operations.', + }); } if (!this.deviceIdentityToken) { @@ -104,12 +109,12 @@ export function localDeviceFactory(deviceDetails: typeof DeviceDetails) { loadPersisted() { const Platform = this.rest.Platform; if (!Platform.Config.push) { - { - const err = new this.rest.ErrorInfo('Push activation is not available on this platform', 40000, 400); - err.hint = - 'push.activate() registers this process as a push target — it cannot succeed in Node.js/server contexts (there is no device to register). Use client.push.admin to manage other devices from a server: client.push.admin.publish(recipient, payload) to send to a device or clientId, client.push.admin.deviceRegistrations.save(device) to register a device record.'; - throw err; - } + throw new this.rest.ErrorInfo({ + message: 'Push activation is not available on this platform', + code: 40000, + statusCode: 400, + hint: PUSH_NOT_AVAILABLE_HINT, + }); } this.platform = Platform.Config.push.platform; this.clientId = this.rest.auth.clientId ?? undefined; @@ -130,12 +135,12 @@ export function localDeviceFactory(deviceDetails: typeof DeviceDetails) { persist() { const config = this.rest.Platform.Config; if (!config.push) { - { - const err = new this.rest.ErrorInfo('Push activation is not available on this platform', 40000, 400); - err.hint = - 'push.activate() registers this process as a push target — it cannot succeed in Node.js/server contexts (there is no device to register). Use client.push.admin to manage other devices from a server: client.push.admin.publish(recipient, payload) to send to a device or clientId, client.push.admin.deviceRegistrations.save(device) to register a device record.'; - throw err; - } + throw new this.rest.ErrorInfo({ + message: 'Push activation is not available on this platform', + code: 40000, + statusCode: 400, + hint: PUSH_NOT_AVAILABLE_HINT, + }); } if (this.id) { config.push.storage.set(persistKeys.deviceId, this.id); @@ -211,14 +216,12 @@ export class ActivationStateMachine { get pushConfig() { if (!this._pushConfig) { - const err = new this.client.ErrorInfo( - 'This platform is not supported as a target of push notifications', - 40000, - 400, - ); - err.hint = - 'push.activate() registers this process as a push target — it cannot succeed in Node.js/server contexts (there is no device to register). Use client.push.admin to manage other devices from a server: client.push.admin.publish(recipient, payload) to send to a device or clientId, client.push.admin.deviceRegistrations.save(device) to register a device record.'; - throw err; + throw new this.client.ErrorInfo({ + message: 'This platform is not supported as a target of push notifications', + code: 40000, + statusCode: 400, + hint: PUSH_NOT_AVAILABLE_HINT, + }); } return this._pushConfig; } @@ -254,9 +257,12 @@ export class ActivationStateMachine { } if (!deviceRegistration) { - const err = new this.client.ErrorInfo('registerCallback did not return deviceRegistration', 40000, 400); - err.hint = - 'Your registerCallback must invoke its callback with (null, deviceRegistration). Returning undefined or null in the second argument fails activation.'; + 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). Returning undefined or null in the second argument fails activation.', + }); this.handleEvent(new GettingDeviceRegistrationFailed(err)); return; } diff --git a/src/plugins/push/pushchannel.ts b/src/plugins/push/pushchannel.ts index dca7b7d6ca..cae945520e 100644 --- a/src/plugins/push/pushchannel.ts +++ b/src/plugins/push/pushchannel.ts @@ -50,10 +50,12 @@ class PushChannel { const client = this.client; const clientId = this.client.auth.clientId; if (!clientId) { - const err = new this.client.ErrorInfo('Cannot subscribe from client without client ID', 50000, 500); - err.hint = - 'Set ClientOptions.clientId (or include clientId in the token) before calling pushChannel.subscribeClient(). Anonymous clients cannot subscribe to push by clientId.'; - throw err; + throw new this.client.ErrorInfo({ + message: 'Cannot subscribe from client without client ID', + code: 50000, + statusCode: 500, + hint: 'Set ClientOptions.clientId (or include clientId in the token) before calling pushChannel.subscribeClient(). Anonymous clients cannot subscribe to push by clientId.', + }); } const format = client.options.useBinaryProtocol ? client.Utils.Format.msgpack : client.Utils.Format.json, body = { clientId: clientId, channel: this.channel.name }, @@ -70,10 +72,12 @@ class PushChannel { const clientId = this.client.auth.clientId; if (!clientId) { - const err = new this.client.ErrorInfo('Cannot unsubscribe from client without client ID', 50000, 500); - err.hint = - 'Set ClientOptions.clientId (or include clientId in the token) before calling pushChannel.unsubscribeClient().'; - throw err; + throw new this.client.ErrorInfo({ + message: 'Cannot unsubscribe from client without client ID', + code: 50000, + statusCode: 500, + hint: 'Set ClientOptions.clientId (or include clientId in the token) before calling pushChannel.unsubscribeClient().', + }); } const format = client.options.useBinaryProtocol ? client.Utils.Format.msgpack : client.Utils.Format.json, headers = client.Defaults.defaultPostHeaders(client.options); @@ -111,10 +115,12 @@ class PushChannel { if (deviceIdentityToken) { return deviceIdentityToken; } else { - const err = new this.client.ErrorInfo('Cannot subscribe from client without deviceIdentityToken', 50000, 500); - err.hint = - 'Activate this device first by awaiting client.push.activate(registerCallback) — the device must hold an identity token before subscribing to push.'; - throw err; + throw new this.client.ErrorInfo({ + message: 'Cannot subscribe from client without deviceIdentityToken', + code: 50000, + statusCode: 500, + hint: 'Activate this device first by awaiting client.push.activate(registerCallback) — the device must hold an identity token before subscribing to push.', + }); } } From 5bee6d5dced188fd04e3460de226aebb18910cff Mon Sep 17 00:00:00 2001 From: umair Date: Thu, 28 May 2026 10:49:50 +0100 Subject: [PATCH 08/20] DX-1209: tighten hint and message content per PR #2233 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address AndyTWF's substantive feedback on hint wording, factual accuracy, and over-prescriptive server-rejection forecasts. Wording (declarative tone, drop docs-style imperative): - auth.ts authUrl Content-Type missing/unacceptable hints: switch from "Have your auth endpoint return..." to "Auth endpoints may return..." - auth.ts unacceptable Content-Type message: "should be either" -> "Expected one of" - "mint" word replaced with "construct" / removed in auth.ts hints Factual corrections: - auth.ts authUrl/token-too-large hints: acknowledge that a TokenDetails can legitimately be large (big capability block); soften the accusation that the endpoint is wrapping the token in extra JSON. - auth.ts authCallback timeout hint: authUrl does not invoke a callback and authCallback cannot return a promise; reword to differentiate the two paths. - auth.ts double-encoded token: trim the docs-style suffix off the message, expand the hint with the JSON-vs-JWT diagnosis. (D1) - connectionmanager.ts authorize 403 hint: per RSA4d the Ably server itself can return 403 refusing to mint a TokenDetails from a TokenRequest; broaden the hint beyond just authUrl/authCallback. - baserealtime.ts channels.get options hint: per RTS3c, channels.get with options is deprecated; rewrite to point at channel.setOptions() only. - realtimechannel.ts invalidStateError suspended branch: "suspended" recovers automatically once the connection is re-established; only "failed" needs an explicit attach() call. - realtimechannel.ts/restchannel.ts publish: drop the capability-forecast tail ("capability must include 'publish'"); there are many reasons a publish can be rejected, not just caps. - realtimechannel.ts sync(): drop the hint entirely — sync() is an internal SDK method, no user/LLM action is applicable. - realtimepresence.ts enter/update/leave: drop the generic presence-capability forecast; surface the wildcard-clientId requirement when using enterClient/updateClient/leaveClient on behalf of a different identity. - realtimeannotations.ts and realtimeobject.ts (×2): "namespace must have X enabled" reads as both an obligation and a cause; reword to "check that the namespace has X enabled" so it's unambiguously a hypothesis the user verifies. - defaults.ts host-not-string hint: change example from "main.realtime.ably.net" (a host) to "main" (an endpoint name). - push.ts / pushactivation.ts PUSH_NOT_AVAILABLE_HINT: lead with the supported platforms (browsers with service-worker support) before naming the unsupported context (Node.js/server). - utils.ts derived-channel regex: replace the placeholder "regex match failed" message with a useful description, and swap the transposed code/statusCode args (40010, 400 not 400, 40010) so href auto-populates correctly under the new ErrorInfo overload. No new throw sites added; no semantic behaviour change. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/common/lib/client/auth.ts | 27 +++++++++---------- src/common/lib/client/baserealtime.ts | 2 +- src/common/lib/client/push.ts | 2 +- src/common/lib/client/realtimeannotations.ts | 2 +- src/common/lib/client/realtimechannel.ts | 6 ++--- src/common/lib/client/realtimepresence.ts | 6 ++--- src/common/lib/client/restchannel.ts | 2 +- src/common/lib/transport/connectionmanager.ts | 2 +- src/common/lib/util/defaults.ts | 2 +- src/common/lib/util/utils.ts | 10 +++---- src/plugins/liveobjects/realtimeobject.ts | 4 +-- src/plugins/push/pushactivation.ts | 2 +- 12 files changed, 33 insertions(+), 34 deletions(-) diff --git a/src/common/lib/client/auth.ts b/src/common/lib/client/auth.ts index 60899e7bdd..ff4a9aba16 100644 --- a/src/common/lib/client/auth.ts +++ b/src/common/lib/client/auth.ts @@ -500,7 +500,7 @@ class Auth { message: 'authUrl response is missing a content-type header', code: 40170, statusCode: 401, - hint: 'Have your auth endpoint return a Content-Type of application/json (TokenDetails/TokenRequest), text/plain (token string) or application/jwt.', + hint: 'Auth endpoints may return a Content-Type of application/json (TokenDetails/TokenRequest), text/plain (token string) or application/jwt.', }); cb(err, null); return; @@ -510,12 +510,12 @@ class Auth { if (!json && !text) { const err = new ErrorInfo({ message: - 'authUrl responded with unacceptable content-type ' + + 'authUrl responded with unacceptable Content-Type ' + contentType + - ', should be either text/plain, application/jwt or application/json', + '. Expected one of: text/plain, application/jwt or application/json', code: 40170, statusCode: 401, - hint: 'Update your auth endpoint to return Content-Type application/json, text/plain or application/jwt — the SDK cannot parse other content types.', + hint: 'Auth endpoints may return a Content-Type of application/json (TokenDetails/TokenRequest), text/plain (token string) or application/jwt; the SDK cannot parse other content types.', }); cb(err, null); return; @@ -526,7 +526,7 @@ class Auth { message: 'authUrl response exceeded max permitted length', code: 40170, statusCode: 401, - hint: 'authUrl payloads must be under 128 KB. Your endpoint is returning more than a TokenDetails/TokenRequest object — return only the token shape, not wrapping JSON or extra fields.', + hint: 'authUrl payloads must be under 128 KB. If your TokenDetails legitimately contains a large capability, trim unused fields or set authOptions.suppressMaxLengthCheck. Otherwise check the endpoint is returning only the token shape, not wrapped in extra JSON.', }); cb(err, null); return; @@ -659,7 +659,7 @@ class Auth { message: msg, code: 40170, statusCode: 401, - hint: 'Your authCallback/authUrl did not respond in time. Make sure the callback invokes its callback parameter (or resolves its promise) on every code path.', + hint: 'authCallback did not invoke its callback within the timeout, or authUrl did not respond. Check that the callback runs to completion on every code path and that authUrl is reachable.', }); reject(err); }, timeoutLength); @@ -693,7 +693,7 @@ class Auth { message: 'Token string exceeded max permitted length (was ' + tokenRequestOrDetails.length + ' bytes)', code: 40170, statusCode: 401, - hint: 'Tokens must be under 128 KB. Your endpoint is returning more than the token itself — return only the token string or a TokenDetails object.', + hint: 'Tokens must be under 128 KB. If the TokenDetails legitimately contains a large capability, trim unused fields. Otherwise check the endpoint is returning only the token, not wrapped in extra data.', }); reject(err); } else if (tokenRequestOrDetails === 'undefined' || tokenRequestOrDetails === 'null') { @@ -710,11 +710,10 @@ class Auth { !(contentType && contentType.indexOf('application/jwt') > -1) ) { const err = new ErrorInfo({ - message: - "Token was double-encoded; make sure you're not JSON-encoding an already encoded token request or details", + message: 'Token was double-encoded', code: 40170, statusCode: 401, - hint: 'Return TokenDetails/TokenRequest as an object (not a JSON-encoded string), or set the response Content-Type to application/jwt for JWT tokens.', + hint: 'The endpoint returned a JSON-encoded string starting with `{`, but the Content-Type was not application/jwt — likely a stringified TokenDetails. Return TokenDetails/TokenRequest as a parsed object, or set Content-Type: application/jwt for JWT tokens.', }); reject(err); } else { @@ -838,7 +837,7 @@ class Auth { message: 'No key specified', code: 40101, statusCode: 403, - hint: 'createTokenRequest needs an API key. Pass ClientOptions.key on the client or { key } in the authOptions argument. Token-auth clients cannot mint token requests themselves.', + hint: 'createTokenRequest needs an API key. Pass ClientOptions.key on the client or { key } in the authOptions argument. Token-auth clients cannot construct token requests themselves.', }); } const keyParts = key.split(':'), @@ -859,7 +858,7 @@ class Auth { message: 'clientId can’t be an empty string', code: 40012, statusCode: 400, - hint: 'Pass a non-empty clientId, or omit the field entirely to mint an anonymous token.', + hint: 'Pass a non-empty clientId, or omit the field entirely for an anonymous token.', }); } @@ -985,7 +984,7 @@ class Auth { 'Mismatch between clientId in token (' + token.clientId + ') and current clientId (' + this.clientId + ')', code: 40102, statusCode: 403, - hint: 'Mint the token with the same clientId as ClientOptions.clientId, or omit ClientOptions.clientId and let the token define it. The two cannot diverge.', + hint: 'Issue the token with the same clientId as ClientOptions.clientId, or omit ClientOptions.clientId and let the token define it. The two cannot diverge.', }); } /* RSA4b1 -- if we have a server time offset set already, we can @@ -1078,7 +1077,7 @@ class Auth { message: msg, code: 40102, statusCode: 401, - hint: 'A clientId from the token does not match ClientOptions.clientId. Mint the token with the matching clientId, or omit ClientOptions.clientId and let the token define it.', + hint: 'A clientId from the token does not match ClientOptions.clientId. 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; diff --git a/src/common/lib/client/baserealtime.ts b/src/common/lib/client/baserealtime.ts index c448eb9cc2..923c8df267 100644 --- a/src/common/lib/client/baserealtime.ts +++ b/src/common/lib/client/baserealtime.ts @@ -204,7 +204,7 @@ class Channels extends EventEmitter { 'Channels.get() cannot be used to set channel options that would cause the channel to reattach. Please, use RealtimeChannel.setOptions() instead.', code: 40000, statusCode: 400, - hint: 'channels.get(name) returns the existing channel — call channel.setOptions(opts) to change params/modes (this triggers a re-attach if modes or params changed). Only the first channels.get() for a name applies options without a re-attach.', + hint: 'channels.get(name) returns the existing channel — to change params or modes, call channel.setOptions(opts) on the returned instance (it will re-attach if the new options differ from current).', }); } channel.setOptions(channelOptions); diff --git a/src/common/lib/client/push.ts b/src/common/lib/client/push.ts index d75c8611b4..becf2b68b2 100644 --- a/src/common/lib/client/push.ts +++ b/src/common/lib/client/push.ts @@ -16,7 +16,7 @@ import Platform from 'common/platform'; import type { ErrCallback } from 'common/types/utils'; const PUSH_NOT_AVAILABLE_HINT = - 'push.activate() registers this process as a push target — it cannot succeed in Node.js/server contexts (there is no device to register). Use client.push.admin to manage other devices from a server: client.push.admin.publish(recipient, payload) to send to a device or clientId, client.push.admin.deviceRegistrations.save(device) to register a device record.'; + 'push.activate() registers the current process as a push target — supported in browser environments with service-worker support. In Node.js or other server contexts there is no device to register; use client.push.admin to manage other devices from a server: client.push.admin.publish(recipient, payload) to send to a device or clientId, client.push.admin.deviceRegistrations.save(device) to register a device record.'; class Push { client: BaseClient; diff --git a/src/common/lib/client/realtimeannotations.ts b/src/common/lib/client/realtimeannotations.ts index fd12b95373..97f256c170 100644 --- a/src/common/lib/client/realtimeannotations.ts +++ b/src/common/lib/client/realtimeannotations.ts @@ -76,7 +76,7 @@ class RealtimeAnnotations { "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: 'Re-create the channel with annotation_subscribe in modes: realtime.channels.get(name, { modes: ["subscribe", "annotation_subscribe", ...] }). If the subsequent attach is rejected by the server, the channel namespace must have "Message annotations, updates, and deletes" enabled in the Ably dashboard, and your API key must have annotation-subscribe capability on this channel. If you have the Ably CLI installed, `ably apps rules list` shows which channel namespaces have Mutable Messages enabled, and `ably auth keys list` shows your key\'s capabilities. Note: appending to channel.modes after attach() does not enable the mode server-side — the array reflects what the server granted, not what you requested.', + hint: 'Re-create the channel with annotation_subscribe in modes: realtime.channels.get(name, { modes: ["subscribe", "annotation_subscribe", ...] }). If the subsequent attach is rejected by the server, check that the channel namespace has "Message annotations, updates, 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 channel namespaces have Mutable Messages enabled, and `ably auth keys list` shows your key\'s capabilities. Note: appending to channel.modes after attach() does not enable the mode server-side — the array reflects what the server granted, not what you requested.', }); } } diff --git a/src/common/lib/client/realtimechannel.ts b/src/common/lib/client/realtimechannel.ts index 3bb6316cd1..230fb50570 100644 --- a/src/common/lib/client/realtimechannel.ts +++ b/src/common/lib/client/realtimechannel.ts @@ -198,7 +198,7 @@ class RealtimeChannel extends EventEmitter { code: 90001, statusCode: 400, cause: this.errorReason || undefined, - hint: 'Inspect channel.errorReason for the underlying cause, then call channel.attach() to recover. From "failed" or "suspended", a fresh attach() is required before further channel operations.', + hint: 'Inspect channel.errorReason for the underlying cause. From "failed", call channel.attach() to recover; "suspended" recovers automatically once the underlying connection is re-established.', }); return err; } @@ -297,7 +297,7 @@ class RealtimeChannel extends EventEmitter { 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. If the resulting publish is rejected by the server, your token/API-key capability must include "publish" on this channel. If you have the Ably CLI installed, `ably auth keys list` shows your key\'s capabilities.', + hint: 'Call publish(name, data) for a single event, or publish(message | message[]) with a Message-shaped object.', }); } const maxMessageSize = this.client.options.maxMessageSize; @@ -535,10 +535,10 @@ class RealtimeChannel extends EventEmitter { case 'initialized': case 'detaching': 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, - hint: 'sync() can only run on an attached or attaching channel. Await channel.attach() (or channel.whenState("attached")) before calling sync().', }); } default: diff --git a/src/common/lib/client/realtimepresence.ts b/src/common/lib/client/realtimepresence.ts index 13ae034921..253c07f6af 100644 --- a/src/common/lib/client/realtimepresence.ts +++ b/src/common/lib/client/realtimepresence.ts @@ -66,7 +66,7 @@ class RealtimePresence extends EventEmitter { 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(). To enter on behalf of another identity, use presence.enterClient(clientId, data). If the resulting presence message is rejected by the server, your token/API-key capability must include "presence" on this channel. If you have the Ably CLI installed, `ably auth keys list` shows your key\'s capabilities.', + hint: 'Set ClientOptions.clientId (or include clientId in the token) before calling presence.enter(). To enter on behalf of another identity, use presence.enterClient(otherId, data) — this requires a wildcard clientId on your API key/token, otherwise the server rejects the request.', }); } return this._enterOrUpdateClient(undefined, undefined, data, 'enter'); @@ -83,7 +83,7 @@ class RealtimePresence extends EventEmitter { 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(). To update on behalf of another identity, use presence.updateClient(clientId, data). If the resulting presence message is rejected by the server, your token/API-key capability must include "presence" on this channel. If you have the Ably CLI installed, `ably auth keys list` shows your key\'s capabilities.', + hint: 'Set ClientOptions.clientId (or include clientId in the token) before calling presence.update(). To update on behalf of another identity, use presence.updateClient(otherId, data) — this requires a wildcard clientId on your API key/token, otherwise the server rejects the request.', }); } return this._enterOrUpdateClient(undefined, undefined, data, 'update'); @@ -167,7 +167,7 @@ class RealtimePresence extends EventEmitter { message: 'clientId must have been specified to enter or leave a presence channel', code: 40012, statusCode: 400, - hint: 'Anonymous clients cannot publish presence. Set ClientOptions.clientId (or include clientId in the token), or use presence.leaveClient(clientId) to leave on behalf of a specific identity. If the resulting leave is rejected by the server, your token/API-key capability must include "presence" on this channel. If you have the Ably CLI installed, `ably auth keys list` shows your key\'s capabilities.', + hint: 'Anonymous clients cannot publish presence. Set ClientOptions.clientId (or include clientId in the token), or use presence.leaveClient(otherId) — leaveClient on behalf of a different identity requires a wildcard clientId on your API key/token.', }); } return this.leaveClient(undefined, data); diff --git a/src/common/lib/client/restchannel.ts b/src/common/lib/client/restchannel.ts index 0b23cb6fb2..a1ee8af59b 100644 --- a/src/common/lib/client/restchannel.ts +++ b/src/common/lib/client/restchannel.ts @@ -110,7 +110,7 @@ class RestChannel { 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. If the resulting publish is rejected by the server, your token/API-key capability must include "publish" on this channel. If you have the Ably CLI installed, `ably auth keys list` shows your key\'s capabilities.', + hint: 'Call publish(name, data) for a single event, or publish(message | message[]) with a Message-shaped object.', }); } diff --git a/src/common/lib/transport/connectionmanager.ts b/src/common/lib/transport/connectionmanager.ts index 2f8383774a..9c96640923 100644 --- a/src/common/lib/transport/connectionmanager.ts +++ b/src/common/lib/transport/connectionmanager.ts @@ -1972,7 +1972,7 @@ class ConnectionManager extends EventEmitter { code: 80019, statusCode: 403, cause: err, - hint: 'Your authUrl/authCallback returned 403. Fix the auth endpoint: the request reached Ably, but your server refused to mint a token. Inspect cause for the underlying error.', + hint: 'Token authorization was refused with 403. This can be your authUrl/authCallback rejecting the request, or the Ably server refusing the resulting TokenRequest (e.g. when the request asks for capabilities outside the API key). Inspect cause for the underlying error.', }); this.notifyState({ state: 'failed', error: wrapped }); } else { diff --git a/src/common/lib/util/defaults.ts b/src/common/lib/util/defaults.ts index 854db8e9cc..16f3941bfd 100644 --- a/src/common/lib/util/defaults.ts +++ b/src/common/lib/util/defaults.ts @@ -179,7 +179,7 @@ function checkHost(host: string): void { message: 'host must be a string; was a ' + typeof host, code: 40000, statusCode: 400, - hint: 'Pass `endpoint` as a single hostname string (e.g. "main.realtime.ably.net"), not an array or object.', + hint: 'Pass `endpoint` as a single endpoint name string (e.g. "main"), not an array or object.', }); } if (!host.length) { diff --git a/src/common/lib/util/utils.ts b/src/common/lib/util/utils.ts index 236b34ad60..4e13f3e188 100644 --- a/src/common/lib/util/utils.ts +++ b/src/common/lib/util/utils.ts @@ -474,9 +474,9 @@ export function matchDerivedChannel(name: string) { const match = name.match(regex); if (!match || !match.length || match.length < 5) { throw new ErrorInfo({ - message: 'regex match failed', - code: 400, - statusCode: 40010, + message: 'Channel name does not match the [filter=...]name shape required for derived channels', + code: 40010, + statusCode: 400, hint: 'Channel names with derived options must look like "[filter=...]name". See https://ably.com/docs/channels/options/derived.', }); } @@ -484,8 +484,8 @@ export function matchDerivedChannel(name: string) { if (match![2]) { throw new ErrorInfo({ message: `cannot use a derived option with a ${match[2]} channel`, - code: 400, - statusCode: 40010, + code: 40010, + statusCode: 400, hint: `A "${match[2]}" channel already has a qualifier; derived options like filter= cannot be layered on top. Use a base channel name instead.`, }); } diff --git a/src/plugins/liveobjects/realtimeobject.ts b/src/plugins/liveobjects/realtimeobject.ts index 61b91a6b55..7ae8e6a026 100644 --- a/src/plugins/liveobjects/realtimeobject.ts +++ b/src/plugins/liveobjects/realtimeobject.ts @@ -572,7 +572,7 @@ export class RealtimeObject { message: `"${expectedMode}" channel mode must be set for this operation`, code: 40024, statusCode: 400, - hint: `Re-create the channel with "${expectedMode}" in modes: realtime.channels.get(name, { modes: [..., "${expectedMode}"] }). If the subsequent attach is rejected by the server, the channel namespace must have LiveObjects enabled in the Ably dashboard, and your API key must have the corresponding capability on this channel. If you have the Ably CLI installed, \`ably apps rules list\` shows channel-namespace settings and \`ably auth keys list\` shows your key's capabilities. Note: appending to channel.modes after attach() does not enable the mode server-side — the array reflects what the server granted, not what you requested.`, + hint: `Re-create the channel with "${expectedMode}" in modes: realtime.channels.get(name, { modes: [..., "${expectedMode}"] }). If the subsequent attach is rejected by the server, check that the channel namespace has LiveObjects enabled in the Ably dashboard and that your API key has the corresponding capability on this channel. If you have the Ably CLI installed, \`ably apps rules list\` shows channel-namespace settings and \`ably auth keys list\` shows your key's capabilities. Note: appending to channel.modes after attach() does not enable the mode server-side — the array reflects what the server granted, not what you requested.`, }); } // RTO2b - otherwise as a best effort use user provided channel options @@ -581,7 +581,7 @@ export class RealtimeObject { message: `"${expectedMode}" channel mode must be set for this operation`, code: 40024, statusCode: 400, - hint: `Re-create the channel with "${expectedMode}" in modes: realtime.channels.get(name, { modes: [..., "${expectedMode}"] }). If the subsequent attach is rejected by the server, the channel namespace must have LiveObjects enabled in the Ably dashboard, and your API key must have the corresponding capability on this channel. If you have the Ably CLI installed, \`ably apps rules list\` shows channel-namespace settings and \`ably auth keys list\` shows your key's capabilities.`, + hint: `Re-create the channel with "${expectedMode}" in modes: realtime.channels.get(name, { modes: [..., "${expectedMode}"] }). If the subsequent attach is rejected by the server, check that the channel namespace has LiveObjects enabled in the Ably dashboard and that your API key has the corresponding capability on this channel. If you have the Ably CLI installed, \`ably apps rules list\` shows channel-namespace settings and \`ably auth keys list\` shows your key's capabilities.`, }); } } diff --git a/src/plugins/push/pushactivation.ts b/src/plugins/push/pushactivation.ts index 54914b71c2..24908b8ce1 100644 --- a/src/plugins/push/pushactivation.ts +++ b/src/plugins/push/pushactivation.ts @@ -10,7 +10,7 @@ import type BaseClient from 'common/lib/client/baseclient'; import type { PaginatedResult } from 'common/lib/client/paginatedresource'; const PUSH_NOT_AVAILABLE_HINT = - 'push.activate() registers this process as a push target — it cannot succeed in Node.js/server contexts (there is no device to register). Use client.push.admin to manage other devices from a server: client.push.admin.publish(recipient, payload) to send to a device or clientId, client.push.admin.deviceRegistrations.save(device) to register a device record.'; + 'push.activate() registers the current process as a push target — supported in browser environments with service-worker support. In Node.js or other server contexts there is no device to register; use client.push.admin to manage other devices from a server: client.push.admin.publish(recipient, payload) to send to a device or clientId, client.push.admin.deviceRegistrations.save(device) to register a device record.'; const persistKeys = { deviceId: 'ably.push.deviceId', From 690f4d9a3d1a34dc5b2a495a92a1928608514d31 Mon Sep 17 00:00:00 2001 From: umair Date: Thu, 28 May 2026 10:51:59 +0100 Subject: [PATCH 09/20] DX-1209: style polish across hint text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace em-dashes (—) with hyphens (-) inside every err.hint string per AndyTWF's convention preference. Code-comment em-dashes elsewhere in the codebase are left alone. - Fix typo: "caes" → "case" in realtimeannotations.ts attach-state comment (CodeRabbit). No semantic change. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/common/lib/client/auth.ts | 4 ++-- src/common/lib/client/baserealtime.ts | 2 +- src/common/lib/client/push.ts | 2 +- src/common/lib/client/realtimeannotations.ts | 4 ++-- src/common/lib/client/realtimechannel.ts | 2 +- src/common/lib/client/realtimepresence.ts | 6 +++--- src/common/lib/types/basemessage.ts | 2 +- src/common/lib/util/defaults.ts | 2 +- src/common/lib/util/utils.ts | 2 +- src/plugins/liveobjects/realtimeobject.ts | 2 +- src/plugins/push/pushactivation.ts | 2 +- src/plugins/push/pushchannel.ts | 2 +- 12 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/common/lib/client/auth.ts b/src/common/lib/client/auth.ts index ff4a9aba16..e6f2969673 100644 --- a/src/common/lib/client/auth.ts +++ b/src/common/lib/client/auth.ts @@ -278,7 +278,7 @@ class Auth { message: 'Unable to update auth options with incompatible key', code: 40102, statusCode: 401, - hint: 'auth.authorize() cannot change the API key — the new authOptions.key differs from the one the client was constructed with. To use a different key, construct a new Ably client.', + hint: 'auth.authorize() cannot change the API key - the new authOptions.key differs from the one the client was constructed with. To use a different key, construct a new Ably client.', }); } @@ -713,7 +713,7 @@ class Auth { message: 'Token was double-encoded', code: 40170, statusCode: 401, - hint: 'The endpoint returned a JSON-encoded string starting with `{`, but the Content-Type was not application/jwt — likely a stringified TokenDetails. Return TokenDetails/TokenRequest as a parsed object, or set Content-Type: application/jwt for JWT tokens.', + hint: 'The endpoint returned a JSON-encoded string starting with `{`, but the Content-Type was not application/jwt - likely a stringified TokenDetails. Return TokenDetails/TokenRequest as a parsed object, or set Content-Type: application/jwt for JWT tokens.', }); reject(err); } else { diff --git a/src/common/lib/client/baserealtime.ts b/src/common/lib/client/baserealtime.ts index 923c8df267..b7f6ae369a 100644 --- a/src/common/lib/client/baserealtime.ts +++ b/src/common/lib/client/baserealtime.ts @@ -204,7 +204,7 @@ class Channels extends EventEmitter { 'Channels.get() cannot be used to set channel options that would cause the channel to reattach. Please, use RealtimeChannel.setOptions() instead.', code: 40000, statusCode: 400, - hint: 'channels.get(name) returns the existing channel — to change params or modes, call channel.setOptions(opts) on the returned instance (it will re-attach if the new options differ from current).', + hint: 'channels.get(name) returns the existing channel - to change params or modes, call channel.setOptions(opts) on the returned instance (it will re-attach if the new options differ from current).', }); } channel.setOptions(channelOptions); diff --git a/src/common/lib/client/push.ts b/src/common/lib/client/push.ts index becf2b68b2..cf3b6184b2 100644 --- a/src/common/lib/client/push.ts +++ b/src/common/lib/client/push.ts @@ -16,7 +16,7 @@ import Platform from 'common/platform'; import type { ErrCallback } from 'common/types/utils'; const PUSH_NOT_AVAILABLE_HINT = - 'push.activate() registers the current process as a push target — supported in browser environments with service-worker support. In Node.js or other server contexts there is no device to register; use client.push.admin to manage other devices from a server: client.push.admin.publish(recipient, payload) to send to a device or clientId, client.push.admin.deviceRegistrations.save(device) to register a device record.'; + 'push.activate() registers the current process as a push target - supported in browser environments with service-worker support. In Node.js or other server contexts there is no device to register; use client.push.admin to manage other devices from a server: client.push.admin.publish(recipient, payload) to send to a device or clientId, client.push.admin.deviceRegistrations.save(device) to register a device record.'; class Push { client: BaseClient; diff --git a/src/common/lib/client/realtimeannotations.ts b/src/common/lib/client/realtimeannotations.ts index 97f256c170..0b0951b0d7 100644 --- a/src/common/lib/client/realtimeannotations.ts +++ b/src/common/lib/client/realtimeannotations.ts @@ -69,14 +69,14 @@ 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({ 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: 'Re-create the channel with annotation_subscribe in modes: realtime.channels.get(name, { modes: ["subscribe", "annotation_subscribe", ...] }). If the subsequent attach is rejected by the server, check that the channel namespace has "Message annotations, updates, 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 channel namespaces have Mutable Messages enabled, and `ably auth keys list` shows your key\'s capabilities. Note: appending to channel.modes after attach() does not enable the mode server-side — the array reflects what the server granted, not what you requested.', + hint: 'Re-create the channel with annotation_subscribe in modes: realtime.channels.get(name, { modes: ["subscribe", "annotation_subscribe", ...] }). If the subsequent attach is rejected by the server, check that the channel namespace has "Message annotations, updates, 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 channel namespaces have Mutable Messages enabled, and `ably auth keys list` shows your key\'s capabilities. Note: appending to channel.modes after attach() does not enable the mode server-side - the array reflects what the server granted, not what you requested.', }); } } diff --git a/src/common/lib/client/realtimechannel.ts b/src/common/lib/client/realtimechannel.ts index 230fb50570..7ea850ee19 100644 --- a/src/common/lib/client/realtimechannel.ts +++ b/src/common/lib/client/realtimechannel.ts @@ -62,7 +62,7 @@ function validateChannelOptions(options?: API.ChannelOptions) { message: 'Invalid channel mode: ' + currentMode, code: 40000, statusCode: 400, - hint: `Valid ChannelMode values are: ${channelModes.join(', ').toLowerCase()}. The server also enforces this — your token/API-key capability must permit the requested modes on this channel, otherwise the subsequent attach is rejected. If you have the Ably CLI installed, \`ably auth keys list\` shows your key's capabilities.`, + hint: `Valid ChannelMode values are: ${channelModes.join(', ').toLowerCase()}. The server also enforces this - your token/API-key capability must permit the requested modes on this channel, otherwise the subsequent attach is rejected. If you have the Ably CLI installed, \`ably auth keys list\` shows your key's capabilities.`, }); return err; } diff --git a/src/common/lib/client/realtimepresence.ts b/src/common/lib/client/realtimepresence.ts index 253c07f6af..5868e6f2e1 100644 --- a/src/common/lib/client/realtimepresence.ts +++ b/src/common/lib/client/realtimepresence.ts @@ -66,7 +66,7 @@ class RealtimePresence extends EventEmitter { 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(). To enter on behalf of another identity, use presence.enterClient(otherId, data) — this requires a wildcard clientId on your API key/token, otherwise the server rejects the request.', + hint: 'Set ClientOptions.clientId (or include clientId in the token) before calling presence.enter(). To enter on behalf of another identity, use presence.enterClient(otherId, data) - this requires a wildcard clientId on your API key/token, otherwise the server rejects the request.', }); } return this._enterOrUpdateClient(undefined, undefined, data, 'enter'); @@ -83,7 +83,7 @@ class RealtimePresence extends EventEmitter { 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(). To update on behalf of another identity, use presence.updateClient(otherId, data) — this requires a wildcard clientId on your API key/token, otherwise the server rejects the request.', + hint: 'Set ClientOptions.clientId (or include clientId in the token) before calling presence.update(). To update on behalf of another identity, use presence.updateClient(otherId, data) - this requires a wildcard clientId on your API key/token, otherwise the server rejects the request.', }); } return this._enterOrUpdateClient(undefined, undefined, data, 'update'); @@ -167,7 +167,7 @@ class RealtimePresence extends EventEmitter { message: 'clientId must have been specified to enter or leave a presence channel', code: 40012, statusCode: 400, - hint: 'Anonymous clients cannot publish presence. Set ClientOptions.clientId (or include clientId in the token), or use presence.leaveClient(otherId) — leaveClient on behalf of a different identity requires a wildcard clientId on your API key/token.', + hint: 'Anonymous clients cannot publish presence. Set ClientOptions.clientId (or include clientId in the token), or use presence.leaveClient(otherId) - leaveClient on behalf of a different identity requires a wildcard clientId on your API key/token.', }); } return this.leaveClient(undefined, data); diff --git a/src/common/lib/types/basemessage.ts b/src/common/lib/types/basemessage.ts index 070da5f904..3bf81e6b91 100644 --- a/src/common/lib/types/basemessage.ts +++ b/src/common/lib/types/basemessage.ts @@ -251,7 +251,7 @@ export async function decodeData( message: 'Vcdiff delta decode failed with ' + e, code: 40018, statusCode: 400, - hint: 'The SDK recovers by re-attaching without delta. If you see this repeatedly, the base payload has diverged — disable channel deltas for this channel.', + hint: 'The SDK recovers by re-attaching without delta. If you see this repeatedly, the base payload has diverged - disable channel deltas for this channel.', }); } continue; diff --git a/src/common/lib/util/defaults.ts b/src/common/lib/util/defaults.ts index 16f3941bfd..6af6ade13d 100644 --- a/src/common/lib/util/defaults.ts +++ b/src/common/lib/util/defaults.ts @@ -266,7 +266,7 @@ function checkIfClientOptionsAreValid(options: ClientOptions) { 'The `endpoint` option cannot be used in conjunction with the `environment`, `restHost`, or `realtimeHost` options.', code: 40106, statusCode: 400, - hint: 'Use only `endpoint` (the v2 option). `environment`, `restHost`, and `realtimeHost` are legacy v1 names — remove them from ClientOptions.', + hint: 'Use only `endpoint` (the v2 option). `environment`, `restHost`, and `realtimeHost` are legacy v1 names - remove them from ClientOptions.', }); } diff --git a/src/common/lib/util/utils.ts b/src/common/lib/util/utils.ts index 4e13f3e188..79b2130523 100644 --- a/src/common/lib/util/utils.ts +++ b/src/common/lib/util/utils.ts @@ -293,7 +293,7 @@ export function detectV1Callback(args: ArrayLike, v2TrailingFnArity: nu code: 40025, statusCode: 400, hint: - 'v2 uses Promises — drop the trailing callback and `await` the returned promise. ' + + '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', }); } diff --git a/src/plugins/liveobjects/realtimeobject.ts b/src/plugins/liveobjects/realtimeobject.ts index 7ae8e6a026..81adb75590 100644 --- a/src/plugins/liveobjects/realtimeobject.ts +++ b/src/plugins/liveobjects/realtimeobject.ts @@ -572,7 +572,7 @@ export class RealtimeObject { message: `"${expectedMode}" channel mode must be set for this operation`, code: 40024, statusCode: 400, - hint: `Re-create the channel with "${expectedMode}" in modes: realtime.channels.get(name, { modes: [..., "${expectedMode}"] }). If the subsequent attach is rejected by the server, check that the channel namespace has LiveObjects enabled in the Ably dashboard and that your API key has the corresponding capability on this channel. If you have the Ably CLI installed, \`ably apps rules list\` shows channel-namespace settings and \`ably auth keys list\` shows your key's capabilities. Note: appending to channel.modes after attach() does not enable the mode server-side — the array reflects what the server granted, not what you requested.`, + hint: `Re-create the channel with "${expectedMode}" in modes: realtime.channels.get(name, { modes: [..., "${expectedMode}"] }). If the subsequent attach is rejected by the server, check that the channel namespace has LiveObjects enabled in the Ably dashboard and that your API key has the corresponding capability on this channel. If you have the Ably CLI installed, \`ably apps rules list\` shows channel-namespace settings and \`ably auth keys list\` shows your key's capabilities. Note: appending to channel.modes after attach() does not enable the mode server-side - the array reflects what the server granted, not what you requested.`, }); } // RTO2b - otherwise as a best effort use user provided channel options diff --git a/src/plugins/push/pushactivation.ts b/src/plugins/push/pushactivation.ts index 24908b8ce1..c0b8c3cc46 100644 --- a/src/plugins/push/pushactivation.ts +++ b/src/plugins/push/pushactivation.ts @@ -10,7 +10,7 @@ import type BaseClient from 'common/lib/client/baseclient'; import type { PaginatedResult } from 'common/lib/client/paginatedresource'; const PUSH_NOT_AVAILABLE_HINT = - 'push.activate() registers the current process as a push target — supported in browser environments with service-worker support. In Node.js or other server contexts there is no device to register; use client.push.admin to manage other devices from a server: client.push.admin.publish(recipient, payload) to send to a device or clientId, client.push.admin.deviceRegistrations.save(device) to register a device record.'; + 'push.activate() registers the current process as a push target - supported in browser environments with service-worker support. In Node.js or other server contexts there is no device to register; use client.push.admin to manage other devices from a server: client.push.admin.publish(recipient, payload) to send to a device or clientId, client.push.admin.deviceRegistrations.save(device) to register a device record.'; const persistKeys = { deviceId: 'ably.push.deviceId', diff --git a/src/plugins/push/pushchannel.ts b/src/plugins/push/pushchannel.ts index cae945520e..1371bd5ee7 100644 --- a/src/plugins/push/pushchannel.ts +++ b/src/plugins/push/pushchannel.ts @@ -119,7 +119,7 @@ class PushChannel { message: 'Cannot subscribe from client without deviceIdentityToken', code: 50000, statusCode: 500, - hint: 'Activate this device first by awaiting client.push.activate(registerCallback) — the device must hold an identity token before subscribing to push.', + hint: 'Activate this device first by awaiting client.push.activate(registerCallback) - the device must hold an identity token before subscribing to push.', }); } } From ba5fd884ede373a0ed177df82a36dc52b7a091fb Mon Sep 17 00:00:00 2001 From: umair Date: Thu, 28 May 2026 10:53:51 +0100 Subject: [PATCH 10/20] DX-1209: rewrite hint-coverage as a TypeScript AST walker Replace the regex-based scanner with a ts.createSourceFile + AST walk. Addresses AndyTWF's concern that the script is brittle, and CodeRabbit's edge-case findings (block-comment stripping breaks inside string literals; semicolon termination breaks inside string literals). The new implementation walks `NewExpression` and `CallExpression` nodes to recognise: - new ErrorInfo({ ..., hint, code, ... }) - new PartialErrorInfo({ ... }) - .ErrorInfo({ ... }) (e.g. this.client.ErrorInfo) - ErrorInfo.fromValues({ ... }) / PartialErrorInfo.fromValues({ ... }) For each match it extracts the `code:` numeric value and the `hint:` string. Hint values can be plain string literals, template literals (interpolations collapsed to their identifier text for substring checks), string concatenations, or identifier references resolved against same-file top-level const declarations (e.g. PUSH_NOT_AVAILABLE_HINT). The RUBRIC and per-code rules are unchanged from the regex version; this commit only swaps the extractor. After C3 (which migrated hint sites to the options-object form) the old regex extractor was finding zero hints. The new walker finds 92 sites; all pass the rubric. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/hint-coverage.ts | 258 +++++++++++++++++++++++++++------------ 1 file changed, 177 insertions(+), 81 deletions(-) diff --git a/scripts/hint-coverage.ts b/scripts/hint-coverage.ts index ac2d42b408..c9a26b3c5c 100644 --- a/scripts/hint-coverage.ts +++ b/scripts/hint-coverage.ts @@ -1,22 +1,32 @@ /* - * DX-1209 — static hint-coverage check. + * DX-1209 - static hint-coverage check. * - * Statically scans every `.ts` file under src/ for `err.hint = ...` - * assignments associated with an `ErrorInfo` throw and verifies: + * Walks the TypeScript AST of every `.ts` file under src/ for + * ErrorInfo (and PartialErrorInfo) constructions that carry a `hint` + * field, then verifies: * * 1. The hint string is non-empty. - * 2. If we have a rubric entry for the ErrorInfo code, the hint contains - * every required substring (`contains`) or matches every required - * regex (`matches`). + * 2. If we have a rubric entry for the ErrorInfo code, the hint + * contains every required substring (`contains`) or matches every + * required regex (`matches`). + * + * Matches three call shapes: + * - `new ErrorInfo({ message, code, statusCode, hint, ... })` + * - `new PartialErrorInfo({ ... })` + * - `ErrorInfo.fromValues({ ... })` / `PartialErrorInfo.fromValues({ ... })` + * + * The constructor reference can be `ErrorInfo`, `PartialErrorInfo`, or a + * member access ending in either (e.g. `this.client.ErrorInfo`). * * Hints are now a discoverable surface for LLMs and humans alike; this * check is a cheap drift guard for that surface. It does NOT lock down - * exact wording — wording is allowed to evolve. It DOES lock down - * presence and the API-name / concept tokens we don't want silently - * renamed (e.g. `annotation_subscribe`, `defaultTokenParams`). + * exact wording. It DOES lock down presence and the API-name / concept + * tokens we don't want silently renamed (e.g. `annotation_subscribe`, + * `defaultTokenParams`). * - * Add new entries to RUBRIC as new hint sites land. Missing rubric entries - * for an error code are not an error — only an explicit rule violation is. + * Add new entries to RUBRIC as new hint sites land. Missing rubric + * entries for an error code are not an error - only an explicit rule + * violation is. * * Run with: * tsc --noEmit --esModuleInterop --target ES2017 --moduleResolution node scripts/hint-coverage.ts \ @@ -29,6 +39,7 @@ import fs from 'fs'; import path from 'path'; import { glob } from 'glob'; import { exit } from 'process'; +import * as ts from 'typescript'; interface HintEntry { file: string; @@ -101,91 +112,179 @@ const RUBRIC: Record = { ], }, // Codes deliberately NOT keyed in the rubric: - // 40000, 40012, 40013, 40400, 40500 — each is shared across multiple + // 40000, 40012, 40013, 40400, 40500 - each is shared across multiple // unrelated throw sites, so a code-level rubric over-constrains. Add // message-keyed rules in a follow-up if/when needed. }; const SRC_ROOT = path.resolve(__dirname, '..', 'src'); -function stripBlockComments(src: string): string { - return src.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' ')); +const ERROR_INFO_CTOR_NAMES = new Set(['ErrorInfo', 'PartialErrorInfo']); + +/** + * Walks an Expression and returns the trailing identifier name if it + * is an ErrorInfo-like ctor target, else null. + * + * `ErrorInfo` -> 'ErrorInfo' + * `PartialErrorInfo` -> 'PartialErrorInfo' + * `this.client.ErrorInfo` -> 'ErrorInfo' + * `client.ErrorInfo` -> 'ErrorInfo' + * `ErrorInfo.fromValues` -> null (this is a call target, not a ctor) + */ +function ctorTrailingName(expr: ts.Expression): string | null { + if (ts.isIdentifier(expr)) return ERROR_INFO_CTOR_NAMES.has(expr.text) ? expr.text : null; + if (ts.isPropertyAccessExpression(expr)) { + return ERROR_INFO_CTOR_NAMES.has(expr.name.text) ? expr.name.text : null; + } + return null; } -function extractHints(filePath: string): HintEntry[] { - const raw = fs.readFileSync(filePath, 'utf8'); - const src = stripBlockComments(raw); - const lines = src.split('\n'); - const entries: HintEntry[] = []; +/** Returns the property-access tail components, e.g. `ErrorInfo.fromValues` -> ['ErrorInfo', 'fromValues']. */ +function propertyAccessChain(expr: ts.Expression): string[] | null { + const parts: string[] = []; + let cur: ts.Expression = expr; + while (ts.isPropertyAccessExpression(cur)) { + parts.unshift(cur.name.text); + cur = cur.expression; + } + if (ts.isIdentifier(cur)) { + parts.unshift(cur.text); + return parts; + } + return null; +} + +/** + * Collapse a template literal to a plain-text approximation suitable + * for substring/regex checks. `${expr}` segments are rendered as the + * identifier-like fragments inside them so token checks succeed on + * dynamic variable names (e.g. `${expectedMode}` -> ` expectedMode `). + */ +function renderTemplateLiteral(tpl: ts.TemplateLiteral): string { + if (ts.isNoSubstitutionTemplateLiteral(tpl)) return tpl.text; + let out = tpl.head.text; + for (const span of tpl.templateSpans) { + const expr = span.expression.getText(); + const ident = ' ' + expr.replace(/[^A-Za-z0-9_]+/g, ' ') + ' '; + out += ident + span.literal.text; + } + return out; +} - // Track the most-recent ErrorInfo code we saw within a sliding window. - let pendingCode: number | null = null; - let pendingCodeLine = -Infinity; +/** + * Resolve a string-valued initializer to its plain-text representation. + * Handles: + * - String literals: 'foo' -> 'foo' + * - Template literals: `foo${x}bar` -> 'foo x bar' + * - String concatenations: 'a' + 'b' + '...' -> 'ab...' + * - Identifier references resolved within the same file's top-level scope + * + * Returns null if the expression cannot be resolved to a static-ish value. + */ +function resolveStringExpr( + expr: ts.Expression, + fileConsts: Map, +): { text: string; isTemplate: boolean } | null { + if (ts.isStringLiteralLike(expr)) return { text: expr.text, isTemplate: false }; + if (ts.isTemplateExpression(expr)) return { text: renderTemplateLiteral(expr), isTemplate: true }; + if (ts.isNoSubstitutionTemplateLiteral(expr)) return { text: expr.text, isTemplate: true }; + if (ts.isBinaryExpression(expr) && expr.operatorToken.kind === ts.SyntaxKind.PlusToken) { + const left = resolveStringExpr(expr.left, fileConsts); + const right = resolveStringExpr(expr.right, fileConsts); + if (left && right) return { text: left.text + right.text, isTemplate: left.isTemplate || right.isTemplate }; + return null; + } + if (ts.isIdentifier(expr)) { + const referenced = fileConsts.get(expr.text); + if (referenced) return resolveStringExpr(referenced, fileConsts); + } + return null; +} - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; +/** Resolve a numeric expression (integer literal or const reference) to its value. */ +function resolveNumericExpr(expr: ts.Expression, fileConsts: Map): number | null { + if (ts.isNumericLiteral(expr)) { + const n = Number(expr.text); + return Number.isFinite(n) ? n : null; + } + if (ts.isIdentifier(expr)) { + const referenced = fileConsts.get(expr.text); + if (referenced) return resolveNumericExpr(referenced, fileConsts); + } + return null; +} - // Detect `new ErrorInfo(... , , ...)` — code can land on the same - // line as the constructor or on a later line in a multiline call. - const errInfoIdx = line.indexOf('new ErrorInfo('); - const errInfoClientIdx = line.indexOf('.ErrorInfo('); - if (errInfoIdx >= 0 || errInfoClientIdx >= 0) { - // Look ahead up to 6 lines for the code argument. - const span = lines.slice(i, i + 6).join(' '); - const codeMatch = span.match(/,\s*(\d{4,5})\s*,/); - if (codeMatch) { - pendingCode = parseInt(codeMatch[1], 10); - pendingCodeLine = i; +/** + * Collect top-level `const X = ` declarations into a map so we can + * resolve identifier references inside ErrorInfo arguments. + */ +function collectFileConsts(sourceFile: ts.SourceFile): Map { + const consts = new Map(); + sourceFile.forEachChild((node) => { + if (ts.isVariableStatement(node)) { + for (const decl of node.declarationList.declarations) { + if (ts.isIdentifier(decl.name) && decl.initializer) { + consts.set(decl.name.text, decl.initializer); + } } } + }); + return consts; +} - // Detect `err.hint = ...` — string body may span multiple lines. - const hintMatch = line.match(/(\w+)\.hint\s*=\s*(.*)$/); - if (hintMatch) { - // Drop the pending code if it's too far away (likely unrelated). - const code = i - pendingCodeLine <= 12 ? pendingCode : null; +function extractHints(filePath: string): HintEntry[] { + const raw = fs.readFileSync(filePath, 'utf8'); + const sourceFile = ts.createSourceFile(filePath, raw, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const fileConsts = collectFileConsts(sourceFile); + const entries: HintEntry[] = []; - // Reassemble the RHS until we hit a semicolon at the end of a line. - const rhsParts: string[] = [hintMatch[2]]; - let j = i; - while (!rhsParts[rhsParts.length - 1].trimEnd().endsWith(';') && j < lines.length - 1) { - j++; - rhsParts.push(lines[j]); - } - const rhs = rhsParts.join(' ').replace(/;$/, '').trim(); + function processObjectArg(objExpr: ts.ObjectLiteralExpression, anchorNode: ts.Node) { + let codeExpr: ts.Expression | null = null; + let hintExpr: ts.Expression | null = null; + for (const prop of objExpr.properties) { + if (!ts.isPropertyAssignment(prop)) continue; + if (!ts.isIdentifier(prop.name)) continue; + if (prop.name.text === 'code') codeExpr = prop.initializer; + else if (prop.name.text === 'hint') hintExpr = prop.initializer; + } + if (!hintExpr) return; + const resolved = resolveStringExpr(hintExpr, fileConsts); + if (!resolved) return; + const code = codeExpr ? resolveNumericExpr(codeExpr, fileConsts) : null; + const { line } = sourceFile.getLineAndCharacterOfPosition(anchorNode.getStart(sourceFile)); + entries.push({ + file: path.relative(process.cwd(), filePath), + line: line + 1, + code, + hintText: resolved.text, + isTemplate: resolved.isTemplate, + }); + } - // Classify literal vs template. - const isTemplate = rhs.startsWith('`'); - // Extract a best-effort literal text by collapsing template segments - // to spaces so we can still check for substring tokens. - let text = rhs; - if (rhs.startsWith("'") || rhs.startsWith('"') || rhs.startsWith('`')) { - const quote = rhs[0]; - // Remove leading + trailing quote. - text = rhs.slice(1); - const lastQuoteIdx = text.lastIndexOf(quote); - if (lastQuoteIdx >= 0) text = text.slice(0, lastQuoteIdx); - // Collapse interpolations (`${...}`) to the bare identifier(s) inside - // them. Lets token checks succeed on dynamic variable names without - // requiring runtime evaluation. E.g. `${expectedMode}` -> `expectedMode`. - text = text.replace(/\$\{([^}]*)\}/g, (_, expr) => { - const id = String(expr).trim(); - // Pull only identifier-like fragments; drop method calls and operators - // so the substring search isn't polluted by syntax. - return ' ' + id.replace(/[^A-Za-z0-9_]+/g, ' ') + ' '; - }); + function visit(node: ts.Node) { + // new ErrorInfo({...}) / new PartialErrorInfo({...}) / new .ErrorInfo({...}) + if (ts.isNewExpression(node) && node.arguments && node.arguments.length >= 1) { + const tail = ctorTrailingName(node.expression); + if (tail) { + const first = node.arguments[0]; + if (ts.isObjectLiteralExpression(first)) processObjectArg(first, node); } - - entries.push({ - file: path.relative(process.cwd(), filePath), - line: i + 1, - code, - hintText: text, - isTemplate, - }); } + // ErrorInfo.fromValues({...}) + if (ts.isCallExpression(node) && node.arguments.length >= 1) { + const chain = propertyAccessChain(node.expression); + if (chain && chain.length >= 2 && chain[chain.length - 1] === 'fromValues') { + const ctorName = chain[chain.length - 2]; + if (ERROR_INFO_CTOR_NAMES.has(ctorName)) { + const first = node.arguments[0]; + if (ts.isObjectLiteralExpression(first)) processObjectArg(first, node); + } + } + } + node.forEachChild(visit); } + visit(sourceFile); return entries; } @@ -230,10 +329,7 @@ async function main() { ignore: ['**/*.test.ts', '**/*.d.ts'], }); - let allEntries: HintEntry[] = []; - for (const f of files) { - allEntries = allEntries.concat(extractHints(f)); - } + const allEntries: HintEntry[] = files.flatMap(extractHints); const failures: Failure[] = []; for (const e of allEntries) { @@ -248,7 +344,7 @@ async function main() { byCode.set(e.code, list); } - console.log(`hint-coverage: scanned ${files.length} files, found ${allEntries.length} err.hint assignments\n`); + console.log(`hint-coverage: scanned ${files.length} files, found ${allEntries.length} hint sites\n`); const codes = [...byCode.keys()].sort((a, b) => (a ?? -1) - (b ?? -1)); for (const code of codes) { const list = byCode.get(code)!; From a25b545cbee384cf2a1d239cab3f496bd87ea00d Mon Sep 17 00:00:00 2001 From: umair Date: Thu, 28 May 2026 10:54:48 +0100 Subject: [PATCH 11/20] DX-1209: tighten error-hints test + document bundle threshold bump test/unit/error-hints.test.js: - Add `expect(err.hint).to.be.a('string')` before each substring assertion so a missing/undefined hint fails with a clear "expected a string" rather than a TypeError on `.contain()` (CodeRabbit nit). - Document why `rest._FilteredSubscriptions` is the trigger for the missing-plugin throw: no public API exposes the throw without a Realtime connection, so we rely on the stable internal getter (CodeRabbit fragility flag). scripts/moduleReport.ts: - Annotate the minimalUsefulRealtimeBundleSizeThresholdsKiB constant: previous {raw: 107, gzip: 33}; bumped in DX-1209 to absorb ~80 inline err.hint strings (~9 KiB raw, ~3 KiB gzip), with recalibration guidance for future hint changes (CodeRabbit nit). Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/moduleReport.ts | 2 +- test/unit/error-hints.test.js | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/scripts/moduleReport.ts b/scripts/moduleReport.ts index bd59f05f84..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: 116, gzip: 36 }; +const minimalUsefulRealtimeBundleSizeThresholdsKiB = { raw: 118, gzip: 36 }; const baseClientNames = ['BaseRest', 'BaseRealtime']; diff --git a/test/unit/error-hints.test.js b/test/unit/error-hints.test.js index 2e075c4ad2..0f56bd1fae 100644 --- a/test/unit/error-hints.test.js +++ b/test/unit/error-hints.test.js @@ -22,6 +22,7 @@ define(['chai', 'ably'], function (chai, Ably) { throw new Error('expected constructor to throw'); } catch (err) { expect(err.code).to.equal(40400); + expect(err.hint).to.be.a('string'); expect(err.hint).to.contain('appId'); expect(err.hint).to.contain('keyId'); } @@ -33,6 +34,7 @@ define(['chai', 'ably'], function (chai, Ably) { throw new Error('expected constructor to throw'); } catch (err) { expect(err.code).to.equal(40012); + expect(err.hint).to.be.a('string'); expect(err.hint).to.contain('defaultTokenParams'); } }); @@ -43,6 +45,7 @@ define(['chai', 'ably'], function (chai, Ably) { throw new Error('expected constructor to throw'); } catch (err) { expect(err.code).to.equal(40160); + expect(err.hint).to.be.a('string'); expect(err.hint).to.contain('authUrl'); expect(err.hint).to.contain('authCallback'); } @@ -54,6 +57,7 @@ define(['chai', 'ably'], function (chai, Ably) { throw new Error('expected constructor to throw'); } catch (err) { expect(err.code).to.equal(40106); + expect(err.hint).to.be.a('string'); expect(err.hint).to.contain('endpoint'); expect(err.hint).to.contain('legacy'); } @@ -64,10 +68,15 @@ define(['chai', 'ably'], function (chai, Ably) { it('missing plugin error carries an import hint', function () { const rest = new Ably.Rest({ key: 'a.b:c' }); try { - rest._FilteredSubscriptions; // triggers throwMissingPluginError('MessageInteractions') + // _FilteredSubscriptions is the stable internal trigger for + // createMissingPluginError('MessageInteractions'); used here only + // because no public API exposes the missing-plugin throw without + // first connecting a Realtime client. + rest._FilteredSubscriptions; throw new Error('expected getter to throw'); } catch (err) { expect(err.code).to.equal(40019); + expect(err.hint).to.be.a('string'); expect(err.hint).to.contain('ably/modular'); expect(err.hint).to.contain('plugins'); } From f043eab1c5a6dff4c7c11603a0038ebf2e8c783c Mon Sep 17 00:00:00 2001 From: umair Date: Thu, 28 May 2026 11:07:56 +0100 Subject: [PATCH 12/20] DX-1209: trim message/hint redundancy per D1 audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweep every (message, hint) pair in the SDK; six sites had a hint that just rephrased the message (or vice versa). For each, keep the "what failed" in the message and lean on the hint for the "how to fix". utils.ts: - derived-channel regex hint dropped the rephrased "Channel names with derived options must look like..." (now in the message); keeps only the docs link. - concurrent next() hint dropped its trailing "Calling next() twice concurrently is not supported" sentence, which mirrored the message. restchannelmixin.ts (×3 sites): - message trimmed to "This message lacks a serial" / "...and cannot be updated"; the "Make sure you have enabled X in channel settings on your dashboard" guidance already lives in the hint and is dropped from the message. realtimechannel.ts: - message-lacks-serial site: same trim as restchannelmixin. - detach-from-failed hint dropped the "A failed channel cannot be detached" sentence (a rephrasing of "Unable to detach; channel state = failed"). - attach/detach-timeout hints dropped the "server did not acknowledge within realtimeRequestTimeout" sentence (a rephrasing of "Channel attach/detach timed out"). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/common/lib/client/realtimechannel.ts | 9 ++++----- src/common/lib/client/restchannelmixin.ts | 9 +++------ src/common/lib/util/utils.ts | 4 ++-- 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/common/lib/client/realtimechannel.ts b/src/common/lib/client/realtimechannel.ts index 7ea850ee19..3c70d970bd 100644 --- a/src/common/lib/client/realtimechannel.ts +++ b/src/common/lib/client/realtimechannel.ts @@ -446,7 +446,7 @@ class RealtimeChannel extends EventEmitter { message: 'Unable to detach; channel state = failed', code: 90001, statusCode: 400, - hint: 'A failed channel cannot be detached. Release it via channels.release(name) and call channels.get(name) again to start a fresh channel.', + hint: 'Release it via channels.release(name) and call channels.get(name) again to start a fresh channel.', }); } default: @@ -988,7 +988,7 @@ class RealtimeChannel extends EventEmitter { message: 'Channel attach timed out', code: 90007, statusCode: 408, - hint: 'The server did not acknowledge the attach within realtimeRequestTimeout. The SDK will retry automatically once the connection is healthy; check connection.state and connection.errorReason.', + hint: 'The SDK will retry automatically once the connection is healthy; check connection.state and connection.errorReason.', }); this.notifyState('suspended', err); break; @@ -998,7 +998,7 @@ class RealtimeChannel extends EventEmitter { message: 'Channel detach timed out', code: 90007, statusCode: 408, - hint: 'The server did not acknowledge the detach within realtimeRequestTimeout. The channel has reverted to attached; retry detach() once the connection is stable.', + hint: 'The channel has reverted to attached; retry detach() once the connection is stable.', }); this.notifyState('attached', err); break; @@ -1175,8 +1175,7 @@ class RealtimeChannel extends EventEmitter { ): Promise { if (!message.serial) { throw new ErrorInfo({ - message: - '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.', + message: 'This message lacks a serial and cannot be updated', code: 40003, statusCode: 400, hint: 'Pass the Message you received from a subscribe callback (which carries .serial), not a freshly constructed object. The namespace must enable message annotations/updates/deletes in the Ably dashboard. If you have the Ably CLI installed, `ably apps rules list` shows which channel namespaces have Mutable Messages enabled.', diff --git a/src/common/lib/client/restchannelmixin.ts b/src/common/lib/client/restchannelmixin.ts index c85ec9a3ab..e77b03ca1f 100644 --- a/src/common/lib/client/restchannelmixin.ts +++ b/src/common/lib/client/restchannelmixin.ts @@ -65,8 +65,7 @@ export class RestChannelMixin { const serial = typeof serialOrMessage === 'string' ? serialOrMessage : serialOrMessage.serial; if (!serial) { throw new ErrorInfo({ - message: - 'This message lacks a serial. Make sure you have enabled "Message annotations, updates, and deletes" in channel settings on your dashboard.', + 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. The namespace must enable message annotations/updates/deletes in the Ably dashboard. If you have the Ably CLI installed, `ably apps rules list` shows which channel namespaces have Mutable Messages enabled.', @@ -100,8 +99,7 @@ export class RestChannelMixin { ): Promise { if (!message.serial) { throw new ErrorInfo({ - message: - '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.', + message: 'This message lacks a serial and cannot be updated', code: 40003, statusCode: 400, hint: 'Pass the Message received from a subscribe callback (which carries .serial), not a freshly constructed object. The namespace must enable message annotations/updates/deletes in the Ably dashboard. If you have the Ably CLI installed, `ably apps rules list` shows which channel namespaces have Mutable Messages enabled.', @@ -144,8 +142,7 @@ export class RestChannelMixin { const serial = typeof serialOrMessage === 'string' ? serialOrMessage : serialOrMessage.serial; if (!serial) { throw new ErrorInfo({ - message: - 'This message lacks a serial. Make sure you have enabled "Message annotations, updates, and deletes" in channel settings on your dashboard.', + 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. The namespace must enable message annotations/updates/deletes in the Ably dashboard. If you have the Ably CLI installed, `ably apps rules list` shows which channel namespaces have Mutable Messages enabled.', diff --git a/src/common/lib/util/utils.ts b/src/common/lib/util/utils.ts index 79b2130523..b730ae5e85 100644 --- a/src/common/lib/util/utils.ts +++ b/src/common/lib/util/utils.ts @@ -477,7 +477,7 @@ export function matchDerivedChannel(name: string) { message: 'Channel name does not match the [filter=...]name shape required for derived channels', code: 40010, statusCode: 400, - hint: 'Channel names with derived options must look like "[filter=...]name". See https://ably.com/docs/channels/options/derived.', + hint: 'See https://ably.com/docs/channels/options/derived.', }); } // Fail if there is already a channel qualifier, eg [meta]foo should fail instead of just overriding with [filter=xyz]foo @@ -577,7 +577,7 @@ export async function* listenerToAsyncIterator( message: 'Concurrent next() calls are not supported', code: 40000, statusCode: 400, - hint: 'Drive the async iterator from a single for-await-of loop. Calling next() twice concurrently is not supported.', + hint: 'Drive the async iterator from a single for-await-of loop.', }); } From 9c20e0b91a936be0a9283428e3303923cb43c8f3 Mon Sep 17 00:00:00 2001 From: umair Date: Thu, 28 May 2026 13:06:16 +0100 Subject: [PATCH 13/20] DX-1209: forward inner hint when wrapping decode failures The decoding loop in basemessage.ts catches per-encoding errors and rewraps them with an outer ErrorInfo so the caller gets one error per message rather than one per encoding stage. The wrapper used the positional ErrorInfo constructor, which has no hint parameter, so the three hint-bearing throws in this loop - Vcdiff plugin missing (40019), no typed-array support (40020), Vcdiff decode failure (40018) - had their hints silently swallowed before reaching the caller. Pre-DX-1209 this was harmless because no inner hints existed; flagged by CodeRabbit review. Switch the wrapper to the options-object constructor and forward err.hint. Cause is intentionally not forwarded: three of the inner throws are plain Error (cipher and 'Unknown encoding' branches) which do not fit cause: ErrorInfo | PartialErrorInfo without an instanceof branch, and CodeRabbit flagged cause as optional. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/common/lib/types/basemessage.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/common/lib/types/basemessage.ts b/src/common/lib/types/basemessage.ts index 3bf81e6b91..fcef615768 100644 --- a/src/common/lib/types/basemessage.ts +++ b/src/common/lib/types/basemessage.ts @@ -261,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('/'); From 98e38d3f3e3b3c0db767e0e32f17864f9c762618 Mon Sep 17 00:00:00 2001 From: umair Date: Thu, 28 May 2026 14:06:26 +0100 Subject: [PATCH 14/20] DX-1209: broaden device-token hint to cover unsubscribe path The _getDeviceIdentityToken() throw site in pushchannel.ts is reached from both subscribeDevice() and unsubscribeDevice() (via the shared _getPushAuthHeaders() helper), but its message and hint only mentioned subscribing. CodeRabbit flagged that this misdirects users hitting the error from an unsubscribe call. Rewrite both message and hint to be action-neutral. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/plugins/push/pushchannel.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugins/push/pushchannel.ts b/src/plugins/push/pushchannel.ts index 1371bd5ee7..4b1df87213 100644 --- a/src/plugins/push/pushchannel.ts +++ b/src/plugins/push/pushchannel.ts @@ -116,10 +116,10 @@ class PushChannel { return deviceIdentityToken; } else { throw new this.client.ErrorInfo({ - message: 'Cannot subscribe from client without deviceIdentityToken', + message: 'Cannot subscribe or unsubscribe this device without a deviceIdentityToken', code: 50000, statusCode: 500, - hint: 'Activate this device first by awaiting client.push.activate(registerCallback) - the device must hold an identity token before subscribing to push.', + hint: 'Activate this device first by awaiting client.push.activate(registerCallback) - the device must hold an identity token before subscribing or unsubscribing via the push channel.', }); } } From a17b1cd286daff0eb5512d7241d15120c63e3b5c Mon Sep 17 00:00:00 2001 From: umair Date: Fri, 29 May 2026 10:53:59 +0100 Subject: [PATCH 15/20] DX-1209: drop hint-coverage script and hint-pinning tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pinning exact hint substrings (and the static AST coverage check that guarded token presence) treats hints like a tested API surface. We don't test exact error-message wording elsewhere, and the same reasoning applies to hints — they're guidance, not a contract. Remove the apparatus rather than maintain drift assertions for it. Removes: - scripts/hint-coverage.ts (+ npm run hintcoverage, CI step in check.yml) - test/unit/error-hints.test.js Guidance on writing/using hints will live in a best-practices doc instead. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/check.yml | 1 - package.json | 1 - scripts/hint-coverage.ts | 374 ---------------------------------- test/unit/error-hints.test.js | 86 -------- 4 files changed, 462 deletions(-) delete mode 100644 scripts/hint-coverage.ts delete mode 100644 test/unit/error-hints.test.js diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index ce5e6e608c..7f5e3363bb 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -27,7 +27,6 @@ jobs: - run: npm ci - run: npm run lint - run: npm run format:check - - run: npm run hintcoverage - run: npx tsc --noEmit ably.d.ts modular.d.ts # for some reason, this doesn't work in CI using `npx attw --pack .` - run: npm pack diff --git a/package.json b/package.json index 7f43687477..c153c64663 100644 --- a/package.json +++ b/package.json @@ -193,7 +193,6 @@ "sourcemap": "source-map-explorer build/ably.min.js", "modulereport": "tsc --noEmit --esModuleInterop scripts/moduleReport.ts && esr scripts/moduleReport.ts", "speccoveragereport": "tsc --noEmit --esModuleInterop --target ES2017 --moduleResolution node scripts/specCoverageReport.ts && esr scripts/specCoverageReport.ts", - "hintcoverage": "tsc --noEmit --esModuleInterop --target ES2017 --moduleResolution node scripts/hint-coverage.ts && esr scripts/hint-coverage.ts", "process-private-api-data": "tsc --noEmit --esModuleInterop --strictNullChecks scripts/processPrivateApiData/run.ts && esr scripts/processPrivateApiData/run.ts", "docs": "typedoc" } diff --git a/scripts/hint-coverage.ts b/scripts/hint-coverage.ts deleted file mode 100644 index c9a26b3c5c..0000000000 --- a/scripts/hint-coverage.ts +++ /dev/null @@ -1,374 +0,0 @@ -/* - * DX-1209 - static hint-coverage check. - * - * Walks the TypeScript AST of every `.ts` file under src/ for - * ErrorInfo (and PartialErrorInfo) constructions that carry a `hint` - * field, then verifies: - * - * 1. The hint string is non-empty. - * 2. If we have a rubric entry for the ErrorInfo code, the hint - * contains every required substring (`contains`) or matches every - * required regex (`matches`). - * - * Matches three call shapes: - * - `new ErrorInfo({ message, code, statusCode, hint, ... })` - * - `new PartialErrorInfo({ ... })` - * - `ErrorInfo.fromValues({ ... })` / `PartialErrorInfo.fromValues({ ... })` - * - * The constructor reference can be `ErrorInfo`, `PartialErrorInfo`, or a - * member access ending in either (e.g. `this.client.ErrorInfo`). - * - * Hints are now a discoverable surface for LLMs and humans alike; this - * check is a cheap drift guard for that surface. It does NOT lock down - * exact wording. It DOES lock down presence and the API-name / concept - * tokens we don't want silently renamed (e.g. `annotation_subscribe`, - * `defaultTokenParams`). - * - * Add new entries to RUBRIC as new hint sites land. Missing rubric - * entries for an error code are not an error - only an explicit rule - * violation is. - * - * Run with: - * tsc --noEmit --esModuleInterop --target ES2017 --moduleResolution node scripts/hint-coverage.ts \ - * && esr scripts/hint-coverage.ts - * - * (Wired up as `npm run hintcoverage` in package.json.) - */ - -import fs from 'fs'; -import path from 'path'; -import { glob } from 'glob'; -import { exit } from 'process'; -import * as ts from 'typescript'; - -interface HintEntry { - file: string; - line: number; - code: number | null; - hintText: string; - isTemplate: boolean; -} - -type RubricRule = { kind: 'contains'; value: string } | { kind: 'matches'; value: RegExp }; - -interface RubricEntry { - description: string; - require: RubricRule[]; -} - -/** - * Per-error-code rubric. Add entries for codes whose hint contains - * API names / concept tokens we want guarded against silent rename. - * - * Rules of thumb when adding entries: - * - Pin SDK API identifiers (`enterClient`, `defaultTokenParams`, ...) - * and Ably product/feature words (`annotation_subscribe`, `Mutable - * Messages`, ...). They're the public-ish surface. - * - Avoid pinning prose ("must", "the resulting"). Wording is allowed - * to evolve. - * - Use `matches` (regex) only when the token genuinely varies. - */ -const RUBRIC: Record = { - 40009: { - description: 'publish payload exceeds maxMessageSize', - require: [{ kind: 'contains', value: 'maxMessageSize' }], - }, - 40019: { - description: 'missing plugin', - // 40019 is shared between modular-plugin missing (ably/modular) and the - // vcdiff-decoder missing site. Either install path is acceptable. - require: [{ kind: 'matches', value: /ably\/modular|@ably\/vcdiff-decoder/ }], - }, - 40024: { - description: 'LiveObjects channel mode missing', - require: [ - { kind: 'contains', value: 'modes' }, - { kind: 'contains', value: 'expectedMode' }, // dynamic; rendered as object_subscribe/_publish - ], - }, - 40106: { - description: 'endpoint + environment conflict', - require: [{ kind: 'contains', value: 'endpoint' }], - }, - 40160: { - description: 'no authentication options', - require: [ - { kind: 'contains', value: 'authUrl' }, - { kind: 'contains', value: 'authCallback' }, - ], - }, - 40162: { - description: 'revokeTokens under token auth', - require: [ - { kind: 'contains', value: 'revoke' }, - { kind: 'contains', value: 'key' }, - ], - }, - 93001: { - description: 'annotation_subscribe mode missing', - require: [ - { kind: 'contains', value: 'annotation_subscribe' }, - { kind: 'contains', value: 'modes' }, - ], - }, - // Codes deliberately NOT keyed in the rubric: - // 40000, 40012, 40013, 40400, 40500 - each is shared across multiple - // unrelated throw sites, so a code-level rubric over-constrains. Add - // message-keyed rules in a follow-up if/when needed. -}; - -const SRC_ROOT = path.resolve(__dirname, '..', 'src'); - -const ERROR_INFO_CTOR_NAMES = new Set(['ErrorInfo', 'PartialErrorInfo']); - -/** - * Walks an Expression and returns the trailing identifier name if it - * is an ErrorInfo-like ctor target, else null. - * - * `ErrorInfo` -> 'ErrorInfo' - * `PartialErrorInfo` -> 'PartialErrorInfo' - * `this.client.ErrorInfo` -> 'ErrorInfo' - * `client.ErrorInfo` -> 'ErrorInfo' - * `ErrorInfo.fromValues` -> null (this is a call target, not a ctor) - */ -function ctorTrailingName(expr: ts.Expression): string | null { - if (ts.isIdentifier(expr)) return ERROR_INFO_CTOR_NAMES.has(expr.text) ? expr.text : null; - if (ts.isPropertyAccessExpression(expr)) { - return ERROR_INFO_CTOR_NAMES.has(expr.name.text) ? expr.name.text : null; - } - return null; -} - -/** Returns the property-access tail components, e.g. `ErrorInfo.fromValues` -> ['ErrorInfo', 'fromValues']. */ -function propertyAccessChain(expr: ts.Expression): string[] | null { - const parts: string[] = []; - let cur: ts.Expression = expr; - while (ts.isPropertyAccessExpression(cur)) { - parts.unshift(cur.name.text); - cur = cur.expression; - } - if (ts.isIdentifier(cur)) { - parts.unshift(cur.text); - return parts; - } - return null; -} - -/** - * Collapse a template literal to a plain-text approximation suitable - * for substring/regex checks. `${expr}` segments are rendered as the - * identifier-like fragments inside them so token checks succeed on - * dynamic variable names (e.g. `${expectedMode}` -> ` expectedMode `). - */ -function renderTemplateLiteral(tpl: ts.TemplateLiteral): string { - if (ts.isNoSubstitutionTemplateLiteral(tpl)) return tpl.text; - let out = tpl.head.text; - for (const span of tpl.templateSpans) { - const expr = span.expression.getText(); - const ident = ' ' + expr.replace(/[^A-Za-z0-9_]+/g, ' ') + ' '; - out += ident + span.literal.text; - } - return out; -} - -/** - * Resolve a string-valued initializer to its plain-text representation. - * Handles: - * - String literals: 'foo' -> 'foo' - * - Template literals: `foo${x}bar` -> 'foo x bar' - * - String concatenations: 'a' + 'b' + '...' -> 'ab...' - * - Identifier references resolved within the same file's top-level scope - * - * Returns null if the expression cannot be resolved to a static-ish value. - */ -function resolveStringExpr( - expr: ts.Expression, - fileConsts: Map, -): { text: string; isTemplate: boolean } | null { - if (ts.isStringLiteralLike(expr)) return { text: expr.text, isTemplate: false }; - if (ts.isTemplateExpression(expr)) return { text: renderTemplateLiteral(expr), isTemplate: true }; - if (ts.isNoSubstitutionTemplateLiteral(expr)) return { text: expr.text, isTemplate: true }; - if (ts.isBinaryExpression(expr) && expr.operatorToken.kind === ts.SyntaxKind.PlusToken) { - const left = resolveStringExpr(expr.left, fileConsts); - const right = resolveStringExpr(expr.right, fileConsts); - if (left && right) return { text: left.text + right.text, isTemplate: left.isTemplate || right.isTemplate }; - return null; - } - if (ts.isIdentifier(expr)) { - const referenced = fileConsts.get(expr.text); - if (referenced) return resolveStringExpr(referenced, fileConsts); - } - return null; -} - -/** Resolve a numeric expression (integer literal or const reference) to its value. */ -function resolveNumericExpr(expr: ts.Expression, fileConsts: Map): number | null { - if (ts.isNumericLiteral(expr)) { - const n = Number(expr.text); - return Number.isFinite(n) ? n : null; - } - if (ts.isIdentifier(expr)) { - const referenced = fileConsts.get(expr.text); - if (referenced) return resolveNumericExpr(referenced, fileConsts); - } - return null; -} - -/** - * Collect top-level `const X = ` declarations into a map so we can - * resolve identifier references inside ErrorInfo arguments. - */ -function collectFileConsts(sourceFile: ts.SourceFile): Map { - const consts = new Map(); - sourceFile.forEachChild((node) => { - if (ts.isVariableStatement(node)) { - for (const decl of node.declarationList.declarations) { - if (ts.isIdentifier(decl.name) && decl.initializer) { - consts.set(decl.name.text, decl.initializer); - } - } - } - }); - return consts; -} - -function extractHints(filePath: string): HintEntry[] { - const raw = fs.readFileSync(filePath, 'utf8'); - const sourceFile = ts.createSourceFile(filePath, raw, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); - const fileConsts = collectFileConsts(sourceFile); - const entries: HintEntry[] = []; - - function processObjectArg(objExpr: ts.ObjectLiteralExpression, anchorNode: ts.Node) { - let codeExpr: ts.Expression | null = null; - let hintExpr: ts.Expression | null = null; - for (const prop of objExpr.properties) { - if (!ts.isPropertyAssignment(prop)) continue; - if (!ts.isIdentifier(prop.name)) continue; - if (prop.name.text === 'code') codeExpr = prop.initializer; - else if (prop.name.text === 'hint') hintExpr = prop.initializer; - } - if (!hintExpr) return; - const resolved = resolveStringExpr(hintExpr, fileConsts); - if (!resolved) return; - const code = codeExpr ? resolveNumericExpr(codeExpr, fileConsts) : null; - const { line } = sourceFile.getLineAndCharacterOfPosition(anchorNode.getStart(sourceFile)); - entries.push({ - file: path.relative(process.cwd(), filePath), - line: line + 1, - code, - hintText: resolved.text, - isTemplate: resolved.isTemplate, - }); - } - - function visit(node: ts.Node) { - // new ErrorInfo({...}) / new PartialErrorInfo({...}) / new .ErrorInfo({...}) - if (ts.isNewExpression(node) && node.arguments && node.arguments.length >= 1) { - const tail = ctorTrailingName(node.expression); - if (tail) { - const first = node.arguments[0]; - if (ts.isObjectLiteralExpression(first)) processObjectArg(first, node); - } - } - // ErrorInfo.fromValues({...}) - if (ts.isCallExpression(node) && node.arguments.length >= 1) { - const chain = propertyAccessChain(node.expression); - if (chain && chain.length >= 2 && chain[chain.length - 1] === 'fromValues') { - const ctorName = chain[chain.length - 2]; - if (ERROR_INFO_CTOR_NAMES.has(ctorName)) { - const first = node.arguments[0]; - if (ts.isObjectLiteralExpression(first)) processObjectArg(first, node); - } - } - } - node.forEachChild(visit); - } - - visit(sourceFile); - return entries; -} - -interface Failure { - kind: 'empty' | 'rubric'; - entry: HintEntry; - detail: string; -} - -function check(entry: HintEntry): Failure[] { - const failures: Failure[] = []; - const text = entry.hintText.trim(); - if (text.length === 0) { - failures.push({ kind: 'empty', entry, detail: 'hint is empty' }); - return failures; - } - if (entry.code != null && RUBRIC[entry.code]) { - const rules = RUBRIC[entry.code].require; - for (const rule of rules) { - if (rule.kind === 'contains' && !text.includes(rule.value)) { - failures.push({ - kind: 'rubric', - entry, - detail: `code ${entry.code} hint must contain "${rule.value}"`, - }); - } else if (rule.kind === 'matches' && !rule.value.test(text)) { - failures.push({ - kind: 'rubric', - entry, - detail: `code ${entry.code} hint must match ${rule.value}`, - }); - } - } - } - return failures; -} - -async function main() { - const files = await glob('**/*.ts', { - cwd: SRC_ROOT, - absolute: true, - ignore: ['**/*.test.ts', '**/*.d.ts'], - }); - - const allEntries: HintEntry[] = files.flatMap(extractHints); - - const failures: Failure[] = []; - for (const e of allEntries) { - failures.push(...check(e)); - } - - // Per-code summary for visibility. - const byCode = new Map(); - for (const e of allEntries) { - const list = byCode.get(e.code) ?? []; - list.push(e); - byCode.set(e.code, list); - } - - console.log(`hint-coverage: scanned ${files.length} files, found ${allEntries.length} hint sites\n`); - const codes = [...byCode.keys()].sort((a, b) => (a ?? -1) - (b ?? -1)); - for (const code of codes) { - const list = byCode.get(code)!; - const rubric = code != null && RUBRIC[code] ? ` [${RUBRIC[code].description}]` : ''; - console.log(` ${code ?? '(no code)'} × ${list.length}${rubric}`); - } - - if (failures.length === 0) { - console.log(`\n✓ all hints non-empty and pass rubric checks`); - return; - } - - console.log(`\n✗ ${failures.length} failure(s):\n`); - for (const f of failures) { - console.log(` ${f.entry.file}:${f.entry.line} (code ${f.entry.code ?? 'unknown'})`); - console.log(` ${f.detail}`); - console.log( - ` hint: ${JSON.stringify(f.entry.hintText.slice(0, 120))}${f.entry.hintText.length > 120 ? '…' : ''}`, - ); - } - exit(1); -} - -main().catch((err) => { - console.error(err); - exit(2); -}); diff --git a/test/unit/error-hints.test.js b/test/unit/error-hints.test.js deleted file mode 100644 index 0f56bd1fae..0000000000 --- a/test/unit/error-hints.test.js +++ /dev/null @@ -1,86 +0,0 @@ -'use strict'; - -/* - * DX-1209 — hint pinning tests for inline SDK throw sites. - * - * Tests pin the hint strings so we don't drift the LLM-discoverable surface. - * If you change a hint, update the assertion too — the drift check is the - * point. - * - * Hints for server-relayed codes (40140 token expired, 40160 capability, etc.) - * are out of scope for this ticket; that propagation is tracked separately. - */ - -define(['chai', 'ably'], function (chai, Ably) { - const { expect } = chai; - - describe('DX-1209 / error hints — inline at SDK throw sites', function () { - describe('BaseClient / ClientOptions construction', function () { - it('invalid key format (40400) carries a key-format hint', function () { - try { - new Ably.Rest({ key: 'not-a-valid-key' }); - throw new Error('expected constructor to throw'); - } catch (err) { - expect(err.code).to.equal(40400); - expect(err.hint).to.be.a('string'); - expect(err.hint).to.contain('appId'); - expect(err.hint).to.contain('keyId'); - } - }); - - it('wildcard clientId (40012) carries a defaultTokenParams hint', function () { - try { - new Ably.Rest({ key: 'a.b:c', clientId: '*' }); - throw new Error('expected constructor to throw'); - } catch (err) { - expect(err.code).to.equal(40012); - expect(err.hint).to.be.a('string'); - expect(err.hint).to.contain('defaultTokenParams'); - } - }); - - it('no auth options (40160) carries a key/authUrl/authCallback hint', function () { - try { - new Ably.Rest({}); - throw new Error('expected constructor to throw'); - } catch (err) { - expect(err.code).to.equal(40160); - expect(err.hint).to.be.a('string'); - expect(err.hint).to.contain('authUrl'); - expect(err.hint).to.contain('authCallback'); - } - }); - - it('endpoint + environment together (40106) carries a v2-naming hint', function () { - try { - new Ably.Rest({ key: 'a.b:c', endpoint: 'foo', environment: 'sandbox' }); - throw new Error('expected constructor to throw'); - } catch (err) { - expect(err.code).to.equal(40106); - expect(err.hint).to.be.a('string'); - expect(err.hint).to.contain('endpoint'); - expect(err.hint).to.contain('legacy'); - } - }); - }); - - describe('Utils.createMissingPluginError', function () { - it('missing plugin error carries an import hint', function () { - const rest = new Ably.Rest({ key: 'a.b:c' }); - try { - // _FilteredSubscriptions is the stable internal trigger for - // createMissingPluginError('MessageInteractions'); used here only - // because no public API exposes the missing-plugin throw without - // first connecting a Realtime client. - rest._FilteredSubscriptions; - throw new Error('expected getter to throw'); - } catch (err) { - expect(err.code).to.equal(40019); - expect(err.hint).to.be.a('string'); - expect(err.hint).to.contain('ably/modular'); - expect(err.hint).to.contain('plugins'); - } - }); - }); - }); -}); From f0c80f2c2a781fa25f5610dd897af6716aeb2c30 Mon Sep 17 00:00:00 2001 From: umair Date: Wed, 3 Jun 2026 10:34:01 +0100 Subject: [PATCH 16/20] DX-1209: second message/hint redundancy pass Per PR review, message and hint must not overlap: message states only "what" went wrong; hint states only "how" to fix it. A workflow assessed all 93 message/hint pairs and tightened the 19 remaining offenders: - Stripped restated-"what" preambles from hints (realtimepresence, connectionmanager, basemessage, auth, push, pushchannel, rest, paginatedresource, defaults, getW3CDeviceDetails). - Reduced messages that carried remediation already present in the hint (wildcard-clientId in auth/baseclient; setOptions guidance in baserealtime). No actionable guidance lost - removed text was duplicated in its counterpart. tsc --noEmit clean for all edited files. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/common/lib/client/auth.ts | 12 +++++------- src/common/lib/client/baseclient.ts | 3 +-- src/common/lib/client/baserealtime.ts | 3 +-- src/common/lib/client/paginatedresource.ts | 4 ++-- src/common/lib/client/push.ts | 4 ++-- src/common/lib/client/realtimepresence.ts | 6 +++--- src/common/lib/client/rest.ts | 2 +- src/common/lib/transport/connectionmanager.ts | 4 ++-- src/common/lib/types/basemessage.ts | 2 +- src/common/lib/util/defaults.ts | 2 +- src/common/lib/util/utils.ts | 2 +- src/plugins/push/getW3CDeviceDetails.ts | 2 +- src/plugins/push/pushactivation.ts | 2 +- src/plugins/push/pushchannel.ts | 4 ++-- 14 files changed, 24 insertions(+), 28 deletions(-) diff --git a/src/common/lib/client/auth.ts b/src/common/lib/client/auth.ts index e6f2969673..217e9c2083 100644 --- a/src/common/lib/client/auth.ts +++ b/src/common/lib/client/auth.ts @@ -155,8 +155,7 @@ 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({ message: msg, @@ -685,7 +684,7 @@ class Auth { message: 'Token string is empty', code: 40170, statusCode: 401, - hint: 'Your authCallback returned an empty string. Return a non-empty token string, or a TokenDetails/TokenRequest object.', + hint: 'Return a non-empty token string, or a TokenDetails/TokenRequest object.', }); reject(err); } else if (tokenRequestOrDetails.length > MAX_TOKEN_LENGTH) { @@ -702,7 +701,7 @@ class Auth { message: 'Token string was literal null/undefined', code: 40170, statusCode: 401, - hint: 'Your authCallback stringified a null/undefined token. Return the token itself, not "undefined"/"null"; callbacks that have no value to return should pass an error instead.', + hint: 'Return the token itself, not "undefined"/"null"; callbacks that have no value to return should pass an error instead.', }); reject(err); } else if ( @@ -713,7 +712,7 @@ class Auth { message: 'Token was double-encoded', code: 40170, statusCode: 401, - hint: 'The endpoint returned a JSON-encoded string starting with `{`, but the Content-Type was not application/jwt - likely a stringified TokenDetails. Return TokenDetails/TokenRequest as a parsed object, or set Content-Type: application/jwt for JWT tokens.', + hint: 'Return TokenDetails/TokenRequest as a parsed object, or set Content-Type: application/jwt for JWT tokens.', }); reject(err); } else { @@ -1055,8 +1054,7 @@ class Auth { }); } else if (clientId === '*') { throw new ErrorInfo({ - message: - '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)', + message: 'Can’t use "*" as a clientId as that string is reserved', code: 40012, statusCode: 400, hint: 'Move "*" out of ClientOptions.clientId. For a wildcard token, set defaultTokenParams: { clientId: "*" } on the client, or pass it to authorize() as a tokenParam. The API key must have wildcard-clientId capability in the Ably dashboard, otherwise the server rejects the token request. If you have the Ably CLI installed, `ably auth keys list` shows your key\'s capabilities.', diff --git a/src/common/lib/client/baseclient.ts b/src/common/lib/client/baseclient.ts index 8232776ce2..0701477755 100644 --- a/src/common/lib/client/baseclient.ts +++ b/src/common/lib/client/baseclient.ts @@ -97,8 +97,7 @@ class BaseClient { }); } else if (normalOptions.clientId === '*') { throw new ErrorInfo({ - message: - '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: "*"}})', + message: 'Can’t use "*" as a clientId as that string is reserved.', code: 40012, statusCode: 400, hint: 'Move "*" out of ClientOptions.clientId. For a wildcard token, set defaultTokenParams: { clientId: "*" } on the client instead. The API key must have wildcard-clientId capability in the Ably dashboard, otherwise the server rejects the token request. If you have the Ably CLI installed, `ably auth keys list` shows your key\'s capabilities.', diff --git a/src/common/lib/client/baserealtime.ts b/src/common/lib/client/baserealtime.ts index b7f6ae369a..f6cf049970 100644 --- a/src/common/lib/client/baserealtime.ts +++ b/src/common/lib/client/baserealtime.ts @@ -200,8 +200,7 @@ class Channels extends EventEmitter { } else if (channelOptions) { if (channel._shouldReattachToSetOptions(channelOptions, channel.channelOptions)) { throw new ErrorInfo({ - message: - 'Channels.get() cannot be used to set channel options that would cause the channel to reattach. Please, use RealtimeChannel.setOptions() instead.', + message: 'Channels.get() cannot be used to set channel options that would cause the channel to reattach.', code: 40000, statusCode: 400, hint: 'channels.get(name) returns the existing channel - to change params or modes, call channel.setOptions(opts) on the returned instance (it will re-attach if the new options differ from current).', diff --git a/src/common/lib/client/paginatedresource.ts b/src/common/lib/client/paginatedresource.ts index 241bcc4100..4f93d4eea2 100644 --- a/src/common/lib/client/paginatedresource.ts +++ b/src/common/lib/client/paginatedresource.ts @@ -151,7 +151,7 @@ export class PaginatedResult { message: 'No link to the first page of results', code: 40400, statusCode: 404, - hint: 'Check hasFirst() before calling first(). Empty result sets and single-page responses have no first-page link.', + hint: 'Check hasFirst() before calling first().', }); } @@ -164,7 +164,7 @@ export class PaginatedResult { message: 'No link to the current page of results', code: 40400, statusCode: 404, - hint: 'Check hasCurrent() before calling current(). The current-page link is only set after at least one page has been fetched.', + hint: 'Check hasCurrent() before calling current().', }); } diff --git a/src/common/lib/client/push.ts b/src/common/lib/client/push.ts index cf3b6184b2..1122a2ea31 100644 --- a/src/common/lib/client/push.ts +++ b/src/common/lib/client/push.ts @@ -54,7 +54,7 @@ class Push { message: 'Activation already in progress', code: 40000, statusCode: 400, - hint: 'Await the in-flight push.activate() before calling it again. Concurrent activations are not supported.', + hint: 'Await the in-flight push.activate() before calling it again.', }); reject(err); return; @@ -94,7 +94,7 @@ class Push { message: 'Deactivation already in progress', code: 40000, statusCode: 400, - hint: 'Await the in-flight push.deactivate() before calling it again. Concurrent deactivations are not supported.', + hint: 'Await the in-flight push.deactivate() before calling it again.', }); reject(err); return; diff --git a/src/common/lib/client/realtimepresence.ts b/src/common/lib/client/realtimepresence.ts index 5868e6f2e1..f1d35b7277 100644 --- a/src/common/lib/client/realtimepresence.ts +++ b/src/common/lib/client/realtimepresence.ts @@ -167,7 +167,7 @@ class RealtimePresence extends EventEmitter { message: 'clientId must have been specified to enter or leave a presence channel', code: 40012, statusCode: 400, - hint: 'Anonymous clients cannot publish presence. Set ClientOptions.clientId (or include clientId in the token), or use presence.leaveClient(otherId) - leaveClient on behalf of a different identity requires a wildcard clientId on your API key/token.', + hint: 'Set ClientOptions.clientId (or include clientId in the token), or use presence.leaveClient(otherId) - leaveClient on behalf of a different identity requires a wildcard clientId on your API key/token.', }); } return this.leaveClient(undefined, data); @@ -213,7 +213,7 @@ class RealtimePresence extends EventEmitter { throw new PartialErrorInfo({ message: 'Unable to leave presence channel while in ' + channel.state + ' state', code: 90001, - hint: 'The channel is in "initialized" or "failed" state, so the client is not currently a presence member. From "initialized" there is no presence entry to leave; from "failed" inspect channel.errorReason and re-attach before retrying.', + hint: 'From "initialized" there is no presence entry to leave; from "failed" inspect channel.errorReason and re-attach before retrying.', }); } } @@ -449,7 +449,7 @@ class RealtimePresence extends EventEmitter { code: 91004, statusCode: 400, cause: err, - hint: 'After a connection recovery the SDK could not re-enter this client into presence. Listen for the channel "update" event and call presence.enter(...) again once the channel is attached.', + hint: 'Listen for the channel "update" event and call presence.enter(...) again once the channel is attached.', }); Logger.logAction( this.logger, diff --git a/src/common/lib/client/rest.ts b/src/common/lib/client/rest.ts index f9dd66f0ad..ce93eb1a80 100644 --- a/src/common/lib/client/rest.ts +++ b/src/common/lib/client/rest.ts @@ -221,7 +221,7 @@ export class Rest { message: 'Cannot revoke tokens when using token auth', code: 40162, statusCode: 401, - hint: 'Token revocation requires basic auth. Construct a separate Ably.Rest client with ClientOptions.key (the API key with the revoke capability) just for this call. If you have the Ably CLI installed, `ably auth keys list` shows which keys have the revoke capability.', + hint: 'Construct a separate Ably.Rest client with ClientOptions.key (the API key with the revoke capability) just for this call. If you have the Ably CLI installed, `ably auth keys list` shows which keys have the revoke capability.', }); } diff --git a/src/common/lib/transport/connectionmanager.ts b/src/common/lib/transport/connectionmanager.ts index 9c96640923..a23e72c9c8 100644 --- a/src/common/lib/transport/connectionmanager.ts +++ b/src/common/lib/transport/connectionmanager.ts @@ -1972,7 +1972,7 @@ class ConnectionManager extends EventEmitter { code: 80019, statusCode: 403, cause: err, - hint: 'Token authorization was refused with 403. This can be your authUrl/authCallback rejecting the request, or the Ably server refusing the resulting TokenRequest (e.g. when the request asks for capabilities outside the API key). Inspect cause for the underlying error.', + hint: 'This can be your authUrl/authCallback rejecting the request, or the Ably server refusing the resulting TokenRequest (e.g. when the request asks for capabilities outside the API key). Inspect cause for the underlying error.', }); this.notifyState({ state: 'failed', error: wrapped }); } else { @@ -1983,7 +1983,7 @@ class ConnectionManager extends EventEmitter { code: 80019, statusCode: 401, cause: err, - hint: 'Your authUrl/authCallback could not be reached or returned an error. Check network connectivity to the auth endpoint and that it returns a valid token shape; the underlying error is in cause.', + hint: 'Check network connectivity to your authUrl/authCallback endpoint and that it returns a valid token shape; the underlying error is in cause.', }); 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 fcef615768..ad51bda443 100644 --- a/src/common/lib/types/basemessage.ts +++ b/src/common/lib/types/basemessage.ts @@ -221,7 +221,7 @@ export async function decodeData( message: 'Missing Vcdiff decoder (https://github.com/ably-forks/vcdiff-decoder)', code: 40019, statusCode: 400, - hint: 'You enabled the delta channel option but did not provide the Vcdiff plugin. Install @ably/vcdiff-decoder and pass it in ClientOptions.plugins.vcdiff.', + hint: 'Install @ably/vcdiff-decoder and pass it in ClientOptions.plugins.vcdiff.', }); } if (typeof Uint8Array === 'undefined') { diff --git a/src/common/lib/util/defaults.ts b/src/common/lib/util/defaults.ts index 6af6ade13d..5cb574ad35 100644 --- a/src/common/lib/util/defaults.ts +++ b/src/common/lib/util/defaults.ts @@ -277,7 +277,7 @@ function checkIfClientOptionsAreValid(options: ClientOptions) { message: 'The `environment` option cannot be used in conjunction with the `restHost`, or `realtimeHost` options.', code: 40106, statusCode: 400, - hint: 'These are mutually exclusive legacy host options. Replace all of them with the v2 `endpoint` option, which subsumes both.', + hint: 'Replace all of them with the v2 `endpoint` option, which subsumes both.', }); } } diff --git a/src/common/lib/util/utils.ts b/src/common/lib/util/utils.ts index b730ae5e85..0ba85088d0 100644 --- a/src/common/lib/util/utils.ts +++ b/src/common/lib/util/utils.ts @@ -486,7 +486,7 @@ export function matchDerivedChannel(name: string) { message: `cannot use a derived option with a ${match[2]} channel`, code: 40010, statusCode: 400, - hint: `A "${match[2]}" channel already has a qualifier; derived options like filter= cannot be layered on top. Use a base channel name instead.`, + hint: `Use a base channel name instead, without the "${match[2]}" qualifier.`, }); } // Return match values to be added to derive channel quantifier. diff --git a/src/plugins/push/getW3CDeviceDetails.ts b/src/plugins/push/getW3CDeviceDetails.ts index 76f5813558..28e685b75d 100644 --- a/src/plugins/push/getW3CDeviceDetails.ts +++ b/src/plugins/push/getW3CDeviceDetails.ts @@ -32,7 +32,7 @@ export async function getW3CPushDeviceDetails(machine: ActivationStateMachine) { message: 'User denied permission to send notifications', code: 40000, statusCode: 400, - hint: 'The browser denied the Notification permission prompt. The user must accept notifications before push activation can complete; surface a UI explaining the value before requesting again.', + hint: 'Surface a UI explaining the value of notifications, then request permission again; push activation can only complete once the user accepts.', }); machine.handleEvent(new GettingPushDeviceDetailsFailed(err)); return; diff --git a/src/plugins/push/pushactivation.ts b/src/plugins/push/pushactivation.ts index c0b8c3cc46..6228e04f7a 100644 --- a/src/plugins/push/pushactivation.ts +++ b/src/plugins/push/pushactivation.ts @@ -261,7 +261,7 @@ export class ActivationStateMachine { message: 'registerCallback did not return deviceRegistration', code: 40000, statusCode: 400, - hint: 'Your registerCallback must invoke its callback with (null, deviceRegistration). Returning undefined or null in the second argument fails activation.', + hint: 'Your registerCallback must invoke its callback with (null, deviceRegistration).', }); this.handleEvent(new GettingDeviceRegistrationFailed(err)); return; diff --git a/src/plugins/push/pushchannel.ts b/src/plugins/push/pushchannel.ts index 4b1df87213..06012879ad 100644 --- a/src/plugins/push/pushchannel.ts +++ b/src/plugins/push/pushchannel.ts @@ -54,7 +54,7 @@ class PushChannel { message: 'Cannot subscribe from client without client ID', code: 50000, statusCode: 500, - hint: 'Set ClientOptions.clientId (or include clientId in the token) before calling pushChannel.subscribeClient(). Anonymous clients cannot subscribe to push by clientId.', + hint: 'Set ClientOptions.clientId (or include clientId in the token) before calling pushChannel.subscribeClient().', }); } const format = client.options.useBinaryProtocol ? client.Utils.Format.msgpack : client.Utils.Format.json, @@ -119,7 +119,7 @@ class PushChannel { message: 'Cannot subscribe or unsubscribe this device without a deviceIdentityToken', code: 50000, statusCode: 500, - hint: 'Activate this device first by awaiting client.push.activate(registerCallback) - the device must hold an identity token before subscribing or unsubscribing via the push channel.', + hint: 'Activate this device first by awaiting client.push.activate(registerCallback).', }); } } From 35f34928832001f638d2096bc423ca0da6d257b8 Mon Sep 17 00:00:00 2001 From: umair Date: Wed, 24 Jun 2026 16:28:45 +0100 Subject: [PATCH 17/20] language tweaks based on PR feedback --- src/common/lib/client/auth.ts | 29 ++++++++++--------- src/common/lib/client/baseclient.ts | 4 +-- src/common/lib/client/paginatedresource.ts | 4 +-- src/common/lib/client/push.ts | 2 +- src/common/lib/client/realtimeannotations.ts | 2 +- src/common/lib/client/realtimechannel.ts | 4 +-- src/common/lib/client/realtimepresence.ts | 8 ++--- src/common/lib/client/rest.ts | 2 +- src/common/lib/client/restchannel.ts | 2 +- src/common/lib/client/restchannelmixin.ts | 6 ++-- src/common/lib/transport/connectionmanager.ts | 2 +- src/common/lib/types/basemessage.ts | 2 +- src/common/lib/util/defaults.ts | 4 +-- src/common/lib/util/utils.ts | 6 ++-- src/plugins/liveobjects/realtimeobject.ts | 4 +-- src/plugins/push/getW3CDeviceDetails.ts | 2 +- 16 files changed, 43 insertions(+), 40 deletions(-) diff --git a/src/common/lib/client/auth.ts b/src/common/lib/client/auth.ts index 217e9c2083..82b3c15e04 100644 --- a/src/common/lib/client/auth.ts +++ b/src/common/lib/client/auth.ts @@ -496,10 +496,10 @@ class Auth { if (Platform.BufferUtils.isBuffer(body)) body = body.toString(); if (!contentType) { const err = new ErrorInfo({ - message: 'authUrl response is missing a content-type header', + message: 'authUrl response is missing a Content-Type header', code: 40170, statusCode: 401, - hint: 'Auth endpoints may return a Content-Type of application/json (TokenDetails/TokenRequest), text/plain (token string) or application/jwt.', + 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; @@ -514,7 +514,7 @@ class Auth { '. Expected one of: text/plain, application/jwt or application/json', code: 40170, statusCode: 401, - hint: 'Auth endpoints may return a Content-Type of application/json (TokenDetails/TokenRequest), text/plain (token string) or application/jwt; the SDK cannot parse other content types.', + 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; @@ -522,10 +522,10 @@ class Auth { if (json) { if ((body as string).length > MAX_TOKEN_LENGTH) { const err = new ErrorInfo({ - message: 'authUrl response exceeded max permitted length', + message: 'authUrl JSON response exceeded the maximum permitted length of 128 KB', code: 40170, statusCode: 401, - hint: 'authUrl payloads must be under 128 KB. If your TokenDetails legitimately contains a large capability, trim unused fields or set authOptions.suppressMaxLengthCheck. Otherwise check the endpoint is returning only the token shape, not wrapped in extra JSON.', + hint: 'Make your authUrl endpoint return only the token payload (a TokenDetails or TokenRequest object, or a token string), not an envelope wrapping it in extra fields. If a TokenDetails genuinely needs a large capability, narrow the capability to the channels and operations the client actually needs.', }); cb(err, null); return; @@ -652,13 +652,16 @@ 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); const err = new ErrorInfo({ message: msg, code: 40170, statusCode: 401, - hint: 'authCallback did not invoke its callback within the timeout, or authUrl did not respond. Check that the callback runs to completion on every code path and that authUrl is reachable.', + 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); @@ -684,7 +687,7 @@ class Auth { message: 'Token string is empty', code: 40170, statusCode: 401, - hint: 'Return a non-empty token string, or a TokenDetails/TokenRequest object.', + hint: 'Return a non-empty value from your authCallback/authUrl: a token string, a TokenRequest, or a TokenDetails object. The callback returned an empty string.', }); reject(err); } else if (tokenRequestOrDetails.length > MAX_TOKEN_LENGTH) { @@ -692,7 +695,7 @@ class Auth { message: 'Token string exceeded max permitted length (was ' + tokenRequestOrDetails.length + ' bytes)', code: 40170, statusCode: 401, - hint: 'Tokens must be under 128 KB. If the TokenDetails legitimately contains a large capability, trim unused fields. Otherwise check the endpoint is returning only the token, not wrapped in extra data.', + hint: 'Return only the bare signed token string from your authCallback/authUrl, not an envelope, debug output, or a stringified TokenDetails wrapping it, since tokens must serialise to under 128 KB. If the token capability is genuinely this large, narrow it to the channels and operations the client needs.', }); reject(err); } else if (tokenRequestOrDetails === 'undefined' || tokenRequestOrDetails === 'null') { @@ -741,7 +744,7 @@ class Auth { 'Token request/details object exceeded max permitted stringified size (was ' + objectSize + ' bytes)', code: 40170, statusCode: 401, - hint: 'Token objects must serialise to under 128 KB. Trim unused fields from your TokenDetails/TokenRequest, or set authOptions.suppressMaxLengthCheck if you understand the risk.', + hint: 'Token objects must serialise to under 128 KB. Trim unused fields from your TokenDetails/TokenRequest, and if a large capability is required, narrow it to the channels and operations the client actually needs.', }); reject(err); return; @@ -759,7 +762,7 @@ class Auth { message: msg, code: 40170, statusCode: 401, - hint: 'Your authCallback/authUrl returned an object without a `keyName` (so it was treated as a TokenDetails) and that shape was also rejected. Return either a token string, a TokenRequest (with keyName), or a TokenDetails (with token).', + 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. The returned object had neither field, so it matched neither shape; note that a bare `token` field is not enough to identify a TokenDetails here, the `issued` field is what the SDK keys on.', }); reject(err); return; @@ -848,7 +851,7 @@ class Auth { message: 'Invalid key specified', code: 40101, statusCode: 403, - hint: 'API keys are "appId.keyId:secret". Copy the full key including the colon from the Ably dashboard. If you have the Ably CLI installed, `ably auth keys list` shows the keys configured on the current app.', + hint: 'Copy the full "appId.keyId:secret" key including the colon and secret from the Ably dashboard, since the key you passed has no colon-separated secret. If you have the Ably CLI installed, `ably auth keys list` shows the keys configured on the current app.', }); } @@ -1057,7 +1060,7 @@ class Auth { message: 'Can’t use "*" as a clientId as that string is reserved', code: 40012, statusCode: 400, - hint: 'Move "*" out of ClientOptions.clientId. For a wildcard token, set defaultTokenParams: { clientId: "*" } on the client, or pass it to authorize() as a tokenParam. The API key must have wildcard-clientId capability in the Ably dashboard, otherwise the server rejects the token request. If you have the Ably CLI installed, `ably auth keys list` shows your key\'s capabilities.', + hint: 'Move "*" out of ClientOptions.clientId; for a wildcard identity set defaultTokenParams: { clientId: "*" } on the client, or pass { clientId: "*" } to authorize() as a tokenParam. A client using a raw API key can already assume any clientId, so a wildcard token request succeeds by default; it is rejected only when the token or key issuing it is itself restricted to a single clientId.', }); } else { const err = this._uncheckedSetClientId(clientId); diff --git a/src/common/lib/client/baseclient.ts b/src/common/lib/client/baseclient.ts index 0701477755..f9d15fd99a 100644 --- a/src/common/lib/client/baseclient.ts +++ b/src/common/lib/client/baseclient.ts @@ -93,14 +93,14 @@ class BaseClient { message: 'clientId must be either a string or null', code: 40012, statusCode: 400, - hint: 'Pass a string (e.g. a user id) or null for an anonymous client. Numbers and objects are not accepted.', + hint: 'For an anonymous client omit clientId or pass null; to identify the client pass a stable string such as a user id. To authenticate as any clientId, use a wildcard token (defaultTokenParams: { clientId: "*" }) rather than setting clientId here.', }); } else if (normalOptions.clientId === '*') { throw new ErrorInfo({ message: 'Can’t use "*" as a clientId as that string is reserved.', code: 40012, statusCode: 400, - hint: 'Move "*" out of ClientOptions.clientId. For a wildcard token, set defaultTokenParams: { clientId: "*" } on the client instead. The API key must have wildcard-clientId capability in the Ably dashboard, otherwise the server rejects the token request. If you have the Ably CLI installed, `ably auth keys list` shows your key\'s capabilities.', + hint: 'Move "*" out of ClientOptions.clientId. For a wildcard identity, set defaultTokenParams: { clientId: "*" } on the client instead. A client using a raw API key can already assume any clientId, so a wildcard token request succeeds by default; it is rejected only when the token or key issuing it is itself restricted to a single clientId.', }); } } diff --git a/src/common/lib/client/paginatedresource.ts b/src/common/lib/client/paginatedresource.ts index 4f93d4eea2..492cfdfce8 100644 --- a/src/common/lib/client/paginatedresource.ts +++ b/src/common/lib/client/paginatedresource.ts @@ -151,7 +151,7 @@ export class PaginatedResult { message: 'No link to the first page of results', code: 40400, statusCode: 404, - hint: 'Check hasFirst() before calling first().', + hint: 'The public PaginatedResult surface exposes no boolean guard for first(), so wrap the call in try/catch or attach a .catch handler to the promise it returns rather than pre-checking. A first-page link exists only on a page whose paginated REST response carried a Link header with rel="first", so expect first() to reject on results that were not produced by such a query.', }); } @@ -164,7 +164,7 @@ export class PaginatedResult { message: 'No link to the current page of results', code: 40400, statusCode: 404, - hint: 'Check hasCurrent() before calling current().', + hint: 'Call current() only on a PaginatedResult returned by a paginated REST query (such as channel history, presence, or stats) whose response carried pagination Link headers. This page has no rel="current" navigation link, so there is nothing to reload; if you only need to walk pages, use next() together with isLast() instead, since those are the public navigation methods this result type exposes.', }); } diff --git a/src/common/lib/client/push.ts b/src/common/lib/client/push.ts index 1122a2ea31..8ff8aff19f 100644 --- a/src/common/lib/client/push.ts +++ b/src/common/lib/client/push.ts @@ -187,7 +187,7 @@ class DeviceRegistrations { message: 'First argument to DeviceRegistrations#get must be a deviceId string or DeviceDetails', code: 40000, statusCode: 400, - hint: 'Pass either the device id string returned from push.activate(), or the DeviceDetails object (with a non-empty .id field).', + hint: 'Pass either the device id string (the local device id, e.g. from client.device().id after push.activate() completes, or the .id of a DeviceDetails returned by push.admin.deviceRegistrations.save()), or a DeviceDetails object with a non-empty .id field.', }); } diff --git a/src/common/lib/client/realtimeannotations.ts b/src/common/lib/client/realtimeannotations.ts index 0b0951b0d7..e45a0c09b6 100644 --- a/src/common/lib/client/realtimeannotations.ts +++ b/src/common/lib/client/realtimeannotations.ts @@ -76,7 +76,7 @@ class RealtimeAnnotations { "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: 'Re-create the channel with annotation_subscribe in modes: realtime.channels.get(name, { modes: ["subscribe", "annotation_subscribe", ...] }). If the subsequent attach is rejected by the server, check that the channel namespace has "Message annotations, updates, 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 channel namespaces have Mutable Messages enabled, and `ably auth keys list` shows your key\'s capabilities. Note: appending to channel.modes after attach() does not enable the mode server-side - the array reflects what the server granted, not what you requested.', + hint: 'Re-create the channel with annotation_subscribe in modes, e.g. realtime.channels.get(name, { modes: ["subscribe", "annotation_subscribe"] }), since appending to channel.modes after attach() does not enable the mode server-side. If the subsequent 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 3c70d970bd..bc3e1344d2 100644 --- a/src/common/lib/client/realtimechannel.ts +++ b/src/common/lib/client/realtimechannel.ts @@ -1084,7 +1084,7 @@ class RealtimeChannel extends EventEmitter { message: 'untilAttach was specified and channel is attached, but attachSerial is not defined', code: 40000, statusCode: 400, - hint: 'Re-attach the channel and try again; the SDK could not record an attachSerial from the previous attach.', + hint: 'Detach the channel (await channel.detach()) and re-attach (await channel.attach()) to force the SDK to record a fresh attachSerial, then retry history({ untilAttach: true }).', }); } delete params.untilAttach; @@ -1178,7 +1178,7 @@ class RealtimeChannel extends EventEmitter { message: 'This message lacks a serial and cannot be updated', code: 40003, statusCode: 400, - hint: 'Pass the Message you received from a subscribe callback (which carries .serial), not a freshly constructed object. The namespace must enable message annotations/updates/deletes in the Ably dashboard. If you have the Ably CLI installed, `ably apps rules list` shows which channel namespaces have Mutable Messages enabled.', + hint: 'Pass the Message you received from a subscribe callback (which carries .serial), not a freshly constructed object. The channel namespace must have "Message annotations, updates, appends, and deletes" enabled in the Ably dashboard. If you have the Ably CLI installed, `ably apps rules list` shows which namespaces have it enabled.', }); } diff --git a/src/common/lib/client/realtimepresence.ts b/src/common/lib/client/realtimepresence.ts index f1d35b7277..743a70b5bc 100644 --- a/src/common/lib/client/realtimepresence.ts +++ b/src/common/lib/client/realtimepresence.ts @@ -66,7 +66,7 @@ class RealtimePresence extends EventEmitter { 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(). To enter on behalf of another identity, use presence.enterClient(otherId, data) - this requires a wildcard clientId on your API key/token, otherwise the server rejects the request.', + hint: 'Set ClientOptions.clientId (or include clientId in the token) before calling presence.enter() again; once a clientId is set the enter still requires the API key or token to grant the presence capability on this channel (and the channel must reach the attached state), or the server rejects it. To enter on behalf of another identity, use presence.enterClient(otherId, data), which additionally requires a wildcard clientId on your API key or token.', }); } return this._enterOrUpdateClient(undefined, undefined, data, 'enter'); @@ -83,7 +83,7 @@ class RealtimePresence extends EventEmitter { 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(). To update on behalf of another identity, use presence.updateClient(otherId, data) - this requires a wildcard clientId on your API key/token, otherwise the server rejects the request.', + hint: 'Set ClientOptions.clientId (or include clientId in the token) before calling presence.update() again; once a clientId is set the update still requires the API key or token to grant the presence capability on this channel, or the server rejects it. To update on behalf of another identity, use presence.updateClient(otherId, data), which additionally requires a wildcard clientId on your API key or token.', }); } return this._enterOrUpdateClient(undefined, undefined, data, 'update'); @@ -167,7 +167,7 @@ class RealtimePresence extends EventEmitter { message: 'clientId must have been specified to enter or leave a presence channel', code: 40012, statusCode: 400, - hint: 'Set ClientOptions.clientId (or include clientId in the token), or use presence.leaveClient(otherId) - leaveClient on behalf of a different identity requires a wildcard clientId on your API key/token.', + hint: 'Set ClientOptions.clientId (or include clientId in the token) before retrying presence.leave(), or call presence.leaveClient(otherId) to leave on behalf of another identity. Either way the API key or token must also carry the presence capability for this channel server-side, and leaveClient for a different identity additionally requires a wildcard clientId, otherwise the server rejects the request.', }); } return this.leaveClient(undefined, data); @@ -213,7 +213,7 @@ class RealtimePresence extends EventEmitter { throw new PartialErrorInfo({ message: 'Unable to leave presence channel while in ' + channel.state + ' state', code: 90001, - hint: 'From "initialized" there is no presence entry to leave; from "failed" inspect channel.errorReason and re-attach before retrying.', + hint: 'Inspect channel.errorReason for the cause, then await channel.attach() and retry presence.leave() if the channel state is "failed". If the state is "initialized" no member was ever entered, so there is nothing to leave and no action is needed.', }); } } diff --git a/src/common/lib/client/rest.ts b/src/common/lib/client/rest.ts index ce93eb1a80..24593df85e 100644 --- a/src/common/lib/client/rest.ts +++ b/src/common/lib/client/rest.ts @@ -221,7 +221,7 @@ export class Rest { message: 'Cannot revoke tokens when using token auth', code: 40162, statusCode: 401, - hint: 'Construct a separate Ably.Rest client with ClientOptions.key (the API key with the revoke capability) just for this call. If you have the Ably CLI installed, `ably auth keys list` shows which keys have the revoke capability.', + hint: 'Token revocation must use basic auth, so construct a separate Ably.Rest client with ClientOptions.key (a raw API key) just for this call. The key must have had token revocation enabled (its "Revocable tokens" setting in the Ably dashboard) before the tokens were issued, otherwise there is nothing to revoke.', }); } diff --git a/src/common/lib/client/restchannel.ts b/src/common/lib/client/restchannel.ts index a1ee8af59b..a497e3160c 100644 --- a/src/common/lib/client/restchannel.ts +++ b/src/common/lib/client/restchannel.ts @@ -144,7 +144,7 @@ class RestChannel { message: `Maximum size of messages that can be published at once exceeded (was ${size} bytes; limit is ${maxMessageSize} bytes)`, code: 40009, statusCode: 400, - hint: 'Split the publish into multiple calls so each batch is under the limit, or contact support to raise maxMessageSize for your app.', + 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 itself, contact Ably support.', }); } diff --git a/src/common/lib/client/restchannelmixin.ts b/src/common/lib/client/restchannelmixin.ts index e77b03ca1f..833c4865a6 100644 --- a/src/common/lib/client/restchannelmixin.ts +++ b/src/common/lib/client/restchannelmixin.ts @@ -68,7 +68,7 @@ export class RestChannelMixin { 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. The namespace must enable message annotations/updates/deletes in the Ably dashboard. If you have the Ably CLI installed, `ably apps rules list` shows which channel namespaces have Mutable Messages enabled.', + hint: 'Pass `getMessage` a non-empty serial string, or a `Message` delivered by a subscribe callback (which carries a populated `.serial`) rather than a freshly constructed `Message` whose `.serial` is unset.', }); } @@ -102,7 +102,7 @@ export class RestChannelMixin { message: 'This message lacks a serial and cannot be updated', code: 40003, statusCode: 400, - hint: 'Pass the Message received from a subscribe callback (which carries .serial), not a freshly constructed object. The namespace must enable message annotations/updates/deletes in the Ably dashboard. If you have the Ably CLI installed, `ably apps rules list` shows which channel namespaces have Mutable Messages enabled.', + hint: 'Pass the Message received from a subscribe callback (which carries .serial), not a freshly constructed object. The channel namespace must have "Message annotations, updates, appends, and deletes" enabled in the Ably dashboard. If you have the Ably CLI installed, `ably apps rules list` shows which namespaces have it enabled.', }); } @@ -145,7 +145,7 @@ export class RestChannelMixin { 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. The namespace must enable message annotations/updates/deletes in the Ably dashboard. If you have the Ably CLI installed, `ably apps rules list` shows which channel namespaces have Mutable Messages enabled.', + hint: 'Pass a Message that carries a non-empty `.serial` (the object delivered to a subscribe or history callback), or pass that serial string directly, so `getMessageVersions` can address the message whose version history you want.', }); } diff --git a/src/common/lib/transport/connectionmanager.ts b/src/common/lib/transport/connectionmanager.ts index a23e72c9c8..74bc5d921a 100644 --- a/src/common/lib/transport/connectionmanager.ts +++ b/src/common/lib/transport/connectionmanager.ts @@ -1972,7 +1972,7 @@ class ConnectionManager extends EventEmitter { code: 80019, statusCode: 403, cause: err, - hint: 'This can be your authUrl/authCallback rejecting the request, or the Ably server refusing the resulting TokenRequest (e.g. when the request asks for capabilities outside the API key). Inspect cause for the underlying error.', + hint: 'Inspect cause for the underlying error: a 403 here means either your authUrl/authCallback rejected the request, or the Ably server refused the resulting TokenRequest. If the latter, narrow the requested capability to what your API key actually grants.', }); this.notifyState({ state: 'failed', error: wrapped }); } else { diff --git a/src/common/lib/types/basemessage.ts b/src/common/lib/types/basemessage.ts index ad51bda443..0893a44a94 100644 --- a/src/common/lib/types/basemessage.ts +++ b/src/common/lib/types/basemessage.ts @@ -251,7 +251,7 @@ export async function decodeData( message: 'Vcdiff delta decode failed with ' + e, code: 40018, statusCode: 400, - hint: 'The SDK recovers by re-attaching without delta. If you see this repeatedly, the base payload has diverged - disable channel deltas for this channel.', + hint: 'Disable deltas for this channel by not setting delta in the channel params if this recurs; the SDK otherwise recovers automatically by re-attaching without delta after the base payload diverges.', }); } continue; diff --git a/src/common/lib/util/defaults.ts b/src/common/lib/util/defaults.ts index 5cb574ad35..b0b0e8b36f 100644 --- a/src/common/lib/util/defaults.ts +++ b/src/common/lib/util/defaults.ts @@ -179,7 +179,7 @@ function checkHost(host: string): void { message: 'host must be a string; was a ' + typeof host, code: 40000, statusCode: 400, - hint: 'Pass `endpoint` as a single endpoint name string (e.g. "main"), not an array or object.', + hint: 'Pass each host option as a string: `endpoint` (e.g. "main"), and every entry of `fallbackHosts` (and `restHost`/`realtimeHost` if used) must be a non-array string.', }); } if (!host.length) { @@ -187,7 +187,7 @@ function checkHost(host: string): void { message: 'host must not be zero-length', code: 40000, statusCode: 400, - hint: 'Omit `endpoint`/`restHost`/`realtimeHost` to use the Ably default, or pass a non-empty hostname.', + hint: 'Remove any empty-string entry from the `fallbackHosts` array, or omit `fallbackHosts` entirely to use the Ably defaults. An empty `endpoint`/`restHost`/`realtimeHost` already falls back to the default and is not the cause here.', }); } } diff --git a/src/common/lib/util/utils.ts b/src/common/lib/util/utils.ts index 0ba85088d0..0343f7ca3b 100644 --- a/src/common/lib/util/utils.ts +++ b/src/common/lib/util/utils.ts @@ -294,7 +294,7 @@ export function detectV1Callback(args: ArrayLike, v2TrailingFnArity: nu statusCode: 400, 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', + 'See https://github.com/ably/ably-js/blob/main/docs/migration-guides/v2/lib.md.', }); } @@ -477,7 +477,7 @@ export function matchDerivedChannel(name: string) { message: 'Channel name does not match the [filter=...]name shape required for derived channels', code: 40010, statusCode: 400, - hint: 'See https://ably.com/docs/channels/options/derived.', + hint: 'Format the name as [filter=], for example "[filter=...]foo". 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 @@ -516,7 +516,7 @@ export function createMissingPluginError(pluginName: keyof ModularPlugins): Erro message: `${pluginName} plugin not provided`, code: 40019, statusCode: 400, - hint: `Import ${pluginName} from "ably/modular" and pass it in ClientOptions.plugins: { ${pluginName} }. See https://ably.com/docs/getting-started/modular.`, + 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.`, }); return err; } diff --git a/src/plugins/liveobjects/realtimeobject.ts b/src/plugins/liveobjects/realtimeobject.ts index 81adb75590..317fd1fa10 100644 --- a/src/plugins/liveobjects/realtimeobject.ts +++ b/src/plugins/liveobjects/realtimeobject.ts @@ -572,7 +572,7 @@ export class RealtimeObject { message: `"${expectedMode}" channel mode must be set for this operation`, code: 40024, statusCode: 400, - hint: `Re-create the channel with "${expectedMode}" in modes: realtime.channels.get(name, { modes: [..., "${expectedMode}"] }). If the subsequent attach is rejected by the server, check that the channel namespace has LiveObjects enabled in the Ably dashboard and that your API key has the corresponding capability on this channel. If you have the Ably CLI installed, \`ably apps rules list\` shows channel-namespace settings and \`ably auth keys list\` shows your key's capabilities. Note: appending to channel.modes after attach() does not enable the mode server-side - the array reflects what the server granted, not what you requested.`, + hint: `Re-create the channel with the required mode via realtime.channels.get(name, { modes: [..., "${expectedMode}"] }); appending to channel.modes after attach() does not enable the mode server-side, since that array reflects only what the server already granted. If the subsequent attach is rejected, check that the channel namespace has LiveObjects enabled in the Ably dashboard and that your API key has the corresponding capability on this channel. If you have the Ably CLI installed, \`ably apps rules list\` shows channel-namespace settings and \`ably auth keys list\` shows the capabilities granted to your key.`, }); } // RTO2b - otherwise as a best effort use user provided channel options @@ -581,7 +581,7 @@ export class RealtimeObject { message: `"${expectedMode}" channel mode must be set for this operation`, code: 40024, statusCode: 400, - hint: `Re-create the channel with "${expectedMode}" in modes: realtime.channels.get(name, { modes: [..., "${expectedMode}"] }). If the subsequent attach is rejected by the server, check that the channel namespace has LiveObjects enabled in the Ably dashboard and that your API key has the corresponding capability on this channel. If you have the Ably CLI installed, \`ably apps rules list\` shows channel-namespace settings and \`ably auth keys list\` shows your key's capabilities.`, + hint: `Re-create the channel with the required mode via realtime.channels.get(name, { modes: [..., "${expectedMode}"] }); appending to channel.modes after attach() does not enable the mode server-side, since that array reflects only what the server already granted. If the subsequent attach is rejected, check that the channel namespace has LiveObjects enabled in the Ably dashboard and that your API key has the corresponding capability on this channel. If you have the Ably CLI installed, \`ably apps rules list\` shows channel-namespace settings and \`ably auth keys list\` shows the capabilities granted to your key.`, }); } } diff --git a/src/plugins/push/getW3CDeviceDetails.ts b/src/plugins/push/getW3CDeviceDetails.ts index 28e685b75d..6fc318d3d6 100644 --- a/src/plugins/push/getW3CDeviceDetails.ts +++ b/src/plugins/push/getW3CDeviceDetails.ts @@ -32,7 +32,7 @@ export async function getW3CPushDeviceDetails(machine: ActivationStateMachine) { message: 'User denied permission to send notifications', code: 40000, statusCode: 400, - hint: 'Surface a UI explaining the value of notifications, then request permission again; push activation can only complete once the user accepts.', + 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 browser permission stays at "denied"; if it was instead merely dismissed (the permission is "default"), surface UI explaining the value of notifications before retrying so the browser prompts again.', }); machine.handleEvent(new GettingPushDeviceDetailsFailed(err)); return; From b1a159efb8d84f5a148c1fa916881cbd51cdca20 Mon Sep 17 00:00:00 2001 From: umair Date: Tue, 30 Jun 2026 15:08:26 +0100 Subject: [PATCH 18/20] DX-1209: fix error hint factual accuracy from PR #2233 review Address lmars' 2026-06-29 review comments plus a PR-wide accuracy sweep, each verified against the SDK code paths, state machines, and ably.com docs: - auth.ts: clarify 40160 client-side vs server-side auth guidance (docs-aligned); split the 40102 message vs hint; reframe the 40170 "too large" family (envelope + capability/wildcards) consistently; tighten the callback return-shape and createTokenRequest hints; clarify clientId "*" (a fixed identity cannot be "*"; a wildcard identity comes from a wildcard token) - baseclient.ts: drop the token/tokenDetails suggestion on an invalid key; remove the irrelevant wildcard tangent on a non-string clientId; clarify clientId "*" - paginatedresource.ts: de-jargon the first()/current() hints - realtimeannotations.ts, realtimeobject.ts: recommend channel.setOptions to change modes (channels.get(name, { modes }) throws on an attached channel) - realtimechannel.ts: align the detach/attach-timeout recovery hints with the channel state machine (attach() recovers from failed; suspended auto-retries) - realtimepresence.ts: correct leave() guidance per state (presence members are cleared on failed/detached, so there is nothing to leave) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/common/lib/client/auth.ts | 22 ++++++++++---------- src/common/lib/client/baseclient.ts | 6 +++--- src/common/lib/client/paginatedresource.ts | 4 ++-- src/common/lib/client/realtimeannotations.ts | 2 +- src/common/lib/client/realtimechannel.ts | 6 +++--- src/common/lib/client/realtimepresence.ts | 2 +- src/plugins/liveobjects/realtimeobject.ts | 4 ++-- 7 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/common/lib/client/auth.ts b/src/common/lib/client/auth.ts index 82b3c15e04..af0882215c 100644 --- a/src/common/lib/client/auth.ts +++ b/src/common/lib/client/auth.ts @@ -161,7 +161,7 @@ class Auth { message: msg, code: 40160, statusCode: 401, - hint: 'Pass one of ClientOptions.{ key, authUrl, authCallback, token, tokenDetails }. For production, prefer authUrl or authCallback so the API key stays on your server.', + 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'); @@ -274,10 +274,10 @@ class Auth { * 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({ - message: 'Unable to update auth options with incompatible key', + message: 'authorize called with a key that does not match the existing key being used by the client', code: 40102, statusCode: 401, - hint: 'auth.authorize() cannot change the API key - the new authOptions.key differs from the one the client was constructed with. To use a different key, construct a new Ably client.', + hint: 'To use a different key, construct a new Ably client with the key as a client option.', }); } @@ -525,7 +525,7 @@ class Auth { message: 'authUrl JSON response exceeded the maximum permitted length of 128 KB', code: 40170, statusCode: 401, - hint: 'Make your authUrl endpoint return only the token payload (a TokenDetails or TokenRequest object, or a token string), not an envelope wrapping it in extra fields. If a TokenDetails genuinely needs a large capability, narrow the capability to the channels and operations the client actually needs.', + 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; @@ -687,7 +687,7 @@ class Auth { 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. The callback returned an empty string.', + 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) { @@ -695,7 +695,7 @@ class Auth { 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 tokens must serialise to under 128 KB. If the token capability is genuinely this large, narrow it to the channels and operations the client needs.', + 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') { @@ -732,7 +732,7 @@ class Auth { message: msg, code: 40170, statusCode: 401, - hint: 'authCallback must invoke its callback with (err, tokenStringOrTokenDetailsOrTokenRequest). authUrl must respond with a token string or TokenDetails/TokenRequest JSON.', + 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; @@ -744,7 +744,7 @@ class Auth { 'Token request/details object exceeded max permitted stringified size (was ' + objectSize + ' bytes)', code: 40170, statusCode: 401, - hint: 'Token objects must serialise to under 128 KB. Trim unused fields from your TokenDetails/TokenRequest, and if a large capability is required, narrow it to the channels and operations the client actually needs.', + 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; @@ -762,7 +762,7 @@ class Auth { 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. The returned object had neither field, so it matched neither shape; note that a bare `token` field is not enough to identify a TokenDetails here, the `issued` field is what the SDK keys on.', + hint: 'Return a token string, a TokenRequest, or a TokenDetails object from your authCallback/authUrl. The object returned had neither a `keyName` field (which identifies a TokenRequest) nor an `issued` field (which identifies a TokenDetails), so it matched neither shape.', }); reject(err); return; @@ -839,7 +839,7 @@ class Auth { message: 'No key specified', code: 40101, statusCode: 403, - hint: 'createTokenRequest needs an API key. Pass ClientOptions.key on the client or { key } in the authOptions argument. Token-auth clients cannot construct token requests themselves.', + hint: 'Pass ClientOptions.key on the client or { key } in the authOptions argument to createTokenRequest.', }); } const keyParts = key.split(':'), @@ -1060,7 +1060,7 @@ class Auth { message: 'Can’t use "*" as a clientId as that string is reserved', code: 40012, statusCode: 400, - hint: 'Move "*" out of ClientOptions.clientId; for a wildcard identity set defaultTokenParams: { clientId: "*" } on the client, or pass { clientId: "*" } to authorize() as a tokenParam. A client using a raw API key can already assume any clientId, so a wildcard token request succeeds by default; it is rejected only when the token or key issuing it is itself restricted to a single clientId.', + 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); diff --git a/src/common/lib/client/baseclient.ts b/src/common/lib/client/baseclient.ts index f9d15fd99a..d20e2afe7f 100644 --- a/src/common/lib/client/baseclient.ts +++ b/src/common/lib/client/baseclient.ts @@ -80,7 +80,7 @@ class BaseClient { message: msg, code: 40400, statusCode: 404, - hint: 'ClientOptions.key must be the full "appId.keyId:secret" string copied from the Ably dashboard. If you only have a token, use ClientOptions.token / tokenDetails instead. If you have the Ably CLI installed, `ably auth keys list` shows the keys configured on the current app.', + 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]; @@ -93,14 +93,14 @@ class BaseClient { message: 'clientId must be either a string or null', code: 40012, statusCode: 400, - hint: 'For an anonymous client omit clientId or pass null; to identify the client pass a stable string such as a user id. To authenticate as any clientId, use a wildcard token (defaultTokenParams: { clientId: "*" }) rather than setting clientId here.', + 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: 'Move "*" out of ClientOptions.clientId. For a wildcard identity, set defaultTokenParams: { clientId: "*" } on the client instead. A client using a raw API key can already assume any clientId, so a wildcard token request succeeds by default; it is rejected only when the token or key issuing it is itself restricted to a single clientId.', + 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).', }); } } diff --git a/src/common/lib/client/paginatedresource.ts b/src/common/lib/client/paginatedresource.ts index 492cfdfce8..72f5fb4a48 100644 --- a/src/common/lib/client/paginatedresource.ts +++ b/src/common/lib/client/paginatedresource.ts @@ -151,7 +151,7 @@ export class PaginatedResult { message: 'No link to the first page of results', code: 40400, statusCode: 404, - hint: 'The public PaginatedResult surface exposes no boolean guard for first(), so wrap the call in try/catch or attach a .catch handler to the promise it returns rather than pre-checking. A first-page link exists only on a page whose paginated REST response carried a Link header with rel="first", so expect first() to reject on results that were not produced by such a query.', + 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.', }); } @@ -164,7 +164,7 @@ export class PaginatedResult { message: 'No link to the current page of results', code: 40400, statusCode: 404, - hint: 'Call current() only on a PaginatedResult returned by a paginated REST query (such as channel history, presence, or stats) whose response carried pagination Link headers. This page has no rel="current" navigation link, so there is nothing to reload; if you only need to walk pages, use next() together with isLast() instead, since those are the public navigation methods this result type exposes.', + 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.', }); } diff --git a/src/common/lib/client/realtimeannotations.ts b/src/common/lib/client/realtimeannotations.ts index e45a0c09b6..5390d55d7d 100644 --- a/src/common/lib/client/realtimeannotations.ts +++ b/src/common/lib/client/realtimeannotations.ts @@ -76,7 +76,7 @@ class RealtimeAnnotations { "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: 'Re-create the channel with annotation_subscribe in modes, e.g. realtime.channels.get(name, { modes: ["subscribe", "annotation_subscribe"] }), since appending to channel.modes after attach() does not enable the mode server-side. If the subsequent attach is rejected by the server, confirm the channel namespace has "Message annotations, updates, appends, and deletes" enabled in the Ably dashboard and that your API key has annotation-subscribe capability on this channel. If you have the Ably CLI installed, `ably apps rules list` shows which namespaces have it enabled and `ably auth keys list` shows the capabilities of your key.', + hint: '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 bc3e1344d2..21ec8f7e4e 100644 --- a/src/common/lib/client/realtimechannel.ts +++ b/src/common/lib/client/realtimechannel.ts @@ -446,7 +446,7 @@ class RealtimeChannel extends EventEmitter { message: 'Unable to detach; channel state = failed', code: 90001, statusCode: 400, - hint: 'Release it via channels.release(name) and call channels.get(name) again to start a fresh channel.', + 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: @@ -988,7 +988,7 @@ class RealtimeChannel extends EventEmitter { message: 'Channel attach timed out', code: 90007, statusCode: 408, - hint: 'The SDK will retry automatically once the connection is healthy; check connection.state and connection.errorReason.', + 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; @@ -998,7 +998,7 @@ class RealtimeChannel extends EventEmitter { message: 'Channel detach timed out', code: 90007, statusCode: 408, - hint: 'The channel has reverted to attached; retry detach() once the connection is stable.', + 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; diff --git a/src/common/lib/client/realtimepresence.ts b/src/common/lib/client/realtimepresence.ts index 743a70b5bc..37e60b8b55 100644 --- a/src/common/lib/client/realtimepresence.ts +++ b/src/common/lib/client/realtimepresence.ts @@ -213,7 +213,7 @@ class RealtimePresence extends EventEmitter { throw new PartialErrorInfo({ message: 'Unable to leave presence channel while in ' + channel.state + ' state', code: 90001, - hint: 'Inspect channel.errorReason for the cause, then await channel.attach() and retry presence.leave() if the channel state is "failed". If the state is "initialized" no member was ever entered, so there is nothing to leave and no action is needed.', + 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.', }); } } diff --git a/src/plugins/liveobjects/realtimeobject.ts b/src/plugins/liveobjects/realtimeobject.ts index 317fd1fa10..40fc5a40c0 100644 --- a/src/plugins/liveobjects/realtimeobject.ts +++ b/src/plugins/liveobjects/realtimeobject.ts @@ -572,7 +572,7 @@ export class RealtimeObject { message: `"${expectedMode}" channel mode must be set for this operation`, code: 40024, statusCode: 400, - hint: `Re-create the channel with the required mode via realtime.channels.get(name, { modes: [..., "${expectedMode}"] }); appending to channel.modes after attach() does not enable the mode server-side, since that array reflects only what the server already granted. If the subsequent attach is rejected, check that the channel namespace has LiveObjects enabled in the Ably dashboard and that your API key has the corresponding capability on this channel. If you have the Ably CLI installed, \`ably apps rules list\` shows channel-namespace settings and \`ably auth keys list\` shows the capabilities granted to your key.`, + hint: `Enable the mode on the channel with channel.setOptions({ modes: [..., "${expectedMode}"] }), 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, check that the channel namespace has LiveObjects enabled in the Ably dashboard and that your API key has the corresponding capability on this channel. If you have the Ably CLI installed, \`ably apps rules list\` shows channel-namespace settings and \`ably auth keys list\` shows the capabilities granted to your key.`, }); } // RTO2b - otherwise as a best effort use user provided channel options @@ -581,7 +581,7 @@ export class RealtimeObject { message: `"${expectedMode}" channel mode must be set for this operation`, code: 40024, statusCode: 400, - hint: `Re-create the channel with the required mode via realtime.channels.get(name, { modes: [..., "${expectedMode}"] }); appending to channel.modes after attach() does not enable the mode server-side, since that array reflects only what the server already granted. If the subsequent attach is rejected, check that the channel namespace has LiveObjects enabled in the Ably dashboard and that your API key has the corresponding capability on this channel. If you have the Ably CLI installed, \`ably apps rules list\` shows channel-namespace settings and \`ably auth keys list\` shows the capabilities granted to your key.`, + hint: `Enable the mode on the channel with channel.setOptions({ modes: [..., "${expectedMode}"] }), 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, check that the channel namespace has LiveObjects enabled in the Ably dashboard and that your API key has the corresponding capability on this channel. If you have the Ably CLI installed, \`ably apps rules list\` shows channel-namespace settings and \`ably auth keys list\` shows the capabilities granted to your key.`, }); } } From 311d6790d3e99b96d882f35a1c4797dd51733acf Mon Sep 17 00:00:00 2001 From: umair Date: Thu, 2 Jul 2026 15:22:43 +0100 Subject: [PATCH 19/20] DX-1209: fix hint factual accuracy and cross-site consistency from full-PR docs review Verified findings from the 2026-07-02 docstrings-pr-review run (each traced against code paths, state machines, CLI output, and a live sandbox trace): - utils.ts: derived-channel 40010 message was false (branch fires only on empty/unparseable names, and the hint's own example triggered a different error); createMissingPluginError now names the real entry point per plugin (Push -> ably/push, LiveObjects -> ably/liveobjects, others -> ably/modular); v1-callback message/hint reworded (tests re-pinned) - defaults.ts: drop invented v1/v2 version framing (endpoint arrived in 2.10.0 per CHANGELOG; environment/restHost/realtimeHost are deprecated v2 options); fallbackHosts hints are now single prescriptions - realtimeobject.ts (LiveObjects): 40024 hints no longer claim the attach is rejected on a capability shortfall (live-traced: the server resolves the attach and silently drops the mode) and no longer cite a nonexistent "LiveObjects namespace" dashboard setting; recipe aligned with the sibling missing-mode hints - realtimechannel.ts: invalid-mode hint no longer claims the attach is rejected (silently-dropped story, matching channel.modes); 40003 sendUpdate hint unified to the canonical missing-serial construction; untilAttach message now carries the observed state like the presence twin - realtimepresence.ts: enter/update 40012 hints share one skeleton; leave 40012 no longer prescribes a no-op set-clientId-and-retry errand - connectionmanager.ts: 403 auth hint no longer narrates a server-refusal branch that never fires on that path; ping hint covers the closed/failed/initialized states that never auto-connect - basemessage.ts: vcdiff 40018 recovery hint matches the traced behaviour (re-attach from last processed message; delta stays enabled) - push cluster: platform-unavailable messages now diagnose (browser/service worker requirement), hints are pure prescriptions; subscribeClient/ unsubscribeClient hints state the real clientId sources per client type; getW3CDeviceDetails branches denied vs dismissed permission - rest.ts: revokeTokens hint names the real mechanism (revocable tokens enabled on the issuing key), not a nonexistent "revoke capability" - restchannel/restchannelmixin/restannotations/paginatedresource/baserealtime: canonical missing-serial constructions, single-prescription hints, message mechanics; 40009 max-size message parenthetical unified across the three publish sites - auth.ts/baseclient.ts: clientId type-check hints byte-identical; residual diagnosis clauses moved from hints into messages Co-Authored-By: Claude Fable 5 --- src/common/lib/client/auth.ts | 20 ++++++------- src/common/lib/client/baseclient.ts | 2 +- src/common/lib/client/baserealtime.ts | 5 ++-- src/common/lib/client/paginatedresource.ts | 2 +- src/common/lib/client/push.ts | 21 +++++++++----- src/common/lib/client/realtimechannel.ts | 26 ++++++++--------- src/common/lib/client/realtimepresence.ts | 10 +++---- src/common/lib/client/rest.ts | 2 +- src/common/lib/client/restannotations.ts | 2 +- src/common/lib/client/restchannel.ts | 7 +++-- src/common/lib/client/restchannelmixin.ts | 8 +++--- src/common/lib/transport/connectionmanager.ts | 10 +++---- src/common/lib/types/basemessage.ts | 2 +- src/common/lib/util/defaults.ts | 12 ++++---- src/common/lib/util/utils.ts | 25 +++++++++++++---- src/plugins/liveobjects/realtimeobject.ts | 6 ++-- src/plugins/push/getW3CDeviceDetails.ts | 21 ++++++++++---- src/plugins/push/pushactivation.ts | 28 +++++++++++-------- src/plugins/push/pushchannel.ts | 6 ++-- test/realtime/auth.test.js | 2 +- test/realtime/channel.test.js | 2 +- test/realtime/connection.test.js | 2 +- test/realtime/presence.test.js | 2 +- 23 files changed, 131 insertions(+), 92 deletions(-) diff --git a/src/common/lib/client/auth.ts b/src/common/lib/client/auth.ts index af0882215c..ed74c61000 100644 --- a/src/common/lib/client/auth.ts +++ b/src/common/lib/client/auth.ts @@ -610,7 +610,7 @@ class Auth { message: msg, code: 40171, statusCode: 403, - hint: 'Initialise the client with one of ClientOptions.{ key, authUrl, authCallback } so the SDK can refresh tokens. A bare token/tokenDetails alone cannot be renewed once expired.', + hint: 'Initialise the client with one of ClientOptions.{ key, authUrl, authCallback } so the SDK can refresh tokens.', }); } @@ -704,7 +704,7 @@ class Auth { 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.', + hint: 'Return the token itself, not "undefined"/"null". Callbacks that have no value to return should pass an error instead.', }); reject(err); } else if ( @@ -756,13 +756,13 @@ 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); const err = new ErrorInfo({ message: msg, code: 40170, statusCode: 401, - hint: 'Return a token string, a TokenRequest, or a TokenDetails object from your authCallback/authUrl. The object returned had neither a `keyName` field (which identifies a TokenRequest) nor an `issued` field (which identifies a TokenDetails), so it matched neither shape.', + 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; @@ -839,7 +839,7 @@ class Auth { message: 'No key specified', code: 40101, statusCode: 403, - hint: 'Pass ClientOptions.key on the client or { key } in the authOptions argument to createTokenRequest.', + 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(':'), @@ -848,10 +848,10 @@ class Auth { if (!keySecret) { throw new ErrorInfo({ - message: 'Invalid key specified', + 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, since the key you passed has no colon-separated secret. If you have the Ably CLI installed, `ably auth keys list` shows the keys configured on the current app.', + 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.', }); } @@ -986,7 +986,7 @@ class Auth { '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. The two cannot diverge.', + 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 @@ -1053,7 +1053,7 @@ class Auth { message: 'clientId must be either a string or null', code: 40012, statusCode: 400, - hint: 'Pass a string (e.g. a user id) or null for an anonymous client. Numbers and objects are not accepted.', + 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({ @@ -1078,7 +1078,7 @@ class Auth { message: msg, code: 40102, statusCode: 401, - hint: 'A clientId from the token does not match ClientOptions.clientId. Issue the token with the matching clientId, or omit ClientOptions.clientId and let the token define it.', + 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; diff --git a/src/common/lib/client/baseclient.ts b/src/common/lib/client/baseclient.ts index d20e2afe7f..28371fb055 100644 --- a/src/common/lib/client/baseclient.ts +++ b/src/common/lib/client/baseclient.ts @@ -97,7 +97,7 @@ class BaseClient { }); } else if (normalOptions.clientId === '*') { throw new ErrorInfo({ - message: 'Can’t use "*" as a clientId as that string is reserved.', + 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).', diff --git a/src/common/lib/client/baserealtime.ts b/src/common/lib/client/baserealtime.ts index f6cf049970..3ef26fca85 100644 --- a/src/common/lib/client/baserealtime.ts +++ b/src/common/lib/client/baserealtime.ts @@ -200,10 +200,11 @@ class Channels extends EventEmitter { } else if (channelOptions) { if (channel._shouldReattachToSetOptions(channelOptions, channel.channelOptions)) { throw new ErrorInfo({ - message: 'Channels.get() cannot be used to set channel options that would cause the channel to reattach.', + 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: 'channels.get(name) returns the existing channel - to change params or modes, call channel.setOptions(opts) on the returned instance (it will re-attach if the new options differ from current).', + 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 72f5fb4a48..e5eef22380 100644 --- a/src/common/lib/client/paginatedresource.ts +++ b/src/common/lib/client/paginatedresource.ts @@ -164,7 +164,7 @@ export class PaginatedResult { 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.', + 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.', }); } diff --git a/src/common/lib/client/push.ts b/src/common/lib/client/push.ts index 8ff8aff19f..24a490f7c9 100644 --- a/src/common/lib/client/push.ts +++ b/src/common/lib/client/push.ts @@ -15,8 +15,13 @@ import type { import Platform from 'common/platform'; import type { ErrCallback } from 'common/types/utils'; -const PUSH_NOT_AVAILABLE_HINT = - 'push.activate() registers the current process as a push target - supported in browser environments with service-worker support. In Node.js or other server contexts there is no device to register; use client.push.admin to manage other devices from a server: client.push.admin.publish(recipient, payload) to send to a device or clientId, client.push.admin.deviceRegistrations.save(device) to register a device record.'; +// 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; @@ -41,10 +46,11 @@ class Push { } if (!this.stateMachine) { const err = new ErrorInfo({ - message: 'This platform is not supported as a target of push notifications', + 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_NOT_AVAILABLE_HINT, + hint: PUSH_ACTIVATION_NOT_AVAILABLE_HINT, }); reject(err); return; @@ -81,10 +87,11 @@ class Push { } if (!this.stateMachine) { const err = new ErrorInfo({ - message: 'This platform is not supported as a target of push notifications', + 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_NOT_AVAILABLE_HINT, + hint: PUSH_DEACTIVATION_NOT_AVAILABLE_HINT, }); reject(err); return; @@ -187,7 +194,7 @@ class DeviceRegistrations { message: 'First argument to DeviceRegistrations#get must be a deviceId string or DeviceDetails', code: 40000, statusCode: 400, - hint: 'Pass either the device id string (the local device id, e.g. from client.device().id after push.activate() completes, or the .id of a DeviceDetails returned by push.admin.deviceRegistrations.save()), or a DeviceDetails object with a non-empty .id field.', + 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().', }); } diff --git a/src/common/lib/client/realtimechannel.ts b/src/common/lib/client/realtimechannel.ts index 21ec8f7e4e..3db5cd0385 100644 --- a/src/common/lib/client/realtimechannel.ts +++ b/src/common/lib/client/realtimechannel.ts @@ -62,7 +62,7 @@ function validateChannelOptions(options?: API.ChannelOptions) { message: 'Invalid channel mode: ' + currentMode, code: 40000, statusCode: 400, - hint: `Valid ChannelMode values are: ${channelModes.join(', ').toLowerCase()}. The server also enforces this - your token/API-key capability must permit the requested modes on this channel, otherwise the subsequent attach is rejected. If you have the Ably CLI installed, \`ably auth keys list\` shows your key's capabilities.`, + 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; } @@ -198,7 +198,7 @@ class RealtimeChannel extends EventEmitter { code: 90001, statusCode: 400, cause: this.errorReason || undefined, - hint: 'Inspect channel.errorReason for the underlying cause. From "failed", call channel.attach() to recover; "suspended" recovers automatically once the underlying connection is re-established.', + 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; } @@ -307,10 +307,10 @@ class RealtimeChannel extends EventEmitter { const size = getMessagesSize(wireMessages); if (size > maxMessageSize) { throw new ErrorInfo({ - message: `Maximum size of messages that can be published at once exceeded (was ${size} bytes; limit is ${maxMessageSize} bytes)`, + 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, or contact support to raise maxMessageSize for your app.', + 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.', }); } @@ -443,10 +443,10 @@ class RealtimeChannel extends EventEmitter { // RTL5b case 'failed': { throw new ErrorInfo({ - message: 'Unable to detach; channel state = failed', + 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.', + 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: @@ -998,7 +998,7 @@ class RealtimeChannel extends EventEmitter { 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.', + 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; @@ -1073,10 +1073,10 @@ class RealtimeChannel extends EventEmitter { if (params && params.untilAttach) { if (this.state !== 'attached') { throw new ErrorInfo({ - message: 'option untilAttach requires the channel to be attached', + message: 'option untilAttach requires the channel to be attached, was: ' + this.state, code: 40000, statusCode: 400, - hint: 'Await channel.attach() (or channel.whenState("attached")) before calling history({ untilAttach: true }).', + hint: 'Await channel.attach() before calling history({ untilAttach: true }).', }); } if (!this.properties.attachSerial) { @@ -1084,7 +1084,7 @@ class RealtimeChannel extends EventEmitter { 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()) to force the SDK to record a fresh attachSerial, then retry history({ untilAttach: true }).', + 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; @@ -1106,7 +1106,7 @@ class RealtimeChannel extends EventEmitter { } 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); was ' + + '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, code: 90001, statusCode: 400, @@ -1175,10 +1175,10 @@ class RealtimeChannel extends EventEmitter { ): Promise { if (!message.serial) { throw new ErrorInfo({ - message: 'This message lacks a serial and cannot be updated', + message: 'This message lacks a serial', code: 40003, statusCode: 400, - hint: 'Pass the Message you received from a subscribe callback (which carries .serial), not a freshly constructed object. The channel namespace must have "Message annotations, updates, appends, and deletes" enabled in the Ably dashboard. If you have the Ably CLI installed, `ably apps rules list` shows which namespaces have it enabled.', + hint: 'Pass the Message received from a subscribe callback (which carries .serial), not a freshly constructed object.', }); } diff --git a/src/common/lib/client/realtimepresence.ts b/src/common/lib/client/realtimepresence.ts index 37e60b8b55..9ae144581b 100644 --- a/src/common/lib/client/realtimepresence.ts +++ b/src/common/lib/client/realtimepresence.ts @@ -66,7 +66,7 @@ class RealtimePresence extends EventEmitter { 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; once a clientId is set the enter still requires the API key or token to grant the presence capability on this channel (and the channel must reach the attached state), or the server rejects it. To enter on behalf of another identity, use presence.enterClient(otherId, data), which additionally requires a wildcard clientId on your API key or token.', + 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'); @@ -83,7 +83,7 @@ class RealtimePresence extends EventEmitter { 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; once a clientId is set the update still requires the API key or token to grant the presence capability on this channel, or the server rejects it. To update on behalf of another identity, use presence.updateClient(otherId, data), which additionally requires a wildcard clientId on your API key or token.', + 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'); @@ -167,7 +167,7 @@ class RealtimePresence extends EventEmitter { message: 'clientId must have been specified to enter or leave a presence channel', code: 40012, statusCode: 400, - hint: 'Set ClientOptions.clientId (or include clientId in the token) before retrying presence.leave(), or call presence.leaveClient(otherId) to leave on behalf of another identity. Either way the API key or token must also carry the presence capability for this channel server-side, and leaveClient for a different identity additionally requires a wildcard clientId, otherwise the server rejects the request.', + 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); @@ -272,7 +272,7 @@ class RealtimePresence extends EventEmitter { message: 'option untilAttach requires the channel to be attached, was: ' + this.channel.state, code: 40000, statusCode: 400, - hint: 'Await channel.attach() (or channel.whenState("attached")) before calling presence.history({ untilAttach: true }).', + hint: 'Await channel.attach() before calling presence.history({ untilAttach: true }).', }); } } @@ -449,7 +449,7 @@ class RealtimePresence extends EventEmitter { code: 91004, statusCode: 400, cause: err, - hint: 'Listen for the channel "update" event and call presence.enter(...) again once the channel is attached.', + hint: 'Listen for the channel "update" event and call presence.enter(...) again (or presence.enterClient(clientId, data) for a member entered on behalf of another clientId) once the channel is attached.', }); Logger.logAction( this.logger, diff --git a/src/common/lib/client/rest.ts b/src/common/lib/client/rest.ts index 24593df85e..7c2fd6af03 100644 --- a/src/common/lib/client/rest.ts +++ b/src/common/lib/client/rest.ts @@ -221,7 +221,7 @@ export class Rest { 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 (a raw API key) just for this call. The key must have had token revocation enabled (its "Revocable tokens" setting in the Ably dashboard) before the tokens were issued, otherwise there is nothing to revoke.', + hint: 'Token revocation must use basic auth, so construct a separate Ably.Rest client with ClientOptions.key (the API key that issued the tokens, with revocable tokens enabled) 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.', }); } diff --git a/src/common/lib/client/restannotations.ts b/src/common/lib/client/restannotations.ts index 52f93a0fa7..fdaa04461c 100644 --- a/src/common/lib/client/restannotations.ts +++ b/src/common/lib/client/restannotations.ts @@ -26,7 +26,7 @@ export function serialFromMsgOrSerial(msgOrSerial: string | Message): string { if (!messageSerial || typeof messageSerial !== 'string') { throw new ErrorInfo({ message: - '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)', + '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.', diff --git a/src/common/lib/client/restchannel.ts b/src/common/lib/client/restchannel.ts index a497e3160c..d18ad690a8 100644 --- a/src/common/lib/client/restchannel.ts +++ b/src/common/lib/client/restchannel.ts @@ -107,7 +107,8 @@ class RestChannel { params = args[1]; } else { throw new ErrorInfo({ - message: 'The single-argument form of publish() expects a message object or an array of message objects', + 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.', @@ -141,10 +142,10 @@ class RestChannel { maxMessageSize = options.maxMessageSize; if (size > maxMessageSize) { throw new ErrorInfo({ - message: `Maximum size of messages that can be published at once exceeded (was ${size} bytes; limit is ${maxMessageSize} bytes)`, + 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 itself, contact Ably support.', + 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.', }); } diff --git a/src/common/lib/client/restchannelmixin.ts b/src/common/lib/client/restchannelmixin.ts index 833c4865a6..2c6653ba98 100644 --- a/src/common/lib/client/restchannelmixin.ts +++ b/src/common/lib/client/restchannelmixin.ts @@ -68,7 +68,7 @@ export class RestChannelMixin { message: 'This message lacks a serial', code: 40003, statusCode: 400, - hint: 'Pass `getMessage` a non-empty serial string, or a `Message` delivered by a subscribe callback (which carries a populated `.serial`) rather than a freshly constructed `Message` whose `.serial` is unset.', + 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.', }); } @@ -99,10 +99,10 @@ export class RestChannelMixin { ): Promise { if (!message.serial) { throw new ErrorInfo({ - message: 'This message lacks a serial and cannot be updated', + 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. The channel namespace must have "Message annotations, updates, appends, and deletes" enabled in the Ably dashboard. If you have the Ably CLI installed, `ably apps rules list` shows which namespaces have it enabled.', + hint: 'Pass the Message received from a subscribe callback (which carries .serial), not a freshly constructed object.', }); } @@ -145,7 +145,7 @@ export class RestChannelMixin { message: 'This message lacks a serial', code: 40003, statusCode: 400, - hint: 'Pass a Message that carries a non-empty `.serial` (the object delivered to a subscribe or history callback), or pass that serial string directly, so `getMessageVersions` can address the message whose version history you want.', + 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.', }); } diff --git a/src/common/lib/transport/connectionmanager.ts b/src/common/lib/transport/connectionmanager.ts index 74bc5d921a..655b58ae75 100644 --- a/src/common/lib/transport/connectionmanager.ts +++ b/src/common/lib/transport/connectionmanager.ts @@ -1899,10 +1899,10 @@ class ConnectionManager extends EventEmitter { async ping(): Promise { if (this.state.state !== 'connected') { throw new ErrorInfo({ - message: 'Unable to ping service; not connected', + 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", …).', + 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.', }); } @@ -1965,14 +1965,14 @@ 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); const wrapped = new ErrorInfo({ message: msg, code: 80019, statusCode: 403, cause: err, - hint: 'Inspect cause for the underlying error: a 403 here means either your authUrl/authCallback rejected the request, or the Ably server refused the resulting TokenRequest. If the latter, narrow the requested capability to what your API key actually grants.', + 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 { @@ -1983,7 +1983,7 @@ class ConnectionManager extends EventEmitter { code: 80019, statusCode: 401, cause: err, - hint: 'Check network connectivity to your authUrl/authCallback endpoint and that it returns a valid token shape; the underlying error is in cause.', + 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 0893a44a94..a83e50d674 100644 --- a/src/common/lib/types/basemessage.ts +++ b/src/common/lib/types/basemessage.ts @@ -251,7 +251,7 @@ export async function decodeData( message: 'Vcdiff delta decode failed with ' + e, code: 40018, statusCode: 400, - hint: 'Disable deltas for this channel by not setting delta in the channel params if this recurs; the SDK otherwise recovers automatically by re-attaching without delta after the base payload diverges.', + 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; diff --git a/src/common/lib/util/defaults.ts b/src/common/lib/util/defaults.ts index b0b0e8b36f..2b9ea7033b 100644 --- a/src/common/lib/util/defaults.ts +++ b/src/common/lib/util/defaults.ts @@ -176,18 +176,18 @@ export function getHosts(options: NormalisedClientOptions): string[] { function checkHost(host: string): void { if (typeof host !== 'string') { throw new ErrorInfo({ - message: 'host must be a string; was a ' + typeof host, + message: 'host must be a string: was of type ' + typeof host, code: 40000, statusCode: 400, - hint: 'Pass each host option as a string: `endpoint` (e.g. "main"), and every entry of `fallbackHosts` (and `restHost`/`realtimeHost` if used) must be a non-array string.', + 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({ - message: 'host must not be zero-length', + 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, or omit `fallbackHosts` entirely to use the Ably defaults. An empty `endpoint`/`restHost`/`realtimeHost` already falls back to the default and is not the cause here.', + hint: 'Remove any empty-string entry from the `fallbackHosts` array.', }); } } @@ -266,7 +266,7 @@ function checkIfClientOptionsAreValid(options: ClientOptions) { 'The `endpoint` option cannot be used in conjunction with the `environment`, `restHost`, or `realtimeHost` options.', code: 40106, statusCode: 400, - hint: 'Use only `endpoint` (the v2 option). `environment`, `restHost`, and `realtimeHost` are legacy v1 names - remove them from ClientOptions.', + hint: 'Remove `environment`, `restHost`, and `realtimeHost` from `ClientOptions` and use only `endpoint`, which replaces them.', }); } @@ -277,7 +277,7 @@ function checkIfClientOptionsAreValid(options: ClientOptions) { message: 'The `environment` option cannot be used in conjunction with the `restHost`, or `realtimeHost` options.', code: 40106, statusCode: 400, - hint: 'Replace all of them with the v2 `endpoint` option, which subsumes both.', + 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 0343f7ca3b..9758167930 100644 --- a/src/common/lib/util/utils.ts +++ b/src/common/lib/util/utils.ts @@ -289,11 +289,11 @@ export function detectV1Callback(args: ArrayLike, v2TrailingFnArity: nu if (typeof args[n - 1] !== 'function') return; if (n <= v2TrailingFnArity && typeof args[n - 2] !== 'function') return; throw new ErrorInfo({ - message: 'v1 callback signature is no longer supported.', + message: 'v1 callback signature is no longer supported: v2 methods return a promise.', code: 40025, statusCode: 400, hint: - 'v2 uses Promises - drop the trailing callback and `await` the returned promise. ' + + 'Drop the trailing callback and `await` the returned promise. ' + 'See https://github.com/ably/ably-js/blob/main/docs/migration-guides/v2/lib.md.', }); } @@ -474,10 +474,12 @@ export function matchDerivedChannel(name: string) { const match = name.match(regex); if (!match || !match.length || match.length < 5) { throw new ErrorInfo({ - message: 'Channel name does not match the [filter=...]name shape required for derived channels', + message: 'Channel name is empty or could not be parsed', code: 40010, statusCode: 400, - hint: 'Format the name as [filter=], for example "[filter=...]foo". See https://ably.com/docs/channels#derived.', + 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 @@ -512,11 +514,24 @@ export function arrEquals(a: any[], b: any[]) { } export function createMissingPluginError(pluginName: keyof ModularPlugins): ErrorInfo { + // 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: `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.`, + hint, }); return err; } diff --git a/src/plugins/liveobjects/realtimeobject.ts b/src/plugins/liveobjects/realtimeobject.ts index 40fc5a40c0..d94e60af02 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, ); @@ -572,7 +572,7 @@ export class RealtimeObject { message: `"${expectedMode}" channel mode must be set for this operation`, code: 40024, statusCode: 400, - hint: `Enable the mode on the channel with channel.setOptions({ modes: [..., "${expectedMode}"] }), 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, check that the channel namespace has LiveObjects enabled in the Ably dashboard and that your API key has the corresponding capability on this channel. If you have the Ably CLI installed, \`ably apps rules list\` shows channel-namespace settings and \`ably auth keys list\` shows the capabilities granted to your key.`, + hint: `Include "${expectedMode}" in the channel modes: realtime.channels.get(name, { modes: ["${expectedMode}", ...] }), or call channel.setOptions({ modes: [...] }) on an existing channel (this triggers 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 @@ -581,7 +581,7 @@ export class RealtimeObject { message: `"${expectedMode}" channel mode must be set for this operation`, code: 40024, statusCode: 400, - hint: `Enable the mode on the channel with channel.setOptions({ modes: [..., "${expectedMode}"] }), 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, check that the channel namespace has LiveObjects enabled in the Ably dashboard and that your API key has the corresponding capability on this channel. If you have the Ably CLI installed, \`ably apps rules list\` shows channel-namespace settings and \`ably auth keys list\` shows the capabilities granted to your key.`, + hint: `Include "${expectedMode}" in the channel modes: realtime.channels.get(name, { modes: ["${expectedMode}", ...] }), or call channel.setOptions({ modes: [...] }) on an existing channel (this triggers 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 6fc318d3d6..1591183380 100644 --- a/src/plugins/push/getW3CDeviceDetails.ts +++ b/src/plugins/push/getW3CDeviceDetails.ts @@ -28,12 +28,21 @@ export async function getW3CPushDeviceDetails(machine: ActivationStateMachine) { const permission = await Notification.requestPermission(); if (permission !== 'granted') { - const err = new ErrorInfo({ - message: 'User denied permission to send notifications', - 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 browser permission stays at "denied"; if it was instead merely dismissed (the permission is "default"), surface UI explaining the value of notifications before retrying so the browser prompts again.', - }); + 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; } diff --git a/src/plugins/push/pushactivation.ts b/src/plugins/push/pushactivation.ts index 6228e04f7a..860939ffb1 100644 --- a/src/plugins/push/pushactivation.ts +++ b/src/plugins/push/pushactivation.ts @@ -9,8 +9,10 @@ import { getW3CPushDeviceDetails } from './getW3CDeviceDetails'; import type BaseClient from 'common/lib/client/baseclient'; import type { PaginatedResult } from 'common/lib/client/paginatedresource'; -const PUSH_NOT_AVAILABLE_HINT = - 'push.activate() registers the current process as a push target - supported in browser environments with service-worker support. In Node.js or other server contexts there is no device to register; use client.push.admin to manage other devices from a server: client.push.admin.publish(recipient, payload) to send to a device or clientId, client.push.admin.deviceRegistrations.save(device) to register a device record.'; +// 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', @@ -66,10 +68,11 @@ export function localDeviceFactory(deviceDetails: typeof DeviceDetails) { const Platform = this.rest.Platform; if (!Platform.Config.push) { throw new this.rest.ErrorInfo({ - message: 'Push activation is not available on this platform', + message: + 'Push activation is not available on this platform: it requires a browser environment with service worker support', code: 40000, statusCode: 400, - hint: PUSH_NOT_AVAILABLE_HINT, + hint: PUSH_ACTIVATION_NOT_AVAILABLE_HINT, }); } @@ -78,7 +81,7 @@ export function localDeviceFactory(deviceDetails: typeof DeviceDetails) { message: 'Device not activated', code: 40000, statusCode: 400, - hint: 'Call client.push.activate(registerCallback) and await its completion before listing subscriptions or other device-scoped operations.', + hint: 'Call client.push.activate() and await its completion before listing subscriptions.', }); } @@ -110,10 +113,11 @@ export function localDeviceFactory(deviceDetails: typeof DeviceDetails) { const Platform = this.rest.Platform; if (!Platform.Config.push) { throw new this.rest.ErrorInfo({ - message: 'Push activation is not available on this platform', + message: + 'Push activation is not available on this platform: it requires a browser environment with service worker support', code: 40000, statusCode: 400, - hint: PUSH_NOT_AVAILABLE_HINT, + hint: PUSH_ACTIVATION_NOT_AVAILABLE_HINT, }); } this.platform = Platform.Config.push.platform; @@ -136,10 +140,11 @@ export function localDeviceFactory(deviceDetails: typeof DeviceDetails) { const config = this.rest.Platform.Config; if (!config.push) { throw new this.rest.ErrorInfo({ - message: 'Push activation is not available on this platform', + message: + 'Push activation is not available on this platform: it requires a browser environment with service worker support', code: 40000, statusCode: 400, - hint: PUSH_NOT_AVAILABLE_HINT, + hint: PUSH_ACTIVATION_NOT_AVAILABLE_HINT, }); } if (this.id) { @@ -217,10 +222,11 @@ export class ActivationStateMachine { get pushConfig() { if (!this._pushConfig) { throw new this.client.ErrorInfo({ - message: 'This platform is not supported as a target of push notifications', + 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_NOT_AVAILABLE_HINT, + hint: PUSH_ACTIVATION_NOT_AVAILABLE_HINT, }); } return this._pushConfig; diff --git a/src/plugins/push/pushchannel.ts b/src/plugins/push/pushchannel.ts index 06012879ad..cd3d1336c6 100644 --- a/src/plugins/push/pushchannel.ts +++ b/src/plugins/push/pushchannel.ts @@ -54,7 +54,7 @@ class PushChannel { message: 'Cannot subscribe from client without client ID', code: 50000, statusCode: 500, - hint: 'Set ClientOptions.clientId (or include clientId in the token) before calling pushChannel.subscribeClient().', + 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, @@ -76,7 +76,7 @@ class PushChannel { message: 'Cannot unsubscribe from client without client ID', code: 50000, statusCode: 500, - hint: 'Set ClientOptions.clientId (or include clientId in the token) before calling pushChannel.unsubscribeClient().', + 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, @@ -119,7 +119,7 @@ class PushChannel { message: 'Cannot subscribe or unsubscribe this device without a deviceIdentityToken', code: 50000, statusCode: 500, - hint: 'Activate this device first by awaiting client.push.activate(registerCallback).', + 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) { From 8c70f4e4e8e194ab91891def849e7f6c45f8f7aa Mon Sep 17 00:00:00 2001 From: umair Date: Fri, 3 Jul 2026 11:29:22 +0100 Subject: [PATCH 20/20] DX-1209: promote fact-bearing parentheticals in hints to sentences Fact-bearing parentheticals fold into the clause or become their own sentence per the hint style bar. The LiveObjects mode hints keep the guard that channels.get(name, { modes }) on an existing channel throws, steering the caller between the two suggested remedies. Co-Authored-By: Claude Fable 5 --- src/common/lib/client/auth.ts | 4 ++-- src/common/lib/client/baseclient.ts | 2 +- src/common/lib/client/realtimepresence.ts | 2 +- src/common/lib/client/rest.ts | 2 +- src/common/lib/client/restchannel.ts | 2 +- src/plugins/liveobjects/realtimeobject.ts | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/common/lib/client/auth.ts b/src/common/lib/client/auth.ts index ed74c61000..afd9f4f079 100644 --- a/src/common/lib/client/auth.ts +++ b/src/common/lib/client/auth.ts @@ -661,7 +661,7 @@ class Auth { 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.', + 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); @@ -1060,7 +1060,7 @@ class Auth { 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).', + 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); diff --git a/src/common/lib/client/baseclient.ts b/src/common/lib/client/baseclient.ts index 28371fb055..52257b22f8 100644 --- a/src/common/lib/client/baseclient.ts +++ b/src/common/lib/client/baseclient.ts @@ -100,7 +100,7 @@ class BaseClient { 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).', + 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.', }); } } diff --git a/src/common/lib/client/realtimepresence.ts b/src/common/lib/client/realtimepresence.ts index 9ae144581b..24f2d19dd2 100644 --- a/src/common/lib/client/realtimepresence.ts +++ b/src/common/lib/client/realtimepresence.ts @@ -449,7 +449,7 @@ class RealtimePresence extends EventEmitter { code: 91004, statusCode: 400, cause: err, - hint: 'Listen for the channel "update" event and call presence.enter(...) again (or presence.enterClient(clientId, data) for a member entered on behalf of another clientId) once the channel is attached.', + 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, diff --git a/src/common/lib/client/rest.ts b/src/common/lib/client/rest.ts index 7c2fd6af03..f22b255d4f 100644 --- a/src/common/lib/client/rest.ts +++ b/src/common/lib/client/rest.ts @@ -221,7 +221,7 @@ export class Rest { 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, with revocable tokens enabled) 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.', + 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.', }); } diff --git a/src/common/lib/client/restchannel.ts b/src/common/lib/client/restchannel.ts index d18ad690a8..c63d8a5b6a 100644 --- a/src/common/lib/client/restchannel.ts +++ b/src/common/lib/client/restchannel.ts @@ -145,7 +145,7 @@ class RestChannel { 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.', + 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.', }); } diff --git a/src/plugins/liveobjects/realtimeobject.ts b/src/plugins/liveobjects/realtimeobject.ts index d94e60af02..abcbfbdb76 100644 --- a/src/plugins/liveobjects/realtimeobject.ts +++ b/src/plugins/liveobjects/realtimeobject.ts @@ -572,7 +572,7 @@ export class RealtimeObject { 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 (this triggers 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.`, + 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 @@ -581,7 +581,7 @@ export class RealtimeObject { 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 (this triggers 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.`, + 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.`, }); } }