Skip to content

Commit 76f07aa

Browse files
authored
Allow createFetchMiddleware to take an RPC service (#357)
`@metamask/network-controller` now contains an `RpcService` class. Using this class not only allows us to remove a lot of code from this package, as it incorporates the vast majority of logic contained in `createFetchMiddleware`, including the retry logic, but it also allows us to use the circuit breaker pattern and the exponential backoff pattern to prevent too many retries if the network is unreliable, and then automatically cut over to a failover node when the network truly goes down. Closes #356. Closes #207. Closes #209. Closes #211. Closes #166.
1 parent af6897b commit 76f07aa

File tree

8 files changed

+1203
-63
lines changed

8 files changed

+1203
-63
lines changed

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
66

77
## [Unreleased]
88
### Changed
9-
- Bump `@metamask/utils` to `^11.1.0`
9+
- Bump `@metamask/utils` to `^11.1.0` ([#358](https://github.com/MetaMask/eth-json-rpc-middleware/pull/358))
10+
- Deprecate passing an RPC endpoint to `createFetchMiddleware`, and add a way to pass an RPC service instead ([#357](https://github.com/MetaMask/eth-json-rpc-middleware/pull/357))
11+
- The new, recommended method signature is now `createFetchMiddleware({ rpcService: AbstractRpcService; options?: { originHttpHeaderKey?: string; } })`, where `AbstractRpcService` matches the same interface from `@metamask/network-controller`
12+
- This allows us to support automatic failover to a secondary node when the network goes down
13+
- The existing method signature `createFetchMiddleware({ btoa: typeof btoa; fetch: typeof fetch; rpcUrl: string; originHttpHeaderKey?: string; })` will be removed in a future major version
1014

1115
## [15.1.2]
1216
### Changed

jest.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ module.exports = {
2222
collectCoverage: true,
2323

2424
// An array of glob patterns indicating a set of files for which coverage information should be collected
25-
collectCoverageFrom: ['./src/**/*.ts'],
25+
collectCoverageFrom: ['./src/**/*.ts', '!./src/**/*.test-d.ts'],
2626

2727
// The directory where Jest should output its coverage files
2828
coverageDirectory: 'coverage',

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@
2424
"lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write",
2525
"lint:misc": "prettier '**/*.json' '**/*.md' '!CHANGELOG.md' '**/*.yml' '!.yarnrc.yml' --ignore-path .gitignore --no-error-on-unmatched-pattern",
2626
"prepack": "./scripts/prepack.sh",
27-
"test": "jest",
27+
"test": "jest && yarn build:clean && yarn test:types",
28+
"test:types": "tsd --files 'src/**/*.test-d.ts'",
2829
"test:watch": "jest --watch"
2930
},
3031
"dependencies": {
@@ -48,6 +49,7 @@
4849
"@metamask/eslint-config-jest": "^12.1.0",
4950
"@metamask/eslint-config-nodejs": "^12.1.0",
5051
"@metamask/eslint-config-typescript": "^12.1.0",
52+
"@metamask/network-controller": "22.2.0",
5153
"@types/btoa": "^1.2.3",
5254
"@types/jest": "^27.4.1",
5355
"@types/node": "^18.16",
@@ -68,6 +70,7 @@
6870
"rimraf": "^3.0.2",
6971
"ts-jest": "^27.1.4",
7072
"ts-node": "^10.7.0",
73+
"tsd": "^0.31.2",
7174
"typescript": "~4.8.4"
7275
},
7376
"packageManager": "[email protected]",

src/fetch.test.ts

Lines changed: 218 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1+
import { JsonRpcEngine } from '@metamask/json-rpc-engine';
2+
import type { Json, JsonRpcParams, JsonRpcRequest } from '@metamask/utils';
3+
14
import { createFetchConfigFromReq } from '.';
5+
import { createFetchMiddleware } from './fetch';
6+
import type { AbstractRpcService } from './types';
27

38
/**
49
* Generate a base64-encoded string from a binary string. This should be equivalent to
@@ -12,7 +17,178 @@ function btoa(stringToEncode: string) {
1217
return Buffer.from(stringToEncode).toString('base64');
1318
}
1419

15-
describe('fetch', () => {
20+
describe('createFetchMiddleware', () => {
21+
it('calls the RPC service with the correct request headers and body when no `originHttpHeaderKey` option given', async () => {
22+
const rpcService = buildRpcService();
23+
const requestSpy = jest.spyOn(rpcService, 'request');
24+
const middleware = createFetchMiddleware({
25+
rpcService,
26+
});
27+
28+
const engine = new JsonRpcEngine();
29+
engine.push(middleware);
30+
await engine.handle({
31+
id: 1,
32+
jsonrpc: '2.0',
33+
method: 'eth_chainId',
34+
params: [],
35+
});
36+
37+
expect(requestSpy).toHaveBeenCalledWith(
38+
{
39+
id: 1,
40+
jsonrpc: '2.0',
41+
method: 'eth_chainId',
42+
params: [],
43+
},
44+
{
45+
headers: {},
46+
},
47+
);
48+
});
49+
50+
it('includes the `origin` from the given request in the request headers under the given `originHttpHeaderKey`', async () => {
51+
const rpcService = buildRpcService();
52+
const requestSpy = jest.spyOn(rpcService, 'request');
53+
const middleware = createFetchMiddleware({
54+
rpcService,
55+
options: {
56+
originHttpHeaderKey: 'X-Dapp-Origin',
57+
},
58+
});
59+
60+
const engine = new JsonRpcEngine();
61+
engine.push(middleware);
62+
// Type assertion: This isn't really a proper JSON-RPC request, but we have
63+
// to get `json-rpc-engine` to think it is.
64+
await engine.handle({
65+
id: 1,
66+
jsonrpc: '2.0' as const,
67+
method: 'eth_chainId',
68+
params: [],
69+
origin: 'somedapp.com',
70+
} as JsonRpcRequest);
71+
72+
expect(requestSpy).toHaveBeenCalledWith(
73+
{
74+
id: 1,
75+
jsonrpc: '2.0',
76+
method: 'eth_chainId',
77+
params: [],
78+
},
79+
{
80+
headers: {
81+
'X-Dapp-Origin': 'somedapp.com',
82+
},
83+
},
84+
);
85+
});
86+
87+
describe('if the request to the service returns a successful JSON-RPC response', () => {
88+
it('includes the `result` field from the RPC service in its own response', async () => {
89+
const rpcService = buildRpcService();
90+
jest.spyOn(rpcService, 'request').mockResolvedValue({
91+
id: 1,
92+
jsonrpc: '2.0',
93+
result: 'the result',
94+
});
95+
const middleware = createFetchMiddleware({
96+
rpcService,
97+
});
98+
99+
const engine = new JsonRpcEngine();
100+
engine.push(middleware);
101+
const result = await engine.handle({
102+
id: 1,
103+
jsonrpc: '2.0',
104+
method: 'eth_chainId',
105+
params: [],
106+
});
107+
108+
expect(result).toStrictEqual({
109+
id: 1,
110+
jsonrpc: '2.0',
111+
result: 'the result',
112+
});
113+
});
114+
});
115+
116+
describe('if the request to the service returns a unsuccessful JSON-RPC response', () => {
117+
it('includes the `error` field from the service in a new internal JSON-RPC error', async () => {
118+
const rpcService = buildRpcService();
119+
jest.spyOn(rpcService, 'request').mockResolvedValue({
120+
id: 1,
121+
jsonrpc: '2.0',
122+
error: {
123+
code: -1000,
124+
message: 'oops',
125+
},
126+
});
127+
const middleware = createFetchMiddleware({
128+
rpcService,
129+
});
130+
131+
const engine = new JsonRpcEngine();
132+
engine.push(middleware);
133+
const result = await engine.handle({
134+
id: 1,
135+
jsonrpc: '2.0',
136+
method: 'eth_chainId',
137+
params: [],
138+
});
139+
140+
expect(result).toMatchObject({
141+
id: 1,
142+
jsonrpc: '2.0',
143+
error: {
144+
code: -32603,
145+
message: 'Internal JSON-RPC error.',
146+
stack: expect.stringContaining('Internal JSON-RPC error.'),
147+
data: {
148+
code: -1000,
149+
message: 'oops',
150+
cause: null,
151+
},
152+
},
153+
});
154+
});
155+
});
156+
157+
describe('if the request to the service throws', () => {
158+
it('includes the message and stack of the error in a new JSON-RPC error', async () => {
159+
const rpcService = buildRpcService();
160+
jest.spyOn(rpcService, 'request').mockRejectedValue(new Error('oops'));
161+
const middleware = createFetchMiddleware({
162+
rpcService,
163+
});
164+
165+
const engine = new JsonRpcEngine();
166+
engine.push(middleware);
167+
const result = await engine.handle({
168+
id: 1,
169+
jsonrpc: '2.0',
170+
method: 'eth_chainId',
171+
params: [],
172+
});
173+
174+
expect(result).toMatchObject({
175+
id: 1,
176+
jsonrpc: '2.0',
177+
error: {
178+
code: -32603,
179+
data: {
180+
cause: {
181+
message: 'oops',
182+
stack: expect.stringContaining('Error: oops'),
183+
},
184+
},
185+
},
186+
});
187+
});
188+
});
189+
});
190+
191+
describe('createFetchConfigFromReq', () => {
16192
it('should create a fetch config from a request', async () => {
17193
const req = {
18194
id: 1,
@@ -65,3 +241,44 @@ describe('fetch', () => {
65241
});
66242
});
67243
});
244+
245+
/**
246+
* Constructs a fake RPC service for use as a failover in tests.
247+
*
248+
* @returns The fake failover service.
249+
*/
250+
function buildRpcService(): AbstractRpcService {
251+
return {
252+
async request<Params extends JsonRpcParams, Result extends Json>(
253+
jsonRpcRequest: JsonRpcRequest<Params>,
254+
_fetchOptions?: RequestInit,
255+
) {
256+
return {
257+
id: jsonRpcRequest.id,
258+
jsonrpc: jsonRpcRequest.jsonrpc,
259+
result: 'ok' as Result,
260+
};
261+
},
262+
onRetry() {
263+
return {
264+
dispose() {
265+
// do nothing
266+
},
267+
};
268+
},
269+
onBreak() {
270+
return {
271+
dispose() {
272+
// do nothing
273+
},
274+
};
275+
},
276+
onDegraded() {
277+
return {
278+
dispose() {
279+
// do nothing
280+
},
281+
};
282+
},
283+
};
284+
}

0 commit comments

Comments
 (0)