diff --git a/callbacks.d.ts b/callbacks.d.ts new file mode 100644 index 0000000000..0f11b3297d --- /dev/null +++ b/callbacks.d.ts @@ -0,0 +1,10 @@ +/** + * @deprecated `'ably/callbacks'` was the v1 callback API entry point and has been removed in ably-js v2. + * v2 is promise-only — import from `'ably'` directly and switch to `await` / `.then()`. + * + * Importing this subpath throws at module load with the migration link. + * + * @see https://github.com/ably/ably-js/blob/main/docs/migration-guides/v2/lib.md + */ +declare const ablyCallbacksV1EntryPointRemoved: never; +export = ablyCallbacksV1EntryPointRemoved; diff --git a/callbacks.js b/callbacks.js new file mode 100644 index 0000000000..db806f6077 --- /dev/null +++ b/callbacks.js @@ -0,0 +1,6 @@ +'use strict'; + +const err = new Error("'ably/callbacks' was the v1 callback API entry point and is no longer available."); +err.hint = + "ably-js v2 is promise-only — import from 'ably' directly and switch to await / .then(). See https://github.com/ably/ably-js/blob/main/docs/migration-guides/v2/lib.md"; +throw err; diff --git a/package.json b/package.json index 85781a3802..459a81bf7e 100644 --- a/package.json +++ b/package.json @@ -39,14 +39,26 @@ "types": "./liveobjects.d.ts", "default": "./build/liveobjects.js" } + }, + "./promises": { + "types": "./promises.d.ts", + "default": "./promises.js" + }, + "./callbacks": { + "types": "./callbacks.d.ts", + "default": "./callbacks.js" } }, "files": [ "build/**", "ably.d.ts", + "callbacks.d.ts", + "callbacks.js", "liveobjects.d.ts", "liveobjects.d.mts", "modular.d.ts", + "promises.d.ts", + "promises.js", "push.d.ts", "resources/**", "src/**", diff --git a/promises.d.ts b/promises.d.ts new file mode 100644 index 0000000000..fc242ebcba --- /dev/null +++ b/promises.d.ts @@ -0,0 +1,10 @@ +/** + * @deprecated `'ably/promises'` was the v1 entry point and is no longer available in ably-js v2. + * v2 is promise-only — import from `'ably'` directly. + * + * Importing this subpath throws at module load with the migration link. + * + * @see https://github.com/ably/ably-js/blob/main/docs/migration-guides/v2/lib.md + */ +declare const ablyPromisesV1EntryPointRemoved: never; +export = ablyPromisesV1EntryPointRemoved; diff --git a/promises.js b/promises.js new file mode 100644 index 0000000000..1139c364f3 --- /dev/null +++ b/promises.js @@ -0,0 +1,6 @@ +'use strict'; + +const err = new Error("'ably/promises' was the v1 entry point and is no longer available."); +err.hint = + "ably-js v2 is promise-only — import from 'ably' directly. See https://github.com/ably/ably-js/blob/main/docs/migration-guides/v2/lib.md"; +throw err; diff --git a/scripts/moduleReport.ts b/scripts/moduleReport.ts index e7cf5979e3..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: 107, gzip: 33 }; +const minimalUsefulRealtimeBundleSizeThresholdsKiB = { raw: 118, gzip: 36 }; const baseClientNames = ['BaseRest', 'BaseRealtime']; diff --git a/src/common/lib/client/auth.ts b/src/common/lib/client/auth.ts index dcc6bcdacb..6487715a15 100644 --- a/src/common/lib/client/auth.ts +++ b/src/common/lib/client/auth.ts @@ -155,10 +155,14 @@ class Auth { } else { /* Basic auth */ if (!options.key) { - const msg = - 'No authentication options provided; need one of: key, authUrl, or authCallback (or for testing only, token or tokenDetails)'; + const msg = 'No authentication options provided'; Logger.logAction(this.logger, Logger.LOG_ERROR, 'Auth()', msg); - throw new ErrorInfo(msg, 40160, 401); + throw new ErrorInfo({ + message: msg, + code: 40160, + statusCode: 401, + hint: '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); @@ -269,7 +273,12 @@ class Auth { /* RSA10a: authorize() call implies token auth. If a key is passed it, we * just check if it doesn't clash and assume we're generating a token from it */ if (authOptions && authOptions.key && this.authOptions.key !== authOptions.key) { - throw new ErrorInfo('Unable to update auth options with incompatible key', 40102, 401); + throw new ErrorInfo({ + message: '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 { @@ -486,27 +495,39 @@ class Auth { } if (Platform.BufferUtils.isBuffer(body)) body = body.toString(); if (!contentType) { - cb(new ErrorInfo('authUrl response is missing a content-type header', 40170, 401), null); + const err = new ErrorInfo({ + message: 'authUrl response is missing a content-type header', + code: 40170, + statusCode: 401, + hint: 'Auth endpoints may 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({ + message: + 'authUrl responded with unacceptable Content-Type ' + + contentType + + '. 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.', + }); + cb(err, null); return; } if (json) { if ((body as string).length > MAX_TOKEN_LENGTH) { - cb(new ErrorInfo('authUrl response exceeded max permitted length', 40170, 401), null); + const err = new ErrorInfo({ + message: 'authUrl response exceeded max permitted length', + 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.', + }); + cb(err, null); return; } try { @@ -585,7 +606,12 @@ class Auth { 'Auth()', 'library initialized with a token literal without any way to renew the token when it expires (no authUrl, authCallback, or key). See https://help.ably.io/error/40171 for help', ); - throw new ErrorInfo(msg, 40171, 403); + throw new ErrorInfo({ + message: msg, + code: 40171, + statusCode: 403, + hint: 'Initialise the client with one of ClientOptions.{ key, authUrl, authCallback } so the SDK can refresh tokens. A bare token/tokenDetails alone cannot be renewed once expired.', + }); } /* normalise token params */ @@ -628,7 +654,13 @@ 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({ + 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.', + }); + reject(err); }, timeoutLength); tokenRequestCallback!(resolvedTokenParams, (err, tokenRequestOrDetails, contentType) => { @@ -648,29 +680,41 @@ class Auth { /* the response from the callback might be a token string, a signed request or a token details */ if (typeof tokenRequestOrDetails === 'string') { if (tokenRequestOrDetails.length === 0) { - reject(new ErrorInfo('Token string is empty', 40170, 401)); + const err = new ErrorInfo({ + message: 'Token string is empty', + code: 40170, + statusCode: 401, + hint: 'Return a non-empty 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({ + 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.', + }); + reject(err); } else if (tokenRequestOrDetails === 'undefined' || tokenRequestOrDetails === 'null') { /* common failure mode with poorly-implemented authCallbacks */ - reject(new ErrorInfo('Token string was literal null/undefined', 40170, 401)); + const err = new ErrorInfo({ + message: 'Token string was literal null/undefined', + code: 40170, + statusCode: 401, + hint: 'Return the token itself, not "undefined"/"null"; callbacks that have no value to return should pass an error instead.', + }); + reject(err); } else if ( tokenRequestOrDetails[0] === '{' && !(contentType && contentType.indexOf('application/jwt') > -1) ) { - reject( - new ErrorInfo( - "Token was double-encoded; make sure you're not JSON-encoding an already encoded token request or details", - 40170, - 401, - ), - ); + const err = new ErrorInfo({ + message: 'Token was double-encoded', + code: 40170, + statusCode: 401, + hint: 'Return TokenDetails/TokenRequest as a parsed object, or set Content-Type: application/jwt for JWT tokens.', + }); + reject(err); } else { resolve({ token: tokenRequestOrDetails } as API.TokenDetails); } @@ -681,18 +725,25 @@ class Auth { 'Expected token request callback to call back with a token string or token request/details object, but got a ' + typeof tokenRequestOrDetails; Logger.logAction(this.logger, Logger.LOG_ERROR, 'Auth.requestToken()', msg); - reject(new ErrorInfo(msg, 40170, 401)); + const err = new ErrorInfo({ + message: msg, + code: 40170, + statusCode: 401, + hint: '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( + const err = new ErrorInfo({ + message: 'Token request/details object exceeded max permitted stringified size (was ' + objectSize + ' bytes)', - 40170, - 401, - ), - ); + 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; } if ('issued' in tokenRequestOrDetails) { @@ -704,7 +755,13 @@ 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({ + 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; } /* it's a token request, so make the request */ @@ -775,18 +832,33 @@ class Auth { const key = authOptions.key; if (!key) { - throw new ErrorInfo('No key specified', 40101, 403); + throw new ErrorInfo({ + message: 'No key specified', + code: 40101, + statusCode: 403, + hint: '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(':'), keyName = keyParts[0], keySecret = keyParts[1]; if (!keySecret) { - throw new ErrorInfo('Invalid key specified', 40101, 403); + 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 === '') { - throw new ErrorInfo('clientId can’t be an empty string', 40012, 400); + throw new ErrorInfo({ + message: 'clientId can’t be an empty string', + code: 40012, + statusCode: 400, + hint: 'Pass a non-empty clientId, or omit the field entirely for an anonymous token.', + }); } if ('capability' in tokenParams) { @@ -906,11 +978,13 @@ class Auth { if (token) { if (this._tokenClientIdMismatch(token.clientId)) { /* 403 to trigger a permanently failed client - RSA15c */ - throw new ErrorInfo( - 'Mismatch between clientId in token (' + token.clientId + ') and current clientId (' + this.clientId + ')', - 40102, - 403, - ); + throw new ErrorInfo({ + message: + 'Mismatch between clientId in token (' + token.clientId + ') and current clientId (' + this.clientId + ')', + code: 40102, + statusCode: 403, + hint: 'Issue the token with the same clientId as ClientOptions.clientId, or omit ClientOptions.clientId and let the token define it. 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 @@ -972,13 +1046,19 @@ class Auth { /* User-set: check types, '*' is disallowed, throw any errors */ _userSetClientId(clientId: string | undefined) { if (!(typeof clientId === 'string' || clientId === null)) { - throw new ErrorInfo('clientId must be either a string or null', 40012, 400); + throw new ErrorInfo({ + message: 'clientId must be either a string or null', + code: 40012, + statusCode: 400, + hint: 'Pass a string (e.g. a user id) or null for an anonymous client. Numbers and objects are not accepted.', + }); } else if (clientId === '*') { - throw new ErrorInfo( - 'Can’t use "*" as a clientId as that string is reserved. (To change the default token request behaviour to use a wildcard clientId, instantiate the library with {defaultTokenParams: {clientId: "*"}}), or if calling authorize(), pass it in as a tokenParam: authorize({clientId: "*"}, authOptions)', - 40012, - 400, - ); + throw new ErrorInfo({ + message: 'Can’t use "*" as a clientId as that string is reserved', + code: 40012, + statusCode: 400, + hint: '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; @@ -991,7 +1071,12 @@ class Auth { /* Should never happen in normal circumstances as realtime should * recognise mismatch and return an error */ const msg = 'Unexpected clientId mismatch: client has ' + this.clientId + ', requested ' + clientId; - const err = new ErrorInfo(msg, 40102, 401); + const err = new ErrorInfo({ + message: msg, + code: 40102, + statusCode: 401, + hint: '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; } else { diff --git a/src/common/lib/client/baseclient.ts b/src/common/lib/client/baseclient.ts index 5586d36d87..1511ca79af 100644 --- a/src/common/lib/client/baseclient.ts +++ b/src/common/lib/client/baseclient.ts @@ -76,21 +76,33 @@ class BaseClient { if (!keyMatch) { const msg = 'invalid key parameter'; Logger.logAction(this.logger, Logger.LOG_ERROR, 'BaseClient()', msg); - throw new ErrorInfo(msg, 40400, 404); + throw new ErrorInfo({ + message: msg, + code: 40400, + statusCode: 404, + hint: 'ClientOptions.key must be the full "appId.keyId:secret" string copied from the Ably dashboard. If you 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]; } if ('clientId' in normalOptions) { - if (!(typeof normalOptions.clientId === 'string' || normalOptions.clientId === null)) - throw new ErrorInfo('clientId must be either a string or null', 40012, 400); - else if (normalOptions.clientId === '*') - throw new ErrorInfo( - 'Can’t use "*" as a clientId as that string is reserved. (To change the default token request behaviour to use a wildcard clientId, use {defaultTokenParams: {clientId: "*"}})', - 40012, - 400, - ); + if (!(typeof normalOptions.clientId === 'string' || normalOptions.clientId === null)) { + throw new ErrorInfo({ + message: 'clientId must be either a string or null', + code: 40012, + statusCode: 400, + hint: 'Pass a string (e.g. a user id) or null for an anonymous client. Numbers and 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 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.', + }); + } } 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..f6cf049970 100644 --- a/src/common/lib/client/baserealtime.ts +++ b/src/common/lib/client/baserealtime.ts @@ -199,11 +199,12 @@ class Channels extends EventEmitter { channel = this.all[name] = new RealtimeChannel(this.realtime, name, channelOptions); } else if (channelOptions) { if (channel._shouldReattachToSetOptions(channelOptions, channel.channelOptions)) { - throw new ErrorInfo( - 'Channels.get() cannot be used to set channel options that would cause the channel to reattach. Please, use RealtimeChannel.setOptions() instead.', - 40000, - 400, - ); + throw new ErrorInfo({ + message: 'Channels.get() cannot be used to set channel options that would cause the channel to reattach.', + 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).', + }); } channel.setOptions(channelOptions); } diff --git a/src/common/lib/client/paginatedresource.ts b/src/common/lib/client/paginatedresource.ts index e0ad472475..4f93d4eea2 100644 --- a/src/common/lib/client/paginatedresource.ts +++ b/src/common/lib/client/paginatedresource.ts @@ -147,7 +147,12 @@ export class PaginatedResult { return this.get(this._relParams!.first); } - throw new ErrorInfo('No link to the first page of results', 40400, 404); + throw new ErrorInfo({ + message: 'No link to the first page of results', + code: 40400, + statusCode: 404, + hint: 'Check hasFirst() before calling first().', + }); } async current(): Promise> { @@ -155,7 +160,12 @@ export class PaginatedResult { return this.get(this._relParams!.current); } - throw new ErrorInfo('No link to the current page of results', 40400, 404); + throw new ErrorInfo({ + message: 'No link to the current page of results', + code: 40400, + statusCode: 404, + hint: 'Check hasCurrent() before calling current().', + }); } async next(): Promise | null> { diff --git a/src/common/lib/client/push.ts b/src/common/lib/client/push.ts index 4d57324c39..1122a2ea31 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 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; admin: Admin; @@ -37,11 +40,23 @@ class Push { return; } if (!this.stateMachine) { - reject(new ErrorInfo('This platform is not supported as a target of push notifications', 40000, 400)); + const err = new ErrorInfo({ + message: 'This platform is not supported as a target of push notifications', + code: 40000, + statusCode: 400, + hint: PUSH_NOT_AVAILABLE_HINT, + }); + reject(err); return; } if (this.stateMachine.activatedCallback) { - reject(new ErrorInfo('Activation already in progress', 40000, 400)); + const err = new ErrorInfo({ + message: 'Activation already in progress', + code: 40000, + statusCode: 400, + hint: 'Await the in-flight push.activate() before calling it again.', + }); + reject(err); return; } this.stateMachine.activatedCallback = (err: ErrorInfo) => { @@ -65,11 +80,23 @@ class Push { return; } if (!this.stateMachine) { - reject(new ErrorInfo('This platform is not supported as a target of push notifications', 40000, 400)); + const err = new ErrorInfo({ + message: 'This platform is not supported as a target of push notifications', + code: 40000, + statusCode: 400, + hint: PUSH_NOT_AVAILABLE_HINT, + }); + reject(err); return; } if (this.stateMachine.deactivatedCallback) { - reject(new ErrorInfo('Deactivation already in progress', 40000, 400)); + const err = new ErrorInfo({ + message: 'Deactivation already in progress', + code: 40000, + statusCode: 400, + hint: 'Await the in-flight push.deactivate() before calling it again.', + }); + reject(err); return; } this.stateMachine.deactivatedCallback = (err: ErrorInfo) => { @@ -156,11 +183,12 @@ class DeviceRegistrations { deviceId = deviceIdOrDetails.id || deviceIdOrDetails; if (typeof deviceId !== 'string' || !deviceId.length) { - throw new ErrorInfo( - 'First argument to DeviceRegistrations#get must be a deviceId string or DeviceDetails', - 40000, - 400, - ); + throw new ErrorInfo({ + message: 'First argument to DeviceRegistrations#get must be a deviceId string or DeviceDetails', + code: 40000, + statusCode: 400, + hint: 'Pass either the device id string returned from push.activate(), or the DeviceDetails object (with a non-empty .id field).', + }); } Utils.mixin(headers, client.options.headers); @@ -209,11 +237,12 @@ class DeviceRegistrations { deviceId = deviceIdOrDetails.id || deviceIdOrDetails; if (typeof deviceId !== 'string' || !deviceId.length) { - throw new ErrorInfo( - 'First argument to DeviceRegistrations#remove must be a deviceId string or DeviceDetails', - 40000, - 400, - ); + throw new ErrorInfo({ + message: 'First argument to DeviceRegistrations#remove must be a deviceId string or DeviceDetails', + code: 40000, + statusCode: 400, + hint: 'Pass either the device id string or the DeviceDetails object (with a non-empty .id field). To deactivate the local device, call client.push.deactivate() instead.', + }); } Utils.mixin(headers, client.options.headers); diff --git a/src/common/lib/client/realtimeannotations.ts b/src/common/lib/client/realtimeannotations.ts index 6d698c7f3a..0b0951b0d7 100644 --- a/src/common/lib/client/realtimeannotations.ts +++ b/src/common/lib/client/realtimeannotations.ts @@ -69,13 +69,15 @@ class RealtimeAnnotations { await channel.attach(); } - // explicit check for attach state in caes attachOnSubscribe=false + // explicit check for attach state in case attachOnSubscribe=false if ((this.channel.state === 'attached' && this.channel._mode & flags.ANNOTATION_SUBSCRIBE) === 0) { - throw new ErrorInfo( - "You are trying to add an annotation listener, but you haven't requested the annotation_subscribe channel mode in ChannelOptions, so this won't do anything (we only deliver annotations to clients who have explicitly requested them)", - 93001, - 400, - ); + throw new ErrorInfo({ + message: + "You are trying to add an annotation listener, but you haven't requested the annotation_subscribe channel mode in ChannelOptions, so this won't do anything (we only deliver annotations to clients who have explicitly requested them)", + code: 93001, + statusCode: 400, + hint: '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 fe34cb5877..f0b7175e8c 100644 --- a/src/common/lib/client/realtimechannel.ts +++ b/src/common/lib/client/realtimechannel.ts @@ -31,11 +31,23 @@ interface RealtimeHistoryParams { function validateChannelOptions(options?: API.ChannelOptions) { if (options && 'params' in options && !Utils.isObject(options.params)) { - return new ErrorInfo('options.params must be an object', 40000, 400); + const err = new ErrorInfo({ + message: 'options.params must be an object', + code: 40000, + statusCode: 400, + hint: 'Pass an object map of channel params (e.g. { rewind: "1" }), not a string or array.', + }); + return err; } if (options && 'modes' in options) { if (!Array.isArray(options.modes)) { - return new ErrorInfo('options.modes must be an array', 40000, 400); + const err = new ErrorInfo({ + message: 'options.modes must be an array', + code: 40000, + statusCode: 400, + hint: 'Pass an array of ChannelMode strings, e.g. { modes: ["publish", "subscribe"] }.', + }); + return err; } for (let i = 0; i < options.modes.length; i++) { const currentMode = options.modes[i]; @@ -44,7 +56,13 @@ function validateChannelOptions(options?: API.ChannelOptions) { typeof currentMode !== 'string' || !channelModes.includes(String.prototype.toUpperCase.call(currentMode)) ) { - return new ErrorInfo('Invalid channel mode: ' + currentMode, 40000, 400); + const err = new ErrorInfo({ + message: 'Invalid channel mode: ' + currentMode, + code: 40000, + statusCode: 400, + hint: `Valid ChannelMode values are: ${channelModes.join(', ').toLowerCase()}. The server 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; } } } @@ -173,12 +191,14 @@ class RealtimeChannel extends EventEmitter { } invalidStateError(): ErrorInfo { - return new ErrorInfo( - 'Channel operation failed as channel state is ' + this.state, - 90001, - 400, - this.errorReason || undefined, - ); + const err = new ErrorInfo({ + message: 'Channel operation failed as channel state is ' + this.state, + code: 90001, + statusCode: 400, + cause: this.errorReason || undefined, + hint: 'Inspect channel.errorReason for the underlying cause. From "failed", call channel.attach() to recover; "suspended" recovers automatically once the underlying connection is re-established.', + }); + return err; } static processListenerArgs(args: unknown[]): any[] { @@ -271,11 +291,12 @@ class RealtimeChannel extends EventEmitter { messages = Message.fromValuesArray(first); params = args[1]; } else { - throw new ErrorInfo( - 'The single-argument form of publish() expects a message object or an array of message objects', - 40013, - 400, - ); + throw new ErrorInfo({ + message: 'The single-argument form of publish() expects a message object or an array of message objects', + code: 40013, + statusCode: 400, + hint: 'Call publish(name, data) for a single event, or publish(message | message[]) with a Message-shaped object.', + }); } const maxMessageSize = this.client.options.maxMessageSize; // TODO get rid of CipherOptions type assertion, indicates channeloptions types are broken @@ -283,11 +304,12 @@ class RealtimeChannel extends EventEmitter { /* RSL1i */ const size = getMessagesSize(wireMessages); if (size > maxMessageSize) { - throw new ErrorInfo( - `Maximum size of messages that can be published at once exceeded (was ${size} bytes; limit is ${maxMessageSize} bytes)`, - 40009, - 400, - ); + throw new ErrorInfo({ + message: `Maximum size of messages that can be published at once exceeded (was ${size} bytes; 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(); @@ -417,8 +439,14 @@ class RealtimeChannel extends EventEmitter { case 'detached': return; // RTL5b - case 'failed': - throw new ErrorInfo('Unable to detach; channel state = failed', 90001, 400); + case 'failed': { + throw new ErrorInfo({ + message: 'Unable to detach; 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.', + }); + } default: // RTL5l: if connection is not connected, immediately transition to detached if (connectionManager.state.state !== 'connected') { @@ -504,8 +532,13 @@ class RealtimeChannel extends EventEmitter { switch (this.state) { case 'initialized': case 'detaching': - case 'detached': - throw new PartialErrorInfo('Unable to sync to channel; not attached', 40000); + case 'detached': { + // sync() is an internal SDK method, so no fix-it hint here — user/LLM code shouldn't reach this throw. + throw new PartialErrorInfo({ + message: 'Unable to sync to channel; not attached', + code: 40000, + }); + } default: } const connectionManager = this.connectionManager; @@ -933,12 +966,22 @@ class RealtimeChannel extends EventEmitter { timeoutPendingState(): void { switch (this.state) { case 'attaching': { - const err = new ErrorInfo('Channel attach timed out', 90007, 408); + const err = new ErrorInfo({ + message: 'Channel attach timed out', + code: 90007, + statusCode: 408, + hint: 'The 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); + const err = new ErrorInfo({ + message: 'Channel detach timed out', + code: 90007, + statusCode: 408, + hint: 'The channel has reverted to attached; retry detach() once the connection is stable.', + }); this.notifyState('attached', err); break; } @@ -1011,14 +1054,20 @@ class RealtimeChannel extends EventEmitter { if (params && params.untilAttach) { if (this.state !== 'attached') { - throw new ErrorInfo('option untilAttach requires the channel to be attached', 40000, 400); + throw new ErrorInfo({ + message: 'option untilAttach requires the channel to be attached', + code: 40000, + statusCode: 400, + hint: 'Await channel.attach() (or channel.whenState("attached")) before calling history({ untilAttach: true }).', + }); } if (!this.properties.attachSerial) { - throw new ErrorInfo( - 'untilAttach was specified and channel is attached, but attachSerial is not defined', - 40000, - 400, - ); + throw new ErrorInfo({ + message: 'untilAttach was specified and channel is attached, but attachSerial is not defined', + code: 40000, + statusCode: 400, + hint: '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; @@ -1037,12 +1086,15 @@ class RealtimeChannel extends EventEmitter { if (s === 'initialized' || s === 'detached' || s === 'failed') { return null; } - return new ErrorInfo( - 'Can only release a channel in a state where there is no possibility of further updates from the server being received (initialized, detached, or failed); was ' + + const err = new ErrorInfo({ + message: + 'Can only release a channel in a state where there is no possibility of further updates from the server being received (initialized, detached, or failed); was ' + s, - 90001, - 400, - ); + code: 90001, + statusCode: 400, + hint: 'Call channel.detach() and wait for the channel to reach "detached" before calling channels.release(name).', + }); + return err; } setChannelSerial(channelSerial?: string | null): void { @@ -1104,11 +1156,12 @@ class RealtimeChannel extends EventEmitter { params?: Record, ): Promise { if (!message.serial) { - throw new ErrorInfo( - 'This message lacks a serial and cannot be updated. Make sure you have enabled "Message annotations, updates, and deletes" in channel settings on your dashboard.', - 40003, - 400, - ); + throw new ErrorInfo({ + message: 'This message lacks a serial 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.', + }); } this.throwIfUnpublishableState(); diff --git a/src/common/lib/client/realtimepresence.ts b/src/common/lib/client/realtimepresence.ts index 9ea798f112..1bd1911dab 100644 --- a/src/common/lib/client/realtimepresence.ts +++ b/src/common/lib/client/realtimepresence.ts @@ -61,7 +61,12 @@ class RealtimePresence extends EventEmitter { private async _enterImpl(data: unknown): Promise { if (isAnonymousOrWildcard(this)) { - throw new ErrorInfo('clientId must be specified to enter a presence channel', 40012, 400); + throw new ErrorInfo({ + message: 'clientId must be specified to enter a presence channel', + code: 40012, + statusCode: 400, + hint: 'Set ClientOptions.clientId (or include clientId in the token) before calling presence.enter(). 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'); } @@ -73,7 +78,12 @@ class RealtimePresence extends EventEmitter { private async _updateImpl(data: unknown): Promise { if (isAnonymousOrWildcard(this)) { - throw new ErrorInfo('clientId must be specified to update presence data', 40012, 400); + throw new ErrorInfo({ + message: 'clientId must be specified to update presence data', + code: 40012, + statusCode: 400, + hint: 'Set ClientOptions.clientId (or include clientId in the token) before calling presence.update(). 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'); } @@ -146,7 +156,12 @@ class RealtimePresence extends EventEmitter { private async _leaveImpl(data: unknown): Promise { if (isAnonymousOrWildcard(this)) { - throw new ErrorInfo('clientId must have been specified to enter or leave a presence channel', 40012, 400); + throw new ErrorInfo({ + message: 'clientId must have been specified to enter or leave a presence channel', + code: 40012, + statusCode: 400, + hint: '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); } @@ -184,7 +199,11 @@ class RealtimePresence extends EventEmitter { case 'failed': { /* we're not attached; therefore we let any entered status * timeout by itself instead of attaching just in order to leave */ - throw new PartialErrorInfo('Unable to leave presence channel (incompatible state)', 90001); + throw new PartialErrorInfo({ + message: 'Unable to leave presence channel (incompatible state)', + code: 90001, + hint: 'From "initialized" there is no presence entry to leave; from "failed" inspect channel.errorReason and re-attach before retrying.', + }); } default: throw channel.invalidStateError(); @@ -210,6 +229,7 @@ class RealtimePresence extends EventEmitter { statusCode: 400, code: 91005, message: 'Presence state is out of sync due to channel being in the SUSPENDED state', + hint: 'Wait for the channel to reach "attached" before calling presence.get(), or pass { waitForSync: false } to read the last known (stale) members.', }); } return toMessages(this.members); @@ -239,11 +259,12 @@ class RealtimePresence extends EventEmitter { delete params.untilAttach; params.from_serial = this.channel.properties.attachSerial; } else { - throw new ErrorInfo( - 'option untilAttach requires the channel to be attached, was: ' + this.channel.state, - 40000, - 400, - ); + throw new ErrorInfo({ + message: 'option untilAttach requires the channel to be attached, was: ' + this.channel.state, + code: 40000, + statusCode: 400, + hint: 'Await channel.attach() (or channel.whenState("attached")) before calling presence.history({ untilAttach: true }).', + }); } } @@ -403,7 +424,13 @@ class RealtimePresence extends EventEmitter { // RTP17g1: suppress id if the connId has changed const id = entry.connectionId === connId ? entry.id : undefined; this._enterOrUpdateClient(id, entry.clientId, entry.data, 'enter').catch((err) => { - const wrappedErr = new ErrorInfo('Presence auto re-enter failed', 91004, 400, err); + const wrappedErr = new ErrorInfo({ + message: 'Presence auto re-enter failed', + code: 91004, + statusCode: 400, + cause: err, + hint: 'Listen for the channel "update" event and call presence.enter(...) again once the channel is attached.', + }); Logger.logAction( this.logger, Logger.LOG_ERROR, diff --git a/src/common/lib/client/rest.ts b/src/common/lib/client/rest.ts index deb9d960ef..430067e114 100644 --- a/src/common/lib/client/rest.ts +++ b/src/common/lib/client/rest.ts @@ -144,7 +144,12 @@ export class Rest { ); if (!Platform.Http.methods.includes(_method)) { - throw new ErrorInfo('Unsupported method ' + _method, 40500, 405); + throw new ErrorInfo({ + message: 'Unsupported method ' + _method, + code: 40500, + statusCode: 405, + hint: `Use one of: ${Platform.Http.methods.join(', ')}.`, + }); } if (Platform.Http.methodsWithBody.includes(_method)) { @@ -212,7 +217,12 @@ export class Rest { options?: TokenRevocationOptions, ): Promise { if (useTokenAuth(this.client.options)) { - throw new ErrorInfo('Cannot revoke tokens when using token auth', 40162, 401); + throw new ErrorInfo({ + message: 'Cannot revoke tokens when using token auth', + code: 40162, + statusCode: 401, + hint: '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 57c5f86069..52f93a0fa7 100644 --- a/src/common/lib/client/restannotations.ts +++ b/src/common/lib/client/restannotations.ts @@ -24,11 +24,13 @@ export function serialFromMsgOrSerial(msgOrSerial: string | Message): string { break; } if (!messageSerial || typeof messageSerial !== 'string') { - throw new ErrorInfo( - 'First argument of annotations.publish() must be either a Message (or at least an object with a string `serial` property) or a message serial (string)', - 40003, - 400, - ); + throw new ErrorInfo({ + message: + '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; } @@ -40,11 +42,12 @@ export function constructValidateAnnotation( const messageSerial = serialFromMsgOrSerial(msgOrSerial); if (!annotationValues || typeof annotationValues !== 'object') { - throw new ErrorInfo( - 'Second argument of annotations.publish() must be an object (the intended annotation to publish)', - 40003, - 400, - ); + throw new ErrorInfo({ + message: 'Second argument of annotations.publish() must be an object (the intended annotation to publish)', + code: 40003, + statusCode: 400, + hint: 'Pass an Annotation-shaped object as the second argument, e.g. { type: "reaction:unique.v1", name: "👍" }.', + }); } const annotation = Annotation.fromValues(annotationValues); diff --git a/src/common/lib/client/restchannel.ts b/src/common/lib/client/restchannel.ts index b55ab8b84a..a416b50a10 100644 --- a/src/common/lib/client/restchannel.ts +++ b/src/common/lib/client/restchannel.ts @@ -106,11 +106,12 @@ class RestChannel { messages = Message.fromValuesArray(first); params = args[1]; } else { - throw new ErrorInfo( - 'The single-argument form of publish() expects a message object or an array of message objects', - 40013, - 400, - ); + throw new ErrorInfo({ + message: '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 (!params) { @@ -139,11 +140,12 @@ class RestChannel { const size = getMessagesSize(wireMessages), maxMessageSize = options.maxMessageSize; if (size > maxMessageSize) { - throw new ErrorInfo( - `Maximum size of messages that can be published at once exceeded (was ${size} bytes; limit is ${maxMessageSize} bytes)`, - 40009, - 400, - ); + throw new ErrorInfo({ + message: `Maximum size of messages that can be published at once exceeded (was ${size} bytes; 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 85dae2b732..e77b03ca1f 100644 --- a/src/common/lib/client/restchannelmixin.ts +++ b/src/common/lib/client/restchannelmixin.ts @@ -64,11 +64,12 @@ export class RestChannelMixin { static async getMessage(channel: RestChannel | RealtimeChannel, serialOrMessage: string | Message): Promise { const serial = typeof serialOrMessage === 'string' ? serialOrMessage : serialOrMessage.serial; if (!serial) { - throw new ErrorInfo( - 'This message lacks a serial. Make sure you have enabled "Message annotations, updates, and deletes" in channel settings on your dashboard.', - 40003, - 400, - ); + throw new ErrorInfo({ + message: 'This message lacks a serial', + code: 40003, + statusCode: 400, + hint: 'Pass the Message received from a subscribe callback (which carries .serial), or its serial string. 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; @@ -97,11 +98,12 @@ export class RestChannelMixin { params?: Record, ): Promise { if (!message.serial) { - throw new ErrorInfo( - 'This message lacks a serial and cannot be updated. Make sure you have enabled "Message annotations, updates, and deletes" in channel settings on your dashboard.', - 40003, - 400, - ); + throw new ErrorInfo({ + message: 'This message lacks a serial 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.', + }); } const client = channel.client; @@ -139,11 +141,12 @@ export class RestChannelMixin { ): Promise> { const serial = typeof serialOrMessage === 'string' ? serialOrMessage : serialOrMessage.serial; if (!serial) { - throw new ErrorInfo( - 'This message lacks a serial. Make sure you have enabled "Message annotations, updates, and deletes" in channel settings on your dashboard.', - 40003, - 400, - ); + throw new ErrorInfo({ + message: 'This message lacks a serial', + code: 40003, + statusCode: 400, + hint: 'Pass the Message received from a subscribe callback (which carries .serial), or its serial string. 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 a2f29fe730..a7b3220862 100644 --- a/src/common/lib/transport/connectionmanager.ts +++ b/src/common/lib/transport/connectionmanager.ts @@ -1881,7 +1881,12 @@ class ConnectionManager extends EventEmitter { async ping(): Promise { if (this.state.state !== 'connected') { - throw new ErrorInfo('Unable to ping service; not connected', 40000, 400); + throw new ErrorInfo({ + message: 'Unable to ping service; not connected', + code: 40000, + statusCode: 400, + hint: 'Wait for connection.state to be "connected" before calling ping(). Use await connection.whenState("connected") or connection.once("connected", …).', + }); } const transport = this.activeProtocol?.getTransport(); @@ -1945,11 +1950,25 @@ 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({ + message: msg, + 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.', + }); + this.notifyState({ state: 'failed', error: wrapped }); } else { const msg = 'Client configured authentication provider request failed'; Logger.logAction(this.logger, Logger.LOG_MINOR, 'ConnectionManager.actOnErrorFromAuthorize', msg); - this.notifyState({ state: this.state.failState as string, error: new ErrorInfo(msg, 80019, 401, err) }); + const wrapped = new ErrorInfo({ + message: msg, + code: 80019, + statusCode: 401, + cause: err, + hint: 'Check network connectivity to your authUrl/authCallback endpoint and that it returns a valid token shape; 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..ad51bda443 100644 --- a/src/common/lib/types/basemessage.ts +++ b/src/common/lib/types/basemessage.ts @@ -132,7 +132,12 @@ export function encodeData( } // RSL4a, throw an error for unsupported types - throw new ErrorInfo('Data type is unsupported', 40013, 400); + throw new ErrorInfo({ + message: 'Data type is unsupported', + code: 40013, + statusCode: 400, + hint: 'Message data must be a string, Buffer/ArrayBuffer/TypedArray, plain object, or array. Convert other types (e.g. Date, Map, Set) to one of these before publishing.', + }); } export async function decode( @@ -212,14 +217,20 @@ export async function decodeData( } case 'vcdiff': if (!context.plugins || !context.plugins.vcdiff) { - throw new ErrorInfo('Missing Vcdiff decoder (https://github.com/ably-forks/vcdiff-decoder)', 40019, 400); + throw new ErrorInfo({ + message: 'Missing Vcdiff decoder (https://github.com/ably-forks/vcdiff-decoder)', + code: 40019, + statusCode: 400, + hint: 'Install @ably/vcdiff-decoder and pass it in ClientOptions.plugins.vcdiff.', + }); } if (typeof Uint8Array === 'undefined') { - throw new ErrorInfo( - 'Delta decoding not supported on this browser (need ArrayBuffer & Uint8Array)', - 40020, - 400, - ); + throw new ErrorInfo({ + message: 'Delta decoding not supported on this browser (need ArrayBuffer & Uint8Array)', + code: 40020, + statusCode: 400, + hint: 'Disable channel deltas (do not set delta in channel params) on environments without typed-array support, or upgrade the JavaScript runtime.', + }); } try { let deltaBase = context.baseEncodedPreviousPayload; @@ -236,7 +247,12 @@ export async function decodeData( ); lastPayload = decodedData; } catch (e) { - throw new ErrorInfo('Vcdiff delta decode failed with ' + e, 40018, 400); + throw new ErrorInfo({ + message: 'Vcdiff delta decode failed with ' + e, + code: 40018, + statusCode: 400, + hint: 'The SDK recovers by re-attaching without delta. If you see this repeatedly, the base payload has diverged - disable channel deltas for this channel.', + }); } continue; default: @@ -245,11 +261,12 @@ export async function decodeData( } } catch (e) { const err = e as ErrorInfo; - decodingError = new ErrorInfo( - `Error processing the ${xform} encoding, decoder returned ‘${err.message}’`, - err.code || 40013, - 400, - ); + decodingError = new ErrorInfo({ + message: `Error processing the ${xform} encoding, decoder returned ‘${err.message}’`, + code: err.code || 40013, + statusCode: 400, + hint: err.hint, + }); } finally { finalEncoding = (lastProcessedEncodingIndex as number) <= 0 ? null : xforms.slice(0, lastProcessedEncodingIndex).join('/'); diff --git a/src/common/lib/types/errorinfo.ts b/src/common/lib/types/errorinfo.ts index d4cbf65e1f..8c584ce9d4 100644 --- a/src/common/lib/types/errorinfo.ts +++ b/src/common/lib/types/errorinfo.ts @@ -29,6 +29,9 @@ export interface IConvertibleToErrorInfo { code: number; statusCode: number; detail?: Record; + hint?: string; + cause?: ErrorInfo | PartialErrorInfo; + href?: string; } export interface IConvertibleToPartialErrorInfo { @@ -36,6 +39,9 @@ export interface IConvertibleToPartialErrorInfo { code: number | null; statusCode?: number; detail?: Record; + hint?: string; + cause?: ErrorInfo | PartialErrorInfo; + href?: string; } export default class ErrorInfo extends Error implements IPartialErrorInfo, API.ErrorInfo { @@ -46,15 +52,43 @@ export default class ErrorInfo extends Error implements IPartialErrorInfo, API.E detail?: Record; hint?: string; - constructor(message: string, code: number, statusCode: number, cause?: ErrorInfo, detail?: Record) { - super(message); - if (typeof Object.setPrototypeOf !== 'undefined') { - Object.setPrototypeOf(this, ErrorInfo.prototype); + constructor(message: string, code: number, statusCode: number, cause?: ErrorInfo, detail?: Record); + constructor(values: IConvertibleToErrorInfo); + constructor( + messageOrValues: string | IConvertibleToErrorInfo, + code?: number, + statusCode?: number, + cause?: ErrorInfo, + detail?: Record, + ) { + if (typeof messageOrValues === 'object') { + const values = messageOrValues; + if ( + typeof values.message !== 'string' || + typeof values.code !== 'number' || + typeof values.statusCode !== 'number' || + (!Utils.isNil(values.detail) && (typeof values.detail !== 'object' || Array.isArray(values.detail))) + ) { + throw new Error('ErrorInfo: invalid values: ' + Platform.Config.inspect(values)); + } + super(values.message); + if (typeof Object.setPrototypeOf !== 'undefined') { + Object.setPrototypeOf(this, ErrorInfo.prototype); + } + this.code = values.code; + this.statusCode = values.statusCode; + this.detail = values.detail; + Object.assign(this, values); + } else { + super(messageOrValues); + if (typeof Object.setPrototypeOf !== 'undefined') { + Object.setPrototypeOf(this, ErrorInfo.prototype); + } + this.code = code as number; + this.statusCode = statusCode as number; + this.cause = cause; + this.detail = detail; } - this.code = code; - this.statusCode = statusCode; - this.cause = cause; - this.detail = detail; } toString(): string { @@ -62,16 +96,11 @@ export default class ErrorInfo extends Error implements IPartialErrorInfo, API.E } static fromValues(values: IConvertibleToErrorInfo): ErrorInfo { - const { message, code, statusCode, detail } = values; - if ( - typeof message !== 'string' || - typeof code !== 'number' || - typeof statusCode !== 'number' || - (!Utils.isNil(detail) && (typeof detail !== 'object' || Array.isArray(detail))) - ) { - throw new Error('ErrorInfo.fromValues(): invalid values: ' + Platform.Config.inspect(values)); - } - const result = Object.assign(new ErrorInfo(message, code, statusCode, undefined, detail), values); + // Delegate shape validation and field assignment to the options-object constructor; + // fromValues only adds the help.ably.io href default for server-decoded errors that + // arrive without one. SDK-thrown errors that use `new ErrorInfo({...})` directly do + // not get this default, by design. + const result = new ErrorInfo(values); if (result.code && !result.href) { result.href = 'https://help.ably.io/error/' + result.code; } @@ -93,15 +122,43 @@ export class PartialErrorInfo extends Error implements IPartialErrorInfo { statusCode?: number, cause?: ErrorInfo | PartialErrorInfo, detail?: Record, + ); + constructor(values: IConvertibleToPartialErrorInfo); + constructor( + messageOrValues: string | IConvertibleToPartialErrorInfo, + code?: number | null, + statusCode?: number, + cause?: ErrorInfo | PartialErrorInfo, + detail?: Record, ) { - super(message); - if (typeof Object.setPrototypeOf !== 'undefined') { - Object.setPrototypeOf(this, PartialErrorInfo.prototype); + if (typeof messageOrValues === 'object') { + const values = messageOrValues; + if ( + typeof values.message !== 'string' || + (!Utils.isNil(values.code) && typeof values.code !== 'number') || + (!Utils.isNil(values.statusCode) && typeof values.statusCode !== 'number') || + (!Utils.isNil(values.detail) && (typeof values.detail !== 'object' || Array.isArray(values.detail))) + ) { + throw new Error('PartialErrorInfo: invalid values: ' + Platform.Config.inspect(values)); + } + super(values.message); + if (typeof Object.setPrototypeOf !== 'undefined') { + Object.setPrototypeOf(this, PartialErrorInfo.prototype); + } + this.code = values.code; + this.statusCode = values.statusCode; + this.detail = values.detail; + Object.assign(this, values); + } else { + super(messageOrValues); + if (typeof Object.setPrototypeOf !== 'undefined') { + Object.setPrototypeOf(this, PartialErrorInfo.prototype); + } + this.code = code as number | null; + this.statusCode = statusCode; + this.cause = cause; + this.detail = detail; } - this.code = code; - this.statusCode = statusCode; - this.cause = cause; - this.detail = detail; } toString(): string { @@ -109,16 +166,9 @@ export class PartialErrorInfo extends Error implements IPartialErrorInfo { } static fromValues(values: IConvertibleToPartialErrorInfo): PartialErrorInfo { - const { message, code, statusCode, detail } = values; - if ( - typeof message !== 'string' || - (!Utils.isNil(code) && typeof code !== 'number') || - (!Utils.isNil(statusCode) && typeof statusCode !== 'number') || - (!Utils.isNil(detail) && (typeof detail !== 'object' || Array.isArray(detail))) - ) { - throw new Error('PartialErrorInfo.fromValues(): invalid values: ' + Platform.Config.inspect(values)); - } - const result = Object.assign(new PartialErrorInfo(message, code, statusCode, undefined, detail), values); + // Same shape as ErrorInfo.fromValues - delegate validation/assignment to the + // options-object constructor; href default applies only to the server-decoded path. + const result = new PartialErrorInfo(values); if (result.code && !result.href) { result.href = 'https://help.ably.io/error/' + result.code; } diff --git a/src/common/lib/util/defaults.ts b/src/common/lib/util/defaults.ts index b9c58d56bf..5cb574ad35 100644 --- a/src/common/lib/util/defaults.ts +++ b/src/common/lib/util/defaults.ts @@ -175,10 +175,20 @@ export function getHosts(options: NormalisedClientOptions): string[] { function checkHost(host: string): void { if (typeof host !== 'string') { - throw new ErrorInfo('host must be a string; was a ' + typeof host, 40000, 400); + throw new ErrorInfo({ + message: 'host must be a string; was a ' + typeof host, + code: 40000, + statusCode: 400, + hint: 'Pass `endpoint` as a single endpoint name string (e.g. "main"), not an array or object.', + }); } if (!host.length) { - throw new ErrorInfo('host must not be zero-length', 40000, 400); + throw new ErrorInfo({ + message: 'host must not be zero-length', + code: 40000, + statusCode: 400, + hint: 'Omit `endpoint`/`restHost`/`realtimeHost` to use the Ably default, or pass a non-empty hostname.', + }); } } @@ -251,21 +261,24 @@ function checkIfClientOptionsAreValid(options: ClientOptions) { // REC1b if (options.endpoint && (options.environment || options.restHost || options.realtimeHost)) { // RSC1b - throw new ErrorInfo( - 'The `endpoint` option cannot be used in conjunction with the `environment`, `restHost`, or `realtimeHost` options.', - 40106, - 400, - ); + throw new ErrorInfo({ + message: + 'The `endpoint` option cannot be used in conjunction with the `environment`, `restHost`, or `realtimeHost` options.', + code: 40106, + statusCode: 400, + hint: '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 - throw new ErrorInfo( - 'The `environment` option cannot be used in conjunction with the `restHost`, or `realtimeHost` options.', - 40106, - 400, - ); + throw new ErrorInfo({ + message: 'The `environment` option cannot be used in conjunction with the `restHost`, or `realtimeHost` options.', + code: 40106, + statusCode: 400, + hint: '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 3a1bd875de..c5143d81c0 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,11 +473,21 @@ export function matchDerivedChannel(name: string) { const regex = /^(\[([^?]*)(?:(.*))\])?(.+)$/; // eslint-disable-line const match = name.match(regex); if (!match || !match.length || match.length < 5) { - throw new ErrorInfo('regex match failed', 400, 40010); + throw new ErrorInfo({ + message: 'Channel name does not match the [filter=...]name shape required for derived channels', + code: 40010, + statusCode: 400, + 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 if (match![2]) { - throw new ErrorInfo(`cannot use a derived option with a ${match[2]} channel`, 400, 40010); + throw new ErrorInfo({ + message: `cannot use a derived option with a ${match[2]} channel`, + code: 40010, + statusCode: 400, + hint: `Use a base channel name instead, without the "${match[2]}" qualifier.`, + }); } // Return match values to be added to derive channel quantifier. return { @@ -499,7 +512,13 @@ 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({ + 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; } export function throwMissingPluginError(pluginName: keyof ModularPlugins): never { @@ -551,7 +570,12 @@ export async function* listenerToAsyncIterator( yield eventQueue.shift()!; } else { if (resolveNext) { - throw new ErrorInfo('Concurrent next() calls are not supported', 40000, 400); + throw new ErrorInfo({ + message: 'Concurrent next() calls are not supported', + code: 40000, + statusCode: 400, + hint: 'Drive the async iterator from a single for-await-of loop.', + }); } // Otherwise wait for the next event to arrive diff --git a/src/plugins/liveobjects/realtimeobject.ts b/src/plugins/liveobjects/realtimeobject.ts index fc5b082051..81adb75590 100644 --- a/src/plugins/liveobjects/realtimeobject.ts +++ b/src/plugins/liveobjects/realtimeobject.ts @@ -568,11 +568,21 @@ export class RealtimeObject { private _throwIfMissingChannelMode(expectedMode: 'object_subscribe' | 'object_publish'): void { // RTO2a - channel.modes is only populated on channel attachment, so use it only if it is set if (this._channel.modes != null && !this._channel.modes.includes(expectedMode)) { - throw new this._client.ErrorInfo(`"${expectedMode}" channel mode must be set for this operation`, 40024, 400); // RTO2a2 + throw new this._client.ErrorInfo({ + message: `"${expectedMode}" channel mode must be set for this operation`, + code: 40024, + statusCode: 400, + hint: `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 if (!this._client.Utils.allToLowerCase(this._channel.channelOptions.modes ?? []).includes(expectedMode)) { - throw new this._client.ErrorInfo(`"${expectedMode}" channel mode must be set for this operation`, 40024, 400); // RTO2b2 + throw new this._client.ErrorInfo({ + message: `"${expectedMode}" channel mode must be set for this operation`, + code: 40024, + statusCode: 400, + hint: `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/getW3CDeviceDetails.ts b/src/plugins/push/getW3CDeviceDetails.ts index 0cee91ef9c..28e685b75d 100644 --- a/src/plugins/push/getW3CDeviceDetails.ts +++ b/src/plugins/push/getW3CDeviceDetails.ts @@ -28,17 +28,25 @@ 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({ + 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.', + }); + machine.handleEvent(new GettingPushDeviceDetailsFailed(err)); return; } const swUrl = machine.client.options.pushServiceWorkerUrl; if (!swUrl) { - machine.handleEvent( - new GettingPushDeviceDetailsFailed(new ErrorInfo('Missing ClientOptions.pushServiceWorkerUrl', 400, 40000)), - ); + const err = new ErrorInfo({ + message: 'Missing ClientOptions.pushServiceWorkerUrl', + code: 40000, + statusCode: 400, + hint: 'Set ClientOptions.pushServiceWorkerUrl to the path of your service worker (e.g. "/ably-push-sw.js") so the SDK can register it for web push.', + }); + machine.handleEvent(new GettingPushDeviceDetailsFailed(err)); return; } diff --git a/src/plugins/push/pushactivation.ts b/src/plugins/push/pushactivation.ts index 53de078d34..6228e04f7a 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 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', deviceSecret: 'ably.push.deviceSecret', @@ -62,11 +65,21 @@ export function localDeviceFactory(deviceDetails: typeof DeviceDetails) { async listSubscriptions(): Promise> { const Platform = this.rest.Platform; if (!Platform.Config.push) { - throw new this.rest.ErrorInfo('Push activation is not available on this platform', 40000, 400); + throw new this.rest.ErrorInfo({ + message: 'Push activation is not available on this platform', + code: 40000, + statusCode: 400, + hint: PUSH_NOT_AVAILABLE_HINT, + }); } if (!this.id) { - throw new this.rest.ErrorInfo('Device not activated', 40000, 400); + throw new this.rest.ErrorInfo({ + message: 'Device not activated', + code: 40000, + statusCode: 400, + hint: 'Call client.push.activate(registerCallback) and await its completion before listing subscriptions or other device-scoped operations.', + }); } if (!this.deviceIdentityToken) { @@ -96,7 +109,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); + 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; @@ -117,7 +135,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); + 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); @@ -193,7 +216,12 @@ export class ActivationStateMachine { get pushConfig() { if (!this._pushConfig) { - throw new this.client.ErrorInfo('This platform is not supported as a target of push notifications', 40000, 400); + throw new this.client.ErrorInfo({ + message: 'This platform is not supported as a target of push notifications', + code: 40000, + statusCode: 400, + hint: PUSH_NOT_AVAILABLE_HINT, + }); } return this._pushConfig; } @@ -229,11 +257,14 @@ export class ActivationStateMachine { } if (!deviceRegistration) { - this.handleEvent( - new GettingDeviceRegistrationFailed( - new this.client.ErrorInfo('registerCallback did not return deviceRegistration', 40000, 400), - ), - ); + const err = new this.client.ErrorInfo({ + message: 'registerCallback did not return deviceRegistration', + code: 40000, + statusCode: 400, + hint: 'Your registerCallback must invoke its callback with (null, deviceRegistration).', + }); + this.handleEvent(new GettingDeviceRegistrationFailed(err)); + return; } if (isNew) { diff --git a/src/plugins/push/pushchannel.ts b/src/plugins/push/pushchannel.ts index 2208c5e569..06012879ad 100644 --- a/src/plugins/push/pushchannel.ts +++ b/src/plugins/push/pushchannel.ts @@ -50,7 +50,12 @@ class PushChannel { const client = this.client; const clientId = this.client.auth.clientId; if (!clientId) { - throw new this.client.ErrorInfo('Cannot subscribe from client without client ID', 50000, 500); + throw new this.client.ErrorInfo({ + message: 'Cannot subscribe from client without client ID', + code: 50000, + statusCode: 500, + hint: 'Set ClientOptions.clientId (or include clientId in the token) before calling pushChannel.subscribeClient().', + }); } const format = client.options.useBinaryProtocol ? client.Utils.Format.msgpack : client.Utils.Format.json, body = { clientId: clientId, channel: this.channel.name }, @@ -67,7 +72,12 @@ class PushChannel { const clientId = this.client.auth.clientId; if (!clientId) { - throw new this.client.ErrorInfo('Cannot unsubscribe from client without client ID', 50000, 500); + throw new this.client.ErrorInfo({ + message: 'Cannot unsubscribe from client without client ID', + code: 50000, + statusCode: 500, + hint: 'Set ClientOptions.clientId (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); @@ -105,7 +115,12 @@ class PushChannel { if (deviceIdentityToken) { return deviceIdentityToken; } else { - throw new this.client.ErrorInfo('Cannot subscribe from client without deviceIdentityToken', 50000, 500); + throw new this.client.ErrorInfo({ + message: 'Cannot subscribe or unsubscribe this device without a deviceIdentityToken', + code: 50000, + statusCode: 500, + hint: 'Activate this device first by awaiting client.push.activate(registerCallback).', + }); } } diff --git a/test/unit/legacy-import-shims.test.js b/test/unit/legacy-import-shims.test.js new file mode 100644 index 0000000000..501e251308 --- /dev/null +++ b/test/unit/legacy-import-shims.test.js @@ -0,0 +1,68 @@ +'use strict'; + +define(['chai'], function (chai) { + const { expect } = chai; + const fs = require('fs'); + const path = require('path'); + const repoRoot = path.resolve(__dirname, '..', '..'); + + describe('legacy v1 import-path shims', function () { + function loadShim(relPath) { + const abs = path.join(repoRoot, relPath); + delete require.cache[abs]; + require(abs); + } + + it("'ably/promises' shim throws naming the v1 entry point, with a hint pointing at the migration guide", function () { + let caught; + try { + loadShim('promises.js'); + } catch (err) { + caught = err; + } + expect(caught).to.be.an.instanceOf(Error); + expect(caught.message).to.match(/'ably\/promises' was the v1 entry point/); + expect(caught.hint).to.be.a('string'); + expect(caught.hint).to.match(/promise-only/); + expect(caught.hint).to.match(/migration-guides\/v2\/lib\.md/); + }); + + it("'ably/callbacks' shim throws naming the v1 callback API, with a hint pointing at the migration guide", function () { + let caught; + try { + loadShim('callbacks.js'); + } catch (err) { + caught = err; + } + expect(caught).to.be.an.instanceOf(Error); + expect(caught.message).to.match(/'ably\/callbacks' was the v1 callback API entry point/); + expect(caught.hint).to.be.a('string'); + expect(caught.hint).to.match(/await/); + expect(caught.hint).to.match(/migration-guides\/v2\/lib\.md/); + }); + + it('package.json exports map wires the legacy subpaths to the shim files and their types', function () { + const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); + + expect(pkg.exports['./promises'], "exports['./promises']").to.deep.equal({ + types: './promises.d.ts', + default: './promises.js', + }); + expect(pkg.exports['./callbacks'], "exports['./callbacks']").to.deep.equal({ + types: './callbacks.d.ts', + default: './callbacks.js', + }); + + for (const file of ['promises.js', 'promises.d.ts', 'callbacks.js', 'callbacks.d.ts']) { + expect(fs.existsSync(path.join(repoRoot, file)), file).to.equal(true); + } + }); + + it("'files' array ships the legacy shim files in the published package", function () { + const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); + for (const file of ['promises.js', 'promises.d.ts', 'callbacks.js', 'callbacks.d.ts']) { + expect(pkg.files, `files[] should include ${file}`).to.include(file); + } + }); + }); +});