Skip to content

Commit ab87a55

Browse files
authored
[experimental] A JSON-RPC 2.0 HTTP transport implementation (#1195)
# Summary This is a cross-environment implementation of a basic JSON-RPC 2.0 transport. It implements the interface required by `@solana/rpc-core`, and as such is the last piece needed to be able to make an actual RPC request: # Test Plan ```ts const transport = createJsonRpcTransport({ url: 'https://api.devnet.solana.com' }); const blockHeight = await rpc.getBlockHeight(transport, { commitment: 'confirmed' }); console.log('blockHeight', blockHeight); ```
1 parent 1c92591 commit ab87a55

File tree

12 files changed

+241
-2
lines changed

12 files changed

+241
-2
lines changed

packages/rpc-transport/package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
"@swc/core": "^1.3.18",
6262
"@swc/jest": "^0.2.23",
6363
"@types/jest": "^29.2.3",
64+
"@types/node-fetch": "2",
6465
"@typescript-eslint/eslint-plugin": "^5.43.0",
6566
"@typescript-eslint/parser": "^5.43.0",
6667
"agadoo": "^2.0.0",
@@ -71,6 +72,7 @@
7172
"eslint-plugin-sort-keys-fix": "^1.1.2",
7273
"jest": "^29.3.1",
7374
"jest-environment-jsdom": "^29.3.1",
75+
"jest-fetch-mock": "^3.0.3",
7476
"jest-runner-eslint": "^1.1.0",
7577
"jest-runner-prettier": "^1.0.0",
7678
"postcss": "^8.4.12",
@@ -89,5 +91,9 @@
8991
"path": "./dist/index*.js"
9092
}
9193
]
94+
},
95+
"dependencies": {
96+
"@solana/fetch-impl-browser": "workspace:*",
97+
"node-fetch": "^2.6.7"
9298
}
9399
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { makeHttpRequest } from '../http-request';
2+
import { SolanaHttpError } from '../http-request-errors';
3+
4+
describe('makeHttpRequest', () => {
5+
describe('when the endpoint returns a non-200 status code', () => {
6+
beforeEach(() => {
7+
fetchMock.once('', { status: 404, statusText: 'We looked everywhere' });
8+
});
9+
it('throws HTTP errors', async () => {
10+
expect.assertions(3);
11+
const requestPromise = makeHttpRequest({ payload: 123, url: 'fake://url' });
12+
await expect(requestPromise).rejects.toThrow(SolanaHttpError);
13+
await expect(requestPromise).rejects.toThrow(/We looked everywhere/);
14+
await expect(requestPromise).rejects.toMatchObject({ statusCode: 404 });
15+
});
16+
});
17+
describe('when the transport fatals', () => {
18+
beforeEach(() => {
19+
fetchMock.mockReject(new TypeError('Failed to fetch'));
20+
});
21+
it('passes the exception through', async () => {
22+
expect.assertions(1);
23+
await expect(makeHttpRequest({ payload: 123, url: 'fake://url' })).rejects.toThrow(
24+
new TypeError('Failed to fetch')
25+
);
26+
});
27+
});
28+
describe('when the endpoint returns a well-formed JSON response', () => {
29+
beforeEach(() => {
30+
fetchMock.once(JSON.stringify({ ok: true }));
31+
});
32+
it('calls fetch with the specified URL', () => {
33+
makeHttpRequest({ payload: 123, url: 'fake://url' });
34+
expect(fetchMock).toHaveBeenCalledWith('fake://url', expect.anything());
35+
});
36+
it('sets the `body` to a stringfied version of the payload', () => {
37+
makeHttpRequest({ payload: { ok: true }, url: 'fake://url' });
38+
expect(fetchMock).toHaveBeenCalledWith(
39+
expect.anything(),
40+
expect.objectContaining({
41+
body: JSON.stringify({ ok: true }),
42+
})
43+
);
44+
});
45+
it('sets the content type header to `application/json`', () => {
46+
makeHttpRequest({ payload: 123, url: 'fake://url' });
47+
expect(fetchMock).toHaveBeenCalledWith(
48+
expect.anything(),
49+
expect.objectContaining({
50+
headers: expect.objectContaining({
51+
'Content-type': 'application/json',
52+
}),
53+
})
54+
);
55+
});
56+
it('sets the `method` to `POST`', () => {
57+
makeHttpRequest({ payload: 123, url: 'fake://url' });
58+
expect(fetchMock).toHaveBeenCalledWith(
59+
expect.anything(),
60+
expect.objectContaining({
61+
method: 'POST',
62+
})
63+
);
64+
});
65+
});
66+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { createJsonRpcMessage } from '../json-rpc-message';
2+
3+
describe('createJsonRpcMessage', () => {
4+
it('auto-increments ids with each new message', () => {
5+
const { id: firstId } = createJsonRpcMessage('foo', 'bar');
6+
const { id: secondId } = createJsonRpcMessage('foo', 'bar');
7+
expect(secondId - firstId).toBe(1);
8+
});
9+
it('returns a well-formed JSON-RPC 2.0 message', () => {
10+
const params = [1, 2, 3];
11+
expect(createJsonRpcMessage('someMethod', params)).toStrictEqual({
12+
id: expect.any(Number),
13+
jsonrpc: '2.0',
14+
method: 'someMethod',
15+
params,
16+
});
17+
});
18+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { IJsonRpcTransport } from '..';
2+
import { SolanaJsonRpcError } from '../json-rpc-errors';
3+
import { createJsonRpcTransport } from '../json-rpc-transport';
4+
5+
import fetchMock from 'jest-fetch-mock';
6+
7+
describe('JSON-RPC 2.0 transport', () => {
8+
let transport: IJsonRpcTransport;
9+
beforeEach(() => {
10+
transport = createJsonRpcTransport({ url: 'fake://url' });
11+
});
12+
it('returns results from a JSON-RPC 2.0 endpoint', async () => {
13+
expect.assertions(1);
14+
fetchMock.once(JSON.stringify({ result: 123 }));
15+
const result = await transport.send('someMethod', undefined);
16+
expect(result).toBe(123);
17+
});
18+
it('throws errors from a JSON-RPC 2.0 endpoint', async () => {
19+
expect.assertions(3);
20+
fetchMock.once(JSON.stringify({ error: { code: 123, data: 'abc', message: 'o no' } }));
21+
const sendPromise = transport.send('someMethod', undefined);
22+
await expect(sendPromise).rejects.toThrow(SolanaJsonRpcError);
23+
await expect(sendPromise).rejects.toThrow(/o no/);
24+
await expect(sendPromise).rejects.toMatchObject({ code: 123, data: 'abc' });
25+
});
26+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
type SolanaHttpErrorDetails = Readonly<{
2+
statusCode: number;
3+
message: string;
4+
}>;
5+
6+
export class SolanaHttpError extends Error {
7+
readonly statusCode: number;
8+
constructor(details: SolanaHttpErrorDetails) {
9+
super(`HTTP error (${details.statusCode}): ${details.message}`);
10+
Error.captureStackTrace(this, this.constructor);
11+
this.statusCode = details.statusCode;
12+
}
13+
get name() {
14+
return 'SolanaHttpError';
15+
}
16+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import fetchImplBrowser from '@solana/fetch-impl-browser';
2+
import { SolanaHttpError } from './http-request-errors';
3+
4+
import fetchImplNode from 'node-fetch';
5+
6+
type Config = Readonly<{
7+
payload: unknown;
8+
url: string;
9+
}>;
10+
11+
export async function makeHttpRequest<TResponse>({ payload, url }: Config): Promise<TResponse> {
12+
const requestInfo = {
13+
body: JSON.stringify(payload),
14+
headers: {
15+
'Content-type': 'application/json',
16+
},
17+
method: 'POST',
18+
};
19+
let response;
20+
if (__BROWSER__ || __REACTNATIVE__) {
21+
response = await fetchImplBrowser(url, requestInfo);
22+
} else {
23+
response = await fetchImplNode(url, requestInfo);
24+
}
25+
if (!response.ok) {
26+
throw new SolanaHttpError({
27+
message: response.statusText,
28+
statusCode: response.status,
29+
});
30+
}
31+
return await response.json();
32+
}
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { createJsonRpcTransport } from './json-rpc-transport';
2+
13
export interface IJsonRpcTransport {
24
send<TParams, TResponse>(method: string, params: TParams): Promise<TResponse>;
35
}
46

5-
export {};
7+
export { createJsonRpcTransport };
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
type SolanaJsonRpcErrorDetails = Readonly<{
2+
code: number;
3+
data?: unknown;
4+
message: string;
5+
}>;
6+
7+
export class SolanaJsonRpcError extends Error {
8+
readonly code: number;
9+
readonly data: unknown;
10+
constructor(details: SolanaJsonRpcErrorDetails) {
11+
super(`JSON-RPC 2.0 error (${details.code}): ${details.message}`);
12+
Error.captureStackTrace(this, this.constructor);
13+
this.code = details.code;
14+
this.data = details.data;
15+
}
16+
get name() {
17+
return 'SolanaJsonRpcError';
18+
}
19+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
let _nextMessageId = 0;
2+
function getNextMessageId() {
3+
const id = _nextMessageId;
4+
_nextMessageId = (_nextMessageId + 1) % Number.MAX_SAFE_INTEGER;
5+
return id;
6+
}
7+
8+
export function createJsonRpcMessage<TParams>(method: string, params: TParams) {
9+
return {
10+
id: getNextMessageId(),
11+
jsonrpc: '2.0',
12+
method,
13+
params,
14+
};
15+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { IJsonRpcTransport } from '.';
2+
import { makeHttpRequest } from './http-request';
3+
import { SolanaJsonRpcError } from './json-rpc-errors';
4+
import { createJsonRpcMessage } from './json-rpc-message';
5+
6+
type Config = Readonly<{
7+
url: string;
8+
}>;
9+
10+
type JsonRpcResponse<TResponse> = Readonly<
11+
{ result: TResponse } | { error: { code: number; message: string; data?: unknown } }
12+
>;
13+
14+
export function createJsonRpcTransport({ url }: Config): IJsonRpcTransport {
15+
return {
16+
async send<TParams, TResponse>(method: string, params: TParams): Promise<TResponse> {
17+
const jsonRpcMessage = createJsonRpcMessage(method, params);
18+
const response = await makeHttpRequest<JsonRpcResponse<TResponse>>({
19+
payload: jsonRpcMessage,
20+
url,
21+
});
22+
if ('error' in response) {
23+
throw new SolanaJsonRpcError(response.error);
24+
} else {
25+
return response.result as TResponse;
26+
}
27+
},
28+
};
29+
}

0 commit comments

Comments
 (0)