diff --git a/packages/providers/README.md b/packages/providers/README.md index f86adf640..7946598c7 100644 --- a/packages/providers/README.md +++ b/packages/providers/README.md @@ -2,4 +2,36 @@ JSON-RPC Providers - +## Custom request headers (`WsProvider`) + +Custom HTTP headers can be sent along with the websocket opening handshake, +e.g: to authenticate with a private RPC endpoint or proxy. + +```ts +import { WsProvider } from '@dedot/providers'; + +const provider = new WsProvider({ + endpoint: 'wss://private.rpc', + headers: { + Authorization: `Bearer ${process.env.API_TOKEN}`, + 'X-Client-ID': 'example-client', + }, +}); +``` + +Headers can also be resolved on each connection attempt (including reconnects), +which is helpful to refresh short-lived tokens or to use different credentials per endpoint: + +```ts +const provider = new WsProvider({ + endpoint: ['wss://private.rpc', 'wss://private-backup.rpc'], + headers: async ({ attempt, currentEndpoint }) => ({ + Authorization: `Bearer ${await fetchToken(currentEndpoint)}`, + }), +}); +``` + +> [!NOTE] +> Custom headers are only supported in non-browser environments (Node.js, Bun). +> Browsers do not allow setting headers for the websocket opening handshake, +> the headers are ignored and a warning is logged in that case. diff --git a/packages/providers/src/__tests__/utils.spec.ts b/packages/providers/src/__tests__/utils.spec.ts index f042254cc..b82061f4a 100644 --- a/packages/providers/src/__tests__/utils.spec.ts +++ b/packages/providers/src/__tests__/utils.spec.ts @@ -1,6 +1,6 @@ import { DedotError } from '@dedot/utils'; -import { describe, expect, it } from 'vitest'; -import { pickRandomItem, validateEndpoint } from '../utils.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { canSendRequestHeaders, pickRandomItem, validateEndpoint } from '../utils.js'; describe('utils', () => { describe('validateEndpoint', () => { @@ -157,4 +157,30 @@ describe('utils', () => { }); }); }); + + describe('canSendRequestHeaders', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('returns true on Node.js', () => { + // Tests are running on Node.js, `process.versions.node` is available + expect(canSendRequestHeaders()).toBe(true); + }); + + it('returns true on Bun', () => { + vi.stubGlobal('process', { versions: { bun: '1.2.0' } }); + expect(canSendRequestHeaders()).toBe(true); + }); + + it('returns false on Deno', () => { + vi.stubGlobal('Deno', {}); + expect(canSendRequestHeaders()).toBe(false); + }); + + it('returns false in browsers', () => { + vi.stubGlobal('process', undefined); + expect(canSendRequestHeaders()).toBe(false); + }); + }); }); diff --git a/packages/providers/src/utils.ts b/packages/providers/src/utils.ts index d734a11d9..ef4d0df2f 100644 --- a/packages/providers/src/utils.ts +++ b/packages/providers/src/utils.ts @@ -21,6 +21,27 @@ export function pickRandomItem(items: T[], excludeItem?: T): T { return finalItems[Math.floor(Math.random() * finalItems.length)]; } +/** + * Check whether the current runtime is able to send custom headers + * along with the websocket opening handshake. + * + * Both the `ws` package (used on Node.js < 22) and the native `WebSocket` + * implementation (Node.js >= 22, Bun) accept an options object as the second + * constructor argument. Browsers (and Deno) follow the WHATWG spec where the + * second argument is a list of subprotocols, so custom headers cannot be set there. + */ +export function canSendRequestHeaders(): boolean { + const global = globalThis as any; + + // Deno exposes `process.versions.node` for compatibility reasons, + // but its WebSocket implementation follows the WHATWG spec + if (global.Deno) return false; + + const versions = global.process?.versions; + + return !!(versions?.node || versions?.bun); +} + /** * Validate that an endpoint is properly formatted */ diff --git a/packages/providers/src/ws/WsProvider.ts b/packages/providers/src/ws/WsProvider.ts index a34ccab3b..bad6bcd45 100644 --- a/packages/providers/src/ws/WsProvider.ts +++ b/packages/providers/src/ws/WsProvider.ts @@ -3,7 +3,7 @@ import { assert, DedotError, deferred, Deferred } from '@dedot/utils'; import { SubscriptionProvider } from '../base/index.js'; import { MaxRetryAttemptedError, NetworkDisconnectedError } from '../error.js'; import { JsonRpcRequest } from '../types.js'; -import { pickRandomItem, validateEndpoint } from '../utils.js'; +import { canSendRequestHeaders, pickRandomItem, validateEndpoint } from '../utils.js'; export interface WsConnectionState { /** @@ -25,6 +25,18 @@ export interface WsConnectionState { */ export type WsEndpointSelector = (info: WsConnectionState) => string | Promise; +/** + * A map of custom HTTP headers to send along with the websocket opening handshake + */ +export type WsRequestHeaders = Record; + +/** + * Function that returns the request headers to use for a connection attempt + * @param info Connection attempt information + * @returns The headers to send along with the websocket opening handshake + */ +export type WsHeadersSelector = (info: WsConnectionState) => WsRequestHeaders | Promise; + export interface WsProviderOptions { /** * The websocket endpoint to connect to. Can be: @@ -62,8 +74,30 @@ export interface WsProviderOptions { * @default 30000 */ timeout?: number; + /** + * Custom HTTP headers to send along with the websocket opening handshake, + * e.g: an auth token for a private RPC endpoint or proxy. + * + * Can be either a static map of headers or a function returning the headers, + * the function is called on every connection attempt (including reconnects), + * which is helpful to refresh short-lived tokens or to use different + * credentials per endpoint. + * + * Note: Custom headers are only supported in non-browser environments (Node.js, Bun), + * browsers do not allow setting headers for the websocket opening handshake. + * A warning will be logged and the headers ignored if the environment does not support this. + * + * @default undefined + */ + headers?: WsRequestHeaders | WsHeadersSelector; } +/** + * Constructor signature of websocket implementations accepting connection options, + * e.g: the `ws` package (Node.js < 22) or the native WebSocket implementation (Node.js >= 22, Bun) + */ +type WebSocketWithOptions = new (url: string, options: { headers: WsRequestHeaders }) => WebSocket; + const DEFAULT_OPTIONS: Partial = { retryDelayMs: 2500, timeout: 30_000, @@ -102,6 +136,21 @@ const NO_RESUBSCRIBE_PREFIXES = ['author_', 'chainHead_', 'transactionWatch_']; * return info.attempt >= 3 ? 'wss://backup.rpc' : 'wss://primary.rpc'; * }); * + * // With custom request headers (Node.js/Bun only), e.g: to authenticate with a private RPC + * const provider = new WsProvider({ + * endpoint: 'wss://private.rpc', + * headers: { + * Authorization: `Bearer ${process.env.API_TOKEN}`, + * 'X-Client-ID': 'example-client', + * }, + * }); + * + * // Headers can also be resolved on each connection attempt (e.g: to refresh short-lived tokens) + * const provider = new WsProvider({ + * endpoint: 'wss://private.rpc', + * headers: async () => ({ Authorization: `Bearer ${await fetchToken()}` }), + * }); + * * await provider.connect(); * * // Fetch the genesis hash @@ -137,6 +186,9 @@ export class WsProvider extends SubscriptionProvider { // Recovering promise for request queueing during reconnection #recovering?: Deferred; + // Only warn once per provider instance if custom headers are not supported by the environment + #headersUnsupportedWarned: boolean = false; + constructor(options: WsProviderOptions | string | string[] | WsEndpointSelector) { super(); @@ -221,13 +273,52 @@ export class WsProvider extends SubscriptionProvider { return endpoint; } + /** + * Get the custom request headers for the current connection attempt, + * either directly or by calling the headers selector function + * + * @returns The headers to use, or `undefined` if no headers should be sent + */ + async #getHeaders(): Promise { + const { headers } = this.#options; + if (!headers) return undefined; + + if (!canSendRequestHeaders()) { + if (!this.#headersUnsupportedWarned) { + this.#headersUnsupportedWarned = true; + console.warn( + 'Custom websocket request headers are not supported in this environment (e.g: browsers), headers will be ignored', + ); + } + + return undefined; + } + + const info: WsConnectionState = { + attempt: this.#attempt, + currentEndpoint: this.#currentEndpoint, + }; + + const resolved = typeof headers === 'function' ? await headers(info) : headers; + if (!resolved || Object.keys(resolved).length === 0) return undefined; + + return resolved; + } + async #doConnect() { assert(!this.#ws, 'Websocket connection already exists'); try { this.#currentEndpoint = await this.#getEndpoint(); - this.#ws = new WebSocket(this.#currentEndpoint); + const headers = await this.#getHeaders(); + + // Both the `ws` package and the native WebSocket implementation (Node.js >= 22, Bun) + // accept an options object as the second constructor argument, + // the type definitions from `@polkadot/x-ws` only expose the WHATWG (browser) signature + this.#ws = headers + ? new (WebSocket as unknown as WebSocketWithOptions)(this.#currentEndpoint, { headers }) + : new WebSocket(this.#currentEndpoint); this.#ws.onopen = this.#onSocketOpen; this.#ws.onclose = this.#onSocketClose; this.#ws.onmessage = this.#onSocketMessage; diff --git a/packages/providers/src/ws/__tests__/WsProvider.spec.ts b/packages/providers/src/ws/__tests__/WsProvider.spec.ts index d4bcaced4..783d3e7bf 100644 --- a/packages/providers/src/ws/__tests__/WsProvider.spec.ts +++ b/packages/providers/src/ws/__tests__/WsProvider.spec.ts @@ -11,15 +11,32 @@ process.on('unhandledRejection', (reason) => { const FAKE_WS_URL = 'ws://127.0.0.1:9944'; +// Records the arguments each websocket is constructed with, so we can verify +// what is being passed down to the underlying websocket implementation +const { capturedWsArgs } = vi.hoisted(() => ({ capturedWsArgs: [] as any[][] })); + vi.mock('@polkadot/x-ws', async (importOriginal) => { - const { WebSocket } = await import('mock-socket'); + const { WebSocket: MockWebSocket } = await import('mock-socket'); const mod = await importOriginal(); + + class CapturingWebSocket extends MockWebSocket { + constructor(url: string, ...rest: any[]) { + capturedWsArgs.push([url, ...rest]); + + // mock-socket only understands the (url, protocols) signature, + // so extra arguments are dropped before handing over to it + super(url); + } + } + return { ...mod, - WebSocket, + WebSocket: CapturingWebSocket, }; }); +const lastWsArgs = () => capturedWsArgs[capturedWsArgs.length - 1]; + describe('WsProvider', () => { let mockServer: Server; const FAKE_WS_URL_2 = 'ws://127.0.0.1:9945'; @@ -707,4 +724,115 @@ describe('WsProvider', () => { }); }); }); + + describe('Request Headers', () => { + beforeEach(() => { + capturedWsArgs.length = 0; + }); + + it('does not pass any options when no headers are provided', async () => { + const provider = new WsProvider(FAKE_WS_URL); + + try { + await provider.connect(); + + expect(lastWsArgs()).toEqual([FAKE_WS_URL]); + } finally { + await provider.disconnect().catch(() => {}); + } + }); + + it('passes static headers to the websocket implementation', async () => { + const headers = { Authorization: 'Bearer token', 'X-Client-ID': 'example-client' }; + const provider = new WsProvider({ endpoint: FAKE_WS_URL, headers }); + + try { + await provider.connect(); + + expect(lastWsArgs()).toEqual([FAKE_WS_URL, { headers }]); + } finally { + await provider.disconnect().catch(() => {}); + } + }); + + it('resolves headers via a selector function receiving the connection state', async () => { + const selector = vi.fn().mockResolvedValue({ Authorization: 'Bearer resolved-token' }); + const provider = new WsProvider({ endpoint: FAKE_WS_URL, headers: selector }); + + try { + await provider.connect(); + + expect(selector).toHaveBeenCalledTimes(1); + expect(selector).toHaveBeenCalledWith({ attempt: 1, currentEndpoint: FAKE_WS_URL }); + expect(lastWsArgs()).toEqual([FAKE_WS_URL, { headers: { Authorization: 'Bearer resolved-token' } }]); + } finally { + await provider.disconnect().catch(() => {}); + } + }); + + it('resolves headers again on each reconnection', async () => { + let token = 0; + const selector = vi.fn().mockImplementation(() => ({ Authorization: `Bearer token-${++token}` })); + + const provider = new WsProvider({ + endpoint: FAKE_WS_URL, + headers: selector, + retryDelayMs: 100, + }); + + provider.on('error', () => { + // Intentionally empty + }); + + try { + await provider.connect(); + expect(lastWsArgs()).toEqual([FAKE_WS_URL, { headers: { Authorization: 'Bearer token-1' } }]); + + // Simulate an abnormal disconnection to trigger a reconnect + (provider as any).__unsafeWs().close(3000); + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(provider.status).toBe('connected'); + expect(selector).toHaveBeenCalledTimes(2); + expect(lastWsArgs()).toEqual([FAKE_WS_URL, { headers: { Authorization: 'Bearer token-2' } }]); + } finally { + await provider.disconnect().catch(() => {}); + } + }); + + it('ignores an empty headers map', async () => { + const provider = new WsProvider({ endpoint: FAKE_WS_URL, headers: {} }); + + try { + await provider.connect(); + + expect(lastWsArgs()).toEqual([FAKE_WS_URL]); + } finally { + await provider.disconnect().catch(() => {}); + } + }); + + it('ignores headers & warns if the environment does not support them', async () => { + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Deno follows the WHATWG spec where custom headers cannot be set + vi.stubGlobal('Deno', {}); + + const provider = new WsProvider({ + endpoint: FAKE_WS_URL, + headers: { Authorization: 'Bearer token' }, + }); + + try { + await provider.connect(); + + expect(lastWsArgs()).toEqual([FAKE_WS_URL]); + expect(consoleWarn).toHaveBeenCalledWith(expect.stringContaining('not supported in this environment')); + } finally { + vi.unstubAllGlobals(); + consoleWarn.mockRestore(); + await provider.disconnect().catch(() => {}); + } + }); + }); }); diff --git a/packages/providers/src/ws/__tests__/WsProviderHeaders.spec.ts b/packages/providers/src/ws/__tests__/WsProviderHeaders.spec.ts new file mode 100644 index 000000000..681542876 --- /dev/null +++ b/packages/providers/src/ws/__tests__/WsProviderHeaders.spec.ts @@ -0,0 +1,99 @@ +// @vitest-environment node +import { createHash } from 'node:crypto'; +import { createServer, IncomingHttpHeaders, Server } from 'node:http'; +import { Socket } from 'node:net'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { WsProvider } from '../WsProvider.js'; + +// https://datatracker.ietf.org/doc/html/rfc6455#section-1.3 +const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; + +const acceptKey = (key: string = '') => { + return createHash('sha1') + .update(key + WS_GUID) + .digest('base64'); +}; + +/** + * Unlike WsProvider.spec.ts, this suite does not mock the underlying websocket implementation, + * it verifies that custom headers are actually sent over the wire with the opening handshake, + * regardless of whether `@polkadot/x-ws` resolves to the `ws` package (Node.js < 22) + * or the native WebSocket implementation (Node.js >= 22, Bun). + * + * A minimal handshake-only server is used here so that no extra dependency is needed, + * `@polkadot/x-ws` only ships a websocket client. + * + * A `node` environment is required, the default `happy-dom` environment + * provides its own global WebSocket which does not support custom headers. + */ +describe('WsProvider request headers (real websocket handshake)', () => { + let server: Server; + let endpoint: string; + let sockets: Socket[] = []; + let receivedHeaders: IncomingHttpHeaders; + + beforeAll(async () => { + server = createServer(); + + server.on('upgrade', (request, socket) => { + receivedHeaders = request.headers; + sockets.push(socket as Socket); + + // Ignore errors from clients going away abruptly + socket.on('error', () => {}); + + socket.write( + [ + 'HTTP/1.1 101 Switching Protocols', + 'Upgrade: websocket', + 'Connection: Upgrade', + `Sec-WebSocket-Accept: ${acceptKey(request.headers['sec-websocket-key'])}`, + '\r\n', + ].join('\r\n'), + ); + }); + + server.listen(0); + await new Promise((resolve) => server.once('listening', resolve)); + + const { port } = server.address() as { port: number }; + endpoint = `ws://127.0.0.1:${port}`; + }); + + afterAll(async () => { + sockets.forEach((socket) => socket.destroy()); + await new Promise((resolve) => server.close(() => resolve())); + }); + + it('sends custom headers with the opening handshake', async () => { + const provider = new WsProvider({ + endpoint, + headers: { + Authorization: 'Bearer secret-token', + 'X-Client-ID': 'example-client', + }, + }); + + try { + await provider.connect(); + + expect(receivedHeaders['authorization']).toBe('Bearer secret-token'); + expect(receivedHeaders['x-client-id']).toBe('example-client'); + } finally { + await provider.disconnect().catch(() => {}); + } + }); + + it('sends no custom headers & no bogus subprotocol when headers are not provided', async () => { + const provider = new WsProvider(endpoint); + + try { + await provider.connect(); + + expect(receivedHeaders['authorization']).toBeUndefined(); + expect(receivedHeaders['sec-websocket-protocol']).toBeUndefined(); + } finally { + await provider.disconnect().catch(() => {}); + } + }); +});