diff --git a/.changeset/stupid-tomatoes-reflect.md b/.changeset/stupid-tomatoes-reflect.md new file mode 100644 index 000000000000..15df294204a7 --- /dev/null +++ b/.changeset/stupid-tomatoes-reflect.md @@ -0,0 +1,8 @@ +--- +'@solana/rpc-transport-http': patch +'@solana/rpc-spec': patch +'@solana/rpc-api': patch +'@solana/rpc': patch +--- + +Make `RpcTransport` return new `RpcReponse` type instead of parsed JSON data diff --git a/packages/rpc-api/src/__tests__/get-health-test.ts b/packages/rpc-api/src/__tests__/get-health-test.ts index e93b3614f051..68ebc81c4896 100644 --- a/packages/rpc-api/src/__tests__/get-health-test.ts +++ b/packages/rpc-api/src/__tests__/get-health-test.ts @@ -1,9 +1,16 @@ import { SOLANA_ERROR__JSON_RPC__SERVER_ERROR_NODE_UNHEALTHY, SolanaError } from '@solana/errors'; -import { createRpc, type Rpc } from '@solana/rpc-spec'; +import { createRpc, type Rpc, type RpcResponse } from '@solana/rpc-spec'; import { createSolanaRpcApi, GetHealthApi } from '../index'; import { createLocalhostSolanaRpc } from './__setup__'; +function createMockResponse(jsonResponse: T): RpcResponse { + return { + json: () => Promise.resolve(jsonResponse), + text: () => Promise.resolve(JSON.stringify(jsonResponse)), + }; +} + describe('getHealth', () => { describe('when the node is healthy', () => { let rpc: Rpc; @@ -29,7 +36,7 @@ describe('getHealth', () => { beforeEach(() => { rpc = createRpc({ api: createSolanaRpcApi(), - transport: jest.fn().mockResolvedValue({ error: errorObject }), + transport: jest.fn().mockResolvedValue(createMockResponse({ error: errorObject })), }); }); it('returns an error message', async () => { diff --git a/packages/rpc-spec/src/__tests__/rpc-test.ts b/packages/rpc-spec/src/__tests__/rpc-test.ts index 4724285598cc..7ab23f4338d8 100644 --- a/packages/rpc-spec/src/__tests__/rpc-test.ts +++ b/packages/rpc-spec/src/__tests__/rpc-test.ts @@ -3,12 +3,20 @@ import { createRpcMessage } from '@solana/rpc-spec-types'; import { createRpc, Rpc } from '../rpc'; import { RpcApi } from '../rpc-api'; import { RpcApiRequestPlan } from '../rpc-request'; +import { RpcResponse } from '../rpc-shared'; import { RpcTransport } from '../rpc-transport'; interface TestRpcMethods { someMethod(...args: unknown[]): unknown; } +function createMockResponse(jsonResponse: T): RpcResponse { + return { + json: () => Promise.resolve(jsonResponse), + text: () => Promise.resolve(JSON.stringify(jsonResponse)), + }; +} + describe('JSON-RPC 2.0', () => { let makeHttpRequest: RpcTransport; let rpc: Rpc; @@ -34,7 +42,7 @@ describe('JSON-RPC 2.0', () => { }); it('returns results from the transport', async () => { expect.assertions(1); - (makeHttpRequest as jest.Mock).mockResolvedValueOnce(123); + (makeHttpRequest as jest.Mock).mockResolvedValueOnce(createMockResponse(123)); const result = await rpc.someMethod().send(); expect(result).toBe(123); }); @@ -90,13 +98,13 @@ describe('JSON-RPC 2.0', () => { }); it('calls the response transformer with the response from the JSON-RPC 2.0 endpoint', async () => { expect.assertions(1); - (makeHttpRequest as jest.Mock).mockResolvedValueOnce(123); + (makeHttpRequest as jest.Mock).mockResolvedValueOnce(createMockResponse(123)); await rpc.someMethod().send(); expect(responseTransformer).toHaveBeenCalledWith(123, 'someMethod'); }); it('returns the processed response', async () => { expect.assertions(1); - (makeHttpRequest as jest.Mock).mockResolvedValueOnce(123); + (makeHttpRequest as jest.Mock).mockResolvedValueOnce(createMockResponse(123)); const result = await rpc.someMethod().send(); expect(result).toBe('123 processed response'); }); diff --git a/packages/rpc-spec/src/rpc-transport.ts b/packages/rpc-spec/src/rpc-transport.ts index 6e5ec798591b..14a068efa86e 100644 --- a/packages/rpc-spec/src/rpc-transport.ts +++ b/packages/rpc-spec/src/rpc-transport.ts @@ -1,8 +1,10 @@ -type RpcTransportConfig = Readonly<{ +import { RpcResponse } from './rpc-shared'; + +type RpcTransportRequest = Readonly<{ payload: unknown; signal?: AbortSignal; }>; -export interface RpcTransport { - (config: RpcTransportConfig): Promise; -} +export type RpcTransport = { + (request: RpcTransportRequest): Promise>; +}; diff --git a/packages/rpc-spec/src/rpc.ts b/packages/rpc-spec/src/rpc.ts index b428b08bc49e..a2b0de523844 100644 --- a/packages/rpc-spec/src/rpc.ts +++ b/packages/rpc-spec/src/rpc.ts @@ -3,7 +3,6 @@ import { createRpcMessage, Flatten, OverloadImplementations, - RpcResponseData, UnionToIntersection, } from '@solana/rpc-spec-types'; @@ -68,12 +67,12 @@ function createPendingRpcRequest { const { methodName, params, responseTransformer } = pendingRequest; - const payload = createRpcMessage(methodName, params); - const response = await rpcConfig.transport>({ - payload, + const response = await rpcConfig.transport({ + payload: createRpcMessage(methodName, params), signal: options?.abortSignal, }); - return (responseTransformer ? responseTransformer(response, methodName) : response) as TResponse; + const responseData = await response.json(); + return responseTransformer ? responseTransformer(responseData, methodName) : responseData; }, }; } diff --git a/packages/rpc-transport-http/README.md b/packages/rpc-transport-http/README.md index a46adbc48452..58aecb1ac999 100644 --- a/packages/rpc-transport-http/README.md +++ b/packages/rpc-transport-http/README.md @@ -29,6 +29,7 @@ const transport = createHttpTransport({ url: 'https://api.mainnet-beta.solana.co const response = await transport({ payload: { id: 1, jsonrpc: '2.0', method: 'getSlot' }, }); +const data = await response.json(); ``` #### Config @@ -67,16 +68,17 @@ const transport = createHttpTransport({ }); let id = 0; const balances = await Promise.allSettled( - accounts.map(account => - transport({ + accounts.map(async account => { + const response = await transport({ payload: { id: ++id, jsonrpc: '2.0', method: 'getBalance', params: [account], }, - }), - ), + }); + return await response.json(); + }), ); ``` @@ -109,7 +111,7 @@ Using this core transport, you can implement specialized functionality for lever Here’s an example of how someone might implement a “round robin” approach to distribute requests to multiple transports: ```ts -import { RpcTransport } from '@solana/rpc-spec'; +import { RpcResponse, RpcTransport } from '@solana/rpc-spec'; import { createHttpTransport } from '@solana/rpc-transport-http'; // Create a transport for each RPC server @@ -121,7 +123,7 @@ const transports = [ // Create a wrapper transport that distributes requests to them let nextTransport = 0; -async function roundRobinTransport(...args: Parameters): Promise { +async function roundRobinTransport(...args: Parameters): Promise> { const transport = transports[nextTransport]; nextTransport = (nextTransport + 1) % transports.length; return await transport(...args); @@ -135,7 +137,7 @@ Another example of a possible customization for a transport is to shard requests Perhaps your application needs to make a large number of requests, or needs to fan request for different methods out to different servers. Here’s an example of an implementation that does the latter: ```ts -import { RpcTransport } from '@solana/rpc-spec'; +import { RpcResponse, RpcTransport } from '@solana/rpc-spec'; import { createHttpTransport } from '@solana/rpc-transport-http'; // Create multiple transports @@ -160,7 +162,7 @@ function selectShard(method: string): RpcTransport { } } -async function shardingTransport(...args: Parameters): Promise { +async function shardingTransport(...args: Parameters): Promise> { const payload = args[0].payload as { method: string }; const selectedTransport = selectShard(payload.method); return await selectedTransport(...args); @@ -172,7 +174,7 @@ async function shardingTransport(...args: Parameters): The transport library can also be used to implement custom retry logic on any request: ```ts -import { RpcTransport } from '@solana/rpc-spec'; +import { RpcResponse, RpcTransport } from '@solana/rpc-spec'; import { createHttpTransport } from '@solana/rpc-transport-http'; // Set the maximum number of attempts to retry a request @@ -193,7 +195,7 @@ function calculateRetryDelay(attempt: number): number { } // A retrying transport that will retry up to `MAX_ATTEMPTS` times before failing -async function retryingTransport(...args: Parameters): Promise { +async function retryingTransport(...args: Parameters): Promise> { let requestError; for (let attempts = 0; attempts < MAX_ATTEMPTS; attempts++) { try { @@ -216,7 +218,7 @@ async function retryingTransport(...args: Parameters): Here’s an example of some failover logic integrated into a transport: ```ts -import { RpcTransport } from '@solana/rpc-spec'; +import { RpcResponse, RpcTransport } from '@solana/rpc-spec'; import { createHttpTransport } from '@solana/rpc-transport-http'; // Create a transport for each RPC server @@ -227,7 +229,7 @@ const transports = [ ]; // A failover transport that will try each transport in order until one succeeds before failing -async function failoverTransport(...args: Parameters): Promise { +async function failoverTransport(...args: Parameters): Promise> { let requestError; for (const transport of transports) { diff --git a/packages/rpc-transport-http/src/__tests__/http-transport-abort-test.ts b/packages/rpc-transport-http/src/__tests__/http-transport-abort-test.ts index 07779f87e636..0d091f1d23ec 100644 --- a/packages/rpc-transport-http/src/__tests__/http-transport-abort-test.ts +++ b/packages/rpc-transport-http/src/__tests__/http-transport-abort-test.ts @@ -71,12 +71,13 @@ describe('createHttpTransport and `AbortSignal`', () => { it('resolves with the response', async () => { expect.assertions(1); jest.mocked(fetchSpy).mockResolvedValueOnce({ - json: () => ({ ok: true }), + json: () => Promise.resolve({ ok: true }), ok: true, } as unknown as Response); const sendPromise = makeHttpRequest({ payload: 123, signal: abortSignal }); abortController.abort('I got bored waiting'); - await expect(sendPromise).resolves.toMatchObject({ + const response = await sendPromise; + await expect(response.json()).resolves.toMatchObject({ ok: true, }); }); diff --git a/packages/rpc-transport-http/src/http-transport.ts b/packages/rpc-transport-http/src/http-transport.ts index 68602c03fb1b..daafb4949baa 100644 --- a/packages/rpc-transport-http/src/http-transport.ts +++ b/packages/rpc-transport-http/src/http-transport.ts @@ -1,5 +1,5 @@ import { SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR, SolanaError } from '@solana/errors'; -import { RpcTransport } from '@solana/rpc-spec'; +import { RpcResponse, RpcTransport } from '@solana/rpc-spec'; import type Dispatcher from 'undici-types/dispatcher'; import { @@ -44,7 +44,7 @@ export function createHttpTransport(config: Config): RpcTransport { return async function makeHttpRequest({ payload, signal, - }: Parameters[0]): Promise { + }: Parameters[0]): Promise> { const body = JSON.stringify(payload); const requestInfo = { ...dispatcherConfig, @@ -66,6 +66,9 @@ export function createHttpTransport(config: Config): RpcTransport { statusCode: response.status, }); } - return (await response.json()) as TResponse; + return Object.freeze({ + json: () => response.json(), + text: () => response.text(), + }); }; } diff --git a/packages/rpc/src/__tests__/rpc-request-coalescer-test.ts b/packages/rpc/src/__tests__/rpc-request-coalescer-test.ts index f852d54c505c..38b2ab4c9eb8 100644 --- a/packages/rpc/src/__tests__/rpc-request-coalescer-test.ts +++ b/packages/rpc/src/__tests__/rpc-request-coalescer-test.ts @@ -1,7 +1,14 @@ -import type { RpcTransport } from '@solana/rpc-spec'; +import type { RpcResponse, RpcTransport } from '@solana/rpc-spec'; import { getRpcTransportWithRequestCoalescing } from '../rpc-request-coalescer'; +function createMockResponse(jsonResponse: T): RpcResponse { + return { + json: () => Promise.resolve(jsonResponse), + text: () => Promise.resolve(JSON.stringify(jsonResponse)), + }; +} + describe('RPC request coalescer', () => { let coalescedTransport: RpcTransport; let hashFn: jest.MockedFunction<() => string | undefined>; @@ -30,7 +37,7 @@ describe('RPC request coalescer', () => { }); it('multiple requests in the same tick receive the same response', async () => { expect.assertions(2); - const mockResponse = { response: 'ok' }; + const mockResponse = createMockResponse({ response: 'ok' }); mockTransport.mockResolvedValueOnce(mockResponse); const responsePromiseA = coalescedTransport({ payload: null }); const responsePromiseB = coalescedTransport({ payload: null }); @@ -41,8 +48,8 @@ describe('RPC request coalescer', () => { }); it('multiple requests in different ticks receive different responses', async () => { expect.assertions(2); - const mockResponseA = { response: 'okA' }; - const mockResponseB = { response: 'okB' }; + const mockResponseA = createMockResponse({ response: 'okA' }); + const mockResponseB = createMockResponse({ response: 'okB' }); mockTransport.mockResolvedValueOnce(mockResponseA); mockTransport.mockResolvedValueOnce(mockResponseB); const responsePromiseA = coalescedTransport({ payload: null }); @@ -100,7 +107,7 @@ describe('RPC request coalescer', () => { let abortControllerB: AbortController; let responsePromiseA: ReturnType; let responsePromiseB: ReturnType; - let transportResponsePromise: (value: unknown) => void; + let transportResponsePromise: (value: RpcResponse) => void; beforeEach(() => { abortControllerA = new AbortController(); abortControllerB = new AbortController(); @@ -157,7 +164,7 @@ describe('RPC request coalescer', () => { it('delivers responses to all but the aborted requests', async () => { expect.assertions(2); abortControllerA.abort('o no A'); - const mockResponse = { response: 'ok' }; + const mockResponse = createMockResponse({ response: 'ok' }); transportResponsePromise(mockResponse); await Promise.all([ expect(responsePromiseA).rejects.toBe('o no A'), @@ -192,8 +199,8 @@ describe('RPC request coalescer', () => { }); it('multiple requests in the same tick receive different responses', async () => { expect.assertions(2); - const mockResponseA = { response: 'okA' }; - const mockResponseB = { response: 'okB' }; + const mockResponseA = createMockResponse({ response: 'okA' }); + const mockResponseB = createMockResponse({ response: 'okB' }); mockTransport.mockResolvedValueOnce(mockResponseA); mockTransport.mockResolvedValueOnce(mockResponseB); const responsePromiseA = coalescedTransport({ payload: null }); diff --git a/packages/rpc/src/rpc-request-coalescer.ts b/packages/rpc/src/rpc-request-coalescer.ts index eb05e7bebe79..156fc0e2114f 100644 --- a/packages/rpc/src/rpc-request-coalescer.ts +++ b/packages/rpc/src/rpc-request-coalescer.ts @@ -1,9 +1,9 @@ -import type { RpcTransport } from '@solana/rpc-spec'; +import type { RpcResponse, RpcTransport } from '@solana/rpc-spec'; type CoalescedRequest = { readonly abortController: AbortController; numConsumers: number; - readonly responsePromise: Promise; + readonly responsePromise: Promise; }; type GetDeduplicationKeyFn = (payload: unknown) => string | undefined; @@ -30,11 +30,13 @@ export function getRpcTransportWithRequestCoalescing | undefined; - return async function makeCoalescedHttpRequest(config: Parameters[0]): Promise { - const { payload, signal } = config; + return async function makeCoalescedHttpRequest( + request: Parameters[0], + ): Promise> { + const { payload, signal } = request; const deduplicationKey = getDeduplicationKey(payload); if (deduplicationKey === undefined) { - return await transport(config); + return await transport(request); } if (!coalescedRequestsByDeduplicationKey) { Promise.resolve().then(() => { @@ -47,7 +49,7 @@ export function getRpcTransportWithRequestCoalescing { try { return await transport({ - ...config, + ...request, signal: abortController.signal, }); } catch (e) { @@ -69,8 +71,8 @@ export function getRpcTransportWithRequestCoalescing; - return await new Promise((resolve, reject) => { + const responsePromise = coalescedRequest.responsePromise as Promise>; + return await new Promise>((resolve, reject) => { const handleAbort = (e: AbortSignalEventMap['abort']) => { signal.removeEventListener('abort', handleAbort); coalescedRequest.numConsumers -= 1; @@ -91,7 +93,7 @@ export function getRpcTransportWithRequestCoalescing; } } as TTransport; }