diff --git a/jest.config.js b/jest.config.js index b5b7788..dabcda1 100644 --- a/jest.config.js +++ b/jest.config.js @@ -45,10 +45,10 @@ module.exports = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 92.64, - functions: 94.44, - lines: 92.85, - statements: 92.85, + branches: 91.89, + functions: 94.59, + lines: 92.42, + statements: 92.42, }, }, diff --git a/src/__fixtures__/errors.ts b/src/__fixtures__/errors.ts index 13cbb76..78e950b 100644 --- a/src/__fixtures__/errors.ts +++ b/src/__fixtures__/errors.ts @@ -1,7 +1,11 @@ import { rpcErrors } from '..'; -export const dummyData = { foo: 'bar' }; export const dummyMessage = 'baz'; +export const dummyData = { foo: 'bar' }; +export const dummyDataWithCause = { + foo: 'bar', + cause: { message: dummyMessage }, +}; export const invalidError0 = 0; export const invalidError1 = ['foo', 'bar', 3]; diff --git a/src/classes.ts b/src/classes.ts index 624dd8f..5bf6426 100644 --- a/src/classes.ts +++ b/src/classes.ts @@ -2,11 +2,11 @@ import type { Json, JsonRpcError as SerializedJsonRpcError, } from '@metamask/utils'; -import { isPlainObject } from '@metamask/utils'; +import { hasProperty, isPlainObject } from '@metamask/utils'; import safeStringify from 'fast-safe-stringify'; import type { OptionalDataWithOptionalCause } from './utils'; -import { serializeCause } from './utils'; +import { dataHasCause, serializeCause } from './utils'; export type { SerializedJsonRpcError }; @@ -19,6 +19,9 @@ export type { SerializedJsonRpcError }; export class JsonRpcError< Data extends OptionalDataWithOptionalCause, > extends Error { + // The `cause` definition can be removed when tsconfig lib and/or target have changed to >=es2022 + public cause?: unknown; + public code: number; public data?: Data; @@ -32,11 +35,23 @@ export class JsonRpcError< throw new Error('"message" must be a non-empty string.'); } - super(message); - this.code = code; + if (dataHasCause(data)) { + // @ts-expect-error - Error class does accept options argument depending on runtime, but types are mapping to oldest supported + super(message, { cause: data.cause }); + + // Browser backwards-compatibility fallback + if (!hasProperty(this, 'cause')) { + Object.assign(this, { cause: data.cause }); + } + } else { + super(message); + } + if (data !== undefined) { this.data = data; } + + this.code = code; } /** diff --git a/src/errors.test.ts b/src/errors.test.ts index 9e6a24f..af3caad 100644 --- a/src/errors.test.ts +++ b/src/errors.test.ts @@ -3,6 +3,8 @@ import { assert, isPlainObject } from '@metamask/utils'; import { rpcErrors, providerErrors, errorCodes } from '.'; import { dummyData, + dummyDataWithCause, + dummyMessage, CUSTOM_ERROR_MESSAGE, SERVER_ERROR_CODE, CUSTOM_ERROR_CODE, @@ -97,6 +99,21 @@ describe('rpcErrors', () => { }, ); + it.each(Object.entries(rpcErrors).filter(([key]) => key !== 'server'))( + '%s propagates data.cause if set', + (key, value) => { + const createError = value as any; + const error = createError({ + message: null, + data: Object.assign({}, dummyDataWithCause), + }); + // @ts-expect-error TypeScript does not like indexing into this with the key + const rpcCode = errorCodes.rpc[key]; + expect(error.message).toBe(getMessageFromCode(rpcCode)); + expect(error.cause.message).toBe(dummyMessage); + }, + ); + it('serializes a cause', () => { const error = rpcErrors.invalidInput({ data: { @@ -156,6 +173,21 @@ describe('providerErrors', () => { }, ); + it.each(Object.entries(providerErrors).filter(([key]) => key !== 'custom'))( + '%s propagates data.cause if set', + (key, value) => { + const createError = value as any; + const error = createError({ + message: null, + data: Object.assign({}, dummyDataWithCause), + }); + // @ts-expect-error TypeScript does not like indexing into this with the key + const providerCode = errorCodes.provider[key]; + expect(error.message).toBe(getMessageFromCode(providerCode)); + expect(error.cause.message).toBe(dummyMessage); + }, + ); + it('custom returns appropriate value', () => { const error = providerErrors.custom({ code: CUSTOM_ERROR_CODE, diff --git a/src/index.ts b/src/index.ts index f27e10d..120afee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,10 @@ export { JsonRpcError, EthereumProviderError } from './classes'; -export { serializeCause, serializeError, getMessageFromCode } from './utils'; +export { + dataHasCause, + serializeCause, + serializeError, + getMessageFromCode, +} from './utils'; export type { DataWithOptionalCause, OptionalDataWithOptionalCause, diff --git a/src/utils.test.ts b/src/utils.test.ts index 7529668..9aefd4a 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -16,7 +16,7 @@ import { dummyMessage, dummyData, } from './__fixtures__'; -import { getMessageFromCode, serializeError } from './utils'; +import { dataHasCause, getMessageFromCode, serializeError } from './utils'; const rpcCodes = errorCodes.rpc; @@ -310,3 +310,23 @@ describe('serializeError', () => { }); }); }); + +describe('dataHasCause', () => { + it('returns false for invalid data types', () => { + [undefined, null, 'hello', 1234].forEach((data) => { + const result = dataHasCause(data); + expect(result).toBe(false); + }); + }); + it('returns false for invalid cause types', () => { + [undefined, null, 'hello', 1234].forEach((cause) => { + const result = dataHasCause({ cause }); + expect(result).toBe(false); + }); + }); + it('returns true when cause is object', () => { + const data = { cause: {} }; + const result = dataHasCause(data); + expect(result).toBe(true); + }); +}); diff --git a/src/utils.ts b/src/utils.ts index e3328a7..de3e061 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -211,3 +211,16 @@ function serializeObject(object: RuntimeObject): Json { {}, ); } + +/** + * Returns true if supplied error data has a usable `cause` property; false otherwise. + * + * @param data - Optional data to validate. + * @returns Whether cause property is present and an object. + */ +export function dataHasCause(data: unknown): data is { + [key: string]: Json | unknown; + cause: object; +} { + return isObject(data) && hasProperty(data, 'cause') && isObject(data.cause); +} diff --git a/tsconfig.json b/tsconfig.json index 56f6531..e5b85d5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "esModuleInterop": true, "exactOptionalPropertyTypes": true, "forceConsistentCasingInFileNames": true, + // Reminder: Remove custom `cause` field from JsonRpcError when enabling es2022 "lib": ["ES2020"], "module": "CommonJS", "moduleResolution": "node",