diff --git a/.changeset/orange-yaks-clean.md b/.changeset/orange-yaks-clean.md new file mode 100644 index 000000000000..db07ac10ef52 --- /dev/null +++ b/.changeset/orange-yaks-clean.md @@ -0,0 +1,7 @@ +--- +'@solana/rpc-transport-http': patch +'@solana/rpc-spec': patch +'@solana/rpc': patch +--- + +Make RPC transports responsible for preparing RPC payloads from requests diff --git a/packages/rpc-spec/src/__tests__/rpc-test.ts b/packages/rpc-spec/src/__tests__/rpc-test.ts index b56042e461b4..76acc736cc5d 100644 --- a/packages/rpc-spec/src/__tests__/rpc-test.ts +++ b/packages/rpc-spec/src/__tests__/rpc-test.ts @@ -1,5 +1,3 @@ -import { createRpcMessage } from '@solana/rpc-spec-types'; - import { createRpc, Rpc } from '../rpc'; import { RpcApi, RpcApiRequestPlan } from '../rpc-api'; import { RpcTransport } from '../rpc-transport'; @@ -28,7 +26,8 @@ describe('JSON-RPC 2.0', () => { it('sends a request to the transport', () => { rpc.someMethod(123).send(); expect(makeHttpRequest).toHaveBeenCalledWith({ - payload: { ...createRpcMessage('someMethod', [123]), id: expect.any(Number) }, + methodName: 'someMethod', + params: [123], }); }); it('returns results from the transport', async () => { @@ -59,13 +58,11 @@ describe('JSON-RPC 2.0', () => { transport: makeHttpRequest, }); }); - it('converts the returned request to a JSON-RPC 2.0 message and sends it to the transport', () => { + it('passes the method name and parameters to the transport', () => { rpc.someMethod(123).send(); expect(makeHttpRequest).toHaveBeenCalledWith({ - payload: { - ...createRpcMessage('someMethodAugmented', [123, 'augmented', 'params']), - id: expect.any(Number), - }, + methodName: 'someMethodAugmented', + params: [123, 'augmented', 'params'], }); }); }); diff --git a/packages/rpc-spec/src/rpc-transport.ts b/packages/rpc-spec/src/rpc-transport.ts index 14a068efa86e..6dea637db628 100644 --- a/packages/rpc-spec/src/rpc-transport.ts +++ b/packages/rpc-spec/src/rpc-transport.ts @@ -1,9 +1,8 @@ -import { RpcResponse } from './rpc-shared'; +import { RpcRequest, RpcResponse } from './rpc-shared'; -type RpcTransportRequest = Readonly<{ - payload: unknown; - signal?: AbortSignal; -}>; +export type RpcTransportRequest = RpcRequest & { + readonly signal?: AbortSignal; +}; export type RpcTransport = { (request: RpcTransportRequest): Promise>; diff --git a/packages/rpc-spec/src/rpc.ts b/packages/rpc-spec/src/rpc.ts index 4421186401a9..8edbfc374961 100644 --- a/packages/rpc-spec/src/rpc.ts +++ b/packages/rpc-spec/src/rpc.ts @@ -1,10 +1,4 @@ -import { - Callable, - createRpcMessage, - Flatten, - OverloadImplementations, - UnionToIntersection, -} from '@solana/rpc-spec-types'; +import { Callable, Flatten, OverloadImplementations, UnionToIntersection } from '@solana/rpc-spec-types'; import { RpcApi, RpcApiRequestPlan } from './rpc-api'; import { RpcTransport } from './rpc-transport'; @@ -74,12 +68,16 @@ function createPendingRpcRequest { const { methodName, params, responseTransformer } = pendingRequest; - const request = Object.freeze({ methodName, params }); - const rawResponse = await rpcConfig.transport({ - payload: createRpcMessage(methodName, params), - signal: options?.abortSignal, - }); - return responseTransformer ? responseTransformer(rawResponse, request) : rawResponse; + const rawResponse = await rpcConfig.transport( + Object.freeze({ + methodName, + params, + signal: options?.abortSignal, + }), + ); + return responseTransformer + ? responseTransformer(rawResponse, Object.freeze({ methodName, params })) + : rawResponse; }, }; } diff --git a/packages/rpc-transport-http/README.md b/packages/rpc-transport-http/README.md index 58aecb1ac999..694fe8026af0 100644 --- a/packages/rpc-transport-http/README.md +++ b/packages/rpc-transport-http/README.md @@ -27,7 +27,8 @@ import { createHttpTransport } from '@solana/rpc-transport-http'; const transport = createHttpTransport({ url: 'https://api.mainnet-beta.solana.com' }); const response = await transport({ - payload: { id: 1, jsonrpc: '2.0', method: 'getSlot' }, + methodName: 'getSlot', + params: [], }); const data = await response.json(); ``` @@ -66,16 +67,11 @@ const transport = createHttpTransport({ dispatcher_NODE_ONLY: dispatcher, url: 'https://mypool', }); -let id = 0; const balances = await Promise.allSettled( accounts.map(async account => { const response = await transport({ - payload: { - id: ++id, - jsonrpc: '2.0', - method: 'getBalance', - params: [account], - }, + methodName: 'getBalance', + params: [account], }); return await response.json(); }), diff --git a/packages/rpc-transport-http/package.json b/packages/rpc-transport-http/package.json index 589a97d44115..8968c927af21 100644 --- a/packages/rpc-transport-http/package.json +++ b/packages/rpc-transport-http/package.json @@ -74,6 +74,7 @@ "dependencies": { "@solana/errors": "workspace:*", "@solana/rpc-spec": "workspace:*", + "@solana/rpc-spec-types": "workspace:*", "undici-types": "^6.19.8" }, "devDependencies": { diff --git a/packages/rpc-transport-http/src/__benchmarks__/run.ts b/packages/rpc-transport-http/src/__benchmarks__/run.ts index 6e32c3ea3d8a..90bb226c5ecb 100755 --- a/packages/rpc-transport-http/src/__benchmarks__/run.ts +++ b/packages/rpc-transport-http/src/__benchmarks__/run.ts @@ -38,12 +38,10 @@ function createDispatcher(options: Agent.Options) { }); } -let id = 0; -function getTestPayload() { +function getTestRequest() { return { - id: ++id, - jsonrpc: '2.0', - method: 'getLatestBlockhash', + methodName: 'getLatestBlockhash', + params: [], }; } @@ -53,9 +51,7 @@ async function makeConcurrentRequests(num: number = NUM_CONCURRENT_REQUESTS) { createHttpTransport({ dispatcher_NODE_ONLY: dispatcher, url: VALIDATOR_URL, - })({ - payload: getTestPayload(), - }), + })(getTestRequest()), ), ); } 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 19ce152a28c8..789e3b4d112b 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 @@ -10,7 +10,7 @@ describe('createHttpTransport and `AbortSignal`', () => { describe('when invoked with an already-aborted `AbortSignal`', () => { it('rejects with an `AbortError` when no reason is specified', async () => { expect.assertions(3); - const sendPromise = makeHttpRequest({ payload: 123, signal: AbortSignal.abort() }); + const sendPromise = makeHttpRequest({ methodName: 'foo', params: 123, signal: AbortSignal.abort() }); await expect(sendPromise).rejects.toThrow(); await expect(sendPromise).rejects.toBeInstanceOf(DOMException); await expect(sendPromise).rejects.toHaveProperty('name', 'AbortError'); @@ -21,7 +21,8 @@ describe('createHttpTransport and `AbortSignal`', () => { it("rejects with the `AbortSignal's` reason", async () => { expect.assertions(1); const sendPromise = makeHttpRequest({ - payload: 123, + methodName: 'foo', + params: 123, signal: AbortSignal.abort('Already aborted'), }); await expect(sendPromise).rejects.toBe('Already aborted'); @@ -37,7 +38,7 @@ describe('createHttpTransport and `AbortSignal`', () => { }); it('rejects with an `AbortError` when no reason is specified', async () => { expect.assertions(1); - const sendPromise = makeHttpRequest({ payload: 123, signal: abortSignal }); + const sendPromise = makeHttpRequest({ methodName: 'foo', params: 123, signal: abortSignal }); abortController.abort(); await expect(sendPromise).rejects.toThrow(); }); @@ -46,7 +47,7 @@ describe('createHttpTransport and `AbortSignal`', () => { if (!__BROWSER__) { it("rejects with with the `AbortSignal's` reason", async () => { expect.assertions(1); - const sendPromise = makeHttpRequest({ payload: 123, signal: abortSignal }); + const sendPromise = makeHttpRequest({ methodName: 'foo', params: 123, signal: abortSignal }); abortController.abort('I got bored waiting'); await expect(sendPromise).rejects.toBe('I got bored waiting'); }); @@ -74,7 +75,7 @@ describe('createHttpTransport and `AbortSignal`', () => { json: () => Promise.resolve({ ok: true }), ok: true, } as unknown as Response); - const sendPromise = makeHttpRequest({ payload: 123, signal: abortSignal }); + const sendPromise = makeHttpRequest({ methodName: 'foo', params: 123, signal: abortSignal }); abortController.abort('I got bored waiting'); await expect(sendPromise).resolves.toMatchObject({ ok: true }); }); diff --git a/packages/rpc-transport-http/src/__tests__/http-transport-headers-test.ts b/packages/rpc-transport-http/src/__tests__/http-transport-headers-test.ts index a85945d4ab50..90accc07294e 100644 --- a/packages/rpc-transport-http/src/__tests__/http-transport-headers-test.ts +++ b/packages/rpc-transport-http/src/__tests__/http-transport-headers-test.ts @@ -1,5 +1,6 @@ import { SOLANA_ERROR__RPC__TRANSPORT_HTTP_HEADER_FORBIDDEN, SolanaError } from '@solana/errors'; import { RpcTransport } from '@solana/rpc-spec'; +import { createRpcMessage } from '@solana/rpc-spec-types'; import { assertIsAllowedHttpRequestHeaders } from '../http-transport-headers'; @@ -81,7 +82,7 @@ describe('createHttpRequest with custom headers', () => { headers: { aCcEpT: 'text/html' }, url: 'http://localhost', }); - makeHttpRequest({ payload: 123 }); + makeHttpRequest({ methodName: 'foo', params: 123 }); expect(fetchSpy).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ @@ -96,12 +97,13 @@ describe('createHttpRequest with custom headers', () => { headers: { 'cOnTeNt-LeNgTh': '420' }, url: 'http://localhost', }); - makeHttpRequest({ payload: 123 }); + makeHttpRequest({ methodName: 'foo', params: 123 }); + const expectedContentLength = JSON.stringify(createRpcMessage('foo', 123)).length; expect(fetchSpy).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ headers: expect.objectContaining({ - 'content-length': '3', + 'content-length': expectedContentLength.toString(), }), }), ); @@ -111,7 +113,7 @@ describe('createHttpRequest with custom headers', () => { headers: { 'cOnTeNt-TyPe': 'text/html' }, url: 'http://localhost', }); - makeHttpRequest({ payload: 123 }); + makeHttpRequest({ methodName: 'foo', params: 123 }); expect(fetchSpy).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ diff --git a/packages/rpc-transport-http/src/__tests__/http-transport-test.ts b/packages/rpc-transport-http/src/__tests__/http-transport-test.ts index 7ff9b3321f28..5e4670918ccc 100644 --- a/packages/rpc-transport-http/src/__tests__/http-transport-test.ts +++ b/packages/rpc-transport-http/src/__tests__/http-transport-test.ts @@ -1,5 +1,6 @@ import { SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR, SolanaError } from '@solana/errors'; import { RpcTransport } from '@solana/rpc-spec'; +import { createRpcMessage } from '@solana/rpc-spec-types'; // HACK: Pierce the veil of `jest.isolateModules` so that the modules inside get the same version of // `@solana/errors` that is imported above. @@ -28,7 +29,7 @@ describe('createHttpTransport', () => { }); it('throws HTTP errors', async () => { expect.assertions(1); - const requestPromise = makeHttpRequest({ payload: 123 }); + const requestPromise = makeHttpRequest({ methodName: 'foo', params: 123 }); await expect(requestPromise).rejects.toThrow( new SolanaError(SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR, { message: 'We looked everywhere', @@ -43,7 +44,9 @@ describe('createHttpTransport', () => { }); it('passes the exception through', async () => { expect.assertions(1); - await expect(makeHttpRequest({ payload: 123 })).rejects.toThrow(new TypeError('Failed to fetch')); + await expect(makeHttpRequest({ methodName: 'foo', params: 123 })).rejects.toThrow( + new TypeError('Failed to fetch'), + ); }); }); describe('when the endpoint returns a well-formed JSON response', () => { @@ -54,20 +57,20 @@ describe('createHttpTransport', () => { }); }); it('calls fetch with the specified URL', () => { - makeHttpRequest({ payload: 123 }); + makeHttpRequest({ methodName: 'foo', params: 123 }); expect(fetchSpy).toHaveBeenCalledWith('http://localhost', expect.anything()); }); it('sets the `body` to a stringfied version of the payload', () => { - makeHttpRequest({ payload: { ok: true } }); + makeHttpRequest({ methodName: 'foo', params: { ok: true } }); expect(fetchSpy).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ - body: JSON.stringify({ ok: true }), + body: JSON.stringify(createRpcMessage('foo', { ok: true })), }), ); }); it('sets the accept header to `application/json`', () => { - makeHttpRequest({ payload: 123 }); + makeHttpRequest({ methodName: 'foo', params: 123 }); expect(fetchSpy).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ @@ -78,7 +81,7 @@ describe('createHttpTransport', () => { ); }); it('sets the content type header to `application/json; charset=utf-8`', () => { - makeHttpRequest({ payload: 123 }); + makeHttpRequest({ methodName: 'foo', params: 123 }); expect(fetchSpy).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ @@ -89,8 +92,9 @@ describe('createHttpTransport', () => { ); }); it('sets the content length header to the length of the JSON-stringified payload', () => { - makeHttpRequest({ - payload: + const request = { + methodName: 'foo', + params: // Shruggie: https://emojipedia.org/person-shrugging/ '\xAF\\\x5F\x28\u30C4\x29\x5F\x2F\xAF' + ' ' + @@ -99,18 +103,20 @@ describe('createHttpTransport', () => { ' ' + // https://tinyurl.com/bdemuf3r '\u{1F469}\u{1F3FB}\u200D\u2764\uFE0F\u200D\u{1F469}\u{1F3FF}', - }); + }; + makeHttpRequest(request); + const expectedContentLength = JSON.stringify(createRpcMessage(request.methodName, request.params)).length; expect(fetchSpy).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ headers: expect.objectContaining({ - 'content-length': '30', + 'content-length': expectedContentLength.toString(), }), }), ); }); it('sets the `method` to `POST`', () => { - makeHttpRequest({ payload: 123 }); + makeHttpRequest({ methodName: 'foo', params: 123 }); expect(fetchSpy).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ diff --git a/packages/rpc-transport-http/src/http-transport.ts b/packages/rpc-transport-http/src/http-transport.ts index 0fb9f0e6996e..91096a4fceb1 100644 --- a/packages/rpc-transport-http/src/http-transport.ts +++ b/packages/rpc-transport-http/src/http-transport.ts @@ -1,5 +1,6 @@ import { SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR, SolanaError } from '@solana/errors'; import { RpcResponse, RpcTransport } from '@solana/rpc-spec'; +import { createRpcMessage } from '@solana/rpc-spec-types'; import type Dispatcher from 'undici-types/dispatcher'; import { @@ -42,9 +43,11 @@ export function createHttpTransport(config: Config): RpcTransport { } const customHeaders = headers && normalizeHeaders(headers); return async function makeHttpRequest({ - payload, + methodName, + params, signal, }: Parameters[0]): Promise> { + const payload = createRpcMessage(methodName, params); const body = JSON.stringify(payload); const requestInfo = { ...dispatcherConfig, diff --git a/packages/rpc/src/__tests__/rpc-request-coalescer-test.ts b/packages/rpc/src/__tests__/rpc-request-coalescer-test.ts index 171f4326ff86..49f0f2a84fbd 100644 --- a/packages/rpc/src/__tests__/rpc-request-coalescer-test.ts +++ b/packages/rpc/src/__tests__/rpc-request-coalescer-test.ts @@ -17,23 +17,23 @@ describe('RPC request coalescer', () => { hashFn.mockReturnValue('samehash'); }); it('multiple requests in the same tick produce a single transport request', () => { - coalescedTransport({ payload: null }); - coalescedTransport({ payload: null }); + coalescedTransport({ methodName: 'foo', params: null }); + coalescedTransport({ methodName: 'foo', params: null }); expect(mockTransport).toHaveBeenCalledTimes(1); }); it('multiple requests in different ticks each produce their own transport request', async () => { expect.assertions(1); - coalescedTransport({ payload: null }); + coalescedTransport({ methodName: 'foo', params: null }); await jest.runOnlyPendingTimersAsync(); - coalescedTransport({ payload: null }); + coalescedTransport({ methodName: 'foo', params: null }); expect(mockTransport).toHaveBeenCalledTimes(2); }); it('multiple requests in the same tick receive the same response', async () => { expect.assertions(2); const mockResponse = { response: 'ok' }; mockTransport.mockResolvedValueOnce(mockResponse); - const responsePromiseA = coalescedTransport({ payload: null }); - const responsePromiseB = coalescedTransport({ payload: null }); + const responsePromiseA = coalescedTransport({ methodName: 'foo', params: null }); + const responsePromiseB = coalescedTransport({ methodName: 'foo', params: null }); await Promise.all([ expect(responsePromiseA).resolves.toBe(mockResponse), expect(responsePromiseB).resolves.toBe(mockResponse), @@ -45,9 +45,9 @@ describe('RPC request coalescer', () => { const mockResponseB = { response: 'okB' }; mockTransport.mockResolvedValueOnce(mockResponseA); mockTransport.mockResolvedValueOnce(mockResponseB); - const responsePromiseA = coalescedTransport({ payload: null }); + const responsePromiseA = coalescedTransport({ methodName: 'foo', params: null }); await jest.runOnlyPendingTimersAsync(); - const responsePromiseB = coalescedTransport({ payload: null }); + const responsePromiseB = coalescedTransport({ methodName: 'foo', params: null }); await Promise.all([ expect(responsePromiseA).resolves.toBe(mockResponseA), expect(responsePromiseB).resolves.toBe(mockResponseB), @@ -57,8 +57,8 @@ describe('RPC request coalescer', () => { expect.assertions(2); const mockError = { err: 'bad' }; mockTransport.mockRejectedValueOnce(mockError); - const responsePromiseA = coalescedTransport({ payload: null }); - const responsePromiseB = coalescedTransport({ payload: null }); + const responsePromiseA = coalescedTransport({ methodName: 'foo', params: null }); + const responsePromiseB = coalescedTransport({ methodName: 'foo', params: null }); await Promise.all([ expect(responsePromiseA).rejects.toBe(mockError), expect(responsePromiseB).rejects.toBe(mockError), @@ -70,11 +70,11 @@ describe('RPC request coalescer', () => { const mockErrorB = { err: 'badB' }; mockTransport.mockRejectedValueOnce(mockErrorA); mockTransport.mockRejectedValueOnce(mockErrorB); - const responsePromiseA = coalescedTransport({ payload: null }); + const responsePromiseA = coalescedTransport({ methodName: 'foo', params: null }); // eslint-disable-next-line jest/valid-expect const expectationA = expect(responsePromiseA).rejects.toBe(mockErrorA); await jest.runOnlyPendingTimersAsync(); - const responsePromiseB = coalescedTransport({ payload: null }); + const responsePromiseB = coalescedTransport({ methodName: 'foo', params: null }); // eslint-disable-next-line jest/valid-expect const expectationB = expect(responsePromiseB).rejects.toBe(mockErrorB); await Promise.all([expectationA, expectationB]); @@ -83,13 +83,13 @@ describe('RPC request coalescer', () => { expect.assertions(2); const abortControllerA = new AbortController(); const abortControllerB = new AbortController(); - coalescedTransport({ payload: null, signal: abortControllerA.signal }).catch(() => {}); - coalescedTransport({ payload: null, signal: abortControllerB.signal }).catch(() => {}); + coalescedTransport({ methodName: 'foo', params: null, signal: abortControllerA.signal }).catch(() => {}); + coalescedTransport({ methodName: 'foo', params: null, signal: abortControllerB.signal }).catch(() => {}); // Both abort, bringing the consumer count to zero. abortControllerA.abort('o no A'); abortControllerB.abort('o no B'); // New request comes in at the last moment before the end of the runloop. - coalescedTransport({ payload: null }); + coalescedTransport({ methodName: 'foo', params: null }); await jest.runOnlyPendingTimersAsync(); expect(mockTransport).toHaveBeenCalledTimes(1); const transportAbortSignal = mockTransport.mock.lastCall![0].signal!; @@ -113,8 +113,16 @@ describe('RPC request coalescer', () => { }); }); }); - responsePromiseA = coalescedTransport({ payload: null, signal: abortControllerA.signal }); - responsePromiseB = coalescedTransport({ payload: null, signal: abortControllerB.signal }); + responsePromiseA = coalescedTransport({ + methodName: 'foo', + params: null, + signal: abortControllerA.signal, + }); + responsePromiseB = coalescedTransport({ + methodName: 'foo', + params: null, + signal: abortControllerB.signal, + }); }); afterEach(async () => { try { @@ -186,8 +194,8 @@ describe('RPC request coalescer', () => { hashFn.mockImplementation(getHashFn()); }); it('multiple requests in the same tick produce one transport request each', () => { - coalescedTransport({ payload: null }); - coalescedTransport({ payload: null }); + coalescedTransport({ methodName: 'foo', params: null }); + coalescedTransport({ methodName: 'foo', params: null }); expect(mockTransport).toHaveBeenCalledTimes(2); }); it('multiple requests in the same tick receive different responses', async () => { @@ -196,8 +204,8 @@ describe('RPC request coalescer', () => { const mockResponseB = { response: 'okB' }; mockTransport.mockResolvedValueOnce(mockResponseA); mockTransport.mockResolvedValueOnce(mockResponseB); - const responsePromiseA = coalescedTransport({ payload: null }); - const responsePromiseB = coalescedTransport({ payload: null }); + const responsePromiseA = coalescedTransport({ methodName: 'foo', params: null }); + const responsePromiseB = coalescedTransport({ methodName: 'foo', params: null }); await Promise.all([ expect(responsePromiseA).resolves.toBe(mockResponseA), expect(responsePromiseB).resolves.toBe(mockResponseB), @@ -209,8 +217,8 @@ describe('RPC request coalescer', () => { const mockErrorB = { err: 'badB' }; mockTransport.mockRejectedValueOnce(mockErrorA); mockTransport.mockRejectedValueOnce(mockErrorB); - const responsePromiseA = coalescedTransport({ payload: null }); - const responsePromiseB = coalescedTransport({ payload: null }); + const responsePromiseA = coalescedTransport({ methodName: 'foo', params: null }); + const responsePromiseB = coalescedTransport({ methodName: 'foo', params: null }); await Promise.all([ expect(responsePromiseA).rejects.toBe(mockErrorA), expect(responsePromiseB).rejects.toBe(mockErrorB), @@ -228,15 +236,15 @@ describe('RPC request coalescer', () => { expect.assertions(1); const mockError = { err: 'bad' }; mockTransport.mockRejectedValueOnce(mockError); - await expect(coalescedTransport({ payload: null })).rejects.toBe(mockError); + await expect(coalescedTransport({ methodName: 'foo', params: null })).rejects.toBe(mockError); }); it('throws an error in the case of failure, if it was configured with an `AbortSignal`', async () => { expect.assertions(1); const mockError = { err: 'bad' }; mockTransport.mockRejectedValueOnce(mockError); - await expect(coalescedTransport({ payload: null, signal: new AbortController().signal })).rejects.toBe( - mockError, - ); + await expect( + coalescedTransport({ methodName: 'foo', params: null, signal: new AbortController().signal }), + ).rejects.toBe(mockError); }); }); }); diff --git a/packages/rpc/src/__tests__/rpc-request-deduplication-test.ts b/packages/rpc/src/__tests__/rpc-request-deduplication-test.ts index 2640ea5736bf..53d35455b40c 100644 --- a/packages/rpc/src/__tests__/rpc-request-deduplication-test.ts +++ b/packages/rpc/src/__tests__/rpc-request-deduplication-test.ts @@ -1,54 +1,25 @@ import { getSolanaRpcPayloadDeduplicationKey } from '../rpc-request-deduplication'; describe('getSolanaRpcPayloadDeduplicationKey', () => { - it('produces no key for undefined payloads', () => { - expect(getSolanaRpcPayloadDeduplicationKey(undefined)).toBeUndefined(); - }); - it('produces no key for null payloads', () => { - expect(getSolanaRpcPayloadDeduplicationKey(null)).toBeUndefined(); - }); - it('produces no key for array payloads', () => { - expect(getSolanaRpcPayloadDeduplicationKey([])).toBeUndefined(); - }); - it('produces no key for string payloads', () => { - expect(getSolanaRpcPayloadDeduplicationKey('o hai')).toBeUndefined(); - }); - it('produces no key for numeric payloads', () => { - expect(getSolanaRpcPayloadDeduplicationKey(123)).toBeUndefined(); - }); - it('produces no key for bigint payloads', () => { - expect(getSolanaRpcPayloadDeduplicationKey(123n)).toBeUndefined(); - }); - it('produces no key for object payloads that are not JSON-RPC payloads', () => { - expect(getSolanaRpcPayloadDeduplicationKey({})).toBeUndefined(); - }); - it('produces a key for a JSON-RPC payload', () => { + it('produces a key for RPC requests', () => { expect( getSolanaRpcPayloadDeduplicationKey({ - id: 1, - jsonrpc: '2.0', - method: 'getFoo', + methodName: 'getFoo', params: 'foo', }), ).toMatchInlineSnapshot(`"["getFoo","foo"]"`); }); - it('produces identical keys for two materially identical JSON-RPC payloads', () => { + it('produces identical keys for two identical RPC requests', () => { expect( getSolanaRpcPayloadDeduplicationKey({ - id: 1, - jsonrpc: '2.0', - method: 'getFoo', + methodName: 'getFoo', params: { a: 1, b: { c: [2, 3], d: 4 } }, }), ).toEqual( - /* eslint-disable sort-keys-fix/sort-keys-fix */ getSolanaRpcPayloadDeduplicationKey({ - jsonrpc: '2.0', - method: 'getFoo', - params: { b: { d: 4, c: [2, 3] }, a: 1 }, - id: 2, + methodName: 'getFoo', + params: { a: 1, b: { c: [2, 3], d: 4 } }, }), - /* eslint-enable sort-keys-fix/sort-keys-fix */ ); }); }); diff --git a/packages/rpc/src/rpc-request-coalescer.ts b/packages/rpc/src/rpc-request-coalescer.ts index 156fc0e2114f..2263792b2fc3 100644 --- a/packages/rpc/src/rpc-request-coalescer.ts +++ b/packages/rpc/src/rpc-request-coalescer.ts @@ -1,4 +1,4 @@ -import type { RpcResponse, RpcTransport } from '@solana/rpc-spec'; +import type { RpcRequest, RpcResponse, RpcTransport } from '@solana/rpc-spec'; type CoalescedRequest = { readonly abortController: AbortController; @@ -6,7 +6,7 @@ type CoalescedRequest = { readonly responsePromise: Promise; }; -type GetDeduplicationKeyFn = (payload: unknown) => string | undefined; +type GetDeduplicationKeyFn = (request: RpcRequest) => string | undefined; // This used to be a `Symbol()`, but there's a bug in Node <21 where the `undici` library passes // the `reason` property of the `AbortSignal` straight to `Error.captureStackTrace()` without first @@ -33,8 +33,8 @@ export function getRpcTransportWithRequestCoalescing( request: Parameters[0], ): Promise> { - const { payload, signal } = request; - const deduplicationKey = getDeduplicationKey(payload); + const { methodName, params, signal } = request; + const deduplicationKey = getDeduplicationKey({ methodName, params }); if (deduplicationKey === undefined) { return await transport(request); } diff --git a/packages/rpc/src/rpc-request-deduplication.ts b/packages/rpc/src/rpc-request-deduplication.ts index 8eabedb1171a..d789688ad0bc 100644 --- a/packages/rpc/src/rpc-request-deduplication.ts +++ b/packages/rpc/src/rpc-request-deduplication.ts @@ -1,18 +1,6 @@ import fastStableStringify from '@solana/fast-stable-stringify'; +import { RpcRequest } from '@solana/rpc-spec'; -function isJsonRpcPayload(payload: unknown): payload is Readonly<{ method: string; params: unknown }> { - if (payload == null || typeof payload !== 'object' || Array.isArray(payload)) { - return false; - } - return ( - 'jsonrpc' in payload && - payload.jsonrpc === '2.0' && - 'method' in payload && - typeof payload.method === 'string' && - 'params' in payload - ); -} - -export function getSolanaRpcPayloadDeduplicationKey(payload: unknown): string | undefined { - return isJsonRpcPayload(payload) ? fastStableStringify([payload.method, payload.params]) : undefined; +export function getSolanaRpcPayloadDeduplicationKey({ methodName, params }: RpcRequest): string | undefined { + return fastStableStringify([methodName, params]); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0bf420640f33..206ae7238951 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -976,6 +976,9 @@ importers: '@solana/rpc-spec': specifier: workspace:* version: link:../rpc-spec + '@solana/rpc-spec-types': + specifier: workspace:* + version: link:../rpc-spec-types typescript: specifier: '>=5' version: 5.5.2