Skip to content

Commit a2fc5a3

Browse files
authored
Allow devs to supply a dispatcher to the Node implementation of fetch (#2178)
# Summary The current version of web3.js allows folks to supply what's called an ‘HTTP Agent.’ This is a class that mediates how network requests should be fetched. One example is to establish a connection pool for a given URL, and keep connections open for a time so that TLS/SSL negotiation doesn't have to be done on every request. The new web3.js up until this point has punted on how to reintroduce this concept. In this PR we bring it back in the form of allowing callers to create transports with custom Undici `Dispatchers`. It only works in Node. If you supply the `dispatcher_NODE_ONLY` property in non-Node environments you'll get a console warning that your config has been ignored. # Alternate designs considered I desperately wanted to avoid the situation in this PR, which was to create a config property on transports that's only usable in Node. Alternate approaches considered: 1. Create a special transport for use in Node. Callers would have to `createDefaultNodeRpcTransport()` and/or `solanaRpcFactoryForNode()` which I thought was a bridge too far. 2. Encourage people to [inject a global dispatcher](https://stackoverflow.com/a/77526783/802047). This is a terrible idea for all of the reasons that global state is terrible: * Applies to all connections in the process * Your global dispatcher can be unset by someone _else_ * There might already be a global dispatcher installed (eg. a `ProxyAgent`) and you might unknowingly replace it 3. Export a `Dispatcher` setter that lets you install a dispatcher accessible _only_ to `@solana/fetch-impl`. Besides having most of the bad features of #​2, this would not work. We inline `@solana/fetch-impl` in the build, meaning there's no longer anywhere to export such a method from. # Test Plan ```shell cd packages/fetch-impl/ pnpm test:unit:node cd ../rpc-transport-http/ pnpm test:unit:node ``` See benchmark tests in next PR. Closes #2126.
1 parent 16f8db8 commit a2fc5a3

File tree

11 files changed

+138
-28
lines changed

11 files changed

+138
-28
lines changed

packages/build-scripts/getBaseConfig.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ export function getBaseConfig(platform: Platform, formats: Format[], _options: O
4343
external: [
4444
// Despite inlining `@solana/text-encoding-impl`, do not recursively inline `fastestsmallesttextencoderdecoder`.
4545
'fastestsmallesttextencoderdecoder',
46+
// Despite inlining `@solana/fetch-impl`, do not recursively inline `undici`.
47+
'undici',
4648
// Despite inlining `@solana/ws-impl`, do not recursively inline `ws`.
4749
'ws',
4850
],

packages/fetch-impl/package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,17 @@
3535
"test:prettier": "jest -c node_modules/@solana/test-config/jest-prettier.config.ts --rootDir . --silent",
3636
"test:treeshakability:browser": "agadoo dist/index.browser.js",
3737
"test:treeshakability:node": "agadoo dist/index.node.js",
38-
"test:typecheck": "tsc --noEmit"
38+
"test:typecheck": "tsc --noEmit",
39+
"test:unit:browser": "jest -c node_modules/@solana/test-config/jest-unit.config.browser.ts --rootDir . --silent --passWithNoTests",
40+
"test:unit:node": "jest -c node_modules/@solana/test-config/jest-unit.config.node.ts --rootDir . --silent"
3941
},
4042
"browserslist": [
4143
"supports bigint and not dead",
4244
"maintained node versions"
4345
],
46+
"dependencies": {
47+
"undici": "^6.2.2"
48+
},
4449
"devDependencies": {
4550
"@solana/eslint-config-solana": "^1.0.2",
4651
"@solana/test-config": "workspace:*",
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Dispatcher, fetch as fetchImpl } from 'undici';
2+
3+
import fetch from '../index.node';
4+
5+
jest.mock('undici');
6+
7+
describe('fetch', () => {
8+
it('should call the underlying `fetch` with the `dispatcher` supplied in `requestInit`', () => {
9+
const explicitDispatcher = Symbol('explicitDispatcher') as unknown as Dispatcher;
10+
fetch('http://solana.com', { dispatcher: explicitDispatcher });
11+
expect(fetchImpl).toHaveBeenCalledWith('http://solana.com', {
12+
dispatcher: explicitDispatcher,
13+
});
14+
});
15+
it('should call the underlying `fetch` with an undefined `dispatcher` when an undefined is explicitly supplied in `requestInit`', () => {
16+
fetch('http://solana.com', { dispatcher: undefined });
17+
expect(fetchImpl).toHaveBeenCalledWith('http://solana.com', {
18+
dispatcher: undefined,
19+
});
20+
});
21+
});
Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1 @@
1-
// TODO(https://github.com/solana-labs/solana-web3.js/issues/1787) Write HTTP/2 implementation.
2-
export default globalThis.fetch; // The Fetch API is supported natively in Node 18+.
1+
export { fetch as default } from 'undici';

packages/rpc-transport-http/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@
6363
"maintained node versions"
6464
],
6565
"dependencies": {
66-
"@solana/rpc-spec": "workspace:*"
66+
"@solana/rpc-spec": "workspace:*",
67+
"undici": "^6.6.2"
6768
},
6869
"devDependencies": {
6970
"@solana/build-scripts": "workspace:*",
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { Dispatcher } from 'undici';
2+
3+
const WARNING_MESSAGE =
4+
'You have supplied a `Dispatcher` to `createHttpTransport()`. It has been ignored because ' +
5+
'Undici dispatchers only work in Node environments. To eliminate this warning, omit the ' +
6+
'`dispatcher_NODE_ONLY` property from your config when running in a non-Node environment.';
7+
8+
describe('createHttpTransport()', () => {
9+
let createHttpTransport: typeof import('../http-transport').createHttpTransport;
10+
beforeEach(async () => {
11+
jest.spyOn(console, 'warn').mockImplementation();
12+
await jest.isolateModulesAsync(async () => {
13+
createHttpTransport =
14+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
15+
// @ts-ignore
16+
(await import('../http-transport')).createHttpTransport;
17+
});
18+
});
19+
describe('in development mode', () => {
20+
beforeEach(() => {
21+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
22+
(globalThis as any).__DEV__ = true;
23+
});
24+
it('warns when configured with a dispatcher', () => {
25+
createHttpTransport({ dispatcher_NODE_ONLY: {} as Dispatcher, url: 'fake://url' });
26+
expect(console.warn).toHaveBeenCalledWith(WARNING_MESSAGE);
27+
});
28+
it('warns when configured with an undefined dispatcher', () => {
29+
createHttpTransport({ dispatcher_NODE_ONLY: undefined, url: 'fake://url' });
30+
expect(console.warn).toHaveBeenCalledWith(WARNING_MESSAGE);
31+
});
32+
it('only warns once no matter how many times it is configured with a dispatcher', () => {
33+
createHttpTransport({ dispatcher_NODE_ONLY: undefined, url: 'fake://url' });
34+
createHttpTransport({ dispatcher_NODE_ONLY: {} as Dispatcher, url: 'fake://url' });
35+
createHttpTransport({ dispatcher_NODE_ONLY: null as unknown as Dispatcher, url: 'fake://url' });
36+
expect(console.warn).toHaveBeenCalledTimes(1);
37+
});
38+
});
39+
describe('in production mode', () => {
40+
beforeEach(() => {
41+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
42+
(globalThis as any).__DEV__ = false;
43+
});
44+
it('does not warn when configured with a dispatcher', () => {
45+
createHttpTransport({ dispatcher_NODE_ONLY: {} as Dispatcher, url: 'fake://url' });
46+
expect(console.warn).not.toHaveBeenCalledWith(WARNING_MESSAGE);
47+
});
48+
it('does not warn when configured with an undefined dispatcher', () => {
49+
createHttpTransport({ dispatcher_NODE_ONLY: undefined, url: 'fake://url' });
50+
expect(console.warn).not.toHaveBeenCalledWith(WARNING_MESSAGE);
51+
});
52+
});
53+
});

packages/rpc-transport-http/src/__tests__/http-transport-headers-test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ describe('createHttpRequest with custom headers', () => {
7575
it('is impossible to override the `Accept` header', () => {
7676
const makeHttpRequest = createHttpTransport({
7777
headers: { aCcEpT: 'text/html' },
78-
url: 'fake://url',
78+
url: 'http://localhost',
7979
});
8080
makeHttpRequest({ payload: 123 });
8181
expect(fetchImpl).toHaveBeenCalledWith(
@@ -90,7 +90,7 @@ describe('createHttpRequest with custom headers', () => {
9090
it('is impossible to override the `Content-Length` header', () => {
9191
const makeHttpRequest = createHttpTransport({
9292
headers: { 'cOnTeNt-LeNgTh': '420' },
93-
url: 'fake://url',
93+
url: 'http://localhost',
9494
});
9595
makeHttpRequest({ payload: 123 });
9696
expect(fetchImpl).toHaveBeenCalledWith(
@@ -105,7 +105,7 @@ describe('createHttpRequest with custom headers', () => {
105105
it('is impossible to override the `Content-Type` header', () => {
106106
const makeHttpRequest = createHttpTransport({
107107
headers: { 'cOnTeNt-TyPe': 'text/html' },
108-
url: 'fake://url',
108+
url: 'http://localhost',
109109
});
110110
makeHttpRequest({ payload: 123 });
111111
expect(fetchImpl).toHaveBeenCalledWith(
@@ -123,7 +123,7 @@ describe('createHttpRequest with custom headers', () => {
123123
createTransportWithForbiddenHeaders = () =>
124124
createHttpTransport({
125125
headers: { 'sEc-FeTcH-mOdE': 'no-cors' },
126-
url: 'fake://url',
126+
url: 'http://localhost',
127127
});
128128
});
129129
it('throws in dev mode', () => {

packages/rpc-transport-http/src/__tests__/http-transport-test.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,26 @@ import { RpcTransport } from '@solana/rpc-spec';
33
describe('createHttpTransport', () => {
44
let fetchMock: jest.Mock;
55
let makeHttpRequest: RpcTransport;
6-
let oldFetch: typeof globalThis.fetch;
76
let SolanaHttpError: typeof import('../http-transport-errors').SolanaHttpError;
87
beforeEach(async () => {
9-
oldFetch = globalThis.fetch;
10-
globalThis.fetch = fetchMock = jest.fn();
118
await jest.isolateModulesAsync(async () => {
12-
const [{ createHttpTransport }, HttpTransportErrorsModule] = await Promise.all([
9+
jest.mock('@solana/fetch-impl');
10+
const [fetchImplModule, { createHttpTransport }, HttpTransportErrorsModule] = await Promise.all([
11+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
12+
// @ts-ignore
13+
import('@solana/fetch-impl'),
1314
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
1415
// @ts-ignore
1516
import('../http-transport'),
1617
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
1718
// @ts-ignore
1819
import('../http-transport-errors'),
1920
]);
21+
fetchMock = jest.mocked(fetchImplModule.default);
2022
SolanaHttpError = HttpTransportErrorsModule.SolanaHttpError;
21-
makeHttpRequest = createHttpTransport({ url: 'fake://url' });
23+
makeHttpRequest = createHttpTransport({ url: 'http://localhost' });
2224
});
2325
});
24-
afterEach(() => {
25-
globalThis.fetch = oldFetch;
26-
});
2726
describe('when the endpoint returns a non-200 status code', () => {
2827
beforeEach(() => {
2928
fetchMock.mockResolvedValue({
@@ -58,7 +57,7 @@ describe('createHttpTransport', () => {
5857
});
5958
it('calls fetch with the specified URL', () => {
6059
makeHttpRequest({ payload: 123 });
61-
expect(fetchMock).toHaveBeenCalledWith('fake://url', expect.anything());
60+
expect(fetchMock).toHaveBeenCalledWith('http://localhost', expect.anything());
6261
});
6362
it('sets the `body` to a stringfied version of the payload', () => {
6463
makeHttpRequest({ payload: { ok: true } });

packages/rpc-transport-http/src/http-transport.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import fetchImpl from '@solana/fetch-impl';
22
import { RpcTransport } from '@solana/rpc-spec';
3+
import type Dispatcher from 'undici/types/dispatcher';
34

45
import { SolanaHttpError } from './http-transport-errors';
56
import {
@@ -9,21 +10,45 @@ import {
910
} from './http-transport-headers';
1011

1112
type Config = Readonly<{
13+
dispatcher_NODE_ONLY?: Dispatcher;
1214
headers?: AllowedHttpRequestHeaders;
1315
url: string;
1416
}>;
1517

16-
export function createHttpTransport({ headers, url }: Config): RpcTransport {
18+
let didWarnDispatcherWasSuppliedInNonNodeEnvironment = false;
19+
function warnDispatcherWasSuppliedInNonNodeEnvironment() {
20+
if (didWarnDispatcherWasSuppliedInNonNodeEnvironment) {
21+
return;
22+
}
23+
didWarnDispatcherWasSuppliedInNonNodeEnvironment = true;
24+
console.warn(
25+
'You have supplied a `Dispatcher` to `createHttpTransport()`. It has been ignored ' +
26+
'because Undici dispatchers only work in Node environments. To eliminate this ' +
27+
'warning, omit the `dispatcher_NODE_ONLY` property from your config when running in ' +
28+
'a non-Node environment.',
29+
);
30+
}
31+
32+
export function createHttpTransport(config: Config): RpcTransport {
33+
if (__DEV__ && !__NODEJS__ && 'dispatcher_NODE_ONLY' in config) {
34+
warnDispatcherWasSuppliedInNonNodeEnvironment();
35+
}
36+
const { headers, url } = config;
1737
if (__DEV__ && headers) {
1838
assertIsAllowedHttpRequestHeaders(headers);
1939
}
40+
let dispatcherConfig: { dispatcher: Dispatcher | undefined } | undefined;
41+
if (__NODEJS__ && 'dispatcher_NODE_ONLY' in config) {
42+
dispatcherConfig = { dispatcher: config.dispatcher_NODE_ONLY };
43+
}
2044
const customHeaders = headers && normalizeHeaders(headers);
2145
return async function makeHttpRequest<TResponse>({
2246
payload,
2347
signal,
2448
}: Parameters<RpcTransport>[0]): Promise<TResponse> {
2549
const body = JSON.stringify(payload);
2650
const requestInfo = {
51+
...dispatcherConfig,
2752
body,
2853
headers: {
2954
...customHeaders,

packages/rpc/src/rpc-transport.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ import { RpcTransportFromClusterUrl } from './rpc-clusters';
66
import { getRpcTransportWithRequestCoalescing } from './rpc-request-coalescer';
77
import { getSolanaRpcPayloadDeduplicationKey } from './rpc-request-deduplication';
88

9-
type Config<TClusterUrl extends ClusterUrl> = Readonly<{
10-
headers?: Parameters<typeof createHttpTransport>[0]['headers'];
9+
type RpcTransportConfig = Parameters<typeof createHttpTransport>[0];
10+
interface Config<TClusterUrl extends ClusterUrl> extends RpcTransportConfig {
1111
url: TClusterUrl;
12-
}>;
12+
}
1313

1414
/**
1515
* Lowercasing header names makes it easier to override user-supplied headers.

0 commit comments

Comments
 (0)