Skip to content

Commit 1b44940

Browse files
authored
feat: propagate data.cause as cause in JsonRpcError constructor (#140)
* test: add cases for cause propagation - break out util function dataHasCause with test - use native causes when available - adding explicit cause field because we can not yet use es2022
1 parent 1c1ffa9 commit 1b44940

File tree

8 files changed

+101
-11
lines changed

8 files changed

+101
-11
lines changed

jest.config.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,10 @@ module.exports = {
4545
// An object that configures minimum threshold enforcement for coverage results
4646
coverageThreshold: {
4747
global: {
48-
branches: 92.64,
49-
functions: 94.44,
50-
lines: 92.85,
51-
statements: 92.85,
48+
branches: 91.89,
49+
functions: 94.59,
50+
lines: 92.42,
51+
statements: 92.42,
5252
},
5353
},
5454

src/__fixtures__/errors.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { rpcErrors } from '..';
22

3-
export const dummyData = { foo: 'bar' };
43
export const dummyMessage = 'baz';
4+
export const dummyData = { foo: 'bar' };
5+
export const dummyDataWithCause = {
6+
foo: 'bar',
7+
cause: { message: dummyMessage },
8+
};
59

610
export const invalidError0 = 0;
711
export const invalidError1 = ['foo', 'bar', 3];

src/classes.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ import type {
22
Json,
33
JsonRpcError as SerializedJsonRpcError,
44
} from '@metamask/utils';
5-
import { isPlainObject } from '@metamask/utils';
5+
import { hasProperty, isPlainObject } from '@metamask/utils';
66
import safeStringify from 'fast-safe-stringify';
77

88
import type { OptionalDataWithOptionalCause } from './utils';
9-
import { serializeCause } from './utils';
9+
import { dataHasCause, serializeCause } from './utils';
1010

1111
export type { SerializedJsonRpcError };
1212

@@ -19,6 +19,9 @@ export type { SerializedJsonRpcError };
1919
export class JsonRpcError<
2020
Data extends OptionalDataWithOptionalCause,
2121
> extends Error {
22+
// The `cause` definition can be removed when tsconfig lib and/or target have changed to >=es2022
23+
public cause?: unknown;
24+
2225
public code: number;
2326

2427
public data?: Data;
@@ -32,11 +35,23 @@ export class JsonRpcError<
3235
throw new Error('"message" must be a non-empty string.');
3336
}
3437

35-
super(message);
36-
this.code = code;
38+
if (dataHasCause(data)) {
39+
// @ts-expect-error - Error class does accept options argument depending on runtime, but types are mapping to oldest supported
40+
super(message, { cause: data.cause });
41+
42+
// Browser backwards-compatibility fallback
43+
if (!hasProperty(this, 'cause')) {
44+
Object.assign(this, { cause: data.cause });
45+
}
46+
} else {
47+
super(message);
48+
}
49+
3750
if (data !== undefined) {
3851
this.data = data;
3952
}
53+
54+
this.code = code;
4055
}
4156

4257
/**

src/errors.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { assert, isPlainObject } from '@metamask/utils';
33
import { rpcErrors, providerErrors, errorCodes } from '.';
44
import {
55
dummyData,
6+
dummyDataWithCause,
7+
dummyMessage,
68
CUSTOM_ERROR_MESSAGE,
79
SERVER_ERROR_CODE,
810
CUSTOM_ERROR_CODE,
@@ -97,6 +99,21 @@ describe('rpcErrors', () => {
9799
},
98100
);
99101

102+
it.each(Object.entries(rpcErrors).filter(([key]) => key !== 'server'))(
103+
'%s propagates data.cause if set',
104+
(key, value) => {
105+
const createError = value as any;
106+
const error = createError({
107+
message: null,
108+
data: Object.assign({}, dummyDataWithCause),
109+
});
110+
// @ts-expect-error TypeScript does not like indexing into this with the key
111+
const rpcCode = errorCodes.rpc[key];
112+
expect(error.message).toBe(getMessageFromCode(rpcCode));
113+
expect(error.cause.message).toBe(dummyMessage);
114+
},
115+
);
116+
100117
it('serializes a cause', () => {
101118
const error = rpcErrors.invalidInput({
102119
data: {
@@ -156,6 +173,21 @@ describe('providerErrors', () => {
156173
},
157174
);
158175

176+
it.each(Object.entries(providerErrors).filter(([key]) => key !== 'custom'))(
177+
'%s propagates data.cause if set',
178+
(key, value) => {
179+
const createError = value as any;
180+
const error = createError({
181+
message: null,
182+
data: Object.assign({}, dummyDataWithCause),
183+
});
184+
// @ts-expect-error TypeScript does not like indexing into this with the key
185+
const providerCode = errorCodes.provider[key];
186+
expect(error.message).toBe(getMessageFromCode(providerCode));
187+
expect(error.cause.message).toBe(dummyMessage);
188+
},
189+
);
190+
159191
it('custom returns appropriate value', () => {
160192
const error = providerErrors.custom({
161193
code: CUSTOM_ERROR_CODE,

src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
export { JsonRpcError, EthereumProviderError } from './classes';
2-
export { serializeCause, serializeError, getMessageFromCode } from './utils';
2+
export {
3+
dataHasCause,
4+
serializeCause,
5+
serializeError,
6+
getMessageFromCode,
7+
} from './utils';
38
export type {
49
DataWithOptionalCause,
510
OptionalDataWithOptionalCause,

src/utils.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
dummyMessage,
1717
dummyData,
1818
} from './__fixtures__';
19-
import { getMessageFromCode, serializeError } from './utils';
19+
import { dataHasCause, getMessageFromCode, serializeError } from './utils';
2020

2121
const rpcCodes = errorCodes.rpc;
2222

@@ -310,3 +310,23 @@ describe('serializeError', () => {
310310
});
311311
});
312312
});
313+
314+
describe('dataHasCause', () => {
315+
it('returns false for invalid data types', () => {
316+
[undefined, null, 'hello', 1234].forEach((data) => {
317+
const result = dataHasCause(data);
318+
expect(result).toBe(false);
319+
});
320+
});
321+
it('returns false for invalid cause types', () => {
322+
[undefined, null, 'hello', 1234].forEach((cause) => {
323+
const result = dataHasCause({ cause });
324+
expect(result).toBe(false);
325+
});
326+
});
327+
it('returns true when cause is object', () => {
328+
const data = { cause: {} };
329+
const result = dataHasCause(data);
330+
expect(result).toBe(true);
331+
});
332+
});

src/utils.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,3 +211,16 @@ function serializeObject(object: RuntimeObject): Json {
211211
{},
212212
);
213213
}
214+
215+
/**
216+
* Returns true if supplied error data has a usable `cause` property; false otherwise.
217+
*
218+
* @param data - Optional data to validate.
219+
* @returns Whether cause property is present and an object.
220+
*/
221+
export function dataHasCause(data: unknown): data is {
222+
[key: string]: Json | unknown;
223+
cause: object;
224+
} {
225+
return isObject(data) && hasProperty(data, 'cause') && isObject(data.cause);
226+
}

tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"esModuleInterop": true,
44
"exactOptionalPropertyTypes": true,
55
"forceConsistentCasingInFileNames": true,
6+
// Reminder: Remove custom `cause` field from JsonRpcError when enabling es2022
67
"lib": ["ES2020"],
78
"module": "CommonJS",
89
"moduleResolution": "node",

0 commit comments

Comments
 (0)